feat(trigcircle): Фаза 6 — простейшие тригонометрические уравнения

Режим уравнения fn(x)=a (sin/cos/tg): окружность подсвечивает ВСЕ решения на [0,2π)
(точки + направляющая линия значения), а панель показывает общую формулу через KaTeX:
  sin x=a → x=(-1)ⁿ·arcsin a + πn;  cos x=a → x=±arccos a + 2πn;  tg x=a → x=arctg a + πn.
Для табличных значений главное значение подставляется точно (arcsin½=π/6 и т.п.), для
нетабличных — символьно (\arcsin a). |a|>1 для sin/cos → «нет решений». Список решений
в градусах. setEquation встаёт на первое решение; clearEquation выходит из режима.

Аддитивно: новое поле this.eq + методы setEquation/clearEquation/_drawEquation + хук в draw();
glue trigSetEqFn/trigSolve/trigClearEq/trigEqKey; секция «Уравнение» в панели labs-bodies.

Verified: node --check; headless-смоук 13/13 (решения sin/cos/tg/один/нет; формулы
(-1)ⁿ/±/+πn/none/нетабличное→arcsin) + изолированная отрисовка _drawEquation без throw.
Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 10:41:07 +03:00
parent cefb5e0836
commit dfa0535b63
2 changed files with 128 additions and 0 deletions
+111
View File
@@ -53,6 +53,7 @@ class TrigCircleSim {
this.graphFn = 'sin';
this.snapToNotable = true;
this.animating = false;
this.eq = null; // режим уравнения: { fn:'sin'|'cos'|'tg', a:Number, sols:[рад] } | null
this._cx = 0; this._cy = 0; this._r = 0;
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
@@ -96,6 +97,7 @@ class TrigCircleSim {
this._drawBg(c);
this._drawCircle(c);
if (this.eq) this._drawEquation(c);
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
this._drawParticles(c);
if (window.LabFX) LabFX.particles.draw(c);
@@ -116,6 +118,41 @@ class TrigCircleSim {
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();
}
goToAngle(rad) {
this._animTarget = this._norm(rad);
if (!this.animating) this._startAnim();
@@ -1058,6 +1095,80 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
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(); }
function _trigUpdateUI(s) {
const _f = v => {
if (v === undefined) return '—';
+17
View File
@@ -582,6 +582,23 @@
<div class="gp-section-title" style="margin-bottom:8px">Точные значения · приведение</div>
<div id="trig-formula" style="margin-bottom:14px;font-size:0.78rem;color:var(--text);background:rgba(155,93,229,0.06);border:1px solid rgba(155,93,229,0.15);border-radius:10px;padding:9px 11px"></div>
<!-- Equation solver: fn(x) = a -->
<div class="gp-section-title" style="margin-bottom:8px">Уравнение</div>
<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;flex-wrap:wrap">
<button class="trig-eq-fn trig-fn-btn active" onclick="trigSetEqFn('sin',this)" style="--fc:#EF476F">sin</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('cos',this)" style="--fc:#06D6E0">cos</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('tg',this)" style="--fc:#FFD166">tg</button>
<span style="color:var(--text-3);font-size:0.82rem;font-weight:700">x =</span>
<input id="trig-eq-input" type="number" step="0.1" placeholder="a" onkeydown="trigEqKey(event)"
style="width:58px;padding:6px 8px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="preset-btn" style="flex:1" onclick="trigSolve()">Решить</button>
<button class="preset-btn" style="flex:1" onclick="trigClearEq()">Сброс</button>
</div>
<div id="trig-eq-formula" style="font-size:0.82rem;color:var(--text);margin-bottom:4px;line-height:1.7"></div>
<div id="trig-eq-sols" style="font-size:0.72rem;color:var(--text-3);margin-bottom:14px"></div>
<!-- Notable angles -->
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">