// QR Scan Difficulty Check: every measured code on one scale, and your own pictures on it. // The bell, the slider and the example codes are QR Scan Difficulty's own (its app.js), on its data (data/bell.json). const SVGNS = 'http://www.w3.org/2000/svg'; const PILE_LO = -6; // codes every scanner read at every resolution const PILE_HI = 106; // codes no scanner read at any resolution const X_MIN = -12; const X_MAX = 112; const BASE_FRAME = 1280; // a typical phone camera image; the count behind every shade const APPLE = 'apple-vision'; const GOOGLE = 'google'; const HUAWEI = 'hms-scankit'; const HERE = ['zxing-java', 'wechat', 'zxing-cpp', 'opencv-aruco', 'opencv']; // the five this server runs, page order const MAX_PICTURES = 40; const MAX_BYTES = 25 * 1024 * 1024; const MAX_CANVAS = 16e6; // pixels: a picture this browser converts is drawn at most this big (every browser can) const ADD_NOTE = 'Read by five of the eight scanners. Nothing is kept.'; const WORDS = ['none', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; const WORLD = { pristine: 0.2, everyday: 0.5, tough: 0.2, dataset: 0.1 }; // the curve's illustrative mix const SPOKEN = { // scanner names inside sentences 'apple-vision': 'Apple’s scanner', google: 'Google’s scanner', 'hms-scankit': 'Huawei’s scanner', 'zxing-java': 'Android Settings’ scanner', wechat: 'WeChat’s scanner', 'zxing-cpp': 'zxing-cpp', 'opencv-aruco': 'OpenCV’s newer detector', opencv: 'OpenCV’s original detector', }; const SHORT = { // the five in a "read · missed" line beside the bell 'zxing-java': 'Android Settings’', wechat: 'WeChat’s', 'zxing-cpp': 'zxing-cpp', 'opencv-aruco': 'OpenCV’s newer', opencv: 'OpenCV’s original', }; const SOURCE_PLAIN = { // what an example is; QR-DN1.0's examples carry their kind pristine: 'Clean print, simulated for this test', everyday: 'Everyday print, simulated for this test', tough: 'Tough print, simulated for this test', boofcv: 'Real photo, BoofCV research set', 'qrdn/clean': 'Clean code, QR-DN1.0 research set', 'qrdn/distorted': 'Distorted, noisy code, QR-DN1.0 research set', syn10k: 'Computer-made warped code, syn10k research set', broken104: 'Real code that fails, collected from the web', }; const START_D = 40; // where the cursor starts const state = { d: START_D, offset: 0 }; const pictures = []; // { n, name, url, file, status: waiting | reading | done | failed, result, error, retry } let counter = 0; let pumping = false; let lit = null; // the picture whose line stands out while you point at its row or reach it by key let chosen = null; // the read picture shown beside the bell, where the cursor sits let wanted = null; // the pictures just added, or one read again: the first of them read is shown beside the bell let press = null; // a press on the bell; a finger acts only on a tap or a sideways drag, so a swipe just scrolls let notice = ''; // what happened to the pictures added last: not added, not pictures let ZONES; let DATA; let RES; let NF; let N; let COUNT; // per code: how many of the eight read it at BASE_FRAME let ORDER; let BELL; const $ = (id) => document.getElementById(id); const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const imgUrl = (path) => `img/${path}?v=${DATA.build}`; // a new build never pairs with a cached old picture function el(tag, attrs = {}, children = []) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'text') node.textContent = v; else if (k === 'class') node.className = v; else node.setAttribute(k, v); } for (const c of [].concat(children)) if (c != null) node.append(c); return node; } function sv(tag, attrs = {}, parent = null) { const node = document.createElementNS(SVGNS, tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'text') node.textContent = v; else node.setAttribute(k, v); } if (parent) parent.append(node); return node; } // ---- data --------------------------------------------------------------------------------- function makeZones(n) { // all, most (more than half), some, none const half = Math.floor(n / 2); return [ { min: n, name: `All ${WORDS[n]}`, words: `all ${WORDS[n]}`, tone: '--z-every' }, { min: half + 1, name: 'Most', words: `most (${half + 1} to ${n - 1})`, range: `${half + 1}–${n - 1}`, tone: '--z-most' }, { min: 1, name: 'Some', words: `some (1 to ${half})`, range: `1–${half}`, tone: '--z-some' }, { min: 0, name: 'None', words: 'none', tone: '--z-none' }, ]; } function decodeResults(data) { const F = data.frames.length; const n = data.items.s.length; const res = new Uint8Array(n * F); for (let i = 0; i < n; i++) { const s = data.items.r[i]; for (let f = 0; f < F; f++) res[i * F + f] = parseInt(s.slice(2 * f, 2 * f + 2), 16); // bit k: scanner k read it } return res; } const decIndex = (key) => DATA.decoders.findIndex((d) => d.key === key); const reads = (i, fi, k) => (RES[i * NF + fi] >> k) & 1; const source = (i) => DATA.sources[DATA.items.s[i]]; const zoneFor = (count) => ZONES.find((z) => count >= z.min); const zoneIndex = (count) => ZONES.indexOf(zoneFor(count)); const snap = (d) => (d > 103 ? PILE_HI : d > 100 ? 100 : d < -3 ? PILE_LO : d < 0 ? 0 : d); // the piles, or the scale const join = (xs) => (xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`); const capital = (s) => s.replace(/^./, (c) => c.toUpperCase()); const pctText = (v) => (v > 0 && v < 0.005 ? '<1%' : `${Math.round(100 * v)}%`); function luminance(hex) { const c = hex.match(/\w\w/g).map((h) => parseInt(h, 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; } /** Text colour for a label on a tone: whichever of ink and surface contrasts more. */ function onTone(z) { const t = luminance(css(z.tone)); const ratio = (name) => { const l = luminance(css(name)); return (Math.max(t, l) + 0.05) / (Math.min(t, l) + 0.05); }; return ratio('--surface') >= ratio('--ink') ? css('--surface') : css('--ink'); } /** Scanner names for a sentence; both OpenCV detectors together read as one phrase. */ function sayNames(keys) { const both = keys.includes('opencv-aruco') && keys.includes('opencv'); const names = keys.filter((k) => !(both && k.startsWith('opencv'))).map((k) => SPOKEN[k]); return both ? [...names, 'both OpenCV detectors'] : names; } // ---- small shared pieces ------------------------------------------------------------------ function showTip(evt, lines) { const tip = $('tip'); tip.replaceChildren(...lines.map(([cls, text]) => el('div', { class: cls, text }))); tip.hidden = false; const w = tip.offsetWidth; const h = tip.offsetHeight; let x = evt.clientX + 14; let y = evt.clientY + 14; if (x + w > window.innerWidth - 8) x = evt.clientX - w - 14; if (y + h > window.innerHeight - 8) y = evt.clientY - h - 14; x = Math.max(8, Math.min(x, window.innerWidth - w - 8)); // a narrow screen: the whole tip stays in view y = Math.max(8, y); tip.style.left = `${x}px`; tip.style.top = `${y}px`; } const hideTip = () => { $('tip').hidden = true; }; const leaveTip = (e) => { if (e.pointerType !== 'touch') hideTip(); }; // a tapped tip stays until the next tap /** A "name: read · name: missed" line whose entries never break inside, so a line never starts with a dot. */ const entries = (items) => items.flatMap((t, j) => [j ? ' ' : null, el('span', { class: 'entry', text: `${t}${j < items.length - 1 ? ' ·' : ''}` })]); const swatch = (z) => el('i', { class: 'swatch', style: `background:${css(z.tone)}` }); const namedSwatch = (z) => { const s = swatch(z); s.title = z.name; return s; }; function countText(n, all = ORDER.length) { if (n === all) return `Read by all ${WORDS[all]} scanners`; if (n === 0) return `Read by none of the ${WORDS[all]}`; return `Read by ${n} of the ${all} scanners`; } // ---- the bell ----------------------------------------------------------------------------- /** The illustrative mix over difficulty, split by how many scanners read each code. */ function bellData() { const counts = {}; for (let i = 0; i < N; i++) counts[source(i).key] = (counts[source(i).key] || 0) + 1; const nSets = DATA.sources.filter((s) => s.group === 'dataset' && counts[s.key]).length; const weight = (s) => (s.group === 'dataset' ? WORLD.dataset / nSets : WORLD[s.key]) / counts[s.key]; const dens = ZONES.map(() => new Float64Array(101)); const piles = { lo: ZONES.map(() => 0), hi: ZONES.map(() => 0) }; let total = 0; for (let i = 0; i < N; i++) { const w = weight(source(i)); const d = DATA.items.d[i]; const z = zoneIndex(COUNT[i]); total += w; if (d < 0) piles.lo[z] += w; else if (d > 100) piles.hi[z] += w; else dens[z][Math.round(d)] += w; } const sigma = 4.5; const medians = {}; for (const key of ['pristine', 'everyday', 'tough']) { const ds = []; for (let i = 0; i < N; i++) if (source(i).key === key) ds.push(DATA.items.d[i]); ds.sort((a, b) => a - b); medians[key] = ds[Math.floor(ds.length / 2)]; } const smooth = dens.map((arr) => Array.from(arr, (_, j) => { let s = 0; let ws = 0; for (let k = Math.max(0, j - 14); k <= Math.min(100, j + 14); k++) { const w = Math.exp(-((k - j) ** 2) / (2 * sigma * sigma)); s += arr[k] * w; ws += w; } return s / ws; })); return { smooth, piles, total, medians }; } /** Shares of the four tones among the codes at difficulty d (the same smoothing as the bell). */ function mixAt(d) { let v; if (d <= PILE_LO) v = BELL.piles.lo; else if (d >= PILE_HI) v = BELL.piles.hi; else { const j = Math.max(0, Math.min(99, Math.floor(d))); v = BELL.smooth.map((s) => s[j] + (s[j + 1] - s[j]) * (d - j)); } const t = v.reduce((a, b) => a + b, 0) || 1; return v.map((x) => x / t); } function renderKey() { $('bellKey').replaceChildren( el('span', { class: 'key-title', text: `How many of the ${WORDS[ORDER.length]} scanners read each code:` }), ...ZONES.map((z) => el('span', {}, [swatch(z), z.range ? `${z.name} (${z.range})` : z.name])), ); } /** Your read pictures, each with its x and the row of its numbered marker (lowest row first). With no room in the * three rows (r: -1), the nearest marker in the top row counts it in its "+N". */ function placeMarks(x) { const rows = [[], [], []]; return pictures.filter((p) => p.status === 'done').map((p) => { const cx = x(p.result.place.d); const r = rows.findIndex((row) => !row.some((q) => Math.abs(q.cx - cx) < 22)); const m = { p, cx, r, more: 0 }; if (r >= 0) rows[r].push(m); else rows[2].reduce((a, b) => (Math.abs(b.cx - cx) < Math.abs(a.cx - cx) ? b : a)).more += 1; return m; }); } /** A place to take loosely: fewer than two of the five read it at 1280 pixels, or some reads didn't finish. */ function rough(p) { const r = p.result; if (r.partial) return true; if (r.place.pile) return false; const fi = r.order.indexOf(BASE_FRAME); return HERE.filter((k) => r.reads[k][fi]).length < 2; } function renderBell() { const svg = $('bell'); const W = svg.clientWidth || 700; const x = (d) => 4 + ((d - X_MIN) / (X_MAX - X_MIN)) * (W - 8); const xInv = (px) => X_MIN + ((px - 4) / (W - 8)) * (X_MAX - X_MIN); const marks = placeMarks(x); const rows = marks.length ? 3 : 0; // the whole band at once, so the bell steps down once, not with each row const more = marks.some((m) => m.more) ? 14 : 0; // room for a "+N" over a crowded spot const markerY = (r) => more + 11 + (rows - 1 - r) * 22; const padTop = 18 + more + rows * 22; // your pictures' markers, then the cursor's handle, then the plot const plotH = 170; const base = padTop + plotH; const H = base + 46; svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.setAttribute('height', H); svg.replaceChildren(); // stacked: all of them at the bottom, then most, some, none; piles keep the same area scale const { smooth, piles } = BELL; const cum = []; smooth.forEach((s, z) => cum.push(s.map((v, j) => v + (z ? cum[z - 1][j] : 0)))); const at = (arr, d) => { const j = Math.max(0, Math.min(99, Math.floor(d))); return arr[j] + (arr[j + 1] - arr[j]) * (d - j); }; const pileH = (p) => p.reduce((a, b) => a + b, 0) / 8; const yMax = Math.max(...cum[cum.length - 1], pileH(piles.lo), pileH(piles.hi)) * 1.12; const y = (v) => base - (Math.min(v, yMax) / yMax) * plotH; ZONES.forEach((z, zi) => { let path = ''; for (let d = 0; d <= 100 + 1e-9; d += 0.5) path += `${path ? 'L' : 'M'}${x(d)},${y(at(cum[zi], d))}`; for (let d = 100; d >= -1e-9; d -= 0.5) path += `L${x(d)},${zi ? y(at(cum[zi - 1], d)) : base}`; sv('path', { d: `${path}Z`, fill: css(z.tone) }, svg); }); ZONES.slice(0, -1).forEach((_, zi) => { // a thin surface line between tones let path = ''; for (let d = 0; d <= 100 + 1e-9; d += 0.5) path += `${path ? 'L' : 'M'}${x(d)},${y(at(cum[zi], d))}`; sv('path', { d: path, fill: 'none', stroke: css('--surface'), 'stroke-width': 1.5 }, svg); }); for (const [c, p, what] of [[PILE_LO, piles.lo, 'Read by every scanner at every resolution'], [PILE_HI, piles.hi, 'Read by no scanner at any resolution']]) { let v0 = 0; p.forEach((w, zi) => { if (!w) return; const top = y((v0 + w) / 8); sv('rect', { x: x(c - 4), y: top, width: x(c + 4) - x(c - 4), height: y(v0 / 8) - top, fill: css(ZONES[zi].tone) }, svg); v0 += w; }); const hit = sv('rect', { x: x(c - 5), y: padTop, width: x(c + 5) - x(c - 5), height: plotH, fill: 'transparent' }, svg); const tip = (e) => showTip(e, [['v', `${Math.round((100 * p.reduce((a, b) => a + b, 0)) / BELL.total)}% of the mix`], ['k', what]]); hit.addEventListener('pointermove', tip); hit.addEventListener('pointerdown', tip); hit.addEventListener('pointerleave', leaveTip); } sv('line', { x1: x(X_MIN), x2: x(X_MAX), y1: base + 0.5, y2: base + 0.5, stroke: css('--rule') }, svg); // a name inside each tone, only where the tone is thick enough under the whole word; a name one of your lines // would cross is left out, so names never move as pictures arrive (the key names every tone) const halfWord = (24 / (W - 8)) * (X_MAX - X_MIN); ZONES.forEach((z, zi) => { let best = null; for (let d = 2 + halfWord; d <= 98 - halfWord; d += 1) { if (Math.abs(d - START_D) < halfWord + 2) continue; // clear of the cursor where it starts const mid = ((zi ? y(at(cum[zi - 1], d)) : base) + y(at(cum[zi], d))) / 2; const room = Math.min(...[d - halfWord, d, d + halfWord].map((dd) => Math.min(mid - 9 - y(at(cum[zi], dd)), (zi ? y(at(cum[zi - 1], dd)) : base) - (mid + 5)))); if (!best || room > best.room) best = { d, room, mid }; } if (!best || best.room < 2) return; const label = sv('text', { x: x(best.d), y: best.mid + 4, 'text-anchor': 'middle', style: `fill:${onTone(z)};font-weight:600`, text: z.name }, svg); const bb = label.getBBox(); if (marks.some((m) => m.cx > bb.x - 6 && m.cx < bb.x + bb.width + 6)) label.remove(); }); // the axis: easier and harder at the ends, and the middle code of each set we made const placed = [{ cx: x(X_MIN) + 30, w: 64, r: 0 }, { cx: x(X_MAX) - 30, w: 64, r: 0 }]; sv('text', { x: x(X_MIN), y: base + 20, class: 'muted', text: '← Easier' }, svg); sv('text', { x: x(X_MAX), y: base + 20, class: 'muted', 'text-anchor': 'end', text: 'Harder →' }, svg); const ticks = []; for (const [key, text] of [['pristine', 'clean prints'], ['everyday', 'everyday prints'], ['tough', 'tough prints']]) { const cx = x(BELL.medians[key]); const w = text.length * 6.4 + 10; let r = 0; while (placed.some((q) => q.r === r && Math.abs(q.cx - cx) < (q.w + w) / 2)) r += 1; placed.push({ cx, w, r }); ticks.push({ cx, r, text }); } for (const { cx, r } of ticks) sv('line', { x1: cx, x2: cx, y1: base, y2: base + 6 + 15 * r, stroke: css('--ink-3') }, svg); for (const { cx, r, text } of ticks) { const label = sv('text', { x: cx, y: base + 20 + 15 * r, 'text-anchor': 'middle', class: 'muted', text }, svg); const bb = label.getBBox(); svg.insertBefore(sv('rect', { x: bb.x - 3, y: bb.y, width: bb.width + 6, height: bb.height, fill: css('--surface') }), label); } // your pictures: an ink line each, cased in the page's colour so it shows on every tone, under a numbered marker; // dashed where its place is rough. The one shown beside the bell stands in front, on the cursor, and its marker is // open. At rest every marker sits over every line, so no line crosses a number; while you point at a row, its line // and marker stand in front instead, and the others step back together. const pointed = marks.find((m) => m.p.n === lit); const on = pointed || marks.find((m) => m.p.n === chosen); const rest = sv('g', pointed ? { opacity: 0.3 } : {}, svg); const front = sv('g', {}, svg); const lines = (g, list, wide) => { // every casing under every ink, so one line never cuts another for (const m of list) sv('line', { x1: m.cx, x2: m.cx, y1: padTop, y2: base, stroke: css('--surface'), 'stroke-width': wide ? 5.5 : 4 }, g); for (const m of list) { sv('line', { // drawn up from the axis, so dashes on one spot line up instead of filling each other's gaps x1: m.cx, x2: m.cx, y1: base, y2: m.r >= 0 ? markerY(m.r) : padTop, stroke: css('--ink'), 'stroke-width': wide ? 2.5 : 1.5, ...(rough(m.p) ? { 'stroke-dasharray': '3 3' } : {}), }, g); } }; lines(rest, marks.filter((m) => m !== on), false); if (on) lines(front, [on], true); const over = pointed ? rest : sv('g', {}, svg); const lead = sv('g', {}, svg); for (const m of marks) { if (m.r < 0) continue; const mine = m.p.n === chosen; const g = m === on ? lead : over; sv('circle', { cx: m.cx, cy: markerY(m.r), r: 9, fill: css(mine ? '--surface' : '--ink'), stroke: css(mine ? '--ink' : '--surface'), 'stroke-width': 2 }, g); sv('text', { x: m.cx, y: markerY(m.r) + 4, 'text-anchor': 'middle', class: mine ? 'marker-n mine' : 'marker-n', text: String(m.p.n) }, g); if (m.more) sv('text', { x: m.cx, y: markerY(m.r) - 14, 'text-anchor': 'middle', class: 'muted marker-more', text: `+${m.more}` }, g); } // what you can point at: each marker, and each line from under its marker to the axis. On a line, the nearest line // that reaches the pointer counts, and where lines share a spot, the one whose marker sits right above it. A mouse // or pen chooses on press; a finger on a tap, so a swipe that scrolls the page chooses nothing. const lineTop = (m) => (m.r >= 0 ? markerY(m.r) + 9 : padTop); const lineAt = (e) => { const b = svg.getBoundingClientRect(); const px = ((e.clientX - b.left) / b.width) * W; const py = ((e.clientY - b.top) / b.height) * H; let best = null; for (const m of marks) { if (lineTop(m) > py) continue; const dx = Math.abs(m.cx - px); if (!best || dx < best.dx - 0.5 || (dx <= best.dx + 0.5 && lineTop(m) > lineTop(best.m))) best = { m, dx }; } return best?.m; }; const hitOn = (node, pick) => { const tip = (e) => { // a tap's answer is the picture beside the bell const m = pick(e); if (m && e.pointerType !== 'touch') showTip(e, [['v', `${m.p.n}. ${m.p.name}`], ['k', statement(m.p)]]); }; node.addEventListener('pointermove', tip); node.addEventListener('pointerleave', leaveTip); node.addEventListener('pointerdown', (e) => { e.stopPropagation(); const m = pick(e); press = e.pointerType === 'touch' ? { id: e.pointerId, x: e.clientX, y: e.clientY, touch: true, p: m.p } : null; if (press) return; // chosen on the tap: see svg.onpointerup tip(e); choose(m.p, 'bell'); }); }; for (const m of marks) { hitOn(sv('rect', { x: m.cx - 7, y: lineTop(m), width: 14, height: base - lineTop(m), fill: 'transparent', style: 'cursor:pointer' }, svg), (e) => lineAt(e) || m); } for (const m of marks) if (m.r >= 0) hitOn(sv('circle', { cx: m.cx, cy: markerY(m.r), r: 11, fill: 'transparent', style: 'cursor:pointer' }, svg), () => m); // the movable cursor const cx = x(state.d); const g = sv('g', { 'pointer-events': 'none' }, svg); sv('line', { x1: cx, x2: cx, y1: padTop - 8, y2: base, stroke: css('--surface'), 'stroke-width': 4 }, g); sv('line', { x1: cx, x2: cx, y1: padTop - 8, y2: base, stroke: css('--accent'), 'stroke-width': 2 }, g); sv('circle', { cx, cy: padTop - 8, r: 7, fill: css('--accent'), stroke: css('--surface'), 'stroke-width': 2 }, g); const move = (e) => { const rect = svg.getBoundingClientRect(); setCursor(snap(xInv(((e.clientX - rect.left) / rect.width) * W))); }; // the cursor follows a mouse or pen from the press; a finger moves it with a tap or a sideways drag, while a swipe // up or down scrolls the page and moves nothing (the press lives outside this drawing, which a drag redraws) svg.onpointerdown = (e) => { press = { id: e.pointerId, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch', p: null }; if (press.touch) return; svg.setPointerCapture(e.pointerId); move(e); }; svg.onpointermove = (e) => { if (!press || press.id !== e.pointerId) return; const dx = Math.abs(e.clientX - press.x); const dy = Math.abs(e.clientY - press.y); if (press.p) { // on one of your pictures: a tap, or nothing if (Math.max(dx, dy) > 10) press = null; return; } if (press.touch) { if (dx < 8 || dx <= dy) return; // not sideways yet: maybe a scroll press.touch = false; svg.setPointerCapture(e.pointerId); } if (e.buttons) move(e); }; svg.onpointerup = (e) => { const was = press; press = null; if (!was || was.id !== e.pointerId) return; if (was.p) choose(was.p, 'bell'); // a tap on one of your pictures else if (was.touch) move(e); // a tap on the bell }; svg.onpointercancel = () => { press = null; }; // the page scrolled instead const yours = marks.length ? ` Your ${marks.length === 1 ? 'picture is a numbered line' : `${marks.length} pictures are numbered lines`} on it.` : ''; svg.setAttribute('aria-label', `QR codes from easiest to hardest, shaded by how many of the ${WORDS[ORDER.length]} scanners read each one: all ${WORDS[ORDER.length]}, most, some or none.${yours}`); } function renderReadout() { const mix = mixAt(state.d); let text; if (state.d <= PILE_LO) text = 'Codes here were read by every scanner at every image resolution.'; else if (state.d >= PILE_HI) text = 'Codes here were read by no scanner at any image resolution.'; else { const parts = ZONES.map((z, zi) => [z, mix[zi]]).filter(([, v]) => v > 0); text = `The curve here: ${parts.map(([z, v], k) => `${pctText(v)} ${k ? 'by' : 'read by'} ${z.words}`).join(' · ')}.`; } $('readout').textContent = text; $('cursor').setAttribute('aria-valuetext', text); } /** The eight example codes nearest this spot; the first one shown has the spot's most common tone. */ function nearest(d) { const c = []; for (let i = 0; i < N; i++) { if (DATA.items.x[i] < 0) continue; c.push([Math.abs(DATA.items.d[i] - d), i]); } const picks = c.sort((a, b) => a[0] - b[0]).slice(0, 8).map(([, i]) => i); const mix = mixAt(d); const common = mix.indexOf(Math.max(...mix)); const first = picks.findIndex((i) => zoneIndex(COUNT[i]) === common); return first > 0 ? [picks[first], ...picks.filter((_, k) => k !== first)] : picks; } /** Beside the bell: your picture when one is chosen, otherwise a measured code from the cursor's spot. */ function renderSpecimen() { const box = $('specimen'); const focused = box.contains(document.activeElement); // focus inside it goes to its one button after a redraw const p = shown(); box.replaceChildren(...(p ? yours(p) : measured()).filter(Boolean)); if (focused) box.querySelector('button')?.focus(); } function measured() { const picks = nearest(state.d); const i = picks[state.offset % picks.length]; const fi = DATA.frames.indexOf(BASE_FRAME); const readKeys = ORDER.filter((k) => reads(i, fi, decIndex(k))); const z = zoneFor(readKeys.length); const yes = (k) => (readKeys.includes(k) ? 'read' : 'missed'); const ex = DATA.examples[DATA.items.x[i]]; const plain = SOURCE_PLAIN[ex.kind ? `${source(i).key}/${ex.kind}` : source(i).key] || source(i).name; const next = el('button', { class: 'linklike', type: 'button', text: 'Another code from here' }); next.addEventListener('click', () => { wanted = null; state.offset += 1; renderSpecimen(); }); const full = /^https?:/.test(ex.href) ? ex.href : imgUrl(ex.href); // the exact picture the scanners read return [ el('img', { src: imgUrl(ex.img), alt: `A measured QR code from this spot: ${plain}` }), el('a', { class: 'scan-it', href: full, target: '_blank', rel: 'noopener', text: 'Open the full picture, and try it with your phone ↗' }), el('div', { class: 'big' }, [namedSwatch(z), countText(readKeys.length)]), el('div', { class: 'small' }, entries([`Apple’s: ${yes(APPLE)}`, `Google’s: ${yes(GOOGLE)}`, `Huawei’s: ${yes(HUAWEI)}`, `the other five: ${HERE.filter((k) => readKeys.includes(k)).length}\u00a0of\u00a0${HERE.length}`])), el('div', { class: 'src', text: plain }), next, ]; } /** Your picture in the measured code's place, with what the five did with it at 1280 pixels, the size behind every * count. */ function yours(p) { const r = p.result; const fi = r.order.indexOf(BASE_FRAME); const yes = HERE.filter((k) => r.reads[k][fi]); const img = el('img', { src: p.url, alt: `Your picture ${p.n}, ${p.name}` }); img.addEventListener('load', () => img.classList.toggle('tall', img.naturalHeight > img.naturalWidth), { once: true }); img.addEventListener('error', () => img.replaceWith(el('span', { class: 'unshown' })), { once: true }); // a format this browser can't show const back = el('button', { class: 'linklike', type: 'button', text: 'A measured code from here' }); back.addEventListener('click', () => { unchoose(); renderBell(); renderSpecimen(); }); const c = r.content; const note = caveat(p); return [ el('div', { class: 'yours' }, img), el('div', { class: 'yours-name' }, [ el('span', { class: 'pic-n mine', 'aria-hidden': 'true', text: String(p.n) }), el('span', { class: 'yours-file', text: p.name }), ]), el('div', { class: 'big', text: countText(yes.length, HERE.length) }), yes.length && yes.length < HERE.length ? el('div', { class: 'small' }, entries(HERE.map((k) => `${SHORT[k]}: ${r.reads[k][fi] ? 'read' : 'missed'}`))) : null, note ? el('div', { class: 'small', text: note }) : null, c ? el('div', { class: 'src yours-content', text: c.text != null ? `${c.text}${c.cut ? '…' : ''}` : `Content that isn’t text, ${c.bytes} bytes` }) : null, back, ]; } /** The cursor at d, and everything that follows it. */ function moveTo(d) { state.d = d; state.offset = 0; $('cursor').value = String(d); renderBell(); renderReadout(); renderSpecimen(); } /** You moved the cursor: beside the bell, a measured code from where it is now. */ function setCursor(d) { unchoose(); moveTo(d); } // ---- your pictures ------------------------------------------------------------------------ /** What the five did with a picture, what the curve holds where it falls, and how sure its place is. */ function statement(p) { const r = p.result; const { d, pile } = r.place; const fi = r.order.indexOf(BASE_FRAME); const yes = HERE.filter((k) => r.reads[k][fi]); const no = HERE.filter((k) => !r.reads[k][fi]); let first; if (pile === 'lo') first = 'All five read it, at every size.'; else if (pile === 'hi') first = 'None of the five read a code in it, at any size.'; else if (yes.length === HERE.length) first = 'All five read it.'; else if (!yes.length) first = 'None of the five read it at 1280 pixels; some did at another size.'; else if (yes.length > HERE.length / 2) first = `${capital(WORDS[yes.length])} of the five read it; ${join(sayNames(no))} missed it.`; else first = `${capital(WORDS[yes.length])} of the five read it: ${join(sayNames(yes))}.`; let second; if (pile === 'lo') second = 'Every measured code like this was read by all eight scanners, at every size.'; else if (pile === 'hi') second = 'If it holds one, a phone’s own scanner may still read it.'; else { const all = mixAt(d)[0]; second = all >= 0.005 ? `Where it falls, codes all eight scanners read make up ${pctText(all)} of the curve.` : 'Where it falls, codes all eight scanners read make up almost none of the curve.'; } let last = ''; if (r.partial) last = ' Some reads didn’t finish in time and count as misses, so it may be easier than this.'; else if (rough(p)) last = ' With so few reads, its place is rough.'; return `${first} ${second}${last}`; } /** Beside the bell, under the five's count at 1280 pixels: what that count doesn't say about the picture's place. */ function caveat(p) { const r = p.result; const { pile } = r.place; const fi = r.order.indexOf(BASE_FRAME); const notes = []; if (pile === 'lo') notes.push('At every size.'); if (pile === 'hi') notes.push('At any size. If it holds a code, a phone’s own scanner may still read it.'); if (!pile && !HERE.some((k) => r.reads[k][fi])) notes.push('At 1280 pixels. Some read it at another size.'); if (r.partial) notes.push('Some reads didn’t finish in time and count as misses, so it may be easier than this.'); else if (rough(p)) notes.push('With so few reads, its place is rough.'); return notes.join(' '); } function what(p) { if (p.status === 'waiting') return 'Waiting to be read'; if (p.status === 'reading') return 'Reading at seven sizes…'; if (p.status === 'failed') return p.error; return statement(p); } function contentLine(p) { const c = p.status === 'done' ? p.result.content : null; if (!c) return null; return el('span', { class: 'pic-content', text: c.text != null ? c.text : `Content that isn’t text, ${c.bytes} bytes` }); } function renderPictures() { const list = $('pictures'); const focused = document.activeElement?.dataset?.key; // keep focus on the same control across a redraw list.hidden = !pictures.length; list.replaceChildren(...pictures.map((p) => { const thumb = el('img', { class: 'pic-thumb', alt: '', src: p.url }); thumb.addEventListener('error', () => thumb.replaceWith(el('span', { class: 'pic-thumb' })), { once: true }); // a format this browser can't show const main = el('button', { type: 'button', class: 'pic-main', 'data-key': `${p.n}:main`, ...(chosen === p.n ? { 'aria-current': 'true' } : {}) }, [ el('span', { class: 'pic-n', text: String(p.n), 'aria-hidden': 'true' }), thumb, el('span', { class: 'pic-text' }, [ el('span', { class: 'pic-name', text: p.name }), el('span', { class: 'pic-what', text: what(p) }), contentLine(p), ]), ]); if (p.status !== 'done') main.disabled = true; main.addEventListener('click', () => choose(p, 'list')); main.addEventListener('focus', () => { if (main.matches(':focus-visible')) light(p.n); }); // reached by key, not by a click main.addEventListener('blur', () => { if (lit === p.n) light(null); }); // pointing is watched on the whole list: wirePictures const items = [main]; if (p.retry && (p.status === 'failed' || p.status === 'done')) { // a failure, or a reading cut short const again = el('button', { type: 'button', class: 'linklike', 'data-key': `${p.n}:again`, text: 'Try again' }); again.addEventListener('click', () => { const was = chosen === p.n; if (was) chosen = null; // shown again once it is read wanted = new Set([p]); p.status = 'waiting'; p.retry = false; renderPictures(); renderBell(); if (was) renderSpecimen(); pump(); }); items.push(again); } const x = el('button', { type: 'button', class: 'pic-x', 'data-key': `${p.n}:x`, 'aria-label': `Remove ${p.name}`, text: '×' }); x.addEventListener('click', (e) => removePicture(p, e.detail === 0)); // detail 0: pressed with a key items.push(x); return el('li', { class: `picture ${p.status}${lit === p.n ? ' lit' : ''}${chosen === p.n ? ' chosen' : ''}`, 'data-n': String(p.n) }, items); })); const end = $('picturesEnd'); const clear = el('button', { type: 'button', class: 'linklike', 'data-key': 'clear', text: 'Remove all' }); clear.addEventListener('click', clearPictures); end.replaceChildren(...(pictures.length > 1 ? [clear] : [])); end.hidden = pictures.length < 2; renderStatus(); if (focused) { // the same control, or its row's × when the control went (Try again) const scope = list.parentElement; (scope.querySelector(`[data-key="${focused}"]`) || scope.querySelector(`[data-key="${focused.split(':')[0]}:x"]`))?.focus(); } } /** Beside the one action: which picture is being read, what happened to the last ones added, or what happens. */ function renderStatus() { const left = pictures.filter((p) => p.status === 'waiting' || p.status === 'reading').length; const reading = pictures.find((p) => p.status === 'reading'); let now = ''; if (reading) now = `Reading picture ${reading.n}…${left > 1 ? ` ${left - 1} more to read.` : ''}`; else if (left) now = pumping ? `${left} waiting to be read.` : `${left} waiting until you try again.`; // paused after a busy server const text = [now, notice].filter(Boolean).join(' ') || ADD_NOTE; if ($('addNote').textContent !== text) $('addNote').textContent = text; // a live region: write only what changed } function note(text) { notice = text; } function light(n) { if (lit === n) return; lit = n; markRows(); renderBell(); } /** The rows' marks: the picture shown beside the bell, and the one you point at or reach by key. */ function markRows() { for (const li of $('pictures').children) { const n = Number(li.dataset.n); li.classList.toggle('lit', n === lit); li.classList.toggle('chosen', n === chosen); const main = li.querySelector('.pic-main'); if (n === chosen) main.setAttribute('aria-current', 'true'); else main.removeAttribute('aria-current'); } } const shown = () => pictures.find((p) => p.n === chosen && p.status === 'done'); /** Show a read picture beside the bell, with the cursor on its line and its row marked. Chosen in the list, the picture * comes into view; chosen on the bell, only where it sits under the bell (a narrow screen); shown as it is read, the * page doesn't scroll. */ function choose(p, from) { if (p.status !== 'done') return; chosen = p.n; wanted = null; if (from === 'bell') lit = null; // you point at the bell, not at a row markRows(); moveTo(snap(p.result.place.d)); const box = $('specimen').getBoundingClientRect(); const under = box.top >= $('bell').getBoundingClientRect().bottom; if ((from === 'list' || (from === 'bell' && under)) && (box.top < 0 || box.bottom > window.innerHeight)) { const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches; $('specimen').scrollIntoView({ behavior: still ? 'auto' : 'smooth', block: 'nearest' }); } } /** Back to the measured codes beside the bell, wherever the cursor is. */ function unchoose() { chosen = null; wanted = null; markRows(); } function removePicture(p, byKey = false) { const i = pictures.indexOf(p); p.abort?.abort(); URL.revokeObjectURL(p.url); pictures.splice(i, 1); if (lit === p.n) lit = null; wanted?.delete(p); const was = chosen === p.n; if (was) chosen = null; // beside the bell: the measured codes where it was if (!pictures.length) counter = 0; // a new list starts at 1 note(''); renderPictures(); renderBell(); if (was) renderSpecimen(); const next = pictures[Math.min(i, pictures.length - 1)]; // keyboard focus stays in the list if (next) $('pictures').querySelector(`[data-key="${next.n}:x"]`).focus(); else $('add').focus({ preventScroll: !byKey }); // a tap on the last × doesn't jump the page to the top if (!pictures.some((q) => q.status === 'failed' && q.retry)) pump(); // the row that paused the reading went: go on } function clearPictures() { for (const p of pictures) { p.abort?.abort(); URL.revokeObjectURL(p.url); } const was = chosen != null; pictures.length = 0; counter = 0; lit = null; chosen = null; wanted = null; note(''); renderPictures(); renderBell(); if (was) renderSpecimen(); $('add').focus(); } const isPicture = (f) => f.type.startsWith('image/') || /\.(jpe?g|png|webp|gif|bmp|tiff?|heic|heif|avif)$/i.test(f.name); function addFiles(list) { const files = [...list]; const fit = files.filter(isPicture); const room = Math.max(0, MAX_PICTURES - pictures.length); const added = fit.slice(0, room).map((f) => { counter += 1; return { n: counter, name: f.name || `Pasted picture ${counter}`, file: f, url: URL.createObjectURL(f), status: 'waiting' }; }); pictures.push(...added); if (added.length) wanted = new Set(added); // the first of these to be read is shown beside the bell const notes = []; if (fit.length > room) notes.push(`Up to ${MAX_PICTURES} pictures at a time: ${fit.length - room} not added.`); if (files.length > fit.length) notes.push(files.length - fit.length === 1 ? 'One file isn’t a picture.' : `${files.length - fit.length} files aren’t pictures.`); note(notes.join(' ')); renderPictures(); pump(); } /** Read waiting pictures one at a time, so a long list never crowds the server. */ async function pump() { if (pumping) return; pumping = true; for (let p = pictures.find((q) => q.status === 'waiting'); p; p = pictures.find((q) => q.status === 'waiting')) { p.status = 'reading'; p.abort = new AbortController(); renderPictures(); try { p.result = await measure(p.file, p.abort.signal); p.status = 'done'; p.retry = Boolean(p.result.partial); // a reading the deadline cut short can be tried again if (!p.retry) p.file = null; } catch (err) { if (!pictures.includes(p)) continue; // removed while it was read p.status = 'failed'; p.error = err.message; p.retry = Boolean(err.retry); wanted?.delete(p); } if (!pictures.includes(p)) continue; // removed just as its reading ended renderPictures(); if (p.status === 'done' && wanted?.has(p)) choose(p, 'read'); // shown beside the bell else renderBell(); if (p.status === 'failed' && p.retry) break; // a busy or unreachable server: the rest wait for Try again } pumping = false; renderStatus(); } function failure(message, retry = false) { const err = new Error(message); err.retry = retry; return err; } async function send(blob, signal) { try { return await fetch('api/measure', { method: 'POST', body: blob, signal, headers: { 'Content-Type': blob.type || 'application/octet-stream' } }); } catch { throw failure('Couldn’t reach the server.', true); } } /** A picture in a format the server doesn't open (HEIC, AVIF, GIF…) as this browser shows it, with transparent parts * on white as on a printed page: as PNG, or as a high-quality JPEG when the PNG would be over 25 MB. One bigger than * MAX_CANVAS pixels is drawn at that size, still well above the largest size it is read at (1920 pixels). */ async function converted(file) { let bitmap; try { bitmap = await createImageBitmap(file); } catch { throw failure('This browser can’t open this picture’s format. Try a JPEG or PNG.'); } const k = Math.min(1, Math.sqrt(MAX_CANVAS / (bitmap.width * bitmap.height))); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(bitmap.width * k)); canvas.height = Math.max(1, Math.round(bitmap.height * k)); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); bitmap.close(); const encode = (type, quality) => new Promise((resolve) => canvas.toBlob(resolve, type, quality)); let out = await encode('image/png'); if (out && out.size > MAX_BYTES) out = await encode('image/jpeg', 0.95); if (!out) throw failure('This picture couldn’t be prepared for reading.'); if (out.size > MAX_BYTES) throw failure('This picture is over 25 MB once converted for reading.'); return out; } async function measure(file, signal) { if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) throw failure('An SVG can’t be read here. Add a photo of the code, or a PNG or JPEG.'); if (file.size > MAX_BYTES) throw failure('This picture is over 25 MB.'); const opened = /^image\/(jpeg|png|webp)$/.test(file.type); // the formats the server opens; others are converted let res = await send(opened ? file : await converted(file), signal); if (res.status === 415 && opened) res = await send(await converted(file), signal); // a file whose type and content disagree if (!res.ok) { let message = ''; try { message = (await res.json()).error; } catch { /* not JSON */ } const retry = res.status === 408 || res.status >= 500; // slow to arrive, busy, or a server fault throw failure(message || (retry ? 'The server couldn’t read it just now.' : 'This picture couldn’t be read.'), retry); } return res.json(); } // ---- wiring ------------------------------------------------------------------------------- function renderLede() { $('lede').textContent = `All ${N.toLocaleString('en')} codes measured at seven sizes, on one scale from easy to hard, shaded by how many of ${WORDS[ORDER.length]} scanners read each one. Add pictures of your own codes to see where they fall.`; } function renderAll() { renderLede(); renderKey(); renderBell(); renderReadout(); renderSpecimen(); renderPictures(); } function wirePictures() { const input = $('file'); $('add').addEventListener('click', () => input.click()); input.addEventListener('change', () => { addFiles(input.files); input.value = ''; }); // pointing at a row lights its line; watched on the list itself, which no redraw replaces, so a row that moves // away from a still pointer never stays lit const list = $('pictures'); list.addEventListener('pointerover', (e) => { if (e.pointerType !== 'touch') light(Number(e.target.closest('.pic-main')?.closest('.picture')?.dataset.n) || null); }); list.addEventListener('pointerleave', (e) => { if (e.pointerType !== 'touch') light(null); }); const inside = new Set(); // the elements a drag is over; a redraw can remove one, and its dragleave never reaches window let quiet = 0; // a drag can also end with no dragleave at all (cancelled): the hint goes once dragover stops const files = (e) => [...(e.dataTransfer?.types || [])].includes('Files'); const hide = () => { inside.clear(); clearTimeout(quiet); $('drop').hidden = true; }; const show = () => { $('drop').hidden = false; clearTimeout(quiet); quiet = setTimeout(hide, 1000); }; window.addEventListener('dragenter', (e) => { if (!files(e)) return; inside.add(e.target); show(); }); window.addEventListener('dragover', (e) => { if (!files(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; show(); }); window.addEventListener('dragleave', (e) => { inside.delete(e.target); for (const t of inside) if (!t.isConnected) inside.delete(t); if (!inside.size) hide(); }); window.addEventListener('drop', (e) => { if (!files(e)) return; e.preventDefault(); hide(); addFiles(e.dataTransfer.files); }); window.addEventListener('paste', (e) => { const pasted = [...(e.clipboardData?.files || [])]; if (!pasted.length) return; e.preventDefault(); addFiles(pasted); }); } async function main() { const params = new URLSearchParams(location.search); const theme = params.get('theme') || params.get('__theme'); // __theme: the theme Hugging Face's page passes to the Space if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme; DATA = await (await fetch('data/bell.json', { cache: 'no-cache' })).json(); RES = decodeResults(DATA); NF = DATA.frames.length; N = DATA.items.s.length; ORDER = DATA.displayOrder; ZONES = makeZones(ORDER.length); const fi = DATA.frames.indexOf(BASE_FRAME); COUNT = Uint8Array.from({ length: N }, (_, i) => ORDER.reduce((s, k) => s + reads(i, fi, decIndex(k)), 0)); BELL = bellData(); if (params.has('d') && Number.isFinite(Number(params.get('d')))) state.d = Math.max(PILE_LO, Math.min(PILE_HI, Number(params.get('d')))); $('cursor').value = String(state.d); $('cursor').addEventListener('input', (e) => setCursor(snap(Number(e.target.value)))); $('cursor').addEventListener('pointerdown', () => { wanted = null; }); // you hold the cursor: a reading doesn't move it wirePictures(); renderAll(); document.addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch') hideTip(); }, true); window.addEventListener('scroll', hideTip, { passive: true }); let raf = 0; new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(renderBell); }).observe(document.querySelector('.page')); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', renderAll); } main().catch((err) => { $('lede').textContent = `Could not load the measurements: ${err.message}`; });