kepler.gl.geoiq
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
1,252 lines (1,075 loc) • 32.5 kB
JavaScript
// Copyright (c) 2023 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 moment from 'moment';
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, polygon as turfPolygon} from '@turf/helpers';
import {ALL_FIELD_TYPES} from 'constants/default-settings';
import {maybeToDate, notNullorUndefined, unique} from './data-utils';
import * as ScaleUtils from './data-scale-utils';
import {generateHashId} from './utils';
import {toArray} from 'utils/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 FILTER_TYPES = keyMirror({
range: null,
select: null,
timeRange: null,
multiSelect: null,
polygon: null
});
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
});
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;
}
export function validatePolygonFilter(dataset, filter, layers) {
const {value, layerId, type, dataId} = filter;
if (!(value && value.id && layerId)) {
return null;
}
const isValidDataset = dataId.includes(dataset.id);
if (!isValidDataset) {
return null;
}
const layer = layers.find(l => layerId.includes(l.id));
if (!layer) {
return null;
}
if (!isValidFilterValue({type, value})) {
return null;
}
return {
...filter,
freeze: true,
fieldIdx: [0]
};
}
const filterValidators = {
[FILTER_TYPES.polygon]: validatePolygonFilter
};
export function validateFilter(dataset, filter) {
const {fields, allData} = dataset;
// match filter.name to field.name
const filterDataId = toArray(filter.dataId);
const filterNames = toArray(filter.name);
const filterDatasetIndex = Array.isArray(filter.dataId)
? getDatasetIndexForFilter(dataset, filter)
: 0;
const fieldIndex = fields.findIndex(
({name}) => name === filterNames[filterDatasetIndex]
);
if (fieldIndex < 0) {
return null;
}
const field = fields[fieldIndex];
if (filterDatasetIndex === -1) {
// the current filter is not mapped against the current dataset
return null;
}
// update fieldIdx with the current value
const newFieldIdx = toArray(filter.fieldIdx);
newFieldIdx[filterDatasetIndex] = fieldIndex;
// return filter type, default value, fieldType and fieldDomain from field
const filterPropsFromField = getFilterProps(allData, field);
let matchedFilter = {
...getDefaultFilter(filter.dataId),
...filter,
...filterPropsFromField,
dataId: filterDataId,
freeze: true,
fieldIdx: Object.assign([...newFieldIdx], {
[filterDatasetIndex]: fieldIndex
}),
name: Object.assign([...filterNames], {[filterDatasetIndex]: field.name})
};
const {yAxis} = matchedFilter;
// TODO: validate yAxis against other datasets
if (yAxis) {
const matcheAxis = fields.find(
({name, type}) => name === yAxis.name && type === yAxis.type
);
matchedFilter = matcheAxis
? {
...matchedFilter,
yAxis: matcheAxis,
...getFilterPlot({...matchedFilter, yAxis: matcheAxis}, allData)
}
: matchedFilter;
}
matchedFilter.value = adjustValueToFilterDomain(filter.value, matchedFilter);
if (matchedFilter.value === null) {
// cannot adjust saved value to filter
return null;
}
return matchedFilter;
}
/**
* Validate saved filter config with new data,
* calculate domain and fieldIdx based new fields and data
*
* @param {Array<Object>} dataset.fields
* @param {Array<Object>} dataset.allData
* @param {Object} filter - filter to be validate
* @param {Array<Object>} layers - existing layers
* @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);
}
export function updatePolygonFilter(filter, feature) {
const polygon = turfPolygon(feature.geometry.coordinates);
return {
...filter,
// we merge both turf and feature properties into one
value: {
...polygon,
id: feature.id,
properties: {
...feature.properties,
...polygon.properties
}
}
};
}
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})
};
}
/**
* Get default filter prop based on field type
*
* @param {Object[]} data
* @param {object} field
* @returns {object} default filter
*/
export function getFilterProps(data, field) {
const filterProp = {
...getFieldDomain(data, field),
fieldType: field.type
};
switch (field.type) {
case ALL_FIELD_TYPES.real:
case ALL_FIELD_TYPES.integer:
return {
...filterProp,
value: filterProp.domain,
type: FILTER_TYPES.range,
typeOptions: [FILTER_TYPES.range]
};
case ALL_FIELD_TYPES.boolean:
return {
...filterProp,
type: FILTER_TYPES.select,
value: true
};
case ALL_FIELD_TYPES.string:
case ALL_FIELD_TYPES.date:
return {
...filterProp,
type: FILTER_TYPES.multiSelect,
value: []
};
case ALL_FIELD_TYPES.timestamp:
return {
...filterProp,
type: FILTER_TYPES.timeRange,
enlarged: true,
fixedDomain: true,
value: filterProp.domain
};
default:
return {};
}
}
/**
* Get default filter prop based on field type
*
* @param {Object[]} data
* @param {object} field
* @returns {object} default filter
*/
export function getFilterApiProps(domain, histogram, field) {
let step = 0.1;
const filterProp = {
domain,
histogram,
fieldType: field.type,
step
};
switch (field.type) {
case ALL_FIELD_TYPES.real:
case ALL_FIELD_TYPES.integer:
return {
...filterProp,
value: filterProp.domain,
type: FILTER_TYPES.range,
typeOptions: [FILTER_TYPES.range]
};
case ALL_FIELD_TYPES.boolean:
return {
...filterProp,
type: FILTER_TYPES.select,
value: true
};
case ALL_FIELD_TYPES.string:
case ALL_FIELD_TYPES.date:
return {
...filterProp,
type: FILTER_TYPES.multiSelect,
value: []
};
case ALL_FIELD_TYPES.timestamp:
return {
...filterProp,
type: FILTER_TYPES.timeRange,
enlarged: true,
fixedDomain: true,
value: filterProp.domain
};
default:
return {};
}
}
/**
* Calculate field domain based on field type and data
*
* @param {Object[]} data
* @param {object} field
* @returns {object} with domain as key
*/
export function getFieldDomain(data, 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;
// console.log('data and field in getFieldDomain', data, field);
switch (field.type) {
case ALL_FIELD_TYPES.real:
case ALL_FIELD_TYPES.integer:
// calculate domain and step
return getNumericFieldDomain(data, valueAccessor);
case ALL_FIELD_TYPES.boolean:
return {domain: [true, false]};
case ALL_FIELD_TYPES.string:
case ALL_FIELD_TYPES.date:
domain = ScaleUtils.getOrdinalDomain(data, valueAccessor);
return {domain};
case ALL_FIELD_TYPES.timestamp:
return getTimestampFieldDomain(data, valueAccessor);
default:
return {domain: ScaleUtils.getOrdinalDomain(data, valueAccessor)};
}
}
/**
* Filter data based on an array of filters
* @param {Object} dataset to perform the filter on
* @param {Object[]} filters list of filters to use against dataset
* @param {Object[]} layers list of layers to perform filter on
*/
export function filterData(dataset, filters, layers) {
const {allData: data, fields} = dataset;
if (!filters.length) {
const defaultValues = data.map((d, i) => i);
return {
data,
filteredIndex: defaultValues,
filteredIndexForDomain: defaultValues
};
}
const appliedFilters = filters.filter(d => shouldApplyfilter(d, dataset.id));
// Map filter against current dataset field
const filtersToFields = filters.reduce(
(acc, filter) => {
const fieldIndex = getDatasetFieldIndexForFilter(dataset, filter);
return {
...acc,
...(fieldIndex !== -1 ? {[filter.id]: fields[fieldIndex]} : {})
};
},
{
// [filterId]: field
}
);
const [dynamicDomainFilters, fixedDomainFilters] = appliedFilters.reduce(
(accu, f) => {
(f.fixedDomain ? accu[1] : accu[0]).push(f);
return accu;
},
[[], []]
);
// we save a reference of allData index here to access dataToFeature
// in geojson and hexagonId layer
const {filtered, filteredIndex, filteredIndexForDomain} = data.reduce(
(accu, d, i) => {
// generate 2 sets of
// filter data used to calculate layer Domain
const matchForDomain = dynamicDomainFilters.every(filter => {
return isDataMatchFilter(
d,
filter,
i,
filtersToFields[filter.id],
layers
);
});
if (matchForDomain) {
accu.filteredIndexForDomain.push(i);
// filter data for render
const matchForRender = fixedDomainFilters.every(filter =>
isDataMatchFilter(d, filter, i, filtersToFields[filter.id], layers)
);
if (matchForRender) {
accu.filtered.push(d);
accu.filteredIndex.push(i);
}
}
return accu;
},
{filtered: [], filteredIndex: [], filteredIndexForDomain: []}
);
return {data: filtered, filteredIndex, filteredIndexForDomain};
}
const filterDataMatchers = {
[FILTER_TYPES.range]: (data, filter, index, field) => {
const val = field ? data[field.tableFieldIndex - 1] : null;
return isInRange(val, filter.value);
},
[FILTER_TYPES.timeRange]: (data, filter, index, field) => {
const val = field ? data[field.tableFieldIndex - 1] : null;
const timeVal =
field && field.filterProp && Array.isArray(field.filterProp.mappedValue)
? field.filterProp.mappedValue[index]
: moment.utc(val).valueOf();
return isInRange(timeVal, filter.value);
},
[FILTER_TYPES.multiSelect]: (data, filter, index, field) => {
const val = field ? data[field.tableFieldIndex - 1] : null;
return filter.value.includes(val);
},
[FILTER_TYPES.select]: (data, filter, index, field) => {
const val = field ? data[field.tableFieldIndex - 1] : null;
return val === filter.value;
},
// layers contain only layers for the current dataset
[FILTER_TYPES.polygon]: (data, filter, index, field, layers) => {
if (!(layers || layers.length === 0)) {
return true;
}
// determine which layers to apply the filter on
const currentLayers = filter.layerId
.map(
id => layers.find(l => l.id === id)
// we may get null value because filter.layerId may contain layers from other datasets
)
.filter(l => Boolean(l));
return currentLayers.every(layer => {
const {lat, lng} = layer.config.columns;
const point = [data[lng.fieldIdx], data[lat.fieldIdx]];
return isInPolygon(point, filter.value);
});
}
};
/**
* Check if value is in range of filter
*
* @param {Object[]} data
* @param {Object} filter
* @param {number} i
* @param {field} field containing values to test data against. This is used only when
* testing timestamp filters
* @param layers to perform filters upon
* @returns {Boolean} - whether value falls in the range of the filter
*/
export function isDataMatchFilter(data, filter, i, field, layers = null) {
return !filter.type || !filterDataMatchers.hasOwnProperty(filter.type)
? true
: filterDataMatchers[filter.type](data, filter, i, field, layers);
}
/**
* Call by parsing filters from URL
* Check if value of filter within filter domain, if not adjust it to match
* filter domain
*
* @param {string[] | string | number | 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};
}
function getNumericStepSize(diff) {
if (diff > 100) {
return 1;
} else if (diff < 20 && diff > 3) {
return 0.01;
} else if (diff <= 3) {
return 0.001;
}
}
/**
* Calculate timestamp domain and suitable step
*
* @param {Object[]} data
* @param {function} valueAccessor
* @returns {object} domain and step
*/
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};
}
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 {number[]} domain
* @param {Object[]} mappedValue
* @returns {Array[]} histogram
*/
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) {
const convertedPoint = turfPoint(point);
const present = booleanWithin(convertedPoint, polygon);
return present;
}
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:
return Boolean(
value && value.id && value.geometry && value.geometry.coordinates
);
default:
return true;
}
}
/* eslint-enable complexity */
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;
}
/**
* Apply a list of filters to a given dataset
* @param dataset
* @param filters
* @return {Object} filtered dataset
*/
export function applyFilterToDataset(dataset, filters, layers) {
return {
...dataset,
...filterData(dataset, filters, layers)
};
}
/**
*
* @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, dataIdentifier) => {
const layersToFilter = (layers || []).filter(
l => l.config.dataId === dataIdentifier
);
return {
...acc,
[dataIdentifier]: applyFilterToDataset(
datasets[dataIdentifier],
filters,
layersToFilter
)
};
}, datasets);
}
/**
* Applies a new field name value to fielter and update both filter and dataset
* @param filter to be applied the new field name on
* @param datasets
* @param fieldName
* @return {object} {filter, datasets}
*/
export function applyFilterFieldName(
filter,
datasets,
fieldName,
filterDatasetIndex = 0
) {
// using filterDatasetIndex we can filter only the specified dataset
const {dataId} = filter;
const dataIdentifier = dataId[filterDatasetIndex];
const {fields, allData} = datasets[dataIdentifier];
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}`
);
}
const newFilter = {
...filter,
// TODO, since we allow to add multiple fields to a filter we can no longer freeze the filter
freeze: true
};
// TODO: validate field type
const field = fields[fieldIndex];
const filterProps = field.hasOwnProperty('filterProps')
? field.filterProps
: getFilterProps(allData, field);
// Update Filter field idx
const filterName = toArray(filter.name);
const filterIdx = toArray(filter.fieldIdx);
const filterWithProps = {
...mergeFilterProps(newFilter, filterProps),
name: Object.assign([...filterName], {[filterDatasetIndex]: field.name}),
fieldIdx: Object.assign([...filterIdx], {
[filterDatasetIndex]: field.tableFieldIndex - 1
})
};
const fieldWithFilterProps = {
...field,
filterProp: filterProps
};
const newFields = fields.map((d, i) =>
i === fieldIndex ? fieldWithFilterProps : d
);
return {
filter: filterWithProps,
datasets: {
...datasets,
[dataIdentifier]: {
...datasets[dataIdentifier],
fields: newFields
}
}
};
}
/**
* Applies a new field name value to fielter and update both filter and dataset
* @param filter to be applied the new field name on
* @param datasets
* @param fieldName
* @return {object} {filter, datasets}
*/
export function applyFilterApiFieldName(
filter,
dataset,
fieldName,
filterDatasetIndex = 0,
{mergeDomain = false} = {},
histogram,
domain
) {
// 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};
}
const newFilter = {
...filter,
// TODO, since we allow to add multiple fields to a filter we can no longer freeze the filter
freeze: true
};
// TODO: validate field type
const field = fields[fieldIndex];
const filterProps = field.hasOwnProperty('filterProps')
? field.filterProps
: getFilterApiProps(domain, histogram, field);
const filterWithProps = {
...(mergeDomain
? mergeFilterDomainStep(newFilter, filterProps)
: {...newFilter, ...filterProps}),
name: Object.assign([].concat(filter.name), {
[filterDatasetIndex]: field.name
}),
fieldIdx: Object.assign([].concat(filter.fieldIdx), {
[filterDatasetIndex]: field.tableFieldIndex - 1
})
};
const fieldWithFilterProps = {
...field,
filterProps
};
const newFields = fields.map((d, i) =>
i === fieldIndex ? fieldWithFilterProps : d
);
return {
filter: filterWithProps,
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 mergeFilterProps(filter, filterProps) {
if (!filter) {
return filterProps;
}
if (!filterProps) {
return filter;
}
if (
!filterProps ||
(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
// step: Math.min(filter.step, filterProps.step)
};
case ALL_FIELD_TYPES.real:
case ALL_FIELD_TYPES.integer:
default:
return newFilter;
}
}
/* eslint-enable complexity */
/**
* Return filter dataset index from filter.dataId
* @param dataset
* @param filter
* @return {*}
*/
export function getDatasetIndexForFilter(dataset, filter) {
const {dataId} = filter;
const dataIds = toArray(dataId);
// dataId is an array
return dataIds.findIndex(id => id === dataset.id);
}
/**
* Return dataset field index from filter.fieldIdx
* The index matches the same dataset index for filter.dataId
* @param dataset
* @param filter
* @return {*}
*/
export function getDatasetFieldIndexForFilter(dataset, filter) {
const datasetIndex = getDatasetIndexForFilter(dataset, filter);
if (datasetIndex === -1) {
return datasetIndex;
}
const fieldIndex = filter.fieldIdx[datasetIndex];
return notNullorUndefined(fieldIndex) ? fieldIndex : -1;
}
/**
* Filter data based on an array of filters
*
* @param {Object[]} data
* @param {string} dataId
* @param {Object[]} filters
* @returns {Object[]} data
* @returns {Number[]} filteredIndex
*/
export function filterTileLayerData(data, dataId, filters) {
if (!data || !dataId) {
// why would there not be any data? are we over doing this?
return {data: [], filteredIndex: []};
}
if (!filters.length) {
return {
data,
filteredIndex: data.map((_, i) => i),
filteredIndexForDomain: data.map((_, i) => i)
};
}
const appliedFilters = filters.filter(
d => d.dataId === dataId && d.fieldIdx > -1 && d.value !== null
);
const [dynamicDomainFilters, fixedDomainFilters] = appliedFilters.reduce(
(accu, f) => {
if (f.dataId === dataId && f.fieldIdx > -1 && f.value !== null) {
(f.fixedDomain ? accu[1] : accu[0]).push(f);
}
return accu;
},
[[], []]
);
// console.log(dynamicDomainFilters)
// console.log(fixedDomainFilters)
// we save a reference of allData index here to access dataToFeature
// in geojson and hexgonId layer
// console.time('filterData');
const {filtered, filteredIndex, filteredIndexForDomain} = data.reduce(
(accu, d, i) => {
// generate 2 sets of
// filter data used to calculate layer Domain
const matchForDomain = dynamicDomainFilters.every(filter =>
isDataMatchFilter(d, filter, i)
);
if (matchForDomain) {
accu.filteredIndexForDomain.push(i);
// filter data for render
const matchForRender = fixedDomainFilters.every(filter =>
isDataMatchFilter(d, filter, i)
);
if (matchForRender) {
accu.filtered.push(d);
accu.filteredIndex.push(i);
}
}
return accu;
},
{filtered: [], filteredIndex: [], filteredIndexForDomain: []}
);
// console.log('data==', data.length)
// console.log('filtered==', filtered.length)
// console.log('filteredIndex==', filteredIndex.length)
// console.log('filteredIndexForDomain==', filteredIndexForDomain.length)
//
// console.timeEnd('filterData');
return {data: filtered, filteredIndex, filteredIndexForDomain};
}