Files
Learn_System/frontend/js/labs/opticsbench.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

1634 lines
71 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';
/* ══════════════════════════════════════════════════════════════
OpticsBenchSim — unified optical bench simulation
Merges: ThinLensSim (thinlens.js) + MirrorSim (mirror.js) + RefractionSim (refraction.js)
Modes:
'lens' — thin lens: 1/f = 1/d + 1/d', M = -d'/d
'mirror' — curved / flat mirrors, same formula
'refraction'— Snell's law: n₁sin θ₁ = n₂sin θ₂, TIR, dispersion
Physics preserved verbatim from original sims.
══════════════════════════════════════════════════════════════ */
/* ─────────────────────────────────────────────────────────────
1. THIN LENS ENGINE (from thinlens.js)
───────────────────────────────────────────────────────────────*/
class ThinLensSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.f = 100;
this.d = 200;
this.h = 50;
this._drag = null;
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
this.draw();
this._emit();
}
reset() {
this.f = 100; this.d = 200; this.h = 50;
this.draw();
this._emit();
}
info() {
const { f, d, h } = this;
const denom = d - f;
const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom;
const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d;
const hPrime = M === Infinity ? Infinity : M * h;
const isVirtual = dPrime < 0;
return {
f: +f.toFixed(1),
d: +d.toFixed(1),
dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
M: M === Infinity ? Infinity : +M.toFixed(3),
imageType: isVirtual ? 'мнимое' : 'действительное',
h: +h.toFixed(1),
hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_toCanvas(sx, sy) { return { cx: this.W / 2 + sx, cy: this.H / 2 - sy }; }
_fromCanvas(cx, cy) { return { sx: cx - this.W / 2, sy: this.H / 2 - cy }; }
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const { f, d, h } = this;
const lensX = W / 2;
const axisY = H / 2;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke();
ctx.setLineDash([]);
this._drawLens(ctx, lensX, axisY, f);
this._drawFocalPoints(ctx, lensX, axisY, f);
const objX = lensX - d;
this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false);
const denom = d - f;
let dPrime, hPrime;
if (Math.abs(denom) < 0.5) {
dPrime = null; hPrime = null;
} else {
dPrime = (f * d) / denom;
hPrime = (-dPrime / d) * h;
}
this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime);
if (dPrime !== null && isFinite(dPrime)) {
const isVirtual = dPrime < 0;
const imgX = lensX + dPrime;
this._drawArrow(ctx, imgX, axisY, imgX, axisY - hPrime,
isVirtual ? '#FFD166' : '#EF476F', isVirtual);
}
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
// Lens caustics: emit dust near focal point when image exists
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const imgX = lensX + dPrime;
if (!this._causticFrame) this._causticFrame = 0;
this._causticFrame++;
if (this._causticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true });
}
}
if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); }
}
_drawLens(ctx, lx, ay, f) {
const lensH = Math.min(this.H * 0.38, 140);
const converging = f > 0;
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
ctx.lineWidth = 2.5;
if (converging) {
const bulge = Math.min(18, Math.abs(f) * 0.12);
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
this._lensArrow(ctx, lx, ay - lensH, -1);
this._lensArrow(ctx, lx, ay + lensH, 1);
} else {
const bulge = Math.min(14, Math.abs(f) * 0.1);
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); ctx.stroke();
this._lensArrowDiv(ctx, lx, ay - lensH, -1);
this._lensArrowDiv(ctx, lx, ay + lensH, 1);
}
ctx.strokeStyle = 'rgba(155,93,229,0.3)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
}
_lensArrow(ctx, x, y, dir) {
const sz = 7;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sz, y + dir * sz * 1.2); ctx.lineTo(x + sz, y + dir * sz * 1.2); ctx.closePath(); ctx.fill();
}
_lensArrowDiv(ctx, x, y, dir) {
const sz = 6;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath(); ctx.moveTo(x - sz, y); ctx.lineTo(x, y - dir * sz); ctx.lineTo(x + sz, y); ctx.closePath(); ctx.fill();
}
_drawFocalPoints(ctx, lx, ay, f) {
const pts = [{ sx: f, label: "F'" }, { sx: -f, label: 'F' }, { sx: 2 * f, label: "2F'" }, { sx: -2 * f, label: '2F' }];
for (const p of pts) {
const px = lx + p.sx;
if (px < 10 || px > this.W - 10) continue;
const isFocal = !p.label.startsWith('2');
const r = isFocal ? 5 : 3.5;
const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)';
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(p.label, px, ay + 10);
}
}
_drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5;
if (dashed) ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
if (dashed) ctx.setLineDash([]);
const angle = Math.atan2(y2 - y1, x2 - x1), aLen = 10;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
ctx.closePath(); ctx.fill();
}
_drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) {
const objX = lx - d, objY = ay - h;
const colors = ['#06D6E0', '#7BF5A4', '#FFD166'];
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5;
const _doGlow = (color, fn) => {
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 });
else fn();
};
// Ray 1: parallel to axis
_doGlow(colors[0], () => {
ctx.strokeStyle = colors[0]; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
if (hasImage) {
const imgX = lx + dPrime, imgY = ay - hPrime;
if (!isVirtual) {
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]);
} else {
const outSlope = (objY - ay) / f;
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke();
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
ctx.setLineDash([]);
}
}
});
// Ray 2: through center
_doGlow(colors[1], () => {
ctx.strokeStyle = colors[1]; ctx.setLineDash([]);
const slope = (objY - ay) / (objX - lx);
const farX = lx + 350, farY = ay + slope * 350;
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke();
if (isVirtual) {
const backX = lx - 350, backY = ay - slope * 350;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
ctx.setLineDash([]);
}
});
// Ray 3: through F
_doGlow(colors[2], () => {
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
const fx = lx - f, slope = (objY - ay) / (objX - fx);
const hitY = objY + slope * (lx - objX);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke();
const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300;
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke();
if (hasImage && isVirtual) {
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
ctx.setLineDash([]);
}
});
}
_extendRay(ctx, x1, y1, x2, y2, color) {
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
if (len < 1) return;
ctx.globalAlpha = 0.3; ctx.strokeStyle = color;
ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 + (dx / len) * 80, y2 + (dy / len) * 80); ctx.stroke();
ctx.globalAlpha = 1;
}
_drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) {
ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top';
const objX = lx - d;
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center';
ctx.fillText('d = ' + d.toFixed(0), (objX + lx) / 2, ay + 26);
ctx.fillStyle = '#06D6E0';
ctx.fillText('f = ' + f.toFixed(0), lx, ay + 42);
if (dPrime !== null && isFinite(dPrime)) {
const imgX = lx + dPrime;
ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; ctx.textAlign = 'center';
ctx.fillText("d' = " + dPrime.toFixed(1), (lx + imgX) / 2, ay + 26);
}
const info = this.info();
const boxW = 200, boxH = 52, bx = 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText("1/f = 1/d + 1/d'", bx + 10, by + 10);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
const mStr = info.M === Infinity ? '---' : info.M.toFixed(2);
const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1);
ctx.fillText('M = ' + mStr + " d' = " + dpStr + ' ' + info.imageType, bx + 10, by + 30);
}
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
};
const hitTest = (mx, my) => {
const lx = this.W / 2, ay = this.H / 2;
if (Math.hypot(mx - (lx - this.d), my - (ay - this.h)) < 20) return 'object';
if (Math.hypot(mx - (lx - this.f), my - ay) < 16) return 'focus';
return null;
};
const onDown = (e) => { const { mx, my } = getPos(e); this._drag = hitTest(mx, my); };
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx } = getPos(e), lx = this.W / 2;
if (this._drag === 'object') this.d = Math.max(30, Math.min(400, lx - mx));
else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx));
this.draw(); this._emit();
};
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
/* ─────────────────────────────────────────────────────────────
2. MIRROR ENGINE (from mirror.js)
───────────────────────────────────────────────────────────────*/
class MirrorSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.type = 'concave';
this.f = 120;
this.d = 240;
this.h = 60;
this._playing = false;
this._animT = 1.4;
this._animSpeed = 1;
this._raf = null;
this._step = -1;
this._showGrid = false;
this._showZones = true;
this._showNormals = true;
this._showDims = true;
this._showAngles = true;
this._showPhotons = true;
this._pointMode = false;
this._photons = [];
this._photonRaf = null;
this._photonTimer = 0;
this._lastPhoTime = 0;
this._photonPaths = [];
this._prevType = 'concave';
this._transT = 1.0;
this._transRaf = null;
this._drag = null;
this._hoverX = -999;
this._hoverY = -999;
this.onUpdate = null;
this.onAnimate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
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();
}
_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()); }
_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);
}
_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());
}
_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();
this._photonRaf = requestAnimationFrame(() => this._photonLoop());
}
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);
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
if (this._showGrid) this._drawGrid(ctx);
if (this._showZones) this._drawZones(ctx, mx);
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([]);
this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill);
this._drawMirror(ctx, mx, ay);
if (this.type !== 'flat') {
this._drawFocalPoints(ctx, mx, ay, f);
this._drawCenterC(ctx, mx, ay, f);
}
if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3))
this._drawNormals(ctx, mx, ay, f);
if (this._showAngles && this.type !== 'flat' && step === -1)
this._drawAngleArcs(ctx, mx, ay, f);
if (step === -1 || step >= 1) this._drawRayLabels(ctx, mx, ay, f, step);
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); }
if (dPrime !== null && isFinite(dPrime)) {
const imgX = mx - dPrime, 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);
}
}
if (this._showDims && (step === -1 || step >= 3))
this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime);
this._drawInfoBox(ctx, f, dPrime);
if ((step === -1 || step >= 3) && dPrime !== null) this._drawImageBadge(ctx, dPrime, hPrime);
this._drawCriticalMarker(ctx, f);
if (this._showDims) this._drawLegend(ctx);
if (this._showPhotons && this._photons.length) this._drawPhotons(ctx);
this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);
if (step >= 0) this._drawStepOverlay(ctx, step);
// Mirror caustics near focal point when real image exists
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime);
if (!this._mCausticFrame) this._mCausticFrame = 0;
this._mCausticFrame++;
if (this._mCausticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true });
}
}
if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
}
_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);
}
_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();
}
_drawFocalPoints(ctx, mx, ay, f) {
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 = f < 0 ? '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);
}
}
_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();
}
_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, 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;
if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
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();
}
_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();
}
_drawAngleArcs(ctx, mx, ay, f) {
if (!isFinite(f)) return;
const { d, h } = this;
const hitY = ay - h;
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);
const normOuter = normInward + Math.PI;
const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d));
const incFrom = incDir + Math.PI;
const r = 14;
ctx.save(); ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(6,214,224,0.45)';
ctx.beginPath(); ctx.arc(mx, hitY, r, normOuter, incFrom, false); ctx.stroke();
ctx.fillStyle = 'rgba(6,214,224,0.7)'; ctx.font = '9px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const mid = (normOuter+incFrom)/2;
ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9));
ctx.restore();
}
_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);
});
}
_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();
}
_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);
}
}
}
_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';
}
}
}
_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 + ' x' + Math.abs(+info.M).toFixed(2), bx+57, by+42);
}
}
}
_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();
}
_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();
}
_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];
}
_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();
}
_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();
}
_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(); }
});
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); 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; });
}
}
/* ─────────────────────────────────────────────────────────────
3. REFRACTION ENGINE (from refraction.js)
───────────────────────────────────────────────────────────────*/
class RefractionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.n1 = 1.0;
this.n2 = 1.5;
this.angle = 30;
this.dispersion = false;
this._drag = false;
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
setParams({ n1, n2, angle, dispersion } = {}) {
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle));
if (dispersion !== undefined) this.dispersion = !!dispersion;
this.draw(); this._emit();
}
reset() { this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; this.dispersion = false; this.draw(); this._emit(); }
info() {
const { n1, n2, angle } = this;
const theta1Rad = angle * Math.PI / 180;
const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
const criticalAngle = n1 > n2 ? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1) : null;
let angle2;
if (isTIR) angle2 = 'ПВО';
else angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1);
return { n1: +n1.toFixed(2), n2: +n2.toFixed(2), angle1: +angle.toFixed(1), angle2, criticalAngle, isTIR };
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const midY = H / 2, hitX = W / 2, hitY = midY;
const gradTop = ctx.createLinearGradient(0, 0, 0, midY);
gradTop.addColorStop(0, '#131328'); gradTop.addColorStop(1, '#1a1a3a');
ctx.fillStyle = gradTop; ctx.fillRect(0, 0, W, midY);
const gradBot = ctx.createLinearGradient(0, midY, 0, H);
gradBot.addColorStop(0, '#0e1a2e'); gradBot.addColorStop(1, '#0D0D1A');
ctx.fillStyle = gradBot; ctx.fillRect(0, midY, W, H - midY);
ctx.save();
ctx.shadowColor = 'rgba(155, 93, 229, 0.4)'; ctx.shadowBlur = 12;
ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke();
ctx.setLineDash([]);
const theta1Rad = this.angle * Math.PI / 180;
const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
let R = 1;
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const cosT1 = Math.cos(theta1Rad), cosT2 = Math.cos(theta2Rad);
const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2);
R = rs * rs;
}
const rayLen = Math.max(W, H) * 0.6;
if (this.n1 > this.n2) {
const critRad = Math.asin(this.n2 / this.n1);
const critDx = Math.sin(critRad), critDy = Math.cos(critRad);
ctx.strokeStyle = 'rgba(255,209,102,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(hitX, hitY); ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); ctx.stroke();
ctx.setLineDash([]);
ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,209,102,0.5)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', hitX - critDx * rayLen * 0.35 + 6, hitY - critDy * rayLen * 0.35);
}
if (this.dispersion && !isTIR) this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen);
else this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen);
this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR);
this._drawMediumLabels(ctx, W, H, midY);
this._drawInfoBox(ctx, isTIR, R);
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
const handleX = hitX - incDx * rayLen * 0.55, handleY = hitY - incDy * rayLen * 0.55;
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
// TIR one-shot sound
if (window.LabFX) {
if (isTIR && !this._wasTIR) {
LabFX.sound.play('spark', { volume: 0.2 });
}
this._wasTIR = isTIR;
// Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol)
const _isBrew = !isTIR && R < 0.005 && this.angle > 0;
if (_isBrew && !this._wasBrewster) {
LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
}
this._wasBrewster = _isBrew;
LabFX.particles.update(1 / 60);
LabFX.particles.draw(ctx);
}
}
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#9B5DE5', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#9B5DE5');
const refDx = incDx, refDy = -incDy;
const refEndX = hitX + refDx * rayLen, refEndY = hitY + refDy * rayLen;
const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R));
ctx.globalAlpha = refAlpha;
this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5);
this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F');
ctx.globalAlpha = 1;
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const refracDx = Math.sin(theta2Rad), refracDy = Math.cos(theta2Rad);
const refracEndX = hitX + refracDx * rayLen, refracEndY = hitY + refracDy * rayLen;
const T = 1 - R;
ctx.globalAlpha = Math.max(0.3, Math.sqrt(T));
this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, '#06D6E0', 2.5);
this._drawArrowhead(ctx, refracEndX, refracEndY, Math.atan2(refracEndY - hitY, refracEndX - hitX), '#06D6E0');
ctx.globalAlpha = 1;
}
}
_drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) {
const spectral = [
{ color: '#FF0000', wave: 656 }, { color: '#FF7F00', wave: 589 }, { color: '#FFFF00', wave: 550 },
{ color: '#00FF00', wave: 510 }, { color: '#00FFFF', wave: 475 }, { color: '#0000FF', wave: 450 },
{ color: '#8B00FF', wave: 400 },
];
const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad);
const incStartX = hitX - incDx * rayLen, incStartY = hitY - incDy * rayLen;
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
const A = this.n2 - 4500 / (550 * 550), B = 4500;
for (const s of spectral) {
const n2w = A + B / (s.wave * s.wave);
const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) continue;
const t2 = Math.asin(sinT2), dx = Math.sin(t2), dy = Math.cos(t2);
ctx.globalAlpha = 0.85;
this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5);
ctx.globalAlpha = 1;
}
const refDx = incDx, refDy = -incDy;
ctx.globalAlpha = 0.35;
this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5);
ctx.globalAlpha = 1;
}
_drawRay(ctx, x1, y1, x2, y2, color, width) {
const drawFn = () => {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
};
if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 });
else drawFn();
}
_drawArrowhead(ctx, x, y, angle, color) {
const aLen = 10;
ctx.fillStyle = color;
ctx.beginPath(); ctx.moveTo(x, y);
ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3));
ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3));
ctx.closePath(); ctx.fill();
}
_drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) {
const arcR = 50, font = '12px Manrope, system-ui, sans-serif';
if (this.angle > 1) {
ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath();
const normAngle = -Math.PI / 2, incAngle = -Math.PI / 2 - theta1Rad;
ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); ctx.stroke();
ctx.font = font; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA = normAngle - theta1Rad / 2;
ctx.fillText('θ₁=' + this.angle.toFixed(1) + '°', hitX + (arcR + 20) * Math.cos(midA), hitY + (arcR + 20) * Math.sin(midA));
}
if (!isTIR && Math.abs(sinTheta2) <= 1) {
const theta2Rad = Math.asin(sinTheta2);
if (theta2Rad > 0.02) {
ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath();
const normDown = Math.PI / 2, refAngle = Math.PI / 2 + theta2Rad;
ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); ctx.stroke();
const angle2Deg = theta2Rad * 180 / Math.PI;
ctx.font = font; ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA2 = normDown + theta2Rad / 2;
ctx.fillText('θ₂=' + angle2Deg.toFixed(1) + '°', hitX + (arcR * 0.8 + 20) * Math.cos(midA2), hitY + (arcR * 0.8 + 20) * Math.sin(midA2));
}
}
}
_drawMediumLabels(ctx, W, H, midY) {
ctx.font = '13px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(155,93,229,0.6)'; ctx.textAlign = 'left';
ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30);
ctx.fillStyle = 'rgba(6,214,224,0.6)';
ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30);
const theta1Rad = this.angle * Math.PI / 180;
const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) {
ctx.font = 'bold 14px Manrope, system-ui, sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60);
}
}
_drawInfoBox(ctx, isTIR, R) {
const boxW = 220, boxH = 72, bx = this.W - boxW - 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10);
const info = this.info();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText('θ₁ = ' + info.angle1 + '° θ₂ = ' + (info.isTIR ? 'ПВО' : info.angle2 + '°'), bx + 10, by + 28);
const rPct = (R * 100).toFixed(1), tPct = ((1 - R) * 100).toFixed(1);
ctx.fillStyle = '#EF476F'; ctx.fillText('R = ' + rPct + '%', bx + 10, by + 46);
ctx.fillStyle = '#06D6E0'; ctx.fillText('T = ' + (isTIR ? '0' : tPct) + '%', bx + 90, by + 46);
if (info.criticalAngle !== null) { ctx.fillStyle = '#FFD166'; ctx.fillText('θc = ' + info.criticalAngle + '°', bx + 160, by + 46); }
}
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height) };
};
const hitTest = (mx, my) => {
const hitX = this.W / 2, hitY = this.H / 2;
if (my >= hitY) return false;
const dist = Math.hypot(mx - hitX, my - hitY);
return dist > 20 && dist < Math.max(this.W, this.H) * 0.6;
};
const angleFromMouse = (mx, my) => {
const hitX = this.W / 2, hitY = this.H / 2;
const dx = mx - hitX, dy = hitY - my;
return Math.max(0, Math.min(89, Math.atan2(Math.abs(dx), dy) * 180 / Math.PI));
};
const onDown = (e) => { const { mx, my } = getPos(e); if (hitTest(mx, my)) this._drag = true; };
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx, my } = getPos(e);
this.angle = angleFromMouse(mx, my);
this.draw(); this._emit();
};
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
/* ─────────────────────────────────────────────────────────────
4. LAB UI INIT — Оптическая скамья
───────────────────────────────────────────────────────────────*/
var lensSim = null;
var mirrorSim = null;
var refrSim = null;
var _obMode = 'lens'; // current active mode within opticsbench
/* Open opticsbench, optionally setting a mode ('lens'|'mirror'|'refraction') */
function _openOpticsBench(mode) {
mode = mode || 'lens';
_obMode = mode;
document.getElementById('sim-topbar-title').textContent = 'Оптическая скамья';
_simShow('sim-opticsbench');
_registerSimState('opticsbench', () => _obGetState(), st => _obApplyState(st));
if (_embedMode) _startStateEmit('opticsbench');
requestAnimationFrame(() => requestAnimationFrame(() => {
obSwitchMode(mode, true);
}));
}
function _obGetState() {
if (_obMode === 'lens') return { mode: 'lens', ...(lensSim ? lensSim.getParams() : {}) };
if (_obMode === 'mirror') return { mode: 'mirror', ...(mirrorSim ? mirrorSim.getParams() : {}) };
if (_obMode === 'refraction') return { mode: 'refraction', ...(refrSim ? refrSim.getParams() : {}) };
return { mode: _obMode };
}
function _obApplyState(st) {
if (!st) return;
const m = st.mode || _obMode;
obSwitchMode(m, true);
const { mode: _m, ...params } = st;
if (m === 'lens' && lensSim) lensSim.setParams(params);
if (m === 'mirror' && mirrorSim) mirrorSim.setParams(params);
if (m === 'refraction' && refrSim) refrSim.setParams(params);
}
/* Switch between modes — mirrors emSwitchMode pattern */
function obSwitchMode(mode, silent) {
if (!silent && window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.3, volume: 0.3 });
_obMode = mode;
/* tab button styling */
['lens', 'mirror', 'refraction'].forEach(m => {
const btn = document.getElementById('ob-tab-' + m);
if (btn) btn.classList.toggle('active', m === mode);
});
/* show/hide per-mode control panels */
['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const activeCtrl = document.getElementById('ob-ctrl-' + mode);
if (activeCtrl) activeCtrl.style.display = '';
/* show/hide stats bar sections */
['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const statsSuffix = mode === 'refraction' ? 'refr' : mode;
const activeStats = document.getElementById('ob-stats-' + statsSuffix);
if (activeStats) activeStats.style.display = 'flex';
/* init engine if not yet done, then (re-)draw */
if (mode === 'lens') {
if (!lensSim) {
const cv = document.getElementById('ob-lens-canvas');
lensSim = new ThinLensSim(cv);
lensSim.onUpdate = _lensUpdateUI;
}
lensSim.fit(); lensSim.draw(); lensSim._emit();
document.getElementById('ob-lens-canvas').style.display = '';
document.getElementById('ob-mirror-canvas').style.display = 'none';
document.getElementById('ob-refr-canvas').style.display = 'none';
} else if (mode === 'mirror') {
if (!mirrorSim) {
const cv = document.getElementById('ob-mirror-canvas');
mirrorSim = new MirrorSim(cv);
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();
document.getElementById('ob-lens-canvas').style.display = 'none';
document.getElementById('ob-mirror-canvas').style.display = '';
document.getElementById('ob-refr-canvas').style.display = 'none';
} else if (mode === 'refraction') {
if (!refrSim) {
const cv = document.getElementById('ob-refr-canvas');
refrSim = new RefractionSim(cv);
refrSim.onUpdate = _refrUpdateUI;
}
refrSim.fit(); refrSim.draw(); refrSim._emit();
document.getElementById('ob-lens-canvas').style.display = 'none';
document.getElementById('ob-mirror-canvas').style.display = 'none';
document.getElementById('ob-refr-canvas').style.display = '';
}
}
/* ── Thin Lens controls ── */
function lensParam(name, val) {
const v = parseFloat(val);
const ids = { f: 'lens-f-val', d: 'lens-d-val', h: 'lens-h-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = v;
if (lensSim) lensSim.setParams({ [name]: v });
}
function lensPreset(f, d, h) {
document.getElementById('sl-lens-f').value = f; document.getElementById('lens-f-val').textContent = f;
document.getElementById('sl-lens-d').value = d; document.getElementById('lens-d-val').textContent = d;
document.getElementById('sl-lens-h').value = h; document.getElementById('lens-h-val').textContent = h;
if (lensSim) lensSim.setParams({ f, d, h });
}
function _lensUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('lensbar-v1', info.f);
v('lensbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
v('lensbar-v3', info.M === Infinity ? '∞' : info.M);
v('lensbar-v4', info.imageType);
}
/* ── Mirror controls ── */
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);
}
/* ── Refraction controls ── */
function refrParam(name, val) {
const v = parseFloat(val);
const ids = { n1: 'refr-n1-val', n2: 'refr-n2-val', angle: 'refr-angle-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = name === 'angle' ? v : v.toFixed(2);
if (refrSim) refrSim.setParams({ [name]: v });
}
function refrPreset(n1, n2, angle) {
document.getElementById('sl-refr-n1').value = n1; document.getElementById('refr-n1-val').textContent = n1.toFixed(2);
document.getElementById('sl-refr-n2').value = n2; document.getElementById('refr-n2-val').textContent = n2.toFixed(2);
document.getElementById('sl-refr-angle').value = angle; document.getElementById('refr-angle-val').textContent = angle;
if (refrSim) refrSim.setParams({ n1, n2, angle });
}
function _refrUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('refrbar-v1', info.angle1 + '°');
v('refrbar-v2', info.isTIR ? 'ПВО' : info.angle2 + '°');
v('refrbar-v3', info.criticalAngle !== null ? info.criticalAngle + '°' : '—');
v('refrbar-v4', info.isTIR ? 'Да' : 'Нет');
}
/* ── dispersion toggle ── */
function refrDispersion(on) {
if (refrSim) refrSim.setParams({ dispersion: on });
}