diff --git a/.gitignore b/.gitignore index 03a5e8505..b41cf7df4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ cursor # catch all for temporary outputs tmp/ + +.agents/ +.scout/ \ No newline at end of file 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 144f27597..643f7b39d 100644 --- a/packages/react-spectrum-charts-s2/src/components/Line/Line.tsx +++ b/packages/react-spectrum-charts-s2/src/components/Line/Line.tsx @@ -34,6 +34,8 @@ const Line: FC = ({ alternateSegmentKey, alternateSegmentLineType, alternateSegmentLabel, + 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/line/directLabelUtils.ts b/packages/vega-spec-builder-s2/src/line/directLabelUtils.ts new file mode 100644 index 000000000..9961b7998 --- /dev/null +++ b/packages/vega-spec-builder-s2/src/line/directLabelUtils.ts @@ -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; +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 = {} +): 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 }, + }, + }, + }, +]; diff --git a/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts b/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts index 4ffa06d56..1e937e682 100644 --- a/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts +++ b/packages/vega-spec-builder-s2/src/line/lineDataUtils.ts @@ -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'; @@ -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}`; diff --git a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts index 285fb0557..7a0416d8b 100644 --- a/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts +++ b/packages/vega-spec-builder-s2/src/line/lineMarkUtils.ts @@ -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, @@ -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, @@ -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, @@ -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 } } : {}), }, @@ -285,6 +287,34 @@ export const getLineOpacity = ({ return strokeOpacityRules; }; +export const getLineStrokeWidth = ({ + interactiveMarkName, + isHighlightedByGroup, +}: LineMarkOptions): ProductionRule => { + 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 @@ -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), @@ -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`, 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 5c0d91cc1..3d5a5de5f 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,8 @@ */ import { TextMark } 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 { LinePointAnnotationOptions, LinePointAnnotationSpecOptions, LineSpecOptions } from '../../types'; +import { getLabelTransformTextMarks } from '../directLabelUtils'; export const getLinePointAnnotationSpecOptions = ( { anchor = ['right', 'top', 'bottom', 'left'], matchLineColor = false, textKey }: LinePointAnnotationOptions, @@ -40,64 +38,20 @@ 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 }, - }, + const foregroundFill = matchLineColor ? { field: 'datum.stroke' } : 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], }, - transform: [ - { - type: 'label', - size: { signal: '[width, height]' }, - anchor: Array.isArray(anchor) ? anchor : [anchor], - }, - ], - }; - - // Foreground mark: reads from the background mark to inherit its label-transform-computed - // positions (x, y, align, baseline, opacity). - // When matchLineColor is true, the fill uses the series color from the static point stroke (datum.stroke); otherwise defaults to black. - const labelFill = matchLineColor - ? { field: 'datum.stroke' } - : { 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 }, - }, - }, - }; - - return [backgroundMark, foregroundMark]; + foregroundFill + ); }); }; diff --git a/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts b/packages/vega-spec-builder-s2/src/line/lineSpecBuilder.ts index 2051e6831..0b0b83e2a 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'; @@ -86,10 +86,14 @@ export const addLine = produce< alternateSegmentKey, alternateSegmentLineType = 'dotted', alternateSegmentLabel, + 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, @@ -131,9 +135,11 @@ export const addLine = produce< alternateSegmentKey, alternateSegmentLineType, alternateSegmentLabel, + 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); @@ -146,12 +152,17 @@ export const addLine = produce< ); export const addData = produce((data, options) => { - const { alternateSegmentKey, chartInspects, dimension, forecasts, highlightedItem, isSparkline, isMethodLast, metric, name, scaleType, staticPoint } = + const { alternateSegmentKey, chartInspects, dimension, dimensionHover, forecasts, highlightedItem, isSparkline, isMethodLast, metric, name, scaleType, 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}"]` }); 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 f4e2d4b4e..6cd83e69f 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 e992454cc..68579c46c 100644 --- a/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/lineDirectLabel/lineDirectLabelUtils.test.ts @@ -54,6 +54,8 @@ const defaultLineOptions: LineSpecOptions = { trendlines: [], lineCap: 'round', interpolate: undefined, + dimensionHover: false, + showHoverLabel: true, }; const defaultLabelSpecOptions: LineDirectLabelSpecOptions = { 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 ca6944d02..628b968e2 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/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 26d5ac849..3c6ff477f 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 @@ -94,6 +94,23 @@ export interface LineOptions { * No default — omitting means no append. */ alternateSegmentLabel?: 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[]; @@ -110,6 +127,7 @@ type LineOptionsWithDefaults = | 'chartInspects' | 'color' | 'dimension' + | 'dimensionHover' | 'forecasts' | 'gradient' | 'hasOnClick' @@ -123,6 +141,7 @@ type LineOptionsWithDefaults = | 'name' | 'opacity' | 'scaleType' + | 'showHoverLabel' | 'trendlines'; export interface LineSpecOptions extends PartiallyRequired {