// 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 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 (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 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 // 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 // pointing at it anymore) and shows the raster instead this._shadowRoot.replaceChild(this._canvas, this._slot); } } } customElements.define('px-icon', PxIcon); export { PxIcon, rasterCache };