aboutsummaryrefslogtreecommitdiff
path: root/src/models/electric-flux
diff options
context:
space:
mode:
authorvictor2026-10-24 15:57:01 +0200
committervictor2026-10-24 15:57:01 +0200
commit4533910bce394e7a0ee6debfc05ca1221ee51899 (patch)
tree73aaa5b2c7a48dbf226e6b81d499ba1a8ecc33a1 /src/models/electric-flux
parentTilføj dansk (diff)
download3D-Electrophysics-4533910bce394e7a0ee6debfc05ca1221ee51899.tar.gz
3D-Electrophysics-4533910bce394e7a0ee6debfc05ca1221ee51899.zip
Astra!ny-start
Diffstat (limited to 'src/models/electric-flux')
-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
4 files changed, 584 insertions, 0 deletions
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();
+ },
+ };
+}