'use strict'; /* ══════════════════════════════════════════════════════════════ OpticsBenchSim — unified optical bench simulation Merges: ThinLensSim (thinlens.js) + MirrorSim (mirror.js) + RefractionSim (refraction.js) Modes: 'lens' — thin lens: 1/f = 1/d + 1/d', M = -d'/d 'mirror' — curved / flat mirrors, same formula 'refraction'— Snell's law: n₁sin θ₁ = n₂sin θ₂, TIR, dispersion Physics preserved verbatim from original sims. ══════════════════════════════════════════════════════════════ */ /* ───────────────────────────────────────────────────────────── 0. WAVELENGTH UTILITIES ───────────────────────────────────────────────────────────────*/ /** * Convert wavelength (nm, 380–780) to an sRGB CSS color string. * Well-known approximation by Dan Bruton (adjusted for alpha at edges). */ function wavelengthToRGB(nm) { let R = 0, G = 0, B = 0, a = 1; if (nm >= 380 && nm < 440) { R = -(nm - 440) / (440 - 380); G = 0; B = 1; } else if (nm < 490) { R = 0; G = (nm - 440) / (490 - 440); B = 1; } else if (nm < 510) { R = 0; G = 1; B = -(nm - 510) / (510 - 490); } else if (nm < 580) { R = (nm - 510) / (580 - 510); G = 1; B = 0; } else if (nm < 645) { R = 1; G = -(nm - 645) / (645 - 580); B = 0; } else if (nm < 781) { R = 1; G = 0; B = 0; } if (nm >= 700) a = 0.3 + 0.7 * (780 - nm) / (780 - 700); else if (nm < 420) a = 0.3 + 0.7 * (nm - 380) / (420 - 380); return `rgba(${Math.round(R * 255 * a)},${Math.round(G * 255 * a)},${Math.round(B * 255 * a)},1)`; } /** * Wavelength-dependent index of refraction — simple linear model. * n(550nm) = n0 (glass baseline), with normal dispersion (blue > red). * Formula: n(λ) = n0 - 0.0002*(λ - 550) */ function _nAtWavelength(n0, nm) { return n0 - 0.0002 * (nm - 550); } /** * Return the ray color for the current global wavelength setting. * If white-light mode: returns null (caller must draw multi-spectral bundles). * Otherwise returns a CSS color string. */ function _obRayColor(fallback) { if (window._obWhiteLight) return null; return wavelengthToRGB(window._obWavelength || 550); } /** Spectral samples used for white-light / prism dispersion */ const OB_SPECTRAL = [ { nm: 405, label: 'violet' }, { nm: 450, label: 'blue' }, { nm: 510, label: 'green' }, { nm: 550, label: 'yellow' }, { nm: 610, label: 'orange' }, { nm: 660, label: 'red' }, ]; /* ───────────────────────────────────────────────────────────── 0b. OB_FX — VISUAL DEPTH TOGGLES (A-agent) Five optional visual layers controlled by checkboxes. State persisted in localStorage as 'ob_fx_state'. ───────────────────────────────────────────────────────────────*/ function _obFXLoad() { try { const raw = localStorage.getItem('ob_fx_state'); if (raw) Object.assign(window.OB_FX, JSON.parse(raw)); } catch (_e) { /* ignore */ } } function _obFXSave() { try { localStorage.setItem('ob_fx_state', JSON.stringify(window.OB_FX)); } catch (_e) { /* ignore */ } } /** Called by each toggle checkbox */ function obFXToggle(key, val) { window.OB_FX[key] = !!val; _obFXSave(); _obRedraw(); } window.OB_FX = { wavefronts: false, mist: false, flare: false, huygens: false, caustics: false }; _obFXLoad(); /* ── Mist: background smoke-particle emitter ── */ let _obMistRaf = 0, _obMistCtx = null, _obMistFrame = 0; function _obMistTick() { if (!window.OB_FX.mist) { _obMistRaf = 0; return; } const ctx = _obMistCtx; if (!ctx || !window.LabFX) { _obMistRaf = 0; return; } _obMistFrame++; if (_obMistFrame % 2 === 0) { const W = ctx.canvas.width, H = ctx.canvas.height; LabFX.particles.emit({ ctx, x: Math.random() * W, y: Math.random() * H, count: 1, color: 'rgba(180,200,255,0.06)', speed: 3, spread: Math.PI * 2, life: 3000, shape: 'smoke', size: 30, glow: false, }); } _obMistRaf = requestAnimationFrame(_obMistTick); } function _obStartMist(ctx) { _obMistCtx = ctx; if (!_obMistRaf && window.OB_FX.mist && window.LabFX) { _obMistRaf = requestAnimationFrame(_obMistTick); } } /* ── Shared wavefront phase clock ── */ let _obWFPhase = 0, _obWFLastT = 0; /** * Draw animated sinusoidal wavefront ticks along a ray segment. * Ticks march along the ray to simulate a travelling wave. * In-glass spacing compresses by 1/nMed (shows refraction at boundary). */ function _obDrawWavefrontTicks(ctx, x1, y1, x2, y2, nm, nMed) { nMed = nMed || 1; const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy); if (len < 4) return; const lambdaPx = Math.max(8, (nm * 0.45) / nMed); const ux = dx / len, uy = dy / len; const qx = -uy, qy = ux; // perpendicular unit vector const tickLen = 5; const col = wavelengthToRGB(nm || 550); const phaseOff = (_obWFPhase * lambdaPx * 2) % lambdaPx; ctx.save(); ctx.globalAlpha = 0.52; ctx.strokeStyle = col; ctx.lineWidth = 1; let t = phaseOff; while (t < len) { const cx = x1 + ux * t, cy = y1 + uy * t; ctx.beginPath(); ctx.moveTo(cx - qx * tickLen, cy - qy * tickLen); ctx.lineTo(cx + qx * tickLen, cy + qy * tickLen); ctx.stroke(); t += lambdaPx; } ctx.restore(); } /** * Draw lens flare: starburst + ghost reflections + chromatic ring. * Uses additive blending for the glow effect. */ function _obDrawLensFlare(ctx, srcX, srcY, W, H) { ctx.save(); ctx.globalCompositeOperation = 'lighter'; const spikes = 6, spikeLen = 40; for (let i = 0; i < spikes; i++) { const angle = (i / spikes) * Math.PI + _obWFPhase * 0.3; const grad = ctx.createLinearGradient(srcX, srcY, srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen); grad.addColorStop(0, 'rgba(255,255,200,0.32)'); grad.addColorStop(1, 'rgba(255,255,200,0)'); ctx.strokeStyle = grad; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(srcX, srcY); ctx.lineTo(srcX + Math.cos(angle) * spikeLen, srcY + Math.sin(angle) * spikeLen); ctx.stroke(); ctx.beginPath(); ctx.moveTo(srcX, srcY); ctx.lineTo(srcX - Math.cos(angle) * spikeLen, srcY - Math.sin(angle) * spikeLen); ctx.stroke(); } const cg = ctx.createRadialGradient(srcX, srcY, 0, srcX, srcY, 14); cg.addColorStop(0, 'rgba(255,255,240,0.55)'); cg.addColorStop(1, 'rgba(255,255,240,0)'); ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(srcX, srcY, 14, 0, Math.PI * 2); ctx.fill(); const gx = W / 2 - srcX, gy = H / 2 - srcY; [ { t: 0.3, r: 18, col: 'rgba(100,80,255,0.12)' }, { t: 0.6, r: 11, col: 'rgba(255,60,60,0.10)' }, { t: 1.1, r: 7, col: 'rgba(60,220,255,0.09)' }, ].forEach(function(g) { const gX = srcX + gx * g.t, gY = srcY + gy * g.t; const gg = ctx.createRadialGradient(gX, gY, 0, gX, gY, g.r); gg.addColorStop(0, g.col); gg.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(gX, gY, g.r, 0, Math.PI * 2); ctx.fill(); }); ctx.globalAlpha = 0.07 + 0.04 * Math.sin(_obWFPhase); ctx.strokeStyle = 'rgba(200,150,255,0.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(srcX, srcY, 22 + 4 * Math.sin(_obWFPhase * 0.5), 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } /** * Draw Huygens wavelets at a refraction/reflection boundary. * 5 sample points along wavefront emit expanding semi-circular arcs. */ function _obDrawHuygens(ctx, bx, by, normalAngle, kind) { const t = _obWFPhase % 1; const maxR = 28, nSamples = 5; ctx.save(); ctx.globalAlpha = 0.22; for (let i = 0; i < nSamples; i++) { const offset = (i - (nSamples - 1) / 2) * 12; const wx = bx + Math.cos(normalAngle + Math.PI / 2) * offset; const wy = by + Math.sin(normalAngle + Math.PI / 2) * offset; const r = t * maxR; const sa = kind === 'reflection' ? normalAngle - Math.PI / 2 : normalAngle + Math.PI / 2; ctx.strokeStyle = 'rgba(180,220,255,0.7)'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.arc(wx, wy, r, sa - Math.PI / 2, sa + Math.PI / 2); ctx.stroke(); } ctx.restore(); } /** * Draw caustic curve under a lens by tracing ~20 parallel rays from infinity. * Each ray is slightly shifted by spherical aberration creating an extended caustic. */ function _obDrawCausticCurve(ctx, lx, ay, f, H) { if (!isFinite(f) || f <= 0) return; const lensH = Math.min(H * 0.38, 140); const nRays = 20; const pts = []; for (let i = 0; i < nRays; i++) { const yIn = -lensH + (2 * lensH * i) / (nRays - 1); const abFac = 1 - 0.0008 * (yIn / lensH) * (yIn / lensH) * Math.min(Math.abs(f), 150); const fEff = f * abFac; const sl = -yIn / fEff; if (Math.abs(sl) < 0.0001) continue; const tCross = -yIn / sl; if (tCross < 0 || tCross > 600) continue; pts.push({ x: lx + tCross, y: ay }); } if (pts.length < 2) return; const pulse = 0.55 + 0.35 * Math.sin(_obWFPhase * Math.PI * 2); ctx.save(); ctx.globalAlpha = pulse; ctx.globalCompositeOperation = 'lighter'; ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2; ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 6; ctx.beginPath(); pts.forEach(function(p, idx) { idx === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y); }); ctx.stroke(); ctx.restore(); } /** * Unified OB_FX layer — called at end of each sim draw() BEFORE particles.draw(). * @param {CanvasRenderingContext2D} ctx * @param {string} simType 'lens'|'mirror'|'refraction'|'prism'|'freebuild' * @param {object} [extras] {rays[], srcX, srcY, boundary, causticParams} */ function _drawOBFXLayer(ctx, simType, extras) { const now = performance.now(); if (_obWFLastT) _obWFPhase += (now - _obWFLastT) / 1000 * 0.5; _obWFLastT = now; if (window.OB_FX.mist) _obStartMist(ctx); if (!extras) return; if (window.OB_FX.wavefronts && extras.rays) { const nm = window._obWavelength || 550; extras.rays.forEach(function(r) { _obDrawWavefrontTicks(ctx, r.x1, r.y1, r.x2, r.y2, nm, r.n || 1); }); } if (window.OB_FX.flare && extras.srcX !== undefined) { _obDrawLensFlare(ctx, extras.srcX, extras.srcY, ctx.canvas.width, ctx.canvas.height); } if (window.OB_FX.huygens && extras.boundary) { const b = extras.boundary; _obDrawHuygens(ctx, b.bx, b.by, b.normalAngle, b.kind); } if (window.OB_FX.caustics && extras.causticParams) { const cp = extras.causticParams; _obDrawCausticCurve(ctx, cp.lx, cp.ay, cp.f, ctx.canvas.height); } } /* ───────────────────────────────────────────────────────────── 1. THIN LENS ENGINE (from thinlens.js) ───────────────────────────────────────────────────────────────*/ class ThinLensSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.f = 100; this.d = 200; this.h = 50; this._drag = null; this.onUpdate = null; /* aberration toggles (Agent OB-A3) */ this._aberrSpherical = false; this._aberrChromatic = false; /* ── Lens-maker formula params (Feature 3) ── */ this._lmSimple = true; // true = simple f-slider mode; false = R1/R2/n mode this._lmR1 = 100; // front surface radius (mm) this._lmR2 = -100; // back surface radius (mm) this._lmN = 1.5; // refractive index /* ── Animated ray construction (Feature 1) ── */ this._rayAnimT = [1, 1, 1]; // progress of each of the 3 rays [0..1] this._rayTweens = []; // active tween handles this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } getParams() { return { f: this.f, d: this.d, h: this.h }; } setParams({ f, d, h } = {}) { if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f)); if (d !== undefined) this.d = Math.max(30, Math.min(400, +d)); if (h !== undefined) this.h = Math.max(20, Math.min(80, +h)); this.draw(); this._emit(); } reset() { this.f = 100; this.d = 200; this.h = 50; this.draw(); this._emit(); } /* toggle aberration modes (Agent OB-A3) */ setAberration(type, on) { if (type === 'spherical') this._aberrSpherical = !!on; if (type === 'chromatic') this._aberrChromatic = !!on; this.draw(); this._emit(); } /* ═══ Lens-maker formula (Feature 3) ════════════════════════════ */ _lmCalcF() { const R1 = this._lmR1, R2 = this._lmR2, n = this._lmN; const inv1 = Math.abs(R1) > 0.5 ? 1 / R1 : 0; const inv2 = Math.abs(R2) > 0.5 ? 1 / R2 : 0; const invF = (n - 1) * (inv1 - inv2); if (Math.abs(invF) < 1e-6) return 9999; return 1 / invF; } setLensMode(simple) { this._lmSimple = !!simple; if (!simple) { const k = this.f * (this._lmN - 1); if (Math.abs(k) > 1) { this._lmR1 = k * 2; this._lmR2 = -k * 2; } } this.draw(); this._emit(); } setLMParam(name, val) { const v = +val; if (name === 'R1') this._lmR1 = Math.max(-300, Math.min(300, v)); else if (name === 'R2') this._lmR2 = Math.max(-300, Math.min(300, v)); else if (name === 'n') this._lmN = Math.max(1.3, Math.min(2.4, v)); if (!this._lmSimple) this.f = Math.max(-200, Math.min(200, this._lmCalcF())); this.draw(); this._emit(); } _lmShapeName() { const R1 = this._lmR1, R2 = this._lmR2; const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280; if (flat1 && flat2) return 'плоскопараллельная'; if (R1 > 0 && R2 < 0) return 'двояковыпуклая'; if (R1 < 0 && R2 > 0) return 'двояковогнутая'; if (flat1 || flat2) return 'плоско-выпуклая / вогнутая'; return 'мениск'; } _drawLensLM(ctx, lx, ay) { const lensH = Math.min(this.H * 0.38, 140); const R1 = this._lmR1, R2 = this._lmR2; const flat1 = Math.abs(R1) > 280, flat2 = Math.abs(R2) > 280; const b1 = flat1 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * R1))); const b2 = flat2 ? 0 : Math.max(-20, Math.min(20, lensH * lensH / (2 * -R2))); ctx.strokeStyle = 'rgba(155,93,229,0.85)'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.quadraticCurveTo(lx + b1 * 2, ay, lx + b1, ay + lensH); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx + b2, ay - lensH); ctx.quadraticCurveTo(lx + b2 * 2, ay, lx + b2, ay + lensH); ctx.stroke(); ctx.strokeStyle = 'rgba(155,93,229,0.4)'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(lx + b1, ay - lensH); ctx.lineTo(lx + b2, ay - lensH); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx + b1, ay + lensH); ctx.lineTo(lx + b2, ay + lensH); ctx.stroke(); ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke(); if (this.f > 0) { this._lensArrow(ctx, lx, ay - lensH, -1); this._lensArrow(ctx, lx, ay + lensH, 1); } else { this._lensArrowDiv(ctx, lx, ay - lensH, -1); this._lensArrowDiv(ctx, lx, ay + lensH, 1); } } _drawLMInfo(ctx, lx, ay) { if (this._lmSimple) return; const f = this.f, D = Math.abs(f) > 0.5 ? (1000 / f).toFixed(2) : '---'; const n = this._lmN.toFixed(2); const R1s = Math.abs(this._lmR1) > 280 ? 'inf' : this._lmR1.toFixed(0); const R2s = Math.abs(this._lmR2) > 280 ? 'inf' : this._lmR2.toFixed(0); const shape = this._lmShapeName(); const bx = lx + 20, by = 12, bw = 200, bh = 72; ctx.fillStyle = 'rgba(13,13,26,0.88)'; ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill(); ctx.strokeStyle = 'rgba(155,93,229,0.3)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.stroke(); ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = 'rgba(155,93,229,0.9)'; ctx.fillText('Lensmaker', bx + 8, by + 7); ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText('R1=' + R1s + ' R2=' + R2s + ' n=' + n, bx + 8, by + 22); ctx.fillStyle = '#06D6E0'; ctx.fillText('f = ' + (isFinite(f) ? f.toFixed(1) : '---') + ' mm', bx + 8, by + 38); ctx.fillStyle = '#FFD166'; ctx.fillText('D = ' + D + ' dptr', bx + 104, by + 38); ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText(shape, bx + 8, by + 54); } /* ═══ Animated 3-ray construction (Feature 1) ═══════════════════ */ buildRays() { this._rayTweens.forEach(h => h && h.cancel && h.cancel()); this._rayTweens = []; this._rayAnimT = [0, 0, 0]; const animate = (idx, onDone) => { if (!window.LabFX) { this._rayAnimT[idx] = 1; this.draw(); if (onDone) onDone(); return; } const h = LabFX.motion.tween(0, 1, 500, 'easeOutCubic', t => { this._rayAnimT[idx] = t; this.draw(); }, onDone); this._rayTweens[idx] = h; }; animate(0, () => animate(1, () => animate(2, () => { if (window.LabFX) LabFX.sound.play('chime'); }))); } resetRays() { this._rayTweens.forEach(h => h && h.cancel && h.cancel()); this._rayTweens = []; this._rayAnimT = [1, 1, 1]; this.draw(); } info() { const { f, d, h } = this; const denom = d - f; const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom; const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d; const hPrime = M === Infinity ? Infinity : M * h; const isVirtual = dPrime < 0; return { f: +f.toFixed(1), d: +d.toFixed(1), dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1), M: M === Infinity ? Infinity : +M.toFixed(3), imageType: isVirtual ? 'мнимое' : 'действительное', h: +h.toFixed(1), hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1), }; } _emit() { if (this.onUpdate) this.onUpdate(this.info()); } _toCanvas(sx, sy) { return { cx: this.W / 2 + sx, cy: this.H / 2 - sy }; } _fromCanvas(cx, cy) { return { sx: cx - this.W / 2, sy: this.H / 2 - cy }; } draw() { const ctx = this.ctx, W = this.W, H = this.H; if (!W || !H) return; const { f, d, h } = this; const lensX = W / 2; const axisY = H / 2; ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke(); ctx.setLineDash([]); // Lens silhouette: detailed (LM) or simple mode if (!this._lmSimple) this._drawLensLM(ctx, lensX, axisY); else this._drawLens(ctx, lensX, axisY, f); this._drawFocalPoints(ctx, lensX, axisY, f); const objX = lensX - d; this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false); const denom = d - f; let dPrime, hPrime; if (Math.abs(denom) < 0.5) { dPrime = null; hPrime = null; } else { dPrime = (f * d) / denom; hPrime = (-dPrime / d) * h; } /* aberrations override standard rays (Agent OB-A3) */ if (this._aberrSpherical) { this._drawSphericalAberration(ctx, lensX, axisY, d, h, f); } else if (this._aberrChromatic) { this._drawChromaticAberration(ctx, lensX, axisY, d, h, f); } else { this._drawRaysAnimated(ctx, lensX, axisY, d, h, f, dPrime, hPrime); } if (!this._aberrSpherical && !this._aberrChromatic && dPrime !== null && isFinite(dPrime)) { const isVirtual = dPrime < 0; const imgX = lensX + dPrime; // Feature 1: real=cyan, virtual=pink/dashed this._drawArrow(ctx, imgX, axisY, imgX, axisY - hPrime, isVirtual ? 'rgba(255,133,162,0.9)' : '#06D6E0', isVirtual); } this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime); this._drawArrowLabels(ctx, lensX, axisY, d, h, dPrime, hPrime); this._drawLMInfo(ctx, lensX, axisY); // Lens caustics: emit dust near focal point when image exists (only when OB_FX.caustics is off) if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) { const imgX = lensX + dPrime; if (!this._causticFrame) this._causticFrame = 0; this._causticFrame++; if (this._causticFrame % 4 === 0) { LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true }); } } // OB_FX visual depth layer { const objX = lensX - d; const fxExtras = { srcX: objX, srcY: axisY - h, causticParams: { lx: lensX, ay: axisY, f }, rays: [ { x1: objX, y1: axisY - h, x2: lensX, y2: axisY - h }, { x1: lensX, y1: axisY - h, x2: lensX + (dPrime || 200), y2: dPrime ? axisY - ((-dPrime / d) * h) : axisY }, ], }; _drawOBFXLayer(ctx, 'lens', fxExtras); } if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); } } _drawLens(ctx, lx, ay, f) { const lensH = Math.min(this.H * 0.38, 140); const converging = f > 0; ctx.strokeStyle = 'rgba(155,93,229,0.8)'; ctx.lineWidth = 2.5; if (converging) { const bulge = Math.min(18, Math.abs(f) * 0.12); ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke(); this._lensArrow(ctx, lx, ay - lensH, -1); this._lensArrow(ctx, lx, ay + lensH, 1); } else { const bulge = Math.min(14, Math.abs(f) * 0.1); ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke(); this._lensArrowDiv(ctx, lx, ay - lensH, -1); this._lensArrowDiv(ctx, lx, ay + lensH, 1); } ctx.strokeStyle = 'rgba(155,93,229,0.3)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke(); } _lensArrow(ctx, x, y, dir) { const sz = 7; ctx.fillStyle = 'rgba(155,93,229,0.8)'; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sz, y + dir * sz * 1.2); ctx.lineTo(x + sz, y + dir * sz * 1.2); ctx.closePath(); ctx.fill(); } _lensArrowDiv(ctx, x, y, dir) { const sz = 6; ctx.fillStyle = 'rgba(155,93,229,0.8)'; ctx.beginPath(); ctx.moveTo(x - sz, y); ctx.lineTo(x, y - dir * sz); ctx.lineTo(x + sz, y); ctx.closePath(); ctx.fill(); } _drawFocalPoints(ctx, lx, ay, f) { const pts = [{ sx: f, label: "F'" }, { sx: -f, label: 'F' }, { sx: 2 * f, label: "2F'" }, { sx: -2 * f, label: '2F' }]; for (const p of pts) { const px = lx + p.sx; if (px < 10 || px > this.W - 10) continue; const isFocal = !p.label.startsWith('2'); const r = isFocal ? 5 : 3.5; const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)'; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(p.label, px, ay + 10); } } _drawArrow(ctx, x1, y1, x2, y2, color, dashed) { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5; if (dashed) ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); if (dashed) ctx.setLineDash([]); const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 10; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35)); ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35)); ctx.closePath(); ctx.fill(); } _drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) { // Wavelength-aware coloring const _wl = window._obWhiteLight; const _wlNm = window._obWavelength || 550; if (_wl) { // White-light mode: draw 3 spectral bundles (R/G/B) with offset for chromatic spread const bundles = [{ nm: 660, dOff: 0 }, { nm: 550, dOff: 0 }, { nm: 450, dOff: 0 }]; const n0 = 1.5; // default glass bundles.forEach(b => { const nc = _nAtWavelength(n0, b.nm); // focal length shifts with n — thin lens: f ~ (n-1)*C → f(λ) = f * (n0-1)/(nc-1) const fC = f * (n0 - 1) / (nc - 1); const denomC = d - fC; const dPC = Math.abs(denomC) < 0.5 ? null : (fC * d) / denomC; const hPC = dPC !== null ? (-dPC / d) * h : null; const col = wavelengthToRGB(b.nm); ctx.save(); ctx.globalAlpha = 0.65; this._drawRaysSingle(ctx, lx, ay, d, h, fC, dPC, hPC, col); ctx.restore(); }); return; } const _monoColor = wavelengthToRGB(_wlNm); const objX = lx - d, objY = ay - h; const colors = [_monoColor, _monoColor, _monoColor]; const hasImage = dPrime !== null && isFinite(dPrime); const isVirtual = hasImage && dPrime < 0; ctx.lineWidth = 1.5; const _doGlow = (color, fn) => { if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 }); else fn(); }; // Ray 1: parallel to axis _doGlow(colors[0], () => { ctx.strokeStyle = colors[0]; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke(); if (hasImage) { const imgX = lx + dPrime, imgY = ay - hPrime; if (!isVirtual) { ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke(); this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]); } else { const outSlope = (objY - ay) / f; ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke(); ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke(); ctx.setLineDash([]); } } }); // Ray 2: through center _doGlow(colors[1], () => { ctx.strokeStyle = colors[1]; ctx.setLineDash([]); const slope = (objY - ay) / (objX - lx); const farX = lx + 350, farY = ay + slope * 350; ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke(); if (isVirtual) { const backX = lx - 350, backY = ay - slope * 350; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke(); ctx.setLineDash([]); } }); // Ray 3: through F _doGlow(colors[2], () => { ctx.strokeStyle = colors[2]; ctx.setLineDash([]); const fx = lx - f, slope = (objY - ay) / (objX - fx); const hitY = objY + slope * (lx - objX); ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke(); const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300; ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke(); if (hasImage && isVirtual) { ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke(); ctx.setLineDash([]); } }); } /** Single-color version of _drawRays used internally for white-light chromatic bundles */ _drawRaysSingle(ctx, lx, ay, d, h, f, dPrime, hPrime, color) { const objX = lx - d, objY = ay - h; const hasImage = dPrime !== null && isFinite(dPrime); const isVirtual = hasImage && dPrime < 0; ctx.lineWidth = 1.5; ctx.strokeStyle = color; ctx.setLineDash([]); // Ray 1: parallel to axis ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke(); if (hasImage) { const imgX = lx + dPrime, imgY = ay - hPrime; if (!isVirtual) { ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke(); } else { const outSlope = (objY - ay) / f; ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 200, objY + outSlope * 200); ctx.stroke(); } } // Ray 2: through center const slope2 = (objY - ay) / (objX - lx); ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx + 250, ay + slope2 * 250); ctx.stroke(); // Ray 3: through F (brief version) const fxOff = lx - f, slope3 = (objY - ay) / (objX - fxOff); const hitY3 = objY + slope3 * (lx - objX); ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY3); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx, hitY3); ctx.lineTo(lx + 250, hitY3); ctx.stroke(); ctx.setLineDash([]); } _extendRay(ctx, x1, y1, x2, y2, color) { const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy); if (len < 1) return; ctx.globalAlpha = 0.3; ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 + (dx / len) * 80, y2 + (dy / len) * 80); ctx.stroke(); ctx.globalAlpha = 1; } /* ── Spherical aberration (Agent OB-A3) ────────────────────── 5 parallel rays at different heights; f_eff(h) = f - h²/(2f). Marginal rays focus closer than paraxial for a converging lens. ─────────────────────────────────────────────────────────────── */ _drawSphericalAberration(ctx, lx, ay, d, h, f) { if (Math.abs(f) < 1) return; const lensH = Math.min(this.H * 0.36, 130); const fracs = [-0.9, -0.5, 0, 0.5, 0.9]; const pal = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF']; const objX = lx - d; ctx.save(); ctx.lineWidth = 1.4; const focusPts = []; fracs.forEach((fr, i) => { const rayH = fr * lensH; const fEff = f > 0 ? f - (rayH * rayH) / (2 * f) : f + (rayH * rayH) / (2 * Math.abs(f)); const denom = d - fEff; if (Math.abs(denom) < 0.5) return; const dPrEff = (fEff * d) / denom; ctx.strokeStyle = pal[i]; ctx.globalAlpha = 0.8; ctx.setLineDash([]); const startY = ay - rayH; ctx.beginPath(); ctx.moveTo(objX, startY); ctx.lineTo(lx, startY); ctx.stroke(); if (f > 0 && isFinite(dPrEff) && dPrEff > 0) { const focX = lx + dPrEff; ctx.beginPath(); ctx.moveTo(lx, startY); ctx.lineTo(focX, ay); ctx.lineTo(focX + 70, ay + (startY - ay) * 0.5); ctx.stroke(); focusPts.push({ fEff, label: i === 2 ? 'парак.' : 'краев.' }); } else { ctx.beginPath(); ctx.moveTo(lx, startY); ctx.lineTo(lx + 280, startY + (ay - startY) * 0.6); ctx.stroke(); } }); ctx.globalAlpha = 1; ctx.restore(); if (focusPts.length >= 2) { const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0); const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0); ctx.save(); const bx = 12, by = 70; ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация', bx + 8, by + 6); ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText('f парак.=' + fMax + ' f краев.=' + fMin, bx + 8, by + 24); ctx.restore(); } } /* ── Chromatic aberration (Agent OB-A3) ────────────────────── f_R = f×1.02, f_G = f, f_B = f×0.98 Three ray bundles drawn in R/G/B colours converging to different points. ─────────────────────────────────────────────────────────────── */ _drawChromaticAberration(ctx, lx, ay, d, h, f) { if (Math.abs(f) < 1) return; const channels = [ { scale: 1.02, color: '#FF4444', label: 'R' }, { scale: 1.00, color: '#7BF5A4', label: 'G' }, { scale: 0.98, color: '#4488FF', label: 'B' }, ]; const objX = lx - d; ctx.save(); ctx.lineWidth = 1.6; const focusPts = []; channels.forEach(ch => { const fc = f * ch.scale; const denom = d - fc; if (Math.abs(denom) < 0.5) return; const dPrEff = (fc * d) / denom; const hPrEff = (-dPrEff / d) * h; const hasImg = isFinite(dPrEff) && dPrEff > 0; ctx.strokeStyle = ch.color; ctx.globalAlpha = 0.78; ctx.setLineDash([]); // Ray 1: parallel to axis ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay - h); if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); } ctx.stroke(); // Ray 2: through centre const slopeC = h / d; ctx.beginPath(); ctx.moveTo(objX, ay - h); ctx.lineTo(lx, ay); if (hasImg) { ctx.lineTo(lx + dPrEff, ay - hPrEff); } else { ctx.lineTo(lx + 280, ay - slopeC * 280); } ctx.stroke(); if (hasImg) focusPts.push({ x: lx + dPrEff, label: ch.label, color: ch.color, f: fc }); }); ctx.globalAlpha = 1; ctx.setLineDash([]); // focal markers focusPts.forEach(fp => { ctx.fillStyle = fp.color; ctx.beginPath(); ctx.arc(fp.x, ay, 5, 0, Math.PI * 2); ctx.fill(); ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = fp.color; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(fp.label + ' f=' + fp.f.toFixed(0), fp.x, ay - 10); }); ctx.restore(); // info box ctx.save(); const bx = 12, by = 70; ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(bx, by, 224, 56, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#4488FF'; ctx.fillText('Хроматическая аберрация', bx + 8, by + 6); if (focusPts.length >= 2) { const spread = Math.abs(focusPts[focusPts.length - 1].x - focusPts[0].x).toFixed(0); ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText('Разброс фокусов: ' + spread + ' px', bx + 8, by + 22); ctx.fillText('fR > fG > fB (красный фокус дальше)', bx + 8, by + 38); } ctx.restore(); } _drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) { ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top'; const objX = lx - d; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.fillText('d = ' + d.toFixed(0), (objX + lx) / 2, ay + 26); ctx.fillStyle = '#06D6E0'; ctx.fillText('f = ' + f.toFixed(0), lx, ay + 42); if (dPrime !== null && isFinite(dPrime)) { const imgX = lx + dPrime; ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; ctx.textAlign = 'center'; ctx.fillText("d' = " + dPrime.toFixed(1), (lx + imgX) / 2, ay + 26); } const info = this.info(); const boxW = 200, boxH = 52, bx = 12, by = 12; ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText("1/f = 1/d + 1/d'", bx + 10, by + 10); ctx.fillStyle = 'rgba(255,255,255,0.5)'; const mStr = info.M === Infinity ? '---' : info.M.toFixed(2); const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1); ctx.fillText('M = ' + mStr + " d' = " + dpStr + ' ' + info.imageType, bx + 10, by + 30); } /* === _drawRaysAnimated: principal rays with per-ray progress === */ _drawRaysAnimated(ctx, lx, ay, d, h, f, dPrime, hPrime) { const T = this._rayAnimT; if (T[0] >= 1 && T[1] >= 1 && T[2] >= 1) { this._drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime); return; } const objX = lx - d, objY = ay - h; const hasImage = dPrime !== null && isFinite(dPrime); const isVirtual = hasImage && dPrime < 0; // Wavelength-aware colors for animation const _wlNm = window._obWavelength || 550; const _wl = window._obWhiteLight; const _mc = wavelengthToRGB(_wlNm); const COLORS = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc]; ctx.lineWidth = 1.8; const lerp = (a, b, t) => a + (b - a) * Math.min(1, Math.max(0, t)); const drawPts = (color, pts, t) => { if (t <= 0 || pts.length < 2) return; const totalLen = pts.reduce((s, p, i) => i === 0 ? 0 : s + Math.hypot(p[0]-pts[i-1][0], p[1]-pts[i-1][1]), 0); const target = totalLen * t; const draw = () => { ctx.strokeStyle = color; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); let drawn = 0; for (let i = 1; i < pts.length; i++) { const segLen = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]); if (drawn + segLen <= target) { ctx.lineTo(pts[i][0], pts[i][1]); drawn += segLen; } else { const fr = segLen > 0 ? (target - drawn) / segLen : 0; ctx.lineTo(lerp(pts[i-1][0], pts[i][0], fr), lerp(pts[i-1][1], pts[i][1], fr)); break; } } ctx.stroke(); }; if (window.LabFX) LabFX.glow.drawGlow(ctx, draw, { color, intensity: 10 }); else draw(); }; const FAR = lx + 360; const imgX = hasImage ? lx + dPrime : null, imgY = hasImage ? ay - hPrime : null; // Ray 1: parallel to axis -> through F' if (T[0] > 0) { let pts; if (!hasImage) { pts = [[objX, objY], [lx, objY], [FAR, objY]]; } else if (!isVirtual) { pts = [[objX, objY], [lx, objY], [imgX, imgY]]; } else { const s = (objY - ay) / f; pts = [[objX, objY], [lx, objY], [FAR, objY + s*(FAR-lx)]]; } drawPts(COLORS[0], pts, T[0]); } // Ray 2: through optical center (straight) if (T[1] > 0) { const s = (objY - ay) / (objX - lx); drawPts(COLORS[1], [[objX, objY], [FAR, ay + s*(FAR-lx)]], T[1]); } // Ray 3: through front focus F -> parallel after lens if (T[2] > 0) { const fx = lx - f, s = (objY - ay) / (objX - fx); const hitY = objY + s * (lx - objX); const endX = hasImage && !isVirtual ? Math.max(imgX + 60, FAR) : FAR; drawPts(COLORS[2], [[objX, objY], [lx, hitY], [endX, hitY]], T[2]); } } /* === Arrow labels: h_o, h_i, magnification Gamma === */ _drawArrowLabels(ctx, lx, ay, d, h, dPrime, hPrime) { const objX = lx - d; ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(155,93,229,0.85)'; ctx.textAlign = 'right'; ctx.fillText('ho=' + h.toFixed(0), objX - 6, ay - h / 2); if (dPrime !== null && isFinite(dPrime)) { const imgX = lx + dPrime, isVirtual = dPrime < 0; const M = -dPrime / d; const Gstr = isFinite(M) ? (M >= 0 ? '+' : '') + M.toFixed(2) : '---'; const imgColor = isVirtual ? 'rgba(255,133,162,0.85)' : 'rgba(6,214,224,0.85)'; ctx.fillStyle = imgColor; ctx.textAlign = 'left'; ctx.fillText("hi=" + Math.abs(hPrime).toFixed(0), imgX + 6, ay - hPrime / 2); ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center'; ctx.fillText('G=' + Gstr, (lx + imgX) / 2, ay + 60); } } _bindEvents() { const cv = this.canvas; const getPos = (e) => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) }; }; const hitTest = (mx, my) => { const lx = this.W / 2, ay = this.H / 2; if (Math.hypot(mx - (lx - this.d), my - (ay - this.h)) < 20) return 'object'; if (Math.hypot(mx - (lx - this.f), my - ay) < 16) return 'focus'; return null; }; const onDown = (e) => { const { mx, my } = getPos(e); this._drag = hitTest(mx, my); }; const onMove = (e) => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const { mx } = getPos(e), lx = this.W / 2; if (this._drag === 'object') this.d = Math.max(30, Math.min(400, lx - mx)); else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx)); this.draw(); this._emit(); }; const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; }; cv.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true }); cv.addEventListener('touchmove', e => onMove(e), { passive: false }); cv.addEventListener('touchend', onUp); cv.addEventListener('mousemove', e => { if (this._drag) { cv.style.cursor = 'grabbing'; return; } const { mx, my } = getPos(e); cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; }); } } /* ───────────────────────────────────────────────────────────── 2. MIRROR ENGINE (from mirror.js) ───────────────────────────────────────────────────────────────*/ class MirrorSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.type = 'concave'; this.f = 120; this.d = 240; this.h = 60; this._playing = false; this._animT = 1.4; this._animSpeed = 1; this._raf = null; this._step = -1; this._showGrid = false; this._showZones = true; this._showNormals = true; this._showDims = true; this._showAngles = true; this._showPhotons = true; this._pointMode = false; this._showSpherical = false; /* spherical aberration toggle (Agent OB-A3) */ this._photons = []; this._photonRaf = null; this._photonTimer = 0; this._lastPhoTime = 0; this._photonPaths = []; this._prevType = 'concave'; this._transT = 1.0; this._transRaf = null; this._drag = null; this._hoverX = -999; this._hoverY = -999; this.onUpdate = null; this.onAnimate = null; /* Feature 2: R slider + spherical aberration toggle */ this._R = 240; // radius of curvature (positive=concave, negative=convex) this._useR = false; // true = R-slider mode; false = classic type+f mode this._parabolic = false; // false = spherical mirror; true = perfect parabolic this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } setType(type) { if (type === this.type) return; this._prevType = this.type; this.type = type; if (this._playing) this._stopAnim(); this._startTransition(); this.draw(); this._emit(); } getParams() { return { f: this.f, d: this.d, h: this.h }; } setParams({ f, d, h } = {}) { if (f !== undefined) this.f = Math.max(30, Math.min(300, +f)); if (d !== undefined) this.d = Math.max(30, Math.min(490, +d)); if (h !== undefined) this.h = Math.max(20, Math.min(80, +h)); this.draw(); this._emit(); } setAnimSpeed(s) { this._animSpeed = +s || 1; } togglePlay() { this._playing ? this._stopAnim() : this._startAnim(); } stepNext() { this._step = Math.min(3, this._step + 1); this.draw(); } stepReset() { this._step = -1; this.draw(); } setPointMode(on) { this._pointMode = !!on; this.draw(); this._emit(); } setToggle(name, val) { const map = { grid:'_showGrid', zones:'_showZones', normals:'_showNormals', dims:'_showDims', angles:'_showAngles', photons:'_showPhotons', spherical:'_showSpherical' }; if (map[name]) this[map[name]] = !!val; if (name === 'photons') { val ? this._startPhotons() : this._stopPhotons(); } this.draw(); } exportPng() { const a = document.createElement('a'); a.href = this.canvas.toDataURL('image/png'); a.download = 'mirror_' + this.type + '_d' + Math.round(this.d) + '.png'; a.click(); } _fSigned() { if (this.type === 'flat') return Infinity; return this.type === 'convex' ? -this.f : this.f; } info() { const { type, d, h } = this; const f = this._fSigned(); let dPrime, M; if (type === 'flat') { dPrime = -d; M = 1; } else { const den = d - f; if (Math.abs(den) < 0.5) { dPrime = Infinity; M = Infinity; } else { dPrime = f * d / den; M = -dPrime / d; } } const hPrime = M === Infinity ? Infinity : M * h; const isReal = dPrime > 0 && dPrime !== Infinity; const imageType = dPrime === Infinity ? '∞' : isReal ? 'действительное' : 'мнимое'; const orient = (M === Infinity || M === 1) ? 'прямое' : M < 0 ? 'перевёрнутое' : 'прямое'; const sizeStr = M === Infinity ? '' : Math.abs(M) > 1.05 ? 'увеличенное' : Math.abs(M) < 0.95 ? 'уменьшенное' : 'равное'; return { f: type === 'flat' ? '∞' : (type === 'convex' ? -this.f : +this.f).toFixed(0), d: +d.toFixed(1), dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1), M: M === Infinity ? Infinity : +M.toFixed(3), imageType, orient, sizeStr, hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1), isReal, }; } _emit() { if (this.onUpdate) this.onUpdate(this.info()); } _getBulge(type) { if (type === 'flat') return 0; if (type === 'concave') return -Math.min(30, this.f * 0.18); return Math.min(24, this.f * 0.16); } _startTransition() { this._transT = 0; if (this._transRaf) cancelAnimationFrame(this._transRaf); const step = () => { this._transT = Math.min(1, this._transT + 0.07); this.draw(); if (this._transT < 1) this._transRaf = requestAnimationFrame(step); else this._transRaf = null; }; this._transRaf = requestAnimationFrame(step); } _startAnim() { this._playing = true; this._animLoop(); } _stopAnim() { this._playing = false; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } _animLoop() { if (!this._playing) return; this._animT += 0.013 * this._animSpeed; const t = 0.5 - 0.5 * Math.cos(this._animT); if (this.type === 'concave') this.d = Math.max(30, Math.min(490, this.f * (0.35 + 2.75 * t))); else this.d = 40 + 400 * t; if (this.onAnimate) this.onAnimate(this.d); this.draw(); this._emit(); this._raf = requestAnimationFrame(() => this._animLoop()); } _getRayPaths(mx, ay, f, dPrime, hPrime) { const { d, h, type } = this; const hasImage = dPrime !== null && isFinite(dPrime); const isReal = hasImage && dPrime > 0; const imgX = hasImage ? mx - dPrime : null; const imgY = hasImage ? ay - (this._pointMode ? 0 : hPrime) : null; const objX = mx - d; const objY = ay - (this._pointMode ? 0 : h); const COLORS = ['#06D6E0', '#7BF5A4', '#FFD166']; if (type === 'flat') { return [objY, ay, ay - h * 0.5].map((hy, i) => ({ pts: [[objX, objY], [mx, hy], ...(hasImage ? [[imgX, imgY]] : [])], color: COLORS[i], })); } const hit1Y = ay - (this._pointMode ? 0 : h); const hit2Y = ay; const denom3 = d - f; const hit3Y = Math.abs(denom3) < 0.5 ? null : ay + (this._pointMode ? 0 : h) * f / denom3; const rays = []; const add = (hitY, color) => { if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2 * this.H) return; const pts = [[objX, objY], [mx, hitY]]; if (hasImage) { if (isReal) { pts.push([imgX, imgY]); const dx = imgX - mx, dy = imgY - hitY, l = Math.hypot(dx, dy); if (l > 1) pts.push([imgX + dx / l * 60, imgY + dy / l * 60]); } else { const dx = imgX - mx, dy = imgY - hitY; if (Math.abs(dx) > 1) { const tL = (mx - 5) / dx; let endX = 5, endY = hitY - dy * tL; if (endY < 5 || endY > this.H - 5) { endY = endY < 5 ? 5 : this.H - 5; const tE = (hitY - endY) / dy; endX = Math.max(5, mx - dx * tE); } pts.push([endX, endY]); } } } rays.push({ pts, color }); }; add(hit1Y, COLORS[0]); add(hit2Y, COLORS[1]); add(hit3Y, COLORS[2]); return rays; } _startPhotons() { if (this._photonRaf) return; this._lastPhoTime = performance.now(); this._photonLoop(); } _stopPhotons() { if (this._photonRaf) { cancelAnimationFrame(this._photonRaf); this._photonRaf = null; } this._photons = []; this.draw(); } _photonLoop() { const now = performance.now(); const dt = Math.min((now - this._lastPhoTime) / 1000, 0.1); this._lastPhoTime = now; const spd = 200; for (const p of this._photons) p.t = Math.min(1, p.t + dt * spd / p.len); this._photons = this._photons.filter(p => p.t < 1); this._photonTimer += dt; if (this._photonTimer > 0.75 && this._photonPaths.length) { this._photonTimer = 0; for (const path of this._photonPaths) { if (path.pts.length < 2) continue; let len = 0; for (let i = 1; i < path.pts.length; i++) len += Math.hypot(path.pts[i][0] - path.pts[i-1][0], path.pts[i][1] - path.pts[i-1][1]); if (len > 20) this._photons.push({ pts: path.pts, color: path.color, t: 0, len }); } } if (!this._playing) this.draw(); this._photonRaf = requestAnimationFrame(() => this._photonLoop()); } draw() { const { ctx, W, H } = this; if (!W || !H) return; const f = this._fSigned(); const mx = Math.round(W * 0.62); const ay = H / 2; let dPrime = null, hPrime = null; if (this.type === 'flat') { dPrime = -this.d; hPrime = this._pointMode ? 0 : this.h; } else { const den = this.d - f; if (Math.abs(den) >= 0.5) { dPrime = f * this.d / den; hPrime = this._pointMode ? 0 : (-dPrime / this.d) * this.h; } } const step = this._step; const showRay = i => step === -1 || i <= step; const showFill = step === -1 || step >= 3; this._photonPaths = this._getRayPaths(mx, ay, f, dPrime, hPrime); ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); if (this._showGrid) this._drawGrid(ctx); if (this._showZones) this._drawZones(ctx, mx); ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); ctx.setLineDash([]); this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill); /* spherical aberration overlay (Agent OB-A3) */ if (this._showSpherical && this.type !== 'flat' && isFinite(f)) this._drawMirrorSphericalAberration(ctx, mx, ay, f); /* Feature 2: parabolic/spherical aberration fan */ if (this._useR && this.type !== 'flat' && isFinite(f)) this._drawAberrationFan(ctx, mx, ay, f); this._drawMirror(ctx, mx, ay); /* Feature 2: R and f labels on mirror */ if (this._useR && this.type !== 'flat' && isFinite(f)) { ctx.save(); ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(6,214,224,0.9)'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('R=' + this._R.toFixed(0) + ' f=' + f.toFixed(0), mx - 4, ay - 6); ctx.restore(); } if (this.type !== 'flat') { this._drawFocalPoints(ctx, mx, ay, f); this._drawCenterC(ctx, mx, ay, f); } if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3)) this._drawNormals(ctx, mx, ay, f); if (this._showAngles && this.type !== 'flat' && step === -1) this._drawAngleArcs(ctx, mx, ay, f); if (step === -1 || step >= 1) this._drawRayLabels(ctx, mx, ay, f, step); const objX = mx - this.d; if (this._pointMode) { ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 10; ctx.fillStyle = '#9B5DE5'; ctx.beginPath(); ctx.arc(objX, ay, 5, 0, Math.PI*2); ctx.fill(); ctx.restore(); } else { this._drawArrow(ctx, objX, ay, objX, ay - this.h, '#9B5DE5', false); } if (dPrime !== null && isFinite(dPrime)) { const imgX = mx - dPrime, imgY = ay - (this._pointMode ? 0 : hPrime); if (this._pointMode) { ctx.save(); ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; if (dPrime < 0) { ctx.globalAlpha = 0.55; ctx.setLineDash([4,3]); } ctx.beginPath(); ctx.arc(imgX, ay, 5, 0, Math.PI*2); dPrime > 0 ? ctx.fill() : (() => { ctx.stroke(); })(); ctx.restore(); } else { this._drawArrow(ctx, imgX, ay, imgX, imgY, dPrime > 0 ? '#EF476F' : '#FFD166', dPrime <= 0); } } if (this._showDims && (step === -1 || step >= 3)) this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime); this._drawInfoBox(ctx, f, dPrime); if ((step === -1 || step >= 3) && dPrime !== null) this._drawImageBadge(ctx, dPrime, hPrime); this._drawCriticalMarker(ctx, f); if (this._showDims) this._drawLegend(ctx); if (this._showPhotons && this._photons.length) this._drawPhotons(ctx); this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime); if (step >= 0) this._drawStepOverlay(ctx, step); // Mirror caustics near focal point when real image exists (only when OB_FX.caustics is off) if (!window.OB_FX.caustics && window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) { const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime); if (!this._mCausticFrame) this._mCausticFrame = 0; this._mCausticFrame++; if (this._mCausticFrame % 4 === 0) { LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true }); } } // OB_FX visual depth layer { const objX = mx - this.d; const fxExtras = { srcX: objX, srcY: ay - this.h, causticParams: f > 0 ? { lx: mx, ay, f } : null, rays: [ { x1: objX, y1: ay - this.h, x2: mx, y2: ay }, ], boundary: { bx: mx, by: ay, normalAngle: 0, kind: 'reflection' }, }; if (!fxExtras.causticParams) delete fxExtras.causticParams; _drawOBFXLayer(ctx, 'mirror', fxExtras); } if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); } } _drawGrid(ctx) { ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x < this.W; x += 40) { ctx.moveTo(x,0); ctx.lineTo(x,this.H); } for (let y = 0; y < this.H; y += 40) { ctx.moveTo(0,y); ctx.lineTo(this.W,y); } ctx.stroke(); } _drawZones(ctx, mx) { const g1 = ctx.createLinearGradient(0,0,mx,0); g1.addColorStop(0, 'rgba(6,214,224,0.0)'); g1.addColorStop(1, 'rgba(6,214,224,0.03)'); ctx.fillStyle = g1; ctx.fillRect(0, 0, mx, this.H); const g2 = ctx.createLinearGradient(mx,0,this.W,0); g2.addColorStop(0, 'rgba(239,71,111,0.04)'); g2.addColorStop(1, 'rgba(239,71,111,0.0)'); ctx.fillStyle = g2; ctx.fillRect(mx, 0, this.W-mx, this.H); } _drawMirror(ctx, mx, ay) { const mH = Math.min(this.H * 0.4, 150); ctx.save(); const ease = t => t < 0.5 ? 2*t*t : -1+(4-2*t)*t; const bulge = this._getBulge(this._prevType) + (this._getBulge(this.type) - this._getBulge(this._prevType)) * ease(this._transT); ctx.strokeStyle = 'rgba(6,214,224,0.92)'; ctx.lineWidth = 3; ctx.shadowColor = 'rgba(6,214,224,0.45)'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.moveTo(mx, ay - mH); ctx.quadraticCurveTo(mx + bulge, ay, mx, ay + mH); ctx.stroke(); ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(6,214,224,0.15)'; ctx.lineWidth = 1.5; for (let i = 0; i <= 10; i++) { const y = ay - mH + i * mH * 2 / 10; ctx.beginPath(); ctx.moveTo(mx, y); ctx.lineTo(mx+14, y+10); ctx.stroke(); } ctx.restore(); } _drawFocalPoints(ctx, mx, ay, f) { const pts = [{ px: mx-f, lbl:'F', r:5 }, { px: mx-2*f, lbl:'2F', r:3.5 }]; ctx.font = '11px Manrope, system-ui, sans-serif'; for (const p of pts) { if (p.px < 4 || p.px > this.W-4) continue; const col = f < 0 ? 'rgba(255,209,102,0.7)' : '#06D6E0'; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.px, ay, p.r, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(p.lbl, p.px, ay+9); } } _drawCenterC(ctx, mx, ay, f) { if (!isFinite(f)) return; const cx = mx - 2*f; if (cx < 4 || cx > this.W-4) return; const pulse = Math.abs(this.d - 2*Math.abs(f)) < Math.abs(f)*0.06; ctx.save(); if (pulse) { ctx.shadowColor='rgba(255,152,0,0.9)'; ctx.shadowBlur=14; } ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.5)'; ctx.beginPath(); ctx.arc(cx, ay, pulse ? 5 : 3.5, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0; ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.6)'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('C', cx, ay+9); ctx.restore(); } _drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill) { const { d, h, type } = this; const hasImg = dPrime !== null && isFinite(dPrime); const isReal = hasImg && dPrime > 0; const imgX = hasImg ? mx - dPrime : null; const imgY = hasImg ? ay - (this._pointMode ? 0 : hPrime) : null; const objX = mx - d, objY = ay - (this._pointMode ? 0 : h); // Wavelength-aware color const _wl = window._obWhiteLight; const _wlNm = window._obWavelength || 550; const _mc = wavelengthToRGB(_wlNm); const COLS = _wl ? [wavelengthToRGB(660), wavelengthToRGB(550), wavelengthToRGB(450)] : [_mc, _mc, _mc]; const FAN = _wl ? 'rgba(255,255,255,0.12)' : `rgba(255,255,255,0.18)`; if (type === 'flat') { const hits = [objY, ay, ay - (this._pointMode ? 0 : h)*0.5]; hits.forEach((hy, i) => { if (!showRay(i)) return; this._flatRay(ctx, mx, ay, d, h, objX, objY, hy, COLS[i], imgX, imgY, hasImg); }); return; } const hit1 = ay - (this._pointMode ? 0 : h); const hit2 = ay; const den3 = d - f; const hit3 = Math.abs(den3) < 0.5 ? null : ay + (this._pointMode ? 0 : h)*f/den3; if (showFill) { const fills = [(hit1+hit2)/2]; if (hit3 !== null && isFinite(hit3)) fills.push((hit2+hit3)/2); for (const hy of fills) this._oneRay(ctx, mx, objX, objY, hy, FAN, 0.6, hasImg, isReal, imgX, imgY); } if (showRay(0)) this._oneRay(ctx, mx, objX, objY, hit1, COLS[0], 1.0, hasImg, isReal, imgX, imgY); if (showRay(1)) this._oneRay(ctx, mx, objX, objY, hit2, COLS[1], 1.0, hasImg, isReal, imgX, imgY); if (showRay(2)) this._oneRay(ctx, mx, objX, objY, hit3, COLS[2], 1.0, hasImg, isReal, imgX, imgY); } _oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) { if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return; ctx.save(); ctx.globalAlpha = alpha; if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; } ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); if (!hasImg) { ctx.restore(); return; } if (isReal) { ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); const dx = imgX-mx, dy = imgY-hitY, l = Math.hypot(dx,dy); if (l > 1) { ctx.globalAlpha = alpha * 0.22; ctx.beginPath(); ctx.moveTo(imgX,imgY); ctx.lineTo(imgX+dx/l*60, imgY+dy/l*60); ctx.stroke(); } } else { const dx = imgX-mx, dy = imgY-hitY; if (Math.abs(dx) < 1) { ctx.restore(); return; } const tL = (mx-5)/dx; let ex = 5, ey = hitY - dy*tL; if (ey < 5 || ey > this.H-5) { ey = ey < 5 ? 5 : this.H-5; ex = Math.max(5, mx - dx*(hitY-ey)/dy); } ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(ex, ey); ctx.stroke(); ctx.globalAlpha = alpha * 0.4; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); ctx.setLineDash([]); } ctx.restore(); } _flatRay(ctx, mx, ay, d, h, ox, oy, hitY, color, imgX, imgY, hasImg) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); const slope = (hitY-oy)/(mx-ox); const farX = Math.max(5, ox-50); const farY = hitY - slope*(mx-farX); ctx.globalAlpha = 0.3; ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(farX, Math.max(5, Math.min(this.H-5, farY))); ctx.stroke(); ctx.globalAlpha = 1; if (hasImg) { ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); ctx.setLineDash([]); } ctx.restore(); } /* ── Mirror spherical aberration (Agent OB-A3) ──────────────── For a spherical concave mirror, paraxial focal length = f. Marginal rays: f_eff(h) = f - h²/(4f) (simplified). Draw 5 incoming parallel rays at different heights; show they focus at slightly different points along the axis. ─────────────────────────────────────────────────────────────── */ _drawMirrorSphericalAberration(ctx, mx, ay, f) { const mH = Math.min(this.H * 0.38, 140); const fracs = [-0.85, -0.5, 0, 0.5, 0.85]; const pal = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF']; ctx.save(); ctx.lineWidth = 1.3; const focusPts = []; fracs.forEach((fr, i) => { const rayH = fr * mH; const fEff = f > 0 ? f - (rayH * rayH) / (4 * f) : f; const focX = mx - fEff; ctx.strokeStyle = pal[i]; ctx.globalAlpha = 0.7; ctx.setLineDash([]); const startY = ay - rayH; // incoming parallel ray from left ctx.beginPath(); ctx.moveTo(0, startY); ctx.lineTo(mx, startY); ctx.stroke(); // reflected ray toward (approximate) focus if (focX > 5 && focX < mx) { ctx.beginPath(); ctx.moveTo(mx, startY); ctx.lineTo(focX, ay); ctx.lineTo(focX - 60, ay + (startY - ay) * 0.4); ctx.stroke(); focusPts.push({ fEff, x: focX }); } }); ctx.globalAlpha = 1; ctx.restore(); // info box if (focusPts.length >= 2) { const fMin = Math.min(...focusPts.map(p => p.fEff)).toFixed(0); const fMax = Math.max(...focusPts.map(p => p.fEff)).toFixed(0); ctx.save(); const bx = 12, by = 70; ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(bx, by, 224, 44, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#FF6B6B'; ctx.fillText('Сферическая аберрация (зеркало)', bx + 8, by + 6); ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.fillText('f парак.=' + fMax + ' f краев.=' + fMin, bx + 8, by + 24); ctx.restore(); } } _drawNormals(ctx, mx, ay, f) { if (!isFinite(f)) return; const { d, h } = this; const cX = mx - 2*f; const hits = [ay-h, ay]; const d3 = d-f; if (Math.abs(d3) >= 0.5) { const y3 = ay + h*f/d3; if (isFinite(y3)) hits.push(y3); } ctx.save(); ctx.strokeStyle='rgba(255,255,255,0.14)'; ctx.lineWidth=1; ctx.setLineDash([4,4]); for (const hy of hits) { if (hy < -this.H || hy > 2*this.H) continue; const nx=cX-mx, ny=ay-hy, nl=Math.hypot(nx,ny); if (nl < 1) continue; const ux=nx/nl*28, uy=ny/nl*28; ctx.beginPath(); ctx.moveTo(mx-ux,hy-uy); ctx.lineTo(mx+ux,hy+uy); ctx.stroke(); } ctx.setLineDash([]); ctx.restore(); } _drawAngleArcs(ctx, mx, ay, f) { if (!isFinite(f)) return; const { d, h } = this; const hitY = ay - h; if (hitY < 5 || hitY > this.H-5) return; const cX = mx - 2*f; const nx = cX-mx, ny = ay-hitY, nl = Math.hypot(nx, ny); if (nl < 1) return; const normInward = Math.atan2(ny, nx); const normOuter = normInward + Math.PI; const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d)); const incFrom = incDir + Math.PI; const r = 14; ctx.save(); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(6,214,224,0.45)'; ctx.beginPath(); ctx.arc(mx, hitY, r, normOuter, incFrom, false); ctx.stroke(); ctx.fillStyle = 'rgba(6,214,224,0.7)'; ctx.font = '9px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const mid = (normOuter+incFrom)/2; ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9)); ctx.restore(); } _drawRayLabels(ctx, mx, ay, f, step) { if (this.type === 'flat' || !isFinite(f)) return; const { d, h } = this; const hits = [ay-h, ay, null]; const den3 = d-f; if (Math.abs(den3) >= 0.5) { const y3 = ay+h*f/den3; if (isFinite(y3)) hits[2] = y3; } const COLS = ['#06D6E0','#7BF5A4','#FFD166']; const LBLS = ['①','②','③']; ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; hits.forEach((hy, i) => { if (hy === null || !isFinite(hy) || hy < -50 || hy > this.H+50) return; if (step !== -1 && i > step) return; ctx.fillStyle = COLS[i]; ctx.textBaseline = 'middle'; ctx.fillText(LBLS[i], mx+8, hy); }); } _drawArrow(ctx, x1, y1, x2, y2, color, dashed) { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5; if (dashed) ctx.setLineDash([6,4]); ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); if (dashed) ctx.setLineDash([]); const a = Math.atan2(y2-y1, x2-x1), s=10; ctx.beginPath(); ctx.moveTo(x2,y2); ctx.lineTo(x2-s*Math.cos(a-0.35), y2-s*Math.sin(a-0.35)); ctx.lineTo(x2-s*Math.cos(a+0.35), y2-s*Math.sin(a+0.35)); ctx.closePath(); ctx.fill(); } _drawDimensions(ctx, mx, ay, f, dPrime, hPrime) { const { d, h } = this; const objX = mx - d; const yBase = ay + Math.min(this.H*0.22, 60); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.lineWidth = 1; const bracket = (x1, x2, y, lbl, col) => { if (x1 === x2 || x1 < 4 || x2 > this.W-4) return; ctx.strokeStyle = col; ctx.fillStyle = col; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(x1, y-5); ctx.lineTo(x1, y+5); ctx.moveTo(x1, y); ctx.lineTo(x2, y); ctx.moveTo(x2, y-5); ctx.lineTo(x2, y+5); ctx.stroke(); ctx.textAlign='center'; ctx.textBaseline='top'; ctx.fillText(lbl, (x1+x2)/2, y+3); }; bracket(objX, mx, yBase, 'd=' + d.toFixed(0), 'rgba(155,93,229,0.65)'); if (isFinite(f) && Math.abs(f) > 5) { const fX = mx-f; if (fX > 4 && fX < this.W-4) bracket(Math.min(fX,mx), Math.max(fX,mx), yBase+20, 'f=' + Math.abs(f).toFixed(0), 'rgba(6,214,224,0.55)'); } if (dPrime !== null && isFinite(dPrime)) { const ix = mx-dPrime; if (ix > 4 && ix < this.W-4) bracket(Math.min(ix,mx), Math.max(ix,mx), yBase, "d'=" + Math.abs(dPrime).toFixed(0), dPrime > 0 ? 'rgba(239,71,111,0.65)' : 'rgba(255,209,102,0.65)'); } const xl = objX-18; if (xl > 4 && h > 6 && !this._pointMode) { ctx.strokeStyle='rgba(155,93,229,0.4)'; ctx.beginPath(); ctx.moveTo(xl,ay); ctx.lineTo(xl,ay-h); ctx.stroke(); ctx.fillStyle='rgba(155,93,229,0.7)'; ctx.textAlign='right'; ctx.textBaseline='middle'; ctx.fillText('h=' + h.toFixed(0), xl-3, ay-h/2); } if (dPrime !== null && isFinite(dPrime) && !this._pointMode && Math.abs(hPrime) > 6) { const ix = mx-dPrime; const xil = ix + (dPrime > 0 ? -18 : 18); if (xil > 4 && xil < this.W-4) { const col = dPrime > 0 ? 'rgba(239,71,111,' : 'rgba(255,209,102,'; ctx.strokeStyle = col+'0.4)'; ctx.beginPath(); ctx.moveTo(ix,ay); ctx.lineTo(ix,ay-hPrime); ctx.stroke(); ctx.fillStyle = col+'0.7)'; ctx.textAlign = dPrime > 0 ? 'right' : 'left'; ctx.textBaseline='middle'; ctx.fillText("h'=" + Math.abs(hPrime).toFixed(0), ix+(dPrime>0?-3:3), ay-hPrime/2); } } } _drawInfoBox(ctx, f, dPrime) { const info = this.info(); const bx=12, by=12, bw=230, bh=76; ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); ctx.font='11px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillStyle='rgba(255,255,255,0.42)'; ctx.fillText("1/f = 1/d + 1/d'", bx+10, by+8); if (isFinite(f) && dPrime !== null && isFinite(dPrime)) { ctx.fillStyle='rgba(6,214,224,0.88)'; ctx.fillText('1/' + Math.abs(+info.f), bx+10, by+28); ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('=',bx+60,by+28); ctx.fillStyle='rgba(155,93,229,0.88)'; ctx.fillText('1/' + info.d, bx+78, by+28); ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('+',bx+120,by+28); ctx.fillStyle= dPrime>0 ? 'rgba(239,71,111,0.88)' : 'rgba(255,209,102,0.88)'; ctx.fillText((dPrime>0?'':'−') + '1/' + Math.abs(+info.dPrime).toFixed(0), bx+136, by+28); } else { ctx.fillStyle='rgba(255,209,102,0.75)'; ctx.fillText('d = f → изображение на ∞', bx+10, by+28); } if (info.M !== Infinity) { ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.fillText('M = ' + info.M, bx+10, by+48); if (isFinite(dPrime)) { ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; ctx.textAlign = 'right'; ctx.fillText(info.imageType + ' ' + info.orient, bx+bw-10, by+48); ctx.textAlign = 'left'; } } } _drawImageBadge(ctx, dPrime, hPrime) { const info = this.info(); const bw=160, bh=58, bx=this.W-bw-12, by=12; ctx.fillStyle='rgba(13,13,26,0.88)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); const isInf = !isFinite(dPrime); ctx.font='10px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top'; const tc = isInf ? '#FFD166' : dPrime>0 ? '#EF476F' : '#FFD166'; ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Тип:', bx+10, by+8); ctx.fillStyle=tc; ctx.fillText(isInf?'∞':info.imageType, bx+44, by+8); if (!isInf) { ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Ориент.:', bx+10, by+26); ctx.fillStyle = info.M<0 ? 'rgba(239,71,111,0.9)' : 'rgba(123,245,164,0.9)'; ctx.fillText(info.orient, bx+62, by+26); if (info.sizeStr) { const sc = Math.abs(+info.M)>1.05 ? '#9B5DE5' : Math.abs(+info.M)<0.95 ? '#06D6E0' : 'rgba(255,255,255,0.6)'; ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Размер:', bx+10, by+42); ctx.fillStyle=sc; ctx.fillText(info.sizeStr + ' x' + Math.abs(+info.M).toFixed(2), bx+57, by+42); } } } _drawCriticalMarker(ctx, f) { if (!isFinite(f) || f <= 0) return; const eps = f*0.06; let text = null; if (Math.abs(this.d-f) < eps) text = 'd = f : лучи параллельны, изображения нет'; else if (Math.abs(this.d-2*f) 0 — предмет перед зеркалом' }, { c:'rgba(239,71,111,0.8)', t:"d' > 0 — действительное" }, { c:'rgba(255,209,102,0.8)',t:"d' < 0 — мнимое" }, ]; const bx=12, lh=14, by=this.H - items.length*lh - 16; ctx.save(); ctx.font='9px Manrope, system-ui, sans-serif'; ctx.textBaseline='top'; items.forEach(({ c, t }, i) => { const y = by+i*lh; ctx.fillStyle=c; ctx.fillRect(bx, y+3, 8, 8); ctx.fillStyle='rgba(255,255,255,0.32)'; ctx.textAlign='left'; ctx.fillText(t, bx+13, y); }); ctx.restore(); } _drawPhotons(ctx) { for (const p of this._photons) { const pos = this._photonPos(p.pts, p.t); if (!pos) continue; ctx.save(); ctx.shadowColor = p.color; ctx.shadowBlur = 8; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(pos[0], pos[1], 3, 0, Math.PI*2); ctx.fill(); ctx.restore(); } } _photonPos(pts, t) { if (pts.length < 2) return null; let total = 0; const lens = []; for (let i=1; i { if (!tip && Math.hypot(hx-px, hy-py) < 15) tip = { lbl, sub }; }; if (isFinite(f)) { chk(mx-f, ay, 'Главный фокус F', 'f = ' + Math.abs(f).toFixed(0)); chk(mx-2*f, ay, 'Центр кривизны C', 'R = 2f = ' + (2*Math.abs(f)).toFixed(0)); } chk(mx-this.d, ay-(this._pointMode?0:this.h), 'Предмет', 'd = ' + this.d.toFixed(0) + ', h = ' + this.h.toFixed(0)); if (dPrime !== null && isFinite(dPrime)) { const ix=mx-dPrime, iy=ay-(this._pointMode?0:hPrime); chk(ix, iy, 'Изображение', "d' = " + Math.abs(dPrime).toFixed(0) + ', M = ' + this.info().M); } if (!tip) return; ctx.save(); ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; const tw = Math.max(ctx.measureText(tip.lbl).width, ctx.measureText(tip.sub).width); const bw=tw+20, bh=34; let tx=hx+14, ty=hy-bh-6; if (tx+bw > this.W-4) tx = hx-bw-14; if (ty < 4) ty = hy+10; ctx.fillStyle='rgba(13,13,26,0.95)'; ctx.strokeStyle='rgba(6,214,224,0.45)'; ctx.lineWidth=1; ctx.beginPath(); ctx.roundRect(tx,ty,bw,bh,6); ctx.fill(); ctx.stroke(); ctx.fillStyle='#fff'; ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText(tip.lbl, tx+10, ty+6); ctx.font='10px Manrope, system-ui, sans-serif'; ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fillText(tip.sub, tx+10, ty+20); ctx.restore(); } _drawStepOverlay(ctx, step) { const lbls = [ '① Луч параллельно оси → отражается через F', '② Луч через вершину → отражается симметрично', '③ Луч через F → отражается параллельно', ' Изображение — пересечение всех отражённых лучей', ]; const text = lbls[Math.min(step, lbls.length-1)]; ctx.save(); ctx.font = '11px Manrope, system-ui, sans-serif'; const tw = ctx.measureText(text).width; const bx = this.W/2-tw/2-12, by = this.H-34; ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+24,24,6); ctx.fill(); ctx.fillStyle='#7BF5A4'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(text, this.W/2, by+12); ctx.restore(); } /* === Feature 2: R-slider mode for MirrorSim === */ setMirrorR(R) { this._useR = true; this._R = +R; // Derive type and f from R const absR = Math.abs(this._R); if (absR < 5) { this.type = 'flat'; } else if (this._R > 0) { this.type = 'concave'; this.f = absR / 2; } else { this.type = 'convex'; this.f = absR / 2; } this.draw(); this._emit(); } setMirrorParabolic(on) { this._parabolic = !!on; this.draw(); } /* Draw 5 parallel rays showing spherical vs parabolic aberration */ _drawAberrationFan(ctx, mx, ay, f) { if (!isFinite(f) || Math.abs(f) < 5) return; const mH = Math.min(this.H * 0.38, 140); const heights = [-0.85, -0.45, 0, 0.45, 0.85]; const COLORS = ['#FF6B6B', '#FFD166', '#7BF5A4', '#06D6E0', '#B8A4FF']; ctx.save(); ctx.lineWidth = 1.4; heights.forEach((fr, i) => { const rayH = fr * mH; // For parabolic mirror: all parallel rays focus exactly at f // For spherical: marginal rays (fr != 0) focus closer by h^2/(2R) approx const fEff = this._parabolic ? f : f - (rayH * rayH) / (2 * Math.abs(f) * 2); const startX = mx - this.d - 40; const hitY = ay - rayH; // hits mirror at height rayH // Incident ray: horizontal from left to mirror ctx.strokeStyle = COLORS[i]; ctx.globalAlpha = 0.75; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(startX, ay - rayH); ctx.lineTo(mx, hitY); ctx.stroke(); // Reflected ray: goes toward focal point fEff const focX = mx - fEff; if (focX > 0 && focX < this.W) { ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(focX, ay); // extend a bit past focus const dx = focX - mx, dy = ay - hitY, len = Math.hypot(dx, dy); if (len > 1) ctx.lineTo(focX + dx/len*50, ay + dy/len*50); ctx.stroke(); } }); ctx.globalAlpha = 1; // label const label = this._parabolic ? 'Параболическое (идеальный фокус)' : 'Сферическое (аберрация)'; const col = this._parabolic ? '#7BF5A4' : '#FF6B6B'; const bx = 12, by = this.H - 36; ctx.fillStyle = 'rgba(13,13,26,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, 250, 24, 6); ctx.fill(); ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = col; ctx.fillText(label, bx + 8, by + 12); ctx.restore(); } _bindEvents() { const cv = this.canvas; const getPos = e => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { px: (t.clientX-r.left)*(this.W/r.width), py: (t.clientY-r.top)*(this.H/r.height) }; }; const mX = () => Math.round(this.W*0.62); const aY = () => this.H/2; const hitTest = (px, py) => { if (this._playing) return null; const mx=mX(), ay=aY(), f=this._fSigned(); if (Math.hypot(px-(mx-this.d), py-(ay-(this._pointMode?0:this.h))) < 20) return 'object'; if (this.type !== 'flat' && isFinite(f) && Math.hypot(px-(mx-f), py-ay) < 16) return 'focus'; const info = this.info(); if (info.dPrime !== Infinity && isFinite(info.dPrime)) { const ix=mx-info.dPrime, iy=ay-(this._pointMode?0:(info.hPrime||0)); if (Math.hypot(px-ix, py-iy) < 18) return 'image'; } return null; }; cv.addEventListener('mousedown', e => { const {px,py}=getPos(e); this._drag=hitTest(px,py); }); window.addEventListener('mousemove', e => { const {px,py} = getPos(e); this._hoverX = px; this._hoverY = py; if (this._drag) { if (e.cancelable) e.preventDefault(); const mx=mX(), f=this._fSigned(); if (this._drag === 'object') this.d = Math.max(30, Math.min(490, mx-px)); else if (this._drag === 'focus') this.f = Math.max(30, Math.min(300, Math.abs(mx-px))); else if (this._drag === 'image' && isFinite(f) && this.type !== 'flat') { const dp = mx-px; if (Math.abs(dp-f) > 5) this.d = Math.max(30, Math.min(490, f*dp/(dp-f))); } if (this.onAnimate) this.onAnimate(this.d); this.draw(); this._emit(); } else if (!this._photonRaf && !this._playing) { this.draw(); } }); window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; }); cv.addEventListener('mousemove', e => { if (this._drag) { cv.style.cursor='grabbing'; return; } const {px,py}=getPos(e); cv.style.cursor = (hitTest(px,py) && !this._playing) ? 'grab' : 'default'; }); cv.addEventListener('touchstart', e => { if (e.touches.length===1) { const {px,py}=getPos(e); this._drag=hitTest(px,py); } }, { passive: true }); cv.addEventListener('touchmove', e => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const {px}=getPos(e), mx=mX(), f=this._fSigned(); if (this._drag==='object') this.d=Math.max(30,Math.min(490,mx-px)); else if (this._drag==='focus') this.f=Math.max(30,Math.min(300,Math.abs(mx-px))); else if (this._drag==='image' && isFinite(f) && this.type!=='flat') { const dp=mx-px; if (Math.abs(dp-f)>5) this.d=Math.max(30,Math.min(490,f*dp/(dp-f))); } if (this.onAnimate) this.onAnimate(this.d); this.draw(); this._emit(); }, { passive: false }); cv.addEventListener('touchend', () => { this._drag=null; }); } } /* ───────────────────────────────────────────────────────────── 3. REFRACTION ENGINE (from refraction.js) ───────────────────────────────────────────────────────────────*/ class RefractionSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; this.dispersion = false; this._drag = false; this.onUpdate = null; this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; } setParams({ n1, n2, angle, dispersion } = {}) { if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1)); if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2)); if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle)); if (dispersion !== undefined) this.dispersion = !!dispersion; this.draw(); this._emit(); } reset() { this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; this.dispersion = false; this.draw(); this._emit(); } info() { const { n1, n2, angle } = this; const theta1Rad = angle * Math.PI / 180; const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad); const isTIR = Math.abs(sinTheta2) > 1; const criticalAngle = n1 > n2 ? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1) : null; let angle2; if (isTIR) angle2 = 'ПВО'; else angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1); return { n1: +n1.toFixed(2), n2: +n2.toFixed(2), angle1: +angle.toFixed(1), angle2, criticalAngle, isTIR }; } _emit() { if (this.onUpdate) this.onUpdate(this.info()); } draw() { const ctx = this.ctx, W = this.W, H = this.H; if (!W || !H) return; const midY = H / 2, hitX = W / 2, hitY = midY; const gradTop = ctx.createLinearGradient(0, 0, 0, midY); gradTop.addColorStop(0, '#131328'); gradTop.addColorStop(1, '#1a1a3a'); ctx.fillStyle = gradTop; ctx.fillRect(0, 0, W, midY); const gradBot = ctx.createLinearGradient(0, midY, 0, H); gradBot.addColorStop(0, '#0e1a2e'); gradBot.addColorStop(1, '#0D0D1A'); ctx.fillStyle = gradBot; ctx.fillRect(0, midY, W, H - midY); ctx.save(); ctx.shadowColor = 'rgba(155, 93, 229, 0.4)'; ctx.shadowBlur = 12; ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke(); ctx.restore(); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke(); ctx.setLineDash([]); const theta1Rad = this.angle * Math.PI / 180; const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad); const isTIR = Math.abs(sinTheta2) > 1; let R = 1; if (!isTIR) { const theta2Rad = Math.asin(sinTheta2); const cosT1 = Math.cos(theta1Rad), cosT2 = Math.cos(theta2Rad); const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2); R = rs * rs; } const rayLen = Math.max(W, H) * 0.6; if (this.n1 > this.n2) { const critRad = Math.asin(this.n2 / this.n1); const critDx = Math.sin(critRad), critDy = Math.cos(critRad); ctx.strokeStyle = 'rgba(255,209,102,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(hitX, hitY); ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); ctx.stroke(); ctx.setLineDash([]); ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,209,102,0.5)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', hitX - critDx * rayLen * 0.35 + 6, hitY - critDy * rayLen * 0.35); } if (this.dispersion && !isTIR) this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen); else this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen); this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR); this._drawMediumLabels(ctx, W, H, midY); this._drawInfoBox(ctx, isTIR, R); const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad); const handleX = hitX - incDx * rayLen * 0.55, handleY = hitY - incDy * rayLen * 0.55; const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10); grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill(); // TIR one-shot sound if (window.LabFX) { if (isTIR && !this._wasTIR) { LabFX.sound.play('spark', { volume: 0.2 }); } this._wasTIR = isTIR; // Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol) const _isBrew = !isTIR && R < 0.005 && this.angle > 0; if (_isBrew && !this._wasBrewster) { LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 }); } this._wasBrewster = _isBrew; } // OB_FX visual depth layer { const incDx2 = Math.sin(theta1Rad), incDy2 = Math.cos(theta1Rad); const incStartX = hitX - incDx2 * rayLen, incStartY = hitY - incDy2 * rayLen; // Normal at boundary points upward (angle = -PI/2 in canvas coords) const normalAngle = -Math.PI / 2; const kind = isTIR ? 'reflection' : 'refraction'; const fxExtras = { srcX: incStartX, srcY: incStartY, rays: [ { x1: incStartX, y1: incStartY, x2: hitX, y2: hitY, n: this.n1 }, ], boundary: { bx: hitX, by: hitY, normalAngle, kind }, }; _drawOBFXLayer(ctx, 'refraction', fxExtras); } if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); } } _drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) { // Use wavelength color if set, otherwise fall back to purple/red/cyan scheme const _wl = window._obWhiteLight; const _wlNm = window._obWavelength || 550; const incColor = _wl ? '#FFFFFF' : wavelengthToRGB(_wlNm); const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad); const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen; if (_wl) { // White-light: draw spectral fan using physical n(λ) model this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5); this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF'); for (const s of OB_SPECTRAL) { const n2w = _nAtWavelength(this.n2, s.nm); const sinT2w = (this.n1 / n2w) * Math.sin(theta1Rad); if (Math.abs(sinT2w) > 1) continue; const t2w = Math.asin(sinT2w); const col = wavelengthToRGB(s.nm); ctx.globalAlpha = 0.8; this._drawRay(ctx, hitX, hitY, hitX + Math.sin(t2w) * rayLen, hitY + Math.cos(t2w) * rayLen, col, 1.8); ctx.globalAlpha = 1; } // Reflected ray (partial, semi-transparent) ctx.globalAlpha = 0.3; this._drawRay(ctx, hitX, hitY, hitX + incDx * rayLen * 0.7, hitY - incDy * rayLen * 0.7, '#FFFFFF', 1.5); ctx.globalAlpha = 1; return; } this._drawRay(ctx, incStartX, incStartY, hitX, hitY, incColor, 2.5); this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), incColor); const refDx = incDx, refDy = -incDy; const refEndX = hitX + refDx * rayLen, refEndY = hitY + refDy * rayLen; const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R)); ctx.globalAlpha = refAlpha; this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5); this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F'); ctx.globalAlpha = 1; if (!isTIR) { const theta2Rad = Math.asin(sinTheta2); const refracDx = Math.sin(theta2Rad), refracDy = Math.cos(theta2Rad); const refracEndX = hitX + refracDx * rayLen, refracEndY = hitY + refracDy * rayLen; const T = 1 - R; ctx.globalAlpha = Math.max(0.3, Math.sqrt(T)); // Refracted ray: use wavelength color with slight shift for refracted this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, incColor, 2.5); this._drawArrowhead(ctx, refracEndX, refracEndY, Math.atan2(refracEndY - hitY, refracEndX - hitX), incColor); ctx.globalAlpha = 1; } } _drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) { const spectral = [ { color: '#FF0000', wave: 656 }, { color: '#FF7F00', wave: 589 }, { color: '#FFFF00', wave: 550 }, { color: '#00FF00', wave: 510 }, { color: '#00FFFF', wave: 475 }, { color: '#0000FF', wave: 450 }, { color: '#8B00FF', wave: 400 }, ]; const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad); const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen; this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5); this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF'); const A = this.n2 - 4500 / (550 * 550), B = 4500; for (const s of spectral) { const n2w = A + B / (s.wave * s.wave); const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad); if (Math.abs(sinT2) > 1) continue; const t2 = Math.asin(sinT2), dx = Math.sin(t2), dy = Math.cos(t2); ctx.globalAlpha = 0.85; this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5); ctx.globalAlpha = 1; } const refDx = incDx, refDy = -incDy; ctx.globalAlpha = 0.35; this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5); ctx.globalAlpha = 1; } _drawRay(ctx, x1, y1, x2, y2, color, width) { const drawFn = () => { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.restore(); }; if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 }); else drawFn(); } _drawArrowhead(ctx, x, y, angle, color) { const aLen = 10; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3)); ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3)); ctx.closePath(); ctx.fill(); } _drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) { const arcR = 50, font = '12px Manrope, system-ui, sans-serif'; if (this.angle > 1) { ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5; ctx.beginPath(); const normAngle = -Math.PI / 2, incAngle = -Math.PI / 2 - theta1Rad; ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); ctx.stroke(); ctx.font = font; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const midA = normAngle - theta1Rad / 2; ctx.fillText('θ₁=' + this.angle.toFixed(1) + '°', hitX + (arcR + 20) * Math.cos(midA), hitY + (arcR + 20) * Math.sin(midA)); } if (!isTIR && Math.abs(sinTheta2) <= 1) { const theta2Rad = Math.asin(sinTheta2); if (theta2Rad > 0.02) { ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1.5; ctx.beginPath(); const normDown = Math.PI / 2, refAngle = Math.PI / 2 + theta2Rad; ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); ctx.stroke(); const angle2Deg = theta2Rad * 180 / Math.PI; ctx.font = font; ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const midA2 = normDown + theta2Rad / 2; ctx.fillText('θ₂=' + angle2Deg.toFixed(1) + '°', hitX + (arcR * 0.8 + 20) * Math.cos(midA2), hitY + (arcR * 0.8 + 20) * Math.sin(midA2)); } } } _drawMediumLabels(ctx, W, H, midY) { ctx.font = '13px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(155,93,229,0.6)'; ctx.textAlign = 'left'; ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30); ctx.fillStyle = 'rgba(6,214,224,0.6)'; ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30); const theta1Rad = this.angle * Math.PI / 180; const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad); if (Math.abs(sinT2) > 1) { ctx.font = 'bold 14px Manrope, system-ui, sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center'; ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60); } } _drawInfoBox(ctx, isTIR, R) { const boxW = 220, boxH = 72, bx = this.W - boxW - 12, by = 12; ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10); const info = this.info(); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fillText('θ₁ = ' + info.angle1 + '° θ₂ = ' + (info.isTIR ? 'ПВО' : info.angle2 + '°'), bx + 10, by + 28); const rPct = (R * 100).toFixed(1), tPct = ((1 - R) * 100).toFixed(1); ctx.fillStyle = '#EF476F'; ctx.fillText('R = ' + rPct + '%', bx + 10, by + 46); ctx.fillStyle = '#06D6E0'; ctx.fillText('T = ' + (isTIR ? '0' : tPct) + '%', bx + 90, by + 46); if (info.criticalAngle !== null) { ctx.fillStyle = '#FFD166'; ctx.fillText('θc = ' + info.criticalAngle + '°', bx + 160, by + 46); } } _bindEvents() { const cv = this.canvas; const getPos = (e) => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) }; }; const hitTest = (mx, my) => { const hitX = this.W / 2, hitY = this.H / 2; if (my >= hitY) return false; const dist = Math.hypot(mx - hitX, my - hitY); return dist > 20 && dist < Math.max(this.W, this.H) * 0.6; }; const angleFromMouse = (mx, my) => { const hitX = this.W / 2, hitY = this.H / 2; const dx = mx - hitX, dy = hitY - my; return Math.max(0, Math.min(89, Math.atan2(Math.abs(dx), dy) * 180 / Math.PI)); }; const onDown = (e) => { const { mx, my } = getPos(e); if (hitTest(mx, my)) this._drag = true; }; const onMove = (e) => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const { mx, my } = getPos(e); this.angle = angleFromMouse(mx, my); this.draw(); this._emit(); }; const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; }; cv.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true }); cv.addEventListener('touchmove', e => onMove(e), { passive: false }); cv.addEventListener('touchend', onUp); cv.addEventListener('mousemove', e => { if (this._drag) { cv.style.cursor = 'grabbing'; return; } const { mx, my } = getPos(e); cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; }); } } /* ───────────────────────────────────────────────────────────── 4. FREE-BUILD MULTI-LENS SIM (Agent OB-A3) Cascaded image formation — each lens treats the previous image as its object. Two-lens effective focal length is F = f1*f2 / (f1+f2-d). ───────────────────────────────────────────────────────────────*/ class FreeBuildSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; /* elements: { x_frac, f, id } sorted left→right at draw time */ this.elements = [ { x_frac: 0.30, f: 120, id: 1 }, { x_frac: 0.65, f: 90, id: 2 }, ]; this.objFrac = 0.10; this.objH = 60; this._drag = null; this.onUpdate = null; this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } /* Returns { stages, totalM, sysFocal, elems } */ _computeChain() { const W = this.W; const elems = [...this.elements].sort((a, b) => a.x_frac - b.x_frac); let curX = this.objFrac * W; let curH = this.objH; const stages = []; let totalM = 1; for (const el of elems) { const lensX = el.x_frac * W; const dO = lensX - curX; if (Math.abs(dO) < 1) continue; const f = el.f; const denom = dO - f; let dI, imgH, M; if (Math.abs(denom) < 0.5) { dI = Infinity; imgH = Infinity; M = Infinity; } else { dI = (f * dO) / denom; M = -(dI / dO); imgH = M * curH; } const imgX = lensX + dI; const isVirt = isFinite(dI) && dI < 0; stages.push({ objX: curX, objH: curH, lensX, f, imgX, imgH, dO, dI, M, isVirt, el }); totalM *= (isFinite(M) ? M : 1); curX = isFinite(imgX) ? imgX : lensX + 1; curH = isFinite(imgH) ? imgH : curH; } let sysFocal = null; if (elems.length === 2) { const f1 = elems[0].f, f2 = elems[1].f; const d = (elems[1].x_frac - elems[0].x_frac) * W; const dn = f1 + f2 - d; if (Math.abs(dn) > 0.5) sysFocal = (f1 * f2) / dn; } return { stages, totalM, sysFocal, elems }; } draw() { const { ctx, W, H } = this; if (!W || !H) return; const ay = H / 2; ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); // axis ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); ctx.setLineDash([]); const { stages, totalM, sysFocal, elems } = this._computeChain(); const objX = this.objFrac * W; // object arrow this._fbArrow(ctx, objX, ay, objX, ay - this.objH, '#9B5DE5', false); // lenses + focal markers elems.forEach(el => { const lx = el.x_frac * W; this._fbLens(ctx, lx, ay, el.f); const fp = lx + el.f; if (fp > 10 && fp < W - 10) { ctx.fillStyle = 'rgba(6,214,224,0.5)'; ctx.beginPath(); ctx.arc(fp, ay, 3.5, 0, Math.PI * 2); ctx.fill(); } }); // chain rays this._drawChainRays(ctx, ay, stages); // intermediate image arrows const imgPal = ['#EF476F', '#FFD166', '#7BF5A4', '#06D6E0']; stages.forEach((s, i) => { if (!isFinite(s.dI)) return; const col = imgPal[i % imgPal.length]; this._fbArrow(ctx, s.imgX, ay, s.imgX, ay - s.imgH, col, s.isVirt); ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText('Изобр.' + (i + 1), s.imgX, ay - Math.abs(s.imgH) - 6); }); this._drawInfoPanel(ctx, stages, totalM, sysFocal); // OB_FX visual depth layer { const objX = this.objFrac * this.W; _drawOBFXLayer(ctx, 'freebuild', { srcX: objX, srcY: ay - this.objH }); } } /* Trace 3 characteristic rays through all lenses (thin-lens matrix method) */ _drawChainRays(ctx, ay, stages) { if (!stages.length) return; const colors = ['#06D6E0', '#7BF5A4', '#FFD166']; const s0 = stages[0]; const W = this.W; const rays = [ { curY: ay - s0.objH, slope: 0 }, // parallel to axis { curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / (s0.lensX - s0.objX) }, // through lens centre { curY: ay - s0.objH, slope: (ay - (ay - s0.objH)) / ((s0.lensX - s0.f) - s0.objX) }, // through F ]; rays.forEach((ray, ri) => { const col = colors[ri]; const doGlow = (fn) => { if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color: col, intensity: 6 }); else fn(); }; doGlow(() => { ctx.strokeStyle = col; ctx.lineWidth = 1.4; ctx.setLineDash([]); let curX = s0.objX; let curY = ray.curY; let slope = isFinite(ray.slope) ? ray.slope : 0; ctx.beginPath(); ctx.moveTo(curX, curY); stages.forEach(st => { const lx = st.lensX; const hitY = curY + slope * (lx - curX); ctx.lineTo(lx, hitY); // thin-lens refraction: slope_out = slope_in - (hitY - ay) / f const hRel = hitY - ay; slope = slope - hRel / st.f; curX = lx; curY = hitY; }); const extX = Math.min(W + 10, curX + 400); ctx.lineTo(extX, curY + slope * (extX - curX)); ctx.stroke(); }); }); } _fbLens(ctx, lx, ay, f) { const lh = Math.min(this.H * 0.36, 130); const conv = f > 0; ctx.strokeStyle = 'rgba(155,93,229,0.8)'; ctx.lineWidth = 2.5; if (conv) { const b = Math.min(16, Math.abs(f) * 0.1); ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke(); } else { const b = Math.min(12, Math.abs(f) * 0.08); ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx - b, ay, lx, ay + lh); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.quadraticCurveTo(lx + b, ay, lx, ay + lh); ctx.stroke(); } ctx.strokeStyle = 'rgba(155,93,229,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, ay - lh); ctx.lineTo(lx, ay + lh); ctx.stroke(); // label f value ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(155,93,229,0.9)'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('f=' + f.toFixed(0), lx, ay + lh + 4); } _fbArrow(ctx, x1, y1, x2, y2, color, dashed) { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5; if (dashed) ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.setLineDash([]); if (Math.abs(y2 - y1) < 2) return; const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 9; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35)); ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35)); ctx.closePath(); ctx.fill(); } _drawInfoPanel(ctx, stages, totalM, sysFocal) { const bx = 8, by = 8, bw = 192, lh = 16; const rows = [{ text: 'Объект', col: '#9B5DE5' }]; stages.forEach((s, i) => { rows.push({ text: 'Лин.' + (i + 1) + ' f=' + s.f.toFixed(0), col: 'rgba(155,93,229,0.85)' }); const dpStr = isFinite(s.dI) ? s.dI.toFixed(0) : '∞'; const mStr = isFinite(s.M) ? s.M.toFixed(2) : '∞'; rows.push({ text: " Изобр." + (i + 1) + " d'=" + dpStr + ' M=' + mStr, col: isFinite(s.M) ? (s.isVirt ? '#FFD166' : '#EF476F') : '#888' }); }); const bh = rows.length * lh + 46; ctx.save(); ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; let cy = by + 8; rows.forEach(r => { ctx.fillStyle = r.col; ctx.fillText(r.text, bx + 8, cy); cy += lh; }); cy += 4; ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.fillText('Г = ' + (isFinite(totalM) ? totalM.toFixed(3) : '∞'), bx + 8, cy); cy += lh; if (sysFocal !== null) { ctx.fillStyle = '#06D6E0'; ctx.fillText('F сист. = ' + sysFocal.toFixed(0) + ' px', bx + 8, cy); } ctx.restore(); } _bindEvents() { const cv = this.canvas; const getPos = (e) => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) }; }; const hitTest = (mx, my) => { const ay = this.H / 2; for (let i = 0; i < this.elements.length; i++) { const lx = this.elements[i].x_frac * this.W; if (Math.abs(mx - lx) < 14 && Math.abs(my - ay) < 100) return { what: 'lens', idx: i }; } const ox = this.objFrac * this.W; if (Math.hypot(mx - ox, my - (ay - this.objH)) < 18) return { what: 'object' }; return null; }; cv.addEventListener('mousedown', e => { const p = getPos(e); this._drag = hitTest(p.mx, p.my); }); window.addEventListener('mousemove', e => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const { mx } = getPos(e); const frac = Math.max(0.02, Math.min(0.98, mx / this.W)); if (this._drag.what === 'object') { this.objFrac = frac; } else if (this._drag.what === 'lens') { this.elements[this._drag.idx].x_frac = frac; } this.draw(); if (this.onUpdate) this.onUpdate(this._computeChain()); }); window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; }); cv.addEventListener('mousemove', e => { if (this._drag) { cv.style.cursor = 'grabbing'; return; } const { mx, my } = getPos(e); cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; }); cv.addEventListener('touchstart', e => { if (e.touches.length === 1) { const p = getPos(e); this._drag = hitTest(p.mx, p.my); } }, { passive: true }); cv.addEventListener('touchmove', e => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const { mx } = getPos(e); const frac = Math.max(0.02, Math.min(0.98, mx / this.W)); if (this._drag.what === 'object') this.objFrac = frac; else if (this._drag.what === 'lens') this.elements[this._drag.idx].x_frac = frac; this.draw(); }, { passive: false }); cv.addEventListener('touchend', () => { this._drag = null; }); } addLens(f) { const lastFrac = this.elements.length ? Math.max(...this.elements.map(e => e.x_frac)) : 0.5; const newFrac = Math.min(0.92, lastFrac + 0.18); const id = (this.elements.reduce((m, e) => Math.max(m, e.id), 0)) + 1; this.elements.push({ x_frac: newFrac, f: +f || 100, id }); this.draw(); } removeLens() { if (this.elements.length > 1) this.elements.pop(); this.draw(); } setLensF(idx, f) { if (this.elements[idx]) { this.elements[idx].f = Math.max(-300, Math.min(300, +f || 100)); this.draw(); } } } /* ───────────────────────────────────────────────────────────── 4a-BIS. OPTICAL BENCH CONSTRUCTOR — general 2D ray tracer Mixed elements (lens, mirror, aperture, screen, prism) + sources. ───────────────────────────────────────────────────────────────*/ class BenchSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.onUpdate = null; this._drag = null; this._nextId = 1; // source: object arrow by default. `ang` (deg) aims point/single/laser/parallel. this.source = { kind: 'object', xf: 0.07, yf: 0, h: 70, spread: 0.32, rays: 9, ang: 0, rayMode: 'char' }; // elements along the bench, positioned by x-fraction; centred on the axis this.elements = [ this._mk('lens', { xf: 0.40, f: 130, ap: 95 }), this._mk('screen', { xf: 0.86 }), ]; this.selectedId = '__src'; // source selected by default so its controls show on open this._bindEvents(); this._ro = new ResizeObserver(() => { this.fit(); this.draw(); }); this._ro.observe(canvas.parentElement || canvas); } _mk(type, p) { const id = this._nextId++; const base = { id, type, xf: p.xf != null ? p.xf : 0.5 }; if (type === 'lens') return { ...base, f: p.f != null ? p.f : 130, ap: p.ap || 95 }; if (type === 'mirror') return { ...base, kind: p.kind || 'concave', R: p.R != null ? p.R : 320, ap: p.ap || 95 }; if (type === 'aperture') return { ...base, gap: p.gap != null ? p.gap : 40 }; if (type === 'screen') return { ...base }; if (type === 'prism') return { ...base, apex: p.apex != null ? p.apex : 50, n: p.n != null ? p.n : 1.52, size: p.size || 90 }; if (type === 'interface') return { ...base, n1: p.n1 != null ? p.n1 : 1, n2: p.n2 != null ? p.n2 : 1.5, ap: p.ap || 110 }; if (type === 'slab') return { ...base, n: p.n != null ? p.n : 1.5, t: p.t != null ? p.t : 60, ap: p.ap || 95 }; return base; } fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } /* ── element API (used by the inspector) ── */ addElement(type) { const xf = Math.min(0.92, (this.elements.length ? Math.max(...this.elements.map(e => e.xf)) : 0.4) + 0.14); const el = this._mk(type, { xf }); this.elements.push(el); this.selectedId = el.id; this._changed(); return el; } removeElement(id) { this.elements = this.elements.filter(e => e.id !== id); if (this.selectedId === id) this.selectedId = null; this._changed(); } selectElement(id) { this.selectedId = id; this._changed(); } updateElement(id, key, val) { const el = this.elements.find(e => e.id === id); if (!el) return; el[key] = (key === 'kind') ? val : +val; this._redraw(); // canvas only — never rebuild the inspector mid-slider-drag } setSource(key, val) { this.source[key] = (key === 'kind' || key === 'rayMode') ? val : +val; // string keys vs numeric this._redraw(); } getSelected() { return this.elements.find(e => e.id === this.selectedId) || null; } _redraw() { this.draw(); } _changed() { this.draw(); if (this.onUpdate) this.onUpdate(); } // draw + rebuild inspector /* ── geometry helpers ── */ _ex(el) { return el.xf * this.W; } _ay() { return this.H / 2; } _sy() { return this._ay() + (this.source.yf || 0) * (this.H / 2 - 14); } // source y (vertical position) /* Emit the initial rays from the source. */ _emitRays() { const ay = this._sy(); // emission height respects the source vertical position const sx = this.source.xf * this.W; const rays = []; // white light → one sub-ray per spectral sample (they coincide until a prism disperses them) const wls = window._obWhiteLight ? OB_SPECTRAL.map(s => s.nm) : [window._obWavelength || 540]; const push = (x, y, ang, role) => { for (const wl of wls) rays.push({ x, y, dx: Math.cos(ang), dy: Math.sin(ang), wl, role: role || null, pts: [{ x, y }], alive: true, bounces: 0 }); }; const aim = (this.source.ang || 0) * Math.PI / 180; if (this.source.kind === 'single') { push(sx, ay, aim); // one aimable ray } else if (this.source.kind === 'laser') { const n = 3, hh = 7; // narrow collimated beam const px = -Math.sin(aim), py = Math.cos(aim); // perpendicular to aim for (let i = 0; i < n; i++) { const o = (i - (n - 1) / 2) * hh; push(sx + px * o, ay + py * o, aim); } } else if (this.source.kind === 'parallel') { const n = 9, hh = 90; const px = -Math.sin(aim), py = Math.cos(aim); for (let i = 0; i < n; i++) { const o = -hh + (2 * hh) * (i / (n - 1)); push(sx + px * o, ay + py * o, aim); } } else if (this.source.kind === 'point') { const n = this.source.rays, A = this.source.spread; for (let i = 0; i < n; i++) push(sx, ay, aim - A + 2 * A * (i / (n - 1))); } else { // object arrow const axis = this._ay(); // optical axis (lens centre / focus sit here) const tipY = ay - this.source.h, baseY = ay; const firstLens = this.elements.filter(e => e.type === 'lens').sort((a, b) => a.xf - b.xf)[0]; if (this.source.rayMode === 'char' && firstLens) { // textbook construction: 2–3 characteristic rays from the tip + axial ray from the base const lensX = firstLens.xf * this.W, f = firstLens.f; const aimAt = (tx, ty) => Math.atan2(ty - tipY, tx - sx); push(sx, tipY, 0, 'char1'); // 1) parallel to axis → through far focus F' push(sx, tipY, aimAt(lensX, axis), 'char2'); // 2) through the optical centre → straight const Fx = lensX - f; // front focal point if (f > 0 && Fx > sx + 5) push(sx, tipY, aimAt(Fx, axis), 'char3'); // 3) through F → emerges parallel push(sx, baseY, 0, 'base'); // base lies on the axis } else { // physical bundle: a fan from tip and base const n = Math.max(2, this.source.rays | 0), A = this.source.spread; [tipY, baseY].forEach(y0 => { for (let i = 0; i < n; i++) push(sx, y0, -A + 2 * A * (i / (n - 1))); }); } } return rays; } /* Trace one ray through the system, filling ray.pts. */ _traceRay(ray) { const eps = 0.5, maxSteps = 40; const elems = this.elements; for (let step = 0; step < maxSteps && ray.alive; step++) { // find nearest element plane ahead let best = null; for (const el of elems) { const ex = this._ex(el); if (Math.abs(ray.dx) < 1e-6) continue; const t = (ex - ray.x) / ray.dx; if (t > eps && (!best || t < best.t)) best = { t, el, ex }; } // boundary intersection const tBound = this._boundT(ray); if (!best || tBound < best.t) { const hx = ray.x + ray.dx * tBound, hy = ray.y + ray.dy * tBound; ray.pts.push({ x: hx, y: hy }); ray.alive = false; break; } // advance to element const hx = ray.x + ray.dx * best.t, hy = ray.y + ray.dy * best.t; ray.x = hx; ray.y = hy; const interacted = this._interact(ray, best.el, hy - this._ay()); ray.pts.push({ x: ray.x, y: ray.y }); if (!interacted) { ray.x += ray.dx * eps; ray.y += ray.dy * eps; } // missed → step past if (ray.bounces > 16) ray.alive = false; } return ray; } _boundT(ray) { const ts = []; if (ray.dx > 1e-9) ts.push((this.W - ray.x) / ray.dx); else if (ray.dx < -1e-9) ts.push((0 - ray.x) / ray.dx); if (ray.dy > 1e-9) ts.push((this.H - ray.y) / ray.dy); else if (ray.dy < -1e-9) ts.push((0 - ray.y) / ray.dy); return ts.length ? Math.min(...ts.filter(t => t > 0)) : 1e6; } /* Apply an element. Returns true if it interacted (false = ray missed it). */ _interact(ray, el, yRel) { const norm = (x, y) => { const l = Math.hypot(x, y) || 1; ray.dx = x / l; ray.dy = y / l; }; if (el.type === 'lens') { if (Math.abs(yRel) > el.ap) { ray.alive = false; return true; } // mount blocks outside aperture const sgn = Math.sign(ray.dx) || 1; const w = ray.dy / Math.abs(ray.dx); norm(sgn, w - yRel / el.f); return true; } if (el.type === 'interface') { if (Math.abs(yRel) > el.ap) return false; // Snell at a vertical plane (normal = x). Tangential (y) component scales by n_i/n_t. const goingRight = ray.dx > 0; const ni = goingRight ? el.n1 : el.n2; const nt = goingRight ? el.n2 : el.n1; const dyT = (ni / nt) * ray.dy; // sinθ_t (tangential preserved) if (Math.abs(dyT) >= 1) { ray.dx = -ray.dx; ray.bounces++; return true; } // total internal reflection const sgn = Math.sign(ray.dx) || 1; ray.dy = dyT; ray.dx = sgn * Math.sqrt(Math.max(0, 1 - dyT * dyT)); return true; } if (el.type === 'slab') { if (Math.abs(yRel) > el.ap) return false; // parallel plate: ray exits parallel but laterally shifted (refract in, travel t, refract out) const sinI = ray.dy; // |d|=1 → y-comp is sinθ from axis const sinT = sinI / el.n; const tanT = sinT / Math.sqrt(Math.max(1e-6, 1 - sinT * sinT)); const sgn = Math.sign(ray.dx) || 1; ray.pts.push({ x: ray.x, y: ray.y }); // entry on the front face ray.x += sgn * el.t; // emerge on the far face ray.y += tanT * el.t; // inside-the-glass vertical travel return true; // direction unchanged (parallel faces); tracer pushes exit } if (el.type === 'mirror') { if (Math.abs(yRel) > el.ap) { ray.alive = false; return true; } ray.dx = -ray.dx; // reflect about the vertical plane ray.bounces++; if (el.kind !== 'plane') { const fM = (el.kind === 'concave' ? 1 : -1) * el.R / 2; const sgn = Math.sign(ray.dx) || 1; const w = ray.dy / Math.abs(ray.dx); norm(sgn, w - yRel / fM); } return true; } if (el.type === 'aperture') { if (Math.abs(yRel) > el.gap) ray.alive = false; // blocked by the stop return true; } if (el.type === 'screen') { ray.hitY = ray.y; ray.hitEl = el.id; ray.alive = false; // absorbed, hit recorded return true; } if (el.type === 'prism') { return this._prismInteract(ray, el, yRel); } return true; } // Thin-prism deviation δ = (n−1)·A toward the base, with chromatic dispersion // via n(λ) — different wavelengths bend differently → spectrum fan. _prismInteract(ray, el, yRel) { if (Math.abs(yRel) > el.size) return false; const n = (typeof _nAtWavelength === 'function') ? _nAtWavelength(el.n, ray.wl) : el.n; const A = el.apex * Math.PI / 180; const dev = (n - 1) * A; // radians, toward the base (+y) const sgn = Math.sign(ray.dx) || 1; const ang = Math.atan2(ray.dy, ray.dx) + sgn * dev; ray.dx = Math.cos(ang); ray.dy = Math.sin(ang); return true; } draw() { const { ctx, W, H } = this; if (!W || !H) return; const ay = this._ay(); ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); // optical axis ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); ctx.setLineDash([]); // trace rays — colour each by its wavelength (so dispersion shows as a fan) const rays = this._emitRays(); const white = !!window._obWhiteLight; ctx.lineWidth = 1.1; for (const ray of rays) { this._traceRay(ray); ctx.strokeStyle = (typeof wavelengthToRGB === 'function') ? wavelengthToRGB(ray.wl) : '#06D6E0'; ctx.globalAlpha = white ? 0.5 : 0.82; ctx.beginPath(); ray.pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke(); } ctx.globalAlpha = 1; // source + elements this._drawSource(ctx, ay); for (const el of this.elements) this._drawElement(ctx, el, ay); // image formed on screens (where rays land) this._drawScreenHits(ctx, rays); // textbook construction overlay (labels, image arrow, dashed extensions) if (this.source.kind === 'object' && (this.source.rayMode || 'char') === 'char') { this._drawCharConstruction(ctx, rays, ay); } if (typeof _drawOBFXLayer === 'function') { // FX anchored at the actual source point (only the object arrow has a raised tip) const fxY = this._sy() - (this.source.kind === 'object' ? this.source.h : 0); _drawOBFXLayer(ctx, 'freebuild', { srcX: this.source.xf * W, srcY: fxY }); } } // Glowing spots on each screen where rays land → the image becomes visible. _drawScreenHits(ctx, rays) { const screens = this.elements.filter(e => e.type === 'screen'); if (!screens.length) return; ctx.save(); ctx.globalCompositeOperation = 'lighter'; // additive → overlapping rays brighten for (const sc of screens) { const x = this._ex(sc); for (const r of rays) { if (r.hitEl !== sc.id) continue; const col = (typeof wavelengthToRGB === 'function') ? wavelengthToRGB(r.wl) : '#fff'; const g = ctx.createRadialGradient(x, r.hitY, 0, x, r.hitY, 9); g.addColorStop(0, col); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.globalAlpha = 0.5; ctx.beginPath(); ctx.arc(x, r.hitY, 9, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); } _lineIntersect(a, b) { const den = a.dx * b.dy - a.dy * b.dx; if (Math.abs(den) < 1e-9) return null; // parallel → no finite intersection const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den; return { x: a.x + t * a.dx, y: a.y + t * a.dy }; } // Textbook overlay for single-lens characteristic construction: // labels 1/2/3, the image arrow, and dashed back-extensions for a virtual image. _drawCharConstruction(ctx, rays, axis) { const lenses = this.elements.filter(e => e.type === 'lens'); if (lenses.length !== 1) return; // construction is only clean for one lens const lensX = this._ex(lenses[0]); ctx.save(); // ── ray labels 1/2/3 near the object ── ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const labelRay = (role, txt) => { const r = rays.find(x => x.role === role); if (!r || r.pts.length < 2) return; const p0 = r.pts[0], p1 = r.pts[1]; const d = Math.hypot(p1.x - p0.x, p1.y - p0.y) || 1; const k = Math.min(40, d * 0.45); const lx = p0.x + (p1.x - p0.x) / d * k, ly = p0.y + (p1.y - p0.y) / d * k; ctx.fillStyle = 'rgba(13,13,26,0.7)'; ctx.beginPath(); ctx.arc(lx, ly, 8, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#7BF5A4'; ctx.fillText(txt, lx, ly); }; labelRay('char1', '1'); labelRay('char2', '2'); labelRay('char3', '3'); // ── image point = intersection of the final segments of rays 1 and 2 ── const finalLine = (role) => { const r = rays.find(x => x.role === role); if (!r || r.pts.length < 2) return null; const b = r.pts[r.pts.length - 1], a = r.pts[r.pts.length - 2]; return { x: a.x, y: a.y, dx: b.x - a.x, dy: b.y - a.y }; }; const L1 = finalLine('char1'), L2 = finalLine('char2'); const P = (L1 && L2) ? this._lineIntersect(L1, L2) : null; if (P && isFinite(P.x) && isFinite(P.y)) { const real = P.x > lensX + 2; // real image forms to the right of the lens const col = real ? '#EF476F' : '#FFD166'; if (!real) { // virtual image: extend the diverging rays backward (dashed) to the apparent source P ctx.strokeStyle = col; ctx.setLineDash([5, 4]); ctx.lineWidth = 1; [L1, L2].forEach(L => { if (!L) return; ctx.beginPath(); ctx.moveTo(L.x, L.y); ctx.lineTo(P.x, P.y); ctx.stroke(); }); ctx.setLineDash([]); } this._arrow(ctx, P.x, axis, P.x, P.y, col); // image arrow: base (axis) → tip (P) ctx.fillStyle = col; ctx.beginPath(); ctx.arc(P.x, P.y, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillText(real ? 'изображение' : 'мнимое изобр.', P.x, P.y + (P.y < axis ? -14 : 16)); } ctx.restore(); } _drawSource(ctx, _ayIgnored) { const ay = this._sy(); // draw at the source vertical position const sx = this.source.xf * this.W; const aim = (this.source.ang || 0) * Math.PI / 180; ctx.save(); if (this.source.kind === 'object') { this._arrow(ctx, sx, ay, sx, ay - this.source.h, '#9B5DE5'); } else { const isLaser = this.source.kind === 'laser', isSingle = this.source.kind === 'single'; ctx.fillStyle = (this.source.kind === 'point' || isSingle) ? '#FFD166' : '#9B5DE5'; ctx.beginPath(); ctx.arc(sx, ay, isLaser ? 4 : 5, 0, Math.PI * 2); ctx.fill(); if (this.source.kind === 'parallel' || isLaser) { const px = -Math.sin(aim), py = Math.cos(aim), hh = isLaser ? 10 : 90; ctx.strokeStyle = isLaser ? '#FF5B5B' : '#9B5DE5'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(sx - px * hh, ay - py * hh); ctx.lineTo(sx + px * hh, ay + py * hh); ctx.stroke(); } // aim arrow for single / laser / point if (isSingle || isLaser || this.source.kind === 'point') { this._arrow(ctx, sx, ay, sx + 22 * Math.cos(aim), ay + 22 * Math.sin(aim), isLaser ? '#FF5B5B' : '#FFD166'); } } const sel = this.selectedId === '__src'; ctx.fillStyle = sel ? '#fff' : 'rgba(155,93,229,0.9)'; ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('источник', sx, ay + 16); ctx.restore(); } _drawElement(ctx, el, ay) { const x = this._ex(el); const sel = el.id === this.selectedId; ctx.save(); ctx.lineWidth = sel ? 3 : 2; if (el.type === 'lens') { const conv = el.f >= 0; ctx.strokeStyle = sel ? '#fff' : '#06D6E0'; ctx.beginPath(); ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); ctx.stroke(); // arrow tips to denote converging/diverging const tip = conv ? 7 : -7; [[ay - el.ap, 1], [ay + el.ap, -1]].forEach(([yy, s]) => { ctx.beginPath(); ctx.moveTo(x, yy); ctx.lineTo(x - tip, yy + s * 7); ctx.moveTo(x, yy); ctx.lineTo(x + tip, yy + s * 7); ctx.stroke(); }); // focal markers F and 2F on both sides of the lens if (conv) { ctx.fillStyle = 'rgba(6,214,224,0.6)'; [el.f, -el.f, 2 * el.f, -2 * el.f].forEach((d, i) => { const fx = x + d; if (fx < 6 || fx > this.W - 6) return; ctx.beginPath(); ctx.arc(fx, ay, 3, 0, Math.PI * 2); ctx.fill(); this._elLabel(ctx, fx, ay - 16, i < 2 ? 'F' : '2F'); }); } this._elLabel(ctx, x, ay + el.ap + 14, (conv ? 'линза +' : 'линза −') + Math.abs(el.f).toFixed(0)); } else if (el.type === 'interface') { ctx.fillStyle = 'rgba(96,165,250,0.10)'; ctx.fillRect(x, ay - el.ap, this.W - x, 2 * el.ap); ctx.strokeStyle = sel ? '#fff' : '#60a5fa'; ctx.beginPath(); ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); ctx.stroke(); this._elLabel(ctx, x, ay + el.ap + 14, 'граница ' + el.n1.toFixed(2) + ' | ' + el.n2.toFixed(2)); } else if (el.type === 'slab') { ctx.fillStyle = 'rgba(123,245,164,0.10)'; ctx.fillRect(x, ay - el.ap, el.t, 2 * el.ap); ctx.strokeStyle = sel ? '#fff' : '#7BF5A4'; ctx.strokeRect(x, ay - el.ap, el.t, 2 * el.ap); this._elLabel(ctx, x + el.t / 2, ay + el.ap + 14, 'пластина n=' + el.n.toFixed(2)); } else if (el.type === 'mirror') { ctx.strokeStyle = sel ? '#fff' : '#A8E063'; ctx.beginPath(); if (el.kind === 'plane') { ctx.moveTo(x, ay - el.ap); ctx.lineTo(x, ay + el.ap); } else { const bow = (el.kind === 'concave' ? -1 : 1) * 14; ctx.moveTo(x, ay - el.ap); ctx.quadraticCurveTo(x + bow, ay, x, ay + el.ap); } ctx.stroke(); // hatch backside ctx.strokeStyle = 'rgba(168,224,99,0.4)'; ctx.lineWidth = 1; for (let yy = -el.ap; yy < el.ap; yy += 12) { ctx.beginPath(); ctx.moveTo(x, ay + yy); ctx.lineTo(x + 6, ay + yy + 6); ctx.stroke(); } this._elLabel(ctx, x, ay + el.ap + 14, 'зеркало ' + ({ plane: 'плоск', concave: 'вогн', convex: 'выпукл' }[el.kind])); } else if (el.type === 'aperture') { ctx.strokeStyle = sel ? '#fff' : '#EF476F'; ctx.lineWidth = sel ? 5 : 4; ctx.beginPath(); ctx.moveTo(x, ay - 110); ctx.lineTo(x, ay - el.gap); ctx.moveTo(x, ay + el.gap); ctx.lineTo(x, ay + 110); ctx.stroke(); this._elLabel(ctx, x, ay + 124, 'диафрагма'); } else if (el.type === 'screen') { ctx.strokeStyle = sel ? '#fff' : 'rgba(255,255,255,0.7)'; ctx.lineWidth = sel ? 5 : 4; ctx.beginPath(); ctx.moveTo(x, ay - 110); ctx.lineTo(x, ay + 110); ctx.stroke(); this._elLabel(ctx, x, ay + 124, 'экран'); } else if (el.type === 'prism') { ctx.strokeStyle = sel ? '#fff' : '#FFD166'; ctx.fillStyle = 'rgba(255,209,102,0.12)'; ctx.beginPath(); ctx.moveTo(x, ay - el.size); ctx.lineTo(x + el.size * 0.7, ay + el.size); ctx.lineTo(x - el.size * 0.7, ay + el.size); ctx.closePath(); ctx.fill(); ctx.stroke(); this._elLabel(ctx, x, ay + el.size + 14, 'призма'); } ctx.restore(); } _elLabel(ctx, x, y, text) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(text, x, y); } _arrow(ctx, x0, y0, x1, y1, color) { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); const a = Math.atan2(y1 - y0, x1 - x0); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1 - 9 * Math.cos(a - 0.4), y1 - 9 * Math.sin(a - 0.4)); ctx.lineTo(x1 - 9 * Math.cos(a + 0.4), y1 - 9 * Math.sin(a + 0.4)); ctx.closePath(); ctx.fill(); } /* ── interaction: drag + select ── */ _bindEvents() { const cv = this.canvas; this._listeners = []; const on = (t, ty, fn, o) => { t.addEventListener(ty, fn, o); this._listeners.push([t, ty, fn, o]); }; const pos = (e) => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) }; }; const hit = (mx, my) => { const ay = this._ay(); // source first (it can sit off-axis), grab around its actual vertical position const sx = this.source.xf * this.W, sy = this._sy(); if (Math.abs(mx - sx) < 16 && Math.abs(my - sy) < 70) return { kind: 'src' }; for (const el of this.elements) { if (Math.abs(mx - this._ex(el)) < 14 && Math.abs(my - ay) < 120) return { kind: 'el', id: el.id }; } return null; }; on(cv, 'pointerdown', e => { const { mx, my } = pos(e); const h = hit(mx, my); this._drag = h; if (h) { this.selectedId = h.kind === 'src' ? '__src' : h.id; try { cv.setPointerCapture(e.pointerId); } catch (_) {} this._changed(); } else { this.selectedId = null; this._changed(); } }); on(cv, 'pointermove', e => { if (!this._drag) { const { mx, my } = pos(e); cv.style.cursor = hit(mx, my) ? 'grab' : 'default'; return; } const { mx, my } = pos(e); const xf = Math.max(0.02, Math.min(0.98, mx / this.W)); if (this._drag.kind === 'src') { this.source.xf = xf; // vertical drag too → move the source up/down off the axis const yf = (my - this._ay()) / (this.H / 2 - 14); this.source.yf = Math.max(-0.95, Math.min(0.95, yf)); } else { const el = this.elements.find(x => x.id === this._drag.id); if (el) el.xf = xf; } this._redraw(); // position drag → redraw canvas, keep inspector intact }); on(cv, 'pointerup', e => { this._drag = null; try { cv.releasePointerCapture(e.pointerId); } catch (_) {} }); } exportPng() { try { return this.canvas.toDataURL('image/png'); } catch (_) { return null; } } dispose() { if (this._ro) { this._ro.disconnect(); this._ro = null; } if (this._listeners) { for (const [t, ty, fn, o] of this._listeners) t.removeEventListener(ty, fn, o); this._listeners = []; } } /* ── state (for snapshot / embed) ── */ getState() { return { source: { ...this.source }, elements: this.elements.map(e => ({ ...e })) }; } setState(st) { if (!st) return; if (st.source) this.source = { ...this.source, ...st.source }; if (Array.isArray(st.elements)) { this.elements = st.elements.map(e => ({ ...e })); this._nextId = this.elements.reduce((m, e) => Math.max(m, e.id || 0), 0) + 1; } this._changed(); } } /* ───────────────────────────────────────────────────────────── 4b. PRISM ENGINE ───────────────────────────────────────────────────────────────*/ class PrismSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.apexAngle = 60; this.n0 = 1.5; this.rotation = 0; this.incAngle = 30; this._drag = null; this.onUpdate = null; this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } getParams() { return { apexAngle: this.apexAngle, n0: this.n0, rotation: this.rotation, incAngle: this.incAngle }; } setParams({ apexAngle, n0, rotation, incAngle } = {}) { if (apexAngle !== undefined) this.apexAngle = Math.max(20, Math.min(80, +apexAngle)); if (n0 !== undefined) this.n0 = Math.max(1.3, Math.min(2.5, +n0)); if (rotation !== undefined) this.rotation = +rotation % 360; if (incAngle !== undefined) this.incAngle = Math.max(0, Math.min(80, +incAngle)); this.draw(); this._emit(); } reset() { this.apexAngle = 60; this.n0 = 1.5; this.rotation = 0; this.incAngle = 30; this.draw(); this._emit(); } _emit() { if (this.onUpdate) this.onUpdate(this.getParams()); } draw() { const ctx = this.ctx, W = this.W, H = this.H; if (!W || !H) return; ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.setLineDash([6,4]); ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke(); ctx.setLineDash([]); const cx = W / 2, cy = H / 2; const size = Math.min(W, H) * 0.30; const rotRad = this.rotation * Math.PI / 180; const topA = -Math.PI / 2 + rotRad; const v = [0, 1, 2].map(i => ({ x: cx + size * Math.cos(topA + i * (2 * Math.PI / 3)), y: cy + size * Math.sin(topA + i * (2 * Math.PI / 3)), })); // Prism body const prismGrad = ctx.createLinearGradient(v[0].x, v[0].y, v[1].x, v[1].y); prismGrad.addColorStop(0, 'rgba(100,180,255,0.07)'); prismGrad.addColorStop(0.5, 'rgba(100,180,255,0.16)'); prismGrad.addColorStop(1, 'rgba(100,180,255,0.07)'); ctx.beginPath(); ctx.moveTo(v[0].x, v[0].y); ctx.lineTo(v[1].x, v[1].y); ctx.lineTo(v[2].x, v[2].y); ctx.closePath(); ctx.fillStyle = prismGrad; ctx.fill(); ctx.strokeStyle = 'rgba(100,180,255,0.5)'; ctx.lineWidth = 2; ctx.stroke(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(100,180,255,0.7)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('n = ' + this.n0.toFixed(2), cx, cy + 4); const isWhite = window._obWhiteLight; const monoNm = window._obWavelength || 550; const spectral = isWhite ? OB_SPECTRAL : [{ nm: monoNm }]; // Entry face: v[0] → v[2]; inward normal toward center const efVec = { x: v[2].x - v[0].x, y: v[2].y - v[0].y }; const efLen = Math.hypot(efVec.x, efVec.y); let efNorm = { x: -efVec.y / efLen, y: efVec.x / efLen }; if (efNorm.x * (cx - v[0].x) + efNorm.y * (cy - v[0].y) < 0) { efNorm = { x: -efNorm.x, y: -efNorm.y }; } const eX = (v[0].x + v[2].x) / 2, eY = (v[0].y + v[2].y) / 2; /* tangDir = 90° CW rotation of efNorm so that positive incAngle tilts the ray toward the apex side (natural source-from-left setup) */ const tangDir = { x: efNorm.y, y: -efNorm.x }; const incRad = this.incAngle * Math.PI / 180; /* incDir = propagation direction (INTO the prism) */ const incDir = { x: Math.cos(incRad) * efNorm.x + Math.sin(incRad) * tangDir.x, y: Math.cos(incRad) * efNorm.y + Math.sin(incRad) * tangDir.y, }; const incDLen = Math.hypot(incDir.x, incDir.y); incDir.x /= incDLen; incDir.y /= incDLen; const rayLen = W * 0.45; /* draw incoming ray from outside (source side) to entry midpoint */ this._drawRayLine(ctx, eX - incDir.x * rayLen, eY - incDir.y * rayLen, eX, eY, isWhite ? '#FFFFFF' : wavelengthToRGB(monoNm), 2.5); // Exit face: v[0] → v[1]; outward normal const exfVec = { x: v[1].x - v[0].x, y: v[1].y - v[0].y }; const exfLen = Math.hypot(exfVec.x, exfVec.y); let exfNorm = { x: -exfVec.y / exfLen, y: exfVec.x / exfLen }; if (exfNorm.x * (cx - v[0].x) + exfNorm.y * (cy - v[0].y) > 0) { exfNorm = { x: -exfNorm.x, y: -exfNorm.y }; } const exitPts = []; for (const s of spectral) { const nP = _nAtWavelength(this.n0, s.nm); const col = wavelengthToRGB(s.nm); /* Snell vector form: l = incDir (propagation), n = -efNorm (toward incident medium) cosθ_i = -n·l = efNorm·l; r = μl + (μcosθ_i - cosθ_t)·n */ const cosI = (incDir.x * efNorm.x + incDir.y * efNorm.y); const sinR = Math.sqrt(Math.max(0, 1 - cosI * cosI)) / nP; if (sinR > 1) continue; const cosR = Math.sqrt(1 - sinR * sinR); const rDir = { x: (1/nP) * incDir.x + (cosR - (1/nP) * cosI) * efNorm.x, y: (1/nP) * incDir.y + (cosR - (1/nP) * cosI) * efNorm.y, }; const rDLen = Math.hypot(rDir.x, rDir.y); rDir.x /= rDLen; rDir.y /= rDLen; // Intersect with exit face segment v[0]→v[1] const det = rDir.x * exfVec.y - rDir.y * exfVec.x; if (Math.abs(det) < 0.001) continue; const d0x = v[0].x - eX, d0y = v[0].y - eY; const tRay = (d0x * exfVec.y - d0y * exfVec.x) / det; const sFace = (d0x * rDir.y - d0y * rDir.x) / det; if (tRay < 0 || sFace < -0.01 || sFace > 1.01) continue; const exitX = eX + tRay * rDir.x, exitY = eY + tRay * rDir.y; ctx.globalAlpha = isWhite ? 0.65 : 1; this._drawRayLine(ctx, eX, eY, exitX, exitY, col, isWhite ? 1.5 : 2); ctx.globalAlpha = 1; // Snell at exit face: n1=nP, n2=1 (vector form) const inward = { x: -exfNorm.x, y: -exfNorm.y }; const cosEx = -(rDir.x * inward.x + rDir.y * inward.y); const sinEx2 = nP * Math.sqrt(Math.max(0, 1 - cosEx * cosEx)); if (sinEx2 > 1) continue; // TIR const cosEx2 = Math.sqrt(1 - sinEx2 * sinEx2); const eDir = { x: nP * rDir.x + (nP * cosEx - cosEx2) * inward.x, y: nP * rDir.y + (nP * cosEx - cosEx2) * inward.y, }; const eDLen = Math.hypot(eDir.x, eDir.y); eDir.x /= eDLen; eDir.y /= eDLen; ctx.globalAlpha = isWhite ? 0.85 : 1; this._drawRayLine(ctx, exitX, exitY, exitX + eDir.x * rayLen, exitY + eDir.y * rayLen, col, isWhite ? 2 : 2.5); ctx.globalAlpha = 1; exitPts.push({ nm: s.nm, col }); } // Drag hint ring ctx.strokeStyle = 'rgba(100,180,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([3,4]); ctx.beginPath(); ctx.arc(cx, cy, size * 1.18, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); // Info box ctx.fillStyle = 'rgba(22,22,38,0.88)'; ctx.beginPath(); ctx.roundRect(12, 12, 224, 52, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('Призма: A = ' + this.apexAngle + '°', 22, 22); ctx.fillStyle = 'rgba(100,180,255,0.8)'; ctx.fillText('n(λ) = ' + this.n0.toFixed(2) + ' − 0.0002·(λ−550)', 22, 40); _obUpdateSpectrometer(exitPts); // OB_FX visual depth layer (prism: source flare + wavefronts) _drawOBFXLayer(ctx, 'prism', { srcX: this.W * 0.1, srcY: this.H / 2 }); if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); } } _drawRayLine(ctx, x1, y1, x2, y2, color, width) { const fn = () => { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); }; if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 6 }); else fn(); } _bindEvents() { const cv = this.canvas; const getPos = (e) => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) }; }; const hitTest = (mx, my) => Math.hypot(mx - this.W/2, my - this.H/2) < Math.min(this.W, this.H) * 0.35; const onDown = (e) => { const { mx, my } = getPos(e); if (hitTest(mx, my)) this._drag = { sx: mx, sy: my, sRot: this.rotation, sInc: this.incAngle }; }; const onMove = (e) => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const { mx, my } = getPos(e); this.rotation = this._drag.sRot + (mx - this._drag.sx) * 0.4; this.incAngle = Math.max(0, Math.min(80, this._drag.sInc + (my - this._drag.sy) * 0.3)); this.draw(); this._emit(); }; const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; }; cv.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true }); cv.addEventListener('touchmove', e => onMove(e), { passive: false }); cv.addEventListener('touchend', onUp); cv.addEventListener('mousemove', e => { if (this._drag) { cv.style.cursor = 'grabbing'; return; } const { mx, my } = getPos(e); cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; }); } } /* ───────────────────────────────────────────────────────────── 4d. INTERFERENCE SIM — Newton's rings / Thin film / Polarization Agent C — additive only, class InterferenceSim ─────────────────────────────────────────────────────────────*/ class InterferenceSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.subMode = 'newton'; // Newton rings this.nR = 200; this.nNmax = 12; // Thin film this.tfT = 400; this.tfN = 1.33; this.tfTheta = 0; this.tfPreset = 'soap'; // Polarization this.polTheta = 45; this.polSrc = 'unpolarized'; this._polTick = 0; this._polRaf = null; this.onUpdate = null; new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement || canvas); } fit() { const p = this.canvas.parentElement; if (!p) return; const r = p.getBoundingClientRect(); this.W = this.canvas.width = r.width || p.offsetWidth || 600; this.H = this.canvas.height = r.height || p.offsetHeight || 400; } setSubMode(sm) { this.subMode = sm; if (sm === 'polarization') { this._polStart(); } else { this._polStop(); } this.draw(); if (this.onUpdate) this.onUpdate(); } /* ── Newton Rings ──────────────────────────────────────── */ _drawNewton() { const { ctx, W, H } = this; const nm = window._obWavelength || 550; const R = this.nR; const nMax = this.nNmax; const white = window._obWhiteLight; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#08081a'; ctx.fillRect(0, 0, W, H); const topH = Math.floor(H * 0.60); const cx = W / 2, cy = topH / 2; const maxR_mm = Math.sqrt(nMax * nm * 1e-6 * R); const scale = Math.min(cx * 0.85, cy * 0.85) / (maxR_mm || 1); for (let n = nMax; n >= 0; n--) { const lambdas = white ? [420, 470, 510, 550, 590, 620, 680] : [nm]; for (const lam of lambdas) { const rDark = Math.sqrt(n * lam * 1e-6 * R) * scale; const rBright = Math.sqrt((n + 0.5) * lam * 1e-6 * R) * scale; if (rDark > 0.5) { ctx.beginPath(); ctx.arc(cx, cy, rDark, 0, Math.PI * 2); ctx.strokeStyle = white ? wavelengthToRGB(lam).replace(')', ',0.5)').replace('rgb', 'rgba') : '#000000'; ctx.lineWidth = white ? 1.2 : 1.5; ctx.stroke(); } if (rBright > 0.5) { const al = white ? 0.22 : 0.55; ctx.beginPath(); ctx.arc(cx, cy, rBright, 0, Math.PI * 2); ctx.strokeStyle = wavelengthToRGB(lam).replace(')', ',' + al + ')').replace('rgb', 'rgba'); ctx.lineWidth = 2.5; ctx.stroke(); } } } ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fillStyle = '#000000'; ctx.fill(); if (window.LabFX && LabFX.glow && !white) { const r1b = Math.sqrt(0.5 * nm * 1e-6 * R) * scale; const ringColor = wavelengthToRGB(nm); LabFX.glow.drawGlow(ctx, function() { ctx.beginPath(); ctx.arc(cx, cy, r1b, 0, Math.PI * 2); ctx.strokeStyle = ringColor; ctx.lineWidth = 2; ctx.stroke(); }, { color: ringColor, intensity: 18 }); } ctx.beginPath(); ctx.arc(cx, cy, maxR_mm * scale * 1.05, 0, Math.PI * 2); ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.stroke(); const crossY0 = topH + 8; const crossH = H - crossY0 - 40; if (crossH < 30) return; ctx.fillStyle = '#0d0d20'; ctx.fillRect(0, crossY0, W, crossH + 36); const glassY = crossY0 + crossH - 10; ctx.fillStyle = '#1a3a5c'; ctx.fillRect(cx - maxR_mm * scale * 1.1, glassY, maxR_mm * scale * 2.2, 10); const sagitta = (maxR_mm * maxR_mm) / (2 * R); const sagPx = sagitta * scale; ctx.beginPath(); ctx.ellipse(cx, glassY - 1 - sagPx, maxR_mm * scale * 1.1, sagPx + 6, 0, 0, Math.PI); ctx.fillStyle = 'rgba(100,180,255,0.15)'; ctx.fill(); ctx.strokeStyle = '#4499cc'; ctx.lineWidth = 1.5; ctx.stroke(); for (let n = 0; n <= nMax; n++) { const rD = Math.sqrt(n * nm * 1e-6 * R) * scale; if (rD < 1) continue; ctx.beginPath(); ctx.moveTo(cx + rD, glassY); ctx.lineTo(cx + rD, glassY + 8); ctx.moveTo(cx - rD, glassY); ctx.lineTo(cx - rD, glassY + 8); ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke(); } ctx.font = '600 11px monospace'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center'; ctx.fillText('Cross-section', cx, crossY0 + 14); const r1d = Math.sqrt(nm * 1e-6 * R).toFixed(3); this._drawHUD(ctx, W, H, 'r1 = sqrt(lam*R) = ' + r1d + ' mm | R=' + R + 'mm | lam=' + nm + 'nm'); } /* ── Thin Film ─────────────────────────────────────────── */ _thinFilmColor(t_nm, n_film, theta_deg) { const sinR = Math.sin(theta_deg * Math.PI / 180) / n_film; const cosR = Math.sqrt(Math.max(0, 1 - sinR * sinR)); const opd = 2 * n_film * t_nm * cosR; let rS = 0, gS = 0, bS = 0; for (let lam = 380; lam <= 780; lam += 5) { const phase = Math.PI * opd / lam; const I = Math.cos(phase) * Math.cos(phase); const rgb = wavelengthToRGB(lam); const m = rgb.match(/d+/g); if (!m) continue; rS += I * +m[0]; gS += I * +m[1]; bS += I * +m[2]; } const sc = 255 / Math.max(rS, gS, bS, 1); return 'rgb(' + Math.round(rS * sc) + ',' + Math.round(gS * sc) + ',' + Math.round(bS * sc) + ')'; } _drawThinFilm() { const { ctx, W, H } = this; const t = this.tfT; const nf = this.tfN; const theta = this.tfTheta; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#08081a'; ctx.fillRect(0, 0, W, H); const midY = H * 0.40; const filmH = Math.max(28, H * 0.12); const margin = W * 0.10; const ang = theta * Math.PI / 180; const skew = Math.tan(ang) * filmH * 0.5; const grad = ctx.createLinearGradient(margin, 0, W - margin, 0); for (let i = 0; i <= 20; i++) { const frac = i / 20; grad.addColorStop(frac, this._thinFilmColor(t * (0.3 + 0.7 * frac), nf, theta)); } ctx.save(); ctx.beginPath(); ctx.moveTo(margin - skew, midY - filmH / 2); ctx.lineTo(W - margin - skew, midY - filmH / 2); ctx.lineTo(W - margin + skew, midY + filmH / 2); ctx.lineTo(margin + skew, midY + filmH / 2); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); ctx.font = '700 11px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.textAlign = 'center'; ctx.fillText('t=' + t + 'nm n=' + nf.toFixed(2), W / 2, midY); const ax2 = W * 0.25, ay2 = midY - filmH / 2; const ax1 = ax2 - Math.cos(ang) * 40, ay1 = ay2 - Math.sin(ang) * 40 - 20; ctx.beginPath(); ctx.moveTo(ax1, ay1); ctx.lineTo(ax2, ay2); ctx.strokeStyle = '#8ab4e8'; ctx.lineWidth = 1.5; ctx.stroke(); const col = this._thinFilmColor(t, nf, theta); ctx.beginPath(); ctx.moveTo(ax2, ay2); ctx.lineTo(ax2 - Math.cos(ang) * 40, ay1); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.stroke(); const dx2 = Math.sin(ang) * filmH / nf; ctx.beginPath(); ctx.moveTo(ax2 + dx2, ay2 + filmH); ctx.lineTo(ax2 + dx2 - Math.cos(ang) * 40, ay1 + filmH - 20); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.setLineDash([4, 3]); ctx.stroke(); ctx.setLineDash([]); const tvX0 = W * 0.55, tvW2 = W * 0.38; const tvY0 = H * 0.05, tvH2 = H * 0.60; ctx.fillStyle = '#0d0d22'; ctx.strokeStyle = '#2a2a4a'; ctx.lineWidth = 1; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(tvX0, tvY0, tvW2, tvH2, 8); else ctx.rect(tvX0, tvY0, tvW2, tvH2); ctx.fill(); ctx.stroke(); ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#555'; ctx.textAlign = 'center'; ctx.fillText('Top view', tvX0 + tvW2 / 2, tvY0 + 14); const tvRows = 28, tvCols = 36; const cW = tvW2 / tvCols, cH = (tvH2 - 20) / tvRows; for (let r = 0; r < tvRows; r++) { for (let c = 0; c < tvCols; c++) { ctx.fillStyle = this._thinFilmColor(t * (0.5 + c / tvCols), nf, theta * (r / tvRows)); ctx.fillRect(tvX0 + c * cW, tvY0 + 20 + r * cH, cW + 0.5, cH + 0.5); } } const sinR2 = Math.sin(ang) / nf; const cosR2 = Math.sqrt(Math.max(0, 1 - sinR2 * sinR2)); const opd2 = (2 * nf * t * cosR2).toFixed(0); this._drawHUD(ctx, W, H, '2nt*cos(th_r)=' + opd2 + 'nm | t=' + t + 'nm n=' + nf.toFixed(2) + ' th=' + theta + 'deg'); } /* ── Polarization ──────────────────────────────────────── */ _polStart() { if (this._polRaf) return; const loop = () => { this._polTick++; this.draw(); this._polRaf = requestAnimationFrame(loop); }; this._polRaf = requestAnimationFrame(loop); } _polStop() { if (this._polRaf) { cancelAnimationFrame(this._polRaf); this._polRaf = null; } } _drawPolarization() { const { ctx, W, H } = this; const theta = this.polTheta * Math.PI / 180; const I_rel = Math.cos(theta) * Math.cos(theta); const tick = this._polTick; const white = window._obWhiteLight; const nm = window._obWavelength || 550; const beamCol = white ? '#ffffff' : wavelengthToRGB(nm); ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#08081a'; ctx.fillRect(0, 0, W, H); const axisY = H * 0.45; const stH = H * 0.38; const st = [ { x: W * 0.12, label: 'Источник', isFilter: false }, { x: W * 0.38, label: 'Поляризатор P1', isFilter: true, angle: 0 }, { x: W * 0.64, label: 'Анализатор P2', isFilter: true, angle: this.polTheta }, { x: W * 0.88, label: 'Детектор', isFilter: false }, ]; ctx.beginPath(); ctx.moveTo(st[0].x - 20, axisY); ctx.lineTo(st[3].x + 20, axisY); ctx.strokeStyle = '#1a1a35'; ctx.lineWidth = 1; ctx.stroke(); const segs = [ { x0: st[0].x, x1: st[1].x, amp: 1, unpol: this.polSrc === 'unpolarized', ang: 0 }, { x0: st[1].x, x1: st[2].x, amp: 1, unpol: false, ang: 0 }, { x0: st[2].x, x1: st[3].x, amp: I_rel, unpol: false, ang: this.polTheta }, ]; for (const seg of segs) { const nA = 20; const sdx = (seg.x1 - seg.x0) / nA; for (let i = 0; i <= nA; i++) { const bx = seg.x0 + i * sdx; const phase = (bx * 0.08 - tick * 0.04) % (Math.PI * 2); const bAmp = stH * 0.28 * seg.amp; if (seg.unpol) { for (let d = 0; d < 4; d++) { const a = d * Math.PI / 4; const oy = Math.sin(phase + d * 0.7) * bAmp; ctx.beginPath(); ctx.moveTo(bx, axisY); ctx.lineTo(bx + oy * Math.sin(a) * 0.25, axisY + oy * Math.cos(a)); ctx.strokeStyle = 'rgba(200,200,255,0.22)'; ctx.lineWidth = 1; ctx.stroke(); } } else { const oy = Math.sin(phase) * bAmp; const a = seg.ang * Math.PI / 180; const py = oy * Math.cos(a), px = oy * Math.sin(a) * 0.35; ctx.beginPath(); ctx.moveTo(bx - px, axisY - py); ctx.lineTo(bx + px, axisY + py); ctx.strokeStyle = (I_rel < 0.01 && seg.amp < 0.5) ? 'rgba(80,80,120,0.5)' : beamCol.replace(')', ',0.75)').replace('rgb', 'rgba'); ctx.lineWidth = 1.5; ctx.stroke(); if (i % 3 === 0 && bAmp > 2) { ctx.beginPath(); ctx.arc(bx + px, axisY + py, 2, 0, Math.PI * 2); ctx.fillStyle = beamCol; ctx.fill(); } } } } for (const s of st) { if (!s.isFilter) continue; const a = s.angle * Math.PI / 180; ctx.save(); ctx.translate(s.x, axisY); ctx.fillStyle = 'rgba(80,120,200,0.18)'; ctx.fillRect(-4, -stH / 2, 8, stH); ctx.strokeStyle = '#4466aa'; ctx.lineWidth = 1.5; ctx.strokeRect(-4, -stH / 2, 8, stH); const axLen = stH * 0.45; ctx.beginPath(); ctx.moveTo(-Math.sin(a) * axLen, -Math.cos(a) * axLen); ctx.lineTo( Math.sin(a) * axLen, Math.cos(a) * axLen); ctx.strokeStyle = '#7aaeff'; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); ctx.font = '700 10px monospace'; ctx.fillStyle = '#7aaeff'; ctx.textAlign = 'center'; ctx.fillText(s.angle + 'deg', s.x, axisY + stH / 2 + 14); } for (const s of st) { ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center'; ctx.fillText(s.label, s.x, axisY - stH / 2 - 8); } const barX = W * 0.91, barW = 16; const barY0 = axisY - stH / 2; ctx.fillStyle = '#111122'; ctx.fillRect(barX, barY0, barW, stH); const fillH2 = stH * I_rel; if (fillH2 > 0) { const bg = ctx.createLinearGradient(barX, barY0 + stH - fillH2, barX, barY0 + stH); bg.addColorStop(0, beamCol); bg.addColorStop(1, 'rgba(0,0,0,0.2)'); ctx.fillStyle = bg; ctx.fillRect(barX, barY0 + stH - fillH2, barW, fillH2); } ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.strokeRect(barX, barY0, barW, stH); ctx.font = '600 9px monospace'; ctx.fillStyle = '#aaaaaa'; ctx.textAlign = 'center'; ctx.fillText('I', barX + barW / 2, barY0 - 5); if (this.polTheta >= 88) { ctx.font = '700 13px sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center'; ctx.fillText('Полное гашение', W / 2, H * 0.85); } ctx.font = '10px sans-serif'; ctx.fillStyle = '#444466'; ctx.textAlign = 'right'; ctx.fillText('Угол Брюстера: отражённый свет поляризован (см. Преломление)', W - 10, H - 10); const pct = (I_rel * 100).toFixed(1); this._drawHUD(ctx, W, H, 'I/I0=cos2(th)=cos2(' + this.polTheta + 'deg)=' + I_rel.toFixed(3) + ' (' + pct + '%)'); } _drawHUD(ctx, W, H, text) { const pad = 8, fs = 11; ctx.font = '600 ' + fs + 'px monospace'; const tw = ctx.measureText(text).width; const bx = (W - tw) / 2 - pad, by = H - 32; const bw = tw + pad * 2, bh = fs + pad * 2; ctx.fillStyle = 'rgba(10,10,30,0.82)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5); else ctx.rect(bx, by, bw, bh); ctx.fill(); ctx.fillStyle = '#c8d8ff'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, bx + pad, by + bh / 2); ctx.textBaseline = 'alphabetic'; } draw() { if (this.subMode === 'newton') this._drawNewton(); else if (this.subMode === 'thinfilm') this._drawThinFilm(); else if (this.subMode === 'polarization') this._drawPolarization(); } } /* ───────────────────────────────────────────────────────────── 4c. SPECTROMETER PANEL ───────────────────────────────────────────────────────────────*/ function _obDrawSpectrometer() { const canvas = document.getElementById('ob-spectrometer-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const W = canvas.offsetWidth || 360, H = canvas.offsetHeight || 72; const dpr = window.devicePixelRatio || 1; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#0a0a18'; ctx.fillRect(0, 0, W, H); const barX1 = 24, barX2 = W - 24, barY = 22, barH = 20; const grad = ctx.createLinearGradient(barX1, 0, barX2, 0); for (let nm = 380; nm <= 780; nm += 10) { grad.addColorStop((nm - 380) / 400, wavelengthToRGB(nm)); } ctx.fillStyle = grad; ctx.fillRect(barX1, barY, barX2 - barX1, barH); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.strokeRect(barX1, barY, barX2 - barX1, barH); ctx.font = '8px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; [400, 450, 500, 550, 600, 650, 700, 750].forEach(nm => { const x = barX1 + (nm - 380) / 400 * (barX2 - barX1); ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillText(nm, x, barY + barH + 3); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(x, barY + barH); ctx.lineTo(x, barY + barH + 2.5); ctx.stroke(); }); const wlNm = window._obWavelength || 550; const wlX = barX1 + (wlNm - 380) / 400 * (barX2 - barX1); ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(wlX, barY - 5); ctx.lineTo(wlX, barY + barH + 5); ctx.stroke(); ctx.font = 'bold 9px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'bottom'; ctx.textAlign = 'center'; ctx.fillStyle = '#FFFFFF'; ctx.fillText(wlNm + ' нм', wlX, barY - 6); if (window._obSpectrometerDots) { for (const dot of window._obSpectrometerDots) { const x = barX1 + (dot.nm - 380) / 400 * (barX2 - barX1); ctx.fillStyle = dot.col; ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.arc(x, barY - 9, 4, 0, Math.PI * 2); ctx.stroke(); } } } function _obUpdateSpectrometer(exitPts) { window._obSpectrometerDots = exitPts || []; _obDrawSpectrometer(); } /* ───────────────────────────────────────────────────────────── 5. LAB UI INIT — Оптическая скамья ───────────────────────────────────────────────────────────────*/ var lensSim = null; var mirrorSim = null; var refrSim = null; var prismSim = null; var freeSim = null; /* multi-lens free-build (legacy, superseded by benchSim) */ var benchSim = null; /* optical bench constructor (general ray tracer) */ var ifSim = null; /* interference/polarization (Agent C) */ var _obMode = 'lens'; // current active mode within opticsbench /* Wavelength state — shared across all modes */ window._obWavelength = 550; // default 550 nm (green/yellow) window._obWhiteLight = false; // monochromatic by default /* Open opticsbench, optionally setting a mode ('lens'|'mirror'|'refraction') */ function _openOpticsBench(mode) { mode = mode || 'lens'; _obMode = mode; document.getElementById('sim-topbar-title').textContent = 'Оптическая скамья'; _simShow('sim-opticsbench'); _registerSimState('opticsbench', () => _obGetState(), st => _obApplyState(st)); if (_embedMode) _startStateEmit('opticsbench'); // Sync OB_FX checkboxes with persisted state ['wavefronts', 'mist', 'flare', 'huygens', 'caustics'].forEach(function(k) { const el = document.getElementById('obfx-' + k); if (el) el.checked = !!window.OB_FX[k]; }); requestAnimationFrame(() => requestAnimationFrame(() => { obSwitchMode(mode, true); })); } function _obGetState() { if (_obMode === 'lens') return { mode: 'lens', ...(lensSim ? lensSim.getParams() : {}) }; if (_obMode === 'mirror') return { mode: 'mirror', ...(mirrorSim ? mirrorSim.getParams() : {}) }; if (_obMode === 'refraction') return { mode: 'refraction', ...(refrSim ? refrSim.getParams() : {}) }; if (_obMode === 'freebuild') return { mode: 'freebuild', bench: benchSim ? benchSim.getState() : null }; if (_obMode === 'waves') return { mode: 'waves' }; return { mode: _obMode }; } function _obApplyState(st) { if (!st) return; const m = st.mode || _obMode; obSwitchMode(m, true); const { mode: _m, ...params } = st; if (m === 'lens' && lensSim) lensSim.setParams(params); if (m === 'mirror' && mirrorSim) mirrorSim.setParams(params); if (m === 'refraction' && refrSim) refrSim.setParams(params); if (m === 'freebuild' && benchSim && st.bench) { benchSim.setState(st.bench); _benchUpdateUI(); } } /* Switch between modes — mirrors emSwitchMode pattern */ function obSwitchMode(mode, silent) { if (!silent && window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.3, volume: 0.3 }); _obMode = mode; /* tab button styling */ ['lens', 'mirror', 'refraction', 'prism', 'freebuild', 'waves', 'interf'].forEach(m => { const btn = document.getElementById('ob-tab-' + m); if (btn) btn.classList.toggle('active', m === mode); }); /* show/hide per-mode control panels */ ['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction', 'ob-ctrl-prism', 'ob-ctrl-freebuild', 'ob-ctrl-waves', 'ob-ctrl-interf'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); const activeCtrl = document.getElementById('ob-ctrl-' + mode); if (activeCtrl) activeCtrl.style.display = ''; /* show/hide stats bar sections */ ['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr', 'ob-stats-prism', 'ob-stats-freebuild', 'ob-stats-waves', 'ob-stats-interf'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); const statsSuffix = mode === 'refraction' ? 'refr' : mode; const activeStats = document.getElementById('ob-stats-' + statsSuffix); if (activeStats) activeStats.style.display = 'flex'; /* canvas visibility */ const canvasIds = ['ob-lens-canvas', 'ob-mirror-canvas', 'ob-refr-canvas', 'ob-prism-canvas', 'ob-free-canvas', 'ob-waves-canvas', 'ob-interf-canvas']; const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-canvas', waves: 'ob-waves-canvas', interf: 'ob-interf-canvas' }; canvasIds.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); const activeCanvas = document.getElementById(modeCanvas[mode] || ''); if (activeCanvas) activeCanvas.style.display = ''; /* spectrometer panel: show for prism mode */ const specPanel = document.getElementById('ob-spectrometer-panel'); if (specPanel) specPanel.style.display = (mode === 'prism') ? '' : 'none'; /* init engine if not yet done, then (re-)draw */ if (mode === 'lens') { if (!lensSim) { const cv = document.getElementById('ob-lens-canvas'); lensSim = new ThinLensSim(cv); lensSim.onUpdate = _lensUpdateUI; } lensSim.fit(); lensSim.draw(); lensSim._emit(); } else if (mode === 'mirror') { if (!mirrorSim) { const cv = document.getElementById('ob-mirror-canvas'); mirrorSim = new MirrorSim(cv); mirrorSim.onUpdate = _mirrorUpdateUI; mirrorSim.onAnimate = (d) => { const sl = document.getElementById('sl-mirror-d'); const lbl = document.getElementById('mirror-d-val'); if (sl) sl.value = Math.round(d); if (lbl) lbl.textContent = Math.round(d); }; } mirrorSim.fit(); mirrorSim.draw(); mirrorSim._emit(); if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons(); } else if (mode === 'refraction') { if (!refrSim) { const cv = document.getElementById('ob-refr-canvas'); refrSim = new RefractionSim(cv); refrSim.onUpdate = _refrUpdateUI; } refrSim.fit(); refrSim.draw(); refrSim._emit(); } else if (mode === 'prism') { if (!prismSim) { const cv = document.getElementById('ob-prism-canvas'); if (cv) prismSim = new PrismSim(cv); /* enable white-light dispersion by default on first prism entry */ if (window._obWhiteLight === false) { window._obWhiteLight = true; const wlBtn = document.getElementById('ob-prism-white-btn'); if (wlBtn) wlBtn.classList.add('active'); } } if (prismSim) { prismSim.fit(); prismSim.draw(); } _obDrawSpectrometer(); } else if (mode === 'freebuild') { /* Optical bench constructor (BenchSim) */ if (!benchSim) { const cv = document.getElementById('ob-free-canvas'); if (cv) { benchSim = new BenchSim(cv); benchSim.onUpdate = _benchUpdateUI; } } if (benchSim) { benchSim.fit(); benchSim.draw(); _benchUpdateUI(); } } else if (mode === 'waves') { /* Agent B1 — diffraction & interference */ if (!diffrSim) { const cv = document.getElementById('ob-waves-canvas'); if (cv) diffrSim = new DiffractionSim(cv); } if (diffrSim) { diffrSim.fit(); diffrSim.draw(); diffrSim._updateHUD(); } } else if (mode === 'interf') { /* Agent C — interference / polarization */ if (!ifSim) { const cv = document.getElementById('ob-interf-canvas'); if (cv) { ifSim = new InterferenceSim(cv); ifSim.onUpdate = _ifUpdateUI; } } if (ifSim) { ifSim.fit(); ifSim.draw(); } _ifUpdateUI(); } } /* ── Wavelength controls ── */ function obSetWavelength(nm) { window._obWavelength = Math.max(380, Math.min(780, +nm)); const lbl = document.getElementById('ob-wl-val'); if (lbl) lbl.textContent = window._obWavelength + ' нм'; _obRedraw(); } function obToggleWhiteLight(on) { window._obWhiteLight = !!on; const slRow = document.getElementById('ob-wl-slider-row'); if (slRow) slRow.style.opacity = on ? '0.4' : '1'; _obRedraw(); } /* ── Interference mode UI callbacks (Agent C) ── */ function _ifUpdateUI() { if (!ifSim) return; const subMode = ifSim.subMode; ['if-ctrl-newton', 'if-ctrl-thinfilm', 'if-ctrl-polarization'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); const active = document.getElementById('if-ctrl-' + subMode); if (active) active.style.display = ''; ['if-sub-newton', 'if-sub-thinfilm', 'if-sub-polarization'].forEach(id => { const el = document.getElementById(id); if (el) el.classList.toggle('active', id === 'if-sub-' + subMode); }); } function ifSwitchSub(sub) { if (window.LabFX) LabFX.sound.play('chime'); if (!ifSim) return; ifSim.setSubMode(sub); _ifUpdateUI(); } function ifNewtParam(key, val) { if (!ifSim) return; const v = parseFloat(val); if (key === 'R') { ifSim.nR = v; document.getElementById('if-newton-r-val').textContent = v; } else if (key === 'nmax') { ifSim.nNmax = Math.round(v); document.getElementById('if-newton-n-val').textContent = Math.round(v); } ifSim.draw(); } function ifThinFilmParam(key, val) { if (!ifSim) return; const v = parseFloat(val); if (key === 't') { ifSim.tfT = v; document.getElementById('if-tf-t-val').textContent = v; } else if (key === 'n') { ifSim.tfN = v; document.getElementById('if-tf-n-val').textContent = v.toFixed(2); } else if (key === 'theta') { ifSim.tfTheta = v; document.getElementById('if-tf-th-val').textContent = v; } ifSim.draw(); } function ifThinFilmPreset(name) { if (!ifSim) return; const presets = { soap: { n: 1.33, label: 'Мыльная плёнка' }, oil: { n: 1.50, label: 'Масло на воде' }, coating: { n: 1.38, label: 'Антибликовое покрытие' }, }; const p = presets[name]; if (!p) return; ifSim.tfN = p.n; ifSim.tfPreset = name; const slN = document.getElementById('sl-if-tf-n'); if (slN) slN.value = p.n; const lbN = document.getElementById('if-tf-n-val'); if (lbN) lbN.textContent = p.n.toFixed(2); ifSim.draw(); if (window.LabFX) LabFX.sound.play('chime'); } function ifPolParam(key, val) { if (!ifSim) return; const v = parseFloat(val); if (key === 'theta') { ifSim.polTheta = v; document.getElementById('if-pol-th-val').textContent = v; } ifSim.draw(); } function ifPolSrc(val) { if (!ifSim) return; ifSim.polSrc = val; ifSim.draw(); } function _obRedraw() { if (_obMode === 'lens' && lensSim) { lensSim.draw(); } if (_obMode === 'mirror' && mirrorSim) { mirrorSim.draw(); } if (_obMode === 'refraction' && refrSim) { refrSim.draw(); } if (_obMode === 'prism' && prismSim) { prismSim.draw(); } if (_obMode === 'freebuild' && benchSim) { benchSim.draw(); } if (_obMode === 'waves' && diffrSim) { diffrSim.draw(); diffrSim._updateHUD(); } if (_obMode === 'interf' && ifSim) { ifSim.draw(); } _obDrawSpectrometer(); // Update prism stats bar const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; }; el('prismbar-wl', (window._obWavelength || 550) + ' нм'); el('prismbar-mode', window._obWhiteLight ? 'Белый' : 'Моно'); if (prismSim) el('prismbar-n', prismSim.n0.toFixed(2)); } /* ── Prism controls ── */ function prismParam(name, val) { const v = parseFloat(val); const ids = { n0: 'prism-n0-val', incAngle: 'prism-inc-val' }; const el = document.getElementById(ids[name]); if (el) el.textContent = name === 'n0' ? v.toFixed(2) : v; if (prismSim) prismSim.setParams({ [name]: v }); } function prismPreset(n0, incAngle) { document.getElementById('sl-prism-n0').value = n0; document.getElementById('prism-n0-val').textContent = n0.toFixed(2); document.getElementById('sl-prism-inc').value = incAngle; document.getElementById('prism-inc-val').textContent = incAngle; if (prismSim) prismSim.setParams({ n0, incAngle }); } function prismToggleWhite(on, btn) { window._obWhiteLight = !!on; const wb = document.getElementById('ob-prism-white-btn'); const mb = document.getElementById('ob-prism-mono-btn'); if (wb) wb.classList.toggle('active', !!on); if (mb) mb.classList.toggle('active', !on); if (prismSim) prismSim.draw(); _obDrawSpectrometer(); } /* ── Thin Lens controls ── */ function lensParam(name, val) { const v = parseFloat(val); const ids = { f: 'lens-f-val', d: 'lens-d-val', h: 'lens-h-val' }; const el = document.getElementById(ids[name]); if (el) el.textContent = v; if (lensSim) lensSim.setParams({ [name]: v }); } function lensPreset(f, d, h) { document.getElementById('sl-lens-f').value = f; document.getElementById('lens-f-val').textContent = f; document.getElementById('sl-lens-d').value = d; document.getElementById('lens-d-val').textContent = d; document.getElementById('sl-lens-h').value = h; document.getElementById('lens-h-val').textContent = h; if (lensSim) lensSim.setParams({ f, d, h }); } /* ── Lens animated ray + LM controls (Feature 1 & 3) ── */ function lensToggleLM(on) { const sliders = document.getElementById('ob-lm-sliders'); const fRow = document.querySelector('#ob-ctrl-lens .proj-slider-row'); if (sliders) sliders.style.display = on ? '' : 'none'; // hide/show simple f slider const fSlRow = document.getElementById('sl-lens-f'); if (fSlRow && fSlRow.parentElement) fSlRow.parentElement.style.display = on ? 'none' : ''; if (lensSim) lensSim.setLensMode(!on); if (on && lensSim) { // sync sliders to current LM params const r1 = lensSim._lmR1, r2 = lensSim._lmR2, n = lensSim._lmN; const s1 = document.getElementById('sl-lm-r1'), l1 = document.getElementById('lm-r1-val'); const s2 = document.getElementById('sl-lm-r2'), l2 = document.getElementById('lm-r2-val'); const sn = document.getElementById('sl-lm-n'), ln = document.getElementById('lm-n-val'); if (s1) s1.value = r1; if (l1) l1.textContent = r1.toFixed(0); if (s2) s2.value = r2; if (l2) l2.textContent = r2.toFixed(0); if (sn) sn.value = n; if (ln) ln.textContent = n.toFixed(2); } } function lensLMParam(name, val) { const v = parseFloat(val); const lblMap = { R1: 'lm-r1-val', R2: 'lm-r2-val', n: 'lm-n-val' }; const el = document.getElementById(lblMap[name]); if (el) el.textContent = name === 'n' ? v.toFixed(2) : v.toFixed(0); if (lensSim) { lensSim.setLMParam(name, v); // update f display const fl = document.getElementById('lens-f-val'); if (fl) fl.textContent = lensSim.f.toFixed(0); } } /* ── Mirror R-slider + parabolic controls (Feature 2) ── */ function mirrorToggleR(on) { const rRow = document.getElementById('ob-mirror-R-row'); if (rRow) rRow.style.display = on ? '' : 'none'; const pbtn = document.getElementById('mirror-parab-btn'); if (pbtn) pbtn.style.display = on ? '' : 'none'; if (mirrorSim) mirrorSim._useR = !!on; if (on && mirrorSim) { const sv = document.getElementById('sl-mirror-R'); const lv = document.getElementById('mirror-R-val'); if (sv) sv.value = mirrorSim._R; if (lv) lv.textContent = mirrorSim._R; mirrorSim.setMirrorR(mirrorSim._R); } else if (mirrorSim) { mirrorSim.draw(); } } function mirrorRParam(val) { const v = parseFloat(val); const el = document.getElementById('mirror-R-val'); if (el) el.textContent = v; if (mirrorSim) mirrorSim.setMirrorR(v); } function mirrorToggleParabolic(btn) { if (!mirrorSim) return; mirrorSim._parabolic = !mirrorSim._parabolic; if (btn) btn.textContent = mirrorSim._parabolic ? 'Параболическое' : 'Сферическое'; if (btn) btn.style.color = mirrorSim._parabolic ? '#7BF5A4' : '#888'; mirrorSim.draw(); } function _lensUpdateUI(info) { const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; v('lensbar-v1', info.f); v('lensbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime); v('lensbar-v3', info.M === Infinity ? '∞' : info.M); v('lensbar-v4', info.imageType); } /* ── Mirror controls ── */ function mirrorType(type, el) { document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active')); if (el) el.classList.add('active'); const fRow = document.getElementById('mirror-f-row'); if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex'; if (mirrorSim) mirrorSim.setType(type); const pb = document.getElementById('mirror-play-btn'); if (pb) { pb.textContent = 'Анимация'; } const sl = document.getElementById('sl-mirror-d'); if (sl) sl.disabled = false; } function mirrorParam(name, val) { const v = parseFloat(val); const ids = { f: 'mirror-f-val', d: 'mirror-d-val', h: 'mirror-h-val' }; const el = document.getElementById(ids[name]); if (el) el.textContent = v; if (mirrorSim) mirrorSim.setParams({ [name]: v }); } function mirrorPreset(name) { const P = { flat: { type: 'flat', f: 120, d: 200, h: 60 }, far: { type: 'concave', f: 100, d: 280, h: 60 }, '2f': { type: 'concave', f: 100, d: 200, h: 60 }, between: { type: 'concave', f: 100, d: 140, h: 60 }, near: { type: 'concave', f: 100, d: 60, h: 60 }, convex: { type: 'convex', f: 100, d: 200, h: 60 }, }; const p = P[name]; if (!p) return; document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active')); const tb = document.getElementById('mtype-' + p.type); if (tb) tb.classList.add('active'); const fRow = document.getElementById('mirror-f-row'); if (fRow) fRow.style.display = p.type === 'flat' ? 'none' : 'flex'; document.getElementById('sl-mirror-f').value = p.f; document.getElementById('mirror-f-val').textContent = p.f; document.getElementById('sl-mirror-d').value = p.d; document.getElementById('mirror-d-val').textContent = p.d; document.getElementById('sl-mirror-h').value = p.h; document.getElementById('mirror-h-val').textContent = p.h; if (mirrorSim) { mirrorSim.setType(p.type); mirrorSim.setParams({ f: p.f, d: p.d, h: p.h }); } } function mirrorTogglePlay(btn) { if (!mirrorSim) return; mirrorSim.togglePlay(); const playing = mirrorSim._playing; if (btn) btn.textContent = playing ? 'Стоп' : 'Анимация'; const sl = document.getElementById('sl-mirror-d'); if (sl) sl.disabled = playing; } function mirrorSetSpeed(val) { if (mirrorSim) mirrorSim.setAnimSpeed(parseFloat(val)); } function mirrorToggle(name, val) { if (mirrorSim) mirrorSim.setToggle(name, val); } function mirrorStepNext() { if (mirrorSim) mirrorSim.stepNext(); } function mirrorStepReset() { if (mirrorSim) mirrorSim.stepReset(); } function mirrorSetPointMode(val) { if (mirrorSim) mirrorSim.setPointMode(val); } function _mirrorUpdateUI(info) { const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; v('mirrorbar-v1', info.f); v('mirrorbar-v5', Math.round(info.d)); v('mirrorbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime); v('mirrorbar-v3', info.M === Infinity ? '∞' : info.M); v('mirrorbar-v4', info.imageType); } /* ── Refraction controls ── */ function refrParam(name, val) { const v = parseFloat(val); const ids = { n1: 'refr-n1-val', n2: 'refr-n2-val', angle: 'refr-angle-val' }; const el = document.getElementById(ids[name]); if (el) el.textContent = name === 'angle' ? v : v.toFixed(2); if (refrSim) refrSim.setParams({ [name]: v }); } function refrPreset(n1, n2, angle) { document.getElementById('sl-refr-n1').value = n1; document.getElementById('refr-n1-val').textContent = n1.toFixed(2); document.getElementById('sl-refr-n2').value = n2; document.getElementById('refr-n2-val').textContent = n2.toFixed(2); document.getElementById('sl-refr-angle').value = angle; document.getElementById('refr-angle-val').textContent = angle; if (refrSim) refrSim.setParams({ n1, n2, angle }); } function _refrUpdateUI(info) { const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; v('refrbar-v1', info.angle1 + '°'); v('refrbar-v2', info.isTIR ? 'ПВО' : info.angle2 + '°'); v('refrbar-v3', info.criticalAngle !== null ? info.criticalAngle + '°' : '—'); v('refrbar-v4', info.isTIR ? 'Да' : 'Нет'); } /* ── dispersion toggle ── */ function refrDispersion(on) { if (refrSim) refrSim.setParams({ dispersion: on }); } /* ───────────────────────────────────────────────────────────── 5. PRE-BUILT INSTRUMENT SCENES (obLoadPreset / OB_PRESETS) ───────────────────────────────────────────────────────────────*/ /* Each preset: { label, desc, mode, build(sim) } build() receives the already-initialised sim object for the target mode. Supported modes: 'lens' | 'mirror' | 'refraction' */ var OB_PRESETS = { /* ── 1. Лупа ─────────────────────────────────────────────── */ magnifier: { label: 'Лупа', desc: 'Объект ближе фокуса (d < f). Мнимое увеличенное прямое изображение.', mode: 'lens', build: function(sim) { sim.setParams({ f: 50, d: 30, h: 50 }); _obSyncLensUI(50, 30, 50); } }, /* ── 2. Микроскоп ─────────────────────────────────────────── */ microscope: { label: 'Микроскоп', desc: 'Объектив f = 10, предмет чуть дальше F. Действительное изображение увеличено.', mode: 'lens', build: function(sim) { /* Show the objective stage: object just outside F of objective */ sim.setParams({ f: 10, d: 14, h: 30 }); _obSyncLensUI(10, 14, 30); } }, /* ── 3. Телескоп Кеплера ──────────────────────────────────── */ keplerian: { label: 'Телескоп Кеплера', desc: 'Две собирающие линзы конфокально (fуб = 200, fок = 30). Объект на ∞.', mode: 'lens', build: function(sim) { /* Show the eyepiece stage receiving collimated light: f_ok = 30, "parallel-ray" object simulated by placing d very large (390 max) */ sim.setParams({ f: 30, d: 390, h: 55 }); _obSyncLensUI(30, 390, 55); } }, /* ── 4. Телескоп Галилея ──────────────────────────────────── */ galilean: { label: 'Телескоп Галилея', desc: 'Собирающий объектив f = 200 + рассеивающий окуляр f = −40. Прямое изображение.', mode: 'lens', build: function(sim) { /* Diverging eyepiece: f = -40, object at 390 (parallel rays) */ sim.setParams({ f: -40, d: 390, h: 55 }); _obSyncLensUI(-40, 390, 55); } }, /* ── 5. Камера / Глаз ─────────────────────────────────────── */ camera: { label: 'Камера / Глаз', desc: 'Линза f = 40, предмет d = 120. Действительное уменьшенное изображение на матрице.', mode: 'lens', build: function(sim) { sim.setParams({ f: 40, d: 120, h: 60 }); _obSyncLensUI(40, 120, 60); } }, /* ── 6. Перископ ───────────────────────────────────────────── Uses MirrorSim with flat mirror to show 45° reflection concept. A true two-mirror periscope can't be drawn in the single-mirror engine, so we show one flat mirror at 45° (flat type, d large). */ periscope: { label: 'Перископ', desc: 'Плоское зеркало под 45°. Луч отражается под прямым углом.', mode: 'mirror', build: function(sim) { sim.setType('flat'); sim.setParams({ d: 300, h: 60 }); _obSyncMirrorUI('flat', 120, 300, 60); } }, /* ── 7. Слайд-проектор ─────────────────────────────────────── */ projector: { label: 'Слайд-проектор', desc: 'Объектив f = 80, слайд d = 100 (чуть дальше F). Действительное увеличенное изображение.', mode: 'lens', build: function(sim) { sim.setParams({ f: 80, d: 100, h: 40 }); _obSyncLensUI(80, 100, 40); } }, /* ── 8. Световод (ПВО) ────────────────────────────────────── Shows TIR inside a dense medium (n2 > n1). Use refraction mode: n1=1.5 (glass), n2=1.0 (air), angle > critical. */ fiber: { label: 'Световод (ПВО)', desc: 'Стекло n = 1.5 → воздух n = 1. Угол > критического — полное внутреннее отражение.', mode: 'refraction', build: function(sim) { /* critical angle = arcsin(1/1.5) ≈ 41.8°; use 50° to clearly show TIR */ sim.setParams({ n1: 1.5, n2: 1.0, angle: 50, dispersion: false }); _obSyncRefrUI(1.5, 1.0, 50); } }, /* ── 9. Ложка в воде ──────────────────────────────────────── Shows apparent-depth illusion: light from object in water (n=1.33) refracts at boundary going into air. Observer sees virtual image. */ spoon: { label: 'Ложка в воде', desc: 'Свет от предмета в воде (n = 1.33) преломляется в воздух. Мнимое изображение кажется ближе.', mode: 'refraction', build: function(sim) { sim.setParams({ n1: 1.33, n2: 1.0, angle: 30, dispersion: false }); _obSyncRefrUI(1.33, 1.0, 30); } } }; /* ── UI sync helpers (keep sliders in sync after preset load) ── */ function _obSyncLensUI(f, d, h) { var sl; sl = document.getElementById('sl-lens-f'); if (sl) sl.value = f; sl = document.getElementById('sl-lens-d'); if (sl) sl.value = d; sl = document.getElementById('sl-lens-h'); if (sl) sl.value = h; var el; el = document.getElementById('lens-f-val'); if (el) el.textContent = f; el = document.getElementById('lens-d-val'); if (el) el.textContent = d; el = document.getElementById('lens-h-val'); if (el) el.textContent = h; } function _obSyncMirrorUI(type, f, d, h) { document.querySelectorAll('.mirror-type-btn').forEach(function(b) { b.classList.remove('active'); }); var tb = document.getElementById('mtype-' + type); if (tb) tb.classList.add('active'); var fRow = document.getElementById('mirror-f-row'); if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex'; var sl; sl = document.getElementById('sl-mirror-f'); if (sl) sl.value = f; sl = document.getElementById('sl-mirror-d'); if (sl) sl.value = d; sl = document.getElementById('sl-mirror-h'); if (sl) sl.value = h; var el; el = document.getElementById('mirror-f-val'); if (el) el.textContent = f; el = document.getElementById('mirror-d-val'); if (el) el.textContent = d; el = document.getElementById('mirror-h-val'); if (el) el.textContent = h; } function _obSyncRefrUI(n1, n2, angle) { var sl; sl = document.getElementById('sl-refr-n1'); if (sl) sl.value = n1; sl = document.getElementById('sl-refr-n2'); if (sl) sl.value = n2; sl = document.getElementById('sl-refr-angle'); if (sl) sl.value = angle; var el; el = document.getElementById('refr-n1-val'); if (el) el.textContent = n1.toFixed(2); el = document.getElementById('refr-n2-val'); if (el) el.textContent = n2.toFixed(2); el = document.getElementById('refr-angle-val'); if (el) el.textContent = angle; } /* ── HUD toast shown for 5 s after preset load ── */ function _obShowPresetHUD(label, desc) { var existing = document.getElementById('ob-preset-hud'); if (existing) existing.remove(); var hud = document.createElement('div'); hud.id = 'ob-preset-hud'; hud.style.cssText = [ 'position:absolute', 'top:10px', 'left:50%', 'transform:translateX(-50%)', 'background:rgba(18,18,30,.92)', 'border:1px solid rgba(100,220,255,.35)', 'border-radius:10px', 'padding:8px 16px', 'z-index:99', 'pointer-events:none', 'text-align:center', 'box-shadow:0 4px 20px rgba(0,0,0,.5)', 'transition:opacity .4s' ].join(';'); hud.innerHTML = '
' + label + '
' + '
' + desc + '
'; /* Attach to the canvas wrapper so it overlays correctly */ var wrap = document.querySelector('#sim-opticsbench .proj-canvas-outer'); if (wrap) { wrap.style.position = 'relative'; wrap.appendChild(hud); } /* Fade out after 5 s */ var timer = setTimeout(function() { hud.style.opacity = '0'; setTimeout(function() { if (hud.parentNode) hud.remove(); }, 420); }, 5000); hud._timer = timer; } /* ── Main entry point called from HTML buttons ── */ function obLoadPreset(name) { var preset = OB_PRESETS[name]; if (!preset) return; /* Switch to the correct mode first, then build */ obSwitchMode(preset.mode, true); /* Give the canvas time to initialise if it was just created */ requestAnimationFrame(function() { var sim = preset.mode === 'lens' ? lensSim : preset.mode === 'mirror' ? mirrorSim : preset.mode === 'refraction' ? refrSim : null; if (!sim) return; preset.build(sim); /* Sound */ if (window.LabFX) LabFX.sound.play('chime'); /* Tab highlight */ ['lens', 'mirror', 'refraction'].forEach(function(m) { var btn = document.getElementById('ob-tab-' + m); if (btn) btn.classList.toggle('active', m === preset.mode); }); /* HUD */ _obShowPresetHUD(preset.label, preset.desc); /* Highlight the active preset chip */ document.querySelectorAll('.ob-preset-chip').forEach(function(c) { c.classList.toggle('active', c.dataset.preset === name); }); }); } /* ── Clear: reset current mode to defaults ── */ function obClearPreset() { /* Deactivate all chips */ document.querySelectorAll('.ob-preset-chip').forEach(function(c) { c.classList.remove('active'); }); /* Remove HUD if visible */ var hud = document.getElementById('ob-preset-hud'); if (hud) hud.remove(); /* Reset active sim */ if (_obMode === 'lens' && lensSim) lensSim.reset(); if (_obMode === 'mirror' && mirrorSim) { mirrorSim.setType('concave'); mirrorSim.reset(); } if (_obMode === 'refraction' && refrSim) refrSim.reset(); /* Re-sync UI sliders to defaults */ if (_obMode === 'lens') _obSyncLensUI(100, 200, 50); if (_obMode === 'mirror') _obSyncMirrorUI('concave', 120, 240, 60); if (_obMode === 'refraction') _obSyncRefrUI(1.0, 1.5, 30); if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.9, volume: 0.25 }); } /* ── Aberration toggles for ThinLensSim (Agent OB-A3) ── */ function lensAberration(type, on) { if (lensSim) lensSim.setAberration(type, on); } /* ── Mirror spherical aberration toggle (Agent OB-A3) ── */ function mirrorAberration(on) { if (mirrorSim) mirrorSim.setToggle('spherical', on); } /* ── Free-build multi-lens controls (Agent OB-A3) ── */ function freeAddLens() { if (freeSim) freeSim.addLens(100); } function freeRemoveLens() { if (freeSim) freeSim.removeLens(); } function freeLensF(idx, val) { const v = parseFloat(val); const el = document.getElementById('free-lens' + idx + '-fval'); if (el) el.textContent = v; if (freeSim) freeSim.setLensF(idx, v); } function freePreset(name) { const presets = { microscope: { els: [{ x_frac: 0.30, f: 40 }, { x_frac: 0.70, f: 80 }], obj: 0.10 }, telescope: { els: [{ x_frac: 0.25, f: 160 }, { x_frac: 0.78, f: 50 }], obj: 0.05 }, relay: { els: [{ x_frac: 0.28, f: 100 }, { x_frac: 0.55, f: 100 }, { x_frac: 0.80, f: 100 }], obj: 0.08 }, }; const p = presets[name]; if (!p || !freeSim) return; freeSim.elements = p.els.map((e, i) => ({ ...e, id: i + 1 })); freeSim.objFrac = p.obj; freeSim.draw(); if (freeSim.onUpdate) freeSim.onUpdate(freeSim._computeChain()); } function _freeUpdateUI(chain) { const el = document.getElementById('freebar-mag'); if (el) el.textContent = isFinite(chain.totalM) ? chain.totalM.toFixed(3) : '∞'; const el2 = document.getElementById('freebar-sys'); if (el2) el2.textContent = chain.sysFocal !== null ? chain.sysFocal.toFixed(0) : '—'; } /* ── Optical bench constructor (BenchSim) UI ── */ function _benchElName(e) { if (e.type === 'lens') return (e.f >= 0 ? 'Линза +' : 'Линза −') + Math.abs(e.f).toFixed(0); if (e.type === 'mirror') return 'Зеркало ' + ({ plane: 'плоск', concave: 'вогн', convex: 'выпукл' }[e.kind] || ''); if (e.type === 'aperture') return 'Диафрагма'; if (e.type === 'screen') return 'Экран'; if (e.type === 'prism') return 'Призма'; if (e.type === 'interface') return 'Граница сред'; if (e.type === 'slab') return 'Пластина'; return e.type; } // Labelled slider with a live numeric value (no panel rebuild → drag stays smooth). function _benchCtl(label, id, key, min, max, step, val, isSource) { const vid = 'bv_' + (isSource ? 's' : id) + '_' + key; const call = isSource ? "benchSourceParam('" + key + "',this.value)" : "benchUpdate(" + id + ",'" + key + "',this.value)"; return '
' + '' + '
'; } function _benchBtnRow(opts, isActive, onClick) { return '
' + opts.map(o => { const [k, lbl] = o.split(':'); return ''; }).join('') + '
'; } function _benchPropsHTML() { if (!benchSim) return ''; if (benchSim.selectedId === '__src') { const s = benchSim.source; let h = '
Источник
'; h += _benchBtnRow(['object:Предмет', 'point:Точка', 'parallel:Параллель', 'single:Луч', 'laser:Лазер'], k => s.kind === k, k => "benchSourceKind('" + k + "')"); h += _benchCtl('Положение ↕', 0, 'yf', -0.9, 0.9, 0.02, +(s.yf || 0).toFixed(2), true); // vertical position (any kind) if (s.kind === 'object') { h += _benchCtl('Размер стрелки', 0, 'h', 20, 120, 2, s.h, true); // ray mode: textbook characteristic rays vs physical bundle h += _benchBtnRow(['char:Характ. лучи', 'bundle:Пучок'], k => (s.rayMode || 'char') === k, k => "benchSourceParam('rayMode','" + k + "');_benchUpdateUI()"); if ((s.rayMode || 'char') === 'bundle') { h += _benchCtl('Лучей', 0, 'rays', 3, 15, 1, s.rays, true); h += _benchCtl('Раствор', 0, 'spread', 0.1, 0.6, 0.02, s.spread, true); } else { h += '
2–3 характеристических луча от вершины + осевой от основания (как в учебнике).
'; } } if (s.kind === 'point') h += _benchCtl('Раствор', 0, 'spread', 0.1, 0.6, 0.02, s.spread, true); if (s.kind !== 'object') h += _benchCtl('Угол°', 0, 'ang', -60, 60, 1, s.ang || 0, true); return h; } const e = benchSim.getSelected(); if (!e) return '
Выберите элемент или источник (клик по схеме)
'; let h = '
' + _benchElName(e) + '
'; if (e.type === 'lens') { h += _benchCtl('f, px', e.id, 'f', -300, 300, 5, e.f); h += _benchCtl('Апертура', e.id, 'ap', 30, 130, 5, e.ap); } else if (e.type === 'mirror') { h += _benchBtnRow(['plane:Плоск', 'concave:Вогн', 'convex:Выпукл'], k => e.kind === k, k => "benchUpdate(" + e.id + ",'kind','" + k + "');_benchUpdateUI()"); if (e.kind !== 'plane') h += _benchCtl('R, px', e.id, 'R', 100, 600, 10, e.R); h += _benchCtl('Апертура', e.id, 'ap', 30, 130, 5, e.ap); } else if (e.type === 'aperture') { h += _benchCtl('Зазор', e.id, 'gap', 5, 110, 2, e.gap); } else if (e.type === 'prism') { h += _benchCtl('Угол', e.id, 'apex', 20, 70, 1, e.apex); h += _benchCtl('n', e.id, 'n', 1.3, 1.9, 0.01, e.n); h += _benchCtl('Размер', e.id, 'size', 50, 130, 5, e.size); } else if (e.type === 'interface') { h += _benchCtl('n слева', e.id, 'n1', 1.0, 2.4, 0.01, e.n1); h += _benchCtl('n справа', e.id, 'n2', 1.0, 2.4, 0.01, e.n2); } else if (e.type === 'slab') { h += _benchCtl('n', e.id, 'n', 1.1, 2.0, 0.01, e.n); h += _benchCtl('Толщина', e.id, 't', 20, 140, 5, e.t); } else if (e.type === 'screen') { h += '
Экран ловит изображение.
'; } h += ''; return h; } function _benchUpdateUI() { if (!benchSim) return; const listEl = document.getElementById('bench-list'); if (listEl) { // permanent "Источник" chip so the source is always selectable (not only via canvas) const srcChip = ''; listEl.innerHTML = srcChip + benchSim.elements.map(e => '' ).join(''); } const propsEl = document.getElementById('bench-props'); if (propsEl) propsEl.innerHTML = _benchPropsHTML(); } function benchAdd(type) { if (benchSim) { benchSim.addElement(type); _benchUpdateUI(); } } function benchRemove(id) { if (benchSim) { benchSim.removeElement(id); _benchUpdateUI(); } } function benchSelect(id) { if (benchSim) { benchSim.selectElement(id); _benchUpdateUI(); } } function benchUpdate(id, k, v) { if (benchSim) benchSim.updateElement(id, k, v); } function benchSourceKind(k) { if (benchSim) { benchSim.setSource('kind', k); _benchUpdateUI(); } } function benchSourceParam(k, v){ if (benchSim) benchSim.setSource(k, v); } function benchClear() { if (!benchSim) return; benchSim.elements = []; benchSim.selectedId = null; benchSim._changed(); _benchUpdateUI(); } function benchExportPng() { if (!benchSim) return; const url = benchSim.exportPng(); if (!url) return; const a = document.createElement('a'); a.href = url; a.download = 'optical-bench.png'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } function benchPreset(name) { if (!benchSim) return; const P = { microscope: { source: { kind: 'object', xf: 0.06, h: 40, spread: 0.32, rays: 9 }, elements: [{ type: 'lens', xf: 0.30, f: 45, ap: 90 }, { type: 'lens', xf: 0.66, f: 90, ap: 95 }, { type: 'screen', xf: 0.92 }] }, telescope: { source: { kind: 'parallel', xf: 0.05, h: 0, spread: 0.2, rays: 9 }, elements: [{ type: 'lens', xf: 0.28, f: 200, ap: 95 }, { type: 'lens', xf: 0.74, f: 60, ap: 80 }] }, projector: { source: { kind: 'object', xf: 0.10, h: 80, spread: 0.34, rays: 9 }, elements: [{ type: 'lens', xf: 0.40, f: 120, ap: 100 }, { type: 'screen', xf: 0.92 }] }, folded: { source: { kind: 'object', xf: 0.08, h: 60, spread: 0.3, rays: 9 }, elements: [{ type: 'lens', xf: 0.34, f: 150, ap: 90 }, { type: 'mirror', xf: 0.82, kind: 'concave', R: 320, ap: 100 }, { type: 'screen', xf: 0.50 }] }, }; const p = P[name]; if (!p) return; let id = 1; benchSim.source = { ...p.source }; benchSim.elements = p.elements.map(e => ({ id: id++, ...e })); benchSim._nextId = id; benchSim.selectedId = null; benchSim._changed(); _benchUpdateUI(); } /* ───────────────────────────────────────────────────────────── 6. DIFFRACTION SIM — Волновая оптика (Юнг / Однощелевая / Решётка) ───────────────────────────────────────────────────────────────*/ /** * DiffractionSim — handles 3 wave-optics sub-experiments on a single canvas. * Sub-modes: * 'young' — Young double-slit interference * 'single' — Single-slit diffraction * 'grating' — Diffraction grating (N slits) */ class DiffractionSim { constructor(canvas) { this._cv = canvas; this._ctx = canvas.getContext('2d'); this._sub = 'young'; // active sub-experiment this._raf = null; /* Young params */ this._d_young = 40; // slit separation µm this._L_young = 1.0; // screen distance m /* Single-slit params */ this._a_single = 80; // slit width µm /* Grating params */ this._N_grating = 10; // number of slits this._d_grating = 2.0; // grating period µm this._a_grating = 0.5; // slit width µm (for envelope) this.fit(); } fit() { const cv = this._cv; const pr = window.devicePixelRatio || 1; const w = cv.offsetWidth || 700; const h = cv.offsetHeight || 380; cv.width = Math.round(w * pr); cv.height = Math.round(h * pr); this._ctx.setTransform(pr, 0, 0, pr, 0, 0); this._W = w; this._H = h; } setSub(sub) { this._sub = sub; this.draw(); } /* ── public setters called from HTML controls ── */ setParam(name, val) { const v = parseFloat(val); if (name === 'd_young') this._d_young = v; if (name === 'L_young') this._L_young = v; if (name === 'a_single') this._a_single = v; if (name === 'N_grating') this._N_grating = Math.round(v); if (name === 'd_grating') this._d_grating = v; if (name === 'a_grating') this._a_grating = v; this.draw(); this._updateHUD(); } /* ── main draw dispatcher ── */ draw() { const ctx = this._ctx; const W = this._W, H = this._H; ctx.clearRect(0, 0, W, H); /* dark background */ ctx.fillStyle = '#0a0a16'; ctx.fillRect(0, 0, W, H); if (this._sub === 'young') this._drawYoung(); else if (this._sub === 'single') this._drawSingle(); else if (this._sub === 'grating') this._drawGrating(); } /* ───────────────────────────────────────────── Young double-slit ─────────────────────────────────────────────── */ _drawYoung() { const ctx = this._ctx; const W = this._W, H = this._H; const λ_nm = window._obWavelength || 550; const λ = λ_nm * 1e-9; // m const d = this._d_young * 1e-6; // m const L = this._L_young; // m const wl = window._obWhiteLight; /* layout */ const slitX = Math.round(W * 0.22); const screenX = Math.round(W * 0.75); const graphH = Math.round(H * 0.26); const simH = H - graphH - 1; const cy = Math.round(simH / 2); /* source (left) */ this._drawSource(slitX - Math.round(W * 0.15), cy); /* draw incoming beam */ ctx.save(); ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(slitX - 2, cy); ctx.stroke(); ctx.restore(); /* slit barrier */ const slitGapPx = 18; // visual gap per slit const sepPx = Math.round(Math.max(12, Math.min(60, this._d_young * 0.8))); this._drawSlitBarrier(slitX, cy, simH, sepPx, slitGapPx, 2); /* wavefronts from two slits */ const slit1Y = cy - sepPx / 2; const slit2Y = cy + sepPx / 2; this._drawWavefronts(slitX, slit1Y, slitX, slit2Y, screenX - slitX, λ_nm, wl, simH); /* intensity screen */ const screenPoints = this._youngIntensity(λ, d, L, W, simH); this._drawScreen(screenX, simH, screenPoints, λ_nm, wl); /* graph */ this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl); /* fringe spacing label */ const dy_mm = (λ * L / d * 1e3).toFixed(2); this._drawHUDLine(ctx, W, H, `Δy = λL/d = ${dy_mm} мм`); /* axis labels */ this._drawLabel(ctx, slitX, simH - 10, 'Щели'); this._drawLabel(ctx, screenX, simH - 10, 'Экран'); } _youngIntensity(λ, d, L, W, simH) { const N = 300; const halfH = simH * 0.46; const pts = []; for (let i = 0; i <= N; i++) { const y_px = (i / N) * simH - simH / 2; // px, centred const y_m = y_px / (simH / 2) * 0.05; // map ±simH/2 → ±50mm const sinT = y_m / Math.sqrt(y_m * y_m + L * L); const phi = Math.PI * d * sinT / λ; const I = Math.cos(phi) * Math.cos(phi); pts.push({ y_px, I }); } return pts; } /* ───────────────────────────────────────────── Single-slit diffraction ─────────────────────────────────────────────── */ _drawSingle() { const ctx = this._ctx; const W = this._W, H = this._H; const λ_nm = window._obWavelength || 550; const λ = λ_nm * 1e-9; const a = this._a_single * 1e-6; const L = 1.0; // fixed 1 m const wl = window._obWhiteLight; const slitX = Math.round(W * 0.22); const screenX = Math.round(W * 0.75); const graphH = Math.round(H * 0.26); const simH = H - graphH - 1; const cy = Math.round(simH / 2); const slitGapPx = Math.round(Math.max(6, Math.min(30, this._a_single * 0.18))); this._drawSource(slitX - Math.round(W * 0.15), cy); ctx.save(); ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(slitX - 2, cy); ctx.stroke(); ctx.restore(); /* single slit barrier */ this._drawSingleSlitBarrier(slitX, cy, simH, slitGapPx); /* wavefronts — single source */ this._drawWavefronts(slitX, cy, slitX, cy, screenX - slitX, λ_nm, wl, simH); const screenPoints = this._singleIntensity(λ, a, L, simH); this._drawScreen(screenX, simH, screenPoints, λ_nm, wl); this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl); /* angular width of central maximum */ const ang_rad = (2 * λ / a); const ang_deg = (ang_rad * 180 / Math.PI).toFixed(2); this._drawHUDLine(ctx, W, H, `Центр. макс: 2λ/a = ${ang_deg}°`); this._drawLabel(ctx, slitX, simH - 10, 'Щель'); this._drawLabel(ctx, screenX, simH - 10, 'Экран'); } _singleIntensity(λ, a, L, simH) { const N = 300; const pts = []; for (let i = 0; i <= N; i++) { const y_px = (i / N) * simH - simH / 2; const y_m = y_px / (simH / 2) * 0.05; const sinT = y_m / Math.sqrt(y_m * y_m + L * L); const alpha = Math.PI * a * sinT / λ; const I = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2); pts.push({ y_px, I }); } return pts; } /* ───────────────────────────────────────────── Diffraction grating ─────────────────────────────────────────────── */ _drawGrating() { const ctx = this._ctx; const W = this._W, H = this._H; const λ_nm = window._obWavelength || 550; const λ = λ_nm * 1e-9; const d = this._d_grating * 1e-6; const a = this._a_grating * 1e-6; const N = this._N_grating; const L = 1.0; const wl = window._obWhiteLight; const slitX = Math.round(W * 0.22); const screenX = Math.round(W * 0.75); const graphH = Math.round(H * 0.26); const simH = H - graphH - 1; const cy = Math.round(simH / 2); this._drawSource(slitX - Math.round(W * 0.15), cy); ctx.save(); ctx.strokeStyle = wl ? 'rgba(255,255,220,0.35)' : this._wlColor(λ_nm, 0.35); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(slitX - 2, cy); ctx.stroke(); ctx.restore(); /* grating barrier — multiple slits */ this._drawGratingBarrier(slitX, cy, simH, N); const screenPoints = wl ? this._gratingIntensityWhite(d, a, N, L, simH) : this._gratingIntensity(λ, d, a, N, L, simH, λ_nm); this._drawScreen(screenX, simH, screenPoints, λ_nm, wl); this._drawGraph(0, simH, W, graphH, screenPoints, λ_nm, wl); /* order labels on screen */ this._drawGratingOrders(ctx, screenX, cy, d, λ, L, simH); /* resolving power at order 1 */ const R = N * 1; this._drawHUDLine(ctx, W, H, `R = Nn = ${R} (порядок 1)  d·sinθ = nλ`); this._drawLabel(ctx, slitX, simH - 10, 'Решётка'); this._drawLabel(ctx, screenX, simH - 10, 'Экран'); } _gratingIntensity(λ, d, a, N, L, simH, λ_nm) { const pts = []; for (let i = 0; i <= 300; i++) { const y_px = (i / 300) * simH - simH / 2; const y_m = y_px / (simH / 2) * 0.05; const sinT = y_m / Math.sqrt(y_m * y_m + L * L); /* multi-slit principal maxima */ const psi = Math.PI * d * sinT / λ; const Imulti = psi === 0 ? 1 : Math.pow(Math.sin(N * psi) / (N * Math.sin(psi)), 2); /* single-slit envelope */ const alpha = Math.PI * a * sinT / λ; const Ienv = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2); const I = Imulti * Ienv; pts.push({ y_px, I, color: this._wlColor(λ_nm, I) }); } return pts; } _gratingIntensityWhite(d, a, N, L, simH) { /* Combine 7 wavelengths for white-light display */ const wls = [400, 450, 490, 530, 570, 620, 680]; const pts = []; for (let i = 0; i <= 300; i++) { const y_px = (i / 300) * simH - simH / 2; const y_m = y_px / (simH / 2) * 0.05; const sinT = y_m / Math.sqrt(y_m * y_m + L * L); /* blend colors from all wavelengths */ let r = 0, g = 0, b = 0, maxI = 0; wls.forEach(nm => { const λ_w = nm * 1e-9; const psi = Math.PI * d * sinT / λ_w; const Im = psi === 0 ? 1 : Math.pow(Math.sin(N * psi) / (N * Math.sin(psi)), 2); const alpha = Math.PI * a * sinT / λ_w; const Ie = alpha === 0 ? 1 : Math.pow(Math.sin(alpha) / alpha, 2); const I = Im * Ie; if (I > maxI) maxI = I; const rgb = this._wlRGB(nm); r += rgb[0] * I; g += rgb[1] * I; b += rgb[2] * I; }); const sc = maxI > 0 ? 1 / (wls.length * maxI + 0.01) * maxI * wls.length : 0; pts.push({ y_px, I: maxI, color: `rgba(${Math.round(Math.min(255, r / wls.length))},${Math.round(Math.min(255, g / wls.length))},${Math.round(Math.min(255, b / wls.length))},${Math.min(1, maxI + 0.02)})` }); } return pts; } _drawGratingOrders(ctx, screenX, cy, d, λ, L, simH) { const maxOrder = 3; ctx.save(); ctx.font = '10px sans-serif'; ctx.fillStyle = 'rgba(255,255,150,0.85)'; ctx.textAlign = 'center'; for (let n = -maxOrder; n <= maxOrder; n++) { const sinT = n * λ / d; if (Math.abs(sinT) >= 1) continue; const y_m = Math.tan(Math.asin(sinT)) * L; const y_px = y_m / 0.05 * (simH / 2) + cy; if (y_px < 4 || y_px > simH - 4) continue; ctx.fillText(`${n >= 0 ? '+' : ''}${n}`, screenX + 18, y_px + 4); } ctx.restore(); } /* ───────────────────────────────────────────── Common drawing helpers ─────────────────────────────────────────────── */ _drawSource(x, cy) { const ctx = this._ctx; ctx.save(); ctx.beginPath(); ctx.arc(x, cy, 7, 0, Math.PI * 2); ctx.fillStyle = '#fff9c0'; ctx.shadowColor = '#fff5a0'; ctx.shadowBlur = 16; ctx.fill(); ctx.shadowBlur = 0; ctx.restore(); /* label */ ctx.save(); ctx.font = '10px sans-serif'; ctx.fillStyle = '#aaa'; ctx.textAlign = 'center'; ctx.fillText('Источник', x, cy + 20); ctx.restore(); } _drawSlitBarrier(x, cy, simH, sepPx, gapPx, slitCount) { const ctx = this._ctx; const half = sepPx / 2; const hw = 6; ctx.save(); ctx.fillStyle = '#3a3a5a'; /* top block */ ctx.fillRect(x - hw, 0, hw * 2, cy - half - gapPx / 2); /* middle block (between slits) */ if (slitCount === 2) { ctx.fillRect(x - hw, cy - half + gapPx / 2, hw * 2, sepPx - gapPx); } /* bottom block */ ctx.fillRect(x - hw, cy + half + gapPx / 2, hw * 2, simH - (cy + half + gapPx / 2)); /* slit highlight lines */ ctx.strokeStyle = 'rgba(255,255,200,0.5)'; ctx.lineWidth = 1; [cy - half - gapPx / 2, cy - half + gapPx / 2, cy + half - gapPx / 2, cy + half + gapPx / 2].forEach(yy => { ctx.beginPath(); ctx.moveTo(x - hw - 2, yy); ctx.lineTo(x + hw + 2, yy); ctx.stroke(); }); ctx.restore(); } _drawSingleSlitBarrier(x, cy, simH, gapPx) { const ctx = this._ctx; const hw = 6; ctx.save(); ctx.fillStyle = '#3a3a5a'; ctx.fillRect(x - hw, 0, hw * 2, cy - gapPx / 2); ctx.fillRect(x - hw, cy + gapPx / 2, hw * 2, simH - cy - gapPx / 2); ctx.strokeStyle = 'rgba(255,255,200,0.5)'; ctx.lineWidth = 1; [cy - gapPx / 2, cy + gapPx / 2].forEach(yy => { ctx.beginPath(); ctx.moveTo(x - hw - 2, yy); ctx.lineTo(x + hw + 2, yy); ctx.stroke(); }); ctx.restore(); } _drawGratingBarrier(x, cy, simH, N) { const ctx = this._ctx; const hw = 6; const totalH = simH * 0.7; const startY = cy - totalH / 2; ctx.save(); ctx.fillStyle = '#3a3a5a'; ctx.fillRect(x - hw, 0, hw * 2, simH); /* punch slit holes */ const slitH = Math.max(2, Math.floor(totalH / (N * 2.5))); const step = totalH / N; ctx.fillStyle = '#0a0a16'; for (let i = 0; i < N; i++) { const sy = startY + i * step + (step - slitH) / 2; ctx.fillRect(x - hw, sy, hw * 2, slitH); } ctx.restore(); } _drawWavefronts(slitX, s1y, slitX2, s2y, maxR, λ_nm, wl, simH) { const ctx = this._ctx; const baseColor = wl ? 'rgba(255,255,200,' : null; const nArcs = 7; const srcPts = s1y === s2y ? [{ x: slitX, y: s1y }] : [{ x: slitX, y: s1y }, { x: slitX2, y: s2y }]; srcPts.forEach(src => { ctx.save(); for (let i = 1; i <= nArcs; i++) { const r = (i / nArcs) * maxR * 0.92; const a = wl ? (0.4 - i * 0.04) : (0.45 - i * 0.05); ctx.strokeStyle = wl ? `rgba(255,255,180,${Math.max(0.04, a)})` : this._wlColor(λ_nm, Math.max(0.04, a)); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(src.x, src.y, r, -Math.PI / 2, Math.PI / 2); ctx.stroke(); } ctx.restore(); }); } _drawScreen(screenX, simH, pts, λ_nm, wl) { const ctx = this._ctx; const bw = 16; // bar width /* glow pass for bright fringes */ if (window.LabFX && window.LabFX.glow) { /* simple manual glow: shadow blur */ } ctx.save(); /* background of screen area */ ctx.fillStyle = '#08080f'; ctx.fillRect(screenX - 1, 0, bw + 2, simH); pts.forEach(({ y_px, I, color }) => { const yy = Math.round(y_px + simH / 2); const col = color || (wl ? `rgba(255,255,200,${I.toFixed(3)})` : this._wlColor(λ_nm, I)); ctx.fillStyle = col; ctx.fillRect(screenX, yy, bw, 2); /* glow for bright pixels */ if (I > 0.55) { ctx.save(); ctx.shadowColor = color || this._wlColor(λ_nm, 1); ctx.shadowBlur = 8 + I * 10; ctx.fillStyle = col; ctx.fillRect(screenX + 1, yy, bw - 2, 2); ctx.restore(); } }); ctx.restore(); } _drawGraph(x0, y0, W, H, pts, λ_nm, wl) { const ctx = this._ctx; const padL = 38, padR = 20, padT = 8, padB = 18; const gx = x0 + padL, gy = y0 + padT; const gw = W - padL - padR, gh = H - padT - padB; /* graph background */ ctx.save(); ctx.fillStyle = '#080810'; ctx.fillRect(x0, y0, W, H); ctx.fillStyle = '#0d0d1e'; ctx.fillRect(gx, gy, gw, gh); /* axes */ ctx.strokeStyle = '#2a2a50'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + gh); ctx.lineTo(gx + gw, gy + gh); ctx.stroke(); /* y-axis label */ ctx.save(); ctx.translate(x0 + 12, gy + gh / 2); ctx.rotate(-Math.PI / 2); ctx.fillStyle = '#666'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('I(y)', 0, 0); ctx.restore(); /* intensity curve */ const maxI = pts.reduce((m, p) => Math.max(m, p.I), 0) || 1; ctx.beginPath(); let first = true; pts.forEach(({ y_px, I }) => { const px = gx + (y_px / (this._H / 2) + 1) / 2 * gw; const py = gy + gh - (I / maxI) * gh * 0.92; if (first) { ctx.moveTo(px, py); first = false; } else ctx.lineTo(px, py); }); ctx.strokeStyle = wl ? '#ffe066' : this._wlColor(λ_nm, 0.9); ctx.lineWidth = 1.5; ctx.stroke(); /* axis label */ ctx.fillStyle = '#555'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('y', gx + gw / 2, gy + gh + padB - 4); ctx.textAlign = 'right'; ctx.fillText('1', gx - 3, gy + gh * 0.08); ctx.restore(); } _drawHUDLine(ctx, W, H, text) { ctx.save(); ctx.fillStyle = 'rgba(10,10,22,0.7)'; ctx.fillRect(4, H - 22, W - 8, 18); ctx.fillStyle = '#9ad8ff'; ctx.font = '11px monospace'; ctx.textAlign = 'left'; ctx.fillText(text, 10, H - 7); ctx.restore(); } _drawLabel(ctx, x, y, text) { ctx.save(); ctx.fillStyle = '#666'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(text, x, y); ctx.restore(); } /* color helpers */ _wlColor(nm, alpha) { const [r, g, b] = this._wlRGB(nm); return `rgba(${r},${g},${b},${(alpha || 1).toFixed(3)})`; } _wlRGB(nm) { const c = wavelengthToRGB(nm); /* wavelengthToRGB returns 'rgb(r,g,b)' — parse it */ const m = c.match(/(\d+),\s*(\d+),\s*(\d+)/); if (m) return [+m[1], +m[2], +m[3]]; return [200, 200, 200]; } _updateHUD() { /* update stats bar */ const λ_nm = window._obWavelength || 550; const λ = λ_nm * 1e-9; const el = id => document.getElementById(id); if (this._sub === 'young') { const d = this._d_young * 1e-6, L = this._L_young; const dy = (λ * L / d * 1e3).toFixed(3); if (el('diffbar-info')) el('diffbar-info').textContent = `Δy = ${dy} мм`; } else if (this._sub === 'single') { const a = this._a_single * 1e-6; const ang = (2 * λ / a * 180 / Math.PI).toFixed(2); if (el('diffbar-info')) el('diffbar-info').textContent = `2λ/a = ${ang}°`; } else if (this._sub === 'grating') { const R = this._N_grating * 1; if (el('diffbar-info')) el('diffbar-info').textContent = `R = Nn = ${R}`; } if (el('diffbar-sub')) el('diffbar-sub').textContent = { young: 'Юнг', single: 'Однощелевая', grating: 'Решётка' }[this._sub] || ''; if (el('diffbar-wl')) el('diffbar-wl').textContent = (window._obWhiteLight ? 'Белый' : λ_nm + ' нм'); } } /* ── DiffractionSim singleton + UI wiring ── */ var diffrSim = null; function diffrSwitchSub(sub) { if (window.LabFX) LabFX.sound.play('chime', { volume: 0.4 }); /* button styling */ ['young', 'single', 'grating'].forEach(s => { const btn = document.getElementById('diffr-sub-' + s); if (btn) btn.classList.toggle('active', s === sub); }); /* param panel visibility */ ['young', 'single', 'grating'].forEach(s => { const pnl = document.getElementById('ob-diffr-' + s + '-params'); if (pnl) pnl.style.display = s === sub ? '' : 'none'; }); if (diffrSim) { diffrSim.setSub(sub); diffrSim._updateHUD(); } } function diffrParam(name, val) { /* update value label */ const labelId = 'diffr-' + name.replace('_', '-') + '-val'; const lbl = document.getElementById(labelId); if (lbl) { const v = parseFloat(val); /* N_grating → integer; L/d_grating/a_grating → 1 decimal; others → 0 */ const decimals = name.includes('N_') ? -1 : (name === 'L_young' || name === 'd_grating' || name === 'a_grating') ? 1 : 0; lbl.textContent = decimals < 0 ? Math.round(v) : v.toFixed(decimals); } if (diffrSim) diffrSim.setParam(name, val); } function diffrReset() { if (!diffrSim) return; diffrSim._d_young = 40; diffrSim._L_young = 1.0; diffrSim._a_single = 80; diffrSim._N_grating = 10; diffrSim._d_grating = 2.0; diffrSim._a_grating = 0.5; /* reset sliders */ ['d-young', 'L-young', 'a-single', 'N-grating', 'd-grating', 'a-grating'].forEach(k => { const sl = document.getElementById('sl-diffr-' + k); if (sl) { const defaults = { 'd-young': 40, 'L-young': 10, 'a-single': 80, 'N-grating': 10, 'd-grating': 20, 'a-grating': 5 }; if (defaults[k] !== undefined) sl.value = defaults[k]; } const lbl = document.getElementById('diffr-' + k + '-val'); if (lbl) { const dv = { 'd-young': 40, 'L-young': '1.0', 'a-single': 80, 'N-grating': 10, 'd-grating': '2.0', 'a-grating': '0.5' }; if (dv[k] !== undefined) lbl.textContent = dv[k]; } }); diffrSim.draw(); diffrSim._updateHUD(); }