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
Original file line number Diff line number Diff line change
@@ -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<typeof Line> = (args): ReactElement => {
const chartProps = useChartProps(defaultChartProps);
return (
<Chart {...chartProps}>
<Axis position="left" grid title="Users" />
<Axis position="bottom" labelFormat="time" baseline ticks />
<Line {...args} />
<Legend lineWidth={{ value: 0 }} highlight />
</Chart>
);
};

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 };
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,25 @@
*/
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 {
getLineXProductionRule,
getLineYProductionRule,
getRuleXEncodings,
getRuleYEncodings,
getTrendlineEndCapMark,
getTrendlineLineMark,
getTrendlineMarks,
getTrendlineRuleMark,
getTrendlineStartCapMark,
} from './trendlineMarkUtils';
import { defaultLineOptions, defaultTrendlineOptions } from './trendlineTestUtils';

Expand All @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
90 changes: 85 additions & 5 deletions packages/vega-spec-builder-s2/src/trendline/trendlineMarkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const defaultTrendlineOptions: TrendlineSpecOptions = {
name: 'line0Trendline0',
opacity: 1,
orientation: 'horizontal',
showEndCaps: false,
trendlineAnnotations: [],
trendlineColor: DEFAULT_COLOR,
trendlineDimension: DEFAULT_TIME_DIMENSION,
Expand Down
2 changes: 2 additions & 0 deletions packages/vega-spec-builder-s2/src/trendline/trendlineUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const applyTrendlinePropDefaults = (
method = 'linear',
opacity = 1,
orientation = 'horizontal',
showEndCaps = false,
trendlineAnnotations = [],
...opts
}: TrendlineOptions,
Expand Down Expand Up @@ -96,6 +97,7 @@ export const applyTrendlinePropDefaults = (
name: `${markOptions.name}Trendline${index}`,
opacity,
orientation,
showEndCaps,
trendlineAnnotations,
trendlineColor,
trendlineDimension,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
*
Expand Down Expand Up @@ -88,6 +94,7 @@ type TrendlineOptionsWithDefaults =
| 'method'
| 'opacity'
| 'orientation'
| 'showEndCaps'
| 'trendlineAnnotations';

export interface TrendlineSpecOptions extends PartiallyRequired<TrendlineOptions, TrendlineOptionsWithDefaults> {
Expand Down
Loading