@automattic/charts 3.1.1 → 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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  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 +38 -11
  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-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+ import { dailySeries, hourlySeries } from '../../../test-utils/series-fixtures';
5
+ import { getFormatter, getMaxTicksForWidth, getTimeAxisTickValues } from '../time-axis';
6
+ import type { DataPointDate, SeriesData } from '../../../types';
7
+
8
+ // The runtime zone deliberately differs from every zone under test, so a test
9
+ // that passes only because the two agree cannot hide here.
10
+ const TOKYO = { timeZone: 'Asia/Tokyo' };
11
+
12
+ const localTimes = ( ticks: Date[], timeZone: string ) =>
13
+ ticks.map( tick =>
14
+ new Intl.DateTimeFormat( 'en-US', {
15
+ timeZone,
16
+ hour: '2-digit',
17
+ minute: '2-digit',
18
+ hour12: false,
19
+ } ).format( tick )
20
+ );
21
+
22
+ describe( 'getMaxTicksForWidth', () => {
23
+ it( 'scales with the width and never returns less than one', () => {
24
+ expect( getMaxTicksForWidth( 600 ) ).toBe( 10 );
25
+ expect( getMaxTicksForWidth( 10 ) ).toBe( 1 );
26
+ expect( getMaxTicksForWidth( 0 ) ).toBe( 1 );
27
+ } );
28
+ } );
29
+
30
+ // One label is `span / maxTicks` wide, so anything closer overlaps its neighbour.
31
+ const closestGap = ( ticks: Date[] ) =>
32
+ ticks
33
+ .slice( 1 )
34
+ .reduce(
35
+ ( nearest, tick, index ) => Math.min( nearest, tick.getTime() - ticks[ index ].getTime() ),
36
+ Infinity
37
+ );
38
+
39
+ const joinSeries = ( label: string, ...parts: SeriesData[][] ): SeriesData[] => [
40
+ { label, data: parts.flatMap( ( [ series ] ) => series.data as DataPointDate[] ) },
41
+ ];
42
+
43
+ describe( 'getTimeAxisTickValues', () => {
44
+ it( 'returns null when no series carries a date', () => {
45
+ const data: SeriesData[] = [ { label: 'views', data: [ { value: 1 }, { value: 2 } ] } ];
46
+ const formatter = getFormatter( data, undefined, TOKYO );
47
+
48
+ expect( getTimeAxisTickValues( data, undefined, formatter, 6 ) ).toBeNull();
49
+ } );
50
+
51
+ it( 'selects only points inside the supplied domain', () => {
52
+ const data = dailySeries( '2026-08-01T15:00:00Z', 10 );
53
+ const formatter = getFormatter( data, 'day', TOKYO );
54
+ const domain: [ Date, Date ] = [
55
+ new Date( '2026-08-03T15:00:00Z' ),
56
+ new Date( '2026-08-06T15:00:00Z' ),
57
+ ];
58
+
59
+ const ticks = getTimeAxisTickValues( data, domain, formatter, 10 );
60
+
61
+ expect( ticks ).not.toBeNull();
62
+ for ( const tick of ticks as Date[] ) {
63
+ expect( tick.getTime() ).toBeGreaterThanOrEqual( domain[ 0 ].getTime() );
64
+ expect( tick.getTime() ).toBeLessThanOrEqual( domain[ 1 ].getTime() );
65
+ }
66
+ } );
67
+
68
+ it( 'deduplicates points shared by two series', () => {
69
+ const [ first ] = dailySeries( '2026-08-01T15:00:00Z', 4 );
70
+ const data: SeriesData[] = [ first, { ...first, label: 'visitors' } ];
71
+ const formatter = getFormatter( data, 'day', TOKYO );
72
+
73
+ const ticks = getTimeAxisTickValues( data, undefined, formatter, 10 ) as Date[];
74
+
75
+ expect( new Set( ticks.map( tick => tick.getTime() ) ).size ).toBe( ticks.length );
76
+ expect( ticks.length ).toBeLessThanOrEqual( 4 );
77
+ } );
78
+
79
+ it( 'returns the points in ascending order', () => {
80
+ const data = dailySeries( '2026-08-01T15:00:00Z', 8 );
81
+ const formatter = getFormatter( data, 'day', TOKYO );
82
+
83
+ const ticks = getTimeAxisTickValues( data, undefined, formatter, 8 ) as Date[];
84
+ const timestamps = ticks.map( tick => tick.getTime() );
85
+
86
+ expect( timestamps ).toEqual( [ ...timestamps ].sort( ( a, b ) => a - b ) );
87
+ } );
88
+
89
+ it( 'degrades to a single tick when the domain admits one point', () => {
90
+ const data = dailySeries( '2026-08-01T15:00:00Z', 10 );
91
+ const formatter = getFormatter( data, 'day', TOKYO );
92
+ const only = new Date( '2026-08-03T15:00:00Z' );
93
+
94
+ const ticks = getTimeAxisTickValues( data, [ only, only ], formatter, 6 ) as Date[];
95
+
96
+ expect( ticks ).toHaveLength( 1 );
97
+ expect( ticks[ 0 ].getTime() ).toBe( only.getTime() );
98
+ } );
99
+
100
+ it( 'returns an empty array when the domain admits no point', () => {
101
+ const data = dailySeries( '2026-08-01T15:00:00Z', 4 );
102
+ const formatter = getFormatter( data, 'day', TOKYO );
103
+ const gap: [ Date, Date ] = [
104
+ new Date( '2026-09-01T00:00:00Z' ),
105
+ new Date( '2026-09-02T00:00:00Z' ),
106
+ ];
107
+
108
+ expect( getTimeAxisTickValues( data, gap, formatter, 6 ) ).toEqual( [] );
109
+ } );
110
+
111
+ // An index stride is a pixel stride only on evenly spaced points: sampling a
112
+ // gapped series by index piles every tick into the two ends.
113
+ it( 'spreads ticks over a long gap rather than crowding its ends', () => {
114
+ const data = joinSeries(
115
+ 'views',
116
+ dailySeries( '2026-01-01T00:00:00Z', 10 ),
117
+ dailySeries( '2026-03-11T00:00:00Z', 10 )
118
+ );
119
+ const formatter = getFormatter( data, 'day', TOKYO );
120
+ const maxTicks = getMaxTicksForWidth( 700 );
121
+
122
+ const ticks = getTimeAxisTickValues( data, undefined, formatter, maxTicks ) as Date[];
123
+ const span = 78 * 24 * 60 * 60 * 1000;
124
+
125
+ expect( ticks.length ).toBeGreaterThan( maxTicks / 2 );
126
+ expect( closestGap( ticks ) ).toBeGreaterThanOrEqual( ( span / maxTicks ) * 0.9 );
127
+ } );
128
+
129
+ it( 'keeps a usable axis when two series sit on different grids', () => {
130
+ const data = [
131
+ ...hourlySeries( '2026-01-01T00:00:00Z', 24 ),
132
+ { ...dailySeries( '2026-01-01T00:00:00Z', 10 )[ 0 ], label: 'visitors' },
133
+ ];
134
+ const formatter = getFormatter( data, undefined, TOKYO );
135
+ const maxTicks = getMaxTicksForWidth( 700 );
136
+
137
+ const ticks = getTimeAxisTickValues( data, undefined, formatter, maxTicks ) as Date[];
138
+
139
+ // The 24 same-day points share one label, which used to veto every stride
140
+ // and leave the ten day axis with two ticks.
141
+ expect( ticks.length ).toBeGreaterThan( maxTicks / 2 );
142
+ expect( new Set( ticks.map( tick => formatter( tick.getTime() ) ) ).size ).toBe( ticks.length );
143
+ } );
144
+
145
+ it( 'still samples evenly spaced points by index', () => {
146
+ const data = dailySeries( '2026-01-01T00:00:00Z', 90 );
147
+ const formatter = getFormatter( data, 'day', TOKYO );
148
+
149
+ const ticks = getTimeAxisTickValues( data, undefined, formatter, 11 ) as Date[];
150
+ const gaps = ticks
151
+ .slice( 1 )
152
+ .map( ( tick, index ) => tick.getTime() - ticks[ index ].getTime() );
153
+
154
+ expect( ticks ).toHaveLength( 11 );
155
+ expect( new Set( gaps ).size ).toBe( 1 );
156
+ } );
157
+
158
+ // Past a week of hourly data the axis switches to bare dates, which used to
159
+ // drop the boundary steering with the hour label: a tick reading "Aug 3" sat
160
+ // 14 hours into Aug 3.
161
+ it( 'keeps date ticks on host zone midnight once hourly data outgrows the hour format', () => {
162
+ const data = hourlySeries( '2026-08-01T15:00:00Z', 8 * 24 );
163
+ const formatter = getFormatter( data, 'hour', TOKYO );
164
+
165
+ const ticks = getTimeAxisTickValues(
166
+ data,
167
+ undefined,
168
+ formatter,
169
+ getMaxTicksForWidth( 700 )
170
+ ) as Date[];
171
+
172
+ expect( ticks.length ).toBeGreaterThan( 4 );
173
+ expect( localTimes( ticks, TOKYO.timeZone ) ).toEqual( ticks.map( () => '00:00' ) );
174
+ } );
175
+
176
+ // Both tick paths have to honour the same anchors: the fallback used to read
177
+ // only the boundary-test kind, so a date tick could sit hours into its day.
178
+ it( 'opens the day on unevenly sampled hourly data, where ticks come from position', () => {
179
+ const dates = Array.from( { length: 10 * 24 }, ( _, index ) => index )
180
+ .filter( index => index % 7 !== 3 && index % 11 !== 5 )
181
+ .map( index => new Date( Date.UTC( 2026, 7, 1, 15 + index ) ) );
182
+ const data: SeriesData[] = [
183
+ { label: 'views', data: dates.map( ( date, index ) => ( { date, value: index } ) ) },
184
+ ];
185
+ const formatter = getFormatter( data, 'hour', TOKYO );
186
+
187
+ const ticks = getTimeAxisTickValues(
188
+ data,
189
+ undefined,
190
+ formatter,
191
+ getMaxTicksForWidth( 700 )
192
+ ) as Date[];
193
+
194
+ expect( ticks.length ).toBeGreaterThan( 4 );
195
+ for ( const tick of ticks ) {
196
+ const label = formatter( tick.getTime() );
197
+ const opensTheDay = dates.find( date => formatter( date.getTime() ) === label );
198
+ expect( tick.getTime() ).toBe( opensTheDay?.getTime() );
199
+ }
200
+ } );
201
+
202
+ // A brush over a sparse stretch, or a caller's own wide xScale.domain: the
203
+ // ticks answer to the scale, so they have to reach across all of it rather
204
+ // than pile into the sliver that carries points.
205
+ it( 'spreads ticks over a domain far wider than the data inside it', () => {
206
+ const data = dailySeries( '2026-01-05T00:00:00Z', 6 );
207
+ const domain: [ Date, Date ] = [
208
+ new Date( '2026-01-05T00:00:00Z' ),
209
+ new Date( '2026-03-05T00:00:00Z' ),
210
+ ];
211
+ const formatter = getFormatter( data, 'day', TOKYO, domain );
212
+ const maxTicks = getMaxTicksForWidth( 700 );
213
+ const span = domain[ 1 ].getTime() - domain[ 0 ].getTime();
214
+
215
+ const ticks = getTimeAxisTickValues( data, domain, formatter, maxTicks ) as Date[];
216
+
217
+ expect( ticks.length ).toBeGreaterThan( maxTicks / 2 );
218
+ expect( ticks[ ticks.length - 1 ].getTime() ).toBeGreaterThan(
219
+ domain[ 0 ].getTime() + span * 0.9
220
+ );
221
+ expect( closestGap( ticks ) ).toBeGreaterThanOrEqual( ( span / maxTicks ) * 0.9 );
222
+ } );
223
+
224
+ // Japan has no DST, so the Tokyo fixture cannot reach this. New York can.
225
+ // 2026-03-08 is spring forward: the local day is 23 hours long.
226
+ it( 'names the first host zone midnights across spring forward, then drifts an hour', () => {
227
+ const zone = { timeZone: 'America/New_York' };
228
+ // 2026-03-07T05:00Z is midnight in New York; 72 hourly points spans the
229
+ // transition and two further local midnights.
230
+ const data = hourlySeries( '2026-03-07T05:00:00Z', 72 );
231
+ const formatter = getFormatter( data, 'hour', zone );
232
+
233
+ const ticks = getTimeAxisTickValues( data, undefined, formatter, 6 ) as Date[];
234
+
235
+ expect( ticks.map( tick => formatter( tick.getTime() ) ) ).toEqual( [
236
+ 'Mar 7',
237
+ '12 PM',
238
+ 'Mar 8',
239
+ '1 PM',
240
+ '1 AM',
241
+ '1 PM',
242
+ ] );
243
+ // An even stride wins the density comparison over the midnight anchors,
244
+ // so once the 23 hour day shifts them the last date lands at 01:00.
245
+ expect( localTimes( ticks, zone.timeZone ) ).toEqual( [
246
+ '00:00',
247
+ '12:00',
248
+ '00:00',
249
+ '13:00',
250
+ '01:00',
251
+ '13:00',
252
+ ] );
253
+ } );
254
+
255
+ // 2026-11-01 is fall back: the local day is 25 hours long.
256
+ it( 'names the first host zone midnights across fall back, then drifts an hour', () => {
257
+ const zone = { timeZone: 'America/New_York' };
258
+ const data = hourlySeries( '2026-10-31T04:00:00Z', 72 );
259
+ const formatter = getFormatter( data, 'hour', zone );
260
+
261
+ const ticks = getTimeAxisTickValues( data, undefined, formatter, 6 ) as Date[];
262
+
263
+ expect( ticks.map( tick => formatter( tick.getTime() ) ) ).toEqual( [
264
+ 'Oct 31',
265
+ '12 PM',
266
+ 'Nov 1',
267
+ '11 AM',
268
+ '11 PM',
269
+ '11 AM',
270
+ ] );
271
+ expect( localTimes( ticks, zone.timeZone ) ).toEqual( [
272
+ '00:00',
273
+ '12:00',
274
+ '00:00',
275
+ '11:00',
276
+ '23:00',
277
+ '11:00',
278
+ ] );
279
+ } );
280
+ } );
@@ -1,4 +1,4 @@
1
- import { getBandTickValues, getBucketResolution, getFormatter } from '../time-axis';
1
+ import { getBandTickValues, getFormatter } from '../time-axis';
2
2
  import type { useChartDataTransform } from '../../../hooks';
