Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6de3d98
Add type definitions for TimeSeriesForecastChart
annacmc Jan 29, 2026
978eb91
Add data transformation hook for forecast data splitting
annacmc Jan 29, 2026
c25eaf5
Add forecast divider component
annacmc Jan 29, 2026
8896218
Add TimeSeriesForecastChart component with styles
annacmc Jan 29, 2026
9ea1b54
Add TimeSeriesForecastChart exports to package
annacmc Jan 29, 2026
03090a6
Add TimeSeriesForecastChart Storybook stories
annacmc Jan 29, 2026
5bbc4c3
Add changelog entry for TimeSeriesForecastChart component
annacmc Jan 29, 2026
ad6dd44
Address Copilot review feedback
annacmc Jan 30, 2026
7d5505b
Fix TimeSeriesForecastChart API consistency and minor issues
annacmc Jan 30, 2026
0faf448
Address Copilot feedback on array concat and RTL padding
annacmc Jan 30, 2026
70667b9
Fix tooltip snapping to band instead of data lines
annacmc Jan 30, 2026
22ef013
Fix tooltip to work across all data points using hidden series
annacmc Jan 30, 2026
5ea808b
Add missing allPoints property in empty data case
annacmc Jan 30, 2026
164b2aa
Adopt new responsive layout pattern for TimeSeriesForecastChart
annacmc Feb 17, 2026
330dcf9
Use getElementStyles for theme color resolution in TimeSeriesForecast…
annacmc Feb 17, 2026
1e5391a
Remove unnecessary fallback on ForecastDivider color prop
annacmc Feb 17, 2026
09e1669
Make forecast strokeDasharray configurable via theme in TimeSeriesFor…
annacmc Feb 17, 2026
5ec636d
Make band fillOpacity configurable via bandFillOpacity prop
annacmc Feb 17, 2026
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,4 @@
Significance: minor
Type: added

