@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.
- package/CHANGELOG.md +12 -0
- package/dist/index.cjs +292 -101
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +292 -101
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/area-chart/area-chart.tsx +3 -5
- package/src/charts/bar-chart/bar-chart.tsx +24 -15
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +197 -30
- package/src/charts/bar-chart/test/bar-chart.test.tsx +685 -1
- package/src/charts/line-chart/line-chart.tsx +3 -5
- package/src/charts/private/test/time-axis.test.ts +247 -2
- package/src/charts/private/time-axis.ts +252 -63
- package/src/components/legend/test/legend.test.tsx +2 -1
- package/src/components/trend-indicator/test/trend-indicator.test.tsx +2 -1
- package/src/hooks/test/use-chart-margin.test.tsx +60 -0
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +83 -0
- package/src/hooks/use-chart-margin.tsx +3 -4
- package/src/hooks/use-xychart-theme.ts +17 -11
- package/src/types.ts +6 -6
- package/src/utils/index.ts +1 -1
- package/src/utils/resolve-css-var.ts +69 -28
- package/src/utils/test/create-css-variable-resolver.test.ts +83 -0
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
useGlobalChartsContext,
|
|
30
30
|
useGlobalChartsTheme,
|
|
31
31
|
} from '../../providers';
|
|
32
|
-
import { attachSubComponents
|
|
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
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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 = (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 = (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 (
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
191
|
+
// Note getComputedStyle converts lengths to px.
|
|
192
|
+
expect( label ).toHaveStyle( { maxWidth: '160px' } );
|
|
192
193
|
} );
|
|
193
194
|
} );
|
|
194
195
|
|