Files
Learn_System/frontend/js/labs/_graph_panel.js
T
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

415 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ══════════════════════════════════════════════════════════════
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;