-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscene_library_generated.json
More file actions
1459 lines (1459 loc) · 177 KB
/
Copy pathscene_library_generated.json
File metadata and controls
1459 lines (1459 loc) · 177 KB
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
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
[
{
"id": "kepler-elliptical-orbit-equal-areas",
"title": "Kepler's Laws: Elliptical Orbit & Equal Areas",
"tag": "Orbital Mechanics",
"dimension": "2D",
"equation": "M = E - e*sin(E), dA/dt = const",
"summary": "A planet sweeps around an elliptical orbit with the star at one focus. Equal-time sectors are highlighted to show they enclose equal area (Kepler's 2nd law), while the planet visibly speeds up near perihelion and slows near aphelion.",
"keywords": [
"kepler",
"keplers laws",
"elliptical orbit",
"ellipse",
"equal areas",
"second law",
"areal velocity",
"eccentric anomaly",
"eccentricity",
"perihelion",
"aphelion",
"planet",
"orbit",
"focus"
],
"bullets": [
"The star sits at one focus of the ellipse, not the center (Kepler's 1st law).",
"Each orange sector covers the same area in the same elapsed time, so the planet moves fastest near perihelion (Kepler's 2nd law).",
"The live speed v comes from the vis-viva equation v = sqrt(2/r - 1/a) with GM = 1, peaking at closest approach."
],
"student_prompts": [
"Why does the planet move faster when it is closer to the star?",
"How is the eccentric anomaly E related to the actual angle swept from the focus?",
"Derive the vis-viva equation and show where the readout speed comes from."
],
"code": "H.background();\n// --- Kepler's first & second law: elliptical orbit + equal areas in equal times ---\nconst a = 5.2; // semi-major axis (data units)\nconst e = 0.6; // eccentricity\nconst b = a * Math.sqrt(1 - e * e); // semi-minor axis\nconst c = a * e; // focus offset from center\nconst v = H.plot2d({ xMin: -7.2, xMax: 7.2, yMin: -4.6, yMax: 4.6, pad: 50 });\nv.grid(); v.axes();\n\n// Star sits at the focus (origin). Center of the ellipse is at (-c, 0).\nconst cxData = -c, cyData = 0;\n// Draw the full ellipse path.\nconst ell = [];\nconst NE = 160;\nfor (let i = 0; i <= NE; i++) {\n const th = (i / NE) * H.TAU;\n ell.push([cxData + a * Math.cos(th), cyData + b * Math.sin(th)]);\n}\nv.path(ell, { color: H.colors.accent, width: 2.4 });\n\n// Solve Kepler's equation M = E - e sin E for the eccentric anomaly E.\n// Mean motion: full period = 6s of sim time, so M sweeps uniformly with t.\nconst period = 6;\nconst M = ((t % period) / period) * H.TAU;\nlet E = M; // Newton iteration (few steps, converges fast)\nfor (let k = 0; k < 6; k++) {\n E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));\n}\n// Planet position on the ellipse (focus at origin).\nconst px = cxData + a * Math.cos(E);\nconst py = cyData + b * Math.sin(E);\n\n// Equal-area swept sector over a fixed slice of the period, ending at \"now\".\nconst dM = H.TAU * 0.10; // mean-anomaly width of the highlighted sweep\nconst swept = [[0, 0]];\nconst SEG = 28;\nfor (let i = 0; i <= SEG; i++) {\n const Mi = M - dM + (dM * i) / SEG;\n let Ei = Mi;\n for (let k = 0; k < 5; k++) {\n Ei -= (Ei - e * Math.sin(Ei) - Mi) / (1 - e * Math.cos(Ei));\n }\n swept.push([cxData + a * Math.cos(Ei), cyData + b * Math.sin(Ei)]);\n}\nv.path(swept, { color: H.colors.accent2, width: 1, fill: \"rgba(244,162,89,0.32)\", close: true });\n\n// Radius line star -> planet, and the star itself at the focus.\nv.line(0, 0, px, py, { color: H.colors.sub, width: 1.4, dash: [5, 5] });\nv.circle(0, 0, 9, { fill: H.colors.yellow, stroke: \"#a8791f\", width: 1.5 });\nv.dot(px, py, { r: 6, fill: H.colors.accent, stroke: H.colors.bg });\n// Mark the empty (other) focus too.\nv.circle(-2 * c, 0, 3, { fill: H.colors.sub });\n\n// Live numbers: orbital radius r and instantaneous speed (vis-viva, GM=1).\nconst r = Math.hypot(px, py);\nconst speed = Math.sqrt(Math.max(0, 2 / r - 1 / a)); // vis-viva, never negative\nconst phaseName = E < Math.PI ? \"speeding up toward perihelion\" : \"slowing toward aphelion\";\n\nH.text(\"Kepler's Laws: elliptical orbit & equal areas\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Orange sectors swept in equal times have equal area (2nd law).\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + r.toFixed(2) + \" AU\", 24, H.H - 54, { color: H.colors.accent2, size: 14 });\nH.text(\"v = \" + speed.toFixed(2) + \" (vis-viva, GM=1)\", 24, H.H - 34,\n { color: H.colors.good, size: 14 });\nH.text(\"e = \" + e.toFixed(2) + \" \" + phaseName, 24, H.H - 14,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"orbit (ellipse)\", color: H.colors.accent },\n { label: \"star at focus\", color: H.colors.yellow },\n { label: \"swept area\", color: H.colors.accent2 },\n], H.W - 200, 30);"
},
{
"id": "moon-phases-lighting-geometry-3d",
"title": "Moon Phases: Lighting Geometry in 3D",
"tag": "Astronomy",
"dimension": "3D",
"equation": "illuminated fraction = (1 - cos(elongation)) / 2",
"summary": "The Moon orbits Earth in 3D while parallel sunlight from one side lights exactly half of it. A corner inset shows the resulting Earth-view disk cycling through new, crescent, quarter, gibbous and full, with a live illuminated-percentage readout.",
"keywords": [
"moon phases",
"lunar phases",
"moon",
"earth",
"sun",
"crescent",
"gibbous",
"waxing",
"waning",
"new moon",
"full moon",
"first quarter",
"synodic month",
"terminator"
],
"bullets": [
"The Sun always lights exactly half the Moon; phase is just how much of that lit half faces Earth.",
"Illuminated fraction equals (1 - cos(elongation))/2, ranging from 0% at new moon to 100% at full moon.",
"The inset disk shows the familiar crescent-to-gibbous shape that an observer on Earth actually sees as the Moon orbits."
],
"student_prompts": [
"Why do we see the same face of the Moon even though its phase changes?",
"What is the difference between the Moon's phase and a lunar eclipse?",
"How long is one full cycle of phases and why is it longer than the orbital period?"
],
"code": "H.background();\n// --- Moon phases (3D): the Moon orbiting Earth, lit from one side by the Sun ---\nconst cam = H.cam3d({ scale: 46, dist: 17, pitch: -0.62, cy: H.H * 0.52 });\ncam.yaw = 0.18 * t; // gentle default spin so it reads as 3D\ncam.grid(7, 1);\n\n// Sun is far away in the +X direction: light rays point toward -X.\nconst sunDir = [1, 0, 0]; // unit vector FROM scene TOWARD the Sun\nconst orbitR = 4.0; // Moon's orbital radius about Earth (data units)\nconst period = 12; // seconds per synodic cycle\nconst phase = ((t % period) / period) * H.TAU; // 0..TAU around the orbit\n// Moon position in the ecliptic plane (x/z ground plane; y is up).\nconst mx = orbitR * Math.cos(phase);\nconst mz = orbitR * Math.sin(phase);\nconst moonPos = [mx, 0, mz];\n\n// Draw the orbit ring of the Moon.\nconst ring = [];\nconst NR = 96;\nfor (let i = 0; i <= NR; i++) {\n const th = (i / NR) * H.TAU;\n ring.push([orbitR * Math.cos(th), 0, orbitR * Math.sin(th)]);\n}\ncam.path(ring, { color: H.colors.grid, width: 1.4 });\n\n// Sunlight direction indicator: a long arrow sweeping in across the scene.\nconst sunHead = cam.project([-orbitR - 1.4, 0, 0]);\nconst sunTail = cam.project([-6.2, 0, 0]);\nH.arrow(sunTail.x, sunTail.y, sunHead.x, sunHead.y, { color: H.colors.yellow, width: 2 });\n\n// Phase angle: angle between Sun direction and Earth->Moon direction.\n// Illuminated fraction = (1 - cos(elongation)) / 2.\nconst moonHat = [Math.cos(phase), 0, Math.sin(phase)];\nconst cosElong = moonHat[0] * sunDir[0] + moonHat[2] * sunDir[2];\nconst illum = (1 - cosElong) / 2; // 0 = new moon, 1 = full moon\nconst elong = Math.acos(H.clamp(cosElong, -1, 1));\n\n// Depth-sort the bodies and draw far-to-near.\nconst bodies = [\n { p: [0, 0, 0], r: 1.05, color: H.colors.accent },\n { p: moonPos, r: 0.55, color: \"#cdd3e0\" },\n];\nbodies\n .map((o) => ({ p: o.p, r: o.r, color: o.color, depth: cam.project(o.p).depth }))\n .sort((a, b) => b.depth - a.depth)\n .forEach((o) => cam.sphere(o.p, o.r, { color: o.color }));\n\n// Terminator shading on the Moon: a dark cap on the side facing away from the\n// Sun, sized by the unlit fraction, drawn in screen space.\nconst mProj = cam.project(moonPos);\nconst moonRpx = Math.max(4, 0.55 * 46 * mProj.f);\nconst darkCenter = [moonPos[0] - sunDir[0] * 0.55, 0, moonPos[2] - sunDir[2] * 0.55];\nconst dProj = cam.project(darkCenter);\nH.circle(dProj.x, dProj.y, moonRpx * (0.45 + 0.55 * (1 - illum)),\n { fill: \"rgba(8,10,24,0.55)\" });\n\n// As-seen-from-Earth phase disk in the corner (crescent/gibbous).\nconst diskX = H.W - 90, diskY = 96, diskR = 34;\nH.circle(diskX, diskY, diskR + 4, { fill: \"#11182b\", stroke: H.colors.grid, width: 1.5 });\nH.circle(diskX, diskY, diskR, { fill: \"#1a2236\" });\nconst waxing = Math.sin(phase) >= 0 ? 1 : -1;\nconst lit = [];\nconst NP = 40;\nfor (let i = 0; i <= NP; i++) {\n const ang = -Math.PI / 2 + (Math.PI * i) / NP;\n lit.push([diskX + waxing * diskR * Math.cos(ang), diskY + diskR * Math.sin(ang)]);\n}\nconst termW = diskR * (1 - 2 * illum);\nfor (let i = NP; i >= 0; i--) {\n const ang = -Math.PI / 2 + (Math.PI * i) / NP;\n lit.push([diskX + waxing * termW * Math.cos(ang), diskY + diskR * Math.sin(ang)]);\n}\nH.path(lit, { color: \"none\", fill: H.colors.yellow, close: true });\n\nlet phaseName;\nif (illum < 0.04) phaseName = \"New Moon\";\nelse if (illum > 0.96) phaseName = \"Full Moon\";\nelse if (Math.abs(illum - 0.5) < 0.06) phaseName = (waxing > 0 ? \"First\" : \"Last\") + \" Quarter\";\nelse if (illum < 0.5) phaseName = (waxing > 0 ? \"Waxing\" : \"Waning\") + \" Crescent\";\nelse phaseName = (waxing > 0 ? \"Waxing\" : \"Waning\") + \" Gibbous\";\n\ncam.axes(5);\nH.text(\"Moon Phases: lighting geometry in 3D\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"The Sun lights one hemisphere; phase = how much of it we see from Earth.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"illuminated = \" + (illum * 100).toFixed(0) + \"%\", 24, H.H - 54,\n { color: H.colors.yellow, size: 14 });\nH.text(\"elongation = \" + (elong * 180 / Math.PI).toFixed(0) + \" deg\", 24, H.H - 34,\n { color: H.colors.sub, size: 13 });\nH.text(\"phase: \" + phaseName, 24, H.H - 14, { color: H.colors.accent, size: 14, weight: 700 });\nH.legend([\n { label: \"Earth\", color: H.colors.accent },\n { label: \"Moon\", color: \"#cdd3e0\" },\n { label: \"sunlight\", color: H.colors.yellow },\n], H.W - 170, H.H - 80);"
},
{
"id": "inverse-square-gravity-potential-well",
"title": "Inverse-Square Gravity & the Potential Well",
"tag": "Gravitation",
"dimension": "3D",
"equation": "g(r) = GM/r^2, Phi(r) = -GM/r",
"summary": "The gravitational potential Phi = -GM/r is drawn as a 3D funnel-shaped well with a test mass orbiting on its wall and an inward force arrow. A 2D inset plots the inverse-square field g(r) = GM/r^2 with the current orbital radius marked, both updating live.",
"keywords": [
"inverse square law",
"inverse-square",
"gravity",
"gravitational field",
"potential well",
"gravitational potential",
"newton",
"gm over r squared",
"force field",
"field strength",
"central force",
"two-body",
"orbit",
"test mass"
],
"bullets": [
"The gravitational potential Phi = -GM/r forms a steep funnel that deepens toward the central mass.",
"Field strength g = GM/r^2 follows the inverse-square law: halving r quadruples the force, as the inset curve shows.",
"The green arrow is the inward gravitational pull on the orbiting test mass; it grows as the mass drops deeper into the well."
],
"student_prompts": [
"Why is the gravitational force an inverse-square law rather than inverse-distance?",
"How does the potential Phi = -GM/r relate to the force g = GM/r^2?",
"What orbital speed keeps the test mass on a stable circular orbit at this radius?"
],
"code": "H.background();\n// --- Inverse-square gravity: the potential well and a test mass orbiting in it ---\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.6, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t; // slow spin so the well reads as 3D\ncam.grid(6, 2);\n\nconst GM = 6.0; // gravitational parameter\nconst soft = 0.45; // softening so the funnel stays finite at r=0\n// Potential height: Phi = -GM / sqrt(r^2 + soft^2), scaled to a visible depth.\nH.surface3d(cam, (x, y) => {\n const r2 = x * x + y * y;\n return (-GM / Math.sqrt(r2 + soft * soft)) * 0.95 + 7.5; // lift so it's in frame\n}, { xMin: -5.5, xMax: 5.5, yMin: -5.5, yMax: 5.5, nx: 40, ny: 40,\n hueMin: 215, hueMax: 280, alpha: 0.94 });\n\n// Test mass on a circular orbit at radius rOrb, sitting on the funnel wall.\nconst rOrb = 2.6;\nconst ang = t * 0.9;\nconst ox = rOrb * Math.cos(ang);\nconst oz = rOrb * Math.sin(ang);\nconst height = (-GM / Math.sqrt(rOrb * rOrb + soft * soft)) * 0.95 + 7.5;\nconst massPos = [ox, height + 0.35, oz];\ncam.sphere(massPos, 0.32, { color: H.colors.accent2 });\n// Central mass at the bottom of the well.\nconst wellBottom = (-GM / Math.sqrt(soft * soft)) * 0.95 + 7.5;\ncam.sphere([0, wellBottom + 0.3, 0], 0.5, { color: H.colors.yellow });\n\n// Inward gravitational force arrow on the test mass.\nconst g = GM / (rOrb * rOrb);\nconst ux = -Math.cos(ang), uz = -Math.sin(ang);\nconst tip = [ox + ux * g * 0.18, massPos[1], oz + uz * g * 0.18];\nconst aP = cam.project(massPos);\nconst bP = cam.project(tip);\nH.arrow(aP.x, aP.y, bP.x, bP.y, { color: H.colors.good, width: 2.4 });\n\ncam.axes(5);\n\n// 2D inset: g(r) = GM/r^2 falling off, with the current radius marked.\nconst v = H.plot2d({\n xMin: 0.4, xMax: 6, yMin: 0, yMax: 12,\n box: { x: H.W - 230, y: 64, w: 200, h: 130 },\n});\nv.grid(); v.axes();\nv.fn((rr) => GM / (rr * rr), { color: H.colors.accent, width: 2.4 });\nv.dot(rOrb, g, { r: 5, fill: H.colors.accent2 });\nv.line(rOrb, 0, rOrb, g, { color: H.colors.sub, width: 1, dash: [3, 4] });\nH.text(\"g(r) = GM/r^2\", H.W - 224, 58, { color: H.colors.accent, size: 12 });\n\nH.text(\"Inverse-square gravity & the potential well\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Phi = -GM/r forms a funnel; force g = GM/r^2 grows steeply as r shrinks.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"r = \" + rOrb.toFixed(2), 24, H.H - 54, { color: H.colors.accent2, size: 14 });\nH.text(\"g(r) = \" + g.toFixed(2) + \" (GM = \" + GM.toFixed(1) + \")\", 24, H.H - 34,\n { color: H.colors.good, size: 14 });\nH.text(\"Phi(r) = \" + (-GM / Math.sqrt(rOrb * rOrb + soft * soft)).toFixed(2),\n 24, H.H - 14, { color: H.colors.violet, size: 13 });\nH.legend([\n { label: \"central mass\", color: H.colors.yellow },\n { label: \"test mass\", color: H.colors.accent2 },\n { label: \"gravity (inward)\", color: H.colors.good },\n], 24, H.H - 110);"
},
{
"id": "sieve-of-eratosthenes-primes",
"title": "Sieve of Eratosthenes: Finding Primes",
"tag": "Number Theory",
"dimension": "2D",
"equation": "cross out kp for k>=2; survivors are prime",
"summary": "A 10x10 grid of the integers 2 to 101. The animation steps through each prime p (2, 3, 5, 7) and sweeps out its multiples one by one, leaving the prime numbers highlighted. A live counter shows how many survivors remain.",
"keywords": [
"sieve of eratosthenes",
"prime numbers",
"primes",
"number theory",
"find primes",
"prime sieve",
"composite numbers",
"factors",
"multiples",
"crossing out",
"integer grid",
"primality"
],
"bullets": [
"Start at the smallest unmarked number p; it must be prime, so circle it.",
"Cross out every multiple of p starting from p*p, since smaller multiples were already removed by smaller primes.",
"Only primes up to sqrt(101) (2,3,5,7) need a pass; whatever survives is guaranteed prime."
],
"student_prompts": [
"Why can we start crossing out at p squared instead of 2p?",
"How many primes are there below 100, and why is 101 included here?",
"What is the time complexity of the Sieve of Eratosthenes?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\n// --- Sieve of Eratosthenes on a 10x10 grid of the integers 2..101 ---\nconst N = 100; // numbers shown: 2 .. 101\nconst cols = 10, rows = 10;\nconst first = 2;\n// Rebuild the sieve state up to the \"stage\" the animation has reached.\n// stage advances roughly one prime every ~1.6s and loops.\nconst primesToProcess = [2, 3, 5, 7]; // sqrt(101) < 11, so these suffice\nconst cycle = primesToProcess.length + 1;\nconst stageF = (t / 1.6) % cycle; // 0..cycle, fractional\nconst stage = Math.floor(stageF); // which prime index we're on\nconst subt = stageF - stage; // progress within this prime's pass\n\n// composite[k] === true -> crossed out (k is the integer 2..101)\nconst composite = new Array(first + N).fill(false);\nconst activePrime = stage < primesToProcess.length ? primesToProcess[stage] : 0;\n// Fully cross out all multiples of every prime strictly before the active one.\nfor (let s = 0; s < stage && s < primesToProcess.length; s++){\n const p = primesToProcess[s];\n for (let m = p * p; m < first + N; m += p) composite[m] = true;\n}\n// Partially reveal the active prime's multiples as subt grows 0->1.\nlet sweepHit = -1;\nif (activePrime > 0){\n const mults = [];\n for (let m = activePrime * activePrime; m < first + N; m += activePrime) mults.push(m);\n const reveal = Math.floor(H.ease(subt) * (mults.length + 0.999));\n for (let i = 0; i < reveal && i < mults.length; i++) composite[mults[i]] = true;\n if (reveal > 0 && reveal <= mults.length) sweepHit = mults[reveal - 1];\n}\n\n// --- layout the grid cell box ---\nconst gx = 54, gy = 96;\nconst gw = Math.min(w - 110, h - 150);\nconst cell = gw / cols;\nconst gh = cell * rows;\n\nlet primeCount = 0;\nfor (let i = 0; i < N; i++){\n const n = first + i;\n const c = i % cols;\n const r = Math.floor(i / cols);\n const x = gx + c * cell;\n const y = gy + r * cell;\n const isComp = composite[n];\n const isActivePrime = (n === activePrime);\n if (!isComp) primeCount++;\n // cell background\n let fill = H.colors.panel;\n if (isActivePrime) fill = \"#274064\";\n else if (n === sweepHit) fill = \"#3a2a44\";\n H.rect(x + 2, y + 2, cell - 4, cell - 4,\n { fill, stroke: isComp ? \"#202a44\" : H.colors.grid, width: 1, radius: 5 });\n // number\n const tcol = isComp ? \"#4f5d80\" : (isActivePrime ? H.colors.yellow : H.colors.ink);\n H.text(String(n), x + cell / 2, y + cell / 2 + 4,\n { color: tcol, size: Math.max(9, cell * 0.34), align: \"center\", weight: isComp ? 400 : 600 });\n // strike-through for composites\n if (isComp){\n const pad = cell * 0.22;\n H.line(x + pad, y + pad, x + cell - pad, y + cell - pad,\n { color: H.colors.warn, width: 1.6 });\n }\n}\n\n// Pulsing ring on the current prime being sieved.\nif (activePrime > 0){\n const idx = activePrime - first;\n const c = idx % cols, r = Math.floor(idx / cols);\n const cxp = gx + c * cell + cell / 2;\n const cyp = gy + r * cell + cell / 2;\n const pr = cell * 0.5 + 4 + 2 * Math.sin(t * 6);\n H.circle(cxp, cyp, pr, { stroke: H.colors.yellow, width: 2.4 });\n}\n\n// --- titles + live readout ---\nH.text(\"Sieve of Eratosthenes\", 24, 34, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Cross out every multiple of each prime; survivors are prime.\", 24, 56,\n { color: H.colors.sub, size: 13 });\nconst passLabel = activePrime > 0\n ? (\"sieving multiples of p = \" + activePrime)\n : \"sweep complete - survivors are prime\";\nH.text(passLabel, gx, gy + gh + 28, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"primes remaining (2..101): \" + primeCount, gx, gy + gh + 50,\n { color: H.colors.good, size: 13 });\n\nH.legend([\n { label: \"prime (survivor)\", color: H.colors.ink },\n { label: \"current prime p\", color: H.colors.yellow },\n { label: \"crossed out\", color: H.colors.warn },\n], gx + gw + 18, gy + 10);"
},
{
"id": "modular-arithmetic-clock",
"title": "Modular Arithmetic Clock (mod 12)",
"tag": "Number Theory",
"dimension": "2D",
"equation": "n mod 12 = n - 12*floor(n/12)",
"summary": "A 12-hour clock dial where a hand steadily advances one position at a time and wraps from 11 back to 0. Live readouts show the running count n, its residue n mod 12, the number of completed laps, and the division identity n = q*12 + r.",
"keywords": [
"modular arithmetic",
"mod",
"modulo",
"clock arithmetic",
"congruence",
"residue",
"remainder",
"wrap around",
"cyclic",
"number theory",
"division algorithm",
"clock face",
"mod 12"
],
"bullets": [
"Counting on a clock is arithmetic modulo 12: after 11 the hand wraps back to 0.",
"The residue n mod 12 is the remainder when n is divided by 12, always between 0 and 11.",
"Every integer n splits uniquely as n = (laps)*12 + residue, the division algorithm in action."
],
"student_prompts": [
"What is 100 mod 12, and how does the clock show it?",
"Why is modular arithmetic called 'clock arithmetic'?",
"How do you add and multiply numbers modulo 12?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\nconst m = 12; // modulus\nconst cx = w * 0.40, cy = h * 0.55;\nconst R = Math.min(w * 0.34, h * 0.40);\n\n// A counter that increments ~1 step per 0.9s, wrapping mod m.\nconst k = (t / 0.9); // continuous count\nconst kInt = Math.floor(k); // whole steps taken\nconst frac = k - kInt; // 0..1 within current step\nconst residue = ((kInt % m) + m) % m; // current residue class\nconst nextResidue = (residue + 1) % m;\n// Hand angle eases from residue -> residue+1 across the step.\nconst eased = residue + H.ease(frac);\n// 12 o'clock at top, going clockwise: angle measured from top.\nconst angOf = (pos) => -H.PI / 2 + (pos / m) * H.TAU;\n\n// --- dial face ---\nH.circle(cx, cy, R + 14, { fill: H.colors.panel, stroke: H.colors.grid, width: 2 });\nH.circle(cx, cy, R, { stroke: H.colors.axis, width: 1.5 });\n\n// tick positions 0..11, highlight the current residue\nfor (let i = 0; i < m; i++){\n const a = angOf(i);\n const px = cx + Math.cos(a) * R;\n const py = cy + Math.sin(a) * R;\n const lx = cx + Math.cos(a) * (R + 26);\n const ly = cy + Math.sin(a) * (R + 26);\n const active = (i === residue);\n const isNext = (i === nextResidue);\n H.circle(px, py, active ? 9 : 5.5, {\n fill: active ? H.colors.accent2 : (isNext ? H.colors.violet : H.colors.grid),\n stroke: H.colors.bg, width: 2,\n });\n H.text(String(i), lx, ly + 5, {\n color: active ? H.colors.yellow : H.colors.sub,\n size: active ? 17 : 14, align: \"center\",\n weight: active ? 700 : 500,\n });\n}\n\n// arc swept since 0 to show how many full laps the count has made\nconst laps = Math.floor(kInt / m);\n// the moving hand\nconst ha = angOf(eased);\nconst hx = cx + Math.cos(ha) * (R - 8);\nconst hy = cy + Math.sin(ha) * (R - 8);\nH.line(cx, cy, hx, hy, { color: H.colors.accent, width: 4 });\nH.arrow(cx, cy, hx, hy, { color: H.colors.accent, width: 4, head: 12 });\nH.circle(cx, cy, 7, { fill: H.colors.ink });\n\n// little orbiting marker that travels the full count (shows wrap-around)\nconst ma = angOf(eased);\nconst mx = cx + Math.cos(ma) * (R + 40);\nconst my = cy + Math.sin(ma) * (R + 40);\nH.circle(mx, my, 6, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\n\n// --- titles + live readout ---\nH.text(\"Modular Arithmetic Clock (mod \" + m + \")\", 24, 34,\n { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Counting wraps around the dial: after \" + (m - 1) + \" comes 0.\", 24, 56,\n { color: H.colors.sub, size: 13 });\n\n// readout panel on the right\nconst px0 = w * 0.74, py0 = h * 0.30;\nH.text(\"count n = \" + kInt, px0, py0, { color: H.colors.ink, size: 16, weight: 700 });\nH.text(\"n mod \" + m + \" = \" + residue, px0, py0 + 28,\n { color: H.colors.accent2, size: 22, weight: 700 });\nH.text(\"full laps = \" + laps, px0, py0 + 56, { color: H.colors.good, size: 14 });\nH.text(kInt + \" = \" + laps + \"x\" + m + \" + \" + residue, px0, py0 + 80,\n { color: H.colors.violet, size: 14 });\nH.text(\"next: (\" + residue + \" + 1) mod \" + m + \" = \" + nextResidue, px0, py0 + 104,\n { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"clock hand (residue)\", color: H.colors.accent },\n { label: \"current residue\", color: H.colors.accent2 },\n { label: \"running count\", color: H.colors.good },\n], px0, py0 + 140);"
},
{
"id": "fibonacci-spiral-golden-ratio",
"title": "Fibonacci Spiral & the Golden Ratio",
"tag": "Number Theory",
"dimension": "2D",
"equation": "F(n+1)/F(n) -> phi = (1+sqrt(5))/2",
"summary": "Squares with Fibonacci side lengths 1,1,2,3,5,8,... are tiled in a spiral, each adding a quarter-circle arc that builds the golden spiral. Live readouts show the ratio F(n+1)/F(n) converging to phi as the spiral grows.",
"keywords": [
"fibonacci",
"fibonacci spiral",
"golden ratio",
"phi",
"golden spiral",
"golden rectangle",
"sequence",
"ratio convergence",
"number theory",
"spiral",
"quarter circle arcs",
"recurrence"
],
"bullets": [
"Each square's side is the sum of the two previous sides: the Fibonacci recurrence F(n)=F(n-1)+F(n-2).",
"Connecting opposite corners of the squares with quarter-circle arcs traces the golden spiral.",
"The ratio of consecutive Fibonacci numbers F(n+1)/F(n) converges to the golden ratio phi ~= 1.61803."
],
"student_prompts": [
"Why does the ratio of consecutive Fibonacci numbers approach the golden ratio?",
"Is the Fibonacci spiral exactly a logarithmic (golden) spiral, or just an approximation?",
"Where does the golden ratio appear in nature and art?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\n// Fibonacci sequence\nconst fib = [1, 1];\nfor (let i = 2; i < 11; i++) fib.push(fib[i - 1] + fib[i - 2]);\nconst PHI = (1 + Math.sqrt(5)) / 2;\n\n// How many squares are \"grown in\" - sweeps up then resets, looping.\nconst maxSq = 9;\nconst period = maxSq + 2.0;\nconst tt = (t / 1.1) % period;\nconst grown = Math.min(maxSq, Math.floor(tt)); // fully drawn squares\nconst partial = Math.min(1, tt - grown); // growth of the next square\n\n// Build square placements by spiraling out: each new square sits on a side\n// of the running bounding box. Directions cycle: right, up, left, down.\nconst squares = [];\n// Canonical Fibonacci tiling: seed a 1x1 square at the origin, then attach\n// each successive square to a side of the running bounding box.\nconst s0 = fib[0];\nsquares.push({ x: 0, y: 0, s: s0, dir: 0, idx: 0 });\nlet minx = 0, miny = 0, maxx = s0, maxy = s0;\nfor (let i = 1; i < fib.length; i++){\n const s = fib[i];\n let nx, ny;\n // attach on a side of current bounding box, going counter-clockwise\n const d = i % 4;\n if (d === 1){ nx = minx - s; ny = miny; } // left\n else if (d === 2){ nx = minx; ny = miny - s; } // bottom\n else if (d === 3){ nx = maxx; ny = miny; } // right (aligned bottom)\n else { nx = minx; ny = maxy; } // top\n squares.push({ x: nx, y: ny, s, dir: d, idx: i });\n minx = Math.min(minx, nx); miny = Math.min(miny, ny);\n maxx = Math.max(maxx, nx + s); maxy = Math.max(maxy, ny + s);\n}\n\n// Fit the bounding box of grown squares into a centered viewport.\nlet bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;\nfor (let i = 0; i <= grown && i < squares.length; i++){\n const q = squares[i];\n bx0 = Math.min(bx0, q.x); by0 = Math.min(by0, q.y);\n bx1 = Math.max(bx1, q.x + q.s); by1 = Math.max(by1, q.y + q.s);\n}\nif (!Number.isFinite(bx0)){ bx0 = 0; by0 = 0; bx1 = 1; by1 = 1; }\nconst bw = Math.max(1e-6, bx1 - bx0), bh = Math.max(1e-6, by1 - by0);\nconst vpw = w * 0.56, vph = h * 0.72;\nconst vx = w * 0.05, vy = h * 0.16;\nconst sc = Math.min(vpw / bw, vph / bh);\n// center inside viewport\nconst offx = vx + (vpw - bw * sc) / 2;\nconst offy = vy + (vph - bh * sc) / 2;\n// world (fib units, y up) -> screen (y down)\nconst SX = (wx) => offx + (wx - bx0) * sc;\nconst SY = (wy) => offy + (by1 - wy) * sc;\n\n// draw each square + its quarter-circle arc\nfor (let i = 0; i <= grown && i < squares.length; i++){\n const q = squares[i];\n const grow = (i === grown) ? partial : 1;\n const col = H.color(i % H.palette.length);\n // square (optionally growing from a corner)\n const sx = SX(q.x), sy = SY(q.y + q.s);\n const pw = q.s * sc, ph = q.s * sc;\n H.rect(sx, sy, pw * grow, ph * grow, {\n stroke: col, width: 2, fill: H.hsl(200 + i * 14, 45, 22, 0.35), radius: 0,\n });\n H.text(\"F=\" + q.s, sx + 6, sy + 16, { color: col, size: Math.min(15, 6 + pw * 0.06) });\n\n // quarter-circle arc spiraling through the square (full square only)\n if (grow >= 0.999){\n const d = q.dir;\n let ccx, ccy, a0;\n // y is up in world; convert to screen for arc center\n if (d === 0){ ccx = q.x; ccy = q.y; a0 = 0; } // first / top\n else if (d === 1){ ccx = q.x + q.s; ccy = q.y; a0 = H.PI / 2; } // left\n else if (d === 2){ ccx = q.x + q.s; ccy = q.y + q.s; a0 = H.PI; } // bottom\n else { ccx = q.x; ccy = q.y + q.s; a0 = -H.PI / 2; } // right\n const pts = [];\n const seg = 22;\n for (let j = 0; j <= seg; j++){\n const a = a0 + (j / seg) * (H.PI / 2);\n const ax = ccx + Math.cos(a) * q.s;\n const ay = ccy + Math.sin(a) * q.s;\n pts.push([SX(ax), SY(ay)]);\n }\n H.path(pts, { color: H.colors.yellow, width: 3 });\n }\n}\n\n// moving dot tracing the very tip of the spiral\nif (grown >= 1){\n const q = squares[Math.min(grown, squares.length - 1)];\n const d = q.dir;\n let ccx, ccy, a0;\n if (d === 0){ ccx = q.x; ccy = q.y; a0 = 0; }\n else if (d === 1){ ccx = q.x + q.s; ccy = q.y; a0 = H.PI / 2; }\n else if (d === 2){ ccx = q.x + q.s; ccy = q.y + q.s; a0 = H.PI; }\n else { ccx = q.x; ccy = q.y + q.s; a0 = -H.PI / 2; }\n const a = a0 + H.ease(partial) * (H.PI / 2);\n const ax = ccx + Math.cos(a) * q.s, ay = ccy + Math.sin(a) * q.s;\n H.circle(SX(ax), SY(ay), 6, { fill: H.colors.good, stroke: H.colors.bg, width: 2 });\n}\n\n// --- titles + live readout ---\nH.text(\"Fibonacci Spiral & the Golden Ratio\", 24, 34,\n { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Quarter-circle arcs across squares of side 1,1,2,3,5,8,...\", 24, 56,\n { color: H.colors.sub, size: 13 });\n\n// ratio readout: F(n+1)/F(n) -> phi\nconst ni = Math.max(1, Math.min(grown, fib.length - 2));\nconst ratio = fib[ni + 1] / fib[ni];\nconst px0 = w * 0.66, py0 = h * 0.24;\nH.text(\"squares drawn: \" + (grown + (grown < maxSq ? 1 : 0)), px0, py0,\n { color: H.colors.ink, size: 15, weight: 600 });\nH.text(\"F(\" + (ni + 2) + \")/F(\" + (ni + 1) + \") = \" + fib[ni + 1] + \"/\" + fib[ni],\n px0, py0 + 28, { color: H.colors.accent, size: 15 });\nH.text(\"= \" + ratio.toFixed(5), px0, py0 + 52, { color: H.colors.accent2, size: 20, weight: 700 });\nH.text(\"phi = \" + PHI.toFixed(5), px0, py0 + 80, { color: H.colors.yellow, size: 16, weight: 700 });\nH.text(\"error = \" + Math.abs(ratio - PHI).toFixed(5), px0, py0 + 104,\n { color: H.colors.good, size: 13 });\n\nH.legend([\n { label: \"Fibonacci squares\", color: H.colors.accent },\n { label: \"golden spiral arc\", color: H.colors.yellow },\n { label: \"spiral tip\", color: H.colors.good },\n], px0, py0 + 140);"
},
{
"id": "central-limit-theorem-means-converge",
"title": "Central Limit Theorem: means converge",
"tag": "Probability & Statistics",
"dimension": "3D",
"equation": "SE = sigma / sqrt(n)",
"summary": "A 3D ridge of normal curves for the sampling distribution of the sample mean. As the sample size n grows along the depth axis, each bell becomes taller and narrower, visualizing why the standard error shrinks like sigma/sqrt(n) and sample means converge on the true mean.",
"keywords": [
"central limit theorem",
"clt",
"sampling distribution",
"sample mean",
"standard error",
"law of large numbers",
"convergence",
"normal distribution",
"gaussian",
"bell curve",
"variance shrinks",
"sqrt n",
"mean of means",
"sampling"
],
"bullets": [
"The peak narrows and rises as n increases because the standard error SE = sigma/sqrt(n) shrinks.",
"Every bell stays centered on the same true mean mu = 0 — only the spread changes.",
"The yellow marker rides the curve for the current n, where the live readout shows n and its SE."
],
"student_prompts": [
"Why does the standard error fall off like 1/sqrt(n) instead of 1/n?",
"If the population is NOT normal, does the sampling distribution of the mean still become bell-shaped?",
"How large does n need to be before the normal approximation is good enough?"
],
"code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\ncam.grid(6, 1);\n\n// Sample size n sweeps up and down over time so the sampling distribution\n// of the sample mean visibly narrows (CLT: SE = sigma / sqrt(n)).\nconst sigma = 1.0; // population standard deviation\nconst mu = 0; // true mean\nconst nMin = 1, nMax = 40;\nconst phase = (Math.sin(t * 0.5) + 1) / 2; // 0..1, smooth\nconst nF = H.lerp(nMin, nMax, phase);\nconst n = Math.max(1, Math.round(nF));\nconst se = sigma / Math.sqrt(n); // standard error\nconst seSafe = Math.max(se, 1e-3);\n\n// A ridge of normal curves, one per \"step\" of n along the z axis, each\n// scaled to the standard error at that n. Draw as a height surface:\n// height(x, z) = peak of a gaussian whose width depends on n(z).\nconst xMin = -4, xMax = 4;\nconst zMin = 0, zMax = 6;\nH.surface3d(cam, (x, z) => {\n // map z -> an n value so the back of the ridge is large-n (narrow/tall)\n const frac = (z - zMin) / (zMax - zMin);\n const nz = H.lerp(nMin, nMax, frac);\n const sez = Math.max(sigma / Math.sqrt(nz), 1e-3);\n const g = Math.exp(-0.5 * ((x - mu) / sez) * ((x - mu) / sez));\n return (g / sez) * 0.55; // taller + narrower as n grows\n}, { xMin: xMin, xMax: xMax, yMin: zMin, yMax: zMax, nx: 40, ny: 40,\n hueMin: 210, hueMax: 30, alpha: 0.95 });\n\ncam.axes(6);\n\n// A travelling marker on the \"current n\" curve to keep a live focal point.\nconst frac = H.clamp((nF - nMin) / (nMax - nMin), 0, 1);\nconst zc = H.lerp(zMin, zMax, frac);\nconst peak = (1 / seSafe) * 0.55;\nconst m = cam.sphere([mu, peak, zc], 0.22, { color: H.colors.yellow });\n\nH.text(\"Central Limit Theorem: means converge\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Sampling distribution of the mean narrows as n grows.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"n = \" + n + \" SE = sigma/sqrt(n) = \" + seSafe.toFixed(3), 24, 76,\n { color: H.colors.sub, size: 13 });\nH.text(\"mu = \" + mu.toFixed(1) + \" sigma = \" + sigma.toFixed(1), 24, 96,\n { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"small n (wide, flat)\", color: H.hsl(210, 72, 55) },\n { label: \"large n (tall, narrow)\", color: H.hsl(30, 72, 55) },\n { label: \"current n\", color: H.colors.yellow },\n], 24, H.H - 70);"
},
{
"id": "linear-regression-line-of-best-fit",
"title": "Linear regression: line of best fit",
"tag": "Probability & Statistics",
"dimension": "2D",
"equation": "y = m x + b (minimize SSE = sum (y - y_hat)^2)",
"summary": "A fixed scatter of points with a candidate line that rotates and slides toward the ordinary-least-squares solution. Dashed residual segments and a live sum-of-squared-errors readout show why the best-fit line is the one that minimizes total squared vertical distance.",
"keywords": [
"linear regression",
"line of best fit",
"least squares",
"ols",
"residuals",
"sum of squared errors",
"sse",
"slope",
"intercept",
"scatter plot",
"trend line",
"correlation",
"fitting",
"regression line"
],
"bullets": [
"Each dashed red segment is a residual: the vertical gap between a point and the line's prediction.",
"The line settles at the slope and intercept that minimize SSE = sum of squared residuals.",
"Watch the SSE readout fall toward its minimum as the animated line locks onto the OLS fit."
],
"student_prompts": [
"Why squared residuals instead of absolute residuals for the line of best fit?",
"How do the closed-form OLS formulas for slope and intercept come from calculus?",
"What does R-squared tell us that SSE alone does not?"
],
"code": "H.background();\nconst v = H.plot2d({ xMin: -1, xMax: 11, yMin: -1, yMax: 11, pad: 50 });\nv.grid(); v.axes();\n\n// Fixed scatter (deterministic pseudo-random so the cloud is stable).\nconst N = 14;\nconst xs = [], ys = [];\nconst trueM = 0.8, trueB = 1.4;\nfor (let i = 0; i < N; i++) {\n const x = 0.6 + i * (9.0 / (N - 1));\n // deterministic \"noise\" from trig hashing of i\n const noise = 1.6 * Math.sin(i * 12.9898) + 1.1 * Math.cos(i * 4.231);\n const y = trueM * x + trueB + noise;\n xs.push(x); ys.push(y);\n}\n\n// Closed-form ordinary-least-squares fit of the fixed cloud.\nlet sx = 0, sy = 0, sxx = 0, sxy = 0;\nfor (let i = 0; i < N; i++) {\n sx += xs[i]; sy += ys[i]; sxx += xs[i] * xs[i]; sxy += xs[i] * ys[i];\n}\nconst denom = N * sxx - sx * sx;\nconst mBest = denom !== 0 ? (N * sxy - sx * sy) / denom : 0;\nconst bBest = (sy - mBest * sx) / N;\n\n// Animate a candidate line rotating toward the OLS best fit, then settling.\nconst settle = H.ease(H.clamp((Math.sin(t * 0.6) + 1) / 2, 0, 1));\nconst mStart = 0.1, bStart = 6.5;\nconst mNow = H.lerp(mStart, mBest, settle);\nconst bNow = H.lerp(bStart, bBest, settle);\nconst line = (x) => mNow * x + bNow;\n\n// Residual segments + running SSE for the current candidate line.\nlet sse = 0;\nfor (let i = 0; i < N; i++) {\n const yhat = line(xs[i]);\n const r = ys[i] - yhat;\n sse += r * r;\n v.line(xs[i], ys[i], xs[i], yhat,\n { color: H.colors.warn, width: 1.3, dash: [4, 4] });\n}\n\n// The candidate line of best fit.\nv.line(-1, line(-1), 11, line(11), { color: H.colors.accent2, width: 3 });\n\n// Scatter points on top.\nfor (let i = 0; i < N; i++) {\n v.dot(xs[i], ys[i], { r: 5, fill: H.colors.accent });\n}\n\nH.text(\"Linear regression: line of best fit\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Slope/intercept slide toward the least-squares minimum.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"y = \" + mNow.toFixed(2) + \" x + \" + bNow.toFixed(2), 24, 76,\n { color: H.colors.accent2, size: 14, weight: 700 });\nH.text(\"SSE = \" + sse.toFixed(1) + \" (best = \"\n + (function () {\n let s = 0;\n for (let i = 0; i < N; i++) { const r = ys[i] - (mBest * xs[i] + bBest); s += r * r; }\n return s.toFixed(1);\n })() + \")\", 24, 96, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"data points\", color: H.colors.accent },\n { label: \"fit line\", color: H.colors.accent2 },\n { label: \"residuals\", color: H.colors.warn },\n], H.W - 180, 40);"
},
{
"id": "bayesian-update-coin-bias-beta",
"title": "Bayesian update: learning a coin's bias",
"tag": "Probability & Statistics",
"dimension": "2D",
"equation": "posterior = Beta(a0 + heads, b0 + tails)",
"summary": "A Beta(a,b) belief distribution over a coin's hidden bias updates as flips stream in. The dim prior reshapes into a sharper posterior that concentrates around the true bias, with a live readout of the flip counts, posterior parameters, and posterior mean.",
"keywords": [
"bayes",
"bayesian update",
"bayes theorem",
"posterior",
"prior",
"beta distribution",
"conjugate prior",
"coin bias",
"belief update",
"probability",
"inference",
"likelihood",
"credible interval",
"learning"
],
"bullets": [
"Beta is the conjugate prior for a coin: each flip just adds 1 to a (head) or b (tail).",
"As evidence accumulates the posterior sharpens and its peak slides toward the true bias.",
"The green dashed line is the hidden true p; the orange dot tracks the posterior mean a/(a+b)."
],
"student_prompts": [
"Why does a Beta prior stay Beta after a Bernoulli observation — what makes it conjugate?",
"How would a strong prior like Beta(20,20) change how fast the posterior moves?",
"What is the difference between the posterior mean and the maximum a posteriori (MAP) estimate here?"
],
"code": "H.background();\nconst v = H.plot2d({ xMin: 0, xMax: 1, yMin: 0, yMax: 6, pad: 50 });\nv.grid({ stepX: 0.2 }); v.axes({ stepX: 0.2 });\n\n// Beta(a,b) density on theta in [0,1] — conjugate prior for a coin's bias.\n// log-gamma via Lanczos for a normalized, accurate Beta pdf.\nfunction logGamma(z) {\n const g = 7;\n const c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,\n 771.32342877765313, -176.61502916214059, 12.507343278686905,\n -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];\n if (z < 0.5) {\n return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z);\n }\n z -= 1;\n let x = c[0];\n for (let i = 1; i < g + 2; i++) x += c[i] / (z + i);\n const tt = z + g + 0.5;\n return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(tt) - tt + Math.log(x);\n}\nfunction betaPdf(x, a, b) {\n if (x <= 0 || x >= 1) return 0;\n const lb = logGamma(a + b) - logGamma(a) - logGamma(b)\n + (a - 1) * Math.log(x) + (b - 1) * Math.log(1 - x);\n const y = Math.exp(lb);\n return Number.isFinite(y) ? y : 0;\n}\n\n// Prior Beta(2,2). Stream in coin flips over time; each flip updates a,b.\nconst trueP = 0.7; // hidden bias we are learning\nconst a0 = 2, b0 = 2;\nconst maxFlips = 60;\nconst flips = Math.min(maxFlips, Math.floor((t % 14) / 14 * (maxFlips + 1)));\nlet heads = 0;\nfor (let i = 0; i < flips; i++) {\n // deterministic stream: ~trueP fraction are heads\n const u = (Math.sin(i * 78.233) * 43758.5453);\n const frac = u - Math.floor(u); // 0..1 pseudo-random\n if (frac < trueP) heads++;\n}\nconst tails = flips - heads;\nconst a = a0 + heads;\nconst b = b0 + tails;\nconst postMean = a / (a + b);\n\n// Draw prior (dim) and posterior (bright).\nv.fn((x) => betaPdf(x, a0, b0), { color: H.colors.sub, width: 2 });\nv.fn((x) => betaPdf(x, a, b), { color: H.colors.violet, width: 3.4 });\n\n// Mark the true bias and the posterior mean.\nv.line(trueP, 0, trueP, 6, { color: H.colors.good, width: 2, dash: [6, 5] });\nconst peakY = betaPdf(postMean, a, b);\nv.dot(postMean, Math.min(peakY, 6), { r: 6, fill: H.colors.accent2 });\n\nH.text(\"Bayesian update: learning a coin's bias\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each flip reshapes Beta(a,b) toward the true value.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"flips = \" + flips + \" (H \" + heads + \" / T \" + tails + \")\", 24, 76,\n { color: H.colors.sub, size: 13 });\nH.text(\"posterior Beta(\" + a + \", \" + b + \") mean = \" + postMean.toFixed(3),\n 24, 96, { color: H.colors.violet, size: 13 });\nH.text(\"true p = \" + trueP.toFixed(2), v.X(trueP) + 6, v.Y(5.4),\n { color: H.colors.good, size: 12 });\nH.legend([\n { label: \"prior Beta(2,2)\", color: H.colors.sub },\n { label: \"posterior\", color: H.colors.violet },\n { label: \"true bias\", color: H.colors.good },\n { label: \"posterior mean\", color: H.colors.accent2 },\n], H.W - 190, 40);"
},
{
"id": "gradient-of-surface-z-fxy-3d",
"title": "Surface z = f(x,y) and Its Gradient",
"tag": "Multivariable Calculus",
"dimension": "3D",
"equation": "grad f = (df/dx, df/dy)",
"summary": "A solid, lit height surface z = f(x,y) (a two-bump landscape) rotates while a point orbits over the x-y plane. At that point the gradient vector is drawn on the ground plane, always pointing uphill, with its components and magnitude shown live.",
"keywords": [
"gradient",
"grad f",
"del f",
"nabla",
"surface",
"z=f(x,y)",
"partial derivatives",
"steepest ascent",
"slope",
"multivariable",
"scalar field",
"3d surface",
"height map",
"vector field of gradient"
],
"bullets": [
"The gradient grad f = (df/dx, df/dy) lives in the x-y plane and points in the direction of steepest increase of f.",
"The length |grad f| equals the steepest slope of the surface at that point — it shrinks near flat tops and grows on steep flanks.",
"Partial derivatives here are estimated with central differences, the same idea as the analytic df/dx and df/dy."
],
"student_prompts": [
"Why does the gradient lie flat in the x-y plane instead of along the surface?",
"How is the gradient related to the contour (level) curves of f?",
"What happens to grad f exactly at a peak or a saddle point?"
],
"code": "H.background();\nconst cam = H.cam3d({ scale: 40, dist: 15, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.3 * t;\nconst f = (x, y) =>\n 1.6 * Math.exp(-((x - 1) * (x - 1) + (y - 1) * (y - 1)) / 2.2) +\n 1.1 * Math.exp(-((x + 1.4) * (x + 1.4) + (y + 1.2) * (y + 1.2)) / 1.8);\ncam.grid(3.2, 0.8);\nH.surface3d(cam, (x, y) => f(x, y) * 1.4, {\n xMin: -3, xMax: 3, yMin: -3, yMax: 3, nx: 40, ny: 40, hueMin: 205, hueMax: 35,\n});\nconst px = 1.8 * Math.cos(t * 0.7);\nconst py = 1.8 * Math.sin(t * 0.7);\nconst h = 1e-3;\nconst fx = (f(px + h, py) - f(px - h, py)) / (2 * h);\nconst fy = (f(px, py + h) - f(px, py - h)) / (2 * h);\nconst gmag = Math.sqrt(fx * fx + fy * fy);\nconst zp = f(px, py) * 1.4;\ncam.line([px, 0, py], [px, zp, py], { color: H.colors.sub, width: 1.4 });\ncam.sphere([px, zp, py], 0.16, { color: H.colors.warn });\nconst s = 0.7;\nconst tip = [px + fx * s, 0.04, py + fy * s];\nconst a0 = cam.project([px, 0.04, py]);\nconst a1 = cam.project(tip);\nH.arrow(a0.x, a0.y, a1.x, a1.y, { color: H.colors.yellow, width: 3, head: 11 });\ncam.line([px, 0.04, py], [px, zp, py], { color: H.colors.warn, width: 1, dash: [4, 4] });\ncam.axes(3.4);\nH.text(\"Surface z = f(x, y) and its gradient\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"grad f points uphill in the x-y plane; its length is the steepest slope.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"point (x, y) = (\" + px.toFixed(2) + \", \" + py.toFixed(2) + \")\", 24, 84, { color: H.colors.ink, size: 13 });\nH.text(\"grad f = (\" + fx.toFixed(2) + \", \" + fy.toFixed(2) + \") |grad f| = \" + gmag.toFixed(2), 24, 104, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"surface z = f(x,y)\", color: H.colors.accent },\n { label: \"point on surface\", color: H.colors.warn },\n { label: \"grad f (gradient on x-y plane)\", color: H.colors.yellow },\n], 24, H.H - 64);"
},
{
"id": "gradient-descent-loss-surface-3d",
"title": "Gradient Descent on a Loss Surface",
"tag": "Optimization / Machine Learning",
"dimension": "3D",
"equation": "x_{k+1} = x_k - alpha * grad L(x_k)",
"summary": "A ball runs gradient descent down a rippled bowl-shaped loss surface L(x,y). The descent is recomputed deterministically each frame, tracing a path that slides downhill into a minimum, with step count, learning rate, current loss, and gradient magnitude shown live.",
"keywords": [
"gradient descent",
"optimization",
"loss surface",
"cost function",
"learning rate",
"minimum",
"convergence",
"steepest descent",
"backpropagation",
"training",
"machine learning",
"descent path",
"local minimum",
"step size"
],
"bullets": [
"Each iteration updates the position by x <- x - alpha*grad L, stepping opposite the gradient so the loss decreases.",
"The learning rate alpha sets the step length; the yellow trail shows the trajectory bending toward the basin's minimum.",
"|grad L| shrinks toward zero as the ball nears the minimum — that flattening is the signal that descent is converging."
],
"student_prompts": [
"What happens to the path if the learning rate is too large or too small?",
"How would momentum change the shape of this descent trajectory?",
"Why can gradient descent get stuck in a local minimum instead of the global one?"
],
"code": "H.background();\nconst cam = H.cam3d({ scale: 42, dist: 15, pitch: -0.5, cy: H.H * 0.58 });\ncam.yaw = 0.25 * t;\nconst L = (x, y) =>\n 0.35 * (x * x + y * y) + 0.5 * Math.sin(1.3 * x) * Math.cos(1.3 * y) + 0.5;\ncam.grid(3, 1);\nH.surface3d(cam, (x, y) => L(x, y), {\n xMin: -3, xMax: 3, yMin: -3, yMax: 3, nx: 40, ny: 40, hueMin: 265, hueMax: 150, alpha: 0.9,\n});\nconst lr = 0.18;\nconst eps = 1e-3;\nconst grad = (x, y) => [\n (L(x + eps, y) - L(x - eps, y)) / (2 * eps),\n (L(x, y + eps) - L(x, y - eps)) / (2 * eps),\n];\nconst totalSteps = 60;\nconst k = Math.min(totalSteps, Math.floor((t % 9) / 9 * totalSteps));\nlet x = 2.5, y = -2.3;\nconst trail = [[x, L(x, y), y]];\nfor (let i = 0; i < k; i++) {\n const g0 = grad(x, y);\n x -= lr * g0[0];\n y -= lr * g0[1];\n if (!Number.isFinite(x) || !Number.isFinite(y)) break;\n x = H.clamp(x, -3, 3); y = H.clamp(y, -3, 3);\n trail.push([x, L(x, y), y]);\n}\nconst loss = L(x, y);\nconst g = grad(x, y);\nconst gmag = Math.sqrt(g[0] * g[0] + g[1] * g[1]);\ncam.path(trail, { color: H.colors.yellow, width: 3 });\ncam.path(trail.map((p) => [p[0], 0.02, p[2]]), { color: H.colors.grid, width: 1.5 });\ncam.sphere([x, loss, y], 0.18, { color: H.colors.warn });\ncam.line([x, 0, y], [x, loss, y], { color: H.colors.sub, width: 1, dash: [4, 4] });\ncam.axes(3.4);\nH.text(\"Gradient descent on a loss surface\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Each step moves against grad L (downhill). The ball settles in a minimum.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"step \" + k + \" / \" + totalSteps + \" learning rate = \" + lr.toFixed(2), 24, 84, { color: H.colors.ink, size: 13 });\nH.text(\"L(x, y) = \" + loss.toFixed(3) + \" |grad L| = \" + gmag.toFixed(3), 24, 104, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"loss surface L(x,y)\", color: H.colors.violet },\n { label: \"descent path\", color: H.colors.yellow },\n { label: \"current iterate\", color: H.colors.warn },\n], 24, H.H - 64);"
},
{
"id": "divergence-curl-2d-vector-field",
"title": "Divergence & Curl of a 2D Vector Field",
"tag": "Vector Calculus",
"dimension": "2D",
"equation": "div F = dFx/dx + dFy/dy, curl F = dFy/dx - dFx/dy",
"summary": "A 2D vector field F(x,y) is drawn as speed-colored arrows with particles advected along its streamlines. A sweeping sample point shows the live divergence (net outflow) and scalar curl (rotation), plus a rotating unit vector u marking the direction for a directional derivative.",
"keywords": [
"divergence",
"curl",
"vector field",
"flux",
"circulation",
"del dot f",
"del cross f",
"flow",
"streamlines",
"directional derivative",
"gradient",
"rotation",
"sources and sinks",
"2d field"
],
"bullets": [
"Divergence div F = dFx/dx + dFy/dy measures net outflow at a point — positive for a source, negative for a sink.",
"Scalar curl dFy/dx - dFx/dy measures local rotation; the ring turns blue for counterclockwise spin and pink for clockwise.",
"The rotating unit vector u sets the direction along which a directional derivative would be measured at the sample point."
],
"student_prompts": [
"How do divergence and curl relate to the flux and circulation forms of Green's theorem?",
"What does it mean physically when divergence is zero everywhere (an incompressible flow)?",
"How would I compute the directional derivative of a scalar field along the vector u?"
],
"code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3.2, yMax: 3.2, pad: 50 });\nv.grid();\nv.axes();\nconst Fx = (x, y) => 0.6 * x - 0.9 * y + 0.4 * Math.sin(0.8 * y);\nconst Fy = (x, y) => 0.9 * x + 0.6 * y;\nconst step = 1.0;\nfor (let gx = -4.5; gx <= 4.5 + 1e-9; gx += step) {\n for (let gy = -3; gy <= 3 + 1e-9; gy += step) {\n const fx = Fx(gx, gy), fy = Fy(gx, gy);\n const mag = Math.sqrt(fx * fx + fy * fy) + 1e-9;\n const sc = 0.42 / Math.max(1, mag * 0.5);\n const hue = H.clamp(210 - mag * 14, 20, 210);\n v.arrow(gx, gy, gx + fx * sc, gy + fy * sc, { color: H.hsl(hue, 80, 62), width: 1.6, head: 6 });\n }\n}\nconst NP = 26;\nfor (let i = 0; i < NP; i++) {\n const ang = (i / NP) * H.TAU;\n let x = 3.4 * Math.cos(ang), y = 2.0 * Math.sin(ang);\n const dt = 0.05;\n const phase = (t * 0.9 + i * 0.13) % 1;\n const adv = Math.floor(phase * 10) + 3;\n for (let sN = 0; sN < adv && sN < 30; sN++) {\n const fx = Fx(x, y), fy = Fy(x, y);\n x += fx * dt; y += fy * dt;\n if (!Number.isFinite(x) || !Number.isFinite(y)) break;\n }\n if (Math.abs(x) < 5 && Math.abs(y) < 3.2)\n v.dot(x, y, { r: 3, fill: H.colors.good, stroke: H.colors.bg });\n}\nconst sx = 2.6 * Math.cos(t * 0.6);\nconst sy = 1.7 * Math.sin(t * 0.6);\nconst e = 1e-3;\nconst dFxdx = (Fx(sx + e, sy) - Fx(sx - e, sy)) / (2 * e);\nconst dFydy = (Fy(sx, sy + e) - Fy(sx, sy - e)) / (2 * e);\nconst dFydx = (Fy(sx + e, sy) - Fy(sx - e, sy)) / (2 * e);\nconst dFxdy = (Fx(sx, sy + e) - Fx(sx, sy - e)) / (2 * e);\nconst div = dFxdx + dFydy;\nconst curl = dFydx - dFxdy;\nconst ua = t * 0.8;\nconst ux = Math.cos(ua), uy = Math.sin(ua);\nconst dirArrowLen = 1.0;\nv.arrow(sx, sy, sx + ux * dirArrowLen, sy + uy * dirArrowLen, { color: H.colors.yellow, width: 2.6, head: 9 });\nconst ringR = 26 + 6 * Math.sin(t * 2);\nH.circle(v.X(sx), v.Y(sy), ringR, { stroke: curl >= 0 ? H.colors.accent : H.colors.warn, width: 2 });\nv.dot(sx, sy, { r: 6, fill: H.colors.violet });\nv.text(\"u\", sx + ux * dirArrowLen + 0.1, sy + uy * dirArrowLen + 0.1, { color: H.colors.yellow, size: 13 });\nH.text(\"Divergence & curl of a 2D vector field\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"F(x,y) = (0.6x - 0.9y + .4sin.8y, 0.9x + 0.6y). div = outflow, curl = spin.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"sample (x,y) = (\" + sx.toFixed(2) + \", \" + sy.toFixed(2) + \")\", 24, 84, { color: H.colors.violet, size: 13 });\nH.text(\"div F = \" + div.toFixed(2) + \" curl F = \" + curl.toFixed(2), 24, 104, { color: H.colors.ink, size: 13 });\nH.text(\"u = (\" + ux.toFixed(2) + \", \" + uy.toFixed(2) + \") -> direction of derivative\", 24, 124, { color: H.colors.yellow, size: 13 });\nH.legend([\n { label: \"field F (arrows)\", color: H.colors.accent },\n { label: \"advected particles\", color: H.colors.good },\n { label: \"sample point\", color: H.colors.violet },\n { label: \"direction u\", color: H.colors.yellow },\n], 24, H.H - 84);"
},
{
"id": "dna-double-helix-3d",
"title": "DNA Double Helix (B-form)",
"tag": "Molecular Biology",
"dimension": "3D",
"equation": "strand: (r·cos θ, y, r·sin θ), θ = 2π·turns·s",
"summary": "A right-handed B-DNA double helix rotates in 3D: two antiparallel sugar–phosphate backbones (shaded spheres) wound 180° apart and joined by color-coded base-pair rungs, with a reading highlight sweeping up the molecule.",
"keywords": [
"dna",
"double helix",
"b-dna",
"nucleotide",
"base pair",
"adenine thymine guanine cytosine",
"genetics",
"molecular biology",
"backbone",
"antiparallel strands",
"chromosome",
"genome",
"biochemistry",
"3d molecule"
],
"bullets": [
"Two antiparallel strands (5'→3' blue, 3'→5' orange) coil into one right-handed helix.",
"Rungs are complementary base pairs (A-T / G-C) holding the strands together via hydrogen bonds.",
"B-DNA rises ~0.34 nm per base pair, so the displayed helix length tracks the base-pair count."
],
"student_prompts": [
"Why are the two strands antiparallel and what does 5'→3' mean?",
"How do A-T and G-C base pairing rules keep the helix width constant?",
"What is the difference between B-DNA, A-DNA, and Z-DNA conformations?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\nconst cam = H.cam3d({ scale: 30, dist: 20, pitch: -0.18, cy: h * 0.52 });\ncam.yaw = 0.45 * t;\n\n// One full turn of the helix unwinds/rewinds slightly so it breathes.\nconst N = 40; // base-pair rungs\nconst turns = 2.2; // helical turns across the strand\nconst radius = 2.2;\nconst ylo = -5.2, yhi = 5.2;\nconst twist = 0.22 * Math.sin(t * 0.6); // gentle live winding\n\n// Base-pair color coding (A-T vs G-C) cycles deterministically.\nconst pairColors = [H.colors.good, H.colors.warn, H.colors.violet, H.colors.yellow];\n\n// Collect every drawable (backbone balls + rung balls) for depth sorting.\nconst balls = [];\nconst rungs = [];\n\nfor (let i = 0; i < N; i++) {\n const s = i / (N - 1);\n const ang = s * H.TAU * turns + twist * i;\n const ypos = H.lerp(ylo, yhi, s);\n // Two antiparallel sugar-phosphate backbones, 180 deg apart.\n const a = [radius * Math.cos(ang), ypos, radius * Math.sin(ang)];\n const b = [radius * Math.cos(ang + H.PI), ypos, radius * Math.sin(ang + H.PI)];\n balls.push({ p: a, color: H.colors.accent, r: 0.34 });\n balls.push({ p: b, color: H.colors.accent2, r: 0.34 });\n\n // A travelling \"replication / read\" highlight sweeps up the molecule.\n const head = (t * 0.32) % 1;\n const lit = Math.abs(s - head) < 0.06;\n const c = pairColors[i % pairColors.length];\n rungs.push({ a: a, b: b, color: c, lit: lit });\n}\n\n// Draw rungs (base pairs) first as thin bonds behind the spheres.\nfor (let i = 0; i < rungs.length; i++) {\n const r = rungs[i];\n cam.line(r.a, r.b, {\n color: r.lit ? H.colors.ink : r.color,\n width: r.lit ? 3.2 : 1.5,\n });\n}\n\n// Depth-sort spheres far-to-near and shade them.\nballs\n .map((o) => ({ p: o.p, color: o.color, r: o.r, depth: cam.project(o.p).depth }))\n .sort((m, n) => n.depth - m.depth)\n .forEach((o) => cam.sphere(o.p, o.r, { color: o.color }));\n\ncam.axes(6);\n\n// Labels + live readout.\nconst rise = 0.34; // nm per base pair (B-DNA)\nconst helixLen = (N * rise);\nH.text(\"DNA double helix (B-form)\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Two antiparallel strands wound into a right-handed helix.\", 24, 52,\n { color: H.colors.sub, size: 13 });\nH.text(\"read position = \" + ((t * 0.32) % 1 * 100).toFixed(0) + \"% length ≈ \"\n + helixLen.toFixed(1) + \" nm\", 24, 76, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"strand 5'→3'\", color: H.colors.accent },\n { label: \"strand 3'→5'\", color: H.colors.accent2 },\n { label: \"base pair (A-T/G-C)\", color: H.colors.good },\n], 24, h - 70);"
},
{
"id": "lotka-volterra-predator-prey",
"title": "Lotka–Volterra Predator–Prey Cycles",
"tag": "Population Ecology",
"dimension": "2D",
"equation": "dx/dt = a·x − b·x·y , dy/dt = d·x·y − c·y",
"summary": "Side-by-side time series and phase portrait of the Lotka–Volterra model: prey and predator populations are integrated forward in real time, tracing oscillating waves on the left and a closed orbit around the equilibrium on the right.",
"keywords": [
"lotka volterra",
"predator prey",
"population dynamics",
"ecology",
"oscillation",
"rabbits foxes",
"phase portrait",
"limit cycle",
"differential equations",
"equilibrium",
"prey predator cycle",
"mathematical biology",
"food web",
"carrying capacity"
],
"bullets": [
"Prey grows exponentially but is eaten; predators die off but reproduce by eating prey.",
"The two populations oscillate out of phase — prey peaks lead predator peaks.",
"On the phase plane the trajectory is a closed loop circling the fixed point (c/d, a/b)."
],
"student_prompts": [
"Why do the predator and prey peaks happen at different times?",
"What happens to the cycles if I increase the predator death rate c?",
"How does adding a prey carrying capacity (logistic term) change the closed orbit?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\n\n// Lotka-Volterra parameters: prey grows (a), eaten (b); predator dies (c), fed (d).\nconst a = 1.1, b = 0.4, d = 0.1, c = 0.4;\n// Integrate forward each frame from a fixed start so motion is a pure fn of t.\nconst dt = 0.02;\nconst steps = Math.min(900, Math.floor((t % 30) / dt) + 1);\nlet x = 10, y = 5; // prey, predator populations\nconst trail = [[x, y]];\nfor (let i = 0; i < steps; i++) {\n const dx = a * x - b * x * y;\n const dy = d * x * y - c * y;\n // midpoint to keep the closed orbit from spiralling out numerically\n const xm = x + 0.5 * dt * dx;\n const ym = y + 0.5 * dt * dy;\n const dxm = a * xm - b * xm * ym;\n const dym = d * xm * ym - c * ym;\n x = Math.max(0.01, x + dt * dxm);\n y = Math.max(0.01, y + dt * dym);\n if (i % 3 === 0) trail.push([x, y]);\n}\n\n// LEFT: time series of both populations.\nconst vt = H.plot2d({\n xMin: 0, xMax: 30, yMin: 0, yMax: 28,\n box: { x: 60, y: 70, w: w * 0.52 - 70, h: h - 150 },\n});\nvt.grid(); vt.axes();\n// Rebuild the two time series up to \"now\".\nconst preyPts = [], predPts = [];\nlet xx = 10, yy = 5;\nfor (let i = 0; i <= steps; i++) {\n const tm = i * dt;\n if (i % 2 === 0) { preyPts.push([tm, xx]); predPts.push([tm, yy]); }\n const dx = a * xx - b * xx * yy;\n const dy = d * xx * yy - c * yy;\n const xm = xx + 0.5 * dt * dx, ym = yy + 0.5 * dt * dy;\n const dxm = a * xm - b * xm * ym, dym = d * xm * ym - c * ym;\n xx = Math.max(0.01, xx + dt * dxm);\n yy = Math.max(0.01, yy + dt * dym);\n}\nvt.path(preyPts, { color: H.colors.good, width: 2.6 });\nvt.path(predPts, { color: H.colors.warn, width: 2.6 });\nvt.dot(Math.min(30, steps * dt), x, { r: 5, fill: H.colors.good });\nvt.dot(Math.min(30, steps * dt), y, { r: 5, fill: H.colors.warn });\nH.text(\"population\", 60, 60, { color: H.colors.sub, size: 12 });\nH.text(\"time\", vt.box.x + vt.box.w - 26, vt.box.y + vt.box.h + 30,\n { color: H.colors.sub, size: 12 });\n\n// RIGHT: phase portrait (predator vs prey) - the closed orbit.\nconst vp = H.plot2d({\n xMin: 0, xMax: 26, yMin: 0, yMax: 18,\n box: { x: w * 0.56 + 20, y: 70, w: w * 0.40 - 40, h: h - 150 },\n});\nvp.grid(); vp.axes();\nvp.path(trail, { color: H.colors.accent, width: 2.2 });\n// Equilibrium fixed point (c/d, a/b).\nvp.dot(c / d, a / b, { r: 4, fill: H.colors.yellow, stroke: H.colors.bg });\nvp.dot(x, y, { r: 6, fill: H.colors.accent2 });\nH.text(\"predator\", vp.box.x - 6, 60, { color: H.colors.sub, size: 12 });\nH.text(\"prey →\", vp.box.x + vp.box.w - 50, vp.box.y + vp.box.h + 30,\n { color: H.colors.sub, size: 12 });\n\n// Title, caption, live readout, legend.\nH.text(\"Lotka–Volterra predator–prey cycles\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Prey booms feed predators; predators crash, prey recover — a closed loop.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"prey = \" + x.toFixed(1) + \" predators = \" + y.toFixed(1)\n + \" t = \" + (steps * dt).toFixed(1) + \"s\",\n w * 0.56 + 20, 52, { color: H.colors.sub, size: 13 });\nH.legend([\n { label: \"prey (rabbits)\", color: H.colors.good },\n { label: \"predators (foxes)\", color: H.colors.warn },\n { label: \"phase orbit\", color: H.colors.accent },\n], 60, h - 46);"
},
{
"id": "neuron-action-potential",
"title": "Neuron Action Potential",
"tag": "Neuroscience",
"dimension": "2D",
"equation": "Vm(t): rest −70 mV → threshold −55 → peak +40 → AHP −80",
"summary": "A repeating action-potential trace plots membrane voltage versus time with threshold and resting reference lines, while a live phase label and an animated Na⁺/K⁺ channel diagram show which ion current is driving each stage of the spike.",
"keywords": [
"action potential",
"neuron",
"membrane potential",
"depolarization",
"repolarization",
"hyperpolarization",
"sodium potassium",
"na+ k+ channel",
"neuroscience",
"spike",
"threshold",
"resting potential",
"refractory period",
"electrophysiology"
],
"bullets": [
"Slow depolarization to −55 mV threshold triggers an all-or-none spike to +40 mV.",
"Voltage-gated Na⁺ channels open first (rising phase); K⁺ channels open to repolarize.",
"K⁺ efflux overshoots to an after-hyperpolarization before the −70 mV resting state returns."
],
"student_prompts": [
"Why is the action potential described as 'all-or-none'?",
"What causes the refractory period and why can't a second spike fire immediately?",
"How do voltage-gated Na⁺ and K⁺ channels differ in their opening and closing timing?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\n\n// Action-potential waveform as a function of phase within a repeating spike.\n// Resting -70 mV, threshold -55, peak +40, after-hyperpolarization -80.\nconst period = 4.0; // seconds between spikes (slowed for teaching)\nconst phase = (t % period);\nfunction Vm(ph) {\n const rest = -70, thr = -55, peak = 40, ahp = -80;\n if (ph < 1.2) { // resting / slow depolarization to threshold\n return H.lerp(rest, thr, H.ease(ph / 1.2));\n } else if (ph < 1.55) { // fast Na+ influx: depolarize to peak\n return H.lerp(thr, peak, H.ease((ph - 1.2) / 0.35));\n } else if (ph < 2.1) { // K+ efflux: repolarize\n return H.lerp(peak, ahp, H.ease((ph - 1.55) / 0.55));\n } else if (ph < 2.9) { // after-hyperpolarization recovers to rest\n return H.lerp(ahp, rest, H.ease((ph - 2.1) / 0.8));\n }\n return rest;\n}\nconst v = Vm(phase);\n\n// Plot the membrane potential trace over one cycle.\nconst view = H.plot2d({\n xMin: 0, xMax: period, yMin: -90, yMax: 50,\n box: { x: 64, y: 78, w: w - 230, h: h - 160 },\n});\nview.grid(); view.axes();\n\n// Threshold and resting reference lines.\nview.line(0, -55, period, -55, { color: H.colors.violet, width: 1.4, dash: [6, 5] });\nview.text(\"threshold –55 mV\", period * 0.62, -49,\n { color: H.colors.violet, size: 11 });\nview.line(0, -70, period, -70, { color: H.colors.axis, width: 1.2, dash: [3, 5] });\nview.text(\"rest –70 mV\", period * 0.02, -66, { color: H.colors.sub, size: 11 });\n\n// Full waveform (faint) + the swept-in portion (bright).\nconst full = [], swept = [];\nconst M = 160;\nfor (let i = 0; i <= M; i++) {\n const ph = (i / M) * period;\n const pt = [ph, Vm(ph)];\n full.push(pt);\n if (ph <= phase) swept.push(pt);\n}\nview.path(full, { color: H.hsl(205, 40, 45, 0.35), width: 2 });\nif (swept.length > 1) view.path(swept, { color: H.colors.accent, width: 3 });\nview.dot(phase, v, { r: 6, fill: H.colors.accent2 });\n\n// Phase label that names the current ion event.\nlet stage = \"resting\", sc = H.colors.sub;\nif (phase >= 1.2 && phase < 1.55) { stage = \"depolarization (Na+ in)\"; sc = H.colors.warn; }\nelse if (phase >= 1.55 && phase < 2.1) { stage = \"repolarization (K+ out)\"; sc = H.colors.accent; }\nelse if (phase >= 2.1 && phase < 2.9) { stage = \"hyperpolarization (refractory)\"; sc = H.colors.violet; }\nelse if (phase >= 0.7 && phase < 1.2) { stage = \"approaching threshold\"; sc = H.colors.good; }\n\n// A small membrane channel diagram on the right that opens/closes with phase.\nconst mx = w - 140, my = h * 0.5;\nH.text(\"membrane\", mx - 20, my - 92, { color: H.colors.sub, size: 12 });\nH.line(mx - 44, my - 70, mx + 44, my - 70, { color: H.colors.axis, width: 2 });\nH.line(mx - 44, my + 70, mx + 44, my + 70, { color: H.colors.axis, width: 2 });\nconst naOpen = phase >= 1.2 && phase < 1.6;\nconst kOpen = phase >= 1.5 && phase < 2.3;\n// Na+ channel\nH.circle(mx - 22, my, 16, { stroke: H.colors.warn, width: 2, fill: naOpen ? H.hsl(350, 70, 40, 0.5) : \"rgba(0,0,0,0)\" });\nH.text(\"Na⁺\", mx - 33, my + 4, { color: H.colors.warn, size: 11 });\nif (naOpen) H.arrow(mx - 22, my - 60, mx - 22, my + 18, { color: H.colors.warn, width: 2, head: 7 });\n// K+ channel\nH.circle(mx + 22, my, 16, { stroke: H.colors.accent, width: 2, fill: kOpen ? H.hsl(205, 70, 45, 0.5) : \"rgba(0,0,0,0)\" });\nH.text(\"K⁺\", mx + 13, my + 4, { color: H.colors.accent, size: 11 });\nif (kOpen) H.arrow(mx + 22, my + 18, mx + 22, my + 84, { color: H.colors.accent, width: 2, head: 7 });\n\n// Titles + live readout.\nH.text(\"Neuron action potential\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Membrane voltage spikes as Na⁺ rushes in, then K⁺ restores rest.\",\n 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"Vm = \" + v.toFixed(1) + \" mV\", 24, 76, { color: H.colors.accent2, size: 14, weight: 600 });\nH.text(\"phase: \" + stage, 200, 76, { color: sc, size: 13, weight: 600 });\nH.text(\"mV\", 30, 70, { color: H.colors.sub, size: 12 });\nH.text(\"time (cycle)\", view.box.x + view.box.w - 70, view.box.y + view.box.h + 30,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"membrane potential Vm\", color: H.colors.accent },\n { label: \"Na⁺ influx\", color: H.colors.warn },\n { label: \"K⁺ efflux / AHP\", color: H.colors.violet },\n], 64, h - 46);"
},
{
"id": "heat-diffusion-plate-colormap",
"title": "Heat diffusion across a plate",
"tag": "Thermodynamics",
"dimension": "2D",
"equation": "u_t = α ∇²u",
"summary": "A hot spot on a square metal plate spreads out and cools toward the fixed-cold edges, shown as a live blue-to-red temperature color map driven by the analytic Fourier-mode solution of the heat equation.",
"keywords": [
"heat diffusion",
"heat equation",
"thermal conduction",
"temperature field",
"color map",
"heatmap",
"fourier modes",
"diffusivity",
"laplacian",
"cooling plate",
"conduction",
"thermodynamics",
"u_t = alpha laplacian u",
"heat spreading"
],
"bullets": [
"Each Fourier sine mode decays like exp(-α π²(m²+n²)t), so fine/sharp features (high m,n) vanish fastest — the bump smooths before it fades.",
"The color bar maps temperature relative to the initial peak T0; watch the hottest cell drop from 100% toward 0 as heat leaks to the cold boundary.",
"Larger thermal diffusivity α makes every mode decay faster, so a more conductive plate equilibrates sooner."
],
"student_prompts": [
"What happens if I make the diffusivity α ten times larger?",
"How would the picture change if the edges were insulated instead of held cold?",
"Why do the sharp, high-frequency features in the heat map disappear before the broad ones?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\n// ---- Heat diffusion on a square plate, u_t = alpha * laplacian(u) ----\n// Closed-form solution on [0,1]x[0,1] with fixed cold edges (u=0 on boundary):\n// a hot Gaussian-ish initial bump decays as a sum of sine modes, each mode\n// shrinking like exp(-alpha*pi^2*(m^2+n^2)*tau). Stable, never blows up.\nconst alpha = 0.18; // thermal diffusivity (arb. units)\nconst tau = (t % 14) * 1.0; // simulation clock, loops every 14 s\n// Plate pixel box\nconst M = 26; // grid cells per side\nconst plateX = 70, plateY = 96;\nconst plateW = Math.min(w - 320, h - 150);\nconst cell = plateW / M;\nconst plateH = plateW;\n// Precompute a small set of Fourier amplitudes for an initial off-center hot spot.\nconst modes = [];\nconst MX = 5, MY = 5;\nconst cx0 = 0.38, cy0 = 0.42; // initial hot-spot center (unit square)\nfor (let mx = 1; mx <= MX; mx++) {\n for (let my = 1; my <= MY; my++) {\n // amplitude ~ projection of a localized bump onto sin modes\n const amp =\n Math.sin(mx * Math.PI * cx0) *\n Math.sin(my * Math.PI * cy0) *\n Math.exp(-0.5 * (mx * mx + my * my) * 0.10);\n modes.push({ mx, my, amp, lam: (mx * mx + my * my) });\n }\n}\n// Temperature field T(x,y,tau) in [0,1]; track max for the readout.\nconst temp = (ux, uy) => {\n let s = 0;\n for (let k = 0; k < modes.length; k++) {\n const m = modes[k];\n s +=\n m.amp *\n Math.sin(m.mx * Math.PI * ux) *\n Math.sin(m.my * Math.PI * uy) *\n Math.exp(-alpha * Math.PI * Math.PI * m.lam * tau);\n }\n return s;\n};\n// Color ramp cold(blue) -> hot(red/white). v in [0,1].\nconst heatColor = (v) => {\n v = H.clamp(v, 0, 1);\n // blue(230) -> cyan -> yellow -> red(0), brighten toward the top end.\n const hue = H.lerp(235, 0, v);\n const light = H.lerp(22, 62, v);\n const sat = H.lerp(70, 92, v);\n return H.hsl(hue, sat, light);\n};\n// Normalize against the t=0 peak so the color scale is steady.\nlet peak0 = 1e-6;\nfor (let i = 0; i < M; i++) {\n for (let j = 0; j < M; j++) {\n const v = temp((i + 0.5) / M, (j + 0.5) / M);\n if (v > peak0) peak0 = v;\n }\n}\nlet curMax = 0;\nfor (let j = 0; j < M; j++) {\n for (let i = 0; i < M; i++) {\n const ux = (i + 0.5) / M;\n const uy = (j + 0.5) / M;\n let v = temp(ux, uy) / peak0;\n if (v < 0) v = 0; // temperature above ambient is nonnegative\n if (v > curMax) curMax = v;\n const px = plateX + i * cell;\n const py = plateY + (M - 1 - j) * cell; // flip so +y is up\n H.rect(px, py, cell + 0.8, cell + 0.8, { fill: heatColor(v) });\n }\n}\n// Plate frame\nH.rect(plateX, plateY, plateW, plateH, { stroke: H.colors.axis, width: 1.6 });\n// Axis ticks (physical plate coordinates 0..1)\nfor (let g = 0; g <= 1.0001; g += 0.25) {\n const gx = plateX + g * plateW;\n const gy = plateY + plateH;\n H.text(g.toFixed(2), gx, gy + 16, { color: H.colors.sub, size: 11, align: \"center\" });\n H.text(g.toFixed(2), plateX - 8, plateY + (1 - g) * plateH, { color: H.colors.sub, size: 11, align: \"right\", baseline: \"middle\" });\n}\nH.text(\"x\", plateX + plateW / 2, plateY + plateH + 32, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"y\", plateX - 30, plateY + plateH / 2, { color: H.colors.sub, size: 12, align: \"center\", baseline: \"middle\" });\n// ---- Color legend bar ----\nconst barX = plateX + plateW + 40, barY = plateY, barW = 22, barH = plateH;\nconst segs = 40;\nfor (let k = 0; k < segs; k++) {\n const v = 1 - k / segs;\n H.rect(barX, barY + (k / segs) * barH, barW, barH / segs + 0.8, { fill: heatColor(v) });\n}\nH.rect(barX, barY, barW, barH, { stroke: H.colors.axis, width: 1.2 });\nH.text(\"hot\", barX + barW + 8, barY + 6, { color: H.colors.warn, size: 12 });\nH.text(\"cold\", barX + barW + 8, barY + barH, { color: H.colors.accent, size: 12 });\nH.text(\"T / T0\", barX, barY - 12, { color: H.colors.sub, size: 12 });\n// ---- Title + live readout ----\nH.text(\"Heat diffusion across a plate\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"u_t = α ∇²u — a hot spot spreads and cools toward the edges\", 24, 56, { color: H.colors.sub, size: 13 });\nconst peakPct = (curMax * 100);\nH.text(\"t = \" + tau.toFixed(2) + \" s\", barX - 4, barY + barH + 60, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"peak temp = \" + peakPct.toFixed(1) + \"% of T0\", barX - 4, barY + barH + 82, { color: H.colors.accent2, size: 13 });\nH.text(\"α = \" + alpha.toFixed(2), barX - 4, barY + barH + 102, { color: H.colors.sub, size: 13 });"
},
{
"id": "ideal-gas-particles-box-3d",
"title": "Ideal gas in a box",
"tag": "Statistical Mechanics",
"dimension": "3D",
"equation": "PV = NkT, T ∝ ⟨v²⟩",
"summary": "Atoms of an ideal gas fly in straight lines and bounce elastically off the walls of a 3D box, each colored by its speed; a live left/right count shows the gas stays statistically uniform while temperature tracks the mean square speed.",
"keywords": [
"ideal gas",
"gas particles",
"kinetic theory",
"molecules in a box",
"elastic collisions",
"gas law",
"pv = nkt",
"temperature kinetic energy",
"atoms bouncing",
"gas pressure",
"statistical mechanics",
"particle simulation",
"3d box gas"
],
"bullets": [
"Each atom moves at constant velocity between walls and reverses on impact (elastic collision), so total kinetic energy — and therefore temperature — is conserved.",
"Faster atoms are drawn warmer (orange) and slower ones cooler (blue); temperature is proportional to the mean of v², not v.",
"The instantaneous left|right tally near 14|14 shows that random motion keeps the two halves of the box equally populated — the basis of uniform pressure."
],
"student_prompts": [
"How is the pressure on a wall related to how fast and how often atoms hit it?",
"If I doubled the temperature, how would the typical atom speed change?",
"Why does the gas stay evenly spread between the two halves instead of clumping?"
],
"code": "H.background();\n// ---- Ideal gas: N atoms bouncing elastically inside a 3D box ----\nconst cam = H.cam3d({ scale: 30, dist: 16, pitch: -0.35, cy: H.H * 0.52 });\ncam.yaw = 0.25 * t; // slow auto-spin so it reads as 3D\nconst L = 3.2; // half-box size (world units)\n// Deterministic pseudo-random per-particle parameters (pure function of t-free\n// seeds) so motion is smooth and reproducible each frame.\nconst N = 28;\nconst rand = (i, k) => {\n const s = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453;\n return s - Math.floor(s); // 0..1\n};\n// Continuous triangle wave: a particle bouncing elastically between walls at\n// -lim and +lim, given a linearly advancing argument.\nconst tri = (val, lim) => {\n // continuous triangle wave bouncing in [-lim, lim] given a linear val\n const span = 2 * lim;\n let m = ((val % (2 * span)) + 2 * span) % (2 * span); // 0..2span\n if (m > span) m = 2 * span - m; // 0..span\n return m - lim; // -lim..lim\n};\n// Draw box wireframe (depth-sorted edges look fine as plain lines)\nconst c = [\n [-L, -L, -L], [L, -L, -L], [L, L, -L], [-L, L, -L],\n [-L, -L, L], [L, -L, L], [L, L, L], [-L, L, L],\n];\nconst edges = [\n [0,1],[1,2],[2,3],[3,0], [4,5],[5,6],[6,7],[7,4], [0,4],[1,5],[2,6],[3,7],\n];\ncam.grid(L, L, { color: H.colors.grid });\nedges.forEach((e) => cam.line(c[e[0]], c[e[1]], { color: H.colors.axis, width: 1.3 }));\n// Particle positions + speeds\nconst r = 0.16;\nlet vSum = 0, vMax = 0, leftCount = 0;\nconst balls = [];\nfor (let i = 0; i < N; i++) {\n const sx = 0.4 + rand(i, 1) * 0.9; // speed components (world units / s)\n const sy = 0.4 + rand(i, 2) * 0.9;\n const sz = 0.4 + rand(i, 3) * 0.9;\n const ph = rand(i, 4) * 100; // phase offset\n const x = tri(sx * t + ph, L - r);\n const y = tri(sy * t + ph * 1.3, L - r);\n const z = tri(sz * t + ph * 0.7, L - r);\n const speed = Math.sqrt(sx * sx + sy * sy + sz * sz);\n vSum += speed;\n if (speed > vMax) vMax = speed;\n if (x < 0) leftCount++; // instantaneous count in the x<0 half\n // color warm = fast, cool = slow\n const frac = H.clamp((speed - 0.7) / 1.6, 0, 1);\n const col = H.hsl(H.lerp(205, 25, frac), 85, H.lerp(58, 60, frac));\n balls.push({ p: [x, y, z], r: r, col: col, depth: cam.project([x, y, z]).depth });\n}\nballs.sort((a, b) => b.depth - a.depth).forEach((o) => cam.sphere(o.p, o.r, { color: o.col }));\n// Faint divider plane at x=0 to make the left/right count meaningful.\ncam.line([0, -L, -L], [0, -L, L], { color: H.colors.violet, width: 1 });\ncam.line([0, -L, -L], [0, L, -L], { color: H.colors.violet, width: 1 });\ncam.axes(L + 0.6);\nconst vAvg = vSum / N;\nconst rightCount = N - leftCount;\n// ---- Title + live readout ----\nH.text(\"Ideal gas in a box\", 24, 34, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"N atoms in constant elastic motion — temperature ∝ mean kinetic energy\", 24, 56, { color: H.colors.sub, size: 13 });\nH.text(\"N = \" + N + \" atoms\", 24, H.H - 100, { color: H.colors.accent, size: 13 });\nH.text(\"⟨v⟩ = \" + vAvg.toFixed(3) + \" (arb.)\", 24, H.H - 78, { color: H.colors.ink, size: 14, weight: 700 });\nH.text(\"T ∝ ⟨v²⟩ → \" + (vAvg * vAvg).toFixed(3), 24, H.H - 56, { color: H.colors.accent2, size: 13 });\nH.text(\"left | right = \" + leftCount + \" | \" + rightCount + \" (t = \" + (t % 100).toFixed(1) + \" s)\", 24, H.H - 34, { color: H.colors.violet, size: 13 });\nH.legend([\n { label: \"fast (hot)\", color: H.hsl(25, 85, 60) },\n { label: \"slow (cool)\", color: H.hsl(205, 85, 58) },\n], H.W - 150, H.H - 70);"
},
{
"id": "maxwell-boltzmann-speed-distribution",
"title": "Maxwell–Boltzmann speed distribution",
"tag": "Statistical Mechanics",
"dimension": "2D",
"equation": "f(v) = √(2/π) · v² e^(−v²/2a²) / a³, a = √(kT/m)",
"summary": "A histogram of thousands of random molecular speeds fills in over time and converges onto the analytic Maxwell-Boltzmann curve, with the most-probable speed marked and temperature slowly breathing.",
"keywords": [
"maxwell-boltzmann",
"speed distribution",
"molecular speeds",
"velocity distribution",
"kinetic theory",
"statistical mechanics",
"most probable speed",
"rms speed",
"mean speed",
"histogram converging",
"probability density",
"gas speeds",
"thermal distribution",
"boltzmann"
],
"bullets": [
"The curve f(v) rises as v² for slow molecules but is killed by the e^(−v²/2a²) factor at high speed, giving the characteristic skewed peak.",
"Three speeds differ: most-probable v_p = a√2 (the peak), mean ⟨v⟩ = a√(8/π), and root-mean-square v_rms = a√3 — always v_p < ⟨v⟩ < v_rms.",
"As more samples accumulate, the noisy histogram settles onto the smooth theoretical density; raising temperature T widens the curve and shifts the peak right."
],
"student_prompts": [
"Why is the most-probable speed smaller than the average speed?",
"How does the whole distribution change when the gas is heated?",
"Where does the v² factor in front of the exponential come from?"
],
"code": "H.background();\n// ---- Maxwell-Boltzmann speed distribution forming from samples ----\n// Analytic 3D speed pdf (mass=kT=1 reduced units):\n// f(v) = sqrt(2/pi) * v^2 * exp(-v^2 / (2 a^2)) / a^3, a = sqrt(kT/m)\n// We let temperature breathe slowly and draw both the smooth curve and a live\n// histogram of N pseudo-random samples that \"fills in\" toward the curve.\nconst T = 1.6 + 0.6 * Math.sin(t * 0.35); // temperature (>0 always)\nconst a = Math.sqrt(T); // scale parameter\nconst vMaxAxis = 6;\nconst v = H.plot2d({ xMin: 0, xMax: vMaxAxis, yMin: 0, yMax: 0.7, pad: 56 });\nv.grid(); v.axes();\nconst pdf = (x) => {\n if (x < 0) return 0;\n const a2 = a * a;\n return Math.sqrt(2 / Math.PI) * (x * x) * Math.exp(-(x * x) / (2 * a2)) / (a2 * a);\n};\n// ---- Histogram of samples (Box-Muller -> 3 normals -> speed) ----\nconst bins = 24;\nconst counts = new Array(bins).fill(0);\n// Number of samples grows with time then holds — the distribution \"forms\".\nconst Nmax = 1400;\nconst N = Math.min(Nmax, Math.floor(60 + (t % 16) * 130));\nconst rnd = (k) => {\n const s = Math.sin(k * 12.9898) * 43758.5453;\n return s - Math.floor(s);\n};\nconst normal = (k) => {\n // Box-Muller from two deterministic uniforms\n let u1 = rnd(k * 2 + 1); let u2 = rnd(k * 2 + 2);\n u1 = Math.min(Math.max(u1, 1e-6), 1 - 1e-6);\n return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\nlet speedSum = 0, vrmsSum = 0;\nfor (let i = 0; i < N; i++) {\n const base = i * 3 + Math.floor(t) * 9973; // shuffle seed slowly with time\n const vx = a * normal(base + 1);\n const vy = a * normal(base + 2);\n const vz = a * normal(base + 3);\n const sp = Math.sqrt(vx * vx + vy * vy + vz * vz);\n speedSum += sp;\n vrmsSum += sp * sp;\n const b = Math.floor((sp / vMaxAxis) * bins);\n if (b >= 0 && b < bins) counts[b]++;\n}\n// Draw histogram bars (normalized to a density so they sit under the curve)\nconst binW = vMaxAxis / bins;\nfor (let b = 0; b < bins; b++) {\n const density = counts[b] / (N * binW); // probability density estimate\n if (density <= 0) continue;\n const x0 = b * binW;\n H.rect(\n v.X(x0), v.Y(density), v.X(x0 + binW) - v.X(x0), v.Y(0) - v.Y(density),\n { fill: H.hsl(205, 70, 52, 0.45), stroke: H.hsl(205, 70, 66, 0.7), width: 1 }\n );\n}\n// Analytic curve on top\nv.fn(pdf, { color: H.colors.accent2, width: 3 });\n// Mark characteristic speeds: most-probable v_p = a*sqrt(2)\nconst vp = a * Math.SQRT2;\nconst vAvg = a * Math.sqrt(8 / Math.PI);\nconst vRms = a * Math.sqrt(3);\nH.line(v.X(vp), v.Y(0), v.X(vp), v.Y(pdf(vp)), { color: H.colors.good, width: 2, dash: [5, 5] });\nv.dot(vp, pdf(vp), { r: 5, fill: H.colors.good });\nH.text(\"v_p\", v.X(vp) + 6, v.Y(pdf(vp)) - 6, { color: H.colors.good, size: 12 });\n// Axis labels\nH.text(\"speed v (arb. units)\", v.box.x + v.box.w / 2, v.box.y + v.box.h + 38, { color: H.colors.sub, size: 12, align: \"center\" });\nH.text(\"f(v)\", v.box.x - 40, v.box.y + 8, { color: H.colors.sub, size: 12 });\n// ---- Title + live readouts ----\nH.text(\"Maxwell–Boltzmann speed distribution\", 24, 32, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Histogram of \" + N + \" random molecular speeds filling in the analytic curve\", 24, 54, { color: H.colors.sub, size: 13 });\nconst measAvg = N > 0 ? speedSum / N : 0;\nconst measRms = N > 0 ? Math.sqrt(vrmsSum / N) : 0;\nH.legend([\n { label: \"analytic f(v)\", color: H.colors.accent2 },\n { label: \"sampled histogram\", color: H.hsl(205, 70, 60) },\n { label: \"v_p (most probable)\", color: H.colors.good },\n], H.W - 230, 92);\nH.text(\"T = \" + T.toFixed(2) + \" N = \" + N, H.W - 230, 168, { color: H.colors.ink, size: 13, weight: 700 });\nH.text(\"⟨v⟩ ≈ \" + measAvg.toFixed(2) + \" (theory \" + vAvg.toFixed(2) + \")\", H.W - 230, 188, { color: H.colors.sub, size: 12 });\nH.text(\"v_rms ≈ \" + measRms.toFixed(2) + \" (theory \" + vRms.toFixed(2) + \")\", H.W - 230, 206, { color: H.colors.sub, size: 12 });"
},
{
"id": "matrix-transform-eigenvectors",
"title": "2x2 Matrix Transforming the Plane",
"tag": "Linear Algebra",
"dimension": "2D",
"equation": "M = [[2,1],[1,2]], eigenvalues 3 and 1",
"summary": "A 2x2 matrix smoothly morphs the coordinate grid and the unit square between identity and M = [[2,1],[1,2]]. The basis-vector images i and j swing, the square's area scales by det(M), and the two eigenvectors stay pinned on their dashed span lines while only their lengths (the eigenvalues) change.",
"keywords": [
"matrix",
"2x2 matrix",
"linear transformation",
"transform the plane",
"unit square",
"determinant",
"eigenvector",
"eigenvalue",
"eigenvectors stay on span",
"basis vectors",
"i hat",
"j hat",
"shear",
"scaling"
],
"bullets": [
"The image of the unit square is a parallelogram whose area equals det(M).",
"Eigenvectors never leave their own line (span); the matrix only stretches them by lambda.",
"i-hat and j-hat are the columns of M, so watching them tells you the whole transform."
],
"student_prompts": [
"Why does the area of the square equal the determinant of M?",
"What happens to the eigenvectors if an eigenvalue is negative?",
"How do I compute the eigenvectors of [[2,1],[1,2]] by hand?"
],
"code": "H.background();\nconst v = H.plot2d({ xMin: -5, xMax: 5, yMin: -3.6, yMax: 3.6, pad: 50 });\nconst s = (1 - Math.cos(t * 0.8)) * 0.5;\nconst m00 = H.lerp(1, 2, s), m01 = H.lerp(0, 1, s);\nconst m10 = H.lerp(0, 1, s), m11 = H.lerp(1, 2, s);\nconst apply = (x, y) => [m00 * x + m01 * y, m10 * x + m11 * y];\nfor (let gx = -5; gx <= 5; gx++) {\n const pts = [];\n for (let gy = -4; gy <= 4; gy += 0.5) pts.push(apply(gx, gy));\n v.path(pts, { color: H.colors.grid, width: 1 });\n}\nfor (let gy = -4; gy <= 4; gy++) {\n const pts = [];\n for (let gx = -5; gx <= 5; gx += 0.5) pts.push(apply(gx, gy));\n v.path(pts, { color: H.colors.grid, width: 1 });\n}\nv.axes();\nconst sq = [[0, 0], [1, 0], [1, 1], [0, 1]].map((p) => apply(p[0], p[1]));\nv.path(sq, { color: H.colors.accent2, width: 2.4, close: true, fill: \"rgba(244,162,89,0.18)\" });\nconst ix = apply(1, 0), jx = apply(0, 1);\nv.arrow(0, 0, ix[0], ix[1], { color: H.colors.accent, width: 3 });\nv.arrow(0, 0, jx[0], jx[1], { color: H.colors.good, width: 3 });\nv.text(\"i\", ix[0] * 0.55 + 0.15, ix[1] * 0.55 - 0.2, { color: H.colors.accent, size: 14 });\nv.text(\"j\", jx[0] * 0.55 - 0.35, jx[1] * 0.55 + 0.1, { color: H.colors.good, size: 14 });\nconst e1 = [1 / Math.SQRT2, 1 / Math.SQRT2];\nconst e2 = [1 / Math.SQRT2, -1 / Math.SQRT2];\nconst lam1 = H.lerp(1, 3, s), lam2 = 1;\nv.line(-4 * e1[0], -4 * e1[1], 4 * e1[0], 4 * e1[1], { color: H.colors.violet, width: 1.2, dash: [6, 6] });\nv.line(-4 * e2[0], -4 * e2[1], 4 * e2[0], 4 * e2[1], { color: H.colors.yellow, width: 1.2, dash: [6, 6] });\nv.arrow(0, 0, lam1 * e1[0], lam1 * e1[1], { color: H.colors.violet, width: 3.2 });\nv.arrow(0, 0, lam2 * e2[0], lam2 * e2[1], { color: H.colors.yellow, width: 3.2 });\nv.text(\"L1 = \" + lam1.toFixed(2), lam1 * e1[0] + 0.2, lam1 * e1[1] + 0.3, { color: H.colors.violet, size: 13 });\nv.text(\"L2 = \" + lam2.toFixed(2), lam2 * e2[0] + 0.3, lam2 * e2[1] - 0.25, { color: H.colors.yellow, size: 13 });\nconst det = m00 * m11 - m01 * m10;\nH.text(\"2x2 matrix transforming the plane\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"Eigenvectors keep their direction; the square's area scales by det.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"M = [[\" + m00.toFixed(2) + \", \" + m01.toFixed(2) + \"], [\" + m10.toFixed(2) + \", \" + m11.toFixed(2) + \"]]\", 24, 78, { color: H.colors.accent, size: 13 });\nH.text(\"det(M) = \" + det.toFixed(2) + \" (unit-square area)\", 24, 98, { color: H.colors.accent2, size: 13 });\nH.legend([\n { label: \"i, j (basis images)\", color: H.colors.accent },\n { label: \"eigvec L1 span\", color: H.colors.violet },\n { label: \"eigvec L2 span\", color: H.colors.yellow },\n], H.W - 210, 96);"
},
{
"id": "vector-addition-dot-projection",
"title": "Vector Addition & Dot Product as Projection",
"tag": "Linear Algebra",
"dimension": "2D",
"equation": "a . b = |a||b|cos(theta) = |a| * (scalar projection of b)",
"summary": "A fixed vector a and a sweeping vector b show two ideas at once: the parallelogram law builds a+b from dashed translated copies, while b's shadow onto a (the vector projection) slides along a as b's angle changes. Live readouts track the dot product, cos(theta), and the scalar projection length.",
"keywords": [
"vector addition",
"parallelogram law",
"resultant",
"dot product",
"scalar product",
"inner product",
"projection",
"vector projection",
"scalar projection",
"shadow",
"cosine angle",
"orthogonal",
"components",
"a dot b"
],
"bullets": [
"a+b is the diagonal of the parallelogram spanned by a and b (tip-to-tail also works).",
"The dot product equals |a| times the signed length of b's projection onto a.",
"When b is perpendicular to a the dot product is 0 and the projection collapses to the origin."
],
"student_prompts": [
"Why does a . b become negative when the angle between a and b exceeds 90 degrees?",
"How is the scalar projection different from the vector projection?",
"Can you show the tip-to-tail method of adding a and b instead of the parallelogram?"
],
"code": "H.background();\nconst v = H.plot2d({ xMin: -1.5, xMax: 6.5, yMin: -1.5, yMax: 5, pad: 50 });\nv.grid();\nv.axes();\nconst a = [4, 1];\nconst ang = 1.05 + 0.85 * Math.sin(t * 0.6);\nconst bMag = 3.2;\nconst b = [bMag * Math.cos(ang), bMag * Math.sin(ang)];\nconst sum = [a[0] + b[0], a[1] + b[1]];\nv.line(a[0], a[1], sum[0], sum[1], { color: H.colors.good, width: 1.4, dash: [5, 5] });\nv.line(b[0], b[1], sum[0], sum[1], { color: H.colors.accent, width: 1.4, dash: [5, 5] });\nv.arrow(0, 0, sum[0], sum[1], { color: H.colors.violet, width: 3 });\nv.text(\"a + b\", sum[0] + 0.15, sum[1] + 0.25, { color: H.colors.violet, size: 14 });\nv.arrow(0, 0, a[0], a[1], { color: H.colors.accent, width: 3.4 });\nv.arrow(0, 0, b[0], b[1], { color: H.colors.good, width: 3.4 });\nv.text(\"a\", a[0] * 0.6 + 0.1, a[1] * 0.6 - 0.25, { color: H.colors.accent, size: 15 });\nv.text(\"b\", b[0] * 0.6 - 0.3, b[1] * 0.6 + 0.2, { color: H.colors.good, size: 15 });\nconst aLen2 = a[0] * a[0] + a[1] * a[1];\nconst aLen = Math.sqrt(aLen2);\nconst dot = a[0] * b[0] + a[1] * b[1];\nconst scal = dot / aLen2;\nconst proj = [scal * a[0], scal * a[1]];\nv.line(b[0], b[1], proj[0], proj[1], { color: H.colors.warn, width: 1.6, dash: [4, 4] });\nv.line(0, 0, proj[0], proj[1], { color: H.colors.accent2, width: 5 });\nv.dot(proj[0], proj[1], { r: 5, fill: H.colors.accent2 });\nv.text(\"proj_a b\", proj[0] - 0.2, proj[1] - 0.35, { color: H.colors.accent2, size: 13 });\nconst scalarProj = dot / aLen;\nconst cosA = dot / (aLen * bMag);\nH.text(\"Vector addition + dot product as projection\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"a+b closes the parallelogram; a.b measures b's shadow on a.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"a = (\" + a[0].toFixed(1) + \", \" + a[1].toFixed(1) + \") b = (\" + b[0].toFixed(2) + \", \" + b[1].toFixed(2) + \")\", 24, 78, { color: H.colors.sub, size: 13 });\nH.text(\"a . b = \" + dot.toFixed(2) + \" = |a||b|cos(t), cos = \" + cosA.toFixed(2), 24, 98, { color: H.colors.accent2, size: 13 });\nH.text(\"scalar proj = a.b/|a| = \" + scalarProj.toFixed(2), 24, 118, { color: H.colors.warn, size: 13 });\nH.legend([\n { label: \"a\", color: H.colors.accent },\n { label: \"b\", color: H.colors.good },\n { label: \"a + b\", color: H.colors.violet },\n { label: \"proj of b on a\", color: H.colors.accent2 },\n], H.W - 190, 96);"
},
{
"id": "rotation-matrix-3d-y-axis",
"title": "3D Rotation About the Y-Axis",
"tag": "Linear Algebra",
"dimension": "3D",
"equation": "R_y(theta) = [[cos,0,sin],[0,1,0],[-sin,0,cos]]",
"summary": "A unit cube and a reference vector v spin in 3D under the rotation matrix R_y(theta), driven by time. The vertical rotation axis is highlighted in violet and stays fixed, points on it never move, while the vector's tip sweeps a circular trail. A live angle readout and the matrix entries cos/sin update each frame.",
"keywords": [
"3d rotation",
"rotation matrix",
"r_y",
"rotate about axis",
"yaw",
"orthogonal matrix",
"rotation in space",
"spinning cube",
"axis of rotation",
"invariant axis",
"angle theta",
"cos sin matrix",
"rigid rotation",
"linear transformation 3d"
],
"bullets": [
"A rotation matrix is orthogonal: it preserves lengths and angles, so the cube never deforms.",
"Vectors lying on the rotation axis are eigenvectors with eigenvalue 1, they do not move.",
"The matrix entries are just cos(theta) and sin(theta) placed to mix the x and z coordinates."
],
"student_prompts": [
"Why is the rotation axis an eigenvector of R_y with eigenvalue 1?",
"How would the matrix change to rotate about the x-axis or z-axis instead?",
"What makes a rotation matrix orthogonal, and why does that preserve lengths?"
],
"code": "H.background();\nconst cam = H.cam3d({ scale: 52, dist: 14, pitch: -0.5, cy: H.H * 0.56 });\ncam.yaw = 0.25 * t;\ncam.grid(4, 1);\nconst theta = t * 0.9;\nconst c = Math.cos(theta), sN = Math.sin(theta);\nconst rot = (p) => [\n c * p[0] + sN * p[2],\n p[1],\n -sN * p[0] + c * p[2],\n];\ncam.line([0, -2.4, 0], [0, 2.4, 0], { color: H.colors.violet, width: 2.4 });\ncam.sphere([0, 2.4, 0], 0.12, { color: H.colors.violet });\nconst half = 1.1;\nconst corners = [];\nfor (let xi = -1; xi <= 1; xi += 2)\n for (let yi = -1; yi <= 1; yi += 2)\n for (let zi = -1; zi <= 1; zi += 2)\n corners.push(rot([xi * half, yi * half, zi * half]));\nconst edges = [[0,1],[0,2],[0,4],[1,3],[1,5],[2,3],[2,6],[3,7],[4,5],[4,6],[5,7],[6,7]];\nedges.forEach((e) => cam.line(corners[e[0]], corners[e[1]], { color: H.colors.accent, width: 1.8 }));\nconst v0 = [1.8, 0.6, 0];\nconst vr = rot(v0);\nconst o = cam.project([0, 0, 0]);\nconst g = cam.project(v0);\nconst p = cam.project(vr);\nH.arrow(o.x, o.y, g.x, g.y, { color: H.colors.sub, width: 1.6 });\nH.arrow(o.x, o.y, p.x, p.y, { color: H.colors.accent2, width: 3 });\nH.text(\"v\", p.x + 6, p.y - 4, { color: H.colors.accent2, size: 14 });\nconst trail = [];\nfor (let k = 0; k <= 40; k++) {\n const a = theta - k / 40 * Math.PI * 0.9;\n trail.push([Math.cos(a) * v0[0] + Math.sin(a) * v0[2], v0[1], -Math.sin(a) * v0[0] + Math.cos(a) * v0[2]]);\n}\ncam.path(trail, { color: H.colors.yellow, width: 1.6 });\ncam.axes(3);\nconst deg = ((theta * 180 / Math.PI) % 360 + 360) % 360;\nH.text(\"3D rotation about the y-axis\", 24, 30, { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"R_y(t) spins the cube and v; points on the axis stay put. Drag to orbit.\", 24, 52, { color: H.colors.sub, size: 13 });\nH.text(\"theta = \" + deg.toFixed(1) + \" deg\", 24, 78, { color: H.colors.accent2, size: 13 });\nH.text(\"R_y = [[cos, 0, sin],[0, 1, 0],[-sin, 0, cos]], cos=\" + c.toFixed(2) + \" sin=\" + sN.toFixed(2), 24, 98, { color: H.colors.accent, size: 13 });\nH.legend([\n { label: \"rotation axis (y)\", color: H.colors.violet },\n { label: \"rotated v\", color: H.colors.accent2 },\n { label: \"tip path\", color: H.colors.yellow },\n], H.W - 180, 96);"
},
{
"id": "beat-frequencies-sine-sum",
"title": "Beat Frequencies: Two Close Tones Add Up",
"tag": "Signals & Systems",
"dimension": "2D",
"equation": "y = sin(2*pi*f1*x) + sin(2*pi*f2*x), beat = |f1 - f2|",
"summary": "Two pure sine tones at slightly different frequencies travel across the top panel; their sum below swells and fades inside a slow beat envelope that pulses at the difference frequency |f1 - f2|. A live readout tracks the carrier, the beat rate, and the throbbing amplitude.",
"keywords": [
"beat frequency",
"beats",
"sine sum",
"superposition",
"interference",
"two tones",
"amplitude modulation",
"envelope",
"carrier frequency",
"detuned",
"constructive destructive interference",
"signals",
"acoustics",
"fourier"
],
"bullets": [
"Adding two sines of nearby frequency f1 and f2 produces a fast carrier at (f1+f2)/2 inside a slow envelope.",
"The envelope amplitude is 2A*|cos(pi*(f1-f2)*x)|, so the loudness throbs at the beat rate |f1-f2|.",
"Watch the green envelope curve hug the orange sum: where the two tones align it swells, where they cancel it pinches to zero."
],
"student_prompts": [
"Why does the beat rate equal |f1 - f2| and not f1 + f2?",
"What would I hear if f1 and f2 were identical, and what does the picture become?",
"How is this beat pattern related to amplitude modulation in radio?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\nconst f1 = 4.0;\nconst f2 = 4.6;\nconst fBeat = Math.abs(f1 - f2);\nconst fCar = (f1 + f2) / 2;\nconst A = 0.9;\nconst xMin = 0, xMax = 6;\nconst scroll = t * 0.9;\nconst top = H.plot2d({\n xMin, xMax, yMin: -1.2, yMax: 1.2,\n box: { x: 56, y: 70, w: w - 96, h: (h - 150) * 0.42 },\n});\ntop.grid({ stepX: 1, stepY: 1 });\ntop.axes({ stepX: 1, stepY: 1 });\nconst s1 = (x) => A * Math.sin(H.TAU * f1 * (x + scroll));\nconst s2 = (x) => A * Math.sin(H.TAU * f2 * (x + scroll));\ntop.fn(s1, { color: H.colors.accent, width: 2.2, steps: 480 });\ntop.fn(s2, { color: H.colors.violet, width: 2.2, steps: 480 });\ntop.text(\"tone 1 + tone 2\", xMin + 0.1, 1.05, { color: H.colors.sub, size: 12 });\nconst bot = H.plot2d({\n xMin, xMax, yMin: -2.1, yMax: 2.1,\n box: { x: 56, y: 70 + (h - 150) * 0.46 + 28, w: w - 96, h: (h - 150) * 0.46 },\n});\nbot.grid({ stepX: 1, stepY: 1 });\nbot.axes({ stepX: 1, stepY: 1 });\nconst sum = (x) => s1(x) + s2(x);\nconst env = (x) => 2 * A * Math.abs(Math.cos(Math.PI * fBeat * (x + scroll)));\nbot.fn((x) => env(x), { color: H.colors.good, width: 1.6 });\nbot.fn((x) => -env(x), { color: H.colors.good, width: 1.6 });\nbot.fn(sum, { color: H.colors.accent2, width: 2.6, steps: 520 });\nconst xm = xMin + ((t * 0.8) % (xMax - xMin));\nbot.dot(xm, sum(xm), { r: 6, fill: H.colors.yellow });\nconst ampNow = env(xm);\nbot.text(\"x = t\", xMin + 0.1, 1.9, { color: H.colors.sub, size: 12 });\nH.text(\"Beat frequencies: two close tones add up\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"sum = sin(2pi f1 x) + sin(2pi f2 x) -> amplitude throbs at |f1-f2|\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"f1 = \" + f1.toFixed(2) + \" Hz\", 24, h - 58, { color: H.colors.accent, size: 13 });\nH.text(\"f2 = \" + f2.toFixed(2) + \" Hz\", 140, h - 58, { color: H.colors.violet, size: 13 });\nH.text(\"beat = \" + fBeat.toFixed(2) + \" Hz\", 260, h - 58, { color: H.colors.good, size: 13 });\nH.text(\"envelope |amp| = \" + ampNow.toFixed(2), 410, h - 58,\n { color: H.colors.yellow, size: 13 });\nH.text(\"carrier ~ \" + fCar.toFixed(2) + \" Hz\", 410, h - 38,\n { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"tone 1\", color: H.colors.accent },\n { label: \"tone 2\", color: H.colors.violet },\n { label: \"sum\", color: H.colors.accent2 },\n { label: \"beat envelope\", color: H.colors.good },\n], w - 168, 84);"
},
{
"id": "low-pass-filter-noisy-signal",
"title": "Low-Pass Filter Smoothing a Noisy Signal",
"tag": "Signal Processing",
"dimension": "2D",
"equation": "y[n] = y[n-1] + a*(x[n] - y[n-1]), a = dt/(RC+dt), fc = 1/(2*pi*RC)",
"summary": "A scrolling noisy waveform (true signal plus jitter) flows left while a one-pole RC low-pass filter, applied sample by sample, traces a smooth orange output that follows the dashed true signal. The cutoff frequency breathes up and down with time so you can watch heavier smoothing trade noise rejection for lag.",
"keywords": [
"low-pass filter",
"lowpass",
"rc filter",
"smoothing",
"noise reduction",
"moving average",
"exponential moving average",
"cutoff frequency",
"one pole filter",
"denoise",
"signal processing",
"filtering",
"time constant",
"rolloff"
],
"bullets": [
"Each output sample is a blend of the new noisy input and the previous output: y[n] = y[n-1] + a*(x[n] - y[n-1]).",
"The smoothing factor a = dt/(RC+dt) sets the cutoff fc = 1/(2*pi*RC): smaller cutoff means smoother output but more lag.",
"Compare the gray noisy input, the dashed true signal, and the orange filtered output - the filter rejects the fast jitter while tracking the slow trend."
],
"student_prompts": [
"How does raising the cutoff frequency change the smoothing and the lag?",
"Why does a one-pole low-pass filter introduce phase delay in the output?",
"How is this discrete recursion equivalent to a physical resistor-capacitor circuit?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\nconst xMin = 0, xMax = 10;\nconst N = 260;\nconst scroll = t * 1.4;\nconst clean = (x) => 1.3 * Math.sin(0.9 * (x + scroll)) + 0.5 * Math.sin(0.42 * (x + scroll) + 0.7);\nconst samples = [];\nfor (let i = 0; i <= N; i++) {\n const x = H.lerp(xMin, xMax, i / N);\n const k = Math.round((x + scroll) * 11);\n let lseed = ((k * 2654435761) % 4294967296 + 4294967296) % 4294967296;\n lseed = (lseed * 1664525 + 1013904223) % 4294967296;\n const noise = (lseed / 4294967296 - 0.5) * 2.0;\n samples.push([x, clean(x) + noise]);\n}\nconst RC = 0.16 + 0.14 * (1 + Math.sin(t * 0.5));\nconst dx = (xMax - xMin) / N;\nconst alpha = dx / (RC + dx);\nconst fc = 1 / (H.TAU * RC);\nconst filtered = [];\nlet y = samples[0][1];\nfor (let i = 0; i <= N; i++) {\n y = y + alpha * (samples[i][1] - y);\n filtered.push([samples[i][0], y]);\n}\nconst view = H.plot2d({\n xMin, xMax, yMin: -3.4, yMax: 3.4,\n box: { x: 56, y: 86, w: w - 96, h: h - 170 },\n});\nview.grid({ stepX: 1, stepY: 1 });\nview.axes({ stepX: 1, stepY: 1 });\nview.path(samples, { color: H.colors.sub, width: 1 });\nview.path(filtered, { color: H.colors.accent2, width: 3 });\nview.path(samples.map((p, i) => [p[0], clean(p[0])]), { color: H.colors.good, width: 1.4, dash: [6, 6] });\nconst xm = xMin + ((t * 1.0) % (xMax - xMin));\nconst idx = H.clamp(Math.round((xm - xMin) / dx), 0, N);\nview.dot(filtered[idx][0], filtered[idx][1], { r: 6, fill: H.colors.accent });\nconst noiseAmp = Math.abs(samples[idx][1] - clean(samples[idx][0]));\nconst resid = Math.abs(filtered[idx][1] - clean(filtered[idx][0]));\nH.text(\"Low-pass filter smoothing a noisy signal\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"one-pole RC filter: y[n] = y[n-1] + a*(x[n] - y[n-1]), a = dt/(RC+dt)\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"cutoff fc = \" + fc.toFixed(2) + \" Hz\", 24, h - 56, { color: H.colors.accent, size: 13 });\nH.text(\"RC = \" + RC.toFixed(2) + \" s\", 190, h - 56, { color: H.colors.violet, size: 13 });\nH.text(\"a = \" + alpha.toFixed(3), 300, h - 56, { color: H.colors.yellow, size: 13 });\nH.text(\"noise in = \" + noiseAmp.toFixed(2), 400, h - 56, { color: H.colors.sub, size: 13 });\nH.text(\"error out = \" + resid.toFixed(2), 530, h - 56, { color: H.colors.accent2, size: 13 });\nH.legend([\n { label: \"noisy input\", color: H.colors.sub },\n { label: \"filtered output\", color: H.colors.accent2 },\n { label: \"true signal\", color: H.colors.good },\n], w - 184, 100);"
},
{
"id": "pid-feedback-settling-setpoint",
"title": "PID Feedback Settling to a Setpoint",
"tag": "Control Systems",
"dimension": "2D",
"equation": "u = Kp*e + Ki*integral(e dt) + Kd*de/dt, e = setpoint - output",
"summary": "A closed-loop PID controller drives a second-order plant toward a target. The green dashed setpoint steps between two levels every few seconds; the orange process output rises, overshoots, and settles inside a +/-5% band, while live readouts show the setpoint, output, error, and control effort u as the loop reacts in real time.",
"keywords": [
"pid controller",
"feedback control",
"setpoint",
"closed loop",
"proportional integral derivative",
"control system",
"settling time",
"overshoot",
"steady state error",
"step response",
"controller",
"regulation",
"stability",
"second order system"
],
"bullets": [
"The controller forms error e = setpoint - output and computes u = Kp*e + Ki*integral(e) + Kd*de/dt every step.",
"The proportional term gives a fast push, the integral term erases steady-state error, and the derivative term damps overshoot.",
"After each setpoint step the orange output rises, overshoots, and settles into the +/-5% band - that recovery time is the settling time."
],
"student_prompts": [
"What happens to overshoot and settling time if I increase Kp or Kd?",
"Why does removing the integral term Ki leave a steady-state error?",
"How would integral windup affect this response after a large setpoint step?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\nconst Kp = 1.8, Ki = 0.9, Kd = 0.35;\nconst tau = 0.7;\nconst setpointAt = (tt) => (Math.floor(tt / 6) % 2 === 0 ? 1.0 : 2.2);\nconst winT = 12;\nconst t0 = Math.max(0, t - winT);\nconst dt = 0.02;\nconst steps = Math.min(900, Math.round((t - t0) / dt) + 1);\nlet pos = 0, vel = 0, integ = 0, prevErr = setpointAt(t0) - 0;\nconst trace = [], spTrace = [];\nlet curErr = 0, curU = 0, curSp = setpointAt(t0), curPos = 0;\nfor (let i = 0; i < steps; i++) {\n const tt = t0 + i * dt;\n const sp = setpointAt(tt);\n const err = sp - pos;\n integ = H.clamp(integ + err * dt, -5, 5);\n const deriv = (err - prevErr) / dt;\n prevErr = err;\n const u = Kp * err + Ki * integ + Kd * deriv;\n const acc = (u - pos) / (tau * tau) - (2 / tau) * vel;\n vel += acc * dt;\n pos += vel * dt;\n if (!Number.isFinite(pos)) { pos = sp; vel = 0; integ = 0; }\n trace.push([tt, pos]);\n spTrace.push([tt, sp]);\n curErr = err; curU = u; curSp = sp; curPos = pos;\n}\nconst view = H.plot2d({\n xMin: t0, xMax: t0 + winT, yMin: -0.4, yMax: 3.0,\n box: { x: 60, y: 92, w: w - 100, h: h - 176 },\n});\nview.grid({ stepX: 2, stepY: 1 });\nview.axes({ stepX: 2, stepY: 1 });\nview.path(spTrace, { color: H.colors.good, width: 2, dash: [7, 6] });\nconst band = 0.05 * curSp;\nview.path([[t0, curSp + band], [t0 + winT, curSp + band]], { color: H.colors.grid, width: 1, dash: [3, 5] });\nview.path([[t0, curSp - band], [t0 + winT, curSp - band]], { color: H.colors.grid, width: 1, dash: [3, 5] });\nview.path(trace, { color: H.colors.accent2, width: 3 });\nview.dot(t, curPos, { r: 6, fill: H.colors.accent });\nview.line(t, curPos, t, curSp, { color: H.colors.warn, width: 1.6, dash: [4, 4] });\nH.text(\"PID feedback settling to a setpoint\", 24, 30,\n { color: H.colors.ink, size: 18, weight: 700 });\nH.text(\"u = Kp*e + Ki*integral(e) + Kd*de/dt drives the plant toward the target\",\n 24, 50, { color: H.colors.sub, size: 13 });\nH.text(\"setpoint = \" + curSp.toFixed(2), 24, h - 58, { color: H.colors.good, size: 13 });\nH.text(\"output = \" + curPos.toFixed(2), 170, h - 58, { color: H.colors.accent2, size: 13 });\nH.text(\"error = \" + curErr.toFixed(3), 320, h - 58, { color: H.colors.warn, size: 13 });\nH.text(\"control u = \" + curU.toFixed(2), 470, h - 58, { color: H.colors.accent, size: 13 });\nH.text(\"Kp=\" + Kp.toFixed(1) + \" Ki=\" + Ki.toFixed(1) + \" Kd=\" + Kd.toFixed(2) +\n \" +/-5% band\", 24, h - 36, { color: H.colors.sub, size: 12 });\nH.legend([\n { label: \"setpoint\", color: H.colors.good },\n { label: \"process output\", color: H.colors.accent2 },\n { label: \"error\", color: H.colors.warn },\n], w - 176, 106);"
},
{
"id": "fourier-series-square-wave",
"title": "Fourier Series Building a Square Wave",
"tag": "Waves & Optics",
"dimension": "2D",
"equation": "f(x) = (4/pi) * sum_{k odd} sin(kx)/k",
"summary": "A square wave is reconstructed term by term from its odd sine harmonics. The number of harmonics ramps up over time so you watch the partial sum sharpen toward the ideal square wave, complete with the Gibbs overshoot at the jumps.",
"keywords": [
"fourier series",
"fourier",
"square wave",
"harmonics",
"partial sum",
"sine series",
"odd harmonics",
"gibbs phenomenon",
"superposition",
"spectrum",
"sawtooth",
"synthesis",
"waveform",
"signal"
],
"bullets": [
"Each faint sine is one odd harmonic sin(kx)/k; the bright orange curve is their running sum S_N(x).",
"As more harmonics are added the sum hugs the dashed square wave more tightly, but a fixed overshoot (Gibbs) lingers at the jumps.",
"Higher harmonics carry smaller amplitude (1/k) yet sharpen the vertical edges where the wave switches sign."
],
"student_prompts": [
"Why does the overshoot near the jump never disappear no matter how many terms I add?",
"Where do the (4/pi) and the 1/k amplitude factors come from?",
"How would the series change for a sawtooth or triangle wave instead of a square wave?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\nconst cycle = 14;\nconst phase = t % cycle;\nconst maxN = 12;\nconst built = H.clamp(Math.floor(phase / cycle * (maxN + 1)) + 1, 1, maxN);\nconst odd = [];\nfor (let k = 1, n = 0; n < built; k += 2, n++) odd.push(k);\n\nconst v = H.plot2d({ xMin: -H.PI, xMax: H.PI, yMin: -1.6, yMax: 1.6, pad: 54 });\nv.grid({ stepX: H.PI / 2 });\nv.axes({ stepX: H.PI / 2, stepY: 0.5 });\n\nv.fn(x => (Math.sin(x) >= 0 ? 1 : -1) * 1, { color: H.colors.grid, width: 2 });\n\nconst four = (x) => {\n let s = 0;\n for (let i = 0; i < odd.length; i++) {\n const k = odd[i];\n s += Math.sin(k * x) / k;\n }\n return (4 / H.PI) * s;\n};\nfor (let i = 0; i < odd.length; i++) {\n const k = odd[i];\n const amp = (4 / H.PI) / k;\n v.fn(x => amp * Math.sin(k * x), {\n color: H.hsl(200 + i * 14, 70, 60, 0.32), width: 1.4,\n });\n}\nv.fn(four, { color: H.colors.accent2, width: 3.4 });\n\nconst sx = H.map(Math.sin(t * 0.9), -1, 1, -H.PI + 0.2, H.PI - 0.2);\nv.dot(sx, four(sx), { r: 6, fill: H.colors.yellow });\nv.text(\"sample\", sx + 0.05, four(sx) + 0.22, { color: H.colors.sub, size: 12 });\n\nH.text(\"Fourier series -> square wave\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Adding odd sine harmonics (4/pi) sin(kx)/k builds the square wave.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"harmonics N = \" + odd.length + \" highest k = \" + odd[odd.length - 1], 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"f(\" + sx.toFixed(2) + \") = \" + four(sx).toFixed(3), 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"partial sum S_N(x)\", color: H.colors.accent2 },\n { label: \"individual harmonics\", color: H.hsl(214, 70, 60) },\n { label: \"ideal square wave\", color: H.colors.grid },\n], 24, h - 70);"
},
{
"id": "two-source-interference-field",
"title": "Two-Source Interference (Ripple Field)",
"tag": "Waves & Optics",
"dimension": "3D",
"equation": "u(x,z,t) = A cos(k r1 - wt) + A cos(k r2 - wt)",
"summary": "Two coherent point sources emit circular waves over a plane. Their height fields add, carving the classic interference pattern of constructive ridges and destructive nodal lines into a lit, depth-sorted 3D surface you can orbit.",
"keywords": [
"interference",
"two source",
"double slit",
"ripple tank",
"coherent sources",
"constructive",
"destructive",
"wave field",
"superposition",
"nodal lines",
"fringes",
"diffraction",
"huygens",
"circular waves"
],
"bullets": [
"Where the two path lengths differ by a whole wavelength the crests add (constructive ridges); a half-wavelength difference cancels them (nodal valleys).",
"Each source radiates expanding circular wavefronts; the spheres mark the two coherent emitters bobbing in phase.",
"The fixed pattern of ridges and troughs is set by the source spacing and the wavenumber k, while the whole field oscillates at angular frequency w."
],
"student_prompts": [
"How does the spacing between the two sources change the number of bright fringes?",
"What is the path-difference condition for a constructive vs destructive point?",
"How does this 2D ripple pattern connect to Young's double-slit experiment?"
],
"code": "H.background();\nconst cam = H.cam3d({ scale: 30, dist: 17, pitch: -0.62, cy: H.H * 0.56 });\ncam.yaw = 0.28 * t;\n\nconst k = 2.2;\nconst omega = 3.0;\nconst sx1 = -2.0, sz1 = 0.0;\nconst sx2 = 2.0, sz2 = 0.0;\nconst amp = 0.9;\n\ncam.grid(6, 1.5);\n\nH.surface3d(cam, (x, z) => {\n const r1 = Math.sqrt((x - sx1) * (x - sx1) + (z - sz1) * (z - sz1)) + 1e-6;\n const r2 = Math.sqrt((x - sx2) * (x - sx2) + (z - sz2) * (z - sz2)) + 1e-6;\n const w1 = amp * Math.cos(k * r1 - omega * t) / (1 + 0.35 * r1);\n const w2 = amp * Math.cos(k * r2 - omega * t) / (1 + 0.35 * r2);\n return (w1 + w2) * 1.6;\n}, { xMin: -6, xMax: 6, yMin: -6, yMax: 6, nx: 44, ny: 44, hueMin: 205, hueMax: 320 });\n\ncam.axes(6);\n\nconst s1 = [sx1, amp * Math.cos(-omega * t) * 1.6 + 0.2, sz1];\nconst s2 = [sx2, amp * Math.cos(-omega * t) * 1.6 + 0.2, sz2];\n[{ p: s1, c: H.colors.yellow }, { p: s2, c: H.colors.good }]\n .map(o => ({ p: o.p, c: o.c, d: cam.project(o.p).depth }))\n .sort((a, b) => b.d - a.d)\n .forEach(o => cam.sphere(o.p, 0.28, { color: o.c }));\n\nconst dt = (2 * H.PI) / omega;\nH.text(\"Two-source interference\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Two coherent sources -> crests reinforce, troughs cancel.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"k = \" + k.toFixed(1) + \" period T = \" + dt.toFixed(2) + \" s\", 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"phase = \" + ((omega * t) % (2 * H.PI)).toFixed(2) + \" rad\", 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"source A\", color: H.colors.yellow },\n { label: \"source B\", color: H.colors.good },\n { label: \"combined wave field\", color: H.hsl(260, 72, 60) },\n], 24, H.H - 70);"
},
{
"id": "wave-packet-phase-group-velocity",
"title": "Wave Packet: Phase vs Group Velocity",
"tag": "Waves & Optics",
"dimension": "2D",
"equation": "u(x,t) = exp(-(x-vg t)^2 / 2s^2) * cos(k0 x - w0 t)",
"summary": "A Gaussian-enveloped wave packet travels to the right. A tracked carrier crest races forward at the phase velocity vp = w/k while the whole envelope (and the energy it carries) drifts more slowly at the group velocity vg, making the distinction concrete.",
"keywords": [
"wave packet",
"wavepacket",
"phase velocity",
"group velocity",
"dispersion",
"carrier",
"envelope",
"gaussian envelope",
"modulation",
"beat",
"pulse",
"quantum wave packet",
"de broglie",
"vp vg"
],
"bullets": [
"The fast pink dot is a single carrier crest moving at the phase velocity vp = w0/k0; individual crests outrun the pulse.",
"The green dot tracks the envelope peak, which moves at the slower group velocity vg = dw/dk and carries the packet's energy.",
"Crests visibly appear at the back of the envelope and vanish at the front because the carrier and the envelope travel at different speeds."
],
"student_prompts": [
"Why does the energy of the pulse travel at the group velocity and not the phase velocity?",
"What happens to the packet's shape over time when the medium is dispersive?",
"How do phase and group velocity relate to a de Broglie matter wave in quantum mechanics?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\n\nconst k0 = 6.0;\nconst omega0 = 9.0;\nconst vg = 0.8;\nconst vp = omega0 / k0;\nconst sigma = 1.4;\n\nconst v = H.plot2d({ xMin: -9, xMax: 9, yMin: -1.5, yMax: 1.5, pad: 54 });\nv.grid({ stepX: 3 });\nv.axes({ stepX: 3, stepY: 0.5 });\n\nconst span = 16;\nlet xc = (-7 + vg * t) % span;\nif (xc > 9) xc -= span;\n\nconst env = (x) => Math.exp(-((x - xc) * (x - xc)) / (2 * sigma * sigma));\nconst packet = (x) => env(x) * Math.cos(k0 * x - omega0 * t);\n\nv.fn(x => env(x), { color: H.hsl(150, 70, 55, 0.45), width: 2 });\nv.fn(x => -env(x), { color: H.hsl(150, 70, 55, 0.45), width: 2 });\nv.fn(packet, { color: H.colors.accent, width: 3 });\n\nconst m = Math.round((k0 * xc - omega0 * t) / (2 * H.PI));\nconst crestX = (2 * H.PI * m + omega0 * t) / k0;\nif (crestX > -9 && crestX < 9) {\n v.dot(crestX, packet(crestX), { r: 6, fill: H.colors.warn });\n v.text(\"crest (vp)\", crestX + 0.2, packet(crestX) + 0.28, { color: H.colors.warn, size: 12 });\n}\nv.dot(xc, 1.0, { r: 6, fill: H.colors.good });\nv.text(\"envelope (vg)\", xc + 0.2, 1.18, { color: H.colors.good, size: 12 });\n\nH.text(\"Wave packet: phase vs group velocity\", 24, 32, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Carrier crests slide through a slower-moving Gaussian envelope.\", 24, 54, { color: H.colors.sub, size: 13 });\nH.text(\"vp = w/k = \" + vp.toFixed(2) + \" vg = \" + vg.toFixed(2), 24, 78, { color: H.colors.accent, size: 14, weight: 600 });\nH.text(\"envelope center xc = \" + xc.toFixed(2), 24, 98, { color: H.colors.sub, size: 13 });\n\nH.legend([\n { label: \"wave packet\", color: H.colors.accent },\n { label: \"Gaussian envelope\", color: H.hsl(150, 70, 55) },\n { label: \"carrier crest (vp)\", color: H.colors.warn },\n { label: \"envelope peak (vg)\", color: H.colors.good },\n], 24, h - 90);"
},
{
"id": "bubble-sort-bars",
"title": "Bubble Sort on Bars",
"tag": "Algorithms",
"dimension": "2D",
"equation": "if a[j] > a[j+1] then swap; O(n^2) comparisons",
"summary": "A bar chart of 9 values runs the full bubble sort one comparison at a time. Adjacent bars are highlighted as they are compared, flash red on a swap, and the already-sorted tail locks in green as larger values bubble to the right.",
"keywords": [
"bubble sort",
"sorting algorithm",
"insertion sort",
"sort bars",
"comparison swap",
"adjacent elements",
"array sorting",
"o(n^2)",
"quadratic sort",
"ascending order",
"in-place sort",
"passes",
"swap animation"
],
"bullets": [
"Each pass compares neighbors left to right; the largest unsorted value bubbles to its final spot on the right.",
"Yellow = comparing, red = swapping, green = locked into the sorted tail.",
"The comparison counter and pass number make the O(n^2) cost concrete as the array converges."
],
"student_prompts": [
"Why is bubble sort O(n^2) in the worst case but O(n) on an already-sorted array?",
"How does insertion sort differ from bubble sort step by step?",
"Which sorting algorithm would you use for nearly-sorted data and why?"
],
"code": "H.background();\nconst w = H.W, h = H.H;\nconst base = [5, 2, 8, 1, 9, 4, 7, 3, 6];\nconst n = base.length;\nconst steps = [];\nconst a = base.slice();\nsteps.push({ arr: a.slice(), i: -1, j: -1, swap: false, done: false });\nfor (let i = 0; i < n - 1; i++) {\n for (let j = 0; j < n - 1 - i; j++) {\n const swap = a[j] > a[j + 1];\n if (swap) { const tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; }\n steps.push({ arr: a.slice(), i: i, j: j, swap: swap, done: false });\n }\n}\nsteps.push({ arr: a.slice(), i: n, j: -1, swap: false, done: true });\nconst total = steps.length;\nconst period = total * 0.55 + 2.2;\nconst tt = t % period;\nlet idx = Math.min(total - 1, Math.floor(tt / 0.55));\nif (tt > total * 0.55) idx = total - 1;\nconst step = steps[idx];\nconst arr = step.arr;\nconst maxV = 9;\nconst box = { x: 60, y: 110, w: w - 120, h: h - 190 };\nconst bw = box.w / n;\nconst sortedFrom = step.done ? 0 : n - 1 - step.i;\nfor (let k = 0; k < n; k++) {\n const val = arr[k];\n const bh = H.map(val, 0, maxV, 8, box.h);\n const bx = box.x + k * bw + bw * 0.12;\n const by = box.y + box.h - bh;\n const bwi = bw * 0.76;\n let fill = H.colors.accent;\n if (!step.done && (k === step.j || k === step.j + 1)) {\n fill = step.swap ? H.colors.warn : H.colors.yellow;\n } else if (k >= sortedFrom || step.done) {\n fill = H.colors.good;\n }\n H.rect(bx, by, bwi, bh, { fill: fill, stroke: H.colors.bg, width: 1.5, radius: 5 });\n H.text(String(val), bx + bwi / 2, by - 8,\n { color: H.colors.ink, size: 14, align: \"center\", weight: 600 });\n H.text(String(k), bx + bwi / 2, box.y + box.h + 18,\n { color: H.colors.sub, size: 11, align: \"center\" });\n}\nif (!step.done && step.j >= 0) {\n const j = step.j;\n const x1 = box.x + j * bw + bw * 0.5;\n const x2 = box.x + (j + 1) * bw + bw * 0.5;\n const yb = box.y + box.h + 30;\n H.line(x1, yb, x2, yb, { color: step.swap ? H.colors.warn : H.colors.yellow, width: 2 });\n H.text(step.swap ? \"swap\" : \"keep\", (x1 + x2) / 2, yb + 16,\n { color: step.swap ? H.colors.warn : H.colors.yellow, size: 12, align: \"center\" });\n}\nH.text(\"Bubble sort\", 24, 36, { color: H.colors.ink, size: 19, weight: 700 });\nH.text(\"Adjacent pairs are compared; the larger bubbles right each pass.\",\n 24, 58, { color: H.colors.sub, size: 13 });\nconst passNum = step.done ? n - 1 : step.i + 1;\nconst compares = idx;\nH.text(\"pass \" + passNum + \" / \" + (n - 1) + \" comparisons: \" + compares +\n (step.done ? \" SORTED\" : \"\"), 24, 84,\n { color: step.done ? H.colors.good : H.colors.accent2, size: 13, weight: 600 });\nH.legend([\n { label: \"comparing\", color: H.colors.yellow },\n { label: \"swapping\", color: H.colors.warn },\n { label: \"sorted\", color: H.colors.good },\n], w - 150, 120);"
},
{
"id": "bfs-graph-traversal",
"title": "BFS Graph Traversal",
"tag": "Algorithms",
"dimension": "2D",
"equation": "frontier expands level by level; depth d(v) = d(u) + 1",
"summary": "Breadth-first search runs on a small 8-node graph from node 0. Nodes light up one at a time in queue order, the expanding node pulses, BFS tree edges thicken, and each node is annotated with its discovery rank and shortest-path depth.",
"keywords": [
"bfs",
"breadth first search",
"graph traversal",
"dfs",
"depth first search",
"queue",
"fifo",
"shortest path",
"level order",
"graph algorithm",
"adjacency list",
"node visit order",
"frontier",
"tree edges"
],
"bullets": [
"A FIFO queue guarantees nodes are discovered in increasing distance from the source, so depth d equals the shortest hop count.",
"The thick blue edges form the BFS tree; thin gray edges are non-tree (cross) edges.",