diff --git a/frontend/css/lab.css b/frontend/css/lab.css index e6670bd..4379de4 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -1512,3 +1512,47 @@ canvas[data-draggable]:active { cursor: grabbing; } border-radius: 4px; background: #0a0a18; } + +/* ═══ OB_FX Effects panel ═══ */ +.ob-fx-panel { + flex-shrink: 0; + background: #0d0d1e; + border-bottom: 1px solid #1e1e32; +} +.ob-fx-summary { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + font-size: .72rem; + font-weight: 700; + color: #888; + cursor: pointer; + list-style: none; + user-select: none; +} +.ob-fx-summary::-webkit-details-marker { display: none; } +.ob-fx-summary:hover { color: var(--cyan); } +.ob-fx-summary .ic { fill: currentColor; opacity: 0.7; flex-shrink: 0; } +.ob-fx-row { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + padding: 6px 12px 8px; +} +.ob-fx-label { + display: flex; + align-items: center; + gap: 5px; + font-size: .72rem; + color: #bbb; + cursor: pointer; + white-space: nowrap; +} +.ob-fx-label:hover { color: #fff; } +.ob-fx-label input[type=checkbox] { + accent-color: var(--cyan); + width: 13px; + height: 13px; + cursor: pointer; +} diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index de3a86a..f178b62 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -510,6 +510,9 @@ { head: 'Закон Снеллиуса', formula: 'n_1 \\sin\\theta_1 = n_2 \\sin\\theta_2', text: 'Угол преломления зависит от соотношения показателей преломления двух сред.' }, { head: 'Полное внутреннее отражение', formula: '\\theta_c = \\arcsin\\frac{n_2}{n_1}', text: 'При n₁ > n₂ и θ₁ > θc — свет полностью отражается.' }, { head: 'Показатель преломления', formula: 'n = \\frac{c}{v}', text: 'Воздух ≈ 1.00, вода = 1.33, стекло ≈ 1.5, алмаз = 2.42.' }, + { head: 'Волновая оптика — Юнг', formula: 'I(y) = I_0 \\cos^2\\!\\left(\\frac{\\pi d \\sin\\theta}{\\lambda}\\right)', vars: [['d','расстояние между щелями'],['\\lambda','длина волны']], text: 'Расстояние между полосами: Δy = λL/d.' }, + { head: 'Однощелевая дифракция', formula: 'I(\\theta) = I_0 \\left(\\frac{\\sin\\alpha}{\\alpha}\\right)^2,\\quad \\alpha = \\frac{\\pi a \\sin\\theta}{\\lambda}', text: 'Угловая ширина центрального максимума: 2λ/a. Минимумы при a·sinθ = nλ.' }, + { head: 'Дифракционная решётка', formula: 'd \\sin\\theta = n\\lambda', vars: [['d','период решётки'],['n','порядок'],['\\lambda','длина волны']], text: 'Разрешающая способность R = Nn, где N — число щелей, n — порядок максимума.' }, ] }, thinlens: { diff --git a/frontend/js/labs/opticsbench.js b/frontend/js/labs/opticsbench.js index 3412ba9..7faf918 100644 --- a/frontend/js/labs/opticsbench.js +++ b/frontend/js/labs/opticsbench.js @@ -61,6 +61,226 @@ const OB_SPECTRAL = [ { 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) ───────────────────────────────────────────────────────────────*/ @@ -304,8 +524,8 @@ class ThinLensSim { this._drawArrowLabels(ctx, lensX, axisY, d, h, dPrime, hPrime); this._drawLMInfo(ctx, lensX, axisY); - // Lens caustics: emit dust near focal point when image exists - if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) { + // 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++; @@ -313,6 +533,19 @@ class ThinLensSim { 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); } } @@ -1082,8 +1315,8 @@ class MirrorSim { this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime); if (step >= 0) this._drawStepOverlay(ctx, step); - // Mirror caustics near focal point when real image exists - if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) { + // 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++; @@ -1091,6 +1324,20 @@ class MirrorSim { 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); } } @@ -1789,7 +2036,24 @@ class RefractionSim { 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); } @@ -2095,6 +2359,11 @@ class FreeBuildSim { 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) */ @@ -2425,6 +2694,8 @@ class PrismSim { 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); } } @@ -2472,6 +2743,385 @@ class PrismSim { } } +/* ───────────────────────────────────────────────────────────── + 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; + LabFX.glow.drawGlow(ctx, cx, cy, r1b, wavelengthToRGB(nm), 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 ───────────────────────────────────────────────────────────────*/ @@ -2530,6 +3180,7 @@ var mirrorSim = null; var refrSim = null; var prismSim = null; var freeSim = null; /* multi-lens free-build (Agent OB-A3) */ +var ifSim = null; /* interference/polarization (Agent C) */ var _obMode = 'lens'; // current active mode within opticsbench /* Wavelength state — shared across all modes */ @@ -2544,6 +3195,11 @@ function _openOpticsBench(mode) { _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); })); @@ -2553,6 +3209,7 @@ 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 === 'waves') return { mode: 'waves' }; return { mode: _obMode }; } @@ -2572,13 +3229,13 @@ function obSwitchMode(mode, silent) { _obMode = mode; /* tab button styling */ - ['lens', 'mirror', 'refraction', 'prism', 'freebuild'].forEach(m => { + ['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'].forEach(id => { + ['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'; }); @@ -2586,7 +3243,7 @@ function obSwitchMode(mode, silent) { 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'].forEach(id => { + ['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'; }); @@ -2595,8 +3252,8 @@ function obSwitchMode(mode, silent) { 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']; - const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-canvas' }; + 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 = ''; @@ -2650,6 +3307,23 @@ function obSwitchMode(mode, silent) { } } if (freeSim) { freeSim.fit(); freeSim.draw(); _freeUpdateUI(freeSim._computeChain()); } + } 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(); } } @@ -2668,12 +3342,87 @@ function obToggleWhiteLight(on) { _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' && freeSim) { freeSim.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; }; @@ -3173,3 +3922,637 @@ function _freeUpdateUI(chain) { const el2 = document.getElementById('freebar-sys'); if (el2) el2.textContent = chain.sysFocal !== null ? chain.sysFocal.toFixed(0) : '—'; } + +/* ───────────────────────────────────────────────────────────── + 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(); +} diff --git a/frontend/lab.html b/frontend/lab.html index ae165af..34827b3 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -2533,6 +2533,8 @@ + +
@@ -2564,6 +2566,35 @@
+ +
+ + + Эффекты + +
+ + + + + +
+
@@ -2770,13 +2801,134 @@
Тащи линзы или предмет по оси мышью
- + + + + +
+ +
@@ -2818,6 +2970,15 @@
λ
550 нм
Режим
Моно
+ +