PageSourceSearch

https://coral.shoes/morse-trainer/morse-trainer.js

js coral.shoes collected 2026-10-09 23:57:02 UTC 46,257 bytes, 1,522 lines download raw bytes

1const DIT = ".";
2const DAH = "-";
3const LETTER_SEPARATOR = "LET";
4const WORD_SEPARATOR = "WORD";
5
6const DITS_PER_WORD = 50; // paris
7
8const TUNE = [
9    0, 0, 0, 12, 7, 6, 5, 3, 0, 3, 5,
10    -2, -2, 12, 7, 6, 5, 3, 0, 3, 5,
11    -4, -4, 12, 7, 6, 5, 3, 0, 3, 5,
12    -5, -5, 12, 7, 6, 5, 3, 0, 3, 5,
13    3, 3, 3, 3, 3, 0, 0,
14    3, 3, 3, 3, 5, 6, 5, 3, 0, 3, 5,
15    3, 3, 3, 5, 7, 10, 10, 7,
16    12, 12, 12, 7, 12, 10, 12+5
17];
18
19function event_span(ev) {
20    let el = document.createElement("span");
21    if (ev == DIT) {
22        el.innerText = "·";
23        el.classList.add("dot");
24    } else if (ev == DAH) {
25        el.innerText = "–";
26        el.classList.add("dot");
27    } else if (ev == LETTER_SEPARATOR) {
28        el.innerText = "-";
29        el.classList.add("space");
30    } else if (ev == WORD_SEPARATOR) {
31        el.innerText = "␣";
32        el.classList.add("space");
33    }
34    return el;
35}
36
37function span_with_class(text, ...clss) {
38    let el = document.createElement("span");
39
40    el.innerText = text;
41
42    for (let cls of clss)
43        el.classList.add(cls);
44    return el;
45}
46
47function get_letter(ev) {
48    for (var i = 0; i < MORSE.length; i++) {
49        let l = MORSE[i];
50        if (l.length == 0)
51            continue;
52        let sequence = l[1];
53        if (sequence.length != ev.length)
54            continue;
55
56        let bad = false;
57        for (var j = 0; j < sequence.length; j++) {
58            if ((sequence[j] == ".") != (ev[j] == DIT))
59                bad = true;
60        }
61        if (!bad)
62            return l[0];
63    }
64    return "?";
65}
66
67function get_word(events, use_real_spaces) {
68    let letters = [];
69    let current_letter = [];
70    for (var i = 0; i < events.length; i++) {
71        let current_ev = events[i];
72        if (current_ev == DIT || current_ev == DAH) {
73            current_letter.push(current_ev);
74        } else if (current_ev == LETTER_SEPARATOR || current_ev == WORD_SEPARATOR) {
75            letters.push(get_letter(current_letter));
76            if (current_ev == WORD_SEPARATOR)
77                letters.push(use_real_spaces ? " " : "␣");
78            current_letter = [];
79        }
80    }
81    return letters.join("");
82}
83
84// empty element = space in table
85const MORSE = [
86    ["A", ".-"],
87    ["B", "-..."],
88    ["C", "-.-."],
89    ["D", "-.."],
90    ["E", "."],
91    ["F", "..-."],
92    ["G", "--."],
93    ["H", "...."],
94    ["I", ".."],
95    ["J", ".---"],
96    ["K", "-.-"],
97    ["L", ".-.."],
98    ["M", "--"],
99    ["N", "-."],
100    ["O", "---"],
101    ["P", ".--."],
102    ["Q", "--.-"],
103    ["R", ".-."],
104    ["S", "..."],
105    ["T", "-"],
106    ["U", "..-"],
107    ["V", "...-"],
108    ["W", ".--"],
109    ["X", "-..-"],
110    ["Y", "-.--"],
111    ["Z", "--.."],
112
113    [],
114
115    ["1", ".----"],
116    ["2", "..---"],
117    ["3", "...--"],
118    ["4", "....-"],
119    ["5", "....."],
120    ["6", "-...."],
121    ["7", "--..."],
122    ["8", "---.."],
123    ["9", "----."],
124    ["0", "-----"],
125
126    [],
127
128    [".", ".-.-.-"],
129    [",", "--..--"],
130    ["?", "..--.."],
131    ["!", "-.-.--"],
132];
133
134class Morse {
135    // draw_callback(typed, typing, morse_spans, current_word)
136    constructor(default_wpm, draw_callback) {
137        this.draw_callback = draw_callback;
138
139        this.last_press_start = null;
140        this.last_press_end = null;
141        this.events = [];
142
143        this.typed_text = "";
144
145        this.force_update = false;
146
147        this.DIT_DURATION = 0;
148        this.DAH_DURATION = 0;
149        this.SYMBOL_SEP = 0;
150        this.LETTER_SEP = 0;
151        this.WORD_SEP = 0;
152
153        this.set_speed_char(default_wpm);
154        this.set_speed_word(default_wpm);
155
156        let self = this;
157        this.redraw_int = setInterval(() => self.redraw(), 10);
158
159        this.last_displayed_events = null;
160    }
161
162    // All sets and gets are in wpm unless specified as other
163    set_speed_char(wpm) {
164        let dit_speed_s = 1 / (wpm / 60 * DITS_PER_WORD);
165
166        this.DIT_DURATION = dit_speed_s;
167        this.DAH_DURATION = 3 * dit_speed_s;
168        this.SYMBOL_SEP = dit_speed_s;
169    }
170
171    set_speed_word(wpm) {
172        let dit_speed_s = 1 / (wpm / 60 * DITS_PER_WORD);
173
174        this.LETTER_SEP = 3 * dit_speed_s;
175        this.WORD_SEP = 7 * dit_speed_s;
176    }
177
178    get_speed_char() {
179        let wpm = (1 / this.DIT_DURATION) / DITS_PER_WORD * 60;
180        return Math.round(wpm * 1000) / 1000;
181    }
182
183    get_speed_word() {
184        let dit_word = this.WORD_SEP / 7;
185        let wpm = (1 / dit_word) / DITS_PER_WORD * 60;
186        // round because floating point inprecision
187        return Math.round(wpm * 1000) / 1000;
188    }
189
190    // set_speeds_ms(dit_speed_ms) {
191    //     this.DIT_DURATION = dit_speed_ms / 1000;
192    //     this.DAH_DURATION = 3 * this.DIT_DURATION;
193    //     this.SYMBOL_SEP = this.DIT_DURATION;
194    //     this.LETTER_SEP = 3 * this.DIT_DURATION;
195    //     this.WORD_SEP = 7 * this.DIT_DURATION;
196    // }
197
198    // set_speeds_wpm(wpm) {
199    //     let s_per_dit = 1 / (wpm / 60 * DITS_PER_WORD);
200    //     this.set_speeds_ms(1000 * s_per_dit);
201    // }
202
203    // get_dit_speed_ms() {
204    //     return 0 | (this.DIT_DURATION * 1000);
205    // }
206
207    // get_dit_speed_wpm() {
208    //     let wpm = (1 / this.DIT_DURATION) / DITS_PER_WORD * 60;
209
210    //     return (0 | (10 * wpm)) / 10;
211    // }
212
213    get_space() {
214        if (this.last_press_end == null) {
215            return null;
216        }
217        let delta = (Date.now() - this.last_press_end) / 1000;
218        if (delta > (this.WORD_SEP + this.LETTER_SEP) / 2) {
219            return WORD_SEPARATOR;
220        } else if (delta > (this.LETTER_SEP + this.SYMBOL_SEP) / 2) {
221            return LETTER_SEPARATOR;
222        }
223        return null;
224    }
225
226    get_dot() {
227        if (this.last_press_start == null) {
228            return null;
229        }
230        let delta = (Date.now() - this.last_press_start) / 1000;
231        if (delta > (this.DAH_DURATION + this.DIT_DURATION) / 2) {
232            return DAH;
233        } else {
234            return DIT;
235        }
236    }
237
238    push_word() {
239        let word = get_word(this.events, true);
240        this.typed_text += word;
241        this.events = [];
242    }
243
244    press() {
245        this.last_press_start = Date.now();
246
247        let space = this.get_space();
248        if (space != null)
249            this.events.push(space);
250
251        this.last_press_end = null;
252
253        if (space == WORD_SEPARATOR) {
254            this.push_word();
255        }
256
257        this.redraw();
258    }
259
260    submit_word() {
261        let space = this.get_space();
262        if (space != null)
263            this.events.push(space);
264        this.push_word();
265
266        this.events = [];
267        this.last_press_start = null;
268        this.last_press_end = null;
269    }
270
271    release() {
272        this.last_press_end = Date.now();
273
274        let dot = this.get_dot();
275        if (dot != null)
276            this.events.push(dot);
277
278        this.last_press_start = null;
279
280        if (this.events.length == 0)
281            this.last_press_end = null;
282
283        this.redraw();
284    }
285
286    clear() {
287        this.events = [];
288        this.last_press_start = null;
289        this.last_press_end = null;
290        this.force_update = true;
291    }
292
293    clear_all() {
294        this.typed_text = "";
295        this.clear();
296    }
297
298    redraw() {
299        let current_events = this.events.slice();
300
301        let dot = this.get_dot();
302        if (dot != null)
303            current_events.push(dot);
304
305        let space = this.get_space();
306        if (space != null)
307            current_events.push(space);
308
309        if (!this.force_update && this.last_displayed_events != null && current_events.join(" ") == this.last_displayed_events.join(" "))
310            return;
311        this.force_update = false;
312        this.last_displayed_events = current_events;
313
314
315        let content = [];
316        for (var i = 0; i < current_events.length; i++) {
317            let thing = current_events[i];
318            content.push(event_span(thing));
319        }
320        let current_word = get_word(current_events, false);
321
322        this.draw_callback(this.typed_text, get_word(current_events, false), content, current_word);
323    }
324}
325
326function bind_speed_input(morse, split_cb, wpm_inp, cwpm_inp, wwpm_inp) {
327    if (localStorage.getItem("speed-char") != null && localStorage.getItem("speed-word")) {
328        morse.set_speed_char(+localStorage.getItem("speed-char"));
329        morse.set_speed_word(+localStorage.getItem("speed-word"));
330    } else {
331        morse.set_speed_char(+cwpm_inp.value);
332        morse.set_speed_word(+wwpm_inp.value);
333    }
334
335    if (morse.get_speed_char() === morse.get_speed_word()) {
336        wpm_inp.value = cwpm_inp.value = wwpm_inp.value = morse.get_speed_char();
337        split_cb.checked = false;
338    } else {
339        split_cb.checked = true;
340        cwpm_inp.value = morse.get_speed_char();
341        wwpm_inp.value = morse.get_speed_word();
342        wpm_inp.value = morse.get_speed_word(); // idk
343    }
344
345    function set_same() {
346        morse.set_speed_char(+wpm_inp.value);
347        morse.set_speed_word(+wpm_inp.value);
348
349        localStorage.setItem("speed-char", +wpm_inp.value);
350        localStorage.setItem("speed-word", +wpm_inp.value);
351    }
352
353    function set_split() {
354        morse.set_speed_char(+cwpm_inp.value);
355        morse.set_speed_word(+wwpm_inp.value);
356
357        localStorage.setItem("speed-char", +cwpm_inp.value);
358        localStorage.setItem("speed-word", +wwpm_inp.value);
359    }
360
361    wpm_inp.addEventListener("change", e => set_same());
362    cwpm_inp.addEventListener("change", e => set_split());
363    wwpm_inp.addEventListener("change", e => set_split());
364    split_cb.addEventListener("change", e => {
365        if (split_cb.checked) {
366            set_split();
367        } else {
368            set_same();
369        }
370    });
371
372}
373
374function fill_morse_table(table, f) {
375    let col_left = table.children[0];
376    let children = [];
377    for (var i = 0; i < MORSE.length / 2; i++) {
378        if (MORSE[i].length == 0) {
379            children.push(document.createElement("br"));
380            continue;
381        }
382        let div = document.createElement("div");
383
384        div.classList.add("morse-element");
385        for (var j = 0; j < MORSE[i][1].length; j++)
386            div.appendChild(event_span(MORSE[i][1][j]));
387
388        div.appendChild(span_with_class(" " + MORSE[i][0]));
389
390        children.push(div);
391
392        if (f != undefined)
393            f(div, MORSE[i]);
394    }
395    col_left.replaceChildren(...children);
396
397    let col_right = table.children[2];
398    children = [];
399    for (; i < MORSE.length; i++) {
400        if (MORSE[i].length == 0) {
401            children.push(document.createElement("br"));
402            continue;
403        }
404        let div = document.createElement("div");
405        div.classList.add("morse-element");
406
407        div.appendChild(span_with_class(MORSE[i][0] + " "));
408        for (var j = 0; j < MORSE[i][1].length; j++)
409            div.appendChild(event_span(MORSE[i][1][j]));
410
411
412        children.push(div);
413
414        if (f != undefined)
415            f(div, MORSE[i]);
416    }
417    col_right.replaceChildren(...children);
418}
419
420
421const AudioContext = window.AudioContext || window.webkitAudioContext;
422
423var HOLD_TIME = 0.03;
424var RAMP_TIME = 0.012;
425
426class MorseAudio {
427    // lamp = document element to be given and ungiven .light class
428    // may be undefined
429
430    // as some browsers (firefox...) requires AudioContexts to be created
431    // as a result of a "user action", we initially set audio_ctx to undefined.
432    // Whenever a user action occurs, the controlling code should call the
433    // init_user() method, which sets up the audio_ctx
434
435    constructor(morse, lamp) {
436        this.morse = morse;
437        this.lamp = lamp;
438        lamp.children[0].focus();
439
440        this.audio_ctx = undefined;
441
442        this._freq = 512;
443        this.needs_freq_update = false;
444        this._volume = 1;
445        this.needs_volume_update = false;
446
447        this.is_on = false;
448
449        // Lists of functions
450        this.on_play_ends = [];
451        this.on_play_letters = [];
452
453        this.playing = []; // [["on"|"off", "dit"|"dah"|"symb"|"let"|"word", idx]]
454        this.is_playing = false;
455        this.next_at = Date.now();
456
457        let self = this;
458        setInterval(() => self.tick(), 10);
459
460        this.tune_enabled = false;
461        this.tune_index = 0;
462    }
463
464    init_user() {
465        if (this.audio_ctx !== undefined)
466            return;
467
468        this.audio_ctx = new AudioContext();
469
470        this.base_osc = this.audio_ctx.createOscillator();
471        this.base_osc.frequency.setValueAtTime(512, this.audio_ctx.currentTime);
472
473        this.gain = this.audio_ctx.createGain();
474        this.gain.gain.setValueAtTime(0, this.audio_ctx.currentTime);
475
476        this.base_osc.connect(this.gain);
477        this.gain.connect(this.audio_ctx.destination);
478        this.base_osc.start();
479
480        console.info("Audio initialized");
481
482        if (this.is_on) {
483            this.is_on = false;
484            this.on();
485        }
486    }
487
488    set_freq(freq_hz) {
489        if (freq_hz === this._freq)
490            return;
491        this._freq = freq_hz;
492        this.needs_freq_update = true;
493    }
494
495    set_volume(volume) {
496        this._volume = volume / 250;
497        if (this.is_on)
498            this.on();
499    }
500
501    set_light_enabled(status) {
502        if (status) {
503            this.lamp_enabled = true;
504        } else {
505            this.lamp_enabled = false;
506            if (this.lamp !== undefined)
507                this.lamp.classList.remove("light");
508        }
509    }
510
511    _freq_multiplier() {
512        if (this.tune_enabled) {
513            return Math.pow(2, TUNE[this.tune_index] / 12);
514        } else {
515            return 1;
516        }
517    }
518
519    tick() {
520        if (this.needs_freq_update && this.audio_ctx !== undefined) {
521            this.base_osc.frequency.setValueAtTime(this._freq * this._freq_multiplier(), this.audio_ctx.currentTime);
522            this.needs_freq_update = false;
523        }
524        if (this.next_at == null || Date.now() > this.next_at) {
525            if (this.playing.length > 0) {
526                let next = this.playing[0];
527                this.playing = this.playing.slice(1);
528
529                if (next[0] == "on")
530                    this.on();
531                else
532                    this.off();
533
534                let duration = null;
535                if (next[1] == "dit")
536                    duration = this.morse.DIT_DURATION;
537                else if (next[1] == "dah")
538                    duration = this.morse.DAH_DURATION;
539                else if (next[1] == "symb")
540                    duration = this.morse.SYMBOL_SEP;
541                else if (next[1] == "let")
542                    duration = this.morse.LETTER_SEP;
543                else if (next[1] == "word")
544                    duration = this.morse.WORD_SEP;
545
546                if (duration == null) {
547                    console.log(`Unknown duration for ${next}`);
548                    return;
549                }
550                this.next_at = Date.now() + duration * 1000;
551                for (let on_play_letter of this.on_play_letters)
552                    on_play_letter(next[2]);
553            } else {
554                if (this.next_at != null) {
555                    this.next_at = null;
556                    this.off();
557
558                    this.is_playing = false;
559                    for (let on_play_end of this.on_play_ends)
560                        on_play_end();
561                }
562            }
563        }
564    }
565
566    play(text) {
567        this.playing = [];
568        let last_letter = false;
569        for (var i = 0; i < text.length; i++) {
570            let char_here = text[i];
571
572            if (char_here == " " || char_here == "\n") {
573                if (last_letter)
574                    this.playing = this.playing.slice(0, this.playing.length-1); // remove last symbol separator
575                this.playing.push(["off", "word", i]);
576                last_letter = false;
577            } else {
578                let code = null;
579                for (let m of MORSE) {
580                    if (m.length == 0)
581                        continue;
582                    if (m[0] == char_here)
583                        code = m[1];
584                }
585                if (code == null) {
586                    console.warn(`unknown char ${char_here}`);
587                    continue;
588                }
589                for (let ch of code) {
590                    if (ch == DIT) {
591                        this.playing.push(["on", "dit", i]);
592                        this.playing.push(["off", "symb", i]);
593                    } else {
594                        this.playing.push(["on", "dah", i]);
595                        this.playing.push(["off", "symb", i]);
596                    }
597                }
598                // Remove last letter symbol separator
599                this.playing = this.playing.slice(0, this.playing.length-1);
600                this.playing.push(["off", "let", i]);
601                last_letter = true;
602            }
603            this.is_playing = true;
604        }
605    }
606
607    stop() {
608        this.next_at = null;
609        this.playing = [];
610        this.off();
611
612        this.is_playing = false;
613        for (let on_play_end of this.on_play_ends)
614            on_play_end();
615    }
616
617    on() {
618        if (this.is_on)
619            return;
620        if (this.audio_ctx === undefined)
621            return;
622        this.gain.gain.setValueAtTime(0, this.audio_ctx.currentTime+HOLD_TIME);
623        this.gain.gain.linearRampToValueAtTime(this._volume, this.audio_ctx.currentTime+HOLD_TIME+RAMP_TIME);
624        if (this.lamp !== undefined && this.lamp_enabled)
625            this.lamp.classList.add("light");
626
627        this.is_on = true;
628
629        if (this.tune_enabled) {
630            this.tune_index += 1;
631            this.tune_index %= TUNE.length;
632            this.needs_freq_update = true;
633        }
634    }
635
636    off() {
637        if (!this.is_on)
638            return;
639        if (this.audio_ctx === undefined)
640            return;
641        this.gain.gain.setValueAtTime(this._volume, this.audio_ctx.currentTime+HOLD_TIME);
642        this.gain.gain.linearRampToValueAtTime(0, this.audio_ctx.currentTime+HOLD_TIME+RAMP_TIME);
643        if (this.lamp !== undefined)
644            this.lamp.classList.remove("light");
645
646        this.is_on = false;
647    }
648}
649
650function bind_volume_input(audio, volume_inp) {
651    if (localStorage.getItem("volume") != null) {
652        audio.set_volume(+localStorage.getItem("volume"));
653        volume_inp.value = localStorage.getItem("volume");
654    } else {
655        audio.set_volume(+volume_inp.value);
656    }
657
658    volume_inp.addEventListener("change", e => {
659        audio.set_volume(+volume_inp.value);
660        localStorage.setItem("volume", volume_inp.value);
661    });
662}
663
664function bind_enable_light(audio, enable_light_checkbox) {
665    if (localStorage.getItem("enable_light") != null) {
666        audio.set_light_enabled(+localStorage.getItem("enable_light"));
667        enable_light_checkbox.checked = localStorage.getItem("enable_light");
668    } else {
669        audio.set_light_enabled(0);
670    }
671
672    enable_light_checkbox.addEventListener("change", e => {
673        audio.set_light_enabled(+enable_light_checkbox.checked);
674        localStorage.setItem("enable_light", +enable_light_checkbox.checked);
675    });
676}
677
678function bind_frequency_input(audio, freq_inp) {
679    if (localStorage.getItem("freq") != null) {
680        audio.set_freq(+localStorage.getItem("freq"));
681        freq_inp.value = localStorage.getItem("freq");
682    } else {
683        audio.set_freq(+freq_inp.value);
684    }
685
686    freq_inp.addEventListener("change", e => {
687        audio.set_freq(+freq_inp.value);
688        localStorage.setItem("freq", freq_inp.value);
689    });
690}
691
692class SelectionHandler {
693    // play_start(text, start, end)
694    // on_play_end()
695    // on_play_letter(idx)
696    constructor(audio, text_field, play_button, stop_button, on_play_start, on_play_end, on_play_letter) {
697        this.audio = audio;
698
699        this.text_field = text_field;
700        this.play_button = play_button;
701        this.stop_button = stop_button;
702
703
704        this.on_play_start = on_play_start;
705        this.on_play_end = on_play_end;
706        this.on_play_letter = on_play_letter;
707
708        this.selection_offset = null;
709
710        let self = this;
711
712        audio.on_play_ends.push(() => self.on_play_end());
713        audio.on_play_letters.push((idx) => {
714            self.on_play_letter(idx + self.selection_offset)
715        });
716
717        this.play_button.addEventListener("click", () => self.play());
718        this.stop_button.addEventListener("click", () => self.stop());
719        setInterval(() => self.check(), 10);
720
721        this.last_selection = this.get_selection();
722    }
723
724    get_selection() {
725        let sel = window.getSelection();
726
727        let selected = true;
728        if ((sel.focusNode == null) || (sel.anchorNode == null)) {
729            selected = false;
730        } else if (!sel.focusNode.parentElement.isEqualNode(this.text_field)) {
731            selected = false;
732        } else if (!sel.anchorNode.parentElement.isEqualNode(this.text_field))
733            selected = false;
734        else if (sel.toString().length == 0) {
735            selected = false;
736        }
737
738        if (!selected)
739            return null;
740
741        let start = Math.min(sel.anchorOffset, sel.focusOffset);
742        let end = Math.max(sel.anchorOffset, sel.focusOffset);
743
744        return {"start": start, "end": end, "text": sel.toString()};
745    }
746
747    check() {
748        let selection = this.get_selection();
749        if (selection == null) {
750            this.play_button.classList.remove("active");
751            if (!this.play_button.contains(window.getSelection().focusNode)) {
752                this.last_selection = null;
753            }
754        } else {
755            this.play_button.classList.add("active");
756            this.last_selection = selection;
757        }
758    }
759
760    play() {
761        if (this.last_selection == null)
762            return;
763
764        this.selection_offset = this.last_selection["start"];
765
766        this.audio.play(this.last_selection["text"]);
767
768        this.on_play_start(this.last_selection["text"], this.last_selection["start"], this.last_selection["end"]);
769
770        this.last_selection = null;
771        this.stop_button.classList.add("active");
772    }
773
774    stop() {
775        this.audio.stop();
776        this.stop_button.classList.remove("active");
777    }
778}
779
780function hue2rgb(hue) {
781    let theta = 2 * Math.PI * hue;
782    let r = Math.cos(theta);
783    let g = Math.cos(theta + 2 * Math.PI / 3);
784    let b = Math.cos(theta + 2 * 2 * Math.PI / 3);
785    return [(r + 1) / 2, (g + 1) / 2, (b + 1) / 2];
786}
787
788class Fireworks {
789    constructor(canvas_element) {
790        this.canvas_element = canvas_element;
791        this.ctx = this.canvas_element.getContext("2d");
792
793        let self = this;
794
795        this.particles = [];
796        for (let i = 0; i < 5; i++) {
797            let x = Math.random() * this.canvas_element.offsetWidth;
798            let y = Math.random() * this.canvas_element.offsetHeight;
799
800            this.particles.push([x, y, (Math.random() - 0.5) * 50, -50, Math.random(), 2, 0.2 + 0.5 * Math.random()]);
801        }
802        this.gravity = 100;
803        this.dt = 0.01;
804        this.render_internal = setInterval(() => self.render(), this.dt * 1000);
805    }
806
807    render() {
808        this.canvas_element.width = this.canvas_element.offsetWidth;
809        this.canvas_element.height = this.canvas_element.offsetHeight;
810
811        this.ctx.clearRect(0, 0, this.canvas_element.width, this.canvas_element.height);
812
813        let new_particles = [];
814        for (let particle of this.particles) {
815            let x = particle[0];
816            let y = particle[1];
817            let vx = particle[2];
818            let vy = particle[3];
819            let hue = particle[4];
820            let count = particle[5];
821            let timeLeft = particle[6];
822
823            x += vx * this.dt;
824            y += vy * this.dt;
825            vy += this.gravity * this.dt;
826
827            timeLeft -= this.dt;
828
829            this.ctx.lineWidth = 2;
830
831            let [r, g, b] = hue2rgb(particle[4]);
832            this.ctx.beginPath();
833            this.ctx.strokeStyle = `rgb(${256*r}, ${256*g}, ${256*b})`;
834            this.ctx.lineWidth = 1.5 * count + 2;
835            this.ctx.moveTo(x, y);
836            this.ctx.lineTo(x + vx * 0.1 * (count + 1), y + vy * 0.1 * (count + 1));
837            this.ctx.stroke();
838
839            particle[0] = x;
840            particle[1] = y;
841            particle[2] = vx;
842            particle[3] = vy;
843            particle[4] = hue;
844            particle[5] = count;
845            particle[6] = timeLeft;
846            if (timeLeft > 0) {
847                if (x > 0 && x < this.canvas_element.width && y > 0 && y < this.canvas_element.height)
848                    new_particles.push(particle);
849            } else if (count > 0) {
850                for (let i = 0; i < 20;  i++) {
851                    let u = Math.random();
852                    let v = Math.random();
853
854                    let z1 = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
855                    let z2 = Math.sqrt(-2 * Math.log(u)) * Math.sin(2 * Math.PI * v);
856
857                    let child_hue = hue + Math.random() / 5;
858
859                    this.particles.push([x, y, z1 * 40, z2 * 40, child_hue, count - 1, Math.random()]);
860                }
861            }
862        }
863        this.particles = new_particles;
864
865        if (this.particles.length == 0) {
866            this.destroy();
867        }
868    }
869
870    destroy() {
871        this.canvas_element.remove();
872        clearInterval(this.render_internal);
873    }
874}
875
876function make_fireworks() {
877    let fireworks_element = document.createElement("canvas");
878    fireworks_element.classList.add("fireworks");
879
880    document.getElementById("main-split").append(fireworks_element);
881
882    return new Fireworks(fireworks_element);
883}
884
885// shamelessly stolen from https://stackoverflow.com/questions/6122571/simple-non-secure-hash-function-for-javascript#comment111181647_8831937
886function strhash(st) {
887    return Array.from(st).reduce((hash, char) => 0 | (31 * hash + char.charCodeAt(0)), 0);
888}
889
890const PRESETS = [
891    {"id": "quotes", "name": "Quotes",      "type": "quotes"},
892    {"id": "all",    "name": "All chars",   "type": "markov", "subset": "KMRSAUPTLOWI.NJEF0YV,G5Q9ZH38B427C1D6X?!"},
893    // {"id": "hamgen", "name": "Fake ham",    "type": "hamgen"},
894    null,
895    {"id": "k10",    "name":  "Koch 10%",   "type": "markov", "important": "KMRS", "subset": "KMRS"},
896    {"id": "k20",    "name":  "Koch 20%",   "type": "markov", "important": "AUPT", "subset": "KMRSAUPT"},
897    {"id": "k30",    "name":  "Koch 30%",   "type": "markov", "important": "LOWI", "subset": "KMRSAUPTLOWI"},
898    {"id": "k40",    "name":  "Koch 40%",   "type": "markov", "important": ".NJE", "subset": "KMRSAUPTLOWI.NJE"},
899    {"id": "k50",    "name":  "Koch 50%",   "type": "markov", "important": "F0YV", "subset": "KMRSAUPTLOWI.NJEF0YV"},
900    {"id": "k60",    "name":  "Koch 60%",   "type": "markov", "important": ",G5Q", "subset": "KMRSAUPTLOWI.NJEF0YV,G5Q"},
901    {"id": "k70",    "name":  "Koch 70%",   "type": "markov", "important": "9ZH3", "subset": "KMRSAUPTLOWI.NJEF0YV,G5Q9ZH3"},
902    {"id": "k80",    "name":  "Koch 80%",   "type": "markov", "important": "8B42", "subset": "KMRSAUPTLOWI.NJEF0YV,G5Q9ZH38B42"},
903    {"id": "k90",    "name":  "Koch 90%",   "type": "markov", "important": "7C1D", "subset": "KMRSAUPTLOWI.NJEF0YV,G5Q9ZH38B427C1D"},
904    {"id": "k100",   "name": "Koch 100%",   "type": "markov", "important": "6X?!", "subset": "KMRSAUPTLOWI.NJEF0YV,G5Q9ZH38B427C1D6X?!"},
905    null,
906    {"id": "vow",    "name": "Vowels",      "type": "markov", "important": "", "subset": "AOEUIY"},
907    {"id": "cons",   "name": "Consonants",  "type": "markov", "important": "", "subset": "PFGCRLDHTNSQJKXBMWVZ"},
908    {"id": "nums",   "name": "Numbers",     "type": "markov", "important": "", "subset": "0123456789"},
909    {"id": "punct",  "name": "Punctuation", "type": "markov", "important": "", "subset": ",.!?"},
910    null,
911    {"id": "a",      "name": "A's (·–)",    "type": "markov", "important": "", "subset": "AJLPRW"},
912    {"id": "i",      "name": "I's (··)",    "type": "markov", "important": "", "subset": "FHISUV"},
913    {"id": "n",      "name": "N's (–·)",    "type": "markov", "important": "", "subset": "BCDKNXY"},
914    {"id": "m",      "name": "M's (––)",    "type": "markov", "important": "", "subset": "MOGQZ"},
915];
916
917class TextGenerator {
918    async load_resources() { throw "Abstract method called" }
919
920    // for result screen. returns string
921    describe() { throw "Abstract method called" }
922
923    // for history
924    describe_short() { throw "Abstract method called" }
925
926    // reteurns bool
927    is_loaded() { throw "Abstract method called" }
928
929    // returns {"text": "...", "author": <optional>}
930    next_text() { throw "Abstract method called" }
931
932    // argument text is what next_text returned
933    // may return dict of attributes about the text to show after completion
934    // might not be called after every next_text
935    text_completed(sentence) { throw "Abstract method called" }
936
937    // Called every "action" with a div below the mode selector
938    // Element might contain previous content
939    // Call cb_save when user interacts with the state of the generator such that the parent sentence loader needs to save
940    render_sidebar(sidebar, cb_save) { throw "Abstract method called" }
941
942    // Called when preset is loaded or from localStorage
943    set_data(data) { throw "Abstract method called" }
944
945    // set_data(get_data()) should not change the state meaningfully
946    // should contain {"type": "..."}
947    get_data() { throw "Abstract method called" }
948}
949
950class HamGen {
951    async load_resources() { }
952    describe() { return "HamGen"; }
953    describe_short() { return "HamGen"; }
954    is_loaded() { return true; }
955    next_text() { return {"text": "73 DE SA6NYA"}; }
956    text_completed(sentence) { }
957    render_sidebar(sidebar, _cb_save) {
958        sidebar.innerText = "mjau";
959    }
960    set_data(data) {}
961    get_data(data) { return {"type": "hamgen"}; }
962}
963
964function normalized(arr) {
965    let total = 0;
966    for (let item of arr)
967        total += item;
968    return arr.map(x => x / total);
969}
970
971// Gives an index into the array
972function weighted_pick(arr, noise) {
973    if (Math.random() < noise) {
974        return 0 | (Math.random() * arr.length);
975    }
976    arr = normalized(arr);
977    let x = Math.random();
978    for (let i = 0; i < arr.length; i++) {
979        x -= arr[i];
980        if (x < 0)
981            return i;
982    }
983    console.log("OH NO");
984    return arr.length - 1;
985}
986
987const MINIMUM_MARKOV_LENGTH = 40;
988class MarkovGenerator extends TextGenerator {
989    constructor() {
990        super();
991        this.min_word_len = 2;
992        this.max_word_len = 6;
993        this.noise = 0.0;
994
995        this.symbols = [];
996        this.forward = [];
997        this.backward = [];
998        this.initial = [];
999
1000        // Invariant: must always contain space
1001        // Both contain numbers indexing into this.symbols
1002        this.subset = [];
1003        this.important = [];
1004
1005        this.loaded = false;
1006    }
1007
1008    describe() {
1009        let self = this;
1010        let letters = [...Array(this.subset.length).keys()].filter(l => !self._terminates(l));
1011        return "Koch " + this.symbolize(letters);
1012    }
1013
1014    describe_short() {
1015        let count_percent = (this.subset.length - 1) / 40; // -1 = remove space, 40 = length of full
1016        return "Koch " + (0 | count_percent * 100) + "%";
1017    }
1018
1019    get_subset(arr) {
1020        return this.subset.map(i => arr[i]);
1021    }
1022
1023    async load_resources() {
1024        let self = this;
1025
1026        await fetch("data/markov.json", {
1027            mode: "no-cors",
1028            headers: {
1029                "Content-Type": "application/json"
1030            },
1031        }).then(data =>
1032            data.json()
1033        ).then(data => {
1034            self.symbols = data["symbols"];
1035            self.forward = data["forward"];
1036            self.backward = data["backward"];
1037            self.initial = data["initial"];
1038
1039            self.subset = [self.symbols.indexOf(" ")];
1040            self.is_loaded = true;
1041        });
1042    }
1043
1044    is_loaded() { return this.is_loaded; }
1045
1046    toggle(letter, is_rightclick) {
1047        if (!this.symbols.includes(letter))
1048            return;
1049
1050        let idx = this.symbols.indexOf(letter);
1051        if (is_rightclick) {
1052            // ensure letter is active
1053            if (!this.subset.includes(idx)) {
1054                this.subset.push(idx);
1055            }
1056            // toggle important
1057            if (this.important.includes(idx)) {
1058                this.important = this.important.filter(x => x != idx);
1059            } else {
1060                this.important.push(idx);
1061            }
1062        } else {
1063            if (this.subset.includes(idx)) {
1064                this.subset = this.subset.filter(a => a != idx);
1065                this.important = this.important.filter(a => a != idx);
1066            } else {
1067                this.subset.push(idx);
1068            }
1069        }
1070    }
1071
1072    has_letter(l) {
1073        return this.get_subset(this.symbols).includes(l);
1074    }
1075
1076    is_on() {
1077        return this.subset.length > 1;
1078    }
1079
1080    _terminates(i) {
1081        return this.symbols[this.subset[i]] == " ";
1082    }
1083
1084    _generate_letter() {
1085        for (let i = 0; i < 10; i++) {
1086            let init = this.get_subset(this.initial);
1087            let letter = weighted_pick(init, this.noise);
1088            if (this._terminates(letter))
1089                continue;
1090            return letter;
1091        }
1092        return 0;
1093    }
1094
1095    _generate_word(containing_letter) {
1096        while (true) {
1097            let after = [];
1098            let last_ch = containing_letter;
1099            for (let i = 0; i < 10; i++) {
1100                let weights = this.get_subset(this.get_subset(this.forward)[last_ch]);
1101                let pick = weighted_pick(weights, this.noise);
1102                if (this._terminates(pick))
1103                    break;
1104
1105                after.push(pick);
1106                last_ch = pick;
1107            }
1108
1109            let before = [];
1110            last_ch = containing_letter;
1111            for (let i = 0; i < 10; i++) {
1112                let weights = this.get_subset(this.get_subset(this.backward)[last_ch]);
1113                let pick = weighted_pick(weights, this.noise);
1114                if (this._terminates(pick))
1115                    break;
1116
1117                before.push(pick);
1118                last_ch = pick;
1119            }
1120            let pick = before.reverse().concat([containing_letter]).concat(after);
1121            if (pick.length >= this.min_word_len && pick.length <= this.max_word_len) {
1122                return pick;
1123            }
1124        }
1125    }
1126
1127    _generate_text() {
1128        let self = this;
1129
1130        let text = [];
1131
1132        let letters_left_count = [];
1133        for (let i = 0; i < this.subset.length; i++) {
1134            let count = 0;
1135
1136            if (this.important.includes(this.subset[i]))
1137                count = 2;
1138
1139            letters_left_count.push(count);
1140        }
1141
1142        while (letters_left_count.reduce((acc, x) => acc || (x > 0), 0)) {
1143            let letter = weighted_pick(letters_left_count);
1144            let w = this._generate_word(letter);
1145
1146            for (let letter of w)
1147                letters_left_count[letter] = Math.max(letters_left_count[letter] - 1, 0);
1148
1149            text.push(...w);
1150            text.push(this.get_subset(this.symbols).indexOf(" "));
1151        }
1152
1153        while (text.length < MINIMUM_MARKOV_LENGTH + 1) {
1154            let idx = 0 | (Math.random() * this.subset.length);
1155            let w = this._generate_word(idx);
1156
1157            text.push(...w);
1158            text.push(this.get_subset(this.symbols).indexOf(" "));
1159        }
1160
1161        return text.slice(0, text.length - 1);
1162    }
1163
1164    symbolize(seq) {
1165        let self = this;
1166        return seq.map(a => this.symbols[this.subset[a]]).join("");
1167    }
1168
1169    matches(subset) {
1170        let subset_sorted = new Array(...subset).sort();
1171        let me = this.get_subset(this.symbols).filter(x => x != " ").slice().sort();
1172        return subset_sorted.join("") == me.join("");
1173    }
1174
1175    next_text() {
1176        return {"text": this.symbolize(this._generate_text())};
1177    }
1178
1179    set_data(data) {
1180        this.subset = new Array(...data["subset"]).map(x => this.symbols.indexOf(x));
1181        this.subset.push(this.symbols.indexOf(" "));
1182        if (data.hasOwnProperty("important"))
1183            this.important = new Array(...data["important"]).map(x => this.symbols.indexOf(x));
1184    }
1185
1186    get_data() {
1187        let subset = new Array(...this.subset).map(x => this.symbols[x]).filter(x => x != " ").join("");
1188        let important = this.symbolize(this.important.map(x => this.subset.indexOf(x)));
1189        return {"type": "markov", "subset": subset, "important": important};
1190    }
1191
1192    text_completed(text_obj) {}
1193
1194    render_sidebar(sidebar, cb_save) {
1195        let table = document.createElement("div");
1196        table.id = "morse-table";
1197
1198        let col_left = document.createElement("div");
1199        col_left.id = "col-left";
1200        col_left.classList.add("morse-col");
1201
1202        let col_mid = document.createElement("div");
1203        col_mid.id = "col-mid";
1204
1205        let col_right = document.createElement("div");
1206        col_right.id = "col-right";
1207        col_right.classList.add("morse-col");
1208
1209        table.replaceChildren(col_left, col_mid, col_right);
1210        sidebar.replaceChildren(table);
1211
1212        let self = this;
1213        fill_morse_table(table, (el, morse_data) => {
1214            let [letter, _] = morse_data;
1215            let idx = this.symbols.indexOf(letter);
1216
1217            if (self.subset.indexOf(idx) === -1) {
1218                el.classList.add("morse-inactive");
1219            } else if (self.important.indexOf(idx) !== -1) {
1220                el.classList.add("morse-important");
1221            }
1222
1223            el.addEventListener("click", _ => {
1224                self.toggle(letter, false);
1225                self.render_sidebar(sidebar, cb_save);
1226                cb_save();
1227            });
1228            el.addEventListener("contextmenu", e => {
1229                e.preventDefault();
1230                self.toggle(letter, true);
1231                self.render_sidebar(sidebar, cb_save);
1232                cb_save();
1233            });
1234        });
1235    }
1236}
1237
1238
1239class QuoteLoader extends TextGenerator {
1240    constructor() {
1241        super();
1242        this.quotes = [];
1243    }
1244
1245    describe() {
1246        return "Quote";
1247    }
1248
1249    describe_short() {
1250        return "Quote";
1251    }
1252
1253    _set_completed(completed_hashes) {
1254        localStorage.setItem("completed-quotes", JSON.stringify(completed_hashes));
1255    }
1256
1257    _get_completed() {
1258        try {
1259            let completed = JSON.parse(localStorage.getItem("completed-quotes"));
1260            if (completed === null) {
1261                this._set_completed([]);
1262                return [];
1263            }
1264            return completed;
1265        } catch (SyntaxError) {
1266            this._set_completed([]);
1267            return [];
1268        }
1269    }
1270
1271    get_available_quotes() {
1272        let available_quotes = [];
1273
1274        let hashes = this._get_completed();
1275        for (let quote of this.quotes) {
1276            let hash = strhash(quote["quote"]);
1277            if (hashes.indexOf(hash) === -1) {
1278                available_quotes.push(quote);
1279            }
1280        }
1281
1282        return available_quotes;
1283    }
1284
1285    async load_resources() {
1286        let self = this;
1287
1288        await fetch("data/quotes.json", {
1289            mode: "no-cors",
1290            headers: {
1291                "Content-Type": "application/json"
1292            },
1293        }).then(data =>
1294            data.json()
1295        ).then(data => {
1296            self.quotes = data;
1297        });
1298    }
1299
1300    next_text() {
1301        let available = this.get_available_quotes();
1302        if (available.length == 0) {
1303            // TODO: Alert user!
1304            this._set_completed([]);
1305            available = this.quotes;
1306        }
1307
1308        let quote = available[0|Math.random()*available.length];
1309        return {"text": quote["quote"], "author": quote["author"]};
1310    }
1311
1312    text_completed(text_obj) {
1313        let hashes = this._get_completed();
1314        hashes.push(strhash(text_obj["text"]));
1315        this._set_completed(hashes);
1316    }
1317
1318    render_sidebar(sidebar, _cb_save) {
1319        let span = document.createElement("span");
1320        let n_quotes = this.quotes.length;
1321        let n_completed = this._get_completed().length;
1322        span.innerText = `Completed ${n_completed}/${n_quotes} quotes`;
1323
1324        let real = document.createElement("span");
1325        real.innerText = "[None of the quotes presented here are real]";
1326
1327        let table = document.createElement("div");
1328        table.id = "morse-table";
1329
1330        let col_left = document.createElement("div");
1331        col_left.id = "col-left";
1332        col_left.classList.add("morse-col");
1333
1334        let col_mid = document.createElement("div");
1335        col_mid.id = "col-mid";
1336
1337        let col_right = document.createElement("div");
1338        col_right.id = "col-right";
1339        col_right.classList.add("morse-col");
1340
1341        table.replaceChildren(col_left, col_mid, col_right);
1342
1343        fill_morse_table(table, (el, morse_data) => {});
1344
1345        sidebar.replaceChildren(span, table, document.createElement("br"), real);
1346    }
1347
1348    set_data(data) {}
1349
1350    get_data() { return { "type": "quotes" } }
1351}
1352
1353class SentenceLoader {
1354    constructor(sentence_config_el) {
1355        this.sentence_config_el = sentence_config_el;
1356
1357        // Are set in create_sidebar
1358        this.preset_selector_el = null;
1359        this.generator_sidebar = null;
1360
1361        this.current_generator = null;
1362
1363        this.sentence_up_to_date = true;
1364    }
1365
1366    current_data() {
1367        if (this.current_generator === null) {
1368            let data = localStorage.getItem("sentence-loader-data");
1369            if (data !== null) {
1370                data = JSON.parse(data);
1371            } else {
1372                data = PRESETS[0];
1373            }
1374            return data;
1375        } else {
1376            return this.current_generator.get_data();
1377        }
1378    }
1379
1380    create_sidebar() {
1381        this.preset_selector_el = document.createElement("select");
1382
1383        let children = [];
1384
1385        let found_match = false;
1386
1387        this.assert_generator();
1388        let current_data = this.current_generator.get_data();
1389        for (let preset of PRESETS) {
1390            if (preset == null) {
1391                let brk = document.createElement("option");
1392                brk.disabled = true;
1393                brk.innerText = "—";
1394                children.push(brk);
1395                continue;
1396            }
1397
1398            let option = document.createElement("option");
1399            option.value = preset["id"];
1400            option.innerText = preset["name"];
1401
1402            let matches = true;
1403            for (let property of Object.getOwnPropertyNames(current_data)) {
1404                if (JSON.stringify(current_data[property]) !== JSON.stringify(preset[property])) {
1405                    matches = false;
1406                }
1407            }
1408
1409            if (matches) {
1410                found_match = true;
1411                option.selected = true;
1412            }
1413
1414            children.push(option);
1415        }
1416        if (!found_match) {
1417            let brk = document.createElement("option");
1418            brk.disabled = true;
1419            brk.innerText = "Custom";
1420            brk.selected = true;
1421            children.push(brk);
1422        }
1423        this.preset_selector_el.replaceChildren(...children);
1424
1425        let self = this;
1426        this.preset_selector_el.addEventListener("change", e => {
1427            let chosen = e.target.value;
1428            for (let preset of PRESETS) {
1429                if (preset == null)
1430                    continue;
1431
1432                if (preset["id"] == chosen) {
1433                    self.set_data(preset);
1434                    return;
1435                }
1436            }
1437            console.log("unknown value??");
1438            console.log(chosen);
1439        });
1440
1441        this.generator_sidebar = document.createElement("div");
1442
1443        this.sentence_config_el.replaceChildren(this.preset_selector_el, this.generator_sidebar);
1444    }
1445
1446    async load_from_localstorage() {
1447        let data = PRESETS[0];
1448
1449        let blob = localStorage.getItem("sentence-loader-data");
1450        if (blob !== null) {
1451            data = JSON.parse(blob);
1452        }
1453
1454        await this.set_data(data).then(() => {
1455            this.create_sidebar();
1456            this.redraw();
1457        });
1458    }
1459
1460    async set_data(data) {
1461        if (data["type"] === "quotes") {
1462            this.current_generator = new QuoteLoader();
1463        } else if (data["type"] === "markov") {
1464            this.current_generator = new MarkovGenerator();
1465        } else if (data["type"] === "hamgen") {
1466            this.current_generator = new HamGen();
1467        } else {
1468            console.log("No such type " + data["type"]);
1469            return;
1470        }
1471
1472        this.sentence_up_to_date = false;
1473
1474        localStorage.setItem("sentence-loader-data", JSON.stringify(data));
1475
1476        await this.current_generator.load_resources();
1477        this.current_generator.set_data(data);
1478
1479        if (this.generator_sidebar !== null)
1480            this.redraw();
1481    }
1482
1483    assert_generator() {
1484        if (this.current_generator == null) {
1485            throw "SentenceLoader function called before initialization. Please call this.load(state)/this.load_from_localstorage()";
1486        }
1487    }
1488
1489    redraw() {
1490        let self = this;
1491        function cb_save() {
1492            let data = self.current_generator.get_data();
1493            localStorage.setItem("sentence-loader-data", JSON.stringify(data));
1494
1495            self.sentence_up_to_date = false;
1496            self.redraw();
1497        }
1498
1499        this.assert_generator();
1500        this.current_generator.render_sidebar(this.generator_sidebar, cb_save);
1501
1502        if (!this.sentence_up_to_date) {
1503            this.generator_sidebar.appendChild(document.createElement("br"));
1504
1505            let note = document.createElement("span");
1506            note.classList.add("warning");
1507            note.innerText = "Current sentence is from previous mode.\nGenerate a new sentence with <Tab>."
1508            this.generator_sidebar.appendChild(note);
1509        }
1510    }
1511
1512    completed(text) {
1513        this.current_generator.text_completed(text);
1514        this.redraw();
1515    }
1516
1517    next_text() {
1518        this.sentence_up_to_date = true;
1519        this.redraw();
1520        return this.current_generator.next_text();
1521    }
1522}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.