Download web/app.js from devmandan/qr-scan-difficulty-check: direct link, hf CLI and curl.
- Browser
- Download file 46 kB
-
https://huggingface.co/spaces/devmandan/qr-scan-difficulty-check/resolve/main/web/app.js
- Command line
-
hf download hf://spaces/devmandan/qr-scan-difficulty-check/web/app.js
-
curl -L -o app.js https://huggingface.co/spaces/devmandan/qr-scan-difficulty-check/resolve/main/web/app.js
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}`; | |
| }); | |