@automattic/charts 1.5.0 → 1.5.2

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.5.0",
3
+ "version": "1.5.2",
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.2",
67
67
  "@babel/runtime": "7.29.2",
68
68
  "@react-spring/web": "9.7.5",
69
69
  "@visx/annotation": "^3.12.0",
@@ -35,7 +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
+ import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
39
39
  import styles from './area-chart.module.scss';
40
40
  import { AreaChartScalesRef, HoverGlyphs, validateData } from './private';
41
41
  import type { AreaChartProps } from './types';
@@ -423,17 +423,21 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
423
423
  </SvgEmptyState>
424
424
  ) : null }
425
425
 
426
- { ! allSeriesHidden && stacked && (
427
- <AnimatedAreaStack
428
- curve={ curve }
429
- offset={ stackOffset }
430
- renderLine={ resolvedWithStroke }
431
- >
432
- { seriesWithVisibility.map( renderSeries ) }
433
- </AnimatedAreaStack>
434
- ) }
435
-
436
- { ! 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>
437
441
 
438
442
  { withTooltips && (
439
443
  <>
@@ -534,4 +534,14 @@ describe( 'AreaChart', () => {
534
534
  expect( screen.queryByTestId( 'area-chart-hover-glyph-0' ) ).not.toBeInTheDocument();
535
535
  } );
536
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
+ } );
537
547
  } );
@@ -37,7 +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
+ import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
41
41
  import styles from './line-chart.module.scss';
42
42
  import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private';
43
43
  import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types';
@@ -458,86 +458,93 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
458
458
  </SvgEmptyState>
459
459
  ) : null }
460
460
 
461
- { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => {
462
- // Skip rendering invisible series
463
- if ( ! isVisible ) {
464
- return null;
465
- }
466
-
467
- const { color, lineStyles, glyph } = getElementStyles( {
468
- data: seriesData,
469
- index,
470
- } );
471
-
472
- const lineProps = {
473
- stroke: color,
474
- ...lineStyles,
475
- };
476
-
477
- return (
478
- <g key={ seriesData?.label || index }>
479
- { withGradientFill && (
480
- <LinearGradient
481
- id={ `area-gradient-${ chartId }-${ index + 1 }` }
482
- from={ color }
483
- fromOpacity={ 0.4 }
484
- toOpacity={ 0.1 }
485
- to={ providerTheme.backgroundColor }
486
- { ...seriesData.options?.gradient }
487
- data-testid="line-gradient"
488
- >
489
- { seriesData.options?.gradient?.stops?.map( ( stop, stopIndex ) => (
490
- <stop
491
- key={ `${ stop.offset }-${ stop.color || color }` }
492
- offset={ stop.offset }
493
- stopColor={ stop.color || color }
494
- stopOpacity={ stop.opacity ?? 1 }
495
- 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"
496
529
  />
497
- ) ) }
498
- </LinearGradient>
499
- ) }
500
- <AreaSeries
501
- key={ seriesData?.label }
502
- dataKey={ seriesData?.label }
503
- data={ seriesData.data as DataPointDate[] }
504
- { ...accessors }
505
- fill={
506
- withGradientFill
507
- ? `url(#area-gradient-${ chartId }-${ index + 1 })`
508
- : 'transparent'
509
- }
510
- renderLine={ true }
511
- curve={ getCurveType( curveType, smoothing ) }
512
- lineProps={ lineProps }
513
- />
514
-
515
- { withStartGlyphs && (
516
- <LineChartGlyph
517
- index={ index }
518
- data={ seriesData }
519
- color={ color }
520
- renderGlyph={ glyph ?? renderGlyph }
521
- accessors={ accessors }
522
- glyphStyle={ glyphStyle }
523
- position="start"
524
- />
525
- ) }
526
-
527
- { withEndGlyphs && (
528
- <LineChartGlyph
529
- index={ index }
530
- data={ seriesData }
531
- color={ color }
532
- renderGlyph={ glyph ?? renderGlyph }
533
- accessors={ accessors }
534
- glyphStyle={ glyphStyle }
535
- position="end"
536
- />
537
- ) }
538
- </g>
539
- );
540
- } ) }
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>
541
548
 
542
549
  { withTooltips && (
543
550
  <AccessibleTooltip
@@ -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
  } );
@@ -5,7 +5,7 @@ import styles from './x-zoom.module.scss';
5
5
  import type { SingleChartRef } from './single-chart-context';
6
6
  import type { AxisScale } from '@visx/axis';
7
7
  import type { EventHandlerParams } from '@visx/xychart';
8
- import type { MutableRefObject } from 'react';
8
+ import type { MutableRefObject, ReactNode } from 'react';
9
9
 
10
10
  const MIN_DRAG_PIXELS = 6;
11
11
 
@@ -122,6 +122,53 @@ export function ZoomSelectionRect( { drag }: { drag: Drag | null } ) {
122
122
  );
123
123
  }
124
124
 
125
+ /**
126
+ * Wraps a chart's series in a group that is clipped to the inner plot rectangle
127
+ * while `active`. Reads the plot geometry from visx's `DataContext` (the same
128
+ * source as `ZoomSelectionRect`), so the host charts don't compute any margins.
129
+ * The group is always rendered (only its `clip-path` toggles) so toggling zoom
130
+ * never remounts or re-animates the series.
131
+ *
132
+ * @param props - Props.
133
+ * @param props.active - Whether to clip (e.g. `zoomable`, or `zoomable && zoomed`).
134
+ * @param props.chartId - Chart id; used to build a unique clip-path id.
135
+ * @param props.children - The series to clip.
136
+ * @return JSX element.
137
+ */
138
+ export function ZoomClip( {
139
+ active,
140
+ chartId,
141
+ children,
142
+ }: {
143
+ active: boolean;
144
+ chartId?: string;
145
+ children: ReactNode;
146
+ } ) {
147
+ const { margin, innerWidth, innerHeight } = useContext( DataContext );
148
+ // Sanitise the chart id to a valid SVG/CSS id, and keep it unique per chart.
149
+ const id = `chart-zoom-clip-${ String( chartId ?? '' ).replace( /[^A-Za-z0-9_-]/g, '' ) }`;
150
+ const clip = active && ( innerWidth ?? 0 ) > 0 && ( innerHeight ?? 0 ) > 0;
151
+ return (
152
+ <>
153
+ { clip && (
154
+ <defs>
155
+ <clipPath id={ id } data-testid="chart-zoom-clip">
156
+ <rect
157
+ x={ margin?.left ?? 0 }
158
+ y={ margin?.top ?? 0 }
159
+ width={ innerWidth }
160
+ height={ innerHeight }
161
+ />
162
+ </clipPath>
163
+ </defs>
164
+ ) }
165
+ <g clipPath={ clip ? `url(#${ id })` : undefined } data-testid="chart-series-clip-group">
166
+ { children }
167
+ </g>
168
+ </>
169
+ );
170
+ }
171
+
125
172
  /**
126
173
  * Visible icon-only reset button rendered as an HTML overlay on top of
127
174
  * the chart container. The host should wrap its SVG in a `position: relative`