@automattic/charts 2.0.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.
@@ -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 );
@@ -1,4 +1,4 @@
1
- import { getFormatter } from '../time-axis';
1
+ import { getBandTickValues, getBucketResolution, getFormatter } from '../time-axis';
2
2
  import type { useChartDataTransform } from '../../../hooks';
3
3
 
4
4
  type SortedData = ReturnType< typeof useChartDataTransform >;
@@ -17,7 +17,7 @@ const hourlyDates = ( start: Date, hours: number ): Date[] =>
17
17
  const dailyDates = ( start: Date, days: number ): Date[] =>
18
18
  Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
19
19
 
20
- // TZ is pinned to UTC by the test script, so local formatting is deterministic.
20
+ // The test script pins TZ and locale, so local formatting is deterministic.
21
21
  describe( 'getFormatter', () => {
22
22
  it( 'uses hour ticks within a single day', () => {
23
23
  const formatter = getFormatter(
@@ -124,6 +124,46 @@ describe( 'getFormatter', () => {
124
124
  expect( yearly( new Date( '2023-01-01T00:00:00' ).getTime() ) ).toBe( '2023' );
125
125
  } );
126
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
+
127
167
  describe( 'with an explicit tickResolution', () => {
128
168
  it( 'uses hour ticks for two hourly points a day apart, where spacing would read as daily', () => {
129
169
  const formatter = getFormatter(
@@ -180,3 +220,208 @@ describe( 'getFormatter', () => {
180
220
  } );
181
221
  } );
182
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
+ } );
@@ -33,37 +33,86 @@ export const getCurveType = ( type?: CurveType, smoothing?: boolean ): typeof cu
33
33
  }
34
34
  };
35
35
 
36
- const formatYearTick = ( timestamp: number ) => {
37
- const date = new Date( timestamp );
38
- return date.toLocaleDateString( undefined, { year: 'numeric' } );
36
+ // Built once and reused: these run per bucket over the whole band domain when
37
+ // the axis picks its tick values, and `toLocaleDateString` rebuilds its
38
+ // formatter on every call once an options object is passed.
39
+ const dateTimeFormat = ( options: Intl.DateTimeFormatOptions ) => {
40
+ let formatter: Intl.DateTimeFormat;
41
+ return ( timestamp: number ) => {
42
+ // `Intl.DateTimeFormat` throws on an invalid date where the `Date`
43
+ // methods it replaces return "Invalid Date", and charts are expected to
44
+ // render past a bad point rather than fail.
45
+ // visx hands its tick formatter a `Date`, so coerce before the check.
46
+ if ( ! Number.isFinite( Number( timestamp ) ) ) {
47
+ return new Date( timestamp ).toLocaleDateString();
48
+ }
49
+ formatter = formatter ?? new Intl.DateTimeFormat( undefined, options );
50
+ return formatter.format( timestamp );
51
+ };
39
52
  };
40
53
 
41
- const formatDateTick = ( timestamp: number ) => {
42
- const date = new Date( timestamp );
43
- return date.toLocaleDateString( undefined, { month: 'short', day: 'numeric' } );
54
+ const formatYearTick = dateTimeFormat( { year: 'numeric' } );
55
+
56
+ const formatDateTick = dateTimeFormat( { month: 'short', day: 'numeric' } );
57
+
58
+ const formatHourTick = dateTimeFormat( { hour: 'numeric', hour12: true } );
59
+
60
+ const formatMonthTick = dateTimeFormat( { month: 'short' } );
61
+
62
+ /**
63
+ * A tick format, carrying the boundary test that decides its coarser label.
64
+ *
65
+ * A band scale samples by index, so `getBandTickValues` has to steer ticks onto
66
+ * the boundaries a format prints the date or the year at. Hanging the test off
67
+ * the format that branches on it keeps the two from drifting apart.
68
+ */
69
+ export type TickFormat = ( ( timestamp: number ) => string ) & {
70
+ isAnchor?: ( date: Date ) => boolean;
44
71
  };
45
72
 
46
- const formatHourTick = ( timestamp: number ) => {
47
- const date = new Date( timestamp );
48
- return date.toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
73
+ const boundaryFormat = (
74
+ isAnchor: ( date: Date ) => boolean,
75
+ atBoundary: ( timestamp: number ) => string,
76
+ between: ( timestamp: number ) => string
77
+ ): TickFormat => {
78
+ const format: TickFormat = ( timestamp: number ) =>
79
+ isAnchor( new Date( timestamp ) ) ? atBoundary( timestamp ) : between( timestamp );
80
+ format.isAnchor = isAnchor;
81
+ return format;
49
82
  };
50
83
 
51
84
  // Hour ticks with the date at midnight boundaries, so multi-day spans of
52
85
  // sub-daily data keep their days identifiable.
53
- const formatDateOrHourTick = ( timestamp: number ) => {
54
- const date = new Date( timestamp );
55
- return date.getHours() === 0 && date.getMinutes() === 0
56
- ? formatDateTick( timestamp )
57
- : formatHourTick( timestamp );
58
- };
86
+ const formatDateOrHourTick = boundaryFormat(
87
+ date => date.getHours() === 0 && date.getMinutes() === 0,
88
+ formatDateTick,
89
+ formatHourTick
90
+ );
59
91
 
60
92
  // Month ticks with the year at January boundaries, for month-or-coarser
61
93
  // buckets where a full "Sep 1" date would misread as a daily point.
62
- const formatMonthOrYearTick = ( timestamp: number ) => {
63
- const date = new Date( timestamp );
64
- return date.getMonth() === 0
65
- ? formatYearTick( timestamp )
66
- : date.toLocaleDateString( undefined, { month: 'short' } );
94
+ const formatMonthOrYearTick = boundaryFormat(
95
+ date => date.getMonth() === 0,
96
+ formatYearTick,
97
+ formatMonthTick
98
+ );
99
+
100
+ // Overall time span of the data. Series with no dated points are dropped rather
101
+ // than folded in: an empty comparison series is legitimate, and one undefined
102
+ // bound would turn the whole span into NaN. Null when nothing is dated.
103
+ const getSpan = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
104
+ const bounds = sortedData
105
+ .map( datom => [ datom.data.at( 0 )?.date, datom.data.at( -1 )?.date ] )
106
+ .filter( ( [ first, last ] ) => first !== undefined && last !== undefined );
107
+
108
+ if ( ! bounds.length ) {
109
+ return null;
110
+ }
111
+
112
+ return {
113
+ minX: Math.min( ...bounds.map( ( [ first ] ) => Number( first ) ) ),
114
+ maxX: Math.max( ...bounds.map( ( [ , last ] ) => Number( last ) ) ),
115
+ };
67
116
  };
68
117
 
69
118
  // Smallest interval between consecutive points across all series, in hours.
@@ -85,64 +134,201 @@ const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTran
85
134
  );
86
135
  };
87
136
 
88
- // Nominal point spacing for a caller-declared bucket resolution. A month maps
89
- // to the shortest month so the month-or-coarser regime engages (see
90
- // getFormatter). Year is absent: it short-circuits to year ticks before the
91
- // spacing regimes run.
92
- const SPACING_BY_RESOLUTION: Record< Exclude< TickResolution, 'year' >, number > = {
93
- hour: 1,
94
- day: 24,
95
- week: 24 * 7,
96
- month: 28 * 24,
137
+ // 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
138
+ // spring-forward DST transition.
139
+ const SUB_DAILY_SPACING_HOURS = 23;
140
+
141
+ // The shortest month, so monthly buckets clear it but weekly ones don't.
142
+ const MONTHLY_SPACING_HOURS = 28 * 24;
143
+
144
+ // The bucket size behind the data, as the resolution label the tick formats are
145
+ // keyed on. Exported so consumers that label a single point — bar chart
146
+ // tooltips — read the same classification the axis does instead of inferring
147
+ // the resolution a second way. Weeks report as 'day': both are calendar-date
148
+ // buckets as far as labelling goes.
149
+ export const getBucketResolution = (
150
+ sortedData: ReturnType< typeof useChartDataTransform >,
151
+ tickResolution?: TickResolution
152
+ ): Exclude< TickResolution, 'week' > => {
153
+ if ( tickResolution ) {
154
+ return tickResolution === 'week' ? 'day' : tickResolution;
155
+ }
156
+
157
+ const spacingInHours = getPointSpacingInHours( sortedData );
158
+ if ( spacingInHours < SUB_DAILY_SPACING_HOURS ) {
159
+ return 'hour';
160
+ }
161
+ // Fewer than two points leaves the spacing unknowable, so fall back to
162
+ // calendar dates rather than reading Infinity as a very coarse bucket.
163
+ if ( ! Number.isFinite( spacingInHours ) || spacingInHours < MONTHLY_SPACING_HOURS ) {
164
+ return 'day';
165
+ }
166
+ // Twelve shortest months: above any monthly gap (31 days at most) and below
167
+ // any yearly one (365 days at least).
168
+ return spacingInHours < 12 * MONTHLY_SPACING_HOURS ? 'month' : 'year';
97
169
  };
98
170
 
99
- // Pick the most informative tick formatter for the data's resolution and time
171
+ // Pick the most informative tick formatter for the data's bucket resolution and
172
+ // time span. Month-or-coarser buckets are formatted from the resolution alone —
173
+ // they carry no day to print at any span — while finer buckets narrow with the
100
174
  // span: hours within a day, hours with day boundaries for sub-daily data
101
- // spanning up to a week, calendar dates for daily-or-finer buckets within a
102
- // year, months (with the year at January) for month-or-coarser buckets,
103
- // otherwise just years. The resolution comes from `tickResolution` when the
104
- // caller knows it, and is inferred from point spacing otherwise.
175
+ // spanning up to a week, calendar dates within a year, otherwise years.
105
176
  export const getFormatter = (
106
177
  sortedData: ReturnType< typeof useChartDataTransform >,
107
178
  tickResolution?: TickResolution
108
- ) => {
179
+ ): TickFormat => {
180
+ const resolution = getBucketResolution( sortedData, tickResolution );
181
+
109
182
  // The month regime only prints the year at January boundaries, so yearly
110
- // buckets starting mid-year would render as month names; year ticks are
111
- // correct for yearly buckets at any span.
112
- if ( tickResolution === 'year' ) {
183
+ // buckets starting mid-year would render as bare month names.
184
+ if ( resolution === 'year' ) {
113
185
  return formatYearTick;
114
186
  }
115
187
 
116
- const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
117
- const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
188
+ if ( resolution === 'month' ) {
189
+ return formatMonthOrYearTick;
190
+ }
191
+
192
+ const span = getSpan( sortedData );
193
+ if ( ! span ) {
194
+ return formatDateTick;
195
+ }
196
+
197
+ const diffInHours = Math.abs( differenceInHours( span.maxX, span.minX ) );
198
+ if ( resolution === 'hour' ) {
199
+ if ( diffInHours <= 24 ) {
200
+ return formatHourTick;
201
+ }
202
+ if ( diffInHours <= 24 * 7 ) {
203
+ return formatDateOrHourTick;
204
+ }
205
+ }
118
206
 
119
- const spacingInHours = tickResolution
120
- ? SPACING_BY_RESOLUTION[ tickResolution ]
121
- : getPointSpacingInHours( sortedData );
122
- // 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
123
- // spring-forward DST transition.
124
- const isSubDaily = spacingInHours < 23;
207
+ // Beyond a year, dates repeat often enough under tick sampling that the year
208
+ // is the only part worth printing.
209
+ return Math.abs( differenceInYears( span.maxX, span.minX ) ) <= 1
210
+ ? formatDateTick
211
+ : formatYearTick;
212
+ };
213
+
214
+ // Indices of the buckets whose label carries the coarser unit.
215
+ const getAnchorIndices = ( domain: Date[], isAnchor: ( date: Date ) => boolean ) =>
216
+ domain.reduce< number[] >( ( indices, date, index ) => {
217
+ if ( isAnchor( date ) ) {
218
+ indices.push( index );
219
+ }
220
+ return indices;
221
+ }, [] );
125
222
 
126
- const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
127
- if ( diffInHours <= 24 && isSubDaily ) {
128
- return formatHourTick;
223
+ // Index strides worth sampling the domain at. Both roundings of each tick count
224
+ // are needed: flooring alone skips the stride that fits a mid-series anchor on
225
+ // short domains. Anchors add the strides that can land on more than one of them
226
+ // — those dividing their spacing, and the smallest whole multiple that still
227
+ // fits, which carries spans too long for any divisor to reach.
228
+ const getCandidateSteps = ( length: number, maxTicks: number, anchorSpacing: number | null ) => {
229
+ const steps = new Set< number >();
230
+ for ( let count = maxTicks; count > 1; count-- ) {
231
+ steps.add( Math.max( 1, Math.floor( ( length - 1 ) / ( count - 1 ) ) ) );
232
+ steps.add( Math.max( 1, Math.ceil( ( length - 1 ) / ( count - 1 ) ) ) );
129
233
  }
130
234
 
131
- if ( diffInHours <= 24 * 7 && isSubDaily ) {
132
- return formatDateOrHourTick;
235
+ if ( anchorSpacing ) {
236
+ for ( let divisor = 1; divisor <= anchorSpacing; divisor++ ) {
237
+ if ( anchorSpacing % divisor === 0 ) {
238
+ steps.add( anchorSpacing / divisor );
239
+ }
240
+ }
241
+ const minStep = Math.floor( ( length - 1 ) / Math.max( 1, maxTicks ) ) + 1;
242
+ steps.add( Math.ceil( minStep / anchorSpacing ) * anchorSpacing );
133
243
  }
134
244
 
135
- const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
136
- if ( diffInYears <= 1 ) {
137
- // 28 days: the shortest month, so monthly buckets qualify but weekly
138
- // don't. The finiteness check keeps all-single-point series — whose
139
- // resolution is unknowable — on date ticks.
140
- return Number.isFinite( spacingInHours ) && spacingInHours >= 28 * 24
141
- ? formatMonthOrYearTick
142
- : formatDateTick;
245
+ return steps;
246
+ };
247
+
248
+ /**
249
+ * Tick values for a band time axis.
250
+ *
251
+ * visx samples a band domain by index from offset zero, blind to which labels
252
+ * carry a boundary and without collapsing repeats — so the tick that prints the
253
+ * year or the date often isn't sampled at all, and a long series can show the
254
+ * same label twice. Choose the values instead: sweep the evenly spaced
255
+ * candidates, including those that step from anchor to anchor, and keep the one
256
+ * that reaches the most anchors without thinning the axis or putting two
257
+ * identical labels side by side.
258
+ *
259
+ * @param domain - Band domain, in axis order.
260
+ * @param tickFormatter - Formatter the axis will render these values with.
261
+ * @param maxTicks - Most ticks the axis should carry.
262
+ * @return Values to hand the axis as `tickValues`.
263
+ */
264
+ export const getBandTickValues = (
265
+ domain: Date[],
266
+ tickFormatter: TickFormat,
267
+ maxTicks: number
268
+ ): Date[] => {
269
+ if ( ! domain.length ) {
270
+ return [];
143
271
  }
144
272
 
145
- return formatYearTick;
273
+ const { isAnchor } = tickFormatter;
274
+ const anchorIndices = isAnchor ? getAnchorIndices( domain, isAnchor ) : [];
275
+
276
+ // Once per bucket rather than once per bucket per candidate: the sweep reads
277
+ // the same labels back for every step and offset.
278
+ const domainLabels = domain.map( date => tickFormatter( date.getTime() ) );
279
+
280
+ const candidates: number[][] = [];
281
+ const consider = ( indices: number[] ) => {
282
+ if ( ! indices.length || indices.length > maxTicks ) {
283
+ return;
284
+ }
285
+ const repeats = indices.some(
286
+ ( index, position ) =>
287
+ position > 0 && domainLabels[ index ] === domainLabels[ indices[ position - 1 ] ]
288
+ );
289
+ if ( ! repeats ) {
290
+ candidates.push( indices );
291
+ }
292
+ };
293
+
294
+ const anchorSpacing = anchorIndices.length > 1 ? anchorIndices[ 1 ] - anchorIndices[ 0 ] : null;
295
+ for ( const step of getCandidateSteps( domain.length, maxTicks, anchorSpacing ) ) {
296
+ for ( let offset = 0; offset < step; offset++ ) {
297
+ const indices: number[] = [];
298
+ for ( let index = offset; index < domain.length; index += step ) {
299
+ indices.push( index );
300
+ }
301
+ consider( indices );
302
+ }
303
+ }
304
+
305
+ // Stepping the anchors themselves rather than the domain, so that anchors an
306
+ // uneven number of buckets apart are still all reachable — a wall-clock day
307
+ // is 23 buckets of hourly data across a spring-forward DST transition, and a
308
+ // fixed stride drifts off midnight for the rest of the span.
309
+ for ( let stride = 1; stride <= anchorIndices.length; stride++ ) {
310
+ consider( anchorIndices.filter( ( _, position ) => position % stride === 0 ) );
311
+ }
312
+
313
+ if ( ! candidates.length ) {
314
+ return [ domain[ 0 ] ];
315
+ }
316
+
317
+ // An anchor is worth at most one tick. Ranking anchors above density outright
318
+ // collapsed ordinary monthly axes to two ticks — worse than sampling by index,
319
+ // which at least stayed dense — while ignoring anchors leaves the year unnamed.
320
+ const densest = candidates.reduce( ( most, indices ) => Math.max( most, indices.length ), 0 );
321
+ const anchorsIn = ( indices: number[] ) =>
322
+ isAnchor ? indices.filter( index => isAnchor( domain[ index ] ) ).length : 0;
323
+
324
+ const best = candidates
325
+ .filter( indices => indices.length >= densest - 1 )
326
+ .reduce( ( chosen, indices ) => {
327
+ const gain = anchorsIn( indices ) - anchorsIn( chosen );
328
+ return gain > 0 || ( gain === 0 && indices.length > chosen.length ) ? indices : chosen;
329
+ } );
330
+
331
+ return best.map( index => domain[ index ] );
146
332
  };
147
333
 
148
334
  // Estimate the largest number of x-axis ticks that fit without producing
@@ -153,9 +339,12 @@ export const guessOptimalNumTicks = (
153
339
  chartWidth: number,
154
340
  tickFormatter: ( timestamp: number, index?: number, values?: unknown ) => string
155
341
  ) => {
156
- const minX = Math.min( ...data.map( datom => datom.data.at( 0 )?.date ) );
157
- const maxX = Math.max( ...data.map( datom => datom.data.at( -1 )?.date ) );
158
- const xScale = scaleTime( { domain: [ minX, maxX ] } );
342
+ const span = getSpan( data );
343
+ if ( ! span ) {
344
+ return 1;
345
+ }
346
+
347
+ const xScale = scaleTime( { domain: [ span.minX, span.maxX ] } );
159
348
 
160
349
  const upperBound = Math.min(
161
350
  data[ 0 ]?.data.length || 3,
@@ -188,7 +188,8 @@ describe( 'BaseLegend', () => {
188
188
  );
189
189
  const labels = screen.getAllByText( /Long Label/ );
190
190
  labels.forEach( label => {
191
- expect( label ).toHaveStyle( { maxWidth: '10rem' } );
191
+ // Note getComputedStyle converts lengths to px.
192
+ expect( label ).toHaveStyle( { maxWidth: '160px' } );
192
193
  } );
193
194
  } );
194
195