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..ca89f6d2d5b6 --- /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. 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 8baeab643175..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 @@ -76,7 +76,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 visually + // 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 ); @@ -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 ] = [ 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.module.scss b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss index 7d0ee592e931..be116cd27a0d 100644 --- a/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.module.scss @@ -1,4 +1,6 @@ .time-series-forecast-chart { + display: flex; + flex-direction: column; position: relative; &__svg-wrapper { @@ -15,6 +17,10 @@ } } + &--legend-top { + flex-direction: column-reverse; + } + svg { overflow: visible; } @@ -50,6 +56,10 @@ border-radius: 3px; background: var(--charts-forecast-badge-background, rgba(0, 0, 0, 0.1)); } + + &__legend { + padding-block-start: 8px; + } } @keyframes rise { diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx index d294b643dd48..c3101e4a7f8f 100644 --- a/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/time-series-forecast-chart.tsx @@ -11,9 +11,13 @@ import { GlobalChartsProvider, GlobalChartsContext, useChartId, + useChartRegistration, useGlobalChartsContext, 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'; @@ -25,6 +29,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'; @@ -32,9 +38,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 ) => { @@ -47,27 +53,34 @@ const formatDateTick = ( d: Date ) => { /** * Internal component that renders the TimeSeriesForecastChart * - * @param root0 - Component props - * @param root0.data - Array of data points to display - * @param root0.accessors - Accessor functions to extract values from data - * @param root0.forecastStart - Date at which forecast begins - * @param root0.height - Chart height in pixels - * @param root0.width - Chart width in pixels - * @param root0.margin - Chart margin configuration - * @param root0.yDomain - Optional fixed y-axis domain - * @param root0.xTickFormat - Formatter function for x-axis ticks - * @param root0.yTickFormat - Formatter function for y-axis ticks - * @param root0.withTooltips - Whether to show tooltips on hover - * @param root0.seriesKeys - Custom labels for series in legend/tooltip - * @param root0.renderTooltip - Custom tooltip renderer function - * @param root0.className - Additional CSS class name - * @param root0.chartId - Unique chart identifier - * @param root0.showLegend - Whether to show the legend - * @param root0.legendPosition - Position of the legend (top or bottom) - * @param root0.animation - Whether to enable chart animations - * @param root0.gridVisibility - Which grid lines to show (x, y, xy, or none) - * @param root0.gap - Gap between chart elements using WP design tokens - * @param root0.bandFillOpacity - Opacity of the uncertainty band fill + * @param root0 - Component props + * @param root0.data - Array of data points to display + * @param root0.accessors - Accessor functions to extract values from data + * @param root0.forecastStart - Date at which forecast begins + * @param root0.height - Chart height in pixels + * @param root0.width - Chart width in pixels + * @param root0.margin - Chart margin configuration + * @param root0.yDomain - Optional fixed y-axis domain + * @param root0.xTickFormat - Formatter function for x-axis ticks + * @param root0.yTickFormat - Formatter function for y-axis ticks + * @param root0.withTooltips - Whether to show tooltips on hover + * @param root0.seriesKeys - Custom labels for series in legend/tooltip + * @param root0.renderTooltip - Custom tooltip renderer function + * @param root0.className - Additional CSS class name + * @param root0.chartId - Unique chart identifier + * @param root0.showLegend - Whether to show the legend + * @param root0.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) + * @param root0.children - Children for compound composition pattern + * @param root0.gap - Gap between chart elements using WP design tokens + * @param root0.bandFillOpacity - Opacity of the uncertainty band fill * @return The rendered chart component */ function TimeSeriesForecastChartInternal< D >( { @@ -86,9 +99,16 @@ function TimeSeriesForecastChartInternal< D >( { className, chartId: providedChartId, showLegend = false, + legendOrientation = 'horizontal', legendPosition = 'bottom', + legendAlignment = 'center', + legendMaxWidth, + legendTextOverflow = 'wrap', + legendItemClassName, + legendShape = 'line', animation = false, gridVisibility = 'y', + children = null, gap = 'md', bandFillOpacity = 0.2, }: TimeSeriesForecastChartProps< D > ) { @@ -187,6 +207,32 @@ function TimeSeriesForecastChartInternal< D >( { return items; }, [ historical.length, forecast.length, seriesKeys, primaryColor, forecastShapeStyles ] ); + // Process children to extract compound components + const { svgChildren, htmlChildren, otherChildren } = useChartChildren( + children, + '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, [] ); @@ -296,139 +342,162 @@ function TimeSeriesForecastChartInternal< D >( { const legendElement = showLegend && ( ); return ( - - { legendPosition === 'top' && legendElement } - -
- { ! isWaitingForMeasurement && ( - - { gridVisibility !== 'none' && ( - - ) } - - - - - { /* Uncertainty band - rendered first (behind lines), no pointer events */ } - { bandData.length > 0 && ( - - ) } - - { /* Historical line - solid, visual only */ } - { historical.length > 0 && ( - + { legendPosition === 'top' && legendElement } + +
+ { ! isWaitingForMeasurement && ( + + { gridVisibility !== 'none' && ( + + ) } + + + + + { /* Uncertainty band - rendered first (behind lines), no pointer events */ } + { bandData.length > 0 && ( + + ) } + + { /* Historical line - solid, visual only */ } + { historical.length > 0 && ( + + ) } + + { /* Forecast line - dashed, visual only */ } + { forecast.length > 0 && ( + + ) } + + { /* Invisible line for tooltip - handles all pointer events */ } + - ) } - { /* Forecast line - dashed, visual only */ } - { forecast.length > 0 && ( - - ) } + { /* Vertical divider at forecast start */ } + { showDivider && ( + + ) } + + { /* Render SVG children from TimeSeriesForecastChart.SVG */ } + { svgChildren } + + { /* Tooltip */ } + { withTooltips && ( + + ) } + + ) } +
- { /* Invisible line for tooltip - handles all pointer events */ } - - - { /* Vertical divider at forecast start */ } - { showDivider && ( - - ) } + { legendPosition === 'bottom' && legendElement } - { /* Tooltip */ } - { withTooltips && ( - - ) } -
- ) } -
+ { /* Render HTML children from TimeSeriesForecastChart.HTML */ } + { htmlChildren } - { legendPosition === 'bottom' && legendElement } -
+ { /* Render other React children for backward compatibility */ } + { otherChildren } + + ); } @@ -457,6 +526,21 @@ 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 * @@ -467,22 +551,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 58cd693366c3..9e58a74317d6 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 { GapSize } from '@wordpress/theme'; import type { ReactNode } from 'react'; @@ -166,10 +167,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 */ @@ -178,6 +206,11 @@ 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; /** * Gap between chart elements (SVG, legend). * Uses WordPress design system tokens.