Files
Learn_System/frontend/js/labs/trigcircle.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

1069 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
if (window.LabFX) LabFX.particles.draw(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) {
if (window.LabFX) {
LabFX.glow.drawGlow(c, () => { this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4); }, { color: '#06D6E0', intensity: 4 });
} else {
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) {
if (window.LabFX) {
LabFX.glow.drawGlow(c, () => { this._glowLine(c, projX, cy, px, py, _TC.sin, 4); }, { color: '#06D6E0', intensity: 4 });
} else {
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';
});
this._lastDragSoundTs = 0;
cv.addEventListener('mousemove', e => {
if (this._drag) {
const na = snapAngle(e); checkSnap(na);
this.angle = na; this.draw();
const now = performance.now();
if (window.LabFX && now - this._lastDragSoundTs > 100) {
this._lastDragSoundTs = now;
const pitch = 0.8 + (this.angle / (2 * Math.PI)) * 0.8;
LabFX.sound.play('tick', { pitch, volume: 0.05 });
LabFX.haptic(5);
}
} 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;
if (window.LabFX) LabFX.particles.update(dt);
/* 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;
/* ─── lab UI init ─────────────────────────────────── */
var trigSim = null;
function _openTrigCircle() {
document.getElementById('sim-topbar-title').textContent = 'Тригонометрическая окружность';
_simShow('sim-trigcircle');
_simShow('ctrl-trigcircle');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!trigSim) {
trigSim = new TrigCircleSim(document.getElementById('trigcircle-canvas'));
trigSim.onUpdate = _trigUpdateUI;
}
trigSim.fit();
trigSim.start();
_trigUpdateUI(trigSim.stats());
}));
}
function trigToggle(layer, rowEl) {
if (!trigSim) return;
const isActive = rowEl.classList.toggle('active');
trigSim.toggleLayer(layer, isActive);
}
function trigSetGraphFn(fn, el) {
if (!trigSim) return;
document.querySelectorAll('.trig-fn-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
trigSim.setGraphFn(fn);
}
function trigGoTo(rad) {
if (!trigSim) return;
trigSim.goToAngle(rad);
}
function trigReset() {
if (!trigSim) return;
trigSim.setAngle(Math.PI / 4);
if (window.LabFX) LabFX.sound.play('click');
}
function _trigUpdateUI(s) {
const _f = v => {
if (v === undefined) return '—';
const a = Math.abs(v), sg = v < 0 ? '' : '';
if (a < 5e-4) return '0';
if (Math.abs(a - 0.5) < 1e-3) return sg + '½';
if (Math.abs(a - 1) < 1e-3) return sg + '1';
if (Math.abs(a - Math.SQRT2/2) < 1e-3) return sg + '√2/2';
if (Math.abs(a - Math.sqrt(3)/2) < 1e-3) return sg + '√3/2';
if (Math.abs(a - Math.sqrt(3)/3) < 1e-3) return sg + '√3/3';
if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '√3';
return v.toFixed(4);
};
const degStr = s.deg.toFixed(1) + '°';
// Panel values (nice fractions)
document.getElementById('trig-v-sin').textContent = _f(s.sin);
document.getElementById('trig-v-cos').textContent = _f(s.cos);
document.getElementById('trig-v-tan').textContent = _f(s.tan);
document.getElementById('trig-v-cot').textContent = _f(s.cot);
// Angle badge
document.getElementById('trig-angle-badge').innerHTML =
`${degStr} = ${s.radLabel}<br><span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>`;
// Stats bar (nice fractions)
document.getElementById('trigbar-angle').textContent = degStr;
document.getElementById('trigbar-sin').textContent = _f(s.sin);
document.getElementById('trigbar-cos').textContent = _f(s.cos);
document.getElementById('trigbar-tan').textContent = _f(s.tan);
document.getElementById('trigbar-cot').textContent = _f(s.cot);
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
}
/* ── KaTeX live preview ── */
/** Convert user ascii expression <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> LaTeX string for KaTeX preview */