diff options
| author | LalleSX | 2026-10-24 04:35:56 +0200 |
|---|---|---|
| committer | LalleSX | 2026-10-24 04:35:56 +0200 |
| commit | c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5 (patch) | |
| tree | 8fcbbfffb6a34af73697a703f77e6b82f191b91f /src/main.ts | |
| download | 3D-Electrophysics-c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5.tar.gz 3D-Electrophysics-c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5.zip | |
init
Diffstat (limited to '')
| -rw-r--r-- | src/main.ts | 137 |
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(); +}); |