fd29acbbdd
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>
405 lines
13 KiB
JavaScript
405 lines
13 KiB
JavaScript
'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(); }
|
||
});
|
||
}
|
||
}
|