@automattic/charts 4.7.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/TOKENS.md +6 -3
- package/dist/index.cjs +834 -321
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +68 -17
- package/dist/index.d.cts +102 -28
- package/dist/index.d.ts +102 -28
- package/dist/index.js +838 -327
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +7 -4
- package/src/charts/area-chart/test/area-chart.test.tsx +24 -15
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/area-chart/types.ts +2 -0
- package/src/charts/bar-chart/bar-chart.module.scss +7 -0
- package/src/charts/bar-chart/bar-chart.tsx +25 -16
- package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +106 -62
- package/src/charts/bar-chart/types.ts +2 -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 +1 -1
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -8
- package/src/charts/heatmap-chart/heatmap-chart.tsx +183 -66
- 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-context.ts +2 -2
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +15 -3
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
- package/src/charts/heatmap-chart/private/index.ts +6 -3
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
- 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 +231 -8
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
- package/src/charts/heatmap-chart/types.ts +6 -3
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
- package/src/charts/leaderboard-chart/types.ts +6 -1
- package/src/charts/line-chart/line-chart.tsx +7 -4
- 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 +45 -35
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/line-chart/types.ts +5 -3
- package/src/charts/pie-chart/pie-chart.tsx +153 -143
- package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +126 -124
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +55 -1
- 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 +44 -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/test/label-text-color.test.ts +6 -8
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
- package/src/components/tooltip/base-tooltip.tsx +31 -32
- package/src/components/tooltip/index.ts +3 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
- package/src/components/tooltip/private/label-value-content.tsx +8 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
- package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
- package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
- package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +283 -365
- package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
- package/src/components/tooltip/tooltip-box.tsx +43 -0
- package/src/components/tooltip/types.ts +7 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
- package/src/hooks/test/use-chart-margin.test.tsx +8 -4
- package/src/hooks/use-chart-margin.tsx +21 -18
- package/src/index.ts +8 -3
- package/src/providers/chart-context/global-charts-provider.tsx +5 -14
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
- package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
- package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
- package/src/styles/chart-scope.scss +1 -0
- package/src/test-utils/zoned-parsing-suite.ts +0 -10
- package/src/types.ts +18 -2
- package/src/utils/test/date-formatting.test.ts +0 -10
- package/src/components/tooltip/accessible-tooltip.tsx +0 -433
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
|
@@ -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 {
|
|
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 (
|
|
@@ -57,7 +69,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
|
|
|
57
69
|
className={ styles[ 'heatmap-chart__legend-swatch' ] }
|
|
58
70
|
style={
|
|
59
71
|
{
|
|
60
|
-
|
|
72
|
+
...fillVars,
|
|
61
73
|
'--a8c-charts-heatmap-cell-intensity': intensity,
|
|
62
74
|
} as CSSProperties
|
|
63
75
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
|
|
2
|
+
import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
|
|
3
|
+
import { mixHexColors, relativeLuminance } from '../../../utils/color-utils';
|
|
4
|
+
|
|
5
|
+
/** Contrast the highest step aims for against the background, so the steps stay apart. */
|
|
6
|
+
export const HEATMAP_HIGH_CONTRAST = 9;
|
|
7
|
+
|
|
8
|
+
const STEPS = 200;
|
|
9
|
+
|
|
10
|
+
export type HeatmapScale = {
|
|
11
|
+
low: string;
|
|
12
|
+
high: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Measured on the rounded hex the browser paints, so an endpoint never lands under its threshold.
|
|
16
|
+
const firstMeeting = ( from: string, to: string, meets: ( color: string ) => boolean ) => {
|
|
17
|
+
for ( let step = 0; step <= STEPS; step++ ) {
|
|
18
|
+
const color = mixHexColors( from, to, step / STEPS );
|
|
19
|
+
if ( meets( color ) ) {
|
|
20
|
+
return color;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The two ends of the heatmap fill scale: 3:1 against the background and the empty cell at the
|
|
28
|
+
* low end, toward 9:1 against the background at the high end.
|
|
29
|
+
*
|
|
30
|
+
* @param primary - The resolved primary color, as six-digit hex.
|
|
31
|
+
* @param background - The resolved chart background, as six-digit hex.
|
|
32
|
+
* @param emptyCell - The resolved empty-cell color, as six-digit hex, when it is opaque.
|
|
33
|
+
* @return The scale ends, as six-digit hex.
|
|
34
|
+
*/
|
|
35
|
+
export const getHeatmapScale = (
|
|
36
|
+
primary: string,
|
|
37
|
+
background: string,
|
|
38
|
+
emptyCell?: string | null
|
|
39
|
+
): HeatmapScale => {
|
|
40
|
+
const primaryStandsOut = contrastRatio( primary, background ) >= MIN_BACKGROUND_CONTRAST;
|
|
41
|
+
// The scale deepens to black or white on the primary's own side of the background, so it never
|
|
42
|
+
// crosses it. A primary too close to the background takes whichever contrasts more.
|
|
43
|
+
const towardWhite = primaryStandsOut
|
|
44
|
+
? relativeLuminance( primary ) > relativeLuminance( background )
|
|
45
|
+
: contrastRatio( '#ffffff', background ) > contrastRatio( '#000000', background );
|
|
46
|
+
const extreme = towardWhite ? '#ffffff' : '#000000';
|
|
47
|
+
|
|
48
|
+
const lowAgainst = ( references: string[] ) => {
|
|
49
|
+
const meets = ( color: string ) =>
|
|
50
|
+
references.every( reference => contrastRatio( color, reference ) >= MIN_BACKGROUND_CONTRAST );
|
|
51
|
+
return (
|
|
52
|
+
( primaryStandsOut ? firstMeeting( background, primary, meets ) : null ) ??
|
|
53
|
+
firstMeeting( primary, extreme, meets )
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
// Only `high` can miss: black or white tops out near 4.58:1 on a mid-tone background.
|
|
57
|
+
const high =
|
|
58
|
+
firstMeeting(
|
|
59
|
+
primary,
|
|
60
|
+
extreme,
|
|
61
|
+
color => contrastRatio( color, background ) >= HEATMAP_HIGH_CONTRAST
|
|
62
|
+
) ?? extreme;
|
|
63
|
+
|
|
64
|
+
// The lowest step sits beside empty cells, so it stands apart from them too, unless an
|
|
65
|
+
// empty-cell color far from the background would push it past the high end.
|
|
66
|
+
const againstEmptyCell = emptyCell ? lowAgainst( [ background, emptyCell ] ) : null;
|
|
67
|
+
const low =
|
|
68
|
+
againstEmptyCell &&
|
|
69
|
+
contrastRatio( againstEmptyCell, background ) <= contrastRatio( high, background )
|
|
70
|
+
? againstEmptyCell
|
|
71
|
+
: lowAgainst( [ background ] );
|
|
72
|
+
|
|
73
|
+
return { low: low ?? extreme, high };
|
|
74
|
+
};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
|
|
1
|
+
export { getValueExtent, getNormalizedValue, isEmptyValue, isPresent } from './use-heatmap-colors';
|
|
2
|
+
export { getHeatmapScale } from './heatmap-scale';
|
|
2
3
|
export { HeatmapLegend } from './heatmap-legend';
|
|
3
4
|
export { HeatmapContext } from './heatmap-context';
|
|
4
5
|
export type { HeatmapLegendProps } from './heatmap-legend';
|
|
5
6
|
export type { HeatmapContextValue } from './heatmap-context';
|
|
6
|
-
export { resolveColumnGroups } from './column-groups';
|
|
7
|
-
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';
|
|
@@ -4,14 +4,16 @@ export const isPresent = ( value: number | null | undefined ): value is number =
|
|
|
4
4
|
value !== null && value !== undefined && ! isNaN( value );
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Get the min and max values from heatmap data, ignoring null/NaN
|
|
8
|
-
* columns stay out: a roll-up on the
|
|
7
|
+
* Get the min and max values from heatmap data, ignoring null/NaN and the zeros
|
|
8
|
+
* `isEmptyValue` paints as empty. Summary columns stay out: a roll-up on the
|
|
9
|
+
* scale would flatten every real cell.
|
|
9
10
|
* @param data - The heatmap columns
|
|
10
11
|
* @return Tuple of [min, max] values
|
|
11
12
|
*/
|
|
12
13
|
export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
|
|
13
14
|
let min = Infinity;
|
|
14
15
|
let max = -Infinity;
|
|
16
|
+
let hasZero = false;
|
|
15
17
|
for ( const column of data ) {
|
|
16
18
|
if ( column.summary ) {
|
|
17
19
|
continue;
|
|
@@ -20,6 +22,10 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
|
|
|
20
22
|
if ( ! isPresent( cell.value ) ) {
|
|
21
23
|
continue;
|
|
22
24
|
}
|
|
25
|
+
if ( cell.value === 0 ) {
|
|
26
|
+
hasZero = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
23
29
|
if ( cell.value < min ) {
|
|
24
30
|
min = cell.value;
|
|
25
31
|
}
|
|
@@ -28,17 +34,29 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
|
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
}
|
|
37
|
+
if ( hasZero && min < 0 ) {
|
|
38
|
+
max = Math.max( max, 0 );
|
|
39
|
+
}
|
|
31
40
|
if ( min === Infinity ) {
|
|
32
41
|
return [ 0, 0 ];
|
|
33
42
|
}
|
|
34
43
|
return [ min, max ];
|
|
35
44
|
};
|
|
36
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whether a value paints as an empty cell: a zero in data with no negatives
|
|
48
|
+
* counts nothing, so it must not look like the lowest step of activity.
|
|
49
|
+
*
|
|
50
|
+
* @param value - The cell value
|
|
51
|
+
* @param extent - The extent from `getValueExtent`
|
|
52
|
+
* @return True when the cell takes the empty-cell color
|
|
53
|
+
*/
|
|
54
|
+
export const isEmptyValue = ( value: number, extent: [ number, number ] ): boolean =>
|
|
55
|
+
value === 0 && extent[ 0 ] >= 0;
|
|
56
|
+
|
|
37
57
|
/**
|
|
38
58
|
* Normalize a value to 0–1 within the extent. A flat extent (min === max)
|
|
39
|
-
* maps to 1
|
|
40
|
-
* extent, which maps to 0 so a no-activity grid renders at the scale's
|
|
41
|
-
* bottom instead of full intensity.
|
|
59
|
+
* maps to 1, since every cell is equally the "highest".
|
|
42
60
|
*
|
|
43
61
|
* @param value - The value to normalize
|
|
44
62
|
* @param extent - Tuple of [min, max] values for the normalization range
|
|
@@ -47,7 +65,7 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
|
|
|
47
65
|
export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => {
|
|
48
66
|
const [ min, max ] = extent;
|
|
49
67
|
if ( min === max ) {
|
|
50
|
-
return
|
|
68
|
+
return 1;
|
|
51
69
|
}
|
|
52
70
|
return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) );
|
|
53
71
|
};
|
|
@@ -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
|
+
} );
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, within } from '@testing-library/react';
|
|
1
|
+
import { act, render, screen, within } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
4
|
import { buildMonthCalendarHeatmapData } from '../build-month-calendar-data';
|
|
@@ -172,6 +172,49 @@ describe( 'HeatmapChart', () => {
|
|
|
172
172
|
).toHaveLength( 4 );
|
|
173
173
|
} );
|
|
174
174
|
|
|
175
|
+
test.each( [
|
|
176
|
+
[ 'with zeros painted empty', data, 1 ],
|
|
177
|
+
[ 'with negative values', [ { label: 'W1', data: [ { value: -2 }, { value: 0 } ] } ], 0 ],
|
|
178
|
+
] )( 'starts the legend scale with an empty-cell swatch %s', ( _case, rows, count ) => {
|
|
179
|
+
render(
|
|
180
|
+
<GlobalChartsProvider>
|
|
181
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ rows }>
|
|
182
|
+
<HeatmapChart.Legend variant="bar" />
|
|
183
|
+
</HeatmapChart>
|
|
184
|
+
</GlobalChartsProvider>
|
|
185
|
+
);
|
|
186
|
+
const empty = screen.queryAllByTestId( 'heatmap-legend-empty-swatch' );
|
|
187
|
+
const scale = screen.getByTestId( 'heatmap-legend-scale' );
|
|
188
|
+
|
|
189
|
+
expect( empty ).toHaveLength( count );
|
|
190
|
+
expect( within( scale ).getAllByTestId( 'heatmap-legend-swatch' ) ).toHaveLength( 5 );
|
|
191
|
+
expect(
|
|
192
|
+
within( scale )
|
|
193
|
+
.getAllByTestId( /^heatmap-legend-(empty-)?swatch$/ )
|
|
194
|
+
.slice( 0, count )
|
|
195
|
+
).toEqual( empty );
|
|
196
|
+
} );
|
|
197
|
+
|
|
198
|
+
test( 'paints the legend on the same scale as the cells', () => {
|
|
199
|
+
render(
|
|
200
|
+
<GlobalChartsProvider>
|
|
201
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9">
|
|
202
|
+
<HeatmapChart.Legend />
|
|
203
|
+
</HeatmapChart>
|
|
204
|
+
</GlobalChartsProvider>
|
|
205
|
+
);
|
|
206
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
207
|
+
|
|
208
|
+
for ( const swatch of screen.getAllByTestId( 'heatmap-legend-swatch' ) ) {
|
|
209
|
+
for ( const end of [ 'primary', 'low', 'high' ] ) {
|
|
210
|
+
const property = `--a8c-charts-color-heatmap-${ end }`;
|
|
211
|
+
expect( swatch.style.getPropertyValue( property ) ).toBe(
|
|
212
|
+
grid.style.getPropertyValue( property )
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} );
|
|
217
|
+
|
|
175
218
|
test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
|
|
176
219
|
renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
|
|
177
220
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
@@ -409,6 +452,78 @@ describe( 'HeatmapChart', () => {
|
|
|
409
452
|
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
|
|
410
453
|
} );
|
|
411
454
|
|
|
455
|
+
test( 'leaves the fill to the stylesheet when the primary color cannot resolve to hex', () => {
|
|
456
|
+
render(
|
|
457
|
+
<GlobalChartsProvider>
|
|
458
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="oklch(0.6 0.2 260)">
|
|
459
|
+
<HeatmapChart.Legend />
|
|
460
|
+
</HeatmapChart>
|
|
461
|
+
</GlobalChartsProvider>
|
|
462
|
+
);
|
|
463
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
464
|
+
const swatches = screen.getAllByTestId( 'heatmap-legend-swatch' );
|
|
465
|
+
|
|
466
|
+
for ( const element of [ grid, ...swatches ] ) {
|
|
467
|
+
expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe(
|
|
468
|
+
'oklch(0.6 0.2 260)'
|
|
469
|
+
);
|
|
470
|
+
expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '' );
|
|
471
|
+
expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '' );
|
|
472
|
+
}
|
|
473
|
+
expect( screen.getByText( '4' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
|
|
474
|
+
} );
|
|
475
|
+
|
|
476
|
+
test( 'builds the scale for white on a see-through background, as the palette does', () => {
|
|
477
|
+
const scope = document.createElement( 'div' );
|
|
478
|
+
scope.style.setProperty( '--a8c-charts-color-background', 'transparent' );
|
|
479
|
+
document.body.appendChild( scope );
|
|
480
|
+
|
|
481
|
+
render(
|
|
482
|
+
<GlobalChartsProvider>
|
|
483
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9" />
|
|
484
|
+
</GlobalChartsProvider>,
|
|
485
|
+
{ container: scope }
|
|
486
|
+
);
|
|
487
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
488
|
+
|
|
489
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '#6c83ef' );
|
|
490
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '#283ea5' );
|
|
491
|
+
|
|
492
|
+
document.body.removeChild( scope );
|
|
493
|
+
} );
|
|
494
|
+
|
|
495
|
+
test( 'reads the computed style once per re-render for the fill scale', () => {
|
|
496
|
+
const { rerender } = renderChart();
|
|
497
|
+
const spy = jest.spyOn( window, 'getComputedStyle' );
|
|
498
|
+
|
|
499
|
+
rerender(
|
|
500
|
+
<GlobalChartsProvider>
|
|
501
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } />
|
|
502
|
+
</GlobalChartsProvider>
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
expect( spy ).toHaveBeenCalledTimes( 1 );
|
|
506
|
+
spy.mockRestore();
|
|
507
|
+
} );
|
|
508
|
+
|
|
509
|
+
test( 'paints a zero as an empty cell, still announcing its value', () => {
|
|
510
|
+
renderChart();
|
|
511
|
+
const zero = screen.getByRole( 'gridcell', { name: 'W2: 0' } );
|
|
512
|
+
|
|
513
|
+
expect( zero ).not.toHaveClass( 'heatmap-chart__cell--filled' );
|
|
514
|
+
expect( zero.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '' );
|
|
515
|
+
expect( screen.getByRole( 'gridcell', { name: 'W1: 1' } ) ).toHaveClass(
|
|
516
|
+
'heatmap-chart__cell--filled'
|
|
517
|
+
);
|
|
518
|
+
} );
|
|
519
|
+
|
|
520
|
+
test( 'puts the lowest non-zero value on the lowest step', () => {
|
|
521
|
+
renderChart();
|
|
522
|
+
const lowest = screen.getByRole( 'gridcell', { name: 'W1: 1' } );
|
|
523
|
+
|
|
524
|
+
expect( lowest.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '0' );
|
|
525
|
+
} );
|
|
526
|
+
|
|
412
527
|
test( 'falls back to the first palette slot when no primaryColor prop is set', () => {
|
|
413
528
|
const scope = document.createElement( 'div' );
|
|
414
529
|
scope.style.setProperty( '--a8c-charts-color-series-1', '#0a0b0c' );
|
|
@@ -469,9 +584,9 @@ const mockRects = () =>
|
|
|
469
584
|
} );
|
|
470
585
|
|
|
471
586
|
describe( 'HeatmapChart value text contrast', () => {
|
|
472
|
-
//
|
|
587
|
+
// The low end, a mid-tone, and the high end of the scale.
|
|
473
588
|
const scale: HeatmapColumn[] = [
|
|
474
|
-
{ label: 'W1', data: [ { value:
|
|
589
|
+
{ label: 'W1', data: [ { value: 1 }, { value: 30 }, { value: 100 } ] },
|
|
475
590
|
];
|
|
476
591
|
|
|
477
592
|
let injectedStyle: HTMLStyleElement | null = null;
|
|
@@ -497,14 +612,14 @@ describe( 'HeatmapChart value text contrast', () => {
|
|
|
497
612
|
test( 'picks the label role that reaches AA on light and dark cells', () => {
|
|
498
613
|
renderScale();
|
|
499
614
|
|
|
500
|
-
expect( screen.getByText( '
|
|
615
|
+
expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
|
|
501
616
|
expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
|
|
502
617
|
} );
|
|
503
618
|
|
|
504
619
|
test( 'falls back to black on a mid-tone cell where neither role reaches AA', () => {
|
|
505
620
|
renderScale();
|
|
506
621
|
|
|
507
|
-
expect( screen.getByText( '
|
|
622
|
+
expect( screen.getByText( '30' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
|
|
508
623
|
} );
|
|
509
624
|
|
|
510
625
|
test( 'reads the label roles once data arrives after an empty first render', () => {
|
|
@@ -542,7 +657,7 @@ describe( 'HeatmapChart value text contrast', () => {
|
|
|
542
657
|
|
|
543
658
|
renderScale( 'translucent-heatmap' );
|
|
544
659
|
|
|
545
|
-
expect( screen.getByText( '
|
|
660
|
+
expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
|
|
546
661
|
expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
|
|
547
662
|
} );
|
|
548
663
|
|
|
@@ -565,7 +680,7 @@ describe( 'HeatmapChart value text contrast', () => {
|
|
|
565
680
|
);
|
|
566
681
|
|
|
567
682
|
expect( screen.getByText( '7' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
|
|
568
|
-
expect( screen.getByText( '
|
|
683
|
+
expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
|
|
569
684
|
} );
|
|
570
685
|
|
|
571
686
|
test( "keeps one color when both label roles are set to it on the chart's own class", () => {
|
|
@@ -576,7 +691,7 @@ describe( 'HeatmapChart value text contrast', () => {
|
|
|
576
691
|
|
|
577
692
|
renderScale( 'pinned-heatmap' );
|
|
578
693
|
|
|
579
|
-
[ '
|
|
694
|
+
[ '1', '30', '100' ].forEach( value =>
|
|
580
695
|
expect( screen.getByText( value ) ).toHaveClass( 'heatmap-chart__cell-value', {
|
|
581
696
|
exact: true,
|
|
582
697
|
} )
|
|
@@ -851,6 +966,114 @@ describe( 'HeatmapChart column groups', () => {
|
|
|
851
966
|
} );
|
|
852
967
|
} );
|
|
853
968
|
|
|
969
|
+
describe( 'HeatmapChart fitCells', () => {
|
|
970
|
+
const threeMonths = Array.from( { length: 6 }, ( _, index ) => ( {
|
|
971
|
+
data: [ { value: index }, { value: index + 1 } ],
|
|
972
|
+
} ) );
|
|
973
|
+
const columnGroups = [
|
|
974
|
+
{ label: 'Jan', span: 2 },
|
|
975
|
+
{ label: 'Feb', span: 2 },
|
|
976
|
+
{ label: 'Mar', span: 2 },
|
|
977
|
+
];
|
|
978
|
+
|
|
979
|
+
let widthSpy: jest.SpyInstance;
|
|
980
|
+
let heightSpy: jest.SpyInstance;
|
|
981
|
+
|
|
982
|
+
beforeEach( () => {
|
|
983
|
+
widthSpy = jest.spyOn( Element.prototype, 'clientWidth', 'get' ).mockReturnValue( 400 );
|
|
984
|
+
heightSpy = jest.spyOn( Element.prototype, 'clientHeight', 'get' ).mockReturnValue( 400 );
|
|
985
|
+
} );
|
|
986
|
+
|
|
987
|
+
afterEach( () => {
|
|
988
|
+
widthSpy.mockRestore();
|
|
989
|
+
heightSpy.mockRestore();
|
|
990
|
+
} );
|
|
991
|
+
|
|
992
|
+
test( 'wraps the groups that fit better on a second band below the first, cells grown to match', () => {
|
|
993
|
+
renderChart( { data: threeMonths, columnGroups, compact: true, fitCells: true } );
|
|
994
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
995
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
|
|
996
|
+
'91px'
|
|
997
|
+
);
|
|
998
|
+
const [ , feb, mar ] = screen.getAllByTestId( 'heatmap-group-label' );
|
|
999
|
+
expect( feb ).toHaveStyle( { gridColumn: '5 / span 2', gridRow: '3' } );
|
|
1000
|
+
// Band one takes rows 1-2, its labels 3 and the gap 4, so band two's labels sit on 7.
|
|
1001
|
+
expect( mar ).toHaveStyle( { gridColumn: '2 / span 2', gridRow: '7' } );
|
|
1002
|
+
const marchCell = screen
|
|
1003
|
+
.getAllByTestId( 'heatmap-cell' )
|
|
1004
|
+
.find( element => element.dataset.column === '4' && element.dataset.row === '0' );
|
|
1005
|
+
expect( marchCell ).toHaveStyle( { gridColumn: '2', gridRow: '5' } );
|
|
1006
|
+
} );
|
|
1007
|
+
|
|
1008
|
+
test( 'keeps the theme size, with a warning, when a column is a summary', () => {
|
|
1009
|
+
renderChart( {
|
|
1010
|
+
data: [
|
|
1011
|
+
...threeMonths,
|
|
1012
|
+
{ label: 'Total', summary: true, data: [ { value: 1 }, { value: 2 } ] },
|
|
1013
|
+
],
|
|
1014
|
+
columnGroups,
|
|
1015
|
+
compact: true,
|
|
1016
|
+
fitCells: true,
|
|
1017
|
+
} );
|
|
1018
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
1019
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
|
|
1020
|
+
'11px'
|
|
1021
|
+
);
|
|
1022
|
+
expect( console ).toHaveWarned();
|
|
1023
|
+
} );
|
|
1024
|
+
|
|
1025
|
+
test( 'leaves the grid the box less the content beside it', () => {
|
|
1026
|
+
const offsetHeight = jest
|
|
1027
|
+
.spyOn( HTMLElement.prototype, 'offsetHeight', 'get' )
|
|
1028
|
+
.mockImplementation( function ( this: HTMLElement ) {
|
|
1029
|
+
return this.dataset.testid === 'beside-grid' ? 100 : 0;
|
|
1030
|
+
} );
|
|
1031
|
+
try {
|
|
1032
|
+
renderChart( {
|
|
1033
|
+
data: threeMonths,
|
|
1034
|
+
columnGroups,
|
|
1035
|
+
compact: true,
|
|
1036
|
+
fitCells: true,
|
|
1037
|
+
children: <div data-testid="beside-grid" />,
|
|
1038
|
+
} );
|
|
1039
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
1040
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
|
|
1041
|
+
'66px'
|
|
1042
|
+
);
|
|
1043
|
+
} finally {
|
|
1044
|
+
offsetHeight.mockRestore();
|
|
1045
|
+
}
|
|
1046
|
+
} );
|
|
1047
|
+
|
|
1048
|
+
test( 'refits within the resize delivery, before later observers read the grid', () => {
|
|
1049
|
+
const observers: ResizeObserverCallback[] = [];
|
|
1050
|
+
const original = window.ResizeObserver;
|
|
1051
|
+
window.ResizeObserver = class {
|
|
1052
|
+
constructor( private callback: ResizeObserverCallback ) {}
|
|
1053
|
+
observe() {
|
|
1054
|
+
observers.push( this.callback );
|
|
1055
|
+
}
|
|
1056
|
+
unobserve() {}
|
|
1057
|
+
disconnect() {}
|
|
1058
|
+
} as unknown as typeof ResizeObserver;
|
|
1059
|
+
try {
|
|
1060
|
+
renderChart( { data: threeMonths, columnGroups, compact: true, fitCells: true } );
|
|
1061
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
1062
|
+
heightSpy.mockReturnValue( 100 );
|
|
1063
|
+
let cellSize = '';
|
|
1064
|
+
act( () => {
|
|
1065
|
+
observers.forEach( callback => callback( [], {} as ResizeObserver ) );
|
|
1066
|
+
cellSize = grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' );
|
|
1067
|
+
} );
|
|
1068
|
+
expect( cellSize ).toBe( '48px' );
|
|
1069
|
+
const [ , , mar ] = screen.getAllByTestId( 'heatmap-group-label' );
|
|
1070
|
+
expect( mar ).toHaveStyle( { gridRow: '3' } );
|
|
1071
|
+
} finally {
|
|
1072
|
+
window.ResizeObserver = original;
|
|
1073
|
+
}
|
|
1074
|
+
} );
|
|
1075
|
+
} );
|
|
1076
|
+
|
|
854
1077
|
describe( 'HeatmapChart calendar navigation', () => {
|
|
855
1078
|
const calendar = ( range: { start: string; end: string }, days: string[] ) =>
|
|
856
1079
|
buildMonthCalendarHeatmapData(
|