Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const Line: FC<LineProps> = ({
dimension = DEFAULT_TIME_DIMENSION,
metric = DEFAULT_METRIC,
metricAxis,
dualMetricAxis,
color = { value: 'categorical-100' },
scaleType = 'time',
lineType = { value: 'solid' },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import React, { ReactElement } from 'react';

import { StoryFn } from '@storybook/react';

import { Content } from '@adobe/react-spectrum';

import { Chart } from '../../../Chart';
import { Axis, ChartPopover, ChartTooltip, Legend, Line } from '../../../components';
import useChartProps from '../../../hooks/useChartProps';
import { bindWithProps } from '../../../test-utils';
import { LineProps } from '../../../types';

export default {
title: 'RSC/Line/Dual Metric Axis',
component: Line,
};

// Sample data with two series: Downloads and Conversion Rate
// Downloads will be on the left axis (primary), Conversion Rate on the right axis (secondary)
const lineDualAxisData = [
{ datetime: 1667890800000, value: 4500, series: 'Downloads', order: 0 },
{ datetime: 1667977200000, value: 5200, series: 'Downloads', order: 0 },
{ datetime: 1668063600000, value: 4800, series: 'Downloads', order: 0 },
{ datetime: 1668150000000, value: 6100, series: 'Downloads', order: 0 },
{ datetime: 1668236400000, value: 5800, series: 'Downloads', order: 0 },
{ datetime: 1668322800000, value: 6500, series: 'Downloads', order: 0 },
{ datetime: 1668409200000, value: 7200, series: 'Downloads', order: 0 },
{ datetime: 1667890800000, value: 2.3, series: 'Conversion Rate (%)', order: 1 },
{ datetime: 1667977200000, value: 2.8, series: 'Conversion Rate (%)', order: 1 },
{ datetime: 1668063600000, value: 2.5, series: 'Conversion Rate (%)', order: 1 },
{ datetime: 1668150000000, value: 3.2, series: 'Conversion Rate (%)', order: 1 },
{ datetime: 1668236400000, value: 3.0, series: 'Conversion Rate (%)', order: 1 },
{ datetime: 1668322800000, value: 3.5, series: 'Conversion Rate (%)', order: 1 },
{ datetime: 1668409200000, value: 3.8, series: 'Conversion Rate (%)', order: 1 },
];

// Sample data with three series
const lineThreeSeriesData = [
{ datetime: 1667890800000, value: 4500, series: 'Downloads', order: 0 },
{ datetime: 1667977200000, value: 5200, series: 'Downloads', order: 0 },
{ datetime: 1668063600000, value: 4800, series: 'Downloads', order: 0 },
{ datetime: 1668150000000, value: 6100, series: 'Downloads', order: 0 },
{ datetime: 1668236400000, value: 5800, series: 'Downloads', order: 0 },
{ datetime: 1668322800000, value: 6500, series: 'Downloads', order: 0 },
{ datetime: 1668409200000, value: 7200, series: 'Downloads', order: 0 },
{ datetime: 1667890800000, value: 3200, series: 'Installs', order: 1 },
{ datetime: 1667977200000, value: 3800, series: 'Installs', order: 1 },
{ datetime: 1668063600000, value: 3500, series: 'Installs', order: 1 },
{ datetime: 1668150000000, value: 4400, series: 'Installs', order: 1 },
{ datetime: 1668236400000, value: 4100, series: 'Installs', order: 1 },
{ datetime: 1668322800000, value: 4700, series: 'Installs', order: 1 },
{ datetime: 1668409200000, value: 5200, series: 'Installs', order: 1 },
{ datetime: 1667890800000, value: 2.3, series: 'Conversion Rate (%)', order: 2 },
{ datetime: 1667977200000, value: 2.8, series: 'Conversion Rate (%)', order: 2 },
{ datetime: 1668063600000, value: 2.5, series: 'Conversion Rate (%)', order: 2 },
{ datetime: 1668150000000, value: 3.2, series: 'Conversion Rate (%)', order: 2 },
{ datetime: 1668236400000, value: 3.0, series: 'Conversion Rate (%)', order: 2 },
{ datetime: 1668322800000, value: 3.5, series: 'Conversion Rate (%)', order: 2 },
{ datetime: 1668409200000, value: 3.8, series: 'Conversion Rate (%)', order: 2 },
];

const dialogContent = (datum) => (
<Content>
<div>Date: {new Date(datum.datetime).toLocaleDateString()}</div>
<div>Series: {datum.series}</div>
<div>Value: {datum.value}</div>
</Content>
);

const BasicStory: StoryFn<typeof Line> = (args): ReactElement => {
const chartProps = useChartProps({ data: lineDualAxisData, width: 800, height: 600 });
return (
<Chart {...chartProps}>
<Axis position="bottom" labelFormat="time" baseline ticks title="Date" />
<Axis position="left" grid ticks title="Downloads" />
<Axis position="right" ticks title="Conversion Rate (%)" />
<Line {...args}>
<ChartTooltip>{dialogContent}</ChartTooltip>
<ChartPopover width={200}>{dialogContent}</ChartPopover>
</Line>
<Legend title="Metrics" highlight />
</Chart>
);
};

const WithThreeSeriesStory: StoryFn<typeof Line> = (args): ReactElement => {
const chartProps = useChartProps({ data: lineThreeSeriesData, width: 800, height: 600 });
return (
<Chart {...chartProps}>
<Axis position="bottom" labelFormat="time" baseline ticks title="Date" />
<Axis position="left" grid ticks title="Count" />
<Axis position="right" ticks title="Conversion Rate (%)" />
<Line {...args}>
<ChartTooltip>{dialogContent}</ChartTooltip>
<ChartPopover width={200}>{dialogContent}</ChartPopover>
</Line>
<Legend title="Metrics" highlight />
</Chart>
);
};

const defaultProps: LineProps = {
dualMetricAxis: true,
dimension: 'datetime',
metric: 'value',
scaleType: 'time',
onClick: undefined,
};

const Basic = bindWithProps(BasicStory);
Basic.args = {
...defaultProps,
color: 'series',
};

const WithThreeSeries = bindWithProps(WithThreeSeriesStory);
WithThreeSeries.args = {
...defaultProps,
color: 'series',
};

const ItemTooltip = bindWithProps(BasicStory);
ItemTooltip.args = {
...defaultProps,
color: 'series',
interactionMode: 'item',
};

export { Basic, WithThreeSeries, ItemTooltip };

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { findChart, render, screen } from '../../../test-utils';
import '../../../test-utils/__mocks__/matchMedia.mock.js';
import { Basic, WithThreeSeries } from './DualMetricAxis.story';

describe('Dual metric axis line axis styling', () => {
describe('Two series', () => {
test('axis title should have fill color based on series', async () => {
render(<Basic {...Basic.args} />);

const chart = await findChart();
expect(chart).toBeInTheDocument();

// set timeout to allow chart to render
await new Promise((resolve) => setTimeout(resolve, 1000));

// Get all occurrences and select the axis title (first occurrence is the axis, second is legend)
const downloadsElements = screen.getAllByText('Downloads');
const conversionRateElements = screen.getAllByText('Conversion Rate (%)');

// first axis uses first series color (Downloads) - axis titles are bold
expect(downloadsElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(15, 181, 174)');
// second axis uses second series color (Conversion Rate)
expect(conversionRateElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(64, 70, 202)');
});

test('axis labels should have fill color based on series', async () => {
render(<Basic {...Basic.args} />);

const chart = await findChart();
expect(chart).toBeInTheDocument();

// Wait for chart to render
await new Promise((resolve) => setTimeout(resolve, 1000));

const zeroLabels = screen.getAllByText('0');

// first axis (left) uses first series color
expect(zeroLabels[0]).toHaveAttribute('fill', 'rgb(15, 181, 174)');
// second axis (right) uses second series color
expect(zeroLabels[1]).toHaveAttribute('fill', 'rgb(64, 70, 202)');
});
});

describe('Three series', () => {
test('axis title should have fill color based on series', async () => {
render(<WithThreeSeries {...WithThreeSeries.args} />);

const chart = await findChart();
expect(chart).toBeInTheDocument();

// Wait for chart to render
await new Promise((resolve) => setTimeout(resolve, 1000));

// Get all occurrences and select the axis title (first occurrence is the axis, second is legend)
const countElements = screen.getAllByText('Count');
const conversionRateElements = screen.getAllByText('Conversion Rate (%)');

// first axis has more than one series. Use default color.
expect(countElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(34, 34, 34)');
// second axis uses third series color.
expect(conversionRateElements.find(el => el.getAttribute('font-weight') === 'bold')).toHaveAttribute('fill', 'rgb(246, 133, 17)');
});

test('axis labels should have fill color based on series', async () => {
render(<WithThreeSeries {...WithThreeSeries.args} />);

const chart = await findChart();
expect(chart).toBeInTheDocument();

// Wait for chart to render
await new Promise((resolve) => setTimeout(resolve, 1000));

const zeroLabels = screen.getAllByText('0');

// first axis has more than one series. Use default color.
expect(zeroLabels[0]).toHaveAttribute('fill', 'rgb(34, 34, 34)');
// second axis uses third series color.
expect(zeroLabels[1]).toHaveAttribute('fill', 'rgb(246, 133, 17)');
});
});
});

2 changes: 1 addition & 1 deletion packages/vega-spec-builder/src/line/lineMarkUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('getLineMark()', () => {
strokeDash: { value: [] },
strokeOpacity: DEFAULT_OPACITY_RULE,
strokeWidth: { value: 1 },
y: { field: 'value', scale: 'yLinear' },
y: [{ field: 'value', scale: 'yLinear' }],
},
update: {
x: { field: DEFAULT_TRANSFORMED_TIME_DIMENSION, scale: 'xTime' },
Expand Down
75 changes: 66 additions & 9 deletions packages/vega-spec-builder/src/line/lineMarkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DEFAULT_OPACITY_RULE,
FADE_FACTOR,
HOVERED_ITEM,
LAST_RSC_SERIES_ID,
SELECTED_SERIES,
SERIES_ID,
} from '@spectrum-charts/constants';
Expand All @@ -28,13 +29,12 @@ import {
getItemHoverArea,
getLineWidthProductionRule,
getOpacityProductionRule,
getPointsForVoronoi,
getStrokeDashProductionRule,
getVoronoiPath,
getXProductionRule,
getYProductionRule,
hasPopover,
} from '../marks/markUtils';
import { getDualAxisScaleNames } from '../scale/scaleUtils';
import { ScaleType } from '../types';
import {
getHighlightBackgroundPoint,
Expand All @@ -43,7 +43,36 @@ import {
getSelectRingPoint,
getSelectionPoint,
} from './linePointUtils';
import { LineMarkOptions } from './lineUtils';
import { isDualMetricAxis, LineMarkOptions } from './lineUtils';

/**
* Gets the Y encoding for line marks with dual metric axis support
* @param lineMarkOptions - Line mark options including metricAxis and dualMetricAxis
* @param metric - The metric field name
* @returns Y encoding with conditional scale selection for dual metric axis
*/
export const getLineYEncoding = (lineMarkOptions: LineMarkOptions, metric: string): ProductionRule<NumericValueRef> => {
const { metricAxis } = lineMarkOptions;

if (isDualMetricAxis(lineMarkOptions)) {
const baseScaleName = metricAxis || 'yLinear';
const scaleNames = getDualAxisScaleNames(baseScaleName);

return [
{
test: `datum.${SERIES_ID} === ${LAST_RSC_SERIES_ID}`,
scale: scaleNames.secondaryScale,
field: metric,
},
{
scale: scaleNames.primaryScale,
field: metric,
},
];
}

return [{ scale: metricAxis || 'yLinear', field: metric }];
};

/**
* generates a line mark
Expand All @@ -60,7 +89,6 @@ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string
lineType,
lineWidth,
metric,
metricAxis,
name,
opacity,
scaleType,
Expand All @@ -78,7 +106,7 @@ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string
interactive: false,
encode: {
enter: {
y: getYProductionRule(metricAxis, metric),
y: getLineYEncoding(lineMarkOptions, metric),
stroke: getColorProductionRule(color, colorScheme),
strokeDash: getStrokeDashProductionRule(lineType),
strokeOpacity: getOpacityProductionRule(opacity),
Expand Down Expand Up @@ -213,21 +241,50 @@ const getInteractiveMarks = (dataSource: string, lineOptions: LineMarkOptions):
};

const getVoronoiMarks = (lineOptions: LineMarkOptions, dataSource: string): Mark[] => {
const { dimension, metric, metricAxis, name, scaleType } = lineOptions;
const { name } = lineOptions;

return [
// points used for the voronoi transform
getPointsForVoronoi(dataSource, dimension, metric, name, scaleType, metricAxis),
getLinePointsForVoronoi(lineOptions, dataSource),
// voronoi transform used to get nearest point paths
getVoronoiPath(lineOptions, `${name}_pointsForVoronoi`),
];
};

/**
* Gets the points used for the voronoi calculation for line charts with dual metric axis support
* @param lineOptions - Line options including dual metric axis settings
* @param dataSource - the name of the data source that will be used in the voronoi calculation
* @returns SymbolMark
*/
const getLinePointsForVoronoi = (lineOptions: LineMarkOptions, dataSource: string): Mark => {
const { dimension, metric, name, scaleType } = lineOptions;

return {
name: `${name}_pointsForVoronoi`,
description: `${name}_pointsForVoronoi`,
type: 'symbol',
from: { data: dataSource },
interactive: false,
encode: {
enter: {
y: getLineYEncoding(lineOptions, metric),
fill: { value: 'transparent' },
stroke: { value: 'transparent' },
},
update: {
x: getXProductionRule(scaleType, dimension),
},
},
};
};

const getItemHoverMarks = (lineOptions: LineMarkOptions, dataSource: string): Mark[] => {
const { chartTooltips = [], dimension, metric, metricAxis, name, scaleType } = lineOptions;
const { chartTooltips = [], dimension, metric, name, scaleType } = lineOptions;

return [
// area around item that triggers hover
getItemHoverArea(chartTooltips, dataSource, dimension, metric, name, scaleType, metricAxis),
getItemHoverArea(chartTooltips, dataSource, dimension, metric, name, scaleType, getLineYEncoding(lineOptions, metric)),
];
};

Loading