palette-shader 0.1.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/README.md +237 -0
- package/dist/palette-shader.d.ts +167 -0
- package/dist/palette-shader.js +1691 -0
- package/dist/palette-shader.js.map +1 -0
- package/dist/palette-shader.umd.cjs +1027 -0
- package/dist/palette-shader.umd.cjs.map +1 -0
- package/package.json +49 -0
- package/src/index.ts +485 -0
- package/src/shaders/closestColor.frag.glsl +41 -0
- package/src/shaders/deltaE.frag.glsl +146 -0
- package/src/shaders/hsl2rgb.frag.glsl +4 -0
- package/src/shaders/hsv2rgb.frag.glsl +5 -0
- package/src/shaders/lch2rgb.frag.glsl +31 -0
- package/src/shaders/oklab.frag.glsl +653 -0
- package/src/shaders/srgb2rgb.frag.glsl +4 -0
- package/src/types.ts +22 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ColorString,
|
|
3
|
+
ColorList,
|
|
4
|
+
PaletteVizOptions,
|
|
5
|
+
SupportedColorModels,
|
|
6
|
+
Axis,
|
|
7
|
+
DistanceMetric,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
import shaderSRGB2RGB from "./shaders/srgb2rgb.frag.glsl?raw" assert { type: "raw" };
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
import shaderOKLab from "./shaders/oklab.frag.glsl?raw" assert { type: "raw" };
|
|
14
|
+
// @ts-ignore
|
|
15
|
+
import shaderHSL2RGB from "./shaders/hsl2rgb.frag.glsl?raw" assert { type: "raw" };
|
|
16
|
+
// @ts-ignore
|
|
17
|
+
import shaderHSV2RGB from "./shaders/hsv2rgb.frag.glsl?raw" assert { type: "raw" };
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
import shaderLCH2RGB from "./shaders/lch2rgb.frag.glsl?raw" assert { type: "raw" };
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
import shaderDeltaE from "./shaders/deltaE.frag.glsl?raw" assert { type: "raw" };
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
import shaderClosestColor from "./shaders/closestColor.frag.glsl?raw" assert { type: "raw" };
|
|
24
|
+
|
|
25
|
+
// Include order matters:
|
|
26
|
+
// srgb2rgb – srgb2rgb()
|
|
27
|
+
// oklab – M_PI, cbrt(), srgb_transfer_function(), okhsv/okhsl_to_srgb(), …
|
|
28
|
+
// hsl2rgb, hsv2rgb, lch2rgb – color model conversions (lch2rgb uses M_PI + srgb_transfer_function)
|
|
29
|
+
// deltaE – srgb_to_cielab(), deltaE76/94/2000() (uses srgb2rgb, cbrt, M_PI, TWO_PI)
|
|
30
|
+
// closestColor – branches on DISTANCE_METRIC define; uses everything above
|
|
31
|
+
//
|
|
32
|
+
// Defines (compile-time, prepended to shader source — trigger recompile, no runtime branching):
|
|
33
|
+
// DISTANCE_METRIC int 0=rgb 1=oklab 2=deltaE76 3=deltaE2000 4=kotsarenkoRamos 5=deltaE94
|
|
34
|
+
// COLOR_MODEL int 0=hsv 1=okhsv 2=hsl 3=okhsl 4=oklch
|
|
35
|
+
// PROGRESS_AXIS int 0=x 1=y 2=z
|
|
36
|
+
// IS_POLAR flag (defined = true)
|
|
37
|
+
// INVERT_Z flag (defined = true)
|
|
38
|
+
// SHOW_RAW flag (defined = true)
|
|
39
|
+
|
|
40
|
+
const vertexShaderSrc = `
|
|
41
|
+
precision highp float;
|
|
42
|
+
layout(location = 0) in vec2 a_position;
|
|
43
|
+
out vec2 vUv;
|
|
44
|
+
void main() {
|
|
45
|
+
vUv = a_position * 0.5 + 0.5;
|
|
46
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
47
|
+
}`;
|
|
48
|
+
|
|
49
|
+
// fragmentShader is exported so users can inspect or reuse the GLSL source.
|
|
50
|
+
// Defines are NOT embedded here — they are prepended at compile time via buildProgram().
|
|
51
|
+
// Note: #version 300 es is prepended by buildProgram() (must be first line,
|
|
52
|
+
// before defines). Do not add it here.
|
|
53
|
+
export const fragmentShader = `
|
|
54
|
+
precision highp float;
|
|
55
|
+
#define TWO_PI 6.28318530718
|
|
56
|
+
in vec2 vUv;
|
|
57
|
+
out vec4 fragColor;
|
|
58
|
+
uniform float progress;
|
|
59
|
+
uniform sampler2D paletteTexture;
|
|
60
|
+
|
|
61
|
+
${shaderSRGB2RGB}
|
|
62
|
+
${shaderOKLab}
|
|
63
|
+
${shaderHSL2RGB}
|
|
64
|
+
${shaderHSV2RGB}
|
|
65
|
+
${shaderLCH2RGB}
|
|
66
|
+
${shaderDeltaE}
|
|
67
|
+
${shaderClosestColor}
|
|
68
|
+
|
|
69
|
+
// COLOR_MODEL: 0=hsv, 1=okhsv, 2=hsl, 3=okhsl, 4=oklch
|
|
70
|
+
vec3 polarToRGB(vec3 colorCoords) {
|
|
71
|
+
#if COLOR_MODEL == 0
|
|
72
|
+
return hsv2rgb(colorCoords);
|
|
73
|
+
#elif COLOR_MODEL == 2
|
|
74
|
+
return hsl2rgb(colorCoords);
|
|
75
|
+
#elif COLOR_MODEL == 3
|
|
76
|
+
return okhsl_to_srgb(colorCoords);
|
|
77
|
+
#elif COLOR_MODEL == 4
|
|
78
|
+
return lch2rgb(vec3(colorCoords.z, colorCoords.y, colorCoords.x));
|
|
79
|
+
#else
|
|
80
|
+
return okhsv_to_srgb(colorCoords);
|
|
81
|
+
#endif
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
void main(){
|
|
85
|
+
#if PROGRESS_AXIS == 1
|
|
86
|
+
vec3 colorCoords = vec3(vUv.x, progress, vUv.y);
|
|
87
|
+
#elif PROGRESS_AXIS == 2
|
|
88
|
+
vec3 colorCoords = vec3(vUv.x, vUv.y, 1. - progress);
|
|
89
|
+
#else
|
|
90
|
+
vec3 colorCoords = vec3(progress, vUv.x, vUv.y);
|
|
91
|
+
#endif
|
|
92
|
+
|
|
93
|
+
#ifdef IS_POLAR
|
|
94
|
+
vec2 toCenter = vUv - 0.5;
|
|
95
|
+
float angle = atan(toCenter.y, toCenter.x);
|
|
96
|
+
float radius = length(toCenter) * 2.0;
|
|
97
|
+
|
|
98
|
+
#if PROGRESS_AXIS == 2
|
|
99
|
+
colorCoords = vec3((angle / TWO_PI), radius, 1. - progress);
|
|
100
|
+
#elif PROGRESS_AXIS == 1
|
|
101
|
+
colorCoords = vec3((angle / TWO_PI), 1. - progress, radius);
|
|
102
|
+
if (radius > 1.0) { discard; }
|
|
103
|
+
#else
|
|
104
|
+
float hue = 1.0 - abs(0.5 - progress * .5) * 2.0;
|
|
105
|
+
if (vUv.x > 0.5) { hue += 0.5; }
|
|
106
|
+
colorCoords = vec3(hue, abs(0.5 - vUv.x) * 2.0, vUv.y);
|
|
107
|
+
#endif
|
|
108
|
+
#endif
|
|
109
|
+
|
|
110
|
+
#ifdef INVERT_Z
|
|
111
|
+
colorCoords.z = 1. - colorCoords.z;
|
|
112
|
+
#endif
|
|
113
|
+
|
|
114
|
+
vec3 rgb = polarToRGB(colorCoords);
|
|
115
|
+
|
|
116
|
+
#ifdef SHOW_RAW
|
|
117
|
+
fragColor = vec4(rgb, 1.);
|
|
118
|
+
#else
|
|
119
|
+
fragColor = vec4(closestColor(rgb, paletteTexture), 1.);
|
|
120
|
+
#endif
|
|
121
|
+
}`;
|
|
122
|
+
|
|
123
|
+
// ── Color parsing ──────────────────────────────────────────────────────────────
|
|
124
|
+
// Use a canvas 2D context as a free CSS color parser — handles hex, rgb(),
|
|
125
|
+
// hsl(), named colors, etc. Lazy-initialised to avoid issues at module load time.
|
|
126
|
+
|
|
127
|
+
let _colorCtx: CanvasRenderingContext2D | null = null;
|
|
128
|
+
|
|
129
|
+
function cssToSRGB(color: string): [number, number, number] {
|
|
130
|
+
if (!_colorCtx) {
|
|
131
|
+
const c = document.createElement("canvas");
|
|
132
|
+
c.width = c.height = 1;
|
|
133
|
+
_colorCtx = c.getContext("2d")!;
|
|
134
|
+
}
|
|
135
|
+
_colorCtx.fillStyle = "#000000"; // reset before setting
|
|
136
|
+
_colorCtx.fillStyle = color;
|
|
137
|
+
const v = _colorCtx.fillStyle; // browser normalises to '#rrggbb' or 'rgba(...)'
|
|
138
|
+
if (v[0] === "#") {
|
|
139
|
+
return [
|
|
140
|
+
parseInt(v.slice(1, 3), 16) / 255,
|
|
141
|
+
parseInt(v.slice(3, 5), 16) / 255,
|
|
142
|
+
parseInt(v.slice(5, 7), 16) / 255,
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
// rgba(r, g, b, a) fallback
|
|
146
|
+
const m = v.match(/[\d.]+/g)!;
|
|
147
|
+
return [+m[0] / 255, +m[1] / 255, +m[2] / 255];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Palette helpers ────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
// Returns the palette as a flat RGBA Uint8Array (sRGB, 1×N texture row).
|
|
153
|
+
// Useful for building your own WebGL texture or inspecting raw color data.
|
|
154
|
+
export const paletteToRGBA = (palette: ColorList): Uint8Array => {
|
|
155
|
+
const data = new Uint8Array(palette.length * 4);
|
|
156
|
+
palette.forEach((color, i) => {
|
|
157
|
+
try {
|
|
158
|
+
const [r, g, b] = cssToSRGB(color);
|
|
159
|
+
data[i * 4 + 0] = Math.round(r * 255);
|
|
160
|
+
data[i * 4 + 1] = Math.round(g * 255);
|
|
161
|
+
data[i * 4 + 2] = Math.round(b * 255);
|
|
162
|
+
data[i * 4 + 3] = 255;
|
|
163
|
+
} catch {
|
|
164
|
+
console.error(`Invalid color: ${color}`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return data;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Backwards-compatible alias (previously returned a Three.js DataTexture)
|
|
171
|
+
export const paletteToTexture = paletteToRGBA;
|
|
172
|
+
|
|
173
|
+
export const randomPalette = (size = 20): ColorList =>
|
|
174
|
+
Array.from({ length: size }, () =>
|
|
175
|
+
`rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)})`
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// ── WebGL helpers ──────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
type Defines = Record<string, number | false>;
|
|
181
|
+
|
|
182
|
+
function compileShader(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader {
|
|
183
|
+
const shader = gl.createShader(type)!;
|
|
184
|
+
gl.shaderSource(shader, src);
|
|
185
|
+
gl.compileShader(shader);
|
|
186
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
187
|
+
const log = gl.getShaderInfoLog(shader);
|
|
188
|
+
gl.deleteShader(shader);
|
|
189
|
+
throw new Error(`Shader compile error:\n${log}`);
|
|
190
|
+
}
|
|
191
|
+
return shader;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildProgram(gl: WebGL2RenderingContext, defines: Defines, fragSrc: string, vertSrc: string): WebGLProgram {
|
|
195
|
+
// #version 300 es must be the very first line — prepend it before defines.
|
|
196
|
+
const defineStr = Object.entries(defines)
|
|
197
|
+
.filter(([, v]) => v !== false)
|
|
198
|
+
.map(([k, v]) => `#define ${k} ${v}`)
|
|
199
|
+
.join("\n") + "\n";
|
|
200
|
+
const prefix = "#version 300 es\n" + defineStr;
|
|
201
|
+
|
|
202
|
+
const vert = compileShader(gl, gl.VERTEX_SHADER, prefix + vertSrc);
|
|
203
|
+
const frag = compileShader(gl, gl.FRAGMENT_SHADER, prefix + fragSrc);
|
|
204
|
+
|
|
205
|
+
const prog = gl.createProgram()!;
|
|
206
|
+
gl.attachShader(prog, vert);
|
|
207
|
+
gl.attachShader(prog, frag);
|
|
208
|
+
gl.linkProgram(prog);
|
|
209
|
+
gl.deleteShader(vert);
|
|
210
|
+
gl.deleteShader(frag);
|
|
211
|
+
|
|
212
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
|
213
|
+
const log = gl.getProgramInfoLog(prog);
|
|
214
|
+
gl.deleteProgram(prog);
|
|
215
|
+
throw new Error(`Program link error:\n${log}`);
|
|
216
|
+
}
|
|
217
|
+
return prog;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function uploadPaletteTexture(gl: WebGL2RenderingContext, tex: WebGLTexture, palette: ColorList): void {
|
|
221
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
222
|
+
// RGBA8: sized internal format required by WebGL2 spec
|
|
223
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, palette.length, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, paletteToRGBA(palette));
|
|
224
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
225
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
226
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
227
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── PaletteViz ─────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export class PaletteViz {
|
|
233
|
+
#palette: ColorList = [];
|
|
234
|
+
#width = 512;
|
|
235
|
+
#height = 512;
|
|
236
|
+
#pixelRatio = 1;
|
|
237
|
+
|
|
238
|
+
// shader state
|
|
239
|
+
#position = 0.0;
|
|
240
|
+
#axis: Axis = "y";
|
|
241
|
+
#colorModel: SupportedColorModels = "okhsv";
|
|
242
|
+
#distanceMetric: DistanceMetric = "oklab";
|
|
243
|
+
#isPolar = true;
|
|
244
|
+
#invertLightness = false;
|
|
245
|
+
#showRaw = false;
|
|
246
|
+
|
|
247
|
+
// uniform value maps
|
|
248
|
+
readonly #axisMap = { x: 0, y: 1, z: 2 } as const;
|
|
249
|
+
readonly #colorModelMap = { hsv: 0, okhsv: 1, hsl: 2, okhsl: 3, oklch: 4 } as const;
|
|
250
|
+
readonly #distanceMetricMap = { rgb: 0, oklab: 1, deltaE76: 2, deltaE2000: 3, kotsarenkoRamos: 4, deltaE94: 5 } as const;
|
|
251
|
+
|
|
252
|
+
// WebGL
|
|
253
|
+
#canvas: HTMLCanvasElement;
|
|
254
|
+
#gl: WebGL2RenderingContext;
|
|
255
|
+
#program: WebGLProgram | null = null;
|
|
256
|
+
#texture: WebGLTexture | null = null;
|
|
257
|
+
#quadBuffer: WebGLBuffer | null = null;
|
|
258
|
+
#vao: WebGLVertexArrayObject | null = null;
|
|
259
|
+
#animationFrame: number | null = null;
|
|
260
|
+
|
|
261
|
+
// cached uniform locations (re-queried after each program rebuild)
|
|
262
|
+
#uProgress: WebGLUniformLocation | null = null;
|
|
263
|
+
#uPaletteTexture: WebGLUniformLocation | null = null;
|
|
264
|
+
|
|
265
|
+
// dom
|
|
266
|
+
#container: HTMLElement | undefined;
|
|
267
|
+
|
|
268
|
+
constructor({
|
|
269
|
+
palette = randomPalette(),
|
|
270
|
+
width = 512,
|
|
271
|
+
height = 512,
|
|
272
|
+
pixelRatio = window.devicePixelRatio,
|
|
273
|
+
container,
|
|
274
|
+
colorModel = "okhsv",
|
|
275
|
+
distanceMetric = "oklab",
|
|
276
|
+
isPolar = true,
|
|
277
|
+
axis = "y",
|
|
278
|
+
position = 0.0,
|
|
279
|
+
invertLightness = false,
|
|
280
|
+
showRaw = false,
|
|
281
|
+
}: PaletteVizOptions = {}) {
|
|
282
|
+
this.#palette = palette;
|
|
283
|
+
this.#width = width;
|
|
284
|
+
this.#height = height;
|
|
285
|
+
this.#pixelRatio = pixelRatio;
|
|
286
|
+
this.#colorModel = colorModel;
|
|
287
|
+
this.#distanceMetric = distanceMetric;
|
|
288
|
+
this.#isPolar = isPolar;
|
|
289
|
+
this.#axis = axis;
|
|
290
|
+
this.#position = position;
|
|
291
|
+
this.#invertLightness = invertLightness;
|
|
292
|
+
this.#showRaw = showRaw;
|
|
293
|
+
this.#container = container;
|
|
294
|
+
|
|
295
|
+
this.#canvas = document.createElement("canvas");
|
|
296
|
+
this.#canvas.classList.add("palette-viz");
|
|
297
|
+
const gl = this.#canvas.getContext("webgl2");
|
|
298
|
+
if (!gl) throw new Error("WebGL2 not supported");
|
|
299
|
+
this.#gl = gl;
|
|
300
|
+
|
|
301
|
+
// Quad buffer + VAO — set up once, reused every frame.
|
|
302
|
+
// layout(location=0) in the vertex shader pins a_position to slot 0,
|
|
303
|
+
// so the VAO remains valid across shader recompiles.
|
|
304
|
+
this.#quadBuffer = gl.createBuffer()!;
|
|
305
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.#quadBuffer);
|
|
306
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
|
|
307
|
+
|
|
308
|
+
this.#vao = gl.createVertexArray()!;
|
|
309
|
+
gl.bindVertexArray(this.#vao);
|
|
310
|
+
gl.enableVertexAttribArray(0);
|
|
311
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
312
|
+
gl.bindVertexArray(null);
|
|
313
|
+
|
|
314
|
+
this.#texture = gl.createTexture()!;
|
|
315
|
+
uploadPaletteTexture(gl, this.#texture, this.#palette);
|
|
316
|
+
|
|
317
|
+
this.#buildProgram();
|
|
318
|
+
this.#setSize(this.#width, this.#height);
|
|
319
|
+
this.#container?.appendChild(this.#canvas);
|
|
320
|
+
this.#paint();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#defines(): Defines {
|
|
324
|
+
return {
|
|
325
|
+
DISTANCE_METRIC: this.#distanceMetricMap[this.#distanceMetric],
|
|
326
|
+
COLOR_MODEL: this.#colorModelMap[this.#colorModel],
|
|
327
|
+
PROGRESS_AXIS: this.#axisMap[this.#axis],
|
|
328
|
+
IS_POLAR: this.#isPolar ? 1 : false,
|
|
329
|
+
INVERT_Z: this.#invertLightness ? 1 : false,
|
|
330
|
+
SHOW_RAW: this.#showRaw ? 1 : false,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#buildProgram(): void {
|
|
335
|
+
const gl = this.#gl;
|
|
336
|
+
if (this.#program) gl.deleteProgram(this.#program);
|
|
337
|
+
this.#program = buildProgram(gl, this.#defines(), fragmentShader, vertexShaderSrc);
|
|
338
|
+
this.#uProgress = gl.getUniformLocation(this.#program, "progress");
|
|
339
|
+
this.#uPaletteTexture = gl.getUniformLocation(this.#program, "paletteTexture");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#setSize(w: number, h: number): void {
|
|
343
|
+
const pw = Math.round(w * this.#pixelRatio);
|
|
344
|
+
const ph = Math.round(h * this.#pixelRatio);
|
|
345
|
+
this.#canvas.width = pw;
|
|
346
|
+
this.#canvas.height = ph;
|
|
347
|
+
this.#canvas.style.width = `${w}px`;
|
|
348
|
+
this.#canvas.style.height = `${h}px`;
|
|
349
|
+
this.#gl.viewport(0, 0, pw, ph);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#paint(): void {
|
|
353
|
+
if (this.#animationFrame !== null) cancelAnimationFrame(this.#animationFrame);
|
|
354
|
+
this.#animationFrame = requestAnimationFrame(() => {
|
|
355
|
+
const gl = this.#gl;
|
|
356
|
+
gl.useProgram(this.#program);
|
|
357
|
+
|
|
358
|
+
gl.uniform1f(this.#uProgress, this.#position);
|
|
359
|
+
|
|
360
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
361
|
+
gl.bindTexture(gl.TEXTURE_2D, this.#texture);
|
|
362
|
+
gl.uniform1i(this.#uPaletteTexture, 0);
|
|
363
|
+
|
|
364
|
+
gl.bindVertexArray(this.#vao);
|
|
365
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
366
|
+
gl.bindVertexArray(null);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
get canvas(): HTMLCanvasElement { return this.#canvas; }
|
|
373
|
+
get width() { return this.#width; }
|
|
374
|
+
get height() { return this.#height; }
|
|
375
|
+
|
|
376
|
+
resize(width: number, height: number | null = null): void {
|
|
377
|
+
this.#width = width;
|
|
378
|
+
this.#height = height ?? width;
|
|
379
|
+
this.#setSize(this.#width, this.#height);
|
|
380
|
+
this.#paint();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
destroy(): void {
|
|
384
|
+
if (this.#animationFrame !== null) {
|
|
385
|
+
cancelAnimationFrame(this.#animationFrame);
|
|
386
|
+
this.#animationFrame = null;
|
|
387
|
+
}
|
|
388
|
+
const gl = this.#gl;
|
|
389
|
+
gl.deleteProgram(this.#program);
|
|
390
|
+
gl.deleteTexture(this.#texture);
|
|
391
|
+
gl.deleteBuffer(this.#quadBuffer);
|
|
392
|
+
gl.deleteVertexArray(this.#vao);
|
|
393
|
+
this.#canvas.remove();
|
|
394
|
+
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Palette ─────────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
set palette(palette: ColorList) {
|
|
400
|
+
this.#palette = palette;
|
|
401
|
+
uploadPaletteTexture(this.#gl, this.#texture!, palette);
|
|
402
|
+
this.#paint();
|
|
403
|
+
}
|
|
404
|
+
get palette() { return this.#palette; }
|
|
405
|
+
|
|
406
|
+
setColor(color: ColorString, index: number): void {
|
|
407
|
+
if (index < 0 || index >= this.#palette.length) throw new Error(`Index ${index} out of range`);
|
|
408
|
+
this.#palette[index] = color;
|
|
409
|
+
uploadPaletteTexture(this.#gl, this.#texture!, this.#palette);
|
|
410
|
+
this.#paint();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
addColor(color: ColorString, index?: number): void {
|
|
414
|
+
this.#palette.splice(index ?? this.#palette.length, 0, color);
|
|
415
|
+
uploadPaletteTexture(this.#gl, this.#texture!, this.#palette);
|
|
416
|
+
this.#paint();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
removeColor(index: number): void;
|
|
420
|
+
removeColor(color: ColorString): void;
|
|
421
|
+
removeColor(indexOrColor: number | ColorString): void {
|
|
422
|
+
const index = typeof indexOrColor === "number"
|
|
423
|
+
? indexOrColor
|
|
424
|
+
: this.#palette.indexOf(indexOrColor);
|
|
425
|
+
if (index === -1) throw new Error("Color not found in palette");
|
|
426
|
+
if (index < 0 || index >= this.#palette.length) throw new Error(`Index ${index} out of range`);
|
|
427
|
+
this.#palette.splice(index, 1);
|
|
428
|
+
uploadPaletteTexture(this.#gl, this.#texture!, this.#palette);
|
|
429
|
+
this.#paint();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Shader properties ────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
set position(value: number) { this.#position = value; this.#paint(); }
|
|
435
|
+
get position() { return this.#position; }
|
|
436
|
+
|
|
437
|
+
set axis(axis: Axis) {
|
|
438
|
+
if (!(axis in this.#axisMap)) throw new Error("axis must be 'x', 'y', or 'z'");
|
|
439
|
+
this.#axis = axis;
|
|
440
|
+
this.#buildProgram();
|
|
441
|
+
this.#paint();
|
|
442
|
+
}
|
|
443
|
+
get axis() { return this.#axis; }
|
|
444
|
+
|
|
445
|
+
set colorModel(model: SupportedColorModels) {
|
|
446
|
+
if (!(model in this.#colorModelMap)) throw new Error("colorModel must be 'hsv', 'okhsv', 'hsl', 'okhsl', or 'oklch'");
|
|
447
|
+
this.#colorModel = model;
|
|
448
|
+
this.#buildProgram();
|
|
449
|
+
this.#paint();
|
|
450
|
+
}
|
|
451
|
+
get colorModel() { return this.#colorModel; }
|
|
452
|
+
|
|
453
|
+
set distanceMetric(metric: DistanceMetric) {
|
|
454
|
+
if (!(metric in this.#distanceMetricMap)) throw new Error("distanceMetric must be 'rgb', 'oklab', 'deltaE76', 'deltaE94', 'deltaE2000', or 'kotsarenkoRamos'");
|
|
455
|
+
this.#distanceMetric = metric;
|
|
456
|
+
this.#buildProgram();
|
|
457
|
+
this.#paint();
|
|
458
|
+
}
|
|
459
|
+
get distanceMetric() { return this.#distanceMetric; }
|
|
460
|
+
|
|
461
|
+
set isPolar(value: boolean) {
|
|
462
|
+
this.#isPolar = value;
|
|
463
|
+
this.#buildProgram();
|
|
464
|
+
this.#paint();
|
|
465
|
+
}
|
|
466
|
+
get isPolar() { return this.#isPolar; }
|
|
467
|
+
|
|
468
|
+
set invertLightness(value: boolean) {
|
|
469
|
+
this.#invertLightness = value;
|
|
470
|
+
this.#buildProgram();
|
|
471
|
+
this.#paint();
|
|
472
|
+
}
|
|
473
|
+
get invertLightness() { return this.#invertLightness; }
|
|
474
|
+
|
|
475
|
+
set showRaw(value: boolean) {
|
|
476
|
+
this.#showRaw = value;
|
|
477
|
+
this.#buildProgram();
|
|
478
|
+
this.#paint();
|
|
479
|
+
}
|
|
480
|
+
get showRaw() { return this.#showRaw; }
|
|
481
|
+
|
|
482
|
+
static paletteToRGBA = paletteToRGBA;
|
|
483
|
+
/** @deprecated use PaletteViz.paletteToRGBA */
|
|
484
|
+
static paletteToTexture = paletteToRGBA;
|
|
485
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// DISTANCE_METRIC define: 0=rgb, 1=oklab, 2=deltaE76, 3=deltaE2000, 4=kotsarenkoRamos, 5=deltaE94
|
|
2
|
+
vec3 closestColor(vec3 color, sampler2D paletteTexture) {
|
|
3
|
+
int paletteSize = textureSize(paletteTexture, 0).x;
|
|
4
|
+
float minDist = 1000000.0;
|
|
5
|
+
vec3 closest = vec3(0.0);
|
|
6
|
+
|
|
7
|
+
// Pre-convert the input color once — palette entries are converted inside the loop.
|
|
8
|
+
#if DISTANCE_METRIC == 1
|
|
9
|
+
vec3 colorConverted = linear_srgb_to_oklab(srgb2rgb(color));
|
|
10
|
+
#elif DISTANCE_METRIC == 2 || DISTANCE_METRIC == 3 || DISTANCE_METRIC == 5
|
|
11
|
+
vec3 colorConverted = srgb_to_cielab(color);
|
|
12
|
+
#else
|
|
13
|
+
vec3 colorConverted = color;
|
|
14
|
+
#endif
|
|
15
|
+
|
|
16
|
+
for (int i = 0; i < paletteSize; i++) {
|
|
17
|
+
vec3 paletteColor = texelFetch(paletteTexture, ivec2(i, 0), 0).rgb;
|
|
18
|
+
|
|
19
|
+
float dist;
|
|
20
|
+
#if DISTANCE_METRIC == 1
|
|
21
|
+
dist = distance(colorConverted, linear_srgb_to_oklab(srgb2rgb(paletteColor)));
|
|
22
|
+
#elif DISTANCE_METRIC == 2
|
|
23
|
+
dist = deltaE76(colorConverted, srgb_to_cielab(paletteColor));
|
|
24
|
+
#elif DISTANCE_METRIC == 3
|
|
25
|
+
dist = deltaE2000(colorConverted, srgb_to_cielab(paletteColor));
|
|
26
|
+
#elif DISTANCE_METRIC == 4
|
|
27
|
+
dist = kotsarenkoRamos(color, paletteColor);
|
|
28
|
+
#elif DISTANCE_METRIC == 5
|
|
29
|
+
dist = deltaE94(colorConverted, srgb_to_cielab(paletteColor));
|
|
30
|
+
#else
|
|
31
|
+
dist = distance(colorConverted, paletteColor);
|
|
32
|
+
#endif
|
|
33
|
+
|
|
34
|
+
if (dist < minDist) {
|
|
35
|
+
minDist = dist;
|
|
36
|
+
closest = paletteColor;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return closest;
|
|
41
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Kotsarenko/Ramos weighted RGB distance.
|
|
2
|
+
// Operates on sRGB values directly (no linearisation needed).
|
|
3
|
+
// Weights red and blue channels by the mean red value, which improves
|
|
4
|
+
// perceptual uniformity compared to plain Euclidean RGB at minimal cost.
|
|
5
|
+
float kotsarenkoRamos(vec3 c1, vec3 c2) {
|
|
6
|
+
float rMean = (c1.r + c2.r) * 0.5;
|
|
7
|
+
vec3 d = c1 - c2;
|
|
8
|
+
return sqrt((2.0 + rMean) * d.r*d.r + 4.0 * d.g*d.g + (3.0 - rMean) * d.b*d.b);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── CIELab ────────────────────────────────────────────────────────────────────
|
|
12
|
+
// sRGB -> XYZ (D65) -> CIELab
|
|
13
|
+
// Depends on: srgb2rgb() from srgb2rgb.frag.glsl, cbrt() from oklab.frag.glsl
|
|
14
|
+
|
|
15
|
+
float _lab_f(float t) {
|
|
16
|
+
float delta = 6.0 / 29.0;
|
|
17
|
+
return t > delta * delta * delta
|
|
18
|
+
? cbrt(t)
|
|
19
|
+
: t / (3.0 * delta * delta) + 4.0 / 29.0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
vec3 srgb_to_cielab(vec3 srgb) {
|
|
23
|
+
vec3 lin = srgb2rgb(srgb);
|
|
24
|
+
|
|
25
|
+
// Linear sRGB -> XYZ (D65 illuminant)
|
|
26
|
+
vec3 xyz = vec3(
|
|
27
|
+
0.4124564 * lin.r + 0.3575761 * lin.g + 0.1804375 * lin.b,
|
|
28
|
+
0.2126729 * lin.r + 0.7151522 * lin.g + 0.0721750 * lin.b,
|
|
29
|
+
0.0193339 * lin.r + 0.1191920 * lin.g + 0.9503041 * lin.b
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// XYZ -> Lab (D65 white point: 0.95047, 1.00000, 1.08883)
|
|
33
|
+
float fx = _lab_f(xyz.x / 0.95047);
|
|
34
|
+
float fy = _lab_f(xyz.y);
|
|
35
|
+
float fz = _lab_f(xyz.z / 1.08883);
|
|
36
|
+
|
|
37
|
+
return vec3(
|
|
38
|
+
116.0 * fy - 16.0, // L*
|
|
39
|
+
500.0 * (fx - fy), // a*
|
|
40
|
+
200.0 * (fy - fz) // b*
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// CIE76: plain Euclidean distance in CIELab
|
|
45
|
+
float deltaE76(vec3 lab1, vec3 lab2) {
|
|
46
|
+
return distance(lab1, lab2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// CIE94: weighted chroma/hue corrections, cheaper than CIEDE2000
|
|
50
|
+
// Uses graphics application constants: kL=1, K1=0.045, K2=0.015
|
|
51
|
+
float deltaE94(vec3 lab1, vec3 lab2) {
|
|
52
|
+
float dL = lab1.x - lab2.x;
|
|
53
|
+
float da = lab1.y - lab2.y;
|
|
54
|
+
float db = lab1.z - lab2.z;
|
|
55
|
+
float C1 = sqrt(lab1.y * lab1.y + lab1.z * lab1.z);
|
|
56
|
+
float C2 = sqrt(lab2.y * lab2.y + lab2.z * lab2.z);
|
|
57
|
+
float dC = C1 - C2;
|
|
58
|
+
float dH = sqrt(max(0.0, da*da + db*db - dC*dC));
|
|
59
|
+
float SC = 1.0 + 0.045 * C1;
|
|
60
|
+
float SH = 1.0 + 0.015 * C1;
|
|
61
|
+
return sqrt(dL*dL + (dC/SC)*(dC/SC) + (dH/SH)*(dH/SH));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// CIEDE2000
|
|
65
|
+
float deltaE2000(vec3 lab1, vec3 lab2) {
|
|
66
|
+
float L1 = lab1.x, a1 = lab1.y, b1 = lab1.z;
|
|
67
|
+
float L2 = lab2.x, a2 = lab2.y, b2 = lab2.z;
|
|
68
|
+
|
|
69
|
+
// Chroma
|
|
70
|
+
float C1 = sqrt(a1*a1 + b1*b1);
|
|
71
|
+
float C2 = sqrt(a2*a2 + b2*b2);
|
|
72
|
+
float Cavg = (C1 + C2) * 0.5;
|
|
73
|
+
float Cavg7 = pow(Cavg, 7.0);
|
|
74
|
+
|
|
75
|
+
// G factor: adjustment to a* axis
|
|
76
|
+
float G = 0.5 * (1.0 - sqrt(Cavg7 / (Cavg7 + 6103515625.0))); // 25^7
|
|
77
|
+
|
|
78
|
+
float a1p = a1 * (1.0 + G);
|
|
79
|
+
float a2p = a2 * (1.0 + G);
|
|
80
|
+
float C1p = sqrt(a1p*a1p + b1*b1);
|
|
81
|
+
float C2p = sqrt(a2p*a2p + b2*b2);
|
|
82
|
+
|
|
83
|
+
// Guard atan(0,0): GLSL ES leaves that undefined, so skip it for achromatic colors.
|
|
84
|
+
// When a color has no chroma its hue angle is meaningless — we just need it to
|
|
85
|
+
// be a well-defined number so it doesn't corrupt the rest of the formula.
|
|
86
|
+
bool c1Achromatic = C1p < 1e-6;
|
|
87
|
+
bool c2Achromatic = C2p < 1e-6;
|
|
88
|
+
|
|
89
|
+
float h1p = c1Achromatic ? 0.0 : atan(b1, a1p);
|
|
90
|
+
if (h1p < 0.0) h1p += TWO_PI;
|
|
91
|
+
float h2p = c2Achromatic ? 0.0 : atan(b2, a2p);
|
|
92
|
+
if (h2p < 0.0) h2p += TWO_PI;
|
|
93
|
+
|
|
94
|
+
// Deltas
|
|
95
|
+
float dLp = L2 - L1;
|
|
96
|
+
float dCp = C2p - C1p;
|
|
97
|
+
|
|
98
|
+
float dhp = 0.0;
|
|
99
|
+
if (!c1Achromatic && !c2Achromatic) {
|
|
100
|
+
dhp = h2p - h1p;
|
|
101
|
+
if (dhp > M_PI) dhp -= TWO_PI;
|
|
102
|
+
else if (dhp < -M_PI) dhp += TWO_PI;
|
|
103
|
+
}
|
|
104
|
+
float dHp = 2.0 * sqrt(C1p * C2p) * sin(dhp * 0.5);
|
|
105
|
+
|
|
106
|
+
// Averages
|
|
107
|
+
float Lp = (L1 + L2) * 0.5;
|
|
108
|
+
float Cp = (C1p + C2p) * 0.5;
|
|
109
|
+
|
|
110
|
+
// When one color is achromatic, its hue is 0 and the average is simply the other's hue
|
|
111
|
+
float hp;
|
|
112
|
+
if (c1Achromatic || c2Achromatic) {
|
|
113
|
+
hp = h1p + h2p;
|
|
114
|
+
} else if (abs(h1p - h2p) <= M_PI) {
|
|
115
|
+
hp = (h1p + h2p) * 0.5;
|
|
116
|
+
} else if (h1p + h2p < TWO_PI) {
|
|
117
|
+
hp = (h1p + h2p + TWO_PI) * 0.5;
|
|
118
|
+
} else {
|
|
119
|
+
hp = (h1p + h2p - TWO_PI) * 0.5;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
float T = 1.0
|
|
123
|
+
- 0.17 * cos(hp - radians(30.0))
|
|
124
|
+
+ 0.24 * cos(2.0 * hp)
|
|
125
|
+
+ 0.32 * cos(3.0 * hp + radians(6.0))
|
|
126
|
+
- 0.20 * cos(4.0 * hp - radians(63.0));
|
|
127
|
+
|
|
128
|
+
// Weighting functions
|
|
129
|
+
float Lpm50sq = (Lp - 50.0) * (Lp - 50.0);
|
|
130
|
+
float SL = 1.0 + 0.015 * Lpm50sq / sqrt(20.0 + Lpm50sq);
|
|
131
|
+
float SC = 1.0 + 0.045 * Cp;
|
|
132
|
+
float SH = 1.0 + 0.015 * Cp * T;
|
|
133
|
+
|
|
134
|
+
// Rotation term
|
|
135
|
+
float Cp7 = pow(Cp, 7.0);
|
|
136
|
+
float RC = 2.0 * sqrt(Cp7 / (Cp7 + 6103515625.0));
|
|
137
|
+
float hpDeg = degrees(hp);
|
|
138
|
+
float dTheta = radians(30.0) * exp(-((hpDeg - 275.0) / 25.0) * ((hpDeg - 275.0) / 25.0));
|
|
139
|
+
float RT = -sin(2.0 * dTheta) * RC;
|
|
140
|
+
|
|
141
|
+
float dLn = dLp / SL;
|
|
142
|
+
float dCn = dCp / SC;
|
|
143
|
+
float dHn = dHp / SH;
|
|
144
|
+
|
|
145
|
+
return sqrt(dLn*dLn + dCn*dCn + dHn*dHn + RT * dCn * dHn);
|
|
146
|
+
}
|