@automattic/charts 4.8.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 (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.cjs +624 -228
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +58 -8
  5. package/dist/index.d.cts +42 -8
  6. package/dist/index.d.ts +42 -8
  7. package/dist/index.js +628 -232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +5 -2
  11. package/src/charts/area-chart/test/area-chart.test.tsx +23 -14
  12. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  13. package/src/charts/area-chart/types.ts +2 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +5 -2
  15. package/src/charts/bar-chart/test/bar-chart.test.tsx +82 -63
  16. package/src/charts/bar-chart/types.ts +2 -0
  17. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  18. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
  19. package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
  20. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  21. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  22. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
  23. package/src/charts/heatmap-chart/private/index.ts +4 -2
  24. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  25. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  26. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -1
  27. package/src/charts/heatmap-chart/types.ts +2 -0
  28. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
  29. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
  30. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
  31. package/src/charts/leaderboard-chart/types.ts +6 -1
  32. package/src/charts/line-chart/line-chart.tsx +5 -2
  33. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  34. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  35. package/src/charts/line-chart/test/line-chart.test.tsx +45 -35
  36. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  37. package/src/charts/line-chart/types.ts +5 -3
  38. package/src/charts/pie-chart/pie-chart.tsx +149 -139
  39. package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
  40. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +122 -120
  41. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
  42. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  43. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  44. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  45. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  46. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  47. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  48. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +44 -0
  49. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  50. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  51. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  52. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  53. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  54. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
  55. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
  56. package/src/types.ts +18 -2
@@ -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
+ };
@@ -1,13 +1,15 @@
1
1
  import { __ } from '@wordpress/i18n';
2
2
  import { Stack, Text } from '@wordpress/ui';
3
+ import clsx from 'clsx';
3
4
  import { useContext } from 'react';
4
5
  import { useGlobalChartsTheme } from '../../../providers';
5
6
  import styles from '../heatmap-chart.module.scss';
6
7
  import { HeatmapContext } from './heatmap-context';
8
+ import { isEmptyValue } from './use-heatmap-colors';
7
9
  import type { CSSProperties, FC } from 'react';
8
10
 
