diff --git a/frontend/css/lab.css b/frontend/css/lab.css index 5656f15..edd634c 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -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; diff --git a/frontend/js/labs/graph.js b/frontend/js/labs/graph.js index eb05f8d..e959d6f 100644 --- a/frontend/js/labs/graph.js +++ b/frontend/js/labs/graph.js @@ -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 = ''; + const _EYE_OFF = ''; + + 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 '—'; diff --git a/frontend/labs-bodies.html b/frontend/labs-bodies.html index bd4b2f1..eede5f0 100644 --- a/frontend/labs-bodies.html +++ b/frontend/labs-bodies.html @@ -14,6 +14,8 @@
+ +
Синтаксическая ошибка
@@ -28,6 +30,8 @@
+ +
Синтаксическая ошибка
@@ -42,6 +46,8 @@
+ +
Синтаксическая ошибка
@@ -121,6 +127,12 @@
+
+ + + + +