@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.0.0",
3
+ "version": "4.1.1",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,8 +63,9 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.2.8",
66
+ "@automattic/number-formatters": "^1.2.9",
67
67
  "@babel/runtime": "7.29.7",
68
+ "@date-fns/tz": "1.4.1",
68
69
  "@react-spring/web": "10.1.1",
69
70
  "@visx/annotation": "^4.0.0",
70
71
  "@visx/axis": "^4.0.0",
@@ -113,9 +114,9 @@
113
114
  "@wordpress/components": "40.0.0",
114
115
  "@wordpress/element": "8.6.0",
115
116
  "@wordpress/private-apis": "1.54.0",
116
- "babel-jest": "30.4.1",
117
+ "babel-jest": "30.5.1",
117
118
  "identity-obj-proxy": "^3.0.0",
118
- "jest": "30.4.2",
119
+ "jest": "30.5.1",
119
120
  "jest-extended": "7.0.0",
120
121
  "magic-string": "0.30.21",
121
122
  "react": "18.3.1",
@@ -411,6 +411,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
411
411
 
412
412
  return (
413
413
  <div
414
+ ref={ chartRef }
414
415
  role="grid"
415
416
  aria-label={ __( 'Area chart', 'jetpack-charts' ) }
416
417
  tabIndex={ 0 }
@@ -419,7 +420,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
419
420
  onBlur={ onChartBlur }
420
421
  >
421
422
  { chartHeight > 0 && (
422
- <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
423
+ <div className={ plotStyles[ 'xy-plot' ] }>
423
424
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
424
425
  <XYChart
425
426
  theme={ theme }
@@ -52,6 +52,37 @@ describe( 'AreaChart', () => {
52
52
  );
53
53
  };
54
54
 
55
+ test.each( [ [ 'Escape', '{Escape}' ] ] )(
56
+ 'returns focus to the grid after %s',
57
+ async ( _name, keys ) => {
58
+ jest.useFakeTimers();
59
+ try {
60
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
61
+ renderWithProvider();
62
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
63
+
64
+ await user.tab();
65
+ expect( chart ).toHaveFocus();
66
+ await user.keyboard( '{ArrowRight}' );
67
+ expect( screen.getByRole( 'tooltip' ) ).toHaveFocus();
68
+
69
+ await user.keyboard( keys );
70
+ await act( async () => {
71
+ jest.advanceTimersByTime( 5000 );
72
+ } );
73
+ expect( chart ).toHaveFocus();
74
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
75
+
76
+ await user.keyboard( '{ArrowRight}' );
77
+ const tooltip = screen.getByRole( 'tooltip' );
78
+ expect( tooltip ).toHaveFocus();
79
+ expect( tooltip ).toHaveTextContent( 'Series A:10' );
80
+ } finally {
81
+ jest.useRealTimers();
82
+ }
83
+ }
84
+ );
85
+
55
86
  describe( 'Data Validation', () => {
56
87
  test( 'shows error when data is empty', () => {
57
88
  renderWithProvider( { data: [] } );
@@ -0,0 +1,189 @@
1
+ import { createDateFormatter, sanitizeFormatting } from '../../utils/date-formatting';
2
+ import { warnOnce } from '../../utils/warn-once';
3
+ import {
4
+ addCivilDays,
5
+ civilDate,
6
+ civilKey,
7
+ civilWeekSpan,
8
+ instantDayReader,
9
+ pointDayKey,
10
+ startOfCivilWeek,
11
+ writtenDayKey,
12
+ } from './private/civil-day';
13
+ import type { CivilDate, DayKey } from './private/civil-day';
14
+ import type {
15
+ CalendarHeatmapOptions,
16
+ CalendarHeatmapResult,
17
+ HeatmapCell,
18
+ HeatmapColumn,
19
+ } from './types';
20
+ import type { DataPointDate } from '../../types';
21
+
22
+ /** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
23
+ const LABELLED_ROWS = [ 0, 2, 4 ];
24
+
25
+ /**
26
+ * Resolve one grid bound: an unparseable or narrowing value falls back to the
27
+ * series' own bound, so the grid can only ever be widened.
28
+ *
29
+ * @param bound - Requested bound as `yyyy-MM-dd`, if any.
30
+ * @param fallback - The series' own bound on this side.
31
+ * @param direction - Which way the bound is allowed to move.
32
+ * @return The day key to draw to.
33
+ */
34
+ const widenTo = (
35
+ bound: string | undefined,
36
+ fallback: DayKey,
37
+ direction: 'earlier' | 'later'
38
+ ): DayKey => {
39
+ const key = bound ? writtenDayKey( bound ) : null;
40
+ if ( ! key ) {
41
+ if ( bound ) {
42
+ warnOnce(
43
+ `heatmap:gridSpan:${ bound }`,
44
+ `gridSpan.${ direction === 'earlier' ? 'start' : 'end' } ${ JSON.stringify(
45
+ bound
46
+ ) } is not a \`yyyy-MM-dd\` day, so the grid is drawn over the series' own span.`
47
+ );
48
+ }
49
+ return fallback;
50
+ }
51
+
52
+ if ( direction === 'earlier' ) {
53
+ return key < fallback ? key : fallback;
54
+ }
55
+
56
+ return key > fallback ? key : fallback;
57
+ };
58
+
59
+ /**
60
+ * Lay a day-bucketed series out as calendar columns.
61
+ *
62
+ * @param series - Points to bucket.
63
+ * @param options - Grid shape, plus the locale and zone to read days in.
64
+ * @return Columns and row labels for `HeatmapChart`.
65
+ */
66
+ export const buildCalendarHeatmapData = (
67
+ series: DataPointDate[],
68
+ options: CalendarHeatmapOptions = {}
69
+ ): CalendarHeatmapResult => {
70
+ const weekStartsOn = options.weekStartsOn ?? 1;
71
+ const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
72
+
73
+ // The provider sanitizes what it holds, but this function is called outside it too.
74
+ const { locale, timeZone } = sanitizeFormatting( {
75
+ locale: options.locale,
76
+ timeZone: options.timeZone,
77
+ } );
78
+
79
+ const readInstant = instantDayReader( timeZone );
80
+
81
+ const valueByDay = new Map< DayKey, number | null >();
82
+ let minDayKey: DayKey | undefined;
83
+ let maxDayKey: DayKey | undefined;
84
+ for ( const point of series ) {
85
+ const key = pointDayKey( point, readInstant );
86
+ if ( ! key ) {
87
+ const offending = point.dateString ?? ( point.date && String( point.date ) );
88
+ warnOnce(
89
+ `heatmap:unreadableDate:${ offending }`,
90
+ offending
91
+ ? `${ JSON.stringify(
92
+ offending
93
+ ) } is not a day this can read, so its point is left out of the calendar. A \`dateString\` must start \`yyyy-MM-dd\`.`
94
+ : 'A point carries neither `date` nor `dateString`, so it is left out of the calendar.'
95
+ );
96
+ continue;
97
+ }
98
+
99
+ valueByDay.set( key, point.value );
100
+ // Day keys are fixed-width, so lexicographic order is chronological order.
101
+ if ( ! minDayKey || key < minDayKey ) {
102
+ minDayKey = key;
103
+ }
104
+ if ( ! maxDayKey || key > maxDayKey ) {
105
+ maxDayKey = key;
106
+ }
107
+ }
108
+
109
+ if ( ! minDayKey || ! maxDayKey ) {
110
+ return { data: [], rowLabels: [] };
111
+ }
112
+
113
+ const requestedMinDayKey = widenTo( options.gridSpan?.start, minDayKey, 'earlier' );
114
+ const gridMaxDayKey = widenTo( options.gridSpan?.end, maxDayKey, 'later' );
115
+
116
+ // Both are `DayKey`s, so they already round-tripped through `civilDate` once, and it
117
+ // is a pure function of the string: re-parsing them here cannot fail.
118
+ const requestedMinDate = civilDate( requestedMinDayKey ) as CivilDate;
119
+ const gridMaxDate = civilDate( gridMaxDayKey ) as CivilDate;
120
+
121
+ // The grid walks UTC proxies, so the label formatters read UTC. The host's zone
122
+ // was already spent on bucketing.
123
+ const labelFormatting = { locale, timeZone: 'UTC' };
124
+ const formatWeekday = createDateFormatter( { weekday: 'short' }, labelFormatting );
125
+ const formatMonth = createDateFormatter( { month: 'short' }, labelFormatting );
126
+ const formatDay = createDateFormatter(
127
+ { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' },
128
+ labelFormatting
129
+ );
130
+
131
+ const gridStart = startOfCivilWeek( requestedMinDate, weekStartsOn );
132
+
133
+ // `gridStart` rounds a start bound down to its week; those days are filler like the
134
+ // rest of the widened span, not a ragged edge that would notch the first column.
135
+ const gridMinDayKey = requestedMinDayKey < minDayKey ? civilKey( gridStart ) : requestedMinDayKey;
136
+ const weekCount = civilWeekSpan( gridStart, gridMaxDate, weekStartsOn );
137
+
138
+ const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
139
+ LABELLED_ROWS.includes( row ) ? formatWeekday( addCivilDays( gridStart, row ) ) : ''
140
+ );
141
+
142
+ // Hide short partial first-month labels when a later month follows; compact
143
+ // cells make adjacent labels collide. Keep the label for single-month ranges.
144
+ const MIN_FIRST_MONTH_WEEKS = 2;
145
+ const firstMonth = gridStart.getUTCMonth();
146
+ let firstMonthWeeks = 0;
147
+ while (
148
+ firstMonthWeeks < weekCount &&
149
+ addCivilDays( gridStart, firstMonthWeeks * 7 ).getUTCMonth() === firstMonth
150
+ ) {
151
+ firstMonthWeeks++;
152
+ }
153
+ const spansLaterMonth = firstMonthWeeks < weekCount;
154
+ const showFirstMonthLabel = ! spansLaterMonth || firstMonthWeeks >= MIN_FIRST_MONTH_WEEKS;
155
+
156
+ const data: HeatmapColumn[] = [];
157
+ let previousMonth = -1;
158
+ for ( let week = 0; week < weekCount; week++ ) {
159
+ const columnStart = addCivilDays( gridStart, week * 7 );
160
+ const month = columnStart.getUTCMonth();
161
+ const isNewMonth = month !== previousMonth;
162
+ const label =
163
+ isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? formatMonth( columnStart ) : '';
164
+ previousMonth = month;
165
+
166
+ const cells: HeatmapCell[] = [];
167
+ for ( let row = 0; row < 7; row++ ) {
168
+ const day = addCivilDays( gridStart, week * 7 + row );
169
+ const key = civilKey( day );
170
+ const cell: HeatmapCell = {
171
+ label: formatDay( day ),
172
+ value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
173
+ };
174
+ if ( key < gridMinDayKey || key > gridMaxDayKey ) {
175
+ // The days completing the grid's first and last week: the ragged edge.
176
+ if ( hideOutOfRangeDays ) {
177
+ cell.hidden = true;
178
+ }
179
+ } else if ( key < minDayKey || key > maxDayKey ) {
180
+ // Inside the grid the caller asked for, outside the series: filler.
181
+ cell.placeholder = true;
182
+ }
183
+ cells.push( cell );
184
+ }
185
+ data.push( { label, data: cells } );
186
+ }
187
+
188
+ return { data, rowLabels };
189
+ };
@@ -113,6 +113,37 @@ $focus-ring-width: var(--wpds-border-width-focus);
113
113
  background: color-mix(in sRGB, var(--a8c-charts-color-track) 45%, transparent);
114
114
  }
115
115
 
116
+ // A roll-up beside the grid, on another scale from it: no fill, its figure
117
+ // always printed and emphasised.
118
+ .heatmap-chart__cell--summary {
119
+ background: none;
120
+ }
121
+
122
+ .heatmap-chart__col-label--summary {
123
+ font-weight: var(--wpds-typography-font-weight-emphasis);
124
+ }
125
+
126
+ .heatmap-chart__cell--summary .heatmap-chart__cell-value {
127
+ font-weight: var(--wpds-typography-font-weight-emphasis);
128
+ }
129
+
130
+ // Compact rows are `compactCellSize` tall, so the figure shrinks to fit them.
131
+ .heatmap-chart__grid--compact .heatmap-chart__cell--summary .heatmap-chart__cell-value {
132
+ font-size: var(--wpds-typography-font-size-xs);
133
+ line-height: 1;
134
+ }
135
+
136
+ // Deliberately on top of the grid gap: one extra gap sets a summary column
137
+ // apart from the data beside it, on cells and label alike, and the auto track
138
+ // grows by the same amount.
139
+ .heatmap-chart__gap-start {
140
+ margin-inline-start: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
141
+ }
142
+
143
+ .heatmap-chart__gap-end {
144
+ margin-inline-end: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
145
+ }
146
+
116
147
  // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
117
148
  // an empty cell.
118
149
  .heatmap-chart__cell--filled {
@@ -43,6 +43,10 @@ import type { CSSProperties, FC } from 'react';
43
43
  // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
44
44
  const CELL_MIX_FLOOR = 0.15;
45
45
 
46
+ // One instance, not a `[]` default in the signature: `buildTooltipData` keys on
47
+ // it, and a fresh array per render re-ran the keyboard tooltip effect endlessly.
48
+ const NO_ROW_LABELS: string[] = [];
49
+
46
50
  const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
47
51
  data,
48
52
  chartId: providedChartId,
@@ -55,7 +59,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
55
59
  maxCellHeight,
56
60
  minCellWidth,
57
61
  minCellHeight,
58
- rowLabels = [],
62
+ rowLabels = NO_ROW_LABELS,
59
63
  primaryColor,
60
64
  gap = 'md',
61
65
  withTooltips = false,
@@ -312,9 +316,18 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
312
316
  const rowTrack = compact
313
317
  ? 'var(--a8c-charts-dimension-heatmap-cell-size)'
314
318
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
319
+ // A summary column takes a content-sized track: a roll-up is wider than a
320
+ // cell, and a shared track would stretch every cell to fit it. `max-content`
321
+ // as the max keeps the leftover width out of it once the data tracks hit
322
+ // `maxCellWidth`, where a plain `auto` would absorb it.
323
+ const columnTracks = data.some( column => column.summary )
324
+ ? data
325
+ .map( column => ( column.summary ? 'minmax(auto, max-content)' : columnTrack ) )
326
+ .join( ' ' )
327
+ : `repeat(${ columns }, ${ columnTrack })`;
315
328
  const gridStyle: Record< string, string | number > = {
316
329
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
317
- gridTemplateColumns: `auto repeat(${ columns }, ${ columnTrack })`,
330
+ gridTemplateColumns: `auto ${ columnTracks }`,
318
331
  gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
319
332
  };
320
333
  if ( compact ) {
@@ -322,6 +335,21 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
322
335
  gridStyle[ '--a8c-charts-dimension-heatmap-cell-size' ] = `${ compactCellSize }px`;
323
336
  }
324
337
 
338
+ // A summary column sits one gap apart from the data on either side; two
339
+ // summaries side by side share no extra gap.
340
+ const summaryGaps = ( columnIndex: number ) => {
341
+ if ( ! data[ columnIndex ]?.summary ) {
342
+ return {};
343
+ }
344
+
345
+ return {
346
+ [ styles[ 'heatmap-chart__gap-start' ] ]:
347
+ columnIndex > 0 && ! data[ columnIndex - 1 ]?.summary,
348
+ [ styles[ 'heatmap-chart__gap-end' ] ]:
349
+ columnIndex < columns - 1 && ! data[ columnIndex + 1 ]?.summary,
350
+ };
351
+ };
352
+
325
353
  const activeDescendant =
326
354
  selectedIndex !== undefined
327
355
  ? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }`
@@ -374,7 +402,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
374
402
  { data.map( ( column, columnIndex ) => (
375
403
  <span
376
404
  key={ `col-${ columnIndex }` }
377
- className={ styles[ 'heatmap-chart__col-label' ] }
405
+ className={ clsx( styles[ 'heatmap-chart__col-label' ], {
406
+ [ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
407
+ ...summaryGaps( columnIndex ),
408
+ } ) }
378
409
  >
379
410
  { column.label }
380
411
  </span>
@@ -433,7 +464,9 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
433
464
 
434
465
  const value = cell?.value ?? null;
435
466
  const present = isPresent( value );
436
- const normalized = present ? getNormalizedValue( value, extent ) : 0;
467
+ // A summary cell is on another scale, so it takes no fill.
468
+ const filled = present && ! column.summary;
469
+ const normalized = filled ? getNormalizedValue( value, extent ) : 0;
437
470
  const flatIndex = columnIndex * rows + rowIndex;
438
471
  const info = buildTooltipData( columnIndex, rowIndex );
439
472
  const accessibleName =
@@ -449,7 +482,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
449
482
  <div
450
483
  key={ `cell-${ columnIndex }-${ rowIndex }` }
451
484
  id={ `${ chartId }-cell-${ columnIndex }-${ rowIndex }` }
452
- data-testid="heatmap-cell"
485
+ data-testid={ column.summary ? 'heatmap-cell-summary' : 'heatmap-cell' }
453
486
  role="gridcell"
454
487
  // Focus stays on the grid (aria-activedescendant); cells are
455
488
  // focusable but out of the tab order.
@@ -459,14 +492,16 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
459
492
  data-column={ columnIndex }
460
493
  data-row={ rowIndex }
461
494
  className={ clsx( styles[ 'heatmap-chart__cell' ], {
462
- [ styles[ 'heatmap-chart__cell--filled' ] ]: present,
495
+ [ styles[ 'heatmap-chart__cell--filled' ] ]: filled,
463
496
  [ styles[ 'heatmap-chart__cell--strong' ] ]:
464
- present && cellHasLightText( normalized ),
497
+ filled && cellHasLightText( normalized ),
498
+ [ styles[ 'heatmap-chart__cell--summary' ] ]: column.summary,
499
+ ...summaryGaps( columnIndex ),
465
500
  [ styles[ 'heatmap-chart__cell--selected' ] ]:
466
501
  selectedIndex === flatIndex,
467
502
  } ) }
468
503
  style={
469
- present
504
+ filled
470
505
  ? ( {
471
506
  '--a8c-charts-heatmap-cell-intensity': normalized,
472
507
  } as CSSProperties )
@@ -475,7 +510,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
475
510
  onMouseMove={ handleCellMouseMove }
476
511
  onMouseLeave={ handleCellMouseLeave }
477
512
  >
478
- { drawValues && present && (
513
+ { ( drawValues || column.summary ) && present && (
479
514
  <span className={ styles[ 'heatmap-chart__cell-value' ] }>
480
515
  { /* Compact display; tooltip and aria-label keep full precision. */ }
481
516
  { formatNumberCompact( value ) }
@@ -1,4 +1,11 @@
1
1
  export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
2
- export { buildCalendarHeatmapData } from './private';
3
- export type { CalendarHeatmapResult } from './private';
4
- export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types';
2
+ export { buildCalendarHeatmapData } from './build-calendar-data';
3
+ export { useCalendarHeatmapData } from './use-calendar-heatmap-data';
4
+ export type {
5
+ CalendarHeatmapOptions,
6
+ CalendarHeatmapResult,
7
+ HeatmapChartProps,
8
+ HeatmapColumn,
9
+ HeatmapCell,
10
+ HeatmapTooltipData,
11
+ } from './types';
@@ -0,0 +1,174 @@
1
+ import { parseISO } from 'date-fns';
2
+ import { hasTimezone } from '../../../utils/date-parsing';
3
+ import type { DataPointDate } from '../../../types';
4
+
5
+ // Branded so a bare string or a real instant cannot pass as either. `civilDate` and
6
+ // `writtenDayKey` validate what they mint; `civilKey` trusts its `CivilDate` argument.
7
+ declare const dayKeyBrand: unique symbol;
8
+ declare const civilDateBrand: unique symbol;
9
+
10
+ /** A calendar day as `yyyy-MM-dd`; the key every cell is bucketed on. */
11
+ export type DayKey = string & { readonly [ dayKeyBrand ]: true };
12
+
13
+ /** A UTC-midnight `Date` standing for a civil day, never a real instant. */
14
+ export type CivilDate = Date & { readonly [ civilDateBrand ]: true };
15
+
16
+ const DAY_MS = 86400000;
17
+
18
+ // A machine key, pinned so a locale like `th-TH-u-ca-buddhist-nu-thai` cannot key
19
+ // cells on Buddhist years in Thai digits.
20
+ const KEY_OPTIONS: Intl.DateTimeFormatOptions = {
21
+ calendar: 'gregory',
22
+ numberingSystem: 'latn',
23
+ year: 'numeric',
24
+ month: '2-digit',
25
+ day: '2-digit',
26
+ };
27
+
28
+ const DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
29
+
30
+ const pad = ( value: number, length: number ) => String( value ).padStart( length, '0' );
31
+
32
+ /**
33
+ * The day a UTC proxy stands for.
34
+ *
35
+ * @param date - UTC proxy.
36
+ * @return Its day key.
37
+ */
38
+ export const civilKey = ( date: CivilDate ): DayKey =>
39
+ `${ pad( date.getUTCFullYear(), 4 ) }-${ pad( date.getUTCMonth() + 1, 2 ) }-${ pad(
40
+ date.getUTCDate(),
41
+ 2
42
+ ) }` as DayKey;
43
+
44
+ /**
45
+ * A day key as a UTC-midnight proxy. UTC has no DST, so every step below is exactly
46
+ * `DAY_MS`.
47
+ *
48
+ * @param key - Day key.
49
+ * @return The proxy, or null when the key names no real day.
50
+ */
51
+ export const civilDate = ( key: string ): CivilDate | null => {
52
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec( key );
53
+ if ( ! match ) {
54
+ return null;
55
+ }
56
+
57
+ // Via the setter rather than `Date.UTC`, which reads a two-digit year as 19xx.
58
+ const date = new Date( 0 ) as CivilDate;
59
+ date.setUTCFullYear( Number( match[ 1 ] ), Number( match[ 2 ] ) - 1, Number( match[ 3 ] ) );
60
+ date.setUTCHours( 0, 0, 0, 0 );
61
+
62
+ // Round-trip rather than range-check each part, so Feb 30 is rejected too.
63
+ return civilKey( date ) === key ? date : null;
64
+ };
65
+
66
+ /**
67
+ * The day a written date names, ignoring any time and zone it carries.
68
+ *
69
+ * @param text - `yyyy-MM-dd`, optionally followed by a time.
70
+ * @return Its day key, or null.
71
+ */
72
+ export const writtenDayKey = ( text: string ): DayKey | null => {
73
+ const match = DATE_PREFIX.exec( text );
74
+ if ( ! match ) {
75
+ return null;
76
+ }
77
+
78
+ const key = `${ match[ 1 ] }-${ match[ 2 ] }-${ match[ 3 ] }`;
79
+ return civilDate( key ) ? ( key as DayKey ) : null;
80
+ };
81
+
82
+ /**
83
+ * Reads the day an instant falls on in a zone.
84
+ *
85
+ * @param timeZone - IANA zone name; absent leaves the runtime's own.
86
+ * @return Reader taking a `Date`, null for a day no key can hold.
87
+ */
88
+ export const instantDayReader = ( timeZone?: string ) => {
89
+ const formatter = new Intl.DateTimeFormat( 'en-US', { ...KEY_OPTIONS, timeZone } );
90
+
91
+ return ( date: Date ): DayKey | null => {
92
+ const parts = formatter.formatToParts( date );
93
+ const read = ( type: Intl.DateTimeFormatPartTypes ) =>
94
+ parts.find( part => part.type === type )?.value ?? '';
95
+
96
+ const key = `${ pad( Number( read( 'year' ) ), 4 ) }-${ read( 'month' ) }-${ read( 'day' ) }`;
97
+ // A `Date` reaches year 275760, past the four digits a key holds, and a wider
98
+ // key would break the lexicographic ordering the buckets are sorted on.
99
+ return civilDate( key ) ? ( key as DayKey ) : null;
100
+ };
101
+ };
102
+
103
+ /**
104
+ * The day a series point belongs to.
105
+ *
106
+ * A `Date`, or a string carrying `Z` or an offset, is an instant read in the host's
107
+ * zone. Anything else is a wall clock the host wrote, so its date part is the day.
108
+ *
109
+ * @param point - Series point.
110
+ * @param readInstant - Reader from `instantDayReader`.
111
+ * @return Its day key, or null when the point carries no usable date.
112
+ */
113
+ export const pointDayKey = (
114
+ point: DataPointDate,
115
+ readInstant: ( date: Date ) => DayKey | null
116
+ ): DayKey | null => {
117
+ // An unusable `date` falls through to `dateString`: a host that fills both fields
118
+ // has a working value in the second. A year too wide for a key is unusable too.
119
+ if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) {
120
+ const key = readInstant( point.date );
121
+ if ( key ) {
122
+ return key;
123
+ }
124
+ }
125
+
126
+ const text = point.dateString;
127
+ if ( ! text ) {
128
+ return null;
129
+ }
130
+
131
+ if ( ! hasTimezone( text ) ) {
132
+ return writtenDayKey( text );
133
+ }
134
+
135
+ const parsed = parseISO( text );
136
+ return isNaN( parsed.getTime() ) ? null : readInstant( parsed );
137
+ };
138
+
139
+ /**
140
+ * Steps a proxy by whole days.
141
+ *
142
+ * @param date - UTC proxy.
143
+ * @param days - Days to add; may be negative.
144
+ * @return The shifted proxy.
145
+ */
146
+ export const addCivilDays = ( date: CivilDate, days: number ): CivilDate =>
147
+ new Date( date.getTime() + days * DAY_MS ) as CivilDate;
148
+
149
+ /**
150
+ * Rounds a proxy down to its week.
151
+ *
152
+ * @param date - UTC proxy.
153
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
154
+ * @return The proxy for that week's first day.
155
+ */
156
+ export const startOfCivilWeek = ( date: CivilDate, weekStartsOn: 0 | 1 ): CivilDate => {
157
+ const daysSinceWeekStart = ( date.getUTCDay() - weekStartsOn + 7 ) % 7;
158
+ return addCivilDays( date, -daysSinceWeekStart );
159
+ };
160
+
161
+ /**
162
+ * Counts the columns a span needs.
163
+ *
164
+ * @param from - Earlier UTC proxy.
165
+ * @param to - Later UTC proxy.
166
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
167
+ * @return Columns needed to cover the span, inclusive.
168
+ */
169
+ export const civilWeekSpan = ( from: CivilDate, to: CivilDate, weekStartsOn: 0 | 1 ): number =>
170
+ Math.floor(
171
+ ( startOfCivilWeek( to, weekStartsOn ).getTime() -
172
+ startOfCivilWeek( from, weekStartsOn ).getTime() ) /
173
+ ( 7 * DAY_MS )
174
+ ) + 1;
@@ -1,7 +1,5 @@
1
1
  export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
2
- export { buildCalendarHeatmapData } from './build-calendar-data';
3
2
  export { HeatmapLegend } from './heatmap-legend';
4
3
  export { HeatmapContext } from './heatmap-context';
5
- export type { CalendarHeatmapResult } from './build-calendar-data';
6
4
  export type { HeatmapLegendProps } from './heatmap-legend';
7
5
  export type { HeatmapContextValue } from './heatmap-context';
@@ -4,7 +4,8 @@ export const isPresent = ( value: number | null | undefined ): value is number =
4
4
  value !== null && value !== undefined && ! isNaN( value );
5
5
 
6
6
  /**
7
- * Get the min and max values from heatmap data, ignoring null/NaN.
7
+ * Get the min and max values from heatmap data, ignoring null/NaN. Summary
8
+ * columns stay out: a roll-up on the scale would flatten every real cell.
8
9
  * @param data - The heatmap columns
9
10
  * @return Tuple of [min, max] values
10
11
  */
@@ -12,6 +13,9 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
12
13
  let min = Infinity;
13
14
  let max = -Infinity;
14
15
  for ( const column of data ) {
16
+ if ( column.summary ) {
17
+ continue;
18
+ }
15
19
  for ( const cell of column.data ) {
16
20
  if ( ! isPresent( cell.value ) ) {
17
21
  continue;
@@ -1,4 +1,4 @@
1
- import { buildCalendarHeatmapData } from '../private/build-calendar-data';
1
+ import { buildCalendarHeatmapData } from '../build-calendar-data';
2
2
  import type { DataPointDate } from '../../../types';
3
3
 
4
4
  const series: DataPointDate[] = [
@@ -113,6 +113,7 @@ describe( 'buildCalendarHeatmapData', () => {
113
113
  expect( values ).toContain( 3 );
114
114
  expect( values ).not.toContain( 9 );
115
115
  expect( values ).not.toContain( 7 );
116
+ expect( console ).toHaveWarned();
116
117
  } );
117
118
 
118
119
  test( 'returns empty result when every entry has an invalid date', () => {
@@ -121,6 +122,7 @@ describe( 'buildCalendarHeatmapData', () => {
121
122
  { date: new Date( NaN ), value: 2 },
122
123
  ];
123
124
  expect( buildCalendarHeatmapData( allInvalid ) ).toEqual( { data: [], rowLabels: [] } );
125
+ expect( console ).toHaveWarned();
124
126
  } );
125
127
 
126
128
  test( 'duplicate days keep the last value (no aggregation)', () => {
@@ -195,6 +197,7 @@ describe( 'buildCalendarHeatmapData with a grid wider than the series', () => {
195
197
  } );
196
198
 
197
199
  expect( data ).toHaveLength( 1 );
200
+ expect( console ).toHaveWarned();
198
201
  } );
199
202
 
200
203
  test( 'separates the ragged edge from the filler', () => {