@automattic/charts 4.1.1 → 4.3.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 +39 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +1335 -348
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +129 -8
- package/dist/index.d.ts +129 -8
- package/dist/index.js +1336 -351
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/area-chart/private/validate-data.ts +2 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
- package/src/charts/bar-chart/bar-chart.tsx +137 -70
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +3 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
- package/src/charts/bar-chart/types.ts +39 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +6 -0
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -0
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +59 -3
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +149 -36
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
- package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
- package/src/charts/line-chart/types.ts +4 -3
- package/src/charts/private/readings.ts +34 -0
- package/src/charts/private/test/readings.test.ts +29 -0
- package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
- package/src/charts/private/time-axis-options.ts +1 -1
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
- package/src/components/legend/private/base-legend.tsx +37 -22
- package/src/components/legend/test/legend.test.tsx +84 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/legend/utils/index.ts +0 -1
- package/src/components/tooltip/accessible-tooltip.tsx +128 -41
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/global-charts-provider.tsx +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/test/chart-scope.test.ts +1 -1
- package/src/types.ts +14 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- package/src/visx/types.ts +7 -10
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
|
|
2
|
-
import { useTooltip } from '@visx/tooltip';
|
|
2
|
+
import { defaultStyles as visxTooltipStyles, useTooltip } from '@visx/tooltip';
|
|
3
3
|
import { __ } from '@wordpress/i18n';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
5
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
-
import
|
|
6
|
+
import tooltipStyles from '../../components/tooltip/base-tooltip.module.scss';
|
|
7
|
+
import { BoundedTooltip, TOOLTIP_Z_INDEX } from '../../components/tooltip/private/bounded-tooltip';
|
|
7
8
|
import {
|
|
8
9
|
GlobalChartsProvider,
|
|
9
10
|
useChartId,
|
|
@@ -33,8 +34,17 @@ import {
|
|
|
33
34
|
HeatmapContext,
|
|
34
35
|
HeatmapLegend,
|
|
35
36
|
isPresent,
|
|
37
|
+
resolveColumnGroups,
|
|
36
38
|
} from './private';
|
|
39
|
+
import {
|
|
40
|
+
firstCalendarCell,
|
|
41
|
+
firstGridCell,
|
|
42
|
+
isNavigationKey,
|
|
43
|
+
stepCalendarCell,
|
|
44
|
+
stepGridCell,
|
|
45
|
+
} from './private/keyboard-navigation';
|
|
37
46
|
import type { HeatmapContextValue } from './private';
|
|
47
|
+
import type { CellBlock, CellPosition } from './private/keyboard-navigation';
|
|
38
48
|
import type { HeatmapChartProps, HeatmapTooltipData } from './types';
|
|
39
49
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
40
50
|
import type { CSSProperties, FC } from 'react';
|
|
@@ -47,6 +57,17 @@ const CELL_MIX_FLOOR = 0.15;
|
|
|
47
57
|
// it, and a fresh array per render re-ran the keyboard tooltip effect endlessly.
|
|
48
58
|
const NO_ROW_LABELS: string[] = [];
|
|
49
59
|
|
|
60
|
+
// The dark variant is painted by the `.surface` class, so its style carries no box.
|
|
61
|
+
const TOOLTIP_BOX_STYLES: Record< 'light' | 'dark', CSSProperties > = {
|
|
62
|
+
light: { ...visxTooltipStyles, zIndex: TOOLTIP_Z_INDEX },
|
|
63
|
+
dark: { zIndex: TOOLTIP_Z_INDEX },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// The cell's own label wins; otherwise the group, column and row labels name it.
|
|
67
|
+
const cellName = ( info: HeatmapTooltipData ) =>
|
|
68
|
+
info.cellLabel ||
|
|
69
|
+
[ info.groupLabel, info.columnLabel, info.rowLabel ].filter( Boolean ).join( ' ' );
|
|
70
|
+
|
|
50
71
|
const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
51
72
|
data,
|
|
52
73
|
chartId: providedChartId,
|
|
@@ -60,19 +81,27 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
60
81
|
minCellWidth,
|
|
61
82
|
minCellHeight,
|
|
62
83
|
rowLabels = NO_ROW_LABELS,
|
|
84
|
+
columnGroups,
|
|
85
|
+
keyboardNavigation = 'grid',
|
|
86
|
+
ariaLabel,
|
|
63
87
|
primaryColor,
|
|
64
88
|
gap = 'md',
|
|
65
89
|
withTooltips = false,
|
|
66
90
|
renderTooltip,
|
|
91
|
+
tooltipVariant = 'light',
|
|
92
|
+
tooltipStyle,
|
|
67
93
|
children,
|
|
68
94
|
} ) => {
|
|
69
95
|
const chartId = useChartId( providedChartId );
|
|
96
|
+
const tooltipBoxStyle = tooltipStyle
|
|
97
|
+
? { ...TOOLTIP_BOX_STYLES[ tooltipVariant ], ...tooltipStyle }
|
|
98
|
+
: TOOLTIP_BOX_STYLES[ tooltipVariant ];
|
|
70
99
|
const { getElementStyles, theme } = useGlobalChartsContext();
|
|
71
100
|
const scopeElement = useChartScopeElement();
|
|
72
101
|
const { heatmapChart: heatmapChartSettings } = theme;
|
|
73
102
|
const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' );
|
|
74
103
|
|
|
75
|
-
const [
|
|
104
|
+
const [ selected, setSelected ] = useState< CellPosition | undefined >();
|
|
76
105
|
const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } =
|
|
77
106
|
useTooltip< HeatmapTooltipData >();
|
|
78
107
|
const standaloneScopeClass = useStandaloneScopeClass();
|
|
@@ -123,27 +152,34 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
123
152
|
|
|
124
153
|
const columns = data.length;
|
|
125
154
|
const rows = Math.max( 0, ...data.map( column => column.data.length ) );
|
|
155
|
+
// Line 1 is the row-label track, so the data columns start on line 2.
|
|
156
|
+
const groupLayout = useMemo(
|
|
157
|
+
() => resolveColumnGroups( columnGroups, columns, 2 ),
|
|
158
|
+
[ columnGroups, columns ]
|
|
159
|
+
);
|
|
126
160
|
|
|
127
|
-
const { compactCellGap, compactCellSize } = heatmapChartSettings;
|
|
161
|
+
const { compactCellGap, compactCellSize, groupGap } = heatmapChartSettings;
|
|
128
162
|
const drawValues = showValues ?? ! compact;
|
|
129
163
|
|
|
130
164
|
const buildTooltipData = useCallback(
|
|
131
165
|
( columnIndex: number, rowIndex: number ): HeatmapTooltipData => {
|
|
132
166
|
const cell = data[ columnIndex ]?.data[ rowIndex ];
|
|
167
|
+
const group = groupLayout.columns[ columnIndex ]?.group;
|
|
133
168
|
return {
|
|
134
169
|
value: cell?.value ?? null,
|
|
135
170
|
rowLabel: rowLabels[ rowIndex ],
|
|
136
171
|
columnLabel: data[ columnIndex ]?.label,
|
|
172
|
+
groupLabel: group === undefined ? undefined : groupLayout.groups[ group ]?.label,
|
|
137
173
|
cellLabel: cell?.label,
|
|
138
174
|
row: rowIndex,
|
|
139
175
|
column: columnIndex,
|
|
140
176
|
};
|
|
141
177
|
},
|
|
142
|
-
[ data, rowLabels ]
|
|
178
|
+
[ data, rowLabels, groupLayout ]
|
|
143
179
|
);
|
|
144
180
|
|
|
145
181
|
const onChartBlur = useCallback( () => {
|
|
146
|
-
|
|
182
|
+
setSelected( undefined );
|
|
147
183
|
hideTooltip();
|
|
148
184
|
}, [ hideTooltip ] );
|
|
149
185
|
|
|
@@ -157,65 +193,49 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
157
193
|
[ data ]
|
|
158
194
|
);
|
|
159
195
|
|
|
196
|
+
// Calendar navigation reads each column group as a page; ungrouped columns
|
|
197
|
+
// (or the whole grid without groups) make a page of their own.
|
|
198
|
+
const blocks = useMemo< CellBlock[] >( () => {
|
|
199
|
+
const grouped = groupLayout.groups.map( ( group, index ) => {
|
|
200
|
+
const start = groupLayout.columns.findIndex( column => column.group === index );
|
|
201
|
+
return { start, end: start + group.span };
|
|
202
|
+
} );
|
|
203
|
+
const groupedEnd = grouped.length ? grouped[ grouped.length - 1 ].end : 0;
|
|
204
|
+
return groupedEnd < columns ? [ ...grouped, { start: groupedEnd, end: columns } ] : grouped;
|
|
205
|
+
}, [ groupLayout, columns ] );
|
|
206
|
+
|
|
160
207
|
const onChartKeyDown = useCallback(
|
|
161
208
|
( event: React.KeyboardEvent< HTMLDivElement > ) => {
|
|
162
|
-
if (
|
|
163
|
-
! [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape', 'Tab' ].includes(
|
|
164
|
-
event.key
|
|
165
|
-
)
|
|
166
|
-
) {
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
209
|
if ( event.key === 'Tab' || event.key === 'Escape' ) {
|
|
171
|
-
|
|
210
|
+
setSelected( undefined );
|
|
172
211
|
hideTooltip();
|
|
173
212
|
return;
|
|
174
213
|
}
|
|
175
|
-
|
|
176
|
-
event.preventDefault();
|
|
177
|
-
|
|
178
|
-
if ( selectedIndex === undefined ) {
|
|
179
|
-
// Start at the first navigable cell (a calendar's leading edge
|
|
180
|
-
// slots may be hidden).
|
|
181
|
-
for ( let index = 0; index < columns * rows; index++ ) {
|
|
182
|
-
if ( ! isCellInert( Math.floor( index / rows ), index % rows ) ) {
|
|
183
|
-
setSelectedIndex( index );
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
214
|
+
if ( ! isNavigationKey( event.key, keyboardNavigation ) ) {
|
|
187
215
|
return;
|
|
188
216
|
}
|
|
189
217
|
|
|
190
|
-
|
|
191
|
-
let stepRow = 0;
|
|
192
|
-
if ( event.key === 'ArrowRight' ) {
|
|
193
|
-
stepCol = 1;
|
|
194
|
-
} else if ( event.key === 'ArrowLeft' ) {
|
|
195
|
-
stepCol = -1;
|
|
196
|
-
} else if ( event.key === 'ArrowDown' ) {
|
|
197
|
-
stepRow = 1;
|
|
198
|
-
} else if ( event.key === 'ArrowUp' ) {
|
|
199
|
-
stepRow = -1;
|
|
200
|
-
}
|
|
218
|
+
event.preventDefault();
|
|
201
219
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
row += stepRow;
|
|
210
|
-
} while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellInert( col, row ) );
|
|
211
|
-
|
|
212
|
-
if ( col < 0 || col >= columns || row < 0 || row >= rows ) {
|
|
220
|
+
const grid = { columns, rows, isInert: isCellInert };
|
|
221
|
+
if ( selected === undefined ) {
|
|
222
|
+
setSelected(
|
|
223
|
+
keyboardNavigation === 'calendar'
|
|
224
|
+
? firstCalendarCell( grid, blocks )
|
|
225
|
+
: firstGridCell( grid )
|
|
226
|
+
);
|
|
213
227
|
return;
|
|
214
228
|
}
|
|
215
229
|
|
|
216
|
-
|
|
230
|
+
const next =
|
|
231
|
+
keyboardNavigation === 'calendar'
|
|
232
|
+
? stepCalendarCell( grid, blocks, selected, event.key )
|
|
233
|
+
: stepGridCell( grid, selected, event.key );
|
|
234
|
+
if ( next ) {
|
|
235
|
+
setSelected( next );
|
|
236
|
+
}
|
|
217
237
|
},
|
|
218
|
-
[ rows, columns,
|
|
238
|
+
[ rows, columns, selected, hideTooltip, isCellInert, keyboardNavigation, blocks ]
|
|
219
239
|
);
|
|
220
240
|
|
|
221
241
|
const handleCellMouseMove = useCallback(
|
|
@@ -239,51 +259,62 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
239
259
|
[ withTooltips, showTooltip, buildTooltipData, getTooltipOrigin ]
|
|
240
260
|
);
|
|
241
261
|
|
|
262
|
+
const getCellElement = useCallback(
|
|
263
|
+
( col: number, row: number ) =>
|
|
264
|
+
typeof document !== 'undefined'
|
|
265
|
+
? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
|
|
266
|
+
: null,
|
|
267
|
+
[ chartId ]
|
|
268
|
+
);
|
|
269
|
+
|
|
242
270
|
const handleCellMouseLeave = useCallback( () => {
|
|
243
271
|
// Keyboard selection owns the tooltip; don't let a mouse-out clear it.
|
|
244
|
-
if ( withTooltips &&
|
|
272
|
+
if ( withTooltips && selected === undefined ) {
|
|
245
273
|
hideTooltip();
|
|
246
274
|
}
|
|
247
|
-
}, [ withTooltips,
|
|
275
|
+
}, [ withTooltips, selected, hideTooltip ] );
|
|
276
|
+
|
|
277
|
+
// A data refresh that shrinks the grid would leave the selection on a slot that
|
|
278
|
+
// no longer exists; navigating from there has nothing to step from.
|
|
279
|
+
useEffect( () => {
|
|
280
|
+
if ( selected && ( selected.column >= columns || selected.row >= rows ) ) {
|
|
281
|
+
setSelected( undefined );
|
|
282
|
+
}
|
|
283
|
+
}, [ selected, columns, rows ] );
|
|
284
|
+
|
|
285
|
+
// Focus stays on the grid (aria-activedescendant), so the browser never scrolls
|
|
286
|
+
// the selected cell into a scroll container's view on its own. Keyed on the
|
|
287
|
+
// selection alone: a data refresh must not scroll the user back to it.
|
|
288
|
+
useEffect( () => {
|
|
289
|
+
if ( selected === undefined ) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const cell = getCellElement( selected.column, selected.row );
|
|
293
|
+
cell?.scrollIntoView?.( { block: 'nearest', inline: 'nearest' } );
|
|
294
|
+
}, [ selected, getCellElement ] );
|
|
248
295
|
|
|
249
296
|
// Anchor the tooltip at the selected cell's center on keyboard nav. Cleared on blur/Escape,
|
|
250
297
|
// not here, so a mouse hover (no selection) isn't affected.
|
|
251
298
|
useEffect( () => {
|
|
252
|
-
if ( ! withTooltips ||
|
|
299
|
+
if ( ! withTooltips || selected === undefined ) {
|
|
253
300
|
return;
|
|
254
301
|
}
|
|
255
302
|
const origin = getTooltipOrigin();
|
|
256
303
|
if ( ! origin ) {
|
|
257
304
|
return;
|
|
258
305
|
}
|
|
259
|
-
const
|
|
260
|
-
const row = selectedIndex % rows;
|
|
261
|
-
const cell =
|
|
262
|
-
typeof document !== 'undefined'
|
|
263
|
-
? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
|
|
264
|
-
: null;
|
|
265
|
-
const rect = cell?.getBoundingClientRect();
|
|
306
|
+
const rect = getCellElement( selected.column, selected.row )?.getBoundingClientRect();
|
|
266
307
|
showTooltip( {
|
|
267
308
|
tooltipLeft: rect ? rect.left + rect.width / 2 - origin.left : 0,
|
|
268
309
|
tooltipTop: rect ? rect.top + rect.height / 2 - origin.top : 0,
|
|
269
|
-
tooltipData: buildTooltipData(
|
|
310
|
+
tooltipData: buildTooltipData( selected.column, selected.row ),
|
|
270
311
|
} );
|
|
271
|
-
}, [
|
|
272
|
-
selectedIndex,
|
|
273
|
-
withTooltips,
|
|
274
|
-
rows,
|
|
275
|
-
chartId,
|
|
276
|
-
buildTooltipData,
|
|
277
|
-
showTooltip,
|
|
278
|
-
getTooltipOrigin,
|
|
279
|
-
] );
|
|
312
|
+
}, [ selected, withTooltips, getCellElement, buildTooltipData, showTooltip, getTooltipOrigin ] );
|
|
280
313
|
|
|
281
314
|
const defaultRenderTooltip = useCallback(
|
|
282
315
|
( info: HeatmapTooltipData ) => (
|
|
283
316
|
<div>
|
|
284
|
-
<strong>
|
|
285
|
-
{ info.cellLabel || `${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim() }
|
|
286
|
-
</strong>
|
|
317
|
+
<strong>{ cellName( info ) }</strong>
|
|
287
318
|
<div>
|
|
288
319
|
{ info.value === null ? __( 'No data', 'jetpack-charts' ) : formatNumber( info.value ) }
|
|
289
320
|
</div>
|
|
@@ -316,19 +347,35 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
316
347
|
const rowTrack = compact
|
|
317
348
|
? 'var(--a8c-charts-dimension-heatmap-cell-size)'
|
|
318
349
|
: `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
|
|
350
|
+
const hasColumnLabels = data.some( column => Boolean( column.label ) );
|
|
351
|
+
const hasGroups = groupLayout.groups.length > 0;
|
|
352
|
+
// Every item is placed by hand rather than auto-flowed, so the template can
|
|
353
|
+
// carry gap tracks that hold no cell.
|
|
354
|
+
const columnLine = ( columnIndex: number ) => groupLayout.columns[ columnIndex ].line;
|
|
355
|
+
const firstDataRow = hasColumnLabels ? 2 : 1;
|
|
319
356
|
// A summary column takes a content-sized track: a roll-up is wider than a
|
|
320
357
|
// cell, and a shared track would stretch every cell to fit it. `max-content`
|
|
321
358
|
// as the max keeps the leftover width out of it once the data tracks hit
|
|
322
359
|
// `maxCellWidth`, where a plain `auto` would absorb it.
|
|
323
|
-
const
|
|
324
|
-
?
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
360
|
+
const dataTrack = ( column: ( typeof data )[ number ] ) =>
|
|
361
|
+
column.summary ? 'minmax(auto, max-content)' : columnTrack;
|
|
362
|
+
// The group gap is a track of its own: a margin cannot widen a fixed or
|
|
363
|
+
// minmax track the way it widens the summary's auto track. Compact cells
|
|
364
|
+
// are fixed, so there the gaps share the leftover width instead.
|
|
365
|
+
const gapTrack = compact ? `minmax(${ groupGap }px, 1fr)` : `${ groupGap }px`;
|
|
366
|
+
const columnTracks = data
|
|
367
|
+
.map( ( column, columnIndex ) =>
|
|
368
|
+
groupLayout.columns[ columnIndex ].gapBefore
|
|
369
|
+
? `${ gapTrack } ${ dataTrack( column ) }`
|
|
370
|
+
: dataTrack( column )
|
|
371
|
+
)
|
|
372
|
+
.join( ' ' );
|
|
328
373
|
const gridStyle: Record< string, string | number > = {
|
|
329
374
|
'--a8c-charts-color-heatmap-primary': primaryColorHex,
|
|
330
375
|
gridTemplateColumns: `auto ${ columnTracks }`,
|
|
331
|
-
gridTemplateRows:
|
|
376
|
+
gridTemplateRows: `${ hasColumnLabels ? 'auto ' : '' }repeat(${ rows }, ${ rowTrack })${
|
|
377
|
+
hasGroups ? ' auto' : ''
|
|
378
|
+
}`,
|
|
332
379
|
};
|
|
333
380
|
if ( compact ) {
|
|
334
381
|
gridStyle[ '--a8c-charts-dimension-heatmap-cell-gap' ] = `${ compactCellGap }px`;
|
|
@@ -350,10 +397,9 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
350
397
|
};
|
|
351
398
|
};
|
|
352
399
|
|
|
353
|
-
const activeDescendant =
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
: undefined;
|
|
400
|
+
const activeDescendant = selected
|
|
401
|
+
? `${ chartId }-cell-${ selected.column }-${ selected.row }`
|
|
402
|
+
: undefined;
|
|
357
403
|
|
|
358
404
|
// A capped row track makes the chart content-sized vertically: neither the
|
|
359
405
|
// wrapper nor the grid stretches, or the leftover container height would
|
|
@@ -383,7 +429,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
383
429
|
<div
|
|
384
430
|
ref={ containerRef }
|
|
385
431
|
role="grid"
|
|
386
|
-
aria-label={ __( 'Heatmap chart', 'jetpack-charts' ) }
|
|
432
|
+
aria-label={ ariaLabel ?? __( 'Heatmap chart', 'jetpack-charts' ) }
|
|
387
433
|
aria-rowcount={ rows }
|
|
388
434
|
aria-colcount={ columns }
|
|
389
435
|
aria-activedescendant={ activeDescendant }
|
|
@@ -392,28 +438,33 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
392
438
|
onKeyDown={ onChartKeyDown }
|
|
393
439
|
className={ clsx( styles[ 'heatmap-chart__grid' ], {
|
|
394
440
|
[ styles[ 'heatmap-chart__grid--compact' ] ]: compact,
|
|
441
|
+
[ styles[ 'heatmap-chart__grid--flex-gaps' ] ]: compact && hasGroups,
|
|
395
442
|
[ styles[ 'heatmap-chart__grid--height-capped' ] ]: heightCapped,
|
|
396
443
|
} ) }
|
|
397
444
|
style={ gridStyle as CSSProperties }
|
|
398
445
|
>
|
|
399
|
-
{ /*
|
|
400
|
-
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
446
|
+
{ /* Decorative: cell aria-labels already carry the column name. */ }
|
|
447
|
+
{ hasColumnLabels && (
|
|
448
|
+
<div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
|
|
449
|
+
<span style={ { gridColumn: 1, gridRow: 1 } } />
|
|
450
|
+
{ data.map( ( column, columnIndex ) => (
|
|
451
|
+
<span
|
|
452
|
+
key={ `col-${ columnIndex }` }
|
|
453
|
+
style={ { gridColumn: columnLine( columnIndex ), gridRow: 1 } }
|
|
454
|
+
className={ clsx( styles[ 'heatmap-chart__col-label' ], {
|
|
455
|
+
[ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
|
|
456
|
+
...summaryGaps( columnIndex ),
|
|
457
|
+
} ) }
|
|
458
|
+
>
|
|
459
|
+
{ column.label }
|
|
460
|
+
</span>
|
|
461
|
+
) ) }
|
|
462
|
+
</div>
|
|
463
|
+
) }
|
|
414
464
|
|
|
415
465
|
{ Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
|
|
416
466
|
const labelVisible = ! compact || rowIndex % 2 === 0;
|
|
467
|
+
const gridRow = firstDataRow + rowIndex;
|
|
417
468
|
return (
|
|
418
469
|
<div
|
|
419
470
|
key={ `row-${ rowIndex }` }
|
|
@@ -421,11 +472,16 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
421
472
|
aria-rowindex={ rowIndex + 1 }
|
|
422
473
|
className={ styles[ 'heatmap-chart__row' ] }
|
|
423
474
|
>
|
|
424
|
-
<span
|
|
425
|
-
|
|
475
|
+
<span
|
|
476
|
+
aria-hidden="true"
|
|
477
|
+
className={ styles[ 'heatmap-chart__row-label' ] }
|
|
478
|
+
style={ { gridColumn: 1, gridRow } }
|
|
479
|
+
>
|
|
480
|
+
{ labelVisible ? ( rowLabels[ rowIndex ] ?? '' ) : '' }
|
|
426
481
|
</span>
|
|
427
482
|
{ data.map( ( column, columnIndex ) => {
|
|
428
483
|
const cell = column.data[ rowIndex ];
|
|
484
|
+
const placement = { gridColumn: columnLine( columnIndex ), gridRow };
|
|
429
485
|
|
|
430
486
|
// A hidden cell keeps its grid slot (so the rest of the
|
|
431
487
|
// column doesn't shift) but paints nothing and takes no
|
|
@@ -436,6 +492,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
436
492
|
key={ `cell-${ columnIndex }-${ rowIndex }` }
|
|
437
493
|
data-testid="heatmap-cell-hidden"
|
|
438
494
|
aria-hidden="true"
|
|
495
|
+
style={ placement }
|
|
439
496
|
className={ clsx(
|
|
440
497
|
styles[ 'heatmap-chart__cell' ],
|
|
441
498
|
styles[ 'heatmap-chart__cell--hidden' ]
|
|
@@ -454,6 +511,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
454
511
|
key={ `cell-${ columnIndex }-${ rowIndex }` }
|
|
455
512
|
data-testid="heatmap-cell-placeholder"
|
|
456
513
|
aria-hidden="true"
|
|
514
|
+
style={ placement }
|
|
457
515
|
className={ clsx(
|
|
458
516
|
styles[ 'heatmap-chart__cell' ],
|
|
459
517
|
styles[ 'heatmap-chart__cell--placeholder' ]
|
|
@@ -467,12 +525,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
467
525
|
// A summary cell is on another scale, so it takes no fill.
|
|
468
526
|
const filled = present && ! column.summary;
|
|
469
527
|
const normalized = filled ? getNormalizedValue( value, extent ) : 0;
|
|
470
|
-
const flatIndex = columnIndex * rows + rowIndex;
|
|
471
528
|
const info = buildTooltipData( columnIndex, rowIndex );
|
|
472
|
-
const
|
|
473
|
-
info.cellLabel ||
|
|
474
|
-
`${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim();
|
|
475
|
-
const accessibleLabel = `${ accessibleName }: ${
|
|
529
|
+
const accessibleLabel = `${ cellName( info ) }: ${
|
|
476
530
|
info.value === null
|
|
477
531
|
? __( 'No data', 'jetpack-charts' )
|
|
478
532
|
: formatNumber( info.value )
|
|
@@ -498,14 +552,15 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
498
552
|
[ styles[ 'heatmap-chart__cell--summary' ] ]: column.summary,
|
|
499
553
|
...summaryGaps( columnIndex ),
|
|
500
554
|
[ styles[ 'heatmap-chart__cell--selected' ] ]:
|
|
501
|
-
|
|
555
|
+
selected?.column === columnIndex && selected?.row === rowIndex,
|
|
502
556
|
} ) }
|
|
503
557
|
style={
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
558
|
+
{
|
|
559
|
+
...placement,
|
|
560
|
+
...( filled
|
|
561
|
+
? { '--a8c-charts-heatmap-cell-intensity': normalized }
|
|
562
|
+
: {} ),
|
|
563
|
+
} as CSSProperties
|
|
509
564
|
}
|
|
510
565
|
onMouseMove={ handleCellMouseMove }
|
|
511
566
|
onMouseLeave={ handleCellMouseLeave }
|
|
@@ -522,9 +577,31 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
522
577
|
</div>
|
|
523
578
|
);
|
|
524
579
|
} ) }
|
|
580
|
+
{ hasGroups && (
|
|
581
|
+
<div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
|
|
582
|
+
{ groupLayout.groups.map( ( group, groupIndex ) => (
|
|
583
|
+
<span
|
|
584
|
+
key={ `group-${ groupIndex }` }
|
|
585
|
+
data-testid="heatmap-group-label"
|
|
586
|
+
className={ styles[ 'heatmap-chart__group-label' ] }
|
|
587
|
+
style={ {
|
|
588
|
+
gridColumn: `${ group.line } / span ${ group.span }`,
|
|
589
|
+
gridRow: firstDataRow + rows,
|
|
590
|
+
} }
|
|
591
|
+
>
|
|
592
|
+
{ group.label }
|
|
593
|
+
</span>
|
|
594
|
+
) ) }
|
|
595
|
+
</div>
|
|
596
|
+
) }
|
|
525
597
|
</div>
|
|
526
598
|
{ withTooltips && tooltipOpen && tooltipData && (
|
|
527
|
-
<BoundedTooltip
|
|
599
|
+
<BoundedTooltip
|
|
600
|
+
top={ tooltipTop }
|
|
601
|
+
left={ tooltipLeft }
|
|
602
|
+
className={ tooltipVariant === 'dark' ? tooltipStyles.surface : undefined }
|
|
603
|
+
style={ tooltipBoxStyle }
|
|
604
|
+
>
|
|
528
605
|
<div className={ standaloneScopeClass } role="tooltip" tabIndex={ -1 }>
|
|
529
606
|
{ ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
|
|
530
607
|
</div>
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
|
|
2
2
|
export { buildCalendarHeatmapData } from './build-calendar-data';
|
|
3
|
+
export { buildMonthCalendarHeatmapData } from './build-month-calendar-data';
|
|
3
4
|
export { useCalendarHeatmapData } from './use-calendar-heatmap-data';
|
|
5
|
+
export { useMonthCalendarHeatmapData } from './use-month-calendar-heatmap-data';
|
|
4
6
|
export type {
|
|
5
7
|
CalendarHeatmapOptions,
|
|
6
8
|
CalendarHeatmapResult,
|
|
7
9
|
HeatmapChartProps,
|
|
8
10
|
HeatmapColumn,
|
|
11
|
+
HeatmapColumnGroup,
|
|
9
12
|
HeatmapCell,
|
|
10
13
|
HeatmapTooltipData,
|
|
14
|
+
MonthCalendarHeatmapOptions,
|
|
15
|
+
MonthCalendarHeatmapRange,
|
|
16
|
+
MonthCalendarHeatmapResult,
|
|
11
17
|
} from './types';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseISO } from 'date-fns';
|
|
2
|
+
import { createDateFormatter } from '../../../utils/date-formatting';
|
|
2
3
|
import { hasTimezone } from '../../../utils/date-parsing';
|
|
3
4
|
import type { DataPointDate } from '../../../types';
|
|
4
5
|
|
|
@@ -27,6 +28,31 @@ const KEY_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
|
27
28
|
|
|
28
29
|
const DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Label formatters for UTC proxies, so both calendar layouts name a day alike.
|
|
33
|
+
*
|
|
34
|
+
* `gregory` keeps a locale whose default calendar is not Gregorian (fa-IR) from
|
|
35
|
+
* splitting one grid month across two names.
|
|
36
|
+
*
|
|
37
|
+
* @param locale - BCP 47 locale.
|
|
38
|
+
* @return Weekday, month and full-day formatters.
|
|
39
|
+
*/
|
|
40
|
+
export const civilLabelFormatters = ( locale: string ) => {
|
|
41
|
+
const formatting = { locale, timeZone: 'UTC' };
|
|
42
|
+
return {
|
|
43
|
+
formatWeekday: createDateFormatter( { weekday: 'short', calendar: 'gregory' }, formatting ),
|
|
44
|
+
formatMonth: createDateFormatter( { month: 'short', calendar: 'gregory' }, formatting ),
|
|
45
|
+
formatMonthYear: createDateFormatter(
|
|
46
|
+
{ month: 'short', year: 'numeric', calendar: 'gregory' },
|
|
47
|
+
formatting
|
|
48
|
+
),
|
|
49
|
+
formatDay: createDateFormatter(
|
|
50
|
+
{ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', calendar: 'gregory' },
|
|
51
|
+
formatting
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
30
56
|
const pad = ( value: number, length: number ) => String( value ).padStart( length, '0' );
|
|
31
57
|
|
|
32
58
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { warnOnce } from '../../../utils/warn-once';
|
|
2
|
+
import type { HeatmapColumnGroup } from '../types';
|
|
3
|
+
|
|
4
|
+
export type ColumnGroupLayout = {
|
|
5
|
+
/** One per data column: its grid column line, whether a gap track precedes it, and its group's index. */
|
|
6
|
+
columns: { line: number; gapBefore: boolean; group?: number }[];
|
|
7
|
+
/** The groups as given, each with the grid column line its label starts on; none when any was unusable. */
|
|
8
|
+
groups: ( HeatmapColumnGroup & { line: number } )[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const isSpan = ( span: number ) => Number.isInteger( span ) && span > 0;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validates column groups and lays them over the columns.
|
|
15
|
+
*
|
|
16
|
+
* @param groups - Groups as passed to the chart.
|
|
17
|
+
* @param columnCount - Number of data columns.
|
|
18
|
+
* @param firstLine - Grid column line of the first data column.
|
|
19
|
+
* @return Grid column lines for every column and group; no groups when the input was unusable.
|
|
20
|
+
*/
|
|
21
|
+
export const resolveColumnGroups = (
|
|
22
|
+
groups: HeatmapColumnGroup[] | undefined,
|
|
23
|
+
columnCount: number,
|
|
24
|
+
firstLine: number
|
|
25
|
+
): ColumnGroupLayout => {
|
|
26
|
+
const ungrouped = (): ColumnGroupLayout => ( {
|
|
27
|
+
columns: Array.from( { length: columnCount }, ( _, index ) => ( {
|
|
28
|
+
line: firstLine + index,
|
|
29
|
+
gapBefore: false,
|
|
30
|
+
} ) ),
|
|
31
|
+
groups: [],
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
// An empty grid draws nothing, so static groups awaiting data are not a misuse.
|
|
35
|
+
if ( ! groups || groups.length === 0 || columnCount === 0 ) {
|
|
36
|
+
return ungrouped();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const spans = groups.map( group => group.span );
|
|
40
|
+
const total = spans.reduce( ( sum, span ) => sum + span, 0 );
|
|
41
|
+
if ( ! spans.every( isSpan ) || total > columnCount ) {
|
|
42
|
+
warnOnce(
|
|
43
|
+
`heatmap:columnGroups:${ JSON.stringify( spans ) }:${ columnCount }`,
|
|
44
|
+
`columnGroups spans ${ JSON.stringify(
|
|
45
|
+
spans
|
|
46
|
+
) } must be positive integers that fit the ${ columnCount } columns, so no groups are drawn.`
|
|
47
|
+
);
|
|
48
|
+
return ungrouped();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const columns: ColumnGroupLayout[ 'columns' ] = [];
|
|
52
|
+
const laidOut: ColumnGroupLayout[ 'groups' ] = [];
|
|
53
|
+
let line = firstLine;
|
|
54
|
+
groups.forEach( ( group, groupIndex ) => {
|
|
55
|
+
if ( groupIndex > 0 ) {
|
|
56
|
+
line += 1;
|
|
57
|
+
}
|
|
58
|
+
laidOut.push( { ...group, line } );
|
|
59
|
+
for ( let offset = 0; offset < group.span; offset++ ) {
|
|
60
|
+
columns.push( { line, gapBefore: groupIndex > 0 && offset === 0, group: groupIndex } );
|
|
61
|
+
line += 1;
|
|
62
|
+
}
|
|
63
|
+
} );
|
|
64
|
+
// An ungrouped tail opens no further gap.
|
|
65
|
+
while ( columns.length < columnCount ) {
|
|
66
|
+
columns.push( { line, gapBefore: false } );
|
|
67
|
+
line += 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { columns, groups: laidOut };
|
|
71
|
+
};
|