@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +53 -58
  4. package/dist/index.cjs +1483 -664
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +93 -29
  7. package/dist/index.d.cts +233 -247
  8. package/dist/index.d.ts +233 -247
  9. package/dist/index.js +1484 -667
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -6
  12. package/src/charts/area-chart/area-chart.tsx +39 -25
  13. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  14. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  15. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  16. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  17. package/src/charts/area-chart/types.ts +4 -7
  18. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  19. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  20. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  21. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  23. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  25. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  27. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  28. package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
  29. package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
  30. package/src/charts/heatmap-chart/index.ts +2 -1
  31. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
  32. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/private/index.ts +1 -1
  35. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  36. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  37. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  38. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
  39. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  40. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  41. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  42. package/src/charts/heatmap-chart/types.ts +8 -2
  43. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  44. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  45. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  46. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  47. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  48. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  49. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  50. package/src/charts/leaderboard-chart/types.ts +2 -2
  51. package/src/charts/line-chart/line-chart.module.scss +10 -18
  52. package/src/charts/line-chart/line-chart.tsx +128 -26
  53. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  54. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  55. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  56. package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
  57. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  58. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  59. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  60. package/src/charts/line-chart/types.ts +17 -3
  61. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  62. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  63. package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
  64. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  65. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  66. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  67. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  68. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  69. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  70. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  71. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  72. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  73. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  74. package/src/charts/private/test/time-axis.test.ts +9 -69
  75. package/src/charts/private/time-axis-options.ts +106 -0
  76. package/src/charts/private/time-axis.ts +318 -136
  77. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  78. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  79. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  80. package/src/charts/sparkline/types.ts +1 -1
  81. package/src/components/legend/private/base-legend.module.scss +9 -5
  82. package/src/components/tooltip/accessible-tooltip.tsx +21 -17
  83. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  84. package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
  85. package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
  86. package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
  87. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
  88. package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
  89. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  90. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  91. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  92. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  93. package/src/hooks/use-chart-data-transform.ts +10 -3
  94. package/src/hooks/use-chart-margin.tsx +54 -9
  95. package/src/hooks/use-xychart-theme.ts +25 -9
  96. package/src/index.ts +8 -0
  97. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  98. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  99. package/src/providers/chart-context/index.ts +1 -0
  100. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  101. package/src/providers/chart-context/private/series-palette.ts +3 -13
  102. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  103. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  104. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  105. package/src/providers/chart-context/themes.ts +7 -52
  106. package/src/providers/chart-context/types.ts +3 -0
  107. package/src/styles/chart-scope.scss +39 -62
  108. package/src/styles/test/chart-scope.test.ts +19 -47
  109. package/src/test-utils/series-fixtures.ts +31 -0
  110. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  111. package/src/types.ts +79 -166
  112. package/src/utils/bucket-info.ts +87 -0
  113. package/src/utils/color-utils.ts +0 -27
  114. package/src/utils/date-formatting.ts +157 -0
  115. package/src/utils/date-parsing.ts +103 -52
  116. package/src/utils/get-edge-tick-widths.ts +33 -0
  117. package/src/utils/index.ts +4 -0
  118. package/src/utils/test/bucket-info.test.ts +185 -0
  119. package/src/utils/test/color-utils.test.ts +1 -48
  120. package/src/utils/test/date-formatting.test.ts +89 -0
  121. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  122. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  123. package/src/utils/test/date-parsing.test.ts +16 -1
  124. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  125. package/src/utils/test/get-styles.test.ts +10 -7
  126. package/src/utils/test/merge-themes.test.ts +18 -46
  127. package/src/utils/warn-once.ts +23 -0
  128. package/src/visx/types.ts +56 -5
  129. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  130. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  131. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  132. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  133. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  134. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  135. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -1,8 +1,10 @@
1
1
  import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
2
2
  import { scaleTime } from '@visx/scale';
3
3
  import { differenceInHours, differenceInYears } from 'date-fns';
4
+ import { getBucketResolution } from '../../utils/bucket-info';
5
+ import { createDateFormatter, createZonedClock } from '../../utils/date-formatting';
4
6
  import type { useChartDataTransform } from '../../hooks';
