UNPKG

kepler.gl.geoiq

Version:

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

1,800 lines (1,601 loc) 63.4 kB
// 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 {console as Console} from 'global/window'; import Task, {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 {set, toArray} from 'utils/utils'; // Tasks import {LOAD_FILE_TASK} from 'tasks/tasks'; // Actions import {loadFilesErr} from 'actions/vis-state-actions'; import {addDataToMap} from 'actions'; // Utils import {getDefaultInteraction, findFieldsToShow} from 'utils/interaction-utils'; import { FILTER_TYPES, FILTER_UPDATER_PROPS, LIMITED_FILTER_EFFECT_PROPS, applyFilterApiFieldName, applyFilterFieldName, applyFiltersToDatasets, generatePolygonFilter, getDefaultFilter, getFilterPlot, getDefaultFilterPlotType, isInRange, getFilterIdInFeature, featureToFilterValue, updateFilterDataId } from 'utils/filter-utils'; import { createNewDataEntry, findPointFieldPairs, getNewDatasetColor } from 'utils/dataset-utils'; import { findDefaultLayer, calculateLayerData, calculateLayerDataAfterAPICall } from 'utils/layer-utils/layer-utils'; import {filterTileLayerData} from 'utils/filter-utils'; import { mergeFilters, mergeLayers, mergeInteractions, mergeLayerBlending, mergeSplitMaps, mergeAnimationConfig, mergeWidgets } from './vis-state-merger'; import simplify from 'simplify-geojson'; import { addNewLayersToSplitMap, removeLayerFromSplitMaps, computeSplitMapLayers } from 'utils/split-map-utils'; import {generateHashId} from 'utils/utils'; import {processGeojson, validateInputData} from 'processors/data-processor'; import {Layer, LayerClasses} from 'layers'; import {Widget, WidgetClasses} from 'widgets'; import {processFileToLoad} from '/utils/file-utils'; import {DEFAULT_TEXT_LABEL} from 'layers/layer-factory'; import {EDITOR_MODES} from 'constants/default-settings'; // 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 */ // const visStateUpdaters = null; /* eslint-enable no-unused-vars */ export const defaultAnimationConfig = { domain: null, currentTime: null, speed: 1 }; export const DEFAULT_EDITOR = { mode: EDITOR_MODES.DRAW_POLYGON, features: [], selectedFeature: null, visible: true }; /** * Default initial `visState` * @memberof visStateUpdaters * @constant * @type {Object} * @property {Array} layers * @property {Array} layerData * @property {Array} layerToBeMerged * @property {Array} layerOrder * @property {Array} filters * @property {Array} filterToBeMerged * @property {Array} datasets * @property {string} editingDataset * @property {Object} interactionConfig * @property {Object} interactionToBeMerged * @property {string} layerBlending * @property {Object} hoverInfo * @property {Object} clicked * @property {Object} mousePos * @property {Array} splitMaps - a list of objects of layer availabilities and visibilities for each map * @property {Object} layerClasses * @property {Object} animationConfig * @property {Object} editor * @public */ export const INITIAL_VIS_STATE = { // layers layers: [], layerData: [], layerToBeMerged: [], layerOrder: [], // filters filters: [], filterToBeMerged: [], // widgets widgets: [], widgetToBeMerged: [], widgetOrder: [], // 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} // } // ] ], // // defaults layer classes layerClasses: LayerClasses, // default widget classes widgetClasses: WidgetClasses, // default animation // time in unix timestamp (milliseconds) (the number of seconds since the Unix Epoch) animationConfig: defaultAnimationConfig, editor: DEFAULT_EDITOR }; function updateStateWithWidgetAndData(state, {widget, idx}) { return { ...state, widgets: state.widgets.map((wdgt, i) => (i === idx ? widget : wdgt)) }; } /** * Called to update layer base config: dataId, label, column, isVisible * */ export function widgetConfigChangeUpdater(state, action) { const {oldWidget, auth, project} = action; const {mapState} = action.newConfig; const x = state.widgets.findIndex(w => w.id === oldWidget.id); const props = Object.keys(action.newConfig); action.newConfig.mapState ? delete action.newConfig.mapState : null; const newWidget = oldWidget.updateWidgetConfig(action.newConfig); // if (newWidget.shouldCalculateAggregatedData(props)) { // const aggregatedData = newWidget.calculateAggregationData( // newWidget, // state.filters, // state.datasets, // mapState, // auth, // project // ); // newWidget.updateWidgetConfg({apiCallRequest: true}); // return updateStateWithWidgetAndData(updatedState ? updatedState : state, { // widget: newWidget, // x // }); // } const newState = { ...state, splitMaps: 'isVisible' in action.newConfig ? toggleWidgetFromSplitMaps(state, widget) : state.splitMaps }; return updateStateWithWidgetAndData(newState, {widget: newWidget, x}); } export function widgetTypeChangeUpdater(state, action) { const {oldWidget, newType} = action; const oldId = oldWidget.id; const idx = state.widgets.findIndex(w => w.id === oldId); if (!state.widgetClasses[newType]) { Console.error(`${newType} is not a valid widget type`); return state; } // get a mint widget, 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 var newWidget = new state.widgetClasses[newType](); newWidget.config.aggregatedData = null; newWidget.assignConfigToWidget(oldWidget.config, oldWidget.visConfigSettings); const newState = state; const widget = newWidget; return updateStateWithWidgetAndData(newState, {widget, idx}); } 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 * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.oldLayer layer to be updated * @param {Object} action.newConfig new config * @returns {Object} 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); 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, { sameData: true } ); 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; } 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} }); } /** * Update layer type. Previews layer config will be copied if applicable. * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.oldLayer layer to be updated * @param {string} action.newType new type * @returns {Object} nextState * @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](); if (newLayer.name !== 'boundary' && newLayer.name !== 'pincode') { oldLayer.config.apiCallRequest = true; } newLayer.assignConfigToLayer(oldLayer.config, oldLayer.visConfigSettings); if (newLayer.config.dataId) { const dataset = state.datasets[newLayer.config.dataId]; newLayer.updateLayerDomain(dataset); } 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 widgets layer id // if (state.widgets) { // state.widgets.map(widget => { // const {config} = widget; // const {selectedLayer} = config; // if (widget.type && selectedLayer.id === oldId) { // widget.updateWidgetConfig({ // selectedLayer: {...selectedLayer, id: newLayer.id} // }); // } // }); // } // 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 * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.oldLayer layer to be updated * @param {Object} action.newConfig new visual channel config * @param {string} action.channel channel to be updated * @returns {Object} nextState * @public */ export function layerVisualChannelChangeUpdater(state, action) { const {oldLayer, newConfig, channel} = action; 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, { sameData: true }); // let data = layer.axiosApiCall(); // if (layer.type === 'boundary') { // data.then(function(result) { // // const testData = layer.formatLayer(); // var {layerData, layer} = calculateLayerDataAfterAPICall( // newLayer, // state, // layerData, // result // ); // layerData = { // ...layerData, // result // }; // newLayers[i] = layer; // newLayerDatas[i] = layerData; // return { // ...state, // layers: newLayers, // layerData: newLayerDatas // }; // }); // } return updateStateWithLayerAndData(state, {layerData, layer, idx}); } /** * Update layer `visConfig` * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.oldLayer layer to be updated * @param {Object} action.newVisConfig new visConfig as a key value map: e.g. `{opacity: 0.8}` * @returns {Object} nextState * @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, { sameData: true } ); return updateStateWithLayerAndData(state, {layerData, layer, idx}); } return updateStateWithLayerAndData(state, {layer: newLayer, idx}); } /* eslint-enable max-statements */ /** * Update `interactionConfig` * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.config new config as key value map: `{tooltip: {enabled: true}}` * @returns {Object} nextState * @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}; } }); } return { ...state, interactionConfig }; } /** * Update filter property * @param {object} state * @param {object} action * @param {Number} action.idx `idx` of filter to be updated * @param {string} action.prop `prop` of filter, e,g, `dataId`, `name`, `value` * @param {*} action.value new value * @param {string} action.dataId * @param {string} action.range * @param {object} action.histogram * @returns {object} newState */ export function setFilterAPIUpdater(state, action) { const { dataId, prop, value, valueIndex = 0, domain, histogram = {}, idx } = action; let newState = state; let newFilter = { ...state.filters[idx], [prop]: value }; if (!histogram) { histogram = {}; } const datasetId = newFilter.dataId[valueIndex]; const {filter: updatedFilter} = applyFilterApiFieldName( newFilter, state.datasets[datasetId], value, valueIndex, {mergeDomain: false}, histogram, domain ); if (!updatedFilter) { return state; } newFilter = updatedFilter; // newState = newState.layers.map(l => { // if (l.config.dataId === dataId) { // layerConfigChangeUpdater(l, { // apiCallRequest: true // }); // } // }); newState = { ...newState, filters: Object.assign([...state.filters], {[idx]: newFilter}) }; return newState; } /** * Update filter property * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.idx `idx` of filter to be updated * @param {string} action.prop `prop` of filter, e,g, `dataId`, `name`, `value` * @param {*} action.value new value * @param {string} datasetId used when updating a prop (dataId, name) that can be linked to multiple datasets * @returns {Object} nextState * @public */ export function setFilterUpdater(state, action) { const {idx, prop, value, valueIndex = 0, mapState, auth, project} = action; const oldFilter = state.filters[idx]; let newFilter = set([prop], value, oldFilter); let newState = state; const {dataId} = newFilter; // Ensuring backward compatibility let datasetIds = Array.isArray(dataId) ? dataId : [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 = getDefaultFilter(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; newState = { ...state, datasets: { ...state.datasets, [datasetId]: newDataset } }; 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 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 }; // only filter the current dataset 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 = { ...newState, filters: Object.assign([...state.filters], {[idx]: newFilter}) }; // 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] ? toArray(datasetIds[valueIndex]) : datasetIds; // filter data newState = { ...newState, datasets: applyFiltersToDatasets( datasetIdsToFilter, newState.datasets, newState.filters, newState.layers ) }; // newState = updateWidgetAggregationData(newState, mapState, auth, project); // 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 * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.idx * @param {Object} action.newProp key value mapping of new prop `{yAxis: 'histogram'}` * @returns {Object} nextState * @public */ export const setFilterPlotUpdater = (state, {idx, newProp}) => { let newFilter = {...state.filters[idx], ...newProp}; const prop = Object.keys(newProp)[0]; if (prop === 'yAxis') { const plotType = getDefaultFilterPlotType(newFilter); if (plotType) { newFilter = { ...newFilter, ...getFilterPlot( {...newFilter, plotType}, state.datasets[newFilter.dataId].allData ), plotType }; } } return { ...state, filters: state.filters.map((f, i) => (i === idx ? newFilter : f)) }; }; /** * Add a new filter * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {string} action.dataId dataset `id` this new filter is associated with * @returns {Object} nextState * @public */ export const addFilterUpdater = (state, action) => !action.dataId ? state : { ...state, filters: [...state.filters, getDefaultFilter(action.dataId)] }; /** * Set layer color palette ui state * @memberof visStateUpdaters * @param {Object} state * @param {Object} action * @param {Object} action.prop * @param {Object} action.newConfig */ export const layerColorUIChangeUpdater = ( state, {oldLayer, prop, newConfig} ) => { const newLayer = oldLayer.updateLayerColorUI(prop, newConfig); return { ...state, layers: state.layers.map(l => (l.id === oldLayer.id ? newLayer : l)) }; }; /** * Start and end filter animation * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.idx idx of filter * @returns {Object} nextState * @public */ export const toggleFilterAnimationUpdater = (state, action) => ({ ...state, filters: state.filters.map((f, i) => i === action.idx ? {...f, isAnimating: !f.isAnimating} : f ) }); /** * Change filter animation speed * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.idx `idx` of filter * @param {Number} action.speed `speed` to change it to. `speed` is a multiplier * @returns {Object} nextState * @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 * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.value the value current time will be set to * @returns {Object} nextState * @public * */ export const updateAnimationTimeUpdater = (state, {value}) => ({ ...state, animationConfig: { ...state.animationConfig, currentTime: value } }); /** * Update animation speed with the vertical speed slider * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.speed the updated speed of the animation * @returns {Object} nextState * @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 * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.idx index of filter to enlarge * @returns {Object} nextState * @public */ export const enlargeFilterUpdater = (state, action) => { const isEnlarged = state.filters[action.idx].enlarged; return { ...state, filters: state.filters.map((f, i) => { f.enlarged = !isEnlarged && i === action.idx; return f; }) }; }; /** * Remove a filter * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.idx index of filter to b e removed * @returns {Object} nextState * @public */ export const removeFilterUpdater = (state, action) => { const {idx, mapState, auth, project} = action; const {dataId} = state.filters[idx]; const newFilters = [ ...state.filters.slice(0, idx), ...state.filters.slice(idx + 1, state.filters.length) ]; const filter = state.filters[idx]; const filteredDatasets = applyFiltersToDatasets( dataId, state.datasets, newFilters, state.layers ); let newState = { ...state, filters: newFilters, editor: { ...state.editor, features: filter.type !== FILTER_TYPES.polygon ? state.editor.features : state.editor.features.filter(f => f.id !== filter.value.id), selectedFeature: null } }; // call layers api to update data when a filter is removed const layers = newState.layers.map(layer => { if (layer.dataId === dataId) { layer.config.apiCallRequest === true; } return layer; }); newState = { ...newState, layers }; newState = set(['datasets'], filteredDatasets, newState); return updateAllLayerDomainData(newState, dataId); }; export const addWidgetUpdater = (state, action) => { const defaultDataset = Object.keys(state.datasets)[0]; const newWidget = new Widget({ isVisible: true, isConfigActive: true, dataId: defaultDataset, ...action.props }); return !defaultDataset ? state : { ...state, widgets: [...state.widgets, newWidget], widgetOrder: [...state.widgetOrder, state.widgetOrder.length] }; }; export const removeWidgetUpdater = (state, {idx}) => { const {widgets} = state; return { ...state, widgets: [ ...widgets.slice(0, idx), ...widgets.slice(idx + 1, widgets.length) ], widgetOrder: state.widgetOrder .filter(i => i !== idx) .map(pid => (pid > idx ? pid - 1 : pid)) }; }; export const reorderWidgetUpdater = (state, {order}) => { return { ...state, widgetOrder: order }; }; /** * Add a new layer * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.props - new layer props * @returns {Object} nextState * @public */ export const addLayerUpdater = (state, action) => { const defaultDataset = Object.keys(state.datasets)[0]; const newLayer = new Layer({ isVisible: true, isConfigActive: true, dataId: defaultDataset, ...action.props }); return { ...state, layers: [...state.layers, newLayer], layerData: [...state.layerData, {}], layerOrder: [...state.layerOrder, state.layerOrder.length], splitMaps: addNewLayersToSplitMap(state.splitMaps, newLayer) }; }; /** * remove layer * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Number} action.idx index of layer to b e removed * @returns {Object} nextState * @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); }; /** * Reorder layer * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Array<Number>} action.order an array of layer indexes * @returns {Object} nextState * @public */ export const reorderLayerUpdater = (state, {order}) => ({ ...state, layerOrder: order }); /** * Remove a dataset and all layers, filters, tooltip configs that based on it * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {string} action.key dataset id * @returns {Object} nextState * @public */ export const removeDatasetUpdater = (state, action) => { // extract dataset key const {key: datasetKey} = action; const {datasets} = state; // check if dataset is present if (!datasets[datasetKey]) { return state; } /* eslint-disable no-unused-vars */ const { layers, widgets, datasets: {[datasetKey]: dataset, ...newDatasets} } = state; /* eslint-enable no-unused-vars */ const indexes = layers.reduce((listOfIndexes, layer, index) => { if (layer.config.dataId === datasetKey) { listOfIndexes.push(index); } return listOfIndexes; }, []); const widgetIndexes = widgets.reduce((listOfIndexes, widget, index) => { if (widget.config.dataId === datasetKey) { 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 widgets // newState = { // ...newState, // widgets: widgetIndexes.map(idx => [ // ...widgets.slice(0, idx), // ...widgets.slice(idx + 1, widgets.length) // ]) // }; // const widgets = state.widgets.filter( // widget => widget.config.dataId !== datasetKey // ); const {finalState} = widgetIndexes.reduce( ({finalState: state, indexCounter}, idx) => { const currentIndex = idx - indexCounter; state = removeWidgetUpdater(state, {idx: currentIndex}); indexCounter++; return {finalState: state, indexCounter}; }, {finalState: {...newState}, indexCounter: 0} ); // remove filters const filters = state.filters.filter( filter => !datasetKey.includes(filter.dataId) ); // 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 {...finalState, filters, interactionConfig}; }; /** * update layer blending mode * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {string} action.mode one of `additive`, `normal` and `subtractive` * @returns {Object} nextState * @public */ export const updateLayerBlendingUpdater = (state, action) => ({ ...state, layerBlending: action.mode }); /** * Display dataset table in a modal * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {string} action.dataId dataset id to show in table * @returns {Object} nextState * @public */ export const showDatasetTableUpdater = (state, action) => { return { ...state, editingDataset: action.dataId }; }; /** * reset visState to initial State * @memberof visStateUpdaters * @param {Object} state `visState` * @returns {Object} nextState * @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 * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.payload map config to be propagated * @param {Object} action.payload.config map config to be propagated * @param {Object} action.payload.option {keepExistingConfig: true | false} * @returns {Object} nextState * @public */ export const receiveMapConfigUpdater = ( state, {payload: {config = {}, options = {}}} ) => { if (!config.visState) { return state; } const { filters, layers, widgets, interactionConfig, layerBlending, splitMaps, animationConfig } = config.visState; const {keepExistingConfig} = options; // reset config if keepExistingConfig is falsy let mergedState = !keepExistingConfig ? resetMapConfigUpdater(state) : state; mergedState = mergeFilters(mergedState, filters); // console.log('After MergeFilters', mergedState); mergedState = mergeLayers(mergedState, layers); // console.log('After MergeLayers', mergedState); mergedState = mergeInteractions(mergedState, interactionConfig); mergedState = mergeLayerBlending(mergedState, layerBlending); mergedState = mergeSplitMaps(mergedState, splitMaps); mergedState = mergeAnimationConfig(mergedState, animationConfig); mergedState = mergeWidgets(mergedState, widgets); return mergedState; }; /** * Trigger layer hover event with hovered object * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.info Object hovered, returned by deck.gl * @returns {Object} nextState * @public */ export const layerHoverUpdater = (state, action) => ({ ...state, hoverInfo: action.info }); /** * Trigger layer click event with clicked object * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {Object} action.info Object clicked, returned by deck.gl * @returns {Object} nextState * @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 * @param {Object} state `visState` * @returns {Object} nextState * @public */ export const mapClickUpdater = state => { return { ...state, clicked: null }; }; 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 * @param {Object} state `visState` * @param {Object} action action * @param {Number|undefined} action.payload index of the split map * @returns {Object} nextState * @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 * @param {Object} state * @param {Object} action * @param {Number} action.mapIndex index of the split map * @param {string} action.layerId id of the layer * @returns {Object} nextState * @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 * @param {Object} state `visState` * @param {Object} action action * @param {Array<Object>|Object} action.datasets - ***required** datasets can be a dataset or an array of datasets * Each dataset object needs to have `info` and `data` property. * @param {Object} action.datasets.info -info of a dataset * @param {string} action.datasets.info.id - id of this dataset. If config is defined, `id` should matches the `dataId` in config. * @param {string} action.datasets.info.label - A display name of this dataset * @param {Object} action.datasets.data - ***required** The data object, in a tabular format with 2 properties `fields` and `rows` * @param {Array<Object>} action.datasets.data.fields - ***required** Array of fields, * @param {string} action.datasets.data.fields.name - ***required** Name of the field, * @param {Array<Array>} action.datasets.data.rows - ***required** Array of rows, in a tabular format with `fields` and `rows` * @param {Object} action.options option object `{centerMap: true, keepExistingConfig: false}` * @param {Object} action.config map config * @returns {Object} nextState * @public */ /* eslint-disable max-statements */ 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 = Array.isArray(action.datasets) ? action.datasets : [action.datasets]; const newDateEntries = datasets.reduce( (accu, {info = {}, data}) => ({ ...accu, ...(createNewDataEntry({info, data}, state.datasets) || {}) }), {} ); if (!Object.keys(newDateEntries).length) { return state; } // apply config if passed from action const previousState = config ? receiveMapConfigUpdater(state, { payload: {config, options} }) : state; const stateWithNewData = { ...previousState, datasets: { ...previousState.datasets, ...newDateEntries } }; // previously saved config before data loaded const { filterToBeMerged = [], layerToBeMerged = [], widgetToBeMerged = [], interactionToBeMerged = {}, splitMapsToBeMerged = [] } = stateWithNewData; // merge state with saved layers // make sure layers are merged first let mergedState = mergeLayers(stateWithNewData, layerToBeMerged); // merge state with saved filters mergedState = mergeFilters(mergedState, filterToBeMerged); // merge state with saved splitMaps mergedState = mergeSplitMaps(mergedState, splitMapsToBeMerged); let newLayers = mergedState.layers.filter( l => l.config.dataId in newDateEntries ); if (!newLayers.length) { // no layer merged, find defaults const result = addDefaultLayers(mergedState, newDateEntries); // const result = mergedState; mergedState = result.state; newLayers = result.newLayers; } // if (mergedState.widgets.length === state.widgets.length) { // mergedState = addDefaultWidget(mergedState, newDateEntries); // } if (mergedState.splitMaps.length) { // if map is split, add new layers to splitMaps newLayers = mergedState.layers.filter( l => l.config.dataId in newDateEntries ); mergedState = { ...mergedState, splitMaps: addNewLayersToSplitMap(mergedState.splitMaps, newLayers) }; } // console.log(mergedState); // merge state with saved interactions mergedState = mergeInteractions(mergedState, interactionToBeMerged); // merge state with saved widgets mergedState = mergeWidgets(mergedState, widgetToBeMerged); // if no tooltips merged add default tooltips Object.keys(newDateEntries).forEach(dataId => { const tooltipFields = mergedState.interactionConfig.tooltip.config.fieldsToShow[dataId]; if (!Array.isArray(tooltipFields) || !tooltipFields.length) { mergedState = addDefaultTooltips(mergedState, newDateEntries[dataId]); } }); let updatedState = updateAllLayerDomainData( mergedState, Object.keys(newDateEntries) ); // register layer animation domain, // need to be called after layer data is calculated updatedState = updateAnimationDomain(updatedState); return updatedState; }; /* eslint-enable max-statements */ /** * Hide an existing layers from custom map layer objects * @param state * @param widget * @returns {[*,*]} Maps of custom layer objects */ function toggleWidgetFromSplitMaps(state, layer) { // console.log('0'); return state.splitMaps.map(settings => { const {widgets} = settings; const newWidgets = { ...widgets, [widget.id]: generateLayerMetaForSplitViews(widget) }; return { ...settings, widgets: newWidgets }; }); } /** * 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 */ 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 * @param {Object} state `visState` * @param {Object} action action * @param {Array<Object>} action.files array of fileblob * @returns {Object} nextState * @public */ export const loadFilesUpdater = (state, action) => { const {files} = action; const filesToLoad = files.map(fileBlob => processFileToLoad(fileBlob)); // reader -> parser -> augment -> receiveVisData // console.log('LOAD_FILE_TASK inside loadFilesUpdater', LOAD_FILE_TASK); const loadFileTasks = [ Task.all(filesToLoad.map(LOAD_FILE_TASK)).bimap(results => { const data = results.reduce( (f, c) => ({ // using concat here because the current datasets could be an array or a single item datasets: f.datasets.concat(c.datasets), // we need to deep merge this thing unless we find a better solution // this case will only happen if we allow to load multiple keplergl json files config: { ...f.config, ...(c.config || {}) } }), {datasets: [], config: {}, options: {centerMap: true}} ); return addDataToMap(data); }, loadFilesErr) ]; // console.log('loadFileTasks inside loadFilesUpdater', loadFileTasks); return withTask(state, loadFileTasks); }; /** * Trigger loading file error * @memberof visStateUpdaters * @param {Object} state `visState` * @param {Object} action action * @param {*} action.error * @returns {Object} nextState * @public */ export const loadFilesErrUpdater = (state, {error}) => ({ ...state, fileLoading: false, fileLoadingErr: error }); /** * * @param {object} state `visState` * @param {object} action action * @param {Text<Object>} action.URL url of mapBox vector tiles * @returns {Object} nextState * @public */ export function loadURLUpdater(state, action) { const {URL, range} = action; var newLayer; if (URL) { const dataId = generateHashId(4); const datasets = { [dataId]: { URL, populationRange: range, fields: [ // { // format: '', // id: '_geojson', // name: '_geojson', // tableFieldIndex: 1, // type: 'geojson' // }, // { // format: '', // id: 'population', // name: 'Population', // tableFieldIndex: 2, // type: 'integer' // } ], id: dataId, externalData: true, color: getNewDatasetColor(state.datasets), label: 'population data' } }; state = {...state, ...state.datasets, datasets}; newLayer = new state.layerClasses.MVT({ isVisible: true, isConfigActive: true, dataId, url: URL }); // newLayer = new state.layerClasses['mvt'](); } return { ...state, layers: [...state.layers, newLayer], layerData: [...state.layerData, {}], layerOrder: [...state.layerOrder, state.layerOrder.length], splitMaps: addNewLayersToSplitMap(state.splitMaps, newLayer) }; } export const loadEDLinkData = (state, action) => { const {data, dataId, idx} = action; const dataset = processGeojson(data); const validatedData = validateInputData(dataset); if (!validatedData) { return state; } const allData = validatedData.rows; const fields = validatedData.fields.map((f, i) => ({ ...f, id: f.name, tableFieldIndex: i + 1 })); // const idx = state.layers.findIndex(l => l.config.dataId === dataId); if (state.fil