From 22d5910d6f0a9bd532f18973d925c2711ab9ee4c Mon Sep 17 00:00:00 2001 From: Connor Lamoureux <29240999+c-lamoureux@users.noreply.github.com> Date: Sat, 16 May 2026 11:01:32 -0600 Subject: [PATCH] feat(AN-450465): add showEndCaps prop to Trendline for aggregate methods Adds optional arrowhead caps at both endpoints of average/median trendlines. Reuses REFERENCE_LINE_START_CAP_PATH / REFERENCE_LINE_END_CAP_PATH path marks already defined in constants; not supported for regression or window methods. Co-Authored-By: Claude Sonnet 4.6 --- .../Trendline/LineTrendline.story.tsx | 65 +++++++++++++ .../src/trendline/trendlineMarkUtils.test.ts | 96 ++++++++++++++++++- .../src/trendline/trendlineMarkUtils.ts | 90 ++++++++++++++++- .../src/trendline/trendlineTestUtils.ts | 1 + .../src/trendline/trendlineUtils.ts | 2 + .../marks/supplemental/trendlineSpec.types.ts | 7 ++ 6 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 packages/react-spectrum-charts-s2/src/stories/Line/Features/Trendline/LineTrendline.story.tsx diff --git a/packages/react-spectrum-charts-s2/src/stories/Line/Features/Trendline/LineTrendline.story.tsx b/packages/react-spectrum-charts-s2/src/stories/Line/Features/Trendline/LineTrendline.story.tsx new file mode 100644 index 000000000..febc7f6e0 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/Line/Features/Trendline/LineTrendline.story.tsx @@ -0,0 +1,65 @@ +/* + * 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 '../../../../stories/data/data'; +import { bindWithProps } from '../../../../test-utils'; +import { ChartProps } from '../../../../types'; + +export default { + title: 'React Spectrum Charts 2/Line/Features/Trendline', + component: Line, +}; + +const defaultChartProps: ChartProps = { data: workspaceTrendsData, minWidth: 400, maxWidth: 800, height: 400 }; + +const defaultArgs = { + color: 'series', + name: 'line0', +}; + +const TrendlineStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps(defaultChartProps); + return ( + + + + + + + ); +}; + +const Basic = bindWithProps(TrendlineStory); +Basic.args = { + ...defaultArgs, + trendlines: [{ method: 'average', lineType: 'solid' }], +}; + +const WithEndCaps = bindWithProps(TrendlineStory); +WithEndCaps.args = { + ...defaultArgs, + trendlines: [{ method: 'average', lineType: 'solid', showEndCaps: true }], +}; + +const WithEndCapsMedian = bindWithProps(TrendlineStory); +WithEndCapsMedian.args = { + ...defaultArgs, + trendlines: [{ method: 'median', lineType: 'solid', showEndCaps: true }], +}; + +export { Basic, WithEndCaps, WithEndCapsMedian }; 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 c5780af5a..b24ea9283 100644 --- a/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.test.ts @@ -11,7 +11,13 @@ */ import { Facet, From, GroupMark, Mark } from 'vega'; -import { COLOR_SCALE, DEFAULT_TIME_DIMENSION, TRENDLINE_VALUE } from '@spectrum-charts/constants'; +import { + COLOR_SCALE, + DEFAULT_TIME_DIMENSION, + REFERENCE_LINE_END_CAP_PATH, + REFERENCE_LINE_START_CAP_PATH, + TRENDLINE_VALUE, +} from '@spectrum-charts/constants'; import { spectrum2Colors } from '@spectrum-charts/themes'; import { @@ -19,9 +25,11 @@ import { getLineYProductionRule, getRuleXEncodings, getRuleYEncodings, + getTrendlineEndCapMark, getTrendlineLineMark, getTrendlineMarks, getTrendlineRuleMark, + getTrendlineStartCapMark, } from './trendlineMarkUtils'; import { defaultLineOptions, defaultTrendlineOptions } from './trendlineTestUtils'; @@ -34,6 +42,26 @@ describe('getTrendlineMarks()', () => { expect(marks).toHaveLength(1); expect(marks[0]).toHaveProperty('type', 'rule'); }); + test('should return rule mark and two cap path marks when showEndCaps is true', () => { + const marks = getTrendlineMarks({ + ...defaultLineOptions, + trendlines: [{ method: 'average', showEndCaps: true }], + }); + expect(marks).toHaveLength(3); + expect(marks[0]).toHaveProperty('type', 'rule'); + expect(marks[1]).toHaveProperty('type', 'path'); + expect(marks[1]).toHaveProperty('name', 'line0Trendline0_startCap'); + expect(marks[2]).toHaveProperty('type', 'path'); + expect(marks[2]).toHaveProperty('name', 'line0Trendline0_endCap'); + }); + test('should not add cap marks for non-aggregate methods even when showEndCaps is true', () => { + const marks = getTrendlineMarks({ + ...defaultLineOptions, + trendlines: [{ method: 'linear', showEndCaps: true }], + }); + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveProperty('type', 'group'); + }); test('should return group and line mark for non-aggregate methods', () => { const marks = getTrendlineMarks({ ...defaultLineOptions, @@ -106,6 +134,72 @@ describe('getTrendlineRuleMark()', () => { }); }); +describe('getTrendlineStartCapMark()', () => { + test('should return a path mark with the start cap path', () => { + const mark = getTrendlineStartCapMark(defaultLineOptions, { ...defaultTrendlineOptions, method: 'average' }); + expect(mark.type).toBe('path'); + expect(mark.name).toBe('line0Trendline0_startCap'); + expect(mark.encode?.enter).toHaveProperty('path', { value: REFERENCE_LINE_START_CAP_PATH }); + }); + test('should use series color if static color is not provided', () => { + const mark = getTrendlineStartCapMark(defaultLineOptions, { ...defaultTrendlineOptions, method: 'average' }); + expect(mark.encode?.enter?.fill).toEqual({ field: 'series', scale: COLOR_SCALE }); + }); + test('should use static color if provided', () => { + const mark = getTrendlineStartCapMark(defaultLineOptions, { + ...defaultTrendlineOptions, + trendlineColor: { value: 'gray-500' }, + method: 'average', + }); + expect(mark.encode?.enter?.fill).toEqual({ value: spectrum2Colors.light['gray-500'] }); + }); + test('should use TRENDLINE_VALUE for the y encoding', () => { + const mark = getTrendlineStartCapMark(defaultLineOptions, { ...defaultTrendlineOptions, method: 'average' }); + expect(mark.encode?.enter?.y).toEqual({ scale: 'yLinear', field: TRENDLINE_VALUE }); + }); + test('should use x from getRuleXEncodings for the update x encoding', () => { + const mark = getTrendlineStartCapMark(defaultLineOptions, { + ...defaultTrendlineOptions, + method: 'average', + dimensionExtent: [0, 10], + }); + expect(mark.encode?.update?.x).toEqual({ scale: 'xTime', value: 0 }); + }); +}); + +describe('getTrendlineEndCapMark()', () => { + test('should return a path mark with the end cap path', () => { + const mark = getTrendlineEndCapMark(defaultLineOptions, { ...defaultTrendlineOptions, method: 'average' }); + expect(mark.type).toBe('path'); + expect(mark.name).toBe('line0Trendline0_endCap'); + expect(mark.encode?.enter).toHaveProperty('path', { value: REFERENCE_LINE_END_CAP_PATH }); + }); + test('should use series color if static color is not provided', () => { + const mark = getTrendlineEndCapMark(defaultLineOptions, { ...defaultTrendlineOptions, method: 'average' }); + expect(mark.encode?.enter?.fill).toEqual({ field: 'series', scale: COLOR_SCALE }); + }); + test('should use static color if provided', () => { + const mark = getTrendlineEndCapMark(defaultLineOptions, { + ...defaultTrendlineOptions, + trendlineColor: { value: 'gray-500' }, + method: 'average', + }); + expect(mark.encode?.enter?.fill).toEqual({ value: spectrum2Colors.light['gray-500'] }); + }); + test('should use TRENDLINE_VALUE for the y encoding', () => { + const mark = getTrendlineEndCapMark(defaultLineOptions, { ...defaultTrendlineOptions, method: 'average' }); + expect(mark.encode?.enter?.y).toEqual({ scale: 'yLinear', field: TRENDLINE_VALUE }); + }); + test('should use x2 from getRuleXEncodings for the update x encoding', () => { + const mark = getTrendlineEndCapMark(defaultLineOptions, { + ...defaultTrendlineOptions, + method: 'average', + dimensionExtent: [0, 10], + }); + expect(mark.encode?.update?.x).toEqual({ scale: 'xTime', value: 10 }); + }); +}); + describe('getRuleYEncodings()', () => { test('should return the correct rules for numeric extent', () => { const encoding = getRuleYEncodings([0, 10], 'count', 'vertical'); diff --git a/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.ts b/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.ts index cceb97523..49c80d8ad 100644 --- a/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.ts +++ b/packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.ts @@ -9,9 +9,9 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { EncodeEntry, GroupMark, LineMark, NumericValueRef, RuleMark } from 'vega'; +import { EncodeEntry, GroupMark, LineMark, NumericValueRef, PathMark, RuleMark } from 'vega'; -import { TRENDLINE_VALUE } from '@spectrum-charts/constants'; +import { REFERENCE_LINE_END_CAP_PATH, REFERENCE_LINE_START_CAP_PATH, TRENDLINE_VALUE } from '@spectrum-charts/constants'; import { getLineHoverMarks, getLineOpacity } from '../line/lineMarkUtils'; import { LineMarkOptions } from '../line/lineUtils'; @@ -35,17 +35,23 @@ import { isRegressionMethod, } from './trendlineUtils'; -export const getTrendlineMarks = (markOptions: TrendlineParentOptions): (GroupMark | RuleMark)[] => { +export const getTrendlineMarks = (markOptions: TrendlineParentOptions): (GroupMark | PathMark | RuleMark)[] => { const { color, lineType } = markOptions; const { facets } = getFacetsFromOptions({ color, lineType }); - const marks: (GroupMark | RuleMark)[] = []; + const marks: (GroupMark | PathMark | RuleMark)[] = []; const trendlines = getTrendlines(markOptions); for (const trendlineOptions of trendlines) { - const { displayOnHover, method, name } = trendlineOptions; + const { displayOnHover, method, name, showEndCaps } = trendlineOptions; if (isAggregateMethod(method)) { marks.push(getTrendlineRuleMark(markOptions, trendlineOptions)); + if (showEndCaps) { + marks.push( + getTrendlineStartCapMark(markOptions, trendlineOptions), + getTrendlineEndCapMark(markOptions, trendlineOptions) + ); + } } else { const data = getDataSourceName(name, method, displayOnHover); marks.push({ @@ -132,6 +138,80 @@ export const getTrendlineRuleMark = ( }; }; +/** + * Gets the left-pointing arrowhead cap path mark for an aggregate trendline. + * Only supported for horizontal orientation. + * @param markOptions + * @param trendlineOptions + * @returns path mark + */ +export const getTrendlineStartCapMark = ( + markOptions: TrendlineParentOptions, + trendlineOptions: TrendlineSpecOptions +): PathMark => { + const { colorScheme } = markOptions; + const { dimensionExtent, dimensionScaleType, displayOnHover, name, orientation, trendlineColor, trendlineDimension } = + trendlineOptions; + const data = displayOnHover ? `${name}_highlightedData` : `${name}_highResolutionData`; + const xEncodings = getRuleXEncodings(dimensionExtent, trendlineDimension, dimensionScaleType, orientation); + + return { + name: `${name}_startCap`, + type: 'path', + clip: true, + from: { data }, + interactive: false, + encode: { + enter: { + path: { value: REFERENCE_LINE_START_CAP_PATH }, + fill: getColorProductionRule(trendlineColor, colorScheme), + y: { scale: 'yLinear', field: TRENDLINE_VALUE }, + }, + update: { + x: xEncodings.x as NumericValueRef, + opacity: getLineOpacity(getLineMarkOptions(markOptions, trendlineOptions)), + }, + }, + }; +}; + +/** + * Gets the right-pointing arrowhead cap path mark for an aggregate trendline. + * Only supported for horizontal orientation. + * @param markOptions + * @param trendlineOptions + * @returns path mark + */ +export const getTrendlineEndCapMark = ( + markOptions: TrendlineParentOptions, + trendlineOptions: TrendlineSpecOptions +): PathMark => { + const { colorScheme } = markOptions; + const { dimensionExtent, dimensionScaleType, displayOnHover, name, orientation, trendlineColor, trendlineDimension } = + trendlineOptions; + const data = displayOnHover ? `${name}_highlightedData` : `${name}_highResolutionData`; + const xEncodings = getRuleXEncodings(dimensionExtent, trendlineDimension, dimensionScaleType, orientation); + + return { + name: `${name}_endCap`, + type: 'path', + clip: true, + from: { data }, + interactive: false, + encode: { + enter: { + path: { value: REFERENCE_LINE_END_CAP_PATH }, + fill: getColorProductionRule(trendlineColor, colorScheme), + y: { scale: 'yLinear', field: TRENDLINE_VALUE }, + }, + update: { + ...(xEncodings.x2 ? { x: xEncodings.x2 as NumericValueRef } : {}), + opacity: getLineOpacity(getLineMarkOptions(markOptions, trendlineOptions)), + }, + }, + }; +}; + /** * gets the production rules for the y and y2 encoding of a rule mark * @param dimensionExtent diff --git a/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts b/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts index 392e76a1b..67cd6509e 100644 --- a/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts +++ b/packages/vega-spec-builder-s2/src/trendline/trendlineTestUtils.ts @@ -61,6 +61,7 @@ export const defaultTrendlineOptions: TrendlineSpecOptions = { name: 'line0Trendline0', opacity: 1, orientation: 'horizontal', + showEndCaps: false, trendlineAnnotations: [], trendlineColor: DEFAULT_COLOR, trendlineDimension: DEFAULT_TIME_DIMENSION, diff --git a/packages/vega-spec-builder-s2/src/trendline/trendlineUtils.ts b/packages/vega-spec-builder-s2/src/trendline/trendlineUtils.ts index e6adf1d63..11bb34a24 100644 --- a/packages/vega-spec-builder-s2/src/trendline/trendlineUtils.ts +++ b/packages/vega-spec-builder-s2/src/trendline/trendlineUtils.ts @@ -64,6 +64,7 @@ export const applyTrendlinePropDefaults = ( method = 'linear', opacity = 1, orientation = 'horizontal', + showEndCaps = false, trendlineAnnotations = [], ...opts }: TrendlineOptions, @@ -96,6 +97,7 @@ export const applyTrendlinePropDefaults = ( name: `${markOptions.name}Trendline${index}`, opacity, orientation, + showEndCaps, trendlineAnnotations, trendlineColor, trendlineDimension, diff --git a/packages/vega-spec-builder-s2/src/types/marks/supplemental/trendlineSpec.types.ts b/packages/vega-spec-builder-s2/src/types/marks/supplemental/trendlineSpec.types.ts index 3315f196d..beb55d84e 100644 --- a/packages/vega-spec-builder-s2/src/types/marks/supplemental/trendlineSpec.types.ts +++ b/packages/vega-spec-builder-s2/src/types/marks/supplemental/trendlineSpec.types.ts @@ -42,6 +42,12 @@ export interface TrendlineOptions { * If null is used as a start or end value, the trendline will be be drawn from the first data point to the last data point respectively. */ dimensionExtent?: [number | 'domain' | null, number | 'domain' | null]; + /** + * If true, renders arrowhead caps at both endpoints of aggregate trendlines (average/median). + * Only applies to aggregate methods — regression and window methods are not supported. + * @default false + */ + showEndCaps?: boolean; /** * The dimension range that the statistical transform should be calculated for. If undefined, the value will default to [null, null] * @@ -88,6 +94,7 @@ type TrendlineOptionsWithDefaults = | 'method' | 'opacity' | 'orientation' + | 'showEndCaps' | 'trendlineAnnotations'; export interface TrendlineSpecOptions extends PartiallyRequired {