diff --git a/src/core/series/plugin.ts b/src/core/series/plugin.ts index 790d9485b..db5793912 100644 --- a/src/core/series/plugin.ts +++ b/src/core/series/plugin.ts @@ -81,6 +81,12 @@ export interface SeriesPlugin { * Should throw a `ChartError` on invalid input. Omit for types that need no validation. */ validate?(args: ValidateSeriesArgs): void; + /** + * Returns the numeric value of a data point used to build the domain of a continuous color scale. + * Called by `getDomainForContinuousColorScale` over the raw series data (before shape data is prepared). + * Omit for types that do not support a continuous color scale (e.g. treemap, sankey, radar). + */ + getColorValue?(data: T['data'][number]): number; /** Computes shape data (geometry, labels, markers) from prepared series. Called once per render cycle. */ prepareShapeData( args: PrepareShapeDataArgs, diff --git a/src/core/utils/__tests__/color.test.ts b/src/core/utils/__tests__/color.test.ts new file mode 100644 index 000000000..3cf56405c --- /dev/null +++ b/src/core/utils/__tests__/color.test.ts @@ -0,0 +1,129 @@ +// Populate the series registry so getSeriesPlugin works (see plugin registration gotcha). +import '../../../plugins'; +import type {ChartData} from '../../../types'; +import {getDomainForContinuousColorScale} from '../color'; + +type Series = ChartData['series']['data']; + +describe('utils/color/getDomainForContinuousColorScale', () => { + // One case per supported type guards each plugin's field mapping individually, + // so a regression in a single type (e.g. a swapped field) is caught. + test.each<{type: string; data: unknown[]; expected: number[]}>([ + { + type: 'line', + data: [ + {x: 0, y: 1}, + {x: 1, y: 5}, + ], + expected: [1, 5], + }, + { + type: 'area', + data: [ + {x: 0, y: 1}, + {x: 1, y: 5}, + ], + expected: [1, 5], + }, + { + type: 'bar-x', + data: [ + {x: 0, y: 1}, + {x: 1, y: 5}, + ], + expected: [1, 5], + }, + { + type: 'waterfall', + data: [ + {x: 0, y: 1}, + {x: 1, y: 5}, + ], + expected: [1, 5], + }, + { + type: 'scatter', + data: [ + {x: 0, y: 1}, + {x: 1, y: 5}, + ], + expected: [1, 5], + }, + { + type: 'bar-y', + data: [ + {x: 2, y: 'a'}, + {x: 8, y: 'b'}, + ], + expected: [2, 8], + }, + { + type: 'pie', + data: [ + {name: 'a', value: 10}, + {name: 'b', value: 40}, + ], + expected: [10, 40], + }, + { + type: 'heatmap', + data: [ + {x: 0, y: 0, value: 10}, + {x: 1, y: 1, value: 40}, + ], + expected: [10, 40], + }, + { + type: 'funnel', + data: [ + {name: 'a', value: 10}, + {name: 'b', value: 40}, + ], + expected: [10, 40], + }, + { + type: 'x-range', + data: [ + {x0: 0, x1: 5, y: 'a'}, + {x0: 10, x1: 12, y: 'b'}, + ], + expected: [2, 5], + }, + ])('computes [min, max] for "$type"', ({type, data, expected}) => { + const series = [{type, data}] as Series; + + expect(getDomainForContinuousColorScale({series})).toEqual(expected); + }); + + test('flattens values across multiple series', () => { + const series = [ + { + type: 'scatter', + data: [ + {x: 0, y: 1}, + {x: 1, y: 5}, + ], + }, + { + type: 'line', + data: [ + {x: 0, y: 0}, + {x: 1, y: 9}, + ], + }, + ] as Series; + + expect(getDomainForContinuousColorScale({series})).toEqual([0, 9]); + }); + + test.each(['treemap', 'sankey', 'radar'])( + 'throws for the "%s" series which has no continuous color scale', + (type) => { + const series = [{type, data: [{name: 'a', value: 1}]}] as Series; + + expect(() => getDomainForContinuousColorScale({series})).toThrow( + `The method for calculation a domain for a continuous color scale for the "${type}" series is not defined`, + ); + }, + ); +}); diff --git a/src/core/utils/color.ts b/src/core/utils/color.ts index e9ef8579d..92d727463 100644 --- a/src/core/utils/color.ts +++ b/src/core/utils/color.ts @@ -2,46 +2,23 @@ import {range} from 'd3-array'; import {scaleLinear} from 'd3-scale'; import type {ChartData} from '../../types'; +import {getSeriesPlugin} from '../series/seriesRegistry'; export function getDomainForContinuousColorScale(args: { series: ChartData['series']['data']; }): number[] { const {series} = args; - const values = series.reduce((acc, s) => { - switch (s.type) { - case 'pie': - case 'heatmap': - case 'funnel': { - acc.push(...s.data.map((d) => Number(d.value))); - break; - } - case 'bar-y': { - acc.push(...s.data.map((d) => Number(d.x))); - break; - } - case 'scatter': - case 'bar-x': - case 'waterfall': - case 'line': - case 'area': { - acc.push(...s.data.map((d) => Number(d.y))); - break; - } - case 'x-range': { - // Use bar duration (x1 - x0) as the color domain value so that - // longer bars can be visually distinguished by color intensity. - acc.push(...s.data.map((d) => Math.abs(Number(d.x1) - Number(d.x0)))); - break; - } - default: { - throw Error( - `The method for calculation a domain for a continuous color scale for the "${s.type}" series is not defined`, - ); - } + const values = series.flatMap((s) => { + const {getColorValue} = getSeriesPlugin(s.type); + + if (!getColorValue) { + throw Error( + `The method for calculation a domain for a continuous color scale for the "${s.type}" series is not defined`, + ); } - return acc; - }, []); + return s.data.map((d) => getColorValue(d)); + }); return [Math.min(...values), Math.max(...values)]; } diff --git a/src/plugins/area/index.ts b/src/plugins/area/index.ts index 8af114de1..7dde247a7 100644 --- a/src/plugins/area/index.ts +++ b/src/plugins/area/index.ts @@ -35,6 +35,7 @@ export const areaPlugin: SeriesPlugin = { }); } }, + getColorValue: (d) => Number(d.y), prepareShapeData: async function (args: PrepareShapeDataArgs): Promise { const { series, diff --git a/src/plugins/bar-x/index.ts b/src/plugins/bar-x/index.ts index 8c9acf709..3bac50c7e 100644 --- a/src/plugins/bar-x/index.ts +++ b/src/plugins/bar-x/index.ts @@ -78,6 +78,7 @@ export const barXPlugin: SeriesPlugin = { validateXYSeries({series, xAxis, yAxis}); validateStacking({series}); }, + getColorValue: (d) => Number(d.y), prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/bar-y/index.ts b/src/plugins/bar-y/index.ts index 8cb2a794c..a154adec1 100644 --- a/src/plugins/bar-y/index.ts +++ b/src/plugins/bar-y/index.ts @@ -49,6 +49,7 @@ export const barYPlugin: SeriesPlugin = { validateXYSeries({series, xAxis, yAxis}); validateStacking({series}); }, + getColorValue: (d) => Number(d.x), prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/funnel/index.ts b/src/plugins/funnel/index.ts index 67f411b1d..b2b31a72e 100644 --- a/src/plugins/funnel/index.ts +++ b/src/plugins/funnel/index.ts @@ -37,6 +37,7 @@ export const funnelPlugin: SeriesPlugin = { useClipPath: false, prepareSeries: ({series, seriesOptions, legend, colors}) => prepareFunnelSeries({series: series as FunnelSeries[], seriesOptions, legend, colors}), + getColorValue: (d) => Number(d.value), prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/heatmap/index.ts b/src/plugins/heatmap/index.ts index 57b70ebda..0ebd4831c 100644 --- a/src/plugins/heatmap/index.ts +++ b/src/plugins/heatmap/index.ts @@ -46,6 +46,7 @@ export const heatmapPlugin: SeriesPlugin = { legend, colorScale, }), + getColorValue: (d) => Number(d.value), prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/line/index.ts b/src/plugins/line/index.ts index f90ff6a4b..5d889f8cd 100644 --- a/src/plugins/line/index.ts +++ b/src/plugins/line/index.ts @@ -62,6 +62,7 @@ export const linePlugin: SeriesPlugin = { validateAxisPlotValues({series, xAxis, yAxis}); validateXYSeries({series, xAxis, yAxis}); }, + getColorValue: (d) => Number(d.y), prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/pie/index.ts b/src/plugins/pie/index.ts index 907e67f7d..755f707d3 100644 --- a/src/plugins/pie/index.ts +++ b/src/plugins/pie/index.ts @@ -49,6 +49,7 @@ export const piePlugin: SeriesPlugin = { } }); }, + getColorValue: (d) => Number(d.value), prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/scatter/index.ts b/src/plugins/scatter/index.ts index dd98a46ff..388f098ce 100644 --- a/src/plugins/scatter/index.ts +++ b/src/plugins/scatter/index.ts @@ -53,6 +53,7 @@ export const scatterPlugin: SeriesPlugin = { validateAxisPlotValues({series, xAxis, yAxis}); validateXYSeries({series, xAxis, yAxis}); }, + getColorValue: (d) => Number(d.y), prepareShapeData, renderShapes, tooltip: { diff --git a/src/plugins/waterfall/index.ts b/src/plugins/waterfall/index.ts index f906edc1e..7c6470bac 100644 --- a/src/plugins/waterfall/index.ts +++ b/src/plugins/waterfall/index.ts @@ -50,6 +50,7 @@ async function prepareShapeData(args: PrepareShapeDataArgs): Promise = { type: 'waterfall', prepareSeries: prepareWaterfallSeries, + getColorValue: (d) => Number(d.y), prepareShapeData, renderShapes: function ({plot, preparedData, seriesOptions, dispatcher}: RenderShapesArgs) { const data = preparedData as PreparedWaterfallData[]; diff --git a/src/plugins/x-range/index.ts b/src/plugins/x-range/index.ts index fd10ceff0..71ff15ad9 100644 --- a/src/plugins/x-range/index.ts +++ b/src/plugins/x-range/index.ts @@ -43,6 +43,9 @@ function renderShapes({plot, preparedData, seriesOptions, dispatcher}: RenderSha export const xRangePlugin: SeriesPlugin = { type: 'x-range', prepareSeries: prepareXRangeSeries, + // Use bar duration (x1 - x0) as the color value so that longer bars can be + // visually distinguished by color intensity. + getColorValue: (d) => Math.abs(Number(d.x1) - Number(d.x0)), prepareShapeData, renderShapes, tooltip: {