From 764f12740b6dc95bc00781d1763d67549416bb61 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Mon, 5 Aug 2019 14:46:53 -0500 Subject: [PATCH] feat(bar_chart): color/style override accessor (#271) Allow user to override colors/styles of given BarChart datum based on accessor function prop called StyleAccessor. BREAKING CHANGE: colorAccessors removed from YBasicSeriesSpec (aka for all series) which had acted similarly to a split accessor. #216 --- .../chart_types/xy_chart/domains/y_domain.ts | 2 +- .../xy_chart/rendering/rendering.test.ts | 118 ++++++++++++++++- .../xy_chart/rendering/rendering.ts | 50 +++++-- .../src/chart_types/xy_chart/store/utils.ts | 1 + .../src/chart_types/xy_chart/utils/series.ts | 9 +- .../src/chart_types/xy_chart/utils/specs.ts | 10 +- packages/osd-charts/stories/bar_chart.tsx | 123 ++++++++++++------ packages/osd-charts/wiki/overview.md | 19 +-- 8 files changed, 262 insertions(+), 70 deletions(-) diff --git a/packages/osd-charts/src/chart_types/xy_chart/domains/y_domain.ts b/packages/osd-charts/src/chart_types/xy_chart/domains/y_domain.ts index 8ce39b30b0ec..f459d66ffb5d 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/domains/y_domain.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/domains/y_domain.ts @@ -16,7 +16,7 @@ export type YDomain = BaseDomain & { }; export type YBasicSeriesSpec = Pick< BasicSeriesSpec, - 'id' | 'seriesType' | 'yScaleType' | 'groupId' | 'stackAccessors' | 'yScaleToDataExtent' | 'colorAccessors' + 'id' | 'seriesType' | 'yScaleType' | 'groupId' | 'stackAccessors' | 'yScaleToDataExtent' | 'styleAccessor' > & { stackAsPercentage?: boolean }; export function mergeYDomain( diff --git a/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.test.ts b/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.test.ts index 72f6180134af..0d1352809d9d 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.test.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/rendering/rendering.test.ts @@ -1,6 +1,16 @@ import { DEFAULT_GEOMETRY_STYLES } from '../../../utils/themes/theme_commons'; import { getSpecId } from '../../../utils/ids'; -import { BarGeometry, getGeometryStyle, isPointOnGeometry, PointGeometry } from './rendering'; +import { + BarGeometry, + getGeometryStyle, + isPointOnGeometry, + PointGeometry, + getStyleOverrides, + GeometryId, +} from './rendering'; +import { BarSeriesStyle } from '../../../utils/themes/theme'; +import { DataSeriesDatum } from '../utils/series'; +import { RecursivePartial, mergePartial } from '../../../utils/commons'; describe('Rendering utils', () => { test('check if point is in geometry', () => { @@ -168,4 +178,110 @@ describe('Rendering utils', () => { expect(noHover).toEqual({ opacity: 1 }); }); + + describe('getStyleOverrides', () => { + let mockAccessor: jest.Mock; + + const sampleSeriesStyle: BarSeriesStyle = { + rect: { + opacity: 1, + }, + rectBorder: { + visible: true, + strokeWidth: 1, + }, + displayValue: { + fontSize: 10, + fontFamily: 'helvetica', + fill: 'blue', + padding: 1, + offsetX: 1, + offsetY: 1, + }, + }; + const datum: DataSeriesDatum = { + x: 1, + y1: 2, + y0: 3, + initialY1: 4, + initialY0: 5, + }; + const geometryId: GeometryId = { + specId: getSpecId('test'), + seriesKey: ['test'], + }; + + beforeEach(() => { + mockAccessor = jest.fn(); + }); + + it('should return input seriesStyle if no styleAccessor is passed', () => { + const styleOverrides = getStyleOverrides(datum, geometryId, sampleSeriesStyle); + + expect(styleOverrides).toBe(sampleSeriesStyle); + }); + + it('should return input seriesStyle if styleAccessor returns null', () => { + mockAccessor.mockReturnValue(null); + const styleOverrides = getStyleOverrides(datum, geometryId, sampleSeriesStyle, mockAccessor); + + expect(styleOverrides).toBe(sampleSeriesStyle); + }); + + it('should call styleAccessor with datum and geometryId', () => { + getStyleOverrides(datum, geometryId, sampleSeriesStyle, mockAccessor); + + expect(mockAccessor).toBeCalledWith(datum, geometryId); + }); + + it('should return seriesStyle with updated fill color', () => { + const color = 'blue'; + mockAccessor.mockReturnValue(color); + const styleOverrides = getStyleOverrides(datum, geometryId, sampleSeriesStyle, mockAccessor); + const expectedStyles: BarSeriesStyle = { + ...sampleSeriesStyle, + rect: { + ...sampleSeriesStyle.rect, + fill: color, + }, + }; + expect(styleOverrides).toEqual(expectedStyles); + }); + + it('should return a new seriesStyle object with color', () => { + mockAccessor.mockReturnValue('blue'); + const styleOverrides = getStyleOverrides(datum, geometryId, sampleSeriesStyle, mockAccessor); + + expect(styleOverrides).not.toBe(sampleSeriesStyle); + }); + + it('should return seriesStyle with updated partial style', () => { + const partialStyle: RecursivePartial = { + rect: { + fill: 'blue', + }, + rectBorder: { + strokeWidth: 10, + }, + }; + mockAccessor.mockReturnValue(partialStyle); + const styleOverrides = getStyleOverrides(datum, geometryId, sampleSeriesStyle, mockAccessor); + const expectedStyles = mergePartial(sampleSeriesStyle, partialStyle, { + mergeOptionalPartialValues: true, + }); + + expect(styleOverrides).toEqual(expectedStyles); + }); + + it('should return a new seriesStyle object with partial styles', () => { + mockAccessor.mockReturnValue({ + rect: { + fill: 'blue', + }, + }); + const styleOverrides = getStyleOverrides(datum, geometryId, sampleSeriesStyle, mockAccessor); + + expect(styleOverrides).not.toBe(sampleSeriesStyle); + }); + }); }); 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 cf192a7dfee9..8c1282f02545 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 @@ -1,4 +1,5 @@ import { area, line } from 'd3-shape'; + import { CanvasTextBBoxCalculator } from '../../../utils/bbox/canvas_text_bbox_calculator'; import { AreaSeriesStyle, @@ -16,7 +17,8 @@ import { CurveType, getCurveFactory } from '../../../utils/curves'; import { LegendItem } from '../legend/legend'; import { DataSeriesDatum } from '../utils/series'; import { belongsToDataSeries } from '../utils/series_utils'; -import { DisplayValueSpec } from '../utils/specs'; +import { DisplayValueSpec, StyleAccessor } from '../utils/specs'; +import { mergePartial } from '../../../utils/commons'; export interface GeometryId { specId: SpecId; @@ -113,6 +115,33 @@ export function mutableIndexedGeometryMapUpsert( } } +export function getStyleOverrides( + datum: DataSeriesDatum, + geometryId: GeometryId, + seriesStyle: BarSeriesStyle, + styleAccessor?: StyleAccessor, +): BarSeriesStyle { + const styleOverride = styleAccessor && styleAccessor(datum, geometryId); + + if (!styleOverride) { + return seriesStyle; + } + + if (typeof styleOverride === 'string') { + return { + ...seriesStyle, + rect: { + ...seriesStyle.rect, + fill: styleOverride, + }, + }; + } + + return mergePartial(seriesStyle, styleOverride, { + mergeOptionalPartialValues: true, + }); +} + export function renderPoints( shift: number, dataset: DataSeriesDatum[], @@ -195,8 +224,9 @@ export function renderBars( color: string, specId: SpecId, seriesKey: any[], - seriesStyle: BarSeriesStyle, + sharedSeriesStyle: BarSeriesStyle, displayValueSettings?: DisplayValueSpec, + styleAccessor?: StyleAccessor, ): { barGeometries: BarGeometry[]; indexedGeometries: Map; @@ -210,8 +240,8 @@ export function renderBars( // default padding to 1 for now const padding = 1; - const fontSize = seriesStyle.displayValue.fontSize; - const fontFamily = seriesStyle.displayValue.fontFamily; + const fontSize = sharedSeriesStyle.displayValue.fontSize; + const fontFamily = sharedSeriesStyle.displayValue.fontFamily; dataset.forEach((datum) => { const { y0, y1, initialY1 } = datum; @@ -278,6 +308,13 @@ export function renderBars( } : undefined; + const geometryId = { + specId, + seriesKey, + }; + + const seriesStyle = getStyleOverrides(datum, geometryId, sharedSeriesStyle, styleAccessor); + const barGeometry: BarGeometry = { displayValue, x, @@ -290,10 +327,7 @@ export function renderBars( y: initialY1, accessor: 'y1', }, - geometryId: { - specId, - seriesKey, - }, + geometryId, seriesStyle, }; mutableIndexedGeometryMapUpsert(indexedGeometries, datum.x, barGeometry); diff --git a/packages/osd-charts/src/chart_types/xy_chart/store/utils.ts b/packages/osd-charts/src/chart_types/xy_chart/store/utils.ts index f8076c457b4d..2230005d227c 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/store/utils.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/store/utils.ts @@ -451,6 +451,7 @@ export function renderGeometries( ds.key, barSeriesStyle, displayValueSettings, + spec.styleAccessor, ); barGeometriesIndex = mergeGeometriesIndexes(barGeometriesIndex, renderedBars.indexedGeometries); bars.push(...renderedBars.barGeometries); 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 64f6c77682eb..fe5c2828e682 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 @@ -92,7 +92,6 @@ export function splitSeries( xValues: Set; } { const { xAccessor, yAccessors, y0Accessors, splitSeriesAccessors = [] } = accessors; - const colorAccessors = accessors.colorAccessors ? accessors.colorAccessors : splitSeriesAccessors; const isMultipleY = yAccessors && yAccessors.length > 1; const series = new Map(); const colorsValues = new Map(); @@ -102,7 +101,7 @@ export function splitSeries( const seriesKey = getAccessorsValues(datum, splitSeriesAccessors); if (isMultipleY) { yAccessors.forEach((accessor, index) => { - const colorValues = getColorValues(datum, colorAccessors, accessor); + const colorValues = getColorValues(datum, splitSeriesAccessors, accessor); const colorValuesKey = getColorValuesAsString(colorValues, specId); colorsValues.set(colorValuesKey, colorValues); const cleanedDatum = cleanDatum(datum, xAccessor, accessor, y0Accessors && y0Accessors[index]); @@ -110,7 +109,7 @@ export function splitSeries( updateSeriesMap(series, [...seriesKey, accessor], cleanedDatum, specId, colorValuesKey); }, {}); } else { - const colorValues = getColorValues(datum, colorAccessors); + const colorValues = getColorValues(datum, splitSeriesAccessors); const colorValuesKey = getColorValuesAsString(colorValues, specId); colorsValues.set(colorValuesKey, colorValues); const cleanedDatum = cleanDatum(datum, xAccessor, yAccessors[0], y0Accessors && y0Accessors[0]); @@ -165,8 +164,8 @@ function getAccessorsValues(datum: Datum, accessors: Accessor[] = []): any[] { /** * Get the array of values that forms a series key */ -function getColorValues(datum: Datum, colorAccessors: Accessor[] = [], yAccessorValue?: any): any[] { - const colorValues = getAccessorsValues(datum, colorAccessors); +function getColorValues(datum: Datum, accessors: Accessor[] = [], yAccessorValue?: any): any[] { + const colorValues = getAccessorsValues(datum, accessors); if (yAccessorValue) { return [...colorValues, yAccessorValue]; } diff --git a/packages/osd-charts/src/chart_types/xy_chart/utils/specs.ts b/packages/osd-charts/src/chart_types/xy_chart/utils/specs.ts index 7c0bf946397d..458375aa2e97 100644 --- a/packages/osd-charts/src/chart_types/xy_chart/utils/specs.ts +++ b/packages/osd-charts/src/chart_types/xy_chart/utils/specs.ts @@ -11,12 +11,16 @@ import { Omit, RecursivePartial } from '../../../utils/commons'; import { AnnotationId, AxisId, GroupId, SpecId } from '../../../utils/ids'; import { ScaleContinuousType, ScaleType } from '../../../utils/scales/scales'; import { CurveType } from '../../../utils/curves'; +import { DataSeriesColorsValues, RawDataSeriesDatum } from './series'; +import { GeometryId } from '../rendering/rendering'; import { AnnotationTooltipFormatter } from '../annotations/annotation_utils'; -import { DataSeriesColorsValues } from './series'; export type Datum = any; export type Rotation = 0 | 90 | -90 | 180; export type Rendering = 'canvas' | 'svg'; +export type Color = string; +export type StyleOverride = RecursivePartial | Color | null; +export type StyleAccessor = (datum: RawDataSeriesDatum, geometryId: GeometryId) => StyleOverride; interface DomainMinInterval { /** Custom minInterval for the domain which will affect data bucket size. @@ -97,8 +101,8 @@ export interface SeriesAccessors { splitSeriesAccessors?: Accessor[]; /** An array of fields thats indicates the stack membership */ stackAccessors?: Accessor[]; - /** An optional array of field name thats indicates the stack membership */ - colorAccessors?: Accessor[]; + /** An optional functional accessor to return custom datum color or style */ + styleAccessor?: StyleAccessor; } export interface SeriesScales { diff --git a/packages/osd-charts/stories/bar_chart.tsx b/packages/osd-charts/stories/bar_chart.tsx index 658ecc0f0ec4..26d5a76a5a97 100644 --- a/packages/osd-charts/stories/bar_chart.tsx +++ b/packages/osd-charts/stories/bar_chart.tsx @@ -2,6 +2,7 @@ import { boolean, color, number, select } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import { DateTime } from 'luxon'; import React from 'react'; + import { AnnotationDomainTypes, AreaSeries, @@ -27,12 +28,15 @@ import { Settings, timeFormatter, TooltipType, -} from '../src/'; + RecursivePartial, +} from '../src'; import * as TestDatasets from '../src/utils/data_samples/test_dataset'; import { KIBANA_METRICS } from '../src/utils/data_samples/test_dataset_kibana'; import { TEST_DATASET_DISCOVER } from '../src/utils/data_samples/test_dataset_discover_per_30s'; +import { StyleAccessor } from '../src/chart_types/xy_chart/utils/specs'; +import { BarSeriesStyle } from '../src/utils/themes/theme'; const dateFormatter = timeFormatter('HH:mm:ss'); @@ -138,7 +142,7 @@ storiesOf('Bar Chart', module) id={getAxisId('left2')} title={'Left axis'} position={Position.Left} - tickFormat={(d) => Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> (stackedAsPercentage && !clusterBars ? `${Number(d * 100).toFixed(0)} %` : d)} + tickFormat={(d: any) => (stackedAsPercentage && !clusterBars ? `${Number(d * 100).toFixed(0)} %` : d)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> { - const data = KIBANA_METRICS.metrics.kibana_os_load[0].data.map((d) => { + const data = KIBANA_METRICS.metrics.kibana_os_load[0].data.map((d: any) => { return { x: d[0], max: d[1] + 4 + 4 * Math.random(), min: d[1] - 4 - 4 * Math.random(), }; }); - const lineData = KIBANA_METRICS.metrics.kibana_os_load[0].data.map((d) => { + const lineData = KIBANA_METRICS.metrics.kibana_os_load[0].data.map((d: any) => { return [d[0], d[1]]; }); const scaleToDataExtent = boolean('scale to extent', true); @@ -1199,7 +1203,7 @@ storiesOf('Bar Chart', module) id={getAxisId('left')} title={KIBANA_METRICS.metrics.kibana_os_load[0].metric.title} position={Position.Left} - tickFormat={(d) => Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} domain={{ min: 0, max: 15 }} /> Number(d).toFixed(2)} + tickFormat={(d: any) => Number(d).toFixed(2)} hide={true} domain={{ min: 0, max: 15 }} /> @@ -1787,4 +1791,37 @@ storiesOf('Bar Chart', module) /> ); + }) + .add('styleAccessor overrides', () => { + const hasThreshold = boolean('threshold', true); + const threshold = number('min threshold', 4); + const style: RecursivePartial = { + rect: { + opacity: 0.5, + fill: 'red', + }, + }; + const styleAccessor: StyleAccessor = (d, g) => (g.specId === getSpecId('bars') && d.y1! > threshold ? style : null); + + return ( + + + Number(d).toFixed(2)} + /> + + + + ); }); diff --git a/packages/osd-charts/wiki/overview.md b/packages/osd-charts/wiki/overview.md index 1a0fe3106cde..e8fe1d88828b 100644 --- a/packages/osd-charts/wiki/overview.md +++ b/packages/osd-charts/wiki/overview.md @@ -214,14 +214,15 @@ Small multiples are created using the `` component, that takes m In the case of small multiples, each `SplittedSeries` computes its own x and y domains. Then the x domains are merged and expanded. The same happens with the main Y domains; they are merged together. -### Colors +### Color/Style overrides -Each data series can have its own color. -The color is assigned through the `colorAccessors` prop that specifies which data attributes are used to define the color, -for example: +Each `datum` of a **Bar Chart** data series can be assigned a custom color or style with the `styleAccessor` prop. -- a dataset without any split accessor or fields that can be used to identify a color will have a single color. -- a dataset that has 1 variable to split the dataset into 3 different series, will have 3 different colors if that variable is specified through the `colorAccessors`. -- a dataset with 2 split variables, each with 3 different values (a dataset with 3 \* 2 series) will have 6 different colors if the two variables are defined in `colorAccessors` -- a dataset with 2 split variables, each with 3 different values, but only one specified in the `colorAccessors` will have only 3 colors. -- if no `colorAccessors` is specified, `splitAccessors` will be used to identify how to coloring the series +The `styleAccessor` prop expects a callback function which will be called on _every_ `datum` in _every_ bar series with the signiture below. This callback should return a color `string` or a partial `BarSeriesStyle`, which will override any series-level styles for the respective `datum`. You are passed `geometryId` to identify the series the `datum` belongs to and the raw `datum` to derive conditions against. + +```ts +type StyleAccessor = ( + datum: RawDataSeriesDatum, + geometryId: GeometryId, +) => RecursivePartial | Color | null; +```