Skip to content
Closed
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,4 @@
Significance: minor
Type: added

TimeSeriesForecastChart: add compound composition pattern and full legend prop parity
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/components/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 @@
"sparkline": [
"./dist/components/sparkline/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 || ! context?.yScale ) {
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,108 @@
import { useMemo } from 'react';
import type { TimeSeriesForecastAccessors, TransformedForecastPoint, BandPoint } from '../types';

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

interface UseForecastDataResult {
historical: TransformedForecastPoint[];
forecast: TransformedForecastPoint[];
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 {
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 ),
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
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 );

const allYValues = [ ...allValues, ...allLowers, ...allUppers ];
const minY = Math.min( ...allYValues );
const maxY = Math.max( ...allYValues );

// Add some padding to y domain
const yPadding = ( maxY - minY ) * 0.1;
const yDomain: [ number, number ] = [ Math.max( 0, minY - yPadding ), maxY + yPadding ];

// 6. Compute x domain
const allDates = transformed.map( p => p.date );
const xDomain: [ Date, Date ] = [
new Date( Math.min( ...allDates.map( d => d.getTime() ) ) ),
new Date( Math.max( ...allDates.map( d => d.getTime() ) ) ),
];

return { 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,
showTooltip: {
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,
showTooltip: true,
showLegend: false,
animation: false,
gridVisibility: 'y' as const,
maxWidth: 800,
aspectRatio: 0.5,
resizeDebounceTime: 300,
};
Loading
Loading