Files
Learn_System/frontend/js/labs/pendulum.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

405 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ══════════════════════════════════════════════════════════════
PendulumSim — simple pendulum simulation
θ'' = -(g/L)sin(θ) γ·θ'
RK4 integration · energy bar · trail · phase portrait
══════════════════════════════════════════════════════════════ */
class PendulumSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.L = 200; // px length
this.g = 9.81;
this.theta = Math.PI / 4; // angle (rad)
this.omega = 0; // angular velocity
this.damping = 0; // damping coefficient γ
/* animation */
this.playing = false;
this._raf = null;
this._lastTs = null;
this.speed = 1;
/* trail */
this._trail = []; // [{x, y, age}]
this._maxTrail = 200;
/* energy chart (bottom) */
this._eHistory = []; // [{t, ke, pe}]
this._tSim = 0;
this.onUpdate = null;
this._drag = 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 { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping };
}
setParams({ L, g, theta, damping } = {}) {
if (L !== undefined) this.L = +L;
if (g !== undefined) this.g = +g;
if (theta !== undefined) { this.theta = +theta * Math.PI / 180; this.omega = 0; this._clearTrail(); }
if (damping !== undefined) this.damping = +damping;
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this.theta = Math.PI / 4;
this.omega = 0;
this._tSim = 0;
this._clearTrail();
this._eHistory = [];
this.draw();
this._emit();
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const T = 2 * Math.PI * Math.sqrt(this.L / (this.g * 100)); // L in px <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> approx
const KE = 0.5 * this.omega * this.omega * this.L * this.L;
const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta));
const total = KE + PE;
return {
angle: (this.theta * 180 / Math.PI).toFixed(1) + '°',
omega: this.omega.toFixed(3) + ' рад/с',
period: T.toFixed(2) + ' с',
energy: total > 0 ? Math.round(KE / total * 100) + '% KE' : '—',
};
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_clearTrail() { this._trail = []; }
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
this._step(dt);
this._tSim += dt;
// trail
const { bx, by } = this._bobPos();
this._trail.push({ x: bx, y: by });
if (this._trail.length > this._maxTrail) this._trail.shift();
// energy history
const KE = 0.5 * this.omega * this.omega * this.L * this.L;
const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta));
this._eHistory.push({ t: this._tSim, ke: KE, pe: PE });
if (this._eHistory.length > 300) this._eHistory.shift();
this.draw();
this._emit();
this._tick();
});
}
/* RK4 step for θ'' = -(g/L)sinθ - γ·ω */
_step(dt) {
const gL = this.g * 100 / this.L; // scale g for px units
const c = this.damping;
const deriv = (th, om) => ({
dth: om,
dom: -gL * Math.sin(th) - c * om,
});
const k1 = deriv(this.theta, this.omega);
const k2 = deriv(this.theta + k1.dth * dt / 2, this.omega + k1.dom * dt / 2);
const k3 = deriv(this.theta + k2.dth * dt / 2, this.omega + k2.dom * dt / 2);
const k4 = deriv(this.theta + k3.dth * dt, this.omega + k3.dom * dt);
this.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth);
this.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom);
}
_bobPos() {
const cx = this.W / 2;
const cy = Math.min(this.H * 0.18, 80);
return {
px: cx,
py: cy,
bx: cx + this.L * Math.sin(this.theta),
by: cy + this.L * Math.cos(this.theta),
};
}
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const { px, py, bx, by } = this._bobPos();
// trail
this._drawTrail(ctx);
// support
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(W / 2 - 30, py - 4, 60, 4);
// string
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(bx, by); ctx.stroke();
// pivot
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
// bob
const bobR = 18;
ctx.fillStyle = '#9B5DE5';
ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
// glow
const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2);
grad.addColorStop(0, 'rgba(155,93,229,0.25)');
grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill();
// angle arc
if (Math.abs(this.theta) > 0.02) {
ctx.strokeStyle = 'rgba(6,214,224,0.5)';
ctx.lineWidth = 1.5;
const arcR = 40;
const startAngle = Math.PI / 2;
const endAngle = Math.PI / 2 + this.theta;
ctx.beginPath();
ctx.arc(px, py, arcR, Math.min(startAngle, endAngle), Math.max(startAngle, endAngle));
ctx.stroke();
ctx.fillStyle = '#06D6E0';
ctx.font = '12px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labelAngle = startAngle + this.theta / 2;
ctx.fillText(
(this.theta * 180 / Math.PI).toFixed(1) + '°',
px + (arcR + 16) * Math.cos(labelAngle),
py + (arcR + 16) * Math.sin(labelAngle)
);
}
// energy bar
this._drawEnergyBar(ctx, W, H);
// energy chart
this._drawEnergyChart(ctx, W, H);
}
_drawTrail(ctx) {
const n = this._trail.length;
if (n < 2) return;
for (let i = 1; i < n; i++) {
const a = i / n * 0.6;
ctx.strokeStyle = `rgba(155,93,229,${a})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(this._trail[i - 1].x, this._trail[i - 1].y);
ctx.lineTo(this._trail[i].x, this._trail[i].y);
ctx.stroke();
}
}
_drawEnergyBar(ctx, W, H) {
const KE = 0.5 * this.omega * this.omega * this.L * this.L;
const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta));
const total = KE + PE || 1;
const bw = 160, bh = 14;
const x = W - bw - 20, y = 20;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(x - 8, y - 6, bw + 16, bh + 32, 8); ctx.fill();
// KE bar
const kw = (KE / total) * bw;
ctx.fillStyle = '#EF476F';
ctx.beginPath(); ctx.roundRect(x, y, Math.max(2, kw), bh, 4); ctx.fill();
// PE bar
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.roundRect(x + kw, y, Math.max(2, bw - kw), bh, 4); ctx.fill();
ctx.font = '10px Manrope, sans-serif';
ctx.textBaseline = 'top';
ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left';
ctx.fillText('KE ' + Math.round(KE / total * 100) + '%', x, y + bh + 4);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right';
ctx.fillText('PE ' + Math.round(PE / total * 100) + '%', x + bw, y + bh + 4);
}
_drawEnergyChart(ctx, W, H) {
const data = this._eHistory;
if (data.length < 2) return;
const cw = Math.min(300, W * 0.4);
const ch = 80;
const cx = W - cw - 20;
const cy = H - ch - 20;
ctx.fillStyle = 'rgba(22,22,38,0.7)';
ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill();
let maxE = 0;
for (const d of data) maxE = Math.max(maxE, d.ke + d.pe);
if (maxE < 0.01) return;
// PE filled area
ctx.fillStyle = 'rgba(6,214,224,0.2)';
ctx.beginPath();
ctx.moveTo(cx, cy + ch);
for (let i = 0; i < data.length; i++) {
const x = cx + (i / (data.length - 1)) * cw;
const y = cy + ch - (data[i].pe / maxE) * ch;
ctx.lineTo(x, y);
}
ctx.lineTo(cx + cw, cy + ch);
ctx.closePath(); ctx.fill();
// KE line
ctx.strokeStyle = '#EF476F';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = cx + (i / (data.length - 1)) * cw;
const y = cy + ch - (data[i].ke / maxE) * ch;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
// total line
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = cx + (i / (data.length - 1)) * cw;
const y = cy + ch - ((data[i].ke + data[i].pe) / maxE) * ch;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
ctx.setLineDash([]);
// labels
ctx.font = '10px Manrope, sans-serif';
ctx.textBaseline = 'bottom';
ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; ctx.fillText('KE', cx + 2, cy);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.fillText('PE', cx + 30, cy);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.textAlign = 'right'; ctx.fillText('Total', cx + cw, cy);
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
cv.addEventListener('mousedown', e => {
const { bx, by } = this._bobPos();
const r = cv.getBoundingClientRect();
const mx = (e.clientX - r.left) * (this.W / r.width);
const my = (e.clientY - r.top) * (this.H / r.height);
if (Math.hypot(mx - bx, my - by) < 30) {
this._drag = true;
this.pause();
}
});
window.addEventListener('mousemove', e => {
if (!this._drag) return;
const r = cv.getBoundingClientRect();
const mx = (e.clientX - r.left) * (this.W / r.width);
const my = (e.clientY - r.top) * (this.H / r.height);
const { px, py } = this._bobPos();
this.theta = Math.atan2(mx - px, my - py);
this.omega = 0;
this._clearTrail();
this.draw();
this._emit();
});
window.addEventListener('mouseup', () => {
if (this._drag) {
this._drag = false;
this.play();
}
});
// touch
cv.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
const { bx, by } = this._bobPos();
const r = cv.getBoundingClientRect();
const mx = (e.touches[0].clientX - r.left) * (this.W / r.width);
const my = (e.touches[0].clientY - r.top) * (this.H / r.height);
if (Math.hypot(mx - bx, my - by) < 40) {
this._drag = true;
this.pause();
}
}, { passive: true });
cv.addEventListener('touchmove', e => {
if (!this._drag) return;
e.preventDefault();
const r = cv.getBoundingClientRect();
const mx = (e.touches[0].clientX - r.left) * (this.W / r.width);
const my = (e.touches[0].clientY - r.top) * (this.H / r.height);
const { px, py } = this._bobPos();
this.theta = Math.atan2(mx - px, my - py);
this.omega = 0;
this._clearTrail();
this.draw();
this._emit();
}, { passive: false });
cv.addEventListener('touchend', () => {
if (this._drag) { this._drag = false; this.play(); }
});
}
}