Files
image-similarity/web/script.js
T

1403 lines
49 KiB
JavaScript

// Plumbing for the demos. All hashing happens in the worker (wasm).
const worker = new Worker('worker.js', { type: 'module' });
let nextId = 0;
const pending = new Map();
function rpc(op, args) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ id, op, args });
});
}
worker.onmessage = (e) => {
const { id, ok, result, error } = e.data;
const promise = pending.get(id);
pending.delete(id);
if (!promise) return;
if (ok) {
promise.resolve(result);
} else {
promise.reject(new Error(error));
}
};
if (window.hljs) hljs.highlightAll();
// Background fade-in, same trick as the landing page
{
const bg = document.querySelector('#bg img');
if (bg.complete) bg.classList.add('loaded');
else bg.addEventListener('load', () => bg.classList.add('loaded'));
}
// Helpers
function cssColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function pngUrl(bytes) {
return URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
}
function hamming(a, b) {
let x = a ^ b;
let count = 0;
while (x) {
x &= x - 1n;
count++;
}
return count;
}
function hex64(hash) {
return hash.toString(16).padStart(16, '0');
}
// Renders the top `bits` bits of a hash, msb first.
// Bits set in `diff` get marked as changed.
function bitGrid(container, hash, bits = 64, diff = 0n) {
container.replaceChildren();
for (let i = bits - 1; i >= 0; i--) {
const cell = document.createElement('div');
cell.className = (hash >> BigInt(i)) & 1n ? 'bit on' : 'bit off';
if ((diff >> BigInt(i)) & 1n) cell.classList.add('diff');
container.appendChild(cell);
}
}
// 8x8 grid of coefficient magnitudes, log scale.
// Green positive, red negative (sign is the semantic part), brightness is magnitude.
function heatGrid(container, values) {
container.replaceChildren();
const max = Math.max(...values.map(Math.abs), 1e-9);
for (const value of values) {
const cell = document.createElement('div');
const strength = Math.log1p(Math.abs(value)) / Math.log1p(max);
const hue = value < 0 ? 4 : 140;
cell.style.background = `hsl(${hue} 55% ${10 + strength * 48}%)`;
cell.title = value.toFixed(1);
container.appendChild(cell);
}
}
function pixelatedImg(url, size = 176) {
const img = document.createElement('img');
img.src = url;
img.className = 'pixelated';
img.width = size;
img.height = size;
return img;
}
function show() {
document.querySelectorAll('.needs-image').forEach((el) => el.classList.add('visible'));
}
// MD5, for the avalanche demo. crypto.subtle dropped it long ago, so a plain
// implementation (verified against md5sum). Returns 16 bytes.
function md5(bytes) {
const K = new Uint32Array(64);
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32);
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
const n = bytes.length;
const total = (((n + 8) >> 6) + 1) << 6;
const buf = new Uint8Array(total);
buf.set(bytes);
buf[n] = 0x80;
const dv = new DataView(buf.buffer);
dv.setUint32(total - 8, (n * 8) >>> 0, true);
dv.setUint32(total - 4, Math.floor(n / 536870912), true);
let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
const M = new Uint32Array(16);
for (let off = 0; off < total; off += 64) {
for (let j = 0; j < 16; j++) M[j] = dv.getUint32(off + j * 4, true);
let A = a0, B = b0, C = c0, D = d0;
for (let i = 0; i < 64; i++) {
let F, g;
if (i < 16) { F = (B & C) | (~B & D); g = i; }
else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16; }
else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16; }
else { F = C ^ (B | ~D); g = (7 * i) % 16; }
F = (F + A + K[i] + M[g]) | 0;
A = D; D = C; C = B;
const s = S[((i >> 4) << 2) | (i & 3)];
B = (B + ((F << s) | (F >>> (32 - s)))) | 0;
}
a0 = (a0 + A) | 0; b0 = (b0 + B) | 0; c0 = (c0 + C) | 0; d0 = (d0 + D) | 0;
}
const out = new Uint8Array(16);
const odv = new DataView(out.buffer);
odv.setUint32(0, a0 >>> 0, true);
odv.setUint32(4, b0 >>> 0, true);
odv.setUint32(8, c0 >>> 0, true);
odv.setUint32(12, d0 >>> 0, true);
return out;
}
// Comparison slider: two stacked images, a draggable vertical divider.
function makeCompare(container, { pixelated = false } = {}) {
const a = document.createElement('img');
const b = document.createElement('img');
a.className = 'compare-a';
b.className = 'compare-b';
if (pixelated) b.classList.add('pixelated');
const labelA = document.createElement('span');
const labelB = document.createElement('span');
labelA.className = 'compare-label a';
labelB.className = 'compare-label b';
labelA.innerText = container.dataset.labelA || '';
labelB.innerText = container.dataset.labelB || '';
const divider = document.createElement('div');
divider.className = 'compare-divider';
const handle = document.createElement('div');
handle.className = 'compare-handle';
handle.innerText = '↔';
divider.appendChild(handle);
container.append(a, b, labelA, labelB, divider);
let pos = 50;
let sweeping = null;
function apply() {
b.style.clipPath = `inset(0 0 0 ${pos}%)`;
divider.style.left = `${pos}%`;
}
apply();
function positionFromEvent(e) {
const rect = container.getBoundingClientRect();
return Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
}
container.addEventListener('pointerdown', (e) => {
cancelAnimationFrame(sweeping);
container.setPointerCapture(e.pointerId);
pos = positionFromEvent(e);
apply();
});
container.addEventListener('pointermove', (e) => {
if (!container.hasPointerCapture(e.pointerId)) return;
pos = positionFromEvent(e);
apply();
});
// One slow reveal sweep so it is obvious the thing moves
function sweep() {
cancelAnimationFrame(sweeping);
const start = performance.now();
const duration = 1200;
function tick(now) {
const t = Math.min(1, (now - start) / duration);
const ease = 1 - Math.pow(1 - t, 3);
pos = 2 + ease * 48;
apply();
if (t < 1) sweeping = requestAnimationFrame(tick);
}
sweeping = requestAnimationFrame(tick);
}
// The reveal sweep only plays when the base image changes, not on
// every update of the overlaid result.
let lastA = null;
return {
set(aUrl, bUrl) {
a.src = aUrl;
b.src = bUrl;
if (aUrl !== lastA) sweep();
lastA = aUrl;
},
};
}
// DCT helpers shared by the reconstruction demos
// Full 64-entry zigzag, matching the order in dct.rs (which stops at 36)
const FULL_ZIGZAG = (() => {
const order = [];
for (let s = 0; s < 15; s++) {
const diagonal = [];
for (let u = 0; u <= s; u++) {
const v = s - u;
if (u < 8 && v < 8) diagonal.push(v * 8 + u);
}
if (s % 2 === 1) diagonal.reverse();
order.push(...diagonal);
}
return order;
})();
const ZIGZAG = FULL_ZIGZAG.slice(0, 36);
// Inverse of the transform in dct.rs: orthonormal scaling, +128 recentering
function idct(values) {
const out = new Array(64);
for (let k = 0; k < 64; k++) {
const x = k % 8;
const y = Math.floor(k / 8);
let sum = 0;
for (let u = 0; u < 8; u++) {
for (let v = 0; v < 8; v++) {
let alpha = 1;
if (u === 0) alpha /= Math.SQRT2;
if (v === 0) alpha /= Math.SQRT2;
sum += alpha * values[v * 8 + u] *
Math.cos(((2 * x + 1) * u * Math.PI) / 16) *
Math.cos(((2 * y + 1) * v * Math.PI) / 16);
}
}
out[k] = Math.max(0, Math.min(255, Math.round(128 + 0.25 * sum)));
}
return out;
}
function drawPixels(canvas, pixels) {
const ctx = canvas.getContext('2d');
const data = ctx.createImageData(8, 8);
for (let i = 0; i < 64; i++) {
data.data[i * 4] = pixels[i];
data.data[i * 4 + 1] = pixels[i];
data.data[i * 4 + 2] = pixels[i];
data.data[i * 4 + 3] = 255;
}
ctx.putImageData(data, 0, 0);
}
// Demo state
let baseBuffer = null; // ArrayBuffer of the selected image
let baseUrl = null;
let baseHashes = null; // [dct, median, phash] as BigInt
let baseCoefficients = null; // 64 DCT coefficients of the 8x8
let loadToken = 0;
// Image picker and pipeline
const imageContainers = document.getElementsByClassName('image-original');
const formImage = document.getElementById('dctimage');
const resizeCompare = makeCompare(document.getElementById('resize-compare'), { pixelated: true });
const mutateCompare = makeCompare(document.getElementById('mutate-compare'));
function reset() {
for (const container of imageContainers) {
container.replaceChildren();
}
for (const name of ['dct', 'median', 'phash']) {
document.getElementById(`flip-b-${name}`).replaceChildren();
document.getElementById(`flip-d-${name}`).innerText = '';
}
document.getElementById('flip-image').classList.remove('flipped');
flipHashes = null;
}
async function loadImage(buffer, url) {
const token = ++loadToken;
reset();
baseBuffer = buffer;
baseUrl = url;
for (const container of imageContainers) {
const img = document.createElement('img');
img.src = url;
container.appendChild(img);
}
document.getElementById('flip-image').src = url;
const data = await rpc('pipeline', { buffer });
if (token !== loadToken) return;
baseHashes = data.hashes;
baseCoefficients = data.coefficients;
const [dct, median, phash] = data.hashes;
// resize step
const resize8Url = pngUrl(data.resize8);
resizeCompare.set(url, resize8Url);
// small 8x8 reference thumbs sprinkled through the sections
for (const id of ['median-input', 'dct-input', 'recon-original', 'ghost-original']) {
document.getElementById(id).replaceChildren(pixelatedImg(resize8Url));
}
// median
bitGrid(document.getElementById('median-bits'), median);
document.getElementById('median-hash').innerText = hex64(median);
// dct
heatGrid(document.getElementById('image-dct'), data.coefficients);
bitGrid(document.getElementById('dct-sign-bits'), data.masks[0], 36);
bitGrid(document.getElementById('dct-ordinal-bits'), data.masks[1], 28);
document.getElementById('dct-hash').innerText = hex64(dct);
heatGrid(document.getElementById('recon-grid'), data.coefficients);
renderReconstruction();
renderGhost(data.masks);
// phash
const small = document.createElement('img');
small.src = pngUrl(data.resize32);
small.className = 'pixelated med';
document.getElementById('phash-resize').replaceChildren(small);
heatGrid(document.getElementById('phash-lowfreq'), data.lowfreq);
bitGrid(document.getElementById('phash-bits'), phash);
document.getElementById('phash-hash').innerText = hex64(phash);
// flip demo, original column
const names = ['dct', 'median', 'phash'];
names.forEach((name, i) => bitGrid(document.getElementById(`flip-a-${name}`), data.hashes[i]));
show();
runMd5Demo(buffer, token);
runLshDemo(buffer, token);
runRetrieval(buffer, token);
resetMutation();
runMutation();
}
formImage.addEventListener('change', () => {
const file = formImage.files[0];
if (!file) return;
file.arrayBuffer().then((buffer) => loadImage(buffer, URL.createObjectURL(file)));
});
// Sample images. The first one doubles as the preselected default.
const samples = [
'img/spook.png',
'img/im21050.jpg',
'img/im21644.jpg',
'img/im21068.jpg',
'img/im21123.jpg',
'img/im21694.jpg',
'img/moon1.jpg',
'img/moon2.jpg',
'img/sunflower1.jpg',
'img/sunflower2.jpg',
];
const sampleContainer = document.getElementById('sample-images');
for (const src of samples) {
// buttons are created up front so the strip keeps a deterministic order
const button = document.createElement('img');
button.className = 'sample';
button.title = src.split('/').pop();
sampleContainer.appendChild(button);
fetch(src)
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status)))
.then((buffer) => {
button.src = src;
button.addEventListener('click', () => loadImage(buffer, src));
if (src === samples[0] && baseBuffer === null) loadImage(buffer, src);
})
.catch(() => button.remove());
}
// MD5 avalanche demo: the same PNG twice, one pixel one unit apart.
// Also quietly hashes both files perceptually for the payoff in the mutation section.
async function runMd5Demo(buffer, token) {
const bitmap = await createImageBitmap(new Blob([buffer]));
const scale = Math.min(1, 1024 / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const toPng = () =>
new Promise((resolve) => canvas.toBlob((blob) => resolve(blob.arrayBuffer()), 'image/png'));
const pngA = await toPng();
const data = ctx.getImageData(0, 0, width, height);
const center = (Math.floor(height / 2) * width + Math.floor(width / 2)) * 4;
data.data[center] = data.data[center] === 255 ? 254 : data.data[center] + 1;
ctx.putImageData(data, 0, 0);
const pngB = await toPng();
const digest = (bytes) => new DataView(md5(new Uint8Array(bytes)).buffer).getBigUint64(0);
const md5A = digest(pngA);
const md5B = digest(pngB);
const [hashesA, hashesB] = await Promise.all([
rpc('hashes', { buffer: pngA }),
rpc('hashes', { buffer: pngB }),
]);
if (token !== loadToken) return;
const diff = md5A ^ md5B;
bitGrid(document.getElementById('md5-bits-a'), md5A);
bitGrid(document.getElementById('md5-bits-b'), md5B, 64, diff);
document.getElementById('md5-hex-a').innerText = hex64(md5A) + '…';
document.getElementById('md5-hex-b').innerText = hex64(md5B) + '…';
document.getElementById('md5-diff').innerText = `${hamming(md5A, md5B)} of 64 bits changed`;
// the payoff, delivered where the perceptual hashes have been introduced.
// The target span lives in prose that is sometimes commented out.
const payoff = document.getElementById('onepixel');
if (payoff) {
const distances = [0, 1, 2].map((i) => hamming(hashesA.hashes[i], hashesB.hashes[i]));
payoff.innerText =
`The three hashes built on this page put them at distance ` +
`${distances[0]}, ${distances[1]} and ${distances[2]}.`;
}
}
// LSH demo: the hash we are about to build, as a black box.
// Near copy agrees on almost all bits, a stranger on about half.
const strangerCache = new Map();
async function runLshDemo(buffer, token) {
// pick a stranger that cannot be the current sample
const stranger = baseUrl === 'img/im21694.jpg' ? 'img/sunflower1.jpg' : 'img/im21694.jpg';
if (!strangerCache.has(stranger)) {
const response = await fetch(stranger);
strangerCache.set(stranger, await response.arrayBuffer());
}
const [hue, other] = await Promise.all([
rpc('mutate', { buffer, kind: 'hue', amount: 90 }),
rpc('hashes', { buffer: strangerCache.get(stranger) }),
]);
if (token !== loadToken) return;
document.getElementById('lsh-img-a').src = baseUrl;
document.getElementById('lsh-img-b').src = pngUrl(hue.png);
document.getElementById('lsh-img-c').src = stranger;
const base = baseHashes[0];
bitGrid(document.getElementById('lsh-bits-a'), base);
bitGrid(document.getElementById('lsh-bits-b'), hue.hashes[0], 64, base ^ hue.hashes[0]);
bitGrid(document.getElementById('lsh-bits-c'), other.hashes[0], 64, base ^ other.hashes[0]);
for (const [id, hash] of [['lsh-d-b', hue.hashes[0]], ['lsh-d-c', other.hashes[0]]]) {
const distance = hamming(base, hash);
const cell = document.getElementById(id);
cell.innerText = distance;
cell.className = distance <= 4 ? 'match' : 'nomatch';
}
}
// The 64 DCT basis patterns, rendered once
(function basisGrid() {
const canvas = document.getElementById('dct-basis');
const ctx = canvas.getContext('2d');
const block = 27;
const cell = 3;
function channel(css, fallback) {
const value = css.match(/#([0-9a-f]{6})/i);
if (!value) return fallback;
const n = parseInt(value[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const bg = channel(cssColor('--background'), [16, 16, 24]);
const fg = channel(cssColor('--foreground'), [225, 225, 218]);
for (let u = 0; u < 8; u++) {
for (let v = 0; v < 8; v++) {
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 8; y++) {
const value =
Math.cos(((2 * x + 1) * u * Math.PI) / 16) *
Math.cos(((2 * y + 1) * v * Math.PI) / 16);
const t = (value + 1) / 2;
const rgb = bg.map((b, i) => Math.round(b + (fg[i] - b) * t));
ctx.fillStyle = `rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`;
ctx.fillRect(u * block + x * cell, v * block + y * cell, cell, cell);
}
}
}
}
})();
// Hovering a basis pattern fills its (u,v) into the transform formula,
// together with the coefficient it produces for the current image.
(function basisHover() {
const wrap = document.getElementById('dct-basis-wrap');
const canvas = document.getElementById('dct-basis');
const formula = document.getElementById('dct-formula');
const us = formula.querySelectorAll('.var-u');
const vs = formula.querySelectorAll('.var-v');
const value = formula.querySelector('.formula-value');
const box = document.createElement('div');
box.className = 'basis-hover';
wrap.appendChild(box);
let markedCell = null;
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const u = Math.min(7, Math.floor(((e.clientX - rect.left) / rect.width) * 8));
const v = Math.min(7, Math.floor(((e.clientY - rect.top) / rect.height) * 8));
const block = rect.width / 8;
box.style.display = 'block';
box.style.left = `${u * block}px`;
box.style.top = `${v * block}px`;
box.style.width = `${block}px`;
box.style.height = `${block}px`;
us.forEach((n) => (n.textContent = u));
vs.forEach((n) => (n.textContent = v));
if (baseCoefficients) value.textContent = baseCoefficients[v * 8 + u].toFixed(1);
formula.classList.add('live');
// mark the matching cell in the coefficient grid
markedCell?.classList.remove('hover-cell');
markedCell = document.getElementById('image-dct').children[v * 8 + u] || null;
markedCell?.classList.add('hover-cell');
});
canvas.addEventListener('mouseleave', () => {
box.style.display = 'none';
us.forEach((n) => (n.textContent = 'u'));
vs.forEach((n) => (n.textContent = 'v'));
formula.classList.remove('live');
markedCell?.classList.remove('hover-cell');
markedCell = null;
});
})();
// Zigzag trace over the coefficient grid
(function zigzagTrace() {
const svg = document.getElementById('dct-zigzag');
const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
const points = ZIGZAG.map((i) => `${(i % 8) * 32 + 16},${Math.floor(i / 8) * 32 + 16}`);
line.setAttribute('points', points.join(' '));
line.setAttribute('fill', 'none');
svg.appendChild(line);
const length = line.getTotalLength() || 1200;
line.style.strokeDasharray = length;
line.style.strokeDashoffset = length;
function play() {
line.classList.remove('play');
line.style.strokeDashoffset = length;
void line.getBoundingClientRect();
line.classList.add('play');
line.style.strokeDashoffset = 0;
}
document.getElementById('zigzag-replay').addEventListener('click', play);
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
play();
observer.disconnect();
}
});
observer.observe(svg);
})();
// Progressive reconstruction: sum the patterns back up in zigzag order.
// A copy of the coefficient grid traces the zigzag along, so image space
// and frequency space fill up together.
const reconSlider = document.getElementById('recon-k');
const reconCanvas = document.getElementById('recon-canvas');
// Two polylines over the recon grid: the 36 coefficients the hash reads,
// and the tail it ignores.
const reconZigzag = (() => {
const svg = document.getElementById('recon-zigzag');
const cell = 176 / 8;
const points = FULL_ZIGZAG.map((i) => [(i % 8) * cell + cell / 2, Math.floor(i / 8) * cell + cell / 2]);
const cumulative = [0];
for (let i = 1; i < 64; i++) {
cumulative.push(cumulative[i - 1] + Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]));
}
function polyline(pts, className) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
line.setAttribute('points', pts.map(([x, y]) => `${x},${y}`).join(' '));
line.setAttribute('fill', 'none');
line.setAttribute('class', className);
svg.appendChild(line);
return line;
}
const head = polyline(points.slice(0, 36), 'zig-head');
const tail = polyline(points.slice(35), 'zig-tail');
const headLength = cumulative[35];
const tailLength = cumulative[63] - cumulative[35];
head.style.strokeDasharray = headLength;
tail.style.strokeDasharray = tailLength;
return function update(k) {
const drawn = cumulative[k - 1];
head.style.strokeDashoffset = headLength - Math.min(drawn, headLength);
tail.style.strokeDashoffset = tailLength - Math.max(0, drawn - headLength);
};
})();
function renderReconstruction() {
if (!baseCoefficients) return;
const k = Number(reconSlider.value);
document.getElementById('recon-count').innerText = k;
const partial = new Array(64).fill(0);
for (let i = 0; i < k; i++) {
partial[FULL_ZIGZAG[i]] = baseCoefficients[FULL_ZIGZAG[i]];
}
drawPixels(reconCanvas, idct(partial));
const grid = document.getElementById('recon-grid');
for (let i = 0; i < 64; i++) {
const cell = grid.children[FULL_ZIGZAG[i]];
if (cell) cell.style.opacity = i < k ? 1 : 0.15;
}
reconZigzag(k);
}
let reconPlayToken = 0;
reconSlider.addEventListener('input', () => {
reconPlayToken++;
renderReconstruction();
});
// Slow pass over the 36 coefficients the hash uses, a beat at the cut,
// then the leftovers: they barely change the picture.
document.getElementById('recon-play').addEventListener('click', async () => {
const token = ++reconPlayToken;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let k = 1; k <= 64; k++) {
if (token !== reconPlayToken) return;
reconSlider.value = k;
renderReconstruction();
if (k === 36) await sleep(900);
else await sleep(k < 36 ? 150 : 90);
}
});
// The ghost: reconstruction from the 64 hash bits alone.
// Signs come from the sign mask; magnitudes are guessed by walking the
// ordinal chain (each bit says bigger/smaller than the previous, so the
// walk gives every coefficient a relative rank).
function renderGhost(masks) {
const [signMask, ordinalMask] = masks;
const ranks = [0];
for (let k = 0; k < 28; k++) {
const bigger = (ordinalMask >> BigInt(27 - k)) & 1n;
ranks.push(ranks[k] + (bigger ? 1 : -1));
}
const floor = Math.min(...ranks) - 1;
const coefficients = new Array(64).fill(0);
for (let k = 0; k < 36; k++) {
const negative = (signMask >> BigInt(35 - k)) & 1n;
const rank = k < ranks.length ? ranks[k] : floor;
const magnitude = Math.min(400, 24 * Math.pow(1.35, rank));
coefficients[ZIGZAG[k]] = (negative ? -1 : 1) * magnitude;
}
drawPixels(document.getElementById('ghost-canvas'), idct(coefficients));
}
document.getElementById('ghost-flip').addEventListener('click', () => {
document.getElementById('ghost-canvas').classList.toggle('flipped');
});
// Mutation demo
const mutators = {
flip: { label: 'Horizontal flip' },
jpeg: { label: 'JPEG quality', min: 1, max: 100, step: 1, value: 50 },
blur: { label: 'Gaussian blur', min: 0, max: 10, step: 0.1, value: 1.5 },
sharpen: { label: 'Unsharp mask', min: 0, max: 10, step: 0.1, value: 1.5 },
scale: { label: 'Rescale %', min: 5, max: 100, step: 1, value: 50 },
crop: { label: 'Crop, keep %', min: 50, max: 100, step: 1, value: 90 },
rotate: { label: 'Rotate deg', min: -45, max: 45, step: 0.5, value: 2 },
hue: { label: 'Hue shift deg', min: 0, max: 180, step: 1, value: 30 },
brightness: { label: 'Brightness', min: -100, max: 100, step: 1, value: 30 },
contrast: { label: 'Contrast', min: -100, max: 100, step: 1, value: 25 },
letterbox: { label: 'Letterbox bar %', min: 1, max: 40, step: 1, value: 10 },
logo: { label: 'Logo size %', min: 1, max: 60, step: 1, value: 10 },
noise: { label: 'Gaussian noise', min: 0, max: 50, step: 1, value: 10 },
};
const kindSelect = document.getElementById('mutator-kind');
const amountSlider = document.getElementById('mutator-amount');
const amountValue = document.getElementById('mutator-amount-value');
for (const [kind, config] of Object.entries(mutators)) {
const option = document.createElement('option');
option.value = kind;
option.innerText = config.label;
kindSelect.appendChild(option);
}
function resetMutation() {
const config = mutators[kindSelect.value];
if (config.min === undefined) {
amountSlider.disabled = true;
amountValue.innerText = '';
} else {
amountSlider.disabled = false;
amountSlider.min = config.min;
amountSlider.max = config.max;
amountSlider.step = config.step;
amountSlider.value = config.value;
amountValue.innerText = config.value;
}
}
let mutationTimer = null;
let mutationToken = 0;
async function runMutation() {
if (!baseBuffer) return;
const token = ++mutationToken;
const kind = kindSelect.value;
const amount = amountSlider.disabled ? 0 : Number(amountSlider.value);
amountValue.innerText = amountSlider.disabled ? '' : amount;
const busy = [document.getElementById('mutate-compare'), document.querySelector('.distances')];
busy.forEach((el) => el.classList.add('busy'));
const data = await rpc('mutate', { buffer: baseBuffer, kind, amount });
if (token !== mutationToken || !baseHashes) return;
busy.forEach((el) => el.classList.remove('busy'));
mutateCompare.set(baseUrl, pngUrl(data.png));
const names = ['dct', 'median', 'phash'];
names.forEach((name, i) => {
const distance = hamming(baseHashes[i], data.hashes[i]);
const cell = document.getElementById(`dist-${name}`);
cell.innerText = distance;
cell.className = distance <= 4 ? 'match' : 'nomatch';
});
}
function scheduleMutation() {
clearTimeout(mutationTimer);
mutationTimer = setTimeout(runMutation, 120);
}
kindSelect.addEventListener('change', () => {
resetMutation();
scheduleMutation();
});
amountSlider.addEventListener('input', scheduleMutation);
resetMutation();
// Flip invariance demo
let flipHashes = null;
document.getElementById('flip-button').addEventListener('click', async () => {
if (!baseBuffer) return;
document.getElementById('flip-image').classList.toggle('flipped');
if (flipHashes) return;
const token = loadToken;
const data = await rpc('mutate', { buffer: baseBuffer, kind: 'flip', amount: 0 });
if (token !== loadToken) return;
flipHashes = data.hashes;
const names = ['dct', 'median', 'phash'];
names.forEach((name, i) => {
const diff = baseHashes[i] ^ flipHashes[i];
bitGrid(document.getElementById(`flip-b-${name}`), flipHashes[i], 64, diff);
const distance = hamming(baseHashes[i], flipHashes[i]);
const cell = document.getElementById(`flip-d-${name}`);
cell.innerText = distance;
cell.className = 'flip-distance ' + (distance <= 4 ? 'match' : 'nomatch');
});
});
// Ranking demo over the precomputed MIRFLICKR pool
const rankingMutations = [
{ key: 'none', label: 'no mutation' },
{ key: 'flip', label: 'horizontal flip', amount: 0 },
{ key: 'jpeg', label: 'JPEG quality 30', amount: 30 },
{ key: 'blur', label: 'Gaussian blur 3', amount: 3 },
{ key: 'hue', label: 'hue shift 60', amount: 60 },
{ key: 'scale', label: 'rescale to 25%', amount: 25 },
{ key: 'crop', label: 'crop to 80%', amount: 80 },
{ key: 'logo', label: 'logo insert 15%', amount: 15 },
{ key: 'noise', label: 'Gaussian noise 20', amount: 20 },
];
const pool = []; // {name, url, hashes: [dct, median, phash]}
const poolContainer = document.getElementById('ranking-pool');
const rankingMutationSelect = document.getElementById('ranking-mutation');
const queryContainer = document.getElementById('ranking-query');
const bufferCache = new Map();
let currentQuery = null; // {name, url, hashes, buffer?}
let rankToken = 0;
for (const { key, label } of rankingMutations) {
const option = document.createElement('option');
option.value = key;
option.innerText = label;
rankingMutationSelect.appendChild(option);
}
const poolReady = fetch('flickr/hashes.json')
.then((response) => response.json())
.then((entries) => {
const fragment = document.createDocumentFragment();
for (const [name, dct, median, phash] of entries) {
const url = `flickr/thumbs/${name}`;
const index = pool.length;
pool.push({
name,
url,
hashes: [BigInt('0x' + dct), BigInt('0x' + median), BigInt('0x' + phash)],
});
const img = document.createElement('img');
img.src = url;
img.loading = 'lazy';
img.decoding = 'async';
img.title = name;
img.dataset.index = index;
fragment.appendChild(img);
}
poolContainer.appendChild(fragment);
return pool;
})
.catch(() => null);
poolContainer.addEventListener('click', (e) => {
const index = e.target.dataset?.index;
if (index === undefined) return;
poolContainer.querySelector('.query')?.classList.remove('query');
e.target.classList.add('query');
currentQuery = pool[Number(index)];
runRanking();
});
document.getElementById('ranking-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const buffer = await file.arrayBuffer();
const { hashes } = await rpc('hashes', { buffer });
poolContainer.querySelector('.query')?.classList.remove('query');
currentQuery = { name: file.name, url: URL.createObjectURL(file), hashes, buffer };
runRanking();
});
rankingMutationSelect.addEventListener('change', runRanking);
async function queryBuffer() {
if (currentQuery.buffer) return currentQuery.buffer;
if (!bufferCache.has(currentQuery.url)) {
const response = await fetch(currentQuery.url);
bufferCache.set(currentQuery.url, await response.arrayBuffer());
}
return bufferCache.get(currentQuery.url);
}
function thumbFigure(url, caption, hit = false) {
const figure = document.createElement('figure');
const img = document.createElement('img');
img.src = url;
img.loading = 'lazy';
const figcaption = document.createElement('figcaption');
figcaption.innerText = caption;
if (hit) figure.className = 'hit';
figure.append(img, figcaption);
return figure;
}
async function runRanking() {
if (!currentQuery) return;
const token = ++rankToken;
const mutation = rankingMutations.find((m) => m.key === rankingMutationSelect.value);
let queryHashes = currentQuery.hashes;
let mutatedUrl = null;
if (mutation.key !== 'none') {
const buffer = await queryBuffer();
const data = await rpc('mutate', { buffer, kind: mutation.key, amount: mutation.amount });
if (token !== rankToken) return;
queryHashes = data.hashes;
mutatedUrl = pngUrl(data.png);
}
queryContainer.replaceChildren(thumbFigure(currentQuery.url, currentQuery.name));
if (mutatedUrl) queryContainer.appendChild(thumbFigure(mutatedUrl, mutation.label));
['dct', 'median', 'phash'].forEach((name, algo) => {
const ranked = pool
.map((entry) => ({ entry, distance: hamming(queryHashes[algo], entry.hashes[algo]) }))
.sort((a, b) => a.distance - b.distance)
.slice(0, 10);
const container = document.getElementById(`results-${name}`);
container.replaceChildren();
for (const { entry, distance } of ranked) {
const caption = `${distance} · ${entry.name.replace('.jpg', '')}`;
container.appendChild(thumbFigure(entry.url, caption, entry.name === currentQuery.name));
}
});
}
// Precision-recall charts, rendered from the run data.
//
// 16 series cannot get 16 distinguishable colors, least of all from a pywal
// palette that changes with the wallpaper. So color encodes the mutation
// *category* (fixed assignment) and a per-category dash pattern is the
// secondary encoding; individual mutators are identified by the legend,
// hover isolation and the tooltip, never by color alone.
(async function prCharts() {
let data;
try {
data = await (await fetch('pr-data.json')).json();
} catch {
return;
}
const CATEGORIES = [
{ name: 'recoding & resampling', color: '--color2', dash: '', muts: ['JPEG quality 90', 'JPEG quality 50', 'JPEG quality 20', 'Rescale 50%'] },
{ name: 'content processing', color: '--color4', dash: '7 4', muts: ['Hue shift 30', 'Gaussian blur 1.5', 'Unsharp mask', 'Contrast +25', 'Brightness +30', 'Gaussian noise 10'] },
{ name: 'framing', color: '--color3', dash: '2 4', muts: ['Crop to 90%', 'Crop to 70%', 'Letterbox 10%'] },
{ name: 'insertion', color: '--color1', dash: '11 4 2 4', muts: ['Logo insert 10%'] },
{ name: 'flip & rotation', color: '--foreground', dash: '15 5', muts: ['Horizontal flip', 'Rotate 2'] },
];
const styleOf = {};
for (const category of CATEGORIES) {
for (const mut of category.muts) styleOf[mut] = category;
}
const ALGOS = ['dct', 'phash', 'median'];
const SIZE = 330;
const MARGIN = { left: 42, right: 12, top: 10, bottom: 38 };
const px = (r) => MARGIN.left + r * (SIZE - MARGIN.left - MARGIN.right);
const py = (p) => SIZE - MARGIN.bottom - p * (SIZE - MARGIN.top - MARGIN.bottom);
const NS = 'http://www.w3.org/2000/svg';
const tooltip = document.getElementById('pr-tooltip');
const tSlider = document.getElementById('pr-t');
const series = []; // {algo, mut, line, dot, points}
function el(name, attrs, parent) {
const node = document.createElementNS(NS, name);
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
if (parent) parent.appendChild(node);
return node;
}
for (const algo of ALGOS) {
const svg = el('svg', { viewBox: `0 0 ${SIZE} ${SIZE}`, class: 'pr-svg' });
document.getElementById(`pr-chart-${algo}`).appendChild(svg);
// recessive grid and axes, text in text tokens
for (const tick of [0, 0.25, 0.5, 0.75, 1]) {
el('line', { x1: px(tick), y1: py(0), x2: px(tick), y2: py(1), class: 'pr-grid' }, svg);
el('line', { x1: px(0), y1: py(tick), x2: px(1), y2: py(tick), class: 'pr-grid' }, svg);
const xLabel = el('text', { x: px(tick), y: py(0) + 14, class: 'pr-tick', 'text-anchor': 'middle' }, svg);
xLabel.textContent = tick;
const yLabel = el('text', { x: px(0) - 5, y: py(tick) + 3, class: 'pr-tick', 'text-anchor': 'end' }, svg);
yLabel.textContent = tick;
}
const xTitle = el('text', { x: px(0.5), y: SIZE - 8, class: 'pr-axis', 'text-anchor': 'middle' }, svg);
xTitle.textContent = 'recall';
const yTitle = el('text', {
x: 12, y: py(0.5), class: 'pr-axis', 'text-anchor': 'middle',
transform: `rotate(-90 12 ${py(0.5)})`,
}, svg);
yTitle.textContent = 'precision';
for (const [mut, points] of Object.entries(data[algo])) {
const category = styleOf[mut];
if (!category) continue;
const color = cssColor(category.color);
const line = el('polyline', {
points: points.map(([r, p]) => `${px(r)},${py(p)}`).join(' '),
class: 'pr-line',
stroke: color,
'stroke-dasharray': category.dash,
}, svg);
const dot = el('circle', { r: 4, class: 'pr-dot', fill: color }, svg);
series.push({ algo, mut, line, dot, points });
}
svg.addEventListener('mousemove', (event) => {
const rect = svg.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * SIZE;
const y = ((event.clientY - rect.top) / rect.height) * SIZE;
let best = null;
for (const s of series) {
if (s.algo !== algo || s.line.classList.contains('faded')) continue;
s.points.forEach(([r, p], t) => {
const distance = Math.hypot(px(r) - x, py(p) - y);
if (distance < 14 && (!best || distance < best.distance)) {
best = { distance, mut: s.mut, t, r, p };
}
});
}
if (best) {
tooltip.hidden = false;
tooltip.innerText =
`${best.mut} · t=${best.t} · ` +
`recall ${(best.r * 100).toFixed(1)}% · precision ${(best.p * 100).toFixed(1)}%`;
tooltip.style.left = `${event.clientX + 14}px`;
tooltip.style.top = `${event.clientY + 14}px`;
} else {
tooltip.hidden = true;
}
});
svg.addEventListener('mouseleave', () => {
tooltip.hidden = true;
});
}
// legend: grouped by category, hover isolates, click pins
const legend = document.getElementById('pr-legend');
const pinned = new Set();
let hovered = null;
const chips = new Map();
function applyHighlight() {
const active = pinned.size ? pinned : hovered ? new Set([hovered]) : null;
for (const s of series) {
s.line.classList.toggle('faded', Boolean(active && !active.has(s.mut)));
s.dot.classList.toggle('faded', Boolean(active && !active.has(s.mut)));
}
for (const [mut, chip] of chips) {
chip.classList.toggle('dim', Boolean(active && !active.has(mut)));
chip.classList.toggle('pinned', pinned.has(mut));
}
}
for (const category of CATEGORIES) {
const group = document.createElement('span');
group.className = 'pr-group';
for (const mut of category.muts) {
const chip = document.createElement('button');
chip.className = 'pr-chip';
chip.type = 'button';
const swatch = el('svg', { width: 26, height: 10, class: 'pr-swatch' });
el('line', {
x1: 1, y1: 5, x2: 25, y2: 5,
stroke: cssColor(category.color),
'stroke-width': 2,
'stroke-dasharray': category.dash,
}, swatch);
const label = document.createElement('span');
label.innerText = mut;
chip.append(swatch, label);
chip.addEventListener('mouseenter', () => {
hovered = mut;
applyHighlight();
});
chip.addEventListener('mouseleave', () => {
hovered = null;
applyHighlight();
});
chip.addEventListener('click', () => {
if (pinned.has(mut)) pinned.delete(mut);
else pinned.add(mut);
applyHighlight();
});
chips.set(mut, chip);
group.appendChild(chip);
}
legend.appendChild(group);
}
function placeDots() {
const t = Number(tSlider.value);
document.getElementById('pr-t-value').innerText = t;
for (const s of series) {
const [r, p] = s.points[Math.min(t, s.points.length - 1)];
s.dot.setAttribute('cx', px(r));
s.dot.setAttribute('cy', py(p));
}
}
tSlider.addEventListener('input', placeDots);
placeDots();
})();
// Retrieval demos: threshold histogram, toy BK-tree, and the full index.
// The trees themselves live in wasm memory inside the worker; this side
// only draws structures and traces it gets back.
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgNode(name, attrs, parent) {
const node = document.createElementNS(SVG_NS, name);
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
if (parent) parent.appendChild(node);
return node;
}
function fmtTime(ms) {
return ms < 1 ? `${Math.round(ms * 1000)} µs` : `${ms.toFixed(1)} ms`;
}
// The 425k-hash index is built once, off the critical path
const bigReady = fetch('flickr/keys.bin')
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status)))
.then((buffer) => rpc('big_build', { buffer }))
.then(({ size }) => size)
.catch(() => null);
const retrieval = {
suite: null, // [{hash, label}] of the current image's 16 mutants
labelOf: new Map(), // hex hash -> human name, for toy node tooltips
toy: null, // {byKey, size} of the visualized tree
profile: null, // compared/found counts per radius on the big tree
bigSize: null,
};
const distSlider = document.getElementById('dist-t');
const toySlider = document.getElementById('toy-t');
const bigSlider = document.getElementById('big-t');
async function runRetrieval(buffer, token) {
const data = await rpc('suite', { buffer });
if (token !== loadToken) return;
retrieval.suite = Array.from(data.hashes).map((hash, i) => ({ hash, label: data.labels[i] }));
const poolData = await poolReady;
if (token !== loadToken) return;
if (poolData) renderHistogram();
// Toy tree: 16 spread-out pool strangers go in first (so the root sits
// in stranger country), then the mutants, which cluster in one branch
const strangers = poolData ? poolData.filter((_, i) => i % 70 === 0).slice(0, 16) : [];
retrieval.labelOf = new Map();
for (const entry of strangers) retrieval.labelOf.set(entry.hashes[0].toString(16), entry.name);
for (const { hash, label } of retrieval.suite) {
const hex = hash.toString(16);
const existing = retrieval.labelOf.get(hex);
retrieval.labelOf.set(hex, existing ? `${existing} / ${label}` : label);
}
const keys = [...strangers.map((e) => e.hashes[0]), ...retrieval.suite.map((m) => m.hash)];
const built = await rpc('toy_build', { keys });
if (token !== loadToken) return;
renderToyTree(built.structure, built.size);
runToyQuery(false);
const size = await bigReady;
if (token !== loadToken || size === null) return;
retrieval.bigSize = size;
document.getElementById('big-size').innerText = size.toLocaleString('en');
retrieval.profile = await rpc('big_profile', { key: baseHashes[0], max: Number(bigSlider.max) });
if (token !== loadToken) return;
renderBigCurve();
runBigQuery();
}
// Distance histogram: strangers as bars, copies as dots, threshold as a line
function renderHistogram() {
if (!retrieval.suite || !baseHashes || !pool.length) return;
const t = Number(distSlider.value);
document.getElementById('dist-t-value').innerText = t;
const base = baseHashes[0];
const strangerCounts = new Array(65).fill(0);
for (const entry of pool) strangerCounts[hamming(base, entry.hashes[0])]++;
const copies = retrieval.suite.map((m) => ({ d: hamming(base, m.hash), label: m.label }));
const W = 640, H = 210;
const M = { left: 34, right: 10, top: 16, bottom: 30 };
const step = (W - M.left - M.right) / 65;
const x = (d) => M.left + (d + 0.5) * step;
const maxCount = Math.max(...strangerCounts, 1);
// square root scale, so the lone stranger bars in the overlap zone stay visible
const y = (c) => H - M.bottom - Math.sqrt(c / maxCount) * (H - M.top - M.bottom);
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, class: 'pr-svg dist-svg' });
svgNode('rect', {
x: M.left, y: M.top,
width: Math.max(0, x(t) + step / 2 - M.left),
height: H - M.top - M.bottom,
class: 'dist-region',
}, svg);
for (let d = 0; d <= 64; d += 8) {
const label = svgNode('text', { x: x(d), y: H - M.bottom + 14, class: 'pr-tick', 'text-anchor': 'middle' }, svg);
label.textContent = d;
}
const axis = svgNode('text', { x: x(32), y: H - 2, class: 'pr-axis', 'text-anchor': 'middle' }, svg);
axis.textContent = 'hamming distance';
svgNode('line', { x1: M.left, y1: H - M.bottom, x2: W - M.right, y2: H - M.bottom, class: 'pr-grid' }, svg);
for (let d = 0; d <= 64; d++) {
if (!strangerCounts[d]) continue;
const bar = svgNode('rect', {
x: x(d) - step * 0.4, y: y(strangerCounts[d]),
width: step * 0.8, height: H - M.bottom - y(strangerCounts[d]),
class: 'dist-bar',
}, svg);
svgNode('title', {}, bar).textContent = `${strangerCounts[d]} pool images at distance ${d}`;
}
const stacked = new Map();
for (const copy of copies) {
const k = stacked.get(copy.d) || 0;
stacked.set(copy.d, k + 1);
const dot = svgNode('circle', {
cx: x(copy.d), cy: H - M.bottom - 6 - k * 11, r: 4.5, class: 'dist-copy',
}, svg);
svgNode('title', {}, dot).textContent = `${copy.label}, distance ${copy.d}`;
}
svgNode('line', {
x1: x(t) + step / 2, y1: M.top - 4, x2: x(t) + step / 2, y2: H - M.bottom,
class: 'dist-thresh',
}, svg);
document.getElementById('dist-chart').replaceChildren(svg);
const copiesIn = copies.filter((c) => c.d <= t).length;
const strangersIn = strangerCounts.slice(0, t + 1).reduce((a, b) => a + b, 0);
document.getElementById('dist-readout').innerText =
`catches ${copiesIn} of ${copies.length} copies, lets in ${strangersIn} of ${pool.length} strangers`;
}
distSlider.addEventListener('input', renderHistogram);
// Toy BK-tree: drawn from the wasm tree's own structure dump,
// colored by the visit trace of a real query
function renderToyTree(structure, size) {
const byKey = new Map();
let root = null;
for (let i = 0; i < structure.length; i += 3) {
const [key, parent, distance] = [structure[i], structure[i + 1], structure[i + 2]];
const node = { key, distance: Number(distance), children: [], parent: null, el: {} };
if (root === null) {
root = node;
} else {
node.parent = byKey.get(parent.toString(16));
node.parent.children.push(node);
}
byKey.set(key.toString(16), node);
}
retrieval.toy = { byKey, size };
if (!root) return;
for (const node of byKey.values()) node.children.sort((a, b) => a.distance - b.distance);
let nextLeaf = 0;
let maxDepth = 0;
(function layout(node, depth) {
node.depth = depth;
maxDepth = Math.max(maxDepth, depth);
if (!node.children.length) {
node.slot = nextLeaf++;
return;
}
node.children.forEach((child) => layout(child, depth + 1));
node.slot = (node.children[0].slot + node.children[node.children.length - 1].slot) / 2;
})(root, 0);
const W = 960;
const rowH = 58;
const H = maxDepth * rowH + 60;
const px = (node) => 30 + (node.slot * (W - 60)) / Math.max(nextLeaf - 1, 1);
const py = (node) => 30 + node.depth * rowH;
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, class: 'pr-svg toy-svg' });
// edges under nodes
for (const node of byKey.values()) {
if (!node.parent) continue;
node.el.edge = svgNode('line', {
x1: px(node.parent), y1: py(node.parent), x2: px(node), y2: py(node), class: 'toy-edge',
}, svg);
node.el.edgeLabel = svgNode('text', {
x: (px(node.parent) + px(node)) / 2,
y: (py(node.parent) + py(node)) / 2 - 3,
class: 'toy-edge-label', 'text-anchor': 'middle',
}, svg);
node.el.edgeLabel.textContent = node.distance;
}
for (const node of byKey.values()) {
node.el.circle = svgNode('circle', { cx: px(node), cy: py(node), r: 8, class: 'toy-node' }, svg);
const hex = node.key.toString(16);
svgNode('title', {}, node.el.circle).textContent =
`${retrieval.labelOf.get(hex) || 'pool image'} · ${hex64(node.key)}`;
}
document.getElementById('toy-tree').replaceChildren(svg);
}
function setToyState(node, state) {
node.el.circle?.setAttribute('class', `toy-node ${state}`);
const edgeState = state === 'pruned' ? ' pruned' : '';
node.el.edge?.setAttribute('class', `toy-edge${edgeState}`);
node.el.edgeLabel?.setAttribute('class', `toy-edge-label${edgeState}`);
}
let toyPaintToken = 0;
async function runToyQuery(animate) {
if (!retrieval.toy || !baseHashes) return;
const token = loadToken;
const paintToken = ++toyPaintToken;
const t = Number(toySlider.value);
document.getElementById('toy-t-value').innerText = t;
const { found, visited } = await rpc('toy_query', { key: baseHashes[0], radius: t });
if (token !== loadToken || paintToken !== toyPaintToken) return;
const foundSet = new Set();
for (let i = 0; i < found.length; i += 2) foundSet.add(found[i].toString(16));
document.getElementById('toy-stats').innerText =
`compared ${visited.length} of ${retrieval.toy.size} hashes, found ${found.length / 2}`;
for (const node of retrieval.toy.byKey.values()) setToyState(node, 'pruned');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (const key of visited) {
if (animate) await sleep(140);
if (paintToken !== toyPaintToken) return;
const hex = key.toString(16);
setToyState(retrieval.toy.byKey.get(hex), foundSet.has(hex) ? 'match' : 'seen');
}
}
let toyTimer = null;
toySlider.addEventListener('input', () => {
clearTimeout(toyTimer);
toyTimer = setTimeout(() => runToyQuery(false), 120);
});
document.getElementById('toy-run').addEventListener('click', () => runToyQuery(true));
// The full index: instant numbers from the precomputed profile,
// timings measured in the worker on demand
function renderBigCurve() {
const { compared } = retrieval.profile;
const size = retrieval.bigSize;
const W = 260, H = 150;
const M = { left: 40, right: 10, top: 8, bottom: 26 };
const maxT = compared.length - 1;
const x = (t) => M.left + (t * (W - M.left - M.right)) / maxT;
const y = (frac) => H - M.bottom - frac * (H - M.top - M.bottom);
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, class: 'pr-svg big-curve-svg' });
for (const frac of [0, 0.5, 1]) {
svgNode('line', { x1: M.left, y1: y(frac), x2: W - M.right, y2: y(frac), class: 'pr-grid' }, svg);
const label = svgNode('text', { x: M.left - 4, y: y(frac) + 3, class: 'pr-tick', 'text-anchor': 'end' }, svg);
label.textContent = `${frac * 100}%`;
}
for (const t of [0, 4, 8, 12, 16]) {
if (t > maxT) continue;
const label = svgNode('text', { x: x(t), y: H - M.bottom + 13, class: 'pr-tick', 'text-anchor': 'middle' }, svg);
label.textContent = t;
}
const axis = svgNode('text', { x: x(maxT / 2), y: H - 2, class: 'pr-axis', 'text-anchor': 'middle' }, svg);
axis.textContent = 'radius t';
svgNode('polyline', {
points: compared.map((c, t) => `${x(t)},${y(c / size)}`).join(' '),
class: 'pr-line big-curve-line',
}, svg);
retrieval.curveDot = svgNode('circle', { r: 4, class: 'pr-dot big-curve-dot' }, svg);
retrieval.curveX = x;
retrieval.curveY = y;
document.getElementById('big-curve').replaceChildren(svg);
}
let bigTimer = null;
let bigQueryToken = 0;
async function runBigQuery() {
if (!retrieval.profile || !baseHashes) return;
const t = Number(bigSlider.value);
document.getElementById('big-t-value').innerText = t;
const { compared, found } = retrieval.profile;
const size = retrieval.bigSize;
document.getElementById('big-found').innerText = found[t].toLocaleString('en');
document.getElementById('big-found-note').innerText = `hashes within distance ${t}`;
document.getElementById('big-compared').innerText = compared[t].toLocaleString('en');
const pct = (100 * compared[t]) / size;
document.getElementById('big-times').innerText =
`${pct < 10 ? pct.toFixed(1) : Math.round(pct)}% of the tree`;
retrieval.curveDot?.setAttribute('cx', retrieval.curveX(t));
retrieval.curveDot?.setAttribute('cy', retrieval.curveY(compared[t] / size));
const token = ++bigQueryToken;
const outer = loadToken;
const data = await rpc('big_query', { key: baseHashes[0], radius: t });
if (token !== bigQueryToken || outer !== loadToken) return;
document.getElementById('big-times').innerText =
`${pct < 10 ? pct.toFixed(1) : Math.round(pct)}% of the tree · tree ${fmtTime(data.treeMs)}, scan ${fmtTime(data.scanMs)}`;
}
bigSlider.addEventListener('input', () => {
clearTimeout(bigTimer);
bigTimer = setTimeout(runBigQuery, 120);
});