From 6de3d98c5b03d382e4989bc9f6342d91ca047063 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:35:49 +1100 Subject: [PATCH 01/18] Add type definitions for TimeSeriesForecastChart --- .../time-series-forecast-chart/types.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/types.ts 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..fcaef7b1fa4b --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/types.ts @@ -0,0 +1,174 @@ +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 + */ + yLower?: Accessor< D, number | null | undefined >; + /** + * Optional accessor function to extract the upper bound of the forecast uncertainty + */ + 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 + */ + showTooltip?: 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'; +} From 978eb910260bfe8bd0410c8f238b9e77422367d3 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:36:23 +1100 Subject: [PATCH 02/18] Add data transformation hook for forecast data splitting --- .../private/use-forecast-data.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/private/use-forecast-data.ts 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..e90e8777b33f --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/use-forecast-data.ts @@ -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 ] ); +} From c25eaf53376fbf1596fa2a54ac2ad406c97531d7 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:36:48 +1100 Subject: [PATCH 03/18] Add forecast divider component --- .../private/forecast-divider.tsx | 57 +++++++++++++++++++ .../private/index.ts | 2 + 2 files changed, 59 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/private/forecast-divider.tsx create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/private/index.ts 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..19d1989444e7 --- /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 || ! 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 ( + + ); +}; 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'; From 88962184c8ceb2ec4cddc54faacf2a2ca9e8ae43 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:07 +1100 Subject: [PATCH 04/18] Add TimeSeriesForecastChart component with styles --- .../time-series-forecast-chart/index.ts | 12 + .../time-series-forecast-chart.module.scss | 63 +++ .../time-series-forecast-chart.tsx | 442 ++++++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/index.ts create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx 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/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..54bb774da261 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss @@ -0,0 +1,63 @@ +.time-series-forecast-chart { + display: flex; + flex-direction: column; + position: relative; + + &--animated { + + path { + transform-origin: 0 95%; + transform: scaleY(0); + animation: rise 1s ease-out forwards; + } + } + + &--legend-top { + flex-direction: column-reverse; + } + + svg { + overflow: visible; + } + + &__tooltip { + background: #fff; + 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-bottom: 8px; + } + + &__tooltip-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + gap: 16px; + } + + &__forecast-badge { + font-size: 0.75rem; + font-weight: 500; + padding: 2px 6px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.1); + } + + &__legend { + padding-top: 8px; + } +} + +@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..3ca648b07449 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx @@ -0,0 +1,442 @@ +import { formatNumberCompact, formatNumber } from '@automattic/number-formatters'; +import { curveMonotoneX } from '@visx/curve'; +import { XYChart, AreaSeries, Grid, Axis, Tooltip } from '@visx/xychart'; +import { __ } from '@wordpress/i18n'; +import clsx from 'clsx'; +import { useContext, useMemo, useCallback } from 'react'; +import { Legend, useChartLegendItems } from '../../components/legend'; +import { useXYChartTheme, useElementHeight, usePrefersReducedMotion } from '../../hooks'; +import { + GlobalChartsProvider, + GlobalChartsContext, + useChartId, + 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.showTooltip - 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) + * @return The rendered chart component + */ +function TimeSeriesForecastChartInternal< D >( { + data, + accessors, + forecastStart, + height, + width = 600, + margin, + yDomain: providedYDomain, + xTickFormat = formatDateTick, + yTickFormat, + showTooltip = true, + seriesKeys: providedSeriesKeys, + renderTooltip, + className, + chartId: providedChartId, + showLegend = false, + legendPosition = 'bottom', + animation = false, + gridVisibility = 'y', +}: TimeSeriesForecastChartProps< D > ) { + const providerTheme = useGlobalChartsTheme(); + const chartId = useChartId( providedChartId ); + const [ legendRef, legendHeight ] = useElementHeight< HTMLDivElement >(); + const prefersReducedMotion = usePrefersReducedMotion(); + + // Merge series keys with defaults + const seriesKeys = useMemo( + () => ( { + ...DEFAULT_SERIES_KEYS, + ...providedSeriesKeys, + } ), + [ providedSeriesKeys ] + ); + + // Transform and split data + const { + historical, + forecast, + bandData, + yDomain: computedYDomain, + xDomain, + } = useForecastData( { + data, + accessors, + forecastStart, + } ); + + // Use provided y domain or computed one + const yDomain = providedYDomain ?? computedYDomain; + + // Get theme colors + const primaryColor = providerTheme.colors[ 0 ] ?? '#3858e9'; + const bandColor = providerTheme.colors[ 1 ] ?? primaryColor; + + // 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 ] + ); + + // Chart dimensions accounting for legend + const chartHeight = height - ( showLegend ? legendHeight : 0 ); + + // 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' }, + } ); + } + + return items; + }, [ historical.length, forecast.length, seriesKeys, primaryColor ] ); + + // Use legend hook for consistent styling + useChartLegendItems( mockSeriesData, {}, 'line' ); + + // 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; + + 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 wrapper + const xAxisTickFormat = useMemo( () => { + return ( d: Date ) => xTickFormat( d ); + }, [ 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'; + + return ( +
+ + { gridVisibility !== 'none' && ( + + ) } + + + + + { /* Uncertainty band - rendered first (behind lines) */ } + { bandData.length > 0 && ( + + ) } + + { /* Historical line - solid */ } + { historical.length > 0 && ( + + ) } + + { /* Forecast line - dashed */ } + { forecast.length > 0 && ( + + ) } + + { /* Vertical divider at forecast start */ } + { showDivider && ( + + ) } + + { /* Tooltip */ } + { showTooltip && ( + + ) } + + + { showLegend && ( + + ) } +
+ ); +} + +/** + * 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 ( + + + + ); +} + +/** + * 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 }; From 9ea1b54059810faf6b0f1f9789baf471e2f733df Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:16 +1100 Subject: [PATCH 05/18] Add TimeSeriesForecastChart exports to package --- projects/js-packages/charts/package.json | 9 +++++++++ projects/js-packages/charts/src/index.ts | 8 ++++++++ 2 files changed, 17 insertions(+) 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/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, From 03090a6895cd069511da537d0ba68319cf6453a1 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:26 +1100 Subject: [PATCH 06/18] Add TimeSeriesForecastChart Storybook stories --- .../stories/config.tsx | 156 ++++++ .../stories/index.stories.tsx | 453 ++++++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/config.tsx create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.stories.tsx 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..45ed52bab979 --- /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, + 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, +}; 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".', + }, + }, +}; From 5bbc4c3e13433e548b008a497a2dc19fca6f0af6 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 10:11:06 +1100 Subject: [PATCH 07/18] Add changelog entry for TimeSeriesForecastChart component --- .../charts/changelog/add-time-series-forecast-chart | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/js-packages/charts/changelog/add-time-series-forecast-chart 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. From ad6dd44d6214a895dcf4393e2b3d0f389087f912 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 11:53:42 +1100 Subject: [PATCH 08/18] Address Copilot review feedback --- .../private/forecast-divider.tsx | 2 +- .../private/use-forecast-data.ts | 13 +++++++------ .../time-series-forecast-chart.module.scss | 6 +++--- .../time-series-forecast-chart.tsx | 8 ++++---- .../src/charts/time-series-forecast-chart/types.ts | 10 ++++++++-- 5 files changed, 23 insertions(+), 16 deletions(-) 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 index 19d1989444e7..0e7c71edd34e 100644 --- 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 @@ -28,7 +28,7 @@ export const ForecastDivider: FC< ForecastDividerProps > = ( { } ) => { const context = useContext( DataContext ); - if ( ! context?.xScale || ! context?.yScale ) { + if ( ! context?.xScale ) { return null; } 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 index e90e8777b33f..dcf8df2f9129 100644 --- 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 @@ -88,19 +88,20 @@ export function useForecastData< D >( { 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 reduce instead of spread to avoid stack overflow on large arrays const allYValues = [ ...allValues, ...allLowers, ...allUppers ]; - const minY = Math.min( ...allYValues ); - const maxY = Math.max( ...allYValues ); + 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 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 ); + // 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( Math.min( ...allDates.map( d => d.getTime() ) ) ), - new Date( Math.max( ...allDates.map( d => d.getTime() ) ) ), + new Date( allDateTimes.reduce( ( min, val ) => Math.min( min, val ), Infinity ) ), + new Date( allDateTimes.reduce( ( max, val ) => Math.max( max, val ), -Infinity ) ), ]; return { historical, forecast, bandData, yDomain, xDomain }; 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 index 54bb774da261..07dfd93df463 100644 --- 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 @@ -32,13 +32,13 @@ align-items: center; gap: 8px; font-weight: 700; - padding-bottom: 8px; + padding-block-end: 8px; } &__tooltip-row { display: flex; justify-content: space-between; - padding: 4px 0; + padding-block: 4px; gap: 16px; } @@ -51,7 +51,7 @@ } &__legend { - padding-top: 8px; + padding-block-start: 8px; } } 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 index 3ca648b07449..d1804093c879 100644 --- 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 @@ -224,6 +224,8 @@ function TimeSeriesForecastChartInternal< D >( { 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(); @@ -248,10 +250,8 @@ function TimeSeriesForecastChartInternal< D >( { return formatNumberCompact as TickFormatter< number >; }, [ yTickFormat ] ); - // X-axis tick formatter wrapper - const xAxisTickFormat = useMemo( () => { - return ( d: Date ) => xTickFormat( d ); - }, [ xTickFormat ] ); + // X-axis tick formatter - use directly without wrapper + const xAxisTickFormat = xTickFormat; // Handle empty data if ( data.length === 0 ) { 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 index fcaef7b1fa4b..cf5325abf04b 100644 --- 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 @@ -18,11 +18,17 @@ export type TimeSeriesForecastAccessors< D > = { */ y: Accessor< D, number >; /** - * Optional accessor function to extract the lower bound of the forecast uncertainty + * 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 + * 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 >; }; From 7d5505bc78124a855a499049840abc806e8364b9 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:32:29 +1100 Subject: [PATCH 09/18] Fix TimeSeriesForecastChart API consistency and minor issues --- .../private/use-forecast-data.ts | 2 +- .../time-series-forecast-chart/stories/config.tsx | 4 ++-- .../time-series-forecast-chart.module.scss | 5 +++-- .../time-series-forecast-chart.tsx | 13 ++++++------- .../src/charts/time-series-forecast-chart/types.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) 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 index dcf8df2f9129..db38f91de8a9 100644 --- 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 @@ -95,7 +95,7 @@ export function useForecastData< D >( { // Add some padding to y domain const yPadding = ( maxY - minY ) * 0.1; - const yDomain: [ number, number ] = [ Math.max( 0, minY - yPadding ), maxY + yPadding ]; + 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() ); 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 index 45ed52bab979..e67e9177a4fd 100644 --- 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 @@ -122,7 +122,7 @@ export const timeSeriesForecastChartMetaArgs: Meta< StoryArgs > = { ...legendArgTypes, ...themeArgTypes, ...sharedChartArgTypes, - showTooltip: { + withTooltips: { control: 'boolean', description: 'Show tooltips on hover', table: { category: 'Tooltip' }, @@ -146,7 +146,7 @@ export const timeSeriesForecastChartStoryArgs = { data: sampleForecastData, accessors: sampleAccessors, forecastStart: sampleForecastStart, - showTooltip: true, + withTooltips: true, showLegend: false, animation: false, gridVisibility: 'y' as const, 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 index 07dfd93df463..da8b198a310f 100644 --- 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 @@ -21,7 +21,8 @@ } &__tooltip { - background: #fff; + 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); @@ -47,7 +48,7 @@ font-weight: 500; padding: 2px 6px; border-radius: 3px; - background: rgba(0, 0, 0, 0.1); + background: var(--charts-forecast-badge-background, rgba(0, 0, 0, 0.1)); } &__legend { 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 index d1804093c879..37d3a33e8b1e 100644 --- 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 @@ -4,7 +4,7 @@ import { XYChart, AreaSeries, Grid, Axis, Tooltip } from '@visx/xychart'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; import { useContext, useMemo, useCallback } from 'react'; -import { Legend, useChartLegendItems } from '../../components/legend'; +import { Legend } from '../../components/legend'; import { useXYChartTheme, useElementHeight, usePrefersReducedMotion } from '../../hooks'; import { GlobalChartsProvider, @@ -55,7 +55,7 @@ const formatDateTick = ( d: Date ) => { * @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.showTooltip - Whether to show tooltips on hover + * @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 @@ -76,7 +76,7 @@ function TimeSeriesForecastChartInternal< D >( { yDomain: providedYDomain, xTickFormat = formatDateTick, yTickFormat, - showTooltip = true, + withTooltips = true, seriesKeys: providedSeriesKeys, renderTooltip, className, @@ -166,9 +166,6 @@ function TimeSeriesForecastChartInternal< D >( { return items; }, [ historical.length, forecast.length, seriesKeys, primaryColor ] ); - // Use legend hook for consistent styling - useChartLegendItems( mockSeriesData, {}, 'line' ); - // 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, [] ); @@ -362,7 +359,7 @@ function TimeSeriesForecastChartInternal< D >( { ) } { /* Tooltip */ } - { showTooltip && ( + { withTooltips && ( ( ); } +TimeSeriesForecastChartWithProvider.displayName = 'TimeSeriesForecastChart'; + /** * TimeSeriesForecastChart - Displays historical data and forecasts with uncertainty bands * 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 index cf5325abf04b..4c8f04e3590a 100644 --- 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 @@ -144,7 +144,7 @@ export interface TimeSeriesForecastChartProps< D > { /** * Whether to show tooltips on hover. Default: true */ - showTooltip?: boolean; + withTooltips?: boolean; /** * Custom labels for series in legend and tooltip */ From 0faf448b54c8b459b36c128941d569c65adf6fb5 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:36:10 +1100 Subject: [PATCH 10/18] Address Copilot feedback on array concat and RTL padding --- .../private/use-forecast-data.ts | 8 ++++---- .../time-series-forecast-chart.module.scss | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) 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 index db38f91de8a9..c67f97331fff 100644 --- 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 @@ -88,13 +88,13 @@ export function useForecastData< D >( { 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 reduce instead of spread to avoid stack overflow on large arrays - const allYValues = [ ...allValues, ...allLowers, ...allUppers ]; + // 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 - const yPadding = ( maxY - minY ) * 0.1; + // 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) 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 index da8b198a310f..f244bea5cdbb 100644 --- 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 @@ -46,7 +46,8 @@ &__forecast-badge { font-size: 0.75rem; font-weight: 500; - padding: 2px 6px; + padding-block: 2px; + padding-inline: 6px; border-radius: 3px; background: var(--charts-forecast-badge-background, rgba(0, 0, 0, 0.1)); } From 70667b9b7d52ed1026dd6cf78bc7d01c20bb57bd Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:41:43 +1100 Subject: [PATCH 11/18] Fix tooltip snapping to band instead of data lines --- .../time-series-forecast-chart/time-series-forecast-chart.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 37d3a33e8b1e..8d281a3f7591 100644 --- 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 @@ -304,7 +304,7 @@ function TimeSeriesForecastChartInternal< D >( { - { /* Uncertainty band - rendered first (behind lines) */ } + { /* Uncertainty band - rendered first (behind lines), no pointer events */ } { bandData.length > 0 && ( ( { fillOpacity={ 0.2 } renderLine={ false } curve={ curveMonotoneX } + enableEvents={ false } /> ) } From 22ef01330eb68f1046dfa2c8b63d3fb8873d6939 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:47:08 +1100 Subject: [PATCH 12/18] Fix tooltip to work across all data points using hidden series --- .../private/use-forecast-data.ts | 9 +++++++-- .../time-series-forecast-chart.tsx | 20 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) 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 index c67f97331fff..72730823215d 100644 --- 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 @@ -8,8 +8,13 @@ interface UseForecastDataOptions< D > { } 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 ]; @@ -70,7 +75,7 @@ export function useForecastData< D >( { 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 + // 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 ); @@ -104,6 +109,6 @@ export function useForecastData< D >( { new Date( allDateTimes.reduce( ( max, val ) => Math.max( max, val ), -Infinity ) ), ]; - return { historical, forecast, bandData, yDomain, xDomain }; + return { allPoints: transformed, historical, forecast, bandData, yDomain, xDomain }; }, [ data, accessors, forecastStart ] ); } 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 index 8d281a3f7591..4444b68bd50a 100644 --- 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 @@ -1,6 +1,6 @@ import { formatNumberCompact, formatNumber } from '@automattic/number-formatters'; import { curveMonotoneX } from '@visx/curve'; -import { XYChart, AreaSeries, Grid, Axis, Tooltip } from '@visx/xychart'; +import { XYChart, AreaSeries, LineSeries, Grid, Axis, Tooltip } from '@visx/xychart'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; import { useContext, useMemo, useCallback } from 'react'; @@ -102,6 +102,7 @@ function TimeSeriesForecastChartInternal< D >( { // Transform and split data const { + allPoints, historical, forecast, bandData, @@ -320,7 +321,7 @@ function TimeSeriesForecastChartInternal< D >( { /> ) } - { /* Historical line - solid */ } + { /* Historical line - solid, visual only */ } { historical.length > 0 && ( ( { renderLine={ true } curve={ curveMonotoneX } lineProps={ { stroke: primaryColor } } + enableEvents={ false } /> ) } - { /* Forecast line - dashed */ } + { /* Forecast line - dashed, visual only */ } { forecast.length > 0 && ( ( { stroke: primaryColor, strokeDasharray: '5 5', } } + enableEvents={ false } /> ) } + { /* Invisible line for tooltip - handles all pointer events */ } + + { /* Vertical divider at forecast start */ } { showDivider && ( Date: Fri, 30 Jan 2026 14:34:25 +1100 Subject: [PATCH 13/18] Add missing allPoints property in empty data case --- .../time-series-forecast-chart/private/use-forecast-data.ts | 1 + 1 file changed, 1 insertion(+) 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 index 72730823215d..8baeab643175 100644 --- 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 @@ -54,6 +54,7 @@ export function useForecastData< D >( { if ( data.length === 0 ) { const now = new Date(); return { + allPoints: [], historical: [], forecast: [], bandData: [], From 164b2aa4afbcdf412104febb0963b4e9eb2695fa Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 13:43:45 +1100 Subject: [PATCH 14/18] Adopt new responsive layout pattern for TimeSeriesForecastChart --- .../time-series-forecast-chart.module.scss | 15 +- .../time-series-forecast-chart.tsx | 235 ++++++++++-------- .../time-series-forecast-chart/types.ts | 7 + 3 files changed, 138 insertions(+), 119 deletions(-) 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 index f244bea5cdbb..7d0ee592e931 100644 --- 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 @@ -1,8 +1,11 @@ .time-series-forecast-chart { - display: flex; - flex-direction: column; position: relative; + &__svg-wrapper { + flex: 1; + min-height: 0; // Required for flex shrinking + } + &--animated { path { @@ -12,10 +15,6 @@ } } - &--legend-top { - flex-direction: column-reverse; - } - svg { overflow: visible; } @@ -51,10 +50,6 @@ border-radius: 3px; background: var(--charts-forecast-badge-background, rgba(0, 0, 0, 0.1)); } - - &__legend { - padding-block-start: 8px; - } } @keyframes rise { 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 index 4444b68bd50a..1073c0b98cf8 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -64,6 +65,7 @@ const formatDateTick = ( d: Date ) => { * @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 * @return The rendered chart component */ function TimeSeriesForecastChartInternal< D >( { @@ -85,10 +87,11 @@ function TimeSeriesForecastChartInternal< D >( { legendPosition = 'bottom', animation = false, gridVisibility = 'y', + gap = 'md', }: TimeSeriesForecastChartProps< D > ) { const providerTheme = useGlobalChartsTheme(); const chartId = useChartId( providedChartId ); - const [ legendRef, legendHeight ] = useElementHeight< HTMLDivElement >(); + const [ svgWrapperRef, svgWrapperHeight ] = useElementHeight< HTMLDivElement >(); const prefersReducedMotion = usePrefersReducedMotion(); // Merge series keys with defaults @@ -140,8 +143,11 @@ function TimeSeriesForecastChartInternal< D >( { [ margin ] ); - // Chart dimensions accounting for legend - const chartHeight = height - ( showLegend ? legendHeight : 0 ); + // 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[] >( () => { @@ -274,129 +280,140 @@ function TimeSeriesForecastChartInternal< D >( { const showXGrid = gridVisibility === 'x' || gridVisibility === 'xy'; const showYGrid = gridVisibility === 'y' || gridVisibility === 'xy'; + const legendElement = showLegend && ( + + ); + return ( -
- - { gridVisibility !== 'none' && ( - - ) } + { legendPosition === 'top' && legendElement } + +
+ { ! isWaitingForMeasurement && ( + + { gridVisibility !== 'none' && ( + + ) } - - - - { /* Uncertainty band - rendered first (behind lines), no pointer events */ } - { bandData.length > 0 && ( - - ) } + + + + { /* Uncertainty band - rendered first (behind lines), no pointer events */ } + { bandData.length > 0 && ( + + ) } - { /* Historical line - solid, visual only */ } - { historical.length > 0 && ( - - ) } + { /* Historical line - solid, visual only */ } + { historical.length > 0 && ( + + ) } - { /* Forecast line - dashed, visual only */ } - { forecast.length > 0 && ( - - ) } + { /* Forecast line - dashed, visual only */ } + { forecast.length > 0 && ( + + ) } - { /* Invisible line for tooltip - handles all pointer events */ } - - - { /* Vertical divider at forecast start */ } - { showDivider && ( - - ) } + { /* Invisible line for tooltip - handles all pointer events */ } + + + { /* Vertical divider at forecast start */ } + { showDivider && ( + + ) } - { /* Tooltip */ } - { withTooltips && ( - + { /* Tooltip */ } + { withTooltips && ( + + ) } + ) } - - - { showLegend && ( - - ) } -
+
+ + { legendPosition === 'bottom' && legendElement } + ); } 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 index 4c8f04e3590a..a8826a41d0bc 100644 --- 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 @@ -1,3 +1,4 @@ +import type { GapSize } from '@wordpress/theme'; import type { ReactNode } from 'react'; /** @@ -177,4 +178,10 @@ export interface TimeSeriesForecastChartProps< D > { * Grid visibility. Default: 'y' */ gridVisibility?: 'x' | 'y' | 'xy' | 'none'; + /** + * Gap between chart elements (SVG, legend). + * Uses WordPress design system tokens. + * @default 'md' + */ + gap?: GapSize; } From 330dcf9b9f80de41a1455564861352f10c22d0cd Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 13:44:56 +1100 Subject: [PATCH 15/18] Use getElementStyles for theme color resolution in TimeSeriesForecastChart --- .../time-series-forecast-chart.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index 1073c0b98cf8..ee431dbf84d0 100644 --- 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 @@ -11,6 +11,7 @@ import { GlobalChartsProvider, GlobalChartsContext, useChartId, + useGlobalChartsContext, useGlobalChartsTheme, } from '../../providers'; import { withResponsive } from '../private/with-responsive'; @@ -90,6 +91,7 @@ function TimeSeriesForecastChartInternal< D >( { gap = 'md', }: TimeSeriesForecastChartProps< D > ) { const providerTheme = useGlobalChartsTheme(); + const { getElementStyles } = useGlobalChartsContext(); const chartId = useChartId( providedChartId ); const [ svgWrapperRef, svgWrapperHeight ] = useElementHeight< HTMLDivElement >(); const prefersReducedMotion = usePrefersReducedMotion(); @@ -121,8 +123,14 @@ function TimeSeriesForecastChartInternal< D >( { const yDomain = providedYDomain ?? computedYDomain; // Get theme colors - const primaryColor = providerTheme.colors[ 0 ] ?? '#3858e9'; - const bandColor = providerTheme.colors[ 1 ] ?? primaryColor; + 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: [] }, + } ); // Create mock series data for theme hook const mockSeriesData = useMemo( From 1e5391ac3451e11c2824a2efbb391d28b4c426fc Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 13:45:41 +1100 Subject: [PATCH 16/18] Remove unnecessary fallback on ForecastDivider color prop --- .../time-series-forecast-chart/time-series-forecast-chart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ee431dbf84d0..652eb4ed0d91 100644 --- 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 @@ -403,7 +403,7 @@ function TimeSeriesForecastChartInternal< D >( { { showDivider && ( ) } From 09e16699747f742cb9755516dbc638727b3e3ace Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 13:53:04 +1100 Subject: [PATCH 17/18] Make forecast strokeDasharray configurable via theme in TimeSeriesForecastChart --- .../time-series-forecast-chart.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 index 652eb4ed0d91..6ffdf3d261f6 100644 --- 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 @@ -122,7 +122,7 @@ function TimeSeriesForecastChartInternal< D >( { // Use provided y domain or computed one const yDomain = providedYDomain ?? computedYDomain; - // Get theme colors + // Get theme colors and styles const { color: primaryColor } = getElementStyles( { index: 0, data: { group: 'primary', label: seriesKeys.historical, data: [] }, @@ -131,6 +131,10 @@ function TimeSeriesForecastChartInternal< D >( { 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( @@ -174,12 +178,12 @@ function TimeSeriesForecastChartInternal< D >( { label: seriesKeys.forecast, value: '', color: primaryColor, - shapeStyle: { strokeDasharray: '5 5' }, + shapeStyle: { strokeDasharray: '5 5', ...forecastShapeStyles }, } ); } return items; - }, [ historical.length, forecast.length, seriesKeys, primaryColor ] ); + }, [ 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, [] ); @@ -383,6 +387,7 @@ function TimeSeriesForecastChartInternal< D >( { lineProps={ { stroke: primaryColor, strokeDasharray: '5 5', + ...forecastLineStyles, } } enableEvents={ false } /> From 5ec636decf61aed233d4bddc8ba75b3cd7491465 Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 13:54:37 +1100 Subject: [PATCH 18/18] Make band fillOpacity configurable via bandFillOpacity prop --- .../time-series-forecast-chart.tsx | 44 ++++++++++--------- .../time-series-forecast-chart/types.ts | 4 ++ 2 files changed, 27 insertions(+), 21 deletions(-) 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 index 6ffdf3d261f6..d294b643dd48 100644 --- 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 @@ -47,26 +47,27 @@ const formatDateTick = ( d: Date ) => { /** * 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 - 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 >( { @@ -89,6 +90,7 @@ function TimeSeriesForecastChartInternal< D >( { animation = false, gridVisibility = 'y', gap = 'md', + bandFillOpacity = 0.2, }: TimeSeriesForecastChartProps< D > ) { const providerTheme = useGlobalChartsTheme(); const { getElementStyles } = useGlobalChartsContext(); @@ -352,7 +354,7 @@ function TimeSeriesForecastChartInternal< D >( { yAccessor={ bandUpperAccessor } y0Accessor={ bandLowerAccessor } fill={ bandColor } - fillOpacity={ 0.2 } + fillOpacity={ bandFillOpacity } renderLine={ false } curve={ curveMonotoneX } enableEvents={ false } 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 index a8826a41d0bc..58cd693366c3 100644 --- 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 @@ -184,4 +184,8 @@ export interface TimeSeriesForecastChartProps< D > { * @default 'md' */ gap?: GapSize; + /** + * Opacity of the uncertainty band fill. Default: 0.2 + */ + bandFillOpacity?: number; }