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 .changeset/s2-line-dash-chart-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@spectrum-charts/constants': patch
'@spectrum-charts/vega-spec-builder-s2': patch
---

S2 Line: named line types now use Spectrum 2 dash and gap sizes for each chart size, compensated for round line caps. Custom dash arrays are treated as visible lengths at medium chart size and scale with chart size. Legend, bar, and trendline dash patterns use the medium-size values.
54 changes: 36 additions & 18 deletions packages/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ export const NAVIGATION_ID_SEPARATOR = '__rsc__';
// scale names
export const COLOR_SCALE = 'color';
export const LINE_TYPE_SCALE = 'lineType';
/** Ordinal scale of line type dash expressions scaled to the chart-size stroke width for round or square caps. */
export const LINE_TYPE_CHART_SIZE_SCALE = 'lineTypeChartSize';
export const LINEAR_COLOR_SCALE = 'linearColor';
export const LINE_WIDTH_SCALE = 'lineWidth';
export const OPACITY_SCALE = 'opacity';
Expand Down Expand Up @@ -385,18 +387,34 @@ const REFERENCE_LINE_CAP_RULE_GAP = 1;

// Rule x-start for explicit size — uses REFERENCE_LINE_SIZE_STROKE_WIDTHS per tier.
export const REFERENCE_LINE_RULE_X_START: Record<ReferenceLineSize, number> = {
XS: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.XS + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.XS / 2, // 9.91667
S: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.S + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.S / 2, // 12.25
M: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.M + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.M / 2, // 12.25
L: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.L + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.L / 2, // 14.08333
XS:
REFERENCE_LINE_START_CAP_ANCHOR +
REFERENCE_LINE_CAP_RIGHT_TIP.XS +
REFERENCE_LINE_CAP_RULE_GAP +
REFERENCE_LINE_SIZE_STROKE_WIDTHS.XS / 2, // 9.91667
S:
REFERENCE_LINE_START_CAP_ANCHOR +
REFERENCE_LINE_CAP_RIGHT_TIP.S +
REFERENCE_LINE_CAP_RULE_GAP +
REFERENCE_LINE_SIZE_STROKE_WIDTHS.S / 2, // 12.25
M:
REFERENCE_LINE_START_CAP_ANCHOR +
REFERENCE_LINE_CAP_RIGHT_TIP.M +
REFERENCE_LINE_CAP_RULE_GAP +
REFERENCE_LINE_SIZE_STROKE_WIDTHS.M / 2, // 12.25
L:
REFERENCE_LINE_START_CAP_ANCHOR +
REFERENCE_LINE_CAP_RIGHT_TIP.L +
REFERENCE_LINE_CAP_RULE_GAP +
REFERENCE_LINE_SIZE_STROKE_WIDTHS.L / 2, // 14.08333
};

// Rule x-start for auto mode — uses CHART_SIZE_STROKE_WIDTH signal values (S=1.5px, M=2px, L=3px)
// so the 1px gap is correct when stroke width reacts to chart width.
export const REFERENCE_LINE_AUTO_RULE_X_START = {
S: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.S + REFERENCE_LINE_CAP_RULE_GAP + 1.5 / 2, // 12.25
M: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.M + REFERENCE_LINE_CAP_RULE_GAP + 1, // strokeWidth(2)/2 = 1, total 12.5
L: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.L + REFERENCE_LINE_CAP_RULE_GAP + 3 / 2, // 14.33333
M: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.M + REFERENCE_LINE_CAP_RULE_GAP + 1, // strokeWidth(2)/2 = 1, total 12.5
L: REFERENCE_LINE_START_CAP_ANCHOR + REFERENCE_LINE_CAP_RIGHT_TIP.L + REFERENCE_LINE_CAP_RULE_GAP + 3 / 2, // 14.33333
};

// Right face x in path space for each end cap. S and M share paths.
Expand All @@ -416,25 +434,25 @@ const REFERENCE_LINE_END_CAP_ANCHOR = REFERENCE_LINE_END_CAP_RIGHT_FACE_X.S - RE

