6afe928c0d
ФУНДАМЕНТ (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>
1634 lines
71 KiB
JavaScript
1634 lines
71 KiB
JavaScript
'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 });
|
||
}
|