@automattic/charts 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.1.6",
66
+ "@automattic/number-formatters": "^1.1.7",
67
67
  "@babel/runtime": "7.29.2",
68
68
  "@react-spring/web": "9.7.5",
69
69
  "@visx/annotation": "^3.12.0",
@@ -121,7 +121,7 @@
121
121
  "identity-obj-proxy": "^3.0.0",
122
122
  "jest": "30.3.0",
123
123
  "jest-extended": "7.0.0",
124
- "postcss": "8.5.6",
124
+ "postcss": "8.5.10",
125
125
  "postcss-modules": "6.0.1",
126
126
  "react": "18.3.1",
127
127
  "react-dom": "18.3.1",
@@ -0,0 +1,23 @@
1
+ .area-chart {
2
+ position: relative;
3
+
4
+ &--animated {
5
+
6
+ path {
7
+ transform-origin: 0 95%;
8
+ transform: scaleY(0);
9
+ animation: rise 1s ease-out forwards;
10
+ }
11
+ }
12
+
13
+ svg {
14
+ overflow: visible;
15
+ }
16
+ }
17
+
18
+ @keyframes rise {
19
+
20
+ to {
21
+ transform: scaleY(1);
22
+ }
23
+ }
@@ -0,0 +1,444 @@
1
+ import { formatNumberCompact } from '@automattic/number-formatters';
2
+ import { XYChart, AreaSeries, AreaStack, Grid, Axis } from '@visx/xychart';
3
+ import { __ } from '@wordpress/i18n';
4
+ import clsx from 'clsx';
5
+ import {
6
+ useMemo,
7
+ useContext,
8
+ forwardRef,
9
+ useImperativeHandle,
10
+ useState,
11
+ useRef,
12
+ useCallback,
13
+ } from 'react';
14
+ import { Legend, useChartLegendItems } from '../../components/legend';
15
+ import { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip';
16
+ import {
17
+ useXYChartTheme,
18
+ useChartDataTransform,
19
+ useChartMargin,
20
+ usePrefersReducedMotion,
21
+ } from '../../hooks';
22
+ import {
23
+ GlobalChartsProvider,
24
+ GlobalChartsContext,
25
+ useChartId,
26
+ useChartRegistration,
27
+ useGlobalChartsContext,
28
+ useGlobalChartsTheme,
29
+ } from '../../providers';
30
+ import { attachSubComponents } from '../../utils';
31
+ import { renderDefaultTooltip } from '../line-chart';
32
+ import { useChartChildren } from '../private/chart-composition';
33
+ import { ChartLayout } from '../private/chart-layout';
34
+ import { SingleChartContext, type SingleChartRef } from '../private/single-chart-context';
35
+ import { SvgEmptyState } from '../private/svg-empty-state';
36
+ import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
37
+ import { withResponsive } from '../private/with-responsive';
38
+ import styles from './area-chart.module.scss';
39
+ import { AreaChartScalesRef, HoverGlyphs, validateData } from './private';
40
+ import type { AreaChartProps } from './types';
41
+ import type { DataPointDate, Optional } from '../../types';
42
+ import type { ResponsiveConfig } from '../private/with-responsive';
43
+ import type { TickFormatter } from '@visx/axis';
44
+
45
+ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
46
+ (
47
+ {
48
+ data,
49
+ chartId: providedChartId,
50
+ width,
51
+ height,
52
+ className,
53
+ margin,
54
+ withTooltips = true,
55
+ withTooltipCrosshairs,
56
+ showLegend = false,
57
+ legend = {},
58
+ stacked = true,
59
+ stackOffset = 'none',
60
+ smoothing = true,
61
+ curveType,
62
+ fillOpacity,
63
+ withStroke,
64
+ renderTooltip = renderDefaultTooltip,
65
+ animation,
66
+ options = {},
67
+ onPointerDown,
68
+ onPointerUp,
69
+ onPointerMove,
70
+ onPointerOut,
71
+ children,
72
+ gridVisibility,
73
+ gap = 'md',
74
+ },
75
+ ref
76
+ ) => {
77
+ const legendInteractive = legend.interactive ?? false;
78
+ const legendShape = legend.shape ?? 'rect';
79
+ const legendPosition = legend.position ?? 'bottom';
80
+
81
+ const providerTheme = useGlobalChartsTheme();
82
+ const theme = useXYChartTheme( data );
83
+ const chartId = useChartId( providedChartId );
84
+ const chartRef = useRef< HTMLDivElement >( null );
85
+ const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
86
+ const [ isNavigating, setIsNavigating ] = useState( false );
87
+ const internalChartRef = useRef< SingleChartRef >( null );
88
+
89
+ const { legendChildren, nonLegendChildren } = useChartChildren( children, 'AreaChart' );
90
+ const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
91
+
92
+ const handleContentHeightChange = useCallback(
93
+ ( contentHeight: number ) => {
94
+ const chartHeight = contentHeight > 0 ? contentHeight : height;
95
+ setMeasuredChartHeight( chartHeight );
96
+ },
97
+ [ height ]
98
+ );
99
+
100
+ useImperativeHandle(
101
+ ref,
102
+ () => ( {
103
+ getScales: () => internalChartRef.current?.getScales() || null,
104
+ getChartDimensions: () =>
105
+ internalChartRef.current?.getChartDimensions() || { width: 0, height: 0, margin: {} },
106
+ } ),
107
+ [ internalChartRef ]
108
+ );
109
+
110
+ const dataSorted = useChartDataTransform( data );
111
+ const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
112
+
113
+ const seriesWithVisibility = useMemo( () => {
114
+ if ( ! chartId || ! legendInteractive ) {
115
+ return dataSorted.map( ( series, index ) => ( { series, index, isVisible: true } ) );
116
+ }
117
+ return dataSorted.map( ( series, index ) => ( {
118
+ series,
119
+ index,
120
+ isVisible: isSeriesVisible( chartId, series.label ),
121
+ } ) );
122
+ }, [ dataSorted, chartId, isSeriesVisible, legendInteractive ] );
123
+
124
+ const allSeriesHidden = useMemo(
125
+ () => seriesWithVisibility.every( ( { isVisible } ) => ! isVisible ),
126
+ [ seriesWithVisibility ]
127
+ );
128
+
129
+ const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
130
+ selectedIndex,
131
+ setSelectedIndex,
132
+ isNavigating,
133
+ setIsNavigating,
134
+ chartRef,
135
+ totalPoints: dataSorted[ 0 ]?.data.length || 0,
136
+ } );
137
+
138
+ const chartOptions = useMemo( () => {
139
+ const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
140
+
141
+ return {
142
+ axis: {
143
+ x: {
144
+ orientation: 'bottom' as const,
145
+ numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
146
+ tickFormat: formatter,
147
+ display: true,
148
+ ...options?.axis?.x,
149
+ },
150
+ y: {
151
+ orientation: 'left' as const,
152
+ numTicks: 4,
153
+ tickFormat: formatNumberCompact as TickFormatter< number >,
154
+ display: true,
155
+ ...options?.axis?.y,
156
+ },
157
+ },
158
+ xScale: {
159
+ type: 'time' as const,
160
+ ...options?.xScale,
161
+ },
162
+ yScale: {
163
+ type: 'linear' as const,
164
+ nice: true,
165
+ // Stacked areas should always include zero so the baseline is meaningful.
166
+ zero: stacked,
167
+ ...options?.yScale,
168
+ },
169
+ };
170
+ }, [ options, dataSorted, width, stacked ] );
171
+
172
+ const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme );
173
+
174
+ const error = validateData( dataSorted );
175
+ const isDataValid = ! error;
176
+
177
+ const legendOptions = useMemo( () => ( { withGlyph: false, glyphSize: 0 } ), [] );
178
+ const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
179
+
180
+ const chartMetadata = useMemo(
181
+ () => ( { stacked, stackOffset, smoothing, curveType } ),
182
+ [ stacked, stackOffset, smoothing, curveType ]
183
+ );
184
+
185
+ useChartRegistration( {
186
+ chartId,
187
+ legendItems,
188
+ chartType: 'area',
189
+ isDataValid,
190
+ metadata: chartMetadata,
191
+ } );
192
+
193
+ const prefersReducedMotion = usePrefersReducedMotion();
194
+
195
+ const accessors = {
196
+ xAccessor: ( d: DataPointDate ) => d?.date,
197
+ yAccessor: ( d: DataPointDate ) => d?.value,
198
+ };
199
+
200
+ // Defaults that depend on stacked vs overlapping mode.
201
+ const resolvedFillOpacity = fillOpacity ?? ( stacked ? 0.85 : 0.4 );
202
+ const resolvedWithStroke = withStroke ?? ! stacked;
203
+
204
+ if ( error ) {
205
+ return <div className={ clsx( 'area-chart', styles[ 'area-chart' ] ) }>{ error }</div>;
206
+ }
207
+
208
+ const legendElement = showLegend && (
209
+ <Legend
210
+ orientation={ legend.orientation ?? 'horizontal' }
211
+ alignment={ legend.alignment ?? 'center' }
212
+ position={ legendPosition }
213
+ labelStyles={ legend.labelStyles }
214
+ itemClassName={ legend.itemClassName }
215
+ itemStyles={ legend.itemStyles }
216
+ shapeStyles={ legend.shapeStyles }
217
+ className={ styles[ 'area-chart__legend' ] }
218
+ shape={ legendShape }
219
+ chartId={ chartId }
220
+ interactive={ legendInteractive }
221
+ />
222
+ );
223
+
224
+ const visibleSeries = seriesWithVisibility.filter( ( { isVisible } ) => isVisible );
225
+ const curve = getCurveType( curveType, smoothing );
226
+
227
+ return (
228
+ <SingleChartContext.Provider
229
+ value={ {
230
+ chartId,
231
+ chartRef: internalChartRef,
232
+ chartWidth: width,
233
+ chartHeight: measuredChartHeight || 0,
234
+ } }
235
+ >
236
+ <ChartLayout
237
+ legendPosition={ legendPosition }
238
+ legendElement={ legendElement }
239
+ legendChildren={ legendChildren }
240
+ gap={ gap }
241
+ className={ clsx(
242
+ 'area-chart',
243
+ styles[ 'area-chart' ],
244
+ { [ styles[ 'area-chart--animated' ] ]: animation && ! prefersReducedMotion },
245
+ className
246
+ ) }
247
+ style={ { width, height } }
248
+ data-testid="area-chart"
249
+ trailingContent={ nonLegendChildren }
250
+ onContentHeightChange={ handleContentHeightChange }
251
+ >
252
+ { ( { contentHeight } ) => {
253
+ const chartHeight = contentHeight > 0 ? contentHeight : height;
254
+
255
+ return (
256
+ <div
257
+ role="grid"
258
+ aria-label={ __( 'Area chart', 'jetpack-charts' ) }
259
+ tabIndex={ 0 }
260
+ onKeyDown={ onChartKeyDown }
261
+ onFocus={ onChartFocus }
262
+ onBlur={ onChartBlur }
263
+ >
264
+ { chartHeight > 0 && (
265
+ <div ref={ chartRef }>
266
+ <XYChart
267
+ theme={ theme }
268
+ width={ width }
269
+ height={ chartHeight }
270
+ margin={ { ...defaultMargin, ...margin } }
271
+ xScale={ chartOptions.xScale }
272
+ yScale={ chartOptions.yScale }
273
+ onPointerDown={ onPointerDown }
274
+ onPointerUp={ onPointerUp }
275
+ onPointerMove={ onPointerMove }
276
+ onPointerOut={ onPointerOut }
277
+ pointerEventsDataKey="nearest"
278
+ >
279
+ { gridVisibility !== 'none' && <Grid columns={ false } numTicks={ 4 } /> }
280
+ { chartOptions.axis.x.display && <Axis { ...chartOptions.axis.x } /> }
281
+ { chartOptions.axis.y.display && <Axis { ...chartOptions.axis.y } /> }
282
+
283
+ { allSeriesHidden ? (
284
+ <SvgEmptyState
285
+ x={ width / 2 }
286
+ y={ chartHeight / 2 }
287
+ width={ width }
288
+ height={ chartHeight }
289
+ >
290
+ { __(
291
+ 'All series are hidden. Click legend items to show data.',
292
+ 'jetpack-charts'
293
+ ) }
294
+ </SvgEmptyState>
295
+ ) : null }
296
+
297
+ { ! allSeriesHidden && stacked && (
298
+ <AreaStack
299
+ curve={ curve }
300
+ offset={ stackOffset }
301
+ renderLine={ resolvedWithStroke }
302
+ >
303
+ { visibleSeries.map( ( { series: seriesData, index } ) => {
304
+ const { color, lineStyles } = getElementStyles( {
305
+ data: seriesData,
306
+ index,
307
+ } );
308
+
309
+ return (
310
+ <AreaSeries
311
+ key={ seriesData?.label || index }
312
+ dataKey={ seriesData?.label }
313
+ data={ seriesData.data as DataPointDate[] }
314
+ { ...accessors }
315
+ fill={ color }
316
+ fillOpacity={ resolvedFillOpacity }
317
+ lineProps={ {
318
+ stroke: color,
319
+ ...lineStyles,
320
+ } }
321
+ data-testid={ `area-chart-series-${ index }` }
322
+ />
323
+ );
324
+ } ) }
325
+ </AreaStack>
326
+ ) }
327
+
328
+ { ! allSeriesHidden &&
329
+ ! stacked &&
330
+ visibleSeries.map( ( { series: seriesData, index } ) => {
331
+ const { color, lineStyles } = getElementStyles( {
332
+ data: seriesData,
333
+ index,
334
+ } );
335
+
336
+ return (
337
+ <AreaSeries
338
+ key={ seriesData?.label || index }
339
+ dataKey={ seriesData?.label }
340
+ data={ seriesData.data as DataPointDate[] }
341
+ { ...accessors }
342
+ fill={ color }
343
+ fillOpacity={ resolvedFillOpacity }
344
+ renderLine={ resolvedWithStroke }
345
+ curve={ curve }
346
+ lineProps={ {
347
+ stroke: color,
348
+ ...lineStyles,
349
+ } }
350
+ data-testid={ `area-chart-series-${ index }` }
351
+ />
352
+ );
353
+ } ) }
354
+
355
+ { withTooltips && (
356
+ <>
357
+ <AccessibleTooltip
358
+ detectBounds
359
+ snapTooltipToDatumX
360
+ // Stacked mode: yAccessor returns raw value, not stacked y — snapping mispositions.
361
+ snapTooltipToDatumY={ ! stacked }
362
+ renderTooltip={ renderTooltip }
363
+ showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
364
+ showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
365
+ selectedIndex={ selectedIndex }
366
+ tooltipRef={ tooltipRef }
367
+ keyboardFocusedClassName={
368
+ styles[ 'area-chart__tooltip--keyboard-focused' ]
369
+ }
370
+ series={ dataSorted }
371
+ />
372
+ <HoverGlyphs
373
+ visibleSeries={ visibleSeries }
374
+ stacked={ stacked }
375
+ stackOffset={ stackOffset }
376
+ getElementStyles={ getElementStyles }
377
+ strokeColor={ providerTheme.backgroundColor }
378
+ />
379
+ </>
380
+ ) }
381
+
382
+ <AreaChartScalesRef
383
+ chartRef={ internalChartRef }
384
+ width={ width }
385
+ // `||` — responsive HOC may pass `height = 0` before measurement.
386
+ height={ height || chartHeight }
387
+ margin={ margin }
388
+ />
389
+ </XYChart>
390
+ </div>
391
+ ) }
392
+ </div>
393
+ );
394
+ } }
395
+ </ChartLayout>
396
+ </SingleChartContext.Provider>
397
+ );
398
+ }
399
+ );
400
+
401
+ type AreaChartSubComponents = {
402
+ Legend: typeof Legend;
403
+ };
404
+
405
+ type AreaChartBaseProps = Optional< AreaChartProps, 'width' | 'height' | 'size' >;
406
+
407
+ type AreaChartComponent = React.ForwardRefExoticComponent<
408
+ AreaChartBaseProps & React.RefAttributes< SingleChartRef >
409
+ > &
410
+ AreaChartSubComponents;
411
+
412
+ type AreaChartResponsiveComponent = React.ForwardRefExoticComponent<
413
+ AreaChartBaseProps & ResponsiveConfig & React.RefAttributes< SingleChartRef >
414
+ > &
415
+ AreaChartSubComponents;
416
+
417
+ const AreaChartWithProvider = forwardRef< SingleChartRef, AreaChartProps >( ( props, ref ) => {
418
+ const existingContext = useContext( GlobalChartsContext );
419
+
420
+ if ( existingContext ) {
421
+ return <AreaChartInternal { ...props } ref={ ref } />;
422
+ }
423
+
424
+ return (
425
+ <GlobalChartsProvider>
426
+ <AreaChartInternal { ...props } ref={ ref } />
427
+ </GlobalChartsProvider>
428
+ );
429
+ } );
430
+
431
+ AreaChartWithProvider.displayName = 'AreaChart';
432
+
433
+ const AreaChart = attachSubComponents( AreaChartWithProvider, {
434
+ Legend: Legend,
435
+ } ) as AreaChartComponent;
436
+
437
+ const AreaChartResponsive = attachSubComponents(
438
+ withResponsive< AreaChartProps >( AreaChartWithProvider ),
439
+ {
440
+ Legend: Legend,
441
+ }
442
+ ) as AreaChartResponsiveComponent;
443
+
444
+ export { AreaChartResponsive as default, AreaChart as AreaChartUnresponsive };
@@ -0,0 +1,2 @@
1
+ export { default as AreaChart, AreaChartUnresponsive } from './area-chart';
2
+ export type { AreaChartProps } from './types';
@@ -0,0 +1,2 @@
1
+ export { validateData } from './validate-data';
2
+ export { AreaChartScalesRef, HoverGlyphs, type VisibleSeriesEntry } from './overlays';
@@ -0,0 +1,123 @@
1
+ import { DataContext, TooltipContext } from '@visx/xychart';
2
+ import { useContext, useImperativeHandle } from 'react';
3
+ import type { ElementStyles, GetElementStylesParams } from '../../../providers';
4
+ import type { DataPointDate, SeriesData } from '../../../types';
5
+ import type { SingleChartRef } from '../../private/single-chart-context';
6
+ import type { FC, ReactNode, Ref } from 'react';
7
+
8
+ export type VisibleSeriesEntry = { series: SeriesData; index: number; isVisible: boolean };
9
+
10
+ // AreaChart only configures time + linear scales; cast to a callable shape
11
+ // instead of spreading `any`. `Number(...)` + `isFinite` guards every call.
12
+ type ScaleFn = ( input: Date | number ) => number;
13
+
14
+ // Bridges visx's `DataContext` to the chart's `SingleChartRef` so consumers
15
+ // can read scales and dimensions imperatively. Must be inside `<XYChart>`.
16
+ export const AreaChartScalesRef: FC< {
17
+ chartRef?: Ref< SingleChartRef >;
18
+ width: number;
19
+ height: number;
20
+ margin?: { top?: number; right?: number; bottom?: number; left?: number };
21
+ } > = ( { chartRef, width, height, margin } ) => {
22
+ const context = useContext( DataContext );
23
+
24
+ useImperativeHandle(
25
+ chartRef,
26
+ () => ( {
27
+ getScales: () => {
28
+ if ( ! context?.xScale || ! context?.yScale ) return null;
29
+ return { xScale: context.xScale, yScale: context.yScale };
30
+ },
31
+ getChartDimensions: () => ( { width, height, margin: margin || {} } ),
32
+ } ),
33
+ [ context, width, height, margin ]
34
+ );
35
+
36
+ return null;
37
+ };
38
+
39
+ // Hover indicators for each visible series. visx's `showSeriesGlyphs`
40
+ // mispositions on AreaStack (its registered yAccessor expects a stack-bar
41
+ // but receives the unwrapped datum, yielding NaN), so we compute positions
42
+ // from the chart's scales: cumulative top edge for stacked + offset='none'
43
+ // (matches d3-stack — missing values count as 0); raw y for unstacked.
44
+ // Skipped for `expand`/`wiggle`/`silhouette` — exact positions there would
45
+ // need re-running the d3-stack layout.
46
+ export const HoverGlyphs: FC< {
47
+ visibleSeries: VisibleSeriesEntry[];
48
+ stacked: boolean;
49
+ stackOffset: 'none' | 'expand' | 'wiggle' | 'silhouette';
50
+ getElementStyles: ( params: GetElementStylesParams ) => ElementStyles;
51
+ strokeColor: string;
52
+ } > = ( { visibleSeries, stacked, stackOffset, getElementStyles, strokeColor } ) => {
53
+ const dataContext = useContext( DataContext );
54
+ const tooltipContext = useContext( TooltipContext );
55
+
56
+ const xScale = dataContext?.xScale as ScaleFn | undefined;
57
+ const yScale = dataContext?.yScale as ScaleFn | undefined;
58
+ const tooltipOpen = tooltipContext?.tooltipOpen;
59
+ const nearestDatum = tooltipContext?.tooltipData?.nearestDatum?.datum as
60
+ | DataPointDate
61
+ | undefined;
62
+
63
+ if (
64
+ ! tooltipOpen ||
65
+ ! xScale ||
66
+ ! yScale ||
67
+ ! nearestDatum ||
68
+ ! nearestDatum.date ||
69
+ ( stacked && stackOffset !== 'none' )
70
+ ) {
71
+ return null;
72
+ }
73
+
74
+ const xPx = Number( xScale( nearestDatum.date ) );
75
+ if ( ! Number.isFinite( xPx ) ) return null;
76
+
77
+ const hoveredTime = nearestDatum.date.getTime();
78
+ let cumulative = 0;
79
+ const circles: ReactNode[] = [];
80
+
81
+ // Always advance `cumulative` (d3-stack treats missing/null as 0), but
82
+ // only render a glyph when the series has a real value at this x.
83
+ for ( const { series, index } of visibleSeries ) {
84
+ const datum = series.data.find(
85
+ d => ( d as DataPointDate ).date?.getTime() === hoveredTime
86
+ ) as DataPointDate | undefined;
87
+
88
+ const value = datum?.value ?? 0;
89
+ if ( stacked ) {
90
+ cumulative += value;
91
+ }
92
+
93
+ if ( ! datum || datum.value == null ) {
94
+ continue;
95
+ }
96
+
97
+ const yPx = Number( yScale( stacked ? cumulative : value ) );
98
+ if ( ! Number.isFinite( yPx ) ) continue;
99
+
100
+ const { color } = getElementStyles( { data: series, index } );
101
+ circles.push(
102
+ <circle
103
+ key={ series.label || index }
104
+ cx={ xPx }
105
+ cy={ yPx }
106
+ r={ 4 }
107
+ fill={ color }
108
+ stroke={ strokeColor }
109
+ strokeWidth={ 1.5 }
110
+ paintOrder="fill"
111
+ data-testid={ `area-chart-hover-glyph-${ index }` }
112
+ />
113
+ );
114
+ }
115
+
116
+ if ( circles.length === 0 ) return null;
117
+
118
+ return (
119
+ <g pointerEvents="none" className="area-chart__hover-glyphs">
120
+ { circles }
121
+ </g>
122
+ );
123
+ };
@@ -0,0 +1,31 @@
1
+ import { __ } from '@wordpress/i18n';
2
+ import type { DataPoint, DataPointDate, SeriesData } from '../../../types';
3
+
4
+ /**
5
+ * Up-front data validation. Returns a localised error message when the chart
6
+ * cannot safely render, otherwise `null`. Catches the cases that would
7
+ * NaN-cascade through the tick formatter and stack layout: empty top-level
8
+ * array, empty per-series data, null/NaN values, invalid dates.
9
+ *
10
+ * @param data - Series data passed to AreaChart.
11
+ * @return Error message, or `null` if the data is renderable.
12
+ */
13
+ export const validateData = ( data: SeriesData[] ) => {
14
+ if ( ! data?.length ) return __( 'No data available', 'jetpack-charts' );
15
+
16
+ const hasEmptySeries = data.some( series => ! series.data?.length );
17
+ if ( hasEmptySeries ) return __( 'No data available', 'jetpack-charts' );
18
+
19
+ const hasInvalidData = data.some( series =>
20
+ series.data.some(
21
+ ( point: DataPointDate | DataPoint ) =>
22
+ isNaN( point.value as number ) ||
23
+ point.value === null ||
24
+ point.value === undefined ||
25
+ ( 'date' in point && point.date && isNaN( point.date.getTime() ) )
26
+ )
27
+ );
28
+
29
+ if ( hasInvalidData ) return __( 'Invalid data', 'jetpack-charts' );
30
+ return null;
31
+ };