diff options
| author | victor | 2026-10-24 15:57:01 +0200 |
|---|---|---|
| committer | victor | 2026-10-24 15:57:01 +0200 |
| commit | 4533910bce394e7a0ee6debfc05ca1221ee51899 (patch) | |
| tree | 73aaa5b2c7a48dbf226e6b81d499ba1a8ecc33a1 /src/models/electric-flux | |
| parent | Tilføj dansk (diff) | |
| download | 3D-Electrophysics-ny-start.tar.gz 3D-Electrophysics-ny-start.zip | |
Astra!ny-start
Diffstat (limited to '')
| -rw-r--r-- | src/models/electric-flux/physics.ts | 53 | ||||
| -rw-r--r-- | src/models/electric-flux/scene.ts | 277 | ||||
| -rw-r--r-- | src/models/electric-flux/state.ts | 36 | ||||
| -rw-r--r-- | src/models/electric-flux/view.ts | 218 |
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(); + }, + }; +} |