@automattic/charts 4.0.0 → 4.1.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 +18 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +548 -199
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +21 -0
- package/dist/index.d.cts +86 -72
- package/dist/index.d.ts +86 -72
- package/dist/index.js +549 -201
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
- package/src/charts/heatmap-chart/index.ts +2 -1
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/index.ts +1 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
- package/src/charts/heatmap-chart/types.ts +6 -0
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.tsx +39 -6
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +13 -2
- package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
- package/src/components/tooltip/accessible-tooltip.tsx +8 -2
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
- package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
- package/src/hooks/test/use-chart-margin.test.tsx +173 -3
- package/src/hooks/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/index.ts +4 -0
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +2 -2
- package/src/utils/date-formatting.ts +1 -18
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/test/date-parsing.auckland.test.ts +6 -0
- package/src/utils/test/date-parsing.santiago.test.ts +6 -0
- package/src/utils/test/date-parsing.test.ts +16 -1
- package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
- package/src/utils/warn-once.ts +23 -0
- package/src/visx/types.ts +27 -3
|
@@ -6,7 +6,7 @@ import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
|
6
6
|
import type { ReactNode } from 'react';
|
|
7
7
|
|
|
8
8
|
// A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
|
|
9
|
-
const renderChart = ( scope?: HTMLElement ) => {
|
|
9
|
+
const renderChart = ( scope?: HTMLElement, tooltipPlacement?: 'auto' | 'below-axis' ) => {
|
|
10
10
|
const chart = (
|
|
11
11
|
<LineChartUnresponsive
|
|
12
12
|
width={ 500 }
|
|
@@ -22,6 +22,7 @@ const renderChart = ( scope?: HTMLElement ) => {
|
|
|
22
22
|
},
|
|
23
23
|
] }
|
|
24
24
|
withTooltips
|
|
25
|
+
tooltipPlacement={ tooltipPlacement }
|
|
25
26
|
withTooltipCrosshairs={ { showVertical: true } }
|
|
26
27
|
withGradientFill={ false }
|
|
27
28
|
/>
|
|
@@ -59,6 +60,32 @@ describe( 'AccessibleTooltip', () => {
|
|
|
59
60
|
document.body.removeChild( scope );
|
|
60
61
|
} );
|
|
61
62
|
|
|
63
|
+
it( 'keeps the existing focus scrolling for automatic tooltips', async () => {
|
|
64
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
65
|
+
try {
|
|
66
|
+
renderChart();
|
|
67
|
+
await openTooltip();
|
|
68
|
+
|
|
69
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
70
|
+
expect( focus ).toHaveBeenLastCalledWith();
|
|
71
|
+
} finally {
|
|
72
|
+
focus.mockRestore();
|
|
73
|
+
}
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'focuses below-axis keyboard tooltips without scrolling their ancestors', async () => {
|
|
77
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
78
|
+
try {
|
|
79
|
+
renderChart( undefined, 'below-axis' );
|
|
80
|
+
await openTooltip();
|
|
81
|
+
|
|
82
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
83
|
+
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
84
|
+
} finally {
|
|
85
|
+
focus.mockRestore();
|
|
86
|
+
}
|
|
87
|
+
} );
|
|
88
|
+
|
|
62
89
|
it( 'falls back to the catalog default when the role is unset', async () => {
|
|
63
90
|
renderChart();
|
|
64
91
|
|
|
@@ -12,6 +12,7 @@ describe( 'getBoundedPosition', () => {
|
|
|
12
12
|
top: 20,
|
|
13
13
|
...OFFSETS,
|
|
14
14
|
box: BOX,
|
|
15
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
15
16
|
bounds: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
16
17
|
} )
|
|
17
18
|
).toEqual( { x: 50, y: 30 } );
|
|
@@ -24,6 +25,7 @@ describe( 'getBoundedPosition', () => {
|
|
|
24
25
|
top: 280,
|
|
25
26
|
...OFFSETS,
|
|
26
27
|
box: BOX,
|
|
28
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
27
29
|
bounds: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
28
30
|
} )
|
|
29
31
|
).toEqual( { x: 282, y: 234 } );
|
|
@@ -38,23 +40,54 @@ describe( 'getBoundedPosition', () => {
|
|
|
38
40
|
top: 50,
|
|
39
41
|
...OFFSETS,
|
|
40
42
|
box: BOX,
|
|
43
|
+
wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
|
|
41
44
|
bounds: { left: 0, top: 0, right: 346, bottom: 200 },
|
|
42
45
|
} )
|
|
43
46
|
).toEqual( { x: 138, y: 60 } );
|
|
44
47
|
} );
|
|
45
48
|
|
|
46
49
|
test( 'may leave the wrapper as long as it stays inside the clipping ancestor', () => {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
50
|
+
// Neither side fits the 346px wrapper, and the clipping card ends 14px
|
|
51
|
+
// past its right edge, so the box keeps the near side and stops there.
|
|
49
52
|
expect(
|
|
50
53
|
getBoundedPosition( {
|
|
51
54
|
left: 173,
|
|
52
55
|
top: 50,
|
|
53
56
|
...OFFSETS,
|
|
54
57
|
box: BOX,
|
|
58
|
+
wrapper: { left: 0, top: 0, right: 346, bottom: 200 },
|
|
55
59
|
bounds: { left: -40, top: -60, right: 360, bottom: 200 },
|
|
56
60
|
} )
|
|
57
|
-
).toEqual( { x:
|
|
61
|
+
).toEqual( { x: 152, y: 60 } );
|
|
62
|
+
} );
|
|
63
|
+
|
|
64
|
+
test( 'flips up to stay inside the wrapper even when the page below has room', () => {
|
|
65
|
+
// The box fits above the anchor inside the 200px wrapper, so it goes
|
|
66
|
+
// there rather than below the chart, where the page would cover it.
|
|
67
|
+
expect(
|
|
68
|
+
getBoundedPosition( {
|
|
69
|
+
left: 40,
|
|
70
|
+
top: 180,
|
|
71
|
+
...OFFSETS,
|
|
72
|
+
box: BOX,
|
|
73
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 200 },
|
|
74
|
+
bounds: { left: 0, top: 0, right: 1024, bottom: 768 },
|
|
75
|
+
} )
|
|
76
|
+
).toEqual( { x: 50, y: 134 } );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
test( 'flips up when the wrapper fits below but the clipping ancestor does not', () => {
|
|
80
|
+
// A scrolled container cuts the wrapper off 20px under the anchor.
|
|
81
|
+
expect(
|
|
82
|
+
getBoundedPosition( {
|
|
83
|
+
left: 40,
|
|
84
|
+
top: 100,
|
|
85
|
+
...OFFSETS,
|
|
86
|
+
box: BOX,
|
|
87
|
+
wrapper: { left: 0, top: 0, right: 600, bottom: 300 },
|
|
88
|
+
bounds: { left: 0, top: -50, right: 600, bottom: 120 },
|
|
89
|
+
} )
|
|
90
|
+
).toEqual( { x: 50, y: 54 } );
|
|
58
91
|
} );
|
|
59
92
|
} );
|
|
60
93
|
|
|
@@ -100,11 +133,74 @@ describe( 'BoundedTooltip', () => {
|
|
|
100
133
|
</div>
|
|
101
134
|
);
|
|
102
135
|
|
|
103
|
-
// Neither side of the anchor fits
|
|
104
|
-
// the card's
|
|
105
|
-
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(
|
|
136
|
+
// Neither side of the anchor fits the wrapper, so the box keeps the near
|
|
137
|
+
// side and stops at the card's right edge, 14px outside the wrapper.
|
|
138
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(152px, 60px)' } );
|
|
106
139
|
} );
|
|
107
140
|
|
|
141
|
+
test( 'keeps the box inside the wrapper when nothing clips and the anchor sits low', () => {
|
|
142
|
+
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
143
|
+
this: Element
|
|
144
|
+
) {
|
|
145
|
+
return this.classList.contains( 'visx-tooltip' )
|
|
146
|
+
? rect( 0, 0, 150, 100 )
|
|
147
|
+
: rect( 100, 100, 600, 200 );
|
|
148
|
+
} );
|
|
149
|
+
|
|
150
|
+
render(
|
|
151
|
+
<div style={ { position: 'relative' } }>
|
|
152
|
+
<BoundedTooltip left={ 300 } top={ 180 } data-testid="box">
|
|
153
|
+
content
|
|
154
|
+
</BoundedTooltip>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// The viewport has room below the wrapper, but that is where the page's
|
|
159
|
+
// own content paints over the box; above the anchor it fits in the chart.
|
|
160
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(310px, 70px)' } );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
test.each( [
|
|
164
|
+
{ anchor: 20, x: 0, pointerLeft: 14 },
|
|
165
|
+
{ anchor: 150, x: 46, pointerLeft: 98 },
|
|
166
|
+
{ anchor: 280, x: 92, pointerLeft: 182 },
|
|
167
|
+
] )(
|
|
168
|
+
'keeps the below-axis pointer at x=$anchor after clamping',
|
|
169
|
+
( { anchor, x, pointerLeft } ) => {
|
|
170
|
+
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
171
|
+
this: Element
|
|
172
|
+
) {
|
|
173
|
+
return this.classList.contains( 'visx-tooltip' )
|
|
174
|
+
? rect( 0, 0, 208, 36 )
|
|
175
|
+
: rect( 0, 0, 300, 100 );
|
|
176
|
+
} );
|
|
177
|
+
|
|
178
|
+
render(
|
|
179
|
+
<div style={ { overflow: 'hidden' } }>
|
|
180
|
+
<div style={ { position: 'relative' } }>
|
|
181
|
+
<BoundedTooltip placement="below-axis" left={ anchor } top={ 100 } data-testid="box">
|
|
182
|
+
content
|
|
183
|
+
</BoundedTooltip>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect( screen.getByTestId( 'box' ) ).toHaveStyle( {
|
|
189
|
+
transform: `translate(${ x }px, 106px)`,
|
|
190
|
+
} );
|
|
191
|
+
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
|
|
192
|
+
left: `${ pointerLeft }px`,
|
|
193
|
+
top: '-6px',
|
|
194
|
+
width: '12px',
|
|
195
|
+
height: '6px',
|
|
196
|
+
} );
|
|
197
|
+
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveAttribute(
|
|
198
|
+
'aria-hidden',
|
|
199
|
+
'true'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
108
204
|
test( 'falls back to the viewport when nothing clips', () => {
|
|
109
205
|
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
110
206
|
this: Element
|
|
@@ -131,6 +131,54 @@ describe( 'XyChartTooltip', () => {
|
|
|
131
131
|
);
|
|
132
132
|
} );
|
|
133
133
|
|
|
134
|
+
test( 'anchors below the x-axis label band without moving datum glyphs or crosshairs', async () => {
|
|
135
|
+
const originalRect = Element.prototype.getBoundingClientRect;
|
|
136
|
+
const rectSpy = jest
|
|
137
|
+
.spyOn( Element.prototype, 'getBoundingClientRect' )
|
|
138
|
+
.mockImplementation( function ( this: Element ) {
|
|
139
|
+
const rect = originalRect.call( this );
|
|
140
|
+
return this.classList.contains( 'visx-tooltip' )
|
|
141
|
+
? { ...rect, width: 80, height: 30, right: rect.left + 80, bottom: rect.top + 30 }
|
|
142
|
+
: rect;
|
|
143
|
+
} );
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
renderChart(
|
|
147
|
+
{
|
|
148
|
+
tooltipPlacement: 'below-axis',
|
|
149
|
+
snapTooltipToDatumY: true,
|
|
150
|
+
showDatumGlyph: true,
|
|
151
|
+
showVerticalCrosshair: true,
|
|
152
|
+
showHorizontalCrosshair: true,
|
|
153
|
+
},
|
|
154
|
+
undefined,
|
|
155
|
+
{ top: 10, right: 20, bottom: 30, left: 40 }
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const box = await screen.findByTestId( 'tooltip-box' );
|
|
159
|
+
expect( box ).toHaveStyle( { transform: 'translate(70px, 106px)' } );
|
|
160
|
+
expect( screen.getByTestId( 'wrapper' ) ).toContainElement( box );
|
|
161
|
+
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
|
|
162
|
+
left: '34px',
|
|
163
|
+
top: '-6px',
|
|
164
|
+
} );
|
|
165
|
+
expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
|
|
166
|
+
'transform',
|
|
167
|
+
'translate(110, 40)'
|
|
168
|
+
);
|
|
169
|
+
expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).toHaveAttribute(
|
|
170
|
+
'y2',
|
|
171
|
+
'70'
|
|
172
|
+
);
|
|
173
|
+
expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-horizontal' ) ).toHaveAttribute(
|
|
174
|
+
'y1',
|
|
175
|
+
'40'
|
|
176
|
+
);
|
|
177
|
+
} finally {
|
|
178
|
+
rectSpy.mockRestore();
|
|
179
|
+
}
|
|
180
|
+
} );
|
|
181
|
+
|
|
134
182
|
test( 'draws one glyph per series in the SVG at the datum position', async () => {
|
|
135
183
|
renderChart( { showSeriesGlyphs: true }, BOTH_SERIES );
|
|
136
184
|
|
|
@@ -235,6 +283,38 @@ describe( 'XyChartTooltip', () => {
|
|
|
235
283
|
expect( horizontal ).toHaveAttribute( 'x2', '180' );
|
|
236
284
|
} );
|
|
237
285
|
|
|
286
|
+
test( 'applies crosshair paint without allowing geometry overrides', async () => {
|
|
287
|
+
const consumerStyle = {
|
|
288
|
+
stroke: 'purple',
|
|
289
|
+
strokeWidth: 8,
|
|
290
|
+
className: 'custom-crosshair',
|
|
291
|
+
x1: 999,
|
|
292
|
+
transform: 'translate(100 0)',
|
|
293
|
+
style: { strokeOpacity: 0.25, transform: 'translateX(100px)' },
|
|
294
|
+
};
|
|
295
|
+
renderChart( {
|
|
296
|
+
snapTooltipToDatumX: true,
|
|
297
|
+
snapTooltipToDatumY: true,
|
|
298
|
+
showVerticalCrosshair: true,
|
|
299
|
+
showHorizontalCrosshair: true,
|
|
300
|
+
verticalCrosshairStyle: consumerStyle,
|
|
301
|
+
horizontalCrosshairStyle: consumerStyle,
|
|
302
|
+
} );
|
|
303
|
+
|
|
304
|
+
for ( const direction of [ 'vertical', 'horizontal' ] ) {
|
|
305
|
+
const guide = await screen.findByTestId( `xy-chart-tooltip-crosshair-${ direction }` );
|
|
306
|
+
expect( guide ).toHaveAttribute( 'stroke', 'purple' );
|
|
307
|
+
expect( guide ).toHaveAttribute( 'stroke-width', '8' );
|
|
308
|
+
expect( guide ).toHaveClass( 'custom-crosshair' );
|
|
309
|
+
expect( guide ).toHaveStyle( { 'stroke-opacity': '0.25', transform: 'none' } );
|
|
310
|
+
expect( guide ).not.toHaveAttribute( 'transform' );
|
|
311
|
+
expect( guide ).toHaveAttribute( 'x1', direction === 'vertical' ? '100' : '0' );
|
|
312
|
+
expect( guide ).toHaveAttribute( 'x2', direction === 'vertical' ? '100' : '200' );
|
|
313
|
+
expect( guide ).toHaveAttribute( 'y1', direction === 'vertical' ? '0' : '50' );
|
|
314
|
+
expect( guide ).toHaveAttribute( 'y2', direction === 'vertical' ? '100' : '50' );
|
|
315
|
+
}
|
|
316
|
+
} );
|
|
317
|
+
|
|
238
318
|
test( 'stacks the box above the chart overlays, with zIndex as the override', async () => {
|
|
239
319
|
const { unmount } = renderChart();
|
|
240
320
|
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '3' } );
|
|
@@ -244,6 +324,26 @@ describe( 'XyChartTooltip', () => {
|
|
|
244
324
|
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '9' } );
|
|
245
325
|
} );
|
|
246
326
|
|
|
327
|
+
test.each( [ 'auto', 'below-axis' ] as const )(
|
|
328
|
+
'preserves %s background and stacking with a partial style override',
|
|
329
|
+
async tooltipPlacement => {
|
|
330
|
+
const { unmount } = renderChart( { tooltipPlacement } );
|
|
331
|
+
const defaultBox = await screen.findByTestId( 'tooltip-box' );
|
|
332
|
+
const background = defaultBox.style.backgroundColor;
|
|
333
|
+
expect( background ).not.toBe( '' );
|
|
334
|
+
expect( background ).not.toBe( 'transparent' );
|
|
335
|
+
unmount();
|
|
336
|
+
|
|
337
|
+
renderChart( { tooltipPlacement, style: { color: 'red' } } );
|
|
338
|
+
|
|
339
|
+
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
|
|
340
|
+
backgroundColor: background,
|
|
341
|
+
zIndex: '3',
|
|
342
|
+
color: 'rgb(255, 0, 0)',
|
|
343
|
+
} );
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
|
|
247
347
|
test( 'accepts the portal-era options without passing them to the box', async () => {
|
|
248
348
|
renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
|
|
249
349
|
|
|
@@ -3,11 +3,36 @@ import { DataContext, TooltipContext } from '@visx/xychart';
|
|
|
3
3
|
import { useCallback, useContext, useState } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import { BoundedTooltip } from './private/bounded-tooltip';
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
CrosshairStyle,
|
|
8
|
+
RenderTooltipGlyphProps,
|
|
9
|
+
XyChartTooltipProps,
|
|
10
|
+
} from '../../visx/types';
|
|
7
11
|
import type { TooltipContextType } from '@visx/xychart';
|
|
8
12
|
import type { CSSProperties, ReactNode } from 'react';
|
|
9
13
|
|
|
10
14
|
const CROSSHAIR_STROKE_WIDTH = 1.5;
|
|
15
|
+
const CROSSHAIR_PAINT_PROPERTIES = new Set( [
|
|
16
|
+
'stroke',
|
|
17
|
+
'strokeWidth',
|
|
18
|
+
'strokeOpacity',
|
|
19
|
+
'strokeDasharray',
|
|
20
|
+
'strokeLinecap',
|
|
21
|
+
'opacity',
|
|
22
|
+
] );
|
|
23
|
+
|
|
24
|
+
const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
|
|
25
|
+
const pickPaint = ( value: object ) =>
|
|
26
|
+
Object.fromEntries(
|
|
27
|
+
Object.entries( value ).filter( ( [ key ] ) => CROSSHAIR_PAINT_PROPERTIES.has( key ) )
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...pickPaint( props ),
|
|
32
|
+
...( props.className === undefined ? {} : { className: props.className } ),
|
|
33
|
+
...( props.style === undefined ? {} : { style: pickPaint( props.style ) } ),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
11
36
|
const DEFAULT_GLYPH_RADIUS = 4;
|
|
12
37
|
const FALLBACK_COLOR = '#222';
|
|
13
38
|
// Above the chart's own overlays (the zoom reset button sits at 2). The chart
|
|
@@ -83,7 +108,9 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
83
108
|
verticalCrosshairStyle,
|
|
84
109
|
horizontalCrosshairStyle,
|
|
85
110
|
detectBounds = true,
|
|
111
|
+
tooltipPlacement = 'auto',
|
|
86
112
|
zIndex = DEFAULT_TOOLTIP_Z_INDEX,
|
|
113
|
+
style,
|
|
87
114
|
...rest
|
|
88
115
|
}: XyChartTooltipContentProps< Datum > ) => {
|
|
89
116
|
const tooltipProps = Object.fromEntries(
|
|
@@ -125,9 +152,12 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
125
152
|
const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
|
|
126
153
|
let { tooltipLeft, tooltipTop } = tooltipContext;
|
|
127
154
|
|
|
128
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
nearestDatum &&
|
|
157
|
+
( snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === 'below-axis' )
|
|
158
|
+
) {
|
|
129
159
|
const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
|
|
130
|
-
if ( snapTooltipToDatumX && isValidNumber( left ) ) {
|
|
160
|
+
if ( ( snapTooltipToDatumX || tooltipPlacement === 'below-axis' ) && isValidNumber( left ) ) {
|
|
131
161
|
tooltipLeft = left;
|
|
132
162
|
}
|
|
133
163
|
if ( snapTooltipToDatumY && isValidNumber( top ) ) {
|
|
@@ -183,15 +213,17 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
183
213
|
const marginTop = margin?.top ?? 0;
|
|
184
214
|
const marginLeft = margin?.left ?? 0;
|
|
185
215
|
|
|
186
|
-
const TooltipComponent =
|
|
216
|
+
const TooltipComponent =
|
|
217
|
+
detectBounds || tooltipPlacement === 'below-axis' ? BoundedTooltip : Tooltip;
|
|
187
218
|
const boxStyle: CSSProperties = {
|
|
188
219
|
...defaultStyles,
|
|
189
220
|
zIndex,
|
|
190
|
-
|
|
221
|
+
backgroundColor: theme?.backgroundColor ?? 'white',
|
|
191
222
|
boxShadow: `0 1px 2px ${
|
|
192
223
|
theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
|
|
193
224
|
}`,
|
|
194
225
|
...theme?.htmlLabel,
|
|
226
|
+
...style,
|
|
195
227
|
};
|
|
196
228
|
|
|
197
229
|
return (
|
|
@@ -201,26 +233,26 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
201
233
|
<line
|
|
202
234
|
className="visx-crosshair visx-crosshair-vertical"
|
|
203
235
|
data-testid="xy-chart-tooltip-crosshair-vertical"
|
|
236
|
+
stroke={ crosshairStroke }
|
|
237
|
+
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
238
|
+
{ ...crosshairPaintProps( verticalCrosshairStyle ) }
|
|
204
239
|
x1={ tooltipLeft }
|
|
205
240
|
x2={ tooltipLeft }
|
|
206
241
|
y1={ marginTop }
|
|
207
242
|
y2={ marginTop + innerHeight }
|
|
208
|
-
stroke={ crosshairStroke }
|
|
209
|
-
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
210
|
-
{ ...verticalCrosshairStyle }
|
|
211
243
|
/>
|
|
212
244
|
) }
|
|
213
245
|
{ showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
|
|
214
246
|
<line
|
|
215
247
|
className="visx-crosshair visx-crosshair-horizontal"
|
|
216
248
|
data-testid="xy-chart-tooltip-crosshair-horizontal"
|
|
249
|
+
stroke={ crosshairStroke }
|
|
250
|
+
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
251
|
+
{ ...crosshairPaintProps( horizontalCrosshairStyle ) }
|
|
217
252
|
x1={ marginLeft }
|
|
218
253
|
x2={ marginLeft + innerWidth }
|
|
219
254
|
y1={ tooltipTop }
|
|
220
255
|
y2={ tooltipTop }
|
|
221
|
-
stroke={ crosshairStroke }
|
|
222
|
-
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
223
|
-
{ ...horizontalCrosshairStyle }
|
|
224
256
|
/>
|
|
225
257
|
) }
|
|
226
258
|
{ glyphs }
|
|
@@ -229,10 +261,15 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
229
261
|
createPortal(
|
|
230
262
|
<TooltipComponent
|
|
231
263
|
left={ tooltipLeft }
|
|
232
|
-
top={
|
|
264
|
+
top={
|
|
265
|
+
tooltipPlacement === 'below-axis'
|
|
266
|
+
? marginTop + innerHeight + ( margin?.bottom ?? 0 )
|
|
267
|
+
: tooltipTop
|
|
268
|
+
}
|
|
233
269
|
style={ boxStyle }
|
|
234
270
|
applyPositionStyle
|
|
235
271
|
{ ...tooltipProps }
|
|
272
|
+
{ ...( tooltipPlacement === 'below-axis' && { placement: tooltipPlacement } ) }
|
|
236
273
|
>
|
|
237
274
|
{ tooltipContent }
|
|
238
275
|
</TooltipComponent>,
|
|
@@ -259,7 +296,8 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
259
296
|
* isolation keeps the box's `zIndex` from competing with page chrome. The box
|
|
260
297
|
* flips and clamps to stay inside the nearest ancestor that clips its overflow
|
|
261
298
|
* (or the viewport), so it may extend past the chart wrapper but is never cut
|
|
262
|
-
* off unless that ancestor is smaller than the box itself.
|
|
299
|
+
* off unless that ancestor is smaller than the box itself. Below-axis placement
|
|
300
|
+
* pins it beneath the x-axis and applies horizontal bounds only.
|
|
263
301
|
*
|
|
264
302
|
* @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
|
|
265
303
|
* @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../../providers';
|
|
3
|
+
import { useChartDataTransform } from '../use-chart-data-transform';
|
|
4
|
+
import type { SeriesData } from '../../types';
|
|
5
|
+
|
|
6
|
+
// One reference, reused across both renders, so only the zone can invalidate the memo.
|
|
7
|
+
const data: SeriesData[] = [
|
|
8
|
+
{ label: 'Series A', data: [ { dateString: '2026-08-02', value: 1 } ] },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const Probe = () => {
|
|
12
|
+
const [ series ] = useChartDataTransform( data );
|
|
13
|
+
const point = series.data[ 0 ] as { date?: Date };
|
|
14
|
+
|
|
15
|
+
return <span data-testid="instant">{ point.date?.toISOString() }</span>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe( 'useChartDataTransform', () => {
|
|
19
|
+
it( 're-dates its points when only the provider time zone changes', () => {
|
|
20
|
+
const { rerender } = render(
|
|
21
|
+
<GlobalChartsProvider timeZone="Asia/Tokyo">
|
|
22
|
+
<Probe />
|
|
23
|
+
</GlobalChartsProvider>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T15:00:00.000Z' );
|
|
27
|
+
|
|
28
|
+
rerender(
|
|
29
|
+
<GlobalChartsProvider timeZone="Pacific/Auckland">
|
|
30
|
+
<Probe />
|
|
31
|
+
</GlobalChartsProvider>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T12:00:00.000Z' );
|
|
35
|
+
} );
|
|
36
|
+
} );
|