'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 */