5
- import type { TickResolution } from '../../types';
7
+ import type { ChartFormatting, TickResolution } from '../../types';
6
8
  import type { CurveType } from '../line-chart/types';
7
9
 
8
10
  // Approximate min pixel width for an x-axis tick label.
@@ -33,41 +35,24 @@ export const getCurveType = ( type?: CurveType, smoothing?: boolean ): typeof cu
33
35
  }
34
36
  };
35
37
 
36
- // Built once and reused: these run per bucket over the whole band domain when
37
- // the axis picks its tick values, and `toLocaleDateString` rebuilds its
38
- // formatter on every call once an options object is passed.
39
- const dateTimeFormat = ( options: Intl.DateTimeFormatOptions ) => {
40
- let formatter: Intl.DateTimeFormat;
41
- return ( timestamp: number ) => {
42
- // `Intl.DateTimeFormat` throws on an invalid date where the `Date`
43
- // methods it replaces return "Invalid Date", and charts are expected to
44
- // render past a bad point rather than fail.
45
- // visx hands its tick formatter a `Date`, so coerce before the check.
46
- if ( ! Number.isFinite( Number( timestamp ) ) ) {
47
- return new Date( timestamp ).toLocaleDateString();
48
- }
49
- formatter = formatter ?? new Intl.DateTimeFormat( undefined, options );
50
- return formatter.format( timestamp );
51
- };
52
- };
53
-
54
- const formatYearTick = dateTimeFormat( { year: 'numeric' } );
55
-
56
- const formatDateTick = dateTimeFormat( { month: 'short', day: 'numeric' } );
57
-
58
- const formatHourTick = dateTimeFormat( { hour: 'numeric', hour12: true } );
59
-
60
- const formatMonthTick = dateTimeFormat( { month: 'short' } );
38
+ const YEAR_TICK = { year: 'numeric' } as const;
39
+ const DATE_TICK = { month: 'short', day: 'numeric' } as const;
40
+ const HOUR_TICK = { hour: 'numeric' } as const;
41
+ const MONTH_TICK = { month: 'short' } as const;
61
42
 
62
43
  /**
63
44
  * A tick format, carrying the boundary test that decides its coarser label.
64
45
  *
65
- * A band scale samples by index, so `getBandTickValues` has to steer ticks onto
66
- * the boundaries a format prints the date or the year at. Hanging the test off
67
- * the format that branches on it keeps the two from drifting apart.
46
+ * Tick selection steers onto the boundaries a format prints the date or the year
47
+ * at. Hanging the test off the format that branches on it keeps the two from
48
+ * drifting apart.
68
49
  */
69
50
  export type TickFormat = ( ( timestamp: number ) => string ) & {
70
51
  isAnchor?: ( date: Date ) => boolean;
52
+ // Anchors the ticks that open a new label instead. A label changes at the
53
+ // host zone's own boundary, so this needs no clock of its own, and it holds
54
+ // for data that starts partway through a day.
55
+ anchorsAtLabelChange?: boolean;
71
56
  };
72
57
 
