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
+414
View File
@@ -0,0 +1,414 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
GraphPanel — reusable stacked time-series widget
Shows up to 3 traces (x/v/a or custom) in stacked sub-plots.
Usage:
const gp = new GraphPanel(canvas, {
traces: ['x', 'v', 'a'],
labels: ['x', 'v', 'a'],
units: ['м', 'м/с', 'м/с²'],
colors: ['#06D6E0', '#FFD166', '#EF476F'],
maxPoints: 600
});
gp.push(t, [xVal, vVal, aVal]); // call every frame
gp.draw(); // call every frame
gp.clear(); // reset
══════════════════════════════════════════════════════════════ */
class GraphPanel {
constructor(canvas, opts) {
opts = opts || {};
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.traces = opts.traces || ['x', 'v', 'a'];
this.colors = opts.colors || ['#06D6E0', '#FFD166', '#EF476F'];
this.labels = opts.labels || ['x', 'v', 'a'];
this.units = opts.units || ['м', 'м/с', 'м/с²'];
this.maxPoints = opts.maxPoints || 600;
this.bgColor = opts.bgColor || 'rgba(10,10,22,0.97)';
this.gridColor = opts.gridColor || 'rgba(255,255,255,0.06)';
this.axisColor = opts.axisColor || 'rgba(255,255,255,0.18)';
this.frozen = false;
this._frozenSnap = null;
this.t = [];
this.data = this.traces.map(() => []);
}
/* ─── Data feed ─────────────────────────────────────────────── */
push(t, values) {
if (this.frozen) return;
this.t.push(t);
values.forEach((v, i) => {
if (this.data[i]) this.data[i].push(isFinite(v) ? v : 0);
});
while (this.t.length > this.maxPoints) {
this.t.shift();
this.data.forEach(arr => arr.shift());
}
}
clear() {
this.t = [];
this.data.forEach(arr => { arr.length = 0; });
this.frozen = false;
this._frozenSnap = null;
this.draw();
}
freeze() {
this.frozen = !this.frozen;
if (this.frozen) {
this._frozenSnap = {
t: [...this.t],
data: this.data.map(arr => [...arr]),
traces: this.traces,
labels: this.labels
};
} else {
this._frozenSnap = null;
}
return this._frozenSnap;
}
exportPNG() { return this.canvas.toDataURL('image/png'); }
/* ─── Draw ───────────────────────────────────────────────────── */
draw() {
const { canvas, ctx } = this;
const W = canvas.width;
const H = canvas.height;
const n = this.traces.length;
if (!n) return;
ctx.clearRect(0, 0, W, H);
/* background */
ctx.fillStyle = this.bgColor;
ctx.fillRect(0, 0, W, H);
/* frozen overlay badge */
if (this.frozen) {
ctx.save();
ctx.fillStyle = 'rgba(255,209,102,0.12)';
ctx.fillRect(0, 0, W, H);
ctx.restore();
}
const PAD_L = 42;
const PAD_R = 8;
const PAD_TOP = 6;
const PAD_BOT = 6;
const plotH = Math.floor((H - PAD_TOP - PAD_BOT) / n);
for (let ti = 0; ti < n; ti++) {
const y0 = PAD_TOP + ti * plotH;
const y1 = y0 + plotH;
const pw = W - PAD_L - PAD_R;
const ph = plotH - 2;
/* sub-plot frame */
if (ti > 0) {
ctx.save();
ctx.strokeStyle = this.gridColor;
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(PAD_L, y0);
ctx.lineTo(W - PAD_R, y0);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
/* label */
ctx.save();
ctx.font = '10px Manrope,sans-serif';
ctx.fillStyle = this.colors[ti] || '#fff';
ctx.textAlign = 'left';
ctx.fillText(this.labels[ti] || this.traces[ti], 2, y0 + 13);
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '8px Manrope,sans-serif';
ctx.fillText(this.units[ti] || '', 2, y0 + 23);
ctx.restore();
const arr = this.data[ti];
if (!arr || arr.length < 2) continue;
/* auto-scale */
let mn = arr[0], mx = arr[0];
for (let k = 1; k < arr.length; k++) {
if (arr[k] < mn) mn = arr[k];
if (arr[k] > mx) mx = arr[k];
}
const span = Math.max(mx - mn, 1e-9);
const pad = span * 0.12;
const yMin = mn - pad;
const yMax = mx + pad;
const mapX = (idx) => PAD_L + (idx / (this.maxPoints - 1)) * pw;
const mapY = (v) => y0 + ph - ((v - yMin) / (yMax - yMin)) * ph;
/* grid lines (3 horizontal) */
ctx.save();
ctx.strokeStyle = this.gridColor;
ctx.lineWidth = 0.5;
for (let g = 0; g <= 2; g++) {
const gy = y0 + (g / 2) * ph;
ctx.beginPath(); ctx.moveTo(PAD_L, gy); ctx.lineTo(W - PAD_R, gy); ctx.stroke();
}
ctx.restore();
/* zero line */
if (yMin < 0 && yMax > 0) {
const zy = mapY(0);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = 0.8;
ctx.setLineDash([2, 3]);
ctx.beginPath(); ctx.moveTo(PAD_L, zy); ctx.lineTo(W - PAD_R, zy); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
/* y-axis min/max labels */
ctx.save();
ctx.font = '8px Manrope,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.textAlign = 'right';
ctx.fillText(_gpFmt(yMax), PAD_L - 2, y0 + 9);
ctx.fillText(_gpFmt(yMin), PAD_L - 2, y0 + ph + 1);
ctx.restore();
/* trace */
ctx.save();
ctx.strokeStyle = this.colors[ti] || '#fff';
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.beginPath();
const startIdx = Math.max(0, this.maxPoints - arr.length);
let first = true;
for (let k = 0; k < arr.length; k++) {
const px = mapX(startIdx + k);
const py = mapY(arr[k]);
if (first) { ctx.moveTo(px, py); first = false; }
else { ctx.lineTo(px, py); }
}
ctx.stroke();
ctx.restore();
/* current-value dot + readout */
const lastV = arr[arr.length - 1];
const dotX = mapX(startIdx + arr.length - 1);
const dotY = mapY(lastV);
ctx.save();
ctx.fillStyle = this.colors[ti] || '#fff';
ctx.shadowColor = this.colors[ti] || '#fff';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(dotX, dotY, 3, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.font = 'bold 9px Manrope,sans-serif';
ctx.fillStyle = this.colors[ti] || '#fff';
ctx.textAlign = 'right';
ctx.fillText(_gpFmt(lastV), W - PAD_R - 1, y0 + 12);
ctx.restore();
}
/* frozen badge */
if (this.frozen) {
ctx.save();
ctx.font = 'bold 9px Manrope,sans-serif';
ctx.fillStyle = '#FFD166';
ctx.textAlign = 'center';
ctx.fillText('ЗАМОРОЖЕНО', W / 2, H - 3);
ctx.restore();
}
}
}
/* compact number format helper */
function _gpFmt(v) {
const av = Math.abs(v);
if (av === 0) return '0';
if (av >= 1000) return v.toFixed(0);
if (av >= 100) return v.toFixed(1);
if (av >= 10) return v.toFixed(2);
if (av >= 0.1) return v.toFixed(3);
return v.toExponential(1);
}
window.LSGraphPanel = GraphPanel;
/* ══════════════════════════════════════════════════════════════
GraphPanelUI — self-contained overlay widget for a sim panel
Creates a bottom-overlay <div> with canvas + controls.
Usage:
const ui = new GraphPanelUI(parentWrap, panelOpts);
ui.push(t, values); // every RAF frame
ui.isOn // read whether active
══════════════════════════════════════════════════════════════ */
class GraphPanelUI {
constructor(parentEl, opts) {
opts = opts || {};
this.isOn = false;
this._parent = parentEl;
this._panelOpts = opts;
this._gp = null;
this._el = null;
// body-selector support (optional)
this._bodySelector = opts.bodySelector || null;
this._selectedBody = 0;
}
toggle() {
this.isOn = !this.isOn;
if (this.isOn) {
this._build();
} else {
this._destroy();
}
return this.isOn;
}
_build() {
if (this._el) return;
const wrap = document.createElement('div');
wrap.id = this._panelOpts.domId || 'gp-overlay-' + Date.now();
wrap.style.cssText = [
'position:absolute', 'bottom:0', 'right:0',
'width:320px', 'height:248px',
'background:rgba(10,10,22,0.97)',
'border:1px solid rgba(255,255,255,0.1)',
'border-radius:10px 0 0 0',
'display:flex', 'flex-direction:column',
'z-index:20', 'overflow:hidden',
].join(';');
/* title bar */
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;align-items:center;gap:4px;padding:4px 6px;background:rgba(255,255,255,0.04);border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0';
const title = document.createElement('span');
title.style.cssText = 'flex:1;font-size:.72rem;color:rgba(255,255,255,.55);font-family:Manrope,sans-serif';
title.textContent = this._panelOpts.title || 'Графики';
bar.appendChild(title);
/* body selector (optional) */
if (this._bodySelector) {
const sel = document.createElement('select');
sel.style.cssText = 'font-size:.68rem;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:5px;padding:1px 4px;margin-right:4px';
this._bodySelector.forEach((label, i) => {
const opt = document.createElement('option');
opt.value = i; opt.textContent = label;
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
this._selectedBody = +sel.value;
if (this._gp) this._gp.clear();
});
this._selEl = sel;
bar.appendChild(sel);
}
/* clear button */
const btnClr = document.createElement('button');
btnClr.textContent = 'Сброс';
btnClr.style.cssText = _gpBtnStyle();
btnClr.addEventListener('click', () => this._gp && this._gp.clear());
bar.appendChild(btnClr);
/* freeze button */
const btnFrz = document.createElement('button');
btnFrz.textContent = 'Стоп';
btnFrz.style.cssText = _gpBtnStyle();
btnFrz.addEventListener('click', () => {
if (!this._gp) return;
this._gp.freeze();
btnFrz.style.color = this._gp.frozen ? '#FFD166' : '';
});
bar.appendChild(btnFrz);
/* export PNG button */
const btnExp = document.createElement('button');
btnExp.title = 'Экспорт PNG';
btnExp.style.cssText = _gpBtnStyle();
btnExp.innerHTML = '<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
btnExp.addEventListener('click', () => {
if (!this._gp) return;
const a = document.createElement('a');
a.href = this._gp.exportPNG();
a.download = 'graph.png';
a.click();
});
bar.appendChild(btnExp);
/* close button */
const btnX = document.createElement('button');
btnX.innerHTML = '<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
btnX.style.cssText = _gpBtnStyle('rgba(239,71,111,0.6)');
btnX.addEventListener('click', () => {
this.isOn = false;
this._destroy();
const toggleBtn = document.getElementById(this._panelOpts.toggleBtnId);
if (toggleBtn) toggleBtn.classList.remove('active');
});
bar.appendChild(btnX);
wrap.appendChild(bar);
/* canvas */
const cv = document.createElement('canvas');
cv.style.cssText = 'flex:1;width:100%;display:block';
wrap.appendChild(cv);
this._parent.style.position = 'relative';
this._parent.appendChild(wrap);
this._el = wrap;
/* init GraphPanel after layout */
requestAnimationFrame(() => {
cv.width = cv.offsetWidth || 320;
cv.height = cv.offsetHeight || 214;
this._gp = new GraphPanel(cv, this._panelOpts);
this._gp.draw();
});
}
_destroy() {
if (this._el) {
this._el.remove();
this._el = null;
this._gp = null;
}
}
push(t, values) {
if (!this.isOn || !this._gp) return;
this._gp.push(t, values);
this._gp.draw();
}
get selectedBody() { return this._selectedBody; }
}
function _gpBtnStyle(color) {
return [
'font-size:.65rem', 'padding:2px 6px',
'border-radius:5px',
'border:1px solid rgba(255,255,255,0.12)',
'background:rgba(255,255,255,0.05)',
'color:' + (color || 'rgba(255,255,255,0.55)'),
'cursor:pointer', 'white-space:nowrap',
].join(';');
}
window.LSGraphPanelUI = GraphPanelUI;