Files
Learn_System/frontend/js/labs/refraction.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

499 lines
17 KiB
JavaScript

'use strict';
/* ══════════════════════════════════════════════════════════════
RefractionSim — light refraction simulation (Snell's law)
n₁·sin(θ₁) = n₂·sin(θ₂)
Total internal reflection · Fresnel coefficients · Dispersion
Interactive incident ray drag · Presets
══════════════════════════════════════════════════════════════ */
class RefractionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.n1 = 1.0; // refractive index of top medium
this.n2 = 1.5; // refractive index of bottom medium
this.angle = 30; // incidence angle in degrees
/* dispersion mode */
this.dispersion = false;
/* drag state */
this._drag = false;
/* 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 { 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,
};
}
/* ── presets ────────────────────────────────── */
static PRESETS = {
air_glass: { n1: 1.0, n2: 1.5, angle: 30 },
glass_air: { n1: 1.5, n2: 1.0, angle: 30 },
water_glass: { n1: 1.33, n2: 1.5, angle: 30 },
diamond: { n1: 1.0, n2: 2.42, angle: 45 },
};
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const midY = H / 2;
const hitX = W / 2;
const hitY = midY;
/* --- background: two media --- */
// top medium (lighter)
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);
// bottom medium (darker, denser feel)
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);
/* --- interface line with glow --- */
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();
/* --- normal line (dashed vertical) --- */
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([]);
/* --- physics --- */
const theta1Rad = this.angle * Math.PI / 180;
const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
/* Fresnel reflectance (simplified) */
let R = 1;
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const cosT1 = Math.cos(theta1Rad);
const cosT2 = Math.cos(theta2Rad);
const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2);
R = rs * rs;
}
/* ray length (from edge to hit point) */
const rayLen = Math.max(W, H) * 0.6;
/* --- critical angle indicator --- */
if (this.n1 > this.n2) {
const critRad = Math.asin(this.n2 / this.n1);
const critDx = Math.sin(critRad);
const critDy = Math.cos(critRad);
ctx.strokeStyle = 'rgba(255,209,102,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
// critical angle ray in top medium
ctx.beginPath();
ctx.moveTo(hitX, hitY);
ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5);
ctx.stroke();
ctx.setLineDash([]);
// label
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,209,102,0.5)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const lblX = hitX - critDx * rayLen * 0.35 + 6;
const lblY = hitY - critDy * rayLen * 0.35;
ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', lblX, lblY);
}
if (this.dispersion && !isTIR) {
this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen);
} else {
this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen);
}
/* --- angle arcs --- */
this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR);
/* --- medium labels --- */
this._drawMediumLabels(ctx, W, H, midY);
/* --- info box --- */
this._drawInfoBox(ctx, isTIR, R);
/* --- drag handle indicator (incident ray endpoint) --- */
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
const handleX = hitX - incDx * rayLen * 0.55;
const 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();
}
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
/* incident ray */
const incStartX = hitX - incDx * rayLen;
const 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');
/* reflected ray */
const refDx = incDx; // same x component
const refDy = -incDy; // flipped y
const refEndX = hitX + refDx * rayLen;
const refEndY = hitY + refDy * rayLen; // goes up (refDy is negative of incDy)
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;
/* refracted ray */
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const refracDx = Math.sin(theta2Rad);
const refracDy = Math.cos(theta2Rad);
const refracEndX = hitX + refracDx * rayLen;
const 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) {
/* Cauchy dispersion: n(λ) = A + B/λ² */
const spectral = [
{ name: 'red', color: '#FF0000', wave: 656 },
{ name: 'orange', color: '#FF7F00', wave: 589 },
{ name: 'yellow', color: '#FFFF00', wave: 550 },
{ name: 'green', color: '#00FF00', wave: 510 },
{ name: 'cyan', color: '#00FFFF', wave: 475 },
{ name: 'blue', color: '#0000FF', wave: 450 },
{ name: 'violet', color: '#8B00FF', wave: 400 },
];
/* incident white ray */
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
const incStartX = hitX - incDx * rayLen;
const 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');
/* Cauchy coefficients derived from base n2 */
const A = this.n2 - 4500 / (550 * 550);
const 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);
const dx = Math.sin(t2);
const 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;
}
/* reflected (white, partial) */
const refDx = incDx;
const 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) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
/* subtle glow */
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();
}
_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;
const font = '12px Manrope, system-ui, sans-serif';
/* θ₁ arc (incidence angle, measured from normal = vertical up) */
if (this.angle > 1) {
ctx.strokeStyle = 'rgba(155,93,229,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
// normal points up from hit: angle = -π/2 in canvas coords
// incident ray comes from upper-left
// Arc from normal (straight up = -π/2) to incident ray direction
const normAngle = -Math.PI / 2;
const incAngle = -Math.PI / 2 - theta1Rad;
ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle));
ctx.stroke();
// label
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)
);
}
/* θ₂ arc (refraction angle, measured from normal = vertical down) */
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;
const refAngle = Math.PI / 2 + theta2Rad;
ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle));
ctx.stroke();
// label
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';
/* top medium */
ctx.fillStyle = 'rgba(155,93,229,0.6)';
ctx.textAlign = 'left';
ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30);
/* bottom medium */
ctx.fillStyle = 'rgba(6,214,224,0.6)';
ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30);
/* TIR badge */
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;
const 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)';
const a2str = info.isTIR ? 'ПВО' : info.angle2 + '°';
ctx.fillText(`θ₁ = ${info.angle1}° θ₂ = ${a2str}`, bx + 10, by + 28);
const rPct = (R * 100).toFixed(1);
const 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);
}
}
/* ── 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) => {
/* Check if near the incident ray line (top half only) */
const hitX = this.W / 2;
const hitY = this.H / 2;
if (my >= hitY) return false;
/* distance from mouse to the hit point — if within top half, allow drag */
const dx = mx - hitX;
const dy = my - hitY;
const dist = Math.hypot(dx, dy);
return dist > 20 && dist < Math.max(this.W, this.H) * 0.6;
};
const angleFromMouse = (mx, my) => {
const hitX = this.W / 2;
const hitY = this.H / 2;
const dx = mx - hitX;
const dy = hitY - my; // flip: canvas y goes down, angle measured from vertical up
// angle from vertical = atan2(|dx|, dy)
const a = Math.atan2(Math.abs(dx), dy) * 180 / Math.PI;
return Math.max(0, Math.min(89, a));
};
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 = () => { this._drag = false; };
/* 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';
});
}
}