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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ cursor

# catch all for temporary outputs
tmp/

.agents/
.scout/
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const Line: FC<LineProps> = ({
alternateSegmentKey,
alternateSegmentLineType,
alternateSegmentLabel,
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,
};
119 changes: 119 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,119 @@
/*
* 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, 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 };

// 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 },
};

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,
xEncoding: PositionRef,
yEncoding: PositionRef,
colorScheme: ColorScheme,
additionalUpdateEncode: Record<string, unknown> = {}
): TextMark[] => [
{
name: backgroundMarkName,
type: 'text',
interactive: false,
from: { data: dataSource },
encode: {
enter: { text: { signal: textSignal }, ...directLabelBackgroundStyle },
update: { x: xEncoding, y: yEncoding, ...additionalUpdateEncode } as never,
},
},
{
name: foregroundMarkName,
type: 'text',
interactive: false,
from: { data: dataSource },
encode: {
enter: {
text: { signal: textSignal },
fill: getDirectLabelForegroundFill(colorScheme),
fontWeight: { value: DIRECT_LABEL_FONT_WEIGHT },
},
update: { x: xEncoding, y: yEncoding, ...additionalUpdateEncode } 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 },
},
},
},
];
3 changes: 1 addition & 2 deletions packages/vega-spec-builder-s2/src/line/lineDataUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
SELECTED_ITEM,
} from '@spectrum-charts/constants';

import { isHighlightedByGroup } from '../chartInspect/chartInspectUtils';
import { hasPopover, isInteractive } from '../marks/markUtils';
import { LineSpecOptions } from '../types';

Expand All @@ -37,7 +36,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}`;
Expand Down
74 changes: 69 additions & 5 deletions packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { ArrayValueRef, LineMark, Mark, NumericValueRef, ProductionRule, RuleMark } from 'vega';
import { ArrayValueRef, LineMark, Mark, NumericValueRef, ProductionRule, RuleMark, TextMark } from 'vega';

import {
CHART_SIZE_STROKE_WIDTH,
Expand All @@ -18,6 +18,7 @@ import {
CONTROLLED_HIGHLIGHTED_TABLE,
DEFAULT_INTERACTION_MODE,
DEFAULT_OPACITY_RULE,
DEFAULT_TRANSFORMED_TIME_DIMENSION,
FADE_FACTOR,
HOVERED_ITEM,
LAST_RSC_SERIES_ID,
Expand All @@ -40,7 +41,9 @@ import {
} from '../marks/markUtils';
import { getStrokeDashFromLineType } from '../specUtils';
import { getDualAxisScaleNames } from '../scale/scaleUtils';
import { getScaleName } from '../scale/scaleSpecBuilder';
import { ScaleType } from '../types';
import { getDirectLabelTextMarks } from './directLabelUtils';
import {
getHighlightBackgroundPoint,
getHighlightPoint,
Expand Down Expand Up @@ -215,12 +218,11 @@ export const getLineMark = (lineMarkOptions: LineMarkOptions, dataSource: string
? getAlternateSegmentStrokeDash(name, lineType, alternateSegmentLineType)
: getStrokeDashProductionRule(lineType),
strokeOpacity: getOpacityProductionRule(opacity),
strokeWidth: { signal: CHART_SIZE_STROKE_WIDTH },
},
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),
strokeWidth: getLineStrokeWidth(lineMarkOptions),
...(popoverWithDimensionHighlightExists ? {} : { opacity: getLineOpacity(lineMarkOptions) }),
...(interpolate ? { interpolate: { value: interpolate } } : {}),
},
Expand Down Expand Up @@ -285,6 +287,34 @@ export const getLineOpacity = ({
return strokeOpacityRules;
};

export const getLineStrokeWidth = ({
interactiveMarkName,
isHighlightedByGroup,
}: LineMarkOptions): ProductionRule<NumericValueRef> => {
if (!interactiveMarkName) return [{ signal: CHART_SIZE_STROKE_WIDTH }];

const normal = CHART_SIZE_STROKE_WIDTH;
const thickened = `${CHART_SIZE_STROKE_WIDTH} + 0.5`;

if (isHighlightedByGroup) {
return [
{
test: `length(data('${interactiveMarkName}_highlightedData'))`,
signal: `indexof(pluck(data('${interactiveMarkName}_highlightedData'), '${SERIES_ID}'), datum.${SERIES_ID}) !== -1 ? ${thickened} : ${normal}`,
},
{ signal: normal },
];
}

return [
{
test: `isValid(${interactiveMarkName}_${HOVERED_ITEM})`,
signal: `${interactiveMarkName}_${HOVERED_ITEM}.${SERIES_ID} === datum.${SERIES_ID} ? ${thickened} : ${normal}`,
},
{ signal: normal },
];
};

/**
* All the marks that get displayed when hovering or selecting a point on a line
* @param lineMarkOptions
Expand All @@ -297,7 +327,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),
Expand All @@ -309,11 +339,45 @@ 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.
* Uses the same visual style as LinePointAnnotation so both always match.
* Both marks read directly from ${name}_highlightedData — no label transform needed since position
* is computed directly from the data rather than via Vega's label layout algorithm.
*/
const getHoverValueLabelMarks = (lineOptions: LineMarkOptions): TextMark[] => {
const { colorScheme, dimension, hoverLabelKey, metric, name, scaleType } = lineOptions;
const labelField = hoverLabelKey ?? metric;

const scaleName = getScaleName('x', scaleType);
const xField = scaleType === 'time' ? DEFAULT_TRANSFORMED_TIME_DIMENSION : dimension;
// Flip label to the left side when the hovered point is in the right 20% of the chart,
// preventing the text from overflowing the chart boundary and causing flicker.
const nearRightEdge = `scale('${scaleName}', datum['${xField}']) > width * 0.8`;

return getDirectLabelTextMarks(
`${name}_hoverLabelBg`,
`${name}_hoverLabel`,
`${name}_highlightedData`,
`datum["${labelField}"]`,
getXProductionRule(scaleType, dimension),
getLineYEncoding(lineOptions, metric),
colorScheme,
{
dx: { signal: `${nearRightEdge} ? -8 : 8` },
align: { signal: `${nearRightEdge} ? 'right' : 'left'` },
baseline: { value: 'middle' },
}
);
};

const getHoverRule = (dimension: string, name: string, scaleType: ScaleType): RuleMark => {
return {
name: `${name}_hoverRule`,
Expand Down
Loading
Loading