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

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

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

1251 lines
46 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';
/* ═══════════════════════════════════════════════════════════════════
ProjectileSim v2 — physics simulation
Features: air drag (RK4) · wind · bounce · speed multiplier
ghost trail comparison · velocity vector labels
range arrow · landing angle · canvas click play/pause
═══════════════════════════════════════════════════════════════════ */
class ProjectileSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
/* ── physics params ── */
this.v0 = 20;
this.angle = 45;
this.h0 = 2;
this.g = 9.81;
/* air resistance */
this.drag = false;
this.Cd = 0.3;
this.mass = 1; // kg
/* wind (m/s, positive = tailwind / right) */
this.wind = 0;
/* bounce */
this.bounce = false;
this.restitution = 0.7;
/* animation speed multiplier */
this.speed = 1;
/* computed trajectory (null = use analytical) */
this._path = null; // [{x, y, vx, vy, t}]
this._pathTf = 0;
/* animation state */
this.t = 0;
this.playing = false;
this._raf = null;
this._lastTs = null;
/* visual effects */
this._trail = [];
this._sparks = [];
this._impactTs = -999;
this._launchFlash = 0;
this._stars = this._genStars(90);
/* ghost trails for comparison */
this._ghosts = [];
this._ghostIdx = 0;
this._GHOST_COLORS = [
'rgba(255,214,102,.45)',
'rgba(6,214,224,.45)',
'rgba(123,245,164,.45)',
'rgba(255,140,66,.45)',
];
this.onUpdate = null;
this.onPlayPause = null; // called by canvas click
/* hover inspector */
this._hover = null; // { t, s } | null
this._viewParams = null; // coordinate transform params (set in draw)
canvas.addEventListener('click', () => {
if (this.onPlayPause) this.onPlayPause();
});
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => this._onMouseLeave());
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ── */
fit() {
const dpr = window.devicePixelRatio || 1;
const r = this.c.parentElement.getBoundingClientRect();
const w = r.width || 600, h = r.height || 400;
this.c.width = w * dpr;
this.c.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this._cw = w; this._ch = h;
}
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 };
}
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));
this._computePath();
this._resetFX();
this.draw();
this._emit();
}
setSpeed(s) { this.speed = +s; }
play() {
if (this.playing) return;
if (this._pathTf > 0 && this.t >= this._pathTf) this._resetFX();
this._launchFlash = 1;
this.playing = true;
this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this._resetFX();
this.draw();
this._emit();
}
/* ghost trails */
saveGhost() {
if (this._pathTf <= 0) return;
const points = [];
if (this._path) {
for (const p of this._path) points.push({ x: p.x, y: p.y });
} else {
const tf = this._pathTf;
for (let i = 0; i <= 200; i++) {
const s = this._stateAnalytical((i / 200) * tf);
points.push({ x: s.x, y: s.y });
}
}
const st = this.stats();
const windStr = this.wind !== 0 ? ` <svg class="ic" viewBox="0 0 24 24"><path d="M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2"/><path d="M9.6 4.6A2 2 0 1 1 11 8H2"/><path d="M12.6 19.4A2 2 0 1 0 14 16H2"/></svg>${this.wind > 0 ? '+' : ''}${this.wind}` : '';
const label = `${this.angle}° ${this.v0}м/с${windStr}${this.drag ? ' +drag' : ''}${this.bounce ? ' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>' : ''}`;
const color = this._GHOST_COLORS[this._ghostIdx % this._GHOST_COLORS.length];
this._ghostIdx++;
this._ghosts.push({ points, color, label, range: st.range, hMax: st.hMax });
if (this._ghosts.length > 4) this._ghosts.shift();
this.draw();
}
clearGhosts() {
this._ghosts = [];
this._ghostIdx = 0;
this.draw();
}
/* ── physics ── */
/* 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);
return {
x: vx * t,
y: this.h0 + vy0 * t - 0.5 * this.g * t * t,
vx,
vy: vy0 - this.g * t,
};
}
/* 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;
if (disc < 0) return 0;
return Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
}
_needsNumerical() {
return this.drag || this.wind !== 0 || this.bounce;
}
/* RK4 integration — handles drag, wind, bounce */
_computePath() {
if (!this._needsNumerical()) {
this._path = null;
this._pathTf = this._tFlightAnalytical();
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 maxBounces = this.bounce ? 7 : 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 }];
let bounceCount = 0;
const deriv = (sx, sy, svx, svy) => {
const rvx = svx - W; // velocity relative to wind
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;
return {
dx: svx,
dy: svy,
dvx: -dragF * rvx + windAcc,
dvy: -g - dragF * rvy,
};
};
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);
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);
const lx = prev.x + (x - prev.x) * frac;
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 });
if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) {
vy = -e * lvy;
vx = lvx * (1 - 0.04); // small horizontal friction
y = 0.001;
x = lx;
bounceCount++;
continue;
}
}
break;
}
path.push({ x, y, vx, vy, t });
}
this._path = path;
this._pathTf = path[path.length - 1].t;
}
_pathStateAt(t) {
const path = this._path;
if (!path || path.length < 2) return { x: 0, y: this.h0, vx: 0, vy: 0 };
if (t <= 0) return path[0];
if (t >= this._pathTf) return path[path.length - 1];
let lo = 0, hi = path.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (path[mid].t <= t) lo = mid; else hi = mid;
}
const a = path[lo], b = path[hi];
const frac = (t - a.t) / (b.t - a.t);
return {
x: a.x + (b.x - a.x) * frac,
y: a.y + (b.y - a.y) * frac,
vx: a.vx + (b.vx - a.vx) * frac,
vy: a.vy + (b.vy - a.vy) * frac,
};
}
_curState(t) {
return this._path ? this._pathStateAt(t) : this._stateAnalytical(t);
}
_curTFlight() { return this._pathTf; }
stats() {
const tf = this._pathTf;
const end = this._curState(tf);
let hMax = this.h0;
if (this._path) {
for (const p of this._path) if (p.y > hMax) hMax = p.y;
} else {
const rad = this.angle * Math.PI / 180;
const vy0 = this.v0 * Math.sin(rad);
const tMax = Math.max(0, vy0 / this.g);
hMax = Math.max(this.h0, this.h0 + vy0 * tMax - 0.5 * this.g * tMax * tMax);
}
const range = Math.max(0, end.x);
const vLand = Math.sqrt(end.vx ** 2 + end.vy ** 2);
const landAngle = vLand > 0.01
? Math.abs(Math.atan2(Math.abs(end.vy), Math.abs(end.vx)) * 180 / Math.PI)
: 0;
// range compared to pure analytical (no drag/wind/bounce)
let rangeLoss = 0;
if (this._needsNumerical()) {
const tfND = this._tFlightAnalytical();
const endND = this._stateAnalytical(tfND);
const rangeND = Math.max(0, endND.x);
if (rangeND > 0.1) rangeLoss = Math.round((range / rangeND - 1) * 100);
}
return {
tf, hMax, range, vLand, rangeLoss, landAngle,
t: this.t,
progress: tf > 0 ? Math.min(1, this.t / tf) : 0,
hasMod: this._needsNumerical(),
};
}
/* ── animation loop ── */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);
const cur = this._curState(this.t);
this._trail.push({ mx: cur.x, my: cur.y });
if (this._trail.length > 80) this._trail.shift();
this.t += rawDt * this.speed;
const tf = this._curTFlight();
if (this.t >= tf) {
this.t = tf;
this.playing = false;
this._triggerImpact();
}
this.draw();
this._emit();
if (this.playing) this._tick();
});
}
_triggerImpact() {
const end = this._curState(this._curTFlight());
this._impactTs = performance.now();
this._sparks = Array.from({ length: 18 }, (_, i) => {
const ang = (i / 18) * Math.PI * 2 + Math.random() * 0.3;
const spd = 40 + Math.random() * 80;
return { ang, spd, mx: end.x };
});
this._tickFX();
}
_tickFX() {
const elapsed = (performance.now() - this._impactTs) / 1000;
if (elapsed < 1.8) {
this.draw(); this._emit();
requestAnimationFrame(() => this._tickFX());
} else {
this._sparks = [];
this.draw(); this._emit();
}
}
_resetFX() {
this.t = 0;
this._trail = [];
this._sparks = [];
this._impactTs = -999;
this._launchFlash = 0;
this._computePath();
}
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
/* ── stars ── */
_genStars(n) {
return Array.from({ length: n }, () => ({
rx: Math.random(), ry: Math.random(),
r: Math.random() * 1.1 + 0.2,
a: Math.random() * 0.55 + 0.15,
}));
}
/* ── main render ── */
draw() {
const W = this._cw || this.c.width, H = this._ch || this.c.height;
if (!W || !H) return;
const ctx = this.ctx;
const tf = this._curTFlight();
const st = this.stats();
const PL = 54, PR = 20, PT = 26, PB = 44;
const pw = W - PL - PR, ph = H - PT - PB;
let maxRange = Math.max(st.range, 1);
let maxH = Math.max(st.hMax, 1);
for (const gh of this._ghosts) {
if (gh.range > maxRange) maxRange = gh.range;
if (gh.hMax > maxH) maxH = gh.hMax;
}
const xMax = maxRange * 1.15;
const yMax = maxH * 1.35;
const scX = pw / xMax, scY = ph / yMax;
const tpx = mx => PL + mx * scX;
const tpy = my => H - PB - my * scY;
const gy = tpy(0);
/* store for hover inspector */
this._viewParams = { xMax, yMax, PL, PR, PT, PB, W, H };
/* ── 1. Sky ── */
const sky = ctx.createLinearGradient(0, 0, 0, gy);
sky.addColorStop(0, '#05050f');
sky.addColorStop(0.6, '#0d0d2a');
sky.addColorStop(1, '#141430');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, gy);
/* ── 2. Stars ── */
for (const s of this._stars) {
const sx = PL + s.rx * pw, sy = PT + s.ry * (gy - PT - 10);
ctx.fillStyle = `rgba(255,255,255,${s.a})`;
ctx.beginPath(); ctx.arc(sx, sy, s.r, 0, Math.PI * 2); ctx.fill();
}
/* ── 2.5. Wind streaks ── */
if (this.wind !== 0) {
this._drawWind(ctx, PL, PT, pw, gy - PT);
}
/* ── 3. Ground ── */
const gnd = ctx.createLinearGradient(0, gy, 0, H - PB);
gnd.addColorStop(0, 'rgba(22,101,52,.35)');
gnd.addColorStop(1, 'rgba(15,23,42,.9)');
ctx.fillStyle = gnd;
ctx.fillRect(PL, gy, pw, H - PB - gy);
const gl = ctx.createLinearGradient(PL, 0, PL + pw, 0);
gl.addColorStop(0, 'rgba(34,197,94,.2)');
gl.addColorStop(0.15, 'rgba(74,222,128,.7)');
gl.addColorStop(1, 'rgba(34,197,94,.3)');
ctx.strokeStyle = gl; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(PL + pw, gy); ctx.stroke();
/* ── 4. Margin fills ── */
ctx.fillStyle = '#0A0A14';
ctx.fillRect(0, H - PB, W, PB);
ctx.fillRect(0, 0, PL, H);
ctx.fillRect(W - PR, 0, PR, H);
/* ── 5. Grid ── */
const stX = _projNiceStep(xMax, 6), stY = _projNiceStep(yMax, 5);
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
for (let x = stX; x < xMax; x += stX) {
ctx.beginPath(); ctx.moveTo(tpx(x), PT); ctx.lineTo(tpx(x), H - PB); ctx.stroke();
}
for (let y = stY; y < yMax; y += stY) {
ctx.beginPath(); ctx.moveTo(PL, tpy(y)); ctx.lineTo(W - PR, tpy(y)); ctx.stroke();
}
/* ── 6. Axes + labels ── */
ctx.strokeStyle = 'rgba(255,255,255,.2)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, H - PB); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(W - PR, gy); ctx.stroke();
ctx.font = '10px Manrope, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,.28)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = stX; x < xMax * 0.97; x += stX)
ctx.fillText(_projFmt(x) + ' м', tpx(x), gy + 7);
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let y = stY; y < yMax * 0.97; y += stY)
ctx.fillText(_projFmt(y) + ' м', PL - 6, tpy(y));
/* ── 6.5. Ghost trails ── */
for (const gh of this._ghosts) {
ctx.strokeStyle = gh.color; ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
for (let i = 0; i < gh.points.length; i++) {
const p = gh.points[i];
i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y));
}
ctx.stroke(); ctx.setLineDash([]);
const last = gh.points[gh.points.length - 1];
const lx = tpx(last.x), ly = tpy(0);
ctx.strokeStyle = gh.color; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(lx - 5, ly - 5); ctx.lineTo(lx + 5, ly + 5);
ctx.moveTo(lx + 5, ly - 5); ctx.lineTo(lx - 5, ly + 5);
ctx.stroke();
ctx.fillStyle = gh.color;
ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(gh.label, lx, ly + 10);
}
/* ── 7. Launch platform ── */
if (this.h0 > 0.2) {
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]);
ctx.beginPath(); ctx.moveTo(px0, py0); ctx.lineTo(px0, pyH); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,200,60,.25)';
ctx.fillRect(px0 - 12, pyH, 28, 4);
ctx.fillStyle = 'rgba(255,200,60,.5)';
ctx.font = '9px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(_projFmt(this.h0) + ' м', px0 - 14, pyH);
}
/* ── 8. Reference / full trajectories ── */
if (tf > 0) {
// analytical reference (always shown as faint dashed)
const noDragTf = this._tFlightAnalytical();
ctx.strokeStyle = 'rgba(155,93,229,.22)';
ctx.lineWidth = 1.5; ctx.setLineDash([7, 5]);
ctx.beginPath();
for (let i = 0; i <= 300; i++) {
const s = this._stateAnalytical((i / 300) * noDragTf);
i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y));
}
ctx.stroke(); ctx.setLineDash([]);
// numerical path preview (if active)
if (this._path && this._path.length > 2) {
ctx.strokeStyle = this.drag ? 'rgba(239,71,111,.3)' : 'rgba(255,200,60,.35)';
ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
ctx.beginPath();
const step = Math.max(1, Math.floor(this._path.length / 300));
for (let i = 0; i < this._path.length; i += step) {
const p = this._path[i];
i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y));
}
const last = this._path[this._path.length - 1];
ctx.lineTo(tpx(last.x), tpy(last.y));
ctx.stroke(); ctx.setLineDash([]);
}
}
/* ── 9. Flown path ── */
if (this.t > 0 && tf > 0) {
const s0 = this._curState(0), s1 = this._curState(Math.min(this.t, tf));
const grad = ctx.createLinearGradient(tpx(s0.x), tpy(s0.y), tpx(s1.x), tpy(s1.y));
grad.addColorStop(0, 'rgba(155,93,229,.4)');
grad.addColorStop(0.5, '#9B5DE5');
grad.addColorStop(1, '#F15BB5');
ctx.strokeStyle = grad; ctx.lineWidth = 3;
ctx.beginPath();
if (this._path) {
let first = true;
for (const p of this._path) {
if (p.t > this.t) break;
first ? (ctx.moveTo(tpx(p.x), tpy(p.y)), first = false)
: ctx.lineTo(tpx(p.x), tpy(p.y));
}
const cur = this._pathStateAt(this.t);
ctx.lineTo(tpx(cur.x), tpy(Math.max(0, cur.y)));
} else {
const steps = Math.max(2, Math.ceil(st.progress * 300));
for (let i = 0; i <= steps; i++) {
const s = this._stateAnalytical((i / 300) * tf);
i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y));
}
}
ctx.stroke();
}
/* ── 10. Trail dots ── */
for (let i = 0; i < this._trail.length; i++) {
const frac = i / this._trail.length;
const tr = this._trail[i];
ctx.fillStyle = `rgba(241,91,181,${frac * 0.55})`;
ctx.beginPath(); ctx.arc(tpx(tr.mx), tpy(tr.my), frac * 5, 0, Math.PI * 2); ctx.fill();
}
/* ── 11. Max height marker ── */
if (st.hMax > this.h0 + 0.2 && tf > 0) {
let mpx, mpy;
if (this._path) {
let best = this._path[0];
for (const p of this._path) if (p.y > best.y) best = p;
mpx = tpx(best.x); mpy = tpy(best.y);
} else {
const rad = this.angle * Math.PI / 180;
const vy0 = this.v0 * Math.sin(rad);
const tPk = vy0 / this.g;
const pk = this._stateAnalytical(Math.max(0, tPk));
mpx = tpx(pk.x); mpy = tpy(pk.y);
}
ctx.strokeStyle = 'rgba(255,200,60,.3)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(PL, mpy); ctx.lineTo(mpx, mpy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(mpx, mpy); ctx.lineTo(mpx, gy); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,200,60,.7)';
ctx.beginPath(); ctx.arc(mpx, mpy, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,200,60,.55)';
ctx.font = '10px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText('↑ ' + _projFmt(st.hMax) + ' м', PL - 6, mpy);
}
/* ── 12. Landing marker + range arrow ── */
if (tf > 0) {
const lx = tpx(st.range), ly = tpy(0);
const elapsed = (performance.now() - this._impactTs) / 1000;
const pulse = (elapsed >= 0 && elapsed < 10) ? 0.7 + 0.3 * Math.sin(elapsed * 8) : 0.6;
// X mark
ctx.strokeStyle = `rgba(6,214,224,${pulse})`; ctx.lineWidth = 2;
const ms = 7;
ctx.beginPath();
ctx.moveTo(lx - ms, ly - ms); ctx.lineTo(lx + ms, ly + ms);
ctx.moveTo(lx + ms, ly - ms); ctx.lineTo(lx - ms, ly + ms);
ctx.stroke();
ctx.fillStyle = `rgba(6,214,224,${pulse * 0.8})`;
ctx.font = 'bold 10px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(st.range) + ' м', lx, ly + 9);
// <svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><polyline points="8 17 3 12 8 7"/><polyline points="16 7 21 12 16 17"/></svg> range arrow
if (st.range > 0.5 && lx > PL + 30) {
const ay = gy + 20;
ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(lx - 3, ay); ctx.stroke();
ctx.fillStyle = 'rgba(6,214,224,.3)';
ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(PL + 9, ay - 3); ctx.lineTo(PL + 9, ay + 3); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(lx - 3, ay); ctx.lineTo(lx - 9, ay - 3); ctx.lineTo(lx - 9, ay + 3); ctx.closePath(); ctx.fill();
}
if (st.hasMod && st.rangeLoss !== 0) {
const sign = st.rangeLoss > 0 ? '+' : '';
ctx.fillStyle = st.rangeLoss < 0 ? 'rgba(239,71,111,.7)' : 'rgba(123,245,164,.7)';
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(sign + st.rangeLoss + '% от идеала', lx, ly + 22);
}
}
/* ── 13. Impact effects ── */
const impactElapsed = (performance.now() - this._impactTs) / 1000;
if (impactElapsed >= 0 && impactElapsed < 1.5 && tf > 0) {
const end = this._curState(tf);
const ix = tpx(end.x), iy = tpy(0);
const p = impactElapsed / 1.5;
for (let r = 0; r < 3; r++) {
const rp = Math.max(0, impactElapsed - r * 0.12);
if (rp <= 0) continue;
const rr = rp * 55 * (1 + r * 0.3);
const ra = Math.max(0, (0.5 - rp * 0.5) * (1 - r * 0.2));
ctx.strokeStyle = `rgba(6,214,224,${ra})`; ctx.lineWidth = 2 - r * 0.4;
ctx.beginPath(); ctx.ellipse(ix, iy, rr, rr * 0.28, 0, 0, Math.PI * 2); ctx.stroke();
}
if (impactElapsed < 0.6) {
const ca = (0.6 - impactElapsed) / 0.6;
const cg = ctx.createRadialGradient(ix, iy, 0, ix, iy, 30 + impactElapsed * 40);
cg.addColorStop(0, `rgba(255,230,100,${ca * 0.7})`);
cg.addColorStop(0.4, `rgba(241,91,181,${ca * 0.4})`);
cg.addColorStop(1, 'transparent');
ctx.fillStyle = cg;
ctx.beginPath(); ctx.arc(ix, iy, 60, 0, Math.PI * 2); ctx.fill();
}
for (const sp of this._sparks) {
if (impactElapsed > 1.0) continue;
const sa = Math.max(0, 1 - impactElapsed * 1.4);
const spd = sp.spd * impactElapsed;
const ex = ix + Math.cos(sp.ang) * spd;
const ey = iy + Math.sin(sp.ang) * spd * 0.4 - impactElapsed * impactElapsed * 120;
ctx.strokeStyle = `rgba(255,220,80,${sa})`; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ix + Math.cos(sp.ang) * spd * 0.6, iy + Math.sin(sp.ang) * spd * 0.6 * 0.4);
ctx.lineTo(ex, ey);
ctx.stroke();
}
const swR = impactElapsed * 120;
const swa = Math.max(0, 0.35 - p * 0.35);
ctx.strokeStyle = `rgba(255,255,255,${swa})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ix - swR, iy); ctx.lineTo(ix + swR, iy); ctx.stroke();
}
/* ── 14. Ball ── */
const cur = this._curState(Math.min(this.t, tf));
const bx = tpx(cur.x), by = tpy(Math.max(0, cur.y));
const speed = Math.sqrt(cur.vx ** 2 + cur.vy ** 2);
// shadow
const shadowX = tpx(cur.x);
const shadowA = Math.max(0, 0.25 - (by - gy) / (ph * 2));
if (shadowA > 0) {
const sh = ctx.createRadialGradient(shadowX, gy + 2, 0, shadowX, gy + 2, 18);
sh.addColorStop(0, `rgba(0,0,0,${shadowA})`);
sh.addColorStop(1, 'transparent');
ctx.fillStyle = sh;
ctx.beginPath(); ctx.ellipse(shadowX, gy + 3, 18, 5, 0, 0, Math.PI * 2); ctx.fill();
}
// glow
const glo = ctx.createRadialGradient(bx, by, 2, bx, by, 30);
glo.addColorStop(0, 'rgba(241,91,181,.5)');
glo.addColorStop(0.4, 'rgba(155,93,229,.25)');
glo.addColorStop(1, 'transparent');
ctx.fillStyle = glo;
ctx.beginPath(); ctx.arc(bx, by, 30, 0, Math.PI * 2); ctx.fill();
// ball body
const ballGrad = ctx.createRadialGradient(bx - 3, by - 3, 1, bx, by, 10);
ballGrad.addColorStop(0, '#ffffff');
ballGrad.addColorStop(0.25, '#F15BB5');
ballGrad.addColorStop(1, '#7c3aed');
ctx.fillStyle = ballGrad;
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();
/* ── 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));
const VY_LEN = Math.min(55, 50 * Math.abs(cur.vy) / Math.max(1, this.v0));
if (Math.abs(cur.vx) > 0.2) {
_projArrow(ctx, bx, by, bx + VX_LEN, by, '#06D6E0', 2);
ctx.fillStyle = '#06D6E0'; ctx.font = 'bold 9px Manrope';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(Math.abs(cur.vx)) + ' м/с', bx + VX_LEN / 2, by + 7);
}
if (Math.abs(cur.vy) > 0.2) {
const vyDir = cur.vy > 0 ? -1 : 1;
const vyCol = cur.vy > 0 ? '#9B5DE5' : '#F15BB5';
_projArrow(ctx, bx, by, bx, by + vyDir * VY_LEN, vyCol, 2);
ctx.fillStyle = vyCol; ctx.font = 'bold 9px Manrope';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(_projFmt(Math.abs(cur.vy)) + ' м/с', bx + 6, by + vyDir * VY_LEN / 2);
}
// total velocity arrow
const vLen = 48 * (speed / Math.max(1, this.v0));
_projArrow(ctx, bx, by,
bx + (cur.vx / speed) * vLen,
by - (cur.vy / speed) * vLen,
'#ffffff', 2.5);
}
/* ── 16. Launch flash ── */
if (this._launchFlash > 0) {
const f = this._launchFlash;
const rad = this.angle * Math.PI / 180;
for (let i = 0; i < 10; i++) {
const a = rad + (i / 10) * Math.PI * 2;
const len = f * (20 + i % 3 * 15);
ctx.strokeStyle = `rgba(255,230,100,${f * 0.8})`; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(bx + Math.cos(a) * 12, by - Math.sin(a) * 12);
ctx.lineTo(bx + Math.cos(a) * len, by - Math.sin(a) * len);
ctx.stroke();
}
const halo = ctx.createRadialGradient(bx, by, 0, bx, by, f * 40);
halo.addColorStop(0, `rgba(255,230,100,${f * 0.5})`);
halo.addColorStop(0.5, `rgba(241,91,181,${f * 0.2})`);
halo.addColorStop(1, 'transparent');
ctx.fillStyle = halo;
ctx.beginPath(); ctx.arc(bx, by, f * 40, 0, Math.PI * 2); ctx.fill();
}
/* ── 17. Launch angle arc (idle) ── */
if (this.t < 0.04 && this.angle > 2 && !this.playing) {
const rad = this.angle * Math.PI / 180;
ctx.strokeStyle = 'rgba(255,200,60,.45)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(bx, by, 34, -rad, 0); ctx.stroke();
const ivLen = Math.min(70, 30 + this.v0 * 0.8);
ctx.strokeStyle = 'rgba(255,255,255,.35)'; ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx + Math.cos(rad) * ivLen, by - Math.sin(rad) * ivLen);
ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,200,60,.75)';
ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.fillText(this.angle + '°', bx + 38, by - 2);
}
/* ── 18. Info badges (top-right) ── */
let bRight = W - PR - 8;
if (this.drag) {
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.wind !== 0) {
const dir = this.wind > 0 ? '<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
this._drawBadge(ctx, bRight, PT + 6, dir + ' ветер ' + Math.abs(this.wind) + 'м/с', 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
bRight -= 130;
}
if (this.bounce) {
this._drawBadge(ctx, bRight, PT + 6, '<svg class="ic" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg> e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)');
}
/* speed badge bottom-right */
if (this.speed !== 1) {
this._drawBadge(ctx, W - PR - 8, H - PB - 28, '×' + this.speed, 'rgba(255,214,102,.12)', 'rgba(255,214,102,.8)');
}
/* ── 19. Hover inspector ── */
if (!this.playing && this._hover) {
this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT);
}
}
/* ── hover inspector ── */
_onMouseMove(e) {
if (this.playing) { this._hover = null; return; }
const tf = this._curTFlight();
if (tf <= 0 || !this._viewParams) { this._hover = null; return; }
const r = this.c.getBoundingClientRect();
const cw = this._cw || this.c.width, ch = this._ch || this.c.height;
const mx = (e.clientX - r.left) * (cw / r.width);
const my = (e.clientY - r.top) * (ch / r.height);
const { xMax, yMax, PL, PR, PT, PB, W, H } = this._viewParams;
const pw = W - PL - PR, ph = H - PT - PB;
const scX = pw / xMax, scY = ph / yMax;
const tpx = wx => PL + wx * scX;
const tpy = wy => H - PB - wy * scY;
let bestT = null, bestDist = Infinity;
const N = 400;
if (this._path) {
const step = Math.max(1, Math.floor(this._path.length / N));
for (let i = 0; i < this._path.length; i += step) {
const p = this._path[i];
const d = Math.hypot(tpx(p.x) - mx, tpy(Math.max(0, p.y)) - my);
if (d < bestDist) { bestDist = d; bestT = p.t; }
}
/* also check last point */
const last = this._path[this._path.length - 1];
const d = Math.hypot(tpx(last.x) - mx, tpy(Math.max(0, last.y)) - my);
if (d < bestDist) { bestDist = d; bestT = last.t; }
} else {
for (let i = 0; i <= N; i++) {
const t = (i / N) * tf;
const s = this._stateAnalytical(t);
const d = Math.hypot(tpx(s.x) - mx, tpy(Math.max(0, s.y)) - my);
if (d < bestDist) { bestDist = d; bestT = t; }
}
}
if (bestDist < 32 && bestT !== null) {
const s = this._curState(bestT);
this._hover = { t: bestT, s };
} else {
this._hover = null;
}
this.draw();
}
_onMouseLeave() {
this._hover = null;
this.draw();
}
_drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT) {
const { t, s } = this._hover;
const bx = tpx(s.x);
const by = tpy(Math.max(0, s.y));
const speed = Math.sqrt(s.vx ** 2 + s.vy ** 2);
const velAng = Math.atan2(s.vy, s.vx) * 180 / Math.PI;
/* ── crosshair lines ── */
ctx.save();
ctx.strokeStyle = 'rgba(255,214,102,.3)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx, gy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PL, by); ctx.lineTo(bx, by); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
/* ── axis labels ── */
ctx.font = 'bold 9px Manrope';
ctx.fillStyle = 'rgba(255,214,102,.7)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(Math.max(0, s.x)) + ' м', bx, gy + 6);
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(_projFmt(Math.max(0, s.y)) + ' м', PL - 4, by);
/* ── dot on trajectory ── */
const glow = ctx.createRadialGradient(bx, by, 0, bx, by, 14);
glow.addColorStop(0, 'rgba(255,214,102,.5)');
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(bx, by, 14, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#FFD166';
ctx.strokeStyle = 'rgba(255,255,255,.9)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(bx, by, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
/* ── tooltip ── */
const rows = [
{ label: 't', val: t.toFixed(3) + ' с', color: '#FFD166' },
{ label: 'x', val: _projFmt(Math.max(0, s.x)) + ' м', color: '#06D6E0' },
{ label: 'y', val: _projFmt(Math.max(0, s.y)) + ' м', color: '#7BF5A4' },
{ label: '|v|', val: _projFmt(speed) + ' м/с', color: '#ffffff' },
{ label: 'vx', val: _projFmt(s.vx) + ' м/с', color: '#06D6E0' },
{ label: 'vy', val: _projFmt(s.vy) + ' м/с', color: '#9B5DE5' },
{ label: 'угол', val: velAng.toFixed(1) + '°', color: '#F15BB5' },
];
const padX = 10, padY = 8, lineH = 17;
const tw = 138, th = padY * 2 + rows.length * lineH;
/* position — avoid canvas edges */
let tx = bx + 16, ty = by - th / 2;
if (tx + tw > W - 22) tx = bx - tw - 16;
if (ty < PT + 4) ty = PT + 4;
if (ty + th > H - PB - 4) ty = H - PB - th - 4;
/* shadow */
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,.6)';
ctx.shadowBlur = 12;
ctx.fillStyle = 'rgba(8,8,18,.92)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill();
ctx.restore();
/* border */
ctx.strokeStyle = 'rgba(255,214,102,.35)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke();
/* top accent line */
ctx.strokeStyle = 'rgba(255,214,102,.6)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(tx + 9, ty + 1);
ctx.lineTo(tx + tw - 9, ty + 1);
ctx.stroke();
/* rows */
ctx.font = '10px Manrope, sans-serif';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const ry = ty + padY + i * lineH + lineH / 2;
/* separator */
if (i > 0) {
ctx.strokeStyle = 'rgba(255,255,255,.04)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(tx + 8, ry - lineH / 2); ctx.lineTo(tx + tw - 8, ry - lineH / 2); ctx.stroke();
}
ctx.fillStyle = 'rgba(255,255,255,.35)';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(row.label, tx + padX, ry);
ctx.fillStyle = row.color;
ctx.textAlign = 'right';
ctx.fillText(row.val, tx + tw - padX, ry);
}
/* connector dot */
ctx.fillStyle = '#FFD166';
ctx.strokeStyle = 'rgba(8,8,18,.9)';
ctx.lineWidth = 1.5;
const cx = tx < bx ? tx + tw : tx;
const cy = ty + th / 2;
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
/* ── draw helpers ── */
_drawBadge(ctx, rightX, y, text, bg, fg) {
const bh = 20;
ctx.font = 'bold 9px Manrope';
const tw = ctx.measureText(text).width;
const bw = tw + 16;
const bx = rightX - bw;
ctx.fillStyle = bg;
ctx.beginPath(); ctx.roundRect(bx, y, bw, bh, 6); ctx.fill();
ctx.fillStyle = fg;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, bx + bw / 2, y + bh / 2);
}
_drawWind(ctx, x, y, w, h) {
const now = performance.now() / 1000;
const dir = this.wind > 0 ? 1 : -1;
const strength = Math.min(1, Math.abs(this.wind) / 20);
const count = Math.floor(4 + strength * 7);
const len = (18 + strength * 45) * dir;
ctx.save();
ctx.strokeStyle = '#06D6E0';
for (let i = 0; i < count; i++) {
const phase = ((i / count) + now * strength * 0.25) % 1;
const streak_x = dir > 0 ? x + phase * w : x + (1 - phase) * w;
const streak_y = y + (0.1 + (i / count) * 0.8) * h;
const alpha = 0.08 + strength * 0.15;
ctx.globalAlpha = alpha;
ctx.lineWidth = 0.8 + strength * 0.6;
ctx.beginPath(); ctx.moveTo(streak_x, streak_y); ctx.lineTo(streak_x + len, streak_y); ctx.stroke();
}
ctx.restore();
}
}
/* ── module helpers ── */
function _projNiceStep(range, n) {
const raw = range / n;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
return p;
}
function _projFmt(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
if (n >= 100) return Math.round(n).toString();
if (n >= 10) return n.toFixed(1);
return n.toFixed(2);
}
function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
const ang = Math.atan2(y2 - y1, x2 - x1);
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw;
ctx.shadowColor = color; ctx.shadowBlur = 6;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 9 * Math.cos(ang - 0.4), y2 - 9 * Math.sin(ang - 0.4));
ctx.lineTo(x2 - 9 * Math.cos(ang + 0.4), y2 - 9 * Math.sin(ang + 0.4));
ctx.closePath(); ctx.fill();
ctx.restore();
}
/* ─── lab UI init ─────────────────────────────────── */
function _openProjectile() {
document.getElementById('sim-topbar-title').textContent = 'Бросок тела';
_simShow('sim-proj');
_simShow('ctrl-proj');
_registerSimState('projectile', () => pSim?.getParams(), st => pSim?.setParams(st));
if (_embedMode) _startStateEmit('projectile');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pSim) {
pSim = new ProjectileSim(document.getElementById('proj-canvas'));
pSim.onUpdate = _projUpdateUI;
pSim.onPlayPause = projPlayPause;
}
pSim.fit();
projParam(); // sync sliders <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> sim
pSim.draw();
_projUpdateUI(pSim.stats());
}));
}
function projPlayPause() {
if (!pSim) return;
if (pSim.playing) {
pSim.pause();
} else {
pSim.play();
}
_projSyncPlayBtn();
}
function _projSyncPlayBtn() {
/* small topbar button */
const tb = document.getElementById('proj-play-btn');
/* big launch button */
const lb = document.getElementById('proj-launch-main');
const lbl = document.getElementById('proj-launch-label');
const lic = document.getElementById('proj-launch-icon');
if (!pSim) return;
const tf = pSim._curTFlight();
const done = !pSim.playing && pSim.t >= tf && pSim.t > 0;
const playing = pSim.playing;
/* topbar */
if (tb) {
tb.innerHTML = playing
? '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>'
: '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
tb.title = playing ? 'Пауза' : 'Запустить';
tb.classList.toggle('active', playing);
}
/* big button */
if (lb && lbl && lic) {
lb.classList.toggle('paused', playing);
lb.classList.toggle('done', done && !playing);
if (playing) {
lic.innerHTML = '<rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/>';
lbl.textContent = 'Пауза';
} else if (done) {
lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
lbl.textContent = 'Повторить';
} else {
lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
lbl.textContent = 'Запустить';
}
}
}
function projParam() {
const v0 = +document.getElementById('sl-v0').value;
const angle = +document.getElementById('sl-angle').value;
const h0 = +document.getElementById('sl-h0').value;
const g = +document.getElementById('sl-g').value;
document.getElementById('p-v0').textContent = v0 + ' м/с';
document.getElementById('p-angle').textContent = angle + '°';
document.getElementById('p-h0').textContent = h0 + ' м';
document.getElementById('p-g').textContent = g.toFixed(2) + ' м/с²';
if (pSim) { pSim.setParams({ v0, angle, h0, g }); _projSyncPlayBtn(); }
}
function projPreset(v0, angle, h0, g) {
document.getElementById('sl-v0').value = v0;
document.getElementById('sl-angle').value = angle;
document.getElementById('sl-h0').value = h0;
document.getElementById('sl-g').value = g;
projParam();
}
function projToggleDrag(rowEl) {
if (!pSim) return;
pSim.drag = !pSim.drag;
const on = pSim.drag;
rowEl.classList.toggle('active', on);
const tog = document.getElementById('drag-toggle');
tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
document.getElementById('drag-params').style.display = on ? '' : 'none';
document.getElementById('ps-loss-wrap').style.display = on ? '' : 'none';
if (on) {
const cd = +document.getElementById('sl-cd').value / 100;
const mass = +document.getElementById('sl-mass').value;
pSim.setParams({ drag: true, Cd: cd, mass });
} else {
pSim.setParams({ drag: false });
}
}
function projCdChange() {
const cd = +document.getElementById('sl-cd').value / 100;
document.getElementById('p-cd').textContent = cd.toFixed(2);
if (pSim) pSim.setParams({ Cd: cd });
}
function projMassChange() {
const mass = +document.getElementById('sl-mass').value;
document.getElementById('p-mass').textContent = mass + ' кг';
if (pSim) pSim.setParams({ mass });
}
function projWindChange() {
const wind = +document.getElementById('sl-wind').value;
const label = wind === 0 ? '0 м/с' : (wind > 0 ? '<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> +' : '<svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> ') + Math.abs(wind) + ' м/с';
document.getElementById('p-wind').textContent = label;
document.getElementById('ps-loss-wrap').style.display = wind !== 0 ? '' : (pSim && pSim.drag ? '' : 'none');
if (pSim) { pSim.setParams({ wind }); _projSyncPlayBtn(); }
}
function projToggleBounce(rowEl) {
if (!pSim) return;
pSim.bounce = !pSim.bounce;
const on = pSim.bounce;
rowEl.classList.toggle('active', on);
const tog = document.getElementById('bounce-toggle');
tog.style.background = on ? 'rgba(123,245,164,0.8)' : 'rgba(255,255,255,0.12)';
tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
document.getElementById('bounce-params').style.display = on ? '' : 'none';
const e = +document.getElementById('sl-restitution').value / 100;
pSim.setParams({ bounce: on, restitution: e });
}
function projRestitutionChange() {
const e = +document.getElementById('sl-restitution').value / 100;
document.getElementById('p-restitution').textContent = e.toFixed(2);
if (pSim) pSim.setParams({ restitution: e });
}
function projSetSpeed(s, el) {
if (pSim) pSim.setSpeed(s);
document.querySelectorAll('.proj-speed').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
}
function projSaveGhost() {
if (pSim) pSim.saveGhost();
}
function projClearGhosts() {
if (pSim) pSim.clearGhosts();
}
function _projUpdateUI(s) {
const fmt = (n, unit) => n < 10000 ? n.toFixed(2) + ' ' + unit : (n/1000).toFixed(2) + ' к' + unit;
document.getElementById('ps-range').textContent = fmt(s.range, 'м');
document.getElementById('ps-hmax').textContent = fmt(s.hMax, 'м');
document.getElementById('ps-tf').textContent = s.tf.toFixed(2) + ' с';
document.getElementById('ps-vland').textContent = fmt(s.vLand, 'м/с');
document.getElementById('ps-t').textContent = s.t.toFixed(2) + ' с';
const laEl = document.getElementById('ps-land-angle');
if (laEl) laEl.textContent = s.landAngle > 0.5 ? s.landAngle.toFixed(1) + '°' : '—';
if (s.hasMod) {
const lossEl = document.getElementById('ps-loss');
if (lossEl) {
const sign = s.rangeLoss > 0 ? '+' : '';
lossEl.textContent = s.rangeLoss !== 0 ? sign + s.rangeLoss + '%' : '0%';
lossEl.style.color = s.rangeLoss < 0 ? '#EF476F' : '#7BF5A4';
}
}
_projSyncPlayBtn();
}
/* ── collision ── */