aboutsummaryrefslogtreecommitdiff
path: root/src/main.ts
diff options
context:
space:
mode:
authorvictor2026-10-24 15:57:01 +0200
committervictor2026-10-24 15:57:01 +0200
commit4533910bce394e7a0ee6debfc05ca1221ee51899 (patch)
tree73aaa5b2c7a48dbf226e6b81d499ba1a8ecc33a1 /src/main.ts
parentTilføj dansk (diff)
download3D-Electrophysics-4533910bce394e7a0ee6debfc05ca1221ee51899.tar.gz
3D-Electrophysics-4533910bce394e7a0ee6debfc05ca1221ee51899.zip
Astra!ny-start
Diffstat (limited to 'src/main.ts')
-rw-r--r--src/main.ts308
1 files changed, 27 insertions, 281 deletions
diff --git a/src/main.ts b/src/main.ts
index e419e46..8d8c5d5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,288 +1,34 @@
import './style.css';
-import { defaults, experiments, formatDisplay, type Experiment, type Parameters } from './experiments';
-import { getLanguage, languageOptions, setLanguage, t, type Chrome, type Language } from './i18n';
-import { presentLesson } from './i18n/present';
-import { createLab } from './scene/lab';
-import { answerUrl, getTestLesson, testLessons } from './test4';
-import { renderEquation, renderNotation, renderProse } from './math';
+import 'katex/dist/katex.min.css';
+import { createI18n } from './i18n';
+import { models } from './models/registry';
+import { icon } from './ui/icons';
+
+const i18n = createI18n();
+const { t } = i18n;
+document.documentElement.lang = i18n.locale;
+document.title = t('app.title');
const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
- <header class="app-header">
- <a class="brand" href="#test-work" aria-label="Field Lab home">E<span>/</span>LAB</a>
- <div class="header-title"><h1 id="app-title">Electrotechnique & Electrophysics</h1><p>Explore the invisible. Understand the physical.</p></div>
- <nav class="collections" aria-label="Learning collection"><a href="#test-work" id="test-collection">Test 4 in 3D</a><a href="#field" id="lab-collection">All experiments</a><label class="language-picker"><span id="language-label">Language</span><select id="language" aria-label="Language">${languageOptions.map(option => `<option value="${option.id}">${option.label}</option>`).join('')}</select></label></nav>
+ <a class="skip-link" href="#experiment">${t('app.skip')}</a>
+ <header class="site-header">
+ <a class="brand" href="#experiment">${icon('orbit')}<span>${t('app.brand')}</span><span class="brand-divider"></span><small>${t('app.tagline')}</small></a>
+ <nav aria-label="${t('app.navigation')}">
+ <a class="nav-link" href="#experiment">${t('app.experiment')}</a>
+ <a class="nav-link" href="#physics">${t('app.principle')}</a>
+ <span class="model-number">${t('app.module', { number: i18n.number(1, { minimumIntegerDigits: 2 }) })}</span>
+ </nav>
</header>
- <section class="test-overview" id="test-overview"><div><p class="eyebrow">FROM EQUATIONS TO INTUITION</p><h2>Electric energy, made visible.</h2><p>Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.</p></div><div class="test-score"><strong>4A <span>100p</span></strong><strong>4B <span>100p</span></strong><span>6 questions · 3D study guide</span></div></section>
- <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Lessons"></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" tabindex="-1"></h2><p class="description" id="description"></p>
- <section id="test-question" class="test-question" aria-label="Original test question"></section>
- <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 id="observation-panel" class="observation" aria-label="Connect the scene to the answer"><span class="eyebrow">NOTICE WHAT CHANGES</span><p id="observation" aria-live="polite"></p></section>
- <section id="learning-panel" 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>
- <section id="test-study" class="test-study" aria-label="Test answers and prediction">
- <div class="solution-heading"><div><p class="eyebrow">BUILD YOUR ANSWER</p><h3>Follow the reasoning</h3></div><a href="${answerUrl}" target="_blank" rel="noopener noreferrer">Original answers ↗</a></div>
- <div id="solution" class="solution"></div>
- <section class="prediction"><p class="eyebrow">PAUSE & PREDICT</p><h3 id="prediction-question"></h3><button type="button" id="reveal-prediction" aria-expanded="false" aria-controls="prediction-answer">Reveal explanation</button><p id="prediction-answer" hidden></p></section>
- <nav class="lesson-pagination" aria-label="Question navigation"><a id="previous-question"></a><span id="question-progress"></span><a id="next-question"></a></nav>
- </section>
- </div>
- </main>`;
-
-const lab = createLab(document.querySelector('#viewport')!);
-const allLessons: Experiment[] = [...testLessons, ...experiments];
-const settings = new Map(allLessons.map(experiment => [experiment.id, defaults(experiment)]));
-const byId = (id: string) => document.getElementById(id)!;
-function applyLanguage() {
- document.documentElement.lang = getLanguage();
- document.title = t('documentTitle');
- document.querySelector('meta[name="description"]')?.setAttribute('content', t('metaDescription'));
- (byId('language') as HTMLSelectElement).value = getLanguage();
- byId('language').setAttribute('aria-label', t('language'));
- byId('language-label').textContent = t('language');
- type CopyKey = { [K in keyof Chrome]: Chrome[K] extends string ? K : never }[keyof Chrome];
- const set = (id: string, key: CopyKey) => { byId(id).textContent = t(key); };
- set('test-collection', 'test'); set('lab-collection', 'experiments');
- set('reset', 'reset'); set('camera-reset', 'camera');
- set('app-title', 'title');
- byId('pause').textContent = paused ? t('resume') : t('pause');
- byId('reveal-prediction').textContent = byId('prediction-answer').hidden ? t('reveal') : t('hide');
- document.querySelector('.brand')!.setAttribute('aria-label', t('home'));
- document.querySelector('.header-title p')!.textContent = t('subtitle');
- document.querySelector('.collections')!.setAttribute('aria-label', t('collection'));
- document.querySelector('.test-overview .eyebrow')!.textContent = t('overview');
- document.querySelector('.test-overview h2')!.textContent = t('overviewTitle');
- document.querySelector('.test-overview > div > p:not(.eyebrow)')!.textContent = t('overviewText');
- document.querySelector('.test-score > span')!.textContent = t('score');
- document.querySelector('.subjects')!.setAttribute('aria-label', t('subjects'));
- document.querySelector('[role="tablist"]')!.setAttribute('aria-label', t('lessons'));
- byId('test-question').setAttribute('aria-label', t('original'));
- byId('parameters').setAttribute('aria-label', t('parameters'));
- byId('measurements').setAttribute('aria-label', t('measurements'));
- byId('viewport').setAttribute('aria-label', t('viewport'));
- byId('test-study').setAttribute('aria-label', t('study'));
- document.querySelector('.scene-toolbar .view-tag')!.textContent = t('view');
- document.querySelector('.scene-footer .hint')!.textContent = t('hint');
- document.querySelector('#observation-panel .eyebrow')!.textContent = t('notice');
- byId('observation-panel').setAttribute('aria-label', t('connect'));
- document.querySelector('.learning-panel > div:first-child .eyebrow')!.textContent = t('physics');
- byId('learning-panel').setAttribute('aria-label', t('learn'));
- document.querySelector('.challenge .eyebrow')!.textContent = t('try');
- document.querySelector('.challenge summary')!.textContent = t('scales');
- document.querySelector('.solution-heading .eyebrow')!.textContent = t('build');
- document.querySelector('.solution-heading h3')!.textContent = t('reasoning');
- document.querySelector('.solution-heading a')!.textContent = t('originalAnswers');
- document.querySelector('.prediction .eyebrow')!.textContent = t('predict');
- document.querySelector('.lesson-pagination')!.setAttribute('aria-label', t('navigation'));
-}
-let active: Experiment = testLessons[0];
-let collection = '';
-const subjects = document.querySelector<HTMLElement>('[role="tablist"]')!;
-let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-function updatePause() {
- byId('pause').textContent = paused ? t('resume') : t('pause');
- byId('pause').setAttribute('aria-pressed', String(paused));
- lab.setPaused(paused);
-}
-updatePause();
+ <main id="experiment" tabindex="-1"></main>
+ <footer class="site-footer"><span>${icon('orbit')}${t('app.footer')}</span><span>${t('app.footerNote')}</span></footer>`;
-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 = `${formatDisplay(Number(p[control.id]), 3)}${control.unit ? ` ${control.unit}` : ''}`;
- }
- if (active.id === 'semiconductor') {
- (byId('parameter-concentration') as HTMLInputElement).disabled = p.doping === 'intrinsic';
- }
- if (active.id === 'test-central') (byId('parameter-rb') as HTMLInputElement).disabled = p.destination === 'infinity';
- const testLesson = getTestLesson(active);
- if (testLesson) renderProse(byId('observation'), presentLesson(testLesson).observe(p));
- byId('measurements').innerHTML = presentLesson(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('');
- byId('measurements').querySelectorAll<HTMLElement>('.measurement > span').forEach(label => renderNotation(label, label.textContent!));
- lab.update(active.id, p);
-}
-let renderedLanguage = '';
-function show(experiment: Experiment) {
- active = experiment;
- const view = presentLesson(experiment);
- const testLesson = getTestLesson(experiment);
- const testView = testLesson ? presentLesson(testLesson) : undefined;
- const lessons: Experiment[] = testLesson ? testLessons : experiments;
- const index = lessons.indexOf(experiment);
- const nextCollection = testLesson ? 'test' : 'lab';
- if (collection !== nextCollection || renderedLanguage !== getLanguage()) {
- collection = nextCollection;
- renderedLanguage = getLanguage();
- subjects.innerHTML = lessons.map((e, i) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(i + 1).padStart(2, '0')}</span>${presentLesson(e).shortTitle}${getTestLesson(e) ? `<small>${getTestLesson(e)!.points}p</small>` : ''}</button>`).join('');
- }
- for (const [id, selected] of [['test-collection', Boolean(testLesson)], ['lab-collection', !testLesson]] as const) {
- if (selected) byId(id).setAttribute('aria-current', 'page');
- else byId(id).removeAttribute('aria-current');
- }
- byId('test-overview').hidden = !testLesson;
- byId('test-question').hidden = !testLesson;
- byId('test-study').hidden = !testLesson;
- byId('observation-panel').hidden = !testLesson;
- byId('lesson-number').textContent = testView ? t('questionKicker')(testView.section, index + 1, testView.points) : t('experimentKicker')(index + 1, lessons.length);
- if (testView) {
- byId('test-question').replaceChildren(...testView.questions.map((text, i) => {
- const paragraph = document.createElement('p');
- renderProse(paragraph, text);
- if (i === 0) paragraph.className = 'question-lead';
- return paragraph;
- }));
- byId('solution').replaceChildren(...testView.answers.map(part => {
- const details = document.createElement('details');
- const summary = document.createElement('summary');
- renderNotation(summary, part.title);
- const steps = document.createElement('ol');
- steps.className = 'derivation';
- for (const step of part.steps) {
- const item = document.createElement('li');
- const why = document.createElement('p'); renderProse(why, step.why); item.append(why);
- if (step.math) { const math = document.createElement('p'); math.className = 'math-step'; renderEquation(math, step.math); item.append(math); }
- steps.append(item);
- }
- details.append(summary, steps);
- return details;
- }));
- renderProse(byId('prediction-question'), testView.prediction);
- renderProse(byId('prediction-answer'), testView.predictionAnswer);
- byId('prediction-answer').hidden = true;
- byId('reveal-prediction').textContent = t('reveal');
- byId('reveal-prediction').setAttribute('aria-expanded', 'false');
- const previous = byId('previous-question') as HTMLAnchorElement;
- const next = byId('next-question') as HTMLAnchorElement;
- previous.hidden = index === 0;
- next.hidden = index === testLessons.length - 1;
- previous.textContent = t('previous');
- next.textContent = t('next');
- if (index > 0) previous.href = `#${testLessons[index - 1].id}`;
- if (index < testLessons.length - 1) next.href = `#${testLessons[index + 1].id}`;
- byId('question-progress').textContent = `${index + 1} / 6`;
- }
- byId('lesson-title').textContent = view.title;
- renderProse(byId('description'), view.description);
- renderProse(byId('scene-note'), view.sceneNote);
- renderProse(byId('explanation'), view.explanation);
- renderProse(byId('challenge'), view.challenge);
- renderProse(byId('limits'), view.limits);
- byId('equations').replaceChildren(...view.equations.map(equation => {
- const div = document.createElement('p');
- renderEquation(div, equation);
- return div;
- }));
- const source = byId('source') as HTMLAnchorElement;
- source.textContent = `${view.source.title} ↗`;
- source.href = view.source.url;
- byId('legend').innerHTML = view.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 = view.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('');
- byId('parameters').querySelectorAll<HTMLLabelElement>('label').forEach(label => {
- const input = document.getElementById(label.htmlFor)!;
- input.setAttribute('aria-label', label.textContent!);
- renderNotation(label, label.textContent!);
- });
- lab.resetCamera(experiment.id);
- update();
-}
-function fromHash() {
- const id = window.location.hash.slice(1);
- show(allLessons.find(experiment => experiment.id === id) ?? testLessons[0]);
+const host = app.querySelector<HTMLElement>('main')!;
+try {
+ const instance = await models[0].mount(host, { i18n });
+ if (import.meta.hot) import.meta.hot.dispose(() => instance.dispose());
+} catch (error) {
+ console.error(error);
+ host.innerHTML = `<div class="load-error" role="alert"><p>${t('app.loadError')}</p><button class="button" type="button">${t('app.reload')}</button></div>`;
+ host.querySelector('button')?.addEventListener('click', () => window.location.reload());
}
-let focusNextQuestion = false;
-document.querySelector('.lesson-pagination')!.addEventListener('click', event => {
- const link = (event.target as HTMLElement).closest('a');
- if (link && !((event as MouseEvent).ctrlKey || (event as MouseEvent).metaKey || (event as MouseEvent).shiftKey || (event as MouseEvent).altKey)) focusNextQuestion = true;
-});
-const onHashChange = () => {
- fromHash();
- if (focusNextQuestion) {
- focusNextQuestion = false;
- byId('lesson-title').focus({ preventScroll: true });
- byId('lesson-title').scrollIntoView({ block: 'start' });
- }
-};
-window.addEventListener('hashchange', onHashChange);
-fromHash();
-applyLanguage();
-byId('language').addEventListener('change', event => {
- const next = (event.target as HTMLSelectElement).value;
- if (next !== 'en' && next !== 'da') return;
- setLanguage(next as Language);
- applyLanguage();
- show(active);
-});
-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();
-});
-byId('reveal-prediction').addEventListener('click', () => {
- const reveal = byId('prediction-answer').hidden;
- byId('prediction-answer').hidden = !reveal;
- byId('reveal-prediction').setAttribute('aria-expanded', String(reveal));
- byId('reveal-prediction').textContent = reveal ? t('hide') : t('reveal');
-});
-subjects.addEventListener('click', event => {
- const tab = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-experiment]');
- if (!tab) return;
- const id = tab.dataset.experiment!;
- if (window.location.hash === `#${id}`) show(allLessons.find(e => e.id === id)!);
- else window.location.hash = id;
-});
-subjects.addEventListener('keydown', event => {
- const tabs = [...subjects.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
- const index = tabs.indexOf(event.target as HTMLButtonElement);
- if (index < 0) return;
- 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();
-});