kepler.gl
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
545 lines (474 loc) • 15.9 kB
JavaScript
// Copyright (c) 2021 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 uniq from 'lodash.uniq';
import pick from 'lodash.pick';
import flattenDeep from 'lodash.flattendeep';
import {isObject, arrayInsert} from 'utils/utils';
import {applyFiltersToDatasets, validateFiltersUpdateDatasets} from 'utils/filter-utils';
import {getInitialMapLayersForSplitMap} from 'utils/split-map-utils';
import {resetFilterGpuMode, assignGpuChannels} from 'utils/gpu-filter-utils';
import {LAYER_BLENDINGS} from 'constants/default-settings';
import {CURRENT_VERSION, visStateSchema} from 'schemas';
/**
* Merge loaded filters with current state, if no fields or data are loaded
* save it for later
*
* @type {typeof import('./vis-state-merger').mergeFilters}
*/
export function mergeFilters(state, filtersToMerge) {
if (!Array.isArray(filtersToMerge) || !filtersToMerge.length) {
return state;
}
const {validated, failed, updatedDatasets} = validateFiltersUpdateDatasets(state, filtersToMerge);
// merge filter with existing
let updatedFilters = [...(state.filters || []), ...validated];
updatedFilters = resetFilterGpuMode(updatedFilters);
updatedFilters = assignGpuChannels(updatedFilters);
// filter data
const datasetsToFilter = uniq(flattenDeep(validated.map(f => f.dataId)));
const filtered = applyFiltersToDatasets(
datasetsToFilter,
updatedDatasets,
updatedFilters,
state.layers
);
return {
...state,
filters: updatedFilters,
datasets: filtered,
filterToBeMerged: [...state.filterToBeMerged, ...failed]
};
}
export function createLayerFromConfig(state, layerConfig) {
// first validate config against dataset
const {validated, failed} = validateLayersByDatasets(state.datasets, state.layerClasses, [
layerConfig
]);
if (failed.length || !validated.length) {
// failed
return null;
}
const newLayer = validated[0];
newLayer.updateLayerDomain(state.datasets);
return newLayer;
}
export function serializeLayer(newLayer) {
const savedVisState = visStateSchema[CURRENT_VERSION].save({
layers: [newLayer],
layerOrder: [0]
}).visState;
const loadedLayer = visStateSchema[CURRENT_VERSION].load(savedVisState).visState.layers[0];
return loadedLayer;
}
/**
* Merge layers from de-serialized state, if no fields or data are loaded
* save it for later
*
* @type {typeof import('./vis-state-merger').mergeLayers}
*/
export function mergeLayers(state, layersToMerge, fromConfig) {
const preserveLayerOrder = fromConfig ? layersToMerge.map(l => l.id) : state.preserveLayerOrder;
if (!Array.isArray(layersToMerge) || !layersToMerge.length) {
return state;
}
const {validated: mergedLayer, failed: unmerged} = validateLayersByDatasets(
state.datasets,
state.layerClasses,
layersToMerge
);
// put new layers in front of current layers
const {newLayerOrder, newLayers} = insertLayerAtRightOrder(
state.layers,
mergedLayer,
state.layerOrder,
preserveLayerOrder
);
return {
...state,
layers: newLayers,
layerOrder: newLayerOrder,
preserveLayerOrder,
layerToBeMerged: [...state.layerToBeMerged, ...unmerged]
};
}
export function insertLayerAtRightOrder(
currentLayers,
layersToInsert,
currentOrder,
preservedOrder = []
) {
// perservedOrder ['a', 'b', 'c'];
// layerOrder [1, 0, 3]
// layerOrderMap ['a', 'c']
let layerOrderQueue = currentOrder.map(i => currentLayers[i].id);
let newLayers = currentLayers;
for (const newLayer of layersToInsert) {
// find where to insert it
const expectedIdx = preservedOrder.indexOf(newLayer.id);
// if cant find place to insert, insert at the font
let insertAt = 0;
if (expectedIdx > 0) {
// look for layer to insert after
let i = expectedIdx + 1;
let preceedIdx = null;
while (i-- > 0 && preceedIdx === null) {
const preceedLayer = preservedOrder[expectedIdx - 1];
preceedIdx = layerOrderQueue.indexOf(preceedLayer);
}
if (preceedIdx > -1) {
insertAt = preceedIdx + 1;
}
}
layerOrderQueue = arrayInsert(layerOrderQueue, insertAt, newLayer.id);
newLayers = newLayers.concat(newLayer);
}
// reconstruct layerOrder after insert
const newLayerOrder = layerOrderQueue.map(id => newLayers.findIndex(l => l.id === id));
return {
newLayerOrder,
newLayers
};
}
/**
* Merge interactions with saved config
*
* @type {typeof import('./vis-state-merger').mergeInteractions}
*/
export function mergeInteractions(state, interactionToBeMerged) {
const merged = {};
const unmerged = {};
if (interactionToBeMerged) {
Object.keys(interactionToBeMerged).forEach(key => {
if (!state.interactionConfig[key]) {
return;
}
const currentConfig = state.interactionConfig[key].config;
const {enabled, ...configSaved} = interactionToBeMerged[key] || {};
let configToMerge = configSaved;
if (key === 'tooltip') {
const {mergedTooltip, unmergedTooltip} = mergeInteractionTooltipConfig(state, configSaved);
// merge new dataset tooltips with original dataset tooltips
configToMerge = {
fieldsToShow: {
...currentConfig.fieldsToShow,
...mergedTooltip
}
};
if (Object.keys(unmergedTooltip).length) {
unmerged.tooltip = {fieldsToShow: unmergedTooltip, enabled};
}
}
merged[key] = {
...state.interactionConfig[key],
enabled,
...(currentConfig
? {
config: pick(
{
...currentConfig,
...configToMerge
},
Object.keys(currentConfig)
)
}
: {})
};
});
}
return {
...state,
interactionConfig: {
...state.interactionConfig,
...merged
},
interactionToBeMerged: unmerged
};
}
/**
* Merge splitMaps config with current visStete.
* 1. if current map is split, but splitMap DOESNOT contain maps
* : don't merge anything
* 2. if current map is NOT split, but splitMaps contain maps
* : add to splitMaps, and add current layers to splitMaps
* @type {typeof import('./vis-state-merger').mergeInteractions}
*/
export function mergeSplitMaps(state, splitMaps = []) {
const merged = [...state.splitMaps];
const unmerged = [];
splitMaps.forEach((sm, i) => {
Object.entries(sm.layers).forEach(([id, value]) => {
// check if layer exists
const pushTo = state.layers.find(l => l.id === id) ? merged : unmerged;
// create map panel if current map is not split
pushTo[i] = pushTo[i] || {
layers: pushTo === merged ? getInitialMapLayersForSplitMap(state.layers) : []
};
pushTo[i].layers = {
...pushTo[i].layers,
[id]: value
};
});
});
return {
...state,
splitMaps: merged,
splitMapsToBeMerged: [...state.splitMapsToBeMerged, ...unmerged]
};
}
/**
* Merge interactionConfig.tooltip with saved config,
* validate fieldsToShow
*
* @param {object} state
* @param {object} tooltipConfig
* @return {object} - {mergedTooltip: {}, unmergedTooltip: {}}
*/
export function mergeInteractionTooltipConfig(state, tooltipConfig = {}) {
const unmergedTooltip = {};
const mergedTooltip = {};
if (!tooltipConfig.fieldsToShow || !Object.keys(tooltipConfig.fieldsToShow).length) {
return {mergedTooltip, unmergedTooltip};
}
for (const dataId in tooltipConfig.fieldsToShow) {
if (!state.datasets[dataId]) {
// is not yet loaded
unmergedTooltip[dataId] = tooltipConfig.fieldsToShow[dataId];
} else {
// if dataset is loaded
const allFields = state.datasets[dataId].fields.map(d => d.name);
const foundFieldsToShow = tooltipConfig.fieldsToShow[dataId].filter(field =>
allFields.includes(field.name)
);
mergedTooltip[dataId] = foundFieldsToShow;
}
}
return {mergedTooltip, unmergedTooltip};
}
/**
* Merge layerBlending with saved
*
* @type {typeof import('./vis-state-merger').mergeLayerBlending}
*/
export function mergeLayerBlending(state, layerBlending) {
if (layerBlending && LAYER_BLENDINGS[layerBlending]) {
return {
...state,
layerBlending
};
}
return state;
}
/**
* Merge animation config
* @type {typeof import('./vis-state-merger').mergeAnimationConfig}
*/
export function mergeAnimationConfig(state, animation) {
if (animation && animation.currentTime) {
return {
...state,
animationConfig: {
...state.animationConfig,
...animation,
domain: null
}
};
}
return state;
}
/**
* Validate saved layer columns with new data,
* update fieldIdx based on new fields
*
* @param {Array<Object>} fields
* @param {Object} savedCols
* @param {Object} emptyCols
* @return {null | Object} - validated columns or null
*/
export function validateSavedLayerColumns(fields, savedCols = {}, emptyCols) {
// Prepare columns for the validator
const columns = {};
for (const key of Object.keys(emptyCols)) {
columns[key] = {...emptyCols[key]};
const saved = savedCols[key];
if (saved) {
const fieldIdx = fields.findIndex(({name}) => name === saved);
if (fieldIdx > -1) {
// update found columns
columns[key].fieldIdx = fieldIdx;
columns[key].value = saved;
}
}
}
// find actual column fieldIdx, in case it has changed
const allColFound = Object.keys(columns).every(key =>
validateColumn(columns[key], columns, fields)
);
if (allColFound) {
return columns;
}
return null;
}
export function validateColumn(column, columns, allFields) {
if (column.optional || column.value) {
return true;
}
if (column.validator) {
return column.validator(column, columns, allFields);
}
return false;
}
/**
* Validate saved text label config with new data
* refer to vis-state-schema.js TextLabelSchemaV1
*
* @param {Array<Object>} fields
* @param {Object} savedTextLabel
* @return {Object} - validated textlabel
*/
export function validateSavedTextLabel(fields, [layerTextLabel], savedTextLabel) {
const savedTextLabels = Array.isArray(savedTextLabel) ? savedTextLabel : [savedTextLabel];
// validate field
return savedTextLabels.map(textLabel => {
const field = textLabel.field
? fields.find(fd =>
Object.keys(textLabel.field).every(key => textLabel.field[key] === fd[key])
)
: null;
return Object.keys(layerTextLabel).reduce(
(accu, key) => ({
...accu,
[key]: key === 'field' ? field : textLabel[key] || layerTextLabel[key]
}),
{}
);
});
}
/**
* Validate saved visual channels config with new data,
* refer to vis-state-schema.js VisualChannelSchemaV1
* @type {typeof import('./vis-state-merger').validateSavedVisualChannels}
*/
export function validateSavedVisualChannels(fields, newLayer, savedLayer) {
Object.values(newLayer.visualChannels).forEach(({field, scale, key}) => {
let foundField;
if (savedLayer.config) {
if (savedLayer.config[field]) {
foundField = fields.find(
fd => savedLayer.config && fd.name === savedLayer.config[field].name
);
}
const foundChannel = {
...(foundField ? {[field]: foundField} : {}),
...(savedLayer.config[scale] ? {[scale]: savedLayer.config[scale]} : {})
};
if (Object.keys(foundChannel).length) {
newLayer.updateLayerConfig(foundChannel);
}
newLayer.validateVisualChannel(key);
}
});
return newLayer;
}
export function validateLayersByDatasets(datasets, layerClasses, layers) {
const validated = [];
const failed = [];
layers.forEach(layer => {
let validateLayer;
if (!layer || !layer.config) {
validateLayer = null;
} else if (datasets[layer.config.dataId]) {
// datasets are already loaded
validateLayer = validateLayerWithData(datasets[layer.config.dataId], layer, layerClasses);
}
if (validateLayer) {
validated.push(validateLayer);
} else {
// datasets not yet loaded
failed.push(layer);
}
});
return {validated, failed};
}
/**
* Validate saved layer config with new data,
* update fieldIdx based on new fields
* @type {typeof import('./vis-state-merger').validateLayerWithData}
*/
export function validateLayerWithData(
{fields, id: dataId},
savedLayer,
layerClasses,
options = {}
) {
const {type} = savedLayer;
// layer doesnt have a valid type
if (!type || !layerClasses.hasOwnProperty(type) || !savedLayer.config) {
return null;
}
let newLayer = new layerClasses[type]({
id: savedLayer.id,
dataId,
label: savedLayer.config.label,
color: savedLayer.config.color,
isVisible: savedLayer.config.isVisible,
hidden: savedLayer.config.hidden,
highlightColor: savedLayer.config.highlightColor
});
// find column fieldIdx
const columnConfig = newLayer.getLayerColumns();
if (Object.keys(columnConfig).length) {
const columns = validateSavedLayerColumns(fields, savedLayer.config.columns, columnConfig);
if (columns) {
newLayer.updateLayerConfig({columns});
} else if (!options.allowEmptyColumn) {
return null;
}
}
// visual channel field is saved to be {name, type}
// find visual channel field by matching both name and type
// refer to vis-state-schema.js VisualChannelSchemaV1
newLayer = validateSavedVisualChannels(fields, newLayer, savedLayer);
const textLabel =
savedLayer.config.textLabel && newLayer.config.textLabel
? validateSavedTextLabel(fields, newLayer.config.textLabel, savedLayer.config.textLabel)
: newLayer.config.textLabel;
// copy visConfig over to emptyLayer to make sure it has all the props
const visConfig = newLayer.copyLayerConfig(
newLayer.config.visConfig,
savedLayer.config.visConfig || {},
{shallowCopy: ['colorRange', 'strokeColorRange']}
);
newLayer.updateLayerConfig({
visConfig,
textLabel
});
return newLayer;
}
export function isValidMerger(merger) {
return isObject(merger) && typeof merger.merge === 'function' && typeof merger.prop === 'string';
}
export const VIS_STATE_MERGERS = [
{merge: mergeLayers, prop: 'layers', toMergeProp: 'layerToBeMerged'},
{merge: mergeFilters, prop: 'filters', toMergeProp: 'filterToBeMerged'},
{merge: mergeInteractions, prop: 'interactionConfig', toMergeProp: 'interactionToBeMerged'},
{merge: mergeLayerBlending, prop: 'layerBlending'},
{merge: mergeSplitMaps, prop: 'splitMaps', toMergeProp: 'splitMapsToBeMerged'},
{merge: mergeAnimationConfig, prop: 'animationConfig'}
];