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/view.ts | |
| 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/view.ts | 218 |
1 files changed, 218 insertions, 0 deletions
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(); + }, + }; +} |