From 96f7ebaa0cffdfb106aa11e133d9d39d0cc7e730 Mon Sep 17 00:00:00 2001 From: Ilya Boyandin Date: Tue, 7 May 2024 12:22:01 +0200 Subject: [PATCH] [chore] Add several vis state mergers combineConfigs and improve TS Signed-off-by: Ihor Dykhta --- src/reducers/src/merger-handler.ts | 10 +- src/reducers/src/vis-state-merger.ts | 153 ++++++++++++- src/schemas/src/vis-state-schema.ts | 1 + src/types/schemas.d.ts | 10 +- src/utils/src/aggregate-utils.ts | 10 +- ...-state-merger-combine-configs-test.spec.js | 208 ++++++++++++++++++ 6 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 test/node/reducers/vis-state-merger-combine-configs-test.spec.js diff --git a/src/reducers/src/merger-handler.ts b/src/reducers/src/merger-handler.ts index 848242518c..ff8591d624 100644 --- a/src/reducers/src/merger-handler.ts +++ b/src/reducers/src/merger-handler.ts @@ -28,7 +28,7 @@ function callFunctionGetTask(fn: () => any): [any, any] { export function mergeStateFromMergers( state: State, initialState: State, - mergers: Merger[], + mergers: Merger[], postMergerPayload: PostMergerPayload ): { mergedState: State; @@ -79,7 +79,7 @@ export function mergeStateFromMergers( export function hasPropsToMerge( state: State, - mergerProps: string | string[] + mergerProps?: string | string[] ): boolean { return Array.isArray(mergerProps) ? Boolean(mergerProps.some(p => Object.prototype.hasOwnProperty.call(state, p))) @@ -88,15 +88,15 @@ export function hasPropsToMerge( export function getPropValueToMerger( state: State, - mergerProps: string | string[], + mergerProps?: string | string[], toMergeProps?: string | string[] ): Partial | ValueOf { - return Array.isArray(mergerProps) + return Array.isArray(mergerProps) && Array.isArray(toMergeProps) ? mergerProps.reduce((accu, p, i) => { if (!toMergeProps) return accu; return {...accu, [toMergeProps[i]]: state[p]}; }, {}) - : state[mergerProps]; + : state[mergerProps as string]; } export function resetStateToMergeProps( diff --git a/src/reducers/src/vis-state-merger.ts b/src/reducers/src/vis-state-merger.ts index e89b78a09c..96cf39a0a8 100644 --- a/src/reducers/src/vis-state-merger.ts +++ b/src/reducers/src/vis-state-merger.ts @@ -10,13 +10,15 @@ import { getInitialMapLayersForSplitMap, applyFiltersToDatasets, validateFiltersUpdateDatasets, - findById + findById, + aggregate, + notNullorUndefined } from '@kepler.gl/utils'; import {getLayerOrderFromLayers} from '@kepler.gl/reducers'; import {LayerColumns, LayerColumn, Layer} from '@kepler.gl/layers'; import {createEffect} from '@kepler.gl/effects'; -import {LAYER_BLENDINGS, OVERLAY_BLENDINGS} from '@kepler.gl/constants'; +import {AGGREGATION_TYPES, LAYER_BLENDINGS, OVERLAY_BLENDINGS} from '@kepler.gl/constants'; import {CURRENT_VERSION, VisState, VisStateMergers, KeplerGLSchemaClass} from '@kepler.gl/schemas'; import { @@ -28,7 +30,9 @@ import { ParsedConfig, Filter, Effect as EffectType, - ParsedEffect + ParsedEffect, + NestedPartial, + SavedAnimationConfig } from '@kepler.gl/types'; import {KeplerTable, Datasets, assignGpuChannels, resetFilterGpuMode} from '@kepler.gl/table'; @@ -305,7 +309,7 @@ export function mergeInteractions( state: S, interactionToBeMerged: Partial | undefined ): S { - const merged: Partial = {}; + const merged: NestedPartial = {}; const unmerged: Partial = {}; if (interactionToBeMerged) { @@ -370,6 +374,64 @@ export function mergeInteractions( return nextState; } +function combineInteractionConfigs(configs: SavedInteractionConfig[]): SavedInteractionConfig { + const combined = {...configs[0]}; + // handle each property key of an `InteractionConfig`, e.g. tooltip, geocoder, brush, coordinate + // by combining values for each among all passed in configs + + for (const key in combined) { + const toBeCombinedProps = configs.map(c => c[key]); + + // all of these have an enabled boolean + combined[key] = { + // are any of the configs' enabled values true? + enabled: toBeCombinedProps.some(p => p?.enabled) + }; + + if (key === 'tooltip') { + // are any of the configs' compareMode values true? + combined[key].compareMode = toBeCombinedProps.some(p => p?.compareMode); + + // return the compare type mode, it will be either absolute or relative + combined[key].compareType = getValueWithHighestOccurrence( + toBeCombinedProps.map(p => p.compareType) + ); + + // combine fieldsToShow among all dataset ids + combined[key].fieldsToShow = toBeCombinedProps + .map(p => p.fieldsToShow) + .reduce((acc, nextFieldsToShow) => { + for (const nextDataIdKey in nextFieldsToShow) { + const nextTooltipFields = nextFieldsToShow[nextDataIdKey]; + if (!acc[nextDataIdKey]) { + // if the dataset id is not present in the accumulator + // then add it with its tooltip fields + acc[nextDataIdKey] = nextTooltipFields; + } else { + // otherwise the dataset id is already present in the accumulator + // so only add the next tooltip fields for this dataset's array if they are not already present, + // using the tooltipField.name property for uniqueness + nextTooltipFields.forEach(nextTF => { + if (!acc[nextDataIdKey].find(({name}) => nextTF.name === name)) { + acc[nextDataIdKey].push(nextTF); + } + }); + } + } + return acc; + }, {}); + } + + if (key === 'brush') { + // keep the biggest brush size + combined[key].size = + aggregate(toBeCombinedProps, AGGREGATION_TYPES.maximum, p => p.size) ?? null; + } + } + + return combined; +} + function savedUnmergedInteraction( state: S, unmerged: Partial @@ -405,6 +467,7 @@ function replaceInteractionDatasetIds(interactionConfig, dataId: string, dataIdT } return null; } + /** * Merge splitMaps config with current visStete. * 1. if current map is split, but splitMap DOESNOT contain maps @@ -542,6 +605,15 @@ export function mergeLayerBlending( return state; } +/** + * Combines multiple layer blending configs into a single string + * by returning the one with the highest occurrence + */ +function combineLayerBlendingConfigs(configs: string[]): string | null { + // return the mode of the layer blending type + return getValueWithHighestOccurrence(configs); +} + /** * Merge overlayBlending with saved */ @@ -559,6 +631,15 @@ export function mergeOverlayBlending( return state; } +/** + * Combines multiple overlay blending configs into a single string + * by returning the one with the highest occurrence + **/ +function combineOverlayBlendingConfigs(configs: string[]): string | null { + // return the mode of the overlay blending type + return getValueWithHighestOccurrence(configs); +} + /** * Merge animation config */ @@ -580,6 +661,14 @@ export function mergeAnimationConfig( return state; } +function combineAnimationConfigs(configs: SavedAnimationConfig[]): SavedAnimationConfig { + // get the smallest values of currentTime and speed among all configs + return { + currentTime: aggregate(configs, AGGREGATION_TYPES.minimum, c => c.currentTime) ?? null, + speed: aggregate(configs, AGGREGATION_TYPES.minimum, c => c.speed) ?? null + }; +} + /** * Validate saved layer columns with new data, * update fieldIdx based on new fields @@ -875,6 +964,24 @@ export function mergeEditor(state: S, savedEditor: SavedEdit }; } +function combineEditorConfigs(configs: SavedEditor[]): SavedEditor { + return configs.reduce( + (acc, nextConfig) => { + return { + ...acc, + features: [...acc.features, ...(nextConfig.features || [])] + }; + }, + { + // start with: + // - empty array for features accumulation + // - and are any of the configs' visible values true? + features: [], + visible: configs.some(c => c?.visible) + } + ); +} + /** * Validate saved layer config with new data, * update fieldIdx based on new fields @@ -902,6 +1009,29 @@ export function mergeDatasetsByOrder(state: VisState, newDataEntries: Datasets): return merged; } +/** + * Simliar purpose to aggregation utils `getMode` function, + * but returns the mode in the same value type without coercing to a string. + * It ignores `undefined` or `null` values, but returns `null` if no mode could be calculated. + */ +function getValueWithHighestOccurrence(arr: T[]): T | null { + const tallys = new Map(); + arr.forEach(value => { + if (notNullorUndefined(value)) { + if (!tallys.has(value)) { + tallys.set(value, 1); + } else { + tallys.set(value, tallys.get(value) + 1); + } + } + }); + // return the value with the highest total occurrence count + if (tallys.size === 0) { + return null; + } + return [...tallys.entries()]?.reduce((acc, next) => (next[1] > acc[1] ? next : acc))[0]; +} + export const VIS_STATE_MERGERS: VisStateMergers = [ { merge: mergeLayers, @@ -925,11 +1055,16 @@ export const VIS_STATE_MERGERS: VisStateMergers = [ prop: 'interactionConfig', toMergeProp: 'interactionToBeMerged', replaceParentDatasetIds: replaceInteractionDatasetIds, - saveUnmerged: savedUnmergedInteraction + saveUnmerged: savedUnmergedInteraction, + combineConfigs: combineInteractionConfigs + }, + {merge: mergeLayerBlending, prop: 'layerBlending', combineConfigs: combineLayerBlendingConfigs}, + { + merge: mergeOverlayBlending, + prop: 'overlayBlending', + combineConfigs: combineOverlayBlendingConfigs }, - {merge: mergeLayerBlending, prop: 'layerBlending'}, - {merge: mergeOverlayBlending, prop: 'overlayBlending'}, {merge: mergeSplitMaps, prop: 'splitMaps', toMergeProp: 'splitMapsToBeMerged'}, - {merge: mergeAnimationConfig, prop: 'animationConfig'}, - {merge: mergeEditor, prop: 'editor'} + {merge: mergeAnimationConfig, prop: 'animationConfig', combineConfigs: combineAnimationConfigs}, + {merge: mergeEditor, prop: 'editor', combineConfigs: combineEditorConfigs} ]; diff --git a/src/schemas/src/vis-state-schema.ts b/src/schemas/src/vis-state-schema.ts index 6133c72015..eed6cb6f72 100644 --- a/src/schemas/src/vis-state-schema.ts +++ b/src/schemas/src/vis-state-schema.ts @@ -116,6 +116,7 @@ export type Merger = { waitForLayerData?: boolean; replaceParentDatasetIds?: ReplaceParentDatasetIdsFunc>; saveUnmerged?: (state: S, unmerged: any) => S; + combineConfigs?: (configs: S[]) => S; getChildDatasetIds?: any; }; export type VisStateMergers = Merger[]; diff --git a/src/types/schemas.d.ts b/src/types/schemas.d.ts index 13ed1689ef..e78edf381d 100644 --- a/src/types/schemas.d.ts +++ b/src/types/schemas.d.ts @@ -3,7 +3,7 @@ import {RGBColor, Merge, RequireFrom} from './types'; -import {Filter, TooltipInfo, AnimationConfig, SplitMap, Feature} from './reducers'; +import {Filter, InteractionConfig, AnimationConfig, SplitMap, Feature} from './reducers'; import {LayerTextLabel} from './layers'; @@ -28,16 +28,16 @@ export type MinSavedFilter = RequireFrom d; export const getFrequency = data => data.reduce( @@ -21,7 +23,11 @@ export const getMode = data => { ); }; -export function aggregate(data, technique) { +export function aggregate( + data: any[], + technique: ValueOf, + accessor: (any: any) => any = identity +): any { switch (technique) { case AGGREGATION_TYPES.average: return mean(data); diff --git a/test/node/reducers/vis-state-merger-combine-configs-test.spec.js b/test/node/reducers/vis-state-merger-combine-configs-test.spec.js new file mode 100644 index 0000000000..fa01ab56d9 --- /dev/null +++ b/test/node/reducers/vis-state-merger-combine-configs-test.spec.js @@ -0,0 +1,208 @@ +import {VIS_STATE_MERGERS} from '@kepler.gl/reducers'; + +const TEST_CASES = [ + { + testMessage: + "interactionConfig (with input configs' tooltips.fieldsToShow with no repeated fields)", + propName: 'interactionConfig', + configsToMerge: [ + { + tooltip: { + enabled: true, + fieldsToShow: { + 'point-dataset': [ + {name: 'DateTime', format: null}, + {name: 'Latitude', format: null}, + {name: 'Longitude', format: null} + ], + 'trip-dataset': [{name: 'vendor', format: null}] + }, + compareMode: true, + compareType: 'absolute' + }, + geocoder: {enabled: true}, + brush: {enabled: true, size: 0.5}, + coordinate: {enabled: false} + }, + { + tooltip: { + enabled: true, + fieldsToShow: { + 'trip-dataset': [{name: 'customer', format: null}] + }, + compareMode: true, + compareType: 'relative' + }, + geocoder: {enabled: true}, + brush: {enabled: true, size: 2}, + coordinate: {enabled: false} + }, + { + tooltip: { + enabled: true, + fieldsToShow: {}, + compareMode: true, + compareType: 'relative' + }, + geocoder: {enabled: false}, + brush: {enabled: true, size: 3}, + coordinate: {enabled: false} + } + ], + expected: { + tooltip: { + enabled: true, + fieldsToShow: { + 'point-dataset': [ + {name: 'DateTime', format: null}, + {name: 'Latitude', format: null}, + {name: 'Longitude', format: null} + ], + 'trip-dataset': [ + {name: 'vendor', format: null}, + {name: 'customer', format: null} + ] + }, + compareMode: true, + compareType: 'relative' + }, + geocoder: {enabled: true}, + brush: {enabled: true, size: 3}, + coordinate: {enabled: false} + } + }, + { + testMessage: + "interactionConfig (with input configs' tooltips.fieldsToShow with some repeated fields)", + propName: 'interactionConfig', + configsToMerge: [ + { + tooltip: { + enabled: true, + fieldsToShow: { + 'point-dataset': [ + {name: 'DateTime', format: null}, + {name: 'Latitude', format: null}, + {name: 'Longitude', format: null} + ], + 'trip-dataset': [{name: 'vendor', format: null}] + }, + compareMode: true, + compareType: 'absolute' + }, + geocoder: {enabled: true}, + brush: {enabled: true, size: 0.5}, + coordinate: {enabled: false} + }, + { + tooltip: { + enabled: true, + fieldsToShow: { + 'trip-dataset': [ + {name: 'customer', format: null}, + {name: 'vendor', format: null} + ], + 'point-dataset': [ + {name: 'DateTime', format: null}, + {name: 'Latitude', format: null}, + {name: 'Longitude', format: null} + ] + }, + compareMode: true, + compareType: 'relative' + }, + geocoder: {enabled: true}, + brush: {enabled: true, size: 2}, + coordinate: {enabled: false} + }, + { + tooltip: { + enabled: true, + fieldsToShow: {}, + compareMode: true, + compareType: 'relative' + }, + geocoder: {enabled: false}, + brush: {enabled: true, size: 3}, + coordinate: {enabled: false} + } + ], + expected: { + tooltip: { + enabled: true, + fieldsToShow: { + 'point-dataset': [ + {name: 'DateTime', format: null}, + {name: 'Latitude', format: null}, + {name: 'Longitude', format: null} + ], + 'trip-dataset': [ + {name: 'vendor', format: null}, + {name: 'customer', format: null} + ] + }, + compareMode: true, + compareType: 'relative' + }, + geocoder: {enabled: true}, + brush: {enabled: true, size: 3}, + coordinate: {enabled: false} + } + }, + { + testMessage: 'layerBlending (with a majority of "additive" values)', + propName: 'layerBlending', + configsToMerge: ['normal', 'additive', 'additive'], + expected: 'additive' + }, + { + testMessage: 'layerBlending (with all undefined or null values)', + propName: 'layerBlending', + configsToMerge: [undefined, null, undefined], + expected: null + }, + { + testMessage: 'overlayBlending (with 3 unique values)', + propName: 'overlayBlending', + configsToMerge: ['normal', 'screen', 'darken'], + expected: 'normal' + }, + { + testMessage: 'overlayBlending (with some undefined values)', + propName: 'overlayBlending', + configsToMerge: [undefined, 'darken', undefined, 'darken', 'screen', undefined], + expected: 'darken' + }, + { + testMessage: 'animationConfig', + propName: 'animationConfig', + configsToMerge: [ + {currentTime: 500, speed: 1}, + {currentTime: 100, speed: 5} + ], + expected: {currentTime: 100, speed: 1} + }, + { + testMessage: 'editor', + propName: 'editor', + configsToMerge: [ + {features: [], visible: undefined}, + {features: [{foo: 'bar'}], visible: true}, + {features: [{foo: 'bar'}, {abc: 'def'}], visible: false} + ], + expected: { + features: [{foo: 'bar'}, {foo: 'bar'}, {abc: 'def'}], + visible: true + } + } +]; + +describe('VisStateMergers: combineConfigs', () => { + test.each(TEST_CASES)('$testMessage', ({propName, configsToMerge, expected}) => { + const {combineConfigs} = VIS_STATE_MERGERS.find( + m => m.prop === propName && typeof m.combineConfigs === 'function' + ); + + expect(combineConfigs(configsToMerge)).toEqual(expected); + }); +});