Files
Maxim Dolgolyov 7a323f8fe0 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>
2026-05-26 14:37:48 +03:00

872 lines
31 KiB
JavaScript
Raw Permalink 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';
/* ═══════════════════════════════════════════════════════════════════
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);