'use strict'; /* ══════════════════════════════════════════════════════════════ ThinLensSim — thin lens ray tracing simulation 1/f = 1/d + 1/d' M = -d'/d Three principal rays · draggable object & focal point Converging (f>0) and diverging (f<0) lenses ══════════════════════════════════════════════════════════════ */ class ThinLensSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; /* physics (px units) */ this.f = 100; // focal length this.d = 200; // object distance (positive, measured from lens) this.h = 50; // object height /* drag state */ this._drag = null; // 'object' | 'focus' | null /* callback */ this.onUpdate = null; this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } /* ── public API ─────────────────────────────── */ 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(); } 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), }; } /* ── internals ─────────────────────────────── */ _emit() { if (this.onUpdate) this.onUpdate(this.info()); } /** Convert simulation coords to canvas coords. * Origin = lens center; +x right, +y up. * Canvas: lensX = W/2, axisY = H/2 */ _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 ──────────────────────────────────── */ 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; /* background */ ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); /* optical axis */ 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 */ this._drawLens(ctx, lensX, axisY, f); /* focal & 2F points */ this._drawFocalPoints(ctx, lensX, axisY, f); /* object arrow */ const objX = lensX - d; this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false); /* compute image */ const denom = d - f; let dPrime, hPrime; if (Math.abs(denom) < 0.5) { /* object at focal point — rays parallel, no image */ dPrime = null; hPrime = null; } else { dPrime = (f * d) / denom; const M = -dPrime / d; hPrime = M * h; } /* principal rays */ this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime); /* image arrow */ if (dPrime !== null && isFinite(dPrime)) { const isVirtual = dPrime < 0; const imgX = lensX + dPrime; const imgTop = axisY - hPrime; this._drawArrow(ctx, imgX, axisY, imgX, imgTop, isVirtual ? '#FFD166' : '#EF476F', isVirtual); } /* labels */ this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime); } _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) { /* biconvex shape */ 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(); /* arrowheads (converging) */ this._lensArrow(ctx, lx, ay - lensH, -1); this._lensArrow(ctx, lx, ay + lensH, 1); } else { /* biconcave shape */ 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(); /* arrowheads (diverging) */ this._lensArrowDiv(ctx, lx, ay - lensH, -1); this._lensArrowDiv(ctx, lx, ay + lensH, 1); } /* center line */ 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([]); /* arrowhead */ const angle = Math.atan2(y2 - y1, x2 - x1); const 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) { const objX = lx - d; const objY = ay - h; const colors = ['#06D6E0', '#7BF5A4', '#FFD166']; const hasImage = dPrime !== null && isFinite(dPrime); const isVirtual = hasImage && dPrime < 0; ctx.lineWidth = 1.5; /* Ray 1: parallel to axis through F' (converging) or from F' (diverging) */ { ctx.strokeStyle = colors[0]; ctx.setLineDash([]); /* incoming: object tip lens, parallel */ ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke(); /* outgoing */ if (hasImage) { const imgX = lx + dPrime; const imgY = ay - hPrime; if (!isVirtual) { ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke(); /* extend past image */ this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]); } else { /* diverging outgoing ray + dashed virtual extension */ 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 straight */ { ctx.strokeStyle = colors[1]; ctx.setLineDash([]); const slope = (objY - ay) / (objX - lx); const farX = lx + 350; const farY = ay + slope * 350; ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke(); if (isVirtual) { /* extend behind lens too */ const backX = lx - 350; const 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 parallel after lens */ { ctx.strokeStyle = colors[2]; ctx.setLineDash([]); const fx = lx - f; const 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([]); } } } _extendRay(ctx, x1, y1, x2, y2, color) { const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy); if (len < 1) return; const ex = x2 + (dx / len) * 80; const ey = y2 + (dy / len) * 80; ctx.globalAlpha = 0.3; ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(ex, ey); ctx.stroke(); ctx.globalAlpha = 1; } _drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) { ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top'; /* d label */ const objX = lx - d; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.fillText(`d = ${d.toFixed(0)}`, (objX + lx) / 2, ay + 26); /* f label */ ctx.fillStyle = '#06D6E0'; ctx.fillText(`f = ${f.toFixed(0)}`, lx, ay + 42); /* d' label */ 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); } /* formula box */ const info = this.info(); const boxW = 200, boxH = 52; const 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'; const mStr = info.M === Infinity ? '---' : info.M.toFixed(2); const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1); ctx.fillText(`1/f = 1/d + 1/d'`, bx + 10, by + 10); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fillText(`M = ${mStr} d' = ${dpStr} ${info.imageType}`, bx + 10, by + 30); } /* ── events ─────────────────────────────────── */ _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; /* object tip */ const objX = lx - this.d; const objY = ay - this.h; if (Math.hypot(mx - objX, my - objY) < 20) return 'object'; /* focal point F (front) */ const fx = lx - this.f; if (Math.hypot(mx - fx, 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); const lx = this.W / 2; if (this._drag === 'object') { this.d = Math.max(30, Math.min(400, lx - mx)); } else if (this._drag === 'focus') { const newF = lx - mx; this.f = Math.max(-200, Math.min(200, newF)); } this.draw(); this._emit(); }; const onUp = () => { this._drag = null; }; /* mouse */ cv.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); /* touch */ 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); /* cursor style */ 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'; }); } } /* ─── lab UI init ─────────────────────────────────── */ function _openThinLens() { document.getElementById('sim-topbar-title').textContent = 'Тонкая линза'; _simShow('sim-thinlens'); _registerSimState('thinlens', () => lensSim?.getParams(), st => lensSim?.setParams(st)); if (_embedMode) _startStateEmit('thinlens'); requestAnimationFrame(() => requestAnimationFrame(() => { if (!lensSim) { lensSim = new ThinLensSim(document.getElementById('thinlens-canvas')); lensSim.onUpdate = _lensUpdateUI; } lensSim.fit(); lensSim.draw(); lensSim._emit(); })); } 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 }); } 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); } /* ── mirrors ── */