From 389fb3a592832092835da0c2cc1608a57ba5f629 Mon Sep 17 00:00:00 2001 From: LalleSX Date: Sat, 24 Oct 2026 14:18:00 +0200 Subject: Add tests for math rendering and interactive scenes; update navigation and viewport handling - Updated `lab.spec.ts` to navigate directly to the field lesson. - Created `math.spec.ts` with tests for math rendering, error checking, and responsive design. - Added `test4.spec.ts` for interactive scenes, ensuring all questions are functional and accessible. - Modified `vite.config.ts` to keep original question/answer links navigable in production. --- src/main.ts | 147 +++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 121 insertions(+), 26 deletions(-) (limited to 'src/main.ts') diff --git a/src/main.ts b/src/main.ts index 172f316..7961a6f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,23 @@ import './style.css'; import { defaults, experiments, format, type Experiment, type Parameters } from './experiments'; import { createLab } from './scene/lab'; +import { answerUrl, getTestLesson, testLessons } from './test4'; +import { renderEquation, renderNotation, renderProse } from './math'; const app = document.querySelector('#app')!; app.innerHTML = `
- E/LAB + E/LAB

Electrotechnique & Electrophysics

Explore the invisible. Understand the physical.

- INTERACTIVE LAB +
- +

FROM EQUATIONS TO INTUITION

Electric energy, made visible.

Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.

4A 100p4B 100p6 questions · 3D study guide
+
`; const lab = createLab(document.querySelector('#viewport')!); -const settings = new Map(experiments.map(experiment => [experiment.id, defaults(experiment)])); +const allLessons: Experiment[] = [...testLessons, ...experiments]; +const settings = new Map(allLessons.map(experiment => [experiment.id, defaults(experiment)])); const byId = (id: string) => document.getElementById(id)!; -let active = experiments[0]; +let active: Experiment = testLessons[0]; +let collection = ''; +const subjects = document.querySelector('[role="tablist"]')!; let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches; function updatePause() { byId('pause').textContent = paused ? 'Resume animation' : 'Pause animation'; @@ -53,22 +67,78 @@ function update() { 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 => `
${m.label}
${m.value}${m.unit}
`).join(''); + byId('measurements').querySelectorAll('.measurement > span').forEach(label => renderNotation(label, label.textContent!)); 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`; + const testLesson = getTestLesson(experiment); + const lessons: Experiment[] = testLesson ? testLessons : experiments; + const index = lessons.indexOf(experiment); + const nextCollection = testLesson ? 'test' : 'lab'; + if (collection !== nextCollection) { + collection = nextCollection; + subjects.innerHTML = lessons.map((e, i) => ``).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 = 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) => { + const paragraph = document.createElement('p'); + renderProse(paragraph, text); + if (i === 0) paragraph.className = 'question-lead'; + return paragraph; + })); + byId('solution').replaceChildren(...testLesson.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'), testLesson.prediction); + renderProse(byId('prediction-answer'), testLesson.predictionAnswer); + byId('prediction-answer').hidden = true; + byId('reveal-prediction').textContent = 'Reveal explanation'; + 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 →'; + 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; - 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; + 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 => { const div = document.createElement('p'); - div.textContent = equation; + renderEquation(div, equation); return div; })); const source = byId('source') as HTMLAnchorElement; @@ -87,14 +157,31 @@ function show(experiment: Experiment) { if (control.kind === 'select') return `
`; return `
`; }).join(''); - lab.resetCamera(); + byId('parameters').querySelectorAll('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(experiments.find(experiment => experiment.id === id) ?? experiments[0]); + show(allLessons.find(experiment => experiment.id === id) ?? testLessons[0]); } -const onHashChange = () => fromHash(); +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(); byId('parameters').addEventListener('input', event => { @@ -112,14 +199,23 @@ byId('pause').addEventListener('click', () => { paused = !paused; updatePause(); }); -const tabs = [...document.querySelectorAll('[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 => { +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'; +}); +subjects.addEventListener('click', event => { + const tab = (event.target as HTMLElement).closest('[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('[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; @@ -129,7 +225,6 @@ tabs.forEach((tab, index) => { event.preventDefault(); tabs[next].focus(); tabs[next].click(); - }); }); if (import.meta.hot) import.meta.hot.dispose(() => { window.removeEventListener('hashchange', onHashChange); -- cgit v1.2.3