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