@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.
- package/CHANGELOG.md +28 -0
- package/dist/index.cjs +766 -277
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -8
- package/dist/index.d.cts +54 -15
- package/dist/index.d.ts +54 -15
- package/dist/index.js +770 -281
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +7 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +12 -4
- package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
- package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
- package/src/charts/conversion-funnel-chart/types.ts +1 -1
- package/src/charts/geo-chart/geo-chart.tsx +17 -2
- package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
- package/src/charts/geo-chart/types.ts +1 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
- package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
- package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
- package/src/charts/heatmap-chart/private/index.ts +4 -2
- package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
- package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
- package/src/charts/leaderboard-chart/types.ts +7 -1
- package/src/charts/line-chart/line-chart.tsx +8 -2
- package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/line-chart/types.ts +3 -3
- package/src/charts/pie-chart/pie-chart.module.scss +3 -0
- package/src/charts/pie-chart/pie-chart.tsx +144 -139
- package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +117 -120
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
- package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
- package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
- package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
- package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
- package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
- package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
- package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
- package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
- package/src/charts/test/accessible-name.test.tsx +181 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
- package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
- package/src/components/legend/private/base-legend.tsx +27 -15
- package/src/components/legend/test/legend.test.tsx +42 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
- package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
- package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
- package/src/hooks/use-xychart-theme.ts +33 -2
- package/src/index.ts +1 -0
- package/src/types.ts +37 -3
- package/src/utils/resolve-font-size.ts +9 -10
- 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[]
|
|
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
|
|
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( '
|
|
284
|
+
test( "hides Google's own SVG label from screen readers once the map is ready", () => {
|
|
285
285
|
renderWithTheme();
|
|
286
286
|
|
|
287
|
-
const
|
|
288
|
-
|
|
287
|
+
const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
|
|
288
|
+
screen.getByTestId( 'google-chart-mock' ).appendChild( svg );
|
|
289
289
|
|
|
290
|
-
|
|
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
|
|
100
|
-
font-size: var(--wpds-typography-font-size-
|
|
101
|
-
line-height: var(--wpds-typography-line-height-
|
|
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:
|
|
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
|
-
|
|
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
|
|
384
|
-
const
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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:
|
|
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' ] = `${
|
|
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:
|
|
577
|
+
<span style={ { gridColumn: 1, gridRow: labelRowOf( 0 ) } } />
|
|
479
578
|
{ data.map( ( column, columnIndex ) => (
|
|
480
579
|
<span
|
|
481
580
|
key={ `col-${ columnIndex }` }
|
|
482
|
-
style={ {
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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 = {
|
|
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
|
-
{
|
|
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:
|
|
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
|
|
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( {
|
|
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
|
+
};
|