@automattic/charts 1.4.3 → 1.5.1
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 +15 -0
- package/SECURITY.md +0 -1
- package/dist/index.cjs +544 -195
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +38 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +22 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.js +839 -490
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/charts/area-chart/area-chart.tsx +39 -21
- package/src/charts/area-chart/test/area-chart.test.tsx +83 -3
- package/src/charts/area-chart/types.ts +15 -0
- package/src/charts/line-chart/line-chart.tsx +102 -84
- package/src/charts/line-chart/test/line-chart.test.tsx +41 -0
- package/src/charts/line-chart/types.ts +6 -0
- package/src/charts/private/test/x-zoom.test.tsx +142 -0
- package/src/charts/private/x-zoom.module.scss +45 -0
- package/src/charts/private/x-zoom.tsx +209 -0
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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.2.
|
|
66
|
+
"@automattic/number-formatters": "^1.2.1",
|
|
67
67
|
"@babel/runtime": "7.29.2",
|
|
68
68
|
"@react-spring/web": "9.7.5",
|
|
69
69
|
"@visx/annotation": "^3.12.0",
|
|
@@ -83,9 +83,9 @@
|
|
|
83
83
|
"@visx/vendor": "^3.12.0",
|
|
84
84
|
"@visx/xychart": "^3.12.0",
|
|
85
85
|
"@wordpress/i18n": "^6.0.0",
|
|
86
|
-
"@wordpress/icons": "^
|
|
86
|
+
"@wordpress/icons": "^13.0.0",
|
|
87
87
|
"@wordpress/theme": "0.13.0",
|
|
88
|
-
"@wordpress/ui": "0.
|
|
88
|
+
"@wordpress/ui": "0.13.0",
|
|
89
89
|
"clsx": "2.1.1",
|
|
90
90
|
"date-fns": "^4.1.0",
|
|
91
91
|
"deepmerge": "4.3.1",
|
|
@@ -35,6 +35,7 @@ import { SingleChartContext, type SingleChartRef } from '../private/single-chart
|
|
|
35
35
|
import { SvgEmptyState } from '../private/svg-empty-state';
|
|
36
36
|
import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
|
|
37
37
|
import { withResponsive } from '../private/with-responsive';
|
|
38
|
+
import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
|
|
38
39
|
import styles from './area-chart.module.scss';
|
|
39
40
|
import { AreaChartScalesRef, HoverGlyphs, validateData } from './private';
|
|
40
41
|
import type { AreaChartProps } from './types';
|
|
@@ -68,6 +69,8 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
68
69
|
onPointerUp,
|
|
69
70
|
onPointerMove,
|
|
70
71
|
onPointerOut,
|
|
72
|
+
zoomable = false,
|
|
73
|
+
rescaleYOnLegendToggle = true,
|
|
71
74
|
children,
|
|
72
75
|
gridVisibility,
|
|
73
76
|
gap = 'md',
|
|
@@ -86,6 +89,12 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
86
89
|
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
87
90
|
const internalChartRef = useRef< SingleChartRef >( null );
|
|
88
91
|
|
|
92
|
+
const zoom = useXZoom< Date >( {
|
|
93
|
+
enabled: zoomable,
|
|
94
|
+
chartRef: internalChartRef,
|
|
95
|
+
userHandlers: { onPointerDown, onPointerMove, onPointerUp },
|
|
96
|
+
} );
|
|
97
|
+
|
|
89
98
|
const { legendChildren, nonLegendChildren } = useChartChildren( children, 'AreaChart' );
|
|
90
99
|
const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
|
|
91
100
|
|
|
@@ -136,12 +145,14 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
136
145
|
} );
|
|
137
146
|
|
|
138
147
|
// Computed from the full data set (ignoring legend visibility) so the y-axis stays
|
|
139
|
-
// fixed when series are toggled off
|
|
140
|
-
// and the chart appears to
|
|
141
|
-
//
|
|
142
|
-
//
|
|
148
|
+
// fixed when series are toggled off - otherwise visx auto-fits to the remaining
|
|
149
|
+
// data and the chart's baseline appears to move. Opt-in via
|
|
150
|
+
// `rescaleYOnLegendToggle={ false }`. Skipped for non-default stack offsets,
|
|
151
|
+
// which reshape the y-extent (`expand` -> [0,1], `wiggle`/`silhouette` -> centred
|
|
152
|
+
// around zero); letting visx derive the domain is correct there.
|
|
143
153
|
const fixedYDomain = useMemo< [ number, number ] | undefined >( () => {
|
|
144
154
|
if (
|
|
155
|
+
rescaleYOnLegendToggle ||
|
|
145
156
|
! legendInteractive ||
|
|
146
157
|
! dataSorted.length ||
|
|
147
158
|
! dataSorted[ 0 ].data.length ||
|
|
@@ -184,7 +195,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
184
195
|
}
|
|
185
196
|
if ( max === -Infinity ) return undefined;
|
|
186
197
|
return [ Math.min( 0, min ), max ];
|
|
187
|
-
}, [ dataSorted, stacked, stackOffset, legendInteractive ] );
|
|
198
|
+
}, [ dataSorted, stacked, stackOffset, legendInteractive, rescaleYOnLegendToggle ] );
|
|
188
199
|
|
|
189
200
|
const chartOptions = useMemo( () => {
|
|
190
201
|
const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
|
|
@@ -209,6 +220,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
209
220
|
xScale: {
|
|
210
221
|
type: 'time' as const,
|
|
211
222
|
...options?.xScale,
|
|
223
|
+
...( zoom.domain ? { domain: zoom.domain } : {} ),
|
|
212
224
|
},
|
|
213
225
|
yScale: {
|
|
214
226
|
type: 'linear' as const,
|
|
@@ -219,7 +231,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
219
231
|
...options?.yScale,
|
|
220
232
|
},
|
|
221
233
|
};
|
|
222
|
-
}, [ options, dataSorted, width, stacked, fixedYDomain ] );
|
|
234
|
+
}, [ options, dataSorted, width, stacked, fixedYDomain, zoom.domain ] );
|
|
223
235
|
|
|
224
236
|
const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme );
|
|
225
237
|
|
|
@@ -378,7 +390,8 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
378
390
|
onBlur={ onChartBlur }
|
|
379
391
|
>
|
|
380
392
|
{ chartHeight > 0 && (
|
|
381
|
-
<div ref={ chartRef }>
|
|
393
|
+
<div ref={ chartRef } style={ { position: 'relative' } }>
|
|
394
|
+
{ zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
|
|
382
395
|
<XYChart
|
|
383
396
|
theme={ theme }
|
|
384
397
|
width={ width }
|
|
@@ -386,9 +399,9 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
386
399
|
margin={ { ...defaultMargin, ...margin } }
|
|
387
400
|
xScale={ chartOptions.xScale }
|
|
388
401
|
yScale={ chartOptions.yScale }
|
|
389
|
-
onPointerDown={ onPointerDown }
|
|
390
|
-
onPointerUp={ onPointerUp }
|
|
391
|
-
onPointerMove={ onPointerMove }
|
|
402
|
+
onPointerDown={ zoom.handlers.onPointerDown }
|
|
403
|
+
onPointerUp={ zoom.handlers.onPointerUp }
|
|
404
|
+
onPointerMove={ zoom.handlers.onPointerMove }
|
|
392
405
|
onPointerOut={ onPointerOut }
|
|
393
406
|
pointerEventsDataKey="nearest"
|
|
394
407
|
>
|
|
@@ -410,17 +423,21 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
410
423
|
</SvgEmptyState>
|
|
411
424
|
) : null }
|
|
412
425
|
|
|
413
|
-
{
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
426
|
+
{ /* Area is animated, so clip the whole time it is zoomable to keep the zoom-out animation in bounds. */ }
|
|
427
|
+
<ZoomClip active={ zoomable } chartId={ chartId }>
|
|
428
|
+
{ ! allSeriesHidden && stacked && (
|
|
429
|
+
<AnimatedAreaStack
|
|
430
|
+
curve={ curve }
|
|
431
|
+
offset={ stackOffset }
|
|
432
|
+
renderLine={ resolvedWithStroke }
|
|
433
|
+
>
|
|
434
|
+
{ seriesWithVisibility.map( renderSeries ) }
|
|
435
|
+
</AnimatedAreaStack>
|
|
436
|
+
) }
|
|
437
|
+
{ ! allSeriesHidden &&
|
|
438
|
+
! stacked &&
|
|
439
|
+
seriesWithVisibility.map( renderSeries ) }
|
|
440
|
+
</ZoomClip>
|
|
424
441
|
|
|
425
442
|
{ withTooltips && (
|
|
426
443
|
<>
|
|
@@ -456,6 +473,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
456
473
|
height={ height || chartHeight }
|
|
457
474
|
margin={ margin }
|
|
458
475
|
/>
|
|
476
|
+
{ zoomable && <ZoomSelectionRect drag={ zoom.drag } /> }
|
|
459
477
|
</XYChart>
|
|
460
478
|
</div>
|
|
461
479
|
) }
|
|
@@ -240,7 +240,7 @@ describe( 'AreaChart', () => {
|
|
|
240
240
|
expect( keyboardCall![ 0 ].tooltipData?.nearestDatum?.key ).not.toBe( 'Series A' );
|
|
241
241
|
} );
|
|
242
242
|
|
|
243
|
-
test( 'y-axis
|
|
243
|
+
test( 'y-axis rescales across legend toggles by default', async () => {
|
|
244
244
|
const user = userEvent.setup();
|
|
245
245
|
const ref = createRef< SingleChartRef >();
|
|
246
246
|
render(
|
|
@@ -248,7 +248,7 @@ describe( 'AreaChart', () => {
|
|
|
248
248
|
<AreaChartUnresponsive
|
|
249
249
|
{ ...defaultProps }
|
|
250
250
|
showLegend
|
|
251
|
-
chartId="test-interactive-domain"
|
|
251
|
+
chartId="test-interactive-domain-rescale"
|
|
252
252
|
legend={ { interactive: true } }
|
|
253
253
|
ref={ ref }
|
|
254
254
|
/>
|
|
@@ -262,13 +262,82 @@ describe( 'AreaChart', () => {
|
|
|
262
262
|
|
|
263
263
|
await user.click( screen.getByText( 'Series A' ) );
|
|
264
264
|
|
|
265
|
+
const afterToggleDomain = (
|
|
266
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
267
|
+
)?.domain();
|
|
268
|
+
// Hiding Series A drops the upper bound; visx should refit.
|
|
269
|
+
expect( afterToggleDomain ).toBeDefined();
|
|
270
|
+
expect( afterToggleDomain![ 1 ] ).toBeLessThan( initialDomain![ 1 ] );
|
|
271
|
+
} );
|
|
272
|
+
|
|
273
|
+
test( 'y-axis stays pinned for unstacked area when rescaleYOnLegendToggle is false', async () => {
|
|
274
|
+
// Exercises the non-stacked branch of fixedYDomain, which scans the
|
|
275
|
+
// raw min/max across all series rather than summing stack columns.
|
|
276
|
+
const user = userEvent.setup();
|
|
277
|
+
const ref = createRef< SingleChartRef >();
|
|
278
|
+
render(
|
|
279
|
+
<GlobalChartsProvider>
|
|
280
|
+
<AreaChartUnresponsive
|
|
281
|
+
{ ...defaultProps }
|
|
282
|
+
showLegend
|
|
283
|
+
chartId="test-interactive-domain-pin-unstacked"
|
|
284
|
+
legend={ { interactive: true } }
|
|
285
|
+
stacked={ false }
|
|
286
|
+
rescaleYOnLegendToggle={ false }
|
|
287
|
+
ref={ ref }
|
|
288
|
+
/>
|
|
289
|
+
</GlobalChartsProvider>
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const initialDomain = (
|
|
293
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
294
|
+
)?.domain();
|
|
295
|
+
expect( initialDomain ).toBeDefined();
|
|
296
|
+
// defaultProps has values up to 20; pinned-unstacked should cover the max.
|
|
297
|
+
expect( initialDomain![ 1 ] ).toBeGreaterThanOrEqual( 20 );
|
|
298
|
+
|
|
299
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
300
|
+
|
|
265
301
|
const afterToggleDomain = (
|
|
266
302
|
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
267
303
|
)?.domain();
|
|
268
304
|
expect( afterToggleDomain ).toEqual( initialDomain );
|
|
269
305
|
} );
|
|
270
306
|
|
|
271
|
-
test( '
|
|
307
|
+
test( 'y-axis stays pinned when rescaleYOnLegendToggle is false', async () => {
|
|
308
|
+
const user = userEvent.setup();
|
|
309
|
+
const ref = createRef< SingleChartRef >();
|
|
310
|
+
render(
|
|
311
|
+
<GlobalChartsProvider>
|
|
312
|
+
<AreaChartUnresponsive
|
|
313
|
+
{ ...defaultProps }
|
|
314
|
+
showLegend
|
|
315
|
+
chartId="test-interactive-domain-pin"
|
|
316
|
+
legend={ { interactive: true } }
|
|
317
|
+
rescaleYOnLegendToggle={ false }
|
|
318
|
+
ref={ ref }
|
|
319
|
+
/>
|
|
320
|
+
</GlobalChartsProvider>
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const initialDomain = (
|
|
324
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
325
|
+
)?.domain();
|
|
326
|
+
expect( initialDomain ).toBeDefined();
|
|
327
|
+
|
|
328
|
+
await user.click( screen.getByText( 'Series A' ) );
|
|
329
|
+
|
|
330
|
+
const afterToggleDomain = (
|
|
331
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
332
|
+
)?.domain();
|
|
333
|
+
expect( afterToggleDomain ).toEqual( initialDomain );
|
|
334
|
+
} );
|
|
335
|
+
|
|
336
|
+
test( 'supports negative stacked values without clipping (with pinned Y)', () => {
|
|
337
|
+
// The mixed-sign full-extent pin only kicks in when the consumer
|
|
338
|
+
// opts into pinned-Y behavior; visx's natural domain derivation for
|
|
339
|
+
// a `stackOffset: 'none'` stack does not extend below zero for
|
|
340
|
+
// purely-negative series, which is what this test guards against.
|
|
272
341
|
const ref = createRef< SingleChartRef >();
|
|
273
342
|
render(
|
|
274
343
|
<GlobalChartsProvider>
|
|
@@ -278,6 +347,7 @@ describe( 'AreaChart', () => {
|
|
|
278
347
|
chartId="test-interactive-negative"
|
|
279
348
|
showLegend
|
|
280
349
|
legend={ { interactive: true } }
|
|
350
|
+
rescaleYOnLegendToggle={ false }
|
|
281
351
|
data={ [
|
|
282
352
|
{
|
|
283
353
|
label: 'Pos',
|
|
@@ -464,4 +534,14 @@ describe( 'AreaChart', () => {
|
|
|
464
534
|
expect( screen.queryByTestId( 'area-chart-hover-glyph-0' ) ).not.toBeInTheDocument();
|
|
465
535
|
} );
|
|
466
536
|
} );
|
|
537
|
+
|
|
538
|
+
// The area is animated, so it clips whenever zoomable (not just while zoomed).
|
|
539
|
+
test( 'clips the series to the plot when zoomable', () => {
|
|
540
|
+
renderUnresponsive( { zoomable: true, chartId: 'zoomtest' } );
|
|
541
|
+
|
|
542
|
+
expect( screen.getByTestId( 'chart-series-clip-group' ) ).toHaveAttribute(
|
|
543
|
+
'clip-path',
|
|
544
|
+
'url(#chart-zoom-clip-zoomtest)'
|
|
545
|
+
);
|
|
546
|
+
} );
|
|
467
547
|
} );
|
|
@@ -47,5 +47,20 @@ export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
|
|
|
47
47
|
* @default false when stacked, true when overlapping
|
|
48
48
|
*/
|
|
49
49
|
withStroke?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Enable drag-to-zoom on the X axis. The user drags horizontally to
|
|
52
|
+
* select a range; the X axis rescales to that range. A small reset
|
|
53
|
+
* button appears in the top-right of the chart while zoomed.
|
|
54
|
+
*/
|
|
55
|
+
zoomable?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* When using an interactive legend, controls whether the Y axis rescales
|
|
58
|
+
* to fit only the visible series. Defaults to `true`, matching the
|
|
59
|
+
* intuitive default for LineChart and BarChart. Set to `false` to pin
|
|
60
|
+
* the Y axis to the full data extent so toggling legend items off does
|
|
61
|
+
* not move the chart's baseline.
|
|
62
|
+
* @default true
|
|
63
|
+
*/
|
|
64
|
+
rescaleYOnLegendToggle?: boolean;
|
|
50
65
|
children?: ReactNode;
|
|
51
66
|
}
|
|
@@ -37,6 +37,7 @@ import { SingleChartContext, type SingleChartRef } from '../private/single-chart
|
|
|
37
37
|
import { SvgEmptyState } from '../private/svg-empty-state';
|
|
38
38
|
import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
|
|
39
39
|
import { withResponsive } from '../private/with-responsive';
|
|
40
|
+
import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
|
|
40
41
|
import styles from './line-chart.module.scss';
|
|
41
42
|
import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private';
|
|
42
43
|
import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types';
|
|
@@ -177,6 +178,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
177
178
|
onPointerUp = undefined,
|
|
178
179
|
onPointerMove = undefined,
|
|
179
180
|
onPointerOut = undefined,
|
|
181
|
+
zoomable = false,
|
|
180
182
|
children,
|
|
181
183
|
gridVisibility,
|
|
182
184
|
gap = 'md',
|
|
@@ -195,6 +197,12 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
195
197
|
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
196
198
|
const internalChartRef = useRef< SingleChartRef >( null );
|
|
197
199
|
|
|
200
|
+
const zoom = useXZoom< Date >( {
|
|
201
|
+
enabled: zoomable,
|
|
202
|
+
chartRef: internalChartRef,
|
|
203
|
+
userHandlers: { onPointerDown, onPointerMove, onPointerUp },
|
|
204
|
+
} );
|
|
205
|
+
|
|
198
206
|
// Process children for composition API (Legend, etc.)
|
|
199
207
|
const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LineChart' );
|
|
200
208
|
const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
|
|
@@ -273,6 +281,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
273
281
|
xScale: {
|
|
274
282
|
type: 'time' as const,
|
|
275
283
|
...options?.xScale,
|
|
284
|
+
...( zoom.domain ? { domain: zoom.domain } : {} ),
|
|
276
285
|
},
|
|
277
286
|
yScale: {
|
|
278
287
|
type: 'linear' as const,
|
|
@@ -281,7 +290,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
281
290
|
...options?.yScale,
|
|
282
291
|
},
|
|
283
292
|
};
|
|
284
|
-
}, [ options, dataSorted, width ] );
|
|
293
|
+
}, [ options, dataSorted, width, zoom.domain ] );
|
|
285
294
|
|
|
286
295
|
const tooltipRenderGlyph = useMemo( () => {
|
|
287
296
|
return ( props: GlyphProps< DataPointDate > ) => {
|
|
@@ -412,7 +421,8 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
412
421
|
onBlur={ onChartBlur }
|
|
413
422
|
>
|
|
414
423
|
{ chartHeight > 0 && (
|
|
415
|
-
<div ref={ chartRef }>
|
|
424
|
+
<div ref={ chartRef } style={ { position: 'relative' } }>
|
|
425
|
+
{ zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
|
|
416
426
|
<XYChart
|
|
417
427
|
theme={ theme }
|
|
418
428
|
width={ width }
|
|
@@ -424,9 +434,9 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
424
434
|
// xScale and yScale could be set in Axis as well, but they are `scale` props there.
|
|
425
435
|
xScale={ chartOptions.xScale }
|
|
426
436
|
yScale={ chartOptions.yScale }
|
|
427
|
-
onPointerDown={ onPointerDown }
|
|
428
|
-
onPointerUp={ onPointerUp }
|
|
429
|
-
onPointerMove={ onPointerMove }
|
|
437
|
+
onPointerDown={ zoom.handlers.onPointerDown }
|
|
438
|
+
onPointerUp={ zoom.handlers.onPointerUp }
|
|
439
|
+
onPointerMove={ zoom.handlers.onPointerMove }
|
|
430
440
|
onPointerOut={ onPointerOut }
|
|
431
441
|
pointerEventsDataKey="nearest"
|
|
432
442
|
>
|
|
@@ -448,86 +458,93 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
448
458
|
</SvgEmptyState>
|
|
449
459
|
) : null }
|
|
450
460
|
|
|
451
|
-
{
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
461
|
+
{ /* Line is not animated, so clip only while zoomed; its edge glyphs sit on the plot border and must not be clipped. */ }
|
|
462
|
+
<ZoomClip active={ zoomable && !! zoom.domain } chartId={ chartId }>
|
|
463
|
+
{ seriesWithVisibility.map(
|
|
464
|
+
( { series: seriesData, index, isVisible } ) => {
|
|
465
|
+
// Skip rendering invisible series
|
|
466
|
+
if ( ! isVisible ) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const { color, lineStyles, glyph } = getElementStyles( {
|
|
471
|
+
data: seriesData,
|
|
472
|
+
index,
|
|
473
|
+
} );
|
|
474
|
+
|
|
475
|
+
const lineProps = {
|
|
476
|
+
stroke: color,
|
|
477
|
+
...lineStyles,
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<g key={ seriesData?.label || index }>
|
|
482
|
+
{ withGradientFill && (
|
|
483
|
+
<LinearGradient
|
|
484
|
+
id={ `area-gradient-${ chartId }-${ index + 1 }` }
|
|
485
|
+
from={ color }
|
|
486
|
+
fromOpacity={ 0.4 }
|
|
487
|
+
toOpacity={ 0.1 }
|
|
488
|
+
to={ providerTheme.backgroundColor }
|
|
489
|
+
{ ...seriesData.options?.gradient }
|
|
490
|
+
data-testid="line-gradient"
|
|
491
|
+
>
|
|
492
|
+
{ seriesData.options?.gradient?.stops?.map(
|
|
493
|
+
( stop, stopIndex ) => (
|
|
494
|
+
<stop
|
|
495
|
+
key={ `${ stop.offset }-${ stop.color || color }` }
|
|
496
|
+
offset={ stop.offset }
|
|
497
|
+
stopColor={ stop.color || color }
|
|
498
|
+
stopOpacity={ stop.opacity ?? 1 }
|
|
499
|
+
data-testid={ `line-gradient-stop-${ chartId }-${ index }-${ stopIndex }` }
|
|
500
|
+
/>
|
|
501
|
+
)
|
|
502
|
+
) }
|
|
503
|
+
</LinearGradient>
|
|
504
|
+
) }
|
|
505
|
+
<AreaSeries
|
|
506
|
+
key={ seriesData?.label }
|
|
507
|
+
dataKey={ seriesData?.label }
|
|
508
|
+
data={ seriesData.data as DataPointDate[] }
|
|
509
|
+
{ ...accessors }
|
|
510
|
+
fill={
|
|
511
|
+
withGradientFill
|
|
512
|
+
? `url(#area-gradient-${ chartId }-${ index + 1 })`
|
|
513
|
+
: 'transparent'
|
|
514
|
+
}
|
|
515
|
+
renderLine={ true }
|
|
516
|
+
curve={ getCurveType( curveType, smoothing ) }
|
|
517
|
+
lineProps={ lineProps }
|
|
518
|
+
/>
|
|
519
|
+
|
|
520
|
+
{ withStartGlyphs && (
|
|
521
|
+
<LineChartGlyph
|
|
522
|
+
index={ index }
|
|
523
|
+
data={ seriesData }
|
|
524
|
+
color={ color }
|
|
525
|
+
renderGlyph={ glyph ?? renderGlyph }
|
|
526
|
+
accessors={ accessors }
|
|
527
|
+
glyphStyle={ glyphStyle }
|
|
528
|
+
position="start"
|
|
486
529
|
/>
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
{ withStartGlyphs && (
|
|
506
|
-
<LineChartGlyph
|
|
507
|
-
index={ index }
|
|
508
|
-
data={ seriesData }
|
|
509
|
-
color={ color }
|
|
510
|
-
renderGlyph={ glyph ?? renderGlyph }
|
|
511
|
-
accessors={ accessors }
|
|
512
|
-
glyphStyle={ glyphStyle }
|
|
513
|
-
position="start"
|
|
514
|
-
/>
|
|
515
|
-
) }
|
|
516
|
-
|
|
517
|
-
{ withEndGlyphs && (
|
|
518
|
-
<LineChartGlyph
|
|
519
|
-
index={ index }
|
|
520
|
-
data={ seriesData }
|
|
521
|
-
color={ color }
|
|
522
|
-
renderGlyph={ glyph ?? renderGlyph }
|
|
523
|
-
accessors={ accessors }
|
|
524
|
-
glyphStyle={ glyphStyle }
|
|
525
|
-
position="end"
|
|
526
|
-
/>
|
|
527
|
-
) }
|
|
528
|
-
</g>
|
|
529
|
-
);
|
|
530
|
-
} ) }
|
|
530
|
+
) }
|
|
531
|
+
|
|
532
|
+
{ withEndGlyphs && (
|
|
533
|
+
<LineChartGlyph
|
|
534
|
+
index={ index }
|
|
535
|
+
data={ seriesData }
|
|
536
|
+
color={ color }
|
|
537
|
+
renderGlyph={ glyph ?? renderGlyph }
|
|
538
|
+
accessors={ accessors }
|
|
539
|
+
glyphStyle={ glyphStyle }
|
|
540
|
+
position="end"
|
|
541
|
+
/>
|
|
542
|
+
) }
|
|
543
|
+
</g>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
) }
|
|
547
|
+
</ZoomClip>
|
|
531
548
|
|
|
532
549
|
{ withTooltips && (
|
|
533
550
|
<AccessibleTooltip
|
|
@@ -556,6 +573,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
556
573
|
height={ height }
|
|
557
574
|
margin={ margin }
|
|
558
575
|
/>
|
|
576
|
+
{ zoomable && <ZoomSelectionRect drag={ zoom.drag } /> }
|
|
559
577
|
</XYChart>
|
|
560
578
|
</div>
|
|
561
579
|
) }
|
|
@@ -14,6 +14,31 @@ jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
|
14
14
|
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
15
15
|
} ) );
|
|
16
16
|
|
|
17
|
+
// Drive the zoom state directly so we can assert the clip-path behaviour without
|
|
18
|
+
// simulating a pointer drag (svgPoint geometry is unavailable in jsdom). The rest
|
|
19
|
+
// of the x-zoom module (ZoomClipPath, getZoomClipPathId, etc.) stays real.
|
|
20
|
+
const mockUseXZoom = jest.fn();
|
|
21
|
+
jest.mock( '../../private/x-zoom', () => {
|
|
22
|
+
const actual = jest.requireActual( '../../private/x-zoom' );
|
|
23
|
+
return {
|
|
24
|
+
__esModule: true,
|
|
25
|
+
...actual,
|
|
26
|
+
useXZoom: ( ...args: unknown[] ) => mockUseXZoom( ...args ),
|
|
27
|
+
};
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
const passthroughZoom = () => ( {
|
|
31
|
+
domain: null,
|
|
32
|
+
drag: null,
|
|
33
|
+
reset: jest.fn(),
|
|
34
|
+
handlers: { onPointerDown: jest.fn(), onPointerMove: jest.fn(), onPointerUp: jest.fn() },
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
beforeEach( () => {
|
|
38
|
+
mockUseXZoom.mockReset();
|
|
39
|
+
mockUseXZoom.mockImplementation( passthroughZoom );
|
|
40
|
+
} );
|
|
41
|
+
|
|
17
42
|
const customTheme = {
|
|
18
43
|
...defaultTheme,
|
|
19
44
|
glyphs: [
|
|
@@ -1253,4 +1278,20 @@ describe( 'LineChart', () => {
|
|
|
1253
1278
|
} );
|
|
1254
1279
|
} );
|
|
1255
1280
|
} );
|
|
1281
|
+
|
|
1282
|
+
// The line is not animated, so it clips only while actually zoomed.
|
|
1283
|
+
test( 'clips the series to the plot when zoomed', () => {
|
|
1284
|
+
mockUseXZoom.mockImplementation( () => ( {
|
|
1285
|
+
domain: [ new Date( '2024-01-01' ), new Date( '2024-01-02' ) ],
|
|
1286
|
+
drag: null,
|
|
1287
|
+
reset: jest.fn(),
|
|
1288
|
+
handlers: { onPointerDown: jest.fn(), onPointerMove: jest.fn(), onPointerUp: jest.fn() },
|
|
1289
|
+
} ) );
|
|
1290
|
+
renderUnwrappedWithTheme( { zoomable: true, chartId: 'zoomtest' } );
|
|
1291
|
+
|
|
1292
|
+
expect( screen.getByTestId( 'chart-series-clip-group' ) ).toHaveAttribute(
|
|
1293
|
+
'clip-path',
|
|
1294
|
+
'url(#chart-zoom-clip-zoomtest)'
|
|
1295
|
+
);
|
|
1296
|
+
} );
|
|
1256
1297
|
} );
|
|
@@ -41,6 +41,12 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
|
|
|
41
41
|
showVertical?: boolean;
|
|
42
42
|
showHorizontal?: boolean;
|
|
43
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* Enable drag-to-zoom on the X axis. The user drags horizontally to
|
|
46
|
+
* select a range; the X axis rescales to that range. A small reset
|
|
47
|
+
* button appears in the top-right of the chart while zoomed.
|
|
48
|
+
*/
|
|
49
|
+
zoomable?: boolean;
|
|
44
50
|
children?: ReactNode;
|
|
45
51
|
}
|
|
46
52
|
|