@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/parse.ts ADDED
@@ -0,0 +1,205 @@
1
+ // Colors from text, read as generously as can be: anything CSS takes (hex
2
+ // codes with or without #, names, rgb(), hsl(), hwb(), oklch(), oklab(),
3
+ // lab(), lch(), color()), color temperatures ("2700K", "candlelight"), the
4
+ // lights' own ("lemtone(265 0.3 40)", or "265° C0.30 · 40 lm" as a color
5
+ // prints itself), and any of those with lumens or a fidelity alongside
6
+ // ("#f80 · 120 lm", "2700K @ 400lm").
7
+ //
8
+ // This only reads the text: what it says comes back as a Spec, and color.ts
9
+ // makes the color.
10
+
11
+ import type { Vec3 } from "./lemtone.ts";
12
+ import { cssNamed, LIGHT_NAMES } from "./names.ts";
13
+
14
+ /** What some text says a color is. */
15
+ export type Spec = { alpha?: number; lm?: number; fidelity?: number } & (
16
+ | { space: "off" }
17
+ | { space: "srgb" | "srgb-linear" | "display-p3"; rgb: Vec3 }
18
+ | { space: "xyz" | "xyz-d50"; xyz: Vec3 }
19
+ | { space: "hsl"; h: number; s: number; l: number }
20
+ | { space: "hwb"; h: number; w: number; b: number }
21
+ | { space: "oklab" | "lab"; lab: Vec3 }
22
+ | { space: "oklch" | "lch"; l: number; c: number; h: number }
23
+ | { space: "kelvin"; kelvin: number }
24
+ | { space: "lemtone"; hue: number; chroma: number }
25
+ );
26
+
27
+ type Unit = "" | "%" | "deg" | "rad" | "grad" | "turn";
28
+ interface Token {
29
+ value: number;
30
+ unit: Unit;
31
+ }
32
+
33
+ const NUMBER = /^([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)(%|deg|rad|grad|turn)?$/;
34
+
35
+ function token(text: string): Token | null {
36
+ if (text === "none") return { value: 0, unit: "" };
37
+ const m = NUMBER.exec(text);
38
+ return m ? { value: Number(m[1]), unit: (m[2] ?? "") as Unit } : null;
39
+ }
40
+
41
+ /** An angle in degrees. */
42
+ function angle(t: Token): number {
43
+ if (t.unit === "rad") return (t.value * 180) / Math.PI;
44
+ if (t.unit === "grad") return t.value * 0.9;
45
+ if (t.unit === "turn") return t.value * 360;
46
+ return t.value;
47
+ }
48
+
49
+ /** A number, with 100% as `hundred`. */
50
+ const scaled = (t: Token, hundred: number) => (t.unit === "%" ? (t.value / 100) * hundred : t.value);
51
+
52
+ /** A fraction 0–1, from a percentage, or a number: 0–1 as it is, past 1 read as a percentage (hsl(240, 100, 50)). */
53
+ const fraction = (t: Token) => (t.unit === "%" || Math.abs(t.value) > 1 ? t.value / 100 : t.value);
54
+
55
+ /** Lightness 0–1, from a percentage, or a number: 0–1 as it is, past 1 read as a percentage (oklch(70 0.2 265)). */
56
+ const lightness = fraction;
57
+
58
+ /** What `text` says a color is, or null if it can't be read. */
59
+ export function parseColor(text: string): Spec | null {
60
+ const s = text.trim().toLowerCase();
61
+ if (!s) return null;
62
+ const whole = one(s);
63
+ if (whole) return whole;
64
+ // A color, its lumens and its fidelity, in any order: "#f80 · 120 lm", "2700k @ 400lm", "2700k 400lm".
65
+ const found: Found = {};
66
+ for (const part of s.split(/\s*[·@]\s*/)) {
67
+ if (!part || read(part, found)) continue;
68
+ if (part.includes("(")) return null;
69
+ // Word by word, a number with the unit after it first ("50 lumens", "fidelity 0.5").
70
+ const words = part.split(/\s+/);
71
+ for (let i = 0; i < words.length; i++) {
72
+ if (i + 1 < words.length && read(`${words[i]} ${words[i + 1]}`, found)) i++;
73
+ else if (!read(words[i]!, found)) return null;
74
+ }
75
+ }
76
+ if (!found.spec) return null;
77
+ if (found.lm !== undefined) found.spec.lm = found.lm;
78
+ if (found.fidelity !== undefined) found.spec.fidelity = found.fidelity;
79
+ return found.spec;
80
+ }
81
+
82
+ interface Found {
83
+ spec?: Spec;
84
+ lm?: number;
85
+ fidelity?: number;
86
+ }
87
+
88
+ /** One part of a longer text, into `found`: lumens ("400 lm"), a fidelity ("F0.50"), or the color. */
89
+ function read(part: string, found: Found): boolean {
90
+ let m: RegExpExecArray | null;
91
+ if ((m = /^([-+]?(?:\d+\.?\d*|\.\d+))\s*(?:lm|lumens?)$/.exec(part))) {
92
+ found.lm = Number(m[1]);
93
+ return true;
94
+ }
95
+ // "f0.5" needs its point, so it isn't taken for a hex code like "f80".
96
+ if ((m = /^(?:f(\d*\.\d+)|fidelity\s*[:=]?\s*(\d*\.?\d+))$/.exec(part))) {
97
+ found.fidelity = Number(m[1] ?? m[2]);
98
+ return true;
99
+ }
100
+ const c = one(part);
101
+ if (!c || found.spec) return false;
102
+ found.spec = c;
103
+ return true;
104
+ }
105
+
106
+ /** One color, alone. */
107
+ function one(s: string): Spec | null {
108
+ if (s === "off" || s === "none" || s === "dark" || s === "transparent") return { space: "off" };
109
+ const name = cssNamed(s.replace(/[\s_-]/g, ""));
110
+ if (name) return hex(name);
111
+ const kelvin = LIGHT_NAMES[s.replace(/[\s_-]/g, "")];
112
+ if (kelvin) return { space: "kelvin", kelvin };
113
+
114
+ let m: RegExpExecArray | null;
115
+ if ((m = /^(\d+(?:\.\d+)?)\s*(?:k|kelvin)$/.exec(s))) return { space: "kelvin", kelvin: Number(m[1]) };
116
+ // As a color prints itself: "265° c0.300".
117
+ if ((m = /^([-+]?\d+(?:\.\d+)?)\s*(?:°|deg)\s*c\s*(\d*\.?\d+)$/.exec(s))) return { space: "lemtone", hue: Number(m[1]), chroma: Number(m[2]) };
118
+ if (/^#?(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(s)) return hex(s.replace("#", ""));
119
+ if ((m = /^([a-z][a-z0-9-]*)\((.*)\)$/.exec(s))) return fn(m[1]!, m[2]!);
120
+ return null;
121
+ }
122
+
123
+ /** Hex digits (3, 4, 6 or 8 of them, the last as alpha) as sRGB. */
124
+ function hex(digits: string): Spec {
125
+ const full = digits.length <= 4 ? [...digits].map((d) => d + d).join("") : digits;
126
+ const at = (i: number) => parseInt(full.slice(i, i + 2), 16) / 255;
127
+ const spec: Spec = { space: "srgb", rgb: [at(0), at(2), at(4)] };
128
+ if (full.length === 8) spec.alpha = at(6);
129
+ return spec;
130
+ }
131
+
132
+ /** A function: rgb(…), oklch(…), color(display-p3 …), lemtone(…), … */
133
+ function fn(name: string, inside: string): Spec | null {
134
+ const [main = "", alphaText] = inside.split("/");
135
+ const words = main.trim().split(/[\s,]+/).filter(Boolean);
136
+ let space: string | undefined;
137
+ if (name === "color") space = words.shift();
138
+ const t = words.map(token);
139
+ if (t.some((x) => x === null)) return null;
140
+ const v = t as Token[];
141
+ // Old-style rgba(1, 2, 3, 0.5): a fourth number is alpha.
142
+ let alpha: number | undefined;
143
+ if (alphaText !== undefined) {
144
+ const a = token(alphaText.trim());
145
+ if (!a) return null;
146
+ alpha = scaled(a, 1);
147
+ } else if (v.length === 4 && name !== "lemtone") alpha = scaled(v.pop()!, 1);
148
+
149
+ const spec = make(name, space, v);
150
+ if (spec && alpha !== undefined) spec.alpha = alpha;
151
+ return spec;
152
+ }
153
+
154
+ function make(name: string, space: string | undefined, v: Token[]): Spec | null {
155
+ const need = (n: number) => v.length >= n;
156
+ switch (name) {
157
+ case "rgb":
158
+ case "rgba":
159
+ if (!need(3)) return null;
160
+ return { space: "srgb", rgb: [scaled(v[0]!, 255) / 255, scaled(v[1]!, 255) / 255, scaled(v[2]!, 255) / 255] };
161
+ case "hsl":
162
+ case "hsla":
163
+ if (!need(3)) return null;
164
+ return { space: "hsl", h: angle(v[0]!), s: fraction(v[1]!), l: fraction(v[2]!) };
165
+ case "hwb":
166
+ if (!need(3)) return null;
167
+ return { space: "hwb", h: angle(v[0]!), w: fraction(v[1]!), b: fraction(v[2]!) };
168
+ case "oklab":
169
+ if (!need(3)) return null;
170
+ return { space: "oklab", lab: [lightness(v[0]!), scaled(v[1]!, 0.4), scaled(v[2]!, 0.4)] };
171
+ case "oklch":
172
+ if (!need(3)) return null;
173
+ return { space: "oklch", l: lightness(v[0]!), c: scaled(v[1]!, 0.4), h: angle(v[2]!) };
174
+ case "lab":
175
+ if (!need(3)) return null;
176
+ return { space: "lab", lab: [scaled(v[0]!, 100), scaled(v[1]!, 125), scaled(v[2]!, 125)] };
177
+ case "lch":
178
+ if (!need(3)) return null;
179
+ return { space: "lch", l: scaled(v[0]!, 100), c: scaled(v[1]!, 150), h: angle(v[2]!) };
180
+ case "color": {
181
+ if (!need(3)) return null;
182
+ const c: Vec3 = [scaled(v[0]!, 1), scaled(v[1]!, 1), scaled(v[2]!, 1)];
183
+ if (space === "srgb" || space === "srgb-linear" || space === "display-p3") return { space, rgb: c };
184
+ if (space === "xyz" || space === "xyz-d65") return { space: "xyz", xyz: c };
185
+ if (space === "xyz-d50") return { space: "xyz-d50", xyz: c };
186
+ return null;
187
+ }
188
+ case "lemtone": {
189
+ if (!need(2)) return null;
190
+ const spec: Spec = { space: "lemtone", hue: angle(v[0]!), chroma: v[1]!.value };
191
+ if (v[2]) spec.lm = v[2].value;
192
+ if (v[3]) spec.fidelity = v[3].value;
193
+ return spec;
194
+ }
195
+ case "white":
196
+ case "kelvin": {
197
+ if (!need(1)) return null;
198
+ const spec: Spec = { space: "kelvin", kelvin: v[0]!.value };
199
+ if (v[1]) spec.lm = v[1].value;
200
+ return spec;
201
+ }
202
+ default:
203
+ return null;
204
+ }
205
+ }
package/src/shape.ts ADDED
@@ -0,0 +1,109 @@
1
+ // Numbers to numbers: waves, easing, ranges, hashing, noise, and time steps.
2
+ // Waves count in cycles: wave(t / 8) goes round once every 8 seconds. They
3
+ // all run 0 to 1, starting at 0, so they make good brightnesses and mixes.
4
+
5
+ const TAU = 2 * Math.PI;
6
+
7
+ /** The fractional part: 2.25 → 0.25, -0.25 → 0.75. */
8
+ export const fract = (x: number) => x - Math.floor(x);
9
+
10
+ export const clamp = (x: number, lo = 0, hi = 1) => (x < lo ? lo : x > hi ? hi : x);
11
+
12
+ /** From a (at t = 0) to b (at t = 1). */
13
+ export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
14
+
15
+ /** x from the range [inLo, inHi] to [outLo, outHi], not clamped. */
16
+ export const remap = (x: number, inLo: number, inHi: number, outLo = 0, outHi = 1) =>
17
+ outLo + ((x - inLo) / (inHi - inLo || 1)) * (outHi - outLo);
18
+
19
+ /** 0 below e0, 1 above e1, and a smooth S between. */
20
+ export function smoothstep(e0: number, e1: number, x: number): number {
21
+ const t = clamp((x - e0) / (e1 - e0 || 1));
22
+ return t * t * (3 - 2 * t);
23
+ }
24
+
25
+ /** A smooth wave, one cycle per unit: 0 at whole numbers, 1 halfway between. */
26
+ export const wave = (x: number) => 0.5 - 0.5 * Math.cos(TAU * x);
27
+
28
+ /** Rises from 0 to 1 over each cycle, then drops back at once. */
29
+ export const saw = (x: number) => fract(x);
30
+
31
+ /** Rises from 0 to 1 over the first half of each cycle and falls back over the second. */
32
+ export const tri = (x: number) => 1 - Math.abs(2 * fract(x) - 1);
33
+
34
+ /** 1 for the first `duty` of each cycle, 0 for the rest: a hard on and off. */
35
+ export const square = (x: number, duty = 0.5) => (fract(x) < duty ? 1 : 0);
36
+
37
+ /** Easings, 0 to 1 onto 0 to 1 (cubic). */
38
+ export const ease = {
39
+ in: (x: number) => clamp(x) ** 3,
40
+ out: (x: number) => 1 - (1 - clamp(x)) ** 3,
41
+ inOut: (x: number) => {
42
+ const t = clamp(x);
43
+ return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
44
+ },
45
+ };
46
+
47
+ // Each number's exact bits, so any number (not only whole ones) hashes.
48
+ const bits = new Float64Array(1);
49
+ const words = new Uint32Array(bits.buffer);
50
+
51
+ /** A number from 0 to 1 that looks random but is always the same for the same inputs. */
52
+ export function hash(...ns: number[]): number {
53
+ let h = 0x9e3779b9;
54
+ for (const n of ns) {
55
+ bits[0] = n === 0 ? 0 : n; // -0 and 0 hash the same
56
+ h = mixBits(h ^ words[0]!);
57
+ h = mixBits(h ^ words[1]!);
58
+ }
59
+ return (h >>> 0) / 4294967296;
60
+ }
61
+
62
+ function mixBits(h: number): number {
63
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
64
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
65
+ return h ^ (h >>> 16);
66
+ }
67
+
68
+ // Whole-number lattice hashing for noise: faster than hash() and as good there.
69
+ function lattice(x: number, y: number, z: number): number {
70
+ let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1) ^ Math.imul(z, 0x9e3779b1);
71
+ h = mixBits(h);
72
+ return (h >>> 0) / 4294967296;
73
+ }
74
+
75
+ const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
76
+
77
+ /**
78
+ * Smooth noise from 0 to 1, in up to three dimensions: nearby inputs give
79
+ * nearby values, and features are about 1 unit apart. noiseAt(x / 4, y / 4,
80
+ * t / 5) makes blobs about 4 m across that change over about 5 s.
81
+ */
82
+ export function noiseAt(x: number, y = 0, z = 0): number {
83
+ const xi = Math.floor(x);
84
+ const yi = Math.floor(y);
85
+ const zi = Math.floor(z);
86
+ const u = fade(x - xi);
87
+ const v = fade(y - yi);
88
+ const w = fade(z - zi);
89
+ const c = (dx: number, dy: number, dz: number) => lattice(xi + dx, yi + dy, zi + dz);
90
+ const x00 = lerp(c(0, 0, 0), c(1, 0, 0), u);
91
+ const x10 = lerp(c(0, 1, 0), c(1, 1, 0), u);
92
+ const x01 = lerp(c(0, 0, 1), c(1, 0, 1), u);
93
+ const x11 = lerp(c(0, 1, 1), c(1, 1, 1), u);
94
+ return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w);
95
+ }
96
+
97
+ /**
98
+ * How much of something is left after dt seconds, if half goes every
99
+ * `halfLife` seconds: value *= decay(dt, 2) halves it every 2 s, whatever the
100
+ * tick rate.
101
+ */
102
+ export const decay = (dt: number, halfLife: number) => (halfLife > 0 ? 0.5 ** (dt / halfLife) : 0);
103
+
104
+ /**
105
+ * True with the chance of something that happens `perSecond` times a second
106
+ * on average, over the last dt seconds: the same in the room (100 ticks a
107
+ * second) as in the preview (about 30).
108
+ */
109
+ export const chance = (perSecond: number, dt: number) => Math.random() < 1 - Math.exp(-perSecond * dt);
package/src/signal.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Signals: fields that only read the time, for wiring into any number a field
2
+ // or source takes. A clock counts beats; every(n) marks each n-th one, and
3
+ // from those come envelopes (a flash on each), sequences (a new value each)
4
+ // and ramps (0 to 1 across each).
5
+ //
6
+ // const beat = clock(96);
7
+ // osc(4).rotate(beat.every(4).seq([0, 45, 90, 135])).color(palettes.dusk).boost(beat.every(1).env(), 0.8)
8
+
9
+ import { field, type Field } from "./field.ts";
10
+ import { fract } from "./shape.ts";
11
+
12
+ export interface Every {
13
+ /** How many marks so far: 0, 1, 2, … */
14
+ count: Field<number>;
15
+ /** 0 to 1 from one mark to the next. */
16
+ ramp: Field<number>;
17
+ /** Seconds since the last mark. */
18
+ since: Field<number>;
19
+ /** 0, up to 1 in `attack` seconds after each mark (default 0.01), and back to 0 over `release` (default 0.3). */
20
+ env(options?: { attack?: number; release?: number }): Field<number>;
21
+ /** The next of these at each mark, round and round. */
22
+ seq<T>(list: readonly T[]): Field<T>;
23
+ }
24
+
25
+ export interface Clock {
26
+ readonly bpm: number;
27
+ /** Beats so far (not whole: 2.5 is halfway through the third). */
28
+ beats: Field<number>;
29
+ /** Marks every n beats: every(1) is each beat, every(4) each bar of four. */
30
+ every(n: number): Every;
31
+ }
32
+
33
+ /** A clock at `bpm` beats a minute, starting with the sketch. */
34
+ export function clock(bpm: number): Clock {
35
+ const beats = field(({ t }) => (t * bpm) / 60);
36
+ return {
37
+ bpm,
38
+ beats,
39
+ every(n: number): Every {
40
+ const at = (t: number) => (t * bpm) / 60 / n;
41
+ const seconds = (60 / bpm) * n;
42
+ const since = field(({ t }) => fract(at(t)) * seconds);
43
+ return {
44
+ count: field(({ t }) => Math.floor(at(t))),
45
+ ramp: field(({ t }) => fract(at(t))),
46
+ since,
47
+ env({ attack = 0.01, release = 0.3 } = {}) {
48
+ return field((here) => {
49
+ const s = since(here);
50
+ if (s < attack) return s / attack;
51
+ const k = Math.max(0, 1 - (s - attack) / release);
52
+ return k * k;
53
+ });
54
+ },
55
+ seq<T>(list: readonly T[]) {
56
+ if (list.length === 0) throw new Error("seq(): give it at least one value");
57
+ return field(({ t }) => {
58
+ const i = Math.floor(at(t)) % list.length;
59
+ return list[i < 0 ? i + list.length : i]!;
60
+ });
61
+ },
62
+ };
63
+ },
64
+ };
65
+ }
package/src/sketch.ts ADDED
@@ -0,0 +1,172 @@
1
+ // sketch(): your looks as arclamp's update().
2
+ //
3
+ // Color lights and old bulbs are controlled apart, each by a look of its own,
4
+ // named for the kind of light it's for (light.kind):
5
+ //
6
+ // sketch({ color: osc(3).color(palettes.dusk), switch: osc(1, 0.25).thresh() })
7
+ //
8
+ // The color look is asked about the color bulbs and panels only: a function
9
+ // of here (your own, or a field) giving a color, one color for every light,
10
+ // or a palette laid across the room. Anything that says a color will do (a
11
+ // Color, "#f80", "2700K", { r, g, b }, …). sketch(look) is
12
+ // sketch({ color: look }).
13
+ //
14
+ // The switch look is asked about the old bulbs only, which just switch: true or
15
+ // false, a number (on at 0.5 or more, as arclamp's own `on` channel), "on" or
16
+ // "off", or a function of here (a field too) giving one. Without one, the old
17
+ // bulbs are left as they are; a color never switches them.
18
+ //
19
+ // Null or undefined from either leaves the light as it is below (so does
20
+ // false from a color look). Something that can't be read leaves it as it is
21
+ // too, with a warning (once for each kind of mistake), so one slip doesn't
22
+ // stop the sketch.
23
+
24
+ import { Color, color, isPalette, type ColorInput } from "./color.ts";
25
+ import type { Out, Prev, Update } from "./arclamp.ts";
26
+ import { colorOf, Here, roomOf, sameLayout, type Room } from "./here.ts";
27
+
28
+ // Plain V8 (arclamp's sandbox) has only what arclamp's prelude gives it.
29
+ declare const console: { warn(...data: unknown[]): void };
30
+
31
+ /** What a color light should make, here and now; null, undefined or false leaves it as it is below. */
32
+ export type LookResult = ColorInput | null | undefined | false | void;
33
+
34
+ /** The color lights' look: a function of here giving a color, one color for every light, or a palette, laid across the room. */
35
+ export type Look = ((here: Here) => LookResult) | ColorInput;
36
+
37
+ /** Whether an old bulb should be on: true or false, a number (on at 0.5 or more), "on" or "off"; null or undefined leaves it as it is. */
38
+ export type SwitchResult = boolean | number | string | null | undefined | void;
39
+
40
+ /** The old bulbs' look: a function of here (a field too) saying whether each is on, or one answer for them all. */
41
+ export type SwitchLook = ((here: Here) => SwitchResult) | boolean | number | string;
42
+
43
+ /** A look for each kind of light (light.kind); either may be left out, and its lights are left as they are. */
44
+ export interface Looks {
45
+ /** For the color bulbs and panels. */
46
+ color?: Look;
47
+ /** For the old bulbs, which only switch. */
48
+ switch?: SwitchLook;
49
+ }
50
+
51
+ export interface SketchOptions {
52
+ /** The old bulbs' look, beside a color look given on its own. */
53
+ switch?: SwitchLook;
54
+ }
55
+
56
+ // A tick after a long gap (the sketch paused, or held up) counts as at most this long.
57
+ const MAX_DT = 0.25;
58
+
59
+ /** Your color look (a function of here, or a field) as arclamp's update(); old bulbs are left alone, unless given `switch`. */
60
+ export function sketch(look: (here: Here) => LookResult, options?: SketchOptions): Update;
61
+ /** A look for each kind of light: { color, switch }. */
62
+ export function sketch(looks: Looks): Update;
63
+ /** One color for every color light, or a palette laid across the room, as arclamp's update(). */
64
+ export function sketch(look: ColorInput, options?: SketchOptions): Update;
65
+ export function sketch(given: Looks | Look, options: SketchOptions = {}): Update {
66
+ if (options && typeof options === "object" && "on" in options) throw new Error(ON_KEY);
67
+ const looks: Looks = isLooks(given) ? { switch: options.switch, ...given } : { color: given as Look, switch: options.switch };
68
+ const askColor = looks.color === undefined ? null : lookOf(looks.color);
69
+ const askSwitch = looks.switch === undefined ? null : switchLookOf(looks.switch);
70
+ let room: Room | null = null;
71
+ let last: number | null = null;
72
+ const warned = new Set<string>();
73
+
74
+ const warn = (kind: string, text: string) => {
75
+ if (warned.has(kind)) return;
76
+ warned.add(kind);
77
+ console.warn(`${text} (warning once)`);
78
+ };
79
+
80
+ return (prev: Prev, nowMs: number, initialMs: number): Out => {
81
+ const list = Array.isArray(prev?.lights) ? prev.lights : [];
82
+ if (!room || !sameLayout(prev, room)) room = roomOf(prev, room);
83
+ const t = (nowMs - initialMs) / 1000;
84
+ const dt = last === null ? 0 : Math.min(MAX_DT, Math.max(0, (nowMs - last) / 1000));
85
+ last = nowMs;
86
+
87
+ const lights: Out["lights"] = [];
88
+ for (let i = 0; i < list.length; i++) {
89
+ const light = room.lights[i]!;
90
+ light.below = colorOf(list[i]!, light.kind);
91
+ light.belowOn = light.below.lm > 0;
92
+ const ask = light.kind === "switch" ? askSwitch : askColor;
93
+ if (!ask) continue;
94
+ const v = ask(new Here(light.x, light.y, t, dt, nowMs, light, room));
95
+
96
+ if (light.kind === "switch") {
97
+ const on = readOn(v);
98
+ if (on === "unreadable") {
99
+ warn(
100
+ "on",
101
+ `light ${light.id} is an old bulb, which only switches: its look gives true or false, a number (on at 0.5 or more), or "on" or "off", not ${describe(v)}; give old bulbs a look of their own, sketch({ color, switch })`,
102
+ );
103
+ } else if (on !== null) lights.push({ id: light.id, channels: { on: on ? 1 : 0 } });
104
+ continue;
105
+ }
106
+
107
+ if (v === null || v === undefined || v === false) continue;
108
+ let c: Color;
109
+ try {
110
+ c = v instanceof Color ? v : color(v as ColorInput);
111
+ } catch (err) {
112
+ if (typeof v === "number") {
113
+ warn("number", `light ${light.id} was given ${v}, and a number is read as a color temperature in kelvin; for a pattern from 0 to 1, color it first, like osc(3).color(palettes.dusk)`);
114
+ } else {
115
+ warn("unreadable", `light ${light.id} was given something that isn't a color, so it's left as it is: ${(err as Error).message}`);
116
+ }
117
+ continue;
118
+ }
119
+ const channels = c.channels;
120
+ if (!Number.isFinite(channels.a) || !Number.isFinite(channels.b) || !Number.isFinite(channels.brightness) || !Number.isFinite(channels.fidelity)) {
121
+ warn("nan", `light ${light.id} was given a color that isn't all numbers, ${JSON.stringify(c)}, so it's left as it is`);
122
+ continue;
123
+ }
124
+ lights.push({ id: light.id, channels });
125
+ }
126
+ return { lights };
127
+ };
128
+ }
129
+
130
+ const ON_KEY = 'old bulbs\' look is `switch`, for the kind of light it\'s for: sketch({ color, switch }), or sketch(look, { switch })';
131
+
132
+ /** Whether a sketch was given a look for each kind of light ({ color, switch }) rather than one look. */
133
+ function isLooks(v: unknown): v is Looks {
134
+ if (!v || typeof v !== "object" || v instanceof Color || Array.isArray(v)) return false;
135
+ const keys = Object.keys(v);
136
+ // { on } would read as an old bulb's channels, a color: say what's meant instead.
137
+ if (keys.includes("on")) throw new Error(ON_KEY);
138
+ return keys.length > 0 && keys.every((k) => k === "color" || k === "switch");
139
+ }
140
+
141
+ /** The color look as a function of here. One color is the same everywhere (and said wrong, an error now, not each tick). */
142
+ function lookOf(look: Look): (here: Here) => unknown {
143
+ if (isPalette(look)) return (here) => look(here.u);
144
+ if (typeof look === "function") return look as (here: Here) => unknown;
145
+ const c = color(look);
146
+ return () => c;
147
+ }
148
+
149
+ /** The switch look as a function of here. One answer is the same everywhere (and said wrong, an error now). */
150
+ function switchLookOf(look: SwitchLook): (here: Here) => unknown {
151
+ if (typeof look === "function") return look as (here: Here) => unknown;
152
+ const on = readOn(look);
153
+ if (on === "unreadable") throw new Error(`${describe(look)} isn't on or off: give true or false, a number (on at 0.5 or more), or "on" or "off"`);
154
+ return () => on;
155
+ }
156
+
157
+ /** On (true), off (false), leave it (null), or can't tell. */
158
+ function readOn(v: unknown): boolean | null | "unreadable" {
159
+ if (v === null || v === undefined) return null;
160
+ if (typeof v === "boolean") return v;
161
+ if (typeof v === "number") return Number.isFinite(v) ? v >= 0.5 : "unreadable";
162
+ if (typeof v === "string") {
163
+ const s = v.trim().toLowerCase();
164
+ if (s === "on" || s === "true" || s === "yes") return true;
165
+ if (s === "off" || s === "false" || s === "no") return false;
166
+ return s !== "" && Number.isFinite(Number(s)) ? Number(s) >= 0.5 : "unreadable";
167
+ }
168
+ if (typeof v === "object" && !(v instanceof Color) && "on" in v) return readOn((v as { on: unknown }).on);
169
+ return "unreadable";
170
+ }
171
+
172
+ const describe = (v: unknown) => (v instanceof Color ? `a color (${v})` : typeof v === "string" ? `"${v}"` : JSON.stringify(v) ?? String(v));
package/src/spaces.ts ADDED
@@ -0,0 +1,102 @@
1
+ // The color spaces people know, to and from the light they stand for.
2
+ //
3
+ // They're all screens' spaces, so their brightness is relative to a screen's
4
+ // white. Here that white is a fixed amount of light, 1000 lm (lemtone's
5
+ // brightness 1.0), the same on every light: rgb(1, 1, 1) asks for 1000 lm,
6
+ // and a screen's full blue, #0000ff, for about 72, as dim next to white as on
7
+ // a screen. Everything goes through XYZ (D65, Y = 1 for that white), with the
8
+ // matrices CSS Color 4 uses.
9
+
10
+ import { lmsToXyz, mul3, xyzToLms, type Vec3 } from "./lemtone.ts";
11
+
12
+ /** The light a screen's white stands for, in lumens. */
13
+ export const SCREEN_WHITE_LM = 1000;
14
+
15
+ // Linear sRGB <-> OKLab's LMS (Björn Ottosson's), so sRGB and OKLab agree exactly.
16
+ const SRGB_LMS = [
17
+ [0.4122214708, 0.5363325363, 0.0514459929],
18
+ [0.2119034982, 0.6806995451, 0.1073969566],
19
+ [0.0883024619, 0.2817188376, 0.6299787005],
20
+ ];
21
+ const LMS_SRGB = [
22
+ [4.0767416621, -3.3077115913, 0.2309699292],
23
+ [-1.2684380046, 2.6097574011, -0.3413193965],
24
+ [-0.0041960863, -0.7034186147, 1.707614701],
25
+ ];
26
+
27
+ export const linearSrgbToXyz = (rgb: Vec3): Vec3 => lmsToXyz(mul3(SRGB_LMS, rgb));
28
+ export const xyzToLinearSrgb = (X: Vec3): Vec3 => mul3(LMS_SRGB, xyzToLms(X));
29
+
30
+ /** sRGB's gamma, undone (extended past 0 and 1 the same way both sides, as CSS does). */
31
+ export function decode(v: number): number {
32
+ const x = Math.abs(v);
33
+ return Math.sign(v) * (x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4);
34
+ }
35
+
36
+ /** sRGB's gamma. */
37
+ export function encode(v: number): number {
38
+ const x = Math.abs(v);
39
+ return Math.sign(v) * (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055);
40
+ }
41
+
42
+ // Linear Display P3 -> XYZ (CSS Color 4).
43
+ const P3_XYZ = [
44
+ [0.48657094864821626, 0.26566769316909294, 0.1982172852343625],
45
+ [0.22897456406974884, 0.6917385218365062, 0.079286914093745],
46
+ [0, 0.045113381858902575, 1.0439443689009757],
47
+ ];
48
+ export const linearP3ToXyz = (rgb: Vec3): Vec3 => mul3(P3_XYZ, rgb);
49
+
50
+ // CIE Lab and LCh are relative to D50; back to D65 by Bradford (CSS Color 4).
51
+ const D50: Vec3 = [0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585];
52
+ const D50_D65 = [
53
+ [0.955473421488075, -0.02309845494876471, 0.06325924320057072],
54
+ [-0.0283697093338637, 1.0099953980813041, 0.021041441191917323],
55
+ [0.012314014864481998, -0.020507649298898964, 1.330365926242124],
56
+ ];
57
+ export const xyzD50ToXyz = (X: Vec3): Vec3 => mul3(D50_D65, X);
58
+
59
+ /** CIE Lab (L 0–100) as XYZ (D65). */
60
+ export function labToXyz(L: number, a: number, b: number): Vec3 {
61
+ const k = 24389 / 27;
62
+ const e = 216 / 24389;
63
+ const f1 = (L + 16) / 116;
64
+ const f0 = a / 500 + f1;
65
+ const f2 = f1 - b / 200;
66
+ const x = f0 ** 3 > e ? f0 ** 3 : (116 * f0 - 16) / k;
67
+ const y = L > k * e ? f1 ** 3 : L / k;
68
+ const z = f2 ** 3 > e ? f2 ** 3 : (116 * f2 - 16) / k;
69
+ return xyzD50ToXyz([x * D50[0], y, z * D50[2]]);
70
+ }
71
+
72
+ /** HSL (hue in degrees, saturation and lightness 0–1) as gamma-encoded sRGB (CSS Color 4). */
73
+ export function hslToSrgb(h: number, s: number, l: number): Vec3 {
74
+ const hue = ((h % 360) + 360) % 360;
75
+ const f = (n: number) => {
76
+ const k = (n + hue / 30) % 12;
77
+ return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
78
+ };
79
+ return [f(0), f(8), f(4)];
80
+ }
81
+
82
+ /** HWB (hue in degrees, whiteness and blackness 0–1) as gamma-encoded sRGB (CSS Color 4). */
83
+ export function hwbToSrgb(h: number, w: number, b: number): Vec3 {
84
+ if (w + b >= 1) {
85
+ const gray = w / (w + b);
86
+ return [gray, gray, gray];
87
+ }
88
+ return hslToSrgb(h, 1, 0.5).map((v) => v * (1 - w - b) + w) as Vec3;
89
+ }
90
+
91
+ /** Gamma-encoded sRGB as HSL (CSS Color 4): hue in degrees (0 for grays), saturation and lightness 0–1. */
92
+ export function srgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
93
+ const max = Math.max(r, g, b);
94
+ const min = Math.min(r, g, b);
95
+ const l = (max + min) / 2;
96
+ const d = max - min;
97
+ if (d === 0) return { h: 0, s: 0, l };
98
+ const s = l === 0 || l === 1 ? 0 : (max - l) / Math.min(l, 1 - l);
99
+ let h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
100
+ h *= 60;
101
+ return { h, s, l };
102
+ }