aboutsummaryrefslogtreecommitdiff
path: root/src/models/electric-flux/view.ts
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/view.ts
parentTilføj dansk (diff)
download3D-Electrophysics-ny-start.tar.gz
3D-Electrophysics-ny-start.zip
Astra!ny-start
Diffstat (limited to 'src/models/electric-flux/view.ts')
-rw-r--r--src/models/electric-flux/view.ts218
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();
+ },
+ };
+}