@automattic/charts 4.4.0 → 4.5.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 (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +521 -203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +10 -3
  7. package/dist/index.d.ts +10 -3
  8. package/dist/index.js +521 -203
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/bar-chart/bar-chart.tsx +22 -10
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
  13. package/src/charts/geo-chart/geo-chart.tsx +4 -0
  14. package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
  15. package/src/charts/geo-chart/types.ts +5 -0
  16. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  17. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  18. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  19. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  20. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  21. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  22. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  23. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  24. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  25. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  26. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  27. package/src/providers/chart-context/private/series-palette.ts +1 -1
  28. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  29. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  30. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  31. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  32. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  33. package/src/utils/color-utils.ts +5 -3
  34. package/src/utils/test/date-formatting.test.ts +1 -1
  35. package/src/providers/chart-context/private/get-chart-color.ts +0 -223
@@ -1,6 +1,6 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { act, render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { useRef, useState } from 'react';
3
+ import { useCallback, useRef, useState } from 'react';
4
4
  import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
5
5
  import { GlobalChartsProvider } from '../../../providers';
6
6
  import { ChartScopeContext } from '../../../providers/chart-scope';
@@ -128,11 +128,17 @@ describe( 'AccessibleTooltip', () => {
128
128
  } );
129
129
 
130
130
  // Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
131
- const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
131
+ const NavigationHarness = ( {
132
+ totalPoints,
133
+ trackBlur = false,
134
+ }: {
135
+ totalPoints: number;
136
+ trackBlur?: boolean;
137
+ } ) => {
132
138
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
133
139
  const [ isNavigating, setIsNavigating ] = useState( false );
134
140
  const chartRef = useRef< HTMLDivElement >( null );
135
- const { onChartKeyDown } = useKeyboardNavigation( {
141
+ const { onChartKeyDown, onChartBlur, onChartPointerMove } = useKeyboardNavigation( {
136
142
  selectedIndex,
137
143
  setSelectedIndex,
138
144
  isNavigating,
@@ -140,14 +146,24 @@ const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
140
146
  chartRef,
141
147
  totalPoints,
142
148
  } );
149
+ const moveToFour = useCallback( () => onChartPointerMove( 4 ), [ onChartPointerMove ] );
150
+ const moveToOne = useCallback( () => onChartPointerMove( 1 ), [ onChartPointerMove ] );
143
151
 
144
152
  return (
145
153
  <>
146
- <div role="grid" aria-label="Harness" tabIndex={ 0 } onKeyDown={ onChartKeyDown }>
154
+ <div
155
+ role="grid"
156
+ aria-label="Harness"
157
+ tabIndex={ 0 }
158
+ onKeyDown={ onChartKeyDown }
159
+ onBlur={ trackBlur ? onChartBlur : undefined }
160
+ >
147
161
  <div ref={ chartRef } data-testid="selected-index">
148
162
  { selectedIndex ?? 'none' }
149
163
  <button type="button">Inside</button>
150
164
  </div>
165
+ <div data-testid="point-4" onPointerMove={ moveToFour } />
166
+ <div data-testid="point-1" onPointerMove={ moveToOne } />
151
167
  </div>
152
168
  <button type="button">Outside</button>
153
169
  </>
@@ -207,6 +223,67 @@ describe( 'useKeyboardNavigation', () => {
207
223
  expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
208
224
  } );
209
225
 
226
+ it( 'hands a keyboard selection to the pointer and continues from where the pointer is', async () => {
227
+ const { user } = await navigate( 2 );
228
+
229
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
230
+ await user.hover( screen.getByTestId( 'point-4' ) );
231
+
232
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
233
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
234
+
235
+ await user.keyboard( '{ArrowRight}' );
236
+
237
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '5' );
238
+ } );
239
+
240
+ it( 'continues from the last point the pointer reached after taking over', async () => {
241
+ const { user } = await navigate( 2 );
242
+
243
+ await user.hover( screen.getByTestId( 'point-4' ) );
244
+ await user.hover( screen.getByTestId( 'point-1' ) );
245
+ await user.keyboard( '{ArrowRight}' );
246
+
247
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
248
+ } );
249
+
250
+ it( 'restarts from the first point once focus leaves after the pointer took over', async () => {
251
+ const user = userEvent.setup();
252
+ render( <NavigationHarness totalPoints={ 6 } trackBlur /> );
253
+ const grid = screen.getByRole( 'grid', { name: 'Harness' } );
254
+
255
+ act( () => grid.focus() );
256
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
257
+ await user.hover( screen.getByTestId( 'point-4' ) );
258
+ act( () => screen.getByRole( 'button', { name: 'Outside' } ).focus() );
259
+ act( () => grid.focus() );
260
+ await user.keyboard( '{ArrowRight}' );
261
+
262
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
263
+ } );
264
+
265
+ it( 'leaves focus outside the chart when the pointer ends a selection', async () => {
266
+ const { user } = await navigate( 2 );
267
+ const outside = screen.getByRole( 'button', { name: 'Outside' } );
268
+
269
+ outside.focus();
270
+ await user.hover( screen.getByTestId( 'point-4' ) );
271
+
272
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
273
+ expect( outside ).toHaveFocus();
274
+ } );
275
+
276
+ it( 'ignores the pointer when there is no keyboard selection', async () => {
277
+ const user = userEvent.setup();
278
+ render( <NavigationHarness totalPoints={ 6 } /> );
279
+
280
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
281
+ await user.hover( screen.getByTestId( 'point-4' ) );
282
+ await user.keyboard( '{ArrowRight}' );
283
+
284
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
285
+ } );
286
+
210
287
  // The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
211
288
  const recordPageKeyDown = () => {
212
289
  const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
@@ -1,4 +1,4 @@
1
- import { hsl as d3Hsl } from '@visx/vendor/d3-color';
1
+ import { color as d3Color } from '@visx/vendor/d3-color';
2
2
  import {
3
3
  createContext,
4
4
  useCallback,
@@ -17,6 +17,7 @@ import {
17
17
  getItemShapeStyles,
18
18
  getSeriesBarStyles,
19
19
  getSeriesLineStyles,
20
+ isValidHexColor,
20
21
  mergeThemes,
21
22
  resolveCssVariable,
22
23
  normalizeColorToHex,
@@ -24,13 +25,39 @@ import {
24
25
  import { sanitizeFormatting } from '../../utils/date-formatting';
25
26
  // Imported from the module rather than the `chart-scope` barrel: the barrel also pulls `use-standalone-scope-class`, which imports `GlobalChartsContext` back from this file. That cycle resolves today only because the binding is read lazily inside the hook body.
26
27
  import { ChartScopeContext } from '../chart-scope/chart-scope-context';
27
- import { getChartColor, type ColorCache } from './private/get-chart-color';
28
- import { SERIES_PALETTE_POINTERS } from './private/series-palette';
28
+ import {
29
+ BACKGROUND_FALLBACK,
30
+ CATALOG_POINTERS,
31
+ LABEL_FALLBACK,
32
+ LABEL_INVERSE_FALLBACK,
33
+ } from './private/catalog-pointers';
34
+ import { createPaletteGenerator } from './private/palette-generator';
35
+ import { SERIES_PALETTE_POINTERS, SERIES_SLOT_1_FALLBACK } from './private/series-palette';
29
36
  import { defaultTheme } from './themes';
30
37
  import type { GlobalChartsContextValue, ChartRegistration } from './types';
31
38
  import type { ChartTheme, CompleteChartTheme } from '../../types';
32
39
  import type { FC, ReactNode } from 'react';
33
40
 
41
+ interface ColorCache {
42
+ colors: string[];
43
+ background: string;
44
+ labelColors: string[];
45
+ colorAt: ( index: number ) => string;
46
+ }
47
+
48
+ // A see-through color (transparent, or any alpha below 1) says nothing about what it will look like
49
+ // over the chart, so it resolves to null rather than let its RGB leak into the palette.
50
+ const resolveOpaqueHex = ( pointer: string, element: HTMLElement | null ): string | null => {
51
+ const raw = resolveCssVariable( pointer, element );
52
+ if ( ! raw || d3Color( raw )?.opacity !== 1 ) {
53
+ return null;
54
+ }
55
+ const hex = normalizeColorToHex( pointer, element, resolveCssVariable );
56
+ return isValidHexColor( hex ) ? hex : null;
57
+ };
58
+
59
+ const PLACEHOLDER_LABEL_COLORS = [ LABEL_FALLBACK, LABEL_INVERSE_FALLBACK ];
60
+
34
61
  export const GlobalChartsContext = createContext< GlobalChartsContextValue | null >( null );
35
62
 
36
63
  export interface GlobalChartsProviderProps {
@@ -81,12 +108,17 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
81
108
  // Cache expensive color computations that only change when theme colors change
82
109
  // Using useState + useLayoutEffect instead of useMemo to ensure CSS variables
83
110
  // in <style> tags are applied to the DOM before we try to resolve them
111
+ // Seeded with what slot 1 resolves to where no DOM answers, so a one-series first render
112
+ // never builds the candidate grid and SSR matches the unthemed client palette.
84
113
  const [ colorCache, setColorCache ] = useState< ColorCache >( () => ( {
85
114
  colors: [],
86
- hues: [],
87
- existingHslColors: [],
88
- minHue: 360,
89
- maxHue: 0,
115
+ background: BACKGROUND_FALLBACK,
116
+ labelColors: PLACEHOLDER_LABEL_COLORS,
117
+ colorAt: createPaletteGenerator(
118
+ [ SERIES_SLOT_1_FALLBACK ],
119
+ BACKGROUND_FALLBACK,
120
+ PLACEHOLDER_LABEL_COLORS
121
+ ),
90
122
  } ) );
91
123
 
92
124
  // Track if the color palette has been resolved from the DOM
@@ -100,45 +132,29 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
100
132
  useLayoutEffect( () => {
101
133
  setIsColorPaletteResolved( false );
102
134
  const resolvedColors: string[] = [];
103
- const hues: number[] = [];
104
- const existingHslColors: Array< [ number, number, number ] > = [];
105
- let minHue = 360;
106
- let maxHue = 0;
107
135
 
108
136
  for ( const color of SERIES_PALETTE_POINTERS ) {
109
- // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
110
- // This uses normalizeColorToHex which resolves CSS variables and converts
111
- // rgb(), rgba(), hsl() formats to hex
112
137
  const normalizedColor = normalizeColorToHex( color, wrapperRef.current, resolveCssVariable );
113
138
 
114
- // Only process valid hex colors. An unset palette slot returns its own
115
- // `var()` unchanged, so this is also what compacts the palette: slots the
116
- // consumer never set drop out here and `getChartColor` generates past
117
- // whatever survived.
118
- if ( normalizedColor.startsWith( '#' ) ) {
139
+ // An unset palette slot returns its own `var()` unchanged, so this is also what
140
+ // compacts the palette: a slot the consumer never set drops out here.
141
+ if ( isValidHexColor( normalizedColor ) ) {
119
142
  resolvedColors.push( normalizedColor );
120
- const hslColor = d3Hsl( normalizedColor );
121
- // d3Hsl returns NaN values for invalid colors
122
- if ( ! isNaN( hslColor.h ) ) {
123
- const hslTuple: [ number, number, number ] = [
124
- hslColor.h,
125
- hslColor.s * 100,
126
- hslColor.l * 100,
127
- ];
128
- hues.push( hslTuple[ 0 ] );
129
- existingHslColors.push( hslTuple );
130
- minHue = Math.min( minHue, hslTuple[ 0 ] );
131
- maxHue = Math.max( maxHue, hslTuple[ 0 ] );
132
- }
133
143
  }
134
144
  }
135
145
 
146
+ const backgroundHex =
147
+ resolveOpaqueHex( CATALOG_POINTERS.background, wrapperRef.current ) ?? BACKGROUND_FALLBACK;
148
+ // The two roles pie labels choose between on a fill; one left see-through is never painted there.
149
+ const labelColors = [ CATALOG_POINTERS.label, CATALOG_POINTERS.labelInverse ]
150
+ .map( pointer => resolveOpaqueHex( pointer, wrapperRef.current ) )
151
+ .filter( ( hex ): hex is string => hex !== null );
152
+
136
153
  setColorCache( {
137
154
  colors: resolvedColors,
138
- hues,
139
- existingHslColors,
140
- minHue,
141
- maxHue,
155
+ background: backgroundHex,
156
+ labelColors,
157
+ colorAt: createPaletteGenerator( resolvedColors, backgroundHex, labelColors ),
142
158
  } );
143
159
  }, [] );
144
160
 
@@ -152,9 +168,9 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
152
168
  () => new Map()
153
169
  );
154
170
 
155
- // Keyed on the resolved colors rather than the cache object, so a consumer passing an inline
156
- // `theme` cannot reset the map on every render.
157
- const paletteKey = colorCache.colors.join( ',' );
171
+ // Keyed on the resolved colors, background and labels, not the cache object, so a consumer passing an
172
+ // inline `theme` cannot reset the map on every render.
173
+ const paletteKey = `${ colorCache.colors.join( ',' ) }|${ colorCache.background }|${ colorCache.labelColors.join( ',' ) }`;
158
174
 
159
175
  useEffect( () => {
160
176
  // Create a completely new Map instance to trigger dependencies, e.g. useChartLegendItems
@@ -206,13 +222,13 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
206
222
  // Use map size as index to assign colors sequentially (0, 1, 2...)
207
223
  // ensuring each new group gets the next available palette color
208
224
  const assignedCount = groupToColorMap.size;
209
- const color = getChartColor( assignedCount, colorCache );
225
+ const color = colorCache.colorAt( assignedCount );
210
226
  groupToColorMap.set( group, color );
211
227
 
212
228
  return color;
213
229
  }
214
230
 
215
- return getChartColor( index, colorCache );
231
+ return colorCache.colorAt( index );
216
232
  },
217
233
  [ colorCache, groupToColorMap ]
218
234
  );
@@ -4,9 +4,17 @@ import { SERIES_PALETTE_POINTERS } from './series-palette';
4
4
  * The `var()` chain for each color JS has to hand to something. Nothing here is resolved in JS —
5
5
  * the chain lands on the element; the terminal literal covers SSR and jsdom. See TOKENS.md.
6
6
  */
7
+ /** Terminal literals the provider also generates from before its first read, so SSR and the first client render agree. */
8
+ export const BACKGROUND_FALLBACK = '#ffffff';
9
+ export const LABEL_FALLBACK = '#1e1e1e';
10
+ export const LABEL_INVERSE_FALLBACK = '#f0f0f0';
11
+
7
12
  export const CATALOG_POINTERS = {
8
- background: 'var(--a8c-charts-color-background, #fff)',
13
+ background: `var(--a8c-charts-color-background, ${ BACKGROUND_FALLBACK })`,
14
+ label: `var(--a8c-charts-color-label, ${ LABEL_FALLBACK })`,
9
15
  labelAxis: 'var(--a8c-charts-color-label-axis, #1e1e1e)',
16
+ labelInverse: `var(--a8c-charts-color-label-inverse, ${ LABEL_INVERSE_FALLBACK })`,
17
+ labelBackground: 'var(--a8c-charts-color-label-background, transparent)',
10
18
  grid: 'var(--a8c-charts-color-grid, #dbdbdb)',
11
19
  // The y pair resolves to `none`: that axis carries labels only until a consumer declares them.
12
20
  axisX: 'var(--a8c-charts-color-axis-x, #dbdbdb)',
@@ -0,0 +1,305 @@
1
+ import { relativeLuminance } from '../../../utils';
2
+ import {
3
+ hexToOklch,
4
+ hexToViews,
5
+ luminanceContrastRatio,
6
+ oklchToHex,
7
+ viewDistance,
8
+ } from './perceptual-color';
9
+ import type { ColorViews } from './perceptual-color';
10
+
11
+ /** WCAG 1.4.11 non-text contrast, which a series mark needs against the chart background. */
12
+ export const MIN_BACKGROUND_CONTRAST = 3;
13
+
14
+ /** WCAG 1.4.3 text contrast, which a label drawn on a fill needs from at least one label color. */
15
+ export const MIN_LABEL_CONTRAST = 4.5;
16
+
17
+ /** ΔE00 every earlier color must clear before a candidate may be picked for its hue. */
18
+ export const PREFERRED_SEPARATION = 12;
19
+
20
+ /** Degrees of hue one unit of OKLCH chroma is worth when ranking candidates by hue. */
21
+ const CHROMA_WEIGHT = 300;
22
+
23
+ /** ΔE00 one unit of OKLCH chroma is worth when ranking candidates by distance. */
24
+ const FAR_CHROMA_WEIGHT = 100;
25
+
26
+ interface Candidate {
27
+ hex: string;
28
+ views: ColorViews;
29
+ luminance: number;
30
+ hue: number;
31
+ chroma: number;
32
+ }
33
+
34
+ const toCandidate = ( hex: string, oklch = hexToOklch( hex ) ): Candidate => ( {
35
+ hex,
36
+ views: hexToViews( hex ),
37
+ luminance: relativeLuminance( hex ),
38
+ hue: oklch.hue,
39
+ chroma: oklch.chroma,
40
+ } );
41
+
42
+ // Lower ranks first. With an anchor: degrees from it going round the wheel. Without: farthest first. Both favor saturation.
43
+ const rank = ( candidate: Candidate, distance: number, anchorHue: number | null ): number =>
44
+ anchorHue === null
45
+ ? -( distance + candidate.chroma * FAR_CHROMA_WEIGHT )
46
+ : ( ( candidate.hue - anchorHue + 360 ) % 360 ) - candidate.chroma * CHROMA_WEIGHT;
47
+
48
+ const LIGHTNESS_STEPS = Array.from( { length: 13 }, ( _, step ) => 0.45 + step * 0.025 );
49
+ const CHROMA_STEPS = [ 0.12, 0.15, 0.18, 0.21 ];
50
+ const HUE_STEPS = Array.from( { length: 72 }, ( _, step ) => step * 5 );
51
+
52
+ let candidateGrid: Candidate[] | null = null;
53
+
54
+ const getCandidateGrid = (): Candidate[] => {
55
+ if ( ! candidateGrid ) {
56
+ const seen = new Set< string >();
57
+ candidateGrid = [];
58
+ for ( const lightness of LIGHTNESS_STEPS ) {
59
+ for ( const chroma of CHROMA_STEPS ) {
60
+ for ( const hue of HUE_STEPS ) {
61
+ const hex = oklchToHex( lightness, chroma, hue );
62
+ if ( hex && ! seen.has( hex ) ) {
63
+ seen.add( hex );
64
+ candidateGrid.push( toCandidate( hex, { hue, chroma } ) );
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return candidateGrid;
71
+ };
72
+
73
+ // Every provider on a page usually shares one background and label pair, so each filters the grid only once.
74
+ const legiblePools = new Map< string, Candidate[] >();
75
+
76
+ const legibleCandidatesFor = (
77
+ rawBackground: string,
78
+ labelColors: readonly string[]
79
+ ): Candidate[] => {
80
+ const background = rawBackground.toLowerCase();
81
+ const key = [ background, ...labelColors.map( hex => hex.toLowerCase() ) ].join( '|' );
82
+ let pool = legiblePools.get( key );
83
+ if ( ! pool ) {
84
+ const backgroundLuminance = relativeLuminance( background );
85
+ const labelLuminances = labelColors.map( relativeLuminance );
86
+ const onBackground = getCandidateGrid().filter(
87
+ candidate =>
88
+ luminanceContrastRatio( candidate.luminance, backgroundLuminance ) >=
89
+ MIN_BACKGROUND_CONTRAST
90
+ );
91
+ const underLabels = onBackground.filter( candidate =>
92
+ labelLuminances.some(
93
+ labelLuminance =>
94
+ luminanceContrastRatio( candidate.luminance, labelLuminance ) >= MIN_LABEL_CONTRAST
95
+ )
96
+ );
97
+ // Label colors that no fill can serve would empty the pool; keep the background guarantee instead.
98
+ pool = underLabels.length > 0 ? underLabels : onBackground;
99
+ legiblePools.set( key, pool );
100
+ }
101
+ return pool;
102
+ };
103
+
104
+ /** A candidate pool with each unused candidate's current nearest distance to the palette. */
105
+ interface Tracker {
106
+ pool: Candidate[];
107
+ nearest: Float64Array;
108
+ }
109
+
110
+ /**
111
+ * Build a tracker for a candidate pool, seeding each unused candidate's nearest distance against
112
+ * every color already in the palette.
113
+ *
114
+ * @param pool - Candidates to track, in selection-priority order.
115
+ * @param palette - Colors already placed.
116
+ * @param usedHexes - Hex values already placed; a used candidate's distance is never read.
117
+ * @return The tracker.
118
+ */
119
+ const buildTracker = (
120
+ pool: Candidate[],
121
+ palette: readonly Candidate[],
122
+ usedHexes: ReadonlySet< string >
123
+ ): Tracker => {
124
+ const nearest = new Float64Array( pool.length ).fill( Infinity );
125
+ for ( let i = 0; i < pool.length; i++ ) {
126
+ if ( usedHexes.has( pool[ i ].hex ) ) {
127
+ continue;
128
+ }
129
+ for ( const color of palette ) {
130
+ const distance = viewDistance( pool[ i ].views, color.views );
131
+ if ( distance < nearest[ i ] ) {
132
+ nearest[ i ] = distance;
133
+ }
134
+ }
135
+ }
136
+ return { pool, nearest };
137
+ };
138
+
139
+ /**
140
+ * Fold one newly placed color into a tracker's nearest distances.
141
+ *
142
+ * @param tracker - Tracker to update.
143
+ * @param addedColor - Color just added to the palette.
144
+ * @param usedHexes - Hex values already placed; a used candidate's distance is never read.
145
+ */
146
+ const updateTracker = (
147
+ tracker: Tracker,
148
+ addedColor: Candidate,
149
+ usedHexes: ReadonlySet< string >
150
+ ): void => {
151
+ for ( let i = 0; i < tracker.pool.length; i++ ) {
152
+ if ( usedHexes.has( tracker.pool[ i ].hex ) ) {
153
+ continue;
154
+ }
155
+ const distance = viewDistance( tracker.pool[ i ].views, addedColor.views );
156
+ if ( distance < tracker.nearest[ i ] ) {
157
+ tracker.nearest[ i ] = distance;
158
+ }
159
+ }
160
+ };
161
+
162
+ /**
163
+ * Pick the next color: the best-ranked unused candidate that clears `PREFERRED_SEPARATION` from
164
+ * every earlier color; otherwise the farthest candidate.
165
+ *
166
+ * @param tracker - Tracker to read.
167
+ * @param usedHexes - Hex values already placed; a used candidate is never picked.
168
+ * @param anchorHue - Hue to walk the wheel from, or null to rank by distance.
169
+ * @return The picked candidate, or null if every candidate in the pool is used.
170
+ */
171
+ const pickNext = (
172
+ tracker: Tracker,
173
+ usedHexes: ReadonlySet< string >,
174
+ anchorHue: number | null
175
+ ): Candidate | null => {
176
+ let farthestIndex = -1;
177
+ let farthestDistance = -Infinity;
178
+ let preferredIndex = -1;
179
+ let preferredRank = Infinity;
180
+ for ( let i = 0; i < tracker.pool.length; i++ ) {
181
+ if ( usedHexes.has( tracker.pool[ i ].hex ) ) {
182
+ continue;
183
+ }
184
+ const distance = tracker.nearest[ i ];
185
+ if ( distance > farthestDistance ) {
186
+ farthestDistance = distance;
187
+ farthestIndex = i;
188
+ }
189
+ if ( distance >= PREFERRED_SEPARATION ) {
190
+ const candidateRank = rank( tracker.pool[ i ], distance, anchorHue );
191
+ if (
192
+ candidateRank < preferredRank ||
193
+ ( candidateRank === preferredRank && distance > tracker.nearest[ preferredIndex ] )
194
+ ) {
195
+ preferredRank = candidateRank;
196
+ preferredIndex = i;
197
+ }
198
+ }
199
+ }
200
+ const index = preferredIndex === -1 ? farthestIndex : preferredIndex;
201
+ return index === -1 ? null : tracker.pool[ index ];
202
+ };
203
+
204
+ type ColorAt = ( index: number ) => string;
205
+
206
+ // Generation is deterministic, so every provider on a page with the same inputs shares one generator.
207
+ const generators = new Map< string, ColorAt >();
208
+ const MAX_CACHED_GENERATORS = 32;
209
+
210
+ /**
211
+ * Build the series palette: the seeds, then colors that stay apart from every earlier color in
212
+ * normal vision and under deuteranopia and protanopia.
213
+ *
214
+ * @param seeds - Resolved hex palette slots, in slot order.
215
+ * @param background - Resolved hex chart background.
216
+ * @param labelColors - Resolved hex label colors that may be drawn on a fill; each generated color reaches `MIN_LABEL_CONTRAST` with one of them.
217
+ * @return The color at a palette index.
218
+ */
219
+ export const createPaletteGenerator = (
220
+ seeds: readonly string[],
221
+ background: string,
222
+ labelColors: readonly string[] = []
223
+ ): ColorAt => {
224
+ const normalizedSeeds = seeds.map( hex => hex.toLowerCase() );
225
+ const normalizedBackground = background.toLowerCase();
226
+ const normalizedLabels = labelColors.map( hex => hex.toLowerCase() );
227
+ const key = `${ normalizedSeeds.join( ',' ) }|${ normalizedBackground }|${ normalizedLabels.join(
228
+ ','
229
+ ) }`;
230
+ let colorAt = generators.get( key );
231
+ if ( ! colorAt ) {
232
+ if ( generators.size >= MAX_CACHED_GENERATORS ) {
233
+ generators.delete( generators.keys().next().value as string );
234
+ }
235
+ colorAt = buildPaletteGenerator( normalizedSeeds, normalizedBackground, normalizedLabels );
236
+ generators.set( key, colorAt );
237
+ }
238
+ return colorAt;
239
+ };
240
+
241
+ const buildPaletteGenerator = (
242
+ normalizedSeeds: readonly string[],
243
+ background: string,
244
+ labelColors: readonly string[]
245
+ ): ColorAt => {
246
+ const palette: Candidate[] = normalizedSeeds.map( hex => toCandidate( hex ) );
247
+ const usedHexes = new Set< string >( normalizedSeeds );
248
+ const anchorHue = palette.length > 0 ? palette[ 0 ].hue : null;
249
+
250
+ let legibleTracker: Tracker | null = null;
251
+ let legibleExhausted = false;
252
+ let gridTracker: Tracker | null = null;
253
+ let gridExhausted = false;
254
+ // Colors placed by the time the grid ran out; the fallback below cycles through only these.
255
+ let placedBeforeExhaustion = 0;
256
+ let repeatCount = 0;
257
+
258
+ const nextColor = (): Candidate => {
259
+ // Alternate far and near, starting far: charts often use the first two colors for comparison, so those must contrast.
260
+ const walkHue = ( palette.length - normalizedSeeds.length ) % 2 === 1 ? anchorHue : null;
261
+ if ( ! legibleExhausted ) {
262
+ legibleTracker ??= buildTracker(
263
+ legibleCandidatesFor( background, labelColors ),
264
+ palette,
265
+ usedHexes
266
+ );
267
+ const picked = pickNext( legibleTracker, usedHexes, walkHue );
268
+ if ( picked ) {
269
+ return picked;
270
+ }
271
+ legibleExhausted = true;
272
+ }
273
+ if ( ! gridExhausted ) {
274
+ gridTracker ??= buildTracker( getCandidateGrid(), palette, usedHexes );
275
+ const picked = pickNext( gridTracker, usedHexes, walkHue );
276
+ if ( picked ) {
277
+ return picked;
278
+ }
279
+ gridExhausted = true;
280
+ placedBeforeExhaustion = palette.length;
281
+ }
282
+ // Every candidate is already in the palette; cycle through the ones placed before the grid ran out.
283
+ const repeated = palette[ repeatCount % placedBeforeExhaustion ];
284
+ repeatCount++;
285
+ return repeated;
286
+ };
287
+
288
+ return ( index: number ): string => {
289
+ if ( index < palette.length ) {
290
+ return palette[ index ].hex;
291
+ }
292
+ while ( palette.length <= index ) {
293
+ const next = nextColor();
294
+ usedHexes.add( next.hex );
295
+ palette.push( next );
296
+ if ( legibleTracker && ! legibleExhausted ) {
297
+ updateTracker( legibleTracker, next, usedHexes );
298
+ }
299
+ if ( gridTracker && ! gridExhausted ) {
300
+ updateTracker( gridTracker, next, usedHexes );
301
+ }
302
+ }
303
+ return palette[ index ].hex;
304
+ };
305
+ };