feat(labs): универсальные инструменты для физических симуляций (Раунд 2)

ФУНДАМЕНТ — frontend/js/labs/_phys_visuals.js (новый файл, 871 строк):
- LSPhysFX.FORCE_COLORS: стандарт 10 цветов (mg красный, N циан, F_тр оранжевый,
  T фиолетовый, F_упр зелёный, F_сопр серый, F_прил жёлтый, импульс, v, a)
- drawVector / drawForceArrow / drawSpring / drawRope / drawSurface
- drawCoordSystem / drawScale / drawClock / drawAngleArc / drawPivot
- drawEnergyBars (KE/PE/W_тр/E_упр стеком с авто-масштабированием)
- LSTimeControl class (pause/play/0.1×-5×/scrubber для одного DOF)
- LSMotionTrail class (gradient line с alpha fade)
- LSBuildTimeControlUI helper для DOM-UI бара

ГРАФИКИ — frontend/js/labs/_graph_panel.js (новый файл, 415 строк):
- LSGraphPanel: 3 stacked time-series плота, авто-scale, freeze/export PNG
- LSGraphPanelUI: overlay-виджет 320×248 в углу canvas, body-selector,
  кнопки Сброс/Стоп/PNG download

FBD (свободные силовые диаграммы) интегрированы в:
- projectile.js: mg + drag + wind + elastic (bounce)
- pendulum.js: mg + T (math), mg + F_упр (spring vert/horiz)
- collision.js: стрелки скорости каждого шара + flash импульса
- newton.js: все сцены законов I-III + новые Атвуд/наклон/скатывание
- forcesandbox.js: gravity/N/friction/spring/applied на каждом теле

ENERGY BARS интегрированы в 5 сим с расчётами:
- projectile: ΔE_drag = F_d·v·dt (cumulative)
- pendulum: для math/spring/double/physical с учётом γ-затухания
- collision: KE loss при каждом столкновении
- newton: все 8 сцен включая Атвуд + наклон + скатывание (с моментом инерции)
- forcesandbox: + E_упр от пружин

GRAPHS PANEL — в 5 сим:
- pendulum: θ/ω/E (режим-aware)
- collision: |v₁|, |v₂|, v_цм
- newton: x/v/a (зависит от закона)
- forcesandbox: x/|v|/|a| выбранного тела
- hydrostatics: depth/vy/submergedFrac (только Архимед)

TIME CONTROL + MOTION TRAILS в 5 сим:
- pendulum (полный scrubber для math), collision, newton, forcesandbox (speed+pause)
- projectile (layered speed+pause, свой trail сохранён)
- LSMotionTrail на bob/балах/блоках с alpha gradient