export const REFERENCE_LINE_END_CAP_ANCHOR_OFFSET: Record<ReferenceLineSize, number> = {
XS: REFERENCE_LINE_END_CAP_RIGHT_FACE_X.XS - REFERENCE_LINE_END_CAP_CLIP, // 2.94961
S: REFERENCE_LINE_END_CAP_ANCHOR, // 5.00039
M: REFERENCE_LINE_END_CAP_ANCHOR, // 5.00039
L: REFERENCE_LINE_END_CAP_ANCHOR, // 5.00039
S: REFERENCE_LINE_END_CAP_ANCHOR, // 5.00039
M: REFERENCE_LINE_END_CAP_ANCHOR, // 5.00039
L: REFERENCE_LINE_END_CAP_ANCHOR, // 5.00039
};

// Rule x2 offset for explicit size = end cap anchor + gap + strokeWidth/2.
// S and M produce the same x2; L produces a shorter line (larger SW reduces x2 further left).
export const REFERENCE_LINE_RULE_X2_OFFSET: Record<ReferenceLineSize, number> = {
XS: REFERENCE_LINE_END_CAP_ANCHOR_OFFSET.XS + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.XS / 2, // 4.44961
S: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.S / 2, // 6.75039
M: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.M / 2, // 6.75039
L: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.L / 2, // 7.25039
S: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.S / 2, // 6.75039
M: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.M / 2, // 6.75039
L: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + REFERENCE_LINE_SIZE_STROKE_WIDTHS.L / 2, // 7.25039
};

// Rule x2 offset for auto mode — uses CHART_SIZE_STROKE_WIDTH signal values (1.5/2/3px).
export const REFERENCE_LINE_AUTO_RULE_X2_OFFSET = {
S: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + 0.75, // 6.75039
M: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + 1, // 7.00039
L: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + 1.5, // 7.50039
M: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + 1, // 7.00039
L: REFERENCE_LINE_END_CAP_ANCHOR + REFERENCE_LINE_CAP_RULE_GAP + 1.5, // 7.50039
};

// Caret SVG paths per size tier, y-shifted to center on reference line.
Expand Down Expand Up @@ -489,10 +507,10 @@ export const DIMENSION_HOVER_AREA = 'dimensionHoverArea';
// - Bar: _dimensionHoverArea excluded — pushed BEFORE bar rect marks, so inserting before
// it would place the reference line behind all bars.
// - Scatter: hover marks are nested inside the group mark, not top-level, so no suffix needed.
export const HOVER_RULE = '_hoverRule'; // line hover rule mark
export const SELECT_BORDER = '_selectBorder'; // area selection border mark (with popover)
export const AREA_HOVER_RULE = '_rule'; // area hover rule mark (dimension interaction)
export const AREA_HOVER_POINT = '_point'; // area hover point mark
export const HOVER_RULE = '_hoverRule'; // line hover rule mark
export const SELECT_BORDER = '_selectBorder'; // area selection border mark (with popover)
export const AREA_HOVER_RULE = '_rule'; // area hover rule mark (dimension interaction)
export const AREA_HOVER_POINT = '_point'; // area hover point mark

