aboutsummaryrefslogtreecommitdiff
path: root/src/main.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.ts')
-rw-r--r--src/main.ts137
1 files changed, 137 insertions, 0 deletions
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..172f316
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,137 @@
+import './style.css';
+import { defaults, experiments, format, type Experiment, type Parameters } from './experiments';
+import { createLab } from './scene/lab';
+
+const app = document.querySelector<HTMLDivElement>('#app')!;
+app.innerHTML = `
+ <header class="app-header">
+ <a class="brand" href="#field" aria-label="Field Lab home">E<span>/</span>LAB</a>
+ <div class="header-title"><h1>Electrotechnique & Electrophysics</h1><p>Explore the invisible. Understand the physical.</p></div>
+ <span class="status"><i></i> INTERACTIVE LAB</span>
+ </header>
+ <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Experiments">${experiments.map((e, index) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(index + 1).padStart(2, '0')}</span>${e.shortTitle}</button>`).join('')}</div></nav>
+ <main id="lesson" role="tabpanel" aria-labelledby="tab-field">
+ <aside class="controls-panel">
+ <p class="eyebrow" id="lesson-number"></p>
+ <h2 id="lesson-title"></h2><p class="description" id="description"></p>
+ <form id="parameters" aria-label="Experiment parameters" onsubmit="return false"></form>
+ <div class="panel-actions"><button type="button" id="reset">Reset experiment</button></div>
+ <section class="measurements" id="measurements" aria-label="Live measurements" aria-live="polite" aria-atomic="true"></section>
+ </aside>
+ <div class="workbench">
+ <section id="viewport" aria-label="3D workbench">
+ <div class="scene-toolbar"><span class="view-tag">3D VIEW</span><div><button type="button" id="camera-reset">Reset camera</button><button type="button" id="pause" aria-pressed="false">Pause animation</button></div></div>
+ <div class="legend" id="legend"></div>
+ <div class="scene-footer"><p id="scene-note"></p><p class="hint">Drag to orbit · Scroll to zoom · Right-drag to pan · Arrow keys to pan when focused</p></div>
+ </section>
+ <section class="learning-panel" aria-label="Learn the physics">
+ <div><p class="eyebrow">THE PHYSICS</p><div id="equations" class="equations"></div><p id="explanation"></p><a id="source" target="_blank" rel="noopener noreferrer"></a></div>
+ <div class="challenge"><p class="eyebrow">TRY THIS</p><p id="challenge"></p><details><summary>Model & visual scales</summary><p id="limits"></p></details></div>
+ </section>
+ </div>
+ </main>`;
+
+const lab = createLab(document.querySelector('#viewport')!);
+const settings = new Map(experiments.map(experiment => [experiment.id, defaults(experiment)]));
+const byId = (id: string) => document.getElementById(id)!;
+let active = experiments[0];
+let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+function updatePause() {
+ byId('pause').textContent = paused ? 'Resume animation' : 'Pause animation';
+ byId('pause').setAttribute('aria-pressed', String(paused));
+ lab.setPaused(paused);
+}
+updatePause();
+
+function parameters(): Parameters { return settings.get(active.id)!; }
+function update() {
+ const p = parameters();
+ for (const control of active.controls) {
+ if (control.kind !== 'range') continue;
+ byId(`output-${control.id}`).textContent = `${format(Number(p[control.id]), 3)}${control.unit ? ` ${control.unit}` : ''}`;
+ }
+ if (active.id === 'semiconductor') {
+ (byId('parameter-concentration') as HTMLInputElement).disabled = p.doping === 'intrinsic';
+ }
+ byId('measurements').innerHTML = active.measure(p).map(m => `<div class="measurement"><span>${m.label}</span><div><strong aria-label="${m.label}">${m.value}</strong><small>${m.unit}</small></div></div>`).join('');
+ lab.update(active.id, p);
+}
+function show(experiment: Experiment) {
+ active = experiment;
+ const index = experiments.indexOf(experiment);
+ byId('lesson-number').textContent = `EXPERIMENT ${String(index + 1).padStart(2, '0')} / 08`;
+ byId('lesson-title').textContent = experiment.title;
+ byId('description').textContent = experiment.description;
+ byId('scene-note').textContent = experiment.sceneNote;
+ byId('explanation').textContent = experiment.explanation;
+ byId('challenge').textContent = experiment.challenge;
+ byId('limits').textContent = experiment.limits;
+ byId('equations').replaceChildren(...experiment.equations.map(equation => {
+ const div = document.createElement('p');
+ div.textContent = equation;
+ return div;
+ }));
+ const source = byId('source') as HTMLAnchorElement;
+ source.textContent = `${experiment.source.title} ↗`;
+ source.href = experiment.source.url;
+ byId('legend').innerHTML = experiment.legend.map(([colour, text]) => `<span><i style="background:${colour}"></i>${text}</span>`).join('');
+ byId('lesson').setAttribute('aria-labelledby', `tab-${experiment.id}`);
+ document.querySelectorAll<HTMLButtonElement>('[role="tab"]').forEach(tab => {
+ const selected = tab.dataset.experiment === experiment.id;
+ tab.setAttribute('aria-selected', String(selected));
+ tab.tabIndex = selected ? 0 : -1;
+ });
+ const p = parameters();
+ byId('parameters').innerHTML = experiment.controls.map(control => {
+ const id = `parameter-${control.id}`;
+ if (control.kind === 'select') return `<div class="parameter"><label for="${id}">${control.label}</label><select id="${id}" name="${control.id}">${control.options.map(option => `<option value="${option.value}" ${option.value === p[control.id] ? 'selected' : ''}>${option.label}</option>`).join('')}</select></div>`;
+ return `<div class="parameter"><div class="parameter-label"><label for="${id}">${control.label}</label><output for="${id}" id="output-${control.id}"></output></div><input id="${id}" name="${control.id}" type="range" min="${control.min}" max="${control.max}" step="${control.step}" value="${p[control.id]}" /></div>`;
+ }).join('');
+ lab.resetCamera();
+ update();
+}
+function fromHash() {
+ const id = window.location.hash.slice(1);
+ show(experiments.find(experiment => experiment.id === id) ?? experiments[0]);
+}
+const onHashChange = () => fromHash();
+window.addEventListener('hashchange', onHashChange);
+fromHash();
+byId('parameters').addEventListener('input', event => {
+ const input = event.target as HTMLInputElement | HTMLSelectElement;
+ if (!input.name) return;
+ parameters()[input.name] = input.type === 'range' ? Number(input.value) : input.value;
+ update();
+});
+byId('reset').addEventListener('click', () => {
+ settings.set(active.id, defaults(active));
+ show(active);
+});
+byId('camera-reset').addEventListener('click', () => lab.resetCamera());
+byId('pause').addEventListener('click', () => {
+ paused = !paused;
+ updatePause();
+});
+const tabs = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
+tabs.forEach((tab, index) => {
+ tab.addEventListener('click', () => {
+ const id = tab.dataset.experiment!;
+ if (window.location.hash === `#${id}`) show(experiments[index]);
+ else window.location.hash = id;
+ });
+ tab.addEventListener('keydown', event => {
+ let next: number;
+ if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
+ else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
+ else if (event.key === 'Home') next = 0;
+ else if (event.key === 'End') next = tabs.length - 1;
+ else return;
+ event.preventDefault();
+ tabs[next].focus();
+ tabs[next].click();
+ });
+});
+if (import.meta.hot) import.meta.hot.dispose(() => {
+ window.removeEventListener('hashchange', onHashChange);
+ lab.dispose();
+});