diff options
| author | LalleSX | 2026-10-24 04:35:56 +0200 |
|---|---|---|
| committer | LalleSX | 2026-10-24 04:35:56 +0200 |
| commit | c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5 (patch) | |
| tree | 8fcbbfffb6a34af73697a703f77e6b82f191b91f /src | |
| download | 3D-Electrophysics-c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5.tar.gz 3D-Electrophysics-c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5.zip | |
init
Diffstat (limited to 'src')
| -rw-r--r-- | src/experiments.ts | 182 | ||||
| -rw-r--r-- | src/main.ts | 137 | ||||
| -rw-r--r-- | src/physics/electricField.test.ts | 21 | ||||
| -rw-r--r-- | src/physics/electricField.ts | 22 | ||||
| -rw-r--r-- | src/physics/models.test.ts | 108 | ||||
| -rw-r--r-- | src/physics/models.ts | 112 | ||||
| -rw-r--r-- | src/scene/experiments.ts | 245 | ||||
| -rw-r--r-- | src/scene/lab.ts | 97 | ||||
| -rw-r--r-- | src/scene/primitives.ts | 90 | ||||
| -rw-r--r-- | src/style.css | 104 |
10 files changed, 1118 insertions, 0 deletions
diff --git a/src/experiments.ts b/src/experiments.ts new file mode 100644 index 0000000..df6835e --- /dev/null +++ b/src/experiments.ts @@ -0,0 +1,182 @@ +import { electricField, type Charge } from './physics/electricField'; +import { capacitor, conductor, forceOn, gaussianFlux, pairEnergy, potential, semiconductor } from './physics/models'; + +export type ExperimentId = 'field' | 'flux' | 'energy' | 'potential' | 'capacitance' | 'dielectric' | 'current' | 'semiconductor'; +export type Parameters = Record<string, number | string>; +export interface Slider { kind: 'range'; id: string; label: string; min: number; max: number; step: number; value: number; unit: string } +export interface Choice { kind: 'select'; id: string; label: string; value: string; options: { value: string; label: string }[] } +export interface Measurement { label: string; value: string; unit: string } +export interface Experiment { + id: ExperimentId; + title: string; + shortTitle: string; + description: string; + controls: (Slider | Choice)[]; + equations: string[]; + explanation: string; + challenge: string; + limits: string; + sceneNote: string; + legend: [string, string][]; + source: { title: string; url: string }; + measure: (p: Parameters) => Measurement[]; +} +export const number = (p: Parameters, key: string): number => Number(p[key]); +const slider = (id: string, label: string, min: number, max: number, step: number, value: number, unit: string): Slider => ({ kind: 'range', id, label, min, max, step, value, unit }); +const choice = (id: string, label: string, value: string, options: [string, string][]): Choice => ({ kind: 'select', id, label, value, options: options.map(([value, label]) => ({ value, label })) }); +export function format(value: number, digits = 2): string { + if (!Number.isFinite(value)) return 'Undefined'; + if (value === 0) return '0'; + if (Math.abs(value) < 0.01 || Math.abs(value) >= 1e5) return value.toExponential(2); + return value.toLocaleString('en', { maximumFractionDigits: digits }); +} +const metric = (label: string, value: number, unit: string): Measurement => ({ label, value: format(value), unit }); +const textbook = (page: string) => ({ title: 'OpenStax · University Physics', url: `https://openstax.org/books/university-physics-volume-2/pages/${page}` }); +export function chargePair(p: Parameters): Charge[] { + const half = number(p, 'separation') / 2; + return [ + { x: -half, y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 }, + { x: half, y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 * (p.configuration === 'like' ? 1 : -1) }, + ]; +} +export function plateModel(p: Parameters) { + return capacitor({ area: number(p, 'area') * 1e-4, gap: number(p, 'gap') * 1e-3, voltage: number(p, 'voltage'), relativePermittivity: p.permittivity === undefined ? 1 : number(p, 'permittivity'), isolated: p.connection === 'isolated' }); +} +export function wireModel(p: Parameters) { + return conductor({ voltage: number(p, 'voltage'), area: number(p, 'area') * 1e-6, length: number(p, 'length'), resistivity: 1.68e-8, carrierDensity: 8.5e28 }); +} +export function carrierModel(p: Parameters) { + return semiconductor({ intrinsicDensity: 1e16, electronMobility: 0.135, holeMobility: 0.048, netDoping: p.doping === 'intrinsic' ? 0 : 10 ** number(p, 'concentration') * (p.doping === 'p' ? -1 : 1) }); +} +const pairControls = [slider('charge', 'Source charge magnitude', 0.2, 5, 0.1, 2, 'nC'), slider('separation', 'Charge separation', 1, 6, 0.1, 3, 'm'), choice('configuration', 'Charge signs', 'opposite', [['opposite', 'Opposite (+ / −)'], ['like', 'Like (+ / +)']])]; +const plateControls = [slider('area', 'Plate area', 25, 225, 5, 100, 'cm²'), slider('gap', 'Plate separation', 0.5, 5, 0.1, 1, 'mm'), slider('voltage', 'Charging voltage', 1, 20, 1, 10, 'V')]; + +export const experiments: Experiment[] = [ + { + id: 'field', title: 'Electric charge, force and field', shortTitle: 'Charge & field', + description: 'See how signed charges create a vector field and exert forces on one another.', + controls: pairControls, + equations: ['E = k Σ qᵢ r̂ᵢ / rᵢ²', 'F = qE · |F₁₂| = k|q₁q₂| / r²'], + explanation: 'A positive test charge follows the electric field; a negative charge feels a force in the opposite direction. The large gold arrow shows the force on the right-hand source due only to the left-hand source.', + challenge: 'Double the separation. What happens to the force? Change to like charges and watch attraction become repulsion.', + limits: 'Vacuum point charges. The x–z plane is sampled; points within 0.18 m of a source are excluded. Arrow lengths are compressed, and the force arrow is normalized.', + sceneNote: 'Grid: 1 m major squares · small arrows: E · gold arrow: force on right charge', + legend: [['#ff967b', 'Positive charge'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Electric field'], ['#f5d58b', 'Force']], + source: textbook('5-3-coulombs-law'), + measure: p => { + const charges = chargePair(p); + const e = electricField({ x: 0, y: 0, z: 0 }, charges); + const f = forceOn(charges[1], [charges[0]]); + return [metric('|E| at origin', Math.hypot(e.x, e.y, e.z), 'N/C'), metric('|Force on right charge|', Math.hypot(f.x, f.y, f.z) * 1e9, 'nN'), { label: 'Interaction', value: p.configuration === 'like' ? 'Repulsive' : 'Attractive', unit: '' }]; + }, + }, + { + id: 'flux', title: 'Electric flux and charge', shortTitle: 'Flux & Gauss’s law', + description: 'Move a charge through a Gaussian surface and compare local field with net flux.', + controls: [slider('charge', 'Signed source charge', -5, 5, 0.1, 2, 'nC'), slider('radius', 'Gaussian sphere radius', 1, 3, 0.1, 2, 'm'), slider('position', 'Charge x position', -4, 4, 0.1, 0, 'm')], + equations: ['ΦE = ∮ E · n̂ dA', 'ΦE = Qenclosed / ε₀'], + explanation: 'The translucent sphere is an imaginary closed surface, not a conductor. Arrows show E at surface samples: mint means outward flux and coral means inward flux. An outside charge still creates a field, but its total flux through the closed surface is zero.', + challenge: 'Keep the charge inside and enlarge the sphere. Then move the charge outside. Can there be a nonzero field with zero net flux?', + limits: 'The flux readout uses exact Gauss’s law; arrows are discrete field samples, not a numerical integral. A point charge exactly on the surface makes the flux undefined.', + sceneNote: 'Imaginary Gaussian surface · mint: E·n > 0 · coral: E·n < 0', + legend: [['#91e2cb', 'Outward flux'], ['#ff967b', 'Inward flux'], ['#73d7ff', 'Gaussian surface']], + source: textbook('6-2-explaining-gausss-law'), + measure: p => { + const q = { x: number(p, 'position'), y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 }; + const flux = gaussianFlux([q], number(p, 'radius')); + return [metric('Net electric flux', flux, 'N·m²/C'), metric('Enclosed charge', Number.isNaN(flux) ? NaN : Math.abs(q.x) < number(p, 'radius') ? q.coulombs * 1e9 : 0, 'nC'), { label: 'Source location', value: Number.isNaN(flux) ? 'On surface' : Math.abs(q.x) < number(p, 'radius') ? 'Inside' : 'Outside', unit: '' }]; + }, + }, + { + id: 'energy', title: 'Electric potential energy', shortTitle: 'Potential energy', + description: 'Separate two charges and connect interaction energy with the work done by the field.', + controls: [slider('testCharge', 'Moving charge', -3, 3, 0.1, -2, 'nC'), slider('distance', 'Distance from +3 nC source', 0.75, 6, 0.05, 2, 'm')], + equations: ['U(r) = kQq / r', 'Wfield, 6m→r = U(6 m) − U(r)'], + explanation: 'The source stays on the left. The mint curve plots the interaction energy against separation; the gold marker follows the current setting. Opposite charges have negative energy relative to infinite separation, so pulling them apart requires external work.', + challenge: 'Move an opposite charge closer. Does U rise or fall? Now make the moving charge positive and compare the sign of the field’s work.', + limits: 'Two prescribed point charges, with U = 0 at infinity. The curve uses a fixed display scale of 20 nJ per vertical scene unit. The controls set positions; they do not integrate motion.', + sceneNote: 'Curve: U versus separation · gold point: current U · horizontal zero-energy axis', + legend: [['#ff967b', 'Fixed +3 nC'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Energy curve'], ['#f5d58b', 'Current energy']], + source: textbook('7-1-electric-potential-energy'), + measure: p => { + const q = number(p, 'testCharge') * 1e-9; + const r = number(p, 'distance'); + const u = pairEnergy(3e-9, q, r); + return [metric('Interaction energy', u * 1e9, 'nJ'), metric('Work by field, from 6 m', (pairEnergy(3e-9, q, 6) - u) * 1e9, 'nJ'), metric('|Force on moving charge|', Math.abs(u / r) * 1e9, 'nN')]; + }, + }, + { + id: 'potential', title: 'Electric potential and gradient', shortTitle: 'Potential & gradient', + description: 'Read electric potential as a landscape and connect its downhill direction to the field.', + controls: [...pairControls, slider('probe', 'Probe x position (z = 1 m)', -4, 4, 0.1, 0, 'm')], + equations: ['V = k Σ qᵢ / rᵢ', 'E = −∇V · Fx = −q ∂V/∂x'], + explanation: 'Warm peaks are positive potential; blue valleys are negative. Mint arrows show the electric field projected in the x–z plane. The gold probe samples V and the x component of E. Field arrows point toward decreasing potential.', + challenge: 'Use opposite charges: the midpoint potential can be zero while the field is nonzero. Compare V and Ex as you move the probe.', + limits: 'Height = 2 tanh(V / 15 V), a compressed potential scale. Holes exclude source singularities. Arrow lengths are compressed. Probe is always at z = 1 m, away from sources; V = 0 at infinity.', + sceneNote: 'Height and colour encode potential · gold probe at z = 1 m · arrows: −∇V', + legend: [['#ff967b', 'Positive potential'], ['#73d7ff', 'Negative potential'], ['#f5d58b', 'Probe']], + source: textbook('7-4-determining-field-from-potential'), + measure: p => { + const point = { x: number(p, 'probe'), y: 0, z: 1 }; + const charges = chargePair(p); + const field = electricField(point, charges); + return [metric('Potential at probe', potential(point, charges), 'V'), metric('Ex at probe', field.x, 'V/m'), metric('|E| at probe', Math.hypot(field.x, field.y, field.z), 'N/C')]; + }, + }, + { + id: 'capacitance', title: 'Capacitance', shortTitle: 'Capacitance', + description: 'Build a parallel-plate capacitor and see how geometry controls charge storage.', + controls: plateControls, + equations: ['C = ε₀A / d · Q = CV', 'E = V / d · U = ½CV²'], + explanation: 'The left plate is positive and the right plate is negative. At a fixed applied voltage, larger plates store more charge, while a wider gap reduces capacitance. The arrows show the uniform field approximation between the plates.', + challenge: 'Double the area, then double the gap. Which measurements return to their original values, and which do not?', + limits: 'Ideal parallel plates in vacuum, connected to a fixed-voltage supply. Fringing is ignored. Plate separation is enlarged 50× relative to lateral dimensions for visibility; charge markers are illustrative. Arrows span the gap to show direction, not magnitude.', + sceneNote: 'Positive plate → field → negative plate · gap enlarged 50×', + legend: [['#ff967b', 'Positive plate'], ['#73d7ff', 'Negative plate'], ['#91e2cb', 'Uniform E']], + source: textbook('8-1-capacitors-and-capacitance'), + measure: p => { const c = plateModel(p); return [metric('Capacitance', c.capacitance * 1e12, 'pF'), metric('Stored free charge |Q|', c.charge * 1e9, 'nC'), metric('Electric field', c.field / 1000, 'kV/m'), metric('Stored energy', c.energy * 1e9, 'nJ')]; }, + }, + { + id: 'dielectric', title: 'Dielectric materials', shortTitle: 'Dielectrics', + description: 'Polarize an insulator and compare insertion with a connected or disconnected supply.', + controls: [slider('permittivity', 'Relative permittivity κ', 1, 8, 0.1, 4, ''), choice('connection', 'Electrical condition', 'connected', [['connected', 'Connected · voltage held fixed'], ['isolated', 'Isolated · free charge held fixed']]), ...plateControls], + equations: ['C = κε₀A / d · P = ε₀(κ − 1)E', 'Fixed V: Q ∝ κ · Fixed Q: V, E ∝ 1/κ'], + explanation: 'Bound dipoles align with the field: their negative ends face the positive plate. The bound charges oppose the field of the free plate charges. With a battery connected, extra free charge enters and V stays fixed. When isolated, Q stays fixed and V falls.', + challenge: 'Switch to isolated and compare κ = 1 with κ = 4. Then reconnect the supply and repeat: which quantities are held constant?', + limits: 'A uniform, linear dielectric completely fills the gap. Isolated Q is defined by charging this geometry in vacuum at the selected charging voltage, then disconnecting and inserting the material. Each setting is a fresh preparation; breakdown and fringing are excluded. Gap enlarged 50×; field arrows show direction, not magnitude.', + sceneNote: 'Gold volume: dielectric · blue/red pairs: bound dipoles · κ = 1: no polarization', + legend: [['#f5d58b', 'Dielectric'], ['#73d7ff', 'Bound −'], ['#ff967b', 'Bound +'], ['#91e2cb', 'Net field']], + source: textbook('8-4-capacitor-with-a-dielectric'), + measure: p => { const c = plateModel(p); return [metric('Capacitance', c.capacitance * 1e12, 'pF'), metric('Plate voltage', c.voltage, 'V'), metric('Free charge |Q|', c.charge * 1e9, 'nC'), metric('Net field', c.field / 1000, 'kV/m'), metric('Polarization |P|', c.polarization * 1e6, 'µC/m²')]; }, + }, + { + id: 'current', title: 'Current', shortTitle: 'Current & drift', + description: 'Drive electrons through a copper wire and compare their drift with conventional current.', + controls: [slider('voltage', 'Voltage across wire', -0.1, 0.1, 0.005, 0.025, 'V'), slider('length', 'Wire length', 0.5, 3, 0.1, 1, 'm'), slider('area', 'Cross-sectional area', 0.5, 4, 0.1, 1, 'mm²')], + equations: ['R = ρL / A · I = V / R', 'J = I / A · ve = −I / (neA)'], + explanation: 'Mint shows conventional current, defined as positive-charge flow. Blue electrons drift in the opposite direction. More voltage increases the drift; a longer wire adds resistance. A wider wire carries more current at the same voltage.', + challenge: 'Reverse the voltage. Then set it to zero. At fixed voltage, increase the wire area: does current increase, and does the electron drift speed change?', + limits: 'Ohmic copper at fixed temperature: ρ = 1.68×10⁻⁸ Ω·m, n = 8.5×10²⁸ m⁻³. Drift is displayed 600× faster and the wire radius is exaggerated. Thermal scattering, heating feedback, and signal propagation are not simulated; P is dissipated power.', + sceneNote: 'Blue: electron drift · mint: conventional current · speed ×600 · radius exaggerated', + legend: [['#73d7ff', 'Electrons'], ['#91e2cb', 'Conventional current'], ['#f5d58b', 'Copper ions']], + source: textbook('9-2-model-of-conduction-in-metals'), + measure: p => { const w = wireModel(p); return [metric('Resistance', w.resistance * 1000, 'mΩ'), metric('Conventional current', w.current, 'A'), metric('Electron drift, x', w.electronDrift * 1000, 'mm/s'), metric('Dissipated power', w.power, 'W')]; }, + }, + { + id: 'semiconductor', title: 'Semiconducting materials', shortTitle: 'Semiconductors', + description: 'Explore intrinsic, n-type and p-type silicon through carriers and an energy-band diagram.', + controls: [choice('doping', 'Silicon sample at 300 K', 'intrinsic', [['intrinsic', 'Intrinsic · undoped'], ['n', 'n-type · donor dopants'], ['p', 'p-type · acceptor dopants']]), slider('concentration', 'Dopant density · log₁₀(N / m⁻³)', 14, 22, 0.25, 20, '')], + equations: ['np = nᵢ² · n − p = ND − NA', 'σ = e(nμn + pμp)', 'EF − Ei = kBT ln(n / nᵢ)'], + explanation: 'Electrons occupy the conduction band; holes are missing electrons in the valence band. Donors raise electron density and the Fermi level; acceptors raise hole density and lower it. The gold line marks EF. Carrier markers show populations on a compressed scale, not individual atoms.', + challenge: 'Switch from intrinsic to n-type, then p-type at the same doping. Watch the majority carrier and Fermi shift change. Why are the conductivities different?', + limits: 'Illustrative silicon reference values at 300 K: Eg = 1.12 eV, nᵢ ≈ 10¹⁶ m⁻³, μn = 0.135 and μp = 0.048 m²/(V·s). Fully ionized, non-degenerate equilibrium with fixed mobilities. Ei is drawn at approximate midgap. Motion is schematic thermal motion with no applied field or net drift; the density control is inactive for intrinsic silicon.', + sceneNote: 'Upper blue band: conduction · lower coral band: valence · gold: EF · no net flow', + legend: [['#73d7ff', 'Conduction electrons'], ['#ff967b', 'Valence holes'], ['#f5d58b', 'Fermi level']], + source: { title: 'OpenStax · Band theory of solids', url: 'https://openstax.org/books/university-physics-volume-3/pages/9-5-band-theory-of-solids' }, + measure: p => { const s = carrierModel(p); return [metric('Electron density n', s.electrons, 'm⁻³'), metric('Hole density p', s.holes, 'm⁻³'), metric('Conductivity σ', s.conductivity, 'S/m'), metric('Fermi shift EF − Ei', s.fermiShift, 'eV')]; }, + }, +]; + +export function defaults(experiment: Experiment): Parameters { + return Object.fromEntries(experiment.controls.map(control => [control.id, control.value])); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..172f316 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,137 @@ +import './style.css'; +import { defaults, experiments, format, type Experiment, type Parameters } from './experiments'; +import { createLab } from './scene/lab'; + +const app = document.querySelector<HTMLDivElement>('#app')!; +app.innerHTML = ` + <header class="app-header"> + <a class="brand" href="#field" aria-label="Field Lab home">E<span>/</span>LAB</a> + <div class="header-title"><h1>Electrotechnique & Electrophysics</h1><p>Explore the invisible. Understand the physical.</p></div> + <span class="status"><i></i> INTERACTIVE LAB</span> + </header> + <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Experiments">${experiments.map((e, index) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(index + 1).padStart(2, '0')}</span>${e.shortTitle}</button>`).join('')}</div></nav> + <main id="lesson" role="tabpanel" aria-labelledby="tab-field"> + <aside class="controls-panel"> + <p class="eyebrow" id="lesson-number"></p> + <h2 id="lesson-title"></h2><p class="description" id="description"></p> + <form id="parameters" aria-label="Experiment parameters" onsubmit="return false"></form> + <div class="panel-actions"><button type="button" id="reset">Reset experiment</button></div> + <section class="measurements" id="measurements" aria-label="Live measurements" aria-live="polite" aria-atomic="true"></section> + </aside> + <div class="workbench"> + <section id="viewport" aria-label="3D workbench"> + <div class="scene-toolbar"><span class="view-tag">3D VIEW</span><div><button type="button" id="camera-reset">Reset camera</button><button type="button" id="pause" aria-pressed="false">Pause animation</button></div></div> + <div class="legend" id="legend"></div> + <div class="scene-footer"><p id="scene-note"></p><p class="hint">Drag to orbit · Scroll to zoom · Right-drag to pan · Arrow keys to pan when focused</p></div> + </section> + <section class="learning-panel" aria-label="Learn the physics"> + <div><p class="eyebrow">THE PHYSICS</p><div id="equations" class="equations"></div><p id="explanation"></p><a id="source" target="_blank" rel="noopener noreferrer"></a></div> + <div class="challenge"><p class="eyebrow">TRY THIS</p><p id="challenge"></p><details><summary>Model & visual scales</summary><p id="limits"></p></details></div> + </section> + </div> + </main>`; + +const lab = createLab(document.querySelector('#viewport')!); +const settings = new Map(experiments.map(experiment => [experiment.id, defaults(experiment)])); +const byId = (id: string) => document.getElementById(id)!; +let active = experiments[0]; +let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches; +function updatePause() { + byId('pause').textContent = paused ? 'Resume animation' : 'Pause animation'; + byId('pause').setAttribute('aria-pressed', String(paused)); + lab.setPaused(paused); +} +updatePause(); + +function parameters(): Parameters { return settings.get(active.id)!; } +function update() { + const p = parameters(); + for (const control of active.controls) { + if (control.kind !== 'range') continue; + byId(`output-${control.id}`).textContent = `${format(Number(p[control.id]), 3)}${control.unit ? ` ${control.unit}` : ''}`; + } + if (active.id === 'semiconductor') { + (byId('parameter-concentration') as HTMLInputElement).disabled = p.doping === 'intrinsic'; + } + byId('measurements').innerHTML = active.measure(p).map(m => `<div class="measurement"><span>${m.label}</span><div><strong aria-label="${m.label}">${m.value}</strong><small>${m.unit}</small></div></div>`).join(''); + lab.update(active.id, p); +} +function show(experiment: Experiment) { + active = experiment; + const index = experiments.indexOf(experiment); + byId('lesson-number').textContent = `EXPERIMENT ${String(index + 1).padStart(2, '0')} / 08`; + byId('lesson-title').textContent = experiment.title; + byId('description').textContent = experiment.description; + byId('scene-note').textContent = experiment.sceneNote; + byId('explanation').textContent = experiment.explanation; + byId('challenge').textContent = experiment.challenge; + byId('limits').textContent = experiment.limits; + byId('equations').replaceChildren(...experiment.equations.map(equation => { + const div = document.createElement('p'); + div.textContent = equation; + return div; + })); + const source = byId('source') as HTMLAnchorElement; + source.textContent = `${experiment.source.title} ↗`; + source.href = experiment.source.url; + byId('legend').innerHTML = experiment.legend.map(([colour, text]) => `<span><i style="background:${colour}"></i>${text}</span>`).join(''); + byId('lesson').setAttribute('aria-labelledby', `tab-${experiment.id}`); + document.querySelectorAll<HTMLButtonElement>('[role="tab"]').forEach(tab => { + const selected = tab.dataset.experiment === experiment.id; + tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; + }); + const p = parameters(); + byId('parameters').innerHTML = experiment.controls.map(control => { + const id = `parameter-${control.id}`; + if (control.kind === 'select') return `<div class="parameter"><label for="${id}">${control.label}</label><select id="${id}" name="${control.id}">${control.options.map(option => `<option value="${option.value}" ${option.value === p[control.id] ? 'selected' : ''}>${option.label}</option>`).join('')}</select></div>`; + return `<div class="parameter"><div class="parameter-label"><label for="${id}">${control.label}</label><output for="${id}" id="output-${control.id}"></output></div><input id="${id}" name="${control.id}" type="range" min="${control.min}" max="${control.max}" step="${control.step}" value="${p[control.id]}" /></div>`; + }).join(''); + lab.resetCamera(); + update(); +} +function fromHash() { + const id = window.location.hash.slice(1); + show(experiments.find(experiment => experiment.id === id) ?? experiments[0]); +} +const onHashChange = () => fromHash(); +window.addEventListener('hashchange', onHashChange); +fromHash(); +byId('parameters').addEventListener('input', event => { + const input = event.target as HTMLInputElement | HTMLSelectElement; + if (!input.name) return; + parameters()[input.name] = input.type === 'range' ? Number(input.value) : input.value; + update(); +}); +byId('reset').addEventListener('click', () => { + settings.set(active.id, defaults(active)); + show(active); +}); +byId('camera-reset').addEventListener('click', () => lab.resetCamera()); +byId('pause').addEventListener('click', () => { + paused = !paused; + updatePause(); +}); +const tabs = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')]; +tabs.forEach((tab, index) => { + tab.addEventListener('click', () => { + const id = tab.dataset.experiment!; + if (window.location.hash === `#${id}`) show(experiments[index]); + else window.location.hash = id; + }); + tab.addEventListener('keydown', event => { + let next: number; + if (event.key === 'ArrowRight') next = (index + 1) % tabs.length; + else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = tabs.length - 1; + else return; + event.preventDefault(); + tabs[next].focus(); + tabs[next].click(); + }); +}); +if (import.meta.hot) import.meta.hot.dispose(() => { + window.removeEventListener('hashchange', onHashChange); + lab.dispose(); +}); diff --git a/src/physics/electricField.test.ts b/src/physics/electricField.test.ts new file mode 100644 index 0000000..2e01309 --- /dev/null +++ b/src/physics/electricField.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { electricField, COULOMB_CONSTANT } from './electricField'; + +describe('Coulomb electric field', () => { + it('points away from a positive charge with inverse-square magnitude', () => { + const field = electricField({ x: 2, y: 0, z: 0 }, [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }]); + expect(field.x).toBeCloseTo(COULOMB_CONSTANT * 1e-9 / 4); + expect(field.y).toBe(0); + expect(field.z).toBe(0); + }); + it('superposes opposite charges in the same direction at the midpoint', () => { + const field = electricField({ x: 0, y: 0, z: 0 }, [ + { x: -1, y: 0, z: 0, coulombs: 1e-9 }, + { x: 1, y: 0, z: 0, coulombs: -1e-9 }, + ]); + expect(field.x).toBeCloseTo(2 * COULOMB_CONSTANT * 1e-9); + }); + it('omits singular samples within the visualization exclusion radius', () => { + expect(electricField({ x: 0, y: 0, z: 0 }, [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }])).toEqual({ x: 0, y: 0, z: 0 }); + }); +}); diff --git a/src/physics/electricField.ts b/src/physics/electricField.ts new file mode 100644 index 0000000..26975e3 --- /dev/null +++ b/src/physics/electricField.ts @@ -0,0 +1,22 @@ +export interface Point { x: number; y: number; z: number } +export interface Charge extends Point { coulombs: number } +export const COULOMB_CONSTANT = 8.9875517923e9; + +/** SI units: positions in metres, charges in coulombs, output in N/C. + * Samples inside 0.18 m of a charge are omitted to avoid visual singularities. + */ +export function electricField(point: Point, charges: readonly Charge[]): Point { + const field = { x: 0, y: 0, z: 0 }; + for (const charge of charges) { + const dx = point.x - charge.x; + const dy = point.y - charge.y; + const dz = point.z - charge.z; + const r2 = dx * dx + dy * dy + dz * dz; + if (r2 < 0.18 ** 2) continue; + const factor = COULOMB_CONSTANT * charge.coulombs / (r2 * Math.sqrt(r2)); + field.x += factor * dx; + field.y += factor * dy; + field.z += factor * dz; + } + return field; +} diff --git a/src/physics/models.test.ts b/src/physics/models.test.ts new file mode 100644 index 0000000..3ff6487 --- /dev/null +++ b/src/physics/models.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { electricField } from './electricField'; +import { potential, forceOn, pairEnergy, gaussianFlux, capacitor, conductor, semiconductor } from './models'; + +// Fixtures use hand-calculated SI results, independent of model implementations. +describe('charge, potential and energy', () => { + const source = [{ x: 0, y: 0, z: 0, coulombs: 2e-9 }]; + it('gives scalar potential tending to zero at infinity', () => { + expect(potential({ x: 2, y: 0, z: 0 }, source)).toBeCloseTo(8.98755, 4); + expect(potential({ x: 1e9, y: 0, z: 0 }, source)).toBeCloseTo(0, 6); + }); + it('makes the field the negative spatial gradient of potential', () => { + const h = 1e-4; + const numerical = -(potential({ x: 2 + h, y: 0, z: 0 }, source) - potential({ x: 2 - h, y: 0, z: 0 }, source)) / (2 * h); + expect(electricField({ x: 2, y: 0, z: 0 }, source).x).toBeCloseTo(numerical, 5); + }); + it('reverses force for a negative test charge', () => { + expect(forceOn({ x: 2, y: 0, z: 0, coulombs: -3e-9 }, source).x).toBeCloseTo(-13.48132768845e-9, 16); + }); + it('gives negative interaction energy for opposite charges', () => { + expect(pairEnergy(2e-9, -3e-9, 2)).toBeCloseTo(-26.9626553769e-9, 16); + }); + it('marks potential at a charge as undefined instead of inventing a finite value', () => { + expect(potential({ x: 0, y: 0, z: 0 }, source)).toBeNaN(); + expect(() => pairEnergy(1, 1, 0)).toThrow(RangeError); + }); +}); + +describe('Gauss law', () => { + it('counts only enclosed charge, including its sign', () => { + const charges = [{ x: 0, y: 0, z: 0, coulombs: -2e-9 }, { x: 3, y: 0, z: 0, coulombs: 5e-9 }]; + expect(gaussianFlux(charges, 2)).toBeCloseTo(-225.8818, 3); + expect(gaussianFlux(charges, 4)).toBeCloseTo(338.8227, 3); + }); + it('does not depend on surface size when enclosed charge is unchanged', () => { + const charges = [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }]; + expect(gaussianFlux(charges, 1)).toBe(gaussianFlux(charges, 2)); + }); + it('marks charge on the Gaussian surface as undefined', () => { + expect(gaussianFlux([{ x: 2, y: 0, z: 0, coulombs: 1e-9 }], 2)).toBeNaN(); + }); +}); + +describe('parallel plate capacitor', () => { + it('converts physical geometry into C, Q, E and stored energy', () => { + const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 1, voltage: 10 }); + expect(c.capacitance).toBeCloseTo(8.8541878128e-11, 18); + expect(c.charge).toBeCloseTo(8.8541878128e-10, 18); + expect(c.field).toBe(10000); + expect(c.energy).toBeCloseTo(4.4270939064e-9, 17); + }); + it('increases free charge at fixed voltage when dielectric is inserted', () => { + const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 4, voltage: 10 }); + expect(c.field).toBe(10000); + expect(c.charge).toBeCloseTo(3.54167512512e-9, 17); + expect(c.polarization).toBeCloseTo(2.65625634384e-7, 15); + }); + it('reduces voltage and energy at fixed free charge', () => { + const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 4, voltage: 10, isolated: true }); + expect(c.charge).toBeCloseTo(8.8541878128e-10, 18); + expect(c.voltage).toBeCloseTo(2.5); + expect(c.field).toBeCloseTo(2500); + expect(c.energy).toBeCloseTo(1.1067734766e-9, 17); + }); + it('rejects impossible geometry', () => { + expect(() => capacitor({ area: 0.01, gap: 0, relativePermittivity: 1, voltage: 10 })).toThrow(RangeError); + }); +}); + +describe('current and drift', () => { + it('follows Ohm law and sends electrons opposite conventional current', () => { + const wire = conductor({ voltage: 2, resistivity: 1.68e-8, length: 1, area: 1e-6, carrierDensity: 8.5e28 }); + expect(wire.resistance).toBeCloseTo(0.0168, 6); + expect(wire.current).toBeCloseTo(119.047619, 5); + expect(wire.electronDrift).toBeCloseTo(-0.00874160935, 10); + expect(wire.power).toBeCloseTo(238.095238, 5); + }); + it('reverses current but keeps dissipated power positive with reversed voltage', () => { + const wire = conductor({ voltage: -2, resistivity: 1.68e-8, length: 1, area: 1e-6, carrierDensity: 8.5e28 }); + expect(wire.current).toBeLessThan(0); + expect(wire.electronDrift).toBeGreaterThan(0); + expect(wire.power).toBeGreaterThan(0); + }); +}); + +describe('semiconductor equilibrium at 300 K', () => { + it('has equal intrinsic electrons and holes', () => { + const s = semiconductor({ intrinsicDensity: 1e16, netDoping: 0, electronMobility: 0.135, holeMobility: 0.048 }); + expect(s.electrons).toBe(1e16); + expect(s.holes).toBe(1e16); + expect(s.fermiShift).toBe(0); + expect(s.conductivity).toBeCloseTo(0.0002931983, 9); + }); + it('conserves mass action and charge neutrality even at heavy donor doping', () => { + const s = semiconductor({ intrinsicDensity: 1e16, netDoping: 1e22, electronMobility: 0.135, holeMobility: 0.048 }); + expect(s.electrons / 1e22).toBeCloseTo(1, 10); + expect(s.holes).toBeCloseTo(1e10, 0); + expect(s.electrons * s.holes / 1e32).toBeCloseTo(1, 12); + expect((s.electrons - s.holes) / 1e22).toBeCloseTo(1, 12); + expect(s.fermiShift).toBeCloseTo(0.3571586, 5); + }); + it('makes holes majority carriers for acceptor doping', () => { + const s = semiconductor({ intrinsicDensity: 1e16, netDoping: -1e22, electronMobility: 0.135, holeMobility: 0.048 }); + expect(s.holes / 1e22).toBeCloseTo(1, 10); + expect(s.electrons).toBeCloseTo(1e10, 0); + expect(s.fermiShift).toBeLessThan(0); + }); +}); diff --git a/src/physics/models.ts b/src/physics/models.ts new file mode 100644 index 0000000..8fb121f --- /dev/null +++ b/src/physics/models.ts @@ -0,0 +1,112 @@ +import { COULOMB_CONSTANT, electricField, type Charge, type Point } from './electricField'; + +export const VACUUM_PERMITTIVITY = 8.8541878128e-12; +export const ELEMENTARY_CHARGE = 1.602176634e-19; +const THERMAL_ENERGY_EV = 8.617333262e-5 * 300; + +function positive(...values: number[]) { + if (values.some(value => !Number.isFinite(value) || value <= 0)) { + throw new RangeError('Physical dimensions and material constants must be finite and positive.'); + } +} + +/** Vacuum potential in volts, referenced to infinity. Undefined inside the + * same 0.18 m visualization exclusion used by electricField. */ +export function potential(point: Point, charges: readonly Charge[]): number { + let volts = 0; + for (const charge of charges) { + if (charge.coulombs === 0) continue; + const r = Math.hypot(point.x - charge.x, point.y - charge.y, point.z - charge.z); + if (r < 0.18) return NaN; + volts += COULOMB_CONSTANT * charge.coulombs / r; + } + return volts; +} + +/** Force on a test charge, excluding that charge from the supplied sources. */ +export function forceOn(test: Charge, sources: readonly Charge[]): Point { + const field = electricField(test, sources); + return { x: field.x * test.coulombs, y: field.y * test.coulombs, z: field.z * test.coulombs }; +} + +export function pairEnergy(q1: number, q2: number, separation: number): number { + positive(separation); + return COULOMB_CONSTANT * q1 * q2 / separation; +} + +/** Exact signed flux for an origin-centred spherical Gaussian surface. + * A point charge exactly on the surface makes the integral undefined. */ +export function gaussianFlux(charges: readonly Charge[], radius: number): number { + positive(radius); + let enclosed = 0; + for (const charge of charges) { + if (charge.coulombs === 0) continue; + const r = Math.hypot(charge.x, charge.y, charge.z); + if (Math.abs(r - radius) < 1e-9) return NaN; + if (r < radius) enclosed += charge.coulombs; + } + return enclosed / VACUUM_PERMITTIVITY; +} + +export interface CapacitorParameters { + area: number; + gap: number; + relativePermittivity: number; + voltage: number; + /** If isolated, voltage means the initial vacuum charging voltage. */ + isolated?: boolean; +} +export function capacitor(p: CapacitorParameters) { + positive(p.area, p.gap, p.relativePermittivity); + const vacuumCapacitance = VACUUM_PERMITTIVITY * p.area / p.gap; + const capacitance = vacuumCapacitance * p.relativePermittivity; + const voltage = p.isolated ? p.voltage / p.relativePermittivity : p.voltage; + const charge = capacitance * voltage; + const field = voltage / p.gap; + return { + capacitance, voltage, charge, field, + energy: 0.5 * capacitance * voltage ** 2, + polarization: VACUUM_PERMITTIVITY * (p.relativePermittivity - 1) * field, + }; +} + +export interface ConductorParameters { + voltage: number; + resistivity: number; + length: number; + area: number; + carrierDensity: number; +} +export function conductor(p: ConductorParameters) { + positive(p.resistivity, p.length, p.area, p.carrierDensity); + const resistance = p.resistivity * p.length / p.area; + const current = p.voltage / resistance; + return { + resistance, current, + power: p.voltage * current, + density: current / p.area, + electronDrift: -current / (p.carrierDensity * ELEMENTARY_CHARGE * p.area), + }; +} + +export interface SemiconductorParameters { + intrinsicDensity: number; + /** Fully ionized donors minus acceptors, in m^-3. */ + netDoping: number; + electronMobility: number; + holeMobility: number; +} +/** Non-degenerate thermal equilibrium at 300 K. Solving for the minority + * through mass action avoids catastrophic subtraction at large doping. */ +export function semiconductor(p: SemiconductorParameters) { + positive(p.intrinsicDensity, p.electronMobility, p.holeMobility); + const majority = (Math.abs(p.netDoping) + Math.hypot(p.netDoping, 2 * p.intrinsicDensity)) / 2; + const minority = p.intrinsicDensity ** 2 / majority; + const electrons = p.netDoping >= 0 ? majority : minority; + const holes = p.netDoping >= 0 ? minority : majority; + return { + electrons, holes, + fermiShift: THERMAL_ENERGY_EV * Math.log(electrons / p.intrinsicDensity), + conductivity: ELEMENTARY_CHARGE * (electrons * p.electronMobility + holes * p.holeMobility), + }; +} diff --git a/src/scene/experiments.ts b/src/scene/experiments.ts new file mode 100644 index 0000000..67d0322 --- /dev/null +++ b/src/scene/experiments.ts @@ -0,0 +1,245 @@ +import * as THREE from 'three'; +import { chargePair, carrierModel, number, plateModel, wireModel, type ExperimentId, type Parameters } from '../experiments'; +import { electricField, type Charge, type Point } from '../physics/electricField'; +import { forceOn, pairEnergy, potential } from '../physics/models'; +import { arrow, box, colours, grid, label, line, sphere } from './primitives'; + +export type Animate = (delta: number) => void; +const still: Animate = () => {}; +function signedCharge(group: THREE.Group, charge: Charge) { + const colour = charge.coulombs >= 0 ? colours.positive : colours.negative; + sphere(group, charge, 0.2, colour); + label(group, `${charge.coulombs >= 0 ? '+' : '−'}${Math.abs(charge.coulombs * 1e9).toFixed(1)} nC`, { ...charge, y: charge.y + 0.55 }, colour, 1.25); +} +function compressedFieldArrow(group: THREE.Group, position: Point, direction: Point, colour = colours.field) { + const strength = Math.hypot(direction.x, direction.y, direction.z); + if (strength < 1e-8) return; + arrow(group, position, direction, 0.12 + 0.32 * strength / (strength + 4), colour); +} + +function fieldScene(group: THREE.Group, p: Parameters) { + grid(group); + const charges = chargePair(p); + charges.forEach(charge => signedCharge(group, charge)); + for (let x = -5; x <= 5; x += 0.6) for (let z = -5; z <= 5; z += 0.6) { + const point = { x, y: 0, z }; + if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.42)) continue; + compressedFieldArrow(group, point, electricField(point, charges)); + } + const force = forceOn(charges[1], [charges[0]]); + arrow(group, { ...charges[1], y: 0.8 }, force, 1.3, colours.gold); + label(group, 'F on right charge', { x: charges[1].x, y: 1.5, z: 0 }, colours.gold, 2); + sphere(group, { x: 0, y: 0, z: 0 }, 0.06, colours.gold); + label(group, 'Origin', { x: 0, y: 0, z: 0.45 }, colours.muted, 0.9); +} + +function fluxScene(group: THREE.Group, p: Parameters) { + const radius = number(p, 'radius'); + const charge: Charge = { x: number(p, 'position'), y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 }; + grid(group, -3.4); + signedCharge(group, charge); + const surface = new THREE.Mesh(new THREE.SphereGeometry(radius, 40, 24), new THREE.MeshBasicMaterial({ color: colours.negative, wireframe: true, transparent: true, opacity: 0.11, depthWrite: false })); + group.add(surface); + for (let i = 0; i < 150; i++) { + const y = 1 - 2 * (i + 0.5) / 150; + const r = Math.sqrt(1 - y * y); + const theta = i * Math.PI * (3 - Math.sqrt(5)); + const normal = { x: r * Math.cos(theta), y, z: r * Math.sin(theta) }; + const point = { x: normal.x * radius, y: y * radius, z: normal.z * radius }; + if (Math.hypot(point.x - charge.x, point.y, point.z) < 0.3) continue; + const e = electricField(point, [charge]); + const dot = e.x * normal.x + e.y * normal.y + e.z * normal.z; + compressedFieldArrow(group, point, e, dot >= 0 ? colours.field : colours.positive); + } + label(group, 'Gaussian surface', { x: 0, y: radius + 0.6, z: 0 }, colours.negative, 2.5); + line(group, [{ x: -4.7, y: 0, z: 0 }, { x: 4.7, y: 0, z: 0 }], colours.muted, 0.4); +} + +function energyScene(group: THREE.Group, p: Parameters) { + grid(group, -4.3); + const r = number(p, 'distance'); + const q = number(p, 'testCharge') * 1e-9; + const source: Charge = { x: -3, y: 0, z: 1.5, coulombs: 3e-9 }; + const moving: Charge = { x: -3 + r, y: 0, z: 1.5, coulombs: q }; + signedCharge(group, source); + signedCharge(group, moving); + line(group, [source, moving], colours.muted); + arrow(group, { ...moving, y: 0.7 }, forceOn(moving, [source]), 1.1, colours.gold); + const curve: Point[] = []; + for (let distance = 0.75; distance <= 6; distance += 0.05) curve.push({ x: -3 + distance, y: pairEnergy(3e-9, q, distance) * 1e9 / 20, z: -1.5 }); + line(group, curve, colours.field); + line(group, [{ x: -2.5, y: 0, z: -1.5 }, { x: 3.5, y: 0, z: -1.5 }], colours.muted); + line(group, [{ x: -3, y: -5.5, z: -1.5 }, { x: -3, y: 5.5, z: -1.5 }], colours.muted, 0.5); + sphere(group, { x: -3 + r, y: pairEnergy(3e-9, q, r) * 1e9 / 20, z: -1.5 }, 0.12, colours.gold); + label(group, 'U(r) · 20 nJ per height unit', { x: 0, y: 5.7, z: -1.5 }, colours.field, 3.9); + label(group, '0 energy', { x: 4, y: 0, z: -1.5 }, colours.muted, 1.5); + label(group, `r = ${r.toFixed(2)} m`, { x: 0, y: -0.65, z: 1.5 }, colours.muted, 2); +} + +function potentialScene(group: THREE.Group, p: Parameters) { + const charges = chargePair(p); + const geometry = new THREE.PlaneGeometry(10, 10, 64, 64); + geometry.rotateX(-Math.PI / 2); + const positions = geometry.getAttribute('position'); + const values: number[] = []; + const colourValues: number[] = []; + const negative = new THREE.Color(colours.negative); + const positive = new THREE.Color(colours.positive); + const neutral = new THREE.Color('#203847'); + for (let i = 0; i < positions.count; i++) { + const point = { x: positions.getX(i), y: 0, z: positions.getZ(i) }; + const v = potential(point, charges); + const height = Number.isFinite(v) ? Math.tanh(v / 15) : NaN; + values.push(height); + positions.setY(i, Number.isFinite(height) ? 2 * height : 0); + const colour = neutral.clone().lerp(height >= 0 ? positive : negative, Math.abs(Number.isFinite(height) ? height : 0)); + colourValues.push(colour.r, colour.g, colour.b); + } + const indices = geometry.getIndex()!; + const kept: number[] = []; + for (let i = 0; i < indices.count; i += 3) { + const a = indices.getX(i), b = indices.getX(i + 1), c = indices.getX(i + 2); + if ([a, b, c].every(index => Number.isFinite(values[index]))) kept.push(a, b, c); + } + geometry.setIndex(kept); + geometry.setAttribute('color', new THREE.Float32BufferAttribute(colourValues, 3)); + geometry.computeVertexNormals(); + group.add(new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.7, transparent: true, opacity: 0.88 }))); + for (let x = -4.5; x <= 4.5; x += 0.75) for (let z = -4.5; z <= 4.5; z += 0.75) { + const point = { x, y: 0, z }; + if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.6)) continue; + const v = potential(point, charges); + if (!Number.isFinite(v)) continue; + compressedFieldArrow(group, { ...point, y: 2 * Math.tanh(v / 15) + 0.12 }, electricField(point, charges)); + } + const probe = { x: number(p, 'probe'), y: 0, z: 1 }; + const v = potential(probe, charges); + sphere(group, { ...probe, y: 2 * Math.tanh(v / 15) + 0.2 }, 0.13, colours.gold); + label(group, 'Probe', { ...probe, y: 2 * Math.tanh(v / 15) + 0.8 }, colours.gold, 1.3); + charges.forEach(charge => signedCharge(group, { ...charge, y: Math.sign(charge.coulombs) * 2.2 })); + grid(group, -2.4); +} + +function plateScene(group: THREE.Group, p: Parameters, dielectric: boolean) { + const c = plateModel(p); + const halfGap = number(p, 'gap') * 0.75; + const side = Math.sqrt(number(p, 'area')) * 0.3; + // Uniform magnification maps lateral metres to 30 scene units/m and gap + // metres to 1500 units/m: gap is enlarged 50x relative to plate width. + for (const sign of [1, -1]) { + const x = -sign * halfGap; + const colour = sign === 1 ? colours.positive : colours.negative; + box(group, { x, y: 0, z: 0 }, { x: 0.05, y: side, z: side }, colour, 0.65); + label(group, sign === 1 ? '+ plate' : '− plate', { x, y: side / 2 + 0.45, z: 0 }, colour, 1.2); + for (let y = -0.35; y <= 0.35; y += 0.35) for (let z = -0.35; z <= 0.35; z += 0.35) { + sphere(group, { x: x + sign * 0.035, y: y * side, z: z * side }, 0.025, colour); + } + } + for (let y = -0.3; y <= 0.3; y += 0.3) for (let z = -0.3; z <= 0.3; z += 0.3) { + const length = Math.max(0.1, 2 * halfGap - 0.1); + // Repeated arrows are direction cues; readouts provide field magnitude. + arrow(group, { x: -halfGap + 0.05, y: y * side, z: z * side }, { x: 1, y: 0, z: 0 }, length, colours.field); + } + if (dielectric) { + box(group, { x: 0, y: 0, z: 0 }, { x: Math.max(0.04, 2 * halfGap - 0.08), y: side * 0.92, z: side * 0.92 }, colours.gold, 0.1); + const kappa = number(p, 'permittivity'); + if (kappa > 1) { + const boundOffset = Math.min(halfGap * 0.65, 0.12) * (kappa - 1) / kappa; + for (let y = -0.32; y <= 0.32; y += 0.32) for (let z = -0.32; z <= 0.32; z += 0.32) { + const center = { x: 0, y: y * side, z: z * side }; + sphere(group, { ...center, x: -boundOffset }, 0.035, colours.negative); + sphere(group, { ...center, x: boundOffset }, 0.035, colours.positive); + line(group, [{ ...center, x: -boundOffset }, { ...center, x: boundOffset }], colours.gold); + } + } + } + line(group, [{ x: -halfGap, y: -side, z: 0 }, { x: halfGap, y: -side, z: 0 }], colours.muted); + label(group, `d = ${number(p, 'gap').toFixed(1)} mm (gap ×50)`, { x: 0, y: -side - 0.45, z: 0 }, colours.muted, 2.5); + label(group, `${c.voltage.toFixed(2)} V · ${p.connection === 'isolated' ? 'isolated' : 'supply connected'}`, { x: 0, y: side + 0.8, z: 0 }, colours.field, 3); + grid(group, -3.2); +} + +function currentScene(group: THREE.Group, p: Parameters): Animate { + grid(group, -2); + const model = wireModel(p); + const length = number(p, 'length') * 3; + const radius = Math.sqrt(number(p, 'area')) * 0.4; + const wire = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 28, 1, true), new THREE.MeshStandardMaterial({ color: '#bf8158', transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false })); + wire.rotation.z = Math.PI / 2; + group.add(wire); + for (let i = 0; i < 6; i++) { + const x = length * ((i + 0.5) / 6 - 0.5); + for (let j = 0; j < 6; j++) { + const angle = j * Math.PI / 3; + sphere(group, { x, y: radius * 0.8 * Math.cos(angle), z: radius * 0.8 * Math.sin(angle) }, 0.055, colours.gold); + } + } + const carriers: THREE.Mesh[] = []; + for (let i = 0; i < 32; i++) { + const angle = i * 2.39996; + const r = radius * 0.65 * Math.sqrt((i % 7 + 1) / 8); + carriers.push(sphere(group, { x: length * (i / 32 - 0.5), y: r * Math.cos(angle), z: r * Math.sin(angle) }, 0.065, colours.negative)); + } + arrow(group, { x: -Math.sign(model.current) * 1.1, y: radius + 0.8, z: 0 }, { x: model.current, y: 0, z: 0 }, 2.2, colours.field); + label(group, model.current === 0 ? 'No net current' : 'Conventional current', { x: 0, y: radius + 1.5, z: 0 }, colours.field, 2.8); + arrow(group, { x: -Math.sign(model.electronDrift) * 1.1, y: -radius - 0.65, z: 0 }, { x: model.electronDrift, y: 0, z: 0 }, 2.2, colours.negative); + label(group, 'Electron drift · displayed ×600', { x: 0, y: -radius - 1.1, z: 0 }, colours.negative, 3.3); + const velocity = model.electronDrift * 600 * 3; + return delta => { + for (const electron of carriers) { + electron.position.x = ((electron.position.x + length / 2 + delta * velocity) % length + length) % length - length / 2; + } + }; +} + +function semiconductorScene(group: THREE.Group, p: Parameters): Animate { + grid(group, -3.5); + const s = carrierModel(p); + // 3 display units per eV; approximate intrinsic level at midgap. + const bandEdge = 1.12 / 2 * 3; + box(group, { x: 0, y: bandEdge + 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.negative, 0.12); + box(group, { x: 0, y: -bandEdge - 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.positive, 0.12); + line(group, [{ x: -3.5, y: 0, z: 1.6 }, { x: 3.5, y: 0, z: 1.6 }], colours.muted, 0.7); + line(group, [{ x: -3.6, y: s.fermiShift * 3, z: 1.65 }, { x: 3.6, y: s.fermiShift * 3, z: 1.65 }], colours.gold); + label(group, 'EF', { x: 4, y: s.fermiShift * 3, z: 1.65 }, colours.gold, 0.7); + label(group, 'Ei ≈ midgap', { x: -4.4, y: 0, z: 1.65 }, colours.muted, 1.7); + label(group, 'Conduction band', { x: 0, y: bandEdge + 1.1, z: 0 }, colours.negative, 2.8); + label(group, 'Valence band', { x: 0, y: -bandEdge - 1.1, z: 0 }, colours.positive, 2.8); + label(group, 'Eg = 1.12 eV', { x: 4.1, y: 0, z: -1.3 }, colours.muted, 1.8); + line(group, [{ x: 3.8, y: -bandEdge, z: -1.3 }, { x: 3.8, y: bandEdge, z: -1.3 }], colours.muted); + const particles: { mesh: THREE.Mesh; base: THREE.Vector3; phase: number }[] = []; + for (const [density, sign] of [[s.electrons, 1], [s.holes, -1]]) { + const count = Math.round(3 + Math.max(0, Math.log10(density) - 8) * 2.5); + for (let i = 0; i < count; i++) { + const x = 6.4 * ((i * 0.618034) % 1 - 0.5); + const z = 2.5 * ((i * 0.414214) % 1 - 0.5); + const mesh = sphere(group, { x, y: sign * (bandEdge + 0.4), z }, 0.075, sign === 1 ? colours.negative : colours.positive); + if (sign === -1) { + (mesh.material as THREE.MeshStandardMaterial).wireframe = true; + } + particles.push({ mesh, base: mesh.position.clone(), phase: i * 2.4 + sign }); + } + } + let time = 0; + return delta => { + time += delta; + for (const particle of particles) { + particle.mesh.position.x = particle.base.x + 0.12 * Math.sin(time * 1.3 + particle.phase); + particle.mesh.position.z = particle.base.z + 0.12 * Math.cos(time * 1.7 + particle.phase); + } + }; +} + +export function buildExperiment(group: THREE.Group, id: ExperimentId, p: Parameters): Animate { + switch (id) { + case 'field': fieldScene(group, p); break; + case 'flux': fluxScene(group, p); break; + case 'energy': energyScene(group, p); break; + case 'potential': potentialScene(group, p); break; + case 'capacitance': plateScene(group, p, false); break; + case 'dielectric': plateScene(group, p, true); break; + case 'current': return currentScene(group, p); + case 'semiconductor': return semiconductorScene(group, p); + } + return still; +} diff --git a/src/scene/lab.ts b/src/scene/lab.ts new file mode 100644 index 0000000..5d2cadf --- /dev/null +++ b/src/scene/lab.ts @@ -0,0 +1,97 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import type { ExperimentId, Parameters } from '../experiments'; +import { buildExperiment, type Animate } from './experiments'; +import { disposeGroup } from './primitives'; + +export function createLab(viewport: HTMLElement) { + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ antialias: true }); + } catch { + const error = document.createElement('div'); + error.className = 'webgl-error'; + error.setAttribute('role', 'alert'); + error.textContent = 'WebGL could not start. Enable browser hardware acceleration and reload to see the 3D scenes. Lessons, controls and measurements still work.'; + viewport.append(error); + return { update: () => {}, resetCamera: () => {}, setPaused: () => {}, dispose: () => {} }; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.domElement.setAttribute('aria-label', '3D experiment: drag to orbit, scroll to zoom, right-drag to pan'); + renderer.domElement.setAttribute('tabindex', '0'); + viewport.append(renderer.domElement); + const scene = new THREE.Scene(); + scene.background = new THREE.Color('#101822'); + scene.add(new THREE.HemisphereLight(0xe9f7ff, 0x263149, 3)); + const keyLight = new THREE.DirectionalLight(0xffffff, 2); + keyLight.position.set(3, 6, 5); + scene.add(keyLight); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.minDistance = 3; + controls.maxDistance = 32; + controls.listenToKeyEvents(renderer.domElement); + function resetCamera() { + camera.position.set(8, 7.5, 11); + controls.target.set(0, 0, 0); + controls.update(); + } + resetCamera(); + let group = new THREE.Group(); + scene.add(group); + let animate: Animate = () => {}; + let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let lastTime = 0; + let pending: { id: ExperimentId; parameters: Parameters } | undefined; + function update(id: ExperimentId, parameters: Parameters) { + // Coalesce range events to one rebuild per animation frame. + pending = { id, parameters: { ...parameters } }; + } + const observer = new ResizeObserver(() => { + const { width, height } = viewport.getBoundingClientRect(); + if (width <= 0 || height <= 0) return; + renderer.setSize(width, height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + }); + observer.observe(viewport); + renderer.domElement.addEventListener('webglcontextlost', event => { + event.preventDefault(); + if (!viewport.querySelector('[role="alert"]')) { + const warning = document.createElement('p'); + warning.setAttribute('role', 'alert'); + warning.className = 'webgl-error'; + warning.textContent = 'WebGL context lost. Reload to restore the 3D view. Numeric experiments still work.'; + viewport.append(warning); + } + }); + renderer.setAnimationLoop(time => { + const delta = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0; + lastTime = time; + if (pending) { + disposeGroup(group); + scene.remove(group); + group = new THREE.Group(); + scene.add(group); + animate = buildExperiment(group, pending.id, pending.parameters); + pending = undefined; + } + if (!paused && !document.hidden) animate(delta); + controls.update(); + renderer.render(scene, camera); + }); + return { + update, + resetCamera, + setPaused: (value: boolean) => { paused = value; }, + dispose: () => { + renderer.setAnimationLoop(null); + observer.disconnect(); + controls.dispose(); + disposeGroup(group); + renderer.dispose(); + renderer.domElement.remove(); + }, + }; +} diff --git a/src/scene/primitives.ts b/src/scene/primitives.ts new file mode 100644 index 0000000..bce9113 --- /dev/null +++ b/src/scene/primitives.ts @@ -0,0 +1,90 @@ +import * as THREE from 'three'; +import type { Point } from '../physics/electricField'; + +export const colours = { positive: '#ff967b', negative: '#73d7ff', field: '#91e2cb', gold: '#f5d58b', muted: '#8294a8' }; +export const vector = (p: Point) => new THREE.Vector3(p.x, p.y, p.z); +export function sphere(group: THREE.Group, position: Point, radius: number, colour: string): THREE.Mesh { + const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 18, 12), new THREE.MeshStandardMaterial({ color: colour, roughness: 0.3, emissive: colour, emissiveIntensity: 0.12 })); + mesh.position.copy(vector(position)); + group.add(mesh); + return mesh; +} +export function box(group: THREE.Group, position: Point, dimensions: Point, colour: string, opacity = 1): THREE.Mesh { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(dimensions.x, dimensions.y, dimensions.z), new THREE.MeshStandardMaterial({ color: colour, transparent: opacity < 1, opacity, depthWrite: opacity === 1, side: THREE.DoubleSide, roughness: 0.45 })); + mesh.position.copy(vector(position)); + group.add(mesh); + return mesh; +} +export function line(group: THREE.Group, points: Point[], colour: string, opacity = 1): THREE.Line { + const mesh = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points.map(vector)), new THREE.LineBasicMaterial({ color: colour, transparent: opacity < 1, opacity })); + group.add(mesh); + return mesh; +} +export function arrow(group: THREE.Group, origin: Point, direction: Point, length: number, colour = colours.field): void { + const dir = vector(direction); + if (dir.lengthSq() < 1e-24) return; + group.add(new THREE.ArrowHelper(dir.normalize(), vector(origin), length, colour, Math.min(0.16, length * 0.3), Math.min(0.1, length * 0.2))); +} +export function label(group: THREE.Group, text: string, position: Point, colour: string = '#dbe7f0', width = 2): THREE.Sprite { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d')!; + context.font = '500 44px system-ui, sans-serif'; + canvas.width = Math.ceil(context.measureText(text).width) + 36; + canvas.height = 80; + context.fillStyle = 'rgba(16, 24, 34, 0.85)'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.font = '500 44px system-ui, sans-serif'; + context.fillStyle = colour; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText(text, canvas.width / 2, 40); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false })); + sprite.position.copy(vector(position)); + const aspect = canvas.width / canvas.height; + const screenHeight = 23 + Math.min(2, Math.max(-1, width - 2)) * 2; + const viewportSize = new THREE.Vector2(); + const cameraPosition = new THREE.Vector3(); + // Keep labels legible when the camera or viewport changes. Width hints + // affect text size slightly; the actual texture fits the text content. + sprite.onBeforeRender = (renderer, _scene, camera) => { + if (!(camera instanceof THREE.PerspectiveCamera)) return; + sprite.getWorldPosition(cameraPosition).applyMatrix4(camera.matrixWorldInverse); + renderer.getSize(viewportSize); + const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * screenHeight / Math.max(1, viewportSize.y); + sprite.scale.set(worldHeight * aspect, worldHeight, 1); + sprite.updateMatrixWorld(); + }; + group.add(sprite); + return sprite; +} +export function grid(group: THREE.Group, y = -0.25) { + const helper = new THREE.GridHelper(12, 12, 0x375066, 0x22313f); + helper.position.y = y; + group.add(helper); +} + +/** Dispose shared geometries/materials once per scene replacement, including + * textures owned by generated labels. No GPU resources survive a lesson. */ +export function disposeGroup(group: THREE.Group) { + const geometries = new Set<THREE.BufferGeometry>(); + const materials = new Set<THREE.Material>(); + const textures = new Set<THREE.Texture>(); + group.traverse(object => { + const mesh = object as THREE.Mesh; + if (mesh.geometry) geometries.add(mesh.geometry); + if (mesh.material) { + const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const material of list) { + materials.add(material); + const map = (material as THREE.MeshBasicMaterial).map; + if (map) textures.add(map); + } + } + }); + geometries.forEach(geometry => geometry.dispose()); + materials.forEach(material => material.dispose()); + textures.forEach(texture => texture.dispose()); + group.clear(); +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..a1d6ced --- /dev/null +++ b/src/style.css @@ -0,0 +1,104 @@ +:root { + font-family: Inter, 'Segoe UI', system-ui, sans-serif; + color: #dbe7f0; + background: #101822; + font-synthesis: none; + color-scheme: dark; + --mint: #91e2cb; + --muted: #98adbf; + --border: #2a394a; +} +* { box-sizing: border-box; } +body { margin: 0; } +button, select, input { font: inherit; } +button, select { color: inherit; } +button, a, select, input { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +a { color: var(--mint); text-underline-offset: 4px; } +button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, canvas:focus-visible, summary:focus-visible { outline: 2px solid var(--mint); outline-offset: 4px; } +.app-header { min-height: 96px; display: flex; align-items: center; gap: 22px; padding: 20px 32px; border-bottom: 1px solid var(--border); background: #121d29; } +.brand { flex-shrink: 0; font: 700 19px monospace; text-decoration: none; color: var(--mint); border: 1px solid #3c7069; border-radius: 9px; padding: 15px 12px; } +.brand span { color: #516a79; padding: 0 3px; } +h1 { font-size: 19px; font-weight: 600; margin: 0; } +.header-title p { margin: 6px 0 0; color: var(--muted); font-size: 13px; } +.status { margin-left: auto; color: var(--mint); font: 10px monospace; letter-spacing: 1px; white-space: nowrap; display: flex; align-items: center; gap: 8px; } +.status i { background: var(--mint); width: 6px; height: 6px; border-radius: 50%; } +.subjects { background: #121d29; border-bottom: 1px solid var(--border); overflow-x: auto; padding: 0 24px; } +[role='tablist'] { display: flex; min-width: max-content; } +[role='tab'] { display: flex; align-items: center; gap: 9px; background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 20px 13px; font-size: 12px; color: var(--muted); white-space: nowrap; } +[role='tab'] span { color: #71899d; font: 10px monospace; } +[role='tab']:hover { background: #1b2b3a; color: #dbe7f0; } +[role='tab'][aria-selected='true'] { color: var(--mint); border-bottom-color: var(--mint); background: #192b32; } +[role='tab'][aria-selected='true'] span { color: var(--mint); } +main { display: grid; grid-template-columns: 310px minmax(0, 1fr); min-height: calc(100dvh - 153px); } +.controls-panel { padding: 28px 25px; border-right: 1px solid var(--border); background: #15212e; } +.eyebrow { font: 10px monospace; color: var(--mint); letter-spacing: 2px; margin: 0 0 14px; } +h2 { font-size: 26px; font-weight: 550; letter-spacing: -0.5px; line-height: 1.25; margin: 0 0 15px; } +p { line-height: 1.7; } +.description { color: var(--muted); font-size: 13px; margin: 0 0 30px; } +.parameter { margin: 24px 0; } +.parameter-label { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +label { font-size: 12px; color: #c2d0dd; } +output { flex-shrink: 0; color: var(--mint); font: 12px monospace; } +input[type='range'] { display: block; width: 100%; accent-color: var(--mint); margin: 14px 0 0; cursor: pointer; } +input:disabled { opacity: 0.35; cursor: default; } +select { display: block; width: 100%; padding: 10px 8px; border: 1px solid #3b4d60; border-radius: 6px; background: #1c2c3d; margin-top: 10px; font-size: 12px; } +.panel-actions button { width: 100%; border: 1px solid #3b4d60; border-radius: 6px; background: #1c2c3d; padding: 11px; font-size: 12px; } +button:hover { border-color: var(--mint); } +.measurements { margin-top: 25px; border: 1px solid #2a4252; border-radius: 8px; padding: 4px 16px; background: #101c27; } +.measurement { padding: 14px 0; } +.measurement + .measurement { border-top: 1px solid #243342; } +.measurement > span { display: block; font-size: 11px; color: var(--muted); } +.measurement > div { display: flex; align-items: baseline; gap: 8px; margin-top: 6px; } +.measurement strong { font: 23px monospace; color: var(--mint); } +.measurement small { font-size: 11px; color: var(--muted); } +.workbench { min-width: 0; } +#viewport { position: relative; height: clamp(480px, 64dvh, 800px); min-width: 0; overflow: hidden; } +#viewport > canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; } +.scene-toolbar { position: absolute; z-index: 1; top: 20px; left: 24px; right: 24px; display: flex; justify-content: space-between; align-items: center; gap: 10px; pointer-events: none; } +.view-tag { font: 10px monospace; color: #71899d; letter-spacing: 2px; } +.scene-toolbar > div { display: flex; gap: 8px; } +.scene-toolbar button { pointer-events: auto; background: #15212ee8; border: 1px solid #34495c; border-radius: 5px; padding: 7px 10px; font-size: 11px; } +.legend { position: absolute; z-index: 1; pointer-events: none; left: 24px; top: 64px; display: flex; flex-wrap: wrap; gap: 8px 18px; right: 24px; } +.legend span { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; } +.legend i { display: inline-block; width: 5px; height: 5px; border-radius: 50%; } +.scene-footer { position: absolute; z-index: 1; pointer-events: none; bottom: 0; padding: 24px 24px 12px; width: 100%; background: linear-gradient(transparent, #101822ed); } +.scene-footer p { margin: 3px 0; color: #b3c8d7; font-size: 11px; line-height: 1.6; } +.scene-footer .hint { color: #7890a4; font-size: 10px; } +.learning-panel { display: grid; grid-template-columns: 1.3fr 1fr; gap: 36px; padding: 28px 32px; border-top: 1px solid var(--border); background: #121d29; } +.learning-panel p { font-size: 13px; color: var(--muted); margin: 12px 0; } +.learning-panel .eyebrow { color: var(--mint); font-size: 10px; margin: 0 0 14px; } +.equations p { color: #e3ecf3; font: 16px ui-monospace, monospace; line-height: 1.8; margin: 4px 0; } +.learning-panel a { font-size: 11px; } +.challenge { border-left: 1px solid var(--border); padding-left: 30px; } +.challenge > p:not(.eyebrow) { color: #cad8e4; } +details { margin-top: 20px; } +summary { color: var(--muted); font-size: 11px; cursor: pointer; } +details p { font-size: 11px !important; } +.webgl-error { position: absolute; z-index: 2; top: 140px; left: 12%; right: 12%; padding: 24px; background: #1e2937; border: 1px solid #ff967b; border-radius: 8px; color: #ffb79f; font-size: 14px; line-height: 1.7; } +@media (min-width: 1600px) { main { grid-template-columns: 340px minmax(0, 1fr); } .controls-panel { padding: 32px; } } +@media (max-width: 1100px) { .app-header { padding: 16px 24px; } .subjects { padding: 0 12px; } [role='tab'] { padding: 18px 12px; } main { grid-template-columns: 280px minmax(0, 1fr); } .controls-panel { padding: 24px 20px; } .learning-panel { grid-template-columns: 1fr; gap: 24px; padding: 25px; } .challenge { border-left: 0; border-top: 1px solid var(--border); padding: 22px 0 0; } } +@media (max-width: 760px) { + .app-header { padding: 16px; gap: 13px; min-height: 88px; } + .brand { font-size: 15px; padding: 12px 8px; } + h1 { font-size: 15px; } + .header-title p { font-size: 10px; } + .status { display: none; } + main { display: flex; flex-direction: column; } + .controls-panel { border-right: 0; border-bottom: 1px solid var(--border); padding: 24px; } + h2 { font-size: 25px; } + #parameters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; } + .parameter { min-width: 0; margin: 10px 0 15px; } + .parameter-label { flex-wrap: wrap; gap: 6px; } + .measurements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 18px; } + .measurement { min-width: 0; } + .measurement strong { font-size: 21px; overflow-wrap: anywhere; } + .measurement > div { flex-wrap: wrap; gap: 4px; } + .measurement:nth-child(2) { border-top: 0; } + #viewport { height: 520px; } + .scene-toolbar { top: 18px; left: 16px; right: 16px; } + .scene-toolbar button { font-size: 10px; padding: 7px; } + .legend { left: 16px; right: 16px; } + .scene-footer { padding: 20px 16px 12px; } + .learning-panel { padding: 24px; } +} |