@automattic/charts 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +548 -199
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +21 -0
- package/dist/index.d.cts +86 -72
- package/dist/index.d.ts +86 -72
- package/dist/index.js +549 -201
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
- package/src/charts/heatmap-chart/index.ts +2 -1
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/index.ts +1 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
- package/src/charts/heatmap-chart/types.ts +6 -0
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.tsx +39 -6
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +13 -2
- package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
- package/src/components/tooltip/accessible-tooltip.tsx +8 -2
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
- package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
- package/src/hooks/test/use-chart-margin.test.tsx +173 -3
- package/src/hooks/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/index.ts +4 -0
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +2 -2
- package/src/utils/date-formatting.ts +1 -18
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/test/date-parsing.auckland.test.ts +6 -0
- package/src/utils/test/date-parsing.santiago.test.ts +6 -0
- package/src/utils/test/date-parsing.test.ts +16 -1
- package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
- package/src/utils/warn-once.ts +23 -0
- package/src/visx/types.ts +27 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
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": {
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@automattic/number-formatters": "^1.2.8",
|
|
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,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
|
|
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
|
-
|
|
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=
|
|
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' ] ]:
|
|
495
|
+
[ styles[ 'heatmap-chart__cell--filled' ] ]: filled,
|
|
463
496
|
[ styles[ 'heatmap-chart__cell--strong' ] ]:
|
|
464
|
-
|
|
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
|
-
|
|
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,5 @@
|
|
|
1
1
|
export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
|
|
2
2
|
export { buildCalendarHeatmapData } from './private';
|
|
3
|
-
export
|
|
3
|
+
export { useCalendarHeatmapData } from './use-calendar-heatmap-data';
|
|
4
|
+
export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './private';
|
|
4
5
|
export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types';
|
|
@@ -1,12 +1,48 @@
|
|
|
1
|
-
import {
|
|
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 './civil-day';
|
|
2
13
|
import type { DataPointDate } from '../../../types';
|
|
3
14
|
import type { HeatmapCell, HeatmapColumn } from '../types';
|
|
15
|
+
import type { CivilDate, DayKey } from './civil-day';
|
|
4
16
|
|
|
5
17
|
export type CalendarHeatmapResult = {
|
|
6
18
|
data: HeatmapColumn[];
|
|
7
19
|
rowLabels: string[];
|
|
8
20
|
};
|
|
9
21
|
|
|
22
|
+
export type CalendarHeatmapOptions = {
|
|
23
|
+
weekStartsOn?: 0 | 1;
|
|
24
|
+
/**
|
|
25
|
+
* Mark the days completing the first and last week outside the series' span as
|
|
26
|
+
* hidden cells rather than blank ones, giving the calendar ragged edges. Days
|
|
27
|
+
* inside the span stay blank even where the series has no entry.
|
|
28
|
+
*/
|
|
29
|
+
hideOutOfRangeDays?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the series' own.
|
|
32
|
+
* Days inside the grid but outside the series become placeholder cells: painted,
|
|
33
|
+
* but reporting nothing, since they were never measured. A start bound opens from
|
|
34
|
+
* the beginning of its week, and bounds narrower than the series are ignored.
|
|
35
|
+
*/
|
|
36
|
+
gridSpan?: { start?: string; end?: string };
|
|
37
|
+
/** BCP-47 tag the labels are written in. Defaults to the runtime's locale. */
|
|
38
|
+
locale?: string;
|
|
39
|
+
/**
|
|
40
|
+
* IANA zone the series' instants are bucketed into days in. Defaults to the
|
|
41
|
+
* runtime's zone. A `dateString` carrying no offset is taken as written.
|
|
42
|
+
*/
|
|
43
|
+
timeZone?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
10
46
|
/** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
|
|
11
47
|
const LABELLED_ROWS = [ 0, 2, 4 ];
|
|
12
48
|
|
|
@@ -17,125 +53,124 @@ const LABELLED_ROWS = [ 0, 2, 4 ];
|
|
|
17
53
|
* @param bound - Requested bound as `yyyy-MM-dd`, if any.
|
|
18
54
|
* @param fallback - The series' own bound on this side.
|
|
19
55
|
* @param direction - Which way the bound is allowed to move.
|
|
20
|
-
* @return The
|
|
56
|
+
* @return The day key to draw to.
|
|
21
57
|
*/
|
|
22
58
|
const widenTo = (
|
|
23
59
|
bound: string | undefined,
|
|
24
|
-
fallback:
|
|
60
|
+
fallback: DayKey,
|
|
25
61
|
direction: 'earlier' | 'later'
|
|
26
|
-
):
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
62
|
+
): DayKey => {
|
|
63
|
+
const key = bound ? writtenDayKey( bound ) : null;
|
|
64
|
+
if ( ! key ) {
|
|
65
|
+
if ( bound ) {
|
|
66
|
+
warnOnce(
|
|
67
|
+
`heatmap:gridSpan:${ bound }`,
|
|
68
|
+
`gridSpan.${ direction === 'earlier' ? 'start' : 'end' } ${ JSON.stringify(
|
|
69
|
+
bound
|
|
70
|
+
) } is not a \`yyyy-MM-dd\` day, so the grid is drawn over the series' own span.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
33
73
|
return fallback;
|
|
34
74
|
}
|
|
35
75
|
|
|
36
76
|
if ( direction === 'earlier' ) {
|
|
37
|
-
return
|
|
77
|
+
return key < fallback ? key : fallback;
|
|
38
78
|
}
|
|
39
79
|
|
|
40
|
-
return
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const toDate = ( point: DataPointDate ): Date | null => {
|
|
44
|
-
if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) {
|
|
45
|
-
return point.date;
|
|
46
|
-
}
|
|
47
|
-
if ( point.dateString ) {
|
|
48
|
-
const parsed = parseISO( point.dateString );
|
|
49
|
-
if ( ! isNaN( parsed.getTime() ) ) {
|
|
50
|
-
return parsed;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return null;
|
|
80
|
+
return key > fallback ? key : fallback;
|
|
54
81
|
};
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Lay a day-bucketed series out as calendar columns.
|
|
85
|
+
*
|
|
86
|
+
* @param series - Points to bucket.
|
|
87
|
+
* @param options - Grid shape, plus the locale and zone to read days in.
|
|
88
|
+
* @return Columns and row labels for `HeatmapChart`.
|
|
89
|
+
*/
|
|
59
90
|
export const buildCalendarHeatmapData = (
|
|
60
91
|
series: DataPointDate[],
|
|
61
|
-
options: {
|
|
62
|
-
weekStartsOn?: 0 | 1;
|
|
63
|
-
/**
|
|
64
|
-
* Mark the days completing the first/last week outside the series'
|
|
65
|
-
* date span as hidden cells (empty grid slots) instead of blank
|
|
66
|
-
* cells, giving the calendar ragged edges. Days inside the span stay
|
|
67
|
-
* blank cells even when the series has no entry for them.
|
|
68
|
-
*/
|
|
69
|
-
hideOutOfRangeDays?: boolean;
|
|
70
|
-
/**
|
|
71
|
-
* Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the
|
|
72
|
-
* series' own. Days inside the grid but outside the series become
|
|
73
|
-
* placeholder cells: painted as empty slots so a short series still
|
|
74
|
-
* fills its container, but reporting nothing, since they were never
|
|
75
|
-
* measured. A start bound is drawn from the beginning of its week, so
|
|
76
|
-
* the grid always opens on a whole column. Bounds narrower than the
|
|
77
|
-
* series are ignored — the grid never drops a day that carries data.
|
|
78
|
-
*/
|
|
79
|
-
gridSpan?: { start?: string; end?: string };
|
|
80
|
-
} = {}
|
|
92
|
+
options: CalendarHeatmapOptions = {}
|
|
81
93
|
): CalendarHeatmapResult => {
|
|
82
94
|
const weekStartsOn = options.weekStartsOn ?? 1;
|
|
83
95
|
const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
// The provider sanitizes what it holds, but this function is called outside it too.
|
|
98
|
+
const { locale, timeZone } = sanitizeFormatting( {
|
|
99
|
+
locale: options.locale,
|
|
100
|
+
timeZone: options.timeZone,
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
const readInstant = instantDayReader( timeZone );
|
|
104
|
+
|
|
105
|
+
const valueByDay = new Map< DayKey, number | null >();
|
|
106
|
+
let minDayKey: DayKey | undefined;
|
|
107
|
+
let maxDayKey: DayKey | undefined;
|
|
108
|
+
for ( const point of series ) {
|
|
109
|
+
const key = pointDayKey( point, readInstant );
|
|
110
|
+
if ( ! key ) {
|
|
111
|
+
const offending = point.dateString ?? ( point.date && String( point.date ) );
|
|
112
|
+
warnOnce(
|
|
113
|
+
`heatmap:unreadableDate:${ offending }`,
|
|
114
|
+
offending
|
|
115
|
+
? `${ JSON.stringify(
|
|
116
|
+
offending
|
|
117
|
+
) } is not a day this can read, so its point is left out of the calendar. A \`dateString\` must start \`yyyy-MM-dd\`.`
|
|
118
|
+
: 'A point carries neither `date` nor `dateString`, so it is left out of the calendar.'
|
|
119
|
+
);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
92
122
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
valueByDay.set( format( date, 'yyyy-MM-dd' ), value );
|
|
98
|
-
if ( date < minDate ) {
|
|
99
|
-
minDate = date;
|
|
123
|
+
valueByDay.set( key, point.value );
|
|
124
|
+
// Day keys are fixed-width, so lexicographic order is chronological order.
|
|
125
|
+
if ( ! minDayKey || key < minDayKey ) {
|
|
126
|
+
minDayKey = key;
|
|
100
127
|
}
|
|
101
|
-
if (
|
|
102
|
-
|
|
128
|
+
if ( ! maxDayKey || key > maxDayKey ) {
|
|
129
|
+
maxDayKey = key;
|
|
103
130
|
}
|
|
104
131
|
}
|
|
105
132
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const maxDayKey = format( maxDate, 'yyyy-MM-dd' );
|
|
110
|
-
|
|
111
|
-
// The grid may run wider than the series, never narrower: a bound that would
|
|
112
|
-
// cut into the data is dropped rather than honoured.
|
|
113
|
-
const gridMinDate = widenTo( options.gridSpan?.start, minDate, 'earlier' );
|
|
114
|
-
const gridMaxDate = widenTo( options.gridSpan?.end, maxDate, 'later' );
|
|
133
|
+
if ( ! minDayKey || ! maxDayKey ) {
|
|
134
|
+
return { data: [], rowLabels: [] };
|
|
135
|
+
}
|
|
115
136
|
|
|
116
|
-
const
|
|
137
|
+
const requestedMinDayKey = widenTo( options.gridSpan?.start, minDayKey, 'earlier' );
|
|
138
|
+
const gridMaxDayKey = widenTo( options.gridSpan?.end, maxDayKey, 'later' );
|
|
139
|
+
|
|
140
|
+
// Both are `DayKey`s, so they already round-tripped through `civilDate` once, and it
|
|
141
|
+
// is a pure function of the string: re-parsing them here cannot fail.
|
|
142
|
+
const requestedMinDate = civilDate( requestedMinDayKey ) as CivilDate;
|
|
143
|
+
const gridMaxDate = civilDate( gridMaxDayKey ) as CivilDate;
|
|
144
|
+
|
|
145
|
+
// The grid walks UTC proxies, so the label formatters read UTC. The host's zone
|
|
146
|
+
// was already spent on bucketing.
|
|
147
|
+
const labelFormatting = { locale, timeZone: 'UTC' };
|
|
148
|
+
const formatWeekday = createDateFormatter( { weekday: 'short' }, labelFormatting );
|
|
149
|
+
const formatMonth = createDateFormatter( { month: 'short' }, labelFormatting );
|
|
150
|
+
const formatDay = createDateFormatter(
|
|
151
|
+
{ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' },
|
|
152
|
+
labelFormatting
|
|
153
|
+
);
|
|
117
154
|
|
|
118
|
-
const gridStart =
|
|
155
|
+
const gridStart = startOfCivilWeek( requestedMinDate, weekStartsOn );
|
|
119
156
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const gridMinDayKey = format( gridMinDate < minDate ? gridStart : gridMinDate, 'yyyy-MM-dd' );
|
|
125
|
-
const weekCount = differenceInCalendarWeeks( gridMaxDate, gridStart, { weekStartsOn } ) + 1;
|
|
157
|
+
// `gridStart` rounds a start bound down to its week; those days are filler like the
|
|
158
|
+
// rest of the widened span, not a ragged edge that would notch the first column.
|
|
159
|
+
const gridMinDayKey = requestedMinDayKey < minDayKey ? civilKey( gridStart ) : requestedMinDayKey;
|
|
160
|
+
const weekCount = civilWeekSpan( gridStart, gridMaxDate, weekStartsOn );
|
|
126
161
|
|
|
127
162
|
const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
|
|
128
|
-
LABELLED_ROWS.includes( row ) ?
|
|
163
|
+
LABELLED_ROWS.includes( row ) ? formatWeekday( addCivilDays( gridStart, row ) ) : ''
|
|
129
164
|
);
|
|
130
165
|
|
|
131
166
|
// Hide short partial first-month labels when a later month follows; compact
|
|
132
167
|
// cells make adjacent labels collide. Keep the label for single-month ranges.
|
|
133
168
|
const MIN_FIRST_MONTH_WEEKS = 2;
|
|
134
|
-
const firstMonth = gridStart.
|
|
169
|
+
const firstMonth = gridStart.getUTCMonth();
|
|
135
170
|
let firstMonthWeeks = 0;
|
|
136
171
|
while (
|
|
137
172
|
firstMonthWeeks < weekCount &&
|
|
138
|
-
|
|
173
|
+
addCivilDays( gridStart, firstMonthWeeks * 7 ).getUTCMonth() === firstMonth
|
|
139
174
|
) {
|
|
140
175
|
firstMonthWeeks++;
|
|
141
176
|
}
|
|
@@ -145,24 +180,23 @@ export const buildCalendarHeatmapData = (
|
|
|
145
180
|
const data: HeatmapColumn[] = [];
|
|
146
181
|
let previousMonth = -1;
|
|
147
182
|
for ( let week = 0; week < weekCount; week++ ) {
|
|
148
|
-
const columnStart =
|
|
149
|
-
const month = columnStart.
|
|
183
|
+
const columnStart = addCivilDays( gridStart, week * 7 );
|
|
184
|
+
const month = columnStart.getUTCMonth();
|
|
150
185
|
const isNewMonth = month !== previousMonth;
|
|
151
186
|
const label =
|
|
152
|
-
isNewMonth && ( week !== 0 || showFirstMonthLabel ) ?
|
|
187
|
+
isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? formatMonth( columnStart ) : '';
|
|
153
188
|
previousMonth = month;
|
|
154
189
|
|
|
155
190
|
const cells: HeatmapCell[] = [];
|
|
156
191
|
for ( let row = 0; row < 7; row++ ) {
|
|
157
|
-
const day =
|
|
158
|
-
const key =
|
|
192
|
+
const day = addCivilDays( gridStart, week * 7 + row );
|
|
193
|
+
const key = civilKey( day );
|
|
159
194
|
const cell: HeatmapCell = {
|
|
160
|
-
label:
|
|
195
|
+
label: formatDay( day ),
|
|
161
196
|
value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
|
|
162
197
|
};
|
|
163
198
|
if ( key < gridMinDayKey || key > gridMaxDayKey ) {
|
|
164
|
-
// The days completing the grid's first
|
|
165
|
-
// ragged edge.
|
|
199
|
+
// The days completing the grid's first and last week: the ragged edge.
|
|
166
200
|
if ( hideOutOfRangeDays ) {
|
|
167
201
|
cell.hidden = true;
|
|
168
202
|
}
|
|
@@ -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;
|
|
@@ -2,6 +2,6 @@ export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-col
|
|
|
2
2
|
export { buildCalendarHeatmapData } from './build-calendar-data';
|
|
3
3
|
export { HeatmapLegend } from './heatmap-legend';
|
|
4
4
|
export { HeatmapContext } from './heatmap-context';
|
|
5
|
-
export type { CalendarHeatmapResult } from './build-calendar-data';
|
|
5
|
+
export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './build-calendar-data';
|
|
6
6
|
export type { HeatmapLegendProps } from './heatmap-legend';
|
|
7
7
|
export type { HeatmapContextValue } from './heatmap-context';
|