@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.
- package/CHANGELOG.md +18 -0
- package/TOKENS.md +2 -2
- package/dist/index.cjs +524 -203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +4 -0
- package/dist/index.d.cts +22 -4
- package/dist/index.d.ts +22 -4
- package/dist/index.js +524 -203
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +22 -10
- package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
- package/src/charts/geo-chart/geo-chart.tsx +8 -0
- package/src/charts/geo-chart/index.ts +7 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +16 -0
- package/src/charts/geo-chart/types.ts +17 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
- package/src/charts/pie-chart/pie-chart.module.scss +4 -0
- package/src/charts/pie-chart/pie-chart.tsx +103 -7
- package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
- package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
- package/src/components/tooltip/accessible-tooltip.tsx +40 -4
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
- package/src/index.ts +7 -1
- package/src/providers/chart-context/global-charts-provider.tsx +57 -41
- package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
- package/src/providers/chart-context/private/palette-generator.ts +305 -0
- package/src/providers/chart-context/private/perceptual-color.ts +219 -0
- package/src/providers/chart-context/private/series-palette.ts +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
- package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
- package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
- package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
- package/src/test-utils/zoned-parsing-suite.ts +1 -1
- package/src/utils/color-utils.ts +5 -3
- package/src/utils/test/date-formatting.test.ts +1 -1
- package/src/providers/chart-context/private/get-chart-color.ts +0 -223
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPaletteGenerator,
|
|
3
|
+
MIN_BACKGROUND_CONTRAST,
|
|
4
|
+
MIN_LABEL_CONTRAST,
|
|
5
|
+
PREFERRED_SEPARATION,
|
|
6
|
+
} from '../private/palette-generator';
|
|
7
|
+
import { contrastRatio, hexToOklch, hexToViews, viewDistance } from '../private/perceptual-color';
|
|
8
|
+
|
|
9
|
+
// `--wp-admin-theme-color` for each wp-admin color scheme, as `@wordpress/base-styles` sets it.
|
|
10
|
+
const WP_ADMIN_THEME_COLORS = Object.entries( {
|
|
11
|
+
light: '#007cba',
|
|
12
|
+
modern: '#3858e9',
|
|
13
|
+
blue: '#437aa8',
|
|
14
|
+
coffee: '#916745',
|
|
15
|
+
ectoplasm: '#646c3e',
|
|
16
|
+
midnight: '#cf4339',
|
|
17
|
+
ocean: '#567958',
|
|
18
|
+
sunrise: '#ad631e',
|
|
19
|
+
} );
|
|
20
|
+
const PALETTE_SIZE = 6;
|
|
21
|
+
const LABEL_COLORS = [ '#1e1e1e', '#f0f0f0' ];
|
|
22
|
+
|
|
23
|
+
const paletteOf = (
|
|
24
|
+
seeds: string[],
|
|
25
|
+
background: string,
|
|
26
|
+
size = PALETTE_SIZE,
|
|
27
|
+
labelColors: string[] = LABEL_COLORS
|
|
28
|
+
): string[] => {
|
|
29
|
+
const colorAt = createPaletteGenerator( seeds, background, labelColors );
|
|
30
|
+
return Array.from( { length: size }, ( _, index ) => colorAt( index ) );
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const minPairwiseDistance = ( palette: string[] ): number => {
|
|
34
|
+
const views = palette.map( hexToViews );
|
|
35
|
+
let min = Infinity;
|
|
36
|
+
for ( let i = 0; i < views.length; i++ ) {
|
|
37
|
+
for ( let j = i + 1; j < views.length; j++ ) {
|
|
38
|
+
min = Math.min( min, viewDistance( views[ i ], views[ j ] ) );
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return min;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe( 'createPaletteGenerator', () => {
|
|
45
|
+
it( 'returns the seeds first, in order', () => {
|
|
46
|
+
const colorAt = createPaletteGenerator( [ '#ff0000', '#00ff00' ], '#ffffff' );
|
|
47
|
+
expect( colorAt( 0 ) ).toBe( '#ff0000' );
|
|
48
|
+
expect( colorAt( 1 ) ).toBe( '#00ff00' );
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
describe.each( [ '#ffffff', '#1e1e1e' ] )( 'on %s', background => {
|
|
52
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
53
|
+
'keeps a palette seeded with the %s scheme (%s) separable in every vision view up to six colors',
|
|
54
|
+
( _scheme, seed ) => {
|
|
55
|
+
expect( minPairwiseDistance( paletteOf( [ seed ], background ) ) ).toBeGreaterThanOrEqual(
|
|
56
|
+
PREFERRED_SEPARATION
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
62
|
+
'gives every color generated from the %s scheme (%s) 3:1 against the background',
|
|
63
|
+
( _scheme, seed ) => {
|
|
64
|
+
paletteOf( [ seed ], background )
|
|
65
|
+
.slice( 1 )
|
|
66
|
+
.forEach( color => {
|
|
67
|
+
expect( contrastRatio( color, background ) ).toBeGreaterThanOrEqual(
|
|
68
|
+
MIN_BACKGROUND_CONTRAST
|
|
69
|
+
);
|
|
70
|
+
} );
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
75
|
+
'gives every color generated from the %s scheme (%s) a label color with 4.5:1 on it',
|
|
76
|
+
( _scheme, seed ) => {
|
|
77
|
+
paletteOf( [ seed ], background )
|
|
78
|
+
.slice( 1 )
|
|
79
|
+
.forEach( color => {
|
|
80
|
+
const best = Math.max( ...LABEL_COLORS.map( label => contrastRatio( color, label ) ) );
|
|
81
|
+
expect( best ).toBeGreaterThanOrEqual( MIN_LABEL_CONTRAST );
|
|
82
|
+
} );
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
it( 'keeps 3:1 against the background when no fill can reach 4.5:1 with the label colors', () => {
|
|
88
|
+
paletteOf( [ '#3858e9' ], '#ffffff', PALETTE_SIZE, [ '#777777' ] )
|
|
89
|
+
.slice( 1 )
|
|
90
|
+
.forEach( color => {
|
|
91
|
+
expect( contrastRatio( color, '#ffffff' ) ).toBeGreaterThanOrEqual(
|
|
92
|
+
MIN_BACKGROUND_CONTRAST
|
|
93
|
+
);
|
|
94
|
+
} );
|
|
95
|
+
} );
|
|
96
|
+
|
|
97
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
98
|
+
'sets the first generated color further from the %s scheme seed (%s) than the second',
|
|
99
|
+
( _scheme, seed ) => {
|
|
100
|
+
const [ seedColor, first, second ] = paletteOf( [ seed ], '#ffffff', 3 ).map( hexToViews );
|
|
101
|
+
expect( viewDistance( seedColor, first ) ).toBeGreaterThan(
|
|
102
|
+
viewDistance( seedColor, second )
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
it.each( [ '#007cba', '#3858e9', '#437aa8' ] )(
|
|
108
|
+
'places the second generated color just ahead of %s on the hue wheel',
|
|
109
|
+
seed => {
|
|
110
|
+
const seedHue = hexToOklch( seed ).hue;
|
|
111
|
+
const walkedHue = hexToOklch( paletteOf( [ seed ], '#ffffff', 3 )[ 2 ] ).hue;
|
|
112
|
+
expect( ( walkedHue - seedHue + 360 ) % 360 ).toBeLessThan( 45 );
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
it( 'shares one generator between calls with the same inputs, whatever their case', () => {
|
|
117
|
+
const first = createPaletteGenerator( [ '#3858E9' ], '#FFFFFF', LABEL_COLORS );
|
|
118
|
+
expect( createPaletteGenerator( [ '#3858e9' ], '#ffffff', LABEL_COLORS ) ).toBe( first );
|
|
119
|
+
expect( createPaletteGenerator( [ '#3858e9' ], '#1e1e1e', LABEL_COLORS ) ).not.toBe( first );
|
|
120
|
+
expect( createPaletteGenerator( [ '#3858e9' ], '#ffffff' ) ).not.toBe( first );
|
|
121
|
+
} );
|
|
122
|
+
|
|
123
|
+
it( 'returns the same colors whatever order they are asked for in', () => {
|
|
124
|
+
const inOrder = paletteOf( [ '#3858e9' ], '#ffffff', 8 );
|
|
125
|
+
const colorAt = createPaletteGenerator( [ '#3858e9' ], '#ffffff', LABEL_COLORS );
|
|
126
|
+
expect( colorAt( 7 ) ).toBe( inOrder[ 7 ] );
|
|
127
|
+
expect( colorAt( 5 ) ).toBe( inOrder[ 5 ] );
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
it( 'never repeats a seed or an earlier generated color', () => {
|
|
131
|
+
const palette = paletteOf( [ '#3858e9', '#e14d43', '#04a4cc' ], '#ffffff', 12 );
|
|
132
|
+
expect( new Set( palette ).size ).toBe( palette.length );
|
|
133
|
+
} );
|
|
134
|
+
|
|
135
|
+
it( 'still generates when no candidate reaches 3:1 against the background', () => {
|
|
136
|
+
const palette = paletteOf( [ '#3858e9' ], '#777777' );
|
|
137
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
138
|
+
expect( new Set( palette ).size ).toBe( palette.length );
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
it( 'generates from nothing when there are no seeds', () => {
|
|
142
|
+
const palette = paletteOf( [], '#ffffff', 3 );
|
|
143
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
144
|
+
expect( minPairwiseDistance( palette ) ).toBeGreaterThanOrEqual( 15 );
|
|
145
|
+
} );
|
|
146
|
+
|
|
147
|
+
it( 'keeps generating past a small non-empty legible set without throwing', () => {
|
|
148
|
+
// #9b9b9b has 5 legible candidates in the grid, so asking for 10 falls back to the rest of it.
|
|
149
|
+
const palette = paletteOf( [], '#9b9b9b', 10 );
|
|
150
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
151
|
+
expect( new Set( palette ).size ).toBe( palette.length );
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
it( 'never regenerates a color equal to a seed', () => {
|
|
155
|
+
// #9b9b9b has exactly 5 legible candidates, so seeding one and asking for the rest of
|
|
156
|
+
// the pool (plus one more, which forces the first grid fallback) exhausts it deterministically.
|
|
157
|
+
const legible = paletteOf( [], '#9b9b9b', 5 );
|
|
158
|
+
const seed = legible[ 0 ];
|
|
159
|
+
const generated = paletteOf( [ seed ], '#9b9b9b', 6 ).slice( 1 );
|
|
160
|
+
expect( generated ).not.toContain( seed );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
it( 'excludes a seed from the grid even when it is given in uppercase', () => {
|
|
164
|
+
const legible = paletteOf( [], '#9b9b9b', 5 );
|
|
165
|
+
const seed = legible[ 0 ];
|
|
166
|
+
const generated = paletteOf( [ seed.toUpperCase() ], '#9b9b9b', 6 ).slice( 1 );
|
|
167
|
+
expect( generated ).not.toContain( seed );
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
it( 'repeats rather than throws once the whole grid is used up', () => {
|
|
171
|
+
// The candidate grid holds roughly 2,757 distinct in-gamut colors; 3000 comfortably exceeds it.
|
|
172
|
+
const palette = paletteOf( [], '#ffffff', 3000 );
|
|
173
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
174
|
+
const placedCount = new Set( palette ).size;
|
|
175
|
+
expect( placedCount ).toBeLessThan( palette.length );
|
|
176
|
+
|
|
177
|
+
// The repeats cycle through the colors placed before the grid ran out, not always the first.
|
|
178
|
+
expect( palette[ placedCount ] ).toBe( palette[ 0 ] );
|
|
179
|
+
expect( palette[ placedCount + 1 ] ).toBe( palette[ 1 ] );
|
|
180
|
+
} );
|
|
181
|
+
} );
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contrastRatio,
|
|
3
|
+
deltaE2000,
|
|
4
|
+
hexToOklch,
|
|
5
|
+
hexToViews,
|
|
6
|
+
oklchToHex,
|
|
7
|
+
viewDistance,
|
|
8
|
+
} from '../private/perceptual-color';
|
|
9
|
+
|
|
10
|
+
describe( 'deltaE2000', () => {
|
|
11
|
+
// Sharma, Wu and Dalal (2005) reference pairs.
|
|
12
|
+
it.each( [
|
|
13
|
+
[ [ 50, 2.6772, -79.7751 ], [ 50, 0, -82.7485 ], 2.0425 ],
|
|
14
|
+
[ [ 50, 2.5, 0 ], [ 50, 0, -2.5 ], 4.3065 ],
|
|
15
|
+
[ [ 50, 2.5, 0 ], [ 73, 25, -18 ], 27.1492 ],
|
|
16
|
+
[ [ 2.0776, 0.0795, -1.135 ], [ 0.9033, -0.0636, -0.5514 ], 0.9082 ],
|
|
17
|
+
] as const )( 'matches the reference value for %j vs %j', ( a, b, expected ) => {
|
|
18
|
+
expect( deltaE2000( a, b ) ).toBeCloseTo( expected, 4 );
|
|
19
|
+
} );
|
|
20
|
+
|
|
21
|
+
it( 'is zero for identical colors', () => {
|
|
22
|
+
expect( deltaE2000( [ 40, 10, -20 ], [ 40, 10, -20 ] ) ).toBe( 0 );
|
|
23
|
+
} );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
describe( 'viewDistance', () => {
|
|
27
|
+
it( 'collapses a red/green pair under color vision deficiency', () => {
|
|
28
|
+
const red = hexToViews( '#cc4444' );
|
|
29
|
+
const green = hexToViews( '#669900' );
|
|
30
|
+
const [ normal ] = red.map( ( view, i ) => deltaE2000( view, green[ i ] ) );
|
|
31
|
+
expect( normal ).toBeGreaterThan( 30 );
|
|
32
|
+
expect( viewDistance( red, green ) ).toBeLessThan( 15 );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'keeps a blue/orange pair apart in every view', () => {
|
|
36
|
+
expect( viewDistance( hexToViews( '#1f4bd0' ), hexToViews( '#dd823b' ) ) ).toBeGreaterThan(
|
|
37
|
+
30
|
|
38
|
+
);
|
|
39
|
+
} );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
describe( 'oklchToHex', () => {
|
|
43
|
+
it( 'converts an in-gamut color', () => {
|
|
44
|
+
expect( oklchToHex( 0.627955, 0.257683, 29.2339 ) ).toBe( '#ff0000' );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'returns null outside the sRGB gamut', () => {
|
|
48
|
+
expect( oklchToHex( 0.9, 0.3, 140 ) ).toBeNull();
|
|
49
|
+
} );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
describe( 'hexToOklch', () => {
|
|
53
|
+
it( 'is the inverse of oklchToHex', () => {
|
|
54
|
+
const { chroma, hue } = hexToOklch( '#ff0000' );
|
|
55
|
+
expect( chroma ).toBeCloseTo( 0.257683, 3 );
|
|
56
|
+
expect( hue ).toBeCloseTo( 29.2339, 1 );
|
|
57
|
+
} );
|
|
58
|
+
|
|
59
|
+
it( 'keeps hue between 0 and 360 for a color whose angle is negative', () => {
|
|
60
|
+
const { hue } = hexToOklch( '#ff00ff' );
|
|
61
|
+
expect( hue ).toBeGreaterThanOrEqual( 0 );
|
|
62
|
+
expect( hue ).toBeLessThan( 360 );
|
|
63
|
+
} );
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
describe( 'contrastRatio', () => {
|
|
67
|
+
it( 'is 21 for black on white, in either order', () => {
|
|
68
|
+
expect( contrastRatio( '#000000', '#ffffff' ) ).toBeCloseTo( 21, 5 );
|
|
69
|
+
expect( contrastRatio( '#ffffff', '#000000' ) ).toBeCloseTo( 21, 5 );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
it( 'is 1 for identical colors', () => {
|
|
73
|
+
expect( contrastRatio( '#3858e9', '#3858e9' ) ).toBe( 1 );
|
|
74
|
+
} );
|
|
75
|
+
} );
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseAsLocalDate } from '../utils/date-parsing';
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// The `jetpack-js-tools` console guard registers the matcher but ships no types for it.
|
|
4
4
|
declare global {
|
|
5
5
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
6
|
namespace jest {
|
package/src/utils/color-utils.ts
CHANGED
|
@@ -52,9 +52,11 @@ export const hexToRgba = ( hex: string, alpha: number ): string => {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* @
|
|
55
|
+
* Weighted Euclidean distance between two HSL colors.
|
|
56
|
+
*
|
|
57
|
+
* @deprecated Not perceptual and blind to color vision deficiency, so charts no longer use it; it will be removed in a future major version.
|
|
58
|
+
* @param hsl1 - first color in HSL format [h, s, l]
|
|
59
|
+
* @param hsl2 - second color in HSL format [h, s, l]
|
|
58
60
|
* @return distance value (0-100+, lower means more similar)
|
|
59
61
|
*/
|
|
60
62
|
export const getColorDistance = (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createDateFormatter, createZonedClock, sanitizeFormatting } from '../date-formatting';
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// The `jetpack-js-tools` console guard registers the matcher but ships no types for it.
|
|
4
4
|
declare global {
|
|
5
5
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
6
|
namespace jest {
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
import { hsl as d3Hsl } from '@visx/vendor/d3-color';
|
|
2
|
-
import { getColorDistance } from '../../../utils';
|
|
3
|
-
|
|
4
|
-
export interface ColorCache {
|
|
5
|
-
colors: string[];
|
|
6
|
-
hues: number[];
|
|
7
|
-
existingHslColors: Array< [ number, number, number ] >;
|
|
8
|
-
minHue: number;
|
|
9
|
-
maxHue: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Golden ratio for mathematically pleasing color distribution
|
|
14
|
-
* Used to generate evenly spaced hues that are visually distinct
|
|
15
|
-
*/
|
|
16
|
-
const GOLDEN_RATIO = 0.618033988749;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Minimum perceptual distance between colors to ensure visual distinction
|
|
20
|
-
* Based on weighted HSL distance calculation optimized for chart readability
|
|
21
|
-
*/
|
|
22
|
-
const MIN_COLOR_DISTANCE = 25;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Maximum attempts to find a sufficiently different color
|
|
26
|
-
* Prevents infinite loops while allowing reasonable search space
|
|
27
|
-
*/
|
|
28
|
-
const MAX_COLOR_GENERATION_ATTEMPTS = 50;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Color variation attempt offset
|
|
32
|
-
* Small increment to explore slightly different color variations per attempt
|
|
33
|
-
*/
|
|
34
|
-
const VARIATION_ATTEMPT_OFFSET = 0.1;
|
|
35
|
-
|
|
36
|
-
// Saturation configuration for generated colors
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Base saturation percentage for generated colors
|
|
40
|
-
* 45% provides muted, professional colors without being washed out
|
|
41
|
-
*/
|
|
42
|
-
const BASE_SATURATION = 45;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Number of saturation variation steps
|
|
46
|
-
* Creates 3 different saturation levels for variety
|
|
47
|
-
*/
|
|
48
|
-
const SATURATION_VARIATION_STEPS = 3;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Saturation increment per variation step
|
|
52
|
-
* 10% increments provide subtle variation while keeping colors muted
|
|
53
|
-
* Results in saturation levels: 45%, 55%, 65%
|
|
54
|
-
*/
|
|
55
|
-
const SATURATION_INCREMENT = 10;
|
|
56
|
-
|
|
57
|
-
// Lightness configuration for WCAG AA accessibility compliance
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Base lightness percentage for generated colors
|
|
61
|
-
* 35% ensures sufficient contrast against white backgrounds for WCAG AA compliance
|
|
62
|
-
* WCAG AA requires 4.5:1 contrast ratio for normal text
|
|
63
|
-
*/
|
|
64
|
-
const BASE_LIGHTNESS = 35;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Number of lightness variation steps
|
|
68
|
-
* Creates 4 different lightness levels for variety
|
|
69
|
-
*/
|
|
70
|
-
const LIGHTNESS_VARIATION_STEPS = 4;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Lightness increment per variation step
|
|
74
|
-
* 8% increments provide subtle lightness variation while maintaining accessibility
|
|
75
|
-
* Results in lightness levels: 35%, 43%, 51%, 59%
|
|
76
|
-
* All levels maintain WCAG AA compliance against white backgrounds
|
|
77
|
-
*/
|
|
78
|
-
const LIGHTNESS_INCREMENT = 8;
|
|
79
|
-
|
|
80
|
-
// Hue range expansion and constraints
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Minimum hue range in degrees to ensure sufficient color variety
|
|
84
|
-
* 60 degrees provides reasonable color spread even for narrow palettes
|
|
85
|
-
*/
|
|
86
|
-
const MIN_HUE_RANGE_DEGREES = 60;
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Hue range expansion factor
|
|
90
|
-
* 1.3x expansion provides slightly more variety than the original palette
|
|
91
|
-
*/
|
|
92
|
-
const HUE_RANGE_EXPANSION_FACTOR = 1.3;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Threshold for detecting hue wrap-around (color wheel boundary crossing)
|
|
96
|
-
* 180 degrees indicates the colors span more than half the color wheel
|
|
97
|
-
*/
|
|
98
|
-
const HUE_WRAP_THRESHOLD_DEGREES = 180;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Full color wheel rotation in degrees
|
|
102
|
-
*/
|
|
103
|
-
const FULL_HUE_ROTATION_DEGREES = 360;
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Factor for single color hue range
|
|
107
|
-
*/
|
|
108
|
-
const SINGLE_COLOR_HUE_RANGE_FACTOR = 0.33;
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Get a color from the colors array or generate a new color using the golden ratio
|
|
112
|
-
*
|
|
113
|
-
* @param index - the index of the color to get
|
|
114
|
-
* @param colorCache - pre-computed color data for performance
|
|
115
|
-
* @return a color from the colors array or a new color using the golden ratio
|
|
116
|
-
*/
|
|
117
|
-
export const getChartColor = ( index: number, colorCache: ColorCache ): string => {
|
|
118
|
-
const {
|
|
119
|
-
colors,
|
|
120
|
-
hues,
|
|
121
|
-
existingHslColors,
|
|
122
|
-
minHue: cachedMinHue,
|
|
123
|
-
maxHue: cachedMaxHue,
|
|
124
|
-
} = colorCache;
|
|
125
|
-
|
|
126
|
-
if ( index < colors.length ) {
|
|
127
|
-
return colors[ index ];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
let minHue = cachedMinHue;
|
|
131
|
-
let maxHue = cachedMaxHue;
|
|
132
|
-
|
|
133
|
-
// Generate additional colors using golden ratio, avoiding similar colors
|
|
134
|
-
for ( let attempt = 0; attempt < MAX_COLOR_GENERATION_ATTEMPTS; attempt++ ) {
|
|
135
|
-
// Calculate hue using golden ratio distribution with variation per attempt
|
|
136
|
-
// Formula: ((base_index + attempt_variation) * golden_ratio * 360°) mod 360°
|
|
137
|
-
// This ensures mathematically pleasing spacing while allowing slight shifts per attempt
|
|
138
|
-
let hue =
|
|
139
|
-
( ( index - colors.length + attempt * VARIATION_ATTEMPT_OFFSET ) *
|
|
140
|
-
GOLDEN_RATIO *
|
|
141
|
-
FULL_HUE_ROTATION_DEGREES ) %
|
|
142
|
-
FULL_HUE_ROTATION_DEGREES;
|
|
143
|
-
|
|
144
|
-
// If we have existing colors, constrain new colors to their hue range
|
|
145
|
-
if ( hues.length > 0 ) {
|
|
146
|
-
// Handle hue wrap-around (e.g., if colors span across 0 degrees)
|
|
147
|
-
let hueRange = maxHue - minHue;
|
|
148
|
-
|
|
149
|
-
// If there's only one color, use a set hue range for limited variety
|
|
150
|
-
if ( hues.length === 1 ) {
|
|
151
|
-
hueRange = FULL_HUE_ROTATION_DEGREES * SINGLE_COLOR_HUE_RANGE_FACTOR;
|
|
152
|
-
} else if ( hueRange > HUE_WRAP_THRESHOLD_DEGREES ) {
|
|
153
|
-
// If the range is very large, it might be wrapping around the color wheel
|
|
154
|
-
// Check if a smaller range exists when considering wrap-around
|
|
155
|
-
const altMinHue = Math.min( ...hues.filter( h => h > HUE_WRAP_THRESHOLD_DEGREES ) );
|
|
156
|
-
const altMaxHue =
|
|
157
|
-
Math.max( ...hues.filter( h => h < HUE_WRAP_THRESHOLD_DEGREES ) ) +
|
|
158
|
-
FULL_HUE_ROTATION_DEGREES;
|
|
159
|
-
const altRange = altMaxHue - altMinHue;
|
|
160
|
-
|
|
161
|
-
if ( altRange < hueRange ) {
|
|
162
|
-
minHue = altMinHue;
|
|
163
|
-
maxHue = altMaxHue;
|
|
164
|
-
hueRange = altRange;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Expand the range slightly to provide some variation
|
|
169
|
-
const expandedRange = Math.max(
|
|
170
|
-
hueRange * HUE_RANGE_EXPANSION_FACTOR,
|
|
171
|
-
MIN_HUE_RANGE_DEGREES
|
|
172
|
-
);
|
|
173
|
-
const rangeCenter = ( minHue + maxHue ) / 2;
|
|
174
|
-
const expandedMin = rangeCenter - expandedRange / 2;
|
|
175
|
-
|
|
176
|
-
// Map the generated hue to the expanded range
|
|
177
|
-
hue = expandedMin + ( hue / FULL_HUE_ROTATION_DEGREES ) * expandedRange;
|
|
178
|
-
|
|
179
|
-
// Normalize to 0-360 range
|
|
180
|
-
hue =
|
|
181
|
-
( ( hue % FULL_HUE_ROTATION_DEGREES ) + FULL_HUE_ROTATION_DEGREES ) %
|
|
182
|
-
FULL_HUE_ROTATION_DEGREES;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const saturation =
|
|
186
|
-
BASE_SATURATION + ( ( index + attempt ) % SATURATION_VARIATION_STEPS ) * SATURATION_INCREMENT;
|
|
187
|
-
const lightness =
|
|
188
|
-
BASE_LIGHTNESS + ( ( index + attempt ) % LIGHTNESS_VARIATION_STEPS ) * LIGHTNESS_INCREMENT;
|
|
189
|
-
|
|
190
|
-
const candidateHsl: [ number, number, number ] = [ hue, saturation, lightness ];
|
|
191
|
-
|
|
192
|
-
// Check if this color is sufficiently different from existing colors
|
|
193
|
-
let isSufficientlyDifferent = true;
|
|
194
|
-
for ( const existingHsl of existingHslColors ) {
|
|
195
|
-
if ( getColorDistance( candidateHsl, existingHsl ) < MIN_COLOR_DISTANCE ) {
|
|
196
|
-
isSufficientlyDifferent = false;
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if ( isSufficientlyDifferent ) {
|
|
202
|
-
// d3-color uses 0-1 scale for saturation and lightness
|
|
203
|
-
return d3Hsl( Math.round( hue ), saturation / 100, lightness / 100 ).formatHex();
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Fallback if we couldn't find a sufficiently different color
|
|
208
|
-
// Formula: ((base_index) * golden_ratio * 360°) mod 360°
|
|
209
|
-
// This ensures mathematically pleasing spacing while maintaining consistency
|
|
210
|
-
const fallbackHue =
|
|
211
|
-
( ( index - colors.length ) * GOLDEN_RATIO * FULL_HUE_ROTATION_DEGREES ) %
|
|
212
|
-
FULL_HUE_ROTATION_DEGREES;
|
|
213
|
-
const fallbackSaturation =
|
|
214
|
-
BASE_SATURATION + ( index % SATURATION_VARIATION_STEPS ) * SATURATION_INCREMENT;
|
|
215
|
-
const fallbackLightness =
|
|
216
|
-
BASE_LIGHTNESS + ( index % LIGHTNESS_VARIATION_STEPS ) * LIGHTNESS_INCREMENT;
|
|
217
|
-
// d3-color uses 0-1 scale for saturation and lightness
|
|
218
|
-
return d3Hsl(
|
|
219
|
-
Math.round( fallbackHue ),
|
|
220
|
-
fallbackSaturation / 100,
|
|
221
|
-
fallbackLightness / 100
|
|
222
|
-
).formatHex();
|
|
223
|
-
};
|