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>
465 lines
16 KiB
JavaScript
465 lines
16 KiB
JavaScript
'use strict';
|
||
/* ══════════════════════════════════════════════════════════════
|
||
IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses
|
||
n = 1, R = 0.0821 L·atm/mol·K; energies in Joules
|
||
Isothermal PV = const ΔU=0, W=nRT·ln(V2/V1), Q=W
|
||
Isochoric V = const W=0, ΔU=νCvΔT, Q=ΔU
|
||
Isobaric P = const W=PΔV, ΔU=νCvΔT, Q=ΔU+W
|
||
Adiabatic PV^γ = const Q=0, ΔU=-W, W=PΔV/(γ-1)
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
class IsoprocessSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0;
|
||
|
||
/* physics */
|
||
this.n = 1;
|
||
this.R = 0.0821; // L·atm / mol·K
|
||
this.R_J = 8.314; // J / mol·K
|
||
this.gamma = 1.4; // 7/5 diatomic default
|
||
|
||
/* state */
|
||
this.P1 = 3.0; // atm
|
||
this.V1 = 10.0; // L
|
||
this._ratio = 0.5; // 0..1, maps end state position along process
|
||
|
||
/* process */
|
||
this.process = 'isothermal';
|
||
|
||
/* axis range */
|
||
this.Vmin = 1; this.Vmax = 33;
|
||
this.Pmin = 0.2; this.Pmax = 9.5;
|
||
|
||
/* margins */
|
||
this.ML = 52; this.MB = 46; this.MT = 20; this.MR = 18;
|
||
|
||
this._drag = null; // 'state1' | 'state2'
|
||
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;
|
||
}
|
||
|
||
setProcess(p) { this.process = p; this.draw(); this._emit(); }
|
||
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
|
||
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
|
||
setParams({ P1, V1 } = {}) {
|
||
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
|
||
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
|
||
this.draw(); this._emit();
|
||
}
|
||
setRatio(r) { this._ratio = Math.max(0.01, Math.min(0.99, +r)); this.draw(); this._emit(); }
|
||
|
||
/* ── coordinate transforms ─────────────────── */
|
||
|
||
_pw() { return this.W - this.ML - this.MR; }
|
||
_ph() { return this.H - this.MT - this.MB; }
|
||
|
||
_vx(v) { return this.ML + (v - this.Vmin) / (this.Vmax - this.Vmin) * this._pw(); }
|
||
_py(p) { return this.MT + (1 - (p - this.Pmin) / (this.Pmax - this.Pmin)) * this._ph(); }
|
||
_xv(x) { return this.Vmin + (x - this.ML) / this._pw() * (this.Vmax - this.Vmin); }
|
||
_yp(y) { return this.Pmin + (1 - (y - this.MT) / this._ph()) * (this.Pmax - this.Pmin); }
|
||
|
||
/* ── physics ───────────────────────────────── */
|
||
|
||
_T(P, V) { return P * V / (this.n * this.R); }
|
||
|
||
_state2() {
|
||
const { P1, V1, _ratio, gamma } = this;
|
||
/* ratio in [0..1] → multiplier in [0.2..3.5] for V2/V1 or P2/P1 */
|
||
const mult = 0.2 + _ratio * 3.3;
|
||
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
|
||
const clampP = p => Math.max(this.Pmin + 0.05, Math.min(this.Pmax - 0.1, p));
|
||
|
||
switch (this.process) {
|
||
case 'isothermal': {
|
||
const V2 = clampV(V1 * mult);
|
||
return { P2: clampP(P1 * V1 / V2), V2 };
|
||
}
|
||
case 'isochoric': {
|
||
return { P2: clampP(P1 * mult), V2: V1 };
|
||
}
|
||
case 'isobaric': {
|
||
const V2 = clampV(V1 * mult);
|
||
return { P2: P1, V2 };
|
||
}
|
||
case 'adiabatic': {
|
||
const V2 = clampV(V1 * mult);
|
||
return { P2: clampP(P1 * Math.pow(V1 / V2, gamma)), V2 };
|
||
}
|
||
}
|
||
return { P2: P1, V2: V1 };
|
||
}
|
||
|
||
info() {
|
||
const { P1, V1, n, R_J, gamma } = this;
|
||
const T1 = this._T(P1, V1);
|
||
const { P2, V2 } = this._state2();
|
||
const T2 = this._T(P2, V2);
|
||
|
||
/* internal energy: ΔU = νCvΔT, Cv = R/(γ-1) */
|
||
const Cv_J = R_J / (gamma - 1);
|
||
const dU_J = n * Cv_J * (T2 - T1);
|
||
|
||
/* P in Pa = P_atm * 101325, V in m³ = V_L * 0.001 */
|
||
const P1Pa = P1 * 101325, P2Pa = P2 * 101325;
|
||
const V1m3 = V1 * 0.001, V2m3 = V2 * 0.001;
|
||
|
||
let W_J = 0, Q_J = 0;
|
||
switch (this.process) {
|
||
case 'isothermal':
|
||
W_J = n * R_J * T1 * Math.log(V2 / V1);
|
||
Q_J = W_J; break;
|
||
case 'isochoric':
|
||
W_J = 0; Q_J = dU_J; break;
|
||
case 'isobaric':
|
||
W_J = P1Pa * (V2m3 - V1m3);
|
||
Q_J = dU_J + W_J; break;
|
||
case 'adiabatic':
|
||
Q_J = 0;
|
||
W_J = -dU_J; break;
|
||
}
|
||
|
||
const fmt = x => (x >= 0 ? '+' : '') + Math.round(x);
|
||
return {
|
||
P1: P1.toFixed(2), V1: V1.toFixed(1), T1: Math.round(T1),
|
||
P2: P2.toFixed(2), V2: V2.toFixed(1), T2: Math.round(T2),
|
||
W: fmt(W_J), Q: fmt(Q_J), dU: fmt(Math.round(dU_J)),
|
||
W_raw: W_J, Q_raw: Q_J, dU_raw: dU_J,
|
||
process: this.process,
|
||
};
|
||
}
|
||
|
||
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
||
|
||
/* ── draw ──────────────────────────────────── */
|
||
|
||
draw() {
|
||
const { ctx, W, H } = this;
|
||
if (!W || !H) return;
|
||
ctx.fillStyle = '#0D0D1A';
|
||
ctx.fillRect(0, 0, W, H);
|
||
this._drawGrid(ctx);
|
||
this._drawBgCurves(ctx);
|
||
this._drawActiveCurve(ctx);
|
||
this._drawPoints(ctx);
|
||
this._drawInfoBox(ctx);
|
||
}
|
||
|
||
_drawGrid(ctx) {
|
||
const { ML, MT, MR, MB } = this;
|
||
const pw = this._pw(), ph = this._ph();
|
||
|
||
/* plot background */
|
||
ctx.fillStyle = 'rgba(255,255,255,0.018)';
|
||
ctx.fillRect(ML, MT, pw, ph);
|
||
|
||
/* grid */
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.055)';
|
||
ctx.lineWidth = 1; ctx.setLineDash([]);
|
||
for (let v = 5; v <= 30; v += 5) {
|
||
const x = this._vx(v);
|
||
ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
|
||
}
|
||
for (let p = 1; p <= 9; p++) {
|
||
const y = this._py(p);
|
||
ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke();
|
||
}
|
||
|
||
/* axes */
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph);
|
||
ctx.stroke();
|
||
|
||
/* tick labels */
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||
for (let p = 1; p <= 9; p++) {
|
||
const y = this._py(p);
|
||
if (y < MT + 2 || y > MT + ph - 2) continue;
|
||
ctx.fillText(p, ML - 6, y);
|
||
}
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
for (let v = 5; v <= 30; v += 5) {
|
||
const x = this._vx(v);
|
||
ctx.fillText(v, x, MT + ph + 5);
|
||
}
|
||
|
||
/* axis titles */
|
||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||
ctx.font = '12px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('V, л', ML + pw / 2, MT + ph + 32);
|
||
ctx.save();
|
||
ctx.translate(13, MT + ph / 2);
|
||
ctx.rotate(-Math.PI / 2);
|
||
ctx.fillText('P, атм', 0, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
_COLORS = {
|
||
isothermal: '#EF476F',
|
||
isochoric: '#06D6E0',
|
||
isobaric: '#7BF5A4',
|
||
adiabatic: '#FFD166',
|
||
};
|
||
|
||
/* draw one process curve through (P1,V1) */
|
||
_curve(ctx, process, alpha, lw, dashed) {
|
||
const { P1, V1, gamma, Vmin, Vmax, Pmin, Pmax } = this;
|
||
ctx.save();
|
||
ctx.globalAlpha = alpha;
|
||
ctx.strokeStyle = this._COLORS[process];
|
||
ctx.lineWidth = lw;
|
||
ctx.setLineDash(dashed ? [5, 4] : []);
|
||
ctx.beginPath();
|
||
|
||
if (process === 'isochoric') {
|
||
const x = this._vx(V1);
|
||
ctx.moveTo(x, this._py(Pmax));
|
||
ctx.lineTo(x, this._py(Pmin));
|
||
} else {
|
||
let started = false;
|
||
const steps = 300;
|
||
for (let i = 0; i <= steps; i++) {
|
||
const v = Vmin + (Vmax - Vmin) * i / steps;
|
||
let p;
|
||
if (process === 'isothermal') p = P1 * V1 / v;
|
||
else if (process === 'isobaric') p = P1;
|
||
else p = P1 * Math.pow(V1 / v, gamma); // adiabatic
|
||
if (p < Pmin || p > Pmax + 0.1) { started = false; continue; }
|
||
const x = this._vx(v), y = this._py(Math.min(p, Pmax));
|
||
if (!started) { ctx.moveTo(x, y); started = true; }
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawBgCurves(ctx) {
|
||
for (const p of ['isothermal', 'isochoric', 'isobaric', 'adiabatic']) {
|
||
if (p !== this.process) this._curve(ctx, p, 0.14, 1.2, true);
|
||
}
|
||
/* legend dots */
|
||
const names = { isothermal: 'Изотерма', isochoric: 'Изохора', isobaric: 'Изобара', adiabatic: 'Адиабата' };
|
||
ctx.font = '10px Manrope, system-ui, sans-serif';
|
||
let lx = this.ML + this._pw() - 8, ly = this.MT + 8;
|
||
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
|
||
for (const [proc, label] of Object.entries(names)) {
|
||
const col = this._COLORS[proc];
|
||
const isCur = proc === this.process;
|
||
ctx.globalAlpha = isCur ? 0.85 : 0.3;
|
||
ctx.fillStyle = col;
|
||
ctx.beginPath(); ctx.arc(lx + 5, ly + 4, 4, 0, Math.PI * 2); ctx.fill();
|
||
ctx.fillText(label, lx - 3, ly);
|
||
ly += 16;
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
_drawActiveCurve(ctx) {
|
||
/* full curve dimmed */
|
||
this._curve(ctx, this.process, 0.3, 1.5, false);
|
||
|
||
/* highlighted segment state1 → state2 */
|
||
const { P1, V1, gamma } = this;
|
||
const { P2, V2 } = this._state2();
|
||
const color = this._COLORS[this.process];
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2.8;
|
||
ctx.setLineDash([]);
|
||
|
||
const steps = 200;
|
||
const [Vs, Ve] = V2 >= V1 ? [V1, V2] : [V2, V1];
|
||
|
||
if (this.process === 'isochoric') {
|
||
const x = this._vx(V1);
|
||
const y1c = this._py(P1), y2c = this._py(P2);
|
||
ctx.beginPath(); ctx.moveTo(x, y1c); ctx.lineTo(x, y2c); ctx.stroke();
|
||
this._arrowHead(ctx, x, y1c, x, y2c, color);
|
||
} else {
|
||
ctx.beginPath();
|
||
let started = false;
|
||
for (let i = 0; i <= steps; i++) {
|
||
const v = Vs + (Ve - Vs) * i / steps;
|
||
let p;
|
||
if (this.process === 'isothermal') p = P1 * V1 / v;
|
||
else if (this.process === 'isobaric') p = P1;
|
||
else p = P1 * Math.pow(V1 / v, gamma);
|
||
const x = this._vx(v), y = this._py(p);
|
||
if (!started) { ctx.moveTo(x, y); started = true; }
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
|
||
/* arrow at ~80% of segment */
|
||
const vArr = Vs + (Ve - Vs) * 0.8;
|
||
const vArr2 = Vs + (Ve - Vs) * 0.82;
|
||
let p1a, p2a;
|
||
if (this.process === 'isothermal') { p1a = P1*V1/vArr; p2a = P1*V1/vArr2; }
|
||
else if (this.process === 'isobaric') { p1a = P1; p2a = P1; }
|
||
else { p1a = P1*Math.pow(V1/vArr,gamma); p2a = P1*Math.pow(V1/vArr2,gamma); }
|
||
/* ensure arrow points from 1→2 */
|
||
const dir = V2 > V1 ? 1 : -1;
|
||
this._arrowHead(ctx,
|
||
this._vx(vArr + dir*0), this._py(p1a + dir*0),
|
||
this._vx(vArr2 + dir*0), this._py(p2a + dir*0), color);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
_arrowHead(ctx, x1, y1, x2, y2, color) {
|
||
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||
const s = 10;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x2, y2);
|
||
ctx.lineTo(x2 - s * Math.cos(angle - 0.4), y2 - s * Math.sin(angle - 0.4));
|
||
ctx.lineTo(x2 - s * Math.cos(angle + 0.4), y2 - s * Math.sin(angle + 0.4));
|
||
ctx.closePath(); ctx.fill();
|
||
}
|
||
|
||
_drawPoints(ctx) {
|
||
const { P2, V2 } = this._state2();
|
||
const color = this._COLORS[this.process];
|
||
|
||
const dot = (x, y, fill, label, textX, textY) => {
|
||
ctx.fillStyle = fill;
|
||
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
||
ctx.fillText(label, textX, textY);
|
||
};
|
||
|
||
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
|
||
const x2 = this._vx(V2), y2 = this._py(P2);
|
||
dot(x1, y1, '#9B5DE5', '1', x1 - 12, y1 - 4);
|
||
dot(x2, y2, color, '2', x2 + 12, y2 - 4);
|
||
}
|
||
|
||
_drawInfoBox(ctx) {
|
||
const info = this.info();
|
||
const color = this._COLORS[info.process];
|
||
const names = { isothermal:'Изотермический', isochoric:'Изохорный', isobaric:'Изобарный', adiabatic:'Адиабатический' };
|
||
const formulas = { isothermal:'PV = const', isochoric:'V = const', isobaric:'P = const', adiabatic:'PV^γ = const' };
|
||
|
||
const bx = this.ML + 6, by = this.MT + 6;
|
||
const boxW = 205, boxH = 98;
|
||
|
||
ctx.fillStyle = 'rgba(13,13,26,0.9)';
|
||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
|
||
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillText(`${names[info.process]} ${formulas[info.process]}`, bx + 10, by + 8);
|
||
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||
ctx.fillText(`T₁ = ${info.T1} K → T₂ = ${info.T2} K`, bx + 10, by + 28);
|
||
|
||
const wColor = info.W_raw > 0 ? '#7BF5A4' : info.W_raw < 0 ? '#EF476F' : 'rgba(255,255,255,0.4)';
|
||
const qColor = info.Q_raw > 0 ? '#FFD166' : info.Q_raw < 0 ? '#06D6E0' : 'rgba(255,255,255,0.4)';
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('W =', bx + 10, by + 48);
|
||
ctx.fillStyle = wColor; ctx.fillText(`${info.W} Дж`, bx + 38, by + 48);
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('Q =', bx + 10, by + 65);
|
||
ctx.fillStyle = qColor; ctx.fillText(`${info.Q} Дж`, bx + 38, by + 65);
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ΔU =', bx + 10, by + 82);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`${info.dU} Дж`, bx + 40, by + 82);
|
||
}
|
||
|
||
/* ── events ─────────────────────────────────── */
|
||
|
||
_bindEvents() {
|
||
const cv = this.canvas;
|
||
|
||
const pos = e => {
|
||
const r = cv.getBoundingClientRect();
|
||
const t = e.touches ? e.touches[0] : e;
|
||
return {
|
||
px: (t.clientX - r.left) * (this.W / r.width),
|
||
py: (t.clientY - r.top) * (this.H / r.height),
|
||
};
|
||
};
|
||
|
||
const hit = (px, py) => {
|
||
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
|
||
if (Math.hypot(px - x1, py - y1) < 18) return 'state1';
|
||
const { P2, V2 } = this._state2();
|
||
const x2 = this._vx(V2), y2 = this._py(P2);
|
||
if (Math.hypot(px - x2, py - y2) < 18) return 'state2';
|
||
return null;
|
||
};
|
||
|
||
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
|
||
const clampP = p => Math.max(this.Pmin + 0.1, Math.min(this.Pmax - 0.1, p));
|
||
|
||
const onDown = e => { const { px, py } = pos(e); this._drag = hit(px, py); };
|
||
|
||
const onMove = e => {
|
||
if (!this._drag) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
const { px, py } = pos(e);
|
||
const v = this._xv(px), p = this._yp(py);
|
||
|
||
if (this._drag === 'state1') {
|
||
this.V1 = clampV(v); this.P1 = clampP(p);
|
||
} else {
|
||
/* constrain state2 to current process curve */
|
||
switch (this.process) {
|
||
case 'isothermal': case 'isobaric': case 'adiabatic': {
|
||
const V2 = clampV(v);
|
||
this._ratio = Math.max(0.01, Math.min(0.99, (V2 / this.V1 - 0.2) / 3.3));
|
||
break;
|
||
}
|
||
case 'isochoric': {
|
||
const P2 = clampP(p);
|
||
this._ratio = Math.max(0.01, Math.min(0.99, (P2 / this.P1 - 0.2) / 3.3));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
this.draw(); this._emit();
|
||
};
|
||
|
||
const onUp = () => { this._drag = null; };
|
||
|
||
cv.addEventListener('mousedown', onDown);
|
||
window.addEventListener('mousemove', onMove);
|
||
window.addEventListener('mouseup', onUp);
|
||
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);
|
||
cv.addEventListener('mousemove', e => {
|
||
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
|
||
const { px, py } = pos(e);
|
||
cv.style.cursor = hit(px, py) ? 'grab' : 'default';
|
||
});
|
||
}
|
||
}
|