'use strict'; /* ══════════════════════════════════════════════════════════════ MirrorSim v3 Flat / Concave / Convex · 1/f = 1/d + 1/d' · M = -d'/d Features: fan rays, normals, angle arcs, ray labels ①②③, center C, zones, grid, photon animation, step mode, speed control, point mode, drag image, hover tooltips, mirror transition, unified infobox, legend, export PNG ══════════════════════════════════════════════════════════════ */ class MirrorSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; // physics this.type = 'concave'; this.f = 120; this.d = 240; this.h = 60; // object animation this._playing = false; this._animT = 1.4; this._animSpeed = 1; this._raf = null; // step mode (-1 = all, 0..3 = progressive) this._step = -1; // display toggles this._showGrid = false; this._showZones = true; this._showNormals = true; this._showDims = true; this._showAngles = true; this._showPhotons = true; this._pointMode = false; // photon system this._photons = []; this._photonRaf = null; this._photonTimer = 0; this._lastPhoTime = 0; this._photonPaths = []; // mirror transition this._prevType = 'concave'; this._transT = 1.0; this._transRaf = null; // drag & hover this._drag = null; this._hoverX = -999; this._hoverY = -999; // callbacks this.onUpdate = null; this.onAnimate = 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; } 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', }; 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(); } /* ── Physics ─────────────────────────────────── */ _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()); } /* ── Mirror transition ───────────────────────── */ _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); } /* ── Object animation ────────────────────────── */ _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()); } /* ── Photon system ───────────────────────────── */ _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(); // animation loop handles draw when playing this._photonRaf = requestAnimationFrame(() => this._photonLoop()); } /* ── Main draw ───────────────────────────────── */ 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); /* bg */ ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); if (this._showGrid) this._drawGrid(ctx); if (this._showZones) this._drawZones(ctx, mx); /* 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([]); /* fan rays */ this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill); /* mirror */ this._drawMirror(ctx, mx, ay); /* focal pts + C */ if (this.type !== 'flat') { this._drawFocalPoints(ctx, mx, ay, f); this._drawCenterC(ctx, mx, ay, f); } /* normals */ if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3)) this._drawNormals(ctx, mx, ay, f); /* angle arcs (only in full view) */ if (this._showAngles && this.type !== 'flat' && step === -1) this._drawAngleArcs(ctx, mx, ay, f); /* ray labels */ if (step === -1 || step >= 1) this._drawRayLabels(ctx, mx, ay, f, step); /* object */ 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); } /* image */ if (dPrime !== null && isFinite(dPrime)) { const imgX = mx - dPrime; const 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); } } /* dims */ if (this._showDims && (step === -1 || step >= 3)) this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime); /* infobox */ this._drawInfoBox(ctx, f, dPrime); /* badge */ if ((step === -1 || step >= 3) && dPrime !== null) this._drawImageBadge(ctx, dPrime, hPrime); /* critical marker */ this._drawCriticalMarker(ctx, f); /* legend */ if (this._showDims) this._drawLegend(ctx); /* photons */ if (this._showPhotons && this._photons.length) this._drawPhotons(ctx); /* tooltip */ this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime); /* step overlay */ if (step >= 0) this._drawStepOverlay(ctx, step); } /* ── Grid & Zones ────────────────────────────── */ _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); } /* ── Mirror surface ──────────────────────────── */ _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(); } /* ── Focal points ────────────────────────────── */ _drawFocalPoints(ctx, mx, ay, f) { const behind = f < 0; 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 = behind ? '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); } } /* ── Center of curvature C ───────────────────── */ _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(); } /* ── Fan rays ────────────────────────────────── */ _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; const objY = ay - (this._pointMode ? 0 : h); const COLS = ['#06D6E0','#7BF5A4','#FFD166']; const FAN = '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; 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(); } /* ── Normals ─────────────────────────────────── */ _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(); } /* ── Angle arcs ──────────────────────────────── */ _drawAngleArcs(ctx, mx, ay, f) { if (!isFinite(f)) return; const { d, h } = this; const hitY = ay - h; // use ray 1 hit point 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); // toward C const normOuter = normInward + Math.PI; // outward normal const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d)); // incident FROM object const incFrom = incDir + Math.PI; // direction FROM mirror to object const r = 14; ctx.save(); ctx.lineWidth = 1; // arc on incident side ctx.strokeStyle = 'rgba(6,214,224,0.45)'; ctx.beginPath(); let a1 = normOuter, a2 = incFrom; // normalize so arc goes the short way ctx.arc(mx, hitY, r, a1, a2, false); ctx.stroke(); // θ label ctx.fillStyle = 'rgba(6,214,224,0.7)'; ctx.font = '9px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const mid = (a1+a2)/2; ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9)); ctx.restore(); } /* ── Ray labels ①②③ ──────────────────────────── */ _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); }); } /* ── Arrow ───────────────────────────────────── */ _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(); } /* ── Dimension annotations ───────────────────── */ _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); } } } /* ── Unified info box ────────────────────────── */ _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'; } } } /* ── Image badge ─────────────────────────────── */ _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} ×${Math.abs(+info.M).toFixed(2)}`, bx+57, by+42); } } } /* ── Critical marker ─────────────────────────── */ _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(); } /* ── Photon drawing ──────────────────────────── */ _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(); } /* ── Step overlay ────────────────────────────── */ _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(); } /* ── Events ──────────────────────────────────── */ _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(); // redraw for tooltip } }); window.addEventListener('mouseup', () => { 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; }); } } /* ─── lab UI init ─────────────────────────────────── */ var mirrorSim = null; function _openMirror() { document.getElementById('sim-topbar-title').textContent = 'Зеркала'; _simShow('sim-mirrors'); _registerSimState('mirrors', () => mirrorSim?.getParams(), st => mirrorSim?.setParams(st)); if (_embedMode) _startStateEmit('mirrors'); requestAnimationFrame(() => requestAnimationFrame(() => { if (!mirrorSim) { mirrorSim = new MirrorSim(document.getElementById('mirror-canvas')); 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(); })); } 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); } /* ── isoprocesses ── */