palette-shader 0.4.0 → 0.5.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 CHANGED
@@ -69,6 +69,7 @@ All options are optional. The palette defaults to a random 20-colour set.
69
69
  | `position` | `number` | `0` | 0–1 position along the chosen axis |
70
70
  | `invertZ` | `boolean` | `false` | Flip the lightness/value axis |
71
71
  | `showRaw` | `boolean` | `false` | Bypass nearest-colour matching (shows the raw colour space) |
72
+ | `outlineWidth` | `number` | `0` | Draw a transparent outline where palette regions meet. Width in physical pixels. `0` disables (no overhead). |
72
73
 
73
74
  ---
74
75
 
@@ -83,6 +84,7 @@ viz.colorModel = 'okhslPolar';
83
84
  viz.distanceMetric = 'deltaE2000';
84
85
  viz.invertZ = true;
85
86
  viz.showRaw = true;
87
+ viz.outlineWidth = 2; // transparent border between regions, in physical pixels
86
88
  ```
87
89
 
88
90
  Additional read-only properties:
@@ -133,7 +135,7 @@ viz.removeColor('#a8dadc');
133
135
 
134
136
  ### `destroy()`
135
137
 
136
- Cancel the animation frame, release all WebGL resources (program, texture, buffer, VAO), and remove the canvas from the DOM.
138
+ Cancel the animation frame, release all WebGL resources (programs, textures, framebuffer, buffer, VAO), and remove the canvas from the DOM.
137
139
 
138
140
  ---
139
141
 
@@ -217,6 +219,28 @@ document.querySelector('#slider').addEventListener('input', (e) => {
217
219
  });
218
220
  ```
219
221
 
222
+ ### Transparent outlines between regions
223
+
224
+ `outlineWidth` draws a transparent gap where one palette colour's region meets another, revealing whatever is behind the canvas. Width is in physical pixels (i.e. it already accounts for `pixelRatio`).
225
+
226
+ ```js
227
+ const viz = new PaletteViz({
228
+ palette,
229
+ outlineWidth: 2,
230
+ container: document.querySelector('#app'),
231
+ });
232
+
233
+ // change at runtime — no shader recompile while the value stays > 0
234
+ viz.outlineWidth = 4;
235
+
236
+ // set back to 0 to disable entirely (zero GPU overhead)
237
+ viz.outlineWidth = 0;
238
+ ```
239
+
240
+ Implemented as a two-pass render: pass 1 draws the colour regions into an offscreen framebuffer at the same cost as without outlines; pass 2 runs a tiny edge-detection shader that checks four neighbours via texture reads (no colour-space math). The result is that enabling outlines adds negligible overhead compared to the single-pass approach.
241
+
242
+ When `outlineWidth` is `0` (the default) the framebuffer and outline program are never allocated.
243
+
220
244
  ### Utility exports
221
245
 
