diff options
| -rw-r--r-- | .vscode/settings.json | 3 | ||||
| -rw-r--r-- | README.md | 30 | ||||
| -rw-r--r-- | docs/Test_4.md | 63 | ||||
| -rw-r--r-- | docs/Test_Answers.md | 361 | ||||
| -rw-r--r-- | docs/implementation-progress.md | 52 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-10-24-test4.md | 59 | ||||
| -rw-r--r-- | index.html | 3 | ||||
| -rw-r--r-- | package-lock.json | 31 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | src/experiments.ts | 61 | ||||
| -rw-r--r-- | src/main.ts | 147 | ||||
| -rw-r--r-- | src/math.ts | 42 | ||||
| -rw-r--r-- | src/physics/test4.test.ts | 42 | ||||
| -rw-r--r-- | src/physics/test4.ts | 36 | ||||
| -rw-r--r-- | src/scene/experiments.ts | 22 | ||||
| -rw-r--r-- | src/scene/lab.ts | 24 | ||||
| -rw-r--r-- | src/scene/primitives.ts | 21 | ||||
| -rw-r--r-- | src/scene/test4.ts | 203 | ||||
| -rw-r--r-- | src/style.css | 75 | ||||
| -rw-r--r-- | src/test4.ts | 232 | ||||
| -rw-r--r-- | tests/lab.spec.ts | 2 | ||||
| -rw-r--r-- | tests/math.spec.ts | 65 | ||||
| -rw-r--r-- | tests/test4.spec.ts | 118 | ||||
| -rw-r--r-- | vite.config.ts | 2 |
24 files changed, 1570 insertions, 126 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e48a724 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "markdown.math.enabled": true +} @@ -30,6 +30,36 @@ For a fresh checkout with a lockfile, use `npm ci` for repeatable installs. ## Eight experiments from PLAN.md +The app now opens with **Test 4 in 3D**, a guided study collection based on +`docs/Test_4.md` and `docs/Test_Answers.md`. Select **All experiments** to +return to the original eight subjects. All previous subject links work. + +| Test question | 3D learning activity | URL fragment | +| --- | --- | --- | +| 1 · Work by the electric force (30p) | Compare straight and curved paths with the same endpoints | `#test-work` | +| 2 · Energy conservation (15p) | Exchange potential and kinetic energy while keeping the total constant | `#test-conservation` | +| 3 · Central field (55p) | Radial motion, U(r), signed work and the exact reference at infinity | `#test-central` | +| 4 · Charge assembly (25p) | Build a tetrahedral configuration, counting each pair once | `#test-assembly` | +| 5 · Uniform field (25p) | Project an angled displacement along E | `#test-uniform` | +| 6 · Dipole rotation (50p) | Inspect force couples, signed torque, rotation work and U(θ) | `#test-dipole` | + +Each question includes its original scope and subparts, adjustable numerical +examples, live observations, expandable derivations and a prediction with a +revealable explanation. Journey/rotation sliders select a position or angle, +not elapsed time. The original Markdown questions and answers are available +from the lesson links, including in the production build. Test 4A and 4B each +carry 100 points. These are teaching examples rather than an automatic grader. + +Equations, derivations, inline lesson notation, control symbols and mathematical +3D annotations use KaTeX with locally bundled fonts and accessible MathML. +Long equations scroll within their own containers on narrow screens. Scene +annotations follow the camera through a CSS2D overlay, leaving orbit controls +usable. + +The added models are in `src/physics/test4.ts`, lesson content in +`src/test4.ts`, scene builders in `src/scene/test4.ts`, and browser coverage +in `tests/test4.spec.ts`. + Each lesson has an interactive 3D scene, adjustable parameters, live SI measurements, governing equations, a short explanation, a challenge and a model/scales disclosure. Use the subject tabs or these URL fragments: diff --git a/docs/Test_4.md b/docs/Test_4.md new file mode 100644 index 0000000..beb7160 --- /dev/null +++ b/docs/Test_4.md @@ -0,0 +1,63 @@ +# Test 4 + +**Test 4A:** Questions 1–3. +**Test 4B:** Questions 4–6. +**Max point:** 100 + 100. + +**NB!** Small sketches may improve the answers in questions 1, 3, 4, 5, 6. + +## 1. (30p) + +What is the work done by the electric force, when $q_0$ is moved from $a$ to $b$? + +## 2. (15p) + +Please write the condition for conservation of the total energy. + +## 3. (55p) + +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. + +**b. (25p)** The work in above is equal to the drop in the electric potential energy +$U_{\mathrm{e\,pot}\,a} - U_{\mathrm{e\,pot}\,b}$. +What is $U_{\mathrm{e\,pot}\,a}$ and $U_{\mathrm{e\,pot}\,b}$ if $b \to \infty$? + +## 4. (25p) + +Bringing charges together. + +**a. (15p)** What is the total electric potential energy if $q_1, q_2, q_3$ are brought together from infinite? + +**b. (10p)** What is the additional electric potential energy if $q_0$ is brought together with $q_1, q_2, q_3$? + +## 5. (25p) + +When $q_0$ moves from $a$ to $b$ in a uniform E-field. + +**a. (15p)** Please derive the work done by the electric force. + +**b. (10p)** What is $U_{\mathrm{pot}\,a}$ and $U_{\mathrm{pot}\,b}$? + +## 6. (50p) + +The electric dipole in a uniform E-field. + +**a. (10p)** Please derive the torque vector. + +Prove, that the magnitude is + +$$ +\tau = p_e E \sin(\theta), +$$ + +where $\theta = \angle(\vec{p}_e, \vec{E})$. + +**b. (30p)** Please derive the work $W_{ab}$ done by the electric force when the electric dipole rotates from $\theta_a$ to $\theta_b$ and what is $U_{\mathrm{e\,pot}\,a}$ and $U_{\mathrm{e\,pot}\,b}$? + +You don’t have to derive $dW = -\tau\,d\theta$. + +**c. (10p)** Explain why $\theta_a = 90^\circ$ can be used as a reference position for the electric potential energy and write the result for $U_{\mathrm{e\,pot}\,a}$. + +Now find the general expression of $U_{\mathrm{e\,pot}}$ for the electric dipole, also written as a scalar product. diff --git a/docs/Test_Answers.md b/docs/Test_Answers.md new file mode 100644 index 0000000..f08a9cc --- /dev/null +++ b/docs/Test_Answers.md @@ -0,0 +1,361 @@ +# Test 4 – Answers + +We assume electrostatic fields in a vacuum and use: + +$$ +k=\frac{1}{4\pi\varepsilon_0}. +$$ + +Here, $U$ denotes electric potential energy, $V$ denotes electric potential, and $q_0$ is the charge being moved. 4Te_pw14 (1)(1) + +## 1. Work done by the electric force from $a$ to $b$ + +The work is found by integrating the force along the path of the charge: + +$$ +W_{ab}=\int_a^b\vec F_e\cdot d\vec r. +$$ + +Since $\vec F_e=q_0\vec E$, we obtain: + +$$ +\boxed{W_{ab}=q_0\int_a^b\vec E\cdot d\vec r}. +$$ + +The electrostatic force is conservative. The work therefore equals the decrease in potential energy: + +$$ +\boxed{W_{ab}=U_a-U_b=q_0(V_a-V_b)}. +$$ + +Positive work means that the potential energy decreases. + +## 2. Condition for conservation of energy + +The total energy of an **isolated system** is constant: + +$$ +\boxed{\Delta E_{\mathrm{total}}=0}. +$$ + +If only the electric force does work, the sum of kinetic and electric potential energy is conserved: + +$$ +\boxed{K_a+U_a=K_b+U_b}. +$$ + +For a particle of mass $m$: + +$$ +\frac12mv_a^2+U_a=\frac12mv_b^2+U_b. +$$ + +This follows from the work–energy theorem: + +$$ +\Delta K=W_{ab}=-\Delta U. +$$ + +## 3. Motion in the field of a point charge + +### 3a. Derivation of the work + +A fixed point charge $q$ creates the field: + +$$ +\vec E(r)=k\frac{q}{r^2}\hat r. +$$ + +The force on $q_0$ is: + +$$ +\vec F_e=k\frac{qq_0}{r^2}\hat r. +$$ + +Let $r_a$ and $r_b$ be the distances from $q$ to $a$ and $b$, respectively. Since $\hat r\cdot d\vec r=dr$, we obtain: + +$$ +W_{ab} +=kqq_0\int_{r_a}^{r_b}\frac{1}{r^2}\,dr. +$$ + +Thus: + +$$ +W_{ab} +=kqq_0\left[-\frac1r\right]_{r_a}^{r_b}. +$$ + +Therefore: + +$$ +\boxed{W_{ab}=kqq_0\left(\frac1{r_a}-\frac1{r_b}\right)}. +$$ + +The result depends only on the initial and final distances, not on the path taken. + +### 3b. Potential energy with a reference at infinity + +We choose: + +$$ +U(\infty)=0. +$$ + +As $r_b\to\infty$, the work becomes: + +$$ +W_{a\infty}=\frac{kqq_0}{r_a}. +$$ + +Since $W_{a\infty}=U_a-U_\infty$, we obtain: + +$$ +\boxed{U_a=\frac{kqq_0}{r_a}}, +\qquad +\boxed{U_b=U_\infty=0}. +$$ + +The general expression for the potential energy is therefore: + +$$ +\boxed{U(r)=\frac{kqq_0}{r}}. +$$ + +Charges with the same sign give positive potential energy; charges with opposite signs give negative potential energy. + +## 4. Assembling point charges + +Let $r_{ij}$ be the distance between charges $q_i$ and $q_j$. The zero of potential energy is chosen to be when all charges are infinitely far apart. + +### 4a. Total potential energy of $q_1,q_2,q_3$ + +The charges can be assembled one at a time: + +- $q_1$ requires no work, since no other charges are present yet. +- $q_2$ adds the energy $kq_1q_2/r_{12}$. +- $q_3$ adds the energy $kq_1q_3/r_{13}+kq_2q_3/r_{23}$. + +The total potential energy is: + +$$ +\boxed{ +U_{123} +=k\left( +\frac{q_1q_2}{r_{12}} ++\frac{q_1q_3}{r_{13}} ++\frac{q_2q_3}{r_{23}} +\right) +}. +$$ + +Each pair of charges is counted once. + +### 4b. Additional potential energy when adding $q_0$ + +With the three existing charges held in place, adding $q_0$ gives: + +$$ +\boxed{ +\Delta U +=k\left( +\frac{q_0q_1}{r_{01}} ++\frac{q_0q_2}{r_{02}} ++\frac{q_0q_3}{r_{03}} +\right) +}. +$$ + +This can also be written as: + +$$ +\boxed{\Delta U=q_0V_{123}}, +$$ + +where $V_{123}$ is the potential due to $q_1,q_2,q_3$ at the position where $q_0$ is placed. + +## 5. Motion in a uniform electric field + +### 5a. Derivation of the work + +In a uniform field, $\vec E$ is constant: + +$$ +W_{ab}=q_0\int_a^b\vec E\cdot d\vec r +=q_0\vec E\cdot(\vec r_b-\vec r_a). +$$ + +Thus: + +$$ +\boxed{W_{ab}=q_0\vec E\cdot(\vec r_b-\vec r_a)}. +$$ + +If the displacement has magnitude $s$ and the angle between the displacement and the field is $\alpha$: + +$$ +\boxed{W_{ab}=q_0Es\cos\alpha}. +$$ + +Choosing the $x$-axis along the direction of the field gives: + +$$ +\boxed{W_{ab}=q_0E(x_b-x_a)}. +$$ + +### 5b. Potential energy at $a$ and $b$ + +Since $W_{ab}=U_a-U_b$: + +$$ +U_b-U_a=-q_0E(x_b-x_a). +$$ + +A potential energy that satisfies this is: + +$$ +U(x)=-q_0Ex+C. +$$ + +Therefore: + +$$ +\boxed{U_a=-q_0Ex_a+C}, +\qquad +\boxed{U_b=-q_0Ex_b+C}. +$$ + +The constant $C$ is determined by the chosen zero of potential energy. Choosing $U_a=0$ gives: + +$$ +\boxed{U_a=0}, +\qquad +\boxed{U_b=-q_0E(x_b-x_a)}. +$$ + +For a positive charge, the potential energy decreases when it moves in the direction of the field. + +## 6. Electric dipole in a uniform electric field + +A dipole consists of charges $+q$ and $-q$, separated by the vector $\vec d$, which points from the negative charge to the positive charge. + +The dipole moment is defined as: + +$$ +\boxed{\vec p_e=q\vec d}. +$$ + +The angle between $\vec p_e$ and $\vec E$ is denoted by $\theta$. + +### 6a. Derivation of the torque + +The forces on the charges are: + +$$ +\vec F_+=q\vec E, +\qquad +\vec F_-=-q\vec E. +$$ + +The net force is zero, but the forces form a couple. + +Taking the midpoint of the dipole as the origin, the position vectors are: + +$$ +\vec r_+=\frac{\vec d}{2}, +\qquad +\vec r_-=-\frac{\vec d}{2}. +$$ + +The total torque is: + +$$ +\begin{aligned} +\vec\tau +&=\vec r_+\times\vec F_+ ++\vec r_-\times\vec F_-\\ +&=\frac{\vec d}{2}\times q\vec E ++\left(-\frac{\vec d}{2}\right)\times(-q\vec E)\\ +&=q\vec d\times\vec E. +\end{aligned} +$$ + +Thus: + +$$ +\boxed{\vec\tau=\vec p_e\times\vec E}. +$$ + +The magnitude of the cross product gives: + +$$ +\boxed{\tau=p_eE\sin\theta}. +$$ + +The torque tends to align the dipole moment with the field. + +### 6b. Work during rotation from $\theta_a$ to $\theta_b$ + +The torque acts in the direction of decreasing $\theta$. With $\tau=p_eE\sin\theta$ as its magnitude: + +$$ +dW=-\tau\,d\theta. +$$ + +The work is therefore: + +$$ +W_{ab}=-p_eE\int_{\theta_a}^{\theta_b}\sin\theta\,d\theta. +$$ + +Integration gives: + +$$ +\boxed{ +W_{ab}=p_eE(\cos\theta_b-\cos\theta_a) +}. +$$ + +Since $W_{ab}=U_a-U_b$, the potential energy can be written as: + +$$ +U(\theta)=-p_eE\cos\theta+C. +$$ + +Therefore: + +$$ +\boxed{U_a=-p_eE\cos\theta_a+C}, +$$ + +$$ +\boxed{U_b=-p_eE\cos\theta_b+C}. +$$ + +### 6c. Reference at $90^\circ$ and the general expression + +The zero of potential energy can be chosen freely. We choose: + +$$ +\boxed{U_a=U(90^\circ)=0}. +$$ + +At $90^\circ$, the two charges are at the same potential in the uniform field, so their contributions to the total potential energy cancel each other. + +Since $\cos90^\circ=0$, this choice of reference gives $C=0$. Therefore: + +$$ +\boxed{U(\theta)=-p_eE\cos\theta}. +$$ + +Using the dot product, we obtain: + +$$ +\boxed{U=-\vec p_e\cdot\vec E}. +$$ + +| Angle | Orientation | Potential energy | Equilibrium | +|---|---|---|---| +| $0^\circ$ | Dipole moment aligned with the field | $-p_eE$, minimum | Stable | +| $90^\circ$ | Dipole moment perpendicular to the field | $0$ | No equilibrium | +| $180^\circ$ | Dipole moment opposite to the field | $+p_eE$, maximum | Unstable |
\ No newline at end of file diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md deleted file mode 100644 index f4a745a..0000000 --- a/docs/implementation-progress.md +++ /dev/null @@ -1,52 +0,0 @@ -# Implementation progress — PLAN.md - -The user asks for educational 3D graphics for eight subjects. PLAN.md is the -subject specification; no separate design or implementation spec exists. - -Ruling: Implement the eight subjects as selectable experiments in the existing -Three.js app, sharing a scene host and parameter/readout interface. This gives -each topic an interactive visual immediately. The models are idealizations, -explained in the UI, rather than general field/circuit/quantum solvers. - -Ruling: Continue in this directory because `.git` is an empty managed mount, -not a usable Git repository; worktree/commit scripts cannot run here. - -Pre-flight: Potential and energy share Coulomb models; capacitance and -permittivity share a parallel-plate model; current and semiconductors share -carrier constants. All physics functions use SI units and rendering handles -unit conversion and illustrative scales. - -- Baseline: existing 3 physics tests pass. -- [x] Electrostatics: charge/force/field, flux, energy, potential/gradient. -- [x] Matter/circuits: capacitance, dielectrics, current, semiconductors. -- [x] Eight experiment definitions and educational UI. -- [x] Eight 3D scenes, interaction, animation, cleanup. -- [x] Tests, browser checks, build, README. - -Implementation: -- Electrostatics complete: force/field, exact enclosed-charge flux (surface - singularity disclosed), interaction energy/work, potential and probe gradient. -- Matter/circuits complete: parallel plates, fixed-V/fixed-Q dielectrics, - Ohmic wire with signed drift, intrinsic/n/p silicon equilibrium/bands. -- UI and eight scenes complete: independent settings, deep links/history, - reset, pause/reduced motion, responsive layout and WebGL fallback. -- Renderer rebuilds are coalesced; obsolete GPU resources are disposed. -- Visual inspection: field, potential, capacitance and semiconductor screenshots - checked. Labels changed to stable screen sizes for readability. - -Final review: fresh read-only reviewer approved the current implementation; -no Critical or Important findings. -- Model direction-only capacitor-arrow disclosure was already added during - implementation and resolves the reviewer's corresponding suggestion. -- Final: minor (deferred): browser pause test verifies the state/button; - pixel comparisons proving actual animation cessation are not yet included. -- Final: minor (deferred): finite-difference potential-gradient unit test covers - one charge along x; add multiple sources/off-axis components when expanding. -- Build has a non-fatal Three.js bundle-size advisory (~598 kB minified, - ~153 kB gzip). No deployment or code-splitting requirement in PLAN.md. - -Final verification: 20/20 unit tests, 10/10 Chromium browser tests, strict -TypeScript and Vite production build pass after the final label adjustment. -All eight scenes were checked for rendered geometry and browser exceptions. -The app remains served on http://localhost:5173. No Git integration action -was available or required in this non-repository workspace. diff --git a/docs/superpowers/plans/2026-10-24-test4.md b/docs/superpowers/plans/2026-10-24-test4.md new file mode 100644 index 0000000..278ab0b --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-test4.md @@ -0,0 +1,59 @@ +# Test 4 in 3D Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan task by task. + +**Goal:** Represent every question and answer in `docs/Test_4.md` and `docs/Test_Answers.md` through an educational, interactive 3D lesson. + +**Architecture:** Extend the existing TypeScript/Three.js lab with a separate Test 4 collection. Share the renderer and parameter UI; keep test physics, lesson content, and scene construction in focused modules. Native execution in the current checkout; the user delegated design and implementation choices. + +**Tech Stack:** Existing Three.js, TypeScript, Vite, Vitest, Playwright; no new dependencies. + +**Spec:** The two source documents above and the design below. + +## Design and constraints + +- Six lessons, with exact question scope, all subparts and original marks: 4A = 100 points, 4B = 100 points. +- Guided lessons were selected as the implementation default after the user said “Choose the details and build it.” An exam-only view hides useful explanation; a disconnected presentation would duplicate the existing renderer. +- Test 4 is the default entry. Existing experiment hash links and all eight experiments remain available through a collection switch. +- Q1: uniform-field straight/curved paths with the same endpoints and work; Q2: repelling charge, energy bars and conserved K + U; Q3: radial motion, inverse-radius energy, reference at infinity; Q4: assemble three charges and add a fourth, showing pair energies once; Q5: angled displacement in a uniform field; Q6: dipole forces, torque, rotation work and energy versus angle. +- Use adjustable progress for inspectable motion; decorative animation respects pause and reduced motion. Explicit physical units and visual scale explanations. Equations and derivations remain usable without WebGL. +- Original questions, step-by-step answers, live numerical examples and a prediction/reveal check in each lesson. Source Markdown is linked from the built app. +- Preserve existing unrelated work and source documents. No publication or repository integration requested. + +## Review focus + +Signed charges and work; arbitrary energy zeros; infinity represented analytically rather than as a finite point; each charge pair counted once; dipole torque sign versus magnitude and unstable equilibrium; mobile overflow; WebGL fallback; collection/history state. + +## Task 1: Physics models + +Files: `src/physics/test4.ts`, `src/physics/test4.test.ts`. + +- [x] Write independent numerical tests for uniform and central work, conservation, pair assembly, dipole torque/work and infinity. +- [x] Run the tests and observe missing model failure. +- [x] Implement SI functions with explicit energy references and domain validation. +- [x] Run physics tests and confirm passing. + +## Task 2: Guided lesson content and scenes + +Files: `src/test4.ts`, `src/scene/test4.ts`, `src/experiments.ts`, `src/scene/experiments.ts`. + +- [x] Add browser checks for lesson selection, parameter changes, answers, graphics and fallback. Initial browser execution required enabling the local server and downloading Chromium into `/tmp`. +- [x] Implement all six lesson definitions with original questions, subparts and derivations. +- [x] Build parameter-linked 3D diagrams with endpoint labels, force/field arrows and energy plots or bars. + +## Task 3: Navigation, learning UI and verification + +Files: `src/main.ts`, `src/style.css`, `tests/test4.spec.ts`, `tests/lab.spec.ts`, `README.md`. + +- [x] Add Test 4/experiment collection navigation, question and solution panels, observation prompts, prediction reveal, previous/next links and retained parameter state. +- [x] Verify direct links, collection/history navigation, mobile overflow, all scenes, resets and WebGL fallback. +- [x] Run `npm test`, `npm run build`, `npm run test:e2e`; inspect desktop and mobile screenshots. +- [x] Obtain an independent code review and address significant findings; document final outcome. + +## Execution record + +Baseline: 20 unit tests passed; production build passed (existing Three.js bundle-size advisory). + +Completed: all six questions and subparts are covered. Independent review confirmed physical and content consistency and found a mobile pagination issue. A new browser regression reproduced the issue, then passed after explicit pagination began focusing and scrolling the new heading. Review polish items were addressed with valid `aria-current="page"`, responsive framing and less crowded mobile labels. Both original Markdown links returned HTTP 200 with their original content from the production preview; assets are emitted as files rather than inlined data URLs. + +Final verification: 25 unit tests passed, 17 browser tests passed, strict TypeScript and production build passed, and `git diff --check` passed. No new dependencies. The existing Three.js bundle-size advisory remains. Work is left in the user's checkout without a commit or publication. @@ -4,7 +4,8 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="theme-color" content="#101822" /> - <title>Electrotechnique · Field Lab</title> + <meta name="description" content="Learn electric work, potential energy and dipole rotation through six interactive 3D lessons from Test 4." /> + <title>Test 4 in 3D · Electrophysics Lab</title> </head> <body> <div id="app"></div> diff --git a/package-lock.json b/package-lock.json index cf51837..833e2ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,12 @@ "name": "electrophysics-sandbox", "version": "0.1.0", "dependencies": { + "katex": "^0.19.1", "three": "^0.186.1" }, "devDependencies": { "@playwright/test": "^1.64.1", + "@types/katex": "^0.16.8", "@types/three": "^0.186.0", "typescript": "^7.0.2", "vite": "^8.3.5", @@ -350,6 +352,12 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -750,6 +758,14 @@ "node": ">=18" } }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -820,6 +836,21 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/katex": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.1.tgz", + "integrity": "sha512-jG8uKntjSEcm2EpQOh0XturpPuuC9r3Sq5HLHdsFdWZT27E672SF19QEi+of0/YeOy15lDgRhKvr5614uRrEfA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/package.json b/package.json index 127a763..77d3b97 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ "test:e2e": "playwright test" }, "dependencies": { + "katex": "^0.19.1", "three": "^0.186.1" }, "devDependencies": { "@playwright/test": "^1.64.1", + "@types/katex": "^0.16.8", "@types/three": "^0.186.0", "typescript": "^7.0.2", "vite": "^8.3.5", 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<string, number | string>; 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<HTMLDivElement>('#app')!; app.innerHTML = ` <header class="app-header"> - <a class="brand" href="#field" aria-label="Field Lab home">E<span>/</span>LAB</a> + <a class="brand" href="#test-work" aria-label="Field Lab home">E<span>/</span>LAB</a> <div class="header-title"><h1>Electrotechnique & Electrophysics</h1><p>Explore the invisible. Understand the physical.</p></div> - <span class="status"><i></i> INTERACTIVE LAB</span> + <nav class="collections" aria-label="Learning collection"><a href="#test-work" id="test-collection">Test 4 in 3D</a><a href="#field" id="lab-collection">All experiments</a></nav> </header> - <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Experiments">${experiments.map((e, index) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(index + 1).padStart(2, '0')}</span>${e.shortTitle}</button>`).join('')}</div></nav> + <section class="test-overview" id="test-overview"><div><p class="eyebrow">FROM EQUATIONS TO INTUITION</p><h2>Electric energy, made visible.</h2><p>Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.</p></div><div class="test-score"><strong>4A <span>100p</span></strong><strong>4B <span>100p</span></strong><span>6 questions · 3D study guide</span></div></section> + <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Lessons"></div></nav> <main id="lesson" role="tabpanel" aria-labelledby="tab-field"> <aside class="controls-panel"> <p class="eyebrow" id="lesson-number"></p> - <h2 id="lesson-title"></h2><p class="description" id="description"></p> + <h2 id="lesson-title" tabindex="-1"></h2><p class="description" id="description"></p> + <section id="test-question" class="test-question" aria-label="Original test question"></section> <form id="parameters" aria-label="Experiment parameters" onsubmit="return false"></form> <div class="panel-actions"><button type="button" id="reset">Reset experiment</button></div> <section class="measurements" id="measurements" aria-label="Live measurements" aria-live="polite" aria-atomic="true"></section> @@ -24,17 +28,27 @@ app.innerHTML = ` <div class="legend" id="legend"></div> <div class="scene-footer"><p id="scene-note"></p><p class="hint">Drag to orbit · Scroll to zoom · Right-drag to pan · Arrow keys to pan when focused</p></div> </section> + <section id="observation-panel" class="observation" aria-label="Connect the scene to the answer"><span class="eyebrow">NOTICE WHAT CHANGES</span><p id="observation" aria-live="polite"></p></section> <section class="learning-panel" aria-label="Learn the physics"> <div><p class="eyebrow">THE PHYSICS</p><div id="equations" class="equations"></div><p id="explanation"></p><a id="source" target="_blank" rel="noopener noreferrer"></a></div> <div class="challenge"><p class="eyebrow">TRY THIS</p><p id="challenge"></p><details><summary>Model & visual scales</summary><p id="limits"></p></details></div> </section> + <section id="test-study" class="test-study" aria-label="Test answers and prediction"> + <div class="solution-heading"><div><p class="eyebrow">BUILD YOUR ANSWER</p><h3>Follow the reasoning</h3></div><a href="${answerUrl}" target="_blank" rel="noopener noreferrer">Original answers ↗</a></div> + <div id="solution" class="solution"></div> + <section class="prediction"><p class="eyebrow">PAUSE & PREDICT</p><h3 id="prediction-question"></h3><button type="button" id="reveal-prediction" aria-expanded="false" aria-controls="prediction-answer">Reveal explanation</button><p id="prediction-answer" hidden></p></section> + <nav class="lesson-pagination" aria-label="Question navigation"><a id="previous-question"></a><span id="question-progress"></span><a id="next-question"></a></nav> + </section> </div> </main>`; const lab = createLab(document.querySelector('#viewport')!); -const settings = new Map(experiments.map(experiment => [experiment.id, defaults(experiment)])); +const allLessons: Experiment[] = [...testLessons, ...experiments]; +const settings = new Map(allLessons.map(experiment => [experiment.id, defaults(experiment)])); const byId = (id: string) => document.getElementById(id)!; -let active = experiments[0]; +let active: Experiment = testLessons[0]; +let collection = ''; +const subjects = document.querySelector<HTMLElement>('[role="tablist"]')!; let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches; function updatePause() { byId('pause').textContent = paused ? 'Resume animation' : 'Pause animation'; @@ -53,22 +67,78 @@ function update() { if (active.id === 'semiconductor') { (byId('parameter-concentration') as HTMLInputElement).disabled = p.doping === 'intrinsic'; } + if (active.id === 'test-central') (byId('parameter-rb') as HTMLInputElement).disabled = p.destination === 'infinity'; + const testLesson = getTestLesson(active); + if (testLesson) renderProse(byId('observation'), testLesson.observe(p)); byId('measurements').innerHTML = active.measure(p).map(m => `<div class="measurement"><span>${m.label}</span><div><strong aria-label="${m.label}">${m.value}</strong><small>${m.unit}</small></div></div>`).join(''); + byId('measurements').querySelectorAll<HTMLElement>('.measurement > span').forEach(label => renderNotation(label, label.textContent!)); lab.update(active.id, p); } function show(experiment: Experiment) { active = experiment; - const index = experiments.indexOf(experiment); - byId('lesson-number').textContent = `EXPERIMENT ${String(index + 1).padStart(2, '0')} / 08`; + const testLesson = getTestLesson(experiment); + const lessons: Experiment[] = testLesson ? testLessons : experiments; + const index = lessons.indexOf(experiment); + const nextCollection = testLesson ? 'test' : 'lab'; + if (collection !== nextCollection) { + collection = nextCollection; + subjects.innerHTML = lessons.map((e, i) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(i + 1).padStart(2, '0')}</span>${e.shortTitle}${getTestLesson(e) ? `<small>${getTestLesson(e)!.points}p</small>` : ''}</button>`).join(''); + } + for (const [id, selected] of [['test-collection', Boolean(testLesson)], ['lab-collection', !testLesson]] as const) { + if (selected) byId(id).setAttribute('aria-current', 'page'); + else byId(id).removeAttribute('aria-current'); + } + byId('test-overview').hidden = !testLesson; + byId('test-question').hidden = !testLesson; + byId('test-study').hidden = !testLesson; + byId('observation-panel').hidden = !testLesson; + byId('lesson-number').textContent = testLesson ? `TEST ${testLesson.section} · QUESTION ${index + 1} · ${testLesson.points} POINTS` : `EXPERIMENT ${String(index + 1).padStart(2, '0')} / 08`; + if (testLesson) { + byId('test-question').replaceChildren(...testLesson.questions.map((text, i) => { + const paragraph = document.createElement('p'); + renderProse(paragraph, text); + if (i === 0) paragraph.className = 'question-lead'; + return paragraph; + })); + byId('solution').replaceChildren(...testLesson.answers.map(part => { + const details = document.createElement('details'); + const summary = document.createElement('summary'); + renderNotation(summary, part.title); + const steps = document.createElement('ol'); + steps.className = 'derivation'; + for (const step of part.steps) { + const item = document.createElement('li'); + const why = document.createElement('p'); renderProse(why, step.why); item.append(why); + if (step.math) { const math = document.createElement('p'); math.className = 'math-step'; renderEquation(math, step.math); item.append(math); } + steps.append(item); + } + details.append(summary, steps); + return details; + })); + renderProse(byId('prediction-question'), testLesson.prediction); + renderProse(byId('prediction-answer'), testLesson.predictionAnswer); + byId('prediction-answer').hidden = true; + byId('reveal-prediction').textContent = 'Reveal explanation'; + byId('reveal-prediction').setAttribute('aria-expanded', 'false'); + const previous = byId('previous-question') as HTMLAnchorElement; + const next = byId('next-question') as HTMLAnchorElement; + previous.hidden = index === 0; + next.hidden = index === testLessons.length - 1; + previous.textContent = '← Previous question'; + next.textContent = 'Next question →'; + if (index > 0) previous.href = `#${testLessons[index - 1].id}`; + if (index < testLessons.length - 1) next.href = `#${testLessons[index + 1].id}`; + byId('question-progress').textContent = `${index + 1} / 6`; + } byId('lesson-title').textContent = experiment.title; - byId('description').textContent = experiment.description; - byId('scene-note').textContent = experiment.sceneNote; - byId('explanation').textContent = experiment.explanation; - byId('challenge').textContent = experiment.challenge; - byId('limits').textContent = experiment.limits; + renderProse(byId('description'), experiment.description); + renderProse(byId('scene-note'), experiment.sceneNote); + renderProse(byId('explanation'), experiment.explanation); + renderProse(byId('challenge'), experiment.challenge); + renderProse(byId('limits'), experiment.limits); byId('equations').replaceChildren(...experiment.equations.map(equation => { const div = document.createElement('p'); - div.textContent = equation; + renderEquation(div, equation); return div; })); const source = byId('source') as HTMLAnchorElement; @@ -87,14 +157,31 @@ function show(experiment: Experiment) { if (control.kind === 'select') return `<div class="parameter"><label for="${id}">${control.label}</label><select id="${id}" name="${control.id}">${control.options.map(option => `<option value="${option.value}" ${option.value === p[control.id] ? 'selected' : ''}>${option.label}</option>`).join('')}</select></div>`; return `<div class="parameter"><div class="parameter-label"><label for="${id}">${control.label}</label><output for="${id}" id="output-${control.id}"></output></div><input id="${id}" name="${control.id}" type="range" min="${control.min}" max="${control.max}" step="${control.step}" value="${p[control.id]}" /></div>`; }).join(''); - lab.resetCamera(); + byId('parameters').querySelectorAll<HTMLLabelElement>('label').forEach(label => { + const input = document.getElementById(label.htmlFor)!; + input.setAttribute('aria-label', label.textContent!); + renderNotation(label, label.textContent!); + }); + lab.resetCamera(experiment.id); update(); } function fromHash() { const id = window.location.hash.slice(1); - show(experiments.find(experiment => experiment.id === id) ?? experiments[0]); + show(allLessons.find(experiment => experiment.id === id) ?? testLessons[0]); } -const onHashChange = () => fromHash(); +let focusNextQuestion = false; +document.querySelector('.lesson-pagination')!.addEventListener('click', event => { + const link = (event.target as HTMLElement).closest('a'); + if (link && !((event as MouseEvent).ctrlKey || (event as MouseEvent).metaKey || (event as MouseEvent).shiftKey || (event as MouseEvent).altKey)) focusNextQuestion = true; +}); +const onHashChange = () => { + fromHash(); + if (focusNextQuestion) { + focusNextQuestion = false; + byId('lesson-title').focus({ preventScroll: true }); + byId('lesson-title').scrollIntoView({ block: 'start' }); + } +}; window.addEventListener('hashchange', onHashChange); fromHash(); byId('parameters').addEventListener('input', event => { @@ -112,14 +199,23 @@ byId('pause').addEventListener('click', () => { paused = !paused; updatePause(); }); -const tabs = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')]; -tabs.forEach((tab, index) => { - tab.addEventListener('click', () => { - const id = tab.dataset.experiment!; - if (window.location.hash === `#${id}`) show(experiments[index]); - else window.location.hash = id; - }); - tab.addEventListener('keydown', event => { +byId('reveal-prediction').addEventListener('click', () => { + const reveal = byId('prediction-answer').hidden; + byId('prediction-answer').hidden = !reveal; + byId('reveal-prediction').setAttribute('aria-expanded', String(reveal)); + byId('reveal-prediction').textContent = reveal ? 'Hide explanation' : 'Reveal explanation'; +}); +subjects.addEventListener('click', event => { + const tab = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-experiment]'); + if (!tab) return; + const id = tab.dataset.experiment!; + if (window.location.hash === `#${id}`) show(allLessons.find(e => e.id === id)!); + else window.location.hash = id; +}); +subjects.addEventListener('keydown', event => { + const tabs = [...subjects.querySelectorAll<HTMLButtonElement>('[role="tab"]')]; + const index = tabs.indexOf(event.target as HTMLButtonElement); + if (index < 0) return; let next: number; if (event.key === 'ArrowRight') next = (index + 1) % tabs.length; else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length; @@ -129,7 +225,6 @@ tabs.forEach((tab, index) => { event.preventDefault(); tabs[next].focus(); tabs[next].click(); - }); }); if (import.meta.hot) import.meta.hot.dispose(() => { window.removeEventListener('hashchange', onHashChange); 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<string, string> = { + '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<j$. Three charges have three distinct pairs; four charges have six. The additional energy from $q_0$ includes just its three new pairs, $\Delta U_0=q_0V_{123}$.`, + sceneNote: 'A tetrahedral arrangement. Mint edges: existing pairs. Gold edges: pairs introduced by the newest charge. Dashed markers: not yet assembled.', + limits: String.raw`Point charges fixed at distinct positions. $q_1$ = +3 nC, $q_2$ = −2 nC, $q_3$ = +2 nC. All coordinates scale with the configuration size in metres. Reference: all charges mutually separated at infinity. Self-energies are excluded. Later-stage totals are shown in readouts even before those charges are placed.`, + measure: p => { 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); +} diff --git a/tests/lab.spec.ts b/tests/lab.spec.ts index 51645cd..e4bac8e 100644 --- a/tests/lab.spec.ts +++ b/tests/lab.spec.ts @@ -10,7 +10,7 @@ async function read(page: Page, label: string) { test('all eight lessons render 3D content with measurements and explanations', async ({ page }) => { const errors: string[] = []; page.on('pageerror', e => errors.push(e.message)); - await page.goto('/'); + await page.goto('/#field'); const subjects = ['Electric charge, force and field', 'Electric flux and charge', 'Electric potential energy', 'Electric potential and gradient', 'Capacitance', 'Dielectric materials', 'Current', 'Semiconducting materials']; const tabs = page.getByRole('tab'); await expect(tabs).toHaveCount(8); diff --git a/tests/math.spec.ts b/tests/math.spec.ts new file mode 100644 index 0000000..71b586d --- /dev/null +++ b/tests/math.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; + +test('work equations typeset vector arrows and integral limits with accessible math', async ({ page }) => { + await page.goto('/#test-work'); + const equations = page.locator('#equations'); + await expect(equations.locator('.katex')).toHaveCount(2); + await expect(equations.locator('math')).toHaveCount(2); + await expect(equations.locator('math msubsup').first()).toBeAttached(); + await expect(equations.locator('math mover').first()).toBeAttached(); + await expect(page.locator('label[for="parameter-q0"] .katex')).toBeVisible(); + await expect(page.getByRole('slider', { name: 'Moving charge q₀', exact: true })).toBeVisible(); + await page.locator('#solution summary').click(); + await expect(page.locator('.math-step .katex')).toHaveCount(3); + await expect(page.locator('.katex-error')).toHaveCount(0); +}); + +test('every lesson summary and derivation renders without math parsing errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + for (const id of ['test-work', 'test-conservation', 'test-central', 'test-assembly', 'test-uniform', 'test-dipole', 'field', 'flux', 'energy', 'potential', 'capacitance', 'dielectric', 'current', 'semiconductor']) { + await page.goto(`/#${id}`); + await expect(page.locator('#equations .katex').first()).toBeVisible(); + expect(await page.locator('#equations .katex').count()).toBe(await page.locator('#equations > p').count()); + if (id.startsWith('test-')) { + await page.locator('#solution details').evaluateAll(details => details.forEach(detail => detail.setAttribute('open', ''))); + expect(await page.locator('.math-step .katex').count()).toBe(await page.locator('.math-step').count()); + } + await expect(page.locator('.katex-error')).toHaveCount(0); + } + await page.goto('/#test-dipole'); + await expect(page.locator('#test-question .katex').first()).toBeVisible(); + await expect(page.locator('#explanation .katex').first()).toBeVisible(); + expect(errors).toEqual([]); +}); + +test('long formulas stay contained and usable on a narrow screen', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/#test-assembly'); + await page.locator('#solution summary').first().click(); + await expect(page.locator('#equations .katex').first()).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + const equation = page.locator('#equations > p').first(); + const layout = await equation.evaluate(element => { + const style = getComputedStyle(element); + return { overflow: style.overflowX, width: element.getBoundingClientRect().width, available: element.parentElement!.getBoundingClientRect().width }; + }); + expect(layout.width).toBeLessThanOrEqual(layout.available); + expect(layout.overflow).toBe('auto'); +}); + +test('3D mathematical annotations track the scene and are removed when switching lessons', async ({ page }) => { + await page.goto('/#test-dipole'); + const labels = page.locator('#viewport .scene-math-label'); + await expect(labels.locator('math mfrac').first()).toBeAttached(); + await expect(labels.filter({ hasText: 'cos' }).first()).toBeVisible(); + const before = await labels.count(); + await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('180'); + await expect(labels).toHaveCount(before); + await page.getByRole('tab', { name: /Work & paths/ }).click(); + await expect(labels.filter({ hasText: 'cos' })).toHaveCount(0); + await expect(labels.locator('.katex').first()).toBeVisible(); + await expect(page.locator('#viewport .katex-error')).toHaveCount(0); + // Labels are an overlay; orbiting the underlying canvas remains possible. + expect(await page.locator('.scene-labels').evaluate(el => getComputedStyle(el).pointerEvents)).toBe('none'); +}); diff --git a/tests/test4.spec.ts b/tests/test4.spec.ts new file mode 100644 index 0000000..3e6a3b5 --- /dev/null +++ b/tests/test4.spec.ts @@ -0,0 +1,118 @@ +import { test, expect, type Page } from '@playwright/test'; + +async function read(page: Page, label: string) { + return Number((await page.getByLabel(label, { exact: true }).innerText()).replaceAll(',', '')); +} + +test('six original test questions have interactive scenes and every answer part', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', e => errors.push(e.message)); + await page.goto('/'); + const ids = ['work', 'conservation', 'central', 'assembly', 'uniform', 'dipole']; + const parts = [1, 1, 2, 2, 2, 3]; + await expect(page.getByRole('tab')).toHaveCount(6); + for (let i = 0; i < ids.length; i++) { + await page.getByRole('tab').nth(i).click(); + await expect(page).toHaveURL(new RegExp(`#test-${ids[i]}$`)); + await expect(page.locator('#test-question')).not.toBeEmpty(); + await expect(page.locator('#solution details')).toHaveCount(parts[i]); + await page.locator('#solution summary').first().click(); + await expect(page.locator('#solution details[open] .derivation')).toBeVisible(); + await expect(page.locator('#measurements')).not.toContainText('Undefined'); + await expect(page.locator('canvas').first()).toBeVisible(); + // Check actual rendered pixels, including scenes constructed after hash navigation. + await expect.poll(() => page.locator('#viewport > canvas').evaluate(canvas => new Promise<number>(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => { + const small = document.createElement('canvas'); small.width = 80; small.height = 60; + const context = small.getContext('2d')!; + context.drawImage(canvas as HTMLCanvasElement, 0, 0, 80, 60); + const data = context.getImageData(0, 0, 80, 60).data; + const colours = new Set<number>(); + for (let k = 0; k < data.length; k += 4) colours.add((data[k] << 16) | (data[k + 1] << 8) | data[k + 2]); + resolve(colours.size); + })); + }))).toBeGreaterThan(12); + } + expect(errors).toEqual([]); +}); + +test('path changes preserve work while signed charge and angle change it', async ({ page }) => { + await page.goto('/#test-work'); + const initial = await read(page, 'Field work a → b'); + await page.getByLabel('Path shape', { exact: true }).selectOption('curved'); + expect(await read(page, 'Field work a → b')).toBe(initial); + await page.getByRole('slider', { name: 'Moving charge q₀', exact: true }).fill('-2'); + expect(await read(page, 'Field work a → b')).toBe(-initial); + await page.goto('/#test-uniform'); + await page.getByRole('slider', { name: 'Displacement angle α', exact: true }).fill('90'); + expect(Math.abs(await read(page, 'Field work a → b'))).toBeLessThan(1e-10); +}); + +test('energy conservation, infinity and dipole equilibrium stay physically consistent', async ({ page }) => { + await page.goto('/#test-conservation'); + const total = await read(page, 'Total K + U'); + await page.getByRole('slider', { name: 'Journey progress', exact: true }).fill('100'); + expect(await read(page, 'Total K + U')).toBe(total); + expect(await read(page, 'Kinetic energy K')).toBeGreaterThan(0); + await page.goto('/#test-central'); + await page.getByLabel('Destination b', { exact: true }).selectOption('infinity'); + expect(await read(page, 'Potential energy Uᵦ')).toBe(0); + await expect(page.locator('#observation')).toContainText('infinity'); + await page.goto('/#test-dipole'); + await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('0'); + await page.getByRole('slider', { name: 'Rotation progress', exact: true }).fill('100'); + expect(await read(page, 'Potential energy U')).toBeLessThan(0); + expect(await read(page, 'Torque τz')).toBe(0); + await expect(page.locator('#observation')).toContainText('stable'); +}); + +test('assembly adds q₀ without double counting and the prediction can be revealed', async ({ page }) => { + await page.goto('/#test-assembly'); + await page.getByLabel('Assembly stage', { exact: true }).selectOption('3'); + const base = await read(page, 'Energy of current assembly'); + await page.getByLabel('Assembly stage', { exact: true }).selectOption('4'); + expect(await read(page, 'Energy of current assembly')).toBeCloseTo(base + await read(page, 'Additional energy ΔU₀'), 1); + await expect(page.locator('#prediction-answer')).toBeHidden(); + await page.getByRole('button', { name: 'Reveal explanation' }).click(); + await expect(page.locator('#prediction-answer')).toBeVisible(); +}); + +test('collection navigation retains settings, works with history, and fits mobile', async ({ page }) => { + await page.goto('/#test-dipole'); + await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('180'); + await page.getByRole('link', { name: 'All experiments', exact: true }).click(); + await expect(page.getByRole('tab')).toHaveCount(8); + await page.goBack(); + await expect(page.getByRole('slider', { name: 'Final angle θᵦ', exact: true })).toHaveValue('180'); + await page.getByRole('button', { name: 'Reset experiment' }).click(); + await expect(page.getByRole('slider', { name: 'Final angle θᵦ', exact: true })).toHaveValue('15'); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator('canvas')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +}); + +test('next question returns a mobile learner to the new heading', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/#test-work'); + await page.getByRole('link', { name: 'Next question →', exact: true }).click(); + await expect(page).toHaveURL(/#test-conservation$/); + await expect(page.getByRole('heading', { name: 'Where does the energy go?' })).toBeInViewport(); + await expect(page.locator('#lesson-title')).toBeFocused(); +}); + +test('test answers and controls remain accessible without WebGL', async ({ page }) => { + await page.addInitScript(() => { + const original = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (type: string, ...args: unknown[]) { + if (type.startsWith('webgl')) return null; + return original.call(this, type as '2d', ...args as []) as never; + } as typeof original; + }); + await page.goto('/#test-dipole'); + await expect(page.getByRole('alert')).toContainText('WebGL'); + await page.getByRole('slider', { name: 'Rotation progress', exact: true }).fill('100'); + expect(await read(page, 'Potential energy U')).toBeLessThan(0); + await page.locator('#solution summary').last().click(); + await expect(page.locator('#solution details[open] math').first()).toBeAttached(); + await expect(page.locator('#solution details[open] .katex-error')).toHaveCount(0); +}); diff --git a/vite.config.ts b/vite.config.ts index a35e76e..935dce8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + // Keep original question/answer links navigable in production. + build: { assetsInlineLimit: 0 }, test: { include: ['src/**/*.test.ts'] }, }); |