73
58
  const boundaryFormat = (
@@ -81,29 +66,47 @@ const boundaryFormat = (
81
66
  return format;
82
67
  };
83
68
 
84
- // Hour ticks with the date at midnight boundaries, so multi-day spans of
85
- // sub-daily data keep their days identifiable.
86
- const formatDateOrHourTick = boundaryFormat(
87
- date => date.getHours() === 0 && date.getMinutes() === 0,
88
- formatDateTick,
89
- formatHourTick
90
- );
91
-
92
- // Month ticks with the year at January boundaries, for month-or-coarser
93
- // buckets where a full "Sep 1" date would misread as a daily point.
94
- const formatMonthOrYearTick = boundaryFormat(
95
- date => date.getMonth() === 0,
96
- formatYearTick,
97
- formatMonthTick
98
- );
69
+ // The tick formats, bound to the host's locale and time zone. Built per call
70
+ // rather than once per module: both halves are the host's to set, and the
71
+ // boundary tests have to read the same clock as the labels they gate.
72
+ const tickFormats = ( formatting: ChartFormatting ) => {
73
+ const clock = createZonedClock( formatting.timeZone );
74
+ const year = createDateFormatter( YEAR_TICK, formatting );
75
+ const date = createDateFormatter( DATE_TICK, formatting );
76
+ const hour = createDateFormatter( HOUR_TICK, formatting );
77
+ const month = createDateFormatter( MONTH_TICK, formatting );
78
+
79
+ // A date tick names a whole day, so it belongs at the start of one; without
80
+ // this the stride drifts hours off the boundary it labels.
81
+ const datedTick: TickFormat = ( timestamp: number ) => date( timestamp );
82
+ datedTick.anchorsAtLabelChange = true;
83
+
84
+ return {
85
+ year,
86
+ date: datedTick,
87
+ // Hour ticks with the date on the day's first bucket, so multi-day spans of
88
+ // sub-daily data keep their days identifiable. The hour alone rather than an
89
+ // exact midnight: on a half-hour zone no UTC-aligned bucket ever reads :00.
90
+ dateOrHour: boundaryFormat( value => clock( value ).hour === 0, date, hour ),
91
+ hour,
92
+ // Month ticks with the year at January boundaries, for month-or-coarser
93
+ // buckets where a full "Sep 1" date would misread as a daily point.
94
+ monthOrYear: boundaryFormat( value => clock( value ).month === 1, year, month ),
95
+ };
96
+ };
99
97
 
100
98
  // Overall time span of the data. Series with no dated points are dropped rather
101
99
  // than folded in: an empty comparison series is legitimate, and one undefined
102
100
  // bound would turn the whole span into NaN. Null when nothing is dated.
103
101
  const getSpan = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
102
+ // An unparsable date arrives as `new Date( NaN )`, and one of those at either
103
+ // end would carry NaN through the span into the domain, the formatter and the
104
+ // tick count, leaving the axis silently unlabelled.
104
105
  const bounds = sortedData
105
106
  .map( datom => [ datom.data.at( 0 )?.date, datom.data.at( -1 )?.date ] )
106
- .filter( ( [ first, last ] ) => first !== undefined && last !== undefined );
107
+ .filter(
108
+ ( [ first, last ] ) => Number.isFinite( Number( first ) ) && Number.isFinite( Number( last ) )
109
+ );
107
110
 
108
111
  if ( ! bounds.length ) {
109
112
  return null;
@@ -115,110 +118,99 @@ const getSpan = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
115
118
  };
116
119
  };
117
120
 
