@automattic/charts 4.8.0 → 4.10.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 (75) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.cjs +766 -277
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +72 -8
  5. package/dist/index.d.cts +54 -15
  6. package/dist/index.d.ts +54 -15
  7. package/dist/index.js +770 -281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +7 -3
  11. package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
  12. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +12 -4
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
  15. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  16. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
  18. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
  19. package/src/charts/conversion-funnel-chart/types.ts +1 -1
  20. package/src/charts/geo-chart/geo-chart.tsx +17 -2
  21. package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
  22. package/src/charts/geo-chart/types.ts +1 -1
  23. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
  25. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  26. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  27. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
  28. package/src/charts/heatmap-chart/private/index.ts +4 -2
  29. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  30. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
  35. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
  36. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
  37. package/src/charts/leaderboard-chart/types.ts +7 -1
  38. package/src/charts/line-chart/line-chart.tsx +8 -2
  39. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  40. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  41. package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
  42. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  43. package/src/charts/line-chart/types.ts +3 -3
  44. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  45. package/src/charts/pie-chart/pie-chart.tsx +144 -139
  46. package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
  47. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  48. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +117 -120
  49. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
  50. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  51. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  52. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  53. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  54. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  55. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
  57. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  58. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  59. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  60. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  61. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  62. package/src/charts/test/accessible-name.test.tsx +181 -0
  63. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  64. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  65. package/src/components/legend/private/base-legend.tsx +27 -15
  66. package/src/components/legend/test/legend.test.tsx +42 -0
  67. package/src/components/legend/types.ts +2 -0
  68. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
  69. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
  70. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  71. package/src/hooks/use-xychart-theme.ts +33 -2
  72. package/src/index.ts +1 -0
  73. package/src/types.ts +37 -3
  74. package/src/utils/resolve-font-size.ts +9 -10
  75. package/src/utils/test/resolve-font-size.test.ts +11 -2
@@ -3,6 +3,11 @@ import userEvent from '@testing-library/user-event';
3
3
  import ConversionFunnelChart from '../conversion-funnel-chart';
4
4
  import type { FunnelStep } from '../types';
5
5
 
6
+ let mockChartHeight = 0;
7
+ jest.mock( '../../../hooks/use-element-size', () => ( {
8
+ useElementSize: () => [ jest.fn(), 500, mockChartHeight ],
9
+ } ) );
10
+
6
11
  // Mock data for testing
