// 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); });