diff --git a/.gitignore b/.gitignore
index e4b74d715..b41cf7df4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,5 @@ cursor
# catch all for temporary outputs
tmp/
+.agents/
.scout/
\ No newline at end of file
diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts
index ac36343b0..b5f534f35 100644
--- a/packages/constants/constants.ts
+++ b/packages/constants/constants.ts
@@ -77,6 +77,17 @@ export const CHART_SIZE_POINT_SIZES = {
L: (BASE_POINT_DIAMETER * CHART_SIZE_SCALE_RATIOS.L) ** 2,
} as const;
+/** Vega signal name for the chart-size-derived label gap, driven by a reactive expression. */
+export const CHART_SIZE_LABEL_GAP = 'rscChartSizeLabelGap';
+
+/** Minimum vertical gap (px) between co-located labels per chart size tier. Base M = 12px. */
+const BASE_LABEL_GAP = 12;
+export const CHART_SIZE_LABEL_GAPS = {
+ S: BASE_LABEL_GAP * CHART_SIZE_SCALE_RATIOS.S,
+ M: BASE_LABEL_GAP * CHART_SIZE_SCALE_RATIOS.M,
+ L: BASE_LABEL_GAP * CHART_SIZE_SCALE_RATIOS.L,
+} as const;
+
export const DEFAULT_LINEAR_DIMENSION = 'x';
export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_METRIC = 'value';
diff --git a/packages/docs/docs/spectrum2/line.md b/packages/docs/docs/spectrum2/line.md
index 0696f6c72..d250f970c 100644
--- a/packages/docs/docs/spectrum2/line.md
+++ b/packages/docs/docs/spectrum2/line.md
@@ -503,6 +503,24 @@ The S2 `Line` component does not yet support `onMouseOver`, `onMouseOut`, `Metri
– |
Text appended to the hover value label for alternate-segment points (e.g. '(Estimated)'). |
+
+ | showHoverLabel |
+ boolean |
+ true |
+ When true, shows the metric value as a label adjacent to the hovered data point. Suppressed when a ChartInspect child is present. |
+
+
+ | dimensionHover |
+ boolean |
+ false |
+ When true, all series at the hovered x-dimension highlight simultaneously instead of only the nearest series. Hover value labels show for every series at that dimension. |
+
+
+ | hoverLabelKey |
+ string |
+ (metric field) |
+ Data field key to display in the hover value label. Defaults to the metric field. Use this to show a pre-formatted or alternate field instead of the raw metric value. |
+
diff --git a/packages/react-spectrum-charts-s2/src/components/Line/Line.tsx b/packages/react-spectrum-charts-s2/src/components/Line/Line.tsx
index 9094bf6a3..61cec1c2c 100644
--- a/packages/react-spectrum-charts-s2/src/components/Line/Line.tsx
+++ b/packages/react-spectrum-charts-s2/src/components/Line/Line.tsx
@@ -36,6 +36,8 @@ const Line: FC = ({
alternateSegmentLabel,
primarySeries,
otherSeriesColor,
+ dimensionHover = false,
+ showHoverLabel = true,
contextMenuMode = 'interaction',
}: LineProps) => {
return null;
diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Features/HoverLabel/LineHoverLabel.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Features/HoverLabel/LineHoverLabel.story.tsx
new file mode 100644
index 000000000..0f3537dea
--- /dev/null
+++ b/packages/react-spectrum-charts-s2/src/stories/Line/Features/HoverLabel/LineHoverLabel.story.tsx
@@ -0,0 +1,98 @@
+/*
+ * 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 } from 'react';
+
+import { StoryFn } from '@storybook/react';
+
+import { Chart } from '../../../../Chart';
+import { Axis, Legend, Line } from '../../../../components';
+import useChartProps from '../../../../hooks/useChartProps';
+import { workspaceTrendsData } from '../../../data/data';
+import { bindWithProps } from '../../../../test-utils';
+import { ChartProps } from '../../../../types';
+
+const DATETIMES = [1667890800000, 1667977200000, 1668063600000, 1668150000000, 1668236400000, 1668322800000, 1668409200000];
+const manySeriesData = Array.from({ length: 25 }, (_, i) =>
+ DATETIMES.map((datetime, j) => ({
+ datetime,
+ series: `Series ${i + 1}`,
+ value: Math.round(1000 + Math.sin((i + j) * 0.8) * 800 + Math.cos(i * 0.5) * 500 + j * 120),
+ }))
+).flat();
+
+export default {
+ title: 'React Spectrum Charts 2/Line/Features/HoverLabel',
+ component: Line,
+};
+
+const defaultChartProps: ChartProps = { data: workspaceTrendsData, minWidth: 400, maxWidth: 800, height: 400 };
+
+const HoverLabelStory: StoryFn = (args): ReactElement => {
+ const chartProps = useChartProps(defaultChartProps);
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const WithHoverLabel = bindWithProps(HoverLabelStory);
+WithHoverLabel.args = {
+ color: 'series',
+ dimension: 'datetime',
+ metric: 'value',
+ scaleType: 'time',
+ showHoverLabel: true,
+};
+
+export const WithoutHoverLabel = bindWithProps(HoverLabelStory);
+WithoutHoverLabel.args = {
+ color: 'series',
+ dimension: 'datetime',
+ metric: 'value',
+ scaleType: 'time',
+ showHoverLabel: false,
+};
+
+export const DimensionHover = bindWithProps(HoverLabelStory);
+DimensionHover.args = {
+ color: 'series',
+ dimension: 'datetime',
+ metric: 'value',
+ scaleType: 'time',
+ showHoverLabel: true,
+ dimensionHover: true,
+};
+
+const ManySeriesStory: StoryFn = (args): ReactElement => {
+ const chartProps = useChartProps({ data: manySeriesData, minWidth: 400, maxWidth: 800, height: 400 });
+ return (
+
+
+
+
+
+ );
+};
+
+export const DimensionHoverManySeries = bindWithProps(ManySeriesStory);
+DimensionHoverManySeries.args = {
+ color: 'series',
+ dimension: 'datetime',
+ metric: 'value',
+ scaleType: 'time',
+ showHoverLabel: true,
+ dimensionHover: true,
+};
diff --git a/packages/vega-spec-builder-s2/src/area/areaSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/area/areaSpecBuilder.test.ts
index c2f219422..490172364 100644
--- a/packages/vega-spec-builder-s2/src/area/areaSpecBuilder.test.ts
+++ b/packages/vega-spec-builder-s2/src/area/areaSpecBuilder.test.ts
@@ -218,9 +218,9 @@ describe('areaSpecBuilder', () => {
expect(signals[3]).toHaveProperty('name', SELECTED_ITEM);
expect(signals[4]).toHaveProperty('name', SELECTED_SERIES);
expect(signals[5]).toHaveProperty('name', SELECTED_GROUP);
- expect(signals[10]).toHaveProperty('name', `${defaultAreaOptions.name}_${HOVERED_ITEM}`);
- expect(signals[10].on).toHaveLength(2);
- expect(signals[11]).toHaveProperty('name', `${defaultAreaOptions.name}_controlledHoveredId`);
+ expect(signals[11]).toHaveProperty('name', `${defaultAreaOptions.name}_${HOVERED_ITEM}`);
+ expect(signals[11].on).toHaveLength(2);
+ expect(signals[12]).toHaveProperty('name', `${defaultAreaOptions.name}_controlledHoveredId`);
});
test('should exclude data with key from update if inspect has excludeDataKey', () => {
diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts
index 337d3fc80..96bc9825a 100644
--- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts
+++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts
@@ -17,6 +17,8 @@ import {
CHART_SIZE_BREAKPOINTS,
CHART_SIZE_HOVER_STROKE_WIDTH,
CHART_SIZE_HOVER_STROKE_WIDTHS,
+ CHART_SIZE_LABEL_GAP,
+ CHART_SIZE_LABEL_GAPS,
CHART_SIZE_POINT_SIZE,
CHART_SIZE_POINT_SIZES,
CHART_SIZE_STROKE_WIDTH,
@@ -283,6 +285,11 @@ export const getDefaultSignals = ({
update: `rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.M} ? ${DIRECT_LABEL_FONT_SIZE_S} : rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.L} ? ${DIRECT_LABEL_FONT_SIZE_M} : ${DIRECT_LABEL_FONT_SIZE_L}`
}
+ const chartSizeLabelGapSignal: Signal = {
+ name: CHART_SIZE_LABEL_GAP,
+ update: `rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.M} ? ${CHART_SIZE_LABEL_GAPS.S} : rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.L} ? ${CHART_SIZE_LABEL_GAPS.M} : ${CHART_SIZE_LABEL_GAPS.L}`,
+ };
+
return [
getGenericValueSignal(BACKGROUND_COLOR, getS2ColorValue(signalBackgroundColor, colorScheme)),
getGenericValueSignal(REFERENCE_LINE_LABEL_BACKGROUND_STROKE, referenceLineLabelStroke),
@@ -300,6 +307,7 @@ export const getDefaultSignals = ({
chartSizeHoverStrokeWidthSignal,
chartSizePointSizeSignal,
chartSizeFontSizeSignal,
+ chartSizeLabelGapSignal,
];
};
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 9c9392620..1228e645f 100644
--- a/packages/vega-spec-builder-s2/src/legend/legendSpecBuilder.test.ts
+++ b/packages/vega-spec-builder-s2/src/legend/legendSpecBuilder.test.ts
@@ -26,6 +26,7 @@ import {
import {
defaultChartSizeFontSizeSignal,
defaultChartSizeHoverStrokeWidthSignal,
+ defaultChartSizeLabelGapSignal,
defaultChartSizePointSizeSignal,
defaultChartSizeStrokeWidthSignal,
defaultHighlightedGroupSignal,
@@ -187,6 +188,7 @@ describe('addLegend()', () => {
defaultChartSizeHoverStrokeWidthSignal,
defaultChartSizePointSizeSignal,
defaultChartSizeFontSizeSignal,
+ defaultChartSizeLabelGapSignal,
{
name: `legend0_${HOVERED_SERIES}`,
value: null,
diff --git a/packages/vega-spec-builder-s2/src/line/directLabelUtils.ts b/packages/vega-spec-builder-s2/src/line/directLabelUtils.ts
new file mode 100644
index 000000000..df9d3709c
--- /dev/null
+++ b/packages/vega-spec-builder-s2/src/line/directLabelUtils.ts
@@ -0,0 +1,170 @@
+/*
+ * 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 { NumericValueRef, ProductionRule, TextMark, Transforms } from 'vega';
+
+import { BACKGROUND_COLOR, CHART_SIZE_FONT_SIZE, CHART_SIZE_LABEL_GAP, DIRECT_LABEL_BACKGROUND_STROKE_WIDTH, DIRECT_LABEL_FONT_WEIGHT } from '@spectrum-charts/constants';
+import { getS2ColorValue } from '@spectrum-charts/themes';
+
+import { ColorScheme } from '../types';
+
+type PositionRef = NumericValueRef | ProductionRule;
+type FillOverride = { field: string } | { value: string } | { signal: string };
+
+export interface DirectLabelUpdateEncode {
+ x: PositionRef;
+ y: PositionRef;
+ additional?: Record;
+}
+
+export const MIN_LABEL_GAP = CHART_SIZE_LABEL_GAP;
+
+/**
+ * Builds the five Vega transforms that compute collision-aware vertical positions for a set of
+ * co-located labels. Shared by direct labels (namespace='') and hover labels (namespace='hover').
+ *
+ * With namespace='': produces fields _seriesCount, _metricRank, _scaledY, _adjustedY, _cumMaxAdjusted
+ * With namespace='hover': produces fields _hover_seriesCount, _hover_metricRank, etc.
+ */
+export const getCascadeTransforms = (yScaleName: string, metric: string, namespace: string): Transforms[] => {
+ const p = namespace ? `_${namespace}_` : '_';
+ const metricRank = `${p}metricRank`;
+ const scaledY = `${p}scaledY`;
+ const adjustedY = `${p}adjustedY`;
+
+ return [
+ {
+ type: 'joinaggregate' as const,
+ fields: [metric],
+ ops: ['count' as const],
+ as: [`${p}seriesCount`],
+ },
+ {
+ type: 'window' as const,
+ sort: { field: [metric], order: ['descending' as const] },
+ ops: ['rank' as const],
+ as: [metricRank],
+ },
+ { type: 'formula' as const, as: scaledY, expr: `scale('${yScaleName}', datum["${metric}"])` },
+ { type: 'formula' as const, as: adjustedY, expr: `datum.${scaledY} - datum.${metricRank} * ${MIN_LABEL_GAP}` },
+ {
+ type: 'window' as const,
+ sort: { field: [metricRank], order: ['ascending' as const] },
+ frame: [null, 0] as [null, number],
+ ops: ['max' as const],
+ fields: [adjustedY],
+ as: [`${p}cumMaxAdjusted`],
+ },
+ ];
+};
+
+// Shared style constants used by both mark variants.
+// Any visual change here automatically applies to hover labels AND static labels.
+const directLabelBackgroundStyle = {
+ fill: { value: 'transparent' as const },
+ stroke: { signal: BACKGROUND_COLOR },
+ strokeWidth: { value: DIRECT_LABEL_BACKGROUND_STROKE_WIDTH },
+ fontWeight: { value: DIRECT_LABEL_FONT_WEIGHT },
+ fontSize: { signal: CHART_SIZE_FONT_SIZE },
+};
+
+const getDirectLabelForegroundFill = (colorScheme: ColorScheme, override?: FillOverride): FillOverride =>
+ override ?? { value: getS2ColorValue('gray-900', colorScheme) };
+
+/**
+ * Builds the two-mark (background halo + foreground text) pattern for hover-style labels
+ * where position is encoded directly from data (no label transform).
+ * Both marks read from the same dataSource.
+ */
+export const getDirectLabelTextMarks = (
+ backgroundMarkName: string,
+ foregroundMarkName: string,
+ dataSource: string,
+ textSignal: string,
+ updateEncode: DirectLabelUpdateEncode,
+ colorScheme: ColorScheme,
+ foregroundFillOverride?: FillOverride
+): TextMark[] => {
+ const { x, y, additional = {} } = updateEncode;
+ return [
+ {
+ name: backgroundMarkName,
+ type: 'text',
+ interactive: false,
+ from: { data: dataSource },
+ encode: {
+ enter: { text: { signal: textSignal }, ...directLabelBackgroundStyle },
+ update: { x, y, ...additional } as never,
+ },
+ },
+ {
+ name: foregroundMarkName,
+ type: 'text',
+ interactive: false,
+ from: { data: dataSource },
+ encode: {
+ enter: {
+ text: { signal: textSignal },
+ fill: getDirectLabelForegroundFill(colorScheme, foregroundFillOverride),
+ fontWeight: { value: DIRECT_LABEL_FONT_WEIGHT },
+ fontSize: { signal: CHART_SIZE_FONT_SIZE },
+ },
+ update: { x, y, ...additional } as never,
+ },
+ },
+ ];
+};
+
+/**
+ * Builds the two-mark (background halo + foreground text) pattern for annotation-style labels
+ * that use Vega's label transform for collision-aware placement.
+ * The background mark runs the label transform; the foreground reads its computed positions
+ * (x, y, align, baseline, opacity) from the background mark.
+ */
+export const getLabelTransformTextMarks = (
+ backgroundMarkName: string,
+ foregroundMarkName: string,
+ dataSource: string,
+ textSignal: string,
+ colorScheme: ColorScheme,
+ labelTransform: Transforms,
+ foregroundFillOverride?: FillOverride
+): TextMark[] => [
+ {
+ name: backgroundMarkName,
+ type: 'text',
+ interactive: false,
+ from: { data: dataSource },
+ encode: {
+ enter: { text: { signal: textSignal }, ...directLabelBackgroundStyle },
+ update: { fontWeight: { value: DIRECT_LABEL_FONT_WEIGHT } },
+ },
+ transform: [labelTransform],
+ },
+ {
+ name: foregroundMarkName,
+ type: 'text',
+ interactive: false,
+ from: { data: backgroundMarkName },
+ encode: {
+ enter: { fill: getDirectLabelForegroundFill(colorScheme, foregroundFillOverride) },
+ update: {
+ text: { field: 'text' },
+ x: { field: 'x' },
+ y: { field: 'y' },
+ align: { field: 'align' },
+ baseline: { field: 'baseline' },
+ opacity: { field: 'opacity' },
+ fontWeight: { value: DIRECT_LABEL_FONT_WEIGHT },
+ },
+ },
+ },
+];
diff --git a/packages/vega-spec-builder-s2/src/line/lineDataUtils.test.ts b/packages/vega-spec-builder-s2/src/line/lineDataUtils.test.ts
index 46d7e411d..1ad429f67 100644
--- a/packages/vega-spec-builder-s2/src/line/lineDataUtils.test.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineDataUtils.test.ts
@@ -33,6 +33,7 @@ describe('getLineHighlightedData()', () => {
...defaultLineOptions,
chartPopovers: [{}],
chartInspects: [{ highlightBy: 'dimension' }],
+ isHighlightedByGroup: true,
}).transform?.[0] as FilterTransform
).expr;
expect(expr.includes(GROUP_ID)).toBeTruthy();
diff --git a/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts b/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts
index fc6721b45..ea2d51021 100644
--- a/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts
@@ -20,8 +20,8 @@ import {
SERIES_ID,
} from '@spectrum-charts/constants';
-import { isHighlightedByGroup } from '../chartInspect/chartInspectUtils';
import { hasPopover, isInteractive } from '../marks/markUtils';
+import { getCascadeTransforms } from './directLabelUtils';
import { LineSpecOptions } from '../types';
/**
@@ -38,7 +38,7 @@ export const getLineHighlightedData = (options: LineSpecOptions): SourceData =>
if (isInteractive(options)) {
const hoveredItemSignal = `${lineName}_${HOVERED_ITEM}`;
const groupKey = `${lineName}_${GROUP_ID}`;
- if (isHighlightedByGroup(options)) {
+ if (options.isHighlightedByGroup) {
expr += ` || isValid(${hoveredItemSignal}) && ${hoveredItemSignal}.${groupKey} === datum.${groupKey}`;
} else {
expr += ` || isValid(${hoveredItemSignal}) && ${hoveredItemSignal}.${idKey} === datum.${idKey}`;
@@ -89,6 +89,21 @@ export const getPrimarySeriesFacetData = (name: string, primarySeries: number |
],
});
+/**
+ * Derives from highlightedData and adds cascade transforms so labels for multiple series
+ * at the same hovered dimension are spread apart rather than overlapping.
+ */
+export const getHoverLabelData = (options: LineSpecOptions): SourceData => {
+ const { metric, name, metricAxis } = options;
+ const yScaleName = metricAxis || 'yLinear';
+
+ return {
+ name: `${name}_hoverLabelData`,
+ source: `${name}_highlightedData`,
+ transform: getCascadeTransforms(yScaleName, metric, 'hover'),
+ };
+};
+
/**
* gets the data used for displaying points
* @param name
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 dde572901..e77734822 100644
--- a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.test.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.test.ts
@@ -115,20 +115,20 @@ describe('getLineMark()', () => {
});
describe('getLineHoverMarks()', () => {
- test('should return 4 marks by default', () => {
+ test('should return 6 marks by default', () => {
expect(
getLineHoverMarks({ ...defaultLineMarkOptions, isHighlightedByDimension: true }, 'line0_facet')
- ).toHaveLength(4);
+ ).toHaveLength(6);
});
- test('should return 3 marks if interactionMode is item', () => {
+ test('should return 5 marks if interactionMode is item', () => {
expect(
getLineHoverMarks(
{ ...defaultLineMarkOptions, isHighlightedByDimension: true, interactionMode: 'item' },
'line0_facet'
)
- ).toHaveLength(3);
+ ).toHaveLength(5);
});
- test('should return 5 marks if a popover is present', () => {
+ test('should return 7 marks if a popover is present', () => {
expect(
getLineHoverMarks(
{
@@ -138,7 +138,7 @@ describe('getLineHoverMarks()', () => {
},
'line0_facet'
)
- ).toHaveLength(5);
+ ).toHaveLength(7);
});
test('should have opacity of 0 if a selected item exists', () => {
const marks = getLineHoverMarks(
diff --git a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts
index e90aabd45..c3f8b2791 100644
--- a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts
@@ -9,9 +9,10 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
-import { ArrayValueRef, ColorValueRef, LineMark, Mark, NumericValueRef, ProductionRule, RuleMark } from 'vega';
+import { ArrayValueRef, ColorValueRef, LineMark, Mark, NumericValueRef, ProductionRule, RuleMark, TextMark } from 'vega';
import {
+ CHART_SIZE_FONT_SIZE,
CHART_SIZE_STROKE_WIDTH,
CHART_SIZE_HOVER_STROKE_WIDTH,
COLOR_SCALE,
@@ -20,6 +21,7 @@ import {
DEFAULT_INTERACTION_MODE,
DEFAULT_OPACITY_RULE,
DEFAULT_STROKE_WIDTH_RULE,
+ DEFAULT_TRANSFORMED_TIME_DIMENSION,
FADE_FACTOR,
HOVERED_ITEM,
LAST_RSC_SERIES_ID,
@@ -44,7 +46,9 @@ import {
import { getPrimarySeriesOtherExpr } from './lineDataUtils';
import { getStrokeDashFromLineType } from '../specUtils';
import { getDualAxisScaleNames } from '../scale/scaleUtils';
+import { getScaleName } from '../scale/scaleSpecBuilder';
import { ScaleType } from '../types';
+import { getDirectLabelTextMarks, MIN_LABEL_GAP } from './directLabelUtils';
import {
getHighlightPoint,
getSecondaryHighlightPoint,
@@ -245,8 +249,7 @@ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string
strokeOpacity: getOpacityProductionRule(opacity),
},
update: {
- // this has to be in update because when you resize the window that doesn't rebuild the spec
- // but it may change the x position if it causes the chart to resize
+ // x and strokeWidth must be in update: x changes on resize, strokeWidth changes on hover
x: getXProductionRule(scaleType, dimension),
...(popoverWithDimensionHighlightExists ? {} : { opacity: getLineOpacity(lineMarkOptions) }),
...(interpolate ? { interpolate: { value: interpolate } } : {}),
@@ -385,7 +388,7 @@ export const getLineHoverMarks = (
dataSource: string,
secondaryHighlightedMetric?: string
): Mark[] => {
- const { dimension, name, scaleType } = lineOptions;
+ const { dimension, name, scaleType, showHoverLabel = true } = lineOptions;
return [
// vertical rule shown for the hovered or selected point
getHoverRule(dimension, name, scaleType),
@@ -395,11 +398,54 @@ export const getLineHoverMarks = (
getHighlightPoint(lineOptions),
// additional point that gets highlighted like the trendline or raw line point
...(secondaryHighlightedMetric ? [getSecondaryHighlightPoint(lineOptions, secondaryHighlightedMetric)] : []),
+ // hover value label: background halo + foreground text — omitted when showHoverLabel is false
+ ...(showHoverLabel ? getHoverValueLabelMarks(lineOptions) : []),
// get interactive marks for the line
...getInteractiveMarks(dataSource, lineOptions),
];
};
+/**
+ * Two text marks (background halo + foreground) showing the metric value adjacent to the hovered point.
+ * Reads from hoverLabelData which carries cascade transforms — when multiple series share the same
+ * hovered dimension (dimensionHover), labels are spread apart using the same cascading formula as
+ * direct labels rather than stacking on top of each other.
+ */
+const getHoverValueLabelMarks = (lineOptions: LineMarkOptions): TextMark[] => {
+ const { color, colorScheme, dimension, hoverLabelKey, metric, metricAxis, name, scaleType } = lineOptions;
+ const labelField = hoverLabelKey ?? metric;
+ const yScaleName = metricAxis || 'yLinear';
+
+ const scaleName = getScaleName('x', scaleType);
+ const xField = scaleType === 'time' ? DEFAULT_TRANSFORMED_TIME_DIMENSION : dimension;
+ // Offset between the hover point center and the label.
+ const LABEL_POINT_GAP = 9;
+ // Flip label to the left side when the label would overflow the right edge of the chart.
+ const nearRightEdge = `scale('${scaleName}', datum['${xField}']) + getLabelWidth(datum["${labelField}"], 'bold', ${CHART_SIZE_FONT_SIZE}) + ${LABEL_POINT_GAP} > width`;
+
+ // Cascade correction only — no fixed anchor offset. Labels default to the natural y of each
+ // hover point; the formula only pushes them apart when they would otherwise collide.
+ const cascadeOffset = `datum._hover_cumMaxAdjusted + datum._hover_metricRank * ${MIN_LABEL_GAP} - datum._hover_scaledY`;
+
+ return getDirectLabelTextMarks(
+ `${name}_hoverLabelBg`,
+ `${name}_hoverLabel`,
+ `${name}_hoverLabelData`,
+ `datum["${labelField}"]`,
+ {
+ x: getXProductionRule(scaleType, dimension),
+ y: [{ scale: yScaleName, field: metric, offset: { signal: cascadeOffset } }],
+ additional: {
+ dx: { signal: `${nearRightEdge} ? -${LABEL_POINT_GAP} : ${LABEL_POINT_GAP}` },
+ align: { signal: `${nearRightEdge} ? 'right' : 'left'` },
+ baseline: { value: 'middle' },
+ },
+ },
+ colorScheme,
+ { signal: getColorProductionRuleSignalString(color, colorScheme) }
+ );
+};
+
const getHoverRule = (dimension: string, name: string, scaleType: ScaleType): RuleMark => {
return {
name: `${name}_hoverRule`,
diff --git a/packages/vega-spec-builder-s2/src/line/linePointAnnotation/linePointAnnotationUtils.ts b/packages/vega-spec-builder-s2/src/line/linePointAnnotation/linePointAnnotationUtils.ts
index 375e3d585..e62615fdb 100644
--- a/packages/vega-spec-builder-s2/src/line/linePointAnnotation/linePointAnnotationUtils.ts
+++ b/packages/vega-spec-builder-s2/src/line/linePointAnnotation/linePointAnnotationUtils.ts
@@ -11,10 +11,10 @@
*/
import { TextMark } from 'vega';
-import { BACKGROUND_COLOR, DIRECT_LABEL_BACKGROUND_STROKE_WIDTH, DIRECT_LABEL_FONT_WEIGHT, LINE_POINT_ANNOTATION_OFFSET } from '@spectrum-charts/constants';
-import { getS2ColorValue } from '@spectrum-charts/themes';
+import { LINE_POINT_ANNOTATION_OFFSET } from '@spectrum-charts/constants';
import { LinePointAnnotationOptions, LinePointAnnotationSpecOptions, LineSpecOptions } from '../../types';
+import { getLabelTransformTextMarks } from '../directLabelUtils';
export const getLinePointAnnotationSpecOptions = (
{ anchor = ['right', 'top', 'bottom', 'left'], matchLineColor = false, textKey }: LinePointAnnotationOptions,
@@ -40,65 +40,21 @@ export const getLinePointAnnotations = (lineOptions: LineSpecOptions): LinePoint
export const getLinePointAnnotationMarks = (lineOptions: LineSpecOptions): TextMark[] => {
return getLinePointAnnotations(lineOptions).flatMap((annotation) => {
const { anchor, matchLineColor, name: linePointAnnotationName, textKey } = annotation;
- const bgMarkName = `${linePointAnnotationName}_bg`;
-
- // Background mark: runs the label transform for collision-avoiding placement.
- // Uses transparent fill + background-color stroke for the halo.
- // Vega's canvas renderer draws fill then stroke, so using a single mark with both
- // would have the stroke cover the fill. Two marks avoids this.
- const backgroundMark: TextMark = {
- name: bgMarkName,
- type: 'text',
- interactive: false,
- from: { data: `${lineOptions.name}_staticPoints` },
- encode: {
- enter: {
- text: { signal: `datum.datum.${textKey}` },
- fill: { value: 'transparent' },
- stroke: { signal: BACKGROUND_COLOR },
- strokeWidth: { value: DIRECT_LABEL_BACKGROUND_STROKE_WIDTH },
- },
- update: {
- fontWeight: { value: DIRECT_LABEL_FONT_WEIGHT },
- },
- },
- transform: [
- {
- type: 'label',
- size: { signal: '[width, height]' },
- anchor: Array.isArray(anchor) ? anchor : [anchor],
- offset: [LINE_POINT_ANNOTATION_OFFSET],
- },
- ],
- };
-
- // Foreground mark: reads from the background mark to inherit its label-transform-computed
- // positions (x, y, align, baseline, opacity).
- // datum.fill navigates: foreground.datum → bgMark item → bgMark.datum → staticPoint item → fill = series color
- const labelFill = matchLineColor
- ? { field: 'datum.fill' }
- : { value: getS2ColorValue('gray-900', lineOptions.colorScheme) };
- const foregroundMark: TextMark = {
- name: linePointAnnotationName,
- type: 'text',
- interactive: false,
- from: { data: bgMarkName },
- encode: {
- enter: {
- fill: labelFill,
- },
- update: {
- text: { field: 'text' },
- x: { field: 'x' },
- y: { field: 'y' },
- align: { field: 'align' },
- baseline: { field: 'baseline' },
- opacity: { field: 'opacity' },
- fontWeight: { value: DIRECT_LABEL_FONT_WEIGHT },
- },
+ const foregroundFill = matchLineColor ? { field: 'datum.fill' } : undefined;
+
+ return getLabelTransformTextMarks(
+ `${linePointAnnotationName}_bg`,
+ linePointAnnotationName,
+ `${lineOptions.name}_staticPoints`,
+ `datum.datum.${textKey}`,
+ lineOptions.colorScheme,
+ {
+ type: 'label',
+ size: { signal: '[width, height]' },
+ anchor: Array.isArray(anchor) ? anchor : [anchor],
+ offset: [LINE_POINT_ANNOTATION_OFFSET],
},
- };
-
- return [backgroundMark, foregroundMark];
+ foregroundFill
+ );
});
};
diff --git a/packages/vega-spec-builder-s2/src/line/linePointUtils.test.ts b/packages/vega-spec-builder-s2/src/line/linePointUtils.test.ts
index 611edcb90..66535cbdd 100644
--- a/packages/vega-spec-builder-s2/src/line/linePointUtils.test.ts
+++ b/packages/vega-spec-builder-s2/src/line/linePointUtils.test.ts
@@ -50,6 +50,7 @@ describe('getHighlightPoint()', () => {
expect(mark.encode?.enter).toBeDefined();
expect(mark.encode?.update).toBeDefined();
expect(mark.encode?.enter?.y).toEqual([{ field: 'value', scale: 'yLinear' }]);
+ expect(mark.encode?.enter?.fill).toEqual({ signal: BACKGROUND_COLOR });
expect(mark.encode?.enter?.stroke).toEqual({ field: DEFAULT_COLOR, scale: COLOR_SCALE });
expect(mark.encode?.enter?.strokeWidth).toEqual({ signal: CHART_SIZE_HOVER_STROKE_WIDTH });
});
@@ -93,6 +94,7 @@ describe('getSelectionPoint()', () => {
expect(mark.encode?.enter).toBeDefined();
expect(mark.encode?.update).toBeDefined();
expect(mark.encode?.enter?.y).toEqual([{ field: 'value', scale: 'yLinear' }]);
+ expect(mark.encode?.enter?.fill).toEqual({ signal: BACKGROUND_COLOR });
expect(mark.encode?.enter?.stroke).toEqual({ field: DEFAULT_COLOR, scale: COLOR_SCALE });
expect(mark.encode?.enter?.strokeWidth).toEqual({ signal: CHART_SIZE_HOVER_STROKE_WIDTH });
});
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 186b40d86..72cc9a07a 100644
--- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.test.ts
@@ -21,6 +21,7 @@ import {
DEFAULT_TIME_DIMENSION,
DEFAULT_TRANSFORMED_TIME_DIMENSION,
FILTERED_TABLE,
+ GROUP_ID,
HOVERED_ITEM,
CHART_SIZE_POINT_SIZE,
LINEAR_PADDING,
@@ -350,6 +351,26 @@ describe('lineSpecBuilder', () => {
expect(addData(baseData, { ...defaultLineOptions, scaleType: 'linear' })).toEqual(baseData);
});
+ test('with dimensionHover adds groupId formula transform to table', () => {
+ const resultData = addData(baseData, { ...defaultLineOptions, dimensionHover: true });
+ const tableData = resultData.find((d) => d.name === TABLE);
+ expect(tableData?.transform).toContainEqual({
+ type: 'formula',
+ as: `line0_${GROUP_ID}`,
+ expr: `datum.${DEFAULT_TIME_DIMENSION}`,
+ });
+ });
+
+ test('with dimensionHover and a chartInspect that groups by dimension does not add groupId transform', () => {
+ const resultData = addData(baseData, {
+ ...defaultLineOptions,
+ dimensionHover: true,
+ chartInspects: [{ highlightBy: 'dimension' }],
+ });
+ const tableData = resultData.find((d) => d.name === TABLE);
+ expect(tableData?.transform?.some((t) => 'as' in t && t.as === `line0_${GROUP_ID}`)).toBe(false);
+ });
+
test('should add trendline transform', () => {
expect(
addData(baseData, {
diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts
index 1d8393a59..655a360d1 100644
--- a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts
@@ -27,7 +27,7 @@ import {
import { toCamelCase } from '@spectrum-charts/utils';
import { addPopoverData } from '../chartPopover/chartPopoverUtils';
-import { addInspectData, addInspectSignals, isHighlightedByGroup } from '../chartInspect/chartInspectUtils';
+import { addInspectData, addInspectSignals, getGroupIdTransform, isHighlightedByGroup } from '../chartInspect/chartInspectUtils';
import { addTimeTransform, getFilteredInspectData, getTableData } from '../data/dataUtils';
import { getHoverMarkNames, getInteractiveMarkName, isInteractive } from '../marks/markUtils';
import { getMetricRangeData, getMetricRangeGroupMarks, getMetricRanges } from '../metricRange/metricRangeUtils';
@@ -40,7 +40,7 @@ import { getForecastAlternateFlagTransform, getForecastEffectiveValueTransform,
import { getLinePointAnnotationMarks } from './linePointAnnotation';
import { addTrendlineData, getTrendlineMarks, getTrendlineScales, setTrendlineSignals } from '../trendline';
import { ColorScheme, HighlightedItem, LineOptions, LineSpecOptions, ScSpec } from '../types';
-import { getLineHighlightedData, getLineStaticPointData, getPrimarySeriesFacetData, getPrimarySeriesOtherExpr } from './lineDataUtils';
+import { getHoverLabelData, getLineHighlightedData, getLineStaticPointData, getPrimarySeriesFacetData, getPrimarySeriesOtherExpr } from './lineDataUtils';
import { getHighlightedSeriesOpacityRules, getLineGradientMark, getLineHighlightOverlayGroup, getLineHoverMarks, getLineMark } from './lineMarkUtils';
import { getLineStaticPoint, getLineStaticPointBackground } from './linePointUtils';
import { getPopoverMarkName, isDualMetricAxis } from './lineUtils';
@@ -88,10 +88,14 @@ export const addLine = produce<
alternateSegmentLabel,
primarySeries,
otherSeriesColor,
+ showHoverLabel = true,
+ dimensionHover = false,
...options
}
) => {
const lineName = toCamelCase(name || `line${index}`);
+ // ChartInspect owns the hover story when present — suppress the hover value label
+ const effectiveShowHoverLabel = chartInspects.length > 0 ? false : showHoverLabel;
// put options back together now that all defaults are set
const lineOptions: LineSpecOptions = {
chartPopovers,
@@ -135,9 +139,11 @@ export const addLine = produce<
alternateSegmentLabel,
primarySeries,
otherSeriesColor,
+ showHoverLabel: effectiveShowHoverLabel,
+ dimensionHover,
...options,
};
- lineOptions.isHighlightedByGroup = isHighlightedByGroup(lineOptions);
+ lineOptions.isHighlightedByGroup = isHighlightedByGroup(lineOptions) || dimensionHover;
spec.usermeta = addUserMetaInteractiveMark(spec.usermeta, lineOptions.interactiveMarkName);
spec.data = addData(spec.data ?? [], lineOptions);
@@ -150,20 +156,24 @@ export const addLine = produce<
);
export const addData = produce((data, options) => {
- const { alternateSegmentKey, chartInspects, dimension, forecasts, highlightedItem, isSparkline, isMethodLast, metric, name, scaleType, primarySeries, staticPoint } =
+ const { alternateSegmentKey, chartInspects, dimension, dimensionHover, forecasts, highlightedItem, isSparkline, isMethodLast, metric, name, scaleType, primarySeries, staticPoint } =
options;
const tableData = getTableData(data);
if (scaleType === 'time') {
tableData.transform = addTimeTransform(tableData.transform ?? [], dimension);
}
+ const inspectAlreadyGroupsByDimension = chartInspects.some(({ highlightBy }) => highlightBy === 'dimension');
+ if (dimensionHover && !inspectAlreadyGroupsByDimension) {
+ tableData.transform = tableData.transform ?? [];
+ tableData.transform.push(getGroupIdTransform([dimension], name));
+ }
if (alternateSegmentKey) {
tableData.transform = tableData.transform ?? [];
tableData.transform.push({ type: 'formula', as: `${name}_alternateFlag`, expr: `datum["${alternateSegmentKey}"]` });
// time data was transformed above, so we need to use the transformed dimension
const dimSortField = scaleType === 'time' ? `${dimension}0` : dimension;
data.push(...getAlternateSegmentData(name, dimSortField));
- }
- if (!alternateSegmentKey && forecasts.length > 0) {
+ } else if (forecasts.length > 0) {
tableData.transform = tableData.transform ?? [];
const dimSortField = scaleType === 'time' ? `${dimension}0` : dimension;
tableData.transform.push(
@@ -171,12 +181,14 @@ export const addData = produce((data, options) => {
getForecastEffectiveValueTransform(name, metric, forecasts[0].metric)
);
data.push(...getAlternateSegmentData(name, dimSortField));
- }
- if (primarySeries && !alternateSegmentKey && !forecasts.length) {
+ } else if (primarySeries) {
data.push(getPrimarySeriesFacetData(name, primarySeries));
}
if (isInteractive(options) || highlightedItem !== undefined) {
data.push(getLineHighlightedData(options), getFilteredInspectData(chartInspects));
+ if (options.showHoverLabel) {
+ data.push(getHoverLabelData(options));
+ }
}
if (staticPoint || isSparkline) {
data.push(getLineStaticPointData(name, staticPoint, FILTERED_TABLE, isSparkline, isMethodLast));
diff --git a/packages/vega-spec-builder-s2/src/line/lineTestUtils.ts b/packages/vega-spec-builder-s2/src/line/lineTestUtils.ts
index c51f14975..1bda9bebd 100644
--- a/packages/vega-spec-builder-s2/src/line/lineTestUtils.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineTestUtils.ts
@@ -62,4 +62,6 @@ export const defaultLineOptions: LineSpecOptions = {
trendlines: [],
interpolate: undefined,
alternateSegmentLineType: 'dotted',
+ dimensionHover: false,
+ showHoverLabel: true,
};
diff --git a/packages/vega-spec-builder-s2/src/line/lineUtils.ts b/packages/vega-spec-builder-s2/src/line/lineUtils.ts
index aee3cc3c0..84b933506 100644
--- a/packages/vega-spec-builder-s2/src/line/lineUtils.ts
+++ b/packages/vega-spec-builder-s2/src/line/lineUtils.ts
@@ -79,6 +79,9 @@ export interface LineMarkOptions {
scaleType: ScaleType;
scatterPaths?: ScatterPathOptions[];
segmentLabels?: SegmentLabelOptions[];
+ dimensionHover?: boolean;
+ hoverLabelKey?: string;
+ showHoverLabel?: boolean;
staticPoint?: string;
trendlines?: TrendlineOptions[];
interpolate?: InterpolationType;
diff --git a/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.test.ts b/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.test.ts
index 19af33ed5..6f6a480c1 100644
--- a/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.test.ts
+++ b/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.test.ts
@@ -56,6 +56,8 @@ const defaultLineOptions: LineSpecOptions = {
trendlines: [],
lineCap: 'round',
interpolate: undefined,
+ dimensionHover: false,
+ showHoverLabel: true,
};
const defaultLabelSpecOptions: LineDirectLabelSpecOptions = {
@@ -218,7 +220,7 @@ describe('getLineDirectLabelData', () => {
const transforms = getTransforms(data);
const formula = transforms.find((t) => t.type === 'formula' && 'as' in t && t.as === '_adjustedY');
expect(formula).toBeDefined();
- expect((formula as { expr: string }).expr).toBe('datum._scaledY - datum._metricRank * 12');
+ expect((formula as { expr: string }).expr).toBe('datum._scaledY - datum._metricRank * rscChartSizeLabelGap');
});
test('includes _cumMaxAdjusted cumulative window transform', () => {
@@ -392,7 +394,8 @@ describe('getLineDirectLabelMarks', () => {
const evalOffsetSignal = (datum: Record): number => {
const marks = getLineDirectLabelMarks('line0', defaultLabelSpecOptions, defaultLineOptions, 'gray-50', 'light');
const signal = (marks[0].encode?.enter?.y as { offset: { signal: string } }).offset.signal;
- return new Function('datum', `return ${signal}`)(datum);
+ // rscChartSizeLabelGap is a Vega signal; inject its M-tier value (12) for unit evaluation
+ return new Function('datum', 'rscChartSizeLabelGap', `return ${signal}`)(datum, 12);
};
test('rank 1 always places label 12px above the line terminus', () => {
diff --git a/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.ts b/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.ts
index e1c4bc0c0..6841b36cd 100644
--- a/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.ts
+++ b/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.ts
@@ -14,6 +14,8 @@ import { Data, Mark, NumericValueRef, ProductionRule, TextMark, Transforms } fro
import { CHART_SIZE_FONT_SIZE, DIRECT_LABEL_BACKGROUND_STROKE_WIDTH, DIRECT_LABEL_FONT_WEIGHT, FILTERED_TABLE, SERIES_ID } from '@spectrum-charts/constants';
import { getS2ColorValue } from '@spectrum-charts/themes';
+import { getCascadeTransforms, MIN_LABEL_GAP } from '../line/directLabelUtils';
+
import { getPrimarySeriesOtherExpr } from '../line/lineDataUtils';
import { getLineOpacity } from '../line/lineMarkUtils';
import { getColorProductionRule } from '../marks/markUtils';
@@ -21,8 +23,6 @@ import { getScaleName } from '../scale/scaleSpecBuilder';
import { getDimensionField, getFacetsFromOptions } from '../specUtils';
import { LineDirectLabelOptions, LineDirectLabelSpecOptions, LineSpecOptions, LabelValue } from '../types';
-const MIN_LABEL_GAP = 12;
-
/**
* Derived dataset: one row per series at the last (max-dimension) data point.
*/
@@ -84,28 +84,7 @@ export const getLineDirectLabelData = (
},
]
: []),
- {
- type: 'joinaggregate' as const,
- fields: [metric],
- ops: ['count' as const],
- as: ['_seriesCount'],
- },
- {
- type: 'window' as const,
- sort: { field: [metric], order: ['descending' as const] },
- ops: ['rank' as const],
- as: ['_metricRank'],
- },
- { type: 'formula' as const, as: '_scaledY', expr: `scale('${yScaleName}', datum["${metric}"])` },
- { type: 'formula' as const, as: '_adjustedY', expr: `datum._scaledY - datum._metricRank * ${MIN_LABEL_GAP}` },
- {
- type: 'window' as const,
- sort: { field: ['_metricRank'], order: ['ascending' as const] },
- frame: [null, 0] as [null, number],
- ops: ['max' as const],
- fields: ['_adjustedY'],
- as: ['_cumMaxAdjusted'],
- },
+ ...getCascadeTransforms(yScaleName, metric, ''),
];
return {
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 173f39f63..c23c63d48 100644
--- a/packages/vega-spec-builder-s2/src/metricRange/metricRangeUtils.test.ts
+++ b/packages/vega-spec-builder-s2/src/metricRange/metricRangeUtils.test.ts
@@ -76,6 +76,8 @@ const defaultLineOptions: LineSpecOptions = {
trendlines: [],
lineCap: 'round',
interpolate: undefined,
+ dimensionHover: false,
+ showHoverLabel: true,
};
const basicMetricRangeMarks = [
diff --git a/packages/vega-spec-builder-s2/src/specTestUtils.ts b/packages/vega-spec-builder-s2/src/specTestUtils.ts
index 3395a8bde..c9abb6af7 100644
--- a/packages/vega-spec-builder-s2/src/specTestUtils.ts
+++ b/packages/vega-spec-builder-s2/src/specTestUtils.ts
@@ -16,6 +16,8 @@ import {
CHART_SIZE_FONT_SIZE,
CHART_SIZE_HOVER_STROKE_WIDTH,
CHART_SIZE_HOVER_STROKE_WIDTHS,
+ CHART_SIZE_LABEL_GAP,
+ CHART_SIZE_LABEL_GAPS,
CHART_SIZE_POINT_SIZE,
CHART_SIZE_POINT_SIZES,
CHART_SIZE_STROKE_WIDTH,
@@ -60,6 +62,11 @@ export const defaultChartSizeFontSizeSignal = {
update: `rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.M} ? ${DIRECT_LABEL_FONT_SIZE_S} : rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.L} ? ${DIRECT_LABEL_FONT_SIZE_M} : ${DIRECT_LABEL_FONT_SIZE_L}`,
};
+export const defaultChartSizeLabelGapSignal = {
+ name: CHART_SIZE_LABEL_GAP,
+ update: `rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.M} ? ${CHART_SIZE_LABEL_GAPS.S} : rscContainerWidth(width) < ${CHART_SIZE_BREAKPOINTS.L} ? ${CHART_SIZE_LABEL_GAPS.M} : ${CHART_SIZE_LABEL_GAPS.L}`,
+};
+
export const defaultSignals: Signal[] = [
defaultHighlightedItemSignal,
defaultHighlightedGroupSignal,
@@ -71,4 +78,5 @@ export const defaultSignals: Signal[] = [
defaultChartSizeHoverStrokeWidthSignal,
defaultChartSizePointSizeSignal,
defaultChartSizeFontSizeSignal,
+ defaultChartSizeLabelGapSignal,
];
diff --git a/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.test.ts b/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.test.ts
index c7663d619..f23bf7c0d 100644
--- a/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.test.ts
+++ b/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.test.ts
@@ -56,11 +56,13 @@ describe('getTrendlineMarks()', () => {
expect(marks[1]).toHaveProperty('type', 'group');
const trendlineMarks = (marks[1] as GroupMark).marks as Mark[];
// line mark
- expect(trendlineMarks).toHaveLength(4);
+ expect(trendlineMarks).toHaveLength(6);
expect(trendlineMarks[0]).toHaveProperty('type', 'rule');
expect(trendlineMarks[1]).toHaveProperty('type', 'symbol'); // highlight point
- expect(trendlineMarks[2]).toHaveProperty('type', 'symbol'); // voronoi points
- expect(trendlineMarks[3]).toHaveProperty('type', 'path'); // voronoi path
+ expect(trendlineMarks[2]).toHaveProperty('type', 'text'); // hover label background
+ expect(trendlineMarks[3]).toHaveProperty('type', 'text'); // hover label foreground
+ expect(trendlineMarks[4]).toHaveProperty('type', 'symbol'); // voronoi points
+ expect(trendlineMarks[5]).toHaveProperty('type', 'path'); // voronoi path
});
test('should reference _data for window method', () => {
const marks = getTrendlineMarks({
diff --git a/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts b/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts
index 39abf9890..d4e5945ef 100644
--- a/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts
+++ b/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts
@@ -46,6 +46,8 @@ export const defaultLineOptions: LineSpecOptions = {
lineCap: 'round',
interpolate: undefined,
alternateSegmentLineType: 'dotted',
+ dimensionHover: false,
+ showHoverLabel: true,
};
export const defaultTrendlineOptions: TrendlineSpecOptions = {
diff --git a/packages/vega-spec-builder-s2/src/types/marks/lineSpec.types.ts b/packages/vega-spec-builder-s2/src/types/marks/lineSpec.types.ts
index 4cb75ce2b..5cd9aac19 100644
--- a/packages/vega-spec-builder-s2/src/types/marks/lineSpec.types.ts
+++ b/packages/vega-spec-builder-s2/src/types/marks/lineSpec.types.ts
@@ -106,6 +106,23 @@ export interface LineOptions {
* Accepts any Spectrum 2 color token (e.g. `'gray-400'`) or CSS color value.
*/
otherSeriesColor?: string;
+ /**
+ * If `true`, all series at the hovered x-dimension highlight simultaneously instead of
+ * only the nearest series. Hover value labels show for every series at that dimension.
+ * @default false
+ */
+ dimensionHover?: boolean;
+ /**
+ * If `true`, shows the metric value as a label adjacent to the hovered data point.
+ * Suppressed when a `` child is present.
+ * @default true
+ */
+ showHoverLabel?: boolean;
+ /**
+ * Data field key to display in the hover value label. Defaults to the `metric` field.
+ * Use this to show a pre-formatted or alternate field (e.g. `'displayValue'`) instead of the raw metric.
+ */
+ hoverLabelKey?: string;
// children
chartPopovers?: ChartPopoverOptions[];
@@ -122,6 +139,7 @@ type LineOptionsWithDefaults =
| 'chartInspects'
| 'color'
| 'dimension'
+ | 'dimensionHover'
| 'forecasts'
| 'gradient'
| 'hasOnClick'
@@ -135,6 +153,7 @@ type LineOptionsWithDefaults =
| 'name'
| 'opacity'
| 'scaleType'
+ | 'showHoverLabel'
| 'trendlines';
export interface LineSpecOptions extends PartiallyRequired {
diff --git a/planning/issues/hover-rightmost-point-chart-shift.md b/planning/issues/hover-rightmost-point-chart-shift.md
new file mode 100644
index 000000000..5fb9283df
--- /dev/null
+++ b/planning/issues/hover-rightmost-point-chart-shift.md
@@ -0,0 +1,29 @@
+# Issue: Hovering the Rightmost Data Point Causes a Slight Horizontal Chart Shift
+
+**Status:** Open
+
+## Symptom
+
+When hovering over the rightmost data point on a line chart, the entire chart — including axes — shifts slightly to the left. Moving off the rightmost point shifts it back. The shift is horizontal only and affects all marks and axes in the plot area. It occurs regardless of whether hover labels are enabled.
+
+Confirmed on: `WithoutHoverLabel`, `WithHoverLabel`, `DimensionHover` stories in `React Spectrum Charts 2/Line/Features/HoverLabel`.
+
+---
+
+## Root Cause
+
+Unknown. Investigation narrowed it down to a pre-existing issue in the hover marks (hover rule, highlight point), not the hover label feature. The chart uses `autosize: { type: 'fit', contains: 'padding', resize: true }`, which reruns layout on every update. The leading hypothesis is that a hover mark at the rightmost data point overflows the plot area boundary by a pixel or two — either the hover rule's 1px stroke bleeding past `x = width`, or the highlight point's stroke at the chart edge — causing Vega to add right padding, compressing the plot width, and shifting all content left. When the hover moves off the rightmost point, the padding reverts.
+
+---
+
+## Relevant Files
+
+| File | Role |
+|---|---|
+| `vega-spec-builder-s2/src/line/lineMarkUtils.ts` | `getHoverRule()`, `getHighlightPoint()` — the hover marks that render at the rightmost point |
+| `vega-spec-builder-s2/src/line/linePointUtils.ts` | `getHighlightPoint()` — highlight point size and stroke |
+| `react-spectrum-charts-s2/src/hooks/useSpec.tsx` | Sets `autosize: { type: 'fit', contains: 'padding', resize: true }` |
+| `themes/src/spectrum2Theme.ts` | Also sets `autosize: { type: 'fit', contains: 'padding', resize: true }` |
+
+---
+