@automattic/charts 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/TOKENS.md +6 -3
  3. package/dist/index.cjs +834 -321
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +68 -17
  6. package/dist/index.d.cts +102 -28
  7. package/dist/index.d.ts +102 -28
  8. package/dist/index.js +838 -327
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +7 -4
  12. package/src/charts/area-chart/test/area-chart.test.tsx +24 -15
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  15. package/src/charts/area-chart/types.ts +2 -0
  16. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  17. package/src/charts/bar-chart/bar-chart.tsx +25 -16
  18. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  19. package/src/charts/bar-chart/test/bar-chart.test.tsx +106 -62
  20. package/src/charts/bar-chart/types.ts +2 -0
  21. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -8
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +183 -66
  26. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  27. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  28. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  29. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +15 -3
  30. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  31. package/src/charts/heatmap-chart/private/index.ts +6 -3
  32. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  33. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  34. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  35. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +231 -8
  36. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  37. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  38. package/src/charts/heatmap-chart/types.ts +6 -3
  39. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
  40. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
  41. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
  42. package/src/charts/leaderboard-chart/types.ts +6 -1
  43. package/src/charts/line-chart/line-chart.tsx +7 -4
  44. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  46. package/src/charts/line-chart/test/line-chart.test.tsx +45 -35
  47. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  48. package/src/charts/line-chart/types.ts +5 -3
  49. package/src/charts/pie-chart/pie-chart.tsx +153 -143
  50. package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +126 -124
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +55 -1
  53. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  55. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  57. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  58. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  59. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +44 -0
  60. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  61. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  62. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  63. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  64. package/src/charts/private/test/label-text-color.test.ts +6 -8
  65. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  66. package/src/components/tooltip/base-tooltip.tsx +31 -32
  67. package/src/components/tooltip/index.ts +3 -1
  68. package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
  69. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  70. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  71. package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
  72. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  74. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +283 -365
  75. package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
  76. package/src/components/tooltip/tooltip-box.tsx +43 -0
  77. package/src/components/tooltip/types.ts +7 -1
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
  79. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  80. package/src/hooks/use-chart-margin.tsx +21 -18
  81. package/src/index.ts +8 -3
  82. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  83. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
  84. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  85. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  86. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  87. package/src/styles/chart-scope.scss +1 -0
  88. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  89. package/src/types.ts +18 -2
  90. package/src/utils/test/date-formatting.test.ts +0 -10
  91. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  92. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -3,6 +3,12 @@
3
3
  // module, so it needs no runtime cascade and never lands on the DOM.
4
4
  $focus-ring-width: var(--wpds-border-width-focus);
5
5
 
