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>
457 lines
17 KiB
JavaScript
457 lines
17 KiB
JavaScript
'use strict';
|
|
/* ═══════════════════════════════════════════
|
|
WavesSim v2 — Волны и звук
|
|
Modes: transverse | longitudinal | superposition | standing
|
|
─────────────────────────────────────────── */
|
|
class WavesSim {
|
|
static BG = '#0D0D1A';
|
|
static FONT = "700 12px 'Manrope',sans-serif";
|
|
static V = '#9B5DE5'; /* violet */
|
|
static C = '#06D6E0'; /* cyan */
|
|
static P = '#F15BB5'; /* pink */
|
|
static G = '#FFD166'; /* gold */
|
|
|
|
constructor(canvas) {
|
|
this._c = canvas;
|
|
this._ctx = canvas.getContext('2d');
|
|
this._dpr = 1;
|
|
this._W = 0;
|
|
this._H = 0;
|
|
|
|
this._mode = 'transverse';
|
|
this._t = 0;
|
|
this._last = null;
|
|
this._raf = null;
|
|
this._paused = true;
|
|
|
|
this._A1 = 50; this._f1 = 1.0; this._phi1 = 0;
|
|
this._A2 = 40; this._f2 = 1.5; this._phi2 = 0;
|
|
this._n = 1;
|
|
this._speed = 2.0;
|
|
|
|
this._resizeObs = null;
|
|
this.onUpdate = null;
|
|
}
|
|
|
|
/* ── публичное API ── */
|
|
|
|
fit() {
|
|
const par = this._c.parentElement;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const w = par.clientWidth || 600;
|
|
const h = par.clientHeight || 400;
|
|
this._c.width = Math.round(w * dpr);
|
|
this._c.height = Math.round(h * dpr);
|
|
this._dpr = dpr;
|
|
this._W = w;
|
|
this._H = h;
|
|
if (this._resizeObs) this._resizeObs.disconnect();
|
|
this._resizeObs = new ResizeObserver(() => this.fit());
|
|
this._resizeObs.observe(par);
|
|
this.draw();
|
|
}
|
|
|
|
setMode(mode) {
|
|
this._mode = mode;
|
|
this._t = 0;
|
|
this._last = null;
|
|
this.draw();
|
|
this._emit();
|
|
}
|
|
|
|
getParams() {
|
|
return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
|
|
n: this._n, speed: this._speed, mode: this._mode };
|
|
}
|
|
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
|
|
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
|
|
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
|
|
if (phi1 !== undefined) this._phi1 = +phi1;
|
|
if (A2 !== undefined) this._A2 = Math.max(5, Math.min(90, +A2));
|
|
if (f2 !== undefined) this._f2 = Math.max(0.3, Math.min(4, +f2));
|
|
if (phi2 !== undefined) this._phi2 = +phi2;
|
|
if (n !== undefined) this._n = Math.max(1, Math.min(5, Math.round(+n)));
|
|
if (speed !== undefined) this._speed = Math.max(0.3, Math.min(5, +speed));
|
|
this.draw();
|
|
this._emit();
|
|
}
|
|
|
|
play() {
|
|
this._paused = false;
|
|
this._last = null;
|
|
if (!this._raf) this._raf = requestAnimationFrame(t => this._tick(t));
|
|
}
|
|
pause() {
|
|
this._paused = true;
|
|
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
|
}
|
|
start() { this.play(); }
|
|
stop() { this.pause(); }
|
|
reset() { this._t = 0; this._last = null; this.draw(); this._emit(); }
|
|
|
|
info() {
|
|
const v = (this._W || 600) / 3;
|
|
return {
|
|
T: (1 / this._f1).toFixed(2),
|
|
lambda: (v / this._f1).toFixed(0),
|
|
v: v.toFixed(0),
|
|
f1: this._f1
|
|
};
|
|
}
|
|
|
|
/* ── анимационный цикл ── */
|
|
|
|
_tick(ts) {
|
|
if (!this._paused) {
|
|
if (this._last !== null)
|
|
this._t += Math.min((ts - this._last) / 1000, 0.05) * this._speed;
|
|
this._last = ts;
|
|
this._raf = requestAnimationFrame(t => this._tick(t));
|
|
} else {
|
|
this._raf = null;
|
|
}
|
|
this.draw();
|
|
this._emit();
|
|
}
|
|
|
|
/* ── главный draw ── */
|
|
|
|
draw() {
|
|
const { _ctx: ctx, _W: W, _H: H, _dpr: dpr } = this;
|
|
if (!W || !H) return;
|
|
/* сбрасываем трансформ + заливаем фон */
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.fillStyle = WavesSim.BG;
|
|
ctx.fillRect(0, 0, W, H);
|
|
if (this._mode === 'transverse') this._transvDraw(ctx, W, H);
|
|
else if (this._mode === 'longitudinal') this._longDraw(ctx, W, H);
|
|
else if (this._mode === 'superposition') this._superDraw(ctx, W, H);
|
|
else this._standDraw(ctx, W, H);
|
|
}
|
|
|
|
/* ══════════════════════════════════════
|
|
ПОПЕРЕЧНАЯ ВОЛНА
|
|
══════════════════════════════════════ */
|
|
_transvDraw(ctx, W, H) {
|
|
const PL = 48, PR = 20, PT = 50, PB = 48;
|
|
const cw = W - PL - PR;
|
|
const ch = H - PT - PB;
|
|
const cy = PT + ch / 2;
|
|
|
|
this._grid(ctx, PL, PR, PT, PB, W, H);
|
|
this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);
|
|
|
|
const A = Math.max(4, Math.min(this._A1, ch / 2 - 8));
|
|
const v = cw / 3;
|
|
const lam = v / this._f1;
|
|
const k = (2 * Math.PI) / lam;
|
|
const om = 2 * Math.PI * this._f1;
|
|
const t = this._t;
|
|
const phi = this._phi1;
|
|
const y = x => A * Math.sin(om * t - k * (x - PL) + phi);
|
|
|
|
/* волновая кривая */
|
|
ctx.save();
|
|
ctx.shadowColor = WavesSim.V;
|
|
ctx.shadowBlur = 16;
|
|
ctx.strokeStyle = WavesSim.V;
|
|
ctx.lineWidth = 2.5;
|
|
ctx.beginPath();
|
|
for (let x = PL; x <= PL + cw; x += 1) {
|
|
const py = cy + y(x);
|
|
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
/* частицы */
|
|
const step = Math.max(12, Math.floor(lam / 10));
|
|
for (let x = PL + step * 0.5; x < PL + cw; x += step) {
|
|
const py = cy + y(x);
|
|
const norm = Math.abs(y(x)) / (A || 1);
|
|
ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x, py);
|
|
ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1; ctx.stroke();
|
|
ctx.save();
|
|
ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 8;
|
|
ctx.beginPath(); ctx.arc(x, py, 4, 0, 6.28);
|
|
ctx.fillStyle = `rgba(155,93,229,${(0.4 + 0.6 * norm).toFixed(2)})`; ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
/* выделенная частица */
|
|
const hx = PL + Math.min(lam * 0.5, cw * 0.22);
|
|
const hy = cy + y(hx);
|
|
ctx.save();
|
|
ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18;
|
|
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.28);
|
|
ctx.fillStyle = WavesSim.G; ctx.fill();
|
|
ctx.restore();
|
|
ctx.save();
|
|
ctx.setLineDash([3, 4]);
|
|
ctx.strokeStyle = 'rgba(255,209,102,0.22)'; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(hx, cy - A); ctx.lineTo(hx, cy + A); ctx.stroke();
|
|
ctx.restore();
|
|
|
|
/* аннотация длины волны */
|
|
const ld = Math.min(lam, cw - 16);
|
|
if (ld > 36) {
|
|
const ay = cy + A + 26;
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(6,214,224,0.7)'; ctx.lineWidth = 1.4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(PL + 8, ay); ctx.lineTo(PL + 8 + ld, ay);
|
|
ctx.moveTo(PL + 13, ay - 4); ctx.lineTo(PL + 8, ay); ctx.lineTo(PL + 13, ay + 4);
|
|
ctx.moveTo(PL + 8 + ld - 5, ay - 4); ctx.lineTo(PL + 8 + ld, ay); ctx.lineTo(PL + 8 + ld - 5, ay + 4);
|
|
ctx.stroke();
|
|
ctx.fillStyle = WavesSim.C; ctx.textAlign = 'center';
|
|
ctx.font = "700 10px 'Manrope',sans-serif";
|
|
ctx.fillText('\u03bb = ' + ld.toFixed(0), PL + 8 + ld / 2, ay - 5);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* аннотация амплитуды */
|
|
if (A > 16) {
|
|
const ax = PL - 20;
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(241,91,181,0.7)'; ctx.lineWidth = 1.4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(ax, cy); ctx.lineTo(ax, cy - A);
|
|
ctx.moveTo(ax - 4, cy - A + 5); ctx.lineTo(ax, cy - A); ctx.lineTo(ax + 4, cy - A + 5);
|
|
ctx.moveTo(ax - 3, cy - 3); ctx.lineTo(ax, cy); ctx.lineTo(ax + 3, cy - 3);
|
|
ctx.stroke();
|
|
ctx.fillStyle = WavesSim.P; ctx.textAlign = 'center';
|
|
ctx.font = "700 10px 'Manrope',sans-serif";
|
|
ctx.save(); ctx.translate(ax - 12, cy - A / 2); ctx.rotate(-Math.PI / 2);
|
|
ctx.fillText('A', 0, 0); ctx.restore();
|
|
ctx.restore();
|
|
}
|
|
|
|
/* подпись */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.28)';
|
|
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
|
|
ctx.fillText('y = A sin(\u03c9t \u2212 kx + \u03c6)', PL, PT - 14);
|
|
}
|
|
|
|
/* ══════════════════════════════════════
|
|
ПРОДОЛЬНАЯ ВОЛНА
|
|
══════════════════════════════════════ */
|
|
_longDraw(ctx, W, H) {
|
|
const PL = 24, PR = 24, PT = 50, PB = 60;
|
|
const cw = W - PL - PR;
|
|
const ch = H - PT - PB;
|
|
|
|
const nRows = 5;
|
|
const rowH = ch / nRows;
|
|
const nPart = Math.max(20, Math.floor(cw / 10));
|
|
const dx0 = cw / nPart;
|
|
|
|
const v = cw / 3;
|
|
const lam = v / this._f1;
|
|
const k = (2 * Math.PI) / lam;
|
|
const om = 2 * Math.PI * this._f1;
|
|
const A = Math.min(this._A1 * 0.5, lam / 4, rowH * 0.36);
|
|
const t = this._t;
|
|
const phi = this._phi1;
|
|
|
|
/* ряды частиц */
|
|
for (let row = 0; row < nRows; row++) {
|
|
const cy = PT + rowH * (row + 0.5);
|
|
for (let i = 0; i < nPart; i++) {
|
|
const x0 = PL + (i + 0.5) * dx0;
|
|
const phase = om * t - k * (x0 - PL) + phi;
|
|
const disp = A * Math.sin(phase);
|
|
const xd = Math.max(PL + 1, Math.min(PL + cw - 1, x0 + disp));
|
|
const dens = 1 / Math.max(0.15, 1 + (-A * k * Math.cos(phase)));
|
|
const alpha = Math.max(0.1, Math.min(0.95, dens * 0.55));
|
|
ctx.beginPath(); ctx.arc(xd, cy, 3, 0, 6.28);
|
|
ctx.fillStyle = `rgba(155,93,229,${alpha.toFixed(2)})`; ctx.fill();
|
|
}
|
|
}
|
|
|
|
/* график давления */
|
|
const pTop = PT + ch + 10;
|
|
const pH = H - pTop - 8;
|
|
if (pH > 20) {
|
|
const axY = pTop + pH / 2;
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.beginPath(); ctx.moveTo(PL, axY); ctx.lineTo(PL + cw, axY); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.save();
|
|
ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 8;
|
|
ctx.strokeStyle = WavesSim.C; ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
for (let x = PL; x <= PL + cw; x += 1) {
|
|
const py = axY - Math.cos(om * t - k * (x - PL) + phi) * pH * 0.4;
|
|
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
|
|
}
|
|
ctx.stroke(); ctx.restore();
|
|
ctx.fillStyle = WavesSim.C; ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left';
|
|
ctx.fillText('P(x,t)', PL + 2, pTop + 11);
|
|
}
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.28)';
|
|
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
|
|
ctx.fillText('Продольная волна', PL, PT - 16);
|
|
}
|
|
|
|
/* ══════════════════════════════════════
|
|
СУПЕРПОЗИЦИЯ
|
|
══════════════════════════════════════ */
|
|
_superDraw(ctx, W, H) {
|
|
const PL = 48, PR = 20, PT = 70, PB = 48;
|
|
const cw = W - PL - PR;
|
|
const ch = H - PT - PB;
|
|
const cy = PT + ch / 2;
|
|
|
|
this._grid(ctx, PL, PR, PT, PB, W, H);
|
|
this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);
|
|
|
|
const v = cw / 3;
|
|
const t = this._t;
|
|
|
|
const mk = (f, A) => {
|
|
const lam = v / f, k = (2 * Math.PI) / lam, om = 2 * Math.PI * f;
|
|
const amp = Math.max(4, Math.min(A, ch / 2 - 8));
|
|
return { k, om, amp };
|
|
};
|
|
|
|
const w1 = mk(this._f1, this._A1);
|
|
const w2 = mk(this._f2, this._A2);
|
|
const y1 = x => w1.amp * Math.sin(w1.om * t - w1.k * (x - PL) + this._phi1);
|
|
const y2 = x => w2.amp * Math.sin(w2.om * t - w2.k * (x - PL) + this._phi2);
|
|
const yR = x => y1(x) + y2(x);
|
|
|
|
this._waveLine(ctx, PL, cw, cy, y1, WavesSim.V, 1.5, 0.45, false);
|
|
this._waveLine(ctx, PL, cw, cy, y2, WavesSim.C, 1.5, 0.45, false);
|
|
this._waveLine(ctx, PL, cw, cy, yR, WavesSim.P, 2.8, 1.0, true);
|
|
|
|
/* легенда */
|
|
const items = [
|
|
{ c: WavesSim.V, txt: 'y\u2081 = A\u2081 sin(\u03c9\u2081t \u2212 k\u2081x + \u03c6\u2081)' },
|
|
{ c: WavesSim.C, txt: 'y\u2082 = A\u2082 sin(\u03c9\u2082t \u2212 k\u2082x + \u03c6\u2082)' },
|
|
{ c: WavesSim.P, txt: 'y = y\u2081 + y\u2082' },
|
|
];
|
|
ctx.font = "600 9px 'Manrope',sans-serif";
|
|
items.forEach((it, i) => {
|
|
const lx = PL + 6, ly = PT - 56 + i * 18;
|
|
ctx.save();
|
|
ctx.shadowColor = it.c; ctx.shadowBlur = 8;
|
|
ctx.fillStyle = it.c;
|
|
ctx.beginPath(); ctx.arc(lx + 4, ly + 4, 3.5, 0, 6.28); ctx.fill();
|
|
ctx.restore();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.textAlign = 'left';
|
|
ctx.fillText(it.txt, lx + 13, ly + 8);
|
|
});
|
|
}
|
|
|
|
/* ══════════════════════════════════════
|
|
СТОЯЧАЯ ВОЛНА
|
|
══════════════════════════════════════ */
|
|
_standDraw(ctx, W, H) {
|
|
const PL = 48, PR = 20, PT = 50, PB = 48;
|
|
const cw = W - PL - PR;
|
|
const ch = H - PT - PB;
|
|
const cy = PT + ch / 2;
|
|
|
|
this._grid(ctx, PL, PR, PT, PB, W, H);
|
|
this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);
|
|
|
|
const n = this._n;
|
|
const k = (n * Math.PI) / cw;
|
|
const om = 2 * Math.PI * this._f1;
|
|
const A = Math.max(4, Math.min(this._A1, ch / 2 - 10));
|
|
const t = this._t;
|
|
|
|
/* прямая и обратная (тусклые) */
|
|
this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t - k * (x - PL)), WavesSim.V, 1.0, 0.25, false);
|
|
this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t + k * (x - PL) + Math.PI), WavesSim.C, 1.0, 0.25, false);
|
|
|
|
/* огибающая */
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.12;
|
|
ctx.fillStyle = WavesSim.V;
|
|
ctx.beginPath(); ctx.moveTo(PL, cy);
|
|
for (let x = PL; x <= PL + cw; x++) ctx.lineTo(x, cy - 2 * A * Math.abs(Math.sin(k * (x - PL))));
|
|
for (let x = PL + cw; x >= PL; x--) ctx.lineTo(x, cy + 2 * A * Math.abs(Math.sin(k * (x - PL))));
|
|
ctx.closePath(); ctx.fill(); ctx.restore();
|
|
|
|
/* стоячая волна */
|
|
const cosT = Math.cos(om * t + this._phi1);
|
|
this._waveLine(ctx, PL, cw, cy, x => 2 * A * Math.sin(k * (x - PL)) * cosT, WavesSim.G, 2.8, 1.0, true);
|
|
|
|
/* узлы (cyan) */
|
|
ctx.save(); ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 10; ctx.fillStyle = WavesSim.C;
|
|
for (let m = 0; m <= n; m++) {
|
|
ctx.beginPath(); ctx.arc(PL + m * cw / n, cy, 5, 0, 6.28); ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
/* пучности (pink) */
|
|
ctx.save(); ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12; ctx.fillStyle = WavesSim.P;
|
|
for (let m = 0; m < n; m++) {
|
|
const ax = PL + (m + 0.5) * cw / n;
|
|
const ay = cy + 2 * A * Math.sin(k * (ax - PL)) * cosT;
|
|
ctx.beginPath(); ctx.arc(ax, ay, 5, 0, 6.28); ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
/* легенда */
|
|
const lx = W - PR - 128, ly = PT - 20;
|
|
ctx.font = "600 9px 'Manrope',sans-serif";
|
|
[{ c: WavesSim.C, t: 'Узел (y\u22610)', dy: 0 }, { c: WavesSim.P, t: 'Пучность', dy: 16 }].forEach(r => {
|
|
ctx.save(); ctx.shadowColor = r.c; ctx.shadowBlur = 8; ctx.fillStyle = r.c;
|
|
ctx.beginPath(); ctx.arc(lx + 5, ly + r.dy + 5, 4, 0, 6.28); ctx.fill(); ctx.restore();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'left';
|
|
ctx.fillText(r.t, lx + 14, ly + r.dy + 9);
|
|
});
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
|
|
ctx.fillText('n = ' + n + ' \u03bb = 2L/' + n, PL, PT - 14);
|
|
}
|
|
|
|
/* ══════════════════════════════════════
|
|
ВСПОМОГАТЕЛЬНЫЕ
|
|
══════════════════════════════════════ */
|
|
|
|
_waveLine(ctx, PL, cw, cy, fn, color, lw, alpha, glow) {
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
|
|
ctx.strokeStyle = color; ctx.lineWidth = lw;
|
|
ctx.beginPath();
|
|
for (let x = PL; x <= PL + cw; x += 1) {
|
|
const py = cy + fn(x);
|
|
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
|
|
}
|
|
ctx.stroke(); ctx.restore();
|
|
}
|
|
|
|
_grid(ctx, PL, PR, PT, PB, W, H) {
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
for (let y = PT; y <= H - PB; y += 28) { ctx.moveTo(PL, y); ctx.lineTo(W - PR, y); }
|
|
for (let x = PL; x <= W - PR; x += 40) { ctx.moveTo(x, PT); ctx.lineTo(x, H - PB); }
|
|
ctx.stroke();
|
|
}
|
|
|
|
_axisLine(ctx, PL, PR, PT, PB, W, H, cy) {
|
|
ctx.save();
|
|
ctx.setLineDash([6, 4]);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(PL, cy); ctx.lineTo(W - PR, cy); ctx.stroke();
|
|
ctx.restore();
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(PL, PT - 6); ctx.lineTo(PL, H - PB);
|
|
ctx.moveTo(PL, H - PB); ctx.lineTo(W - PR + 6, H - PB);
|
|
ctx.stroke();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.22)';
|
|
ctx.font = "600 9px 'Manrope',sans-serif";
|
|
ctx.textAlign = 'right'; ctx.fillText('y', PL - 4, PT);
|
|
ctx.textAlign = 'left'; ctx.fillText('x', W - PR + 8, H - PB + 4);
|
|
}
|
|
|
|
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
|
}
|