@roobscoob/vibes 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -2
  3. package/dist/arclamp.d.ts +34 -0
  4. package/dist/arclamp.d.ts.map +1 -0
  5. package/dist/arclamp.js +5 -0
  6. package/dist/color.d.ts +202 -0
  7. package/dist/color.d.ts.map +1 -0
  8. package/dist/color.js +492 -0
  9. package/dist/field.d.ts +94 -0
  10. package/dist/field.d.ts.map +1 -0
  11. package/dist/field.js +232 -0
  12. package/dist/gamut.d.ts +5 -0
  13. package/dist/gamut.d.ts.map +1 -0
  14. package/dist/gamut.js +29 -0
  15. package/dist/gamut.table.d.ts +5 -0
  16. package/dist/gamut.table.d.ts.map +1 -0
  17. package/dist/gamut.table.js +153 -0
  18. package/dist/here.d.ts +105 -0
  19. package/dist/here.d.ts.map +1 -0
  20. package/dist/here.js +219 -0
  21. package/dist/index.d.ts +8 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +8 -0
  24. package/dist/lemtone.d.ts +37 -0
  25. package/dist/lemtone.d.ts.map +1 -0
  26. package/dist/lemtone.js +158 -0
  27. package/dist/names.d.ts +5 -0
  28. package/dist/names.d.ts.map +1 -0
  29. package/dist/names.js +46 -0
  30. package/dist/parse.d.ts +43 -0
  31. package/dist/parse.d.ts.map +1 -0
  32. package/dist/parse.js +208 -0
  33. package/dist/shape.d.ts +44 -0
  34. package/dist/shape.d.ts.map +1 -0
  35. package/dist/shape.js +89 -0
  36. package/dist/signal.d.ts +26 -0
  37. package/dist/signal.d.ts.map +1 -0
  38. package/dist/signal.js +44 -0
  39. package/dist/sketch.d.ts +29 -0
  40. package/dist/sketch.d.ts.map +1 -0
  41. package/dist/sketch.js +141 -0
  42. package/dist/spaces.d.ts +24 -0
  43. package/dist/spaces.d.ts.map +1 -0
  44. package/dist/spaces.js +91 -0
  45. package/package.json +24 -4
  46. package/src/arclamp.ts +36 -0
  47. package/src/color.ts +590 -0
  48. package/src/field.ts +321 -0
  49. package/src/gamut.table.ts +156 -0
  50. package/src/gamut.ts +32 -0
  51. package/src/here.ts +272 -0
  52. package/src/index.ts +52 -0
  53. package/src/lemtone.ts +180 -0
  54. package/src/names.ts +50 -0
  55. package/src/parse.ts +205 -0
  56. package/src/shape.ts +109 -0
  57. package/src/signal.ts +65 -0
  58. package/src/sketch.ts +172 -0
  59. package/src/spaces.ts +102 -0
