Skip to content

Commit 2b1d5d7

Browse files
authored
Merge pull request #654 from shalehaha/linedualaxis
add dual axis support for line chart
2 parents 31548c7 + 79b807d commit 2b1d5d7

File tree

13 files changed

+413
-57
lines changed

13 files changed

+413
-57
lines changed

packages/react-spectrum-charts/src/components/Line/Line.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const Line: FC<LineProps> = ({
2222
dimension = DEFAULT_TIME_DIMENSION,
2323
metric = DEFAULT_METRIC,
2424
metricAxis,
25+
dualMetricAxis,
2526
color = { value: 'categorical-100' },
2627
scaleType = 'time',
2728
lineType = { value: 'solid' },
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import React, { ReactElement } from 'react';
13+
14+
import { StoryFn } from '@storybook/react';
15+
16+
import { Content } from '@adobe/react-spectrum';
17+
18+
import { Chart } from '../../../Chart';
19+
import { Axis, ChartPopover, ChartTooltip, Legend, Line } from '../../../components';
20+
import useChartProps from '../../../hooks/useChartProps';
21+
import { bindWithProps } from '../../../test-utils';
22+
import { LineProps } from '../../../types';
23+
24+
export default {
25+
title: 'RSC/Line/Dual Metric Axis',
26+
component: Line,
27+
};
28+
29+
// Sample data with two series: Downloads and Conversion Rate
30+
// Downloads will be on the left axis (primary), Conversion Rate on the right axis (secondary)
31+
const lineDualAxisData = [
32+
{ datetime: 1667890800000, value: 4500, series: 'Downloads', order: 0 },
33+
{ datetime: 1667977200000, value: 5200, series: 'Downloads', order: 0 },
34+
{ datetime: 1668063600000, value: 4800, series: 'Downloads', order: 0 },
35+
{ datetime: 1668150000000, value: 6100, series: 'Downloads', order: 0 },
36+
{ datetime: 1668236400000, value: 5800, series: 'Downloads', order: 0 },
37+
{ datetime: 1668322800000, value: 6500, series: 'Downloads', order: 0 },
38+
{ datetime: 1668409200000, value: 7200, series: 'Downloads', order: 0 },
39+
{ datetime: 1667890800000, value: 2.3, series: 'Conversion Rate (%)', order: 1 },
40+
{ datetime: 1667977200000, value: 2.8, series: 'Conversion Rate (%)', order: 1 },
41+
{ datetime: 1668063600000, value: 2.5, series: 'Conversion Rate (%)', order: 1 },
42+
{ datetime: 1668150000000, value: 3.2, series: 'Conversion Rate (%)', order: 1 },
43+
{ datetime: 1668236400000, value: 3.0, series: 'Conversion Rate (%)', order: 1 },
44+
{ datetime: 1668322800000, value: 3.5, series: 'Conversion Rate (%)', order: 1 },
45+
{ datetime: 1668409200000, value: 3.8, series: 'Conversion Rate (%)', order: 1 },
46+
];
47+
48+
// Sample data with three series
49+
const lineThreeSeriesData = [
50+
{ datetime: 1667890800000, value: 4500, series: 'Downloads', order: 0 },
51+
{ datetime: 1667977200000, value: 5200, series: 'Downloads', order: 0 },
52+
{ datetime: 1668063600000, value: 4800, series: 'Downloads', order: 0 },
53+
{ datetime: 1668150000000, value: 6100, series: 'Downloads', order: 0 },
54+
{ datetime: 1668236400000, value: 5800, series: 'Downloads', order: 0 },
55+
{ datetime: 1668322800000, value: 6500, series: 'Downloads', order: 0 },
56+
{ datetime: 1668409200000, value: 7200, series: 'Downloads', order: 0 },
57+
{ datetime: 1667890800000, value: 3200, series: 'Installs', order: 1 },
58+
{ datetime: 1667977200000, value: 3800, series: 'Installs', order: 1 },
59+
{ datetime: 1668063600000, value: 3500, series: 'Installs', order: 1 },
60+
{ datetime: 1668150000000, value: 4400, series: 'Installs', order: 1 },
61+
{ datetime: 1668236400000, value: 4100, series: 'Installs', order: 1 },
62+
{ datetime: 1668322800000, value: 4700, series: 'Installs', order: 1 },
63+
{ datetime: 1668409200000, value: 5200, series: 'Installs', order: 1 },
64+
{ datetime: 1667890800000, value: 2.3, series: 'Conversion Rate (%)', order: 2 },
65+
{ datetime: 1667977200000, value: 2.8, series: 'Conversion Rate (%)', order: 2 },
66+
{ datetime: 1668063600000, value: 2.5, series: 'Conversion Rate (%)', order: 2 },
67+
{ datetime: 1668150000000, value: 3.2, series: 'Conversion Rate (%)', order: 2 },
68+
{ datetime: 1668236400000, value: 3.0, series: 'Conversion Rate (%)', order: 2 },
69+
{ datetime: 1668322800000, value: 3.5, series: 'Conversion Rate (%)', order: 2 },
70+
{ datetime: 1668409200000, value: 3.8, series: 'Conversion Rate (%)', order: 2 },
71+
];
72+
73+
const dialogContent = (datum) => (
74+
<Content>
75+
<div>Date: {new Date(datum.datetime).toLocaleDateString()}</div>
76+
<div>Series: {datum.series}</div>
77+
<div>Value: {datum.value}</div>
78+
</Content>
79+
);
80+
81+
const BasicStory: StoryFn<typeof Line> = (args): ReactElement => {
82+
const chartProps = useChartProps({ data: lineDualAxisData, width: 800, height: 600 });
83+
return (
84+
<Chart {...chartProps}>
85+
<Axis position="bottom" labelFormat="time" baseline ticks title="Date" />
86+
<Axis position="left" grid ticks title="Downloads" />
87+
<Axis position="right" ticks title="Conversion Rate (%)" />
88+
<Line {...args}>
89+
<ChartTooltip>{dialogContent}</ChartTooltip>
90+
<ChartPopover width={200}>{dialogContent}</ChartPopover>
91+
</Line>
92+
<Legend title="Metrics" highlight />
93+
</Chart>
94+
);
95+
};
96+
97+
const WithThreeSeriesStory: StoryFn<typeof Line> = (args): ReactElement => {
98+
const chartProps = useChartProps({ data: lineThreeSeriesData, width: 800, height: 600 });
99+
return (
100+
<Chart {...chartProps}>
101+
<Axis position="bottom" labelFormat="time" baseline ticks title="Date" />
102+
<Axis position="left" grid ticks title="Count" />
103+
<Axis position="right" ticks title="Conversion Rate (%)" />
104+
<Line {...args}>
105+
<ChartTooltip>{dialogContent}</ChartTooltip>
106+
<ChartPopover width={200}>{dialogContent}</ChartPopover>
107+
</Line>
108+
<Legend title="Metrics" highlight />
109+
</Chart>
110+
);
111+
};
112+
113+
const defaultProps: LineProps = {
114+
dualMetricAxis: true,
115+
dimension: 'datetime',
116+
metric: 'value',
117+
scaleType: 'time',
118+
onClick: undefined,
119+
};
120+
121+
const Basic = bindWithProps(BasicStory);
122+
Basic.args = {
123+
...defaultProps,
124+
color: 'series',
125+
};
126+
127+
const WithThreeSeries = bindWithProps(WithThreeSeriesStory);
128+
WithThreeSeries.args = {
129+
...defaultProps,
130+
color: 'series',
131+
};
132+
133+
const ItemTooltip = bindWithProps(BasicStory);
134+
ItemTooltip.args = {
135+
...defaultProps,
136+
color: 'series',
137+
interactionMode: 'item',
138+
};
139+
140+
export { Basic, WithThreeSeries, ItemTooltip };
141+
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { findChart, render, screen } from '../../../test-utils';
13+
import '../../../test-utils/__mocks__/matchMedia.mock.js';
14+
import { Basic, WithThreeSeries } from './DualMetricAxis.story';
15+
16+
describe('Dual metric axis line axis styling', () => {
17+
describe('Two series', () => {
18+
test('axis title should have fill color based on series', async () => {
19+
render(<Basic {...Basic.args} />);
20+
21+
const chart = await findChart();
22+
expect(chart).toBeInTheDocument();
23+
24+
// set timeout to allow chart to render
25+
await new Promise((resolve) => setTimeout(resolve, 1000));
26+
27+
// Get all occurrences and select the axis title (first occurrence is the axis, second is legend)
28+
const downloadsElements = screen.getAllByText('Downloads');
29+
const conversionRateElements = screen.getAllByText('Conversion Rate (%)');
30+
31+
// first axis uses first series color (Downloads) - axis titles are bold
32+
expect(downloadsElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(15, 181, 174)');
33+
// second axis uses second series color (Conversion Rate)
34+
expect(conversionRateElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(64, 70, 202)');
35+
});
36+
37+
test('axis labels should have fill color based on series', async () => {
38+
render(<Basic {...Basic.args} />);
39+
40+
const chart = await findChart();
41+
expect(chart).toBeInTheDocument();
42+
43+
// Wait for chart to render
44+
await new Promise((resolve) => setTimeout(resolve, 1000));
45+
46+
const zeroLabels = screen.getAllByText('0');
47+
48+
// first axis (left) uses first series color
49+
expect(zeroLabels[0]).toHaveAttribute('fill', 'rgb(15, 181, 174)');
50+
// second axis (right) uses second series color
51+
expect(zeroLabels[1]).toHaveAttribute('fill', 'rgb(64, 70, 202)');
52+
});
53+
});
54+
55+
describe('Three series', () => {
56+
test('axis title should have fill color based on series', async () => {
57+
render(<WithThreeSeries {...WithThreeSeries.args} />);
58+
59+
const chart = await findChart();
60+
expect(chart).toBeInTheDocument();
61+
62+
// Wait for chart to render
63+
await new Promise((resolve) => setTimeout(resolve, 1000));
64+
65+
// Get all occurrences and select the axis title (first occurrence is the axis, second is legend)
66+
const countElements = screen.getAllByText('Count');
67+
const conversionRateElements = screen.getAllByText('Conversion Rate (%)');
68+
69+
// first axis has more than one series. Use default color.
70+
expect(countElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(34, 34, 34)');
71+
// second axis uses third series color.
72+
expect(conversionRateElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(246, 133, 17)');
73+
});
74+
75+
test('axis labels should have fill color based on series', async () => {
76+
render(<WithThreeSeries {...WithThreeSeries.args} />);
77+
78+
const chart = await findChart();
79+
expect(chart).toBeInTheDocument();
80+
81+
// Wait for chart to render
82+
await new Promise((resolve) => setTimeout(resolve, 1000));
83+
84+
const zeroLabels = screen.getAllByText('0');
85+
86+
// first axis has more than one series. Use default color.
87+
expect(zeroLabels[0]).toHaveAttribute('fill', 'rgb(34, 34, 34)');
88+
// second axis uses third series color.
89+
expect(zeroLabels[1]).toHaveAttribute('fill', 'rgb(246, 133, 17)');
90+
});
91+
});
92+
});
93+

packages/vega-spec-builder/src/line/lineMarkUtils.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('getLineMark()', () => {
3939
strokeDash: { value: [] },
4040
strokeOpacity: DEFAULT_OPACITY_RULE,
4141
strokeWidth: { value: 1 },
42-
y: { field: 'value', scale: 'yLinear' },
42+
y: [{ field: 'value', scale: 'yLinear' }],
4343
},
4444
update: {
4545
x: { field: DEFAULT_TRANSFORMED_TIME_DIMENSION, scale: 'xTime' },

packages/vega-spec-builder/src/line/lineMarkUtils.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
DEFAULT_OPACITY_RULE,
1919
FADE_FACTOR,
2020
HOVERED_ITEM,
21+
LAST_RSC_SERIES_ID,
2122
SELECTED_SERIES,
2223
SERIES_ID,
2324
} from '@spectrum-charts/constants';
@@ -28,13 +29,12 @@ import {
2829
getItemHoverArea,
2930
getLineWidthProductionRule,
3031
getOpacityProductionRule,
31-
getPointsForVoronoi,
3232
getStrokeDashProductionRule,
3333
getVoronoiPath,
3434
getXProductionRule,
35-
getYProductionRule,
3635
hasPopover,
3736
} from '../marks/markUtils';
37+
import { getDualAxisScaleNames } from '../scale/scaleUtils';
3838
import { ScaleType } from '../types';
3939
import {
4040
getHighlightBackgroundPoint,
@@ -43,7 +43,36 @@ import {
4343
getSelectRingPoint,
4444
getSelectionPoint,
4545
} from './linePointUtils';
46-
import { LineMarkOptions } from './lineUtils';
46+
import { isDualMetricAxis, LineMarkOptions } from './lineUtils';
47+
48+
/**
49+
* Gets the Y encoding for line marks with dual metric axis support
50+
* @param lineMarkOptions - Line mark options including metricAxis and dualMetricAxis
51+
* @param metric - The metric field name
52+
* @returns Y encoding with conditional scale selection for dual metric axis
53+
*/
54+
export const getLineYEncoding = (lineMarkOptions: LineMarkOptions, metric: string): ProductionRule<NumericValueRef> => {
55+
const { metricAxis } = lineMarkOptions;
56+
57+
if (isDualMetricAxis(lineMarkOptions)) {
58+
const baseScaleName = metricAxis || 'yLinear';
59+
const scaleNames = getDualAxisScaleNames(baseScaleName);
60+
61+
return [
62+
{
63+
test: `datum.${SERIES_ID} === ${LAST_RSC_SERIES_ID}`,
64+
scale: scaleNames.secondaryScale,
65+
field: metric,
66+
},
67+
{
68+
scale: scaleNames.primaryScale,
69+
field: metric,
70+
},
71+
];
72+
}
73+
74+
return [{ scale: metricAxis || 'yLinear', field: metric }];
75+
};
4776

4877
/**
4978
* generates a line mark
@@ -60,7 +89,6 @@ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string
6089
lineType,
6190
lineWidth,
6291
metric,
63-
metricAxis,
6492
name,
6593
opacity,
6694
scaleType,
@@ -78,7 +106,7 @@ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string
78106
interactive: false,
79107
encode: {
80108
enter: {
81-
y: getYProductionRule(metricAxis, metric),
109+
y: getLineYEncoding(lineMarkOptions, metric),
82110
stroke: getColorProductionRule(color, colorScheme),
83111
strokeDash: getStrokeDashProductionRule(lineType),
84112
strokeOpacity: getOpacityProductionRule(opacity),
@@ -213,21 +241,50 @@ const getInteractiveMarks = (dataSource: string, lineOptions: LineMarkOptions):
213241
};
214242

215243
const getVoronoiMarks = (lineOptions: LineMarkOptions, dataSource: string): Mark[] => {
216-
const { dimension, metric, metricAxis, name, scaleType } = lineOptions;
244+
const { name } = lineOptions;
217245

218246
return [
219247
// points used for the voronoi transform
220-
getPointsForVoronoi(dataSource, dimension, metric, name, scaleType, metricAxis),
248+
getLinePointsForVoronoi(lineOptions, dataSource),
221249
// voronoi transform used to get nearest point paths
222250
getVoronoiPath(lineOptions, `${name}_pointsForVoronoi`),
223251
];
224252
};
225253

254+
/**
255+
* Gets the points used for the voronoi calculation for line charts with dual metric axis support
256+
* @param lineOptions - Line options including dual metric axis settings
257+
* @param dataSource - the name of the data source that will be used in the voronoi calculation
258+
* @returns SymbolMark
259+
*/
260+
const getLinePointsForVoronoi = (lineOptions: LineMarkOptions, dataSource: string): Mark => {
261+
const { dimension, metric, name, scaleType } = lineOptions;
262+
263+
return {
264+
name: `${name}_pointsForVoronoi`,
265+
description: `${name}_pointsForVoronoi`,
266+
type: 'symbol',
267+
from: { data: dataSource },
268+
interactive: false,
269+
encode: {
270+
enter: {
271+
y: getLineYEncoding(lineOptions, metric),
272+
fill: { value: 'transparent' },
273+
stroke: { value: 'transparent' },
274+
},
275+
update: {
276+
x: getXProductionRule(scaleType, dimension),
277+
},
278+
},
279+
};
280+
};
281+
226282
const getItemHoverMarks = (lineOptions: LineMarkOptions, dataSource: string): Mark[] => {
227-
const { chartTooltips = [], dimension, metric, metricAxis, name, scaleType } = lineOptions;
283+
const { chartTooltips = [], dimension, metric, name, scaleType } = lineOptions;
228284

229285
return [
230286
// area around item that triggers hover
231-
getItemHoverArea(chartTooltips, dataSource, dimension, metric, name, scaleType, metricAxis),
287+
getItemHoverArea(chartTooltips, dataSource, dimension, metric, name, scaleType, getLineYEncoding(lineOptions, metric)),
232288
];
233289
};
290+

0 commit comments

Comments
 (0)