@automattic/charts 4.6.0 → 4.8.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 +32 -0
- package/TOKENS.md +15 -10
- package/dist/index.cjs +427 -211
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +40 -26
- package/dist/index.d.cts +69 -29
- package/dist/index.d.ts +69 -29
- package/dist/index.js +427 -213
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +2 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
- package/src/charts/bar-chart/bar-chart.module.scss +7 -0
- package/src/charts/bar-chart/bar-chart.tsx +20 -14
- package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +20 -1
- package/src/charts/conversion-funnel-chart/types.ts +3 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -7
- package/src/charts/heatmap-chart/heatmap-chart.tsx +80 -53
- package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
- package/src/charts/heatmap-chart/private/index.ts +2 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +221 -25
- 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 -9
- package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
- package/src/charts/line-chart/line-chart.module.scss +0 -5
- package/src/charts/line-chart/line-chart.tsx +10 -42
- package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
- package/src/charts/pie-chart/pie-chart.module.scss +9 -0
- package/src/charts/pie-chart/pie-chart.tsx +26 -80
- package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
- package/src/charts/private/chart-composition/chart-html.tsx +1 -1
- package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
- package/src/charts/private/label-text-color.ts +116 -0
- package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
- package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
- package/src/charts/private/test/label-text-color.test.ts +189 -0
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
- package/src/components/tooltip/base-tooltip.tsx +31 -30
- package/src/components/tooltip/index.ts +3 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +32 -13
- package/src/components/tooltip/private/label-value-content.tsx +8 -0
- package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
- package/src/components/tooltip/test/tool-tip.test.tsx +10 -3
- package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
- package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
- package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +286 -356
- package/src/components/tooltip/tooltip-box.module.scss +21 -0
- 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 +411 -301
- package/src/components/trend-indicator/trend-indicator.tsx +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/test/use-chart-margin.test.tsx +8 -4
- package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
- package/src/hooks/use-chart-margin.tsx +21 -18
- package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
- package/src/hooks/use-xychart-theme.ts +4 -12
- 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 +3 -5
- package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
- package/src/providers/chart-context/private/perceptual-color.ts +45 -4
- package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
- package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
- package/src/styles/chart-scope-class.ts +5 -0
- package/src/styles/chart-scope.scss +10 -6
- package/src/styles/test/chart-scope.test.ts +3 -3
- package/src/test-utils/zoned-parsing-suite.ts +0 -10
- package/src/utils/color-utils.ts +2 -1
- package/src/utils/test/date-formatting.test.ts +0 -10
- package/src/visx/types.ts +1 -1
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
- package/src/components/tooltip/accessible-tooltip.tsx +0 -433
- package/src/components/tooltip/base-tooltip.module.scss +0 -20
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
|
@@ -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,4 +1,5 @@
|
|
|
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';
|
|
@@ -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
|
};
|
|
@@ -85,34 +85,22 @@ describe( 'HeatmapChart', () => {
|
|
|
85
85
|
await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
|
|
86
86
|
} );
|
|
87
87
|
|
|
88
|
-
test(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
withTooltips: true,
|
|
101
|
-
tooltipVariant: 'dark',
|
|
102
|
-
rowLabels: [ 'Mon', 'Tue', 'Wed' ],
|
|
103
|
-
} );
|
|
104
|
-
await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
|
|
105
|
-
await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
|
|
106
|
-
const box = screen.getByTestId( 'bounded-tooltip' );
|
|
107
|
-
expect( box ).toHaveClass( 'surface' );
|
|
108
|
-
expect( box ).not.toHaveStyle( { backgroundColor: 'rgb(255, 255, 255)' } );
|
|
109
|
-
expect( box ).not.toHaveStyle( { color: 'rgb(102, 102, 102)' } );
|
|
110
|
-
} );
|
|
88
|
+
test.each( [ undefined, 'light', 'dark' ] as const )(
|
|
89
|
+
'draws the tooltip on the package surface for tooltipVariant %s',
|
|
90
|
+
async tooltipVariant => {
|
|
91
|
+
renderChart( { withTooltips: true, tooltipVariant, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
|
|
92
|
+
await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
|
|
93
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
|
|
94
|
+
const box = screen.getByTestId( 'bounded-tooltip' );
|
|
95
|
+
expect( box ).toHaveClass( 'surface' );
|
|
96
|
+
expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'background' ) );
|
|
97
|
+
expect( box ).toHaveStyle( { zIndex: 3 } );
|
|
98
|
+
}
|
|
99
|
+
);
|
|
111
100
|
|
|
112
|
-
test( 'merges tooltipStyle over the
|
|
101
|
+
test( 'merges tooltipStyle over the box styles', async () => {
|
|
113
102
|
renderChart( {
|
|
114
103
|
withTooltips: true,
|
|
115
|
-
tooltipVariant: 'dark',
|
|
116
104
|
tooltipStyle: { padding: '2px 4px' },
|
|
117
105
|
rowLabels: [ 'Mon', 'Tue', 'Wed' ],
|
|
118
106
|
} );
|
|
@@ -184,6 +172,26 @@ describe( 'HeatmapChart', () => {
|
|
|
184
172
|
).toHaveLength( 4 );
|
|
185
173
|
} );
|
|
186
174
|
|
|
175
|
+
test( 'paints the legend on the same scale as the cells', () => {
|
|
176
|
+
render(
|
|
177
|
+
<GlobalChartsProvider>
|
|
178
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9">
|
|
179
|
+
<HeatmapChart.Legend />
|
|
180
|
+
</HeatmapChart>
|
|
181
|
+
</GlobalChartsProvider>
|
|
182
|
+
);
|
|
183
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
184
|
+
|
|
185
|
+
for ( const swatch of screen.getAllByTestId( 'heatmap-legend-swatch' ) ) {
|
|
186
|
+
for ( const end of [ 'primary', 'low', 'high' ] ) {
|
|
187
|
+
const property = `--a8c-charts-color-heatmap-${ end }`;
|
|
188
|
+
expect( swatch.style.getPropertyValue( property ) ).toBe(
|
|
189
|
+
grid.style.getPropertyValue( property )
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} );
|
|
194
|
+
|
|
187
195
|
test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
|
|
188
196
|
renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
|
|
189
197
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
@@ -421,6 +429,78 @@ describe( 'HeatmapChart', () => {
|
|
|
421
429
|
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
|
|
422
430
|
} );
|
|
423
431
|
|
|
432
|
+
test( 'leaves the fill to the stylesheet when the primary color cannot resolve to hex', () => {
|
|
433
|
+
render(
|
|
434
|
+
<GlobalChartsProvider>
|
|
435
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="oklch(0.6 0.2 260)">
|
|
436
|
+
<HeatmapChart.Legend />
|
|
437
|
+
</HeatmapChart>
|
|
438
|
+
</GlobalChartsProvider>
|
|
439
|
+
);
|
|
440
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
441
|
+
const swatches = screen.getAllByTestId( 'heatmap-legend-swatch' );
|
|
442
|
+
|
|
443
|
+
for ( const element of [ grid, ...swatches ] ) {
|
|
444
|
+
expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe(
|
|
445
|
+
'oklch(0.6 0.2 260)'
|
|
446
|
+
);
|
|
447
|
+
expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '' );
|
|
448
|
+
expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '' );
|
|
449
|
+
}
|
|
450
|
+
expect( screen.getByText( '4' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
|
|
451
|
+
} );
|
|
452
|
+
|
|
453
|
+
test( 'builds the scale for white on a see-through background, as the palette does', () => {
|
|
454
|
+
const scope = document.createElement( 'div' );
|
|
455
|
+
scope.style.setProperty( '--a8c-charts-color-background', 'transparent' );
|
|
456
|
+
document.body.appendChild( scope );
|
|
457
|
+
|
|
458
|
+
render(
|
|
459
|
+
<GlobalChartsProvider>
|
|
460
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9" />
|
|
461
|
+
</GlobalChartsProvider>,
|
|
462
|
+
{ container: scope }
|
|
463
|
+
);
|
|
464
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
465
|
+
|
|
466
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '#6c83ef' );
|
|
467
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '#283ea5' );
|
|
468
|
+
|
|
469
|
+
document.body.removeChild( scope );
|
|
470
|
+
} );
|
|
471
|
+
|
|
472
|
+
test( 'reads the computed style once per re-render for the fill scale', () => {
|
|
473
|
+
const { rerender } = renderChart();
|
|
474
|
+
const spy = jest.spyOn( window, 'getComputedStyle' );
|
|
475
|
+
|
|
476
|
+
rerender(
|
|
477
|
+
<GlobalChartsProvider>
|
|
478
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ data } />
|
|
479
|
+
</GlobalChartsProvider>
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect( spy ).toHaveBeenCalledTimes( 1 );
|
|
483
|
+
spy.mockRestore();
|
|
484
|
+
} );
|
|
485
|
+
|
|
486
|
+
test( 'paints a zero as an empty cell, still announcing its value', () => {
|
|
487
|
+
renderChart();
|
|
488
|
+
const zero = screen.getByRole( 'gridcell', { name: 'W2: 0' } );
|
|
489
|
+
|
|
490
|
+
expect( zero ).not.toHaveClass( 'heatmap-chart__cell--filled' );
|
|
491
|
+
expect( zero.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '' );
|
|
492
|
+
expect( screen.getByRole( 'gridcell', { name: 'W1: 1' } ) ).toHaveClass(
|
|
493
|
+
'heatmap-chart__cell--filled'
|
|
494
|
+
);
|
|
495
|
+
} );
|
|
496
|
+
|
|
497
|
+
test( 'puts the lowest non-zero value on the lowest step', () => {
|
|
498
|
+
renderChart();
|
|
499
|
+
const lowest = screen.getByRole( 'gridcell', { name: 'W1: 1' } );
|
|
500
|
+
|
|
501
|
+
expect( lowest.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '0' );
|
|
502
|
+
} );
|
|
503
|
+
|
|
424
504
|
test( 'falls back to the first palette slot when no primaryColor prop is set', () => {
|
|
425
505
|
const scope = document.createElement( 'div' );
|
|
426
506
|
scope.style.setProperty( '--a8c-charts-color-series-1', '#0a0b0c' );
|
|
@@ -480,6 +560,122 @@ const mockRects = () =>
|
|
|
480
560
|
} as DOMRect;
|
|
481
561
|
} );
|
|
482
562
|
|
|
563
|
+
describe( 'HeatmapChart value text contrast', () => {
|
|
564
|
+
// The low end, a mid-tone, and the high end of the scale.
|
|
565
|
+
const scale: HeatmapColumn[] = [
|
|
566
|
+
{ label: 'W1', data: [ { value: 1 }, { value: 30 }, { value: 100 } ] },
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
let injectedStyle: HTMLStyleElement | null = null;
|
|
570
|
+
|
|
571
|
+
afterEach( () => {
|
|
572
|
+
injectedStyle?.remove();
|
|
573
|
+
injectedStyle = null;
|
|
574
|
+
} );
|
|
575
|
+
|
|
576
|
+
const renderScale = ( className?: string ) =>
|
|
577
|
+
render(
|
|
578
|
+
<GlobalChartsProvider>
|
|
579
|
+
<HeatmapChart
|
|
580
|
+
width={ 500 }
|
|
581
|
+
height={ 300 }
|
|
582
|
+
data={ scale }
|
|
583
|
+
primaryColor="#3858e9"
|
|
584
|
+
className={ className }
|
|
585
|
+
/>
|
|
586
|
+
</GlobalChartsProvider>
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
test( 'picks the label role that reaches AA on light and dark cells', () => {
|
|
590
|
+
renderScale();
|
|
591
|
+
|
|
592
|
+
expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
|
|
593
|
+
expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
|
|
594
|
+
} );
|
|
595
|
+
|
|
596
|
+
test( 'falls back to black on a mid-tone cell where neither role reaches AA', () => {
|
|
597
|
+
renderScale();
|
|
598
|
+
|
|
599
|
+
expect( screen.getByText( '30' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
|
|
600
|
+
} );
|
|
601
|
+
|
|
602
|
+
test( 'reads the label roles once data arrives after an empty first render', () => {
|
|
603
|
+
const { rerender } = render(
|
|
604
|
+
<GlobalChartsProvider>
|
|
605
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ [] } primaryColor="#3858e9" />
|
|
606
|
+
</GlobalChartsProvider>
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
rerender(
|
|
610
|
+
<GlobalChartsProvider>
|
|
611
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ scale } primaryColor="#3858e9" />
|
|
612
|
+
</GlobalChartsProvider>
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
|
|
616
|
+
} );
|
|
617
|
+
|
|
618
|
+
test( 'falls back to black or white when a label role uses syntax it cannot read', () => {
|
|
619
|
+
injectedStyle = document.createElement( 'style' );
|
|
620
|
+
injectedStyle.textContent =
|
|
621
|
+
'.modern-heatmap { --a8c-charts-color-label-inverse: rgb(255 255 255); }';
|
|
622
|
+
document.head.appendChild( injectedStyle );
|
|
623
|
+
|
|
624
|
+
renderScale( 'modern-heatmap' );
|
|
625
|
+
|
|
626
|
+
expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--white' );
|
|
627
|
+
} );
|
|
628
|
+
|
|
629
|
+
test( 'measures a translucent label role as it paints over each cell', () => {
|
|
630
|
+
injectedStyle = document.createElement( 'style' );
|
|
631
|
+
injectedStyle.textContent =
|
|
632
|
+
'.translucent-heatmap { --a8c-charts-color-label: rgba(30, 30, 30, 0.3); }';
|
|
633
|
+
document.head.appendChild( injectedStyle );
|
|
634
|
+
|
|
635
|
+
renderScale( 'translucent-heatmap' );
|
|
636
|
+
|
|
637
|
+
expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
|
|
638
|
+
expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
|
|
639
|
+
} );
|
|
640
|
+
|
|
641
|
+
test( 'leaves a summary value, which sits on no fill, on the default role', () => {
|
|
642
|
+
injectedStyle = document.createElement( 'style' );
|
|
643
|
+
injectedStyle.textContent =
|
|
644
|
+
'.swapped-heatmap { --a8c-charts-color-label: #f0f0f0; --a8c-charts-color-label-inverse: #1e1e1e; }';
|
|
645
|
+
document.head.appendChild( injectedStyle );
|
|
646
|
+
|
|
647
|
+
render(
|
|
648
|
+
<GlobalChartsProvider>
|
|
649
|
+
<HeatmapChart
|
|
650
|
+
width={ 500 }
|
|
651
|
+
height={ 300 }
|
|
652
|
+
className="swapped-heatmap"
|
|
653
|
+
primaryColor="#3858e9"
|
|
654
|
+
data={ [ ...scale, { label: 'Total', summary: true, data: [ { value: 7 } ] } ] }
|
|
655
|
+
/>
|
|
656
|
+
</GlobalChartsProvider>
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
expect( screen.getByText( '7' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
|
|
660
|
+
expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
|
|
661
|
+
} );
|
|
662
|
+
|
|
663
|
+
test( "keeps one color when both label roles are set to it on the chart's own class", () => {
|
|
664
|
+
injectedStyle = document.createElement( 'style' );
|
|
665
|
+
injectedStyle.textContent =
|
|
666
|
+
'.pinned-heatmap { --a8c-charts-color-label: #767676; --a8c-charts-color-label-inverse: #767676; }';
|
|
667
|
+
document.head.appendChild( injectedStyle );
|
|
668
|
+
|
|
669
|
+
renderScale( 'pinned-heatmap' );
|
|
670
|
+
|
|
671
|
+
[ '1', '30', '100' ].forEach( value =>
|
|
672
|
+
expect( screen.getByText( value ) ).toHaveClass( 'heatmap-chart__cell-value', {
|
|
673
|
+
exact: true,
|
|
674
|
+
} )
|
|
675
|
+
);
|
|
676
|
+
} );
|
|
677
|
+
} );
|
|
678
|
+
|
|
483
679
|
describe( 'HeatmapChart keyboard tooltip', () => {
|
|
484
680
|
test( 'opens on the selected cell without row labels', async () => {
|
|
485
681
|
renderChart( { withTooltips: true } );
|
|
@@ -0,0 +1,78 @@
|
|
|
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 } from '../../../utils/color-utils';
|
|
4
|
+
import { getHeatmapScale, HEATMAP_HIGH_CONTRAST } from '../private/heatmap-scale';
|
|
5
|
+
|
|
6
|
+
// The wp-admin scheme colors, then the pre-7.0 ones, which include a sunrise below 3:1 on white.
|
|
7
|
+
const PRIMARIES = [
|
|
8
|
+
'#007cba',
|
|
9
|
+
'#3858e9',
|
|
10
|
+
'#437aa8',
|
|
11
|
+
'#916745',
|
|
12
|
+
'#646c3e',
|
|
13
|
+
'#cf4339',
|
|
14
|
+
'#567958',
|
|
15
|
+
'#ad631e',
|
|
16
|
+
'#0085ba',
|
|
17
|
+
'#dd823b',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe.each( [
|
|
21
|
+
[ 'light', '#ffffff', '#f0f0f0' ],
|
|
22
|
+
[ 'dark', '#242424', '#272727' ],
|
|
23
|
+
] )( 'getHeatmapScale on a %s background', ( _mode, background, emptyCell ) => {
|
|
24
|
+
it.each( PRIMARIES )(
|
|
25
|
+
'keeps the lowest step of %s at 3:1 against the background and empty cell, no further',
|
|
26
|
+
primary => {
|
|
27
|
+
const { low } = getHeatmapScale( primary, background, emptyCell );
|
|
28
|
+
const contrasts = [ contrastRatio( low, background ), contrastRatio( low, emptyCell ) ];
|
|
29
|
+
|
|
30
|
+
expect( Math.min( ...contrasts ) ).toBeGreaterThanOrEqual( MIN_BACKGROUND_CONTRAST );
|
|
31
|
+
expect( Math.min( ...contrasts ) ).toBeLessThan( MIN_BACKGROUND_CONTRAST + 0.1 );
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
it.each( PRIMARIES )( 'runs the scale of %s out to 9:1', primary => {
|
|
36
|
+
const { high } = getHeatmapScale( primary, background );
|
|
37
|
+
|
|
38
|
+
expect( contrastRatio( high, background ) ).toBeGreaterThanOrEqual( HEATMAP_HIGH_CONTRAST );
|
|
39
|
+
} );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
describe( 'getHeatmapScale', () => {
|
|
43
|
+
it( 'keeps a primary already past 9:1 as the high end', () => {
|
|
44
|
+
expect( getHeatmapScale( '#1d2327', '#ffffff' ).high ).toBe( '#1d2327' );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it.each( [
|
|
48
|
+
[ '#ffffff', '#808080' ],
|
|
49
|
+
[ '#ffd000', '#767676' ],
|
|
50
|
+
[ '#3858e9', '#808080' ],
|
|
51
|
+
[ '#1e1e1e', '#999999' ],
|
|
52
|
+
] )( 'keeps every step of %s at 3:1 on a mid-gray %s', ( primary, background ) => {
|
|
53
|
+
const { low, high } = getHeatmapScale( primary, background );
|
|
54
|
+
|
|
55
|
+
for ( let step = 0; step <= 20; step++ ) {
|
|
56
|
+
expect(
|
|
57
|
+
contrastRatio( mixHexColors( low, high, step / 20 ), background )
|
|
58
|
+
).toBeGreaterThanOrEqual( MIN_BACKGROUND_CONTRAST );
|
|
59
|
+
}
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it.each( [
|
|
63
|
+
[ 'no step clears it', '#ffffff', '#555555' ],
|
|
64
|
+
[ 'clearing it would pass the high end', '#ffffff', '#767676' ],
|
|
65
|
+
[ 'clearing it would pass the high end', '#1e1e1e', '#6b6b6b' ],
|
|
66
|
+
] )(
|
|
67
|
+
'measures against the background alone when %s (%s, %s)',
|
|
68
|
+
( _case, background, emptyCell ) => {
|
|
69
|
+
expect( getHeatmapScale( '#3858e9', background, emptyCell ) ).toEqual(
|
|
70
|
+
getHeatmapScale( '#3858e9', background )
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
it( 'ends on black or white when the background leaves no room for 9:1', () => {
|
|
76
|
+
expect( getHeatmapScale( '#3858e9', '#808080' ).high ).toBe( '#000000' );
|
|
77
|
+
} );
|
|
78
|
+
} );
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getValueExtent, getNormalizedValue } from '../private/use-heatmap-colors';
|
|
1
|
+
import { getValueExtent, getNormalizedValue, isEmptyValue } from '../private/use-heatmap-colors';
|
|
2
2
|
import type { HeatmapColumn } from '../types';
|
|
3
3
|
|
|
4
4
|
const data: HeatmapColumn[] = [
|
|
@@ -7,8 +7,12 @@ const data: HeatmapColumn[] = [
|
|
|
7
7
|
];
|
|
8
8
|
|
|
9
9
|
describe( 'getValueExtent', () => {
|
|
10
|
-
test( 'returns [min, max] ignoring null/NaN', () => {
|
|
11
|
-
expect( getValueExtent( data ) ).toEqual( [
|
|
10
|
+
test( 'returns [min, max] ignoring null/NaN and empty zeros', () => {
|
|
11
|
+
expect( getValueExtent( data ) ).toEqual( [ 5, 20 ] );
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
test( 'keeps a zero in the extent when the data has negatives', () => {
|
|
15
|
+
expect( getValueExtent( [ { data: [ { value: -4 }, { value: 0 } ] } ] ) ).toEqual( [ -4, 0 ] );
|
|
12
16
|
} );
|
|
13
17
|
|
|
14
18
|
test( 'leaves summary columns out of the extent', () => {
|
|
@@ -16,11 +20,12 @@ describe( 'getValueExtent', () => {
|
|
|
16
20
|
...data,
|
|
17
21
|
{ label: 'Total', summary: true, data: [ { value: 500 }, { value: 900 }, { value: null } ] },
|
|
18
22
|
];
|
|
19
|
-
expect( getValueExtent( withTotals ) ).toEqual( [
|
|
23
|
+
expect( getValueExtent( withTotals ) ).toEqual( [ 5, 20 ] );
|
|
20
24
|
} );
|
|
21
25
|
|
|
22
26
|
test( 'returns [0, 0] for all-empty data', () => {
|
|
23
27
|
expect( getValueExtent( [ { data: [ { value: null } ] } ] ) ).toEqual( [ 0, 0 ] );
|
|
28
|
+
expect( getValueExtent( [ { data: [ { value: 0 }, { value: 0 } ] } ] ) ).toEqual( [ 0, 0 ] );
|
|
24
29
|
} );
|
|
25
30
|
} );
|
|
26
31
|
|
|
@@ -39,8 +44,15 @@ describe( 'getNormalizedValue', () => {
|
|
|
39
44
|
test( 'returns 1 when min === max', () => {
|
|
40
45
|
expect( getNormalizedValue( 7, [ 7, 7 ] ) ).toBe( 1 );
|
|
41
46
|
} );
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
describe( 'isEmptyValue', () => {
|
|
50
|
+
test( 'paints a zero as empty in data with no negatives', () => {
|
|
51
|
+
expect( isEmptyValue( 0, [ 0, 20 ] ) ).toBe( true );
|
|
52
|
+
expect( isEmptyValue( 5, [ 0, 20 ] ) ).toBe( false );
|
|
53
|
+
} );
|
|
42
54
|
|
|
43
|
-
test( '
|
|
44
|
-
expect(
|
|
55
|
+
test( 'keeps a zero on the scale when the data has negatives', () => {
|
|
56
|
+
expect( isEmptyValue( 0, [ -4, 6 ] ) ).toBe( false );
|
|
45
57
|
} );
|
|
46
58
|
} );
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { BaseChartProps } from '../../types';
|
|
2
2
|
import type { CSSProperties, ReactNode } from 'react';
|
|
3
3
|
|
|
4
|
-
/** A single heatmap cell. `value: null` marks an empty cell. */
|
|
4
|
+
/** A single heatmap cell. `value: null` marks an empty cell, as does a zero when no value is negative. */
|
|
5
5
|
export type HeatmapCell = {
|
|
6
6
|
/** Per-cell label used in the tooltip / accessible name. */
|
|
7
7
|
label?: string;
|
|
@@ -102,18 +102,15 @@ export interface HeatmapChartProps extends Omit<
|
|
|
102
102
|
/** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
|
|
103
103
|
minCellHeight?: number;
|
|
104
104
|
/**
|
|
105
|
-
* Color the cell scale
|
|
106
|
-
* first series palette slot,
|
|
105
|
+
* Color the cell scale is built from, deepened at the highest value toward 9:1
|
|
106
|
+
* against the background. Defaults to the first series palette slot,
|
|
107
|
+
* `--a8c-charts-color-series-1`.
|
|
107
108
|
*/
|
|
108
109
|
primaryColor?: string;
|
|
109
110
|
renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
|
|
110
|
-
/**
|
|
111
|
-
* The tooltip box: `light` is the plain white box, `dark` the package's
|
|
112
|
-
* tooltip surface, themed through `--a8c-charts-color-tooltip-surface`.
|
|
113
|
-
* Default `light`.
|
|
114
|
-
*/
|
|
111
|
+
/** @deprecated Every tooltip draws on the package tooltip surface; this prop has no effect. */
|
|
115
112
|
tooltipVariant?: 'light' | 'dark';
|
|
116
|
-
/** Inline styles merged onto the tooltip box, over the
|
|
113
|
+
/** Inline styles merged onto the tooltip box, over the surface's own. */
|
|
117
114
|
tooltipStyle?: CSSProperties;
|
|
118
115
|
children?: ReactNode;
|
|
119
116
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/* istanbul ignore file */ // Compile-only: tsgo checks these assignments; nothing imports or runs them.
|
|
2
|
+
import type { LineChartProps } from './types';
|
|
3
|
+
|
|
4
|
+
type Crosshairs = NonNullable< LineChartProps[ 'withTooltipCrosshairs' ] >;
|
|
5
|
+
|
|
6
|
+
export const paintCrosshairs: Crosshairs = {
|
|
7
|
+
verticalStyle: { stroke: 'purple', strokeWidth: 2 },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const coordinateCrosshairs: Crosshairs = {
|
|
11
|
+
// @ts-expect-error Crosshair coordinates are computed from the active datum.
|
|
12
|
+
verticalStyle: { x1: 12 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const transformedCrosshairs: Crosshairs = {
|
|
16
|
+
// @ts-expect-error Crosshair transforms are not paint attributes.
|
|
17
|
+
verticalStyle: { transform: 'translate(100 0)' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const transformedStyles: Crosshairs = {
|
|
21
|
+
// @ts-expect-error Inline crosshair styles accept only paint properties.
|
|
22
|
+
horizontalStyle: { style: { transform: 'translateX(100px)' } },
|
|
23
|
+
};
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
overflow: visible;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
&__tooltip,
|
|
21
20
|
&__annotation-label-popover {
|
|
22
21
|
background: var(--a8c-charts-color-surface);
|
|
23
22
|
color: var(--a8c-charts-color-label);
|
|
@@ -29,10 +28,6 @@
|
|
|
29
28
|
padding-bottom: var(--wpds-dimension-padding-md);
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
&__tooltip-row {
|
|
33
|
-
padding: var(--wpds-dimension-padding-xs) 0;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
31
|
&__tooltip-label {
|
|
37
32
|
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
38
33
|
padding-right: var(--wpds-dimension-padding-lg);
|