222
246
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palette-shader",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Dependency-free WebGL2 shader that maps any colour palette across perceptual colour spaces — OKHsv, OKHsl, OKLCH and more.",
5
5
  "keywords": [
6
6
  "color",
package/src/index.ts CHANGED
@@ -126,6 +126,35 @@ void main(){
126
126
  #endif
127
127
  }`;
128
128
 
129
+ // Pass-2 shader: reads from the FBO color texture, detects edges by comparing
130
+ // N/S/E/W neighbors. Only opaque neighbors (a>0) participate in the comparison
131
+ // so polar-disc edges don't bleed into the outline.
132
+ const outlineFragmentShaderSrc = `
133
+ precision highp float;
134
+ in vec2 vUv;
135
+ out vec4 fragColor;
136
+ uniform sampler2D colorMap;
137
+ uniform float outlineWidth;
138
+ uniform vec2 resolution;
139
+
140
+ void main() {
141
+ vec4 center = texture(colorMap, vUv);
142
+ if (center.a == 0.0) { fragColor = vec4(0.0); return; }
143
+ vec2 px = outlineWidth / resolution;
144
+ vec4 n0 = texture(colorMap, vUv + vec2( px.x, 0.0));
145
+ vec4 n1 = texture(colorMap, vUv + vec2(-px.x, 0.0));
146
+ vec4 n2 = texture(colorMap, vUv + vec2(0.0, px.y));
147
+ vec4 n3 = texture(colorMap, vUv + vec2(0.0, -px.y));
148
+ if ((n0.a > 0.0 && any(notEqual(n0.rgb, center.rgb))) ||
149
+ (n1.a > 0.0 && any(notEqual(n1.rgb, center.rgb))) ||
150
+ (n2.a > 0.0 && any(notEqual(n2.rgb, center.rgb))) ||
151
+ (n3.a > 0.0 && any(notEqual(n3.rgb, center.rgb)))) {
152
+ fragColor = vec4(0.0);
153
+ return;
154
+ }
155
+ fragColor = center;
156
+ }`;
157
+
129
158
  // ── Color parsing ──────────────────────────────────────────────────────────────
130
159
  // Use a canvas 2D context as a free CSS color parser — handles hex, rgb(),
131
160
  // hsl(), named colors, etc. Lazy-initialised to avoid issues at module load time.
@@ -270,6 +299,7 @@ export class PaletteViz {
270
299
  #distanceMetric: DistanceMetric = 'oklab';
271
300
  #invertZ = false;
272
301
  #showRaw = false;
302
+ #outlineWidth = 0;
273
303
 
274
304
  // uniform value maps
275
305
  readonly #axisMap = { x: 0, y: 1, z: 2 } as const;
@@ -309,6 +339,14 @@ export class PaletteViz {
309
339
  #uProgress: WebGLUniformLocation | null = null;
310
340
  #uPaletteTexture: WebGLUniformLocation | null = null;
311
341
 
342
+ // outline pass (created/destroyed when outlineWidth toggles between 0 and >0)
343
+ #fbo: WebGLFramebuffer | null = null;
344
+ #fboTexture: WebGLTexture | null = null;
345
+ #outlineProgram: WebGLProgram | null = null;
346
+ #uColorMap: WebGLUniformLocation | null = null;
347
+ #uOutlineWidth: WebGLUniformLocation | null = null;
348
+ #uOutlineResolution: WebGLUniformLocation | null = null;
349
+
312
350
  // dom
313
351
  #container: HTMLElement | undefined;
314
352
 
@@ -324,6 +362,7 @@ export class PaletteViz {
324
362
  position = 0.0,
325
363
  invertZ = false,
326
364
  showRaw = false,
365
+ outlineWidth = 0,
327
366
  }: PaletteVizOptions = {}) {
328
367
  this.#palette = palette;
329
368
  this.#width = width;
@@ -335,6 +374,7 @@ export class PaletteViz {
335
374
  this.#position = position;
336
375
  this.#invertZ = invertZ;
337
376
  this.#showRaw = showRaw;
377
+ this.#outlineWidth = outlineWidth;
338
378
  this.#container = container;
339
379
 
340
380
  this.#canvas = document.createElement('canvas');
@@ -361,6 +401,7 @@ export class PaletteViz {
361
401
 
362
402
  this.#buildProgram();
363
403
  this.#setSize(this.#width, this.#height);
404
+ if (this.#outlineWidth > 0) this.#buildOutlineResources();
364
405
  this.#container?.appendChild(this.#canvas);
365
406
  this.#paint();
366
407
  }
@@ -383,6 +424,39 @@ export class PaletteViz {
383
424
  this.#uPaletteTexture = gl.getUniformLocation(this.#program, 'paletteTexture');
384
425
  }
385
426
 
427
+ #buildOutlineResources(): void {
428
+ const gl = this.#gl;
429
+ this.#outlineProgram = buildProgram(gl, {}, outlineFragmentShaderSrc, vertexShaderSrc);
430
+ this.#uColorMap = gl.getUniformLocation(this.#outlineProgram, 'colorMap');
431
+ this.#uOutlineWidth = gl.getUniformLocation(this.#outlineProgram, 'outlineWidth');
432
+ this.#uOutlineResolution = gl.getUniformLocation(this.#outlineProgram, 'resolution');
433
+
434
+ this.#fboTexture = gl.createTexture()!;
435
+ this.#fbo = gl.createFramebuffer()!;
436
+ this.#resizeFBO(this.#canvas.width, this.#canvas.height);
437
+ }
438
+
439
+ #destroyOutlineResources(): void {
440
+ const gl = this.#gl;
441
+ if (this.#outlineProgram) { gl.deleteProgram(this.#outlineProgram); this.#outlineProgram = null; }
442
+ if (this.#fboTexture) { gl.deleteTexture(this.#fboTexture); this.#fboTexture = null; }
443
+ if (this.#fbo) { gl.deleteFramebuffer(this.#fbo); this.#fbo = null; }
444
+ }
445
+
446
+ #resizeFBO(pw: number, ph: number): void {
447
+ const gl = this.#gl;
448
+ gl.bindTexture(gl.TEXTURE_2D, this.#fboTexture);
449
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, pw, ph, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
450
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
451
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
452
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
453
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
454
+ gl.bindTexture(gl.TEXTURE_2D, null);
455
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.#fbo);
456
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.#fboTexture, 0);
457
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
458
+ }
459
+
386
460
  #setSize(w: number, h: number): void {
387
461
  const pw = Math.round(w * this.#pixelRatio);
388
462
  const ph = Math.round(h * this.#pixelRatio);
@@ -391,22 +465,43 @@ export class PaletteViz {
391
465
  this.#canvas.style.width = `${w}px`;
392
466
  this.#canvas.style.height = `${h}px`;
393
467
  this.#gl.viewport(0, 0, pw, ph);
468
+ if (this.#fboTexture) this.#resizeFBO(pw, ph);
394
469
  }
395
470
 
396
471
  #paint(): void {
397
472
  if (this.#animationFrame !== null) cancelAnimationFrame(this.#animationFrame);
398
473
  this.#animationFrame = requestAnimationFrame(() => {
399
474
  const gl = this.#gl;
400
- gl.useProgram(this.#program);
401
475
 
402
- gl.uniform1f(this.#uProgress, this.#position);
476
+ // ── Pass 1: closest-color render ────────────────────────────────────────
477
+ // Target: FBO when outline is active, canvas otherwise.
478
+ if (this.#fbo) gl.bindFramebuffer(gl.FRAMEBUFFER, this.#fbo);
403
479
 
480
+ gl.useProgram(this.#program);
481
+ gl.uniform1f(this.#uProgress, this.#position);
404
482
  gl.activeTexture(gl.TEXTURE0);
405
483
  gl.bindTexture(gl.TEXTURE_2D, this.#texture);
406
484
  gl.uniform1i(this.#uPaletteTexture, 0);
407
485
 
486
+ gl.clearColor(0, 0, 0, 0);
487
+ gl.clear(gl.COLOR_BUFFER_BIT);
408
488
  gl.bindVertexArray(this.#vao);
409
489
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
490
+
491
+ if (!this.#fbo) { gl.bindVertexArray(null); return; }
492
+
493
+ // ── Pass 2: edge-detection using FBO texture ─────────────────────────────
494
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
495
+ gl.useProgram(this.#outlineProgram);
496
+ gl.activeTexture(gl.TEXTURE0);
497
+ gl.bindTexture(gl.TEXTURE_2D, this.#fboTexture);
498
+ gl.uniform1i(this.#uColorMap, 0);
499
+ gl.uniform1f(this.#uOutlineWidth, this.#outlineWidth);
500
+ gl.uniform2f(this.#uOutlineResolution, this.#canvas.width, this.#canvas.height);
501
+
502
+ gl.clearColor(0, 0, 0, 0);
503
+ gl.clear(gl.COLOR_BUFFER_BIT);
504
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
410
505
  gl.bindVertexArray(null);
411
506
  });
412
507
  }
@@ -436,6 +531,7 @@ export class PaletteViz {
436
531
  this.#animationFrame = null;
437
532
  }
438
533
  const gl = this.#gl;
534
+ this.#destroyOutlineResources();
439
535
  gl.deleteProgram(this.#program);
440
536
  gl.deleteTexture(this.#texture);
441
537
  gl.deleteBuffer(this.#quadBuffer);
@@ -541,6 +637,19 @@ export class PaletteViz {
541
637
  return this.#showRaw;
542
638
  }
543
639
 
640
+ set outlineWidth(value: number) {
641
+ const wasEnabled = this.#outlineWidth > 0;
642
+ this.#outlineWidth = value;
643
+ if ((value > 0) !== wasEnabled) {
644
+ if (value > 0) this.#buildOutlineResources();
645
+ else this.#destroyOutlineResources();
646
+ }
647
+ this.#paint();
648
+ }
649
+ get outlineWidth() {
650
+ return this.#outlineWidth;
651
+ }
652
+
544
653
  static paletteToRGBA = paletteToRGBA;
545
654
  /** @deprecated use PaletteViz.paletteToRGBA */
546
655
  static paletteToTexture = paletteToRGBA;
package/src/types.ts CHANGED
@@ -36,4 +36,5 @@ export type PaletteVizOptions = {
36
36
  position?: number;
37
37
  invertZ?: boolean;
38
38
  showRaw?: boolean;
39
+ outlineWidth?: number;
39
40
  };