@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +7 -5
  4. package/dist/index.cjs +614 -259
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +30 -0
  7. package/dist/index.d.cts +88 -74
  8. package/dist/index.d.ts +88 -74
  9. package/dist/index.js +615 -261
  10. package/dist/index.js.map +1 -1
  11. package/package.json +5 -4
  12. package/src/charts/area-chart/area-chart.tsx +2 -1
  13. package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
  14. package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
  15. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  17. package/src/charts/heatmap-chart/index.ts +10 -3
  18. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  19. package/src/charts/heatmap-chart/private/index.ts +0 -2
  20. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  21. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
  22. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  23. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  24. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  25. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  26. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  27. package/src/charts/heatmap-chart/types.ts +35 -0
  28. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  29. package/src/charts/leaderboard-chart/types.ts +2 -2
  30. package/src/charts/line-chart/line-chart.tsx +41 -7
  31. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  32. package/src/charts/line-chart/test/line-chart.test.tsx +178 -38
  33. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  34. package/src/charts/line-chart/types.ts +13 -2
  35. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  36. package/src/components/tooltip/accessible-tooltip.tsx +40 -12
  37. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  38. package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
  39. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  40. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
  41. package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
  42. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  43. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  44. package/src/hooks/use-chart-data-transform.ts +10 -3
  45. package/src/hooks/use-chart-margin.tsx +54 -9
  46. package/src/index.ts +4 -0
  47. package/src/styles/chart-scope.scss +11 -3
  48. package/src/styles/test/chart-scope.test.ts +35 -1
  49. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  50. package/src/types.ts +2 -2
  51. package/src/utils/date-formatting.ts +1 -18
  52. package/src/utils/date-parsing.ts +103 -52
  53. package/src/utils/get-edge-tick-widths.ts +33 -0
  54. package/src/utils/index.ts +1 -0
  55. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  56. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  57. package/src/utils/test/date-parsing.test.ts +16 -1
  58. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  59. package/src/utils/warn-once.ts +23 -0
  60. package/src/visx/types.ts +27 -3
  61. 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. `HeatmapChart`'s
482
- * calendar labels take neither — see `buildCalendarHeatmapData`.
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 utilities using date-fns for local timezone handling
2
+ * @file Date parsing: a naive string is dated in a supplied IANA zone, or the runtime's own
3
3
  *
4
- * This module provides utilities for parsing various date string formats and converting
5
- * them to local timezone dates using the battle-tested date-fns library. For formats
6
- * without timezone info, they're treated as local. For formats with timezone info,
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: And specifically it prevents format `YYYY-MM-DD` being parsed as UTC date.
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}:?\d{2}$/.test( dateString.slice( tIndex + 1 ) );
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 and returns a local timezone date
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. For strings without timezone
63
- * info, treats as local timezone. For strings with timezone info, converts to
64
- * local timezone equivalent.
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
- * Supports:
67
- * - YYYY-MM-DD (local)
68
- * - YYYY-MM-DD HH:mm:ss (local)
69
- * - YYYY-MM-DD HH:mm (local)
70
- * - YYYY-MM-DDTHH:mm:ss (local)
71
- * - YYYY-MM-DDTHH:mm:ss.SSS (local)
72
- * - YYYY-MM-DDTHH:mm (local)
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
+ };
@@ -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
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-auckland.mjs
3
+ */
4
+ import { describeZonedParsing } from '../../test-utils/zoned-parsing-suite';
5
+
6
+ describeZonedParsing();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-santiago.mjs
3
+ */
4
+ import { describeZonedParsing } from '../../test-utils/zoned-parsing-suite';
5
+
6
+ describeZonedParsing();
@@ -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?: SVGProps< SVGLineElement >;
25
- horizontalCrosshairStyle?: SVGProps< SVGLineElement >;
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;