Files
Learn_System/frontend/js/labs/projectile.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

1064 lines
38 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();
}