Files
Learn_System/frontend/js/labs/projectile.js
T
Maxim Dolgolyov 7f75c96acd feat(labs): planimetry locus + emfield merger + projectile graphs + UI cleanup
Геометрия (планиметрия):
- Живые измерения как объекты: длина / угол / площадь — auto-recompute, draggable chips
- Инструмент ГМТ: sweep мовера через параметр, рисует кривую места точек
- Новые типы точек: on_segment (скользит по отрезку, _t), on_circle (по окружности, _theta)
- Toolbar: «Длина», «Угол», «Площадь», «ГМТ», «На отрезке», «На окружности»

Электромагнитные поля (emfield):
- Merge magnetic.js + coulomb.js в один EMFieldSim с 3 режимами (E / B / комбинированное)
- Унифицированный pipeline: colormap, field lines, vectors, equipotentials, flux loop, test particle
- Combined-режим: полная сила Лоренца F=q(E+v×B)
- Backward compat: #coulomb и #magnetic хеши и ?sim= параметры редиректят в emfield
- Удалены: magnetic.js, coulomb.js. Добавлен: emfield.js

Бросок тела (projectile):
- Режим целей: 3 окна, hit-детекция, HUD «Цели: N/M / Попыток: K»
- Графики x(t), y(t), vx(t), vy(t) — 2×2 Canvas 2D, real-time
- Двойной бросок: одновременно 2 траектории для сравнения (cyan vs gold)

UI fixes (по результатам аудита):
- Заменены emoji/unicode на inline SVG .ic: switch ⌇, spring 〜 (5 мест), download ⬇ (2), camera 📷
- Убраны декоративные символы ☉ ○ из geometry tool labels
- Добавлены THEORY entries: geometry, hydrostatics (раньше показывали fallback)
- Стандартизирована ширина panel для sim-proj и sim-coll (240px)
- waves перенесён в физический блок SIMS catalog (был после биологии)
- Очищен дефолтный sim-topbar-title (был «График функции»)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:09:44 +03:00

