@automattic/charts 3.2.0 → 4.0.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 +29 -0
- package/TOKENS.md +53 -58
- package/dist/index.cjs +1012 -542
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -29
- package/dist/index.d.cts +151 -179
- package/dist/index.d.ts +151 -179
- package/dist/index.js +1013 -544
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/charts/area-chart/area-chart.tsx +39 -25
- package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
- package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
- package/src/charts/area-chart/types.ts +4 -7
- package/src/charts/bar-chart/bar-chart.tsx +2 -1
- package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
- package/src/charts/geo-chart/geo-chart.module.scss +1 -0
- package/src/charts/geo-chart/geo-chart.tsx +5 -10
- package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
- package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +93 -24
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
- package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
- package/src/charts/line-chart/types.ts +4 -1
- package/src/charts/pie-chart/pie-chart.module.scss +13 -1
- package/src/charts/pie-chart/pie-chart.tsx +30 -40
- package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
- package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/axis-formatting.test.tsx +85 -0
- package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
- package/src/charts/private/test/time-axis-options.test.ts +267 -0
- package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
- package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
- package/src/charts/private/test/time-axis.test.ts +9 -69
- package/src/charts/private/time-axis-options.ts +106 -0
- package/src/charts/private/time-axis.ts +318 -136
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
- package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +13 -15
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
- package/src/hooks/use-xychart-theme.ts +25 -9
- package/src/index.ts +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +37 -38
- package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
- package/src/providers/chart-context/index.ts +1 -0
- package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
- package/src/providers/chart-context/private/series-palette.ts +3 -13
- package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
- package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
- package/src/providers/chart-context/themes.ts +7 -52
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +39 -62
- package/src/styles/test/chart-scope.test.ts +19 -47
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/types.ts +79 -166
- package/src/utils/bucket-info.ts +87 -0
- package/src/utils/color-utils.ts +0 -27
- package/src/utils/date-formatting.ts +174 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/test/bucket-info.test.ts +185 -0
- package/src/utils/test/color-utils.test.ts +1 -48
- package/src/utils/test/date-formatting.test.ts +89 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/visx/types.ts +29 -2
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
- package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
- package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
- package/src/styles/test/wpds-fallbacks.test.ts +0 -131
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
2
|
import { Pie } from '@visx/shape';
|
|
3
3
|
import { Text } from '@visx/text';
|
|
4
|
-
import { useTooltip
|
|
4
|
+
import { useTooltip } from '@visx/tooltip';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
|
-
import { useCallback, useContext, useMemo } from 'react';
|
|
6
|
+
import { useCallback, useContext, useMemo, useRef } from 'react';
|
|
7
7
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
8
8
|
import { BaseTooltip } from '../../components/tooltip';
|
|
9
|
+
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
9
10
|
import {
|
|
10
11
|
useDataWithPercentages,
|
|
11
12
|
useLegendVisibilityData,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
useGlobalChartsContext,
|
|
19
20
|
GlobalChartsContext,
|
|
20
21
|
} from '../../providers';
|
|
21
|
-
import {
|
|
22
|
+
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
22
23
|
import { attachSubComponents } from '../../utils';
|
|
23
24
|
import { Center } from '../private/center';
|
|
24
25
|
import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
|
|
@@ -189,42 +190,25 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
|
|
|
189
190
|
const chartId = useChartId( providedChartId );
|
|
190
191
|
const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
|
|
191
192
|
useTooltip< DataPointPercentageCalculated >();
|
|
193
|
+
const standaloneScopeClass = useStandaloneScopeClass();
|
|
192
194
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
const { containerRef, TooltipInPortal, containerBounds } = useTooltipInPortal( {
|
|
196
|
-
detectBounds: true,
|
|
197
|
-
scroll: true,
|
|
198
|
-
debounce: 0,
|
|
199
|
-
} );
|
|
195
|
+
// The tooltip renders inside this element, so pointer coordinates are taken relative to it.
|
|
196
|
+
const containerRef = useRef< HTMLDivElement >( null );
|
|
200
197
|
|
|
201
198
|
const handleMouseMove = useCallback(
|
|
202
199
|
( event: MouseEvent< SVGElement >, arc: ArcData ) => {
|
|
203
|
-
|
|
204
|
-
if (
|
|
200
|
+
const bounds = containerRef.current?.getBoundingClientRect();
|
|
201
|
+
if ( ! bounds ) {
|
|
205
202
|
return;
|
|
206
203
|
}
|
|
207
204
|
|
|
208
|
-
// Use clientX/Y and subtract containerBounds to cancel out any stale offset.
|
|
209
|
-
// TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX
|
|
210
|
-
// By passing (clientX - containerBounds.left), we get:
|
|
211
|
-
// (clientX - containerBounds.left) + containerBounds.left + scrollX = clientX + scrollX
|
|
212
|
-
// This gives correct page coordinates regardless of stale bounds.
|
|
213
205
|
showTooltip( {
|
|
214
206
|
tooltipData: arc.data,
|
|
215
|
-
tooltipLeft: event.clientX -
|
|
216
|
-
tooltipTop: event.clientY -
|
|
207
|
+
tooltipLeft: event.clientX - bounds.left + tooltipOffsetX,
|
|
208
|
+
tooltipTop: event.clientY - bounds.top + tooltipOffsetY,
|
|
217
209
|
} );
|
|
218
210
|
},
|
|
219
|
-
[
|
|
220
|
-
containerBounds.width,
|
|
221
|
-
containerBounds.height,
|
|
222
|
-
containerBounds.left,
|
|
223
|
-
containerBounds.top,
|
|
224
|
-
showTooltip,
|
|
225
|
-
tooltipOffsetX,
|
|
226
|
-
tooltipOffsetY,
|
|
227
|
-
]
|
|
211
|
+
[ showTooltip, tooltipOffsetX, tooltipOffsetY ]
|
|
228
212
|
);
|
|
229
213
|
|
|
230
214
|
const handleMouseLeave = useCallback( () => {
|
|
@@ -311,7 +295,9 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
|
|
|
311
295
|
const errorHeight = errorWidth / 2;
|
|
312
296
|
|
|
313
297
|
return (
|
|
314
|
-
<div
|
|
298
|
+
<div
|
|
299
|
+
className={ clsx( 'pie-semi-circle-chart', styles[ 'pie-semi-circle-chart' ], className ) }
|
|
300
|
+
>
|
|
315
301
|
<svg width={ errorWidth } height={ errorHeight } data-testid="pie-chart-svg">
|
|
316
302
|
<text x="50%" y="50%" textAnchor="middle" className={ styles.error }>
|
|
317
303
|
{ message }
|
|
@@ -372,13 +358,6 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
|
|
|
372
358
|
data-testid="pie-chart-container"
|
|
373
359
|
trailingContent={
|
|
374
360
|
<>
|
|
375
|
-
{ withTooltips && tooltipOpen && tooltipData && (
|
|
376
|
-
<TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
|
|
377
|
-
<div className={ CHART_SCOPE_CLASS } role="tooltip">
|
|
378
|
-
{ renderTooltip( { tooltipData } ) }
|
|
379
|
-
</div>
|
|
380
|
-
</TooltipInPortal>
|
|
381
|
-
) }
|
|
382
361
|
{ htmlChildren }
|
|
383
362
|
{ otherChildren }
|
|
384
363
|
</>
|
|
@@ -398,7 +377,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
|
|
|
398
377
|
const innerRadius = radius * ( 1 - thickness );
|
|
399
378
|
|
|
400
379
|
return (
|
|
401
|
-
<Center ref={ containerRef }>
|
|
380
|
+
<Center ref={ containerRef } className={ styles[ 'pie-semi-circle-chart__plot' ] }>
|
|
402
381
|
<svg
|
|
403
382
|
width={ width }
|
|
404
383
|
height={ height }
|
|
@@ -484,6 +463,13 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
|
|
|
484
463
|
) }
|
|
485
464
|
</Group>
|
|
486
465
|
</svg>
|
|
466
|
+
{ withTooltips && tooltipOpen && tooltipData && (
|
|
467
|
+
<BoundedTooltip top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
|
|
468
|
+
<div className={ standaloneScopeClass } role="tooltip">
|
|
469
|
+
{ renderTooltip( { tooltipData } ) }
|
|
470
|
+
</div>
|
|
471
|
+
</BoundedTooltip>
|
|
472
|
+
) }
|
|
487
473
|
</Center>
|
|
488
474
|
);
|
|
489
475
|
} }
|
|
@@ -362,3 +362,53 @@ describe( 'PieSemiCircleChart', () => {
|
|
|
362
362
|
} );
|
|
363
363
|
} );
|
|
364
364
|
} );
|
|
365
|
+
|
|
366
|
+
// Chart container at (100, 50); the tooltip box measures 120x40. Everything
|
|
367
|
+
// else the charts measure (wrapper, clipping lookups) reports the container.
|
|
368
|
+
const mockRects = () =>
|
|
369
|
+
jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
|
|
370
|
+
this: Element
|
|
371
|
+
) {
|
|
372
|
+
const box = this.classList.contains( 'visx-tooltip' );
|
|
373
|
+
return {
|
|
374
|
+
left: box ? 0 : 100,
|
|
375
|
+
top: box ? 0 : 50,
|
|
376
|
+
width: box ? 120 : 400,
|
|
377
|
+
height: box ? 40 : 300,
|
|
378
|
+
right: box ? 120 : 500,
|
|
379
|
+
bottom: box ? 40 : 350,
|
|
380
|
+
x: box ? 0 : 100,
|
|
381
|
+
y: box ? 0 : 50,
|
|
382
|
+
toJSON: () => ( {} ),
|
|
383
|
+
} as DOMRect;
|
|
384
|
+
} );
|
|
385
|
+
|
|
386
|
+
describe( 'PieSemiCircleChart tooltip position', () => {
|
|
387
|
+
afterEach( () => {
|
|
388
|
+
jest.restoreAllMocks();
|
|
389
|
+
} );
|
|
390
|
+
|
|
391
|
+
it( 'places the box relative to the chart container, at the pointer plus the offsets', async () => {
|
|
392
|
+
mockRects();
|
|
393
|
+
const user = userEvent.setup();
|
|
394
|
+
renderPieChart( {
|
|
395
|
+
data: [
|
|
396
|
+
{ label: 'A', value: 60, valueDisplay: '60' },
|
|
397
|
+
{ label: 'B', value: 40, valueDisplay: '40' },
|
|
398
|
+
],
|
|
399
|
+
withTooltips: true,
|
|
400
|
+
width: 400,
|
|
401
|
+
} );
|
|
402
|
+
|
|
403
|
+
await user.pointer( {
|
|
404
|
+
target: screen.getAllByTestId( 'pie-segment' )[ 0 ],
|
|
405
|
+
coords: { clientX: 150, clientY: 120 },
|
|
406
|
+
} );
|
|
407
|
+
|
|
408
|
+
// (150, 120) is (50, 70) inside the container; the default -15px vertical
|
|
409
|
+
// offset makes that (50, 55), and the box adds 10px each way.
|
|
410
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
|
|
411
|
+
transform: 'translate(60px, 65px)',
|
|
412
|
+
} );
|
|
413
|
+
} );
|
|
414
|
+
} );
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// The svg displaying inline includes a few px for descenders, which
|
|
9
9
|
// confuses some browser environments (e.g. headless Chrome) into
|
|
10
10
|
// constantly growing the container. Setting it to display as block avoids
|
|
11
|
-
// that.
|
|
12
|
-
|
|
11
|
+
// that. The tooltip renders in here too, and an inline icon in its rows
|
|
12
|
+
// must stay inline.
|
|
13
|
+
svg:not(:global(.visx-tooltip) svg) {
|
|
13
14
|
display: block;
|
|
14
15
|
}
|
|
15
16
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
6
|
+
import AreaChart from '../../area-chart/area-chart';
|
|
7
|
+
import BarChart from '../../bar-chart/bar-chart';
|
|
8
|
+
import LineChart from '../../line-chart/line-chart';
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
const mockRefCallback = jest.fn();
|
|
12
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
13
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
14
|
+
} ) );
|
|
15
|
+
|
|
16
|
+
// 15:30 UTC is Aug 2 in the runtime zone and Aug 3 in Tokyo.
|
|
17
|
+
const START = Date.parse( '2026-08-02T15:30:00Z' );
|
|
18
|
+
|
|
19
|
+
const data = [
|
|
20
|
+
{
|
|
21
|
+
label: 'Series A',
|
|
22
|
+
data: Array.from( { length: 14 }, ( _, i ) => ( {
|
|
23
|
+
date: new Date( START + i * 24 * 60 * 60 * 1000 ),
|
|
24
|
+
value: 10 + i,
|
|
25
|
+
} ) ),
|
|
26
|
+
options: {},
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const size = { width: 500, height: 300 };
|
|
31
|
+
|
|
32
|
+
// Every axis labels the data's own buckets, so the fallback names the first
|
|
33
|
+
// point's runtime-zone day rather than a round-numbered instant near it.
|
|
34
|
+
const RUNTIME_TICK = 'Aug 2';
|
|
35
|
+
|
|
36
|
+
const charts: [ string, ReactNode ][] = [
|
|
37
|
+
[ 'AreaChart', <AreaChart key="a" { ...size } data={ data } /> ],
|
|
38
|
+
[ 'BarChart', <BarChart key="b" { ...size } data={ data } /> ],
|
|
39
|
+
[ 'LineChart', <LineChart key="l" { ...size } data={ data } withGradientFill={ false } /> ],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
describe.each( charts )( '%s x axis', ( _name, chart ) => {
|
|
43
|
+
const renderIn = ( props: { locale?: string; timeZone?: string } ) =>
|
|
44
|
+
render( <GlobalChartsProvider { ...props }>{ chart }</GlobalChartsProvider> );
|
|
45
|
+
|
|
46
|
+
it( "labels ticks in the provider's locale", () => {
|
|
47
|
+
renderIn( { locale: 'de-DE', timeZone: 'Asia/Tokyo' } );
|
|
48
|
+
|
|
49
|
+
expect( screen.getByText( '3. Aug.' ) ).toBeInTheDocument();
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
it( 'falls back to the runtime locale and zone when the provider supplies none', () => {
|
|
53
|
+
renderIn( {} );
|
|
54
|
+
|
|
55
|
+
expect( screen.getByText( RUNTIME_TICK ) ).toBeInTheDocument();
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'relabels when the provider swaps time zone', () => {
|
|
59
|
+
const { rerender } = render(
|
|
60
|
+
<GlobalChartsProvider timeZone="Asia/Tokyo">{ chart }</GlobalChartsProvider>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect( screen.getByText( 'Aug 3' ) ).toBeInTheDocument();
|
|
64
|
+
|
|
65
|
+
rerender( <GlobalChartsProvider timeZone="Pacific/Honolulu">{ chart }</GlobalChartsProvider> );
|
|
66
|
+
|
|
67
|
+
expect( screen.getByText( 'Aug 2' ) ).toBeInTheDocument();
|
|
68
|
+
} );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
// The values WordPress hands out: `get_locale()` is `en_US`, and `timezone_string`
|
|
72
|
+
// is empty on a site set to a raw UTC offset. Unsanitized, `Intl` throws during
|
|
73
|
+
// render and React unmounts the host tree rather than one chart.
|
|
74
|
+
describe( 'a host locale and zone Intl cannot use', () => {
|
|
75
|
+
it( 'still renders, in the runtime zone, and warns', () => {
|
|
76
|
+
render(
|
|
77
|
+
<GlobalChartsProvider locale="en_US" timeZone="">
|
|
78
|
+
<BarChart { ...size } data={ data } />
|
|
79
|
+
</GlobalChartsProvider>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect( screen.getByText( 'Aug 2' ) ).toBeInTheDocument();
|
|
83
|
+
expect( console ).toHaveWarned();
|
|
84
|
+
} );
|
|
85
|
+
} );
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { getBandTickValues, getFormatter } from '../time-axis';
|
|
5
|
+
import type { useChartDataTransform } from '../../../hooks';
|
|
6
|
+
|
|
7
|
+
type SortedData = ReturnType< typeof useChartDataTransform >;
|
|
8
|
+
|
|
9
|
+
const toSeries = ( dates: Date[] ): SortedData =>
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
label: 'series',
|
|
13
|
+
data: dates.map( date => ( { date, value: 1 } ) ),
|
|
14
|
+
},
|
|
15
|
+
] as unknown as SortedData;
|
|
16
|
+
|
|
17
|
+
// Tokyo has no DST, so its midnight is a fixed nine hours before UTC midnight.
|
|
18
|
+
const tokyoMidnight = ( year: number, month: number, day = 1 ) =>
|
|
19
|
+
new Date( Date.UTC( year, month, day ) - 9 * 60 * 60 * 1000 );
|
|
20
|
+
|
|
21
|
+
const every = ( start: Date, count: number, hours: number ): Date[] =>
|
|
22
|
+
Array.from(
|
|
23
|
+
{ length: count },
|
|
24
|
+
( _, i ) => new Date( start.getTime() + i * hours * 60 * 60 * 1000 )
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// A formatter that ignores its context reads Los Angeles here, and the instants
|
|
28
|
+
// below land on a different calendar day there than in Tokyo. The runtime locale
|
|
29
|
+
// stays en-US, so a German label can only come from the supplied context.
|
|
30
|
+
|
|
31
|
+
describe( 'getFormatter with a host formatting context', () => {
|
|
32
|
+
const daily = toSeries( every( new Date( '2026-08-02T12:00:00Z' ), 14, 24 ) );
|
|
33
|
+
|
|
34
|
+
it( 'labels date ticks in the supplied locale', () => {
|
|
35
|
+
const format = getFormatter( daily, undefined, { locale: 'de-DE' } );
|
|
36
|
+
|
|
37
|
+
expect( format( Date.parse( '2026-08-02T12:00:00Z' ) ) ).toBe( '2. Aug.' );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
it( "labels date ticks on the supplied zone's calendar day", () => {
|
|
41
|
+
const format = getFormatter( daily, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
42
|
+
|
|
43
|
+
// 00:30 on Aug 3 in Tokyo, still Aug 2 in the runtime zone.
|
|
44
|
+
expect( format( Date.parse( '2026-08-02T15:30:00Z' ) ) ).toBe( 'Aug 3' );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
// Aug 3 in UTC and Aug 2 in the pinned zone, so this fails without the
|
|
48
|
+
// environment rather than passing under the script's own `TZ=UTC`.
|
|
49
|
+
it( 'falls back to the runtime locale and zone when given no context', () => {
|
|
50
|
+
expect( getFormatter( daily )( Date.parse( '2026-08-03T03:00:00Z' ) ) ).toBe( 'Aug 2' );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( "anchors the date label to the supplied zone's first hour of the day", () => {
|
|
54
|
+
const hourly = toSeries( every( tokyoMidnight( 2026, 7, 1 ), 72, 1 ) );
|
|
55
|
+
const format = getFormatter( hourly, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
56
|
+
|
|
57
|
+
// Midnight in Tokyo, 08:00 the previous day in the runtime zone.
|
|
58
|
+
expect( format( tokyoMidnight( 2026, 7, 2 ).getTime() ) ).toBe( 'Aug 2' );
|
|
59
|
+
// Midnight in the runtime zone, 16:00 in Tokyo.
|
|
60
|
+
expect( format( Date.parse( '2026-08-02T07:00:00Z' ) ) ).toBe( '4 PM' );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
// The boundary test runs before the label formatter, so its own guard is the
|
|
64
|
+
// only thing standing between a bad point and a throw out of `formatToParts`.
|
|
65
|
+
it( 'renders past an invalid date on a boundary format', () => {
|
|
66
|
+
const hourly = toSeries( every( tokyoMidnight( 2026, 7, 1 ), 72, 1 ) );
|
|
67
|
+
const format = getFormatter( hourly, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
68
|
+
|
|
69
|
+
expect( format( NaN ) ).toBe( 'Invalid Date' );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
it( 'anchors the year label to January in the supplied zone', () => {
|
|
73
|
+
const monthly = toSeries(
|
|
74
|
+
Array.from( { length: 12 }, ( _, i ) => tokyoMidnight( 2025, 7 + i ) )
|
|
75
|
+
);
|
|
76
|
+
const format = getFormatter( monthly, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
77
|
+
|
|
78
|
+
// January 1 in Tokyo, December 31 in the runtime zone.
|
|
79
|
+
expect( format( tokyoMidnight( 2026, 0 ).getTime() ) ).toBe( '2026' );
|
|
80
|
+
expect( format( tokyoMidnight( 2026, 1 ).getTime() ) ).toBe( 'Feb' );
|
|
81
|
+
} );
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
describe( 'getBandTickValues with a host formatting context', () => {
|
|
85
|
+
// The shape the Stats payload arrives in: buckets aligned to UTC, which a
|
|
86
|
+
// half-hour zone reads at :30, so none of them is ever an exact midnight.
|
|
87
|
+
it( 'dates the day on a half-hour zone, where no bucket reads midnight', () => {
|
|
88
|
+
const domain = every( new Date( '2026-08-01T00:00:00Z' ), 72, 1 );
|
|
89
|
+
const format = getFormatter( toSeries( domain ), undefined, { timeZone: 'Asia/Kolkata' } );
|
|
90
|
+
|
|
91
|
+
const labels = getBandTickValues( domain, format, 4 ).map( date => format( date.getTime() ) );
|
|
92
|
+
|
|
93
|
+
expect( labels ).toEqual( [ 'Aug 2', 'Aug 3', 'Aug 4' ] );
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
it( "puts the year tick on the supplied zone's January bucket", () => {
|
|
97
|
+
const domain = Array.from( { length: 12 }, ( _, i ) => tokyoMidnight( 2025, 7 + i ) );
|
|
98
|
+
const format = getFormatter( toSeries( domain ), undefined, {
|
|
99
|
+
locale: 'de-DE',
|
|
100
|
+
timeZone: 'Asia/Tokyo',
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
const values = getBandTickValues( domain, format, 4 );
|
|
104
|
+
|
|
105
|
+
expect( values.find( date => format( date.getTime() ) === '2026' ) ).toEqual(
|
|
106
|
+
tokyoMidnight( 2026, 0 )
|
|
107
|
+
);
|
|
108
|
+
} );
|
|
109
|
+
} );
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { dailySeries, hourlySeries } from '../../../test-utils/series-fixtures';
|
|
5
|
+
import { buildTimeAxisOptions } from '../time-axis-options';
|
|
6
|
+
|
|
7
|
+
const TOKYO = { timeZone: 'Asia/Tokyo' };
|
|
8
|
+
|
|
9
|
+
const build = ( overrides: Partial< Parameters< typeof buildTimeAxisOptions >[ 0 ] > = {} ) =>
|
|
10
|
+
buildTimeAxisOptions( {
|
|
11
|
+
dataSorted: dailySeries( '2026-08-01T15:00:00Z', 30 ),
|
|
12
|
+
width: 600,
|
|
13
|
+
axisOptions: { tickResolution: 'day' },
|
|
14
|
+
scaleDomain: undefined,
|
|
15
|
+
zoomDomain: undefined,
|
|
16
|
+
formatting: TOKYO,
|
|
17
|
+
...overrides,
|
|
18
|
+
} );
|
|
19
|
+
|
|
20
|
+
describe( 'buildTimeAxisOptions', () => {
|
|
21
|
+
it( 'selects tick values instead of leaving the count to d3', () => {
|
|
22
|
+
const axis = build();
|
|
23
|
+
|
|
24
|
+
expect( Array.isArray( axis.tickValues ) ).toBe( true );
|
|
25
|
+
expect( ( axis.tickValues as Date[] ).length ).toBeGreaterThan( 1 );
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
it( 'confines tick values to the zoom window', () => {
|
|
29
|
+
const zoomDomain: [ Date, Date ] = [
|
|
30
|
+
new Date( '2026-08-10T15:00:00Z' ),
|
|
31
|
+
new Date( '2026-08-14T15:00:00Z' ),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const ticks = build( { zoomDomain } ).tickValues as Date[];
|
|
35
|
+
|
|
36
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
37
|
+
for ( const tick of ticks ) {
|
|
38
|
+
expect( tick.getTime() ).toBeGreaterThanOrEqual( zoomDomain[ 0 ].getTime() );
|
|
39
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( zoomDomain[ 1 ].getTime() );
|
|
40
|
+
}
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
it( 'prefers the zoom window over a caller scale domain', () => {
|
|
44
|
+
const scaleDomain: [ Date, Date ] = [
|
|
45
|
+
new Date( '2026-08-01T15:00:00Z' ),
|
|
46
|
+
new Date( '2026-08-30T15:00:00Z' ),
|
|
47
|
+
];
|
|
48
|
+
const zoomDomain: [ Date, Date ] = [
|
|
49
|
+
new Date( '2026-08-10T15:00:00Z' ),
|
|
50
|
+
new Date( '2026-08-12T15:00:00Z' ),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const ticks = build( { scaleDomain, zoomDomain } ).tickValues as Date[];
|
|
54
|
+
|
|
55
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
56
|
+
for ( const tick of ticks ) {
|
|
57
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( zoomDomain[ 1 ].getTime() );
|
|
58
|
+
}
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
it( 'confines tick values to a caller scale domain when there is no zoom', () => {
|
|
62
|
+
const scaleDomain: [ Date, Date ] = [
|
|
63
|
+
new Date( '2026-08-05T15:00:00Z' ),
|
|
64
|
+
new Date( '2026-08-08T15:00:00Z' ),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const ticks = build( { scaleDomain } ).tickValues as Date[];
|
|
68
|
+
|
|
69
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
70
|
+
for ( const tick of ticks ) {
|
|
71
|
+
expect( tick.getTime() ).toBeGreaterThanOrEqual( scaleDomain[ 0 ].getTime() );
|
|
72
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( scaleDomain[ 1 ].getTime() );
|
|
73
|
+
}
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'caps the selection at a caller numTicks', () => {
|
|
77
|
+
const axis = build( { axisOptions: { tickResolution: 'day', numTicks: 3 } } );
|
|
78
|
+
|
|
79
|
+
expect( ( axis.tickValues as Date[] ).length ).toBeLessThanOrEqual( 3 );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
it( 'caps the selection by width when no numTicks is given', () => {
|
|
83
|
+
const narrow = build( { width: 180 } ).tickValues as Date[];
|
|
84
|
+
const wide = build( { width: 900 } ).tickValues as Date[];
|
|
85
|
+
|
|
86
|
+
expect( narrow.length ).toBeLessThan( wide.length );
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
it( 'selects nothing when the caller supplies its own tickFormat', () => {
|
|
90
|
+
const axis = build( {
|
|
91
|
+
axisOptions: { tickResolution: 'day', tickFormat: () => 'x' },
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
expect( axis.tickValues ).toBeUndefined();
|
|
95
|
+
expect( axis.tickFormat?.( new Date(), 0, [] ) ).toBe( 'x' );
|
|
96
|
+
expect( typeof axis.numTicks ).toBe( 'number' );
|
|
97
|
+
} );
|
|
98
|
+
|
|
99
|
+
it( 'fits a caller tickFormat to the width rather than leaving visx its fixed default', () => {
|
|
100
|
+
const isoDay = ( value: unknown ) => new Date( Number( value ) ).toISOString().slice( 0, 10 );
|
|
101
|
+
|
|
102
|
+
const narrow = build( {
|
|
103
|
+
width: 180,
|
|
104
|
+
axisOptions: { tickResolution: 'day', tickFormat: isoDay },
|
|
105
|
+
} );
|
|
106
|
+
const wide = build( {
|
|
107
|
+
width: 900,
|
|
108
|
+
axisOptions: { tickResolution: 'day', tickFormat: isoDay },
|
|
109
|
+
} );
|
|
110
|
+
|
|
111
|
+
expect( narrow.numTicks ).toBeLessThan( wide.numTicks as number );
|
|
112
|
+
} );
|
|
113
|
+
|
|
114
|
+
it( "keeps a caller's numTicks on the tickFormat branch", () => {
|
|
115
|
+
const axis = build( {
|
|
116
|
+
axisOptions: { tickResolution: 'day', tickFormat: () => 'x', numTicks: 2 },
|
|
117
|
+
} );
|
|
118
|
+
|
|
119
|
+
expect( axis.numTicks ).toBe( 2 );
|
|
120
|
+
} );
|
|
121
|
+
|
|
122
|
+
it( 'narrows the format to the zoom window on a multi-year dataset', () => {
|
|
123
|
+
const axis = build( {
|
|
124
|
+
dataSorted: dailySeries( '2023-01-01T15:00:00Z', 1100 ),
|
|
125
|
+
zoomDomain: [ new Date( '2024-03-01T15:00:00Z' ), new Date( '2024-08-01T15:00:00Z' ) ],
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
const labels = new Set(
|
|
129
|
+
( axis.tickValues as Date[] ).map( tick => axis.tickFormat?.( tick, 0, [] ) )
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect( labels.size ).toBeGreaterThan( 1 );
|
|
133
|
+
} );
|
|
134
|
+
|
|
135
|
+
it( 'selects ticks only from the series the chart renders', () => {
|
|
136
|
+
const dataSorted = [
|
|
137
|
+
{ ...dailySeries( '2026-06-01T15:00:00Z', 90 )[ 0 ], label: 'hidden' },
|
|
138
|
+
{ ...dailySeries( '2026-08-01T15:00:00Z', 5 )[ 0 ], label: 'shown' },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const ticks = build( {
|
|
142
|
+
dataSorted,
|
|
143
|
+
isSeriesRendered: series => series.label === 'shown',
|
|
144
|
+
} ).tickValues as Date[];
|
|
145
|
+
|
|
146
|
+
const rendered = dataSorted[ 1 ].data.map( point => Number( point.date ) );
|
|
147
|
+
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
148
|
+
for ( const tick of ticks ) {
|
|
149
|
+
expect( rendered ).toContain( tick.getTime() );
|
|
150
|
+
}
|
|
151
|
+
} );
|
|
152
|
+
|
|
153
|
+
it( "lets a caller's own tickValues win", () => {
|
|
154
|
+
const mine = [ new Date( '2026-08-05T15:00:00Z' ) ];
|
|
155
|
+
|
|
156
|
+
const axis = build( { axisOptions: { tickResolution: 'day', tickValues: mine } } );
|
|
157
|
+
|
|
158
|
+
expect( axis.tickValues ).toBe( mine );
|
|
159
|
+
} );
|
|
160
|
+
|
|
161
|
+
it( 'labels ticks in the host time zone', () => {
|
|
162
|
+
// 2026-08-02T15:30Z is Aug 2 in Los Angeles and Aug 3 in Tokyo.
|
|
163
|
+
const axis = build( { formatting: { locale: 'de-DE', timeZone: 'Asia/Tokyo' } } );
|
|
164
|
+
|
|
165
|
+
expect( axis.tickFormat?.( new Date( '2026-08-02T15:30:00Z' ), 0, [] ) ).toBe( '3. Aug.' );
|
|
166
|
+
} );
|
|
167
|
+
|
|
168
|
+
it( 'falls back to the runtime locale and zone when no formatting is supplied', () => {
|
|
169
|
+
const axis = build( { formatting: {} } );
|
|
170
|
+
|
|
171
|
+
expect( axis.tickFormat?.( new Date( '2026-08-02T15:30:00Z' ), 0, [] ) ).toBe( 'Aug 2' );
|
|
172
|
+
} );
|
|
173
|
+
it( 'keeps its own selection when a caller passes tickValues as undefined', () => {
|
|
174
|
+
const axis = build( { axisOptions: { tickResolution: 'day', tickValues: undefined } } );
|
|
175
|
+
|
|
176
|
+
expect( ( axis.tickValues as Date[] ).length ).toBeGreaterThan( 1 );
|
|
177
|
+
} );
|
|
178
|
+
|
|
179
|
+
it( 'keeps its width-fitted count when a caller passes numTicks as undefined', () => {
|
|
180
|
+
const isoDay = ( value: unknown ) => new Date( Number( value ) ).toISOString().slice( 0, 10 );
|
|
181
|
+
|
|
182
|
+
const axis = build( {
|
|
183
|
+
width: 180,
|
|
184
|
+
axisOptions: { tickResolution: 'day', tickFormat: isoDay, numTicks: undefined },
|
|
185
|
+
} );
|
|
186
|
+
|
|
187
|
+
expect( axis.numTicks ).toBeLessThan( 10 );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
it( 'keeps its defaults when a caller passes orientation and display as undefined', () => {
|
|
191
|
+
const axis = build( { axisOptions: { orientation: undefined, display: undefined } } );
|
|
192
|
+
|
|
193
|
+
expect( axis.orientation ).toBe( 'bottom' );
|
|
194
|
+
expect( axis.display ).toBe( true );
|
|
195
|
+
} );
|
|
196
|
+
|
|
197
|
+
it( 'still lets a caller set orientation and display', () => {
|
|
198
|
+
const axis = build( { axisOptions: { orientation: 'top', display: false } } );
|
|
199
|
+
|
|
200
|
+
expect( axis.orientation ).toBe( 'top' );
|
|
201
|
+
expect( axis.display ).toBe( false );
|
|
202
|
+
} );
|
|
203
|
+
|
|
204
|
+
it( 'hands visx a width-fitted count rather than an empty selection', () => {
|
|
205
|
+
const axis = build( {
|
|
206
|
+
zoomDomain: [ new Date( '2027-01-01T00:00:00Z' ), new Date( '2027-01-02T00:00:00Z' ) ],
|
|
207
|
+
} );
|
|
208
|
+
|
|
209
|
+
expect( axis.tickValues ).toBeUndefined();
|
|
210
|
+
expect( typeof axis.numTicks ).toBe( 'number' );
|
|
211
|
+
} );
|
|
212
|
+
|
|
213
|
+
it( 'accepts a numeric scale domain', () => {
|
|
214
|
+
const axis = build( {
|
|
215
|
+
scaleDomain: [ Date.parse( '2026-08-05T15:00:00Z' ), Date.parse( '2026-08-08T15:00:00Z' ) ],
|
|
216
|
+
} );
|
|
217
|
+
|
|
218
|
+
const ticks = axis.tickValues as Date[];
|
|
219
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
220
|
+
for ( const tick of ticks ) {
|
|
221
|
+
expect( tick.getTime() ).toBeGreaterThanOrEqual( Date.parse( '2026-08-05T15:00:00Z' ) );
|
|
222
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( Date.parse( '2026-08-08T15:00:00Z' ) );
|
|
223
|
+
}
|
|
224
|
+
} );
|
|
225
|
+
|
|
226
|
+
it( "keeps a caller's orientation and display", () => {
|
|
227
|
+
const axis = build( {
|
|
228
|
+
axisOptions: { tickResolution: 'day', orientation: 'top', display: false },
|
|
229
|
+
} );
|
|
230
|
+
|
|
231
|
+
expect( axis.orientation ).toBe( 'top' );
|
|
232
|
+
expect( axis.display ).toBe( false );
|
|
233
|
+
} );
|
|
234
|
+
|
|
235
|
+
it( 'sends no numTicks when it selects the tick values itself', () => {
|
|
236
|
+
expect( 'numTicks' in build() ).toBe( false );
|
|
237
|
+
} );
|
|
238
|
+
|
|
239
|
+
// A date the host could not parse arrives as `new Date( NaN )`. Left in the
|
|
240
|
+
// span it turns the domain, the formatter and the tick count into NaN, and
|
|
241
|
+
// visx reads the resulting `numTicks: 0` as "label nothing".
|
|
242
|
+
it( 'still labels the axis when a series ends on an unparsable date', () => {
|
|
243
|
+
const [ series ] = dailySeries( '2026-08-01T15:00:00Z', 4 );
|
|
244
|
+
const withBadDate = [
|
|
245
|
+
{ ...series, data: [ ...series.data, { date: new Date( NaN ), value: 5 } ] },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const options = build( { dataSorted: withBadDate } );
|
|
249
|
+
|
|
250
|
+
expect( options.tickValues?.length ).toBeGreaterThan( 0 );
|
|
251
|
+
expect( options.numTicks ?? 1 ).toBeGreaterThan( 0 );
|
|
252
|
+
} );
|
|
253
|
+
|
|
254
|
+
it( 'reads the resolution from the series it renders, not the hidden ones', () => {
|
|
255
|
+
const dataSorted = [
|
|
256
|
+
{ ...hourlySeries( '2026-08-01T00:00:00Z', 48 )[ 0 ], label: 'hidden' },
|
|
257
|
+
{ ...dailySeries( '2026-08-01T15:00:00Z', 5 )[ 0 ], label: 'shown' },
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const axis = build( { dataSorted, isSeriesRendered: series => series.label === 'shown' } );
|
|
261
|
+
|
|
262
|
+
// A daily label, not one of the hidden series' hours.
|
|
263
|
+
expect( axis.tickFormat?.( new Date( '2026-08-02T15:00:00Z' ), 0, [] ) ).not.toMatch(
|
|
264
|
+
/(AM|PM|Uhr)/
|
|
265
|
+
);
|
|
266
|
+
} );
|
|
267
|
+
} );
|