Заменено рисование пружин на LSPhysFX.drawSpring везде.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-26 14:37:48 +03:00
parent e46548d06b
commit 7a323f8fe0
10 changed files with 2536 additions and 39 deletions
+871
View File
@@ -0,0 +1,871 @@
'use strict';
/* ═══════════════════════════════════════════════════════════════════
LSPhysFX — shared physics visualization helpers
Provides: drawVector, drawForceArrow, drawSpring, drawRope,
drawSurface, drawCoordSystem, drawScale, drawClock,
drawAngleArc, drawPivot
═══════════════════════════════════════════════════════════════════ */
(function(global) {
if (!global.LSPhysFX) global.LSPhysFX = {};
/* ── Standard force colours ─────────────────────────────────── */
LSPhysFX.FORCE_COLORS = {
gravity: '#EF476F', // mg — red
normal: '#06D6E0', // N — cyan
friction: '#FF8C42', // F_тр — orange
tension: '#9B5DE5', // T — violet
elastic: '#7BF5A4', // F_упр — green
drag: '#888', // F_сопр — gray
applied: '#FFD166', // F_прил — gold
impulse: '#FF6B6B', // удар — bright red
velocity: '#4CC9F0', // v vector — light blue
accel: '#F15BB5', // a vector — pink
};
/* ── drawVector ─────────────────────────────────────────────── */
/* Draws an arrow from (x0, y0) with components (vx, vy).
opts: { color, label, scale=1, lineWidth=2, dashed=false, arrowSize=8 } */
LSPhysFX.drawVector = function(ctx, x0, y0, vx, vy, opts) {
const o = opts || {};
const color = o.color || '#fff';
const scale = (o.scale !== undefined) ? o.scale : 1;
const lw = (o.lineWidth !== undefined) ? o.lineWidth : 2;
const arrowSize = (o.arrowSize !== undefined) ? o.arrowSize : 8;
const label = o.label || '';
const dashed = o.dashed || false;
const ex = x0 + vx * scale;
const ey = y0 + vy * scale;
const dx = ex - x0, dy = ey - y0;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 2) return;
const ux = dx / len, uy = dy / len;
const hw = arrowSize * 0.55, hl = arrowSize * 1.4;
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.shadowColor = color;
ctx.shadowBlur = 5;
if (dashed) ctx.setLineDash([5, 4]);
/* shaft — stop before arrowhead */
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(ex - ux * hl, ey - uy * hl);
ctx.stroke();
ctx.setLineDash([]);
/* arrowhead */
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - ux * hl - uy * hw, ey - uy * hl + ux * hw);
ctx.lineTo(ex - ux * hl + uy * hw, ey - uy * hl - ux * hw);
ctx.closePath();
ctx.fill();
/* label at tip offset perpendicular */
if (label) {
ctx.shadowBlur = 0;
ctx.font = 'bold 10px Manrope, monospace';
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lx = (x0 + ex) / 2 - uy * 14;
const ly = (y0 + ey) / 2 + ux * 14;
ctx.fillText(label, lx, ly);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
}
ctx.restore();
};
/* ── drawForceArrow ─────────────────────────────────────────── */
/* Convenience wrapper: type = key from FORCE_COLORS.
(fx, fy) are canvas-space pixel components of the arrow. */
LSPhysFX.drawForceArrow = function(ctx, x0, y0, fx, fy, type, label) {
const col = LSPhysFX.FORCE_COLORS[type] || '#fff';
LSPhysFX.drawVector(ctx, x0, y0, fx, fy, {
color: col, label: label || '', scale: 1,
lineWidth: 2.5, arrowSize: 8,
});
};
/* ── drawSpring ─────────────────────────────────────────────── */
/* Draws a zigzag spring between two endpoints.
opts: { coils=10, amp=8, color='#FFD166', lineWidth=2 } */
LSPhysFX.drawSpring = function(ctx, x1, y1, x2, y2, opts) {
const o = opts || {};
const coils = (o.coils !== undefined) ? o.coils : 10;
const amp = (o.amp !== undefined) ? o.amp : 8;
const color = o.color || '#FFD166';
const lw = (o.lineWidth !== undefined) ? o.lineWidth : 2;
const dx = x2 - x1, dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 2) return;
/* unit vectors along and perpendicular to spring axis */
const ux = dx / len, uy = dy / len;
const nx = -uy, ny = ux;
/* end pads: 10% each side */
const pad = Math.min(len * 0.08, 8);
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const seg = coils * 2 + 2;
const bodyL = len - 2 * pad;
ctx.beginPath();
ctx.moveTo(x1, y1);
/* short pad line */
ctx.lineTo(x1 + ux * pad, y1 + uy * pad);
for (let i = 1; i <= seg; i++) {
const t = i / seg;
const side = (i % 2 === 0) ? amp : -amp;
const bx = x1 + ux * pad + t * bodyL * ux + nx * side;
const bya = y1 + uy * pad + t * bodyL * uy + ny * side;
ctx.lineTo(bx, bya);
}
/* short pad line at end */
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
};
/* ── drawRope ───────────────────────────────────────────────── */
/* Draws a rope (straight or catenary-like if sag > 0).
opts: { color='rgba(210,225,255,0.6)', sag=0, lineWidth=2, dashed=false } */
LSPhysFX.drawRope = function(ctx, x1, y1, x2, y2, opts) {
const o = opts || {};
const color = o.color || 'rgba(210,225,255,0.6)';
const sag = (o.sag !== undefined) ? o.sag : 0;
const lw = (o.lineWidth !== undefined) ? o.lineWidth : 2;
const dashed = o.dashed || false;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
if (dashed) ctx.setLineDash([5, 4]);
ctx.beginPath();
if (sag > 0) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2 + sag;
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
} else {
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
};
/* ── drawSurface ────────────────────────────────────────────── */
/* Draws a floor/wall/ramp with diagonal hatching.
(x, y) = left endpoint of the surface line; w = length.
opts: { angle=0 (radians), hatch=true, color='rgba(255,255,255,0.25)', thick=4 } */
LSPhysFX.drawSurface = function(ctx, x, y, w, opts) {
const o = opts || {};
const angle = (o.angle !== undefined) ? o.angle : 0;
const hatch = (o.hatch !== undefined) ? o.hatch : true;
const color = o.color || 'rgba(255,255,255,0.25)';
const thick = (o.thick !== undefined) ? o.thick : 4;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
/* main surface line */
ctx.strokeStyle = color;
ctx.lineWidth = thick;
ctx.lineCap = 'butt';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(w, 0);
ctx.stroke();
/* hatching below */
if (hatch) {
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.lineWidth = 1;
const step = 10;
const depth = 12;
for (let xi = 0; xi < w + depth; xi += step) {
const xs = Math.min(xi, w);
const xe = Math.max(0, xi - depth);
if (xs === xe) continue;
ctx.beginPath();
ctx.moveTo(xs, 0);
ctx.lineTo(xe, depth);
ctx.stroke();
}
}
ctx.restore();
};
/* ── drawCoordSystem ────────────────────────────────────────── */
/* Draws XY axes with optional grid inside a rect (ox,oy)(ox+w,oy+h).
opts: { labelX='x', labelY='y', gridSize=20, gridColor, axisColor, labelColor } */
LSPhysFX.drawCoordSystem = function(ctx, ox, oy, w, h, opts) {
const o = opts || {};
const labelX = o.labelX || 'x';
const labelY = o.labelY || 'y';
const gridSize = (o.gridSize !== undefined) ? o.gridSize : 20;
const gridColor = o.gridColor || 'rgba(255,255,255,0.05)';
const axisColor = o.axisColor || 'rgba(255,255,255,0.4)';
const labelColor = o.labelColor || 'rgba(255,255,255,0.6)';
ctx.save();
/* grid */
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
for (let gx = ox; gx <= ox + w; gx += gridSize) {
ctx.beginPath(); ctx.moveTo(gx, oy); ctx.lineTo(gx, oy + h); ctx.stroke();
}
for (let gy = oy; gy <= oy + h; gy += gridSize) {
ctx.beginPath(); ctx.moveTo(ox, gy); ctx.lineTo(ox + w, gy); ctx.stroke();
}
/* axes */
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1.5;
/* X axis */
ctx.beginPath(); ctx.moveTo(ox, oy + h); ctx.lineTo(ox + w + 10, oy + h); ctx.stroke();
/* Y axis */
ctx.beginPath(); ctx.moveTo(ox, oy + h); ctx.lineTo(ox, oy - 10); ctx.stroke();
/* axis labels */
ctx.fillStyle = labelColor;
ctx.font = 'bold 11px Manrope, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(labelX, ox + w + 12, oy + h);
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(labelY, ox, oy - 12);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.restore();
};
/* ── drawScale ──────────────────────────────────────────────── */
/* Ruler with tick marks.
opts: { horizontal=true, label='', color='rgba(255,255,255,0.5)' } */
LSPhysFX.drawScale = function(ctx, x, y, length, divisions, opts) {
const o = opts || {};
const horiz = (o.horizontal !== undefined) ? o.horizontal : true;
const label = o.label || '';
const color = o.color || 'rgba(255,255,255,0.5)';
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.font = '9px Manrope, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const step = length / divisions;
if (horiz) {
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + length, y); ctx.stroke();
for (let i = 0; i <= divisions; i++) {
const tx = x + i * step;
const tall = (i % 5 === 0) ? 8 : 4;
ctx.beginPath(); ctx.moveTo(tx, y); ctx.lineTo(tx, y + tall); ctx.stroke();
if (i % 5 === 0) ctx.fillText(String(i), tx, y + 10);
}
} else {
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + length); ctx.stroke();
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= divisions; i++) {
const ty = y + i * step;
const tall = (i % 5 === 0) ? 8 : 4;
ctx.beginPath(); ctx.moveTo(x, ty); ctx.lineTo(x - tall, ty); ctx.stroke();
if (i % 5 === 0) ctx.fillText(String(i), x - 10, ty);
}
}
if (label) {
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(label, horiz ? x + length / 2 : x - 22, horiz ? y + 22 : y + length + 4);
}
ctx.restore();
};
/* ── drawClock ──────────────────────────────────────────────── */
/* Stopwatch face showing time t (seconds). */
LSPhysFX.drawClock = function(ctx, x, y, r, t) {
ctx.save();
/* face */
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; ctx.stroke();
/* ticks */
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1;
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
const r1 = r * 0.80, r2 = r * 0.92;
ctx.beginPath();
ctx.moveTo(x + Math.cos(a) * r1, y + Math.sin(a) * r1);
ctx.lineTo(x + Math.cos(a) * r2, y + Math.sin(a) * r2);
ctx.stroke();
}
/* second hand — completes one revolution per 60 s */
const secAngle = (t % 60) / 60 * Math.PI * 2 - Math.PI / 2;
ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(secAngle) * r * 0.75, y + Math.sin(secAngle) * r * 0.75);
ctx.stroke();
/* minute hand */
const minAngle = (t % 3600) / 3600 * Math.PI * 2 - Math.PI / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(minAngle) * r * 0.55, y + Math.sin(minAngle) * r * 0.55);
ctx.stroke();
/* time label */
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = `bold ${Math.max(8, r * 0.28)}px Manrope, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const mins = Math.floor(t / 60);
const secs = (t % 60).toFixed(1);
ctx.fillText((mins > 0 ? mins + ':' : '') + (mins > 0 && t % 60 < 10 ? '0' : '') + secs + 'с', x, y + r * 0.38);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.restore();
};
/* ── drawAngleArc ───────────────────────────────────────────── */
/* Arc from angle a1 to a2 (radians) with optional label.
opts: { color='rgba(255,200,60,0.5)', label='', lineWidth=1.5 } */
LSPhysFX.drawAngleArc = function(ctx, cx, cy, r, a1, a2, opts) {
const o = opts || {};
const color = o.color || 'rgba(255,200,60,0.5)';
const label = o.label || '';
const lw = (o.lineWidth !== undefined) ? o.lineWidth : 1.5;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.beginPath();
ctx.arc(cx, cy, r, a1, a2);
ctx.stroke();
if (label) {
const mid = (a1 + a2) / 2;
const lx = cx + Math.cos(mid) * (r + 12);
const ly = cy + Math.sin(mid) * (r + 12);
ctx.fillStyle = color;
ctx.font = 'bold 10px Manrope, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, lx, ly);
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
}
ctx.restore();
};
/* ── drawEnergyBars ─────────────────────────────────────────── */
/* Universal stacked energy-bar widget for mech sims.
Renders inside a semi-transparent panel at canvas position (x, y).
energies : {
ke — kinetic energy, J (always shown)
pe — gravitational PE, J
elastic — spring/elastic PE, J
friction — cumulative heat loss (W_тр), J
total — optional: external scale anchor (if provided and > sum, used for normalization)
}
opts : {
w — panel width in px (default 185)
bgColor — background fill
borderColor — border stroke
colors — { ke, pe, elastic, friction }
}
*/
LSPhysFX.drawEnergyBars = function(ctx, x, y, w, _h, energies, opts) {
opts = opts || {};
var ke = Math.max(0, energies.ke || 0);
var pe = Math.max(0, energies.pe || 0);
var friction = Math.max(0, energies.friction || 0);
var elastic = Math.max(0, energies.elastic || 0);
var mechSum = ke + pe + elastic;
var total = mechSum + friction;
/* external scale anchor for stable bars */
if (energies.total && energies.total > total) total = energies.total;
if (total < 0.001) return;
var cols = opts.colors || {};
var CKE = cols.ke || '#06D6E0';
var CPE = cols.pe || '#FFD166';
var CEL = cols.elastic || '#7BF5A4';
var CFR = cols.friction || '#888888';
/* layout */
var PAD_L = 40; /* left label column */
var PAD_R = 52; /* right value column */
var ROW_H = 14;
var GAP = 5;
var panelW = (w && w > 0 ? w : 185);
/* rows — always show ke and W_тр, conditionally PE and elastic */
var rows = [];
rows.push({ label: 'Eк', val: ke, color: CKE });
if (pe > 0.001) rows.push({ label: 'Eп', val: pe, color: CPE });
if (elastic > 0.001) rows.push({ label: 'Eупр', val: elastic, color: CEL });
rows.push({ label: 'Wтр', val: friction, color: CFR });
var panelH = GAP + (ROW_H + GAP) * (rows.length + 1); /* +1 for Sigma */
var barX = x + PAD_L;
var barW = panelW - PAD_L - PAD_R;
/* background panel */
ctx.save();
ctx.fillStyle = opts.bgColor || 'rgba(10,12,26,0.82)';
ctx.strokeStyle = opts.borderColor || 'rgba(255,255,255,0.13)';
ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, panelW, panelH, 8);
else ctx.rect(x, y, panelW, panelH);
ctx.fill();
ctx.stroke();
ctx.font = 'bold 9px Manrope, monospace';
ctx.textBaseline = 'middle';
/* ── Sigma row ── */
var ry = y + GAP;
var sigmaW = (total / total) * barW; /* always full = reference */
ctx.fillStyle = 'rgba(255,255,255,0.18)';
_lsfx_rrect2(ctx, barX, ry + 1, barW, ROW_H - 2, 3); ctx.fill();
/* mech portion highlighted */
var mechW = (mechSum / total) * barW;
ctx.fillStyle = 'rgba(255,255,255,0.38)';
if (mechW > 0.5) { _lsfx_rrect2(ctx, barX, ry + 1, mechW, ROW_H - 2, 3); ctx.fill(); }
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.textAlign = 'right';
ctx.fillText('ΣE', x + PAD_L - 4, ry + ROW_H / 2);
ctx.textAlign = 'left';
ctx.fillText(_lsfx_fmt2(mechSum + friction), barX + barW + 4, ry + ROW_H / 2);
ry += ROW_H + GAP;
/* ── individual rows ── */
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var bw = barW * (row.val / total);
ctx.fillStyle = 'rgba(255,255,255,0.07)';
_lsfx_rrect2(ctx, barX, ry + 1, barW, ROW_H - 2, 3); ctx.fill();
if (bw > 0.5) {
ctx.fillStyle = row.color;
_lsfx_rrect2(ctx, barX, ry + 1, Math.max(bw, 1.5), ROW_H - 2, 3); ctx.fill();
}
ctx.fillStyle = row.color;
ctx.textAlign = 'right';
ctx.fillText(row.label, x + PAD_L - 4, ry + ROW_H / 2);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.textAlign = 'left';
ctx.fillText(_lsfx_fmt2(row.val), barX + barW + 4, ry + ROW_H / 2);
ry += ROW_H + GAP;
}
ctx.restore();
};
function _lsfx_rrect2(ctx, x, y, w, h, r) {
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
else ctx.rect(x, y, w, h);
}
function _lsfx_fmt2(v) {
if (v >= 1000) return (v / 1000).toFixed(1) + 'кДж';
if (v >= 10) return v.toFixed(0) + ' Дж';
if (v >= 0.1) return v.toFixed(1) + ' Дж';
return v.toFixed(2) + ' Дж';
}
/* ── drawPivot ──────────────────────────────────────────────── */
/* Pinned joint circle (pivot) for pendulum/lever. */
LSPhysFX.drawPivot = function(ctx, x, y, r) {
const rad = r || 5;
ctx.save();
ctx.beginPath();
ctx.arc(x, y, rad, 0, Math.PI * 2);
ctx.fillStyle = '#444';
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.fill();
ctx.stroke();
/* crosshair */
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x - rad, y); ctx.lineTo(x + rad, y);
ctx.moveTo(x, y - rad); ctx.lineTo(x, y + rad);
ctx.stroke();
ctx.restore();
};
})(window);
/* ══════════════════════════════════════════════════════════════
TimeControl — playback speed + history scrubber
Manages slow-motion, fast-forward and history replay.
Integration in a sim's RAF tick:
const scaledDt = this._tc.advance(rawDt);
if (scaledDt === 0) { this.draw(); return; }
this._step(scaledDt);
this._tc.record(this.getState());
══════════════════════════════════════════════════════════════ */
(function(global) {
if (global.LSTimeControl) return; // guard: another agent may have loaded
function _pvClone(obj) {
try {
if (typeof structuredClone === 'function') return structuredClone(obj);
} catch (e) { /* ignore */ }
return JSON.parse(JSON.stringify(obj));
}
class TimeControl {
constructor(sim, opts) {
opts = opts || {};
this.sim = sim;
this.scale = 1;
this.paused = false;
this.history = [];
this.historyMax = opts.historyMax || 600; // 10 s @ 60 fps
this.scrubbing = false;
this.scrubIndex = null;
}
/* Returns scaled dt (0 when paused or scrubbing) */
advance(dtRaw) {
if (this.paused) return 0;
if (this.scrubbing) return 0;
return dtRaw * this.scale;
}
record(state) {
if (this.scrubbing) return;
this.history.push({ t: performance.now(), state: _pvClone(state) });
while (this.history.length > this.historyMax) this.history.shift();
}
setScrub(idx) {
this.scrubbing = true;
this.scrubIndex = idx;
if (this.sim.applyState && this.history[idx]) {
this.sim.applyState(this.history[idx].state);
}
}
exitScrub() { this.scrubbing = false; }
setScale(s) { this.scale = +s; }
togglePause() { this.paused = !this.paused; return this.paused; }
stepBack() {
if (!this.history.length) return;
const idx = (this.scrubIndex !== null)
? Math.max(0, this.scrubIndex - 1)
: this.history.length - 1;
this.setScrub(idx);
}
stepForward() {
if (!this.history.length) return;
if (this.scrubIndex === null) return;
const idx = Math.min(this.history.length - 1, this.scrubIndex + 1);
this.setScrub(idx);
if (idx >= this.history.length - 1) this.exitScrub();
}
reset() {
this.history = [];
this.scrubbing = false;
this.scrubIndex = null;
this.paused = false;
}
}
global.LSTimeControl = TimeControl;
/* ════════════════════════════════════════════════════════════
MotionTrail — fading gradient trail for moving bodies
Usage each frame:
trail.push(x, y);
trail.tick();
trail.draw(ctx);
════════════════════════════════════════════════════════════ */
class MotionTrail {
constructor(opts) {
opts = opts || {};
this.points = [];
this.maxAge = opts.maxAge || 60;
this.maxLen = opts.maxLen || 120;
this.color = opts.color || '#06D6E0';
this.width = opts.width || 3;
}
push(x, y) {
this.points.push({ x, y, age: 0 });
while (this.points.length > this.maxLen) this.points.shift();
}
tick() {
for (let i = 0; i < this.points.length; i++) this.points[i].age++;
while (this.points.length && this.points[0].age > this.maxAge) {
this.points.shift();
}
}
clear() { this.points = []; }
draw(ctx) {
const pts = this.points;
if (pts.length < 2) return;
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let i = 1; i < pts.length; i++) {
const p0 = pts[i - 1];
const p1 = pts[i];
const ratio = p1.age / this.maxAge; // 0=fresh, 1=gone
const alpha = Math.max(0, (1 - ratio) * 0.72);
if (alpha < 0.01) continue;
ctx.globalAlpha = alpha;
ctx.strokeStyle = this.color;
ctx.lineWidth = Math.max(0.5, this.width * (1 - ratio * 0.6));
ctx.shadowColor = this.color;
ctx.shadowBlur = 6 * (1 - ratio);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.stroke();
}
ctx.restore();
}
}
global.LSMotionTrail = MotionTrail;
/* ════════════════════════════════════════════════════════════
buildTimeControlUI(sim, tc, opts)
Builds and returns a <div> with the full time-control bar.
opts: { scrubSupported, onPause, onScale }
════════════════════════════════════════════════════════════ */
function buildTimeControlUI(sim, tc, opts) {
opts = opts || {};
const scrubSupported = !!opts.scrubSupported;
const wrap = document.createElement('div');
wrap.className = 'tc-bar';
wrap.style.cssText = [
'display:flex',
'align-items:center',
'gap:4px',
'padding:5px 10px',
'background:rgba(12,8,28,0.9)',
'border-top:1px solid rgba(255,255,255,0.07)',
'flex-wrap:wrap',
'flex-shrink:0',
].join(';');
const BTN = [
'display:inline-flex',
'align-items:center',
'justify-content:center',
'gap:3px',
'padding:3px 8px',
'border:1px solid rgba(255,255,255,0.15)',
'border-radius:7px',
'background:rgba(28,18,48,0.8)',
'color:#ccc',
'font-size:.68rem',
'font-weight:700',
'cursor:pointer',
'white-space:nowrap',
'flex-shrink:0',
'font-family:Manrope,sans-serif',
].join(';');
const IC_PAUSE = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
const IC_PLAY = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
const IC_BACK = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="4" x2="5" y2="20"/></svg>';
const IC_FWD = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="4" x2="19" y2="20"/></svg>';
function mkBtn(inner, title, onclick) {
const b = document.createElement('button');
b.className = 'zoom-btn';
b.style.cssText = BTN;
b.title = title || '';
b.innerHTML = inner;
b.addEventListener('click', onclick);
return b;
}
function divider() {
const d = document.createElement('div');
d.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
return d;
}
/* Pause/Play */
const playBtn = mkBtn(IC_PAUSE + ' Пауза', 'Пауза / Воспроизведение', function() {
const paused = tc.togglePause();
playBtn.innerHTML = paused
? IC_PLAY + ' Играть'
: IC_PAUSE + ' Пауза';
if (paused && sim.draw) sim.draw();
if (opts.onPause) opts.onPause(paused);
});
wrap.appendChild(playBtn);
wrap.appendChild(divider());
/* Speed buttons */
const SPEEDS = [0.1, 0.25, 0.5, 1, 2, 5];
const speedBtns = [];
SPEEDS.forEach(s => {
const label = (s === 0.1 ? '0.1' : s === 0.25 ? '¼' : s === 0.5 ? '½' : s) + '×';
const b = mkBtn(label, s + '× скорость', function() {
tc.setScale(s);
speedBtns.forEach(sb => {
sb.style.background = 'rgba(28,18,48,0.8)';
sb.style.color = '#ccc';
sb.style.borderColor = 'rgba(255,255,255,0.15)';
});
b.style.background = 'rgba(155,93,229,0.28)';
b.style.color = '#c9a0ff';
b.style.borderColor = 'rgba(155,93,229,0.55)';
if (opts.onScale) opts.onScale(s);
});
if (s === 1) {
b.style.background = 'rgba(155,93,229,0.28)';
b.style.color = '#c9a0ff';
b.style.borderColor = 'rgba(155,93,229,0.55)';
}
speedBtns.push(b);
wrap.appendChild(b);
});
/* Step + scrubber (only when scrubSupported) */
if (scrubSupported) {
wrap.appendChild(divider());
const backBtn = mkBtn(IC_BACK, 'Шаг назад', function() {
tc.stepBack();
if (sim.draw) sim.draw();
sync();
});
wrap.appendChild(backBtn);
const fwdBtn = mkBtn(IC_FWD, 'Шаг вперёд', function() {
tc.stepForward();
if (sim.draw) sim.draw();
sync();
});
wrap.appendChild(fwdBtn);
wrap.appendChild(divider());
const scrubWrap = document.createElement('div');
scrubWrap.style.cssText = 'display:flex;align-items:center;gap:5px;flex:1;min-width:80px;max-width:200px';
const lbl = document.createElement('span');
lbl.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.4);flex-shrink:0;font-family:Manrope,sans-serif';
lbl.textContent = 'История:';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '0';
slider.value = '0';
slider.style.cssText = 'flex:1;accent-color:#9B5DE5;cursor:pointer';
slider.addEventListener('pointerdown', () => { tc.scrubbing = true; });
slider.addEventListener('input', function() {
const idx = parseInt(this.value, 10);
tc.setScrub(idx);
if (sim.draw) sim.draw();
});
slider.addEventListener('pointerup', function() {
if (parseInt(this.value, 10) >= tc.history.length - 1) tc.exitScrub();
sync();
});
function sync() {
const len = tc.history.length;
if (!len) return;
slider.max = String(len - 1);
slider.value = (tc.scrubIndex !== null)
? String(tc.scrubIndex)
: String(len - 1);
}
const iv = setInterval(() => {
if (!document.body.contains(wrap)) { clearInterval(iv); return; }
sync();
}, 400);
scrubWrap.appendChild(lbl);
scrubWrap.appendChild(slider);
wrap.appendChild(scrubWrap);
}
return wrap;
}
global.LSBuildTimeControlUI = buildTimeControlUI;
})(window);