@automattic/charts 4.4.0 → 4.6.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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +524 -203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +22 -4
  7. package/dist/index.d.ts +22 -4
  8. package/dist/index.js +524 -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 +8 -0
  14. package/src/charts/geo-chart/index.ts +7 -1
  15. package/src/charts/geo-chart/test/geo-chart.test.tsx +16 -0
  16. package/src/charts/geo-chart/types.ts +17 -0
  17. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  18. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  19. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  20. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  21. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  22. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  23. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  24. package/src/index.ts +7 -1
  25. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  26. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  27. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  28. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  29. package/src/providers/chart-context/private/series-palette.ts +1 -1
  30. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  31. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  32. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  33. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  34. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  35. package/src/utils/color-utils.ts +5 -3
  36. package/src/utils/test/date-formatting.test.ts +1 -1
  37. package/src/providers/chart-context/private/get-chart-color.ts +0 -223
@@ -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
+ };
@@ -0,0 +1,219 @@
1
+ import { relativeLuminance, validateHexColor } from '../../../utils';
2
+
3
+ export type Lab = readonly [ number, number, number ];
4
+
5
+ /** A color as seen in normal vision, then under deuteranopia and protanopia. */
6
+ export type ColorViews = readonly [ Lab, Lab, Lab ];
7
+
8
+ type Matrix = readonly number[];
9
+
10
+ // Machado, Oliveira and Fernandes (2009), severity 1.0, on linear sRGB.
11
+ const DEUTERANOPIA: Matrix = [
12
+ 0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881,
13
+ ];
14
+ const PROTANOPIA: Matrix = [
15
+ 0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998,
16
+ ];
17
+
18
+ const DEGREES = Math.PI / 180;
19
+
20
+ const toLinear = ( channel: number ): number =>
21
+ channel <= 0.04045 ? channel / 12.92 : Math.pow( ( channel + 0.055 ) / 1.055, 2.4 );
22
+
23
+ const fromLinear = ( channel: number ): number =>
24
+ channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow( channel, 1 / 2.4 ) - 0.055;
25
+
26
+ const hexToLinear = ( hex: string ): number[] => {
27
+ validateHexColor( hex );
28
+ return [ 1, 3, 5 ].map( start =>
29
+ toLinear( parseInt( hex.slice( start, start + 2 ), 16 ) / 255 )
30
+ );
31
+ };
32
+
33
+ const linearToHex = ( linear: number[] ): string =>
34
+ '#' +
35
+ linear
36
+ .map( channel =>
37
+ Math.round( Math.min( 1, Math.max( 0, fromLinear( channel ) ) ) * 255 )
38
+ .toString( 16 )
39
+ .padStart( 2, '0' )
40
+ )
41
+ .join( '' );
42
+
43
+ const simulate = ( matrix: Matrix, [ r, g, b ]: number[] ): number[] =>
44
+ [ 0, 3, 6 ].map( row =>
45
+ Math.min( 1, Math.max( 0, matrix[ row ] * r + matrix[ row + 1 ] * g + matrix[ row + 2 ] * b ) )
46
+ );
47
+
48
+ const linearToLab = ( [ r, g, b ]: number[] ): Lab => {
49
+ const x = ( 0.4124564 * r + 0.3575761 * g + 0.1804375 * b ) / 0.95047;
50
+ const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
51
+ const z = ( 0.0193339 * r + 0.119192 * g + 0.9503041 * b ) / 1.08883;
52
+ const f = ( t: number ): number =>
53
+ t > 216 / 24389 ? Math.cbrt( t ) : ( ( 24389 / 27 ) * t + 16 ) / 116;
54
+ return [ 116 * f( y ) - 16, 500 * ( f( x ) - f( y ) ), 200 * ( f( y ) - f( z ) ) ];
55
+ };
56
+
57
+ /**
58
+ * CIEDE2000 color difference between two CIELAB colors.
59
+ *
60
+ * @param first - First color in CIELAB.
61
+ * @param second - Second color in CIELAB.
62
+ * @return ΔE00; about 10 is where two chart categories stop being reliably distinguishable.
63
+ */
64
+ export const deltaE2000 = ( first: Lab, second: Lab ): number => {
65
+ const [ l1, a1, b1 ] = first;
66
+ const [ l2, a2, b2 ] = second;
67
+ const chromaMean = ( Math.hypot( a1, b1 ) + Math.hypot( a2, b2 ) ) / 2;
68
+ const g = 0.5 * ( 1 - Math.sqrt( chromaMean ** 7 / ( chromaMean ** 7 + 25 ** 7 ) ) );
69
+ const a1p = a1 * ( 1 + g );
70
+ const a2p = a2 * ( 1 + g );
71
+ const c1p = Math.hypot( a1p, b1 );
72
+ const c2p = Math.hypot( a2p, b2 );
73
+ const hueOf = ( a: number, b: number ): number =>
74
+ a === 0 && b === 0 ? 0 : ( Math.atan2( b, a ) / DEGREES + 360 ) % 360;
75
+ const h1p = hueOf( a1p, b1 );
76
+ const h2p = hueOf( a2p, b2 );
77
+ const chromatic = c1p * c2p !== 0;
78
+
79
+ let hueDelta = chromatic ? h2p - h1p : 0;
80
+ if ( hueDelta > 180 ) {
81
+ hueDelta -= 360;
82
+ } else if ( hueDelta < -180 ) {
83
+ hueDelta += 360;
84
+ }
85
+
86
+ const lightnessDelta = l2 - l1;
87
+ const chromaDelta = c2p - c1p;
88
+ const hueDifference = 2 * Math.sqrt( c1p * c2p ) * Math.sin( ( hueDelta * DEGREES ) / 2 );
89
+ const lightnessMean = ( l1 + l2 ) / 2;
90
+ const chromaMeanPrime = ( c1p + c2p ) / 2;
91
+
92
+ let hueMean = h1p + h2p;
93
+ if ( chromatic ) {
94
+ if ( Math.abs( h1p - h2p ) > 180 ) {
95
+ hueMean += hueMean < 360 ? 360 : -360;
96
+ }
97
+ hueMean /= 2;
98
+ }
99
+
100
+ const t =
101
+ 1 -
102
+ 0.17 * Math.cos( ( hueMean - 30 ) * DEGREES ) +
103
+ 0.24 * Math.cos( 2 * hueMean * DEGREES ) +
104
+ 0.32 * Math.cos( ( 3 * hueMean + 6 ) * DEGREES ) -
105
+ 0.2 * Math.cos( ( 4 * hueMean - 63 ) * DEGREES );
106
+ const lightnessScale =
107
+ 1 + ( 0.015 * ( lightnessMean - 50 ) ** 2 ) / Math.sqrt( 20 + ( lightnessMean - 50 ) ** 2 );
108
+ const chromaScale = 1 + 0.045 * chromaMeanPrime;
109
+ const hueScale = 1 + 0.015 * chromaMeanPrime * t;
110
+ const rotation =
111
+ -2 *
112
+ Math.sqrt( chromaMeanPrime ** 7 / ( chromaMeanPrime ** 7 + 25 ** 7 ) ) *
113
+ Math.sin( 60 * Math.exp( -( ( ( hueMean - 275 ) / 25 ) ** 2 ) ) * DEGREES );
114
+
115
+ const lightnessTerm = lightnessDelta / lightnessScale;
116
+ const chromaTerm = chromaDelta / chromaScale;
117
+ const hueTerm = hueDifference / hueScale;
118
+
119
+ return Math.sqrt(
120
+ lightnessTerm ** 2 + chromaTerm ** 2 + hueTerm ** 2 + rotation * chromaTerm * hueTerm
121
+ );
122
+ };
123
+
124
+ /**
125
+ * A hex color in CIELAB as seen in normal vision, under deuteranopia and under protanopia.
126
+ *
127
+ * @param hex - Six-digit hex color.
128
+ * @return The three views.
129
+ * @throws {Error} if hex string is malformed
130
+ */
131
+ export const hexToViews = ( hex: string ): ColorViews => {
132
+ const linear = hexToLinear( hex );
133
+ return [
134
+ linearToLab( linear ),
135
+ linearToLab( simulate( DEUTERANOPIA, linear ) ),
136
+ linearToLab( simulate( PROTANOPIA, linear ) ),
137
+ ];
138
+ };
139
+
140
+ /**
141
+ * The smallest ΔE00 between two colors across the three views.
142
+ *
143
+ * @param first - First color's views.
144
+ * @param second - Second color's views.
145
+ * @return The distance in the view where the two are hardest to tell apart.
146
+ */
147
+ export const viewDistance = ( first: ColorViews, second: ColorViews ): number =>
148
+ Math.min(
149
+ deltaE2000( first[ 0 ], second[ 0 ] ),
150
+ deltaE2000( first[ 1 ], second[ 1 ] ),
151
+ deltaE2000( first[ 2 ], second[ 2 ] )
152
+ );
153
+
154
+ /**
155
+ * Convert an OKLCH color to sRGB hex.
156
+ *
157
+ * @param lightness - OKLab L, 0 to 1.
158
+ * @param chroma - OKLCH C.
159
+ * @param hue - Hue in degrees.
160
+ * @return Hex color, or null when the color is outside the sRGB gamut.
161
+ */
162
+ export const oklchToHex = ( lightness: number, chroma: number, hue: number ): string | null => {
163
+ const a = chroma * Math.cos( hue * DEGREES );
164
+ const b = chroma * Math.sin( hue * DEGREES );
165
+ const l = ( lightness + 0.3963377774 * a + 0.2158037573 * b ) ** 3;
166
+ const m = ( lightness - 0.1055613458 * a - 0.0638541728 * b ) ** 3;
167
+ const s = ( lightness - 0.0894841775 * a - 1.291485548 * b ) ** 3;
168
+ const linear = [
169
+ 4.0767416621 * l - 3.3077633306 * m + 0.2309645873 * s,
170
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
171
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
172
+ ];
173
+ const tolerance = 1e-4;
174
+ if ( linear.some( channel => channel < -tolerance || channel > 1 + tolerance ) ) {
175
+ return null;
176
+ }
177
+ return linearToHex( linear );
178
+ };
179
+
180
+ /**
181
+ * OKLCH chroma and hue of a hex color.
182
+ *
183
+ * @param hex - Six-digit hex color.
184
+ * @return Chroma, and hue in degrees from 0 to 360.
185
+ * @throws {Error} if hex string is malformed
186
+ */
187
+ export const hexToOklch = ( hex: string ): { chroma: number; hue: number } => {
188
+ const [ r, g, b ] = hexToLinear( hex );
189
+ const l = Math.cbrt( 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b );
190
+ const m = Math.cbrt( 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b );
191
+ const s = Math.cbrt( 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b );
192
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
193
+ const bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
194
+ return {
195
+ chroma: Math.hypot( a, bAxis ),
196
+ hue: ( Math.atan2( bAxis, a ) / DEGREES + 360 ) % 360,
197
+ };
198
+ };
199
+
200
+ /**
201
+ * WCAG contrast ratio between two relative luminances.
202
+ *
203
+ * @param first - First relative luminance.
204
+ * @param second - Second relative luminance.
205
+ * @return Ratio from 1 to 21.
206
+ */
207
+ export const luminanceContrastRatio = ( first: number, second: number ): number =>
208
+ ( Math.max( first, second ) + 0.05 ) / ( Math.min( first, second ) + 0.05 );
209
+
210
+ /**
211
+ * WCAG contrast ratio between two hex colors.
212
+ *
213
+ * @param first - First hex color.
214
+ * @param second - Second hex color.
215
+ * @return Ratio from 1 to 21.
216
+ * @throws {Error} if either hex string is malformed
217
+ */
218
+ export const contrastRatio = ( first: string, second: string ): number =>
219
+ luminanceContrastRatio( relativeLuminance( first ), relativeLuminance( second ) );
@@ -23,7 +23,7 @@ export const seriesRole = ( slot: number ): string => `--a8c-charts-color-series
23
23
  * the bare hex rather than that slot's full `var()` chain: this string is resolved against the DOM,
24
24
  * where the chain has already been walked, and is only read at all when no DOM answered.
25
25
  */
26
- const SERIES_SLOT_1_FALLBACK = '#3858e9';
26
+ export const SERIES_SLOT_1_FALLBACK = '#3858e9';
27
27
 
28
28
  /** The catalog pointer for every slot, in slot order. */
29
29
  export const SERIES_PALETTE_POINTERS: readonly string[] = Array.from(
@@ -4,6 +4,7 @@ import { GlobalChartsProvider } from '../global-charts-provider';
4
4
  import { useChartId } from '../hooks/use-chart-id';
5
5
  import { useChartRegistration } from '../hooks/use-chart-registration';
6
6
  import { useGlobalChartsContext } from '../hooks/use-global-charts-context';
7
+ import { createPaletteGenerator } from '../private/palette-generator';
7
8
  import { defaultTheme } from '../themes';
8
9
  import type { BaseLegendItem } from '../../../components/legend';
9
10
  import type { SeriesData } from '../../../types';
@@ -1893,13 +1894,22 @@ describe( 'ChartContext', () => {
1893
1894
  return <div>Test</div>;
1894
1895
  };
1895
1896
 
1896
- const renderWithSlots = ( slots: Array< string | undefined > ) => {
1897
+ const renderWithSlots = (
1898
+ slots: Array< string | undefined >,
1899
+ extraRoles?: Record< string, string >
1900
+ ) => {
1897
1901
  slots.forEach( ( color, slot ) => {
1898
1902
  if ( color !== undefined ) {
1899
1903
  container.style.setProperty( `--a8c-charts-color-series-${ slot + 1 }`, color );
1900
1904
  }
1901
1905
  } );
1902
1906
 
1907
+ if ( extraRoles ) {
1908
+ Object.entries( extraRoles ).forEach( ( [ property, value ] ) =>
1909
+ container.style.setProperty( property, value )
1910
+ );
1911
+ }
1912
+
1903
1913
  return render(
1904
1914
  <GlobalChartsProvider>
1905
1915
  <TestComponent />
@@ -1990,19 +2000,16 @@ describe( 'ChartContext', () => {
1990
2000
  } );
1991
2001
 
1992
2002
  describe( 'Error Handling', () => {
1993
- it( 'keeps an unparseable value without crashing', () => {
2003
+ it( 'compacts out an unparseable value without crashing', () => {
1994
2004
  expect( () => renderWithSlots( [ '#invalid', '#ff0000' ] ) ).not.toThrow();
1995
2005
 
1996
- expect( colorAt( 0 ) ).toBe( '#invalid' );
1997
- expect( colorAt( 1 ) ).toBe( '#ff0000' );
2006
+ expect( colorAt( 0 ) ).toBe( '#ff0000' );
1998
2007
  } );
1999
2008
 
2000
- it( 'keeps malformed hex values without crashing', () => {
2009
+ it( 'compacts out malformed hex values without crashing', () => {
2001
2010
  expect( () => renderWithSlots( [ '#ff', '#gggggg', '#00ff00' ] ) ).not.toThrow();
2002
2011
 
2003
- expect( colorAt( 0 ) ).toBe( '#ff' );
2004
- expect( colorAt( 1 ) ).toBe( '#gggggg' );
2005
- expect( colorAt( 2 ) ).toBe( '#00ff00' );
2012
+ expect( colorAt( 0 ) ).toBe( '#00ff00' );
2006
2013
  } );
2007
2014
 
2008
2015
  it( 'keeps resolving the later slots after one fails to convert', () => {
@@ -2034,6 +2041,79 @@ describe( 'ChartContext', () => {
2034
2041
  } );
2035
2042
  } );
2036
2043
 
2044
+ describe( 'Background-aware generation', () => {
2045
+ const seed = '#3858e9';
2046
+ const labels = [ '#1e1e1e', '#f0f0f0' ];
2047
+
2048
+ it( 'wires the resolved dark background into the generator', () => {
2049
+ // Fixture guard: proves index 1 actually depends on the background for this seed,
2050
+ // so a match below can't pass by coincidence.
2051
+ const onDark = createPaletteGenerator( [ seed ], '#1e1e1e', labels )( 1 );
2052
+ const onLight = createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 );
2053
+ expect( onDark ).not.toBe( onLight );
2054
+
2055
+ renderWithSlots( [ seed ], { '--a8c-charts-color-background': '#1e1e1e' } );
2056
+
2057
+ expect( colorAt( 1 ) ).toBe( onDark );
2058
+ } );
2059
+
2060
+ it( 'falls back to a white background when the resolved value does not normalize to hex', () => {
2061
+ renderWithSlots( [ seed ], { '--a8c-charts-color-background': 'not-a-real-color' } );
2062
+
2063
+ expect( colorAt( 1 ) ).toBe( createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 ) );
2064
+ } );
2065
+
2066
+ it( 'falls back to a white background when the background is transparent', () => {
2067
+ const onWhite = createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 );
2068
+ const onBlack = createPaletteGenerator( [ seed ], '#000000', labels )( 1 );
2069
+ expect( onWhite ).not.toBe( onBlack );
2070
+
2071
+ renderWithSlots( [ seed ], { '--a8c-charts-color-background': 'transparent' } );
2072
+
2073
+ expect( colorAt( 1 ) ).toBe( onWhite );
2074
+ } );
2075
+
2076
+ it( 'falls back to a white background when the background is a translucent color', () => {
2077
+ const onWhite = createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 );
2078
+ const onBlack = createPaletteGenerator( [ seed ], '#000000', labels )( 1 );
2079
+ expect( onWhite ).not.toBe( onBlack );
2080
+
2081
+ renderWithSlots( [ seed ], { '--a8c-charts-color-background': 'rgba(0, 0, 0, 0.5)' } );
2082
+
2083
+ expect( colorAt( 1 ) ).toBe( onWhite );
2084
+ } );
2085
+
2086
+ it( 'wires the resolved label colors into the generator, leaving out a see-through one', () => {
2087
+ const firstSix = ( at: ( index: number ) => string ) =>
2088
+ Array.from( { length: 6 }, ( _, index ) => at( index ) );
2089
+ const inverseOnly = firstSix(
2090
+ createPaletteGenerator( [ seed ], '#ffffff', [ '#f0f0f0' ] )
2091
+ );
2092
+ expect( inverseOnly ).not.toEqual(
2093
+ firstSix( createPaletteGenerator( [ seed ], '#ffffff', labels ) )
2094
+ );
2095
+
2096
+ renderWithSlots( [ seed ], { '--a8c-charts-color-label': 'transparent' } );
2097
+
2098
+ expect( firstSix( colorAt ) ).toEqual( inverseOnly );
2099
+ } );
2100
+
2101
+ it( 'uses the resolved background for group colors when no seed resolves', () => {
2102
+ const background = '#1e1e1e';
2103
+
2104
+ // An unparseable slot 1 keeps the resolved palette empty even after resolution;
2105
+ // an unset slot 1 would instead fall back to its own terminal literal.
2106
+ renderWithSlots( [ '#invalid' ], { '--a8c-charts-color-background': background } );
2107
+
2108
+ const groupColor = contextValue.getElementStyles( {
2109
+ data: createMockDataWithGroup( 'test-group' ),
2110
+ index: 0,
2111
+ } ).color;
2112
+
2113
+ expect( groupColor ).toBe( createPaletteGenerator( [], background, labels )( 0 ) );
2114
+ } );
2115
+ } );
2116
+
2037
2117
  describe( 'Server-Side Rendering', () => {
2038
2118
  // Slot 1's terminal literal is the palette's only carrier under SSR.
2039
2119
  it( 'falls back to the seed when getComputedStyle is unavailable', () => {
@@ -0,0 +1,27 @@
1
+ import { render } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../global-charts-provider';
3
+ import { useGlobalChartsContext } from '../hooks/use-global-charts-context';
4
+
5
+ describe( 'GlobalChartsProvider before the palette resolves', () => {
6
+ it( 'renders the same colors an unthemed page resolves to', () => {
7
+ const renders: string[][] = [];
8
+
9
+ const FirstColors = () => {
10
+ const { getElementStyles } = useGlobalChartsContext();
11
+ renders.push(
12
+ Array.from( { length: 6 }, ( _, index ) => getElementStyles( { index } ).color )
13
+ );
14
+ return null;
15
+ };
16
+
17
+ render(
18
+ <GlobalChartsProvider>
19
+ <FirstColors />
20
+ </GlobalChartsProvider>
21
+ );
22
+
23
+ expect( renders.length ).toBeGreaterThan( 1 );
24
+ expect( renders[ 0 ][ 0 ] ).toBe( '#3858e9' );
25
+ expect( renders[ 0 ] ).toEqual( renders[ renders.length - 1 ] );
26
+ } );
27
+ } );