1857 lines
67 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 v3 — 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
═══════════════════════════════════════════════════════════════════ */
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)
/* ── Feature 1: target challenge mode ── */
this.targetMode = false;
this._targets = []; // [{x,y,w,h,hit,flashTs}]
this._targetAttempts = 0;
this.onTargetUpdate = null; // callback → ({hits, total, attempts})
/* ── Feature 2: graphs panel ── */
this._graphsCanvas = null; // set by attachGraphsCanvas()
this._graphsVisible = false;
/* ── Feature 3: dual throw ── */
this.dualMode = false;
this._p2 = { // second projectile params + live state
v0: 25, angle: 30, h0: 0,
path: null, pathTf: 0,
t: 0, trail: [],
};
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();
if (this.dualMode) this._computeP2Path();
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;
/* reset p2 at launch so both start simultaneously */
if (this.dualMode) { this._p2.t = 0; this._p2.trail = []; }
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 ? ` ветер ${this.wind > 0 ? '+' : ''}${this.wind}` : '';
const label = `${this.angle}° ${this.v0}м/с${windStr}${this.drag ? ' +drag' : ''}${this.bounce ? ' ↩' : ''}`;
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();
}
/* ── Feature 1: target mode ── */
toggleTargetMode() {
this.targetMode = !this.targetMode;
if (this.targetMode && this._targets.length === 0) this.genTargets();
this._emitTargets();
this.draw();
return this.targetMode;
}
genTargets() {
const st = this.stats();
const range = Math.max(st.range, 10);
const hMax = Math.max(st.hMax, 5);
const count = 3;
this._targets = [];
for (let i = 0; i < count; i++) {
const tw = 1.0 + Math.random() * 1.5; // window width 12.5 m
const th = 1.0 + Math.random() * 1.5; // window height 12.5 m
/* spread windows across [10%, 90%] of range so they're reachable */
const x = range * (0.1 + 0.8 * (i + Math.random() * 0.5) / count);
const y = 1.0 + Math.random() * Math.max(1, hMax * 0.7);
this._targets.push({ x, y, w: tw, h: th, hit: false, flashTs: -999 });
}
this._targetAttempts = 0;
this._emitTargets();
this.draw();
}
_checkTargetHits(prevT, nextT) {
if (!this.targetMode || this._targets.length === 0) return;
/* sample a few sub-steps between prevT and nextT for precision */
const steps = 8;
for (let s = 0; s <= steps; s++) {
const t = prevT + (nextT - prevT) * (s / steps);
const st = this._curState(t);
if (st.y < 0) break;
for (const tgt of this._targets) {
if (tgt.hit) continue;
if (st.x >= tgt.x && st.x <= tgt.x + tgt.w &&
st.y >= tgt.y && st.y <= tgt.y + tgt.h) {
tgt.hit = true;
tgt.flashTs = performance.now();
this._emitTargets();
}
}
}
}
_emitTargets() {
if (!this.onTargetUpdate) return;
const hits = this._targets.filter(t => t.hit).length;
this.onTargetUpdate({ hits, total: this._targets.length, attempts: this._targetAttempts });
}
_drawTargets(ctx, tpx, tpy) {
if (!this.targetMode) return;
const now = performance.now();
for (const tgt of this._targets) {
const cx = tpx(tgt.x);
const cy = tpy(tgt.y + tgt.h); // top edge in canvas coords
const cw = tpx(tgt.x + tgt.w) - tpx(tgt.x);
const ch = tpy(tgt.y) - tpy(tgt.y + tgt.h); // positive height
const flashAge = (now - tgt.flashTs) / 1000;
const flashing = flashAge < 1.2;
if (tgt.hit) {
/* gold fill on hit */
const alpha = flashing ? 0.25 + 0.2 * Math.sin(flashAge * 18) : 0.18;
ctx.fillStyle = `rgba(255,214,102,${alpha})`;
ctx.fillRect(cx, cy, cw, ch);
ctx.strokeStyle = flashing
? `rgba(255,214,102,${0.7 + 0.3 * Math.sin(flashAge * 18)})`
: 'rgba(255,214,102,.6)';
ctx.lineWidth = 2.5;
ctx.strokeRect(cx, cy, cw, ch);
/* checkmark */
const mx = cx + cw / 2, my = cy + ch / 2;
ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(mx - cw * 0.22, my);
ctx.lineTo(mx - cw * 0.05, my + ch * 0.22);
ctx.lineTo(mx + cw * 0.28, my - ch * 0.28);
ctx.stroke();
} else {
/* inactive window: translucent blue rect with dashed border */
ctx.fillStyle = 'rgba(6,214,224,.06)';
ctx.fillRect(cx, cy, cw, ch);
ctx.strokeStyle = 'rgba(6,214,224,.5)';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(cx, cy, cw, ch);
ctx.setLineDash([]);
/* small cross in top-right corner to look like a window frame */
ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(cx + cw / 2, cy + ch);
ctx.moveTo(cx, cy + ch / 2); ctx.lineTo(cx + cw, cy + ch / 2);
ctx.stroke();
}
}
}
/* ── Feature 2: graphs canvas attachment ── */
attachGraphsCanvas(canvas) {
this._graphsCanvas = canvas;
}
drawGraphs() {
const gc = this._graphsCanvas;
if (!gc || !this._graphsVisible) return;
const dpr = window.devicePixelRatio || 1;
const W = gc.clientWidth || gc.width / dpr;
const H = gc.clientHeight || gc.height / dpr;
if (!W || !H) return;
/* keep physical pixel size in sync */
if (gc.width !== Math.round(W * dpr) || gc.height !== Math.round(H * dpr)) {
gc.width = Math.round(W * dpr);
gc.height = Math.round(H * dpr);
}
const gctx = gc.getContext('2d');
gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
gctx.clearRect(0, 0, W, H);
const tf = this._curTFlight();
if (tf <= 0) {
gctx.fillStyle = 'rgba(255,255,255,.2)';
gctx.font = '11px Manrope, sans-serif';
gctx.textAlign = 'center'; gctx.textBaseline = 'middle';
gctx.fillText('Запустите симуляцию', W / 2, H / 2);
return;
}
/* collect full trajectory data for plotting */
const N = 200;
const pts = [];
for (let i = 0; i <= N; i++) {
const t = (i / N) * tf;
const s = this._curState(t);
pts.push({ t, x: s.x, y: Math.max(0, s.y), vx: s.vx, vy: s.vy });
}
const plots = [
{ key: 'x', label: 'x(t)', unit: 'м', color: '#06D6E0' },
{ key: 'y', label: 'y(t)', unit: 'м', color: '#7BF5A4' },
{ key: 'vx', label: 'vx(t)', unit: 'м/с', color: '#9B5DE5' },
{ key: 'vy', label: 'vy(t)', unit: 'м/с', color: '#F15BB5' },
];
const cols = 2, rows = 2;
const PL = 36, PR = 10, PT = 20, PB = 22;
const cw = W / cols, ch = H / rows;
const pw = cw - PL - PR, ph = ch - PT - PB;
/* current time marker fraction */
const curFrac = tf > 0 ? Math.min(1, this.t / tf) : 0;
for (let pi = 0; pi < plots.length; pi++) {
const col = pi % cols, row = Math.floor(pi / cols);
const ox = col * cw, oy = row * ch;
const plot = plots[pi];
const vals = pts.map(p => p[plot.key]);
const vMin = Math.min(...vals), vMax = Math.max(...vals);
const vRange = Math.max(vMax - vMin, 0.1);
const tx = t => ox + PL + (t / tf) * pw;
const ty = v => oy + PT + ph - ((v - vMin) / vRange) * ph;
/* background */
gctx.fillStyle = 'rgba(5,5,20,.85)';
gctx.fillRect(ox + PL, oy + PT, pw, ph);
/* grid lines */
gctx.strokeStyle = 'rgba(255,255,255,.05)'; gctx.lineWidth = 1;
for (let gi = 1; gi < 4; gi++) {
const gv = vMin + (gi / 4) * vRange;
const gy = ty(gv);
gctx.beginPath(); gctx.moveTo(ox + PL, gy); gctx.lineTo(ox + PL + pw, gy); gctx.stroke();
}
/* axes */
gctx.strokeStyle = 'rgba(255,255,255,.25)'; gctx.lineWidth = 1.2;
gctx.beginPath();
gctx.moveTo(ox + PL, oy + PT); gctx.lineTo(ox + PL, oy + PT + ph);
gctx.lineTo(ox + PL + pw, oy + PT + ph);
gctx.stroke();
/* axis labels */
gctx.font = '9px Manrope, sans-serif';
gctx.fillStyle = 'rgba(255,255,255,.35)';
gctx.textAlign = 'right'; gctx.textBaseline = 'middle';
gctx.fillText(_projFmt(vMax) + ' ' + plot.unit, ox + PL - 3, oy + PT + 4);
gctx.fillText(_projFmt(vMin) + ' ' + plot.unit, ox + PL - 3, oy + PT + ph - 2);
gctx.textAlign = 'center'; gctx.textBaseline = 'top';
gctx.fillText(_projFmt(tf) + ' с', ox + PL + pw, oy + PT + ph + 4);
/* data line */
gctx.strokeStyle = plot.color; gctx.lineWidth = 2;
gctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const px = tx(pts[i].t), py = ty(pts[i][plot.key]);
i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
}
gctx.stroke();
/* second projectile overlay */
if (this.dualMode && this._p2.pathTf > 0) {
const tf2 = this._p2.pathTf;
const pts2 = [];
for (let i = 0; i <= N; i++) {
const t2 = (i / N) * tf2;
const s2 = this._p2.path ? this._p2PathStateAt(t2) : this._p2StateAnalytical(t2);
pts2.push({ t: t2, x: s2.x, y: Math.max(0, s2.y), vx: s2.vx, vy: s2.vy });
}
gctx.strokeStyle = 'rgba(0,230,255,.55)'; gctx.lineWidth = 1.5;
gctx.setLineDash([4, 3]);
gctx.beginPath();
for (let i = 0; i < pts2.length; i++) {
const px = tx(pts2[i].t), py = ty(pts2[i][plot.key]);
i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
}
gctx.stroke(); gctx.setLineDash([]);
}
/* current time indicator */
if (curFrac > 0) {
const curX = tx(this.t);
gctx.strokeStyle = 'rgba(255,214,102,.7)'; gctx.lineWidth = 1.5;
gctx.setLineDash([3, 3]);
gctx.beginPath(); gctx.moveTo(curX, oy + PT); gctx.lineTo(curX, oy + PT + ph); gctx.stroke();
gctx.setLineDash([]);
/* dot on the line */
const curV = this._curState(this.t)[plot.key];
const curVclamped = Math.min(vMax, Math.max(vMin, curV));
gctx.fillStyle = '#FFD166';
gctx.beginPath(); gctx.arc(curX, ty(curVclamped), 3.5, 0, Math.PI * 2); gctx.fill();
}
/* label */
gctx.font = 'bold 11px Manrope, sans-serif';
gctx.fillStyle = plot.color;
gctx.textAlign = 'left'; gctx.textBaseline = 'top';
gctx.fillText(plot.label, ox + PL + 5, oy + PT + 4);
}
}
/* ── Feature 3: second projectile helpers ── */
_p2StateAnalytical(t) {
const p2 = this._p2;
const rad = p2.angle * Math.PI / 180;
const vx = p2.v0 * Math.cos(rad);
const vy0 = p2.v0 * Math.sin(rad);
return {
x: vx * t,
y: p2.h0 + vy0 * t - 0.5 * this.g * t * t,
vx,
vy: vy0 - this.g * t,
};
}
_p2PathStateAt(t) {
const path = this._p2.path;
if (!path || path.length < 2) return { x: 0, y: this._p2.h0, vx: 0, vy: 0 };
if (t <= 0) return path[0];
if (t >= this._p2.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,
};
}
_p2CurState(t) {
return this._p2.path ? this._p2PathStateAt(t) : this._p2StateAnalytical(t);
}
/* recompute second projectile path using same RK4 if drag/wind/bounce active */
_computeP2Path() {
const p2 = this._p2;
if (!this._needsNumerical()) {
const rad = p2.angle * Math.PI / 180;
const vy0 = p2.v0 * Math.sin(rad);
const disc = vy0 * vy0 + 2 * this.g * p2.h0;
p2.path = null;
p2.pathTf = disc < 0 ? 0 : Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
return;
}
const rho = 1.225, 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;
const rad = p2.angle * Math.PI / 180;
let x = 0, y = p2.h0;
let vx = p2.v0 * Math.cos(rad), vy = p2.v0 * Math.sin(rad);
const dt = 0.005;
const path = [{ x, y, vx, vy, t: 0 }];
let bounces = 0;
const deriv = (sx, sy, svx, svy) => {
const rvx = svx - W;
const speed = Math.sqrt(rvx * rvx + svy * svy);
const dragF = speed > 0 ? k * speed : 0;
const wAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
return { dx: svx, dy: svy, dvx: -dragF * rvx + wAcc, dvy: -g - dragF * svy };
};
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 t2 = (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 && bounces < maxBounces && Math.abs(lvy) > 0.4) {
vy = -e * lvy; vx = lvx * 0.96; y = 0.001; x = lx;
bounces++;
continue;
}
}
break;
}
path.push({ x, y, vx, vy, t: t2 });
}
p2.path = path;
p2.pathTf = path[path.length - 1].t;
}
/* ── 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 prevT = this.t;
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();
if (this.targetMode) this._targetAttempts++;
}
/* target hit detection on this step interval */
this._checkTargetHits(prevT, Math.min(this.t, tf));
/* advance second projectile */
if (this.dualMode) {
const p2 = this._p2;
const p2cur = this._p2CurState(p2.t);
p2.trail.push({ mx: p2cur.x, my: p2cur.y });
if (p2.trail.length > 80) p2.trail.shift();
p2.t = Math.min(p2.t + rawDt * this.speed, p2.pathTf);
}
this.draw();
this._emit();
if (this._graphsVisible) this.drawGraphs();
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();
if (this.dualMode) {
this._p2.t = 0;
this._p2.trail = [];
this._computeP2Path();
}
/* clear target hits so player can retry */
for (const tgt of this._targets) tgt.hit = false;
}
_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);
}
/* ── 6.7. Target windows ── */
this._drawTargets(ctx, tpx, tpy);
/* ── 6.8. HUD: target counter (top-right inside canvas) ── */
if (this.targetMode && this._targets.length > 0) {
const hits = this._targets.filter(t => t.hit).length;
const hudText = `Цели: ${hits}/${this._targets.length} Попыток: ${this._targetAttempts}`;
ctx.font = 'bold 11px Manrope, sans-serif';
const tw = ctx.measureText(hudText).width;
const hx = W - PR - 8 - tw - 20, hy = PT + 30;
ctx.fillStyle = 'rgba(5,5,20,.75)';
ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,214,102,.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.stroke();
ctx.fillStyle = '#FFD166';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(hudText, hx + 4, hy + 7);
}
/* ── 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();
}
/* ── 10.5. Dual throw — second projectile ── */
if (this.dualMode && this._p2.pathTf > 0) {
const p2 = this._p2;
const tf2 = p2.pathTf;
/* full reference trajectory */
ctx.strokeStyle = 'rgba(0,230,255,.25)'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
ctx.beginPath();
const step2 = Math.max(1, p2.path ? Math.floor(p2.path.length / 250) : 1);
if (p2.path) {
for (let i = 0; i < p2.path.length; i += step2) {
const pp = p2.path[i];
i === 0 ? ctx.moveTo(tpx(pp.x), tpy(pp.y)) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
}
} else {
for (let i = 0; i <= 250; i++) {
const s2 = this._p2StateAnalytical((i / 250) * tf2);
i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
}
}
ctx.stroke(); ctx.setLineDash([]);
/* flown path */
if (p2.t > 0) {
const s2_0 = this._p2CurState(0), s2_1 = this._p2CurState(Math.min(p2.t, tf2));
const g2 = ctx.createLinearGradient(tpx(s2_0.x), tpy(s2_0.y), tpx(s2_1.x), tpy(s2_1.y));
g2.addColorStop(0, 'rgba(0,230,255,.3)');
g2.addColorStop(1, '#00E6FF');
ctx.strokeStyle = g2; ctx.lineWidth = 3;
ctx.beginPath();
if (p2.path) {
let first = true;
for (const pp of p2.path) {
if (pp.t > p2.t) break;
first ? (ctx.moveTo(tpx(pp.x), tpy(pp.y)), first = false) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
}
const ps2 = this._p2PathStateAt(p2.t);
ctx.lineTo(tpx(ps2.x), tpy(Math.max(0, ps2.y)));
} else {
const steps2 = Math.max(2, Math.ceil((p2.t / tf2) * 250));
for (let i = 0; i <= steps2; i++) {
const s2 = this._p2StateAnalytical((i / 250) * tf2);
i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
}
}
ctx.stroke();
}
/* p2 trail dots */
for (let i = 0; i < p2.trail.length; i++) {
const frac = i / p2.trail.length;
const tr2 = p2.trail[i];
ctx.fillStyle = `rgba(0,230,255,${frac * 0.45})`;
ctx.beginPath(); ctx.arc(tpx(tr2.mx), tpy(tr2.my), frac * 4.5, 0, Math.PI * 2); ctx.fill();
}
/* p2 ball */
const c2 = this._p2CurState(Math.min(p2.t, tf2));
const b2x = tpx(c2.x), b2y = tpy(Math.max(0, c2.y));
const glo2 = ctx.createRadialGradient(b2x, b2y, 2, b2x, b2y, 28);
glo2.addColorStop(0, 'rgba(0,230,255,.45)');
glo2.addColorStop(1, 'transparent');
ctx.fillStyle = glo2;
ctx.beginPath(); ctx.arc(b2x, b2y, 28, 0, Math.PI * 2); ctx.fill();
const bg2 = ctx.createRadialGradient(b2x - 3, b2y - 3, 1, b2x, b2y, 10);
bg2.addColorStop(0, '#ffffff');
bg2.addColorStop(0.25, '#00E6FF');
bg2.addColorStop(1, '#0891b2');
ctx.fillStyle = bg2;
ctx.beginPath(); ctx.arc(b2x, b2y, 10, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke();
/* p2 landing marker */
const end2 = this._p2CurState(tf2);
const lx2 = tpx(end2.x), ly2 = tpy(0);
ctx.strokeStyle = 'rgba(0,230,255,.6)'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(lx2 - 6, ly2 - 6); ctx.lineTo(lx2 + 6, ly2 + 6);
ctx.moveTo(lx2 + 6, ly2 - 6); ctx.lineTo(lx2 - 6, ly2 + 6);
ctx.stroke();
ctx.fillStyle = 'rgba(0,230,255,.8)';
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(_projFmt(end2.x) + ' м', lx2, ly2 + 8);
}
/* ── 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 ? '→' : '←';
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, '↩ 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.onTargetUpdate = _projUpdateTargetHUD;
const gc = document.getElementById('proj-graphs-canvas');
if (gc) pSim.attachGraphsCanvas(gc);
}
pSim.fit();
projParam(); // sync sliders → 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 ? '→ +' : '← ') + 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();
}
/* ── Feature 1: target mode UI ── */
function projToggleTargetMode() {
if (!pSim) return;
const on = pSim.toggleTargetMode();
const btn = document.getElementById('proj-target-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Режим целей: Вкл' : 'Режим целей: Выкл';
}
const panel = document.getElementById('proj-target-panel');
if (panel) panel.style.display = on ? '' : 'none';
_projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
}
function projGenTargets() {
if (!pSim) return;
pSim.genTargets();
_projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
}
function _projUpdateTargetHUD(info) {
const el = document.getElementById('proj-target-hud');
if (!el) return;
el.textContent = `Цели: ${info.hits}/${info.total} Попыток: ${info.attempts}`;
}
/* ── Feature 2: graphs panel UI ── */
function projToggleGraphs() {
if (!pSim) return;
pSim._graphsVisible = !pSim._graphsVisible;
const panel = document.getElementById('proj-graphs-panel');
const btn = document.getElementById('proj-graphs-btn');
if (panel) panel.style.display = pSim._graphsVisible ? '' : 'none';
if (btn) btn.classList.toggle('active', pSim._graphsVisible);
if (pSim._graphsVisible) {
if (!pSim._graphsCanvas) {
const gc = document.getElementById('proj-graphs-canvas');
if (gc) pSim.attachGraphsCanvas(gc);
}
pSim.drawGraphs();
}
}
/* ── Feature 3: dual throw UI ── */
function projToggleDual() {
if (!pSim) return;
pSim.dualMode = !pSim.dualMode;
const on = pSim.dualMode;
const btn = document.getElementById('proj-dual-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Двойной: Вкл' : 'Двойной: Выкл';
}
const panel = document.getElementById('proj-dual-panel');
if (panel) panel.style.display = on ? '' : 'none';
/* show/hide dual stats cells */
const w1 = document.getElementById('ps-p2-wrap');
const w2 = document.getElementById('ps-p2-tf-wrap');
if (w1) w1.style.display = on ? '' : 'none';
if (w2) w2.style.display = on ? '' : 'none';
if (on) {
pSim._computeP2Path();
projP2Param();
}
pSim.draw();
}
function projP2Param() {
if (!pSim) return;
const v0 = +document.getElementById('sl-p2-v0').value;
const angle = +document.getElementById('sl-p2-angle').value;
const h0 = +document.getElementById('sl-p2-h0').value;
document.getElementById('p2-v0').textContent = v0 + ' м/с';
document.getElementById('p2-angle').textContent = angle + '°';
document.getElementById('p2-h0').textContent = h0 + ' м';
pSim._p2.v0 = v0;
pSim._p2.angle = angle;
pSim._p2.h0 = h0;
pSim._computeP2Path();
pSim.draw();
}
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';
}
}
/* update dual stats row */
if (pSim && pSim.dualMode && pSim._p2.pathTf > 0) {
const p2end = pSim._p2CurState(pSim._p2.pathTf);
const d2El = document.getElementById('ps-p2-range');
if (d2El) d2El.textContent = fmt(Math.max(0, p2end.x), 'м');
const d2tf = document.getElementById('ps-p2-tf');
if (d2tf) d2tf.textContent = pSim._p2.pathTf.toFixed(2) + ' с';
}
_projSyncPlayBtn();
/* redraw graphs if open and not in flight (flight loop handles it) */
if (pSim && pSim._graphsVisible && !pSim.playing) pSim.drawGraphs();
}
/* ── collision ── */