@rafters/color-utils 0.5.4 → 0.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/README.md +1 -1
- package/package.json +5 -5
- package/src/accessibility.ts +6 -1
- package/src/builder.ts +30 -1
- package/src/conversion.ts +20 -0
- package/src/gamut.ts +21 -73
- package/src/harmony.ts +4 -4
- package/src/index.ts +10 -0
- package/src/normalized-scale-constants.ts +66 -0
- package/src/normalized-scale.ts +176 -0
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ everywhere, `p3` needs a wide-gamut screen, `out` is displayable nowhere.
|
|
|
73
73
|
|
|
74
74
|
- `getGamutTier(oklch)` — which tier a color falls in.
|
|
75
75
|
- `isInSRGBGamut(oklch)` / `isInP3Gamut(oklch)` — the two boundary checks.
|
|
76
|
-
- `toNearestGamut(oklch)` — snap an out-of-gamut color into sRGB. Returns
|
|
76
|
+
- `toNearestGamut(oklch)` — snap an out-of-gamut color into sRGB, keeping lightness, hue and alpha; only chroma drops. Returns
|
|
77
77
|
`{ color, tier }`: the snapped OKLCH and the tier it landed in.
|
|
78
78
|
- `computeGamutBoundaries(hue, steps?)` — the boundary points a picker draws
|
|
79
79
|
against.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rafters/color-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "OKLCH color manipulation utilities for Rafters design system",
|
|
5
5
|
"homepage": "https://rafters.studio",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,15 +22,15 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@rafters/shared": "0.6.0",
|
|
25
26
|
"apca-w3": "^0.1.9",
|
|
26
27
|
"colorjs.io": "^0.5.2",
|
|
27
|
-
"zod": "^4.
|
|
28
|
-
"@rafters/shared": "0.5.4"
|
|
28
|
+
"zod": "^4.6.5"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@types/node": "24
|
|
31
|
+
"@types/node": "^24",
|
|
32
32
|
"tsx": "^4.21.0",
|
|
33
|
-
"typescript": "
|
|
33
|
+
"typescript": "^7.0.2",
|
|
34
34
|
"vitest": "^5.0.0",
|
|
35
35
|
"zocker": "3.0.0"
|
|
36
36
|
},
|
package/src/accessibility.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { APCAcontrast, displayP3toY, sRGBtoY } from 'apca-w3';
|
|
|
18
18
|
import Color from 'colorjs.io';
|
|
19
19
|
import { z } from 'zod';
|
|
20
20
|
import { getGamutTier, toGamutCeiling } from './gamut.js';
|
|
21
|
+
import { POSITION_TO_INDEX } from './scale-positions.js';
|
|
21
22
|
|
|
22
23
|
/** White reference color for contrast calculations. */
|
|
23
24
|
export const CONTRAST_WHITE: OKLCH = { l: 1, c: 0, h: 0, alpha: 1 };
|
|
@@ -276,6 +277,8 @@ export function assembleWcagAccessibility(
|
|
|
276
277
|
|
|
277
278
|
/**
|
|
278
279
|
* Re-derive accessibility metadata for a ColorValue whose scale changed.
|
|
280
|
+
* On-white and on-black are measured on the recorded anchor step when the
|
|
281
|
+
* value has one (createNormalizedScale), else on the 600 step.
|
|
279
282
|
* A family value supplied without its WCAG pair matrices would starve the
|
|
280
283
|
* contrast/state/invert selection downstream -- this guard re-bakes them.
|
|
281
284
|
* No-op for values without a scale. (Moved from the removed CLI set command;
|
|
@@ -283,7 +286,9 @@ export function assembleWcagAccessibility(
|
|
|
283
286
|
*/
|
|
284
287
|
export function rebakeAccessibility(value: ColorValue): ColorValue {
|
|
285
288
|
if (!('scale' in value) || !Array.isArray(value.scale)) return value;
|
|
286
|
-
const
|
|
289
|
+
const anchorIndex =
|
|
290
|
+
value.anchor === undefined ? REFERENCE_STEP_INDEX : POSITION_TO_INDEX[value.anchor];
|
|
291
|
+
const reference = value.scale[anchorIndex ?? REFERENCE_STEP_INDEX] ?? value.scale[0];
|
|
287
292
|
if (!reference) return value;
|
|
288
293
|
return { ...value, accessibility: assembleWcagAccessibility(value.scale, reference) };
|
|
289
294
|
}
|
package/src/builder.ts
CHANGED
|
@@ -120,13 +120,42 @@ export function buildColorValue(oklch: OKLCH, options: BuildColorValueOptions =
|
|
|
120
120
|
}
|
|
121
121
|
: undefined;
|
|
122
122
|
|
|
123
|
+
return assembleColorValue(
|
|
124
|
+
oklch,
|
|
125
|
+
{ scale, gamut, seedAdjusted, reference: landed ?? oklch },
|
|
126
|
+
options,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The scale half of a ColorValue, as a builder produced it. */
|
|
131
|
+
export interface BuiltScale {
|
|
132
|
+
scale: OKLCH[];
|
|
133
|
+
gamut: NonNullable<ColorValue['gamut']>;
|
|
134
|
+
seedAdjusted: ColorValue['seedAdjusted'];
|
|
135
|
+
/** The step the on-white and on-black figures are measured on. */
|
|
136
|
+
reference: OKLCH;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Assemble a ColorValue around a built scale: harmonies, accessibility,
|
|
141
|
+
* analysis, weights, semantic suggestions and the name, all from the
|
|
142
|
+
* requested colour. Shared by buildColorValue and createNormalizedScale so
|
|
143
|
+
* both families carry the same intelligence.
|
|
144
|
+
*/
|
|
145
|
+
export function assembleColorValue(
|
|
146
|
+
oklch: OKLCH,
|
|
147
|
+
built: BuiltScale,
|
|
148
|
+
options: BuildColorValueOptions,
|
|
149
|
+
): ColorValue {
|
|
150
|
+
const { scale, gamut, seedAdjusted, reference } = built;
|
|
151
|
+
|
|
123
152
|
// Generate harmonies
|
|
124
153
|
const harmony = generateHarmony(oklch);
|
|
125
154
|
|
|
126
155
|
// WCAG accessibility (pair matrices + on-white/on-black) via the shared
|
|
127
156
|
// assembler, measured on the step the seed landed on (the same step
|
|
128
157
|
// rebakeAccessibility uses); APCA is layered on below, builder-only.
|
|
129
|
-
const wcagAccessibility = assembleWcagAccessibility(scale,
|
|
158
|
+
const wcagAccessibility = assembleWcagAccessibility(scale, reference);
|
|
130
159
|
const apcaOnWhite = calculateAPCAContrast(oklch, CONTRAST_WHITE);
|
|
131
160
|
const apcaOnBlack = calculateAPCAContrast(oklch, CONTRAST_BLACK);
|
|
132
161
|
|
package/src/conversion.ts
CHANGED
|
@@ -90,3 +90,23 @@ export function roundOKLCH(oklch: OKLCH): OKLCH {
|
|
|
90
90
|
alpha: oklch.alpha !== undefined ? Math.round(oklch.alpha * 100) / 100 : 1,
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Convert an in-gamut OKLCH colour to sRGB: gamma-encoded components in
|
|
96
|
+
* [0, 1] (clamped, so a colour on the sRGB boundary cannot round outside it)
|
|
97
|
+
* and a six-digit hex. Map an out-of-gamut colour first, with
|
|
98
|
+
* toGamutCeiling(oklch, 'srgb'); alpha is not part of the result.
|
|
99
|
+
*/
|
|
100
|
+
export function oklchToSRGB(oklch: OKLCH): { components: [number, number, number]; hex: string } {
|
|
101
|
+
const coords = new Color('oklch', [oklch.l, oklch.c, oklch.h]).to('srgb').coords;
|
|
102
|
+
const channel = (i: number): number => Math.min(1, Math.max(0, Number(coords[i] ?? 0) || 0));
|
|
103
|
+
const components: [number, number, number] = [channel(0), channel(1), channel(2)];
|
|
104
|
+
const hex = `#${components
|
|
105
|
+
.map((v) =>
|
|
106
|
+
Math.round(v * 255)
|
|
107
|
+
.toString(16)
|
|
108
|
+
.padStart(2, '0'),
|
|
109
|
+
)
|
|
110
|
+
.join('')}`;
|
|
111
|
+
return { components, hex };
|
|
112
|
+
}
|
package/src/gamut.ts
CHANGED
|
@@ -45,33 +45,17 @@ export function getGamutTier(oklch: OKLCH): GamutTier {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Snap an OKLCH color
|
|
49
|
-
*
|
|
48
|
+
* Snap an OKLCH color into sRGB, keeping lightness, hue and alpha: the
|
|
49
|
+
* largest chroma sRGB holds there, as toGamutCeiling(oklch, 'srgb').
|
|
50
50
|
*/
|
|
51
51
|
export function toNearestGamut(oklch: OKLCH): { color: OKLCH; tier: GamutTier } {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (color.inGamut('srgb')) {
|
|
55
|
-
return { color: { ...oklch }, tier: 'srgb' };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const srgbMapped = color.toGamut({ space: 'srgb' });
|
|
59
|
-
const mappedOklch = srgbMapped.to('oklch');
|
|
60
|
-
return {
|
|
61
|
-
color: {
|
|
62
|
-
l: mappedOklch.coords[0] ?? 0,
|
|
63
|
-
c: mappedOklch.coords[1] ?? 0,
|
|
64
|
-
h: mappedOklch.coords[2] ?? oklch.h,
|
|
65
|
-
alpha: oklch.alpha,
|
|
66
|
-
},
|
|
67
|
-
tier: 'srgb',
|
|
68
|
-
};
|
|
52
|
+
return { color: toGamutCeiling(oklch, 'srgb'), tier: 'srgb' };
|
|
69
53
|
}
|
|
70
54
|
|
|
71
55
|
/**
|
|
72
|
-
* Whether Display P3 steps are allowed in a family.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
56
|
+
* Whether Display P3 steps are allowed in a family. One constant, off by
|
|
57
|
+
* default, so every family is built in sRGB until a setting exists to turn it
|
|
58
|
+
* on.
|
|
75
59
|
*/
|
|
76
60
|
export const ALLOW_P3 = false;
|
|
77
61
|
|
|
@@ -84,18 +68,11 @@ function inCeiling(oklch: OKLCH, ceiling: GamutCeiling): boolean {
|
|
|
84
68
|
|
|
85
69
|
/**
|
|
86
70
|
* Map one colour into a gamut ceiling: unchanged when already inside, else
|
|
87
|
-
*
|
|
71
|
+
* the same lightness and hue at the largest chroma the ceiling holds there.
|
|
88
72
|
*/
|
|
89
73
|
export function toGamutCeiling(oklch: OKLCH, ceiling: GamutCeiling): OKLCH {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const [l, c, h] = color.toGamut({ space: ceiling }).to('oklch').coords;
|
|
93
|
-
return {
|
|
94
|
-
l: l ?? oklch.l,
|
|
95
|
-
c: c ?? 0,
|
|
96
|
-
h: h === undefined || Number.isNaN(h) ? oklch.h : h,
|
|
97
|
-
alpha: oklch.alpha,
|
|
98
|
-
};
|
|
74
|
+
if (inCeiling(oklch, ceiling)) return { ...oklch };
|
|
75
|
+
return { ...oklch, c: Math.min(oklch.c, findMaxChroma(oklch.l, oklch.h, ceiling)) };
|
|
99
76
|
}
|
|
100
77
|
|
|
101
78
|
/**
|
|
@@ -113,46 +90,17 @@ function roundInside(oklch: OKLCH, ceiling: GamutCeiling): OKLCH {
|
|
|
113
90
|
return { ...rounded, c: Math.max(0, c) };
|
|
114
91
|
}
|
|
115
92
|
|
|
116
|
-
/** Keep the step's lightness and hue; reduce chroma only, to the ceiling's edge. */
|
|
117
|
-
function reduceChromaOnly(oklch: OKLCH, ceiling: GamutCeiling): OKLCH {
|
|
118
|
-
const maxC = findMaxChroma(oklch.l, oklch.h, ceiling);
|
|
119
|
-
return roundInside({ ...oklch, c: Math.min(oklch.c, maxC) }, ceiling);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
93
|
/**
|
|
123
|
-
* Map every step of a scale into the ceiling, keeping lightness
|
|
94
|
+
* Map every step of a scale into the ceiling, keeping lightness and hue.
|
|
124
95
|
*
|
|
125
|
-
* A step already inside the ceiling is not moved. A step outside
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* only. Steps already inside are never re-marked, so the loop ends: at worst
|
|
129
|
-
* every outside step keeps its lightness, and the input order holds.
|
|
96
|
+
* A step already inside the ceiling is not moved. A step outside keeps its
|
|
97
|
+
* lightness and hue and loses chroma only, to the ceiling's edge, so the
|
|
98
|
+
* scale's lightness order is the input's.
|
|
130
99
|
*/
|
|
131
100
|
export function mapScaleToGamut(scale: readonly OKLCH[], ceiling: GamutCeiling): OKLCH[] {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (chromaOnly.has(i)) return reduceChromaOnly(step, ceiling);
|
|
136
|
-
return roundInside(toGamutCeiling(step, ceiling), ceiling);
|
|
137
|
-
};
|
|
138
|
-
let mapped = scale.map(mapStep);
|
|
139
|
-
for (;;) {
|
|
140
|
-
let marked = false;
|
|
141
|
-
for (let i = 1; i < mapped.length; i++) {
|
|
142
|
-
const prev = mapped[i - 1];
|
|
143
|
-
const cur = mapped[i];
|
|
144
|
-
if (prev === undefined || cur === undefined || cur.l < prev.l) continue;
|
|
145
|
-
for (const j of [i - 1, i]) {
|
|
146
|
-
const original = scale[j];
|
|
147
|
-
if (original && !chromaOnly.has(j) && !inCeiling(original, ceiling)) {
|
|
148
|
-
chromaOnly.add(j);
|
|
149
|
-
marked = true;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (!marked) return mapped;
|
|
154
|
-
mapped = scale.map(mapStep);
|
|
155
|
-
}
|
|
101
|
+
return scale.map((step) =>
|
|
102
|
+
inCeiling(step, ceiling) ? { ...step } : roundInside(toGamutCeiling(step, ceiling), ceiling),
|
|
103
|
+
);
|
|
156
104
|
}
|
|
157
105
|
|
|
158
106
|
/**
|
|
@@ -172,12 +120,12 @@ export function storedTier(oklch: OKLCH): GamutCeiling {
|
|
|
172
120
|
}
|
|
173
121
|
|
|
174
122
|
/**
|
|
175
|
-
* Snap an OKLCH into sRGB and round it -- the shape every
|
|
176
|
-
* emitted in. Lives here because both halves are
|
|
177
|
-
* harmony.ts and semantic.ts are callers, not owners.
|
|
123
|
+
* Snap an OKLCH into sRGB and round it inside sRGB -- the shape every
|
|
124
|
+
* generated color is emitted in. Lives here because both halves are
|
|
125
|
+
* gamut/conversion concerns; harmony.ts and semantic.ts are callers, not owners.
|
|
178
126
|
*/
|
|
179
127
|
export function clampColor(color: OKLCH): OKLCH {
|
|
180
|
-
return
|
|
128
|
+
return roundInside(toNearestGamut(color).color, 'srgb');
|
|
181
129
|
}
|
|
182
130
|
|
|
183
131
|
const DEFAULT_STEPS = 101;
|
|
@@ -196,7 +144,7 @@ const TOLERANCE = 0.001;
|
|
|
196
144
|
* against 0.0227 at h=145, l=0.05. `inGamut` is the predicate this function
|
|
197
145
|
* actually needs, and bisecting it is the only way to ask where it flips.
|
|
198
146
|
*/
|
|
199
|
-
function findMaxChroma(l: number, h: number, gamutSpace: string): number {
|
|
147
|
+
export function findMaxChroma(l: number, h: number, gamutSpace: string): number {
|
|
200
148
|
let lo = 0;
|
|
201
149
|
let hi = MAX_CHROMA;
|
|
202
150
|
if (l <= 0 || l >= 1) return 0;
|
package/src/harmony.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Harmony generation for design systems using pure OKLCH hue rotation.
|
|
3
3
|
*
|
|
4
4
|
* Rule: harmonies are PURE HUE ROTATIONS. L and C are never mutated.
|
|
5
|
-
* Only H changes. Every harmony output goes through
|
|
6
|
-
*
|
|
7
|
-
* steps, and buildColorValue maps them
|
|
8
|
-
* mapScaleToGamut (gamut.ts).
|
|
5
|
+
* Only H changes. Every harmony output goes through clampColor(), which
|
|
6
|
+
* drops chroma into sRGB and rounds inside it. The scale is different:
|
|
7
|
+
* generateOKLCHScale returns unmapped steps, and buildColorValue maps them
|
|
8
|
+
* into the family's ceiling with mapScaleToGamut (gamut.ts).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ColorHarmonies, OKLCH } from '@rafters/shared';
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export {
|
|
|
37
37
|
hexToOKLCH,
|
|
38
38
|
type OklchToCSSOptions,
|
|
39
39
|
oklchToCSS,
|
|
40
|
+
oklchToSRGB,
|
|
40
41
|
roundOKLCH,
|
|
41
42
|
tryParseColor,
|
|
42
43
|
} from './conversion.js';
|
|
@@ -59,6 +60,15 @@ export {
|
|
|
59
60
|
// Harmony
|
|
60
61
|
export { generateHarmony, generateOKLCHScale } from './harmony.js';
|
|
61
62
|
|
|
63
|
+
// Normalized scale (#2872)
|
|
64
|
+
export { createNormalizedScale } from './normalized-scale.js';
|
|
65
|
+
export {
|
|
66
|
+
CHROMA_SHARE,
|
|
67
|
+
HUE_DRIFT_BANDS,
|
|
68
|
+
HUE_DRIFT_SAMPLES,
|
|
69
|
+
LIGHTNESS_LADDERS,
|
|
70
|
+
} from './normalized-scale-constants.js';
|
|
71
|
+
|
|
62
72
|
// Manipulation
|
|
63
73
|
export { adjustHue } from './manipulation.js';
|
|
64
74
|
// Naming
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measured constants for createNormalizedScale.
|
|
3
|
+
*
|
|
4
|
+
* Source: the hand-tuned palettes in platform's colour seed
|
|
5
|
+
* (apps/api/seed/colors.json) -- Tailwind, Material, Open Color, Ant and
|
|
6
|
+
* Chakra -- 66 chromatic palettes, each interpolated to 11 positions, median
|
|
7
|
+
* across palettes, measured 2026-10-26. Radix is excluded: its light and dark
|
|
8
|
+
* scales are mixed in the data. Every array runs lightest (50) to darkest (950).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Step lightness, by ladder.
|
|
13
|
+
* deep: Tailwind, Ant and Chakra, 38 palettes (17, 12 and 9).
|
|
14
|
+
* compact: Material and Open Color, 28 palettes (16 and 12).
|
|
15
|
+
*/
|
|
16
|
+
export const LIGHTNESS_LADDERS = {
|
|
17
|
+
deep: [0.978, 0.942, 0.896, 0.827, 0.74, 0.67, 0.593, 0.52, 0.451, 0.392, 0.292],
|
|
18
|
+
compact: [0.964, 0.925, 0.874, 0.811, 0.762, 0.719, 0.677, 0.644, 0.6, 0.543, 0.49],
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Step chroma as a share of the sRGB chroma maximum at the step's lightness
|
|
23
|
+
* and hue.
|
|
24
|
+
* gamut: the maximum itself, by definition.
|
|
25
|
+
* measured: all five systems, 66 palettes.
|
|
26
|
+
*/
|
|
27
|
+
export const CHROMA_SHARE = {
|
|
28
|
+
gamut: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
|
29
|
+
measured: [0.97, 0.95, 0.92, 0.9, 0.94, 0.95, 0.97, 0.97, 0.97, 0.92, 0.94],
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hue drift in degrees, relative to the palette's middle step (500), keyed by
|
|
34
|
+
* band centre hue (0 = red, 60 = amber, 90 = yellow, ... 330). All five
|
|
35
|
+
* systems, 66 palettes; the count per band is HUE_DRIFT_SAMPLES.
|
|
36
|
+
*/
|
|
37
|
+
export const HUE_DRIFT_BANDS = {
|
|
38
|
+
0: [-10.0, -9.1, -6.6, -4.6, -2.8, 0.0, -0.8, -2.2, -3.4, -4.6, -7.0],
|
|
39
|
+
30: [-7.0, -4.0, -5.4, -3.8, -2.4, 0.0, 0.0, -0.4, -1.3, -2.1, -2.5],
|
|
40
|
+
60: [25.5, 25.6, 22.4, 17.8, 10.2, 3.2, -2.4, -7.6, -11.1, -13.8, -16.0],
|
|
41
|
+
90: [11.0, 10.1, 10.0, 8.6, 6.0, 1.5, -3.6, -10.3, -17.6, -24.8, -32.0],
|
|
42
|
+
120: [-9.0, -7.2, -6.2, -4.0, -2.0, -0.5, 0.4, 1.0, 2.0, 2.0, 2.0],
|
|
43
|
+
150: [4.0, 1.6, 1.6, 1.2, 0.7, 0.0, 0.2, 0.6, 1.5, 2.5, 2.0],
|
|
44
|
+
180: [3.5, 2.6, 1.2, 1.2, 0.9, 0.5, 0.4, 1.0, 1.9, 1.5, 4.5],
|
|
45
|
+
210: [-10.0, -9.3, -8.4, -6.6, -3.4, -1.5, 0.4, 0.4, 1.2, 2.2, 4.0],
|
|
46
|
+
240: [-10.0, -8.1, -6.4, -7.0, -4.0, -1.0, 0.8, 2.6, 4.4, 6.1, 7.0],
|
|
47
|
+
270: [-4.0, -3.5, -2.2, -0.8, 0.5, 0.2, 0.2, 0.4, 0.0, 1.0, 2.0],
|
|
48
|
+
300: [7.0, 7.0, 6.2, 4.6, 2.6, 0.5, -1.2, -2.3, -2.5, -2.5, -2.5],
|
|
49
|
+
330: [-2.0, -0.4, 0.0, 0.7, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0],
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/** Palettes per hue band behind HUE_DRIFT_BANDS (66 in all); 330 (pink) rests on 3. */
|
|
53
|
+
export const HUE_DRIFT_SAMPLES = {
|
|
54
|
+
0: 5,
|
|
55
|
+
30: 8,
|
|
56
|
+
60: 6,
|
|
57
|
+
90: 7,
|
|
58
|
+
120: 5,
|
|
59
|
+
150: 6,
|
|
60
|
+
180: 4,
|
|
61
|
+
210: 5,
|
|
62
|
+
240: 5,
|
|
63
|
+
270: 6,
|
|
64
|
+
300: 6,
|
|
65
|
+
330: 3,
|
|
66
|
+
} as const;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized scale builder.
|
|
3
|
+
*
|
|
4
|
+
* A second way to build a colour family, beside buildColorValue: step
|
|
5
|
+
* lightness from a measured ladder bent through the source, step chroma at
|
|
6
|
+
* the source's own share of what sRGB holds, and step hue drifting toward
|
|
7
|
+
* both ends the way hand-tuned systems drift it. The constants are in normalized-scale-constants.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ColorValue, OKLCH } from '@rafters/shared';
|
|
11
|
+
import { assembleColorValue } from './builder.js';
|
|
12
|
+
import { findMaxChroma, isInSRGBGamut, storedTier } from './gamut.js';
|
|
13
|
+
import { normalizeHue } from './internal/math.js';
|
|
14
|
+
import { CHROMA_SHARE, HUE_DRIFT_BANDS, LIGHTNESS_LADDERS } from './normalized-scale-constants.js';
|
|
15
|
+
import { SCALE_POSITIONS } from './scale-positions.js';
|
|
16
|
+
|
|
17
|
+
/** An option createNormalizedScale cannot build from. */
|
|
18
|
+
export class NormalizedScaleError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
public readonly option: 'lightness' | 'chroma' | 'hue',
|
|
21
|
+
message: string,
|
|
22
|
+
) {
|
|
23
|
+
super(`createNormalizedScale: ${option} ${message}`);
|
|
24
|
+
this.name = 'NormalizedScaleError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STEP_COUNT = SCALE_POSITIONS.length;
|
|
29
|
+
const BAND_WIDTH = 30;
|
|
30
|
+
|
|
31
|
+
function checkLength(option: 'lightness' | 'chroma' | 'hue', values: readonly number[]): void {
|
|
32
|
+
if (values.length !== STEP_COUNT) {
|
|
33
|
+
throw new NormalizedScaleError(option, `must have ${STEP_COUNT} entries, got ${values.length}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveLadder(lightness: number[] | 'deep' | 'compact'): readonly number[] {
|
|
38
|
+
if (typeof lightness === 'string') return LIGHTNESS_LADDERS[lightness];
|
|
39
|
+
checkLength('lightness', lightness);
|
|
40
|
+
for (let i = 1; i < lightness.length; i++) {
|
|
41
|
+
const prev = lightness[i - 1];
|
|
42
|
+
const cur = lightness[i];
|
|
43
|
+
if (prev === undefined || cur === undefined || cur >= prev) {
|
|
44
|
+
throw new NormalizedScaleError('lightness', 'must be strictly decreasing');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return lightness;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveShare(chroma: number[] | 'gamut' | 'measured'): readonly number[] {
|
|
51
|
+
if (typeof chroma === 'string') return CHROMA_SHARE[chroma];
|
|
52
|
+
checkLength('chroma', chroma);
|
|
53
|
+
return chroma;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DRIFT_BY_BAND: Readonly<Record<number, readonly number[]>> = HUE_DRIFT_BANDS;
|
|
57
|
+
|
|
58
|
+
function bandDrift(band: number): readonly number[] {
|
|
59
|
+
return DRIFT_BY_BAND[normalizeHue(band)] ?? HUE_DRIFT_BANDS[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The measured drift for a hue, interpolated between the two nearest bands. */
|
|
63
|
+
function measuredDrift(hue: number): number[] {
|
|
64
|
+
const h = normalizeHue(hue);
|
|
65
|
+
const lower = Math.floor(h / BAND_WIDTH) * BAND_WIDTH;
|
|
66
|
+
const t = (h - lower) / BAND_WIDTH;
|
|
67
|
+
const lo = bandDrift(lower);
|
|
68
|
+
const hi = bandDrift(lower + BAND_WIDTH);
|
|
69
|
+
return lo.map((d, i) => d * (1 - t) + (hi[i] ?? d) * t);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveDrift(hue: number[] | 'measured' | 'none', sourceHue: number): readonly number[] {
|
|
73
|
+
if (hue === 'none') return Array.from({ length: STEP_COUNT }, () => 0);
|
|
74
|
+
if (hue === 'measured') return measuredDrift(sourceHue);
|
|
75
|
+
checkLength('hue', hue);
|
|
76
|
+
return hue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The step whose ladder lightness is nearest the source's own. */
|
|
80
|
+
function nearestStep(ladder: readonly number[], l: number): number {
|
|
81
|
+
let best = 0;
|
|
82
|
+
for (let i = 1; i < ladder.length; i++) {
|
|
83
|
+
const cur = ladder[i];
|
|
84
|
+
const bestL = ladder[best];
|
|
85
|
+
if (cur !== undefined && bestL !== undefined && Math.abs(cur - l) < Math.abs(bestL - l)) {
|
|
86
|
+
best = i;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return best;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Step lightness: the ladder bent toward the pin. Steps lighter than the
|
|
94
|
+
* anchor are re-spaced between the ladder's top and the pin, steps darker
|
|
95
|
+
* between the pin and the ladder's bottom, so the run has no gap.
|
|
96
|
+
*/
|
|
97
|
+
function bendLadder(ladder: readonly number[], anchor: number, pin: number): number[] {
|
|
98
|
+
const top = ladder[0] ?? pin;
|
|
99
|
+
const bottom = ladder[ladder.length - 1] ?? pin;
|
|
100
|
+
const at = ladder[anchor] ?? pin;
|
|
101
|
+
return ladder.map((lad, k) => {
|
|
102
|
+
if (k === anchor) return pin;
|
|
103
|
+
if (k < anchor) return top + ((pin - top) * (lad - top)) / (at - top);
|
|
104
|
+
return pin + ((bottom - pin) * (lad - at)) / (bottom - at);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Chroma floored to the stored precision, so a step at the sRGB edge stays inside. */
|
|
109
|
+
function floorChroma(c: number): number {
|
|
110
|
+
return Math.floor(c * 1000) / 1000;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Chroma reduction into sRGB: a step outside keeps its lightness and hue
|
|
115
|
+
* exactly and takes the sRGB chroma maximum there.
|
|
116
|
+
*/
|
|
117
|
+
function reduceIntoSRGB(step: OKLCH): OKLCH {
|
|
118
|
+
if (isInSRGBGamut(step)) return step;
|
|
119
|
+
return { ...step, c: floorChroma(findMaxChroma(step.l, step.h, 'srgb')) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Lightness at which every hue has room, for the source's chroma share. */
|
|
123
|
+
const SHARE_REFERENCE_L = 0.6;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a ColorValue on a normalized 11-step scale.
|
|
127
|
+
*
|
|
128
|
+
* The source is the pin, exactly as typed, at the step whose ladder lightness
|
|
129
|
+
* is nearest its own (the anchor). The other steps take the ladder bent
|
|
130
|
+
* toward the pin, the source's share of the sRGB chroma maximum (at L 0.6)
|
|
131
|
+
* times the chroma curve times the maximum at the step, and the source hue
|
|
132
|
+
* plus the drift relative to the anchor. A step outside sRGB, the anchor
|
|
133
|
+
* included, loses chroma only. Harmonies, accessibility, analysis and naming
|
|
134
|
+
* are buildColorValue's.
|
|
135
|
+
*
|
|
136
|
+
* @throws NormalizedScaleError when an array option does not have 11 entries,
|
|
137
|
+
* or a lightness array is not strictly decreasing.
|
|
138
|
+
*/
|
|
139
|
+
export function createNormalizedScale(
|
|
140
|
+
source: OKLCH,
|
|
141
|
+
options: {
|
|
142
|
+
lightness?: number[] | 'deep' | 'compact';
|
|
143
|
+
chroma?: number[] | 'gamut' | 'measured';
|
|
144
|
+
hue?: number[] | 'measured' | 'none';
|
|
145
|
+
} = {},
|
|
146
|
+
): ColorValue {
|
|
147
|
+
const lightness = options.lightness ?? 'deep';
|
|
148
|
+
const ladder = resolveLadder(lightness);
|
|
149
|
+
const curve = resolveShare(options.chroma ?? 'measured');
|
|
150
|
+
const drift = resolveDrift(options.hue ?? 'measured', source.h);
|
|
151
|
+
|
|
152
|
+
const anchor = nearestStep(ladder, source.l);
|
|
153
|
+
const anchorDrift = drift[anchor] ?? 0;
|
|
154
|
+
const stepL = bendLadder(ladder, anchor, source.l);
|
|
155
|
+
const referenceMax = findMaxChroma(SHARE_REFERENCE_L, source.h, 'srgb');
|
|
156
|
+
const seedShare = referenceMax > 0 ? Math.min(1, source.c / referenceMax) : 0;
|
|
157
|
+
|
|
158
|
+
const scale = stepL.map((rawL, i): OKLCH => {
|
|
159
|
+
if (i === anchor) return reduceIntoSRGB({ ...source });
|
|
160
|
+
const l = Math.round(rawL * 1000) / 1000;
|
|
161
|
+
const h = Math.round(normalizeHue(source.h + (drift[i] ?? 0) - anchorDrift));
|
|
162
|
+
const c = floorChroma(seedShare * (curve[i] ?? 0) * findMaxChroma(l, h, 'srgb'));
|
|
163
|
+
return reduceIntoSRGB({ l, c, h, alpha: source.alpha ?? 1 });
|
|
164
|
+
});
|
|
165
|
+
const gamut = scale.map(storedTier);
|
|
166
|
+
|
|
167
|
+
const landed = scale[anchor] ?? source;
|
|
168
|
+
const seedAdjusted: ColorValue['seedAdjusted'] =
|
|
169
|
+
landed.c !== source.c ? { from: { ...source }, to: { ...landed }, reason: 'gamut' } : undefined;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
...assembleColorValue(source, { scale, gamut, seedAdjusted, reference: landed }, {}),
|
|
173
|
+
scaleStyle: typeof lightness === 'string' ? lightness : 'custom',
|
|
174
|
+
anchor: SCALE_POSITIONS[anchor],
|
|
175
|
+
};
|
|
176
|
+
}
|