@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.4.3",
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.0",
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": "^12.0.0",
86
+ "@wordpress/icons": "^13.0.0",
87
87
  "@wordpress/theme": "0.13.0",
88
- "@wordpress/ui": "0.11.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 otherwise visx auto-fits to the remaining data
140
- // and the chart appears to rescale. Skipped for non-default stack offsets, which
141
- // reshape the y-extent (`expand` [0,1], `wiggle`/`silhouette` → centred around
142
- // zero) letting visx derive the domain is correct there.
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
- { ! allSeriesHidden && stacked && (
414
- <AnimatedAreaStack
415
- curve={ curve }
416
- offset={ stackOffset }
417
- renderLine={ resolvedWithStroke }
418
- >
419
- { seriesWithVisibility.map( renderSeries ) }
420
- </AnimatedAreaStack>
421
- ) }
422
-
423
- { ! allSeriesHidden && ! stacked && seriesWithVisibility.map( renderSeries ) }
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 domain stays fixed across legend toggles', async () => {
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( 'supports negative stacked values without clipping', () => {
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
- { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => {
452
- // Skip rendering invisible series
453
- if ( ! isVisible ) {
454
- return null;
455
- }
456
-
457
- const { color, lineStyles, glyph } = getElementStyles( {
458
- data: seriesData,
459
- index,
460
- } );
461
-
462
- const lineProps = {
463
- stroke: color,
464
- ...lineStyles,
465
- };
466
-
467
- return (
468
- <g key={ seriesData?.label || index }>
469
- { withGradientFill && (
470
- <LinearGradient
471
- id={ `area-gradient-${ chartId }-${ index + 1 }` }
472
- from={ color }
473
- fromOpacity={ 0.4 }
474
- toOpacity={ 0.1 }
475
- to={ providerTheme.backgroundColor }
476
- { ...seriesData.options?.gradient }
477
- data-testid="line-gradient"
478
- >
479
- { seriesData.options?.gradient?.stops?.map( ( stop, stopIndex ) => (
480
- <stop
481
- key={ `${ stop.offset }-${ stop.color || color }` }
482
- offset={ stop.offset }
483
- stopColor={ stop.color || color }
484
- stopOpacity={ stop.opacity ?? 1 }
485
- data-testid={ `line-gradient-stop-${ chartId }-${ index }-${ stopIndex }` }
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
- </LinearGradient>
489
- ) }
490
- <AreaSeries
491
- key={ seriesData?.label }
492
- dataKey={ seriesData?.label }
493
- data={ seriesData.data as DataPointDate[] }
494
- { ...accessors }
495
- fill={
496
- withGradientFill
497
- ? `url(#area-gradient-${ chartId }-${ index + 1 })`
498
- : 'transparent'
499
- }
500
- renderLine={ true }
501
- curve={ getCurveType( curveType, smoothing ) }
502
- lineProps={ lineProps }
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