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/experiments.ts | 61 ++++++------ src/main.ts | 147 +++++++++++++++++++++++------ src/math.ts | 42 +++++++++ src/physics/test4.test.ts | 42 +++++++++ src/physics/test4.ts | 36 +++++++ src/scene/experiments.ts | 22 +++-- src/scene/lab.ts | 24 ++++- src/scene/primitives.ts | 21 ++++- src/scene/test4.ts | 203 ++++++++++++++++++++++++++++++++++++++++ src/style.css | 75 ++++++++++++++- src/test4.ts | 232 ++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 833 insertions(+), 72 deletions(-) create mode 100644 src/math.ts create mode 100644 src/physics/test4.test.ts create mode 100644 src/physics/test4.ts create mode 100644 src/scene/test4.ts create mode 100644 src/test4.ts (limited to 'src') diff --git a/src/experiments.ts b/src/experiments.ts index df6835e..6045a88 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -1,7 +1,8 @@ import { electricField, type Charge } from './physics/electricField'; import { capacitor, conductor, forceOn, gaussianFlux, pairEnergy, potential, semiconductor } from './physics/models'; -export type ExperimentId = 'field' | 'flux' | 'energy' | 'potential' | 'capacitance' | 'dielectric' | 'current' | 'semiconductor'; +export type TestId = 'test-work' | 'test-conservation' | 'test-central' | 'test-assembly' | 'test-uniform' | 'test-dipole'; +export type ExperimentId = 'field' | 'flux' | 'energy' | 'potential' | 'capacitance' | 'dielectric' | 'current' | 'semiconductor' | TestId; export type Parameters = Record; export interface Slider { kind: 'range'; id: string; label: string; min: number; max: number; step: number; value: number; unit: string } export interface Choice { kind: 'select'; id: string; label: string; value: string; options: { value: string; label: string }[] } @@ -56,11 +57,11 @@ export const experiments: Experiment[] = [ id: 'field', title: 'Electric charge, force and field', shortTitle: 'Charge & field', description: 'See how signed charges create a vector field and exert forces on one another.', controls: pairControls, - equations: ['E = k Σ qᵢ r̂ᵢ / rᵢ²', 'F = qE · |F₁₂| = k|q₁q₂| / r²'], + equations: [String.raw`\vec E = k\sum_i \frac{q_i}{r_i^2}\hat r_i`, String.raw`\vec F = q\vec E,\qquad |\vec F_{12}| = k\frac{|q_1q_2|}{r^2}`], explanation: 'A positive test charge follows the electric field; a negative charge feels a force in the opposite direction. The large gold arrow shows the force on the right-hand source due only to the left-hand source.', challenge: 'Double the separation. What happens to the force? Change to like charges and watch attraction become repulsion.', - limits: 'Vacuum point charges. The x–z plane is sampled; points within 0.18 m of a source are excluded. Arrow lengths are compressed, and the force arrow is normalized.', - sceneNote: 'Grid: 1 m major squares · small arrows: E · gold arrow: force on right charge', + limits: String.raw`Vacuum point charges. The x–z plane is sampled; points within $0.18\,\mathrm m$ of a source are excluded. Arrow lengths are compressed, and the force arrow is normalized.`, + sceneNote: String.raw`Grid: $1\,\mathrm m$ major squares · small arrows: $E$ · gold arrow: force on right charge`, legend: [['#ff967b', 'Positive charge'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Electric field'], ['#f5d58b', 'Force']], source: textbook('5-3-coulombs-law'), measure: p => { @@ -74,11 +75,11 @@ export const experiments: Experiment[] = [ id: 'flux', title: 'Electric flux and charge', shortTitle: 'Flux & Gauss’s law', description: 'Move a charge through a Gaussian surface and compare local field with net flux.', controls: [slider('charge', 'Signed source charge', -5, 5, 0.1, 2, 'nC'), slider('radius', 'Gaussian sphere radius', 1, 3, 0.1, 2, 'm'), slider('position', 'Charge x position', -4, 4, 0.1, 0, 'm')], - equations: ['ΦE = ∮ E · n̂ dA', 'ΦE = Qenclosed / ε₀'], - explanation: 'The translucent sphere is an imaginary closed surface, not a conductor. Arrows show E at surface samples: mint means outward flux and coral means inward flux. An outside charge still creates a field, but its total flux through the closed surface is zero.', + equations: [String.raw`\Phi_E = \oint \vec E\cdot\hat n\,\mathrm dA`, String.raw`\Phi_E = \frac{Q_{\mathrm{enclosed}}}{\varepsilon_0}`], + explanation: String.raw`The translucent sphere is an imaginary closed surface, not a conductor. Arrows show $E$ at surface samples: mint means outward flux and coral means inward flux. An outside charge still creates a field, but its total flux through the closed surface is zero.`, challenge: 'Keep the charge inside and enlarge the sphere. Then move the charge outside. Can there be a nonzero field with zero net flux?', limits: 'The flux readout uses exact Gauss’s law; arrows are discrete field samples, not a numerical integral. A point charge exactly on the surface makes the flux undefined.', - sceneNote: 'Imaginary Gaussian surface · mint: E·n > 0 · coral: E·n < 0', + sceneNote: String.raw`Imaginary Gaussian surface · mint: $\vec E\cdot\hat n>0$ · coral: $\vec E\cdot\hat n<0$`, legend: [['#91e2cb', 'Outward flux'], ['#ff967b', 'Inward flux'], ['#73d7ff', 'Gaussian surface']], source: textbook('6-2-explaining-gausss-law'), measure: p => { @@ -91,11 +92,11 @@ export const experiments: Experiment[] = [ id: 'energy', title: 'Electric potential energy', shortTitle: 'Potential energy', description: 'Separate two charges and connect interaction energy with the work done by the field.', controls: [slider('testCharge', 'Moving charge', -3, 3, 0.1, -2, 'nC'), slider('distance', 'Distance from +3 nC source', 0.75, 6, 0.05, 2, 'm')], - equations: ['U(r) = kQq / r', 'Wfield, 6m→r = U(6 m) − U(r)'], + equations: [String.raw`U(r) = \frac{kQq}{r}`, String.raw`W_{\mathrm{field},\,6\,\mathrm m\to r} = U(6\,\mathrm m)-U(r)`], explanation: 'The source stays on the left. The mint curve plots the interaction energy against separation; the gold marker follows the current setting. Opposite charges have negative energy relative to infinite separation, so pulling them apart requires external work.', - challenge: 'Move an opposite charge closer. Does U rise or fall? Now make the moving charge positive and compare the sign of the field’s work.', - limits: 'Two prescribed point charges, with U = 0 at infinity. The curve uses a fixed display scale of 20 nJ per vertical scene unit. The controls set positions; they do not integrate motion.', - sceneNote: 'Curve: U versus separation · gold point: current U · horizontal zero-energy axis', + challenge: String.raw`Move an opposite charge closer. Does $U$ rise or fall? Now make the moving charge positive and compare the sign of the field’s work.`, + limits: String.raw`Two prescribed point charges, with $U=0$ at infinity. The curve uses a fixed display scale of 20 nJ per vertical scene unit. The controls set positions; they do not integrate motion.`, + sceneNote: String.raw`Curve: $U$ versus separation · gold point: current $U$ · horizontal zero-energy axis`, legend: [['#ff967b', 'Fixed +3 nC'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Energy curve'], ['#f5d58b', 'Current energy']], source: textbook('7-1-electric-potential-energy'), measure: p => { @@ -109,11 +110,11 @@ export const experiments: Experiment[] = [ id: 'potential', title: 'Electric potential and gradient', shortTitle: 'Potential & gradient', description: 'Read electric potential as a landscape and connect its downhill direction to the field.', controls: [...pairControls, slider('probe', 'Probe x position (z = 1 m)', -4, 4, 0.1, 0, 'm')], - equations: ['V = k Σ qᵢ / rᵢ', 'E = −∇V · Fx = −q ∂V/∂x'], - explanation: 'Warm peaks are positive potential; blue valleys are negative. Mint arrows show the electric field projected in the x–z plane. The gold probe samples V and the x component of E. Field arrows point toward decreasing potential.', - challenge: 'Use opposite charges: the midpoint potential can be zero while the field is nonzero. Compare V and Ex as you move the probe.', - limits: 'Height = 2 tanh(V / 15 V), a compressed potential scale. Holes exclude source singularities. Arrow lengths are compressed. Probe is always at z = 1 m, away from sources; V = 0 at infinity.', - sceneNote: 'Height and colour encode potential · gold probe at z = 1 m · arrows: −∇V', + equations: [String.raw`V = k\sum_i\frac{q_i}{r_i}`, String.raw`\vec E = -\nabla V,\qquad F_x = -q\frac{\partial V}{\partial x}`], + explanation: String.raw`Warm peaks are positive potential; blue valleys are negative. Mint arrows show the electric field projected in the x–z plane. The gold probe samples $V$ and the x component of $E$. Field arrows point toward decreasing potential.`, + challenge: String.raw`Use opposite charges: the midpoint potential can be zero while the field is nonzero. Compare $V$ and $E_x$ as you move the probe.`, + limits: String.raw`Height $=2\tanh\left(\frac{V}{15\,\mathrm V}\right)$, a compressed potential scale. Holes exclude source singularities. Arrow lengths are compressed. Probe is always at $z=1\,\mathrm m$, away from sources; $V=0$ at infinity.`, + sceneNote: String.raw`Height and colour encode potential · gold probe at $z=1\,\mathrm m$ · arrows: $-\nabla V$`, legend: [['#ff967b', 'Positive potential'], ['#73d7ff', 'Negative potential'], ['#f5d58b', 'Probe']], source: textbook('7-4-determining-field-from-potential'), measure: p => { @@ -127,7 +128,7 @@ export const experiments: Experiment[] = [ id: 'capacitance', title: 'Capacitance', shortTitle: 'Capacitance', description: 'Build a parallel-plate capacitor and see how geometry controls charge storage.', controls: plateControls, - equations: ['C = ε₀A / d · Q = CV', 'E = V / d · U = ½CV²'], + equations: [String.raw`C = \frac{\varepsilon_0 A}{d},\qquad Q = CV`, String.raw`E = \frac{V}{d},\qquad U = \frac12 CV^2`], explanation: 'The left plate is positive and the right plate is negative. At a fixed applied voltage, larger plates store more charge, while a wider gap reduces capacitance. The arrows show the uniform field approximation between the plates.', challenge: 'Double the area, then double the gap. Which measurements return to their original values, and which do not?', limits: 'Ideal parallel plates in vacuum, connected to a fixed-voltage supply. Fringing is ignored. Plate separation is enlarged 50× relative to lateral dimensions for visibility; charge markers are illustrative. Arrows span the gap to show direction, not magnitude.', @@ -140,11 +141,11 @@ export const experiments: Experiment[] = [ id: 'dielectric', title: 'Dielectric materials', shortTitle: 'Dielectrics', description: 'Polarize an insulator and compare insertion with a connected or disconnected supply.', controls: [slider('permittivity', 'Relative permittivity κ', 1, 8, 0.1, 4, ''), choice('connection', 'Electrical condition', 'connected', [['connected', 'Connected · voltage held fixed'], ['isolated', 'Isolated · free charge held fixed']]), ...plateControls], - equations: ['C = κε₀A / d · P = ε₀(κ − 1)E', 'Fixed V: Q ∝ κ · Fixed Q: V, E ∝ 1/κ'], - explanation: 'Bound dipoles align with the field: their negative ends face the positive plate. The bound charges oppose the field of the free plate charges. With a battery connected, extra free charge enters and V stays fixed. When isolated, Q stays fixed and V falls.', - challenge: 'Switch to isolated and compare κ = 1 with κ = 4. Then reconnect the supply and repeat: which quantities are held constant?', - limits: 'A uniform, linear dielectric completely fills the gap. Isolated Q is defined by charging this geometry in vacuum at the selected charging voltage, then disconnecting and inserting the material. Each setting is a fresh preparation; breakdown and fringing are excluded. Gap enlarged 50×; field arrows show direction, not magnitude.', - sceneNote: 'Gold volume: dielectric · blue/red pairs: bound dipoles · κ = 1: no polarization', + equations: [String.raw`C = \frac{\kappa\varepsilon_0 A}{d},\qquad \vec P = \varepsilon_0(\kappa-1)\vec E`, String.raw`\begin{aligned}\text{Fixed }V:&\quad Q\propto\kappa\\\text{Fixed }Q:&\quad V,E\propto\frac1\kappa\end{aligned}`], + explanation: String.raw`Bound dipoles align with the field: their negative ends face the positive plate. The bound charges oppose the field of the free plate charges. With a battery connected, extra free charge enters and $V$ stays fixed. When isolated, $Q$ stays fixed and $V$ falls.`, + challenge: String.raw`Switch to isolated and compare $\kappa$ = 1 with $\kappa$ = 4. Then reconnect the supply and repeat: which quantities are held constant?`, + limits: String.raw`A uniform, linear dielectric completely fills the gap. Isolated $Q$ is defined by charging this geometry in vacuum at the selected charging voltage, then disconnecting and inserting the material. Each setting is a fresh preparation; breakdown and fringing are excluded. Gap enlarged 50×; field arrows show direction, not magnitude.`, + sceneNote: String.raw`Gold volume: dielectric · blue/red pairs: bound dipoles · $\kappa$ = 1: no polarization`, legend: [['#f5d58b', 'Dielectric'], ['#73d7ff', 'Bound −'], ['#ff967b', 'Bound +'], ['#91e2cb', 'Net field']], source: textbook('8-4-capacitor-with-a-dielectric'), measure: p => { const c = plateModel(p); return [metric('Capacitance', c.capacitance * 1e12, 'pF'), metric('Plate voltage', c.voltage, 'V'), metric('Free charge |Q|', c.charge * 1e9, 'nC'), metric('Net field', c.field / 1000, 'kV/m'), metric('Polarization |P|', c.polarization * 1e6, 'µC/m²')]; }, @@ -153,10 +154,10 @@ export const experiments: Experiment[] = [ id: 'current', title: 'Current', shortTitle: 'Current & drift', description: 'Drive electrons through a copper wire and compare their drift with conventional current.', controls: [slider('voltage', 'Voltage across wire', -0.1, 0.1, 0.005, 0.025, 'V'), slider('length', 'Wire length', 0.5, 3, 0.1, 1, 'm'), slider('area', 'Cross-sectional area', 0.5, 4, 0.1, 1, 'mm²')], - equations: ['R = ρL / A · I = V / R', 'J = I / A · ve = −I / (neA)'], + equations: [String.raw`R = \frac{\rho L}{A},\qquad I = \frac{V}{R}`, String.raw`J = \frac{I}{A},\qquad v_e = -\frac{I}{neA}`], explanation: 'Mint shows conventional current, defined as positive-charge flow. Blue electrons drift in the opposite direction. More voltage increases the drift; a longer wire adds resistance. A wider wire carries more current at the same voltage.', challenge: 'Reverse the voltage. Then set it to zero. At fixed voltage, increase the wire area: does current increase, and does the electron drift speed change?', - limits: 'Ohmic copper at fixed temperature: ρ = 1.68×10⁻⁸ Ω·m, n = 8.5×10²⁸ m⁻³. Drift is displayed 600× faster and the wire radius is exaggerated. Thermal scattering, heating feedback, and signal propagation are not simulated; P is dissipated power.', + limits: String.raw`Ohmic copper at fixed temperature: $\rho=1.68\times10^{-8}\,\Omega\,\mathrm m$, $n=8.5\times10^{28}\,\mathrm m^{-3}$. Drift is displayed 600× faster and the wire radius is exaggerated. Thermal scattering, heating feedback, and signal propagation are not simulated; $P$ is dissipated power.`, sceneNote: 'Blue: electron drift · mint: conventional current · speed ×600 · radius exaggerated', legend: [['#73d7ff', 'Electrons'], ['#91e2cb', 'Conventional current'], ['#f5d58b', 'Copper ions']], source: textbook('9-2-model-of-conduction-in-metals'), @@ -164,13 +165,13 @@ export const experiments: Experiment[] = [ }, { id: 'semiconductor', title: 'Semiconducting materials', shortTitle: 'Semiconductors', - description: 'Explore intrinsic, n-type and p-type silicon through carriers and an energy-band diagram.', + description: String.raw`Explore intrinsic, $n$-type and $p$-type silicon through carriers and an energy-band diagram.`, controls: [choice('doping', 'Silicon sample at 300 K', 'intrinsic', [['intrinsic', 'Intrinsic · undoped'], ['n', 'n-type · donor dopants'], ['p', 'p-type · acceptor dopants']]), slider('concentration', 'Dopant density · log₁₀(N / m⁻³)', 14, 22, 0.25, 20, '')], - equations: ['np = nᵢ² · n − p = ND − NA', 'σ = e(nμn + pμp)', 'EF − Ei = kBT ln(n / nᵢ)'], - explanation: 'Electrons occupy the conduction band; holes are missing electrons in the valence band. Donors raise electron density and the Fermi level; acceptors raise hole density and lower it. The gold line marks EF. Carrier markers show populations on a compressed scale, not individual atoms.', - challenge: 'Switch from intrinsic to n-type, then p-type at the same doping. Watch the majority carrier and Fermi shift change. Why are the conductivities different?', - limits: 'Illustrative silicon reference values at 300 K: Eg = 1.12 eV, nᵢ ≈ 10¹⁶ m⁻³, μn = 0.135 and μp = 0.048 m²/(V·s). Fully ionized, non-degenerate equilibrium with fixed mobilities. Ei is drawn at approximate midgap. Motion is schematic thermal motion with no applied field or net drift; the density control is inactive for intrinsic silicon.', - sceneNote: 'Upper blue band: conduction · lower coral band: valence · gold: EF · no net flow', + equations: [String.raw`np = n_i^2,\qquad n-p = N_D-N_A`, String.raw`\sigma = e(n\mu_n+p\mu_p)`, String.raw`E_F-E_i = k_B T\ln\left(\frac{n}{n_i}\right)`], + explanation: String.raw`Electrons occupy the conduction band; holes are missing electrons in the valence band. Donors raise electron density and the Fermi level; acceptors raise hole density and lower it. The gold line marks $E_F$. Carrier markers show populations on a compressed scale, not individual atoms.`, + challenge: String.raw`Switch from intrinsic to $n$-type, then $p$-type at the same doping. Watch the majority carrier and Fermi shift change. Why are the conductivities different?`, + limits: String.raw`Illustrative silicon reference values at $300\,\mathrm K$: $E_g=1.12\,\mathrm{eV}$, $n_i\approx10^{16}\,\mathrm m^{-3}$, $\mu_n=0.135\,\mathrm{m^2/(V\,s)}$ and $\mu_p=0.048\,\mathrm{m^2/(V\,s)}$. Fully ionized, non-degenerate equilibrium with fixed mobilities. $E_i$ is drawn at approximate midgap. Motion is schematic thermal motion with no applied field or net drift; the density control is inactive for intrinsic silicon.`, + sceneNote: String.raw`Upper blue band: conduction · lower coral band: valence · gold: $E_F$ · no net flow`, legend: [['#73d7ff', 'Conduction electrons'], ['#ff967b', 'Valence holes'], ['#f5d58b', 'Fermi level']], source: { title: 'OpenStax · Band theory of solids', url: 'https://openstax.org/books/university-physics-volume-3/pages/9-5-band-theory-of-solids' }, measure: p => { const s = carrierModel(p); return [metric('Electron density n', s.electrons, 'm⁻³'), metric('Hole density p', s.holes, 'm⁻³'), metric('Conductivity σ', s.conductivity, 'S/m'), metric('Fermi shift EF − Ei', s.fermiShift, 'eV')]; }, 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); diff --git a/src/math.ts b/src/math.ts new file mode 100644 index 0000000..71403f4 --- /dev/null +++ b/src/math.ts @@ -0,0 +1,42 @@ +import katex from 'katex'; +import 'katex/dist/katex.min.css'; + +const options = { output: 'htmlAndMathml', throwOnError: false, strict: 'error', trust: false } as const; + +/** Render authored TeX with bundled fonts and a MathML equivalent for assistive technology. */ +export function renderEquation(element: HTMLElement, tex: string) { + element.classList.add('math-display'); + element.tabIndex = 0; // Long equations can be scrolled with the keyboard on narrow screens. + katex.render(tex, element, { ...options, displayMode: true }); +} + +/** Plain prose with explicit $...$ inline math; everything outside delimiters stays text. */ +export function renderProse(element: HTMLElement, text: string) { + element.replaceChildren(); + let offset = 0; + for (const match of text.matchAll(/\$([^$\n]+)\$/g)) { + element.append(document.createTextNode(text.slice(offset, match.index))); + const math = document.createElement('span'); + katex.render(match[1], math, { ...options, displayMode: false }); + element.append(math); + offset = match.index + match[0].length; + } + element.append(document.createTextNode(text.slice(offset))); +} + +// Short UI labels retain their plain accessible names while sharing the lesson notation. +const notation: Record = { + 'log₁₀(N / m⁻³)': String.raw`\log_{10}(N/\mathrm m^{-3})`, + 'EF − Ei': 'E_F-E_i', 'U₁₂₃': 'U_{123}', 'ΔU₀': String.raw`\Delta U_0`, + 'K + U': 'K+U', 'q₀': 'q_0', 'q₁': 'q_1', 'q₂': 'q_2', 'q₃': 'q_3', + 'Uₐ': 'U_a', 'Uᵦ': 'U_b', 'Kₐ': 'K_a', 'rₐ': 'r_a', 'rᵦ': 'r_b', + 'θₐ': String.raw`\theta_a`, 'θᵦ': String.raw`\theta_b`, 'τz': String.raw`\tau_z`, + '|Q|': '|Q|', '|P|': '|P|', '|E|': String.raw`|\vec E|`, 'Ex': 'E_x', + 'Δx': String.raw`\Delta x`, 'θ': String.raw`\theta`, 'α': String.raw`\alpha`, + 'κ': String.raw`\kappa`, 'σ': String.raw`\sigma`, '90°': String.raw`90^\circ`, +}; +const notationPattern = new RegExp(Object.keys(notation).sort((a, b) => b.length - a.length).map(key => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + '|\\b[EqdnpUVK]\\b', 'g'); + +export function renderNotation(element: HTMLElement, text: string) { + renderProse(element, text.replace(notationPattern, token => `$${notation[token] ?? token}$`)); +} diff --git a/src/physics/test4.test.ts b/src/physics/test4.test.ts new file mode 100644 index 0000000..08dd868 --- /dev/null +++ b/src/physics/test4.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { assembledEnergy, centralWork, dipoleState, energyTransfer, uniformWork } from './test4'; + +describe('Test 4 physical answers', () => { + it('uses only displacement along the uniform field and preserves the charge sign', () => { + expect(uniformWork(2e-9, 100, 3, Math.PI / 3)).toBeCloseTo(3e-7, 14); + expect(uniformWork(-2e-9, 100, 3, 0)).toBeCloseTo(-6e-7, 14); + expect(uniformWork(2e-9, 100, 3, Math.PI / 2)).toBeCloseTo(0, 14); + }); + it('computes point-charge work including the analytic infinity reference', () => { + expect(centralWork(3e-9, 2e-9, 2, 4)).toBeCloseTo(1.348132768845e-8, 16); + expect(centralWork(3e-9, -2e-9, 2, Infinity)).toBeCloseTo(-2.69626553769e-8, 16); + expect(() => centralWork(1, 1, 0, 2)).toThrow(RangeError); + }); + it('converts the potential drop to kinetic energy without changing total energy', () => { + const state = energyTransfer(3e-8, 1e-8, 5e-9, 1e-6); + expect(state.kinetic).toBeCloseTo(2.5e-8, 16); + expect(state.total).toBeCloseTo(3.5e-8, 16); + expect(state.speed).toBeCloseTo(0.22360679775, 10); + expect(() => energyTransfer(0, 1, 0, 1)).toThrow(RangeError); + }); + it('counts each pair once when building a charge configuration', () => { + const charges = [ + { x: 0, y: 0, z: 0, coulombs: 1e-9 }, + { x: 1, y: 0, z: 0, coulombs: 1e-9 }, + { x: 0, y: 0, z: 0.75, coulombs: -1e-9 }, + ]; + expect(assembledEnergy(charges.slice(0, 1))).toBe(0); + expect(assembledEnergy(charges)).toBeCloseTo(-1.0185892031273e-8, 16); + expect(() => assembledEnergy([charges[0], charges[0]])).toThrow(RangeError); + }); + it('distinguishes signed restoring torque, rotation work and zero at 90 degrees', () => { + const state = dipoleState(2e-9, 100, Math.PI / 2, 0); + expect(state.energy).toBeCloseTo(0, 14); + expect(state.torqueZ).toBeCloseTo(-2e-7, 14); + expect(state.work).toBeCloseTo(-2e-7, 14); + const aligned = dipoleState(2e-9, 100, 0, Math.PI / 2); + expect(aligned.energy).toBeCloseTo(-2e-7, 14); + expect(aligned.work).toBeCloseTo(2e-7, 14); + expect(dipoleState(2e-9, 100, Math.PI, 0).energy).toBeCloseTo(2e-7, 14); + }); +}); diff --git a/src/physics/test4.ts b/src/physics/test4.ts new file mode 100644 index 0000000..a4d4be5 --- /dev/null +++ b/src/physics/test4.ts @@ -0,0 +1,36 @@ +import { COULOMB_CONSTANT, type Charge } from './electricField'; + +export function uniformWork(q: number, field: number, distance: number, angle: number): number { + return q * field * distance * Math.cos(angle); +} + +export function centralWork(q: number, q0: number, ra: number, rb: number): number { + if (!Number.isFinite(ra) || ra <= 0 || rb <= 0 || Number.isNaN(rb)) throw new RangeError('Distances must be positive; only the final distance may be infinity.'); + return COULOMB_CONSTANT * q * q0 * (1 / ra - 1 / rb); +} + +export function energyTransfer(initialPotential: number, potential: number, initialKinetic: number, mass: number) { + const kinetic = initialKinetic + initialPotential - potential; + if (kinetic < -1e-20 || mass <= 0) throw new RangeError('This position is energetically inaccessible or the mass is invalid.'); + return { kinetic: Math.max(0, kinetic), total: initialKinetic + initialPotential, speed: Math.sqrt(2 * Math.max(0, kinetic) / mass) }; +} + +export function assembledEnergy(charges: readonly Charge[]): number { + let energy = 0; + for (let i = 0; i < charges.length; i++) for (let j = i + 1; j < charges.length; j++) { + const a = charges[i], b = charges[j]; + const distance = Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); + if (distance === 0) throw new RangeError('Point charges must have distinct positions.'); + energy += COULOMB_CONSTANT * a.coulombs * b.coulombs / distance; + } + return energy; +} + +/** Dipole in the xy plane, E along +x; positive theta is counterclockwise. */ +export function dipoleState(moment: number, field: number, theta: number, thetaA: number) { + return { + energy: -moment * field * Math.cos(theta), + torqueZ: -moment * field * Math.sin(theta), + work: moment * field * (Math.cos(theta) - Math.cos(thetaA)), + }; +} diff --git a/src/scene/experiments.ts b/src/scene/experiments.ts index 67d0322..f48c984 100644 --- a/src/scene/experiments.ts +++ b/src/scene/experiments.ts @@ -3,13 +3,14 @@ import { chargePair, carrierModel, number, plateModel, wireModel, type Experimen import { electricField, type Charge, type Point } from '../physics/electricField'; import { forceOn, pairEnergy, potential } from '../physics/models'; import { arrow, box, colours, grid, label, line, sphere } from './primitives'; +import { buildTestScene } from './test4'; export type Animate = (delta: number) => void; const still: Animate = () => {}; function signedCharge(group: THREE.Group, charge: Charge) { const colour = charge.coulombs >= 0 ? colours.positive : colours.negative; sphere(group, charge, 0.2, colour); - label(group, `${charge.coulombs >= 0 ? '+' : '−'}${Math.abs(charge.coulombs * 1e9).toFixed(1)} nC`, { ...charge, y: charge.y + 0.55 }, colour, 1.25); + label(group, String.raw`$${charge.coulombs >= 0 ? '+' : '-'}${Math.abs(charge.coulombs * 1e9).toFixed(1)}\,\mathrm{nC}$`, { ...charge, y: charge.y + 0.55 }, colour, 1.25); } function compressedFieldArrow(group: THREE.Group, position: Point, direction: Point, colour = colours.field) { const strength = Math.hypot(direction.x, direction.y, direction.z); @@ -28,7 +29,7 @@ function fieldScene(group: THREE.Group, p: Parameters) { } const force = forceOn(charges[1], [charges[0]]); arrow(group, { ...charges[1], y: 0.8 }, force, 1.3, colours.gold); - label(group, 'F on right charge', { x: charges[1].x, y: 1.5, z: 0 }, colours.gold, 2); + label(group, String.raw`$\vec F$ on right charge`, { x: charges[1].x, y: 1.5, z: 0 }, colours.gold, 2); sphere(group, { x: 0, y: 0, z: 0 }, 0.06, colours.gold); label(group, 'Origin', { x: 0, y: 0, z: 0.45 }, colours.muted, 0.9); } @@ -71,9 +72,9 @@ function energyScene(group: THREE.Group, p: Parameters) { line(group, [{ x: -2.5, y: 0, z: -1.5 }, { x: 3.5, y: 0, z: -1.5 }], colours.muted); line(group, [{ x: -3, y: -5.5, z: -1.5 }, { x: -3, y: 5.5, z: -1.5 }], colours.muted, 0.5); sphere(group, { x: -3 + r, y: pairEnergy(3e-9, q, r) * 1e9 / 20, z: -1.5 }, 0.12, colours.gold); - label(group, 'U(r) · 20 nJ per height unit', { x: 0, y: 5.7, z: -1.5 }, colours.field, 3.9); + label(group, String.raw`$U(r)$ · 20 nJ per height unit`, { x: 0, y: 5.7, z: -1.5 }, colours.field, 3.9); label(group, '0 energy', { x: 4, y: 0, z: -1.5 }, colours.muted, 1.5); - label(group, `r = ${r.toFixed(2)} m`, { x: 0, y: -0.65, z: 1.5 }, colours.muted, 2); + label(group, String.raw`$r = ${r.toFixed(2)}\,\mathrm m$`, { x: 0, y: -0.65, z: 1.5 }, colours.muted, 2); } function potentialScene(group: THREE.Group, p: Parameters) { @@ -154,8 +155,8 @@ function plateScene(group: THREE.Group, p: Parameters, dielectric: boolean) { } } line(group, [{ x: -halfGap, y: -side, z: 0 }, { x: halfGap, y: -side, z: 0 }], colours.muted); - label(group, `d = ${number(p, 'gap').toFixed(1)} mm (gap ×50)`, { x: 0, y: -side - 0.45, z: 0 }, colours.muted, 2.5); - label(group, `${c.voltage.toFixed(2)} V · ${p.connection === 'isolated' ? 'isolated' : 'supply connected'}`, { x: 0, y: side + 0.8, z: 0 }, colours.field, 3); + label(group, String.raw`$d = ${number(p, 'gap').toFixed(1)}\,\mathrm{mm}$ (gap $\times50$)`, { x: 0, y: -side - 0.45, z: 0 }, colours.muted, 2.5); + label(group, String.raw`$${c.voltage.toFixed(2)}\,\mathrm V$ · ${p.connection === 'isolated' ? 'isolated' : 'supply connected'}`, { x: 0, y: side + 0.8, z: 0 }, colours.field, 3); grid(group, -3.2); } @@ -183,7 +184,7 @@ function currentScene(group: THREE.Group, p: Parameters): Animate { arrow(group, { x: -Math.sign(model.current) * 1.1, y: radius + 0.8, z: 0 }, { x: model.current, y: 0, z: 0 }, 2.2, colours.field); label(group, model.current === 0 ? 'No net current' : 'Conventional current', { x: 0, y: radius + 1.5, z: 0 }, colours.field, 2.8); arrow(group, { x: -Math.sign(model.electronDrift) * 1.1, y: -radius - 0.65, z: 0 }, { x: model.electronDrift, y: 0, z: 0 }, 2.2, colours.negative); - label(group, 'Electron drift · displayed ×600', { x: 0, y: -radius - 1.1, z: 0 }, colours.negative, 3.3); + label(group, String.raw`Electron drift · displayed $\times600$`, { x: 0, y: -radius - 1.1, z: 0 }, colours.negative, 3.3); const velocity = model.electronDrift * 600 * 3; return delta => { for (const electron of carriers) { @@ -201,11 +202,11 @@ function semiconductorScene(group: THREE.Group, p: Parameters): Animate { box(group, { x: 0, y: -bandEdge - 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.positive, 0.12); line(group, [{ x: -3.5, y: 0, z: 1.6 }, { x: 3.5, y: 0, z: 1.6 }], colours.muted, 0.7); line(group, [{ x: -3.6, y: s.fermiShift * 3, z: 1.65 }, { x: 3.6, y: s.fermiShift * 3, z: 1.65 }], colours.gold); - label(group, 'EF', { x: 4, y: s.fermiShift * 3, z: 1.65 }, colours.gold, 0.7); - label(group, 'Ei ≈ midgap', { x: -4.4, y: 0, z: 1.65 }, colours.muted, 1.7); + label(group, String.raw`$E_F$`, { x: 4, y: s.fermiShift * 3, z: 1.65 }, colours.gold, 0.7); + label(group, String.raw`$E_i\approx\text{midgap}$`, { x: -4.4, y: 0, z: 1.65 }, colours.muted, 1.7); label(group, 'Conduction band', { x: 0, y: bandEdge + 1.1, z: 0 }, colours.negative, 2.8); label(group, 'Valence band', { x: 0, y: -bandEdge - 1.1, z: 0 }, colours.positive, 2.8); - label(group, 'Eg = 1.12 eV', { x: 4.1, y: 0, z: -1.3 }, colours.muted, 1.8); + label(group, String.raw`$E_g=1.12\,\mathrm{eV}$`, { x: 4.1, y: 0, z: -1.3 }, colours.muted, 1.8); line(group, [{ x: 3.8, y: -bandEdge, z: -1.3 }, { x: 3.8, y: bandEdge, z: -1.3 }], colours.muted); const particles: { mesh: THREE.Mesh; base: THREE.Vector3; phase: number }[] = []; for (const [density, sign] of [[s.electrons, 1], [s.holes, -1]]) { @@ -240,6 +241,7 @@ export function buildExperiment(group: THREE.Group, id: ExperimentId, p: Paramet case 'dielectric': plateScene(group, p, true); break; case 'current': return currentScene(group, p); case 'semiconductor': return semiconductorScene(group, p); + default: return buildTestScene(group, id, p); } return still; } diff --git a/src/scene/lab.ts b/src/scene/lab.ts index 5d2cadf..782f300 100644 --- a/src/scene/lab.ts +++ b/src/scene/lab.ts @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js'; import type { ExperimentId, Parameters } from '../experiments'; import { buildExperiment, type Animate } from './experiments'; import { disposeGroup } from './primitives'; @@ -14,12 +15,15 @@ export function createLab(viewport: HTMLElement) { error.setAttribute('role', 'alert'); error.textContent = 'WebGL could not start. Enable browser hardware acceleration and reload to see the 3D scenes. Lessons, controls and measurements still work.'; viewport.append(error); - return { update: () => {}, resetCamera: () => {}, setPaused: () => {}, dispose: () => {} }; + return { update: () => {}, resetCamera: (_id?: ExperimentId) => {}, setPaused: () => {}, dispose: () => {} }; } renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.domElement.setAttribute('aria-label', '3D experiment: drag to orbit, scroll to zoom, right-drag to pan'); renderer.domElement.setAttribute('tabindex', '0'); viewport.append(renderer.domElement); + const labels = new CSS2DRenderer(); + labels.domElement.className = 'scene-labels'; + viewport.append(labels.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color('#101822'); scene.add(new THREE.HemisphereLight(0xe9f7ff, 0x263149, 3)); @@ -32,8 +36,15 @@ export function createLab(viewport: HTMLElement) { controls.minDistance = 3; controls.maxDistance = 32; controls.listenToKeyEvents(renderer.domElement); - function resetCamera() { - camera.position.set(8, 7.5, 11); + let currentId: ExperimentId | undefined; + let framingScale = 1; + const fitScale = (aspect: number) => currentId?.startsWith('test-') ? Math.max(1, Math.min(2, 1.1 / aspect)) : 1; + function resetCamera(id = currentId) { + currentId = id; + const { width, height } = viewport.getBoundingClientRect(); + framingScale = fitScale(width / Math.max(1, height)); + if (currentId?.startsWith('test-')) camera.position.set(3.7, 5, 14.5).multiplyScalar(framingScale); + else camera.position.set(8, 7.5, 11); controls.target.set(0, 0, 0); controls.update(); } @@ -52,8 +63,13 @@ export function createLab(viewport: HTMLElement) { const { width, height } = viewport.getBoundingClientRect(); if (width <= 0 || height <= 0) return; renderer.setSize(width, height); + labels.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); + const nextScale = fitScale(camera.aspect); + camera.position.sub(controls.target).multiplyScalar(nextScale / framingScale).add(controls.target); + framingScale = nextScale; + controls.update(); }); observer.observe(viewport); renderer.domElement.addEventListener('webglcontextlost', event => { @@ -80,6 +96,7 @@ export function createLab(viewport: HTMLElement) { if (!paused && !document.hidden) animate(delta); controls.update(); renderer.render(scene, camera); + labels.render(scene, camera); }); return { update, @@ -92,6 +109,7 @@ export function createLab(viewport: HTMLElement) { disposeGroup(group); renderer.dispose(); renderer.domElement.remove(); + labels.domElement.remove(); }, }; } diff --git a/src/scene/primitives.ts b/src/scene/primitives.ts index bce9113..384ea1d 100644 --- a/src/scene/primitives.ts +++ b/src/scene/primitives.ts @@ -1,4 +1,6 @@ import * as THREE from 'three'; +import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; +import { renderProse } from '../math'; import type { Point } from '../physics/electricField'; export const colours = { positive: '#ff967b', negative: '#73d7ff', field: '#91e2cb', gold: '#f5d58b', muted: '#8294a8' }; @@ -25,7 +27,20 @@ export function arrow(group: THREE.Group, origin: Point, direction: Point, lengt if (dir.lengthSq() < 1e-24) return; group.add(new THREE.ArrowHelper(dir.normalize(), vector(origin), length, colour, Math.min(0.16, length * 0.3), Math.min(0.1, length * 0.2))); } -export function label(group: THREE.Group, text: string, position: Point, colour: string = '#dbe7f0', width = 2): THREE.Sprite { +export function label(group: THREE.Group, text: string, position: Point, colour: string = '#dbe7f0', width = 2, minViewportWidth = 0): THREE.Sprite | CSS2DObject { + if (text.includes('$')) { + const element = document.createElement('div'); + element.className = 'scene-math-label'; + element.style.color = colour; + renderProse(element, text); + const annotation = new CSS2DObject(element); + annotation.position.copy(vector(position)); + annotation.onBeforeRender = () => { + element.style.visibility = (element.parentElement?.clientWidth ?? Infinity) < minViewportWidth ? 'hidden' : 'visible'; + }; + group.add(annotation); + return annotation; + } const canvas = document.createElement('canvas'); const context = canvas.getContext('2d')!; context.font = '500 44px system-ui, sans-serif'; @@ -52,7 +67,9 @@ export function label(group: THREE.Group, text: string, position: Point, colour: if (!(camera instanceof THREE.PerspectiveCamera)) return; sprite.getWorldPosition(cameraPosition).applyMatrix4(camera.matrixWorldInverse); renderer.getSize(viewportSize); - const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * screenHeight / Math.max(1, viewportSize.y); + sprite.material.opacity = viewportSize.x < minViewportWidth ? 0 : 1; + const responsiveHeight = viewportSize.x < 600 ? screenHeight * 0.78 : screenHeight; + const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * responsiveHeight / Math.max(1, viewportSize.y); sprite.scale.set(worldHeight * aspect, worldHeight, 1); sprite.updateMatrixWorld(); }; diff --git a/src/scene/test4.ts b/src/scene/test4.ts new file mode 100644 index 0000000..97e53ee --- /dev/null +++ b/src/scene/test4.ts @@ -0,0 +1,203 @@ +import * as THREE from 'three'; +import { number, format as formatNumber, type Parameters, type TestId } from '../experiments'; +import { type Charge, type Point } from '../physics/electricField'; +import { pairEnergy } from '../physics/models'; +import { assemblyState, centralState, conservationState, motionState, movingCharge, progress, rotationState } from '../test4'; +import { arrow, box, colours, grid, label, line, sphere } from './primitives'; +import type { Animate } from './experiments'; + +const format = (value: number) => formatNumber(Math.abs(value) < 1e-10 ? 0 : value) + .replaceAll(',', '{,}').replace(/e([+-]?\d+)$/, String.raw`\times10^{$1}`); + +function charge(group: THREE.Group, position: Point, q: number, name: string, radius = 0.21) { + const colour = q === 0 ? colours.muted : q > 0 ? colours.positive : colours.negative; + const mesh = sphere(group, position, radius, colour); + label(group, String.raw`$${name} = ${q >= 0 ? '+' : '-'}${format(Math.abs(q) * 1e9)}\,\mathrm{nC}$`, { ...position, y: position.y + 0.6 }, colour, 1.2); + return mesh; +} +function pulse(mesh: THREE.Mesh): Animate { + let time = 0; + return delta => { time += delta; mesh.scale.setScalar(1 + 0.035 * Math.sin(time * 2)); }; +} +function field(group: THREE.Group, strength: number) { + if (strength === 0) return; + for (const x of [-4, -2, 0, 2, 4]) for (const y of [-1, 1, 3]) for (const z of [-2, 2]) { + arrow(group, { x, y, z }, { x: 1, y: 0, z: 0 }, 0.6, colours.field); + } + label(group, String.raw`$\vec E\to +x$`, { x: 3.8, y: 3.5, z: -2 }, colours.field, 1.6, 600); +} +function endpoint(group: THREE.Group, name: string, point: Point) { + const marker = sphere(group, point, 0.12, colours.gold); + (marker.material as THREE.MeshStandardMaterial).wireframe = true; + label(group, name, { ...point, y: point.y - 0.55 }, colours.gold, 1); +} + +function motion(group: THREE.Group, p: Parameters, angled: boolean): Animate { + grid(group, -3.5); + const m = motionState(p); + field(group, m.field); + const a = { x: -m.dx / 2, y: 0.6 - m.dy / 2, z: 0 }; + const b = { x: m.dx / 2, y: 0.6 + m.dy / 2, z: 0 }; + const curved = !angled && p.path === 'curved'; + const at = (t: number): Point => ({ x: a.x + m.dx * t, y: a.y + m.dy * t + (curved ? 2 * Math.sin(Math.PI * t) : 0), z: curved ? 1.3 * Math.sin(Math.PI * t) : 0 }); + line(group, Array.from({ length: 101 }, (_, i) => at(i / 100)), colours.gold); + if (curved) line(group, [a, b], colours.muted, 0.4); + endpoint(group, String.raw`$a:\; U=0$`, a); + endpoint(group, 'b', b); + const point = at(progress(p)); + const probe = charge(group, point, movingCharge(p), 'q_0'); + const f = movingCharge(p) * m.field; + arrow(group, { ...point, y: point.y + 0.1, z: point.z + 0.35 }, { x: f, y: 0, z: 0 }, 1, colours.gold); + if (f !== 0) label(group, String.raw`$\vec F_e$`, { x: point.x + Math.sign(f) * 0.6, y: point.y + 0.25, z: point.z + 0.65 }, colours.gold, 0.8); + const projection = { x: b.x, y: a.y, z: -0.18 }; + line(group, [{ ...a, z: -0.18 }, projection], colours.negative); + if (angled) { + line(group, [projection, { ...b, z: -0.18 }], colours.muted, 0.5); + arrow(group, { ...a, z: 0.2 }, { x: m.dx, y: m.dy, z: 0 }, m.s, colours.gold); + // The arc is measured from the +x direction even when cos alpha is negative. + const arc = Array.from({ length: 41 }, (_, i) => ({ x: a.x + 0.8 * Math.cos(m.angle * i / 40), y: a.y + 0.8 * Math.sin(m.angle * i / 40), z: 0.3 })); + line(group, arc, colours.field); + label(group, String.raw`$\alpha = ${number(p, 'angle')}^\circ$`, { x: a.x + 1, y: a.y + 0.5, z: 0.4 }, colours.field, 1.4); + } + label(group, String.raw`$\Delta x = ${format(m.dx)}\,\mathrm m$`, { x: 0, y: a.y - 1, z: 0 }, colours.negative, 2); + label(group, String.raw`$W_{ab} = ${format(m.work * 1e9)}\,\mathrm{nJ}$`, { x: 0, y: -2.7, z: 2 }, colours.gold, 2.8); + return pulse(probe); +} + +function conservation(group: THREE.Group, p: Parameters): Animate { + grid(group, -3.3); + const s = conservationState(p); + const source = { x: -3.5, y: 1.8, z: 0 }; + const point = { x: source.x + s.r, y: 1.8, z: 0 }; + charge(group, source, 3e-9, 'q'); + const probe = charge(group, point, movingCharge(p), 'q_0'); + line(group, [source, { x: 2, y: 1.8, z: 0 }], colours.muted, 0.5); + if (s.speed > 0) arrow(group, { ...point, y: 2.8 }, { x: 1, y: 0, z: 0 }, 0.8 + s.speed, colours.gold); + label(group, String.raw`$r = ${format(s.r)}\,\mathrm m,\quad v = ${format(s.speed)}\,\mathrm{m/s}$`, { x: 0, y: 3.8, z: 0 }, colours.gold, 3.5); + const baseline = -2.7; + for (const [i, [name, joules, colour]] of ([['U', s.u, colours.negative], ['K', s.kinetic, colours.gold], ['K + U', s.total, colours.field]] as [string, number, string][]).entries()) { + const x = -2.2 + i * 2.2, height = joules / s.total * 2.6; + if (height > 0.001) box(group, { x, y: baseline + height / 2, z: 1 }, { x: 0.85, y: height, z: 0.6 }, colour, 0.85); + line(group, [{ x: x - 0.55, y: baseline, z: 1 }, { x: x + 0.55, y: baseline, z: 1 }], colours.muted); + label(group, String.raw`$${name} = ${format(joules * 1e9)}\,\mathrm{nJ}$`, { x, y: baseline - 0.45, z: 1 }, colour, 1.3); + } + line(group, [{ x: -2.8, y: -0.1, z: 1 }, { x: 2.8, y: -0.1, z: 1 }], colours.field, 0.4); + label(group, 'Energy changes form · total stays constant', { x: 0, y: -4.2, z: 1 }, colours.field, 3.5); + return pulse(probe); +} + +function central(group: THREE.Group, p: Parameters): Animate { + grid(group, -4.6); + const s = centralState(p); + const source = { x: -3.3, y: 1.4, z: 0 }; + charge(group, source, s.q, 'q'); + if (s.q !== 0) for (let i = 0; i < 24; i++) { + const angle = i * Math.PI / 12; + for (const radius of [1, 2.4, 3.6]) { + const dir = { x: Math.cos(angle), y: Math.sin(angle) * 0.6, z: Math.sin(angle) * 0.8 }; + arrow(group, { x: source.x + radius * dir.x, y: source.y + radius * dir.y, z: radius * dir.z }, { x: Math.sign(s.q) * dir.x, y: Math.sign(s.q) * dir.y, z: Math.sign(s.q) * dir.z }, 0.25, colours.field); + } + } + const visibleEnd = Number.isFinite(s.rb) ? s.rb : 6; + const at = (r: number) => ({ x: source.x + r, y: source.y, z: 0 }); + line(group, [at(s.ra), at(visibleEnd)], colours.gold); + endpoint(group, String.raw`$a:\;r_a=${s.ra}\,\mathrm m$`, at(s.ra)); + if (Number.isFinite(s.rb)) endpoint(group, String.raw`$b:\;r_b=${s.rb}\,\mathrm m$`, at(s.rb)); + else { + arrow(group, at(5.6), { x: 1, y: 0, z: 0 }, 1.3, colours.gold); + label(group, String.raw`$r_b\to\infty,\quad U_b=0$`, { x: 3.5, y: 2.8, z: 0 }, colours.gold, 2.3); + } + const radius = s.ra + progress(p) * (visibleEnd - s.ra); + const probe = charge(group, at(radius), s.q0, 'q_0'); + const forceSign = Math.sign(s.q * s.q0); + arrow(group, { ...at(radius), z: 0.4 }, { x: forceSign, y: 0, z: 0 }, 0.9, colours.gold); + const y0 = -2.4, z0 = 1.5; + const plot = (r: number): Point => ({ x: source.x + r, y: y0 + THREE.MathUtils.clamp(pairEnergy(s.q, s.q0, r) * 1e9 / 30, -2.5, 2.5), z: z0 }); + line(group, Array.from({ length: 108 }, (_, i) => plot(0.65 + i * 0.05)), colours.negative); + line(group, [{ x: -3, y: y0, z: z0 }, { x: 3.6, y: y0, z: z0 }], colours.muted, 0.6); + line(group, [{ x: -3, y: y0 - 1.6, z: z0 }, { x: -3, y: y0 + 2.3, z: z0 }], colours.muted, 0.6); + sphere(group, plot(radius), 0.12, colours.gold); + label(group, String.raw`$U(r)$ · 30 nJ / height unit`, { x: 0.8, y: -0.7, z: z0 }, colours.negative, 3); + label(group, String.raw`$U = 0\to$`, { x: 3.8, y: y0 - 0.3, z: z0 }, colours.muted, 1.4); + label(group, String.raw`$r\;(\mathrm m)\to$`, { x: 2, y: -4.4, z: z0 }, colours.muted, 1.4); + return pulse(probe); +} + +function assembly(group: THREE.Group, p: Parameters) { + grid(group, -1.4); + const s = assemblyState(p); + const names = ['q_1', 'q_2', 'q_3', 'q_0']; + s.charges.forEach((q, i) => { + if (i < s.stage) charge(group, q, q.coulombs, names[i]); + else { + const ghost = sphere(group, q, 0.19, colours.muted); + const material = ghost.material as THREE.MeshStandardMaterial; + material.wireframe = true; material.transparent = true; material.opacity = 0.35; + label(group, String.raw`$${names[i]}$ · not placed`, { ...q, y: q.y + 0.6 }, colours.muted, 1.2); + } + }); + for (let i = 0; i < s.stage; i++) for (let j = i + 1; j < s.stage; j++) { + const a: Charge = s.charges[i], b = s.charges[j]; + const colour = j === s.stage - 1 ? colours.gold : colours.field; + line(group, [a, b], colour, 0.8); + const r = Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); + const pair = `${i === 3 ? '0' : i + 1}${j === 3 ? '0' : j + 1}`; + label(group, String.raw`$U_{${pair}} = ${format(pairEnergy(a.coulombs, b.coulombs, r) * 1e9)}\,\mathrm{nJ}$`, { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 - 0.3, z: (a.z + b.z) / 2 }, colour, 1); + } + label(group, `${s.stage} charges · ${s.stage * (s.stage - 1) / 2} unique pairs`, { x: 0, y: 4.5, z: 0 }, colours.field, 3); + label(group, String.raw`$U = ${format(s.current * 1e9)}\,\mathrm{nJ}$`, { x: 0, y: -1.9, z: 1 }, colours.gold, 2.4); +} + +function dipole(group: THREE.Group, p: Parameters): Animate { + grid(group, -4); + const s = rotationState(p), d = number(p, 'length'), q = number(p, 'charge') * 1e-9; + field(group, s.field); + const centre = { x: 0, y: 1.5, z: 0 }; + const at = (theta: number, sign: number) => ({ x: sign * d / 2 * Math.cos(theta), y: centre.y + sign * d / 2 * Math.sin(theta), z: 0 }); + const minus = at(s.theta, -1), plus = at(s.theta, 1); + // Initial/final orientation marks help relate the scrubber to theta_a and theta_b. + for (const [theta, name] of [[s.a, 'a'], [s.b, 'b']] as [number, string][]) { + line(group, [at(theta, -1), at(theta, 1)], colours.muted, 0.25); + const end = at(theta, 1); + label(group, String.raw`$\theta_${name} = ${format(theta * 180 / Math.PI)}^\circ$`, { x: end.x * 1.6, y: centre.y + (end.y - centre.y) * 1.6 + 0.2, z: -0.6 }, colours.muted, 1.1, 600); + } + line(group, [minus, plus], colours.muted); + charge(group, minus, -q, 'q_-'); + const positive = charge(group, plus, q, 'q_+'); + arrow(group, minus, { x: plus.x - minus.x, y: plus.y - minus.y, z: 0 }, d, colours.field); + label(group, String.raw`$\vec p_e:\; -\to+$`, { x: -1.7, y: centre.y + 0.4, z: 0.65 }, colours.field, 1.5); + if (s.field !== 0) { + arrow(group, { ...plus, z: 0.2 }, { x: 1, y: 0, z: 0 }, 1.15, colours.gold); + arrow(group, { ...minus, z: 0.2 }, { x: -1, y: 0, z: 0 }, 1.15, colours.gold); + label(group, String.raw`$\vec F_+$`, { x: plus.x + 1.1, y: plus.y + 0.2, z: 0.2 }, colours.gold, 0.8); + label(group, String.raw`$\vec F_-$`, { x: minus.x - 1.1, y: minus.y - 0.3, z: 0.2 }, colours.gold, 0.8); + } + const torqueNormalized = s.field ? s.torqueZ / (s.moment * s.field) : 0; + if (Math.abs(torqueNormalized) > 1e-6) { + arrow(group, centre, { x: 0, y: 0, z: torqueNormalized }, Math.abs(torqueNormalized) * 1.8, colours.negative); + label(group, String.raw`$\vec\tau\to -z$`, { x: 2, y: 1.7, z: -1.9 }, colours.negative, 1.3); + } + const arc = Array.from({ length: 61 }, (_, i) => ({ x: 0.7 * Math.cos(s.theta * i / 60), y: centre.y + 0.7 * Math.sin(s.theta * i / 60), z: 0.25 })); + line(group, arc, colours.gold); + label(group, String.raw`$\theta = ${format(s.theta * 180 / Math.PI)}^\circ$`, { x: 2.5, y: 3.6, z: 0 }, colours.gold, 2); + const plot = (theta: number): Point => ({ x: -3 + theta / Math.PI * 6, y: -2 - (s.field === 0 ? 0 : Math.cos(theta)), z: 1.2 }); + line(group, Array.from({ length: 101 }, (_, i) => plot(i * Math.PI / 100)), colours.negative); + line(group, [{ x: -3.3, y: -2, z: 1.2 }, { x: 3.3, y: -2, z: 1.2 }], colours.muted, 0.5); + sphere(group, plot(s.theta), 0.13, colours.gold); + for (const [x, text] of [[-3, String.raw`$0^\circ$ · stable`], [0, String.raw`$90^\circ$ · $U=0$`], [3, String.raw`$180^\circ$ · unstable`]] as [number, string][]) { + label(group, s.field === 0 ? text.split(' · ')[0] : text, { x, y: -3.9, z: 1.2 }, colours.muted, 1.4); + } + label(group, s.field ? String.raw`$\frac{U}{p_eE} = -\cos\theta$` : String.raw`$E=0,\quad U=0$ at all angles`, { x: 0, y: -0.6, z: 1.2 }, colours.negative, 2.6); + return pulse(positive); +} + +export function buildTestScene(group: THREE.Group, id: TestId, p: Parameters): Animate { + switch (id) { + case 'test-work': return motion(group, p, false); + case 'test-conservation': return conservation(group, p); + case 'test-central': return central(group, p); + case 'test-assembly': assembly(group, p); return () => {}; + case 'test-uniform': return motion(group, p, true); + case 'test-dipole': return dipole(group, p); + } +} diff --git a/src/style.css b/src/style.css index a1d6ced..1e8542b 100644 --- a/src/style.css +++ b/src/style.css @@ -9,6 +9,7 @@ --border: #2a394a; } * { box-sizing: border-box; } +[hidden] { display: none !important; } body { margin: 0; } button, select, input { font: inherit; } button, select { color: inherit; } @@ -55,6 +56,9 @@ button:hover { border-color: var(--mint); } .workbench { min-width: 0; } #viewport { position: relative; height: clamp(480px, 64dvh, 800px); min-width: 0; overflow: hidden; } #viewport > canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; } +.scene-labels { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; container-type: inline-size; } +.scene-math-label { pointer-events: none; white-space: nowrap; padding: 2px 5px; border-radius: 3px; background: #101822d9; font-size: 13px; line-height: 1.5; } +@container (max-width: 599px) { .scene-math-label { font-size: 11px; } } .scene-toolbar { position: absolute; z-index: 1; top: 20px; left: 24px; right: 24px; display: flex; justify-content: space-between; align-items: center; gap: 10px; pointer-events: none; } .view-tag { font: 10px monospace; color: #71899d; letter-spacing: 2px; } .scene-toolbar > div { display: flex; gap: 8px; } @@ -68,12 +72,81 @@ button:hover { border-color: var(--mint); } .learning-panel { display: grid; grid-template-columns: 1.3fr 1fr; gap: 36px; padding: 28px 32px; border-top: 1px solid var(--border); background: #121d29; } .learning-panel p { font-size: 13px; color: var(--muted); margin: 12px 0; } .learning-panel .eyebrow { color: var(--mint); font-size: 10px; margin: 0 0 14px; } -.equations p { color: #e3ecf3; font: 16px ui-monospace, monospace; line-height: 1.8; margin: 4px 0; } +.learning-panel > div { min-width: 0; } +.equations p { color: #e3ecf3; font-size: 16px; margin: 8px 0; } .learning-panel a { font-size: 11px; } .challenge { border-left: 1px solid var(--border); padding-left: 30px; } .challenge > p:not(.eyebrow) { color: #cad8e4; } details { margin-top: 20px; } summary { color: var(--muted); font-size: 11px; cursor: pointer; } +.collections { display: flex; gap: 5px; margin-left: auto; padding: 4px; background: #0c151f; border: 1px solid var(--border); border-radius: 8px; flex-shrink: 0; } +.collections a { color: var(--muted); text-decoration: none; font-size: 12px; padding: 10px 13px; border-radius: 5px; } +.collections a[aria-current] { background: #234139; color: #b0f3dc; } +.test-overview { display: flex; justify-content: space-between; align-items: center; gap: 32px; padding: 30px 34px; background: radial-gradient(ellipse at 80% 0%, #20413988, transparent 65%), #121d29; border-bottom: 1px solid var(--border); } +.test-overview .eyebrow { font-size: 9px; margin-bottom: 10px; } +.test-overview h2 { font-size: clamp(22px, 2.4vw, 34px); font-weight: 500; letter-spacing: -1px; margin-bottom: 8px; } +.test-overview p:last-child { font-size: 12px; color: var(--muted); margin: 0; } +.test-score { flex-shrink: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; } +.test-score strong { font: 17px ui-monospace, monospace; color: var(--mint); } +.test-score strong span { font-size: 11px; color: var(--muted); } +.test-score > span { grid-column: 1 / -1; font-size: 10px; color: var(--muted); } +[role='tab'] small { font: 10px ui-monospace, monospace; color: #729989; padding: 3px 5px; border: 1px solid #294739; border-radius: 4px; } +.test-question { padding: 14px 16px; margin: 0 0 24px; border-left: 2px solid #708b7e; background: #1b2d35; border-radius: 0 7px 7px 0; } +.test-question p { font-size: 12px; line-height: 1.75; margin: 9px 0 0; color: #bdcbd5; } +.test-question .question-lead { margin-top: 0; color: #eef3ed; } +.observation { padding: 18px 30px; background: #192e31; border-top: 1px solid #2c4947; border-bottom: 1px solid #2c4947; } +.observation .eyebrow { font-size: 9px; } +.observation p { margin: 8px 0 0; font-size: 13px; line-height: 1.75; color: #d1e3df; } +.test-study { padding: 30px 32px; background: #101b26; border-top: 1px solid var(--border); } +.solution-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; } +.solution-heading .eyebrow { margin-bottom: 8px; } +.solution-heading h3 { font-weight: 500; font-size: 23px; margin: 0; letter-spacing: -0.5px; } +.solution-heading a { font-size: 11px; } +.solution details { border: 1px solid #314555; border-radius: 8px; background: #152331; margin: 12px 0; overflow: hidden; } +.solution summary { font-size: 13px; color: #d0e1e8; padding: 17px 20px; line-height: 1.6; } +.solution details[open] summary { color: var(--mint); border-bottom: 1px solid #314555; } +.derivation { margin: 0; padding: 8px 24px 20px 52px; counter-reset: step; } +.derivation li { padding: 8px 0 0 6px; color: var(--mint); } +.derivation li::marker { font: 13px ui-monospace, monospace; } +.derivation p { font-size: 13px !important; color: #a9becb; line-height: 1.8; margin: 8px 0; } +.derivation .math-step { font-size: 15px !important; color: #edf4ef; background: #0d1924; border-radius: 5px; padding: 12px 14px; } +.prediction { padding: 24px; margin-top: 24px; border: 1px solid #5b5138; border-radius: 8px; background: #262a28; } +.prediction .eyebrow { color: var(--gold, #f5d58b); } +.prediction h3 { font-size: 16px; font-weight: 500; line-height: 1.65; margin: 0 0 16px; } +.prediction button { background: transparent; border: 1px solid #8c805b; border-radius: 5px; color: #f5d58b; padding: 9px 13px; font-size: 12px; } +.prediction > p:last-child { font-size: 13px; color: #d3d6c6; line-height: 1.8; margin: 18px 0 0; } +.lesson-pagination { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; margin-top: 30px; } +.lesson-pagination a { font-size: 12px; text-decoration: none; } +.lesson-pagination a:first-child { grid-column: 1; } +.lesson-pagination span { grid-column: 2; font: 11px ui-monospace, monospace; color: var(--muted); } +.lesson-pagination a:last-child { grid-column: 3; text-align: right; } +.math-display { max-width: 100%; min-width: 0; overflow-x: auto; overflow-y: hidden; padding: 6px 2px; scrollbar-width: thin; scrollbar-color: #526e7c transparent; } +.math-display .katex-display { text-align: left; margin: 0.5em 0; } +.math-display .katex-display > .katex { text-align: left; } +.katex { font-size: 1.08em; } +.math-display:focus-visible { outline: 2px solid var(--mint); outline-offset: 3px; } +@media (max-width: 850px) { + .app-header { flex-wrap: wrap; gap: 16px; } + .collections { margin-left: auto; } + .header-title { flex: 1; } + .test-overview { padding: 25px 22px; gap: 20px; } + .test-score { gap: 8px 14px; } +} +@media (max-width: 600px) { + .app-header { padding: 16px 20px; } + .collections { width: 100%; margin: 0; } + .collections a { flex: 1; text-align: center; } + .test-overview { align-items: flex-start; flex-direction: column; gap: 18px; padding: 24px 20px; } + .test-score { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; } + .test-score > span { font-size: 9px; } + .observation { padding: 18px 20px; } + .test-study { padding: 26px 20px; } + .solution-heading { align-items: flex-start; flex-direction: column; } + .derivation { padding-left: 35px; padding-right: 14px; } + .derivation .math-step { font-size: 13px !important; padding: 10px; } + .prediction { padding: 19px; } + .lesson-pagination a { font-size: 11px; } +} details p { font-size: 11px !important; } .webgl-error { position: absolute; z-index: 2; top: 140px; left: 12%; right: 12%; padding: 24px; background: #1e2937; border: 1px solid #ff967b; border-radius: 8px; color: #ffb79f; font-size: 14px; line-height: 1.7; } @media (min-width: 1600px) { main { grid-template-columns: 340px minmax(0, 1fr); } .controls-panel { padding: 32px; } } diff --git a/src/test4.ts b/src/test4.ts new file mode 100644 index 0000000..9bd32d8 --- /dev/null +++ b/src/test4.ts @@ -0,0 +1,232 @@ +import { format, number, type Choice, type Experiment, type Measurement, type Parameters, type Slider, type TestId } from './experiments'; +import { type Charge } from './physics/electricField'; +import { pairEnergy } from './physics/models'; +import { assembledEnergy, centralWork, dipoleState, energyTransfer, uniformWork } from './physics/test4'; +import questionUrl from '../docs/Test_4.md?url'; +import answerUrl from '../docs/Test_Answers.md?url'; + +export { answerUrl }; +export interface AnswerPart { title: string; steps: { why: string; math?: string }[] } +export interface TestLesson extends Experiment { + id: TestId; + points: number; + section: '4A' | '4B'; + questions: string[]; + answers: AnswerPart[]; + prediction: string; + predictionAnswer: string; + observe: (p: Parameters) => string; +} +const slider = (id: string, label: string, min: number, max: number, step: number, value: number, unit: string): Slider => ({ kind: 'range', id, label, min, max, step, value, unit }); +const choice = (id: string, label: string, value: string, options: [string, string][]): Choice => ({ kind: 'select', id, label, value, options: options.map(([value, label]) => ({ value, label })) }); +const metric = (label: string, value: number, unit = 'nJ'): Measurement => ({ label, value: format(Math.abs(value) < 1e-10 ? 0 : value), unit }); +const energy = (label: string, joules: number) => metric(label, joules * 1e9); +const radians = (degrees: number) => degrees * Math.PI / 180; +export const progress = (p: Parameters) => number(p, 'progress') / 100; +export const movingCharge = (p: Parameters) => number(p, 'q0') * 1e-9; +export function motionState(p: Parameters) { + const field = number(p, 'field'); + const s = number(p, 'distance'); + const angle = radians(number(p, 'angle') || 0); + const work = uniformWork(movingCharge(p), field, s, angle); + return { field, s, angle, work, dx: s * Math.cos(angle), dy: s * Math.sin(angle) }; +} +export function centralState(p: Parameters) { + const q = number(p, 'source') * 1e-9, q0 = movingCharge(p); + const ra = number(p, 'ra'); + const rb = p.destination === 'infinity' ? Infinity : number(p, 'rb'); + return { q, q0, ra, rb, ua: pairEnergy(q, q0, ra), ub: rb === Infinity ? 0 : pairEnergy(q, q0, rb), work: centralWork(q, q0, ra, rb) }; +} +export function conservationState(p: Parameters) { + const r = 1.2 + progress(p) * 4.3; + const q0 = movingCharge(p); + const ua = pairEnergy(3e-9, q0, 1.2), u = pairEnergy(3e-9, q0, r); + return { r, ua, u, ...energyTransfer(ua, u, number(p, 'kinetic') * 1e-9, 1e-6) }; +} +export function assemblyCharges(p: Parameters): Charge[] { + const s = number(p, 'spacing'); + return [ + { x: -s / 2, y: 0, z: -s / 3, coulombs: 3e-9 }, + { x: s / 2, y: 0, z: -s / 3, coulombs: -2e-9 }, + { x: 0, y: 0, z: s * 0.6, coulombs: 2e-9 }, + { x: 0, y: s * 0.8, z: 0, coulombs: movingCharge(p) }, + ]; +} +export function assemblyState(p: Parameters) { + const charges = assemblyCharges(p), stage = Number(p.stage); + const base = assembledEnergy(charges.slice(0, 3)); + const total = assembledEnergy(charges); + return { charges, stage, base, extra: total - base, current: assembledEnergy(charges.slice(0, stage)) }; +} +export function rotationState(p: Parameters) { + const a = radians(number(p, 'thetaA')), b = radians(number(p, 'thetaB')); + const theta = a + (b - a) * progress(p); + const moment = number(p, 'charge') * 1e-9 * number(p, 'length'); + const field = number(p, 'field'); + return { a, b, theta, moment, field, ...dipoleState(moment, field, theta, a), endpointWork: dipoleState(moment, field, b, a).work }; +} +const common = { + source: { title: 'Original question · Test_4.md', url: questionUrl }, + legend: [['#ff967b', 'Positive charge'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Electric field E'], ['#f5d58b', 'Path / force / energy']] as [string, string][], +}; +const motionControls = [slider('q0', 'Moving charge q₀', -4, 4, 0.5, 2, 'nC'), slider('field', 'Electric field E', 0, 80, 5, 30, 'V/m'), slider('distance', 'Displacement s', 1, 6, 0.25, 4, 'm')]; +const journey = () => slider('progress', 'Journey progress', 0, 100, 1, 35, '%'); + +export const testLessons: TestLesson[] = [ + { + ...common, id: 'test-work', title: 'Work along a path', shortTitle: 'Work & paths', points: 30, section: '4A', + description: 'Follow a charge from a to b. Only the component of force along each tiny step contributes to work.', + questions: [String.raw`What is the work done by the electric force, when $q_0$ is moved from a to b?`], + controls: [...motionControls, choice('path', 'Path shape', 'straight', [['straight', 'Straight path'], ['curved', 'Curved path · same endpoints']]), journey()], + equations: [String.raw`W_{ab} = \int_a^b \vec F_e\cdot\mathrm d\vec r = q_0\int_a^b \vec E\cdot\mathrm d\vec r`, String.raw`W_{ab} = U_a-U_b = q_0(V_a-V_b)`], + explanation: 'The dot product measures how much the electric force helps or opposes each step. Electrostatic work depends on the endpoints. Positive field work means a drop in electric potential energy.', + challenge: 'Switch between the two paths. Predict the final work before changing the moving charge from positive to negative.', + prediction: 'Does the longer, curved route require more work by the electric field?', + predictionAnswer: String.raw`No. In an electrostatic field, $W=U_a-U_b$. Both routes start and finish at the same positions. Sideways steps are perpendicular to $E$ and contribute no work. Any extra motion along $E$ cancels the motion back against $E$.`, + sceneNote: String.raw`Example: uniform $E$ along +x. Green arrows are $E$; the gold arrow is electric force. Both paths share a and b.`, + limits: String.raw`Electrostatic vacuum model; prescribed path, not a free-particle trajectory. Geometry is in metres; field and force arrows show direction with compressed lengths. The gold probe is scrubbed by journey progress; dotted endpoint markers stay fixed. Choose $U_a=0$.`, + measure: p => { const m = motionState(p); return [energy('Field work a → b', m.work), energy('Work to current position', m.work * progress(p)), energy('Potential energy Uᵦ', -m.work)]; }, + observe: p => String.raw`${p.path === 'curved' ? 'The curved route adds sideways motion, but the final work stays the same.' : 'The straight route makes the force–displacement dot product easy to see.'} At b, the field does ${format(motionState(p).work * 1e9)} nJ of work; $U$ drops by that amount.`, + answers: [{ title: 'Question 1 · Work by the electric force · 30p', steps: [ + { why: 'Start with the definition of work: add the component of force along every small displacement.', math: String.raw`W_{ab} = \int_a^b\vec F_e\cdot\mathrm d\vec r` }, + { why: String.raw`Substitute the electric force on the moving charge, $\vec F_e=q_0\vec E$.`, math: String.raw`W_{ab} = q_0\int_a^b\vec E\cdot\mathrm d\vec r` }, + { why: 'The electrostatic force is conservative, so field work is the decrease in potential energy.', math: String.raw`W_{ab} = U_a-U_b = q_0(V_a-V_b)` }, + ] }], + }, + { + ...common, id: 'test-conservation', title: 'Where does the energy go?', shortTitle: 'Conservation', points: 15, section: '4A', + description: 'A positive charge moves away from a fixed positive source. Watch potential energy become kinetic energy.', + questions: ['Please write the condition for conservation of the total energy.'], + controls: [slider('q0', 'Moving charge q₀', 0.5, 4, 0.5, 2, 'nC'), slider('kinetic', 'Initial kinetic energy Kₐ', 0, 40, 1, 0, 'nJ'), journey()], + equations: [String.raw`\Delta E_{\mathrm{total}} = 0\quad\text{(isolated system)}`, String.raw`K_a+U_a = K_b+U_b`, String.raw`\Delta K = W_{ab} = -\Delta U`], + explanation: 'Total energy is conserved for an isolated system. If only the electric force does work on the moving charge, kinetic plus electric potential energy stays constant. The source is fixed; its support does no work.', + challenge: String.raw`Move the journey slider from 0% to 100%. Check that the total bar stays the same height as $K$ rises and $U$ falls.`, + prediction: 'If the particle starts at rest, can it gain speed without an energy supply?', + predictionAnswer: String.raw`Yes. Its initial electric potential energy supplies the increase in kinetic energy. Energy changes form while $K+U$ stays constant. The particle and source together are the electrostatic system; the moving particle alone does not have constant kinetic energy.`, + sceneNote: String.raw`Free radial motion away from +3 nC. Bars show $U$, $K$ and their constant sum; mass = 1 mg.`, + limits: String.raw`Source fixed, vacuum, nonrelativistic point particle of mass 1 mg. $U(\infty)=0$. Journey progress selects a position, not elapsed time; the speed is computed from energy, not from slider speed. Bar heights are normalized to the total energy; readouts are in nJ.`, + measure: p => { const s = conservationState(p); return [energy('Potential energy U', s.u), energy('Kinetic energy K', s.kinetic), energy('Total K + U', s.total), metric('Particle speed', s.speed, 'm/s')]; }, + observe: p => { const s = conservationState(p); return String.raw`At $r$ = ${format(s.r)} m, $U$ = ${format(s.u * 1e9)} nJ and $K$ = ${format(s.kinetic * 1e9)} nJ. Their sum stays ${format(s.total * 1e9)} nJ. The work done so far is the increase in $K$.`; }, + answers: [{ title: 'Question 2 · Conservation condition · 15p', steps: [ + { why: 'For an isolated system, no net energy crosses the system boundary.', math: String.raw`\Delta E_{\mathrm{total}} = 0` }, + { why: 'If the electric force is the only force doing work, the mechanical energy is conserved.', math: String.raw`K_a+U_a = K_b+U_b` }, + { why: String.raw`For a particle of mass $m$, substitute kinetic energy. The work–energy theorem connects the two changes.`, math: String.raw`\begin{aligned}\frac12 mv_a^2+U_a &= \frac12 mv_b^2+U_b\\\Delta K &= W_{ab} = -\Delta U\end{aligned}` }, + ] }], + }, + { + ...common, id: 'test-central', title: 'The field of a point charge', shortTitle: 'Central field', points: 55, section: '4A', + description: String.raw`Move $q_0$ radially through a central field. Connect the inverse-square force to an inverse-distance potential energy.`, + questions: [String.raw`When $q_0$ moves from a to b in an $E$-field from a point charge $q$ (“Central field”).`, 'a. (30p) Please derive the work done by the electric force.', String.raw`b. (25p) The work above is equal to the drop $U_a$ − $U_b$. What are $U_a$ and $U_b$ if $b\to\infty$?`], + controls: [slider('source', 'Source charge q', -4, 4, 0.5, 3, 'nC'), slider('q0', 'Moving charge q₀', -4, 4, 0.5, 2, 'nC'), slider('ra', 'Initial distance rₐ', 0.75, 4, 0.25, 1.5, 'm'), slider('rb', 'Final distance rᵦ', 0.75, 6, 0.25, 4.5, 'm'), choice('destination', 'Destination b', 'finite', [['finite', 'Finite final distance'], ['infinity', 'Infinity · final energy is zero']]), journey()], + equations: [String.raw`\vec E(r) = \frac{kq}{r^2}\hat r`, String.raw`W_{ab} = kqq_0\left(\frac1{r_a}-\frac1{r_b}\right)`, String.raw`U(r) = \frac{kqq_0}{r},\qquad U(\infty) = 0`], + explanation: String.raw`Only radial displacement contributes to work in a central field. Integrating $\frac1{r^2}$ gives $-\frac1r$. The zero at infinity fixes the energy reference; it is not an additional force law.`, + challenge: String.raw`Compare like and opposite charges. Send b to infinity and check that $W_{a\infty}=U_a$, including its sign.`, + prediction: String.raw`Is $U$ always positive when we choose $U(\infty)=0$?`, + predictionAnswer: String.raw`No. $U=\frac{kqq_0}{r}$ is positive for like charges and negative for opposite charges. A negative value means the configuration has less energy than the separated charges at infinity. Work by the field in separating opposite charges is negative.`, + sceneNote: String.raw`Radial path above; $U(r)$ curve below. Infinity mode shows an outward continuation, not a finite stand-in for infinity.`, + limits: String.raw`Fixed point source; vacuum Coulomb constant $k=\frac1{4\pi\varepsilon_0}$. Distances in metres. $U$ graph: 30 nJ per vertical scene unit, clipped outside ±2.5 units. Infinity has exactly $U_b=0$ analytically; the visible path stops at the edge of the diagram and does not locate b at a finite radius.`, + measure: p => { const s = centralState(p); return [energy('Potential energy Uₐ', s.ua), energy('Potential energy Uᵦ', s.ub), energy('Field work a → b', s.work)]; }, + observe: p => { const s = centralState(p); return s.rb === Infinity ? String.raw`At infinity, $U_b=0$ exactly. $W_{a\infty}=U_a$ = ${format(s.work * 1e9)} nJ. The diagram’s edge is only a visual continuation toward infinity.` : String.raw`The field does ${format(s.work * 1e9)} nJ of work from $r_a$ = ${s.ra} m to $r_b$ = ${s.rb} m. Switching the sign of either charge reverses $U$ and $W$.`; }, + answers: [ + { title: '3a · Derive the radial work · 30p', steps: [ + { why: 'Write the radial field and force of the fixed point charge.', math: String.raw`\vec E = \frac{kq}{r^2}\hat r,\qquad \vec F_e = \frac{kqq_0}{r^2}\hat r` }, + { why: String.raw`The radial component of the displacement is $\hat r\cdot\mathrm d\vec r=\mathrm dr$. Tangential steps do no work.`, math: String.raw`W_{ab} = kqq_0\int_{r_a}^{r_b}\frac1{r^2}\,\mathrm dr` }, + { why: 'Integrate and evaluate at the endpoints. The result depends only on their distances.', math: String.raw`W_{ab} = kqq_0\left[-\frac1r\right]_{r_a}^{r_b} = kqq_0\left(\frac1{r_a}-\frac1{r_b}\right)` }, + ] }, + { title: '3b · Set the zero at infinity · 25p', steps: [ + { why: String.raw`Choose $U(\infty)=0$. As $r_b\to\infty$, the reciprocal distance goes to zero.`, math: String.raw`W_{a\infty} = \frac{kqq_0}{r_a}` }, + { why: String.raw`Use $W_{a\infty}=U_a-U_\infty$ to identify the two endpoint energies.`, math: String.raw`U_a = \frac{kqq_0}{r_a},\qquad U_b = U_\infty = 0` }, + { why: String.raw`This gives the general interaction energy. Its sign is the sign of $qq_0$.`, math: String.raw`U(r) = \frac{kqq_0}{r}` }, + ] }, + ], + }, + { + ...common, id: 'test-assembly', title: 'Build a system of charges', shortTitle: 'Charge assembly', points: 25, section: '4B', + description: 'Bring charges in one at a time. Every connecting edge is one interaction energy, counted exactly once.', + questions: ['Bringing charges together.', String.raw`a. (15p) What is the total electric potential energy if $q_1$, $q_2$, $q_3$ are brought together from infinity?`, String.raw`b. (10p) What is the additional electric potential energy if $q_0$ is brought together with $q_1$, $q_2$, $q_3$?`], + controls: [choice('stage', 'Assembly stage', '3', [['1', '1 · Place first charge'], ['2', '2 · Add second charge'], ['3', '3 · Add third charge'], ['4', '4 · Add test charge']]), slider('spacing', 'Configuration size', 2, 5, 0.25, 3.5, 'm'), slider('q0', 'Added charge q₀', -4, 4, 0.5, 2, 'nC')], + equations: [String.raw`U_{123} = k\left(\frac{q_1q_2}{r_{12}}+\frac{q_1q_3}{r_{13}}+\frac{q_2q_3}{r_{23}}\right)`, String.raw`\Delta U_0 = k\left(\frac{q_0q_1}{r_{01}}+\frac{q_0q_2}{r_{02}}+\frac{q_0q_3}{r_{03}}\right)`, String.raw`\Delta U_0 = q_0V_{123}`], + explanation: String.raw`Place $q_1$ for free. Adding $q_2$ creates one pair; adding $q_3$ creates two more. The fourth charge creates three new pairs without changing the energy of the existing three. For quasistatic assembly, external work equals the increase in $U$.`, + challenge: String.raw`Step through all four assembly stages. Then reverse $q_0$ and watch the sign of its three new interactions reverse.`, + prediction: String.raw`Should the total energy contain both $\frac{q_1q_2}{r_{12}}$ and $\frac{q_2q_1}{r_{21}}$?`, + predictionAnswer: String.raw`No. Those describe the same pair. Sum only over $i { const s = assemblyState(p); return [energy('Energy of current assembly', s.current), energy('Three-charge energy U₁₂₃', s.base), energy('Additional energy ΔU₀', s.extra), metric('Pairs currently assembled', s.stage * (s.stage - 1) / 2, 'pairs')]; }, + observe: p => { const s = assemblyState(p); return String.raw`Stage ${s.stage}: ${s.stage * (s.stage - 1) / 2} distinct pairs are present. $U_{123}$ = ${format(s.base * 1e9)} nJ; adding $q_0$ changes it by ${format(s.extra * 1e9)} nJ. Negative pair energy indicates opposite signs.`; }, + answers: [ + { title: '4a · Assemble q₁, q₂, q₃ · 15p', steps: [ + { why: String.raw`Bring $q_1$ from infinity first. With no other charges present, its interaction energy is zero.`, math: String.raw`U_1 = 0` }, + { why: String.raw`Bring in $q_2$ against the potential due to $q_1$; then bring $q_3$ against the potential due to $q_1$ and $q_2$.`, math: String.raw`\begin{aligned}\Delta U_2 &= \frac{kq_1q_2}{r_{12}}\\\Delta U_3 &= \frac{kq_1q_3}{r_{13}}+\frac{kq_2q_3}{r_{23}}\end{aligned}` }, + { why: 'Add the contributions, counting each pair once.', math: String.raw`U_{123} = k\left(\frac{q_1q_2}{r_{12}}+\frac{q_1q_3}{r_{13}}+\frac{q_2q_3}{r_{23}}\right)` }, + ] }, + { title: '4b · Add q₀ to the fixed system · 10p', steps: [ + { why: String.raw`The existing three charges are held fixed. Add only the three new interactions involving $q_0$.`, math: String.raw`\Delta U_0 = k\left(\frac{q_0q_1}{r_{01}}+\frac{q_0q_2}{r_{02}}+\frac{q_0q_3}{r_{03}}\right)` }, + { why: String.raw`Factor out $q_0$. The remaining sum is the potential of the original three charges at the new position.`, math: String.raw`\begin{aligned}V_{123} &= k\left(\frac{q_1}{r_{01}}+\frac{q_2}{r_{02}}+\frac{q_3}{r_{03}}\right)\\\Delta U_0 &= q_0V_{123}\end{aligned}` }, + ] }, + ], + }, + { + ...common, id: 'test-uniform', title: 'Work in a uniform field', shortTitle: 'Uniform field', points: 25, section: '4B', + description: String.raw`Rotate the displacement relative to the field. The projection along $E$ sets the work, not the full path length.`, + questions: [String.raw`When $q_0$ moves from a to b in a uniform $E$-field.`, 'a. (15p) Please derive the work done by the electric force.', String.raw`b. (10p) What are $U_{\mathrm{pot},a}$ and $U_{\mathrm{pot},b}$?`], + controls: [...motionControls, slider('angle', 'Displacement angle α', 0, 180, 5, 35, '°'), journey()], + equations: [String.raw`W_{ab} = q_0\vec E\cdot(\vec r_b-\vec r_a) = q_0Es\cos\alpha`, String.raw`W_{ab} = q_0E(x_b-x_a)`, String.raw`U(x) = -q_0Ex+C`], + explanation: String.raw`A constant field can be taken outside the work integral. With x along $E$, the potential energy changes only with x. The energy zero is arbitrary; here a is the reference, so $U_a=0$.`, + challenge: String.raw`Set $\alpha$ to $90^\circ$. The charge travels a nonzero distance, but $E$ is perpendicular to every step: work is zero.`, + prediction: String.raw`If $\alpha$ exceeds $90^\circ$ for a positive charge, does the field give energy to the charge?`, + predictionAnswer: String.raw`The field does negative work because $\cos\alpha<0$. $U$ increases as the charge moves against the field. An external agent would need to supply that energy to move it slowly, or a freely moving particle would lose kinetic energy.`, + sceneNote: String.raw`Displacement is in the xy plane; $E$ points along +x. The blue segment is $\Delta x=s\cos\alpha$. Choose $U_a=0$.`, + limits: String.raw`Ideal constant field with no edge effects. Geometry in metres; arrows show direction with compressed lengths. Prescribed motion, not a free-particle trajectory. $U_a=0$ fixes $C=q_0Ex_a$; $x_a$ is shown explicitly by the a marker.`, + measure: p => { const m = motionState(p); return [energy('Field work a → b', m.work), metric('Projection Δx', m.dx, 'm'), energy('Potential energy Uₐ', 0), energy('Potential energy Uᵦ', -m.work)]; }, + observe: p => { const m = motionState(p); return String.raw`$\alpha = ${number(p, 'angle')}^\circ$ projects $s$ = ${m.s} m onto $\Delta x$ = ${format(m.dx)} m. $W$ = ${format(m.work * 1e9)} nJ and $U_b$ = ${format(-m.work * 1e9)} nJ with $U_a=0$.`; }, + answers: [ + { title: '5a · Derive uniform-field work · 15p', steps: [ + { why: String.raw`Start with the work integral. The field is constant, so take $\vec E$ outside the integral.`, math: String.raw`W_{ab} = q_0\int_a^b\vec E\cdot\mathrm d\vec r = q_0\vec E\cdot(\vec r_b-\vec r_a)` }, + { why: String.raw`Write the dot product using the displacement length and angle $\alpha$.`, math: String.raw`W_{ab} = q_0Es\cos\alpha` }, + { why: 'Choose the x-axis along the field. Only the x-displacement contributes.', math: String.raw`W_{ab} = q_0E(x_b-x_a)` }, + ] }, + { title: '5b · Endpoint potential energies · 10p', steps: [ + { why: 'The change in potential energy is the negative of the field work.', math: String.raw`U_b-U_a = -q_0E(x_b-x_a)` }, + { why: String.raw`Integrating with respect to x leaves an arbitrary reference constant $C$.`, math: String.raw`U_a = -q_0Ex_a+C,\qquad U_b = -q_0Ex_b+C` }, + { why: String.raw`A convenient choice is $U_a=0$. It changes no forces or energy differences.`, math: String.raw`U_a = 0,\qquad U_b = -q_0E(x_b-x_a)` }, + ] }, + ], + }, + { + ...common, id: 'test-dipole', title: 'Turn a dipole in a field', shortTitle: 'Dipole rotation', points: 50, section: '4B', + description: 'Two equal and opposite forces cancel in translation, yet form a torque. Rotate the dipole to explore work and stability.', + questions: [String.raw`The electric dipole in a uniform $E$-field.`, String.raw`a. (10p) Derive the torque vector and prove $\tau=p_eE\sin\theta$, where $\theta=\angle(\vec p_e,\vec E)$.`, String.raw`b. (30p) Derive $W_{ab}$ as the dipole rotates from $\theta_a$ to $\theta_b$. What are $U_a$ and $U_b$? You do not have to derive $\mathrm dW=-\tau\,\mathrm d\theta$.`, String.raw`c. (10p) Explain why $\theta_a$ = $90^\circ$ can be a reference, give $U_a$, and find the general $U$ also as a scalar product.`], + controls: [slider('charge', 'Dipole charge magnitude q', 0.5, 4, 0.5, 2, 'nC'), slider('length', 'Charge separation d', 0.5, 3, 0.25, 2, 'm'), slider('field', 'Electric field E', 0, 80, 5, 30, 'V/m'), slider('thetaA', 'Initial angle θₐ', 0, 180, 5, 90, '°'), slider('thetaB', 'Final angle θᵦ', 0, 180, 5, 15, '°'), slider('progress', 'Rotation progress', 0, 100, 1, 50, '%')], + equations: [String.raw`\vec p_e = q\vec d,\qquad \vec\tau = \vec p_e\times\vec E,\qquad |\vec\tau| = p_e E\sin\theta`, String.raw`W_{ab} = p_e E(\cos\theta_b-\cos\theta_a)`, String.raw`U(\theta) = -p_e E\cos\theta = -\vec p_e\cdot\vec E`], + explanation: String.raw`The dipole moment points from $-q$ to $+q$. The equal and opposite electric forces create a couple that tends to align $\vec p_e$ with $\vec E$. We use $U(90^\circ)=0$; alignment is the minimum energy state.`, + challenge: String.raw`Set the final angle to $0^\circ$, then $180^\circ$, with rotation progress at 100%. Both have zero torque. Compare their energy and stability.`, + prediction: String.raw`If the torque is zero at both $0^\circ$ and $180^\circ$, are both orientations stable?`, + predictionAnswer: String.raw`No. At $0^\circ$, $U=-p_eE$ is a minimum: a small angular displacement produces a restoring torque. At $180^\circ$, $U=+p_eE$ is a maximum: a small displacement drives the dipole farther away. At $90^\circ$, $U=0$ by convention but torque is largest, so this is not equilibrium. Without damping, a freely rotating dipole can oscillate about alignment.`, + sceneNote: String.raw`$E$ along $+x$; $\vec p_e$ points from − to +. Gold arrows show forces; the blue z-arrow shows signed torque. $U(\theta)$ below.`, + limits: String.raw`Ideal rigid dipole with fixed centre in a uniform field. Geometry in metres. No net force. Angle measured counterclockwise in xy, so $\tau_z=-p_eE\sin\theta$. The slider prescribes orientation, not a damped dynamical trajectory. Torque arrow length and graph height are normalized; readouts give magnitudes in SI-derived units. $U(90^\circ)=0$.`, + measure: p => { const s = rotationState(p); return [metric('Current angle θ', s.theta * 180 / Math.PI, '°'), metric('Torque τz', s.torqueZ * 1e9, 'nN·m'), energy('Potential energy U', s.energy), energy('Field work a → b', s.endpointWork), energy('Work to current angle', s.work)]; }, + observe: p => { const s = rotationState(p); const degrees = s.theta * 180 / Math.PI; return s.field === 0 ? String.raw`$E=0$: both forces, torque, rotation work and dipole interaction energy vanish. There is no preferred orientation.` : degrees < 0.01 ? String.raw`Aligned at $0^\circ$: $U$ is minimum and torque is zero. This is stable equilibrium; a small rotation produces a restoring torque.` : degrees > 179.99 ? String.raw`Opposed at $180^\circ$: $U$ is maximum and torque is zero. This is unstable equilibrium; a small rotation takes it away from this position.` : String.raw`At $\theta = ${format(degrees)}^\circ$, $\tau_z$ = ${format(s.torqueZ * 1e9)} nN·m. The negative z direction reduces $\theta$ toward alignment. ${Math.abs(degrees - 90) < 0.01 ? String.raw`At $90^\circ$, $U=0$ by choice, but the torque is maximum: this is not equilibrium.` : 'Follow the point on the energy curve as you rotate.'}`; }, + answers: [ + { title: '6a · Torque vector and magnitude · 10p', steps: [ + { why: String.raw`Let $\vec d$ point from the negative charge to the positive charge. Define the dipole moment.`, math: String.raw`\begin{aligned}\vec p_e &= q\vec d\\\vec F_+ &= q\vec E,\quad \vec F_- = -q\vec E\\\vec F_{\mathrm{net}} &= 0\end{aligned}` }, + { why: String.raw`Take moments about the midpoint. The two lever arms are $\pm\frac{\vec d}{2}$.`, math: String.raw`\vec\tau = \frac{\vec d}{2}\times q\vec E+\left(-\frac{\vec d}{2}\right)\times(-q\vec E) = q\vec d\times\vec E` }, + { why: 'The cross product gives the vector direction and magnitude. The two forces form a couple.', math: String.raw`\vec\tau = \vec p_e\times\vec E,\qquad |\vec\tau| = p_e E\sin\theta` }, + ] }, + { title: '6b · Rotation work and endpoint energies · 30p', steps: [ + { why: String.raw`The torque acts toward decreasing $\theta$. With $\tau$ as its positive magnitude, use the supplied relation.`, math: String.raw`\mathrm dW = -\tau\,\mathrm d\theta = -p_e E\sin\theta\,\mathrm d\theta` }, + { why: 'Integrate from the initial angle to the final angle.', math: String.raw`W_{ab} = -p_e E\int_{\theta_a}^{\theta_b}\sin\theta\,\mathrm d\theta = p_e E(\cos\theta_b-\cos\theta_a)` }, + { why: String.raw`Use $W_{ab}=U_a-U_b$. The reference constant is the same at both endpoints.`, math: String.raw`\begin{aligned}U_a &= -p_e E\cos\theta_a+C\\U_b &= -p_e E\cos\theta_b+C\end{aligned}` }, + ] }, + { title: '6c · Reference at 90° and scalar product · 10p', steps: [ + { why: String.raw`The zero of potential energy is arbitrary. At $90^\circ$ the two charges are at the same electric potential, so $+qV$ and $-qV$ cancel.`, math: String.raw`U_a = U(90^\circ) = 0` }, + { why: String.raw`Since $\cos90^\circ=0$, this reference sets $C=0$.`, math: String.raw`U(\theta) = -p_e E\cos\theta = -\vec p_e\cdot\vec E` }, + { why: 'Aligned is a stable energy minimum; opposed is an unstable maximum. The perpendicular reference is not an equilibrium.', math: String.raw`\begin{aligned}U(0^\circ) &= -p_e E\\U(90^\circ) &= 0\\U(180^\circ) &= +p_e E\end{aligned}` }, + ] }, + ], + }, +]; + +export function getTestLesson(experiment: Experiment): TestLesson | undefined { + return testLessons.find(lesson => lesson.id === experiment.id); +} -- cgit v1.2.3