devmandan's picture
Show your picture beside the bell
be9ca13 verified
Raw History Blame Contribute Delete
46 kB
// 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}`;
});