From 20bbdaeade4134fef0e0f486af3693bf348733d4 Mon Sep 17 00:00:00 2001 From: Robert Monfera Date: Wed, 10 Feb 2021 10:57:17 +0100 Subject: [PATCH] feat(partition): drilldown (#995) Co-authored-by: Nick Partridge --- .playground/playground.tsx | 2 +- api/charts.api.md | 6 +-- .../partition_chart/layout/config.ts | 4 ++ .../layout/types/config_types.ts | 4 ++ .../layout/utils/group_by_rollup.ts | 49 ++++++++++++------- .../viewmodel/hierarchy_of_arrays.test.ts | 2 +- .../layout/viewmodel/hierarchy_of_arrays.ts | 9 +++- .../partition_chart/state/selectors/tree.ts | 22 +++++++-- src/state/chart_state.ts | 5 +- src/state/reducers/interactions.ts | 22 +++++++-- stories/icicle/01_unix_icicle.tsx | 2 +- stories/icicle/02_unix_flame.tsx | 2 +- 12 files changed, 93 insertions(+), 36 deletions(-) diff --git a/.playground/playground.tsx b/.playground/playground.tsx index b693e6ca70..26d1e2d7b5 100644 --- a/.playground/playground.tsx +++ b/.playground/playground.tsx @@ -19,7 +19,7 @@ import React from 'react'; -import { Example } from '../stories/sunburst/9_sunburst_three_layers'; +import { Example } from '../stories/icicle/02_unix_flame'; export class Playground extends React.Component { render() { diff --git a/api/charts.api.md b/api/charts.api.md index 5585d8c377..d9d27b7e21 100644 --- a/api/charts.api.md +++ b/api/charts.api.md @@ -1435,7 +1435,7 @@ export const Partition: React.FunctionComponent; @@ -2346,8 +2346,8 @@ export type YDomainRange = YDomainBase & DomainRange; // src/chart_types/heatmap/layout/types/config_types.ts:29:13 - (ae-forgotten-export) The symbol "SizeRatio" needs to be exported by the entry point index.d.ts // src/chart_types/heatmap/layout/types/config_types.ts:61:5 - (ae-forgotten-export) The symbol "TextAlign" needs to be exported by the entry point index.d.ts // src/chart_types/heatmap/layout/types/config_types.ts:62:5 - (ae-forgotten-export) The symbol "TextBaseline" needs to be exported by the entry point index.d.ts -// src/chart_types/partition_chart/layout/types/config_types.ts:126:5 - (ae-forgotten-export) The symbol "TimeMs" needs to be exported by the entry point index.d.ts -// src/chart_types/partition_chart/layout/types/config_types.ts:127:5 - (ae-forgotten-export) The symbol "AnimKeyframe" needs to be exported by the entry point index.d.ts +// src/chart_types/partition_chart/layout/types/config_types.ts:130:5 - (ae-forgotten-export) The symbol "TimeMs" needs to be exported by the entry point index.d.ts +// src/chart_types/partition_chart/layout/types/config_types.ts:131:5 - (ae-forgotten-export) The symbol "AnimKeyframe" needs to be exported by the entry point index.d.ts // src/common/series_id.ts:40:3 - (ae-forgotten-export) The symbol "SeriesKey" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/chart_types/partition_chart/layout/config.ts b/src/chart_types/partition_chart/layout/config.ts index 337197d46e..36c8523632 100644 --- a/src/chart_types/partition_chart/layout/config.ts +++ b/src/chart_types/partition_chart/layout/config.ts @@ -181,6 +181,10 @@ export const configMetadata: Record = { type: 'string', values: Object.keys(PartitionLayout), }, + drilldown: { + dflt: false, + type: 'boolean', + }, // fill text layout config circlePadding: { dflt: 2, min: 0.0, max: 8, type: 'number' }, diff --git a/src/chart_types/partition_chart/layout/types/config_types.ts b/src/chart_types/partition_chart/layout/types/config_types.ts index 2938ae7cc1..0516baf287 100644 --- a/src/chart_types/partition_chart/layout/types/config_types.ts +++ b/src/chart_types/partition_chart/layout/types/config_types.ts @@ -88,6 +88,8 @@ export interface StaticConfig extends FillFontSizeRange { clockwiseSectors: boolean; specialFirstInnermostSector: boolean; partitionLayout: PartitionLayout; + /** @alpha */ + drilldown: boolean; // general text config fontFamily: FontFamily; @@ -115,6 +117,7 @@ export interface StaticConfig extends FillFontSizeRange { export type EasingFunction = (x: Ratio) => Ratio; +/** @alpha */ export interface AnimKeyframe { time: number; easingFunction: EasingFunction; @@ -122,6 +125,7 @@ export interface AnimKeyframe { } export interface Config extends StaticConfig { + /** @alpha */ animation: { duration: TimeMs; keyframes: Array; diff --git a/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts b/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts index 889f606344..b79cd4812f 100644 --- a/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts +++ b/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts @@ -108,6 +108,8 @@ export function groupByRollup( identity: () => any; }, factTable: Relation, + drilldown: boolean, + drilldownSelection: CategoryKey[], ): HierarchyOfMaps { const statistics: Statistics = { globalAggregate: NaN, @@ -115,26 +117,35 @@ export function groupByRollup( const reductionMap: HierarchyOfMaps = factTable.reduce((p: HierarchyOfMaps, n, index) => { const keyCount = keyAccessors.length; let pointer: HierarchyOfMaps = p; - keyAccessors.forEach((keyAccessor, i) => { - const key: Key = keyAccessor(n, index); - const last = i === keyCount - 1; - const node = pointer.get(key); - const inputIndices = node?.[INPUT_KEY] ?? []; - const childrenMap = node?.[CHILDREN_KEY] ?? new Map(); - const aggregate = node?.[AGGREGATE_KEY] ?? identity(); - const reductionValue = reducer(aggregate, valueAccessor(n)); - pointer.set(key, { - [AGGREGATE_KEY]: reductionValue, - [STATISTICS_KEY]: statistics, - [INPUT_KEY]: [...inputIndices, index], - [DEPTH_KEY]: i, - ...(!last && { [CHILDREN_KEY]: childrenMap }), + keyAccessors + .filter( + () => + !drilldown || + keyAccessors + .slice(0, drilldownSelection.length) + .map((keyAccessor) => keyAccessor(n, index)) + .join(' | ') === drilldownSelection.slice(0, drilldownSelection.length).join(' | '), + ) + .forEach((keyAccessor, i) => { + const key: Key = keyAccessor(n, index); + const last = i === keyCount - 1; + const node = pointer.get(key); + const inputIndices = node?.[INPUT_KEY] ?? []; + const childrenMap = node?.[CHILDREN_KEY] ?? new Map(); + const aggregate = node?.[AGGREGATE_KEY] ?? identity(); + const reductionValue = reducer(aggregate, valueAccessor(n)); + pointer.set(key, { + [AGGREGATE_KEY]: reductionValue, + [STATISTICS_KEY]: statistics, + [INPUT_KEY]: [...inputIndices, index], + [DEPTH_KEY]: i, + ...(!last && { [CHILDREN_KEY]: childrenMap }), + }); + if (childrenMap) { + // will always be true except when exiting from forEach, ie. upon encountering the leaf node + pointer = childrenMap; + } }); - if (childrenMap) { - // will always be true except when exiting from forEach, ie. upon encountering the leaf node - pointer = childrenMap; - } - }); return p; }, new Map()); if (reductionMap.get(HIERARCHY_ROOT_KEY) !== undefined) { diff --git a/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.test.ts b/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.test.ts index 9af02cb739..69b782b402 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.test.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.test.ts @@ -32,7 +32,7 @@ const groupByRollupAccessors = [() => null, (d: any) => d.sitc1]; describe('Test', () => { test('getHierarchyOfArrays should omit zero and negative values', () => { - const outerResult = getHierarchyOfArrays(rawFacts, valueAccessor, groupByRollupAccessors); + const outerResult = getHierarchyOfArrays(rawFacts, valueAccessor, groupByRollupAccessors, null, false, []); expect(outerResult.length).toBe(1); const results = outerResult[0]; diff --git a/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts b/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts index 9a73c149a4..cc1a1c8c81 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts @@ -17,6 +17,7 @@ * under the License. */ +import { CategoryKey } from '../../../../common/category'; import { LegendItemExtraValues } from '../../../../common/legend'; import { SeriesKey } from '../../../../common/series_id'; import { Relation } from '../../../../common/text_utils'; @@ -44,6 +45,8 @@ export function getHierarchyOfArrays( valueAccessor: ValueAccessor, groupByRollupAccessors: IndexedAccessorFn[], sorter: Sorter | null = childOrders.descending, + drilldown: boolean, + drilldownSelection: CategoryKey[], ): HierarchyOfArrays { const aggregator = aggregators.sum; @@ -61,7 +64,7 @@ export function getHierarchyOfArrays( // By introducing `scale`, we no longer need to deal with the dichotomy of // size as data value vs size as number of pixels in the rectangle return mapsToArrays( - groupByRollup(groupByRollupAccessors, valueAccessor, aggregator, facts), + groupByRollup(groupByRollupAccessors, valueAccessor, aggregator, facts, drilldown, drilldownSelection), sorter && aggregateComparator(mapEntryValue, sorter), ); } @@ -73,6 +76,8 @@ export function partitionTree( layers: Layer[], defaultLayout: PartitionLayout, layout: PartitionLayout = defaultLayout, + drilldown: boolean, + drilldownSelection: CategoryKey[], ) { const sorter = isTreemap(layout) || isSunburst(layout) ? childOrders.descending : null; return getHierarchyOfArrays( @@ -81,6 +86,8 @@ export function partitionTree( // eslint-disable-next-line no-shadow [() => HIERARCHY_ROOT_KEY, ...layers.map(({ groupByRollup }) => groupByRollup)], sorter, + drilldown, + drilldownSelection, ); } diff --git a/src/chart_types/partition_chart/state/selectors/tree.ts b/src/chart_types/partition_chart/state/selectors/tree.ts index 21d025fcab..1247ea0b17 100644 --- a/src/chart_types/partition_chart/state/selectors/tree.ts +++ b/src/chart_types/partition_chart/state/selectors/tree.ts @@ -19,21 +19,33 @@ import createCachedSelector from 're-reselect'; +import { CategoryKey } from '../../../../common/category'; +import { GlobalChartState } from '../../../../state/chart_state'; import { configMetadata } from '../../layout/config'; import { HierarchyOfArrays } from '../../layout/utils/group_by_rollup'; import { partitionTree } from '../../layout/viewmodel/hierarchy_of_arrays'; import { PartitionSpec } from '../../specs'; import { getPartitionSpecs } from './get_partition_specs'; -function getTreeForSpec(spec: PartitionSpec) { +function getTreeForSpec(spec: PartitionSpec, drilldownSelection: CategoryKey[]) { const { data, valueAccessor, layers, config } = spec; - return partitionTree(data, valueAccessor, layers, configMetadata.partitionLayout.dflt, config.partitionLayout); + return partitionTree( + data, + valueAccessor, + layers, + configMetadata.partitionLayout.dflt, + config.partitionLayout, + Boolean(config.drilldown), + drilldownSelection, + ); } +const getDrilldownSelection = (state: GlobalChartState) => state.interactions.drilldown || []; + /** @internal */ export const getTree = createCachedSelector( - [getPartitionSpecs], - (partitionSpecs): HierarchyOfArrays => { - return partitionSpecs.length > 0 ? getTreeForSpec(partitionSpecs[0]) : []; // singleton! + [getPartitionSpecs, getDrilldownSelection], + (partitionSpecs, drilldownSelection): HierarchyOfArrays => { + return partitionSpecs.length > 0 ? getTreeForSpec(partitionSpecs[0], drilldownSelection) : []; // singleton! }, )((state) => state.chartId); diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index a88e346fc7..8e0233e0d5 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -25,6 +25,7 @@ import { HeatmapState } from '../chart_types/heatmap/state/chart_state'; import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; import { PartitionState } from '../chart_types/partition_chart/state/chart_state'; import { XYAxisChartState } from '../chart_types/xy_chart/state/chart_state'; +import { CategoryKey } from '../common/category'; import { LegendItem, LegendItemExtraValues } from '../common/legend'; import { SeriesIdentifier, SeriesKey } from '../common/series_id'; import { TooltipAnchorPosition, TooltipInfo } from '../components/tooltip/types'; @@ -186,6 +187,7 @@ export interface InteractionsState { highlightedLegendPath: LegendPath; deselectedDataSeries: SeriesIdentifier[]; hoveredDOMElement: DOMElement | null; + drilldown: CategoryKey[]; } /** @internal */ @@ -275,6 +277,7 @@ export const getInitialState = (chartId: string): GlobalChartState => ({ highlightedLegendPath: [], deselectedDataSeries: [], hoveredDOMElement: null, + drilldown: [], }, externalEvents: { pointer: null, @@ -391,7 +394,7 @@ export const chartStoreReducer = (chartId: string) => { return getInternalIsInitializedSelector(state) === InitStatus.Initialized ? { ...state, - interactions: interactionsReducer(state.interactions, action, getLegendItemsSelector(state)), + interactions: interactionsReducer(state, action, getLegendItemsSelector(state)), } : state; } diff --git a/src/state/reducers/interactions.ts b/src/state/reducers/interactions.ts index 5f26b693c9..c31580e225 100644 --- a/src/state/reducers/interactions.ts +++ b/src/state/reducers/interactions.ts @@ -17,9 +17,12 @@ * under the License. */ +import { ChartTypes } from '../../chart_types'; +import { getPickedShapesLayerValues } from '../../chart_types/partition_chart/state/selectors/picked_shapes'; import { getSeriesIndex } from '../../chart_types/xy_chart/utils/series'; import { LegendItem } from '../../common/legend'; import { SeriesIdentifier } from '../../common/series_id'; +import { LayerValue } from '../../specs'; import { getDelta } from '../../utils/point'; import { DOMElementActions, ON_DOM_ELEMENT_ENTER, ON_DOM_ELEMENT_LEAVE } from '../actions/dom_element'; import { KeyActions, ON_KEY_UP } from '../actions/key'; @@ -31,7 +34,7 @@ import { ToggleDeselectSeriesAction, } from '../actions/legend'; import { MouseActions, ON_MOUSE_DOWN, ON_MOUSE_UP, ON_POINTER_MOVE } from '../actions/mouse'; -import { InteractionsState } from '../chart_state'; +import { GlobalChartState, InteractionsState } from '../chart_state'; import { getInitialPointerState } from '../utils'; /** @@ -46,10 +49,11 @@ const DRAG_DETECTION_PIXEL_DELTA = 4; /** @internal */ export function interactionsReducer( - state: InteractionsState, + globalState: GlobalChartState, action: LegendActions | MouseActions | KeyActions | DOMElementActions, legendItems: LegendItem[], ): InteractionsState { + const { interactions: state } = globalState; switch (action.type) { case ON_KEY_UP: if (action.key === 'Escape') { @@ -81,6 +85,7 @@ export function interactionsReducer( case ON_MOUSE_DOWN: return { ...state, + drilldown: getDrilldownData(globalState), pointer: { ...state.pointer, dragging: false, @@ -169,7 +174,10 @@ export function interactionsReducer( } } -/** @internal */ +/** + * Helper functions that currently depend on chart type eg. xy or partition + */ + function toggleDeselectedDataSeries( { legendItemId: id, negate }: ToggleDeselectSeriesAction, deselectedDataSeries: SeriesIdentifier[], @@ -194,3 +202,11 @@ function toggleDeselectedDataSeries( } return [...deselectedDataSeries, id]; } + +function getDrilldownData(globalState: GlobalChartState) { + if (globalState.chartType !== ChartTypes.Partition) { + return []; + } + const layerValues: LayerValue[] = getPickedShapesLayerValues(globalState)[0]; + return layerValues ? layerValues[layerValues.length - 1].path.map((n) => n.value) : []; +} diff --git a/stories/icicle/01_unix_icicle.tsx b/stories/icicle/01_unix_icicle.tsx index d4bbecdf3b..8c4fde3696 100644 --- a/stories/icicle/01_unix_icicle.tsx +++ b/stories/icicle/01_unix_icicle.tsx @@ -36,7 +36,7 @@ export const Example = () => { valueAccessor={(d: Datum) => d.value as number} valueFormatter={() => ''} layers={getLayerSpec(color)} - config={{ ...config, partitionLayout: PartitionLayout.icicle }} + config={{ ...config, partitionLayout: PartitionLayout.icicle, drilldown: true }} /> ); diff --git a/stories/icicle/02_unix_flame.tsx b/stories/icicle/02_unix_flame.tsx index 3e60cb7eaf..3b117218af 100644 --- a/stories/icicle/02_unix_flame.tsx +++ b/stories/icicle/02_unix_flame.tsx @@ -43,7 +43,7 @@ export const Example = () => { valueAccessor={(d: Datum) => d.value as number} valueFormatter={() => ''} layers={getLayerSpec(color)} - config={{ ...config, partitionLayout: PartitionLayout.flame }} + config={{ ...config, partitionLayout: PartitionLayout.flame, drilldown: true }} /> );