3
3
 
4
4
  type SortedData = ReturnType< typeof useChartDataTransform >;
@@ -36,6 +36,14 @@ describe( 'getFormatter', () => {
36
36
  expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
37
37
  } );
38
38
 
39
+ it( 'dates the ticks when sub-daily data spans exactly a day', () => {
40
+ const formatter = getFormatter(
41
+ toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 25 ) )
42
+ );
43
+
44
+ expect( formatter( new Date( '2026-08-03T00:00:00' ).getTime() ) ).toMatch( /Aug 3/ );
45
+ } );
46
+
39
47
  it( 'keeps hour ticks up to a full week of sub-daily data', () => {
40
48
  const formatter = getFormatter(
41
49
  toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 24 * 7 + 1 ) )
@@ -221,74 +229,6 @@ describe( 'getFormatter', () => {
221
229
  } );
222
230
  } );
223
231
 
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
232
  describe( 'tick format anchors', () => {
293
233
  // The band axis steers ticks onto boundaries using the predicate the format
294
234
  // hangs off itself. If the two ever disagree, the axis aims at buckets that
@@ -0,0 +1,106 @@
1
+ import {
2
+ getFormatter,
3
+ getMaxTicksForWidth,
4
+ getSeriesExtent,
5
+ getTimeAxisTickValues,
6
+ guessOptimalNumTicks,
7
+ } from './time-axis';
8
+ import type { useChartDataTransform } from '../../hooks';
9
+ import type { AxisOptions, ChartFormatting, OrientationType, ScaleOptions } from '../../types';
10
+
11
+ type ChartSeries = ReturnType< typeof useChartDataTransform >[ number ];
12
+
13
+ type BuildTimeAxisOptionsArgs = {
14
+ dataSorted: ReturnType< typeof useChartDataTransform >;
15
+ width: number;
16
+ axisOptions?: AxisOptions;
17
+ scaleDomain?: ScaleOptions[ 'domain' ];
18
+ zoomDomain?: [ Date, Date ] | null;
19
+ formatting: ChartFormatting;
20
+ isSeriesRendered?: ( series: ChartSeries ) => boolean;
21
+ };
22
+
23
+ // A numeric domain is a legal `xScale.domain` that d3 coerces for itself, but
24
+ // the helpers below read `Date` methods off its members.
25
+ const toDateDomain = ( domain?: ScaleOptions[ 'domain' ] ): [ Date, Date ] | undefined =>
26
+ domain ? [ new Date( domain[ 0 ] ), new Date( domain[ 1 ] ) ] : undefined;
27
+
28
+ /**
29
+ * The x-axis options for a continuous time chart, shared by LineChart and AreaChart.
30
+ *
31
+ * Kept in one place so the tick-selection logic isn't duplicated between them.
32
+ *
33
+ * @param args - Named arguments.
34
+ * @param args.dataSorted - Series as returned by `useChartDataTransform`.
35
+ * @param args.width - Chart width in pixels, which bounds how many ticks fit.
36
+ * @param args.axisOptions - The caller's `options.axis.x`.
37
+ * @param args.scaleDomain - The caller's `options.xScale.domain`.
38
+ * @param args.zoomDomain - The chart's current zoom window, when zoomed.
39
+ * @param args.formatting - Host locale and time zone.
40
+ * @param args.isSeriesRendered - Whether visx mounts a series, i.e. the legend shows it. Defaults to all of them.
41
+ * @return Options ready to spread into the chart's `axis.x`; `orientation` is always set.
42
+ */
43
+ export const buildTimeAxisOptions = ( {
44
+ dataSorted,
45
+ width,
46
+ axisOptions,
47
+ scaleDomain,
48
+ zoomDomain,
49
+ formatting,
50
+ isSeriesRendered,
51
+ }: BuildTimeAxisOptionsArgs ): AxisOptions & { orientation: OrientationType } => {
52
+ // Every key we resolve ourselves is destructured out: an own-but-undefined
53
+ // key surviving in `rest` would clobber the derived value, as in #51531.
54
+ const {
55
+ tickResolution,
56
+ tickFormat,
57
+ tickValues: callerTickValues,
58
+ numTicks: callerNumTicks,
59
+ orientation: callerOrientation,
60
+ display: callerDisplay,
61
+ ...rest
62
+ } = axisOptions ?? {};
63
+
64
+ // A series visx never mounts is absent from the scale it builds, so a tick
65
+ // chosen from one would be placed outside the domain.
66
+ const rendered = isSeriesRendered ? dataSorted.filter( isSeriesRendered ) : dataSorted;
67
+
68
+ // The same precedence the scale resolves: a zoom window wins, then the
69
+ // caller's domain, then the extent of what is drawn.
70
+ const effectiveDomain = zoomDomain ?? toDateDomain( scaleDomain ) ?? getSeriesExtent( rendered );
71
+
72
+ const ownFormatter = getFormatter( rendered, tickResolution, formatting, effectiveDomain );
73
+ const formatter = tickFormat || ownFormatter;
74
+
75
+ // Only for our own formatter: a caller's tickFormat wasn't written for
76
+ // these values. Mirrors the guard in `use-bar-chart-options.ts`. A caller's
77
+ // own tickValues win below, so selecting ours would be work thrown away.
78
+ const ownTickValues =
79
+ tickFormat || callerTickValues
80
+ ? null
81
+ : getTimeAxisTickValues(
82
+ rendered,
83
+ effectiveDomain,
84
+ ownFormatter,
85
+ callerNumTicks ?? getMaxTicksForWidth( width )
86
+ );
87
+
88
+ // An empty selection means the domain holds no point to tick, not that the
89
+ // axis wants no ticks: visx reads `[]` as the latter and labels nothing.
90
+ const tickValues = callerTickValues ?? ( ownTickValues?.length ? ownTickValues : undefined );
91
+
92
+ // With no tick values to place, visx falls back to a fixed ten whatever the
93
+ // chart's width; count what fits under the formatter in play instead.
94
+ const numTicks =
95
+ callerNumTicks ??
96
+ ( tickValues ? undefined : guessOptimalNumTicks( rendered, width, formatter ) );
97
+
98
+ return {
99
+ orientation: callerOrientation ?? 'bottom',
100
+ tickFormat: formatter,
101
+ display: callerDisplay ?? true,
102
+ ...rest,
103
+ ...( tickValues ? { tickValues } : {} ),
104
+ ...( numTicks === undefined ? {} : { numTicks } ),
105
+ };
106
+ };