feat(lab-graph): KaTeX-формулы + панель ввода как редактор формул

«График функции»:
- примеры (чипы) и живой предпросмотр каждой функции рендерятся в KaTeX
  (data-tex на чипах, _initGraphPanel рендерит при открытии)
- предпросмотр теперь всегда виден, крупный и в цвет функции; пустое поле
  показывает плейсхолдер-формулу приглушённо
- НОВОЕ: keypad вставки структур (x², xⁿ, √, a/b, |x|, π, sin/cos/tg/ln/eˣ, ())
  — клик вставляет в активное поле по каретке (как редактор формул в PowerPoint)
- graphInsert(token) с маркером каретки |; активное поле отслеживается по focus

Только фронт. Проверено: node --check, логика вставки 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 18:56:31 +03:00
parent ce4f1dcec1
commit 000e42f9b3
3 changed files with 97 additions and 29 deletions
+42 -4
View File
@@ -507,6 +507,7 @@ class GraphSim {
document.getElementById('sim-topbar-title').textContent = 'График функции';
_simShow('sim-graph');
_simShow('ctrl-graph');
_initGraphPanel();
_registerSimState('graph',
() => ({
@@ -524,6 +525,7 @@ class GraphSim {
if (_embedMode) _startStateEmit('graph');
requestAnimationFrame(() => requestAnimationFrame(() => {
_initGraphPanel(); // KaTeX к этому моменту точно загружен
if (!gSim) {
gSim = new GraphSim(document.getElementById('graph-canvas'));
gSim.onHover = updateInfoBar;
@@ -575,16 +577,52 @@ class GraphSim {
function renderPreview(idx) {
const inp = document.getElementById('fn' + idx);
const prev = document.getElementById('fn' + idx + '-prev');
if (!prev) return;
const raw = inp?.value?.trim() || '';
if (!raw || typeof katex === 'undefined') {
prev.innerHTML = ''; prev.classList.remove('has-content'); return;
const src = raw || (inp?.getAttribute('placeholder') || ''); // пусто → показываем плейсхолдер-формулу
if (!src || typeof katex === 'undefined') {
prev.innerHTML = ''; prev.classList.remove('has-content', 'is-ph'); return;
}
try {
prev.innerHTML = katex.renderToString(toLatex(raw), {
prev.innerHTML = katex.renderToString(toLatex(src), {
throwOnError: false, strict: false, displayMode: false,
});
prev.classList.add('has-content');
} catch { prev.innerHTML = ''; prev.classList.remove('has-content'); }
prev.classList.toggle('is-ph', !raw); // плейсхолдер — приглушённо
} catch { prev.innerHTML = ''; prev.classList.remove('has-content', 'is-ph'); }
}
/* Вставка структуры формулы в активное поле (как редактор формул).
В токене символ | помечает позицию каретки, напр. 'sin(|)'. */
let _activeFnIdx = 0, _graphPanelInit = false;
function graphInsert(token) {
const el = document.getElementById('fn' + _activeFnIdx) || document.getElementById('fn0');
if (!el) return;
el.focus();
let ins = String(token || ''); let caretInTok = -1;
const bar = ins.indexOf('|');
if (bar >= 0) { caretInTok = bar; ins = ins.slice(0, bar) + ins.slice(bar + 1); }
const s = (el.selectionStart != null) ? el.selectionStart : el.value.length;
const e = (el.selectionEnd != null) ? el.selectionEnd : el.value.length;
el.value = el.value.slice(0, s) + ins + el.value.slice(e);
const pos = s + (caretInTok >= 0 ? caretInTok : ins.length);
try { el.setSelectionRange(pos, pos); } catch (_) {}
updateFn(_activeFnIdx);
}
/* Отрисовать KaTeX на чипах/клавиатуре + следить за активным полем (идемпотентно). */
function _initGraphPanel() {
const root = document.getElementById('sim-graph');
if (!root || typeof katex === 'undefined') return;
root.querySelectorAll('.preset-btn[data-tex], .kp-btn[data-tex]').forEach(b => {
if (b.dataset.rendered) return;
try { b.innerHTML = katex.renderToString(b.dataset.tex, { throwOnError: false, strict: false, displayMode: false }); b.dataset.rendered = '1'; } catch (_) {}
});
if (!_graphPanelInit) {
_graphPanelInit = true;
[0, 1, 2].forEach(i => { const el = document.getElementById('fn' + i); if (el) el.addEventListener('focus', () => { _activeFnIdx = i; }); });
}
[0, 1, 2].forEach(renderPreview);
}
/* debounced formula update */