@automattic/charts 1.11.0 → 2.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 (39) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/TOKENS.md +19 -1
  3. package/dist/index.cjs +2196 -2711
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +23 -43
  6. package/dist/index.d.cts +72 -6
  7. package/dist/index.d.ts +72 -6
  8. package/dist/index.js +278 -792
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -4
  11. package/src/charts/area-chart/area-chart.tsx +31 -10
  12. package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
  13. package/src/charts/area-chart/types.ts +20 -6
  14. package/src/charts/bar-chart/bar-chart.tsx +33 -9
  15. package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
  16. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  17. package/src/charts/line-chart/line-chart.module.scss +3 -3
  18. package/src/charts/line-chart/line-chart.tsx +43 -7
  19. package/src/charts/line-chart/test/line-chart.test.tsx +234 -5
  20. package/src/charts/line-chart/types.ts +14 -0
  21. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
  22. package/src/charts/private/test/time-axis.test.ts +182 -0
  23. package/src/charts/private/time-axis.ts +84 -5
  24. package/src/charts/private/x-zoom/index.ts +1 -0
  25. package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +92 -4
  26. package/src/charts/private/x-zoom/x-zoom.module.scss +35 -0
  27. package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +44 -27
  28. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
  29. package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
  30. package/src/components/legend/private/base-legend.module.scss +1 -1
  31. package/src/components/legend/private/base-legend.tsx +21 -9
  32. package/src/components/legend/test/legend-config-types.test.tsx +32 -0
  33. package/src/components/legend/test/legend.test.tsx +121 -1
  34. package/src/components/legend/types.ts +6 -0
  35. package/src/components/tooltip/base-tooltip.module.scss +1 -1
  36. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  37. package/src/index.ts +2 -0
  38. package/src/types.ts +31 -0
  39. package/src/charts/private/x-zoom.module.scss +0 -50
@@ -179,6 +179,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
179
179
  onPointerMove = undefined,
180
180
  onPointerOut = undefined,
181
181
  zoomable = false,
182
+ rescaleYOnVisibilityChange = true,
182
183
  children,
183
184
  gridVisibility,
184
185
  gap = 'md',
@@ -186,6 +187,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
186
187
  ref
187
188
  ) => {
188
189
  const legendInteractive = legend.interactive ?? false;
190
+ const legendCollapseGroups = legend.collapseGroups ?? false;
189
191
  const legendShape = legend.shape ?? 'line';
190
192
  const legendPosition = legend.position ?? 'bottom';
191
193
 
@@ -252,6 +254,28 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
252
254
  return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
253
255
  }, [ seriesWithVisibility ] );
254
256
 
257
+ // When the interactive legend can hide series and rescaling is opted out, pin the value axis
258
+ // to the full data range so it stays put as series are toggled instead of visx rescaling the
259
+ // domain to whatever is currently visible and making the axis jump. Default is to rescale,
260
+ // matching the pre-existing behaviour and AreaChart's `rescaleYOnVisibilityChange`.
261
+ const stableYDomain = useMemo< [ number, number ] | undefined >( () => {
262
+ if ( ! legendInteractive || rescaleYOnVisibilityChange ) {
263
+ return undefined;
264
+ }
265
+ let min = Infinity;
266
+ let max = -Infinity;
267
+ for ( const series of dataSorted ) {
268
+ for ( const point of series.data ?? [] ) {
269
+ const value = point?.value;
270
+ if ( typeof value === 'number' && Number.isFinite( value ) ) {
271
+ min = Math.min( min, value );
272
+ max = Math.max( max, value );
273
+ }
274
+ }
275
+ }
276
+ return min < max ? [ min, max ] : undefined;
277
+ }, [ legendInteractive, rescaleYOnVisibilityChange, dataSorted ] );
278
+
255
279
  // Use the keyboard navigation hook
256
280
  const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
257
281
  selectedIndex,
@@ -263,7 +287,8 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
263
287
  } );
264
288
 
265
289
  const chartOptions = useMemo( () => {
266
- const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
290
+ const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
291
+ const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
267
292
 
268
293
  return {
269
294
  axis: {
@@ -272,7 +297,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
272
297
  numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
273
298
  tickFormat: formatter,
274
299
  display: true,
275
- ...options?.axis?.x,
300
+ ...xAxisOptions,
276
301
  },
277
302
  y: {
278
303
  orientation: 'left' as const,
@@ -291,10 +316,11 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
291
316
  type: 'linear' as const,
292
317
  nice: true,
293
318
  zero: false,
319
+ ...( stableYDomain ? { domain: stableYDomain } : {} ),
294
320
  ...options?.yScale,
295
321
  },
296
322
  };
297
- }, [ options, dataSorted, width, zoom.domain ] );
323
+ }, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
298
324
 
