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>
499 lines
17 KiB
JavaScript
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';
|
|
});
|
|
}
|
|
}
|