feat(lab-graph): удобство и красота — скрытие функций, точки, контролы вида, пинч

«График функции», большой апгрейд UX:
- у каждой функции кнопки «глаз» (скрыть/показать, не удаляя) и «очистить»;
  скрытая — приглушена и зачёркнута, исключается из графика/hover/значений
- плавающие контролы вида поверх canvas: зум +/−, сброс вида, тумблер «Особые точки»
- ОСОБЫЕ ТОЧКИ: нули функций, y-перехваты и пересечения кривых — ringed-точки
  с подписью координат (бисекция по смене знака; правка: точные нули на узлах
  сетки больше не теряются; дедуп; подписи скрываются при «частоколе» >22 точек)
- пинч-зум двумя пальцами к центру жеста (к 1-пальцевой панораме)

Движок: setHidden/setShowPoints/_drawPoints/_findZeros/_visible; hover и
инфобар уважают скрытие. Только фронт. node --check OK; zero-finder 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 19:11:21 +03:00
parent fa29332bcd
commit cd0ce17a60
3 changed files with 174 additions and 14 deletions
+27
View File
@@ -459,6 +459,33 @@
}
#graph-canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/* плавающие контролы вида поверх canvas */
.graph-view-ctrls {
position: absolute; top: 12px; right: 12px; z-index: 4;
display: flex; flex-direction: column; gap: 6px;
}
.gv-btn {
width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
border-radius: 10px; border: 1px solid rgba(255,255,255,.12);
background: rgba(13,13,26,.62); color: rgba(255,255,255,.72);
cursor: pointer; transition: all .14s; backdrop-filter: blur(6px);
}
.gv-btn svg { width: 17px; height: 17px; }
.gv-btn:hover { border-color: var(--violet); color: #fff; background: rgba(155,93,229,.32); }
.gv-btn.active { border-color: var(--violet); color: #fff; background: var(--violet); box-shadow: 0 0 12px rgba(155,93,229,.5); }
/* кнопки управления функцией (глаз/очистить) в строке */
.fn-act {
flex-shrink: 0; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
border: none; background: transparent; color: var(--text-3);
border-radius: 8px; cursor: pointer; transition: all .14s; padding: 0;
}
.fn-act svg { width: 15px; height: 15px; }
.fn-act:hover { color: var(--fn-color, var(--violet)); background: rgba(155,93,229,.1); }
.fn-act.off { color: var(--text-3); opacity: .85; }
.fn-row.fn-hidden { opacity: .5; }
.fn-row.fn-hidden .fn-math, .fn-row.fn-hidden .fn-input { text-decoration: line-through; text-decoration-color: rgba(255,255,255,.3); }
/* info bar */
.graph-info-bar {
flex-shrink: 0;
+135 -14
View File
@@ -16,6 +16,8 @@ class GraphSim {
this.oy = 0; // viewport centre y (math units)
this.scl = 50; // px per unit
this.fns = []; // [{ color, fn } | null]
this._hidden = [false, false, false]; // показ/скрытие функции
this.showPts = false; // особые точки (нули/пересечения/y-перехват)
this.hx = null; // hovered x (math) or null
this._dg = null; // drag state
this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null)
@@ -62,6 +64,8 @@ class GraphSim {
resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); }
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
setHidden(idx, v) { this._hidden[idx] = !!v; this.draw(); }
setShowPoints(v) { this.showPts = !!v; this.draw(); }
/* ── formula compiler (CSP-safe: no eval / new Function) ── */
@@ -271,10 +275,14 @@ class GraphSim {
this._drawGrid(c, W, H);
this._drawAxes(c, W, H);
for (const f of this.fns) if (f) this._drawCurve(c, W, H, f);
this.fns.forEach((f, i) => { if (f && !this._hidden[i]) this._drawCurve(c, W, H, f); });
if (this.showPts) this._drawPoints(c, W, H);
if (this.hx !== null) this._drawHover(c, W, H);
}
/* видимые функции (для hover/особых точек) */
_visible() { return this.fns.map((f, i) => (f && !this._hidden[i]) ? f : null); }
/* ── grid ──────────────────────────────────── */
_niceStep() {
@@ -414,21 +422,90 @@ class GraphSim {
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
c.setLineDash([]);
for (const f of this.fns) {
if (!f) continue;
this.fns.forEach((f, i) => {
if (!f || this._hidden[i]) return;
let my;
try { my = f.fn(this.hx); } catch { continue; }
if (!isFinite(my) || isNaN(my)) continue;
try { my = f.fn(this.hx); } catch { return; }
if (!isFinite(my) || isNaN(my)) return;
const [, py] = this._toPx(this.hx, my);
if (py < -20 || py > H + 20) continue;
if (py < -20 || py > H + 20) return;
c.fillStyle = f.color;
c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill();
c.strokeStyle = 'rgba(255,255,255,0.8)';
c.lineWidth = 1.5; c.stroke();
});
}
/* ── особые точки: нули, y-перехват, пересечения ─── */
_findZeros(g, a, b, samples) {
const zeros = []; const dx = (b - a) / samples; const eps = Math.abs(dx) * 0.25;
const push = (r) => { if (!zeros.length || Math.abs(r - zeros[zeros.length - 1]) > eps) zeros.push(r); };
let pmx = a, pv; try { pv = g(a); } catch { pv = NaN; }
if (isFinite(pv) && pv === 0) push(a);
for (let i = 1; i <= samples && zeros.length < 60; i++) {
const mx = a + i * dx;
let v; try { v = g(mx); } catch { v = NaN; }
if (isFinite(pv) && isFinite(v)) {
if (v === 0) { push(mx); } // точный ноль на узле сетки
else if (pv !== 0 && pv * v < 0) { // смена знака — бисекция
let lo = pmx, hi = mx, flo = pv;
for (let k = 0; k < 50; k++) {
const mid = (lo + hi) / 2; let fm; try { fm = g(mid); } catch { fm = NaN; }
if (!isFinite(fm)) { lo = hi = mid; break; }
if (flo * fm <= 0) hi = mid; else { lo = mid; flo = fm; }
}
push((lo + hi) / 2);
}
}
pmx = mx; pv = v;
}
return zeros;
}
_drawPoints(c, W, H) {
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
const samples = Math.min(Math.round(W), 800);
const vis = this._visible();
const pts = []; // { mx, my, color, kind }
vis.forEach(f => {
if (!f) return;
// нули функции
this._findZeros(f.fn, x0, x1, samples).forEach(rx => pts.push({ mx: rx, my: 0, color: f.color, kind: 'root' }));
// y-перехват
if (x0 <= 0 && x1 >= 0) { let v; try { v = f.fn(0); } catch { v = NaN; } if (isFinite(v)) pts.push({ mx: 0, my: v, color: f.color, kind: 'yint' }); }
});
// пересечения пар
for (let i = 0; i < vis.length; i++) for (let j = i + 1; j < vis.length; j++) {
if (!vis[i] || !vis[j]) continue;
const fi = vis[i].fn, fj = vis[j].fn;
this._findZeros(x => fi(x) - fj(x), x0, x1, samples).forEach(ix => {
let v; try { v = fi(ix); } catch { v = NaN; }
if (isFinite(v)) pts.push({ mx: ix, my: v, color: '#ffffff', kind: 'cross' });
});
}
const labels = pts.length <= 22; // не подписываем при «частоколе» (sin на широком диапазоне)
c.font = '600 10.5px Manrope, system-ui, sans-serif';
for (const p of pts) {
const [px, py] = this._toPx(p.mx, p.my);
if (px < -8 || px > W + 8 || py < -8 || py > H + 8) continue;
c.beginPath(); c.arc(px, py, 4.5, 0, 2 * Math.PI);
c.fillStyle = p.color; c.fill();
c.lineWidth = 1.5; c.strokeStyle = '#0D0D1A'; c.stroke();
if (labels) {
const tx = '(' + this._fmtP(p.mx) + '; ' + this._fmtP(p.my) + ')';
c.textAlign = 'left'; c.textBaseline = 'bottom';
const lx = Math.min(px + 7, W - c.measureText(tx).width - 4), ly = Math.max(12, py - 6);
c.fillStyle = 'rgba(13,13,26,0.78)';
const tw = c.measureText(tx).width;
c.fillRect(lx - 3, ly - 12, tw + 6, 14);
c.fillStyle = p.kind === 'cross' ? 'rgba(255,255,255,0.92)' : p.color;
c.fillText(tx, lx, ly);
}
}
}
_fmtP(n) { if (Math.abs(n) < 1e-9) return '0'; const r = Math.round(n * 100) / 100; return Number.isInteger(r) ? String(r) : r.toFixed(2); }
/* ── events ─────────────────────────────────── */
@@ -475,27 +552,41 @@ class GraphSim {
});
cv.style.cursor = 'crosshair';
/* touch drag */
let t0 = null;
/* touch: 1 палец — панорама, 2 пальца — пинч-зум к центру жеста */
let t0 = null, pinch = null;
const tDist = ts => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY);
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1)
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
if (e.touches.length === 1) {
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy }; pinch = null;
} else if (e.touches.length === 2) {
const r = cv.getBoundingClientRect();
pinch = { d: tDist(e.touches), scl: this.scl,
cx: (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left,
cy: (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top };
t0 = null;
}
}, { passive: true });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1 && t0) {
if (e.touches.length === 2 && pinch) {
const [mx, my] = this._toMx(pinch.cx, pinch.cy);
this.scl = Math.max(4, Math.min(800, pinch.scl * (tDist(e.touches) / (pinch.d || 1))));
const [nx, ny] = this._toMx(pinch.cx, pinch.cy);
this.ox -= nx - mx; this.oy -= ny - my;
this.draw();
} else if (e.touches.length === 1 && t0) {
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
this.draw();
}
}, { passive: false });
cv.addEventListener('touchend', () => { t0 = null; });
cv.addEventListener('touchend', e => { if (e.touches.length === 0) { t0 = null; pinch = null; } });
}
_emitHover() {
if (!this.onHover) return;
const vals = this.fns.map(f => {
if (!f) return null;
const vals = this.fns.map((f, i) => {
if (!f || this._hidden[i]) return null;
try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
});
this.onHover(this.hx, vals);
@@ -697,6 +788,36 @@ class GraphSim {
}
}
/* ── per-function controls + view controls ── */
const _EYE_ON = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>';
const _EYE_OFF = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.9 4.2A10 10 0 0 1 12 4c6.5 0 10 7 10 7a17 17 0 0 1-3 3.7M6.6 6.6A17 17 0 0 0 2 11s3.5 7 10 7a10 10 0 0 0 4.1-.9"/><line x1="3" y1="3" x2="21" y2="21"/></svg>';
function toggleFn(idx) {
if (!gSim) return;
const hidden = !gSim._hidden[idx];
gSim.setHidden(idx, hidden);
const row = document.getElementById('fn' + idx)?.closest('.fn-row');
if (row) row.classList.toggle('fn-hidden', hidden);
const eye = document.getElementById('fn' + idx + '-eye');
if (eye) { eye.innerHTML = hidden ? _EYE_OFF : _EYE_ON; eye.classList.toggle('off', hidden); }
}
function clearFn(idx) {
const el = document.getElementById('fn' + idx); if (!el) return;
el.value = ''; updateFn(idx);
const m = document.getElementById('fn' + idx + '-math'); if (m) m.innerHTML = '';
const f = el.closest('.fn-field'); if (f) f.classList.remove('has-math');
// снять скрытие, если было
if (gSim && gSim._hidden[idx]) toggleFn(idx);
el.focus();
}
function graphZoom(dir) { if (gSim) { dir > 0 ? gSim.zoomIn() : gSim.zoomOut(); } }
function graphFit() { if (gSim) gSim.resetView(); }
function toggleGraphPoints() {
if (!gSim) return;
const on = !gSim.showPts; gSim.setShowPoints(on);
const b = document.getElementById('graph-pts-btn'); if (b) b.classList.toggle('active', on);
}
/* hover info bar */
function fmtVal(v) {
if (v === null || v === undefined) return '—';
+12
View File
@@ -14,6 +14,8 @@
<input class="fn-input" id="fn0" placeholder="sin(x)" autocomplete="off" spellcheck="false" oninput="updateFn(0)" />
<div class="fn-math" id="fn0-math" title="Нажми, чтобы изменить"></div>
</div>
<button class="fn-act" id="fn0-eye" type="button" title="Скрыть/показать" onclick="toggleFn(0)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(0)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="fn-preview" id="fn0-prev"></div>
<div class="fn-err" id="fn0-err">Синтаксическая ошибка</div>
@@ -28,6 +30,8 @@
<input class="fn-input" id="fn1" placeholder="x^2 - 4" autocomplete="off" spellcheck="false" oninput="updateFn(1)" />
<div class="fn-math" id="fn1-math" title="Нажми, чтобы изменить"></div>
</div>
<button class="fn-act" id="fn1-eye" type="button" title="Скрыть/показать" onclick="toggleFn(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="fn-preview" id="fn1-prev"></div>
<div class="fn-err" id="fn1-err">Синтаксическая ошибка</div>
@@ -42,6 +46,8 @@
<input class="fn-input" id="fn2" placeholder="tg(x)" autocomplete="off" spellcheck="false" oninput="updateFn(2)" />
<div class="fn-math" id="fn2-math" title="Нажми, чтобы изменить"></div>
</div>
<button class="fn-act" id="fn2-eye" type="button" title="Скрыть/показать" onclick="toggleFn(2)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(2)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="fn-preview" id="fn2-prev"></div>
<div class="fn-err" id="fn2-err">Синтаксическая ошибка</div>
@@ -121,6 +127,12 @@
<div class="graph-canvas-outer">
<div class="graph-canvas-wrap">
<canvas id="graph-canvas"></canvas>
<div class="graph-view-ctrls">
<button class="gv-btn" id="graph-pts-btn" type="button" title="Особые точки: нули, пересечения, y-перехват" onclick="toggleGraphPoints()"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="7" r="2"/><circle cx="13" cy="15" r="2"/><circle cx="19" cy="6" r="2"/></svg></button>
<button class="gv-btn" type="button" title="Приблизить" onclick="graphZoom(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="gv-btn" type="button" title="Отдалить" onclick="graphZoom(-1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="gv-btn" type="button" title="Сбросить вид" onclick="graphFit()"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 9 4 4 9 4"/><polyline points="20 9 20 4 15 4"/><polyline points="4 15 4 20 9 20"/><polyline points="20 15 20 20 15 20"/></svg></button>
</div>
</div>
<div class="graph-info-bar" id="graph-info-bar">
<div class="info-coord">