Repository navigation
Expand file tree
/
Copy pathtimeline.html
More file actions
730 lines (705 loc) · 65.9 KB
/
Copy pathtimeline.html
File metadata and controls
730 lines (705 loc) · 65.9 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
---
layout: null
title: Timeline
nav_title: Timeline
permalink: /timeline.html
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>The FM-1 Story</title>
<meta name="description" content="A 3D timeline of the M-VAVE FM-1: the hardware, M-VAVE's firmware updates, and the custom firmware explosion of October 2026.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#05060b; --ink:#eef1ff; --dim:#8f96b8; --line:rgba(160,170,230,.18);
--hw:#ffb020; --mv:#38d6ff; --cf:#ff4fa3; --tool:#52f0a0; --smk:#a98bff; --press:#cfd6ff;
--panel:rgba(12,14,28,.82);
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font-family:"Space Grotesk",system-ui,sans-serif}
body{min-height:100vh}
#scene{position:fixed;inset:0;display:block}
#labels{position:fixed;inset:0;pointer-events:none}
#scroller{position:relative;height:1400vh}
.hud{position:fixed;z-index:5;pointer-events:none}
.hud *{pointer-events:auto}
#brand{top:18px;left:22px;max-width:min(560px,70vw)}
#brand a.back{font:500 12px "JetBrains Mono",monospace;color:var(--dim);text-decoration:none;letter-spacing:.08em}
#brand a.back:hover{color:var(--ink)}
#brand h1{margin:4px 0 0;font-size:clamp(22px,3.2vw,38px);letter-spacing:-.02em;line-height:1}
#brand p{margin:6px 0 0;color:var(--dim);font-size:14px;line-height:1.35}
#clock{top:18px;right:22px;text-align:right}
#clock .d{font:600 clamp(26px,4.2vw,54px) "JetBrains Mono",monospace;letter-spacing:-.03em;line-height:1;
background:linear-gradient(90deg,var(--mv),var(--cf));-webkit-background-clip:text;background-clip:text;color:transparent}
#clock .era{font:500 12px "JetBrains Mono",monospace;color:var(--dim);letter-spacing:.12em;text-transform:uppercase;margin-top:6px}
#legend{left:22px;bottom:70px;display:flex;flex-wrap:wrap;gap:6px;max-width:min(640px,92vw)}
.chip{font:500 12px "Space Grotesk";border:1px solid var(--line);background:rgba(10,12,24,.7);color:var(--ink);
border-radius:999px;padding:5px 10px 5px 8px;cursor:pointer;display:flex;align-items:center;gap:6px;user-select:none}
.chip i{width:9px;height:9px;border-radius:50%;display:inline-block;box-shadow:0 0 10px currentColor}
.chip.off{opacity:.35}
#bar{left:22px;right:22px;bottom:22px;height:34px}
#track{position:absolute;left:0;right:150px;top:15px;height:2px;background:var(--line);border-radius:2px}
#fill{position:absolute;left:0;top:0;height:2px;background:linear-gradient(90deg,var(--mv),var(--cf));border-radius:2px;width:0}
.tick{position:absolute;top:-14px;font:500 10px "JetBrains Mono",monospace;color:var(--dim);transform:translateX(-50%);white-space:nowrap}
.tick::after{content:"";position:absolute;left:50%;top:13px;width:1px;height:8px;background:var(--line)}
#ctrls{position:absolute;right:0;top:0;display:flex;gap:6px}
#ctrls button{font:600 12px "JetBrains Mono",monospace;background:rgba(10,12,24,.8);color:var(--ink);border:1px solid var(--line);
border-radius:8px;height:30px;min-width:34px;padding:0 10px;cursor:pointer}
#ctrls button:hover{border-color:var(--ink)}
/* cards in 3D */
.card{pointer-events:auto;cursor:pointer;width:min(250px,56vw);padding:10px 12px 11px;border-radius:12px;background:var(--panel);
border:1px solid var(--line);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);transition:opacity .25s,border-color .2s,transform .2s;
box-shadow:0 10px 40px rgba(0,0,0,.45)}
.card:hover{border-color:var(--c);transform:translateY(-2px)}
.card .k{font:600 10px "JetBrains Mono",monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--c);display:flex;justify-content:space-between;gap:8px}
.card .k span:last-child{color:var(--dim)}
.card h3{margin:5px 0 0;font-size:15px;line-height:1.2;font-weight:700}
.card p{margin:5px 0 0;font-size:12.5px;line-height:1.35;color:#c5cbe6}
.card.major{width:min(300px,64vw);border-color:color-mix(in srgb,var(--c) 45%,transparent)}
.card.major h3{font-size:18px}
.card.minor{width:auto;max-width:210px;padding:6px 10px;border-radius:999px;background:rgba(10,12,24,.6)}
.card.minor p{display:none}
.card.minor h3{font-size:12.5px;font-weight:500;margin:2px 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card.minor .k{font-size:9px}
.card.focus{z-index:99 !important;box-shadow:0 0 0 1px var(--c),0 16px 60px rgba(0,0,0,.55),0 0 40px color-mix(in srgb,var(--c) 25%,transparent)}
.daylabel{font:600 11px "JetBrains Mono",monospace;color:var(--dim);letter-spacing:.1em;white-space:nowrap;transition:opacity .3s}
.monthlabel{font:700 clamp(28px,5vw,64px) "Space Grotesk";color:rgba(200,210,255,.08);letter-spacing:-.03em;white-space:nowrap;transition:opacity .3s}
/* detail panel */
/* the event list: always on the right (desktop), a pull-up sheet (phone) */
:root{--side:330px}
#side{position:fixed;z-index:7;right:18px;top:96px;bottom:72px;width:var(--side);background:rgba(9,10,20,.78);border:1px solid var(--line);
border-radius:14px;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);display:flex;flex-direction:column;overflow:hidden;
box-shadow:0 20px 60px rgba(0,0,0,.45)}
#side .sh{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);font:600 11px "JetBrains Mono",monospace;letter-spacing:.12em;color:var(--dim)}
#side .sh #scount{margin-left:auto;letter-spacing:.04em}
#sclose{display:none;background:none;border:1px solid var(--line);color:var(--ink);border-radius:8px;width:28px;height:28px;cursor:pointer}
#sitems{position:relative;overflow:auto;padding:0 6px 10px;overscroll-behavior:contain}
.sm{position:sticky;top:0;z-index:1;background:rgba(9,10,20,.96);font:600 10.5px "JetBrains Mono",monospace;letter-spacing:.14em;color:var(--dim);padding:10px 8px 6px}
.si{display:block;padding:7px 8px;border-radius:9px;cursor:pointer;outline:none}
.si:hover,.si:focus-visible{background:rgba(255,255,255,.05)}
.si .row{display:flex;gap:8px;align-items:baseline}
.si i{width:8px;height:8px;border-radius:50%;flex:0 0 8px;transform:translateY(-1px);box-shadow:0 0 8px currentColor}
.si time{font:500 10.5px "JetBrains Mono",monospace;color:var(--dim);flex:0 0 46px}
.si b{font-weight:500;font-size:13px;line-height:1.3}
.si.maj b{font-weight:700}
.si .more{display:none;margin:6px 0 2px 62px;font-size:12.5px;line-height:1.45;color:#c5cbe6}
.si .more a{color:var(--mv);display:inline-block;margin:5px 10px 0 0;font-size:12px}
.si.on{background:color-mix(in srgb,var(--c) 15%,transparent);box-shadow:inset 3px 0 0 var(--c)}
.si.on .more{display:block}
.si.past{opacity:.6}
.si.hid{display:none}
#bar{right:calc(var(--side) + 40px)}
#legend{max-width:calc(100vw - var(--side) - 90px)}
#auto{min-width:86px}
#auto.on{border-color:var(--cf);color:var(--cf);box-shadow:0 0 18px rgba(255,79,163,.35)}
#sidebtn{display:none}
@media (max-width:760px){
#side{left:10px;right:10px;width:auto;top:auto;bottom:66px;height:48vh;transform:translateY(calc(100% + 80px));transition:transform .35s cubic-bezier(.2,.8,.2,1)}
#side.open{transform:none}
#sclose{display:block}
#bar{right:22px}
#legend{max-width:92vw}
#sidebtn{display:inline-block}
#track{right:218px}
}
/* embed (a live preview on another page), the start screen, the loop fade */
#embedui{display:none;position:fixed;inset:0;z-index:20;cursor:pointer;background:linear-gradient(180deg,rgba(5,6,11,0) 50%,rgba(5,6,11,.8))}
body.embed #embedui{display:block}
#embedui .eb{position:absolute;left:18px;bottom:16px;right:310px}
#embedui .t{font:700 clamp(18px,3vw,26px) "Space Grotesk";letter-spacing:-.02em}
#embedui .s{color:var(--dim);font-size:13px;margin-top:2px}
#embedui .pill{position:absolute;right:16px;bottom:16px;font:600 12px "JetBrains Mono",monospace;letter-spacing:.06em;padding:10px 14px;border-radius:999px;
background:rgba(255,79,163,.18);border:1px solid rgba(255,79,163,.65);color:#fff;animation:pulse 2.4s ease-in-out infinite;white-space:nowrap}
@keyframes pulse{50%{box-shadow:0 0 0 7px rgba(255,79,163,.12)}}
body.embed #brand,body.embed #legend,body.embed #bar,body.embed #side,body.embed #hint,body.embed #scroller,body.embed #start{display:none}
body.embed #clock{top:12px;right:14px} body.embed #clock .era{display:none} body.embed #clock .d{font-size:22px}
@media (max-width:520px){ #embedui .eb{right:16px;bottom:58px} #embedui .s{display:none} }
#start{position:fixed;inset:0;z-index:15;display:flex;align-items:center;justify-content:center;pointer-events:none;
background:radial-gradient(ellipse at center,rgba(5,6,11,.1),rgba(5,6,11,.7));transition:opacity 1.2s}
#soundpill{position:fixed;z-index:16;left:50%;top:92px;transform:translateX(-50%);font:600 13px "Space Grotesk";padding:10px 18px;border-radius:999px;border:0;cursor:pointer;
color:#08090f;background:linear-gradient(90deg,var(--mv),var(--cf));box-shadow:0 8px 30px rgba(255,79,163,.35);transition:opacity .5s}
#soundpill.gone{opacity:0;pointer-events:none}
body.embed #soundpill,body.listmode #soundpill{display:none}
#start.gone{opacity:0;pointer-events:none}
body.starting #labels,body.starting #hint{opacity:0}
#labels{transition:opacity .6s}
body.listmode #start{display:none}
#start .box{max-width:560px;text-align:center;padding:24px}
#start .k{font:600 11px "JetBrains Mono",monospace;letter-spacing:.16em;color:var(--mv)}
#start h2{font-size:clamp(36px,7vw,72px);margin:8px 0 10px;letter-spacing:-.035em;line-height:1}
#start p{color:#c5cbe6;line-height:1.5;margin:0 auto 22px;max-width:460px}
#start .b{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
#playsnd{font:700 16px "Space Grotesk";padding:14px 24px;border-radius:999px;border:0;cursor:pointer;color:#08090f;
background:linear-gradient(90deg,var(--mv),var(--cf));box-shadow:0 10px 40px rgba(255,79,163,.35)}
#playsnd span{font-weight:500;opacity:.75}
#explore{font:600 14px "Space Grotesk";padding:13px 20px;border-radius:999px;cursor:pointer;background:transparent;color:var(--ink);border:1px solid var(--line)}
#start small{display:block;margin-top:16px;color:var(--dim)}
#fade{position:fixed;inset:0;z-index:12;background:#05060b;opacity:0;pointer-events:none;transition:opacity .7s}
#snd.off{opacity:.5}
/* event images */
.ci{margin:-10px -12px 9px;border-radius:12px 12px 0 0;overflow:hidden;background:#05060b;position:relative}
.ci img{display:block;width:100%;height:auto}
.ci.photo img,.ci.card img{aspect-ratio:16/9;object-fit:cover}
.ci.screen{display:flex;justify-content:center;padding:10px 0 8px;background:radial-gradient(ellipse at center,#14172b,#05060b)}
.ci.screen img{width:150px;height:150px;border-radius:8px;box-shadow:0 0 0 1px rgba(255,255,255,.08),0 6px 20px rgba(0,0,0,.5)}
.ci.wide{padding:10px 10px 8px;background:radial-gradient(ellipse at center,#14172b,#05060b)}
.ci.wide img{border-radius:8px}
.ci figcaption,.si-img figcaption,.li-img figcaption{position:absolute;right:6px;bottom:4px;font:500 9px "JetBrains Mono",monospace;color:rgba(230,235,255,.7);
background:rgba(5,6,11,.65);padding:2px 5px;border-radius:4px;letter-spacing:.02em}
.card.minor .ci{float:left;width:30px;height:30px;margin:0 8px 0 0;border-radius:7px;padding:0;background:#05060b}
.card.minor .ci img{width:30px;height:30px;object-fit:cover;aspect-ratio:auto;border-radius:7px;box-shadow:none}
.card.minor .ci figcaption{display:none}
.card.minor{display:flow-root}
.lg{width:14px;height:14px;border-radius:3px;vertical-align:-3px;margin-right:6px;background:#fff;object-fit:contain}
.lgt{font-style:normal;font:700 8px "JetBrains Mono",monospace;letter-spacing:.06em;background:#cfd6ff;color:#08090f;padding:1px 4px;border-radius:3px;margin-right:6px}
.si-img{position:relative;margin:2px 0 8px;border-radius:9px;overflow:hidden;background:#05060b}
.si-img img{display:block;width:100%;height:auto;max-height:170px;object-fit:cover}
.si-img.screen,.si-img.wide{display:flex;justify-content:center;padding:8px;background:radial-gradient(ellipse at center,#14172b,#05060b)}
.si-img.screen img{width:150px;height:150px;border-radius:7px;object-fit:contain}
.li-img{position:relative;float:right;margin:0 0 8px 14px;width:200px;border-radius:9px;overflow:hidden;background:#05060b}
.li-img img{display:block;width:100%;height:auto}
.li-img.screen{width:140px}
@media (max-width:640px){ .ci.screen img{width:120px;height:120px} .li-img{float:none;margin:6px 0;width:100%} .li-img.screen{width:160px} }
/* intro + hint */
#hint{left:50%;bottom:76px;transform:translateX(-50%);font:500 12px "JetBrains Mono",monospace;color:var(--dim);letter-spacing:.12em;text-transform:uppercase;transition:opacity .6s}
#hint b{color:var(--ink)}
/* list view */
#list{display:none;position:relative;z-index:6;max-width:820px;margin:0 auto;padding:110px 18px 80px}
body.listmode #list{display:block}
body.listmode #side,body.listmode #scene,body.listmode #labels,body.listmode #scroller,body.listmode #legend,body.listmode #bar,body.listmode #hint,body.listmode #clock{display:none}
body.listmode #brand{position:absolute}
#list h2{font-size:20px;margin:34px 0 8px;color:var(--dim);font-weight:500;letter-spacing:.04em}
#list .it{display:grid;grid-template-columns:92px 1fr;gap:14px;padding:12px 0;border-top:1px solid var(--line)}
#list .it time{font:600 12px "JetBrains Mono",monospace;color:var(--dim);padding-top:3px}
#list .it .k{font:600 10px "JetBrains Mono",monospace;letter-spacing:.1em;text-transform:uppercase}
#list .it h3{margin:3px 0 4px;font-size:17px}
#list .it p{margin:0;color:#c5cbe6;line-height:1.45;font-size:14.5px}
#list .it a{color:var(--mv);font-size:13px}
#list .listtoggle{position:fixed}
#swap{position:fixed;z-index:8;top:18px;right:22px;display:none}
body.listmode #swap{display:block}
#swap button{font:600 12px "JetBrains Mono",monospace;background:rgba(10,12,24,.9);color:var(--ink);border:1px solid var(--line);border-radius:8px;height:30px;padding:0 12px;cursor:pointer}
#chart{margin:12px 0 4px}
#chart .bars{display:flex;align-items:flex-end;gap:2px;height:90px;border-bottom:1px solid var(--line)}
#chart .bars span{flex:1;background:linear-gradient(0deg,var(--mv),var(--cf));border-radius:2px 2px 0 0;min-height:2px}
#chart small{color:var(--dim);font-size:12px}
@media (max-width:640px){
#brand p{display:none}
#legend{bottom:66px}
.card p{display:none}
.card.major p{display:block}
#track{right:118px}
#ctrls button.wide{display:none}
#hint{display:none}
#clock .era{display:none}
#clock .d{font-size:22px}
#brand h1{font-size:20px}
.card,.card.major{width:min(250px,72vw)}
}
@media (prefers-reduced-motion: reduce){ .card{transition:none} }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
} }
</script>
</head>
<body>
<canvas id="scene" aria-hidden="true"></canvas>
<div id="labels" aria-hidden="true"></div>
<div id="scroller"></div>
<div id="brand" class="hud">
<a class="back" href="./">← FM-1 GUIDE</a>
<h1>The FM-1 Story</h1>
<p>A $90 pocket FM synth, M-VAVE's firmware updates, and how the community turned it into 30+ instruments in under three weeks. Scroll to travel through time.</p>
</div>
<div id="clock" class="hud"><div class="d" id="date">JUN 2026</div><div class="era" id="era">the hardware</div></div>
<div id="hint" class="hud">scroll · <b>→</b> next event · <b>space</b> autoplay</div>
<div id="embedui" role="button" aria-label="Play the timeline full screen"><div class="eb"><div class="t">The FM-1 Story</div><div class="s">An animated 3D timeline: from a pocket synth to the firmware war</div></div><div class="pill">⤢ CLICK · FULL SCREEN WITH SOUND</div></div>
<div id="start"><div class="box"><div class="k">3D TIMELINE · <span id="nev">46</span> EVENTS · JUN–OCT 2026</div><h2>The FM-1 Story</h2>
<p>How a $90 pocket FM synth became 30+ instruments in under three weeks.</p>
<small>Playing… scroll, drag or press ← → to take over · Space pauses</small></div></div>
<button id="soundpill">♪ Play the soundtrack</button>
<div id="fade"></div>
<div id="legend" class="hud" role="group" aria-label="Filter by kind"></div>
<div id="bar" class="hud">
<div id="track"><div id="fill"></div></div>
<div id="ctrls">
<button id="auto" title="Play the story (Space)">▶ AUTO</button>
<button id="snd" title="Sound on/off (M)">♪ ON</button>
<button id="prev" title="Previous event (←)">←</button>
<button id="next" title="Next event (→)">→</button>
<button id="sidebtn" title="Show the events">EVENTS</button>
<button id="tolist" class="wide" title="Read it as a page">READ</button>
</div>
</div>
<aside id="side" aria-label="All events"><div class="sh"><span>EVENTS</span><span id="scount"></span><button id="sclose" aria-label="Hide the list">✕</button></div><div id="sitems"></div></aside>
<div id="swap"><button id="to3d">3D VIEW</button></div>
<main id="list" aria-label="Timeline as a list"></main>
<script type="module">
// ------------------------------------------------------------------ data
// Dates: M-VAVE's release notes (V09, V13, V15), publication dates of the cited articles, and GitHub
// creation / first-release dates. Gathered 16 Oct 2026.
const C = {
hw: {name:"Hardware", col:"#ffb020"},
mv: {name:"M-VAVE firmware", col:"#38d6ff"},
cf: {name:"Custom firmware", col:"#ff4fa3"},
tool: {name:"Tools & research",col:"#52f0a0"},
smk: {name:"SMK-37 Pro", col:"#a98bff"},
press:{name:"Press", col:"#cfd6ff"},
};
const E = [
{d:"2026-06-24",c:"mv",t:"Firmware V09",x:"The earliest version in M-VAVE's release notes: an envelope on/off switch, changing RATE while recording a sequence, and rests and note replacement in the sequencer.",s:[["M-VAVE downloads","https://www.m-vave.com/download"]]},
{d:"2026-06-27",c:"hw",m:1,t:"The FM-1 is unveiled",x:"M-VAVE's pocket 6-operator FM synth: a DX7-style engine with 32 algorithms, 12 voices, 27 silicone keys, six effects, an arpeggiator and a 16-step sequencer, for under US$100. It loads original DX7 SysEx banks.",s:[["MatrixSynth","https://www.matrixsynth.com/2026/06/new-mvave-fm1-mini-fm-synthesizr.html"]]},
{d:"2026-07-03",c:"mv",t:"V13, the 'major update'",x:"The arpeggiator and sequencer sync to the BPM, Program Change selects voices, the MIDI channel defaults to ALL, new 1/1 and 1/2 rates, and the factory sounds' levels are evened out.",s:[["M-VAVE release note","https://www.m-vave.com/download"]]},
{d:"2026-07-10",c:"press",t:"First reviews",x:"Sonicstate rounds up the first reviews of the FM-1.",s:[["Sonicstate","https://sonicstate.com/news/2026/07/10/m-vave-fm-1-reviews-/"]]},
{d:"2026-07-13",c:"press",t:"'M-VAVE Introduces FM-1'",x:"Synthtopia covers the launch, and the FM-1 goes on sale widely.",s:[["Synthtopia","https://www.synthtopia.com/content/2026/07/13/m-vave-introduces-fm-1-fm-pocket-synthesizer/"]]},
{d:"2026-07-19",c:"tool",m:1,t:"FM-1-RE: the firmware is opened up",x:"AL-255 publishes a reverse-engineering of the FM-1's firmware and its USB-MIDI update protocol. The update has a checksum but no signature, which is the door every custom firmware later walks through.",s:[["AL-255/FM-1-RE","https://github.com/AL-255/FM-1-RE"]]},
{d:"2026-07-25",c:"tool",t:"fm1-custom-fw resource hub",x:"aroum starts a hub for the scene: a directory of firmwares, documentation, SysEx tools and a flasher.",s:[["aroum/fm1-custom-fw","https://github.com/aroum/fm1-custom-fw"]]},
{d:"2026-07-30",c:"mv",m:1,t:"V15, M-VAVE's latest",x:"Long notes, pattern length (Step), Chain of up to 16 patterns, a voice per pattern, Glide, brighter LEDs, and effect and envelope scales from 0–10 to 0–100. That last change is why the reverb tail seemed to vanish.",s:[["Synth Anatomy","https://synthanatomy.com/2026/07/m-vave-fm-1-a-budget-friendly-dx-7-style-desktop-fm-polysynth.html"],["This guide: the reverb fix","reverb.html"]]},
{d:"2026-09-27",c:"tool",t:"czietz: read the voices, patch the stock firmware",x:"Two utilities: one reads the FM-1's voices, the other patches M-VAVE's own firmware.",s:[["czietz/fm1-firmware-patcher","https://github.com/czietz/fm1-firmware-patcher"]]},
{d:"2026-09-28",c:"cf",m:1,t:"Baud Girl FM-1+VA: the first custom firmware",x:"A virtual-analog engine (Super mode with seven detuned oscillators, ZDF filters) next to FM, fixes that make DX7 patches play like a DX7, and a 64-step sequencer with chords and ratchets. Free, closed source. It keeps evolving, to version 101 by mid-October.",s:[["Synth Anatomy","https://synthanatomy.com/2026/09/baud-girl-fm-1-va-custom-m-vave-fm-1-firmware.html"],["Baud Girl","https://baudgirl.com/work/FM-1+VA"]]},
{d:"2026-09-29",c:"press",t:"'Free Custom Firmware for Mvave FM-1'",x:"Sonicstate spreads the word about FM-1+VA.",s:[["Sonicstate","https://sonicstate.com/news/2026/09/29/free-custom-firmware-for-mvave-fm-1-/"]]},
{d:"2026-09-30",c:"tool",t:"FM-1 Transporter: an unbrick tool",x:"kurogedelic's recovery tool reads and writes the flash of an FM-1 stuck in the chip's boot mode, through a Seeed XIAO RP2040 on three wires.",s:[["kurogedelic/FM-1-transporter","https://github.com/kurogedelic/FM-1-transporter"]]},
{d:"2026-10-03",c:"cf",m:1,t:"Felucca: the open-source one",x:"Leo Kuroshita (Hügelton Instruments) releases Felucca 0.8 and 0.9 beta: a new firmware written from scratch, GPL-3.0, with multiple engines and a multitrack sequencer. It becomes the base that most later firmwares build on.",s:[["hugelton/Felucca","https://github.com/hugelton/Felucca"]]},
{d:"2026-10-04",c:"cf",m:1,t:"SLOOP 2.0",x:"isod89's live 4-track groovebox (three synths and drums) is built on Felucca. Within two weeks it becomes the most-starred FM-1 firmware.",s:[["isod89/sloop-fm1","https://github.com/isod89/sloop-fm1"]]},
{d:"2026-10-04",c:"cf",t:"X0X 0.1",x:"Charles Vestal's ReBirth-style groovebox: a 909, an 808 and two 303s with the TB-3PO line generator, plus a breakbeat player.",s:[["charlesvestal/fm1-x0x","https://github.com/charlesvestal/fm1-x0x"]]},
{d:"2026-10-04",c:"cf",t:"Groove OS (paid)",x:"An 8-track groovebox for $29, with FM, VA, acid and drum engines. Closed source.",s:[["Synth Anatomy","https://synthanatomy.com/2026/10/groove-os-turns-the-m-vave-fm-1-into-an-8-track-groovebox.html"]]},
{d:"2026-10-04",c:"cf",t:"Jangada 0.1",x:"A virtual-analog firmware with superwaves, a modulation matrix and drones.",s:[["zednaked/jangada","https://github.com/zednaked/jangada"]]},
{d:"2026-10-04",c:"tool",t:"An FM-1 emulator for developers",x:"simonjohansson's emulator runs FM-1 firmwares without flashing a real device.",s:[["simonjohansson/fm1-emulator","https://github.com/simonjohansson/fm1-emulator"]]},
{d:"2026-10-05",c:"cf",m:1,t:"Felucca 1.0",x:"The first stable Felucca, covered by Synth Anatomy. It ships ten more releases in a week.",s:[["Synth Anatomy","https://synthanatomy.com/2026/10/hugelton-instruments-felucca-custom-m-vave-fm-1-firmware-turns-it-into-a-multi-engine-synth.html"]]},
{d:"2026-10-05",c:"tool",t:"awesome-fm-1",x:"A curated list of firmwares, tools and resources.",s:[["cicloid/awesome-fm-1","https://github.com/cicloid/awesome-fm-1"]]},
{d:"2026-10-06",c:"press",m:1,t:"'The firmware wars'",x:"Time To House: Groove OS, SLOOP, Felucca 1.0 and X0X all landed within 48 hours, one paid and three free and open-source.",s:[["Time To House","https://timetohouse.com/en/articles/mvave-fm-1-firmware-wars-groove-os-sloop-felucca-x0x"]]},
{d:"2026-10-06",c:"cf",t:"FoMni: an Omnichord",x:"Charles Vestal again: strum the white keys, pick chords on the black ones, with ten OM-84 rhythms.",s:[["charlesvestal/fm1-fomni","https://github.com/charlesvestal/fm1-fomni"]]},
{d:"2026-10-06",c:"cf",t:"Melodee 0.11",x:"keremimo's Felucca-family synth firmware.",s:[["keremimo/melodee","https://github.com/keremimo/melodee"]]},
{d:"2026-10-07",c:"cf",t:"Baud Girl 97: two FM-1s in sync",x:"Peer-to-peer Bluetooth MIDI clock sync between two FM-1s, and the return of the Flow Grid sequencer.",s:[["Baud Girl announcements","https://baudgirl.com/work/FM-1+VA/announcements"]]},
{d:"2026-10-07",c:"cf",t:"zp12 and ORBIT",x:"zp12 is an E-mu SP-12-style sampling drum machine. ORBIT is SLOOP with OP-1-inspired screens and an event tape.",s:[["zvenson/zp12","https://github.com/zvenson/zp12"],["dspaudio/orbit","https://github.com/dspaudio/orbit"]]},
{d:"2026-10-08",c:"cf",t:"FiMba-1: a kalimba",x:"A physically modelled thumb piano with mbira patterns.",s:[["jadamsowers/fm1-fimba","https://github.com/jadamsowers/fm1-fimba"]]},
{d:"2026-10-08",c:"tool",t:"FM1VST and Optimist",x:"FM1VST is a plugin built from FM-1 firmware code. Optimist is a modular platform for building your own firmware.",s:[["Quixotic7/FM1VST","https://github.com/Quixotic7/FM1VST"],["w0ts/optimist","https://github.com/w0ts/optimist"]]},
{d:"2026-10-09",c:"cf",m:1,t:"Felucca 1.4 → 1.5 in one day",x:"Three releases in a day bring songs with a lane per track (an arrangement), recorded timing (NUDGE), a HALL reverb, LFO sync, insert effects, MIDI LEARN and per-step velocity.",s:[["Felucca 1.4 notes","https://github.com/hugelton/Felucca/releases/tag/v1.4"],["Felucca 1.5 notes","https://github.com/hugelton/Felucca/releases/tag/v1.5"]]},
{d:"2026-10-09",c:"smk",m:1,t:"The jump to the SMK-37 Pro",x:"The SMK-37 Pro keyboard has the same JieLi chip and update loader as the FM-1. genkigenki publishes a porting guide and ports SLOOP (Snoop37), X0X and FoMni. Baud Girl's 98 beta adds the SMK-37 Pro.",s:[["genkigenki/fm1-to-smk37","https://github.com/genkigenki/fm1-to-smk37"],["Baud Girl SMK-37+VA","https://baudgirl.com/work/SMK-37+VA"]]},
{d:"2026-10-09",c:"tool",t:"Dinghy: build your own",x:"The smallest firmware to start from: a 200-line sine synth, with updates, USB rescue, MIDI, audio, keys and screen already done.",s:[["zvenson/dinghy","https://github.com/zvenson/dinghy"]]},
{d:"2026-10-10",c:"cf",t:"It runs Doom",x:"The first level of Doom on the FM-1's 240×240 screen, keys and speaker, in 1 MB of flash.",s:[["seanohollaren/fm1-doom","https://github.com/seanohollaren/fm1-doom"]]},
{d:"2026-10-10",c:"cf",t:"X0X 1.0",x:"X0X leaves beta after 18 releases in six days.",s:[["X0X releases","https://github.com/charlesvestal/fm1-x0x/releases"]]},
{d:"2026-10-11",c:"cf",t:"FM-106 and 6502 BASIC",x:"FM-106 is a 6-voice Juno-106, ported from the KR-106 emulation. Felucca's author boots Microsoft 6502 BASIC on the FM-1's keys and screen.",s:[["PatttF/fm-106","https://github.com/PatttF/fm-106"],["kurogedelic/FM-1-6502-BASIC","https://github.com/kurogedelic/FM-1-6502-BASIC"]]},
{d:"2026-10-12",c:"tool",m:1,t:"fm1-emu: the whole FM-1 in a browser",x:"Joakim Eriksson emulates the FM-1's chip (two pi32v2 cores), screen, keys and audio in WebAssembly, and runs every firmware unchanged, including M-VAVE's own V15.",s:[["fm1-emu (Felucca)","https://joakimeriksson.github.io/fm1-emu/?fw=felucca&mhz=100"],["joakimeriksson/fm1-emu","https://github.com/joakimeriksson/fm1-emu"]]},
{d:"2026-10-12",c:"cf",t:"Baud Girl 101",x:"A performance release for both the FM-1 and the SMK-37 Pro, with lower key-to-note latency.",s:[["Baud Girl announcements","https://baudgirl.com/work/FM-1+VA/announcements"]]},
{d:"2026-10-13",c:"cf",t:"Felucca 1.6",x:"Conditional steps (1:4, FILL), EUCLID, a piano roll that follows playback, a MIDI channel per track, and your own UI colours.",s:[["Felucca releases","https://github.com/hugelton/Felucca/releases"]]},
{d:"2026-10-14",c:"cf",t:"Felucca 1.6.1: engines go MIT",x:"Five engines move to Felucca-Engines under the MIT licence, so other firmwares can reuse them freely, and the second core arrives as an opt-in beta.",s:[["Felucca 1.6.1 notes","https://github.com/hugelton/Felucca/releases/tag/v1.6.1"]]},
{d:"2026-10-14",c:"mv",m:1,t:"FM-1 Open: M-VAVE opens the FM-1",x:"M-VAVE's own answer to the scene: an official open firmware (FM1 Open: 8 tracks, 8 sample slots, 16 effect demos) on GitHub. Its app source is GPL-3.0 with a binary engine SDK, and a browser workbench explores it, edits projects, manages samples and flashes firmware. Synth Anatomy calls it the green light for custom firmware.",s:[["m-vave/fm1_open","https://github.com/m-vave/fm1_open"],["FM1 Open workbench","https://open.m-vave.com/fm1-workbench"],["Synth Anatomy","https://synthanatomy.com/2026/10/m-vave-fm-1-open-the-green-light-for-custom-firmware-and-community-hacks.html"]]},
{d:"2026-10-15",c:"cf",t:"OberVave: an Oberheim in your pocket",x:"zvenson's 8-voice analog-style polysynth for the FM-1 and the SMK-37 Pro/Elite, after the American polysynths of 1979–84. It has two oscillators per voice with sync and cross-mod, a SEM-style morphing filter, voice-card drift and SPREAD.",s:[["zvenson/obervave","https://github.com/zvenson/obervave"],["Synth Anatomy","https://synthanatomy.com/2026/10/zvenson-obervave-your-m-vave-fm-1-and-smk-37-pro-elite-are-now-oberheim-like-polysynths.html"]]},
{d:"2026-10-16",c:"cf",m:1,t:"Felucca's author steps back",x:"Leo Kuroshita adds a Maintenance Notice to Felucca: he steps back from active development to make new instruments and music. Felucca stays available with occasional maintenance, but new features, issue answers and PR reviews are not guaranteed. Its MIT engine library, Felucca-Engines, is still maintained.",s:[["Felucca's notice","https://github.com/hugelton/Felucca#maintenance-notice"],["Felucca-Engines","https://github.com/hugelton/Felucca-Engines"]]},
{d:"2026-10-16",c:"tool",t:"m-bootloader and M-Flasher",x:"jvitkauskas publishes an MIT clean-room bootloader for the FM-1 and SMK-37 Pro/Elite, with M-Flasher to back up, restore and install firmware and bootloaders.",s:[["jvitkauskas/m-bootloader","https://github.com/jvitkauskas/m-bootloader"]]},
{d:"2026-10-17",c:"cf",t:"GameDude RGB: a Game Boy Color",x:"A Game Boy Color project for the FM-1 and the SMK-37, based on GameDude v2.0.",s:[["ChanceTheMaker/gamedude-rgb","https://github.com/ChanceTheMaker/gamedude-rgb"]]},
{d:"2026-10-17",c:"smk",t:"Speak37: a talking synth",x:"genkigenki's talking synth for the SMK-37 and the FM-1, with MOPE, a gloomy robot. genkigenki also ships erik37, a sampled grand piano for the SMK-37.",s:[["genkigenki/Speak37","https://github.com/genkigenki/Speak37"]]},
{d:"2026-10-17",c:"cf",t:"fmini-1: a Minimoog",x:"ruppert's Minimoog-inspired synth firmware for the FM-1.",s:[["ruppert/fmini-1","https://github.com/ruppert/fmini-1"]]},
{d:"2026-10-17",c:"press",t:"'Play the FM-1 for free in your browser'",x:"Bedroom Producers Blog writes up fm1-emu: the FM-1 and its custom firmwares, free in a browser.",s:[["Bedroom Producers Blog","https://bedroomproducersblog.com/2026/10/17/m-wave-browser/"]]},
{d:"2026-10-18",c:"cf",m:1,t:"Today: SLOOP 2.7.1 leads",x:"SLOOP (535★) leads Felucca (522★) and X0X (182★), and SLOOP 2.7.1 fixes a dual-core crash screen. M-VAVE now has its own open firmware, and 40+ firmwares and ports exist for a synth that launched in July.",s:[["This guide's scene table","./#the-custom-firmware-scene"]]},
];
// an image per event: our own screens (the real firmware in fm1-emu or Felucca's browser version), the sites' own
// link-preview images (credited), or GitHub's repo cards; and the source's logo where it has one
const IMG = [["assets/stock/seq-page2.png", "screen", "Stock V15 in fm1-emu", "assets/timeline/logo-mvave.png"], ["assets/timeline/hw-fm1.jpg", "photo", "Image: Synth Anatomy", "assets/timeline/logo-matrixsynth.png"], ["assets/stock/stock-panel.png", "photo", "Stock firmware in fm1-emu", "assets/timeline/logo-mvave.png"], ["assets/timeline/press-sonicstate-reviews.jpg", "photo", "Image: Sonicstate", "assets/timeline/logo-sonicstate.png"], ["assets/timeline/press-synthtopia.jpg", "photo", "Image: Synthtopia", "txt:SYNTHTOPIA"], ["assets/timeline/gh-al-255_fm-1-re.jpg", "card", "GitHub", ""], ["assets/timeline/gh-aroum_fm1-custom-fw.jpg", "card", "GitHub", ""], ["assets/stock/seq-page1.png", "screen", "Stock V15: Chain and Step in fm1-emu", "assets/timeline/logo-mvave.png"], ["assets/timeline/gh-czietz_fm1-firmware-patcher.jpg", "card", "GitHub", ""], ["assets/timeline/cf-baudgirl.jpg", "photo", "Image: Baud Girl", "assets/timeline/logo-baudgirl.svg"], ["assets/timeline/press-sonicstate-cfw.jpg", "photo", "Image: Sonicstate", "assets/timeline/logo-sonicstate.png"], ["assets/timeline/gh-kurogedelic_fm-1-transporter.jpg", "card", "GitHub", ""], ["assets/timeline/fw-felucca.png", "screen", "Felucca (1.6.1) in fm1-emu", ""], ["assets/timeline/fw-sloop.png", "screen", "SLOOP (2.6) in fm1-emu", ""], ["assets/timeline/fw-x0x.png", "screen", "X0X (1.1) in fm1-emu", ""], ["assets/timeline/cf-grooveos.jpg", "photo", "Image: Synth Anatomy", "assets/timeline/logo-synthanatomy.png"], ["assets/timeline/fw-jangada.png", "screen", "Jangada (1.0) in fm1-emu", ""], ["assets/timeline/gh-simonjohansson_fm1-emulator.jpg", "card", "GitHub", ""], ["assets/timeline/cf-felucca10.jpg", "photo", "Image: Synth Anatomy", "assets/timeline/logo-synthanatomy.png"], ["assets/timeline/gh-cicloid_awesome-fm-1.jpg", "card", "GitHub", ""], ["assets/timeline/press-timetohouse.jpg", "photo", "Image: Time To House", "assets/timeline/logo-timetohouse.png"], ["assets/timeline/fw-fomni.png", "screen", "FoMni (0.4) in fm1-emu", ""], ["assets/timeline/fw-melodee.png", "screen", "Melodee (1.3.6) in fm1-emu", ""], ["assets/timeline/cf-baudgirl-sa.jpg", "photo", "Image: Synth Anatomy", "assets/timeline/logo-baudgirl.svg"], ["assets/timeline/fw-zp12-orbit.png", "wide", "zp12 and ORBIT in fm1-emu", ""], ["assets/timeline/fw-fimba.png", "screen", "FiMba-1 in fm1-emu", ""], ["assets/timeline/gh-quixotic7_fm1vst.jpg", "card", "GitHub", ""], ["assets/felucca/song.png", "screen", "Felucca's SONG lanes (1.6.1)", ""], ["assets/timeline/smk-baudgirl.jpg", "photo", "Image: Baud Girl", "assets/timeline/logo-baudgirl.svg"], ["assets/timeline/fw-dinghy.png", "screen", "Dinghy in fm1-emu", ""], ["assets/timeline/fw-doom.png", "screen", "FM-1 Doom in fm1-emu", ""], ["assets/timeline/fw-x0x-boot.png", "screen", "X0X in fm1-emu", ""], ["assets/timeline/fw-fm106.png", "screen", "FM-106 in fm1-emu", ""], ["assets/timeline/tool-fm1emu.jpg", "photo", "fm1-emu running Felucca", ""], ["assets/timeline/cf-baudgirl.jpg", "photo", "Image: Baud Girl", "assets/timeline/logo-baudgirl.svg"], ["assets/felucca-adv/cond-1-2.png", "screen", "Felucca: a 1:2 condition", ""], ["assets/timeline/gh-hugelton_felucca-engines.jpg", "card", "GitHub", ""], ["assets/timeline/mv-fm1open.jpg", "photo", "Image: Synth Anatomy", "assets/timeline/logo-mvave.png"], ["assets/timeline/cf-obervave.jpg", "photo", "Image: Synth Anatomy", "assets/timeline/logo-synthanatomy.png"], ["assets/timeline/gh-hugelton_felucca.jpg", "card", "GitHub", ""], ["assets/timeline/gh-jvitkauskas_m-bootloader.jpg", "card", "GitHub", ""], ["assets/timeline/gh-chancethemaker_gamedude-rgb.jpg", "card", "GitHub", ""], ["assets/timeline/gh-genkigenki_speak37.jpg", "card", "GitHub", ""], ["assets/timeline/gh-ruppert_fmini-1.jpg", "card", "GitHub", ""], ["assets/timeline/tool-fm1emu.jpg", "photo", "fm1-emu running Felucca", "txt:BPB"], ["assets/timeline/fw-sloop-boot.png", "screen", "SLOOP in fm1-emu", ""]];
E.forEach((e, i) => { const [im, ik, cr, lg] = IMG[i] || []; Object.assign(e, { im, ik, cr, lg }); });
// New GitHub repositories mentioning "M-VAVE" (name, description or readme), per day. GitHub search, 16 Oct 2026.
// June-Sept as monthly totals (spread per day); a few hits are other M-VAVE products.
const MONTHLY = {"2026-06":11,"2026-07":26,"2026-08":34,"2026-09a":32};
const DAILY = {"2026-09-20":1,"2026-09-21":1,"2026-09-22":1,"2026-09-23":5,"2026-09-24":5,"2026-09-25":7,"2026-09-26":2,"2026-09-27":3,"2026-09-28":2,"2026-09-29":5,"2026-09-30":4,
"2026-10-01":4,"2026-10-02":5,"2026-10-03":8,"2026-10-04":8,"2026-10-05":3,"2026-10-06":13,"2026-10-07":13,"2026-10-08":10,"2026-10-09":11,"2026-10-10":13,
"2026-10-11":21,"2026-10-12":25,"2026-10-13":19,"2026-10-14":30,"2026-10-15":19,"2026-10-16":33,"2026-10-17":28};
const MON = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
const day = (s) => Math.round(Date.UTC(+s.slice(0,4), +s.slice(5,7)-1, +s.slice(8,10)) / 864e5);
const fmt = (n) => { const d = new Date(n*864e5); return `${String(d.getUTCDate()).padStart(2,"0")} ${MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`; };
const fmtLong = (s) => { const d = new Date(day(s)*864e5); return d.toLocaleDateString("en-GB",{day:"numeric",month:"long",year:"numeric",timeZone:"UTC"}); };
E.forEach((e,i) => { e.i = i; e.n = day(e.d); });
{ const n = document.getElementById("nev"); if (n) n.textContent = E.length; }
const logoHTML = (e) => !e.lg ? "" : e.lg.startsWith("txt:") ? `<em class="lgt">${e.lg.slice(4)}</em>` : `<img class="lg" src="${e.lg}" alt="" loading="lazy">`;
const imgHTML = (e, cls = "ci") => !e.im ? "" : `<figure class="${cls} ${e.ik}"><img src="${e.im}" alt="${e.t.replace(/"/g, "")}" loading="lazy" decoding="async"><figcaption>${e.cr}</figcaption></figure>`;
// ------------------------------------------------------------------ list view (always built: also the no-WebGL / reduced-motion view)
const list = document.getElementById("list");
{
let html = "", lastMonth = "";
for (const e of E) {
const m = e.d.slice(0,7);
if (m !== lastMonth) { html += `<h2>${new Date(day(e.d)*864e5).toLocaleDateString("en-GB",{month:"long",year:"numeric",timeZone:"UTC"})}</h2>`; lastMonth = m;
if (m === "2026-10") html += chartHTML(); }
html += `<article class="it"><time datetime="${e.d}">${fmt(e.n).slice(0,6)}</time><div>${imgHTML(e, "li-img")}<div class="k" style="color:${C[e.c].col}">${logoHTML(e)}${C[e.c].name}</div><h3>${e.t}</h3><p>${e.x}</p>${e.s.map(([a,h])=>`<a href="${h}">${a}</a>`).join(" · ")}</div></article>`;
}
list.innerHTML = html;
}
function chartHTML(){
const days = Object.keys(DAILY), max = Math.max(...Object.values(DAILY));
return `<div id="chart"><div class="bars">${days.map(d=>`<span title="${d}: ${DAILY[d]}" style="height:${Math.max(2,DAILY[d]/max*100)}%"></span>`).join("")}</div>
<small>New GitHub repos mentioning M-VAVE per day, 20 Sep – 17 Oct 2026 (peak ${max}). Over the summer it was about one a day.</small></div>`;
}
const setList = (on) => { document.body.classList.toggle("listmode", on); if (on) scrollTo(0,0); };
document.getElementById("tolist").onclick = () => setList(true);
document.getElementById("to3d").onclick = () => { setList(false); if (!started) boot(); };
let started = false;
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const gl = (() => { try { const c = document.createElement("canvas"); return !!(c.getContext("webgl2") || c.getContext("webgl")); } catch { return false; } })();
const EMBED = new URLSearchParams(location.search).has("embed");
document.body.classList.toggle("embed", EMBED);
if (EMBED && (!gl || reduce)) { // a still picture and a link instead of the animation
document.body.innerHTML = `<a href="timeline.html" target="_top" style="position:fixed;inset:0;display:block;background:#05060b url(assets/timeline-poster.jpg) center/cover;color:#fff;text-decoration:none">
<span style="position:absolute;left:18px;bottom:16px;font:700 22px 'Space Grotesk',sans-serif">The FM-1 Story →</span></a>`;
} else if (!gl || reduce) setList(true); else boot();
// ------------------------------------------------------------------ sound: an FM drone and FM bells, made live with Web Audio
const Sound = (() => {
let ac = null, master, dry, rev, drone = [], active = false;
let on = (() => { try { return localStorage.getItem("fm1tl.snd") !== "0"; } catch { return true; } })();
function init(){
if (ac) return;
ac = new (window.AudioContext || window.webkitAudioContext)();
master = ac.createGain(); master.gain.value = 0;
const comp = ac.createDynamicsCompressor(); comp.threshold.value = -18; comp.ratio.value = 3;
master.connect(comp); comp.connect(ac.destination);
dry = ac.createGain(); dry.gain.value = .8; dry.connect(master);
rev = ac.createConvolver(); // a generated 3.4 s hall
const len = Math.floor(ac.sampleRate * 3.4), ir = ac.createBuffer(2, len, ac.sampleRate);
for (let ch = 0; ch < 2; ch++) { const d = ir.getChannelData(ch); for (let i = 0; i < len; i++) d[i] = (Math.random()*2 - 1) * Math.pow(1 - i/len, 2.8); }
rev.buffer = ir; const rg = ac.createGain(); rg.gain.value = .5; rev.connect(rg); rg.connect(master);
// three 2-operator FM voices: carrier + modulator at 2:1, the index and the filter follow the story's heat
drone = [55, 82.41, 110].map((f, k) => {
const car = ac.createOscillator(), mod = ac.createOscillator(), mg = ac.createGain(), vg = ac.createGain(), lp = ac.createBiquadFilter();
car.frequency.value = f; mod.frequency.value = f * 2; mg.gain.value = f * .4; mod.connect(mg); mg.connect(car.frequency);
vg.gain.value = .04; lp.type = "lowpass"; lp.frequency.value = 500; lp.Q.value = .7;
car.connect(vg); vg.connect(lp);
const pan = ac.createStereoPanner ? ac.createStereoPanner() : null;
if (pan) { pan.pan.value = [-.55, 0, .55][k]; lp.connect(pan); pan.connect(dry); pan.connect(rev); } else { lp.connect(dry); lp.connect(rev); }
const lfo = ac.createOscillator(), lg = ac.createGain(); lfo.frequency.value = .07 + k*.03; lg.gain.value = .7; lfo.connect(lg); lg.connect(car.detune);
car.start(); mod.start(); lfo.start();
return { car, mod, mg, vg, lp, f };
});
}
const level = () => (on && active ? .8 : 0);
function start(){ init(); ac.resume(); active = true; master.gain.setTargetAtTime(level(), ac.currentTime, .8); }
function stop(){ active = false; if (ac) master.gain.setTargetAtTime(0, ac.currentTime, .5); }
function setOn(v){ on = v; try { localStorage.setItem("fm1tl.snd", v ? "1" : "0"); } catch {} if (ac) master.gain.setTargetAtTime(level(), ac.currentTime, .3); }
function heat(h, root){ // h 0..1: how hot the scene is at this date
if (!ac || !active) return; const t = ac.currentTime;
for (const v of drone) {
v.car.frequency.setTargetAtTime(v.f * root, t, 2); v.mod.frequency.setTargetAtTime(v.f * root * 2, t, 2);
v.mg.gain.setTargetAtTime(v.f * root * (.35 + h * 3.4), t, 1.2);
v.lp.frequency.setTargetAtTime(380 + h * 2800, t, 1.2); v.vg.gain.setTargetAtTime(.035 + h * .03, t, 1.2);
}
}
function ping(note, major, panv){ // a DX-style FM bell; the big moments get an electric piano and a low octave
if (!ac || !active || !on) return; const t = ac.currentTime + .02, f = 440 * Math.pow(2, (note - 69) / 12), dur = major ? 4 : 1.8;
const car = ac.createOscillator(), mod = ac.createOscillator(), mg = ac.createGain(), vg = ac.createGain();
car.frequency.value = f; mod.frequency.value = f * (major ? 1 : 3.5);
mg.gain.setValueAtTime(f * (major ? 2.4 : 5), t); mg.gain.exponentialRampToValueAtTime(f * .03, t + (major ? 2.6 : .8));
mod.connect(mg); mg.connect(car.frequency);
vg.gain.setValueAtTime(.0001, t); vg.gain.exponentialRampToValueAtTime(major ? .3 : .17, t + .008); vg.gain.exponentialRampToValueAtTime(.0001, t + dur);
car.connect(vg); let out = vg;
if (ac.createStereoPanner) { const pn = ac.createStereoPanner(); pn.pan.value = panv; vg.connect(pn); out = pn; }
out.connect(dry); out.connect(rev);
car.start(t); mod.start(t); car.stop(t + dur + .1); mod.stop(t + dur + .1);
if (major) { const o = ac.createOscillator(), g = ac.createGain(); o.frequency.value = f / 2;
g.gain.setValueAtTime(.0001, t); g.gain.exponentialRampToValueAtTime(.22, t + .02); g.gain.exponentialRampToValueAtTime(.0001, t + 3.6);
o.connect(g); g.connect(dry); g.connect(rev); o.start(t); o.stop(t + 3.7); }
}
return { start, stop, setOn, heat, ping, get on(){ return on; }, get active(){ return active; } };
})();
// ------------------------------------------------------------------ the 3D timeline
async function boot(){
started = true;
const THREE = await import("three");
const { CSS2DRenderer, CSS2DObject } = await import("three/addons/renderers/CSS2DRenderer.js");
const { EffectComposer } = await import("three/addons/postprocessing/EffectComposer.js");
const { RenderPass } = await import("three/addons/postprocessing/RenderPass.js");
const { UnrealBloomPass } = await import("three/addons/postprocessing/UnrealBloomPass.js");
const { OutputPass } = await import("three/addons/postprocessing/OutputPass.js");
// time -> distance: each event gets its own slot; long quiet gaps are compressed (sqrt), so October's
// explosion gets the room it needs
let z = 0; const slotZ = [];
E.forEach((e,i) => { if (i) { const gap = e.n - E[i-1].n; z += gap === 0 ? 7 : 7 + 9*Math.sqrt(gap); } slotZ.push(z); e.z = z; });
const Z0 = -24, Z1 = z + 30;
const zOfDay = (n) => { // a day number -> z (interpolated between event slots)
if (n <= E[0].n) return E[0].z - (E[0].n - n) * 0.6;
for (let i = 1; i < E.length; i++) if (n <= E[i].n) {
const a = E[i-1], b = E[i]; if (b.n === a.n) continue;
// the first event of day b.n starts the day
let bz = b.z; for (let j = i; j > 0 && E[j-1].n === b.n; j--) bz = E[j-1].z;
return a.z + (bz - a.z) * (n - a.n) / (b.n - a.n);
}
return E[E.length-1].z + (n - E[E.length-1].n) * 6;
};
const dayOfZ = (zz) => { let lo = E[0].n - 30, hi = E[E.length-1].n + 5;
for (let k = 0; k < 40; k++) { const mid = (lo+hi)/2; if (zOfDay(mid) < zz) lo = mid; else hi = mid; } return Math.round(lo); };
const warZ = zOfDay(day("2026-09-28"));
// the carrier: a slow wave whose amplitude grows once the custom firmware starts (an FM signal
// getting hotter); the modulator: a fast helix around it
const amp = (zz) => 1.2 + 3.2 / (1 + Math.exp(-(zz - warZ) / 30));
const P = (zz) => new THREE.Vector3(Math.sin(zz*0.035) * amp(zz) * 1.6, Math.sin(zz*0.021 + 1.3) * amp(zz) * 0.7, -zz);
const NARROW = innerWidth < 640;
const canvas = document.getElementById("scene");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(devicePixelRatio, EMBED ? 1.25 : 2));
renderer.setSize(innerWidth, innerHeight);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05060b);
scene.fog = new THREE.FogExp2(0x05060b, 0.0105);
const camera = new THREE.PerspectiveCamera(NARROW ? 72 : 60, innerWidth/innerHeight, 0.1, 600);
const labels = new CSS2DRenderer({ element: document.getElementById("labels") });
labels.setSize(innerWidth, innerHeight);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.85, 0.5, 0.2);
composer.addPass(bloom);
composer.addPass(new OutputPass());
// ribbons
const pts = []; for (let zz = Z0; zz <= Z1; zz += 1.5) pts.push(P(zz));
const curve = new THREE.CatmullRomCurve3(pts);
const segs = pts.length * 4;
const tube = new THREE.TubeGeometry(curve, segs, 0.07, 8, false);
const cA = new THREE.Color(C.mv.col), cB = new THREE.Color(C.cf.col), cH = new THREE.Color(C.hw.col);
{ const pos = tube.attributes.position, col = new Float32Array(pos.count*3), t = new THREE.Color();
for (let i = 0; i < pos.count; i++) { const zz = -pos.getZ(i); const k = 1/(1+Math.exp(-(zz-warZ)/20));
t.copy(zz < E[1].z + 20 ? cH.clone().lerp(cA, Math.min(1, Math.max(0,(zz-E[1].z+10)/40))) : cA).lerp(cB, k); t.toArray(col, i*3); }
tube.setAttribute("color", new THREE.BufferAttribute(col, 3)); }
scene.add(new THREE.Mesh(tube, new THREE.MeshBasicMaterial({ vertexColors: true })));
// modulator helix
{ const hp = []; for (let zz = Z0; zz <= Z1; zz += 0.5) { const c = P(zz), r = 0.55 + 0.25*Math.sin(zz*0.11); const a = zz*0.42;
hp.push(new THREE.Vector3(c.x + Math.cos(a)*r, c.y + Math.sin(a)*r, c.z)); }
const g = new THREE.BufferGeometry().setFromPoints(hp);
scene.add(new THREE.Line(g, new THREE.LineBasicMaterial({ color: 0x6f7cff, transparent: true, opacity: .35 }))); }
// stars
{ const n = 2600, g = new THREE.BufferGeometry(), a = new Float32Array(n*3);
for (let i = 0; i < n; i++) { let x, y, zz;
do { x = (Math.random()-.5)*260; y = (Math.random()-.5)*160; zz = Math.random()*(Z1+80)-40; const c = P(zz); if (Math.hypot(x-c.x, y-c.y) > 14) break; } while (true);
a[i*3] = x; a[i*3+1] = y; a[i*3+2] = -zz; }
g.setAttribute("position", new THREE.BufferAttribute(a,3));
scene.add(new THREE.Points(g, new THREE.PointsMaterial({ color: 0x4a5180, size: .22, sizeAttenuation: true, transparent: true, opacity: .7, depthWrite: false }))); }
// the explosion: one particle cloud per day, as dense as that day's new repos
const burst = new THREE.Group(); scene.add(burst);
const spark = (() => { const c = document.createElement("canvas"); c.width = c.height = 64; const x = c.getContext("2d");
const g = x.createRadialGradient(32,32,0,32,32,32); g.addColorStop(0,"rgba(255,255,255,1)"); g.addColorStop(.25,"rgba(255,255,255,.6)"); g.addColorStop(1,"rgba(255,255,255,0)");
x.fillStyle = g; x.fillRect(0,0,64,64); return new THREE.CanvasTexture(c); })();
{ const pos = [], col = [], t = new THREE.Color();
const add = (zc, n, spread) => { for (let i = 0; i < n; i++) { const c = P(zc + (Math.random()-.5)*spread); const r = 3.2 + Math.pow(Math.random(),.6)*(4 + n*.05);
const a = Math.random()*Math.PI*2, b = (Math.random()-.5)*Math.PI;
pos.push(c.x + Math.cos(a)*Math.cos(b)*r, c.y + Math.sin(b)*r*.8, c.z + Math.sin(a)*Math.cos(b)*r);
t.copy(cA).lerp(cB, Math.min(1, n/220 + Math.random()*.4)); col.push(t.r, t.g, t.b); } };
for (const [m, n] of Object.entries(MONTHLY)) { const s = day(m.slice(0,7)+"-01"), len = m.endsWith("a") ? 19 : 30;
for (let k = 0; k < len; k++) add(zOfDay(s+k), Math.round(n/len*14), 6); }
for (const [d, n] of Object.entries(DAILY)) add(zOfDay(day(d)) + 3, n*14, 10);
const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos,3)); g.setAttribute("color", new THREE.Float32BufferAttribute(col,3));
burst.add(new THREE.Points(g, new THREE.PointsMaterial({ size: .3, map: spark, vertexColors: true, transparent: true, opacity: .75, depthWrite: false, blending: THREE.AdditiveBlending }))); }
// month and day markers
const monthLabels = [];
for (const m of ["2026-06","2026-07","2026-08","2026-09","2026-10"]) {
const zz = zOfDay(day(m+"-01")); const el = document.createElement("div"); el.className = "monthlabel";
el.textContent = new Date(day(m+"-01")*864e5).toLocaleDateString("en-GB",{month:"long",timeZone:"UTC"}).toUpperCase();
const o = new CSS2DObject(el); const c = P(zz); o.position.set(c.x - 9, c.y + 6, c.z); scene.add(o); monthLabels.push({o, el, z: zz});
const ring = new THREE.Mesh(new THREE.TorusGeometry(2.4, 0.015, 6, 90), new THREE.MeshBasicMaterial({ color: 0x3a4170, transparent: true, opacity: .8 }));
ring.position.copy(c); scene.add(ring);
}
// events
const nodes = [], ringGeo = new THREE.TorusGeometry(0.62, 0.035, 8, 64);
const side = {};
for (const e of E) {
const c = P(e.z), col = new THREE.Color(C[e.c].col);
const s = new THREE.Mesh(new THREE.SphereGeometry(e.m ? .34 : .2, 24, 16), new THREE.MeshBasicMaterial({ color: col }));
s.position.copy(c); scene.add(s);
let ring = null;
if (e.m) { ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: .9 })); ring.position.copy(c); scene.add(ring); }
// card on alternating sides, stacked when several share a day
const k = (side[e.n] = (side[e.n] ?? -1) + 1);
const sx = (e.i % 2 ? 1 : -1), off = NARROW ? new THREE.Vector3(sx * 0.6, 2.2 + k*1.6, 0) : new THREE.Vector3(sx * (4.2 + (k%2)*1.6), 1.2 + k*1.5, 0);
const el = document.createElement("div"); el.className = "card " + (e.m ? "major" : "");
el.style.setProperty("--c", C[e.c].col);
el.innerHTML = `${imgHTML(e)}<div class="k"><span>${logoHTML(e)}${C[e.c].name}</span><span>${fmt(e.n).slice(0,6)}</span></div><h3>${e.t}</h3><p>${e.x.split(". ")[0]}.</p>`;
el.addEventListener("click", () => openDetail(e.i));
const o = new CSS2DObject(el); o.position.copy(c).add(off); scene.add(o);
const lg = new THREE.BufferGeometry().setFromPoints([c, c.clone().add(off.clone().multiplyScalar(.92))]);
const line = new THREE.Line(lg, new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: .45 })); scene.add(line);
nodes.push({ e, s, ring, o, el, line });
}
// legend: filter by kind
const off = new Set(), legend = document.getElementById("legend");
for (const [k, v] of Object.entries(C)) {
const b = document.createElement("button"); b.className = "chip"; b.innerHTML = `<i style="color:${v.col};background:${v.col}"></i>${v.name}`;
b.onclick = () => { off.has(k) ? off.delete(k) : off.add(k); b.classList.toggle("off", off.has(k)); };
legend.appendChild(b);
}
// progress bar month ticks
const track = document.getElementById("track"), fill = document.getElementById("fill");
const zStart = E[0].z - 14, zEnd = E[E.length-1].z + 4;
for (const m of (NARROW ? ["2026-07","2026-10"] : ["2026-07","2026-08","2026-09","2026-10"])) { const t = document.createElement("div"); t.className = "tick";
t.style.left = ((zOfDay(day(m+"-01")) - zStart)/(zEnd - zStart)*100) + "%"; t.textContent = MON[+m.slice(5)-1]; track.appendChild(t); }
// scroll -> camera
const scrollMax = () => document.documentElement.scrollHeight - innerHeight;
let camZ = zStart, target = zStart;
const zFromScroll = () => zStart + (scrollY / Math.max(1, scrollMax())) * (zEnd - zStart);
const isEmbed = () => document.body.classList.contains("embed");
addEventListener("scroll", () => { if (auto || isEmbed()) return; target = zFromScroll(); document.getElementById("hint").style.opacity = scrollY > 40 ? 0 : 1; }, { passive: true });
const goZ = (zz) => scrollTo({ top: (zz - zStart) / (zEnd - zStart) * scrollMax(), behavior: "smooth" });
const nearest = (dir) => { const cur = target + 9; const vis = E.filter(e => !off.has(e.c));
const n = dir > 0 ? vis.find(e => e.z > cur + .5) : [...vis].reverse().find(e => e.z < cur - .5); return n; };
document.getElementById("next").onclick = () => { const n = nearest(1); if (n) goZ(n.z - 9); };
document.getElementById("prev").onclick = () => { const n = nearest(-1); if (n) goZ(n.z - 9); };
// ---- the event list (right) : the active event expands in place
const sideEl = document.getElementById("side"), sitems = document.getElementById("sitems"), items = [];
{ let lastM = "";
for (const e of E) {
const m = e.d.slice(0,7);
if (m !== lastM) { const h = document.createElement("div"); h.className = "sm"; h.textContent = new Date(day(e.d)*864e5).toLocaleDateString("en-GB",{month:"long",year:"numeric",timeZone:"UTC"}).toUpperCase(); sitems.appendChild(h); lastM = m; }
const it = document.createElement("div"); it.className = "si" + (e.m ? " maj" : ""); it.tabIndex = 0; it.setAttribute("role","button");
it.style.setProperty("--c", C[e.c].col);
it.innerHTML = `<div class="row"><i style="color:${C[e.c].col};background:${C[e.c].col}"></i><time>${fmt(e.n).slice(0,6)}</time><b>${e.t}</b></div>
<div class="more">${imgHTML(e, "si-img")}${e.x}<br>${e.s.map(([t,h]) => `<a href="${h}" target="_blank" rel="noopener">${t} ↗</a>`).join("")}</div>`;
it.addEventListener("click", (ev) => { if (ev.target.closest("a")) return; openDetail(e.i); });
it.addEventListener("keydown", (ev) => { if (ev.key === "Enter") openDetail(e.i); });
sitems.appendChild(it); items.push(it);
}
}
const refreshFilter = () => { let n = 0; E.forEach((e,i) => { const h = off.has(e.c); items[i].classList.toggle("hid", h); if (!h) n++; });
document.getElementById("scount").textContent = n + " / " + E.length; };
refreshFilter();
legend.addEventListener("click", () => setTimeout(refreshFilter));
let active = -1;
function setActive(i){
if (i === active) return; active = i;
items.forEach((it, k) => { it.classList.toggle("on", k === i); it.classList.toggle("past", k < i); });
const it = items[i]; if (it) sitems.scrollTo({ top: Math.max(0, it.offsetTop - sitems.clientHeight * .3), behavior: "smooth" });
}
const WIDE = () => innerWidth > 760;
function openDetail(i, fromAuto){
if (!fromAuto) setAuto(false);
setActive(i); goZ(E[i].z - 9);
if (!WIDE() && !fromAuto) sideEl.classList.add("open");
}
window.openDetail = openDetail;
document.getElementById("sidebtn").onclick = () => sideEl.classList.toggle("open");
document.getElementById("sclose").onclick = () => sideEl.classList.remove("open");
// ---- autoplay: one continuous glide that slows down near each event (most near the big ones), never stops
let auto = false; const autoBtn = document.getElementById("auto"), sndBtn = document.getElementById("snd"), fadeEl = document.getElementById("fade");
const bumps = E.map(e => [e.z, e.m ? .8 : .52]);
const slow = (zz) => { let s = 0; for (const [ez, w] of bumps) { const d = (zz + 9 - ez) / 5.5; if (d > -4 && d < 4) s = Math.max(s, w * Math.exp(-d*d)); } return 1 - s; };
let BASE = 1; { let T = 0; for (let zz = zStart; zz < zEnd; zz += .5) T += .5 / slow(zz); BASE = T / 165; } // the whole story in ~2 min 45 s
const syncScroll = () => { if (!isEmbed()) scrollTo(0, (target - zStart) / (zEnd - zStart) * scrollMax()); };
let wrapping = false;
function wrapToStart(){ if (wrapping) return; wrapping = true; fadeEl.style.opacity = 1;
setTimeout(() => { target = camZ = zStart; lastPing = -1; syncScroll(); fadeEl.style.opacity = 0; wrapping = false; }, 750); }
function setAuto(on){
auto = on;
autoBtn.classList.toggle("on", on); autoBtn.textContent = on ? "❚❚ PAUSE" : "▶ AUTO";
if (on) { if (target >= zEnd - 2) wrapToStart(); if (!isEmbed() && gestured) Sound.start(); }
else Sound.stop();
}
autoBtn.onclick = () => setAuto(!auto);
const sndLabel = () => { sndBtn.textContent = Sound.on ? "♪ ON" : "♪ OFF"; sndBtn.classList.toggle("off", !Sound.on); };
sndBtn.onclick = () => { Sound.setOn(!Sound.on); sndLabel(); }; sndLabel();
const pause = () => { if (auto && !isEmbed()) setAuto(false); };
addEventListener("wheel", (ev) => { if (!ev.target.closest || !ev.target.closest("#side")) { pause(); startGone(); } }, { passive: true });
addEventListener("touchmove", (ev) => { if (!ev.target.closest("#side")) pause(); }, { passive: true });
canvas.addEventListener("pointerdown", pause);
document.getElementById("next").addEventListener("click", pause, true);
document.getElementById("prev").addEventListener("click", pause, true);
addEventListener("keydown", (ev) => {
if (isEmbed()) return;
if (ev.target.closest && ev.target.closest(".si") && ev.key === "Enter") return;
if (ev.key === "ArrowRight" || ev.key === "j") { ev.preventDefault(); pause(); startGone(); document.getElementById("next").click(); }
if (ev.key === "ArrowLeft" || ev.key === "k") { ev.preventDefault(); pause(); startGone(); document.getElementById("prev").click(); }
if (ev.key === " " && !(ev.target.closest && ev.target.closest("button"))) { ev.preventDefault(); startGone(); setAuto(!auto); }
if (ev.key === "m") sndBtn.click();
if (ev.key === "Escape") { pause(); sideEl.classList.remove("open"); }
});
// ---- the start screen (full page) and the embed (a silent looping preview; a click goes full screen with sound)
const startEl = document.getElementById("start");
function startGone(){ startEl.classList.add("gone"); document.body.classList.remove("starting"); document.getElementById("hint").style.display = "none"; }
if (!isEmbed()) document.body.classList.add("starting");
// autoplay starts on its own; sound needs a click (browser rule), so it waits for one
let gestured = false; const pill = document.getElementById("soundpill");
addEventListener("pointerdown", () => { gestured = true; }, { capture: true });
addEventListener("keydown", () => { gestured = true; }, { capture: true });
pill.onclick = () => { gestured = true; pill.classList.add("gone"); Sound.setOn(true); sndLabel(); if (!auto) setAuto(true); else Sound.start(); };
sndBtn.addEventListener("click", () => { pill.classList.add("gone"); if (auto && Sound.on) Sound.start(); });
const NOAUTO = new URLSearchParams(location.search).has("noauto"); // (for linking to a still view, and for tests)
if (!isEmbed()) { if (!NOAUTO) setAuto(true); setTimeout(startGone, NOAUTO ? 0 : 3800); }
let fullFromEmbed = false;
async function enterFull(){
const el = document.documentElement; let ok = false;
try { if (el.requestFullscreen) { await el.requestFullscreen(); ok = true; } else if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); ok = true; } } catch { ok = false; }
if (!ok) { (window.top || window).location.href = "timeline.html?play=1"; return; } // no element full screen (iPhone): the page itself
fullFromEmbed = true; document.body.classList.remove("embed"); startGone();
applyView(); syncScroll(); Sound.setOn(true); sndLabel(); Sound.start(); if (!auto) setAuto(true);
}
document.getElementById("embedui").addEventListener("click", enterFull);
const fsChange = () => { if (!(document.fullscreenElement || document.webkitFullscreenElement) && fullFromEmbed) {
fullFromEmbed = false; document.body.classList.add("embed"); Sound.stop(); sideEl.classList.remove("open"); applyView(); if (!auto) setAuto(true); } };
document.addEventListener("fullscreenchange", fsChange); document.addEventListener("webkitfullscreenchange", fsChange);
if (isEmbed()) { startGone(); setAuto(true); }
// the page around the embed says when it is out of view: no rendering then
let hiddenByHost = false;
addEventListener("message", (ev) => { if (ev.data && ev.data.fm1tl === "vis") hiddenByHost = !ev.data.v; });
// the 3D view is centred in the space left of the list
const SIDE = 330 + 36;
const applyView = () => { if (WIDE() && !isEmbed()) camera.setViewOffset(innerWidth, innerHeight, SIDE/2, 0, innerWidth, innerHeight); else camera.clearViewOffset(); };
applyView();
// mouse parallax
let mx = 0, my = 0; addEventListener("pointermove", (ev) => { mx = ev.clientX/innerWidth - .5; my = ev.clientY/innerHeight - .5; });
addEventListener("resize", () => { camera.aspect = innerWidth/innerHeight; applyView(); camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight); labels.setSize(innerWidth, innerHeight); composer.setSize(innerWidth, innerHeight); });
const dateEl = document.getElementById("date"), eraEl = document.getElementById("era");
const clock = new THREE.Clock(); const look = new THREE.Vector3(); const cp = new THREE.Vector3();
target = isEmbed() ? zStart : zFromScroll(); camZ = target; let lastPing = -1, lastHeat = 0;
(function frame(){
const dt = Math.min(.25, clock.getDelta()), t = clock.elapsedTime;
if (hiddenByHost || document.hidden) { requestAnimationFrame(frame); return; }
if (auto && !wrapping) {
target += BASE * slow(target) * dt;
if (target >= zEnd) { target = zEnd; if (isEmbed() || fullFromEmbed) wrapToStart(); else setAuto(false); }
syncScroll();
}
const ease = 1 - Math.exp(-dt * 4.5); // frame-rate independent: the same glide at 20 or 120 fps
camZ += (target - camZ) * ease;
const c = P(camZ), ahead = P(camZ + 14);
cp.set(c.x + (NARROW ? 0 : 1.5) + mx*3, c.y + (NARROW ? 3.4 : 2.6) - my*2, c.z + (NARROW ? 12 : 9));
const e2 = 1 - Math.exp(-dt * 12); camera.position.lerp(cp, e2); look.lerp(ahead, e2); camera.lookAt(look);
// the event at the focus point gets the full card; its neighbours show as chips
let focus = null, best = 1e9;
for (const n of nodes) { if (off.has(n.e.c)) continue; const dd = Math.abs(n.e.z - camZ - 9); if (dd < best) { best = dd; focus = n; } }
for (const n of nodes) {
const vis = !off.has(n.e.c), d = n.e.z - camZ; // ahead of the camera = positive
const isF = n === focus, far = n.e.m ? 40 : 24;
const o = !vis ? 0 : isF ? 1 : (!NARROW && d > 2 && d < far) ? Math.min(.8, (far - d)/8, (d - 2)/4) : 0;
n.el.style.opacity = o.toFixed(2); n.el.style.pointerEvents = o > .3 ? "auto" : "none";
n.el.classList.toggle("minor", !isF); n.el.classList.toggle("focus", isF);
n.line.material.opacity = isF ? .8 : .3 * o;
n.s.visible = n.line.visible = vis; if (n.ring) { n.ring.visible = vis; n.ring.rotation.x = t*.7; n.ring.rotation.y = t*.5;
n.ring.scale.setScalar(1 + .12*Math.sin(t*2 + n.e.i)); }
n.s.scale.setScalar(1 + .15*Math.sin(t*3 + n.e.i*1.7));
}
if (focus) setActive(focus.e.i);
if (auto && focus && focus.e.i !== lastPing && Math.abs(focus.e.z - camZ - 9) < 2.5) { // an event reaches the focus: its note
lastPing = focus.e.i; const base = { hw: 57, mv: 64, cf: 69, tool: 72, smk: 74, press: 76 }[focus.e.c];
Sound.ping(focus.e.m ? base - 12 : base, !!focus.e.m, focus.e.i % 2 ? .4 : -.4);
}
if (Sound.active && t - lastHeat > .5) { lastHeat = t; const dnow = dayOfZ(camZ + 9);
const cnt = DAILY[new Date(dnow*864e5).toISOString().slice(0,10)] ?? (dnow > day("2026-10-15") ? 20 : 1);
const root = dnow < day("2026-08-01") ? 1 : dnow < day("2026-09-28") ? .8909 : dnow < day("2026-10-04") ? 1.1892 : 1.3348;
Sound.heat(Math.min(1, cnt / 28), root); }
for (const m of monthLabels) { const d = m.z - camZ; m.el.style.opacity = d > -4 && d < 70 ? 1 : 0; }
burst.rotation.z = Math.sin(t*.05)*.02;
const dn = Math.max(E[0].n - 8, Math.min(dayOfZ(camZ + 9), E[E.length-1].n)); dateEl.textContent = fmt(dn); // (never past the last event, never before June)
eraEl.textContent = dn < day("2026-07-19") ? "the hardware" : dn < day("2026-09-28") ? "M-VAVE updates · the firmware opened up" : dn < day("2026-10-04") ? "the first custom firmwares" : dn < day("2026-10-14") ? "the firmware war" : "the official phase";
fill.style.width = Math.max(0, Math.min(100, (camZ - zStart)/(zEnd - zStart)*100)) + "%";
composer.render(); labels.render(scene, camera);
// a chip on top of the focus card fades away (the renderer orders labels by depth, not by importance)
if (focus) { const fr = focus.el.getBoundingClientRect(), sr = WIDE() ? sideEl.getBoundingClientRect() : null;
const hit = (r, q) => r.left < q.right + 8 && r.right > q.left - 8 && r.top < q.bottom + 8 && r.bottom > q.top - 8;
for (const n of nodes) { if (+n.el.style.opacity < .05) continue; const r = n.el.getBoundingClientRect();
if ((n !== focus && hit(r, fr)) || (sr && hit(r, sr))) n.el.style.opacity = n === focus ? ".25" : ".08"; } }
requestAnimationFrame(frame);
})();
}
</script>
</body>
</html>