6
+ $heatmap-empty-fill: var(--a8c-charts-color-track);
7
+
8
+ // JS sets the ends (getHeatmapScale): 3:1 against background and empty cell at
9
+ // the low end. Unresolved, the floor is 15% of the primary.
10
+ $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c-charts-color-heatmap-primary)) calc(var(--a8c-charts-heatmap-cell-intensity) * 100%), var(--a8c-charts-color-heatmap-low, color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) 15%, var(--a8c-charts-color-background, #fff))));
11
+
6
12
  .heatmap-chart {
7
13
  position: relative;
8
14
  // Keeps the tooltip's z-index inside the chart, as `.xy-plot` does.
@@ -92,9 +98,9 @@ $focus-ring-width: var(--wpds-border-width-focus);
92
98
 
93
99
  // Beneath its columns, set off from the last row by more than the cell gap.
94
100
  .heatmap-chart__group-label {
95
- color: var(--a8c-charts-color-label-secondary);
96
- font-size: var(--wpds-typography-font-size-xs);
97
- line-height: var(--wpds-typography-line-height-xs);
101
+ color: var(--a8c-charts-color-label);
102
+ font-size: var(--wpds-typography-font-size-md);
103
+ line-height: var(--wpds-typography-line-height-sm);
98
104
  white-space: nowrap;
99
105
  text-align: center;
100
106
  padding-block-start: var(--wpds-dimension-padding-xs);
@@ -115,7 +121,7 @@ $focus-ring-width: var(--wpds-border-width-focus);
115
121
  min-height: 0;
116
122
  border-radius: var(--a8c-charts-border-radius-cell);
117
123
  // Empty (no-data) default; cells with a value override it below.
118
- background: var(--a8c-charts-color-track);
124
+ background: $heatmap-empty-fill;
119
125
  }
120
126
 
121
127
  // An empty grid slot (calendar ragged edges): keeps its track but paints and
@@ -165,10 +171,8 @@ $focus-ring-width: var(--wpds-border-width-focus);
165
171
  margin-inline-end: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
166
172
  }
167
173
 
168
- // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
169
- // an empty cell.
170
174
  .heatmap-chart__cell--filled {
171
- background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
175
+ background: $heatmap-fill;
172
176
  }
173
177
 
174
178
  .heatmap-chart__cell--selected {
@@ -204,7 +208,11 @@ $focus-ring-width: var(--wpds-border-width-focus);
204
208
  width: var(--wpds-dimension-size-3xs);
205
209
  height: var(--wpds-dimension-size-3xs);
206
210
  border-radius: var(--a8c-charts-border-radius-cell);
207
- background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
211
+ background: $heatmap-fill;
212
+ }
213
+
214
+ .heatmap-chart__legend-swatch--empty {
215
+ background: $heatmap-empty-fill;
208
216
  }
209
217
 
210
218
  // One band: the steps butt against each other and only the band's ends round.
@@ -4,6 +4,7 @@ import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
5
  import isEqual from 'fast-deep-equal';
6
6
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { flushSync } from 'react-dom';
7
8
  import { BoundedTooltip, TOOLTIP_Z_INDEX } from '../../components/tooltip/private/bounded-tooltip';
8
9
  import { useIsomorphicLayoutEffect } from '../../hooks';
9
10
  import {
@@ -13,12 +14,17 @@ import {
13
14
  useGlobalChartsContext,
14
15
  GlobalChartsContext,
15
16
  } from '../../providers';
16
- import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
17
+ import {
18
+ BACKGROUND_FALLBACK,
19
+ CATALOG_POINTERS,
20
+ } from '../../providers/chart-context/private/catalog-pointers';
17
21
  import { blendRgb, hexToRgb } from '../../providers/chart-context/private/perceptual-color';
22
+ import { resolveOpaqueHex } from '../../providers/chart-context/private/resolve-opaque-hex';
18
23
  import { useStandaloneScopeClass } from '../../providers/chart-scope';
19
24
  import { attachSubComponents } from '../../utils';
20
25
  import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
21
- import { createCssVariableResolver, resolveCssVariable } from '../../utils/resolve-css-var';
26
+ import { createCssVariableResolver } from '../../utils/resolve-css-var';
27
+ import { warnOnce } from '../../utils/warn-once';
22
28
  import { Center } from '../private/center';
23
29
  import { useChartChildren } from '../private/chart-composition';
24
30
  import { ChartInstanceContext } from '../private/chart-instance-context';
@@ -27,12 +33,16 @@ import { pickLabelTextColorForFill, resolveLabelRoles } from '../private/label-t
27
33
  import { withResponsive } from '../private/with-responsive';
28
34
  import styles from './heatmap-chart.module.scss';
29
35
  import {
36
+ fitCompactCells,
37
+ getHeatmapScale,
30
38
  getValueExtent,
31
39
  getNormalizedValue,
32
40
  HeatmapContext,
33
41
  HeatmapLegend,
42
+ isEmptyValue,
34
43
  isPresent,
35
44
  resolveColumnGroups,
45
+ wrapColumnGroups,
36
46
  } from './private';
37
47
  import {
38
48
  firstCalendarCell,
@@ -41,17 +51,13 @@ import {
41
51
  stepCalendarCell,
42
52
  stepGridCell,
43
53
  } from './private/keyboard-navigation';
44
- import type { HeatmapContextValue } from './private';
54
+ import type { CompactCellFit, HeatmapContextValue } from './private';
45
55
  import type { CellBlock, CellPosition } from './private/keyboard-navigation';
46
56
  import type { HeatmapChartProps, HeatmapTooltipData } from './types';
47
57
  import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
48
58
  import type { ResponsiveConfig } from '../private/with-responsive';
49
59
  import type { CSSProperties, FC } from 'react';
50
60
 
51
- // Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
52
- // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
53
- const CELL_MIX_FLOOR = 0.15;
54
-
55
61
  // `label` is the stylesheet's default, so it needs no modifier.
56
62
  const CELL_VALUE_MODIFIER: Record< LabelTextColor, string | undefined > = {
57
63
  label: undefined,
@@ -66,6 +72,27 @@ const NO_ROW_LABELS: string[] = [];
66
72
 
67
73
  const TOOLTIP_BOX_STYLE: CSSProperties = { zIndex: TOOLTIP_Z_INDEX };
68
74
 
75
+ const largest = ( root: Element, selector: string, size: 'offsetWidth' | 'offsetHeight' ) =>
76
+ Math.max(
77
+ 0,
78
+ ...Array.from( root.querySelectorAll< HTMLElement >( selector ), el => el[ size ] )
79
+ );
80
+
81
+ // The box the grid may fill: the chart's own box less its other in-flow children
82
+ // (the legend, say) and the gaps between them.
83
+ const gridBox = ( chart: HTMLElement, grid: HTMLElement ) => {
84
+ let height = chart.clientHeight;
85
+ const rowGap = parseFloat( getComputedStyle( chart ).rowGap ) || 0;
86
+ Array.from( chart.children ).forEach( child => {
87
+ const { display, position } = getComputedStyle( child );
88
+ if ( child === grid || display === 'none' || position === 'absolute' || position === 'fixed' ) {
89
+ return;
90
+ }
91
+ height -= ( child as HTMLElement ).offsetHeight + rowGap;
92
+ } );
93
+ return { width: chart.clientWidth, height };
94
+ };
95
+
69
96
  // The cell's own label wins; otherwise the group, column and row labels name it.
70
97
  const cellName = ( info: HeatmapTooltipData ) =>
71
98
  info.cellLabel ||
@@ -78,6 +105,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
78
105
  height = 0,
79
106
  className,
80
107
  compact = false,
108
+ fitCells = false,
81
109
  showValues,
82
110
  maxCellWidth,
83
111
  maxCellHeight,
@@ -108,16 +136,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
108
136
  useTooltip< HeatmapTooltipData >();
109
137
  const standaloneScopeClass = useStandaloneScopeClass();
110
138
  const containerRef = useRef< HTMLDivElement >( null );
139
+ const chartRef = useRef< HTMLDivElement >( null );
111
140
  const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
112
- // The chart root positions the tooltip, so pointer and cell coordinates are
113
- // measured against it — found by its id rather than by walking up, so
114
- // whatever ChartLayout wraps the grid in cannot shift the origin.
115
141
  const getTooltipOrigin = useCallback(
116
- () =>
117
- containerRef.current
118
- ?.closest( `[data-chart-id="heatmap-chart-${ chartId }"]` )
119
- ?.getBoundingClientRect() ?? null,
120
- [ chartId ]
142
+ () => chartRef.current?.getBoundingClientRect() ?? null,
143
+ []
121
144
  );
122
145
 
123
146
  const { color: primaryColorHex } = getElementStyles( {
@@ -125,29 +148,37 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
125
148
  overrideColor: primaryColor,
126
149
  } );
127
150
 
128
- // The cell blend substitutes this role at the cell; this read happens at the scope
129
- // element, so an override on the chart's own class makes the two disagree. CHARTS-255.
130
- const chartBackgroundHex = normalizeColorToHex(
131
- CATALOG_POINTERS.background,
132
- scopeElement,
133
- resolveCssVariable
134
- );
151
+ // Both read at the scope element, so an override on the chart's own class reaches neither the
152
+ // fill scale nor the text color (CHARTS-255). See-through counts as white, as for the palette.
153
+ const resolveAtScope = createCssVariableResolver( scopeElement );
154
+ const chartBackgroundHex =
155
+ resolveOpaqueHex( CATALOG_POINTERS.background, resolveAtScope ) ?? BACKGROUND_FALLBACK;
156
+ const emptyCellHex = resolveOpaqueHex( CATALOG_POINTERS.track, resolveAtScope );
135
157
 
136
- // Choose text color from the blended fill, not the raw value.
137
- // If either color cannot resolve to hex, keep the stylesheet's default role.
158
+ // If the primary cannot resolve to hex, the stylesheet falls back to its own blend.
138
159
  const primaryHex = normalizeColorToHex( primaryColorHex );
139
- const cellMix =
140
- isValidHexColor( primaryHex ) && isValidHexColor( chartBackgroundHex )
141
- ? { primary: hexToRgb( primaryHex ), background: hexToRgb( chartBackgroundHex ) }
142
- : null;
160
+ const scale = useMemo(
161
+ () =>
162
+ isValidHexColor( primaryHex )
163
+ ? getHeatmapScale( primaryHex, chartBackgroundHex, emptyCellHex )
164
+ : null,
165
+ [ primaryHex, chartBackgroundHex, emptyCellHex ]
166
+ );
167
+ const fillVars = useMemo(
168
+ () => ( {
169
+ '--a8c-charts-color-heatmap-primary': primaryColorHex,
170
+ ...( scale && {
171
+ '--a8c-charts-color-heatmap-low': scale.low,
172
+ '--a8c-charts-color-heatmap-high': scale.high,
173
+ } ),
174
+ } ),
175
+ [ primaryColorHex, scale ]
176
+ );
177
+ // Choose text color from the fill the cell paints, mirroring .heatmap-chart__cell--filled.
143
178
  const cellTextColor = ( intensity: number ): LabelTextColor =>
144
- cellMix
179
+ scale
145
180
  ? pickLabelTextColorForFill(
146
- blendRgb(
147
- cellMix.primary,
148
- cellMix.background,
149
- CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity
150
- ),
181
+ blendRgb( hexToRgb( scale.high ), hexToRgb( scale.low ), intensity ),
151
182
  labelRoles,
152
183
  'label'
153
184
  )
@@ -165,8 +196,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
165
196
 
166
197
  const extent = useMemo( () => getValueExtent( data ), [ data ] );
167
198
  const heatmapContext = useMemo< HeatmapContextValue >(
168
- () => ( { extent, primaryColorHex } ),
169
- [ extent, primaryColorHex ]
199
+ () => ( { extent, fillVars } ),
200
+ [ extent, fillVars ]
170
201
  );
171
202
 
172
203
  const columns = data.length;
@@ -179,6 +210,67 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
179
210
 
180
211
  const { compactCellGap, compactCellSize, groupGap } = heatmapChartSettings;
181
212
  const drawValues = showValues ?? ! compact;
213
+ const hasColumnLabels = data.some( column => Boolean( column.label ) );
214
+ const hasGroups = groupLayout.groups.length > 0;
215
+
216
+ const [ fit, setFit ] = useState< CompactCellFit | null >( null );
217
+ // A summary track is as wide as its figure, which the fit cannot size as a cell.
218
+ const hasSummary = data.some( column => column.summary );
219
+ const fitting = compact && fitCells && ! hasSummary;
220
+ if ( fitCells && ! fitting ) {
221
+ warnOnce(
222
+ 'heatmap:fitCells',
223
+ 'fitCells applies only in compact mode without summary columns, so the cells keep their size.'
224
+ );
225
+ }
226
+ useIsomorphicLayoutEffect( () => {
227
+ const chart = chartRef.current;
228
+ const grid = containerRef.current;
229
+ if ( ! fitting || ! chart || ! grid ) {
230
+ setFit( null );
231
+ return;
232
+ }
233
+ const measure = () => {
234
+ const next = fitCompactCells( {
235
+ ...gridBox( chart, grid ),
236
+ layout: groupLayout,
237
+ rows,
238
+ cellGap: compactCellGap,
239
+ groupGap,
240
+ rowLabelWidth: largest( grid, `.${ styles[ 'heatmap-chart__row-label' ] }`, 'offsetWidth' ),
241
+ columnLabelHeight: hasColumnLabels
242
+ ? largest( grid, `.${ styles[ 'heatmap-chart__col-label' ] }`, 'offsetHeight' )
243
+ : null,
244
+ groupLabelHeight: hasGroups
245
+ ? largest( grid, `.${ styles[ 'heatmap-chart__group-label' ] }`, 'offsetHeight' )
246
+ : null,
247
+ minCellSize: compactCellSize,
248
+ } );
249
+ setFit( previous => ( isEqual( previous, next ) ? previous : next ) );
250
+ };
251
+ measure();
252
+ if ( typeof ResizeObserver === 'undefined' ) {
253
+ return;
254
+ }
255
+ // Commit before later observers in this delivery read the grid, or they see the
256
+ // old cells at the new width, and before paint, so that frame is never drawn.
257
+ const observer = new ResizeObserver( () => flushSync( measure ) );
258
+ observer.observe( chart );
259
+ return () => observer.disconnect();
260
+ }, [
261
+ fitting,
262
+ groupLayout,
263
+ rows,
264
+ hasColumnLabels,
265
+ hasGroups,
266
+ compactCellGap,
267
+ compactCellSize,
268
+ groupGap,
269
+ ] );
270
+ const wrapped = useMemo(
271
+ () => ( fit ? wrapColumnGroups( groupLayout, fit.bands ) : null ),
272
+ [ fit, groupLayout ]
273
+ );
182
274
 
183
275
  const buildTooltipData = useCallback(
184
276
  ( columnIndex: number, rowIndex: number ): HeatmapTooltipData => {
@@ -366,12 +458,18 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
366
458
  const rowTrack = compact
367
459
  ? 'var(--a8c-charts-dimension-heatmap-cell-size)'
368
460
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
369
- const hasColumnLabels = data.some( column => Boolean( column.label ) );
370
- const hasGroups = groupLayout.groups.length > 0;
371
461
  // Every item is placed by hand rather than auto-flowed, so the template can
372
462
  // carry gap tracks that hold no cell.
373
- const columnLine = ( columnIndex: number ) => groupLayout.columns[ columnIndex ].line;
374
- const firstDataRow = hasColumnLabels ? 2 : 1;
463
+ const placed = wrapped ?? groupLayout;
464
+ const columnLine = ( columnIndex: number ) => placed.columns[ columnIndex ].line;
465
+ const bandOf = ( columnIndex: number ) => placed.columns[ columnIndex ].band;
466
+ const { bands } = placed;
467
+ // Each band repeats the label row, the data rows and the group-label row, and
468
+ // the next starts one group gap below.
469
+ const bandRowCount = ( hasColumnLabels ? 1 : 0 ) + rows + ( hasGroups ? 1 : 0 ) + 1;
470
+ const labelRowOf = ( band: number ) => 1 + band * bandRowCount;
471
+ const dataRowOf = ( band: number, rowIndex: number ) =>
472
+ labelRowOf( band ) + ( hasColumnLabels ? 1 : 0 ) + rowIndex;
375
473
  // A summary column takes a content-sized track: a roll-up is wider than a
376
474
  // cell, and a shared track would stretch every cell to fit it. `max-content`
377
475
  // as the max keeps the leftover width out of it once the data tracks hit
@@ -382,23 +480,33 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
382
480
  // minmax track the way it widens the summary's auto track. Compact cells
383
481
  // are fixed, so there the gaps share the leftover width instead.
384
482
  const gapTrack = compact ? `minmax(${ groupGap }px, 1fr)` : `${ groupGap }px`;
385
- const columnTracks = data
386
- .map( ( column, columnIndex ) =>
387
- groupLayout.columns[ columnIndex ].gapBefore
388
- ? `${ gapTrack } ${ dataTrack( column ) }`
389
- : dataTrack( column )
390
- )
391
- .join( ' ' );
483
+ // Wrapped bands share one set of slots, each as wide as its widest group.
484
+ const columnTracks = wrapped
485
+ ? wrapped.slotSpans
486
+ .map( ( span, slot ) =>
487
+ [ ...( slot > 0 ? [ gapTrack ] : [] ), ...Array( span ).fill( columnTrack ) ].join( ' ' )
488
+ )
489
+ .join( ' ' )
490
+ : data
491
+ .map( ( column, columnIndex ) =>
492
+ groupLayout.columns[ columnIndex ].gapBefore
493
+ ? `${ gapTrack } ${ dataTrack( column ) }`
494
+ : dataTrack( column )
495
+ )
496
+ .join( ' ' );
497
+ const bandTracks = `${ hasColumnLabels ? 'auto ' : '' }repeat(${ rows }, ${ rowTrack })${
498
+ hasGroups ? ' auto' : ''
499
+ }`;
392
500
  const gridStyle: Record< string, string | number > = {
393
- '--a8c-charts-color-heatmap-primary': primaryColorHex,
501
+ ...fillVars,
394
502
  gridTemplateColumns: `auto ${ columnTracks }`,
395
- gridTemplateRows: `${ hasColumnLabels ? 'auto ' : '' }repeat(${ rows }, ${ rowTrack })${
396
- hasGroups ? ' auto' : ''
397
- }`,
503
+ gridTemplateRows: Array( bands ).fill( bandTracks ).join( ` ${ groupGap }px ` ),
398
504
  };
399
505
  if ( compact ) {
400
506
  gridStyle[ '--a8c-charts-dimension-heatmap-cell-gap' ] = `${ compactCellGap }px`;
401
- gridStyle[ '--a8c-charts-dimension-heatmap-cell-size' ] = `${ compactCellSize }px`;
507
+ gridStyle[ '--a8c-charts-dimension-heatmap-cell-size' ] = `${
508
+ fit?.cellSize ?? compactCellSize
509
+ }px`;
402
510
  }
403
511
 
404
512
  // A summary column sits one gap apart from the data on either side; two
@@ -435,6 +543,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
435
543
  legendChildren={ [] }
436
544
  trailingContent={ nonLegendChildren }
437
545
  gap={ gap }
546
+ rootRef={ chartRef }
438
547
  className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className, {
439
548
  [ styles[ 'heatmap-chart--height-capped' ] ]: heightCapped,
440
549
  } ) }
@@ -465,11 +574,14 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
465
574
  { /* Decorative: cell aria-labels already carry the column name. */ }
466
575
  { hasColumnLabels && (
467
576
  <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
468
- <span style={ { gridColumn: 1, gridRow: 1 } } />
577
+ <span style={ { gridColumn: 1, gridRow: labelRowOf( 0 ) } } />
469
578
  { data.map( ( column, columnIndex ) => (
470
579
  <span
471
580
  key={ `col-${ columnIndex }` }
472
- style={ { gridColumn: columnLine( columnIndex ), gridRow: 1 } }
581
+ style={ {
582
+ gridColumn: columnLine( columnIndex ),
583
+ gridRow: labelRowOf( bandOf( columnIndex ) ),
584
+ } }
473
585
  className={ clsx( styles[ 'heatmap-chart__col-label' ], {
474
586
  [ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
475
587
  ...summaryGaps( columnIndex ),
@@ -483,7 +595,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
483
595
 
484
596
  { Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
485
597
  const labelVisible = ! compact || rowIndex % 2 === 0;
486
- const gridRow = firstDataRow + rowIndex;
487
598
  return (
488
599
  <div
489
600
  key={ `row-${ rowIndex }` }
@@ -491,16 +602,22 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
491
602
  aria-rowindex={ rowIndex + 1 }
492
603
  className={ styles[ 'heatmap-chart__row' ] }
493
604
  >
494
- <span
495
- aria-hidden="true"
496
- className={ styles[ 'heatmap-chart__row-label' ] }
497
- style={ { gridColumn: 1, gridRow } }
498
- >
499
- { labelVisible ? ( rowLabels[ rowIndex ] ?? '' ) : '' }
500
- </span>
605
+ { Array.from( { length: bands } ).map( ( _band, band ) => (
606
+ <span
607
+ key={ `row-label-${ band }` }
608
+ aria-hidden="true"
609
+ className={ styles[ 'heatmap-chart__row-label' ] }
610
+ style={ { gridColumn: 1, gridRow: dataRowOf( band, rowIndex ) } }
611
+ >
612
+ { labelVisible ? ( rowLabels[ rowIndex ] ?? '' ) : '' }
613
+ </span>
614
+ ) ) }
501
615
  { data.map( ( column, columnIndex ) => {
502
616
  const cell = column.data[ rowIndex ];
503
- const placement = { gridColumn: columnLine( columnIndex ), gridRow };
617
+ const placement = {
618
+ gridColumn: columnLine( columnIndex ),
619
+ gridRow: dataRowOf( bandOf( columnIndex ), rowIndex ),
620
+ };
504
621
 
505
622
  // A hidden cell keeps its grid slot (so the rest of the
506
623
  // column doesn't shift) but paints nothing and takes no
@@ -542,7 +659,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
542
659
  const value = cell?.value ?? null;
543
660
  const present = isPresent( value );
544
661
  // A summary cell is on another scale, so it takes no fill.
545
- const filled = present && ! column.summary;
662
+ const filled = present && ! column.summary && ! isEmptyValue( value, extent );
546
663
  const normalized = filled ? getNormalizedValue( value, extent ) : 0;
547
664
  const info = buildTooltipData( columnIndex, rowIndex );
548
665
  const accessibleLabel = `${ cellName( info ) }: ${
@@ -601,14 +718,14 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
601
718
  } ) }
602
719
  { hasGroups && (
603
720
  <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
604
- { groupLayout.groups.map( ( group, groupIndex ) => (
721
+ { placed.groups.map( ( group, groupIndex ) => (
605
722
  <span
606
723
  key={ `group-${ groupIndex }` }
607
724
  data-testid="heatmap-group-label"
608
725
  className={ styles[ 'heatmap-chart__group-label' ] }
609
726
  style={ {
610
727
  gridColumn: `${ group.line } / span ${ group.span }`,
611
- gridRow: firstDataRow + rows,
728
+ gridRow: dataRowOf( group.band, rows ),
612
729
  } }
613
730
  >
614
731
  { group.label }
@@ -2,10 +2,12 @@ import { warnOnce } from '../../../utils/warn-once';
2
2
  import type { HeatmapColumnGroup } from '../types';
3
3
 
4
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 } )[];
5
+ /** One per data column: its grid column line and band, whether a gap track precedes it, and its group's index. */
6
+ columns: { line: number; band: number; gapBefore: boolean; group?: number }[];
7
+ /** The groups as given, each with the grid column line and band its label sits on; none when any was unusable. */
8
+ groups: ( HeatmapColumnGroup & { line: number; band: number } )[];
9
+ /** Rows of groups the columns are laid out on. */
10
+ bands: number;
9
11
  };
10
12
 
11
13
  const isSpan = ( span: number ) => Number.isInteger( span ) && span > 0;
@@ -26,9 +28,11 @@ export const resolveColumnGroups = (
26
28
  const ungrouped = (): ColumnGroupLayout => ( {
27
29
  columns: Array.from( { length: columnCount }, ( _, index ) => ( {
28
30
  line: firstLine + index,
31
+ band: 0,
29
32
  gapBefore: false,
30
33
  } ) ),
31
34
  groups: [],
35
+ bands: 1,
32
36
  } );
33
37
 
34
38
  // An empty grid draws nothing, so static groups awaiting data are not a misuse.
@@ -55,17 +59,84 @@ export const resolveColumnGroups = (
55
59
  if ( groupIndex > 0 ) {
56
60
  line += 1;
57
61
  }
58
- laidOut.push( { ...group, line } );
62
+ laidOut.push( { ...group, line, band: 0 } );
59
63
  for ( let offset = 0; offset < group.span; offset++ ) {
60
- columns.push( { line, gapBefore: groupIndex > 0 && offset === 0, group: groupIndex } );
64
+ columns.push( {
65
+ line,
66
+ band: 0,
67
+ gapBefore: groupIndex > 0 && offset === 0,
68
+ group: groupIndex,
69
+ } );
61
70
  line += 1;
62
71
  }
63
72
  } );
64
73
  // An ungrouped tail opens no further gap.
65
74
  while ( columns.length < columnCount ) {
66
- columns.push( { line, gapBefore: false } );
75
+ columns.push( { line, band: 0, gapBefore: false } );
67
76
  line += 1;
68
77
  }
69
78
 
70
- return { columns, groups: laidOut };
79
+ return { columns, groups: laidOut, bands: 1 };
80
+ };
81
+
82
+ export type WrappedColumnGroupLayout = ColumnGroupLayout & {
83
+ /** Columns per group slot: the widest group in that position on any band. */
84
+ slotSpans: number[];
85
+ };
86
+
87
+ /**
88
+ * Wraps the groups onto bands of equal group count, each band starting back at
89
+ * the first data line so the groups line up in slots down the bands.
90
+ *
91
+ * @param layout - The unwrapped layout from `resolveColumnGroups`.
92
+ * @param bands - Bands to aim for; the groups split as evenly as whole bands allow.
93
+ * @return The wrapped layout, or null when there is nothing to wrap: one band, one group, or a column outside every group.
94
+ */
95
+ export const wrapColumnGroups = (
96
+ layout: ColumnGroupLayout,
97
+ bands: number
98
+ ): WrappedColumnGroupLayout | null => {
99
+ const { columns, groups } = layout;
100
+ if ( bands < 2 || groups.length < 2 || columns.some( column => column.group === undefined ) ) {
101
+ return null;
102
+ }
103
+
104
+ const perBand = Math.ceil( groups.length / bands );
105
+ const slotSpans: number[] = Array( perBand ).fill( 0 );
106
+ groups.forEach( ( group, index ) => {
107
+ const slot = index % perBand;
108
+ slotSpans[ slot ] = Math.max( slotSpans[ slot ], group.span );
109
+ } );
110
+ const slotLines: number[] = [];
111
+ let line = columns[ 0 ].line;
112
+ slotSpans.forEach( ( span, slot ) => {
113
+ if ( slot > 0 ) {
114
+ line += 1;
115
+ }
116
+ slotLines.push( line );
117
+ line += span;
118
+ } );
119
+
120
+ const wrappedGroups = groups.map( ( group, index ) => ( {
121
+ ...group,
122
+ line: slotLines[ index % perBand ],
123
+ band: Math.floor( index / perBand ),
124
+ } ) );
125
+ const wrappedColumns = columns.map( column => {
126
+ const group = column.group as number;
127
+ const offset = column.line - groups[ group ].line;
128
+ return {
129
+ ...column,
130
+ line: wrappedGroups[ group ].line + offset,
131
+ band: wrappedGroups[ group ].band,
132
+ gapBefore: offset === 0 && group % perBand > 0,
133
+ };
134
+ } );
135
+
136
+ return {
137
+ columns: wrappedColumns,
138
+ groups: wrappedGroups,
139
+ bands: Math.ceil( groups.length / perBand ),
140
+ slotSpans,
141
+ };
71
142
  };
@@ -0,0 +1,72 @@
1
+ import { wrapColumnGroups } from './column-groups';
2
+ import type { ColumnGroupLayout } from './column-groups';
3
+
4
+ export type CompactCellFitInput = {
5
+ /** Width and height (px) the grid may take. */
6
+ width: number;
7
+ height: number;
8
+ /** The unwrapped layout; every band count is tried through `wrapColumnGroups` on it. */
9
+ layout: ColumnGroupLayout;
10
+ rows: number;
11
+ cellGap: number;
12
+ groupGap: number;
13
+ /** Label track and row sizes (px); null for a label row the grid does not draw. */
14
+ rowLabelWidth: number;
15
+ columnLabelHeight: number | null;
16
+ groupLabelHeight: number | null;
17
+ /** Smallest cell worth drawing; below it the grid stays on one band and overflows. */
18
+ minCellSize: number;
19
+ };
20
+
21
+ export type CompactCellFit = { bands: number; cellSize: number };
22
+
23
+ /**
24
+ * Largest whole-pixel square cell, and the band count giving it, for a compact grid in a box.
25
+ *
26
+ * @param input - The box, the grid's layout, and its fixed gaps and label sizes.
27
+ * @return The fit; one band at `minCellSize` when no band count reaches it.
28
+ */
29
+ export const fitCompactCells = ( input: CompactCellFitInput ): CompactCellFit => {
30
+ const { width, height, layout, rows, cellGap, groupGap, minCellSize } = input;
31
+ const columns = layout.columns.length;
32
+ if ( columns === 0 || rows === 0 ) {
33
+ return { bands: 1, cellSize: minCellSize };
34
+ }
35
+
36
+ const maxBands = Math.max( 1, layout.groups.length );
37
+ const labelHeights = ( input.columnLabelHeight ?? 0 ) + ( input.groupLabelHeight ?? 0 );
38
+ const bandTracks =
39
+ ( input.columnLabelHeight === null ? 0 : 1 ) +
40
+ rows +
41
+ ( input.groupLabelHeight === null ? 0 : 1 );
42
+ let best: CompactCellFit | null = null;
43
+
44
+ for ( let target = 1; target <= maxBands; target++ ) {
45
+ const wrapped = target > 1 ? wrapColumnGroups( layout, target ) : null;
46
+ const { bands } = wrapped ?? layout;
47
+ const cellColumns = wrapped
48
+ ? wrapped.slotSpans.reduce( ( sum, span ) => sum + span, 0 )
49
+ : columns;
50
+ const gapTracks = wrapped
51
+ ? wrapped.slotSpans.length - 1
52
+ : layout.columns.filter( column => column.gapBefore ).length;
53
+ // The row-label track, the cell tracks and the group-gap tracks, one cell gap apart.
54
+ const columnTracks = 1 + cellColumns + gapTracks;
55
+ const byWidth =
56
+ ( width - input.rowLabelWidth - gapTracks * groupGap - ( columnTracks - 1 ) * cellGap ) /
57
+ cellColumns;
58
+
59
+ // Each band's tracks, plus one group-gap track between bands.
60
+ const rowTracks = bands * bandTracks + ( bands - 1 );
61
+ const fixedHeight =
62
+ bands * labelHeights + ( bands - 1 ) * groupGap + ( rowTracks - 1 ) * cellGap;
63
+ const byHeight = ( height - fixedHeight ) / ( bands * rows );
64
+
65
+ const cellSize = Math.floor( Math.min( byWidth, byHeight ) );
66
+ if ( cellSize >= minCellSize && ( ! best || cellSize > best.cellSize ) ) {
67
+ best = { bands, cellSize };
68
+ }
69
+ }
70
+
71
+ return best ?? { bands: 1, cellSize: minCellSize };
72
+ };
@@ -2,8 +2,8 @@ import { createContext } from 'react';
2
2
 
3
3
  export type HeatmapContextValue = {
4
4
  extent: [ number, number ];
5
- /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
6
- primaryColorHex: string;
5
+ /** The custom properties the fill reads, set on the grid and on each legend swatch. */
6
+ fillVars: Record< string, string >;
7
7
  };
8
8
 
9
9
  /** Shared by the chart and legend without importing back from `heatmap-chart.tsx`. */