From 4533910bce394e7a0ee6debfc05ca1221ee51899 Mon Sep 17 00:00:00 2001 From: victor Date: Sat, 24 Oct 2026 15:57:01 +0200 Subject: Astra! --- src/models/electric-flux/view.ts | 218 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/models/electric-flux/view.ts (limited to 'src/models/electric-flux/view.ts') 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 | undefined; + + const range = (id: string, label: MessageKey, min: number, max: number, step: number, value: number, symbol: string) => ` +
+
+ + +
`; + + const lesson = (key: 'one' | 'two' | 'three', formula: string) => ` +
${t(`flux.lesson.${key}.number`)} +

${t(`flux.lesson.${key}.title`)}

${math(formula)}
+

${t(`flux.lesson.${key}.body`)}

`; + + host.innerHTML = ` +
+

${t('flux.eyebrow')}

+

${t('flux.title')}

${t('flux.subtitle')}

+ ${icon('orbit')}${t('flux.topic')} +
+
+
+
+

${t('flux.live')}

${t('flux.sceneTitle')}

+
+ + + +
+
+
+
+
${t('flux.legend.surface')}

${icon('orbit')}${t('flux.sceneHint')}

+ ${t('flux.keyboardHint')} +
+
+
${t('flux.enclosed')} Qenc
+
${t('flux.area')} A
+
${t('flux.netFlux')} ΦE
+
+
+ +
+
${t('flux.presets')} + + + +
+
${icon('orbit')}

${math(equations.gauss)}${t('flux.exact')}
+
+

${t('flux.physicsEyebrow')}

${t('flux.physicsTitle')}

${t('flux.physicsSubtitle')}

+
${lesson('one', equations.patch)}${lesson('two', equations.surface)}${lesson('three', equations.charge)}
+
${t('flux.try')}

${t('flux.tryText')}

${icon('arrow')}
+
${icon('help')}${t('flux.assumptions.title')}

${t('flux.assumptions.body')}

${t('flux.constant', { value: number(VACUUM_PERMITTIVITY, { notation: 'scientific', maximumFractionDigits: 10 }) })}

${t('flux.reference')}

+
+

`; + + const element = (selector: string) => host.querySelector(selector)!; + const sceneHost = element('#scene'); + const marker = element('#charge-marker'); + let scene: ReturnType | undefined; + + function showGraphicsError(key: MessageKey) { + marker.hidden = true; + let message = sceneHost.querySelector('.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('[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('#charge'); + const positionInput = element('#position'); + const sizeInput = element('#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(`[name="surface-shape"][value="${state.shape}"]`).checked = true; + element('#field-lines').checked = state.fieldLines; + element('#surface-layer').checked = state.surface; + element('#normals').checked = state.normals; + host.querySelectorAll('[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('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(); + }, + }; +} -- cgit v1.2.3