@automattic/charts 1.12.0 → 2.0.1

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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/TOKENS.md +8 -1
  3. package/dist/index.cjs +2487 -3465
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +15 -18
  6. package/dist/index.d.cts +15 -1
  7. package/dist/index.d.ts +15 -1
  8. package/dist/index.js +452 -1429
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -5
  11. package/src/charts/area-chart/area-chart.tsx +6 -7
  12. package/src/charts/bar-chart/bar-chart.tsx +24 -15
  13. package/src/charts/bar-chart/private/use-bar-chart-options.ts +197 -30
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +685 -1
  15. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  16. package/src/charts/line-chart/line-chart.module.scss +3 -3
  17. package/src/charts/line-chart/line-chart.tsx +6 -7
  18. package/src/charts/line-chart/test/line-chart.test.tsx +44 -4
  19. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
  20. package/src/charts/private/test/time-axis.test.ts +427 -0
  21. package/src/charts/private/time-axis.ts +291 -23
  22. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +52 -7
  23. package/src/charts/private/x-zoom/x-zoom.module.scss +7 -15
  24. package/src/charts/private/x-zoom/x-zoom.tsx +29 -40
  25. package/src/components/legend/private/base-legend.module.scss +1 -1
  26. package/src/components/legend/test/legend.test.tsx +2 -1
  27. package/src/components/tooltip/base-tooltip.module.scss +1 -1
  28. package/src/components/trend-indicator/test/trend-indicator.test.tsx +2 -1
  29. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  30. package/src/hooks/test/use-chart-margin.test.tsx +60 -0
  31. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +83 -0
  32. package/src/hooks/use-chart-margin.tsx +3 -4
  33. package/src/hooks/use-xychart-theme.ts +17 -11
  34. package/src/index.ts +1 -0
  35. package/src/types.ts +15 -0
  36. package/src/utils/index.ts +1 -1
  37. package/src/utils/resolve-css-var.ts +69 -28
  38. package/src/utils/test/create-css-variable-resolver.test.ts +83 -0
@@ -16,7 +16,7 @@
16
16
  text-overflow: ellipsis;
17
17
  font-size: var(--wpds-typography-font-size-xl, 18px);
18
18
  font-style: normal;
19
- font-weight: var(--wpds-typography-font-weight-medium, 499);
19
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
20
20
  line-height: var(--wpds-typography-line-height-sm, 20px);
21
21
  margin: 0;
22
22
  }
@@ -26,7 +26,7 @@
26
26
  text-overflow: ellipsis;
27
27
  font-size: var(--wpds-typography-font-size-md, 13px);
28
28
  font-style: normal;
29
- font-weight: var(--wpds-typography-font-weight-medium, 499);
29
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
30
30
  line-height: var(--wpds-typography-line-height-sm, 20px);
31
31
  margin: 0;
32
32
  }
@@ -56,7 +56,7 @@
56
56
  .step-label {
57
57
  color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
58
58
  font-size: var(--wpds-typography-font-size-sm, 12px);
59
- font-weight: var(--wpds-typography-font-weight-regular, 400);
59
+ font-weight: var(--wpds-typography-font-weight-default, 400);
60
60
  line-height: var(--wpds-typography-line-height-xs, 16px);
61
61
  white-space: nowrap;
62
62
  overflow: hidden;
@@ -66,7 +66,7 @@
66
66
  .step-rate {
67
67
  color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
68
68
  font-size: var(--wpds-typography-font-size-md, 13px);
69
- font-weight: var(--wpds-typography-font-weight-medium, 499);
69
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
70
70
  line-height: var(--wpds-typography-line-height-xs, 16px);
71
71
  }
72
72
 
@@ -105,7 +105,7 @@
105
105
  // Override .visx-tooltip inline styles.
106
106
  border-radius: var(--wpds-border-radius-md, 4px) !important;
107
107
  padding: var(--wpds-dimension-padding-md, 12px) !important;
108
- box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
108
+ box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
109
109
 
110
110
  }
