From f810d94c03f91191e9d86d156b25db22be888a59 Mon Sep 17 00:00:00 2001 From: Robert Monfera Date: Tue, 19 Jan 2021 18:48:36 +0100 Subject: [PATCH] feat(partition): legend hover options (#978) --- .eslintrc.js | 2 +- api/charts.api.md | 16 ++- .../heatmap/state/selectors/compute_legend.ts | 1 + .../state/selectors/get_grid_full_height.ts | 4 +- .../layout/types/viewmodel_types.ts | 6 +- .../layout/utils/group_by_rollup.ts | 26 +++-- .../partition_chart/partition.test.tsx | 105 +++++++++++++++--- .../renderer/dom/highlighter_legend.tsx | 4 +- .../state/selectors/compute_legend.ts | 19 ++-- .../state/selectors/get_highlighted_shapes.ts | 82 ++++++++++++-- .../selectors/get_legend_items_labels.ts | 4 +- .../partition_chart/state/selectors/tree.ts | 4 +- .../xy_chart/legend/legend.test.ts | 6 + src/chart_types/xy_chart/legend/legend.ts | 2 + .../xy_chart/rendering/rendering.test.ts | 1 + .../xy_chart/state/chart_state.test.ts | 2 + .../xy_chart/state/utils/common.test.ts | 4 + src/commons/category.ts | 29 +++++ src/commons/legend.ts | 7 +- src/commons/series_id.ts | 3 +- src/components/legend/legend_item.tsx | 12 +- src/components/legend/style_utils.ts | 7 +- src/index.ts | 1 + src/specs/settings.tsx | 5 + src/state/actions/legend.ts | 13 ++- src/state/chart_state.ts | 6 +- src/state/reducers/interactions.ts | 5 +- src/utils/legend.ts | 26 +++++ stories/icicle/02_unix_flame.tsx | 10 +- stories/legend/10_sunburst.tsx | 13 ++- stories/sunburst/9_sunburst_three_layers.tsx | 2 +- 31 files changed, 347 insertions(+), 80 deletions(-) create mode 100644 src/commons/category.ts create mode 100644 src/utils/legend.ts diff --git a/.eslintrc.js b/.eslintrc.js index 5838f5f875..8de32ee315 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -107,7 +107,7 @@ module.exports = { 'no-bitwise': 0, 'no-void': 0, yoda: 0, - 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], + '@typescript-eslint/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 'no-restricted-globals': 0, 'no-case-declarations': 0, 'no-return-await': 0, diff --git a/api/charts.api.md b/api/charts.api.md index 38f8e4df3c..0128b08ef4 100644 --- a/api/charts.api.md +++ b/api/charts.api.md @@ -1063,6 +1063,19 @@ export interface LegendColorPickerProps { // @public (undocumented) export type LegendItemListener = (series: SeriesIdentifier | null) => void; +// @public (undocumented) +export const LegendStrategy: Readonly<{ + Node: "node"; + Path: "path"; + KeyInLayer: "keyInLayer"; + Key: "key"; + NodeWithDescendants: "nodeWithDescendants"; + PathWithDescendants: "pathWithDescendants"; +}>; + +// @public (undocumented) +export type LegendStrategy = $Values; + // Warning: (ae-missing-release-tag) "LegendStyle" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1605,6 +1618,7 @@ export interface SettingsSpec extends Spec { legendColorPicker?: LegendColorPicker; legendMaxDepth: number; legendPosition: Position; + legendStrategy?: LegendStrategy; minBrushDelta?: number; noResults?: ComponentType | ReactChild; // (undocumented) @@ -1965,7 +1979,7 @@ export type YDomainRange = YDomainBase & DomainRange; // src/chart_types/partition_chart/layout/types/config_types.ts:128: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:129:5 - (ae-forgotten-export) The symbol "AnimKeyframe" needs to be exported by the entry point index.d.ts // src/chart_types/partition_chart/specs/index.ts:48:13 - (ae-forgotten-export) The symbol "NodeColorAccessor" needs to be exported by the entry point index.d.ts -// src/commons/series_id.ts:39:3 - (ae-forgotten-export) The symbol "SeriesKey" needs to be exported by the entry point index.d.ts +// src/commons/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/heatmap/state/selectors/compute_legend.ts b/src/chart_types/heatmap/state/selectors/compute_legend.ts index 4b8fee12d8..7f0a2b3418 100644 --- a/src/chart_types/heatmap/state/selectors/compute_legend.ts +++ b/src/chart_types/heatmap/state/selectors/compute_legend.ts @@ -48,6 +48,7 @@ export const computeLegendSelector = createCachedSelector( seriesIdentifier, isSeriesHidden: deselectedDataSeries.some((dataSeries) => dataSeries.key === seriesIdentifier.key), isToggleable: true, + path: [{ index: 0, value: seriesIdentifier.key }], }; }); }, diff --git a/src/chart_types/heatmap/state/selectors/get_grid_full_height.ts b/src/chart_types/heatmap/state/selectors/get_grid_full_height.ts index 36bcb1a76e..9978cd2d50 100644 --- a/src/chart_types/heatmap/state/selectors/get_grid_full_height.ts +++ b/src/chart_types/heatmap/state/selectors/get_grid_full_height.ts @@ -22,7 +22,7 @@ import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getLegendSizeSelector } from '../../../../state/selectors/get_legend_size'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { Position } from '../../../../utils/commons'; +import { isHorizontalLegend } from '../../../../utils/legend'; import { Config } from '../../layout/types/config_types'; import { getHeatmapConfigSelector } from './get_heatmap_config'; import { getHeatmapTableSelector } from './get_heatmap_table'; @@ -54,7 +54,7 @@ export const getGridHeightParamsSelector = createCachedSelector( const xAxisHeight = visible ? fontSize : 0; const totalVerticalPadding = padding * 2; let legendHeight = 0; - if (showLegend && (legendPosition === Position.Top || legendPosition === Position.Bottom)) { + if (showLegend && isHorizontalLegend(legendPosition)) { legendHeight = maxLegendHeight ?? legendSize.height; } const verticalRemainingSpace = containerHeight - xAxisHeight - totalVerticalPadding - legendHeight; diff --git a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index 71e0da3210..e8dd7628c4 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -17,6 +17,8 @@ * under the License. */ +import { CategoryKey } from '../../../../commons/category'; +import { LegendPath } from '../../../../state/actions/legend'; import { Color } from '../../../../utils/commons'; import { config, ValueGetterName } from '../config/config'; import { ArrayNode, HierarchyOfArrays } from '../utils/group_by_rollup'; @@ -148,13 +150,13 @@ interface SectorGeomSpecY { y1px: Distance; } -export type DataName = any; // todo consider narrowing it to eg. primitives +export type DataName = CategoryKey; // todo consider narrowing it to eg. primitives export interface ShapeTreeNode extends TreeNode, SectorGeomSpecY { yMidPx: Distance; depth: number; sortIndex: number; - path: number[]; + path: LegendPath; dataName: DataName; value: number; parent: ArrayNode; 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 f39943ac4c..05aae6c1ad 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 @@ -17,6 +17,8 @@ * under the License. */ +import { CategoryKey } from '../../../../commons/category'; +import { LegendPath } from '../../../../state/actions/legend'; import { Datum } from '../../../../utils/commons'; import { Relation } from '../types/types'; @@ -46,7 +48,7 @@ export interface ArrayNode extends NodeDescriptor { [CHILDREN_KEY]: HierarchyOfArrays; [PARENT_KEY]: ArrayNode; [SORT_INDEX_KEY]: number; - [PATH_KEY]: number[]; + [PATH_KEY]: LegendPath; } type HierarchyOfMaps = Map; @@ -55,11 +57,12 @@ interface MapNode extends NodeDescriptor { [PARENT_KEY]?: ArrayNode; } -export type PrimitiveValue = string | number | null; // there could be more but sufficient for now -type Key = PrimitiveValue; +/** @internal */ +export const HIERARCHY_ROOT_KEY: Key = '__root_key__'; +export type PrimitiveValue = string | number | null; // there could be more but sufficient for now +type Key = CategoryKey; export type Sorter = (a: number, b: number) => number; - type NodeSorter = (a: ArrayEntry, b: ArrayEntry) => number; export const entryKey = ([key]: ArrayEntry) => key; @@ -126,8 +129,8 @@ export function groupByRollup( }); return p; }, new Map()); - if (reductionMap.get(null) !== void 0) { - statistics.globalAggregate = (reductionMap.get(null) as MapNode)[AGGREGATE_KEY]; + if (reductionMap.get(HIERARCHY_ROOT_KEY) !== undefined) { + statistics.globalAggregate = (reductionMap.get(HIERARCHY_ROOT_KEY) as MapNode)[AGGREGATE_KEY]; } return reductionMap; } @@ -139,11 +142,12 @@ function getRootArrayNode(): ArrayNode { [DEPTH_KEY]: NaN, [CHILDREN_KEY]: children, [INPUT_KEY]: [] as number[], - [PATH_KEY]: [] as number[], + [PATH_KEY]: [] as LegendPath, [SORT_INDEX_KEY]: 0, [STATISTICS_KEY]: { globalAggregate: 0 }, }; - return { ...bootstrap, [PARENT_KEY]: bootstrap } as ArrayNode; // TS doesn't yet handle bootstrapping but the `Omit` above retains guarantee for all props except `[PARENT_KEY` + (bootstrap as ArrayNode)[PARENT_KEY] = bootstrap as ArrayNode; + return bootstrap as ArrayNode; // TS doesn't yet handle bootstrapping but the `Omit` above retains guarantee for all props except `[PARENT_KEY]` } /** @internal */ @@ -180,9 +184,9 @@ export function mapsToArrays(root: HierarchyOfMaps, sorter: NodeSorter | null): }); }; // with the current algo, decreasing order is important const tree = groupByMap(root, getRootArrayNode()); - const buildPaths = ([, mapNode]: ArrayEntry, currentPath: number[]) => { - const newPath = [...currentPath, mapNode[SORT_INDEX_KEY]]; - mapNode[PATH_KEY] = newPath; + const buildPaths = ([key, mapNode]: ArrayEntry, currentPath: LegendPath) => { + const newPath = [...currentPath, { index: mapNode[SORT_INDEX_KEY], value: key }]; + mapNode[PATH_KEY] = newPath; // in-place mutation, so disabled `no-param-reassign` mapNode.children.forEach((entry) => buildPaths(entry, newPath)); }; buildPaths(tree[0], []); diff --git a/src/chart_types/partition_chart/partition.test.tsx b/src/chart_types/partition_chart/partition.test.tsx index a69a844928..05f257e8d5 100644 --- a/src/chart_types/partition_chart/partition.test.tsx +++ b/src/chart_types/partition_chart/partition.test.tsx @@ -23,6 +23,7 @@ import { MockSeriesSpec } from '../../mocks/specs'; import { MockStore } from '../../mocks/store'; import { GlobalChartState } from '../../state/chart_state'; import { LegendItemLabel } from '../../state/selectors/get_legend_items_labels'; +import { HIERARCHY_ROOT_KEY } from './layout/utils/group_by_rollup'; import { computeLegendSelector } from './state/selectors/compute_legend'; import { getLegendItemsLabels } from './state/selectors/get_legend_items_labels'; @@ -96,7 +97,10 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'A', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'A', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 0, value: 'A' }, + ], depth: 0, label: 'A', seriesIdentifier: { key: 'A', specId: 'spec1' }, @@ -104,7 +108,11 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'A', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'A', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 0, value: 'A' }, + { index: 0, value: 'A' }, + ], depth: 1, label: 'A', seriesIdentifier: { key: 'A', specId: 'spec1' }, @@ -112,7 +120,11 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'B', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'B', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 0, value: 'A' }, + { index: 1, value: 'B' }, + ], depth: 1, label: 'B', seriesIdentifier: { key: 'B', specId: 'spec1' }, @@ -120,7 +132,10 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'B', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'B', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 1, value: 'B' }, + ], depth: 0, label: 'B', seriesIdentifier: { key: 'B', specId: 'spec1' }, @@ -128,7 +143,11 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'A', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'A', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 1, value: 'B' }, + { index: 0, value: 'A' }, + ], depth: 1, label: 'A', seriesIdentifier: { key: 'A', specId: 'spec1' }, @@ -136,7 +155,11 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'B', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'B', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 1, value: 'B' }, + { index: 1, value: 'B' }, + ], depth: 1, label: 'B', seriesIdentifier: { key: 'B', specId: 'spec1' }, @@ -144,7 +167,10 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'C', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'C', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 2, value: 'C' }, + ], depth: 0, label: 'C', seriesIdentifier: { key: 'C', specId: 'spec1' }, @@ -152,7 +178,11 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'A', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'A', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 2, value: 'C' }, + { index: 0, value: 'A' }, + ], depth: 1, label: 'A', seriesIdentifier: { key: 'A', specId: 'spec1' }, @@ -160,7 +190,11 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'B', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'B', + path: [ + { index: 0, value: HIERARCHY_ROOT_KEY }, + { index: 2, value: 'C' }, + { index: 1, value: 'B' }, + ], depth: 1, label: 'B', seriesIdentifier: { key: 'B', specId: 'spec1' }, @@ -174,7 +208,16 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'A', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'A', + path: [ + { + index: 0, + value: HIERARCHY_ROOT_KEY, + }, + { + index: 0, + value: 'A', + }, + ], depth: 0, label: 'A', seriesIdentifier: { key: 'A', specId: 'spec1' }, @@ -182,7 +225,21 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'A', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'A', + path: [ + { + index: 0, + value: HIERARCHY_ROOT_KEY, + }, + { + index: 0, + value: 'A', + }, + { + index: 0, + value: 'A', + }, + ], + depth: 1, label: 'A', seriesIdentifier: { key: 'A', specId: 'spec1' }, @@ -196,7 +253,16 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'C', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'C', + path: [ + { + index: 0, + value: HIERARCHY_ROOT_KEY, + }, + { + index: 0, + value: 'C', + }, + ], depth: 0, label: 'C', seriesIdentifier: { key: 'C', specId: 'spec1' }, @@ -204,7 +270,20 @@ describe('Retain hierarchy even with arbitrary names', () => { { childId: 'B', color: 'rgba(128, 0, 0, 0.5)', - dataName: 'B', + path: [ + { + index: 0, + value: HIERARCHY_ROOT_KEY, + }, + { + index: 0, + value: 'C', + }, + { + index: 0, + value: 'B', + }, + ], depth: 1, label: 'B', seriesIdentifier: { key: 'B', specId: 'spec1' }, diff --git a/src/chart_types/partition_chart/renderer/dom/highlighter_legend.tsx b/src/chart_types/partition_chart/renderer/dom/highlighter_legend.tsx index 76dc11b118..334dceef8f 100644 --- a/src/chart_types/partition_chart/renderer/dom/highlighter_legend.tsx +++ b/src/chart_types/partition_chart/renderer/dom/highlighter_legend.tsx @@ -23,7 +23,7 @@ import { GlobalChartState } from '../../../../state/chart_state'; import { getChartContainerDimensionsSelector } from '../../../../state/selectors/get_chart_container_dimensions'; import { getInternalIsInitializedSelector, InitStatus } from '../../../../state/selectors/get_internal_is_intialized'; import { partitionGeometries } from '../../state/selectors/geometries'; -import { getHighlightedSectorsSelector } from '../../state/selectors/get_highlighted_shapes'; +import { legendHoverHighlightNodes } from '../../state/selectors/get_highlighted_shapes'; import { HighlighterComponent, HighlighterProps, DEFAULT_PROPS } from './highlighter'; const legendMapStateToProps = (state: GlobalChartState): HighlighterProps => { @@ -38,7 +38,7 @@ const legendMapStateToProps = (state: GlobalChartState): HighlighterProps => { config: { partitionLayout }, } = partitionGeometries(state); - const geometries = getHighlightedSectorsSelector(state); + const geometries = legendHoverHighlightNodes(state); const canvasDimension = getChartContainerDimensionsSelector(state); return { chartId, diff --git a/src/chart_types/partition_chart/state/selectors/compute_legend.ts b/src/chart_types/partition_chart/state/selectors/compute_legend.ts index a67fb9ff32..b9a52322f4 100644 --- a/src/chart_types/partition_chart/state/selectors/compute_legend.ts +++ b/src/chart_types/partition_chart/state/selectors/compute_legend.ts @@ -19,12 +19,13 @@ import createCachedSelector from 're-reselect'; +import { CategoryKey } from '../../../../commons/category'; import { LegendItem } from '../../../../commons/legend'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { identity, Position } from '../../../../utils/commons'; +import { identity } from '../../../../utils/commons'; +import { isHierarchicalLegend } from '../../../../utils/legend'; import { QuadViewModel } from '../../layout/types/viewmodel_types'; -import { PrimitiveValue } from '../../layout/utils/group_by_rollup'; import { map } from '../iterables'; import { partitionGeometries } from './geometries'; import { getPieSpec } from './pie_spec'; @@ -38,14 +39,14 @@ export const computeLegendSelector = createCachedSelector( } const uniqueNames = new Set(map(({ dataName, fillColor }) => makeKey(dataName, fillColor), quadViewModel)); - const forceFlatLegend = flatLegend || legendPosition === Position.Bottom || legendPosition === Position.Top; + const useHierarchicalLegend = isHierarchicalLegend(flatLegend, legendPosition); const excluded: Set = new Set(); const items = quadViewModel.filter(({ depth, dataName, fillColor }) => { if (legendMaxDepth != null) { return depth <= legendMaxDepth; } - if (forceFlatLegend) { + if (!useHierarchicalLegend) { const key = makeKey(dataName, fillColor); if (uniqueNames.has(key) && excluded.has(key)) { return false; @@ -57,27 +58,27 @@ export const computeLegendSelector = createCachedSelector( items.sort(compareTreePaths); - return items.map(({ dataName, fillColor, depth }) => { + return items.map(({ dataName, fillColor, depth, path }) => { const formatter = pieSpec.layers[depth - 1]?.nodeLabel ?? identity; return { color: fillColor, label: formatter(dataName), - dataName, childId: dataName, - depth: forceFlatLegend ? 0 : depth - 1, + depth: useHierarchicalLegend ? depth - 1 : 0, + path, seriesIdentifier: { key: dataName, specId: pieSpec.id }, }; }); }, )(getChartIdSelector); -function makeKey(...keyParts: PrimitiveValue[]): string { +function makeKey(...keyParts: CategoryKey[]): string { return keyParts.join('---'); } function compareTreePaths({ path: a }: QuadViewModel, { path: b }: QuadViewModel): number { for (let i = 0; i < Math.min(a.length, b.length); i++) { - const diff = a[i] - b[i]; + const diff = a[i].index - b[i].index; if (diff) { return diff; } diff --git a/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts b/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts index e9f91e2904..dc6e1c516b 100644 --- a/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts +++ b/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts @@ -18,22 +18,88 @@ */ import createCachedSelector from 're-reselect'; +import { $Values } from 'utility-types'; +import { LegendPath } from '../../../../state/actions/legend'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; -import { QuadViewModel } from '../../layout/types/viewmodel_types'; +import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; +import { DataName, QuadViewModel } from '../../layout/types/viewmodel_types'; import { partitionGeometries } from './geometries'; -const getHighlightedLegendItemKey = (state: GlobalChartState) => state.interactions.highlightedLegendItemKey; +const getHighlightedLegendItemPath = (state: GlobalChartState) => state.interactions.highlightedLegendPath; + +type LegendStrategyFn = (legendPath: LegendPath) => (partialShape: { path: LegendPath; dataName: DataName }) => boolean; + +const legendStrategies: Record = { + node: (legendPath) => ({ path }) => + // highlight exact match in the path only + legendPath.length === path.length && + legendPath.every(({ index, value }, i) => index === path[i]?.index && value === path[i]?.value), + + path: (legendPath) => ({ path }) => + // highlight members of the exact path; ie. exact match in the path, plus all its ancestors + path.every(({ index, value }, i) => index === legendPath[i]?.index && value === legendPath[i]?.value), + + keyInLayer: (legendPath) => ({ path, dataName }) => + // highlight all identically named items which are within the same depth (ring) as the hovered legend depth + legendPath.length === path.length && dataName === legendPath[legendPath.length - 1].value, + + key: (legendPath) => ({ dataName }) => + // highlight all identically named items, no matter where they are + dataName === legendPath[legendPath.length - 1].value, + + nodeWithDescendants: (legendPath) => ({ path }) => + // highlight exact match in the path, and everything that is its descendant in that branch + legendPath.every(({ index, value }, i) => index === path[i]?.index && value === path[i]?.value), + + pathWithDescendants: (legendPath) => ({ path }) => + // highlight exact match in the path, and everything that is its ancestor, or its descendant in that branch + legendPath + .slice(0, path.length) + .every(({ index, value }, i) => index === path[i]?.index && value === path[i]?.value), +}; + +/** @public */ +export const LegendStrategy = Object.freeze({ + /** + * Highlight the specific node(s) that the legend item stands for. + */ + Node: 'node' as const, + /** + * Highlight members of the exact path; ie. like `Node`, plus all its ancestors + */ + Path: 'path' as const, + /** + * Highlight all identically named (labelled) items within the tree layer (depth or ring) of the specific node(s) that the legend item stands for + */ + KeyInLayer: 'keyInLayer' as const, + /** + * Highlight all identically named (labelled) items, no matter where they are + */ + Key: 'key' as const, + /** + * Highlight the specific node(s) that the legend item stands for, plus all descendants + */ + NodeWithDescendants: 'nodeWithDescendants' as const, + /** + * Highlight the specific node(s) that the legend item stands for, plus all ancestors and descendants + */ + PathWithDescendants: 'pathWithDescendants' as const, +}); + +/** @public */ +export type LegendStrategy = $Values; +const defaultStrategy: LegendStrategy = LegendStrategy.Key; /** @internal */ -// why is it called highlighted... when it's a legend hover related thing, not a hover over the slices? -export const getHighlightedSectorsSelector = createCachedSelector( - [getHighlightedLegendItemKey, partitionGeometries], - (highlightedLegendItemKey, geoms): QuadViewModel[] => { - if (!highlightedLegendItemKey) { +export const legendHoverHighlightNodes = createCachedSelector( + [getSettingsSpecSelector, getHighlightedLegendItemPath, partitionGeometries], + (specs, highlightedLegendItemPath, geoms): QuadViewModel[] => { + if (highlightedLegendItemPath.length === 0) { return []; } - return geoms.quadViewModel.filter(({ dataName }) => dataName === highlightedLegendItemKey); + const pickedLogic: LegendStrategy = specs.legendStrategy ?? defaultStrategy; + return geoms.quadViewModel.filter(legendStrategies[pickedLogic](highlightedLegendItemPath)); }, )(getChartIdSelector); diff --git a/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts b/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts index 354099eb79..9aedd42cbf 100644 --- a/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts +++ b/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts @@ -22,7 +22,7 @@ import createCachedSelector from 're-reselect'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { LegendItemLabel } from '../../../../state/selectors/get_legend_items_labels'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { CHILDREN_KEY, HierarchyOfArrays } from '../../layout/utils/group_by_rollup'; +import { CHILDREN_KEY, HierarchyOfArrays, HIERARCHY_ROOT_KEY } from '../../layout/utils/group_by_rollup'; import { Layer } from '../../specs'; import { getPieSpec } from './pie_spec'; import { getTree } from './tree'; @@ -57,7 +57,7 @@ function flatSlicesNames( formattedValue = formatter ? formatter(key) : `${key}`; } // preventing errors from external formatters - if (formattedValue != null && formattedValue !== '') { + if (formattedValue != null && formattedValue !== '' && formattedValue !== HIERARCHY_ROOT_KEY) { // save only the max depth, so we can compute the the max extension of the legend keys.set(formattedValue, Math.max(depth, keys.get(formattedValue) ?? 0)); } diff --git a/src/chart_types/partition_chart/state/selectors/tree.ts b/src/chart_types/partition_chart/state/selectors/tree.ts index 8d744be838..2839720442 100644 --- a/src/chart_types/partition_chart/state/selectors/tree.ts +++ b/src/chart_types/partition_chart/state/selectors/tree.ts @@ -24,7 +24,7 @@ import { SpecTypes } from '../../../../specs'; import { GlobalChartState } from '../../../../state/chart_state'; import { getSpecsFromStore } from '../../../../state/utils'; import { configMetadata } from '../../layout/config/config'; -import { childOrders, HierarchyOfArrays } from '../../layout/utils/group_by_rollup'; +import { childOrders, HierarchyOfArrays, HIERARCHY_ROOT_KEY } from '../../layout/utils/group_by_rollup'; import { getHierarchyOfArrays } from '../../layout/viewmodel/hierarchy_of_arrays'; import { isSunburst, isTreemap } from '../../layout/viewmodel/viewmodel'; import { PartitionSpec } from '../../specs'; @@ -45,7 +45,7 @@ export const getTree = createCachedSelector( return getHierarchyOfArrays( data, valueAccessor, - [() => null, ...layers.map(({ groupByRollup }) => groupByRollup)], + [() => HIERARCHY_ROOT_KEY, ...layers.map(({ groupByRollup }) => groupByRollup)], sorter, ); }, diff --git a/src/chart_types/xy_chart/legend/legend.test.ts b/src/chart_types/xy_chart/legend/legend.test.ts index dbd38ece1b..d5a6b8e6af 100644 --- a/src/chart_types/xy_chart/legend/legend.test.ts +++ b/src/chart_types/xy_chart/legend/legend.test.ts @@ -141,6 +141,7 @@ describe('Legends', () => { isSeriesHidden: false, isToggleable: true, defaultExtra: nullDisplayValue, + path: [{ index: 0, value: 'seriesCollectionValue1a' }], }, ]; expect([...legend.values()]).toEqual(expected); @@ -159,6 +160,7 @@ describe('Legends', () => { isSeriesHidden: false, isToggleable: true, defaultExtra: nullDisplayValue, + path: [{ index: 0, value: 'seriesCollectionValue1a' }], }, { color: 'blue', @@ -169,6 +171,7 @@ describe('Legends', () => { isSeriesHidden: false, isToggleable: true, defaultExtra: nullDisplayValue, + path: [{ index: 0, value: 'seriesCollectionValue1b' }], }, ]; expect([...legend.values()]).toEqual(expected); @@ -187,6 +190,7 @@ describe('Legends', () => { isSeriesHidden: false, isToggleable: true, defaultExtra: nullDisplayValue, + path: [{ index: 0, value: 'seriesCollectionValue1a' }], }, { color: 'green', @@ -197,6 +201,7 @@ describe('Legends', () => { isSeriesHidden: false, isToggleable: true, defaultExtra: nullDisplayValue, + path: [{ index: 0, value: 'seriesCollectionValue2a' }], }, ]; expect([...legend.values()]).toEqual(expected); @@ -220,6 +225,7 @@ describe('Legends', () => { isSeriesHidden: false, isToggleable: true, defaultExtra: nullDisplayValue, + path: [{ index: 0, value: 'seriesCollectionValue1a' }], }, ]; expect([...legend.values()]).toEqual(expected); diff --git a/src/chart_types/xy_chart/legend/legend.ts b/src/chart_types/xy_chart/legend/legend.ts index a3ae74f35f..dc53b53cc5 100644 --- a/src/chart_types/xy_chart/legend/legend.ts +++ b/src/chart_types/xy_chart/legend/legend.ts @@ -127,6 +127,7 @@ export function computeLegend( isItemHidden: hideInLegend, isToggleable: true, defaultExtra: getLegendExtra(showLegendExtra, spec.xScaleType, formatter, 'y1', lastValue), + path: [{ index: 0, value: seriesIdentifier.key }], }); if (banded) { const labelY0 = getBandedLegendItemLabel(name, BandedAccessorType.Y0, postFixes); @@ -139,6 +140,7 @@ export function computeLegend( isItemHidden: hideInLegend, isToggleable: true, defaultExtra: getLegendExtra(showLegendExtra, spec.xScaleType, formatter, 'y0', lastValue), + path: [{ index: 0, value: seriesIdentifier.key }], }); } }); diff --git a/src/chart_types/xy_chart/rendering/rendering.test.ts b/src/chart_types/xy_chart/rendering/rendering.test.ts index 085131a251..b438225854 100644 --- a/src/chart_types/xy_chart/rendering/rendering.test.ts +++ b/src/chart_types/xy_chart/rendering/rendering.test.ts @@ -140,6 +140,7 @@ describe('Rendering utils', () => { raw: null, legendSizingLabel: null, }, + path: [], }; const unhighlightedLegendItem: LegendItem = { diff --git a/src/chart_types/xy_chart/state/chart_state.test.ts b/src/chart_types/xy_chart/state/chart_state.test.ts index 9aaa1a3fc2..6b1ea18c42 100644 --- a/src/chart_types/xy_chart/state/chart_state.test.ts +++ b/src/chart_types/xy_chart/state/chart_state.test.ts @@ -78,6 +78,7 @@ describe.skip('Chart Store', () => { specId: SPEC_ID, key: 'color1', }, + path: [], }; const secondLegendItem: LegendItem = { @@ -87,6 +88,7 @@ describe.skip('Chart Store', () => { specId: SPEC_ID, key: 'color2', }, + path: [], }; beforeEach(() => { store = null; // new ChartStore(); diff --git a/src/chart_types/xy_chart/state/utils/common.test.ts b/src/chart_types/xy_chart/state/utils/common.test.ts index 6cd8ab6614..07df2d44b1 100644 --- a/src/chart_types/xy_chart/state/utils/common.test.ts +++ b/src/chart_types/xy_chart/state/utils/common.test.ts @@ -142,6 +142,7 @@ describe('Type Checks', () => { }, defaultExtra: { raw: 6, formatted: '6.00', legendSizingLabel: '6.00' }, isSeriesHidden: true, + path: [], }, { color: '#2B70F7', @@ -152,6 +153,7 @@ describe('Type Checks', () => { }, defaultExtra: { raw: 2, formatted: '2.00', legendSizingLabel: '2.00' }, isSeriesHidden: true, + path: [], }, ]; expect(isAllSeriesDeselected(legendItems1)).toBe(true); @@ -167,6 +169,7 @@ describe('Type Checks', () => { }, defaultExtra: { raw: 6, formatted: '6.00', legendSizingLabel: '6.00' }, isSeriesHidden: false, + path: [], }, { color: '#2B70F7', @@ -177,6 +180,7 @@ describe('Type Checks', () => { }, defaultExtra: { raw: 2, formatted: '2.00', legendSizingLabel: '2.00' }, isSeriesHidden: true, + path: [], }, ]; expect(isAllSeriesDeselected(legendItems2)).toBe(false); diff --git a/src/commons/category.ts b/src/commons/category.ts new file mode 100644 index 0000000000..80c63f9fa8 --- /dev/null +++ b/src/commons/category.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A string key is used to uniquely identify categories + * + * todo: broaden it; some options: + * - allow other values of `PrimitiveValue` type (now: string | number | null) but should add Symbol + * - allow a descriptor object, eg. `{ key: PrimitiveValue, label: string }` + * - allow an accessor that operates on the key, and maps it to a label + */ +export type CategoryKey = string; +export type CategoryLabel = string; diff --git a/src/commons/legend.ts b/src/commons/legend.ts index 2de4a457d0..225aa66c49 100644 --- a/src/commons/legend.ts +++ b/src/commons/legend.ts @@ -18,18 +18,21 @@ */ import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; +import { LegendPath } from '../state/actions/legend'; import { Color } from '../utils/commons'; +import { CategoryKey, CategoryLabel } from './category'; import { SeriesIdentifier } from './series_id'; /** @internal */ -export type LegendItemChildId = string; +export type LegendItemChildId = CategoryKey; /** @internal */ export type LegendItem = { seriesIdentifier: SeriesIdentifier; childId?: LegendItemChildId; depth?: number; + path: LegendPath; color: Color; - label: string; + label: CategoryLabel; isSeriesHidden?: boolean; isItemHidden?: boolean; defaultExtra?: { diff --git a/src/commons/series_id.ts b/src/commons/series_id.ts index c90860d040..52b0d5677b 100644 --- a/src/commons/series_id.ts +++ b/src/commons/series_id.ts @@ -18,11 +18,12 @@ */ import { SpecId } from '../utils/ids'; +import { CategoryKey } from './category'; /** * A string key used to uniquely identify a series */ -export type SeriesKey = string; +export type SeriesKey = CategoryKey; /** * A series identifier diff --git a/src/components/legend/legend_item.tsx b/src/components/legend/legend_item.tsx index bcc85c1d14..84031b48ce 100644 --- a/src/components/legend/legend_item.tsx +++ b/src/components/legend/legend_item.tsx @@ -63,11 +63,7 @@ export interface LegendItemProps { toggleDeselectSeriesAction: typeof onToggleDeselectSeriesAction; } -/** - * @internal - * @param item - * @param props - */ +/** @internal */ export function renderLegendItem( item: LegendItem, props: Omit, @@ -124,8 +120,8 @@ export class LegendListItem extends Component return !deepEqual(this.props, nextProps) || !deepEqual(this.state, nextState); } - handleColorClick = (changable: boolean): MouseEventHandler | undefined => - changable + handleColorClick = (changeable: boolean): MouseEventHandler | undefined => + changeable ? (event) => { event.stopPropagation(); this.toggleIsOpen(); @@ -142,7 +138,7 @@ export class LegendListItem extends Component if (onMouseOver) { onMouseOver(item.seriesIdentifier); } - mouseOverAction(item.seriesIdentifier.key); + mouseOverAction(item.path); }; onLegendItemMouseOut = () => { diff --git a/src/components/legend/style_utils.ts b/src/components/legend/style_utils.ts index c534329f5b..0cfb90b8ac 100644 --- a/src/components/legend/style_utils.ts +++ b/src/components/legend/style_utils.ts @@ -20,6 +20,7 @@ import { BBox } from '../../utils/bbox/bbox_calculator'; import { Position } from '../../utils/commons'; import { Margins } from '../../utils/dimensions'; +import { isHorizontalLegend } from '../../utils/legend'; import { LegendStyle as ThemeLegendStyle } from '../../utils/themes/theme'; /** @internal */ @@ -47,8 +48,6 @@ export interface LegendListStyle { } /** * Get the legend list style - * @param position - * @param chartMarrings, legend from the Theme * @internal */ export function getLegendListStyle( @@ -58,7 +57,7 @@ export function getLegendListStyle( ): LegendListStyle { const { top: paddingTop, bottom: paddingBottom, left: paddingLeft, right: paddingRight } = chartMargins; - if (position === Position.Bottom || position === Position.Top) { + if (isHorizontalLegend(position)) { return { paddingLeft, paddingRight, @@ -73,8 +72,6 @@ export function getLegendListStyle( } /** * Get the legend global style - * @param position the position of the legend - * @param size the computed size of the legend * @internal */ export function getLegendStyle(position: Position, size: BBox, margin: number): LegendStyle { diff --git a/src/index.ts b/src/index.ts index 9833a871a9..b7fd07d9d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export { SeriesIdentifier } from './commons/series_id'; export { XYChartSeriesIdentifier, DataSeriesDatum, FilledValues } from './chart_types/xy_chart/utils/series'; export { AnnotationTooltipFormatter, CustomAnnotationTooltip } from './chart_types/xy_chart/annotations/types'; export { GeometryValue } from './utils/geometry'; +export { LegendStrategy } from './chart_types/partition_chart/state/selectors/get_highlighted_shapes'; export { Config as PartitionConfig, FillLabelConfig as PartitionFillLabel, diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 8aa9093a0b..3f20574f08 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -22,6 +22,7 @@ import React, { ComponentType, ReactChild } from 'react'; import { Spec } from '.'; import { Cell } from '../chart_types/heatmap/layout/types/viewmodel_types'; import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; +import { LegendStrategy } from '../chart_types/partition_chart/state/selectors/get_highlighted_shapes'; import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; import { DomainRange } from '../chart_types/xy_chart/utils/specs'; import { SeriesIdentifier } from '../commons/series_id'; @@ -354,6 +355,10 @@ export interface SettingsSpec extends Spec { * Display the legend as a flat hierarchy */ flatLegend?: boolean; + /** + * Choose a partition highlighting strategy for hovering over legend items + */ + legendStrategy?: LegendStrategy; /** * Removes duplicate axes * diff --git a/src/state/actions/legend.ts b/src/state/actions/legend.ts index 406c3b61d4..0c4a306264 100644 --- a/src/state/actions/legend.ts +++ b/src/state/actions/legend.ts @@ -17,6 +17,7 @@ * under the License. */ +import { CategoryKey } from '../../commons/category'; import { SeriesIdentifier } from '../../commons/series_id'; /** @internal */ @@ -28,9 +29,14 @@ export const ON_LEGEND_ITEM_OUT = 'ON_LEGEND_ITEM_OUT'; /** @internal */ export const ON_TOGGLE_DESELECT_SERIES = 'ON_TOGGLE_DESELECT_SERIES'; +export type LegendPathElement = { index: number; value: CategoryKey }; + +export type LegendPath = LegendPathElement[]; + interface LegendItemOverAction { type: typeof ON_LEGEND_ITEM_OVER; - legendItemKey: string | null; + legendItemKey: CategoryKey | null; + legendPath: LegendPath; } interface LegendItemOutAction { type: typeof ON_LEGEND_ITEM_OUT; @@ -44,8 +50,9 @@ export interface ToggleDeselectSeriesAction { } /** @internal */ -export function onLegendItemOverAction(legendItemKey: string | null): LegendItemOverAction { - return { type: ON_LEGEND_ITEM_OVER, legendItemKey }; +export function onLegendItemOverAction(legendPath: LegendPath): LegendItemOverAction { + // todo remove obsoleted `legendItemKey` + return { type: ON_LEGEND_ITEM_OVER, legendItemKey: legendPath[legendPath.length - 1]?.value ?? null, legendPath }; } /** @internal */ diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index c97fd1be34..1b72616fbb 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -22,6 +22,7 @@ import React, { RefObject } from 'react'; import { ChartTypes } from '../chart_types'; import { GoalState } from '../chart_types/goal_chart/state/chart_state'; 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 { LegendItem, LegendItemExtraValues } from '../commons/legend'; @@ -37,6 +38,7 @@ import { CHART_RENDERED } from './actions/chart'; import { UPDATE_PARENT_DIMENSION } from './actions/chart_settings'; import { CLEAR_TEMPORARY_COLORS, SET_PERSISTED_COLOR, SET_TEMPORARY_COLOR } from './actions/colors'; import { EXTERNAL_POINTER_EVENT } from './actions/events'; +import { LegendPath } from './actions/legend'; import { REMOVE_SPEC, SPEC_PARSED, SPEC_UNMOUNTED, UPSERT_SPEC } from './actions/specs'; import { Z_INDEX_EVENT } from './actions/z_index'; import { interactionsReducer } from './reducers/interactions'; @@ -179,7 +181,8 @@ export interface PointerStates { /** @internal */ export interface InteractionsState { pointer: PointerStates; - highlightedLegendItemKey: string | null; + highlightedLegendItemKey: PrimitiveValue; + highlightedLegendPath: LegendPath; deselectedDataSeries: SeriesIdentifier[]; } @@ -267,6 +270,7 @@ export const getInitialState = (chartId: string): GlobalChartState => ({ interactions: { pointer: getInitialPointerState(), highlightedLegendItemKey: null, + highlightedLegendPath: [], deselectedDataSeries: [], }, externalEvents: { diff --git a/src/state/reducers/interactions.ts b/src/state/reducers/interactions.ts index 68b5ff20a9..0ff834997b 100644 --- a/src/state/reducers/interactions.ts +++ b/src/state/reducers/interactions.ts @@ -138,11 +138,14 @@ export function interactionsReducer( return { ...state, highlightedLegendItemKey: null, + highlightedLegendPath: [], }; case ON_LEGEND_ITEM_OVER: + const { legendItemKey: highlightedLegendItemKey, legendPath: highlightedLegendPath } = action; return { ...state, - highlightedLegendItemKey: action.legendItemKey, + highlightedLegendItemKey, + highlightedLegendPath, }; case ON_TOGGLE_DESELECT_SERIES: return { diff --git a/src/utils/legend.ts b/src/utils/legend.ts new file mode 100644 index 0000000000..14d9cc4776 --- /dev/null +++ b/src/utils/legend.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Position } from './commons'; + +export const isHorizontalLegend = (legendPosition: Position) => + legendPosition === Position.Bottom || legendPosition === Position.Top; + +export const isHierarchicalLegend = (flatLegend: boolean | undefined, legendPosition: Position) => + !flatLegend && !isHorizontalLegend(legendPosition); diff --git a/stories/icicle/02_unix_flame.tsx b/stories/icicle/02_unix_flame.tsx index b76a714eab..88df8b654a 100644 --- a/stories/icicle/02_unix_flame.tsx +++ b/stories/icicle/02_unix_flame.tsx @@ -19,7 +19,7 @@ import React from 'react'; -import { Chart, Datum, Partition, PartitionLayout, Settings } from '../../src'; +import { Chart, Datum, LegendStrategy, Partition, PartitionLayout, Settings } from '../../src'; import { STORYBOOK_LIGHT_THEME } from '../shared'; import { config, getFlatData, getLayerSpec, maxDepth } from '../utils/hierarchical_input_utils'; import { plasma18 as palette } from '../utils/utils'; @@ -29,7 +29,13 @@ const color = palette.slice().reverse(); export const Example = () => { return ( - + { max: 3, step: 1, }); + const legendStrategy = select('legendStrategy', LegendStrategy, LegendStrategy.Key); return ( - + ( - +