diff --git a/projects/js-packages/charts/changelog/add-time-series-forecast-chart b/projects/js-packages/charts/changelog/add-time-series-forecast-chart new file mode 100644 index 000000000000..f1aef3b57aae --- /dev/null +++ b/projects/js-packages/charts/changelog/add-time-series-forecast-chart @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add TimeSeriesForecastChart component for visualizing time series data with forecast periods. diff --git a/projects/js-packages/charts/package.json b/projects/js-packages/charts/package.json index 87cb3ef37518..dff135fddab1 100644 --- a/projects/js-packages/charts/package.json +++ b/projects/js-packages/charts/package.json @@ -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", @@ -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" ], diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/index.ts b/projects/js-packages/charts/src/charts/time-series-forecast-chart/index.ts new file mode 100644 index 000000000000..ca7f81724ba5 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/index.ts @@ -0,0 +1,12 @@ +export { + default as TimeSeriesForecastChart, + TimeSeriesForecastChartUnresponsive, +} from './time-series-forecast-chart'; + +export type { + TimeSeriesForecastChartProps, + TimeSeriesForecastAccessors, + SeriesKeys, + Accessor, + ForecastTooltipParams, +} from './types'; diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/forecast-divider.tsx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/forecast-divider.tsx new file mode 100644 index 000000000000..0e7c71edd34e --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/forecast-divider.tsx @@ -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 ( + + ); +}; diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/index.ts b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/index.ts new file mode 100644 index 000000000000..f846e8e2085b --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/index.ts @@ -0,0 +1,2 @@ +export { useForecastData } from './use-forecast-data'; +export { ForecastDivider } from './forecast-divider'; diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/use-forecast-data.ts b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/use-forecast-data.ts new file mode 100644 index 000000000000..8baeab643175 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/use-forecast-data.ts @@ -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 ), + 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 ] ); +} diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/config.tsx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/config.tsx new file mode 100644 index 000000000000..e67e9177a4fd --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/config.tsx @@ -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, +}; diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.stories.tsx new file mode 100644 index 000000000000..e7d683a3e461 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.stories.tsx @@ -0,0 +1,453 @@ +import { formatNumber } from '@automattic/number-formatters'; +import { ChartStoryArgs } from '../../../stories'; +import TimeSeriesForecastChart from '../time-series-forecast-chart'; +import { + timeSeriesForecastChartMetaArgs, + timeSeriesForecastChartStoryArgs, + sampleForecastData, + sampleAccessors, + sampleForecastStart, + generateForecastData, + type SampleForecastDatum, +} from './config'; +import type { TimeSeriesForecastChartProps } from '../types'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +type StoryArgs = ChartStoryArgs< TimeSeriesForecastChartProps< SampleForecastDatum > >; + +const meta: Meta< StoryArgs > = { + ...timeSeriesForecastChartMetaArgs, + title: 'JS Packages/Charts Library/Charts/Time Series Forecast Chart', +}; + +export default meta; + +const Template: StoryFn< typeof TimeSeriesForecastChart > = args => { + return ; +}; + +/** + * Default story showing historical data with forecast and uncertainty bands + */ +export const Default: StoryObj< StoryArgs > = Template.bind( {} ); +Default.args = { + ...timeSeriesForecastChartStoryArgs, +}; + +Default.parameters = { + docs: { + description: { + story: + 'The TimeSeriesForecastChart displays historical data as a solid line and forecast data as a dashed line. ' + + 'The shaded area represents the uncertainty band between lower and upper bounds. ' + + 'A vertical dashed line marks the forecast start date.', + }, + }, +}; + +/** + * With Legend enabled + */ +export const WithLegend: StoryObj< StoryArgs > = Template.bind( {} ); +WithLegend.args = { + ...timeSeriesForecastChartStoryArgs, + showLegend: true, + legendPosition: 'bottom', +}; + +WithLegend.parameters = { + docs: { + description: { + story: + 'The chart with legend showing series labels. The forecast series shows a dashed line indicator.', + }, + }, +}; + +/** + * With Animation + */ +export const WithAnimation: StoryObj< StoryArgs > = Template.bind( {} ); +WithAnimation.args = { + ...timeSeriesForecastChartStoryArgs, + animation: true, +}; + +WithAnimation.parameters = { + docs: { + description: { + story: + 'The chart with animation enabled. Animation respects the prefers-reduced-motion setting.', + }, + }, +}; + +/** + * Custom Series Labels + */ +export const CustomSeriesLabels: StoryObj< StoryArgs > = Template.bind( {} ); +CustomSeriesLabels.args = { + ...timeSeriesForecastChartStoryArgs, + showLegend: true, + seriesKeys: { + historical: 'Actual Revenue', + forecast: 'Projected Revenue', + band: 'Confidence Interval', + }, +}; + +CustomSeriesLabels.parameters = { + docs: { + description: { + story: + 'Customize the series labels shown in the legend and tooltip using the seriesKeys prop.', + }, + }, +}; + +/** + * Custom Tooltip + */ +export const CustomTooltip: StoryObj< StoryArgs > = Template.bind( {} ); +CustomTooltip.args = { + ...timeSeriesForecastChartStoryArgs, + renderTooltip: ( { x, y, yLower, yUpper, isForecast } ) => ( +
+
+ { x.toLocaleDateString( 'en-US', { month: 'short', day: 'numeric', year: 'numeric' } ) } +
+
Value: ${ formatNumber( y ) }
+ { isForecast && yLower !== undefined && yUpper !== undefined && ( +
+ Range: ${ formatNumber( yLower ) } - ${ formatNumber( yUpper ) } +
+ ) } + { isForecast && ( +
+ Forecast +
+ ) } +
+ ), +}; + +CustomTooltip.parameters = { + docs: { + description: { + story: + 'Use the renderTooltip prop to provide a custom tooltip renderer. ' + + 'The tooltip receives the original datum, x/y values, bounds (if available), and whether the point is in the forecast region.', + }, + }, +}; + +/** + * Fixed Dimensions + */ +export const FixedDimensions: StoryObj< StoryArgs > = Template.bind( {} ); +FixedDimensions.args = { + ...timeSeriesForecastChartStoryArgs, + width: 600, + height: 300, +}; + +FixedDimensions.parameters = { + docs: { + description: { + story: + 'The chart with fixed dimensions instead of responsive behavior. Use width and height props directly.', + }, + }, +}; + +/** + * Custom Y Domain + */ +export const CustomYDomain: StoryObj< StoryArgs > = Template.bind( {} ); +CustomYDomain.args = { + ...timeSeriesForecastChartStoryArgs, + yDomain: [ 0, 200 ], +}; + +CustomYDomain.parameters = { + docs: { + description: { + story: 'Override the auto-calculated Y domain with a fixed range using the yDomain prop.', + }, + }, +}; + +/** + * Custom Tick Formatters + */ +export const CustomTickFormatters: StoryObj< StoryArgs > = Template.bind( {} ); +CustomTickFormatters.args = { + ...timeSeriesForecastChartStoryArgs, + xTickFormat: ( d: Date ) => d.toLocaleDateString( 'en-US', { month: 'short', day: 'numeric' } ), + yTickFormat: ( n: number ) => `$${ n }`, +}; + +CustomTickFormatters.parameters = { + docs: { + description: { + story: 'Customize axis tick labels using xTickFormat and yTickFormat props.', + }, + }, +}; + +/** + * Historical Only (no forecast data) + */ +export const HistoricalOnly: StoryObj< StoryArgs > = Template.bind( {} ); +HistoricalOnly.args = { + ...timeSeriesForecastChartStoryArgs, + data: sampleForecastData.slice( 0, 7 ), // Only historical points (before transition) + forecastStart: new Date( '2024-12-31' ), // Far in the future +}; + +HistoricalOnly.parameters = { + docs: { + description: { + story: + 'When forecastStart is after all data points, only the historical line is shown (no divider or band).', + }, + }, +}; + +/** + * Forecast Only (no historical data) + */ +export const ForecastOnly: StoryObj< StoryArgs > = Template.bind( {} ); +ForecastOnly.args = { + ...timeSeriesForecastChartStoryArgs, + data: sampleForecastData.slice( 7 ), // Only forecast points (from transition onward) + forecastStart: new Date( '2024-01-01' ), // Before all data +}; + +ForecastOnly.parameters = { + docs: { + description: { + story: + 'When forecastStart is before all data points, only the forecast line and band are shown (no divider).', + }, + }, +}; + +/** + * Different Grid Visibility Options + */ +export const GridVisibilityOptions: StoryObj< StoryArgs > = { + render: () => ( +
+
+

Y Grid (default)

+ +
+
+

X Grid

+ +
+
+

Both (XY)

+ +
+
+

No Grid

+ +
+
+ ), + parameters: { + docs: { + description: { + story: 'Different grid visibility options: y (default), x, xy, or none.', + }, + }, + }, +}; + +/** + * Dynamic/Generated Data + */ +export const DynamicData: StoryObj< StoryArgs > = { + render: () => { + const data = generateForecastData( 30, 14, 100, 1.5, 15 ); + const forecastStart = new Date(); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: + 'Example with dynamically generated data. The generateForecastData helper creates realistic time series with configurable parameters for trend, volatility, and forecast length.', + }, + }, + }, +}; + +/** + * Generic Data Type Example + */ +type CustomDatum = { + timestamp: number; + measurement: number; + confidenceLow?: number; + confidenceHigh?: number; +}; + +const customData: CustomDatum[] = [ + { timestamp: Date.parse( '2024-01-01' ), measurement: 50 }, + { timestamp: Date.parse( '2024-01-15' ), measurement: 55 }, + // Transition point - shared by both series + { timestamp: Date.parse( '2024-02-01' ), measurement: 60, confidenceLow: 60, confidenceHigh: 60 }, + { timestamp: Date.parse( '2024-02-15' ), measurement: 65, confidenceLow: 58, confidenceHigh: 72 }, + { timestamp: Date.parse( '2024-03-01' ), measurement: 70, confidenceLow: 60, confidenceHigh: 80 }, +]; + +const customAccessors = { + x: ( d: CustomDatum ) => new Date( d.timestamp ), + y: ( d: CustomDatum ) => d.measurement, + yLower: ( d: CustomDatum ) => d.confidenceLow, + yUpper: ( d: CustomDatum ) => d.confidenceHigh, +}; + +export const GenericDataType: StoryObj< StoryArgs > = { + render: () => ( + + ), + parameters: { + docs: { + description: { + story: + 'The chart supports generic data types via accessor functions. ' + + 'This example uses a custom datum shape with timestamp (number) instead of Date, ' + + 'and differently named fields for values and bounds.', + }, + }, + }, +}; + +/** + * Empty Data State + */ +export const EmptyData: StoryObj< StoryArgs > = Template.bind( {} ); +EmptyData.args = { + ...timeSeriesForecastChartStoryArgs, + data: [], +}; + +EmptyData.parameters = { + docs: { + description: { + story: 'When no data is provided, the chart displays a "No data available" message.', + }, + }, +}; + +/** + * Partial Bounds (some forecast points missing bounds) + */ +const partialBoundsData: SampleForecastDatum[] = [ + { date: new Date( '2024-01-01' ), value: 100 }, + { date: new Date( '2024-01-15' ), value: 110 }, + // Transition point - shared by both series (tight bounds at start) + { date: new Date( '2024-02-01' ), value: 120, lower: 120, upper: 120 }, + // Forecast with bounds + { date: new Date( '2024-02-15' ), value: 130, lower: 120, upper: 140 }, + // Forecast WITHOUT bounds (band won't include this point) + { date: new Date( '2024-03-01' ), value: 140 }, + // Forecast with bounds again + { date: new Date( '2024-03-15' ), value: 150, lower: 135, upper: 165 }, +]; + +export const PartialBounds: StoryObj< StoryArgs > = Template.bind( {} ); +PartialBounds.args = { + ...timeSeriesForecastChartStoryArgs, + data: partialBoundsData, + forecastStart: new Date( '2024-02-01' ), +}; + +PartialBounds.parameters = { + docs: { + description: { + story: + 'The uncertainty band only renders for forecast points that have both lower AND upper bounds. ' + + 'Points with missing bounds are still shown on the forecast line but excluded from the band.', + }, + }, +}; + +/** + * Legend at Top + */ +export const LegendAtTop: StoryObj< StoryArgs > = Template.bind( {} ); +LegendAtTop.args = { + ...timeSeriesForecastChartStoryArgs, + showLegend: true, + legendPosition: 'top', +}; + +LegendAtTop.parameters = { + docs: { + description: { + story: 'The legend positioned above the chart using legendPosition="top".', + }, + }, +}; diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss new file mode 100644 index 000000000000..7d0ee592e931 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss @@ -0,0 +1,60 @@ +.time-series-forecast-chart { + position: relative; + + &__svg-wrapper { + flex: 1; + min-height: 0; // Required for flex shrinking + } + + &--animated { + + path { + transform-origin: 0 95%; + transform: scaleY(0); + animation: rise 1s ease-out forwards; + } + } + + svg { + overflow: visible; + } + + &__tooltip { + background: var(--charts-tooltip-background, #fff); + color: var(--charts-tooltip-color, inherit); + padding: 0.5rem; + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + } + + &__tooltip-header { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; + padding-block-end: 8px; + } + + &__tooltip-row { + display: flex; + justify-content: space-between; + padding-block: 4px; + gap: 16px; + } + + &__forecast-badge { + font-size: 0.75rem; + font-weight: 500; + padding-block: 2px; + padding-inline: 6px; + border-radius: 3px; + background: var(--charts-forecast-badge-background, rgba(0, 0, 0, 0.1)); + } +} + +@keyframes rise { + + to { + transform: scaleY(1); + } +} diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx new file mode 100644 index 000000000000..d294b643dd48 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx @@ -0,0 +1,488 @@ +import { formatNumberCompact, formatNumber } from '@automattic/number-formatters'; +import { curveMonotoneX } from '@visx/curve'; +import { XYChart, AreaSeries, LineSeries, Grid, Axis, Tooltip } from '@visx/xychart'; +import { __ } from '@wordpress/i18n'; +import { Stack } from '@wordpress/ui'; +import clsx from 'clsx'; +import { useContext, useMemo, useCallback } from 'react'; +import { Legend } from '../../components/legend'; +import { useXYChartTheme, useElementHeight, usePrefersReducedMotion } from '../../hooks'; +import { + GlobalChartsProvider, + GlobalChartsContext, + useChartId, + useGlobalChartsContext, + useGlobalChartsTheme, +} from '../../providers'; +import { withResponsive } from '../private/with-responsive'; +import { ForecastDivider, useForecastData } from './private'; +import styles from './time-series-forecast-chart.module.scss'; +import type { + TimeSeriesForecastChartProps, + TransformedForecastPoint, + BandPoint, + SeriesKeys, +} from './types'; +import type { BaseLegendItem } from '../../components/legend'; +import type { Optional } from '../../types'; +import type { TickFormatter } from '@visx/axis'; +import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip'; +import type { FC } from 'react'; + +const DEFAULT_MARGIN = { top: 20, right: 20, bottom: 40, left: 50 }; + +const DEFAULT_SERIES_KEYS: Required< SeriesKeys > = { + historical: 'Historical', + forecast: 'Forecast', + band: 'Uncertainty', +}; + +const formatDateTick = ( d: Date ) => { + return d.toLocaleDateString( undefined, { + month: 'short', + day: 'numeric', + } ); +}; + +/** + * Internal component that renders the TimeSeriesForecastChart + * + * @param root0 - Component props + * @param root0.data - Array of data points to display + * @param root0.accessors - Accessor functions to extract values from data + * @param root0.forecastStart - Date at which forecast begins + * @param root0.height - Chart height in pixels + * @param root0.width - Chart width in pixels + * @param root0.margin - Chart margin configuration + * @param root0.yDomain - Optional fixed y-axis domain + * @param root0.xTickFormat - Formatter function for x-axis ticks + * @param root0.yTickFormat - Formatter function for y-axis ticks + * @param root0.withTooltips - Whether to show tooltips on hover + * @param root0.seriesKeys - Custom labels for series in legend/tooltip + * @param root0.renderTooltip - Custom tooltip renderer function + * @param root0.className - Additional CSS class name + * @param root0.chartId - Unique chart identifier + * @param root0.showLegend - Whether to show the legend + * @param root0.legendPosition - Position of the legend (top or bottom) + * @param root0.animation - Whether to enable chart animations + * @param root0.gridVisibility - Which grid lines to show (x, y, xy, or none) + * @param root0.gap - Gap between chart elements using WP design tokens + * @param root0.bandFillOpacity - Opacity of the uncertainty band fill + * @return The rendered chart component + */ +function TimeSeriesForecastChartInternal< D >( { + data, + accessors, + forecastStart, + height, + width = 600, + margin, + yDomain: providedYDomain, + xTickFormat = formatDateTick, + yTickFormat, + withTooltips = true, + seriesKeys: providedSeriesKeys, + renderTooltip, + className, + chartId: providedChartId, + showLegend = false, + legendPosition = 'bottom', + animation = false, + gridVisibility = 'y', + gap = 'md', + bandFillOpacity = 0.2, +}: TimeSeriesForecastChartProps< D > ) { + const providerTheme = useGlobalChartsTheme(); + const { getElementStyles } = useGlobalChartsContext(); + const chartId = useChartId( providedChartId ); + const [ svgWrapperRef, svgWrapperHeight ] = useElementHeight< HTMLDivElement >(); + const prefersReducedMotion = usePrefersReducedMotion(); + + // Merge series keys with defaults + const seriesKeys = useMemo( + () => ( { + ...DEFAULT_SERIES_KEYS, + ...providedSeriesKeys, + } ), + [ providedSeriesKeys ] + ); + + // Transform and split data + const { + allPoints, + historical, + forecast, + bandData, + yDomain: computedYDomain, + xDomain, + } = useForecastData( { + data, + accessors, + forecastStart, + } ); + + // Use provided y domain or computed one + const yDomain = providedYDomain ?? computedYDomain; + + // Get theme colors and styles + const { color: primaryColor } = getElementStyles( { + index: 0, + data: { group: 'primary', label: seriesKeys.historical, data: [] }, + } ); + const { color: bandColor } = getElementStyles( { + index: 1, + data: { group: 'primary', label: seriesKeys.band, data: [] }, + } ); + const { lineStyles: forecastLineStyles, shapeStyles: forecastShapeStyles } = getElementStyles( { + index: 1, + data: { group: 'primary', label: seriesKeys.forecast, data: [], options: {} }, + } ); + + // Create mock series data for theme hook + const mockSeriesData = useMemo( + () => [ + { label: seriesKeys.historical, data: [] }, + { label: seriesKeys.forecast, data: [] }, + ], + [ seriesKeys ] + ); + const theme = useXYChartTheme( mockSeriesData ); + + // Computed margin + const computedMargin = useMemo( + () => ( { + ...DEFAULT_MARGIN, + ...margin, + } ), + [ margin ] + ); + + // Use the measured SVG wrapper height, falling back to the passed height if provided. + // When there's a legend, we must wait for measurement because + // the legend takes space and the svg-wrapper height will be less than the total height. + const chartHeight = svgWrapperHeight > 0 ? svgWrapperHeight : height; + const isWaitingForMeasurement = showLegend ? svgWrapperHeight === 0 : ! chartHeight; + + // Create legend items + const legendItems = useMemo< BaseLegendItem[] >( () => { + const items: BaseLegendItem[] = []; + + if ( historical.length > 0 ) { + items.push( { + label: seriesKeys.historical, + value: '', + color: primaryColor, + } ); + } + + if ( forecast.length > 0 ) { + items.push( { + label: seriesKeys.forecast, + value: '', + color: primaryColor, + shapeStyle: { strokeDasharray: '5 5', ...forecastShapeStyles }, + } ); + } + + return items; + }, [ historical.length, forecast.length, seriesKeys, primaryColor, forecastShapeStyles ] ); + + // Accessors for transformed points (memoized to avoid recreating on each render) + const xAccessor = useCallback( ( p: TransformedForecastPoint | BandPoint ) => p.date, [] ); + const yAccessor = useCallback( ( p: TransformedForecastPoint ) => p.value, [] ); + const bandUpperAccessor = useCallback( ( p: BandPoint ) => p.upper, [] ); + const bandLowerAccessor = useCallback( ( p: BandPoint ) => p.lower, [] ); + + // Default tooltip renderer (memoized to avoid recreating on each render) + const defaultRenderTooltip = useCallback( + ( params: RenderTooltipParams< TransformedForecastPoint > ) => { + const { tooltipData } = params; + const nearestDatum = tooltipData?.nearestDatum?.datum; + + if ( ! nearestDatum ) { + return null; + } + + const isForecast = nearestDatum.date.getTime() >= forecastStart.getTime(); + const hasBounds = nearestDatum.lower !== null && nearestDatum.upper !== null; + + return ( +
+
+ { xTickFormat( nearestDatum.date ) } + { isForecast && ( + + { seriesKeys.forecast } + + ) } +
+
+ { __( 'Value', 'jetpack-charts' ) }: + { formatNumber( nearestDatum.value ) } +
+ { isForecast && hasBounds && ( +
+ { __( 'Range', 'jetpack-charts' ) }: + + { formatNumber( nearestDatum.lower as number ) } -{ ' ' } + { formatNumber( nearestDatum.upper as number ) } + +
+ ) } +
+ ); + }, + [ forecastStart, seriesKeys.forecast, xTickFormat ] + ); + + // Custom tooltip renderer that wraps user-provided function + const tooltipRenderer = useMemo( () => { + if ( renderTooltip ) { + return ( params: RenderTooltipParams< TransformedForecastPoint > ) => { + const nearestDatum = params.tooltipData?.nearestDatum?.datum; + if ( ! nearestDatum ) return null; + + // originalIndex refers to the position in the original data array (before sorting), + // so this correctly retrieves the user's original datum for custom tooltip rendering + const originalDatum = data[ nearestDatum.originalIndex ]; + const isForecast = nearestDatum.date.getTime() >= forecastStart.getTime(); + + return renderTooltip( { + nearest: originalDatum, + x: nearestDatum.date, + y: nearestDatum.value, + yLower: nearestDatum.lower ?? undefined, + yUpper: nearestDatum.upper ?? undefined, + isForecast, + } ); + }; + } + return defaultRenderTooltip; + }, [ renderTooltip, data, forecastStart, defaultRenderTooltip ] ); + + // Y-axis tick formatter + const yAxisTickFormat = useMemo( () => { + if ( yTickFormat ) { + return yTickFormat as TickFormatter< number >; + } + return formatNumberCompact as TickFormatter< number >; + }, [ yTickFormat ] ); + + // X-axis tick formatter - use directly without wrapper + const xAxisTickFormat = xTickFormat; + + // Handle empty data + if ( data.length === 0 ) { + return ( +
+ { __( 'No data available', 'jetpack-charts' ) } +
+ ); + } + + // Determine if we should show the divider + const showDivider = forecast.length > 0 && historical.length > 0; + + // Determine grid settings + const showXGrid = gridVisibility === 'x' || gridVisibility === 'xy'; + const showYGrid = gridVisibility === 'y' || gridVisibility === 'xy'; + + const legendElement = showLegend && ( + + ); + + return ( + + { legendPosition === 'top' && legendElement } + +
+ { ! isWaitingForMeasurement && ( + + { gridVisibility !== 'none' && ( + + ) } + + + + + { /* Uncertainty band - rendered first (behind lines), no pointer events */ } + { bandData.length > 0 && ( + + ) } + + { /* Historical line - solid, visual only */ } + { historical.length > 0 && ( + + ) } + + { /* Forecast line - dashed, visual only */ } + { forecast.length > 0 && ( + + ) } + + { /* Invisible line for tooltip - handles all pointer events */ } + + + { /* Vertical divider at forecast start */ } + { showDivider && ( + + ) } + + { /* Tooltip */ } + { withTooltips && ( + + ) } + + ) } +
+ + { legendPosition === 'bottom' && legendElement } +
+ ); +} + +/** + * TimeSeriesForecastChart with GlobalChartsProvider wrapper. + * Automatically wraps with provider if not already in one. + * + * @param props - Chart component props + * @return The rendered chart component with provider context + */ +function TimeSeriesForecastChartWithProvider< D >( + props: TimeSeriesForecastChartProps< D > +): JSX.Element { + const existingContext = useContext( GlobalChartsContext ); + + if ( existingContext ) { + return ; + } + + return ( + + + + ); +} + +TimeSeriesForecastChartWithProvider.displayName = 'TimeSeriesForecastChart'; + +/** + * TimeSeriesForecastChart - Displays historical data and forecasts with uncertainty bands + * + * Features: + * - Historical series rendered as a solid line + * - Forecast series rendered as a dashed line + * - Uncertainty band (shaded area) between upper/lower bounds + * - Vertical divider at the forecast start date + * - Generic data type support via accessors + */ +const TimeSeriesForecastChart = withResponsive( + TimeSeriesForecastChartWithProvider as FC< TimeSeriesForecastChartProps< unknown > > +) as < D >( + props: Optional< TimeSeriesForecastChartProps< D >, 'width' | 'height' > & { + maxWidth?: number; + aspectRatio?: number; + resizeDebounceTime?: number; + } +) => JSX.Element; + +/** + * Non-responsive version of TimeSeriesForecastChart + * Requires explicit width and height props + */ +const TimeSeriesForecastChartUnresponsive = TimeSeriesForecastChartWithProvider as < D >( + props: TimeSeriesForecastChartProps< D > +) => JSX.Element; + +export { TimeSeriesForecastChart as default, TimeSeriesForecastChartUnresponsive }; diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/types.ts b/projects/js-packages/charts/src/charts/time-series-forecast-chart/types.ts new file mode 100644 index 000000000000..58cd693366c3 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/types.ts @@ -0,0 +1,191 @@ +import type { GapSize } from '@wordpress/theme'; +import type { ReactNode } from 'react'; + +/** + * Generic accessor type for extracting values from datum + */ +export type Accessor< D, R > = ( d: D, index: number ) => R; + +/** + * Accessors for extracting values from generic datum + */ +export type TimeSeriesForecastAccessors< D > = { + /** + * Accessor function to extract the x-axis date value from each datum + */ + x: Accessor< D, Date >; + /** + * Accessor function to extract the y-axis numeric value from each datum + */ + y: Accessor< D, number >; + /** + * Optional accessor function to extract the lower bound of the forecast uncertainty. + * The uncertainty band is only rendered for points where BOTH yLower and yUpper return + * valid numbers. If bounds are not provided, the forecast line will still be shown + * without an uncertainty band. + */ + yLower?: Accessor< D, number | null | undefined >; + /** + * Optional accessor function to extract the upper bound of the forecast uncertainty. + * The uncertainty band is only rendered for points where BOTH yLower and yUpper return + * valid numbers. If bounds are not provided, the forecast line will still be shown + * without an uncertainty band. + */ + yUpper?: Accessor< D, number | null | undefined >; +}; + +/** + * Series key labels for legend/tooltip + */ +export type SeriesKeys = { + /** + * Label for the historical series. Default: 'Historical' + */ + historical?: string; + /** + * Label for the forecast series. Default: 'Forecast' + */ + forecast?: string; + /** + * Label for the uncertainty band. Default: 'Uncertainty' + */ + band?: string; +}; + +/** + * Internal transformed point (used after accessor extraction) + */ +export type TransformedForecastPoint = { + date: Date; + value: number; + lower: number | null; + upper: number | null; + originalIndex: number; +}; + +/** + * Band point with guaranteed bounds + */ +export type BandPoint = { + date: Date; + lower: number; + upper: number; +}; + +/** + * Tooltip params passed to custom tooltip renderer + */ +export interface ForecastTooltipParams< D > { + /** + * The nearest datum to the cursor + */ + nearest: D; + /** + * The x-axis date value + */ + x: Date; + /** + * The y-axis numeric value + */ + y: number; + /** + * The lower bound of the forecast uncertainty (if available) + */ + yLower?: number; + /** + * The upper bound of the forecast uncertainty (if available) + */ + yUpper?: number; + /** + * Whether this point is in the forecast region + */ + isForecast: boolean; +} + +/** + * Main component props (generic over datum type D) + */ +export interface TimeSeriesForecastChartProps< D > { + /** + * Array of data points to display in the chart + */ + data: readonly D[]; + /** + * Accessor functions to extract values from each datum + */ + accessors: TimeSeriesForecastAccessors< D >; + /** + * The date at which the forecast begins + */ + forecastStart: Date; + /** + * Height of the chart in pixels + */ + height: number; + /** + * Width of the chart in pixels + */ + width?: number; + /** + * Chart margins + */ + margin?: { top?: number; right?: number; bottom?: number; left?: number }; + /** + * Fixed y-axis domain [min, max] + */ + yDomain?: [ number, number ]; + /** + * Custom formatter for x-axis tick labels + */ + xTickFormat?: ( d: Date ) => string; + /** + * Custom formatter for y-axis tick labels + */ + yTickFormat?: ( n: number ) => string; + /** + * Whether to show tooltips on hover. Default: true + */ + withTooltips?: boolean; + /** + * Custom labels for series in legend and tooltip + */ + seriesKeys?: SeriesKeys; + /** + * Custom tooltip renderer + */ + renderTooltip?: ( params: ForecastTooltipParams< D > ) => ReactNode; + /** + * Additional CSS class name for the chart container + */ + className?: string; + /** + * Optional unique identifier for the chart + */ + chartId?: string; + /** + * Whether to show the legend. Default: false + */ + showLegend?: boolean; + /** + * Legend position. Default: 'bottom' + */ + legendPosition?: 'top' | 'bottom'; + /** + * Whether to animate the chart on initial render. Default: false + */ + animation?: boolean; + /** + * Grid visibility. Default: 'y' + */ + gridVisibility?: 'x' | 'y' | 'xy' | 'none'; + /** + * Gap between chart elements (SVG, legend). + * Uses WordPress design system tokens. + * @default 'md' + */ + gap?: GapSize; + /** + * Opacity of the uncertainty band fill. Default: 0.2 + */ + bandFillOpacity?: number; +} diff --git a/projects/js-packages/charts/src/index.ts b/projects/js-packages/charts/src/index.ts index cb5991f8aa9c..e951e8b48943 100644 --- a/projects/js-packages/charts/src/index.ts +++ b/projects/js-packages/charts/src/index.ts @@ -8,6 +8,10 @@ export { LineChart, LineChartUnresponsive } from './charts/line-chart'; export { PieChart, PieChartUnresponsive } from './charts/pie-chart'; export { PieSemiCircleChart, PieSemiCircleChartUnresponsive } from './charts/pie-semi-circle-chart'; export { Sparkline, SparklineUnresponsive } from './charts/sparkline'; +export { + TimeSeriesForecastChart, + TimeSeriesForecastChartUnresponsive, +} from './charts/time-series-forecast-chart'; // Components export { BaseTooltip } from './components/tooltip'; @@ -39,6 +43,10 @@ export type { export type { GeoChartProps, GeoRegion, GeoResolution } from './charts/geo-chart'; export type { LegendValueDisplay, BaseLegendItem } from './components/legend'; export type { TrendIndicatorProps, TrendDirection } from './components/trend-indicator'; +export type { + TimeSeriesForecastChartProps, + TimeSeriesForecastAccessors, +} from './charts/time-series-forecast-chart'; export type { LineStyles, GridStyles, EventHandlerParams } from '@visx/xychart'; export type { GoogleDataTableColumn,