111
111
 
@@ -113,7 +113,7 @@
113
113
  color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
114
114
  font-size: var(--wpds-typography-font-size-sm, 12px);
115
115
  font-style: normal;
116
- font-weight: var(--wpds-typography-font-weight-regular, 400);
116
+ font-weight: var(--wpds-typography-font-weight-default, 400);
117
117
  line-height: var(--wpds-typography-line-height-xs, 16px);
118
118
  }
119
119
 
@@ -121,7 +121,7 @@
121
121
  color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
122
122
  font-size: var(--wpds-typography-font-size-md, 13px);
123
123
  font-style: normal;
124
- font-weight: var(--wpds-typography-font-weight-medium, 499);
124
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
125
125
  line-height: var(--wpds-typography-line-height-sm, 20px);
126
126
  }
127
127
 
@@ -24,7 +24,7 @@
24
24
  }
25
25
 
26
26
  &__tooltip-date {
27
- font-weight: var(--wpds-typography-font-weight-medium, 499);
27
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
28
28
  padding-bottom: var(--wpds-dimension-padding-md, 12px);
29
29
  }
30
30
 
@@ -33,7 +33,7 @@
33
33
  }
34
34
 
35
35
  &__tooltip-label {
36
- font-weight: var(--wpds-typography-font-weight-medium, 499);
36
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
37
37
  padding-right: var(--wpds-dimension-padding-lg, 16px);
38
38
  }
39
39
 
@@ -65,7 +65,7 @@
65
65
  border: none;
66
66
  border-radius: var(--wpds-border-radius-md, 4px);
67
67
  font-size: var(--wpds-typography-font-size-md, 13px);
68
- box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
68
+ box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
69
69
  position: fixed;
70
70
  // Without margin set, the popover has margin:auto which upsets the
71
71
  // positioning relative to the trigger button. A gap token is appropriate
@@ -29,7 +29,7 @@ import {
29
29
  useGlobalChartsContext,
30
30
  useGlobalChartsTheme,
31
31
  } from '../../providers';
32
- import { attachSubComponents, resolveCssVariable } from '../../utils';
32
+ import { attachSubComponents } from '../../utils';
33
33
  import { useChartChildren } from '../private/chart-composition';
34
34
  import { ChartLayout } from '../private/chart-layout';
35
35
  import { DefaultGlyph } from '../private/default-glyph';
@@ -192,11 +192,9 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
192
192
  const legendPosition = legend.position ?? 'bottom';
193
193
 
194
194
  const providerTheme = useGlobalChartsTheme();
195
- // Gradient stops apply this as an SVG attribute, where CSS var() cannot
196
- // resolve, so resolve the WPDS token to a concrete value first.
197
- const resolvedBackgroundColor =
198
- resolveCssVariable( providerTheme.backgroundColor ) ?? providerTheme.backgroundColor;
199
195
  const theme = useXYChartTheme( data );
196
+ // Gradient stops apply this as an SVG attribute, where CSS var() cannot resolve. useXYChartTheme has already resolved the same role inside its memo, so read it back rather than paying another getComputedStyle on every render.
197
+ const resolvedBackgroundColor = theme.backgroundColor ?? providerTheme.backgroundColor;
200
198
  const chartId = useChartId( providedChartId );
201
199
  const chartRef = useRef< HTMLDivElement >( null );
202
200
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
@@ -287,7 +285,8 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
287
285
  } );
288
286
 
