aboutsummaryrefslogtreecommitdiff
path: root/src/main.ts
blob: 172f316a67ff8e1e77d4ae3d789940baccdf37af (plain) (blame)
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
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();
});