From 22ef1e66005ed829080db12723641d36ba34159d Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Thu, 27 Feb 2020 14:45:38 -0600 Subject: [PATCH] feat(legend): allow color picker component render prop (#545) - Add option to pass color picker component to chart - Allow picker prop to be react component or function component - Configure elastic charts to maintain color override state in memory - Generalize vrt common page object and add method for clicking on element --- packages/osd-charts/.eslintrc.js | 1 + packages/osd-charts/.storybook/style.scss | 2 + .../osd-charts/integration/jest.config.js | 9 + .../integration/page_objects/common.ts | 165 +++++++++++++---- ...r-picker-visually-looks-correct-1-snap.png | Bin 0 -> 12474 bytes ...der-color-picker-on-mouse-click-1-snap.png | Bin 0 -> 39642 bytes .../integration/tests/legend_stories.test.ts | 13 ++ packages/osd-charts/package.json | 1 + packages/osd-charts/scripts/setup_enzyme.ts | 2 + .../partition_chart/layout/utils/calcs.ts | 3 +- .../xy_chart/annotations/annotation_utils.ts | 4 +- .../src/chart_types/xy_chart/legend/legend.ts | 15 +- .../xy_chart/renderer/canvas/axes/index.ts | 2 +- .../xy_chart/rendering/rendering.ts | 10 +- .../xy_chart/state/chart_state.tsx | 3 +- .../state/selectors/compute_legend.ts | 3 +- .../selectors/get_legend_tooltip_values.ts | 3 +- .../state/selectors/get_series_color_map.ts | 13 +- .../state/selectors/merge_y_custom_domains.ts | 10 +- .../chart_types/xy_chart/state/utils.test.ts | 110 ++++++++---- .../src/chart_types/xy_chart/state/utils.ts | 30 ++-- .../chart_types/xy_chart/tooltip/tooltip.ts | 6 +- .../chart_types/xy_chart/utils/series.test.ts | 77 ++++---- .../src/chart_types/xy_chart/utils/series.ts | 70 ++++++-- packages/osd-charts/src/components/chart.tsx | 10 +- .../legend/__snapshots__/legend.test.tsx.snap | 9 + .../src/components/legend/_legend_item.scss | 7 +- .../src/components/legend/legend.test.tsx | 168 +++++++++++++++++- .../src/components/legend/legend.tsx | 22 ++- .../src/components/legend/legend_item.tsx | 143 +++++++++++---- packages/osd-charts/src/specs/settings.tsx | 33 +++- .../osd-charts/src/state/actions/colors.ts | 36 ++++ .../osd-charts/src/state/actions/index.ts | 4 +- packages/osd-charts/src/state/chart_state.ts | 91 ++++++++-- .../src/state/selectors/get_legend_items.ts | 5 +- .../selectors/get_legend_items_values.ts | 5 +- packages/osd-charts/src/utils/geometry.ts | 11 +- .../stories/legend/9_color_picker.tsx | 68 +++++++ .../stories/legend/legend.stories.tsx | 1 + packages/osd-charts/yarn.lock | 7 + 40 files changed, 927 insertions(+), 245 deletions(-) create mode 100644 packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-color-picker-visually-looks-correct-1-snap.png create mode 100644 packages/osd-charts/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png create mode 100644 packages/osd-charts/src/components/legend/__snapshots__/legend.test.tsx.snap create mode 100644 packages/osd-charts/src/state/actions/colors.ts create mode 100644 packages/osd-charts/stories/legend/9_color_picker.tsx diff --git a/packages/osd-charts/.eslintrc.js b/packages/osd-charts/.eslintrc.js index 14770614e081..9cb851408661 100644 --- a/packages/osd-charts/.eslintrc.js +++ b/packages/osd-charts/.eslintrc.js @@ -69,6 +69,7 @@ module.exports = { '@typescript-eslint/ban-ts-ignore': 'off', '@typescript-eslint/no-inferrable-types': 'off', 'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }], + 'react/prop-types': 0, }, settings: { 'import/resolver': { diff --git a/packages/osd-charts/.storybook/style.scss b/packages/osd-charts/.storybook/style.scss index f7df75d34606..8d9d656642f9 100644 --- a/packages/osd-charts/.storybook/style.scss +++ b/packages/osd-charts/.storybook/style.scss @@ -1,3 +1,5 @@ +@import '../node_modules/@elastic/eui/src/theme_light.scss'; + .story-chart { box-sizing: border-box; background: white; diff --git a/packages/osd-charts/integration/jest.config.js b/packages/osd-charts/integration/jest.config.js index cb02bbd111bd..1b2ce938fe93 100644 --- a/packages/osd-charts/integration/jest.config.js +++ b/packages/osd-charts/integration/jest.config.js @@ -8,6 +8,15 @@ module.exports = Object.assign( 'ts-jest': { tsConfig: '/tsconfig.json', }, + /* + * The window and HTMLElement globals are required to use @elastic/eui with VRT + * + * The jest-puppeteer-docker env extends a node test environment and not jsdom test environment. + * Some EUI components that are included in the bundle, but not used, require the jsdom setup. + * To bypass these errors we are just mocking both as empty objects. + */ + window: {}, + HTMLElement: {}, }, }, jestPuppeteerDocker, diff --git a/packages/osd-charts/integration/page_objects/common.ts b/packages/osd-charts/integration/page_objects/common.ts index 9f0669a07393..15b69cc12284 100644 --- a/packages/osd-charts/integration/page_objects/common.ts +++ b/packages/osd-charts/integration/page_objects/common.ts @@ -15,13 +15,48 @@ interface ScreenshotDOMElementOptions { path?: string; } +type ScreenshotElementAtUrlOptions = ScreenshotDOMElementOptions & { + /** + * timeout for waiting on element to appear in DOM + * + * @default JEST_TIMEOUT + */ + timeout?: number; + /** + * any desired action to be performed after loading url, prior to screenshot + */ + action?: () => void | Promise; + /** + * Selector used to wait on DOM element + */ + waitSelector?: string; + /** + * Delay to take screenshot after element is visiable + */ + delay?: number; +}; + class CommonPage { + readonly chartWaitSelector = '.echChartStatus[data-ech-render-complete=true]'; + readonly chartSelector = '.echChart'; + + /** + * Parse url from knob storybook url to iframe storybook url + * + * @param url + */ static parseUrl(url: string): string { const { query } = Url.parse(url); return `${baseUrl}?${query}${query ? '&' : ''}knob-debug=false`; } - async getBoundingClientRect(selector = '.echChart') { + + /** + * Get getBoundingClientRect of selected element + * + * @param selector + */ + async getBoundingClientRect(selector: string) { return await page.evaluate((selector) => { const element = document.querySelector(selector); @@ -34,12 +69,16 @@ class CommonPage { return { left: x, top: y, width, height, id: element.id }; }, selector); } + /** - * Capture screenshot or chart element only + * Capture screenshot of selected element only + * + * @param selector + * @param options */ - async screenshotDOMElement(selector = '.echChart', opts?: ScreenshotDOMElementOptions) { - const padding: number = opts && opts.padding ? opts.padding : 0; - const path: string | undefined = opts && opts.path ? opts.path : undefined; + async screenshotDOMElement(selector: string, options?: ScreenshotDOMElementOptions) { + const padding: number = options && options.padding ? options.padding : 0; + const path: string | undefined = options && options.path ? options.path : undefined; const rect = await this.getBoundingClientRect(selector); return page.screenshot({ @@ -53,69 +92,121 @@ class CommonPage { }); } - async moveMouseRelativeToDOMElement(mousePosition: { x: number; y: number }, selector = '.echChart') { - const chartContainer = await this.getBoundingClientRect(selector); - await page.mouse.move(chartContainer.left + mousePosition.x, chartContainer.top + mousePosition.y); + /** + * Move mouse relative to element + * + * @param mousePosition + * @param selector + */ + async moveMouseRelativeToDOMElement(mousePosition: { x: number; y: number }, selector: string) { + const element = await this.getBoundingClientRect(selector); + await page.mouse.move(element.left + mousePosition.x, element.top + mousePosition.y); + } + + /** + * Click mouse relative to element + * + * @param mousePosition + * @param selector + */ + async clickMouseRelativeToDOMElement(mousePosition: { x: number; y: number }, selector: string) { + const element = await this.getBoundingClientRect(selector); + await page.mouse.click(element.left + mousePosition.x, element.top + mousePosition.y); } /** - * Expect a chart given a url from storybook. + * Expect an element given a url and selector from storybook * * - Note: No need to fix host or port. They will be set automatically. * * @param url Storybook url from knobs section + * @param selector selector of element to screenshot + * @param options */ - async expectChartAtUrlToMatchScreenshot(url: string) { + async expectElementAtUrlToMatchScreenshot( + url: string, + selector: string = 'body', + options?: ScreenshotElementAtUrlOptions, + ) { try { - await this.loadChartFromURL(url); - await this.waitForElement(); + await this.loadElementFromURL(url, options?.waitSelector ?? selector, options?.timeout); - const chart = await this.screenshotDOMElement(); + if (options?.action) { + await options.action(); + } - if (!chart) { - throw new Error(`Error: Unable to find chart element\n\n\t${url}`); + if (options?.delay) { + await page.waitFor(options.delay); } - expect(chart).toMatchImageSnapshot(); + const element = await this.screenshotDOMElement(selector, options); + + if (!element) { + throw new Error(`Error: Unable to find element\n\n\t${url}`); + } + + expect(element).toMatchImageSnapshot(); } catch (error) { throw new Error(error); } } /** - * Expect a chart given a url from storybook. - * - * - Note: No need to fix host or port. They will be set automatically. + * Expect a chart given a url from storybook * * @param url Storybook url from knobs section + * @param options */ - async expectChartWithMouseAtUrlToMatchScreenshot(url: string, mousePosition: { x: number; y: number }) { - try { - await this.loadChartFromURL(url); - await this.waitForElement(); - await this.moveMouseRelativeToDOMElement(mousePosition); - const chart = await this.screenshotDOMElement(); - if (!chart) { - throw new Error(`Error: Unable to find chart element\n\n\t${url}`); - } + async expectChartAtUrlToMatchScreenshot(url: string, options?: ScreenshotElementAtUrlOptions) { + await this.expectElementAtUrlToMatchScreenshot(url, this.chartSelector, { + waitSelector: this.chartWaitSelector, + ...options, + }); + } - expect(chart).toMatchImageSnapshot(); - } catch (error) { - throw new Error(`${error}\n\n${url}`); - } + /** + * Expect a chart given a url from storybook with mouse move + * + * @param url Storybook url from knobs section + * @param mousePosition - postion of mouse relative to chart + * @param options + */ + async expectChartWithMouseAtUrlToMatchScreenshot( + url: string, + mousePosition: { x: number; y: number }, + options?: Omit, + ) { + const action = async () => await this.moveMouseRelativeToDOMElement(mousePosition, this.chartSelector); + await this.expectChartAtUrlToMatchScreenshot(url, { + ...options, + action, + }); } - async loadChartFromURL(url: string) { + + /** + * Loads storybook page from raw url, and waits for element + * + * @param url Storybook url from knobs section + * @param waitSelector selector of element to wait to appear in DOM + * @param timeout timeout for waiting on element to appear in DOM + */ + async loadElementFromURL(url: string, waitSelector?: string, timeout?: number) { const cleanUrl = CommonPage.parseUrl(url); await page.goto(cleanUrl); - this.waitForElement(); + + if (waitSelector) { + await this.waitForElement(waitSelector, timeout); + } } + /** * Wait for an element to be on the DOM - * @param {string} [selector] the DOM selector to wait for, default to '.echChartStatus[data-ech-render-complete=true]' + * + * @param {string} [waitSelector] the DOM selector to wait for, default to '.echChartStatus[data-ech-render-complete=true]' * @param {number} [timeout] - the timeout for the operation, default to 10000ms */ - async waitForElement(selector = '.echChartStatus[data-ech-render-complete=true]', timeout = JEST_TIMEOUT) { - await page.waitForSelector(selector, { timeout }); + async waitForElement(waitSelector: string, timeout = JEST_TIMEOUT) { + await page.waitForSelector(waitSelector, { timeout }); } } diff --git a/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-color-picker-visually-looks-correct-1-snap.png b/packages/osd-charts/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-color-picker-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..98c49da8def68eaac6b6dde3c8b3d7aa16ada898 GIT binary patch literal 12474 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 N>XQ1!f(zGg{~u|cL&pFB literal 0 HcmV?d00001 diff --git a/packages/osd-charts/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png b/packages/osd-charts/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..5dc72e320160864ff5cca6e95bcec95075d6674b GIT binary patch literal 39642 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 literal 0 HcmV?d00001 diff --git a/packages/osd-charts/integration/tests/legend_stories.test.ts b/packages/osd-charts/integration/tests/legend_stories.test.ts index 0c402362017d..a2b76b89ef75 100644 --- a/packages/osd-charts/integration/tests/legend_stories.test.ts +++ b/packages/osd-charts/integration/tests/legend_stories.test.ts @@ -21,4 +21,17 @@ describe('Legend stories', () => { 'http://localhost:9001/?path=/story/legend--legend-spacing-buffer&knob-legend buffer value=0', ); }); + + it('should render color picker on mouse click', async () => { + const action = async () => await common.clickMouseRelativeToDOMElement({ x: 0, y: 0 }, '.echLegendItem__color'); + await common.expectElementAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/legend--color-picker', + 'body', + { + action, + waitSelector: common.chartWaitSelector, + delay: 500, // needed for popover animation to complete + }, + ); + }); }); diff --git a/packages/osd-charts/package.json b/packages/osd-charts/package.json index 0d035eb95c36..27d8bd0b8e14 100644 --- a/packages/osd-charts/package.json +++ b/packages/osd-charts/package.json @@ -56,6 +56,7 @@ "@babel/preset-react": "^7.8.3", "@commitlint/cli": "^8.1.0", "@commitlint/config-conventional": "^8.1.0", + "@elastic/datemath": "^5.0.2", "@elastic/eui": "^16.0.1", "@mdx-js/loader": "^1.5.5", "@semantic-release/changelog": "^3.0.6", diff --git a/packages/osd-charts/scripts/setup_enzyme.ts b/packages/osd-charts/scripts/setup_enzyme.ts index 82edfc9e5ade..7435d8602fa4 100644 --- a/packages/osd-charts/scripts/setup_enzyme.ts +++ b/packages/osd-charts/scripts/setup_enzyme.ts @@ -2,3 +2,5 @@ import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() }); + +process.env.RNG_SEED = 'jest-unit-tests'; diff --git a/packages/osd-charts/src/chart_types/partition_chart/layout/utils/calcs.ts b/packages/osd-charts/src/chart_types/partition_chart/layout/utils/calcs.ts index d3f1ce6861a8..355c0ed371c1 100644 --- a/packages/osd-charts/src/chart_types/partition_chart/layout/utils/calcs.ts +++ b/packages/osd-charts/src/chart_types/partition_chart/layout/utils/calcs.ts @@ -1,5 +1,6 @@ import { Ratio } from '../types/geometry_types'; import { RgbTuple, stringToRGB } from './d3_utils'; +import { Color } from '../../../../utils/commons'; export function hueInterpolator(colors: RgbTuple[]) { return (d: number) => { @@ -26,7 +27,7 @@ export function arrayToLookup(keyFun: Function, array: Array) { return Object.assign({}, ...array.map((d) => ({ [keyFun(d)]: d }))); } -export function colorIsDark(color: string) { +export function colorIsDark(color: Color) { // fixme this assumes a white or very light background const rgba = stringToRGB(color); const { r, g, b, opacity } = rgba; diff --git a/packages/osd-charts/src/chart_types/xy_chart/annotations/annotation_utils.ts b/packages/osd-charts/src/chart_types/xy_chart/annotations/annotation_utils.ts index fd7463e263ba..088f4d164d47 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/annotations/annotation_utils.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/annotations/annotation_utils.ts @@ -23,7 +23,7 @@ import { AnnotationRectProps, computeRectAnnotationDimensions, } from './rect_annotation_tooltip'; -import { Rotation, Position } from '../../../utils/commons'; +import { Rotation, Position, Color } from '../../../utils/commons'; export type AnnotationTooltipFormatter = (details?: string) => JSX.Element | null; @@ -54,7 +54,7 @@ export interface AnnotationMarker { icon: JSX.Element; position: { top: number; left: number }; dimension: { width: number; height: number }; - color: string; + color: Color; } export type AnnotationDimensions = AnnotationLineProps[] | AnnotationRectProps[]; diff --git a/packages/osd-charts/src/chart_types/xy_chart/legend/legend.ts b/packages/osd-charts/src/chart_types/xy_chart/legend/legend.ts index defdb8ca8b11..72c9a8ec04ee 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/legend/legend.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/legend/legend.ts @@ -1,11 +1,12 @@ import { getAxesSpecForSpecId, LastValues, getSpecsById } from '../state/utils'; -import { identity } from '../../../utils/commons'; +import { identity, Color } from '../../../utils/commons'; import { SeriesCollectionValue, getSeriesIndex, getSortedDataSeriesColorsValuesMap, getSeriesName, XYChartSeriesIdentifier, + SeriesKey, } from '../utils/series'; import { AxisSpec, BasicSeriesSpec, Postfixes, isAreaSeriesSpec, isBarSeriesSpec } from '../utils/specs'; import { Y0_ACCESSOR_POSTFIX, Y1_ACCESSOR_POSTFIX } from '../tooltip/tooltip'; @@ -17,8 +18,8 @@ interface FormattedLastValues { } export type LegendItem = Postfixes & { - key: string; - color: string; + key: SeriesKey; + color: Color; name: string; seriesIdentifier: XYChartSeriesIdentifier; isSeriesVisible?: boolean; @@ -54,14 +55,14 @@ export function getItemLabel( } export function computeLegend( - seriesCollection: Map, - seriesColors: Map, + seriesCollection: Map, + seriesColors: Map, specs: BasicSeriesSpec[], defaultColor: string, axesSpecs: AxisSpec[], deselectedDataSeries: XYChartSeriesIdentifier[] = [], -): Map { - const legendItems: Map = new Map(); +): Map { + const legendItems: Map = new Map(); const sortedCollection = getSortedDataSeriesColorsValuesMap(seriesCollection); sortedCollection.forEach((series, key) => { diff --git a/packages/osd-charts/src/chart_types/xy_chart/renderer/canvas/axes/index.ts b/packages/osd-charts/src/chart_types/xy_chart/renderer/canvas/axes/index.ts index 6d457607e866..c7c063e41d2a 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/renderer/canvas/axes/index.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/renderer/canvas/axes/index.ts @@ -24,7 +24,7 @@ export interface AxesProps { axesVisibleTicks: Map; axesSpecs: AxisSpec[]; axesTicksDimensions: Map; - axesPositions: Map; + axesPositions: Map; axisStyle: AxisConfig; debug: boolean; chartDimensions: Dimensions; diff --git a/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.ts b/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.ts index f74410db0b0b..a1d087b2ec11 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.ts @@ -22,7 +22,7 @@ import { ClippedRanges, BandedAccessorType, } from '../../../utils/geometry'; -import { mergePartial } from '../../../utils/commons'; +import { mergePartial, Color } from '../../../utils/commons'; import { LegendItem } from '../legend/legend'; export function mutableIndexedGeometryMapUpsert( @@ -91,7 +91,7 @@ function renderPoints( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, hasY0Accessors: boolean, styleAccessor?: PointStyleAccessor, ): { @@ -170,7 +170,7 @@ export function renderBars( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, sharedSeriesStyle: BarSeriesStyle, displayValueSettings?: DisplayValueSpec, styleAccessor?: BarStyleAccessor, @@ -310,7 +310,7 @@ export function renderLine( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, curve: CurveType, hasY0Accessors: boolean, xScaleOffset: number, @@ -399,7 +399,7 @@ export function renderArea( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, curve: CurveType, hasY0Accessors: boolean, xScaleOffset: number, diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx b/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx index 1c0f4497f487..4120f752eead 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx +++ b/packages/osd-charts/src/chart_types/xy_chart/state/chart_state.tsx @@ -18,6 +18,7 @@ import { getTooltipInfoSelector } from './selectors/get_tooltip_values_highlight import { htmlIdGenerator } from '../../../utils/commons'; import { Tooltip } from '../../../components/tooltip'; import { getTooltipAnchorPositionSelector } from './selectors/get_tooltip_position'; +import { SeriesKey } from '../utils/series'; export class XYAxisChartState implements InternalChartState { chartType = ChartTypes.XYAxis; @@ -34,7 +35,7 @@ export class XYAxisChartState implements InternalChartState { getLegendItems(globalState: GlobalChartState) { return computeLegendSelector(globalState); } - getLegendItemsValues(globalState: GlobalChartState): Map { + getLegendItemsValues(globalState: GlobalChartState): Map { return getLegendTooltipValuesSelector(globalState); } chartRenderer(containerRef: BackwardRef, forwardStageRef: RefObject) { diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/compute_legend.ts b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/compute_legend.ts index b91f665dff1e..c9611483d5ef 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/compute_legend.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/compute_legend.ts @@ -6,6 +6,7 @@ import { getSeriesColorsSelector } from './get_series_color_map'; import { computeLegend, LegendItem } from '../../legend/legend'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { SeriesKey } from '../../utils/series'; const getDeselectedSeriesSelector = (state: GlobalChartState) => state.interactions.deselectedDataSeries; @@ -25,7 +26,7 @@ export const computeLegendSelector = createCachedSelector( seriesColors, axesSpecs, deselectedDataSeries, - ): Map => { + ): Map => { return computeLegend( seriesDomainsAndData.seriesCollection, seriesColors, diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts index 35a72e898fdb..df8a9040283d 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts @@ -2,10 +2,11 @@ import createCachedSelector from 're-reselect'; import { getSeriesTooltipValues, TooltipLegendValue } from '../../tooltip/tooltip'; import { getTooltipInfoSelector } from './get_tooltip_values_highlighted_geoms'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { SeriesKey } from '../../utils/series'; export const getLegendTooltipValuesSelector = createCachedSelector( [getTooltipInfoSelector], - ({ values }): Map => { + ({ values }): Map => { return getSeriesTooltipValues(values); }, )(getChartIdSelector); diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts index a38a8ae0288d..dab4568b7a23 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts @@ -1,20 +1,27 @@ import createCachedSelector from 're-reselect'; import { computeSeriesDomainsSelector } from './compute_series_domains'; import { getSeriesSpecsSelector } from './get_specs'; -import { getSeriesColors } from '../../utils/series'; +import { getSeriesColors, SeriesKey } from '../../utils/series'; import { getChartThemeSelector } from '../../../../state/selectors/get_chart_theme'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getCustomSeriesColors } from '../utils'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { Color } from '../../../../utils/commons'; + +function getColorOverrides({ colors }: GlobalChartState) { + return colors; +} export const getSeriesColorsSelector = createCachedSelector( - [getSeriesSpecsSelector, computeSeriesDomainsSelector, getChartThemeSelector], - (seriesSpecs, seriesDomainsAndData, chartTheme): Map => { + [getSeriesSpecsSelector, computeSeriesDomainsSelector, getChartThemeSelector, getColorOverrides], + (seriesSpecs, seriesDomainsAndData, chartTheme, colorOverrides): Map => { const updatedCustomSeriesColors = getCustomSeriesColors(seriesSpecs, seriesDomainsAndData.seriesCollection); const seriesColorMap = getSeriesColors( seriesDomainsAndData.seriesCollection, chartTheme.colors, updatedCustomSeriesColors, + colorOverrides, ); return seriesColorMap; }, diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts index c37c9b47fad6..fa76565cd22f 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts @@ -5,16 +5,20 @@ import { AxisSpec, DomainRange } from '../../utils/specs'; import { Rotation } from '../../../../utils/commons'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { GroupId } from '../../../../utils/ids'; export const mergeYCustomDomainsByGroupIdSelector = createCachedSelector( [getAxisSpecsSelector, getSettingsSpecSelector], - (axisSpecs, settingsSpec): Map => { + (axisSpecs, settingsSpec): Map => { return mergeYCustomDomainsByGroupId(axisSpecs, settingsSpec ? settingsSpec.rotation : 0); }, )(getChartIdSelector); -export function mergeYCustomDomainsByGroupId(axesSpecs: AxisSpec[], chartRotation: Rotation): Map { - const domainsByGroupId = new Map(); +export function mergeYCustomDomainsByGroupId( + axesSpecs: AxisSpec[], + chartRotation: Rotation, +): Map { + const domainsByGroupId = new Map(); axesSpecs.forEach((spec: AxisSpec) => { const { id, groupId, domain } = spec; diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/utils.test.ts b/packages/osd-charts/src/chart_types/xy_chart/state/utils.test.ts index 527bb1f05c0f..fba0ecac1dd7 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/utils.test.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/utils.test.ts @@ -36,8 +36,14 @@ import { MockSeriesCollection } from '../../../mocks/series/series_identifiers'; import { SeededDataGenerator } from '../../../mocks/utils'; import { SeriesCollectionValue, getSeriesIndex, getSeriesColors } from '../utils/series'; import { SpecTypes } from '../../../specs/settings'; +import { ColorOverrides } from '../../../state/chart_state'; describe('Chart State utils', () => { + const emptySeriesOverrides: ColorOverrides = { + temporary: {}, + persisted: {}, + }; + it('should compute and format specifications for non stacked chart', () => { const spec1: BasicSeriesSpec = { chartType: ChartTypes.XYAxis, @@ -327,11 +333,10 @@ describe('Chart State utils', () => { // 4 groups generated const data = dg.generateGroupedSeries(50, 4); const targetKey = 'spec{bar1}yAccessor{y}splitAccessors{g-b}'; - const seriesColorOverrides = new Map([[targetKey, 'blue']]); describe('empty series collection and specs', () => { it('it should return an empty map', () => { - const actual = getCustomSeriesColors(MockSeriesSpecs.empty(), MockSeriesCollection.empty(), new Map()); + const actual = getCustomSeriesColors(MockSeriesSpecs.empty(), MockSeriesCollection.empty()); expect(actual.size).toBe(0); }); @@ -343,7 +348,7 @@ describe('Chart State utils', () => { const barSpec2 = MockSeriesSpec.bar({ id: specId2, data }); const barSeriesSpecs = MockSeriesSpecs.fromSpecs([barSpec1, barSpec2]); const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect(actual.size).toBe(0); }); @@ -354,7 +359,7 @@ describe('Chart State utils', () => { const barSpec2 = MockSeriesSpec.bar({ id: specId2, data }); const barSeriesSpecs = MockSeriesSpecs.fromSpecs([barSpec1, barSpec2]); const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect([...actual.values()]).toEqualArrayOf(color); }); @@ -367,7 +372,7 @@ describe('Chart State utils', () => { const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); it('it should return color from color array', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect(actual.size).toBe(4); barSeriesCollection.forEach(({ seriesIdentifier: { specId, key } }) => { @@ -379,22 +384,6 @@ describe('Chart State utils', () => { } }); }); - - it('it should return color from seriesColorOverrides', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, seriesColorOverrides); - - expect(actual.size).toBe(4); - barSeriesCollection.forEach(({ seriesIdentifier: { specId, key } }) => { - const color = actual.get(key); - if (key === targetKey) { - expect(color).toBe('blue'); - } else if (specId === specId1) { - expect(customSeriesColors).toContainEqual(color); - } else { - expect(color).toBeUndefined(); - } - }); - }); }); describe('with color function', () => { @@ -411,18 +400,11 @@ describe('Chart State utils', () => { const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); it('it should return color from color function', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect(actual.size).toBe(1); expect(actual.get(targetKey)).toBe('aquamarine'); }); - - it('it should return color from seriesColorOverrides', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, seriesColorOverrides); - - expect(actual.size).toBe(1); - expect(actual.get(targetKey)).toBe('blue'); - }); }); }); }); @@ -484,7 +466,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -541,7 +528,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -600,7 +592,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -686,7 +683,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -759,7 +761,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -832,7 +839,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -913,7 +925,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -1006,7 +1023,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -1092,7 +1114,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -1157,7 +1184,12 @@ describe('Chart State utils', () => { }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, diff --git a/packages/osd-charts/src/chart_types/xy_chart/state/utils.ts b/packages/osd-charts/src/chart_types/xy_chart/state/utils.ts index ad127ec92622..13d83a74a7ca 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/state/utils.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/state/utils.ts @@ -14,6 +14,7 @@ import { getSeriesKey, RawDataSeries, XYChartSeriesIdentifier, + SeriesKey, } from '../utils/series'; import { AreaSeriesSpec, @@ -32,7 +33,7 @@ import { SeriesTypes, } from '../utils/specs'; import { ColorConfig, Theme } from '../../../utils/themes/theme'; -import { identity, mergePartial, Rotation } from '../../../utils/commons'; +import { identity, mergePartial, Rotation, Color } from '../../../utils/commons'; import { Dimensions } from '../../../utils/dimensions'; import { Domain } from '../../../utils/domain'; import { GroupId, SpecId } from '../../../utils/ids'; @@ -91,7 +92,7 @@ export interface SeriesDomainsAndData { stacked: FormattedDataSeries[]; nonStacked: FormattedDataSeries[]; }; - seriesCollection: Map; + seriesCollection: Map; } /** @@ -122,24 +123,19 @@ export function updateDeselectedDataSeries( */ export function getCustomSeriesColors( seriesSpecs: BasicSeriesSpec[], - seriesCollection: Map, - seriesColorOverrides: Map = new Map(), -): Map { - const updatedCustomSeriesColors = new Map(); + seriesCollection: Map, +): Map { + const updatedCustomSeriesColors = new Map(); const counters = new Map(); seriesCollection.forEach(({ seriesIdentifier }, seriesKey) => { const spec = getSpecsById(seriesSpecs, seriesIdentifier.specId); - if (!spec || !(spec.color || seriesColorOverrides.size > 0)) { + if (!spec || !spec.color) { return; } - let color: string | undefined | null; - - if (seriesColorOverrides.has(seriesKey)) { - color = seriesColorOverrides.get(seriesKey); - } + let color: Color | undefined | null; if (!color && spec.color) { if (typeof spec.color === 'string') { @@ -166,8 +162,8 @@ export interface LastValues { function getLastValues(formattedDataSeries: { stacked: FormattedDataSeries[]; nonStacked: FormattedDataSeries[]; -}): Map { - const lastValues = new Map(); +}): Map { + const lastValues = new Map(); // we need to get the latest formattedDataSeries.stacked.forEach((ds) => { @@ -238,7 +234,7 @@ export function computeSeriesDomains( // we need to get the last values from the formatted dataseries // because we change the format if we are on percentage mode const lastValues = getLastValues(formattedDataSeries); - const updatedSeriesCollection = new Map(); + const updatedSeriesCollection = new Map(); seriesCollection.forEach((value, key) => { const lastValue = lastValues.get(key); const updatedColorSet: SeriesCollectionValue = { @@ -264,7 +260,7 @@ export function computeSeriesGeometries( stacked: FormattedDataSeries[]; nonStacked: FormattedDataSeries[]; }, - seriesColorMap: Map, + seriesColorMap: Map, chartTheme: Theme, chartDims: Dimensions, chartRotation: Rotation, @@ -450,7 +446,7 @@ function renderGeometries( xScale: Scale, yScale: Scale, seriesSpecs: BasicSeriesSpec[], - seriesColorsMap: Map, + seriesColorsMap: Map, defaultColor: string, axesSpecs: AxisSpec[], chartTheme: Theme, diff --git a/packages/osd-charts/src/chart_types/xy_chart/tooltip/tooltip.ts b/packages/osd-charts/src/chart_types/xy_chart/tooltip/tooltip.ts index 76ee34ea9c4a..bd807310864f 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/tooltip/tooltip.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/tooltip/tooltip.ts @@ -8,7 +8,7 @@ import { } from '../utils/specs'; import { IndexedGeometry, BandedAccessorType } from '../../../utils/geometry'; import { getAccessorFormatLabel } from '../../../utils/accessor'; -import { getSeriesName } from '../utils/series'; +import { getSeriesName, SeriesKey } from '../utils/series'; import { TooltipValue } from '../../../specs'; export interface TooltipLegendValue { @@ -22,9 +22,9 @@ export const Y1_ACCESSOR_POSTFIX = ' - upper'; export function getSeriesTooltipValues( tooltipValues: TooltipValue[], defaultValue?: string, -): Map { +): Map { // map from seriesKey to TooltipLegendValue - const seriesTooltipValues = new Map(); + const seriesTooltipValues = new Map(); tooltipValues.forEach(({ value, seriesIdentifier, valueAccessor }) => { const seriesValue = defaultValue ? defaultValue : value; diff --git a/packages/osd-charts/src/chart_types/xy_chart/utils/series.test.ts b/packages/osd-charts/src/chart_types/xy_chart/utils/series.test.ts index 4898d281508e..9310adb5aa8e 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/utils/series.test.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/utils/series.test.ts @@ -485,32 +485,16 @@ describe('Series', () => { ); expect(stackedDataSeries.stacked).toMatchSnapshot(); }); - test('should get series color map', () => { - const spec1: BasicSeriesSpec = { - specType: SpecTypes.Series, - chartType: ChartTypes.XYAxis, - id: 'spec1', - groupId: 'group', - seriesType: SeriesTypes.Line, - yScaleType: ScaleType.Log, - xScaleType: ScaleType.Linear, - xAccessor: 'x', - yAccessors: ['y'], - yScaleToDataExtent: false, - data: TestDataset.BARCHART_1Y0G, - hideInLegend: false, - }; - - const specs = new Map(); - specs.set(spec1.id, spec1); - const dataSeriesValuesA: SeriesCollectionValue = { + describe('#getSeriesColors', () => { + const seriesKey = 'mock_series_key'; + const mockSeries: SeriesCollectionValue = { seriesIdentifier: { specId: 'spec1', yAccessor: 'y1', splitAccessors: new Map(), seriesKeys: ['a', 'b', 'c'], - key: '', + key: seriesKey, }, }; @@ -520,22 +504,53 @@ describe('Series', () => { }; const seriesColors = new Map(); - seriesColors.set('spec1', dataSeriesValuesA); + seriesColors.set(seriesKey, mockSeries); const emptyCustomColors = new Map(); + const persistedColor = 'persisted_color'; + const customColor = 'custom_color'; + const customColors: Map = new Map(); + customColors.set(seriesKey, customColor); + const emptyColorOverrides = { + persisted: {}, + temporary: {}, + }; + const persistedOverrides = { + persisted: { [seriesKey]: persistedColor }, + temporary: {}, + }; - const defaultColorMap = getSeriesColors(seriesColors, chartColors, emptyCustomColors); - const expectedDefaultColorMap = new Map(); - expectedDefaultColorMap.set('spec1', 'elastic_charts_c1'); - expect(defaultColorMap).toEqual(expectedDefaultColorMap); + it('should return deafult color', () => { + const result = getSeriesColors(seriesColors, chartColors, emptyCustomColors, emptyColorOverrides); + const expected = new Map(); + expected.set(seriesKey, 'elastic_charts_c1'); + expect(result).toEqual(expected); + }); - const customColors: Map = new Map(); - customColors.set('spec1', 'custom_color'); + it('should return persisted color', () => { + const result = getSeriesColors(seriesColors, chartColors, emptyCustomColors, persistedOverrides); + const expected = new Map(); + expected.set(seriesKey, persistedColor); + expect(result).toEqual(expected); + }); + + it('should return custom color', () => { + const result = getSeriesColors(seriesColors, chartColors, customColors, persistedOverrides); + const expected = new Map(); + expected.set(seriesKey, customColor); + expect(result).toEqual(expected); + }); - const customizedColorMap = getSeriesColors(seriesColors, chartColors, customColors); - const expectedCustomizedColorMap = new Map(); - expectedCustomizedColorMap.set('spec1', 'custom_color'); - expect(customizedColorMap).toEqual(expectedCustomizedColorMap); + it('should return temporary color', () => { + const temporaryColor = 'persisted-color'; + const result = getSeriesColors(seriesColors, chartColors, customColors, { + ...persistedOverrides, + temporary: { [seriesKey]: temporaryColor }, + }); + const expected = new Map(); + expected.set(seriesKey, temporaryColor); + expect(result).toEqual(expected); + }); }); test('should only include deselectedDataSeries when splitting series if deselectedDataSeries is defined', () => { const specId = 'splitSpec'; diff --git a/packages/osd-charts/src/chart_types/xy_chart/utils/series.ts b/packages/osd-charts/src/chart_types/xy_chart/utils/series.ts index a7b6304cd200..3cb0c84aa10a 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/utils/series.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/utils/series.ts @@ -7,7 +7,8 @@ import { BasicSeriesSpec, SeriesTypes, SeriesSpecs, SeriesNameConfigOptions } fr import { formatStackedDataSeriesValues } from './stacked_series_utils'; import { ScaleType } from '../../../scales'; import { LastValues } from '../state/utils'; -import { Datum } from '../../../utils/commons'; +import { Datum, Color } from '../../../utils/commons'; +import { ColorOverrides } from '../../../state/chart_state'; export const SERIES_DELIMITER = ' - '; @@ -47,9 +48,12 @@ export interface DataSeriesDatum { /** the list of filled values because missing or nulls */ filled?: FilledValues; } + +export type SeriesKey = string; + export type SeriesIdentifier = { specId: SpecId; - key: string; + key: SeriesKey; }; export interface XYChartSeriesIdentifier extends SeriesIdentifier { @@ -113,7 +117,7 @@ export function splitSeries({ xValues: Set; } { const isMultipleY = yAccessors && yAccessors.length > 1; - const series = new Map(); + const series = new Map(); const colorsValues = new Set(); const xValues = new Set(); @@ -166,7 +170,7 @@ export function getSeriesKey({ * along with the series key */ function updateSeriesMap( - seriesMap: Map, + seriesMap: Map, splitAccessors: Map, accessor: any, datum: RawDataSeriesDatum, @@ -339,11 +343,11 @@ export function getSplittedSeries( deselectedDataSeries: XYChartSeriesIdentifier[] = [], ): { splittedSeries: Map; - seriesCollection: Map; + seriesCollection: Map; xValues: Set; } { const splittedSeries = new Map(); - const seriesCollection = new Map(); + const seriesCollection = new Map(); const xValues: Set = new Set(); let isOrdinalScale = false; for (const spec of seriesSpecs) { @@ -464,8 +468,8 @@ function getSortIndex({ specSortIndex }: SeriesCollectionValue, total: number): } export function getSortedDataSeriesColorsValuesMap( - seriesCollection: Map, -): Map { + seriesCollection: Map, +): Map { const seriesColorsArray = [...seriesCollection]; seriesColorsArray.sort(([, specA], [, specB]) => { return getSortIndex(specA, seriesCollection.size) - getSortIndex(specB, seriesCollection.size); @@ -474,17 +478,55 @@ export function getSortedDataSeriesColorsValuesMap( return new Map([...seriesColorsArray]); } +/** + * Helper function to get highest override color. + * + * from highest to lowest: `temporary`, `seriesSpec.color` then `persisted` + * + * @param key + * @param customColors + * @param overrides + */ +function getHighestOverride( + key: string, + customColors: Map, + overrides: ColorOverrides, +): Color | undefined { + let color: Color | undefined = overrides.temporary[key]; + + if (color) { + return color; + } + + color = customColors.get(key); + + if (color) { + return color; + } + + return overrides.persisted[key]; +} + +/** + * Returns color for a series given all color hierarchies + * + * @param seriesCollection + * @param chartColors + * @param customColors + * @param overrides + */ export function getSeriesColors( - seriesCollection: Map, + seriesCollection: Map, chartColors: ColorConfig, - customColors: Map, -): Map { - const seriesColorMap = new Map(); + customColors: Map, + overrides: ColorOverrides, +): Map { + const seriesColorMap = new Map(); let counter = 0; seriesCollection.forEach((_, seriesKey) => { - const customSeriesColor: string | undefined = customColors.get(seriesKey); - const color = customSeriesColor || chartColors.vizColors[counter % chartColors.vizColors.length]; + const colorOverride = getHighestOverride(seriesKey, customColors, overrides); + const color = colorOverride || chartColors.vizColors[counter % chartColors.vizColors.length]; seriesColorMap.set(seriesKey, color); counter++; diff --git a/packages/osd-charts/src/components/chart.tsx b/packages/osd-charts/src/components/chart.tsx index d5a86145ef0a..5c702bc249a9 100644 --- a/packages/osd-charts/src/components/chart.tsx +++ b/packages/osd-charts/src/components/chart.tsx @@ -1,7 +1,7 @@ import React, { CSSProperties, createRef } from 'react'; import classNames from 'classnames'; import { Provider } from 'react-redux'; -import { createStore, Store } from 'redux'; +import { createStore, Store, Unsubscribe } from 'redux'; import uuid from 'uuid'; import { SpecsParser } from '../specs/specs_parser'; import { ChartResizer } from './chart_resizer'; @@ -51,6 +51,7 @@ export class Chart extends React.Component { static defaultProps: ChartProps = { renderer: 'canvas', }; + private unsubscribeToStore: Unsubscribe; private chartStore: Store; private chartContainerRef: React.RefObject; private chartStageRef: React.RefObject; @@ -77,11 +78,12 @@ export class Chart extends React.Component { const onElementOutCaller = createOnElementOutCaller(); const onBrushEndCaller = createOnBrushEndCaller(); const onPointerMoveCaller = createOnPointerMoveCaller(); - this.chartStore.subscribe(() => { + this.unsubscribeToStore = this.chartStore.subscribe(() => { const state = this.chartStore.getState(); if (!isInitialized(state)) { return; } + const settings = getSettingsSpecSelector(state); if (this.state.legendPosition !== settings.legendPosition) { this.setState({ @@ -100,6 +102,10 @@ export class Chart extends React.Component { }); } + componentWillUnmount() { + this.unsubscribeToStore(); + } + dispatchExternalPointerEvent(event: PointerEvent) { this.chartStore.dispatch(onExternalPointerEvent(event)); } diff --git a/packages/osd-charts/src/components/legend/__snapshots__/legend.test.tsx.snap b/packages/osd-charts/src/components/legend/__snapshots__/legend.test.tsx.snap new file mode 100644 index 000000000000..b4f88a8934b1 --- /dev/null +++ b/packages/osd-charts/src/components/legend/__snapshots__/legend.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Legend #legendColorPicker should match snapshot after onChange is called 1`] = `"
splita
splitb
splitc
splitd
"`; + +exports[`Legend #legendColorPicker should match snapshot after onClose is called 1`] = `"
splita
splitb
splitc
splitd
"`; + +exports[`Legend #legendColorPicker should render colorPicker when color is clicked 1`] = `"
Custom Color Picker
"`; + +exports[`Legend #legendColorPicker should render colorPicker when color is clicked 2`] = `"
splita
Custom Color Picker
splitb
splitc
splitd
"`; diff --git a/packages/osd-charts/src/components/legend/_legend_item.scss b/packages/osd-charts/src/components/legend/_legend_item.scss index 70a62d079cd7..36e2cbf7025a 100644 --- a/packages/osd-charts/src/components/legend/_legend_item.scss +++ b/packages/osd-charts/src/components/legend/_legend_item.scss @@ -9,9 +9,9 @@ $legendItemVerticalPadding: $echLegendRowGap / 2; align-items: center; width: 100%; - &:hover { - .echLegendItem__label { - text-decoration: underline; + &:not(&--hidden) { + .echLegendItem__color--changable { + cursor: pointer; } } @@ -34,6 +34,7 @@ $legendItemVerticalPadding: $echLegendRowGap / 2; &:hover { cursor: pointer; + text-decoration: underline; } } diff --git a/packages/osd-charts/src/components/legend/legend.test.tsx b/packages/osd-charts/src/components/legend/legend.test.tsx index f64b6a1781b0..252cae2fb533 100644 --- a/packages/osd-charts/src/components/legend/legend.test.tsx +++ b/packages/osd-charts/src/components/legend/legend.test.tsx @@ -1,11 +1,13 @@ -import React from 'react'; -import { mount } from 'enzyme'; +import React, { Component } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; import { Chart } from '../chart'; -import { Settings, BarSeries } from '../../specs'; +import { Settings, BarSeries, LegendColorPicker } from '../../specs'; import { ScaleType } from '../../scales'; -import { DataGenerator } from '../../utils/data_generators/data_generator'; import { Legend } from './legend'; import { LegendListItem } from './legend_item'; +import { SeededDataGenerator } from '../../mocks/utils'; + +const dg = new SeededDataGenerator(); describe('Legend', () => { it('shall render the all the series names', () => { @@ -73,7 +75,6 @@ describe('Legend', () => { it('shall call the over and out listeners for every list item', () => { const onLegendItemOver = jest.fn(); const onLegendItemOut = jest.fn(); - const dg = new DataGenerator(); const numberOfSeries = 4; const data = dg.generateGroupedSeries(10, numberOfSeries, 'split'); const wrapper = mount( @@ -103,7 +104,6 @@ describe('Legend', () => { }); it('shall call click listener for every list item', () => { const onLegendItemClick = jest.fn(); - const dg = new DataGenerator(); const numberOfSeries = 4; const data = dg.generateGroupedSeries(10, numberOfSeries, 'split'); const wrapper = mount( @@ -131,4 +131,160 @@ describe('Legend', () => { expect(onLegendItemClick).toBeCalledTimes(i + 1); }); }); + + describe('#legendColorPicker', () => { + class LegendColorPickerMock extends Component< + { onLegendItemClick: () => void; customColor: string }, + { colors: string[] } + > { + state = { + colors: ['red'], + }; + + data = dg.generateGroupedSeries(10, 4, 'split'); + + legendColorPickerFn: LegendColorPicker = ({ onClose }) => { + return ( +
+ Custom Color Picker + + +
+ ); + }; + + render() { + return ( + + + + + ); + } + } + + let wrapper: ReactWrapper; + const customColor = '#0c7b93'; + const onLegendItemClick = jest.fn(); + + beforeEach(() => { + wrapper = mount(); + }); + + const clickFirstColor = () => { + const legendWrapper = wrapper.find(Legend); + expect(legendWrapper.exists).toBeTruthy(); + const legendItems = legendWrapper.find(LegendListItem); + expect(legendItems.exists).toBeTruthy(); + expect(legendItems).toHaveLength(4); + legendItems + .first() + .find('.echLegendItem__color') + .simulate('click'); + }; + + it('should render colorPicker when color is clicked', () => { + clickFirstColor(); + expect(wrapper.find('#colorPicker').html()).toMatchSnapshot(); + expect( + wrapper + .find(LegendListItem) + .map((e) => e.html()) + .join(''), + ).toMatchSnapshot(); + }); + + it('should match snapshot after onChange is called', () => { + clickFirstColor(); + wrapper + .find('#change') + .simulate('click') + .first(); + + expect( + wrapper + .find(LegendListItem) + .map((e) => e.html()) + .join(''), + ).toMatchSnapshot(); + }); + + it('should set isOpen to false after onChange is called', () => { + clickFirstColor(); + wrapper + .find('#change') + .simulate('click') + .first(); + expect(wrapper.find('#colorPicker').exists()).toBe(false); + }); + + it('should set color after onChange is called', () => { + clickFirstColor(); + wrapper + .find('#change') + .simulate('click') + .first(); + const dot = wrapper.find('.echLegendItem__color svg'); + expect(dot.exists(`[color="${customColor}"]`)).toBe(true); + }); + + it('should match snapshot after onClose is called', () => { + clickFirstColor(); + wrapper + .find('#close') + .simulate('click') + .first(); + expect( + wrapper + .find(LegendListItem) + .map((e) => e.html()) + .join(''), + ).toMatchSnapshot(); + }); + + it('should set isOpen to false after onClose is called', () => { + clickFirstColor(); + wrapper + .find('#close') + .simulate('click') + .first(); + expect(wrapper.find('#colorPicker').exists()).toBe(false); + }); + + it('should call click listener for every list item', () => { + const legendWrapper = wrapper.find(Legend); + expect(legendWrapper.exists).toBeTruthy(); + const legendItems = legendWrapper.find(LegendListItem); + expect(legendItems.exists).toBeTruthy(); + expect(legendItems).toHaveLength(4); + legendItems.forEach((legendItem, i) => { + // toggle click is only enabled on the title + legendItem.find('.echLegendItem__label').simulate('click'); + expect(onLegendItemClick).toBeCalledTimes(i + 1); + }); + }); + }); }); diff --git a/packages/osd-charts/src/components/legend/legend.tsx b/packages/osd-charts/src/components/legend/legend.tsx index 91f6d14e2f75..6037f7bc52b2 100644 --- a/packages/osd-charts/src/components/legend/legend.tsx +++ b/packages/osd-charts/src/components/legend/legend.tsx @@ -21,11 +21,13 @@ import { onLegendItemOutAction, onLegendItemOverAction, } from '../../state/actions/legend'; +import { clearTemporaryColors, setTemporaryColor, setPersistedColor } from '../../state/actions/colors'; import { SettingsSpec } from '../../specs'; import { BandedAccessorType } from '../../utils/geometry'; +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; interface LegendStateProps { - legendItems: Map; + legendItems: Map; legendPosition: Position; legendItemTooltipValues: Map; showLegend: boolean; @@ -40,6 +42,9 @@ interface LegendDispatchProps { onLegendItemOutAction: typeof onLegendItemOutAction; onLegendItemOverAction: typeof onLegendItemOverAction; onToggleDeselectSeriesAction: typeof onToggleDeselectSeriesAction; + clearTemporaryColors: typeof clearTemporaryColors; + setTemporaryColor: typeof setTemporaryColor; + setPersistedColor: typeof setPersistedColor; } type LegendProps = LegendStateProps & LegendDispatchProps; @@ -119,8 +124,8 @@ class LegendComponent extends React.Component { }; private getLegendValues( - tooltipValues: Map | undefined, - key: string, + tooltipValues: Map | undefined, + key: SeriesKey, banded: boolean = false, ): any[] { const values = tooltipValues && tooltipValues.get(key); @@ -138,21 +143,25 @@ class LegendComponent extends React.Component { } const { key, displayValue, banded } = item; const { legendItemTooltipValues, settings } = this.props; - const { showLegendExtra, legendPosition } = settings; + const { showLegendExtra, legendPosition, legendColorPicker } = settings; const legendValues = this.getLegendValues(legendItemTooltipValues, key, banded); return legendValues.map((value, index) => { const yAccessor: BandedAccessorType = index === 0 ? BandedAccessorType.Y1 : BandedAccessorType.Y0; return ( onToggleDeselectSeriesAction, onLegendItemOutAction, onLegendItemOverAction, + clearTemporaryColors, + setTemporaryColor, + setPersistedColor, }, dispatch, ); diff --git a/packages/osd-charts/src/components/legend/legend_item.tsx b/packages/osd-charts/src/components/legend/legend_item.tsx index 4a479c404e2c..7d342b3e86e5 100644 --- a/packages/osd-charts/src/components/legend/legend_item.tsx +++ b/packages/osd-charts/src/components/legend/legend_item.tsx @@ -1,12 +1,13 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { Component, createRef } from 'react'; import { deepEqual } from '../../utils/fast_deep_equal'; import { Icon } from '../icons/icon'; -import { LegendItemListener, BasicListener } from '../../specs/settings'; +import { LegendItemListener, BasicListener, LegendColorPicker } from '../../specs/settings'; import { LegendItem } from '../../chart_types/xy_chart/legend/legend'; import { onLegendItemOutAction, onLegendItemOverAction } from '../../state/actions/legend'; -import { Position } from '../../utils/commons'; +import { Position, Color } from '../../utils/commons'; import { XYChartSeriesIdentifier } from '../../chart_types/xy_chart/utils/series'; +import { clearTemporaryColors, setTemporaryColor, setPersistedColor } from '../../state/actions/colors'; interface LegendItemProps { legendItem: LegendItem; @@ -14,11 +15,15 @@ interface LegendItemProps { label?: string; legendPosition: Position; showExtra: boolean; + legendColorPicker?: LegendColorPicker; onLegendItemClickListener?: LegendItemListener; onLegendItemOutListener?: BasicListener; onLegendItemOverListener?: LegendItemListener; legendItemOutAction: typeof onLegendItemOutAction; legendItemOverAction: typeof onLegendItemOverAction; + clearTemporaryColors: typeof clearTemporaryColors; + setTemporaryColor: typeof setTemporaryColor; + setPersistedColor: typeof setPersistedColor; toggleDeselectSeriesAction: (legendItemId: XYChartSeriesIdentifier) => void; } @@ -62,63 +67,127 @@ function renderLabel( ); } -/** - * Create a div for the color/eye icon - * @param color - * @param isSeriesVisible - */ -function renderColor(color?: string, isSeriesVisible = true) { - if (!color) { - return null; +interface LegendItemState { + isOpen: boolean; +} + +export class LegendListItem extends Component { + static displayName = 'LegendItem'; + ref = createRef(); + + state: LegendItemState = { + isOpen: false, + }; + + shouldComponentUpdate(nextProps: LegendItemProps, nextState: LegendItemState) { + return !deepEqual(this.props, nextProps) || !deepEqual(this.state, nextState); } - // TODO add color picker - if (isSeriesVisible) { + + handleColorClick = (changable: boolean) => + changable + ? (event: React.MouseEvent) => { + event.stopPropagation(); + this.toggleIsOpen(); + } + : undefined; + + /** + * Create a div for the color/eye icon + * @param color + * @param isSeriesVisible + */ + renderColor = (color?: string, isSeriesVisible = true) => { + if (!color) { + return null; + } + + if (!isSeriesVisible) { + return ( +
+ {/* changing the default viewBox for the eyeClosed icon to keep the same dimensions */} + +
+ ); + } + + const changable = Boolean(this.props.legendColorPicker); + const colorClasses = classNames('echLegendItem__color', { + 'echLegendItem__color--changable': changable, + }); + return ( -
+
); - } - // changing the default viewBox for the eyeClosed icon to keep the same dimensions - return ( -
- -
- ); -} + }; -export class LegendListItem extends React.Component { - static displayName = 'LegendItem'; + renderColorPicker() { + const { + legendColorPicker: ColorPicker, + legendItem, + clearTemporaryColors, + setTemporaryColor, + setPersistedColor, + } = this.props; + const { seriesIdentifier, color } = legendItem; - shouldComponentUpdate(nextProps: LegendItemProps) { - return !deepEqual(this.props, nextProps); + const handleClose = () => { + setPersistedColor(seriesIdentifier.key, color); + clearTemporaryColors(); + this.toggleIsOpen(); + }; + + if (ColorPicker && this.state.isOpen && this.ref.current) { + return ( + setTemporaryColor(seriesIdentifier.key, color)} + seriesIdentifier={seriesIdentifier} + /> + ); + } } render() { const { extra, legendItem, legendPosition, label, showExtra, onLegendItemClickListener } = this.props; const { color, isSeriesVisible, seriesIdentifier, isLegendItemVisible } = legendItem; - const onLabelClick = this.onVisibilityClick(seriesIdentifier); const hasLabelClickListener = Boolean(onLegendItemClickListener); const itemClassNames = classNames('echLegendItem', `echLegendItem--${legendPosition}`, { - 'echLegendItem-isHidden': !isSeriesVisible, + 'echLegendItem--hidden': !isSeriesVisible, 'echLegendItem__extra--hidden': !isLegendItemVisible, }); return ( -
- {renderColor(color, isSeriesVisible)} - {renderLabel(onLabelClick, hasLabelClickListener, label)} - {showExtra && renderExtra(extra, isSeriesVisible)} -
+ <> +
+ {this.renderColor(color, isSeriesVisible)} + {renderLabel(onLabelClick, hasLabelClickListener, label)} + {showExtra && renderExtra(extra, isSeriesVisible)} +
+ {this.renderColorPicker()} + ); } + toggleIsOpen = () => { + this.setState(({ isOpen }) => ({ isOpen: !isOpen })); + }; + onLegendItemMouseOver = () => { const { onLegendItemOverListener, legendItemOverAction, legendItem } = this.props; // call the settings listener directly if available diff --git a/packages/osd-charts/src/specs/settings.tsx b/packages/osd-charts/src/specs/settings.tsx index d316532ef5a1..68120c9b1536 100644 --- a/packages/osd-charts/src/specs/settings.tsx +++ b/packages/osd-charts/src/specs/settings.tsx @@ -1,4 +1,6 @@ +import { ComponentType } from 'react'; import { $Values } from 'utility-types'; + import { DomainRange } from '../chart_types/xy_chart/utils/specs'; import { PartialTheme, Theme } from '../utils/themes/theme'; import { Domain } from '../utils/domain'; @@ -8,9 +10,9 @@ import { LIGHT_THEME } from '../utils/themes/light_theme'; import { ChartTypes } from '../chart_types'; import { GeometryValue } from '../utils/geometry'; import { XYChartSeriesIdentifier, SeriesIdentifier } from '../chart_types/xy_chart/utils/series'; -import { Position, Rendering, Rotation } from '../utils/commons'; -import { ScaleContinuousType, ScaleOrdinalType } from '../scales'; import { Accessor } from '../utils/accessor'; +import { Position, Rendering, Rotation, Color } from '../utils/commons'; +import { ScaleContinuousType, ScaleOrdinalType } from '../scales'; export type ElementClickListener = (elements: Array<[GeometryValue, XYChartSeriesIdentifier]>) => void; export type ElementOverListener = (elements: Array<[GeometryValue, XYChartSeriesIdentifier]>) => void; @@ -83,7 +85,7 @@ export interface TooltipValue { /** * The color of the graphic mark (by default the color of the series) */ - color: string; + color: Color; /** * True if the mouse is over the graphic mark connected to the tooltip */ @@ -111,6 +113,30 @@ export interface TooltipProps { unit?: string; } +export interface LegendColorPickerProps { + /** + * Anchor used to position picker + */ + anchor: HTMLDivElement; + /** + * Current color of the given series + */ + color: Color; + /** + * Callback to close color picker and set persistent color + */ + onClose: () => void; + /** + * Callback to update temporary color state + */ + onChange: (color: Color) => void; + /** + * Series id for the active series + */ + seriesIdentifier: XYChartSeriesIdentifier; +} +export type LegendColorPicker = ComponentType; + export interface SettingsSpec extends Spec { /** * Partial theme to be merged with base @@ -161,6 +187,7 @@ export interface SettingsSpec extends Spec { onRenderChange?: RenderChangeListener; xDomain?: Domain | DomainRange; resizeDebounce?: number; + legendColorPicker?: LegendColorPicker; } export type DefaultSettingsProps = diff --git a/packages/osd-charts/src/state/actions/colors.ts b/packages/osd-charts/src/state/actions/colors.ts new file mode 100644 index 000000000000..61e571a240bb --- /dev/null +++ b/packages/osd-charts/src/state/actions/colors.ts @@ -0,0 +1,36 @@ +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; +import { Color } from '../../utils/commons'; + +export const CLEAR_TEMPORARY_COLORS = 'CLEAR_TEMPORARY_COLORS'; +export const SET_TEMPORARY_COLOR = 'SET_TEMPORARY_COLOR'; +export const SET_PERSISTED_COLOR = 'SET_PERSISTED_COLOR'; + +interface ClearTemporaryColors { + type: typeof CLEAR_TEMPORARY_COLORS; +} + +interface SetTemporaryColor { + type: typeof SET_TEMPORARY_COLOR; + key: SeriesKey; + color: Color; +} + +interface SetPersistedColor { + type: typeof SET_PERSISTED_COLOR; + key: SeriesKey; + color: Color; +} + +export function clearTemporaryColors(): ClearTemporaryColors { + return { type: CLEAR_TEMPORARY_COLORS }; +} + +export function setTemporaryColor(key: SeriesKey, color: Color): SetTemporaryColor { + return { type: SET_TEMPORARY_COLOR, key, color }; +} + +export function setPersistedColor(key: SeriesKey, color: Color): SetPersistedColor { + return { type: SET_PERSISTED_COLOR, key, color }; +} + +export type ColorsActions = ClearTemporaryColors | SetTemporaryColor | SetPersistedColor; diff --git a/packages/osd-charts/src/state/actions/index.ts b/packages/osd-charts/src/state/actions/index.ts index 7044bba8b6cd..64f2c49148a3 100644 --- a/packages/osd-charts/src/state/actions/index.ts +++ b/packages/osd-charts/src/state/actions/index.ts @@ -4,6 +4,7 @@ import { ChartSettingsActions } from './chart_settings'; import { LegendActions } from './legend'; import { EventsActions } from './events'; import { MouseActions } from './mouse'; +import { ColorsActions } from './colors'; export type StateActions = | SpecActions @@ -11,4 +12,5 @@ export type StateActions = | ChartSettingsActions | LegendActions | EventsActions - | MouseActions; + | MouseActions + | ColorsActions; diff --git a/packages/osd-charts/src/state/chart_state.ts b/packages/osd-charts/src/state/chart_state.ts index 1cb0c4d29e4a..c392883e3693 100644 --- a/packages/osd-charts/src/state/chart_state.ts +++ b/packages/osd-charts/src/state/chart_state.ts @@ -1,8 +1,9 @@ import { SPEC_PARSED, SPEC_UNMOUNTED, UPSERT_SPEC, REMOVE_SPEC, SPEC_PARSING } from './actions/specs'; +import { SET_PERSISTED_COLOR, SET_TEMPORARY_COLOR, CLEAR_TEMPORARY_COLORS } from './actions/colors'; import { interactionsReducer } from './reducers/interactions'; import { ChartTypes } from '../chart_types'; import { XYAxisChartState } from '../chart_types/xy_chart/state/chart_state'; -import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; +import { XYChartSeriesIdentifier, SeriesKey } from '../chart_types/xy_chart/utils/series'; import { Spec, PointerEvent } from '../specs'; import { DEFAULT_SETTINGS_SPEC } from '../specs/settings'; import { Dimensions } from '../utils/dimensions'; @@ -17,6 +18,7 @@ import { RefObject } from 'react'; import { PartitionState } from '../chart_types/partition_chart/state/chart_state'; import { TooltipInfo } from '../components/tooltip/types'; import { TooltipAnchorPosition } from '../components/tooltip/utils'; +import { Color } from '../utils/commons'; export type BackwardRef = () => React.RefObject; @@ -54,12 +56,12 @@ export interface InternalChartState { * return the list of legend items * @param globalState */ - getLegendItems(globalState: GlobalChartState): Map; + getLegendItems(globalState: GlobalChartState): Map; /** * return the list of values for each legend item * @param globalState */ - getLegendItemsValues(globalState: GlobalChartState): Map; + getLegendItemsValues(globalState: GlobalChartState): Map; /** * return the CSS pointer cursor depending on the internal chart state * @param globalState @@ -116,27 +118,56 @@ export interface ExternalEventsState { pointer: PointerEvent | null; } +export interface ColorOverrides { + temporary: Record; + persisted: Record; +} + export interface GlobalChartState { - // an unique ID for each chart used by re-reselect to memoize selector per chart + /** + * a unique ID for each chart used by re-reselect to memoize selector per chart + */ chartId: string; - // true when all all the specs are parsed ad stored into the specs object + /** + * true when all all the specs are parsed ad stored into the specs object + */ specsInitialized: boolean; - // true if the chart is rendered on dom + /** + * true if the chart is rendered on dom + */ chartRendered: boolean; - // incremental count of the chart rendering + /** + * incremental count of the chart rendering + */ chartRenderedCount: number; - // the map of parsed specs + /** + * the map of parsed specs + */ specs: SpecList; - // the chart type depending on the used specs + /** + * the chart type depending on the used specs + */ chartType: ChartTypes | null; - // a chart-type-dependant class that is used to render and share chart-type dependant functions + /** + * a chart-type-dependant class that is used to render and share chart-type dependant functions + */ internalChartState: InternalChartState | null; - // the dimensions of the parent container, including the legend + /** + * the dimensions of the parent container, including the legend + */ parentDimensions: Dimensions; - // the state of the interactions + /** + * the state of the interactions + */ interactions: InteractionsState; - // external event state + /** + * external event state + */ externalEvents: ExternalEventsState; + /** + * Color map used to persist color picker changes + */ + colors: ColorOverrides; } export const getInitialState = (chartId: string): GlobalChartState => ({ @@ -147,6 +178,10 @@ export const getInitialState = (chartId: string): GlobalChartState => ({ specs: { [DEFAULT_SETTINGS_SPEC.id]: DEFAULT_SETTINGS_SPEC, }, + colors: { + temporary: {}, + persisted: {}, + }, chartType: null, internalChartState: null, interactions: { @@ -267,6 +302,36 @@ export const chartStoreReducer = (chartId: string) => { }, }, }; + case CLEAR_TEMPORARY_COLORS: + return { + ...state, + colors: { + ...state.colors, + temporary: {}, + }, + }; + case SET_TEMPORARY_COLOR: + return { + ...state, + colors: { + ...state.colors, + temporary: { + ...state.colors.temporary, + [action.key]: action.color, + }, + }, + }; + case SET_PERSISTED_COLOR: + return { + ...state, + colors: { + ...state.colors, + persisted: { + ...state.colors.persisted, + [action.key]: action.color, + }, + }, + }; default: return { ...state, diff --git a/packages/osd-charts/src/state/selectors/get_legend_items.ts b/packages/osd-charts/src/state/selectors/get_legend_items.ts index d3e1888e4c89..8b9064cb8024 100644 --- a/packages/osd-charts/src/state/selectors/get_legend_items.ts +++ b/packages/osd-charts/src/state/selectors/get_legend_items.ts @@ -1,8 +1,9 @@ import { GlobalChartState } from '../chart_state'; import { LegendItem } from '../../chart_types/xy_chart/legend/legend'; +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; -const EMPTY_LEGEND_LIST = new Map(); -export const getLegendItemsSelector = (state: GlobalChartState): Map => { +const EMPTY_LEGEND_LIST = new Map(); +export const getLegendItemsSelector = (state: GlobalChartState): Map => { if (state.internalChartState) { return state.internalChartState.getLegendItems(state); } else { diff --git a/packages/osd-charts/src/state/selectors/get_legend_items_values.ts b/packages/osd-charts/src/state/selectors/get_legend_items_values.ts index 9a41a4f8bea2..5b06c1313689 100644 --- a/packages/osd-charts/src/state/selectors/get_legend_items_values.ts +++ b/packages/osd-charts/src/state/selectors/get_legend_items_values.ts @@ -1,8 +1,9 @@ import { TooltipLegendValue } from '../../chart_types/xy_chart/tooltip/tooltip'; import { GlobalChartState } from '../chart_state'; +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; -const EMPTY_ITEM_LIST = new Map(); -export const getLegendItemsValuesSelector = (state: GlobalChartState): Map => { +const EMPTY_ITEM_LIST = new Map(); +export const getLegendItemsValuesSelector = (state: GlobalChartState): Map => { if (state.internalChartState) { return state.internalChartState.getLegendItemsValues(state); } else { diff --git a/packages/osd-charts/src/utils/geometry.ts b/packages/osd-charts/src/utils/geometry.ts index 8c0cded6c7cb..47da1b834293 100644 --- a/packages/osd-charts/src/utils/geometry.ts +++ b/packages/osd-charts/src/utils/geometry.ts @@ -1,6 +1,7 @@ import { $Values } from 'utility-types'; import { BarSeriesStyle, PointStyle, AreaStyle, LineStyle, ArcStyle } from './themes/theme'; import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; +import { Color } from './commons'; /** * The accessor type @@ -31,7 +32,7 @@ export interface PointGeometry { x: number; y: number; radius: number; - color: string; + color: Color; transform: { x: number; y: number; @@ -45,7 +46,7 @@ export interface BarGeometry { y: number; width: number; height: number; - color: string; + color: Color; displayValue?: { text: any; width: number; @@ -61,7 +62,7 @@ export interface BarGeometry { export interface LineGeometry { line: string; points: PointGeometry[]; - color: string; + color: Color; transform: { x: number; y: number; @@ -79,7 +80,7 @@ export interface AreaGeometry { area: string; lines: string[]; points: PointGeometry[]; - color: string; + color: Color; transform: { x: number; y: number; @@ -97,7 +98,7 @@ export interface AreaGeometry { export interface ArcGeometry { arc: string; - color: string; + color: Color; seriesIdentifier: XYChartSeriesIdentifier; seriesArcStyle: ArcStyle; transform: { diff --git a/packages/osd-charts/stories/legend/9_color_picker.tsx b/packages/osd-charts/stories/legend/9_color_picker.tsx new file mode 100644 index 000000000000..f961dcda3343 --- /dev/null +++ b/packages/osd-charts/stories/legend/9_color_picker.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; + +import { EuiColorPicker, EuiWrappingPopover, EuiButton, EuiSpacer } from '@elastic/eui'; + +import { Axis, BarSeries, Chart, Position, ScaleType, Settings, LegendColorPicker } from '../../src/'; +import { BARCHART_1Y1G } from '../../src/utils/data_samples/test_dataset'; +import { SeriesKey } from '../../src/chart_types/xy_chart/utils/series'; +import { Color } from '../../src/utils/commons'; + +const onChangeAction = action('onChange'); +const onCloseAction = action('onClose'); + +export const example = () => { + const [colors, setColors] = useState>({}); + + const renderColorPicker: LegendColorPicker = ({ anchor, color, onClose, seriesIdentifier, onChange }) => { + const handleClose = () => { + onClose(); + onCloseAction(); + setColors({ + ...colors, + [seriesIdentifier.key]: color, + }); + }; + const handleChange = (color: Color) => { + onChange(color); + onChangeAction(color); + }; + return ( + + + + + Done + + + ); + }; + + return ( + + + + Number(d).toFixed(2)} /> + + colors[key] ?? null} + /> + + ); +}; + +example.story = { + parameters: { + info: { + text: + 'Elastic charts will maintain the color selection in memory beyond chart updates. However, to persist colors beyond browser refresh the consumer would need to manage the color state and use the color prop on the SeriesSpec to assign a color via a SeriesColorAccessor.', + }, + }, +}; diff --git a/packages/osd-charts/stories/legend/legend.stories.tsx b/packages/osd-charts/stories/legend/legend.stories.tsx index 908565bd29d4..44dd12573ed5 100644 --- a/packages/osd-charts/stories/legend/legend.stories.tsx +++ b/packages/osd-charts/stories/legend/legend.stories.tsx @@ -15,3 +15,4 @@ export { example as changingSpecs } from './5_changing_specs'; export { example as hideLegendItemsBySeries } from './6_hide_legend'; export { example as displayValuesInLegendElements } from './7_display_values'; export { example as legendSpacingBuffer } from './8_spacing_buffer'; +export { example as colorPicker } from './9_color_picker'; diff --git a/packages/osd-charts/yarn.lock b/packages/osd-charts/yarn.lock index cc84ed5d168a..05c539a8a738 100644 --- a/packages/osd-charts/yarn.lock +++ b/packages/osd-charts/yarn.lock @@ -2670,6 +2670,13 @@ resolved "https://registry.yarnpkg.com/@egoist/vue-to-react/-/vue-to-react-1.1.0.tgz#83c884b8608e8ee62e76c03e91ce9c26063a91ad" integrity sha512-MwfwXHDh6ptZGLEtNLPXp2Wghteav7mzpT2Mcwl3NZWKF814i5hhHnNkVrcQQEuxUroSWQqzxLkMKSb+nhPang== +"@elastic/datemath@^5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@elastic/datemath/-/datemath-5.0.2.tgz#1e62fe7137acd6ebcde9a794ef22b91820c9e6cf" + integrity sha512-MYU7KedGPMYu3ljgrO3tY8I8rD73lvBCltd78k5avDIv/6gMbuhKXsMhkEPbb9angs9hR/2ADk0QcGbVxUBXUw== + dependencies: + tslib "^1.9.3" + "@elastic/eui@^16.0.1": version "16.0.1" resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-16.0.1.tgz#8b3d358d1574f4168fd276c5cb190361c477f0b0"