be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
446 lines
15 KiB
JavaScript
446 lines
15 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;
|
|
}
|
|
|
|
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';
|
|
});
|
|
}
|
|
}
|