be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
970 lines
38 KiB
JavaScript
970 lines
38 KiB
JavaScript
'use strict';
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
TrigCircleSim — premium interactive unit-circle + graph visualisation
|
||
v3 — maximum polish
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
|
||
const _TC_NOTABLE = [
|
||
{ a: 0, l: '0', d: '0°' },
|
||
{ a: Math.PI / 6, l: 'π/6', d: '30°' },
|
||
{ a: Math.PI / 4, l: 'π/4', d: '45°' },
|
||
{ a: Math.PI / 3, l: 'π/3', d: '60°' },
|
||
{ a: Math.PI / 2, l: 'π/2', d: '90°' },
|
||
{ a: 2*Math.PI / 3, l: '2π/3', d: '120°' },
|
||
{ a: 3*Math.PI / 4, l: '3π/4', d: '135°' },
|
||
{ a: 5*Math.PI / 6, l: '5π/6', d: '150°' },
|
||
{ a: Math.PI, l: 'π', d: '180°' },
|
||
{ a: 7*Math.PI / 6, l: '7π/6', d: '210°' },
|
||
{ a: 5*Math.PI / 4, l: '5π/4', d: '225°' },
|
||
{ a: 4*Math.PI / 3, l: '4π/3', d: '240°' },
|
||
{ a: 3*Math.PI / 2, l: '3π/2', d: '270°' },
|
||
{ a: 5*Math.PI / 3, l: '5π/3', d: '300°' },
|
||
{ a: 7*Math.PI / 4, l: '7π/4', d: '315°' },
|
||
{ a: 11*Math.PI / 6, l: '11π/6', d: '330°' },
|
||
];
|
||
|
||
const _TC = {
|
||
sin: '#EF476F', cos: '#06D6E0', tan: '#FFD166', cot: '#7BF5A4',
|
||
point: '#9B5DE5', violet: '#9B5DE5',
|
||
};
|
||
|
||
function _tcRgb(hex) {
|
||
const n = parseInt(hex.slice(1), 16);
|
||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||
}
|
||
function _tcRgba(hex, a) {
|
||
const [r, g, b] = _tcRgb(hex);
|
||
return `rgba(${r},${g},${b},${a})`;
|
||
}
|
||
|
||
class TrigCircleSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0; this.dpr = 1;
|
||
|
||
this.angle = Math.PI / 4;
|
||
this.showSin = true;
|
||
this.showCos = true;
|
||
this.showTan = false;
|
||
this.showCot = false;
|
||
this.showGraph = true;
|
||
this.graphFn = 'sin';
|
||
this.snapToNotable = true;
|
||
this.animating = false;
|
||
|
||
this._cx = 0; this._cy = 0; this._r = 0;
|
||
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
|
||
|
||
this._drag = false;
|
||
this._hover = false;
|
||
this._raf = null;
|
||
this._animTarget = null;
|
||
this._animSpeed = 3;
|
||
this._idlePulse = 0;
|
||
this._idleRaf = null;
|
||
|
||
/* snap particles */
|
||
this._particles = [];
|
||
this._lastSnap = -1;
|
||
|
||
this.onUpdate = null;
|
||
|
||
this._bindEvents();
|
||
this._ro = new ResizeObserver(() => { this.fit(); this.draw(); });
|
||
this._ro.observe(canvas.parentElement);
|
||
}
|
||
|
||
/* ═══ Public ═══════════════════════════════════════════════════════ */
|
||
|
||
fit() {
|
||
const p = this.canvas.parentElement.getBoundingClientRect();
|
||
this.dpr = window.devicePixelRatio || 1;
|
||
this.W = p.width || 800; this.H = p.height || 500;
|
||
this.canvas.width = this.W * this.dpr;
|
||
this.canvas.height = this.H * this.dpr;
|
||
this.canvas.style.width = this.W + 'px';
|
||
this.canvas.style.height = this.H + 'px';
|
||
this._layout();
|
||
}
|
||
|
||
draw() {
|
||
const c = this.ctx;
|
||
c.save(); c.scale(this.dpr, this.dpr);
|
||
c.clearRect(0, 0, this.W, this.H);
|
||
|
||
this._drawBg(c);
|
||
this._drawCircle(c);
|
||
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
|
||
this._drawParticles(c);
|
||
|
||
c.restore();
|
||
this._fireUpdate();
|
||
}
|
||
|
||
setAngle(a) { this.angle = this._norm(a); this.draw(); }
|
||
setGraphFn(f){ this.graphFn = f; this.draw(); }
|
||
|
||
toggleLayer(n, v) {
|
||
if (n === 'sin') this.showSin = v;
|
||
if (n === 'cos') this.showCos = v;
|
||
if (n === 'tan') this.showTan = v;
|
||
if (n === 'cot') this.showCot = v;
|
||
if (n === 'graph') this.showGraph = v;
|
||
this._layout(); this.draw();
|
||
}
|
||
|
||
goToAngle(rad) {
|
||
this._animTarget = this._norm(rad);
|
||
if (!this.animating) this._startAnim();
|
||
}
|
||
|
||
start() { this._startIdle(); }
|
||
stop() { this._stopAnim(); this._stopIdle(); }
|
||
|
||
stats() {
|
||
const a = this.angle, s = Math.sin(a), co = Math.cos(a);
|
||
const t = Math.abs(co) > 1e-9 ? s / co : undefined;
|
||
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
|
||
const deg = a * 180 / Math.PI;
|
||
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
|
||
return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q };
|
||
}
|
||
|
||
/* ═══ Layout ═══════════════════════════════════════════════════════ */
|
||
|
||
_layout() {
|
||
const m = 44;
|
||
if (this.showGraph) {
|
||
const cW = this.W * 0.50;
|
||
this._r = Math.min(cW - m * 2, this.H - m * 2) / 2 * 0.76;
|
||
this._cx = cW / 2;
|
||
this._cy = this.H / 2;
|
||
this._gx = cW + 24;
|
||
this._gw = this.W - this._gx - m;
|
||
this._gh = this.H - m * 2;
|
||
this._gy = m;
|
||
} else {
|
||
this._r = Math.min(this.W - m * 2, this.H - m * 2) / 2 * 0.76;
|
||
this._cx = this.W / 2;
|
||
this._cy = this.H / 2;
|
||
}
|
||
this._r = Math.max(55, this._r);
|
||
}
|
||
|
||
/* ═══ Background ═══════════════════════════════════════════════════ */
|
||
|
||
_drawBg(c) {
|
||
const g = c.createRadialGradient(this._cx, this._cy, 0, this._cx, this._cy, this._r * 2.4);
|
||
g.addColorStop(0, 'rgba(155,93,229,0.055)');
|
||
g.addColorStop(0.5,'rgba(155,93,229,0.02)');
|
||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||
c.fillStyle = g; c.fillRect(0, 0, this.W, this.H);
|
||
|
||
/* decorative rings */
|
||
c.strokeStyle = 'rgba(255,255,255,0.016)'; c.lineWidth = 1;
|
||
for (let i = 1; i <= 3; i++) {
|
||
c.beginPath(); c.arc(this._cx, this._cy, this._r * (0.5 + i * 0.35), 0, Math.PI * 2);
|
||
c.stroke();
|
||
}
|
||
}
|
||
|
||
/* ═══ Unit Circle ══════════════════════════════════════════════════ */
|
||
|
||
_drawCircle(c) {
|
||
const cx = this._cx, cy = this._cy, r = this._r;
|
||
const a = this.angle;
|
||
const cosA = Math.cos(a), sinA = Math.sin(a);
|
||
const px = cx + r * cosA, py = cy - r * sinA;
|
||
const ext = Math.min(55, r * 0.35);
|
||
|
||
/* ── quadrant soft fill ── */
|
||
const q = this.stats().quadrant;
|
||
const qS = [0, Math.PI/2, Math.PI, 3*Math.PI/2][q-1];
|
||
c.fillStyle = 'rgba(155,93,229,0.022)';
|
||
c.beginPath(); c.moveTo(cx, cy);
|
||
c.arc(cx, cy, r, -(qS + Math.PI/2), -qS);
|
||
c.closePath(); c.fill();
|
||
|
||
/* ── degree tick marks (every 10°, bigger every 30°) ── */
|
||
for (let deg = 0; deg < 360; deg += 10) {
|
||
const rad = deg * Math.PI / 180;
|
||
const big = deg % 30 === 0;
|
||
const len = big ? 8 : 4;
|
||
const x1 = cx + (r - len) * Math.cos(rad);
|
||
const y1 = cy - (r - len) * Math.sin(rad);
|
||
const x2 = cx + r * Math.cos(rad);
|
||
const y2 = cy - r * Math.sin(rad);
|
||
c.strokeStyle = big ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.05)';
|
||
c.lineWidth = big ? 1.5 : 1;
|
||
c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke();
|
||
}
|
||
|
||
/* ── axes (gradient fade) ── */
|
||
const axGrad = (x1,y1,x2,y2) => {
|
||
const g = c.createLinearGradient(x1,y1,x2,y2);
|
||
g.addColorStop(0, 'rgba(255,255,255,0.0)');
|
||
g.addColorStop(0.08,'rgba(255,255,255,0.30)');
|
||
g.addColorStop(0.5, 'rgba(255,255,255,0.50)');
|
||
g.addColorStop(0.92,'rgba(255,255,255,0.30)');
|
||
g.addColorStop(1, 'rgba(255,255,255,0.0)');
|
||
return g;
|
||
};
|
||
c.lineWidth = 1.5;
|
||
c.strokeStyle = axGrad(cx - r - ext, cy, cx + r + ext, cy);
|
||
c.beginPath(); c.moveTo(cx - r - ext, cy); c.lineTo(cx + r + ext, cy); c.stroke();
|
||
c.strokeStyle = axGrad(cx, cy + r + ext, cx, cy - r - ext);
|
||
c.beginPath(); c.moveTo(cx, cy + r + ext); c.lineTo(cx, cy - r - ext); c.stroke();
|
||
|
||
/* arrows */
|
||
this._arrowH(c, cx + r + ext, cy, 0, 'rgba(255,255,255,0.5)');
|
||
this._arrowH(c, cx, cy - r - ext, -Math.PI/2, 'rgba(255,255,255,0.5)');
|
||
|
||
/* axis labels */
|
||
c.font = '700 13px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.45)';
|
||
c.textAlign = 'left'; c.textBaseline = 'top';
|
||
c.fillText('x', cx + r + ext - 12, cy + 8);
|
||
c.textAlign = 'right'; c.textBaseline = 'bottom';
|
||
c.fillText('y', cx - 10, cy - r - ext + 16);
|
||
|
||
/* ±1 ticks & labels */
|
||
c.strokeStyle = 'rgba(255,255,255,0.30)'; c.lineWidth = 1.5;
|
||
c.font = '600 11px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.45)';
|
||
const tk = 6;
|
||
c.beginPath(); c.moveTo(cx+r, cy-tk); c.lineTo(cx+r, cy+tk); c.stroke();
|
||
c.textAlign='center'; c.textBaseline='top'; c.fillText('1', cx+r, cy+9);
|
||
c.beginPath(); c.moveTo(cx-r, cy-tk); c.lineTo(cx-r, cy+tk); c.stroke();
|
||
c.fillText('−1', cx-r, cy+9);
|
||
c.beginPath(); c.moveTo(cx-tk, cy-r); c.lineTo(cx+tk, cy-r); c.stroke();
|
||
c.textAlign='right'; c.textBaseline='middle'; c.fillText('1', cx-10, cy-r);
|
||
c.beginPath(); c.moveTo(cx-tk, cy+r); c.lineTo(cx+tk, cy+r); c.stroke();
|
||
c.fillText('−1', cx-10, cy+r);
|
||
|
||
/* origin dot */
|
||
c.fillStyle = 'rgba(255,255,255,0.35)';
|
||
c.beginPath(); c.arc(cx, cy, 2.5, 0, Math.PI*2); c.fill();
|
||
|
||
/* ── unit circle (multi-layer) ── */
|
||
c.strokeStyle = _tcRgba(_TC.violet, 0.05 + Math.sin(this._idlePulse) * 0.02);
|
||
c.lineWidth = 14;
|
||
c.beginPath(); c.arc(cx, cy, r, 0, Math.PI*2); c.stroke();
|
||
c.strokeStyle = 'rgba(255,255,255,0.13)'; c.lineWidth = 2;
|
||
c.beginPath(); c.arc(cx, cy, r, 0, Math.PI*2); c.stroke();
|
||
|
||
/* ── notable angle dots + labels ── */
|
||
for (const n of _TC_NOTABLE) {
|
||
const nx = cx + r * Math.cos(n.a), ny = cy - r * Math.sin(n.a);
|
||
const act = Math.abs(a - n.a) < 0.03;
|
||
if (act) {
|
||
c.fillStyle = _tcRgba(_TC.violet, 0.5);
|
||
c.shadowColor = _TC.violet; c.shadowBlur = 10;
|
||
c.beginPath(); c.arc(nx, ny, 5, 0, Math.PI*2); c.fill();
|
||
c.shadowBlur = 0;
|
||
c.strokeStyle = _TC.violet; c.lineWidth = 1.5;
|
||
c.beginPath(); c.arc(nx, ny, 5, 0, Math.PI*2); c.stroke();
|
||
} else {
|
||
c.fillStyle = 'rgba(255,255,255,0.12)';
|
||
c.beginPath(); c.arc(nx, ny, 2.5, 0, Math.PI*2); c.fill();
|
||
}
|
||
if (n.l && n.l !== '0') {
|
||
const d = act ? 24 : 20;
|
||
const lx = cx + (r + d) * Math.cos(n.a);
|
||
const ly = cy - (r + d) * Math.sin(n.a);
|
||
c.font = act ? '700 11px Manrope,sans-serif' : '400 9px Manrope,sans-serif';
|
||
c.fillStyle = act ? _tcRgba(_TC.violet, 0.95) : 'rgba(255,255,255,0.18)';
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(n.l, lx, ly);
|
||
}
|
||
}
|
||
|
||
/* ── angle arc ── */
|
||
if (a > 0.015) {
|
||
const ar = Math.min(r * 0.22, 44);
|
||
c.fillStyle = _tcRgba(_TC.violet, 0.06);
|
||
c.beginPath(); c.moveTo(cx, cy); c.arc(cx, cy, ar, 0, -a, true); c.closePath(); c.fill();
|
||
const ag = c.createConicGradient(0, cx, cy);
|
||
ag.addColorStop(0, _tcRgba(_TC.violet, 0.7));
|
||
ag.addColorStop(Math.min(a / (Math.PI*2), 0.99), _tcRgba(_TC.violet, 0.25));
|
||
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
|
||
c.strokeStyle = ag; c.lineWidth = 2.5;
|
||
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
|
||
/* label */
|
||
const mid = a / 2, lr = ar + 18;
|
||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet;
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
|
||
}
|
||
|
||
/* ── radius ── */
|
||
const rg = c.createLinearGradient(cx, cy, px, py);
|
||
rg.addColorStop(0, 'rgba(255,255,255,0.12)'); rg.addColorStop(1, 'rgba(255,255,255,0.40)');
|
||
c.strokeStyle = rg; c.lineWidth = 1.5;
|
||
c.beginPath(); c.moveTo(cx, cy); c.lineTo(px, py); c.stroke();
|
||
|
||
/* ── projection dashes ── */
|
||
c.strokeStyle = 'rgba(255,255,255,0.08)'; c.lineWidth = 1;
|
||
c.setLineDash([4, 4]);
|
||
c.beginPath(); c.moveTo(px, py); c.lineTo(px, cy); c.stroke();
|
||
c.beginPath(); c.moveTo(px, py); c.lineTo(cx, py); c.stroke();
|
||
c.setLineDash([]);
|
||
|
||
const projX = cx + r * cosA;
|
||
|
||
/* ── triangle fill (sin+cos) ── */
|
||
if (this.showSin && this.showCos && Math.abs(cosA) > 0.04 && Math.abs(sinA) > 0.04) {
|
||
c.fillStyle = 'rgba(155,93,229,0.035)';
|
||
c.beginPath(); c.moveTo(cx, cy); c.lineTo(projX, cy); c.lineTo(px, py); c.closePath(); c.fill();
|
||
}
|
||
|
||
/* ═══ trig segments ═══ */
|
||
|
||
if (this.showCos) {
|
||
this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4);
|
||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cos;
|
||
c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'top' : 'bottom';
|
||
c.fillText('cos', (cx + projX) / 2, cy + (sinA >= 0 ? 12 : -12));
|
||
}
|
||
|
||
if (this.showSin) {
|
||
this._glowLine(c, projX, cy, px, py, _TC.sin, 4);
|
||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.sin;
|
||
c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle';
|
||
c.fillText('sin', projX + (cosA >= 0 ? 9 : -9), (cy + py) / 2);
|
||
}
|
||
|
||
if (this.showTan && Math.abs(cosA) > 0.025) {
|
||
const tanV = sinA / cosA;
|
||
if (Math.abs(tanV) < 10) {
|
||
const tX = cosA >= 0 ? cx + r : cx - r;
|
||
const tY = cosA >= 0 ? cy - r * tanV : cy + r * tanV;
|
||
/* faint tangent guide line */
|
||
c.strokeStyle = _tcRgba(_TC.tan, 0.06); c.lineWidth = 1;
|
||
c.beginPath(); c.moveTo(tX, cy - r - ext); c.lineTo(tX, cy + r + ext); c.stroke();
|
||
this._glowLine(c, tX, cy, tX, tY, _TC.tan, 3.5);
|
||
c.strokeStyle = _tcRgba(_TC.tan, 0.18); c.lineWidth = 1;
|
||
c.setLineDash([5, 4]); c.beginPath(); c.moveTo(px, py); c.lineTo(tX, tY); c.stroke(); c.setLineDash([]);
|
||
c.fillStyle = _TC.tan; c.shadowColor = _TC.tan; c.shadowBlur = 8;
|
||
c.beginPath(); c.arc(tX, tY, 4.5, 0, Math.PI*2); c.fill(); c.shadowBlur = 0;
|
||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.tan;
|
||
c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle';
|
||
c.fillText('tg', tX + (cosA >= 0 ? 8 : -8), (cy + tY) / 2);
|
||
}
|
||
}
|
||
|
||
if (this.showCot && Math.abs(sinA) > 0.025) {
|
||
const cotV = cosA / sinA;
|
||
if (Math.abs(cotV) < 10) {
|
||
const cX = sinA >= 0 ? cx + r * cotV : cx - r * cotV;
|
||
const cY = sinA >= 0 ? cy - r : cy + r;
|
||
c.strokeStyle = _tcRgba(_TC.cot, 0.06); c.lineWidth = 1;
|
||
c.beginPath(); c.moveTo(cx - r - ext, cY); c.lineTo(cx + r + ext, cY); c.stroke();
|
||
this._glowLine(c, cx, cY, cX, cY, _TC.cot, 3.5);
|
||
c.strokeStyle = _tcRgba(_TC.cot, 0.18); c.lineWidth = 1;
|
||
c.setLineDash([5, 4]); c.beginPath(); c.moveTo(px, py); c.lineTo(cX, cY); c.stroke(); c.setLineDash([]);
|
||
c.fillStyle = _TC.cot; c.shadowColor = _TC.cot; c.shadowBlur = 8;
|
||
c.beginPath(); c.arc(cX, cY, 4.5, 0, Math.PI*2); c.fill(); c.shadowBlur = 0;
|
||
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cot;
|
||
c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'bottom' : 'top';
|
||
c.fillText('ctg', (cx + cX) / 2, cY + (sinA >= 0 ? -8 : 8));
|
||
}
|
||
}
|
||
|
||
/* ── right-angle marker ── */
|
||
if (this.showSin && this.showCos && Math.abs(cosA) > 0.06 && Math.abs(sinA) > 0.06) {
|
||
const sz = 8, dx = cosA > 0 ? -sz : sz, dy = sinA > 0 ? sz : -sz;
|
||
c.strokeStyle = 'rgba(255,255,255,0.18)'; c.lineWidth = 1;
|
||
c.beginPath(); c.moveTo(projX+dx, cy); c.lineTo(projX+dx, cy-dy); c.lineTo(projX, cy-dy); c.stroke();
|
||
}
|
||
|
||
/* ── axis value badges ── */
|
||
if (this.showSin && Math.abs(sinA) > 0.04)
|
||
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle');
|
||
if (this.showCos && Math.abs(cosA) > 0.04)
|
||
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top');
|
||
|
||
/* ── main point ── */
|
||
const ps = this._hover || this._drag ? 10 : 8;
|
||
const blur = this._hover || this._drag ? 22 : 16;
|
||
c.fillStyle = _tcRgba(_TC.point, 0.10);
|
||
c.beginPath(); c.arc(px, py, ps + 10, 0, Math.PI*2); c.fill();
|
||
c.shadowColor = _TC.point; c.shadowBlur = blur;
|
||
c.fillStyle = _TC.point;
|
||
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.fill();
|
||
c.shadowBlur = 0;
|
||
c.fillStyle = 'rgba(255,255,255,0.85)';
|
||
c.beginPath(); c.arc(px, py, ps * 0.35, 0, Math.PI*2); c.fill();
|
||
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
|
||
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
|
||
|
||
/* ── coordinate tooltip ── */
|
||
this._tooltip(c, px, py, cosA, sinA);
|
||
|
||
/* ── quadrant roman numeral ── */
|
||
const qOff = r * 0.46;
|
||
const qx = (q===1||q===4) ? cx+qOff : cx-qOff;
|
||
const qy = (q<=2) ? cy-qOff : cy+qOff;
|
||
c.font = 'bold 22px Manrope,sans-serif'; c.fillStyle = _tcRgba(_TC.violet, 0.07);
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(['I','II','III','IV'][q-1]||'', qx, qy);
|
||
|
||
/* ── sign pills per quadrant ── */
|
||
this._quadSigns(c, cx, cy, r);
|
||
|
||
/* ── Pythagorean identity bar ── */
|
||
this._pythBar(c);
|
||
|
||
/* ── connection line to graph ── */
|
||
if (this.showGraph) this._connLine(c, px, py, sinA, cosA);
|
||
}
|
||
|
||
/* ═══ Connection line: circle <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> graph ═══════════════════════════════ */
|
||
|
||
_connLine(c, px, py, sinA, cosA) {
|
||
const fn = this.graphFn;
|
||
const val = fn === 'sin' ? sinA : fn === 'cos' ? cosA :
|
||
fn === 'tan' ? (Math.abs(cosA)>0.02 ? sinA/cosA : null) :
|
||
(Math.abs(sinA)>0.02 ? cosA/sinA : null);
|
||
if (val === null || !isFinite(val)) return;
|
||
|
||
const yR = (fn === 'tan' || fn === 'cot') ? 4 : 1.5;
|
||
if (Math.abs(val) > yR * 2) return;
|
||
|
||
const gy = this._gy, gh = this._gh;
|
||
const targetY = gy + gh/2 - val / yR * (gh/2);
|
||
|
||
/* source Y = py for sin, cy for cos, depends on fn */
|
||
const srcY = (fn === 'sin') ? py : (fn === 'cos') ? this._cy : py;
|
||
const srcX = (fn === 'sin' || fn === 'tan') ? px : this._cx;
|
||
|
||
c.strokeStyle = _tcRgba(_TC[fn] || _TC.sin, 0.12);
|
||
c.lineWidth = 1;
|
||
c.setLineDash([3, 5]);
|
||
c.beginPath(); c.moveTo(srcX, srcY); c.lineTo(this._gx, targetY); c.stroke();
|
||
c.setLineDash([]);
|
||
}
|
||
|
||
/* ═══ Quadrant sign pills ═══════════════════════════════════════════ */
|
||
|
||
_quadSigns(c, cx, cy, r) {
|
||
const signs = [
|
||
{ q: 1, s:'+', co:'+', t:'+' }, { q: 2, s:'+', co:'−', t:'−' },
|
||
{ q: 3, s:'−', co:'−', t:'+' }, { q: 4, s:'−', co:'+', t:'−' },
|
||
];
|
||
const curr = this.stats().quadrant;
|
||
const off = r * 0.78;
|
||
for (const sg of signs) {
|
||
const sx = (sg.q===1||sg.q===4) ? cx+off : cx-off;
|
||
const sy = (sg.q<=2) ? cy-off : cy+off;
|
||
const isCurr = sg.q === curr;
|
||
c.font = '500 8px Manrope,sans-serif';
|
||
c.fillStyle = isCurr ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.07)';
|
||
c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
const txt = `s${sg.s} c${sg.co} t${sg.t}`;
|
||
c.fillText(txt, sx, sy);
|
||
}
|
||
}
|
||
|
||
/* ═══ Pythagorean identity bar ══════════════════════════════════════ */
|
||
|
||
_pythBar(c) {
|
||
const s = Math.sin(this.angle), co = Math.cos(this.angle);
|
||
const sin2 = s * s, cos2 = co * co;
|
||
const bw = Math.min(this._r * 1.4, 180);
|
||
const bh = 6;
|
||
const bx = this._cx - bw / 2;
|
||
const by = this._cy + this._r + 38;
|
||
if (by + bh + 16 > this.H) return;
|
||
|
||
/* background track */
|
||
c.fillStyle = 'rgba(255,255,255,0.04)';
|
||
c.beginPath(); c.roundRect(bx, by, bw, bh, 3); c.fill();
|
||
|
||
/* sin² portion */
|
||
const sw = bw * sin2;
|
||
if (sw > 0.5) {
|
||
c.fillStyle = _tcRgba(_TC.sin, 0.5);
|
||
c.beginPath(); c.roundRect(bx, by, sw, bh, [3,0,0,3]); c.fill();
|
||
}
|
||
|
||
/* cos² portion */
|
||
const cw = bw * cos2;
|
||
if (cw > 0.5) {
|
||
c.fillStyle = _tcRgba(_TC.cos, 0.5);
|
||
c.beginPath(); c.roundRect(bx + sw, by, cw, bh, [0,3,3,0]); c.fill();
|
||
}
|
||
|
||
/* label */
|
||
c.font = '500 9px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.25)';
|
||
c.textAlign = 'center'; c.textBaseline = 'top';
|
||
c.fillText(`sin² + cos² = 1`, this._cx, by + bh + 4);
|
||
}
|
||
|
||
/* ═══ Divider ══════════════════════════════════════════════════════ */
|
||
|
||
_drawDivider(c) {
|
||
const x = this._gx - 14;
|
||
const pad = 20;
|
||
const lg = c.createLinearGradient(x, pad, x, this.H - pad);
|
||
lg.addColorStop(0, 'rgba(155,93,229,0.0)');
|
||
lg.addColorStop(0.15,'rgba(155,93,229,0.18)');
|
||
lg.addColorStop(0.5, 'rgba(155,93,229,0.28)');
|
||
lg.addColorStop(0.85,'rgba(155,93,229,0.18)');
|
||
lg.addColorStop(1, 'rgba(155,93,229,0.0)');
|
||
c.strokeStyle = lg; c.lineWidth = 1;
|
||
c.beginPath(); c.moveTo(x, pad); c.lineTo(x, this.H - pad); c.stroke();
|
||
/* glow */
|
||
const gg = c.createLinearGradient(x - 16, 0, x + 16, 0);
|
||
gg.addColorStop(0, 'rgba(155,93,229,0.0)');
|
||
gg.addColorStop(0.5, 'rgba(155,93,229,0.035)');
|
||
gg.addColorStop(1, 'rgba(155,93,229,0.0)');
|
||
c.fillStyle = gg; c.fillRect(x - 16, pad, 32, this.H - pad*2);
|
||
}
|
||
|
||
/* ═══ Graph ════════════════════════════════════════════════════════ */
|
||
|
||
_drawGraph(c) {
|
||
const gx = this._gx, gy = this._gy, gw = this._gw, gh = this._gh;
|
||
if (gw < 50 || gh < 50) return;
|
||
|
||
const fn = this.graphFn;
|
||
const col = _TC[fn] || _TC.sin;
|
||
const lbl = fn==='sin'?'y = sin x':fn==='cos'?'y = cos x':fn==='tan'?'y = tg x':'y = ctg x';
|
||
const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x));
|
||
const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
|
||
const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI;
|
||
const sx = x => gx + (x-xMin)/(xMax-xMin)*gw;
|
||
const sy = y => gy + gh/2 - y/yR*(gh/2);
|
||
|
||
/* ── glass panel ── */
|
||
const pp = 12;
|
||
const px1 = gx-pp, py1 = gy-pp, pw = gw+pp*2, ph = gh+pp*2;
|
||
c.fillStyle = 'rgba(10,10,20,0.50)';
|
||
c.beginPath(); c.roundRect(px1, py1, pw, ph, 20); c.fill();
|
||
/* gradient border */
|
||
const bg = c.createLinearGradient(px1, py1, px1+pw, py1+ph);
|
||
bg.addColorStop(0, 'rgba(155,93,229,0.18)');
|
||
bg.addColorStop(0.3,'rgba(255,255,255,0.06)');
|
||
bg.addColorStop(0.7,'rgba(255,255,255,0.06)');
|
||
bg.addColorStop(1, 'rgba(155,93,229,0.18)');
|
||
c.strokeStyle = bg; c.lineWidth = 1.5;
|
||
c.beginPath(); c.roundRect(px1, py1, pw, ph, 20); c.stroke();
|
||
/* top highlight */
|
||
const hg = c.createLinearGradient(px1, py1, px1, py1+50);
|
||
hg.addColorStop(0, 'rgba(255,255,255,0.025)'); hg.addColorStop(1, 'rgba(255,255,255,0.0)');
|
||
c.fillStyle = hg;
|
||
c.beginPath(); c.roundRect(px1+1, py1+1, pw-2, 50, [20,20,0,0]); c.fill();
|
||
|
||
/* ── zero axis ── */
|
||
const zy = sy(0);
|
||
c.strokeStyle = 'rgba(255,255,255,0.14)'; c.lineWidth = 1;
|
||
c.beginPath(); c.moveTo(gx, zy); c.lineTo(gx+gw, zy); c.stroke();
|
||
/* y-axis on graph */
|
||
const x0 = sx(0);
|
||
if (x0 > gx + 4 && x0 < gx + gw - 4) {
|
||
c.strokeStyle = 'rgba(255,255,255,0.08)'; c.lineWidth = 1;
|
||
c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke();
|
||
}
|
||
|
||
/* ±1 lines */
|
||
if (fn==='sin'||fn==='cos') {
|
||
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]);
|
||
[1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); });
|
||
c.setLineDash([]);
|
||
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)';
|
||
c.textAlign='right'; c.textBaseline='middle';
|
||
c.fillText('1', gx-5, sy(1)); c.fillText('−1', gx-5, sy(-1));
|
||
}
|
||
|
||
/* x ticks */
|
||
const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']];
|
||
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)';
|
||
c.textAlign='center'; c.textBaseline='top';
|
||
for (const [v,l] of ticks) {
|
||
const xx = sx(v);
|
||
if (xx < gx+6 || xx > gx+gw-6) continue;
|
||
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1;
|
||
c.setLineDash([3,3]);
|
||
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke();
|
||
c.setLineDash([]);
|
||
c.fillText(l, xx, gy+gh+6);
|
||
}
|
||
|
||
/* ── ghost curves (other functions, dimmed) ── */
|
||
c.save();
|
||
c.beginPath(); c.rect(gx, gy, gw, gh); c.clip();
|
||
|
||
const allFns = [
|
||
{ id: 'sin', ev: Math.sin, c: _TC.sin },
|
||
{ id: 'cos', ev: Math.cos, c: _TC.cos },
|
||
{ id: 'tan', ev: Math.tan, c: _TC.tan },
|
||
{ id: 'cot', ev: x => 1/Math.tan(x), c: _TC.cot },
|
||
];
|
||
const step = (xMax - xMin) / (gw * 1.5);
|
||
|
||
for (const f of allFns) {
|
||
if (f.id === fn) continue; /* skip active — draw it last */
|
||
const show = (f.id==='sin'&&this.showSin) || (f.id==='cos'&&this.showCos) ||
|
||
(f.id==='tan'&&this.showTan) || (f.id==='cot'&&this.showCot);
|
||
if (!show) continue;
|
||
const yRg = (f.id==='tan'||f.id==='cot') ? 4 : 1.5;
|
||
const syG = y => gy + gh/2 - y/yRg*(gh/2);
|
||
c.strokeStyle = _tcRgba(f.c, 0.18); c.lineWidth = 1.5;
|
||
c.beginPath(); let on = false;
|
||
for (let x = xMin; x <= xMax; x += step) {
|
||
const yv = f.ev(x);
|
||
if (!isFinite(yv) || Math.abs(yv) > yRg*2) { on = false; continue; }
|
||
const spx = sx(x), spy = syG(yv);
|
||
if (!on) { c.moveTo(spx, spy); on = true; } else c.lineTo(spx, spy);
|
||
}
|
||
c.stroke();
|
||
}
|
||
|
||
/* gradient fill under active curve (sin/cos) */
|
||
if (fn==='sin'||fn==='cos') {
|
||
const pts = [];
|
||
for (let x = xMin; x <= xMax; x += step) {
|
||
const yv = evFn(x);
|
||
if (isFinite(yv)) pts.push({ sx: sx(x), sy: sy(yv) });
|
||
}
|
||
if (pts.length > 2) {
|
||
const fg = c.createLinearGradient(0, gy, 0, gy+gh);
|
||
fg.addColorStop(0, _tcRgba(col, 0.10));
|
||
fg.addColorStop(0.5, _tcRgba(col, 0.0));
|
||
fg.addColorStop(1, _tcRgba(col, 0.10));
|
||
c.fillStyle = fg; c.beginPath();
|
||
c.moveTo(pts[0].sx, zy);
|
||
pts.forEach(p => c.lineTo(p.sx, p.sy));
|
||
c.lineTo(pts[pts.length-1].sx, zy);
|
||
c.closePath(); c.fill();
|
||
}
|
||
}
|
||
|
||
/* active curve: glow + main */
|
||
c.strokeStyle = _tcRgba(col, 0.12); c.lineWidth = 10; c.lineCap='round'; c.lineJoin='round';
|
||
c.beginPath(); let on2 = false;
|
||
for (let x = xMin; x <= xMax; x += step) {
|
||
const yv = evFn(x);
|
||
if (!isFinite(yv)||Math.abs(yv)>yR*2) { on2 = false; continue; }
|
||
const spx = sx(x), spy = sy(yv);
|
||
if (!on2) { c.moveTo(spx, spy); on2 = true; } else c.lineTo(spx, spy);
|
||
}
|
||
c.stroke();
|
||
c.strokeStyle = col; c.lineWidth = 2.5;
|
||
c.beginPath(); on2 = false;
|
||
for (let x = xMin; x <= xMax; x += step) {
|
||
const yv = evFn(x);
|
||
if (!isFinite(yv)||Math.abs(yv)>yR*2) { on2 = false; continue; }
|
||
const spx = sx(x), spy = sy(yv);
|
||
if (!on2) { c.moveTo(spx, spy); on2 = true; } else c.lineTo(spx, spy);
|
||
}
|
||
c.stroke();
|
||
|
||
/* ── current angle marker ── */
|
||
const curY = evFn(this.angle);
|
||
if (isFinite(curY) && Math.abs(curY) <= yR*2) {
|
||
const mx = sx(this.angle), my = sy(curY);
|
||
c.strokeStyle = _tcRgba(_TC.violet, 0.18); c.lineWidth = 1;
|
||
c.setLineDash([4, 4]);
|
||
c.beginPath(); c.moveTo(mx, gy); c.lineTo(mx, gy+gh); c.stroke();
|
||
c.strokeStyle = _tcRgba(col, 0.18);
|
||
c.beginPath(); c.moveTo(gx, my); c.lineTo(mx, my); c.stroke();
|
||
c.setLineDash([]);
|
||
/* dot */
|
||
c.fillStyle = _tcRgba(_TC.point, 0.12);
|
||
c.beginPath(); c.arc(mx, my, 13, 0, Math.PI*2); c.fill();
|
||
c.fillStyle = _TC.point; c.shadowColor = _TC.point; c.shadowBlur = 12;
|
||
c.beginPath(); c.arc(mx, my, 5.5, 0, Math.PI*2); c.fill();
|
||
c.shadowBlur = 0;
|
||
c.fillStyle = 'rgba(255,255,255,0.7)';
|
||
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
|
||
/* value badge */
|
||
const txt = this._fmt(curY);
|
||
c.font = 'bold 11px Manrope,sans-serif';
|
||
const tm = c.measureText(txt);
|
||
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20;
|
||
c.fillStyle='rgba(12,12,22,0.85)';
|
||
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill();
|
||
c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1;
|
||
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke();
|
||
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
|
||
c.fillText(txt, bx2+7, by2);
|
||
}
|
||
|
||
c.restore();
|
||
|
||
/* fn name badge */
|
||
c.font='bold 13px Manrope,sans-serif';
|
||
const tm2 = c.measureText(lbl);
|
||
const bw3 = tm2.width+18, bh3 = 26;
|
||
c.fillStyle='rgba(12,12,22,0.7)';
|
||
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill();
|
||
c.strokeStyle = _tcRgba(col, 0.25); c.lineWidth = 1;
|
||
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.stroke();
|
||
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
|
||
c.fillText(lbl, gx+17, gy+21);
|
||
}
|
||
|
||
/* ═══ Snap particles ═══════════════════════════════════════════════ */
|
||
|
||
_spawnSnap(px, py) {
|
||
for (let i = 0; i < 8; i++) {
|
||
const ang = Math.random() * Math.PI * 2;
|
||
const speed = 30 + Math.random() * 50;
|
||
this._particles.push({
|
||
x: px, y: py,
|
||
vx: Math.cos(ang) * speed,
|
||
vy: Math.sin(ang) * speed,
|
||
life: 1,
|
||
col: _TC.violet,
|
||
});
|
||
}
|
||
}
|
||
|
||
_drawParticles(c) {
|
||
const dt = 0.016;
|
||
for (let i = this._particles.length - 1; i >= 0; i--) {
|
||
const p = this._particles[i];
|
||
p.x += p.vx * dt; p.y += p.vy * dt;
|
||
p.life -= dt * 1.8;
|
||
if (p.life <= 0) { this._particles.splice(i, 1); continue; }
|
||
c.fillStyle = _tcRgba(p.col, p.life * 0.6);
|
||
c.shadowColor = p.col; c.shadowBlur = 6;
|
||
c.beginPath(); c.arc(p.x, p.y, 2 * p.life, 0, Math.PI*2); c.fill();
|
||
c.shadowBlur = 0;
|
||
}
|
||
}
|
||
|
||
/* ═══ Drawing helpers ══════════════════════════════════════════════ */
|
||
|
||
_glowLine(c, x1, y1, x2, y2, col, w) {
|
||
c.lineCap = 'round';
|
||
c.strokeStyle = _tcRgba(col, 0.14); c.lineWidth = w + 8;
|
||
c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke();
|
||
c.strokeStyle = col; c.lineWidth = w;
|
||
c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke();
|
||
c.strokeStyle = _tcRgba(col, 0.45); c.lineWidth = 1;
|
||
c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke();
|
||
}
|
||
|
||
_arrowH(c, x, y, angle, col) {
|
||
c.save(); c.translate(x, y); c.rotate(angle);
|
||
c.fillStyle = col;
|
||
c.beginPath(); c.moveTo(0,0); c.lineTo(-9,-4.5); c.lineTo(-9,4.5); c.closePath(); c.fill();
|
||
c.restore();
|
||
}
|
||
|
||
_badge(c, x, y, txt, col, tA, tB) {
|
||
c.font='600 10px Manrope,sans-serif';
|
||
const m = c.measureText(txt);
|
||
const pw = m.width+10, ph = 17;
|
||
let bx = x, by = y;
|
||
if (tA==='right') bx = x - pw;
|
||
else if (tA==='center') bx = x - pw/2;
|
||
if (tB==='middle') by = y - ph/2;
|
||
c.fillStyle='rgba(12,12,22,0.75)';
|
||
c.beginPath(); c.roundRect(bx, by, pw, ph, 4); c.fill();
|
||
c.strokeStyle = _tcRgba(col, 0.35); c.lineWidth = 1;
|
||
c.beginPath(); c.roundRect(bx, by, pw, ph, 4); c.stroke();
|
||
c.fillStyle = col; c.textAlign='center'; c.textBaseline='middle';
|
||
c.fillText(txt, bx + pw/2, by + ph/2);
|
||
}
|
||
|
||
_tooltip(c, px, py, cosA, sinA) {
|
||
const txt = `(${this._fmt(cosA)}; ${this._fmt(sinA)})`;
|
||
c.font='600 11px Manrope,sans-serif';
|
||
const m = c.measureText(txt);
|
||
const pw = m.width+16, ph = 24;
|
||
const offX = cosA >= 0 ? 16 : -pw-16;
|
||
const offY = sinA >= 0 ? -ph-12 : 12;
|
||
const bx = px+offX, by = py+offY;
|
||
c.fillStyle='rgba(12,12,22,0.80)';
|
||
c.beginPath(); c.roundRect(bx, by, pw, ph, 8); c.fill();
|
||
c.strokeStyle = _tcRgba(_TC.violet, 0.3); c.lineWidth = 1;
|
||
c.beginPath(); c.roundRect(bx, by, pw, ph, 8); c.stroke();
|
||
c.fillStyle='rgba(255,255,255,0.82)';
|
||
c.textAlign='center'; c.textBaseline='middle';
|
||
c.fillText(txt, bx+pw/2, by+ph/2);
|
||
}
|
||
|
||
/* ═══ Formatting ═══════════════════════════════════════════════════ */
|
||
|
||
_fmt(v) {
|
||
const a = Math.abs(v), s = v < 0 ? '−' : '';
|
||
if (a < 5e-4) return '0';
|
||
if (Math.abs(a-0.5)<1e-3) return s+'½';
|
||
if (Math.abs(a-1)<1e-3) return s+'1';
|
||
if (Math.abs(a-Math.SQRT2/2)<1e-3) return s+'√2/2';
|
||
if (Math.abs(a-Math.sqrt(3)/2)<1e-3) return s+'√3/2';
|
||
if (Math.abs(a-Math.sqrt(3)/3)<1e-3) return s+'√3/3';
|
||
if (Math.abs(a-Math.sqrt(3))<1e-3) return s+'√3';
|
||
if (Math.abs(a-2)<1e-3) return s+'2';
|
||
if (Math.abs(a-2*Math.sqrt(3)/3)<1e-3)return s+'2√3/3';
|
||
return v.toFixed(3);
|
||
}
|
||
|
||
_radLbl(a) {
|
||
for (const n of _TC_NOTABLE) { if (Math.abs(a-n.a)<0.02) return n.l; }
|
||
return (a*180/Math.PI).toFixed(1)+'°';
|
||
}
|
||
|
||
_norm(a) { return ((a%(2*Math.PI))+2*Math.PI)%(2*Math.PI); }
|
||
_fireUpdate() { if (this.onUpdate) this.onUpdate(this.stats()); }
|
||
|
||
/* ═══ Events ═══════════════════════════════════════════════════════ */
|
||
|
||
_bindEvents() {
|
||
const cv = this.canvas;
|
||
|
||
const mp = e => {
|
||
const r = cv.getBoundingClientRect();
|
||
return { x:(e.clientX??e.touches?.[0]?.clientX??0)-r.left, y:(e.clientY??e.touches?.[0]?.clientY??0)-r.top };
|
||
};
|
||
|
||
const snapAngle = e => {
|
||
const m = mp(e);
|
||
let a = Math.atan2(-(m.y-this._cy), m.x-this._cx);
|
||
if (a<0) a += 2*Math.PI;
|
||
if (this.snapToNotable) {
|
||
for (const n of _TC_NOTABLE) {
|
||
if (Math.abs(a-n.a)<0.09) { a = n.a; break; }
|
||
}
|
||
}
|
||
return a;
|
||
};
|
||
|
||
const hit = e => {
|
||
const m = mp(e);
|
||
const px = this._cx + this._r*Math.cos(this.angle);
|
||
const py = this._cy - this._r*Math.sin(this.angle);
|
||
if (Math.hypot(m.x-px, m.y-py) < 30) return true;
|
||
return Math.abs(Math.hypot(m.x-this._cx, m.y-this._cy) - this._r) < 22;
|
||
};
|
||
|
||
const checkSnap = (newA) => {
|
||
for (const n of _TC_NOTABLE) {
|
||
if (Math.abs(newA-n.a)<0.02 && this._lastSnap !== n.a) {
|
||
this._lastSnap = n.a;
|
||
const nx = this._cx + this._r*Math.cos(n.a);
|
||
const ny = this._cy - this._r*Math.sin(n.a);
|
||
this._spawnSnap(nx, ny);
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
|
||
const end = () => {
|
||
if (this._drag) { this._drag = false; this.draw(); }
|
||
cv.style.cursor = 'default';
|
||
};
|
||
|
||
cv.addEventListener('mousedown', e => {
|
||
if (!hit(e)) return;
|
||
this._drag = true; this._stopAnim();
|
||
const na = snapAngle(e); checkSnap(na);
|
||
this.angle = na; this.draw();
|
||
cv.style.cursor = 'grabbing';
|
||
});
|
||
|
||
cv.addEventListener('mousemove', e => {
|
||
if (this._drag) {
|
||
const na = snapAngle(e); checkSnap(na);
|
||
this.angle = na; this.draw();
|
||
} else {
|
||
const h = hit(e);
|
||
if (h !== this._hover) { this._hover = h; this.draw(); }
|
||
cv.style.cursor = h ? 'grab' : 'default';
|
||
}
|
||
});
|
||
cv.addEventListener('mouseup', end);
|
||
cv.addEventListener('mouseleave', () => { if (this._hover){this._hover=false;this.draw();} end(); });
|
||
|
||
/* scroll wheel fine-tune */
|
||
cv.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const step = e.shiftKey ? 0.01 : (Math.PI / 180);
|
||
this.angle = this._norm(this.angle - Math.sign(e.deltaY) * step);
|
||
this._lastSnap = -1;
|
||
this.draw();
|
||
}, { passive: false });
|
||
|
||
/* keyboard arrows */
|
||
cv.setAttribute('tabindex', '0');
|
||
cv.style.outline = 'none';
|
||
cv.addEventListener('keydown', e => {
|
||
const step = e.shiftKey ? (Math.PI/180) : (Math.PI/36); /* 1° or 5° */
|
||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||
e.preventDefault(); this.angle = this._norm(this.angle + step); this.draw();
|
||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||
e.preventDefault(); this.angle = this._norm(this.angle - step); this.draw();
|
||
}
|
||
});
|
||
|
||
/* touch */
|
||
cv.addEventListener('touchstart', e => {
|
||
e.preventDefault();
|
||
if (!hit(e)) return;
|
||
this._drag = true; this._stopAnim();
|
||
const na = snapAngle(e); checkSnap(na); this.angle = na; this.draw();
|
||
}, { passive: false });
|
||
cv.addEventListener('touchmove', e => {
|
||
e.preventDefault();
|
||
if (this._drag) { const na = snapAngle(e); checkSnap(na); this.angle = na; this.draw(); }
|
||
}, { passive: false });
|
||
cv.addEventListener('touchend', end);
|
||
}
|
||
|
||
/* ═══ Animation ════════════════════════════════════════════════════ */
|
||
|
||
_startAnim() {
|
||
this.animating = true;
|
||
let last = performance.now();
|
||
const loop = now => {
|
||
if (!this.animating) return;
|
||
const dt = (now-last)/1000; last = now;
|
||
let d = this._animTarget - this.angle;
|
||
if (d > Math.PI) d -= 2*Math.PI;
|
||
if (d < -Math.PI) d += 2*Math.PI;
|
||
if (Math.abs(d) < 0.012) {
|
||
this.angle = this._animTarget;
|
||
this.animating = false;
|
||
/* snap particle at end */
|
||
const nx = this._cx + this._r*Math.cos(this.angle);
|
||
const ny = this._cy - this._r*Math.sin(this.angle);
|
||
this._spawnSnap(nx, ny);
|
||
this.draw(); return;
|
||
}
|
||
const speed = this._animSpeed * Math.max(0.3, Math.min(1, Math.abs(d)/0.5));
|
||
this.angle += Math.sign(d) * Math.min(Math.abs(d), speed * dt);
|
||
this.angle = this._norm(this.angle);
|
||
this.draw();
|
||
this._raf = requestAnimationFrame(loop);
|
||
};
|
||
this._raf = requestAnimationFrame(loop);
|
||
}
|
||
|
||
_stopAnim() {
|
||
this.animating = false;
|
||
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
||
}
|
||
|
||
_startIdle() {
|
||
if (this._idleRaf) return;
|
||
let last = performance.now();
|
||
const loop = now => {
|
||
const dt = (now-last)/1000; last = now;
|
||
this._idlePulse += dt * 1.5;
|
||
/* update particles */
|
||
if (this._particles.length > 0 || (!this._drag && !this.animating)) this.draw();
|
||
this._idleRaf = requestAnimationFrame(loop);
|
||
};
|
||
this._idleRaf = requestAnimationFrame(loop);
|
||
}
|
||
|
||
_stopIdle() {
|
||
if (this._idleRaf) { cancelAnimationFrame(this._idleRaf); this._idleRaf = null; }
|
||
}
|
||
}
|
||
|
||
if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
|