feat(trigcircle): KaTeX-оверлей для подписей на canvas (координаты, значения, угол)

На <canvas> KaTeX не рисуется (fillText), поэтому подписи, которые были юникод-текстом
(√2/2, координаты точки, π/4, значение на графике), переведены на HTML-оверлей #trig-overlay
поверх холста с KaTeX-рендером и точным позиционированием (transform по CSS-px = координаты
canvas). Переведены: координатная подсказка (cos; sin), бейджи значений sin/cos, метка угла
у дуги, бейдж значения на графике. Подписи-слова sin/cos/tg/ctg и мелкие точки табличных
углов остаются на canvas (не математика / 16 мелких меток).

Механика: _ov/_ovLabel/_ovClearUnused — кэш по ключу (ре-рендер только при смене LaTeX),
KaTeX лишь для дробей/корней, простые числа — текстом (быстро при перетаскивании), неис-
пользованные за кадр подписи прячутся. Старые canvas-методы _badge/_tooltip больше не зовутся.

Verified: node --check; headless-смоук оверлея 12/12 (coord/vsin/vcos/angle/gval создаются,
KaTeX-LaTeX для √2/2 и π/4, позиционирование/плашка, десятичные как текст, скрытие при
выкл. слоя/графика). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 11:03:39 +03:00
parent e70bf819ce
commit 1707a510a9
2 changed files with 60 additions and 21 deletions
+57 -20
View File
@@ -105,6 +105,7 @@ class TrigCircleSim {
if (window.LabFX) LabFX.particles.draw(c);
c.restore();
this._ovClearUnused();
this._fireUpdate();
}
@@ -171,6 +172,51 @@ class TrigCircleSim {
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) {
const useK = /\\tfrac|\\sqrt|\\left|\\frac/.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();
@@ -354,11 +400,10 @@ class TrigCircleSim {
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));
/* 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 ── */
@@ -452,9 +497,9 @@ class TrigCircleSim {
/* ── axis value badges ── */
if (this.showSin && Math.abs(sinA) > 0.04)
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle');
this._ovLabel('vsin', _latexVal(sinA), cx - 14, py, _TC.sin, 'r');
if (this.showCos && Math.abs(cosA) > 0.04)
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top');
this._ovLabel('vcos', _latexVal(cosA), projX, cy + 20, _TC.cos, 't');
/* ── main point ── */
const ps = this._hover || this._drag ? 10 : 8;
@@ -470,8 +515,9 @@ class TrigCircleSim {
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);
/* ── coordinate tooltip (KaTeX overlay) ── */
this._ovLabel('coord', `\\left(${_latexVal(cosA)};\\ ${_latexVal(sinA)}\\right)`,
px + (cosA >= 0 ? 18 : -18), py + (sinA >= 0 ? -20 : 20), '#fff', cosA >= 0 ? 'l' : 'r', true);
/* ── quadrant roman numeral ── */
const qOff = r * 0.46;
@@ -751,17 +797,8 @@ class TrigCircleSim {
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);
/* value badge (KaTeX overlay) */
this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
}
c.restore();
+3 -1
View File
@@ -655,8 +655,10 @@
</div><!-- /.proj-panel -->
<!-- canvas -->
<div class="proj-canvas-outer">
<div class="proj-canvas-outer" style="position:relative">
<canvas id="trigcircle-canvas"></canvas>
<!-- KaTeX overlay: подписи значений/координат/угла над canvas -->
<div id="trig-overlay" style="position:absolute;inset:0;pointer-events:none;overflow:hidden;font-size:0.82rem"></div>
</div>
</div><!-- /.sim-body-wrap -->