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

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

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

490 lines
16 KiB
JavaScript

'use strict';
/* ══════════════════════════════════════════════════════════════
ThinLensSim — thin lens ray tracing simulation
1/f = 1/d + 1/d' M = -d'/d
Three principal rays · draggable object & focal point
Converging (f>0) and diverging (f<0) lenses
══════════════════════════════════════════════════════════════ */
class ThinLensSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics (px units) */
this.f = 100; // focal length
this.d = 200; // object distance (positive, measured from lens)
this.h = 50; // object height
/* drag state */
this._drag = null; // 'object' | 'focus' | null
/* callback */
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
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),
};
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/** Convert simulation coords to canvas coords.
* Origin = lens center; +x right, +y up.
* Canvas: lensX = W/2, axisY = H/2 */
_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 ──────────────────────────────────── */
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;
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* optical axis */
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([]);
/* lens */
this._drawLens(ctx, lensX, axisY, f);
/* focal & 2F points */
this._drawFocalPoints(ctx, lensX, axisY, f);
/* object arrow */
const objX = lensX - d;
this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false);
/* compute image */
const denom = d - f;
let dPrime, hPrime;
if (Math.abs(denom) < 0.5) {
/* object at focal point — rays parallel, no image */
dPrime = null;
hPrime = null;
} else {
dPrime = (f * d) / denom;
const M = -dPrime / d;
hPrime = M * h;
}
/* principal rays */
this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime);
/* image arrow */
if (dPrime !== null && isFinite(dPrime)) {
const isVirtual = dPrime < 0;
const imgX = lensX + dPrime;
const imgTop = axisY - hPrime;
this._drawArrow(ctx, imgX, axisY, imgX, imgTop,
isVirtual ? '#FFD166' : '#EF476F', isVirtual);
}
/* labels */
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
}
_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) {
/* biconvex shape */
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();
/* arrowheads (converging) */
this._lensArrow(ctx, lx, ay - lensH, -1);
this._lensArrow(ctx, lx, ay + lensH, 1);
} else {
/* biconcave shape */
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();
/* arrowheads (diverging) */
this._lensArrowDiv(ctx, lx, ay - lensH, -1);
this._lensArrowDiv(ctx, lx, ay + lensH, 1);
}
/* center line */
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([]);
/* arrowhead */
const angle = Math.atan2(y2 - y1, x2 - x1);
const 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;
const objY = ay - h;
const colors = ['#06D6E0', '#7BF5A4', '#FFD166'];
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5;
/* Ray 1: parallel to axis <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> through F' (converging) or from F' (diverging) */
{
ctx.strokeStyle = colors[0];
ctx.setLineDash([]);
/* incoming: object tip <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> lens, parallel */
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
/* outgoing */
if (hasImage) {
const imgX = lx + dPrime;
const imgY = ay - hPrime;
if (!isVirtual) {
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
/* extend past image */
this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]);
} else {
/* diverging outgoing ray + dashed virtual extension */
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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> straight */
{
ctx.strokeStyle = colors[1];
ctx.setLineDash([]);
const slope = (objY - ay) / (objX - lx);
const farX = lx + 350;
const farY = ay + slope * 350;
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke();
if (isVirtual) {
/* extend behind lens too */
const backX = lx - 350;
const 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> parallel after lens */
{
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
const fx = lx - f;
const 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;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const ex = x2 + (dx / len) * 80;
const ey = y2 + (dy / len) * 80;
ctx.globalAlpha = 0.3;
ctx.strokeStyle = color;
ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(ex, ey); ctx.stroke();
ctx.globalAlpha = 1;
}
_drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) {
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textBaseline = 'top';
/* d label */
const objX = lx - d;
ctx.fillStyle = '#9B5DE5';
ctx.textAlign = 'center';
ctx.fillText(`d = ${d.toFixed(0)}`, (objX + lx) / 2, ay + 26);
/* f label */
ctx.fillStyle = '#06D6E0';
ctx.fillText(`f = ${f.toFixed(0)}`, lx, ay + 42);
/* d' label */
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);
}
/* formula box */
const info = this.info();
const boxW = 200, boxH = 52;
const 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';
const mStr = info.M === Infinity ? '---' : info.M.toFixed(2);
const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1);
ctx.fillText(`1/f = 1/d + 1/d'`, bx + 10, by + 10);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText(`M = ${mStr} d' = ${dpStr} ${info.imageType}`, bx + 10, by + 30);
}
/* ── events ─────────────────────────────────── */
_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;
/* object tip */
const objX = lx - this.d;
const objY = ay - this.h;
if (Math.hypot(mx - objX, my - objY) < 20) return 'object';
/* focal point F (front) */
const fx = lx - this.f;
if (Math.hypot(mx - fx, 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);
const lx = this.W / 2;
if (this._drag === 'object') {
this.d = Math.max(30, Math.min(400, lx - mx));
} else if (this._drag === 'focus') {
const newF = lx - mx;
this.f = Math.max(-200, Math.min(200, newF));
}
this.draw();
this._emit();
};
const onUp = () => { this._drag = null; };
/* mouse */
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
/* touch */
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);
/* cursor style */
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';
});
}
}
/* ─── lab UI init ─────────────────────────────────── */
function _openThinLens() {
document.getElementById('sim-topbar-title').textContent = 'Тонкая линза';
_simShow('sim-thinlens');
_registerSimState('thinlens', () => lensSim?.getParams(), st => lensSim?.setParams(st));
if (_embedMode) _startStateEmit('thinlens');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!lensSim) {
lensSim = new ThinLensSim(document.getElementById('thinlens-canvas'));
lensSim.onUpdate = _lensUpdateUI;
}
lensSim.fit();
lensSim.draw();
lensSim._emit();
}));
}
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);
}
/* ── mirrors ── */