@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,88 @@
|
|
|
1
|
+
// Legacy CSS string parsers: rgb()/rgba()/hsl()/hsla().
|
|
2
|
+
//
|
|
3
|
+
// Accepts both the comma-separated (legacy) and whitespace + slash (modern)
|
|
4
|
+
// syntaxes, because v2 accepted both and existing tests depend on it.
|
|
5
|
+
// Modern CSS Color 4 extras like the `none` keyword and `<percentage>`
|
|
6
|
+
// alpha are handled in parse/css.js (Phase 4).
|
|
7
|
+
//
|
|
8
|
+
// Hue units supported: bare number (deg), `deg`, `rad`, `turn`. Mirrors
|
|
9
|
+
// v2's behavior in `_HSLToRGB` (src/swatch.js:546-613) and `_HSLAToRGBA`
|
|
10
|
+
// (src/swatch.js:625-730).
|
|
11
|
+
|
|
12
|
+
const RGB_FN = /^rgba?\(([^)]+)\)$/i;
|
|
13
|
+
const HSL_FN = /^hsla?\(([^)]+)\)$/i;
|
|
14
|
+
|
|
15
|
+
function splitArgs(body) {
|
|
16
|
+
// Split on slashes, commas, or runs of whitespace. The slash is
|
|
17
|
+
// always a separator for the alpha argument.
|
|
18
|
+
const slashParts = body.split("/").map((s) => s.trim());
|
|
19
|
+
const main = slashParts[0];
|
|
20
|
+
const alphaStr = slashParts[1];
|
|
21
|
+
const parts = main.split(/[\s,]+/).filter(Boolean);
|
|
22
|
+
if (alphaStr != null && alphaStr !== "") parts.push(alphaStr);
|
|
23
|
+
return parts;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parsePercentOrNumber(token) {
|
|
27
|
+
if (token.endsWith("%")) {
|
|
28
|
+
return parseFloat(token.slice(0, -1)) / 100;
|
|
29
|
+
}
|
|
30
|
+
return parseFloat(token);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseAlpha(token) {
|
|
34
|
+
if (token == null) return 1;
|
|
35
|
+
if (token.endsWith("%")) return parseFloat(token.slice(0, -1)) / 100;
|
|
36
|
+
const n = parseFloat(token);
|
|
37
|
+
return isNaN(n) ? 1 : n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseHue(token) {
|
|
41
|
+
// Bare number is degrees.
|
|
42
|
+
if (/turn$/i.test(token)) return parseFloat(token.slice(0, -4)) * 360;
|
|
43
|
+
if (/rad$/i.test(token)) return (parseFloat(token.slice(0, -3)) * 180) / Math.PI;
|
|
44
|
+
if (/deg$/i.test(token)) return parseFloat(token.slice(0, -3));
|
|
45
|
+
return parseFloat(token);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseRgbFn(input) {
|
|
49
|
+
if (typeof input !== "string") return null;
|
|
50
|
+
const match = RGB_FN.exec(input.trim());
|
|
51
|
+
if (!match) return null;
|
|
52
|
+
const parts = splitArgs(match[1]);
|
|
53
|
+
if (parts.length !== 3 && parts.length !== 4) return null;
|
|
54
|
+
|
|
55
|
+
// Each of r, g, b may be a number in 0..255 or a percentage 0..100%.
|
|
56
|
+
const parseChannel = (token) => {
|
|
57
|
+
if (token.endsWith("%")) {
|
|
58
|
+
return parseFloat(token.slice(0, -1)) / 100;
|
|
59
|
+
}
|
|
60
|
+
const n = parseFloat(token);
|
|
61
|
+
return n / 255;
|
|
62
|
+
};
|
|
63
|
+
const r = parseChannel(parts[0]);
|
|
64
|
+
const g = parseChannel(parts[1]);
|
|
65
|
+
const b = parseChannel(parts[2]);
|
|
66
|
+
const a = parts.length === 4 ? parseAlpha(parts[3]) : 1;
|
|
67
|
+
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
|
|
68
|
+
return { space: "srgb", coords: [r, g, b], alpha: a };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseHslFn(input) {
|
|
72
|
+
if (typeof input !== "string") return null;
|
|
73
|
+
const match = HSL_FN.exec(input.trim());
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
const parts = splitArgs(match[1]);
|
|
76
|
+
if (parts.length !== 3 && parts.length !== 4) return null;
|
|
77
|
+
|
|
78
|
+
const h = parseHue(parts[0]);
|
|
79
|
+
const s = parsePercentOrNumber(parts[1]) * 100;
|
|
80
|
+
const l = parsePercentOrNumber(parts[2]) * 100;
|
|
81
|
+
const a = parts.length === 4 ? parseAlpha(parts[3]) : 1;
|
|
82
|
+
if (isNaN(h) || isNaN(s) || isNaN(l) || isNaN(a)) return null;
|
|
83
|
+
return { space: "hsl", coords: [h, s, l], alpha: a };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseLegacy(input) {
|
|
87
|
+
return parseRgbFn(input) || parseHslFn(input);
|
|
88
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// CSS named color lookup (148 names + `transparent`). Case-insensitive.
|
|
2
|
+
|
|
3
|
+
import namedColors from "../data/named-colors.js";
|
|
4
|
+
import { parseHex } from "./hex.js";
|
|
5
|
+
|
|
6
|
+
export function parseNamed(input) {
|
|
7
|
+
if (typeof input !== "string") return null;
|
|
8
|
+
const key = input.trim().toLowerCase();
|
|
9
|
+
if (!Object.prototype.hasOwnProperty.call(namedColors, key)) return null;
|
|
10
|
+
return parseHex("#" + namedColors[key]);
|
|
11
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Object-literal input parsing.
|
|
2
|
+
//
|
|
3
|
+
// Accepts:
|
|
4
|
+
//
|
|
5
|
+
// { space, coords, alpha? } — canonical v3 form
|
|
6
|
+
// { r, g, b, a? } — sRGB-255 object (legacy v2)
|
|
7
|
+
// { h, s, l, a? } — HSL object (legacy v2)
|
|
8
|
+
// { h, s, v, a? } — HSV object
|
|
9
|
+
// { h, w, b, a? } — HWB object
|
|
10
|
+
// { l, a, b, alpha? } — CIE Lab D65
|
|
11
|
+
// { l, c, h, alpha? } — generic LCh — ambiguous between CIE
|
|
12
|
+
// and OK until disambiguated by the
|
|
13
|
+
// `space` hint; defaults to OKLCh
|
|
14
|
+
// when L ≤ 1 and CIE LCh when L > 1
|
|
15
|
+
//
|
|
16
|
+
// Returns a v3 state object or null if the input shape doesn't match.
|
|
17
|
+
|
|
18
|
+
const HAS = Object.prototype.hasOwnProperty;
|
|
19
|
+
|
|
20
|
+
export function parseObject(input) {
|
|
21
|
+
if (input === null || typeof input !== "object") return null;
|
|
22
|
+
|
|
23
|
+
// Canonical v3 form.
|
|
24
|
+
if (HAS.call(input, "space") && HAS.call(input, "coords")) {
|
|
25
|
+
const coords = input.coords;
|
|
26
|
+
if (!Array.isArray(coords) || coords.length !== 3) return null;
|
|
27
|
+
const alpha =
|
|
28
|
+
input.alpha != null ? input.alpha : input.a != null ? input.a : 1;
|
|
29
|
+
return {
|
|
30
|
+
space: String(input.space),
|
|
31
|
+
coords: [+coords[0], +coords[1], +coords[2]],
|
|
32
|
+
alpha: +alpha
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Legacy RGB-255 object.
|
|
37
|
+
if (HAS.call(input, "r") && HAS.call(input, "g") && HAS.call(input, "b")) {
|
|
38
|
+
const alpha = input.a != null ? +input.a : 1;
|
|
39
|
+
return {
|
|
40
|
+
space: "srgb",
|
|
41
|
+
coords: [+input.r / 255, +input.g / 255, +input.b / 255],
|
|
42
|
+
alpha
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// HSL legacy object (also supports HSLuv later when that space registers,
|
|
47
|
+
// but the built-in HSL is the common case).
|
|
48
|
+
if (HAS.call(input, "h") && HAS.call(input, "s") && HAS.call(input, "l")) {
|
|
49
|
+
const alpha = input.a != null ? +input.a : 1;
|
|
50
|
+
return {
|
|
51
|
+
space: "hsl",
|
|
52
|
+
coords: [+input.h, +input.s, +input.l],
|
|
53
|
+
alpha
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// HSV object.
|
|
58
|
+
if (HAS.call(input, "h") && HAS.call(input, "s") && HAS.call(input, "v")) {
|
|
59
|
+
const alpha = input.a != null ? +input.a : 1;
|
|
60
|
+
return {
|
|
61
|
+
space: "hsv",
|
|
62
|
+
coords: [+input.h, +input.s, +input.v],
|
|
63
|
+
alpha
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// HWB object. Collision with { h, s, b } for HSB isn't a real problem
|
|
68
|
+
// because HWB's key order is h/w/b and HSB is spelled h/s/v in practice.
|
|
69
|
+
if (HAS.call(input, "h") && HAS.call(input, "w") && HAS.call(input, "b")) {
|
|
70
|
+
const alpha = input.a != null ? +input.a : 1;
|
|
71
|
+
return {
|
|
72
|
+
space: "hwb",
|
|
73
|
+
coords: [+input.h, +input.w, +input.b],
|
|
74
|
+
alpha
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// CIE Lab D65 (L/a/b). Ambiguous vs OKLab; we pick CIE Lab here because
|
|
79
|
+
// OKLab users should use the explicit { space: 'oklab', coords: [...] }
|
|
80
|
+
// form (OKLab L is in 0..1, CIE Lab L in 0..100 — enforcing that via
|
|
81
|
+
// this parser would be surprising).
|
|
82
|
+
if (HAS.call(input, "l") && HAS.call(input, "a") && HAS.call(input, "b")) {
|
|
83
|
+
const alpha = input.alpha != null ? +input.alpha : 1;
|
|
84
|
+
return {
|
|
85
|
+
space: "lab",
|
|
86
|
+
coords: [+input.l, +input.a, +input.b],
|
|
87
|
+
alpha
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// CIE LCh / OKLCh. Disambiguate by L magnitude: CIE LCh has L in 0..100,
|
|
92
|
+
// OKLCh L in 0..1. Callers who want to force a choice should pass the
|
|
93
|
+
// explicit { space, coords } form.
|
|
94
|
+
if (HAS.call(input, "l") && HAS.call(input, "c") && HAS.call(input, "h")) {
|
|
95
|
+
const alpha = input.alpha != null ? +input.alpha : 1;
|
|
96
|
+
const space = +input.l > 1 ? "lch" : "oklch";
|
|
97
|
+
return {
|
|
98
|
+
space,
|
|
99
|
+
coords: [+input.l, +input.c, +input.h],
|
|
100
|
+
alpha
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// CMYK object. Stored as the 3-channel "folded K" representation so it
|
|
105
|
+
// fits the canonical state shape; see src/spaces/cmyk.js.
|
|
106
|
+
if (
|
|
107
|
+
HAS.call(input, "c") &&
|
|
108
|
+
HAS.call(input, "m") &&
|
|
109
|
+
HAS.call(input, "y") &&
|
|
110
|
+
HAS.call(input, "k")
|
|
111
|
+
) {
|
|
112
|
+
const alpha = input.alpha != null ? +input.alpha : input.a != null ? +input.a : 1;
|
|
113
|
+
const c = +input.c;
|
|
114
|
+
const m = +input.m;
|
|
115
|
+
const y = +input.y;
|
|
116
|
+
const k = +input.k;
|
|
117
|
+
return {
|
|
118
|
+
space: "cmyk",
|
|
119
|
+
coords: [c + k - c * k, m + k - m * k, y + k - y * k],
|
|
120
|
+
alpha
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// Color scales — chroma.js-style.
|
|
2
|
+
//
|
|
3
|
+
// swatch.scale([c0, c1, c2, ...]) // fixed stops
|
|
4
|
+
// swatch.scale('viridis') // built-in palette (Phase 18)
|
|
5
|
+
//
|
|
6
|
+
// Returns a `Scale` function you can call with a number in [0, 1]
|
|
7
|
+
// (the default domain) to interpolate between the stops. The scale
|
|
8
|
+
// also carries methods for reshaping the curve:
|
|
9
|
+
//
|
|
10
|
+
// .domain([a, b]) — map input through this domain before lerp
|
|
11
|
+
// .classes(n | [...]) — bucket the output into n (or explicit) bins
|
|
12
|
+
// .padding(p | [l, r]) — trim fractional amounts off each end
|
|
13
|
+
// .gamma(g) — pow the normalized t by g (emphasis curve)
|
|
14
|
+
// .mode(space) — interpolation color space (default 'oklab')
|
|
15
|
+
// .correctLightness() — resample t so the output Lab L is linear
|
|
16
|
+
// .cache(on) — toggle result memoization
|
|
17
|
+
//
|
|
18
|
+
// Calling pattern:
|
|
19
|
+
// scale(t) — Swatch at position t
|
|
20
|
+
// scale.colors(n, fmt?) — array of n Swatches or formatted strings
|
|
21
|
+
//
|
|
22
|
+
// Also:
|
|
23
|
+
// swatch.bezier([colors]) — returns a fn that can be passed to scale
|
|
24
|
+
// swatch.cubehelix({start, rotations, hue, gamma, lightness}) — ditto
|
|
25
|
+
|
|
26
|
+
import { Swatch, swatch } from "../core/swatch-class.js";
|
|
27
|
+
import { mix } from "../operations/mix.js";
|
|
28
|
+
import { getPalette, listPalettes } from "../palettes/index.js";
|
|
29
|
+
import { appendSuggestion } from "../util/suggest.js";
|
|
30
|
+
|
|
31
|
+
function toSwatch(input) {
|
|
32
|
+
return input instanceof Swatch ? input : swatch(input);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function clamp01(x) {
|
|
36
|
+
return x < 0 ? 0 : x > 1 ? 1 : x;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Interpolate directly between two Swatches in `space`. This is the
|
|
40
|
+
// same math as mix(a, b, t, {space}); we bypass the toSwatch dance
|
|
41
|
+
// inside the hot path.
|
|
42
|
+
function interp(a, b, t, space) {
|
|
43
|
+
return mix(a, b, t, { space });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildScale(stops, state) {
|
|
47
|
+
const arr = stops.map(toSwatch);
|
|
48
|
+
if (arr.length < 2) {
|
|
49
|
+
// Single-stop: always that color.
|
|
50
|
+
arr.push(arr[0]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sampleRaw(t) {
|
|
54
|
+
// Input t is in state.domain coords; normalize to [0, 1].
|
|
55
|
+
const [d0, d1] = state.domain;
|
|
56
|
+
let u = d0 === d1 ? 0 : (t - d0) / (d1 - d0);
|
|
57
|
+
u = clamp01(u);
|
|
58
|
+
|
|
59
|
+
// Padding: compress [0,1] to [pl, 1-pr] *before* other shaping.
|
|
60
|
+
const [pl, pr] = state.padding;
|
|
61
|
+
u = pl + u * (1 - pl - pr);
|
|
62
|
+
|
|
63
|
+
// Gamma: emphasis curve on the normalized position.
|
|
64
|
+
if (state.gamma !== 1) u = Math.pow(u, state.gamma);
|
|
65
|
+
|
|
66
|
+
// correctLightness: resample via the precomputed L lookup.
|
|
67
|
+
if (state.lightnessCorrection) {
|
|
68
|
+
u = state.lightnessCorrection(u);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Classes: bucket output into bins (step-function).
|
|
72
|
+
if (state.classes) {
|
|
73
|
+
u = bucketize(u, state.classes);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Map u onto the piecewise-linear sequence of stops.
|
|
77
|
+
const n = arr.length - 1;
|
|
78
|
+
const p = u * n;
|
|
79
|
+
const i = Math.min(Math.floor(p), n - 1);
|
|
80
|
+
const localT = p - i;
|
|
81
|
+
return interp(arr[i], arr[i + 1], localT, state.space);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sample(t) {
|
|
85
|
+
if (state.cache) {
|
|
86
|
+
const key = t;
|
|
87
|
+
if (state.cacheMap.has(key)) return state.cacheMap.get(key);
|
|
88
|
+
const v = sampleRaw(t);
|
|
89
|
+
state.cacheMap.set(key, v);
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
return sampleRaw(t);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scale(t) {
|
|
96
|
+
return sample(t);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
scale.colors = function (n, format) {
|
|
100
|
+
const out = [];
|
|
101
|
+
const [d0, d1] = state.domain;
|
|
102
|
+
if (n <= 0) return out;
|
|
103
|
+
if (n === 1) {
|
|
104
|
+
out.push(sample((d0 + d1) / 2));
|
|
105
|
+
} else {
|
|
106
|
+
for (let i = 0; i < n; i++) {
|
|
107
|
+
const t = d0 + (d1 - d0) * (i / (n - 1));
|
|
108
|
+
out.push(sample(t));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (format) {
|
|
112
|
+
return out.map((c) => c.toString({ format }));
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function clearCache() {
|
|
118
|
+
state.cacheMap = new Map();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
scale.domain = function (d) {
|
|
122
|
+
if (d === undefined) return state.domain.slice();
|
|
123
|
+
state.domain = [d[0], d[1]];
|
|
124
|
+
clearCache();
|
|
125
|
+
return scale;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
scale.classes = function (n) {
|
|
129
|
+
if (n === undefined) return state.classes;
|
|
130
|
+
if (typeof n === "number") {
|
|
131
|
+
// Build n+1 evenly-spaced breakpoints in normalized [0, 1].
|
|
132
|
+
const breaks = [];
|
|
133
|
+
for (let i = 0; i <= n; i++) breaks.push(i / n);
|
|
134
|
+
state.classes = breaks;
|
|
135
|
+
} else if (Array.isArray(n)) {
|
|
136
|
+
// Caller provided explicit domain-space breakpoints; convert
|
|
137
|
+
// to normalized positions via the *current* domain.
|
|
138
|
+
const [d0, d1] = state.domain;
|
|
139
|
+
state.classes = n.map((v) => clamp01((v - d0) / (d1 - d0)));
|
|
140
|
+
state.classes.sort((a, b) => a - b);
|
|
141
|
+
} else {
|
|
142
|
+
state.classes = null;
|
|
143
|
+
}
|
|
144
|
+
clearCache();
|
|
145
|
+
return scale;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
scale.padding = function (p) {
|
|
149
|
+
if (p === undefined) return state.padding.slice();
|
|
150
|
+
if (typeof p === "number") state.padding = [p, p];
|
|
151
|
+
else state.padding = [p[0], p[1]];
|
|
152
|
+
clearCache();
|
|
153
|
+
return scale;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
scale.gamma = function (g) {
|
|
157
|
+
if (g === undefined) return state.gamma;
|
|
158
|
+
state.gamma = g;
|
|
159
|
+
clearCache();
|
|
160
|
+
return scale;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
scale.mode = function (space) {
|
|
164
|
+
if (space === undefined) return state.space;
|
|
165
|
+
state.space = space;
|
|
166
|
+
clearCache();
|
|
167
|
+
return scale;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
scale.correctLightness = function (enable = true) {
|
|
171
|
+
if (!enable) {
|
|
172
|
+
state.lightnessCorrection = null;
|
|
173
|
+
} else {
|
|
174
|
+
// Precompute: sample the raw scale at uniform t, record Lab L,
|
|
175
|
+
// then build an inverse lookup from desired-L to actual-t.
|
|
176
|
+
const samples = 64;
|
|
177
|
+
const ts = new Array(samples + 1);
|
|
178
|
+
const ls = new Array(samples + 1);
|
|
179
|
+
const saved = state.lightnessCorrection;
|
|
180
|
+
state.lightnessCorrection = null;
|
|
181
|
+
for (let i = 0; i <= samples; i++) {
|
|
182
|
+
const t = i / samples;
|
|
183
|
+
ts[i] = t;
|
|
184
|
+
ls[i] = sampleRaw(normalizedToDomain(t, state.domain)).lab.l;
|
|
185
|
+
}
|
|
186
|
+
state.lightnessCorrection = saved;
|
|
187
|
+
const lStart = ls[0];
|
|
188
|
+
const lEnd = ls[samples];
|
|
189
|
+
state.lightnessCorrection = function (u) {
|
|
190
|
+
// Desired L at position u (linear in L).
|
|
191
|
+
const targetL = lStart + (lEnd - lStart) * u;
|
|
192
|
+
// Find the two sample indices bracketing targetL.
|
|
193
|
+
// (Monotonic-in-t but not necessarily monotonic-in-L,
|
|
194
|
+
// so we take the segment whose endpoints straddle it.)
|
|
195
|
+
for (let i = 0; i < samples; i++) {
|
|
196
|
+
const la = ls[i];
|
|
197
|
+
const lb = ls[i + 1];
|
|
198
|
+
if (
|
|
199
|
+
(la <= targetL && targetL <= lb) ||
|
|
200
|
+
(lb <= targetL && targetL <= la)
|
|
201
|
+
) {
|
|
202
|
+
const frac =
|
|
203
|
+
lb === la ? 0 : (targetL - la) / (lb - la);
|
|
204
|
+
return ts[i] + frac * (ts[i + 1] - ts[i]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return u;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
clearCache();
|
|
211
|
+
return scale;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
scale.cache = function (on) {
|
|
215
|
+
if (on === undefined) return state.cache;
|
|
216
|
+
state.cache = !!on;
|
|
217
|
+
if (!state.cache) state.cacheMap = new Map();
|
|
218
|
+
return scale;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return scale;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizedToDomain(u, domain) {
|
|
225
|
+
const [d0, d1] = domain;
|
|
226
|
+
return d0 + u * (d1 - d0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function bucketize(u, breaks) {
|
|
230
|
+
// Classes are normalized breakpoints in [0, 1]; find the bin
|
|
231
|
+
// index, return the midpoint of its normalized bin.
|
|
232
|
+
for (let i = 0; i < breaks.length - 1; i++) {
|
|
233
|
+
if (u >= breaks[i] && u <= breaks[i + 1]) {
|
|
234
|
+
return (breaks[i] + breaks[i + 1]) / 2;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return u;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Public entry: swatch.scale(stops or palette name)
|
|
241
|
+
export function scale(stops) {
|
|
242
|
+
let resolved;
|
|
243
|
+
if (typeof stops === "string") {
|
|
244
|
+
const palette = getPalette(stops);
|
|
245
|
+
if (!palette) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
appendSuggestion(
|
|
248
|
+
`scale: unknown palette "${stops}"`,
|
|
249
|
+
stops,
|
|
250
|
+
listPalettes()
|
|
251
|
+
)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
resolved = palette;
|
|
255
|
+
} else if (typeof stops === "function") {
|
|
256
|
+
// Caller passed a bezier/cubehelix function: wrap it as a
|
|
257
|
+
// pre-built sampler. It already takes t in [0,1] and returns a
|
|
258
|
+
// Swatch, so we just expose it with the same surface API.
|
|
259
|
+
return wrapFunctionScale(stops);
|
|
260
|
+
} else {
|
|
261
|
+
resolved = stops;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const state = {
|
|
265
|
+
domain: [0, 1],
|
|
266
|
+
padding: [0, 0],
|
|
267
|
+
gamma: 1,
|
|
268
|
+
classes: null,
|
|
269
|
+
space: "oklab",
|
|
270
|
+
lightnessCorrection: null,
|
|
271
|
+
cache: true,
|
|
272
|
+
cacheMap: new Map()
|
|
273
|
+
};
|
|
274
|
+
return buildScale(resolved, state);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function wrapFunctionScale(fn) {
|
|
278
|
+
const state = {
|
|
279
|
+
domain: [0, 1],
|
|
280
|
+
padding: [0, 0],
|
|
281
|
+
gamma: 1,
|
|
282
|
+
classes: null,
|
|
283
|
+
space: "oklab",
|
|
284
|
+
lightnessCorrection: null,
|
|
285
|
+
cache: true,
|
|
286
|
+
cacheMap: new Map()
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
function sampleRaw(t) {
|
|
290
|
+
const [d0, d1] = state.domain;
|
|
291
|
+
let u = d0 === d1 ? 0 : (t - d0) / (d1 - d0);
|
|
292
|
+
u = clamp01(u);
|
|
293
|
+
const [pl, pr] = state.padding;
|
|
294
|
+
u = pl + u * (1 - pl - pr);
|
|
295
|
+
if (state.gamma !== 1) u = Math.pow(u, state.gamma);
|
|
296
|
+
if (state.lightnessCorrection) u = state.lightnessCorrection(u);
|
|
297
|
+
if (state.classes) u = bucketize(u, state.classes);
|
|
298
|
+
return fn(u);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function sample(t) {
|
|
302
|
+
if (state.cache) {
|
|
303
|
+
if (state.cacheMap.has(t)) return state.cacheMap.get(t);
|
|
304
|
+
const v = sampleRaw(t);
|
|
305
|
+
state.cacheMap.set(t, v);
|
|
306
|
+
return v;
|
|
307
|
+
}
|
|
308
|
+
return sampleRaw(t);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function scale(t) {
|
|
312
|
+
return sample(t);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
scale.colors = function (n, format) {
|
|
316
|
+
const out = [];
|
|
317
|
+
const [d0, d1] = state.domain;
|
|
318
|
+
if (n <= 0) return out;
|
|
319
|
+
if (n === 1) {
|
|
320
|
+
out.push(sample((d0 + d1) / 2));
|
|
321
|
+
} else {
|
|
322
|
+
for (let i = 0; i < n; i++) {
|
|
323
|
+
const t = d0 + (d1 - d0) * (i / (n - 1));
|
|
324
|
+
out.push(sample(t));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (format) return out.map((c) => c.toString({ format }));
|
|
328
|
+
return out;
|
|
329
|
+
};
|
|
330
|
+
scale.domain = function (d) {
|
|
331
|
+
if (d === undefined) return state.domain.slice();
|
|
332
|
+
state.domain = [d[0], d[1]];
|
|
333
|
+
state.cacheMap = new Map();
|
|
334
|
+
return scale;
|
|
335
|
+
};
|
|
336
|
+
scale.classes = function (n) {
|
|
337
|
+
if (n === undefined) return state.classes;
|
|
338
|
+
if (typeof n === "number") {
|
|
339
|
+
const breaks = [];
|
|
340
|
+
for (let i = 0; i <= n; i++) breaks.push(i / n);
|
|
341
|
+
state.classes = breaks;
|
|
342
|
+
} else if (Array.isArray(n)) {
|
|
343
|
+
const [d0, d1] = state.domain;
|
|
344
|
+
state.classes = n
|
|
345
|
+
.map((v) => clamp01((v - d0) / (d1 - d0)))
|
|
346
|
+
.sort((a, b) => a - b);
|
|
347
|
+
} else state.classes = null;
|
|
348
|
+
state.cacheMap = new Map();
|
|
349
|
+
return scale;
|
|
350
|
+
};
|
|
351
|
+
scale.padding = function (p) {
|
|
352
|
+
if (p === undefined) return state.padding.slice();
|
|
353
|
+
state.padding = typeof p === "number" ? [p, p] : [p[0], p[1]];
|
|
354
|
+
state.cacheMap = new Map();
|
|
355
|
+
return scale;
|
|
356
|
+
};
|
|
357
|
+
scale.gamma = function (g) {
|
|
358
|
+
if (g === undefined) return state.gamma;
|
|
359
|
+
state.gamma = g;
|
|
360
|
+
state.cacheMap = new Map();
|
|
361
|
+
return scale;
|
|
362
|
+
};
|
|
363
|
+
scale.mode = function (space) {
|
|
364
|
+
if (space === undefined) return state.space;
|
|
365
|
+
state.space = space;
|
|
366
|
+
state.cacheMap = new Map();
|
|
367
|
+
return scale;
|
|
368
|
+
};
|
|
369
|
+
scale.correctLightness = function (enable = true) {
|
|
370
|
+
// Not implemented for wrapped function scales — the semantics
|
|
371
|
+
// are the same but would require resampling through the input
|
|
372
|
+
// fn, and bezier/cubehelix usually already control L explicitly.
|
|
373
|
+
return scale;
|
|
374
|
+
};
|
|
375
|
+
scale.cache = function (on) {
|
|
376
|
+
if (on === undefined) return state.cache;
|
|
377
|
+
state.cache = !!on;
|
|
378
|
+
if (!state.cache) state.cacheMap = new Map();
|
|
379
|
+
return scale;
|
|
380
|
+
};
|
|
381
|
+
return scale;
|
|
382
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Scale interpolators.
|
|
2
|
+
//
|
|
3
|
+
// bezier([c0, c1, c2, ...])
|
|
4
|
+
// chroma.js-style Bezier-smoothed interpolation through Lab stops.
|
|
5
|
+
// Returns a function t∈[0,1] → Swatch that can be passed to
|
|
6
|
+
// swatch.scale(...) and then reshaped with .domain(), .classes(),
|
|
7
|
+
// .padding(), .gamma() like any other scale.
|
|
8
|
+
//
|
|
9
|
+
// cubehelix({ start, rotations, hue, gamma, lightness })
|
|
10
|
+
// Green & Dave's cubehelix interpolation — a one-dimensional
|
|
11
|
+
// colormap that spirals through RGB space with monotonically
|
|
12
|
+
// varying lightness. Arguments match chroma.js's cubehelix.
|
|
13
|
+
// Returns the same t∈[0,1] → Swatch function shape.
|
|
14
|
+
|
|
15
|
+
import { Swatch, swatch } from "../core/swatch-class.js";
|
|
16
|
+
|
|
17
|
+
function toSwatch(input) {
|
|
18
|
+
return input instanceof Swatch ? input : swatch(input);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// De Casteljau's algorithm on Lab coords. Returns a Lab triple.
|
|
22
|
+
function bezierLab(stops, t) {
|
|
23
|
+
const n = stops.length;
|
|
24
|
+
if (n === 1) return stops[0];
|
|
25
|
+
const work = stops.map((s) => [s[0], s[1], s[2]]);
|
|
26
|
+
for (let r = 1; r < n; r++) {
|
|
27
|
+
for (let i = 0; i < n - r; i++) {
|
|
28
|
+
for (let k = 0; k < 3; k++) {
|
|
29
|
+
work[i][k] = work[i][k] * (1 - t) + work[i + 1][k] * t;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return work[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function bezier(colors) {
|
|
37
|
+
const stops = colors.map(toSwatch);
|
|
38
|
+
const labs = stops.map((s) => {
|
|
39
|
+
const { l, a, b } = s.lab;
|
|
40
|
+
return [l, a, b];
|
|
41
|
+
});
|
|
42
|
+
return function (t) {
|
|
43
|
+
const [l, a, b] = bezierLab(labs, t);
|
|
44
|
+
return new Swatch({ space: "lab", coords: [l, a, b], alpha: 1 });
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Cubehelix — reference: D. A. Green (2011), "A colour scheme for
|
|
49
|
+
// the display of astronomical intensity images", Bulletin of the
|
|
50
|
+
// Astronomical Society of India, 39, 289.
|
|
51
|
+
//
|
|
52
|
+
// t∈[0,1] → Swatch (sRGB). gamma shapes the lightness ramp; hue
|
|
53
|
+
// controls chroma amplitude; start/rotations define the spiral.
|
|
54
|
+
export function cubehelix(opts = {}) {
|
|
55
|
+
const start = opts.start != null ? opts.start : 300;
|
|
56
|
+
const rotations = opts.rotations != null ? opts.rotations : -1.5;
|
|
57
|
+
const hue = opts.hue != null ? opts.hue : 1;
|
|
58
|
+
const gamma = opts.gamma != null ? opts.gamma : 1;
|
|
59
|
+
const lightness =
|
|
60
|
+
opts.lightness != null ? opts.lightness : [0, 1];
|
|
61
|
+
|
|
62
|
+
return function (t) {
|
|
63
|
+
const lStart = lightness[0];
|
|
64
|
+
const lEnd = lightness[1];
|
|
65
|
+
const f = lStart + (lEnd - lStart) * t;
|
|
66
|
+
const l = Math.pow(f, gamma);
|
|
67
|
+
const angle =
|
|
68
|
+
2 * Math.PI * (start / 360 + 1 + rotations * t);
|
|
69
|
+
const amp = (hue * l * (1 - l)) / 2;
|
|
70
|
+
const cosA = Math.cos(angle);
|
|
71
|
+
const sinA = Math.sin(angle);
|
|
72
|
+
let r = l + amp * (-0.14861 * cosA + 1.78277 * sinA);
|
|
73
|
+
let g = l + amp * (-0.29227 * cosA - 0.90649 * sinA);
|
|
74
|
+
let b = l + amp * (1.97294 * cosA);
|
|
75
|
+
if (r < 0) r = 0;
|
|
76
|
+
if (r > 1) r = 1;
|
|
77
|
+
if (g < 0) g = 0;
|
|
78
|
+
if (g > 1) g = 1;
|
|
79
|
+
if (b < 0) b = 0;
|
|
80
|
+
if (b > 1) b = 1;
|
|
81
|
+
return new Swatch({ space: "srgb", coords: [r, g, b], alpha: 1 });
|
|
82
|
+
};
|
|
83
|
+
}
|