Files
px-icon/px-icon.js
T
2026-08-08 15:47:25 +02:00

223 lines
7.6 KiB
JavaScript

// px-icon: rasterizes an inline SVG (light DOM, source of truth) onto a
// shared art-pixel grid, rendered into shadow DOM (generated output).
//
// Why shadow DOM: idiomorph/Datastar morphs diff the live DOM against
// server HTML by walking childNodes, which does not include shadow trees.
// Keeping the raw <svg> in light DOM and the raster in shadow DOM means
// server-driven morphs can freely update/preserve the source icon without
// ever touching or being fought by the generated output. No ignore-morph
// markers needed.
//
// Config (ap size, downsample mode) comes from CSS custom properties so it
// cascades normally: --pxicon-ap-size, --pxicon-mode ('average' | 'point').
const DEFAULT_AP_SIZE = 8;
const DEFAULT_MODE = 'average';
const SUPERSAMPLE = 8; // per grid cell, for point-sample mode
// key -> Promise<HTMLCanvasElement> (the small pre-upscale grid raster)
const rasterCache = new Map();
function hashString(str) {
// fast non-crypto hash (FNV-1a), fine for a cache key over short svg markup
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36);
}
function snapToGrid(px, apPx) {
return Math.max(apPx, Math.round(px / apPx) * apPx);
}
function svgToImage(svgMarkup) {
return new Promise((resolve, reject) => {
const blob = new Blob([svgMarkup], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
function areaAverageDownsample(img, gridW, gridH) {
const canvas = document.createElement('canvas');
canvas.width = gridW;
canvas.height = gridH;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, gridW, gridH);
return canvas;
}
function pointSampleDownsample(img, gridW, gridH) {
const bufW = gridW * SUPERSAMPLE;
const bufH = gridH * SUPERSAMPLE;
const buf = document.createElement('canvas');
buf.width = bufW;
buf.height = bufH;
const bctx = buf.getContext('2d', { willReadFrequently: true });
bctx.imageSmoothingEnabled = true;
bctx.imageSmoothingQuality = 'high';
bctx.drawImage(img, 0, 0, bufW, bufH);
let srcData;
try {
srcData = bctx.getImageData(0, 0, bufW, bufH);
} catch (e) {
// tainted canvas (cross-origin source without CORS headers); caller
// falls back to area-average
throw e;
}
const gridCanvas = document.createElement('canvas');
gridCanvas.width = gridW;
gridCanvas.height = gridH;
const gctx = gridCanvas.getContext('2d');
const out = gctx.createImageData(gridW, gridH);
for (let gy = 0; gy < gridH; gy++) {
for (let gx = 0; gx < gridW; gx++) {
const sx = Math.min(bufW - 1, Math.floor((gx + 0.5) * SUPERSAMPLE));
const sy = Math.min(bufH - 1, Math.floor((gy + 0.5) * SUPERSAMPLE));
const si = (sy * bufW + sx) * 4;
const di = (gy * gridW + gx) * 4;
out.data[di] = srcData.data[si];
out.data[di + 1] = srcData.data[si + 1];
out.data[di + 2] = srcData.data[si + 2];
out.data[di + 3] = srcData.data[si + 3];
}
}
gctx.putImageData(out, 0, 0);
return gridCanvas;
}
// Returns a cached Promise<HTMLCanvasElement> for the small grid raster,
// coalescing concurrent requests for the same key.
function getGridRaster(svgMarkup, mode, gridW, gridH) {
const key = `${hashString(svgMarkup)}|${mode}|${gridW}x${gridH}`;
let entry = rasterCache.get(key);
if (entry) return entry;
entry = svgToImage(svgMarkup).then((img) => {
let raster;
if (mode === 'point') {
try {
raster = pointSampleDownsample(img, gridW, gridH);
} catch (e) {
raster = areaAverageDownsample(img, gridW, gridH);
}
} else {
raster = areaAverageDownsample(img, gridW, gridH);
}
return raster;
});
rasterCache.set(key, entry);
return entry;
}
class PxIcon extends HTMLElement {
connectedCallback() {
if (!this._upgraded) {
this._upgraded = true;
this._upgrade();
}
// (Re)start watching the light-dom source for server-driven changes,
// e.g. a morph swapping in a different icon.
this._observer ??= new MutationObserver(() => this._scheduleRender());
this._observer.observe(this, { childList: true, subtree: true, attributes: true, characterData: true });
this._scheduleRender();
}
disconnectedCallback() {
this._observer?.disconnect();
this._resizeObserver?.disconnect();
}
_upgrade() {
const root = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { display: inline-block; width: 1.5em; height: 1.5em; line-height: 0; }
canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
`;
root.appendChild(style);
// Progressive enhancement: keep the light-dom svg visible through a
// <slot> until the first raster is ready, so there is no flash of
// empty content while the rasterization is in flight.
const slot = document.createElement('slot');
root.appendChild(slot);
this._canvas = document.createElement('canvas');
this._shadowRoot = root;
this._slot = slot;
this._resizeObserver = new ResizeObserver(() => this._scheduleRender());
this._resizeObserver.observe(this);
}
_scheduleRender() {
// Batch bursts of mutations/resizes into a single render on the next
// frame, rather than re-rasterizing once per mutation record.
if (this._renderQueued) return;
this._renderQueued = true;
requestAnimationFrame(() => {
this._renderQueued = false;
this._render();
});
}
async _render() {
const svgEl = this.querySelector('svg');
if (!svgEl) return;
const cs = getComputedStyle(this);
const apSize = parseFloat(cs.getPropertyValue('--pxicon-ap-size')) || DEFAULT_AP_SIZE;
const mode = (cs.getPropertyValue('--pxicon-mode') || DEFAULT_MODE).trim();
const displaySize = Math.max(this.clientWidth, this.clientHeight) || 24;
const snapped = snapToGrid(displaySize, apSize);
const gridW = Math.max(1, Math.round(snapped / apSize));
const gridH = gridW;
const svgMarkup = svgEl.outerHTML;
const key = `${svgMarkup}|${mode}|${gridW}x${gridH}`;
if (this._lastKey === key) return; // nothing actually changed
this._lastKey = key;
// debug-only: --pxicon-debug-delay lets demos/tests prove the
// slot stays visible while rasterization is genuinely in flight,
// rather than faking it with a CSS transition
const debugDelay = parseFloat(cs.getPropertyValue('--pxicon-debug-delay')) || 0;
if (debugDelay > 0) await new Promise((r) => setTimeout(r, debugDelay));
const gridCanvas = await getGridRaster(svgMarkup, mode, gridW, gridH);
const dpr = window.devicePixelRatio || 1;
const backingSize = Math.round(snapped * dpr);
this._canvas.width = backingSize;
this._canvas.height = backingSize;
const ctx = this._canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, backingSize, backingSize);
ctx.drawImage(gridCanvas, 0, 0, backingSize, backingSize);
if (this._slot.isConnected) {
// first successful raster: swap the placeholder slot for the
// canvas, which stops rendering the light-dom svg (no <slot>
// pointing at it anymore) and shows the raster instead
this._shadowRoot.replaceChild(this._canvas, this._slot);
}
}
}
customElements.define('px-icon', PxIcon);
export { PxIcon, rasterCache };