//SVG Paths
export const ROUNDED_SQUARE_PATH =
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/docs/spectrum2/line.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ The S2 `Line` component does not yet support `onMouseOver`, `onMouseOut`, `Metri
<td>lineType</td>
<td>string | &#123;value: LineType | number[]&#125;</td>
<td>&#123;value: 'solid'&#125;</td>
<td>Key in the data for line type faceting, or a fixed line type value.</td>
<td>Key in the data for line type faceting, or a fixed line type value. Named line types use Spectrum 2 dash and gap sizes for each chart size. Custom <code>number[]</code> patterns are visible dash (including caps) and gap lengths at medium chart size, and scale with chart size.</td>
</tr>
<tr>
<td>metric</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('Time comparison stories', () => {
expect(bars).toHaveLength(112);

testBarOpacity(bars[0], '0.5');
testBarStroke(bars[0], '3,4', '1.5');
testBarStroke(bars[0], '4,2', '1.5');
});

test('current series should have typical style', async () => {
Expand Down Expand Up @@ -101,11 +101,11 @@ describe('Time comparison stories', () => {
expect(bars).toHaveLength(24);

testBarOpacity(bars[0], '0.5');
testBarStroke(bars[0], '3,4', '1.5');
testBarStroke(bars[0], '4,2', '1.5');

// dropoff bars
testBarOpacity(bars[2], '0.5');
testBarStroke(bars[2], '3,4', '1.5');
testBarStroke(bars[2], '4,2', '1.5');
});

test('current series should have typical style', async () => {
Expand Down Expand Up @@ -140,7 +140,7 @@ describe('Time comparison stories', () => {
expect(legendSymbols[1]).toHaveAttribute('fill-opacity', '1');

// stroke-dasharray
expect(legendSymbols[0]).toHaveAttribute('stroke-dasharray', '3,4');
expect(legendSymbols[0]).toHaveAttribute('stroke-dasharray', '4,2');
expect(legendSymbols[1]).toHaveAttribute('stroke-dasharray', '');

// fill
Expand Down Expand Up @@ -175,13 +175,13 @@ describe('Time comparison stories', () => {
// previous period April
for (let i = 0; i <= 3; i++) {
testBarOpacity(bars[i], '0.5');
testBarStroke(bars[i], '3,4', '1.5');
testBarStroke(bars[i], '4,2', '1.5');
}

// previous period May
for (let i = 8; i <= 11; i++) {
testBarOpacity(bars[i], '0.5');
testBarStroke(bars[i], '3,4', '1.5');
testBarStroke(bars[i], '4,2', '1.5');
}
});

Expand Down Expand Up @@ -218,7 +218,7 @@ describe('Time comparison stories', () => {
expect(bars).toHaveLength(112);

testBarOpacity(bars[0], '0.5');
testBarStroke(bars[0], '3,4', '1.5');
testBarStroke(bars[0], '4,2', '1.5');
});

test('current series should have typical style', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright 2026 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 { ReactElement, useState } from 'react';

import { CHART_SIZE_BREAKPOINTS } from '@spectrum-charts/constants';
import { LineType } from '@spectrum-charts/vega-spec-builder-s2';

import { Chart } from '../../../Chart';
import { Axis, Legend, Line } from '../../../components';

export default {
title: 'React Spectrum Charts 2/Line/Features',
component: Line,
};

const LINE_TYPES: LineType[] = ['dashed', 'dotted', 'dotDash', 'shortDash', 'longDash', 'twoDash'];
const START_DATE = Date.UTC(2023, 10, 8);
const DAY_MS = 24 * 60 * 60 * 1000;
const data = LINE_TYPES.flatMap((series, seriesIndex) =>
Array.from({ length: 7 }, (_, day) => ({
datetime: START_DATE + day * DAY_MS,
series,
value: (LINE_TYPES.length - seriesIndex) * 10 + Math.sin(day + seriesIndex) * 3,
}))
);

const CHART_HEIGHT = 400;
const MAX_WIDTH = CHART_SIZE_BREAKPOINTS.L + 200;
const THUMB_HEIGHT = 32;
const THRESHOLDS = [
{ px: CHART_SIZE_BREAKPOINTS.M, label: 'M' },
{ px: CHART_SIZE_BREAKPOINTS.L, label: 'L' },
];

const HANDLE_STYLES = `
.rsc-line-type-size-handle {
-webkit-appearance: none;
appearance: none;
background: transparent;
border: none;
outline: none;
position: absolute;
top: 0;
left: 0;
height: ${CHART_HEIGHT}px;
pointer-events: none;
z-index: 20;
}
.rsc-line-type-size-handle::-webkit-slider-runnable-track {
background: transparent;
height: ${CHART_HEIGHT}px;
}
.rsc-line-type-size-handle::-webkit-slider-thumb {
-webkit-appearance: none;
width: 8px;
height: ${THUMB_HEIGHT}px;
border-radius: 4px;
background: #999;
cursor: ew-resize;
pointer-events: all;
margin-top: ${(CHART_HEIGHT - THUMB_HEIGHT) / 2}px;
}
.rsc-line-type-size-handle::-moz-range-track {
background: transparent;
}
.rsc-line-type-size-handle::-moz-range-thumb {
width: 8px;
height: ${THUMB_HEIGHT}px;
border-radius: 4px;
background: #999;
border: none;
cursor: ew-resize;
}
`;

const getSizeTier = (width: number): string => {
if (width < CHART_SIZE_BREAKPOINTS.M) return 'S';
if (width < CHART_SIZE_BREAKPOINTS.L) return 'M';
return 'L';
};

// Drag the handle across the S/M/L breakpoints to compare dash spacing at each chart size.
const LineTypeChartSizeStory = (): ReactElement => {
const [width, setWidth] = useState(600);

return (
<div style={{ padding: '16px 0' }}>
<style>{HANDLE_STYLES}</style>
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
Width: <strong>{width}px</strong> — Size tier: <strong>{getSizeTier(width)}</strong>
</div>
<div style={{ position: 'relative', minWidth: MAX_WIDTH }}>
{THRESHOLDS.map(({ px, label }) => (
<div
key={label}
style={{
position: 'absolute',
left: px,
top: 0,
bottom: 0,
width: 1,
background: 'rgba(220, 60, 60, 0.6)',
zIndex: 10,
pointerEvents: 'none',
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: 3,
fontSize: 10,
color: 'rgba(220, 60, 60, 0.9)',
whiteSpace: 'nowrap',
lineHeight: 1,
}}
>
{label} ({px}px)
</span>
</div>
))}

<div style={{ position: 'relative', display: 'inline-block' }}>
<Chart data={data} width={width} height={CHART_HEIGHT} lineTypes={LINE_TYPES}>
<Axis position="bottom" baseline ticks labelFormat="time" />
<Axis position="left" grid />
<Line dimension="datetime" metric="value" color="series" lineType="series" scaleType="time" />
<Legend />
</Chart>

<input
type="range"
className="rsc-line-type-size-handle"
aria-label="Chart width"
min={0}
max={MAX_WIDTH}
value={width}
onChange={(e) => setWidth(Math.max(100, Number(e.target.value)))}
style={{ width: MAX_WIDTH }}
/>
</div>
</div>
</div>
);
};

export const LineTypeChartSize = LineTypeChartSizeStory;
20 changes: 18 additions & 2 deletions packages/react-spectrum-charts-s2/src/stories/Line/Line.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import { FADE_FACTOR } from '@spectrum-charts/constants';

import { Chart } from '../../Chart';
import { Line, LinePointAnnotation } from '../../components';
import { workspaceTrendsData } from '../../stories/data/data';
import {
Expand Down Expand Up @@ -100,9 +101,24 @@ describe('Line', () => {
const lines = await findAllMarksByGroupName(chart, 'line0');
expect(lines.length).toEqual(4);
expect(lines[0].getAttribute('stroke-dasharray')).toEqual('');
expect(lines[1].getAttribute('stroke-dasharray')).toEqual('7,4');
expect(lines[1].getAttribute('stroke-dasharray')).toEqual('4.5,4');
expect(lines[2].getAttribute('stroke-dasharray')).toEqual('0,4');
expect(lines[3].getAttribute('stroke-dasharray')).toEqual('2,3,7,4');
expect(lines[3].getAttribute('stroke-dasharray')).toEqual('0,4,4.5,4');
});

test.each([
[300, '1.5', ['', '4.5,3.5', '0,2.5', '0,3.5,4.5,3.5']],
[900, '2.5', ['', '6,5', '0,5.5', '0,5,6,5']],
])('LineType dashes scale with chart size stroke width at width %d', async (width, strokeWidth, dashes) => {
render(
<Chart data={workspaceTrendsData} width={width} height={300}>
<Line color="series" lineType="series" dimension="datetime" metric="value" scaleType="time" />
</Chart>
);
const chart = await findChart();
const lines = await findAllMarksByGroupName(chart, 'line0');
expect(lines.map((line) => line.getAttribute('stroke-width'))).toEqual(Array(4).fill(strokeWidth));
expect(lines.map((line) => line.getAttribute('stroke-dasharray'))).toEqual(dashes);
});

test('Opacity renders', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ test('LineType renders correctly', async () => {
expect(chart).toBeInTheDocument();

const symbols = getAllLegendSymbols(chart);
expect(symbols[0].getAttribute('stroke-dasharray')).toEqual('7,4');
expect(symbols[0].getAttribute('stroke-dasharray')).toEqual('6.5,2');
});

test('LineWidth renders correctly', async () => {
Expand Down Expand Up @@ -87,8 +87,8 @@ test('Supreme renders correctly', async () => {

const symbols = getAllLegendSymbols(chart);
expect(symbols[0].getAttribute('stroke-dasharray')).toEqual('');
expect(symbols[1].getAttribute('stroke-dasharray')).toEqual('7,4');
expect(symbols[2].getAttribute('stroke-dasharray')).toEqual('0,4');
expect(symbols[1].getAttribute('stroke-dasharray')).toEqual('6.5,2');
expect(symbols[2].getAttribute('stroke-dasharray')).toEqual('2,2');

expect(symbols[0].getAttribute('stroke-width')).toEqual('1.5');

Expand Down
Loading
Loading