7
12
  const mockSteps: FunnelStep[] = [
8
13
  {
@@ -42,6 +47,10 @@ const renderWithoutTheme = ( component: React.ReactElement ) => {
42
47
  };
43
48
 
44
49
  describe( 'ConversionFunnelChart', () => {
50
+ afterEach( () => {
51
+ mockChartHeight = 0;
52
+ } );
53
+
45
54
  describe( 'Basic Rendering', () => {
46
55
  it( 'renders the main conversion rate', () => {
47
56
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
@@ -116,6 +125,19 @@ describe( 'ConversionFunnelChart', () => {
116
125
  height: '250px',
117
126
  } );
118
127
  } );
128
+
129
+ it.each( [
130
+ [ 'unmeasured', 0, 'xl' ],
131
+ [ 'short', 120, 'sm' ],
132
+ [ 'tall', 320, 'xl' ],
133
+ ] )( 'spaces a %s (%ipx) chart with the %s gap', ( _, height, gap ) => {
134
+ mockChartHeight = height;
135
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
136
+
137
+ expect( screen.getByTestId( 'conversion-funnel-chart' ).style.gap ).toContain(
138
+ `gap-${ gap }`
139
+ );
140
+ } );
119
141
  } );
120
142
 
121
143
  describe( 'Empty State', () => {
@@ -59,7 +59,7 @@ export interface TooltipRenderProps {
59
59
  */
60
60
  export interface ConversionFunnelChartProps extends Pick<
61
61
  BaseChartProps,
62
- 'className' | 'chartId' | 'animation'
62
+ 'className' | 'chartId' | 'animation' | 'ariaLabel'
63
63
  > {
64
64
  /** Main conversion rate to highlight */
65
65
  mainRate: number;
@@ -126,6 +126,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
126
126
  *
127
127
  * @param props - The props for the GeoChart component
128
128
  * @param props.data - Data in Google Charts format (array of arrays with headers)
129
+ * @param props.ariaLabel - Accessible name of the chart
129
130
  * @param props.width - Width of the chart in pixels
130
131
  * @param props.height - Height of the chart in pixels
131
132
  * @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
@@ -140,6 +141,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
140
141
  const GeoChartInternal: FC< GeoChartProps > = ( {
141
142
  className,
142
143
  data,
144
+ ariaLabel,
143
145
  width,
144
146
  height,
145
147
  region = 'world',
@@ -289,12 +291,23 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
289
291
  ]
290
292
  );
291
293
 
292
- const chartEvents = useMemo< ReactGoogleChartEvent[] | undefined >( () => {
294
+ const chartEvents = useMemo< ReactGoogleChartEvent[] >( () => {
295
+ const readyEvent: ReactGoogleChartEvent = {
296
+ eventName: 'ready',
297
+ // Google hardcodes aria-label="A chart." on its SVG, which repeats the chart's own name.
298
+ callback: () => {
299
+ containerRef.current
300
+ ?.querySelectorAll( 'svg' )
301
+ .forEach( svg => svg.setAttribute( 'aria-hidden', 'true' ) );
302
+ },
303
+ };
304
+
293
305
  if ( ! onError ) {
294
- return undefined;
306
+ return [ readyEvent ];
295
307
  }
296
308
 
297
309
  return [
310
+ readyEvent,
298
311
  {
299
312
  eventName: 'error',
300
313
  callback: ( { eventArgs } ) => {
@@ -307,6 +320,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
307
320
  return (
308
321
  <Center
309
322
  ref={ containerRef }
323
+ role="group"
324
+ aria-label={ ariaLabel ?? __( 'Geo chart', 'jetpack-charts' ) }
310
325
  className={ clsx( 'geo-chart', styles.container, className ) }
311
326
  data-testid="geo-chart"
312
327
  style={ { width, height } }
@@ -281,13 +281,18 @@ describe( 'GeoChart', () => {
281
281
  } );
282
282
 
283
283
  describe( 'Chart Events', () => {
284
- test( 'does not pass chart events when onError is omitted', () => {
284
+ test( "hides Google's own SVG label from screen readers once the map is ready", () => {
285
285
  renderWithTheme();
286
286
 
287
- const chartEvents = screen.getByTestId( 'chart-events' );
288
- const events = JSON.parse( chartEvents.textContent || '[]' );
287
+ const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
288
+ screen.getByTestId( 'google-chart-mock' ).appendChild( svg );
289
289
 
290
- expect( events ).toEqual( [] );
290
+ const chartProps = ChartMock.mock.calls[ ChartMock.mock.calls.length - 1 ]?.[ 0 ];
291
+ chartProps.chartEvents
292
+ .find( ( event: { eventName: string } ) => event.eventName === 'ready' )
293
+ .callback( {} );
294
+
295
+ expect( svg ).toHaveAttribute( 'aria-hidden', 'true' );
291
296
  } );
292
297
 
293
298
  test( 'calls onError when Google Charts emits an error', () => {
@@ -31,7 +31,7 @@ export interface GeoChartError {
31
31
 
32
32
  export interface GeoChartProps extends Pick<
33
33
  BaseChartProps,
34
- 'className' | 'chartId' | 'width' | 'height'
34
+ 'className' | 'chartId' | 'ariaLabel' | 'width' | 'height'
35
35
  > {
36
36
  /**
37
37
  * Data in Google Charts native format for maximum flexibility.
@@ -3,6 +3,8 @@
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
+
6
8
  // JS sets the ends (getHeatmapScale): 3:1 against background and empty cell at
7
9
  // the low end. Unresolved, the floor is 15% of the primary.
8
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))));
@@ -96,9 +98,9 @@ $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c
96
98
 
97
99
  // Beneath its columns, set off from the last row by more than the cell gap.
98
100
  .heatmap-chart__group-label {
99
- color: var(--a8c-charts-color-label-secondary);
100
- font-size: var(--wpds-typography-font-size-xs);
101
- 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);
102
104
  white-space: nowrap;
103
105
  text-align: center;
104
106
  padding-block-start: var(--wpds-dimension-padding-xs);
@@ -119,7 +121,7 @@ $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c
119
121
  min-height: 0;
120
122
  border-radius: var(--a8c-charts-border-radius-cell);
121
123
  // Empty (no-data) default; cells with a value override it below.
122
- background: var(--a8c-charts-color-track);
124
+ background: $heatmap-empty-fill;
123
125
  }
124
126
 
125
127
  // An empty grid slot (calendar ragged edges): keeps its track but paints and
@@ -209,6 +211,10 @@ $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c
209
211
  background: $heatmap-fill;
210
212
  }
211
213
 
214
+ .heatmap-chart__legend-swatch--empty {
215
+ background: $heatmap-empty-fill;
216
+ }
217
+
212
218
  // One band: the steps butt against each other and only the band's ends round.
213
219
  .heatmap-chart__legend-scale--bar {
214
220
  overflow: hidden;
@@ -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 {
@@ -23,6 +24,7 @@ import { useStandaloneScopeClass } from '../../providers/chart-scope';
23
24
  import { attachSubComponents } from '../../utils';
24
25
  import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
25
26
  import { createCssVariableResolver } from '../../utils/resolve-css-var';
27
+ import { warnOnce } from '../../utils/warn-once';
26
28
  import { Center } from '../private/center';
27
29
  import { useChartChildren } from '../private/chart-composition';
28
30
  import { ChartInstanceContext } from '../private/chart-instance-context';
@@ -31,6 +33,7 @@ import { pickLabelTextColorForFill, resolveLabelRoles } from '../private/label-t
31
33
  import { withResponsive } from '../private/with-responsive';
32
34
  import styles from './heatmap-chart.module.scss';
33
35
  import {
36
+ fitCompactCells,
34
37
  getHeatmapScale,
35
38
  getValueExtent,
36
39
  getNormalizedValue,
@@ -39,6 +42,7 @@ import {
39
42
  isEmptyValue,
40
43
  isPresent,
41
44
  resolveColumnGroups,
45
+ wrapColumnGroups,
42
46
  } from './private';
43
47
  import {
44
48
  firstCalendarCell,
@@ -47,7 +51,7 @@ import {
47
51
  stepCalendarCell,
48
52
  stepGridCell,
49
53
  } from './private/keyboard-navigation';
50
- import type { HeatmapContextValue } from './private';
54
+ import type { CompactCellFit, HeatmapContextValue } from './private';
51
55
  import type { CellBlock, CellPosition } from './private/keyboard-navigation';
52
56
  import type { HeatmapChartProps, HeatmapTooltipData } from './types';
53
57
  import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
@@ -68,6 +72,27 @@ const NO_ROW_LABELS: string[] = [];
68
72
 
69
73
  const TOOLTIP_BOX_STYLE: CSSProperties = { zIndex: TOOLTIP_Z_INDEX };
70
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
+
71
96
  // The cell's own label wins; otherwise the group, column and row labels name it.
72
97
  const cellName = ( info: HeatmapTooltipData ) =>
73
98
  info.cellLabel ||
@@ -80,6 +105,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
80
105
  height = 0,
81
106
  className,
82
107
  compact = false,
108
+ fitCells = false,
83
109
  showValues,
84
110
  maxCellWidth,
85
111
  maxCellHeight,
@@ -110,16 +136,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
110
136
  useTooltip< HeatmapTooltipData >();
111
137
  const standaloneScopeClass = useStandaloneScopeClass();
112
138
  const containerRef = useRef< HTMLDivElement >( null );
139
+ const chartRef = useRef< HTMLDivElement >( null );
113
140
  const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
114
- // The chart root positions the tooltip, so pointer and cell coordinates are
115
- // measured against it — found by its id rather than by walking up, so
116
- // whatever ChartLayout wraps the grid in cannot shift the origin.
117
141
  const getTooltipOrigin = useCallback(
118
- () =>
119
- containerRef.current
120
- ?.closest( `[data-chart-id="heatmap-chart-${ chartId }"]` )
121
- ?.getBoundingClientRect() ?? null,
122
- [ chartId ]
142
+ () => chartRef.current?.getBoundingClientRect() ?? null,
143
+ []
123
144
  );
124
145
 
125
146
  const { color: primaryColorHex } = getElementStyles( {
@@ -189,6 +210,67 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
189
210
 
190
211
  const { compactCellGap, compactCellSize, groupGap } = heatmapChartSettings;
191
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
+ );
192
274
 
193
275
  const buildTooltipData = useCallback(
194
276
  ( columnIndex: number, rowIndex: number ): HeatmapTooltipData => {
@@ -376,12 +458,18 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
376
458
  const rowTrack = compact
377
459
  ? 'var(--a8c-charts-dimension-heatmap-cell-size)'
378
460
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
379
- const hasColumnLabels = data.some( column => Boolean( column.label ) );
380
- const hasGroups = groupLayout.groups.length > 0;
381
461
  // Every item is placed by hand rather than auto-flowed, so the template can
382
462
  // carry gap tracks that hold no cell.
383
- const columnLine = ( columnIndex: number ) => groupLayout.columns[ columnIndex ].line;
384
- 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;
385
473
  // A summary column takes a content-sized track: a roll-up is wider than a
386
474
  // cell, and a shared track would stretch every cell to fit it. `max-content`
387
475
  // as the max keeps the leftover width out of it once the data tracks hit
@@ -392,23 +480,33 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
392
480
  // minmax track the way it widens the summary's auto track. Compact cells
393
481
  // are fixed, so there the gaps share the leftover width instead.
394
482
  const gapTrack = compact ? `minmax(${ groupGap }px, 1fr)` : `${ groupGap }px`;
395
- const columnTracks = data
396
- .map( ( column, columnIndex ) =>
397
- groupLayout.columns[ columnIndex ].gapBefore
398
- ? `${ gapTrack } ${ dataTrack( column ) }`
399
- : dataTrack( column )
400
- )
401
- .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
+ }`;
402
500
  const gridStyle: Record< string, string | number > = {
403
501
  ...fillVars,
404
502
  gridTemplateColumns: `auto ${ columnTracks }`,
405
- gridTemplateRows: `${ hasColumnLabels ? 'auto ' : '' }repeat(${ rows }, ${ rowTrack })${
406
- hasGroups ? ' auto' : ''
407
- }`,
503
+ gridTemplateRows: Array( bands ).fill( bandTracks ).join( ` ${ groupGap }px ` ),
408
504
  };
409
505
  if ( compact ) {
410
506
  gridStyle[ '--a8c-charts-dimension-heatmap-cell-gap' ] = `${ compactCellGap }px`;
411
- gridStyle[ '--a8c-charts-dimension-heatmap-cell-size' ] = `${ compactCellSize }px`;
507
+ gridStyle[ '--a8c-charts-dimension-heatmap-cell-size' ] = `${
508
+ fit?.cellSize ?? compactCellSize
509
+ }px`;
412
510
  }
413
511
 
414
512
  // A summary column sits one gap apart from the data on either side; two
@@ -445,6 +543,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
445
543
  legendChildren={ [] }
446
544
  trailingContent={ nonLegendChildren }
447
545
  gap={ gap }
546
+ rootRef={ chartRef }
448
547
  className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className, {
449
548
  [ styles[ 'heatmap-chart--height-capped' ] ]: heightCapped,
450
549
  } ) }
@@ -475,11 +574,14 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
475
574
  { /* Decorative: cell aria-labels already carry the column name. */ }
476
575
  { hasColumnLabels && (
477
576
  <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
478
- <span style={ { gridColumn: 1, gridRow: 1 } } />
577
+ <span style={ { gridColumn: 1, gridRow: labelRowOf( 0 ) } } />
479
578
  { data.map( ( column, columnIndex ) => (
480
579
  <span
481
580
  key={ `col-${ columnIndex }` }
482
- style={ { gridColumn: columnLine( columnIndex ), gridRow: 1 } }
581
+ style={ {
582
+ gridColumn: columnLine( columnIndex ),
583
+ gridRow: labelRowOf( bandOf( columnIndex ) ),
584
+ } }
483
585
  className={ clsx( styles[ 'heatmap-chart__col-label' ], {
484
586
  [ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
485
587
  ...summaryGaps( columnIndex ),
@@ -493,7 +595,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
493
595
 
494
596
  { Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
495
597
  const labelVisible = ! compact || rowIndex % 2 === 0;
496
- const gridRow = firstDataRow + rowIndex;
497
598
  return (
498
599
  <div
499
600
  key={ `row-${ rowIndex }` }
@@ -501,16 +602,22 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
501
602
  aria-rowindex={ rowIndex + 1 }
502
603
  className={ styles[ 'heatmap-chart__row' ] }
503
604
  >
504
- <span
505
- aria-hidden="true"
506
- className={ styles[ 'heatmap-chart__row-label' ] }
507
- style={ { gridColumn: 1, gridRow } }
508
- >
509
- { labelVisible ? ( rowLabels[ rowIndex ] ?? '' ) : '' }
510
- </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
+ ) ) }
511
615
  { data.map( ( column, columnIndex ) => {
512
616
  const cell = column.data[ rowIndex ];
513
- const placement = { gridColumn: columnLine( columnIndex ), gridRow };
617
+ const placement = {
618
+ gridColumn: columnLine( columnIndex ),
619
+ gridRow: dataRowOf( bandOf( columnIndex ), rowIndex ),
620
+ };
514
621
 
515
622
  // A hidden cell keeps its grid slot (so the rest of the
516
623
  // column doesn't shift) but paints nothing and takes no
@@ -611,14 +718,14 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
611
718
  } ) }
612
719
  { hasGroups && (
613
720
  <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
614
- { groupLayout.groups.map( ( group, groupIndex ) => (
721
+ { placed.groups.map( ( group, groupIndex ) => (
615
722
  <span
616
723
  key={ `group-${ groupIndex }` }
617
724
  data-testid="heatmap-group-label"
618
725
  className={ styles[ 'heatmap-chart__group-label' ] }
619
726
  style={ {
620
727
  gridColumn: `${ group.line } / span ${ group.span }`,
621
- gridRow: firstDataRow + rows,
728
+ gridRow: dataRowOf( group.band, rows ),
622
729
  } }
623
730
  >
624
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
+ };