@automattic/charts 3.2.0 → 4.1.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.
- package/CHANGELOG.md +47 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +53 -58
- package/dist/index.cjs +1483 -664
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +93 -29
- package/dist/index.d.cts +233 -247
- package/dist/index.d.ts +233 -247
- package/dist/index.js +1484 -667
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/charts/area-chart/area-chart.tsx +39 -25
- package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
- package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
- package/src/charts/area-chart/types.ts +4 -7
- package/src/charts/bar-chart/bar-chart.tsx +2 -1
- package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
- package/src/charts/geo-chart/geo-chart.module.scss +1 -0
- package/src/charts/geo-chart/geo-chart.tsx +5 -10
- package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
- package/src/charts/heatmap-chart/index.ts +2 -1
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/private/index.ts +1 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
- package/src/charts/heatmap-chart/types.ts +8 -2
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
- package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +128 -26
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
- package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +17 -3
- package/src/charts/pie-chart/pie-chart.module.scss +13 -1
- package/src/charts/pie-chart/pie-chart.tsx +30 -40
- package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
- package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/axis-formatting.test.tsx +85 -0
- package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
- package/src/charts/private/test/time-axis-options.test.ts +267 -0
- package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
- package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
- package/src/charts/private/test/time-axis.test.ts +9 -69
- package/src/charts/private/time-axis-options.ts +106 -0
- package/src/charts/private/time-axis.ts +318 -136
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
- package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +21 -17
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
- package/src/hooks/test/use-chart-margin.test.tsx +173 -3
- package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
- package/src/hooks/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/hooks/use-xychart-theme.ts +25 -9
- package/src/index.ts +8 -0
- package/src/providers/chart-context/global-charts-provider.tsx +37 -38
- package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
- package/src/providers/chart-context/index.ts +1 -0
- package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
- package/src/providers/chart-context/private/series-palette.ts +3 -13
- package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
- package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
- package/src/providers/chart-context/themes.ts +7 -52
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +39 -62
- package/src/styles/test/chart-scope.test.ts +19 -47
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +79 -166
- package/src/utils/bucket-info.ts +87 -0
- package/src/utils/color-utils.ts +0 -27
- package/src/utils/date-formatting.ts +157 -0
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/test/bucket-info.test.ts +185 -0
- package/src/utils/test/color-utils.test.ts +1 -48
- package/src/utils/test/date-formatting.test.ts +89 -0
- package/src/utils/test/date-parsing.auckland.test.ts +6 -0
- package/src/utils/test/date-parsing.santiago.test.ts +6 -0
- package/src/utils/test/date-parsing.test.ts +16 -1
- package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/utils/warn-once.ts +23 -0
- package/src/visx/types.ts +56 -5
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
- package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
- package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
- package/src/styles/test/wpds-fallbacks.test.ts +0 -131
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { getBandTickValues, getFormatter } from '../time-axis';
|
|
5
|
+
import type { useChartDataTransform } from '../../../hooks';
|
|
6
|
+
|
|
7
|
+
type SortedData = ReturnType< typeof useChartDataTransform >;
|
|
8
|
+
|
|
9
|
+
const toSeries = ( dates: Date[] ): SortedData =>
|
|
10
|
+
[
|
|
11
|
+
{
|
|
12
|
+
label: 'series',
|
|
13
|
+
data: dates.map( date => ( { date, value: 1 } ) ),
|
|
14
|
+
},
|
|
15
|
+
] as unknown as SortedData;
|
|
16
|
+
|
|
17
|
+
// Tokyo has no DST, so its midnight is a fixed nine hours before UTC midnight.
|
|
18
|
+
const tokyoMidnight = ( year: number, month: number, day = 1 ) =>
|
|
19
|
+
new Date( Date.UTC( year, month, day ) - 9 * 60 * 60 * 1000 );
|
|
20
|
+
|
|
21
|
+
const every = ( start: Date, count: number, hours: number ): Date[] =>
|
|
22
|
+
Array.from(
|
|
23
|
+
{ length: count },
|
|
24
|
+
( _, i ) => new Date( start.getTime() + i * hours * 60 * 60 * 1000 )
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// A formatter that ignores its context reads Los Angeles here, and the instants
|
|
28
|
+
// below land on a different calendar day there than in Tokyo. The runtime locale
|
|
29
|
+
// stays en-US, so a German label can only come from the supplied context.
|
|
30
|
+
|
|
31
|
+
describe( 'getFormatter with a host formatting context', () => {
|
|
32
|
+
const daily = toSeries( every( new Date( '2026-08-02T12:00:00Z' ), 14, 24 ) );
|
|
33
|
+
|
|
34
|
+
it( 'labels date ticks in the supplied locale', () => {
|
|
35
|
+
const format = getFormatter( daily, undefined, { locale: 'de-DE' } );
|
|
36
|
+
|
|
37
|
+
expect( format( Date.parse( '2026-08-02T12:00:00Z' ) ) ).toBe( '2. Aug.' );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
it( "labels date ticks on the supplied zone's calendar day", () => {
|
|
41
|
+
const format = getFormatter( daily, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
42
|
+
|
|
43
|
+
// 00:30 on Aug 3 in Tokyo, still Aug 2 in the runtime zone.
|
|
44
|
+
expect( format( Date.parse( '2026-08-02T15:30:00Z' ) ) ).toBe( 'Aug 3' );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
// Aug 3 in UTC and Aug 2 in the pinned zone, so this fails without the
|
|
48
|
+
// environment rather than passing under the script's own `TZ=UTC`.
|
|
49
|
+
it( 'falls back to the runtime locale and zone when given no context', () => {
|
|
50
|
+
expect( getFormatter( daily )( Date.parse( '2026-08-03T03:00:00Z' ) ) ).toBe( 'Aug 2' );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( "anchors the date label to the supplied zone's first hour of the day", () => {
|
|
54
|
+
const hourly = toSeries( every( tokyoMidnight( 2026, 7, 1 ), 72, 1 ) );
|
|
55
|
+
const format = getFormatter( hourly, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
56
|
+
|
|
57
|
+
// Midnight in Tokyo, 08:00 the previous day in the runtime zone.
|
|
58
|
+
expect( format( tokyoMidnight( 2026, 7, 2 ).getTime() ) ).toBe( 'Aug 2' );
|
|
59
|
+
// Midnight in the runtime zone, 16:00 in Tokyo.
|
|
60
|
+
expect( format( Date.parse( '2026-08-02T07:00:00Z' ) ) ).toBe( '4 PM' );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
// The boundary test runs before the label formatter, so its own guard is the
|
|
64
|
+
// only thing standing between a bad point and a throw out of `formatToParts`.
|
|
65
|
+
it( 'renders past an invalid date on a boundary format', () => {
|
|
66
|
+
const hourly = toSeries( every( tokyoMidnight( 2026, 7, 1 ), 72, 1 ) );
|
|
67
|
+
const format = getFormatter( hourly, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
68
|
+
|
|
69
|
+
expect( format( NaN ) ).toBe( 'Invalid Date' );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
it( 'anchors the year label to January in the supplied zone', () => {
|
|
73
|
+
const monthly = toSeries(
|
|
74
|
+
Array.from( { length: 12 }, ( _, i ) => tokyoMidnight( 2025, 7 + i ) )
|
|
75
|
+
);
|
|
76
|
+
const format = getFormatter( monthly, undefined, { timeZone: 'Asia/Tokyo' } );
|
|
77
|
+
|
|
78
|
+
// January 1 in Tokyo, December 31 in the runtime zone.
|
|
79
|
+
expect( format( tokyoMidnight( 2026, 0 ).getTime() ) ).toBe( '2026' );
|
|
80
|
+
expect( format( tokyoMidnight( 2026, 1 ).getTime() ) ).toBe( 'Feb' );
|
|
81
|
+
} );
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
describe( 'getBandTickValues with a host formatting context', () => {
|
|
85
|
+
// The shape the Stats payload arrives in: buckets aligned to UTC, which a
|
|
86
|
+
// half-hour zone reads at :30, so none of them is ever an exact midnight.
|
|
87
|
+
it( 'dates the day on a half-hour zone, where no bucket reads midnight', () => {
|
|
88
|
+
const domain = every( new Date( '2026-08-01T00:00:00Z' ), 72, 1 );
|
|
89
|
+
const format = getFormatter( toSeries( domain ), undefined, { timeZone: 'Asia/Kolkata' } );
|
|
90
|
+
|
|
91
|
+
const labels = getBandTickValues( domain, format, 4 ).map( date => format( date.getTime() ) );
|
|
92
|
+
|
|
93
|
+
expect( labels ).toEqual( [ 'Aug 2', 'Aug 3', 'Aug 4' ] );
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
it( "puts the year tick on the supplied zone's January bucket", () => {
|
|
97
|
+
const domain = Array.from( { length: 12 }, ( _, i ) => tokyoMidnight( 2025, 7 + i ) );
|
|
98
|
+
const format = getFormatter( toSeries( domain ), undefined, {
|
|
99
|
+
locale: 'de-DE',
|
|
100
|
+
timeZone: 'Asia/Tokyo',
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
const values = getBandTickValues( domain, format, 4 );
|
|
104
|
+
|
|
105
|
+
expect( values.find( date => format( date.getTime() ) === '2026' ) ).toEqual(
|
|
106
|
+
tokyoMidnight( 2026, 0 )
|
|
107
|
+
);
|
|
108
|
+
} );
|
|
109
|
+
} );
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { dailySeries, hourlySeries } from '../../../test-utils/series-fixtures';
|
|
5
|
+
import { buildTimeAxisOptions } from '../time-axis-options';
|
|
6
|
+
|
|
7
|
+
const TOKYO = { timeZone: 'Asia/Tokyo' };
|
|
8
|
+
|
|
9
|
+
const build = ( overrides: Partial< Parameters< typeof buildTimeAxisOptions >[ 0 ] > = {} ) =>
|
|
10
|
+
buildTimeAxisOptions( {
|
|
11
|
+
dataSorted: dailySeries( '2026-08-01T15:00:00Z', 30 ),
|
|
12
|
+
width: 600,
|
|
13
|
+
axisOptions: { tickResolution: 'day' },
|
|
14
|
+
scaleDomain: undefined,
|
|
15
|
+
zoomDomain: undefined,
|
|
16
|
+
formatting: TOKYO,
|
|
17
|
+
...overrides,
|
|
18
|
+
} );
|
|
19
|
+
|
|
20
|
+
describe( 'buildTimeAxisOptions', () => {
|
|
21
|
+
it( 'selects tick values instead of leaving the count to d3', () => {
|
|
22
|
+
const axis = build();
|
|
23
|
+
|
|
24
|
+
expect( Array.isArray( axis.tickValues ) ).toBe( true );
|
|
25
|
+
expect( ( axis.tickValues as Date[] ).length ).toBeGreaterThan( 1 );
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
it( 'confines tick values to the zoom window', () => {
|
|
29
|
+
const zoomDomain: [ Date, Date ] = [
|
|
30
|
+
new Date( '2026-08-10T15:00:00Z' ),
|
|
31
|
+
new Date( '2026-08-14T15:00:00Z' ),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const ticks = build( { zoomDomain } ).tickValues as Date[];
|
|
35
|
+
|
|
36
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
37
|
+
for ( const tick of ticks ) {
|
|
38
|
+
expect( tick.getTime() ).toBeGreaterThanOrEqual( zoomDomain[ 0 ].getTime() );
|
|
39
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( zoomDomain[ 1 ].getTime() );
|
|
40
|
+
}
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
it( 'prefers the zoom window over a caller scale domain', () => {
|
|
44
|
+
const scaleDomain: [ Date, Date ] = [
|
|
45
|
+
new Date( '2026-08-01T15:00:00Z' ),
|
|
46
|
+
new Date( '2026-08-30T15:00:00Z' ),
|
|
47
|
+
];
|
|
48
|
+
const zoomDomain: [ Date, Date ] = [
|
|
49
|
+
new Date( '2026-08-10T15:00:00Z' ),
|
|
50
|
+
new Date( '2026-08-12T15:00:00Z' ),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const ticks = build( { scaleDomain, zoomDomain } ).tickValues as Date[];
|
|
54
|
+
|
|
55
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
56
|
+
for ( const tick of ticks ) {
|
|
57
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( zoomDomain[ 1 ].getTime() );
|
|
58
|
+
}
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
it( 'confines tick values to a caller scale domain when there is no zoom', () => {
|
|
62
|
+
const scaleDomain: [ Date, Date ] = [
|
|
63
|
+
new Date( '2026-08-05T15:00:00Z' ),
|
|
64
|
+
new Date( '2026-08-08T15:00:00Z' ),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const ticks = build( { scaleDomain } ).tickValues as Date[];
|
|
68
|
+
|
|
69
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
70
|
+
for ( const tick of ticks ) {
|
|
71
|
+
expect( tick.getTime() ).toBeGreaterThanOrEqual( scaleDomain[ 0 ].getTime() );
|
|
72
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( scaleDomain[ 1 ].getTime() );
|
|
73
|
+
}
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'caps the selection at a caller numTicks', () => {
|
|
77
|
+
const axis = build( { axisOptions: { tickResolution: 'day', numTicks: 3 } } );
|
|
78
|
+
|
|
79
|
+
expect( ( axis.tickValues as Date[] ).length ).toBeLessThanOrEqual( 3 );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
it( 'caps the selection by width when no numTicks is given', () => {
|
|
83
|
+
const narrow = build( { width: 180 } ).tickValues as Date[];
|
|
84
|
+
const wide = build( { width: 900 } ).tickValues as Date[];
|
|
85
|
+
|
|
86
|
+
expect( narrow.length ).toBeLessThan( wide.length );
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
it( 'selects nothing when the caller supplies its own tickFormat', () => {
|
|
90
|
+
const axis = build( {
|
|
91
|
+
axisOptions: { tickResolution: 'day', tickFormat: () => 'x' },
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
expect( axis.tickValues ).toBeUndefined();
|
|
95
|
+
expect( axis.tickFormat?.( new Date(), 0, [] ) ).toBe( 'x' );
|
|
96
|
+
expect( typeof axis.numTicks ).toBe( 'number' );
|
|
97
|
+
} );
|
|
98
|
+
|
|
99
|
+
it( 'fits a caller tickFormat to the width rather than leaving visx its fixed default', () => {
|
|
100
|
+
const isoDay = ( value: unknown ) => new Date( Number( value ) ).toISOString().slice( 0, 10 );
|
|
101
|
+
|
|
102
|
+
const narrow = build( {
|
|
103
|
+
width: 180,
|
|
104
|
+
axisOptions: { tickResolution: 'day', tickFormat: isoDay },
|
|
105
|
+
} );
|
|
106
|
+
const wide = build( {
|
|
107
|
+
width: 900,
|
|
108
|
+
axisOptions: { tickResolution: 'day', tickFormat: isoDay },
|
|
109
|
+
} );
|
|
110
|
+
|
|
111
|
+
expect( narrow.numTicks ).toBeLessThan( wide.numTicks as number );
|
|
112
|
+
} );
|
|
113
|
+
|
|
114
|
+
it( "keeps a caller's numTicks on the tickFormat branch", () => {
|
|
115
|
+
const axis = build( {
|
|
116
|
+
axisOptions: { tickResolution: 'day', tickFormat: () => 'x', numTicks: 2 },
|
|
117
|
+
} );
|
|
118
|
+
|
|
119
|
+
expect( axis.numTicks ).toBe( 2 );
|
|
120
|
+
} );
|
|
121
|
+
|
|
122
|
+
it( 'narrows the format to the zoom window on a multi-year dataset', () => {
|
|
123
|
+
const axis = build( {
|
|
124
|
+
dataSorted: dailySeries( '2023-01-01T15:00:00Z', 1100 ),
|
|
125
|
+
zoomDomain: [ new Date( '2024-03-01T15:00:00Z' ), new Date( '2024-08-01T15:00:00Z' ) ],
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
const labels = new Set(
|
|
129
|
+
( axis.tickValues as Date[] ).map( tick => axis.tickFormat?.( tick, 0, [] ) )
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect( labels.size ).toBeGreaterThan( 1 );
|
|
133
|
+
} );
|
|
134
|
+
|
|
135
|
+
it( 'selects ticks only from the series the chart renders', () => {
|
|
136
|
+
const dataSorted = [
|
|
137
|
+
{ ...dailySeries( '2026-06-01T15:00:00Z', 90 )[ 0 ], label: 'hidden' },
|
|
138
|
+
{ ...dailySeries( '2026-08-01T15:00:00Z', 5 )[ 0 ], label: 'shown' },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const ticks = build( {
|
|
142
|
+
dataSorted,
|
|
143
|
+
isSeriesRendered: series => series.label === 'shown',
|
|
144
|
+
} ).tickValues as Date[];
|
|
145
|
+
|
|
146
|
+
const rendered = dataSorted[ 1 ].data.map( point => Number( point.date ) );
|
|
147
|
+
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
148
|
+
for ( const tick of ticks ) {
|
|
149
|
+
expect( rendered ).toContain( tick.getTime() );
|
|
150
|
+
}
|
|
151
|
+
} );
|
|
152
|
+
|
|
153
|
+
it( "lets a caller's own tickValues win", () => {
|
|
154
|
+
const mine = [ new Date( '2026-08-05T15:00:00Z' ) ];
|
|
155
|
+
|
|
156
|
+
const axis = build( { axisOptions: { tickResolution: 'day', tickValues: mine } } );
|
|
157
|
+
|
|
158
|
+
expect( axis.tickValues ).toBe( mine );
|
|
159
|
+
} );
|
|
160
|
+
|
|
161
|
+
it( 'labels ticks in the host time zone', () => {
|
|
162
|
+
// 2026-08-02T15:30Z is Aug 2 in Los Angeles and Aug 3 in Tokyo.
|
|
163
|
+
const axis = build( { formatting: { locale: 'de-DE', timeZone: 'Asia/Tokyo' } } );
|
|
164
|
+
|
|
165
|
+
expect( axis.tickFormat?.( new Date( '2026-08-02T15:30:00Z' ), 0, [] ) ).toBe( '3. Aug.' );
|
|
166
|
+
} );
|
|
167
|
+
|
|
168
|
+
it( 'falls back to the runtime locale and zone when no formatting is supplied', () => {
|
|
169
|
+
const axis = build( { formatting: {} } );
|
|
170
|
+
|
|
171
|
+
expect( axis.tickFormat?.( new Date( '2026-08-02T15:30:00Z' ), 0, [] ) ).toBe( 'Aug 2' );
|
|
172
|
+
} );
|
|
173
|
+
it( 'keeps its own selection when a caller passes tickValues as undefined', () => {
|
|
174
|
+
const axis = build( { axisOptions: { tickResolution: 'day', tickValues: undefined } } );
|
|
175
|
+
|
|
176
|
+
expect( ( axis.tickValues as Date[] ).length ).toBeGreaterThan( 1 );
|
|
177
|
+
} );
|
|
178
|
+
|
|
179
|
+
it( 'keeps its width-fitted count when a caller passes numTicks as undefined', () => {
|
|
180
|
+
const isoDay = ( value: unknown ) => new Date( Number( value ) ).toISOString().slice( 0, 10 );
|
|
181
|
+
|
|
182
|
+
const axis = build( {
|
|
183
|
+
width: 180,
|
|
184
|
+
axisOptions: { tickResolution: 'day', tickFormat: isoDay, numTicks: undefined },
|
|
185
|
+
} );
|
|
186
|
+
|
|
187
|
+
expect( axis.numTicks ).toBeLessThan( 10 );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
it( 'keeps its defaults when a caller passes orientation and display as undefined', () => {
|
|
191
|
+
const axis = build( { axisOptions: { orientation: undefined, display: undefined } } );
|
|
192
|
+
|
|
193
|
+
expect( axis.orientation ).toBe( 'bottom' );
|
|
194
|
+
expect( axis.display ).toBe( true );
|
|
195
|
+
} );
|
|
196
|
+
|
|
197
|
+
it( 'still lets a caller set orientation and display', () => {
|
|
198
|
+
const axis = build( { axisOptions: { orientation: 'top', display: false } } );
|
|
199
|
+
|
|
200
|
+
expect( axis.orientation ).toBe( 'top' );
|
|
201
|
+
expect( axis.display ).toBe( false );
|
|
202
|
+
} );
|
|
203
|
+
|
|
204
|
+
it( 'hands visx a width-fitted count rather than an empty selection', () => {
|
|
205
|
+
const axis = build( {
|
|
206
|
+
zoomDomain: [ new Date( '2027-01-01T00:00:00Z' ), new Date( '2027-01-02T00:00:00Z' ) ],
|
|
207
|
+
} );
|
|
208
|
+
|
|
209
|
+
expect( axis.tickValues ).toBeUndefined();
|
|
210
|
+
expect( typeof axis.numTicks ).toBe( 'number' );
|
|
211
|
+
} );
|
|
212
|
+
|
|
213
|
+
it( 'accepts a numeric scale domain', () => {
|
|
214
|
+
const axis = build( {
|
|
215
|
+
scaleDomain: [ Date.parse( '2026-08-05T15:00:00Z' ), Date.parse( '2026-08-08T15:00:00Z' ) ],
|
|
216
|
+
} );
|
|
217
|
+
|
|
218
|
+
const ticks = axis.tickValues as Date[];
|
|
219
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
220
|
+
for ( const tick of ticks ) {
|
|
221
|
+
expect( tick.getTime() ).toBeGreaterThanOrEqual( Date.parse( '2026-08-05T15:00:00Z' ) );
|
|
222
|
+
expect( tick.getTime() ).toBeLessThanOrEqual( Date.parse( '2026-08-08T15:00:00Z' ) );
|
|
223
|
+
}
|
|
224
|
+
} );
|
|
225
|
+
|
|
226
|
+
it( "keeps a caller's orientation and display", () => {
|
|
227
|
+
const axis = build( {
|
|
228
|
+
axisOptions: { tickResolution: 'day', orientation: 'top', display: false },
|
|
229
|
+
} );
|
|
230
|
+
|
|
231
|
+
expect( axis.orientation ).toBe( 'top' );
|
|
232
|
+
expect( axis.display ).toBe( false );
|
|
233
|
+
} );
|
|
234
|
+
|
|
235
|
+
it( 'sends no numTicks when it selects the tick values itself', () => {
|
|
236
|
+
expect( 'numTicks' in build() ).toBe( false );
|
|
237
|
+
} );
|
|
238
|
+
|
|
239
|
+
// A date the host could not parse arrives as `new Date( NaN )`. Left in the
|
|
240
|
+
// span it turns the domain, the formatter and the tick count into NaN, and
|
|
241
|
+
// visx reads the resulting `numTicks: 0` as "label nothing".
|
|
242
|
+
it( 'still labels the axis when a series ends on an unparsable date', () => {
|
|
243
|
+
const [ series ] = dailySeries( '2026-08-01T15:00:00Z', 4 );
|
|
244
|
+
const withBadDate = [
|
|
245
|
+
{ ...series, data: [ ...series.data, { date: new Date( NaN ), value: 5 } ] },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const options = build( { dataSorted: withBadDate } );
|
|
249
|
+
|
|
250
|
+
expect( options.tickValues?.length ).toBeGreaterThan( 0 );
|
|
251
|
+
expect( options.numTicks ?? 1 ).toBeGreaterThan( 0 );
|
|
252
|
+
} );
|
|
253
|
+
|
|
254
|
+
it( 'reads the resolution from the series it renders, not the hidden ones', () => {
|
|
255
|
+
const dataSorted = [
|
|
256
|
+
{ ...hourlySeries( '2026-08-01T00:00:00Z', 48 )[ 0 ], label: 'hidden' },
|
|
257
|
+
{ ...dailySeries( '2026-08-01T15:00:00Z', 5 )[ 0 ], label: 'shown' },
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const axis = build( { dataSorted, isSeriesRendered: series => series.label === 'shown' } );
|
|
261
|
+
|
|
262
|
+
// A daily label, not one of the hidden series' hours.
|
|
263
|
+
expect( axis.tickFormat?.( new Date( '2026-08-02T15:00:00Z' ), 0, [] ) ).not.toMatch(
|
|
264
|
+
/(AM|PM|Uhr)/
|
|
265
|
+
);
|
|
266
|
+
} );
|
|
267
|
+
} );
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { getFormatter, getMaxTicksForWidth, getTimeAxisTickValues } from '../time-axis';
|
|
2
|
+
import type { SeriesData, TickResolution } from '../../../types';
|
|
3
|
+
|
|
4
|
+
// Tick placement depends on the shape of the data, not just its values, and a
|
|
5
|
+
// hand-written fixture only covers the shapes someone thought of. Sweep the
|
|
6
|
+
// shapes instead, and assert what has to hold for every one of them.
|
|
7
|
+
|
|
8
|
+
const HOUR = 3600 * 1000;
|
|
9
|
+
const ZONES = [ 'UTC', 'Asia/Tokyo', 'America/New_York', 'Australia/Lord_Howe', 'Pacific/Chatham' ];
|
|
10
|
+
const RESOLUTIONS: ( TickResolution | undefined )[] = [
|
|
11
|
+
undefined,
|
|
12
|
+
'hour',
|
|
13
|
+
'day',
|
|
14
|
+
'week',
|
|
15
|
+
'month',
|
|
16
|
+
'year',
|
|
17
|
+
];
|
|
18
|
+
const SHAPES = [ 'even', 'gap', 'burst', 'scattered', 'repeated', 'minutes' ] as const;
|
|
19
|
+
|
|
20
|
+
// A seeded generator, so a failure names a run that reproduces.
|
|
21
|
+
let seed = 1;
|
|
22
|
+
const random = () => ( seed = ( seed * 1103515245 + 12345 ) % 2147483648 ) / 2147483648;
|
|
23
|
+
const oneOf = < T >( values: readonly T[] ): T => values[ Math.floor( random() * values.length ) ];
|
|
24
|
+
|
|
25
|
+
const makeDates = (): Date[] => {
|
|
26
|
+
const start = Date.UTC(
|
|
27
|
+
2025,
|
|
28
|
+
Math.floor( random() * 12 ),
|
|
29
|
+
1 + Math.floor( random() * 27 ),
|
|
30
|
+
Math.floor( random() * 24 )
|
|
31
|
+
);
|
|
32
|
+
const shape = oneOf( SHAPES );
|
|
33
|
+
const stepHours = oneOf( [ 1, 6, 24, 24 * 7, 24 * 30 ] );
|
|
34
|
+
const count = 1 + Math.floor( random() * 200 );
|
|
35
|
+
const times: number[] = [];
|
|
36
|
+
|
|
37
|
+
for ( let index = 0; index < count; index++ ) {
|
|
38
|
+
switch ( shape ) {
|
|
39
|
+
case 'gap':
|
|
40
|
+
times.push( start + ( index < count / 2 ? index : index + 500 ) * stepHours * HOUR );
|
|
41
|
+
break;
|
|
42
|
+
case 'burst':
|
|
43
|
+
times.push(
|
|
44
|
+
start + Math.floor( index / 5 ) * stepHours * 50 * HOUR + ( index % 5 ) * HOUR
|
|
45
|
+
);
|
|
46
|
+
break;
|
|
47
|
+
case 'scattered':
|
|
48
|
+
times.push( start + Math.floor( random() * 5000 ) * HOUR );
|
|
49
|
+
break;
|
|
50
|
+
case 'repeated':
|
|
51
|
+
times.push( start + Math.floor( index / 3 ) * stepHours * HOUR );
|
|
52
|
+
break;
|
|
53
|
+
case 'minutes':
|
|
54
|
+
times.push( start + index * 60 * 1000 );
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
times.push( start + index * stepHours * HOUR );
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return times.sort( ( a, b ) => a - b ).map( time => new Date( time ) );
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const makeSeries = (): SeriesData[] =>
|
|
65
|
+
Array.from( { length: 1 + Math.floor( random() * 3 ) }, ( _, index ) => ( {
|
|
66
|
+
label: `series ${ index }`,
|
|
67
|
+
data: makeDates().map( ( date, position ) => ( { date, value: position } ) ),
|
|
68
|
+
} ) );
|
|
69
|
+
|
|
70
|
+
describe( 'time axis tick invariants', () => {
|
|
71
|
+
it( 'holds across data shapes, zones, resolutions and widths', () => {
|
|
72
|
+
const failures: string[] = [];
|
|
73
|
+
|
|
74
|
+
for ( let run = 0; run < 2000; run++ ) {
|
|
75
|
+
const data = makeSeries();
|
|
76
|
+
const timeZone = oneOf( ZONES );
|
|
77
|
+
const resolution = oneOf( RESOLUTIONS );
|
|
78
|
+
const maxTicks = getMaxTicksForWidth( oneOf( [ 60, 120, 320, 700, 900, 1400 ] ) );
|
|
79
|
+
const formatter = getFormatter( data, resolution, { timeZone } );
|
|
80
|
+
|
|
81
|
+
const points = [
|
|
82
|
+
...new Set(
|
|
83
|
+
data.flatMap( series =>
|
|
84
|
+
series.data.map( point => ( point as { date: Date } ).date.getTime() )
|
|
85
|
+
)
|
|
86
|
+
),
|
|
87
|
+
].sort( ( a, b ) => a - b );
|
|
88
|
+
|
|
89
|
+
// Half the runs read a domain rather than the whole extent, and some of
|
|
90
|
+
// those reach well past the data, which is what a brush dragged over a
|
|
91
|
+
// sparse stretch hands the axis.
|
|
92
|
+
let domain: [ Date, Date ] | undefined;
|
|
93
|
+
if ( random() < 0.5 && points.length > 1 ) {
|
|
94
|
+
const one = points[ Math.floor( random() * points.length ) ];
|
|
95
|
+
const other = points[ Math.floor( random() * points.length ) ];
|
|
96
|
+
const padding = random() < 0.5 ? Math.floor( random() * 2000 ) * HOUR : 0;
|
|
97
|
+
domain = [
|
|
98
|
+
new Date( Math.min( one, other ) - padding ),
|
|
99
|
+
new Date( Math.max( one, other ) + padding ),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ticks = getTimeAxisTickValues( data, domain, formatter, maxTicks );
|
|
104
|
+
if ( ! ticks ) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const visible = domain
|
|
109
|
+
? points.filter( time => time >= domain[ 0 ].getTime() && time <= domain[ 1 ].getTime() )
|
|
110
|
+
: points;
|
|
111
|
+
if ( ! visible.length ) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const times = ticks.map( tick => tick.getTime() );
|
|
116
|
+
const labels = ticks.map( tick => formatter( tick.getTime() ) );
|
|
117
|
+
// Ticks answer to the scale, which spans the domain when there is one.
|
|
118
|
+
const first = domain ? domain[ 0 ].getTime() : visible[ 0 ];
|
|
119
|
+
const last = domain ? domain[ 1 ].getTime() : visible[ visible.length - 1 ];
|
|
120
|
+
const note = ( why: string ) =>
|
|
121
|
+
failures.push(
|
|
122
|
+
`run ${ run }: ${ why } (points ${ visible.length }, maxTicks ${ maxTicks }, ${ timeZone }, resolution ${ resolution })`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if ( times.some( ( time, index ) => index > 0 && time <= times[ index - 1 ] ) ) {
|
|
126
|
+
note( 'ticks are not strictly ascending' );
|
|
127
|
+
}
|
|
128
|
+
if ( times.length > maxTicks ) {
|
|
129
|
+
note( `${ times.length } ticks over a budget of ${ maxTicks }` );
|
|
130
|
+
}
|
|
131
|
+
if ( times.some( time => time < first || time > last ) ) {
|
|
132
|
+
note( 'a tick falls outside the domain' );
|
|
133
|
+
}
|
|
134
|
+
if ( labels.some( ( label, index ) => index > 0 && label === labels[ index - 1 ] ) ) {
|
|
135
|
+
note( 'two adjacent ticks carry the same label' );
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// One label is `span / maxTicks` wide, so a closer pair overlaps.
|
|
139
|
+
const span = last - first;
|
|
140
|
+
if ( span > 0 && times.length > 1 ) {
|
|
141
|
+
const closest = Math.min(
|
|
142
|
+
...times.slice( 1 ).map( ( time, index ) => time - times[ index ] )
|
|
143
|
+
);
|
|
144
|
+
const room = span / maxTicks;
|
|
145
|
+
if ( closest < room * 0.9 ) {
|
|
146
|
+
note( `two ticks share ${ Math.round( ( closest / room ) * 100 ) }% of a label's width` );
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
expect( failures.slice( 0, 10 ) ).toEqual( [] );
|
|
152
|
+
} );
|
|
153
|
+
} );
|