@automattic/charts 4.0.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +7 -5
- package/dist/index.cjs +614 -259
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -0
- package/dist/index.d.cts +88 -74
- package/dist/index.d.ts +88 -74
- package/dist/index.js +615 -261
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
- package/src/charts/heatmap-chart/index.ts +10 -3
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/index.ts +0 -2
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
- 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 +35 -0
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.tsx +41 -7
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +178 -38
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +13 -2
- package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
- package/src/components/tooltip/accessible-tooltip.tsx +40 -12
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
- 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/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/index.ts +4 -0
- package/src/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +35 -1
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +2 -2
- package/src/utils/date-formatting.ts +1 -18
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +1 -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/warn-once.ts +23 -0
- package/src/visx/types.ts +27 -3
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { parseAsLocalDate } from '../utils/date-parsing';
|
|
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
|
+
toHaveWarnedWith( ...args: unknown[] ): R;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Every case asserts an absolute instant, so running the suite from more than one
|
|
14
|
+
// worker zone is what proves the result does not depend on the viewer's.
|
|
15
|
+
const CASES: Array< [ string, string, string ] > = [
|
|
16
|
+
// The shape the Stats payload gives for a day bucket.
|
|
17
|
+
[ '2026-08-02', 'Asia/Tokyo', '2026-08-01T15:00:00.000Z' ],
|
|
18
|
+
[ '2026-08-02 09:30:00', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
|
|
19
|
+
[ '2026-08-02T09:30:00', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
|
|
20
|
+
// Santiago springs forward at midnight, deleting this reading entirely.
|
|
21
|
+
[ '2026-09-06', 'America/Santiago', '2026-09-06T04:00:00.000Z' ],
|
|
22
|
+
// Berlin's gap runs the other way, its offset growing rather than shrinking, which is
|
|
23
|
+
// the shape most of Europe and North America has. Only this case tells the smaller of
|
|
24
|
+
// the two candidate offsets apart from the later probe.
|
|
25
|
+
[ '2026-03-29 02:30:00', 'Europe/Berlin', '2026-03-29T01:30:00.000Z' ],
|
|
26
|
+
// And Santiago falls back at midnight, so this reading happens twice.
|
|
27
|
+
[ '2026-04-04 23:00:00', 'America/Santiago', '2026-04-05T02:00:00.000Z' ],
|
|
28
|
+
// Which of the two an overlap resolves to follows the sign of the offset, so a zone
|
|
29
|
+
// east of UTC settles on the other one. Auckland is where this PR's own stories sit.
|
|
30
|
+
[ '2026-04-05 02:30:00', 'Pacific/Auckland', '2026-04-04T14:30:00.000Z' ],
|
|
31
|
+
// Hours past a fall-back, where the first offset probe lands on the wrong side
|
|
32
|
+
// of the transition and only the second one is right.
|
|
33
|
+
[ '2026-11-01 08:00:00', 'America/Los_Angeles', '2026-11-01T16:00:00.000Z' ],
|
|
34
|
+
// Nothing about this reading is remarkable in Tokyo; it is the worker zones that make it
|
|
35
|
+
// worth asserting, since Santiago deletes this local midnight and used to drag the
|
|
36
|
+
// answer an hour with it.
|
|
37
|
+
[ '2026-09-06', 'Asia/Tokyo', '2026-09-05T15:00:00.000Z' ],
|
|
38
|
+
// date-fns reads each field as one to N digits, so an unpadded string must land on the
|
|
39
|
+
// same instant as its padded twin. A one-digit fraction is 5ms to date-fns, not 500.
|
|
40
|
+
[ '2026-8-2', 'Asia/Tokyo', '2026-08-01T15:00:00.000Z' ],
|
|
41
|
+
[ '2026-08-02 9:30', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
|
|
42
|
+
[ '2026-08-02T9:30:5.5', 'Asia/Tokyo', '2026-08-02T00:30:05.005Z' ],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// A string carrying its own offset is already an instant, so the zone must not move it.
|
|
46
|
+
const INSTANT_CASES: Array< [ string, string ] > = [
|
|
47
|
+
[ '2026-08-02T00:00:00Z', '2026-08-02T00:00:00.000Z' ],
|
|
48
|
+
[ '2026-08-02T00:00:00+05:00', '2026-08-01T19:00:00.000Z' ],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The zone-aware half of `parseAsLocalDate`, run from whatever zone the caller's
|
|
53
|
+
* environment pins.
|
|
54
|
+
*/
|
|
55
|
+
export const describeZonedParsing = () => {
|
|
56
|
+
describe( 'parseAsLocalDate with a time zone', () => {
|
|
57
|
+
test.each( CASES )( 'reads %s in %s', ( dateString, timeZone, expected ) => {
|
|
58
|
+
expect( parseAsLocalDate( dateString, timeZone ).toISOString() ).toBe( expected );
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
test.each( INSTANT_CASES )( 'leaves %s where it is', ( dateString, expected ) => {
|
|
62
|
+
expect( parseAsLocalDate( dateString, 'Asia/Tokyo' ).toISOString() ).toBe( expected );
|
|
63
|
+
} );
|
|
64
|
+
|
|
65
|
+
it( 'falls back to the runtime zone where Intl rejects the zone, and says so', () => {
|
|
66
|
+
const fallback = parseAsLocalDate( '2026-08-02', 'Not/AZone' );
|
|
67
|
+
|
|
68
|
+
expect( fallback.getTime() ).toBe( parseAsLocalDate( '2026-08-02' ).getTime() );
|
|
69
|
+
expect( console ).toHaveWarnedWith(
|
|
70
|
+
'[Charts] timeZone "Not/AZone" 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".'
|
|
71
|
+
);
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
it( 'keeps a year below 100 out of the 1900s', () => {
|
|
75
|
+
expect( parseAsLocalDate( '0099-08-02', 'Asia/Tokyo' ).getUTCFullYear() ).toBe( 99 );
|
|
76
|
+
expect( parseAsLocalDate( '99-08-02', 'Asia/Tokyo' ).getUTCFullYear() ).toBe( 99 );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
it( 'reads a naive string in the runtime zone when no zone is supplied', () => {
|
|
80
|
+
const local = parseAsLocalDate( '2026-08-02' );
|
|
81
|
+
|
|
82
|
+
expect( [
|
|
83
|
+
local.getFullYear(),
|
|
84
|
+
local.getMonth(),
|
|
85
|
+
local.getDate(),
|
|
86
|
+
local.getHours(),
|
|
87
|
+
] ).toEqual( [ 2026, 7, 2, 0 ] );
|
|
88
|
+
} );
|
|
89
|
+
} );
|
|
90
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -478,8 +478,8 @@ export type BucketInfo = {
|
|
|
478
478
|
* `timeZone` re-dates the instants the host supplies, so a value that is really a
|
|
479
479
|
* calendar day shifts under it: `new Date( '2026-08-02' )` is UTC midnight and
|
|
480
480
|
* labels as Aug 1 in `America/Los_Angeles`. Supply true instants alongside a
|
|
481
|
-
* `timeZone`, or set only `locale` for day-bucketed data. `
|
|
482
|
-
*
|
|
481
|
+
* `timeZone`, or set only `locale` for day-bucketed data. `buildCalendarHeatmapData`
|
|
482
|
+
* is the exception: it reads a bare `yyyy-MM-dd` as the day it names.
|
|
483
483
|
*/
|
|
484
484
|
export type ChartFormatting = {
|
|
485
485
|
/** BCP-47 language tag, e.g. `de-DE`. Defaults to the runtime's locale. */
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
+
import { warnOnce } from './warn-once';
|
|
1
2
|
import type { ChartFormatting } from '../types';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
* `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
|
|
5
|
-
* minimal `process` locally so this file type-checks as source under `jetpack:src`.
|
|
6
|
-
*/
|
|
7
|
-
declare const process: { env: Record< string, string | undefined > };
|
|
8
|
-
|
|
9
4
|
// One `Intl.DateTimeFormat` per locale, zone and options triple. The axis formats
|
|
10
5
|
// every bucket in the domain, and rebuilding the formatter is what costs; the tick
|
|
11
6
|
// formats themselves are rebuilt on every resize, so a per-closure cache is not
|
|
@@ -27,18 +22,6 @@ const getFormatter = (
|
|
|
27
22
|
return formatter;
|
|
28
23
|
};
|
|
29
24
|
|
|
30
|
-
const warned = new Set< string >();
|
|
31
|
-
|
|
32
|
-
const warnOnce = ( key: string, message: string ): void => {
|
|
33
|
-
if ( warned.has( key ) || process.env.NODE_ENV === 'production' ) {
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
warned.add( key );
|
|
38
|
-
// eslint-disable-next-line no-console
|
|
39
|
-
console.warn( `[Charts] ${ message }` );
|
|
40
|
-
};
|
|
41
|
-
|
|
42
25
|
const isUsable = ( formatting: ChartFormatting ): boolean => {
|
|
43
26
|
try {
|
|
44
27
|
// Constructing is the check: `Intl` throws on a tag or zone it cannot use.
|
|
@@ -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
|
@@ -13,6 +13,7 @@ export type { MetricValueType } from './format-metric-value';
|
|
|
13
13
|
export { formatPercentage } from './format-percentage';
|
|
14
14
|
|
|
15
15
|
// Chart measurement utilities
|
|
16
|
+
export { getEdgeTickWidths } from './get-edge-tick-widths';
|
|
16
17
|
export { getLongestTickWidth } from './get-longest-tick-width';
|
|
17
18
|
|
|
18
19
|
// Style and theming utilities
|
|
@@ -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();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { getEdgeTickWidths } from '../get-edge-tick-widths';
|
|
2
|
+
|
|
3
|
+
const mockGetStringWidth = jest.fn();
|
|
4
|
+
jest.mock( '@visx/text', () => ( {
|
|
5
|
+
...jest.requireActual( '@visx/text' ),
|
|
6
|
+
getStringWidth: ( ...args: unknown[] ) => mockGetStringWidth( ...args ),
|
|
7
|
+
} ) );
|
|
8
|
+
|
|
9
|
+
describe( 'getEdgeTickWidths', () => {
|
|
10
|
+
beforeEach( () => {
|
|
11
|
+
mockGetStringWidth.mockReset();
|
|
12
|
+
mockGetStringWidth.mockImplementation( ( label: string ) => label.length );
|
|
13
|
+
} );
|
|
14
|
+
|
|
15
|
+
it( 'measures the formatted first and last ticks', () => {
|
|
16
|
+
const style = { fontSize: 11 };
|
|
17
|
+
|
|
18
|
+
const widths = getEdgeTickWidths( [ 1, 2, 3 ], value => `tick-${ value }`, style );
|
|
19
|
+
|
|
20
|
+
expect( widths ).toEqual( { first: 6, last: 6 } );
|
|
21
|
+
expect( mockGetStringWidth ).toHaveBeenCalledWith( 'tick-1', style );
|
|
22
|
+
expect( mockGetStringWidth ).toHaveBeenCalledWith( 'tick-3', style );
|
|
23
|
+
} );
|
|
24
|
+
|
|
25
|
+
it( 'passes each tick its own index', () => {
|
|
26
|
+
getEdgeTickWidths( [ 'a', 'b', 'c' ], ( value, index ) => `${ index }:${ value }` );
|
|
27
|
+
|
|
28
|
+
expect( mockGetStringWidth ).toHaveBeenCalledWith( '0:a', undefined );
|
|
29
|
+
expect( mockGetStringWidth ).toHaveBeenCalledWith( '2:c', undefined );
|
|
30
|
+
} );
|
|
31
|
+
|
|
32
|
+
it( 'measures a single tick as both edges', () => {
|
|
33
|
+
expect( getEdgeTickWidths( [ 42 ], value => `${ value }` ) ).toEqual( { first: 2, last: 2 } );
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
it( 'measures the raw value when there is no formatter', () => {
|
|
37
|
+
getEdgeTickWidths( [ 'abcd' ] );
|
|
38
|
+
|
|
39
|
+
expect( mockGetStringWidth ).toHaveBeenCalledWith( 'abcd', undefined );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
it( 'returns nothing to reserve for an axis with no ticks', () => {
|
|
43
|
+
expect( getEdgeTickWidths( [] ) ).toEqual( { first: 0, last: 0 } );
|
|
44
|
+
expect( mockGetStringWidth ).not.toHaveBeenCalled();
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'reserves nothing for a label the measurer cannot size', () => {
|
|
48
|
+
mockGetStringWidth.mockReturnValue( null );
|
|
49
|
+
|
|
50
|
+
expect( getEdgeTickWidths( [ 1, 2, 3 ], value => `tick-${ value }` ) ).toEqual( {
|
|
51
|
+
first: 0,
|
|
52
|
+
last: 0,
|
|
53
|
+
} );
|
|
54
|
+
} );
|
|
55
|
+
} );
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
|
|
3
|
+
* minimal `process` locally so this file type-checks as source under `jetpack:src`.
|
|
4
|
+
*/
|
|
5
|
+
declare const process: { env: Record< string, string | undefined > };
|
|
6
|
+
|
|
7
|
+
const warned = new Set< string >();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Warns about a host-supplied value once per process, and never in production.
|
|
11
|
+
*
|
|
12
|
+
* @param key - Identifies the value, so a bad prop on every point warns once.
|
|
13
|
+
* @param message - What the host got wrong and what happens instead.
|
|
14
|
+
*/
|
|
15
|
+
export const warnOnce = ( key: string, message: string ): void => {
|
|
16
|
+
if ( warned.has( key ) || process.env.NODE_ENV === 'production' ) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
warned.add( key );
|
|
21
|
+
// eslint-disable-next-line no-console
|
|
22
|
+
console.warn( `[Charts] ${ message }` );
|
|
23
|
+
};
|
package/src/visx/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PickD3Scale } from '@visx/scale';
|
|
2
2
|
import type { TooltipProps as VisxTooltipProps, UseTooltipPortalOptions } from '@visx/tooltip';
|
|
3
3
|
import type { GlyphProps, TooltipContextType } from '@visx/xychart';
|
|
4
|
-
import type { ReactNode, SVGProps } from 'react';
|
|
4
|
+
import type { CSSProperties, ReactNode, SVGProps } from 'react';
|
|
5
5
|
|
|
6
6
|
export type RenderTooltipParams< Datum extends object > = TooltipContextType< Datum > & {
|
|
7
7
|
colorScale?: PickD3Scale< 'ordinal', string, string >;
|
|
@@ -12,23 +12,47 @@ export interface RenderTooltipGlyphProps< Datum extends object > extends GlyphPr
|
|
|
12
12
|
isNearestDatum: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export type TooltipPlacement = 'auto' | 'below-axis';
|
|
16
|
+
|
|
17
|
+
type CrosshairPaintProperty =
|
|
18
|
+
| 'stroke'
|
|
19
|
+
| 'strokeWidth'
|
|
20
|
+
| 'strokeOpacity'
|
|
21
|
+
| 'strokeDasharray'
|
|
22
|
+
| 'strokeLinecap'
|
|
23
|
+
| 'opacity';
|
|
24
|
+
|
|
25
|
+
export type CrosshairStyle = Pick< SVGProps< SVGLineElement >, CrosshairPaintProperty > & {
|
|
26
|
+
className?: string;
|
|
27
|
+
style?: Pick< CSSProperties, CrosshairPaintProperty >;
|
|
28
|
+
};
|
|
29
|
+
|
|
15
30
|
export type XyChartTooltipProps< Datum extends object > = {
|
|
16
31
|
renderTooltip: ( params: RenderTooltipParams< Datum > ) => ReactNode;
|
|
17
32
|
renderGlyph?: ( params: RenderTooltipGlyphProps< Datum > ) => ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Keep the panel below the x-axis label band, centered at the datum x and horizontally clamped.
|
|
35
|
+
* Vertical bounds do not move this placement; clipping ancestors can still cut it off.
|
|
36
|
+
* @default 'auto'
|
|
37
|
+
*/
|
|
38
|
+
tooltipPlacement?: TooltipPlacement;
|
|
39
|
+
/** Merge overrides with the default box styles; use `unstyled` to strip the box styling. */
|
|
40
|
+
style?: VisxTooltipProps[ 'style' ];
|
|
18
41
|
snapTooltipToDatumX?: boolean;
|
|
19
42
|
snapTooltipToDatumY?: boolean;
|
|
20
43
|
showVerticalCrosshair?: boolean;
|
|
21
44
|
showHorizontalCrosshair?: boolean;
|
|
22
45
|
showDatumGlyph?: boolean;
|
|
23
46
|
showSeriesGlyphs?: boolean;
|
|
24
|
-
verticalCrosshairStyle?:
|
|
25
|
-
horizontalCrosshairStyle?:
|
|
47
|
+
verticalCrosshairStyle?: CrosshairStyle;
|
|
48
|
+
horizontalCrosshairStyle?: CrosshairStyle;
|
|
26
49
|
glyphStyle?: SVGProps< SVGCircleElement >;
|
|
27
50
|
/**
|
|
28
51
|
* Flip and clamp the tooltip box so it stays inside the nearest ancestor
|
|
29
52
|
* that clips its overflow, or the viewport when there is none. The box may
|
|
30
53
|
* leave the chart wrapper. (It used to keep a body-level portal inside the
|
|
31
54
|
* viewport.)
|
|
55
|
+
* Ignored by `below-axis`.
|
|
32
56
|
* @default true
|
|
33
57
|
*/
|
|
34
58
|
detectBounds?: boolean;
|