Files
Learn_System/frontend/js/labs/quadratic.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

475 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
/* 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)) : ''}${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());
}
/* ── 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
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();
}));
}
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 });
}
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 ── */