299
325
  const tooltipRenderGlyph = useMemo( () => {
300
326
  return ( props: GlyphProps< DataPointDate > ) => {
@@ -328,9 +354,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
328
354
  () => ( {
329
355
  withGlyph: withLegendGlyph,
330
356
  glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
357
+ collapseGroups: legendCollapseGroups,
331
358
  renderGlyph,
332
359
  } ),
333
- [ withLegendGlyph, glyphStyle?.radius, renderGlyph ]
360
+ [ withLegendGlyph, glyphStyle?.radius, legendCollapseGroups, renderGlyph ]
334
361
  );
335
362
 
336
363
  // Create legend items using the reusable hook
@@ -444,9 +471,18 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
444
471
  onPointerOut={ onPointerOut }
445
472
  pointerEventsDataKey="nearest"
446
473
  >
447
- { gridVisibility !== 'none' && <Grid columns={ false } numTicks={ 4 } /> }
448
- { chartOptions.axis.x.display && <Axis { ...chartOptions.axis.x } /> }
449
- { chartOptions.axis.y.display && <Axis { ...chartOptions.axis.y } /> }
474
+ { /* With every series hidden there is no data to scale against, so the grid and
475
+ axes are dropped while the empty state stands in — otherwise they render
476
+ squished at the top. */ }
477
+ { ! allSeriesHidden && gridVisibility !== 'none' && (
478
+ <Grid columns={ false } numTicks={ 4 } />
479
+ ) }
480
+ { ! allSeriesHidden && chartOptions.axis.x.display && (
481
+ <Axis { ...chartOptions.axis.x } />
482
+ ) }
483
+ { ! allSeriesHidden && chartOptions.axis.y.display && (
484
+ <Axis { ...chartOptions.axis.y } />
485
+ ) }
450
486
 
451
487
  { allSeriesHidden ? (
452
488
  <SvgEmptyState
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
2
 
3
- import { render, screen, waitFor } from '@testing-library/react';
3
+ import { render, screen, waitFor, within } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { GlyphDiamond } from '@visx/glyph';
6
6
  import { createElement, createRef } from 'react';
@@ -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
 
@@ -1294,4 +1334,193 @@ describe( 'LineChart', () => {
1294
1334
  'url(#chart-zoom-clip-zoomtest)'
1295
1335
  );
1296
1336
  } );
1337
+
1338
+ describe( 'Legend group collapsing', () => {
1339
+ const comparisonPair = [
1340
+ {
1341
+ label: 'Views',
1342
+ group: 'views',
1343
+ data: [
1344
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1345
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1346
+ ],
1347
+ },
1348
+ {
1349
+ label: 'Views — previous',
1350
+ group: 'views',
1351
+ options: { type: 'comparison' as const },
1352
+ data: [
1353
+ { date: new Date( '2024-01-01' ), value: 8, label: 'Jan 1' },
1354
+ { date: new Date( '2024-01-02' ), value: 16, label: 'Jan 2' },
1355
+ ],
1356
+ },
1357
+ ];
1358
+
1359
+ it( 'renders one legend item per series by default', () => {
1360
+ renderWithTheme( {
1361
+ showLegend: true,
1362
+ chartId: 'legend-groups-default',
1363
+ data: comparisonPair,
1364
+ } );
1365
+
1366
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
1367
+ expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
1368
+ } );
1369
+
1370
+ it( 'collapses a group to one item when legend.collapseGroups is set', () => {
1371
+ renderWithTheme( {
1372
+ showLegend: true,
1373
+ legend: { collapseGroups: true },
1374
+ chartId: 'legend-groups-collapsed',
1375
+ data: comparisonPair,
1376
+ } );
1377
+
1378
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
1379
+ expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
1380
+ } );
1381
+
1382
+ it( 'toggles only the clicked series when interactive without collapseGroups', async () => {
1383
+ const user = userEvent.setup();
1384
+
1385
+ renderWithTheme( {
1386
+ showLegend: true,
1387
+ legend: { interactive: true },
1388
+ chartId: 'legend-groups-interactive',
1389
+ data: comparisonPair,
1390
+ } );
1391
+
1392
+ const buttons = screen.getAllByRole( 'button' );
1393
+ await user.click( buttons[ 0 ] );
1394
+
1395
+ expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1396
+ expect( buttons[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1397
+ } );
1398
+
1399
+ it( 'toggles the whole group when interactive with collapseGroups', async () => {
1400
+ const user = userEvent.setup();
1401
+
1402
+ renderWithTheme( {
1403
+ showLegend: true,
1404
+ legend: { interactive: true, collapseGroups: true },
1405
+ chartId: 'legend-groups-interactive-collapsed',
1406
+ data: comparisonPair,
1407
+ } );
1408
+
1409
+ const buttons = screen.getAllByRole( 'button' );
1410
+ expect( buttons ).toHaveLength( 1 );
1411
+
1412
+ await user.click( buttons[ 0 ] );
1413
+
1414
+ expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1415
+ expect(
1416
+ screen.getByText( /all series are hidden.*click legend items to show data/i )
1417
+ ).toBeInTheDocument();
1418
+ } );
1419
+ } );
1420
+
1421
+ describe( 'Interactive Legend — value axis', () => {
1422
+ const twoSeries = [
1423
+ {
1424
+ label: 'Views',
1425
+ data: [
1426
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1427
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1428
+ ],
1429
+ options: {},
1430
+ },
1431
+ {
1432
+ label: 'Visitors',
1433
+ data: [
1434
+ { date: new Date( '2024-01-01' ), value: 100, label: 'Jan 1' },
1435
+ { date: new Date( '2024-01-02' ), value: 200, label: 'Jan 2' },
1436
+ ],
1437
+ options: {},
1438
+ },
1439
+ ];
1440
+ // Bare numbers inside the plot (scoped via the chart's grid role, away from visx's
1441
+ // off-screen text-measurement SVGs) are value-axis ticks: the series labels carry no digits
1442
+ // and the empty-state message is text-only.
1443
+ const numericTick = /^[\d,]+$/;
1444
+
1445
+ it( 'pins the value axis when rescaleYOnVisibilityChange is false', async () => {
1446
+ const user = userEvent.setup();
1447
+
1448
+ renderWithTheme( {
1449
+ showLegend: true,
1450
+ legend: { interactive: true },
1451
+ rescaleYOnVisibilityChange: false,
1452
+ chartId: 'line-stable-axis',
1453
+ data: twoSeries,
1454
+ } );
1455
+
1456
+ const chart = screen.getByRole( 'grid' );
1457
+ const ticksBefore = within( chart )
1458
+ .getAllByText( numericTick )
1459
+ .map( el => el.textContent )
1460
+ .sort();
1461
+ expect( ticksBefore.length ).toBeGreaterThan( 0 );
1462
+
1463
+ // Hide the high-range Visitors series; with a pinned domain the axis keeps its ticks.
1464
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1465
+
1466
+ const ticksAfter = within( chart )
1467
+ .getAllByText( numericTick )
1468
+ .map( el => el.textContent )
1469
+ .sort();
1470
+ expect( ticksAfter ).toEqual( ticksBefore );
1471
+ } );
1472
+
1473
+ it( 'rescales the value axis by default when a series is toggled off', async () => {
1474
+ const user = userEvent.setup();
1475
+
1476
+ renderWithTheme( {
1477
+ showLegend: true,
1478
+ legend: { interactive: true },
1479
+ chartId: 'line-rescale-axis',
1480
+ data: twoSeries,
1481
+ } );
1482
+
1483
+ const chart = screen.getByRole( 'grid' );
1484
+ const ticksBefore = within( chart )
1485
+ .getAllByText( numericTick )
1486
+ .map( el => el.textContent )
1487
+ .sort();
1488
+ expect( ticksBefore.length ).toBeGreaterThan( 0 );
1489
+
1490
+ // Default preserves the pre-existing behaviour: hiding the high-range Visitors series
1491
+ // lets the axis rescale to the remaining Views range, so its ticks change.
1492
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1493
+
1494
+ const ticksAfter = within( chart )
1495
+ .getAllByText( numericTick )
1496
+ .map( el => el.textContent )
1497
+ .sort();
1498
+ expect( ticksAfter ).not.toEqual( ticksBefore );
1499
+ } );
1500
+
1501
+ it( 'drops the axes when all series are hidden so they do not collapse', async () => {
1502
+ const user = userEvent.setup();
1503
+
1504
+ renderWithTheme( {
1505
+ showLegend: true,
1506
+ legend: { interactive: true },
1507
+ chartId: 'line-hidden-axes',
1508
+ data: twoSeries,
1509
+ } );
1510
+
1511
+ const chart = screen.getByRole( 'grid' );
1512
+ expect( within( chart ).getAllByText( numericTick ).length ).toBeGreaterThan( 0 );
1513
+
1514
+ const buttons = screen.getAllByRole( 'button' );
1515
+ await user.click( buttons[ 0 ] );
1516
+ await user.click( buttons[ 1 ] );
1517
+
1518
+ // With no visible data the value scale would collapse, so the axes are removed rather
1519
+ // than rendered squished at the top — no tick labels remain.
1520
+ expect( within( chart ).queryAllByText( numericTick ) ).toHaveLength( 0 );
1521
+ expect(
1522
+ screen.getByText( /all series are hidden.*click legend items to show data/i )
1523
+ ).toBeInTheDocument();
1524
+ } );
1525
+ } );
1297
1526
  } );
@@ -2,6 +2,7 @@ import type {
2
2
  BaseChartProps,
3
3
  DataPointDate,
4
4
  SeriesData,
5
+ SeriesChartLegendConfig,
5
6
  AnnotationStyles,
6
7
  DataPoint,
7
8
  } from '../../types';
@@ -28,6 +29,10 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
28
29
  };
29
30
 
30
31
  export interface LineChartProps extends BaseChartProps< SeriesData[] > {
32
+ /**
33
+ * Legend configuration. Supports `collapseGroups` on top of the shared options.
34
+ */
35
+ legend?: SeriesChartLegendConfig;
31
36
  withGradientFill: boolean;
32
37
  smoothing?: boolean;
33
38
  curveType?: CurveType;
@@ -47,6 +52,15 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
47
52
  * button appears in the top-right of the chart while zoomed.
48
53
  */
49
54
  zoomable?: boolean;
55
+ /**
56
+ * Whether the Y axis rescales to fit only the visible series when series are
57
+ * hidden or shown through the interactive legend.
58
+ * Defaults to `true` (the pre-existing behaviour). Set to `false` to pin the Y
59
+ * axis to the full data extent so hiding series does not move the chart's
60
+ * baseline — useful for comparison charts. Matches `AreaChart`.
61
+ * @default true
62
+ */
63
+ rescaleYOnVisibilityChange?: boolean;
50
64
  children?: ReactNode;
51
65
  }
52
66
 
@@ -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,182 @@
1
+ import { 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
+ // TZ is pinned to UTC by the test script, 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
+ describe( 'with an explicit tickResolution', () => {
128
+ it( 'uses hour ticks for two hourly points a day apart, where spacing would read as daily', () => {
129
+ const formatter = getFormatter(
130
+ toSeries( [ new Date( '2026-08-01T00:00:00' ), new Date( '2026-08-02T00:00:00' ) ] ),
131
+ 'hour'
132
+ );
133
+
134
+ expect( formatter( new Date( '2026-08-01T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
135
+ } );
136
+
137
+ it( 'uses date ticks for a lone daily point, where spacing is unknowable', () => {
138
+ const formatter = getFormatter( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ), 'day' );
139
+
140
+ expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
141
+ } );
142
+
143
+ it( 'uses month ticks for monthly buckets regardless of their measured gaps', () => {
144
+ const formatter = getFormatter(
145
+ toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] ),
146
+ 'month'
147
+ );
148
+
149
+ expect( formatter( new Date( '2026-03-01T00:00:00' ).getTime() ) ).toBe( 'Mar' );
150
+ } );
151
+
152
+ it( 'uses date ticks for weekly buckets', () => {
153
+ const weeklyDates = Array.from(
154
+ { length: 8 },
155
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
156
+ );
157
+ const formatter = getFormatter( toSeries( weeklyDates ), 'week' );
158
+
159
+ expect( formatter( new Date( '2026-01-12T00:00:00' ).getTime() ) ).toMatch( /Jan 12/ );
160
+ } );
161
+
162
+ it( 'uses year ticks for a pair of yearly buckets', () => {
163
+ const formatter = getFormatter(
164
+ toSeries( [ new Date( '2025-01-01T00:00:00' ), new Date( '2026-01-01T00:00:00' ) ] ),
165
+ 'year'
166
+ );
167
+
168
+ expect( formatter( new Date( '2025-01-01T00:00:00' ).getTime() ) ).toBe( '2025' );
169
+ expect( formatter( new Date( '2026-01-01T00:00:00' ).getTime() ) ).toBe( '2026' );
170
+ } );
171
+
172
+ it( 'uses year ticks for yearly buckets that do not start in January', () => {
173
+ const formatter = getFormatter(
174
+ toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] ),
175
+ 'year'
176
+ );
177
+
178
+ expect( formatter( new Date( '2025-06-01T00:00:00' ).getTime() ) ).toBe( '2025' );
179
+ expect( formatter( new Date( '2026-06-01T00:00:00' ).getTime() ) ).toBe( '2026' );
180
+ } );
181
+ } );
182
+ } );