feat(labs): механика V2 — 4 ключевые симы школьной физики расширены

pendulum V2 (472 → 1651 строк):
- Математический (default, сохранён)
- Двойной маятник (Lagrangian RK4, ghost-копия для демо хаоса)
- Связанные маятники (биения, чарт θ₁/θ₂)
- Пружинный (вертикальный/горизонтальный, T=2π√(m/k))
- Физический (4 формы: стержень/обруч/диск/прямоугольник, с моментом инерции)
- Маятник Фуко (Кориолис, slider широты, период прецессии)
- Резонанс (внешняя F₀·cos(ω_d·t), резонансная кривая A(ω))
- Фазовый портрет (универсальный toggle для всех режимов)

collision V2 (~1000 → 2416 строк):
- 1D (default, сохранён)
- 2D под углом (импульс по осям, slider e, до/после стат)
- Multi-ball (N=2-10, стены с отскоками, перемешать)
- Бильярдный стол (6 луз, кий с прицелом, треугольник шаров, реалистичное трение)
- Реф.фрейм ЦМ (universal toggle)

newton V2 (1693 → 2585 строк):
- 4-й закон-таб «Классические задачи»
- Машина Атвуда (a=(m₂-m₁)g/(m₁+m₂), идеальный/массивный блок)
- Тело на наклонной плоскости (FBD, статика/скольжение, slider α/μ/F_app)
- Скатывание шара/цилиндра/обруча (момент инерции, гонка, наглядно почему обруч медленнее)

projectile V2 (1900 → 2400 строк):
- Парашют: F_d = ½C_d·ρ·A·v² с терминальной скоростью v_t = √(2mg/(C_d·ρ·A))
- C_d selector: парашют/куб/сфера/полусфера/диск; раскрытие парашюта на заданной высоте
- Горка-катапульта: v_0 = √(2gL(sinα-μcosα)) автомат
- 10 планет: Земля/Луна/Марс/Юпитер/Меркурий/Венера/Сатурн/Уран/Нептун/Плутон
  с реальными g + плотностью атмосферы (для drag)
- Сравнительный режим: 3 планеты одновременно с разными цветами

