aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md53
-rw-r--r--PLAN.md16
-rw-r--r--README.md190
-rw-r--r--docs/superpowers/plans/2026-10-24-electric-flux.md40
-rw-r--r--docs/superpowers/plans/2026-10-24-test4.md59
-rw-r--r--docs/theory/1Introduction to electrophysics.pdf (renamed from theory/1Introduction to electrophysics.pdf)bin1373195 -> 1373195 bytes
-rw-r--r--docs/theory/2Electric charge, force and field.pdf (renamed from theory/2Electric charge, force and field.pdf)bin1307864 -> 1307864 bytes
-rw-r--r--docs/theory/3Electric flux and charge.pdf (renamed from theory/3Electric flux and charge.pdf)bin2051370 -> 2051370 bytes
-rw-r--r--docs/theory/4Electric potential energy.pdf (renamed from theory/4Electric potential energy.pdf)bin1922512 -> 1922512 bytes
-rw-r--r--docs/theory/5Electric potential and gradient.pdf (renamed from theory/5Electric potential and gradient.pdf)bin3009513 -> 3009513 bytes
-rw-r--r--docs/theory/6capacitance.pdf (renamed from theory/6capacitance.pdf)bin1315542 -> 1315542 bytes
-rw-r--r--docs/theory/7dielectricmaterials.pdf (renamed from theory/7dielectricmaterials.pdf)bin1657183 -> 1657183 bytes
-rw-r--r--docs/theory/8current.pdf (renamed from theory/8current.pdf)bin2134217 -> 2134217 bytes
-rw-r--r--docs/theory/9semiconductingmaterials.pdf (renamed from theory/9semiconductingmaterials.pdf)bin3016847 -> 3016847 bytes
-rw-r--r--index.html5
-rw-r--r--package-lock.json1515
-rw-r--r--playwright.config.ts14
-rw-r--r--src/core/model.ts16
-rw-r--r--src/experiments.ts192
-rw-r--r--src/i18n.ts308
-rw-r--r--src/i18n/coverage.test.ts61
-rw-r--r--src/i18n/da.ts488
-rw-r--r--src/i18n/en.ts101
-rw-r--r--src/i18n/index.ts28
-rw-r--r--src/i18n/lesson-text.ts22
-rw-r--r--src/i18n/narrate.ts41
-rw-r--r--src/i18n/present.ts76
-rw-r--r--src/main.ts308
-rw-r--r--src/math.ts42
-rw-r--r--src/models/electric-flux/physics.ts53
-rw-r--r--src/models/electric-flux/scene.ts277
-rw-r--r--src/models/electric-flux/state.ts36
-rw-r--r--src/models/electric-flux/view.ts218
-rw-r--r--src/models/registry.ts13
-rw-r--r--src/physics/electricField.test.ts21
-rw-r--r--src/physics/electricField.ts22
-rw-r--r--src/physics/models.test.ts108
-rw-r--r--src/physics/models.ts112
-rw-r--r--src/physics/test4.test.ts42
-rw-r--r--src/physics/test4.ts36
-rw-r--r--src/scene/experiments.ts248
-rw-r--r--src/scene/lab.ts124
-rw-r--r--src/scene/primitives.ts107
-rw-r--r--src/scene/test4.ts204
-rw-r--r--src/style.css426
-rw-r--r--src/test4.ts233
-rw-r--r--src/ui/icons.ts12
-rw-r--r--tests/lab.spec.ts152
-rw-r--r--tests/language.spec.ts24
-rw-r--r--tests/math.spec.ts65
-rw-r--r--tests/test4.spec.ts118
-rw-r--r--tsconfig.json5
-rw-r--r--vite.config.ts17
53 files changed, 1306 insertions, 4942 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..db45eee
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,53 @@
+# Project instructions
+
+## Purpose and scope
+
+Build an educational electrophysics application with interactive 3D graphics.
+Treat `PLAN.md` as the source of truth for subject scope. A subject line
+starting with `#` is excluded from implementation; the document's title and
+introductory heading are not subjects.
+
+The only currently enabled subject is **Electric flux and charge**. Do not
+implement other listed subjects unless the plan or the user enables them.
+Use `docs/theory/` as subject references. Exercises and answers in `docs/`
+provide context but do not expand the enabled scope.
+
+## Languages
+
+- The languages supported should be English and Danish
+
+## Repository and tooling
+
+- `src/`: application source, physics models, and 3D rendering.
+- `tests/`: browser tests.
+- `docs/`: educational references and exercises.
+- `PLAN.md`: enabled and excluded subjects.
+- The existing package manifest uses TypeScript, Vite, Three.js, and KaTeX,
+ with Vitest and Playwright for testing.
+- `.nvmrc` specifies Node 22; `package.json` requires Node >=22.12.0.
+
+Available package scripts:
+
+| Command | Purpose |
+| --- | --- |
+| `npm run dev` | Start Vite on 127.0.0.1 |
+| `npm run build` | Type-check and build for production |
+| `npm run typecheck` | Run TypeScript checks |
+| `npm test` | Run Vitest |
+| `npm run test:e2e` | Run Playwright |
+| `npm run preview` | Preview the production build |
+
+The manifest defines these commands, but source and configuration files may
+still need to be created before they can run. Inspect the current working tree
+and preserve existing user edits and deletions; do not restore deleted files
+without a task requiring it.
+
+## Implementation guidance
+
+- Keep physics calculations separate from presentation and test meaningful
+ physical invariants, units, and limiting cases when implementing models.
+- Keep explanations, equations, units, and visualizations consistent with the
+ educational references and state any simplifying assumptions.
+- Provide accessible labels and keyboard-operable controls for interactions.
+- Run checks appropriate to the changes. Report checks that could not run and
+ their actual blockers; do not claim unexecuted checks passed.
diff --git a/PLAN.md b/PLAN.md
index 73f7f15..728943b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,10 +1,12 @@
# The plan of Electrophysics
### It is the plan to have a visual and educating 3D graphics for the following subjects:
-Electric charge, force and field
+#Electric charge, force and field
Electric flux and charge
-Electric potential energy
-Electric potential and gradient
-Capacitance
-Dielectric materials
-Current
-Semiconducting materials
+#Electric potential energy
+#Electric potential and gradient
+#Capacitance
+#Dielectric materials
+#Current
+#Semiconducting materials
+
+If the line of the subject starts with an '#' that means that it should not be implemented
diff --git a/README.md b/README.md
index c3eb7b9..ce86f3a 100644
--- a/README.md
+++ b/README.md
@@ -1,150 +1,58 @@
-# Electrotechnique & Electrophysics Sandbox
+# Fieldwork · Electrophysics
-Three.js + strict TypeScript + Vite, developed in Ubuntu on WSL2.
+An interactive 3D learning lab built with TypeScript, Vite, Three.js, and KaTeX.
+`PLAN.md` controls the subject scope. The only implemented subject is **Electric flux and charge**.
-## Develop
+## Run locally
-Run in your Ubuntu terminal:
+Use Node 22 (at least 22.12.0), as specified in `.nvmrc`.
-```bash
-cd /home/victor/projects/geekedin
+```sh
npm install
npm run dev
```
-Open http://localhost:5173 in your Windows browser. The dev server binds to
-127.0.0.1 by default and Vite provides hot module replacement.
-Use a browser with WebGL and hardware acceleration enabled.
-
-Node.js 22.12+ is required; `.nvmrc` selects Node 22 if you use nvm.
-For a fresh checkout with a lockfile, use `npm ci` for repeatable installs.
-
-## Commands
-
-- `npm run dev`: development server, hot reload.
-- `npm run typecheck`: strict TypeScript check.
-- `npm test`: physics unit tests with Vitest.
-- `npm run test:e2e`: Chromium browser checks with Playwright.
-- `npm run build`: type check and production bundle in `dist/`.
-- `npm run preview`: preview the production build at http://localhost:4173.
-
-## 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:
-
-| Subject | Interactive view | URL fragment |
-| --- | --- | --- |
-| Electric charge, force and field | Dipole/like charges, field arrows and force on a source | `#field` |
-| Electric flux and charge | Move a signed charge through a spherical Gaussian surface | `#flux` |
-| Electric potential energy | Vary separation and charge, plot U(r) and field work | `#energy` |
-| Electric potential and gradient | Potential landscape with field arrows and a movable probe | `#potential` |
-| Capacitance | Vary plate area, gap and applied voltage | `#capacitance` |
-| Dielectric materials | Polarization at fixed voltage versus fixed free charge | `#dielectric` |
-| Current | Ohmic copper wire with electrons drifting against conventional current | `#current` |
-| Semiconducting materials | Intrinsic, n-type and p-type silicon, carrier densities and bands | `#semiconductor` |
-
-Drag to orbit, scroll to zoom, right-drag to pan. Focus the canvas to pan
-with arrow keys. Reset the camera independently or reset the current
-experiment. Settings survive switching subjects for the current page session.
-URL fragments support browser back/forward and direct links. Animations can
-be paused, and start paused when reduced motion is preferred. Without WebGL,
-lessons, controls and measurements remain usable.
-
-These are teaching models: electrostatics in vacuum, ideal parallel plates,
-linear fully filling dielectrics, an Ohmic copper wire at fixed temperature,
-and non-degenerate silicon in equilibrium at 300 K. The semiconductor lab
-uses approximate reference constants disclosed in the lesson. It does not
-model a p–n junction or compare real devices. Geometry, arrow lengths,
-carrier populations and animation speeds use explicit visual scales.
-
-In the isolated dielectric experiment, each parameter setting represents
-charging the selected geometry in vacuum at the charging voltage, then
-disconnecting and filling it with dielectric. Q is fixed while varying κ;
-changing geometry or charging voltage prepares a different capacitor state.
-
-## Verification
-
-```bash
-npm test
-npm run build
-# Once per development machine:
-npx playwright install chromium
-npm run test:e2e
-```
+Open the local URL printed by Vite. `npm run build` type-checks and builds the app into `dist/`; `npm run preview` serves that build. `npm run typecheck` runs TypeScript separately.
+
+No test scripts or test files were created for this implementation. The pre-existing test commands and dependencies in `package.json` are retained, but there is no test suite in the current working tree.
+
+## The experiment
+
+- Change a point charge from −5 to +5 nC and move it along the x-axis.
+- Choose a sphere or cube and change its radius or half side length.
+- Compare enclosed charge, surface area, and exact net flux.
+- Try positive, negative, and external-charge presets.
+- Toggle the surface, field lines, and outward normals.
+- Drag to orbit; scroll to zoom. Focus the canvas and use arrow keys to orbit, `+`/`−` to zoom, and `Home` to reset. All other controls are native keyboard-operable inputs.
+
+The model assumes a stationary ideal point charge in vacuum. The imaginary Gaussian surface is centered at the origin. Geometry uses metres, physics uses SI units, and the interface presents charge in nanocoulombs. A nonzero charge exactly on the surface produces an undefined enclosed-charge/flux result because its field is singular there. The visible marker's radius does not change the point-charge model. Sampled lines and fixed-size arrows illustrate field direction; total flux is calculated analytically, never by counting those lines.
+
+Reference: `docs/theory/3Electric flux and charge.pdf`, sections 3.2–3.4. Lesson copy and graphics are original, based on these definitions.
+
+## Add another 3D model
-The unit suite checks force direction, the potential gradient, energy,
-Gauss's law, capacitance, fixed-Q/fixed-V dielectric behavior, current,
-drift and semiconductor mass action/neutrality. Playwright checks rendered
-content in all eight scenes, parameter changes, reset, history, mobile layout,
-pausing and the WebGL fallback. The browser suite starts Vite if necessary.
-On a minimal Ubuntu installation, Playwright may require browser libraries;
-its installer can identify them (`npx playwright install-deps chromium`).
-
-## Extend
-
-- `src/main.ts`: subject navigation, parameter controls, lesson UI.
-- `src/experiments.ts`: lesson definitions, defaults, units and measurements.
-- `src/scene/lab.ts`: renderer, orbit controls, resizing and frame loop.
-- `src/scene/experiments.ts`: one scene builder per topic.
-- `src/scene/primitives.ts`: shared geometry/labels and GPU resource disposal.
-- `src/physics/`: framework-independent SI models and unit tests.
-- `tests/lab.spec.ts`: real-browser interaction and rendering checks.
-
-Add model calculations under `src/physics/`, then connect an experiment
-and a scene builder. Keep physical quantities in SI and perform display
-conversion in the lesson definitions.
-
-## WSL2 notes
-
-Keep the project in `/home/victor/projects` for Linux file watching and I/O.
-Run Node/npm in Ubuntu rather than mixing Windows and Linux installations.
-For VS Code, run `code .` in Ubuntu and use its WSL extension.
-
-If Windows cannot reach localhost, first check the URL printed by Vite (it
-may choose another port if 5173 is busy). If WSL localhost forwarding is
-unavailable, run `npm run dev -- --host 0.0.0.0` and access the WSL address
-shown by `hostname -I`. This exposes the development server on available
-interfaces; use it only on a trusted development network.
-
-If file changes do not reload on a mounted Windows filesystem, move the
-project into the Linux filesystem. Polling can be enabled in Vite config if
-needed, but is unnecessary for the default project location.
-
-## References
-
-- [Vite getting started](https://vite.dev/guide/)
-- [Three.js installation](https://threejs.org/manual/pages/installation.html)
-- [OpenStax University Physics Volume 2](https://openstax.org/details/books/university-physics-volume-2)
-- [OpenStax band theory of solids](https://openstax.org/books/university-physics-volume-3/pages/9-5-band-theory-of-solids)
+1. Enable its subject in `PLAN.md` first.
+2. Create `src/models/<model-id>/` with separate physics, state, scene, and view modules.
+3. Implement `ModelDefinition` from `src/core/model.ts`. `mount(host, { i18n })` renders into its host and returns a `ModelInstance` whose `dispose()` releases listeners, GPU resources, and observers.
+4. Add its title, controls, explanations, accessibility text, and messages to `src/i18n/en.ts`.
+5. Register a lazy-loading definition in `src/models/registry.ts`. The current shell mounts its first entry. Add model selection to the shell when a second enabled model is implemented; each model's physics and scene remain independent.
+
+`src/models/electric-flux/physics.ts` has no dependency on DOM, Three.js, or translations. Rendering consumes physical field directions; it does not own the equations or measurement state. Scenes render only when controls, state, or viewport size changes.
+
+## Add a language
+
+English (`en`) is the default and fallback. User-facing copy is in `src/i18n/en.ts`, accessed through stable typed keys. Messages accept named parameters and numbers use `Intl.NumberFormat`.
+
+To support another language, add its catalog and register its BCP 47 locale in `src/i18n/index.ts`. Pass that locale to `createI18n` in the shell. Missing messages fall back to English. No physics or model components need rewriting. Add a language selector only once another language has been requested and supplied.
+
+## Layout
+
+```text
+src/core/ Shared model lifecycle contract
+src/i18n/ English catalog, locale registry, formatting
+src/models/registry.ts Enabled, lazy-loaded models
+src/models/electric-flux/ Pure physics, state, Three.js scene, lesson view
+src/ui/ Shared interface graphics
+src/main.ts Application shell
+src/style.css Responsive presentation
+```
diff --git a/docs/superpowers/plans/2026-10-24-electric-flux.md b/docs/superpowers/plans/2026-10-24-electric-flux.md
new file mode 100644
index 0000000..c7c7a2e
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-electric-flux.md
@@ -0,0 +1,40 @@
+# Electric Flux and Charge Implementation Plan
+
+**Goal:** Build an interactive 3D lesson for the only enabled subject in `PLAN.md`, with an extensible model interface.
+
+**Architecture:** A TypeScript application shell loads models from a registry. Each model owns its controls, lesson, state, and disposable Three.js scene. Pure SI-unit physics is independent of rendering and localization. One English catalog supplies all interface text.
+
+**Tech stack:** Existing Vite, TypeScript, Three.js, and KaTeX dependencies; Node >=22.12.0.
+
+**References:** `PLAN.md`, `AGENTS.md`, and `docs/theory/3Electric flux and charge.pdf`, especially sections 3.2–3.4.
+
+## Constraints and decisions
+
+- Implement only electric flux and charge. Keep excluded subjects and deleted tests absent.
+- Work in the supplied checkout, preserving existing edits and unrelated deletions.
+- Do not create test scripts, per the user's explicit request. Use type checking, a production build, and direct runtime inspection.
+- Use English as default and fallback, with typed message keys, parameterized messages, and locale-aware numbers. No language selector.
+- Use a point charge in vacuum and a closed sphere or cube centered at the origin. Let charge position vary along x; explain the finite visual charge marker and qualitative arrows.
+- Compute total flux analytically using Gauss's law. A nonzero charge exactly on the boundary gives an undefined result, not a misleading finite value.
+- Native sliders, buttons, checkboxes, camera keyboard shortcuts, and a text summary make the experiment operable without a mouse.
+
+## Tasks
+
+- [x] Create build configuration, app shell, typed model lifecycle and registry, and English localization.
+- [x] Implement pure point-charge field, surface membership, and enclosed-charge/flux calculations.
+- [x] Implement the 3D surface, signed field arrows, movable charge, orbit camera, toggles, and presets; add live measurements and explanations.
+- [x] Add responsive styling, keyboard operation, WebGL fallback, and developer documentation for adding models/locales.
+- [x] Run type checking and production build; inspect browser behavior and fix observed issues without adding test scripts.
+
+## Review focus
+
+Zero/negative charge, boundary singularity, moving outside a surface, switching surface shape, resetting all controls, keyboard input, narrow screens, rendering unavailable, and scene disposal.
+
+## Verification record
+
+- `npm run build`: TypeScript and Vite production build passed. Vite reports a size warning for the Three.js vendor chunk (about 562 kB minified, 139 kB gzip); the model is loaded lazily.
+- Direct Chromium inspection (no test files created): +2 nC gives +225.9 N·m²/C; −2 nC gives −225.9; an external charge gives zero. Changing sphere radius from 1.8 m to 3 m and switching to a cube preserves total flux. A nonzero charge exactly on the boundary is undefined; zero charge on that boundary gives zero.
+- Reset restores the initial values and layers. Native slider keyboard input and camera arrow keys work. Desktop and 768, 390, and 320 px layouts have no horizontal page overflow. No browser page errors observed.
+- Forced WebGL unavailability shows a useful message and preserves working numerical controls.
+- A separate read-only code review found secondary text contrast issues. These were fixed, with the affected text now measuring 4.73:1–5.26:1. No other important findings.
+- Unrelated deletions, staged user changes, `PLAN.md`, and `package.json` were preserved. Fresh application entry points were created as required by the task. No tests, test configuration, or lockfile were restored.
diff --git a/docs/superpowers/plans/2026-10-24-test4.md b/docs/superpowers/plans/2026-10-24-test4.md
deleted file mode 100644
index 278ab0b..0000000
--- a/docs/superpowers/plans/2026-10-24-test4.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# 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.
diff --git a/theory/1Introduction to electrophysics.pdf b/docs/theory/1Introduction to electrophysics.pdf
index 526298a..526298a 100644
--- a/theory/1Introduction to electrophysics.pdf
+++ b/docs/theory/1Introduction to electrophysics.pdf
Binary files differ
diff --git a/theory/2Electric charge, force and field.pdf b/docs/theory/2Electric charge, force and field.pdf
index 31ad03f..31ad03f 100644
--- a/theory/2Electric charge, force and field.pdf
+++ b/docs/theory/2Electric charge, force and field.pdf
Binary files differ
diff --git a/theory/3Electric flux and charge.pdf b/docs/theory/3Electric flux and charge.pdf
index 39a10ec..39a10ec 100644
--- a/theory/3Electric flux and charge.pdf
+++ b/docs/theory/3Electric flux and charge.pdf
Binary files differ
diff --git a/theory/4Electric potential energy.pdf b/docs/theory/4Electric potential energy.pdf
index fd62020..fd62020 100644
--- a/theory/4Electric potential energy.pdf
+++ b/docs/theory/4Electric potential energy.pdf
Binary files differ
diff --git a/theory/5Electric potential and gradient.pdf b/docs/theory/5Electric potential and gradient.pdf
index 87eee7f..87eee7f 100644
--- a/theory/5Electric potential and gradient.pdf
+++ b/docs/theory/5Electric potential and gradient.pdf
Binary files differ
diff --git a/theory/6capacitance.pdf b/docs/theory/6capacitance.pdf
index 67e697e..67e697e 100644
--- a/theory/6capacitance.pdf
+++ b/docs/theory/6capacitance.pdf
Binary files differ
diff --git a/theory/7dielectricmaterials.pdf b/docs/theory/7dielectricmaterials.pdf
index f67dd91..f67dd91 100644
--- a/theory/7dielectricmaterials.pdf
+++ b/docs/theory/7dielectricmaterials.pdf
Binary files differ
diff --git a/theory/8current.pdf b/docs/theory/8current.pdf
index a09d270..a09d270 100644
--- a/theory/8current.pdf
+++ b/docs/theory/8current.pdf
Binary files differ
diff --git a/theory/9semiconductingmaterials.pdf b/docs/theory/9semiconductingmaterials.pdf
index 14c5cae..14c5cae 100644
--- a/theory/9semiconductingmaterials.pdf
+++ b/docs/theory/9semiconductingmaterials.pdf
Binary files differ
diff --git a/index.html b/index.html
index e4fac18..07f5fc2 100644
--- a/index.html
+++ b/index.html
@@ -3,9 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <meta name="theme-color" content="#101822" />
- <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>
+ <meta name="theme-color" content="#102c30" />
+ <title></title>
</head>
<body>
<div id="app"></div>
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 833e2ae..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,1515 +0,0 @@
-{
- "name": "electrophysics-sandbox",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "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",
- "vitest": "^5.0.5"
- },
- "engines": {
- "node": ">=22.12.0"
- }
- },
- "node_modules/@dimforge/rapier3d-compat": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
- "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
- "dev": true
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
- "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
- "dev": true
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.155.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
- "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
- "dev": true,
- "funding": {
- "url": "https://github.com/sponsors/oxc-project"
- }
- },
- "node_modules/@playwright/test": {
- "version": "1.64.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
- "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
- "dev": true,
- "dependencies": {
- "playwright": "1.64.1"
- },
- "bin": {
- "playwright": "cli.js"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/@rolldown/binding-android-arm-eabi": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
- "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
- "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
- "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
- "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
- "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
- "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
- "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
- "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
- "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
- "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
- "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
- "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
- "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
- "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
- "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
- "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
- "dev": true
- },
- "node_modules/@tweenjs/tween.js": {
- "version": "23.1.3",
- "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
- "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
- "dev": true
- },
- "node_modules/@types/chai": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
- "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
- "dev": true,
- "dependencies": {
- "@types/deep-eql": "*",
- "assertion-error": "^2.0.1"
- }
- },
- "node_modules/@types/deep-eql": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
- "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
- "dev": true
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "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",
- "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
- "dev": true
- },
- "node_modules/@types/three": {
- "version": "0.186.0",
- "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
- "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
- "dev": true,
- "dependencies": {
- "@dimforge/rapier3d-compat": "~0.12.0",
- "@tweenjs/tween.js": "~23.1.3",
- "@types/stats.js": "*",
- "@types/webxr": ">=0.5.17",
- "fflate": "~0.8.3",
- "meshoptimizer": "~1.1.1"
- }
- },
- "node_modules/@types/webxr": {
- "version": "0.5.24",
- "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
- "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
- "dev": true
- },
- "node_modules/@typescript/typescript-aix-ppc64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
- "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-darwin-arm64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
- "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-darwin-x64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
- "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-freebsd-arm64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
- "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-freebsd-x64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
- "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-arm": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
- "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-arm64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
- "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-loong64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
- "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-mips64el": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
- "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-ppc64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
- "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-riscv64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
- "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-s390x": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
- "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-linux-x64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
- "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-netbsd-arm64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
- "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-netbsd-x64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
- "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-openbsd-arm64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
- "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-openbsd-x64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
- "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-sunos-x64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
- "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-win32-arm64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
- "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@typescript/typescript-win32-x64": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
- "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=16.20.0"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
- "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
- "dev": true,
- "dependencies": {
- "@jridgewell/trace-mapping": "0.3.31",
- "@vitest/spy": "5.0.5",
- "estree-walker": "^3.0.3",
- "magic-string": "^1.4.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9 || ^3.0.0",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/spy": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
- "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
- "dev": true,
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/assertion-error": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
- "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
- "dev": true,
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/chai": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
- "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
- "dev": true,
- "engines": {
- "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",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/es-module-lexer": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
- "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
- "dev": true
- },
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/expect-type": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
- "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
- "dev": true,
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fflate": {
- "version": "0.8.3",
- "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
- "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
- "dev": true
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "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",
- "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
- "dev": true,
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.33.0",
- "lightningcss-darwin-arm64": "1.33.0",
- "lightningcss-darwin-x64": "1.33.0",
- "lightningcss-freebsd-x64": "1.33.0",
- "lightningcss-linux-arm-gnueabihf": "1.33.0",
- "lightningcss-linux-arm64-gnu": "1.33.0",
- "lightningcss-linux-arm64-musl": "1.33.0",
- "lightningcss-linux-x64-gnu": "1.33.0",
- "lightningcss-linux-x64-musl": "1.33.0",
- "lightningcss-win32-arm64-msvc": "1.33.0",
- "lightningcss-win32-x64-msvc": "1.33.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
- "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
- "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
- "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
- "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
- "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
- "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
- "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
- "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
- "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
- "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
- "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/magic-string": {
- "version": "1.4.3",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
- "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
- "dev": true,
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.6.0"
- }
- },
- "node_modules/meshoptimizer": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
- "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
- "dev": true
- },
- "node_modules/nanoid": {
- "version": "3.3.20",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
- "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/obug": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
- "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
- "dev": true,
- "funding": [
- "https://github.com/sponsors/sxzz",
- "https://opencollective.com/debug"
- ],
- "engines": {
- "node": ">=12.20.0"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true
- },
- "node_modules/picomatch": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
- "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/playwright": {
- "version": "1.64.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
- "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
- "dev": true,
- "dependencies": {
- "playwright-core": "1.64.1"
- },
- "bin": {
- "playwright": "cli.js"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/playwright-core": {
- "version": "1.64.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
- "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
- "dev": true,
- "bin": {
- "playwright-core": "cli.js"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.31",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
- "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "dependencies": {
- "nanoid": "^3.3.20",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.2"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/rolldown": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
- "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
- "dev": true,
- "dependencies": {
- "@oxc-project/types": "=0.155.0",
- "@rolldown/pluginutils": "^1.0.0"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "optionalDependencies": {
- "@rolldown/binding-android-arm-eabi": "1.2.15",
- "@rolldown/binding-android-arm64": "1.2.15",
- "@rolldown/binding-darwin-arm64": "1.2.15",
- "@rolldown/binding-darwin-x64": "1.2.15",
- "@rolldown/binding-freebsd-x64": "1.2.15",
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
- "@rolldown/binding-linux-arm64-gnu": "1.2.15",
- "@rolldown/binding-linux-arm64-musl": "1.2.15",
- "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
- "@rolldown/binding-linux-s390x-gnu": "1.2.15",
- "@rolldown/binding-linux-x64-gnu": "1.2.15",
- "@rolldown/binding-linux-x64-musl": "1.2.15",
- "@rolldown/binding-openharmony-arm64": "1.2.15",
- "@rolldown/binding-win32-arm64-msvc": "1.2.15",
- "@rolldown/binding-win32-x64-msvc": "1.2.15"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
- "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/std-env": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
- "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
- "dev": true
- },
- "node_modules/three": {
- "version": "0.186.1",
- "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
- "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="
- },
- "node_modules/tinybench": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
- "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
- "dev": true,
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/tinyexec": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
- "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
- "dev": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "dev": true,
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/typescript": {
- "version": "7.0.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
- "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
- "dev": true,
- "bin": {
- "tsc": "bin/tsc"
- },
- "engines": {
- "node": ">=16.20.0"
- },
- "optionalDependencies": {
- "@typescript/typescript-aix-ppc64": "7.0.2",
- "@typescript/typescript-darwin-arm64": "7.0.2",
- "@typescript/typescript-darwin-x64": "7.0.2",
- "@typescript/typescript-freebsd-arm64": "7.0.2",
- "@typescript/typescript-freebsd-x64": "7.0.2",
- "@typescript/typescript-linux-arm": "7.0.2",
- "@typescript/typescript-linux-arm64": "7.0.2",
- "@typescript/typescript-linux-loong64": "7.0.2",
- "@typescript/typescript-linux-mips64el": "7.0.2",
- "@typescript/typescript-linux-ppc64": "7.0.2",
- "@typescript/typescript-linux-riscv64": "7.0.2",
- "@typescript/typescript-linux-s390x": "7.0.2",
- "@typescript/typescript-linux-x64": "7.0.2",
- "@typescript/typescript-netbsd-arm64": "7.0.2",
- "@typescript/typescript-netbsd-x64": "7.0.2",
- "@typescript/typescript-openbsd-arm64": "7.0.2",
- "@typescript/typescript-openbsd-x64": "7.0.2",
- "@typescript/typescript-sunos-x64": "7.0.2",
- "@typescript/typescript-win32-arm64": "7.0.2",
- "@typescript/typescript-win32-x64": "7.0.2"
- }
- },
- "node_modules/vite": {
- "version": "8.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
- "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
- "dev": true,
- "dependencies": {
- "lightningcss": "^1.33.0",
- "picomatch": "^4.0.7",
- "postcss": "^8.5.30",
- "rolldown": "~1.2.14",
- "tinyglobby": "^0.2.17"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.7.1 || ^0.8.0",
- "esbuild": "^0.27.0 || ^0.28.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "@vitejs/devtools": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vitest": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
- "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
- "dev": true,
- "dependencies": {
- "@types/chai": "^5.2.2",
- "@vitest/mocker": "5.0.5",
- "chai": "^6.3.0",
- "es-module-lexer": "^2.3.2",
- "expect-type": "^1.4.0",
- "magic-string": "^1.4.3",
- "obug": "^3.0.0",
- "picomatch": "^4.0.7",
- "std-env": "^4.3.0",
- "tinybench": "^6.2.2",
- "tinyexec": "^1.3.1",
- "tinyglobby": "^0.2.17",
- "why-is-node-running": "3.2.1"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@opentelemetry/api": "^1.9.0",
- "@types/node": "^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "5.0.5",
- "@vitest/browser-preview": "5.0.5",
- "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
- "@vitest/coverage-istanbul": "5.0.5",
- "@vitest/coverage-v8": "5.0.5",
- "@vitest/ui": "5.0.5",
- "happy-dom": "*",
- "jsdom": "*",
- "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser-playwright": {
- "optional": true
- },
- "@vitest/browser-preview": {
- "optional": true
- },
- "@vitest/browser-webdriverio": {
- "optional": true
- },
- "@vitest/coverage-istanbul": {
- "optional": true
- },
- "@vitest/coverage-v8": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- },
- "vite": {
- "optional": false
- }
- }
- },
- "node_modules/why-is-node-running": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
- "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
- "dev": true,
- "bin": {
- "why-is-node-running": "cli.js"
- },
- "engines": {
- "node": ">=20.11"
- }
- }
- }
-}
diff --git a/playwright.config.ts b/playwright.config.ts
deleted file mode 100644
index d1cd1b3..0000000
--- a/playwright.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineConfig } from '@playwright/test';
-
-export default defineConfig({
- testDir: './tests',
- fullyParallel: false,
- workers: 1,
- use: {
- baseURL: 'http://127.0.0.1:5173',
- viewport: { width: 1440, height: 1000 },
- launchOptions: { args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] },
- screenshot: 'only-on-failure',
- },
- webServer: { command: 'npm run dev -- --port 5173 --strictPort', url: 'http://127.0.0.1:5173', reuseExistingServer: true },
-});
diff --git a/src/core/model.ts b/src/core/model.ts
new file mode 100644
index 0000000..f250839
--- /dev/null
+++ b/src/core/model.ts
@@ -0,0 +1,16 @@
+import type { I18n, MessageKey } from '../i18n';
+
+export interface ModelContext {
+ i18n: I18n;
+}
+
+export interface ModelInstance {
+ /** Release animation frames, GPU resources, observers, and event listeners. */
+ dispose(): void;
+}
+
+export interface ModelDefinition {
+ id: string;
+ titleKey: MessageKey;
+ mount(host: HTMLElement, context: ModelContext): ModelInstance | Promise<ModelInstance>;
+}
diff --git a/src/experiments.ts b/src/experiments.ts
deleted file mode 100644
index d4d2bc2..0000000
--- a/src/experiments.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { getLanguage, word } from './i18n';
-import { electricField, type Charge } from './physics/electricField';
-import { capacitor, conductor, forceOn, gaussianFlux, pairEnergy, potential, semiconductor } from './physics/models';
-
-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 }[] }
-export interface Measurement { label: string; value: string; unit: string }
-export interface Experiment {
- id: ExperimentId;
- title: string;
- shortTitle: string;
- description: string;
- controls: (Slider | Choice)[];
- equations: string[];
- explanation: string;
- challenge: string;
- limits: string;
- sceneNote: string;
- legend: [string, string][];
- source: { title: string; url: string };
- measure: (p: Parameters) => Measurement[];
-}
-export const number = (p: Parameters, key: string): number => Number(p[key]);
-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 })) });
-export function format(value: number, digits = 2): string {
- if (!Number.isFinite(value)) return 'Undefined';
- if (value === 0) return '0';
- if (Math.abs(value) < 0.01 || Math.abs(value) >= 1e5) return value.toExponential(2);
- return value.toLocaleString('en', { maximumFractionDigits: digits });
-}
-export function formatDisplay(value: number, digits = 2): string {
- if (getLanguage() !== 'da') return format(value, digits);
- if (!Number.isFinite(value)) return 'Udefineret';
- if (value === 0) return '0';
- const locale = 'da-DK';
- if (Math.abs(value) < 0.01 || Math.abs(value) >= 1e5) return value.toLocaleString(locale, { notation: 'scientific', maximumFractionDigits: 2 });
- return value.toLocaleString(locale, { maximumFractionDigits: digits });
-}
-const metric = (label: string, value: number, unit: string): Measurement => ({ label, value: formatDisplay(value), unit });
-const textbook = (page: string) => ({ title: 'OpenStax · University Physics', url: `https://openstax.org/books/university-physics-volume-2/pages/${page}` });
-export function chargePair(p: Parameters): Charge[] {
- const half = number(p, 'separation') / 2;
- return [
- { x: -half, y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 },
- { x: half, y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 * (p.configuration === 'like' ? 1 : -1) },
- ];
-}
-export function plateModel(p: Parameters) {
- return capacitor({ area: number(p, 'area') * 1e-4, gap: number(p, 'gap') * 1e-3, voltage: number(p, 'voltage'), relativePermittivity: p.permittivity === undefined ? 1 : number(p, 'permittivity'), isolated: p.connection === 'isolated' });
-}
-export function wireModel(p: Parameters) {
- return conductor({ voltage: number(p, 'voltage'), area: number(p, 'area') * 1e-6, length: number(p, 'length'), resistivity: 1.68e-8, carrierDensity: 8.5e28 });
-}
-export function carrierModel(p: Parameters) {
- return semiconductor({ intrinsicDensity: 1e16, electronMobility: 0.135, holeMobility: 0.048, netDoping: p.doping === 'intrinsic' ? 0 : 10 ** number(p, 'concentration') * (p.doping === 'p' ? -1 : 1) });
-}
-const pairControls = [slider('charge', 'Source charge magnitude', 0.2, 5, 0.1, 2, 'nC'), slider('separation', 'Charge separation', 1, 6, 0.1, 3, 'm'), choice('configuration', 'Charge signs', 'opposite', [['opposite', 'Opposite (+ / −)'], ['like', 'Like (+ / +)']])];
-const plateControls = [slider('area', 'Plate area', 25, 225, 5, 100, 'cm²'), slider('gap', 'Plate separation', 0.5, 5, 0.1, 1, 'mm'), slider('voltage', 'Charging voltage', 1, 20, 1, 10, 'V')];
-
-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: [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: 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 => {
- const charges = chargePair(p);
- const e = electricField({ x: 0, y: 0, z: 0 }, charges);
- const f = forceOn(charges[1], [charges[0]]);
- return [metric('|E| at origin', Math.hypot(e.x, e.y, e.z), 'N/C'), metric('|Force on right charge|', Math.hypot(f.x, f.y, f.z) * 1e9, 'nN'), { label: 'Interaction', value: word(p.configuration === 'like' ? 'Repulsive' : 'Attractive'), unit: '' }];
- },
- },
- {
- 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: [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: 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 => {
- const q = { x: number(p, 'position'), y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 };
- const flux = gaussianFlux([q], number(p, 'radius'));
- return [metric('Net electric flux', flux, 'N·m²/C'), metric('Enclosed charge', Number.isNaN(flux) ? NaN : Math.abs(q.x) < number(p, 'radius') ? q.coulombs * 1e9 : 0, 'nC'), { label: 'Source location', value: word(Number.isNaN(flux) ? 'On surface' : Math.abs(q.x) < number(p, 'radius') ? 'Inside' : 'Outside'), unit: '' }];
- },
- },
- {
- 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: [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: 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 => {
- const q = number(p, 'testCharge') * 1e-9;
- const r = number(p, 'distance');
- const u = pairEnergy(3e-9, q, r);
- return [metric('Interaction energy', u * 1e9, 'nJ'), metric('Work by field, from 6 m', (pairEnergy(3e-9, q, 6) - u) * 1e9, 'nJ'), metric('|Force on moving charge|', Math.abs(u / r) * 1e9, 'nN')];
- },
- },
- {
- 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: [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 => {
- const point = { x: number(p, 'probe'), y: 0, z: 1 };
- const charges = chargePair(p);
- const field = electricField(point, charges);
- return [metric('Potential at probe', potential(point, charges), 'V'), metric('Ex at probe', field.x, 'V/m'), metric('|E| at probe', Math.hypot(field.x, field.y, field.z), 'N/C')];
- },
- },
- {
- id: 'capacitance', title: 'Capacitance', shortTitle: 'Capacitance',
- description: 'Build a parallel-plate capacitor and see how geometry controls charge storage.',
- controls: plateControls,
- 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.',
- sceneNote: 'Positive plate → field → negative plate · gap enlarged 50×',
- legend: [['#ff967b', 'Positive plate'], ['#73d7ff', 'Negative plate'], ['#91e2cb', 'Uniform E']],
- source: textbook('8-1-capacitors-and-capacitance'),
- measure: p => { const c = plateModel(p); return [metric('Capacitance', c.capacitance * 1e12, 'pF'), metric('Stored free charge |Q|', c.charge * 1e9, 'nC'), metric('Electric field', c.field / 1000, 'kV/m'), metric('Stored energy', c.energy * 1e9, 'nJ')]; },
- },
- {
- 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: [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²')]; },
- },
- {
- 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: [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: 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'),
- measure: p => { const w = wireModel(p); return [metric('Resistance', w.resistance * 1000, 'mΩ'), metric('Conventional current', w.current, 'A'), metric('Electron drift, x', w.electronDrift * 1000, 'mm/s'), metric('Dissipated power', w.power, 'W')]; },
- },
- {
- id: 'semiconductor', title: 'Semiconducting materials', shortTitle: 'Semiconductors',
- 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: [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')]; },
- },
-];
-
-export function defaults(experiment: Experiment): Parameters {
- return Object.fromEntries(experiment.controls.map(control => [control.id, control.value]));
-}
diff --git a/src/i18n.ts b/src/i18n.ts
deleted file mode 100644
index 2fd3dca..0000000
--- a/src/i18n.ts
+++ /dev/null
@@ -1,308 +0,0 @@
-export type Language = 'en' | 'da';
-
-export const languageOptions: { id: Language; label: string }[] = [
- { id: 'en', label: 'English' },
- { id: 'da', label: 'Dansk' },
-];
-
-export interface Chrome {
- subtitle: string;
- title: string;
- documentTitle: string;
- metaDescription: string;
- home: string;
- collection: string;
- language: string;
- test: string;
- experiments: string;
- overview: string;
- overviewTitle: string;
- overviewText: string;
- score: string;
- subjects: string;
- lessons: string;
- reset: string;
- original: string;
- parameters: string;
- measurements: string;
- camera: string;
- pause: string;
- resume: string;
- hint: string;
- notice: string;
- connect: string;
- physics: string;
- learn: string;
- try: string;
- scales: string;
- build: string;
- reasoning: string;
- originalAnswers: string;
- predict: string;
- reveal: string;
- hide: string;
- navigation: string;
- previous: string;
- next: string;
- view: string;
- viewport: string;
- study: string;
- canvas: string;
- webglStart: string;
- webglLost: string;
- experimentKicker: (index: number, total: number) => string;
- questionKicker: (section: string, question: number, points: number) => string;
-}
-
-const pad = (value: number) => String(value).padStart(2, '0');
-
-const chrome: Record<Language, Chrome> = {
- en: {
- subtitle: 'Explore the invisible. Understand the physical.',
- title: 'Electrotechnique & Electrophysics',
- documentTitle: 'Test 4 in 3D · Electrophysics Lab',
- metaDescription: 'Learn electric work, potential energy and dipole rotation through six interactive 3D lessons from Test 4.',
- home: 'Field Lab home',
- collection: 'Learning collection',
- language: 'Language',
- test: 'Test 4 in 3D',
- experiments: 'All experiments',
- overview: 'FROM EQUATIONS TO INTUITION',
- overviewTitle: 'Electric energy, made visible.',
- overviewText: 'Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.',
- score: '6 questions · 3D study guide',
- subjects: 'Electrophysics subjects',
- lessons: 'Lessons',
- reset: 'Reset experiment',
- original: 'Original test question',
- parameters: 'Experiment parameters',
- measurements: 'Live measurements',
- camera: 'Reset camera',
- pause: 'Pause animation',
- resume: 'Resume animation',
- hint: 'Drag to orbit · Scroll to zoom · Right-drag to pan · Arrow keys to pan when focused',
- notice: 'NOTICE WHAT CHANGES',
- connect: 'Connect the scene to the answer',
- physics: 'THE PHYSICS',
- learn: 'Learn the physics',
- try: 'TRY THIS',
- scales: 'Model & visual scales',
- build: 'BUILD YOUR ANSWER',
- reasoning: 'Follow the reasoning',
- originalAnswers: 'Original answers ↗',
- predict: 'PAUSE & PREDICT',
- reveal: 'Reveal explanation',
- hide: 'Hide explanation',
- navigation: 'Question navigation',
- previous: '← Previous question',
- next: 'Next question →',
- view: '3D VIEW',
- viewport: '3D workbench',
- study: 'Test answers and prediction',
- canvas: '3D experiment: drag to orbit, scroll to zoom, right-drag to pan',
- webglStart: 'WebGL could not start. Enable browser hardware acceleration and reload to see the 3D scenes. Lessons, controls and measurements still work.',
- webglLost: 'WebGL context lost. Reload to restore the 3D view. Numeric experiments still work.',
- experimentKicker: (index, total) => `EXPERIMENT ${pad(index)} / ${pad(total)}`,
- questionKicker: (section, question, points) => `TEST ${section} · QUESTION ${question} · ${points} POINTS`,
- },
- da: {
- subtitle: 'Udforsk det usynlige. Forstå fysikken.',
- title: 'Elektroteknik og elektrofysik',
- documentTitle: 'Test 4 i 3D · Elektrofysiklaboratoriet',
- metaDescription: 'Lær elektrisk arbejde, potentiel energi og dipolrotation gennem seks interaktive 3D-lektioner fra Test 4.',
- home: 'Feltlaboratoriets forside',
- collection: 'Læringssamling',
- language: 'Sprog',
- test: 'Test 4 i 3D',
- experiments: 'Alle eksperimenter',
- overview: 'FRA LIGNINGER TIL INTUITION',
- overviewTitle: 'Elektrisk energi gjort synlig.',
- overviewText: 'Udforsk de seks spørgsmål fra Test 4. Ændr scenen, forudsig resultatet, og følg derefter udledningen.',
- score: '6 spørgsmål · 3D-studievejledning',
- subjects: 'Elektrofysikkens emner',
- lessons: 'Lektioner',
- reset: 'Nulstil eksperiment',
- original: 'Oprindeligt eksamensspørgsmål',
- parameters: 'Eksperimentparametre',
- measurements: 'Målinger i realtid',
- camera: 'Nulstil kamera',
- pause: 'Sæt animation på pause',
- resume: 'Genoptag animation',
- hint: 'Træk for at dreje · Rul for at zoome · Højreklik og træk for at panorere · Piletaster panorerer, når feltet er valgt',
- notice: 'BEMÆRK, HVAD DER ÆNDRER SIG',
- connect: 'Forbind scenen med svaret',
- physics: 'FYSIKKEN',
- learn: 'Lær fysikken',
- try: 'PRØV DETTE',
- scales: 'Model- og visuelle skalaer',
- build: 'BYG DIT SVAR',
- reasoning: 'Følg ræsonnementet',
- originalAnswers: 'Oprindelige svar ↗',
- predict: 'PAUSE OG FORUDSIG',
- reveal: 'Vis forklaring',
- hide: 'Skjul forklaring',
- navigation: 'Spørgsmålsnavigation',
- previous: '← Forrige spørgsmål',
- next: 'Næste spørgsmål →',
- view: '3D-VISNING',
- viewport: '3D-arbejdsbord',
- study: 'Prøvesvar og forudsigelse',
- canvas: '3D-eksperiment: træk for at dreje, rul for at zoome, højreklik og træk for at panorere',
- webglStart: 'WebGL kunne ikke starte. Slå browserens hardwareacceleration til, og genindlæs for at se 3D-scenerne. Lektioner, betjening og målinger virker stadig.',
- webglLost: 'WebGL-konteksten gik tabt. Genindlæs for at gendanne 3D-visningen. De numeriske eksperimenter virker stadig.',
- experimentKicker: (index, total) => `EKSPERIMENT ${pad(index)} / ${pad(total)}`,
- questionKicker: (section, question, points) => `TEST ${section} · SPØRGSMÅL ${question} · ${points} POINT`,
- },
-};
-
-const scenePhrases = {
- en: {
- origin: 'Origin',
- onRightCharge: 'on right charge',
- gaussian: 'Gaussian surface',
- probe: 'Probe',
- isolated: 'isolated',
- connected: 'supply connected',
- noCurrent: 'No net current',
- conventional: 'Conventional current',
- drift: String.raw`Electron drift · displayed $\times600$`,
- conduction: 'Conduction band',
- valence: 'Valence band',
- midgap: 'midgap',
- gap: 'gap',
- energyConstant: 'Energy changes form · total stays constant',
- },
- da: {
- origin: 'Origo',
- onRightCharge: 'på højre ladning',
- gaussian: 'Gaussisk flade',
- probe: 'Sonde',
- isolated: 'isoleret',
- connected: 'forsyning tilsluttet',
- noCurrent: 'Ingen nettostrøm',
- conventional: 'Konventionel strøm',
- drift: String.raw`Elektrondrift · vist $\times600$`,
- conduction: 'Ledningsbånd',
- valence: 'Valensbånd',
- midgap: 'båndmidte',
- gap: 'gab',
- energyConstant: 'Energien skifter form · summen er konstant',
- },
-} as const;
-
-export type SceneKey = keyof typeof scenePhrases.en;
-
-const words: Record<Language, Record<string, string>> = {
- en: {},
- da: {
- Repulsive: 'Frastødende',
- Attractive: 'Tiltrækkende',
- 'On surface': 'På overfladen',
- Inside: 'Inden for',
- Outside: 'Uden for',
- Undefined: 'Udefineret',
- pairs: 'par',
- },
-};
-
-const measurementLabels: Record<Language, Record<string, string>> = {
- en: {},
- da: {
- '|E| at origin': '|E| i origo',
- '|Force on right charge|': '|Kraft på højre ladning|',
- Interaction: 'Vekselvirkning',
- 'Net electric flux': 'Netto elektrisk flux',
- 'Enclosed charge': 'Indesluttet ladning',
- 'Source location': 'Kildens placering',
- 'Interaction energy': 'Vekselvirkningsenergi',
- 'Work by field, from 6 m': 'Feltets arbejde fra 6 m',
- '|Force on moving charge|': '|Kraft på den bevægelige ladning|',
- 'Potential at probe': 'Potential ved sonden',
- 'Ex at probe': 'Ex ved sonden',
- '|E| at probe': '|E| ved sonden',
- Capacitance: 'Kapacitans',
- 'Stored free charge |Q|': 'Lagret fri ladning |Q|',
- 'Electric field': 'Elektrisk felt',
- 'Stored energy': 'Lagret energi',
- 'Plate voltage': 'Pladespænding',
- 'Free charge |Q|': 'Fri ladning |Q|',
- 'Net field': 'Nettofelt',
- 'Polarization |P|': 'Polarisering |P|',
- Resistance: 'Resistans',
- 'Conventional current': 'Konventionel strøm',
- 'Electron drift, x': 'Elektrondrift, x',
- 'Dissipated power': 'Afgivet effekt',
- 'Electron density n': 'Elektrontæthed n',
- 'Hole density p': 'Hulletæthed p',
- 'Conductivity σ': 'Ledningsevne σ',
- 'Fermi shift EF − Ei': 'Forskydning af Fermi EF − Ei',
- 'Field work a → b': 'Feltets arbejde a → b',
- 'Work to current position': 'Arbejde til nuværende position',
- 'Potential energy Uᵦ': 'Potentiel energi Uᵦ',
- 'Potential energy U': 'Potentiel energi U',
- 'Kinetic energy K': 'Kinetisk energi K',
- 'Total K + U': 'Samlet K + U',
- 'Particle speed': 'Partikelhastighed',
- 'Potential energy Uₐ': 'Potentiel energi Uₐ',
- 'Energy of current assembly': 'Energi af den aktuelle samling',
- 'Three-charge energy U₁₂₃': 'Tre ladningers energi U₁₂₃',
- 'Additional energy ΔU₀': 'Ekstra energi ΔU₀',
- 'Pairs currently assembled': 'Par samlet nu',
- 'Projection Δx': 'Projektion Δx',
- 'Current angle θ': 'Aktuel vinkel θ',
- 'Torque τz': 'Kraftmoment τz',
- 'Work to current angle': 'Arbejde til aktuel vinkel',
- },
-};
-
-function storedLanguage(): Language {
- try {
- const stored = globalThis.localStorage?.getItem('language');
- if (stored === 'da' || stored === 'en') return stored;
- } catch {
- // Storage can be unavailable in private or non-browser contexts.
- }
- return 'en';
-}
-
-let language: Language = storedLanguage();
-const listeners = new Set<() => void>();
-
-export function getLanguage(): Language {
- return language;
-}
-
-export function setLanguage(next: Language) {
- language = next;
- try {
- localStorage.setItem('language', next);
- } catch {
- // Ignore storage failures; the in-memory choice still applies.
- }
- if (typeof document !== 'undefined') document.documentElement.lang = next;
- for (const listener of listeners) listener();
-}
-
-export function onLanguageChange(listener: () => void) {
- listeners.add(listener);
- return () => listeners.delete(listener);
-}
-
-export function t<K extends keyof Chrome>(key: K): Chrome[K] {
- return chrome[language][key];
-}
-
-export function scene(key: SceneKey): string {
- return scenePhrases[language][key];
-}
-
-export function word(english: string): string {
- return words[language][english] ?? english;
-}
-
-export function measurementLabel(english: string): string {
- return measurementLabels[language][english] ?? english;
-}
-
-export const daMeasurementLabels = measurementLabels.da;
-export const daWords = words.da;
diff --git a/src/i18n/coverage.test.ts b/src/i18n/coverage.test.ts
deleted file mode 100644
index f12178d..0000000
--- a/src/i18n/coverage.test.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { describe, expect, it, afterEach } from 'vitest';
-import { defaults, experiments, type Experiment } from '../experiments';
-import { daMeasurementLabels, daWords, setLanguage } from '../i18n';
-import { testLessons, type TestLesson } from '../test4';
-import { danishLessons } from './da';
-import { presentLesson } from './present';
-
-const lessons: Experiment[] = [...testLessons, ...experiments];
-
-afterEach(() => setLanguage('en'));
-
-describe('Danish catalog', () => {
- it('translates every lesson, control and answer while keeping the mathematics', () => {
- setLanguage('da');
- for (const lesson of lessons) {
- const copy = danishLessons[lesson.id];
- const view = presentLesson(lesson);
- expect(view.title).toBe(copy.title);
- expect(view.title).not.toBe(lesson.title);
- expect(view.shortTitle).toBe(copy.shortTitle);
- expect(view.legend.map(([, text]) => text)).toEqual(copy.legend);
- expect(view.controls.map(control => control.id)).toEqual(lesson.controls.map(control => control.id));
- for (const control of view.controls) {
- const source = lesson.controls.find(item => item.id === control.id)!;
- expect(control.label).toBe(copy.controls[control.id].label);
- expect(control.label).not.toBe(source.label);
- if (control.kind === 'select' && source.kind === 'select') {
- expect(control.options.map(option => option.value)).toEqual(source.options.map(option => option.value));
- expect(control.options.map(option => option.label)).toEqual(source.options.map(option => copy.controls[control.id].options?.[option.value]));
- }
- }
- if (!('questions' in lesson) || !('questions' in view)) continue;
- const source = lesson as TestLesson;
- const translated = view as TestLesson;
- expect(translated.questions).toHaveLength(source.questions.length);
- expect(translated.answers).toHaveLength(source.answers.length);
- translated.answers.forEach((part, index) => {
- expect(part.steps).toHaveLength(source.answers[index].steps.length);
- part.steps.forEach((step, stepIndex) => {
- expect(step.math).toBe(source.answers[index].steps[stepIndex].math);
- expect(step.why).not.toBe(source.answers[index].steps[stepIndex].why);
- });
- });
- expect(translated.observe(defaults(lesson))).not.toBe(source.observe(defaults(lesson)));
- }
- });
-
- it('has a Danish label for every live measurement and status word', () => {
- const labels = lessons.flatMap(lesson => lesson.measure(defaults(lesson)).map(measurement => measurement.label));
- for (const label of labels) expect(daMeasurementLabels[label], label).toBeTruthy();
- for (const status of ['Repulsive', 'Attractive', 'Inside', 'Outside', 'On surface', 'Undefined', 'pairs']) {
- expect(daWords[status], status).toBeTruthy();
- }
- setLanguage('da');
- const field = presentLesson(experiments[0]);
- expect(field.measure({ ...defaults(field), configuration: 'like' }).map(measurement => measurement.value)).toContain('Frastødende');
- expect(presentLesson(lessons[0]).title).toBe('Arbejde langs en bane');
- setLanguage('en');
- expect(presentLesson(lessons[0]).title).toBe('Work along a path');
- });
-});
diff --git a/src/i18n/da.ts b/src/i18n/da.ts
index dbdbc2e..2eea50e 100644
--- a/src/i18n/da.ts
+++ b/src/i18n/da.ts
@@ -1,391 +1,101 @@
-import type { ExperimentId } from '../experiments';
-import type { LessonText, TestCopy } from './lesson-text';
+import type { MessageKey } from './en';
-const openstax = 'OpenStax · Universitetsfysik';
-const original = 'Oprindeligt spørgsmål · Test_4.md';
-const chargeLegend = ['Positiv ladning', 'Negativ ladning', 'Elektrisk felt E', 'Bane / kraft / energi'];
-
-const lab: Record<Exclude<ExperimentId, `test-${string}`>, LessonText> = {
- field: {
- title: 'Elektrisk ladning, kraft og felt',
- shortTitle: 'Ladning og felt',
- description: 'Se hvordan ladninger med fortegn skaber et vektorfelt og udøver kræfter på hinanden.',
- explanation: 'En positiv prøveladning følger det elektriske felt; en negativ ladning mærker en kraft i modsat retning. Den store guld-pil viser kraften på den højre kilde, og kun den, der skyldes den venstre kilde.',
- challenge: 'Fordobl afstanden. Hvad sker der med kraften? Skift til ens ladninger, og se tiltrækning blive til frastødning.',
- limits: String.raw`Punktladninger i vakuum. x–z-planen samples; punkter inden for $0.18\,\mathrm m$ fra en kilde udelades. Pilenes længder er komprimeret, og kraftpilen er normaliseret.`,
- sceneNote: String.raw`Gitter: store kvadrater på $1\,\mathrm m$ · små pile: $E$ · guld-pil: kraft på højre ladning`,
- legend: ['Positiv ladning', 'Negativ ladning', 'Elektrisk felt', 'Kraft'],
- sourceTitle: openstax,
- controls: {
- charge: { label: 'Kildeladningens størrelse' },
- separation: { label: 'Ladningsafstand' },
- configuration: { label: 'Ladningstegn', options: { opposite: 'Modsat (+ / −)', like: 'Ens (+ / +)' } },
- },
- },
- flux: {
- title: 'Elektrisk flux og ladning',
- shortTitle: 'Flux og Gauss’ lov',
- description: 'Flyt en ladning gennem en gaussisk flade, og sammenlign det lokale felt med nettofluxen.',
- explanation: String.raw`Den gennemsigtige kugle er en tænkt lukket flade, ikke en leder. Pilene viser $E$ i fladens prøvepunkter: mint betyder udadgående flux, og koral betyder indadgående flux. En ladning udenfor skaber stadig et felt, men dens samlede flux gennem den lukkede flade er nul.`,
- challenge: 'Hold ladningen indenfor, og gør kuglen større. Flyt derefter ladningen udenfor. Kan der være et felt forskelligt fra nul, når nettofluxen er nul?',
- limits: 'Fluxvisningen bruger Gauss’ lov eksakt; pilene er diskrete feltprøver, ikke et numerisk integral. En punktladning præcis på fladen gør fluxen udefineret.',
- sceneNote: String.raw`Tænkt gaussisk flade · mint: $\vec E\cdot\hat n>0$ · koral: $\vec E\cdot\hat n<0$`,
- legend: ['Udadgående flux', 'Indadgående flux', 'Gaussisk flade'],
- sourceTitle: openstax,
- controls: {
- charge: { label: 'Kildeladning med fortegn' },
- radius: { label: 'Gaussisk kugles radius' },
- position: { label: 'Ladningens x-position' },
- },
- },
- energy: {
- title: 'Elektrisk potentiel energi',
- shortTitle: 'Potentiel energi',
- description: 'Adskil to ladninger, og forbind vekselvirkningsenergien med det arbejde, feltet udfører.',
- explanation: 'Kilden bliver til venstre. Den mintfarvede kurve viser vekselvirkningsenergien som funktion af afstanden; guld-markøren følger den aktuelle indstilling. Modsatte ladninger har negativ energi i forhold til uendelig adskillelse, så det kræver ydre arbejde at trække dem fra hinanden.',
- challenge: String.raw`Flyt en modsat ladning tættere på. Stiger eller falder $U$? Gør derefter den bevægelige ladning positiv, og sammenlign fortegnet for feltets arbejde.`,
- limits: String.raw`To foreskrevne punktladninger med $U=0$ i det uendelige. Kurven bruger en fast visningsskala på 20 nJ pr. lodret sceneenhed. Betjeningen sætter positioner; den integrerer ikke bevægelse.`,
- sceneNote: String.raw`Kurve: $U$ som funktion af afstanden · guld-punkt: aktuel $U$ · vandret akse for nulenergi`,
- legend: ['Fast +3 nC', 'Negativ ladning', 'Energikurve', 'Aktuel energi'],
- sourceTitle: openstax,
- controls: {
- testCharge: { label: 'Bevægelig ladning' },
- distance: { label: 'Afstand fra +3 nC-kilden' },
- },
- },
- potential: {
- title: 'Elektrisk potential og gradient',
- shortTitle: 'Potential og gradient',
- description: 'Aflæs det elektriske potential som et landskab, og forbind nedadbakke-retningen med feltet.',
- explanation: String.raw`Varme toppe er positivt potential; blå dale er negativt. Mint-pile viser det elektriske felt projiceret i x–z-planen. Guld-sonden måler $V$ og x-komponenten af $E$. Feltpilene peger mod aftagende potential.`,
- challenge: String.raw`Brug modsatte ladninger: potentialet i midtpunktet kan være nul, mens feltet er forskelligt fra nul. Sammenlign $V$ og $E_x$, mens du flytter sonden.`,
- limits: String.raw`Højde $=2\tanh\left(\frac{V}{15\,\mathrm V}\right)$, en komprimeret potentialskala. Huller udelader kildernes singulariteter. Pilenes længder er komprimeret. Sonden er altid i $z=1\,\mathrm m$, væk fra kilderne; $V=0$ i det uendelige.`,
- sceneNote: String.raw`Højde og farve koder potentialet · guld-sonde i $z=1\,\mathrm m$ · pile: $-\nabla V$`,
- legend: ['Positivt potential', 'Negativt potential', 'Sonde'],
- sourceTitle: openstax,
- controls: {
- charge: { label: 'Kildeladningens størrelse' },
- separation: { label: 'Ladningsafstand' },
- configuration: { label: 'Ladningstegn', options: { opposite: 'Modsat (+ / −)', like: 'Ens (+ / +)' } },
- probe: { label: 'Sondens x-position (z = 1 m)' },
- },
- },
- capacitance: {
- title: 'Kapacitans',
- shortTitle: 'Kapacitans',
- description: 'Byg en pladekondensator, og se hvordan geometrien styrer ladningslagringen.',
- explanation: 'Den venstre plade er positiv, og den højre er negativ. Ved en fast påtrykt spænding lagrer større plader mere ladning, mens et større gab mindsker kapacitansen. Pilene viser tilnærmelsen med et homogent felt mellem pladerne.',
- challenge: 'Fordobl arealet, og fordobl derefter gabet. Hvilke målinger vender tilbage til deres oprindelige værdier, og hvilke gør ikke?',
- limits: 'Ideelle parallelle plader i vakuum, forbundet til en fast spændingskilde. Randeffekter ignoreres. Pladeafstanden er forstørret 50× i forhold til de laterale mål, så den kan ses; ladningsmarkørerne er illustrative. Pilene spænder over gabet for at vise retning, ikke størrelse.',
- sceneNote: 'Positiv plade → felt → negativ plade · gab forstørret 50×',
- legend: ['Positiv plade', 'Negativ plade', 'Homogent E'],
- sourceTitle: openstax,
- controls: {
- area: { label: 'Pladeareal' },
- gap: { label: 'Pladeafstand' },
- voltage: { label: 'Opladningsspænding' },
- },
- },
- dielectric: {
- title: 'Dielektriske materialer',
- shortTitle: 'Dielektrika',
- description: 'Polariser en isolator, og sammenlign indsættelse med tilsluttet eller afbrudt forsyning.',
- explanation: String.raw`Bundne dipoler retter sig ind efter feltet: deres negative ender vender mod den positive plade. De bundne ladninger modvirker feltet fra pladernes frie ladninger. Med et batteri tilsluttet strømmer ekstra fri ladning til, og $V$ forbliver fast. Når kondensatoren er isoleret, forbliver $Q$ fast, og $V$ falder.`,
- challenge: String.raw`Skift til isoleret, og sammenlign $\kappa$ = 1 med $\kappa$ = 4. Tilslut derefter forsyningen igen, og gentag: hvilke størrelser holdes konstante?`,
- limits: String.raw`Et homogent, lineært dielektrikum udfylder hele gabet. Isoleret $Q$ er defineret ved at oplade denne geometri i vakuum ved den valgte opladningsspænding, derefter afbryde og indsætte materialet. Hver indstilling er en ny forberedelse; gennemslag og randeffekter er udeladt. Gab forstørret 50×; feltpile viser retning, ikke størrelse.`,
- sceneNote: String.raw`Guld-volumen: dielektrikum · blå/røde par: bundne dipoler · $\kappa$ = 1: ingen polarisering`,
- legend: ['Dielektrikum', 'Bundet −', 'Bundet +', 'Nettofelt'],
- sourceTitle: openstax,
- controls: {
- permittivity: { label: 'Relativ permittivitet κ' },
- connection: { label: 'Elektrisk tilstand', options: { connected: 'Tilsluttet · spænding holdes fast', isolated: 'Isoleret · fri ladning holdes fast' } },
- area: { label: 'Pladeareal' },
- gap: { label: 'Pladeafstand' },
- voltage: { label: 'Opladningsspænding' },
- },
- equations: [
- String.raw`C = \frac{\kappa\varepsilon_0 A}{d},\qquad \vec P = \varepsilon_0(\kappa-1)\vec E`,
- String.raw`\begin{aligned}\text{Fast }V:&\quad Q\propto\kappa\\\text{Fast }Q:&\quad V,E\propto\frac1\kappa\end{aligned}`,
- ],
- },
- current: {
- title: 'Strøm',
- shortTitle: 'Strøm og drift',
- description: 'Driv elektroner gennem en kobbertråd, og sammenlign deres drift med den konventionelle strøm.',
- explanation: 'Mint viser den konventionelle strøm, defineret som strøm af positiv ladning. Blå elektroner driver i modsat retning. Højere spænding øger driften; en længere tråd øger resistansen. En bredere tråd fører mere strøm ved samme spænding.',
- challenge: 'Vend spændingen. Sæt den derefter til nul. Øg trådens areal ved fast spænding: stiger strømmen, og ændrer elektronernes driftshastighed sig?',
- limits: String.raw`Ohmisk kobber ved fast temperatur: $\rho=1.68\times10^{-8}\,\Omega\,\mathrm m$, $n=8.5\times10^{28}\,\mathrm m^{-3}$. Driften vises 600× hurtigere, og trådens radius er overdrevet. Termisk spredning, opvarmning og signaludbredelse simuleres ikke; $P$ er afgiven effekt.`,
- sceneNote: 'Blå: elektrondrift · mint: konventionel strøm · hastighed ×600 · radius overdrevet',
- legend: ['Elektroner', 'Konventionel strøm', 'Kobberioner'],
- sourceTitle: openstax,
- controls: {
- voltage: { label: 'Spænding over tråden' },
- length: { label: 'Trådlængde' },
- area: { label: 'Tværsnitsareal' },
- },
- },
- semiconductor: {
- title: 'Halvledermaterialer',
- shortTitle: 'Halvledere',
- description: String.raw`Undersøg intrinsic, $n$-type og $p$-type silicium gennem ladningsbærere og et energibånddiagram.`,
- explanation: String.raw`Elektroner optager ledningsbåndet; huller er manglende elektroner i valensbåndet. Donorer hæver elektrontætheden og Ferminiveauet; acceptorer hæver hulletætheden og sænker det. Guld-linjen markerer $E_F$. Bærermarkørerne viser populationer på en komprimeret skala, ikke enkelte atomer.`,
- challenge: String.raw`Skift fra intrinsic til $n$-type og derefter til $p$-type ved samme dotering. Følg majoritetsbæreren og Ferminiveauets forskydning. Hvorfor er ledningsevnerne forskellige?`,
- limits: String.raw`Illustrative referenceværdier for silicium ved $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)}$ og $\mu_p=0.048\,\mathrm{m^2/(V\,s)}$. Fuldt ioniseret, ikke-degenereret ligevægt med faste mobiliteter. $E_i$ er tegnet omtrent ved båndmidten. Bevægelsen er skematisk termisk bevægelse uden påtrykt felt eller nettodrift; tæthedsbetjeningen er inaktiv for intrinsic silicium.`,
- sceneNote: String.raw`Øvre blå bånd: ledning · nedre koralbånd: valens · guld: $E_F$ · ingen nettostrøm`,
- legend: ['Ledningselektroner', 'Valenshuller', 'Ferminiveau'],
- sourceTitle: 'OpenStax · Båndteori for faste stoffer',
- controls: {
- doping: { label: 'Siliciumprøve ved 300 K', options: { intrinsic: 'Intrinsic · udopet', n: 'n-type · donorurenheder', p: 'p-type · acceptorurenheder' } },
- concentration: { label: 'Doteringstæthed · log₁₀(N / m⁻³)' },
- },
- },
-};
-
-const test: Record<Extract<ExperimentId, `test-${string}`>, TestCopy> = {
- 'test-work': {
- title: 'Arbejde langs en bane',
- shortTitle: 'Arbejde og baner',
- description: 'Følg en ladning fra a til b. Kun den del af kraften, der ligger langs hvert lille skridt, bidrager til arbejdet.',
- questions: [String.raw`Hvilket arbejde udfører den elektriske kraft, når $q_0$ flyttes fra a til b?`],
- controls: {
- q0: { label: 'Bevægelig ladning q₀' },
- field: { label: 'Elektrisk felt E' },
- distance: { label: 'Forskydning s' },
- path: { label: 'Baneform', options: { straight: 'Ret bane', curved: 'Krum bane · samme endepunkter' } },
- progress: { label: 'Rejseforløb' },
- },
- explanation: 'Skalarproduktet måler, hvor meget den elektriske kraft hjælper eller modvirker hvert skridt. Elektrostatisk arbejde afhænger af endepunkterne. Positivt feltarbejde betyder et fald i elektrisk potentiel energi.',
- challenge: 'Skift mellem de to baner. Forudsig det endelige arbejde, før du ændrer den bevægelige ladning fra positiv til negativ.',
- prediction: 'Kræver den længere, krumme rute mere arbejde af det elektriske felt?',
- predictionAnswer: String.raw`Nej. I et elektrostatisk felt er $W=U_a-U_b$. Begge ruter starter og slutter de samme steder. Skridt på tværs er vinkelrette på $E$ og bidrager ikke til arbejdet. Ekstra bevægelse langs $E$ ophæver bevægelsen tilbage mod $E$.`,
- sceneNote: String.raw`Eksempel: homogent $E$ langs +x. Grønne pile er $E$; guld-pilen er den elektriske kraft. Begge baner deler a og b.`,
- limits: String.raw`Elektrostatisk vakuummodel; foreskreven bane, ikke en fri partikelbane. Geometrien er i meter; felt- og kraftpile viser retning med komprimerede længder. Guld-sonden styres af rejseforløbet; stiplede endepunkter bliver stående. Vælg $U_a=0$.`,
- legend: chargeLegend,
- sourceTitle: original,
- answers: [{
- title: 'Spørgsmål 1 · Arbejde udført af den elektriske kraft · 30p',
- steps: [
- 'Start med definitionen af arbejde: læg den del af kraften sammen, der følger hver lille forskydning.',
- String.raw`Indsæt den elektriske kraft på den bevægelige ladning, $\vec F_e=q_0\vec E$.`,
- 'Den elektrostatiske kraft er konservativ, så feltets arbejde er faldet i potentiel energi.',
- ],
- }],
- },
- 'test-conservation': {
- title: 'Hvor bliver energien af?',
- shortTitle: 'Bevarelse',
- description: 'En positiv ladning bevæger sig væk fra en fast positiv kilde. Se potentiel energi blive til kinetisk energi.',
- questions: ['Skriv betingelsen for bevarelse af den samlede energi.'],
- controls: {
- q0: { label: 'Bevægelig ladning q₀' },
- kinetic: { label: 'Begyndelseskinetisk energi Kₐ' },
- progress: { label: 'Rejseforløb' },
- },
- equations: [String.raw`\Delta E_{\mathrm{total}} = 0\quad\text{(isoleret system)}`, String.raw`K_a+U_a = K_b+U_b`, String.raw`\Delta K = W_{ab} = -\Delta U`],
- explanation: 'Den samlede energi er bevaret for et isoleret system. Hvis kun den elektriske kraft udfører arbejde på den bevægelige ladning, er summen af kinetisk energi og elektrisk potentiel energi konstant. Kilden er fast; dens ophæng udfører intet arbejde.',
- challenge: String.raw`Flyt rejseforløbet fra 0 % til 100 %. Kontrollér, at den samlede søjle holder samme højde, mens $K$ stiger og $U$ falder.`,
- prediction: 'Hvis partiklen starter i hvile, kan den så få fart uden en energitilførsel?',
- predictionAnswer: String.raw`Ja. Dens begyndelsesværdi af elektrisk potentiel energi leverer stigningen i kinetisk energi. Energien skifter form, mens $K+U$ forbliver konstant. Partiklen og kilden udgør tilsammen det elektrostatiske system; den bevægelige partikel alene har ikke konstant kinetisk energi.`,
- sceneNote: String.raw`Fri radial bevægelse væk fra +3 nC. Søjlerne viser $U$, $K$ og deres konstante sum; massen er 1 mg.`,
- limits: String.raw`Kilden er fast, vakuum, ikke-relativistisk punktpartikel med massen 1 mg. $U(\infty)=0$. Rejseforløbet vælger en position, ikke forløbet tid; hastigheden beregnes ud fra energien, ikke ud fra skyderens hastighed. Søjlehøjder er normaliseret til den samlede energi; aflæsningerne er i nJ.`,
- legend: chargeLegend,
- sourceTitle: original,
- answers: [{
- title: 'Spørgsmål 2 · Bevarelsesbetingelse · 15p',
- steps: [
- 'For et isoleret system krydser der ingen nettoenergi systemets grænse.',
- 'Hvis den elektriske kraft er den eneste kraft, der udfører arbejde, er den mekaniske energi bevaret.',
- String.raw`Indsæt den kinetiske energi for en partikel med massen $m$. Arbejde-energi-sætningen forbinder de to ændringer.`,
- ],
- }],
- },
- 'test-central': {
- title: 'En punktladnings felt',
- shortTitle: 'Centralt felt',
- description: String.raw`Flyt $q_0$ radialt gennem et centralt felt. Forbind den afstandskvadrerede kraft med en potentiel energi, der aftager som én over afstanden.`,
- questions: [
- String.raw`Når $q_0$ bevæger sig fra a til b i et $E$-felt fra en punktladning $q$ (“centralt felt”).`,
- 'a. (30p) Udled det arbejde, den elektriske kraft udfører.',
- String.raw`b. (25p) Arbejdet ovenfor er lig med faldet $U_a$ − $U_b$. Hvad er $U_a$ og $U_b$, hvis $b\to\infty$?`,
- ],
- controls: {
- source: { label: 'Kildeladning q' },
- q0: { label: 'Bevægelig ladning q₀' },
- ra: { label: 'Begyndelsesafstand rₐ' },
- rb: { label: 'Slutafstand rᵦ' },
- destination: { label: 'Endepunkt b', options: { finite: 'Endelig slutafstand', infinity: 'Uendelighed · slutenergien er nul' } },
- progress: { label: 'Rejseforløb' },
- },
- explanation: String.raw`Kun radial forskydning bidrager til arbejdet i et centralt felt. Integration af $\frac1{r^2}$ giver $-\frac1r$. Nulpunktet i det uendelige fastlægger energireferencen; det er ikke en ekstra kraftlov.`,
- challenge: String.raw`Sammenlign ens og modsatte ladninger. Send b til uendelig, og kontrollér at $W_{a\infty}=U_a$, inklusive fortegnet.`,
- prediction: String.raw`Er $U$ altid positiv, når vi vælger $U(\infty)=0$?`,
- predictionAnswer: String.raw`Nej. $U=\frac{kqq_0}{r}$ er positiv for ens ladninger og negativ for modsatte ladninger. En negativ værdi betyder, at konfigurationen har mindre energi end de adskilte ladninger i det uendelige. Feltets arbejde ved at adskille modsatte ladninger er negativt.`,
- sceneNote: String.raw`Radial bane foroven; $U(r)$-kurve forneden. Uendelighedstilstanden viser en fortsættelse udad, ikke en endelig stedfortræder for uendelighed.`,
- limits: String.raw`Fast punktkilde; Coulombs konstant i vakuum $k=\frac1{4\pi\varepsilon_0}$. Afstande i meter. $U$-graf: 30 nJ pr. lodret sceneenhed, klippet uden for ±2,5 enheder. Uendelighed har analytisk præcis $U_b=0$; den synlige bane stopper ved diagrammets kant og placerer ikke b i en endelig radius.`,
- legend: chargeLegend,
- sourceTitle: original,
- answers: [
- {
- title: '3a · Udled det radiale arbejde · 30p',
- steps: [
- 'Skriv det radiale felt og kraften fra den faste punktladning.',
- String.raw`Den radiale komponent af forskydningen er $\hat r\cdot\mathrm d\vec r=\mathrm dr$. Tangentiale skridt udfører intet arbejde.`,
- 'Integrer, og indsæt endepunkterne. Resultatet afhænger kun af deres afstande.',
- ],
- },
- {
- title: '3b · Sæt nulpunktet i det uendelige · 25p',
- steps: [
- String.raw`Vælg $U(\infty)=0$. Når $r_b\to\infty$, går den reciprokke afstand mod nul.`,
- String.raw`Brug $W_{a\infty}=U_a-U_\infty$ til at identificere de to endepunktsenergier.`,
- String.raw`Det giver den generelle vekselvirkningsenergi. Dens fortegn er fortegnet af $qq_0$.`,
- ],
- },
- ],
- },
- 'test-assembly': {
- title: 'Opbyg et ladningssystem',
- shortTitle: 'Ladningssamling',
- description: 'Bring ladningerne ind én ad gangen. Hver forbindende kant er én vekselvirkningsenergi, talt præcis én gang.',
- questions: [
- 'Ladninger bringes sammen.',
- String.raw`a. (15p) Hvad er den samlede elektriske potentielle energi, hvis $q_1$, $q_2$ og $q_3$ bringes sammen fra det uendelige?`,
- String.raw`b. (10p) Hvad er den ekstra elektriske potentielle energi, hvis $q_0$ bringes sammen med $q_1$, $q_2$ og $q_3$?`,
- ],
- controls: {
- stage: {
- label: 'Samlingstrin',
- options: { '1': '1 · Placer første ladning', '2': '2 · Tilføj anden ladning', '3': '3 · Tilføj tredje ladning', '4': '4 · Tilføj prøveladning' },
- },
- spacing: { label: 'Konfigurationens størrelse' },
- q0: { label: 'Tilføjet ladning q₀' },
- },
- explanation: String.raw`Placer $q_1$ gratis. Tilføjelse af $q_2$ skaber ét par; tilføjelse af $q_3$ skaber to mere. Den fjerde ladning skaber tre nye par uden at ændre energien af de eksisterende tre. Ved kvasistatisk samling er det ydre arbejde lig med stigningen i $U$.`,
- challenge: String.raw`Gå igennem alle fire samlingstrin. Vend derefter $q_0$, og se fortegnet af dens tre nye vekselvirkninger vende.`,
- prediction: String.raw`Skal den samlede energi indeholde både $\frac{q_1q_2}{r_{12}}$ og $\frac{q_2q_1}{r_{21}}$?`,
- predictionAnswer: String.raw`Nej. De beskriver det samme par. Sum kun over $i<j$. Tre ladninger har tre forskellige par; fire ladninger har seks. Den ekstra energi fra $q_0$ omfatter kun dens tre nye par, $\Delta U_0=q_0V_{123}$.`,
- sceneNote: 'Et tetraederarrangement. Mint-kanter: eksisterende par. Guld-kanter: par indført af den nyeste ladning. Stiplede markører: endnu ikke samlet.',
- limits: String.raw`Punktladninger fastholdt i forskellige positioner. $q_1$ = +3 nC, $q_2$ = −2 nC, $q_3$ = +2 nC. Alle koordinater skaleres med konfigurationens størrelse i meter. Reference: alle ladninger er gensidigt adskilt i det uendelige. Selvenergier er udeladt. Totaler for senere trin vises i aflæsningerne, også før de ladninger er placeret.`,
- legend: chargeLegend,
- sourceTitle: original,
- answers: [
- {
- title: '4a · Saml q₁, q₂, q₃ · 15p',
- steps: [
- String.raw`Bring først $q_1$ ind fra det uendelige. Når ingen andre ladninger er til stede, er dens vekselvirkningsenergi nul.`,
- String.raw`Bring $q_2$ ind mod potentialet fra $q_1$; bring derefter $q_3$ ind mod potentialet fra $q_1$ og $q_2$.`,
- 'Læg bidragene sammen, og tæl hvert par én gang.',
- ],
- },
- {
- title: '4b · Tilføj q₀ til det faste system · 10p',
- steps: [
- String.raw`De eksisterende tre ladninger holdes fast. Tilføj kun de tre nye vekselvirkninger, der involverer $q_0$.`,
- String.raw`Sæt $q_0$ uden for parentesen. Den resterende sum er potentialet fra de oprindelige tre ladninger i den nye position.`,
- ],
- },
- ],
- },
- 'test-uniform': {
- title: 'Arbejde i et homogent felt',
- shortTitle: 'Homogent felt',
- description: String.raw`Drej forskydningen i forhold til feltet. Projektionen langs $E$ bestemmer arbejdet, ikke hele banens længde.`,
- questions: [
- String.raw`Når $q_0$ bevæger sig fra a til b i et homogent $E$-felt.`,
- 'a. (15p) Udled det arbejde, den elektriske kraft udfører.',
- String.raw`b. (10p) Hvad er $U_{\mathrm{pot},a}$ og $U_{\mathrm{pot},b}$?`,
- ],
- controls: {
- q0: { label: 'Bevægelig ladning q₀' },
- field: { label: 'Elektrisk felt E' },
- distance: { label: 'Forskydning s' },
- angle: { label: 'Forskydningsvinkel α' },
- progress: { label: 'Rejseforløb' },
- },
- explanation: String.raw`Et konstant felt kan tages uden for arbejdsintegralet. Med x langs $E$ ændrer den potentielle energi sig kun med x. Energinulpunktet er vilkårligt; her er a referencen, så $U_a=0$.`,
- challenge: String.raw`Sæt $\alpha$ til $90^\circ$. Ladningen tilbagelægger en afstand forskellig fra nul, men $E$ er vinkelret på hvert skridt: arbejdet er nul.`,
- prediction: String.raw`Hvis $\alpha$ er over $90^\circ$ for en positiv ladning, afgiver feltet så energi til ladningen?`,
- predictionAnswer: String.raw`Feltet udfører negativt arbejde, fordi $\cos\alpha<0$. $U$ stiger, når ladningen bevæger sig mod feltet. En ydre aktør måtte tilføre den energi for at flytte den langsomt, eller en frit bevægelig partikel ville miste kinetisk energi.`,
- sceneNote: String.raw`Forskydningen ligger i xy-planen; $E$ peger langs +x. Det blå stykke er $\Delta x=s\cos\alpha$. Vælg $U_a=0$.`,
- limits: String.raw`Ideelt konstant felt uden randeffekter. Geometri i meter; pile viser retning med komprimerede længder. Foreskreven bevægelse, ikke en fri partikelbane. $U_a=0$ fastlægger $C=q_0Ex_a$; $x_a$ vises eksplicit af markøren a.`,
- legend: chargeLegend,
- sourceTitle: original,
- answers: [
- {
- title: '5a · Udled arbejdet i et homogent felt · 15p',
- steps: [
- String.raw`Start med arbejdsintegralet. Feltet er konstant, så tag $\vec E$ uden for integralet.`,
- String.raw`Skriv skalarproduktet med forskydningens længde og vinklen $\alpha$.`,
- 'Vælg x-aksen langs feltet. Kun x-forskydningen bidrager.',
- ],
- },
- {
- title: '5b · Potentiel energi i endepunkterne · 10p',
- steps: [
- 'Ændringen i potentiel energi er minus feltets arbejde.',
- String.raw`Integration med hensyn til x efterlader en vilkårlig referencekonstant $C$.`,
- String.raw`Et bekvemt valg er $U_a=0$. Det ændrer ingen kræfter eller energiforskelle.`,
- ],
- },
- ],
- },
- 'test-dipole': {
- title: 'Drej en dipol i et felt',
- shortTitle: 'Dipolrotation',
- description: 'To lige store og modsat rettede kræfter ophæver hinanden ved translation, men danner et kraftmoment. Drej dipolen for at undersøge arbejde og stabilitet.',
- questions: [
- String.raw`Den elektriske dipol i et homogent $E$-felt.`,
- String.raw`a. (10p) Udled kraftmomentvektoren, og bevis $\tau=p_eE\sin\theta$, hvor $\theta=\angle(\vec p_e,\vec E)$.`,
- String.raw`b. (30p) Udled $W_{ab}$, når dipolen drejer fra $\theta_a$ til $\theta_b$. Hvad er $U_a$ og $U_b$? Du behøver ikke udlede $\mathrm dW=-\tau\,\mathrm d\theta$.`,
- String.raw`c. (10p) Forklar, hvorfor $\theta_a$ = $90^\circ$ kan være en reference, angiv $U_a$, og find det generelle $U$ også som et skalarprodukt.`,
- ],
- controls: {
- charge: { label: 'Dipolladningens størrelse q' },
- length: { label: 'Ladningsafstand d' },
- field: { label: 'Elektrisk felt E' },
- thetaA: { label: 'Begyndelsesvinkel θₐ' },
- thetaB: { label: 'Slutvinkel θᵦ' },
- progress: { label: 'Rotationsforløb' },
- },
- explanation: String.raw`Dipolmomentet peger fra $-q$ mod $+q$. De lige store og modsat rettede elektriske kræfter danner et kraftpar, der søger at rette $\vec p_e$ ind efter $\vec E$. Vi bruger $U(90^\circ)=0$; indretning er tilstanden med mindst energi.`,
- challenge: String.raw`Sæt slutvinklen til $0^\circ$ og derefter til $180^\circ$, med rotationsforløbet på 100 %. Begge har kraftmoment nul. Sammenlign deres energi og stabilitet.`,
- prediction: String.raw`Hvis kraftmomentet er nul ved både $0^\circ$ og $180^\circ$, er begge orienteringer så stabile?`,
- predictionAnswer: String.raw`Nej. Ved $0^\circ$ er $U=-p_eE$ et minimum: en lille vinkeldrejning giver et tilbageførende kraftmoment. Ved $180^\circ$ er $U=+p_eE$ et maksimum: en lille drejning fører dipolen længere væk. Ved $90^\circ$ er $U=0$ per konvention, men kraftmomentet er størst, så det er ikke ligevægt. Uden dæmpning kan en frit roterende dipol svinge om indretningen.`,
- sceneNote: String.raw`$E$ langs $+x$; $\vec p_e$ peger fra − mod +. Guld-pile viser kræfter; den blå z-pil viser kraftmomentet med fortegn. $U(\theta)$ forneden.`,
- limits: String.raw`Ideel stiv dipol med fast centrum i et homogent felt. Geometri i meter. Ingen nettokraft. Vinklen måles mod uret i xy, så $\tau_z=-p_eE\sin\theta$. Skyderen foreskriver orienteringen, ikke en dæmpet dynamisk bane. Kraftmomentets pillængde og grafhøjden er normaliseret; aflæsningerne giver størrelser i SI-afledte enheder. $U(90^\circ)=0$.`,
- legend: chargeLegend,
- sourceTitle: original,
- answers: [
- {
- title: '6a · Kraftmomentets vektor og størrelse · 10p',
- steps: [
- String.raw`Lad $\vec d$ pege fra den negative ladning mod den positive. Definér dipolmomentet.`,
- String.raw`Tag momenter om midtpunktet. De to momentarme er $\pm\frac{\vec d}{2}$.`,
- 'Krydsproduktet giver vektorens retning og størrelse. De to kræfter danner et kraftpar.',
- ],
- },
- {
- title: '6b · Rotationsarbejde og energi i endepunkterne · 30p',
- steps: [
- String.raw`Kraftmomentet virker mod aftagende $\theta$. Med $\tau$ som den positive størrelse bruges den givne relation.`,
- 'Integrer fra begyndelsesvinklen til slutvinklen.',
- String.raw`Brug $W_{ab}=U_a-U_b$. Referencekonstanten er den samme i begge endepunkter.`,
- ],
- },
- {
- title: '6c · Reference ved 90° og skalarprodukt · 10p',
- steps: [
- String.raw`Nulpunktet for potentiel energi er vilkårligt. Ved $90^\circ$ er de to ladninger på samme elektriske potential, så $+qV$ og $-qV$ ophæver hinanden.`,
- String.raw`Da $\cos90^\circ=0$, sætter denne reference $C=0$.`,
- 'Indrettet er et stabilt energiminimum; modsat er et ustabilt maksimum. Den vinkelrette reference er ikke en ligevægt.',
- ],
- },
- ],
- },
+export const da: Record<MessageKey, string> = {
+ 'app.title': 'Fieldwork · Elektrisk fluks og ladning',
+ 'app.brand': 'fieldwork',
+ 'app.tagline': 'Et laboratorium for elektrofysik',
+ 'app.skip': 'Gå til eksperimentet',
+ 'app.navigation': 'Hovednavigation',
+ 'app.experiment': 'Eksperiment',
+ 'app.principle': 'Fysikken',
+ 'app.module': 'MODEL {number}',
+ 'app.footer': 'Lidt nysgerrighed. En klarere forståelse.',
+ 'app.footerNote': 'Elektrofysik / Interaktiv læring',
+ 'app.loadError': 'Modellen kunne ikke indlæses. Genindlæs siden for at prøve igen.',
+ 'app.reload': 'Genindlæs siden',
+ 'flux.eyebrow': 'ELEKTRICITET GJORT SYNLIG',
+ 'flux.title': 'Elektrisk fluks og ladning',
+ 'flux.subtitle': 'Flyt en ladning. Ændr dens omgivelser. Find ud af, hvad der virkelig bestemmer den elektriske fluks.',
+ 'flux.topic': 'Gauss’ lov',
+ 'flux.live': 'INTERAKTIV 3D-MODEL',
+ 'flux.sceneTitle': 'Én ladning. Én lukket flade.',
+ 'flux.sceneLabel': '3D-model af elektrisk fluks. Træk for at dreje rundt og scroll for at zoome. Når modellen er i fokus, brug piletasterne til at dreje, plus eller minus til at zoome og Home til at nulstille visningen.',
+ 'flux.sceneHint': 'Træk for at dreje · Scroll for at zoome',
+ 'flux.keyboardHint': 'Tastatur: piletaster for at dreje · + / − for at zoome · Home for at nulstille',
+ 'flux.resetView': 'Nulstil visning',
+ 'flux.frontView': 'Set forfra',
+ 'flux.topView': 'Set ovenfra',
+ 'flux.cameraViews': 'Kameravisninger',
+ 'flux.legend.positive': 'Positivt felt',
+ 'flux.legend.negative': 'Negativt felt',
+ 'flux.legend.zero': 'Intet elektrisk felt',
+ 'flux.legend.surface': 'Gaussisk flade',
+ 'flux.legend.normal': 'Udadrettet normal',
+ 'flux.marker': 'q = {value}',
+ 'flux.status.inside': 'Ladning indeni',
+ 'flux.status.outside': 'Ladning udenfor',
+ 'flux.status.boundary': 'Ladning på fladen',
+ 'flux.status.neutral': 'Ingen ladning',
+ 'flux.controls': 'Gør det til dit eget eksperiment',
+ 'flux.controlsHint': 'Ændr én ting. Se, hvad der sker.',
+ 'flux.charge': 'Ladning',
+ 'flux.position': 'Ladningens position',
+ 'flux.positionHint': 'Flyt den langs x-aksen gennem fladen.',
+ 'flux.surface': 'Gaussisk flade',
+ 'flux.sphere': 'Kugle',
+ 'flux.cube': 'Terning',
+ 'flux.radius': 'Fladens radius',
+ 'flux.halfSide': 'Halv sidelængde',
+ 'flux.surfaceHint': 'En tænkt grænse, ikke et fysisk skal.',
+ 'flux.layers': 'Synlige lag',
+ 'flux.fieldLines': 'Elektriske feltlinjer',
+ 'flux.surfaceLayer': 'Gaussisk flade',
+ 'flux.normals': 'Fladens normaler',
+ 'flux.reset': 'Nulstil eksperiment',
+ 'flux.presets': 'Start med et scenarie',
+ 'flux.preset.positive': 'Positiv ladning',
+ 'flux.preset.negative': 'Negativ ladning',
+ 'flux.preset.outside': 'Udenfor fladen',
+ 'flux.preset.custom': 'Eget eksperiment',
+ 'flux.readings': 'Målinger i realtid',
+ 'flux.enclosed': 'Omsluttet ladning',
+ 'flux.area': 'Fladens areal',
+ 'flux.netFlux': 'Samlet elektrisk fluks',
+ 'flux.exact': 'Beregnet ud fra Gauss’ lov',
+ 'flux.undefined': 'Udefineret',
+ 'flux.result.insidePositive': 'Mere går ud, end der kommer ind.',
+ 'flux.result.insideNegative': 'Mere kommer ind, end der går ud.',
+ 'flux.result.outside': 'Det, der kommer ind, går også ud igen.',
+ 'flux.result.neutral': 'Ingen ladning. Intet felt. Ingen fluks.',
+ 'flux.result.boundary': 'Ladningen ligger på selve fladen.',
+ 'flux.explanation.insidePositive': 'Fladen omslutter en positiv ladning, så den samlede fluks går udad. Prøv at ændre fladens størrelse: Den samlede fluks forbliver den samme, så længe ladningen er indeni.',
+ 'flux.explanation.insideNegative': 'Fladen omslutter en negativ ladning, så den samlede fluks går indad. Det negative fortegn fortæller dig retningen i forhold til fladens udadrettede normal.',
+ 'flux.explanation.outside': 'Det elektriske felt er der stadig, men den omsluttede ladning er nul. Hver feltlinje, der går ind i den lukkede flade, går også ud af den igen, så den samlede fluks bliver nul.',
+ 'flux.explanation.neutral': 'Når kildeladningen er sat til nul, er der intet elektrisk felt nogen steder i modellen. Fluksen gennem den lukkede flade er derfor også nul.',
+ 'flux.explanation.boundary': 'En ideel punktladning på fladen gør feltet singulært netop dér. Flyt ladningen en smule ind eller ud for at få en veldefineret omsluttet ladning og fluks.',
+ 'flux.summary': '{status}. Omsluttet ladning: {charge}. Samlet elektrisk fluks: {flux}.',
+ 'flux.webglError': 'Den 3D-visningen kræver WebGL 2. Prøv en browser med hardwareacceleration slået til. Kontrollerne, beregningerne og forklaringerne virker stadig.',
+ 'flux.webglLost': 'Forbindelsen til 3D-grafikken blev afbrudt. Genindlæs siden for at gendanne den. Beregningerne virker stadig.',
+ 'flux.physicsEyebrow': 'FRA MODEL TIL IDÉ',
+ 'flux.physicsTitle': 'En flade fortæller en historie.',
+ 'flux.physicsSubtitle': 'Fluks måler, hvor meget elektrisk felt der går igennem en flade. Sådan læser du den.',
+ 'flux.lesson.one.number': '01',
+ 'flux.lesson.one.title': 'Retningen betyder noget',
+ 'flux.lesson.one.body': 'Et lille fladestykke har en arealvektor vinkelret på sig. Et felt, der peger samme vej som normalen, giver positiv fluks; et felt, der peger imod, giver negativ fluks. Et felt langs fladen bidrager med nul.',
+ 'flux.lesson.two.number': '02',
+ 'flux.lesson.two.title': 'Læg hele fladen sammen',
+ 'flux.lesson.two.body': 'For en lukket flade peger alle arealvektorer udad. Læg de fortegnsbestemte bidrag fra alle fladestykkerne sammen for at finde den samlede fluks. En samlet sum på nul betyder ikke, at feltet er nul.',
+ 'flux.lesson.three.number': '03',
+ 'flux.lesson.three.title': 'Kun det indeni tæller',
+ 'flux.lesson.three.body': 'Gauss’ lov forbinder den samlede fluks med den omsluttede ladning. Størrelse og form kan ændre feltet henover fladen, men de kan ikke ændre den samlede fluks, så længe den samme ladning er omsluttet.',
+ 'flux.try': 'PRØV SELV',
+ 'flux.tryText': 'Gør fladen større. Ændrer den samlede fluks sig?',
+ 'flux.assumptions.title': 'Om denne model',
+ 'flux.assumptions.body': 'Én stationær, ideel punktladning i vakuum; ingen andre ladninger eller materialer. Fladen er tænkt og har centrum i origo. Én enhed i scenen svarer til én meter. Ladningsmarkøren har kun en synlig størrelse, så du kan finde den; om ladningen er indeni afgøres ud fra dens centrum. Feltlinjerne er illustrative eksempler, og pilenes længde angiver ikke feltstyrken. Den viste samlede værdi bruger det eksakte resultat fra Gauss’ lov og er ikke en optælling af de tegnede linjer.',
+ 'flux.reference': 'Baseret på kursusreferencen: Elektrisk fluks og ladning, afsnit 3.2–3.4.',
+ 'flux.constant': 'Vakuumpermittivitet: ε₀ = {value} F/m.',
+ 'units.charge': '{value} nC',
+ 'units.distance': '{value} m',
+ 'units.area': '{value} m²',
+ 'units.flux': '{value} N·m²/C',
};
-
-export const danishLessons: Record<ExperimentId, LessonText> = { ...lab, ...test };
-
-export function danishTest(id: Extract<ExperimentId, `test-${string}`>): TestCopy {
- return test[id];
-}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
new file mode 100644
index 0000000..ff8d1b2
--- /dev/null
+++ b/src/i18n/en.ts
@@ -0,0 +1,101 @@
+export const en = {
+ 'app.title': 'Fieldwork · Electric flux and charge',
+ 'app.brand': 'fieldwork',
+ 'app.tagline': 'An electrophysics lab',
+ 'app.skip': 'Skip to the experiment',
+ 'app.navigation': 'Main navigation',
+ 'app.experiment': 'Experiment',
+ 'app.principle': 'The physics',
+ 'app.module': 'MODEL {number}',
+ 'app.footer': 'A little curiosity. A clearer understanding.',
+ 'app.footerNote': 'Electrophysics / Interactive learning',
+ 'app.loadError': 'The model could not be loaded. Reload the page to try again.',
+ 'app.reload': 'Reload page',
+ 'flux.eyebrow': 'ELECTRICITY, MADE VISIBLE',
+ 'flux.title': 'Electric flux and charge',
+ 'flux.subtitle': 'Move a charge. Reshape its surroundings. Discover what really determines electric flux.',
+ 'flux.topic': 'Gauss’s law',
+ 'flux.live': 'INTERACTIVE 3D MODEL',
+ 'flux.sceneTitle': 'One charge. One closed surface.',
+ 'flux.sceneLabel': '3D electric flux model. Drag to orbit and scroll to zoom. When focused, use arrow keys to orbit, plus or minus to zoom, and Home to reset the view.',
+ 'flux.sceneHint': 'Drag to orbit · Scroll to zoom',
+ 'flux.keyboardHint': 'Keyboard: arrows to orbit · + / − to zoom · Home to reset',
+ 'flux.resetView': 'Reset view',
+ 'flux.frontView': 'Front view',
+ 'flux.topView': 'Top view',
+ 'flux.cameraViews': 'Camera views',
+ 'flux.legend.positive': 'Positive field',
+ 'flux.legend.negative': 'Negative field',
+ 'flux.legend.zero': 'No electric field',
+ 'flux.legend.surface': 'Gaussian surface',
+ 'flux.legend.normal': 'Outward normal',
+ 'flux.marker': 'q = {value}',
+ 'flux.status.inside': 'Charge inside',
+ 'flux.status.outside': 'Charge outside',
+ 'flux.status.boundary': 'Charge on surface',
+ 'flux.status.neutral': 'Zero charge',
+ 'flux.controls': 'Make it your experiment',
+ 'flux.controlsHint': 'Change one thing. Watch what follows.',
+ 'flux.charge': 'Charge',
+ 'flux.position': 'Charge position',
+ 'flux.positionHint': 'Move along the x-axis through the surface.',
+ 'flux.surface': 'Gaussian surface',
+ 'flux.sphere': 'Sphere',
+ 'flux.cube': 'Cube',
+ 'flux.radius': 'Surface radius',
+ 'flux.halfSide': 'Half side length',
+ 'flux.surfaceHint': 'An imaginary boundary, not a physical shell.',
+ 'flux.layers': 'Visible layers',
+ 'flux.fieldLines': 'Electric field lines',
+ 'flux.surfaceLayer': 'Gaussian surface',
+ 'flux.normals': 'Surface normals',
+ 'flux.reset': 'Reset experiment',
+ 'flux.presets': 'Start with a scenario',
+ 'flux.preset.positive': 'Positive charge',
+ 'flux.preset.negative': 'Negative charge',
+ 'flux.preset.outside': 'Outside the surface',
+ 'flux.preset.custom': 'Custom experiment',
+ 'flux.readings': 'Live measurements',
+ 'flux.enclosed': 'Enclosed charge',
+ 'flux.area': 'Surface area',
+ 'flux.netFlux': 'Net electric flux',
+ 'flux.exact': 'Calculated from Gauss’s law',
+ 'flux.undefined': 'Undefined',
+ 'flux.result.insidePositive': 'More leaves than enters.',
+ 'flux.result.insideNegative': 'More enters than leaves.',
+ 'flux.result.outside': 'What enters also leaves.',
+ 'flux.result.neutral': 'No charge. No field. No flux.',
+ 'flux.result.boundary': 'The charge is on the boundary.',
+ 'flux.explanation.insidePositive': 'The surface encloses a positive charge, so the net flux is outward. Change the surface size: the total flux stays the same while the charge remains inside.',
+ 'flux.explanation.insideNegative': 'The surface encloses a negative charge, so the net flux is inward. The negative sign tells you the direction relative to the outward surface normal.',
+ 'flux.explanation.outside': 'The electric field is still present, but the enclosed charge is zero. Every field line that enters the closed surface also exits it, giving zero net flux.',
+ 'flux.explanation.neutral': 'With the source charge set to zero, this model has no electric field anywhere. The flux through the closed surface is also zero.',
+ 'flux.explanation.boundary': 'An ideal point charge on the surface makes the field singular there. Move the charge slightly inside or outside to get a well-defined enclosed charge and flux.',
+ 'flux.summary': '{status}. Enclosed charge: {charge}. Net electric flux: {flux}.',
+ 'flux.webglError': 'The 3D view needs WebGL 2. Try a browser with hardware acceleration enabled. The controls, calculations, and explanations still work.',
+ 'flux.webglLost': 'The 3D graphics connection was lost. Reload the page to restore it. Calculations still work.',
+ 'flux.physicsEyebrow': 'FROM THE MODEL TO THE IDEA',
+ 'flux.physicsTitle': 'A surface tells a story.',
+ 'flux.physicsSubtitle': 'Flux measures how much electric field passes through a surface. Here is how to read it.',
+ 'flux.lesson.one.number': '01',
+ 'flux.lesson.one.title': 'Direction matters',
+ 'flux.lesson.one.body': 'A small patch has an area vector perpendicular to it. Field pointing with this normal gives positive flux; field pointing against it gives negative flux. A tangent field contributes zero.',
+ 'flux.lesson.two.number': '02',
+ 'flux.lesson.two.title': 'Add up the whole surface',
+ 'flux.lesson.two.body': 'For a closed surface, every area vector points outward. Add the signed contributions from all its patches to find the net flux. A zero total does not mean the field is zero.',
+ 'flux.lesson.three.number': '03',
+ 'flux.lesson.three.title': 'Only what is inside counts',
+ 'flux.lesson.three.body': 'Gauss’s law connects that total to the enclosed charge. Size and shape can change the field across the surface, but they cannot change the total flux while the same charge stays enclosed.',
+ 'flux.try': 'TRY IT',
+ 'flux.tryText': 'Make the surface larger. Does the net flux change?',
+ 'flux.assumptions.title': 'About this model',
+ 'flux.assumptions.body': 'One stationary ideal point charge in vacuum; no other charges or materials. The surface is imaginary and centered at the origin. One scene unit represents one metre. The charge marker has a visible size only so you can find it; membership uses its center. Field lines are illustrative samples and arrow lengths do not measure field strength. The displayed total uses the exact Gauss’s-law result, not a count of the drawn lines.',
+ 'flux.reference': 'Based on the course reference: Electric flux and charge, sections 3.2–3.4.',
+ 'flux.constant': 'Vacuum permittivity: ε₀ = {value} F/m.',
+ 'units.charge': '{value} nC',
+ 'units.distance': '{value} m',
+ 'units.area': '{value} m²',
+ 'units.flux': '{value} N·m²/C',
+} as const;
+
+export type MessageKey = keyof typeof en;
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..2f46dd0
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,28 @@
+import { en, type MessageKey } from './en';
+import { da } from './da';
+
+// Add a catalog and register its BCP 47 locale here to support another language.
+// Missing messages always fall back to English. Locale selection belongs to the shell.
+const catalogs: Record<string, Partial<Record<MessageKey, string>>> = { en, da };
+export const defaultLocale = 'en';
+export type Parameters = Record<string, string | number>;
+
+export function createI18n(requestedLocale = defaultLocale) {
+ const locale = Object.hasOwn(catalogs, requestedLocale) ? requestedLocale : defaultLocale;
+ const catalog = catalogs[locale];
+ return {
+ locale,
+ t(key: MessageKey, parameters: Parameters = {}): string {
+ const message = catalog[key] ?? en[key];
+ return message.replace(/\{(\w+)\}/g, (_, name: string) => String(parameters[name] ?? `{${name}}`));
+ },
+ number(value: number, options: Intl.NumberFormatOptions = {}): string {
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 2, ...options }).format(
+ Object.is(value, -0) ? 0 : value,
+ );
+ },
+ };
+}
+
+export type I18n = ReturnType<typeof createI18n>;
+export type { MessageKey };
diff --git a/src/i18n/lesson-text.ts b/src/i18n/lesson-text.ts
deleted file mode 100644
index 0ed290c..0000000
--- a/src/i18n/lesson-text.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-export interface ControlText { label: string; options?: Record<string, string> }
-
-export interface LessonText {
- title: string;
- shortTitle: string;
- description: string;
- explanation: string;
- challenge: string;
- limits: string;
- sceneNote: string;
- legend: string[];
- sourceTitle: string;
- controls: Record<string, ControlText>;
- equations?: string[];
-}
-
-export interface TestCopy extends LessonText {
- questions: string[];
- answers: { title: string; steps: string[] }[];
- prediction: string;
- predictionAnswer: string;
-}
diff --git a/src/i18n/narrate.ts b/src/i18n/narrate.ts
deleted file mode 100644
index e112b42..0000000
--- a/src/i18n/narrate.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { formatDisplay, number, type Parameters, type TestId } from '../experiments';
-import { assemblyState, centralState, conservationState, motionState, rotationState } from '../test4';
-
-const n = (value: number) => formatDisplay(value);
-
-export function narrate(id: TestId, p: Parameters): string {
- if (id === 'test-work') {
- const work = n(motionState(p).work * 1e9);
- const route = p.path === 'curved'
- ? 'Den krumme rute tilføjer bevægelse på tværs, men det endelige arbejde forbliver det samme.'
- : 'Den rette rute gør skalarproduktet mellem kraft og forskydning let at se.';
- return `${route} Ved b udfører feltet ${work} nJ arbejde; $U$ falder med det beløb.`;
- }
- if (id === 'test-conservation') {
- const s = conservationState(p);
- return String.raw`Ved $r$ = ${n(s.r)} m er $U$ = ${n(s.u * 1e9)} nJ og $K$ = ${n(s.kinetic * 1e9)} nJ. Summen forbliver ${n(s.total * 1e9)} nJ. Det hidtidige arbejde er stigningen i $K$.`;
- }
- if (id === 'test-central') {
- const s = centralState(p);
- return s.rb === Infinity
- ? String.raw`I det uendelige er $U_b=0$ eksakt. $W_{a\infty}=U_a$ = ${n(s.work * 1e9)} nJ. Diagrammets kant er kun en visuel fortsættelse mod uendelighed.`
- : String.raw`Feltet udfører ${n(s.work * 1e9)} nJ arbejde fra $r_a$ = ${s.ra} m til $r_b$ = ${s.rb} m. Skift af fortegn på en af ladningerne vender $U$ og $W$.`;
- }
- if (id === 'test-assembly') {
- const s = assemblyState(p);
- return String.raw`Trin ${s.stage}: ${s.stage * (s.stage - 1) / 2} forskellige par er til stede. $U_{123}$ = ${n(s.base * 1e9)} nJ; tilføjelse af $q_0$ ændrer den med ${n(s.extra * 1e9)} nJ. Negativ parenergi betyder modsatte fortegn.`;
- }
- if (id === 'test-uniform') {
- const m = motionState(p);
- return String.raw`$\alpha = ${number(p, 'angle')}^\circ$ projicerer $s$ = ${m.s} m over på $\Delta x$ = ${n(m.dx)} m. $W$ = ${n(m.work * 1e9)} nJ og $U_b$ = ${n(-m.work * 1e9)} nJ med $U_a=0$.`;
- }
- const s = rotationState(p);
- const degrees = s.theta * 180 / Math.PI;
- if (s.field === 0) return String.raw`$E=0$: begge kræfter, kraftmomentet, rotationsarbejdet og dipolens vekselvirkningsenergi forsvinder. Der er ingen foretrukken orientering.`;
- if (degrees < 0.01) return String.raw`Indrettet ved $0^\circ$: $U$ er minimum, og kraftmomentet er nul. Det er stabil ligevægt; en lille drejning giver et tilbageførende kraftmoment.`;
- if (degrees > 179.99) return String.raw`Modsat ved $180^\circ$: $U$ er maksimum, og kraftmomentet er nul. Det er ustabil ligevægt; en lille drejning fører den væk fra denne position.`;
- const follow = Math.abs(degrees - 90) < 0.01
- ? String.raw`Ved $90^\circ$ er $U=0$ per valg, men kraftmomentet er størst: det er ikke ligevægt.`
- : 'Følg punktet på energikurven, mens du drejer.';
- return String.raw`Ved $\theta = ${n(degrees)}^\circ$ er $\tau_z$ = ${n(s.torqueZ * 1e9)} nN·m. Den negative z-retning mindsker $\theta$ mod indretning. ${follow}`;
-}
diff --git a/src/i18n/present.ts b/src/i18n/present.ts
deleted file mode 100644
index d734efd..0000000
--- a/src/i18n/present.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { Choice, Experiment, ExperimentId, Measurement, Parameters, Slider, TestId } from '../experiments';
-import { measurementLabel, word, getLanguage, type Language } from '../i18n';
-import type { TestLesson } from '../test4';
-import { danishLessons, danishTest } from './da';
-import type { LessonText, TestCopy } from './lesson-text';
-import { narrate } from './narrate';
-
-interface Catalog {
- lessons: Record<ExperimentId, LessonText>;
- test: (id: TestId) => TestCopy;
- narrate: (id: TestId, p: Parameters) => string;
-}
-
-const catalogs: Record<Exclude<Language, 'en'>, Catalog> = {
- da: { lessons: danishLessons, test: danishTest, narrate },
-};
-
-function localizeMeasurements(measurements: Measurement[]): Measurement[] {
- return measurements.map(measurement => ({
- label: measurementLabel(measurement.label),
- value: word(measurement.value),
- unit: word(measurement.unit),
- }));
-}
-
-function localizeControls(experiment: Experiment, lessons: Record<ExperimentId, LessonText>): (Slider | Choice)[] {
- const copy = lessons[experiment.id].controls;
- return experiment.controls.map(control => {
- const text = copy[control.id];
- if (!text) return control;
- if (control.kind === 'range') return { ...control, label: text.label };
- return {
- ...control,
- label: text.label,
- options: control.options.map(option => ({ ...option, label: text.options?.[option.value] ?? option.label })),
- };
- });
-}
-
-export function presentLesson<T extends Experiment>(experiment: T): T {
- const measure = (p: Parameters) => localizeMeasurements(experiment.measure(p));
- const language = getLanguage();
- if (language === 'en') return { ...experiment, measure };
- const catalog = catalogs[language];
- const text = catalog.lessons[experiment.id];
- const localized = {
- ...experiment,
- title: text.title,
- shortTitle: text.shortTitle,
- description: text.description,
- explanation: text.explanation,
- challenge: text.challenge,
- limits: text.limits,
- sceneNote: text.sceneNote,
- legend: experiment.legend.map(([colour], index) => [colour, text.legend[index] ?? experiment.legend[index][1]] as [string, string]),
- source: { ...experiment.source, title: text.sourceTitle },
- controls: localizeControls(experiment, catalog.lessons),
- equations: text.equations ?? experiment.equations,
- measure,
- };
- if (!('questions' in experiment)) return localized as T;
- const lesson = experiment as unknown as TestLesson;
- const test = catalog.test(lesson.id);
- return {
- ...localized,
- questions: test.questions,
- prediction: test.prediction,
- predictionAnswer: test.predictionAnswer,
- answers: lesson.answers.map((part, index) => ({
- ...part,
- title: test.answers[index].title,
- steps: part.steps.map((step, stepIndex) => ({ ...step, why: test.answers[index].steps[stepIndex] })),
- })),
- observe: (p: Parameters) => catalog.narrate(lesson.id, p),
- } as T;
-}
diff --git a/src/main.ts b/src/main.ts
index e419e46..8d8c5d5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,288 +1,34 @@
import './style.css';
-import { defaults, experiments, formatDisplay, type Experiment, type Parameters } from './experiments';
-import { getLanguage, languageOptions, setLanguage, t, type Chrome, type Language } from './i18n';
-import { presentLesson } from './i18n/present';
-import { createLab } from './scene/lab';
-import { answerUrl, getTestLesson, testLessons } from './test4';
-import { renderEquation, renderNotation, renderProse } from './math';
+import 'katex/dist/katex.min.css';
+import { createI18n } from './i18n';
+import { models } from './models/registry';
+import { icon } from './ui/icons';
+
+const i18n = createI18n();
+const { t } = i18n;
+document.documentElement.lang = i18n.locale;
+document.title = t('app.title');
const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
- <header class="app-header">
- <a class="brand" href="#test-work" aria-label="Field Lab home">E<span>/</span>LAB</a>
- <div class="header-title"><h1 id="app-title">Electrotechnique & Electrophysics</h1><p>Explore the invisible. Understand the physical.</p></div>
- <nav class="collections" aria-label="Learning collection"><a href="#test-work" id="test-collection">Test 4 in 3D</a><a href="#field" id="lab-collection">All experiments</a><label class="language-picker"><span id="language-label">Language</span><select id="language" aria-label="Language">${languageOptions.map(option => `<option value="${option.id}">${option.label}</option>`).join('')}</select></label></nav>
+ <a class="skip-link" href="#experiment">${t('app.skip')}</a>
+ <header class="site-header">
+ <a class="brand" href="#experiment">${icon('orbit')}<span>${t('app.brand')}</span><span class="brand-divider"></span><small>${t('app.tagline')}</small></a>
+ <nav aria-label="${t('app.navigation')}">
+ <a class="nav-link" href="#experiment">${t('app.experiment')}</a>
+ <a class="nav-link" href="#physics">${t('app.principle')}</a>
+ <span class="model-number">${t('app.module', { number: i18n.number(1, { minimumIntegerDigits: 2 }) })}</span>
+ </nav>
</header>
- <section class="test-overview" id="test-overview"><div><p class="eyebrow">FROM EQUATIONS TO INTUITION</p><h2>Electric energy, made visible.</h2><p>Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.</p></div><div class="test-score"><strong>4A <span>100p</span></strong><strong>4B <span>100p</span></strong><span>6 questions · 3D study guide</span></div></section>
- <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Lessons"></div></nav>
- <main id="lesson" role="tabpanel" aria-labelledby="tab-field">
- <aside class="controls-panel">
- <p class="eyebrow" id="lesson-number"></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>
- </aside>
- <div class="workbench">
- <section id="viewport" aria-label="3D workbench">
- <div class="scene-toolbar"><span class="view-tag">3D VIEW</span><div><button type="button" id="camera-reset">Reset camera</button><button type="button" id="pause" aria-pressed="false">Pause animation</button></div></div>
- <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 id="learning-panel" class="learning-panel" aria-label="Learn the physics">
- <div><p class="eyebrow">THE PHYSICS</p><div id="equations" class="equations"></div><p id="explanation"></p><a id="source" target="_blank" rel="noopener noreferrer"></a></div>
- <div class="challenge"><p class="eyebrow">TRY THIS</p><p id="challenge"></p><details><summary>Model & visual scales</summary><p id="limits"></p></details></div>
- </section>
- <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 allLessons: Experiment[] = [...testLessons, ...experiments];
-const settings = new Map(allLessons.map(experiment => [experiment.id, defaults(experiment)]));
-const byId = (id: string) => document.getElementById(id)!;
-function applyLanguage() {
- document.documentElement.lang = getLanguage();
- document.title = t('documentTitle');
- document.querySelector('meta[name="description"]')?.setAttribute('content', t('metaDescription'));
- (byId('language') as HTMLSelectElement).value = getLanguage();
- byId('language').setAttribute('aria-label', t('language'));
- byId('language-label').textContent = t('language');
- type CopyKey = { [K in keyof Chrome]: Chrome[K] extends string ? K : never }[keyof Chrome];
- const set = (id: string, key: CopyKey) => { byId(id).textContent = t(key); };
- set('test-collection', 'test'); set('lab-collection', 'experiments');
- set('reset', 'reset'); set('camera-reset', 'camera');
- set('app-title', 'title');
- byId('pause').textContent = paused ? t('resume') : t('pause');
- byId('reveal-prediction').textContent = byId('prediction-answer').hidden ? t('reveal') : t('hide');
- document.querySelector('.brand')!.setAttribute('aria-label', t('home'));
- document.querySelector('.header-title p')!.textContent = t('subtitle');
- document.querySelector('.collections')!.setAttribute('aria-label', t('collection'));
- document.querySelector('.test-overview .eyebrow')!.textContent = t('overview');
- document.querySelector('.test-overview h2')!.textContent = t('overviewTitle');
- document.querySelector('.test-overview > div > p:not(.eyebrow)')!.textContent = t('overviewText');
- document.querySelector('.test-score > span')!.textContent = t('score');
- document.querySelector('.subjects')!.setAttribute('aria-label', t('subjects'));
- document.querySelector('[role="tablist"]')!.setAttribute('aria-label', t('lessons'));
- byId('test-question').setAttribute('aria-label', t('original'));
- byId('parameters').setAttribute('aria-label', t('parameters'));
- byId('measurements').setAttribute('aria-label', t('measurements'));
- byId('viewport').setAttribute('aria-label', t('viewport'));
- byId('test-study').setAttribute('aria-label', t('study'));
- document.querySelector('.scene-toolbar .view-tag')!.textContent = t('view');
- document.querySelector('.scene-footer .hint')!.textContent = t('hint');
- document.querySelector('#observation-panel .eyebrow')!.textContent = t('notice');
- byId('observation-panel').setAttribute('aria-label', t('connect'));
- document.querySelector('.learning-panel > div:first-child .eyebrow')!.textContent = t('physics');
- byId('learning-panel').setAttribute('aria-label', t('learn'));
- document.querySelector('.challenge .eyebrow')!.textContent = t('try');
- document.querySelector('.challenge summary')!.textContent = t('scales');
- document.querySelector('.solution-heading .eyebrow')!.textContent = t('build');
- document.querySelector('.solution-heading h3')!.textContent = t('reasoning');
- document.querySelector('.solution-heading a')!.textContent = t('originalAnswers');
- document.querySelector('.prediction .eyebrow')!.textContent = t('predict');
- document.querySelector('.lesson-pagination')!.setAttribute('aria-label', t('navigation'));
-}
-let active: Experiment = testLessons[0];
-let collection = '';
-const subjects = document.querySelector<HTMLElement>('[role="tablist"]')!;
-let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-function updatePause() {
- byId('pause').textContent = paused ? t('resume') : t('pause');
- byId('pause').setAttribute('aria-pressed', String(paused));
- lab.setPaused(paused);
-}
-updatePause();
+ <main id="experiment" tabindex="-1"></main>
+ <footer class="site-footer"><span>${icon('orbit')}${t('app.footer')}</span><span>${t('app.footerNote')}</span></footer>`;
-function parameters(): Parameters { return settings.get(active.id)!; }
-function update() {
- const p = parameters();
- for (const control of active.controls) {
- if (control.kind !== 'range') continue;
- byId(`output-${control.id}`).textContent = `${formatDisplay(Number(p[control.id]), 3)}${control.unit ? ` ${control.unit}` : ''}`;
- }
- if (active.id === 'semiconductor') {
- (byId('parameter-concentration') as HTMLInputElement).disabled = p.doping === 'intrinsic';
- }
- if (active.id === 'test-central') (byId('parameter-rb') as HTMLInputElement).disabled = p.destination === 'infinity';
- const testLesson = getTestLesson(active);
- if (testLesson) renderProse(byId('observation'), presentLesson(testLesson).observe(p));
- byId('measurements').innerHTML = presentLesson(active).measure(p).map(m => `<div class="measurement"><span>${m.label}</span><div><strong aria-label="${m.label}">${m.value}</strong><small>${m.unit}</small></div></div>`).join('');
- byId('measurements').querySelectorAll<HTMLElement>('.measurement > span').forEach(label => renderNotation(label, label.textContent!));
- lab.update(active.id, p);
-}
-let renderedLanguage = '';
-function show(experiment: Experiment) {
- active = experiment;
- const view = presentLesson(experiment);
- const testLesson = getTestLesson(experiment);
- const testView = testLesson ? presentLesson(testLesson) : undefined;
- const lessons: Experiment[] = testLesson ? testLessons : experiments;
- const index = lessons.indexOf(experiment);
- const nextCollection = testLesson ? 'test' : 'lab';
- if (collection !== nextCollection || renderedLanguage !== getLanguage()) {
- collection = nextCollection;
- renderedLanguage = getLanguage();
- subjects.innerHTML = lessons.map((e, i) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(i + 1).padStart(2, '0')}</span>${presentLesson(e).shortTitle}${getTestLesson(e) ? `<small>${getTestLesson(e)!.points}p</small>` : ''}</button>`).join('');
- }
- for (const [id, selected] of [['test-collection', Boolean(testLesson)], ['lab-collection', !testLesson]] as const) {
- if (selected) byId(id).setAttribute('aria-current', 'page');
- 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 = testView ? t('questionKicker')(testView.section, index + 1, testView.points) : t('experimentKicker')(index + 1, lessons.length);
- if (testView) {
- byId('test-question').replaceChildren(...testView.questions.map((text, i) => {
- const paragraph = document.createElement('p');
- renderProse(paragraph, text);
- if (i === 0) paragraph.className = 'question-lead';
- return paragraph;
- }));
- byId('solution').replaceChildren(...testView.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'), testView.prediction);
- renderProse(byId('prediction-answer'), testView.predictionAnswer);
- byId('prediction-answer').hidden = true;
- byId('reveal-prediction').textContent = t('reveal');
- byId('reveal-prediction').setAttribute('aria-expanded', 'false');
- const previous = byId('previous-question') as HTMLAnchorElement;
- const next = byId('next-question') as HTMLAnchorElement;
- previous.hidden = index === 0;
- next.hidden = index === testLessons.length - 1;
- previous.textContent = t('previous');
- next.textContent = t('next');
- if (index > 0) previous.href = `#${testLessons[index - 1].id}`;
- if (index < testLessons.length - 1) next.href = `#${testLessons[index + 1].id}`;
- byId('question-progress').textContent = `${index + 1} / 6`;
- }
- byId('lesson-title').textContent = view.title;
- renderProse(byId('description'), view.description);
- renderProse(byId('scene-note'), view.sceneNote);
- renderProse(byId('explanation'), view.explanation);
- renderProse(byId('challenge'), view.challenge);
- renderProse(byId('limits'), view.limits);
- byId('equations').replaceChildren(...view.equations.map(equation => {
- const div = document.createElement('p');
- renderEquation(div, equation);
- return div;
- }));
- const source = byId('source') as HTMLAnchorElement;
- source.textContent = `${view.source.title} ↗`;
- source.href = view.source.url;
- byId('legend').innerHTML = view.legend.map(([colour, text]) => `<span><i style="background:${colour}"></i>${text}</span>`).join('');
- byId('lesson').setAttribute('aria-labelledby', `tab-${experiment.id}`);
- document.querySelectorAll<HTMLButtonElement>('[role="tab"]').forEach(tab => {
- const selected = tab.dataset.experiment === experiment.id;
- tab.setAttribute('aria-selected', String(selected));
- tab.tabIndex = selected ? 0 : -1;
- });
- const p = parameters();
- byId('parameters').innerHTML = view.controls.map(control => {
- const id = `parameter-${control.id}`;
- if (control.kind === 'select') return `<div class="parameter"><label for="${id}">${control.label}</label><select id="${id}" name="${control.id}">${control.options.map(option => `<option value="${option.value}" ${option.value === p[control.id] ? 'selected' : ''}>${option.label}</option>`).join('')}</select></div>`;
- return `<div class="parameter"><div class="parameter-label"><label for="${id}">${control.label}</label><output for="${id}" id="output-${control.id}"></output></div><input id="${id}" name="${control.id}" type="range" min="${control.min}" max="${control.max}" step="${control.step}" value="${p[control.id]}" /></div>`;
- }).join('');
- 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(allLessons.find(experiment => experiment.id === id) ?? testLessons[0]);
+const host = app.querySelector<HTMLElement>('main')!;
+try {
+ const instance = await models[0].mount(host, { i18n });
+ if (import.meta.hot) import.meta.hot.dispose(() => instance.dispose());
+} catch (error) {
+ console.error(error);
+ host.innerHTML = `<div class="load-error" role="alert"><p>${t('app.loadError')}</p><button class="button" type="button">${t('app.reload')}</button></div>`;
+ host.querySelector('button')?.addEventListener('click', () => window.location.reload());
}
-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();
-applyLanguage();
-byId('language').addEventListener('change', event => {
- const next = (event.target as HTMLSelectElement).value;
- if (next !== 'en' && next !== 'da') return;
- setLanguage(next as Language);
- applyLanguage();
- show(active);
-});
-byId('parameters').addEventListener('input', event => {
- const input = event.target as HTMLInputElement | HTMLSelectElement;
- if (!input.name) return;
- parameters()[input.name] = input.type === 'range' ? Number(input.value) : input.value;
- update();
-});
-byId('reset').addEventListener('click', () => {
- settings.set(active.id, defaults(active));
- show(active);
-});
-byId('camera-reset').addEventListener('click', () => lab.resetCamera());
-byId('pause').addEventListener('click', () => {
- paused = !paused;
- updatePause();
-});
-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 ? t('hide') : t('reveal');
-});
-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;
- else if (event.key === 'Home') next = 0;
- else if (event.key === 'End') next = tabs.length - 1;
- else return;
- event.preventDefault();
- tabs[next].focus();
- tabs[next].click();
-});
-if (import.meta.hot) import.meta.hot.dispose(() => {
- window.removeEventListener('hashchange', onHashChange);
- lab.dispose();
-});
diff --git a/src/math.ts b/src/math.ts
deleted file mode 100644
index 71403f4..0000000
--- a/src/math.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-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/models/electric-flux/physics.ts b/src/models/electric-flux/physics.ts
new file mode 100644
index 0000000..78c21d0
--- /dev/null
+++ b/src/models/electric-flux/physics.ts
@@ -0,0 +1,53 @@
+/** All physics values use SI units. No rendering or localization dependencies. */
+export const VACUUM_PERMITTIVITY = 8.8541878128e-12;
+export const COULOMB_CONSTANT = 1 / (4 * Math.PI * VACUUM_PERMITTIVITY);
+
+export type Vector3 = Readonly<{ x: number; y: number; z: number }>;
+export type SurfaceShape = 'sphere' | 'cube';
+export type Membership = 'inside' | 'outside' | 'boundary';
+
+export interface ClosedSurface {
+ shape: SurfaceShape;
+ /** Sphere radius or cube half side length, in metres. Center is the origin. */
+ size: number;
+}
+
+export interface PointCharge {
+ coulombs: number;
+ position: Vector3;
+}
+
+export function surfaceMembership(position: Vector3, surface: ClosedSurface): Membership {
+ const distance = surface.shape === 'sphere'
+ ? Math.hypot(position.x, position.y, position.z)
+ : Math.max(Math.abs(position.x), Math.abs(position.y), Math.abs(position.z));
+ // Absorb floating-point slider arithmetic without hiding a physical boundary region.
+ if (Math.abs(distance - surface.size) < 1e-9) return 'boundary';
+ return distance < surface.size ? 'inside' : 'outside';
+}
+
+/** Returns null at the singular source of a nonzero ideal point charge. */
+export function electricFieldAt(point: Vector3, charge: PointCharge): Vector3 | null {
+ if (charge.coulombs === 0) return { x: 0, y: 0, z: 0 };
+ const x = point.x - charge.position.x;
+ const y = point.y - charge.position.y;
+ const z = point.z - charge.position.z;
+ const r = Math.hypot(x, y, z);
+ if (r === 0) return null;
+ const scale = COULOMB_CONSTANT * charge.coulombs / r ** 3;
+ return { x: x * scale, y: y * scale, z: z * scale };
+}
+
+export function measureFlux(charge: PointCharge, surface: ClosedSurface) {
+ const membership = surfaceMembership(charge.position, surface);
+ const area = surface.shape === 'sphere' ? 4 * Math.PI * surface.size ** 2 : 24 * surface.size ** 2;
+ const enclosedCharge = charge.coulombs === 0 ? 0
+ : membership === 'boundary' ? null
+ : membership === 'inside' ? charge.coulombs : 0;
+ return {
+ membership,
+ area,
+ enclosedCharge,
+ netFlux: enclosedCharge === null ? null : enclosedCharge / VACUUM_PERMITTIVITY,
+ };
+}
diff --git a/src/models/electric-flux/scene.ts b/src/models/electric-flux/scene.ts
new file mode 100644
index 0000000..9409fd4
--- /dev/null
+++ b/src/models/electric-flux/scene.ts
@@ -0,0 +1,277 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import type { FluxState } from './state';
+import { physicsInputs } from './state';
+import { electricFieldAt } from './physics';
+
+const colors = { surface: 0x6fc9bb, positive: 0xf4b58a, negative: 0x85bafa, normal: 0xd9e8d2 };
+
+function disposeGroup(group: THREE.Group) {
+ const geometries = new Set<THREE.BufferGeometry>();
+ const materials = new Set<THREE.Material>();
+ group.traverse((object) => {
+ if (object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points) {
+ geometries.add(object.geometry);
+ for (const material of Array.isArray(object.material) ? object.material : [object.material]) materials.add(material);
+ }
+ });
+ geometries.forEach((geometry) => geometry.dispose());
+ materials.forEach((material) => material.dispose());
+ group.clear();
+}
+
+/** A static, demand-rendered scene: no perpetual animation loop or hidden physics state. */
+export function createFluxScene(
+ host: HTMLElement,
+ marker: HTMLElement,
+ onContextLost: () => void,
+) {
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setClearColor(0x10282d, 0);
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ const canvas = renderer.domElement;
+ canvas.tabIndex = 0;
+ host.prepend(canvas);
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 80);
+ const controls = new OrbitControls(camera, canvas);
+ controls.enablePan = false;
+ controls.enableDamping = false;
+ controls.minDistance = 7;
+ controls.maxDistance = 23;
+ controls.maxPolarAngle = Math.PI - 0.08;
+ controls.minPolarAngle = 0.08;
+ controls.zoomSpeed = 0.75;
+ controls.rotateSpeed = 0.65;
+
+ scene.add(new THREE.AmbientLight(0xb5e6e3, 2));
+ const light = new THREE.DirectionalLight(0xffe5c5, 4);
+ light.position.set(3, 6, 5);
+ scene.add(light);
+
+ const world = new THREE.Group();
+ const surfaceGroup = new THREE.Group();
+ const fieldGroup = new THREE.Group();
+ const normalGroup = new THREE.Group();
+ const chargeGroup = new THREE.Group();
+ scene.add(world, surfaceGroup, fieldGroup, normalGroup, chargeGroup);
+
+ const grid = new THREE.GridHelper(24, 48, 0x548080, 0x34565a);
+ grid.position.y = -3.2;
+ grid.material.transparent = true;
+ grid.material.opacity = 0.22;
+ world.add(grid);
+ const axis = new THREE.Line(
+ new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-5, 0, 0), new THREE.Vector3(5, 0, 0)]),
+ new THREE.LineDashedMaterial({ color: 0x729797, transparent: true, opacity: 0.3, dashSize: 0.08, gapSize: 0.12 }),
+ );
+ axis.computeLineDistances();
+ world.add(axis);
+
+ let width = 1;
+ let height = 1;
+ let disposed = false;
+ let contextLost = false;
+ let current: FluxState | undefined;
+
+ function render() {
+ if (disposed || contextLost) return;
+ renderer.render(scene, camera);
+ if (!current) return;
+ const projected = new THREE.Vector3(current.chargeX, 0, 0).project(camera);
+ const visible = projected.z > -1 && projected.z < 1 && Math.abs(projected.x) < 0.92 && Math.abs(projected.y) < 0.9;
+ marker.hidden = !visible;
+ marker.style.left = `${(projected.x * 0.5 + 0.5) * width}px`;
+ marker.style.top = `${(-projected.y * 0.5 + 0.5) * height + 22}px`;
+ }
+
+ function setView(view: 'default' | 'front' | 'top') {
+ controls.target.set(0, 0, 0);
+ if (view === 'front') camera.position.set(0, 0, 13);
+ else if (view === 'top') camera.position.set(0, 13, 0.01);
+ else camera.position.set(6.5, 4.2, 9.5);
+ controls.update();
+ render();
+ }
+
+ function addArrow(position: THREE.Vector3, direction: THREE.Vector3, color: number, parent: THREE.Group, length = 0.45) {
+ const arrow = new THREE.ArrowHelper(direction, position, length, color, 0.13, 0.07);
+ parent.add(arrow);
+ }
+
+ function buildSurface(state: FluxState) {
+ disposeGroup(surfaceGroup);
+ disposeGroup(normalGroup);
+ const radius = state.size;
+ const geometry = state.shape === 'sphere'
+ ? new THREE.SphereGeometry(radius, 48, 32)
+ : new THREE.BoxGeometry(radius * 2, radius * 2, radius * 2);
+ const shell = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({
+ color: colors.surface, transparent: true, opacity: 0.055, side: THREE.DoubleSide, depthWrite: false,
+ }));
+ surfaceGroup.add(shell);
+
+ const lineMaterial = new THREE.LineBasicMaterial({ color: colors.surface, transparent: true, opacity: 0.36, depthWrite: false });
+ if (state.shape === 'sphere') {
+ for (let latitude = 1; latitude < 10; latitude++) {
+ const phi = Math.PI * latitude / 10;
+ const points = Array.from({ length: 96 }, (_, i) => {
+ const theta = i * Math.PI * 2 / 96;
+ return new THREE.Vector3(radius * Math.sin(phi) * Math.cos(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta));
+ });
+ surfaceGroup.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), lineMaterial));
+ }
+ for (let longitude = 0; longitude < 12; longitude++) {
+ const theta = longitude * Math.PI / 12;
+ const points = Array.from({ length: 96 }, (_, i) => {
+ const phi = i * Math.PI * 2 / 96;
+ return new THREE.Vector3(radius * Math.sin(phi) * Math.cos(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta));
+ });
+ surfaceGroup.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), lineMaterial));
+ }
+ for (let i = 0; i < 18; i++) {
+ const normal = fibonacciDirection(i, 18);
+ addArrow(normal.clone().multiplyScalar(radius), normal, colors.normal, normalGroup);
+ }
+ } else {
+ surfaceGroup.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry), lineMaterial));
+ const gridMaterial = new THREE.LineBasicMaterial({ color: colors.surface, transparent: true, opacity: 0.2 });
+ const points: THREE.Vector3[] = [];
+ for (const sign of [-1, 1]) {
+ for (let axis = 0; axis < 3; axis++) {
+ const normal = new THREE.Vector3().setComponent(axis, sign);
+ addArrow(normal.clone().multiplyScalar(radius), normal, colors.normal, normalGroup);
+ const u = (axis + 1) % 3;
+ const v = (axis + 2) % 3;
+ for (let step = -2; step <= 2; step++) {
+ const a = new THREE.Vector3().setComponent(axis, sign * radius).setComponent(u, step * radius / 3).setComponent(v, -radius);
+ const b = a.clone().setComponent(v, radius);
+ const c = new THREE.Vector3().setComponent(axis, sign * radius).setComponent(v, step * radius / 3).setComponent(u, -radius);
+ points.push(a, b, c, c.clone().setComponent(u, radius));
+ }
+ }
+ }
+ surfaceGroup.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), gridMaterial));
+ }
+ }
+
+ function buildField(state: FluxState) {
+ disposeGroup(fieldGroup);
+ disposeGroup(chargeGroup);
+ const { charge } = physicsInputs(state);
+ const origin = new THREE.Vector3(state.chargeX, 0, 0);
+ const color = state.chargeNC > 0 ? colors.positive : state.chargeNC < 0 ? colors.negative : 0xa1b3b4;
+ const chargeMesh = new THREE.Mesh(new THREE.SphereGeometry(0.16, 32, 24), new THREE.MeshStandardMaterial({
+ color, emissive: color, emissiveIntensity: 0.5, metalness: 0.2, roughness: 0.3,
+ }));
+ chargeMesh.position.copy(origin);
+ chargeGroup.add(chargeMesh);
+ const halo = new THREE.Mesh(new THREE.SphereGeometry(0.23, 24, 16), new THREE.MeshBasicMaterial({
+ color, transparent: true, opacity: 0.08, depthWrite: false,
+ }));
+ halo.position.copy(origin);
+ chargeGroup.add(halo);
+
+ if (state.chargeNC === 0) return;
+ const count = Math.round(18 + Math.abs(state.chargeNC) * 9);
+ const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.47, depthWrite: false });
+ const arrowGeometry = new THREE.ConeGeometry(0.045, 0.16, 6);
+ const arrowMaterial = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.85 });
+ const up = new THREE.Vector3(0, 1, 0);
+ for (let i = 0; i < count; i++) {
+ const direction = fibonacciDirection(i, count);
+ // Clip rays to a common world sphere so an external source remains legible.
+ const dot = origin.dot(direction);
+ const end = -dot + Math.sqrt(dot * dot + 5.5 ** 2 - origin.lengthSq());
+ const points = [origin.clone().addScaledVector(direction, 0.24), origin.clone().addScaledVector(direction, end)];
+ fieldGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material));
+ for (let distance = 0.9; distance < end - 0.2; distance += 1.25) {
+ const point = origin.clone().addScaledVector(direction, distance);
+ const field = electricFieldAt(point, charge)!;
+ const fieldDirection = new THREE.Vector3(field.x, field.y, field.z).normalize();
+ const arrow = new THREE.Mesh(arrowGeometry, arrowMaterial);
+ arrow.position.copy(point);
+ arrow.quaternion.setFromUnitVectors(up, fieldDirection);
+ fieldGroup.add(arrow);
+ }
+ }
+ }
+
+ function update(state: FluxState) {
+ const shapeChanged = !current || current.shape !== state.shape || current.size !== state.size;
+ const chargeChanged = !current || current.chargeNC !== state.chargeNC || current.chargeX !== state.chargeX;
+ if (shapeChanged) buildSurface(state);
+ if (chargeChanged) buildField(state);
+ surfaceGroup.visible = state.surface;
+ normalGroup.visible = state.normals;
+ fieldGroup.visible = state.fieldLines;
+ current = { ...state };
+ render();
+ }
+
+ function onKey(event: KeyboardEvent) {
+ const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', '_', 'Home'];
+ if (!keys.includes(event.key)) return;
+ event.preventDefault();
+ if (event.key === 'Home') return setView('default');
+ const offset = camera.position.clone().sub(controls.target);
+ const spherical = new THREE.Spherical().setFromVector3(offset);
+ if (event.key === 'ArrowLeft') spherical.theta -= 0.12;
+ if (event.key === 'ArrowRight') spherical.theta += 0.12;
+ if (event.key === 'ArrowUp') spherical.phi -= 0.12;
+ if (event.key === 'ArrowDown') spherical.phi += 0.12;
+ if (event.key === '+' || event.key === '=') spherical.radius *= 0.9;
+ if (event.key === '-' || event.key === '_') spherical.radius *= 1.1;
+ spherical.phi = THREE.MathUtils.clamp(spherical.phi, controls.minPolarAngle, controls.maxPolarAngle);
+ spherical.radius = THREE.MathUtils.clamp(spherical.radius, controls.minDistance, controls.maxDistance);
+ camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(spherical));
+ controls.update();
+ }
+
+ function onLost(event: Event) {
+ event.preventDefault();
+ contextLost = true;
+ marker.hidden = true;
+ onContextLost();
+ }
+
+ const observer = new ResizeObserver(() => {
+ width = Math.max(1, host.clientWidth);
+ height = Math.max(1, host.clientHeight);
+ renderer.setSize(width, height);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+ render();
+ });
+ observer.observe(host);
+ controls.addEventListener('change', render);
+ canvas.addEventListener('keydown', onKey);
+ canvas.addEventListener('webglcontextlost', onLost);
+ setView('default');
+
+ return {
+ canvas,
+ update,
+ setView,
+ dispose() {
+ disposed = true;
+ observer.disconnect();
+ controls.removeEventListener('change', render);
+ controls.dispose();
+ canvas.removeEventListener('keydown', onKey);
+ canvas.removeEventListener('webglcontextlost', onLost);
+ [world, surfaceGroup, fieldGroup, normalGroup, chargeGroup].forEach(disposeGroup);
+ renderer.dispose();
+ canvas.remove();
+ },
+ };
+}
+
+function fibonacciDirection(index: number, count: number) {
+ const y = 1 - 2 * (index + 0.5) / count;
+ const radius = Math.sqrt(1 - y * y);
+ const angle = index * Math.PI * (3 - Math.sqrt(5));
+ return new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius);
+}
diff --git a/src/models/electric-flux/state.ts b/src/models/electric-flux/state.ts
new file mode 100644
index 0000000..7bded33
--- /dev/null
+++ b/src/models/electric-flux/state.ts
@@ -0,0 +1,36 @@
+import type { ClosedSurface, PointCharge, SurfaceShape } from './physics';
+
+export interface FluxState {
+ chargeNC: number;
+ chargeX: number;
+ size: number;
+ shape: SurfaceShape;
+ fieldLines: boolean;
+ surface: boolean;
+ normals: boolean;
+}
+
+export const defaultState: Readonly<FluxState> = {
+ chargeNC: 2,
+ chargeX: 0,
+ size: 1.8,
+ shape: 'sphere',
+ fieldLines: true,
+ surface: true,
+ normals: false,
+};
+
+export const presets = {
+ positive: { ...defaultState },
+ negative: { ...defaultState, chargeNC: -2 },
+ outside: { ...defaultState, chargeX: 3.2 },
+} satisfies Record<string, FluxState>;
+
+export type Preset = keyof typeof presets;
+
+export function physicsInputs(state: FluxState): { charge: PointCharge; surface: ClosedSurface } {
+ return {
+ charge: { coulombs: state.chargeNC * 1e-9, position: { x: state.chargeX, y: 0, z: 0 } },
+ surface: { shape: state.shape, size: state.size },
+ };
+}
diff --git a/src/models/electric-flux/view.ts b/src/models/electric-flux/view.ts
new file mode 100644
index 0000000..7413cd9
--- /dev/null
+++ b/src/models/electric-flux/view.ts
@@ -0,0 +1,218 @@
+import katex from 'katex';
+import type { ModelContext, ModelInstance } from '../../core/model';
+import type { MessageKey } from '../../i18n';
+import { icon } from '../../ui/icons';
+import { measureFlux, VACUUM_PERMITTIVITY } from './physics';
+import { createFluxScene } from './scene';
+import { defaultState, physicsInputs, presets, type FluxState, type Preset } from './state';
+
+const equations = {
+ gauss: String.raw`\Phi_E = \oiint_S \mathbf{E}\cdot d\mathbf{A} = \frac{Q_{\mathrm{enc}}}{\varepsilon_0}`,
+ patch: String.raw`d\Phi_E = \mathbf{E}\cdot d\mathbf{A}`,
+ surface: String.raw`\Phi_E = \oiint_S \mathbf{E}\cdot d\mathbf{A}`,
+ charge: String.raw`\Phi_E = \frac{Q_{\mathrm{enc}}}{\varepsilon_0}`,
+};
+
+const math = (expression: string) => katex.renderToString(expression, { throwOnError: true, output: 'htmlAndMathml' });
+
+export function mountFluxModel(host: HTMLElement, { i18n }: ModelContext): ModelInstance {
+ const { t, number } = i18n;
+ let state: FluxState = { ...defaultState };
+ const abort = new AbortController();
+ let announcement: ReturnType<typeof setTimeout> | undefined;
+
+ const range = (id: string, label: MessageKey, min: number, max: number, step: number, value: number, symbol: string) => `
+ <div class="range-control">
+ <div class="control-heading"><label for="${id}" id="${id}-label">${t(label)} <span class="symbol">${symbol}</span></label><output for="${id}" id="${id}-value"></output></div>
+ <input id="${id}" type="range" min="${min}" max="${max}" step="${step}" value="${value}">
+ <div class="range-limits" aria-hidden="true"><span>${number(min)}</span><span>${number(max)}</span></div>
+ </div>`;
+
+ const lesson = (key: 'one' | 'two' | 'three', formula: string) => `
+ <article class="lesson-card"><span class="lesson-number">${t(`flux.lesson.${key}.number`)}</span>
+ <h3>${t(`flux.lesson.${key}.title`)}</h3><div class="lesson-formula">${math(formula)}</div>
+ <p>${t(`flux.lesson.${key}.body`)}</p></article>`;
+
+ host.innerHTML = `
+ <section class="intro" aria-labelledby="model-title">
+ <div><p class="eyebrow"><span class="little-line"></span>${t('flux.eyebrow')}</p>
+ <h1 id="model-title">${t('flux.title')}</h1><p class="subtitle">${t('flux.subtitle')}</p></div>
+ <span class="topic-tag">${icon('orbit')}${t('flux.topic')}</span>
+ </section>
+ <section class="lab" aria-labelledby="model-title">
+ <div class="visual-column">
+ <div class="scene-wrap">
+ <div class="scene-heading"><div><p class="scene-eyebrow"><span class="live-dot"></span>${t('flux.live')}</p><h2>${t('flux.sceneTitle')}</h2></div>
+ <div class="camera-controls" role="group" aria-label="${t('flux.cameraViews')}">
+ <button type="button" data-view="front" title="${t('flux.frontView')}" aria-label="${t('flux.frontView')}">${icon('sphere')}</button>
+ <button type="button" data-view="top" title="${t('flux.topView')}" aria-label="${t('flux.topView')}">${icon('cube')}</button>
+ <button type="button" data-view="default" title="${t('flux.resetView')}" aria-label="${t('flux.resetView')}">${icon('reset')}</button>
+ </div>
+ </div>
+ <div class="scene-host" id="scene"><span class="charge-marker" id="charge-marker"></span></div>
+ <div class="scene-status" id="membership"></div>
+ <div class="scene-bottom"><div class="legend"><span><i class="legend-field"></i><span id="field-legend"></span></span><span><i class="legend-surface"></i>${t('flux.legend.surface')}</span><span id="normal-legend" hidden><i class="legend-normal"></i>${t('flux.legend.normal')}</span></div><p>${icon('orbit')}${t('flux.sceneHint')}</p></div>
+ <span class="sr-only" id="keyboard-hint">${t('flux.keyboardHint')}</span>
+ </div>
+ <div class="readings" role="group" aria-label="${t('flux.readings')}">
+ <div class="reading"><span>${t('flux.enclosed')} <i>Q<sub>enc</sub></i></span><strong id="enclosed-value"></strong></div>
+ <div class="reading"><span>${t('flux.area')} <i>A</i></span><strong id="area-value"></strong></div>
+ <div class="reading reading-flux"><span>${t('flux.netFlux')} <i>Φ<sub>E</sub></i></span><strong id="flux-value"></strong></div>
+ </div>
+ </div>
+ <aside class="controls" aria-labelledby="control-title">
+ <div class="controls-intro"><h2 id="control-title">${t('flux.controls')}</h2><p>${t('flux.controlsHint')}</p></div>
+ ${range('charge', 'flux.charge', -5, 5, 0.25, state.chargeNC, 'q')}
+ ${range('position', 'flux.position', -4, 4, 0.05, state.chargeX, 'x')}
+ <p class="control-note">${t('flux.positionHint')}</p>
+ <div class="control-divider"></div>
+ <fieldset class="shape-control"><legend>${t('flux.surface')}</legend><div class="segmented">
+ <label><input type="radio" name="surface-shape" value="sphere" checked><span>${icon('sphere')}${t('flux.sphere')}</span></label>
+ <label><input type="radio" name="surface-shape" value="cube"><span>${icon('cube')}${t('flux.cube')}</span></label>
+ </div></fieldset>
+ ${range('size', 'flux.radius', 0.75, 3, 0.05, state.size, 'r')}
+ <p class="control-note">${t('flux.surfaceHint')}</p>
+ <div class="control-divider"></div>
+ <fieldset class="layer-controls"><legend>${t('flux.layers')}</legend>
+ <label><span>${t('flux.fieldLines')}</span><input type="checkbox" id="field-lines" checked><span class="toggle" aria-hidden="true"></span></label>
+ <label><span>${t('flux.surfaceLayer')}</span><input type="checkbox" id="surface-layer" checked><span class="toggle" aria-hidden="true"></span></label>
+ <label><span>${t('flux.normals')}</span><input type="checkbox" id="normals"><span class="toggle" aria-hidden="true"></span></label>
+ </fieldset>
+ <button class="reset-button" id="reset" type="button">${icon('reset')}${t('flux.reset')}</button>
+ </aside>
+ </section>
+ <div class="scenarios" role="group" aria-label="${t('flux.presets')}"><span class="scenarios-label">${t('flux.presets')}</span>
+ <button type="button" data-preset="positive" class="scenario" aria-pressed="true"><span class="scenario-symbol positive" aria-hidden="true">+</span>${t('flux.preset.positive')}</button>
+ <button type="button" data-preset="negative" class="scenario" aria-pressed="false"><span class="scenario-symbol negative" aria-hidden="true">−</span>${t('flux.preset.negative')}</button>
+ <button type="button" data-preset="outside" class="scenario" aria-pressed="false">${icon('arrow')}${t('flux.preset.outside')}</button>
+ </div>
+ <section class="insight" aria-labelledby="insight-title"><div class="insight-copy"><div class="insight-icon">${icon('orbit')}</div><div><h2 id="insight-title"></h2><p id="explanation"></p></div></div><div class="main-equation">${math(equations.gauss)}<span>${t('flux.exact')}</span></div></section>
+ <section class="physics-section" id="physics" aria-labelledby="physics-title">
+ <p class="eyebrow">${t('flux.physicsEyebrow')}</p><h2 id="physics-title">${t('flux.physicsTitle')}</h2><p class="physics-subtitle">${t('flux.physicsSubtitle')}</p>
+ <div class="lesson-grid">${lesson('one', equations.patch)}${lesson('two', equations.surface)}${lesson('three', equations.charge)}</div>
+ <div class="try-it"><span>${t('flux.try')}</span><p>${t('flux.tryText')}</p>${icon('arrow')}</div>
+ <details class="model-notes"><summary>${icon('help')}${t('flux.assumptions.title')}</summary><p>${t('flux.assumptions.body')}</p><p>${t('flux.constant', { value: number(VACUUM_PERMITTIVITY, { notation: 'scientific', maximumFractionDigits: 10 }) })}</p><p>${t('flux.reference')}</p></details>
+ </section>
+ <p class="sr-only" id="live-summary" role="status" aria-live="polite" aria-atomic="true"></p>`;
+
+ const element = <T extends HTMLElement = HTMLElement>(selector: string) => host.querySelector<T>(selector)!;
+ const sceneHost = element('#scene');
+ const marker = element('#charge-marker');
+ let scene: ReturnType<typeof createFluxScene> | undefined;
+
+ function showGraphicsError(key: MessageKey) {
+ marker.hidden = true;
+ let message = sceneHost.querySelector<HTMLDivElement>('.graphics-error');
+ if (!message) {
+ message = document.createElement('div');
+ message.className = 'graphics-error';
+ message.setAttribute('role', 'alert');
+ sceneHost.append(message);
+ }
+ message.textContent = t(key);
+ host.querySelectorAll<HTMLButtonElement>('[data-view]').forEach((button) => { button.disabled = true; });
+ }
+
+ try {
+ scene = createFluxScene(sceneHost, marker, () => showGraphicsError('flux.webglLost'));
+ scene.canvas.setAttribute('aria-label', t('flux.sceneLabel'));
+ scene.canvas.setAttribute('aria-describedby', 'keyboard-hint live-summary');
+ } catch (error) {
+ console.warn('WebGL unavailable:', error);
+ showGraphicsError('flux.webglError');
+ }
+
+ const chargeInput = element<HTMLInputElement>('#charge');
+ const positionInput = element<HTMLInputElement>('#position');
+ const sizeInput = element<HTMLInputElement>('#size');
+ const signed = (value: number) => number(value, { maximumFractionDigits: 2, signDisplay: 'exceptZero' });
+ const formatCharge = (value: number) => t('units.charge', { value: signed(value) });
+ const formatDistance = (value: number) => t('units.distance', { value: number(value) });
+ const formatFlux = (value: number) => t('units.flux', { value: number(value, { maximumFractionDigits: 1, signDisplay: 'exceptZero' }) });
+
+ function update() {
+ const { charge, surface } = physicsInputs(state);
+ const readings = measureFlux(charge, surface);
+ const chargeText = readings.enclosedCharge === null ? t('flux.undefined') : formatCharge(readings.enclosedCharge * 1e9);
+ const fluxText = readings.netFlux === null ? t('flux.undefined') : formatFlux(readings.netFlux);
+ const status = state.chargeNC === 0 ? 'neutral' : readings.membership;
+ const explanation = status === 'inside' ? (state.chargeNC > 0 ? 'insidePositive' : 'insideNegative') : status;
+
+ element('#enclosed-value').textContent = chargeText;
+ element('#area-value').textContent = t('units.area', { value: number(readings.area, { maximumFractionDigits: 1 }) });
+ element('#flux-value').textContent = fluxText;
+ element('#membership').textContent = t(`flux.status.${status}`);
+ element('#membership').dataset.status = status;
+ element('#insight-title').textContent = t(`flux.result.${explanation}`);
+ element('#explanation').textContent = t(`flux.explanation.${explanation}`);
+ element('#field-legend').textContent = t(state.chargeNC > 0 ? 'flux.legend.positive' : state.chargeNC < 0 ? 'flux.legend.negative' : 'flux.legend.zero');
+ element('.scene-wrap').dataset.polarity = state.chargeNC > 0 ? 'positive' : state.chargeNC < 0 ? 'negative' : 'neutral';
+ element('#normal-legend').hidden = !state.normals;
+ marker.textContent = t('flux.marker', { value: formatCharge(state.chargeNC) });
+
+ for (const [input, value, text] of [
+ [chargeInput, state.chargeNC, formatCharge(state.chargeNC)],
+ [positionInput, state.chargeX, formatDistance(state.chargeX)],
+ [sizeInput, state.size, formatDistance(state.size)],
+ ] as const) {
+ input.value = String(value);
+ input.setAttribute('aria-valuetext', text);
+ element(`#${input.id}-value`).textContent = text;
+ input.style.setProperty('--progress', `${100 * (value - Number(input.min)) / (Number(input.max) - Number(input.min))}%`);
+ }
+ element('#size-label').textContent = t(state.shape === 'sphere' ? 'flux.radius' : 'flux.halfSide');
+ element<HTMLInputElement>(`[name="surface-shape"][value="${state.shape}"]`).checked = true;
+ element<HTMLInputElement>('#field-lines').checked = state.fieldLines;
+ element<HTMLInputElement>('#surface-layer').checked = state.surface;
+ element<HTMLInputElement>('#normals').checked = state.normals;
+ host.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => {
+ const preset = presets[button.dataset.preset as Preset];
+ const active = (['chargeNC', 'chargeX', 'size', 'shape'] as const).every((key) => state[key] === preset[key]);
+ button.setAttribute('aria-pressed', String(active));
+ });
+ scene?.update(state);
+ clearTimeout(announcement);
+ announcement = setTimeout(() => {
+ element('#live-summary').textContent = t('flux.summary', { status: t(`flux.status.${status}`), charge: chargeText, flux: fluxText });
+ }, 200);
+ }
+
+ host.addEventListener('input', (event) => {
+ const input = event.target;
+ if (!(input instanceof HTMLInputElement)) return;
+ if (input === chargeInput) state.chargeNC = input.valueAsNumber;
+ else if (input === positionInput) state.chargeX = input.valueAsNumber;
+ else if (input === sizeInput) state.size = input.valueAsNumber;
+ else if (input.name === 'surface-shape') state.shape = input.value === 'cube' ? 'cube' : 'sphere';
+ else if (input.id === 'field-lines') state.fieldLines = input.checked;
+ else if (input.id === 'surface-layer') state.surface = input.checked;
+ else if (input.id === 'normals') state.normals = input.checked;
+ else return;
+ update();
+ }, { signal: abort.signal });
+
+ host.addEventListener('click', (event) => {
+ const button = (event.target as HTMLElement).closest<HTMLButtonElement>('button');
+ if (!button) return;
+ if (button.dataset.view) {
+ scene?.setView(button.dataset.view as 'default' | 'front' | 'top');
+ } else if (button.dataset.preset) {
+ state = { ...presets[button.dataset.preset as Preset] };
+ update();
+ } else if (button.id === 'reset') {
+ state = { ...defaultState };
+ scene?.setView('default');
+ update();
+ }
+ }, { signal: abort.signal });
+
+ update();
+ return {
+ dispose() {
+ clearTimeout(announcement);
+ abort.abort();
+ scene?.dispose();
+ host.replaceChildren();
+ },
+ };
+}
diff --git a/src/models/registry.ts b/src/models/registry.ts
new file mode 100644
index 0000000..e78ddba
--- /dev/null
+++ b/src/models/registry.ts
@@ -0,0 +1,13 @@
+import type { ModelDefinition } from '../core/model';
+
+// Register only subjects enabled by PLAN.md. Each model loads its own scene and UI.
+export const models: readonly ModelDefinition[] = [
+ {
+ id: 'electric-flux',
+ titleKey: 'flux.title',
+ async mount(host, context) {
+ const { mountFluxModel } = await import('./electric-flux/view');
+ return mountFluxModel(host, context);
+ },
+ },
+];
diff --git a/src/physics/electricField.test.ts b/src/physics/electricField.test.ts
deleted file mode 100644
index 2e01309..0000000
--- a/src/physics/electricField.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { electricField, COULOMB_CONSTANT } from './electricField';
-
-describe('Coulomb electric field', () => {
- it('points away from a positive charge with inverse-square magnitude', () => {
- const field = electricField({ x: 2, y: 0, z: 0 }, [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }]);
- expect(field.x).toBeCloseTo(COULOMB_CONSTANT * 1e-9 / 4);
- expect(field.y).toBe(0);
- expect(field.z).toBe(0);
- });
- it('superposes opposite charges in the same direction at the midpoint', () => {
- const field = electricField({ x: 0, y: 0, z: 0 }, [
- { x: -1, y: 0, z: 0, coulombs: 1e-9 },
- { x: 1, y: 0, z: 0, coulombs: -1e-9 },
- ]);
- expect(field.x).toBeCloseTo(2 * COULOMB_CONSTANT * 1e-9);
- });
- it('omits singular samples within the visualization exclusion radius', () => {
- expect(electricField({ x: 0, y: 0, z: 0 }, [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }])).toEqual({ x: 0, y: 0, z: 0 });
- });
-});
diff --git a/src/physics/electricField.ts b/src/physics/electricField.ts
deleted file mode 100644
index 26975e3..0000000
--- a/src/physics/electricField.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-export interface Point { x: number; y: number; z: number }
-export interface Charge extends Point { coulombs: number }
-export const COULOMB_CONSTANT = 8.9875517923e9;
-
-/** SI units: positions in metres, charges in coulombs, output in N/C.
- * Samples inside 0.18 m of a charge are omitted to avoid visual singularities.
- */
-export function electricField(point: Point, charges: readonly Charge[]): Point {
- const field = { x: 0, y: 0, z: 0 };
- for (const charge of charges) {
- const dx = point.x - charge.x;
- const dy = point.y - charge.y;
- const dz = point.z - charge.z;
- const r2 = dx * dx + dy * dy + dz * dz;
- if (r2 < 0.18 ** 2) continue;
- const factor = COULOMB_CONSTANT * charge.coulombs / (r2 * Math.sqrt(r2));
- field.x += factor * dx;
- field.y += factor * dy;
- field.z += factor * dz;
- }
- return field;
-}
diff --git a/src/physics/models.test.ts b/src/physics/models.test.ts
deleted file mode 100644
index 3ff6487..0000000
--- a/src/physics/models.test.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { electricField } from './electricField';
-import { potential, forceOn, pairEnergy, gaussianFlux, capacitor, conductor, semiconductor } from './models';
-
-// Fixtures use hand-calculated SI results, independent of model implementations.
-describe('charge, potential and energy', () => {
- const source = [{ x: 0, y: 0, z: 0, coulombs: 2e-9 }];
- it('gives scalar potential tending to zero at infinity', () => {
- expect(potential({ x: 2, y: 0, z: 0 }, source)).toBeCloseTo(8.98755, 4);
- expect(potential({ x: 1e9, y: 0, z: 0 }, source)).toBeCloseTo(0, 6);
- });
- it('makes the field the negative spatial gradient of potential', () => {
- const h = 1e-4;
- const numerical = -(potential({ x: 2 + h, y: 0, z: 0 }, source) - potential({ x: 2 - h, y: 0, z: 0 }, source)) / (2 * h);
- expect(electricField({ x: 2, y: 0, z: 0 }, source).x).toBeCloseTo(numerical, 5);
- });
- it('reverses force for a negative test charge', () => {
- expect(forceOn({ x: 2, y: 0, z: 0, coulombs: -3e-9 }, source).x).toBeCloseTo(-13.48132768845e-9, 16);
- });
- it('gives negative interaction energy for opposite charges', () => {
- expect(pairEnergy(2e-9, -3e-9, 2)).toBeCloseTo(-26.9626553769e-9, 16);
- });
- it('marks potential at a charge as undefined instead of inventing a finite value', () => {
- expect(potential({ x: 0, y: 0, z: 0 }, source)).toBeNaN();
- expect(() => pairEnergy(1, 1, 0)).toThrow(RangeError);
- });
-});
-
-describe('Gauss law', () => {
- it('counts only enclosed charge, including its sign', () => {
- const charges = [{ x: 0, y: 0, z: 0, coulombs: -2e-9 }, { x: 3, y: 0, z: 0, coulombs: 5e-9 }];
- expect(gaussianFlux(charges, 2)).toBeCloseTo(-225.8818, 3);
- expect(gaussianFlux(charges, 4)).toBeCloseTo(338.8227, 3);
- });
- it('does not depend on surface size when enclosed charge is unchanged', () => {
- const charges = [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }];
- expect(gaussianFlux(charges, 1)).toBe(gaussianFlux(charges, 2));
- });
- it('marks charge on the Gaussian surface as undefined', () => {
- expect(gaussianFlux([{ x: 2, y: 0, z: 0, coulombs: 1e-9 }], 2)).toBeNaN();
- });
-});
-
-describe('parallel plate capacitor', () => {
- it('converts physical geometry into C, Q, E and stored energy', () => {
- const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 1, voltage: 10 });
- expect(c.capacitance).toBeCloseTo(8.8541878128e-11, 18);
- expect(c.charge).toBeCloseTo(8.8541878128e-10, 18);
- expect(c.field).toBe(10000);
- expect(c.energy).toBeCloseTo(4.4270939064e-9, 17);
- });
- it('increases free charge at fixed voltage when dielectric is inserted', () => {
- const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 4, voltage: 10 });
- expect(c.field).toBe(10000);
- expect(c.charge).toBeCloseTo(3.54167512512e-9, 17);
- expect(c.polarization).toBeCloseTo(2.65625634384e-7, 15);
- });
- it('reduces voltage and energy at fixed free charge', () => {
- const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 4, voltage: 10, isolated: true });
- expect(c.charge).toBeCloseTo(8.8541878128e-10, 18);
- expect(c.voltage).toBeCloseTo(2.5);
- expect(c.field).toBeCloseTo(2500);
- expect(c.energy).toBeCloseTo(1.1067734766e-9, 17);
- });
- it('rejects impossible geometry', () => {
- expect(() => capacitor({ area: 0.01, gap: 0, relativePermittivity: 1, voltage: 10 })).toThrow(RangeError);
- });
-});
-
-describe('current and drift', () => {
- it('follows Ohm law and sends electrons opposite conventional current', () => {
- const wire = conductor({ voltage: 2, resistivity: 1.68e-8, length: 1, area: 1e-6, carrierDensity: 8.5e28 });
- expect(wire.resistance).toBeCloseTo(0.0168, 6);
- expect(wire.current).toBeCloseTo(119.047619, 5);
- expect(wire.electronDrift).toBeCloseTo(-0.00874160935, 10);
- expect(wire.power).toBeCloseTo(238.095238, 5);
- });
- it('reverses current but keeps dissipated power positive with reversed voltage', () => {
- const wire = conductor({ voltage: -2, resistivity: 1.68e-8, length: 1, area: 1e-6, carrierDensity: 8.5e28 });
- expect(wire.current).toBeLessThan(0);
- expect(wire.electronDrift).toBeGreaterThan(0);
- expect(wire.power).toBeGreaterThan(0);
- });
-});
-
-describe('semiconductor equilibrium at 300 K', () => {
- it('has equal intrinsic electrons and holes', () => {
- const s = semiconductor({ intrinsicDensity: 1e16, netDoping: 0, electronMobility: 0.135, holeMobility: 0.048 });
- expect(s.electrons).toBe(1e16);
- expect(s.holes).toBe(1e16);
- expect(s.fermiShift).toBe(0);
- expect(s.conductivity).toBeCloseTo(0.0002931983, 9);
- });
- it('conserves mass action and charge neutrality even at heavy donor doping', () => {
- const s = semiconductor({ intrinsicDensity: 1e16, netDoping: 1e22, electronMobility: 0.135, holeMobility: 0.048 });
- expect(s.electrons / 1e22).toBeCloseTo(1, 10);
- expect(s.holes).toBeCloseTo(1e10, 0);
- expect(s.electrons * s.holes / 1e32).toBeCloseTo(1, 12);
- expect((s.electrons - s.holes) / 1e22).toBeCloseTo(1, 12);
- expect(s.fermiShift).toBeCloseTo(0.3571586, 5);
- });
- it('makes holes majority carriers for acceptor doping', () => {
- const s = semiconductor({ intrinsicDensity: 1e16, netDoping: -1e22, electronMobility: 0.135, holeMobility: 0.048 });
- expect(s.holes / 1e22).toBeCloseTo(1, 10);
- expect(s.electrons).toBeCloseTo(1e10, 0);
- expect(s.fermiShift).toBeLessThan(0);
- });
-});
diff --git a/src/physics/models.ts b/src/physics/models.ts
deleted file mode 100644
index 8fb121f..0000000
--- a/src/physics/models.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { COULOMB_CONSTANT, electricField, type Charge, type Point } from './electricField';
-
-export const VACUUM_PERMITTIVITY = 8.8541878128e-12;
-export const ELEMENTARY_CHARGE = 1.602176634e-19;
-const THERMAL_ENERGY_EV = 8.617333262e-5 * 300;
-
-function positive(...values: number[]) {
- if (values.some(value => !Number.isFinite(value) || value <= 0)) {
- throw new RangeError('Physical dimensions and material constants must be finite and positive.');
- }
-}
-
-/** Vacuum potential in volts, referenced to infinity. Undefined inside the
- * same 0.18 m visualization exclusion used by electricField. */
-export function potential(point: Point, charges: readonly Charge[]): number {
- let volts = 0;
- for (const charge of charges) {
- if (charge.coulombs === 0) continue;
- const r = Math.hypot(point.x - charge.x, point.y - charge.y, point.z - charge.z);
- if (r < 0.18) return NaN;
- volts += COULOMB_CONSTANT * charge.coulombs / r;
- }
- return volts;
-}
-
-/** Force on a test charge, excluding that charge from the supplied sources. */
-export function forceOn(test: Charge, sources: readonly Charge[]): Point {
- const field = electricField(test, sources);
- return { x: field.x * test.coulombs, y: field.y * test.coulombs, z: field.z * test.coulombs };
-}
-
-export function pairEnergy(q1: number, q2: number, separation: number): number {
- positive(separation);
- return COULOMB_CONSTANT * q1 * q2 / separation;
-}
-
-/** Exact signed flux for an origin-centred spherical Gaussian surface.
- * A point charge exactly on the surface makes the integral undefined. */
-export function gaussianFlux(charges: readonly Charge[], radius: number): number {
- positive(radius);
- let enclosed = 0;
- for (const charge of charges) {
- if (charge.coulombs === 0) continue;
- const r = Math.hypot(charge.x, charge.y, charge.z);
- if (Math.abs(r - radius) < 1e-9) return NaN;
- if (r < radius) enclosed += charge.coulombs;
- }
- return enclosed / VACUUM_PERMITTIVITY;
-}
-
-export interface CapacitorParameters {
- area: number;
- gap: number;
- relativePermittivity: number;
- voltage: number;
- /** If isolated, voltage means the initial vacuum charging voltage. */
- isolated?: boolean;
-}
-export function capacitor(p: CapacitorParameters) {
- positive(p.area, p.gap, p.relativePermittivity);
- const vacuumCapacitance = VACUUM_PERMITTIVITY * p.area / p.gap;
- const capacitance = vacuumCapacitance * p.relativePermittivity;
- const voltage = p.isolated ? p.voltage / p.relativePermittivity : p.voltage;
- const charge = capacitance * voltage;
- const field = voltage / p.gap;
- return {
- capacitance, voltage, charge, field,
- energy: 0.5 * capacitance * voltage ** 2,
- polarization: VACUUM_PERMITTIVITY * (p.relativePermittivity - 1) * field,
- };
-}
-
-export interface ConductorParameters {
- voltage: number;
- resistivity: number;
- length: number;
- area: number;
- carrierDensity: number;
-}
-export function conductor(p: ConductorParameters) {
- positive(p.resistivity, p.length, p.area, p.carrierDensity);
- const resistance = p.resistivity * p.length / p.area;
- const current = p.voltage / resistance;
- return {
- resistance, current,
- power: p.voltage * current,
- density: current / p.area,
- electronDrift: -current / (p.carrierDensity * ELEMENTARY_CHARGE * p.area),
- };
-}
-
-export interface SemiconductorParameters {
- intrinsicDensity: number;
- /** Fully ionized donors minus acceptors, in m^-3. */
- netDoping: number;
- electronMobility: number;
- holeMobility: number;
-}
-/** Non-degenerate thermal equilibrium at 300 K. Solving for the minority
- * through mass action avoids catastrophic subtraction at large doping. */
-export function semiconductor(p: SemiconductorParameters) {
- positive(p.intrinsicDensity, p.electronMobility, p.holeMobility);
- const majority = (Math.abs(p.netDoping) + Math.hypot(p.netDoping, 2 * p.intrinsicDensity)) / 2;
- const minority = p.intrinsicDensity ** 2 / majority;
- const electrons = p.netDoping >= 0 ? majority : minority;
- const holes = p.netDoping >= 0 ? minority : majority;
- return {
- electrons, holes,
- fermiShift: THERMAL_ENERGY_EV * Math.log(electrons / p.intrinsicDensity),
- conductivity: ELEMENTARY_CHARGE * (electrons * p.electronMobility + holes * p.holeMobility),
- };
-}
diff --git a/src/physics/test4.test.ts b/src/physics/test4.test.ts
deleted file mode 100644
index 08dd868..0000000
--- a/src/physics/test4.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-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
deleted file mode 100644
index a4d4be5..0000000
--- a/src/physics/test4.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index f42017d..0000000
--- a/src/scene/experiments.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import * as THREE from 'three';
-import { chargePair, carrierModel, number, plateModel, wireModel, type ExperimentId, type Parameters } from '../experiments';
-import { scene } from '../i18n';
-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, 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);
- if (strength < 1e-8) return;
- arrow(group, position, direction, 0.12 + 0.32 * strength / (strength + 4), colour);
-}
-
-function fieldScene(group: THREE.Group, p: Parameters) {
- grid(group);
- const charges = chargePair(p);
- charges.forEach(charge => signedCharge(group, charge));
- for (let x = -5; x <= 5; x += 0.6) for (let z = -5; z <= 5; z += 0.6) {
- const point = { x, y: 0, z };
- if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.42)) continue;
- compressedFieldArrow(group, point, electricField(point, charges));
- }
- const force = forceOn(charges[1], [charges[0]]);
- arrow(group, { ...charges[1], y: 0.8 }, force, 1.3, colours.gold);
- label(group, String.raw`$\vec F$ ${scene('onRightCharge')}`, { 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, scene('origin'), { x: 0, y: 0, z: 0.45 }, colours.muted, 0.9);
-}
-
-function fluxScene(group: THREE.Group, p: Parameters) {
- const radius = number(p, 'radius');
- const charge: Charge = { x: number(p, 'position'), y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 };
- grid(group, -3.4);
- signedCharge(group, charge);
- const surface = new THREE.Mesh(new THREE.SphereGeometry(radius, 40, 24), new THREE.MeshBasicMaterial({ color: colours.negative, wireframe: true, transparent: true, opacity: 0.11, depthWrite: false }));
- group.add(surface);
- for (let i = 0; i < 150; i++) {
- const y = 1 - 2 * (i + 0.5) / 150;
- const r = Math.sqrt(1 - y * y);
- const theta = i * Math.PI * (3 - Math.sqrt(5));
- const normal = { x: r * Math.cos(theta), y, z: r * Math.sin(theta) };
- const point = { x: normal.x * radius, y: y * radius, z: normal.z * radius };
- if (Math.hypot(point.x - charge.x, point.y, point.z) < 0.3) continue;
- const e = electricField(point, [charge]);
- const dot = e.x * normal.x + e.y * normal.y + e.z * normal.z;
- compressedFieldArrow(group, point, e, dot >= 0 ? colours.field : colours.positive);
- }
- label(group, scene('gaussian'), { x: 0, y: radius + 0.6, z: 0 }, colours.negative, 2.5);
- line(group, [{ x: -4.7, y: 0, z: 0 }, { x: 4.7, y: 0, z: 0 }], colours.muted, 0.4);
-}
-
-function energyScene(group: THREE.Group, p: Parameters) {
- grid(group, -4.3);
- const r = number(p, 'distance');
- const q = number(p, 'testCharge') * 1e-9;
- const source: Charge = { x: -3, y: 0, z: 1.5, coulombs: 3e-9 };
- const moving: Charge = { x: -3 + r, y: 0, z: 1.5, coulombs: q };
- signedCharge(group, source);
- signedCharge(group, moving);
- line(group, [source, moving], colours.muted);
- arrow(group, { ...moving, y: 0.7 }, forceOn(moving, [source]), 1.1, colours.gold);
- const curve: Point[] = [];
- for (let distance = 0.75; distance <= 6; distance += 0.05) curve.push({ x: -3 + distance, y: pairEnergy(3e-9, q, distance) * 1e9 / 20, z: -1.5 });
- line(group, curve, colours.field);
- 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, 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, 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) {
- const charges = chargePair(p);
- const geometry = new THREE.PlaneGeometry(10, 10, 64, 64);
- geometry.rotateX(-Math.PI / 2);
- const positions = geometry.getAttribute('position');
- const values: number[] = [];
- const colourValues: number[] = [];
- const negative = new THREE.Color(colours.negative);
- const positive = new THREE.Color(colours.positive);
- const neutral = new THREE.Color('#203847');
- for (let i = 0; i < positions.count; i++) {
- const point = { x: positions.getX(i), y: 0, z: positions.getZ(i) };
- const v = potential(point, charges);
- const height = Number.isFinite(v) ? Math.tanh(v / 15) : NaN;
- values.push(height);
- positions.setY(i, Number.isFinite(height) ? 2 * height : 0);
- const colour = neutral.clone().lerp(height >= 0 ? positive : negative, Math.abs(Number.isFinite(height) ? height : 0));
- colourValues.push(colour.r, colour.g, colour.b);
- }
- const indices = geometry.getIndex()!;
- const kept: number[] = [];
- for (let i = 0; i < indices.count; i += 3) {
- const a = indices.getX(i), b = indices.getX(i + 1), c = indices.getX(i + 2);
- if ([a, b, c].every(index => Number.isFinite(values[index]))) kept.push(a, b, c);
- }
- geometry.setIndex(kept);
- geometry.setAttribute('color', new THREE.Float32BufferAttribute(colourValues, 3));
- geometry.computeVertexNormals();
- group.add(new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.7, transparent: true, opacity: 0.88 })));
- for (let x = -4.5; x <= 4.5; x += 0.75) for (let z = -4.5; z <= 4.5; z += 0.75) {
- const point = { x, y: 0, z };
- if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.6)) continue;
- const v = potential(point, charges);
- if (!Number.isFinite(v)) continue;
- compressedFieldArrow(group, { ...point, y: 2 * Math.tanh(v / 15) + 0.12 }, electricField(point, charges));
- }
- const probe = { x: number(p, 'probe'), y: 0, z: 1 };
- const v = potential(probe, charges);
- sphere(group, { ...probe, y: 2 * Math.tanh(v / 15) + 0.2 }, 0.13, colours.gold);
- label(group, scene('probe'), { ...probe, y: 2 * Math.tanh(v / 15) + 0.8 }, colours.gold, 1.3);
- charges.forEach(charge => signedCharge(group, { ...charge, y: Math.sign(charge.coulombs) * 2.2 }));
- grid(group, -2.4);
-}
-
-function plateScene(group: THREE.Group, p: Parameters, dielectric: boolean) {
- const c = plateModel(p);
- const halfGap = number(p, 'gap') * 0.75;
- const side = Math.sqrt(number(p, 'area')) * 0.3;
- // Uniform magnification maps lateral metres to 30 scene units/m and gap
- // metres to 1500 units/m: gap is enlarged 50x relative to plate width.
- for (const sign of [1, -1]) {
- const x = -sign * halfGap;
- const colour = sign === 1 ? colours.positive : colours.negative;
- box(group, { x, y: 0, z: 0 }, { x: 0.05, y: side, z: side }, colour, 0.65);
- label(group, sign === 1 ? '+ plate' : '− plate', { x, y: side / 2 + 0.45, z: 0 }, colour, 1.2);
- for (let y = -0.35; y <= 0.35; y += 0.35) for (let z = -0.35; z <= 0.35; z += 0.35) {
- sphere(group, { x: x + sign * 0.035, y: y * side, z: z * side }, 0.025, colour);
- }
- }
- for (let y = -0.3; y <= 0.3; y += 0.3) for (let z = -0.3; z <= 0.3; z += 0.3) {
- const length = Math.max(0.1, 2 * halfGap - 0.1);
- // Repeated arrows are direction cues; readouts provide field magnitude.
- arrow(group, { x: -halfGap + 0.05, y: y * side, z: z * side }, { x: 1, y: 0, z: 0 }, length, colours.field);
- }
- if (dielectric) {
- box(group, { x: 0, y: 0, z: 0 }, { x: Math.max(0.04, 2 * halfGap - 0.08), y: side * 0.92, z: side * 0.92 }, colours.gold, 0.1);
- const kappa = number(p, 'permittivity');
- if (kappa > 1) {
- const boundOffset = Math.min(halfGap * 0.65, 0.12) * (kappa - 1) / kappa;
- for (let y = -0.32; y <= 0.32; y += 0.32) for (let z = -0.32; z <= 0.32; z += 0.32) {
- const center = { x: 0, y: y * side, z: z * side };
- sphere(group, { ...center, x: -boundOffset }, 0.035, colours.negative);
- sphere(group, { ...center, x: boundOffset }, 0.035, colours.positive);
- line(group, [{ ...center, x: -boundOffset }, { ...center, x: boundOffset }], colours.gold);
- }
- }
- }
- line(group, [{ x: -halfGap, y: -side, z: 0 }, { x: halfGap, y: -side, z: 0 }], colours.muted);
- label(group, String.raw`$d = ${number(p, 'gap').toFixed(1)}\,\mathrm{mm}$ (${scene('gap')} $\times50$)`, { x: 0, y: -side - 0.45, z: 0 }, colours.muted, 2.5);
- label(group, String.raw`$${c.voltage.toFixed(2)}\,\mathrm V$ · ${scene(p.connection === 'isolated' ? 'isolated' : 'connected')}`, { x: 0, y: side + 0.8, z: 0 }, colours.field, 3);
- grid(group, -3.2);
-}
-
-function currentScene(group: THREE.Group, p: Parameters): Animate {
- grid(group, -2);
- const model = wireModel(p);
- const length = number(p, 'length') * 3;
- const radius = Math.sqrt(number(p, 'area')) * 0.4;
- const wire = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 28, 1, true), new THREE.MeshStandardMaterial({ color: '#bf8158', transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false }));
- wire.rotation.z = Math.PI / 2;
- group.add(wire);
- for (let i = 0; i < 6; i++) {
- const x = length * ((i + 0.5) / 6 - 0.5);
- for (let j = 0; j < 6; j++) {
- const angle = j * Math.PI / 3;
- sphere(group, { x, y: radius * 0.8 * Math.cos(angle), z: radius * 0.8 * Math.sin(angle) }, 0.055, colours.gold);
- }
- }
- const carriers: THREE.Mesh[] = [];
- for (let i = 0; i < 32; i++) {
- const angle = i * 2.39996;
- const r = radius * 0.65 * Math.sqrt((i % 7 + 1) / 8);
- carriers.push(sphere(group, { x: length * (i / 32 - 0.5), y: r * Math.cos(angle), z: r * Math.sin(angle) }, 0.065, colours.negative));
- }
- 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, scene(model.current === 0 ? 'noCurrent' : 'conventional'), { 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, scene('drift'), { 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) {
- electron.position.x = ((electron.position.x + length / 2 + delta * velocity) % length + length) % length - length / 2;
- }
- };
-}
-
-function semiconductorScene(group: THREE.Group, p: Parameters): Animate {
- grid(group, -3.5);
- const s = carrierModel(p);
- // 3 display units per eV; approximate intrinsic level at midgap.
- const bandEdge = 1.12 / 2 * 3;
- box(group, { x: 0, y: bandEdge + 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.negative, 0.12);
- 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, 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{${scene('midgap')}}$`, { x: -4.4, y: 0, z: 1.65 }, colours.muted, 1.7);
- label(group, scene('conduction'), { x: 0, y: bandEdge + 1.1, z: 0 }, colours.negative, 2.8);
- label(group, scene('valence'), { x: 0, y: -bandEdge - 1.1, z: 0 }, colours.positive, 2.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]]) {
- const count = Math.round(3 + Math.max(0, Math.log10(density) - 8) * 2.5);
- for (let i = 0; i < count; i++) {
- const x = 6.4 * ((i * 0.618034) % 1 - 0.5);
- const z = 2.5 * ((i * 0.414214) % 1 - 0.5);
- const mesh = sphere(group, { x, y: sign * (bandEdge + 0.4), z }, 0.075, sign === 1 ? colours.negative : colours.positive);
- if (sign === -1) {
- (mesh.material as THREE.MeshStandardMaterial).wireframe = true;
- }
- particles.push({ mesh, base: mesh.position.clone(), phase: i * 2.4 + sign });
- }
- }
- let time = 0;
- return delta => {
- time += delta;
- for (const particle of particles) {
- particle.mesh.position.x = particle.base.x + 0.12 * Math.sin(time * 1.3 + particle.phase);
- particle.mesh.position.z = particle.base.z + 0.12 * Math.cos(time * 1.7 + particle.phase);
- }
- };
-}
-
-export function buildExperiment(group: THREE.Group, id: ExperimentId, p: Parameters): Animate {
- switch (id) {
- case 'field': fieldScene(group, p); break;
- case 'flux': fluxScene(group, p); break;
- case 'energy': energyScene(group, p); break;
- case 'potential': potentialScene(group, p); break;
- case 'capacitance': plateScene(group, p, false); break;
- 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
deleted file mode 100644
index e00c234..0000000
--- a/src/scene/lab.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-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 { onLanguageChange, t } from '../i18n';
-import { buildExperiment, type Animate } from './experiments';
-import { disposeGroup } from './primitives';
-
-export function createLab(viewport: HTMLElement) {
- let renderer: THREE.WebGLRenderer;
- try {
- renderer = new THREE.WebGLRenderer({ antialias: true });
- } catch {
- const error = document.createElement('div');
- error.className = 'webgl-error';
- error.setAttribute('role', 'alert');
- error.textContent = t('webglStart');
- viewport.append(error);
- const stop = onLanguageChange(() => { error.textContent = t('webglStart'); });
- return { update: () => {}, resetCamera: (_id?: ExperimentId) => {}, setPaused: () => {}, dispose: () => stop() };
- }
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- renderer.domElement.setAttribute('aria-label', t('canvas'));
- 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));
- const keyLight = new THREE.DirectionalLight(0xffffff, 2);
- keyLight.position.set(3, 6, 5);
- scene.add(keyLight);
- const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
- const controls = new OrbitControls(camera, renderer.domElement);
- controls.enableDamping = true;
- controls.minDistance = 3;
- controls.maxDistance = 32;
- controls.listenToKeyEvents(renderer.domElement);
- 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();
- }
- resetCamera();
- let group = new THREE.Group();
- scene.add(group);
- let animate: Animate = () => {};
- let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- let lastTime = 0;
- let pending: { id: ExperimentId; parameters: Parameters } | undefined;
- let lostWarning: HTMLElement | undefined;
- const stopLanguage = onLanguageChange(() => {
- renderer.domElement.setAttribute('aria-label', t('canvas'));
- if (lostWarning) lostWarning.textContent = t('webglLost');
- });
- function update(id: ExperimentId, parameters: Parameters) {
- // Coalesce range events to one rebuild per animation frame.
- pending = { id, parameters: { ...parameters } };
- }
- const observer = new ResizeObserver(() => {
- 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 => {
- event.preventDefault();
- if (!viewport.querySelector('[role="alert"]')) {
- const warning = document.createElement('p');
- warning.setAttribute('role', 'alert');
- warning.className = 'webgl-error';
- warning.textContent = t('webglLost');
- lostWarning = warning;
- viewport.append(warning);
- }
- });
- renderer.setAnimationLoop(time => {
- const delta = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0;
- lastTime = time;
- if (pending) {
- disposeGroup(group);
- scene.remove(group);
- group = new THREE.Group();
- scene.add(group);
- animate = buildExperiment(group, pending.id, pending.parameters);
- pending = undefined;
- }
- if (!paused && !document.hidden) animate(delta);
- controls.update();
- renderer.render(scene, camera);
- labels.render(scene, camera);
- });
- return {
- update,
- resetCamera,
- setPaused: (value: boolean) => { paused = value; },
- dispose: () => {
- stopLanguage();
- renderer.setAnimationLoop(null);
- observer.disconnect();
- controls.dispose();
- disposeGroup(group);
- renderer.dispose();
- renderer.domElement.remove();
- labels.domElement.remove();
- },
- };
-}
diff --git a/src/scene/primitives.ts b/src/scene/primitives.ts
deleted file mode 100644
index 384ea1d..0000000
--- a/src/scene/primitives.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-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' };
-export const vector = (p: Point) => new THREE.Vector3(p.x, p.y, p.z);
-export function sphere(group: THREE.Group, position: Point, radius: number, colour: string): THREE.Mesh {
- const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 18, 12), new THREE.MeshStandardMaterial({ color: colour, roughness: 0.3, emissive: colour, emissiveIntensity: 0.12 }));
- mesh.position.copy(vector(position));
- group.add(mesh);
- return mesh;
-}
-export function box(group: THREE.Group, position: Point, dimensions: Point, colour: string, opacity = 1): THREE.Mesh {
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(dimensions.x, dimensions.y, dimensions.z), new THREE.MeshStandardMaterial({ color: colour, transparent: opacity < 1, opacity, depthWrite: opacity === 1, side: THREE.DoubleSide, roughness: 0.45 }));
- mesh.position.copy(vector(position));
- group.add(mesh);
- return mesh;
-}
-export function line(group: THREE.Group, points: Point[], colour: string, opacity = 1): THREE.Line {
- const mesh = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points.map(vector)), new THREE.LineBasicMaterial({ color: colour, transparent: opacity < 1, opacity }));
- group.add(mesh);
- return mesh;
-}
-export function arrow(group: THREE.Group, origin: Point, direction: Point, length: number, colour = colours.field): void {
- const dir = vector(direction);
- 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, 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';
- canvas.width = Math.ceil(context.measureText(text).width) + 36;
- canvas.height = 80;
- context.fillStyle = 'rgba(16, 24, 34, 0.85)';
- context.fillRect(0, 0, canvas.width, canvas.height);
- context.font = '500 44px system-ui, sans-serif';
- context.fillStyle = colour;
- context.textAlign = 'center';
- context.textBaseline = 'middle';
- context.fillText(text, canvas.width / 2, 40);
- const texture = new THREE.CanvasTexture(canvas);
- texture.colorSpace = THREE.SRGBColorSpace;
- const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false }));
- sprite.position.copy(vector(position));
- const aspect = canvas.width / canvas.height;
- const screenHeight = 23 + Math.min(2, Math.max(-1, width - 2)) * 2;
- const viewportSize = new THREE.Vector2();
- const cameraPosition = new THREE.Vector3();
- // Keep labels legible when the camera or viewport changes. Width hints
- // affect text size slightly; the actual texture fits the text content.
- sprite.onBeforeRender = (renderer, _scene, camera) => {
- if (!(camera instanceof THREE.PerspectiveCamera)) return;
- sprite.getWorldPosition(cameraPosition).applyMatrix4(camera.matrixWorldInverse);
- renderer.getSize(viewportSize);
- 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();
- };
- group.add(sprite);
- return sprite;
-}
-export function grid(group: THREE.Group, y = -0.25) {
- const helper = new THREE.GridHelper(12, 12, 0x375066, 0x22313f);
- helper.position.y = y;
- group.add(helper);
-}
-
-/** Dispose shared geometries/materials once per scene replacement, including
- * textures owned by generated labels. No GPU resources survive a lesson. */
-export function disposeGroup(group: THREE.Group) {
- const geometries = new Set<THREE.BufferGeometry>();
- const materials = new Set<THREE.Material>();
- const textures = new Set<THREE.Texture>();
- group.traverse(object => {
- const mesh = object as THREE.Mesh;
- if (mesh.geometry) geometries.add(mesh.geometry);
- if (mesh.material) {
- const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
- for (const material of list) {
- materials.add(material);
- const map = (material as THREE.MeshBasicMaterial).map;
- if (map) textures.add(map);
- }
- }
- });
- geometries.forEach(geometry => geometry.dispose());
- materials.forEach(material => material.dispose());
- textures.forEach(texture => texture.dispose());
- group.clear();
-}
diff --git a/src/scene/test4.ts b/src/scene/test4.ts
deleted file mode 100644
index a3f6ccd..0000000
--- a/src/scene/test4.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import * as THREE from 'three';
-import { number, format as formatNumber, type Parameters, type TestId } from '../experiments';
-import { scene } from '../i18n';
-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, scene('energyConstant'), { 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 f494d8e..5ef2c34 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,180 +1,266 @@
:root {
- font-family: Inter, 'Segoe UI', system-ui, sans-serif;
- color: #dbe7f0;
- background: #101822;
+ font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
+ color: #243b3c;
+ background: #f6f7f3;
font-synthesis: none;
- color-scheme: dark;
- --mint: #91e2cb;
- --muted: #98adbf;
- --border: #2a394a;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ font-weight: 400;
+ --accent: #287a6a;
+ --muted: #6b7775;
+ --border: #dce3dd;
}
+
* { box-sizing: border-box; }
-[hidden] { display: none !important; }
+html { scroll-behavior: smooth; scroll-padding-top: 30px; }
body { margin: 0; }
-button, select, input { font: inherit; }
-button, select { color: inherit; }
-button, a, select, input { -webkit-tap-highlight-color: transparent; }
+button, input { font: inherit; }
+button, a, input, summary { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
-a { color: var(--mint); text-underline-offset: 4px; }
-button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, canvas:focus-visible, summary:focus-visible { outline: 2px solid var(--mint); outline-offset: 4px; }
-.app-header { min-height: 96px; display: flex; align-items: center; gap: 22px; padding: 20px 32px; border-bottom: 1px solid var(--border); background: #121d29; }
-.brand { flex-shrink: 0; font: 700 19px monospace; text-decoration: none; color: var(--mint); border: 1px solid #3c7069; border-radius: 9px; padding: 15px 12px; }
-.brand span { color: #516a79; padding: 0 3px; }
-h1 { font-size: 19px; font-weight: 600; margin: 0; }
-.header-title p { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
-.status { margin-left: auto; color: var(--mint); font: 10px monospace; letter-spacing: 1px; white-space: nowrap; display: flex; align-items: center; gap: 8px; }
-.status i { background: var(--mint); width: 6px; height: 6px; border-radius: 50%; }
-.subjects { background: #121d29; border-bottom: 1px solid var(--border); overflow-x: auto; padding: 0 24px; }
-[role='tablist'] { display: flex; min-width: max-content; }
-[role='tab'] { display: flex; align-items: center; gap: 9px; background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 20px 13px; font-size: 12px; color: var(--muted); white-space: nowrap; }
-[role='tab'] span { color: #71899d; font: 10px monospace; }
-[role='tab']:hover { background: #1b2b3a; color: #dbe7f0; }
-[role='tab'][aria-selected='true'] { color: var(--mint); border-bottom-color: var(--mint); background: #192b32; }
-[role='tab'][aria-selected='true'] span { color: var(--mint); }
-main { display: grid; grid-template-columns: 310px minmax(0, 1fr); min-height: calc(100dvh - 153px); }
-.controls-panel { padding: 28px 25px; border-right: 1px solid var(--border); background: #15212e; }
-.eyebrow { font: 10px monospace; color: var(--mint); letter-spacing: 2px; margin: 0 0 14px; }
-h2 { font-size: 26px; font-weight: 550; letter-spacing: -0.5px; line-height: 1.25; margin: 0 0 15px; }
-p { line-height: 1.7; }
-.description { color: var(--muted); font-size: 13px; margin: 0 0 30px; }
-.parameter { margin: 24px 0; }
-.parameter-label { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
-label { font-size: 12px; color: #c2d0dd; }
-output { flex-shrink: 0; color: var(--mint); font: 12px monospace; }
-input[type='range'] { display: block; width: 100%; accent-color: var(--mint); margin: 14px 0 0; cursor: pointer; }
-input:disabled { opacity: 0.35; cursor: default; }
-select { display: block; width: 100%; padding: 10px 8px; border: 1px solid #3b4d60; border-radius: 6px; background: #1c2c3d; margin-top: 10px; font-size: 12px; }
-.panel-actions button { width: 100%; border: 1px solid #3b4d60; border-radius: 6px; background: #1c2c3d; padding: 11px; font-size: 12px; }
-button:hover { border-color: var(--mint); }
-.measurements { margin-top: 25px; border: 1px solid #2a4252; border-radius: 8px; padding: 4px 16px; background: #101c27; }
-.measurement { padding: 14px 0; }
-.measurement + .measurement { border-top: 1px solid #243342; }
-.measurement > span { display: block; font-size: 11px; color: var(--muted); }
-.measurement > div { display: flex; align-items: baseline; gap: 8px; margin-top: 6px; }
-.measurement strong { font: 23px monospace; color: var(--mint); }
-.measurement small { font-size: 11px; color: var(--muted); }
-.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; }
-.scene-toolbar button { pointer-events: auto; background: #15212ee8; border: 1px solid #34495c; border-radius: 5px; padding: 7px 10px; font-size: 11px; }
-.legend { position: absolute; z-index: 1; pointer-events: none; left: 24px; top: 64px; display: flex; flex-wrap: wrap; gap: 8px 18px; right: 24px; }
-.legend span { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; }
-.legend i { display: inline-block; width: 5px; height: 5px; border-radius: 50%; }
-.scene-footer { position: absolute; z-index: 1; pointer-events: none; bottom: 0; padding: 24px 24px 12px; width: 100%; background: linear-gradient(transparent, #101822ed); }
-.scene-footer p { margin: 3px 0; color: #b3c8d7; font-size: 11px; line-height: 1.6; }
-.scene-footer .hint { color: #7890a4; font-size: 10px; }
-.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; }
-.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; align-items: center; 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; }
-.language-picker { display: flex; align-items: center; gap: 8px; margin-left: 6px; padding: 0 8px 0 10px; border-left: 1px solid var(--border); color: var(--muted); font-size: 12px; }
-.language-picker select { width: auto; margin: 0; padding: 8px 8px; }
-.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; }
+button:disabled { cursor: default; opacity: 0.4; }
+a { color: inherit; }
+button, a, input, summary, canvas { outline-offset: 5px; }
+:focus-visible { outline: 3px solid #428dce; }
+button { transition: background 150ms ease, border-color 150ms ease, color 150ms ease; }
+[hidden] { display: none !important; }
+h1, h2, h3, p { margin: 0; }
+.icon { width: 20px; height: 20px; flex-shrink: 0; vertical-align: middle; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+.skip-link { position: fixed; top: -100px; left: 20px; z-index: 10; background: white; padding: 12px 20px; border-radius: 6px; }
+.skip-link:focus { top: 12px; }
+.site-header { height: 88px; max-width: 1480px; padding: 0 48px; margin: auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); }
+.brand { text-decoration: none; display: flex; align-items: center; gap: 10px; }
+.brand > .icon { width: 35px; height: 35px; color: var(--accent); }
+.brand > span:first-of-type { font-size: 27px; font-weight: 650; letter-spacing: -1.2px; }
+.brand-divider { height: 23px; width: 1px; background: #d2dbd5; margin: 0 10px; }
+.brand small { color: var(--muted); font-size: 12px; letter-spacing: 0.1px; }
+.site-header nav { display: flex; align-items: center; gap: 29px; }
+.nav-link { font-size: 13px; text-decoration: none; color: #65716d; }
+.nav-link:hover { color: var(--accent); }
+.model-number { padding: 7px 10px; background: #e8ede6; border-radius: 5px; color: #586b60; font-family: "Consolas", monospace; font-size: 10px; letter-spacing: 1.2px; }
+main { max-width: 1384px; margin: 0 auto; padding: 0 40px; }
+main:focus { outline: none; }
+.intro { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 46px 0 32px; }
+.eyebrow { color: var(--accent); font-size: 10px; font-weight: 650; letter-spacing: 1.9px; display: flex; align-items: center; gap: 9px; }
+.little-line { height: 1px; width: 22px; background: var(--accent); }
+h1 { font-size: clamp(30px, 3.1vw, 43px); font-weight: 550; line-height: 1.2; letter-spacing: -1.7px; margin-top: 12px; color: #203c3a; }
+.subtitle { color: var(--muted); font-size: 14px; line-height: 1.65; margin-top: 13px; }
+.topic-tag { display: flex; align-items: center; gap: 8px; flex-shrink: 0; border: 1px solid #d4dfd5; border-radius: 20px; padding: 8px 13px; font-size: 12px; color: #57705d; background: #eef2e9; }
+.topic-tag .icon { width: 17px; height: 17px; }
+.lab { display: grid; grid-template-columns: minmax(0, 1fr) 310px; background: #fff; border: 1px solid #d6dfd8; border-radius: 13px; overflow: hidden; box-shadow: 0 8px 25px #233e3610; }
+.visual-column { min-width: 0; display: flex; flex-direction: column; }
+.scene-wrap { position: relative; min-height: 510px; flex: 1; background: radial-gradient(ellipse at 48% 44%, #1c3c40 0%, #10292e 65%, #0e242a 100%); color: #e3eeea; isolation: isolate; overflow: hidden; }
+.scene-heading { position: absolute; z-index: 2; inset: 26px 27px auto; display: flex; justify-content: space-between; align-items: flex-start; gap: 15px; pointer-events: none; }
+.scene-eyebrow { display: flex; align-items: center; gap: 7px; font-size: 9px; letter-spacing: 1.6px; color: #9ab7b4; font-weight: 500; }
+.live-dot { width: 5px; height: 5px; border-radius: 100%; background: #a7d5ad; box-shadow: 0 0 10px #98ceaa66; }
+.scene-heading h2 { font-size: 15px; font-weight: 450; margin-top: 8px; letter-spacing: 0.1px; }
+.camera-controls { display: flex; background: #ffffff06; border: 1px solid #ffffff14; border-radius: 6px; pointer-events: auto; }
+.camera-controls button { display: grid; place-items: center; border: 0; background: none; color: #bad1ce; width: 34px; height: 34px; }
+.camera-controls button:hover { background: #ffffff10; }
+.camera-controls .icon { height: 17px; width: 17px; }
+.scene-host { position: absolute; inset: 0; }
+.scene-host canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
+.scene-host canvas:active { cursor: grabbing; }
+.scene-host canvas:focus-visible { outline-offset: -4px; }
+.charge-marker { position: absolute; padding: 4px 8px; border: 1px solid #f4b58a29; background: #132c30c9; border-radius: 4px; color: #f4c09c; font-family: "Consolas", monospace; font-size: 11px; white-space: nowrap; transform: translateX(-50%); pointer-events: none; }
+[data-polarity="negative"] .charge-marker { color: #a4caff; border-color: #85bafa30; }
+[data-polarity="neutral"] .charge-marker { color: #c3d1d0; }
+.scene-status { position: absolute; top: 99px; left: 27px; padding: 5px 8px; font-size: 10px; color: #b4c9bd; border: 1px solid #addbb324; border-radius: 4px; background: #cee4d207; pointer-events: none; }
+.scene-status::before { content: ''; display: inline-block; height: 4px; width: 4px; background: currentColor; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
+.scene-status[data-status="boundary"] { color: #f2c387; }
+.scene-bottom { position: absolute; inset: auto 26px 20px; display: flex; flex-direction: column; gap: 14px; pointer-events: none; }
+.legend { display: flex; gap: 17px; flex-wrap: wrap; font-size: 10px; color: #b2c6c2; }
+.legend > span { display: inline-flex; align-items: center; gap: 6px; }
+.legend i { width: 15px; height: 2px; display: inline-block; }
+.legend-field { background: #f4b58a; }
+[data-polarity="negative"] .legend-field { background: #85bafa; }
+[data-polarity="neutral"] .legend-field { background: #a1b3b4; }
+.legend-surface { border-top: 1px dashed #6fc9bb; }
+.legend-normal { background: #d9e8d2; }
+.scene-bottom > p { color: #88a5a6; font-size: 10px; display: flex; align-items: center; gap: 6px; }
+.scene-bottom > p .icon { width: 14px; height: 14px; }
+.graphics-error { position: absolute; top: 42%; left: 10%; right: 10%; padding: 20px; border: 1px solid #9db7b6; background: #173137; line-height: 1.7; font-size: 14px; border-radius: 7px; }
+.readings { display: grid; grid-template-columns: 1fr 1fr 1.22fr; padding: 22px 25px; border-top: 1px solid #dde4df; background: #fdfefb; gap: 20px; }
+.reading { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
+.reading + .reading { padding-left: 22px; border-left: 1px solid var(--border); }
+.reading > span { color: var(--muted); font-size: 11px; display: flex; align-items: baseline; gap: 6px; }
+.reading i { font-family: Georgia, serif; color: #7d8d84; }
+.reading strong { font-family: "Consolas", "SFMono-Regular", monospace; font-size: clamp(15px, 1.6vw, 21px); font-weight: 400; letter-spacing: -0.7px; color: #354c42; white-space: nowrap; }
+.reading-flux strong { color: #247e65; }
+.controls { padding: 25px 25px 17px; border-left: 1px solid var(--border); }
+.controls-intro h2 { font-size: 15px; font-weight: 600; letter-spacing: -0.2px; }
+.controls-intro p { font-size: 11px; color: var(--muted); line-height: 1.6; margin-top: 5px; margin-bottom: 23px; }
+.range-control { margin-top: 18px; }
+.control-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }
+.control-heading label { font-weight: 550; }
+.symbol { color: #859489; font-family: Georgia, serif; font-style: italic; font-size: 14px; margin-left: 5px; }
+.control-heading output { font-family: "Consolas", monospace; font-size: 11px; background: #edf3ee; color: #33765e; padding: 4px 7px; border-radius: 4px; min-width: 55px; text-align: center; }
+input[type="range"] { display: block; width: 100%; height: 22px; background: transparent; margin: 11px 0 0; appearance: none; cursor: pointer; --progress: 50%; }
+input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 10px; background: linear-gradient(to right, #6e9983 var(--progress), #e4eae4 var(--progress)); }
+input[type="range"]::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 3px solid #52816c; margin-top: -5.5px; box-shadow: 0 1px 3px #263e4022; }
+input[type="range"]::-moz-range-track { height: 3px; border-radius: 10px; background: #e4eae4; }
+input[type="range"]::-moz-range-progress { height: 3px; background: #6e9983; }
+input[type="range"]::-moz-range-thumb { width: 9px; height: 9px; border-radius: 50%; background: #fff; border: 3px solid #52816c; }
+.range-limits { display: flex; justify-content: space-between; font-family: "Consolas", monospace; color: #64756b; font-size: 9px; margin-top: 0; }
+.control-note { font-size: 10px; color: #5f7065; line-height: 1.65; margin-top: 7px; }
+.control-divider { height: 1px; background: #e5eae4; margin: 18px 0; }
+fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
+legend { font-size: 11px; font-weight: 550; padding: 0; margin-bottom: 10px; }
+.segmented { display: flex; gap: 5px; padding: 4px; background: #f1f4ef; border-radius: 6px; border: 1px solid #e6ebe4; }
+.segmented label { flex: 1; position: relative; cursor: pointer; }
+.segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; }
+.segmented span { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 4px; padding: 7px 5px; font-size: 11px; color: #63746b; }
+.segmented .icon { width: 16px; height: 16px; }
+.segmented input:checked + span { background: white; border-color: #d3ded3; color: #2b6c54; box-shadow: 0 1px 2px #153c1810; }
+.segmented input:focus-visible + span { outline: 3px solid #428dce; outline-offset: 2px; }
+.layer-controls legend { margin-bottom: 6px; }
+.layer-controls label { position: relative; display: flex; align-items: center; justify-content: space-between; font-size: 11px; line-height: 1.5; padding: 6px 0; color: #5f6e65; cursor: pointer; }
+.layer-controls input { position: absolute; right: 0; width: 29px; height: 17px; opacity: 0; margin: 0; }
+.toggle { width: 28px; height: 16px; border-radius: 20px; background: #d9e1d9; padding: 2px; transition: background 150ms ease; }
+.toggle::before { content: ''; display: block; width: 12px; height: 12px; border-radius: 50%; background: white; box-shadow: 0 1px 2px #0002; transition: transform 150ms ease; }
+.layer-controls input:checked + .toggle { background: #63977b; }
+.layer-controls input:checked + .toggle::before { transform: translateX(12px); }
+.layer-controls input:focus-visible + .toggle { outline: 3px solid #428dce; outline-offset: 3px; }
+.reset-button { width: 100%; display: flex; justify-content: center; align-items: center; gap: 7px; background: #fff; color: #627168; border: 1px solid #dce4dc; border-radius: 5px; padding: 9px; font-size: 11px; margin-top: 17px; }
+.reset-button .icon { width: 14px; height: 14px; }
+.reset-button:hover { background: #f0f5ef; border-color: #b7cbb9; }
+.scenarios { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; margin-top: 19px; }
+.scenarios-label { font-size: 11px; color: #5f7060; margin-right: 7px; }
+.scenario { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; padding: 8px 11px; border: 1px solid #dce3d8; background: #fff9; border-radius: 6px; color: #596d60; }
+.scenario:hover { background: #eaf0e7; }
+.scenario[aria-pressed="true"] { background: #e9f0e5; border-color: #b3c6aa; color: #355b3e; }
+.scenario-symbol { font-size: 13px; font-weight: 500; border-radius: 50%; width: 17px; height: 17px; display: inline-grid; place-items: center; }
+.positive { color: #9c6648; background: #f2e5d7; }
+.negative { color: #48799b; background: #e3ebf3; }
+.scenario .icon { width: 15px; height: 15px; }
+.insight { display: grid; grid-template-columns: 1.2fr 1fr; align-items: center; gap: 30px; padding: 25px 28px; margin-top: 25px; background: #eaf0e5; border: 1px solid #dbe4d3; border-radius: 9px; }
+.insight-copy { display: flex; align-items: flex-start; gap: 15px; }
+.insight-icon { border-radius: 50%; background: #dce7d4; color: #5a7c52; display: grid; place-items: center; width: 37px; height: 37px; flex-shrink: 0; }
+.insight-copy h2 { font-size: 15px; font-weight: 600; color: #3a573b; margin: 0 0 7px; }
+.insight-copy p { font-size: 12px; line-height: 1.85; color: #5d6f58; max-width: 540px; }
+.main-equation { text-align: center; color: #3c604a; padding-left: 20px; border-left: 1px solid #cfdbc8; min-width: 0; }
+.main-equation > .katex { font-size: 1.22em; }
+.main-equation > span:last-child { display: block; font-size: 10px; letter-spacing: 0.2px; margin-top: 14px; color: #596d52; }
+.physics-section { padding-top: 52px; padding-bottom: 32px; }
+.physics-section > h2 { margin-top: 12px; font-size: 30px; font-weight: 500; letter-spacing: -1px; }
+.physics-subtitle { color: var(--muted); font-size: 13px; line-height: 1.8; margin-top: 9px; }
+.lesson-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; margin-top: 25px; }
+.lesson-card { position: relative; border-top: 1px solid #d3dfd3; padding: 24px 20px 15px 0; }
+.lesson-number { font-family: "Consolas", monospace; font-size: 11px; color: #5f7459; }
+.lesson-card h3 { font-size: 16px; font-weight: 550; margin-top: 12px; color: #354e3e; }
+.lesson-formula { color: #537457; margin-top: 23px; margin-bottom: 19px; min-height: 34px; display: flex; align-items: center; }
+.lesson-formula .katex { font-size: 1.12em; }
+.lesson-card p { color: #6c786a; font-size: 12px; line-height: 1.9; max-width: 350px; }
+.try-it { display: flex; align-items: center; gap: 16px; padding: 17px 20px; background: #eef1e9; border-radius: 6px; margin-top: 20px; }
+.try-it > span { color: #566f4b; font-size: 9px; letter-spacing: 1.2px; font-weight: 650; flex-shrink: 0; }
+.try-it p { font-size: 12px; color: #4e6348; }
+.try-it .icon { margin-left: auto; width: 17px; color: #7a8f70; }
+.model-notes { color: #5e6f59; font-size: 11px; margin-top: 25px; line-height: 1.8; }
+.model-notes summary { cursor: pointer; width: fit-content; color: #5d7157; }
+.model-notes summary .icon { width: 14px; height: 14px; margin: 0 5px; }
+.model-notes p { margin-top: 12px; max-width: 920px; }
+.site-footer { max-width: 1304px; margin: 0 auto; padding: 22px 0 30px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; gap: 20px; color: #5f715f; font-size: 10px; }
+.site-footer > span:first-child { display: flex; align-items: center; gap: 8px; }
+.site-footer .icon { width: 18px; height: 18px; }
+.load-error { padding: 100px 20px; text-align: center; }
+.button { margin-top: 20px; padding: 10px 16px; background: #287a6a; border: 0; border-radius: 6px; color: white; }
+
+@media (min-width: 1500px) { .scene-wrap { min-height: 560px; } }
+@media (max-width: 1100px) {
+ .site-header { padding: 0 28px; }
+ main { padding: 0 28px; }
+ .lab { grid-template-columns: minmax(0, 1fr) 285px; }
+ .controls { padding: 24px 20px 17px; }
+ .scene-heading { left: 20px; right: 20px; }
+ .scene-heading h2 { font-size: 13px; }
+ .scene-status { left: 20px; }
+ .scene-bottom { left: 20px; right: 20px; }
+ .readings { padding: 21px 18px; gap: 12px; }
+ .reading + .reading { padding-left: 12px; }
+ .reading > span { font-size: 10px; }
+ .insight { gap: 20px; padding: 22px; }
+ .main-equation > .katex { font-size: 1.03em; }
+ .site-footer { margin-left: 28px; margin-right: 28px; }
+}
@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; }
+ .brand small, .brand-divider, .topic-tag { display: none; }
+ .site-header { height: 74px; }
+ .intro { padding-top: 32px; }
+ .lab { grid-template-columns: minmax(0, 1fr); }
+ .scene-wrap { height: 480px; flex: none; }
+ .controls { border-left: 0; border-top: 1px solid var(--border); display: grid; grid-template-columns: 1fr 1fr; gap: 0 30px; padding: 24px; }
+ .controls-intro { grid-column: 1 / -1; }
+ .controls-intro p { margin-bottom: 4px; }
+ .controls .control-divider { grid-column: 1 / -1; margin: 20px 0; }
+ .controls .control-note { grid-column: 1 / -1; }
+ .controls .shape-control { align-self: center; }
+ .controls #size { margin-top: 7px; }
+ .controls .shape-control + .range-control { margin-top: 0; }
+ .reset-button { align-self: center; height: fit-content; }
+ .reading strong { font-size: 20px; }
+ .reading > span { font-size: 11px; }
+ .insight { grid-template-columns: 1fr; }
+ .main-equation { border-left: 0; border-top: 1px solid #cfdbc8; padding: 20px 0 0; }
+ .main-equation > .katex { font-size: 1.2em; }
+ .lesson-grid { gap: 18px; }
+ .lesson-card h3 { font-size: 14px; }
}
-@media (max-width: 600px) {
- .app-header { padding: 16px 20px; }
- .collections { width: 100%; margin: 0; flex-wrap: wrap; }
- .collections a { flex: 1; text-align: center; }
- .language-picker { flex: 1 0 100%; margin: 4px 0 0; padding: 8px 4px 4px; border-left: 0; border-top: 1px solid var(--border); }
- .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; }
+@media (max-width: 560px) {
+ .site-header { padding: 0 19px; }
+ .brand > span:first-of-type { font-size: 24px; }
+ .brand > .icon { width: 29px; height: 29px; }
+ .site-header nav { gap: 15px; }
+ .nav-link { font-size: 11px; }
+ .model-number { display: none; }
+ main { padding: 0 16px; }
+ .intro { padding: 28px 2px 25px; }
+ .eyebrow { font-size: 8px; letter-spacing: 1.5px; }
+ h1 { font-size: 32px; letter-spacing: -1.3px; max-width: 300px; }
+ .subtitle { font-size: 12px; }
+ .scene-wrap { height: 420px; min-height: 420px; }
+ .scene-heading { top: 20px; left: 18px; right: 15px; gap: 8px; }
+ .scene-heading h2 { font-size: 12px; max-width: 180px; line-height: 1.6; }
+ .scene-eyebrow { font-size: 7px; letter-spacing: 1px; }
+ .camera-controls button { width: 29px; height: 29px; }
+ .camera-controls .icon { width: 14px; }
+ .scene-status { left: 18px; top: 83px; font-size: 9px; }
+ .scene-bottom { left: 18px; right: 18px; bottom: 16px; }
+ .legend { gap: 9px; font-size: 9px; }
+ .scene-bottom > p { font-size: 9px; }
+ .readings { gap: 8px; padding: 18px 13px; grid-template-columns: 0.85fr 0.85fr 1.3fr; }
+ .reading { gap: 7px; }
+ .reading + .reading { padding-left: 8px; }
+ .reading > span { font-size: 9px; display: block; line-height: 1.6; }
+ .reading > span i { display: none; }
+ .reading strong { font-size: 13px; letter-spacing: -0.7px; }
+ .controls { display: block; padding: 23px; }
+ .controls .shape-control + .range-control { margin-top: 20px; }
+ .controls .control-note { font-size: 11px; }
+ .control-heading { font-size: 13px; }
+ .layer-controls label { padding: 9px 0; font-size: 12px; }
+ .reset-button { margin-top: 20px; padding: 12px; }
+ .scenarios { gap: 7px; }
+ .scenarios-label { flex-basis: 100%; margin: 3px 0; }
+ .scenario { padding: 8px; font-size: 10px; gap: 5px; }
+ .insight { padding: 20px 18px; }
+ .insight-copy { gap: 10px; }
+ .insight-icon { width: 28px; height: 28px; }
+ .insight-icon .icon { width: 18px; }
+ .insight-copy h2 { font-size: 14px; }
+ .main-equation > .katex { font-size: 1.05em; }
+ .physics-section { padding: 37px 4px 25px; }
+ .physics-section > h2 { font-size: 27px; }
+ .physics-subtitle { font-size: 12px; }
+ .lesson-grid { grid-template-columns: 1fr; gap: 8px; }
+ .lesson-card { padding: 20px 3px 15px; }
+ .lesson-card h3 { font-size: 17px; margin-top: 9px; }
+ .lesson-card p { max-width: none; }
+ .lesson-formula { margin: 19px 0 14px; }
+ .try-it { gap: 10px; padding: 14px; }
+ .try-it p { font-size: 11px; }
+ .site-footer { flex-direction: column; gap: 12px; margin: 0 20px; padding: 20px 0 25px; }
}
-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; } }
-@media (max-width: 1100px) { .app-header { padding: 16px 24px; } .subjects { padding: 0 12px; } [role='tab'] { padding: 18px 12px; } main { grid-template-columns: 280px minmax(0, 1fr); } .controls-panel { padding: 24px 20px; } .learning-panel { grid-template-columns: 1fr; gap: 24px; padding: 25px; } .challenge { border-left: 0; border-top: 1px solid var(--border); padding: 22px 0 0; } }
-@media (max-width: 760px) {
- .app-header { padding: 16px; gap: 13px; min-height: 88px; }
- .brand { font-size: 15px; padding: 12px 8px; }
- h1 { font-size: 15px; }
- .header-title p { font-size: 10px; }
- .status { display: none; }
- main { display: flex; flex-direction: column; }
- .controls-panel { border-right: 0; border-bottom: 1px solid var(--border); padding: 24px; }
- h2 { font-size: 25px; }
- #parameters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
- .parameter { min-width: 0; margin: 10px 0 15px; }
- .parameter-label { flex-wrap: wrap; gap: 6px; }
- .measurements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 18px; }
- .measurement { min-width: 0; }
- .measurement strong { font-size: 21px; overflow-wrap: anywhere; }
- .measurement > div { flex-wrap: wrap; gap: 4px; }
- .measurement:nth-child(2) { border-top: 0; }
- #viewport { height: 520px; }
- .scene-toolbar { top: 18px; left: 16px; right: 16px; }
- .scene-toolbar button { font-size: 10px; padding: 7px; }
- .legend { left: 16px; right: 16px; }
- .scene-footer { padding: 20px 16px 12px; }
- .learning-panel { padding: 24px; }
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *, *::before, *::after { transition: none !important; }
}
diff --git a/src/test4.ts b/src/test4.ts
deleted file mode 100644
index c031fa5..0000000
--- a/src/test4.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { format, formatDisplay, number, type Choice, type Experiment, type Measurement, type Parameters, type Slider, type TestId } from './experiments';
-import { word } from './i18n';
-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: formatDisplay(Math.abs(value) < 1e-10 ? 0 : value), unit: word(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/src/ui/icons.ts b/src/ui/icons.ts
new file mode 100644
index 0000000..58c9375
--- /dev/null
+++ b/src/ui/icons.ts
@@ -0,0 +1,12 @@
+const paths = {
+ orbit: '<ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(-40 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(40 12 12)"/><circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"/>',
+ reset: '<path d="M3 10a9 9 0 1 1 2 8M3 4v6h6"/>',
+ arrow: '<path d="M5 12h14m-6-6 6 6-6 6"/>',
+ sphere: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>',
+ cube: '<path d="m12 2 9 5v10l-9 5-9-5V7Zm0 10 9-5M12 12 3 7m9 5v10"/>',
+ help: '<circle cx="12" cy="12" r="9"/><path d="M9 9a3 3 0 0 1 6 0c0 2-3 2-3 4m0 3h.01"/>',
+} as const;
+
+export function icon(name: keyof typeof paths, className = '') {
+ return `<svg class="icon ${className}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name]}</svg>`;
+}
diff --git a/tests/lab.spec.ts b/tests/lab.spec.ts
deleted file mode 100644
index e4bac8e..0000000
--- a/tests/lab.spec.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { test, expect, type Page } from '@playwright/test';
-
-async function setRange(page: Page, label: string, value: number) {
- await page.getByRole('slider', { name: label, exact: true }).fill(String(value));
-}
-async function read(page: Page, label: string) {
- return Number((await page.getByLabel(label, { exact: true }).innerText()).replaceAll(',', ''));
-}
-
-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('/#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);
- for (let i = 0; i < subjects.length; i++) {
- await tabs.nth(i).click();
- await expect(page.getByRole('heading', { name: subjects[i], exact: true })).toBeVisible();
- await expect(page.locator('canvas')).toBeVisible();
- await expect(page.locator('#measurements')).not.toContainText('Undefined');
- await expect(page.locator('#measurements .measurement')).toHaveCount(i === 5 ? 5 : i >= 4 ? 4 : 3);
- await expect(page.locator('#challenge')).not.toBeEmpty();
- // A rendered frame must contain geometry beyond the solid background.
- const colours = await page.locator('canvas').evaluate(canvas => new Promise<number>(resolve => 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 unique = new Set<number>();
- for (let k = 0; k < data.length; k += 4) unique.add((data[k] << 16) | (data[k + 1] << 8) | data[k + 2]);
- resolve(unique.size);
- })));
- expect(colours).toBeGreaterThan(12);
- }
- expect(errors).toEqual([]);
-});
-
-test('charge separation changes force by inverse square and reset restores defaults', async ({ page }) => {
- await page.goto('/#field');
- const force = await read(page, '|Force on right charge|');
- await setRange(page, 'Charge separation', 6);
- expect(await read(page, '|Force on right charge|')).toBeCloseTo(force / 4, 1);
- await page.getByRole('button', { name: 'Reset experiment' }).click();
- await expect(page.getByRole('slider', { name: 'Charge separation', exact: true })).toHaveValue('3');
- await page.getByLabel('Charge signs', { exact: true }).selectOption('like');
- await expect(page.getByLabel('Interaction', { exact: true })).toHaveText('Repulsive');
-});
-
-test('Gaussian flux distinguishes enclosed, outside and surface charges', async ({ page }) => {
- await page.goto('/#flux');
- expect(await read(page, 'Net electric flux')).toBeGreaterThan(200);
- await setRange(page, 'Charge x position', 3);
- expect(await read(page, 'Net electric flux')).toBe(0);
- await setRange(page, 'Charge x position', 2);
- await expect(page.getByLabel('Net electric flux', { exact: true })).toHaveText('Undefined');
- await expect(page.getByLabel('Source location', { exact: true })).toHaveText('On surface');
-});
-
-test('capacitance scales with area and dielectric conditions preserve the correct quantity', async ({ page }) => {
- await page.goto('/#capacitance');
- const initial = await read(page, 'Capacitance');
- await setRange(page, 'Plate area', 200);
- expect(await read(page, 'Capacitance')).toBeCloseTo(2 * initial, 1);
- await page.getByRole('tab', { name: /Dielectrics/ }).click();
- const q = await read(page, 'Free charge |Q|');
- await setRange(page, 'Relative permittivity κ', 8);
- expect(await read(page, 'Free charge |Q|')).toBeCloseTo(2 * q, 1);
- expect(await read(page, 'Plate voltage')).toBe(10);
- await page.getByLabel('Electrical condition', { exact: true }).selectOption('isolated');
- const isolatedCharge = await read(page, 'Free charge |Q|');
- expect(await read(page, 'Plate voltage')).toBe(1.25);
- await setRange(page, 'Relative permittivity κ', 4);
- expect(await read(page, 'Free charge |Q|')).toBe(isolatedCharge);
- expect(await read(page, 'Plate voltage')).toBe(2.5);
-});
-
-test('reversed voltage reverses drift and animation can be paused', async ({ page }) => {
- await page.goto('/#current');
- expect(await read(page, 'Conventional current')).toBeGreaterThan(0);
- expect(await read(page, 'Electron drift, x')).toBeLessThan(0);
- await setRange(page, 'Voltage across wire', -0.025);
- expect(await read(page, 'Conventional current')).toBeLessThan(0);
- expect(await read(page, 'Electron drift, x')).toBeGreaterThan(0);
- await page.getByRole('button', { name: 'Pause animation' }).click();
- await expect(page.getByRole('button', { name: 'Resume animation' })).toBeVisible();
- await setRange(page, 'Voltage across wire', 0);
- expect(await read(page, 'Conventional current')).toBe(0);
-});
-
-test('doping selects majority carriers without modifying intrinsic silicon', async ({ page }) => {
- await page.goto('/#semiconductor');
- const doping = page.getByLabel('Silicon sample at 300 K', { exact: true });
- const concentration = page.getByRole('slider', { name: /Dopant density/ });
- await expect(concentration).toBeDisabled();
- expect(await read(page, 'Electron density n')).toBe(await read(page, 'Hole density p'));
- await doping.selectOption('n');
- await expect(concentration).toBeEnabled();
- expect(await read(page, 'Electron density n')).toBeGreaterThan(await read(page, 'Hole density p'));
- expect(await read(page, 'Fermi shift EF − Ei')).toBeGreaterThan(0);
- await doping.selectOption('p');
- expect(await read(page, 'Hole density p')).toBeGreaterThan(await read(page, 'Electron density n'));
- expect(await read(page, 'Fermi shift EF − Ei')).toBeLessThan(0);
-});
-
-test('lesson navigation preserves parameters, supports history, and fits a mobile viewport', async ({ page }) => {
- await page.goto('/#energy');
- await setRange(page, 'Moving charge', 3);
- await page.getByRole('tab', { name: /Potential & gradient/ }).click();
- await page.goBack();
- await expect(page.getByRole('slider', { name: 'Moving charge', exact: true })).toHaveValue('3');
- await page.setViewportSize({ width: 390, height: 844 });
- await expect(page.locator('canvas')).toBeVisible();
- expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
-});
-
-test('WebGL failure leaves lessons and numeric experiments usable', 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('/#capacitance');
- await expect(page.getByRole('alert')).toContainText('WebGL');
- expect(await read(page, 'Capacitance')).toBeGreaterThan(80);
- await setRange(page, 'Plate area', 200);
- expect(await read(page, 'Capacitance')).toBeGreaterThan(170);
-});
-
-test('energy sign, distance and field work agree', async ({ page }) => {
- await page.goto('/#energy');
- expect(await read(page, 'Interaction energy')).toBeCloseTo(-26.96, 2);
- expect(await read(page, 'Work by field, from 6 m')).toBeCloseTo(17.98, 2);
- await setRange(page, 'Distance from +3 nC source', 4);
- expect(await read(page, 'Interaction energy')).toBeCloseTo(-13.48, 2);
- await setRange(page, 'Moving charge', 2);
- expect(await read(page, 'Interaction energy')).toBeCloseTo(13.48, 2);
- expect(await read(page, 'Work by field, from 6 m')).toBeLessThan(0);
-});
-
-test('zero dipole potential can coexist with nonzero field', async ({ page }) => {
- await page.goto('/#potential');
- expect(await read(page, 'Potential at probe')).toBe(0);
- expect(await read(page, 'Ex at probe')).toBeGreaterThan(0);
- await setRange(page, 'Probe x position (z = 1 m)', 2);
- expect(await read(page, 'Potential at probe')).toBeLessThan(0);
- await page.getByLabel('Charge signs', { exact: true }).selectOption('like');
- expect(await read(page, 'Potential at probe')).toBeGreaterThan(0);
-});
diff --git a/tests/language.spec.ts b/tests/language.spec.ts
deleted file mode 100644
index 5ff86ca..0000000
--- a/tests/language.spec.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { expect, test } from '@playwright/test';
-
-test('the language picker switches the whole lesson between English and Danish', async ({ page }) => {
- await page.goto('/#field');
- await expect(page.locator('html')).toHaveAttribute('lang', 'en');
- await page.locator('#language').selectOption('da');
- await expect(page.locator('html')).toHaveAttribute('lang', 'da');
- await expect(page).toHaveTitle('Test 4 i 3D · Elektrofysiklaboratoriet');
- await expect(page.getByRole('heading', { name: 'Elektrisk ladning, kraft og felt', exact: true })).toBeVisible();
- await expect(page.getByRole('slider', { name: 'Ladningsafstand', exact: true })).toBeVisible();
- await expect(page.getByLabel('Vekselvirkning', { exact: true })).toHaveText('Tiltrækkende');
- await page.getByLabel('Ladningstegn', { exact: true }).selectOption('like');
- await expect(page.getByLabel('Vekselvirkning', { exact: true })).toHaveText('Frastødende');
- await page.getByRole('link', { name: 'Test 4 i 3D', exact: true }).click();
- await expect(page.getByRole('heading', { name: 'Arbejde langs en bane', exact: true })).toBeVisible();
- await expect(page.getByRole('button', { name: 'Vis forklaring' })).toBeVisible();
- await expect(page.locator('#observation')).toContainText('arbejde');
- await page.reload();
- await expect(page.getByRole('heading', { name: 'Arbejde langs en bane', exact: true })).toBeVisible();
- await page.locator('#language').selectOption('en');
- await expect(page.locator('html')).toHaveAttribute('lang', 'en');
- await expect(page.getByRole('heading', { name: 'Work along a path', exact: true })).toBeVisible();
- await expect(page.getByRole('button', { name: 'Reveal explanation' })).toBeVisible();
-});
diff --git a/tests/math.spec.ts b/tests/math.spec.ts
deleted file mode 100644
index 71b586d..0000000
--- a/tests/math.spec.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-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
deleted file mode 100644
index 3e6a3b5..0000000
--- a/tests/test4.spec.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-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/tsconfig.json b/tsconfig.json
index 8c204c5..3973352 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,9 +5,12 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
"noEmit": true,
"skipLibCheck": true,
+ "verbatimModuleSyntax": true,
"types": ["vite/client"]
},
- "include": ["src"]
+ "include": ["src", "vite.config.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index 935dce8..08cb8e5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,16 @@
-import { defineConfig } from 'vitest/config';
+import { defineConfig } from 'vite';
export default defineConfig({
- // Keep original question/answer links navigable in production.
- build: { assetsInlineLimit: 0 },
- test: { include: ['src/**/*.test.ts'] },
+ build: {
+ rolldownOptions: {
+ output: {
+ codeSplitting: {
+ groups: [
+ { name: 'three', test: /node_modules\/three\// },
+ { name: 'math', test: /node_modules\/katex\// },
+ ],
+ },
+ },
+ },
+ },
});