@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/CHANGELOG.md +14 -1
- package/dist/index.cjs +5013 -4565
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +108 -90
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +120 -64
- package/dist/index.d.ts +120 -64
- package/dist/index.js +5032 -4584
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/charts/area-chart/area-chart.module.scss +23 -0
- package/src/charts/area-chart/area-chart.tsx +444 -0
- package/src/charts/area-chart/index.ts +2 -0
- package/src/charts/area-chart/private/index.ts +2 -0
- package/src/charts/area-chart/private/overlays.tsx +123 -0
- package/src/charts/area-chart/private/validate-data.ts +31 -0
- package/src/charts/area-chart/test/area-chart.test.tsx +264 -0
- package/src/charts/area-chart/types.ts +51 -0
- package/src/charts/line-chart/index.ts +1 -1
- package/src/charts/line-chart/line-chart.tsx +8 -118
- package/src/charts/private/time-axis.ts +106 -0
- package/src/components/legend/legend.tsx +1 -0
- package/src/index.ts +2 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
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,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
|
+
};
|