From 5a102bc4b1c5c4791db03efc906bf158ea081da8 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:35:49 +1100 Subject: [PATCH 01/33] 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 89b33a0ed32847aacf13e7e54e636e759e743757 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:36:23 +1100 Subject: [PATCH 02/33] 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 c6d6be088269e4a59d4d6600444e5d0a120bbad3 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:36:48 +1100 Subject: [PATCH 03/33] 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 96845d0a8b7e2f70269d822045b908b35e426adb Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:07 +1100 Subject: [PATCH 04/33] 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 324f32bd26bb3bb950f2cee04381afc02c584cc6 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:16 +1100 Subject: [PATCH 05/33] 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 be004f7482b2..53237232b1b0 100644 --- a/projects/js-packages/charts/package.json +++ b/projects/js-packages/charts/package.json @@ -95,6 +95,12 @@ }, "./sparkline/style.css": "./dist/components/sparkline/index.css", "./style.css": "./dist/index.css", + "./time-series-forecast-chart": { + "jetpack:src": "./src/charts/time-series-forecast-chart/index.ts", + "import": "./dist/charts/time-series-forecast-chart/index.js", + "require": "./dist/charts/time-series-forecast-chart/index.cjs" + }, + "./time-series-forecast-chart/style.css": "./dist/charts/time-series-forecast-chart/index.css", "./tooltip": { "jetpack:src": "./src/components/tooltip/index.ts", "import": "./dist/components/tooltip/index.js", @@ -171,6 +177,9 @@ "sparkline": [ "./dist/components/sparkline/index.d.ts" ], + "time-series-forecast-chart": [ + "./dist/charts/time-series-forecast-chart/index.d.ts" + ], "tooltip": [ "./dist/components/tooltip/index.d.ts" ], diff --git a/projects/js-packages/charts/src/index.ts b/projects/js-packages/charts/src/index.ts index 240f96d21184..de595cd038ca 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'; @@ -35,6 +39,10 @@ export type { PieChartProps } from './charts/pie-chart'; 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 628f8d8c014e352fe15d8b3f10518eca7ccb3286 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:26 +1100 Subject: [PATCH 06/33] 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 db32d182e45b72bacffac4999dc0a05d6e269c9f Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 10:11:06 +1100 Subject: [PATCH 07/33] 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 767b6287c3195f3d6a4d8b8542057381b60a1876 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 11:53:42 +1100 Subject: [PATCH 08/33] 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 642e619a3d3f8e9bab95f908d9810eb3349a6d5e Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:25:59 +1100 Subject: [PATCH 09/33] Add i18n to TimeSeriesForecastChart default series keys --- .../time-series-forecast-chart.tsx | 6 +++--- 1 file changed, 3 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 d1804093c879..4a028f10e3de 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 @@ -30,9 +30,9 @@ 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', + historical: __( 'Historical', 'jetpack-charts' ), + forecast: __( 'Forecast', 'jetpack-charts' ), + band: __( 'Uncertainty', 'jetpack-charts' ), }; const formatDateTick = ( d: Date ) => { From d8c19f308e0f37d4378726cf11482c22c19d81ef Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:26:32 +1100 Subject: [PATCH 10/33] Remove unused useChartLegendItems from TimeSeriesForecastChart --- .../time-series-forecast-chart.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 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 4a028f10e3de..04b1fb1845b8 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, @@ -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, [] ); From a31a47f334f8914ebb1ca3c5d18e699ce0ef02c0 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:27:15 +1100 Subject: [PATCH 11/33] Align forecast line dasharray with divider for visual consistency --- .../time-series-forecast-chart/time-series-forecast-chart.tsx | 4 ++-- 1 file changed, 2 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 04b1fb1845b8..510b0ceafe45 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 @@ -159,7 +159,7 @@ function TimeSeriesForecastChartInternal< D >( { label: seriesKeys.forecast, value: '', color: primaryColor, - shapeStyle: { strokeDasharray: '5 5' }, + shapeStyle: { strokeDasharray: '4 4' }, } ); } @@ -345,7 +345,7 @@ function TimeSeriesForecastChartInternal< D >( { curve={ curveMonotoneX } lineProps={ { stroke: primaryColor, - strokeDasharray: '5 5', + strokeDasharray: '4 4', } } /> ) } From e2868abdf0755deae8f44080460212e542f228b7 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:27:45 +1100 Subject: [PATCH 12/33] Add legend prop types to TimeSeriesForecastChartProps --- .../time-series-forecast-chart/types.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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..4ce0581fc77e 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 { LegendShape } from '@visx/legend/lib/types'; import type { ReactNode } from 'react'; /** @@ -165,10 +166,37 @@ export interface TimeSeriesForecastChartProps< D > { * Whether to show the legend. Default: false */ showLegend?: boolean; + /** + * Legend orientation. Default: 'horizontal' + */ + legendOrientation?: 'horizontal' | 'vertical'; /** * Legend position. Default: 'bottom' */ legendPosition?: 'top' | 'bottom'; + /** + * Legend alignment within its position. Default: 'center' + */ + legendAlignment?: 'start' | 'center' | 'end'; + /** + * Maximum width for legend items. When set, text overflow behavior is controlled by legendTextOverflow. + * Should be a CSS value string (e.g. '200px', '50%', '10rem') + */ + legendMaxWidth?: string; + /** + * Controls how text behaves when it exceeds legendMaxWidth. + * - 'ellipsis': Truncate with ellipsis + * - 'wrap': Wrap text to multiple lines (default) + */ + legendTextOverflow?: 'ellipsis' | 'wrap'; + /** + * Additional CSS class name for legend items. + */ + legendItemClassName?: string; + /** + * Legend shape. Default: 'line' + */ + legendShape?: LegendShape< D, number >; /** * Whether to animate the chart on initial render. Default: false */ From b34736328420720137c438ae75c4dc1c99f3b49e Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:28:31 +1100 Subject: [PATCH 13/33] Add legend props to TimeSeriesForecastChart component --- .../time-series-forecast-chart.tsx | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 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 510b0ceafe45..f2292d166e40 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 @@ -45,25 +45,31 @@ 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.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) + * @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.legendOrientation - Legend orientation (horizontal or vertical) + * @param root0.legendPosition - Position of the legend (top or bottom) + * @param root0.legendAlignment - Legend alignment within its position + * @param root0.legendMaxWidth - Maximum width for legend items + * @param root0.legendTextOverflow - How text behaves when exceeding legendMaxWidth + * @param root0.legendItemClassName - Additional CSS class name for legend items + * @param root0.legendShape - Shape for legend items + * @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 >( { @@ -82,7 +88,13 @@ function TimeSeriesForecastChartInternal< D >( { className, chartId: providedChartId, showLegend = false, + legendOrientation = 'horizontal', legendPosition = 'bottom', + legendAlignment = 'center', + legendMaxWidth, + legendTextOverflow = 'wrap', + legendItemClassName, + legendShape = 'line', animation = false, gridVisibility = 'y', }: TimeSeriesForecastChartProps< D > ) { @@ -371,11 +383,14 @@ function TimeSeriesForecastChartInternal< D >( { { showLegend && ( Date: Thu, 29 Jan 2026 21:28:53 +1100 Subject: [PATCH 14/33] Add displayName to TimeSeriesForecastChartWithProvider --- .../time-series-forecast-chart/time-series-forecast-chart.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 f2292d166e40..0669784dec6e 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 @@ -423,6 +423,8 @@ function TimeSeriesForecastChartWithProvider< D >( ); } +TimeSeriesForecastChartWithProvider.displayName = 'TimeSeriesForecastChart'; + /** * TimeSeriesForecastChart - Displays historical data and forecasts with uncertainty bands * From 2555312d65ed5c186e84acdc587ff6241205b9c7 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:31:06 +1100 Subject: [PATCH 15/33] Add compound composition pattern to TimeSeriesForecastChart --- .../time-series-forecast-chart.tsx | 279 ++++++++++-------- .../time-series-forecast-chart/types.ts | 5 + 2 files changed, 169 insertions(+), 115 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 0669784dec6e..c6fc55b94bad 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 @@ -12,6 +12,9 @@ import { useChartId, useGlobalChartsTheme, } from '../../providers'; +import { attachSubComponents } from '../../utils'; +import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition'; +import { SingleChartContext } from '../private/single-chart-context'; import { withResponsive } from '../private/with-responsive'; import { ForecastDivider, useForecastData } from './private'; import styles from './time-series-forecast-chart.module.scss'; @@ -23,6 +26,8 @@ import type { } from './types'; import type { BaseLegendItem } from '../../components/legend'; import type { Optional } from '../../types'; +import type { ChartComponentWithComposition } from '../private/chart-composition'; +import type { ResponsiveConfig } from '../private/with-responsive'; import type { TickFormatter } from '@visx/axis'; import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip'; import type { FC } from 'react'; @@ -70,6 +75,7 @@ const formatDateTick = ( d: Date ) => { * @param root0.legendShape - Shape for legend items * @param root0.animation - Whether to enable chart animations * @param root0.gridVisibility - Which grid lines to show (x, y, xy, or none) + * @param root0.children - Children for compound composition pattern * @return The rendered chart component */ function TimeSeriesForecastChartInternal< D >( { @@ -97,6 +103,7 @@ function TimeSeriesForecastChartInternal< D >( { legendShape = 'line', animation = false, gridVisibility = 'y', + children = null, }: TimeSeriesForecastChartProps< D > ) { const providerTheme = useGlobalChartsTheme(); const chartId = useChartId( providedChartId ); @@ -178,6 +185,12 @@ function TimeSeriesForecastChartInternal< D >( { return items; }, [ historical.length, forecast.length, seriesKeys, primaryColor ] ); + // Process children to extract compound components + const { svgChildren, htmlChildren, otherChildren } = useChartChildren( + children, + 'TimeSeriesForecastChart' + ); + // 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, [] ); @@ -259,8 +272,6 @@ function TimeSeriesForecastChartInternal< D >( { return formatNumberCompact as TickFormatter< number >; }, [ yTickFormat ] ); - // X-axis tick formatter - use directly without wrapper - const xAxisTickFormat = xTickFormat; // Handle empty data if ( data.length === 0 ) { @@ -286,117 +297,135 @@ function TimeSeriesForecastChartInternal< D >( { const showYGrid = gridVisibility === 'y' || gridVisibility === 'xy'; return ( -
- - { gridVisibility !== 'none' && ( - +
+ + { gridVisibility !== 'none' && ( + + ) } - - - - { /* Uncertainty band - rendered first (behind lines) */ } - { bandData.length > 0 && ( - - ) } + + + + { /* Uncertainty band - rendered first (behind lines) */ } + { bandData.length > 0 && ( + + ) } - { /* Historical line - solid */ } - { historical.length > 0 && ( - - ) } + { /* Historical line - solid */ } + { historical.length > 0 && ( + + ) } - { /* Forecast line - dashed */ } - { forecast.length > 0 && ( - - ) } + { /* Forecast line - dashed */ } + { forecast.length > 0 && ( + + ) } - { /* Vertical divider at forecast start */ } - { showDivider && ( - - ) } + { /* Vertical divider at forecast start */ } + { showDivider && ( + + ) } - { /* Tooltip */ } - { showTooltip && ( - + ) } + + + { /* Render SVG children from TimeSeriesForecastChart.SVG */ } + { svgChildren } + + { showLegend && ( + ) } - - - { showLegend && ( - - ) } -
+ + { /* Render HTML children from TimeSeriesForecastChart.HTML */ } + { htmlChildren } + + { /* Render other React children for backward compatibility */ } + { otherChildren } +
+ ); } @@ -425,6 +454,20 @@ function TimeSeriesForecastChartWithProvider< D >( TimeSeriesForecastChartWithProvider.displayName = 'TimeSeriesForecastChart'; +// Base props type with optional responsive properties +type TimeSeriesForecastChartBaseProps< D > = Optional< + TimeSeriesForecastChartProps< D >, + 'width' | 'height' +>; + +// Composition API types +type TimeSeriesForecastChartComponent = ChartComponentWithComposition< + TimeSeriesForecastChartBaseProps< unknown > +>; +type TimeSeriesForecastChartResponsiveComponent = ChartComponentWithComposition< + TimeSeriesForecastChartBaseProps< unknown > & ResponsiveConfig +>; + /** * TimeSeriesForecastChart - Displays historical data and forecasts with uncertainty bands * @@ -435,22 +478,28 @@ TimeSeriesForecastChartWithProvider.displayName = 'TimeSeriesForecastChart'; * - 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; +const TimeSeriesForecastChart = attachSubComponents( + withResponsive< TimeSeriesForecastChartProps< unknown > >( + TimeSeriesForecastChartWithProvider as FC< TimeSeriesForecastChartProps< unknown > > + ), + { + Legend, + SVG: ChartSVG, + HTML: ChartHTML, } -) => JSX.Element; +) as TimeSeriesForecastChartResponsiveComponent; /** * Non-responsive version of TimeSeriesForecastChart * Requires explicit width and height props */ -const TimeSeriesForecastChartUnresponsive = TimeSeriesForecastChartWithProvider as < D >( - props: TimeSeriesForecastChartProps< D > -) => JSX.Element; +const TimeSeriesForecastChartUnresponsive = attachSubComponents( + TimeSeriesForecastChartWithProvider as FC< TimeSeriesForecastChartProps< unknown > >, + { + Legend, + SVG: ChartSVG, + HTML: ChartHTML, + } +) as TimeSeriesForecastChartComponent; 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 index 4ce0581fc77e..daf07ee0dc41 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 @@ -205,4 +205,9 @@ export interface TimeSeriesForecastChartProps< D > { * Grid visibility. Default: 'y' */ gridVisibility?: 'x' | 'y' | 'xy' | 'none'; + /** + * Children for compound composition pattern. + * Use TimeSeriesForecastChart.SVG for SVG children and TimeSeriesForecastChart.HTML for HTML children. + */ + children?: ReactNode; } From d4d2a907a7fea579eeed4b81952223ae71e11af1 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:31:42 +1100 Subject: [PATCH 16/33] Add useChartRegistration to TimeSeriesForecastChart --- .../time-series-forecast-chart.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 c6fc55b94bad..141d70d4fccb 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 @@ -10,6 +10,7 @@ import { GlobalChartsProvider, GlobalChartsContext, useChartId, + useChartRegistration, useGlobalChartsTheme, } from '../../providers'; import { attachSubComponents } from '../../utils'; @@ -191,6 +192,26 @@ function TimeSeriesForecastChartInternal< D >( { 'TimeSeriesForecastChart' ); + // Memoize metadata to prevent unnecessary re-registration + const chartMetadata = useMemo( + () => ( { + forecastStart, + hasHistorical: historical.length > 0, + hasForecast: forecast.length > 0, + hasBand: bandData.length > 0, + } ), + [ forecastStart, historical.length, forecast.length, bandData.length ] + ); + + // Register chart with context + useChartRegistration( { + chartId, + legendItems, + chartType: 'time-series-forecast', + isDataValid: data.length > 0, + metadata: chartMetadata, + } ); + // 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, [] ); From ff3733b000f1f0514fe72477f3414da935c51f37 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 21:38:38 +1100 Subject: [PATCH 17/33] Add changelog entry for TimeSeriesForecastChart improvements --- .../js-packages/charts/changelog/add-area-chart-component | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/js-packages/charts/changelog/add-area-chart-component diff --git a/projects/js-packages/charts/changelog/add-area-chart-component b/projects/js-packages/charts/changelog/add-area-chart-component new file mode 100644 index 000000000000..49d4bb86c495 --- /dev/null +++ b/projects/js-packages/charts/changelog/add-area-chart-component @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +TimeSeriesForecastChart: add compound composition pattern and full legend prop parity From eec1f2191fbdd9bedfc1a5029640eac4137e025a Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:35:49 +1100 Subject: [PATCH 18/33] 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 48ee13ba8115c0b5f28cddc4ca6c4509ee692c0e Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:36:23 +1100 Subject: [PATCH 19/33] 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 ffa8e08769522c8395e5570262f902215f978424 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:36:48 +1100 Subject: [PATCH 20/33] 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 a0ec5b9b5ff5ab5bccaaf53cbe6095e479c16d02 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:07 +1100 Subject: [PATCH 21/33] 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 c564af0024c998638a9aba31984b96f7547351f8 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:16 +1100 Subject: [PATCH 22/33] 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 be004f7482b2..53237232b1b0 100644 --- a/projects/js-packages/charts/package.json +++ b/projects/js-packages/charts/package.json @@ -95,6 +95,12 @@ }, "./sparkline/style.css": "./dist/components/sparkline/index.css", "./style.css": "./dist/index.css", + "./time-series-forecast-chart": { + "jetpack:src": "./src/charts/time-series-forecast-chart/index.ts", + "import": "./dist/charts/time-series-forecast-chart/index.js", + "require": "./dist/charts/time-series-forecast-chart/index.cjs" + }, + "./time-series-forecast-chart/style.css": "./dist/charts/time-series-forecast-chart/index.css", "./tooltip": { "jetpack:src": "./src/components/tooltip/index.ts", "import": "./dist/components/tooltip/index.js", @@ -171,6 +177,9 @@ "sparkline": [ "./dist/components/sparkline/index.d.ts" ], + "time-series-forecast-chart": [ + "./dist/charts/time-series-forecast-chart/index.d.ts" + ], "tooltip": [ "./dist/components/tooltip/index.d.ts" ], diff --git a/projects/js-packages/charts/src/index.ts b/projects/js-packages/charts/src/index.ts index 240f96d21184..de595cd038ca 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'; @@ -35,6 +39,10 @@ export type { PieChartProps } from './charts/pie-chart'; 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 ff0516397ec6bdac690c8c083c673a1f2c0de9a6 Mon Sep 17 00:00:00 2001 From: annacmc Date: Thu, 29 Jan 2026 20:40:26 +1100 Subject: [PATCH 23/33] 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 a25de7948f4ac6aa551ebb60f58ac81d24b54609 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 10:11:06 +1100 Subject: [PATCH 24/33] 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 6c926a5b0f31e1c5b0bf5978988e2ce0e73a4f3b Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 11:53:42 +1100 Subject: [PATCH 25/33] 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 1e081b587ae2e10ef7280557d80760ed93703e54 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:32:29 +1100 Subject: [PATCH 26/33] 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 b30f774035f18341599037a17c505fd8f9d19656 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:36:10 +1100 Subject: [PATCH 27/33] 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 a3c249e0cf64e1225c4d1ec4f499ad4e49f4fbea Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:41:43 +1100 Subject: [PATCH 28/33] 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 cb85ab5eaafb02e52c0d4b124b486b4e0b541c30 Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 13:47:08 +1100 Subject: [PATCH 29/33] 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 30/33] 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 f0e5b67d58a1d68d7117ef27d2d2d3383af97f06 Mon Sep 17 00:00:00 2001 From: Anna McPhee <30754158+annacmc@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:12:01 +1100 Subject: [PATCH 31/33] Add TimeSeriesForecast Chart to changelog --- projects/js-packages/charts/changelog/add-area-chart-component | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/js-packages/charts/changelog/add-area-chart-component b/projects/js-packages/charts/changelog/add-area-chart-component index 49d4bb86c495..3c665ff8c51c 100644 --- a/projects/js-packages/charts/changelog/add-area-chart-component +++ b/projects/js-packages/charts/changelog/add-area-chart-component @@ -1,4 +1,4 @@ Significance: minor Type: added -TimeSeriesForecastChart: add compound composition pattern and full legend prop parity +Charts: Add TimeSeriesForecast Chart From 43ea4ce742f674ec690aa244805b4a588f69aecd Mon Sep 17 00:00:00 2001 From: annacmc Date: Fri, 30 Jan 2026 16:23:41 +1100 Subject: [PATCH 32/33] Address Copilot review feedback --- .../js-packages/charts/changelog/add-area-chart-component | 2 +- .../time-series-forecast-chart/private/use-forecast-data.ts | 6 +++++- .../time-series-forecast-chart.tsx | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/projects/js-packages/charts/changelog/add-area-chart-component b/projects/js-packages/charts/changelog/add-area-chart-component index 3c665ff8c51c..ca89f6d2d5b6 100644 --- a/projects/js-packages/charts/changelog/add-area-chart-component +++ b/projects/js-packages/charts/changelog/add-area-chart-component @@ -1,4 +1,4 @@ Significance: minor Type: added -Charts: Add TimeSeriesForecast Chart +TimeSeriesForecastChart: Add compound composition pattern and full legend prop parity. 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 c552c1c3c53c..46a5b7bb95da 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 @@ -101,7 +101,11 @@ export function useForecastData< D >( { // 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 ] = [ Math.max( 0, minY - yPadding ), maxY + yPadding ]; + const yMinWithPadding = minY - yPadding; + const yDomain: [ number, number ] = [ + minY >= 0 ? Math.max( 0, yMinWithPadding ) : yMinWithPadding, + 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/time-series-forecast-chart.tsx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx index bf6a5243ddd0..cf03313c439c 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 @@ -424,6 +424,9 @@ function TimeSeriesForecastChartInternal< D >( { /> ) } + { /* Render SVG children from TimeSeriesForecastChart.SVG */ } + { svgChildren } + { /* Tooltip */ } { withTooltips && ( ( { ) } - { /* Render SVG children from TimeSeriesForecastChart.SVG */ } - { svgChildren } - { showLegend && ( Date: Tue, 17 Feb 2026 20:34:19 +1100 Subject: [PATCH 33/33] Remove duplicate typesVersions entry in package.json --- projects/js-packages/charts/package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/projects/js-packages/charts/package.json b/projects/js-packages/charts/package.json index 14555e0d1e0b..dff135fddab1 100644 --- a/projects/js-packages/charts/package.json +++ b/projects/js-packages/charts/package.json @@ -180,9 +180,6 @@ "time-series-forecast-chart": [ "./dist/charts/time-series-forecast-chart/index.d.ts" ], - "time-series-forecast-chart": [ - "./dist/charts/time-series-forecast-chart/index.d.ts" - ], "tooltip": [ "./dist/components/tooltip/index.d.ts" ],