Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/core/series/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export interface SeriesPlugin<T extends ChartSeries = ChartSeries> {
* Should throw a `ChartError` on invalid input. Omit for types that need no validation.
*/
validate?(args: ValidateSeriesArgs<T>): 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be beneficial to group these methods into logical blocks, similar to how it's done with the tooltip. Since the list of functions is growing, adding a bit of structure would prevent the flat list from becoming difficult to read.

/** Computes shape data (geometry, labels, markers) from prepared series. Called once per render cycle. */
prepareShapeData(
args: PrepareShapeDataArgs,
Expand Down
129 changes: 129 additions & 0 deletions src/core/utils/__tests__/color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Populate the series registry so getSeriesPlugin works (see plugin registration gotcha).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to write this for all tests? Can you put it in a general setup?

import '../../../plugins';
import type {ChartData} from '../../../types';
import {getDomainForContinuousColorScale} from '../color';

type Series = ChartData['series']['data'];

describe('utils/color/getDomainForContinuousColorScale', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to avoid tying test names to file paths. When directories change, keeping test names in sync can be easily overlooked, which might lead to outdated or confusing test names in the future.

// 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`,
);
},
);
});
43 changes: 10 additions & 33 deletions src/core/utils/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>((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)];
}
Expand Down
1 change: 1 addition & 0 deletions src/plugins/area/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const areaPlugin: SeriesPlugin<AreaSeries> = {
});
}
},
getColorValue: (d) => Number(d.y),
prepareShapeData: async function (args: PrepareShapeDataArgs): Promise<PrepareShapeDataResult> {
const {
series,
Expand Down
1 change: 1 addition & 0 deletions src/plugins/bar-x/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const barXPlugin: SeriesPlugin<BarXSeries> = {
validateXYSeries({series, xAxis, yAxis});
validateStacking({series});
},
getColorValue: (d) => Number(d.y),
prepareShapeData,
renderShapes,
tooltip: {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/bar-y/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const barYPlugin: SeriesPlugin<BarYSeries> = {
validateXYSeries({series, xAxis, yAxis});
validateStacking({series});
},
getColorValue: (d) => Number(d.x),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If colorValue is strictly meant to be a number, it might be better practice to handle the type casting outside the plugin closure rather than inside it.

prepareShapeData,
renderShapes,
tooltip: {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/funnel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const funnelPlugin: SeriesPlugin<FunnelSeries> = {
useClipPath: false,
prepareSeries: ({series, seriesOptions, legend, colors}) =>
prepareFunnelSeries({series: series as FunnelSeries[], seriesOptions, legend, colors}),
getColorValue: (d) => Number(d.value),
prepareShapeData,
renderShapes,
tooltip: {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/heatmap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const heatmapPlugin: SeriesPlugin<HeatmapSeries> = {
legend,
colorScale,
}),
getColorValue: (d) => Number(d.value),
prepareShapeData,
renderShapes,
tooltip: {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/line/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const linePlugin: SeriesPlugin<LineSeries> = {
validateAxisPlotValues({series, xAxis, yAxis});
validateXYSeries({series, xAxis, yAxis});
},
getColorValue: (d) => Number(d.y),
prepareShapeData,
renderShapes,
tooltip: {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/pie/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const piePlugin: SeriesPlugin<PieSeries> = {
}
});
},
getColorValue: (d) => Number(d.value),
prepareShapeData,
renderShapes,
tooltip: {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/scatter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const scatterPlugin: SeriesPlugin<ScatterSeries> = {
validateAxisPlotValues({series, xAxis, yAxis});
validateXYSeries({series, xAxis, yAxis});
},
getColorValue: (d) => Number(d.y),
prepareShapeData,
renderShapes,
tooltip: {
Expand Down
1 change: 1 addition & 0 deletions src/plugins/waterfall/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ async function prepareShapeData(args: PrepareShapeDataArgs): Promise<PrepareShap
export const waterfallPlugin: SeriesPlugin<WaterfallSeries> = {
type: 'waterfall',
prepareSeries: prepareWaterfallSeries,
getColorValue: (d) => Number(d.y),
prepareShapeData,
renderShapes: function ({plot, preparedData, seriesOptions, dispatcher}: RenderShapesArgs) {
const data = preparedData as PreparedWaterfallData[];
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/x-range/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ function renderShapes({plot, preparedData, seriesOptions, dispatcher}: RenderSha
export const xRangePlugin: SeriesPlugin<XRangeSeries> = {
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: {
Expand Down
Loading