@zakkster/lite-color-engine 1.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/LICENSE.md +19 -0
- package/README.md +337 -0
- package/index.d.ts +202 -0
- package/index.js +19 -0
- package/llms.txt +201 -0
- package/package.json +64 -0
- package/src/authoring.js +262 -0
- package/src/convert.js +58 -0
- package/src/lut.js +68 -0
- package/src/runtime.js +149 -0
package/llms.txt
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# @zakkster/lite-color-engine — LLM Reference
|
|
2
|
+
|
|
3
|
+
> Zero-GC, data-oriented OKLCH color engine for WebGL/Canvas rendering pipelines.
|
|
4
|
+
> Buffer-and-offset API. Three layers: authoring (CSS parsing), LUT (gradient
|
|
5
|
+
> baking), runtime (zero-allocation hot path).
|
|
6
|
+
|
|
7
|
+
## Identity
|
|
8
|
+
|
|
9
|
+
- **Package name:** `@zakkster/lite-color-engine`
|
|
10
|
+
- **Module type:** ESM only (`"type": "module"`)
|
|
11
|
+
- **Peer dependency:** `@zakkster/lite-lerp` (provides `lerp`, `lerpAngle`)
|
|
12
|
+
- **Bundle:** sub-2KB min+gzip
|
|
13
|
+
- **Side effects:** none (`"sideEffects": false`)
|
|
14
|
+
- **Node:** `>=18`
|
|
15
|
+
|
|
16
|
+
## Mental model
|
|
17
|
+
|
|
18
|
+
Every color in this library is exactly **3 contiguous Float32 entries** in a `Float32Array`: `[L, C, H]`.
|
|
19
|
+
|
|
20
|
+
- `L` (Lightness): `[0, 1]`, perceptually uniform.
|
|
21
|
+
- `C` (Chroma): `[0, ~0.4]` in practice, mathematically unbounded but only a small chroma range maps to displayable sRGB.
|
|
22
|
+
- `H` (Hue): `[0, 360)` degrees. Always canonicalized after every write.
|
|
23
|
+
|
|
24
|
+
Alpha is **not** stored in the OKLCH buffer. Parsers return alpha as a `number`. If you need alpha alongside color, store it in a sibling `Float32Array` or add a fourth slot yourself (the library only reads the first three).
|
|
25
|
+
|
|
26
|
+
The output of the pack functions is a 32-bit unsigned integer in **little-endian RGBA byte order** — exactly what `new Uint32Array(imageData.data.buffer)` consumes. Browsers are universally little-endian; do not byte-swap.
|
|
27
|
+
|
|
28
|
+
## Public API surface
|
|
29
|
+
|
|
30
|
+
### Authoring (init-time, allocations OK)
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import {
|
|
34
|
+
parseCSSColor,
|
|
35
|
+
parseHexToBuffer,
|
|
36
|
+
parseRgbToBuffer,
|
|
37
|
+
parseHslToBuffer,
|
|
38
|
+
parseOklchToBuffer,
|
|
39
|
+
parseOklabToBuffer,
|
|
40
|
+
} from '@zakkster/lite-color-engine';
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
All five format-specific parsers and `parseCSSColor` have the same signature:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
(str: string, outBuf: Float32Array, offset: number) => number /* alpha [0,1] */
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`parseCSSColor` dispatches by string prefix. Use it unless you know the format and care about shaving the dispatch.
|
|
50
|
+
|
|
51
|
+
Throws on invalid input.
|
|
52
|
+
|
|
53
|
+
### Convert (raw math)
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { sRgbToOklchBuffer } from '@zakkster/lite-color-engine';
|
|
57
|
+
|
|
58
|
+
sRgbToOklchBuffer(r, g, b, outBuf, outOffset);
|
|
59
|
+
// r, g, b are 0-255 bytes. Writes 3 floats to outBuf.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Runtime (zero allocations)
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import {
|
|
66
|
+
lerpOklchBuffer,
|
|
67
|
+
packOklchBufferToUint32,
|
|
68
|
+
packOklchBufferToUint32Fast,
|
|
69
|
+
sampleColorLUT,
|
|
70
|
+
} from '@zakkster/lite-color-engine';
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Signatures:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
lerpOklchBuffer(
|
|
77
|
+
bufA: Float32Array, offsetA: number,
|
|
78
|
+
bufB: Float32Array, offsetB: number,
|
|
79
|
+
t: number,
|
|
80
|
+
outBuf: Float32Array, outOffset: number
|
|
81
|
+
): void;
|
|
82
|
+
|
|
83
|
+
packOklchBufferToUint32(buf: Float32Array, offset: number, alpha?: number): number;
|
|
84
|
+
packOklchBufferToUint32Fast(buf: Float32Array, offset: number, alpha?: number): number;
|
|
85
|
+
|
|
86
|
+
sampleColorLUT(lut: Uint32Array, t: number): number;
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### LUT (gradient baking)
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
import { bakeGradientToUint32 } from '@zakkster/lite-color-engine';
|
|
93
|
+
|
|
94
|
+
bakeGradientToUint32(
|
|
95
|
+
keyframesBuf: Float32Array, // [L0,C0,H0, L1,C1,H1, ...]
|
|
96
|
+
numStops: number, // >= 2
|
|
97
|
+
resolution?: number, // default 256, >= 2
|
|
98
|
+
easeFn?: (t: number) => number,
|
|
99
|
+
packer?: (buf: Float32Array, offset: number, alpha?: number) => number
|
|
100
|
+
): Uint32Array;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Integration patterns
|
|
104
|
+
|
|
105
|
+
### Pattern 1 — palette load
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const palette = new Float32Array(3 * COLOR_COUNT);
|
|
109
|
+
const alphas = new Float32Array(COLOR_COUNT);
|
|
110
|
+
for (let i = 0; i < cssStrings.length; i++) {
|
|
111
|
+
alphas[i] = parseCSSColor(cssStrings[i], palette, i * 3);
|
|
112
|
+
}
|
|
113
|
+
// palette is now your color storage for the program lifetime.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Pattern 2 — per-frame tween
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
const scratch = new Float32Array(3); // module-level, allocated ONCE
|
|
120
|
+
|
|
121
|
+
function frame(t) {
|
|
122
|
+
lerpOklchBuffer(palette, 0, palette, 3, t, scratch, 0);
|
|
123
|
+
return packOklchBufferToUint32(scratch, 0, 1.0);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Pattern 3 — gradient LUT for a particle system
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
const stops = new Float32Array(9);
|
|
131
|
+
parseCSSColor('#ff0000', stops, 0);
|
|
132
|
+
parseCSSColor('#ffd700', stops, 3);
|
|
133
|
+
parseCSSColor('#00aaff', stops, 6);
|
|
134
|
+
|
|
135
|
+
const lut = bakeGradientToUint32(stops, 3, 256);
|
|
136
|
+
|
|
137
|
+
// In the render loop:
|
|
138
|
+
for (let i = 0; i < particles.count; i++) {
|
|
139
|
+
const lifeT = particles.life[i];
|
|
140
|
+
pixels32[particles.pixelIdx[i]] = sampleColorLUT(lut, lifeT);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Pattern 4 — direct ImageData write
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
const imgData = ctx.createImageData(width, height);
|
|
148
|
+
const pixels = new Uint32Array(imgData.data.buffer); // RGBA-LE view
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < pixels.length; i++) {
|
|
151
|
+
pixels[i] = sampleColorLUT(lut, i / pixels.length);
|
|
152
|
+
}
|
|
153
|
+
ctx.putImageData(imgData, 0, 0);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Invariants the library guarantees
|
|
157
|
+
|
|
158
|
+
- After any successful write, `outBuf[offset + 2]` (hue) is in `[0, 360)`.
|
|
159
|
+
- After any successful write, `outBuf[offset]` (lightness) is in `[0, 1]`.
|
|
160
|
+
- After any successful write, `outBuf[offset + 1]` (chroma) is in `[0, +∞)`.
|
|
161
|
+
- `packOklchBufferToUint32` and `packOklchBufferToUint32Fast` return values that are **always** non-negative integers (they apply `>>> 0`).
|
|
162
|
+
- Hue interpolation is **always** shortest-path (uses `lerpAngle` from `@zakkster/lite-lerp`).
|
|
163
|
+
- Bake output bytes are **always** little-endian RGBA. Same on every browser.
|
|
164
|
+
|
|
165
|
+
## Common pitfalls
|
|
166
|
+
|
|
167
|
+
- **Mistaking the byte order.** The pack functions return little-endian RGBA, so on x86 the low byte is R. If you mask with `(packed >> 24) & 0xff` expecting red, you'll get alpha. Use `packed & 0xff` for red.
|
|
168
|
+
|
|
169
|
+
- **Storing per-color objects anyway.** The library is explicitly buffer-first. If you find yourself writing `{ l, c, h }` wrappers, you've reintroduced GC pressure; just use the offset-based API.
|
|
170
|
+
|
|
171
|
+
- **Choosing the Fast packer for UI.** `packOklchBufferToUint32Fast` darkens mid-tones by ~10/255 and shifts warm midtones toward black. Use it for transient pixels (particle trails, alpha-blended sprites). Use the accurate variant for anything a designer or QA might compare against the source color.
|
|
172
|
+
|
|
173
|
+
- **Using non-monotonic eases with the LUT.** `bakeGradientToUint32` clamps eased `t` to `[0, 1]`. Overshoot easings (`easeOutBack`, etc.) get silently capped because a fixed-resolution LUT cannot represent overshoot. If you need overshoot, drive the eased `t` at sample time with `sampleColorLUT(lut, easeFn(t))` instead.
|
|
174
|
+
|
|
175
|
+
- **Forgetting alpha is returned, not written.** Parsers return alpha. They do not write it to the buffer. If you mix parsers and hand-written buffers, decide on an alpha layout up front (sibling array, stride-4, or per-instance) and stick with it.
|
|
176
|
+
|
|
177
|
+
## What this library is NOT
|
|
178
|
+
|
|
179
|
+
- It is not a general-purpose color manipulation library. No `darken`, `lighten`, `mix`, `saturate`, `complement`, etc. methods. Compose your own from the buffer math primitives.
|
|
180
|
+
- It is not a gamut mapper. Pack uses a hard clamp; out-of-gamut OKLCH values are clipped per-channel, not mapped via MINDE chroma reduction. Proper gamut mapping is on the v1.1 roadmap.
|
|
181
|
+
- It is not a CSS parser for everything. It does CSS Color Level 4 colors. It does not handle `color()`, `color-mix()`, relative color syntax, or system colors.
|
|
182
|
+
- It is not async. There are no Promises. Every function is synchronous and side-effect-free (writes only to its `outBuf` argument).
|
|
183
|
+
|
|
184
|
+
## Performance budget
|
|
185
|
+
|
|
186
|
+
| Operation | Wall-clock estimate (modern V8, 2024 laptop) |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `parseCSSColor` | ~1µs per call (not the hot path; numbers vary by format) |
|
|
189
|
+
| `lerpOklchBuffer` | ~50ns per call |
|
|
190
|
+
| `packOklchBufferToUint32` | ~150ns per call |
|
|
191
|
+
| `packOklchBufferToUint32Fast` | ~80ns per call |
|
|
192
|
+
| `sampleColorLUT` | ~10ns per call |
|
|
193
|
+
| `bakeGradientToUint32(..., 256)` | ~50µs total |
|
|
194
|
+
|
|
195
|
+
Absolute numbers depend on hardware. Relative ratios are stable.
|
|
196
|
+
|
|
197
|
+
## Versioning
|
|
198
|
+
|
|
199
|
+
`1.0.x` — the locked public API surface above. No breaking changes within the major. Patch releases for bug fixes; minor releases may **add** functions but not remove or rename.
|
|
200
|
+
|
|
201
|
+
`1.1` (planned) — see `ROADMAP.md` if present, or the Pairs-well-with section of the README.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zakkster/lite-color-engine",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
5
|
+
"description": "Zero-GC, data-oriented OKLCH color engine and WebGL/Canvas buffer pipeline.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"module": "index.js",
|
|
10
|
+
"types": "index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "index.d.ts",
|
|
14
|
+
"import": "index.js",
|
|
15
|
+
"default": "index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src/",
|
|
20
|
+
"index.js",
|
|
21
|
+
"index.d.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"llms.txt",
|
|
24
|
+
"LICENSE.txt"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"color",
|
|
28
|
+
"oklch",
|
|
29
|
+
"oklab",
|
|
30
|
+
"zero-gc",
|
|
31
|
+
"webgl",
|
|
32
|
+
"canvas",
|
|
33
|
+
"lut",
|
|
34
|
+
"interpolation",
|
|
35
|
+
"gradient",
|
|
36
|
+
"color-space",
|
|
37
|
+
"dod",
|
|
38
|
+
"data-oriented",
|
|
39
|
+
"high-performance",
|
|
40
|
+
"game-dev"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@zakkster/lite-lerp": "^1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"vitest": "^1.0.0"
|
|
50
|
+
},
|
|
51
|
+
"license": "Mit",
|
|
52
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-color-engine#readme",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-color-engine.git"
|
|
56
|
+
},
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/PeshoVurtoleta/lite-color-engine/issues",
|
|
59
|
+
"email": "shinikchiev@yahoo.com"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=18"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/authoring.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { sRgbToOklchBuffer } from './convert.js';
|
|
2
|
+
|
|
3
|
+
const RAD_TO_DEG = 180 / Math.PI;
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// 1. UTILITIES
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parses a CSS numeric token. `'50%'` -> `0.5 * max`. `'50'` -> `50`. `''` / undefined -> undefined.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
const parseVal = (val, max = 1) => {
|
|
14
|
+
if (val === undefined || val === null || val === '') return undefined;
|
|
15
|
+
return val.endsWith('%') ? (parseFloat(val) / 100) * max : parseFloat(val);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parses a CSS hue token, accepting `deg` (default), `rad`, and `turn` units.
|
|
20
|
+
* Result is canonicalized to [0, 360).
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const parseHue = (val) => {
|
|
24
|
+
let h = parseFloat(val);
|
|
25
|
+
if (val.includes('rad')) h *= RAD_TO_DEG;
|
|
26
|
+
if (val.includes('turn')) h *= 360;
|
|
27
|
+
const m = h % 360;
|
|
28
|
+
return m < 0 ? m + 360 : m;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reference HSL -> sRGB conversion. Used as a stepping stone before sRGB -> OKLCH.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
const hslToRgb = (h, s, l) => {
|
|
36
|
+
h /= 360;
|
|
37
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
38
|
+
const p = 2 * l - q;
|
|
39
|
+
const hue2rgb = (p, q, t) => {
|
|
40
|
+
if (t < 0) t += 1;
|
|
41
|
+
if (t > 1) t -= 1;
|
|
42
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
43
|
+
if (t < 1 / 2) return q;
|
|
44
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
45
|
+
return p;
|
|
46
|
+
};
|
|
47
|
+
return [
|
|
48
|
+
(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5) | 0,
|
|
49
|
+
(hue2rgb(p, q, h) * 255 + 0.5) | 0,
|
|
50
|
+
(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5) | 0
|
|
51
|
+
];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// 2. REGEX DICTIONARY (CSS Color Level 4 Permissive)
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
const HEX_REGEX = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i;
|
|
59
|
+
const SHORT_HEX_REGEX = /^#([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i;
|
|
60
|
+
const OKLCH_REGEX = /^oklch\(\s*([\d.%]+)\s+([\d.%]+)\s+([\d.%a-z]+)(?:\s*\/\s*([\d.%]+))?\s*\)$/i;
|
|
61
|
+
const OKLAB_REGEX = /^oklab\(\s*([\d.%]+)[,\s]+([\d.-]+)[,\s]+([\d.-]+)(?:[,\s/]+([\d.%]+))?\s*\)$/i;
|
|
62
|
+
const RGB_REGEX = /^rgba?\(\s*([\d.%]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
|
|
63
|
+
const HSL_REGEX = /^hsla?\(\s*([\d.%a-z]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// 3. NAMED COLORS FAST-PATH
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
/** CSS Color Module Level 4 named-color table. Single allocation at module load. */
|
|
70
|
+
const NAMED_COLORS = {
|
|
71
|
+
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4',
|
|
72
|
+
azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
|
73
|
+
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a',
|
|
74
|
+
burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00', chocolate: '#d2691e',
|
|
75
|
+
coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
|
|
76
|
+
cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b',
|
|
77
|
+
darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
|
|
78
|
+
darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
|
|
79
|
+
darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
|
|
80
|
+
darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
|
|
81
|
+
deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
|
|
82
|
+
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
|
|
83
|
+
fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
|
|
84
|
+
goldenrod: '#daa520', gray: '#808080', green: '#008000', greenyellow: '#adff2f',
|
|
85
|
+
grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c',
|
|
86
|
+
indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
|
|
87
|
+
lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6',
|
|
88
|
+
lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
|
|
89
|
+
lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
|
|
90
|
+
lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
|
|
91
|
+
lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
|
|
92
|
+
linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
|
|
93
|
+
mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
|
|
94
|
+
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585',
|
|
95
|
+
midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
|
|
96
|
+
navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
|
|
97
|
+
olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6',
|
|
98
|
+
palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
|
|
99
|
+
papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb',
|
|
100
|
+
plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399',
|
|
101
|
+
red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513',
|
|
102
|
+
salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee',
|
|
103
|
+
sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd',
|
|
104
|
+
slategray: '#708090', slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f',
|
|
105
|
+
steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
|
|
106
|
+
tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3',
|
|
107
|
+
white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
|
|
108
|
+
transparent: '#00000000'
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// 4. PARSERS
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parses a CSS hex string (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) into an
|
|
117
|
+
* OKLCH triplet at `outBuf[offset..offset+2]`.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} str - The hex string (with leading `#`)
|
|
120
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
121
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
122
|
+
* @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
|
|
123
|
+
* @throws If `str` is not a valid hex literal.
|
|
124
|
+
*/
|
|
125
|
+
export const parseHexToBuffer = (str, outBuf, offset) => {
|
|
126
|
+
let match = str.match(HEX_REGEX);
|
|
127
|
+
if (!match) {
|
|
128
|
+
match = str.match(SHORT_HEX_REGEX);
|
|
129
|
+
if (!match) throw new Error(`lite-color-engine: Invalid HEX "${str}"`);
|
|
130
|
+
match = [
|
|
131
|
+
match[0], match[1] + match[1], match[2] + match[2],
|
|
132
|
+
match[3] + match[3], match[4] ? match[4] + match[4] : undefined
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
sRgbToOklchBuffer(parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16), outBuf, offset);
|
|
136
|
+
return match[4] ? parseInt(match[4], 16) / 255 : 1.0;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parses a CSS `oklch()` string directly into an OKLCH triplet — no color-space
|
|
141
|
+
* conversion needed.
|
|
142
|
+
*
|
|
143
|
+
* Accepts the modern slash-alpha form: `oklch(60% 0.15 250 / 0.5)`. Hue may
|
|
144
|
+
* carry a `deg`, `rad`, or `turn` unit.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} str - The `oklch(...)` string
|
|
147
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
148
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
149
|
+
* @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
|
|
150
|
+
* @throws If `str` does not match `oklch(...)`.
|
|
151
|
+
*/
|
|
152
|
+
export const parseOklchToBuffer = (str, outBuf, offset) => {
|
|
153
|
+
const match = str.match(OKLCH_REGEX);
|
|
154
|
+
if (!match) throw new Error(`lite-color-engine: Invalid OKLCH "${str}"`);
|
|
155
|
+
outBuf[offset] = parseVal(match[1], 1);
|
|
156
|
+
outBuf[offset + 1] = parseVal(match[2], 1);
|
|
157
|
+
outBuf[offset + 2] = parseHue(match[3]);
|
|
158
|
+
return parseVal(match[4], 1) ?? 1.0;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parses a CSS `oklab()` string and converts (a, b) -> (C, H) via polar math
|
|
163
|
+
* before writing the OKLCH triplet.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} str - The `oklab(...)` string
|
|
166
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
167
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
168
|
+
* @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
|
|
169
|
+
* @throws If `str` does not match `oklab(...)`.
|
|
170
|
+
*/
|
|
171
|
+
export const parseOklabToBuffer = (str, outBuf, offset) => {
|
|
172
|
+
const match = str.match(OKLAB_REGEX);
|
|
173
|
+
if (!match) throw new Error(`lite-color-engine: Invalid OKLAB "${str}"`);
|
|
174
|
+
const l = parseVal(match[1], 1);
|
|
175
|
+
const a = parseFloat(match[2]);
|
|
176
|
+
const b = parseFloat(match[3]);
|
|
177
|
+
const c = Math.sqrt(a * a + b * b);
|
|
178
|
+
|
|
179
|
+
outBuf[offset] = l < 0 ? 0 : (l > 1 ? 1 : l);
|
|
180
|
+
outBuf[offset + 1] = c || 0;
|
|
181
|
+
let h = Math.atan2(b, a) * RAD_TO_DEG;
|
|
182
|
+
if (h < 0) h += 360;
|
|
183
|
+
outBuf[offset + 2] = h;
|
|
184
|
+
|
|
185
|
+
return parseVal(match[4], 1) ?? 1.0;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Parses a CSS `rgb()` / `rgba()` string and converts to OKLCH.
|
|
190
|
+
*
|
|
191
|
+
* Accepts comma-separated and modern slash-alpha forms, with `0-255` or `%`
|
|
192
|
+
* channel values. Conversion uses the proper sRGB linearization.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} str - The `rgb(...)` / `rgba(...)` string
|
|
195
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
196
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
197
|
+
* @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
|
|
198
|
+
* @throws If `str` does not match `rgb(...)` / `rgba(...)`.
|
|
199
|
+
*/
|
|
200
|
+
export const parseRgbToBuffer = (str, outBuf, offset) => {
|
|
201
|
+
const match = str.match(RGB_REGEX);
|
|
202
|
+
if (!match) throw new Error(`lite-color-engine: Invalid RGB "${str}"`);
|
|
203
|
+
sRgbToOklchBuffer(parseVal(match[1], 255), parseVal(match[2], 255), parseVal(match[3], 255), outBuf, offset);
|
|
204
|
+
return parseVal(match[4], 1) ?? 1.0;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parses a CSS `hsl()` / `hsla()` string, converts via sRGB, and writes the
|
|
209
|
+
* OKLCH triplet.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} str - The `hsl(...)` / `hsla(...)` string
|
|
212
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
213
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
214
|
+
* @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
|
|
215
|
+
* @throws If `str` does not match `hsl(...)` / `hsla(...)`.
|
|
216
|
+
*/
|
|
217
|
+
export const parseHslToBuffer = (str, outBuf, offset) => {
|
|
218
|
+
const match = str.match(HSL_REGEX);
|
|
219
|
+
if (!match) throw new Error(`lite-color-engine: Invalid HSL "${str}"`);
|
|
220
|
+
const [r, g, b] = hslToRgb(parseHue(match[1]), parseVal(match[2], 1), parseVal(match[3], 1));
|
|
221
|
+
sRgbToOklchBuffer(r, g, b, outBuf, offset);
|
|
222
|
+
return parseVal(match[4], 1) ?? 1.0;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ============================================================================
|
|
226
|
+
// 5. MASTER SWITCHBOARD
|
|
227
|
+
// ============================================================================
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Universal CSS color parser. Dispatches by string prefix to the appropriate
|
|
231
|
+
* format-specific parser and writes an OKLCH triplet.
|
|
232
|
+
*
|
|
233
|
+
* Supports: named colors, `#RGB[A]`, `#RRGGBB[AA]`, `rgb()` / `rgba()`,
|
|
234
|
+
* `hsl()` / `hsla()`, `oklch()`, `oklab()`.
|
|
235
|
+
*
|
|
236
|
+
* Intended for the **authoring/init phase** of a render pipeline (level loads,
|
|
237
|
+
* theme parsing, gradient compilation). After that, work with the resulting
|
|
238
|
+
* `Float32Array` buffer directly via {@link lerpOklchBuffer} and
|
|
239
|
+
* {@link packOklchBufferToUint32}.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} str - The CSS color string
|
|
242
|
+
* @param {Float32Array} outBuf - Pre-allocated destination
|
|
243
|
+
* @param {number} offset - Start index of the L, C, H triplet
|
|
244
|
+
* @returns {number} Parsed alpha in [0, 1]
|
|
245
|
+
* @throws If `str` is not a recognized format
|
|
246
|
+
*/
|
|
247
|
+
export const parseCSSColor = (str, outBuf, offset) => {
|
|
248
|
+
const cleanStr = str.trim().toLowerCase();
|
|
249
|
+
|
|
250
|
+
const named = NAMED_COLORS[cleanStr];
|
|
251
|
+
if (named) {
|
|
252
|
+
return parseHexToBuffer(named, outBuf, offset);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (cleanStr.startsWith('#')) return parseHexToBuffer(cleanStr, outBuf, offset);
|
|
256
|
+
if (cleanStr.startsWith('oklch')) return parseOklchToBuffer(cleanStr, outBuf, offset);
|
|
257
|
+
if (cleanStr.startsWith('oklab')) return parseOklabToBuffer(cleanStr, outBuf, offset);
|
|
258
|
+
if (cleanStr.startsWith('rgb')) return parseRgbToBuffer(cleanStr, outBuf, offset);
|
|
259
|
+
if (cleanStr.startsWith('hsl')) return parseHslToBuffer(cleanStr, outBuf, offset);
|
|
260
|
+
|
|
261
|
+
throw new Error(`lite-color-engine: Unsupported color format "${str}".`);
|
|
262
|
+
};
|
package/src/convert.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const RAD_TO_DEG = 180 / Math.PI;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IEC 61966-2-1 sRGB linearization.
|
|
5
|
+
* @param {number} c - Normalized non-linear sRGB channel in [0, 1]
|
|
6
|
+
* @returns {number} Linear-light value in [0, 1]
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts standard sRGB (0-255 byte channels) into a flat OKLCH buffer.
|
|
13
|
+
*
|
|
14
|
+
* Defends against negative floating-point cube roots (numerical noise from the
|
|
15
|
+
* matrix multiply) and hard-clamps output lightness to [0, 1]. Hue is
|
|
16
|
+
* canonicalized to [0, 360).
|
|
17
|
+
*
|
|
18
|
+
* Algorithm: Björn Ottosson's OKLab (2020) — sRGB -> linear sRGB -> LMS ->
|
|
19
|
+
* non-linear LMS (cube root) -> OKLab -> polar OKLCH.
|
|
20
|
+
*
|
|
21
|
+
* @param {number} r - Red channel [0, 255]
|
|
22
|
+
* @param {number} g - Green channel [0, 255]
|
|
23
|
+
* @param {number} b - Blue channel [0, 255]
|
|
24
|
+
* @param {Float32Array} outBuf - Destination buffer
|
|
25
|
+
* @param {number} outOffset - Start index for the L, C, H triplet
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*/
|
|
28
|
+
export const sRgbToOklchBuffer = (r, g, b, outBuf, outOffset) => {
|
|
29
|
+
// 1. Normalize and linearize.
|
|
30
|
+
const r_lin = linearize(r / 255);
|
|
31
|
+
const g_lin = linearize(g / 255);
|
|
32
|
+
const b_lin = linearize(b / 255);
|
|
33
|
+
|
|
34
|
+
// 2. Linear sRGB -> linear LMS.
|
|
35
|
+
const lms_l = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin;
|
|
36
|
+
const lms_m = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin;
|
|
37
|
+
const lms_s = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin;
|
|
38
|
+
|
|
39
|
+
// 3. Non-linear LMS (defended against negative-NaN propagation from matrix noise).
|
|
40
|
+
const l_ = Math.cbrt(lms_l < 0 ? 0 : lms_l);
|
|
41
|
+
const m_ = Math.cbrt(lms_m < 0 ? 0 : lms_m);
|
|
42
|
+
const s_ = Math.cbrt(lms_s < 0 ? 0 : lms_s);
|
|
43
|
+
|
|
44
|
+
// 4. Non-linear LMS -> OKLab.
|
|
45
|
+
const lab_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
|
|
46
|
+
const lab_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
|
|
47
|
+
const lab_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
|
|
48
|
+
|
|
49
|
+
// 5. OKLab -> OKLCH (polar).
|
|
50
|
+
const chroma = Math.sqrt(lab_a * lab_a + lab_b * lab_b);
|
|
51
|
+
let hue = Math.atan2(lab_b, lab_a) * RAD_TO_DEG;
|
|
52
|
+
if (hue < 0) hue += 360;
|
|
53
|
+
|
|
54
|
+
// 6. Write to buffer with a strict lightness clamp.
|
|
55
|
+
outBuf[outOffset] = lab_l < 0 ? 0 : (lab_l > 1 ? 1 : lab_l);
|
|
56
|
+
outBuf[outOffset + 1] = chroma;
|
|
57
|
+
outBuf[outOffset + 2] = hue;
|
|
58
|
+
};
|
package/src/lut.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { lerpOklchBuffer, packOklchBufferToUint32 } from './runtime.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bakes a multi-stop OKLCH gradient into a ready-to-render `Uint32Array`.
|
|
5
|
+
*
|
|
6
|
+
* The output is in little-endian RGBA byte order — drop it directly into a
|
|
7
|
+
* `Uint32Array` view of `Canvas ImageData`, or `texImage2D` with `RGBA / UNSIGNED_BYTE`.
|
|
8
|
+
*
|
|
9
|
+
* Stops are evenly spaced across the gradient (stop _i_ is at `i / (numStops - 1)`).
|
|
10
|
+
* The optional `easeFn` warps the parametric position **before** stop selection,
|
|
11
|
+
* letting you bias which stop dominates which range of the LUT. Easing outputs
|
|
12
|
+
* outside `[0, 1]` are clamped (the LUT is fixed-resolution and cannot
|
|
13
|
+
* represent overshoot).
|
|
14
|
+
*
|
|
15
|
+
* @param {Float32Array} keyframesBuf - Contiguous buffer of `[L0, C0, H0, L1, C1, H1, ...]`
|
|
16
|
+
* @param {number} numStops - Count of color stops in the buffer (must be >= 2)
|
|
17
|
+
* @param {number} [resolution=256] - Number of LUT entries to bake (must be >= 2)
|
|
18
|
+
* @param {(t: number) => number} [easeFn] - Optional easing applied to `t` before stop selection
|
|
19
|
+
* @param {(buf: Float32Array, offset: number, alpha?: number) => number} [packer]
|
|
20
|
+
* Optional packer override. Defaults to the accurate `packOklchBufferToUint32`.
|
|
21
|
+
* Pass `packOklchBufferToUint32Fast` for ~2x throughput at the cost of round-trip accuracy.
|
|
22
|
+
* @returns {Uint32Array} LUT of 32-bit packed colors
|
|
23
|
+
* @throws If `numStops < 2` or `resolution < 2`
|
|
24
|
+
*/
|
|
25
|
+
export const bakeGradientToUint32 = (
|
|
26
|
+
keyframesBuf,
|
|
27
|
+
numStops,
|
|
28
|
+
resolution = 256,
|
|
29
|
+
easeFn,
|
|
30
|
+
packer = packOklchBufferToUint32
|
|
31
|
+
) => {
|
|
32
|
+
if (numStops < 2) {
|
|
33
|
+
throw new Error('lite-color-engine: bakeGradientToUint32 requires numStops >= 2');
|
|
34
|
+
}
|
|
35
|
+
if (resolution < 2) {
|
|
36
|
+
throw new Error('lite-color-engine: bakeGradientToUint32 requires resolution >= 2');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const outLUT = new Uint32Array(resolution);
|
|
40
|
+
const tempOklch = new Float32Array(3);
|
|
41
|
+
|
|
42
|
+
const step = 1 / (resolution - 1);
|
|
43
|
+
const last = numStops - 1;
|
|
44
|
+
const hasEase = typeof easeFn === 'function';
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < resolution; i++) {
|
|
47
|
+
const rawT = i * step;
|
|
48
|
+
let t = hasEase ? easeFn(rawT) : rawT;
|
|
49
|
+
|
|
50
|
+
// LUTs are fixed-resolution; overshoot/undershoot is undefined and
|
|
51
|
+
// would either NaN-poison the buffer or extrapolate silently.
|
|
52
|
+
if (t < 0) t = 0;
|
|
53
|
+
else if (t > 1) t = 1;
|
|
54
|
+
|
|
55
|
+
const scaledT = t * last;
|
|
56
|
+
let index = scaledT | 0; // bitwise fast-floor for positive values
|
|
57
|
+
if (index >= last) index = last - 1;
|
|
58
|
+
|
|
59
|
+
const localT = scaledT - index;
|
|
60
|
+
const offsetA = index * 3;
|
|
61
|
+
const offsetB = offsetA + 3;
|
|
62
|
+
|
|
63
|
+
lerpOklchBuffer(keyframesBuf, offsetA, keyframesBuf, offsetB, localT, tempOklch, 0);
|
|
64
|
+
outLUT[i] = packer(tempOklch, 0, 1.0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return outLUT;
|
|
68
|
+
};
|