UNPKG

kepler.gl

Version:

kepler.gl is a webgl based application to visualize large scale location data in the browser

1,765 lines (1,556 loc) 59.7 kB
// 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 {console as Console} from 'global/window'; import {disableStackCapturing, withTask} from 'react-palm/tasks'; import cloneDeep from 'lodash.clonedeep'; import uniq from 'lodash.uniq'; import get from 'lodash.get'; import xor from 'lodash.xor'; import copy from 'copy-to-clipboard'; import {parseFieldValue} from 'utils/data-utils'; // Tasks import {LOAD_FILE_TASK, UNWRAP_TASK, PROCESS_FILE_DATA, DELAY_TASK} from 'tasks/tasks'; // Actions import { loadFilesErr, loadFilesSuccess, loadFileStepSuccess, loadNextFile, nextFileBatch } from 'actions/vis-state-actions'; // Utils import {findFieldsToShow, getDefaultInteraction} from 'utils/interaction-utils'; import { applyFilterFieldName, applyFiltersToDatasets, featureToFilterValue, FILTER_UPDATER_PROPS, filterDatasetCPU, generatePolygonFilter, getDefaultFilter, getDefaultFilterPlotType, getFilterIdInFeature, getFilterPlot, getTimeWidgetTitleFormatter, isInRange, LIMITED_FILTER_EFFECT_PROPS, updateFilterDataId } from 'utils/filter-utils'; import {assignGpuChannel, setFilterGpuMode} from 'utils/gpu-filter-utils'; import {createNewDataEntry} from 'utils/dataset-utils'; import {sortDatasetByColumn} from 'utils/table-utils/kepler-table'; import {set, toArray, arrayInsert, generateHashId} from 'utils/utils'; import {calculateLayerData, findDefaultLayer} from 'utils/layer-utils'; import { isValidMerger, VIS_STATE_MERGERS, validateLayerWithData, createLayerFromConfig, serializeLayer } from './vis-state-merger'; import { addNewLayersToSplitMap, computeSplitMapLayers, removeLayerFromSplitMaps } from 'utils/split-map-utils'; import {Layer, LayerClasses, LAYER_ID_LENGTH} from 'layers'; import {DEFAULT_TEXT_LABEL} from 'layers/layer-factory'; import {EDITOR_MODES, SORT_ORDER, FILTER_TYPES} from 'constants/default-settings'; import {pick_, merge_, swap_} from './composer-helpers'; import {processFileContent} from 'actions/vis-state-actions'; import KeplerGLSchema from 'schemas'; // type imports /** @typedef {import('./vis-state-updaters').Field} Field */ /** @typedef {import('./vis-state-updaters').Filter} Filter */ /** @typedef {import('./vis-state-updaters').KeplerTable} KeplerTable */ /** @typedef {import('./vis-state-updaters').VisState} VisState */ /** @typedef {import('./vis-state-updaters').Datasets} Datasets */ /** @typedef {import('./vis-state-updaters').AnimationConfig} AnimationConfig */ /** @typedef {import('./vis-state-updaters').Editor} Editor */ // react-palm // disable capture exception for react-palm call to withTask disableStackCapturing(); /** * Updaters for `visState` reducer. Can be used in your root reducer to directly modify kepler.gl's state. * Read more about [Using updaters](../advanced-usage/using-updaters.md) * * @public * @example * * import keplerGlReducer, {visStateUpdaters} from 'kepler.gl/reducers'; * // Root Reducer * const reducers = combineReducers({ * keplerGl: keplerGlReducer, * app: appReducer * }); * * const composedReducer = (state, action) => { * switch (action.type) { * case 'CLICK_BUTTON': * return { * ...state, * keplerGl: { * ...state.keplerGl, * foo: { * ...state.keplerGl.foo, * visState: visStateUpdaters.enlargeFilterUpdater( * state.keplerGl.foo.visState, * {idx: 0} * ) * } * } * }; * } * return reducers(state, action); * }; * * export default composedReducer; */ /* eslint-disable no-unused-vars */ // @ts-ignore const visStateUpdaters = null; /* eslint-enable no-unused-vars */ /** @type {AnimationConfig} */ export const DEFAULT_ANIMATION_CONFIG = { domain: null, currentTime: null, speed: 1, isAnimating: false, timeFormat: null, timezone: null, defaultTimeFormat: null }; /** @type {Editor} */ export const DEFAULT_EDITOR = { mode: EDITOR_MODES.DRAW_POLYGON, features: [], selectedFeature: null, visible: true }; /** * Default initial `visState` * @memberof visStateUpdaters * @constant * @type {VisState} * @public */ export const INITIAL_VIS_STATE = { // map info mapInfo: { title: '', description: '' }, // layers layers: [], layerData: [], layerToBeMerged: [], layerOrder: [], // filters filters: [], filterToBeMerged: [], // a collection of multiple dataset datasets: {}, editingDataset: undefined, interactionConfig: getDefaultInteraction(), interactionToBeMerged: undefined, layerBlending: 'normal', hoverInfo: undefined, clicked: undefined, mousePos: {}, // this is used when user split maps splitMaps: [ // this will contain a list of objects to // describe the state of layer availability and visibility for each map // [ // { // layers: {layer_id: true | false} // } // ] ], splitMapsToBeMerged: [], // defaults layer classes layerClasses: LayerClasses, // default animation // time in unix timestamp (milliseconds) (the number of seconds since the Unix Epoch) animationConfig: DEFAULT_ANIMATION_CONFIG, editor: DEFAULT_EDITOR, fileLoading: false, fileLoadingProgress: {}, loaders: [], loadOptions: {}, // visStateMergers mergers: VIS_STATE_MERGERS, // kepler schemas schema: KeplerGLSchema }; /** * Update state with updated layer and layerData * @type {typeof import('./vis-state-updaters').updateStateWithLayerAndData} * */ export function updateStateWithLayerAndData(state, {layerData, layer, idx}) { return { ...state, layers: state.layers.map((lyr, i) => (i === idx ? layer : lyr)), layerData: layerData ? state.layerData.map((d, i) => (i === idx ? layerData : d)) : state.layerData }; } export function updateStateOnLayerVisibilityChange(state, layer) { let newState = state; if (state.splitMaps.length) { newState = { ...state, splitMaps: layer.config.isVisible ? addNewLayersToSplitMap(state.splitMaps, layer) : removeLayerFromSplitMaps(state.splitMaps, layer) }; } if (layer.config.animation.enabled) { newState = updateAnimationDomain(state); } return newState; } /** * Update layer base config: dataId, label, column, isVisible * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerConfigChangeUpdater} * @returns nextState */ export function layerConfigChangeUpdater(state, action) { const {oldLayer} = action; const idx = state.layers.findIndex(l => l.id === oldLayer.id); const props = Object.keys(action.newConfig); if (typeof action.newConfig.dataId === 'string') { const {dataId, ...restConfig} = action.newConfig; const stateWithDataId = layerDataIdChangeUpdater(state, { oldLayer, newConfig: {dataId} }); const nextLayer = stateWithDataId.layers.find(l => l.id === oldLayer.id); return nextLayer && Object.keys(restConfig).length ? layerConfigChangeUpdater(stateWithDataId, {oldLayer: nextLayer, newConfig: restConfig}) : stateWithDataId; } let newLayer = oldLayer.updateLayerConfig(action.newConfig); let layerData; // let newLayer; if (newLayer.shouldCalculateLayerData(props)) { const oldLayerData = state.layerData[idx]; const updateLayerDataResult = calculateLayerData(newLayer, state, oldLayerData); layerData = updateLayerDataResult.layerData; newLayer = updateLayerDataResult.layer; } let newState = state; if ('isVisible' in action.newConfig) { newState = updateStateOnLayerVisibilityChange(state, newLayer); } return updateStateWithLayerAndData(newState, { layer: newLayer, layerData, idx }); } function addOrRemoveTextLabels(newFields, textLabel) { let newTextLabel = textLabel.slice(); const currentFields = textLabel.map(tl => tl.field && tl.field.name).filter(d => d); const addFields = newFields.filter(f => !currentFields.includes(f.name)); const deleteFields = currentFields.filter(f => !newFields.find(fd => fd.name === f)); // delete newTextLabel = newTextLabel.filter(tl => tl.field && !deleteFields.includes(tl.field.name)); newTextLabel = !newTextLabel.length ? [DEFAULT_TEXT_LABEL] : newTextLabel; // add newTextLabel = [ ...newTextLabel.filter(tl => tl.field), ...addFields.map(af => ({ ...DEFAULT_TEXT_LABEL, field: af })) ]; return newTextLabel; } function updateTextLabelPropAndValue(idx, prop, value, textLabel) { if (!textLabel[idx].hasOwnProperty(prop)) { return textLabel; } let newTextLabel = textLabel.slice(); if (prop && (value || textLabel.length === 1)) { newTextLabel = textLabel.map((tl, i) => (i === idx ? {...tl, [prop]: value} : tl)); } else if (prop === 'field' && value === null && textLabel.length > 1) { // remove label when field value is set to null newTextLabel.splice(idx, 1); } return newTextLabel; } /** * Update layer base config: dataId, label, column, isVisible * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerTextLabelChangeUpdater} * @returns nextState */ export function layerTextLabelChangeUpdater(state, action) { const {oldLayer, idx, prop, value} = action; const {textLabel} = oldLayer.config; let newTextLabel = textLabel.slice(); if (!textLabel[idx] && idx === textLabel.length) { // if idx is set to length, add empty text label newTextLabel = [...textLabel, DEFAULT_TEXT_LABEL]; } if (idx === 'all' && prop === 'fields') { newTextLabel = addOrRemoveTextLabels(value, textLabel); } else { newTextLabel = updateTextLabelPropAndValue(idx, prop, value, newTextLabel); } // update text label prop and value return layerConfigChangeUpdater(state, { oldLayer, newConfig: {textLabel: newTextLabel} }); } function validateExistingLayerWithData(dataset, layerClasses, layer) { const loadedLayer = serializeLayer(layer); return validateLayerWithData(dataset, loadedLayer, layerClasses, { allowEmptyColumn: true }); } /** * Update layer config dataId * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerDataIdChangeUpdater} * @returns nextState */ export function layerDataIdChangeUpdater(state, action) { const {oldLayer, newConfig} = action; const {dataId} = newConfig; if (!oldLayer || !state.datasets[dataId]) { return state; } const idx = state.layers.findIndex(l => l.id === oldLayer.id); let newLayer = oldLayer.updateLayerConfig({dataId}); // this may happen when a layer is new (type: null and no columns) but it's not ready to be saved if (newLayer.isValidToSave()) { const validated = validateExistingLayerWithData( state.datasets[dataId], state.layerClasses, newLayer ); // if cant validate it with data create a new one if (!validated) { newLayer = new state.layerClasses[oldLayer.type]({dataId, id: oldLayer.id}); } else { newLayer = validated; } } newLayer = newLayer.updateLayerConfig({ isVisible: oldLayer.config.isVisible, isConfigActive: true }); newLayer.updateLayerDomain(state.datasets); const {layerData, layer} = calculateLayerData(newLayer, state, undefined); return updateStateWithLayerAndData(state, {layerData, layer, idx}); } /** * Update layer type. Previews layer config will be copied if applicable. * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerTypeChangeUpdater} * @public */ export function layerTypeChangeUpdater(state, action) { const {oldLayer, newType} = action; if (!oldLayer) { return state; } const oldId = oldLayer.id; const idx = state.layers.findIndex(l => l.id === oldId); if (!state.layerClasses[newType]) { Console.error(`${newType} is not a valid layer type`); return state; } // get a mint layer, with new id and type // because deck.gl uses id to match between new and old layer. // If type has changed but id is the same, it will break const newLayer = new state.layerClasses[newType](); newLayer.assignConfigToLayer(oldLayer.config, oldLayer.visConfigSettings); newLayer.updateLayerDomain(state.datasets); const {layerData, layer} = calculateLayerData(newLayer, state); let newState = updateStateWithLayerAndData(state, {layerData, layer, idx}); if (layer.config.animation.enabled || oldLayer.config.animation.enabled) { newState = updateAnimationDomain(newState); } // update splitMap layer id if (state.splitMaps.length) { newState = { ...newState, splitMaps: newState.splitMaps.map(settings => { const {[oldId]: oldLayerMap, ...otherLayers} = settings.layers; return oldId in settings.layers ? { ...settings, layers: { ...otherLayers, [layer.id]: oldLayerMap } } : settings; }) }; } return newState; } /** * Update layer visual channel * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerVisualChannelChangeUpdater} * @returns {Object} nextState * @public */ export function layerVisualChannelChangeUpdater(state, action) { const {oldLayer, newConfig, channel} = action; if (!oldLayer.config.dataId) { return state; } const dataset = state.datasets[oldLayer.config.dataId]; const idx = state.layers.findIndex(l => l.id === oldLayer.id); const newLayer = oldLayer.updateLayerConfig(newConfig); newLayer.updateLayerVisualChannel(dataset, channel); const oldLayerData = state.layerData[idx]; const {layerData, layer} = calculateLayerData(newLayer, state, oldLayerData); return updateStateWithLayerAndData(state, {layerData, layer, idx}); } /** * Update layer `visConfig` * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerVisConfigChangeUpdater} * @public */ export function layerVisConfigChangeUpdater(state, action) { const {oldLayer} = action; const idx = state.layers.findIndex(l => l.id === oldLayer.id); const props = Object.keys(action.newVisConfig); const newVisConfig = { ...oldLayer.config.visConfig, ...action.newVisConfig }; const newLayer = oldLayer.updateLayerConfig({visConfig: newVisConfig}); if (newLayer.shouldCalculateLayerData(props)) { const oldLayerData = state.layerData[idx]; const {layerData, layer} = calculateLayerData(newLayer, state, oldLayerData); return updateStateWithLayerAndData(state, {layerData, layer, idx}); } return updateStateWithLayerAndData(state, {layer: newLayer, idx}); } /** * Update filter property * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').setFilterAnimationTimeUpdater} * @public */ export function setFilterAnimationTimeUpdater(state, action) { return setFilterUpdater(state, action); } /** * Update filter animation window * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').setFilterAnimationWindowUpdater} * @public */ export function setFilterAnimationWindowUpdater(state, {id, animationWindow}) { return { ...state, filters: state.filters.map(f => f.id === id ? { ...f, animationWindow } : f ) }; } /** * Update filter property * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').setFilterUpdater} * @public */ export function setFilterUpdater(state, action) { const {idx, prop, value, valueIndex = 0} = action; const oldFilter = state.filters[idx]; if (!oldFilter) { Console.error(`filters.${idx} is undefined`); return state; } let newFilter = set([prop], value, oldFilter); let newState = state; const {dataId} = newFilter; // Ensuring backward compatibility let datasetIds = toArray(dataId); switch (prop) { // TODO: Next PR for UI if we update dataId, we need to consider two cases: // 1. dataId is empty: create a default filter // 2. Add a new dataset id case FILTER_UPDATER_PROPS.dataId: // if trying to update filter dataId. create an empty new filter newFilter = updateFilterDataId(dataId); break; case FILTER_UPDATER_PROPS.name: // we are supporting the current functionality // TODO: Next PR for UI filter name will only update filter name but it won't have side effects // we are gonna use pair of datasets and fieldIdx to update the filter const datasetId = newFilter.dataId[valueIndex]; const {filter: updatedFilter, dataset: newDataset} = applyFilterFieldName( newFilter, state.datasets[datasetId], value, valueIndex, {mergeDomain: false} ); if (!updatedFilter) { return state; } newFilter = updatedFilter; if (newFilter.gpu) { newFilter = setFilterGpuMode(newFilter, state.filters); newFilter = assignGpuChannel(newFilter, state.filters); } newState = set(['datasets', datasetId], newDataset, state); // only filter the current dataset break; case FILTER_UPDATER_PROPS.layerId: // We need to update only datasetId/s if we have added/removed layers // - check for layerId changes (XOR works because of string values) // if no differences between layerIds, don't do any filtering // @ts-ignore const layerIdDifference = xor(newFilter.layerId, oldFilter.layerId); const layerDataIds = uniq( layerIdDifference .map(lid => get( state.layers.find(l => l.id === lid), ['config', 'dataId'] ) ) .filter(d => d) ); // only filter datasetsIds datasetIds = layerDataIds; // Update newFilter dataIds const newDataIds = uniq( newFilter.layerId .map(lid => get( state.layers.find(l => l.id === lid), ['config', 'dataId'] ) ) .filter(d => d) ); newFilter = { ...newFilter, dataId: newDataIds }; break; default: break; } const enlargedFilter = state.filters.find(f => f.enlarged); if (enlargedFilter && enlargedFilter.id !== newFilter.id) { // there should be only one enlarged filter newFilter.enlarged = false; } // save new filters to newState newState = set(['filters', idx], newFilter, newState); // if we are currently setting a prop that only requires to filter the current // dataset we will pass only the current dataset to applyFiltersToDatasets and // updateAllLayerDomainData otherwise we pass the all list of datasets as defined in dataId const datasetIdsToFilter = LIMITED_FILTER_EFFECT_PROPS[prop] ? [datasetIds[valueIndex]] : datasetIds; // filter data const filteredDatasets = applyFiltersToDatasets( datasetIdsToFilter, newState.datasets, newState.filters, newState.layers ); newState = set(['datasets'], filteredDatasets, newState); // dataId is an array // pass only the dataset we need to update newState = updateAllLayerDomainData(newState, datasetIdsToFilter, newFilter); return newState; } /** * Set the property of a filter plot * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').setFilterPlotUpdater} * @public */ export const setFilterPlotUpdater = (state, {idx, newProp, valueIndex = 0}) => { let newFilter = {...state.filters[idx], ...newProp}; const prop = Object.keys(newProp)[0]; if (prop === 'yAxis') { const plotType = getDefaultFilterPlotType(newFilter); // TODO: plot is not supported in multi dataset filter for now if (plotType) { newFilter = { ...newFilter, ...getFilterPlot({...newFilter, plotType}, state.datasets[newFilter.dataId[valueIndex]]), plotType }; } } return { ...state, filters: state.filters.map((f, i) => (i === idx ? newFilter : f)) }; }; /** * Add a new filter * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').addFilterUpdater} * @public */ export const addFilterUpdater = (state, action) => !action.dataId ? state : { ...state, filters: [...state.filters, getDefaultFilter(action.dataId)] }; /** * Set layer color palette ui state * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerColorUIChangeUpdater} */ export const layerColorUIChangeUpdater = (state, {oldLayer, prop, newConfig}) => { const oldVixConfig = oldLayer.config.visConfig[prop]; const newLayer = oldLayer.updateLayerColorUI(prop, newConfig); const newVisConfig = newLayer.config.visConfig[prop]; if (oldVixConfig !== newVisConfig) { return layerVisConfigChangeUpdater(state, { oldLayer, newVisConfig: { [prop]: newVisConfig } }); } return { ...state, layers: state.layers.map(l => (l.id === oldLayer.id ? newLayer : l)) }; }; /** * Start and end filter animation * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').toggleFilterAnimationUpdater} * @public */ export const toggleFilterAnimationUpdater = (state, action) => ({ ...state, filters: state.filters.map((f, i) => (i === action.idx ? {...f, isAnimating: !f.isAnimating} : f)) }); /** * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').toggleLayerAnimationUpdater} * @public */ export const toggleLayerAnimationUpdater = state => ({ ...state, animationConfig: { ...state.animationConfig, isAnimating: !state.animationConfig.isAnimating } }); /** * Hide and show layer animation control * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').toggleLayerAnimationControlUpdater} * @public */ export const toggleLayerAnimationControlUpdater = state => ({ ...state, animationConfig: { ...state.animationConfig, hideControl: !state.animationConfig.hideControl } }); /** * Change filter animation speed * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').updateFilterAnimationSpeedUpdater} * @public */ export const updateFilterAnimationSpeedUpdater = (state, action) => ({ ...state, filters: state.filters.map((f, i) => (i === action.idx ? {...f, speed: action.speed} : f)) }); /** * Reset animation config current time to a specified value * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').setLayerAnimationTimeUpdater} * @public * */ export const setLayerAnimationTimeUpdater = (state, {value}) => ({ ...state, animationConfig: { ...state.animationConfig, currentTime: value } }); /** * Update animation speed with the vertical speed slider * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').updateLayerAnimationSpeedUpdater} * @public * */ export const updateLayerAnimationSpeedUpdater = (state, {speed}) => { return { ...state, animationConfig: { ...state.animationConfig, speed } }; }; /** * Show larger time filter at bottom for time playback (apply to time filter only) * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').enlargeFilterUpdater} * @public */ export const enlargeFilterUpdater = (state, action) => { return { ...state, filters: state.filters.map((f, i) => i === action.idx ? { ...f, enlarged: !f.enlarged } : f ) }; }; /** * Toggles filter feature visibility * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').toggleFilterFeatureUpdater} */ export const toggleFilterFeatureUpdater = (state, action) => { const filter = state.filters[action.idx]; const isVisible = get(filter, ['value', 'properties', 'isVisible']); const newFilter = { ...filter, value: featureToFilterValue(filter.value, filter.id, { isVisible: !isVisible }) }; return { ...state, filters: Object.assign([...state.filters], {[action.idx]: newFilter}) }; }; /** * Remove a filter * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').removeFilterUpdater} * @public */ export const removeFilterUpdater = (state, action) => { const {idx} = action; const {dataId, id} = state.filters[idx]; const newFilters = [ ...state.filters.slice(0, idx), ...state.filters.slice(idx + 1, state.filters.length) ]; const filteredDatasets = applyFiltersToDatasets(dataId, state.datasets, newFilters, state.layers); const newEditor = getFilterIdInFeature(state.editor.selectedFeature) === id ? { ...state.editor, selectedFeature: null } : state.editor; let newState = set(['filters'], newFilters, state); newState = set(['datasets'], filteredDatasets, newState); newState = set(['editor'], newEditor, newState); return updateAllLayerDomainData(newState, dataId, undefined); }; /** * Add a new layer * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').addLayerUpdater} * @public */ export const addLayerUpdater = (state, action) => { let newLayer; let newLayerData; if (action.config) { newLayer = createLayerFromConfig(state, action.config); if (!newLayer) { Console.warn( 'Failed to create layer from config, it usually means the config is not be in correct format', action.config ); return state; } const result = calculateLayerData(newLayer, state); newLayer = result.layer; newLayerData = result.layerData; } else { // create an empty layer with the first available dataset const defaultDataset = Object.keys(state.datasets)[0]; newLayer = new Layer({ isVisible: true, isConfigActive: true, dataId: defaultDataset }); newLayerData = {}; } return { ...state, layers: [...state.layers, newLayer], layerData: [...state.layerData, newLayerData], layerOrder: [...state.layerOrder, state.layerOrder.length], splitMaps: addNewLayersToSplitMap(state.splitMaps, newLayer) }; }; /** * remove layer * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').removeLayerUpdater} * @public */ export const removeLayerUpdater = (state, {idx}) => { const {layers, layerData, clicked, hoverInfo} = state; const layerToRemove = state.layers[idx]; const newMaps = removeLayerFromSplitMaps(state.splitMaps, layerToRemove); const newState = { ...state, layers: [...layers.slice(0, idx), ...layers.slice(idx + 1, layers.length)], layerData: [...layerData.slice(0, idx), ...layerData.slice(idx + 1, layerData.length)], layerOrder: state.layerOrder.filter(i => i !== idx).map(pid => (pid > idx ? pid - 1 : pid)), clicked: layerToRemove.isLayerHovered(clicked) ? undefined : clicked, hoverInfo: layerToRemove.isLayerHovered(hoverInfo) ? undefined : hoverInfo, splitMaps: newMaps // TODO: update filters, create helper to remove layer form filter (remove layerid and dataid) if mapped }; return updateAnimationDomain(newState); }; /** * duplicate layer * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').duplicateLayerUpdater} * @public */ export const duplicateLayerUpdater = (state, {idx}) => { const {layers} = state; const original = state.layers[idx]; const originalLayerOrderIdx = state.layerOrder.findIndex(i => i === idx); if (!original) { Console.warn(`layer.${idx} is undefined`); return state; } let newLabel = `Copy of ${original.config.label}`; let postfix = 0; // eslint-disable-next-line no-loop-func while (layers.find(l => l.config.label === newLabel)) { newLabel = `Copy of ${original.config.label} ${++postfix}`; } // collect layer config from original const loadedLayer = serializeLayer(original); // assign new id and label to copied layer if (!loadedLayer.config) { return state; } loadedLayer.config.label = newLabel; loadedLayer.id = generateHashId(LAYER_ID_LENGTH); // add layer to state let nextState = addLayerUpdater(state, {config: loadedLayer}); // new added layer are at the end, move it to be on top of original layer const newLayerOrderIdx = nextState.layerOrder.length - 1; const newLayerOrder = arrayInsert( nextState.layerOrder.slice(0, newLayerOrderIdx), originalLayerOrderIdx, newLayerOrderIdx ); nextState = { ...nextState, layerOrder: newLayerOrder }; return updateAnimationDomain(nextState); }; /** * Reorder layer * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').reorderLayerUpdater} * @public */ export const reorderLayerUpdater = (state, {order}) => ({ ...state, layerOrder: order }); /** * Remove a dataset and all layers, filters, tooltip configs that based on it * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').removeDatasetUpdater} * @public */ export const removeDatasetUpdater = (state, action) => { // extract dataset key const {dataId: datasetKey} = action; const {datasets} = state; // check if dataset is present if (!datasets[datasetKey]) { return state; } /* eslint-disable no-unused-vars */ const { layers, datasets: {[datasetKey]: dataset, ...newDatasets} } = state; /* eslint-enable no-unused-vars */ const indexes = layers.reduce((listOfIndexes, layer, index) => { if (layer.config.dataId === datasetKey) { // @ts-ignore listOfIndexes.push(index); } return listOfIndexes; }, []); // remove layers and datasets const {newState} = indexes.reduce( ({newState: currentState, indexCounter}, idx) => { const currentIndex = idx - indexCounter; currentState = removeLayerUpdater(currentState, {idx: currentIndex}); indexCounter++; return {newState: currentState, indexCounter}; }, {newState: {...state, datasets: newDatasets}, indexCounter: 0} ); // remove filters const filters = state.filters.filter(filter => !filter.dataId.includes(datasetKey)); // update interactionConfig let {interactionConfig} = state; const {tooltip} = interactionConfig; if (tooltip) { const {config} = tooltip; /* eslint-disable no-unused-vars */ const {[datasetKey]: fields, ...fieldsToShow} = config.fieldsToShow; /* eslint-enable no-unused-vars */ interactionConfig = { ...interactionConfig, tooltip: {...tooltip, config: {...config, fieldsToShow}} }; } return {...newState, filters, interactionConfig}; }; /** * update layer blending mode * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').updateLayerBlendingUpdater} * @public */ export const updateLayerBlendingUpdater = (state, action) => ({ ...state, layerBlending: action.mode }); /** * Display dataset table in a modal * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').showDatasetTableUpdater} * @public */ export const showDatasetTableUpdater = (state, action) => { return { ...state, editingDataset: action.dataId }; }; /** * reset visState to initial State * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').resetMapConfigUpdater} * @public */ export const resetMapConfigUpdater = state => ({ ...INITIAL_VIS_STATE, ...state.initialState, initialState: state.initialState }); /** * Propagate `visState` reducer with a new configuration. Current config will be override. * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').receiveMapConfigUpdater} * @public */ export const receiveMapConfigUpdater = (state, {payload: {config = {}, options = {}}}) => { if (!config.visState) { return state; } const {keepExistingConfig} = options; // reset config if keepExistingConfig is falsy let mergedState = !keepExistingConfig ? resetMapConfigUpdater(state) : state; for (const merger of state.mergers) { if (isValidMerger(merger) && config.visState[merger.prop]) { mergedState = merger.merge(mergedState, config.visState[merger.prop], true); } } return mergedState; }; /** * Trigger layer hover event with hovered object * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerHoverUpdater} * @public */ export const layerHoverUpdater = (state, action) => ({ ...state, hoverInfo: action.info }); /* eslint-enable max-statements */ /** * Update `interactionConfig` * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').interactionConfigChangeUpdater} * @public */ export function interactionConfigChangeUpdater(state, action) { const {config} = action; const interactionConfig = { ...state.interactionConfig, ...{[config.id]: config} }; // Don't enable tooltip and brush at the same time // but coordinates can be shown at all time const contradict = ['brush', 'tooltip']; if ( contradict.includes(config.id) && config.enabled && !state.interactionConfig[config.id].enabled ) { // only enable one interaction at a time contradict.forEach(k => { if (k !== config.id) { interactionConfig[k] = {...interactionConfig[k], enabled: false}; } }); } const newState = { ...state, interactionConfig }; if (config.id === 'geocoder' && !config.enabled) { return removeDatasetUpdater(newState, {dataId: 'geocoder_dataset'}); } return newState; } /** * Trigger layer click event with clicked object * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').layerClickUpdater} * @public */ export const layerClickUpdater = (state, action) => ({ ...state, mousePos: state.interactionConfig.coordinate.enabled ? { ...state.mousePos, pinned: state.mousePos.pinned ? null : cloneDeep(state.mousePos) } : state.mousePos, clicked: action.info && action.info.picked ? action.info : null }); /** * Trigger map click event, unselect clicked object * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').mapClickUpdater} * @public */ export const mapClickUpdater = state => { return { ...state, clicked: null }; }; /** * Trigger map move event * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').mouseMoveUpdater} * @public */ export const mouseMoveUpdater = (state, {evt}) => { if (Object.values(state.interactionConfig).some(config => config.enabled)) { return { ...state, mousePos: { ...state.mousePos, mousePosition: [...evt.point], coordinate: [...evt.lngLat] } }; } return state; }; /** * Toggle visibility of a layer for a split map * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').toggleSplitMapUpdater} * @public */ export const toggleSplitMapUpdater = (state, action) => state.splitMaps && state.splitMaps.length === 0 ? { ...state, // maybe we should use an array to store state for a single map as well // if current maps length is equal to 0 it means that we are about to split the view splitMaps: computeSplitMapLayers(state.layers) } : closeSpecificMapAtIndex(state, action); /** * Toggle visibility of a layer in a split map * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').toggleLayerForMapUpdater} * @public */ export const toggleLayerForMapUpdater = (state, {mapIndex, layerId}) => { const {splitMaps} = state; return { ...state, splitMaps: splitMaps.map((sm, i) => i === mapIndex ? { ...splitMaps[i], layers: { ...splitMaps[i].layers, // if layerId not in layers, set it to visible [layerId]: !splitMaps[i].layers[layerId] } } : sm ) }; }; /** * Add new dataset to `visState`, with option to load a map config along with the datasets * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').updateVisDataUpdater} * @public */ /* eslint-disable max-statements */ // eslint-disable-next-line complexity export const updateVisDataUpdater = (state, action) => { // datasets can be a single data entries or an array of multiple data entries const {config, options} = action; const datasets = toArray(action.datasets); const newDataEntries = datasets.reduce( (accu, {info = {}, ...rest} = {}) => ({ ...accu, ...(createNewDataEntry({info, ...rest}, state.datasets) || {}) }), {} ); const dataEmpty = Object.keys(newDataEntries).length < 1; // apply config if passed from action const previousState = config ? receiveMapConfigUpdater(state, { payload: {config, options} }) : state; let mergedState = { ...previousState, datasets: { ...previousState.datasets, ...newDataEntries } }; // merge state with config to be merged for (const merger of mergedState.mergers) { if (isValidMerger(merger) && merger.toMergeProp && mergedState[merger.toMergeProp]) { const toMerge = mergedState[merger.toMergeProp]; mergedState[merger.toMergeProp] = INITIAL_VIS_STATE[merger.toMergeProp]; mergedState = merger.merge(mergedState, toMerge); } } let newLayers = !dataEmpty ? mergedState.layers.filter(l => l.config.dataId && l.config.dataId in newDataEntries) : []; if (!newLayers.length && (options || {}).autoCreateLayers !== false) { // no layer merged, find defaults const result = addDefaultLayers(mergedState, newDataEntries); mergedState = result.state; newLayers = result.newLayers; } if (mergedState.splitMaps.length) { // if map is split, add new layers to splitMaps newLayers = mergedState.layers.filter( l => l.config.dataId && l.config.dataId in newDataEntries ); mergedState = { ...mergedState, splitMaps: addNewLayersToSplitMap(mergedState.splitMaps, newLayers) }; } // if no tooltips merged add default tooltips Object.keys(newDataEntries).forEach(dataId => { const tooltipFields = mergedState.interactionConfig.tooltip.config.fieldsToShow[dataId]; if (!Array.isArray(tooltipFields) || !tooltipFields.length) { mergedState = addDefaultTooltips(mergedState, newDataEntries[dataId]); } }); let updatedState = updateAllLayerDomainData( mergedState, dataEmpty ? Object.keys(mergedState.datasets) : Object.keys(newDataEntries), undefined ); // register layer animation domain, // need to be called after layer data is calculated updatedState = updateAnimationDomain(updatedState); return updatedState; }; /* eslint-enable max-statements */ /** * Rename an existing dataset in `visState` * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').renameDatasetUpdater} * @public */ export function renameDatasetUpdater(state, action) { const {dataId, label} = action; const {datasets} = state; const existing = datasets[dataId]; // @ts-ignore return existing ? { ...state, datasets: { ...datasets, [dataId]: { ...existing, label } } } : // No-op if the dataset doesn't exist state; } /** * When a user clicks on the specific map closing icon * the application will close the selected map * and will merge the remaining one with the global state * TODO: i think in the future this action should be called merge map layers with global settings * @param {Object} state `visState` * @param {Object} action action * @returns {Object} nextState */ export function closeSpecificMapAtIndex(state, action) { // retrieve layers meta data from the remaining map that we need to keep const indexToRetrieve = 1 - action.payload; const mapLayers = state.splitMaps[indexToRetrieve].layers; const {layers} = state; // update layer visibility const newLayers = layers.map(layer => !mapLayers[layer.id] && layer.config.isVisible ? layer.updateLayerConfig({ // if layer.id is not in mapLayers, it should be inVisible isVisible: false }) : layer ); // delete map return { ...state, layers: newLayers, splitMaps: [] }; } /** * Trigger file loading dispatch `addDataToMap` if succeed, or `loadFilesErr` if failed * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').loadFilesUpdater} * @public */ export const loadFilesUpdater = (state, action) => { const {files, onFinish = loadFilesSuccess} = action; if (!files.length) { return state; } const fileLoadingProgress = Array.from(files).reduce( (accu, f, i) => merge_(initialFileLoadingProgress(f, i))(accu), {} ); const fileLoading = { fileCache: [], filesToLoad: files, onFinish }; const nextState = merge_({fileLoadingProgress, fileLoading})(state); return loadNextFileUpdater(nextState); }; /** * Sucessfully loaded one file, move on to the next one * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').loadFileStepSuccessUpdater} * @public */ export function loadFileStepSuccessUpdater(state, action) { if (!state.fileLoading) { return state; } const {fileName, fileCache} = action; const {filesToLoad, onFinish} = state.fileLoading; const stateWithProgress = updateFileLoadingProgressUpdater(state, { fileName, progress: {percent: 1, message: 'Done'} }); // save processed file to fileCache const stateWithCache = pick_('fileLoading')(merge_({fileCache}))(stateWithProgress); return withTask( stateWithCache, DELAY_TASK(200).map(filesToLoad.length ? loadNextFile : () => onFinish(fileCache)) ); } // withTask<T>(state: T, task: any): T /** * * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').loadNextFileUpdater} * @public */ export function loadNextFileUpdater(state) { if (!state.fileLoading) { return state; } const {filesToLoad} = state.fileLoading; const [file, ...remainingFilesToLoad] = filesToLoad; // save filesToLoad to state const nextState = pick_('fileLoading')(merge_({filesToLoad: remainingFilesToLoad}))(state); const stateWithProgress = updateFileLoadingProgressUpdater(nextState, { fileName: file.name, progress: {percent: 0, message: 'loading...'} }); const {loaders, loadOptions} = state; return withTask( stateWithProgress, makeLoadFileTask(file, nextState.fileLoading.fileCache, loaders, loadOptions) ); } export function makeLoadFileTask(file, fileCache, loaders = [], loadOptions = {}) { return LOAD_FILE_TASK({file, fileCache, loaders, loadOptions}).bimap( // prettier ignore // success gen => nextFileBatch({ gen, fileName: file.name, onFinish: result => processFileContent({ content: result, fileCache }) }), // error err => loadFilesErr(file.name, err) ); } /** * * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').processFileContentUpdater} * @public */ export function processFileContentUpdater(state, action) { const {content, fileCache} = action.payload; const stateWithProgress = updateFileLoadingProgressUpdater(state, { fileName: content.fileName, progress: {percent: 1, message: 'processing...'} }); return withTask( stateWithProgress, PROCESS_FILE_DATA({content, fileCache}).bimap( result => loadFileStepSuccess({fileName: content.fileName, fileCache: result}), err => loadFilesErr(content.fileName, err) ) ); } export function parseProgress(prevProgress = {}, progress) { // This happens when receiving query metadata or other cases we don't // have an update for the user. if (!progress || !progress.percent) { return {}; } return { percent: progress.percent }; } /** * gets called with payload = AsyncGenerator<???> * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').nextFileBatchUpdater} * @public */ export const nextFileBatchUpdater = ( state, {payload: {gen, fileName, progress, accumulated, onFinish}} ) => { const stateWithProgress = updateFileLoadingProgressUpdater(state, { fileName, progress: parseProgress(state.fileLoadingProgress[fileName], progress) }); return withTask( stateWithProgress, UNWRAP_TASK(gen.next()).bimap( ({value, done}) => { return done ? onFinish(accumulated) : nextFileBatch({ gen, fileName, progress: value.progress, accumulated: value, onFinish }); }, err => loadFilesErr(fileName, err) ) ); }; /** * Trigger loading file error * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').loadFilesErrUpdater} * @public */ export const loadFilesErrUpdater = (state, {error, fileName}) => { // update ui with error message Console.warn(error); if (!state.fileLoading) { return state; } const {filesToLoad, onFinish, fileCache} = state.fileLoading; const nextState = updateFileLoadingProgressUpdater(state, { fileName, progress: {error} }); // kick off next file or finish return withTask( nextState, DELAY_TASK(200).map(filesToLoad.length ? loadNextFile : () => onFinish(fileCache)) ); }; /** * When select dataset for export, apply cpu filter to selected dataset * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').applyCPUFilterUpdater} * @public */ export const applyCPUFilterUpdater = (state, {dataId}) => { // apply cpuFilter const dataIds = toArray(dataId); return dataIds.reduce((accu, id) => filterDatasetCPU(accu, id), state); }; /** * User input to update the info of the map * @memberof visStateUpdaters * @type {typeof import('./vis-state-updaters').setMapInfoUpdater} * @public */ export const setMapInfoUpdater = (state, action) => ({ ...state, mapInfo: { ...state.mapInfo, ...action.info } }); /** * Helper function to update All layer domain and layer data of state * @type {typeof import('./vis-state-updaters').addDefaultLayers} */ export function addDefaultLayers(state, datasets) { /** @type {Layer[]} */ const empty = []; const defaultLayers = Object.values(datasets).reduce((accu, dataset) => { const foundLayers = findDefaultLayer(dataset, state.layerClasses); return foundLayers && foundLayers.length ? accu.concat(foundLayers) : accu; }, empty); return { state: { ...state, layers: [...state.layers, ...defaultLayers], layerOrder: [ // put new layers on top of old ones ...defaultLayers.map((_, i) => state.layers.length + i), ...state.layerOrder ] }, newLayers: defaultLayers }; } /** * helper function to find default tooltips * @param {Object} state * @param {Object} dataset * @returns {Object} nextState */ export function addDefaultTooltips(state, dataset) { const tooltipFields = findFieldsToShow(dataset); const merged = { ...state.interactionConfig.tooltip.config.fieldsToShow, ...tooltipFields }; return set(['interactionConfig', 'tooltip', 'config', 'fieldsToShow'], merged, state); } export function initialFileLoadingProgress(file, index) { const fileName = file.name || `Untitled File ${index}`; return { [fileName]: { // percent of current file percent: 0, message: '', fileName, error: null } }; } export function updateFileLoadingProgressUpdater(state, {fileName, progress}) { return pick_('fileLoadingProgress')(pick_(fileName)(merge_(progress)))(state); } /** * Helper function to update layer domains for an array of datasets * @type {typeof import('./vis-state-updaters').updateAllLayerDomainData} */ export function updateAllLayerDomainData(state, dataId, updatedFilter) { const dataIds = typeof dataId === 'string' ? [dataId] : dataId; const newLayers = []; const newLayerData = []; state.layers.forEach((oldLayer, i) => { if (oldLayer.config.dataId && dataIds.includes(oldLayer.config.dataId)) { // No need to recalculate layer domain if filter has fixed domain const newLayer = updatedFilter && updatedFilter.fixedDomain ? oldLayer : oldLayer.updateLayerDomain(state.datasets, updatedFilter); const {layerData, layer} = calculateLayerData(newLayer, state, state.layerData[i]); newLayers.push(layer); newLayerData.push(layerData); } else { newLayers.push(oldLay