'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 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}
${s.angle.toFixed(4)} рад`; // 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 LaTeX string for KaTeX preview */