351af76Interactive tutorial on the physics and the mathematics of the simulatorJeremy Magland 1// The tutorial panel: a column beside the main view that shows one step of
2// steps.ts at a time, with a table of contents, Back and Next, the step's
3// text with its mathematics typeset by KaTeX, and the step's actions as
4// buttons that drive the application through the TutorialHost. The host
5// calls refresh() whenever its state changes, so that the numbers in the
6// text follow the design in the editor.
8import renderMathInElement from "katex/contrib/auto-render";
9import "katex/dist/katex.min.css";
10import { STEPS, type TutorialHost, type TutorialStep, chapters, stepIndex } from "./steps.ts";
12const STORAGE_KEY = "cymbalsim.tutorial";
14export class Tutorial {
15 private readonly root: HTMLElement;
16 private readonly host: TutorialHost;
17 private readonly onToggle: (open: boolean) => void;
18 private readonly select = document.createElement("select");
19 private readonly counter = document.createElement("span");
20 private readonly body = document.createElement("div");
21 private readonly backBtn = document.createElement("button");
22 private readonly nextBtn = document.createElement("button");
23 private readonly error = document.createElement("div");
24 private index = 0;
25 private busy = false;
27 constructor(root: HTMLElement, host: TutorialHost, onToggle: (open: boolean) => void) {
28 this.root = root;
29 this.host = host;
30 this.onToggle = onToggle;
31 root.classList.add("tutorial");
33 const head = document.createElement("div");
34 head.className = "tut-head";
35 const title = document.createElement("span");
36 title.className = "tut-title";
37 title.textContent = "Tutorial";
38 for (const ch of chapters()) {
39 const group = document.createElement("optgroup");
40 group.label = ch.title;
41 for (const i of ch.steps) {
42 const o = document.createElement("option");
43 o.value = String(i);
44 o.textContent = STEPS[i].title;
45 group.append(o);
46 }
47 this.select.append(group);
48 }
49 this.select.addEventListener("change", () => this.goto(Number(this.select.value)));
50 const close = document.createElement("button");
51 close.textContent = "×";
52 close.title = "Close the tutorial";
53 close.className = "tut-close";
54 close.addEventListener("click", () => this.close());
55 head.append(title, this.select, close);
57 this.body.className = "tut-body";
58 this.error.className = "tut-error";
59 this.error.hidden = true;
61 const foot = document.createElement("div");
62 foot.className = "tut-foot";
63 this.backBtn.textContent = "Back";
64 this.backBtn.addEventListener("click", () => this.goto(this.index - 1));
65 this.nextBtn.textContent = "Next";
66 this.nextBtn.className = "primary";
67 this.nextBtn.addEventListener("click", () => this.goto(this.index + 1));
68 this.counter.className = "tut-counter";
69 foot.append(this.backBtn, this.counter, this.nextBtn);
71 root.append(head, this.body, this.error, foot);
73 const saved = this.load();
74 if (saved) this.index = Math.max(0, Math.min(STEPS.length - 1, saved.index));
75 }
77 get isOpen(): boolean {
78 return !this.root.hidden;
79 }
81 /** Opens the panel, at a step given by id or index when one is passed. */
82 open(step?: string | number): void {
83 if (typeof step === "string") { const i = stepIndex(step); if (i >= 0) this.index = i; }
84 else if (typeof step === "number" && step >= 0 && step < STEPS.length) this.index = step;
85 this.root.hidden = false;
86 this.render(true);
87 this.save();
88 this.onToggle(true);
89 }
91 close(): void {
92 this.root.hidden = true;
93 this.save();
94 this.onToggle(false);
95 }
97 toggle(): void {
98 if (this.isOpen) this.close(); else this.open();
99 }
101 goto(i: number): void {
102 if (i < 0 || i >= STEPS.length || this.busy) return;
103 this.index = i;
104 this.render(true);
105 this.save();
106 }
108 get step(): TutorialStep {
109 return STEPS[this.index];
110 }
112 /** Re-renders the current step's text after a change of the application's state (keeps the scroll position). */
113 refresh(): void {
114 if (this.isOpen) this.render(false);
115 }
117 private render(top: boolean): void {
118 const step = STEPS[this.index];
119 const scroll = this.body.scrollTop;
120 this.select.value = String(this.index);
121 this.counter.textContent = `${this.index + 1} / ${STEPS.length}`;
122 this.backBtn.disabled = this.index === 0 || this.busy;
123 this.nextBtn.disabled = this.index === STEPS.length - 1 || this.busy;
124 const ctx = this.host.ctx();
125 let html: string;
126 try {
127 html = step.body(ctx);
128 } catch (e) {
129 html = `<p class="tut-error">This page could not be rendered: ${e instanceof Error ? e.message : String(e)}</p>`;
130 }
131 this.body.innerHTML = `<div class="tut-chapter">${step.chapter}</div><h2>${step.title}</h2>${html}`;
132 if (step.actions && step.actions.length) {
133 const box = document.createElement("div");
134 box.className = "tut-actions";
135 const label = document.createElement("div");
136 label.className = "tut-actions-label";
137 label.textContent = this.busy ? "Working..." : "Try it";
138 box.append(label);
139 for (const a of step.actions) {
140 const b = document.createElement("button");
141 b.textContent = a.label;
142 b.disabled = this.busy || (!!a.needsGpu && !ctx.gpu);
143 if (a.needsGpu && !ctx.gpu) b.title = "Needs WebGPU";
144 b.addEventListener("click", () => void this.runAction(a.run));
145 box.append(b);
146 }
147 this.body.append(box);
148 }
149 try {
150 renderMathInElement(this.body, {
151 delimiters: [
152 { left: "$$", right: "$$", display: true },
153 { left: "$", right: "$", display: false },
154 ],
155 throwOnError: false,
156 });
157 } catch (e) {
158 console.warn("math rendering failed", e);
159 }
160 this.body.scrollTop = top ? 0 : scroll;
161 }
163 private async runAction(run: (host: TutorialHost) => Promise<void> | void): Promise<void> {
164 if (this.busy) return;
165 this.busy = true;
166 this.error.hidden = true;
167 this.render(false);
168 try {
169 await run(this.host);
170 } catch (e) {
171 this.error.textContent = e instanceof Error ? e.message : String(e);
172 this.error.hidden = false;
173 } finally {
174 this.busy = false;
175 this.render(false);
176 }
177 }
179 private save(): void {
180 try {
181 localStorage.setItem(STORAGE_KEY, JSON.stringify({ index: this.index, open: this.isOpen }));
182 } catch {
183 // Storage may be unavailable; the tutorial works without it.
184 }
185 }
187 private load(): { index: number; open: boolean } | null {
188 try {
189 const s = localStorage.getItem(STORAGE_KEY);
190 return s ? (JSON.parse(s) as { index: number; open: boolean }) : null;
191 } catch {
192 return null;
193 }
194 }
196 /** Whether the panel was open when the page was last left. */
197 wasOpen(): boolean {
198 return this.load()?.open ?? false;
199 }
200}