@automattic/charts 4.0.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +7 -5
- package/dist/index.cjs +614 -259
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -0
- package/dist/index.d.cts +88 -74
- package/dist/index.d.ts +88 -74
- package/dist/index.js +615 -261
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
- package/src/charts/heatmap-chart/index.ts +10 -3
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/index.ts +0 -2
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
- package/src/charts/heatmap-chart/types.ts +35 -0
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.tsx +41 -7
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +178 -38
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +13 -2
- package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
- package/src/components/tooltip/accessible-tooltip.tsx +40 -12
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
- package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
- package/src/hooks/test/use-chart-margin.test.tsx +173 -3
- package/src/hooks/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/index.ts +4 -0
- package/src/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +35 -1
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +2 -2
- package/src/utils/date-formatting.ts +1 -18
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/test/date-parsing.auckland.test.ts +6 -0
- package/src/utils/test/date-parsing.santiago.test.ts +6 -0
- package/src/utils/test/date-parsing.test.ts +16 -1
- package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
- package/src/utils/warn-once.ts +23 -0
- package/src/visx/types.ts +27 -3
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
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.
|
|
117
|
+
"babel-jest": "30.5.1",
|
|
117
118
|
"identity-obj-proxy": "^3.0.0",
|
|
118
|
-
"jest": "30.
|
|
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
|
|
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
|
|
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,11 @@
|
|
|
1
1
|
export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
|
|
2
|
-
export { buildCalendarHeatmapData } from './
|
|
3
|
-
export
|
|
4
|
-
export type {
|
|
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 '../
|
|
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', () => {
|