'use strict'; /* ══════════════════════════════════════════════════════════════ QuadraticSim — interactive quadratic equation explorer y = ax² + bx + c · discriminant, roots, vertex ══════════════════════════════════════════════════════════════ */ class QuadraticSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; /* coefficients */ this.a = 1; this.b = 0; this.c = -1; this._lastDSign = Math.sign(1 * 1 * 1 - 4 * 1 * (-1)); // track discriminant sign /* view */ this.ox = 0; this.oy = 0; this.scl = 40; // px per unit /* interaction */ this._drag = null; this.hx = null; // hovered math x /* callback */ this.onUpdate = null; this._bind(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } /* ── public API ───────────────────────────────────── */ fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } getParams() { return { a: this.a, b: this.b, c: this.c }; } setParams({ a, b, c } = {}) { if (a !== undefined) this.a = +a; if (b !== undefined) this.b = +b; if (c !== undefined) this.c = +c; this.draw(); this._emit(); } resetView() { this.ox = 0; this.oy = 0; this.scl = 40; 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(); } info() { const { a, b, c } = this; const D = b * b - 4 * a * c; let roots = '—'; let rootCount = 0; if (a === 0) { roots = b !== 0 ? `x = ${this._fmt(-c / b)}` : '—'; rootCount = b !== 0 ? 1 : 0; } else if (D > 0.0001) { const sqD = Math.sqrt(D); const x1 = (-b - sqD) / (2 * a); const x2 = (-b + sqD) / (2 * a); roots = `x₁=${this._fmt(x1)}, x₂=${this._fmt(x2)}`; rootCount = 2; } else if (Math.abs(D) <= 0.0001) { roots = `x = ${this._fmt(-b / (2 * a))}`; rootCount = 1; } const vx = a !== 0 ? -b / (2 * a) : 0; const vy = a !== 0 ? c - b * b / (4 * a) : 0; return { D: this._fmt(D), rootCount, roots, vertex: a !== 0 ? `(${this._fmt(vx)}; ${this._fmt(vy)})` : '—', equation: `y = ${a !== 1 ? (a === -1 ? '−' : this._fmt(a)) : ''}x² ${b >= 0 ? '+' : '−'} ${this._fmt(Math.abs(b))}x ${c >= 0 ? '+' : '−'} ${this._fmt(Math.abs(c))}`, }; } /* ── internals ────────────────────────────────────── */ _fmt(n) { if (Number.isInteger(n)) return String(n); return Math.abs(n) < 0.005 ? '0' : n.toFixed(2).replace(/\.?0+$/, ''); } _f(x) { return this.a * x * x + this.b * x + this.c; } _emit() { if (this.onUpdate) this.onUpdate(this.info()); if (window.LabFX) { const D = this.b * this.b - 4 * this.a * this.c; const sign = Math.sign(D); if (sign !== this._lastDSign) { this._lastDSign = sign; LabFX.sound.play('chime', { pitch: 1.3 }); } } } /* ── coordinate transforms ─────────────────────────── */ _toPx(mx, my) { return [ this.W / 2 + (mx - this.ox) * this.scl, this.H / 2 - (my - this.oy) * this.scl, ]; } _toMath(px, py) { return [ (px - this.W / 2) / this.scl + this.ox, -(py - this.H / 2) / this.scl + this.oy, ]; } /* ── draw ─────────────────────────────────────────── */ draw() { const ctx = this.ctx, W = this.W, H = this.H; if (!W || !H) return; ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); this._drawGrid(ctx, W, H); this._drawAxes(ctx, W, H); this._drawParabola(ctx, W, H); this._drawFeatures(ctx, W, H); if (this.hx !== null) this._drawHover(ctx, W, H); } /* ── grid & axes ──────────────────────────────────── */ _niceStep() { const raw = this.W / this.scl / 8; const p = Math.pow(10, Math.floor(Math.log10(raw))); for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p; return p; } _drawGrid(ctx, W, H) { const step = this._niceStep(); const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0); const [, y0] = this._toMath(0, H), [, y1] = this._toMath(0, 0); const gx = Math.floor(x0 / step) * step; const gy = Math.floor(y0 / step) * step; ctx.strokeStyle = 'rgba(255,255,255,0.065)'; ctx.lineWidth = 1; for (let x = gx; x <= x1 + step; x += step) { const [px] = this._toPx(x, 0); ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); } for (let y = gy; y <= y1 + step; y += step) { const [, py] = this._toPx(0, y); ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); } // labels ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; const [axX, axY] = this._toPx(0, 0); const lblY = Math.max(4, Math.min(H - 18, axY + 5)); const lblX = Math.max(28, Math.min(W - 6, axX - 5)); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (let x = gx; x <= x1; x += step) { if (Math.abs(x) < step * 0.01) continue; const [px] = this._toPx(x, 0); if (px < 18 || px > W - 18) continue; ctx.fillText(this._fmtLabel(x, step), px, lblY); } ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let y = gy; y <= y1; y += step) { if (Math.abs(y) < step * 0.01) continue; const [, py] = this._toPx(0, y); if (py < 12 || py > H - 12) continue; ctx.fillText(this._fmtLabel(y, step), lblX, py); } } _fmtLabel(n, step) { if (n === 0) return '0'; if (step >= 1 && Number.isInteger(n)) return String(n); if (step < 0.001) return n.toExponential(1); const dec = Math.max(0, -Math.floor(Math.log10(step))); return n.toFixed(dec); } _drawAxes(ctx, W, H) { const [ax, ay] = this._toPx(0, 0); ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W - 10, ay); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ax, H); ctx.lineTo(ax, 8); ctx.stroke(); // arrowheads ctx.fillStyle = 'rgba(255,255,255,0.4)'; this._arrowHead(ctx, W - 8, ay, 0); this._arrowHead(ctx, ax, 6, -Math.PI / 2); ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = 'bold 12px Manrope, sans-serif'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.fillText('x', W - 10, ay - 13); ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.fillText('y', ax + 7, 4); } _arrowHead(ctx, x, y, angle) { const s = 5; ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore(); } /* ── parabola curve ───────────────────────────────── */ _drawParabola(ctx, W, H) { const steps = Math.min(W * 2, 2000); const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0); const dx = (x1 - x0) / steps; // glow ctx.strokeStyle = 'rgba(155,93,229,0.15)'; ctx.lineWidth = 8; ctx.lineJoin = 'round'; ctx.beginPath(); let pen = false; for (let i = 0; i <= steps; i++) { const mx = x0 + i * dx; const my = this._f(mx); if (!isFinite(my)) { pen = false; continue; } const [px, py] = this._toPx(mx, my); if (py < -200 || py > H + 200) { pen = false; continue; } pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py); pen = true; } ctx.stroke(); // main curve ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.beginPath(); pen = false; for (let i = 0; i <= steps; i++) { const mx = x0 + i * dx; const my = this._f(mx); if (!isFinite(my)) { pen = false; continue; } const [px, py] = this._toPx(mx, my); if (py < -200 || py > H + 200) { pen = false; continue; } pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py); pen = true; } ctx.stroke(); } /* ── vertex, roots, axis of symmetry ──────────────── */ _drawFeatures(ctx, W, H) { const { a, b, c } = this; if (a === 0) return; // linear — no features const vx = -b / (2 * a); const vy = this._f(vx); const D = b * b - 4 * a * c; // axis of symmetry const [symPx] = this._toPx(vx, 0); ctx.strokeStyle = 'rgba(6,214,224,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(symPx, 0); ctx.lineTo(symPx, H); ctx.stroke(); ctx.setLineDash([]); // vertex point const [vpx, vpy] = this._toPx(vx, vy); if (vpy > -20 && vpy < H + 20) { ctx.fillStyle = '#06D6E0'; ctx.beginPath(); ctx.arc(vpx, vpy, 6, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); // label ctx.fillStyle = '#06D6E0'; ctx.font = 'bold 12px Manrope, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(`(${this._fmt(vx)}; ${this._fmt(vy)})`, vpx, vpy - 12); } // roots if (D >= -0.0001) { ctx.fillStyle = '#EF476F'; const roots = []; if (D > 0.0001) { const sqD = Math.sqrt(D); roots.push((-b - sqD) / (2 * a)); roots.push((-b + sqD) / (2 * a)); } else { roots.push(-b / (2 * a)); } for (const rx of roots) { const [rpx, rpy] = this._toPx(rx, 0); if (rpx < -20 || rpx > W + 20) continue; // root dot with glow if (window.LabFX) { LabFX.glow.drawGlow(ctx, () => { ctx.fillStyle = '#EF476F'; ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); }, { color: '#F59E0B', intensity: 8 }); } else { ctx.fillStyle = '#EF476F'; ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); } // label ctx.fillStyle = '#EF476F'; ctx.font = '11px Manrope, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(this._fmt(rx), rpx, rpy + 10); } } // discriminant badge const badgeColor = D > 0.0001 ? '#7BF5A4' : (D < -0.0001 ? '#EF476F' : '#FFD166'); const badgeText = D > 0.0001 ? `D = ${this._fmt(D)} > 0 (2 корня)` : D < -0.0001 ? `D = ${this._fmt(D)} < 0 (нет корней)` : `D = 0 (1 корень)`; ctx.font = 'bold 12px Manrope, sans-serif'; const tw = ctx.measureText(badgeText).width; const bx = W - tw - 28, by = 16; ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.fill(); ctx.strokeStyle = badgeColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.stroke(); ctx.fillStyle = badgeColor; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(badgeText, bx + 10, by + 14); } /* ── hover crosshair ──────────────────────────────── */ _drawHover(ctx, W, H) { const [px] = this._toPx(this.hx, 0); const my = this._f(this.hx); if (!isFinite(my)) return; const [, py] = this._toPx(this.hx, my); // vertical line ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); ctx.setLineDash([]); if (py < -20 || py > H + 20) return; // point ctx.fillStyle = '#FFD166'; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); // tooltip ctx.fillStyle = 'rgba(22,22,38,0.9)'; const text = `(${this._fmt(this.hx)}, ${this._fmt(my)})`; ctx.font = '12px Manrope, sans-serif'; const tw2 = ctx.measureText(text).width; const tx = px + 14, ty = py - 14; ctx.beginPath(); ctx.roundRect(tx, ty - 10, tw2 + 16, 22, 6); ctx.fill(); ctx.fillStyle = '#FFD166'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, tx + 8, ty + 1); } /* ── events ───────────────────────────────────────── */ _bind() { const cv = this.canvas; cv.addEventListener('wheel', e => { e.preventDefault(); const [mx, my] = this._toMath(e.offsetX, e.offsetY); this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15))); const [nx, ny] = this._toMath(e.offsetX, e.offsetY); this.ox -= nx - mx; this.oy -= ny - my; this.draw(); }, { passive: false }); cv.addEventListener('mousedown', e => { this._drag = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy }; cv.style.cursor = 'grabbing'; }); window.addEventListener('mousemove', e => { if (this._drag) { this.ox = this._drag.ox - (e.clientX - this._drag.x) / this.scl; this.oy = this._drag.oy + (e.clientY - this._drag.y) / this.scl; this.draw(); } else { const r = cv.getBoundingClientRect(); const lx = e.clientX - r.left, ly = e.clientY - r.top; if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) { this.hx = this._toMath(lx, ly)[0]; this.draw(); } } }); window.addEventListener('mouseup', () => { this._drag = null; cv.style.cursor = 'crosshair'; }); cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); }); cv.style.cursor = 'crosshair'; // touch let t0 = null; 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 }; }, { passive: true }); cv.addEventListener('touchmove', e => { e.preventDefault(); 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; }); } } /* ─── lab UI init ─────────────────────────────────── */ function _openQuadratic() { document.getElementById('sim-topbar-title').textContent = 'Корни квадратного уравнения'; _simShow('sim-quadratic'); _registerSimState('quadratic', () => quadSim?.getParams(), st => quadSim?.setParams(st)); if (_embedMode) _startStateEmit('quadratic'); requestAnimationFrame(() => requestAnimationFrame(() => { if (!quadSim) { quadSim = new QuadraticSim(document.getElementById('quadratic-canvas')); quadSim.onUpdate = _quadUpdateUI; } quadSim.fit(); quadSim.draw(); quadSim._emit(); })); } let _quadSoundTs = 0; function quadParam(name, val) { const v = parseFloat(val); document.getElementById('quad-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1); if (quadSim) quadSim.setParams({ [name]: v }); const now = performance.now(); if (window.LabFX && now - _quadSoundTs > 80) { _quadSoundTs = now; LabFX.sound.play('tick', { volume: 0.1 }); } } function quadPreset(a, b, c) { document.getElementById('sl-quad-a').value = a; document.getElementById('quad-a-val').textContent = a; document.getElementById('sl-quad-b').value = b; document.getElementById('quad-b-val').textContent = b; document.getElementById('sl-quad-c').value = c; document.getElementById('quad-c-val').textContent = c; if (quadSim) quadSim.setParams({ a, b, c }); } function _quadUpdateUI(info) { const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; v('qbar-v1', 'D = ' + info.D); v('qbar-v2', info.roots); v('qbar-v3', info.vertex); v('qbar-v4', info.equation); } /* ── normal distribution ── */