From 489f55663c9190b36bffb8957647eb3158c16bc4 Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:48:44 -0600 Subject: [PATCH] fix(s2): use Spectrum 2 chart-size dash patterns for line types Named line types use per-chart-size dash and gap values compensated for round line caps. Custom dash arrays are treated as visible lengths at medium chart size and scale with chart size. Adds a resizable LineType chart size story. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/s2-line-dash-chart-size.md | 6 + packages/constants/constants.ts | 54 ++++-- packages/docs/docs/spectrum2/line.md | 2 +- .../src/stories/ChartExamples.test.tsx | 14 +- .../Line/Features/LineTypeChartSize.story.tsx | 156 ++++++++++++++++++ .../src/stories/Line/Line.test.tsx | 20 ++- .../components/Legend/LegendSymbol.test.tsx | 6 +- .../src/chartSpecBuilder.test.ts | 10 +- .../src/legend/legendSpecBuilder.test.ts | 2 +- .../src/line/lineMarkUtils.test.ts | 105 ++++++++---- .../src/line/lineMarkUtils.ts | 55 +++--- .../src/line/lineSpecBuilder.test.ts | 45 +++-- .../src/line/lineSpecBuilder.ts | 44 ++++- .../src/marks/markUtils.test.ts | 2 +- .../src/metricRange/metricRangeUtils.test.ts | 7 +- .../src/specUtils.test.ts | 54 ++++-- .../vega-spec-builder-s2/src/specUtils.ts | 81 ++++++--- 17 files changed, 520 insertions(+), 143 deletions(-) create mode 100644 .changeset/s2-line-dash-chart-size.md create mode 100644 packages/react-spectrum-charts-s2/src/stories/Line/Features/LineTypeChartSize.story.tsx diff --git a/.changeset/s2-line-dash-chart-size.md b/.changeset/s2-line-dash-chart-size.md new file mode 100644 index 0000000000..4a5a4a8aa5 --- /dev/null +++ b/.changeset/s2-line-dash-chart-size.md @@ -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. diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts index b074447e5f..c76c9b3bd8 100644 --- a/packages/constants/constants.ts +++ b/packages/constants/constants.ts @@ -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'; @@ -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 = { - 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. @@ -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 = { 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 = { 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. @@ -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 = diff --git a/packages/docs/docs/spectrum2/line.md b/packages/docs/docs/spectrum2/line.md index b41e26fc1b..b392096cd9 100644 --- a/packages/docs/docs/spectrum2/line.md +++ b/packages/docs/docs/spectrum2/line.md @@ -417,7 +417,7 @@ The S2 `Line` component does not yet support `onMouseOver`, `onMouseOut`, `Metri lineType string | {value: LineType | number[]} {value: 'solid'} - Key in the data for line type faceting, or a fixed line type value. + 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 number[] patterns are visible dash (including caps) and gap lengths at medium chart size, and scale with chart size. metric diff --git a/packages/react-spectrum-charts-s2/src/stories/ChartExamples.test.tsx b/packages/react-spectrum-charts-s2/src/stories/ChartExamples.test.tsx index 241ac60c0a..297d33222d 100644 --- a/packages/react-spectrum-charts-s2/src/stories/ChartExamples.test.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/ChartExamples.test.tsx @@ -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 () => { @@ -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 () => { @@ -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 @@ -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'); } }); @@ -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 () => { diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Features/LineTypeChartSize.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Features/LineTypeChartSize.story.tsx new file mode 100644 index 0000000000..6ae9fb0f41 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/Line/Features/LineTypeChartSize.story.tsx @@ -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 ( +
+ +
+ Width: {width}px — Size tier: {getSizeTier(width)} +
+
+ {THRESHOLDS.map(({ px, label }) => ( +
+ + {label} ({px}px) + +
+ ))} + +
+ + + + + + + + setWidth(Math.max(100, Number(e.target.value)))} + style={{ width: MAX_WIDTH }} + /> +
+
+
+ ); +}; + +export const LineTypeChartSize = LineTypeChartSizeStory; diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Line.test.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Line.test.tsx index 04cc25c62a..297b7557b9 100644 --- a/packages/react-spectrum-charts-s2/src/stories/Line/Line.test.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/Line/Line.test.tsx @@ -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 { @@ -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( + + + + ); + 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 () => { diff --git a/packages/react-spectrum-charts-s2/src/stories/components/Legend/LegendSymbol.test.tsx b/packages/react-spectrum-charts-s2/src/stories/components/Legend/LegendSymbol.test.tsx index 950a01532c..0cd1f6e6bf 100644 --- a/packages/react-spectrum-charts-s2/src/stories/components/Legend/LegendSymbol.test.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/components/Legend/LegendSymbol.test.tsx @@ -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 () => { @@ -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'); diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts index f064394558..f275aa49d9 100644 --- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts @@ -214,12 +214,12 @@ describe('Chart spec builder', () => { describe('getTwoDimensionalLineTypes()', () => { test('should get 2d line types from line type array', () => { - expect(getTwoDimensionalLineTypes(['solid', 'dashed'])).toStrictEqual([[[]], [[7, 4]]]); - expect(getTwoDimensionalLineTypes([[1, 2, 3, 4], 'dashed'])).toStrictEqual([[[1, 2, 3, 4]], [[7, 4]]]); + expect(getTwoDimensionalLineTypes(['solid', 'dashed'])).toStrictEqual([[[]], [[6.5, 2]]]); + expect(getTwoDimensionalLineTypes([[1, 2, 3, 4], 'dashed'])).toStrictEqual([[[1, 2, 3, 4]], [[6.5, 2]]]); }); test('should convert line type names', () => { - expect(getTwoDimensionalLineTypes([['solid', 'dashed'], ['dotted']])).toStrictEqual([[[], [7, 4]], [[0, 4]]]); + expect(getTwoDimensionalLineTypes([['solid', 'dashed'], ['dotted']])).toStrictEqual([[[], [6.5, 2]], [[2, 2]]]); }); }); @@ -242,7 +242,7 @@ describe('Chart spec builder', () => { expect(getLineTypeScale(['solid', 'dashed'])).toStrictEqual({ name: LINE_TYPE_SCALE, type: 'ordinal', - range: [[], [7, 4]], + range: [[], [6.5, 2]], domain: { data: 'table', fields: [] }, }); }); @@ -471,7 +471,7 @@ describe('Chart spec builder', () => { ['rgb(188, 233, 49)'], ], }, - { name: 'lineTypes', value: [[[7, 4]]] }, + { name: 'lineTypes', value: [[[6.5, 2]]] }, { name: 'opacities', value: [[1]] }, ]; diff --git a/packages/vega-spec-builder-s2/src/legend/legendSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/legend/legendSpecBuilder.test.ts index 6a60cac924..19396810a9 100644 --- a/packages/vega-spec-builder-s2/src/legend/legendSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/legend/legendSpecBuilder.test.ts @@ -463,7 +463,7 @@ describe('formatFacetRefsWithPresets()', () => { DEFAULT_COLOR_SCHEME ); expect(formattedColor).toStrictEqual({ value: '#FF9D91' }); - expect(formattedLineType).toStrictEqual({ value: [2, 3, 7, 4] }); + expect(formattedLineType).toStrictEqual({ value: [2, 2, 6.5, 2] }); expect(formattedLineWidth).toStrictEqual({ value: 4 }); expect(formattedSymbolShape).toStrictEqual({ value: 'wedge' }); }); diff --git a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.test.ts b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.test.ts index 2fce071d5d..18fb774449 100644 --- a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.test.ts @@ -20,6 +20,7 @@ import { DEFAULT_TRANSFORMED_TIME_DIMENSION, FADE_FACTOR, HOVERED_ITEM, + LINE_TYPE_CHART_SIZE_SCALE, LINE_TYPE_SCALE, OPACITY_SCALE, SELECTED_SERIES, @@ -28,6 +29,7 @@ import { import { getLineDrawInXEncoding, getLineDrawInYEncoding } from '../marks/drawInAnimationUtils'; import { getDeemphasisRamp, getHoverFractionSignal } from '../marks/hoverAnimationUtils'; +import { getChartSizeDashExpr, getStrokeDashFromLineType } from '../specUtils'; import { getAlternateSegmentStrokeDash, getHighlightedSeriesOpacityRules, @@ -35,7 +37,9 @@ import { getLineHighlightOverlayGroup, getLineHoverMarks, getLineMark, - getLineOpacity, getLineStrokeWidth, + getLineOpacity, + getLineStrokeDash, + getLineStrokeWidth, } from './lineMarkUtils'; import { defaultLineMarkOptions } from './lineTestUtils'; @@ -52,7 +56,6 @@ describe('getLineMark()', () => { enter: { stroke: { field: 'series', scale: COLOR_SCALE }, strokeCap: { value: 'round' }, - strokeDash: { value: [] }, strokeOpacity: DEFAULT_OPACITY_RULE, y: [{ field: 'value', scale: 'yLinear' }], }, @@ -60,6 +63,7 @@ describe('getLineMark()', () => { x: { field: DEFAULT_TRANSFORMED_TIME_DIMENSION, scale: 'xTime' }, opacity: [DEFAULT_OPACITY_RULE], strokeWidth: [DEFAULT_STROKE_WIDTH_RULE], + strokeDash: { value: [] }, }, }, }); @@ -138,8 +142,12 @@ describe('getLineMark()', () => { test('omits the enter y encoding and uses the draw-in x/y encodings in update when true', () => { const lineMark = getLineMark({ ...defaultLineMarkOptions, isDrawInAnimate: true }, 'line0_facet'); expect(lineMark.encode?.enter).not.toHaveProperty('y'); - expect(lineMark.encode?.update?.x).toStrictEqual(getLineDrawInXEncoding({ ...defaultLineMarkOptions, isDrawInAnimate: true })); - expect(lineMark.encode?.update?.y).toStrictEqual(getLineDrawInYEncoding({ ...defaultLineMarkOptions, isDrawInAnimate: true })); + expect(lineMark.encode?.update?.x).toStrictEqual( + getLineDrawInXEncoding({ ...defaultLineMarkOptions, isDrawInAnimate: true }) + ); + expect(lineMark.encode?.update?.y).toStrictEqual( + getLineDrawInYEncoding({ ...defaultLineMarkOptions, isDrawInAnimate: true }) + ); }); test('keeps the static enter y encoding and the scale-based update x, with no update y, when false', () => { @@ -437,19 +445,13 @@ describe('getLineGradientMark()', () => { }); test('should scale fillOpacity with static opacity value', () => { - const gradientMark = getLineGradientMark( - { ...defaultLineMarkOptions, opacity: { value: 0.6 } }, - 'line0_facet' - ); + const gradientMark = getLineGradientMark({ ...defaultLineMarkOptions, opacity: { value: 0.6 } }, 'line0_facet'); const fillOpacity = gradientMark.encode?.enter?.fillOpacity; expect(fillOpacity).toEqual({ value: 0.6 * 0.2 }); }); test('should scale fillOpacity with dynamic opacity facet', () => { - const gradientMark = getLineGradientMark( - { ...defaultLineMarkOptions, opacity: 'weight' }, - 'line0_facet' - ); + const gradientMark = getLineGradientMark({ ...defaultLineMarkOptions, opacity: 'weight' }, 'line0_facet'); const fillOpacity = gradientMark.encode?.enter?.fillOpacity; expect(fillOpacity).toEqual({ signal: `scale('${OPACITY_SCALE}', datum.weight) * 0.2` }); }); @@ -491,10 +493,7 @@ describe('getLineGradientMark()', () => { }); test('should support dual metric axis y encoding', () => { - const gradientMark = getLineGradientMark( - { ...defaultLineMarkOptions, dualMetricAxis: true }, - 'line0_facet' - ); + const gradientMark = getLineGradientMark({ ...defaultLineMarkOptions, dualMetricAxis: true }, 'line0_facet'); const y = gradientMark.encode?.enter?.y; expect(Array.isArray(y)).toBe(true); expect((y as unknown[]).length).toBe(2); @@ -510,8 +509,8 @@ describe('getAlternateSegmentStrokeDash()', () => { test('data-driven lineType + static alternateSegmentLineType: base uses scale lookup, alt does not', () => { const result = getAlternateSegmentStrokeDash('line0', 'lineTypeField', 'dotted') as { signal: string }; - expect(result.signal).toContain(`scale('${LINE_TYPE_SCALE}', datum['lineTypeField'])`); - expect(result.signal).not.toContain(`scale('${LINE_TYPE_SCALE}', datum['dotted'])`); + expect(result.signal).toContain(`scale('${LINE_TYPE_CHART_SIZE_SCALE}', datum['lineTypeField'])`); + expect(result.signal).not.toContain(`datum['dotted']`); expect(result.signal).toContain('line0_alternateFlag'); }); @@ -520,6 +519,50 @@ describe('getAlternateSegmentStrokeDash()', () => { const dashed = getAlternateSegmentStrokeDash('line0', { value: 'solid' }, 'dashed') as { signal: string }; expect(dotted.signal).not.toBe(dashed.signal); }); + + test('alternate segment dash scales with the chart-size stroke width', () => { + const result = getAlternateSegmentStrokeDash('line0', { value: 'solid' }, 'dotted') as { signal: string }; + expect(result.signal).toBe(`datum.line0_alternateFlag ? ${getChartSizeDashExpr([2, 2])} : []`); + }); +}); + +describe('getLineStrokeDash()', () => { + test('solid lineType returns an empty static dash', () => { + expect(getLineStrokeDash({ ...defaultLineMarkOptions, lineType: { value: 'solid' } })).toStrictEqual({ + value: [], + }); + }); + + test.each(['dashed', 'dotted', 'dotDash', 'shortDash', 'longDash', 'twoDash'] as const)( + 'static %s lineType scales with the chart-size stroke width', + (lineType) => { + expect(getLineStrokeDash({ ...defaultLineMarkOptions, lineType: { value: lineType } })).toStrictEqual({ + signal: getChartSizeDashExpr(getStrokeDashFromLineType(lineType)), + }); + } + ); + + test('custom dash array scales with the chart-size stroke width', () => { + expect(getLineStrokeDash({ ...defaultLineMarkOptions, lineType: { value: [8, 4] } })).toStrictEqual({ + signal: `(${CHART_SIZE_STROKE_WIDTH} < 2 ? [4.5, 4.5] : ${CHART_SIZE_STROKE_WIDTH} < 2.5 ? [6, 6] : [7.5, 7.5])`, + }); + }); + + test('data-driven lineType uses the chart-size line type scale', () => { + expect(getLineStrokeDash({ ...defaultLineMarkOptions, lineType: 'lineTypeField' })).toStrictEqual({ + signal: `scale('${LINE_TYPE_CHART_SIZE_SCALE}', datum['lineTypeField'])`, + }); + }); + + test('uses the alternate segment signal when alternateSegmentKey and alternateSegmentLineType are set', () => { + const result = getLineStrokeDash({ + ...defaultLineMarkOptions, + alternateSegmentKey: 'isForecast', + alternateSegmentLineType: 'dotted', + }); + expect(result).toHaveProperty('signal'); + expect((result as { signal: string }).signal).toContain('line0_alternateFlag'); + }); }); describe('getHighlightedSeriesOpacityRules()', () => { @@ -532,21 +575,30 @@ describe('getHighlightedSeriesOpacityRules()', () => { }); test('with interactiveMarkName adds hover rule as first condition', () => { - const rules = getHighlightedSeriesOpacityRules({ interactiveMarkName: 'line0' }) as { test?: string; value: number }[]; + const rules = getHighlightedSeriesOpacityRules({ interactiveMarkName: 'line0' }) as { + test?: string; + value: number; + }[]; expect(rules).toHaveLength(4); expect(rules[0].test).toContain(`line0_${HOVERED_ITEM}`); expect(rules[0].test).toContain(SERIES_ID); }); test('with isHighlightedByGroup uses highlightedData condition instead of hover item', () => { - const rules = getHighlightedSeriesOpacityRules({ interactiveMarkName: 'line0', isHighlightedByGroup: true }) as { test?: string; value: number }[]; + const rules = getHighlightedSeriesOpacityRules({ interactiveMarkName: 'line0', isHighlightedByGroup: true }) as { + test?: string; + value: number; + }[]; expect(rules[0].test).toContain(`line0_highlightedData`); expect(rules[0].test).not.toContain(HOVERED_ITEM); }); test('all show rules have value 1 and fallback has value 0', () => { - const rules = getHighlightedSeriesOpacityRules({ interactiveMarkName: 'line0' }) as { test?: string; value: number }[]; - rules.slice(0, -1).forEach(rule => expect(rule.value).toBe(1)); + const rules = getHighlightedSeriesOpacityRules({ interactiveMarkName: 'line0' }) as { + test?: string; + value: number; + }[]; + rules.slice(0, -1).forEach((rule) => expect(rule.value).toBe(1)); expect(rules.at(-1)?.value).toBe(0); }); @@ -569,7 +621,6 @@ describe('getHighlightedSeriesOpacityRules()', () => { }); }); - describe('getLineHighlightOverlayGroup()', () => { test('returns a group mark named _highlightOverlay_group', () => { const group = getLineHighlightOverlayGroup(defaultLineMarkOptions, 'filteredTable', [SERIES_ID]); @@ -613,11 +664,9 @@ describe('getLineHighlightOverlayGroup()', () => { // the overlay mark is renamed to `${name}_highlightOverlayLine`, but the draw-in cutoff signal is // only ever registered under the original line's name — using draw-in encoding here would reference // a signal that doesn't exist (e.g. "line0_highlightOverlayLine_drawInAnimCutoff") - const group = getLineHighlightOverlayGroup( - { ...defaultLineMarkOptions, isDrawInAnimate: true }, - 'filteredTable', - [SERIES_ID] - ); + const group = getLineHighlightOverlayGroup({ ...defaultLineMarkOptions, isDrawInAnimate: true }, 'filteredTable', [ + SERIES_ID, + ]); const marks = (group as { marks: { encode: { update: { x: { signal?: string } } } }[] }).marks; expect(marks[0].encode.update.x).not.toHaveProperty('signal'); expect(marks[0].encode.update).not.toHaveProperty('y'); diff --git a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts index 2aaddb7637..6ab609e357 100644 --- a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts +++ b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts @@ -33,6 +33,7 @@ import { DEFAULT_TRANSFORMED_TIME_DIMENSION, FADE_FACTOR, HOVERED_ITEM, + LINE_TYPE_CHART_SIZE_SCALE, LINE_TYPE_SCALE, OPACITY_SCALE, SELECTED_SERIES, @@ -41,20 +42,19 @@ import { import { getS2ColorValue } from '@spectrum-charts/themes'; import { getPopovers } from '../chartPopover/chartPopoverUtils'; -import { getLineDrawInXEncoding, getLineDrawInYEncoding } from '../marks/drawInAnimationUtils' +import { getLineDrawInXEncoding, getLineDrawInYEncoding } from '../marks/drawInAnimationUtils'; import { getColorProductionRule, getColorProductionRuleSignalString, getItemHoverArea, getOpacityProductionRule, - getStrokeDashProductionRule, getVoronoiPath, getXProductionRule, hasActionBar, hasPopover, } from '../marks/markUtils'; import { getScaleName } from '../scale/scaleSpecBuilder'; -import { getStrokeDashFromLineType } from '../specUtils'; +import { getChartSizeDashExpr, getStrokeDashFromLineType } from '../specUtils'; import { ScaleType } from '../types'; import { MIN_LABEL_GAP, getDirectLabelTextMarks } from './directLabelUtils'; import { getPrimarySeriesOtherExpr } from './lineDataUtils'; @@ -164,29 +164,51 @@ const getGradientOpacity = (opacity: LineMarkOptions['opacity']): { value: numbe }; /** - * Returns the strokeDash encoding for a line mark that has alternateSegmentKey set. - * When lineType is a static value, returns a signal expression that switches between - * the base dash and the alternate dash based on the per-datum alternateFlag field. - * Falls back to the standard scale/field lookup for data-driven lineType facets. + * Gets the chart-size dash expression for a line type facet. + * @param lineTypeFacet + * @returns vega expression string */ const getLineTypeDashSignal = (lineTypeFacet: LineMarkOptions['lineType']): string => { - if (typeof lineTypeFacet === 'string') { - return `scale('${LINE_TYPE_SCALE}', datum['${lineTypeFacet}'])`; - } - return JSON.stringify(getStrokeDashFromLineType(lineTypeFacet.value)); + if (typeof lineTypeFacet === 'string') return `scale('${LINE_TYPE_CHART_SIZE_SCALE}', datum['${lineTypeFacet}'])`; + return getChartSizeDashExpr(getStrokeDashFromLineType(lineTypeFacet.value)); }; +/** + * Gets the strokeDash signal that switches between the base and alternate dash per datum. + * @param name + * @param lineType + * @param alternateSegmentLineType + * @returns ArrayValueRef + */ export const getAlternateSegmentStrokeDash = ( name: string, lineType: LineMarkOptions['lineType'], alternateSegmentLineType: LineMarkOptions['alternateSegmentLineType'] ): ArrayValueRef | undefined => { if (!alternateSegmentLineType) return; - const altDash = JSON.stringify(getStrokeDashFromLineType(alternateSegmentLineType)); + const altDash = getChartSizeDashExpr(getStrokeDashFromLineType(alternateSegmentLineType)); const baseDash = getLineTypeDashSignal(lineType); return { signal: `datum.${name}_alternateFlag ? ${altDash} : ${baseDash}` }; }; +/** + * Gets the strokeDash encoding for a line mark. + * @param lineMarkOptions + * @returns ArrayValueRef + */ +export const getLineStrokeDash = ({ + alternateSegmentKey, + alternateSegmentLineType, + lineType, + name, +}: LineMarkOptions): ArrayValueRef => { + if (alternateSegmentKey && alternateSegmentLineType) { + return getAlternateSegmentStrokeDash(name, lineType, alternateSegmentLineType) as ArrayValueRef; + } + const signal = getLineTypeDashSignal(lineType); + return signal === '[]' ? { value: [] } : { signal }; +}; + /** * generates a line mark * @param lineOptions @@ -195,15 +217,12 @@ export const getAlternateSegmentStrokeDash = ( */ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string): LineMark => { const { - alternateSegmentKey, - alternateSegmentLineType, chartPopovers, color, colorScheme, dimension, otherSeriesColor, lineCap = 'round', - lineType, metric, name, opacity, @@ -228,18 +247,16 @@ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string ...(isDrawInAnimate ? {} : { y: getLineYEncoding(lineMarkOptions, metric) }), stroke: getStrokeEncoding(primarySeries, otherSeriesColor, color, colorScheme), strokeCap: { value: lineCap }, - strokeDash: alternateSegmentKey - ? getAlternateSegmentStrokeDash(name, lineType, alternateSegmentLineType) - : getStrokeDashProductionRule(lineType), strokeOpacity: getOpacityProductionRule(opacity), }, update: { - // x and strokeWidth must be in update: x changes on resize, strokeWidth changes on hover + // x, strokeWidth and strokeDash must be in update: x and strokeDash change on resize, strokeWidth on hover x: isDrawInAnimate ? getLineDrawInXEncoding(lineMarkOptions) : getXProductionRule(scaleType, dimension), ...(isDrawInAnimate ? { y: getLineDrawInYEncoding(lineMarkOptions) } : {}), ...(popoverWithDimensionHighlightExists ? {} : { opacity: getLineOpacity(lineMarkOptions) }), ...(interpolate ? { interpolate: { value: interpolate } } : {}), strokeWidth: getLineStrokeWidth(lineMarkOptions), + strokeDash: getLineStrokeDash(lineMarkOptions), }, }, }; diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts index 367d96b204..c7af0f4975 100644 --- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts @@ -12,6 +12,7 @@ import { Data } from 'vega'; import { + ANIMATION_TIMER, BACKGROUND_COLOR, CHART_SIZE_POINT_SIZE, COLOR_SCALE, @@ -26,8 +27,9 @@ import { HOVERED_ITEM, HOVER_ANIM_LAST_CHANGE_DATA, HOVER_TARGETS, - ANIMATION_TIMER, LINEAR_PADDING, + LINE_TYPE_CHART_SIZE_SCALE, + LINE_TYPE_SCALE, MARK_ID, SERIES_ID, TABLE, @@ -36,7 +38,7 @@ import { import * as signalSpecBuilder from '../signal/signalSpecBuilder'; import { defaultSignals } from '../specTestUtils'; -import { initializeSpec } from '../specUtils'; +import { getChartSizeDashExpr, getStrokeDashFromLineType, initializeSpec } from '../specUtils'; import { ScSpec } from '../types'; import { addData, addLine, addLineMarks, addSignals, getAlternateSegmentData, setScales } from './lineSpecBuilder'; import { defaultLineOptions } from './lineTestUtils'; @@ -79,7 +81,6 @@ const defaultSpec = initializeSpec({ enter: { stroke: { field: DEFAULT_COLOR, scale: COLOR_SCALE }, strokeCap: { value: 'round' }, - strokeDash: { value: [] }, strokeOpacity: DEFAULT_OPACITY_RULE, y: [{ field: 'value', scale: 'yLinear' }], }, @@ -87,6 +88,7 @@ const defaultSpec = initializeSpec({ x: { field: DEFAULT_TRANSFORMED_TIME_DIMENSION, scale: 'xTime' }, opacity: [DEFAULT_OPACITY_RULE], strokeWidth: [DEFAULT_STROKE_WIDTH_RULE], + strokeDash: { value: [] }, }, }, from: { data: 'line0_facet' }, @@ -162,13 +164,13 @@ const line0_groupMark = { y: [{ scale: 'yLinear', field: 'value' }], stroke: { scale: COLOR_SCALE, field: 'series' }, strokeCap: { value: 'round' }, - strokeDash: { value: [] }, strokeOpacity: DEFAULT_OPACITY_RULE, }, update: { x: { scale: 'xTime', field: DEFAULT_TRANSFORMED_TIME_DIMENSION }, opacity: [DEFAULT_OPACITY_RULE], strokeWidth: [DEFAULT_STROKE_WIDTH_RULE], + strokeDash: { value: [] }, }, }, }, @@ -208,9 +210,6 @@ const metricRangeGroupMark = { field: 'series', }, strokeCap: { value: 'round' }, - strokeDash: { - value: [7, 4], - }, strokeOpacity: DEFAULT_OPACITY_RULE, }, update: { @@ -220,6 +219,7 @@ const metricRangeGroupMark = { }, opacity: [DEFAULT_OPACITY_RULE], strokeWidth: [DEFAULT_STROKE_WIDTH_RULE], + strokeDash: { signal: getChartSizeDashExpr(getStrokeDashFromLineType('dashed')) }, }, }, }, @@ -858,6 +858,27 @@ describe('lineSpecBuilder', () => { expect(fields).toContain('line0_effectiveValue'); expect(fields).not.toContain('value'); }); + + test('with a data-driven lineType adds the chart-size line type scale', () => { + const lineTypeScale = { + name: LINE_TYPE_SCALE, + type: 'ordinal' as const, + range: [[], [6.5, 2]], + domain: { data: 'table', fields: [] }, + }; + const scales = setScales([lineTypeScale], { ...defaultLineOptions, lineType: 'series' }); + expect(scales.find((s) => s.name === LINE_TYPE_CHART_SIZE_SCALE)).toStrictEqual({ + name: LINE_TYPE_CHART_SIZE_SCALE, + type: 'ordinal', + domain: { signal: `domain('${LINE_TYPE_SCALE}')` }, + range: { signal: `[[], ${getChartSizeDashExpr([6.5, 2])}]` }, + }); + }); + + test('with a static lineType does not add the chart-size line type scale', () => { + const scales = setScales(startingSpec.scales ?? [], defaultLineOptions); + expect(scales.some((s) => s.name === LINE_TYPE_CHART_SIZE_SCALE)).toBe(false); + }); }); describe('addLineMarks()', () => { @@ -876,13 +897,13 @@ describe('lineSpecBuilder', () => { stroke: { field: DEFAULT_COLOR, scale: COLOR_SCALE }, strokeCap: { value: 'round' }, strokeOpacity: DEFAULT_OPACITY_RULE, - strokeDash: { value: [8, 8] }, y: [{ field: 'value', scale: 'yLinear' }], }, update: { x: { field: DEFAULT_TRANSFORMED_TIME_DIMENSION, scale: 'xTime' }, opacity: [DEFAULT_OPACITY_RULE], strokeWidth: [DEFAULT_STROKE_WIDTH_RULE], + strokeDash: { signal: getChartSizeDashExpr([8, 8]) }, }, }, from: { data: 'line0_facet' }, @@ -1188,8 +1209,8 @@ describe('lineSpecBuilder', () => { alternateSegmentKey: 'isEstimated', alternateSegmentLineType: 'dotted', }); - const groupMark = marks[0] as { marks: { encode: { enter: { strokeDash: unknown } } }[] }; - const strokeDash = groupMark.marks[0].encode.enter.strokeDash; + const groupMark = marks[0] as { marks: { encode: { update: { strokeDash: unknown } } }[] }; + const strokeDash = groupMark.marks[0].encode.update.strokeDash; expect(strokeDash).toHaveProperty('signal'); }); @@ -1238,9 +1259,9 @@ describe('lineSpecBuilder', () => { forecasts: [{ metric: 'forecastValue', start: 1725148800000 }], }); const groupMark = marks.find((m) => m.name === 'line0_group') as { - marks: { encode: { enter: { strokeDash: unknown } } }[]; + marks: { encode: { update: { strokeDash: unknown } } }[]; }; - expect(groupMark?.marks?.[0]?.encode?.enter?.strokeDash).toHaveProperty('signal'); + expect(groupMark?.marks?.[0]?.encode?.update?.strokeDash).toHaveProperty('signal'); }); test('with forecasts line mark y-encoding uses effectiveValue field', () => { diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts index 4c542a9200..b3f3bd24b4 100644 --- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import { produce } from 'immer'; -import { Data, Mark, Scale, Signal } from 'vega'; +import { Data, Mark, OrdinalScale, Scale, Signal } from 'vega'; import { AnimationType, @@ -23,6 +23,7 @@ import { FILTERED_TABLE, INTERACTION_MODE, LAST_RSC_SERIES_ID, + LINE_TYPE_CHART_SIZE_SCALE, LINE_TYPE_SCALE, OPACITY_SCALE, SERIES_ID, @@ -46,6 +47,13 @@ import { getLineForecastLabelMarks, getLineForecastSpecOptions, } from '../lineForecast'; +import { + addLineDrawInAnimationSignals, + addLineDrawInLeadTransform, + addLineDrawInTimeMsTransform, + getLineDrawInData, + getLineDrawInPointIndexData, +} from '../marks/drawInAnimationUtils'; import { addHoverAnimLastChangeData, addHoverAnimationSignals, @@ -60,7 +68,12 @@ import { getMetricRangeData, getMetricRangeGroupMarks, getMetricRanges } from '. import { addContinuousDimensionScale, addFieldToFacetScaleDomain, addMetricScale } from '../scale/scaleSpecBuilder'; import { getDualAxisScaleNames } from '../scale/scaleUtils'; import { addHoveredItemSignal, getFirstRscSeriesIdSignal, getLastRscSeriesIdSignal } from '../signal/signalSpecBuilder'; -import { addUserMetaAnimatedMark, addUserMetaInteractiveMark, getFacetsFromOptions } from '../specUtils'; +import { + addUserMetaAnimatedMark, + addUserMetaInteractiveMark, + getChartSizeDashExpr, + getFacetsFromOptions, +} from '../specUtils'; import { addTrendlineData, getTrendlineMarks, getTrendlineScales, setTrendlineSignals } from '../trendline'; import { ChartData, @@ -90,7 +103,6 @@ import { import { getLinePointAnnotationMarks } from './linePointAnnotation'; import { getLineStaticPoint, getLineStaticPointBackground } from './linePointUtils'; import { getPopoverMarkName, isDualMetricAxis } from './lineUtils'; -import { addLineDrawInAnimationSignals, addLineDrawInLeadTransform, addLineDrawInTimeMsTransform, getLineDrawInData, getLineDrawInPointIndexData } from '../marks/drawInAnimationUtils'; export const addLine = produce< ScSpec, @@ -290,7 +302,7 @@ export const addData = produce((data, options) => { } if (staticPoint || isSparkline) { - if (options.isDrawInAnimate){ + if (options.isDrawInAnimate) { data.push(getLineStaticPointData(name, staticPoint, `${name}_${DRAW_IN_PREV_DATA}`, isSparkline, isMethodLast)); } else { data.push(getLineStaticPointData(name, staticPoint, FILTERED_TABLE, isSparkline, isMethodLast)); @@ -437,6 +449,7 @@ export const setScales = produce((scales, options) = addFieldToFacetScaleDomain(scales, COLOR_SCALE, color); // add lineType to the lineType domain addFieldToFacetScaleDomain(scales, LINE_TYPE_SCALE, lineType); + if (typeof lineType === 'string') addLineTypeChartSizeScale(scales); // add opacity to the opacity domain addFieldToFacetScaleDomain(scales, OPACITY_SCALE, opacity); // find the linear scale and add our fields to it @@ -457,6 +470,23 @@ export const setScales = produce((scales, options) = return scales; }); +/** + * Adds an ordinal scale that mirrors the lineType scale with chart-size dash expressions. + * @param scales + */ +export const addLineTypeChartSizeScale = (scales: Scale[]) => { + if (scales.some((scale) => scale.name === LINE_TYPE_CHART_SIZE_SCALE)) return; + const lineTypeScale = scales.find((scale) => scale.name === LINE_TYPE_SCALE) as OrdinalScale | undefined; + const lineTypeRange = lineTypeScale?.range; + if (!Array.isArray(lineTypeRange)) return; + scales.push({ + name: LINE_TYPE_CHART_SIZE_SCALE, + type: 'ordinal', + domain: { signal: `domain('${LINE_TYPE_SCALE}')` }, + range: { signal: `[${(lineTypeRange as number[][]).map(getChartSizeDashExpr).join(', ')}]` }, + }); +}; + // The order that marks are added is important since it determines the draw order. export const addLineMarks = produce((marks, options) => { const { highlightedItem, legendHighlightSignals, name } = options; @@ -608,11 +638,7 @@ const addLineHighlightOverlayMarks = ( ); }; -const addLineForecastLabelMarks = ( - marks: Mark[], - forecasts: LineForecastOptions[], - options: LineSpecOptions -): void => { +const addLineForecastLabelMarks = (marks: Mark[], forecasts: LineForecastOptions[], options: LineSpecOptions): void => { for (const [i, forecast] of forecasts.entries()) { marks.push(...getLineForecastLabelMarks(getLineForecastSpecOptions(forecast, i, options))); } diff --git a/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts b/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts index 38c4f1e3fd..10e60f35c1 100644 --- a/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts @@ -112,7 +112,7 @@ describe('getStrokeDashProductionRule', () => { }); test('should return static value and convert preset line type to dash array', () => { - expect(getStrokeDashProductionRule({ value: 'dotted' })).toStrictEqual({ value: [0, 4] }); + expect(getStrokeDashProductionRule({ value: 'dotted' })).toStrictEqual({ value: [2, 2] }); }); test('should return static value of the dash array provided', () => { diff --git a/packages/vega-spec-builder-s2/src/metricRange/metricRangeUtils.test.ts b/packages/vega-spec-builder-s2/src/metricRange/metricRangeUtils.test.ts index ac0aff9f9c..ba58bf067b 100644 --- a/packages/vega-spec-builder-s2/src/metricRange/metricRangeUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/metricRange/metricRangeUtils.test.ts @@ -22,6 +22,7 @@ import { MARK_ID, } from '@spectrum-charts/constants'; +import { getChartSizeDashExpr, getStrokeDashFromLineType } from '../specUtils'; import { LineSpecOptions, MetricRangeOptions, MetricRangeSpecOptions } from '../types'; import { applyMetricRangeOptionDefaults, @@ -95,7 +96,6 @@ const basicMetricRangeMarks = [ y: [{ scale: 'yLinear', field: 'metric' }], stroke: { scale: COLOR_SCALE, field: 'series' }, strokeCap: { value: 'round' }, - strokeDash: { value: [3, 4] }, strokeOpacity: DEFAULT_OPACITY_RULE, }, update: { @@ -105,6 +105,7 @@ const basicMetricRangeMarks = [ }, opacity: [DEFAULT_OPACITY_RULE], strokeWidth: [DEFAULT_STROKE_WIDTH_RULE], + strokeDash: { signal: getChartSizeDashExpr(getStrokeDashFromLineType('shortDash')) }, }, }, }, @@ -190,9 +191,7 @@ describe('getMetricRangeMark', () => { { ...defaultLineOptions, interactiveMarkName: 'line0', isHoverAnimate: true }, defaultMetricRangeSpecOptions ); - expect(Array.isArray((lineMark as { encode: { update: { opacity: unknown } } }).encode.update.opacity)).toBe( - true - ); + expect(Array.isArray((lineMark as { encode: { update: { opacity: unknown } } }).encode.update.opacity)).toBe(true); }); }); diff --git a/packages/vega-spec-builder-s2/src/specUtils.test.ts b/packages/vega-spec-builder-s2/src/specUtils.test.ts index 333e6cbbb8..939ac576fe 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.test.ts @@ -12,6 +12,7 @@ import { BandScale, OrdinalScale } from 'vega'; import { + CHART_SIZE_STROKE_WIDTH, COLOR_SCALE, DEFAULT_CATEGORICAL_DIMENSION, DEFAULT_COLOR, @@ -22,16 +23,15 @@ import { TABLE, VISIBILITY_OFF_PATH, } from '@spectrum-charts/constants'; -import { - getSpectrum2VegaConfig, - spectrum2Colors, -} from '@spectrum-charts/themes'; +import { getSpectrum2VegaConfig, spectrum2Colors } from '@spectrum-charts/themes'; import { - addUserMetaInteractiveMark, addUserMetaAnimatedMark, + addUserMetaInteractiveMark, escapeD3FormatSpecifier, getChartConfig, + getChartSizeDashExpr, + getChartSizeDashes, getD3FormatSpecifierFromNumberFormat, getDimensionField, getFacetsFromOptions, @@ -129,12 +129,44 @@ describe('getStrokeDashFromLineType()', () => { test('should convert line type names to their coresponding stroke dash array', () => { expect(getStrokeDashFromLineType('solid')).toStrictEqual([]); - expect(getStrokeDashFromLineType('dashed')).toStrictEqual([7, 4]); - expect(getStrokeDashFromLineType('dotted')).toStrictEqual([0, 4]); - expect(getStrokeDashFromLineType('dotDash')).toStrictEqual([2, 3, 7, 4]); - expect(getStrokeDashFromLineType('shortDash')).toStrictEqual([3, 4]); - expect(getStrokeDashFromLineType('longDash')).toStrictEqual([11, 4]); - expect(getStrokeDashFromLineType('twoDash')).toStrictEqual([5, 2, 11, 2]); + expect(getStrokeDashFromLineType('dashed')).toStrictEqual([6.5, 2]); + expect(getStrokeDashFromLineType('dotted')).toStrictEqual([2, 2]); + expect(getStrokeDashFromLineType('dotDash')).toStrictEqual([2, 2, 6.5, 2]); + expect(getStrokeDashFromLineType('shortDash')).toStrictEqual([4, 2]); + expect(getStrokeDashFromLineType('longDash')).toStrictEqual([11, 2]); + expect(getStrokeDashFromLineType('twoDash')).toStrictEqual([6.5, 2, 11, 2]); + }); +}); + +describe('getChartSizeDashes()', () => { + test('returns design values for named line types', () => { + expect(getChartSizeDashes([6.5, 2])).toStrictEqual({ S: [6, 2], M: [6.5, 2], L: [8.5, 2.5] }); + expect(getChartSizeDashes([2, 2])).toStrictEqual({ S: [1.5, 1], M: [2, 2], L: [2.5, 3] }); + }); + + test('scales custom arrays with the chart-size stroke width', () => { + expect(getChartSizeDashes([8, 4])).toStrictEqual({ S: [6, 3], M: [8, 4], L: [10, 5] }); + }); +}); + +describe('getChartSizeDashExpr()', () => { + const W = CHART_SIZE_STROKE_WIDTH; + + test('picks the cap-compensated dash for each chart size', () => { + expect(getChartSizeDashExpr([6.5, 2])).toBe(`(${W} < 2 ? [4.5, 3.5] : ${W} < 2.5 ? [4.5, 4] : [6, 5])`); + expect(getChartSizeDashExpr([2, 2])).toBe(`(${W} < 2 ? [0, 2.5] : ${W} < 2.5 ? [0, 4] : [0, 5.5])`); + }); + + test('clamps dashes shorter than the stroke width to dots', () => { + expect(getChartSizeDashExpr([1, 2])).toBe(`(${W} < 2 ? [0, 3] : ${W} < 2.5 ? [0, 4] : [0, 5])`); + }); + + test('repeats odd-length arrays', () => { + expect(getChartSizeDashExpr([4])).toBe(`(${W} < 2 ? [1.5, 4.5] : ${W} < 2.5 ? [2, 6] : [2.5, 7.5])`); + }); + + test('returns an empty array for solid lines', () => { + expect(getChartSizeDashExpr([])).toBe('[]'); }); }); diff --git a/packages/vega-spec-builder-s2/src/specUtils.ts b/packages/vega-spec-builder-s2/src/specUtils.ts index 2e25510785..b2745b7878 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.ts @@ -14,6 +14,8 @@ import { Config, Data, Scale, ScaleType, Spec } from 'vega'; import { mergeConfig } from 'vega-util'; import { + CHART_SIZE_STROKE_WIDTH, + CHART_SIZE_STROKE_WIDTHS, COLOR_SCALE, DATE_PATH, DEFAULT_TRANSFORMED_TIME_DIMENSION, @@ -106,6 +108,19 @@ export const getFacetsFromScales = (scales: Scale[] = []): string[] => { return [...new Set(facets)]; }; +type ChartSize = keyof typeof CHART_SIZE_STROKE_WIDTHS; +const CHART_SIZES: ChartSize[] = ['S', 'M', 'L']; + +/** Visible dash (including caps) and gap lengths for each named line type at each chart size. */ +const LINE_TYPE_DASHES: Record, Record> = { + dashed: { S: [6, 2], M: [6.5, 2], L: [8.5, 2.5] }, + dotted: { S: [1.5, 1], M: [2, 2], L: [2.5, 3] }, + dotDash: { S: [1.5, 2, 6, 2], M: [2, 2, 6.5, 2], L: [2.5, 2.5, 8.5, 2.5] }, + shortDash: { S: [3, 2], M: [4, 2], L: [5, 2.5] }, + longDash: { S: [10.5, 2], M: [11, 2], L: [14.5, 2.5] }, + twoDash: { S: [6, 2, 10.5, 2], M: [6.5, 2, 11, 2], L: [8.5, 2.5, 14.5, 2.5] }, +}; + /** * gets the strokeDash array from the lineType * @param lineType @@ -115,23 +130,48 @@ export const getStrokeDashFromLineType = (lineType: LineType): number[] => { if (Array.isArray(lineType)) { return lineType; } - switch (lineType) { - case 'dashed': - return [7, 4]; - case 'dotted': - return [0, 4]; - case 'dotDash': - return [2, 3, 7, 4]; - case 'shortDash': - return [3, 4]; - case 'longDash': - return [11, 4]; - case 'twoDash': - return [5, 2, 11, 2]; - case 'solid': - default: - return []; - } + if (lineType === 'solid' || !(lineType in LINE_TYPE_DASHES)) return []; + return LINE_TYPE_DASHES[lineType].M; +}; + +/** + * Gets the visible dash lengths for each chart size, using design values for named line types and scaling custom arrays. + * @param strokeDash visible dash lengths at medium chart size + * @returns visible dash lengths per chart size + */ +export const getChartSizeDashes = (strokeDash: number[]): Record => { + const key = strokeDash.join(); + const named = Object.values(LINE_TYPE_DASHES).find((dashes) => dashes.M.join() === key); + if (named) return named; + const scale = (size: ChartSize) => + strokeDash.map((length) => (length * CHART_SIZE_STROKE_WIDTHS[size]) / CHART_SIZE_STROKE_WIDTHS.M); + return { S: scale('S'), M: strokeDash, L: scale('L') }; +}; + +/** + * Converts visible dash lengths into a strokeDash for round or square caps, which extend each dash by the stroke width. + * @param strokeDash visible dash lengths + * @param strokeWidth + * @returns strokeDash array + */ +const getCapCompensatedDash = (strokeDash: number[], strokeWidth: number): number[] => { + const evenDash = strokeDash.length % 2 ? [...strokeDash, ...strokeDash] : strokeDash; + return evenDash.map((length, index) => (index % 2 ? length + strokeWidth : Math.max(0, length - strokeWidth))); +}; + +/** + * Gets a strokeDash expression that picks the cap-compensated dash for the current chart size. + * @param strokeDash visible dash lengths at medium chart size + * @returns vega expression string + */ +export const getChartSizeDashExpr = (strokeDash: number[]): string => { + if (!strokeDash.length) return '[]'; + const dashes = getChartSizeDashes(strokeDash); + const [s, m, l] = CHART_SIZES.map( + (size) => `[${getCapCompensatedDash(dashes[size], CHART_SIZE_STROKE_WIDTHS[size]).join(', ')}]` + ); + const width = CHART_SIZE_STROKE_WIDTH; + return `(${width} < ${CHART_SIZE_STROKE_WIDTHS.M} ? ${s} : ${width} < ${CHART_SIZE_STROKE_WIDTHS.L} ? ${m} : ${l})`; }; /** @@ -344,8 +384,5 @@ export const addUserMetaDivergingBarMark = produce((usermeta, animatedMarkName) => { - usermeta.animatedMarks = [ - ...(usermeta.animatedMarks ?? []), - ...(animatedMarkName ? [animatedMarkName] : []), - ]; -}); \ No newline at end of file + usermeta.animatedMarks = [...(usermeta.animatedMarks ?? []), ...(animatedMarkName ? [animatedMarkName] : [])]; +});