Add TimeSeriesForecastChart component for visualizing time series data with forecast periods.
9 changes: 9 additions & 0 deletions projects/js-packages/charts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@
},
"./sparkline/style.css": "./dist/charts/sparkline/index.css",
"./style.css": "./dist/index.css",
"./time-series-forecast-chart": {
"jetpack:src": "./src/charts/time-series-forecast-chart/index.ts",
"import": "./dist/charts/time-series-forecast-chart/index.js",
"require": "./dist/charts/time-series-forecast-chart/index.cjs"
},
"./time-series-forecast-chart/style.css": "./dist/charts/time-series-forecast-chart/index.css",
"./tooltip": {
"jetpack:src": "./src/components/tooltip/index.ts",
"import": "./dist/components/tooltip/index.js",
Expand Down Expand Up @@ -171,6 +177,9 @@
"providers": [
"./dist/providers/index.d.ts"
],
"time-series-forecast-chart": [
"./dist/charts/time-series-forecast-chart/index.d.ts"
],
"tooltip": [
"./dist/components/tooltip/index.d.ts"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export {
default as TimeSeriesForecastChart,
TimeSeriesForecastChartUnresponsive,
} from './time-series-forecast-chart';

export type {
TimeSeriesForecastChartProps,
TimeSeriesForecastAccessors,
SeriesKeys,
Accessor,
ForecastTooltipParams,
} from './types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { DataContext } from '@visx/xychart';
import { useContext } from 'react';
import type { FC } from 'react';

interface ForecastDividerProps {
/**
* The date at which the forecast begins
*/
forecastStart: Date;
/**
* Color of the divider line. Default: '#888888'
*/
color?: string;
}

/**
* Renders a vertical dashed line at the forecast start position.
* Uses DataContext from visx/xychart to access scales.
*
* @param root0 - Props object
* @param root0.forecastStart - The date at which the forecast begins
* @param root0.color - Color of the divider line
* @return SVG line element or null if scales are not available
*/
export const ForecastDivider: FC< ForecastDividerProps > = ( {
forecastStart,
color = '#888888',
} ) => {
const context = useContext( DataContext );

if ( ! context?.xScale ) {
return null;
}

const { xScale, innerHeight } = context;

// Get x position from scale
const xScaleFn = xScale as ( value: Date ) => number | undefined;
const x = xScaleFn( forecastStart );

if ( x === undefined || ! Number.isFinite( x ) ) {
return null;
}

return (
<line
x1={ x }
x2={ x }
y1={ 0 }
y2={ innerHeight }
stroke={ color }
strokeDasharray="4 4"
strokeWidth={ 1 }
data-testid="forecast-divider"
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useForecastData } from './use-forecast-data';
export { ForecastDivider } from './forecast-divider';
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useMemo } from 'react';
import type { TimeSeriesForecastAccessors, TransformedForecastPoint, BandPoint } from '../types';

interface UseForecastDataOptions< D > {
data: readonly D[];
accessors: TimeSeriesForecastAccessors< D >;
forecastStart: Date;
}

interface UseForecastDataResult {
/** All transformed points (no duplicates) - used for tooltip */
allPoints: TransformedForecastPoint[];
/** Historical points (before forecastStart) - for visual rendering */
historical: TransformedForecastPoint[];
/** Forecast points (at or after forecastStart) - for visual rendering */
forecast: TransformedForecastPoint[];
/** Band data for uncertainty region */
bandData: BandPoint[];
yDomain: [ number, number ];
xDomain: [ Date, Date ];
}

/**
* Safe number extraction - returns null for non-finite numbers
*
* @param n - The value to extract as a number
* @return The number if valid and finite, otherwise null
*/
const asNumber = ( n: number | null | undefined ): number | null => {
return typeof n === 'number' && Number.isFinite( n ) ? n : null;
};

/**
* Hook to transform and split forecast data for rendering
*
* Handles:
* - Transforming generic data via accessors
* - Splitting data into historical and forecast portions
* - Creating band data for uncertainty regions
* - Computing x and y domains
*
* @param root0 - Options object
* @param root0.data - Array of data points
* @param root0.accessors - Accessor functions to extract values from data
* @param root0.forecastStart - Date at which forecast begins
* @return Transformed data split into historical and forecast portions
*/
export function useForecastData< D >( {
data,
accessors,
forecastStart,
}: UseForecastDataOptions< D > ): UseForecastDataResult {
return useMemo( () => {
if ( data.length === 0 ) {
const now = new Date();
return {
allPoints: [],
historical: [],
forecast: [],
bandData: [],
yDomain: [ 0, 100 ] as [ number, number ],
xDomain: [ now, now ] as [ Date, Date ],
};
}

// 1. Transform all points via accessors
const transformed: TransformedForecastPoint[] = data.map( ( d, i ) => ( {
date: accessors.x( d, i ),
value: accessors.y( d, i ),
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The y accessor value is not validated to be a finite number. If accessors.y returns NaN or Infinity, it could lead to invalid domain calculations. Consider adding validation similar to the asNumber function used for bounds, or add a check after computing minY/maxY to ensure they are finite numbers.

Suggested change
value: accessors.y( d, i ),
value: asNumber( accessors.y( d, i ) ),

Copilot uses AI. Check for mistakes.
lower: accessors.yLower ? asNumber( accessors.yLower( d, i ) ) : null,
upper: accessors.yUpper ? asNumber( accessors.yUpper( d, i ) ) : null,
originalIndex: i,
} ) );

// 2. Sort by date for rendering stability
transformed.sort( ( a, b ) => a.date.getTime() - b.date.getTime() );

// 3. Split by forecastStart
// Both series include the transition point so the lines connect seamlessly visually
const forecastStartTime = forecastStart.getTime();
const historical = transformed.filter( p => p.date.getTime() <= forecastStartTime );
const forecast = transformed.filter( p => p.date.getTime() >= forecastStartTime );

// 4. Band data: only points with BOTH valid lower AND upper
const bandData: BandPoint[] = forecast
.filter(
( p ): p is TransformedForecastPoint & { lower: number; upper: number } =>
p.lower !== null && p.upper !== null
)
.map( p => ( { date: p.date, lower: p.lower, upper: p.upper } ) );

// 5. Compute y domain including bounds
const allValues = transformed.map( p => p.value );
const allLowers = transformed.map( p => p.lower ).filter( ( v ): v is number => v !== null );
const allUppers = transformed.map( p => p.upper ).filter( ( v ): v is number => v !== null );

// Use concat instead of spread to avoid stack overflow on large arrays
const allYValues = allValues.concat( allLowers, allUppers );
const minY = allYValues.reduce( ( min, val ) => Math.min( min, val ), Infinity );
const maxY = allYValues.reduce( ( max, val ) => Math.max( max, val ), -Infinity );

// Add some padding to y domain (ensure minimum padding to avoid zero-height scales)
const yPadding = Math.max( ( maxY - minY ) * 0.1, 1 );
const yDomain: [ number, number ] = [ minY - yPadding, maxY + yPadding ];

// 6. Compute x domain (use reduce to avoid stack overflow on large arrays)
const allDateTimes = transformed.map( p => p.date.getTime() );
const xDomain: [ Date, Date ] = [
new Date( allDateTimes.reduce( ( min, val ) => Math.min( min, val ), Infinity ) ),
new Date( allDateTimes.reduce( ( max, val ) => Math.max( max, val ), -Infinity ) ),
];

return { allPoints: transformed, historical, forecast, bandData, yDomain, xDomain };
}, [ data, accessors, forecastStart ] );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
chartDecorator,
sharedChartArgTypes,
ChartStoryArgs,
} from '../../../stories/chart-decorator';
import { legendArgTypes } from '../../../stories/legend-config';
import { sharedThemeArgs, themeArgTypes } from '../../../stories/theme-config';
import TimeSeriesForecastChart from '../time-series-forecast-chart';
import type { TimeSeriesForecastChartProps } from '../types';
import type { Meta } from '@storybook/react';

/**
* Sample forecast data point type
*/
export type SampleForecastDatum = {
date: Date;
value: number;
lower?: number;
upper?: number;
};

/**
* Generate sample forecast data for storybook demonstrations.
*
* @param historicalDays - Number of historical days to generate
* @param forecastDays - Number of forecast days to generate
* @param baseValue - Starting value for the data
* @param trend - Daily trend increment
* @param volatility - Random noise amplitude
* @return Array of sample forecast data points
*/
export const generateForecastData = (
historicalDays: number = 30,
forecastDays: number = 14,
baseValue: number = 100,
trend: number = 0.5,
volatility: number = 10
): SampleForecastDatum[] => {
const data: SampleForecastDatum[] = [];
const startDate = new Date();
startDate.setDate( startDate.getDate() - historicalDays );

// Generate historical data (no bounds)
for ( let i = 0; i < historicalDays; i++ ) {
const date = new Date( startDate );
date.setDate( date.getDate() + i );
const noise = ( Math.random() - 0.5 ) * volatility * 2;
const value = baseValue + trend * i + noise;
data.push( { date, value: Math.max( 0, value ) } );
}

// Generate forecast data (with bounds)
const lastHistoricalValue = data[ data.length - 1 ].value;
for ( let i = 0; i < forecastDays; i++ ) {
const date = new Date( startDate );
date.setDate( date.getDate() + historicalDays + i );
const projectedValue = lastHistoricalValue + trend * ( i + 1 );

// Uncertainty grows with time
const uncertaintySpread = volatility * ( 1 + i * 0.2 );
const lower = projectedValue - uncertaintySpread;
const upper = projectedValue + uncertaintySpread;

data.push( {
date,
value: projectedValue,
lower: Math.max( 0, lower ),
upper,
} );
}

return data;
};

/**
* Fixed sample data for consistent story rendering
* Note: The last historical point (2024-02-19) is also the first forecast point
* to ensure the lines connect seamlessly at the transition.
*/
export const sampleForecastData: SampleForecastDatum[] = [
// Historical data (no bounds)
{ date: new Date( '2024-01-01' ), value: 100 },
{ date: new Date( '2024-01-08' ), value: 108 },
{ date: new Date( '2024-01-15' ), value: 115 },
{ date: new Date( '2024-01-22' ), value: 112 },
{ date: new Date( '2024-01-29' ), value: 125 },
{ date: new Date( '2024-02-05' ), value: 130 },
{ date: new Date( '2024-02-12' ), value: 128 },
// Forecast data (with bounds) - starts at last historical point for seamless connection
{ date: new Date( '2024-02-19' ), value: 135, lower: 135, upper: 135 }, // Transition point (no spread yet)
{ date: new Date( '2024-02-26' ), value: 142, lower: 132, upper: 152 },
{ date: new Date( '2024-03-04' ), value: 148, lower: 135, upper: 161 },
{ date: new Date( '2024-03-11' ), value: 155, lower: 138, upper: 172 },
{ date: new Date( '2024-03-18' ), value: 162, lower: 140, upper: 184 },
];

/**
* Sample accessors for the default data type
*/
export const sampleAccessors = {
x: ( d: SampleForecastDatum ) => d.date,
y: ( d: SampleForecastDatum ) => d.value,
yLower: ( d: SampleForecastDatum ) => d.lower,
yUpper: ( d: SampleForecastDatum ) => d.upper,
};

/**
* Forecast start date - the transition point where forecast begins
*/
export const sampleForecastStart = new Date( '2024-02-19' );

type StoryArgs = ChartStoryArgs< TimeSeriesForecastChartProps< SampleForecastDatum > >;

export const timeSeriesForecastChartMetaArgs: Meta< StoryArgs > = {
title: 'JS Packages/Charts Library/Charts/Time Series Forecast Chart',
component: TimeSeriesForecastChart,
parameters: {
layout: 'centered',
},
decorators: [ chartDecorator ],
argTypes: {
...legendArgTypes,
...themeArgTypes,
...sharedChartArgTypes,
withTooltips: {
control: 'boolean',
description: 'Show tooltips on hover',
table: { category: 'Tooltip' },
},
animation: {
control: 'boolean',
description: 'Enable chart animation',
table: { category: 'Visual Style' },
},
gridVisibility: {
control: { type: 'select' },
options: [ 'x', 'y', 'xy', 'none' ],
description: 'Grid visibility',
table: { category: 'Visual Style' },
},
},
};

export const timeSeriesForecastChartStoryArgs = {
...sharedThemeArgs,
data: sampleForecastData,
accessors: sampleAccessors,
forecastStart: sampleForecastStart,
withTooltips: true,
showLegend: false,
animation: false,
gridVisibility: 'y' as const,
maxWidth: 800,
aspectRatio: 0.5,
resizeDebounceTime: 300,
};
Loading
Loading