File size: 66,066 Bytes
a7ffb22
 
 
 
 
 
 
 
 
 
f59a1d4
 
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a1d4
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a1d4
 
 
 
a7ffb22
f59a1d4
a7ffb22
 
f59a1d4
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a1d4
 
a7ffb22
 
 
 
 
 
 
 
 
 
 
f59a1d4
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a1d4
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a1d4
 
a7ffb22
 
 
 
 
 
 
 
 
 
 
f59a1d4
a7ffb22
f59a1d4
 
a7ffb22
f59a1d4
 
 
 
 
 
 
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aeda545
 
 
 
 
 
 
 
 
 
 
a7ffb22
aeda545
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a1d4
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f59a1d4
a7ffb22
 
 
 
 
 
 
 
 
 
 
aeda545
a7ffb22
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
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
/* Mathly's sketch board. On a desktop screen it sits beside the exercise (the student turns it on from the sheet's
   header; this browser remembers the choice). On a phone or a tablet it opens full screen over the exercise, from
   the same button, and closes back to the question. Used by a skill's exercise, the practice exam, the unit quiz,
   and the phone's answer to a photo request from the computer (openFull). Each question keeps its own sketch for
   as long as the page is open. Nothing is sent anywhere unless the student hands it in.

   Everything on the board is an element that can be moved or erased: a pen stroke (with a pen's pressure),
   a straight line, a circle (dragged from the center), an angle's arc with its words (the angle tool: pointing at a
   corner where two lines meet shows the arc; a click adds it and asks for its words, and a number gets a degree
   sign), typed math (the math keyboard, static/board-math.js), and a
   copy of the question (its text and figures), which is placed at the top by itself the first time a question's sketch is shown.
   Holding the right mouse button (or a pen's barrel button) erases, whatever tool is chosen.
   Line ends and circle centers snap to the ends and centers already drawn, so a triangle closes neatly;
   lines that meet that way move together. The eraser removes the lines and circles it touches and cuts pen strokes;
   it leaves the question's copy alone (select it and delete it instead).
   The paper is endless downwards and to the right (what was written while the board was slid open stays in reach
   when it slides back): the mouse wheel over the board scrolls it (sideways with Shift or a trackpad), and so do
   two fingers on a touch screen in any direction (one finger draws), and so does dragging empty paper with the move tool. Once a pen has touched the screen,
   a single finger is ignored: a hand resting on it. While the pen writes (and for a moment after it lifts) every
   touch is ignored, so a palm neither scrolls the paper nor takes back the stroke, and a wide contact is a palm too.
   A sheet can let the board hand in a question's answer (the unit quiz): the sketch becomes a picture. */