package/src/color.ts ADDED
@@ -0,0 +1,590 @@
1
+ // Colors: lemtones, the light itself, with brightness in lumens.
2
+ //
3
+ // A Color is a hue and chroma (OKLab's a and b at full lightness: 0, 0 is
4
+ // white) and an amount of light in lumens. Lumens are absolute, the same on
5
+ // every light: 800 lm is a bright bulb, 20 lm a glow, whichever light it's
6
+ // on. Colors never change: read them with properties (c.lm, c.hue, c.hex),
7
+ // and change them with methods, each giving a new color (c.at(400),
8
+ // c.mix(other, 0.5)).
9
+ //
10
+ // Anywhere a color goes, anything that says one will do (a ColorInput): a
11
+ // Color; text (any CSS color, "2700K", "265° C0.30 · 40 lm"); a number, as a
12
+ // color temperature in kelvin; [r, g, b]; or an object ({ a, b, lm },
13
+ // { r, g, b }, { h, s, l }, { l, c, h }, { hue, chroma }, { kelvin },
14
+ // { x, y }, or arclamp's own channels). Screens' spaces count their white as
15
+ // 1000 lm (spaces.ts), so #0000ff is about 72 lm and rgb(1, 1, 1) 1000.
16
+
17
+ import { edgeOf, maxLmOf } from "./gamut.ts";
18
+ import {
19
+ abOf,
20
+ abOfXy,
21
+ abToXyz,
22
+ hueChroma,
23
+ kelvinOf as kelvinOfAb,
24
+ LUMENS_PER_BRIGHTNESS,
25
+ oklabToXyz,
26
+ whiteAB,
27
+ xyzToAb,
28
+ xyzToOklab,
29
+ type Vec3,
30
+ } from "./lemtone.ts";
31
+ import type { Here } from "./here.ts";
32
+ import { parseColor, type Spec } from "./parse.ts";
33
+ import { clamp, lerp } from "./shape.ts";
34
+ import {
35
+ decode,
36
+ encode,
37
+ hslToSrgb,
38
+ hwbToSrgb,
39
+ labToXyz,
40
+ linearP3ToXyz,
41
+ linearSrgbToXyz,
42
+ SCREEN_WHITE_LM,
43
+ srgbToHsl,
44
+ xyzD50ToXyz,
45
+ xyzToLinearSrgb,
46
+ } from "./spaces.ts";
47
+
48
+ /** Anything that says a color: see the top of color.ts. */
49
+ export type ColorInput = Color | string | number | readonly number[] | { readonly [key: string]: unknown };
50
+
51
+ /** A color, given anywhere but as a number (which in a field is a number, not a color temperature). */
52
+ export type ColorLike = Exclude<ColorInput, number>;
53
+
54
+ /** What Color.with() changes: any of these, the rest kept. */
55
+ export interface ColorChanges {
56
+ a?: number;
57
+ b?: number;
58
+ hue?: number;
59
+ chroma?: number;
60
+ kelvin?: number;
61
+ lm?: number;
62
+ lumens?: number;
63
+ /** In arclamp's units: 1.0 is 1000 lm. */
64
+ brightness?: number;
65
+ fidelity?: number;
66
+ }
67
+
68
+ export class Color {
69
+ /** OKLab's a at full lightness: green (−) to red (+). */
70
+ readonly a: number;
71
+ /** OKLab's b at full lightness: blue (−) to yellow (+). */
72
+ readonly b: number;
73
+ /** How much light, in lumens. */
74
+ readonly lm: number;
75
+ /** 0 builds pale colors and whites from the RGB LEDs, 1 from the white ones. */
76
+ readonly fidelity: number;
77
+
78
+ constructor(a: number, b: number, lm: number, fidelity = 1) {
79
+ this.a = a;
80
+ this.b = b;
81
+ this.lm = lm;
82
+ this.fidelity = fidelity;
83
+ }
84
+
85
+ /** Reads anything that says a color, as color() does. */
86
+ static from(input: ColorInput, lm?: number): Color {
87
+ return color(input, lm);
88
+ }
89
+
90
+ // ---- reading ----
91
+
92
+ /** Its hue in degrees, 0–360 (OKLab's: about 30 red, 110 yellow, 145 green, 265 blue, 330 magenta). */
93
+ get hue(): number {
94
+ return hueChroma(this.a, this.b).hue;
95
+ }
96
+
97
+ /** How saturated: 0 is white, 0.1 pale, 0.3 strong. */
98
+ get chroma(): number {
99
+ return Math.hypot(this.a, this.b);
100
+ }
101
+
102
+ /** Its color temperature, if it's a white; null if it isn't. */
103
+ get kelvin(): number | null {
104
+ return kelvinOfAb(this.a, this.b);
105
+ }
106
+
107
+ /** The color temperature of the white nearest it, white or not. */
108
+ get nearestKelvin(): number {
109
+ return kelvinOfAb(this.a, this.b, Infinity)!;
110
+ }
111
+
112
+ /** How bright it looks, 0–1 (the cube root of its light; 1000 lm and up is 1). */
113
+ get looks(): number {
114
+ return Math.min(1, Math.cbrt(Math.max(0, this.lm) / SCREEN_WHITE_LM));
115
+ }
116
+
117
+ /** Its brightness in arclamp's units: 1.0 is 1000 lm. */
118
+ get brightness(): number {
119
+ return this.lm / LUMENS_PER_BRIGHTNESS;
120
+ }
121
+
122
+ /** As arclamp's channels for a color light. */
123
+ get channels(): { a: number; b: number; brightness: number; fidelity: number } {
124
+ return { a: this.a, b: this.b, brightness: this.brightness, fidelity: this.fidelity };
125
+ }
126
+
127
+ /** As CIE XYZ (D65), with Y = 1 for 1000 lm. */
128
+ get xyz(): Vec3 {
129
+ const unit = abToXyz(this.a, this.b);
130
+ const k = Math.max(0, this.lm) / SCREEN_WHITE_LM;
131
+ return unit ? [unit[0] * k, unit[1] * k, unit[2] * k] : [0, 0, 0];
132
+ }
133
+
134
+ /** Its CIE 1931 chromaticity. */
135
+ get xy(): { x: number; y: number } {
136
+ const [X, Y, Z] = abToXyz(this.a, this.b) ?? [0.9505, 1, 1.089];
137
+ return { x: X / (X + Y + Z), y: Y / (X + Y + Z) };
138
+ }
139
+
140
+ /**
141
+ * As sRGB, 0–1 per channel at 1000 lm of white (past 1 when brighter). What
142
+ * a screen can't show of it (the bulbs' deepest blues, say) is left out.
143
+ */
144
+ get rgb(): { r: number; g: number; b: number } {
145
+ const [r, g, b] = xyzToLinearSrgb(this.xyz).map((v) => encode(Math.max(0, v)));
146
+ return { r: r!, g: g!, b: b! };
147
+ }
148
+
149
+ /** As a CSS hex code, "#rrggbb", clipped to what a screen shows. */
150
+ get hex(): string {
151
+ const { r, g, b } = this.rgb;
152
+ return `#${[r, g, b].map((v) => Math.round(clamp(v) * 255).toString(16).padStart(2, "0")).join("")}`;
153
+ }
154
+
155
+ /** As HSL: hue in degrees, saturation and lightness 0–1 (clipped to what a screen shows). */
156
+ get hsl(): { h: number; s: number; l: number } {
157
+ const { r, g, b } = this.rgb;
158
+ return srgbToHsl(clamp(r), clamp(g), clamp(b));
159
+ }
160
+
161
+ /** As OKLab (CSS's: L 0–1, at 1000 lm of white). */
162
+ get oklab(): { l: number; a: number; b: number } {
163
+ const [l, a, b] = xyzToOklab(this.xyz);
164
+ return { l, a, b };
165
+ }
166
+
167
+ /** As OKLCh (CSS's: L 0–1 at 1000 lm of white, chroma, hue in degrees). */
168
+ get oklch(): { l: number; c: number; h: number } {
169
+ const { l, a, b } = this.oklab;
170
+ return { l, c: Math.hypot(a, b), h: hueChroma(a, b).hue };
171
+ }
172
+
173
+ // ---- changing (each gives a new color) ----
174
+
175
+ /** The same color at `lm` lumens. */
176
+ at(lm: number): Color {
177
+ return new Color(this.a, this.b, lm, this.fidelity);
178
+ }
179
+
180
+ /** k times the light: dim(0.5) is half the lumens, dim(2) twice. */
181
+ dim(k: number): Color {
182
+ return this.at(this.lm * k);
183
+ }
184
+
185
+ /** Looks k times as bright: fade(0.5) looks half as bright (an eighth of the lumens). */
186
+ fade(k: number): Color {
187
+ return this.at(this.lm * Math.max(0, k) ** 3);
188
+ }
189
+
190
+ /** With some of it changed: with({ fidelity: 0 }), with({ hue: 200 }), with({ kelvin: 3000 }). */
191
+ with(changes: ColorChanges): Color {
192
+ let { a, b } = this;
193
+ if (changes.kelvin !== undefined) [a, b] = whiteAB(kelvinIn(changes.kelvin));
194
+ if (changes.hue !== undefined || changes.chroma !== undefined) {
195
+ const now = hueChroma(a, b);
196
+ [a, b] = abOf(changes.hue ?? now.hue, changes.chroma ?? now.chroma);
197
+ }
198
+ a = changes.a ?? a;
199
+ b = changes.b ?? b;
200
+ const lm = changes.lm ?? changes.lumens ?? (changes.brightness !== undefined ? changes.brightness * LUMENS_PER_BRIGHTNESS : this.lm);
201
+ return new Color(a, b, lm, changes.fidelity ?? this.fidelity);
202
+ }
203
+
204
+ /**
205
+ * Toward `other` by t (0 is this, 1 is other; 0.5 if not given), evenly as
206
+ * it looks: the color in a straight line through OKLab, brightness by its
207
+ * cube root. To or from dark, only the brightness changes.
208
+ */
209
+ mix(other: ColorInput, t = 0.5): Color {
210
+ const y = color(other);
211
+ const k = clamp(t);
212
+ if (k === 0) return this;
213
+ if (k === 1) return y;
214
+ const lm = lerp(Math.cbrt(this.lm), Math.cbrt(y.lm), k) ** 3;
215
+ if (!(this.lm > 0)) return y.at(lm);
216
+ if (!(y.lm > 0)) return this.at(lm);
217
+ return new Color(lerp(this.a, y.a, k), lerp(this.b, y.b, k), lm, lerp(this.fidelity, y.fidelity, k));
218
+ }
219
+
220
+ /** This and others shining together: their light added up, as it would be on a wall. */
221
+ add(...others: ColorInput[]): Color {
222
+ const sum: Vec3 = [0, 0, 0];
223
+ let fidelity = 0;
224
+ for (const c of [this, ...others.map((o) => color(o))]) {
225
+ if (!(c.lm > 0)) continue;
226
+ const xyz = abToXyz(c.a, c.b);
227
+ if (!xyz) continue;
228
+ for (let i = 0; i < 3; i++) sum[i] += xyz[i]! * c.lm;
229
+ fidelity += c.fidelity * c.lm;
230
+ }
231
+ if (!(sum[1] > 0)) return off;
232
+ const [a, b] = xyzToAb(sum);
233
+ return new Color(a, b, sum[1], fidelity / sum[1]);
234
+ }
235
+
236
+ /** The hue turned by `degrees`. */
237
+ shiftHue(degrees: number): Color {
238
+ return this.with({ hue: this.hue + degrees });
239
+ }
240
+
241
+ /** k times the chroma: 0 is white, 1 unchanged, 2 twice as saturated. */
242
+ saturate(k: number): Color {
243
+ return new Color(this.a * k, this.b * k, this.lm, this.fidelity);
244
+ }
245
+
246
+ /** Dimmed, if need be, to what a bulb makes of it (a color bulb, br30, unless told), so it isn't paled. */
247
+ vivid(bulb: Bulb = br30): Color {
248
+ return bulb.vivid(this);
249
+ }
250
+
251
+ /** Like "2700 K · 400 lm" or "265° C0.300 · 40 lm"; color() reads it back. */
252
+ toString(): string {
253
+ if (!(this.lm > 0)) return "off";
254
+ const k = this.kelvin;
255
+ const name = k !== null ? `${Math.round(k)} K` : `${+this.hue.toFixed(1)}° C${this.chroma.toFixed(3)}`;
256
+ const f = this.fidelity !== 1 ? ` · F${this.fidelity.toFixed(2)}` : "";
257
+ return `${name} · ${+this.lm.toFixed(2)} lm${f}`;
258
+ }
259
+
260
+ toJSON(): { a: number; b: number; lm: number; fidelity: number } {
261
+ return { a: this.a, b: this.b, lm: this.lm, fidelity: this.fidelity };
262
+ }
263
+ }
264
+
265
+ /** No light. */
266
+ export const off: Color = Object.freeze(new Color(0, 0, 0));
267
+
268
+ // An old bulb that's on, as a color: a 2700 K incandescent of about 800 lm.
269
+ const OLD_BULB = (() => {
270
+ const [a, b] = whiteAB(2700);
271
+ return Object.freeze(new Color(a, b, 800));
272
+ })();
273
+
274
+ // ---- making colors ----------------------------------------------------------------
275
+
276
+ /**
277
+ * Anything that says a color, as a Color (see the top of color.ts), at `lm`
278
+ * lumens if given. Throws, saying why, if it can't tell what color it is.
279
+ */
280
+ export function color(input: ColorInput, lm?: number): Color {
281
+ const c = read(input);
282
+ return lm === undefined ? c : c.at(lm);
283
+ }
284
+
285
+ /**
286
+ * A white of this color temperature (2700 K is a warm bulb, 4000 K neutral,
287
+ * 6500 K daylight; the bulbs go as warm as about 1800 K, dimly), at `lm`
288
+ * lumens (1000 if not given). With no temperature, OKLab's own white.
289
+ */
290
+ export function white(kelvin?: number | string, lm = SCREEN_WHITE_LM): Color {
291
+ if (kelvin === undefined) return new Color(0, 0, lm);
292
+ const [a, b] = whiteAB(kelvinIn(typeof kelvin === "string" ? parseFloat(kelvin) : kelvin));
293
+ return new Color(a, b, lm);
294
+ }
295
+
296
+ /**
297
+ * A color in the lights' own terms: hue in degrees (OKLab's: about 30 red,
298
+ * 110 yellow, 145 green, 265 blue, 330 magenta), chroma at full lightness (0
299
+ * white, 0.1 pale, 0.3 strong, up to about 0.8 for the bulbs' violet), and
300
+ * lumens (1000 if not given).
301
+ */
302
+ export function lemtone(hue: number, chroma: number, lm = SCREEN_WHITE_LM, fidelity = 1): Color {
303
+ const [a, b] = abOf(hue, chroma);
304
+ return new Color(a, b, lm, fidelity);
305
+ }
306
+
307
+ /** CSS's oklch(): lightness 0–1 (past 1, a percentage), chroma, hue in degrees; L = 1 is 1000 lm of white. */
308
+ export function oklch(l: number, c: number, h: number, alpha = 1): Color {
309
+ const r = (h * Math.PI) / 180;
310
+ return fromXyz(oklabToXyz([unitOrPercent(l), c * Math.cos(r), c * Math.sin(r)])).dim(alpha);
311
+ }
312
+
313
+ /** CSS's oklab(): lightness 0–1 (past 1, a percentage), a, b; L = 1 is 1000 lm of white. */
314
+ export function oklab(l: number, a: number, b: number, alpha = 1): Color {
315
+ return fromXyz(oklabToXyz([unitOrPercent(l), a, b])).dim(alpha);
316
+ }
317
+
318
+ /** sRGB, 0–1 per channel (0–255 if any is past 1), white at 1000 lm. */
319
+ export function rgb(r: number, g: number, b: number, alpha = 1): Color {
320
+ const k = r > 1 || g > 1 || b > 1 ? 255 : 1;
321
+ return fromSrgb([r / k, g / k, b / k]).dim(alpha);
322
+ }
323
+
324
+ /** HSL: hue in degrees, saturation and lightness 0–1 (past 1, percentages), white at 1000 lm. */
325
+ export function hsl(h: number, s: number, l: number, alpha = 1): Color {
326
+ return fromSrgb(hslToSrgb(h, unitOrPercent(s), unitOrPercent(l))).dim(alpha);
327
+ }
328
+
329
+ /** A CSS hex code ("#f80", "#ff8800", "ff8800cc"), or anything else color() reads. */
330
+ export const hex = (code: string): Color => color(code);
331
+
332
+ /** A CIE 1931 chromaticity, at `lm` lumens (1000 if not given). */
333
+ export function xy(x: number, y: number, lm = SCREEN_WHITE_LM): Color {
334
+ const [a, b] = abOfXy([x, y]);
335
+ return new Color(a, b, lm);
336
+ }
337
+
338
+ // The Planckian spline's range (Kim et al.): warmer or cooler is held at its ends.
339
+ const kelvinIn = (k: number) => Math.min(25000, Math.max(1667, k));
340
+
341
+ const unitOrPercent = (v: number) => (Math.abs(v) > 1 ? v / 100 : v);
342
+
343
+ /** Light as XYZ (Y = 1 for 1000 lm), as a Color. */
344
+ function fromXyz(X: Vec3): Color {
345
+ if (!(X[1] > 0)) return off;
346
+ const [a, b] = xyzToAb(X);
347
+ return new Color(a, b, X[1] * SCREEN_WHITE_LM);
348
+ }
349
+
350
+ const fromSrgb = (rgb: Vec3) => fromXyz(linearSrgbToXyz(rgb.map(decode) as Vec3));
351
+
352
+ function read(input: unknown): Color {
353
+ if (input instanceof Color) return input;
354
+ if (input === null || input === undefined || input === false) return off;
355
+ if (typeof input === "string") {
356
+ const spec = parseColor(input);
357
+ if (!spec) {
358
+ throw new Error(
359
+ `"${input}" isn't a color I can read: try a CSS color ("#ff8800", "orange", "oklch(0.7 0.2 265)"), a color temperature ("2700K"), or "265° C0.3 · 40 lm"`,
360
+ );
361
+ }
362
+ return fromSpec(spec);
363
+ }
364
+ if (typeof input === "number") return fromKelvin(input);
365
+ if (Array.isArray(input)) return fromArray(input);
366
+ if (typeof input === "object") return fromObject(input as Record<string, unknown>);
367
+ throw new Error(`${String(input)} isn't a color`);
368
+ }
369
+
370
+ function fromKelvin(k: number): Color {
371
+ if (!(k >= 500 && k <= 100000)) {
372
+ throw new Error(`${k} isn't a color: a number is read as a color temperature in kelvin, like 2700 (a warm bulb) or 6500 (daylight)`);
373
+ }
374
+ return white(k);
375
+ }
376
+
377
+ function fromArray(list: readonly unknown[]): Color {
378
+ if (list.length >= 3 && list.length <= 4 && list.every((v) => typeof v === "number")) {
379
+ const [r, g, b, alpha = 1] = list as number[];
380
+ return rgb(r!, g!, b!, alpha > 1 ? alpha / 255 : alpha);
381
+ }
382
+ throw new Error(`[${list.join(", ")}] isn't a color: as a list, give [r, g, b] (0–1, or 0–255)`);
383
+ }
384
+
385
+ function fromSpec(spec: Spec): Color {
386
+ let c: Color;
387
+ switch (spec.space) {
388
+ case "off":
389
+ c = off;
390
+ break;
391
+ case "srgb":
392
+ c = fromSrgb(spec.rgb);
393
+ break;
394
+ case "srgb-linear":
395
+ c = fromXyz(linearSrgbToXyz(spec.rgb));
396
+ break;
397
+ case "display-p3":
398
+ c = fromXyz(linearP3ToXyz(spec.rgb.map(decode) as Vec3));
399
+ break;
400
+ case "xyz":
401
+ c = fromXyz(spec.xyz);
402
+ break;
403
+ case "xyz-d50":
404
+ c = fromXyz(xyzD50ToXyz(spec.xyz));
405
+ break;
406
+ case "hsl":
407
+ c = fromSrgb(hslToSrgb(spec.h, spec.s, spec.l));
408
+ break;
409
+ case "hwb":
410
+ c = fromSrgb(hwbToSrgb(spec.h, spec.w, spec.b));
411
+ break;
412
+ case "oklab":
413
+ c = fromXyz(oklabToXyz(spec.lab));
414
+ break;
415
+ case "oklch": {
416
+ const r = (spec.h * Math.PI) / 180;
417
+ c = fromXyz(oklabToXyz([spec.l, spec.c * Math.cos(r), spec.c * Math.sin(r)]));
418
+ break;
419
+ }
420
+ case "lab":
421
+ c = fromXyz(labToXyz(...spec.lab));
422
+ break;
423
+ case "lch": {
424
+ const r = (spec.h * Math.PI) / 180;
425
+ c = fromXyz(labToXyz(spec.l, spec.c * Math.cos(r), spec.c * Math.sin(r)));
426
+ break;
427
+ }
428
+ case "kelvin":
429
+ c = white(spec.kelvin);
430
+ break;
431
+ case "lemtone":
432
+ c = lemtone(spec.hue, spec.chroma);
433
+ break;
434
+ }
435
+ if (spec.alpha !== undefined) c = c.dim(clamp(spec.alpha));
436
+ if (spec.lm !== undefined) c = c.at(spec.lm);
437
+ if (spec.fidelity !== undefined) c = c.with({ fidelity: spec.fidelity });
438
+ return c;
439
+ }
440
+
441
+ /** The first of these an object has as a number (numbers given as text too). */
442
+ function numberIn(o: Record<string, unknown>, ...keys: string[]): number | undefined {
443
+ for (const key of keys) {
444
+ const v = o[key];
445
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : typeof v === "boolean" ? Number(v) : NaN;
446
+ if (Number.isFinite(n)) return n;
447
+ }
448
+ return undefined;
449
+ }
450
+
451
+ function fromObject(o: Record<string, unknown>): Color {
452
+ const n = (...keys: string[]) => numberIn(o, ...keys);
453
+ const has = (...keys: string[]) => keys.every((k) => n(k) !== undefined);
454
+ const alpha = (...keys: string[]) => {
455
+ const v = n(...keys);
456
+ return v === undefined ? 1 : v > 1 ? v / 255 : v;
457
+ };
458
+
459
+ let c: Color;
460
+ if (has("on") && !has("a") && !has("r")) c = n("on")! >= 0.5 ? OLD_BULB : off;
461
+ else if (has("r", "g", "b")) c = rgb(n("r")!, n("g")!, n("b")!, alpha("alpha", "a", "opacity"));
462
+ else if (has("h", "s", "l")) c = hsl(n("h")!, n("s")!, n("l")!, alpha("alpha", "a", "opacity"));
463
+ else if (has("h", "w", "b") && !has("a")) c = fromSrgb(hwbToSrgb(n("h")!, unitOrPercent(n("w")!), unitOrPercent(n("b")!)));
464
+ else if (has("l", "c", "h")) c = oklch(n("l")!, n("c")!, n("h")!);
465
+ else if (has("L", "C", "H")) c = oklch(n("L")!, n("C")!, n("H")!);
466
+ else if (has("l", "a", "b")) c = oklab(n("l")!, n("a")!, n("b")!);
467
+ else if (has("L", "a", "b")) c = oklab(n("L")!, n("a")!, n("b")!);
468
+ else if (n("hue", "chroma") !== undefined) c = lemtone(n("hue") ?? 0, n("chroma") ?? 0);
469
+ else if (n("kelvin", "k", "K", "cct") !== undefined) c = white(n("kelvin", "k", "K", "cct")!);
470
+ else if (has("x", "y")) c = xy(n("x")!, n("y")!, n("Y") !== undefined ? n("Y")! * SCREEN_WHITE_LM : SCREEN_WHITE_LM);
471
+ else if (has("a", "b") || n("lm", "lumens", "brightness") !== undefined) c = new Color(n("a") ?? 0, n("b") ?? 0, SCREEN_WHITE_LM);
472
+ else throw new Error(`${JSON.stringify(o)} isn't a color I can read: give it { a, b, lm }, { r, g, b }, { h, s, l }, { l, c, h }, { hue, chroma }, { kelvin }, or { x, y }`);
473
+
474
+ const lm = n("lm", "lumens", "lumen");
475
+ const brightness = n("brightness");
476
+ if (lm !== undefined) c = c.at(lm);
477
+ else if (brightness !== undefined) c = c.at(brightness * LUMENS_PER_BRIGHTNESS);
478
+ const fidelity = n("fidelity");
479
+ if (fidelity !== undefined) c = c.with({ fidelity });
480
+ return c;
481
+ }
482
+
483
+ // ---- palettes ---------------------------------------------------------------------
484
+
485
+ /** Colors along a line from 0 to 1: palette(x) is the color at x. */
486
+ export interface Palette {
487
+ (x: number): Color;
488
+ /** Given a here instead, the palette laid across the room, left to right: its color at here.u. */
489
+ (here: Here): Color;
490
+ readonly stops: readonly Color[];
491
+ /** Where each stop is, 0 to 1. */
492
+ readonly positions: readonly number[];
493
+ }
494
+
495
+ /** A palette's stop: a color, or a color at a place, [0.3, "#f80"]. */
496
+ export type Stop = ColorInput | readonly [number, ColorInput];
497
+
498
+ const isPositioned = (s: unknown): s is readonly [number, ColorInput] => Array.isArray(s) && s.length === 2 && typeof s[0] === "number";
499
+ const isRgbList = (s: unknown) => Array.isArray(s) && s.length >= 3 && s.every((v) => typeof v === "number");
500
+
501
+ /** Whether v is a palette. */
502
+ export const isPalette = (v: unknown): v is Palette => typeof v === "function" && Array.isArray((v as Partial<Palette>).stops);
503
+
504
+ /**
505
+ * A palette through these colors (evenly spaced from 0 to 1 unless placed,
506
+ * [0.3, "#f80"]), mixed between them; outside 0–1 it holds the ends.
507
+ * palette(a, b, c) and palette([a, b, c]) are the same.
508
+ */
509
+ export function palette(...given: Stop[] | [readonly Stop[]]): Palette {
510
+ const list: readonly Stop[] =
511
+ given.length === 1 && Array.isArray(given[0]) && !isPositioned(given[0]) && !isRgbList(given[0]) ? (given[0] as readonly Stop[]) : (given as Stop[]);
512
+ if (list.length === 0) throw new Error("palette(): give it at least one color");
513
+ const placed = list
514
+ .map((s, i) => (isPositioned(s) ? { at: clamp(s[0]), c: color(s[1]) } : { at: list.length > 1 ? i / (list.length - 1) : 0, c: color(s as ColorInput) }))
515
+ .sort((x, y) => x.at - y.at);
516
+ const stops = Object.freeze(placed.map((p) => Object.freeze(p.c)));
517
+ const positions = Object.freeze(placed.map((p) => p.at));
518
+ const at = (x: number | Here): Color => {
519
+ const along = typeof x === "number" ? x : (x as Partial<Here> | null)?.u;
520
+ const v = typeof along === "number" && Number.isFinite(along) ? along : 0;
521
+ if (v <= positions[0]!) return stops[0]!;
522
+ for (let i = 1; i < stops.length; i++) {
523
+ if (v <= positions[i]!) {
524
+ const span = positions[i]! - positions[i - 1]!;
525
+ return span > 0 ? stops[i - 1]!.mix(stops[i]!, (v - positions[i - 1]!) / span) : stops[i]!;
526
+ }
527
+ }
528
+ return stops[stops.length - 1]!;
529
+ };
530
+ return Object.assign(at, { stops, positions }) as Palette;
531
+ }
532
+
533
+ /** A palette from anything that says one: a palette, a list of stops, or one color (from dark at 0 to it at 1). */
534
+ export function toPalette(v: unknown): Palette {
535
+ if (isPalette(v)) return v;
536
+ if (Array.isArray(v) && !isRgbList(v)) return palette(v as Stop[]);
537
+ return palette(off, v as ColorInput);
538
+ }
539
+
540
+ /** Palettes to start from, each dark to bright, within what the color bulbs make. */
541
+ export const palettes = {
542
+ /** Deep violet, magenta, orange, warm white. */
543
+ dusk: palette(lemtone(285, 0.3, 6), lemtone(330, 0.28, 30), lemtone(45, 0.2, 150), white(2700, 400)),
544
+ /** Deep blue, teal, pale cyan, cool white. */
545
+ sea: palette(lemtone(265, 0.35, 6), lemtone(220, 0.2, 40), lemtone(195, 0.12, 160), white(6500, 400)),
546
+ /** Ember red, orange, firelight, warm white. */
547
+ ember: palette(lemtone(30, 0.32, 4), lemtone(45, 0.28, 30), white(2000, 80), white(2700, 400)),
548
+ /** Green, teal, violet: northern lights. */
549
+ aurora: palette(lemtone(150, 0.25, 8), lemtone(165, 0.22, 60), lemtone(200, 0.15, 90), lemtone(300, 0.2, 40)),
550
+ };
551
+
552
+ // ---- what bulbs make ----------------------------------------------------------------
553
+
554
+ /** What a kind of light can make of each color. */
555
+ export interface Bulb {
556
+ /** arclamp's name for it. */
557
+ readonly type: string;
558
+ /** The most lumens it makes of this color before it starts to pale it; 0 past its most saturated. */
559
+ maxLm(c: ColorInput): number;
560
+ /** The most chroma it makes of this hue. */
561
+ edgeOf(hue: number): number;
562
+ /** The color, dimmed if need be to what it makes, so it isn't paled; one more saturated than it makes at all, brought in to the most it does. */
563
+ vivid(c: ColorInput): Color;
564
+ }
565
+
566
+ // vivid() keeps this far inside what the table says, for the few percent it can be over.
567
+ const MARGIN = 0.97;
568
+
569
+ /**
570
+ * The color bulbs (br30), from vibes' model of Walter's solve: asked for more
571
+ * than they can make of a color, they keep the lumens and pale it toward
572
+ * white. A strong blue tops out near 30 lm; whites near 950 at 4000 K, 500 at
573
+ * 2700 K.
574
+ */
575
+ export const br30: Bulb = {
576
+ type: "br30",
577
+ maxLm: (c) => {
578
+ const k = color(c);
579
+ return maxLmOf(k.a, k.b);
580
+ },
581
+ edgeOf,
582
+ vivid(input) {
583
+ const c = color(input);
584
+ const { hue, chroma } = hueChroma(c.a, c.b);
585
+ const edge = edgeOf(hue) * 0.999;
586
+ const made = chroma > edge ? c.with({ chroma: edge }) : c;
587
+ const most = maxLmOf(made.a, made.b) * MARGIN;
588
+ return made.lm > most ? made.at(most) : made;
589
+ },
590
+ };