@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +53 -58
  3. package/dist/index.cjs +1012 -542
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -29
  6. package/dist/index.d.cts +151 -179
  7. package/dist/index.d.ts +151 -179
  8. package/dist/index.js +1013 -544
  9. package/dist/index.js.map +1 -1
  10. package/package.json +6 -6
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  24. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  25. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  26. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  27. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  28. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  29. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  30. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  35. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  36. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  37. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  38. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  39. package/src/charts/line-chart/line-chart.module.scss +10 -18
  40. package/src/charts/line-chart/line-chart.tsx +93 -24
  41. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  42. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  43. package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
  44. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  45. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  46. package/src/charts/line-chart/types.ts +4 -1
  47. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  48. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  49. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  50. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  53. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  54. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  55. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  56. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  57. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  58. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  59. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  60. package/src/charts/private/test/time-axis.test.ts +9 -69
  61. package/src/charts/private/time-axis-options.ts +106 -0
  62. package/src/charts/private/time-axis.ts +318 -136
  63. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  64. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  65. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  66. package/src/charts/sparkline/types.ts +1 -1
  67. package/src/components/legend/private/base-legend.module.scss +9 -5
  68. package/src/components/tooltip/accessible-tooltip.tsx +13 -15
  69. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  70. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  71. package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
  72. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  74. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  75. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  76. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  77. package/src/hooks/use-xychart-theme.ts +25 -9
  78. package/src/index.ts +4 -0
  79. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  80. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  81. package/src/providers/chart-context/index.ts +1 -0
  82. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  83. package/src/providers/chart-context/private/series-palette.ts +3 -13
  84. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  85. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  86. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  87. package/src/providers/chart-context/themes.ts +7 -52
  88. package/src/providers/chart-context/types.ts +3 -0
  89. package/src/styles/chart-scope.scss +39 -62
  90. package/src/styles/test/chart-scope.test.ts +19 -47
  91. package/src/test-utils/series-fixtures.ts +31 -0
  92. package/src/types.ts +79 -166
  93. package/src/utils/bucket-info.ts +87 -0
  94. package/src/utils/color-utils.ts +0 -27
  95. package/src/utils/date-formatting.ts +174 -0
  96. package/src/utils/index.ts +3 -0
  97. package/src/utils/test/bucket-info.test.ts +185 -0
  98. package/src/utils/test/color-utils.test.ts +1 -48
  99. package/src/utils/test/date-formatting.test.ts +89 -0
  100. package/src/utils/test/get-styles.test.ts +10 -7
  101. package/src/utils/test/merge-themes.test.ts +18 -46
  102. package/src/visx/types.ts +29 -2
  103. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  104. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  105. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  106. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  107. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  108. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  109. 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, useTooltipInPortal } from '@visx/tooltip';
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 { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
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
- // Set up portal tooltip for better z-index handling
194
- // We get containerBounds to cancel out stale offsets in the position calculation
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
- // Don't show tooltip until container bounds are measured
204
- if ( containerBounds.width === 0 || containerBounds.height === 0 ) {
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 - containerBounds.left + tooltipOffsetX,
216
- tooltipTop: event.clientY - containerBounds.top + tooltipOffsetY,
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 className={ styles[ 'pie-semi-circle-chart' ] }>
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
- svg {
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
  }
@@ -1,5 +1,5 @@
1
1
  .svg-empty-state {
2
2
  text-align: center;
3
- font-size: var(--wpds-typography-font-size-md, 13px);
3
+ font-size: var(--wpds-typography-font-size-md);
4
4
  color: var(--a8c-charts-color-label-secondary);
5
5
  }
@@ -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
+ } );