kepler.gl
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
1,150 lines (986 loc) • 31.4 kB
JavaScript
// Copyright (c) 2020 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import {ascending, extent, histogram as d3Histogram, ticks} from 'd3-array';
import keyMirror from 'keymirror';
import get from 'lodash.get';
import booleanWithin from '@turf/boolean-within';
import {point as turfPoint} from '@turf/helpers';
import {Decimal} from 'decimal.js';
import {ALL_FIELD_TYPES, FILTER_TYPES} from 'constants/default-settings';
import {maybeToDate, notNullorUndefined, unique, timeToUnixMilli} from './data-utils';
import * as ScaleUtils from './data-scale-utils';
import {LAYER_TYPES} from '../constants';
import {generateHashId, set, toArray} from './utils';
import {getGpuFilterProps, getDatasetFieldIndexForFilter} from './gpu-filter-utils';
export const TimestampStepMap = [
{max: 1, step: 0.05},
{max: 10, step: 0.1},
{max: 100, step: 1},
{max: 500, step: 5},
{max: 1000, step: 10},
{max: 5000, step: 50},
{max: Number.POSITIVE_INFINITY, step: 1000}
];
export const histogramBins = 30;
export const enlargedHistogramBins = 100;
const durationSecond = 1000;
const durationMinute = durationSecond * 60;
const durationHour = durationMinute * 60;
const durationDay = durationHour * 24;
const durationWeek = durationDay * 7;
const durationYear = durationDay * 365;
export const PLOT_TYPES = keyMirror({
histogram: null,
lineChart: null
});
export const FILTER_UPDATER_PROPS = keyMirror({
dataId: null,
name: null,
layerId: null
});
export const LIMITED_FILTER_EFFECT_PROPS = keyMirror({
[FILTER_UPDATER_PROPS.name]: null
});
/**
* Max number of filter value buffers that deck.gl provides
*/
const SupportedPlotType = {
[FILTER_TYPES.timeRange]: {
default: 'histogram',
[ALL_FIELD_TYPES.integer]: 'lineChart',
[ALL_FIELD_TYPES.real]: 'lineChart'
},
[FILTER_TYPES.range]: {
default: 'histogram',
[ALL_FIELD_TYPES.integer]: 'lineChart',
[ALL_FIELD_TYPES.real]: 'lineChart'
}
};
export const FILTER_COMPONENTS = {
[FILTER_TYPES.select]: 'SingleSelectFilter',
[FILTER_TYPES.multiSelect]: 'MultiSelectFilter',
[FILTER_TYPES.timeRange]: 'TimeRangeFilter',
[FILTER_TYPES.range]: 'RangeFilter',
[FILTER_TYPES.polygon]: 'PolygonFilter'
};
export const DEFAULT_FILTER_STRUCTURE = {
dataId: [], // [string]
freeze: false,
id: null,
// time range filter specific
fixedDomain: false,
enlarged: false,
isAnimating: false,
speed: 1,
// field specific
name: [], // string
type: null,
fieldIdx: [], // [integer]
domain: null,
value: null,
// plot
plotType: PLOT_TYPES.histogram,
yAxis: null,
interval: null,
// mode
gpu: false
};
export const FILTER_ID_LENGTH = 4;
export const LAYER_FILTERS = [FILTER_TYPES.polygon];
/**
* Generates a filter with a dataset id as dataId
* @param {[string]} dataId
* @return {object} filter
*/
export function getDefaultFilter(dataId) {
return {
...DEFAULT_FILTER_STRUCTURE,
// store it as dataId and it could be one or many
dataId: toArray(dataId),
id: generateHashId(FILTER_ID_LENGTH)
};
}
/**
* Check if a filter is valid based on the given dataId
* @param {object} filter to validate
* @param {string} dataset id to validate filter against
* @return {boolean} true if a filter is valid, false otherwise
*/
export function shouldApplyFilter(filter, datasetId) {
const dataIds = toArray(filter.dataId);
return dataIds.includes(datasetId) && filter.value !== null;
}
/**
* Validates and modifies polygon filter structure
* @param dataset
* @param filter
* @param layers
* @return {object}
*/
export function validatePolygonFilter(dataset, filter, layers) {
const failed = {dataset, filter: null};
const {value, layerId, type, dataId} = filter;
if (!layerId || !isValidFilterValue(type, value)) {
return failed;
}
const isValidDataset = dataId.includes(dataset.id);
if (!isValidDataset) {
return failed;
}
const layer = layers.find(l => layerId.includes(l.id));
if (!layer) {
return failed;
}
return {
filter: {
...filter,
freeze: true,
fieldIdx: []
},
dataset
};
}
/**
* Custom filter validators
* @type {Function}
*/
const filterValidators = {
[FILTER_TYPES.polygon]: validatePolygonFilter
};
/**
* Default validate filter function
* @param dataset
* @param filter
* @return {*}
*/
export function validateFilter(dataset, filter) {
// match filter.dataId
const failed = {dataset, filter: null};
const filterDataId = toArray(filter.dataId);
const filterDatasetIndex = filterDataId.indexOf(dataset.id);
if (filterDatasetIndex < 0) {
// the current filter is not mapped against the current dataset
return failed;
}
const initializeFilter = {
...getDefaultFilter(filter.dataId),
...filter,
dataId: filterDataId,
name: toArray(filter.name)
};
const fieldName = initializeFilter.name[filterDatasetIndex];
const {filter: updatedFilter, dataset: updatedDataset} = applyFilterFieldName(
initializeFilter,
dataset,
fieldName,
filterDatasetIndex,
{mergeDomain: true}
);
if (!updatedFilter) {
return failed;
}
updatedFilter.value = adjustValueToFilterDomain(filter.value, updatedFilter);
if (updatedFilter.value === null) {
// cannot adjust saved value to filter
return failed;
}
return {
filter: validateFilterYAxis(updatedFilter, updatedDataset),
dataset: updatedDataset
};
}
/**
* Validate saved filter config with new data,
* calculate domain and fieldIdx based new fields and data
*
* @param {Object} dataset
* @param {Object} filter - filter to be validate
* @return {Object | null} - validated filter
*/
export function validateFilterWithData(dataset, filter, layers) {
return filterValidators.hasOwnProperty(filter.type)
? filterValidators[filter.type](dataset, filter, layers)
: validateFilter(dataset, filter);
}
/**
* Validate YAxis
* @param filter
* @param dataset
* @return {*}
*/
function validateFilterYAxis(filter, dataset) {
// TODO: validate yAxis against other datasets
const {fields, allData} = dataset;
const {yAxis} = filter;
// TODO: validate yAxis against other datasets
if (yAxis) {
const matchedAxis = fields.find(({name, type}) => name === yAxis.name && type === yAxis.type);
filter = matchedAxis
? {
...filter,
yAxis: matchedAxis,
...getFilterPlot({...filter, yAxis: matchedAxis}, allData)
}
: filter;
}
return filter;
}
/**
* Get default filter prop based on field type
*
* @param {Array<Array>} allData
* @param {Object} field
* @returns {Object} default filter
*/
export function getFilterProps(allData, field) {
const filterProps = {
...getFieldDomain(allData, field),
fieldType: field.type
};
switch (field.type) {
case ALL_FIELD_TYPES.real:
case ALL_FIELD_TYPES.integer:
return {
...filterProps,
value: filterProps.domain,
type: FILTER_TYPES.range,
typeOptions: [FILTER_TYPES.range],
gpu: true
};
case ALL_FIELD_TYPES.boolean:
return {
...filterProps,
type: FILTER_TYPES.select,
value: true,
gpu: false
};
case ALL_FIELD_TYPES.string:
case ALL_FIELD_TYPES.date:
return {
...filterProps,
type: FILTER_TYPES.multiSelect,
value: [],
gpu: false
};
case ALL_FIELD_TYPES.timestamp:
return {
...filterProps,
type: FILTER_TYPES.timeRange,
enlarged: true,
fixedDomain: true,
value: filterProps.domain,
gpu: true
};
default:
return {};
}
}
/**
* Calculate field domain based on field type and data
*
* @param {Array<Array>} allData
* @param {Object} field
* @returns {Object} with domain as key
*/
export function getFieldDomain(allData, field) {
const fieldIdx = field.tableFieldIndex - 1;
const isTime = field.type === ALL_FIELD_TYPES.timestamp;
const valueAccessor = maybeToDate.bind(null, isTime, fieldIdx, field.format);
let domain;
switch (field.type) {
case ALL_FIELD_TYPES.real:
case ALL_FIELD_TYPES.integer:
// calculate domain and step
return getNumericFieldDomain(allData, valueAccessor);
case ALL_FIELD_TYPES.boolean:
return {domain: [true, false]};
case ALL_FIELD_TYPES.string:
case ALL_FIELD_TYPES.date:
domain = ScaleUtils.getOrdinalDomain(allData, valueAccessor);
return {domain};
case ALL_FIELD_TYPES.timestamp:
return getTimestampFieldDomain(allData, valueAccessor);
default:
return {domain: ScaleUtils.getOrdinalDomain(allData, valueAccessor)};
}
}
export const getPolygonFilterFunctor = (layer, filter) => {
const getPosition = layer.getPositionAccessor();
switch (layer.type) {
case LAYER_TYPES.point:
case LAYER_TYPES.icon:
return data => {
const pos = getPosition({data});
return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
};
case LAYER_TYPES.arc:
case LAYER_TYPES.line:
return data => {
const pos = getPosition({data});
return (
pos.every(Number.isFinite) &&
[
[pos[0], pos[1]],
[pos[3], pos[4]]
].every(point => isInPolygon(point, filter.value))
);
};
default:
return () => true;
}
};
/**
* @param field dataset Field
* @param dataId Dataset id
* @param filter Filter object
* @param layers list of layers to filter upon
* @return {*}
*/
export function getFilterFunction(field, dataId, filter, layers) {
// field could be null
const valueAccessor = data => (field ? data[field.tableFieldIndex - 1] : null);
switch (filter.type) {
case FILTER_TYPES.range:
return data => isInRange(valueAccessor(data), filter.value);
case FILTER_TYPES.multiSelect:
return data => filter.value.includes(valueAccessor(data));
case FILTER_TYPES.select:
return data => valueAccessor(data) === filter.value;
case FILTER_TYPES.timeRange:
const mappedValue = get(field, ['filterProps', 'mappedValue']);
const accessor = Array.isArray(mappedValue)
? (data, index) => mappedValue[index]
: data => timeToUnixMilli(valueAccessor(data), field.format);
return (data, index) => isInRange(accessor(data, index), filter.value);
case FILTER_TYPES.polygon:
if (!layers || !layers.length) {
return () => true;
}
const layerFilterFunctions = filter.layerId
.map(id => layers.find(l => l.id === id))
.filter(l => l && l.config.dataId === dataId)
.map(layer => getPolygonFilterFunctor(layer, filter));
return data => layerFilterFunctions.every(filterFunc => filterFunc(data));
default:
return () => true;
}
}
export function updateFilterDataId(dataId) {
return getDefaultFilter(dataId);
}
/**
* Filter data based on an array of filters
*
* @param {Object} dataset
* @param {Array<Object>} filters
* @param {Object} opt
* @param {Object} opt.cpuOnly only allow cpu filtering
* @param {Object} opt.ignoreDomain ignore filter for domain calculation
* @returns {Object} dataset
* @returns {Array<Number>} dataset.filteredIndex
* @returns {Array<Number>} dataset.filteredIndexForDomain
*/
export function filterDataset(dataset, filters, layers, opt = {}) {
const {allData, id: dataId, filterRecord: oldFilterRecord, fields} = dataset;
// if there is no filters
const filterRecord = getFilterRecord(dataId, filters, opt);
const newDataset = set(['filterRecord'], filterRecord, dataset);
if (!filters.length) {
return {
...newDataset,
gpuFilter: getGpuFilterProps(filters, dataId, fields),
filteredIndex: dataset.allIndexes,
filteredIndexForDomain: dataset.allIndexes
};
}
const changedFilters = diffFilters(filterRecord, oldFilterRecord);
// generate 2 sets of filter result
// filteredIndex used to calculate layer data
// filteredIndexForDomain used to calculate layer Domain
const shouldCalDomain = Boolean(changedFilters.dynamicDomain);
const shouldCalIndex = Boolean(changedFilters.cpu);
let filterResult = {};
if (shouldCalDomain || shouldCalIndex) {
const dynamicDomainFilters = shouldCalDomain ? filterRecord.dynamicDomain : null;
const cpuFilters = shouldCalIndex ? filterRecord.cpu : null;
const filterFuncs = filters.reduce((acc, filter) => {
const fieldIndex = getDatasetFieldIndexForFilter(dataset.id, filter);
const field = fieldIndex !== -1 ? fields[fieldIndex] : null;
return {
...acc,
[filter.id]: getFilterFunction(field, dataset.id, filter, layers)
};
}, {});
filterResult = filterDataByFilterTypes(
{dynamicDomainFilters, cpuFilters, filterFuncs},
allData
);
}
return {
...newDataset,
...filterResult,
gpuFilter: getGpuFilterProps(filters, dataId, fields)
};
}
/**
*
* @param {Object} filters
* @param {Array|null} filters.dynamicDomainFilters
* @param {Array|null} filters.cpuFilters
* @param {Object} filters.filterFuncs
* @returns {{filteredIndex: Array, filteredIndexForDomain: Array}} filteredIndex and filteredIndexForDomain
*/
function filterDataByFilterTypes({dynamicDomainFilters, cpuFilters, filterFuncs}, allData) {
const result = {
...(dynamicDomainFilters ? {filteredIndexForDomain: []} : {}),
...(cpuFilters ? {filteredIndex: []} : {})
};
for (let i = 0; i < allData.length; i++) {
const d = allData[i];
const matchForDomain =
dynamicDomainFilters && dynamicDomainFilters.every(filter => filterFuncs[filter.id](d, i));
if (matchForDomain) {
result.filteredIndexForDomain.push(i);
}
const matchForRender = cpuFilters && cpuFilters.every(filter => filterFuncs[filter.id](d, i));
if (matchForRender) {
result.filteredIndex.push(i);
}
}
return result;
}
/**
* Get a record of filters based on domain type and gpu / cpu
* @param {string} dataId
* @param {Array<Object>} filters
* @param {Object} opt.cpuOnly only allow cpu filtering
* @param {Object} opt.ignoreDomain ignore filter for domain calculation
* @returns {{dynamicDomain: Array, fixedDomain: Array, cpu: Array, gpu: Array}} filterRecord
*/
export function getFilterRecord(dataId, filters, opt = {}) {
const filterRecord = {
dynamicDomain: [],
fixedDomain: [],
cpu: [],
gpu: []
};
filters.forEach(f => {
if (isValidFilterValue(f.type, f.value) && toArray(f.dataId).includes(dataId)) {
(f.fixedDomain || opt.ignoreDomain
? filterRecord.fixedDomain
: filterRecord.dynamicDomain
).push(f);
(f.gpu && !opt.cpuOnly ? filterRecord.gpu : filterRecord.cpu).push(f);
}
});
return filterRecord;
}
/**
* Compare filter records to get what has changed
* @param {Object} filterRecord
* @param {Object} oldFilterRecord
* @returns {{dynamicDomain: Object, fixedDomain: Object, cpu: Object, gpu: Object}} changed filters based on type
*/
export function diffFilters(filterRecord, oldFilterRecord = {}) {
let filterChanged = {};
Object.entries(filterRecord).forEach(([record, items]) => {
items.forEach(filter => {
const oldFilter = (oldFilterRecord[record] || []).find(f => f.id === filter.id);
if (!oldFilter) {
// added
filterChanged = set([record, filter.id], 'added', filterChanged);
} else {
// check what has changed
['name', 'value', 'dataId'].forEach(prop => {
if (filter[prop] !== oldFilter[prop]) {
filterChanged = set([record, filter.id], `${prop}_changed`, filterChanged);
}
});
}
});
(oldFilterRecord[record] || []).forEach(oldFilter => {
// deleted
if (!items.find(f => f.id === oldFilter.id)) {
filterChanged = set([record, oldFilter.id], 'deleted', filterChanged);
}
});
if (!filterChanged[record]) {
filterChanged[record] = null;
}
});
return filterChanged;
}
/**
* Call by parsing filters from URL
* Check if value of filter within filter domain, if not adjust it to match
* filter domain
*
* @param {Array<string> | string | Number | Array<Number>} value
* @param {Array} filter.domain
* @param {String} filter.type
* @returns {*} - adjusted value to match filter or null to remove filter
*/
/* eslint-disable complexity */
export function adjustValueToFilterDomain(value, {domain, type}) {
if (!domain || !type) {
return false;
}
switch (type) {
case FILTER_TYPES.range:
case FILTER_TYPES.timeRange:
if (!Array.isArray(value) || value.length !== 2) {
return domain.map(d => d);
}
return value.map((d, i) => (notNullorUndefined(d) && isInRange(d, domain) ? d : domain[i]));
case FILTER_TYPES.multiSelect:
if (!Array.isArray(value)) {
return [];
}
const filteredValue = value.filter(d => domain.includes(d));
return filteredValue.length ? filteredValue : [];
case FILTER_TYPES.select:
return domain.includes(value) ? value : true;
default:
return null;
}
}
/* eslint-enable complexity */
/**
* Calculate numeric domain and suitable step
*
* @param {Object[]} data
* @param {function} valueAccessor
* @returns {object} domain and step
*/
export function getNumericFieldDomain(data, valueAccessor) {
let domain = [0, 1];
let step = 0.1;
const mappedValue = Array.isArray(data) ? data.map(valueAccessor) : [];
if (Array.isArray(data) && data.length > 1) {
domain = ScaleUtils.getLinearDomain(mappedValue);
const diff = domain[1] - domain[0];
// in case equal domain, [96, 96], which will break quantize scale
if (!diff) {
domain[1] = domain[0] + 1;
}
step = getNumericStepSize(diff) || step;
domain[0] = formatNumberByStep(domain[0], step, 'floor');
domain[1] = formatNumberByStep(domain[1], step, 'ceil');
}
const {histogram, enlargedHistogram} = getHistogram(domain, mappedValue);
return {domain, step, histogram, enlargedHistogram};
}
export function getNumericStepSize(diff) {
diff = Math.abs(diff);
if (diff > 100) {
return 1;
} else if (diff > 3) {
return 0.01;
} else if (diff > 1) {
return 0.001;
} else if (diff <= 1) {
// Try to get at least 1000 steps - and keep the step size below that of
// the (diff > 1) case.
const x = diff / 1000;
// Find the exponent and truncate to 10 to the power of that exponent
const exponentialForm = x.toExponential();
const exponent = parseFloat(exponentialForm.split('e')[1]);
// Getting ready for node 12
// this is why we need decimal.js
// Math.pow(10, -5) = 0.000009999999999999999
// the above result shows in browser and node 10
// node 12 behaves correctly
return new Decimal(10).pow(exponent).toNumber();
}
}
/**
* Calculate timestamp domain and suitable step
*
* @param {Array<Array>} data
* @param {Function} valueAccessor
* @returns {{
* domain: Array<Number>,
* step: Number,
* mappedValue: Array<Number>,
* histogram: Array<Object>,
* enlargedHistogram: Array<Object>
* }} timestamp field domain
*/
export function getTimestampFieldDomain(data, valueAccessor) {
// to avoid converting string format time to epoch
// every time we compare we store a value mapped to int in filter domain
const mappedValue = Array.isArray(data) ? data.map(valueAccessor) : [];
const domain = ScaleUtils.getLinearDomain(mappedValue);
let step = 0.01;
const diff = domain[1] - domain[0];
const entry = TimestampStepMap.find(f => f.max >= diff);
if (entry) {
step = entry.step;
}
const {histogram, enlargedHistogram} = getHistogram(domain, mappedValue);
return {domain, step, mappedValue, histogram, enlargedHistogram};
}
/**
*
* @param {Array<Number>} domain
* @param {Array<Number>} mappedValue
* @param {Number} bins
* @returns {Array<{count: Number, x0: Number, x1: number}>} histogram
*/
export function histogramConstruct(domain, mappedValue, bins) {
return d3Histogram()
.thresholds(ticks(domain[0], domain[1], bins))
.domain(domain)(mappedValue)
.map(bin => ({
count: bin.length,
x0: bin.x0,
x1: bin.x1
}));
}
/**
* Calculate histogram from domain and array of values
*
* @param {Array<Number>} domain
* @param {Array<Object>} mappedValue
* @returns {{histogram: Array<Object>, enlargedHistogram: Array<Object>}} 2 sets of histogram
*/
export function getHistogram(domain, mappedValue) {
const histogram = histogramConstruct(domain, mappedValue, histogramBins);
const enlargedHistogram = histogramConstruct(domain, mappedValue, enlargedHistogramBins);
return {histogram, enlargedHistogram};
}
/**
* round number based on step
*
* @param {Number} val
* @param {Number} step
* @param {string} bound
* @returns {Number} rounded number
*/
export function formatNumberByStep(val, step, bound) {
if (bound === 'floor') {
return Math.floor(val * (1 / step)) / (1 / step);
}
return Math.ceil(val * (1 / step)) / (1 / step);
}
export function isInRange(val, domain) {
if (!Array.isArray(domain)) {
return false;
}
return val >= domain[0] && val <= domain[1];
}
/**
* Determines whether a point is within the provided polygon
*
* @param point as input search [lat, lng]
* @param polygon Points must be within these (Multi)Polygon(s)
* @return {boolean}
*/
export function isInPolygon(point, polygon) {
return booleanWithin(turfPoint(point), polygon);
}
export function getTimeWidgetTitleFormatter(domain) {
if (!Array.isArray(domain)) {
return null;
}
const diff = domain[1] - domain[0];
return diff > durationYear
? 'MM/DD/YY'
: diff > durationDay
? 'MM/DD/YY hh:mma'
: 'MM/DD/YY hh:mm:ssa';
}
export function getTimeWidgetHintFormatter(domain) {
if (!Array.isArray(domain)) {
return null;
}
const diff = domain[1] - domain[0];
return diff > durationYear
? 'MM/DD/YY'
: diff > durationWeek
? 'MM/DD'
: diff > durationDay
? 'MM/DD hha'
: diff > durationHour
? 'hh:mma'
: 'hh:mm:ssa';
}
/**
* Sanity check on filters to prepare for save
* @param {String} type - filter type
* @param {*} value - filter value
* @returns {boolean} whether filter is value
*/
/* eslint-disable complexity */
export function isValidFilterValue(type, value) {
if (!type) {
return false;
}
switch (type) {
case FILTER_TYPES.select:
return value === true || value === false;
case FILTER_TYPES.range:
case FILTER_TYPES.timeRange:
return Array.isArray(value) && value.every(v => v !== null && !isNaN(v));
case FILTER_TYPES.multiSelect:
return Array.isArray(value) && Boolean(value.length);
case FILTER_TYPES.input:
return Boolean(value.length);
case FILTER_TYPES.polygon:
const coordinates = get(value, ['geometry', 'coordinates']);
return Boolean(value && value.id && coordinates);
default:
return true;
}
}
export function getFilterPlot(filter, allData) {
if (filter.plotType === PLOT_TYPES.histogram || !filter.yAxis) {
// histogram should be calculated when create filter
return {};
}
const {mappedValue} = filter;
const {yAxis} = filter;
// return lineChart
const series = allData
.map((d, i) => ({
x: mappedValue[i],
y: d[yAxis.tableFieldIndex - 1]
}))
.filter(({x, y}) => Number.isFinite(x) && Number.isFinite(y))
.sort((a, b) => ascending(a.x, b.x));
const yDomain = extent(series, d => d.y);
const xDomain = [series[0].x, series[series.length - 1].x];
return {lineChart: {series, yDomain, xDomain}, yAxis};
}
export function getDefaultFilterPlotType(filter) {
const filterPlotTypes = SupportedPlotType[filter.type];
if (!filterPlotTypes) {
return null;
}
if (!filter.yAxis) {
return filterPlotTypes.default;
}
return filterPlotTypes[filter.yAxis.type] || null;
}
/**
*
* @param datasetIds list of dataset ids to be filtered
* @param datasets all datasets
* @param filters all filters to be applied to datasets
* @return {{[datasetId: string]: Object}} datasets - new updated datasets
*/
export function applyFiltersToDatasets(datasetIds, datasets, filters, layers) {
const dataIds = toArray(datasetIds);
return dataIds.reduce((acc, dataId) => {
const layersToFilter = (layers || []).filter(l => l.config.dataId === dataId);
const appliedFilters = filters.filter(d => shouldApplyFilter(d, dataId));
return {
...acc,
[dataId]: filterDataset(datasets[dataId], appliedFilters, layersToFilter)
};
}, datasets);
}
/**
* Applies a new field name value to fielter and update both filter and dataset
* @param {Object} filter - to be applied the new field name on
* @param {Object} dataset - dataset the field belongs to
* @param {string} fieldName - field.name
* @param {Number} filterDatasetIndex - field.name
* @param {Number} filters - current
* @param {Object} option
* @return {Object} {filter, datasets}
*/
export function applyFilterFieldName(
filter,
dataset,
fieldName,
filterDatasetIndex = 0,
{mergeDomain = false} = {}
) {
// using filterDatasetIndex we can filter only the specified dataset
const {fields, allData} = dataset;
const fieldIndex = fields.findIndex(f => f.name === fieldName);
// if no field with same name is found, move to the next datasets
if (fieldIndex === -1) {
// throw new Error(`fieldIndex not found. Dataset must contain a property with name: ${fieldName}`);
return {filter: null, dataset};
}
// TODO: validate field type
const field = fields[fieldIndex];
const filterProps = field.hasOwnProperty('filterProps')
? field.filterProps
: getFilterProps(allData, field);
const newFilter = {
...(mergeDomain ? mergeFilterDomainStep(filter, filterProps) : {...filter, ...filterProps}),
name: Object.assign([].concat(filter.name), {[filterDatasetIndex]: field.name}),
fieldIdx: Object.assign([].concat(filter.fieldIdx), {
[filterDatasetIndex]: field.tableFieldIndex - 1
}),
// TODO, since we allow to add multiple fields to a filter we can no longer freeze the filter
freeze: true
};
const fieldWithFilterProps = {
...field,
filterProps
};
const newFields = Object.assign([].concat(fields), {[fieldIndex]: fieldWithFilterProps});
return {
filter: newFilter,
dataset: {
...dataset,
fields: newFields
}
};
}
/**
* Merge one filter with other filter prop domain
* @param filter
* @param filterProps
* @param fieldIndex
* @param datasetIndex
* @return {*}
*/
/* eslint-disable complexity */
export function mergeFilterDomainStep(filter, filterProps) {
if (!filter) {
return null;
}
if (!filterProps) {
return filter;
}
if ((filter.fieldType && filter.fieldType !== filterProps.fieldType) || !filterProps.domain) {
return filter;
}
const combinedDomain = !filter.domain
? filterProps.domain
: [...(filter.domain || []), ...(filterProps.domain || [])].sort((a, b) => a - b);
const newFilter = {
...filter,
...filterProps,
domain: [combinedDomain[0], combinedDomain[combinedDomain.length - 1]]
};
switch (filterProps.fieldType) {
case ALL_FIELD_TYPES.string:
case ALL_FIELD_TYPES.date:
return {
...newFilter,
domain: unique(combinedDomain).sort()
};
case ALL_FIELD_TYPES.timestamp:
const step = filter.step < filterProps.step ? filter.step : filterProps.step;
return {
...newFilter,
step
};
case ALL_FIELD_TYPES.real:
case ALL_FIELD_TYPES.integer:
default:
return newFilter;
}
}
/* eslint-enable complexity */
export const featureToFilterValue = (feature, filterId, properties = {}) => ({
...feature,
id: feature.id,
properties: {
...feature.properties,
...properties,
filterId
}
});
export const getFilterIdInFeature = f => get(f, ['properties', 'filterId']);
/**
* Generates polygon filter
* @param layers array of layers
* @param feature polygon to use
* @return {object} filter
*/
export function generatePolygonFilter(layers, feature) {
const {dataId, layerId, name} = layers.reduce(
(acc, layer) => ({
...acc,
dataId: [...acc.dataId, layer.config.dataId],
layerId: [...acc.layerId, layer.id],
name: [...acc.name, layer.config.label]
}),
{
dataId: [],
layerId: [],
name: []
}
);
const filter = getDefaultFilter(dataId);
return {
...filter,
fixedDomain: true,
type: FILTER_TYPES.polygon,
name,
layerId,
value: featureToFilterValue(feature, filter.id, {isVisible: true})
};
}
/**
* Run filter entirely on CPU
* @param {Object} state - visState
* @param {string} dataId
* @return {Object} state state with updated datasets
*/
export function filterDatasetCPU(state, dataId) {
const datasetFilters = state.filters.filter(f => f.dataId.includes(dataId));
const selectedDataset = state.datasets[dataId];
if (!selectedDataset) {
return state;
}
const opt = {
cpuOnly: true,
ignoreDomain: true
};
if (!datasetFilters.length) {
// no filter
const filtered = {
...selectedDataset,
filteredIdxCPU: selectedDataset.allIndexes,
filterRecordCPU: getFilterRecord(dataId, state.filters, opt)
};
return set(['datasets', dataId], filtered, state);
}
// no gpu filter
if (!datasetFilters.find(f => f.gpu)) {
const filtered = {
...selectedDataset,
filteredIdxCPU: selectedDataset.filteredIndex,
filterRecordCPU: getFilterRecord(dataId, state.filters, opt)
};
return set(['datasets', dataId], filtered, state);
}
// make a copy for cpu filtering
const copied = {
...selectedDataset,
filterRecord: selectedDataset.filterRecordCPU,
filteredIndex: selectedDataset.filteredIdxCPU
};
const filtered = filterDataset(copied, state.filters, state.layers, opt);
const cpuFilteredDataset = {
...selectedDataset,
filteredIdxCPU: filtered.filteredIndex,
filterRecordCPU: filtered.filterRecord
};
return set(['datasets', dataId], cpuFilteredDataset, state);
}