diff options
| author | LalleSX | 2026-10-24 14:58:21 +0200 |
|---|---|---|
| committer | LalleSX | 2026-10-24 14:58:21 +0200 |
| commit | 73b09270c54acab810c10a80bed1d77c1a7bb2ab (patch) | |
| tree | e5bae1180c9a76345cc7779a24988875542d2dd9 /src/main.ts | |
| parent | Add tests for math rendering and interactive scenes; update navigation and viewport handling (diff) | |
| download | 3D-Electrophysics-73b09270c54acab810c10a80bed1d77c1a7bb2ab.tar.gz 3D-Electrophysics-73b09270c54acab810c10a80bed1d77c1a7bb2ab.zip | |
Diffstat (limited to '')
| -rw-r--r-- | src/main.ts | 118 |
1 files changed, 87 insertions, 31 deletions
diff --git a/src/main.ts b/src/main.ts index 7961a6f..e419e46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,7 @@ import './style.css'; -import { defaults, experiments, format, type Experiment, type Parameters } from './experiments'; +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'; @@ -8,8 +10,8 @@ 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>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></nav> + <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> </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> @@ -29,7 +31,7 @@ app.innerHTML = ` <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 class="learning-panel" aria-label="Learn the physics"> + <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> @@ -46,12 +48,54 @@ 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 ? 'Resume animation' : 'Pause animation'; + byId('pause').textContent = paused ? t('resume') : t('pause'); byId('pause').setAttribute('aria-pressed', String(paused)); lab.setPaused(paused); } @@ -62,27 +106,31 @@ 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}` : ''}`; + 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'), testLesson.observe(p)); - 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(''); + 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) { + if (collection !== nextCollection || renderedLanguage !== getLanguage()) { collection = nextCollection; - 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>${e.shortTitle}${getTestLesson(e) ? `<small>${getTestLesson(e)!.points}p</small>` : ''}</button>`).join(''); + 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'); @@ -92,15 +140,15 @@ function show(experiment: Experiment) { byId('test-question').hidden = !testLesson; byId('test-study').hidden = !testLesson; byId('observation-panel').hidden = !testLesson; - byId('lesson-number').textContent = testLesson ? `TEST ${testLesson.section} · QUESTION ${index + 1} · ${testLesson.points} POINTS` : `EXPERIMENT ${String(index + 1).padStart(2, '0')} / 08`; - if (testLesson) { - byId('test-question').replaceChildren(...testLesson.questions.map((text, i) => { + 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(...testLesson.answers.map(part => { + byId('solution').replaceChildren(...testView.answers.map(part => { const details = document.createElement('details'); const summary = document.createElement('summary'); renderNotation(summary, part.title); @@ -115,36 +163,36 @@ function show(experiment: Experiment) { details.append(summary, steps); return details; })); - renderProse(byId('prediction-question'), testLesson.prediction); - renderProse(byId('prediction-answer'), testLesson.predictionAnswer); + renderProse(byId('prediction-question'), testView.prediction); + renderProse(byId('prediction-answer'), testView.predictionAnswer); byId('prediction-answer').hidden = true; - byId('reveal-prediction').textContent = 'Reveal explanation'; + 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 = '← Previous question'; - next.textContent = 'Next question →'; + 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 = experiment.title; - renderProse(byId('description'), experiment.description); - renderProse(byId('scene-note'), experiment.sceneNote); - renderProse(byId('explanation'), experiment.explanation); - renderProse(byId('challenge'), experiment.challenge); - renderProse(byId('limits'), experiment.limits); - byId('equations').replaceChildren(...experiment.equations.map(equation => { + 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 = `${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(''); + 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; @@ -152,7 +200,7 @@ function show(experiment: Experiment) { tab.tabIndex = selected ? 0 : -1; }); const p = parameters(); - byId('parameters').innerHTML = experiment.controls.map(control => { + 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>`; @@ -184,6 +232,14 @@ const onHashChange = () => { }; 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; @@ -203,7 +259,7 @@ 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 ? 'Hide explanation' : 'Reveal explanation'; + byId('reveal-prediction').textContent = reveal ? t('hide') : t('reveal'); }); subjects.addEventListener('click', event => { const tab = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-experiment]'); |