@automattic/charts 1.12.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.
- package/CHANGELOG.md +14 -0
- package/TOKENS.md +8 -1
- package/dist/index.cjs +4038 -5207
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +15 -18
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +113 -1281
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/charts/area-chart/area-chart.tsx +3 -2
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
- package/src/charts/line-chart/line-chart.module.scss +3 -3
- package/src/charts/line-chart/line-chart.tsx +3 -2
- package/src/charts/line-chart/test/line-chart.test.tsx +44 -4
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
- package/src/charts/private/test/time-axis.test.ts +182 -0
- package/src/charts/private/time-axis.ts +84 -5
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +52 -7
- package/src/charts/private/x-zoom/x-zoom.module.scss +7 -15
- package/src/charts/private/x-zoom/x-zoom.tsx +29 -40
- package/src/components/legend/private/base-legend.module.scss +1 -1
- package/src/components/tooltip/base-tooltip.module.scss +1 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
- package/src/index.ts +1 -0
- package/src/types.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -84,8 +84,8 @@
|
|
|
84
84
|
"@visx/xychart": "^4.0.0",
|
|
85
85
|
"@wordpress/i18n": "^6.0.0",
|
|
86
86
|
"@wordpress/icons": "^15.0.0",
|
|
87
|
-
"@wordpress/theme": "
|
|
88
|
-
"@wordpress/ui": "0.
|
|
87
|
+
"@wordpress/theme": "1.1.0",
|
|
88
|
+
"@wordpress/ui": "0.19.0",
|
|
89
89
|
"clsx": "2.1.1",
|
|
90
90
|
"date-fns": "^4.1.0",
|
|
91
91
|
"deepmerge": "4.3.1",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"@visx/glyph": "4.0.0",
|
|
113
113
|
"@wordpress/components": "37.0.0",
|
|
114
114
|
"@wordpress/element": "8.3.0",
|
|
115
|
-
"@wordpress/private-apis": "1.
|
|
115
|
+
"@wordpress/private-apis": "1.52.0",
|
|
116
116
|
"babel-jest": "30.4.1",
|
|
117
117
|
"identity-obj-proxy": "^3.0.0",
|
|
118
118
|
"jest": "30.4.2",
|
|
@@ -202,7 +202,8 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
202
202
|
}, [ dataSorted, stacked, stackOffset, legendInteractive, rescaleYOnVisibility ] );
|
|
203
203
|
|
|
204
204
|
const chartOptions = useMemo( () => {
|
|
205
|
-
const
|
|
205
|
+
const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
|
|
206
|
+
const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
|
|
206
207
|
|
|
207
208
|
return {
|
|
208
209
|
axis: {
|
|
@@ -211,7 +212,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
|
|
|
211
212
|
numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
|
|
212
213
|
tickFormat: formatter,
|
|
213
214
|
display: true,
|
|
214
|
-
...
|
|
215
|
+
...xAxisOptions,
|
|
215
216
|
},
|
|
216
217
|
y: {
|
|
217
218
|
orientation: 'left' as const,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
text-overflow: ellipsis;
|
|
17
17
|
font-size: var(--wpds-typography-font-size-xl, 18px);
|
|
18
18
|
font-style: normal;
|
|
19
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
19
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
20
20
|
line-height: var(--wpds-typography-line-height-sm, 20px);
|
|
21
21
|
margin: 0;
|
|
22
22
|
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
text-overflow: ellipsis;
|
|
27
27
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
28
28
|
font-style: normal;
|
|
29
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
29
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
30
30
|
line-height: var(--wpds-typography-line-height-sm, 20px);
|
|
31
31
|
margin: 0;
|
|
32
32
|
}
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
.step-label {
|
|
57
57
|
color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
|
|
58
58
|
font-size: var(--wpds-typography-font-size-sm, 12px);
|
|
59
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
59
|
+
font-weight: var(--wpds-typography-font-weight-default, 400);
|
|
60
60
|
line-height: var(--wpds-typography-line-height-xs, 16px);
|
|
61
61
|
white-space: nowrap;
|
|
62
62
|
overflow: hidden;
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
.step-rate {
|
|
67
67
|
color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
|
|
68
68
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
69
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
69
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
70
70
|
line-height: var(--wpds-typography-line-height-xs, 16px);
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
// Override .visx-tooltip inline styles.
|
|
106
106
|
border-radius: var(--wpds-border-radius-md, 4px) !important;
|
|
107
107
|
padding: var(--wpds-dimension-padding-md, 12px) !important;
|
|
108
|
-
box-shadow: var(--
|
|
108
|
+
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
|
|
109
109
|
|
|
110
110
|
}
|
|
111
111
|
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
|
|
114
114
|
font-size: var(--wpds-typography-font-size-sm, 12px);
|
|
115
115
|
font-style: normal;
|
|
116
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
116
|
+
font-weight: var(--wpds-typography-font-weight-default, 400);
|
|
117
117
|
line-height: var(--wpds-typography-line-height-xs, 16px);
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
|
|
122
122
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
123
123
|
font-style: normal;
|
|
124
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
124
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
125
125
|
line-height: var(--wpds-typography-line-height-sm, 20px);
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
&__tooltip-date {
|
|
27
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
27
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
28
28
|
padding-bottom: var(--wpds-dimension-padding-md, 12px);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
&__tooltip-label {
|
|
36
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
36
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
37
37
|
padding-right: var(--wpds-dimension-padding-lg, 16px);
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
border: none;
|
|
66
66
|
border-radius: var(--wpds-border-radius-md, 4px);
|
|
67
67
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
68
|
-
box-shadow: var(--
|
|
68
|
+
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
69
69
|
position: fixed;
|
|
70
70
|
// Without margin set, the popover has margin:auto which upsets the
|
|
71
71
|
// positioning relative to the trigger button. A gap token is appropriate
|
|
@@ -287,7 +287,8 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
287
287
|
} );
|
|
288
288
|
|
|
289
289
|
const chartOptions = useMemo( () => {
|
|
290
|
-
const
|
|
290
|
+
const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
|
|
291
|
+
const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
|
|
291
292
|
|
|
292
293
|
return {
|
|
293
294
|
axis: {
|
|
@@ -296,7 +297,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
296
297
|
numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
|
|
297
298
|
tickFormat: formatter,
|
|
298
299
|
display: true,
|
|
299
|
-
...
|
|
300
|
+
...xAxisOptions,
|
|
300
301
|
},
|
|
301
302
|
y: {
|
|
302
303
|
orientation: 'left' as const,
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
+
} );
|
|
@@ -2,6 +2,7 @@ import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
|
|
|
2
2
|
import { scaleTime } from '@visx/scale';
|
|
3
3
|
import { differenceInHours, differenceInYears } from 'date-fns';
|
|
4
4
|
import type { useChartDataTransform } from '../../hooks';
|
|
5
|
+
import type { TickResolution } from '../../types';
|
|
5
6
|
import type { CurveType } from '../line-chart/types';
|
|
6
7
|
|
|
7
8
|
// Approximate min pixel width for an x-axis tick label.
|
|
@@ -47,20 +48,98 @@ const formatHourTick = ( timestamp: number ) => {
|
|
|
47
48
|
return date.toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
51
|
+
// Hour ticks with the date at midnight boundaries, so multi-day spans of
|
|
52
|
+
// 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
|
+
};
|
|
59
|
+
|
|
60
|
+
// Month ticks with the year at January boundaries, for month-or-coarser
|
|
61
|
+
// 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' } );
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Smallest interval between consecutive points across all series, in hours.
|
|
70
|
+
// Infinity when no series has two points.
|
|
71
|
+
const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
|
|
72
|
+
return sortedData.reduce(
|
|
73
|
+
( spacing, datom ) =>
|
|
74
|
+
datom.data.reduce( ( seriesSpacing, point, index ) => {
|
|
75
|
+
const previous = datom.data[ index - 1 ];
|
|
76
|
+
if ( previous?.date === undefined || point?.date === undefined ) {
|
|
77
|
+
return seriesSpacing;
|
|
78
|
+
}
|
|
79
|
+
return Math.min(
|
|
80
|
+
seriesSpacing,
|
|
81
|
+
Math.abs( differenceInHours( point.date, previous.date ) )
|
|
82
|
+
);
|
|
83
|
+
}, spacing ),
|
|
84
|
+
Number.POSITIVE_INFINITY
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
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,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Pick the most informative tick formatter for the data's resolution and time
|
|
100
|
+
// 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.
|
|
105
|
+
export const getFormatter = (
|
|
106
|
+
sortedData: ReturnType< typeof useChartDataTransform >,
|
|
107
|
+
tickResolution?: TickResolution
|
|
108
|
+
) => {
|
|
109
|
+
// 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' ) {
|
|
113
|
+
return formatYearTick;
|
|
114
|
+
}
|
|
115
|
+
|
|
53
116
|
const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
|
|
54
117
|
const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
|
|
55
118
|
|
|
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;
|
|
125
|
+
|
|
56
126
|
const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
|
|
57
|
-
if ( diffInHours <= 24 ) {
|
|
127
|
+
if ( diffInHours <= 24 && isSubDaily ) {
|
|
58
128
|
return formatHourTick;
|
|
59
129
|
}
|
|
60
130
|
|
|
131
|
+
if ( diffInHours <= 24 * 7 && isSubDaily ) {
|
|
132
|
+
return formatDateOrHourTick;
|
|
133
|
+
}
|
|
134
|
+
|
|
61
135
|
const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
|
|
62
136
|
if ( diffInYears <= 1 ) {
|
|
63
|
-
|
|
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;
|
|
64
143
|
}
|
|
65
144
|
|
|
66
145
|
return formatYearTick;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
1
|
+
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { useRef } from 'react';
|
|
3
|
+
import { useCallback, useRef, useState } from 'react';
|
|
4
4
|
import { useXZoom, ZoomResetButton } from '../index';
|
|
5
5
|
import type { SingleChartRef } from '../../single-chart-context';
|
|
6
6
|
import type { EventHandlerParams } from '@visx/xychart';
|
|
@@ -147,17 +147,35 @@ const preventDefaultKeydown = ( event: ReactKeyboardEvent< HTMLDivElement > ) =>
|
|
|
147
147
|
event.preventDefault();
|
|
148
148
|
|
|
149
149
|
describe( 'ZoomResetButton', () => {
|
|
150
|
-
test( 'renders a labelled button
|
|
150
|
+
test( 'renders a labelled button without a native title tooltip', () => {
|
|
151
151
|
const noop = jest.fn();
|
|
152
152
|
render( <ZoomResetButton onClick={ noop } /> );
|
|
153
153
|
const button = screen.getByTestId( 'chart-zoom-reset' );
|
|
154
154
|
expect( button.tagName ).toBe( 'BUTTON' );
|
|
155
155
|
expect( button ).toHaveClass( 'x-zoom__reset' );
|
|
156
156
|
expect( button ).toHaveAccessibleName( 'Reset zoom' );
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
// IconButton renders a real tooltip, so the `title` fallback is gone —
|
|
158
|
+
// `title` is invisible to keyboard users and cannot be dismissed.
|
|
159
|
+
expect( button ).not.toHaveAttribute( 'title' );
|
|
160
|
+
} );
|
|
161
|
+
|
|
162
|
+
test( 'shows a tooltip on keyboard focus', async () => {
|
|
163
|
+
const noop = jest.fn();
|
|
164
|
+
render( <ZoomResetButton onClick={ noop } /> );
|
|
165
|
+
await userEvent.tab();
|
|
166
|
+
expect( screen.getByTestId( 'chart-zoom-reset' ) ).toHaveFocus();
|
|
167
|
+
// The button's only text is the tooltip's — its own label is an
|
|
168
|
+
// `aria-label` attribute, so this cannot match the trigger.
|
|
169
|
+
await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
|
|
170
|
+
} );
|
|
171
|
+
|
|
172
|
+
test( 'dismisses the tooltip on Escape', async () => {
|
|
173
|
+
const noop = jest.fn();
|
|
174
|
+
render( <ZoomResetButton onClick={ noop } /> );
|
|
175
|
+
await userEvent.tab();
|
|
176
|
+
await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
|
|
177
|
+
await userEvent.keyboard( '{Escape}' );
|
|
178
|
+
await waitFor( () => expect( screen.queryByText( 'Reset zoom' ) ).not.toBeInTheDocument() );
|
|
161
179
|
} );
|
|
162
180
|
|
|
163
181
|
test( 'fires onClick when activated', async () => {
|
|
@@ -167,6 +185,33 @@ describe( 'ZoomResetButton', () => {
|
|
|
167
185
|
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
168
186
|
} );
|
|
169
187
|
|
|
188
|
+
test( 'leaves no orphaned tooltip when activation unmounts the button', async () => {
|
|
189
|
+
// Resetting the zoom unmounts this control while its tooltip is open.
|
|
190
|
+
// The tooltip renders in a portal outside the container, so a missed
|
|
191
|
+
// cleanup would strand it on the page rather than remove it with the
|
|
192
|
+
// button.
|
|
193
|
+
/**
|
|
194
|
+
* Mirrors the host charts: the reset control exists only while zoomed.
|
|
195
|
+
*
|
|
196
|
+
* @return JSX element or null.
|
|
197
|
+
*/
|
|
198
|
+
function Host() {
|
|
199
|
+
const [ zoomed, setZoomed ] = useState( true );
|
|
200
|
+
const unzoom = useCallback( () => setZoomed( false ), [] );
|
|
201
|
+
return zoomed ? <ZoomResetButton onClick={ unzoom } /> : null;
|
|
202
|
+
}
|
|
203
|
+
render( <Host /> );
|
|
204
|
+
await userEvent.tab();
|
|
205
|
+
await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
|
|
206
|
+
|
|
207
|
+
await userEvent.keyboard( '{Enter}' );
|
|
208
|
+
|
|
209
|
+
await waitFor( () =>
|
|
210
|
+
expect( screen.queryByTestId( 'chart-zoom-reset' ) ).not.toBeInTheDocument()
|
|
211
|
+
);
|
|
212
|
+
expect( document.body ).not.toHaveTextContent( 'Reset zoom' );
|
|
213
|
+
} );
|
|
214
|
+
|
|
170
215
|
test( 'keyboard activation survives the chart wrapper keydown handler', async () => {
|
|
171
216
|
const onClick = jest.fn();
|
|
172
217
|
// Mirrors the chart's grid wrapper, whose keyboard-navigation handler
|
|
@@ -11,26 +11,18 @@
|
|
|
11
11
|
pointer-events: none;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// The elevation shadow is deliberately kept for separation
|
|
15
|
+
// from chart content (CHARTS-237 design call, carrying
|
|
16
|
+
// over CHARTS-200's tokenization). `@wordpress/theme` 1.0.0
|
|
17
|
+
// dropped the `--wpds-elevation-*` group, so this role carries
|
|
18
|
+
// the former token's spec value directly rather than nesting
|
|
19
|
+
// a `--wpds-*` var.
|
|
18
20
|
&__reset {
|
|
19
21
|
position: absolute;
|
|
20
22
|
top: var(--wpds-dimension-gap-sm, 8px);
|
|
21
23
|
right: var(--wpds-dimension-gap-sm, 8px);
|
|
22
24
|
z-index: 2;
|
|
23
|
-
box-shadow: var(--
|
|
24
|
-
|
|
25
|
-
// `Button` is sized for a text label. These are the same three custom
|
|
26
|
-
// properties @wordpress/ui's own IconButton composition sets to make a
|
|
27
|
-
// square icon-only control; we set them directly because IconButton
|
|
28
|
-
// itself cannot be used here — it renders a Base UI tooltip, whose
|
|
29
|
-
// CommonJS `use-sync-external-store` dependency makes Rolldown emit a
|
|
30
|
-
// dynamic `require()` that throws in Script Module consumers.
|
|
31
|
-
--wp-ui-button-aspect-ratio: 1;
|
|
32
|
-
--wp-ui-button-padding-inline: 0;
|
|
33
|
-
--wp-ui-button-min-width: unset;
|
|
25
|
+
box-shadow: var(--a8c-charts-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
|
|
34
26
|
|
|
35
27
|
// WPDS outline-neutral is transparent-bodied at rest; give this
|
|
36
28
|
// floating control an opaque body so chart content doesn't show
|