diff --git a/packages/osd-charts/.eslintrc.js b/packages/osd-charts/.eslintrc.js index 4e1c82e3c94..82e4eeeefac 100644 --- a/packages/osd-charts/.eslintrc.js +++ b/packages/osd-charts/.eslintrc.js @@ -32,7 +32,7 @@ module.exports = { ], rules: { /** - * depricated to be deleted + * deprecated to be deleted */ // https://github.com/typescript-eslint/typescript-eslint/issues/2077 '@typescript-eslint/camelcase': 0, @@ -92,6 +92,7 @@ module.exports = { /** * Standard rules */ + 'no-restricted-syntax': 0, // this is a good rule, for-of is good 'no-console': process.env.NODE_ENV === 'production' ? 2 : 1, 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 1, 'prefer-template': 'error', diff --git a/packages/osd-charts/.playground/playground.tsx b/packages/osd-charts/.playground/playground.tsx index 7d0a1427dd5..13036f22048 100644 --- a/packages/osd-charts/.playground/playground.tsx +++ b/packages/osd-charts/.playground/playground.tsx @@ -17,149 +17,60 @@ * under the License. */ -import React from 'react'; - -interface Food { - label: string; - count: number; - actionLabel: string; -} +/* + * 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. + */ -type Foods = Array; +import React from 'react'; -type FoodArray = Array; +import { Chart, Settings, Partition, PartitionLayout } from '../src'; export class Playground extends React.Component { - foods: Foods = [ - { label: 'pie', count: 2, actionLabel: 'tab' }, - { label: 'asparagus', count: 5, actionLabel: 'tab' }, - { label: 'brownies', count: 0, actionLabel: 'enter' }, - { label: 'popsicles', count: 3, actionLabel: 'enter' }, - ]; - - foodsAsAnArray: FoodArray = ['tab', 'tab', 'tab', 'enter']; - - getFoodsArrayAction = (foodsArray: FoodArray) => { - for (let i = 0; i < foodsArray.length; i++) { - if (foodsArray[i] === 'tab') { - // alert('tab!'); - } else if (foodsArray[i] === 'enter') { - // alert('enter!'); - } - } - }; - - getFoodAction = (foodLabel: Food[keyof Food]) => { - // eslint-disable-next-line array-callback-return - return this.foods.map(({ label, count, actionLabel }) => { - if (foodLabel === label && actionLabel === 'tab') { - let c = 0; - while (c < count) { - // alert(`${label} Tab!`); - c++; - } - } else if (foodLabel === label && actionLabel === 'enter') { - let c = 0; - while (c < count) { - // alert(`${label} Enter!`); - c++; - } - } - }); - }; - - makingFood = (ms: number, food: string) => { - return new Promise((resolve) => { - setTimeout(() => { - // console.log(`resolving the promise ${food}`, new Date()); - resolve(`done with ${food}`); - }, ms); - // console.log(`starting the promise ${food}`, new Date()); - }); - }; - - getNumberOfFood = (food: any) => { - return this.makingFood(1000, food).then(() => this.getFoodAction(food)); - }; - - getNumberOfFoodArray = () => { - return this.makingFood(1000, 'apple').then(() => this.getFoodsArrayAction(this.foodsAsAnArray)); - }; - - getAsyncNumberOfFoodArray = async () => { - // const result = await this.makingFood(2000).then(() => this.getFoodsArrayAction(this.foodsAsAnArray)); - // alert(result); - }; - - // forLoop = async () => { - // alert('start'); - // for (let index = 0; index < this.foods.length; index++) { - // const foodLabel = this.foods[index].label; - // const numFood = await this.getFoodNumber(foodLabel); - // alert(numFood); - // } - // const foodsPromiseArray = this.foods.map(async (foodObject) => { - // for (let i = 0; i < foodObject.length; i++) { - // const numFoodAction = foodObject[i].actionLabel; - // if (numFoodAction === 'enter') { - // alert ('Enter!'); - // } else if (numFoodAction === 'tab') { - // alert('tab!'); - // } - // } - // }); - // const numberOfFoods = await Promise.all(foodsPromiseArray); - // alert(numberOfFoods); - // alert('End'); - // }; - - // getFoodArray = - // async() => { - // console.log(await this.makingFood(1000, 'apricot')); - // console.log(await this.makingFood(50, 'apple')); - // const foodTimeArray = [ - // { ms: 1000, food: 'a', count: 2 }, - // { ms: 50, food: 'b', count: 1 }, - // { ms: 500, food: 'c', count: 3 }, - // ]; - - // for (let i = 0; i < foodTimeArray.length; i++) { - // void this.makingFood(foodTimeArray[i].ms, foodTimeArray[i].food); - // } - - // const foodMap = foodTimeArray.map(({ ms, food }) => { - // return this.makingFood(ms, food); - // }); - - // console.log('before the promise'); - // for (const i of foodTimeArray) { - // const j = 0; - // while (j < i.count) { - // await this.makingFood(i.ms, i.food); - // j++; - // } - // } - - // await Promise.all(); - // console.log('after the promise'); - // }; - render() { - // console.log(this.makingFood(1000, 'apricot')); - // console.log(this.makingFood(50, 'apple')); - // void this.getFoodArray(); - - return null; - // <> - //
- //
- // {/*
{this.foods.map(({ label }) => this.getNumberOfFood(label))}
*/} - // {/*
{alert(this.makingFood(50000).then(this.getFoodsArrayAction(this.foodsAsAnArray)))}
*/} - // {/*
{alert(this.getNumberOfFoodArray())}
*/} - // {/*
{alert(this.getAsyncNumberOfFoodArray())}
*/} - //
{this.makingFood(50)}
- //
- //
- // + return ( +
+ + + d.val as number} + layers={[ + { + groupByRollup: (d: any) => d.cat1, + }, + { + groupByRollup: (d: any) => d.cat2, + }, + ]} + config={{ + partitionLayout: PartitionLayout.sunburst, + }} + /> + +
+ ); } } diff --git a/packages/osd-charts/api/charts.api.md b/packages/osd-charts/api/charts.api.md index 5781c77fdbe..e1d5681d0fd 100644 --- a/packages/osd-charts/api/charts.api.md +++ b/packages/osd-charts/api/charts.api.md @@ -569,7 +569,7 @@ export const DEFAULT_TOOLTIP_TYPE: "vertical"; // Warning: (ae-missing-release-tag) "DefaultSettingsProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type DefaultSettingsProps = 'id' | 'chartType' | 'specType' | 'rendering' | 'rotation' | 'resizeDebounce' | 'animateData' | 'showLegend' | 'debug' | 'tooltip' | 'showLegendExtra' | 'theme' | 'legendPosition' | 'hideDuplicateAxes' | 'brushAxis' | 'minBrushDelta' | 'externalPointerEvents'; +export type DefaultSettingsProps = 'id' | 'chartType' | 'specType' | 'rendering' | 'rotation' | 'resizeDebounce' | 'animateData' | 'showLegend' | 'debug' | 'tooltip' | 'showLegendExtra' | 'theme' | 'legendPosition' | 'legendMaxDepth' | 'hideDuplicateAxes' | 'brushAxis' | 'minBrushDelta' | 'externalPointerEvents'; // Warning: (ae-missing-release-tag) "Direction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1597,7 +1597,7 @@ export interface SettingsSpec extends Spec { legendAction?: LegendAction; // (undocumented) legendColorPicker?: LegendColorPicker; - legendMaxDepth?: number; + legendMaxDepth: number; legendPosition: Position; minBrushDelta?: number; noResults?: ComponentType | ReactChild; diff --git a/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-piechart-repeated-labels-visually-looks-correct-1-snap.png b/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-piechart-repeated-labels-visually-looks-correct-1-snap.png new file mode 100644 index 00000000000..4e539c4b4c5 Binary files /dev/null and b/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-piechart-repeated-labels-visually-looks-correct-1-snap.png differ diff --git a/packages/osd-charts/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-have-the-same-order-as-nested-with-no-indent-even-if-there-are-repeated-labels-1-snap.png b/packages/osd-charts/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-have-the-same-order-as-nested-with-no-indent-even-if-there-are-repeated-labels-1-snap.png new file mode 100644 index 00000000000..41748670104 Binary files /dev/null and b/packages/osd-charts/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-have-the-same-order-as-nested-with-no-indent-even-if-there-are-repeated-labels-1-snap.png differ diff --git a/packages/osd-charts/integration/tests/legend_stories.test.ts b/packages/osd-charts/integration/tests/legend_stories.test.ts index c5d3ce42440..a8c3713045d 100644 --- a/packages/osd-charts/integration/tests/legend_stories.test.ts +++ b/packages/osd-charts/integration/tests/legend_stories.test.ts @@ -40,6 +40,11 @@ describe('Legend stories', () => { 'http://localhost:9001/?path=/story/legend--legend-spacing-buffer&knob-legend buffer value=0', ); }); + it('should have the same order as nested with no indent even if there are repeated labels', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/legend--piechart-repeated-labels&knob-flatLegend=true&knob-legendMaxDepth=2', + ); + }); it('should render color picker on mouse click', async () => { const action = async () => diff --git a/packages/osd-charts/src/chart_types/partition_chart/layout/types/viewmodel_types.ts b/packages/osd-charts/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index 6f9b189eeb6..71e0da32105 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/packages/osd-charts/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -148,11 +148,14 @@ interface SectorGeomSpecY { y1px: Distance; } +export type DataName = any; // todo consider narrowing it to eg. primitives + export interface ShapeTreeNode extends TreeNode, SectorGeomSpecY { yMidPx: Distance; depth: number; sortIndex: number; - dataName: any; + path: number[]; + dataName: DataName; value: number; parent: ArrayNode; } diff --git a/packages/osd-charts/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts b/packages/osd-charts/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts index 388349cbd74..11a7d7a5743 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts +++ b/packages/osd-charts/src/chart_types/partition_chart/layout/utils/group_by_rollup.ts @@ -27,6 +27,7 @@ export const CHILDREN_KEY = 'children'; export const INPUT_KEY = 'inputIndex'; export const PARENT_KEY = 'parent'; export const SORT_INDEX_KEY = 'sortIndex'; +export const PATH_KEY = 'path'; interface Statistics { globalAggregate: number; @@ -45,6 +46,7 @@ export interface ArrayNode extends NodeDescriptor { [CHILDREN_KEY]: HierarchyOfArrays; [PARENT_KEY]: ArrayNode; [SORT_INDEX_KEY]: number; + [PATH_KEY]: number[]; } type HierarchyOfMaps = Map; @@ -75,6 +77,9 @@ export function childrenAccessor(n: ArrayEntry) { export function sortIndexAccessor(n: ArrayEntry) { return entryValue(n)[SORT_INDEX_KEY]; } +export function pathAccessor(n: ArrayEntry) { + return entryValue(n)[PATH_KEY]; +} const ascending: Sorter = (a, b) => a - b; const descending: Sorter = (a, b) => b - a; @@ -128,7 +133,13 @@ export function groupByRollup( function getRootArrayNode(): ArrayNode { const children: HierarchyOfArrays = []; - const bootstrap = { [AGGREGATE_KEY]: NaN, [DEPTH_KEY]: NaN, [CHILDREN_KEY]: children, [INPUT_KEY]: [] as number[] }; + const bootstrap = { + [AGGREGATE_KEY]: NaN, + [DEPTH_KEY]: NaN, + [CHILDREN_KEY]: children, + [INPUT_KEY]: [] as number[], + [PATH_KEY]: [] as number[], + }; Object.assign(bootstrap, { [PARENT_KEY]: bootstrap }); const result: ArrayNode = bootstrap as ArrayNode; return result; @@ -149,6 +160,7 @@ export function mapsToArrays(root: HierarchyOfMaps, sorter: NodeSorter): Hierarc [SORT_INDEX_KEY]: NaN, [PARENT_KEY]: parent, [INPUT_KEY]: [], + [PATH_KEY]: [], }; const newValue: ArrayNode = Object.assign( resultNode, @@ -163,7 +175,14 @@ export function mapsToArrays(root: HierarchyOfMaps, sorter: NodeSorter): Hierarc entryValue(n).sortIndex = i; return n; }); // with the current algo, decreasing order is important - return groupByMap(root, getRootArrayNode()); + const tree = groupByMap(root, getRootArrayNode()); + const buildPaths = ([, mapNode]: ArrayEntry, currentPath: number[]) => { + const newPath = [...currentPath, mapNode[SORT_INDEX_KEY]]; + mapNode[PATH_KEY] = newPath; + mapNode.children.forEach((entry) => buildPaths(entry, newPath)); + }; + buildPaths(tree[0], []); + return tree; } /** @internal */ diff --git a/packages/osd-charts/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts b/packages/osd-charts/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts index d3f5fbc0a5c..a11b244fa2c 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts +++ b/packages/osd-charts/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts @@ -47,6 +47,7 @@ import { parentAccessor, sortIndexAccessor, HierarchyOfArrays, + pathAccessor, } from '../utils/group_by_rollup'; import { trueBearingToStandardPositionAngle } from '../utils/math'; import { sunburst } from '../utils/sunburst'; @@ -349,6 +350,7 @@ function partToShapeTreeNode(treemapLayout: boolean, innerRadius: Radius, ringTh value: aggregateAccessor(node), parent: parentAccessor(node), sortIndex: sortIndexAccessor(node), + path: pathAccessor(node), x0, x1, y0, diff --git a/packages/osd-charts/src/chart_types/partition_chart/partition.test.tsx b/packages/osd-charts/src/chart_types/partition_chart/partition.test.tsx new file mode 100644 index 00000000000..a69a8449288 --- /dev/null +++ b/packages/osd-charts/src/chart_types/partition_chart/partition.test.tsx @@ -0,0 +1,220 @@ +/* + * 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 { Store } from 'redux'; + +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 { computeLegendSelector } from './state/selectors/compute_legend'; +import { getLegendItemsLabels } from './state/selectors/get_legend_items_labels'; + +// sorting is useful to ensure tests pass even if order changes (where order doesn't matter) +const ascByLabel = (a: LegendItemLabel, b: LegendItemLabel) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0); + +describe('Retain hierarchy even with arbitrary names', () => { + type TestDatum = { cat1: string; cat2: string; val: number }; + const specJSON = { + data: [ + { cat1: 'A', cat2: 'A', val: 1 }, + { cat1: 'A', cat2: 'B', val: 1 }, + { cat1: 'B', cat2: 'A', val: 1 }, + { cat1: 'B', cat2: 'B', val: 1 }, + { cat1: 'C', cat2: 'A', val: 1 }, + { cat1: 'C', cat2: 'B', val: 1 }, + ], + valueAccessor: (d: TestDatum) => d.val, + layers: [ + { + groupByRollup: (d: TestDatum) => d.cat1, + }, + { + groupByRollup: (d: TestDatum) => d.cat2, + }, + ], + }; + let store: Store; + + beforeEach(() => { + store = MockStore.default(); + }); + + describe('getLegendItemsLabels', () => { + // todo discuss question marks about testing this selector, and also about unification with `get_legend_items_labels.test.ts` + + it('all distinct labels are present', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst(specJSON)], store); + expect(getLegendItemsLabels(store.getState()).sort(ascByLabel)).toEqual([ + { depth: 2, label: 'A' }, + { depth: 2, label: 'B' }, + { depth: 1, label: 'C' }, + ]); + }); + + it('special case: one input, one label', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst({ ...specJSON, data: [{ cat1: 'A', cat2: 'A', val: 1 }] })], store); + expect(getLegendItemsLabels(store.getState())).toEqual([{ depth: 2, label: 'A' }]); + }); + + it('special case: one input, two labels', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst({ ...specJSON, data: [{ cat1: 'C', cat2: 'B', val: 1 }] })], store); + expect(getLegendItemsLabels(store.getState()).sort(ascByLabel)).toEqual([ + { depth: 2, label: 'B' }, + { depth: 1, label: 'C' }, + ]); + }); + + it('special case: no labels', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst({ ...specJSON, data: [] })], store); + expect(getLegendItemsLabels(store.getState()).map((l) => l.label)).toEqual([]); + }); + }); + + describe('getLegendItems', () => { + // todo discuss question marks about testing this selector, and also about unification with `get_legend_items_labels.test.ts` + + it('all distinct labels are present', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst(specJSON)], store); + expect(computeLegendSelector(store.getState())).toEqual([ + { + childId: 'A', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'A', + depth: 0, + label: 'A', + seriesIdentifier: { key: 'A', specId: 'spec1' }, + }, + { + childId: 'A', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'A', + depth: 1, + label: 'A', + seriesIdentifier: { key: 'A', specId: 'spec1' }, + }, + { + childId: 'B', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'B', + depth: 1, + label: 'B', + seriesIdentifier: { key: 'B', specId: 'spec1' }, + }, + { + childId: 'B', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'B', + depth: 0, + label: 'B', + seriesIdentifier: { key: 'B', specId: 'spec1' }, + }, + { + childId: 'A', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'A', + depth: 1, + label: 'A', + seriesIdentifier: { key: 'A', specId: 'spec1' }, + }, + { + childId: 'B', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'B', + depth: 1, + label: 'B', + seriesIdentifier: { key: 'B', specId: 'spec1' }, + }, + { + childId: 'C', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'C', + depth: 0, + label: 'C', + seriesIdentifier: { key: 'C', specId: 'spec1' }, + }, + { + childId: 'A', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'A', + depth: 1, + label: 'A', + seriesIdentifier: { key: 'A', specId: 'spec1' }, + }, + { + childId: 'B', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'B', + depth: 1, + label: 'B', + seriesIdentifier: { key: 'B', specId: 'spec1' }, + }, + ]); + }); + + it('special case: one input, one label', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst({ ...specJSON, data: [{ cat1: 'A', cat2: 'A', val: 1 }] })], store); + expect(computeLegendSelector(store.getState())).toEqual([ + { + childId: 'A', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'A', + depth: 0, + label: 'A', + seriesIdentifier: { key: 'A', specId: 'spec1' }, + }, + { + childId: 'A', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'A', + depth: 1, + label: 'A', + seriesIdentifier: { key: 'A', specId: 'spec1' }, + }, + ]); + }); + + it('special case: one input, two labels', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst({ ...specJSON, data: [{ cat1: 'C', cat2: 'B', val: 1 }] })], store); + expect(computeLegendSelector(store.getState())).toEqual([ + { + childId: 'C', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'C', + depth: 0, + label: 'C', + seriesIdentifier: { key: 'C', specId: 'spec1' }, + }, + { + childId: 'B', + color: 'rgba(128, 0, 0, 0.5)', + dataName: 'B', + depth: 1, + label: 'B', + seriesIdentifier: { key: 'B', specId: 'spec1' }, + }, + ]); + }); + + it('special case: no labels', () => { + MockStore.addSpecs([MockSeriesSpec.sunburst({ ...specJSON, data: [] })], store); + expect(getLegendItemsLabels(store.getState()).map((l) => l.label)).toEqual([]); + }); + }); +}); diff --git a/packages/osd-charts/src/chart_types/partition_chart/state/chart_state.tsx b/packages/osd-charts/src/chart_types/partition_chart/state/chart_state.tsx index 801488f4d4d..dacdfbb2a27 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/state/chart_state.tsx +++ b/packages/osd-charts/src/chart_types/partition_chart/state/chart_state.tsx @@ -70,6 +70,7 @@ export class PartitionState implements InternalChartState { } getLegendItemsLabels(globalState: GlobalChartState) { + // order doesn't matter, but it needs to return the highest depth of the label occurrence so enough horizontal width is allocated return getLegendItemsLabels(globalState); } diff --git a/packages/osd-charts/src/chart_types/partition_chart/state/iterables.ts b/packages/osd-charts/src/chart_types/partition_chart/state/iterables.ts new file mode 100644 index 00000000000..5e8d17c8c00 --- /dev/null +++ b/packages/osd-charts/src/chart_types/partition_chart/state/iterables.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +// just like [].map except on iterables, to avoid having to materialize both input and output arrays +/** @internal */ +export function map(fun: (arg: InElem, index: number) => OutElem, iterable: Iterable) { + let i = 0; + return (function*() { + for (const next of iterable) yield fun(next, i++); + })(); +} diff --git a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts b/packages/osd-charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts index 6e015ef8782..a67fb9ff328 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts +++ b/packages/osd-charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts @@ -22,42 +22,32 @@ import createCachedSelector from 're-reselect'; import { LegendItem } from '../../../../commons/legend'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { Position } from '../../../../utils/commons'; +import { identity, Position } from '../../../../utils/commons'; import { QuadViewModel } from '../../layout/types/viewmodel_types'; import { PrimitiveValue } from '../../layout/utils/group_by_rollup'; +import { map } from '../iterables'; import { partitionGeometries } from './geometries'; -import { getFlatHierarchy } from './get_flat_hierarchy'; import { getPieSpec } from './pie_spec'; /** @internal */ export const computeLegendSelector = createCachedSelector( - [getPieSpec, getSettingsSpecSelector, partitionGeometries, getFlatHierarchy], - (pieSpec, settings, geoms, sortedItems): LegendItem[] => { + [getPieSpec, getSettingsSpecSelector, partitionGeometries], + (pieSpec, { flatLegend, legendMaxDepth, legendPosition }, { quadViewModel }): LegendItem[] => { if (!pieSpec) { return []; } - const { id, layers: labelFormatters } = pieSpec; - const uniqueNames = geoms.quadViewModel.reduce>((acc, { dataName, fillColor }) => { - const key = [dataName, fillColor].join('---'); - if (!acc[key]) { - acc[key] = 0; - } - acc[key] += 1; - return acc; - }, {}); - - const { flatLegend, legendMaxDepth, legendPosition } = settings; + const uniqueNames = new Set(map(({ dataName, fillColor }) => makeKey(dataName, fillColor), quadViewModel)); const forceFlatLegend = flatLegend || legendPosition === Position.Bottom || legendPosition === Position.Top; const excluded: Set = new Set(); - let items = geoms.quadViewModel.filter(({ depth, dataName, fillColor }) => { + const items = quadViewModel.filter(({ depth, dataName, fillColor }) => { if (legendMaxDepth != null) { return depth <= legendMaxDepth; } if (forceFlatLegend) { - const key = [dataName, fillColor].join('---'); - if (uniqueNames[key] > 1 && excluded.has(key)) { + const key = makeKey(dataName, fillColor); + if (uniqueNames.has(key) && excluded.has(key)) { return false; } excluded.add(key); @@ -65,37 +55,32 @@ export const computeLegendSelector = createCachedSelector( return true; }); - if (forceFlatLegend) { - items = items.sort(({ depth: a }, { depth: b }) => a - b); - } - - return items - .sort((a, b) => { - const aIndex = findIndex(sortedItems, a); - const bIndex = findIndex(sortedItems, b); - return aIndex - bIndex; - }) - .map(({ dataName, fillColor, depth }) => { - const labelFormatter = labelFormatters[depth - 1]; - const formatter = labelFormatter?.nodeLabel; + items.sort(compareTreePaths); - return { - color: fillColor, - label: formatter ? formatter(dataName) : dataName, - dataName, - childId: dataName, - depth: forceFlatLegend ? 0 : depth - 1, - seriesIdentifier: { - key: dataName, - specId: id, - }, - }; - }); + return items.map(({ dataName, fillColor, depth }) => { + const formatter = pieSpec.layers[depth - 1]?.nodeLabel ?? identity; + return { + color: fillColor, + label: formatter(dataName), + dataName, + childId: dataName, + depth: forceFlatLegend ? 0 : depth - 1, + seriesIdentifier: { key: dataName, specId: pieSpec.id }, + }; + }); }, )(getChartIdSelector); -function findIndex(items: Array<[PrimitiveValue, number, PrimitiveValue]>, child: QuadViewModel) { - return items.findIndex( - ([dataName, depth, value]) => dataName === child.dataName && depth === child.depth && value === child.value, - ); +function makeKey(...keyParts: PrimitiveValue[]): 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]; + if (diff) { + return diff; + } + } + return a.length - b.length; // if one path is fully contained in the other, then parent (shorter) goes first } diff --git a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_flat_hierarchy.ts b/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_flat_hierarchy.ts deleted file mode 100644 index fad11b30f5a..00000000000 --- a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_flat_hierarchy.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 createCachedSelector from 're-reselect'; - -import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; -import { HierarchyOfArrays, PrimitiveValue } from '../../layout/utils/group_by_rollup'; -import { getTree } from './tree'; - -/** @internal */ -export const getFlatHierarchy = createCachedSelector( - [getTree], - (tree): Array<[PrimitiveValue, number, PrimitiveValue]> => flatHierarchy(tree), -)(getChartIdSelector); - -function flatHierarchy(tree: HierarchyOfArrays, orderedList: Array<[PrimitiveValue, number, PrimitiveValue]> = []) { - for (let i = 0; i < tree.length; i++) { - const branch = tree[i]; - const [key, arrayNode] = branch; - const { children, depth, value } = arrayNode; - - if (key !== null) { - orderedList.push([key, depth, value]); - } - if (children.length > 0) { - flatHierarchy(children, orderedList); - } - } - return orderedList; -} diff --git a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts b/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts index 380c33c7450..e9f91e29049 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts +++ b/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_highlighted_shapes.ts @@ -27,6 +27,7 @@ import { partitionGeometries } from './geometries'; const getHighlightedLegendItemKey = (state: GlobalChartState) => state.interactions.highlightedLegendItemKey; /** @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[] => { diff --git a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts b/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts index 7cb025a59b0..354099eb79f 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts +++ b/packages/osd-charts/src/chart_types/partition_chart/state/selectors/get_legend_items_labels.ts @@ -19,11 +19,10 @@ import createCachedSelector from 're-reselect'; -import { SettingsSpec } from '../../../../specs'; 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 { HierarchyOfArrays, CHILDREN_KEY } from '../../layout/utils/group_by_rollup'; +import { CHILDREN_KEY, HierarchyOfArrays } from '../../layout/utils/group_by_rollup'; import { Layer } from '../../specs'; import { getPieSpec } from './pie_spec'; import { getTree } from './tree'; @@ -31,32 +30,10 @@ import { getTree } from './tree'; /** @internal */ export const getLegendItemsLabels = createCachedSelector( [getPieSpec, getSettingsSpecSelector, getTree], - (pieSpec, { legendMaxDepth }, tree): LegendItemLabel[] => { - if (!pieSpec) { - return []; - } - if (isInvalidLegendMaxDepth(legendMaxDepth)) { - return []; - } - const labels = flatSlicesNames(pieSpec.layers, 0, tree).filter(({ depth }) => { - if (typeof legendMaxDepth !== 'number') { - return true; - } - return depth <= legendMaxDepth; - }); - - return labels; - }, + (pieSpec, { legendMaxDepth }, tree): LegendItemLabel[] => + pieSpec ? flatSlicesNames(pieSpec.layers, 0, tree).filter(({ depth }) => depth <= legendMaxDepth) : [], )(getChartIdSelector); -/** - * Check if the legendMaxDepth from settings is not a valid number (NaN or <=0) - * @param legendMaxDepth - SettingsSpec['legendMaxDepth'] - */ -function isInvalidLegendMaxDepth(legendMaxDepth: SettingsSpec['legendMaxDepth']): boolean { - return typeof legendMaxDepth === 'number' && (Number.isNaN(legendMaxDepth) || legendMaxDepth <= 0); -} - function flatSlicesNames( layers: Layer[], depth: number, diff --git a/packages/osd-charts/src/specs/constants.ts b/packages/osd-charts/src/specs/constants.ts index c5e2333f6aa..68362b4eec6 100644 --- a/packages/osd-charts/src/specs/constants.ts +++ b/packages/osd-charts/src/specs/constants.ts @@ -135,6 +135,7 @@ export const DEFAULT_SETTINGS_SPEC: SettingsSpec = { visible: false, }, }, + legendMaxDepth: Infinity, legendPosition: Position.Right, showLegendExtra: false, hideDuplicateAxes: false, diff --git a/packages/osd-charts/src/specs/settings.tsx b/packages/osd-charts/src/specs/settings.tsx index 527f94269ec..8aa9093a0b5 100644 --- a/packages/osd-charts/src/specs/settings.tsx +++ b/packages/osd-charts/src/specs/settings.tsx @@ -25,7 +25,7 @@ import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/grou import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; import { DomainRange } from '../chart_types/xy_chart/utils/specs'; import { SeriesIdentifier } from '../commons/series_id'; -import { TooltipPortalSettings } from '../components/portal'; +import { TooltipPortalSettings } from '../components'; import { CustomTooltip } from '../components/tooltip/types'; import { ScaleContinuousType, ScaleOrdinalType } from '../scales'; import { getConnect, specComponentFactory } from '../state/spec_factory'; @@ -349,7 +349,7 @@ export interface SettingsSpec extends Spec { /** * Limit the legend to a max depth when showing a hierarchical legend */ - legendMaxDepth?: number; + legendMaxDepth: number; /** * Display the legend as a flat hierarchy */ @@ -453,6 +453,7 @@ export type DefaultSettingsProps = | 'showLegendExtra' | 'theme' | 'legendPosition' + | 'legendMaxDepth' | 'hideDuplicateAxes' | 'brushAxis' | 'minBrushDelta' @@ -466,12 +467,12 @@ export const Settings: React.FunctionComponent = getConnect() /** @internal */ export function isPointerOutEvent(event: PointerEvent | null | undefined): event is PointerOutEvent { - return event !== null && event !== undefined && event.type === PointerEventType.Out; + return event?.type === PointerEventType.Out; } /** @internal */ export function isPointerOverEvent(event: PointerEvent | null | undefined): event is PointerOverEvent { - return event !== null && event !== undefined && event.type === PointerEventType.Over; + return event?.type === PointerEventType.Over; } /** @internal */ @@ -481,7 +482,7 @@ export function isTooltipProps(config: TooltipType | TooltipProps): config is To /** @internal */ export function isTooltipType(config: TooltipType | TooltipProps): config is TooltipType { - return typeof config === 'string'; + return typeof config !== 'object'; // TooltipType is 'vertical'|'cross'|'follow'|'none' while TooltipProps is object } /** @internal */ diff --git a/packages/osd-charts/src/state/chart_state.ts b/packages/osd-charts/src/state/chart_state.ts index 6fcd3262fb3..67869ca6db5 100644 --- a/packages/osd-charts/src/state/chart_state.ts +++ b/packages/osd-charts/src/state/chart_state.ts @@ -28,7 +28,7 @@ import { LegendItem, LegendItemExtraValues } from '../commons/legend'; import { SeriesKey, SeriesIdentifier } from '../commons/series_id'; import { TooltipInfo, TooltipAnchorPosition } from '../components/tooltip/types'; import { Spec, PointerEvent } from '../specs'; -import { DEFAULT_SETTINGS_SPEC } from '../specs/constants'; +import { DEFAULT_SETTINGS_SPEC } from '../specs'; import { Color } from '../utils/commons'; import { Dimensions } from '../utils/dimensions'; import { Logger } from '../utils/logger'; diff --git a/packages/osd-charts/src/state/selectors/get_legend_items_labels.ts b/packages/osd-charts/src/state/selectors/get_legend_items_labels.ts index d53b1250610..bc9524bf907 100644 --- a/packages/osd-charts/src/state/selectors/get_legend_items_labels.ts +++ b/packages/osd-charts/src/state/selectors/get_legend_items_labels.ts @@ -26,9 +26,5 @@ export interface LegendItemLabel { } /** @internal */ -export const getLegendItemsLabelsSelector = (state: GlobalChartState): LegendItemLabel[] => { - if (state.internalChartState) { - return state.internalChartState.getLegendItemsLabels(state); - } - return []; -}; +export const getLegendItemsLabelsSelector = (state: GlobalChartState): LegendItemLabel[] => + state.internalChartState?.getLegendItemsLabels(state) ?? []; diff --git a/packages/osd-charts/stories/legend/10_sunburst_repeated_label.tsx b/packages/osd-charts/stories/legend/10_sunburst_repeated_label.tsx new file mode 100644 index 00000000000..88eccb6f0cf --- /dev/null +++ b/packages/osd-charts/stories/legend/10_sunburst_repeated_label.tsx @@ -0,0 +1,69 @@ +/* + * 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 { boolean, number } from '@storybook/addon-knobs'; +import React from 'react'; + +import { Chart, Partition, Settings } from '../../src'; +import { STORYBOOK_LIGHT_THEME } from '../shared'; + +export const Example = () => { + const flatLegend = boolean('flatLegend', false); + const legendMaxDepth = number('legendMaxDepth', 2, { + min: 0, + max: 3, + step: 1, + }); + + type TestDatum = { cat1: string; cat2: string; val: number }; + + return ( + + + d.val} + layers={[ + { + groupByRollup: (d: TestDatum) => d.cat1, + }, + { + groupByRollup: (d: TestDatum) => d.cat2, + }, + ]} + /> + + ); +}; + +Example.story = { + parameters: { + info: { + text: `Nested legend with reused node labels means that they can reoccur in various points of the legend tree.`, + }, + }, +}; diff --git a/packages/osd-charts/stories/legend/legend.stories.tsx b/packages/osd-charts/stories/legend/legend.stories.tsx index 846a71f1748..1dd13e9211b 100644 --- a/packages/osd-charts/stories/legend/legend.stories.tsx +++ b/packages/osd-charts/stories/legend/legend.stories.tsx @@ -36,5 +36,6 @@ export { Example as displayValuesInLegendElements } from './7_display_values'; export { Example as legendSpacingBuffer } from './8_spacing_buffer'; export { Example as colorPicker } from './9_color_picker'; export { Example as piechart } from './10_sunburst'; +export { Example as piechartRepeatedLabels } from './10_sunburst_repeated_label'; export { Example as actions } from './11_legend_actions'; export { Example as margins } from './12_legend_margins';