9
11
  export interface HeatmapLegendProps {
10
- /** Number of swatches in the scale. Default 5. */
12
+ /** Number of color swatches in the scale, not counting the empty-cell swatch. Default 5. */
11
13
  steps?: number;
12
14
  /**
13
15
  * `swatches` spaces the steps out as cell-sized squares; `bar` joins them
@@ -29,8 +31,9 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
29
31
  if ( ! context ) {
30
32
  return null;
31
33
  }
32
- const { fillVars } = context;
34
+ const { extent, fillVars } = context;
33
35
  const labelStyle = legend.labelStyles;
36
+ const showsEmptyCell = isEmptyValue( 0, extent );
34
37
 
35
38
  return (
36
39
  <Stack direction="row" gap="xs" align="center">
@@ -48,6 +51,15 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
48
51
  data-testid="heatmap-legend-scale"
49
52
  className={ variant === 'bar' ? styles[ 'heatmap-chart__legend-scale--bar' ] : undefined }
50
53
  >
54
+ { showsEmptyCell && (
55
+ <span
56
+ data-testid="heatmap-legend-empty-swatch"
57
+ className={ clsx(
58
+ styles[ 'heatmap-chart__legend-swatch' ],
59
+ styles[ 'heatmap-chart__legend-swatch--empty' ]
60
+ ) }
61
+ />
62
+ ) }
51
63
  { Array.from( { length: steps }, ( _, index ) => {
52
64
  const intensity = steps <= 1 ? 1 : index / ( steps - 1 );
53
65
  return (
@@ -4,5 +4,7 @@ export { HeatmapLegend } from './heatmap-legend';
4
4
  export { HeatmapContext } from './heatmap-context';
5
5
  export type { HeatmapLegendProps } from './heatmap-legend';
6
6
  export type { HeatmapContextValue } from './heatmap-context';
7
- export { resolveColumnGroups } from './column-groups';
8
- export type { ColumnGroupLayout } from './column-groups';
7
+ export { resolveColumnGroups, wrapColumnGroups } from './column-groups';
8
+ export type { ColumnGroupLayout, WrappedColumnGroupLayout } from './column-groups';
9
+ export { fitCompactCells } from './fit-compact-cells';
10
+ export type { CompactCellFit } from './fit-compact-cells';
@@ -1,4 +1,4 @@
1
- import { resolveColumnGroups } from '../private/column-groups';
1
+ import { resolveColumnGroups, wrapColumnGroups } from '../private/column-groups';
2
2
 
3
3
  const lines = ( columns: { line: number }[] ) => columns.map( column => column.line );
4
4
  const gaps = ( columns: { gapBefore: boolean }[] ) => columns.map( column => column.gapBefore );
@@ -16,6 +16,7 @@ describe( 'resolveColumnGroups', () => {
16
16
  expect( resolveColumnGroups( [ { label: 'A', span: 3 } ], 0, 2 ) ).toEqual( {
17
17
  columns: [],
18
18
  groups: [],
19
+ bands: 1,
19
20
  } );
20
21
  expect( console ).not.toHaveWarned();
21
22
  } );
@@ -30,9 +31,9 @@ describe( 'resolveColumnGroups', () => {
30
31
  expect( lines( layout.columns ) ).toEqual( [ 2, 3, 5, 7, 8 ] );
31
32
  expect( gaps( layout.columns ) ).toEqual( [ false, false, true, true, false ] );
32
33
  expect( layout.groups ).toEqual( [
33
- { label: 'A', span: 2, line: 2 },
34
- { label: 'B', span: 1, line: 5 },
35
- { label: 'C', span: 2, line: 7 },
34
+ { label: 'A', span: 2, line: 2, band: 0 },
35
+ { label: 'B', span: 1, line: 5, band: 0 },
36
+ { label: 'C', span: 2, line: 7, band: 0 },
36
37
  ] );
37
38
  expect( layout.columns.map( column => column.group ) ).toEqual( [ 0, 0, 1, 2, 2 ] );
38
39
  } );
@@ -81,3 +82,39 @@ describe( 'resolveColumnGroups', () => {
81
82
  expect( console ).toHaveWarned();
82
83
  } );
83
84
  } );
85
+
86
+ describe( 'wrapColumnGroups', () => {
87
+ const groups = [
88
+ { label: 'A', span: 2 },
89
+ { label: 'B', span: 1 },
90
+ { label: 'C', span: 2 },
91
+ ];
92
+
93
+ test( 'restarts each band at the first line, aligning the groups in slots as wide as their widest', () => {
94
+ const wrapped = wrapColumnGroups( resolveColumnGroups( groups, 5, 2 ), 2 );
95
+ expect( wrapped?.slotSpans ).toEqual( [ 2, 1 ] );
96
+ expect(
97
+ wrapped?.columns.map( ( { line, band, gapBefore } ) => [ line, band, gapBefore ] )
98
+ ).toEqual( [
99
+ [ 2, 0, false ],
100
+ [ 3, 0, false ],
101
+ [ 5, 0, true ],
102
+ [ 2, 1, false ],
103
+ [ 3, 1, false ],
104
+ ] );
105
+ expect( wrapped?.groups.map( ( { label, line, band } ) => [ label, line, band ] ) ).toEqual( [
106
+ [ 'A', 2, 0 ],
107
+ [ 'B', 5, 0 ],
108
+ [ 'C', 2, 1 ],
109
+ ] );
110
+ } );
111
+
112
+ test.each( [
113
+ [ 'one band', groups, 5, 1 ],
114
+ [ 'an ungrouped tail', groups, 6, 2 ],
115
+ ] )( 'leaves the layout unwrapped for %s', ( _, columnGroups, columnCount, bands ) => {
116
+ expect(
117
+ wrapColumnGroups( resolveColumnGroups( columnGroups, columnCount, 2 ), bands )
118
+ ).toBeNull();
119
+ } );
120
+ } );
@@ -0,0 +1,59 @@
1
+ import { resolveColumnGroups } from '../private/column-groups';
2
+ import { fitCompactCells } from '../private/fit-compact-cells';
3
+
4
+ const monthGroups = Array.from( { length: 12 }, ( _, index ) => ( {
5
+ label: `M${ index + 1 }`,
6
+ span: 7,
7
+ } ) );
8
+
9
+ // Twelve month calendars, weekdays across and six weeks down, labelled beneath.
10
+ const months = {
11
+ layout: resolveColumnGroups( monthGroups, 84, 2 ),
12
+ rows: 6,
13
+ cellGap: 2,
14
+ groupGap: 16,
15
+ rowLabelWidth: 0,
16
+ columnLabelHeight: null,
17
+ groupLabelHeight: 22,
18
+ minCellSize: 11,
19
+ };
20
+
21
+ describe( 'fitCompactCells', () => {
22
+ test.each( [
23
+ [ 'a one-row tile keeps one band at the floor', 1189, 110, { bands: 1, cellSize: 11 } ],
24
+ [ 'a wide one-row tile grows its single band', 1900, 130, { bands: 1, cellSize: 16 } ],
25
+ [ 'a two-row tile wraps into two bands of six', 1189, 300, { bands: 2, cellSize: 17 } ],
26
+ [ 'a tall narrow tile wraps into four bands of three', 372, 641, { bands: 4, cellSize: 14 } ],
27
+ [
28
+ 'a narrow tile wraps rather than overflow at the floor',
29
+ 320,
30
+ 641,
31
+ { bands: 4, cellSize: 11 },
32
+ ],
33
+ ] )( '%s', ( _, width, height, expected ) => {
34
+ expect( fitCompactCells( { ...months, width, height } ) ).toEqual( expected );
35
+ } );
36
+
37
+ test( 'sizes a grid with an ungrouped tail for the one band it can draw', () => {
38
+ const layout = resolveColumnGroups( monthGroups.slice( 0, 3 ), 22, 2 );
39
+ expect( fitCompactCells( { ...months, layout, width: 400, height: 400 } ) ).toEqual( {
40
+ bands: 1,
41
+ cellSize: 14,
42
+ } );
43
+ } );
44
+
45
+ test( 'leaves room for the row-label track and the column-label row', () => {
46
+ expect(
47
+ fitCompactCells( {
48
+ ...months,
49
+ layout: resolveColumnGroups( undefined, 10, 2 ),
50
+ rows: 2,
51
+ groupLabelHeight: null,
52
+ rowLabelWidth: 40,
53
+ columnLabelHeight: 20,
54
+ width: 300,
55
+ height: 100,
56
+ } )
57
+ ).toEqual( { bands: 1, cellSize: 24 } );
58
+ } );
59
+ } );