Files
Learn_System/frontend/js/labs/mirror.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

1100 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)<eps) text = 'd = 2f : изображение равное, M = −1';
if (!text) return;
ctx.save(); ctx.font='bold 11px Manrope, system-ui, sans-serif';
ctx.textAlign='center'; ctx.textBaseline='top';
const tw=ctx.measureText(text).width, bx=this.W/2-tw/2-10, by=4;
ctx.fillStyle='rgba(255,209,102,0.15)';
ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.fill();
ctx.strokeStyle='rgba(255,209,102,0.35)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(bx,by,tw+20,22,6); ctx.stroke();
ctx.fillStyle='#FFD166'; ctx.fillText(text, this.W/2, by+5);
ctx.restore();
}
/* ── Legend ──────────────────────────────────── */
_drawLegend(ctx) {
const items = [
{ c:'rgba(155,93,229,0.8)', t:'d > 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<pts.length; i++) {
const l = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);
lens.push(l); total += l;
}
let dist = t * total;
for (let i=0; i<lens.length; i++) {
if (dist <= lens[i]) {
const k = dist/lens[i];
return [pts[i][0]+(pts[i+1][0]-pts[i][0])*k, pts[i][1]+(pts[i+1][1]-pts[i][1])*k];
}
dist -= lens[i];
}
return pts[pts.length-1];
}
/* ── Hover tooltip ───────────────────────────── */
_drawTooltip(ctx, mx, ay, f, dPrime, hPrime) {
const { _hoverX:hx, _hoverY:hy } = this;
if (hx < 0) return;
let tip = null;
const chk = (px, py, lbl, sub) => {
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 ── */