@automattic/charts 4.7.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 +16 -0
- package/TOKENS.md +6 -3
- package/dist/index.cjs +212 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +10 -9
- package/dist/index.d.cts +60 -20
- package/dist/index.d.ts +60 -20
- package/dist/index.js +212 -97
- 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.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 +6 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +39 -29
- 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 +99 -7
- 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 +4 -3
- package/src/charts/line-chart/line-chart.tsx +2 -2
- package/src/charts/pie-chart/pie-chart.tsx +4 -4
- 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/test/label-text-color.test.ts +6 -8
- 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 +282 -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/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
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
useCallback,
|
|
13
13
|
} from 'react';
|
|
14
14
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
15
|
-
import {
|
|
15
|
+
import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
|
|
16
16
|
import {
|
|
17
17
|
useXYChartTheme,
|
|
18
18
|
useChartDataTransform,
|
|
@@ -509,7 +509,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
|
|
|
509
509
|
|
|
510
510
|
{ withTooltips && (
|
|
511
511
|
<>
|
|
512
|
-
<
|
|
512
|
+
<XYChartTooltip
|
|
513
513
|
detectBounds
|
|
514
514
|
snapTooltipToDatumX
|
|
515
515
|
// Stacked mode: yAccessor returns raw value, not stacked y — snapping mispositions.
|
|
@@ -817,7 +817,7 @@ describe( 'AreaChart', () => {
|
|
|
817
817
|
} );
|
|
818
818
|
|
|
819
819
|
describe( 'Hover glyphs', () => {
|
|
820
|
-
// Trigger the
|
|
820
|
+
// Trigger the XYChartTooltip's keyboard nav so a tooltip is opened
|
|
821
821
|
// against a known datum index, which is the only reliable way to
|
|
822
822
|
// surface the visx TooltipContext state in a jsdom environment.
|
|
823
823
|
const focusFirstDatum = async () => {
|
|
@@ -19,7 +19,7 @@ type CapturedRenderTooltip = ( params: RenderTooltipParams< DataPointDate > ) =>
|
|
|
19
19
|
let capturedRenderTooltip: CapturedRenderTooltip | undefined;
|
|
20
20
|
jest.mock( '../../../components/tooltip', () => ( {
|
|
21
21
|
...jest.requireActual( '../../../components/tooltip' ),
|
|
22
|
-
|
|
22
|
+
XYChartTooltip: ( props: { renderTooltip?: CapturedRenderTooltip } ) => {
|
|
23
23
|
capturedRenderTooltip = props.renderTooltip;
|
|
24
24
|
return null;
|
|
25
25
|
},
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
overflow: visible;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
// The CSS property, not the attribute: an SVG geometry attribute does
|
|
8
|
+
// not substitute var().
|
|
9
|
+
:global(.visx-bar),
|
|
10
|
+
:global(.bar-chart__comparison-bar) {
|
|
11
|
+
rx: var(--a8c-charts-border-radius-bar-chart);
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
// Keep the collapsed state in `from` and never on the element: a dropped
|
|
8
15
|
// animation then fails visible. `both`, not `forwards`, for backwards fill.
|
|
9
16
|
&--animated rect {
|
|
@@ -4,7 +4,7 @@ import { __, sprintf } from '@wordpress/i18n';
|
|
|
4
4
|
import clsx from 'clsx';
|
|
5
5
|
import { useCallback, useContext, useState, useRef, useMemo } from 'react';
|
|
6
6
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
7
|
-
import {
|
|
7
|
+
import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
|
|
8
8
|
import {
|
|
9
9
|
useXYChartTheme,
|
|
10
10
|
useChartDataTransform,
|
|
@@ -242,7 +242,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
242
242
|
);
|
|
243
243
|
|
|
244
244
|
// The keyboard-navigation index space and the highlight CSS both stride over primary
|
|
245
|
-
// bars only;
|
|
245
|
+
// bars only; `XYChartTooltip` must use the same list, or its datum diverges from
|
|
246
246
|
// the highlighted bar once a comparison series shifts the indices.
|
|
247
247
|
const primarySeries = useMemo(
|
|
248
248
|
() => primaryEntries.map( ( { series } ) => series ),
|
|
@@ -474,7 +474,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
474
474
|
// so a patterned shadow gets the same outline as its primary bar.
|
|
475
475
|
return `
|
|
476
476
|
.visx-bar[fill="url(#${ patternId })"],
|
|
477
|
-
.bar-chart__comparison-
|
|
477
|
+
.bar-chart__comparison-bar[fill="url(#${ patternId })"] {
|
|
478
478
|
stroke: ${ color };
|
|
479
479
|
stroke-width: 1;
|
|
480
480
|
}
|
|
@@ -767,23 +767,29 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
767
767
|
<WholeNumberTicks { ...wholeNumberTicksProps }>
|
|
768
768
|
{ valueTicks => (
|
|
769
769
|
<>
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
770
|
+
{ chartOptions.axis.x.display !== false && (
|
|
771
|
+
<Axis
|
|
772
|
+
{ ...chartOptions.axis.x }
|
|
773
|
+
{ ...( horizontal && valueTicks
|
|
774
|
+
? { tickValues: valueTicks }
|
|
775
|
+
: {} ) }
|
|
776
|
+
/>
|
|
777
|
+
) }
|
|
778
|
+
{ chartOptions.axis.y.display !== false && (
|
|
779
|
+
<Axis
|
|
780
|
+
{ ...chartOptions.axis.y }
|
|
781
|
+
{ ...( ! horizontal && valueTicks
|
|
782
|
+
? { tickValues: valueTicks }
|
|
783
|
+
: {} ) }
|
|
784
|
+
/>
|
|
785
|
+
) }
|
|
780
786
|
</>
|
|
781
787
|
) }
|
|
782
788
|
</WholeNumberTicks>
|
|
783
789
|
) }
|
|
784
790
|
|
|
785
791
|
{ withTooltips && (
|
|
786
|
-
<
|
|
792
|
+
<XYChartTooltip
|
|
787
793
|
tooltipPlacement={ tooltipPlacement }
|
|
788
794
|
tooltipAnchorTop={ tooltipAnchorTop }
|
|
789
795
|
style={ tooltipStyle }
|
|
@@ -806,6 +806,31 @@ describe( 'BarChart', () => {
|
|
|
806
806
|
);
|
|
807
807
|
} );
|
|
808
808
|
|
|
809
|
+
describe( 'Axis display', () => {
|
|
810
|
+
test.each( [
|
|
811
|
+
[ 'vertical', 'x', 'y' ],
|
|
812
|
+
[ 'vertical', 'y', 'x' ],
|
|
813
|
+
[ 'horizontal', 'x', 'y' ],
|
|
814
|
+
[ 'horizontal', 'y', 'x' ],
|
|
815
|
+
] )( 'skips a hidden axis (%s, hidden %s)', ( orientation, hidden, shown ) => {
|
|
816
|
+
renderWithTheme( {
|
|
817
|
+
orientation,
|
|
818
|
+
options: {
|
|
819
|
+
axis: {
|
|
820
|
+
[ hidden ]: { display: false, axisClassName: 'test-hidden-axis' },
|
|
821
|
+
[ shown ]: { axisClassName: 'test-shown-axis' },
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
} );
|
|
825
|
+
const chart = screen.getByRole( 'grid' );
|
|
826
|
+
|
|
827
|
+
// eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
|
|
828
|
+
expect( chart.querySelector( '.test-hidden-axis' ) ).toBeNull();
|
|
829
|
+
// eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
|
|
830
|
+
expect( chart.querySelector( '.test-shown-axis' ) ).not.toBeNull();
|
|
831
|
+
} );
|
|
832
|
+
} );
|
|
833
|
+
|
|
809
834
|
describe( 'Grid Visibility', () => {
|
|
810
835
|
test( 'renders with different grid visibility options', () => {
|
|
811
836
|
const { rerender } = renderWithTheme( { gridVisibility: 'x' } );
|
|
@@ -427,7 +427,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
|
|
|
427
427
|
className={ standaloneScopeClass }
|
|
428
428
|
style={ tooltipStyle }
|
|
429
429
|
>
|
|
430
|
-
{ tooltipContent }
|
|
430
|
+
<div role="tooltip">{ tooltipContent }</div>
|
|
431
431
|
</BoundedTooltip>
|
|
432
432
|
);
|
|
433
433
|
} )() }
|
|
@@ -430,6 +430,15 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
430
430
|
} );
|
|
431
431
|
} );
|
|
432
432
|
|
|
433
|
+
it( 'exposes the tooltip content with role="tooltip"', async () => {
|
|
434
|
+
const user = userEvent.setup();
|
|
435
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
436
|
+
|
|
437
|
+
await user.click( screen.getByRole( 'button', { name: /cart/i } ) );
|
|
438
|
+
|
|
439
|
+
expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Cart' );
|
|
440
|
+
} );
|
|
441
|
+
|
|
433
442
|
it( 'merges tooltipStyle onto the tooltip box', async () => {
|
|
434
443
|
const user = userEvent.setup();
|
|
435
444
|
renderWithoutTheme(
|
|
@@ -3,6 +3,10 @@
|
|
|
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
|
+
// JS sets the ends (getHeatmapScale): 3:1 against background and empty cell at
|
|
7
|
+
// the low end. Unresolved, the floor is 15% of the primary.
|
|
8
|
+
$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))));
|
|
9
|
+
|
|
6
10
|
.heatmap-chart {
|
|
7
11
|
position: relative;
|
|
8
12
|
// Keeps the tooltip's z-index inside the chart, as `.xy-plot` does.
|
|
@@ -165,10 +169,8 @@ $focus-ring-width: var(--wpds-border-width-focus);
|
|
|
165
169
|
margin-inline-end: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
|
|
166
170
|
}
|
|
167
171
|
|
|
168
|
-
// Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
|
|
169
|
-
// an empty cell.
|
|
170
172
|
.heatmap-chart__cell--filled {
|
|
171
|
-
background:
|
|
173
|
+
background: $heatmap-fill;
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
.heatmap-chart__cell--selected {
|
|
@@ -204,7 +206,7 @@ $focus-ring-width: var(--wpds-border-width-focus);
|
|
|
204
206
|
width: var(--wpds-dimension-size-3xs);
|
|
205
207
|
height: var(--wpds-dimension-size-3xs);
|
|
206
208
|
border-radius: var(--a8c-charts-border-radius-cell);
|
|
207
|
-
background:
|
|
209
|
+
background: $heatmap-fill;
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
// One band: the steps butt against each other and only the band's ends round.
|
|
@@ -13,12 +13,16 @@ import {
|
|
|
13
13
|
useGlobalChartsContext,
|
|
14
14
|
GlobalChartsContext,
|
|
15
15
|
} from '../../providers';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
BACKGROUND_FALLBACK,
|
|
18
|
+
CATALOG_POINTERS,
|
|
19
|
+
} from '../../providers/chart-context/private/catalog-pointers';
|
|
17
20
|
import { blendRgb, hexToRgb } from '../../providers/chart-context/private/perceptual-color';
|
|
21
|
+
import { resolveOpaqueHex } from '../../providers/chart-context/private/resolve-opaque-hex';
|
|
18
22
|
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
19
23
|
import { attachSubComponents } from '../../utils';
|
|
20
24
|
import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
|
|
21
|
-
import { createCssVariableResolver
|
|
25
|
+
import { createCssVariableResolver } from '../../utils/resolve-css-var';
|
|
22
26
|
import { Center } from '../private/center';
|
|
23
27
|
import { useChartChildren } from '../private/chart-composition';
|
|
24
28
|
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
@@ -27,10 +31,12 @@ import { pickLabelTextColorForFill, resolveLabelRoles } from '../private/label-t
|
|
|
27
31
|
import { withResponsive } from '../private/with-responsive';
|
|
28
32
|
import styles from './heatmap-chart.module.scss';
|
|
29
33
|
import {
|
|
34
|
+
getHeatmapScale,
|
|
30
35
|
getValueExtent,
|
|
31
36
|
getNormalizedValue,
|
|
32
37
|
HeatmapContext,
|
|
33
38
|
HeatmapLegend,
|
|
39
|
+
isEmptyValue,
|
|
34
40
|
isPresent,
|
|
35
41
|
resolveColumnGroups,
|
|
36
42
|
} from './private';
|
|
@@ -48,10 +54,6 @@ import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
|
|
|
48
54
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
49
55
|
import type { CSSProperties, FC } from 'react';
|
|
50
56
|
|
|
51
|
-
// Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
|
|
52
|
-
// the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
|
|
53
|
-
const CELL_MIX_FLOOR = 0.15;
|
|
54
|
-
|
|
55
57
|
// `label` is the stylesheet's default, so it needs no modifier.
|
|
56
58
|
const CELL_VALUE_MODIFIER: Record< LabelTextColor, string | undefined > = {
|
|
57
59
|
label: undefined,
|
|
@@ -125,29 +127,37 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
125
127
|
overrideColor: primaryColor,
|
|
126
128
|
} );
|
|
127
129
|
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
);
|
|
130
|
+
// Both read at the scope element, so an override on the chart's own class reaches neither the
|
|
131
|
+
// fill scale nor the text color (CHARTS-255). See-through counts as white, as for the palette.
|
|
132
|
+
const resolveAtScope = createCssVariableResolver( scopeElement );
|
|
133
|
+
const chartBackgroundHex =
|
|
134
|
+
resolveOpaqueHex( CATALOG_POINTERS.background, resolveAtScope ) ?? BACKGROUND_FALLBACK;
|
|
135
|
+
const emptyCellHex = resolveOpaqueHex( CATALOG_POINTERS.track, resolveAtScope );
|
|
135
136
|
|
|
136
|
-
//
|
|
137
|
-
// If either color cannot resolve to hex, keep the stylesheet's default role.
|
|
137
|
+
// If the primary cannot resolve to hex, the stylesheet falls back to its own blend.
|
|
138
138
|
const primaryHex = normalizeColorToHex( primaryColorHex );
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
const scale = useMemo(
|
|
140
|
+
() =>
|
|
141
|
+
isValidHexColor( primaryHex )
|
|
142
|
+
? getHeatmapScale( primaryHex, chartBackgroundHex, emptyCellHex )
|
|
143
|
+
: null,
|
|
144
|
+
[ primaryHex, chartBackgroundHex, emptyCellHex ]
|
|
145
|
+
);
|
|
146
|
+
const fillVars = useMemo(
|
|
147
|
+
() => ( {
|
|
148
|
+
'--a8c-charts-color-heatmap-primary': primaryColorHex,
|
|
149
|
+
...( scale && {
|
|
150
|
+
'--a8c-charts-color-heatmap-low': scale.low,
|
|
151
|
+
'--a8c-charts-color-heatmap-high': scale.high,
|
|
152
|
+
} ),
|
|
153
|
+
} ),
|
|
154
|
+
[ primaryColorHex, scale ]
|
|
155
|
+
);
|
|
156
|
+
// Choose text color from the fill the cell paints, mirroring .heatmap-chart__cell--filled.
|
|
143
157
|
const cellTextColor = ( intensity: number ): LabelTextColor =>
|
|
144
|
-
|
|
158
|
+
scale
|
|
145
159
|
? pickLabelTextColorForFill(
|
|
146
|
-
blendRgb(
|
|
147
|
-
cellMix.primary,
|
|
148
|
-
cellMix.background,
|
|
149
|
-
CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity
|
|
150
|
-
),
|
|
160
|
+
blendRgb( hexToRgb( scale.high ), hexToRgb( scale.low ), intensity ),
|
|
151
161
|
labelRoles,
|
|
152
162
|
'label'
|
|
153
163
|
)
|
|
@@ -165,8 +175,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
165
175
|
|
|
166
176
|
const extent = useMemo( () => getValueExtent( data ), [ data ] );
|
|
167
177
|
const heatmapContext = useMemo< HeatmapContextValue >(
|
|
168
|
-
() => ( { extent,
|
|
169
|
-
[ extent,
|
|
178
|
+
() => ( { extent, fillVars } ),
|
|
179
|
+
[ extent, fillVars ]
|
|
170
180
|
);
|
|
171
181
|
|
|
172
182
|
const columns = data.length;
|
|
@@ -390,7 +400,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
390
400
|
)
|
|
391
401
|
.join( ' ' );
|
|
392
402
|
const gridStyle: Record< string, string | number > = {
|
|
393
|
-
|
|
403
|
+
...fillVars,
|
|
394
404
|
gridTemplateColumns: `auto ${ columnTracks }`,
|
|
395
405
|
gridTemplateRows: `${ hasColumnLabels ? 'auto ' : '' }repeat(${ rows }, ${ rowTrack })${
|
|
396
406
|
hasGroups ? ' auto' : ''
|
|
@@ -542,7 +552,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
542
552
|
const value = cell?.value ?? null;
|
|
543
553
|
const present = isPresent( value );
|
|
544
554
|
// A summary cell is on another scale, so it takes no fill.
|
|
545
|
-
const filled = present && ! column.summary;
|
|
555
|
+
const filled = present && ! column.summary && ! isEmptyValue( value, extent );
|
|
546
556
|
const normalized = filled ? getNormalizedValue( value, extent ) : 0;
|
|
547
557
|
const info = buildTooltipData( columnIndex, rowIndex );
|
|
548
558
|
const accessibleLabel = `${ cellName( info ) }: ${
|
|
@@ -2,8 +2,8 @@ import { createContext } from 'react';
|
|
|
2
2
|
|
|
3
3
|
export type HeatmapContextValue = {
|
|
4
4
|
extent: [ number, number ];
|
|
5
|
-
/** The
|
|
6
|
-
|
|
5
|
+
/** The custom properties the fill reads, set on the grid and on each legend swatch. */
|
|
6
|
+
fillVars: Record< string, string >;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
/** Shared by the chart and legend without importing back from `heatmap-chart.tsx`. */
|
|
@@ -29,7 +29,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
|
|
|
29
29
|
if ( ! context ) {
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
|
-
const {
|
|
32
|
+
const { fillVars } = context;
|
|
33
33
|
const labelStyle = legend.labelStyles;
|
|
34
34
|
|
|
35
35
|
return (
|
|
@@ -57,7 +57,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
|
|
|
57
57
|
className={ styles[ 'heatmap-chart__legend-swatch' ] }
|
|
58
58
|
style={
|
|
59
59
|
{
|
|
60
|
-
|
|
60
|
+
...fillVars,
|
|
61
61
|
'--a8c-charts-heatmap-cell-intensity': intensity,
|
|
62
62
|
} as CSSProperties
|
|
63
63
|
}
|
|
@@ -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
|
};
|