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(); }, }; }