@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
|
@@ -1,49 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file Date parsing
|
|
2
|
+
* @file Date parsing: a naive string is dated in a supplied IANA zone, or the runtime's own
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* they're converted to the equivalent local time.
|
|
4
|
+
* A string carrying an offset is already an instant and parses the same everywhere. A string
|
|
5
|
+
* without one is only a wall-clock reading, so it means nothing until a zone is named. See
|
|
6
|
+
* `parseAsLocalDate` for the supported formats.
|
|
8
7
|
*
|
|
9
|
-
* Note:
|
|
10
|
-
*
|
|
11
|
-
* Key Features:
|
|
12
|
-
* - All parsed dates are in local timezone
|
|
13
|
-
* - Converts timezone-aware strings to local equivalent
|
|
14
|
-
* - Robust input validation and error handling using date-fns
|
|
15
|
-
* - TypeScript type safety
|
|
16
|
-
* - Much smaller codebase than custom parsing
|
|
17
|
-
*
|
|
18
|
-
* Supported Formats:
|
|
19
|
-
* - YYYY-MM-DD (treated as local)
|
|
20
|
-
* - YYYY-MM-DD HH:mm:ss (treated as local)
|
|
21
|
-
* - YYYY-MM-DD HH:mm (treated as local)
|
|
22
|
-
* - YYYY-MM-DDTHH:mm:ss (treated as local)
|
|
23
|
-
* - YYYY-MM-DDTHH:mm:ss.SSS (treated as local)
|
|
24
|
-
* - YYYY-MM-DDTHH:mm (treated as local)
|
|
25
|
-
* - YYYY-MM-DDTHH:mm:ssZ (converted to local)
|
|
26
|
-
* - YYYY-MM-DDTHH:mm:ss±HH:mm (converted to local)
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* ```typescript
|
|
30
|
-
* parseAsLocalDate("2025-01-01"); // Local timezone
|
|
31
|
-
* parseAsLocalDate("2025-01-01 14:30:00"); // Local timezone
|
|
32
|
-
* parseAsLocalDate("2025-01-01 14:30"); // Local timezone
|
|
33
|
-
* parseAsLocalDate("2025-01-01T14:30:45.123"); // Local timezone
|
|
34
|
-
* parseAsLocalDate("2025-01-01T14:30:00Z"); // UTC 14:30 → Local equivalent
|
|
35
|
-
* parseAsLocalDate("2025-01-01T14:30:00+05:00"); // +05:00 14:30 → Local equivalent
|
|
36
|
-
* ```
|
|
8
|
+
* Note: this specifically avoids date-fns's default of parsing `YYYY-MM-DD` as a UTC date.
|
|
37
9
|
*/
|
|
38
10
|
|
|
11
|
+
import { tzOffset } from '@date-fns/tz';
|
|
39
12
|
import { parse, parseISO, isValid } from 'date-fns';
|
|
13
|
+
import { warnOnce } from './warn-once';
|
|
40
14
|
|
|
41
15
|
/**
|
|
42
16
|
* Checks if a date string contains timezone information
|
|
17
|
+
*
|
|
18
|
+
* The minutes are optional because ISO 8601 allows a bare `±hh`, which `parseISO` reads.
|
|
19
|
+
*
|
|
43
20
|
* @param {string} dateString - The date string to check for timezone information
|
|
44
21
|
* @return {boolean} True if the date string contains timezone information, false otherwise
|
|
45
22
|
*/
|
|
46
|
-
const hasTimezone = ( dateString: string ): boolean => {
|
|
23
|
+
export const hasTimezone = ( dateString: string ): boolean => {
|
|
47
24
|
const tIndex = dateString.indexOf( 'T' );
|
|
48
25
|
if ( tIndex === -1 ) {
|
|
49
26
|
return false;
|
|
@@ -53,29 +30,93 @@ const hasTimezone = ( dateString: string ): boolean => {
|
|
|
53
30
|
return true;
|
|
54
31
|
}
|
|
55
32
|
|
|
56
|
-
return /[+-]\d{2}
|
|
33
|
+
return /[+-]\d{2}(?::?\d{2})?$/.test( dateString.slice( tIndex + 1 ) );
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// The wall clock, read off the string rather than back out of the parsed `Date`: date-fns
|
|
37
|
+
// builds that with local setters, so a runtime zone whose own DST gap swallows the reading
|
|
38
|
+
// hands back fields an hour out. Covers every naive shape in `formats` below, at the one to
|
|
39
|
+
// N digits date-fns reads each field as; it takes a short fraction literally, so `.5` is 5ms.
|
|
40
|
+
const NAIVE =
|
|
41
|
+
/^(\d{1,4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2})(?:\.(\d{1,3}))?)?)?$/;
|
|
42
|
+
|
|
43
|
+
// `Date.UTC` reads a year below 100 as 1900 + year, which would silently re-date the first
|
|
44
|
+
// century. `setUTCFullYear` has no such mapping.
|
|
45
|
+
const asUtcMs = ( [ , ...fields ]: RegExpExecArray ): number => {
|
|
46
|
+
const [ year, month, day, hour, minute, second, ms ] = fields.map( field =>
|
|
47
|
+
Number( field ?? 0 )
|
|
48
|
+
);
|
|
49
|
+
const date = new Date( 0 );
|
|
50
|
+
date.setUTCFullYear( year, month - 1, day );
|
|
51
|
+
date.setUTCHours( hour, minute, second, ms );
|
|
52
|
+
return date.getTime();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The instant a wall-clock reading names in a given zone.
|
|
57
|
+
*
|
|
58
|
+
* A reading a spring-forward gap deleted moves forward past the gap, and one a fall-back
|
|
59
|
+
* repeated resolves to a single instant. Neither is configurable: see CHARTS-268.
|
|
60
|
+
*
|
|
61
|
+
* @param wallClock - The reading, as the milliseconds it would be if it were UTC.
|
|
62
|
+
* @param timeZone - IANA zone the reading is dated in.
|
|
63
|
+
* @return The instant, or `null` where `timeZone` is not a zone Intl accepts.
|
|
64
|
+
*/
|
|
65
|
+
const wallClockToInstant = ( wallClock: number, timeZone: string ): Date | null => {
|
|
66
|
+
// `tzOffset` answers NaN for a zone Intl cannot use. Falling back to the runtime's zone
|
|
67
|
+
// keeps the contract that this never throws, but it silently reinstates the very defect
|
|
68
|
+
// the argument exists to fix, so say so where a developer can see it.
|
|
69
|
+
const first = tzOffset( timeZone, new Date( wallClock ) );
|
|
70
|
+
|
|
71
|
+
if ( Number.isNaN( first ) ) {
|
|
72
|
+
warnOnce(
|
|
73
|
+
`parse:timeZone:${ timeZone }`,
|
|
74
|
+
`timeZone ${ JSON.stringify(
|
|
75
|
+
timeZone
|
|
76
|
+
) } is not a zone Intl accepts, so dates are read in the browser's zone. Pass an IANA name or a UTC offset such as "+05:30".`
|
|
77
|
+
);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The offset depends on the instant, so the first answer is only a guess: a DST
|
|
82
|
+
// transition between the two moves it. Re-reading the offset at the guess settles
|
|
83
|
+
// every reading the zone actually has.
|
|
84
|
+
const guess = wallClock - first * 60000;
|
|
85
|
+
const second = tzOffset( timeZone, new Date( guess ) );
|
|
86
|
+
|
|
87
|
+
if ( first === second ) {
|
|
88
|
+
return new Date( guess );
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const corrected = wallClock - second * 60000;
|
|
92
|
+
const third = tzOffset( timeZone, new Date( corrected ) );
|
|
93
|
+
|
|
94
|
+
if ( second === third ) {
|
|
95
|
+
return new Date( corrected );
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The smaller offset moves the reading forward past the gap, so a day bucket in a zone
|
|
99
|
+
// that springs forward at midnight keeps its own calendar day.
|
|
100
|
+
return new Date( wallClock - Math.min( second, third ) * 60000 );
|
|
57
101
|
};
|
|
58
102
|
|
|
59
103
|
/**
|
|
60
|
-
* Parses any supported date string format
|
|
104
|
+
* Parses any supported date string format into an instant, dated in `timeZone` or the runtime's own
|
|
61
105
|
*
|
|
62
|
-
* Uses date-fns for robust parsing and validation.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
106
|
+
* Uses date-fns for robust parsing and validation. A string carrying timezone info
|
|
107
|
+
* is already an instant and is returned as one, whatever `timeZone` says. A string
|
|
108
|
+
* without it is a wall-clock reading, dated in `timeZone` when one is supplied and
|
|
109
|
+
* in the runtime's own zone when none is.
|
|
65
110
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* - YYYY-MM-DDTHH:mm:ssZ (UTC → local)
|
|
74
|
-
* - YYYY-MM-DDTHH:mm:ss±HH:mm (offset → local)
|
|
75
|
-
* @param {string} dateString - The date string to parse into a local timezone date
|
|
76
|
-
* @return {Date} A Date object representing the parsed date in local timezone, or an invalid Date if parsing fails
|
|
111
|
+
* A wall clock a DST gap deleted is moved forward past the gap; one a fall-back repeated
|
|
112
|
+
* resolves to a single instant. Neither policy is selectable. A zone `Intl` rejects falls
|
|
113
|
+
* back to the runtime's own, and warns once outside production.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} dateString - The date string to parse into a date
|
|
116
|
+
* @param {string} [timeZone] - IANA zone a naive string is read in; the runtime's own when absent, and ignored by a string that carries its own offset
|
|
117
|
+
* @return {Date} A Date object representing the parsed instant, or an invalid Date if parsing fails
|
|
77
118
|
*/
|
|
78
|
-
export const parseAsLocalDate = ( dateString: string ): Date => {
|
|
119
|
+
export const parseAsLocalDate = ( dateString: string, timeZone?: string ): Date => {
|
|
79
120
|
const trimmedString = dateString.trim();
|
|
80
121
|
|
|
81
122
|
// If it has timezone information, parse as ISO and convert to local
|
|
@@ -101,10 +142,20 @@ export const parseAsLocalDate = ( dateString: string ): Date => {
|
|
|
101
142
|
];
|
|
102
143
|
|
|
103
144
|
for ( const format of formats ) {
|
|
145
|
+
// date-fns still decides whether the string is a date at all, calendar rules included;
|
|
146
|
+
// only the fields it read are untrustworthy, and only when a zone was named.
|
|
104
147
|
const result = parse( trimmedString, format, new Date() );
|
|
105
|
-
if ( isValid( result ) ) {
|
|
148
|
+
if ( ! isValid( result ) ) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if ( ! timeZone ) {
|
|
106
153
|
return result;
|
|
107
154
|
}
|
|
155
|
+
|
|
156
|
+
const fields = NAIVE.exec( trimmedString );
|
|
157
|
+
|
|
158
|
+
return ( fields && wallClockToInstant( asUtcMs( fields ), timeZone ) ) ?? result;
|
|
108
159
|
}
|
|
109
160
|
|
|
110
161
|
// If no format matched, return invalid date
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getStringWidth } from '@visx/text';
|
|
2
|
+
import type { TickFormatter } from '@visx/axis';
|
|
3
|
+
import type { AnyD3Scale, ScaleInput } from '@visx/scale';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rendered widths of the first and last tick labels on an axis.
|
|
7
|
+
*
|
|
8
|
+
* An unmeasurable label reserves nothing, which is what a width of 0 already
|
|
9
|
+
* means to every caller, so it is reported as 0 rather than as its own case.
|
|
10
|
+
*
|
|
11
|
+
* @param ticks - Tick values, in axis order.
|
|
12
|
+
* @param formatTick - Function to format a tick.
|
|
13
|
+
* @param {object} labelStyle - Style object for the label.
|
|
14
|
+
* @return {object} - Widths in pixels.
|
|
15
|
+
*/
|
|
16
|
+
export const getEdgeTickWidths = < T extends AnyD3Scale >(
|
|
17
|
+
ticks: ScaleInput< T >[],
|
|
18
|
+
formatTick?: TickFormatter< ScaleInput< T > >,
|
|
19
|
+
labelStyle?: object
|
|
20
|
+
): { first: number; last: number } => {
|
|
21
|
+
if ( ! ticks.length ) {
|
|
22
|
+
return { first: 0, last: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lastIndex = ticks.length - 1;
|
|
26
|
+
const label = ( tick: ScaleInput< T >, index: number ) =>
|
|
27
|
+
String( formatTick ? formatTick( tick, index, [] ) ?? '' : tick );
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
first: getStringWidth( label( ticks[ 0 ], 0 ), labelStyle ) ?? 0,
|
|
31
|
+
last: getStringWidth( label( ticks[ lastIndex ], lastIndex ), labelStyle ) ?? 0,
|
|
32
|
+
};
|
|
33
|
+
};
|
package/src/utils/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Chart composition utilities
|
|
2
2
|
export { attachSubComponents } from './create-composition';
|
|
3
3
|
|
|
4
|
+
// Date bucket classification
|
|
5
|
+
export { getBucketInfo } from './bucket-info';
|
|
6
|
+
|
|
4
7
|
// Date parsing utilities
|
|
5
8
|
export { parseAsLocalDate } from './date-parsing';
|
|
6
9
|
|
|
@@ -10,6 +13,7 @@ export type { MetricValueType } from './format-metric-value';
|
|
|
10
13
|
export { formatPercentage } from './format-percentage';
|
|
11
14
|
|
|
12
15
|
// Chart measurement utilities
|
|
16
|
+
export { getEdgeTickWidths } from './get-edge-tick-widths';
|
|
13
17
|
export { getLongestTickWidth } from './get-longest-tick-width';
|
|
14
18
|
|
|
15
19
|
// Style and theming utilities
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { getBucketInfo, getBucketResolution } from '../bucket-info';
|
|
2
|
+
import type { SeriesData } from '../../types';
|
|
3
|
+
|
|
4
|
+
const at = ( hoursFromStart: number ) =>
|
|
5
|
+
new Date( Date.UTC( 2026, 7, 1 ) + hoursFromStart * 60 * 60 * 1000 );
|
|
6
|
+
|
|
7
|
+
const seriesOf = ( offsetsInHours: number[] ): SeriesData[] => [
|
|
8
|
+
{
|
|
9
|
+
label: 'views',
|
|
10
|
+
data: offsetsInHours.map( ( offset, index ) => ( { date: at( offset ), value: index } ) ),
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const seriesSpacedHours = ( hours: number, count: number ): SeriesData[] =>
|
|
15
|
+
seriesOf( Array.from( { length: count }, ( _, index ) => index * hours ) );
|
|
16
|
+
|
|
17
|
+
const pad = ( value: number ) => String( value ).padStart( 2, '0' );
|
|
18
|
+
|
|
19
|
+
// Built from local components so it round-trips through `parseAsLocalDate`,
|
|
20
|
+
// which reads a `dateString` as wall-clock time in the runtime's own zone.
|
|
21
|
+
const seriesOfStrings = ( offsetsInHours: number[] ): SeriesData[] => [
|
|
22
|
+
{
|
|
23
|
+
label: 'views',
|
|
24
|
+
data: offsetsInHours.map( ( offset, index ) => {
|
|
25
|
+
const date = at( offset );
|
|
26
|
+
return {
|
|
27
|
+
dateString: `${ date.getFullYear() }-${ pad( date.getMonth() + 1 ) }-${ pad(
|
|
28
|
+
date.getDate()
|
|
29
|
+
) }T${ pad( date.getHours() ) }:${ pad( date.getMinutes() ) }:00`,
|
|
30
|
+
value: index,
|
|
31
|
+
};
|
|
32
|
+
} ),
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const stringSeriesSpacedHours = ( hours: number, count: number ): SeriesData[] =>
|
|
37
|
+
seriesOfStrings( Array.from( { length: count }, ( _, index ) => index * hours ) );
|
|
38
|
+
|
|
39
|
+
const hourlyDates = ( start: Date, hours: number ): Date[] =>
|
|
40
|
+
Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
|
|
41
|
+
|
|
42
|
+
const dailyDates = ( start: Date, days: number ): Date[] =>
|
|
43
|
+
Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
|
|
44
|
+
|
|
45
|
+
const toSeries = ( dates: Date[] ): SeriesData[] => [
|
|
46
|
+
{
|
|
47
|
+
label: 'series',
|
|
48
|
+
data: dates.map( date => ( { date, value: 1 } ) ),
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
describe( 'getBucketInfo', () => {
|
|
53
|
+
it( 'preserves a declared week in bucket and collapses it for display', () => {
|
|
54
|
+
const data = seriesSpacedHours( 24 * 7, 6 );
|
|
55
|
+
|
|
56
|
+
expect( getBucketInfo( data, 'week' ) ).toEqual( {
|
|
57
|
+
bucket: 'week',
|
|
58
|
+
displayResolution: 'day',
|
|
59
|
+
} );
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'passes a declared resolution through unchanged otherwise', () => {
|
|
63
|
+
const data = seriesSpacedHours( 1, 6 );
|
|
64
|
+
|
|
65
|
+
expect( getBucketInfo( data, 'hour' ) ).toEqual( {
|
|
66
|
+
bucket: 'hour',
|
|
67
|
+
displayResolution: 'hour',
|
|
68
|
+
} );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'infers from spacing when nothing is declared', () => {
|
|
72
|
+
expect( getBucketInfo( seriesSpacedHours( 1, 6 ) ) ).toEqual( {
|
|
73
|
+
bucket: 'hour',
|
|
74
|
+
displayResolution: 'hour',
|
|
75
|
+
} );
|
|
76
|
+
expect( getBucketInfo( seriesSpacedHours( 24, 6 ) ) ).toEqual( {
|
|
77
|
+
bucket: 'day',
|
|
78
|
+
displayResolution: 'day',
|
|
79
|
+
} );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
it( 'infers from dateString points, which the chart parses before it draws them', () => {
|
|
83
|
+
expect( getBucketInfo( stringSeriesSpacedHours( 1, 6 ) ) ).toEqual( {
|
|
84
|
+
bucket: 'hour',
|
|
85
|
+
displayResolution: 'hour',
|
|
86
|
+
} );
|
|
87
|
+
expect( getBucketInfo( stringSeriesSpacedHours( 24, 6 ) ) ).toEqual( {
|
|
88
|
+
bucket: 'day',
|
|
89
|
+
displayResolution: 'day',
|
|
90
|
+
} );
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
it( 'reads a series the same whichever date field carries it', () => {
|
|
94
|
+
const offsets = [ 0, 1, 2, 3 ];
|
|
95
|
+
|
|
96
|
+
expect( getBucketInfo( seriesOfStrings( offsets ) ) ).toEqual(
|
|
97
|
+
getBucketInfo( seriesOf( offsets ) )
|
|
98
|
+
);
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
it( 'never infers week, only reports it when declared', () => {
|
|
102
|
+
expect( getBucketInfo( seriesSpacedHours( 24 * 7, 6 ) ).bucket ).toBe( 'day' );
|
|
103
|
+
} );
|
|
104
|
+
|
|
105
|
+
it( 'reads the same bucket whatever order the points arrive in', () => {
|
|
106
|
+
const offsets = [ 0, 24, 48 ];
|
|
107
|
+
const shuffled = [ 0, 48, 24 ];
|
|
108
|
+
|
|
109
|
+
expect( getBucketInfo( seriesOf( shuffled ) ) ).toEqual( getBucketInfo( seriesOf( offsets ) ) );
|
|
110
|
+
expect( getBucketInfo( seriesOf( shuffled ) ).bucket ).toBe( 'day' );
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
it( 'ignores a repeated instant, which pads a series rather than dating it', () => {
|
|
114
|
+
// A shorter comparison series can repeat its last date to match length.
|
|
115
|
+
expect( getBucketInfo( seriesOf( [ 0, 24, 48, 48 ] ) ).bucket ).toBe( 'day' );
|
|
116
|
+
} );
|
|
117
|
+
} );
|
|
118
|
+
|
|
119
|
+
describe( 'getBucketResolution', () => {
|
|
120
|
+
it( 'infers hourly buckets from sub-daily spacing', () => {
|
|
121
|
+
expect(
|
|
122
|
+
getBucketResolution( toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) ) )
|
|
123
|
+
).toBe( 'hour' );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
it( 'infers daily buckets from daily spacing', () => {
|
|
127
|
+
expect(
|
|
128
|
+
getBucketResolution( toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 5 ) ) )
|
|
129
|
+
).toBe( 'day' );
|
|
130
|
+
} );
|
|
131
|
+
|
|
132
|
+
it( 'infers daily buckets from weekly spacing, which labels the same way', () => {
|
|
133
|
+
const weeklyDates = Array.from(
|
|
134
|
+
{ length: 5 },
|
|
135
|
+
( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect( getBucketResolution( toSeries( weeklyDates ) ) ).toBe( 'day' );
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
it( 'infers monthly buckets from a shortest-month gap', () => {
|
|
142
|
+
expect(
|
|
143
|
+
getBucketResolution(
|
|
144
|
+
toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] )
|
|
145
|
+
)
|
|
146
|
+
).toBe( 'month' );
|
|
147
|
+
} );
|
|
148
|
+
|
|
149
|
+
it( 'infers yearly buckets from a yearly gap', () => {
|
|
150
|
+
expect(
|
|
151
|
+
getBucketResolution(
|
|
152
|
+
toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] )
|
|
153
|
+
)
|
|
154
|
+
).toBe( 'year' );
|
|
155
|
+
} );
|
|
156
|
+
|
|
157
|
+
it( 'keeps a daily gap across spring-forward out of the hourly bucket', () => {
|
|
158
|
+
const start = new Date( '2026-03-08T00:00:00' );
|
|
159
|
+
const dstDates = [ 0, 23, 47 ].map(
|
|
160
|
+
offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect( getBucketResolution( toSeries( dstDates ) ) ).toBe( 'day' );
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
it( 'reports daily buckets when no series has two points', () => {
|
|
167
|
+
// Unmeasurable spacing reads as Infinity, which must not be mistaken for a
|
|
168
|
+
// very coarse bucket.
|
|
169
|
+
expect( getBucketResolution( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ) ) ).toBe(
|
|
170
|
+
'day'
|
|
171
|
+
);
|
|
172
|
+
} );
|
|
173
|
+
|
|
174
|
+
it( 'reports a declared resolution over the measured spacing', () => {
|
|
175
|
+
const hourly = toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) );
|
|
176
|
+
|
|
177
|
+
expect( getBucketResolution( hourly, 'month' ) ).toBe( 'month' );
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
it( 'reports a declared weekly resolution as daily', () => {
|
|
181
|
+
// Weeks and days are both calendar-date buckets as far as labelling goes,
|
|
182
|
+
// and 'week' is not a label format of its own.
|
|
183
|
+
expect( getBucketResolution( toSeries( [] ), 'week' ) ).toBe( 'day' );
|
|
184
|
+
} );
|
|
185
|
+
} );
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
hexToRgba,
|
|
8
8
|
validateHexColor,
|
|
9
9
|
parseHslString,
|
|
10
|
-
parseRgbString,
|
|
11
10
|
normalizeColorToHex,
|
|
12
11
|
relativeLuminance,
|
|
13
12
|
prefersLightText,
|
|
@@ -547,7 +546,7 @@ describe( 'lightenHexColor', () => {
|
|
|
547
546
|
expect( result ).toBe( '#8080ff' );
|
|
548
547
|
} );
|
|
549
548
|
|
|
550
|
-
it( 'lightens a
|
|
549
|
+
it( 'lightens a color by 80%', () => {
|
|
551
550
|
const result = lightenHexColor( '#98C8DF', 0.8 );
|
|
552
551
|
// R: 152 + (255-152)*0.8 = 152 + 82.4 = 234
|
|
553
552
|
// G: 200 + (255-200)*0.8 = 200 + 44 = 244
|
|
@@ -668,52 +667,6 @@ describe( 'parseHslString', () => {
|
|
|
668
667
|
} );
|
|
669
668
|
} );
|
|
670
669
|
|
|
671
|
-
describe( 'parseRgbString', () => {
|
|
672
|
-
describe( 'Valid RGB strings', () => {
|
|
673
|
-
it( 'parses rgb(255, 0, 0) to #ff0000', () => {
|
|
674
|
-
expect( parseRgbString( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
|
|
675
|
-
} );
|
|
676
|
-
|
|
677
|
-
it( 'parses rgb(0, 255, 0) to #00ff00', () => {
|
|
678
|
-
expect( parseRgbString( 'rgb(0, 255, 0)' ) ).toBe( '#00ff00' );
|
|
679
|
-
} );
|
|
680
|
-
|
|
681
|
-
it( 'parses rgb(0, 0, 255) to #0000ff', () => {
|
|
682
|
-
expect( parseRgbString( 'rgb(0, 0, 255)' ) ).toBe( '#0000ff' );
|
|
683
|
-
} );
|
|
684
|
-
|
|
685
|
-
it( 'parses rgb with no spaces', () => {
|
|
686
|
-
expect( parseRgbString( 'rgb(128,128,128)' ) ).toBe( '#808080' );
|
|
687
|
-
} );
|
|
688
|
-
|
|
689
|
-
it( 'clamps values above 255', () => {
|
|
690
|
-
expect( parseRgbString( 'rgb(300, 0, 0)' ) ).toBe( '#ff0000' );
|
|
691
|
-
} );
|
|
692
|
-
|
|
693
|
-
it( 'clamps negative values to 0', () => {
|
|
694
|
-
expect( parseRgbString( 'rgb(-50, 0, 0)' ) ).toBe( '#000000' );
|
|
695
|
-
} );
|
|
696
|
-
} );
|
|
697
|
-
|
|
698
|
-
describe( 'Invalid RGB strings', () => {
|
|
699
|
-
it( 'returns null for hsl strings', () => {
|
|
700
|
-
expect( parseRgbString( 'hsl(0, 100%, 50%)' ) ).toBeNull();
|
|
701
|
-
} );
|
|
702
|
-
|
|
703
|
-
it( 'returns null for hex colors', () => {
|
|
704
|
-
expect( parseRgbString( '#ff0000' ) ).toBeNull();
|
|
705
|
-
} );
|
|
706
|
-
|
|
707
|
-
it( 'returns null for rgba strings', () => {
|
|
708
|
-
expect( parseRgbString( 'rgba(255, 0, 0, 1)' ) ).toBeNull();
|
|
709
|
-
} );
|
|
710
|
-
|
|
711
|
-
it( 'returns null for empty string', () => {
|
|
712
|
-
expect( parseRgbString( '' ) ).toBeNull();
|
|
713
|
-
} );
|
|
714
|
-
} );
|
|
715
|
-
} );
|
|
716
|
-
|
|
717
670
|
describe( 'normalizeColorToHex', () => {
|
|
718
671
|
describe( 'Hex colors', () => {
|
|
719
672
|
it( 'returns valid 6-digit hex as-is', () => {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createDateFormatter, createZonedClock, sanitizeFormatting } from '../date-formatting';
|
|
2
|
+
|
|
3
|
+
// `@wordpress/jest-console` registers the matcher but ships no types for it.
|
|
4
|
+
declare global {
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
|
+
namespace jest {
|
|
7
|
+
interface Matchers< R > {
|
|
8
|
+
toHaveWarned(): R;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe( 'createDateFormatter', () => {
|
|
14
|
+
it( 'renders an invalid date rather than throwing', () => {
|
|
15
|
+
const format = createDateFormatter(
|
|
16
|
+
{ month: 'short', day: 'numeric' },
|
|
17
|
+
{ locale: 'de-DE', timeZone: 'Asia/Tokyo' }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect( format( NaN ) ).toBe( 'Invalid Date' );
|
|
21
|
+
expect( format( new Date( 'nope' ) ) ).toBe( 'Invalid Date' );
|
|
22
|
+
} );
|
|
23
|
+
} );
|
|
24
|
+
|
|
25
|
+
describe( 'createZonedClock', () => {
|
|
26
|
+
it( 'reads calendar fields in the supplied zone', () => {
|
|
27
|
+
// 00:30 on Aug 3 in Tokyo.
|
|
28
|
+
expect( createZonedClock( 'Asia/Tokyo' )( new Date( '2026-08-02T15:30:00Z' ) ) ).toEqual( {
|
|
29
|
+
month: 8,
|
|
30
|
+
hour: 0,
|
|
31
|
+
} );
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
it.each( [
|
|
35
|
+
[ 'with a zone', 'Asia/Tokyo' ],
|
|
36
|
+
[ 'without one', undefined ],
|
|
37
|
+
] )( 'answers NaN for an invalid date %s', ( _label, timeZone ) => {
|
|
38
|
+
expect( createZonedClock( timeZone )( new Date( 'nope' ) ) ).toEqual( {
|
|
39
|
+
month: NaN,
|
|
40
|
+
hour: NaN,
|
|
41
|
+
} );
|
|
42
|
+
} );
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
describe( 'sanitizeFormatting', () => {
|
|
46
|
+
it( 'keeps a usable locale and zone', () => {
|
|
47
|
+
const formatting = { locale: 'de-DE', timeZone: 'Asia/Tokyo' };
|
|
48
|
+
|
|
49
|
+
expect( sanitizeFormatting( formatting ) ).toEqual( formatting );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
it( 'keeps a UTC offset, which a WordPress site may store instead of a zone name', () => {
|
|
53
|
+
expect( sanitizeFormatting( { timeZone: '+05:30' } ) ).toEqual( {
|
|
54
|
+
locale: undefined,
|
|
55
|
+
timeZone: '+05:30',
|
|
56
|
+
} );
|
|
57
|
+
} );
|
|
58
|
+
|
|
59
|
+
it( 'repairs the underscored locale WordPress reports', () => {
|
|
60
|
+
expect( sanitizeFormatting( { locale: 'en_US', timeZone: 'Asia/Tokyo' } ) ).toEqual( {
|
|
61
|
+
locale: 'en-US',
|
|
62
|
+
timeZone: 'Asia/Tokyo',
|
|
63
|
+
} );
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
it( 'drops a locale no underscore swap can rescue, keeping the zone', () => {
|
|
67
|
+
expect( sanitizeFormatting( { locale: 'nope!', timeZone: 'Asia/Tokyo' } ) ).toEqual( {
|
|
68
|
+
locale: undefined,
|
|
69
|
+
timeZone: 'Asia/Tokyo',
|
|
70
|
+
} );
|
|
71
|
+
expect( console ).toHaveWarned();
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
it( 'drops the empty zone a site set to a manual offset reports, keeping the locale', () => {
|
|
75
|
+
expect( sanitizeFormatting( { locale: 'de-DE', timeZone: '' } ) ).toEqual( {
|
|
76
|
+
locale: 'de-DE',
|
|
77
|
+
timeZone: undefined,
|
|
78
|
+
} );
|
|
79
|
+
expect( console ).toHaveWarned();
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
it( 'drops both when neither is usable', () => {
|
|
83
|
+
expect( sanitizeFormatting( { locale: '', timeZone: 'Mars/Olympus' } ) ).toEqual( {
|
|
84
|
+
locale: undefined,
|
|
85
|
+
timeZone: undefined,
|
|
86
|
+
} );
|
|
87
|
+
expect( console ).toHaveWarned();
|
|
88
|
+
} );
|
|
89
|
+
} );
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { describeZonedParsing } from '../../test-utils/zoned-parsing-suite';
|
|
9
10
|
import { parseAsLocalDate } from '../date-parsing';
|
|
10
11
|
|
|
11
12
|
describe( 'parseAsLocalDate', () => {
|
|
@@ -173,6 +174,16 @@ describe( 'parseAsLocalDate', () => {
|
|
|
173
174
|
expect( isNaN( result.getTime() ) ).toBe( false );
|
|
174
175
|
} );
|
|
175
176
|
|
|
177
|
+
test( 'should read an hour-only offset as the instant it names', () => {
|
|
178
|
+
// ISO 8601 allows a bare `±hh`, and it must not be read as a naive wall clock.
|
|
179
|
+
expect( parseAsLocalDate( '2025-01-15T14:30:45+05', 'Asia/Tokyo' ).toISOString() ).toBe(
|
|
180
|
+
parseAsLocalDate( '2025-01-15T14:30:45+05:00', 'Asia/Tokyo' ).toISOString()
|
|
181
|
+
);
|
|
182
|
+
expect( parseAsLocalDate( '2025-01-15T14:30:45+05' ).toISOString() ).toBe(
|
|
183
|
+
'2025-01-15T09:30:45.000Z'
|
|
184
|
+
);
|
|
185
|
+
} );
|
|
186
|
+
|
|
176
187
|
test( 'should handle UTC with milliseconds', () => {
|
|
177
188
|
const result = parseAsLocalDate( '2025-01-15T14:30:45.123Z' );
|
|
178
189
|
|
|
@@ -304,7 +315,7 @@ describe( 'parseAsLocalDate', () => {
|
|
|
304
315
|
];
|
|
305
316
|
|
|
306
317
|
const startTime = performance.now();
|
|
307
|
-
const results = testDates.map( parseAsLocalDate );
|
|
318
|
+
const results = testDates.map( dateString => parseAsLocalDate( dateString ) );
|
|
308
319
|
const endTime = performance.now();
|
|
309
320
|
|
|
310
321
|
// Should complete quickly (this is more of a smoke test)
|
|
@@ -316,3 +327,7 @@ describe( 'parseAsLocalDate', () => {
|
|
|
316
327
|
} );
|
|
317
328
|
} );
|
|
318
329
|
} );
|
|
330
|
+
|
|
331
|
+
// The test script pins TZ=UTC, so this run is a second viewer zone for the same
|
|
332
|
+
// table `date-parsing.auckland.test.ts` asserts.
|
|
333
|
+
describeZonedParsing();
|