(()=>{
const PREF="mathly.board";
const isOn=()=>{ try{ return localStorage.getItem(PREF)==="on"; }catch{ return false; } };
const setOn=on=>{ try{ localStorage.setItem(PREF,on?"on":"off"); }catch{} };

const INKS=[["#1B2140","ืฉื—ื•ืจ"],["#2563D9","ื›ื—ื•ืœ"],["#D3455B","ืื“ื•ื"]];
const ERASERS=[[16,"ืงื˜ืŸ"],[40,"ื‘ื™ื ื•ื ื™"],[90,"ื’ื“ื•ืœ"]];
const TOOLS=[["move","ื”ื–ื–ื”"],["pen","ืขื˜"],["line","ืงื• ื™ืฉืจ"],["circle","ืขื™ื’ื•ืœ"],["angle","ื–ื•ื•ื™ืช"],["math","ื ื•ืกื—ื”"],["eraser","ืžื—ืง"]];
/* one line-drawn icon set (24ร—24, the button's text colour), so every button looks alike on every device and
   follows the button's hover and pressed colours; "flip" icons point the other way in a right-to-left page */
const DOT='fill="currentColor" stroke="none"';
const ICONS={
  move:'<path d="M18 11V6.5a1.5 1.5 0 0 0-3 0V11M15 10V4.5a1.5 1.5 0 0 0-3 0V10M12 10V5.5a1.5 1.5 0 0 0-3 0V13M18 9.5a1.5 1.5 0 0 1 3 0V14a7 7 0 0 1-7 7h-1.5c-2.2 0-3.7-.8-5-2.1l-3.3-3.3a1.5 1.5 0 0 1 2.1-2.1L9 15.2"/>',
  pen:'<path d="M4 20l1.2-4.2L16.5 4.5a2.1 2.1 0 0 1 3 3L8.2 18.8z"/><path d="M14.5 6.5l3 3"/>',
  line:`<path d="M6 18L18 6"/><circle cx="6" cy="18" r="2" ${DOT}/><circle cx="18" cy="6" r="2" ${DOT}/>`,
  circle:`<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="1.6" ${DOT}/>`,
  angle:'<path d="M20 19H4l11-14"/><path d="M10.5 19a6.5 6.5 0 0 0-2.5-5.1"/>',
  math:'<path d="M17.5 5.5V4H6.5l6 8-6 8h11v-1.5"/>',
  eraser:'<path d="M20 20H9.5M4.6 15.4l9.8-9.8a2 2 0 0 1 2.8 0l2.2 2.2a2 2 0 0 1 0 2.8L11 19H8.2l-3.6-3.6"/><path d="M9.5 10.5l5 5"/>',
  question:'<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1M9 11h6M9 15h4"/>',
  undo:'<path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
  clear:'<path d="M4 7h16M10 11v6M14 11v6M6 7l1 12.2A2 2 0 0 0 9 21h6a2 2 0 0 0 2-1.8L18 7M9 7V4.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1V7"/>',
  close:'<path d="M6 6l12 12M18 6L6 18"/>',
  send:'<path d="M12 15V4M7 9l5-5 5 5M5 15v4a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4"/>',
  done:'<path d="M5 12.5l4.5 4.5L19 7"/>',
  back:'<path d="M19 12H5M11 6l-6 6 6 6"/>',
  top:'<path d="M12 19V5M6 11l6-6 6 6"/>',
  calc:'<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M8.5 7.5h7M8.5 12h.01M12 12h.01M15.5 12h.01M8.5 16h.01M12 16h.01M15.5 16h.01"/>'};
const FLIP=new Set(["undo","back"]);
const ic=n=>`<svg class="mx-ic${FLIP.has(n)?" mx-ic-flip":""}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n]}</svg>`;
const MATH_SIZE=26;
const GREEK=["ฮฑ","ฮฒ","ฮณ"], ARC_GAP=4;   // the angle box's letters; how far apart a double or triple arc's arcs are
const ARCS=[[1,"ืงืฉืช ืื—ืช"],[2,"ืงืฉืช ื›ืคื•ืœื”"],[3,"ืงืฉืช ืžืฉื•ืœืฉืช"],["right","ื–ื•ื•ื™ืช ื™ืฉืจื” (ืจื™ื‘ื•ืข)"]];
// a corner with one, two or three arcs: equal angles are marked alike
const arcsIcon=n=>n==="right"?'<svg class="mx-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 20h18M3 20V4M3 12.5h7.5V20"/></svg>'
  :`<svg class="mx-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 20h18M3 20l12-14"/>${[7,10.5,14].slice(0,n).map(r=>`<path d="M${3+r} 20A${r} ${r} 0 0 0 ${(3+r*.65).toFixed(2)} ${(20-r*.76).toFixed(2)}"/>`).join("")}</svg>`;
const ANGLE_FONT='500 18px "STIX Two Text","Cambria Math","Times New Roman",serif', CORNER=32, CORNER_TOUCH=40;   // how near a corner the angle tool reaches: about as far as an arc
const ON=3;   // how close a point is to count as on a line: where an angle's corner is, and what moves with what
const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
const PAPER="#F8F7F2", PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
const FONT='500 17px Rubik, system-ui, "Segoe UI", sans-serif', LINE_H=27, CARD_PAD=14;

/* sketch key -> {marks, past}: marks are drawn in order; past holds copies of marks for undo */
const sketches=new Map(), images=new Map();
let el=null, canvas=null, ctx=null, key="", base="", pages=0, page=0, layer=null, source=null, hand=null, onPage=null;
let tool="pen", ink=INKS[0][0], eraser=ERASERS[1][0], sendLabel="";
let draft=null, drag=null, rub=null, slide=null, selection=[];   // slide: the move tool dragging empty paper, which scrolls it
let paper=PAPER;   // the paper's colour: the board's, or white in the picture handed in
let naming=null, hover=null;   // naming: an angle's words being typed {mark, node, input, fresh, was, key}; hover: the corner the angle tool points at
let editing=null;   // the formula being typed: {mark (null for a new one), at, node, field, key}
let full=null;   // the board open full screen: {saved: the context to go back to, done: called when it closes}
const touches=new Map(); let pan=null, penSeen=false;
// a hand resting on the screen while a pen writes: its touches are ignored until they lift
const palms=new Set(); let pensDown=0, penUpAt=-1e9;
const PALM_AFTER_PEN=500, PALM_SIZE=60;   // ms after the pen lifts; a contact this wide (css px) is a palm, not a finger
const desk=()=>matchMedia("(min-width:1000px) and (any-pointer:fine)").matches;   // the same test as math.css
const context=()=>({layer,base,pages,page,source,hand,onPage,sendLabel,key});
function restore(c){ ({layer,base,pages,page,source,hand,onPage,sendLabel,key}=c); }

const sketch=()=>{ if(!sketches.has(key)) sketches.set(key,{marks:[],past:[],x:0,y:0}); return sketches.get(key); };   // x, y: how far right and down the paper is scrolled
const scrollTo=(x,y)=>{ const s=sketch(); s.x=Math.max(0,x); s.y=Math.max(0,y); paint(); };   // never left of or above the paper's corner
const marks=()=>sketch().marks;
const copy=list=>JSON.parse(JSON.stringify(list));
function remember(before=copy(marks())){ const s=sketch(); s.past.push(before); if(s.past.length>HISTORY) s.past.shift(); }
const shown=()=>!!el?.isConnected&&canvas.clientWidth>0;
const dist=(a,b)=>Math.hypot(a[0]-b[0],a[1]-b[1]);
const same=(a,b)=>dist(a,b)<.5;

/* The calculator: a scientific one in the layout of the Casio fx-991ES PLUS, on a panel over the paper's corner
   (the toolbar's calculator button). SHIFT gives the second function printed above a key (sinโปยน, 10หฃ, xยณ, nCr, ...),
   DEG/RAD/GRA is the angle unit, Sโ‡”D turns an answer into a fraction and back, Ans is the last answer.
   The expression is read by a small parser, never evaluated as code. */
// [label, what it types (or an @command), shift label, what shift types, class]
const CALC_KEYS=[
  ["SHIFT","@shift",,,"fn shift"],["DEG","@mode",,,"fn"],["Sโ‡”D","@sd",,,"fn"],["nPr","nPr","nCr","nCr","fn"],["%","%",,,"fn"],
  ["sin","sin(","sinโปยน","sinโปยน(","fn"],["cos","cos(","cosโปยน","cosโปยน(","fn"],["tan","tan(","tanโปยน","tanโปยน(","fn"],["log","log(","10หฃ","10^(","fn"],["ln","ln(","eหฃ","e^(","fn"],
  ["(","(",,,"fn"],[")",")",,,"fn"],["xยฒ","^2","xยณ","^3","fn"],["xสธ","^",,,"fn"],["โˆš","โˆš(","ยณโˆš","ยณโˆš(","fn"],
  ["xโปยน","^(โˆ’1)","x!","!","fn"],["|x|","Abs(",,,"fn"],["ฯ€","ฯ€","e","e","fn"],["(โˆ’)","โˆ’",,,"fn"],["Ans","Ans",,,"fn"],
  ["7","7"],["8","8"],["9","9"],["DEL","@del",,,"del"],["AC","@ac",,,"del"],
  ["4","4"],["5","5"],["6","6"],["ร—","ร—",,,"op"],["รท","รท",,,"op"],
  ["1","1"],["2","2"],["3","3"],["+","+",,,"op"],["โˆ’","โˆ’",,,"op"],
  ["0","0"],[".","."],["แด‡","แด‡",,,"fn"],["=","@eq",,,"eq"]];
const cs={expr:"",res:null,done:false,shift:false,mode:"DEG",ans:0,frac:false};
const CALC_FUNS=["sinโปยน(","cosโปยน(","tanโปยน(","sin(","cos(","tan(","log(","ln(","ยณโˆš(","โˆš(","Abs("];
const CALC_NAMES=[...CALC_FUNS,"Ans","nPr","nCr","ฯ€"];
function calcTrig(k,x,inv){
  const d=cs.mode, full=d==="DEG"?360:d==="GRA"?400:2*Math.PI, f={s:"sin",c:"cos",t:"tan"}[k];
  if(inv){ const r=Math[`a${f}`](x); return d==="DEG"?r*180/Math.PI:d==="GRA"?r*200/Math.PI:r; }
  if(d!=="RAD"){   // whole turns, halves and quarters give exact answers (sin 180 is 0, not 1.2e-16)
    const q=((x%full)+full)%full, half=full/2, quarter=full/4;
    if(k==="s"){ if(q%half===0) return 0; if(q===quarter) return 1; if(q===3*quarter) return -1; }
    if(k==="c"){ if(q===0) return 1; if(q===half) return -1; if(q===quarter||q===3*quarter) return 0; }
    if(k==="t"){ if(q%half===0) return 0; if(q%half===quarter) throw 0; }
  }
  return Math[f](d==="DEG"?x*Math.PI/180:d==="GRA"?x*Math.PI/200:x);
}
const calcFact=n=>{ if(!Number.isInteger(n)||n<0||n>170) throw 0; let r=1; for(let k=2;k<=n;k++) r*=k; return r; };
function calcValue(text){
  const T=[], src=text.replace(/\s+/g,"").replace(/-/g,"โˆ’").replace(/\*/g,"ร—").replace(/\//g,"รท");
  for(let i=0;i<src.length;){
    const r=src.slice(i); let m=/^(?:\d+\.?\d*|\.\d+)(?:แด‡[โˆ’]?\d+)?/.exec(r);
    if(m){ T.push({n:parseFloat(m[0].replace("แด‡","e").replace("โˆ’","-"))}); i+=m[0].length; continue; }
    const nm=CALC_NAMES.find(x=>r.startsWith(x))||("+โˆ’ร—รท()^!%e".includes(r[0])?r[0]:null);
    if(!nm) throw 0; T.push(nm); i+=nm.length;
  }
  let p=0; const nx=()=>T[p];
  const starts=t=>t!==undefined&&(typeof t==="object"||t==="("||t==="e"||t==="ฯ€"||t==="Ans"||CALC_FUNS.includes(t));
  const close=()=>{ if(nx()===")") p++; else if(p<T.length) throw 0; };   // a bracket left open at the end is fine
  function atom(){
    const t=T[p++]; if(t===undefined) throw 0;
    if(typeof t==="object") return t.n;
    if(t==="ฯ€") return Math.PI;
    if(t==="e") return Math.E;
    if(t==="Ans") return cs.ans;
    if(t==="(" ){ const v=sum(); close(); return v; }
    if(!CALC_FUNS.includes(t)) throw 0;
    const x=sum(); close();
    switch(t){
      case "sin(": return calcTrig("s",x); case "cos(": return calcTrig("c",x); case "tan(": return calcTrig("t",x);
      case "sinโปยน(": return calcTrig("s",x,1); case "cosโปยน(": return calcTrig("c",x,1); case "tanโปยน(": return calcTrig("t",x,1);
      case "log(": return Math.log10(x); case "ln(": return Math.log(x);
      case "โˆš(": return Math.sqrt(x); case "ยณโˆš(": return Math.cbrt(x); default: return Math.abs(x);
    }
  }
  function post(){ let v=atom(); for(;;){ if(nx()==="!"){ p++; v=calcFact(v); } else if(nx()==="%"){ p++; v/=100; } else return v; } }
  function power(){ const b=post(); if(nx()==="^"){ p++; return Math.pow(b,unary()); } return b; }
  function unary(){ if(nx()==="โˆ’"){ p++; return -unary(); } if(nx()==="+"){ p++; return unary(); } return power(); }
  function perm(){ let v=unary(); while(nx()==="nPr"||nx()==="nCr"){
    const c=T[p++]==="nCr", r=unary();
    if(!Number.isInteger(v)||!Number.isInteger(r)||r<0||r>v) throw 0;
    v=calcFact(v)/calcFact(v-r)/(c?calcFact(r):1); } return v; }
  function prod(){ let v=perm(); for(;;){ const t=nx();
    if(t==="ร—"){ p++; v*=perm(); } else if(t==="รท"){ p++; v/=perm(); } else if(starts(t)) v*=perm(); else return v; } }   // 2ฯ€ and 2(3+4) multiply
  function sum(){ let v=prod(); for(;;){ if(nx()==="+"){ p++; v+=prod(); } else if(nx()==="โˆ’"){ p++; v-=prod(); } else return v; } }
  const v=sum(); if(p<T.length||!Number.isFinite(v)) throw 0; return v;
}
const calcShow=v=>String(+v.toPrecision(10)).replace("e+","แด‡").replace("e-","แด‡โˆ’").replace("-","โˆ’");
function calcFrac(v){   // a decimal answer as a fraction with a denominator up to 10000, when it is one
  if(!Number.isFinite(v)||Number.isInteger(v)) return null;
  const x=Math.abs(v); let h0=0,h1=1,k0=1,k1=0,b=x;
  for(let n=0;n<24;n++){
    const a=Math.floor(b), h2=a*h1+h0, k2=a*k1+k0; h0=h1; h1=h2; k0=k1; k1=k2;
    if(k1>10000) return null;
    if(Math.abs(x-h1/k1)<1e-11*Math.max(1,x)) return `${v<0?"โˆ’":""}${h1}/${k1}`;
    const f=b-a; if(f<1e-14) return null; b=1/f;
  }
  return null;
}
function calcDraw(){
  const box=el.querySelector(".mx-calc"), ex=box.querySelector("[data-calc-expr]"), out=box.querySelector("[data-calc-out]");
  let text="", cls="";
  if(cs.res?.err) text="ืฉื’ื™ืื”";
  else if(cs.res){ text=(cs.frac&&calcFrac(cs.res.v))||calcShow(cs.res.v); }
  else if(cs.expr){ try{ if(/[^0-9.]/.test(cs.expr)) text=calcShow(calcValue(cs.expr)); }catch{} cls="live"; }
  ex.textContent=cs.expr||"0"; ex.scrollLeft=ex.scrollWidth;
  out.textContent=text||"ย "; out.className=cls;
  box.querySelector("[data-calc-flags]").textContent=[cs.shift?"S":"",cs.mode==="DEG"?"D":cs.mode==="RAD"?"R":"G"].filter(Boolean).join(" ");
  box.querySelector('[data-calc-do="mode"]').textContent=cs.mode;
  box.querySelectorAll("[data-calc]").forEach(b=>b.classList.toggle("shifted",cs.shift));
  box.querySelector(".shift").setAttribute("aria-pressed",cs.shift);
}
function calcAct(act){
  if(act==="@shift"){ cs.shift=!cs.shift; return calcDraw(); }
  cs.shift=false;
  if(act==="@mode"){ cs.mode=cs.mode==="DEG"?"RAD":cs.mode==="RAD"?"GRA":"DEG"; if(cs.res&&!cs.res.err&&cs.done) calcAct("@eq"); return calcDraw(); }
  if(act==="@sd"){ if(cs.res&&!cs.res.err&&calcFrac(cs.res.v)) cs.frac=!cs.frac; return calcDraw(); }
  if(act==="@ac"){ cs.expr=""; cs.res=null; cs.done=false; cs.frac=false; return calcDraw(); }
  if(act==="@del"){
    if(cs.done){ cs.done=false; cs.res=null; }
    else cs.expr=cs.expr.replace(/(?:sinโปยน\(|cosโปยน\(|tanโปยน\(|sin\(|cos\(|tan\(|log\(|ln\(|ยณโˆš\(|โˆš\(|Abs\(|Ans|nPr|nCr|.)$/u,"");
    return calcDraw();
  }
  if(act==="@eq"){
    if(!cs.expr) return calcDraw();
    try{ const v=calcValue(cs.expr); cs.res={v}; cs.ans=v; }catch{ cs.res={err:true}; }
    cs.done=true; cs.frac=false; return calcDraw();
  }
  if(cs.done){ cs.expr=/^(?:\^|!|%|nPr|nCr|[+โˆ’ร—รท])/.test(act)?"Ans"+act:act; cs.done=false; cs.res=null; cs.frac=false; }   // an operator goes on from the answer
  else cs.expr+=act;
  calcDraw();
}
/* the calculator's bar on top drags it anywhere over the paper; it keeps its place while the page is open,
   and stays whole inside the board when the board gets smaller */
let calcAt=null;   // {x, y}: its corner in the board's stage; null: the corner it starts in
function calcPlace(){
  const box=el?.querySelector(".mx-calc"); if(!box||box.hidden||!calcAt) return;
  const st=box.parentElement, x=Math.max(0,Math.min(calcAt.x,st.clientWidth-box.offsetWidth)), y=Math.max(0,Math.min(calcAt.y,st.clientHeight-box.offsetHeight));
  box.style.inset="auto"; box.style.left=x+"px"; box.style.top=y+"px";   // inset first: it would undo left and top
}
function calcGrip(){
  const box=el.querySelector(".mx-calc"), grip=box.querySelector(".mx-calc-grip"); let from=null;
  grip.addEventListener("pointerdown",e=>{
    e.preventDefault(); try{ grip.setPointerCapture(e.pointerId); }catch{}
    from={id:e.pointerId,x:e.clientX-box.offsetLeft,y:e.clientY-box.offsetTop}; box.classList.add("moving");
  });
  grip.addEventListener("pointermove",e=>{ if(from?.id!==e.pointerId) return; calcAt={x:e.clientX-from.x,y:e.clientY-from.y}; calcPlace(); });
  const end=e=>{ if(from?.id===e.pointerId){ from=null; box.classList.remove("moving"); } };
  grip.addEventListener("pointerup",end); grip.addEventListener("pointercancel",end);
  new ResizeObserver(calcPlace).observe(box.parentElement);
}
function calcPress(i){ const [,ins,,sins]=CALC_KEYS[i]; calcAct(cs.shift&&sins!==undefined?sins:ins); }
const calcKeysHtml=()=>CALC_KEYS.map(([label,,sl,,cls="num"],i)=>
  `<button type="button" data-calc="${i}" class="${cls}"${label==="DEG"?' data-calc-do="mode"':""}>${sl?`<i>${sl}</i>`:""}<b>${label}</b></button>`).join("");
function toggleCalc(){
  const box=el.querySelector(".mx-calc"), on=box.hidden;
  box.hidden=!on; el.querySelector('[data-do="calc"]').setAttribute("aria-pressed",on);
  if(on){ calcDraw(); calcPlace(); box.querySelector("button").focus({preventScroll:true,focusVisible:false}); }
}
document.addEventListener("keydown",e=>{
  const box=el?.querySelector(".mx-calc");
  if(!box||box.hidden||!box.contains(document.activeElement)||e.ctrlKey||e.metaKey||e.altKey) return;
  const k={"*":"ร—","/":"รท","-":"โˆ’"}[e.key]||e.key;
  if(/^[0-9.+โˆ’ร—รท()^!%]$/.test(k)) calcAct(k);
  else if(k==="Enter"||k==="=") calcAct("@eq");
  else if(k==="Backspace") calcAct("@del");
  else if(k==="Escape"&&(cs.expr||cs.res)) calcAct("@ac");
  else return;
  e.preventDefault(); e.stopImmediatePropagation();
},true);

function build(){
  el=document.createElement("aside"); el.className="mx-board"; el.setAttribute("aria-label","ืœื•ื— ื˜ื™ื•ื˜ื”");
  el.innerHTML=`<div class="mx-board-head"><b data-title>ืœื•ื— ื˜ื™ื•ื˜ื”</b><button type="button" data-do="hide" class="mx-board-back">${ic("back")}<span>ื—ื–ืจื” ืœืฉืืœื”</span></button></div>
    <div class="mx-board-bar" role="toolbar" aria-label="ื›ืœื™ ื”ืœื•ื—">
      <span class="mx-board-group">${TOOLS.map(([t,name])=>`<button type="button" data-tool="${t}" aria-pressed="${t===tool}" title="${name}">${ic(t)}<span>${name}</span></button>`).join("")}</span>
      <span class="mx-board-group mx-board-inks">${INKS.map(([c,name])=>`<button type="button" data-ink="${c}" aria-pressed="${c===ink}" aria-label="${name}" title="${name}" style="--dot:${c}"></button>`).join("")}</span>
      <span class="mx-board-group mx-board-sizes">${ERASERS.map(([w,name])=>`<button type="button" data-eraser="${w}" aria-pressed="${w===eraser}" aria-label="ืžื—ืง ${name}" title="ืžื—ืง ${name}" style="--d:${Math.round(6+w/6)}px"></button>`).join("")}</span>
      <span class="mx-board-group mx-board-acts">
        <button type="button" data-do="calc" aria-pressed="false" title="ืžื—ืฉื‘ื•ืŸ">${ic("calc")}<span>ืžื—ืฉื‘ื•ืŸ</span></button>
        <button type="button" data-do="question" title="ื”ืขืชืงืช ื”ืฉืืœื” ืœืจืืฉ ื”ืœื•ื—">${ic("question")}<span>ื”ืฉืืœื”</span></button>
        <button type="button" data-do="undo" title="ื‘ื™ื˜ื•ืœ (Ctrl+Z)">${ic("undo")}<span>ื‘ื™ื˜ื•ืœ</span></button>
        <button type="button" data-do="clear" title="ื ื™ืงื•ื™ ื”ืœื•ื—">${ic("clear")}<span>ื ื™ืงื•ื™</span></button></span>
      <button type="button" data-do="hide" class="mx-board-close" aria-label="ืกื’ื™ืจืช ื”ืœื•ื—" title="ืกื’ื™ืจืช ื”ืœื•ื—">${ic("close")}</button>
    </div><button type="button" data-do="door" class="mx-board-door" aria-expanded="false" title="ื”ืจื—ื‘ืช ื”ืœื•ื—" aria-label="ื”ืจื—ื‘ืช ื”ืœื•ื—"></button>
    <div class="mx-board-row"><div class="mx-board-pages" role="tablist" aria-label="ื”ื“ืฃ ืฉืœ ื›ืœ ืฉืืœื”"></div>
      <button type="button" data-do="send" class="mx-board-send" hidden></button></div>
    <div class="mx-board-stage"><canvas></canvas>
      <button type="button" data-do="delete" class="mx-board-del" title="ืžื—ื™ืงืช ืžื” ืฉื ื‘ื—ืจ (Delete)" hidden>${ic("clear")}ืžื—ื™ืงื”</button>
      <button type="button" data-do="top" class="mx-board-top" hidden>${ic("top")}ืœืชื—ื™ืœืช ื”ื“ืฃ</button>
      <div class="mx-calc" dir="ltr" role="group" aria-label="ืžื—ืฉื‘ื•ืŸ ืžื“ืขื™" hidden>
        <div class="mx-calc-grip" title="ื’ืจืจื• ื›ื“ื™ ืœื”ื–ื™ื– ืืช ื”ืžื—ืฉื‘ื•ืŸ" aria-hidden="true"></div>
        <div class="mx-calc-screen"><span class="mx-calc-flags" data-calc-flags></span><div class="mx-calc-expr" data-calc-expr>0</div><output data-calc-out>&nbsp;</output></div>
        <div class="mx-calc-keys">${calcKeysHtml()}</div></div></div>`;
  canvas=el.querySelector("canvas"); ctx=canvas.getContext("2d");
  calcGrip(); doorGrip();
  el.addEventListener("click",e=>{
    const b=e.target.closest("button"); if(!b) return;
    if(b.dataset.tool) setTool(b.dataset.tool);
    if(b.dataset.ink){ ink=b.dataset.ink; if(tool==="eraser"||tool==="move") setTool("pen"); pressed("[data-ink]",b); }
    if(b.dataset.eraser){ eraser=+b.dataset.eraser; setTool("eraser"); pressed("[data-eraser]",b); }
    if(b.dataset.page){ turn(+b.dataset.page); onPage?.(page); }
    const act=b.dataset.do;
    if(act==="undo") undo();
    if(act==="clear"&&marks().some(m=>m.kind!=="card")){ remember(); const keep=marks().filter(m=>m.kind==="card"); marks().length=0; marks().push(...keep); select([]); paint(); drawPages(); }   // the student's work goes; the question stays
    if(act==="delete") removeSelection();
    if(act==="question") copyQuestion();
    if(act==="send") send();
    if(b.dataset.calc) calcPress(+b.dataset.calc);
    if(act==="calc") toggleCalc();
    if(act==="top") scrollTo(0,0);
    if(act==="door"&&!doorDragged) setDoor(doorOpen()?null:DOOR_OPEN);
    doorDragged=false;
    if(act==="hide") full?closeFull():toggle();
  });
  canvas.addEventListener("pointerdown",down);
  canvas.addEventListener("pointermove",move);
  canvas.addEventListener("pointerup",up);
  canvas.addEventListener("pointercancel",up);
  canvas.addEventListener("contextmenu",e=>e.preventDefault());
  // Safari on an iPad: a pen or a finger held still on the paper would select the page's text and offer to copy it
  canvas.addEventListener("touchstart",e=>e.preventDefault(),{passive:false});
  el.addEventListener("selectstart",e=>{ if(!e.target.closest?.("input,math-field,[contenteditable]")) e.preventDefault(); });
  canvas.addEventListener("dblclick",e=>{ const m=hit(at(e)); if(m?.kind==="math") editMath(m,at(e)); else if(m?.kind==="angle") startAngle(at(e),0); });
  canvas.addEventListener("pointerleave",()=>{ if(hover){ hover=null; paint(); } });   // typed math is edited where it is   // the pen's side button: no browser menu over the board
  canvas.addEventListener("wheel",e=>{   // endless paper: scrolls down as far as needed, never above the top
    if(e.ctrlKey) return;   // the browser's zoom
    e.preventDefault(); const s=sketch(), k=e.deltaMode===1?24:e.deltaMode===2?canvas.clientHeight:1;
    let dx=e.deltaX*k, dy=e.deltaY*k; if(e.shiftKey&&!dx){ dx=dy; dy=0; }   // Shift and the wheel: sideways
    scrollTo(s.x+dx,s.y+dy);
  },{passive:false});
  new ResizeObserver(fit).observe(canvas);
  setTool(tool);
}
/* the board's right edge slides like a closet door: a tap on its handle opens the board over the exercise and
   closes it back; dragging the handle stops it anywhere between. The CSS keeps it between the two
   (--door: the gap on the board's right, in px). It stays as it was left while the page is open. */
const DOOR_OPEN=16;
const doorShut=()=>Math.min(600,innerWidth*.42)+32;   // the gap beside the exercise: the same sum as math.css
const doorOpen=()=>innerWidth-el.getBoundingClientRect().right<doorShut()-1;
let doorDragged=false;
function setDoor(r){
  if(r===null) el.style.removeProperty("--door"); else el.style.setProperty("--door",r+"px");
  const b=el.querySelector(".mx-board-door"), open=r!==null&&r<doorShut()-1, t=open?"ื”ื—ื–ืจืช ื”ืœื•ื— ืœืžืงื•ืžื•":"ื”ืจื—ื‘ืช ื”ืœื•ื—";
  b.setAttribute("aria-expanded",open); b.title=t; b.setAttribute("aria-label",t); el.classList.toggle("wide",open);
}
function doorGrip(){
  const b=el.querySelector(".mx-board-door"); let from=null;
  b.addEventListener("pointerdown",e=>{
    e.preventDefault(); try{ b.setPointerCapture(e.pointerId); }catch{}
    from={id:e.pointerId,x:e.clientX,r:innerWidth-el.getBoundingClientRect().right,moved:false};
  });
  b.addEventListener("pointermove",e=>{
    if(from?.id!==e.pointerId) return; const dx=e.clientX-from.x;
    if(!from.moved&&Math.abs(dx)<6) return;
    if(!from.moved){ from.moved=true; el.classList.add("sliding"); }
    setDoor(Math.max(DOOR_OPEN,Math.min(doorShut(),from.r-dx)));
  });
  const end=e=>{
    if(from?.id!==e.pointerId) return; const f=from; from=null; el.classList.remove("sliding");
    if(!f.moved) return; doorDragged=true;   // the click that follows is not a tap
    const r=innerWidth-el.getBoundingClientRect().right;
    setDoor(r>=doorShut()-24?null:r<=DOOR_OPEN+24?DOOR_OPEN:r);   // near either end: all the way
  };
  b.addEventListener("pointerup",end); b.addEventListener("pointercancel",end);
}
function pressed(sel,b){ el.querySelectorAll(sel).forEach(x=>x.setAttribute("aria-pressed",x===b)); }
function reset(){ draft=drag=rub=slide=null; hover=null; select([]); if(editing) finishMath(false); if(naming) finishAngle(true); }
function setTool(t){
  tool=t; if(hover){ hover=null; paint(); } el.querySelectorAll("[data-tool]").forEach(x=>x.setAttribute("aria-pressed",x.dataset.tool===t));
  el.dataset.tool=t; if(t!=="move"&&selection.length){ select([]); paint(); }
  canvas.style.cursor=cursorFor(t);
}
// the eraser's cursor is a ring its own size, so the student sees what it will wipe
function cursorFor(t){
  const r=eraser/2, svg=`<svg xmlns='http://www.w3.org/2000/svg' width='${eraser+2}' height='${eraser+2}'><circle cx='${r+1}' cy='${r+1}' r='${r}' fill='rgba(255,255,255,.35)' stroke='%231B2140' stroke-width='1.2'/></svg>`;
  return t==="eraser"?`url("data:image/svg+xml,${svg.replace(/</g,"%3C").replace(/>/g,"%3E")}") ${r+1} ${r+1}, cell`:t==="move"?"grab":"";
}
function select(list){ selection=list; }
function turn(p){ if(editing) finishMath(true); if(naming) finishAngle(true); page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); autoQuestion(); }
function drawSend(){
  const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
  b.disabled=false; b.innerHTML=`${ic("send")}<span>${sendLabel||`ื”ื’ืฉื” ืœืฉืืœื” ${page+1}`}</span>`; b.title=sendLabel||`ื”ื’ืฉืช ื”ืœื•ื— ื›ืชืฉื•ื‘ื” ืœืฉืืœื” ${page+1}`;
}
function drawPages(){
  const box=el.querySelector(".mx-board-pages"); el.querySelector(".mx-board-row").hidden=pages<2&&!hand;
  box.innerHTML=pages<2?"":`<span>ืฉืืœื”</span>${Array.from({length:pages},(_,i)=>{
    const has=(sketches.get(`${base}:${i}`)?.marks||[]).length>0;
    return `<button type="button" role="tab" data-page="${i}" aria-selected="${i===page}" class="${has?"has":""}">${i+1}</button>`; }).join("")}`;
}

/* ---------- drawing ---------- */
function fit(){
  if(!shown()) return;
  const d=devicePixelRatio||1, w=Math.round(canvas.clientWidth*d), h=Math.round(canvas.clientHeight*d);
  if(canvas.width!==w||canvas.height!==h){ canvas.width=w; canvas.height=h; }
  paint();
}
function paint(){
  if(!ctx) return;
  const d=devicePixelRatio||1, {x,y}=sketch(); ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,canvas.width,canvas.height); ctx.setTransform(d,0,0,d,-x*d,-y*d);
  canvas.style.backgroundPosition=`${-x}px ${-y}px`;   // the grid scrolls with the paper
  el.querySelector(".mx-board-top").hidden=x<1&&y<1;
  marks().forEach(draw);
  if(draft?.kind==="circle") guide(draft);
  if(hover&&!naming) aimed(hover);
  if(selection.length) outline(selection);
  const del=el.querySelector(".mx-board-del"); del.hidden=!selection.length||!!drag?.before;
  if(selection.length){ const [x0,y0]=edges(selection); del.style.left=`${Math.max(4,x0)}px`; const top=y0-y; del.style.top=`${top>40?top-36:Math.max(4,top+4)}px`; }
}
function draw(m){
  ctx.save(); ctx.strokeStyle=m.color; ctx.lineCap="round"; ctx.lineJoin="round";
  if(m.kind==="line"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke(); }
  else if(m.kind==="circle"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.arc(m.a[0],m.a[1],dist(m.a,m.b),0,Math.PI*2); ctx.stroke(); }
  else if(m.kind==="angle") angleMark(m);
  else if(m.kind==="card") card(m);
  else if(m.kind==="math"){ const L=mathBox(m); if(L){ ctx.fillStyle=m.color; ctx.direction="ltr"; ctx.textAlign="left"; ctx.textBaseline="alphabetic"; L.paint(ctx,m.x,m.y+L.a); } }
  else{ for(let i=1;i<m.pts.length;i++) segment(m,i); if(m.pts.length===1) segment(m,0); }
  ctx.restore();
}
// one piece of a pen stroke, from point i-1 to point i; a pen's pressure sets the width
function segment(m,i){
  const a=m.pts[Math.max(0,i-1)], b=m.pts[i];
  ctx.lineWidth=PEN*(.35+1.3*(a[2]+b[2])/2);
  ctx.beginPath(); ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0]+(i?0:.01),b[1]); ctx.stroke();
}
// while a circle is dragged: its center and radius, faintly, so it can be placed exactly
function guide(m){
  ctx.save(); ctx.strokeStyle=ctx.fillStyle="rgb(27 33 64/.45)"; ctx.lineWidth=1; ctx.setLineDash([4,4]);
  ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke();
  ctx.setLineDash([]); ctx.beginPath(); ctx.arc(m.a[0],m.a[1],2.5,0,Math.PI*2); ctx.fill(); ctx.restore();
}
// an angle: the arc between its two edges, and its words just outside the arc (hidden while they are typed)
const arcsOf=m=>naming?.mark===m?naming.arcs:m.arcs||1;   // while its box is open: the arcs picked there
// a right angle is marked with a little square in its corner: its sides along the edges, its far corner on the line splitting the angle
const square=m=>{
  const k=Math.max(8,Math.min(14,arcR(m)*.5)), u=[Math.cos(m.s),Math.sin(m.s)], w=[Math.cos(m.s+m.w),Math.sin(m.s+m.w)];
  return [[m.v[0]+k*u[0],m.v[1]+k*u[1]],[m.v[0]+k*(u[0]+w[0]),m.v[1]+k*(u[1]+w[1])],[m.v[0]+k*w[0],m.v[1]+k*w[1]]];
};
const rim=m=>arcsOf(m)==="right"?dist(square(m)[1],m.v):arcR(m)+ARC_GAP*(arcsOf(m)-1);   // how far out the mark reaches
const colorOf=m=>naming?.mark===m?naming.color:m.color;   // while its box is open: the colour picked there
function angleMark(m){
  ctx.lineWidth=1.8; ctx.strokeStyle=colorOf(m);
  if(arcsOf(m)==="right"){ const [a,b,c]=square(m); ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.lineTo(...c); ctx.stroke(); }
  else for(let k=0;k<arcsOf(m);k++){ ctx.beginPath(); ctx.arc(m.v[0],m.v[1],arcR(m)+k*ARC_GAP,m.s,m.s+m.w); ctx.stroke(); }
  if(!m.text||naming?.mark===m) return;
  const [x,y]=labelAt(m); ctx.font=ANGLE_FONT; ctx.direction="ltr"; ctx.textAlign="center"; ctx.textBaseline="middle";
  ctx.strokeStyle=paper; ctx.lineWidth=5; ctx.strokeText(m.text,x,y);   // a rim of paper, where the words cross a line
  ctx.fillStyle=m.color; ctx.fillText(m.text,x,y);
}
// the corner the angle tool points at: the arc it would add (or the angle it would retype), dashed
function aimed(c){
  ctx.save(); ctx.strokeStyle=ctx.fillStyle=SELECT; ctx.lineWidth=2; ctx.setLineDash([4,3]);
  ctx.beginPath(); ctx.arc(c.v[0],c.v[1],c.r,c.s,c.s+c.w); ctx.stroke();
  ctx.setLineDash([]); ctx.beginPath(); ctx.arc(c.v[0],c.v[1],3.5,0,TAU); ctx.fill(); ctx.restore();
}
function card(m){
  ctx.fillStyle="#EEF1FB"; ctx.strokeStyle=PAPER_FAINT; ctx.lineWidth=1;
  ctx.beginPath(); ctx.roundRect(m.x,m.y,m.w,m.h,12); ctx.fill(); ctx.stroke();
  ctx.fillStyle=PAPER_INK; ctx.font=FONT; ctx.direction="rtl"; ctx.textAlign="right"; ctx.textBaseline="top";
  m.lines.forEach((line,i)=>ctx.fillText(line,m.x+m.w-CARD_PAD,m.y+CARD_PAD+i*LINE_H+4));
  for(const f of m.figs){ const img=picture(f.src); if(img.complete&&img.naturalWidth) ctx.drawImage(img,m.x+f.x,m.y+f.y,f.w,f.h); }
}
function picture(src){
  if(!images.has(src)){ const img=new Image(); img.onload=()=>paint(); img.src=src; images.set(src,img); }
  return images.get(src);
}
const mathBox=m=>window.MathlyBoardMath?.layout(m.latex,m.size||MATH_SIZE);
function box(m){
  if(m.kind==="card") return [m.x,m.y,m.x+m.w,m.y+m.h];
  if(m.kind==="math"){ const L=mathBox(m)||{w:0,a:0,d:0}; return [m.x,m.y,m.x+L.w,m.y+L.a+L.d]; }
  if(m.kind==="angle"){ const [x,y,w]=labelAt(m), r=rim(m); return [Math.min(m.v[0]-r,x-w/2),Math.min(m.v[1]-r,y-10),Math.max(m.v[0]+r,x+w/2),Math.max(m.v[1]+r,y+10)]; }
  if(m.kind==="circle"){ const r=dist(m.a,m.b); return [m.a[0]-r,m.a[1]-r,m.a[0]+r,m.a[1]+r]; }
  const pts=m.kind==="line"?[m.a,m.b]:m.pts, xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
  return [Math.min(...xs),Math.min(...ys),Math.max(...xs),Math.max(...ys)];
}
// the box around the elements, a little outside their ink
function edges(list){ const bs=list.map(box); return [Math.min(...bs.map(b=>b[0]))-6,Math.min(...bs.map(b=>b[1]))-6,Math.max(...bs.map(b=>b[2]))+6,Math.max(...bs.map(b=>b[3]))+6]; }
function outline(list){
  const [x0,y0,x1,y1]=edges(list);
  ctx.save(); ctx.strokeStyle=SELECT; ctx.lineWidth=1.5; ctx.setLineDash([6,4]); ctx.strokeRect(x0,y0,x1-x0,y1-y0); ctx.restore();
}

/* ---------- geometry ---------- */
function nearestOn(p,a,b){
  const dx=b[0]-a[0], dy=b[1]-a[1], l=dx*dx+dy*dy, t=l?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/l)):0;
  return [a[0]+t*dx,a[1]+t*dy];
}
const toSegment=(p,a,b)=>dist(p,nearestOn(p,a,b));
// how far p is from the element's ink
function reach(m,p){
  if(m.kind==="line") return toSegment(p,m.a,m.b);
  if(m.kind==="circle") return Math.min(Math.abs(dist(p,m.a)-dist(m.a,m.b)),dist(p,m.a));
  if(m.kind==="angle"&&arcsOf(m)==="right"){ const [a,b,c]=square(m); return Math.min(toSegment(p,a,b),toSegment(p,b,c),toWords(m,p)); }
  if(m.kind==="angle") return Math.min(inWedge(Math.atan2(p[1]-m.v[1],p[0]-m.v[0]),m.s,m.w)?Math.max(0,Math.abs(dist(p,m.v)-(arcR(m)+rim(m))/2)-(rim(m)-arcR(m))/2):Infinity,toWords(m,p));
  if(m.kind==="card"||m.kind==="math"){ const [x0,y0,x1,y1]=box(m), dx=Math.max(x0-p[0],0,p[0]-x1), dy=Math.max(y0-p[1],0,p[1]-y1);
    return m.kind==="card"?(dx||dy?Infinity:0):Math.hypot(dx,dy); }
  if(m.pts.length===1) return dist(p,m.pts[0]);
  let d=Infinity; for(let i=1;i<m.pts.length;i++) d=Math.min(d,toSegment(p,m.pts[i-1],m.pts[i])); return d;
}
const hit=p=>{ const all=marks(); for(let i=all.length-1;i>=0;i--) if(reach(all[i],p)<=GRAB) return all[i]; return null; };
const joints=m=>m.kind==="line"?[m.a,m.b]:m.kind==="circle"?[m.a]:m.kind==="angle"?[m.v]:[];   // an angle moves with its corner
// an element and everything joined to it by a shared end or center: a triangle moves as one
function group(m){
  const out=new Set([m]), todo=[m];
  while(todo.length){ const cur=todo.pop();
    for(const o of marks()) if(!out.has(o)&&linked(cur,o)){ out.add(o); todo.push(o); } }
  return [...out];
}
// joined: a shared end or center, a line's end on another line (drawn from a side), or an angle on a line through it
const lies=(m,o)=>o.kind==="line"&&(m.kind==="line"||m.kind==="angle")&&joints(m).some(p=>toSegment(p,o.a,o.b)<=ON);
const linked=(m,o)=>joints(m).some(p=>joints(o).some(q=>same(p,q)))||lies(m,o)||lies(o,m);
// a line's ends and a circle's center pull to the ends and centers already on the board, or else onto a line
function snap(p,skip){
  let best=null, d=SNAP;
  for(const m of marks()){ if(m===skip) continue; for(const q of joints(m)){ const k=dist(p,q); if(k<d){ d=k; best=q; } } }
  if(!best) for(const m of marks()) if(m!==skip&&m.kind==="line"){ const q=nearestOn(p,m.a,m.b), k=dist(p,q); if(k<d){ d=k; best=q; } }
  return best?[best[0],best[1]]:[p[0],p[1]];
}
function shift(m,dx,dy){
  const by=p=>{ p[0]+=dx; p[1]+=dy; };
  if(m.kind==="card"||m.kind==="math"){ m.x+=dx; m.y+=dy; } else if(m.kind==="free") m.pts.forEach(by); else if(m.kind==="angle") by(m.v); else { by(m.a); by(m.b); }
}
// the eraser at p: lines, circles and typed math it touches go; pen strokes are cut; the question's copy stays
function rubOut(p){
  const r=eraser/2, all=marks(); let changed=false;
  for(let i=all.length-1;i>=0;i--){ const m=all[i];
    if(m.kind==="line"||m.kind==="circle"||m.kind==="math"||m.kind==="angle"){ if(reach(m,p)<=r+SHAPE/2){ all.splice(i,1); changed=true; } continue; }
    if(m.kind!=="free") continue;
    const gone=m.pts.map(q=>dist(p,q)<=r+PEN); if(!gone.some(Boolean)) continue;
    const runs=[]; let run=[]; m.pts.forEach((q,k)=>{ if(gone[k]){ if(run.length) runs.push(run); run=[]; } else run.push(q); }); if(run.length) runs.push(run);
    all.splice(i,1,...runs.map(pts=>({kind:"free",color:m.color,pts}))); changed=true;
  }
  return changed;
}
/* ---------- angles at corners ---------- */
const TAU=Math.PI*2, turnOf=a=>(a%TAU+TAU)%TAU, inWedge=(t,s,w)=>turnOf(t-s)<=w;
const gap=(a,b)=>Math.abs(turnOf(a-b+Math.PI)-Math.PI);
// where two lines cross, or null (an end on the other line counts)
function cross(m,o){
  const r=[m.b[0]-m.a[0],m.b[1]-m.a[1]], q=[o.b[0]-o.a[0],o.b[1]-o.a[1]], den=r[0]*q[1]-r[1]*q[0];
  if(Math.abs(den)<1e-9) return null;
  const ex=o.a[0]-m.a[0], ey=o.a[1]-m.a[1], t=(ex*q[1]-ey*q[0])/den, u=(ex*r[1]-ey*r[0])/den, k=.01;
  return t>=-k&&t<=1+k&&u>=-k&&u<=1+k?[m.a[0]+t*r[0],m.a[1]+t*r[1]]:null;
}
// the points where lines meet: shared ends, an end partway along another line, and crossings
function meets(){
  const lines=marks().filter(m=>m.kind==="line"), out=lines.flatMap(m=>[m.a,m.b]);
  lines.forEach((m,i)=>lines.slice(i+1).forEach(o=>{ const c=cross(m,o); if(c) out.push(c); }));
  return out;
}
// every line through v, as rays going out of v: one for a line ending there, two for a line passing through
function raysAt(v){
  const rays=[], add=b=>{
    if(dist(b,v)<=ON) return;
    const t=turnOf(Math.atan2(b[1]-v[1],b[0]-v[0])), len=dist(v,b), twin=rays.find(r=>gap(r.t,t)<.02);
    if(twin) twin.len=Math.min(twin.len,len); else rays.push({t,len});
  };
  for(const m of marks()) if(m.kind==="line"){
    if(dist(m.a,v)<=ON) add(m.b); else if(dist(m.b,v)<=ON) add(m.a); else if(toSegment(v,m.a,m.b)<=ON){ add(m.a); add(m.b); }
  }
  return rays.sort((a,b)=>a.t-b.t);
}
/* A corner near p where lines meet, and the angle (under half a turn) between two of its rays that p points into:
   the inside of a triangle's corner, either side of a line drawn from a side, or the smallest one when p is on the
   corner itself. Pointing along a line that splits an angle picks the whole angle, its arc a little wider, and
   its words go in the widest gap the inner lines leave. */
function corner(p,near){
  let v=null, d=near;
  for(const q of meets()){ const k=dist(p,q); if(k<d){ d=k; v=q; } }
  if(!v) return null;
  const rays=raysAt(v); if(rays.length<2) return null;
  const wedges=[];
  rays.forEach(r=>rays.forEach(n=>{
    const w=turnOf(n.t-r.t); if(r===n||w<.02||w>Math.PI-.01) return;
    wedges.push({s:r.t,w,len:Math.min(r.len,n.len),inner:rays.filter(x=>x!==r&&x!==n&&turnOf(x.t-r.t)<w)});
  }));
  if(!wedges.length) return null;
  wedges.sort((a,b)=>a.w-b.w);
  const t=Math.atan2(p[1]-v[1],p[0]-v[0]), far=dist(p,v)>6, along=far&&rays.find(r=>gap(r.t,t)<.1);
  const off=x=>inWedge(t,x.s,x.w)?0:Math.min(gap(t,x.s),gap(t,x.s+x.w))+(x.inner.length?1:0);   // outside them all: the nearest
  const w=(along&&wedges.find(x=>x.inner.includes(along)))||(far&&wedges.reduce((a,b)=>off(b)<off(a)?b:a))||wedges[0];
  const cuts=[0,...w.inner.map(x=>turnOf(x.t-w.s)).sort((a,b)=>a-b),w.w];
  let f=w.w/2, g=w.w;
  if(w.inner.length){ g=0; for(let k=1;k<cuts.length;k++) if(cuts[k]-cuts[k-1]>g){ g=cuts[k]-cuts[k-1]; f=(cuts[k]+cuts[k-1])/2; } }
  return {v:[v[0],v[1]],s:w.s,w:w.w,r:Math.max(12,Math.min(30,w.len*.3))+10*w.inner.length,f:w.s+f};
}
// what the angle tool would act on at p: an angle already there (to retype its words), or a corner to add one to
// (an angle's words come first; then the corner decides, so pointing along a middle line over a half's arc still
// gives the whole angle; an arc whose corner is gone is still there to retype)
function aim(p,near){
  const all=marks().filter(m=>m.kind==="angle").reverse(), as=m=>({mark:m,v:m.v,s:m.s,w:m.w,r:arcR(m)});
  const named=all.find(m=>toWords(m,p)<=4); if(named) return as(named);
  const c=corner(p,near);
  if(c){ c.mark=all.find(m=>dist(m.v,c.v)<=ON&&gap(m.s,c.s)<.05&&Math.abs(m.w-c.w)<.05)||null; if(c.mark) c.r=arcR(c.mark); return c; }
  const old=all.find(m=>reach(m,p)<=GRAB); return old?as(old):null;
}
// how far p is from an angle's words
function toWords(m,p){
  if(!m.text) return Infinity;
  const [x,y,w]=labelAt(m); return Math.hypot(Math.max(Math.abs(p[0]-x)-w/2,0),Math.max(Math.abs(p[1]-y)-10,0));
}
// where an angle's words sit: on the line splitting the angle, just past the arc, and far enough out to clear the edges
// (right by the arc, even in a narrow angle: they are drawn over a line's ink with a rim of paper)
function labelAt(m,text=m.text){
  ctx.save(); ctx.font=ANGLE_FONT; const w=text?ctx.measureText(text).width:0; ctx.restore();
  const f=m.f??m.s+m.w/2, out=Math.abs(w/2*Math.cos(f))+Math.abs(10*Math.sin(f)), c=Math.cos(f), s=Math.sin(f);   // f: the words' way
  // words of an angle beside it at the same corner, added before it: these move out until they are clear of them
  const all=marks(), i=all.indexOf(m), mine=inside(m);
  const near=i<0?[]:all.slice(0,i).filter(o=>o.kind==="angle"&&o.text&&dist(o.v,m.v)<=ON&&!mine.includes(o)&&!inside(o).includes(m)).map(o=>labelAt(o));
  let d=rim(m)+5+out;
  for(let k=0;k<30&&near.some(([x,y,ow])=>Math.abs(m.v[0]+d*c-x)<(w+ow)/2+4&&Math.abs(m.v[1]+d*s-y)<20);k++) d+=3;
  return [m.v[0]+d*c,m.v[1]+d*s,w,out];
}
// the angles inside m at its corner (the halves of an angle a line splits)
const inside=m=>marks().filter(o=>o!==m&&o.kind==="angle"&&dist(o.v,m.v)<=ON&&o.w<m.w-.01&&turnOf(o.s-m.s)<=m.w-o.w+.01);
// an angle's arc goes around the arcs and words of the angles inside it, so each angle's words sit by its own arc
function arcR(m){
  let r=m.r;
  for(const o of inside(m)){ r=Math.max(r,rim(o)+8); if(o.text){ const [x,y,,out]=labelAt(o); r=Math.max(r,dist([x,y],m.v)+out+4); } }
  return r;
}
// a number on its own or added to the rest ("60", "ฮฒ+90", "180-ฮฑ-ฮฒ") is a size in degrees; a factor ("2ฮฒ") is not
const TERM=/(^|[+\-โˆ’=]\s*)(\d+(?:[.,]\d+)?)ยฐ?(?=\s*(?:$|[+\-โˆ’=]))/g;
const NAMES={alpha:"ฮฑ",beta:"ฮฒ",gamma:"ฮณ"};
const degrees=v=>v.trim().replace(/(?<![a-z])(alpha|beta|gamma)(?![a-z])/gi,w=>NAMES[w.toLowerCase()]).replace(TERM,"$1$2ยฐ");
const bare=t=>t.replace(TERM,"$1$2");   // for retyping: the signs degrees() puts back by itself
// adds the angle at p (or opens one already there), with a box over its words
function startAngle(p,near){
  const c=aim(p,near); if(!c) return;
  hover=null;
  let m=c.mark; const fresh=!m;
  if(fresh){ remember(); m={kind:"angle",color:ink,v:c.v,s:c.s,w:c.w,r:c.r,f:c.f,text:""}; marks().push(m); }
  // the box for the words, with the usual names of a triangle's angles a tap away
  const node=document.createElement("div"); node.className="mx-board-angleedit";
  // two rows, to fit a phone: the words and the letters; the arcs and the colour
  node.innerHTML=`<div class="mx-board-anglerow"><input type="text" class="mx-board-anglein" dir="auto" maxlength="16" placeholder="ยฐ" enterkeyhint="done"
      aria-label="ืžื” ื›ืชื•ื‘ ื‘ื–ื•ื•ื™ืช (ืžืกืคืจ ืžืงื‘ืœ ืกื™ืžืŸ ืžืขืœื•ืช)">${GREEK.map(g=>`<button type="button" tabindex="-1" data-letter title="${g}">${g}</button>`).join("")}</div>
    <div class="mx-board-anglerow"><span class="mx-board-arcs">${ARCS.map(([n,name])=>`<button type="button" tabindex="-1" data-arcs="${n}" aria-pressed="${n===(m.arcs||1)}" title="${name}" aria-label="${name}">${arcsIcon(n)}</button>`).join("")}</span>
    <span class="mx-board-colors">${INKS.map(([c,name])=>`<button type="button" tabindex="-1" data-color="${c}" aria-pressed="${c===m.color}" title="${name}" aria-label="${name}" style="--dot:${c}"></button>`).join("")}</span></div>`;
  const input=node.querySelector("input"); input.value=bare(m.text);
  naming={mark:m,node,input,fresh,was:m.text,arcs:m.arcs||1,color:m.color,key};
  input.addEventListener("keydown",ev=>{
    if(ev.key==="Enter"){ ev.preventDefault(); finishAngle(true); }
    else if(ev.key==="Escape"){ ev.preventDefault(); ev.stopPropagation(); finishAngle(false); }
  });
  input.addEventListener("blur",()=>naming?.node===node&&finishAngle(true));
  // a letter's button types it where the cursor is ("ฮฒ+90" takes a tap and a few keys); the box keeps its focus
  node.querySelectorAll("[data-letter]").forEach(b=>b.addEventListener("pointerdown",ev=>{
    ev.preventDefault(); input.setRangeText(b.textContent,input.selectionStart??input.value.length,input.selectionEnd??input.value.length,"end");
  }));
  // one, two or three arcs: shown at once, kept with the words
  node.querySelectorAll("[data-arcs]").forEach(b=>b.addEventListener("pointerdown",ev=>{
    ev.preventDefault(); naming.arcs=b.dataset.arcs==="right"?"right":+b.dataset.arcs; node.querySelectorAll("[data-arcs]").forEach(x=>x.setAttribute("aria-pressed",x===b)); paint();
  }));
  // the angle's colour, its arcs and words together: shown at once on the arcs and in the box, kept with the words
  const tint=()=>{ input.style.color=naming.color; paint(); };
  node.querySelectorAll("[data-color]").forEach(b=>b.addEventListener("pointerdown",ev=>{
    ev.preventDefault(); naming.color=b.dataset.color; node.querySelectorAll("[data-color]").forEach(x=>x.setAttribute("aria-pressed",x===b)); tint();
  }));
  input.style.color=m.color;
  el.querySelector(".mx-board-stage").append(node);
  const [x,y]=labelAt(m,"000ยฐ"), W=canvas.clientWidth, bw=node.offsetWidth;
  node.style.left=`${Math.max(4,Math.min(W-4-bw,x-sketch().x-bw/2))}px`; node.style.top=`${Math.max(4,y-sketch().y-21)}px`;
  paint(); input.focus({preventScroll:true});
  if(pages>1) drawPages();
}
// keep: the words go in (a new angle with none keeps just its arc); otherwise a new angle is taken back
function finishAngle(keep){
  const n=naming; if(!n) return; naming=null; n.node.remove();
  const s=sketches.get(n.key), m=n.mark; if(!s) return;
  if(keep){
    const t=degrees(n.input.value); if(!n.fresh&&(t!==n.was||n.arcs!==(m.arcs||1)||n.color!==m.color)) remember();
    m.text=t; m.color=n.color; if(n.arcs!==1) m.arcs=n.arcs; else delete m.arcs;   // 2, 3, or "right"
  }
  else if(n.fresh){ const i=s.marks.indexOf(m); if(i>=0){ s.marks.splice(i,1); s.past.pop(); } }
  paint(); if(pages>1) drawPages();
}

// points along a to b, close enough that nothing between two of them is skipped
function along(a,b,step){ const n=Math.max(1,Math.ceil(dist(a,b)/step)), out=[]; for(let k=1;k<=n;k++) out.push([a[0]+(b[0]-a[0])*k/n,a[1]+(b[1]-a[1])*k/n,a[2]+((b[2]??a[2])-a[2])*k/n]); return out; }

/* ---------- the pointer ---------- */
const at=e=>{ const r=canvas.getBoundingClientRect(); return [e.clientX-r.left+sketch().x,e.clientY-r.top+sketch().y,e.pointerType==="pen"?e.pressure:.5]; };   // a point on the paper, not on the screen
const capture=id=>{ try{ canvas.setPointerCapture(id); }catch{} };   // a pointer the browser no longer knows: draw anyway
const fingers=()=>{ let x=0,y=0; touches.forEach(p=>{ x+=p[0]; y+=p[1]; }); return [x/touches.size,y/touches.size]; };   // where the fingers are, together
// a second finger came down: what the first one started is taken back, and the two scroll the paper
function stopLive(){
  const s=sketch();
  if(draft){ s.marks.splice(s.marks.indexOf(draft),1); s.past.pop(); draft=null; }
  if(rub){ s.marks=rub.before; rub=null; }
  if(drag){ if(drag.before) s.marks=s.past.pop(); drag=null; select([]); }
  slide=null; paint();
}
function down(e){
  if(e.pointerType==="pen"){
    penSeen=true; pensDown++;
    if(touches.size){   // the hand came down first: whatever it started is taken back, and it is a palm now
      stopLive(); touches.forEach((_,id)=>palms.add(id)); touches.clear(); pan=null;
    }
  }
  if(e.pointerType==="touch"&&penSeen&&(pensDown||performance.now()-penUpAt<PALM_AFTER_PEN||Math.max(e.width||0,e.height||0)>=PALM_SIZE)){
    palms.add(e.pointerId); e.preventDefault(); return;
  }
  if(e.pointerType==="touch"){
    touches.set(e.pointerId,[e.clientX,e.clientY]); capture(e.pointerId); e.preventDefault();
    if(touches.size===2){ stopLive(); pan={at:fingers(),x:sketch().x,y:sketch().y}; return; }
    if(touches.size>2||pan||penSeen) return;   // a hand resting on the screen while a pen writes
  }
  if(e.button!==0&&e.button!==5&&e.button!==2) return;   // 5: the pen's eraser end; 2: the right button, held, erases (also a pen's barrel button)
  const p=at(e), use=e.button===5||e.button===2||(e.buttons&(32|2))>0?"eraser":tool;
  if(editing){ e.preventDefault(); return finishMath(true); }   // a tap beside the formula being typed: it is done
  if(naming){ e.preventDefault(); return finishAngle(true); }   // the same for an angle's words
  if(use==="angle"){ e.preventDefault(); return startAngle(p,e.pointerType==="mouse"?CORNER:CORNER_TOUCH); }
  if(use==="math"){ e.preventDefault(); const m=hit(p); return editMath(m?.kind==="math"?m:null,p); }
  capture(e.pointerId); e.preventDefault();
  if(use==="move"){
    const m=hit(p);
    if(!m){ select([]); slide={id:e.pointerId,at:[e.clientX,e.clientY],x:sketch().x,y:sketch().y}; canvas.style.cursor="grabbing"; return paint(); }   // empty paper: scroll it
    if(!selection.includes(m)) select(group(m));
    drag={id:e.pointerId,last:p,before:null}; return paint();
  }
  if(use==="eraser"){ rub={id:e.pointerId,last:p,before:copy(marks()),changed:rubOut(p)}; if(tool!=="eraser") canvas.style.cursor=cursorFor("eraser"); return paint(); }
  remember();
  draft=use==="pen"?{kind:"free",color:ink,pts:[p]}:{kind:use,color:ink,a:snap(p),b:null};
  if(draft.b===null) draft.b=[...draft.a];
  draft.id=e.pointerId; marks().push(draft); paint();
}
function move(e){
  if(palms.has(e.pointerId)) return;
  if(touches.has(e.pointerId)){
    touches.set(e.pointerId,[e.clientX,e.clientY]);
    if(pan){ if(touches.size>=2){ const [fx,fy]=fingers(); scrollTo(pan.x-(fx-pan.at[0]),pan.y-(fy-pan.at[1])); } return; }
  }
  if(slide&&e.pointerId===slide.id) return scrollTo(slide.x-(e.clientX-slide.at[0]),slide.y-(e.clientY-slide.at[1]));
  if(tool==="move"&&!drag&&!e.buttons) canvas.style.cursor=hit(at(e))?"move":"grab";
  if(tool==="angle"&&!e.buttons&&e.pointerType!=="touch"){   // a mouse or a pen over a corner: its arc, dashed
    const c=naming?null:aim(at(e),CORNER), was=hover; hover=c; canvas.style.cursor=c?"pointer":"";
    if(c?.v[0]!==was?.v[0]||c?.v[1]!==was?.v[1]||c?.s!==was?.s) paint();
    return;
  }
  if(drag&&e.pointerId===drag.id){
    const p=at(e), dx=p[0]-drag.last[0], dy=p[1]-drag.last[1]; if(!dx&&!dy) return;
    if(!drag.before){ drag.before=true; remember(); }
    selection.forEach(m=>shift(m,dx,dy)); drag.last=p; return paint();
  }
  if(rub&&e.pointerId===rub.id){
    const p=at(e); for(const q of along(rub.last,p,eraser/4)) rub.changed=rubOut(q)||rub.changed;
    rub.last=p; return paint();
  }
  if(!draft||e.pointerId!==draft.id) return;
  if(draft.kind==="free"){
    const all=e.getCoalescedEvents?.();
    for(const c of all?.length?all:[e]){ const p=at(c); draft.pts.push(...along(draft.pts.at(-1),p,STEP)); }
    ctx.save(); ctx.strokeStyle=draft.color; ctx.lineCap=ctx.lineJoin="round";
    for(let i=Math.max(1,draft.drawn||1);i<draft.pts.length;i++) segment(draft,i);
    draft.drawn=draft.pts.length; ctx.restore(); return;
  }
  const p=at(e); draft.b=draft.kind==="line"?snap(p,draft):[p[0],p[1]]; paint();
}
function up(e){
  if(e.pointerType==="pen"){ pensDown=Math.max(0,pensDown-1); penUpAt=performance.now(); }
  if(palms.delete(e.pointerId)) return;
  if(touches.delete(e.pointerId)&&pan){ if(!touches.size) pan=null; return; }
  if(slide&&e.pointerId===slide.id){ slide=null; canvas.style.cursor="grab"; return; }
  if(drag&&e.pointerId===drag.id){ drag=null; return paint(); }
  if(rub&&e.pointerId===rub.id){ if(rub.changed) remember(rub.before); rub=null; canvas.style.cursor=cursorFor(tool); paint(); return pages>1&&drawPages(); }   // back to the chosen tool's cursor
  if(!draft||e.pointerId!==draft.id) return;
  const m=draft; draft=null; delete m.id; delete m.drawn;
  if(m.kind!=="free"&&dist(m.a,m.b)<3){ marks().pop(); sketch().past.pop(); }   // a tap, not a shape
  paint(); if(pages>1) drawPages();
}
function undo(){ const s=sketch(); if(!s.past.length) return; s.marks=s.past.pop(); reset(); paint(); if(pages>1) drawPages(); }
function removeSelection(){
  if(!selection.length) return;
  remember(); const all=marks(); for(const m of selection) all.splice(all.indexOf(m),1);
  select([]); paint(); if(pages>1) drawPages();
}

/* ---------- the question's copy ---------- */
// the question's words, with each piece of math kept left to right and in one piece
function words(node){
  const out=[]; let word="";
  const flush=()=>{ if(word) out.push(word); word=""; };
  (function walk(n){
    for(const c of n.childNodes){
      if(c.nodeType===3){ for(const ch of c.textContent){ if(/\s/.test(ch)) flush(); else word+=ch; } }
      else if(c.nodeName==="BR"){ flush(); out.push("\n"); }
      else if(c.classList?.contains("m")) word+="โฆ"+c.textContent.trim()+"โฉ";   // math kept left to right, stuck to the brackets and marks around it
      else walk(c);
    }
  })(node);
  flush(); return out;
}
function wrap(tokens,width){
  ctx.save(); ctx.font=FONT; ctx.direction="rtl";
  const lines=[]; let line="";
  for(const t of tokens){
    if(t==="\n"){ lines.push(line); line=""; continue; }
    const next=line?line+" "+t:t;
    if(line&&ctx.measureText(next).width>width){ lines.push(line); line=t; } else line=next;
  }
  if(line) lines.push(line); ctx.restore(); return lines;
}
// a figure drawn for the dark sheet, redrawn in ink for the white board
function paperColor(c){
  const m=String(c).match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?/); if(!m) return c;
  if(m[4]!==undefined&&+m[4]===0) return "none";
  const l=(.299*m[1]+.587*m[2]+.114*m[3])/255;
  return l>.55?PAPER_INK:l<.3?PAPER_FAINT:c;
}
const STYLE=["fill","stroke","stroke-width","stroke-dasharray","stroke-linecap","stroke-linejoin","opacity","font-size","font-family","font-weight","font-style","text-anchor","dominant-baseline"];
function paperFigure(svg){
  const r=svg.getBoundingClientRect(); if(!r.width||!r.height) return null;
  const clone=svg.cloneNode(true), from=[svg,...svg.querySelectorAll("*")], to=[clone,...clone.querySelectorAll("*")];
  from.forEach((n,i)=>{ const cs=getComputedStyle(n);
    for(const prop of STYLE){ let v=cs.getPropertyValue(prop); if(prop==="fill"||prop==="stroke") v=paperColor(v); if(v) to[i].style.setProperty(prop,v); } });
  clone.setAttribute("xmlns","http://www.w3.org/2000/svg"); clone.setAttribute("width",r.width); clone.setAttribute("height",r.height);
  clone.removeAttribute("class"); clone.style.background="none";
  return {src:"data:image/svg+xml;charset=utf-8,"+encodeURIComponent(new XMLSerializer().serializeToString(clone)),w:r.width,h:r.height};
}
function copyQuestion(auto=false){
  const q=source?.(page); if(!q||!shown()) return false;
  const text=q.querySelector(".mx-uq-text")||q.querySelector(":scope > p");
  const W=Math.min(canvas.clientWidth-32,600), inner=W-2*CARD_PAD;
  const lines=text?wrap(words(text),inner):[];
  let y=CARD_PAD+lines.length*LINE_H+(lines.length?6:0);
  const figs=[];
  for(const svg of q.querySelectorAll("svg")){
    if(svg.closest("button")) continue;   // the answers' buttons are not part of the question
    const f=paperFigure(svg); if(!f) continue;
    const s=Math.min(1,inner/f.w), w=f.w*s, h=f.h*s;
    figs.push({src:f.src,x:(W-w)/2,y,w,h}); y+=h+8;
  }
  if(!lines.length&&!figs.length) return false;
  const m={kind:"card",x:sketch().x+canvas.clientWidth-16-W,y:sketch().y+16,w:W,h:y+CARD_PAD-(figs.length?8:0),lines,figs};
  if(auto){ marks().push(m); paint(); if(pages>1) drawPages(); return true; }       // by itself: the tool stays, and undo does not take it away
  remember();
  marks().push(m); setTool("move"); select([m]); paint(); if(pages>1) drawPages();
  return true;
}
// the first time a question's sketch is shown, its question is put at the top, once: a copy the student deleted does not come back
function autoQuestion(){
  const s=sketch(); if(s.asked||!source||!shown()) return;
  if(marks().some(m=>m.kind==="card")){ s.asked=true; return; }
  if(copyQuestion(true)) s.asked=true;
}

/* ---------- typed math ---------- */
// a bar over the board's top with the math field; the math keyboard opens with it
async function editMath(mark,p){
  const M=window.MathlyBoardMath; if(!M||editing) return;
  const node=document.createElement("div"); node.className="mx-board-mathedit"; node.dir="ltr";
  node.innerHTML='<span class="mx-board-mathwait" dir="rtl">ื˜ื•ืขื ื™ื ืืช ืžืงืœื“ืช ื”ืžืชืžื˜ื™ืงื”โ€ฆ</span>';
  el.querySelector(".mx-board-stage").append(node);
  const e=editing={mark,at:p,node,field:null,key};
  try{ await M.load(); }
  catch{ node.querySelector("span").textContent="ืœื ื”ืฆืœื—ื ื• ืœื˜ืขื•ืŸ ืืช ืžืงืœื“ืช ื”ืžืชืžื˜ื™ืงื”. ื ืกื• ืฉื•ื‘."; return setTimeout(()=>editing===e&&finishMath(false),2500); }
  if(editing!==e) return;
  node.innerHTML=`<math-field aria-label="ื ื•ืกื—ื”"></math-field>
    <button type="button" data-math="ok" title="ื”ื•ืกืคื” ืœืœื•ื—">โœ“</button><button type="button" data-math="cancel" title="ื‘ื™ื˜ื•ืœ">โœ•</button>`;
  const f=e.field=node.querySelector("math-field");
  f.mathVirtualKeyboardPolicy="manual";
  f.value=mark?.latex||"";
  f.addEventListener("change",()=>editing===e&&finishMath(true));   // the keyboard's return key, Enter, or leaving the field
  f.addEventListener("focusin",()=>window.mathVirtualKeyboard?.show());
  // the buttons keep the field's focus, so leaving it does not count as done before the tap is read
  node.querySelectorAll("button").forEach(b=>b.addEventListener("pointerdown",ev=>{ ev.preventDefault(); finishMath(b.dataset.math==="ok"); }));
  f.focus(); window.mathVirtualKeyboard?.show();
}
function finishMath(keep){
  const e=editing; if(!e) return; editing=null;
  const latex=keep&&e.field?String(e.field.getValue?.("latex")??e.field.value??"").trim():null;
  try{ window.mathVirtualKeyboard?.hide(); }catch{}
  e.node.remove();
  if(latex===null||e.key!==key) return;   // cancelled, or another page by now
  const all=marks();
  if(e.mark){
    if(!all.includes(e.mark)||latex===e.mark.latex) return;
    remember(); if(latex) e.mark.latex=latex; else all.splice(all.indexOf(e.mark),1);   // emptied: gone
  }else if(latex){
    const L=window.MathlyBoardMath.layout(latex,MATH_SIZE), W=canvas.clientWidth;
    remember();
    all.push({kind:"math",latex,color:ink,size:MATH_SIZE,x:Math.max(8,Math.min(e.at[0],sketch().x+W-8-L.w)),y:Math.max(0,e.at[1]-(L.a+L.d)/2)});
  }else return;
  paint(); if(pages>1) drawPages();
}
if(window.MathlyBoardMath) window.MathlyBoardMath.onFonts=()=>el&&paint();

/* ---------- handing in ---------- */
// the whole sketch as a picture on white paper: as wide as the board (wider when it was written further right), as tall as what is drawn
function snapshot(){ return pictureOf(key); }
/* the work on the board (ื”ื“ืจืš): has this question's sketch anything but the question's copy; its picture, even while the board is closed;
   and a signature that changes when the sketch does (so a picture is sent again only after a change) */
const hasWork=k=>(sketches.get(k)?.marks||[]).some(m=>m.kind!=="card");
const workSig=k=>JSON.stringify(sketches.get(k)?.marks||[]);
function pictureOf(k){
  const all=sketches.get(k)?.marks||[]; if(!all.some(m=>m.kind!=="card")) return null;
  if(!el) build();
  const wide=canvas.clientWidth||720;
  const [,y0,x1,y1]=edges(all), top=Math.max(0,y0-24), W=Math.min(Math.max(wide,x1+24),8000), H=Math.min(y1+24-top,8000), k2=2;
  const out=document.createElement("canvas"); out.width=W*k2; out.height=Math.round(H*k2);
  const keep=ctx; ctx=out.getContext("2d"); paper="#fff";
  try{ ctx.fillStyle="#fff"; ctx.fillRect(0,0,out.width,out.height); ctx.setTransform(k2,0,0,k2,0,-top*k2); all.forEach(draw); }
  finally{ ctx=keep; paper=PAPER; }
  return new Promise(ok=>out.toBlob(ok,"image/png"));
}
async function send(){
  const b=el.querySelector(".mx-board-send"), p=page, say=t=>{ if(page===p) b.innerHTML=t; };
  const blob=await snapshot();
  if(!blob){ say(`<span>ื”ืœื•ื— ืจื™ืง: ืื™ืŸ ืžื” ืœื”ื’ื™ืฉ</span>`); return setTimeout(()=>page===p&&drawSend(),2500); }
  b.disabled=true; say(`<span>ืžื’ื™ืฉื™ืโ€ฆ</span>`);
  let r; try{ r=await hand(p,blob); }catch(e){ r={error:e.message}; }
  b.disabled=false; say(r?.error?`<span>โš  ${r.error}</span>`:`${ic("done")}<span>${sendLabel?"ื ืฉืœื—ื”":`ื”ื•ื’ืฉื” ืœืฉืืœื” ${p+1}`}</span>`);
  if(r?.close&&full) return setTimeout(closeFull,900);   // done: back to the window that asked
  setTimeout(()=>page===p&&!b.disabled&&drawSend(),3000);
}

document.addEventListener("keydown",e=>{
  if(editing&&e.key==="Escape"){ e.preventDefault(); e.stopImmediatePropagation(); return finishMath(false); }
  if(full&&e.key==="Escape"){ e.preventDefault(); e.stopImmediatePropagation(); return closeFull(); }   // back to the question, not out of it
  if(!shown()||editing||e.target.closest?.("input,textarea,[contenteditable],math-field")) return;
  if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.shiftKey){ e.preventDefault(); undo(); }
  else if((e.key==="Delete"||e.key==="Backspace")&&selection.length){ e.preventDefault(); removeSelection(); }
});

/* Shows the board in the sheet's layer when the student has it on (a desktop screen), and takes it away when not.
   sketchKey names the sketch; with pages>1 (a sheet with several questions at once) the board has a tab per question.
   question(page) gives the element the question is shown in, for the board's copy of it; submit(page, picture)
   hands a page in and resolves to {error} or {}; turned(page) is told when the student picks another tab.
   On a phone nothing shows until the header's button opens the board full screen. */
function mount(sheetLayer,sketchKey,{pages:n=1,question=null,submit=null,turned=null,sendLabel:label=""}={}){
  const was=full?full.saved:context(), same=sketchKey===was.base&&n===was.pages;
  const next={layer:sheetLayer,source:question,hand:submit,onPage:turned,sendLabel:label,
    base:sketchKey,pages:n,page:same?was.page:0};
  next.key=next.pages>1?`${next.base}:${next.page}`:next.base;
  if(full){ full.saved=next; return; }   // the sheet changed under the full-screen board: it is where the board goes back to
  restore(next);
  const sheet=layer.querySelector(".mx-sheet"), on=isOn()&&desk();
  layer.querySelector("[data-board-toggle]")?.setAttribute("aria-pressed",on);
  sheet?.classList.toggle("with-board",on);
  if(on) sheet?.removeAttribute("aria-modal"); else sheet?.setAttribute("aria-modal","true");   // the board is outside the dialog: still reachable
  if(!on){ el?.remove(); return; }
  if(!el) build();
  reset(); drawPages(); drawSend(); layer.append(el); fit(); autoQuestion();
}
const again=()=>mount(layer,base,{pages,question:source,submit:hand,turned:onPage,sendLabel});
const here=p=>({host:layer,key:base,pages,page:p,question:source,submit:hand,turned:onPage});
function toggle(){
  if(!desk()) return layer&&openFull(here(page));   // a phone: the board over the exercise
  setOn(!isOn()); if(layer) again();
}
// opens the board on one question's page (the unit quiz's own button under each question)
function show(p){
  if(!desk()) return layer&&openFull(here(p));
  setOn(true); if(!layer) return; again(); if(pages>1&&p!==page) turn(p);
}
/* The board full screen over everything in host, until the student goes back. Options as mount's, and:
   title (the head's words), sendLabel (the hand-in button's words), done (called when it closes).
   A submit answer with close:true closes it after the hand-in. */
function openFull({host,key:k,pages:n=1,page:p=0,question=null,submit=null,turned=null,title="ืœื•ื— ื˜ื™ื•ื˜ื”",sendLabel:label="",done=null}){
  if(!el) build();
  if(full) full.done=done; else full={saved:context(),done};
  restore({layer:host,base:k,pages:n,page:p,source:question,hand:submit,onPage:turned,sendLabel:label,key:n>1?`${k}:${p}`:k});
  el.querySelector("[data-title]").textContent=title;
  el.classList.add("full"); el.setAttribute("role","dialog"); el.setAttribute("aria-modal","true");
  reset(); drawPages(); drawSend(); host.append(el); fit(); autoQuestion();
  el.querySelector(".mx-board-back").focus({preventScroll:true,focusVisible:false});   // reachable by keyboard, without a ring on a tap
}
function closeFull(){
  if(!full) return; if(editing) finishMath(true); if(naming) finishAngle(true); const f=full; full=null;
  el.classList.remove("full"); el.removeAttribute("role"); el.removeAttribute("aria-modal"); el.remove();
  restore(f.saved); f.done?.();
  if(layer?.isConnected&&desk()) again();
}
// the header's switch: beside the exercise on a desktop screen, full screen on a phone
const button=()=>`<button class="mx-board-btn" data-board-toggle aria-pressed="${isOn()&&desk()}" title="ืœื•ื— ื˜ื™ื•ื˜ื”">${ic("pen")}<span>ืœื•ื—</span></button>`;

window.MathlyBoard={button,mount,toggle,show,openFull,closeFull,isOn,desk,hasWork,workSig,pictureOf};
})();