ae31e4c4e8
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.
1100 lines
42 KiB
JavaScript
1100 lines
42 KiB
JavaScript
'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 ── */
|
||
|