src/reducers/vis-state-merger.js (329 lines of code) (raw):

// Copyright (c) 2020 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import uniq from 'lodash.uniq'; import pick from 'lodash.pick'; import isEqual from 'lodash.isequal'; import flattenDeep from 'lodash.flattendeep'; import {toArray, isObject} from 'utils/utils'; import { applyFiltersToDatasets, mergeFilterDomainStep, validateFilterWithData } 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'; /** * 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) { const merged = []; const unmerged = []; const {datasets} = state; let updatedDatasets = datasets; if (!Array.isArray(filtersToMerge) || !filtersToMerge.length) { return state; } // merge filters filtersToMerge.forEach(filter => { // we can only look for datasets define in the filter dataId const datasetIds = toArray(filter.dataId); // we can merge a filter only if all datasets in filter.dataId are loaded if (datasetIds.every(d => datasets[d])) { // all datasetIds in filter must be present the state datasets const {filter: validatedFilter, applyToDatasets, augmentedDatasets} = datasetIds.reduce( (acc, datasetId) => { const dataset = updatedDatasets[datasetId]; const layers = state.layers.filter(l => l.config.dataId === dataset.id); const {filter: updatedFilter, dataset: updatedDataset} = validateFilterWithData( acc.augmentedDatasets[datasetId] || dataset, filter, layers ); if (updatedFilter) { return { ...acc, // merge filter props filter: acc.filter ? { ...acc.filter, ...mergeFilterDomainStep(acc, updatedFilter) } : updatedFilter, applyToDatasets: [...acc.applyToDatasets, datasetId], augmentedDatasets: { ...acc.augmentedDatasets, [datasetId]: updatedDataset } }; } return acc; }, { filter: null, applyToDatasets: [], augmentedDatasets: {} } ); if (validatedFilter && isEqual(datasetIds, applyToDatasets)) { merged.push(validatedFilter); updatedDatasets = { ...updatedDatasets, ...augmentedDatasets }; } } else { unmerged.push(filter); } }); // merge filter with existing let updatedFilters = [...(state.filters || []), ...merged]; updatedFilters = resetFilterGpuMode(updatedFilters); updatedFilters = assignGpuChannels(updatedFilters); // filter data const datasetsToFilter = uniq(flattenDeep(merged.map(f => f.dataId))); const filtered = applyFiltersToDatasets( datasetsToFilter, updatedDatasets, updatedFilters, state.layers ); return { ...state, filters: updatedFilters, datasets: filtered, filterToBeMerged: [...state.filterToBeMerged, ...unmerged] }; } /** * 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) { const mergedLayer = []; const unmerged = []; const {datasets} = state; if (!Array.isArray(layersToMerge) || !layersToMerge.length) { return state; } layersToMerge.forEach(layer => { if (datasets[layer.config.dataId]) { // datasets are already loaded const validateLayer = validateLayerWithData( datasets[layer.config.dataId], layer, state.layerClasses ); if (validateLayer) { mergedLayer.push(validateLayer); } } else { // datasets not yet loaded unmerged.push(layer); } }); const layers = [...state.layers, ...mergedLayer]; const newLayerOrder = mergedLayer.map((_, i) => state.layers.length + i); // put new layers in front of current layers const layerOrder = [...newLayerOrder, ...state.layerOrder]; return { ...state, layers, layerOrder, layerToBeMerged: [...state.layerToBeMerged, ...unmerged] }; } /** * 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) { const colFound = {}; // find actual column fieldIdx, in case it has changed const allColFound = Object.keys(emptyCols).every(key => { const saved = savedCols[key]; colFound[key] = {...emptyCols[key]}; // TODO: replace with new approach const fieldIdx = fields.findIndex(({name}) => name === saved); if (fieldIdx > -1) { // update found columns colFound[key].fieldIdx = fieldIdx; colFound[key].value = saved; return true; } // if col is optional, allow null value return emptyCols[key].optional || false; }); return allColFound && colFound; } /** * 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 * * @param {Array<Object>} fields * @param {Object} newLayer * @param {Object} savedLayer * @return {Object} - newLayer */ export function validateSavedVisualChannels(fields, newLayer, savedLayer) { Object.values(newLayer.visualChannels).forEach(({field, scale, key}) => { let foundField; if (savedLayer.config[field]) { foundField = fields.find(fd => Object.keys(savedLayer.config[field]).every( prop => savedLayer.config[field][prop] === fd[prop] ) ); } 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; } /** * Validate saved layer config with new data, * update fieldIdx based on new fields * @param {object} dataset * @param {Array<Object>} dataset.fields * @param {string} dataset.id * @param {Object} savedLayer * @param {Object} layerClasses * @return {null | Object} - validated layer or null */ export function validateLayerWithData({fields, id: dataId}, savedLayer, layerClasses) { const {type} = savedLayer; // layer doesnt have a valid type if (!layerClasses.hasOwnProperty(type) || !savedLayer.config || !savedLayer.config.columns) { 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 }); // find column fieldIdx const columns = validateSavedLayerColumns( fields, savedLayer.config.columns, newLayer.getLayerColumns() ); if (!columns) { 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({ columns, 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'} ];