289
287
  const chartOptions = useMemo( () => {
290
- const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
288
+ const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
289
+ const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
291
290
 
292
291
  return {
293
292
  axis: {
@@ -296,7 +295,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
296
295
  numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
297
296
  tickFormat: formatter,
298
297
  display: true,
299
- ...options?.axis?.x,
298
+ ...xAxisOptions,
300
299
  },
301
300
  y: {
302
301
  orientation: 'left' as const,
@@ -436,7 +436,48 @@ describe( 'LineChart', () => {
436
436
  expect( ticks.length ).toBeGreaterThan( 1 );
437
437
  } );
438
438
 
439
+ test( 'renders hour ticks when tickResolution declares the buckets sub-daily.', () => {
440
+ renderWithTheme( {
441
+ width: 800,
442
+ options: { axis: { x: { tickResolution: 'hour' } } },
443
+ data: [
444
+ {
445
+ label: 'Series A',
446
+ // A day apart: spacing inference would read this as daily buckets.
447
+ data: [
448
+ { date: new Date( '2024-01-01T00:00:00' ), value: 10 },
449
+ { date: new Date( '2024-01-02T00:00:00' ), value: 20 },
450
+ ],
451
+ },
452
+ ],
453
+ } );
454
+
455
+ const ticks = screen.getAllByText( /\d+\s(AM|PM)/ );
456
+ expect( ticks.length ).toBeGreaterThan( 1 );
457
+ } );
458
+
439
459
  test( 'renders ticks in short date format.', () => {
460
+ renderWithTheme( {
461
+ width: 800,
462
+ data: [
463
+ {
464
+ label: 'Series A',
465
+ // Weekly resolution: fine enough to keep full dates on the ticks.
466
+ data: Array.from( { length: 16 }, ( _, i ) => ( {
467
+ date: new Date( Date.UTC( 2024, 0, 1 + i * 7 ) ),
468
+ value: 10 + i,
469
+ } ) ),
470
+ },
471
+ ],
472
+ } );
473
+
474
+ const ticks = screen.getAllByText(
475
+ /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+$/
476
+ );
477
+ expect( ticks.length ).toBeGreaterThan( 1 );
478
+ } );
479
+
480
+ test( 'renders month ticks for month-or-coarser buckets within a year.', () => {
440
481
  renderWithTheme( {
441
482
  width: 800,
442
483
  data: [
@@ -453,9 +494,7 @@ describe( 'LineChart', () => {
453
494
  ],
454
495
  } );
455
496
 
456
- const ticks = screen.getAllByText(
457
- /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+$/
458
- );
497
+ const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
459
498
  expect( ticks.length ).toBeGreaterThan( 1 );
460
499
  } );
461
500
 
@@ -502,7 +541,8 @@ describe( 'LineChart', () => {
502
541
  ],
503
542
  } );
504
543
 
505
- const ticks = screen.getAllByText( /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct) \d+/ );
544
+ // Roughly monthly buckets render month ticks under the resolution-aware formatter.
545
+ const ticks = screen.getAllByText( /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct)$/ );
506
546
  expect( ticks.length ).toBeLessThan( 6 ); // Not much space
507
547
  } );
508
548
 
@@ -6,7 +6,7 @@
6
6
  }
7
7
 
8
8
  .label {
9
- font-weight: var(--wpds-typography-font-weight-medium, 499);
9
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
10
10
  font-size: var(--wpds-typography-font-size-lg, 16px);
11
11
  }
12
12
 
