aboutsummaryrefslogtreecommitdiff
path: root/src/main.ts
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/main.ts147
1 files changed, 121 insertions, 26 deletions
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<HTMLDivElement>('#app')!;
app.innerHTML = `
<header class="app-header">
- <a class="brand" href="#field" aria-label="Field Lab home">E<span>/</span>LAB</a>
+ <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>
- <span class="status"><i></i> INTERACTIVE LAB</span>
+ <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>
</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>
+ <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"></h2><p class="description" id="description"></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>
@@ -24,17 +28,27 @@ app.innerHTML = `
<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 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 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<HTMLElement>('[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 => `<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);
}
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) => `<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('');
+ }
+ 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 `<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();
+ 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(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<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 => {
+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<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;
@@ -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);