@automattic/charts 1.4.2 → 1.5.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 -0
- package/SECURITY.md +0 -1
- package/dist/index.cjs +449 -137
- 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 +747 -435
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/charts/area-chart/area-chart.tsx +24 -10
- package/src/charts/area-chart/test/area-chart.test.tsx +73 -3
- package/src/charts/area-chart/types.ts +15 -0
- package/src/charts/line-chart/line-chart.tsx +16 -5
- 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 +162 -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.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.
|
|
66
|
+
"@automattic/number-formatters": "^1.2.0",
|
|
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 } 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
|
>
|
|
@@ -456,6 +469,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
456
469
|
height={ height || chartHeight }
|
|
457
470
|
margin={ margin }
|
|
458
471
|
/>
|
|
472
|
+
{ zoomable && <ZoomSelectionRect drag={ zoom.drag } /> }
|
|
459
473
|
</XYChart>
|
|
460
474
|
</div>
|
|
461
475
|
) }
|
|
@@ -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
|
+
|
|
301
|
+
const afterToggleDomain = (
|
|
302
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
303
|
+
)?.domain();
|
|
304
|
+
expect( afterToggleDomain ).toEqual( initialDomain );
|
|
305
|
+
} );
|
|
306
|
+
|
|
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
|
+
|
|
265
330
|
const afterToggleDomain = (
|
|
266
331
|
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
267
332
|
)?.domain();
|
|
268
333
|
expect( afterToggleDomain ).toEqual( initialDomain );
|
|
269
334
|
} );
|
|
270
335
|
|
|
271
|
-
test( 'supports negative stacked values without clipping', () => {
|
|
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',
|
|
@@ -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 } 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
|
>
|
|
@@ -556,6 +566,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
556
566
|
height={ height }
|
|
557
567
|
margin={ margin }
|
|
558
568
|
/>
|
|
569
|
+
{ zoomable && <ZoomSelectionRect drag={ zoom.drag } /> }
|
|
559
570
|
</XYChart>
|
|
560
571
|
</div>
|
|
561
572
|
) }
|
|
@@ -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
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { useRef } from 'react';
|
|
3
|
+
import { useXZoom } from '../x-zoom';
|
|
4
|
+
import type { SingleChartRef } from '../single-chart-context';
|
|
5
|
+
import type { EventHandlerParams } from '@visx/xychart';
|
|
6
|
+
|
|
7
|
+
// A fake scale that lets the hook convert pixel positions to data values.
|
|
8
|
+
// The hook only uses `.invert()`; everything else can be stubbed.
|
|
9
|
+
const fakeXScale = ( ( x: number ) => x * 2 ) as unknown as ( ( v: number ) => number ) & {
|
|
10
|
+
invert: ( v: number ) => number;
|
|
11
|
+
};
|
|
12
|
+
( fakeXScale as unknown as { invert: ( v: number ) => number } ).invert = v => v * 2;
|
|
13
|
+
|
|
14
|
+
const makeChartRef = (
|
|
15
|
+
scale: { invert?: ( v: number ) => unknown } | null = fakeXScale as unknown as {
|
|
16
|
+
invert: ( v: number ) => unknown;
|
|
17
|
+
}
|
|
18
|
+
) =>
|
|
19
|
+
( {
|
|
20
|
+
current: {
|
|
21
|
+
getScales: () => ( scale ? { xScale: scale, yScale: scale } : null ),
|
|
22
|
+
getChartDimensions: () => ( { width: 0, height: 0, margin: {} } ),
|
|
23
|
+
},
|
|
24
|
+
} ) as unknown as ReturnType< typeof useRef< SingleChartRef > >;
|
|
25
|
+
|
|
26
|
+
const makeParams = ( x: number ): EventHandlerParams< object > =>
|
|
27
|
+
( {
|
|
28
|
+
key: '',
|
|
29
|
+
index: 0,
|
|
30
|
+
datum: {},
|
|
31
|
+
event: new Event( 'pointerdown' ) as unknown as PointerEvent,
|
|
32
|
+
svgPoint: { x, y: 0 },
|
|
33
|
+
} ) as unknown as EventHandlerParams< object >;
|
|
34
|
+
|
|
35
|
+
describe( 'useXZoom', () => {
|
|
36
|
+
test( 'commits a domain on pointerup after dragging more than minDragPixels', () => {
|
|
37
|
+
const chartRef = makeChartRef();
|
|
38
|
+
const { result } = renderHook( () => useXZoom< number >( { enabled: true, chartRef } ) );
|
|
39
|
+
|
|
40
|
+
act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
|
|
41
|
+
act( () => result.current.handlers.onPointerMove( makeParams( 200 ) ) );
|
|
42
|
+
act( () => result.current.handlers.onPointerUp( makeParams( 200 ) ) );
|
|
43
|
+
|
|
44
|
+
// fakeXScale.invert(x) = x * 2.
|
|
45
|
+
expect( result.current.domain ).toEqual( [ 200, 400 ] );
|
|
46
|
+
expect( result.current.drag ).toBeNull();
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
test( 'discards drags shorter than minDragPixels', () => {
|
|
50
|
+
const chartRef = makeChartRef();
|
|
51
|
+
const { result } = renderHook( () => useXZoom< number >( { enabled: true, chartRef } ) );
|
|
52
|
+
|
|
53
|
+
act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
|
|
54
|
+
act( () => result.current.handlers.onPointerMove( makeParams( 103 ) ) );
|
|
55
|
+
act( () => result.current.handlers.onPointerUp( makeParams( 103 ) ) );
|
|
56
|
+
|
|
57
|
+
expect( result.current.domain ).toBeNull();
|
|
58
|
+
expect( result.current.drag ).toBeNull();
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
test( 'normalises right-to-left drags', () => {
|
|
62
|
+
const chartRef = makeChartRef();
|
|
63
|
+
const { result } = renderHook( () => useXZoom< number >( { enabled: true, chartRef } ) );
|
|
64
|
+
|
|
65
|
+
act( () => result.current.handlers.onPointerDown( makeParams( 300 ) ) );
|
|
66
|
+
act( () => result.current.handlers.onPointerMove( makeParams( 100 ) ) );
|
|
67
|
+
act( () => result.current.handlers.onPointerUp( makeParams( 100 ) ) );
|
|
68
|
+
|
|
69
|
+
expect( result.current.domain ).toEqual( [ 200, 600 ] );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
test( 'reset clears the committed domain', () => {
|
|
73
|
+
const chartRef = makeChartRef();
|
|
74
|
+
const { result } = renderHook( () => useXZoom< number >( { enabled: true, chartRef } ) );
|
|
75
|
+
|
|
76
|
+
act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
|
|
77
|
+
act( () => result.current.handlers.onPointerMove( makeParams( 200 ) ) );
|
|
78
|
+
act( () => result.current.handlers.onPointerUp( makeParams( 200 ) ) );
|
|
79
|
+
expect( result.current.domain ).not.toBeNull();
|
|
80
|
+
act( () => result.current.reset() );
|
|
81
|
+
expect( result.current.domain ).toBeNull();
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
test( 'is a passthrough when disabled', () => {
|
|
85
|
+
const chartRef = makeChartRef();
|
|
86
|
+
const userOnPointerDown = jest.fn();
|
|
87
|
+
const { result } = renderHook( () =>
|
|
88
|
+
useXZoom< number >( {
|
|
89
|
+
enabled: false,
|
|
90
|
+
chartRef,
|
|
91
|
+
userHandlers: { onPointerDown: userOnPointerDown },
|
|
92
|
+
} )
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
|
|
96
|
+
act( () => result.current.handlers.onPointerMove( makeParams( 200 ) ) );
|
|
97
|
+
act( () => result.current.handlers.onPointerUp( makeParams( 200 ) ) );
|
|
98
|
+
|
|
99
|
+
expect( userOnPointerDown ).toHaveBeenCalledTimes( 1 );
|
|
100
|
+
expect( result.current.domain ).toBeNull();
|
|
101
|
+
expect( result.current.drag ).toBeNull();
|
|
102
|
+
} );
|
|
103
|
+
|
|
104
|
+
test( 'forwards events to user handlers when zoom is enabled', () => {
|
|
105
|
+
const chartRef = makeChartRef();
|
|
106
|
+
const userOnPointerDown = jest.fn();
|
|
107
|
+
const userOnPointerMove = jest.fn();
|
|
108
|
+
const userOnPointerUp = jest.fn();
|
|
109
|
+
const { result } = renderHook( () =>
|
|
110
|
+
useXZoom< number >( {
|
|
111
|
+
enabled: true,
|
|
112
|
+
chartRef,
|
|
113
|
+
userHandlers: {
|
|
114
|
+
onPointerDown: userOnPointerDown,
|
|
115
|
+
onPointerMove: userOnPointerMove,
|
|
116
|
+
onPointerUp: userOnPointerUp,
|
|
117
|
+
},
|
|
118
|
+
} )
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
|
|
122
|
+
act( () => result.current.handlers.onPointerMove( makeParams( 200 ) ) );
|
|
123
|
+
act( () => result.current.handlers.onPointerUp( makeParams( 200 ) ) );
|
|
124
|
+
|
|
125
|
+
expect( userOnPointerDown ).toHaveBeenCalledTimes( 1 );
|
|
126
|
+
expect( userOnPointerMove ).toHaveBeenCalledTimes( 1 );
|
|
127
|
+
expect( userOnPointerUp ).toHaveBeenCalledTimes( 1 );
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
test( 'leaves domain null when the X scale has no invert function', () => {
|
|
131
|
+
const chartRef = makeChartRef( {
|
|
132
|
+
/* no invert */
|
|
133
|
+
} );
|
|
134
|
+
const { result } = renderHook( () => useXZoom< number >( { enabled: true, chartRef } ) );
|
|
135
|
+
|
|
136
|
+
act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
|
|
137
|
+
act( () => result.current.handlers.onPointerMove( makeParams( 200 ) ) );
|
|
138
|
+
act( () => result.current.handlers.onPointerUp( makeParams( 200 ) ) );
|
|
139
|
+
|
|
140
|
+
expect( result.current.domain ).toBeNull();
|
|
141
|
+
} );
|
|
142
|
+
} );
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
.x-zoom {
|
|
2
|
+
|
|
3
|
+
&__selection {
|
|
4
|
+
fill: var(--charts-zoom-selection-fill, rgba(56, 88, 233, 0.16));
|
|
5
|
+
stroke: var(--charts-zoom-selection-stroke, rgba(56, 88, 233, 0.65));
|
|
6
|
+
stroke-width: var(--wpds-border-width-xs, 1px);
|
|
7
|
+
pointer-events: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
&__reset {
|
|
11
|
+
position: absolute;
|
|
12
|
+
top: var(--wpds-dimension-gap-sm, 8px);
|
|
13
|
+
right: var(--wpds-dimension-gap-sm, 8px);
|
|
14
|
+
z-index: 2;
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
width: 28px;
|
|
19
|
+
height: 28px;
|
|
20
|
+
padding: 0;
|
|
21
|
+
background: var(--charts-zoom-reset-bg, rgba(255, 255, 255, 0.92));
|
|
22
|
+
color: var(--charts-zoom-reset-fg, #1e1e1e);
|
|
23
|
+
border:
|
|
24
|
+
var(--wpds-border-width-xs, 1px) solid
|
|
25
|
+
var(--charts-zoom-reset-border, rgba(0, 0, 0, 0.16));
|
|
26
|
+
border-radius: var(--wpds-border-radius-md, 4px);
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
29
|
+
|
|
30
|
+
&:hover {
|
|
31
|
+
background: var(--charts-zoom-reset-bg-hover, rgba(255, 255, 255, 1));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
&:focus-visible {
|
|
35
|
+
outline: 2px solid var(--charts-zoom-reset-focus, #3858e9);
|
|
36
|
+
outline-offset: 1px;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
&__reset-icon {
|
|
41
|
+
width: 16px;
|
|
42
|
+
height: 16px;
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
}
|
|
45
|
+
}
|