Все 4 симы — additive, существующая функциональность сохранена.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-26 14:14:42 +03:00
parent 7ffed45974
commit e46548d06b
5 changed files with 4485 additions and 349 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1374 -195
View File
File diff suppressed because it is too large Load Diff
+529 -54
View File
@@ -1,11 +1,12 @@
'use strict';
/* ═══════════════════════════════════════════════════════════════════
ProjectileSim v3 — physics simulation
ProjectileSim v4 — physics simulation
Features: air drag (RK4) · wind · bounce · speed multiplier
ghost trail comparison · velocity vector labels
range arrow · landing angle · canvas click play/pause
target challenge mode · x/y/vx/vy graphs · dual throw
parachute physics · ramp launch · multi-planet gravity
═══════════════════════════════════════════════════════════════════ */
class ProjectileSim {
@@ -86,6 +87,43 @@ class ProjectileSim {
t: 0, trail: [],
};
/* ── Feature 4: parachute ── */
this.parachute = false; // parachute mode on/off
this.chuteArea = 1.0; // A m² cross-section
this.chuteCd = 1.5; // drag coefficient (preset: parachute)
this.chuteOpenHeight = -1; // -1 = immediate; >=0 = open at this altitude
this._chuteOpen = false; // runtime: is chute deployed?
this._chuteOpenedTs = -999; // perf.now when deployed
this._chimeEmitted = false; // v_t chime fired once per run
/* ── Feature 5: ramp launch ── */
this.ramp = false; // ramp/slope mode on/off
this.rampAngle = 30; // degrees
this.rampLength = 10; // m
this.rampMu = 0.1; // friction coefficient
this._rampV0 = 0; // computed launch speed from ramp
/* ── Feature 6: planet gravity ── */
// planets table: { name, g, rho } (rho = atmospheric density kg/m³)
this.planets = [
{ id: 'earth', name: 'Земля', g: 9.81, rho: 1.225 },
{ id: 'moon', name: 'Луна', g: 1.62, rho: 0 },
{ id: 'mars', name: 'Марс', g: 3.71, rho: 0.020 },
{ id: 'venus', name: 'Венера', g: 8.87, rho: 65 },
{ id: 'jupiter', name: 'Юпитер', g: 24.79, rho: 1.3 },
{ id: 'mercury', name: 'Меркурий', g: 3.7, rho: 0 },
{ id: 'saturn', name: 'Сатурн', g: 10.44, rho: 0.19 },
{ id: 'uranus', name: 'Уран', g: 8.69, rho: 0.42 },
{ id: 'neptune', name: 'Нептун', g: 11.15, rho: 0.45 },
{ id: 'pluto', name: 'Плутон', g: 0.62, rho: 0.0001 },
];
this.planetId = 'earth'; // active planet
this.rho = 1.225; // air density (set by planet or override)
/* ── Feature 6b: multi-planet compare ── */
this.planetCompare = false; // show 3 planet trajectories simultaneously
this.comparePlanets = ['earth', 'moon', 'mars']; // which 3
canvas.addEventListener('click', () => {
if (this.onPlayPause) this.onPlayPause();
});
@@ -110,19 +148,42 @@ class ProjectileSim {
getParams() {
return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
bounce: this.bounce, restitution: this.restitution };
bounce: this.bounce, restitution: this.restitution,
parachute: this.parachute, chuteArea: this.chuteArea, chuteCd: this.chuteCd,
chuteOpenHeight: this.chuteOpenHeight,
ramp: this.ramp, rampAngle: this.rampAngle, rampLength: this.rampLength, rampMu: this.rampMu,
planetId: this.planetId };
}
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
if (h0 !== undefined) this.h0 = +h0;
if (g !== undefined) this.g = +g;
if (drag !== undefined) this.drag = !!drag;
if (Cd !== undefined) this.Cd = +Cd;
if (mass !== undefined) this.mass = Math.max(0.1, +mass);
if (wind !== undefined) this.wind = +wind;
if (bounce !== undefined) this.bounce = !!bounce;
if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution));
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution,
parachute, chuteArea, chuteCd, chuteOpenHeight,
ramp, rampAngle, rampLength, rampMu,
planetId } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
if (h0 !== undefined) this.h0 = +h0;
if (g !== undefined) this.g = +g;
if (drag !== undefined) this.drag = !!drag;
if (Cd !== undefined) this.Cd = +Cd;
if (mass !== undefined) this.mass = Math.max(0.1, +mass);
if (wind !== undefined) this.wind = +wind;
if (bounce !== undefined) this.bounce = !!bounce;
if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution));
if (parachute !== undefined) this.parachute = !!parachute;
if (chuteArea !== undefined) this.chuteArea = Math.max(0.1, +chuteArea);
if (chuteCd !== undefined) this.chuteCd = +chuteCd;
if (chuteOpenHeight !== undefined) this.chuteOpenHeight = +chuteOpenHeight;
if (ramp !== undefined) this.ramp = !!ramp;
if (rampAngle !== undefined) this.rampAngle = Math.max(1, Math.min(89, +rampAngle));
if (rampLength !== undefined) this.rampLength = Math.max(1, +rampLength);
if (rampMu !== undefined) this.rampMu = Math.max(0, Math.min(1, +rampMu));
if (planetId !== undefined) {
this.planetId = planetId;
const pl = this.planets.find(p => p.id === planetId);
if (pl) {
this.g = pl.g;
this.rho = pl.rho;
}
}
this._computePath();
if (this.dualMode) this._computeP2Path();
this._resetFX();
@@ -135,7 +196,10 @@ class ProjectileSim {
play() {
if (this.playing) return;
if (this._pathTf > 0 && this.t >= this._pathTf) this._resetFX();
this._launchFlash = 1;
this._launchFlash = 1;
this._chuteOpen = this.parachute && this.chuteOpenHeight < 0;
this._chuteOpenedTs = this._chuteOpen ? performance.now() : -999;
this._chimeEmitted = false;
this.playing = true;
this._lastTs = null;
/* reset p2 at launch so both start simultaneously */
@@ -517,7 +581,7 @@ class ProjectileSim {
return;
}
const rho = 1.225, A = 0.00785;
const rho = this.rho, A = 0.00785;
const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0;
const g = this.g, W = this.wind, e = this.restitution;
const maxBounces = this.bounce ? 7 : 0;
@@ -573,12 +637,13 @@ class ProjectileSim {
/* pure analytical solution (no drag/wind/bounce) */
_stateAnalytical(t) {
const rad = this.angle * Math.PI / 180;
const vx = this.v0 * Math.cos(rad);
const vy0 = this.v0 * Math.sin(rad);
const launch = this._effectiveLaunch();
const rad = launch.angle * Math.PI / 180;
const vx = launch.v0 * Math.cos(rad);
const vy0 = launch.v0 * Math.sin(rad);
return {
x: vx * t,
y: this.h0 + vy0 * t - 0.5 * this.g * t * t,
y: launch.h0 + vy0 * t - 0.5 * this.g * t * t,
vx,
vy: vy0 - this.g * t,
};
@@ -586,18 +651,43 @@ class ProjectileSim {
/* analytical flight time (for reference / no-effect comparison) */
_tFlightAnalytical() {
const rad = this.angle * Math.PI / 180;
const vy0 = this.v0 * Math.sin(rad);
const disc = vy0 * vy0 + 2 * this.g * this.h0;
const launch = this._effectiveLaunch();
const rad = launch.angle * Math.PI / 180;
const vy0 = launch.v0 * Math.sin(rad);
const disc = vy0 * vy0 + 2 * this.g * launch.h0;
if (disc < 0) return 0;
return Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
}
_needsNumerical() {
return this.drag || this.wind !== 0 || this.bounce;
return this.drag || this.parachute || this.wind !== 0 || this.bounce || this.ramp;
}
/* RK4 integration — handles drag, wind, bounce */
/* compute launch speed from ramp: v = sqrt(2·g·L·sinα·(1-μ·cosα/sinα))
v = sqrt(2·g·L·(sinα - μ·cosα)) assuming μ < tanα else no motion */
_rampComputeV0() {
const a = this.rampAngle * Math.PI / 180;
const sin = Math.sin(a), cos = Math.cos(a);
const net = sin - this.rampMu * cos;
if (net <= 0) return 0;
return Math.sqrt(2 * this.g * this.rampLength * net);
}
/* effective launch angle = ramp angle when ramp is active */
_effectiveLaunch() {
if (this.ramp) {
const v = this._rampComputeV0();
return { v0: v, angle: this.rampAngle, h0: this.h0 };
}
return { v0: this.v0, angle: this.angle, h0: this.h0 };
}
/* terminal velocity for current parachute config */
_terminalVelocity() {
return Math.sqrt(2 * this.mass * this.g / (this.chuteCd * this.rho * this.chuteArea));
}
/* RK4 integration — handles drag, parachute, wind, bounce, ramp */
_computePath() {
if (!this._needsNumerical()) {
this._path = null;
@@ -605,28 +695,47 @@ class ProjectileSim {
return;
}
const rho = 1.225, A = 0.00785; // air density, ball cross-section
const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0;
const g = this.g;
const W = this.wind;
const e = this.restitution;
const rho = this.rho; // air density (planet-aware)
const A_ball = 0.00785; // small ball cross-section m²
const g = this.g;
const W = this.wind;
const e = this.restitution;
const maxBounces = this.bounce ? 7 : 0;
const mass = Math.max(0.1, this.mass);
const rad = this.angle * Math.PI / 180;
let x = 0, y = this.h0;
let vx = this.v0 * Math.cos(rad);
let vy = this.v0 * Math.sin(rad);
/* simple-drag k factor (ball drag, legacy mode) */
const kBall = this.drag && !this.parachute
? 0.5 * this.Cd * rho * A_ball / mass
: 0;
/* parachute: open immediately if chuteOpenHeight < 0, else on altitude trigger */
const chuteAutoOpen = this.parachute && this.chuteOpenHeight < 0;
const chuteThreshold = this.parachute ? Math.max(0, this.chuteOpenHeight) : Infinity;
const launch = this._effectiveLaunch();
const rad = launch.angle * Math.PI / 180;
let x = 0, y = launch.h0;
let vx = launch.v0 * Math.cos(rad);
let vy = launch.v0 * Math.sin(rad);
let chuteOpen = chuteAutoOpen;
const dt = 0.005;
const path = [{ x, y, vx, vy, t: 0 }];
const path = [{ x, y, vx, vy, t: 0, chuteOpen }];
let bounceCount = 0;
const deriv = (sx, sy, svx, svy) => {
const rvx = svx - W; // velocity relative to wind
const rvy = svy;
const deriv = (sx, sy, svx, svy, chute) => {
const rvx = svx - W;
const rvy = svy;
const speed = Math.sqrt(rvx * rvx + rvy * rvy);
const dragF = speed > 0 ? k * speed : 0;
// wind-only pseudo-force when drag is off (simplified model)
const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
let dragF = 0;
if (chute) {
/* parachute: F_d = 0.5 * Cd * rho * A * v² / m → acceleration */
dragF = speed > 0
? 0.5 * this.chuteCd * rho * this.chuteArea * speed / mass
: 0;
} else if (kBall > 0) {
dragF = speed > 0 ? kBall * speed : 0;
}
const windAcc = (!this.drag && !chute && W !== 0) ? W * 0.05 : 0;
return {
dx: svx,
dy: svy,
@@ -636,10 +745,15 @@ class ProjectileSim {
};
for (let step = 0; step < 200000; step++) {
const k1 = deriv(x, y, vx, vy);
const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2);
const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2);
const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt);
/* check if chute should open by altitude trigger */
if (this.parachute && !chuteOpen && y <= chuteThreshold && y > 0) {
chuteOpen = true;
}
const k1 = deriv(x, y, vx, vy, chuteOpen);
const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2, chuteOpen);
const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2, chuteOpen);
const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt, chuteOpen);
x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6;
y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6;
@@ -655,11 +769,11 @@ class ProjectileSim {
const lvx = prev.vx + (vx - prev.vx) * frac;
const lvy = prev.vy + (vy - prev.vy) * frac;
const lt = prev.t + dt * frac;
path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt });
path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt, chuteOpen });
if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) {
vy = -e * lvy;
vx = lvx * (1 - 0.04); // small horizontal friction
vx = lvx * (1 - 0.04);
y = 0.001;
x = lx;
bounceCount++;
@@ -669,13 +783,72 @@ class ProjectileSim {
break;
}
path.push({ x, y, vx, vy, t });
path.push({ x, y, vx, vy, t, chuteOpen });
}
this._path = path;
this._pathTf = path[path.length - 1].t;
}
/* compute a trajectory for a given planet (for compare mode) */
_computePlanetPath(planetId) {
const pl = this.planets.find(p => p.id === planetId) || this.planets[0];
const rho = pl.rho;
const g = pl.g;
const W = this.wind;
const mass = Math.max(0.1, this.mass);
const A_ball = 0.00785;
const kBall = this.drag ? 0.5 * this.Cd * rho * A_ball / mass : 0;
const rad = this.angle * Math.PI / 180;
let x = 0, y = this.h0;
let vx = this.v0 * Math.cos(rad);
let vy = this.v0 * Math.sin(rad);
const dt = 0.005;
const path = [{ x, y, vx, vy, t: 0 }];
const deriv2 = (sx, sy, svx, svy) => {
const rvx = svx - W;
const rvy = svy;
const speed = Math.sqrt(rvx * rvx + rvy * rvy);
const dragF = speed > 0 ? kBall * speed : 0;
const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
return {
dx: svx, dy: svy,
dvx: -dragF * rvx + windAcc,
dvy: -g - dragF * rvy,
};
};
for (let step = 0; step < 100000; step++) {
const k1 = deriv2(x, y, vx, vy);
const k2 = deriv2(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2);
const k3 = deriv2(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2);
const k4 = deriv2(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt);
x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6;
y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6;
vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6;
vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6;
const t = (step + 1) * dt;
if (y <= 0) {
const prev = path[path.length - 1];
if (prev && prev.y > 0) {
const frac = prev.y / (prev.y - y);
path.push({
x: prev.x + (x - prev.x) * frac,
y: 0,
vx: prev.vx + (vx - prev.vx) * frac,
vy: prev.vy + (vy - prev.vy) * frac,
t: prev.t + dt * frac,
});
}
break;
}
path.push({ x, y, vx, vy, t });
}
return path;
}
_pathStateAt(t) {
const path = this._path;
if (!path || path.length < 2) return { x: 0, y: this.h0, vx: 0, vy: 0 };
@@ -768,6 +941,39 @@ class ProjectileSim {
if (this.targetMode) this._targetAttempts++;
}
/* parachute: check altitude-triggered deployment */
if (this.parachute && !this._chuteOpen && this.chuteOpenHeight >= 0) {
const cs = this._curState(this.t);
if (cs.y <= this.chuteOpenHeight && cs.y > 0) {
this._chuteOpen = true;
this._chuteOpenedTs = performance.now();
if (window.LabFX) {
LabFX.sound.play('whoosh');
const _vp = this._viewParams;
if (_vp) {
const scX = (_vp.W - _vp.PL - _vp.PR) / _vp.xMax;
const scY = (_vp.H - _vp.PB - _vp.PT) / _vp.yMax;
LabFX.particles.emit({
ctx: this.ctx,
x: _vp.PL + cs.x * scX, y: _vp.H - _vp.PB - cs.y * scY,
count: 30, color: ['#06D6E0', '#FFD166'], speed: 90,
spread: Math.PI, angle: -Math.PI / 2, life: 800, glow: true, shape: 'spark',
});
}
}
}
}
/* parachute: chime when ~90% terminal velocity reached */
if (this.parachute && this._chuteOpen && !this._chimeEmitted) {
const vt = this._terminalVelocity();
const spd = Math.sqrt(cur.vx ** 2 + cur.vy ** 2);
if (spd <= vt * 1.1) {
this._chimeEmitted = true;
if (window.LabFX) LabFX.sound.play('chime');
}
}
/* target hit detection on this step interval */
this._checkTargetHits(prevT, Math.min(this.t, tf));
@@ -830,11 +1036,14 @@ class ProjectileSim {
}
_resetFX() {
this.t = 0;
this._trail = [];
this._sparks = [];
this._impactTs = -999;
this._launchFlash = 0;
this.t = 0;
this._trail = [];
this._sparks = [];
this._impactTs = -999;
this._launchFlash = 0;
this._chuteOpen = this.parachute && this.chuteOpenHeight < 0;
this._chuteOpenedTs = -999;
this._chimeEmitted = false;
this._computePath();
if (this.dualMode) {
this._p2.t = 0;
@@ -966,6 +1175,33 @@ class ProjectileSim {
for (let y = stY; y < yMax * 0.97; y += stY)
ctx.fillText(_projFmt(y) + ' м', PL - 6, tpy(y));
/* ── 6.4. Planet compare trajectories ── */
if (this.planetCompare) {
const PCOLORS = ['#06D6E0', '#7BF5A4', '#F15BB5'];
for (let ci = 0; ci < this.comparePlanets.length; ci++) {
const pid = this.comparePlanets[ci];
const pl = this.planets.find(p => p.id === pid);
if (!pl) continue;
const ppath = this._computePlanetPath(pid);
const col = PCOLORS[ci % PCOLORS.length];
ctx.strokeStyle = col; ctx.lineWidth = 1.8; ctx.setLineDash([5, 3]);
ctx.beginPath();
for (let i = 0; i < ppath.length; i++) {
const pp = ppath[i];
i === 0 ? ctx.moveTo(tpx(pp.x), tpy(Math.max(0, pp.y)))
: ctx.lineTo(tpx(pp.x), tpy(Math.max(0, pp.y)));
}
ctx.stroke(); ctx.setLineDash([]);
/* label at landing */
const plast = ppath[ppath.length - 1];
const plx = tpx(plast.x), ply = tpy(0);
ctx.fillStyle = col;
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(pl.name, plx, ply + 8);
ctx.fillText(_projFmt(plast.x) + ' м', plx, ply + 20);
}
}
/* ── 6.5. Ghost trails ── */
for (const gh of this._ghosts) {
ctx.strokeStyle = gh.color; ctx.lineWidth = 2;
@@ -1009,7 +1245,7 @@ class ProjectileSim {
}
/* ── 7. Launch platform ── */
if (this.h0 > 0.2) {
if (this.h0 > 0.2 && !this.ramp) {
const px0 = tpx(0), py0 = tpy(0), pyH = tpy(this.h0);
ctx.strokeStyle = 'rgba(255,200,60,.35)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
@@ -1022,6 +1258,40 @@ class ProjectileSim {
ctx.fillText(_projFmt(this.h0) + ' м', px0 - 14, pyH);
}
/* ── 7.5. Ramp visualization ── */
if (this.ramp) {
const rA = this.rampAngle * Math.PI / 180;
const rL = this.rampLength;
/* ramp starts at (0, h0) going left-down at angle rA */
const rxStart = -rL * Math.cos(rA);
const ryStart = this.h0;
const rxEnd = 0;
const ryEnd = this.h0 + rL * Math.sin(rA); /* ramp bottom */
/* clamp start x to left edge */
const sx = Math.max(PL, tpx(rxStart));
const sy = tpy(ryStart);
const ex = tpx(rxEnd);
const ey = tpy(ryEnd);
/* ramp surface */
ctx.strokeStyle = 'rgba(255,180,50,.7)'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
/* angle arc */
ctx.strokeStyle = 'rgba(255,200,60,.5)'; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.arc(ex, ey, 22, -Math.PI / 2, -rA - Math.PI / 2, true); ctx.stroke();
ctx.fillStyle = 'rgba(255,200,60,.8)';
ctx.font = '9px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.fillText(this.rampAngle + '°', ex + 25, ey - 2);
/* ramp speed label */
const rv = this._rampComputeV0();
ctx.fillStyle = 'rgba(255,214,102,.7)';
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('v = ' + rv.toFixed(1) + ' м/с', (sx + ex) / 2, (sy + ey) / 2 - 4);
}
/* ── 8. Reference / full trajectories ── */
if (tf > 0) {
// analytical reference (always shown as faint dashed)
@@ -1311,6 +1581,36 @@ class ProjectileSim {
ctx.beginPath(); ctx.arc(bx, by, 10, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke();
/* ── 14.5. Parachute ── */
if (this.parachute && this._chuteOpen && this.t < tf && cur.y > 0) {
this._drawParachute(ctx, bx, by);
}
/* ── 14.6. Parachute HUD ── */
if (this.parachute) {
const vt = this._terminalVelocity();
const pct = Math.min(100, Math.round((1 - (speed - vt) / Math.max(vt, 0.01)) * 100));
const pctC = Math.min(100, Math.round(speed / (vt * 2 + 0.01) * 100));
const hudRows = [
'v = ' + speed.toFixed(1) + ' м/с',
'v_t = ' + vt.toFixed(1) + ' м/с',
(this._chuteOpen ? 'Откр' : 'Закр') + ' ' + Math.max(0, Math.min(100, Math.round(vt / Math.max(speed, 0.01) * 100))) + '%',
];
const hudX = W - PR - 8;
const hudY = PT + 34;
ctx.font = '9px Manrope, sans-serif';
const maxW = Math.max(...hudRows.map(r => ctx.measureText(r).width));
ctx.fillStyle = 'rgba(5,5,20,.8)';
ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.stroke();
for (let ri = 0; ri < hudRows.length; ri++) {
ctx.fillStyle = ri === 2 ? (this._chuteOpen ? '#7BF5A4' : '#FFD166') : '#06D6E0';
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.fillText(hudRows[ri], hudX - 6, hudY + ri * 16);
}
}
/* ── 15. Velocity arrows + labels ── */
if (speed > 0.3 && this.t < tf) {
const VX_LEN = Math.min(55, 50 * Math.abs(cur.vx) / Math.max(1, this.v0));
@@ -1380,10 +1680,14 @@ class ProjectileSim {
/* ── 18. Info badges (top-right) ── */
let bRight = W - PR - 8;
if (this.drag) {
if (this.drag && !this.parachute) {
this._drawBadge(ctx, bRight, PT + 6, 'Cd=' + this.Cd.toFixed(2) + ' m=' + this.mass + 'кг', 'rgba(239,71,111,.15)', 'rgba(239,71,111,.75)');
bRight -= 130;
}
if (this.parachute) {
this._drawBadge(ctx, bRight, PT + 6, 'A=' + this.chuteArea.toFixed(1) + 'м² Cd=' + this.chuteCd, 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
bRight -= 150;
}
if (this.wind !== 0) {
const dir = this.wind > 0 ? '→' : '←';
this._drawBadge(ctx, bRight, PT + 6, dir + ' ветер ' + Math.abs(this.wind) + 'м/с', 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
@@ -1391,6 +1695,17 @@ class ProjectileSim {
}
if (this.bounce) {
this._drawBadge(ctx, bRight, PT + 6, '↩ e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)');
bRight -= 100;
}
if (this.ramp) {
this._drawBadge(ctx, bRight, PT + 6, 'Горка ' + this.rampAngle + '° L=' + this.rampLength + 'м', 'rgba(255,180,50,.12)', 'rgba(255,180,50,.85)');
bRight -= 140;
}
if (this.planetId !== 'earth') {
const pl = this.planets.find(p => p.id === this.planetId);
if (pl) {
this._drawBadge(ctx, bRight, PT + 6, pl.name + ' g=' + pl.g, 'rgba(123,245,164,.1)', 'rgba(123,245,164,.8)');
}
}
/* speed badge bottom-right */
@@ -1607,6 +1922,47 @@ class ProjectileSim {
}
ctx.restore();
}
/* Draw parachute dome above the ball */
_drawParachute(ctx, bx, by) {
const now = performance.now();
const age = (now - this._chuteOpenedTs) / 1000;
/* deploy animation: scale from 0 to 1 over 0.3 s */
const scale = Math.min(1, age / 0.3);
const R = 26 * scale; /* dome radius */
const cy = by - R - 12; /* centre of dome */
ctx.save();
/* dome fill */
const fill = ctx.createRadialGradient(bx, cy, 0, bx, cy, R);
fill.addColorStop(0, 'rgba(6,214,224,0.55)');
fill.addColorStop(0.7, 'rgba(6,214,224,0.25)');
fill.addColorStop(1, 'rgba(6,214,224,0.05)');
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(bx, cy, R, Math.PI, 0);
ctx.lineTo(bx + R, cy);
ctx.closePath();
ctx.fill();
/* dome border */
ctx.strokeStyle = 'rgba(6,214,224,0.75)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(bx, cy, R, Math.PI, 0);
ctx.stroke();
/* suspension lines (4) */
ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 0.8;
for (let li = 0; li < 4; li++) {
const a = Math.PI + (li + 0.5) / 4 * Math.PI;
ctx.beginPath();
ctx.moveTo(bx + Math.cos(a) * R, cy + Math.sin(a) * R);
ctx.lineTo(bx, by - 10);
ctx.stroke();
}
ctx.restore();
}
}
/* ── module helpers ── */
@@ -1921,5 +2277,124 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
if (pSim && pSim._graphsVisible && !pSim.playing) pSim.drawGraphs();
}
/* ── Feature 4: Parachute UI ── */
function projToggleParachute(rowEl) {
if (!pSim) return;
pSim.parachute = !pSim.parachute;
const on = pSim.parachute;
if (rowEl) rowEl.classList.toggle('active', on);
const tog = document.getElementById('chute-toggle');
if (tog) {
tog.style.background = on ? 'var(--cyan,#06D6E0)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
}
document.getElementById('chute-params').style.display = on ? '' : 'none';
/* parachute and simple drag are mutually exclusive */
if (on) pSim.setParams({ parachute: true, drag: false });
else pSim.setParams({ parachute: false });
/* also reflect drag row */
const dragRow = document.getElementById('drag-row');
if (dragRow) dragRow.classList.toggle('active', false);
const dragTog = document.getElementById('drag-toggle');
if (dragTog) {
dragTog.style.background = 'rgba(255,255,255,0.12)';
dragTog.querySelector('span').style.marginLeft = '2px';
}
document.getElementById('drag-params').style.display = 'none';
}
function projChuteAreaChange() {
const A = +document.getElementById('sl-chute-area').value / 10;
document.getElementById('p-chute-area').textContent = A.toFixed(1) + ' м²';
if (pSim) pSim.setParams({ chuteArea: A });
}
function projChuteCdChange() {
const sel = document.getElementById('sel-chute-cd');
if (!sel || !pSim) return;
const cd = +sel.value;
pSim.setParams({ chuteCd: cd });
}
function projChuteHeightChange() {
const val = +document.getElementById('sl-chute-height').value;
const h = val <= 0 ? -1 : val;
const lbl = document.getElementById('p-chute-height');
if (lbl) lbl.textContent = h < 0 ? 'Сразу' : h.toFixed(0) + ' м';
if (pSim) pSim.setParams({ chuteOpenHeight: h });
}
/* ── Feature 5: Ramp UI ── */
function projToggleRamp(rowEl) {
if (!pSim) return;
pSim.ramp = !pSim.ramp;
const on = pSim.ramp;
if (rowEl) rowEl.classList.toggle('active', on);
const tog = document.getElementById('ramp-toggle');
if (tog) {
tog.style.background = on ? 'rgba(255,180,50,.9)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
}
document.getElementById('ramp-params').style.display = on ? '' : 'none';
pSim.setParams({ ramp: on });
}
function projRampChange() {
const angle = +document.getElementById('sl-ramp-angle').value;
const length = +document.getElementById('sl-ramp-length').value;
const mu = +document.getElementById('sl-ramp-mu').value / 100;
document.getElementById('p-ramp-angle').textContent = angle + '°';
document.getElementById('p-ramp-length').textContent = length + ' м';
document.getElementById('p-ramp-mu').textContent = mu.toFixed(2);
if (pSim) pSim.setParams({ rampAngle: angle, rampLength: length, rampMu: mu });
}
/* ── Feature 6: Planet UI ── */
function projPlanetChange() {
const sel = document.getElementById('sel-planet');
if (!sel || !pSim) return;
const planetId = sel.value;
pSim.planetId = planetId;
const pl = pSim.planets.find(p => p.id === planetId);
if (pl) {
pSim.g = pl.g;
pSim.rho = pl.rho;
/* sync g slider */
const gSl = document.getElementById('sl-g');
if (gSl) {
gSl.value = Math.min(+gSl.max, pl.g);
document.getElementById('p-g').textContent = pl.g.toFixed(2) + ' м/с²';
}
}
pSim._computePath();
if (pSim.dualMode) pSim._computeP2Path();
pSim._resetFX();
pSim.draw();
pSim._emit();
}
function projTogglePlanetCompare() {
if (!pSim) return;
pSim.planetCompare = !pSim.planetCompare;
const on = pSim.planetCompare;
const btn = document.getElementById('proj-planet-compare-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Сравн.планет: Вкл' : 'Сравн.планет: Выкл';
}
const panel = document.getElementById('proj-planet-compare-panel');
if (panel) panel.style.display = on ? '' : 'none';
pSim.draw();
}
function projPlanetCompareChange(idx, val) {
if (!pSim) return;
pSim.comparePlanets[idx] = val;
pSim.draw();
}
/* ── collision ── */
+353 -30
View File
@@ -141,7 +141,7 @@
<button class="zoom-btn" id="coll-play-btn" onclick="collPlayPause()" title="Запустить">
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<button class="zoom-btn" onclick="cSim && cSim.reset()" title="Сброс">
<button class="zoom-btn" onclick="var _as=_activeSim&&_activeSim();if(_as)_as.reset();_collSyncBtn();" title="Сброс">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</button>
</div>
@@ -237,6 +237,10 @@
<button class="zoom-btn nscene-btn active" id="nscn-A" onclick="newtonScene('A',this)" style="font-size:.65rem;font-weight:800">A</button>
<button class="zoom-btn nscene-btn" id="nscn-B" onclick="newtonScene('B',this)" style="font-size:.65rem;font-weight:800">B</button>
<button class="zoom-btn nscene-btn" id="nscn-C" onclick="newtonScene('C',this)" style="font-size:.65rem;font-weight:800">C</button>
<!-- classic scenes (law 4, hidden by default) -->
<button class="zoom-btn nscene-btn cl-scene-btn" id="nscn-cl-atwood" data-scene="atwood" onclick="classicScene('atwood')" style="display:none;font-size:.6rem;font-weight:800">Атвуд</button>
<button class="zoom-btn nscene-btn cl-scene-btn" id="nscn-cl-ramp" data-scene="ramp" onclick="classicScene('ramp')" style="display:none;font-size:.6rem;font-weight:800">Наклон</button>
<button class="zoom-btn nscene-btn cl-scene-btn" id="nscn-cl-roll" data-scene="roll" onclick="classicScene('roll')" style="display:none;font-size:.6rem;font-weight:800">Качение</button>
<div style="width:1px;height:20px;background:rgba(255,255,255,0.15);margin:0 3px"></div>
<button class="zoom-btn" id="newton-action-top" onclick="newtonAction()" style="font-size:.65rem;font-weight:800;font-family:Manrope,sans-serif"><svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Действие</button>
</span>
@@ -1499,19 +1503,84 @@
<button class="mag-mode-btn dyn-mode" id="dyn-mode-law1" onclick="dynMode('law1',this)" style="flex:1;font-size:.68rem;padding:5px 0">I закон</button>
<button class="mag-mode-btn dyn-mode" id="dyn-mode-law2" onclick="dynMode('law2',this)" style="flex:1;font-size:.68rem;padding:5px 0">II закон</button>
<button class="mag-mode-btn dyn-mode" id="dyn-mode-law3" onclick="dynMode('law3',this)" style="flex:1;font-size:.68rem;padding:5px 0">III закон</button>
<button class="mag-mode-btn dyn-mode" id="dyn-mode-law4" onclick="dynMode('law4',this)" style="flex:1;font-size:.68rem;padding:5px 0">Классич.</button>
</div>
<!-- ══ Newton controls (shown in law modes) ══ -->
<div id="dyn-newton-panel" style="display:none">
<!-- Scene selector -->
<div class="gp-section-title" style="margin-bottom:8px">Сцена</div>
<!-- Scene selector (standard A/B/C — hidden for law 4) -->
<div class="gp-section-title" style="margin-bottom:8px" id="newton-scene-title">Сцена</div>
<div style="display:flex;gap:5px;margin-bottom:12px" id="newton-scene-row">
<button class="mag-mode-btn nscene-btn active" id="nscn-panel-A" onclick="newtonScene('A',null,this)" style="flex:1;font-size:.72rem">A</button>
<button class="mag-mode-btn nscene-btn" id="nscn-panel-B" onclick="newtonScene('B',null,this)" style="flex:1;font-size:.72rem">B</button>
<button class="mag-mode-btn nscene-btn" id="nscn-panel-C" onclick="newtonScene('C',null,this)" style="flex:1;font-size:.72rem">C</button>
</div>
<!-- Classic scene selector (law 4 only) -->
<div id="newton-classic-panel" style="display:none">
<div style="display:flex;gap:5px;margin-bottom:10px">
<button class="mag-mode-btn cl-scene-btn active" id="nscn-panel-cl-atwood" data-scene="atwood" onclick="classicScene('atwood')" style="flex:1;font-size:.68rem">Атвуд</button>
<button class="mag-mode-btn cl-scene-btn" id="nscn-panel-cl-ramp" data-scene="ramp" onclick="classicScene('ramp')" style="flex:1;font-size:.68rem">Наклон</button>
<button class="mag-mode-btn cl-scene-btn" id="nscn-panel-cl-roll" data-scene="roll" onclick="classicScene('roll')" style="flex:1;font-size:.68rem">Качение</button>
</div>
<!-- Atwood sub-panel -->
<div id="cl-sub-atwood">
<div class="param-block">
<div class="param-header"><span class="param-name">m₁ (кг)</span><span class="param-val" id="atw-m1-val">5 кг</span></div>
<input type="range" class="param-slider" id="sl-atw-m1" min="1" max="20" value="5" oninput="atwM1Change()">
</div>
<div class="param-block">
<div class="param-header"><span class="param-name">m₂ (кг)</span><span class="param-val" id="atw-m2-val">8 кг</span></div>
<input type="range" class="param-slider" id="sl-atw-m2" min="1" max="20" value="8" oninput="atwM2Change()">
</div>
<div class="gp-section-title" style="margin:4px 0">Блок</div>
<div style="display:flex;gap:5px;margin-bottom:8px">
<button class="mag-mode-btn atw-massive-btn active" data-val="false" onclick="atwMassiveToggle(false)" style="flex:1;font-size:.68rem">Идеальный</button>
<button class="mag-mode-btn atw-massive-btn" data-val="true" onclick="atwMassiveToggle(true)" style="flex:1;font-size:.68rem">Массивный</button>
</div>
</div>
<!-- Ramp sub-panel -->
<div id="cl-sub-ramp" style="display:none">
<div class="param-block">
<div class="param-header"><span class="param-name">Угол α</span><span class="param-val" id="ramp-alpha-val">30°</span></div>
<input type="range" class="param-slider" id="sl-ramp-alpha" min="1" max="89" value="30" oninput="rampAlphaChange()">
</div>
<div class="param-block">
<div class="param-header"><span class="param-name">Масса m (кг)</span><span class="param-val" id="newton-m1-ramp-val">5 кг</span></div>
<input type="range" class="param-slider" id="sl-ramp-mass" min="1" max="20" value="5" oninput="rampMassChange()">
</div>
<div class="param-block">
<div class="param-header"><span class="param-name">Коэфф. трения μ</span><span class="param-val" id="ramp-mu-val">0.20</span></div>
<input type="range" class="param-slider" id="sl-ramp-mu" min="0" max="1.5" step="0.01" value="0.20" oninput="rampMuChange()">
</div>
<div class="param-block">
<div class="param-header"><span class="param-name">Внеш. сила F (Н)</span><span class="param-val" id="ramp-force-val">0 Н</span></div>
<input type="range" class="param-slider" id="sl-ramp-force" min="0" max="80" value="0" oninput="rampForceChange()">
</div>
</div>
<!-- Roll sub-panel -->
<div id="cl-sub-roll" style="display:none">
<div class="param-block">
<div class="param-header"><span class="param-name">Угол α</span><span class="param-val" id="roll-alpha-val">20°</span></div>
<input type="range" class="param-slider" id="sl-roll-alpha" min="5" max="60" value="20" oninput="rollAlphaChange()">
</div>
<div class="gp-section-title" style="margin:4px 0">Режим</div>
<div style="display:flex;gap:5px;margin-bottom:8px">
<button class="mag-mode-btn roll-friction-btn active" data-val="false" onclick="rollFrictionToggle(false)" style="flex:1;font-size:.68rem">Качение</button>
<button class="mag-mode-btn roll-friction-btn" data-val="true" onclick="rollFrictionToggle(true)" style="flex:1;font-size:.68rem">+Трение</button>
</div>
<div style="font-size:0.68rem;color:var(--text-3);line-height:1.5;padding:4px 6px;background:rgba(255,255,255,0.03);border-radius:6px;border:1px solid var(--border);margin-bottom:6px">
Шар: a = (5/7)g·sinα<br>
Цилиндр: a = (2/3)g·sinα<br>
Обруч: a = (1/2)g·sinα
</div>
</div>
</div><!-- /#newton-classic-panel -->
<!-- Scene description -->
<div id="newton-scene-desc" style="font-size:0.71rem;color:var(--text-3);line-height:1.6;margin-bottom:10px;padding:6px 8px;background:rgba(255,255,255,0.03);border-radius:8px;border:1px solid var(--border)">
Закон инерции: тело движется равномерно при отсутствии сил.
@@ -1777,6 +1846,117 @@
</div>
</div>
<!-- Parachute -->
<div class="gp-section-title" style="margin-top:6px">Парашют</div>
<label class="tri-layer-row" id="chute-row" onclick="projToggleParachute(this)" style="margin-bottom:6px">
<span class="tri-dot" style="background:rgba(6,214,224,0.5)"></span>
<span class="tri-layer-name">Парашют</span>
<span class="tri-toggle" id="chute-toggle" style="background:rgba(255,255,255,0.12)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px;transition:margin-left .15s"></span></span>
</label>
<div id="chute-params" style="display:none">
<div class="param-block">
<div class="param-header">
<span class="param-name">Площадь A</span>
<span class="param-val" id="p-chute-area">1.0 м²</span>
</div>
<input type="range" class="param-slider" id="sl-chute-area" min="1" max="100" value="10" oninput="projChuteAreaChange()">
</div>
<div class="param-block" style="margin-bottom:6px">
<span class="param-name" style="font-size:.68rem">Форма (Cd)</span>
<select id="sel-chute-cd" onchange="projChuteCdChange()" style="width:100%;margin-top:4px;background:#0d0d2a;color:#fff;border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:4px 6px;font-size:.75rem">
<option value="1.5" selected>Парашют (1.5)</option>
<option value="1.05">Куб (1.05)</option>
<option value="0.47">Сфера (0.47)</option>
<option value="0.42">Полусфера (0.42)</option>
<option value="1.17">Плоский диск (1.17)</option>
</select>
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Раскрыть на h</span>
<span class="param-val" id="p-chute-height">Сразу</span>
</div>
<input type="range" class="param-slider" id="sl-chute-height" min="0" max="100" value="0" oninput="projChuteHeightChange()">
<div style="font-size:.6rem;color:var(--text-3);margin-top:2px">0 = немедленно, &gt;0 = раскрыть на этой высоте</div>
</div>
</div>
<!-- Ramp -->
<div class="gp-section-title" style="margin-top:6px">Наклонная горка</div>
<label class="tri-layer-row" id="ramp-row" onclick="projToggleRamp(this)" style="margin-bottom:6px">
<span class="tri-dot" style="background:rgba(255,180,50,0.5)"></span>
<span class="tri-layer-name">Старт с горки</span>
<span class="tri-toggle" id="ramp-toggle" style="background:rgba(255,255,255,0.12)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px;transition:margin-left .15s"></span></span>
</label>
<div id="ramp-params" style="display:none">
<div class="param-block">
<div class="param-header">
<span class="param-name">Угол горки &alpha;</span>
<span class="param-val" id="p-ramp-angle">30&deg;</span>
</div>
<input type="range" class="param-slider" id="sl-ramp-angle" min="5" max="85" value="30" oninput="projRampChange()">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Длина L</span>
<span class="param-val" id="p-ramp-length">10 м</span>
</div>
<input type="range" class="param-slider" id="sl-ramp-length" min="1" max="50" value="10" oninput="projRampChange()">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Трение &mu;</span>
<span class="param-val" id="p-ramp-mu">0.10</span>
</div>
<input type="range" class="param-slider" id="sl-ramp-mu" min="0" max="80" value="10" oninput="projRampChange()">
<div style="font-size:.6rem;color:var(--text-3);margin-top:2px">v = sqrt(2gL(sin&alpha; &minus; &mu; cos&alpha;))</div>
</div>
</div>
<!-- Planet -->
<div class="gp-section-title" style="margin-top:6px">Планета / гравитация</div>
<div class="param-block" style="margin-bottom:6px">
<select id="sel-planet" onchange="projPlanetChange()" style="width:100%;background:#0d0d2a;color:#fff;border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:4px 6px;font-size:.75rem">
<option value="earth" selected>Земля &mdash; g=9.81</option>
<option value="moon">Луна &mdash; g=1.62</option>
<option value="mars">Марс &mdash; g=3.71</option>
<option value="venus">Венера &mdash; g=8.87</option>
<option value="jupiter">Юпитер &mdash; g=24.79</option>
<option value="mercury">Меркурий &mdash; g=3.7</option>
<option value="saturn">Сатурн &mdash; g=10.44</option>
<option value="uranus">Уран &mdash; g=8.69</option>
<option value="neptune">Нептун &mdash; g=11.15</option>
<option value="pluto">Плутон &mdash; g=0.62</option>
</select>
</div>
<button id="proj-planet-compare-btn" class="proj-preset-chip" onclick="projTogglePlanetCompare()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px;margin-bottom:4px">
<svg class="ic" viewBox="0 0 24 24"><circle cx="5" cy="12" r="3"/><circle cx="12" cy="5" r="3"/><circle cx="19" cy="19" r="3"/><line x1="5" y1="12" x2="12" y2="5"/><line x1="12" y1="5" x2="19" y2="19"/></svg>
<span>Сравн.планет: Выкл</span>
</button>
<div id="proj-planet-compare-panel" style="display:none;font-size:.65rem;color:rgba(255,255,255,.6);margin-bottom:4px">
<div style="display:flex;gap:4px;flex-direction:column">
<div style="display:flex;align-items:center;gap:6px">
<span style="color:#06D6E0;font-weight:700">1:</span>
<select onchange="projPlanetCompareChange(0,this.value)" style="flex:1;background:#0d0d2a;color:#06D6E0;border:1px solid rgba(6,214,224,.3);border-radius:4px;padding:2px 4px;font-size:.7rem">
<option value="earth" selected>Земля</option><option value="moon">Луна</option><option value="mars">Марс</option><option value="venus">Венера</option><option value="jupiter">Юпитер</option><option value="mercury">Меркурий</option><option value="saturn">Сатурн</option><option value="uranus">Уран</option><option value="neptune">Нептун</option><option value="pluto">Плутон</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="color:#7BF5A4;font-weight:700">2:</span>
<select onchange="projPlanetCompareChange(1,this.value)" style="flex:1;background:#0d0d2a;color:#7BF5A4;border:1px solid rgba(123,245,164,.3);border-radius:4px;padding:2px 4px;font-size:.7rem">
<option value="earth">Земля</option><option value="moon" selected>Луна</option><option value="mars">Марс</option><option value="venus">Венера</option><option value="jupiter">Юпитер</option><option value="mercury">Меркурий</option><option value="saturn">Сатурн</option><option value="uranus">Уран</option><option value="neptune">Нептун</option><option value="pluto">Плутон</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="color:#F15BB5;font-weight:700">3:</span>
<select onchange="projPlanetCompareChange(2,this.value)" style="flex:1;background:#0d0d2a;color:#F15BB5;border:1px solid rgba(241,91,181,.3);border-radius:4px;padding:2px 4px;font-size:.7rem">
<option value="earth">Земля</option><option value="moon">Луна</option><option value="mars" selected>Марс</option><option value="venus">Венера</option><option value="jupiter">Юпитер</option><option value="mercury">Меркурий</option><option value="saturn">Сатурн</option><option value="uranus">Уран</option><option value="neptune">Нептун</option><option value="pluto">Плутон</option>
</select>
</div>
</div>
</div>
<div class="gp-section-title" style="margin-top:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<button class="proj-preset-chip" onclick="projPreset(20,45,0,9.81)">Земля 45°</button>
@@ -2017,7 +2197,7 @@
</svg>
<span id="coll-launch-label">Запустить</span>
</button>
<button class="proj-reset-btn" onclick="cSim && cSim.reset(); _collSyncBtn()">
<button class="proj-reset-btn" onclick="var _rs=_activeSim&&_activeSim();if(_rs)_rs.reset();_collSyncBtn()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>
</svg>
@@ -2444,44 +2624,187 @@
<!-- ── PENDULUM sim body ── -->
<div id="sim-pendulum" class="sim-proj-wrap" style="display:none">
<!-- mode bar -->
<div style="display:flex;gap:4px;padding:6px 12px;background:rgba(22,22,38,0.8);flex-wrap:wrap;border-bottom:1px solid rgba(255,255,255,0.07)">
<button class="pend-mode-btn active" data-mode="math" onclick="pendSetMode('math')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(155,93,229,0.2);color:#ccc;cursor:pointer">&#1052;&#1072;&#1090;&#1077;&#1084;&#1072;&#1090;.</button>
<button class="pend-mode-btn" data-mode="double" onclick="pendSetMode('double')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1044;&#1074;&#1086;&#1081;&#1085;&#1086;&#1081;</button>
<button class="pend-mode-btn" data-mode="coupled" onclick="pendSetMode('coupled')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1057;&#1074;&#1103;&#1079;&#1072;&#1085;&#1085;&#1099;&#1077;</button>
<button class="pend-mode-btn" data-mode="spring" onclick="pendSetMode('spring')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1055;&#1088;&#1091;&#1078;&#1080;&#1085;&#1085;&#1099;&#1081;</button>
<button class="pend-mode-btn" data-mode="physical" onclick="pendSetMode('physical')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1060;&#1080;&#1079;&#1080;&#1095;.</button>
<button class="pend-mode-btn" data-mode="foucault" onclick="pendSetMode('foucault')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1060;&#1091;&#1082;&#1086;</button>
<button class="pend-mode-btn" data-mode="resonance" onclick="pendSetMode('resonance')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1056;&#1077;&#1079;&#1086;&#1085;&#1072;&#1085;&#1089;</button>
<button id="btn-pend-phase" onclick="pendTogglePhase()" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer;margin-left:auto">&#1060;&#1072;&#1079;. &#1087;&#1086;&#1088;&#1090;&#1088;&#1077;&#1090;</button>
</div>
<div class="sim-body-wrap">
<div class="proj-panel" style="width:220px;gap:0">
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">θ = <span id="pend-theta-val" style="color:var(--violet);font-weight:700">45</span>°</label>
<input type="range" id="sl-pend-theta" min="5" max="170" step="1" value="45" oninput="pendParam('theta',this.value)" style="flex:1">
<div class="proj-panel" style="width:230px;gap:0;overflow-y:auto;max-height:100%">
<!-- MATH params -->
<div class="pend-params" data-mode="math">
<div class="gp-section-title" style="margin-bottom:8px">&#1052;&#1072;&#1090;&#1077;&#1084;&#1072;&#1090;&#1080;&#1095;. &#1084;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082;</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">&#952; = <span id="pend-theta-val" style="color:var(--violet);font-weight:700">45</span>&#176;</label>
<input type="range" id="sl-pend-theta" min="5" max="170" step="1" value="45" oninput="pendParam('theta',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">L = <span id="pend-L-val" style="color:var(--cyan);font-weight:700">200</span></label>
<input type="range" id="sl-pend-L" min="60" max="300" step="5" value="200" oninput="pendParam('L',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">g = <span id="pend-g-val" style="color:#FFD166;font-weight:700">9.81</span></label>
<input type="range" id="sl-pend-g" min="1" max="25" step="0.1" value="9.81" oninput="pendParam('g',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:70px">&#1047;&#1072;&#1090;&#1091;&#1093;. <span id="pend-damp-val" style="color:#EF476F;font-weight:700">0</span></label>
<input type="range" id="sl-pend-damp" min="0" max="2" step="0.05" value="0" oninput="pendParam('damping',this.value)" style="flex:1">
</div>
<div class="gp-section-title" style="margin-bottom:6px;margin-top:8px">&#1055;&#1088;&#1077;&#1089;&#1077;&#1090;&#1099;</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="pendPreset(45,200,9.81,0)">&#1047;&#1077;&#1084;&#1083;&#1103;</button>
<button class="preset-btn" onclick="pendPreset(45,200,1.62,0)">&#1051;&#1091;&#1085;&#1072;</button>
<button class="preset-btn" onclick="pendPreset(170,200,9.81,0)">&#1041;&#1086;&#1083;&#1100;&#1096;&#1086;&#1081; &#952;</button>
<button class="preset-btn" onclick="pendPreset(45,200,9.81,0.5)">&#1047;&#1072;&#1090;&#1091;&#1093;&#1072;&#1085;&#1080;&#1077;</button>
</div>
<div class="pp-hint">&#1058;&#1072;&#1097;&#1080; &#1075;&#1088;&#1091;&#1079;&#1080;&#1082; &#1084;&#1099;&#1096;&#1100;&#1102; &#1076;&#1083;&#1103; &#1091;&#1089;&#1090;&#1072;&#1085;&#1086;&#1074;&#1082;&#1080; &#1091;&#1075;&#1083;&#1072;</div>
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">L = <span id="pend-L-val" style="color:var(--cyan);font-weight:700">200</span></label>
<input type="range" id="sl-pend-L" min="60" max="300" step="5" value="200" oninput="pendParam('L',this.value)" style="flex:1">
<!-- DOUBLE params -->
<div class="pend-params" data-mode="double" style="display:none">
<div class="gp-section-title" style="margin-bottom:8px">&#1044;&#1074;&#1086;&#1081;&#1085;&#1086;&#1081; &#1084;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082;</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">L1 = <span id="pend-d-L1-val" style="color:var(--cyan);font-weight:700">130</span></label>
<input type="range" min="50" max="200" step="5" value="130" oninput="pendDoubleParam('L1',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">L2 = <span id="pend-d-L2-val" style="color:var(--cyan);font-weight:700">100</span></label>
<input type="range" min="50" max="200" step="5" value="100" oninput="pendDoubleParam('L2',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">m1 = <span id="pend-d-m1-val" style="color:#FFD166;font-weight:700">1.5</span></label>
<input type="range" min="0.5" max="4" step="0.1" value="1.5" oninput="pendDoubleParam('m1',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">m2 = <span id="pend-d-m2-val" style="color:#FFD166;font-weight:700">1.0</span></label>
<input type="range" min="0.5" max="4" step="0.1" value="1.0" oninput="pendDoubleParam('m2',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">&#952;1 = <span id="pend-d-th1-val" style="color:var(--violet);font-weight:700">108</span>&#176;</label>
<input type="range" min="10" max="170" step="1" value="108" oninput="pendDoubleParam('th1',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">&#952;2 = <span id="pend-d-th2-val" style="color:var(--violet);font-weight:700">72</span>&#176;</label>
<input type="range" min="10" max="170" step="1" value="72" oninput="pendDoubleParam('th2',this.value)" style="flex:1">
</div>
<button id="btn-pend-ghost" onclick="pendToggleGhost()" style="width:100%;margin-top:6px;font-size:.75rem;padding:5px;border-radius:8px;border:1px solid rgba(239,71,111,0.4);background:rgba(239,71,111,0.1);color:#EF476F;cursor:pointer">&#1057;&#1088;&#1072;&#1074;&#1085;&#1080;&#1090;&#1100; &#1090;&#1088;&#1072;&#1077;&#1082;&#1090;&#1086;&#1088;&#1080;&#1080; (&#1093;&#1072;&#1086;&#1089;)</button>
<div class="pp-hint" style="margin-top:6px">&#1063;&#1091;&#1074;&#1089;&#1090;&#1074;&#1080;&#1090;&#1077;&#1083;&#1100;&#1085;&#1086;&#1089;&#1090;&#1100; &#1082; &#1085;&#1072;&#1095;. &#1091;&#1089;&#1083;&#1086;&#1074;&#1080;&#1103;&#1084; (+0.001 &#1088;&#1072;&#1076;)</div>
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">g = <span id="pend-g-val" style="color:#FFD166;font-weight:700">9.81</span></label>
<input type="range" id="sl-pend-g" min="1" max="25" step="0.1" value="9.81" oninput="pendParam('g',this.value)" style="flex:1">
<!-- COUPLED params -->
<div class="pend-params" data-mode="coupled" style="display:none">
<div class="gp-section-title" style="margin-bottom:8px">&#1057;&#1074;&#1103;&#1079;&#1072;&#1085;&#1085;&#1099;&#1077; &#1084;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082;&#1080;</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">L = <span id="pend-cp-L-val" style="color:var(--cyan);font-weight:700">160</span></label>
<input type="range" min="80" max="260" step="5" value="160" oninput="pendCoupledParam('L',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">k = <span id="pend-cp-k-val" style="color:#FFD166;font-weight:700">0.30</span></label>
<input type="range" min="0.01" max="2" step="0.01" value="0.3" oninput="pendCoupledParam('k',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">&#952;1 = <span id="pend-cp-th1-val" style="color:var(--violet);font-weight:700">36</span>&#176;</label>
<input type="range" min="5" max="90" step="1" value="36" oninput="pendCoupledParam('th1',this.value)" style="flex:1">
</div>
<div class="pp-hint">&#952;2=0: &#1101;&#1085;&#1077;&#1088;&#1075;&#1080;&#1103; &#1087;&#1077;&#1088;&#1077;&#1082;&#1072;&#1095;&#1080;&#1074;&#1072;&#1077;&#1090;&#1089;&#1103; &#1086;&#1090; &#1084;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082;&#1072; 1 &#1082; &#1084;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082;&#1091; 2 &#1080; &#1086;&#1073;&#1088;&#1072;&#1090;&#1085;&#1086;</div>
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:70px">Затух. <span id="pend-damp-val" style="color:#EF476F;font-weight:700">0</span></label>
<input type="range" id="sl-pend-damp" min="0" max="2" step="0.05" value="0" oninput="pendParam('damping',this.value)" style="flex:1">
<!-- SPRING params -->
<div class="pend-params" data-mode="spring" style="display:none">
<div class="gp-section-title" style="margin-bottom:8px">&#1055;&#1088;&#1091;&#1078;&#1080;&#1085;&#1085;&#1099;&#1081; &#1084;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082;</div>
<div style="display:flex;gap:4px;margin-bottom:10px">
<button class="sp-mode-btn active" data-m="vert" onclick="pendSpringMode('vert')" style="flex:1;font-size:.72rem;padding:4px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:rgba(6,214,224,0.15);color:#06D6E0;cursor:pointer">&#1042;&#1077;&#1088;&#1090;&#1080;&#1082;.</button>
<button class="sp-mode-btn" data-m="horiz" onclick="pendSpringMode('horiz')" style="flex:1;font-size:.72rem;padding:4px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1043;&#1086;&#1088;&#1080;&#1079;&#1086;&#1085;&#1090;.</button>
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">k = <span id="pend-sp-k-val" style="color:#FFD166;font-weight:700">20</span></label>
<input type="range" min="1" max="100" step="1" value="20" oninput="pendSpringParam('k',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">m = <span id="pend-sp-m-val" style="color:var(--violet);font-weight:700">1</span> &#1082;&#1075;</label>
<input type="range" min="0.1" max="5" step="0.1" value="1" oninput="pendSpringParam('m',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">x0 = <span id="pend-sp-x0-val" style="color:#EF476F;font-weight:700">8</span> &#1089;&#1084;</label>
<input type="range" min="1" max="20" step="0.5" value="8" oninput="pendSpringParam('x0',this.value)" style="flex:1">
</div>
<div class="pp-hint">T = 2&#960;&#8730;(m/k) &#1085;&#1077; &#1079;&#1072;&#1074;&#1080;&#1089;&#1080;&#1090; &#1086;&#1090; g (&#1075;&#1086;&#1088;&#1080;&#1079;&#1086;&#1085;&#1090;.)</div>
</div>
<div style="margin-top:8px"></div>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="pendPreset(45,200,9.81,0)">Земля</button>
<button class="preset-btn" onclick="pendPreset(45,200,1.62,0)">Луна</button>
<button class="preset-btn" onclick="pendPreset(170,200,9.81,0)">Большой θ</button>
<button class="preset-btn" onclick="pendPreset(45,200,9.81,0.5)">Затухание</button>
<!-- PHYSICAL params -->
<div class="pend-params" data-mode="physical" style="display:none">
<div class="gp-section-title" style="margin-bottom:8px">&#1060;&#1080;&#1079;&#1080;&#1095;&#1077;&#1089;&#1082;&#1080;&#1081; &#1084;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082;</div>
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:10px">
<button class="ph-shape-btn active" data-s="rod" onclick="pendPhysShape('rod')" style="font-size:.72rem;padding:3px 8px;border-radius:8px;border:1px solid rgba(155,93,229,0.4);background:rgba(155,93,229,0.15);color:#9B5DE5;cursor:pointer">&#1057;&#1090;&#1077;&#1088;&#1078;&#1077;&#1085;&#1100;</button>
<button class="ph-shape-btn" data-s="hoop" onclick="pendPhysShape('hoop')" style="font-size:.72rem;padding:3px 8px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1054;&#1073;&#1088;&#1091;&#1095;</button>
<button class="ph-shape-btn" data-s="disk" onclick="pendPhysShape('disk')" style="font-size:.72rem;padding:3px 8px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1044;&#1080;&#1089;&#1082;</button>
<button class="ph-shape-btn" data-s="rect" onclick="pendPhysShape('rect')" style="font-size:.72rem;padding:3px 8px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">&#1055;&#1088;&#1103;&#1084;&#1086;&#1091;&#1075;.</button>
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">L = <span id="pend-ph-L-val" style="color:var(--cyan);font-weight:700">200</span></label>
<input type="range" min="60" max="260" step="5" value="200" oninput="pendPhysParam('L',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">&#952; = <span id="pend-ph-theta-val" style="color:var(--violet);font-weight:700">36</span>&#176;</label>
<input type="range" min="5" max="120" step="1" value="36" oninput="pendPhysParam('theta',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">&#1047;&#1072;&#1090;&#1091;&#1093;. <span id="pend-ph-damping-val" style="color:#EF476F;font-weight:700">0</span></label>
<input type="range" min="0" max="1" step="0.02" value="0" oninput="pendPhysParam('damping',this.value)" style="flex:1">
</div>
<div class="pp-hint">T &#1079;&#1072;&#1074;&#1080;&#1089;&#1080;&#1090; &#1086;&#1090; &#1084;&#1086;&#1084;&#1077;&#1085;&#1090;&#1072; &#1080;&#1085;&#1077;&#1088;&#1094;&#1080;&#1080; I &#1080; &#1088;&#1072;&#1089;&#1089;&#1090;. &#1076;&#1086; &#1094;.&#1090;. d</div>
</div>
<div class="pp-hint">Тащи грузик мышью для установки угла</div>
<!-- FOUCAULT params -->
<div class="pend-params" data-mode="foucault" style="display:none">
<div class="gp-section-title" style="margin-bottom:8px">&#1052;&#1072;&#1103;&#1090;&#1085;&#1080;&#1082; &#1060;&#1091;&#1082;&#1086;</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:65px">&#966; = <span id="pend-fc-phi-val" style="color:#FFD166;font-weight:700">45</span>&#176;</label>
<input type="range" min="1" max="90" step="1" value="45" oninput="pendFoucaultParam('phi',this.value)" style="flex:1">
</div>
<div class="pp-hint">&#1042;&#1080;&#1076; &#1089;&#1074;&#1077;&#1088;&#1093;&#1091;. &#1055;&#1083;&#1086;&#1089;&#1082;&#1086;&#1089;&#1090;&#1100; &#1082;&#1072;&#1095;&#1072;&#1085;&#1080;&#1081; &#1074;&#1088;&#1072;&#1097;&#1072;&#1077;&#1090;&#1089;&#1; &#1080;&#1079;-&#1079;&#1072; &#1074;&#1088;&#1072;&#1097;&#1077;&#1085;&#1080;&#1103; &#1047;&#1077;&#1084;&#1083;&#1080;. T = 24 &#1095; / sin(&#966;)</div>
<div class="pp-hint" style="color:#FFD166;margin-top:4px">&#1055;&#1086;&#1083;&#1102;&#1089; (90&#176;): &#1086;&#1073;&#1086;&#1088;&#1086;&#1090; &#1079;&#1072; 24 &#1095;. &#1069;&#1082;&#1074;&#1072;&#1090;&#1086;&#1088;: &#1087;&#1086;&#1095;&#1090;&#1080; &#1085;&#1077; &#1074;&#1088;&#1072;&#1097;&#1072;&#1077;&#1090;&#1089;&#1103;.</div>
</div>
<!-- RESONANCE params -->
<div class="pend-params" data-mode="resonance" style="display:none">
<div class="gp-section-title" style="margin-bottom:8px">&#1056;&#1077;&#1079;&#1086;&#1085;&#1072;&#1085;&#1089;</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:70px">&#969; = <span id="pend-rs-dOmega-val" style="color:#EF476F;font-weight:700">1.50</span></label>
<input type="range" min="0.1" max="6" step="0.05" value="1.5" oninput="pendResonanceParam('dOmega',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:70px">F0 = <span id="pend-rs-F0-val" style="color:#FFD166;font-weight:700">0.80</span></label>
<input type="range" min="0.1" max="3" step="0.05" value="0.8" oninput="pendResonanceParam('F0',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:70px">&#947; = <span id="pend-rs-gamma-val" style="color:var(--cyan);font-weight:700">0.30</span></label>
<input type="range" min="0.01" max="2" step="0.01" value="0.3" oninput="pendResonanceParam('gamma',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:70px">L = <span id="pend-rs-L-val" style="color:var(--violet);font-weight:700">180</span></label>
<input type="range" min="60" max="300" step="5" value="180" oninput="pendResonanceParam('L',this.value)" style="flex:1">
</div>
<div class="pp-hint">&#1050;&#1088;&#1072;&#1089;&#1085;&#1072;&#1103; &#1089;&#1090;&#1088;&#1077;&#1083;&#1082;&#1072;&#1074;&#1099;&#1085;&#1091;&#1078;&#1076;&#1072;&#1102;&#1097;&#1072;&#1103; &#1089;&#1080;&#1083;&#1072;. &#1055;&#1080;&#1082; &#1087;&#1088;&#1080; &#969; &#8776; &#969;&#8320;.</div>
</div>
</div>
<div class="proj-canvas-outer">
<canvas id="pendulum-canvas"></canvas>
</div>
</div>
<div class="proj-stats-bar" id="pendbar">
<div class="pstat"><div class="pstat-label">Угол</div><div class="pstat-val" id="pendbar-v1" style="color:var(--violet)">45°</div></div>
<div class="pstat"><div class="pstat-label">ω</div><div class="pstat-val" id="pendbar-v2" style="color:var(--cyan)">0</div></div>
<div class="pstat"><div class="pstat-label">Период T</div><div class="pstat-val" id="pendbar-v3" style="color:#FFD166"></div></div>
<div class="pstat"><div class="pstat-label">Энергия</div><div class="pstat-val" id="pendbar-v4" style="color:#EF476F"></div></div>
<div class="pstat"><div class="pstat-label">&#1059;&#1075;&#1086;&#1083; / &#1082;&#1086;&#1086;&#1088;&#1076;.</div><div class="pstat-val" id="pendbar-v1" style="color:var(--violet)">45&#176;</div></div>
<div class="pstat"><div class="pstat-label">&#969;</div><div class="pstat-val" id="pendbar-v2" style="color:var(--cyan)">0</div></div>
<div class="pstat"><div class="pstat-label">&#1055;&#1077;&#1088;&#1080;&#1086;&#1076; T</div><div class="pstat-val" id="pendbar-v3" style="color:#FFD166"></div></div>
<div class="pstat"><div class="pstat-label">&#1056;&#1077;&#1078;&#1080;&#1084;</div><div class="pstat-val" id="pendbar-v4" style="color:#EF476F"></div></div>
</div>
</div>