diff --git a/.storybook/config.ts b/.storybook/config.ts index 7c3391082f..6ebf39a7c5 100644 --- a/.storybook/config.ts +++ b/.storybook/config.ts @@ -33,8 +33,7 @@ addParameters({ base: 'light', brandTitle: 'Elastic Charts', brandUrl: 'https://github.com/elastic/elastic-charts', - brandImage: - 'https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6ae3d6980b5fd629/5bbca1d1af3a954c36f95ed3/logo-elastic.svg', + brandImage: 'logo-name.svg', }), panelPosition: 'right', sidebarAnimations: true, diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html deleted file mode 100644 index 1c18c9e88b..0000000000 --- a/.storybook/manager-head.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/api/charts.api.md b/api/charts.api.md index a47d23fc8f..d9d27b7e21 100644 --- a/api/charts.api.md +++ b/api/charts.api.md @@ -1435,7 +1435,7 @@ export const Partition: React.FunctionComponent; @@ -1741,7 +1741,7 @@ export type ScaleType = $Values; // Warning: (ae-missing-release-tag) "SectorGeomSpecY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export interface SectorGeomSpecY { // Warning: (ae-forgotten-export) The symbol "Distance" needs to be exported by the entry point index.d.ts // @@ -2224,7 +2224,7 @@ export type TreeLevel = number; // Warning: (ae-missing-release-tag) "TreeNode" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export interface TreeNode extends AngleFromTo { // (undocumented) fill?: Color; @@ -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/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-grids-lines-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-grids-lines-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..b4e16cf34f Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-grids-lines-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-interactions-line-point-clicks-and-hovers-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-interactions-line-point-clicks-and-hovers-visually-looks-correct-1-snap.png index b49ab72d60..970d228afc 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-interactions-line-point-clicks-and-hovers-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-interactions-line-point-clicks-and-hovers-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-stylings-custom-series-colors-via-accessor-function-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-stylings-custom-series-colors-via-accessor-function-visually-looks-correct-1-snap.png index 87c6c9c2a9..1c69df1961 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-stylings-custom-series-colors-via-accessor-function-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-stylings-custom-series-colors-via-accessor-function-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/grid-stories-test-ts-grid-stories-should-render-crosshair-lines-above-grid-lines-1-snap.png b/integration/tests/__image_snapshots__/grid-stories-test-ts-grid-stories-should-render-crosshair-lines-above-grid-lines-1-snap.png new file mode 100644 index 0000000000..d834825dbd Binary files /dev/null and b/integration/tests/__image_snapshots__/grid-stories-test-ts-grid-stories-should-render-crosshair-lines-above-grid-lines-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-1-snap.png index 186aa65987..93e2cbccc5 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-90-degree-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-90-degree-1-snap.png index ba9c76f72f..a9d68b8d40 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-90-degree-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-rectangular-brush-selection-90-degree-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-1-snap.png index aa04ba5541..66f9386ae7 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-90-degree-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-90-degree-1-snap.png index 5d2d2bee38..a04b385cdb 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-90-degree-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-x-brush-selection-90-degree-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-1-snap.png index 65d40a21eb..c9a96b77fc 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-90-degree-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-90-degree-1-snap.png index 08507e0281..0e0da9312e 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-90-degree-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-brushing-show-y-brush-selection-90-degree-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-formatting-should-use-custom-mark-formatters-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-formatting-should-use-custom-mark-formatters-1-snap.png index b9300e4f86..46d1a2d2b1 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-formatting-should-use-custom-mark-formatters-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-formatting-should-use-custom-mark-formatters-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-sync-show-synced-extra-values-in-legend-1-snap.png b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-sync-show-synced-extra-values-in-legend-1-snap.png index 642004a548..fce8fb07ce 100644 Binary files a/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-sync-show-synced-extra-values-in-legend-1-snap.png and b/integration/tests/__image_snapshots__/interactions-test-ts-interactions-tooltip-sync-show-synced-extra-values-in-legend-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/line-stories-test-ts-line-series-stories-line-with-mark-accessor-with-hidden-points-default-point-highlighter-size-1-snap.png b/integration/tests/__image_snapshots__/line-stories-test-ts-line-series-stories-line-with-mark-accessor-with-hidden-points-default-point-highlighter-size-1-snap.png index b8eaea4328..611db2dfca 100644 Binary files a/integration/tests/__image_snapshots__/line-stories-test-ts-line-series-stories-line-with-mark-accessor-with-hidden-points-default-point-highlighter-size-1-snap.png and b/integration/tests/__image_snapshots__/line-stories-test-ts-line-series-stories-line-with-mark-accessor-with-hidden-points-default-point-highlighter-size-1-snap.png differ diff --git a/integration/tests/grid_stories.test.ts b/integration/tests/grid_stories.test.ts new file mode 100644 index 0000000000..3edfeccb5d --- /dev/null +++ b/integration/tests/grid_stories.test.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. + */ + +import { common } from '../page_objects'; + +describe('Grid stories', () => { + it('should render crosshair lines above grid lines', async () => { + await common.expectChartWithMouseAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/grids--lines&knob-Stroke_Crosshair line=red&knob-Stroke width_Crosshair line=10&knob-Dash_Crosshair line[0]=0&knob-Dash_Crosshair line[1]=0&knob-Stroke_Crosshair cross line=red&knob-Stroke width_Crosshair cross line=10&knob-Dash_Crosshair cross line[0]=0&knob-Dash_Crosshair cross line[1]=0&knob-debug=&knob-Tooltip type=cross&knob-Show gridline_Left axis=true&knob-Opacity_Left axis=1&knob-Stroke_Left axis=rgba(0,0,0,1)&knob-Stroke width_Left axis=2&knob-Dash_Left axis[0]=4&knob-Dash_Left axis[1]=4&knob-Show gridline_Bottom axis=true&knob-Opacity_Bottom axis=1&knob-Stroke_Bottom axis=rgba(0,0,0,1)&knob-Stroke width_Bottom axis=2', + { top: 115, right: 120 }, + ); + }); +}); diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000..82c04278dc Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/logo-name.svg b/public/logo-name.svg new file mode 100644 index 0000000000..c9a8d0f2f6 --- /dev/null +++ b/public/logo-name.svg @@ -0,0 +1,19 @@ + + + + Group + Created with Sketch. + + + + + Elastic-Charts + + + + \ No newline at end of file diff --git a/src/chart_types/goal_chart/state/selectors/on_element_click_caller.ts b/src/chart_types/goal_chart/state/selectors/on_element_click_caller.ts index e919f044a3..61707c3ae3 100644 --- a/src/chart_types/goal_chart/state/selectors/on_element_click_caller.ts +++ b/src/chart_types/goal_chart/state/selectors/on_element_click_caller.ts @@ -21,13 +21,11 @@ import createCachedSelector from 're-reselect'; import { Selector } from 'reselect'; import { ChartTypes } from '../../..'; -import { SeriesIdentifier } from '../../../../common/series_id'; -import { SettingsSpec, LayerValue } from '../../../../specs'; -import { GlobalChartState, PointerState } from '../../../../state/chart_state'; +import { getOnElementClickSelector } from '../../../../common/event_handler_selectors'; +import { GlobalChartState, PointerStates } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getLastClickSelector } from '../../../../state/selectors/get_last_click'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { isClicking } from '../../../../state/utils'; import { getSpecOrNull } from './goal_spec'; import { getPickedShapesLayerValues } from './picked_shapes'; @@ -39,32 +37,13 @@ import { getPickedShapesLayerValues } from './picked_shapes'; * @internal */ export function createOnElementClickCaller(): (state: GlobalChartState) => void { - let prevClick: PointerState | null = null; + const prev: { click: PointerStates['lastClick'] } = { click: null }; let selector: Selector | null = null; return (state: GlobalChartState) => { if (selector === null && state.chartType === ChartTypes.Goal) { selector = createCachedSelector( [getSpecOrNull, getLastClickSelector, getSettingsSpecSelector, getPickedShapesLayerValues], - (spec, lastClick: PointerState | null, settings: SettingsSpec, pickedShapes): void => { - if (!spec) { - return; - } - if (!settings.onElementClick) { - return; - } - const nextPickedShapesLength = pickedShapes.length; - if (nextPickedShapesLength > 0 && isClicking(prevClick, lastClick) && settings && settings.onElementClick) { - const elements = pickedShapes.map<[Array, SeriesIdentifier]>((values) => [ - values, - { - specId: spec.id, - key: `spec{${spec.id}}`, - }, - ]); - settings.onElementClick(elements); - } - prevClick = lastClick; - }, + getOnElementClickSelector(prev), )(getChartIdSelector); } if (selector) { diff --git a/src/chart_types/goal_chart/state/selectors/on_element_out_caller.ts b/src/chart_types/goal_chart/state/selectors/on_element_out_caller.ts index b83d5ca693..3c2fa65b9f 100644 --- a/src/chart_types/goal_chart/state/selectors/on_element_out_caller.ts +++ b/src/chart_types/goal_chart/state/selectors/on_element_out_caller.ts @@ -21,6 +21,7 @@ import createCachedSelector from 're-reselect'; import { Selector } from 'react-redux'; import { ChartTypes } from '../../..'; +import { getOnElementOutSelector } from '../../../../common/event_handler_selectors'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; @@ -34,26 +35,13 @@ import { getPickedShapesLayerValues } from './picked_shapes'; * @internal */ export function createOnElementOutCaller(): (state: GlobalChartState) => void { - let prevPickedShapes: number | null = null; + const prev: { pickedShapes: number | null } = { pickedShapes: null }; let selector: Selector | null = null; return (state: GlobalChartState) => { if (selector === null && state.chartType === ChartTypes.Goal) { selector = createCachedSelector( [getSpecOrNull, getPickedShapesLayerValues, getSettingsSpecSelector], - (spec, pickedShapes, settings): void => { - if (!spec) { - return; - } - if (!settings.onElementOut) { - return; - } - const nextPickedShapes = pickedShapes.length; - - if (prevPickedShapes !== null && prevPickedShapes > 0 && nextPickedShapes === 0) { - settings.onElementOut(); - } - prevPickedShapes = nextPickedShapes; - }, + getOnElementOutSelector(prev), )(getChartIdSelector); } if (selector) { diff --git a/src/chart_types/goal_chart/state/selectors/on_element_over_caller.ts b/src/chart_types/goal_chart/state/selectors/on_element_over_caller.ts index 42a1b5a89e..db612a41b6 100644 --- a/src/chart_types/goal_chart/state/selectors/on_element_over_caller.ts +++ b/src/chart_types/goal_chart/state/selectors/on_element_over_caller.ts @@ -21,7 +21,7 @@ import createCachedSelector from 're-reselect'; import { Selector } from 'react-redux'; import { ChartTypes } from '../../..'; -import { SeriesIdentifier } from '../../../../common/series_id'; +import { getOnElementOverSelector } from '../../../../common/event_handler_selectors'; import { LayerValue } from '../../../../specs'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; @@ -29,31 +29,6 @@ import { getSettingsSpecSelector } from '../../../../state/selectors/get_setting import { getSpecOrNull } from './goal_spec'; import { getPickedShapesLayerValues } from './picked_shapes'; -function isOverElement(prevPickedShapes: Array> = [], nextPickedShapes: Array>) { - if (nextPickedShapes.length === 0) { - return; - } - if (nextPickedShapes.length !== prevPickedShapes.length) { - return true; - } - return !nextPickedShapes.every((nextPickedShapeValues, index) => { - const prevPickedShapeValues = prevPickedShapes[index]; - if (prevPickedShapeValues === null) { - return false; - } - if (prevPickedShapeValues.length !== nextPickedShapeValues.length) { - return false; - } - return nextPickedShapeValues.every((layerValue, i) => { - const prevPickedValue = prevPickedShapeValues[i]; - if (!prevPickedValue) { - return false; - } - return layerValue.value === prevPickedValue.value && layerValue.groupByRollup === prevPickedValue.groupByRollup; - }); - }); -} - /** * Will call the onElementOver listener every time the following preconditions are met: * - the onElementOver listener is available @@ -61,32 +36,13 @@ function isOverElement(prevPickedShapes: Array> = [], nextPick * @internal */ export function createOnElementOverCaller(): (state: GlobalChartState) => void { - let prevPickedShapes: Array> = []; + const prev: { pickedShapes: LayerValue[][] } = { pickedShapes: [] }; let selector: Selector | null = null; return (state: GlobalChartState) => { if (selector === null && state.chartType === ChartTypes.Goal) { selector = createCachedSelector( [getSpecOrNull, getPickedShapesLayerValues, getSettingsSpecSelector], - (spec, nextPickedShapes, settings): void => { - if (!spec) { - return; - } - if (!settings.onElementOver) { - return; - } - - if (isOverElement(prevPickedShapes, nextPickedShapes)) { - const elements = nextPickedShapes.map<[Array, SeriesIdentifier]>((values) => [ - values, - { - specId: spec.id, - key: `spec{${spec.id}}`, - }, - ]); - settings.onElementOver(elements); - } - prevPickedShapes = nextPickedShapes; - }, + getOnElementOverSelector(prev), )({ keySelector: getChartIdSelector, }); 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/types/viewmodel_types.ts b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts index 301df67091..e062bd5bc7 100644 --- a/src/chart_types/partition_chart/layout/types/viewmodel_types.ts +++ b/src/chart_types/partition_chart/layout/types/viewmodel_types.ts @@ -144,6 +144,13 @@ export interface AngleFromTo { x1: Radian; } +/** @internal */ +export interface LayerFromTo { + y0: TreeLevel; + y1: TreeLevel; +} + +/** potential internal */ export interface TreeNode extends AngleFromTo { x0: Radian; x1: Radian; @@ -152,6 +159,7 @@ export interface TreeNode extends AngleFromTo { fill?: Color; } +/** potential internal */ export interface SectorGeomSpecY { y0px: Distance; y1px: Distance; diff --git a/src/chart_types/partition_chart/layout/utils/circline_geometry.ts b/src/chart_types/partition_chart/layout/utils/circline_geometry.ts index a4371517bc..07596143b2 100644 --- a/src/chart_types/partition_chart/layout/utils/circline_geometry.ts +++ b/src/chart_types/partition_chart/layout/utils/circline_geometry.ts @@ -19,13 +19,19 @@ import { TAU } from '../../../../common/constants'; import { - CirclineArc, Circline, + CirclineArc, CirclinePredicate, + Coordinate, Distance, PointObject, + Radian, + Radius, RingSectorConstruction, + trueBearingToStandardPositionAngle, } from '../../../../common/geometry'; +import { Config } from '../types/config_types'; +import { AngleFromTo, LayerFromTo, ShapeTreeNode } from '../types/viewmodel_types'; function euclideanDistance({ x: x1, y: y1 }: PointObject, { x: x2, y: y2 }: PointObject): Distance { return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)); @@ -109,7 +115,9 @@ function circlineValidSectors(refC: CirclinePredicate, c: CirclineArc): Circline // imperative, slightly optimized buildup of `result` as it's in the hot loop: const result = []; for (let i = 0; i < breakpoints.length - 1; i++) { + // eslint-disable-next-line no-shadow const from = breakpoints[i]; + // eslint-disable-next-line no-shadow const to = breakpoints[i + 1]; const midAngle = (from + to) / 2; // no winding clip ie. `meanAngle()` would be wrong here const xx = x + r * Math.cos(midAngle); @@ -135,3 +143,98 @@ export function conjunctiveConstraint(constraints: RingSectorConstruction, c: Ci } return valids; } + +/** @internal */ +export const INFINITY_RADIUS = 1e4; // far enough for a sub-2px precision on a 4k screen, good enough for text bounds; 64 bit floats still work well with it + +/** @internal */ +export function angleToCircline( + midRadius: Radius, + alpha: Radian, + direction: 1 | -1 /* 1 for clockwise and -1 for anticlockwise circline */, +) { + const sectorRadiusLineX = Math.cos(alpha) * midRadius; + const sectorRadiusLineY = Math.sin(alpha) * midRadius; + const normalAngle = alpha + (direction * Math.PI) / 2; + const x = sectorRadiusLineX + INFINITY_RADIUS * Math.cos(normalAngle); + const y = sectorRadiusLineY + INFINITY_RADIUS * Math.sin(normalAngle); + return { x, y, r: INFINITY_RADIUS, inside: false, from: 0, to: TAU }; +} + +function ringSectorStartAngle(d: AngleFromTo): Radian { + return trueBearingToStandardPositionAngle(d.x0 + Math.max(0, d.x1 - d.x0 - TAU / 2) / 2); +} + +function ringSectorEndAngle(d: AngleFromTo): Radian { + return trueBearingToStandardPositionAngle(d.x1 - Math.max(0, d.x1 - d.x0 - TAU / 2) / 2); +} + +function ringSectorInnerRadius(innerRadius: Radian, ringThickness: Distance) { + return (d: LayerFromTo): Radius => innerRadius + d.y0 * ringThickness; +} + +function ringSectorOuterRadius(innerRadius: Radian, ringThickness: Distance) { + return (d: LayerFromTo): Radius => innerRadius + (d.y0 + 1) * ringThickness; +} + +/** @internal */ +export function ringSectorConstruction(config: Config, innerRadius: Radius, ringThickness: Distance) { + return (ringSector: ShapeTreeNode): RingSectorConstruction => { + const { + circlePadding, + radialPadding, + fillOutside, + radiusOutside, + fillRectangleWidth, + fillRectangleHeight, + } = config; + const radiusGetter = fillOutside ? ringSectorOuterRadius : ringSectorInnerRadius; + const geometricInnerRadius = radiusGetter(innerRadius, ringThickness)(ringSector); + const innerR = geometricInnerRadius + circlePadding * 2; + const outerR = Math.max( + innerR, + ringSectorOuterRadius(innerRadius, ringThickness)(ringSector) - circlePadding + (fillOutside ? radiusOutside : 0), + ); + const startAngle = ringSectorStartAngle(ringSector); + const endAngle = ringSectorEndAngle(ringSector); + const innerCircline = { x: 0, y: 0, r: innerR, inside: true, from: 0, to: TAU }; + const outerCircline = { x: 0, y: 0, r: outerR, inside: false, from: 0, to: TAU }; + const midRadius = (innerR + outerR) / 2; + const sectorStartCircle = angleToCircline(midRadius, startAngle - radialPadding, -1); + const sectorEndCircle = angleToCircline(midRadius, endAngle + radialPadding, 1); + const outerRadiusFromRectangleWidth = fillRectangleWidth / 2; + const outerRadiusFromRectanglHeight = fillRectangleHeight / 2; + const fullCircle = ringSector.x0 === 0 && ringSector.x1 === TAU && geometricInnerRadius === 0; + const sectorCirclines = [ + ...(fullCircle && innerRadius === 0 ? [] : [innerCircline]), + outerCircline, + ...(fullCircle ? [] : [sectorStartCircle, sectorEndCircle]), + ]; + const rectangleCirclines = + outerRadiusFromRectangleWidth === Infinity && outerRadiusFromRectanglHeight === Infinity + ? [] + : [ + { x: INFINITY_RADIUS - outerRadiusFromRectangleWidth, y: 0, r: INFINITY_RADIUS, inside: true }, + { x: -INFINITY_RADIUS + outerRadiusFromRectangleWidth, y: 0, r: INFINITY_RADIUS, inside: true }, + { x: 0, y: INFINITY_RADIUS - outerRadiusFromRectanglHeight, r: INFINITY_RADIUS, inside: true }, + { x: 0, y: -INFINITY_RADIUS + outerRadiusFromRectanglHeight, r: INFINITY_RADIUS, inside: true }, + ]; + return [...sectorCirclines, ...rectangleCirclines]; + }; +} +/** @internal */ +export function makeRowCircline( + cx: Coordinate, + cy: Coordinate, + radialOffset: Distance, + rotation: Radian, + fontSize: number, + offsetSign: -1 | 0 | 1, +) { + const r = INFINITY_RADIUS; + const offset = (offsetSign * fontSize) / 2; + const topRadius = r - offset; + const x = cx + topRadius * Math.cos(-rotation + TAU / 4); + const y = cy + topRadius * Math.cos(-rotation + TAU / 2); + return { r: r + radialOffset, x, y }; +} 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/utils/highlighted_geoms.ts b/src/chart_types/partition_chart/layout/utils/highlighted_geoms.ts new file mode 100644 index 0000000000..630b5f4622 --- /dev/null +++ b/src/chart_types/partition_chart/layout/utils/highlighted_geoms.ts @@ -0,0 +1,96 @@ +/* + * 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 { $Values as Values } from 'utility-types'; + +import { LegendPath } from '../../../../state/actions/legend'; +import { DataName, QuadViewModel } from '../types/viewmodel_types'; + +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 */ +export function highlightedGeoms( + legendStrategy: LegendStrategy | undefined, + quadViewModel: QuadViewModel[], + highlightedLegendItemPath: LegendPath, +) { + const pickedLogic: LegendStrategy = legendStrategy ?? defaultStrategy; + return quadViewModel.filter(legendStrategies[pickedLogic](highlightedLegendItemPath)); +} diff --git a/src/chart_types/partition_chart/layout/utils/legend.ts b/src/chart_types/partition_chart/layout/utils/legend.ts new file mode 100644 index 0000000000..0f92045ba4 --- /dev/null +++ b/src/chart_types/partition_chart/layout/utils/legend.ts @@ -0,0 +1,82 @@ +/* + * 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 { CategoryKey } from '../../../../common/category'; +import { map } from '../../../../common/iterables'; +import { LegendItem } from '../../../../common/legend'; +import { identity, Position } from '../../../../utils/common'; +import { isHierarchicalLegend } from '../../../../utils/legend'; +import { Layer } from '../../specs'; +import { QuadViewModel } from '../types/viewmodel_types'; + +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].index - b[i].index; + if (diff) { + return diff; + } + } + return a.length - b.length; // if one path is fully contained in the other, then parent (shorter) goes first +} + +/** @internal */ +export function getLegendItems( + id: string, + layers: Layer[], + flatLegend: boolean | undefined, + legendMaxDepth: number, + legendPosition: Position, + quadViewModel: QuadViewModel[], +): LegendItem[] { + const uniqueNames = new Set(map(({ dataName, fillColor }) => makeKey(dataName, fillColor), quadViewModel)); + const useHierarchicalLegend = isHierarchicalLegend(flatLegend, legendPosition); + + const excluded: Set = new Set(); + const items = quadViewModel.filter(({ depth, dataName, fillColor }) => { + if (legendMaxDepth != null) { + return depth <= legendMaxDepth; + } + if (!useHierarchicalLegend) { + const key = makeKey(dataName, fillColor); + if (uniqueNames.has(key) && excluded.has(key)) { + return false; + } + excluded.add(key); + } + return true; + }); + + items.sort(compareTreePaths); + + return items.map(({ dataName, fillColor, depth, path }) => { + const formatter = layers[depth - 1]?.nodeLabel ?? identity; + return { + color: fillColor, + label: formatter(dataName), + childId: dataName, + depth: useHierarchicalLegend ? depth - 1 : 0, + path, + seriesIdentifier: { key: dataName, specId: id }, + }; + }); +} diff --git a/src/chart_types/partition_chart/layout/utils/legend_labels.ts b/src/chart_types/partition_chart/layout/utils/legend_labels.ts new file mode 100644 index 0000000000..b7d85ffed9 --- /dev/null +++ b/src/chart_types/partition_chart/layout/utils/legend_labels.ts @@ -0,0 +1,64 @@ +/* + * 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 { LegendItemLabel } from '../../../../state/selectors/get_legend_items_labels'; +import { Layer } from '../../specs'; +import { CHILDREN_KEY, HIERARCHY_ROOT_KEY, HierarchyOfArrays } from './group_by_rollup'; + +/** @internal */ +export function getLegendLabels(layers: Layer[], tree: HierarchyOfArrays, legendMaxDepth: number) { + return flatSlicesNames(layers, 0, tree).filter(({ depth }) => depth <= legendMaxDepth); +} + +function flatSlicesNames( + layers: Layer[], + depth: number, + tree: HierarchyOfArrays, + keys: Map = new Map(), +): LegendItemLabel[] { + if (tree.length === 0) { + return []; + } + + for (let i = 0; i < tree.length; i++) { + const branch = tree[i]; + const arrayNode = branch[1]; + const key = branch[0]; + + // format the key with the layer formatter + const layer = layers[depth - 1]; + const formatter = layer?.nodeLabel; + let formattedValue = ''; + if (key != null) { + formattedValue = formatter ? formatter(key) : `${key}`; + } + // preventing errors from external formatters + 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)); + } + + const children = arrayNode[CHILDREN_KEY]; + flatSlicesNames(layers, depth + 1, children, keys); + } + return [...keys.keys()].map((k) => ({ + label: k, + depth: keys.get(k) ?? 0, + })); +} diff --git a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts index 6521c1da2d..cea5eec3d4 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/fill_text_layout.ts @@ -20,29 +20,28 @@ import chroma from 'chroma-js'; import { - combineColors, - makeHighContrastColor, colorIsDark, + combineColors, getTextColorIfTextInvertible, isColorValid, + makeHighContrastColor, } from '../../../../common/color_calcs'; import { TAU } from '../../../../common/constants'; import { Coordinate, Distance, Pixels, + PointTuple, Radian, - Radius, Ratio, RingSectorConstruction, - PointTuple, trueBearingToStandardPositionAngle, wrapToTau, } from '../../../../common/geometry'; import { logarithm } from '../../../../common/math'; import { integerSnap, monotonicHillClimb } from '../../../../common/optimize'; import { Box, Font, PartialFont, TextContrast, TextMeasure, VerticalAlignments } from '../../../../common/text_utils'; -import { ValueFormatter, Color } from '../../../../utils/common'; +import { Color, ValueFormatter } from '../../../../utils/common'; import { Logger } from '../../../../utils/logger'; import { Layer } from '../../specs'; import { Config, Padding } from '../types/config_types'; @@ -55,109 +54,15 @@ import { ShapeTreeNode, ValueGetterFunction, } from '../types/viewmodel_types'; -import { conjunctiveConstraint } from '../utils/circline_geometry'; +import { conjunctiveConstraint, INFINITY_RADIUS, makeRowCircline } from '../utils/circline_geometry'; import { RectangleConstruction } from './viewmodel'; -const INFINITY_RADIUS = 1e4; // far enough for a sub-2px precision on a 4k screen, good enough for text bounds; 64 bit floats still work well with it - -function ringSectorStartAngle(d: ShapeTreeNode): Radian { - return trueBearingToStandardPositionAngle(d.x0 + Math.max(0, d.x1 - d.x0 - TAU / 2) / 2); -} - -function ringSectorEndAngle(d: ShapeTreeNode): Radian { - return trueBearingToStandardPositionAngle(d.x1 - Math.max(0, d.x1 - d.x0 - TAU / 2) / 2); -} - -function ringSectorInnerRadius(innerRadius: Radian, ringThickness: Distance) { - return (d: ShapeTreeNode): Radius => innerRadius + d.y0 * ringThickness; -} -function ringSectorOuterRadius(innerRadius: Radian, ringThickness: Distance) { - return (d: ShapeTreeNode): Radius => innerRadius + (d.y0 + 1) * ringThickness; -} - -function angleToCircline( - midRadius: Radius, - alpha: Radian, - direction: 1 | -1 /* 1 for clockwise and -1 for anticlockwise circline */, -) { - const sectorRadiusLineX = Math.cos(alpha) * midRadius; - const sectorRadiusLineY = Math.sin(alpha) * midRadius; - const normalAngle = alpha + (direction * Math.PI) / 2; - const x = sectorRadiusLineX + INFINITY_RADIUS * Math.cos(normalAngle); - const y = sectorRadiusLineY + INFINITY_RADIUS * Math.sin(normalAngle); - const sectorRadiusCircline = { x, y, r: INFINITY_RADIUS, inside: false, from: 0, to: TAU }; - return sectorRadiusCircline; -} - /** @internal */ // todo pick a better unique key for the slices (D3 doesn't keep track of an index) export function nodeId(node: ShapeTreeNode): string { return `${node.x0}|${node.y0}`; } -/** @internal */ -export function ringSectorConstruction(config: Config, innerRadius: Radius, ringThickness: Distance) { - return (ringSector: ShapeTreeNode): RingSectorConstruction => { - const { - circlePadding, - radialPadding, - fillOutside, - radiusOutside, - fillRectangleWidth, - fillRectangleHeight, - } = config; - const radiusGetter = fillOutside ? ringSectorOuterRadius : ringSectorInnerRadius; - const geometricInnerRadius = radiusGetter(innerRadius, ringThickness)(ringSector); - const innerR = geometricInnerRadius + circlePadding * 2; - const outerR = Math.max( - innerR, - ringSectorOuterRadius(innerRadius, ringThickness)(ringSector) - circlePadding + (fillOutside ? radiusOutside : 0), - ); - const startAngle = ringSectorStartAngle(ringSector); - const endAngle = ringSectorEndAngle(ringSector); - const innerCircline = { x: 0, y: 0, r: innerR, inside: true, from: 0, to: TAU }; - const outerCircline = { x: 0, y: 0, r: outerR, inside: false, from: 0, to: TAU }; - const midRadius = (innerR + outerR) / 2; - const sectorStartCircle = angleToCircline(midRadius, startAngle - radialPadding, -1); - const sectorEndCircle = angleToCircline(midRadius, endAngle + radialPadding, 1); - const outerRadiusFromRectangleWidth = fillRectangleWidth / 2; - const outerRadiusFromRectanglHeight = fillRectangleHeight / 2; - const fullCircle = ringSector.x0 === 0 && ringSector.x1 === TAU && geometricInnerRadius === 0; - const sectorCirclines = [ - ...(fullCircle && innerRadius === 0 ? [] : [innerCircline]), - outerCircline, - ...(fullCircle ? [] : [sectorStartCircle, sectorEndCircle]), - ]; - const rectangleCirclines = - outerRadiusFromRectangleWidth === Infinity && outerRadiusFromRectanglHeight === Infinity - ? [] - : [ - { x: INFINITY_RADIUS - outerRadiusFromRectangleWidth, y: 0, r: INFINITY_RADIUS, inside: true }, - { x: -INFINITY_RADIUS + outerRadiusFromRectangleWidth, y: 0, r: INFINITY_RADIUS, inside: true }, - { x: 0, y: INFINITY_RADIUS - outerRadiusFromRectanglHeight, r: INFINITY_RADIUS, inside: true }, - { x: 0, y: -INFINITY_RADIUS + outerRadiusFromRectanglHeight, r: INFINITY_RADIUS, inside: true }, - ]; - return [...sectorCirclines, ...rectangleCirclines]; - }; -} - -function makeRowCircline( - cx: Coordinate, - cy: Coordinate, - radialOffset: Distance, - rotation: Radian, - fontSize: number, - offsetSign: -1 | 0 | 1, -) { - const r = INFINITY_RADIUS; - const offset = (offsetSign * fontSize) / 2; - const topRadius = r - offset; - const x = cx + topRadius * Math.cos(-rotation + TAU / 4); - const y = cy + topRadius * Math.cos(-rotation + TAU / 2); - const circline = { r: r + radialOffset, x, y }; - return circline; -} - /** @internal */ export const getSectorRowGeometry: GetShapeRowGeometry = ( ringSector, @@ -319,7 +224,7 @@ export function getFillTextColor( if (bgColorAlpha < 1) { Logger.expected('Text contrast requires a background color with an alpha value of 1', 1, bgColorAlpha); } else if (containerBackgroundColor !== 'transparent') { - Logger.warn(`Invalid background color "${containerBackgroundColor}"`); + Logger.warn(`Invalid background color "${String(containerBackgroundColor)}"`); } return getTextColorIfTextInvertible( @@ -356,6 +261,7 @@ export function getFillTextColor( return adjustedTextColor; } + type GetShapeRowGeometry = ( container: C, cx: Distance, 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 44b51f8756..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,25 +17,36 @@ * 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'; import { IndexedAccessorFn } from '../../../../utils/accessor'; -import { ValueAccessor } from '../../../../utils/common'; +import { Datum, ValueAccessor, ValueFormatter } from '../../../../utils/common'; +import { Layer } from '../../specs'; +import { PartitionLayout } from '../types/config_types'; import { - HierarchyOfArrays, aggregateComparator, aggregators, childOrders, + CHILDREN_KEY, groupByRollup, + HIERARCHY_ROOT_KEY, + HierarchyOfArrays, mapEntryValue, mapsToArrays, Sorter, } from '../utils/group_by_rollup'; +import { isSunburst, isTreemap } from './viewmodel'; +/* @internal */ export function getHierarchyOfArrays( rawFacts: Relation, valueAccessor: ValueAccessor, groupByRollupAccessors: IndexedAccessorFn[], sorter: Sorter | null = childOrders.descending, + drilldown: boolean, + drilldownSelection: CategoryKey[], ): HierarchyOfArrays { const aggregator = aggregators.sum; @@ -53,7 +64,61 @@ 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), ); } + +/** @internal */ +export function partitionTree( + data: Datum[], + valueAccessor: ValueAccessor, + layers: Layer[], + defaultLayout: PartitionLayout, + layout: PartitionLayout = defaultLayout, + drilldown: boolean, + drilldownSelection: CategoryKey[], +) { + const sorter = isTreemap(layout) || isSunburst(layout) ? childOrders.descending : null; + return getHierarchyOfArrays( + data, + valueAccessor, + // eslint-disable-next-line no-shadow + [() => HIERARCHY_ROOT_KEY, ...layers.map(({ groupByRollup }) => groupByRollup)], + sorter, + drilldown, + drilldownSelection, + ); +} + +/** + * Creates flat extra value map from nested key path + * @internal + */ +export function getExtraValueMap( + layers: Layer[], + valueFormatter: ValueFormatter, + tree: HierarchyOfArrays, + maxDepth: number, + depth: number = 0, + keys: Map = new Map(), +): Map { + for (let i = 0; i < tree.length; i++) { + const branch = tree[i]; + const [key, arrayNode] = branch; + const { value, path, [CHILDREN_KEY]: children } = arrayNode; + + if (key != null) { + const values: LegendItemExtraValues = new Map(); + const formattedValue = valueFormatter ? valueFormatter(value) : value; + + values.set(key, formattedValue); + keys.set(path.map(({ index }) => index).join('__'), values); + } + + if (depth < maxDepth) { + getExtraValueMap(layers, valueFormatter, children, maxDepth, depth + 1, keys); + } + } + return keys; +} diff --git a/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts b/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts index af00ba6363..d6492f87b5 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/link_text_layout.ts @@ -17,26 +17,22 @@ * under the License. */ -import { makeHighContrastColor, isColorValid } from '../../../../common/color_calcs'; +import { getOnPaperColorSet } from '../../../../common/color_calcs'; import { TAU } from '../../../../common/constants'; import { Distance, meanAngle, + Pixels, PointTuple, PointTuples, trueBearingToStandardPositionAngle, } from '../../../../common/geometry'; -import { integerSnap, monotonicHillClimb } from '../../../../common/optimize'; -import { Box, Font, TextAlign, TextMeasure } from '../../../../common/text_utils'; -import { ValueFormatter, Color } from '../../../../utils/common'; +import { cutToLength, fitText, Font, measureOneBoxWidth, TextMeasure } from '../../../../common/text_utils'; +import { Color, ValueFormatter } from '../../../../utils/common'; import { Point } from '../../../../utils/point'; -import { Config } from '../types/config_types'; +import { Config, LinkLabelConfig } from '../types/config_types'; import { LinkLabelVM, RawTextGetter, ShapeTreeNode, ValueGetterFunction } from '../types/viewmodel_types'; -function cutToLength(s: string, maxLength: number) { - return s.length <= maxLength ? s : `${s.slice(0, Math.max(0, maxLength - 1))}…`; // ellipsis is one char -} - /** @internal */ export interface LinkLabelsViewModelSpec { linkLabels: LinkLabelVM[]; @@ -65,117 +61,142 @@ export function linkTextLayout( const maxDepth = nodesWithoutRoom.reduce((p: number, n: ShapeTreeNode) => Math.max(p, n.depth), 0); const yRelativeIncrement = Math.sin(linkLabel.stemAngle) * linkLabel.minimumStemLength; const rowPitch = linkLabel.fontSize + linkLabel.spacing; - // determine the ideal contrast color for the link labels - const validBackgroundColor = isColorValid(containerBackgroundColor) - ? containerBackgroundColor - : 'rgba(255, 255, 255, 0)'; - const contrastTextColor = containerBackgroundColor - ? makeHighContrastColor(linkLabel.textColor, validBackgroundColor) - : linkLabel.textColor; - const strokeColor = containerBackgroundColor - ? makeHighContrastColor(sectorLineStroke, validBackgroundColor) - : undefined; - const labelFontSpec = { - ...linkLabel, - textColor: contrastTextColor, - }; - const valueFontSpec = { - ...linkLabel, - ...linkLabel.valueFont, - textColor: contrastTextColor, - }; + + const { contrastTextColor, strokeColor } = getOnPaperColorSet( + linkLabel.textColor, + sectorLineStroke, + containerBackgroundColor, + ); + const labelFontSpec: Font = { ...linkLabel, textColor: contrastTextColor }; + const valueFontSpec: Font = { ...linkLabel, ...linkLabel.valueFont, textColor: contrastTextColor }; const linkLabels: LinkLabelVM[] = nodesWithoutRoom .filter((n: ShapeTreeNode) => n.depth === maxDepth) // only the outermost ring can have links .sort((n1: ShapeTreeNode, n2: ShapeTreeNode) => Math.abs(n2.x0 - n2.x1) - Math.abs(n1.x0 - n1.x1)) .slice(0, linkLabel.maxCount) // largest linkLabel.MaxCount slices - .sort((n1: ShapeTreeNode, n2: ShapeTreeNode) => { - const mid1 = meanAngle(n1.x0, n1.x1); - const mid2 = meanAngle(n2.x0, n2.x1); - const dist1 = Math.min(Math.abs(mid1 - TAU / 4), Math.abs(mid1 - (3 * TAU) / 4)); - const dist2 = Math.min(Math.abs(mid2 - TAU / 4), Math.abs(mid2 - (3 * TAU) / 4)); - return dist1 - dist2; - }) - .map((node: ShapeTreeNode) => { - const midAngle = trueBearingToStandardPositionAngle((node.x0 + node.x1) / 2); - const north = midAngle < TAU / 2 ? 1 : -1; - const rightSide = TAU / 4 < midAngle && midAngle < (3 * TAU) / 4 ? 0 : 1; - const west = rightSide ? 1 : -1; - const cos = Math.cos(midAngle); - const sin = Math.sin(midAngle); - const x0 = cos * anchorRadius; - const y0 = sin * anchorRadius; - const x = cos * (anchorRadius + linkLabel.radiusPadding); - const y = sin * (anchorRadius + linkLabel.radiusPadding); - const poolIndex = rightSide + (1 - north); - const relativeY = north * y; - currentY[poolIndex] = Math.max(currentY[poolIndex] + rowPitch, relativeY + yRelativeIncrement, rowPitch / 2); - const cy = north * currentY[poolIndex]; - const stemFromX = x; - const stemFromY = y; - const stemToX = x + north * west * cy - west * relativeY; - const stemToY = cy; - const rawText = rawTextGetter(node); - const labelText = cutToLength(rawText, maxTextLength); - const valueText = valueFormatter(valueGetter(node)); - - const labelFontSpec: Font = { - ...linkLabel, - }; - const valueFontSpec: Font = { - ...linkLabel, - ...linkLabel.valueFont, - }; - const translateX = stemToX + west * (linkLabel.horizontalStemLength + linkLabel.gap); - const { width: valueWidth } = measure(linkLabel.fontSize, [{ ...valueFontSpec, text: valueText }])[0]; - const widthAdjustment = valueWidth + 2 * linkLabel.fontSize; // gap between label and value, plus possibly 2em wide ellipsis - const allottedLabelWidth = Math.max( - 0, - rightSide - ? rectWidth - diskCenter.x - translateX - widthAdjustment - : diskCenter.x + translateX - widthAdjustment, - ); - const { text, width, verticalOffset } = - linkLabel.fontSize / 2 <= cy + diskCenter.y && cy + diskCenter.y <= rectHeight - linkLabel.fontSize / 2 - ? fitText(measure, labelText, allottedLabelWidth, linkLabel.fontSize, { - ...labelFontSpec, - text: labelText, - }) - : { text: '', width: 0, verticalOffset: 0 }; - const linkLabels: PointTuples = [ - [x0, y0], - [stemFromX, stemFromY], - [stemToX, stemToY], - [stemToX + west * linkLabel.horizontalStemLength, stemToY], - ]; - const translate: PointTuple = [translateX, stemToY]; - const textAlign: TextAlign = rightSide ? 'left' : 'right'; - return { - linkLabels, - translate, - textAlign, - text, - valueText, - width, - valueWidth, - verticalOffset, - labelFontSpec, - valueFontSpec, - }; - }) + .sort(linkLabelCompare) + .map( + nodeToLinkLabel({ + linkLabel, + anchorRadius, + currentY, + rowPitch, + yRelativeIncrement, + rawTextGetter, + maxTextLength, + valueFormatter, + valueGetter, + measure, + rectWidth, + rectHeight, + diskCenter, + }), + ) .filter(({ text }) => text !== ''); // cull linked labels whose text was truncated to nothing; + return { linkLabels, valueFontSpec, labelFontSpec, strokeColor }; +} + +function linkLabelCompare(n1: ShapeTreeNode, n2: ShapeTreeNode) { + const mid1 = meanAngle(n1.x0, n1.x1); + const mid2 = meanAngle(n2.x0, n2.x1); + const dist1 = Math.min(Math.abs(mid1 - TAU / 4), Math.abs(mid1 - (3 * TAU) / 4)); + const dist2 = Math.min(Math.abs(mid2 - TAU / 4), Math.abs(mid2 - (3 * TAU) / 4)); + return dist1 - dist2; +} + +function nodeToLinkLabel({ + linkLabel, + anchorRadius, + currentY, + rowPitch, + yRelativeIncrement, + rawTextGetter, + maxTextLength, + valueFormatter, + valueGetter, + measure, + rectWidth, + rectHeight, + diskCenter, +}: { + linkLabel: LinkLabelConfig; + anchorRadius: Distance; + currentY: Distance[]; + rowPitch: Pixels; + yRelativeIncrement: Distance; + rawTextGetter: RawTextGetter; + maxTextLength: number; + valueFormatter: ValueFormatter; + valueGetter: ValueGetterFunction; + measure: TextMeasure; + rectWidth: Distance; + rectHeight: Distance; + diskCenter: Point; +}) { + const labelFont: Font = linkLabel; // only interested in the font properties + const valueFont: Font = { ...labelFont, ...linkLabel.valueFont }; // only interested in the font properties + return function nodeToLinkLabelMap(node: ShapeTreeNode): LinkLabelVM { + // geometry + const midAngle = trueBearingToStandardPositionAngle((node.x0 + node.x1) / 2); + const north = midAngle < TAU / 2 ? 1 : -1; + const rightSide = TAU / 4 < midAngle && midAngle < (3 * TAU) / 4 ? 0 : 1; + const west = rightSide ? 1 : -1; + const cos = Math.cos(midAngle); + const sin = Math.sin(midAngle); + const x0 = cos * anchorRadius; + const y0 = sin * anchorRadius; + const x = cos * (anchorRadius + linkLabel.radiusPadding); + const y = sin * (anchorRadius + linkLabel.radiusPadding); + const stemFromX = x; // might be different in the future, eg. to allow a small gap: doc purpose + const stemFromY = y; // might be different in the future, eg. to allow a small gap: doc purpose + + // calculate and remember vertical offset, as linked labels accrete + const poolIndex = rightSide + (1 - north); + const relativeY = north * y; + const yOffset = Math.max(currentY[poolIndex] + rowPitch, relativeY + yRelativeIncrement, rowPitch / 2); + currentY[poolIndex] = yOffset; + + // more geometry: the part that depends on vertical position + const cy = north * yOffset; + const stemToX = x + north * west * cy - west * relativeY; + const stemToY = cy; + const translateX = stemToX + west * (linkLabel.horizontalStemLength + linkLabel.gap); + const translate: PointTuple = [translateX, stemToY]; + + // the path points of the label link, ie. a polyline + const linkLabels: PointTuples = [ + [x0, y0], + [stemFromX, stemFromY], + [stemToX, stemToY], + [stemToX + west * linkLabel.horizontalStemLength, stemToY], + ]; + + // value text is simple: the full, formatted value is always shown, not truncated + const valueText = valueFormatter(valueGetter(node)); + const valueWidth = measureOneBoxWidth(measure, linkLabel.fontSize, { ...valueFont, text: valueText }); + const widthAdjustment = valueWidth + 2 * linkLabel.fontSize; // gap between label and value, plus possibly 2em wide ellipsis + + // label text removes space allotted for value and gaps, then tries to fit as much as possible + const labelText = cutToLength(rawTextGetter(node), maxTextLength); + const allottedLabelWidth = Math.max( + 0, + rightSide ? rectWidth - diskCenter.x - translateX - widthAdjustment : diskCenter.x + translateX - widthAdjustment, + ); + const { text, width, verticalOffset } = + linkLabel.fontSize / 2 <= cy + diskCenter.y && cy + diskCenter.y <= rectHeight - linkLabel.fontSize / 2 + ? fitText(measure, labelText, allottedLabelWidth, linkLabel.fontSize, labelFont) + : { text: '', width: 0, verticalOffset: 0 }; - function fitText(measure: TextMeasure, desiredText: string, allottedWidth: number, fontSize: number, box: Box) { - const desiredLength = desiredText.length; - const response = (v: number) => measure(fontSize, [{ ...box, text: box.text.slice(0, Math.max(0, v)) }])[0].width; - const visibleLength = monotonicHillClimb(response, desiredLength, allottedWidth, integerSnap); - const text = visibleLength < 2 && desiredLength >= 2 ? '' : cutToLength(box.text, visibleLength); - const { width, emHeightAscent, emHeightDescent } = measure(fontSize, [{ ...box, text }])[0]; return { - width, - verticalOffset: -(emHeightDescent + emHeightAscent) / 2, // meaning, `middle` + linkLabels, + translate, text, + valueText, + width, + valueWidth, + verticalOffset, + textAlign: rightSide ? 'left' : 'right', }; - } + }; } diff --git a/src/chart_types/partition_chart/layout/viewmodel/picked_shapes.ts b/src/chart_types/partition_chart/layout/viewmodel/picked_shapes.ts new file mode 100644 index 0000000000..3c2587fa38 --- /dev/null +++ b/src/chart_types/partition_chart/layout/viewmodel/picked_shapes.ts @@ -0,0 +1,66 @@ +/* + * 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 { LayerValue } from '../../../../specs'; +import { Point } from '../../../../utils/point'; +import { MODEL_KEY } from '../config'; +import { QuadViewModel, ShapeViewModel } from '../types/viewmodel_types'; +import { AGGREGATE_KEY, DEPTH_KEY, getNodeName, PARENT_KEY, PATH_KEY, SORT_INDEX_KEY } from '../utils/group_by_rollup'; + +/** @internal */ +export const pickedShapes = (models: ShapeViewModel[], pointerPosition: Point): QuadViewModel[] => { + const geoms = models[0]; + const picker = geoms.pickQuads; + const { diskCenter } = geoms; + const x = pointerPosition.x - diskCenter.x; + const y = pointerPosition.y - diskCenter.y; + return picker(x, y); +}; + +/** @internal */ +export function pickShapesLayerValues(shapes: QuadViewModel[]): LayerValue[][] { + const maxDepth = shapes.reduce((acc, curr) => Math.max(acc, curr.depth), 0); + return shapes + .filter(({ depth }) => depth === maxDepth) // eg. lowest layer in a treemap, where layers overlap in screen space; doesn't apply to sunburst/flame + .map>((viewModel) => { + const values: Array = []; + values.push({ + groupByRollup: viewModel.dataName, + value: viewModel[AGGREGATE_KEY], + depth: viewModel[DEPTH_KEY], + sortIndex: viewModel[SORT_INDEX_KEY], + path: viewModel[PATH_KEY], + }); + let node = viewModel[MODEL_KEY]; + while (node[DEPTH_KEY] > 0) { + const value = node[AGGREGATE_KEY]; + const dataName = getNodeName(node); + values.push({ + groupByRollup: dataName, + value, + depth: node[DEPTH_KEY], + sortIndex: node[SORT_INDEX_KEY], + path: node[PATH_KEY], + }); + + node = node[PARENT_KEY]; + } + return values.reverse(); + }); +} diff --git a/src/chart_types/partition_chart/state/selectors/scenegraph.ts b/src/chart_types/partition_chart/layout/viewmodel/scenegraph.ts similarity index 87% rename from src/chart_types/partition_chart/state/selectors/scenegraph.ts rename to src/chart_types/partition_chart/layout/viewmodel/scenegraph.ts index 9049b2bbb1..549c19feb6 100644 --- a/src/chart_types/partition_chart/state/selectors/scenegraph.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/scenegraph.ts @@ -20,18 +20,18 @@ import { measureText } from '../../../../common/text_utils'; import { identity, mergePartial, RecursivePartial, Color } from '../../../../utils/common'; import { Dimensions } from '../../../../utils/dimensions'; -import { config as defaultConfig, VALUE_GETTERS } from '../../layout/config'; -import { Config } from '../../layout/types/config_types'; +import { PartitionSpec, Layer } from '../../specs'; +import { config as defaultConfig, VALUE_GETTERS } from '../config'; +import { Config } from '../types/config_types'; import { ShapeTreeNode, ShapeViewModel, RawTextGetter, nullShapeViewModel, ValueGetter, -} from '../../layout/types/viewmodel_types'; -import { DEPTH_KEY, HierarchyOfArrays } from '../../layout/utils/group_by_rollup'; -import { shapeViewModel } from '../../layout/viewmodel/viewmodel'; -import { PartitionSpec, Layer } from '../../specs'; +} from '../types/viewmodel_types'; +import { DEPTH_KEY, HierarchyOfArrays } from '../utils/group_by_rollup'; +import { shapeViewModel } from './viewmodel'; function rawTextGetter(layers: Layer[]): RawTextGetter { return (node: ShapeTreeNode) => { @@ -46,7 +46,7 @@ export function valueGetterFunction(valueGetter: ValueGetter) { } /** @internal */ -export function render( +export function getShapeViewModel( partitionSpec: PartitionSpec, parentDimensions: Dimensions, tree: HierarchyOfArrays, diff --git a/src/chart_types/partition_chart/layout/viewmodel/tooltip_info.ts b/src/chart_types/partition_chart/layout/viewmodel/tooltip_info.ts new file mode 100644 index 0000000000..a33793d99a --- /dev/null +++ b/src/chart_types/partition_chart/layout/viewmodel/tooltip_info.ts @@ -0,0 +1,72 @@ +/* + * 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 { TooltipInfo } from '../../../../components/tooltip/types'; +import { LabelAccessor, ValueFormatter } from '../../../../utils/common'; +import { percentValueGetter, sumValueGetter } from '../config'; +import { QuadViewModel, ValueGetter } from '../types/viewmodel_types'; +import { valueGetterFunction } from './scenegraph'; + +/** @internal */ +export const EMPTY_TOOLTIP = Object.freeze({ + header: null, + values: [], +}); + +/** @internal */ +export function getTooltipInfo( + pickedShapes: QuadViewModel[], + labelFormatters: (LabelAccessor | undefined)[], + valueGetter: ValueGetter, + valueFormatter: ValueFormatter, + percentFormatter: ValueFormatter, + id: string, +) { + if (!valueFormatter || !labelFormatters) { + return EMPTY_TOOLTIP; + } + + const tooltipInfo: TooltipInfo = { + header: null, + values: [], + }; + + const valueGetterFun = valueGetterFunction(valueGetter); + const primaryValueGetterFun = valueGetterFun === percentValueGetter ? sumValueGetter : valueGetterFun; + pickedShapes.forEach((shape) => { + const formatter = labelFormatters[shape.depth - 1]; + const value = primaryValueGetterFun(shape); + + tooltipInfo.values.push({ + label: formatter ? formatter(shape.dataName) : shape.dataName, + color: shape.fillColor, + isHighlighted: false, + isVisible: true, + seriesIdentifier: { + specId: id, + key: id, + }, + value, + formattedValue: `${valueFormatter(value)} (${percentFormatter(percentValueGetter(shape))})`, + valueAccessor: shape.depth, + }); + }); + + return tooltipInfo; +} diff --git a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts index 6ed6af3f31..7d4d335569 100644 --- a/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts +++ b/src/chart_types/partition_chart/layout/viewmodel/viewmodel.ts @@ -43,6 +43,7 @@ import { ShapeViewModel, ValueGetterFunction, } from '../types/viewmodel_types'; +import { ringSectorConstruction } from '../utils/circline_geometry'; import { aggregateAccessor, ArrayEntry, @@ -60,7 +61,6 @@ import { getTopPadding, treemap } from '../utils/treemap'; import { fillTextLayout, getRectangleRowGeometry, - ringSectorConstruction, getSectorRowGeometry, inSectorRotation, nodeId, diff --git a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts index c6fe7e5a6e..4fd6d4358a 100644 --- a/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts +++ b/src/chart_types/partition_chart/renderer/canvas/canvas_renderers.ts @@ -21,7 +21,7 @@ import { addOpacity } from '../../../../common/color_calcs'; import { TAU } from '../../../../common/constants'; import { Pixels } from '../../../../common/geometry'; import { cssFontShorthand } from '../../../../common/text_utils'; -import { clearCanvas, renderLayers, withContext } from '../../../../renderers/canvas'; +import { renderLayers, withContext } from '../../../../renderers/canvas'; import { Color } from '../../../../utils/common'; import { LinkLabelVM, @@ -261,9 +261,6 @@ export function renderPartitionCanvas2d( // unlike SVG and esp. WebGL, Canvas2d doesn't support the 3rd dimension well, see ctx.transform / ctx.setTransform). // The layers are callbacks, because of the need to not bake in the `ctx`, it feels more composable and uncoupled this way. renderLayers(ctx, [ - // clear the canvas - (ctx: CanvasRenderingContext2D) => clearCanvas(ctx, 200000, 200000), - // bottom layer: sectors (pie slices, ring sectors etc.) (ctx: CanvasRenderingContext2D) => isSunburst(config.partitionLayout) ? renderSectors(ctx, quadViewModel) : renderRectangles(ctx, quadViewModel), diff --git a/src/chart_types/partition_chart/renderer/canvas/partition.tsx b/src/chart_types/partition_chart/renderer/canvas/partition.tsx index ec26c18737..ef9d6055ad 100644 --- a/src/chart_types/partition_chart/renderer/canvas/partition.tsx +++ b/src/chart_types/partition_chart/renderer/canvas/partition.tsx @@ -21,6 +21,7 @@ import React, { MouseEvent, RefObject } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators, Dispatch } from 'redux'; +import { clearCanvas } from '../../../../renderers/canvas'; import { onChartRendered } from '../../../../state/actions/chart'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartContainerDimensionsSelector } from '../../../../state/selectors/get_chart_container_dimensions'; @@ -29,12 +30,13 @@ import { Dimensions } from '../../../../utils/dimensions'; import { MODEL_KEY } from '../../layout/config'; import { nullShapeViewModel, QuadViewModel, ShapeViewModel } from '../../layout/types/viewmodel_types'; import { INPUT_KEY } from '../../layout/utils/group_by_rollup'; -import { partitionGeometries } from '../../state/selectors/geometries'; +import { partitionMultiGeometries } from '../../state/selectors/geometries'; import { renderPartitionCanvas2d } from './canvas_renderers'; interface ReactiveChartStateProps { initialized: boolean; geometries: ShapeViewModel; + multiGeometries: ShapeViewModel[]; chartContainerDimensions: Dimensions; } @@ -46,10 +48,11 @@ interface ReactiveChartOwnProps { } type PartitionProps = ReactiveChartStateProps & ReactiveChartDispatchProps & ReactiveChartOwnProps; + class PartitionComponent extends React.Component { static displayName = 'Partition'; - // firstRender = true; // this'll be useful for stable resizing of treemaps + // firstRender = true; // this will be useful for stable resizing of treemaps private ctx: CanvasRenderingContext2D | null; // see example https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#Example @@ -139,10 +142,13 @@ class PartitionComponent extends React.Component { private drawCanvas() { if (this.ctx) { const { width, height }: Dimensions = this.props.chartContainerDimensions; - renderPartitionCanvas2d(this.ctx, this.devicePixelRatio, { - ...this.props.geometries, - config: { ...this.props.geometries.config, width, height }, - }); + clearCanvas(this.ctx, width * this.devicePixelRatio, height * this.devicePixelRatio); + for (const geometries of this.props.multiGeometries) { + renderPartitionCanvas2d(this.ctx, this.devicePixelRatio, { + ...geometries, + config: { ...geometries.config, width, height }, + }); + } } } @@ -163,6 +169,7 @@ const mapDispatchToProps = (dispatch: Dispatch): ReactiveChartDispatchProps => const DEFAULT_PROPS: ReactiveChartStateProps = { initialized: false, geometries: nullShapeViewModel(), + multiGeometries: [], chartContainerDimensions: { width: 0, height: 0, @@ -175,9 +182,11 @@ const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => { if (getInternalIsInitializedSelector(state) !== InitStatus.Initialized) { return DEFAULT_PROPS; } + const multiGeometries = partitionMultiGeometries(state); return { initialized: true, - geometries: partitionGeometries(state), + geometries: multiGeometries.length > 0 ? multiGeometries[0] : nullShapeViewModel(), + multiGeometries, chartContainerDimensions: getChartContainerDimensionsSelector(state), }; }; diff --git a/src/chart_types/partition_chart/renderer/dom/highlighter_hover.tsx b/src/chart_types/partition_chart/renderer/dom/highlighter_hover.tsx index 246c193197..398821940b 100644 --- a/src/chart_types/partition_chart/renderer/dom/highlighter_hover.tsx +++ b/src/chart_types/partition_chart/renderer/dom/highlighter_hover.tsx @@ -36,7 +36,7 @@ const hoverMapStateToProps = (state: GlobalChartState): HighlighterProps => { outerRadius, diskCenter, config: { partitionLayout }, - } = partitionGeometries(state); + } = partitionGeometries(state)[0]; const geometries = getPickedShapes(state); const canvasDimension = getChartContainerDimensionsSelector(state); 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 334dceef8f..495c0d3188 100644 --- a/src/chart_types/partition_chart/renderer/dom/highlighter_legend.tsx +++ b/src/chart_types/partition_chart/renderer/dom/highlighter_legend.tsx @@ -36,7 +36,7 @@ const legendMapStateToProps = (state: GlobalChartState): HighlighterProps => { outerRadius, diskCenter, config: { partitionLayout }, - } = partitionGeometries(state); + } = partitionGeometries(state)[0]; const geometries = legendHoverHighlightNodes(state); const canvasDimension = getChartContainerDimensionsSelector(state); diff --git a/src/chart_types/partition_chart/renderer/dom/layered_partition_chart.tsx b/src/chart_types/partition_chart/renderer/dom/layered_partition_chart.tsx new file mode 100644 index 0000000000..4ccc91fd90 --- /dev/null +++ b/src/chart_types/partition_chart/renderer/dom/layered_partition_chart.tsx @@ -0,0 +1,37 @@ +/* + * 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 React, { RefObject } from 'react'; + +import { Tooltip } from '../../../../components/tooltip'; +import { BackwardRef } from '../../../../state/chart_state'; +import { Partition } from '../canvas/partition'; +import { HighlighterFromHover } from './highlighter_hover'; +import { HighlighterFromLegend } from './highlighter_legend'; + +export function render(containerRef: BackwardRef, forwardStageRef: RefObject) { + return ( + <> + + + + + + ); +} diff --git a/src/chart_types/partition_chart/state/chart_state.tsx b/src/chart_types/partition_chart/state/chart_state.tsx index e666eac543..416f8b8a58 100644 --- a/src/chart_types/partition_chart/state/chart_state.tsx +++ b/src/chart_types/partition_chart/state/chart_state.tsx @@ -17,18 +17,15 @@ * under the License. */ -import React, { RefObject } from 'react'; +import { RefObject } from 'react'; import { ChartTypes } from '../..'; import { DEFAULT_CSS_CURSOR } from '../../../common/constants'; -import { Tooltip } from '../../../components/tooltip'; import { BackwardRef, GlobalChartState, InternalChartState } from '../../../state/chart_state'; import { InitStatus } from '../../../state/selectors/get_internal_is_intialized'; import { DebugState } from '../../../state/types'; import { Dimensions } from '../../../utils/dimensions'; -import { Partition } from '../renderer/canvas/partition'; -import { HighlighterFromHover } from '../renderer/dom/highlighter_hover'; -import { HighlighterFromLegend } from '../renderer/dom/highlighter_legend'; +import { render } from '../renderer/dom/layered_partition_chart'; import { computeLegendSelector } from './selectors/compute_legend'; import { getLegendItemsExtra } from './selectors/get_legend_items_extra'; import { getLegendItemsLabels } from './selectors/get_legend_items_labels'; @@ -39,17 +36,6 @@ import { createOnElementOverCaller } from './selectors/on_element_over_caller'; import { getPartitionSpec } from './selectors/partition_spec'; import { getTooltipInfoSelector } from './selectors/tooltip'; -function render(containerRef: BackwardRef, forwardStageRef: RefObject) { - return ( - <> - - - - - - ); -} - /** @internal */ export class PartitionState implements InternalChartState { chartType = ChartTypes.Partition; 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 a604274d40..c6ce8bba1c 100644 --- a/src/chart_types/partition_chart/state/selectors/compute_legend.ts +++ b/src/chart_types/partition_chart/state/selectors/compute_legend.ts @@ -19,69 +19,27 @@ import createCachedSelector from 're-reselect'; -import { CategoryKey } from '../../../../common/category'; -import { map } from '../../../../common/iterables'; import { LegendItem } from '../../../../common/legend'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { identity } from '../../../../utils/common'; -import { isHierarchicalLegend } from '../../../../utils/legend'; -import { QuadViewModel } from '../../layout/types/viewmodel_types'; +import { getLegendItems } from '../../layout/utils/legend'; import { partitionGeometries } from './geometries'; import { getPartitionSpec } from './partition_spec'; /** @internal */ export const computeLegendSelector = createCachedSelector( [getPartitionSpec, getSettingsSpecSelector, partitionGeometries], - (pieSpec, { flatLegend, legendMaxDepth, legendPosition }, { quadViewModel }): LegendItem[] => { - if (!pieSpec) { - return []; - } - - const uniqueNames = new Set(map(({ dataName, fillColor }) => makeKey(dataName, fillColor), quadViewModel)); - const useHierarchicalLegend = isHierarchicalLegend(flatLegend, legendPosition); - - const excluded: Set = new Set(); - const items = quadViewModel.filter(({ depth, dataName, fillColor }) => { - if (legendMaxDepth != null) { - return depth <= legendMaxDepth; - } - if (!useHierarchicalLegend) { - const key = makeKey(dataName, fillColor); - if (uniqueNames.has(key) && excluded.has(key)) { - return false; - } - excluded.add(key); - } - return true; - }); - - items.sort(compareTreePaths); - - return items.map(({ dataName, fillColor, depth, path }) => { - const formatter = pieSpec.layers[depth - 1]?.nodeLabel ?? identity; - return { - color: fillColor, - label: formatter(dataName), - childId: dataName, - depth: useHierarchicalLegend ? depth - 1 : 0, - path, - seriesIdentifier: { key: dataName, specId: pieSpec.id }, - }; - }); + (partitionSpec, { flatLegend, legendMaxDepth, legendPosition }, geometries): LegendItem[] => { + const { quadViewModel } = geometries[0]; + return partitionSpec + ? getLegendItems( + partitionSpec.id, + partitionSpec.layers, + flatLegend, + legendMaxDepth, + legendPosition, + quadViewModel, + ) + : []; }, )(getChartIdSelector); - -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].index - b[i].index; - 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/src/chart_types/partition_chart/state/selectors/geometries.ts b/src/chart_types/partition_chart/state/selectors/geometries.ts index 2ba3b017c4..24fad4d9a1 100644 --- a/src/chart_types/partition_chart/state/selectors/geometries.ts +++ b/src/chart_types/partition_chart/state/selectors/geometries.ts @@ -19,25 +19,29 @@ import createCachedSelector from 're-reselect'; -import { ChartTypes } from '../../..'; -import { SpecTypes } from '../../../../specs/constants'; -import { GlobalChartState } from '../../../../state/chart_state'; import { getChartContainerDimensionsSelector } from '../../../../state/selectors/get_chart_container_dimensions'; import { getChartThemeSelector } from '../../../../state/selectors/get_chart_theme'; -import { getSpecsFromStore } from '../../../../state/utils'; import { nullShapeViewModel, ShapeViewModel } from '../../layout/types/viewmodel_types'; -import { PartitionSpec } from '../../specs'; -import { render } from './scenegraph'; +import { getShapeViewModel } from '../../layout/viewmodel/scenegraph'; +import { getPartitionSpecs } from './get_partition_specs'; import { getTree } from './tree'; -const getSpecs = (state: GlobalChartState) => state.specs; - /** @internal */ export const partitionGeometries = createCachedSelector( - [getSpecs, getChartContainerDimensionsSelector, getTree, getChartThemeSelector], - (specs, parentDimensions, tree, { background }): ShapeViewModel => { - const pieSpecs = getSpecsFromStore(specs, ChartTypes.Partition, SpecTypes.Series); + [getPartitionSpecs, getChartContainerDimensionsSelector, getTree, getChartThemeSelector], + (partitionSpecs, parentDimensions, tree, { background }): ShapeViewModel[] => { + return [ + partitionSpecs.length > 0 // singleton! + ? getShapeViewModel(partitionSpecs[0], parentDimensions, tree, background.color) + : nullShapeViewModel(), + ]; + }, +)((state) => state.chartId); - return pieSpecs.length === 1 ? render(pieSpecs[0], parentDimensions, tree, background.color) : nullShapeViewModel(); +/** @internal */ +export const partitionMultiGeometries = createCachedSelector( + [getPartitionSpecs, getChartContainerDimensionsSelector, getTree, getChartThemeSelector], + (partitionSpecs, parentDimensions, tree, { background }): ShapeViewModel[] => { + return partitionSpecs.map((spec) => getShapeViewModel(spec, parentDimensions, tree, background.color)); }, )((state) => state.chartId); 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 dc6e1c516b..3aa4240431 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,88 +18,23 @@ */ 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 { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { DataName, QuadViewModel } from '../../layout/types/viewmodel_types'; +import { QuadViewModel } from '../../layout/types/viewmodel_types'; +import { highlightedGeoms } from '../../layout/utils/highlighted_geoms'; import { partitionGeometries } from './geometries'; 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 */ export const legendHoverHighlightNodes = createCachedSelector( [getSettingsSpecSelector, getHighlightedLegendItemPath, partitionGeometries], - (specs, highlightedLegendItemPath, geoms): QuadViewModel[] => { - if (highlightedLegendItemPath.length === 0) { - return []; - } - const pickedLogic: LegendStrategy = specs.legendStrategy ?? defaultStrategy; - return geoms.quadViewModel.filter(legendStrategies[pickedLogic](highlightedLegendItemPath)); + ({ legendStrategy }, highlightedLegendItemPath, geometries): QuadViewModel[] => { + const { quadViewModel } = geometries[0]; + return highlightedLegendItemPath.length > 0 + ? highlightedGeoms(legendStrategy, quadViewModel, highlightedLegendItemPath) + : []; }, )(getChartIdSelector); diff --git a/src/chart_types/partition_chart/state/selectors/get_legend_items_extra.ts b/src/chart_types/partition_chart/state/selectors/get_legend_items_extra.ts index 9074bc2b01..97b37794e1 100644 --- a/src/chart_types/partition_chart/state/selectors/get_legend_items_extra.ts +++ b/src/chart_types/partition_chart/state/selectors/get_legend_items_extra.ts @@ -21,61 +21,18 @@ import createCachedSelector from 're-reselect'; import { LegendItemExtraValues } from '../../../../common/legend'; import { SeriesKey } from '../../../../common/series_id'; -import { SettingsSpec } from '../../../../specs'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { HierarchyOfArrays, CHILDREN_KEY } from '../../layout/utils/group_by_rollup'; -import { PartitionSpec } from '../../specs'; +import { getExtraValueMap } from '../../layout/viewmodel/hierarchy_of_arrays'; import { getPartitionSpec } from './partition_spec'; import { getTree } from './tree'; /** @internal */ export const getLegendItemsExtra = createCachedSelector( [getPartitionSpec, getSettingsSpecSelector, getTree], - (pieSpec, { legendMaxDepth }, tree): Map => { - const legendExtraValues = new Map(); - - return pieSpec && isValidLegendMaxDepth(legendMaxDepth) - ? getExtraValueMap(pieSpec, tree, legendMaxDepth) - : legendExtraValues; + (spec, { legendMaxDepth }, tree): Map => { + return spec && !Number.isNaN(legendMaxDepth) && legendMaxDepth > 0 + ? getExtraValueMap(spec.layers, spec.valueFormatter, tree, legendMaxDepth) + : new Map(); }, )(getChartIdSelector); - -/** - * Check if the legendMaxDepth from settings is a valid number (NaN or <=0) - * - * @param legendMaxDepth - SettingsSpec['legendMaxDepth'] - */ -function isValidLegendMaxDepth(legendMaxDepth: SettingsSpec['legendMaxDepth']): boolean { - return typeof legendMaxDepth === 'number' && !Number.isNaN(legendMaxDepth) && legendMaxDepth > 0; -} - -/** - * Creates flat extra value map from nested key path - */ -function getExtraValueMap( - { layers, valueFormatter }: Pick, - tree: HierarchyOfArrays, - maxDepth: number, - depth: number = 0, - keys: Map = new Map(), -): Map { - for (let i = 0; i < tree.length; i++) { - const branch = tree[i]; - const [key, arrayNode] = branch; - const { value, path, [CHILDREN_KEY]: children } = arrayNode; - - if (key != null) { - const values: LegendItemExtraValues = new Map(); - const formattedValue = valueFormatter ? valueFormatter(value) : value; - - values.set(key, formattedValue); - keys.set(path.map(({ index }) => index).join('__'), values); - } - - if (depth < maxDepth) { - getExtraValueMap({ layers, valueFormatter }, children, maxDepth, depth + 1, keys); - } - } - return keys; -} 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 b9a2c6da10..83330a80bf 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,51 +22,13 @@ 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, HIERARCHY_ROOT_KEY } from '../../layout/utils/group_by_rollup'; -import { Layer } from '../../specs'; +import { getLegendLabels } from '../../layout/utils/legend_labels'; import { getPartitionSpec } from './partition_spec'; import { getTree } from './tree'; /** @internal */ export const getLegendItemsLabels = createCachedSelector( [getPartitionSpec, getSettingsSpecSelector, getTree], - (pieSpec, { legendMaxDepth }, tree): LegendItemLabel[] => - pieSpec ? flatSlicesNames(pieSpec.layers, 0, tree).filter(({ depth }) => depth <= legendMaxDepth) : [], + (spec, { legendMaxDepth }, tree): LegendItemLabel[] => + spec ? getLegendLabels(spec.layers, tree, legendMaxDepth) : [], )(getChartIdSelector); - -function flatSlicesNames( - layers: Layer[], - depth: number, - tree: HierarchyOfArrays, - keys: Map = new Map(), -): LegendItemLabel[] { - if (tree.length === 0) { - return []; - } - - for (let i = 0; i < tree.length; i++) { - const branch = tree[i]; - const arrayNode = branch[1]; - const key = branch[0]; - - // format the key with the layer formatter - const layer = layers[depth - 1]; - const formatter = layer?.nodeLabel; - let formattedValue = ''; - if (key != null) { - formattedValue = formatter ? formatter(key) : `${key}`; - } - // preventing errors from external formatters - 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)); - } - - const children = arrayNode[CHILDREN_KEY]; - flatSlicesNames(layers, depth + 1, children, keys); - } - return [...keys.keys()].map((k) => ({ - label: k, - depth: keys.get(k) ?? 0, - })); -} diff --git a/src/chart_types/partition_chart/state/selectors/get_partition_specs.ts b/src/chart_types/partition_chart/state/selectors/get_partition_specs.ts new file mode 100644 index 0000000000..a5330bda5b --- /dev/null +++ b/src/chart_types/partition_chart/state/selectors/get_partition_specs.ts @@ -0,0 +1,33 @@ +/* + * 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 { ChartTypes } from '../../..'; +import { SpecTypes } from '../../../../specs'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { getSpecsFromStore } from '../../../../state/utils'; +import { PartitionSpec } from '../../specs'; + +const getSpecs = (state: GlobalChartState) => state.specs; + +/** @internal */ +export const getPartitionSpecs = createCachedSelector([getSpecs], (specs) => { + return getSpecsFromStore(specs, ChartTypes.Partition, SpecTypes.Series); +})((state) => state.chartId); diff --git a/src/chart_types/partition_chart/state/selectors/is_tooltip_visible.ts b/src/chart_types/partition_chart/state/selectors/is_tooltip_visible.ts index 3c9ee27319..02b5f9f690 100644 --- a/src/chart_types/partition_chart/state/selectors/is_tooltip_visible.ts +++ b/src/chart_types/partition_chart/state/selectors/is_tooltip_visible.ts @@ -33,9 +33,6 @@ import { getTooltipInfoSelector } from './tooltip'; export const isTooltipVisibleSelector = createCachedSelector( [getSettingsSpecSelector, getTooltipInfoSelector], (settingsSpec, tooltipInfo): boolean => { - if (getTooltipType(settingsSpec) === TooltipType.None) { - return false; - } - return tooltipInfo.values.length > 0; + return getTooltipType(settingsSpec) !== TooltipType.None && tooltipInfo.values.length > 0; }, )(getChartIdSelector); diff --git a/src/chart_types/partition_chart/state/selectors/on_element_click_caller.ts b/src/chart_types/partition_chart/state/selectors/on_element_click_caller.ts index 8415076cc8..dc3940d0ab 100644 --- a/src/chart_types/partition_chart/state/selectors/on_element_click_caller.ts +++ b/src/chart_types/partition_chart/state/selectors/on_element_click_caller.ts @@ -21,12 +21,10 @@ import createCachedSelector from 're-reselect'; import { Selector } from 'reselect'; import { ChartTypes } from '../../..'; -import { SeriesIdentifier } from '../../../../common/series_id'; -import { SettingsSpec, LayerValue } from '../../../../specs'; -import { GlobalChartState, PointerState } from '../../../../state/chart_state'; +import { getOnElementClickSelector } from '../../../../common/event_handler_selectors'; +import { GlobalChartState, PointerStates } from '../../../../state/chart_state'; import { getLastClickSelector } from '../../../../state/selectors/get_last_click'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; -import { isClicking } from '../../../../state/utils'; import { getPartitionSpec } from './partition_spec'; import { getPickedShapesLayerValues } from './picked_shapes'; @@ -38,34 +36,15 @@ import { getPickedShapesLayerValues } from './picked_shapes'; * @internal */ export function createOnElementClickCaller(): (state: GlobalChartState) => void { - let prevClick: PointerState | null = null; + const prev: { click: PointerStates['lastClick'] } = { click: null }; let selector: Selector | null = null; return (state: GlobalChartState) => { if (selector === null && state.chartType === ChartTypes.Partition) { selector = createCachedSelector( [getPartitionSpec, getLastClickSelector, getSettingsSpecSelector, getPickedShapesLayerValues], - (pieSpec, lastClick: PointerState | null, settings: SettingsSpec, pickedShapes): void => { - if (!pieSpec) { - return; - } - if (!settings.onElementClick) { - return; - } - const nextPickedShapesLength = pickedShapes.length; - if (nextPickedShapesLength > 0 && isClicking(prevClick, lastClick) && settings && settings.onElementClick) { - const elements = pickedShapes.map<[Array, SeriesIdentifier]>((values) => [ - values, - { - specId: pieSpec.id, - key: `spec{${pieSpec.id}}`, - }, - ]); - settings.onElementClick(elements); - } - prevClick = lastClick; - }, + getOnElementClickSelector(prev), )({ - keySelector: (state: GlobalChartState) => state.chartId, + keySelector: (s: GlobalChartState) => s.chartId, }); } if (selector) { diff --git a/src/chart_types/partition_chart/state/selectors/on_element_out_caller.ts b/src/chart_types/partition_chart/state/selectors/on_element_out_caller.ts index 1a6b6b1e00..eeb727babe 100644 --- a/src/chart_types/partition_chart/state/selectors/on_element_out_caller.ts +++ b/src/chart_types/partition_chart/state/selectors/on_element_out_caller.ts @@ -21,6 +21,7 @@ import createCachedSelector from 're-reselect'; import { Selector } from 'react-redux'; import { ChartTypes } from '../../..'; +import { getOnElementOutSelector } from '../../../../common/event_handler_selectors'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; @@ -34,26 +35,13 @@ import { getPickedShapesLayerValues } from './picked_shapes'; * @internal */ export function createOnElementOutCaller(): (state: GlobalChartState) => void { - let prevPickedShapes: number | null = null; + const prev: { pickedShapes: number | null } = { pickedShapes: null }; let selector: Selector | null = null; return (state: GlobalChartState) => { if (selector === null && state.chartType === ChartTypes.Partition) { selector = createCachedSelector( [getPartitionSpec, getPickedShapesLayerValues, getSettingsSpecSelector], - (pieSpec, pickedShapes, settings): void => { - if (!pieSpec) { - return; - } - if (!settings.onElementOut) { - return; - } - const nextPickedShapes = pickedShapes.length; - - if (prevPickedShapes !== null && prevPickedShapes > 0 && nextPickedShapes === 0) { - settings.onElementOut(); - } - prevPickedShapes = nextPickedShapes; - }, + getOnElementOutSelector(prev), )({ keySelector: getChartIdSelector, }); diff --git a/src/chart_types/partition_chart/state/selectors/on_element_over_caller.ts b/src/chart_types/partition_chart/state/selectors/on_element_over_caller.ts index 092ab38b32..fbd8016a83 100644 --- a/src/chart_types/partition_chart/state/selectors/on_element_over_caller.ts +++ b/src/chart_types/partition_chart/state/selectors/on_element_over_caller.ts @@ -21,7 +21,7 @@ import createCachedSelector from 're-reselect'; import { Selector } from 'react-redux'; import { ChartTypes } from '../../..'; -import { SeriesIdentifier } from '../../../../common/series_id'; +import { getOnElementOverSelector } from '../../../../common/event_handler_selectors'; import { LayerValue } from '../../../../specs'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; @@ -29,31 +29,6 @@ import { getSettingsSpecSelector } from '../../../../state/selectors/get_setting import { getPartitionSpec } from './partition_spec'; import { getPickedShapesLayerValues } from './picked_shapes'; -function isOverElement(prevPickedShapes: Array> = [], nextPickedShapes: Array>) { - if (nextPickedShapes.length === 0) { - return; - } - if (nextPickedShapes.length !== prevPickedShapes.length) { - return true; - } - return !nextPickedShapes.every((nextPickedShapeValues, index) => { - const prevPickedShapeValues = prevPickedShapes[index]; - if (prevPickedShapeValues === null) { - return false; - } - if (prevPickedShapeValues.length !== nextPickedShapeValues.length) { - return false; - } - return nextPickedShapeValues.every((layerValue, i) => { - const prevPickedValue = prevPickedShapeValues[i]; - if (!prevPickedValue) { - return false; - } - return layerValue.value === prevPickedValue.value && layerValue.groupByRollup === prevPickedValue.groupByRollup; - }); - }); -} - /** * Will call the onElementOver listener every time the following preconditions are met: * - the onElementOver listener is available @@ -61,32 +36,13 @@ function isOverElement(prevPickedShapes: Array> = [], nextPick * @internal */ export function createOnElementOverCaller(): (state: GlobalChartState) => void { - let prevPickedShapes: Array> = []; + const prev: { pickedShapes: LayerValue[][] } = { pickedShapes: [] }; let selector: Selector | null = null; return (state: GlobalChartState) => { if (selector === null && state.chartType === ChartTypes.Partition) { selector = createCachedSelector( [getPartitionSpec, getPickedShapesLayerValues, getSettingsSpecSelector], - (pieSpec, nextPickedShapes, settings): void => { - if (!pieSpec) { - return; - } - if (!settings.onElementOver) { - return; - } - - if (isOverElement(prevPickedShapes, nextPickedShapes)) { - const elements = nextPickedShapes.map<[Array, SeriesIdentifier]>((values) => [ - values, - { - specId: pieSpec.id, - key: `spec{${pieSpec.id}}`, - }, - ]); - settings.onElementOver(elements); - } - prevPickedShapes = nextPickedShapes; - }, + getOnElementOverSelector(prev), )({ keySelector: getChartIdSelector, }); diff --git a/src/chart_types/partition_chart/state/selectors/partition_spec.ts b/src/chart_types/partition_chart/state/selectors/partition_spec.ts index aba87e54f3..32dc018041 100644 --- a/src/chart_types/partition_chart/state/selectors/partition_spec.ts +++ b/src/chart_types/partition_chart/state/selectors/partition_spec.ts @@ -25,6 +25,6 @@ import { PartitionSpec } from '../../specs'; /** @internal */ export function getPartitionSpec(state: GlobalChartState): PartitionSpec | null { - const pieSpecs = getSpecsFromStore(state.specs, ChartTypes.Partition, SpecTypes.Series); - return pieSpecs.length > 0 ? pieSpecs[0] : null; + const partitionSpecs = getSpecsFromStore(state.specs, ChartTypes.Partition, SpecTypes.Series); + return partitionSpecs.length > 0 ? partitionSpecs[0] : null; // singleton! } diff --git a/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts b/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts index 8352fa2546..7d9264e327 100644 --- a/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts +++ b/src/chart_types/partition_chart/state/selectors/picked_shapes.test.ts @@ -68,11 +68,11 @@ describe('Picked shapes selector', () => { }); test('check initial geoms', () => { addSeries(store, treemapSpec); - const treemapGeometries = partitionGeometries(store.getState()); + const treemapGeometries = partitionGeometries(store.getState())[0]; expect(treemapGeometries.quadViewModel).toHaveLength(6); addSeries(store, sunburstSpec); - const sunburstGeometries = partitionGeometries(store.getState()); + const sunburstGeometries = partitionGeometries(store.getState())[0]; expect(sunburstGeometries.quadViewModel).toHaveLength(6); }); test('treemap check picked geometries', () => { @@ -83,7 +83,7 @@ describe('Picked shapes selector', () => { addSeries(store, treemapSpec, { onElementClick: onClickListener, }); - const geometries = partitionGeometries(store.getState()); + const geometries = partitionGeometries(store.getState())[0]; expect(geometries.quadViewModel).toHaveLength(6); const onElementClickCaller = createOnElementClickCaller(); @@ -134,7 +134,7 @@ describe('Picked shapes selector', () => { addSeries(store, sunburstSpec, { onElementClick: onClickListener, }); - const geometries = partitionGeometries(store.getState()); + const geometries = partitionGeometries(store.getState())[0]; expect(geometries.quadViewModel).toHaveLength(6); const onElementClickCaller = createOnElementClickCaller(); diff --git a/src/chart_types/partition_chart/state/selectors/picked_shapes.ts b/src/chart_types/partition_chart/state/selectors/picked_shapes.ts index 58f6342e43..ceb5b37042 100644 --- a/src/chart_types/partition_chart/state/selectors/picked_shapes.ts +++ b/src/chart_types/partition_chart/state/selectors/picked_shapes.ts @@ -19,18 +19,8 @@ import createCachedSelector from 're-reselect'; -import { LayerValue } from '../../../../specs'; import { GlobalChartState } from '../../../../state/chart_state'; -import { MODEL_KEY } from '../../layout/config'; -import { QuadViewModel } from '../../layout/types/viewmodel_types'; -import { - AGGREGATE_KEY, - DEPTH_KEY, - getNodeName, - PARENT_KEY, - PATH_KEY, - SORT_INDEX_KEY, -} from '../../layout/utils/group_by_rollup'; +import { pickedShapes, pickShapesLayerValues } from '../../layout/viewmodel/picked_shapes'; import { partitionGeometries } from './geometries'; function getCurrentPointerPosition(state: GlobalChartState) { @@ -40,13 +30,7 @@ function getCurrentPointerPosition(state: GlobalChartState) { /** @internal */ export const getPickedShapes = createCachedSelector( [partitionGeometries, getCurrentPointerPosition], - (geoms, pointerPosition): QuadViewModel[] => { - const picker = geoms.pickQuads; - const { diskCenter } = geoms; - const x = pointerPosition.x - diskCenter.x; - const y = pointerPosition.y - diskCenter.y; - return picker(x, y); - }, + pickedShapes, )((state) => state.chartId); /** @internal */ @@ -54,35 +38,3 @@ export const getPickedShapesLayerValues = createCachedSelector( [getPickedShapes], pickShapesLayerValues, )((state) => state.chartId); - -/** @internal */ -export function pickShapesLayerValues(pickedShapes: QuadViewModel[]): Array> { - const maxDepth = pickedShapes.reduce((acc, curr) => Math.max(acc, curr.depth), 0); - return pickedShapes - .filter(({ depth }) => depth === maxDepth) // eg. lowest layer in a treemap, where layers overlap in screen space; doesn't apply to sunburst/flame - .map>((viewModel) => { - const values: Array = []; - values.push({ - groupByRollup: viewModel.dataName, - value: viewModel[AGGREGATE_KEY], - depth: viewModel[DEPTH_KEY], - sortIndex: viewModel[SORT_INDEX_KEY], - path: viewModel[PATH_KEY], - }); - let node = viewModel[MODEL_KEY]; - while (node[DEPTH_KEY] > 0) { - const value = node[AGGREGATE_KEY]; - const dataName = getNodeName(node); - values.push({ - groupByRollup: dataName, - value, - depth: node[DEPTH_KEY], - sortIndex: node[SORT_INDEX_KEY], - path: node[PATH_KEY], - }); - - node = node[PARENT_KEY]; - } - return values.reverse(); - }); -} diff --git a/src/chart_types/partition_chart/state/selectors/tooltip.ts b/src/chart_types/partition_chart/state/selectors/tooltip.ts index c2e2c7213a..08b72972a8 100644 --- a/src/chart_types/partition_chart/state/selectors/tooltip.ts +++ b/src/chart_types/partition_chart/state/selectors/tooltip.ts @@ -20,55 +20,23 @@ import createCachedSelector from 're-reselect'; import { TooltipInfo } from '../../../../components/tooltip/types'; -import { percentValueGetter, sumValueGetter } from '../../layout/config'; +import { EMPTY_TOOLTIP, getTooltipInfo } from '../../layout/viewmodel/tooltip_info'; import { getPartitionSpec } from './partition_spec'; import { getPickedShapes } from './picked_shapes'; -import { valueGetterFunction } from './scenegraph'; - -const EMPTY_TOOLTIP = Object.freeze({ - header: null, - values: [], -}); /** @internal */ export const getTooltipInfoSelector = createCachedSelector( [getPartitionSpec, getPickedShapes], - (pieSpec, pickedShapes): TooltipInfo => { - if (!pieSpec) { - return EMPTY_TOOLTIP; - } - const { valueGetter, valueFormatter, layers: labelFormatters } = pieSpec; - if (!valueFormatter || !labelFormatters) { - return EMPTY_TOOLTIP; - } - - const tooltipInfo: TooltipInfo = { - header: null, - values: [], - }; - - const valueGetterFun = valueGetterFunction(valueGetter); - const primaryValueGetterFun = valueGetterFun === percentValueGetter ? sumValueGetter : valueGetterFun; - pickedShapes.forEach((shape) => { - const labelFormatter = labelFormatters[shape.depth - 1]; - const formatter = labelFormatter?.nodeLabel; - const value = primaryValueGetterFun(shape); - - tooltipInfo.values.push({ - label: formatter ? formatter(shape.dataName) : shape.dataName, - color: shape.fillColor, - isHighlighted: false, - isVisible: true, - seriesIdentifier: { - specId: pieSpec.id, - key: pieSpec.id, - }, - value, - formattedValue: `${valueFormatter(value)} (${pieSpec.percentFormatter(percentValueGetter(shape))})`, - valueAccessor: shape.depth, - }); - }); - - return tooltipInfo; + (spec, pickedShapes): TooltipInfo => { + return spec + ? getTooltipInfo( + pickedShapes, + spec.layers.map((l) => l.nodeLabel), + spec.valueGetter, + spec.valueFormatter, + spec.percentFormatter, + spec.id, + ) + : EMPTY_TOOLTIP; }, )((state) => state.chartId); diff --git a/src/chart_types/partition_chart/state/selectors/tree.ts b/src/chart_types/partition_chart/state/selectors/tree.ts index c258d8f27a..1247ea0b17 100644 --- a/src/chart_types/partition_chart/state/selectors/tree.ts +++ b/src/chart_types/partition_chart/state/selectors/tree.ts @@ -19,34 +19,33 @@ import createCachedSelector from 're-reselect'; -import { ChartTypes } from '../../..'; -import { SpecTypes } from '../../../../specs'; +import { CategoryKey } from '../../../../common/category'; import { GlobalChartState } from '../../../../state/chart_state'; -import { getSpecsFromStore } from '../../../../state/utils'; import { configMetadata } from '../../layout/config'; -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 { 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'; -const getSpecs = (state: GlobalChartState) => state.specs; +function getTreeForSpec(spec: PartitionSpec, drilldownSelection: CategoryKey[]) { + const { data, valueAccessor, layers, config } = spec; + 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( - [getSpecs], - (specs): HierarchyOfArrays => { - const pieSpecs = getSpecsFromStore(specs, ChartTypes.Partition, SpecTypes.Series); - if (pieSpecs.length !== 1) { - return []; - } - const { data, valueAccessor, layers } = pieSpecs[0]; - const layout = pieSpecs[0].config.partitionLayout ?? configMetadata.partitionLayout.dflt; - const sorter = isTreemap(layout) || isSunburst(layout) ? childOrders.descending : null; - return getHierarchyOfArrays( - data, - valueAccessor, - [() => HIERARCHY_ROOT_KEY, ...layers.map(({ groupByRollup }) => groupByRollup)], - sorter, - ); + [getPartitionSpecs, getDrilldownSelection], + (partitionSpecs, drilldownSelection): HierarchyOfArrays => { + return partitionSpecs.length > 0 ? getTreeForSpec(partitionSpecs[0], drilldownSelection) : []; // singleton! }, )((state) => state.chartId); diff --git a/src/chart_types/xy_chart/renderer/dom/crosshair.tsx b/src/chart_types/xy_chart/renderer/dom/crosshair.tsx index c3830d60a7..4a9db4dcaf 100644 --- a/src/chart_types/xy_chart/renderer/dom/crosshair.tsx +++ b/src/chart_types/xy_chart/renderer/dom/crosshair.tsx @@ -103,12 +103,18 @@ class CrosshairComponent extends React.Component { } render() { - const { zIndex } = this.props; + const { zIndex, cursorPosition } = this.props; return ( <> - + {this.renderCursor()} + {this.renderCrossLine()} diff --git a/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.test.ts b/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.test.ts new file mode 100644 index 0000000000..6847e8a1dc --- /dev/null +++ b/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { MockGlobalSpec, MockSeriesSpec } from '../../../../mocks/specs/specs'; +import { MockStore } from '../../../../mocks/store/store'; +import { ScaleType } from '../../../../scales/constants'; +import { onPointerMove } from '../../../../state/actions/mouse'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { getElementAtCursorPositionSelector } from './get_elements_at_cursor_pos'; + +const data = [ + { x: 0, y: 2 }, + { x: 0, y: 2.2 }, + { x: 1, y: 2 }, + { x: 2, y: 3 }, +]; + +describe('getElementAtCursorPositionSelector', () => { + let store: Store; + + describe('Area', () => { + beforeEach(() => { + store = MockStore.default({ width: 300, height: 300, top: 0, left: 0 }, 'chartId'); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins(), + MockSeriesSpec.area({ + data, + xScaleType: ScaleType.Ordinal, + }), + ], + store, + ); + }); + + it('should correctly sort matched points near y = 2', () => { + store.dispatch(onPointerMove({ x: 0, y: 100 }, 0)); + const elements = getElementAtCursorPositionSelector(store.getState()); + expect(elements).toHaveLength(2); + expect(elements.map(({ value }) => value.datum.y)).toEqual([2, 2.2]); + }); + + it('should correctly sort matched points near y = 2.2', () => { + store.dispatch(onPointerMove({ x: 0, y: 80 }, 0)); + const elements = getElementAtCursorPositionSelector(store.getState()); + expect(elements).toHaveLength(2); + expect(elements.map(({ value }) => value.datum.y)).toEqual([2.2, 2]); + }); + }); + + describe('Bubble', () => { + beforeEach(() => { + store = MockStore.default({ width: 300, height: 300, top: 0, left: 0 }, 'chartId'); + MockStore.addSpecs( + [ + MockGlobalSpec.settingsNoMargins(), + MockSeriesSpec.bubble({ + data, + xScaleType: ScaleType.Ordinal, + }), + ], + store, + ); + }); + + it('should correctly sort matched points near y = 2', () => { + store.dispatch(onPointerMove({ x: 0, y: 100 }, 0)); + const elements = getElementAtCursorPositionSelector(store.getState()); + expect(elements).toHaveLength(3); + expect(elements.map(({ value }) => value.datum.y)).toEqual([2, 2.2, 2]); + }); + + it('should correctly sort matched points near y = 2.2', () => { + store.dispatch(onPointerMove({ x: 0, y: 80 }, 0)); + const elements = getElementAtCursorPositionSelector(store.getState()); + expect(elements).toHaveLength(4); + expect(elements.map(({ value }) => value.datum.y)).toEqual([2.2, 2, 2, 3]); + }); + }); +}); diff --git a/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts b/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts index 4966e32f4c..e2ad8e77a8 100644 --- a/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts +++ b/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts @@ -26,6 +26,7 @@ import { isValidPointerOverEvent } from '../../../../utils/events'; import { IndexedGeometry } from '../../../../utils/geometry'; import { ChartDimensions } from '../../utils/dimensions'; import { IndexedGeometryMap } from '../../utils/indexed_geometry_map'; +import { sortClosestToPoint } from '../utils/common'; import { ComputedScales } from '../utils/types'; import { computeChartDimensionsSelector } from './compute_chart_dimensions'; import { getComputedScalesSelector } from './get_computed_scales'; @@ -71,10 +72,12 @@ function getElementAtCursorPosition( return []; } // get the elements at cursor position - return geometriesIndex.find( - xValue?.value, - orientedProjectedPointerPosition, - orientedProjectedPointerPosition.horizontalPanelValue, - orientedProjectedPointerPosition.verticalPanelValue, - ); + return geometriesIndex + .find( + xValue?.value, + orientedProjectedPointerPosition, + orientedProjectedPointerPosition.horizontalPanelValue, + orientedProjectedPointerPosition.verticalPanelValue, + ) + .sort(sortClosestToPoint(orientedProjectedPointerPosition)); } 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 9924025159..938498a978 100644 --- a/src/chart_types/xy_chart/state/utils/common.test.ts +++ b/src/chart_types/xy_chart/state/utils/common.test.ts @@ -22,6 +22,7 @@ import { LegendItem } from '../../../../common/legend'; import { ScaleType } from '../../../../scales/constants'; import { SpecTypes } from '../../../../specs'; import { BARCHART_1Y1G } from '../../../../utils/data_samples/test_dataset'; +import { Point } from '../../../../utils/point'; import { AreaSeriesSpec, SeriesTypes, LineSeriesSpec, BarSeriesSpec } from '../../utils/specs'; import { isHorizontalRotation, @@ -29,10 +30,11 @@ import { isLineAreaOnlyChart, isChartAnimatable, isAllSeriesDeselected, + sortClosestToPoint, } from './common'; describe('Type Checks', () => { - test('is horizontal chart rotation', () => { + it('is horizontal chart rotation', () => { expect(isHorizontalRotation(0)).toBe(true); expect(isHorizontalRotation(180)).toBe(true); expect(isHorizontalRotation(-90)).toBe(false); @@ -42,7 +44,7 @@ describe('Type Checks', () => { expect(isVerticalRotation(0)).toBe(false); expect(isVerticalRotation(180)).toBe(false); }); - test('is vertical chart rotation', () => { + it('is vertical chart rotation', () => { expect(isVerticalRotation(-90)).toBe(true); expect(isVerticalRotation(90)).toBe(true); expect(isVerticalRotation(0)).toBe(false); @@ -50,7 +52,7 @@ describe('Type Checks', () => { }); describe('#isLineAreaOnlyChart', () => { - test('is an area or line only map', () => { + it('is an area or line only map', () => { const area: AreaSeriesSpec = { chartType: ChartTypes.XYAxis, specType: SpecTypes.Series, @@ -106,7 +108,7 @@ describe('Type Checks', () => { }); describe('#isChartAnimatable', () => { - test('can enable the chart animation if we have a valid number of elements', () => { + it('can enable the chart animation if we have a valid number of elements', () => { const geometriesCounts = { points: 0, bars: 0, @@ -131,7 +133,7 @@ describe('Type Checks', () => { }); }); - test('displays no data available if chart is empty', () => { + it('displays no data available if chart is empty', () => { const legendItems1: LegendItem[] = [ { color: '#1EA593', @@ -158,7 +160,7 @@ describe('Type Checks', () => { ]; expect(isAllSeriesDeselected(legendItems1)).toBe(true); }); - test('displays data availble if chart is not empty', () => { + it('displays data availble if chart is not empty', () => { const legendItems2: LegendItem[] = [ { color: '#1EA593', @@ -185,4 +187,90 @@ describe('Type Checks', () => { ]; expect(isAllSeriesDeselected(legendItems2)).toBe(false); }); + + describe('#sortClosestToPoint', () => { + describe('positive cursor', () => { + const cursor: Point = { x: 10, y: 10 }; + + it('should sort points with same x', () => { + const points: Point[] = [ + { x: 10, y: -10 }, + { x: 10, y: 12 }, + { x: 10, y: 11 }, + { x: 10, y: 10 }, + { x: 10, y: 5 }, + { x: 10, y: -12 }, + ]; + expect(points.sort(sortClosestToPoint(cursor))).toEqual([ + { x: 10, y: 10 }, + { x: 10, y: 11 }, + { x: 10, y: 12 }, + { x: 10, y: 5 }, + { x: 10, y: -10 }, + { x: 10, y: -12 }, + ]); + }); + + it('should sort points with different x', () => { + const points: Point[] = [ + { x: 9, y: -10 }, + { x: -6, y: 12 }, + { x: 3, y: 11 }, + { x: 9, y: 10 }, + { x: 1, y: 5 }, + { x: -9, y: -12 }, + ]; + expect(points.sort(sortClosestToPoint(cursor))).toEqual([ + { x: 9, y: 10 }, + { x: 3, y: 11 }, + { x: 1, y: 5 }, + { x: -6, y: 12 }, + { x: 9, y: -10 }, + { x: -9, y: -12 }, + ]); + }); + }); + + describe('negative cursor', () => { + const cursor: Point = { x: -10, y: -10 }; + + it('should sort points with same x', () => { + const points: Point[] = [ + { x: 10, y: -10 }, + { x: 10, y: 12 }, + { x: 10, y: 11 }, + { x: 10, y: 10 }, + { x: 10, y: 5 }, + { x: 10, y: -12 }, + ]; + expect(points.sort(sortClosestToPoint(cursor))).toEqual([ + { x: 10, y: -10 }, + { x: 10, y: -12 }, + { x: 10, y: 5 }, + { x: 10, y: 10 }, + { x: 10, y: 11 }, + { x: 10, y: 12 }, + ]); + }); + + it('should sort points with different x', () => { + const points: Point[] = [ + { x: 9, y: -10 }, + { x: -6, y: 12 }, + { x: 3, y: 11 }, + { x: 9, y: 10 }, + { x: 1, y: 5 }, + { x: -9, y: -12 }, + ]; + expect(points.sort(sortClosestToPoint(cursor))).toEqual([ + { x: -9, y: -12 }, + { x: 1, y: 5 }, + { x: 9, y: -10 }, + { x: -6, y: 12 }, + { x: 3, y: 11 }, + { x: 9, y: 10 }, + ]); + }); + }); + }); }); diff --git a/src/chart_types/xy_chart/state/utils/common.ts b/src/chart_types/xy_chart/state/utils/common.ts index a5edb6299b..63a762b420 100644 --- a/src/chart_types/xy_chart/state/utils/common.ts +++ b/src/chart_types/xy_chart/state/utils/common.ts @@ -18,7 +18,8 @@ */ import { LegendItem } from '../../../../common/legend'; -import { Rotation } from '../../../../utils/common'; +import { getDistance, Rotation } from '../../../../utils/common'; +import { Point } from '../../../../utils/point'; import { BasicSeriesSpec, SeriesTypes } from '../../utils/specs'; import { GeometriesCounts } from './types'; @@ -64,3 +65,11 @@ export function isAllSeriesDeselected(legendItems: LegendItem[]): boolean { } return true; } + +/** + * Sorts points in order from closest to farthest from cursor + * @internal + */ +export const sortClosestToPoint = (cursor: Point) => (a: Point, b: Point): number => { + return getDistance(cursor, a) - getDistance(cursor, b); +}; diff --git a/src/common/color_calcs.ts b/src/common/color_calcs.ts index 3f0b4e823b..e5a27f0f1d 100644 --- a/src/common/color_calcs.ts +++ b/src/common/color_calcs.ts @@ -167,3 +167,23 @@ export function getTextColorIfTextInvertible( : makeHighContrastColor(textColor, backgroundColor, textContrast); } } + +/** + * This function generates color for non-occluded text rendering directly on the + * paper, with possible background color, ie. not on some data ink + * + * @internal + */ +export function getOnPaperColorSet(textColor: Color, sectorLineStroke: Color, containerBackgroundColor?: Color) { + // determine the ideal contrast color for the link labels + const validBackgroundColor = isColorValid(containerBackgroundColor) + ? containerBackgroundColor + : 'rgba(255, 255, 255, 0)'; + const contrastTextColor = containerBackgroundColor + ? makeHighContrastColor(textColor, validBackgroundColor) + : textColor; + const strokeColor = containerBackgroundColor + ? makeHighContrastColor(sectorLineStroke, validBackgroundColor) + : undefined; + return { contrastTextColor, strokeColor }; +} diff --git a/src/common/event_handler_selectors.ts b/src/common/event_handler_selectors.ts new file mode 100644 index 0000000000..517a5e02bb --- /dev/null +++ b/src/common/event_handler_selectors.ts @@ -0,0 +1,121 @@ +/* + * 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 { LayerValue, SettingsSpec, Spec } from '../specs'; +import { PointerStates } from '../state/chart_state'; +import { isClicking } from '../state/utils'; +import { SeriesIdentifier } from './series_id'; + +/** @internal */ +export const getOnElementClickSelector = (prev: { click: PointerStates['lastClick'] }) => ( + spec: Spec | null, + lastClick: PointerStates['lastClick'], + settings: SettingsSpec, + pickedShapes: LayerValue[][], +): void => { + if (!spec) { + return; + } + if (!settings.onElementClick) { + return; + } + const nextPickedShapesLength = pickedShapes.length; + if (nextPickedShapesLength > 0 && isClicking(prev.click, lastClick) && settings && settings.onElementClick) { + const elements = pickedShapes.map<[Array, SeriesIdentifier]>((values) => [ + values, + { + specId: spec.id, + key: `spec{${spec.id}}`, + }, + ]); + settings.onElementClick(elements); + } + prev.click = lastClick; +}; + +/** @internal */ +export const getOnElementOutSelector = (prev: { pickedShapes: number | null }) => ( + spec: Spec | null, + pickedShapes: LayerValue[][], + settings: SettingsSpec, +): void => { + if (!spec) { + return; + } + if (!settings.onElementOut) { + return; + } + const nextPickedShapes = pickedShapes.length; + + if (prev.pickedShapes !== null && prev.pickedShapes > 0 && nextPickedShapes === 0) { + settings.onElementOut(); + } + prev.pickedShapes = nextPickedShapes; +}; + +function isOverElement(prevPickedShapes: Array> = [], nextPickedShapes: Array>) { + if (nextPickedShapes.length === 0) { + return; + } + if (nextPickedShapes.length !== prevPickedShapes.length) { + return true; + } + return !nextPickedShapes.every((nextPickedShapeValues, index) => { + const prevPickedShapeValues = prevPickedShapes[index]; + if (prevPickedShapeValues === null) { + return false; + } + if (prevPickedShapeValues.length !== nextPickedShapeValues.length) { + return false; + } + return nextPickedShapeValues.every((layerValue, i) => { + const prevPickedValue = prevPickedShapeValues[i]; + if (!prevPickedValue) { + return false; + } + return layerValue.value === prevPickedValue.value && layerValue.groupByRollup === prevPickedValue.groupByRollup; + }); + }); +} + +/** @internal */ +export const getOnElementOverSelector = (prev: { pickedShapes: LayerValue[][] }) => ( + spec: Spec | null, + nextPickedShapes: LayerValue[][], + settings: SettingsSpec, +): void => { + if (!spec) { + return; + } + if (!settings.onElementOver) { + return; + } + + if (isOverElement(prev.pickedShapes, nextPickedShapes)) { + const elements = nextPickedShapes.map<[Array, SeriesIdentifier]>((values) => [ + values, + { + specId: spec.id, + key: `spec{${spec.id}}`, + }, + ]); + settings.onElementOver(elements); + } + prev.pickedShapes = nextPickedShapes; +}; diff --git a/src/common/text_utils.ts b/src/common/text_utils.ts index a03eee91ba..ffe17f4180 100644 --- a/src/common/text_utils.ts +++ b/src/common/text_utils.ts @@ -22,6 +22,7 @@ import { $Values as Values } from 'utility-types'; import { ArrayEntry } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; import { Datum } from '../utils/common'; import { Pixels } from './geometry'; +import { integerSnap, monotonicHillClimb } from './optimize'; export const FONT_VARIANTS = Object.freeze(['normal', 'small-caps'] as const); export type FontVariant = typeof FONT_VARIANTS[number]; @@ -48,6 +49,7 @@ export type NumericFontWeight = number & typeof FONT_WEIGHTS[number]; export const FONT_STYLES = Object.freeze(['normal', 'italic', 'oblique', 'inherit', 'initial', 'unset'] as const); export type FontStyle = typeof FONT_STYLES[number]; +// this doesn't include the font size, so it's more like a font face (?) - unfortunately all vague terms export interface Font { fontStyle: FontStyle; fontVariant: FontVariant; @@ -127,3 +129,33 @@ export const VerticalAlignments = Object.freeze({ }); export type VerticalAlignments = Values; + +/** @internal */ +export function measureOneBoxWidth(measure: TextMeasure, fontSize: number, box: Box) { + return measure(fontSize, [box])[0].width; +} + +/** @internal */ +export function cutToLength(s: string, maxLength: number) { + return s.length <= maxLength ? s : `${s.slice(0, Math.max(0, maxLength - 1))}…`; // ellipsis is one char +} + +/** @internal */ +export function fitText( + measure: TextMeasure, + desiredText: string, + allottedWidth: number, + fontSize: number, + font: Font, +) { + const desiredLength = desiredText.length; + const response = (v: number) => measure(fontSize, [{ ...font, text: desiredText.slice(0, Math.max(0, v)) }])[0].width; + const visibleLength = monotonicHillClimb(response, desiredLength, allottedWidth, integerSnap); + const text = visibleLength < 2 && desiredLength >= 2 ? '' : cutToLength(desiredText, visibleLength); + const { width, emHeightAscent, emHeightDescent } = measure(fontSize, [{ ...font, text }])[0]; + return { + width, + verticalOffset: -(emHeightDescent + emHeightAscent) / 2, // meaning, `middle` + text, + }; +} diff --git a/src/components/__snapshots__/chart.test.tsx.snap b/src/components/__snapshots__/chart.test.tsx.snap index ab35a43e06..a354f9c450 100644 --- a/src/components/__snapshots__/chart.test.tsx.snap +++ b/src/components/__snapshots__/chart.test.tsx.snap @@ -67,7 +67,7 @@ exports[`Chart should render the legend name test 1`] = `
- + @@ -100,7 +100,7 @@ exports[`Chart should render the legend name test 1`] = ` - +
diff --git a/src/components/brush/brush.tsx b/src/components/brush/brush.tsx index 410ea621c4..e89ef7d8ff 100644 --- a/src/components/brush/brush.tsx +++ b/src/components/brush/brush.tsx @@ -42,6 +42,7 @@ interface StateProps { isBrushing: boolean | undefined; isBrushAvailable: boolean | undefined; brushArea: Dimensions | null; + zIndex: number; } const DEFAULT_FILL_COLOR: RgbObject = { @@ -129,7 +130,7 @@ class BrushToolComponent extends React.Component { } render() { - const { initialized, isBrushAvailable, isBrushing, projectionContainer } = this.props; + const { initialized, isBrushAvailable, isBrushing, projectionContainer, zIndex } = this.props; if (!initialized || !isBrushAvailable || !isBrushing) { this.ctx = null; return null; @@ -144,6 +145,7 @@ class BrushToolComponent extends React.Component { style={{ width, height, + zIndex, }} /> ); @@ -169,6 +171,7 @@ const mapStateToProps = (state: GlobalChartState): StateProps => { isBrushing: false, isBrushAvailable: false, brushArea: null, + zIndex: 0, }; } return { @@ -178,6 +181,7 @@ const mapStateToProps = (state: GlobalChartState): StateProps => { isBrushAvailable: getInternalIsBrushingAvailableSelector(state), isBrushing: getInternalIsBrushingSelector(state), brushArea: getInternalBrushAreaSelector(state), + zIndex: state.zIndex, }; }; diff --git a/src/index.ts b/src/index.ts index 62d049c7b5..f48f76e7e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,6 @@ export { SeriesIdentifier } from './common/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, BandedAccessorType } from './utils/geometry'; -export { LegendStrategy } from './chart_types/partition_chart/state/selectors/get_highlighted_shapes'; export { LegendPath, LegendPathElement } from './state/actions/legend'; export { CategoryKey } from './common/category'; export { @@ -89,3 +88,4 @@ export { export { DataGenerator } from './utils/data_generators/data_generator'; export * from './utils/themes/merge_utils'; export { MODEL_KEY } from './chart_types/partition_chart/layout/config'; +export { LegendStrategy } from './chart_types/partition_chart/layout/utils/highlighted_geoms'; diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 654b0b9cfe..1d648d32fa 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -22,7 +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 { LegendStrategy } from '../chart_types/partition_chart/layout/utils/highlighted_geoms'; import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; import { DomainRange } from '../chart_types/xy_chart/utils/specs'; import { SeriesIdentifier } from '../common/series_id'; diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index e2fe1200ef..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'; @@ -36,7 +37,7 @@ import { Point } from '../utils/point'; import { StateActions } from './actions'; import { CHART_RENDERED } from './actions/chart'; import { UPDATE_PARENT_DIMENSION } from './actions/chart_settings'; -import { SET_PERSISTED_COLOR, SET_TEMPORARY_COLOR, CLEAR_TEMPORARY_COLORS } from './actions/colors'; +import { CLEAR_TEMPORARY_COLORS, SET_PERSISTED_COLOR, SET_TEMPORARY_COLOR } from './actions/colors'; import { DOMElement } from './actions/dom_element'; import { EXTERNAL_POINTER_EVENT } from './actions/events'; import { LegendPath } from './actions/legend'; @@ -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; } @@ -410,17 +413,14 @@ function chartTypeFromSpecs(specs: SpecList): ChartTypes | null { return nonGlobalTypes[0]; } +const constructors: Record InternalChartState | null> = { + [ChartTypes.Goal]: () => new GoalState(), + [ChartTypes.Partition]: () => new PartitionState(), + [ChartTypes.XYAxis]: () => new XYAxisChartState(), + [ChartTypes.Heatmap]: () => new HeatmapState(), + [ChartTypes.Global]: () => null, +}; // with no default, TS signals if a new chart type isn't added here too + function newInternalState(chartType: ChartTypes | null): InternalChartState | null { - switch (chartType) { - case ChartTypes.Goal: - return new GoalState(); - case ChartTypes.Partition: - return new PartitionState(); - case ChartTypes.XYAxis: - return new XYAxisChartState(); - case ChartTypes.Heatmap: - return new HeatmapState(); - default: - return null; - } + return chartType ? constructors[chartType]() : null; } 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/src/utils/common.ts b/src/utils/common.ts index 121a26562b..302f327106 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -426,7 +426,10 @@ export type ValueAccessor = (d: Datum) => number; export type LabelAccessor = (value: PrimitiveValue) => string; export type ShowAccessor = (value: PrimitiveValue) => boolean; -/** @internal */ +/** + * Returns planar distance bewtween two points + * @internal + */ export function getDistance(a: Point, b: Point): number { return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2)); } diff --git a/stories/grids/3_lines.tsx b/stories/grids/3_lines.tsx new file mode 100644 index 0000000000..fc0974bdb4 --- /dev/null +++ b/stories/grids/3_lines.tsx @@ -0,0 +1,97 @@ +/* + * 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 { array, boolean, color, number } from '@storybook/addon-knobs'; +import { startCase } from 'lodash'; +import React from 'react'; + +import { + Axis, + LineSeries, + Chart, + Position, + ScaleType, + Settings, + TooltipType, + PartialTheme, + StrokeStyle, + StrokeDashArray, +} from '../../src'; +import { SeededDataGenerator } from '../../src/mocks/utils'; +import { getTooltipTypeKnob } from '../utils/knobs'; + +const dg = new SeededDataGenerator(); +const data = dg.generateBasicSeries(20); + +type LineProps = StrokeStyle & StrokeDashArray; + +const getLineStyles = ({ stroke, strokeWidth, dash }: Partial = {}, group?: string): LineProps => ({ + stroke: color('Stroke', stroke ?? '#ccc', group), + strokeWidth: number('Stroke width', strokeWidth ?? 2, { min: 1, max: 6, range: true, step: 1 }, group), + dash: ( + array( + 'Dash', + (dash ?? []).map((n) => `${n}`), + ',', + group, + ) ?? [] + ).map((s) => parseInt(s, 10)), +}); + +const getAxisKnobs = (position: Position) => { + const title = `${startCase(position)} axis`; + const visible = boolean('Show gridline', true, title); + return { + id: position, + position, + title, + tickFormat: (n: number) => n.toFixed(1), + gridLine: { + visible, + opacity: number('Opacity', 0.2, { min: 0, max: 1, range: true, step: 0.1 }, title), + ...getLineStyles( + { + dash: position === Position.Left ? [4, 4] : undefined, + }, + title, + ), + }, + }; +}; + +export const Example = () => { + const theme: PartialTheme = { + crosshair: { + line: getLineStyles({ stroke: 'red' }, 'Crosshair line'), + crossLine: getLineStyles({ stroke: 'red', dash: [4, 4] }, 'Crosshair cross line'), + }, + }; + return ( + + + + + + + ); +}; diff --git a/stories/grids/grids.stories.tsx b/stories/grids/grids.stories.tsx index a5f87767d1..a67b867c92 100644 --- a/stories/grids/grids.stories.tsx +++ b/stories/grids/grids.stories.tsx @@ -28,3 +28,4 @@ export default { export { Example as basic } from './1_basic'; export { Example as multipleAxesWithTheSamePosition } from './2_multiple_axes'; +export { Example as lines } from './3_lines'; 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 ed7c1d0ecc..3b117218af 100644 --- a/stories/icicle/02_unix_flame.tsx +++ b/stories/icicle/02_unix_flame.tsx @@ -24,7 +24,7 @@ import { STORYBOOK_LIGHT_THEME } from '../shared'; import { config, getFlatData, getLayerSpec, maxDepth } from '../utils/hierarchical_input_utils'; import { plasma18 as palette } from '../utils/utils'; -const color = palette.slice().reverse(); +const color = [...palette].reverse(); export const Example = () => { return ( @@ -32,13 +32,10 @@ export const Example = () => { { - // eslint-disable-next-line no-console - console.log(e); - }} /> { valueAccessor={(d: Datum) => d.value as number} valueFormatter={() => ''} layers={getLayerSpec(color)} - config={{ ...config, partitionLayout: PartitionLayout.flame }} + config={{ ...config, partitionLayout: PartitionLayout.flame, drilldown: true }} /> ); diff --git a/stories/interactions/3_line_point_clicks.tsx b/stories/interactions/3_line_point_clicks.tsx index 7f0b8abb67..4787ebd0de 100644 --- a/stories/interactions/3_line_point_clicks.tsx +++ b/stories/interactions/3_line_point_clicks.tsx @@ -35,7 +35,7 @@ export const Example = () => ( Number(d).toFixed(2)} /> ( { x: 3, y: 6 }, ]} /> + ); diff --git a/stories/stylings/9_custom_series_colors_function.tsx b/stories/stylings/9_custom_series_colors_function.tsx index 280a6d0c3d..baab617a74 100644 --- a/stories/stylings/9_custom_series_colors_function.tsx +++ b/stories/stylings/9_custom_series_colors_function.tsx @@ -39,7 +39,7 @@ export const Example = () => { const lineColor = color('linelineSeriesColor', '#ff0'); const lineSeriesColorAccessor: SeriesColorAccessor = ({ specId, yAccessor, splitAccessors }) => { - if (specId === 'lines' && yAccessor === 'y1' && splitAccessors.size === 0) { + if (specId === 'lines' && yAccessor === 'y' && splitAccessors.size === 0) { return lineColor; } return null; diff --git a/stories/utils/knobs.ts b/stories/utils/knobs.ts index a9ff4ceb1b..3f83a277da 100644 --- a/stories/utils/knobs.ts +++ b/stories/utils/knobs.ts @@ -48,7 +48,7 @@ export const getChartRotationKnob = () => export const getTooltipTypeKnob = ( name = 'tooltip type', - defaultValue = TooltipType.VerticalCursor, + defaultValue: TooltipType = TooltipType.VerticalCursor, groupId?: string, ) => select(