118
- // Smallest interval between consecutive points across all series, in hours.
119
- // Infinity when no series has two points.
120
- const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
121
- return sortedData.reduce(
122
- ( spacing, datom ) =>
123
- datom.data.reduce( ( seriesSpacing, point, index ) => {
124
- const previous = datom.data[ index - 1 ];
125
- if ( previous?.date === undefined || point?.date === undefined ) {
126
- return seriesSpacing;
127
- }
128
- return Math.min(
129
- seriesSpacing,
130
- Math.abs( differenceInHours( point.date, previous.date ) )
131
- );
132
- }, spacing ),
133
- Number.POSITIVE_INFINITY
134
- );
135
- };
136
-
137
- // 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
138
- // spring-forward DST transition.
139
- const SUB_DAILY_SPACING_HOURS = 23;
140
-
141
- // The shortest month, so monthly buckets clear it but weekly ones don't.
142
- const MONTHLY_SPACING_HOURS = 28 * 24;
143
-
144
- // The bucket size behind the data, as the resolution label the tick formats are
145
- // keyed on. Exported so consumers that label a single point — bar chart
146
- // tooltips — read the same classification the axis does instead of inferring
147
- // the resolution a second way. Weeks report as 'day': both are calendar-date
148
- // buckets as far as labelling goes.
149
- export const getBucketResolution = (
150
- sortedData: ReturnType< typeof useChartDataTransform >,
151
- tickResolution?: TickResolution
152
- ): Exclude< TickResolution, 'week' > => {
153
- if ( tickResolution ) {
154
- return tickResolution === 'week' ? 'day' : tickResolution;
155
- }
156
-
157
- const spacingInHours = getPointSpacingInHours( sortedData );
158
- if ( spacingInHours < SUB_DAILY_SPACING_HOURS ) {
159
- return 'hour';
160
- }
161
- // Fewer than two points leaves the spacing unknowable, so fall back to
162
- // calendar dates rather than reading Infinity as a very coarse bucket.
163
- if ( ! Number.isFinite( spacingInHours ) || spacingInHours < MONTHLY_SPACING_HOURS ) {
164
- return 'day';
165
- }
166
- // Twelve shortest months: above any monthly gap (31 days at most) and below
167
- // any yearly one (365 days at least).
168
- return spacingInHours < 12 * MONTHLY_SPACING_HOURS ? 'month' : 'year';
169
- };
170
-
171
- // Pick the most informative tick formatter for the data's bucket resolution and
172
- // time span. Month-or-coarser buckets are formatted from the resolution alone —
173
- // they carry no day to print at any span — while finer buckets narrow with the
174
- // span: hours within a day, hours with day boundaries for sub-daily data
175
- // spanning up to a week, calendar dates within a year, otherwise years.
121
+ /**
122
+ * The most informative tick format for a bucket resolution and the span on screen.
123
+ *
124
+ * @param sortedData - Series as returned by `useChartDataTransform`.
125
+ * @param tickResolution - Caller-declared bucket resolution, when known.
126
+ * @param formatting - Host locale and time zone.
127
+ * @param domain - The effective scale domain, or undefined for the data's own extent.
128
+ * @return The formatter the axis labels its ticks with.
129
+ */
176
130
  export const getFormatter = (
177
131
  sortedData: ReturnType< typeof useChartDataTransform >,
178
- tickResolution?: TickResolution
132
+ tickResolution?: TickResolution,
133
+ formatting: ChartFormatting = {},
134
+ domain?: [ Date, Date ]
179
135
  ): TickFormat => {
136
+ const format = tickFormats( formatting );
180
137
  const resolution = getBucketResolution( sortedData, tickResolution );
181
138
 
182
139
  // The month regime only prints the year at January boundaries, so yearly
183
140
  // buckets starting mid-year would render as bare month names.
184
141
  if ( resolution === 'year' ) {
185
- return formatYearTick;
142
+ return format.year;
186
143
  }
187
144
 
188
145
  if ( resolution === 'month' ) {
189
- return formatMonthOrYearTick;
146
+ return format.monthOrYear;
190
147
  }
191
148
 
192
- const span = getSpan( sortedData );
149
+ // Narrowing by span is a readability call about what is on screen, so it reads
150
+ // the domain when there is one; the resolution above stays keyed on the data.
151
+ const span = domain
152
+ ? { minX: domain[ 0 ].getTime(), maxX: domain[ 1 ].getTime() }
153
+ : getSpan( sortedData );
193
154
  if ( ! span ) {
194
- return formatDateTick;
155
+ return format.date;
195
156
  }
196
157
 
197
158
  const diffInHours = Math.abs( differenceInHours( span.maxX, span.minX ) );
198
159
  if ( resolution === 'hour' ) {
199
- if ( diffInHours <= 24 ) {
200
- return formatHourTick;
160
+ // Under a day, not up to one: a span of exactly 24 hours starts and ends on
161
+ // the same clock time, so bare hours would print one label twice and leave
162
+ // the second day unnamed.
163
+ if ( diffInHours < 24 ) {
164
+ return format.hour;
201
165
  }
202
166
  if ( diffInHours <= 24 * 7 ) {
203
- return formatDateOrHourTick;
167
+ return format.dateOrHour;
204
168
  }
205
169
  }
206
170
 
207
171
  // Beyond a year, dates repeat often enough under tick sampling that the year
208
172
  // is the only part worth printing.
209
- return Math.abs( differenceInYears( span.maxX, span.minX ) ) <= 1
210
- ? formatDateTick
211
- : formatYearTick;
173
+ return Math.abs( differenceInYears( span.maxX, span.minX ) ) <= 1 ? format.date : format.year;
212
174
  };
213
175
 
214
- // Indices of the buckets whose label carries the coarser unit.
215
- const getAnchorIndices = ( domain: Date[], isAnchor: ( date: Date ) => boolean ) =>
216
- domain.reduce< number[] >( ( indices, date, index ) => {
176
+ /**
177
+ * The instants a set of series spans, as a scale domain.
178
+ *
179
+ * @param sortedData - Series as returned by `useChartDataTransform`.
180
+ * @return Earliest and latest dated point, or undefined when nothing is dated.
181
+ */
182
+ export const getSeriesExtent = (
183
+ sortedData: ReturnType< typeof useChartDataTransform >
184
+ ): [ Date, Date ] | undefined => {
185
+ const span = getSpan( sortedData );
186
+ return span ? [ new Date( span.minX ), new Date( span.maxX ) ] : undefined;
187
+ };
188
+
189
+ // Indices of the buckets whose label carries the coarser unit, by whichever
190
+ // anchor the format declares. Both callers steer ticks onto these, so a format
191
+ // that gains one must not have to be taught to each of them separately.
192
+ const getAnchorIndices = ( domain: Date[], tickFormatter: TickFormat, labels: string[] ) => {
193
+ if ( tickFormatter.anchorsAtLabelChange ) {
194
+ return labels.reduce< number[] >( ( indices, label, index ) => {
195
+ if ( index === 0 || label !== labels[ index - 1 ] ) {
196
+ indices.push( index );
197
+ }
198
+ return indices;
199
+ }, [] );
200
+ }
201
+
202
+ const { isAnchor } = tickFormatter;
203
+ if ( ! isAnchor ) {
204
+ return [];
205
+ }
206
+
207
+ return domain.reduce< number[] >( ( indices, date, index ) => {
217
208
  if ( isAnchor( date ) ) {
218
209
  indices.push( index );
219
210
  }
220
211
  return indices;
221
212
  }, [] );
213
+ };
222
214
 
223
215
  // Index strides worth sampling the domain at. Both roundings of each tick count
224
216
  // are needed: flooring alone skips the stride that fits a mid-series anchor on
@@ -246,17 +238,17 @@ const getCandidateSteps = ( length: number, maxTicks: number, anchorSpacing: num
246
238
  };
247
239
 
248
240
  /**
249
- * Tick values for a band time axis.
241
+ * Tick values sampled by index, for the bar chart's band axis and for evenly
242
+ * spaced points on a continuous one.
250
243
  *
251
- * visx samples a band domain by index from offset zero, blind to which labels
252
- * carry a boundary and without collapsing repeats — so the tick that prints the
253
- * year or the date often isn't sampled at all, and a long series can show the
254
- * same label twice. Choose the values instead: sweep the evenly spaced
255
- * candidates, including those that step from anchor to anchor, and keep the one
256
- * that reaches the most anchors without thinning the axis or putting two
257
- * identical labels side by side.
244
+ * visx samples by index from offset zero, blind to which labels carry a boundary
245
+ * and without collapsing repeats — so the tick that prints the year or the date
246
+ * often isn't sampled at all, and a long series can show the same label twice.
247
+ * Choose the values instead: sweep the evenly spaced candidates, including those
248
+ * that step from anchor to anchor, and keep the one that reaches the most anchors
249
+ * without thinning the axis or putting two identical labels side by side.
258
250
  *
259
- * @param domain - Band domain, in axis order.
251
+ * @param domain - Buckets in axis order.
260
252
  * @param tickFormatter - Formatter the axis will render these values with.
261
253
  * @param maxTicks - Most ticks the axis should carry.
262
254
  * @return Values to hand the axis as `tickValues`.
@@ -270,13 +262,12 @@ export const getBandTickValues = (
270
262
  return [];
271
263
  }
272
264
 
273
- const { isAnchor } = tickFormatter;
274
- const anchorIndices = isAnchor ? getAnchorIndices( domain, isAnchor ) : [];
275
-
276
265
  // Once per bucket rather than once per bucket per candidate: the sweep reads
277
266
  // the same labels back for every step and offset.
278
267
  const domainLabels = domain.map( date => tickFormatter( date.getTime() ) );
279
268
 
269
+ const anchorIndices = getAnchorIndices( domain, tickFormatter, domainLabels );
270
+
280
271
  const candidates: number[][] = [];
281
272
  const consider = ( indices: number[] ) => {
282
273
  if ( ! indices.length || indices.length > maxTicks ) {
@@ -306,20 +297,28 @@ export const getBandTickValues = (
306
297
  // uneven number of buckets apart are still all reachable — a wall-clock day
307
298
  // is 23 buckets of hourly data across a spring-forward DST transition, and a
308
299
  // fixed stride drifts off midnight for the rest of the span.
309
- for ( let stride = 1; stride <= anchorIndices.length; stride++ ) {
310
- consider( anchorIndices.filter( ( _, position ) => position % stride === 0 ) );
300
+ // Denser strides than this exceed `maxTicks` and `consider` drops them, which
301
+ // matters because on daily data every bucket opens a label and anchors.
302
+ const minStride = Math.max( 1, Math.ceil( anchorIndices.length / maxTicks ) );
303
+ for ( let stride = minStride; stride <= anchorIndices.length; stride++ ) {
304
+ const stepped: number[] = [];
305
+ for ( let position = 0; position < anchorIndices.length; position += stride ) {
306
+ stepped.push( anchorIndices[ position ] );
307
+ }
308
+ consider( stepped );
311
309
  }
312
310
 
313
311
  if ( ! candidates.length ) {
314
312
  return [ domain[ 0 ] ];
315
313
  }
316
314
 
317
- // An anchor is worth at most one tick. Ranking anchors above density outright
318
- // collapsed ordinary monthly axes to two ticks — worse than sampling by index,
319
- // which at least stayed dense — while ignoring anchors leaves the year unnamed.
315
+ // An anchor is worth at most one tick, so density wins ties.
320
316
  const densest = candidates.reduce( ( most, indices ) => Math.max( most, indices.length ), 0 );
317
+ // Re-reading `isAnchor` here would run a time-zone-aware clock once per index
318
+ // per candidate; the indices it would answer for are already known.
319
+ const anchorIndexSet = new Set( anchorIndices );
321
320
  const anchorsIn = ( indices: number[] ) =>
322
- isAnchor ? indices.filter( index => isAnchor( domain[ index ] ) ).length : 0;
321
+ indices.filter( index => anchorIndexSet.has( index ) ).length;
323
322
 
324
323
  const best = candidates
325
324
  .filter( indices => indices.length >= densest - 1 )
@@ -331,6 +330,189 @@ export const getBandTickValues = (
331
330
  return best.map( index => domain[ index ] );
332
331
  };
333
332
 
333
+ /**
334
+ * The most x-axis ticks a chart of this width has room for.
335
+ *
336
+ * @param chartWidth - Chart width in pixels.
337
+ * @return A tick budget of at least one.
338
+ */
339
+ export const getMaxTicksForWidth = ( chartWidth: number ): number =>
340
+ Math.max( 1, Math.floor( chartWidth / X_TICK_WIDTH ) );
341
+
342
+ // The smallest gap two ticks may keep, as a fraction of one label's width.
343
+ const MIN_GAP_IN_LABELS = 0.9;
344
+
345
+ // Closest value in a sorted list.
346
+ const nearestValue = ( sorted: number[], target: number ) => {
347
+ let low = 0;
348
+ let high = sorted.length - 1;
349
+ while ( low < high ) {
350
+ const middle = Math.floor( ( low + high ) / 2 );
351
+ if ( sorted[ middle ] < target ) {
352
+ low = middle + 1;
353
+ } else {
354
+ high = middle;
355
+ }
356
+ }
357
+ const above = sorted[ low ];
358
+ const below = low > 0 ? sorted[ low - 1 ] : above;
359
+ return Math.abs( above - target ) <= Math.abs( below - target ) ? above : below;
360
+ };
361
+
362
+ // Ticks chosen by position: step evenly across the domain in time, and take the
363
+ // nearest point to each target, preferring an anchor within half a step. A
364
+ // target with no point that close keeps its own instant, since a gap in the data
365
+ // holds nothing for a tick to align with.
366
+ const getPositionTickValues = (
367
+ points: Date[],
368
+ tickFormatter: TickFormat,
369
+ maxTicks: number,
370
+ axis: { first: number; last: number }
371
+ ): Date[] => {
372
+ const times = points.map( point => point.getTime() );
373
+ const { first, last } = axis;
374
+
375
+ if ( maxTicks <= 1 || times.length === 1 || first === last ) {
376
+ return [ points[ 0 ] ];
377
+ }
378
+
379
+ // One target per label the axis has room for. Repeats and targets that land
380
+ // too close are dropped below, so a short series cannot over-tick itself.
381
+ const step = ( last - first ) / ( maxTicks - 1 );
382
+ const labels = points.map( point => tickFormatter( point.getTime() ) );
383
+ // The same width `isIndexSamplingUnusable` rejects a selection for, so the
384
+ // fallback cannot emit the overlap it was reached to avoid.
385
+ const minGap = ( ( last - first ) / maxTicks ) * MIN_GAP_IN_LABELS;
386
+ const anchors = getAnchorIndices( points, tickFormatter, labels ).map( index => times[ index ] );
387
+
388
+ const chosen: number[] = [];
389
+ let lastLabel: string | null = null;
390
+
391
+ for ( let index = 0; index < maxTicks; index++ ) {
392
+ const target = first + index * step;
393
+ const anchor = anchors.length ? nearestValue( anchors, target ) : null;
394
+ let tick = anchor !== null && Math.abs( anchor - target ) <= step / 2 ? anchor : null;
395
+
396
+ if ( tick === null ) {
397
+ const point = nearestValue( times, target );
398
+ tick = Math.abs( point - target ) <= step / 2 ? point : target;
399
+ }
400
+
401
+ // Two targets can reach for the same anchor, and snapping can pull a pair
402
+ // close enough to collide even though their targets were a step apart.
403
+ const previous = chosen[ chosen.length - 1 ];
404
+ if ( previous !== undefined && ( tick <= previous || tick - previous < minGap ) ) {
405
+ continue;
406
+ }
407
+
408
+ const label = tickFormatter( tick );
409
+ if ( label === lastLabel ) {
410
+ continue;
411
+ }
412
+
413
+ chosen.push( tick );
414
+ lastLabel = label;
415
+ }
416
+
417
+ return chosen.map( timestamp => new Date( timestamp ) );
418
+ };
419
+
420
+ // An index stride is a pixel stride only while the points are evenly spaced.
421
+ // `span / maxTicks` is one label's worth of axis, so a closer pair overlaps, and
422
+ // a selection this much thinner than the axis has room for means the
423
+ // duplicate-label veto in `getBandTickValues` ate every stride.
424
+ const isIndexSamplingUnusable = (
425
+ ticks: Date[],
426
+ points: Date[],
427
+ maxTicks: number,
428
+ axis: { first: number; last: number }
429
+ ) => {
430
+ if ( ticks.length < 2 || points.length < 2 ) {
431
+ return false;
432
+ }
433
+
434
+ // The axis, not the data: a domain far wider than the points inside it turns
435
+ // a comfortable-looking selection into a pile-up in one corner of the scale.
436
+ const span = axis.last - axis.first;
437
+ if ( ! span ) {
438
+ return false;
439
+ }
440
+
441
+ const closest = ticks
442
+ .slice( 1 )
443
+ .reduce(
444
+ ( nearest, tick, index ) => Math.min( nearest, tick.getTime() - ticks[ index ].getTime() ),
445
+ Infinity
446
+ );
447
+
448
+ return (
449
+ closest < ( span / maxTicks ) * MIN_GAP_IN_LABELS ||
450
+ ticks.length < Math.min( maxTicks, points.length ) / 3
451
+ );
452
+ };
453
+
454
+ /**
455
+ * Tick values for a continuous time axis, chosen from the points it draws.
456
+ *
457
+ * Sampled by index while the points are evenly spaced, which keeps the axis on
458
+ * the calendar boundaries `getBandTickValues` steers towards, and by position
459
+ * once they are not, where an index stride crowds one end of the scale.
460
+ *
461
+ * Values are taken from the data wherever there is one to take, so a local time
462
+ * that does not exist cannot become a tick and DST needs no special case. Only a
463
+ * target stranded in a gap is constructed, as a plain instant.
464
+ *
465
+ * @param sortedData - Series as returned by `useChartDataTransform`.
466
+ * @param domain - The effective scale domain, or undefined for all data.
467
+ * @param tickFormatter - The formatter the ticks will be labeled with.
468
+ * @param maxTicks - The most ticks the axis has room for.
469
+ * @return Tick dates, or null when no series carries a usable date.
470
+ */
471
+ export const getTimeAxisTickValues = (
472
+ sortedData: ReturnType< typeof useChartDataTransform >,
473
+ domain: [ Date, Date ] | undefined,
474
+ tickFormatter: TickFormat,
475
+ maxTicks: number
476
+ ): Date[] | null => {
477
+ const byTimestamp = new Map< number, Date >();
478
+
479
+ for ( const series of sortedData ) {
480
+ for ( const point of series.data ) {
481
+ const date = ( point as { date?: Date } ).date;
482
+ if ( date instanceof Date && Number.isFinite( date.getTime() ) ) {
483
+ byTimestamp.set( date.getTime(), date );
484
+ }
485
+ }
486
+ }
487
+
488
+ if ( ! byTimestamp.size ) {
489
+ return null;
490
+ }
491
+
492
+ const min = domain?.[ 0 ]?.getTime();
493
+ const max = domain?.[ 1 ]?.getTime();
494
+ const visible = [ ...byTimestamp.keys() ]
495
+ .sort( ( a, b ) => a - b )
496
+ .filter(
497
+ timestamp =>
498
+ ( min === undefined || timestamp >= min ) && ( max === undefined || timestamp <= max )
499
+ )
500
+ .map( timestamp => byTimestamp.get( timestamp ) as Date );
501
+
502
+ // The scale spans the domain when it has one, and the ticks have to reach
503
+ // across all of it, not only across the stretch that carries points.
504
+ const axis = {
505
+ first: min ?? visible[ 0 ].getTime(),
506
+ last: max ?? visible[ visible.length - 1 ].getTime(),
507
+ };
508
+
509
+ const ticks = getBandTickValues( visible, tickFormatter, maxTicks );
510
+
511
+ return isIndexSamplingUnusable( ticks, visible, maxTicks, axis )
512
+ ? getPositionTickValues( visible, tickFormatter, maxTicks, axis )
513
+ : ticks;
514
+ };
515
+
334
516
  // Estimate the largest number of x-axis ticks that fit without producing
335
517
  // consecutive duplicate labels under the given formatter. Used so the axis
336
518
  // adapts to the data's resolution rather than picking a fixed count.
@@ -370,5 +552,5 @@ export const guessOptimalNumTicks = (
370
552
  return ticks.length;
371
553
  }
372
554
 
373
- return secondBestGuess;
555
+ return Math.max( 1, secondBestGuess );
374
556
  };
@@ -7,7 +7,7 @@
7
7
  fill-opacity: 0.16;
8
8
  stroke: var(--a8c-charts-color-zoom-selection-stroke);
9
9
  stroke-opacity: 0.65;
10
- stroke-width: var(--wpds-border-width-xs, 1px);
10
+ stroke-width: var(--wpds-border-width-xs);
11
11
  pointer-events: none;
12
12
  }
13
13
 
@@ -19,8 +19,8 @@
19
19
  // a `--wpds-*` var.
20
20
  &__reset {
21
21
  position: absolute;
22
- top: var(--wpds-dimension-gap-sm, 8px);
23
- right: var(--wpds-dimension-gap-sm, 8px);
22
+ top: var(--wpds-dimension-gap-sm);
23
+ right: var(--wpds-dimension-gap-sm);
24
24
  z-index: 2;
25
25
  box-shadow: var(--a8c-charts-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
26
26
 
@@ -0,0 +1,8 @@
1
+ // The XYChart and its in-tree tooltip. `position: relative` anchors the tooltip
2
+ // box on the SVG's origin. `isolation: isolate` keeps the box's z-index, and
3
+ // the zoom reset button's, inside the chart, so page chrome such as a sticky
4
+ // header still paints over them.
5
+ .xy-plot {
6
+ position: relative;
7
+ isolation: isolate;
8
+ }