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>
415 lines
13 KiB
JavaScript
415 lines
13 KiB
JavaScript
'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;
|