Files
Learn_System/frontend/js/labs/trigcircle.js
T
Maxim Dolgolyov 4c1ce8394c feat(trigcircle): шкала значений по оси Y на графике (координатная плоскость)
- y-ось графика теперь подписана значениями (KaTeX):
  sin/cos — 1, ½, 0, −½, −1; tg/ctg — 3, 2, 1, 0, −1, −2, −3
- пунктирные линии уровней + подписи слева от панели
- подписи прячутся при смене функции (лишние уровни tg/ctg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:44:29 +03:00

1420 lines
61 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.eq = null; // режим уравнения: { fn:'sin'|'cos'|'tg', a:Number, sols:[рад] } | null
this.showParity = 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.eq) this._drawEquation(c);
if (this.showParity) this._drawParity(c);
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
this._drawParticles(c);
if (window.LabFX) LabFX.particles.draw(c);
c.restore();
this._ovClearUnused();
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();
}
/* Режим уравнения: подсветить на окружности все решения fn(x)=a. */
setEquation(fn, a, sols) {
this.eq = { fn, a, sols: sols || [] };
if (this.eq.sols.length) this.angle = this.eq.sols[0]; // встать на первое решение
this.draw();
}
clearEquation() { this.eq = null; this.draw(); }
_drawEquation(c) {
const cx = this._cx, cy = this._cy, r = this._r;
const { fn, a, sols } = this.eq;
const accent = fn === 'sin' ? _TC.sin : fn === 'cos' ? _TC.cos : _TC.tan;
c.save();
/* направляющая линия значения */
c.strokeStyle = _tcRgba(accent, 0.55); c.lineWidth = 1.5; c.setLineDash([6, 5]);
c.beginPath();
if (fn === 'sin') { const y = cy - r * a; c.moveTo(cx - r - 22, y); c.lineTo(cx + r + 22, y); }
else if (fn === 'cos') { const x = cx + r * a; c.moveTo(x, cy - r - 22); c.lineTo(x, cy + r + 22); }
else { const ang = sols.length ? sols[0] : Math.atan(a); const dx = Math.cos(ang), dy = Math.sin(ang), L = r + 24;
c.moveTo(cx - L * dx, cy + L * dy); c.lineTo(cx + L * dx, cy - L * dy); }
c.stroke(); c.setLineDash([]);
/* точки-решения + подписи градусов */
c.font = 'bold 11px Manrope,sans-serif';
sols.forEach(ang => {
const x = cx + r * Math.cos(ang), y = cy - r * Math.sin(ang);
c.fillStyle = accent; c.shadowColor = accent; c.shadowBlur = 12;
c.beginPath(); c.arc(x, y, 6, 0, Math.PI * 2); c.fill(); c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.92)'; c.beginPath(); c.arc(x, y, 2.2, 0, Math.PI * 2); c.fill();
const lr = r + 18, lx = cx + lr * Math.cos(ang), ly = cy - lr * Math.sin(ang);
c.fillStyle = accent; c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(Math.round(ang * 180 / Math.PI) + '°', lx, ly);
});
c.restore();
}
/* Зеркальная точка −α (отражение через ось Ox): наглядно чётность cos и нечётность sin. */
_drawParity(c) {
const cx = this._cx, cy = this._cy, r = this._r, a = this.angle;
const px = cx + r * Math.cos(a), py = cy - r * Math.sin(a);
const mx = cx + r * Math.cos(-a), my = cy - r * Math.sin(-a);
c.save();
c.strokeStyle = _tcRgba(_TC.violet, 0.4); c.setLineDash([4, 4]); c.lineWidth = 1;
c.beginPath(); c.moveTo(px, py); c.lineTo(mx, my); c.stroke(); c.setLineDash([]);
c.strokeStyle = _TC.violet; c.lineWidth = 2; c.fillStyle = 'rgba(155,93,229,0.15)';
c.beginPath(); c.arc(mx, my, 6, 0, Math.PI * 2); c.fill(); c.stroke();
c.font = 'bold 11px Manrope,sans-serif'; c.fillStyle = _TC.violet;
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText('-α', mx + (Math.cos(-a) >= 0 ? 14 : -14), my);
c.restore();
}
/* ═══ KaTeX-оверлей: HTML-подписи поверх canvas (на canvas KaTeX не рисуется) ══════ */
_ov() {
if (this._ovEl === undefined) this._ovEl = (typeof document !== 'undefined' && document.getElementById) ? document.getElementById('trig-overlay') : null;
return this._ovEl;
}
/* key — стабильный id подписи; latex — LaTeX (дробь/корень → KaTeX, иначе текст);
x,y — CSS-px над canvas; anchor: c|l|r|t|b; boxed — тёмная плашка (для координат). */
_ovLabel(key, latex, x, y, color, anchor, boxed) {
const ov = this._ov(); if (!ov) return;
this._ovMap = this._ovMap || {};
this._ovUsed = this._ovUsed || {};
let rec = this._ovMap[key];
if (!rec) {
const el = document.createElement('div');
el.style.position = 'absolute'; el.style.whiteSpace = 'nowrap'; el.style.pointerEvents = 'none';
el.style.willChange = 'transform';
ov.appendChild(el);
rec = this._ovMap[key] = { el, last: null, boxed: null };
}
if (rec.last !== latex) {
// Любая LaTeX-команда (\pi, \tfrac, \sin…) → KaTeX; простой текст/число — быстро текстом.
const useK = /\\/.test(latex) && (typeof window !== 'undefined' && window.katex);
if (useK) rec.el.innerHTML = window.katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false });
else rec.el.textContent = latex;
rec.last = latex;
}
if (rec.boxed !== !!boxed) {
rec.el.style.cssText += boxed
? ';background:rgba(12,12,22,0.82);border:1px solid rgba(155,93,229,0.3);border-radius:8px;padding:3px 9px'
: ';background:none;border:none;padding:0';
rec.boxed = !!boxed;
}
rec.el.style.color = color || '#fff';
const a = anchor || 'c';
const tr = a === 'r' ? 'translate(-100%,-50%)' : a === 'l' ? 'translate(0,-50%)'
: a === 't' ? 'translate(-50%,0)' : a === 'b' ? 'translate(-50%,-100%)' : 'translate(-50%,-50%)';
rec.el.style.transform = `translate(${x}px,${y}px) ${tr}`;
rec.el.style.display = '';
this._ovUsed[key] = true;
}
_ovClearUnused() {
if (!this._ovMap) return;
for (const k in this._ovMap) if (!(this._ovUsed && this._ovUsed[k])) this._ovMap[k].el.style.display = 'none';
this._ovUsed = {};
}
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;
// Опорный (острый) угол — к ближайшей оси Ox: основа формул приведения.
let ref;
if (a <= Math.PI / 2) ref = a;
else if (a <= Math.PI) ref = Math.PI - a;
else if (a <= 3 * Math.PI/2) ref = a - Math.PI;
else ref = 2 * Math.PI - a;
return {
angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q,
refAngle: ref, refDeg: ref * 180 / Math.PI,
};
}
/* ═══ 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 (KaTeX overlay: π-доля для табличных, иначе текст) */
const mid = a / 2, lr = ar + 20;
this._ovLabel('angle', _angleLatex(a) || this._radLbl(a),
cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid), _TC.violet, 'c');
}
/* ── 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._ovLabel('vsin', _latexVal(sinA), cx - 14, py, _TC.sin, 'r');
if (this.showCos && Math.abs(cosA) > 0.04)
this._ovLabel('vcos', _latexVal(cosA), projX, cy + 20, _TC.cos, 't');
/* ── 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 (KaTeX overlay) — выносим РАДИАЛЬНО НАРУЖУ за точку,
чтобы не перекрывать центральную дугу угла и её подпись ── */
const _odx = Math.cos(a), _ody = -Math.sin(a);
this._ovLabel('coord', `\\left(${_latexVal(cosA)};\\ ${_latexVal(sinA)}\\right)`,
px + _odx * 20 + (cosA >= 0 ? 6 : -6), py + _ody * 20 + (sinA >= 0 ? -8 : 8),
'#fff', cosA >= 0 ? 'l' : 'r', true);
/* ── 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 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();
}
/* ── шкала значений по оси Y (значения на координатной плоскости) ── */
const yVals = (fn==='tan'||fn==='cot')
? [[3,'3'],[2,'2'],[1,'1'],[0,'0'],[-1,'-1'],[-2,'-2'],[-3,'-3']]
: [[1,'1'],[0.5,'\\tfrac{1}{2}'],[0,'0'],[-0.5,'-\\tfrac{1}{2}'],[-1,'-1']];
yVals.forEach(([v, lx], i) => {
const yy = sy(v);
if (yy < gy + 6 || yy > gy + gh - 6) return;
if (v !== 0) {
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.lineWidth = 1; c.setLineDash([4, 4]);
c.beginPath(); c.moveTo(gx, yy); c.lineTo(gx+gw, yy); c.stroke(); c.setLineDash([]);
}
this._ovLabel('gy' + i, lx, gx - 6, yy, 'rgba(255,255,255,0.55)', 'r');
});
/* x ticks — линии на canvas, подписи KaTeX-оверлеем */
const ticks = [[0, '0'], [Math.PI/2, '\\tfrac{\\pi}{2}'], [Math.PI, '\\pi'],
[3*Math.PI/2, '\\tfrac{3\\pi}{2}'], [2*Math.PI, '2\\pi']];
ticks.forEach(([v, lx], i) => {
const xx = sx(v);
if (xx < gx+6 || xx > gx+gw-6) return;
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([]);
this._ovLabel('gtick' + i, lx, xx, gy + gh + 9, 'rgba(255,255,255,0.55)', 't');
});
/* ── 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();
/* ── развёртка: ярче выделяем кривую на [0, α] — как угол «разворачивается» в график ── */
{
const aMax = Math.min(Math.max(this.angle, 0), xMax);
c.strokeStyle = col; c.lineWidth = 4.5; c.lineCap = 'round'; c.lineJoin = 'round';
c.shadowColor = col; c.shadowBlur = 6;
c.beginPath(); let onS = false;
for (let x = 0; x <= aMax + 1e-9; x += step) {
const yv = evFn(x);
if (!isFinite(yv) || Math.abs(yv) > yR * 2) { onS = false; continue; }
const spx = sx(x), spy = sy(yv);
if (!onS) { c.moveTo(spx, spy); onS = true; } else c.lineTo(spx, spy);
}
c.stroke(); c.shadowBlur = 0;
}
/* ── 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 (KaTeX overlay) */
this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
/* подпись угла на оси X (развёртка: где текущий угол на графике) */
this._ovLabel('gangle', _angleLatex(this.angle) || this._radLbl(this.angle),
mx, gy + 5, _TC.violet, 't', true);
}
c.restore();
/* fn name badge (KaTeX-оверлей) */
const _glblTex = fn === 'sin' ? 'y = \\sin x'
: fn === 'cos' ? 'y = \\cos x'
: fn === 'tan' ? 'y = \\operatorname{tg} x'
: 'y = \\operatorname{ctg} x';
this._ovLabel('glabel', _glblTex, gx + 16, gy + 21, col, 'l', true);
}
/* ═══ 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');
}
/* Ввод угла в градусах (поле + Enter/кнопка). Принимает любое число (включая <0 и >360),
goToAngle нормализует — заодно демонстрирует котерминальность. */
function trigSetAngleDeg(inp) {
if (!trigSim || !inp) return;
const v = parseFloat(String(inp.value || '').replace(',', '.'));
if (!isFinite(v)) return;
trigSim.goToAngle(v * Math.PI / 180);
}
function trigAngleKey(e, inp) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSetAngleDeg(inp); }
/* Показать/скрыть график функций (тема «функции» — по умолчанию можно убрать,
круг займёт всю ширину). Переиспользует существующий слой 'graph'. */
function trigToggleGraph(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.toggleLayer('graph', on);
const fns = document.getElementById('trig-graph-fns');
if (fns) fns.style.display = on ? '' : 'none';
}
/* ── Уравнения: решения fn(x)=a на [0,2π) ── */
function _trigSolveAngles(fn, a) {
const TAU = 2 * Math.PI, norm = x => ((x % TAU) + TAU) % TAU;
let raw;
if (fn === 'sin') { if (Math.abs(a) > 1) return []; const b = Math.asin(a); raw = [b, Math.PI - b]; }
else if (fn === 'cos') { if (Math.abs(a) > 1) return []; const b = Math.acos(a); raw = [b, -b]; }
else { const b = Math.atan(a); raw = [b, b + Math.PI]; } // tg — всегда есть решения
const out = [];
raw.map(norm).forEach(x => { if (!out.some(y => Math.abs(y - x) < 1e-6 || Math.abs(y - x - TAU) < 1e-6)) out.push(x); });
return out.sort((p, q) => p - q);
}
/* Радиан → LaTeX красивой π-доли (или null). Покрывает главные значения arcsin/arccos/arctg. */
function _radLatex(rad) {
const P = Math.PI;
const T = [[0, '0'], [P/6, '\\tfrac{\\pi}{6}'], [P/4, '\\tfrac{\\pi}{4}'], [P/3, '\\tfrac{\\pi}{3}'],
[P/2, '\\tfrac{\\pi}{2}'], [2*P/3, '\\tfrac{2\\pi}{3}'], [3*P/4, '\\tfrac{3\\pi}{4}'],
[5*P/6, '\\tfrac{5\\pi}{6}'], [P, '\\pi']];
for (const [v, l] of T) {
if (Math.abs(rad - v) < 1e-6) return l;
if (v > 0 && Math.abs(rad + v) < 1e-6) return '-' + l;
}
return null;
}
/* Общая формула решения (LaTeX) или {none:true}. */
function _trigEqFormulaLatex(fn, a) {
if ((fn === 'sin' || fn === 'cos') && Math.abs(a) > 1) return { none: true };
if (fn === 'sin') {
const p = _radLatex(Math.asin(a)) || ('\\arcsin ' + _latexVal(a));
return { latex: `x = (-1)^{n}\\,${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
if (fn === 'cos') {
const p = _radLatex(Math.acos(a)) || ('\\arccos ' + _latexVal(a));
return { latex: `x = \\pm ${p} + 2\\pi n,\\ n\\in\\mathbb{Z}` };
}
const p = _radLatex(Math.atan(a)) || ('\\operatorname{arctg} ' + _latexVal(a));
return { latex: `x = ${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
var trigEqFn = 'sin';
function trigSetEqFn(fn, btn) {
trigEqFn = fn;
document.querySelectorAll('.trig-eq-fn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function trigSolve() {
if (!trigSim) return;
const inp = document.getElementById('trig-eq-input');
const a = parseFloat(String(inp && inp.value || '').replace(',', '.'));
const fnTex = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}' }[trigEqFn];
const fEl = document.getElementById('trig-eq-formula');
const sEl = document.getElementById('trig-eq-sols');
if (!isFinite(a)) { if (fEl) fEl.innerHTML = '<span style="color:var(--text-3)">Введите значение a</span>'; if (sEl) sEl.textContent = ''; return; }
const sols = _trigSolveAngles(trigEqFn, a);
trigSim.setEquation(trigEqFn, a, sols);
const K = window.katex;
const tex = l => (K ? K.renderToString(l, { throwOnError: false, strict: false, displayMode: false }) : l);
const eqHead = tex(`${fnTex} x = ${_latexVal(a)}`);
const f = _trigEqFormulaLatex(trigEqFn, a);
if (fEl) {
fEl.innerHTML = `<div style="margin-bottom:5px;color:var(--violet)">${eqHead}</div>` +
(f.none ? '<div style="color:#EF476F">Нет решений (|a| > 1)</div>' : `<div>${tex(f.latex)}</div>`);
}
if (sEl) sEl.textContent = sols.length
? 'На [0, 2π): ' + sols.map(x => Math.round(x * 180 / Math.PI) + '°').join(', ')
: '';
}
function trigClearEq() {
if (!trigSim) return;
trigSim.clearEquation();
const fEl = document.getElementById('trig-eq-formula'); if (fEl) fEl.innerHTML = '';
const sEl = document.getElementById('trig-eq-sols'); if (sEl) sEl.textContent = '';
}
function trigEqKey(e) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSolve(); }
/* ── Таблица значений (первая четверть) — строится один раз, KaTeX ── */
function _trigBuildValueTable() {
const el = document.getElementById('trig-table');
if (!el || el.dataset.built) return;
const cols = [['sin', '#EF476F'], ['cos', '#06D6E0'], ['tg', '#FFD166'], ['ctg', '#7BF5A4']];
const head = '<tr><th style="text-align:left;padding:2px 4px;color:var(--text-3);font-weight:700">α</th>' +
cols.map(([n, c]) => `<th style="padding:2px 4px;color:${c};font-weight:700">${n}</th>`).join('') + '</tr>';
const body = [0, 30, 45, 60, 90].map(deg => {
const a = deg * Math.PI / 180, sn = Math.sin(a), cs = Math.cos(a);
const tn = Math.abs(cs) > 1e-9 ? sn / cs : undefined;
const ct = Math.abs(sn) > 1e-9 ? cs / sn : undefined;
const cell = v => `<td style="padding:3px 4px;text-align:center">${_tex(_latexVal(v))}</td>`;
return `<tr data-deg="${deg}"><td style="padding:3px 4px;font-weight:700">${deg}°</td>${cell(sn)}${cell(cs)}${cell(tn)}${cell(ct)}</tr>`;
}).join('');
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:0.74rem">${head}${body}</table>`;
el.dataset.built = '1';
}
function trigToggleTable(rowEl) {
const on = rowEl.classList.toggle('active');
const el = document.getElementById('trig-table');
if (!el) return;
if (on) { _trigBuildValueTable(); el.style.display = ''; if (trigSim) _trigUpdateUI(trigSim.stats()); }
else el.style.display = 'none';
}
/* ── Чётность/нечётность + периоды (статический KaTeX-блок, строится один раз) ── */
function trigToggleParity(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.showParity = on;
trigSim.draw();
const pEl = document.getElementById('trig-parity');
if (!pEl) return;
pEl.style.display = on ? '' : 'none';
if (on && !pEl.dataset.built) {
pEl.innerHTML =
`<div>${_tex('\\sin(-\\alpha) = -\\sin\\alpha')}</div>` +
`<div>${_tex('\\cos(-\\alpha) = \\cos\\alpha')}</div>` +
`<div>${_tex('\\operatorname{tg}(-\\alpha) = -\\operatorname{tg}\\alpha')}</div>` +
`<div style="margin-top:6px;color:var(--text-3);font-size:0.7rem">${_tex('T_{\\sin}=T_{\\cos}=2\\pi,\\quad T_{\\operatorname{tg}}=T_{\\operatorname{ctg}}=\\pi')}</div>`;
pEl.dataset.built = '1';
}
}
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) + '°';
// Значения — KaTeX для дробей/корней, текст для простых чисел (быстро при перетаскивании).
const setMathVal = (id, v) => {
const el = document.getElementById(id); if (!el) return;
const lx = _latexVal(v);
if (/\\tfrac|\\sqrt|\\text/.test(lx)) el.innerHTML = _tex(lx);
else el.textContent = lx;
};
setMathVal('trig-v-sin', s.sin);
setMathVal('trig-v-cos', s.cos);
setMathVal('trig-v-tan', s.tan);
setMathVal('trig-v-cot', s.cot);
// Угол: KaTeX (град = π-доля) + радианы + котерминальные (+360°·k)
const al = _angleLatex(s.angle);
const head = al ? `${Math.round(s.deg)}^\\circ = ${al}` : `${degStr}`;
document.getElementById('trig-angle-badge').innerHTML =
`<div>${_tex(head)}</div>` +
`<span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>` +
`<br><span style="font-size:0.68rem;opacity:0.5">+ 360°·k (котерминальные)</span>`;
// Опорный (острый) угол — guarded (панель может не иметь элемента)
const refEl = document.getElementById('trig-ref');
if (refEl) refEl.textContent = (Math.round(s.refDeg * 10) / 10) + '°';
// Знаки функций в текущей четверти
const signsEl = document.getElementById('trig-signs');
if (signsEl) {
const sg = v => (v > 1e-9 ? '+' : v < -1e-9 ? '' : '0');
signsEl.innerHTML =
`<b style="color:#EF476F">sin ${sg(s.sin)}</b> · <b style="color:#06D6E0">cos ${sg(s.cos)}</b> · ` +
`<b style="color:#FFD166">tg ${s.tan === undefined ? '—' : sg(s.tan)}</b>`;
}
// Точные значения + формула приведения (только для табличных углов)
const fEl = document.getElementById('trig-formula');
if (fEl) {
const beta = Math.round(s.refDeg);
const degR = Math.round(s.deg);
const isTable = [0, 30, 45, 60, 90].some(b => Math.abs(s.refDeg - b) < 0.5);
if (!isTable) {
fEl.innerHTML = '<span style="color:var(--text-3);font-size:0.72rem;line-height:1.5">Нетабличный угол — точных значений нет, см. приближённые выше.</span>';
} else {
const reduce = (s.quadrant !== 1) && (beta === 30 || beta === 45 || beta === 60);
const K = window.katex;
const tex = latex => (K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex);
const FN = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}', ctg: '\\operatorname{ctg}' };
let html = '';
if (reduce) {
const wrap = s.quadrant === 2 ? `180^\\circ - ${beta}^\\circ`
: s.quadrant === 3 ? `180^\\circ + ${beta}^\\circ`
: `360^\\circ - ${beta}^\\circ`;
html += `<div style="color:var(--violet);margin-bottom:6px">${tex(`${degR}^\\circ = ${wrap}`)}</div>`;
}
const line = (nm, color, val) => {
const sgn = (val !== undefined && val < -1e-9) ? '-' : '';
const mid = reduce ? ` = ${sgn}${FN[nm]}\\,${beta}^\\circ` : '';
// KaTeX наследует CSS-цвет родителя → красим div, формулу не трогаем.
return `<div style="color:${color};line-height:1.95">${tex(`${FN[nm]}\\,${degR}^\\circ${mid} = ${_latexVal(val)}`)}</div>`;
};
fEl.innerHTML = html + line('sin', '#EF476F', s.sin) + line('cos', '#06D6E0', s.cos) +
line('tg', '#FFD166', s.tan) + line('ctg', '#7BF5A4', s.cot);
}
}
// Подсветка строки таблицы значений (по опорному острому углу)
const tbl = document.getElementById('trig-table');
if (tbl && tbl.dataset.built && typeof tbl.querySelectorAll === 'function') {
const beta = Math.round(s.refDeg);
tbl.querySelectorAll('tr[data-deg]').forEach(tr => {
tr.style.background = (Number(tr.dataset.deg) === beta) ? 'rgba(155,93,229,0.18)' : '';
});
}
// Stats bar — значения тоже KaTeX (дроби/корни)
document.getElementById('trigbar-angle').textContent = degStr;
setMathVal('trigbar-sin', s.sin);
setMathVal('trigbar-cos', s.cos);
setMathVal('trigbar-tan', s.tan);
setMathVal('trigbar-cot', s.cot);
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
}
/* Точное значение → LaTeX (зеркалит _f, но для KaTeX). undefined → «—». */
function _latexVal(v) {
if (v === undefined) return '\\text{не опр.}';
const a = Math.abs(v), sg = v < -1e-9 ? '-' : '';
if (a < 5e-4) return '0';
if (Math.abs(a - 0.5) < 1e-3) return sg + '\\tfrac{1}{2}';
if (Math.abs(a - Math.SQRT2 / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{2}}{2}';
if (Math.abs(a - Math.sqrt(3) / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{2}';
if (Math.abs(a - Math.sqrt(3) / 3) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{3}';
if (Math.abs(a - 1) < 1e-3) return sg + '1';
if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '\\sqrt{3}';
return v.toFixed(3);
}
/* Рендер LaTeX → HTML через KaTeX (с фолбэком на сырой LaTeX, если katex ещё не готов). */
function _tex(latex) {
const K = window.katex;
return K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex;
}
/* Юникод-метка π-доли ('7π/6','π/4','π','0') → LaTeX. */
function _piLabelToLatex(l) {
if (l === '0') return '0';
const conv = s => s.replace('π', '\\pi');
if (l.indexOf('/') >= 0) { const p = l.split('/'); return `\\tfrac{${conv(p[0])}}{${p[1]}}`; }
return conv(l);
}
/* Радиан текущего угла → LaTeX красивой π-доли по таблице 16 углов (или null). */
function _angleLatex(rad) {
for (const n of _TC_NOTABLE) if (Math.abs(rad - n.a) < 1e-6) return _piLabelToLatex(n.l);
return null;
}
/* ── 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 */