@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/TOKENS.md +6 -3
  3. package/dist/index.cjs +212 -95
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +10 -9
  6. package/dist/index.d.cts +60 -20
  7. package/dist/index.d.ts +60 -20
  8. package/dist/index.js +212 -97
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  15. package/src/charts/bar-chart/bar-chart.tsx +20 -14
  16. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  19. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +6 -4
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +39 -29
  22. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  23. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
  24. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  25. package/src/charts/heatmap-chart/private/index.ts +2 -1
  26. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  27. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +99 -7
  28. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  29. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  30. package/src/charts/heatmap-chart/types.ts +4 -3
  31. package/src/charts/line-chart/line-chart.tsx +2 -2
  32. package/src/charts/pie-chart/pie-chart.tsx +4 -4
  33. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
  34. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
  35. package/src/charts/private/test/label-text-color.test.ts +6 -8
  36. package/src/components/tooltip/base-tooltip.tsx +31 -32
  37. package/src/components/tooltip/index.ts +3 -1
  38. package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
  39. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  40. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  41. package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
  42. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  43. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  44. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +282 -365
  45. package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
  46. package/src/components/tooltip/tooltip-box.tsx +43 -0
  47. package/src/components/tooltip/types.ts +7 -1
  48. package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
  49. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  50. package/src/hooks/use-chart-margin.tsx +21 -18
  51. package/src/index.ts +8 -3
  52. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  53. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
  54. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  55. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  56. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  57. package/src/styles/chart-scope.scss +1 -0
  58. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  59. package/src/utils/test/date-formatting.test.ts +0 -10
  60. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  61. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.7.0",
3
+ "version": "4.8.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -12,7 +12,7 @@ import {
12
12
  useCallback,
13
13
  } from 'react';
14
14
  import { Legend, useChartLegendItems } from '../../components/legend';
15
- import { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip';
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
- <AccessibleTooltip
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 AccessibleTooltip's keyboard nav so a tooltip is opened
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
- AccessibleTooltip: ( props: { renderTooltip?: CapturedRenderTooltip } ) => {
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 { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip';
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; the accessible tooltip must use the same list, or its datum diverges from
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-bars rect[fill="url(#${ patternId })"] {
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
- <Axis
771
- { ...chartOptions.axis.x }
772
- { ...( horizontal && valueTicks ? { tickValues: valueTicks } : {} ) }
773
- />
774
- <Axis
775
- { ...chartOptions.axis.y }
776
- { ...( ! horizontal && valueTicks
777
- ? { tickValues: valueTicks }
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
- <AccessibleTooltip
792
+ <XYChartTooltip
787
793
  tooltipPlacement={ tooltipPlacement }
788
794
  tooltipAnchorTop={ tooltipAnchorTop }
789
795
  style={ tooltipStyle }
@@ -127,6 +127,7 @@ export const ComparisonBars: FC< {
127
127
  rects.push(
128
128
  <rect
129
129
  key={ `${ index }-${ i }` }
130
+ className="bar-chart__comparison-bar"
130
131
  data-testid={ `bar-chart-comparison-${ index }-${ i }` }
131
132
  x={ rect.x }
132
133
  y={ rect.y }
@@ -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: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
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: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
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 { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
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, resolveCssVariable } from '../../utils/resolve-css-var';
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
- // The cell blend substitutes this role at the cell; this read happens at the scope
129
- // element, so an override on the chart's own class makes the two disagree. CHARTS-255.
130
- const chartBackgroundHex = normalizeColorToHex(
131
- CATALOG_POINTERS.background,
132
- scopeElement,
133
- resolveCssVariable
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
- // Choose text color from the blended fill, not the raw value.
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 cellMix =
140
- isValidHexColor( primaryHex ) && isValidHexColor( chartBackgroundHex )
141
- ? { primary: hexToRgb( primaryHex ), background: hexToRgb( chartBackgroundHex ) }
142
- : null;
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
- cellMix
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, primaryColorHex } ),
169
- [ extent, primaryColorHex ]
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
- '--a8c-charts-color-heatmap-primary': primaryColorHex,
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 resolved primary color (full intensity); the legend mixes toward it in CSS. */
6
- primaryColorHex: string;
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 { primaryColorHex } = context;
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
- '--a8c-charts-color-heatmap-primary': primaryColorHex,
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. Summary
8
- * columns stay out: a roll-up on the scale would flatten every real cell.
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 — every cell is equally the "highest" — except an all-zero
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 max === 0 ? 0 : 1;
68
+ return 1;
51
69
  }
52
70
  return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) );
53
71
  };