@@ -0,0 +1,427 @@
1
+ import { getBandTickValues, getBucketResolution, getFormatter } from '../time-axis';
2
+ import type { useChartDataTransform } from '../../../hooks';
3
+
4
+ type SortedData = ReturnType< typeof useChartDataTransform >;
5
+
6
+ const toSeries = ( dates: Date[] ): SortedData =>
7
+ [
8
+ {
9
+ label: 'series',
10
+ data: dates.map( date => ( { date, value: 1 } ) ),
11
+ },
12
+ ] as unknown as SortedData;
13
+
14
+ const hourlyDates = ( start: Date, hours: number ): Date[] =>
15
+ Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
16
+
17
+ const dailyDates = ( start: Date, days: number ): Date[] =>
18
+ Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
19
+
20
+ // The test script pins TZ and locale, so local formatting is deterministic.
21
+ describe( 'getFormatter', () => {
22
+ it( 'uses hour ticks within a single day', () => {
23
+ const formatter = getFormatter(
24
+ toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 24 ) )
25
+ );
26
+
27
+ expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
28
+ } );
29
+
30
+ it( 'uses hour ticks with dates at midnight for sub-daily data spanning multiple days', () => {
31
+ const formatter = getFormatter(
32
+ toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 48 ) )
33
+ );
34
+
35
+ expect( formatter( new Date( '2026-08-03T00:00:00' ).getTime() ) ).toMatch( /Aug 3/ );
36
+ expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
37
+ } );
38
+
39
+ it( 'keeps hour ticks up to a full week of sub-daily data', () => {
40
+ const formatter = getFormatter(
41
+ toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 24 * 7 + 1 ) )
42
+ );
43
+
44
+ expect( formatter( new Date( '2026-08-05T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
45
+ } );
46
+
47
+ it( 'keeps date ticks for a two-point daily series spanning exactly a day', () => {
48
+ const formatter = getFormatter(
49
+ toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 2 ) )
50
+ );
51
+
52
+ expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
53
+ } );
54
+
55
+ it( 'keeps date ticks for daily data over the same multi-day span', () => {
56
+ const formatter = getFormatter(
57
+ toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 3 ) )
58
+ );
59
+
60
+ expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
61
+ expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).not.toMatch( /PM/ );
62
+ } );
63
+
64
+ it( 'keeps date ticks for daily buckets across a spring-forward transition', () => {
65
+ // A 23-hour first gap, as a daily series straddling spring-forward
66
+ // produces, must not demote the series to the sub-daily regime.
67
+ const start = new Date( '2026-03-08T00:00:00' );
68
+ const dstDates = [ 0, 23, 47, 71 ].map(
69
+ offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
70
+ );
71
+ const formatter = getFormatter( toSeries( dstDates ) );
72
+
73
+ expect( formatter( dstDates[ 2 ].getTime() ) ).not.toMatch( /AM|PM/ );
74
+ expect( formatter( dstDates[ 2 ].getTime() ) ).toMatch( /Mar/ );
75
+ } );
76
+
77
+ it( 'keeps date ticks when no series has two points', () => {
78
+ const formatter = getFormatter( [
79
+ ...toSeries( [ new Date( '2026-03-01T00:00:00' ) ] ),
80
+ ...toSeries( [ new Date( '2026-03-04T00:00:00' ) ] ),
81
+ ] );
82
+
83
+ expect( formatter( new Date( '2026-03-02T00:00:00' ).getTime() ) ).toMatch( /Mar 2/ );
84
+ } );
85
+
86
+ it( 'uses month ticks for buckets exactly a shortest-month apart', () => {
87
+ const formatter = getFormatter(
88
+ toSeries( [
89
+ new Date( '2026-02-01T00:00:00' ),
90
+ new Date( '2026-03-01T00:00:00' ),
91
+ new Date( '2026-04-01T00:00:00' ),
92
+ ] )
93
+ );
94
+
95
+ expect( formatter( new Date( '2026-03-01T00:00:00' ).getTime() ) ).toBe( 'Mar' );
96
+ } );
97
+
98
+ it( 'uses month ticks with the year at January for monthly buckets', () => {
99
+ const monthlyDates = Array.from(
100
+ { length: 13 },
101
+ ( _, i ) => new Date( Date.UTC( 2025, 7 + i, 1 ) )
102
+ );
103
+ const formatter = getFormatter( toSeries( monthlyDates ) );
104
+
105
+ expect( formatter( new Date( '2025-09-01T00:00:00' ).getTime() ) ).toBe( 'Sep' );
106
+ expect( formatter( new Date( '2026-01-01T00:00:00' ).getTime() ) ).toBe( '2026' );
107
+ } );
108
+
109
+ it( 'keeps date ticks for weekly buckets within a year', () => {
110
+ const weeklyDates = Array.from(
111
+ { length: 20 },
112
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
113
+ );
114
+ const formatter = getFormatter( toSeries( weeklyDates ) );
115
+
116
+ expect( formatter( new Date( '2026-03-02T00:00:00' ).getTime() ) ).toMatch( /Mar 2/ );
117
+ } );
118
+
119
+ it( 'uses year ticks beyond a year', () => {
120
+ const yearly = getFormatter(
121
+ toSeries( [ new Date( '2020-01-01T00:00:00' ), new Date( '2026-01-01T00:00:00' ) ] )
122
+ );
123
+
124
+ expect( yearly( new Date( '2023-01-01T00:00:00' ).getTime() ) ).toBe( '2023' );
125
+ } );
126
+
127
+ it( 'keeps month ticks for monthly buckets spanning several years', () => {
128
+ const monthlyDates = Array.from(
129
+ { length: 36 },
130
+ ( _, i ) => new Date( Date.UTC( 2024, i, 1 ) )
131
+ );
132
+ const formatter = getFormatter( toSeries( monthlyDates ) );
133
+
134
+ // Year ticks here would render the same year for every tick the band scale
135
+ // samples out of a calendar year.
136
+ expect( formatter( new Date( '2024-10-01T00:00:00' ).getTime() ) ).toBe( 'Oct' );
137
+ expect( formatter( new Date( '2025-01-01T00:00:00' ).getTime() ) ).toBe( '2025' );
138
+ } );
139
+
140
+ it( 'uses year ticks for inferred yearly buckets that do not start in January', () => {
141
+ const formatter = getFormatter(
142
+ toSeries( [ new Date( '2023-07-01T00:00:00' ), new Date( '2024-07-01T00:00:00' ) ] )
143
+ );
144
+
145
+ // The month regime would print a bare "Jul" for both.
146
+ expect( formatter( new Date( '2023-07-01T00:00:00' ).getTime() ) ).toBe( '2023' );
147
+ expect( formatter( new Date( '2024-07-01T00:00:00' ).getTime() ) ).toBe( '2024' );
148
+ } );
149
+
150
+ it( 'ignores series with no dated points when measuring the span', () => {
151
+ const formatter = getFormatter( [
152
+ ...toSeries( dailyDates( new Date( '2024-01-01T00:00:00' ), 14 ) ),
153
+ ...toSeries( [] ),
154
+ ] );
155
+
156
+ // An empty comparison series must not turn the span into NaN, which would
157
+ // fall through every span check to year ticks.
158
+ expect( formatter( new Date( '2024-01-03T00:00:00' ).getTime() ) ).toMatch( /Jan 3/ );
159
+ } );
160
+
161
+ it( 'falls back to date ticks when nothing is dated', () => {
162
+ const formatter = getFormatter( toSeries( [] ) );
163
+
164
+ expect( formatter( new Date( '2024-01-03T00:00:00' ).getTime() ) ).toMatch( /Jan 3/ );
165
+ } );
166
+
167
+ describe( 'with an explicit tickResolution', () => {
168
+ it( 'uses hour ticks for two hourly points a day apart, where spacing would read as daily', () => {
169
+ const formatter = getFormatter(
170
+ toSeries( [ new Date( '2026-08-01T00:00:00' ), new Date( '2026-08-02T00:00:00' ) ] ),
171
+ 'hour'
172
+ );
173
+
174
+ expect( formatter( new Date( '2026-08-01T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
175
+ } );
176
+
177
+ it( 'uses date ticks for a lone daily point, where spacing is unknowable', () => {
178
+ const formatter = getFormatter( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ), 'day' );
179
+
180
+ expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
181
+ } );
182
+
183
+ it( 'uses month ticks for monthly buckets regardless of their measured gaps', () => {
184
+ const formatter = getFormatter(
185
+ toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] ),
186
+ 'month'
187
+ );
188
+
189
+ expect( formatter( new Date( '2026-03-01T00:00:00' ).getTime() ) ).toBe( 'Mar' );
190
+ } );
191
+
192
+ it( 'uses date ticks for weekly buckets', () => {
193
+ const weeklyDates = Array.from(
194
+ { length: 8 },
195
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
196
+ );
197
+ const formatter = getFormatter( toSeries( weeklyDates ), 'week' );
198
+
199
+ expect( formatter( new Date( '2026-01-12T00:00:00' ).getTime() ) ).toMatch( /Jan 12/ );
200
+ } );
201
+
202
+ it( 'uses year ticks for a pair of yearly buckets', () => {
203
+ const formatter = getFormatter(
204
+ toSeries( [ new Date( '2025-01-01T00:00:00' ), new Date( '2026-01-01T00:00:00' ) ] ),
205
+ 'year'
206
+ );
207
+
208
+ expect( formatter( new Date( '2025-01-01T00:00:00' ).getTime() ) ).toBe( '2025' );
209
+ expect( formatter( new Date( '2026-01-01T00:00:00' ).getTime() ) ).toBe( '2026' );
210
+ } );
211
+
212
+ it( 'uses year ticks for yearly buckets that do not start in January', () => {
213
+ const formatter = getFormatter(
214
+ toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] ),
215
+ 'year'
216
+ );
217
+
218
+ expect( formatter( new Date( '2025-06-01T00:00:00' ).getTime() ) ).toBe( '2025' );
219
+ expect( formatter( new Date( '2026-06-01T00:00:00' ).getTime() ) ).toBe( '2026' );
220
+ } );
221
+ } );
222
+ } );
223
+
224
+ describe( 'getBucketResolution', () => {
225
+ it( 'infers hourly buckets from sub-daily spacing', () => {
226
+ expect(
227
+ getBucketResolution( toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) ) )
228
+ ).toBe( 'hour' );
229
+ } );
230
+
231
+ it( 'infers daily buckets from daily spacing', () => {
232
+ expect(
233
+ getBucketResolution( toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 5 ) ) )
234
+ ).toBe( 'day' );
235
+ } );
236
+
237
+ it( 'infers daily buckets from weekly spacing, which labels the same way', () => {
238
+ const weeklyDates = Array.from(
239
+ { length: 5 },
240
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
241
+ );
242
+
243
+ expect( getBucketResolution( toSeries( weeklyDates ) ) ).toBe( 'day' );
244
+ } );
245
+
246
+ it( 'infers monthly buckets from a shortest-month gap', () => {
247
+ expect(
248
+ getBucketResolution(
249
+ toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] )
250
+ )
251
+ ).toBe( 'month' );
252
+ } );
253
+
254
+ it( 'infers yearly buckets from a yearly gap', () => {
255
+ expect(
256
+ getBucketResolution(
257
+ toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] )
258
+ )
259
+ ).toBe( 'year' );
260
+ } );
261
+
262
+ it( 'keeps a daily gap across spring-forward out of the hourly bucket', () => {
263
+ const start = new Date( '2026-03-08T00:00:00' );
264
+ const dstDates = [ 0, 23, 47 ].map(
265
+ offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
266
+ );
267
+
268
+ expect( getBucketResolution( toSeries( dstDates ) ) ).toBe( 'day' );
269
+ } );
270
+
271
+ it( 'reports daily buckets when no series has two points', () => {
272
+ // Unmeasurable spacing reads as Infinity, which must not be mistaken for a
273
+ // very coarse bucket.
274
+ expect( getBucketResolution( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ) ) ).toBe(
275
+ 'day'
276
+ );
277
+ } );
278
+
279
+ it( 'reports a declared resolution over the measured spacing', () => {
280
+ const hourly = toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) );
281
+
282
+ expect( getBucketResolution( hourly, 'month' ) ).toBe( 'month' );
283
+ } );
284
+
285
+ it( 'reports a declared weekly resolution as daily', () => {
286
+ // Weeks and days are both calendar-date buckets as far as labelling goes,
287
+ // and 'week' is not a label format of its own.
288
+ expect( getBucketResolution( toSeries( [] ), 'week' ) ).toBe( 'day' );
289
+ } );
290
+ } );
291
+
292
+ describe( 'tick format anchors', () => {
293
+ // The band axis steers ticks onto boundaries using the predicate the format
294
+ // hangs off itself. If the two ever disagree, the axis aims at buckets that
295
+ // do not actually print the coarser label.
296
+ it( 'marks exactly the buckets whose label carries the coarser unit', () => {
297
+ const hourly = Array.from( { length: 48 }, ( _, i ) => new Date( 2026, 7, 2, i ) );
298
+ const hourFormat = getFormatter( toSeries( hourly ) );
299
+
300
+ expect( hourFormat.isAnchor ).toBeDefined();
301
+ hourly.forEach( date => {
302
+ const isDateLabel = ! /(AM|PM)/.test( hourFormat( date.getTime() ) );
303
+ expect( hourFormat.isAnchor( date ) ).toBe( isDateLabel );
304
+ } );
305
+
306
+ const monthly = Array.from( { length: 36 }, ( _, i ) => new Date( 2023, 6 + i, 1 ) );
307
+ const monthFormat = getFormatter( toSeries( monthly ) );
308
+
309
+ expect( monthFormat.isAnchor ).toBeDefined();
310
+ monthly.forEach( date => {
311
+ const isYearLabel = /^\d{4}$/.test( monthFormat( date.getTime() ) );
312
+ expect( monthFormat.isAnchor( date ) ).toBe( isYearLabel );
313
+ } );
314
+ } );
315
+ } );
316
+
317
+ describe( 'getBandTickValues', () => {
318
+ const monthlyDomain = ( year: number, month: number, months: number ) =>
319
+ Array.from( { length: months }, ( _, i ) => new Date( year, month + i, 1 ) );
320
+
321
+ const hourlyDomain = ( start: Date, hours: number ) =>
322
+ Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
323
+
324
+ const labelsFor = ( domain: Date[], maxTicks = 4 ) => {
325
+ const formatter = getFormatter( toSeries( domain ) );
326
+ return getBandTickValues( domain, formatter, maxTicks ).map( date =>
327
+ formatter( date.getTime() )
328
+ );
329
+ };
330
+
331
+ it( 'shifts the sampling offset onto January for a monthly series starting mid-year', () => {
332
+ // Sampling from index 0 gives Aug, Nov, Feb, May, Aug — no year at all on a
333
+ // series that crosses one, and "Aug" twice.
334
+ expect( labelsFor( monthlyDomain( 2025, 7, 13 ) ) ).toContain( '2026' );
335
+ } );
336
+
337
+ it( 'lands on every January of a multi-year monthly series that starts mid-year', () => {
338
+ expect( labelsFor( monthlyDomain( 2023, 6, 36 ) ) ).toEqual( [ '2024', '2025', '2026' ] );
339
+ } );
340
+
341
+ it( 'keeps the dated midnight ticks for sub-daily data spanning days', () => {
342
+ // Index sampling already lands here; this pins that the steering keeps it.
343
+ expect( labelsFor( hourlyDomain( new Date( 2026, 7, 2 ), 48 ) ) ).toEqual( [
344
+ 'Aug 2',
345
+ '12 PM',
346
+ 'Aug 3',
347
+ '12 PM',
348
+ ] );
349
+ } );
350
+
351
+ it( 'steps whole days once a sub-daily span is too long to date any other way', () => {
352
+ // No step inside a day fits four ticks across a week, so the offset sweep
353
+ // alone would leave every tick but the first a bare hour on an unnamed day.
354
+ expect( labelsFor( hourlyDomain( new Date( 2026, 7, 2 ), 168 ) ) ).toEqual( [
355
+ 'Aug 2',
356
+ 'Aug 4',
357
+ 'Aug 6',
358
+ 'Aug 8',
359
+ ] );
360
+ } );
361
+
362
+ it( 'steps whole years once a monthly span is too long to reach January any other way', () => {
363
+ expect( labelsFor( monthlyDomain( 2023, 6, 120 ) ) ).toEqual( [
364
+ '2024',
365
+ '2027',
366
+ '2030',
367
+ '2033',
368
+ ] );
369
+ } );
370
+
371
+ it( 'still names a year at the lowest tick counts a caller can ask for', () => {
372
+ // An anchor outranks a denser axis, so without a step that reaches two of
373
+ // them a two-tick axis would settle for a single January.
374
+ expect( labelsFor( monthlyDomain( 2023, 6, 36 ), 2 ) ).toEqual( [ '2024', '2026' ] );
375
+ expect( labelsFor( monthlyDomain( 2023, 6, 36 ), 3 ) ).toEqual( [ '2024', '2025', '2026' ] );
376
+ } );
377
+
378
+ it( 'never repeats a label on adjacent ticks', () => {
379
+ // 49 buckets put every fourth tick a whole year apart, so sampling by index
380
+ // spells the same month five times over: Feb, Feb, Feb, Feb, Feb.
381
+ const labels = labelsFor( monthlyDomain( 2023, 1, 49 ) );
382
+
383
+ expect( labels.some( ( label, i ) => i > 0 && label === labels[ i - 1 ] ) ).toBe( false );
384
+ } );
385
+
386
+ it( 'keeps the axis dense when only a sparser one could reach an anchor', () => {
387
+ // Ranking anchors above everything picked the two ticks that happened to be
388
+ // Januaries and dropped the rest of the axis with them.
389
+ expect( labelsFor( monthlyDomain( 2023, 3, 30 ) ) ).toEqual( [ 'Apr', '2024', 'Oct', 'Jul' ] );
390
+ } );
391
+
392
+ it( 'names the single year of a short series without thinning the axis to it', () => {
393
+ expect( labelsFor( monthlyDomain( 2023, 6, 9 ) ) ).toEqual( [ 'Jul', 'Oct', '2024' ] );
394
+ } );
395
+
396
+ it( 'reaches anchors that sit an uneven number of buckets apart', () => {
397
+ // A spring-forward day is 23 hourly buckets, not 24, so a fixed stride
398
+ // slides off midnight and leaves the rest of the week on bare hours.
399
+ const domain: Date[] = [];
400
+ for ( let day = 0; day < 7; day++ ) {
401
+ for ( let hour = 0; hour < 24; hour++ ) {
402
+ if ( day !== 2 || hour !== 2 ) {
403
+ domain.push( new Date( 2026, 2, 6 + day, hour ) );
404
+ }
405
+ }
406
+ }
407
+
408
+ expect( labelsFor( domain ) ).toEqual( [ 'Mar 6', 'Mar 8', 'Mar 10', 'Mar 12' ] );
409
+ } );
410
+
411
+ it( 'keeps the whole domain when it already fits', () => {
412
+ const domain = monthlyDomain( 2023, 0, 4 );
413
+
414
+ expect( getBandTickValues( domain, String, 4 ) ).toEqual( domain );
415
+ } );
416
+
417
+ it( 'never exceeds the requested tick count', () => {
418
+ const domain = monthlyDomain( 2023, 6, 36 );
419
+
420
+ expect( getBandTickValues( domain, String, 4 ).length ).toBeLessThanOrEqual( 4 );
421
+ expect( getBandTickValues( domain, String, 7 ).length ).toBeLessThanOrEqual( 7 );
422
+ } );
423
+
424
+ it( 'returns nothing for an empty domain', () => {
425
+ expect( getBandTickValues( [], String, 4 ) ).toEqual( [] );
426
+ } );
427
+ } );