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>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 12:09:44 +03:00
parent 085b7322cf
commit 7f75c96acd
11 changed files with 3037 additions and 2239 deletions
+609 -3
View File
@@ -1,10 +1,11 @@
'use strict';
/* ═══════════════════════════════════════════════════════════════════
ProjectileSim v2 — physics simulation
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 {
@@ -67,6 +68,24 @@ class ProjectileSim {
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();
});
@@ -105,6 +124,7 @@ class ProjectileSim {
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();
@@ -118,6 +138,8 @@ class ProjectileSim {
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();
}
@@ -162,6 +184,363 @@ class ProjectileSim {
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) */
@@ -345,6 +724,7 @@ class ProjectileSim {
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();
@@ -355,9 +735,24 @@ class ProjectileSim {
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();
});
}
@@ -391,6 +786,13 @@ class ProjectileSim {
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()); }
@@ -521,6 +923,25 @@ class ProjectileSim {
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);
@@ -601,6 +1022,93 @@ class ProjectileSim {
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;
@@ -1073,8 +1581,11 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pSim) {
pSim = new ProjectileSim(document.getElementById('proj-canvas'));
pSim.onUpdate = _projUpdateUI;
pSim.onPlayPause = projPlayPause;
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
@@ -1226,6 +1737,91 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
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, 'м');
@@ -1243,7 +1839,17 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
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 ── */