6afe928c0d
ФУНДАМЕНТ (4 новых файла): - _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake - _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust) - _fx_motion.js: tween + 12 easings + critically-damped spring - _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API - Sound toggle в шапке lab.html с localStorage-persist UX МИКРО (CSS + JS): - Button states: hover scale+brightness, active scale-down, disabled grayscale - Slider polish: custom thumb с тенью, filled-track gradient, hover/active - Focus rings через :focus-visible - Tooltip system .tt-host data-tt= с 400ms hover, fade-in - Marching ants для selection - Loading skeleton с shimmer - Empty state .sim-empty-* паттерн - Toast: progress bar внизу, icons по типу - Cursor states utility classes - View Transitions API для smooth sim-switch, fallback на CSS fade PHASE 2 — визуальные эффекты для 33 симуляций: Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks) Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds) Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow) Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click) Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow) Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям) Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
'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 ── */
|