@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/CHANGELOG.md +11 -0
- package/dist/index.cjs +194 -157
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +114 -77
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/area-chart/area-chart.tsx +16 -12
- package/src/charts/area-chart/test/area-chart.test.tsx +10 -0
- package/src/charts/line-chart/line-chart.tsx +87 -80
- package/src/charts/line-chart/test/line-chart.test.tsx +41 -0
- package/src/charts/private/x-zoom.tsx +48 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.5.
|
|
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.
|
|
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
|
-
{
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
{
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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`
|