7a323f8fe0
ФУНДАМЕНТ — 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>
872 lines
31 KiB
JavaScript
872 lines
31 KiB
JavaScript
'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);
|