Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ cursor
# catch all for temporary outputs
tmp/

.agents/
.scout/
11 changes: 11 additions & 0 deletions packages/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we make a prior decision to make label gaps scale with chart size?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I decided to scale it because with the large size they were overlapping a little too much, I think I showed you when we had that call a few weeks ago. If it looks off though let me know

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks good. I just forgot over the break. We set the constants up so it's easy to tweak later. Good stuff!

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';
Expand Down
18 changes: 18 additions & 0 deletions packages/docs/docs/spectrum2/line.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,24 @@ The S2 `Line` component does not yet support `onMouseOver`, `onMouseOut`, `Metri
<td>–</td>
<td>Text appended to the hover value label for alternate-segment points (e.g. <code>'(Estimated)'</code>).</td>
</tr>
<tr>
<td>showHoverLabel</td>
<td>boolean</td>
<td>true</td>
<td>When true, shows the metric value as a label adjacent to the hovered data point. Suppressed when a <code>ChartInspect</code> child is present.</td>
</tr>
<tr>
<td>dimensionHover</td>
<td>boolean</td>
<td>false</td>
<td>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.</td>
</tr>
<tr>
<td>hoverLabelKey</td>
<td>string</td>
<td>(metric field)</td>
<td>Data field key to display in the hover value label. Defaults to the <code>metric</code> field. Use this to show a pre-formatted or alternate field instead of the raw metric value.</td>
</tr>
</tbody>
</table>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const Line: FC<LineProps> = ({
alternateSegmentLabel,
primarySeries,
otherSeriesColor,
dimensionHover = false,
showHoverLabel = true,
contextMenuMode = 'interaction',
}: LineProps) => {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof Line> = (args): ReactElement => {
const chartProps = useChartProps(defaultChartProps);
return (
<Chart {...chartProps}>
<Axis position="left" grid />
<Axis position="bottom" labelFormat="time" />
<Line {...args} />
<Legend lineWidth={{ value: 0 }} />
</Chart>
);
};

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<typeof Line> = (args): ReactElement => {
const chartProps = useChartProps({ data: manySeriesData, minWidth: 400, maxWidth: 800, height: 400 });
return (
<Chart {...chartProps}>
<Axis position="left" grid />
<Axis position="bottom" labelFormat="time" />
<Line {...args} />
</Chart>
);
};

export const DimensionHoverManySeries = bindWithProps(ManySeriesStory);
DimensionHoverManySeries.args = {
color: 'series',
dimension: 'datetime',
metric: 'value',
scaleType: 'time',
showHoverLabel: true,
dimensionHover: true,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/vega-spec-builder-s2/src/chartSpecBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -300,6 +307,7 @@ export const getDefaultSignals = ({
chartSizeHoverStrokeWidthSignal,
chartSizePointSizeSignal,
chartSizeFontSizeSignal,
chartSizeLabelGapSignal,
];
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import {
defaultChartSizeFontSizeSignal,
defaultChartSizeHoverStrokeWidthSignal,
defaultChartSizeLabelGapSignal,
defaultChartSizePointSizeSignal,
defaultChartSizeStrokeWidthSignal,
defaultHighlightedGroupSignal,
Expand Down Expand Up @@ -187,6 +188,7 @@ describe('addLegend()', () => {
defaultChartSizeHoverStrokeWidthSignal,
defaultChartSizePointSizeSignal,
defaultChartSizeFontSizeSignal,
defaultChartSizeLabelGapSignal,
{
name: `legend0_${HOVERED_SERIES}`,
value: null,
Expand Down
170 changes: 170 additions & 0 deletions packages/vega-spec-builder-s2/src/line/directLabelUtils.ts
Original file line number Diff line number Diff line change
@@ -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<NumericValueRef>;
type FillOverride = { field: string } | { value: string } | { signal: string };

export interface DirectLabelUpdateEncode {
x: PositionRef;
y: PositionRef;
additional?: Record<string, unknown>;
}

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 },
},
},
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('getLineHighlightedData()', () => {
...defaultLineOptions,
chartPopovers: [{}],
chartInspects: [{ highlightBy: 'dimension' }],
isHighlightedByGroup: true,
}).transform?.[0] as FilterTransform
).expr;
expect(expr.includes(GROUP_ID)).toBeTruthy();
Expand Down
Loading
Loading