@luntta/swatch 3.0.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 +56 -0
- package/CONTRIBUTING.md +89 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +189 -0
- package/README.md +463 -0
- package/package.json +57 -0
- package/scripts/pack-check.mjs +18 -0
- package/src/bootstrap.js +159 -0
- package/src/core/registry.js +81 -0
- package/src/core/state.js +36 -0
- package/src/core/swatch-class.js +524 -0
- package/src/data/cvd-matrices.js +179 -0
- package/src/data/named-colors.js +157 -0
- package/src/format/css.js +256 -0
- package/src/operations/accessibility.js +103 -0
- package/src/operations/apca.js +80 -0
- package/src/operations/blend.js +72 -0
- package/src/operations/channels.js +123 -0
- package/src/operations/cvd.js +119 -0
- package/src/operations/deltaE.js +207 -0
- package/src/operations/gamut.js +206 -0
- package/src/operations/image.js +192 -0
- package/src/operations/manipulation.js +100 -0
- package/src/operations/mix.js +129 -0
- package/src/operations/naming.js +158 -0
- package/src/operations/palette.js +133 -0
- package/src/operations/random.js +75 -0
- package/src/operations/temperature.js +126 -0
- package/src/operations/tint-shade.js +42 -0
- package/src/palettes/colorbrewer.js +232 -0
- package/src/palettes/index.js +58 -0
- package/src/palettes/viridis.js +59 -0
- package/src/parse/css.js +241 -0
- package/src/parse/hex.js +38 -0
- package/src/parse/index.js +43 -0
- package/src/parse/legacy.js +88 -0
- package/src/parse/named.js +11 -0
- package/src/parse/objects.js +125 -0
- package/src/scale/index.js +382 -0
- package/src/scale/interpolators.js +83 -0
- package/src/spaces/a98.js +55 -0
- package/src/spaces/cmyk.js +75 -0
- package/src/spaces/display-p3.js +50 -0
- package/src/spaces/hsl.js +93 -0
- package/src/spaces/hsluv.js +211 -0
- package/src/spaces/hsv.js +78 -0
- package/src/spaces/hwb.js +48 -0
- package/src/spaces/lab.js +70 -0
- package/src/spaces/lch.js +65 -0
- package/src/spaces/oklab.js +79 -0
- package/src/spaces/oklch.js +53 -0
- package/src/spaces/prophoto.js +72 -0
- package/src/spaces/rec2020.js +65 -0
- package/src/spaces/srgb.js +85 -0
- package/src/spaces/xyz.js +71 -0
- package/src/swatch.js +57 -0
- package/src/util/math.js +53 -0
- package/src/util/matrix.js +92 -0
- package/src/util/suggest.js +66 -0
- package/types/swatch.d.ts +664 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Gamut detection and mapping.
|
|
2
|
+
//
|
|
3
|
+
// `inGamut(swatch, spaceId)` reports whether the color fits inside the
|
|
4
|
+
// [0, 1] cube of the target display RGB space. Only gamut-bounded spaces
|
|
5
|
+
// are meaningful; for unbounded spaces (Lab, OKLab, XYZ, polar forms) the
|
|
6
|
+
// function always returns `true`.
|
|
7
|
+
//
|
|
8
|
+
// `toGamut(swatch, opts)` maps an out-of-gamut color into the target
|
|
9
|
+
// space. Two methods:
|
|
10
|
+
//
|
|
11
|
+
// 'clip' — convert to target and clamp each channel. Fast, but
|
|
12
|
+
// shifts hue in a perceptually unfriendly way.
|
|
13
|
+
// 'css4' — the CSS Color 4 binary chroma reduction: hold OKLCh L
|
|
14
|
+
// and H fixed, binary-search C downward until the clipped
|
|
15
|
+
// result is within ΔEOK < 0.02 of the candidate. Hue is
|
|
16
|
+
// preserved, lightness drifts only minimally.
|
|
17
|
+
//
|
|
18
|
+
// 'oklch-chroma' is an alias for 'css4'.
|
|
19
|
+
|
|
20
|
+
import { Swatch } from "../core/swatch-class.js";
|
|
21
|
+
import { getSpace } from "../core/registry.js";
|
|
22
|
+
import { clamp } from "../util/math.js";
|
|
23
|
+
import { appendSuggestion } from "../util/suggest.js";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_EPSILON = 1e-5;
|
|
26
|
+
const JND = 0.02;
|
|
27
|
+
const SEARCH_EPSILON = 1e-4;
|
|
28
|
+
|
|
29
|
+
// The set of display RGB spaces whose natural coord domain is [0, 1]^3.
|
|
30
|
+
// Phase 7 extends this list when display-p3, rec2020, a98, and prophoto
|
|
31
|
+
// register.
|
|
32
|
+
const RGB_GAMUT_SPACES = new Set([
|
|
33
|
+
"srgb",
|
|
34
|
+
"srgb-linear",
|
|
35
|
+
"display-p3",
|
|
36
|
+
"rec2020",
|
|
37
|
+
"a98",
|
|
38
|
+
"prophoto"
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function isGamutBounded(spaceId) {
|
|
42
|
+
return RGB_GAMUT_SPACES.has(spaceId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function inGamut(swatch, spaceId = "srgb", opts = {}) {
|
|
46
|
+
if (!isGamutBounded(spaceId)) return true;
|
|
47
|
+
const epsilon = opts.epsilon ?? DEFAULT_EPSILON;
|
|
48
|
+
const coords = swatch._getCoordsIn(spaceId);
|
|
49
|
+
for (let i = 0; i < 3; i++) {
|
|
50
|
+
if (coords[i] < -epsilon) return false;
|
|
51
|
+
if (coords[i] > 1 + epsilon) return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clipCoords(coords) {
|
|
57
|
+
return [clamp(coords[0], 0, 1), clamp(coords[1], 0, 1), clamp(coords[2], 0, 1)];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deltaEOK(lab1, lab2) {
|
|
61
|
+
const dL = lab1[0] - lab2[0];
|
|
62
|
+
const da = lab1[1] - lab2[1];
|
|
63
|
+
const db = lab1[2] - lab2[2];
|
|
64
|
+
return Math.sqrt(dL * dL + da * da + db * db);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Convert an (L, C, H) OKLCh triple to a target RGB space's coords.
|
|
68
|
+
function oklchToTarget(L, C, H, targetId) {
|
|
69
|
+
const oklch = getSpace("oklch");
|
|
70
|
+
const target = getSpace(targetId);
|
|
71
|
+
// Shortcut if oklch has one, else via XYZ.
|
|
72
|
+
if (oklch.shortcuts[targetId]) {
|
|
73
|
+
return oklch.shortcuts[targetId]([L, C, H]);
|
|
74
|
+
}
|
|
75
|
+
return target.fromXYZ(oklch.toXYZ([L, C, H]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Convert an RGB-space triple to OKLab coords (for ΔEOK).
|
|
79
|
+
function targetToOklab(coords, targetId) {
|
|
80
|
+
const target = getSpace(targetId);
|
|
81
|
+
const oklab = getSpace("oklab");
|
|
82
|
+
if (target.shortcuts.oklab) {
|
|
83
|
+
return target.shortcuts.oklab(coords);
|
|
84
|
+
}
|
|
85
|
+
return oklab.fromXYZ(target.toXYZ(coords));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function oklchToOklab(L, C, H) {
|
|
89
|
+
const oklch = getSpace("oklch");
|
|
90
|
+
if (oklch.shortcuts.oklab) {
|
|
91
|
+
return oklch.shortcuts.oklab([L, C, H]);
|
|
92
|
+
}
|
|
93
|
+
const oklab = getSpace("oklab");
|
|
94
|
+
return oklab.fromXYZ(oklch.toXYZ([L, C, H]));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clipInto(swatch, targetId) {
|
|
98
|
+
const coords = swatch._getCoordsIn(targetId);
|
|
99
|
+
return new Swatch({
|
|
100
|
+
space: targetId,
|
|
101
|
+
coords: clipCoords(coords),
|
|
102
|
+
alpha: swatch.alpha
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function toGamutCss4(swatch, targetId) {
|
|
107
|
+
// Convert to OKLCh to read L and H.
|
|
108
|
+
const originOklch = swatch._getCoordsIn("oklch");
|
|
109
|
+
const L = originOklch[0];
|
|
110
|
+
const originC = originOklch[1];
|
|
111
|
+
const H = originOklch[2];
|
|
112
|
+
|
|
113
|
+
// Edge cases: pure white / pure black.
|
|
114
|
+
if (L >= 1 - 1e-12) {
|
|
115
|
+
return new Swatch({ space: targetId, coords: [1, 1, 1], alpha: swatch.alpha });
|
|
116
|
+
}
|
|
117
|
+
if (L <= 1e-12) {
|
|
118
|
+
return new Swatch({ space: targetId, coords: [0, 0, 0], alpha: swatch.alpha });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Quick-return if the clipped origin is already perceptually close.
|
|
122
|
+
const originTarget = swatch._getCoordsIn(targetId);
|
|
123
|
+
const originClipped = clipCoords(originTarget);
|
|
124
|
+
const originClippedOklab = targetToOklab(originClipped, targetId);
|
|
125
|
+
const originOklab = oklchToOklab(L, originC, H);
|
|
126
|
+
if (deltaEOK(originClippedOklab, originOklab) < JND) {
|
|
127
|
+
return new Swatch({
|
|
128
|
+
space: targetId,
|
|
129
|
+
coords: originClipped,
|
|
130
|
+
alpha: swatch.alpha
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Binary search chroma.
|
|
135
|
+
let min = 0;
|
|
136
|
+
let max = originC;
|
|
137
|
+
let minInGamut = true;
|
|
138
|
+
let lastClipped = originClipped;
|
|
139
|
+
|
|
140
|
+
while (max - min > SEARCH_EPSILON) {
|
|
141
|
+
const chroma = (min + max) / 2;
|
|
142
|
+
const candidate = oklchToTarget(L, chroma, H, targetId);
|
|
143
|
+
|
|
144
|
+
const candidateInGamut =
|
|
145
|
+
candidate[0] >= -DEFAULT_EPSILON &&
|
|
146
|
+
candidate[0] <= 1 + DEFAULT_EPSILON &&
|
|
147
|
+
candidate[1] >= -DEFAULT_EPSILON &&
|
|
148
|
+
candidate[1] <= 1 + DEFAULT_EPSILON &&
|
|
149
|
+
candidate[2] >= -DEFAULT_EPSILON &&
|
|
150
|
+
candidate[2] <= 1 + DEFAULT_EPSILON;
|
|
151
|
+
|
|
152
|
+
if (minInGamut && candidateInGamut) {
|
|
153
|
+
min = chroma;
|
|
154
|
+
lastClipped = clipCoords(candidate);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const clipped = clipCoords(candidate);
|
|
159
|
+
const clippedOklab = targetToOklab(clipped, targetId);
|
|
160
|
+
const candOklab = oklchToOklab(L, chroma, H);
|
|
161
|
+
const E = deltaEOK(clippedOklab, candOklab);
|
|
162
|
+
|
|
163
|
+
if (E < JND) {
|
|
164
|
+
if (JND - E < SEARCH_EPSILON) {
|
|
165
|
+
lastClipped = clipped;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
minInGamut = false;
|
|
169
|
+
min = chroma;
|
|
170
|
+
lastClipped = clipped;
|
|
171
|
+
} else {
|
|
172
|
+
max = chroma;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return new Swatch({
|
|
177
|
+
space: targetId,
|
|
178
|
+
coords: lastClipped,
|
|
179
|
+
alpha: swatch.alpha
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function toGamut(swatch, opts = {}) {
|
|
184
|
+
const spaceId = opts.space ?? "srgb";
|
|
185
|
+
const method = opts.method ?? "css4";
|
|
186
|
+
if (!isGamutBounded(spaceId)) {
|
|
187
|
+
// Not a bounded target — just convert.
|
|
188
|
+
return swatch.to(spaceId);
|
|
189
|
+
}
|
|
190
|
+
if (inGamut(swatch, spaceId)) {
|
|
191
|
+
return swatch.to(spaceId);
|
|
192
|
+
}
|
|
193
|
+
if (method === "clip") {
|
|
194
|
+
return clipInto(swatch, spaceId);
|
|
195
|
+
}
|
|
196
|
+
if (method === "css4" || method === "oklch-chroma") {
|
|
197
|
+
return toGamutCss4(swatch, spaceId);
|
|
198
|
+
}
|
|
199
|
+
throw new Error(
|
|
200
|
+
appendSuggestion(
|
|
201
|
+
`toGamut: unknown method "${method}"`,
|
|
202
|
+
method,
|
|
203
|
+
["css4", "oklch-chroma", "clip"]
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// High-throughput ImageData transforms for CVD simulation and daltonization.
|
|
2
|
+
//
|
|
3
|
+
// The color-level CVD APIs are intentionally ergonomic. This module is the hot
|
|
4
|
+
// path for image processing: it works directly on RGBA byte buffers, reuses
|
|
5
|
+
// precomputed transfer-function LUTs, computes the CVD matrix once per image,
|
|
6
|
+
// and preserves alpha untouched.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CVD_RGB_MATRICES,
|
|
10
|
+
IDENTITY3,
|
|
11
|
+
ACHROMA_MATRIX,
|
|
12
|
+
interpolateMatrix3,
|
|
13
|
+
normalizeCVDType
|
|
14
|
+
} from "../data/cvd-matrices.js";
|
|
15
|
+
|
|
16
|
+
const LUT_SIZE = 65536;
|
|
17
|
+
|
|
18
|
+
const SRGB8_TO_LINEAR = new Float32Array(256);
|
|
19
|
+
for (let i = 0; i < 256; i++) {
|
|
20
|
+
const v = i / 255;
|
|
21
|
+
SRGB8_TO_LINEAR[i] =
|
|
22
|
+
v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LINEAR_TO_SRGB8 = new Uint8ClampedArray(LUT_SIZE);
|
|
26
|
+
for (let i = 0; i < LUT_SIZE; i++) {
|
|
27
|
+
const v = i / (LUT_SIZE - 1);
|
|
28
|
+
const srgb =
|
|
29
|
+
v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
|
30
|
+
LINEAR_TO_SRGB8[i] = Math.round(srgb * 255);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clamp01ToByte(v) {
|
|
34
|
+
if (v <= 0) return 0;
|
|
35
|
+
if (v >= 1) return 255;
|
|
36
|
+
return LINEAR_TO_SRGB8[(v * (LUT_SIZE - 1) + 0.5) | 0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeSeverity(severity) {
|
|
40
|
+
const n = Number(severity);
|
|
41
|
+
if (!Number.isFinite(n)) return 1;
|
|
42
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertImageDataLike(imageData) {
|
|
46
|
+
if (!imageData || !imageData.data) {
|
|
47
|
+
throw new Error("imageData: expected { data, width, height }");
|
|
48
|
+
}
|
|
49
|
+
const { data, width, height } = imageData;
|
|
50
|
+
if (typeof width !== "number" || typeof height !== "number") {
|
|
51
|
+
throw new Error("imageData: width and height must be numbers");
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
!Number.isInteger(width) ||
|
|
55
|
+
!Number.isInteger(height) ||
|
|
56
|
+
width <= 0 ||
|
|
57
|
+
height <= 0
|
|
58
|
+
) {
|
|
59
|
+
throw new Error("imageData: width and height must be positive integers");
|
|
60
|
+
}
|
|
61
|
+
if (typeof data.length !== "number" || data.length % 4 !== 0) {
|
|
62
|
+
throw new Error("imageData: data length must be a multiple of 4");
|
|
63
|
+
}
|
|
64
|
+
const expected = width * height * 4;
|
|
65
|
+
if (data.length < expected) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`imageData: data length ${data.length} is smaller than width × height × 4 (${expected})`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cloneImageDataLike(imageData) {
|
|
73
|
+
const data = new Uint8ClampedArray(imageData.data);
|
|
74
|
+
const width = imageData.width;
|
|
75
|
+
const height = imageData.height;
|
|
76
|
+
|
|
77
|
+
// Preserve the browser-native ImageData shape when possible. The guarded
|
|
78
|
+
// lookup keeps this module usable in non-DOM runtimes and tests.
|
|
79
|
+
if (typeof ImageData !== "undefined") {
|
|
80
|
+
try {
|
|
81
|
+
if (imageData.colorSpace) {
|
|
82
|
+
return new ImageData(data, width, height, {
|
|
83
|
+
colorSpace: imageData.colorSpace
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
} catch (_err) {
|
|
87
|
+
// Fall through to the two-argument constructor.
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return new ImageData(data, width, height);
|
|
91
|
+
} catch (_err) {
|
|
92
|
+
// Fall through to a structural clone.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { data, width, height };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function targetImageData(imageData, inPlace) {
|
|
100
|
+
assertImageDataLike(imageData);
|
|
101
|
+
return inPlace === false ? cloneImageDataLike(imageData) : imageData;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function simulationMatrix(type, severity) {
|
|
105
|
+
const normalized = normalizeCVDType(type);
|
|
106
|
+
const sev = normalizeSeverity(severity);
|
|
107
|
+
const target = normalized === "achroma"
|
|
108
|
+
? ACHROMA_MATRIX
|
|
109
|
+
: CVD_RGB_MATRICES[normalized];
|
|
110
|
+
return interpolateMatrix3(IDENTITY3, target, sev);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function correctionMatrix(type, severity) {
|
|
114
|
+
const normalized = normalizeCVDType(type);
|
|
115
|
+
if (normalized === "achroma") {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"daltonizeImageData: achromatopsia cannot be corrected (no remaining channels)"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
normalized,
|
|
122
|
+
matrix: interpolateMatrix3(
|
|
123
|
+
IDENTITY3,
|
|
124
|
+
CVD_RGB_MATRICES[normalized],
|
|
125
|
+
normalizeSeverity(severity)
|
|
126
|
+
)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applySimulation(data, M) {
|
|
131
|
+
const m00 = M[0][0], m01 = M[0][1], m02 = M[0][2];
|
|
132
|
+
const m10 = M[1][0], m11 = M[1][1], m12 = M[1][2];
|
|
133
|
+
const m20 = M[2][0], m21 = M[2][1], m22 = M[2][2];
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
136
|
+
const r = SRGB8_TO_LINEAR[data[i]];
|
|
137
|
+
const g = SRGB8_TO_LINEAR[data[i + 1]];
|
|
138
|
+
const b = SRGB8_TO_LINEAR[data[i + 2]];
|
|
139
|
+
|
|
140
|
+
data[i] = clamp01ToByte(m00 * r + m01 * g + m02 * b);
|
|
141
|
+
data[i + 1] = clamp01ToByte(m10 * r + m11 * g + m12 * b);
|
|
142
|
+
data[i + 2] = clamp01ToByte(m20 * r + m21 * g + m22 * b);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyDaltonize(data, M, normalized) {
|
|
147
|
+
const m00 = M[0][0], m01 = M[0][1], m02 = M[0][2];
|
|
148
|
+
const m20 = M[2][0], m21 = M[2][1], m22 = M[2][2];
|
|
149
|
+
|
|
150
|
+
// Fidaner shift matrices, inlined for the two cases to keep the inner loop
|
|
151
|
+
// branch-free after this one-time setup.
|
|
152
|
+
if (normalized === "tritan") {
|
|
153
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
154
|
+
const r = SRGB8_TO_LINEAR[data[i]];
|
|
155
|
+
const g = SRGB8_TO_LINEAR[data[i + 1]];
|
|
156
|
+
const b = SRGB8_TO_LINEAR[data[i + 2]];
|
|
157
|
+
|
|
158
|
+
const simB = m20 * r + m21 * g + m22 * b;
|
|
159
|
+
const errB = b - simB;
|
|
160
|
+
data[i] = clamp01ToByte(r + 0.7 * errB);
|
|
161
|
+
data[i + 1] = clamp01ToByte(g + 0.7 * errB);
|
|
162
|
+
data[i + 2] = clamp01ToByte(b);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
168
|
+
const r = SRGB8_TO_LINEAR[data[i]];
|
|
169
|
+
const g = SRGB8_TO_LINEAR[data[i + 1]];
|
|
170
|
+
const b = SRGB8_TO_LINEAR[data[i + 2]];
|
|
171
|
+
|
|
172
|
+
const simR = m00 * r + m01 * g + m02 * b;
|
|
173
|
+
const errR = r - simR;
|
|
174
|
+
data[i] = clamp01ToByte(r);
|
|
175
|
+
data[i + 1] = clamp01ToByte(g + 0.7 * errR);
|
|
176
|
+
data[i + 2] = clamp01ToByte(b + 0.7 * errR);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function simulateImageData(imageData, type, opts = {}) {
|
|
181
|
+
const out = targetImageData(imageData, opts.inPlace);
|
|
182
|
+
const M = simulationMatrix(type, opts.severity ?? 1);
|
|
183
|
+
applySimulation(out.data, M);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function daltonizeImageData(imageData, type, opts = {}) {
|
|
188
|
+
const out = targetImageData(imageData, opts.inPlace);
|
|
189
|
+
const { matrix, normalized } = correctionMatrix(type, opts.severity ?? 1);
|
|
190
|
+
applyDaltonize(out.data, matrix, normalized);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// OKLCh-based perceptual manipulation.
|
|
2
|
+
//
|
|
3
|
+
// BREAKING CHANGE from v2: `lighten`/`darken`/`saturate` all operate on
|
|
4
|
+
// OKLCh coordinates instead of HSL. Amounts are in the native OKLCh
|
|
5
|
+
// unit (L is 0..1, C is the absolute chroma, H is degrees), NOT the
|
|
6
|
+
// v2 0..100 percent scale. See MIGRATING.md.
|
|
7
|
+
//
|
|
8
|
+
// Rationale: HSL's "lightness" is not perceptually uniform — lightening
|
|
9
|
+
// yellow and blue by 10% HSL lightness produces very different visual
|
|
10
|
+
// lightness changes. OKLCh is designed to be perceptually uniform so
|
|
11
|
+
// the same numeric delta produces the same perceived change.
|
|
12
|
+
//
|
|
13
|
+
// Each operation returns a new Swatch. By default the result is passed
|
|
14
|
+
// through `toGamut('srgb')` so you still get a displayable sRGB color;
|
|
15
|
+
// pass `{ gamut: false }` to keep the raw OKLCh result (useful when
|
|
16
|
+
// chaining further operations).
|
|
17
|
+
|
|
18
|
+
import { Swatch } from "../core/swatch-class.js";
|
|
19
|
+
import { toGamut } from "./gamut.js";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_LIGHTEN = 0.1;
|
|
22
|
+
const DEFAULT_SATURATE = 0.05;
|
|
23
|
+
|
|
24
|
+
function applyOklch(swatch, mutator, opts = {}) {
|
|
25
|
+
const oklch = swatch._getCoordsIn("oklch");
|
|
26
|
+
const out = mutator(oklch.slice());
|
|
27
|
+
const result = new Swatch({
|
|
28
|
+
space: "oklch",
|
|
29
|
+
coords: out,
|
|
30
|
+
alpha: swatch.alpha
|
|
31
|
+
});
|
|
32
|
+
if (opts.gamut === false) return result;
|
|
33
|
+
return toGamut(result, { space: "srgb" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function lighten(swatch, amount = DEFAULT_LIGHTEN, opts) {
|
|
37
|
+
return applyOklch(
|
|
38
|
+
swatch,
|
|
39
|
+
(c) => {
|
|
40
|
+
c[0] = Math.min(1, Math.max(0, c[0] + amount));
|
|
41
|
+
return c;
|
|
42
|
+
},
|
|
43
|
+
opts
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function darken(swatch, amount = DEFAULT_LIGHTEN, opts) {
|
|
48
|
+
return lighten(swatch, -amount, opts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saturate(swatch, amount = DEFAULT_SATURATE, opts) {
|
|
52
|
+
return applyOklch(
|
|
53
|
+
swatch,
|
|
54
|
+
(c) => {
|
|
55
|
+
c[1] = Math.max(0, c[1] + amount);
|
|
56
|
+
return c;
|
|
57
|
+
},
|
|
58
|
+
opts
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function desaturate(swatch, amount = DEFAULT_SATURATE, opts) {
|
|
63
|
+
return saturate(swatch, -amount, opts);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function spin(swatch, degrees, opts) {
|
|
67
|
+
return applyOklch(
|
|
68
|
+
swatch,
|
|
69
|
+
(c) => {
|
|
70
|
+
c[2] = ((c[2] + degrees) % 360 + 360) % 360;
|
|
71
|
+
return c;
|
|
72
|
+
},
|
|
73
|
+
opts
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function greyscale(swatch, opts) {
|
|
78
|
+
return applyOklch(
|
|
79
|
+
swatch,
|
|
80
|
+
(c) => {
|
|
81
|
+
c[1] = 0;
|
|
82
|
+
return c;
|
|
83
|
+
},
|
|
84
|
+
opts
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function complement(swatch, opts) {
|
|
89
|
+
return spin(swatch, 180, opts);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function invert(swatch) {
|
|
93
|
+
// sRGB channel inversion — unchanged from v2 semantics.
|
|
94
|
+
const { r, g, b } = swatch.srgb;
|
|
95
|
+
return new Swatch({
|
|
96
|
+
space: "srgb",
|
|
97
|
+
coords: [1 - r, 1 - g, 1 - b],
|
|
98
|
+
alpha: swatch.alpha
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Mix (lerp) and average operations.
|
|
2
|
+
//
|
|
3
|
+
// `mix(a, b, amount, { space })` interpolates between two colors in the
|
|
4
|
+
// given color space. `amount` is 0..1 (0 = a, 1 = b). Default space is
|
|
5
|
+
// `'oklab'` since linear interpolation there is perceptually meaningful.
|
|
6
|
+
//
|
|
7
|
+
// Polar spaces (hsl, hsv, hwb, lch, oklch, hsluv) use shortest-arc hue
|
|
8
|
+
// interpolation — the hue walks the smaller of the two directions
|
|
9
|
+
// around the 360° circle.
|
|
10
|
+
//
|
|
11
|
+
// `average(colors, { space })` is the multi-color analogue: take the
|
|
12
|
+
// arithmetic mean of the coord arrays in the given space. Hue is
|
|
13
|
+
// averaged as a unit-vector sum (handles wrap-around correctly).
|
|
14
|
+
|
|
15
|
+
import { Swatch, swatch } from "../core/swatch-class.js";
|
|
16
|
+
import { clamp } from "../util/math.js";
|
|
17
|
+
|
|
18
|
+
const POLAR_SPACES = new Set([
|
|
19
|
+
"hsl",
|
|
20
|
+
"hsv",
|
|
21
|
+
"hwb",
|
|
22
|
+
"lch",
|
|
23
|
+
"lch-d50",
|
|
24
|
+
"oklch",
|
|
25
|
+
"hsluv"
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Each polar space has its hue as channel 0 except for lch/oklch which
|
|
29
|
+
// have L/C/H — hue is channel 2.
|
|
30
|
+
const HUE_INDEX = {
|
|
31
|
+
hsl: 0,
|
|
32
|
+
hsv: 0,
|
|
33
|
+
hwb: 0,
|
|
34
|
+
hsluv: 0,
|
|
35
|
+
lch: 2,
|
|
36
|
+
"lch-d50": 2,
|
|
37
|
+
oklch: 2
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function lerp(a, b, t) {
|
|
41
|
+
return a + (b - a) * t;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function lerpHueShortest(a, b, t) {
|
|
45
|
+
let dh = b - a;
|
|
46
|
+
if (dh > 180) dh -= 360;
|
|
47
|
+
else if (dh < -180) dh += 360;
|
|
48
|
+
let h = a + dh * t;
|
|
49
|
+
if (h < 0) h += 360;
|
|
50
|
+
if (h >= 360) h -= 360;
|
|
51
|
+
return h;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toSwatch(input) {
|
|
55
|
+
return input instanceof Swatch ? input : swatch(input);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mix(a, b, amount = 0.5, opts = {}) {
|
|
59
|
+
const spaceId = opts.space || "oklab";
|
|
60
|
+
const t = clamp(amount, 0, 1);
|
|
61
|
+
const as = toSwatch(a);
|
|
62
|
+
const bs = toSwatch(b);
|
|
63
|
+
const ac = as._getCoordsIn(spaceId);
|
|
64
|
+
const bc = bs._getCoordsIn(spaceId);
|
|
65
|
+
const alpha = as.alpha + (bs.alpha - as.alpha) * t;
|
|
66
|
+
|
|
67
|
+
const out = [ac[0], ac[1], ac[2]];
|
|
68
|
+
if (POLAR_SPACES.has(spaceId)) {
|
|
69
|
+
const hIdx = HUE_INDEX[spaceId];
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
if (i === hIdx) out[i] = lerpHueShortest(ac[i], bc[i], t);
|
|
72
|
+
else out[i] = lerp(ac[i], bc[i], t);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
out[0] = lerp(ac[0], bc[0], t);
|
|
76
|
+
out[1] = lerp(ac[1], bc[1], t);
|
|
77
|
+
out[2] = lerp(ac[2], bc[2], t);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return new Swatch({ space: spaceId, coords: out, alpha });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Hue average as unit-vector sum — handles wrap-around correctly
|
|
84
|
+
// (averaging 350° and 10° should give 0°, not 180°).
|
|
85
|
+
function averageHue(hues) {
|
|
86
|
+
let x = 0;
|
|
87
|
+
let y = 0;
|
|
88
|
+
for (const h of hues) {
|
|
89
|
+
const rad = (h * Math.PI) / 180;
|
|
90
|
+
x += Math.cos(rad);
|
|
91
|
+
y += Math.sin(rad);
|
|
92
|
+
}
|
|
93
|
+
let result = (Math.atan2(y, x) * 180) / Math.PI;
|
|
94
|
+
if (result < 0) result += 360;
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function average(inputs, opts = {}) {
|
|
99
|
+
const spaceId = opts.space || "oklab";
|
|
100
|
+
if (!Array.isArray(inputs) || inputs.length === 0) {
|
|
101
|
+
throw new Error("average: inputs must be a non-empty array");
|
|
102
|
+
}
|
|
103
|
+
const swatches = inputs.map(toSwatch);
|
|
104
|
+
const n = swatches.length;
|
|
105
|
+
const coords = swatches.map((s) => s._getCoordsIn(spaceId));
|
|
106
|
+
const alphas = swatches.map((s) => s.alpha);
|
|
107
|
+
|
|
108
|
+
const out = [0, 0, 0];
|
|
109
|
+
if (POLAR_SPACES.has(spaceId)) {
|
|
110
|
+
const hIdx = HUE_INDEX[spaceId];
|
|
111
|
+
for (let i = 0; i < 3; i++) {
|
|
112
|
+
if (i === hIdx) {
|
|
113
|
+
out[i] = averageHue(coords.map((c) => c[i]));
|
|
114
|
+
} else {
|
|
115
|
+
let sum = 0;
|
|
116
|
+
for (const c of coords) sum += c[i];
|
|
117
|
+
out[i] = sum / n;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
for (let i = 0; i < 3; i++) {
|
|
122
|
+
let sum = 0;
|
|
123
|
+
for (const c of coords) sum += c[i];
|
|
124
|
+
out[i] = sum / n;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const alpha = alphas.reduce((a, b) => a + b, 0) / n;
|
|
128
|
+
return new Swatch({ space: spaceId, coords: out, alpha });
|
|
129
|
+
}
|