-
Notifications
You must be signed in to change notification settings - Fork 862
Charts: Add TimeSeriesForecastChart component #46844
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
6de3d98
978eb91
c25eaf5
8896218
9ea1b54
03090a6
5bbc4c3
ad6dd44
7d5505b
0faf448
70667b9
22ef013
5ea808b
164b2aa
330dcf9
1e5391a
09e1669
5ec636d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 ), | ||||||
|
||||||
| value: accessors.y( d, i ), | |
| value: asNumber( accessors.y( d, i ) ), |
| 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, | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.