File size: 43,876 Bytes
6f28f9e b43c3a8 6f28f9e b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 6c26597 6f28f9e b43c3a8 be9ca13 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 be9ca13 2a2a176 b43c3a8 6f28f9e b43c3a8 2a2a176 b43c3a8 6f28f9e b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 6c26597 b43c3a8 6f28f9e b43c3a8 6f28f9e be9ca13 b43c3a8 6c26597 6f28f9e b43c3a8 be9ca13 b43c3a8 be9ca13 b43c3a8 be9ca13 6f28f9e be9ca13 b43c3a8 be9ca13 b43c3a8 be9ca13 b43c3a8 be9ca13 2a2a176 6f28f9e 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 6f28f9e b43c3a8 be9ca13 b43c3a8 be9ca13 6f28f9e be9ca13 6f28f9e be9ca13 b43c3a8 2a2a176 b43c3a8 2a2a176 be9ca13 b43c3a8 6f28f9e be9ca13 6f28f9e 2a2a176 6f28f9e b43c3a8 2a2a176 b43c3a8 be9ca13 b43c3a8 6f28f9e be9ca13 2a2a176 be9ca13 2a2a176 be9ca13 6f28f9e 2a2a176 be9ca13 b43c3a8 be9ca13 b43c3a8 6f28f9e b43c3a8 be9ca13 6f28f9e 2a2a176 be9ca13 b43c3a8 2a2a176 b43c3a8 6f28f9e b43c3a8 2a2a176 b43c3a8 be9ca13 b43c3a8 6f28f9e b43c3a8 be9ca13 b43c3a8 6f28f9e 2a2a176 6c26597 b43c3a8 be9ca13 b43c3a8 2a2a176 b43c3a8 6f28f9e b43c3a8 6f28f9e b43c3a8 6f28f9e 2a2a176 6f28f9e b43c3a8 6c26597 b43c3a8 2a2a176 6c26597 b43c3a8 be9ca13 b43c3a8 be9ca13 b43c3a8 be9ca13 6f28f9e be9ca13 6f28f9e b43c3a8 be9ca13 6f28f9e be9ca13 6c26597 b43c3a8 be9ca13 b43c3a8 be9ca13 b43c3a8 6c26597 b43c3a8 be9ca13 b43c3a8 be9ca13 b43c3a8 be9ca13 b43c3a8 be9ca13 b43c3a8 2a2a176 be9ca13 b43c3a8 2a2a176 b43c3a8 6f28f9e b43c3a8 be9ca13 b43c3a8 be9ca13 6f28f9e b43c3a8 be9ca13 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 b43c3a8 2a2a176 6f28f9e 2a2a176 b43c3a8 6f28f9e b43c3a8 6f28f9e b43c3a8 be9ca13 b43c3a8 6f28f9e b43c3a8 906f760 b43c3a8 6f28f9e b43c3a8 6f28f9e 2a2a176 b43c3a8 be9ca13 b43c3a8 2a2a176 6f28f9e b43c3a8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 | // QR Scan Difficulty Check: every measured code on one scale, and your own pictures on it, each with its level, how
// sure that level is, and checks on the picture (levels.js, verdict.js, checks.js, yours.js; words from copy.json).
// The bell, the slider and the example codes are QR Scan Difficulty's own (its app.js), on its data (data/bell.json).
import { $, BASE_FRAME, HERE, WORDS, capital, css, el, entries, goTo, join, pctText, readAt1280, sayNames, sv } from './util.js';
import { getJson, loadCopy, t } from './copy.js';
import { drawLevelAxis, fillHow, initLevels, levelName, levelNames, levelOf, levelStats, measuredScore, renderLevelsList } from './levels.js';
import { lowConf, panelName, panelVerdict, prefixText, rowLine, rowVerdict, toSection } from './verdict.js';
import { kindDefault, renderYours } from './yours.js';
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 APPLE = 'apple-vision';
const GOOGLE = 'google';
const HUAWEI = 'hms-scankit';
const MAX_PICTURES = 40;
const MAX_MB = 25;
const MAX_BYTES = MAX_MB * 1024 * 1024;
const MAX_CANVAS = 16e6; // pixels: a picture this browser converts is drawn at most this big (every browser can)
const TIP_LINGER = 400; // ms: a tip stays this long after the pointer leaves what it names, so the pointer can reach it
const WORLD = { pristine: 0.2, everyday: 0.5, tough: 0.2, dataset: 0.1 }; // the curve's illustrative mix
const START_D = 40; // where the cursor starts
const state = { d: START_D, offset: 0 };
const pictures = []; // { n, name, url, file, kind, status: waiting | reading | done | failed, result, readings, 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 imgUrl = (path) => `img/${path}?v=${DATA.build}`; // a new build never pairs with a cached old picture
// ---- data ---------------------------------------------------------------------------------
function makeZones(n) { // all, most (more than half), some, none
const half = Math.floor(n / 2);
const say = (key, vars = {}) => ({ name: t(`bell.zone.${key}.name`, vars), words: t(`bell.zone.${key}.words`, vars) });
return [
{ min: n, ...say('all', { eight: WORDS[n] }), tone: '--z-every' },
{ min: half + 1, ...say('most', { lo: half + 1, hi: n - 1 }), range: `${half + 1}–${n - 1}`, tone: '--z-most' },
{ min: 1, ...say('some', { lo: 1, hi: half }), range: `1–${half}`, tone: '--z-some' },
{ min: 0, ...say('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
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');
}
// ---- small shared pieces ------------------------------------------------------------------
let tipTimer = 0;
function showTip(evt, lines) {
clearTimeout(tipTimer);
const tip = $('tip');
tip.replaceChildren(...lines.map(([cls, text]) => el('div', { class: cls, text })));
tip.hidden = false;
const { offsetWidth: w, offsetHeight: h } = tip;
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`;
tip.classList.toggle('by-touch', evt.pointerType === 'touch'); // a finger's tip lets the next tap through (style.css)
}
const hideTip = () => { clearTimeout(tipTimer); $('tip').hidden = true; };
// leaving what a tip names: the tip goes a moment later, unless the pointer moves onto it (main()); a tapped tip stays
const leaveTip = (e) => { if (e.pointerType !== 'touch') { clearTimeout(tipTimer); tipTimer = setTimeout(hideTip, TIP_LINGER); } };
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 t('panel.count.all', { eight: WORDS[all] });
if (n === 0) return t('panel.count.none', { eight: WORDS[all] });
return t('panel.count.some', { n, all });
}
// ---- 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 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 };
}
/** 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: t('bell.key_title', { eight: WORDS[ORDER.length] }) }),
...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;
});
}
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 + 34; // one row of level names under the axis
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, t('bell.pile.lo')], [PILE_HI, piles.hi, t('bell.pile.hi')]]) {
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 share = `${Math.round((100 * p.reduce((a, b) => a + b, 0)) / BELL.total)}%`;
const tip = (e) => showTip(e, [['v', t('bell.pile.mix', { mix_share: share })], ['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: the four levels, from the difficulty alone (levels.js); your lines and the cursor pass over them
drawLevelAxis(svg, x, base, W, state.d, [X_MIN, X_MAX]);
// 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 level's confidence is low. 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,
...(lowConf(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', prefixText(m.p.result)], ['k', statement(m.p)]].filter(([, s]) => s));
};
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 > 1 ? t('bell.aria_many', { n: marks.length }) : marks.length ? t('bell.aria_one') : '';
const levels = t('levels.bell_band_aria', { names: join(levelNames()) });
svg.setAttribute('aria-label', [t('bell.aria', { eight: WORDS[ORDER.length] }), levels, yours].filter(Boolean).join(' '));
}
function renderReadout() {
const mix = mixAt(state.d);
let text;
if (state.d <= PILE_LO) text = t('bell.readout.pile_lo');
else if (state.d >= PILE_HI) text = t('bell.readout.pile_hi');
else {
const parts = ZONES.map((z, zi) => [z, mix[zi]]).filter(([, v]) => v > 0);
const shares = parts.map(([z, v], k) => t(k ? 'bell.readout.part' : 'bell.readout.part_first', { pct: pctText(v), zone: z.words }));
text = t('bell.readout.curve', { shares: shares.join(' · ') });
}
const level = t('levels.readout_prefix', { level: levelName(levelOf(state.d)) }); // the cursor's level first
$('readout').replaceChildren(...(level ? [el('strong', { class: 'readout-level', text: level }), ' '] : []), text);
$('cursor').setAttribute('aria-valuetext', level ? `${level} ${text}` : 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 was = box.contains(document.activeElement) ? document.activeElement.dataset.key || '' : null;
const p = shown();
box.replaceChildren(...(p ? yours(p) : measured()).filter(Boolean));
if (was == null) return;
// focus inside it stays inside it, without moving the page: on the same control, else Try again, else the way down
// (rather than "A measured code from here", which would put the picture away), else its first button
const at = (key) => (key ? box.querySelector(`[data-key="${key}"]`) : null);
(at(was) || at('panel:again') || at('panel:down') || box.querySelector('button'))?.focus({ preventScroll: true });
}
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) => t(readKeys.includes(k) ? 'panel.result.read' : 'panel.result.missed');
const phones = [['apple', APPLE], ['google', GOOGLE], ['huawei', HUAWEI]].map(([who, k]) => t(`panel.scanner.${who}`, { result: yes(k) }));
const ex = DATA.examples[DATA.items.x[i]];
const set = ex.kind ? `${source(i).key}.${ex.kind}` : source(i).key; // what it is, by its set (QR-DN1.0's by kind)
const plain = t(`panel.source.${set}`) || source(i).name;
const next = el('button', { class: 'linklike', type: 'button', 'data-key': 'panel:next', text: t('panel.next') });
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
const d = DATA.items.d[i]; // its level, from all eight scanners' place
const score = measuredScore(d);
return [
el('h3', { class: 'visually-hidden', id: 'panelName', text: t('panel.measured_heading') }),
el('img', { src: imgUrl(ex.img), alt: t('panel.measured_alt', { source: plain }) }),
el('a', { class: 'scan-it', href: full, target: '_blank', rel: 'noopener', text: t('panel.open') }),
el('p', { class: 'measured-level' }, [el('span', { class: 'measured-level-name', text: levelName(levelOf(d)) }), ...(score ? [' · ', score] : [])]),
el('div', { class: 'big' }, [namedSwatch(z), countText(readKeys.length)]),
el('div', { class: 'small' }, entries([...phones, t('panel.scanner.others', { n: HERE.filter((k) => readKeys.includes(k)).length, all: HERE.length })])),
el('div', { class: 'src', text: plain }),
next,
];
}
/** The panel stays in view as you scroll only while all of it fits in the window; taller (with larger text, say), it
* scrolls with the page, so none of it is out of reach. */
function fitPanel() {
const box = $('specimen');
box.classList.remove('unstuck');
const st = getComputedStyle(box);
if (st.position === 'sticky' && box.offsetHeight + (parseFloat(st.top) || 0) > window.innerHeight) box.classList.add('unstuck');
}
/** Your picture in the measured code's place: its level, score and confidence (verdict.js), and the way down to why,
* and what to do. */
function yours(p) {
const r = p.result;
const img = el('img', { src: p.url, alt: t('panel.yours_alt', { n: p.n, name: 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', 'data-key': 'panel:back', text: t('panel.back') });
back.addEventListener('click', () => { unchoose(); renderBell(); renderSpecimen(); });
const c = r.content;
return [
el('div', { class: 'yours' }, img),
panelName(p),
panelVerdict(r),
p.retry ? again(p, 'panel:again') : null, // a reading cut short
$('yourPicture') ? toSection(r, () => goTo($('yourPicture'), $('yoursTitle'))) : null,
c ? el('div', { class: 'src yours-content', text: c.text != null ? `${c.text}${c.cut ? '…' : ''}` : t('rows.content_bytes', { bytes: c.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 at 1280 pixels (on a pile, at every size): the bell's tooltip, and in "Your
* picture" when only some of them read it. */
function statement(p) {
const r = p.result;
const { pile } = r.place;
const yes = readAt1280(r);
const no = HERE.filter((k) => !yes.includes(k));
if (pile === 'lo') return t('yours.five.pile_lo');
if (pile === 'hi') return t('yours.five.pile_hi');
if (yes.length === HERE.length) return t('yours.five.all');
if (!yes.length) return t('yours.five.none_1280');
if (yes.length > HERE.length / 2) return t('yours.five.most', { count: capital(WORDS[yes.length]), names: join(sayNames(no)) });
return t('yours.five.some', { count: capital(WORDS[yes.length]), names: join(sayNames(yes)) });
}
function what(p) {
if (p.status === 'waiting') return t('rows.waiting');
if (p.status === 'reading') return t('rows.reading');
if (p.status === 'failed') return p.error;
return rowLine(p.result); // its checks in a line
}
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 : t('rows.content_bytes', { bytes: c.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-name', text: p.name }),
rowVerdict(p), // its level and confidence, in their own column
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')) items.push(again(p, `${p.n}:again`)); // a failure, or a reading cut short
const x = el('button', { type: 'button', class: 'pic-x', 'data-key': `${p.n}:x`, 'aria-label': t('rows.remove', { name: 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: t('rows.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), where it is: the page stays put
const scope = list.parentElement;
(scope.querySelector(`[data-key="${focused}"]`) || scope.querySelector(`[data-key="${focused.split(':')[0]}:x"]`))?.focus({ preventScroll: true });
}
showYours();
}
/** "Try again", in a row and in the panel: one handler. */
function again(p, key) {
const button = el('button', { type: 'button', class: 'linklike again', 'data-key': key, text: t('rows.again') });
button.addEventListener('click', () => readAgain(p));
return button;
}
function readAgain(p) {
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();
}
/** "Your picture" under the bell: the chosen picture, or an invitation to choose one (yours.js). */
function showYours() {
renderYours(shown(), { any: pictures.some((q) => q.status === 'done'), five: statement, onBack: (p) => {
const row = $('pictures').querySelector(`[data-key="${p.n}:main"]`);
if (row) goTo(row, row, 'center'); // the bell sits just above the list
} });
}
/** 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 = [t('add.reading', { n: reading.n }), left > 1 ? t('add.more', { n: left - 1 }) : ''].filter(Boolean).join(' ');
else if (left) now = t(pumping ? 'add.waiting' : 'add.paused', { n: left }); // paused after a busy server
const text = [now, notice].filter(Boolean).join(' ') || t('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));
showYours();
const box = $('specimen').getBoundingClientRect();
const under = box.top >= $('bell').getBoundingClientRect().bottom;
if ((from === 'list' || (from === 'bell' && under)) && (box.top < 0 || box.bottom > window.innerHeight)) goTo($('specimen'), null, 'nearest');
}
/** Back to the measured codes beside the bell, wherever the cursor is. */
function unchoose() {
chosen = null;
wanted = null;
markRows();
showYours();
}
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 || t('rows.pasted', { n: counter }), file: f, url: URL.createObjectURL(f), status: 'waiting', kind: kindDefault() };
});
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(t('add.too_many', { max: MAX_PICTURES, n: fit.length - room }));
if (files.length > fit.length) notes.push(files.length - fit.length === 1 ? t('add.not_picture') : t('add.not_pictures', { n: files.length - fit.length }));
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.readings = (p.readings || 0) + 1; // a reading again redraws "Your picture"
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
const said = $('readNote'); // for screen readers: one sentence per picture read, or not read
if (said) {
said.textContent = p.status === 'done' ? t('read.announce', { n: p.n, name: p.name, verdict: prefixText(p.result) })
: t('read.announce_failed', { n: p.n, name: p.name, error: p.error });
}
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(t('errors.unreachable'), 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(t('errors.format'));
}
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(t('errors.prepare'));
if (out.size > MAX_BYTES) throw failure(t('errors.too_big_converted', { mb: MAX_MB }));
return out;
}
async function measure(file, signal) {
if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) throw failure(t('errors.svg'));
if (file.size > MAX_BYTES) throw failure(t('errors.too_big', { mb: MAX_MB }));
if (!file.size) throw failure(t('rows.empty_file'));
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 || t(retry ? 'errors.busy' : 'errors.unreadable'), retry);
}
try {
return await res.json();
} catch {
throw failure(t('errors.busy'), true); // cut off on the way, or not the server's answer
}
}
// ---- wiring -------------------------------------------------------------------------------
function renderLede() {
const lede = t('how.lede', { N: N.toLocaleString('en'), eight: WORDS[ORDER.length] });
if (lede) $('lede').textContent = lede;
}
function renderAll() {
renderLede();
renderKey();
renderBell();
renderReadout();
renderSpecimen();
renderPictures(); // and "Your picture"
}
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 node of inside) if (!node.isConnected) inside.delete(node);
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;
const [data, levels] = await Promise.all([getJson('data/bell.json'), getJson('data/levels.json'), loadCopy('data/copy.json')]);
DATA = data;
initLevels(levels); // the cuts and names the server rates with
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();
levelStats({ n: N, d: (i) => DATA.items.d[i], read1280: (i, k) => reads(i, fi, decIndex(k)), set: (i) => source(i).key });
renderLevelsList($('levelsMore'));
fillHow();
const caption = t('bell.caption_levels');
if (caption) $('bellNote').append(` ${caption}`);
const about = t('how.meta_description');
if (about) document.querySelector('meta[name="description"]')?.setAttribute('content', about);
if (params.has('d') && Number.isFinite(Number(params.get('d')))) state.d = snap(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);
$('tip').addEventListener('pointerenter', () => clearTimeout(tipTimer)); // on the tip, it stays where it is
$('tip').addEventListener('pointerleave', (e) => { if (e.pointerType !== 'touch') hideTip(); });
$('bell').addEventListener('pointerleave', leaveTip); // leaving the bell, also after a redraw under a still pointer
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTip(); });
new ResizeObserver(fitPanel).observe($('specimen'));
window.addEventListener('resize', fitPanel);
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}`;
});
|