Files
Learn_System/frontend/js/labs/newton.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (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>
2026-05-23 13:58:49 +03:00

1694 lines
66 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';
/* ════════════════════════════════════════════════════════════════
NewtonSim — три закона Ньютона
Закон I : A — скользящий блок, B — шар на нити
Закон II : A — один блок F=ma, B — сравнение масс
Закон III: A — пушка + откат, B — столкновение шаров, C — ракета
════════════════════════════════════════════════════════════════ */
class NewtonSim {
static SCALE = 58; // px per metre (visual)
static G = 9.81; // m/s²
/* ── Конструктор ─────────────────────────────────────────── */
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
/* Пользовательские параметры */
this.law = 1;
this.scene = 'A';
this.mu = 0.20;
this.mass1 = 5; // кг — основной блок / шар / ядро
this.mass2 = 12; // кг — сравниваемый блок / пушка
this.force = 30; // Н — приложенная сила (закон II)
/* Состояние сцен */
this._1A = {};
this._1B = {};
this._2 = {};
this._3A = {};
this._3B = {};
this._3C = {};
/* Петля */
this._raf = null;
this._last = 0;
this._paused = false;
/* Геометрия */
this.W = 0; this.H = 0;
this._g = {};
this.onUpdate = null;
this.onModeChange = null;
this.fit();
this._bindEvents();
}
/* ── Геометрия ───────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 700;
const H = this.canvas.offsetHeight || 440;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._g = {
gY: H * 0.73,
cx: W * 0.50,
cy: H * 0.48,
orbitR: Math.min(W, H) * 0.255,
};
this._resetAll();
}
_resetAll() {
this._reset1A(); this._reset1B();
this._reset2();
this._reset3A(); this._reset3B(); this._reset3C();
}
/* ── Сброс каждой сцены ──────────────────────────────────── */
_reset1A() {
const { W, _g: g } = this;
this._1A = {
bx: W * 0.15, by: g.gY, bvx: 0, bvy: 0,
BW: 56, BH: 46,
trail: [], inAir: false,
};
}
_reset1B() {
const { _g: g } = this;
const omega = 1.35;
this._1B = {
angle: 0, omega,
cut: false, cutTimer: 0,
bx: g.cx + g.orbitR, by: g.cy,
bvx: 0, bvy: -g.orbitR * omega,
};
}
_reset2() {
const { W, _g: g } = this;
this._2 = {
b1x: W * 0.12, b1vx: 0,
b2x: W * 0.12, b2vx: 0,
history: [], t: 0, running: false,
flash: '', // 'fin' message
};
}
_reset3A() {
const { W, _g: g } = this;
this._3A = {
cx: W * 0.38, cvx: 0,
ball: null, fired: false,
sparks: [], forceFlash: 0,
};
}
_reset3B() {
const { W, _g: g } = this;
const r1 = 16 + this.mass1 * 1.1;
const r2 = 16 + this.mass2 * 1.1;
this._3B = {
b1: { x: W * 0.18, vx: 160, mass: this.mass1, r: r1, color: '#EF476F' },
b2: { x: W * 0.82, vx: -100, mass: this.mass2, r: r2, color: '#4CC9F0' },
colFlash: 0, done: false,
};
}
_reset3C() {
const { W, H } = this;
this._3C = {
ry: H * 0.78, rvy: 0,
rmass: 10, fuel: 1,
particles: [], running: false,
stopped: false,
};
}
/* ── Запуск / остановка ──────────────────────────────────── */
start() {
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
/* ── Публичный API ───────────────────────────────────────── */
setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); if (this.onModeChange) this.onModeChange(); }
setScene(s) { this.scene = s; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); }
setMu(v) { this.mu = v; }
setMass1(v) { this.mass1 = v; this._reset3B(); }
setMass2(v) { this.mass2 = v; this._reset3B(); }
setForce(v) { this.force = v; }
cutString() {
this._1B.cut = true;
this._1B.bvx = -Math.sin(this._1B.angle) * this._g.orbitR * this._1B.omega;
this._1B.bvy = Math.cos(this._1B.angle) * this._g.orbitR * this._1B.omega;
}
startL2() { this._2.running = true; }
resetL2() { this._reset2(); }
fireCannon() {
if (this._3A.fired) { this._reset3A(); return; }
const { _g: g } = this;
const S = NewtonSim.SCALE;
const vBall = 360; // px/s
const vCannon = -(this.mass1 / this.mass2) * vBall;
this._3A.ball = { x: this._3A.cx + 68, y: g.gY - 22, vx: vBall, vy: -160 };
this._3A.cvx = vCannon;
this._3A.fired = true;
this._3A.forceFlash = 0.55;
for (let i = 0; i < 24; i++) {
const a = (Math.random() - 0.5) * 1.1 - 0.05;
this._3A.sparks.push({
x: this._3A.cx + 68, y: g.gY - 22,
vx: Math.cos(a) * (180 + Math.random() * 220),
vy: Math.sin(a) * 140 - 80 - Math.random() * 120,
life: 1,
});
}
}
toggleRocket() {
if (this._3C.fuel <= 0) { this._reset3C(); return; }
this._3C.running = !this._3C.running;
if (this.onModeChange) this.onModeChange();
}
togglePause() { this._paused = !this._paused; }
preset(name) {
switch (name) {
case 'space': this.mu = 0; this._reset1A(); break;
case 'ice': this.mu = 0.04; this._reset1A(); break;
case 'asphalt': this.mu = 0.38; this._reset1A(); break;
case 'rubber': this.mu = 0.72; this._reset1A(); break;
case 'light': this.mass1 = 2; this.force = 20; this._reset2(); break;
case 'heavy': this.mass1 = 18; this.force = 20; this._reset2(); break;
case 'compare': this.mass1 = 2; this.mass2 = 16; this.scene = 'B'; this._reset2(); break;
case 'big_cannon': this.mass2 = 50; this.mass1 = 1; this._reset3A(); break;
case 'small_cannon': this.mass2 = 5; this.mass1 = 4; this._reset3A(); break;
case 'equal_balls': this.mass1 = 8; this.mass2 = 8; this._reset3B(); break;
}
if (this.onUpdate) this.onUpdate(this.info());
}
/* ── Тик ──────────────────────────────────────────────────── */
_tick(now) {
const dt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
if (window.LabFX) LabFX.particles.update(dt);
if (!this._paused) {
if (this.law === 1 && this.scene === 'A') this._step1A(dt);
else if (this.law === 1) this._step1B(dt);
else if (this.law === 2) this._step2(dt);
else if (this.scene === 'A') this._step3A(dt);
else if (this.scene === 'B') this._step3B(dt);
else this._step3C(dt);
}
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ── Физика I-A : блок с трением ────────────────────────── */
_step1A(dt) {
const b = this._1A;
const { W, _g: g } = this;
const S = NewtonSim.SCALE;
const GV = NewtonSim.G * S;
/* Гравитация */
if (b.by < g.gY || b.bvy < 0) {
b.bvy += GV * dt;
b.inAir = true;
}
/* Интеграция */
b.bx += b.bvx * dt;
b.by += b.bvy * dt;
/* Приземление */
if (b.by >= g.gY) {
b.by = g.gY;
b.bvy = Math.abs(b.bvy) > 60 ? -b.bvy * 0.42 : 0;
b.inAir = false;
}
/* Трение (только на земле) */
if (!b.inAir) {
const speed = Math.abs(b.bvx);
if (speed > 1) {
const dec = this.mu * GV * dt;
if (dec >= speed) b.bvx = 0;
else b.bvx -= Math.sign(b.bvx) * dec;
}
}
/* Стены (упругий отскок) */
const hw = b.BW / 2;
if (b.bx < hw) { b.bx = hw; b.bvx = Math.abs(b.bvx) * 0.65; }
if (b.bx > W - hw) { b.bx = W - hw; b.bvx = -Math.abs(b.bvx) * 0.65; }
/* След */
const speed = Math.hypot(b.bvx, b.bvy);
if (speed > 15) {
b.trail.push({ x: b.bx, y: Math.min(b.by, g.gY) });
if (b.trail.length > 90) b.trail.shift();
} else if (b.trail.length > 0) {
b.trail.shift();
}
}
/* ── Физика I-B : орбита → прямолинейное движение ────────── */
_step1B(dt) {
const s = this._1B;
const { _g: g } = this;
if (!s.cut) {
s.angle += s.omega * dt;
s.bx = g.cx + Math.cos(s.angle) * g.orbitR;
s.by = g.cy + Math.sin(s.angle) * g.orbitR;
s.bvx = -Math.sin(s.angle) * g.orbitR * s.omega;
s.bvy = Math.cos(s.angle) * g.orbitR * s.omega;
} else {
s.bx += s.bvx * dt;
s.by += s.bvy * dt;
s.cutTimer += dt;
if (s.cutTimer > 4.5) this._reset1B();
}
}
/* ── Физика II : F = m·a ──────────────────────────────────── */
_step2(dt) {
if (!this._2.running) return;
const { W } = this;
const S = NewtonSim.SCALE;
const a1 = (this.force / this.mass1) * S;
const a2 = (this.force / this.mass2) * S;
this._2.b1vx += a1 * dt; this._2.b1x += this._2.b1vx * dt;
this._2.b2vx += a2 * dt; this._2.b2x += this._2.b2vx * dt;
this._2.t += dt;
/* История для графика (примерно 20 точек/с) */
if (this._2.t % 0.05 < dt) {
this._2.history.push({ v1: this._2.b1vx / S, v2: this._2.b2vx / S });
if (this._2.history.length > 130) this._2.history.shift();
}
/* Сброс при достижении правого края */
if (this._2.b1x > W * 0.89 || this._2.b2x > W * 0.89) {
this._reset2(); this._2.running = true;
}
}
/* ── Физика III-A : пушка ─────────────────────────────────── */
_step3A(dt) {
const s = this._3A;
const { W, _g: g } = this;
const S = NewtonSim.SCALE;
const GV = NewtonSim.G * S;
if (!s.fired) return;
if (this._3A.forceFlash > 0) this._3A.forceFlash -= dt;
/* Пушка тормозит */
if (Math.abs(s.cvx) > 2) {
const dec = this.mu * GV * dt;
if (dec >= Math.abs(s.cvx)) s.cvx = 0;
else s.cvx -= Math.sign(s.cvx) * dec;
} else { s.cvx = 0; }
s.cx = Math.max(60, Math.min(W - 80, s.cx + s.cvx * dt));
/* Ядро (баллистика) */
if (s.ball) {
s.ball.vy += GV * dt;
s.ball.x += s.ball.vx * dt;
s.ball.y += s.ball.vy * dt;
if (s.ball.y > g.gY + 40 || s.ball.x > W + 120 || s.ball.x < -120) s.ball = null;
}
/* Искры */
for (const sp of s.sparks) {
sp.x += sp.vx * dt; sp.y += sp.vy * dt;
sp.vy += GV * 0.6 * dt; sp.life -= dt * 2;
}
s.sparks = s.sparks.filter(sp => sp.life > 0);
}
/* ── Физика III-B : столкновение ─────────────────────────── */
_step3B(dt) {
const { b1, b2 } = this._3B;
const { W, _g: g } = this;
const S = NewtonSim.SCALE;
b1.x += b1.vx * dt;
b2.x += b2.vx * dt;
if (this._3B.colFlash > 0) this._3B.colFlash -= dt;
/* Упругое столкновение */
if (!this._3B.done) {
const dist = b2.x - b1.x;
if (dist < b1.r + b2.r) {
const m1 = b1.mass, m2 = b2.mass;
const v1 = b1.vx, v2 = b2.vx;
b1.vx = ((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2);
b2.vx = ((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2);
const overlap = b1.r + b2.r - dist;
b1.x -= overlap * 0.5; b2.x += overlap * 0.5;
this._3B.colFlash = 0.55;
this._3B.done = true;
}
}
/* Минимальное трение поверхности (сохраняет видимость закона импульса) */
if (this._3B.done) {
[b1, b2].forEach(b => { b.vx *= (1 - dt * 0.04); });
}
/* Авто-сброс */
if (b1.x < -120 || b2.x > W + 120 || (this._3B.done && Math.abs(b1.vx) < 2 && Math.abs(b2.vx) < 2)) {
setTimeout(() => this._reset3B(), 1800);
this._3B.done = true; // prevent double reset
}
}
/* ── Физика III-C : ракета ────────────────────────────────── */
_step3C(dt) {
const s = this._3C;
const { W, H } = this;
const g_vis = NewtonSim.G * 0.42; // visual gravity (px/s²)
/* Gravity always acts — even after fuel is out */
if (!s.running && s.fuel <= 0 && !s.stopped) {
s.rvy += g_vis * dt;
s.ry += s.rvy * dt;
if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; s.stopped = true; }
/* exhaust smoke fading */
for (const p of s.particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6; }
s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);
return;
}
if (!s.running || s.fuel <= 0) {
if (s.fuel <= 0 && !s.stopped) { s.running = false; }
return;
}
const dmdt = 0.025; // кг/с — сгорание топлива
const F_thr = 220; // Н — тяга (визуальная)
s.fuel = Math.max(0, s.fuel - dmdt * dt);
s.rmass = Math.max(2, 10 * s.fuel + 2);
/* F_net = F_thrust - m·g */
const a_thrust = F_thr / s.rmass;
const a_net = a_thrust - g_vis;
s.rvy -= a_net * dt;
s.ry += s.rvy * dt;
if (s.ry < H * 0.08) { s.ry = H * 0.08; s.rvy = 0; }
if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; }
/* Частицы выхлопа */
if (Math.random() < 0.55) {
s.particles.push({
x: W * 0.5 + (Math.random() - 0.5) * 16,
y: s.ry + 48,
vx: (Math.random() - 0.5) * 38,
vy: 130 + Math.random() * 170,
r: 2 + Math.random() * 4,
life: 1,
});
}
for (const p of s.particles) {
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6;
}
s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);
/* LabFX: rocket flame trail at nozzle */
if (window.LabFX) {
const nozzleX = W * 0.5;
const nozzleY = s.ry + 22;
LabFX.particles.emit({
ctx: this.ctx, x: nozzleX, y: nozzleY,
count: 3, color: ['#FFD166', '#FF6B35', '#EF476F'],
speed: 80, spread: Math.PI / 6, angle: Math.PI / 2,
gravity: -50, life: 400, glow: true, shape: 'spark',
});
}
}
/* ── Мышь ─────────────────────────────────────────────────── */
_bindEvents() {
this.canvas.addEventListener('click', e => {
if (this.law !== 1 || this.scene !== 'A') return;
const r = this.canvas.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
const b = this._1A;
const dx = x - b.bx, dy = y - b.by;
const d = Math.hypot(dx, dy);
if (d < 4) return;
const spd = 340;
b.bvx = (dx / d) * spd;
b.bvy = (dy / d) * spd;
});
}
/* ═══════════════════════════════════════════════════════════
РЕНДЕРИНГ
═══════════════════════════════════════════════════════════ */
draw() {
const ctx = this.ctx;
const { W, H } = this;
ctx.clearRect(0, 0, W, H);
/* Фон */
const bg = ctx.createRadialGradient(W / 2, H * 0.3, 0, W / 2, H / 2, W * 0.82);
bg.addColorStop(0, '#0d1320'); bg.addColorStop(1, '#050810');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
/* Водяной знак — номер закона */
ctx.save();
ctx.font = 'bold 78px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.023)';
ctx.textAlign = 'center';
ctx.fillText(['', 'ЗАКОН I', 'ЗАКОН II', 'ЗАКОН III'][this.law], W / 2, H * 0.60);
ctx.textAlign = 'left';
ctx.restore();
if (this.law === 1 && this.scene === 'A') this._drawL1A(ctx);
else if (this.law === 1) this._drawL1B(ctx);
else if (this.law === 2) this._drawL2(ctx);
else if (this.scene === 'A') this._drawL3A(ctx);
else if (this.scene === 'B') this._drawL3B(ctx);
else this._drawL3C(ctx);
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Закон I — Сцена A ───────────────────────────────────── */
_drawL1A(ctx) {
const { W, H, _g: g } = this;
const b = this._1A;
const S = NewtonSim.SCALE;
const spd = Math.hypot(b.bvx, b.bvy);
/* Звёздный фон при μ≈0 */
if (this.mu < 0.02) {
this._stars(ctx);
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center';
ctx.fillText('Трения нет — тело движется вечно!', W / 2, H * 0.10);
ctx.textAlign = 'left';
} else {
this._ground(ctx, g.gY, W);
}
/* След */
if (b.trail.length > 2) {
for (let i = 2; i < b.trail.length; i++) {
const a = (i / b.trail.length) * 0.55;
ctx.strokeStyle = `rgba(255,209,102,${a})`;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(b.trail[i-1].x, b.trail[i-1].y - 2);
ctx.lineTo(b.trail[i].x, b.trail[i].y - 2);
ctx.stroke();
}
}
/* Блок */
const by = b.by - b.BH / 2;
this._block(ctx, b.bx, by, b.BW, b.BH, '#9B5DE5', `${this.mass1} кг`);
/* Вектор скорости */
if (spd > 8) {
const scale = 0.28;
this._arrow(ctx,
b.bx, by,
b.bx + b.bvx * scale, by + b.bvy * scale,
'#FFD166', 'v = ' + (spd / S).toFixed(1) + ' м/с', 2.5);
}
/* Сила трения (горизонтальная, только на земле) */
if (!b.inAir && Math.abs(b.bvx) > 8 && this.mu > 0.01) {
const fFr = this.mu * this.mass1 * NewtonSim.G;
this._arrow(ctx,
b.bx, by - 32,
b.bx - Math.sign(b.bvx) * 55, by - 32,
'#EF476F', `F тр = ${fFr.toFixed(1)} Н`, 2);
}
/* Подсказка */
if (spd < 4) {
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
ctx.textAlign = 'center';
ctx.fillText('Кликни куда угодно — придай импульс блоку', W / 2, g.gY + 28);
ctx.textAlign = 'left';
}
/* μ и формула */
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.75)';
ctx.fillText(`μ = ${this.mu.toFixed(2)} F тр = μ · m · g`, 18, 26);
this._caption(ctx, 'Тело продолжает движение\nпока не подействует сила', W, H);
}
/* ── Закон I — Сцена B ───────────────────────────────────── */
_drawL1B(ctx) {
const { W, H, _g: g } = this;
const s = this._1B;
this._stars(ctx);
/* Орбита */
ctx.save();
ctx.setLineDash([5, 9]);
ctx.strokeStyle = 'rgba(100,165,255,0.20)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(g.cx, g.cy, g.orbitR, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
/* Центральное тело */
ctx.save();
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 22;
ctx.beginPath(); ctx.arc(g.cx, g.cy, 20, 0, Math.PI * 2);
const cg = ctx.createRadialGradient(g.cx - 5, g.cy - 5, 0, g.cx, g.cy, 20);
cg.addColorStop(0, '#FFEA70'); cg.addColorStop(1, '#FF9500');
ctx.fillStyle = cg; ctx.fill();
ctx.restore();
/* Нить */
if (!s.cut) {
ctx.strokeStyle = 'rgba(210,225,255,0.55)'; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo(g.cx, g.cy); ctx.lineTo(s.bx, s.by); ctx.stroke();
/* Стрелка натяжения (центростремительная) */
const len = g.orbitR;
const tx = (g.cx - s.bx) / len * 36;
const ty = (g.cy - s.by) / len * 36;
const F_c = (this.mass1 * g.orbitR * s.omega * s.omega * NewtonSim.SCALE / NewtonSim.SCALE).toFixed(1);
this._arrow(ctx, s.bx, s.by, s.bx + tx, s.by + ty, '#4CC9F0', `T = F ц`, 1.8);
} else {
ctx.font = 'bold 14px sans-serif'; ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center';
ctx.fillText('✂ Нить разрезана — тело летит прямолинейно!', W / 2, H * 0.10);
ctx.textAlign = 'left';
/* Вектор скорости по касательной */
this._arrow(ctx, s.bx, s.by,
s.bx + s.bvx * 0.22, s.by + s.bvy * 0.22,
'#FFD166', 'v = const', 2.8);
}
/* Шар */
ctx.save();
ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 14;
ctx.beginPath(); ctx.arc(s.bx, s.by, 16, 0, Math.PI * 2);
ctx.fillStyle = '#4CC9F0'; ctx.fill();
ctx.restore();
/* Вектор скорости (во время орбиты) */
if (!s.cut) {
this._arrow(ctx, s.bx, s.by,
s.bx + s.bvx * 0.18, s.by + s.bvy * 0.18,
'#FFD166', '', 2);
}
this._caption(ctx, 'Без силы тело движется прямолинейно\nравномерно (1-й закон Ньютона)', W, H);
}
/* ── Закон II ─────────────────────────────────────────────── */
_drawL2(ctx) {
const { W, H, _g: g } = this;
const S = NewtonSim.SCALE;
const a1 = this.force / this.mass1;
const a2 = this.force / this.mass2;
this._ground(ctx, g.gY, W);
/* Линия финиша */
ctx.strokeStyle = 'rgba(255,255,255,0.13)'; ctx.lineWidth = 1;
ctx.setLineDash([6, 7]);
ctx.beginPath(); ctx.moveTo(W * 0.89, 0); ctx.lineTo(W * 0.89, g.gY + 8); ctx.stroke();
ctx.setLineDash([]);
const BW = 58, BH = 48;
if (this.scene === 'A') {
/* ── Один блок ── */
const { b1x: bx, b1vx: bvx } = this._2;
const by = g.gY - BH / 2;
this._block(ctx, bx, by, BW, BH, '#EF476F', `${this.mass1} кг`);
/* Сила F */
const _fTipX = bx + BW / 2 + 48 + this.force * 0.9;
const _fTipY = by;
this._arrow(ctx, bx + BW / 2, by,
_fTipX, _fTipY,
'#EF476F', `F = ${this.force} Н`, 2.5);
/* LabFX: spark at force arrow tip (scene II A, running) */
if (window.LabFX && this._2.running && Math.random() < 0.25) {
LabFX.particles.emit({
ctx, x: _fTipX, y: _fTipY,
count: 5, color: '#FFD166', speed: 40,
spread: Math.PI / 2, angle: 0, life: 200,
glow: true, shape: 'spark',
});
}
/* Ускорение a */
const aLen = 32 + a1 * 5;
this._arrow(ctx, bx + BW / 2, by - 32,
bx + BW / 2 + aLen, by - 32,
'#7BF5A4', `a = ${a1.toFixed(1)} м/с²`, 2.5);
/* Скорость v */
if (bvx > 8) {
const v = bvx / S;
this._arrow(ctx, bx + BW / 2, by + 32,
bx + BW / 2 + bvx * 0.28, by + 32,
'#FFD166', `v = ${v.toFixed(1)} м/с`, 2);
}
/* Уравнение F = m·a */
this._fma(ctx, this.force, this.mass1, a1, W / 2, H * 0.12);
/* Мини-график v(t) */
if (this._2.history.length > 3) {
this._graph(ctx, this._2.history.map(h => h.v1),
W * 0.72, 14, 145, 62, '#FFD166', 'v(t) м/с');
}
if (!this._2.running) {
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
ctx.textAlign = 'center';
ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28);
ctx.textAlign = 'left';
}
} else {
/* ── Сравнение двух масс ── */
const y1 = g.gY - BH - 6, y2 = g.gY + 4;
/* Разделитель дорожек */
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 7]);
ctx.beginPath(); ctx.moveTo(0, g.gY - BH / 2 - 4); ctx.lineTo(W, g.gY - BH / 2 - 4); ctx.stroke();
ctx.setLineDash([]);
const bx1 = this._2.b1x, bx2 = this._2.b2x;
this._block(ctx, bx1, y1, BW, BH, '#EF476F', `${this.mass1} кг`);
this._block(ctx, bx2, y2, BW, BH, '#4CC9F0', `${this.mass2} кг`);
/* Силы (одинаковые) */
const fLen = 40 + this.force * 0.9;
this._arrow(ctx, bx1 + BW / 2, y1 + BH / 2, bx1 + BW / 2 + fLen, y1 + BH / 2, '#EF476F', `F=${this.force}Н`, 2);
this._arrow(ctx, bx2 + BW / 2, y2 + BH / 2, bx2 + BW / 2 + fLen, y2 + BH / 2, '#4CC9F0', `F=${this.force}Н`, 2);
/* Ускорения */
ctx.font = 'bold 11px monospace';
ctx.fillStyle = '#7BF5A4'; ctx.fillText(`a₁=${a1.toFixed(1)} м/с²`, bx1 + 2, y1 - 8);
ctx.fillStyle = '#06D6E0'; ctx.fillText(`a₂=${a2.toFixed(1)} м/с²`, bx2 + 2, y2 - 8);
/* Вывод */
if (this._2.running && bx1 > bx2 + 20) {
ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center';
ctx.fillText('Меньше масса → больше ускорение!', W / 2, g.gY + 26);
ctx.textAlign = 'left';
}
/* Графики */
if (this._2.history.length > 3) {
this._graph(ctx, this._2.history.map(h => h.v1), W * 0.68, 14, 130, 58, '#EF476F', 'v₁ м/с');
this._graph(ctx, this._2.history.map(h => h.v2), W * 0.68, 80, 130, 58, '#4CC9F0', 'v₂ м/с');
}
if (!this._2.running) {
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
ctx.textAlign = 'center';
ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28);
ctx.textAlign = 'left';
}
}
this._caption(ctx, 'F = m · a', W, H);
}
/* ── Закон III — Сцена A : пушка ────────────────────────── */
_drawL3A(ctx) {
const { W, H, _g: g } = this;
const s = this._3A;
const S = NewtonSim.SCALE;
const CW = 124, CH = 42;
this._ground(ctx, g.gY, W);
/* Корпус пушки */
ctx.save();
ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 14;
_nwt_rrect(ctx, s.cx - CW / 2, g.gY - CH - 4, CW, CH, 8);
const cg = ctx.createLinearGradient(0, g.gY - CH - 4, 0, g.gY - 4);
cg.addColorStop(0, '#5a3a7a'); cg.addColorStop(1, '#3a2260');
ctx.fillStyle = cg; ctx.fill();
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.8; ctx.stroke();
ctx.restore();
/* Ствол */
ctx.save();
ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 7;
_nwt_rrect(ctx, s.cx + CW / 2 - 8, g.gY - CH / 2 - 8 - 4, 58, 16, 4);
ctx.fillStyle = '#7340a0'; ctx.fill();
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.restore();
/* Колёса */
[s.cx - CW / 2 + 18, s.cx + CW / 2 - 18].forEach(wx => {
ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 6;
ctx.beginPath(); ctx.arc(wx, g.gY, 10, 0, Math.PI * 2);
ctx.fillStyle = '#4a2870'; ctx.fill();
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.restore();
});
/* Масса пушки */
ctx.font = 'bold 11px monospace'; ctx.fillStyle = 'rgba(200,180,255,0.9)';
ctx.textAlign = 'center';
ctx.fillText(`M = ${this.mass2} кг`, s.cx, g.gY - CH - 16);
ctx.textAlign = 'left';
/* Ядро */
if (s.ball) {
ctx.save();
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 12;
ctx.beginPath(); ctx.arc(s.ball.x, s.ball.y, 12, 0, Math.PI * 2);
ctx.fillStyle = '#FFD166'; ctx.fill();
ctx.restore();
ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(255,209,102,0.82)';
ctx.textAlign = 'center';
ctx.fillText(`m = ${this.mass1} кг`, s.ball.x, s.ball.y + 26);
ctx.textAlign = 'left';
}
/* Стрелки сил (сразу после выстрела) */
if (s.forceFlash > 0) {
const alpha = Math.min(1, s.forceFlash * 2.5);
const fScale = 72 * alpha;
const ny = g.gY - CH - 32;
/* Сила на ядро → вправо */
this._arrow(ctx, s.cx + CW / 2 + 20, ny, s.cx + CW / 2 + 20 + fScale, ny, '#EF476F', 'F→ядро', 2.5);
/* Реакция на пушку → влево */
this._arrow(ctx, s.cx - CW / 2 - 20, ny, s.cx - CW / 2 - 20 - fScale, ny, '#4CC9F0', 'F→пушка', 2.5);
ctx.save(); ctx.globalAlpha = alpha;
ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166';
ctx.textAlign = 'center';
ctx.fillText('|F→ядро| = |F→пушка|', s.cx, ny - 22);
ctx.restore();
}
/* Скорости (после выстрела, когда искры погасли) */
if (s.fired && s.sparks.length === 0 && s.forceFlash <= 0) {
if (s.cvx !== 0) {
this._arrow(ctx, s.cx, g.gY - CH / 2 - 4, s.cx + s.cvx * 0.35, g.gY - CH / 2 - 4,
'#4CC9F0', `V₂=${(s.cvx/S).toFixed(1)}м/с`, 2);
}
if (s.ball) {
this._arrow(ctx, s.ball.x, s.ball.y, s.ball.x + s.ball.vx * 0.12, s.ball.y + s.ball.vy * 0.12,
'#EF476F', `V₁=${(s.ball.vx/S).toFixed(1)}м/с`, 2);
}
/* Сохранение импульса */
const p1 = (this.mass1 * 360 / S).toFixed(1);
const p2 = Math.abs(this.mass2 * (this.mass1 / this.mass2) * 360 / S).toFixed(1);
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
ctx.textAlign = 'center';
ctx.fillText(`p₁ = ${p1} кг·м/с p₂ = ${p2} кг·м/с Δp_total = 0`, W / 2, H * 0.11);
ctx.textAlign = 'left';
}
/* Искры */
for (const sp of s.sparks) {
ctx.save(); ctx.globalAlpha = sp.life;
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(sp.x, sp.y, 3 * sp.life, 0, Math.PI * 2);
ctx.fillStyle = sp.life > 0.5 ? '#FFD166' : '#EF476F'; ctx.fill();
ctx.restore();
}
if (!s.fired) {
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
ctx.textAlign = 'center';
ctx.fillText('Нажмите «Выстрел!» чтобы запустить ядро', W / 2, g.gY + 28);
ctx.textAlign = 'left';
}
this._caption(ctx, 'Действие = Противодействие\nF₁ = −F₂', W, H);
}
/* ── Закон III — Сцена B : столкновение ─────────────────── */
_drawL3B(ctx) {
const { W, H, _g: g } = this;
const { b1, b2, colFlash } = this._3B;
const S = NewtonSim.SCALE;
const cy = g.cy + 15;
/* Дорожка */
ctx.fillStyle = 'rgba(60,90,160,0.07)';
ctx.fillRect(0, cy - 50, W, 100);
/* Тени-следы */
[b1, b2].forEach(b => {
if (Math.abs(b.vx) < 4) return;
ctx.save(); ctx.globalAlpha = 0.14;
for (let s2 = 1; s2 <= 3; s2++) {
ctx.beginPath();
ctx.arc(b.x - b.vx * 0.06 * s2, cy, b.r * (1 - s2 * 0.1), 0, Math.PI * 2);
ctx.fillStyle = b.color; ctx.fill();
}
ctx.restore();
});
/* Шары */
[b1, b2].forEach(b => {
ctx.save();
ctx.shadowColor = b.color; ctx.shadowBlur = 16;
ctx.beginPath(); ctx.arc(b.x, cy, b.r, 0, Math.PI * 2);
const bg2 = ctx.createRadialGradient(b.x - b.r * 0.3, cy - b.r * 0.3, 0, b.x, cy, b.r);
bg2.addColorStop(0, _nwt_lighten(b.color, 65));
bg2.addColorStop(1, b.color);
ctx.fillStyle = bg2; ctx.fill();
ctx.restore();
/* Масса и скорость */
ctx.font = 'bold 12px monospace'; ctx.fillStyle = 'rgba(225,235,255,0.9)';
ctx.textAlign = 'center';
ctx.fillText(`${b.mass} кг`, b.x, cy + b.r + 19);
if (Math.abs(b.vx) > 6) {
ctx.fillStyle = '#FFD166';
ctx.fillText(`v=${( b.vx / S ).toFixed(1)}`, b.x, cy - b.r - 8);
}
ctx.textAlign = 'left';
});
/* Вспышка сил при ударе */
if (colFlash > 0.06) {
const mx = (b1.x + b2.x) / 2;
const fY = cy - b1.r - 28;
const a = Math.min(1, colFlash * 2.5);
const len = 65 * a;
this._arrow(ctx, mx, fY, mx - len, fY, '#EF476F', 'F₁₂', 2.5);
this._arrow(ctx, mx, fY + 8, mx + len, fY + 8, '#4CC9F0', 'F₂₁', 2.5);
ctx.save(); ctx.globalAlpha = a;
ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166';
ctx.textAlign = 'center'; ctx.fillText('|F₁₂| = |F₂₁|', mx, fY - 18); ctx.restore();
}
/* Импульс */
const p1 = (b1.mass * b1.vx / S).toFixed(2);
const p2 = (b2.mass * b2.vx / S).toFixed(2);
const pt = ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2);
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
ctx.textAlign = 'center';
ctx.fillText(`p₁ = ${p1} p₂ = ${p2} p(сумм) = ${pt} кг·м/с`, W / 2, H * 0.12);
ctx.textAlign = 'left';
this._caption(ctx, 'Δp₁ = −Δp₂ (импульс сохраняется)', W, H);
}
/* ── Закон III — Сцена C : ракета ───────────────────────── */
_drawL3C(ctx) {
const { W, H } = this;
const s = this._3C;
const S = NewtonSim.SCALE;
const rx = W / 2;
this._stars(ctx);
/* Частицы выхлопа */
for (const p of s.particles) {
ctx.save();
ctx.globalAlpha = p.life * 0.85;
const col = p.life > 0.6 ? '#FFD166' : p.life > 0.3 ? '#FF6B35' : '#EF476F';
ctx.shadowColor = col; ctx.shadowBlur = 7;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * p.life, 0, Math.PI * 2);
ctx.fillStyle = col; ctx.fill();
ctx.restore();
}
/* Ракета */
const ry = s.ry;
ctx.save(); ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 18;
/* Фюзеляж */
_nwt_rrect(ctx, rx - 17, ry - 50, 34, 62, 7);
const rg = ctx.createLinearGradient(rx - 17, 0, rx + 17, 0);
rg.addColorStop(0, '#1a3a5a'); rg.addColorStop(0.5, '#4CC9F0'); rg.addColorStop(1, '#1a3a5a');
ctx.fillStyle = rg; ctx.fill();
ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1.5; ctx.stroke();
/* Нос */
ctx.beginPath();
ctx.moveTo(rx, ry - 72); ctx.lineTo(rx - 15, ry - 50); ctx.lineTo(rx + 15, ry - 50);
ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill();
/* Плавники */
[[-1], [1]].forEach(([dx]) => {
ctx.beginPath();
ctx.moveTo(rx + dx * 17, ry + 12);
ctx.lineTo(rx + dx * 32, ry + 32);
ctx.lineTo(rx + dx * 17, ry + 22);
ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill();
});
/* Иллюминатор */
ctx.beginPath(); ctx.arc(rx, ry - 20, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(200,240,255,0.25)'; ctx.fill();
ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
/* Стрелки сил */
if (s.running) {
const g_vis = NewtonSim.G * 0.42;
const a_thrust = 220 / s.rmass;
const a_net = (a_thrust - g_vis).toFixed(1);
this._arrow(ctx, rx, ry - 55, rx, ry - 55 - 52, '#7BF5A4', `F тяга`, 2.5);
this._arrow(ctx, rx - 38, ry, rx - 38, ry + 36, '#FFD166', 'mg', 1.8);
this._arrow(ctx, rx, ry + 25, rx, ry + 80, '#EF476F', 'F газ', 2.5);
ctx.font = '12px monospace'; ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center';
ctx.fillText(`a = F/m g = ${a_net} м/с²`, rx, ry - 110);
ctx.textAlign = 'left';
}
/* Falling after fuel out — show gravity arrow */
if (s.fuel <= 0 && !s.stopped) {
this._arrow(ctx, rx, ry + 25, rx, ry + 65, '#EF476F', 'mg↓', 2.5);
ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#EF476F';
ctx.textAlign = 'center'; ctx.fillText('Топливо кончилось — ракета падает!', W / 2, H * 0.15); ctx.textAlign = 'left';
}
/* Инфо */
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)';
ctx.textAlign = 'center';
ctx.fillText(`Масса: ${s.rmass.toFixed(1)} кг Топливо: ${(s.fuel * 100).toFixed(0)}%`, W / 2, H * 0.94);
ctx.textAlign = 'left';
if (s.fuel <= 0 && s.stopped) {
ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#FFD166';
ctx.textAlign = 'center'; ctx.fillText('Ракета приземлилась. Нажмите «Запуск» для сброса.', W / 2, H * 0.15); ctx.textAlign = 'left';
} else if (!s.running && s.fuel > 0) {
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)';
ctx.textAlign = 'center'; ctx.fillText('Нажмите «Запуск» для включения двигателя', W / 2, H * 0.50); ctx.textAlign = 'left';
}
this._caption(ctx, 'Газ вниз → ракета вверх\n(3-й закон Ньютона)', W, H);
}
/* ── Вспомогательные рисовалки ──────────────────────────── */
_ground(ctx, gY, W) {
const mu = this.mu;
/* Поверхность */
const gg = ctx.createLinearGradient(0, gY, 0, gY + 42);
gg.addColorStop(0, mu < 0.1 ? '#182535' : mu < 0.45 ? '#1c1f2d' : '#201420');
gg.addColorStop(1, '#0c101a');
ctx.fillStyle = gg; ctx.fillRect(0, gY, W, 55);
/* Линия */
ctx.strokeStyle = mu < 0.1 ? 'rgba(76,201,240,0.42)' : mu < 0.45 ? 'rgba(155,93,229,0.42)' : 'rgba(239,71,111,0.42)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, gY); ctx.lineTo(W, gY); ctx.stroke();
/* Штриховка */
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
for (let x = 0; x < W; x += 22) {
ctx.beginPath(); ctx.moveTo(x, gY); ctx.lineTo(x + 12, gY + 12); ctx.stroke();
}
/* Шероховатость (при высоком трении) */
if (mu > 0.06) {
ctx.fillStyle = `rgba(255,255,255,${mu * 0.055})`;
for (let x = 9; x < W; x += 20) {
ctx.beginPath(); ctx.arc(x, gY + 5, 2.5, 0, Math.PI * 2); ctx.fill();
}
}
}
_stars(ctx) {
const { W, H } = this;
for (let i = 0; i < 65; i++) {
const x = ((i * 139.5 + 7) % W);
const y = ((i * 97.3 + 5) % (H * 0.88));
const r = i % 8 === 0 ? 1.6 : 0.9;
ctx.fillStyle = `rgba(255,255,255,${0.35 + (i % 3) * 0.18})`;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
}
_block(ctx, cx, cy, w, h, color, label) {
ctx.save();
ctx.shadowColor = color; ctx.shadowBlur = 10;
_nwt_rrect(ctx, cx - w / 2, cy - h / 2, w, h, 7);
const bg = ctx.createLinearGradient(cx - w/2, cy - h/2, cx + w/2, cy + h/2);
bg.addColorStop(0, _nwt_lighten(color, 45));
bg.addColorStop(1, color);
ctx.fillStyle = bg; ctx.fill();
ctx.strokeStyle = _nwt_lighten(color, 60); ctx.lineWidth = 1.5; ctx.stroke();
ctx.shadowBlur = 0;
ctx.font = 'bold 11px monospace'; ctx.fillStyle = '#fff';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, cx, cy);
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
ctx.restore();
}
_arrow(ctx, x1, y1, x2, y2, color, label, lw = 2) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 5) return;
const ux = dx / len, uy = dy / len;
const hw = 7, hl = 13;
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = lw;
ctx.shadowColor = color; ctx.shadowBlur = 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2 - ux * hl, y2 - uy * hl);
ctx.stroke();
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - ux * hl - uy * hw, y2 - uy * hl + ux * hw);
ctx.lineTo(x2 - ux * hl + uy * hw, y2 - uy * hl - ux * hw);
ctx.closePath(); ctx.fill();
if (label) {
ctx.shadowBlur = 0; ctx.font = '11px monospace'; ctx.fillStyle = color;
const lx = (x1 + x2) / 2 - uy * 16;
const ly = (y1 + y2) / 2 + ux * 16;
ctx.textAlign = 'center'; ctx.fillText(label, lx, ly); ctx.textAlign = 'left';
}
ctx.restore();
}
_fma(ctx, F, m, a, cx, y) {
ctx.save();
ctx.font = 'bold 15px monospace';
ctx.textAlign = 'center';
ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 14;
ctx.fillStyle = 'rgba(123,245,164,0.88)';
ctx.fillText(`F = m·a ${F} Н = ${m} кг × ${a.toFixed(1)} м/с²`, cx, y);
ctx.restore();
}
_graph(ctx, data, x, y, w, h, color, label) {
if (data.length < 2) return;
const max = Math.max(...data, 0.01);
_nwt_rrect(ctx, x, y, w, h, 4);
ctx.fillStyle = 'rgba(0,0,0,0.38)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; ctx.stroke();
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
ctx.beginPath();
data.forEach((v, i) => {
const px = x + (i / (data.length - 1)) * w;
const py = y + h - 3 - (v / max) * (h - 7);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.stroke();
ctx.font = '9px monospace'; ctx.fillStyle = color;
ctx.fillText(label, x + 3, y + 11);
ctx.fillText(max.toFixed(1), x + 3, y + h - 3);
}
_caption(ctx, text, W, H) {
ctx.save();
ctx.font = 'italic 12px sans-serif';
ctx.fillStyle = 'rgba(185,210,255,0.35)';
ctx.textAlign = 'right';
text.split('\n').forEach((line, i) => ctx.fillText(line, W - 16, H * 0.90 + i * 18));
ctx.textAlign = 'left';
ctx.restore();
}
/* ── Info ──────────────────────────────────────────────────── */
info() {
const S = NewtonSim.SCALE;
const base = { law: this.law, scene: this.scene };
if (this.law === 1 && this.scene === 'A') {
const b = this._1A;
const spd = Math.hypot(b.bvx, b.bvy) / S;
const fFr = this.mu * this.mass1 * NewtonSim.G;
return { ...base, v: spd.toFixed(2), fFr: fFr.toFixed(2), mu: this.mu.toFixed(2), m: this.mass1 };
}
if (this.law === 1) {
const s = this._1B;
const spd = Math.hypot(s.bvx, s.bvy) / S;
return { ...base, v: spd.toFixed(2), cut: s.cut };
}
if (this.law === 2) {
const a = this.force / this.mass1;
const v = this._2.b1vx / S;
return { ...base, F: this.force, m: this.mass1, a: a.toFixed(2), v: v.toFixed(2) };
}
if (this.scene === 'A') {
const vBall = this._3A.ball ? (this._3A.ball.vx / S).toFixed(1) : '—';
const vCannon = (this._3A.cvx / S).toFixed(2);
return { ...base, vBall, vCannon, m1: this.mass1, m2: this.mass2 };
}
if (this.scene === 'B') {
const { b1, b2 } = this._3B;
return { ...base,
p1: (b1.mass * b1.vx / S).toFixed(2),
p2: (b2.mass * b2.vx / S).toFixed(2),
pt: ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2),
};
}
/* III-C rocket */
const s = this._3C;
const g_vis = NewtonSim.G * 0.42;
const a_net = s.running ? (220 / s.rmass - g_vis) : (s.stopped ? 0 : -g_vis);
return { ...base,
a: a_net.toFixed(1),
v: Math.abs(s.rvy / S).toFixed(2),
fuel: (s.fuel * 100).toFixed(0),
m: s.rmass.toFixed(1),
};
}
}
/* ── Утилиты ─────────────────────────────────────────────────── */
function _nwt_rrect(ctx, x, y, w, h, r) {
if (w <= 0 || h <= 0) return;
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function _nwt_lighten(hex, d) {
const n = parseInt(hex.slice(1), 16);
const c = v => Math.max(0, Math.min(255, v));
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
}
/* ─── lab UI init ─────────────────────────────────── */
var newtonSim = null;
var sandboxSim = null;
let _dynMode = 'sandbox'; // current mode: 'sandbox' | 'law1' | 'law2' | 'law3'
function _openDynamics(preset) {
document.getElementById('sim-topbar-title').textContent = 'Динамика';
_simShow('sim-dynamics');
_simShow('ctrl-dynamics');
requestAnimationFrame(() => requestAnimationFrame(() => {
// init sandbox
const sbCanvas = document.getElementById('sandbox-canvas');
if (!sandboxSim) {
sandboxSim = new ForceSandboxSim(sbCanvas);
sandboxSim.onUpdate = _sbUpdateUI;
}
// init newton
const nwCanvas = document.getElementById('newton-canvas');
if (!newtonSim) {
newtonSim = new NewtonSim(nwCanvas);
newtonSim.onUpdate = _newtonUpdateUI;
}
// activate current mode
dynMode(_dynMode);
if (preset) setTimeout(() => sbPreset(preset), 120);
}));
}
function dynMode(mode, btn) {
_dynMode = mode;
const isSandbox = mode === 'sandbox';
// toggle mode buttons
document.querySelectorAll('.dyn-mode').forEach(b => b.classList.remove('active'));
const modeBtn = document.getElementById('dyn-mode-' + mode);
if (modeBtn) modeBtn.classList.add('active');
// toggle panels
document.getElementById('dyn-sandbox-panel').style.display = isSandbox ? '' : 'none';
document.getElementById('dyn-newton-panel').style.display = isSandbox ? 'none' : '';
// toggle canvases
document.getElementById('sandbox-canvas').style.display = isSandbox ? 'block' : 'none';
document.getElementById('newton-canvas').style.display = isSandbox ? 'none' : 'block';
// toggle topbar tool groups
document.getElementById('ctrl-dyn-sb').style.display = isSandbox ? 'contents' : 'none';
document.getElementById('ctrl-dyn-nw').style.display = isSandbox ? 'none' : 'contents';
if (isSandbox) {
// stop newton, start sandbox
if (newtonSim) newtonSim.stop();
if (sandboxSim) { sandboxSim.fit(); sandboxSim.start(); }
_sbUpdateUI(sandboxSim ? sandboxSim.info() : null);
} else {
// stop sandbox, switch newton law
if (sandboxSim) sandboxSim.stop();
const lawN = mode === 'law1' ? 1 : mode === 'law2' ? 2 : 3;
if (newtonSim) {
newtonSim.setLaw(lawN);
newtonSim.fit();
newtonSim.start();
_newtonSyncUI();
_newtonUpdateUI(newtonSim.info());
}
}
}
function dynPause() {
if (_dynMode === 'sandbox') {
if (sandboxSim) sandboxSim.togglePause();
} else {
if (newtonSim) newtonSim.togglePause();
}
}
function dynReset() {
if (_dynMode === 'sandbox') {
sbReset();
} else {
_resetNewtonScene();
}
}
const _NEWTON_SCENES = {
1: {
A: { desc: 'Закон инерции: тело скользит по поверхности. Нажми на canvas — толкни блок.', action: null },
B: { desc: 'Инерция в орбите: шар вращается на нити. Отруби нить — полетит по касательной!', action: '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> Отрубить нить' },
C: { desc: 'Инерция в космосе: тело движется равномерно, нет сил — нет ускорения.', action: null },
},
2: {
A: { desc: 'Второй закон: F = ma. Прикладывай силу и следи за ускорением и скоростью.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
B: { desc: 'Два тела, разные массы — одинаковая сила. Сравни ускорения!', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
C: { desc: 'Второй закон: изменяй силу и массу ползунками, наблюдай в реальном времени.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить' },
},
3: {
A: { desc: 'Третий закон: пушка выстрелила — отдача. Импульс сохраняется!', action: 'Выстрел' },
B: { desc: 'Третий закон: два шара сталкиваются — силы равны и противоположны.', action: '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Столкнуть' },
C: { desc: 'Реактивное движение: ракета выбрасывает газ — летит в обратную сторону.', action: 'Двигатель' },
},
};
const _NEWTON_PRESETS = {
1: [
{ label: 'Космос', fn: 'space' },
{ label: 'Лёд', fn: 'ice' },
{ label: 'Асфальт', fn: 'asphalt' },
{ label: 'Резина', fn: 'rubber' },
],
2: [
{ label: 'Лёгкий', fn: 'light' },
{ label: 'Тяжёлый', fn: 'heavy' },
{ label: 'Сравнить', fn: 'compare' },
],
3: [
{ label: 'Большая пушка', fn: 'big_cannon' },
{ label: 'Маленькая', fn: 'small_cannon' },
{ label: 'Равные шары', fn: 'equal_balls' },
],
};
// _openNewton is now handled by _openDynamics + dynMode
// newtonLaw is now handled by dynMode('law1'/'law2'/'law3')
function newtonScene(s, topBtn, panelBtn) {
if (!newtonSim) return;
newtonSim.setScene(s);
document.querySelectorAll('.nscene-btn').forEach(b => {
b.classList.toggle('active', b.id === 'nscn-' + s || b.id === 'nscn-panel-' + s);
});
_newtonSyncUI();
_newtonUpdateUI(newtonSim.info());
}
function _newtonSyncUI() {
if (!newtonSim) return;
const law = newtonSim.law;
const scene = newtonSim.scene;
const sceneData = (_NEWTON_SCENES[law] || {})[scene] || {};
// description
const desc = document.getElementById('newton-scene-desc');
if (desc) desc.textContent = sceneData.desc || '';
// action button label
const lbl = sceneData.action || (law === 1 ? '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> Нить' : '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Действие');
document.getElementById('newton-action-label').innerHTML = lbl;
document.getElementById('newton-action-top').innerHTML = lbl;
// show/hide sliders
document.getElementById('newton-mu-block').style.display = law === 1 && scene === 'A' ? '' : 'none';
document.getElementById('newton-mass1-block').style.display = (law === 2 || law === 3) ? '' : 'none';
document.getElementById('newton-mass2-block').style.display = law === 3 ? '' : 'none';
document.getElementById('newton-force-block').style.display = law === 2 ? '' : 'none';
// sync slider values from sim
document.getElementById('sl-newton-mu').value = newtonSim.mu;
document.getElementById('newton-mu-val').textContent = newtonSim.mu.toFixed(2);
document.getElementById('sl-newton-m1').value = newtonSim.mass1;
document.getElementById('newton-m1-val').textContent = newtonSim.mass1 + ' кг';
document.getElementById('sl-newton-m2').value = newtonSim.mass2;
document.getElementById('newton-m2-val').textContent = newtonSim.mass2 + ' кг';
document.getElementById('sl-newton-F').value = newtonSim.force;
document.getElementById('newton-F-val').textContent = newtonSim.force + ' Н';
// sync scene highlight buttons in both topbar and panel
['A','B','C'].forEach(s => {
const tb = document.getElementById('nscn-' + s);
const pb = document.getElementById('nscn-panel-' + s);
const on = s === scene;
if (tb) tb.classList.toggle('active', on);
if (pb) pb.classList.toggle('active', on);
});
// presets
const presetsEl = document.getElementById('newton-presets');
const presets = _NEWTON_PRESETS[law] || [];
presetsEl.innerHTML = presets.map(p =>
`<button class="proj-preset-chip" onclick="newtonPreset('${p.fn}')">${p.label}</button>`
).join('');
// scene B/C visibility for law I (B = orbital, C = space — but law I only has A,B)
// scene C doesn't exist for law I/II panel scene picker visibility
const cBtn = document.getElementById('nscn-panel-C');
const cTopBtn = document.getElementById('nscn-C');
const showC = law === 3;
if (cBtn) cBtn.style.display = showC ? '' : 'none';
if (cTopBtn) cTopBtn.style.display = showC ? '' : 'none';
const bBtn = document.getElementById('nscn-panel-B');
const bTopBtn = document.getElementById('nscn-B');
const showB = law !== 2 || true; // law 2 has compare scene B
if (bBtn) bBtn.style.display = '';
if (bTopBtn) bTopBtn.style.display = '';
}
function newtonAction() {
if (!newtonSim) return;
const law = newtonSim.law;
const scene = newtonSim.scene;
if (law === 1 && scene === 'B') newtonSim.cutString();
else if (law === 2) newtonSim.startL2();
else if (law === 3 && scene === 'A') newtonSim.fireCannon();
else if (law === 3 && scene === 'B') newtonSim._reset3B ? newtonSim._reset3B() : null;
else if (law === 3 && scene === 'C') newtonSim.toggleRocket();
_newtonUpdateUI(newtonSim.info());
}
function _resetNewtonScene() {
if (!newtonSim) return;
const law = newtonSim.law;
const scene = newtonSim.scene;
if (law === 1 && scene === 'A') newtonSim.preset('ice');
else if (law === 1) newtonSim.setScene(scene);
else if (law === 2) newtonSim.resetL2 ? newtonSim.resetL2() : newtonSim.setScene(scene);
else newtonSim.setScene(scene);
_newtonUpdateUI(newtonSim.info());
}
function newtonMuChange() {
const v = +document.getElementById('sl-newton-mu').value;
document.getElementById('newton-mu-val').textContent = v.toFixed(2);
if (newtonSim) newtonSim.setMu(v);
}
function newtonMass1Change() {
const v = +document.getElementById('sl-newton-m1').value;
document.getElementById('newton-m1-val').textContent = v + ' кг';
if (newtonSim) newtonSim.setMass1(v);
}
function newtonMass2Change() {
const v = +document.getElementById('sl-newton-m2').value;
document.getElementById('newton-m2-val').textContent = v + ' кг';
if (newtonSim) newtonSim.setMass2(v);
}
function newtonForceChange() {
const v = +document.getElementById('sl-newton-F').value;
document.getElementById('newton-F-val').textContent = v + ' Н';
if (newtonSim) newtonSim.setForce(v);
}
function newtonPreset(name) {
if (!newtonSim) return;
newtonSim.preset(name);
_newtonSyncUI();
_newtonUpdateUI(newtonSim.info());
}
function _newtonUpdateUI(info) {
if (!info) return;
const law = info.law;
const scene = info.scene;
if (law === 1 && scene === 'A') {
document.getElementById('dbar-l1').textContent = 'Закон I-A';
document.getElementById('dbar-v1').textContent = 'Скольжение';
document.getElementById('dbar-l2').textContent = 'Скорость';
document.getElementById('dbar-v2').textContent = info.v + ' м/с';
document.getElementById('dbar-l3').textContent = 'Сила трения';
document.getElementById('dbar-v3').textContent = info.fFr + ' Н';
document.getElementById('dbar-l4').textContent = 'Масса';
document.getElementById('dbar-v4').textContent = info.m + ' кг';
document.getElementById('dbar-l5').textContent = 'μ';
document.getElementById('dbar-v5').textContent = info.mu;
} else if (law === 1) {
document.getElementById('dbar-l1').textContent = 'Закон I-B';
document.getElementById('dbar-v1').textContent = info.cut ? 'Нить срублена' : 'Вращение';
document.getElementById('dbar-l2').textContent = 'Скорость';
document.getElementById('dbar-v2').textContent = info.v + ' м/с';
document.getElementById('dbar-l3').textContent = '';
document.getElementById('dbar-v3').textContent = '—';
document.getElementById('dbar-l4').textContent = '';
document.getElementById('dbar-v4').textContent = '—';
document.getElementById('dbar-l5').textContent = '';
document.getElementById('dbar-v5').textContent = '—';
} else if (law === 2) {
document.getElementById('dbar-l1').textContent = 'Закон II';
document.getElementById('dbar-v1').textContent = 'F = ma';
document.getElementById('dbar-l2').textContent = 'Сила F';
document.getElementById('dbar-v2').textContent = info.F + ' Н';
document.getElementById('dbar-l3').textContent = 'Масса m';
document.getElementById('dbar-v3').textContent = info.m + ' кг';
document.getElementById('dbar-l4').textContent = 'Ускор. a';
document.getElementById('dbar-v4').textContent = info.a + ' м/с²';
document.getElementById('dbar-l5').textContent = 'Скорость';
document.getElementById('dbar-v5').textContent = info.v + ' м/с';
} else if (scene === 'A') {
document.getElementById('dbar-l1').textContent = 'Закон III-A';
document.getElementById('dbar-v1').textContent = 'Пушка';
document.getElementById('dbar-l2').textContent = 'v снаряда';
document.getElementById('dbar-v2').textContent = info.vBall !== '—' ? info.vBall + ' м/с' : '—';
document.getElementById('dbar-l3').textContent = 'v пушки';
document.getElementById('dbar-v3').textContent = info.vCannon + ' м/с';
document.getElementById('dbar-l4').textContent = 'm снаряда';
document.getElementById('dbar-v4').textContent = info.m1 + ' кг';
document.getElementById('dbar-l5').textContent = 'm пушки';
document.getElementById('dbar-v5').textContent = info.m2 + ' кг';
} else if (scene === 'B') {
document.getElementById('dbar-l1').textContent = 'Закон III-B';
document.getElementById('dbar-v1').textContent = 'Удар';
document.getElementById('dbar-l2').textContent = 'p₁';
document.getElementById('dbar-v2').textContent = info.p1 + ' кг·м/с';
document.getElementById('dbar-l3').textContent = 'p₂';
document.getElementById('dbar-v3').textContent = info.p2 + ' кг·м/с';
document.getElementById('dbar-l4').textContent = 'p суммарный';
document.getElementById('dbar-v4').textContent = info.pt + ' кг·м/с';
document.getElementById('dbar-l5').textContent = '';
document.getElementById('dbar-v5').textContent = '—';
} else {
document.getElementById('dbar-l1').textContent = 'Закон III-C';
document.getElementById('dbar-v1').textContent = 'Ракета';
document.getElementById('dbar-l2').textContent = 'Ускорение';
document.getElementById('dbar-v2').textContent = info.a + ' м/с²';
document.getElementById('dbar-l3').textContent = 'Скорость';
document.getElementById('dbar-v3').textContent = info.v + ' м/с';
document.getElementById('dbar-l4').textContent = 'Масса';
document.getElementById('dbar-v4').textContent = info.m + ' кг';
document.getElementById('dbar-l5').textContent = 'Топливо';
document.getElementById('dbar-v5').textContent = info.fuel + '%';
}
}
// _openSandbox is now handled by _openDynamics + dynMode
function sbTool(t, btn) {
if (!sandboxSim) return;
sandboxSim.tool = t;
sandboxSim._springStart = null;
sandboxSim._ropeStart = null;
document.querySelectorAll('.sb-tool-btn').forEach(b => b.classList.toggle('active', b.id === 'sbt-' + t));
document.querySelectorAll('.sb-panel-tool').forEach(b => b.classList.toggle('active', b.id === 'sbpt-' + t));
const canvas = document.getElementById('sandbox-canvas');
canvas.style.cursor = t === 'erase' ? 'not-allowed'
: (t === 'spring' || t === 'rope') ? 'cell'
: t === 'anchor' ? 'copy'
: 'crosshair';
document.getElementById('sb-spring-block').style.display = t === 'spring' ? '' : 'none';
}
function sbSpringKChange() {
const v = +document.getElementById('sl-sb-springk').value;
document.getElementById('sb-springk-val').textContent = v + ' Н/м';
if (sandboxSim) sandboxSim.newSpringK = v;
}
function sbForceMode(m, btn) {
if (!sandboxSim) return;
sandboxSim.forceMode = m;
document.querySelectorAll('.sb-fmode').forEach(b => b.classList.toggle('active', b.id === 'sbfm-' + m));
}
function sbMassChange() {
const v = +document.getElementById('sl-sb-mass').value;
document.getElementById('sb-mass-val').textContent = v + ' кг';
if (sandboxSim) sandboxSim.newMass = v;
}
function sbRestChange() {
const v = +document.getElementById('sl-sb-rest').value;
document.getElementById('sb-rest-val').textContent = v.toFixed(2);
if (sandboxSim) sandboxSim.newRestitution = v;
}
function sbFloorMuChange() {
const v = +document.getElementById('sl-sb-floormu').value;
document.getElementById('sb-floormu-val').textContent = v.toFixed(2);
if (sandboxSim) sandboxSim.floorMu = v;
}
function sbWorldToggle() {
if (!sandboxSim) return;
sandboxSim.gravity = document.getElementById('sb-gravity').checked;
sandboxSim.hasFloor = document.getElementById('sb-floor').checked;
sandboxSim.hasWalls = document.getElementById('sb-walls').checked;
sandboxSim.airDrag = document.getElementById('sb-airdrag').checked;
}
function sbRampToggle() {
if (!sandboxSim) return;
const on = document.getElementById('sb-ramp').checked;
sandboxSim.setRamp(on);
document.getElementById('sb-ramp-block').style.display = on ? '' : 'none';
}
function sbAngleChange() {
const v = +document.getElementById('sl-sb-angle').value;
document.getElementById('sb-angle-val').textContent = v + '°';
if (sandboxSim) sandboxSim.setRampAngle(v);
}
function sbRampMuChange() {
const v = +document.getElementById('sl-sb-rampmu').value;
document.getElementById('sb-rampmu-val').textContent = v.toFixed(2);
if (sandboxSim) sandboxSim.setRampMu(v);
}
function sbDecompToggle() {
if (!sandboxSim) return;
sandboxSim.showDecomp = document.getElementById('sb-decomp').checked;
}
function sbDisplayToggle() {
if (!sandboxSim) return;
sandboxSim.showForces = document.getElementById('sb-forces').checked;
sandboxSim.showVelocity = document.getElementById('sb-vel').checked;
sandboxSim.showFBD = document.getElementById('sb-fbd').checked;
sandboxSim.showEnergy = document.getElementById('sb-energy').checked;
sandboxSim.showTrail = document.getElementById('sb-trail').checked;
}
function sbTimeScale(v, btn) {
if (!sandboxSim) return;
sandboxSim.timeScale = v;
document.querySelectorAll('.sb-time').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function sbPreset(name) {
if (!sandboxSim) return;
sandboxSim.preset(name);
// sync world checkboxes
document.getElementById('sb-gravity').checked = sandboxSim.gravity;
document.getElementById('sb-floor').checked = sandboxSim.hasFloor;
document.getElementById('sb-walls').checked = sandboxSim.hasWalls;
document.getElementById('sb-airdrag').checked = sandboxSim.airDrag;
document.getElementById('sl-sb-floormu').value = sandboxSim.floorMu;
document.getElementById('sb-floormu-val').textContent = sandboxSim.floorMu.toFixed(2);
// sync ramp
document.getElementById('sb-ramp').checked = sandboxSim.ramp;
document.getElementById('sb-ramp-block').style.display = sandboxSim.ramp ? '' : 'none';
document.getElementById('sl-sb-angle').value = sandboxSim.rampAngle;
document.getElementById('sb-angle-val').textContent = sandboxSim.rampAngle + '°';
document.getElementById('sl-sb-rampmu').value = sandboxSim.rampMu;
document.getElementById('sb-rampmu-val').textContent = sandboxSim.rampMu.toFixed(2);
_sbUpdateUI(sandboxSim.info());
}
function sbReset() {
if (!sandboxSim) return;
sandboxSim.reset();
_sbUpdateUI(sandboxSim.info());
}
function _sbUpdateUI(info) {
if (!info) return;
document.getElementById('dbar-l1').textContent = 'Тел / связей';
document.getElementById('dbar-v1').textContent = info.bodies + ' / ' + (info.springs + info.ropes);
document.getElementById('dbar-l2').textContent = 'KE (Дж)';
document.getElementById('dbar-v2').textContent = info.KE;
document.getElementById('dbar-l3').textContent = 'PE (Дж)';
document.getElementById('dbar-v3').textContent = info.PE;
document.getElementById('dbar-l4').textContent = 'ΣF';
document.getElementById('dbar-v4').textContent = info.netF;
document.getElementById('dbar-l5').textContent = 'Время';
document.getElementById('dbar-v5').textContent = info.time + ' с';
}
/* ── chem sandbox ── */