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:
@@ -1941,3 +1941,14 @@ canvas[data-draggable]:active { cursor: grabbing; }
|
||||
.ptbl-reaction-label { font-size: .68rem; color: rgba(255,255,255,0.4); margin-bottom: 3px; }
|
||||
.ptbl-reaction-eq { font-size: .78rem; font-weight: 600; font-family: 'Courier New', monospace; }
|
||||
.ptbl-reaction-note { font-size: .68rem; color: rgba(255,255,255,0.3); font-style: italic; margin-top: 4px; }
|
||||
|
||||
/* ── GraphPanel toggle button active state ── */
|
||||
#btn-pend-graphs.active,
|
||||
#btn-coll-graphs.active,
|
||||
#btn-newton-graphs.active,
|
||||
#btn-sandbox-graphs.active,
|
||||
#btn-hydro-graphs.active {
|
||||
background: rgba(6,214,224,0.22) !important;
|
||||
border-color: #06D6E0 !important;
|
||||
box-shadow: 0 0 8px rgba(6,214,224,0.3);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,871 @@
|
||||
'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);
|
||||
@@ -58,6 +58,23 @@ class CollisionSim {
|
||||
/* hover inspector */
|
||||
this._hoverBall = null;
|
||||
|
||||
/* FBD toggle */
|
||||
this._fbdOn = false;
|
||||
|
||||
/* ── Energy bars widget ── */
|
||||
this._energyOn = false;
|
||||
this._frictionWork = 0; // cumulative KE lost in inelastic collisions
|
||||
this._energyScale = 0;
|
||||
|
||||
/* -- GraphPanel widget -- */
|
||||
this._graphsOn = false;
|
||||
this._graphUI = null;
|
||||
|
||||
/* ── TimeControl + MotionTrails ── */
|
||||
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
|
||||
this._tcTrailColors = ['#06D6E0', '#F15BB5'];
|
||||
this.showTCTrails = false;
|
||||
|
||||
canvas.addEventListener('click', () => { if (this.onPlayPause) this.onPlayPause(); });
|
||||
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
|
||||
canvas.addEventListener('mouseleave', () => { this._hoverBall = null; this.draw(); });
|
||||
@@ -148,16 +165,20 @@ class CollisionSim {
|
||||
this._b = [
|
||||
{ id:1, m:this.m1, r:r1, x:cx - gap/2, y:cy,
|
||||
vx:this.v1, vy:0, angle: 0, angVel: 0,
|
||||
color:'#9B5DE5', rgb:'155,93,229', trail:[] },
|
||||
color:'#9B5DE5', rgb:'155,93,229', trail:[],
|
||||
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#9B5DE5', width:2.5, maxLen:120 }) : null },
|
||||
{ id:2, m:this.m2, r:r2, x:cx + gap/2, y:cy + dy2,
|
||||
vx:-this.v2 * Math.cos(rad), vy:-this.v2 * Math.sin(rad), angle: 0, angVel: 0,
|
||||
color:'#06D6E0', rgb:'6,214,224', trail:[] },
|
||||
color:'#06D6E0', rgb:'6,214,224', trail:[],
|
||||
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#06D6E0', width:2.5, maxLen:120 }) : null },
|
||||
];
|
||||
|
||||
this._cooldown = 0;
|
||||
this._colCount = 0;
|
||||
this._snapBefore = null;
|
||||
this._snapAfter = null;
|
||||
this._cooldown = 0;
|
||||
this._colCount = 0;
|
||||
this._snapBefore = null;
|
||||
this._snapAfter = null;
|
||||
this._frictionWork = 0;
|
||||
this._energyScale = 0;
|
||||
}
|
||||
|
||||
/* ═══ launch visual burst ═══ */
|
||||
@@ -190,11 +211,29 @@ class CollisionSim {
|
||||
if (this._lastTs === null) this._lastTs = ts;
|
||||
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||||
this._lastTs = ts;
|
||||
const dt = rawDt * this.speed;
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
|
||||
let dt;
|
||||
if (this._tc) {
|
||||
dt = this._tc.advance(rawDt * this.speed);
|
||||
if (dt === 0) { this.draw(); this._emit(); if (this.playing) this._tick(); return; }
|
||||
} else {
|
||||
dt = rawDt * this.speed;
|
||||
}
|
||||
this._tSim = (this._tSim || 0) + dt;
|
||||
|
||||
/* tick ball motion trails */
|
||||
if (this.showTCTrails) {
|
||||
for (const b of this._b) { if (b._trail2) b._trail2.tick(); }
|
||||
}
|
||||
|
||||
this._step(dt);
|
||||
this.draw();
|
||||
this._emit();
|
||||
if (window.LSGraphPanel && this._graphsOn && this._graphUI) {
|
||||
const [b1, b2] = this._b;
|
||||
this._graphUI.push(this._tSim || 0, [b1 ? Math.hypot(b1.vx, b1.vy) : 0, b2 ? Math.hypot(b2.vx, b2.vy) : 0, 0]);
|
||||
}
|
||||
if (this.playing) this._tick();
|
||||
});
|
||||
}
|
||||
@@ -208,6 +247,7 @@ class CollisionSim {
|
||||
for (const b of this._b) {
|
||||
b.trail.push({ x: b.x, y: b.y, spd: Math.hypot(b.vx, b.vy) });
|
||||
if (b.trail.length > 90) b.trail.shift();
|
||||
if (this.showTCTrails && b._trail2) b._trail2.push(b.x, b.y);
|
||||
}
|
||||
|
||||
/* centre-of-mass trail */
|
||||
@@ -299,6 +339,12 @@ class CollisionSim {
|
||||
}
|
||||
|
||||
this._snapAfter = this._snapshot();
|
||||
/* track inelastic energy loss for energy bars */
|
||||
if (this._energyOn && this._snapBefore && this._snapAfter) {
|
||||
const sumKE = arr => arr.reduce((s, b) => s + b.ke, 0);
|
||||
const dKE = sumKE(this._snapBefore) - sumKE(this._snapAfter);
|
||||
if (dKE > 0) this._frictionWork += dKE;
|
||||
}
|
||||
this._colCount++;
|
||||
this._cooldown = 8;
|
||||
|
||||
@@ -427,6 +473,28 @@ class CollisionSim {
|
||||
|
||||
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
|
||||
|
||||
toggleGraphs(canvasOuter) {
|
||||
if (!window.LSGraphPanelUI) return false;
|
||||
this._graphsOn = !this._graphsOn;
|
||||
if (this._graphsOn) {
|
||||
this._tSim = 0;
|
||||
this._graphUI = new GraphPanelUI(canvasOuter, {
|
||||
maxPoints: 400,
|
||||
traces: ['v1', 'v2', 'cm'],
|
||||
labels: ['|v1|', '|v2|', 'v_цм'],
|
||||
units: ['м/с', 'м/с', 'м/с'],
|
||||
colors: ['#9B5DE5', '#06D6E0', '#FFD166'],
|
||||
toggleBtnId: 'btn-coll-graphs',
|
||||
title: 'Скорости'
|
||||
});
|
||||
this._graphUI.isOn = true;
|
||||
this._graphUI._build();
|
||||
} else {
|
||||
if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
|
||||
}
|
||||
return this._graphsOn;
|
||||
}
|
||||
|
||||
/* ═══ RENDER ═══ */
|
||||
|
||||
draw() {
|
||||
@@ -564,6 +632,11 @@ class CollisionSim {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 7a. MotionTrail overlay ── */
|
||||
if (this.showTCTrails) {
|
||||
for (const b of this._b) { if (b._trail2) b._trail2.draw(ctx); }
|
||||
}
|
||||
|
||||
/* ── 7b. Centre-of-mass trail ── */
|
||||
for (let i = 1; i < this._cmTrail.length; i++) {
|
||||
const frac = i / this._cmTrail.length;
|
||||
@@ -928,10 +1001,52 @@ class CollisionSim {
|
||||
this._drawBallTooltip(ctx, this._hoverBall, W, H);
|
||||
}
|
||||
|
||||
/* ── 18. FBD velocity / impulse overlays ── */
|
||||
if (this._fbdOn && window.LSPhysFX) {
|
||||
for (const b of this._b) {
|
||||
const spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
|
||||
if (spd > 0.5) {
|
||||
const vLen = Math.min(60, spd * 3);
|
||||
LSPhysFX.drawForceArrow(ctx, b.x, b.y - b.r - 8,
|
||||
(b.vx / spd) * vLen, (b.vy / spd) * vLen,
|
||||
'velocity', 'v=' + spd.toFixed(1));
|
||||
}
|
||||
}
|
||||
/* impulse flash at impact */
|
||||
if (this._impactPt) {
|
||||
const iEl = (performance.now() - this._impactPt.ts) / 300;
|
||||
if (iEl < 1) {
|
||||
const ip = this._impactPt;
|
||||
LSPhysFX.drawForceArrow(ctx, ip.x, ip.y, 0, -40 * (1 - iEl), 'impulse', 'J');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Energy bars overlay ── */
|
||||
if (this._energyOn && window.LSPhysFX) {
|
||||
this._drawEnergyBarsColl(ctx, W, H);
|
||||
}
|
||||
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── Energy bars: collision (1D) ── */
|
||||
|
||||
_drawEnergyBarsColl(ctx, W, H) {
|
||||
var ke = 0;
|
||||
for (var i = 0; i < this._b.length; i++) {
|
||||
var b = this._b[i];
|
||||
var spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
|
||||
ke += 0.5 * b.m * spd * spd;
|
||||
}
|
||||
var tot = ke + this._frictionWork;
|
||||
if (tot > this._energyScale) this._energyScale = tot;
|
||||
var PW = 188, MARGIN = 12;
|
||||
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
|
||||
{ ke: ke, friction: this._frictionWork, total: this._energyScale }, {});
|
||||
}
|
||||
|
||||
/* ── hover inspector ── */
|
||||
|
||||
_onMouseMove(e) {
|
||||
@@ -2154,6 +2269,57 @@ function _roundRect(ctx, x, y, w, h, r) {
|
||||
/* first fit + draw */
|
||||
cSim.fit(); cSim.setSpeed(+document.getElementById('sl-speed').value);
|
||||
collParam(); cSim.draw(); _collUpdateUI(cSim.stats());
|
||||
|
||||
/* ── Inject TimeControl bar ── */
|
||||
_collInjectTCBar();
|
||||
}
|
||||
|
||||
function _collInjectTCBar() {
|
||||
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
|
||||
var wrap = document.getElementById('sim-coll');
|
||||
if (!wrap || wrap.querySelector('.tc-bar')) return;
|
||||
|
||||
/* Build a shared TC that controls whichever sim is active */
|
||||
var proxyTC = {
|
||||
scale: 1, paused: false,
|
||||
advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
|
||||
setScale: function(s) {
|
||||
this.scale = +s;
|
||||
/* propagate to all sims */
|
||||
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.setScale(proxyTC.scale); });
|
||||
},
|
||||
togglePause: function() {
|
||||
this.paused = !this.paused;
|
||||
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.paused = proxyTC.paused; });
|
||||
return this.paused;
|
||||
},
|
||||
};
|
||||
|
||||
/* Trail toggle */
|
||||
var trailBtn = document.createElement('button');
|
||||
trailBtn.className = 'zoom-btn';
|
||||
trailBtn.style.cssText = 'display:inline-flex;align-items: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';
|
||||
trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
|
||||
trailBtn.title = 'Следы движения';
|
||||
trailBtn.addEventListener('click', function() {
|
||||
var on = !((cSim && cSim.showTCTrails));
|
||||
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s) s.showTCTrails = on; });
|
||||
trailBtn.style.background = on ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
|
||||
trailBtn.style.color = on ? '#06D6E0' : '#ccc';
|
||||
trailBtn.style.borderColor = on ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
|
||||
});
|
||||
|
||||
/* dummy sim adapter for LSBuildTimeControlUI */
|
||||
var simProxy = { draw: function() { var a = cSim || cSim2D || cSimMB || cSimBL; if (a) a.draw(); } };
|
||||
var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });
|
||||
var sep = document.createElement('div');
|
||||
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
|
||||
tcBar.appendChild(sep);
|
||||
tcBar.appendChild(trailBtn);
|
||||
|
||||
var statsBar = wrap.querySelector('.proj-stats-bar');
|
||||
if (statsBar) wrap.insertBefore(tcBar, statsBar);
|
||||
else wrap.appendChild(tcBar);
|
||||
}
|
||||
|
||||
/* Switch active mode */
|
||||
@@ -2412,5 +2578,31 @@ function _roundRect(ctx, x, y, w, h, r) {
|
||||
_collSyncBtn();
|
||||
}
|
||||
|
||||
/* ── Energy toggle: collision ── */
|
||||
function collToggleEnergy() {
|
||||
const as = _activeSim && _activeSim();
|
||||
if (!as) return;
|
||||
as._energyOn = !as._energyOn;
|
||||
const on = as._energyOn;
|
||||
const btn = document.getElementById('coll-energy-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', on);
|
||||
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
|
||||
}
|
||||
if (!on) { as._frictionWork = 0; as._energyScale = 0; }
|
||||
as.draw();
|
||||
}
|
||||
|
||||
/* ── magnetic ── */
|
||||
|
||||
|
||||
|
||||
function collToggleGraphs() {
|
||||
const as = _activeSim && _activeSim();
|
||||
if (!as || typeof as.toggleGraphs !== 'function') return;
|
||||
const canvasOuter = document.querySelector('#sim-coll .proj-canvas-outer');
|
||||
if (!canvasOuter) return;
|
||||
const on = as.toggleGraphs(canvasOuter);
|
||||
const btn = document.getElementById('btn-coll-graphs');
|
||||
if (btn) btn.classList.toggle('active', on);
|
||||
}
|
||||
@@ -83,6 +83,22 @@ class ForceSandboxSim {
|
||||
this._floorY = 0;
|
||||
|
||||
this.onUpdate = null;
|
||||
|
||||
/* FBD toggle — when true all forces drawn via LSPhysFX colours */
|
||||
this._fbdOn = false;
|
||||
|
||||
/* ── Universal energy bars ── */
|
||||
this._energyOn = false;
|
||||
this._energyScaleFSB = 0;
|
||||
|
||||
/* -- GraphPanel widget -- */
|
||||
this._graphsOn = false;
|
||||
this._graphUI = null;
|
||||
this._graphBodyIdx = 0;
|
||||
|
||||
/* ── TimeControl ── */
|
||||
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
|
||||
|
||||
this.fit();
|
||||
this._bindEvents();
|
||||
}
|
||||
@@ -176,6 +192,7 @@ class ForceSandboxSim {
|
||||
pinned: false,
|
||||
trail: [],
|
||||
forces: [],
|
||||
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color, width: 2.5, maxLen: 100 }) : null,
|
||||
};
|
||||
this.bodies.push(body);
|
||||
/* LabFX: spawn sound */
|
||||
@@ -608,11 +625,30 @@ class ForceSandboxSim {
|
||||
this._last = now;
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
if (this._paused) { this.draw(); return; }
|
||||
const dt = rawDt * this.timeScale;
|
||||
|
||||
/* TimeControl: scale + pause (wraps existing timeScale) */
|
||||
let dt;
|
||||
if (this._tc) {
|
||||
dt = this._tc.advance(rawDt * this.timeScale);
|
||||
if (dt === 0) { this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; }
|
||||
} else {
|
||||
dt = rawDt * this.timeScale;
|
||||
}
|
||||
|
||||
this._simTime += dt;
|
||||
this._step(dt);
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
if (window.LSGraphPanel && this._graphsOn && this._graphUI && this.bodies.length > 0) {
|
||||
const b = this.bodies[this._graphBodyIdx] || this.bodies[0];
|
||||
const S = ForceSandboxSim.SCALE;
|
||||
if (b && !b.pinned) {
|
||||
const spd = Math.hypot(b.vx, b.vy) / S;
|
||||
const dspd = this._gpPrevSpd != null ? (spd - this._gpPrevSpd) / Math.max(dt, 1e-6) : 0;
|
||||
this._gpPrevSpd = spd;
|
||||
this._graphUI.push(this._simTime, [b.x / S, spd, dspd]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
@@ -718,11 +754,16 @@ class ForceSandboxSim {
|
||||
if (this._strobeTimer >= 0.12) {
|
||||
this._strobeTimer = 0;
|
||||
for (const b of this.bodies) {
|
||||
if (!this.showTrail || b.pinned) continue;
|
||||
if (Math.hypot(b.vx, b.vy) > 8) {
|
||||
if (b.pinned) continue;
|
||||
if (this.showTrail && Math.hypot(b.vx, b.vy) > 8) {
|
||||
b.trail.push({ x: b.x, y: b.y, a: b.angle });
|
||||
if (b.trail.length > 40) b.trail.shift();
|
||||
}
|
||||
/* LSMotionTrail: continuous update regardless of speed threshold */
|
||||
if (b._trail2) {
|
||||
b._trail2.tick();
|
||||
if (Math.hypot(b.vx, b.vy) > 2) b._trail2.push(b.x, b.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1404,6 +1445,8 @@ class ForceSandboxSim {
|
||||
if (this.hasWalls) this._drawWalls(ctx, W, H, fY);
|
||||
if (this.ramp) this._drawRamp(ctx);
|
||||
if (this.showTrail) this._drawTrails(ctx);
|
||||
/* LSMotionTrail overlay for each body */
|
||||
for (const b of this.bodies) { if (b._trail2) b._trail2.draw(ctx); }
|
||||
this._drawRopes(ctx);
|
||||
if (window.LabFX && this.springs.length > 0) {
|
||||
LabFX.glow.drawGlow(ctx, () => this._drawSprings(ctx), { color: '#9B5DE5', intensity: 4 });
|
||||
@@ -1415,13 +1458,54 @@ class ForceSandboxSim {
|
||||
if (this.showVelocity) this._drawVelocities(ctx);
|
||||
if (this._drag) this._drawDragArrow(ctx);
|
||||
if (this.showFBD && this._selected !== null) this._drawFBD(ctx);
|
||||
if (this._fbdOn) this._pv_drawAllForces(ctx);
|
||||
if (this.showEnergy) this._drawEnergyBar(ctx);
|
||||
/* universal energy bars if enabled via toggle */
|
||||
if (this._energyOn && window.LSPhysFX) this._drawEnergyBarsFSB(ctx);
|
||||
if (this._ghostPos && !this._drag && !this._hovered && this.tool !== 'erase') this._drawGhost(ctx);
|
||||
if (this.bodies.length === 0) this._drawHint(ctx);
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── Universal energy bars: forcesandbox ── */
|
||||
|
||||
_drawEnergyBarsFSB(ctx) {
|
||||
if (!this.bodies.length) return;
|
||||
const S = ForceSandboxSim.SCALE, fY = this._floorY;
|
||||
let KE = 0, PE = 0, EL = 0;
|
||||
for (const b of this.bodies) {
|
||||
const v = Math.hypot(b.vx, b.vy) / S;
|
||||
KE += 0.5 * b.mass * v * v;
|
||||
KE += 0.5 * (b.I / (S * S)) * b.omega * b.omega;
|
||||
if (this.gravity && this.hasFloor) {
|
||||
const bot = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r;
|
||||
PE += b.mass * this.gVal * Math.max(0, fY - bot) / S;
|
||||
}
|
||||
/* elastic PE from springs */
|
||||
for (const sp of this.springs) {
|
||||
if (sp.a === b.id || sp.b === b.id) {
|
||||
const ba = this.bodies.find(x => x.id === sp.a);
|
||||
const bb = this.bodies.find(x => x.id === sp.b);
|
||||
if (ba && bb) {
|
||||
const dx = (bb.x - ba.x) / S, dy = (bb.y - ba.y) / S;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||
const dl = dist - (sp.restLen || 0.1);
|
||||
EL += 0.25 * sp.k * dl * dl; /* shared 50/50 per endpoint */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var fr = this._energyLoss;
|
||||
var tot = KE + PE + EL + fr;
|
||||
if (tot > this._energyScaleFSB) this._energyScaleFSB = tot;
|
||||
const PW = 188, MARGIN = 12;
|
||||
/* place in top-right but offset down if existing bar visible */
|
||||
const oy = this.showEnergy ? 72 : MARGIN;
|
||||
LSPhysFX.drawEnergyBars(ctx, this.W - PW - MARGIN, oy, PW, 0,
|
||||
{ ke: KE, pe: PE, elastic: EL, friction: fr, total: this._energyScaleFSB }, {});
|
||||
}
|
||||
|
||||
_drawBg(ctx, W, H) {
|
||||
const bg = ctx.createRadialGradient(W / 2, H * 0.3, 0, W / 2, H / 2, W * 0.82);
|
||||
bg.addColorStop(0, '#0d1320'); bg.addColorStop(1, '#050810');
|
||||
@@ -1605,29 +1689,36 @@ class ForceSandboxSim {
|
||||
} else {
|
||||
cr = 6; cg = Math.round(214 + Math.abs(strain) / 1.5 * 41); cb = 224;
|
||||
}
|
||||
ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.9)`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowColor = `rgb(${cr},${cg},${cb})`;
|
||||
ctx.shadowBlur = 6;
|
||||
|
||||
// Zigzag coil rendering
|
||||
const COILS = 8;
|
||||
const headLen = Math.min(dist * 0.08, 16);
|
||||
const zigDist = dist - 2 * headLen;
|
||||
const spColor = `rgba(${cr},${cg},${cb},0.9)`;
|
||||
const amp = Math.max(3, Math.min(14, 10 / (1 + Math.abs(strain) * 3)));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x1 + ux * headLen, y1 + uy * headLen);
|
||||
for (let i = 0; i < COILS * 2; i++) {
|
||||
const frac = (i + 0.5) / (COILS * 2);
|
||||
const along = headLen + frac * zigDist;
|
||||
const side = (i % 2 === 0) ? amp : -amp;
|
||||
ctx.lineTo(x1 + ux * along + px * side, y1 + uy * along + py * side);
|
||||
/* use LSPhysFX.drawSpring if available, else fallback */
|
||||
if (window.LSPhysFX) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = `rgb(${cr},${cg},${cb})`; ctx.shadowBlur = 6;
|
||||
LSPhysFX.drawSpring(ctx, x1, y1, x2, y2, {
|
||||
coils: 8, amp: amp, color: spColor, lineWidth: 2,
|
||||
});
|
||||
ctx.restore();
|
||||
} else {
|
||||
const COILS = 8;
|
||||
const headLen = Math.min(dist * 0.08, 16);
|
||||
const zigDist = dist - 2 * headLen;
|
||||
ctx.strokeStyle = spColor; ctx.lineWidth = 2;
|
||||
ctx.shadowColor = `rgb(${cr},${cg},${cb})`; ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x1 + ux * headLen, y1 + uy * headLen);
|
||||
for (let i = 0; i < COILS * 2; i++) {
|
||||
const frac = (i + 0.5) / (COILS * 2);
|
||||
const along = headLen + frac * zigDist;
|
||||
const side = (i % 2 === 0) ? amp : -amp;
|
||||
ctx.lineTo(x1 + ux * along + px * side, y1 + uy * along + py * side);
|
||||
}
|
||||
ctx.lineTo(x2 - ux * headLen, y2 - uy * headLen);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.lineTo(x2 - ux * headLen, y2 - uy * headLen);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.shadowBlur = 0;
|
||||
@@ -1877,6 +1968,60 @@ class ForceSandboxSim {
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
/* ── _pv_drawAllForces: unified FBD via LSPhysFX ───────────── */
|
||||
_pv_drawAllForces(ctx) {
|
||||
if (!window.LSPhysFX) return;
|
||||
const S = ForceSandboxSim.SCALE;
|
||||
for (const b of this.bodies) {
|
||||
const cx = b.x, cy = b.type === 'box' ? b.y : b.y;
|
||||
/* gravity */
|
||||
if (this.gravity) {
|
||||
const mg = b.mass * this.gVal;
|
||||
LSPhysFX.drawForceArrow(ctx, cx, cy, 0, Math.min(60, mg * 2.5), 'gravity',
|
||||
'mg=' + mg.toFixed(0) + 'Н');
|
||||
/* normal from floor */
|
||||
const bottom = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r;
|
||||
if (this.hasFloor && Math.abs(bottom - this._floorY) < 6 && Math.abs(b.vy) < 8) {
|
||||
LSPhysFX.drawForceArrow(ctx, cx, cy, 0, -Math.min(60, mg * 2.5), 'normal',
|
||||
'N=' + mg.toFixed(0) + 'Н');
|
||||
/* kinetic friction */
|
||||
if (Math.abs(b.vx) > 10) {
|
||||
const fFr = Math.max(b.mu, this.floorMu) * mg;
|
||||
LSPhysFX.drawForceArrow(ctx, cx, cy,
|
||||
-Math.sign(b.vx) * Math.min(50, fFr * 2), 0,
|
||||
'friction', 'Fтр=' + fFr.toFixed(0) + 'Н');
|
||||
}
|
||||
}
|
||||
}
|
||||
/* spring forces */
|
||||
for (const sp of this.springs) {
|
||||
const other = (sp.b1id === b.id) ? this.bodies.find(bb => bb.id === sp.b2id)
|
||||
: (sp.b2id === b.id ? this.bodies.find(bb => bb.id === sp.b1id) : null);
|
||||
if (!other) continue;
|
||||
const dx = other.x - b.x, dy = other.y - b.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const ext = dist - sp.L0;
|
||||
if (Math.abs(ext) < 0.5) continue;
|
||||
const fMag = sp.k * ext;
|
||||
const fLen = Math.min(50, Math.abs(fMag) / S * 3 + 10);
|
||||
LSPhysFX.drawForceArrow(ctx, cx, cy,
|
||||
(dx / dist) * fLen * Math.sign(ext),
|
||||
(dy / dist) * fLen * Math.sign(ext),
|
||||
'elastic', 'Fупр=' + Math.abs(fMag / S).toFixed(0) + 'Н');
|
||||
}
|
||||
/* applied forces */
|
||||
for (const f of b.forces) {
|
||||
const fMag = Math.hypot(f.fx, f.fy) / S;
|
||||
const fLen = Math.min(60, fMag * 2.5);
|
||||
if (fLen < 3) continue;
|
||||
const dir = Math.atan2(f.fy, f.fx);
|
||||
LSPhysFX.drawForceArrow(ctx, cx, cy,
|
||||
Math.cos(dir) * fLen, Math.sin(dir) * fLen,
|
||||
'applied', (f.label || 'F') + '=' + fMag.toFixed(0) + 'Н');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_drawEnergyBar(ctx) {
|
||||
if (!this.bodies.length) return;
|
||||
const S = ForceSandboxSim.SCALE, fY = this._floorY;
|
||||
@@ -2096,6 +2241,20 @@ class ForceSandboxSim {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Energy toggle: forcesandbox ── */
|
||||
function fsbToggleEnergy() {
|
||||
if (!sbSim) return;
|
||||
sbSim._energyOn = !sbSim._energyOn;
|
||||
const on = sbSim._energyOn;
|
||||
const btn = document.getElementById('fsb-energy-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', on);
|
||||
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
|
||||
}
|
||||
if (!on) sbSim._energyScaleFSB = 0;
|
||||
sbSim.draw();
|
||||
}
|
||||
|
||||
/* ── Utilities ───────────────────────────────────────────────── */
|
||||
|
||||
function _fsb_rrect(ctx, x, y, w, h, r) {
|
||||
@@ -2115,3 +2274,32 @@ function _fsb_lighten(hex, d) {
|
||||
const c = v => Math.max(0, Math.min(255, v));
|
||||
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
|
||||
}
|
||||
|
||||
|
||||
function sandboxToggleGraphs() {
|
||||
if (typeof sandboxSim === 'undefined' || !sandboxSim) return;
|
||||
if (!window.LSGraphPanelUI) return;
|
||||
sandboxSim._graphsOn = !sandboxSim._graphsOn;
|
||||
sandboxSim._gpPrevSpd = null;
|
||||
if (sandboxSim._graphsOn) {
|
||||
const canvasOuter = document.querySelector('#sim-dynamics .proj-canvas-outer');
|
||||
if (!canvasOuter) return;
|
||||
const labels = sandboxSim.bodies.map((b, i) => (b.type === 'ball' ? 'Шар' : 'Блок') + (i + 1));
|
||||
sandboxSim._graphUI = new GraphPanelUI(canvasOuter, {
|
||||
maxPoints: 400,
|
||||
traces: ['x', 'v', 'a'],
|
||||
labels: ['x', 'v', '|a|'],
|
||||
units: ['м', 'м/с', 'м/с²'],
|
||||
colors: ['#06D6E0', '#FFD166', '#EF476F'],
|
||||
toggleBtnId: 'btn-sandbox-graphs',
|
||||
title: 'Тело',
|
||||
bodySelector: labels.length ? labels : ['Тело 1'],
|
||||
});
|
||||
sandboxSim._graphUI.isOn = true;
|
||||
sandboxSim._graphUI._build();
|
||||
} else {
|
||||
if (sandboxSim._graphUI) { sandboxSim._graphUI._destroy(); sandboxSim._graphUI = null; }
|
||||
}
|
||||
const btn = document.getElementById('btn-sandbox-graphs');
|
||||
if (btn) btn.classList.toggle('active', sandboxSim._graphsOn);
|
||||
}
|
||||
@@ -65,6 +65,10 @@ class HydroSim {
|
||||
this._waterLevel = 0.60;
|
||||
this._bodyShape = 'rect';
|
||||
|
||||
/* -- GraphPanel widget -- */
|
||||
this._graphsOn = false;
|
||||
this._graphUI = null;
|
||||
|
||||
this._bindEvents();
|
||||
this._ro = new ResizeObserver(() => this.fit());
|
||||
this._ro.observe(canvas.parentElement || canvas);
|
||||
@@ -179,6 +183,16 @@ class HydroSim {
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
this._update(t);
|
||||
this._draw(t);
|
||||
if (window.LSGraphPanel && this._graphsOn && this._graphUI && this.mode === 'archimedes' && this._archReady) {
|
||||
const bodies = this._bodies;
|
||||
if (bodies && bodies.length > 0) {
|
||||
const b = bodies[0];
|
||||
const submersion = b ? Math.max(0, Math.min(1, b.submergedFrac || 0)) : 0;
|
||||
const depth = b ? (b.y || 0) : 0;
|
||||
const vel = b ? (b.vy || 0) : 0;
|
||||
this._graphUI.push(this._t / 1000, [depth, vel, submersion]);
|
||||
}
|
||||
}
|
||||
if (t - this._lastNotify > 120) { this._lastNotify = t; this._notify(); }
|
||||
}
|
||||
|
||||
@@ -1382,6 +1396,28 @@ class HydroSim {
|
||||
.map(([a,b]) => p(a,b,t).toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
_notify() { if (this.onUpdate) try { this.onUpdate(this.getInfo()); } catch {} }
|
||||
|
||||
toggleGraphs(canvasWrap) {
|
||||
if (!window.LSGraphPanelUI) return false;
|
||||
this._graphsOn = !this._graphsOn;
|
||||
if (this._graphsOn) {
|
||||
this._simT = 0;
|
||||
this._graphUI = new GraphPanelUI(canvasWrap, {
|
||||
maxPoints: 300,
|
||||
traces: ['depth', 'vy', 'sub'],
|
||||
labels: ['Глубина', 'v', 'Погружение'],
|
||||
units: ['пкс', 'пкс/с', '0..1'],
|
||||
colors: ['#06D6E0', '#FFD166', '#7BF5A4'],
|
||||
toggleBtnId: 'btn-hydro-graphs',
|
||||
title: 'Погружение'
|
||||
});
|
||||
this._graphUI.isOn = true;
|
||||
this._graphUI._build();
|
||||
} else {
|
||||
if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
|
||||
}
|
||||
return this._graphsOn;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── lab UI init ─────────────────────────────────── */
|
||||
@@ -1423,6 +1459,8 @@ class HydroSim {
|
||||
if (el) el.style.display = 'none';
|
||||
if (el2) el2.style.display = 'none';
|
||||
});
|
||||
const gpRow = document.getElementById('hydro-graphs-row');
|
||||
if (gpRow) gpRow.style.display = mode === 'archimedes' ? '' : 'none';
|
||||
if (mode === 'archimedes') {
|
||||
const a = document.getElementById('hydro-panel-mat');
|
||||
const b = document.getElementById('hydro-arch-ctrl');
|
||||
@@ -1467,6 +1505,15 @@ class HydroSim {
|
||||
});
|
||||
}
|
||||
|
||||
function hydroToggleGraphs() {
|
||||
if (!hydroSim || typeof hydroSim.toggleGraphs !== 'function') return;
|
||||
const canvasWrap = document.getElementById('hydro-canvas-wrap');
|
||||
if (!canvasWrap) return;
|
||||
const on = hydroSim.toggleGraphs(canvasWrap);
|
||||
const btn = document.getElementById('btn-hydro-graphs');
|
||||
if (btn) btn.classList.toggle('active', on);
|
||||
}
|
||||
|
||||
function hydroSetVessels(n, btn) {
|
||||
if (hydroSim) hydroSim.setNumVessels(n);
|
||||
document.querySelectorAll('.hydro-nv').forEach(b => b.classList.remove('active'));
|
||||
|
||||
+263
-2
@@ -59,6 +59,24 @@ class NewtonSim {
|
||||
this.onUpdate = null;
|
||||
this.onModeChange = null;
|
||||
|
||||
/* FBD toggle */
|
||||
this._fbdOn = false;
|
||||
|
||||
/* ── Energy bars widget ── */
|
||||
this._energyOn = false;
|
||||
this._frictionWork = 0; // cumulative friction heat (J)
|
||||
this._appliedWork = 0; // work done by applied force (J)
|
||||
this._energyScale = 0;
|
||||
|
||||
/* -- GraphPanel widget -- */
|
||||
this._graphsOn = false;
|
||||
this._graphUI = null;
|
||||
this._tSim = 0;
|
||||
|
||||
/* ── TimeControl ── */
|
||||
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
|
||||
this._tcScale = 1; // separate from tc.scale so we can multiply with existing dt
|
||||
|
||||
this.fit();
|
||||
this._bindEvents();
|
||||
}
|
||||
@@ -248,9 +266,17 @@ class NewtonSim {
|
||||
/* ── Тик ──────────────────────────────────────────────────── */
|
||||
|
||||
_tick(now) {
|
||||
const dt = Math.min((now - this._last) / 1000, 0.05);
|
||||
const rawDt = Math.min((now - this._last) / 1000, 0.05);
|
||||
this._last = now;
|
||||
if (window.LabFX) LabFX.particles.update(dt);
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
|
||||
/* TimeControl: pause / speed scale */
|
||||
let dt = rawDt;
|
||||
if (this._tc) {
|
||||
dt = this._tc.advance(rawDt);
|
||||
if (dt === 0) { this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; }
|
||||
}
|
||||
|
||||
if (!this._paused) {
|
||||
if (this.law === 1 && this.scene === 'A') this._step1A(dt);
|
||||
else if (this.law === 1) this._step1B(dt);
|
||||
@@ -262,8 +288,12 @@ class NewtonSim {
|
||||
else if (this.scene === 'B') this._step3B(dt);
|
||||
else this._step3C(dt);
|
||||
}
|
||||
this._tSim += dt;
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
if (window.LSGraphPanel && this._graphsOn && this._graphUI && !this._paused) {
|
||||
this._graphUI.push(this._tSim, this._newtonGraphValues());
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Физика I-A : блок с трением ────────────────────────── */
|
||||
@@ -553,10 +583,79 @@ class NewtonSim {
|
||||
else if (this.scene === 'B') this._drawL3B(ctx);
|
||||
else this._drawL3C(ctx);
|
||||
|
||||
/* ── Energy bars overlay ── */
|
||||
if (this._energyOn && window.LSPhysFX) {
|
||||
this._drawEnergyBarsNwt(ctx);
|
||||
}
|
||||
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── Energy bars: newton ── */
|
||||
|
||||
_drawEnergyBarsNwt(ctx) {
|
||||
var en = this._calcEnergiesNwt();
|
||||
if (!en) return;
|
||||
var tot = en.ke + en.pe + en.friction;
|
||||
if (tot > this._energyScale) this._energyScale = tot;
|
||||
var PW = 188, MARGIN = 12;
|
||||
LSPhysFX.drawEnergyBars(ctx, this.W - PW - MARGIN, MARGIN, PW, 0,
|
||||
{ ke: en.ke, pe: en.pe, friction: en.friction, total: this._energyScale }, {});
|
||||
}
|
||||
|
||||
_calcEnergiesNwt() {
|
||||
var ke = 0, pe = 0, fr = this._frictionWork;
|
||||
var S = NewtonSim.SCALE, G = NewtonSim.G;
|
||||
/* law 1A — sliding block */
|
||||
if (this.law === 1 && this.scene === 'A') {
|
||||
var b = this._1A;
|
||||
var spd = Math.hypot(b.bvx || 0, b.bvy || 0) / S;
|
||||
ke = 0.5 * this.mass1 * spd * spd;
|
||||
} else if (this.law === 1) {
|
||||
/* law 1B — projectile */
|
||||
var b2 = this._1B;
|
||||
if (b2) {
|
||||
var spd2 = Math.hypot(b2.vx || 0, b2.vy || 0) / S;
|
||||
var h2 = Math.max(0, (this.H * 0.6 - (b2.y || 0))) / S;
|
||||
ke = 0.5 * this.mass1 * spd2 * spd2;
|
||||
pe = this.mass1 * G * h2;
|
||||
}
|
||||
} else if (this.law === 4 && this.scene === 'atwood') {
|
||||
var atw = this._atw;
|
||||
var v = Math.abs(atw.vy || 0) / S;
|
||||
ke = 0.5 * (this.atwM1 + this.atwM2) * v * v;
|
||||
/* PE from starting positions */
|
||||
var dy1 = ((atw.y1 || 0) - (this.H * 0.3)) / S;
|
||||
var dy2 = ((atw.y2 || 0) - (this.H * 0.3)) / S;
|
||||
pe = Math.max(0, this.atwM1 * G * (-dy1) + this.atwM2 * G * (-dy2));
|
||||
} else if (this.law === 4 && this.scene === 'ramp') {
|
||||
var rmp = this._ramp;
|
||||
ke = 0.5 * this.mass1 * (rmp.bv || 0) * (rmp.bv || 0);
|
||||
var h = (rmp.bx || 0) * Math.sin(rmp.alpha || 0);
|
||||
pe = Math.max(0, this.mass1 * G * h);
|
||||
} else if (this.law === 4 && this.scene === 'roll') {
|
||||
/* rolling: use ball as representative */
|
||||
var roll = this._roll;
|
||||
var vb = roll.vBall || 0;
|
||||
/* KE includes rotational: for solid ball k=2/5, cyl=1/2, hoop=1 */
|
||||
ke = 0.5 * this.mass1 * vb * vb * (1 + 0.4) / 1; /* solid ball */
|
||||
var hRoll = Math.max(0, (roll.L || 3) * Math.sin(roll.alpha || 0) - (roll.sBall || 0) * Math.sin(roll.alpha || 0));
|
||||
pe = this.mass1 * G * hRoll;
|
||||
} else if (this.law === 2) {
|
||||
var b2s = this._2;
|
||||
var spd3 = Math.abs(b2s.v || 0);
|
||||
ke = 0.5 * this.mass1 * spd3 * spd3;
|
||||
} else if (this.law === 3) {
|
||||
var b3 = this.scene === 'A' ? this._3A : (this.scene === 'B' ? this._3B : this._3C);
|
||||
if (b3) {
|
||||
var v3a = Math.abs(b3.v1 || 0), v3b = Math.abs(b3.v2 || 0);
|
||||
ke = 0.5 * this.mass1 * v3a * v3a + 0.5 * this.mass2 * v3b * v3b;
|
||||
}
|
||||
}
|
||||
return { ke: Math.max(0, ke), pe: Math.max(0, pe), friction: Math.max(0, fr) };
|
||||
}
|
||||
|
||||
/* ── Закон I — Сцена A ───────────────────────────────────── */
|
||||
|
||||
_drawL1A(ctx) {
|
||||
@@ -624,6 +723,16 @@ class NewtonSim {
|
||||
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.75)';
|
||||
ctx.fillText(`μ = ${this.mu.toFixed(2)} F тр = μ · m · g`, 18, 26);
|
||||
|
||||
/* FBD: mg, N for Law I scene A */
|
||||
if (this._fbdOn && window.LSPhysFX) {
|
||||
const by2 = b.by - b.BH / 2;
|
||||
const mg2 = this.mass1 * NewtonSim.G;
|
||||
LSPhysFX.drawForceArrow(ctx, b.bx, by2, 0, 50, 'gravity', 'mg=' + mg2.toFixed(0) + 'Н');
|
||||
if (!b.inAir) {
|
||||
LSPhysFX.drawForceArrow(ctx, b.bx, by2, 0, -50, 'normal', 'N=' + mg2.toFixed(0) + 'Н');
|
||||
}
|
||||
}
|
||||
|
||||
this._caption(ctx, 'Тело продолжает движение\nпока не подействует сила', W, H);
|
||||
}
|
||||
|
||||
@@ -809,6 +918,21 @@ class NewtonSim {
|
||||
}
|
||||
}
|
||||
|
||||
/* FBD: F_applied, mg, N, F_friction for Law II scene A */
|
||||
if (this._fbdOn && window.LSPhysFX && this.scene === 'A') {
|
||||
const { b1x: bx2 } = this._2;
|
||||
const BH2 = 48;
|
||||
const by2 = g.gY - BH2 / 2;
|
||||
const mg2 = this.mass1 * NewtonSim.G;
|
||||
const fFr2 = this.mu ? this.mu * mg2 : 0;
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, 44, 'gravity', 'mg=' + mg2.toFixed(0) + 'Н');
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, -44, 'normal', 'N=' + mg2.toFixed(0) + 'Н');
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2, Math.min(55, this.force * 1.5), 0, 'applied', 'F=' + this.force + 'Н');
|
||||
if (fFr2 > 0.5) {
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2, -Math.min(40, fFr2 * 1.5), 0, 'friction', 'Fтр=' + fFr2.toFixed(0) + 'Н');
|
||||
}
|
||||
}
|
||||
|
||||
this._caption(ctx, 'F = m · a', W, H);
|
||||
}
|
||||
|
||||
@@ -920,6 +1044,19 @@ class NewtonSim {
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
/* FBD: action/reaction via LSPhysFX for Law III scene A */
|
||||
if (this._fbdOn && window.LSPhysFX && s.fired && s.ball) {
|
||||
const ny3 = g.gY - CW - 50;
|
||||
const S3 = NewtonSim.SCALE;
|
||||
const fMag = Math.min(70, this.mass1 * 6);
|
||||
/* force on ball → right */
|
||||
LSPhysFX.drawForceArrow(ctx, s.cx + CW / 2 + 12, ny3,
|
||||
fMag, 0, 'applied', 'F→ядро');
|
||||
/* reaction on cannon → left */
|
||||
LSPhysFX.drawForceArrow(ctx, s.cx - CW / 2 - 12, ny3,
|
||||
-fMag, 0, 'impulse', 'F→пушка');
|
||||
}
|
||||
|
||||
this._caption(ctx, 'Действие = Противодействие\nF₁ = −F₂', W, H);
|
||||
}
|
||||
|
||||
@@ -1165,6 +1302,33 @@ class NewtonSim {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
_newtonGraphValues() {
|
||||
const S = NewtonSim.SCALE;
|
||||
if (this.law === 1 && this.scene === 'A') {
|
||||
const b = this._1A;
|
||||
const x = b.bx ? b.bx / S : 0;
|
||||
const v = Math.hypot(b.bvx || 0, b.bvy || 0) / S;
|
||||
const a = this._paused ? 0 : (this.mu * NewtonSim.G);
|
||||
return [x, v, -a];
|
||||
}
|
||||
if (this.law === 2) {
|
||||
const b = this._2;
|
||||
const x = (b.b1x || 0) / S;
|
||||
const v = (b.b1vx || 0) / S;
|
||||
const a = b.running ? (this.force / this.mass1) : 0;
|
||||
return [x, v, a];
|
||||
}
|
||||
if (this.law === 4 && this.scene === 'atwood' && this._atw) {
|
||||
const atw = this._atw;
|
||||
const x = atw.y1 ? atw.y1 / S : 0;
|
||||
const v = (atw.vy || 0) / S;
|
||||
const a = atw.aPhys || 0;
|
||||
return [x, v, a];
|
||||
}
|
||||
// default: use 1A block or zero
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
_fma(ctx, F, m, a, cx, y) {
|
||||
ctx.save();
|
||||
ctx.font = 'bold 15px monospace';
|
||||
@@ -1946,6 +2110,35 @@ function _nwt_lighten(hex, d) {
|
||||
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
|
||||
}
|
||||
|
||||
/* ─── GraphPanel helpers ─────────────────────────── */
|
||||
|
||||
function newtonToggleGraphs() {
|
||||
const sim = typeof newtonSim !== 'undefined' ? newtonSim : null;
|
||||
if (!sim || !window.LSGraphPanelUI) return;
|
||||
sim._graphsOn = !sim._graphsOn;
|
||||
if (sim._graphsOn) {
|
||||
sim._tSim = 0;
|
||||
const canvasOuter = document.querySelector('#sim-dynamics .proj-canvas-outer');
|
||||
if (!canvasOuter) return;
|
||||
const S = NewtonSim.SCALE;
|
||||
sim._graphUI = new GraphPanelUI(canvasOuter, {
|
||||
maxPoints: 400,
|
||||
traces: ['x', 'v', 'a'],
|
||||
labels: ['x', 'v', 'a'],
|
||||
units: ['м', 'м/с', 'м/с²'],
|
||||
colors: ['#06D6E0', '#FFD166', '#EF476F'],
|
||||
toggleBtnId: 'btn-newton-graphs',
|
||||
title: 'Графики x/v/a'
|
||||
});
|
||||
sim._graphUI.isOn = true;
|
||||
sim._graphUI._build();
|
||||
} else {
|
||||
if (sim._graphUI) { sim._graphUI._destroy(); sim._graphUI = null; }
|
||||
}
|
||||
const btn = document.getElementById('btn-newton-graphs');
|
||||
if (btn) btn.classList.toggle('active', sim._graphsOn);
|
||||
}
|
||||
|
||||
/* ─── lab UI init ─────────────────────────────────── */
|
||||
var newtonSim = null;
|
||||
var sandboxSim = null;
|
||||
@@ -1967,6 +2160,7 @@ function _nwt_lighten(hex, d) {
|
||||
if (!newtonSim) {
|
||||
newtonSim = new NewtonSim(nwCanvas);
|
||||
newtonSim.onUpdate = _newtonUpdateUI;
|
||||
_dynInjectTCBar();
|
||||
}
|
||||
// activate current mode
|
||||
dynMode(_dynMode);
|
||||
@@ -1974,6 +2168,58 @@ function _nwt_lighten(hex, d) {
|
||||
}));
|
||||
}
|
||||
|
||||
function _dynInjectTCBar() {
|
||||
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
|
||||
var wrap = document.getElementById('sim-dynamics');
|
||||
if (!wrap || wrap.querySelector('.tc-bar')) return;
|
||||
|
||||
/* proxy TC that routes to whichever sim is active */
|
||||
var proxyTC = {
|
||||
paused: false,
|
||||
scale: 1,
|
||||
advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
|
||||
setScale: function(s) {
|
||||
this.scale = +s;
|
||||
if (newtonSim && newtonSim._tc) newtonSim._tc.setScale(+s);
|
||||
if (sandboxSim && sandboxSim._tc) sandboxSim._tc.setScale(+s);
|
||||
},
|
||||
togglePause: function() {
|
||||
this.paused = !this.paused;
|
||||
if (newtonSim && newtonSim._tc) newtonSim._tc.paused = this.paused;
|
||||
if (sandboxSim && sandboxSim._tc) sandboxSim._tc.paused = this.paused;
|
||||
return this.paused;
|
||||
},
|
||||
};
|
||||
|
||||
var simProxy = { draw: function() {
|
||||
if (_dynMode === 'sandbox' && sandboxSim) sandboxSim.draw();
|
||||
else if (newtonSim) newtonSim.draw();
|
||||
}};
|
||||
var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });
|
||||
|
||||
/* Trails toggle (sandbox only — newton doesn't have generic bodies) */
|
||||
var sep = document.createElement('div');
|
||||
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
|
||||
var trailBtn = document.createElement('button');
|
||||
trailBtn.className = 'zoom-btn';
|
||||
trailBtn.style.cssText = 'display:inline-flex;align-items: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';
|
||||
trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
|
||||
trailBtn.title = 'Следы движения (Sandbox)';
|
||||
trailBtn.addEventListener('click', function() {
|
||||
var on = sandboxSim ? !sandboxSim.showTrail : false;
|
||||
if (sandboxSim) sandboxSim.showTrail = on;
|
||||
trailBtn.style.background = on ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
|
||||
trailBtn.style.color = on ? '#06D6E0' : '#ccc';
|
||||
trailBtn.style.borderColor = on ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
|
||||
});
|
||||
tcBar.appendChild(sep);
|
||||
tcBar.appendChild(trailBtn);
|
||||
|
||||
var statsBar = wrap.querySelector('.proj-stats-bar');
|
||||
if (statsBar) wrap.insertBefore(tcBar, statsBar);
|
||||
else wrap.appendChild(tcBar);
|
||||
}
|
||||
|
||||
function dynMode(mode, btn) {
|
||||
_dynMode = mode;
|
||||
const isSandbox = mode === 'sandbox';
|
||||
@@ -2579,5 +2825,20 @@ function _nwt_lighten(hex, d) {
|
||||
document.getElementById('dbar-v5').textContent = info.time + ' с';
|
||||
}
|
||||
|
||||
/* ── Energy toggle: newton ── */
|
||||
function dynToggleEnergy() {
|
||||
if (nSim) {
|
||||
nSim._energyOn = !nSim._energyOn;
|
||||
const on = nSim._energyOn;
|
||||
const btn = document.getElementById('nwt-energy-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', on);
|
||||
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
|
||||
}
|
||||
if (!on) { nSim._frictionWork = 0; nSim._energyScale = 0; }
|
||||
nSim.draw();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── chem sandbox ── */
|
||||
|
||||
|
||||
@@ -123,6 +123,28 @@ class PendulumSim {
|
||||
|
||||
this.onUpdate = null;
|
||||
this._drag = null;
|
||||
|
||||
/* FBD toggle */
|
||||
this._fbdOn = false;
|
||||
|
||||
/* ── Energy bars widget ── */
|
||||
this._energyOn = false;
|
||||
this._frictionWork = 0; // cumulative damping loss (J)
|
||||
this._energyScale = 0;
|
||||
|
||||
/* ── GraphPanel widget ── */
|
||||
this._graphsOn = false;
|
||||
this._graphUI = null;
|
||||
|
||||
/* ── TimeControl + MotionTrails ── */
|
||||
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
|
||||
this._tcTrails = {
|
||||
math: window.LSMotionTrail ? new window.LSMotionTrail({ color: '#9B5DE5', width: 3, maxLen: 150 }) : null,
|
||||
double1: window.LSMotionTrail ? new window.LSMotionTrail({ color: '#06D6E0', width: 2.5, maxLen: 200 }) : null,
|
||||
double2: window.LSMotionTrail ? new window.LSMotionTrail({ color: '#F15BB5', width: 2.5, maxLen: 200 }) : null,
|
||||
};
|
||||
this.showTCTrails = false;
|
||||
|
||||
this._bindEvents();
|
||||
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
|
||||
}
|
||||
@@ -205,6 +227,11 @@ class PendulumSim {
|
||||
this.rs.theta = 0.1; this.rs.omega = 0; this.rs.tSim = 0;
|
||||
break;
|
||||
}
|
||||
this._frictionWork = 0;
|
||||
this._energyScale = 0;
|
||||
if (this._tc) this._tc.reset();
|
||||
/* clear motion trails */
|
||||
for (const t of Object.values(this._tcTrails)) { if (t) t.clear(); }
|
||||
if (window.LabFX) LabFX.sound.play('click');
|
||||
this.draw();
|
||||
this._emit();
|
||||
@@ -293,6 +320,61 @@ class PendulumSim {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Graph panel helpers ───────────────────── */
|
||||
|
||||
_pendGraphValues() {
|
||||
switch (this.mode) {
|
||||
case 'math':
|
||||
case 'physical':
|
||||
case 'resonance': {
|
||||
const th = (this.mode === 'physical') ? this.ph.theta :
|
||||
(this.mode === 'resonance') ? this.rs.theta : this.theta;
|
||||
const om = (this.mode === 'physical') ? this.ph.omega :
|
||||
(this.mode === 'resonance') ? this.rs.omega : this.omega;
|
||||
const L2 = (this.L || 200) * (this.L || 200);
|
||||
const KE = 0.5 * om * om * L2;
|
||||
const PE = (this.g || 9.81) * 100 * (this.L || 200) * (1 - Math.cos(th));
|
||||
return [th, om, KE + PE];
|
||||
}
|
||||
case 'double': return [this.d.th1, this.d.th2, this.d.om1];
|
||||
case 'coupled': return [this.cp.th1, this.cp.th2, this.cp.om1 - this.cp.om2];
|
||||
case 'spring': return [this.sp.x, this.sp.v, 0.5 * (this.sp.k || 8) * this.sp.x * this.sp.x];
|
||||
case 'foucault': return [this.fc.x, this.fc.y, Math.hypot(this.fc.vx || 0, this.fc.vy || 0)];
|
||||
default: return [0, 0, 0];
|
||||
}
|
||||
}
|
||||
|
||||
_pendGraphOpts() {
|
||||
const BASE = { maxPoints: 400, colors: ['#06D6E0', '#FFD166', '#EF476F'], toggleBtnId: 'btn-pend-graphs', title: 'Графики' };
|
||||
switch (this.mode) {
|
||||
case 'math': case 'physical': case 'resonance':
|
||||
return Object.assign({}, BASE, { traces: ['th', 'om', 'E'], labels: ['θ', 'ω', 'E'], units: ['рад', 'рад/с', 'Дж'] });
|
||||
case 'double':
|
||||
return Object.assign({}, BASE, { traces: ['th1', 'th2', 'om1'], labels: ['θ1', 'θ2', 'ω1'], units: ['рад', 'рад', 'рад/с'] });
|
||||
case 'coupled':
|
||||
return Object.assign({}, BASE, { traces: ['th1', 'th2', 'dom'], labels: ['θ1', 'θ2', 'Δω'], units: ['рад', 'рад', 'рад/с'] });
|
||||
case 'spring':
|
||||
return Object.assign({}, BASE, { traces: ['x', 'v', 'E'], labels: ['x', 'v', 'E'], units: ['м', 'м/с', 'Дж'] });
|
||||
case 'foucault':
|
||||
return Object.assign({}, BASE, { traces: ['x', 'y', 'v'], labels: ['x', 'y', '|v|'], units: ['м', 'м', 'м/с'] });
|
||||
default:
|
||||
return Object.assign({}, BASE, { traces: ['th', 'om', 'E'], labels: ['θ', 'ω', 'E'], units: ['', '', ''] });
|
||||
}
|
||||
}
|
||||
|
||||
toggleGraphs(canvasOuter) {
|
||||
if (!window.LSGraphPanelUI) return false;
|
||||
this._graphsOn = !this._graphsOn;
|
||||
if (this._graphsOn) {
|
||||
this._graphUI = new GraphPanelUI(canvasOuter, this._pendGraphOpts());
|
||||
this._graphUI.isOn = true;
|
||||
this._graphUI._build();
|
||||
} else {
|
||||
if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
|
||||
}
|
||||
return this._graphsOn;
|
||||
}
|
||||
|
||||
/* ── internals ─────────────────────────────── */
|
||||
|
||||
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
||||
@@ -301,19 +383,56 @@ class PendulumSim {
|
||||
_clearPhase() { this._phaseTrail = []; }
|
||||
_clearAll() { this._clearTrail(); this._clearPhase(); }
|
||||
|
||||
/* ── getState / applyState for math mode (scrub support) ── */
|
||||
getState() {
|
||||
if (this.mode !== 'math') return null;
|
||||
return { theta: this.theta, omega: this.omega, tSim: this._tSim };
|
||||
}
|
||||
|
||||
applyState(st) {
|
||||
if (!st || this.mode !== 'math') return;
|
||||
this.theta = st.theta;
|
||||
this.omega = st.omega;
|
||||
this._tSim = st.tSim || 0;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
_tick() {
|
||||
if (!this.playing) return;
|
||||
this._raf = requestAnimationFrame(ts => {
|
||||
if (this._lastTs === null) this._lastTs = ts;
|
||||
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||||
this._lastTs = ts;
|
||||
const dt = rawDt * this.speed;
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
|
||||
/* TimeControl: scale speed, handle pause */
|
||||
let dt;
|
||||
if (this._tc) {
|
||||
dt = this._tc.advance(rawDt * this.speed);
|
||||
if (dt === 0) { this.draw(); this._tick(); return; }
|
||||
/* record state for math mode scrubbing */
|
||||
if (this.mode === 'math') {
|
||||
this._tc.record(this.getState());
|
||||
}
|
||||
} else {
|
||||
dt = rawDt * this.speed;
|
||||
}
|
||||
|
||||
/* tick motion trails */
|
||||
if (this.showTCTrails) {
|
||||
const tr = this._tcTrails;
|
||||
if (tr.math) tr.math.tick();
|
||||
if (tr.double1) tr.double1.tick();
|
||||
if (tr.double2) tr.double2.tick();
|
||||
}
|
||||
|
||||
this._stepMode(dt);
|
||||
this.draw();
|
||||
this._emit();
|
||||
if (window.LSGraphPanel && this._graphsOn && this._graphUI) {
|
||||
this._graphUI.push(this._tSim, this._pendGraphValues());
|
||||
}
|
||||
this._tick();
|
||||
});
|
||||
}
|
||||
@@ -358,12 +477,19 @@ class PendulumSim {
|
||||
const { bx, by } = this._bobPos();
|
||||
this._trail.push({ x: bx, y: by });
|
||||
if (this._trail.length > this._maxTrail) this._trail.shift();
|
||||
if (this.showTCTrails && this._tcTrails.math) this._tcTrails.math.push(bx, by);
|
||||
|
||||
const KE = 0.5 * this.omega * this.omega * this.L * this.L;
|
||||
const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta));
|
||||
this._eHistory.push({ t: this._tSim, ke: KE, pe: PE });
|
||||
if (this._eHistory.length > 300) this._eHistory.shift();
|
||||
|
||||
/* accumulate damping loss for energy bars */
|
||||
if (this._energyOn && this.damping > 0) {
|
||||
/* power dissipated = c * omega² (normalised) */
|
||||
this._frictionWork += this.damping * this.omega * this.omega * dt * 0.5 * this.L * this.L;
|
||||
}
|
||||
|
||||
if (this.showPhase) {
|
||||
this._phaseTrail.push({ x: this.theta, y: this.omega });
|
||||
if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift();
|
||||
@@ -385,6 +511,12 @@ class PendulumSim {
|
||||
const { bx, by } = this._doubleBobPos();
|
||||
this.d.trail.push({ x: bx, y: by });
|
||||
if (this.d.trail.length > this.d.maxTrail) this.d.trail.shift();
|
||||
if (this.showTCTrails) {
|
||||
/* push both bobs to motion trails */
|
||||
const { bx: b1x, by: b1y } = this._doubleBobPos();
|
||||
if (this._tcTrails.double1) this._tcTrails.double1.push(b1x, b1y);
|
||||
if (this._tcTrails.double2) this._tcTrails.double2.push(bx, by);
|
||||
}
|
||||
|
||||
if (this.d.showGhost) {
|
||||
const { bx: gx, by: gy } = this._doubleBobPos(true);
|
||||
@@ -688,15 +820,86 @@ class PendulumSim {
|
||||
this._drawPhasePortrait(ctx, mainW, 0, W - mainW, H);
|
||||
}
|
||||
|
||||
/* ── Energy bars overlay ── */
|
||||
if (this._energyOn && window.LSPhysFX) {
|
||||
this._drawEnergyBarsPend(ctx, mainW, H);
|
||||
}
|
||||
|
||||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||||
}
|
||||
|
||||
/* ── Energy bars: pendulum ── */
|
||||
|
||||
_drawEnergyBarsPend(ctx, W, H) {
|
||||
var en = this._calcEnergiesPend();
|
||||
if (!en) return;
|
||||
var tot = en.ke + en.pe + en.elastic + en.friction;
|
||||
if (tot > this._energyScale) this._energyScale = tot;
|
||||
var PW = 188, MARGIN = 12;
|
||||
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
|
||||
{ ke: en.ke, pe: en.pe, elastic: en.elastic, friction: en.friction,
|
||||
total: this._energyScale }, {});
|
||||
}
|
||||
|
||||
_calcEnergiesPend() {
|
||||
var ke = 0, pe = 0, el = 0, fr = this._frictionWork;
|
||||
var m = 1; // normalised mass
|
||||
switch (this.mode) {
|
||||
case 'math': {
|
||||
var L = this.L / 100; // px -> m (1px=1cm)
|
||||
ke = 0.5 * (this.omega * this.omega * L * L);
|
||||
pe = this.g * L * (1 - Math.cos(this.theta));
|
||||
break;
|
||||
}
|
||||
case 'spring': {
|
||||
var sm = this.sp.m || 1;
|
||||
var sk = this.sp.k || 20;
|
||||
ke = 0.5 * sm * this.sp.v * this.sp.v;
|
||||
el = 0.5 * sk * this.sp.x * this.sp.x;
|
||||
break;
|
||||
}
|
||||
case 'double': {
|
||||
var d = this.d;
|
||||
var L1 = d.L1 / 100, L2 = d.L2 / 100;
|
||||
var m1 = d.m1, m2 = d.m2;
|
||||
/* KE of both bobs (approx: treat as point masses) */
|
||||
var v1sq = L1 * L1 * d.om1 * d.om1;
|
||||
/* bob2 velocity via compound motion */
|
||||
var vx2 = L1 * d.om1 * Math.cos(d.th1) + L2 * d.om2 * Math.cos(d.th2);
|
||||
var vy2 = L1 * d.om1 * Math.sin(d.th1) + L2 * d.om2 * Math.sin(d.th2);
|
||||
ke = 0.5 * m1 * v1sq + 0.5 * m2 * (vx2 * vx2 + vy2 * vy2);
|
||||
pe = m1 * this.g * L1 * (1 - Math.cos(d.th1)) +
|
||||
m2 * this.g * (L1 * (1 - Math.cos(d.th1)) + L2 * (1 - Math.cos(d.th2)));
|
||||
break;
|
||||
}
|
||||
case 'physical': {
|
||||
var ph = this.ph;
|
||||
var Lp = ph.L / 100;
|
||||
/* moment of inertia about pivot depends on shape */
|
||||
var I;
|
||||
if (ph.shape === 'rod') I = (1/3) * Lp * Lp; // rod: I = 1/3 mL²
|
||||
else if (ph.shape === 'hoop') I = 2 * (Lp/2) * (Lp/2); // hoop: I = 2*m*R² (R=L/2)
|
||||
else I = (1/2) * (Lp/2) * (Lp/2); // disk: I = 1/2 mR²
|
||||
ke = 0.5 * I * ph.omega * ph.omega;
|
||||
var Lcom = Lp / 2;
|
||||
pe = ph.g * Lcom * (1 - Math.cos(ph.theta));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return { ke: Math.max(0, ke), pe: Math.max(0, pe),
|
||||
elastic: Math.max(0, el), friction: Math.max(0, fr) };
|
||||
}
|
||||
|
||||
/* ── draw: math ──────────────────────────────── */
|
||||
|
||||
_drawMath(ctx, W, H) {
|
||||
const { px, py, bx, by } = this._bobPos();
|
||||
|
||||
this._drawTrailPts(ctx, this._trail, '#9B5DE5');
|
||||
/* MotionTrail overlay */
|
||||
if (this.showTCTrails && this._tcTrails.math) this._tcTrails.math.draw(ctx);
|
||||
|
||||
// support
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
@@ -715,6 +918,23 @@ class PendulumSim {
|
||||
this._drawAngleArc(ctx, px, py, this.theta);
|
||||
this._drawEnergyBar(ctx, W, H);
|
||||
this._drawEnergyChart(ctx, W, H);
|
||||
|
||||
/* FBD overlay for math pendulum */
|
||||
if (this._fbdOn && window.LSPhysFX) {
|
||||
const L = this.L * (H * 0.42);
|
||||
const mg = this.m * this.g;
|
||||
/* gravity — downward */
|
||||
LSPhysFX.drawForceArrow(ctx, bx, by, 0, 50, 'gravity',
|
||||
'mg=' + mg.toFixed(1) + 'Н');
|
||||
/* tension — along rod toward pivot */
|
||||
const T_mag = mg * Math.cos(this.theta) + this.m * L * this.thetaDot * this.thetaDot;
|
||||
const rodDx = px - bx, rodDy = py - by;
|
||||
const rodLen = Math.sqrt(rodDx * rodDx + rodDy * rodDy) || 1;
|
||||
const tLen = Math.min(55, Math.max(20, T_mag * 2.5));
|
||||
LSPhysFX.drawForceArrow(ctx, bx, by,
|
||||
(rodDx / rodLen) * tLen, (rodDy / rodLen) * tLen,
|
||||
'tension', 'T=' + T_mag.toFixed(1) + 'Н');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── draw: double ────────────────────────────── */
|
||||
@@ -727,6 +947,11 @@ class PendulumSim {
|
||||
|
||||
// main trail
|
||||
this._drawTrailPts(ctx, this.d.trail, '#FFD166');
|
||||
/* MotionTrail overlay for both bobs */
|
||||
if (this.showTCTrails) {
|
||||
if (this._tcTrails.double1) this._tcTrails.double1.draw(ctx);
|
||||
if (this._tcTrails.double2) this._tcTrails.double2.draw(ctx);
|
||||
}
|
||||
|
||||
const { px, py, mx, my, bx, by } = this._doubleBobPos(false);
|
||||
|
||||
@@ -893,6 +1118,33 @@ class PendulumSim {
|
||||
if (!isVert) {
|
||||
ctx.fillText('(T не зависит от g)', 12, 28);
|
||||
}
|
||||
|
||||
/* FBD overlay for spring pendulum */
|
||||
if (this._fbdOn && window.LSPhysFX) {
|
||||
if (isVert) {
|
||||
const ancX2 = W / 2;
|
||||
const ancY2 = H * 0.15;
|
||||
const eqY2 = ancY2 + sp.restLen * 300;
|
||||
const bobY2 = eqY2 + sp.x * 300;
|
||||
const mg2 = sp.m * this.g;
|
||||
const Fsp2 = -sp.k * sp.x;
|
||||
/* gravity down */
|
||||
LSPhysFX.drawForceArrow(ctx, ancX2, bobY2, 0, 48, 'gravity',
|
||||
'mg=' + mg2.toFixed(1) + 'Н');
|
||||
/* spring force */
|
||||
LSPhysFX.drawForceArrow(ctx, ancX2, bobY2, 0, -Math.sign(sp.x) * Math.min(55, Math.abs(Fsp2) * 2.5 + 10),
|
||||
'elastic', 'F_упр=' + Math.abs(Fsp2).toFixed(1) + 'Н');
|
||||
} else {
|
||||
const ancX3 = W * 0.25;
|
||||
const baseY3 = H * 0.5;
|
||||
const eqX3 = ancX3 + sp.restLen * 300;
|
||||
const bobX3 = eqX3 + sp.x * 300;
|
||||
const Fsp3 = -sp.k * sp.x;
|
||||
/* spring force horizontal */
|
||||
LSPhysFX.drawForceArrow(ctx, bobX3, baseY3, -Math.sign(sp.x) * Math.min(55, Math.abs(Fsp3) * 2.5 + 10), 0,
|
||||
'elastic', 'F_упр=' + Math.abs(Fsp3).toFixed(1) + 'Н');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_drawSpringVert(ctx, W, H) {
|
||||
@@ -907,8 +1159,12 @@ class PendulumSim {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||||
ctx.fillRect(anchorX - 30, anchorY - 4, 60, 4);
|
||||
|
||||
// spring
|
||||
this._drawSpringCoils(ctx, anchorX, anchorY, anchorX, springEndY, 10, 8, '#FFD166');
|
||||
// spring — unified via LSPhysFX.drawSpring when available
|
||||
if (window.LSPhysFX) {
|
||||
LSPhysFX.drawSpring(ctx, anchorX, anchorY, anchorX, springEndY, { coils: 10, amp: 8, color: '#FFD166' });
|
||||
} else {
|
||||
this._drawSpringCoils(ctx, anchorX, anchorY, anchorX, springEndY, 10, 8, '#FFD166');
|
||||
}
|
||||
|
||||
// equilibrium dashed line
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
@@ -935,8 +1191,12 @@ class PendulumSim {
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(anchorX, baseY + 22); ctx.lineTo(W * 0.85, baseY + 22); ctx.stroke();
|
||||
|
||||
// spring
|
||||
this._drawSpringCoils(ctx, anchorX, baseY, bobX - 20, baseY, 10, 8, '#FFD166');
|
||||
// spring — unified via LSPhysFX.drawSpring when available
|
||||
if (window.LSPhysFX) {
|
||||
LSPhysFX.drawSpring(ctx, anchorX, baseY, bobX - 20, baseY, { coils: 10, amp: 8, color: '#FFD166' });
|
||||
} else {
|
||||
this._drawSpringCoils(ctx, anchorX, baseY, bobX - 20, baseY, 10, 8, '#FFD166');
|
||||
}
|
||||
|
||||
// equilibrium dashed
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
@@ -1495,6 +1755,7 @@ function _openPendulum() {
|
||||
if (!pendSim) {
|
||||
pendSim = new PendulumSim(document.getElementById('pendulum-canvas'));
|
||||
pendSim.onUpdate = _pendUpdateUI;
|
||||
_pendInjectTimeControlUI(pendSim);
|
||||
}
|
||||
pendSim.fit();
|
||||
pendSim.setMode(pendSim.mode || 'math');
|
||||
@@ -1502,6 +1763,42 @@ function _openPendulum() {
|
||||
}));
|
||||
}
|
||||
|
||||
function _pendInjectTimeControlUI(sim) {
|
||||
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
|
||||
var wrap = document.getElementById('sim-pendulum');
|
||||
if (!wrap || wrap.querySelector('.tc-bar')) return;
|
||||
|
||||
var tc = sim._tc;
|
||||
|
||||
/* Trail toggle button */
|
||||
var trailBtn = document.createElement('button');
|
||||
trailBtn.className = 'zoom-btn';
|
||||
trailBtn.style.cssText = 'display:inline-flex;align-items: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';
|
||||
trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
|
||||
trailBtn.title = 'Включить следы движения';
|
||||
trailBtn.addEventListener('click', function() {
|
||||
sim.showTCTrails = !sim.showTCTrails;
|
||||
if (!sim.showTCTrails) {
|
||||
Object.values(sim._tcTrails).forEach(function(t) { if (t) t.clear(); });
|
||||
}
|
||||
trailBtn.style.background = sim.showTCTrails ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
|
||||
trailBtn.style.color = sim.showTCTrails ? '#06D6E0' : '#ccc';
|
||||
trailBtn.style.borderColor = sim.showTCTrails ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
|
||||
});
|
||||
|
||||
var tcBar = window.LSBuildTimeControlUI(sim, tc, { scrubSupported: true });
|
||||
|
||||
/* Append trail toggle */
|
||||
var sep = document.createElement('div');
|
||||
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
|
||||
tcBar.appendChild(sep);
|
||||
tcBar.appendChild(trailBtn);
|
||||
|
||||
var statsBar = wrap.querySelector('.proj-stats-bar');
|
||||
if (statsBar) wrap.insertBefore(tcBar, statsBar);
|
||||
else wrap.appendChild(tcBar);
|
||||
}
|
||||
|
||||
function pendSetMode(m) {
|
||||
if (!pendSim) return;
|
||||
pendSim.setMode(m);
|
||||
@@ -1524,6 +1821,15 @@ function pendTogglePhase() {
|
||||
if (btn) btn.classList.toggle('active', pendSim.showPhase);
|
||||
}
|
||||
|
||||
function pendToggleGraphs() {
|
||||
if (!pendSim) return;
|
||||
const canvasOuter = document.querySelector('#sim-pendulum .proj-canvas-outer');
|
||||
if (!canvasOuter) return;
|
||||
const on = pendSim.toggleGraphs(canvasOuter);
|
||||
const btn = document.getElementById('btn-pend-graphs');
|
||||
if (btn) btn.classList.toggle('active', on);
|
||||
}
|
||||
|
||||
function pendToggleGhost() {
|
||||
if (!pendSim) return;
|
||||
pendSim.d.showGhost = !pendSim.d.showGhost;
|
||||
@@ -1648,3 +1954,17 @@ function _pendUpdateUI(info) {
|
||||
v('pendbar-v3', info.period);
|
||||
v('pendbar-v4', info.energy);
|
||||
}
|
||||
|
||||
/* ── Energy toggle: pendulum ── */
|
||||
function pendToggleEnergy() {
|
||||
if (!pendSim) return;
|
||||
pendSim._energyOn = !pendSim._energyOn;
|
||||
const on = pendSim._energyOn;
|
||||
const btn = document.getElementById('pend-energy-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', on);
|
||||
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
|
||||
}
|
||||
if (!on) { pendSim._frictionWork = 0; pendSim._energyScale = 0; }
|
||||
pendSim.draw();
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ class ProjectileSim {
|
||||
this._path = null; // [{x, y, vx, vy, t}]
|
||||
this._pathTf = 0;
|
||||
|
||||
/* ── Energy bars widget ── */
|
||||
this._energyOn = false; // toggle state
|
||||
this._frictionWork = 0; // cumulative J lost to drag
|
||||
this._energyScale = 0; // max observed total (for stable scale)
|
||||
|
||||
/* animation state */
|
||||
this.t = 0;
|
||||
this.playing = false;
|
||||
@@ -124,6 +129,12 @@ class ProjectileSim {
|
||||
this.planetCompare = false; // show 3 planet trajectories simultaneously
|
||||
this.comparePlanets = ['earth', 'moon', 'mars']; // which 3
|
||||
|
||||
/* FBD toggle */
|
||||
this._fbdOn = false;
|
||||
|
||||
/* ── TimeControl (speed-only; projectile manages its own time) ── */
|
||||
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
|
||||
|
||||
canvas.addEventListener('click', () => {
|
||||
if (this.onPlayPause) this.onPlayPause();
|
||||
});
|
||||
@@ -925,6 +936,13 @@ class ProjectileSim {
|
||||
|
||||
if (window.LabFX) LabFX.particles.update(rawDt);
|
||||
|
||||
/* TimeControl: if paused via TC, just redraw */
|
||||
if (this._tc && this._tc.paused) {
|
||||
this.draw(); this._emit();
|
||||
if (this.playing) this._tick();
|
||||
return;
|
||||
}
|
||||
|
||||
this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);
|
||||
|
||||
const prevT = this.t;
|
||||
@@ -932,7 +950,20 @@ class ProjectileSim {
|
||||
this._trail.push({ mx: cur.x, my: cur.y });
|
||||
if (this._trail.length > 80) this._trail.shift();
|
||||
|
||||
this.t += rawDt * this.speed;
|
||||
/* energy: accumulate drag work ΔW = F_drag · v · dt (approx) */
|
||||
if (this._energyOn && (this.drag || this.parachute)) {
|
||||
const spd = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy);
|
||||
const rho = this.rho;
|
||||
const mass = Math.max(0.1, this.mass);
|
||||
const A = this._chuteOpen ? this.chuteArea : 0.00785;
|
||||
const Cd = this._chuteOpen ? this.chuteCd : this.Cd;
|
||||
const Fd = 0.5 * Cd * rho * A * spd * spd;
|
||||
this._frictionWork += Fd * spd * rawDt * this.speed;
|
||||
}
|
||||
|
||||
/* advance time; respect TC scale on top of existing speed multiplier */
|
||||
const tcScale = (this._tc && !this._tc.paused) ? this._tc.scale : 1;
|
||||
this.t += rawDt * this.speed * tcScale;
|
||||
const tf = this._curTFlight();
|
||||
if (this.t >= tf) {
|
||||
this.t = tf;
|
||||
@@ -1044,6 +1075,8 @@ class ProjectileSim {
|
||||
this._chuteOpen = this.parachute && this.chuteOpenHeight < 0;
|
||||
this._chuteOpenedTs = -999;
|
||||
this._chimeEmitted = false;
|
||||
this._frictionWork = 0;
|
||||
this._energyScale = 0;
|
||||
this._computePath();
|
||||
if (this.dualMode) {
|
||||
this._p2.t = 0;
|
||||
@@ -1718,10 +1751,80 @@ class ProjectileSim {
|
||||
this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT);
|
||||
}
|
||||
|
||||
/* ── 20. FBD overlay ── */
|
||||
if (this._fbdOn && window.LSPhysFX && this.t > 0) {
|
||||
const tf2 = this._curTFlight();
|
||||
const cur2 = this._curState(Math.min(this.t, tf2));
|
||||
const bx2 = tpx(cur2.x), by2 = tpy(Math.max(0, cur2.y));
|
||||
const spd2 = Math.sqrt(cur2.vx * cur2.vx + cur2.vy * cur2.vy);
|
||||
const FLEN = 48;
|
||||
|
||||
/* gravity — straight down */
|
||||
const mg = this.mass * this.g;
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, FLEN, 'gravity',
|
||||
'mg=' + mg.toFixed(0) + 'Н');
|
||||
|
||||
/* drag — opposite velocity */
|
||||
if (this.drag && spd2 > 0.2) {
|
||||
const rho2 = this.rho || 1.225;
|
||||
const Fd = 0.5 * rho2 * this.Cd * 0.1 * spd2 * spd2;
|
||||
const dragLen = Math.min(FLEN, Fd * 6);
|
||||
if (dragLen > 3) {
|
||||
const dnx = -cur2.vx / spd2, dny = -cur2.vy / spd2;
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2,
|
||||
dnx * dragLen, dny * dragLen,
|
||||
'drag', 'F_c=' + Fd.toFixed(1) + 'Н');
|
||||
}
|
||||
}
|
||||
|
||||
/* wind — horizontal */
|
||||
if (this.wind !== 0) {
|
||||
const windLen = Math.min(FLEN, Math.abs(this.wind) * 4);
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2,
|
||||
Math.sign(this.wind) * windLen, 0,
|
||||
'applied', 'F_w=' + Math.abs(this.wind).toFixed(0) + 'м/с');
|
||||
}
|
||||
|
||||
/* elastic spring force — only when bounce just occurred */
|
||||
if (this.bounce) {
|
||||
const bounceElapsed = (performance.now() - this._impactTs) / 1000;
|
||||
if (bounceElapsed >= 0 && bounceElapsed < 0.25 && cur2.y <= 0.5) {
|
||||
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, -FLEN, 'elastic', 'F_упр');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Energy bars overlay ── */
|
||||
if (this._energyOn && window.LSPhysFX) {
|
||||
this._drawEnergyBarsProj(ctx, W, H);
|
||||
}
|
||||
|
||||
/* LabFX: particles overlay */
|
||||
if (window.LabFX) LabFX.particles.draw(this.ctx);
|
||||
}
|
||||
|
||||
/* ── Energy bars: projectile ── */
|
||||
|
||||
_drawEnergyBarsProj(ctx, W, H) {
|
||||
const tf = this._curTFlight();
|
||||
const cur = this._curState(Math.min(this.t, tf));
|
||||
const h = Math.max(0, cur.y); // height above launch (m)
|
||||
const spd = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy);
|
||||
const m = Math.max(0.1, this.mass);
|
||||
const g = this.g;
|
||||
const ke = 0.5 * m * spd * spd;
|
||||
const pe = m * g * h;
|
||||
const fr = this._frictionWork;
|
||||
const tot = ke + pe + fr;
|
||||
/* stable scale */
|
||||
if (tot > this._energyScale) this._energyScale = tot;
|
||||
const scaleTotal = this._energyScale;
|
||||
|
||||
const PW = 188, MARGIN = 12;
|
||||
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
|
||||
{ ke, pe, friction: fr, total: scaleTotal }, {});
|
||||
}
|
||||
|
||||
/* ── hover inspector ── */
|
||||
|
||||
_onMouseMove(e) {
|
||||
@@ -2011,6 +2114,7 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
pSim.onTargetUpdate = _projUpdateTargetHUD;
|
||||
const gc = document.getElementById('proj-graphs-canvas');
|
||||
if (gc) pSim.attachGraphsCanvas(gc);
|
||||
_projInjectTCBar(pSim);
|
||||
}
|
||||
pSim.fit();
|
||||
projParam(); // sync sliders → sim
|
||||
@@ -2019,6 +2123,24 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
}));
|
||||
}
|
||||
|
||||
function _projInjectTCBar(sim) {
|
||||
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
|
||||
var wrap = document.getElementById('sim-proj');
|
||||
if (!wrap || wrap.querySelector('.tc-bar')) return;
|
||||
|
||||
/* Only speed control — projectile uses analytical/pre-computed paths */
|
||||
/* TC.paused is checked in _tick. TC.scale multiplies rawDt * speed */
|
||||
var tc = sim._tc;
|
||||
|
||||
var tcBar = window.LSBuildTimeControlUI(sim, tc, { scrubSupported: false });
|
||||
|
||||
/* Note: "Следы" already exist in the projectile panel — don't duplicate */
|
||||
|
||||
var statsBar = wrap.querySelector('.proj-stats-bar');
|
||||
if (statsBar) wrap.insertBefore(tcBar, statsBar);
|
||||
else wrap.appendChild(tcBar);
|
||||
}
|
||||
|
||||
function projPlayPause() {
|
||||
if (!pSim) return;
|
||||
if (pSim.playing) {
|
||||
@@ -2396,5 +2518,19 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
pSim.draw();
|
||||
}
|
||||
|
||||
/* ── Energy toggle: projectile ── */
|
||||
function projToggleEnergy() {
|
||||
if (!pSim) return;
|
||||
pSim._energyOn = !pSim._energyOn;
|
||||
const on = pSim._energyOn;
|
||||
const btn = document.getElementById('proj-energy-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', on);
|
||||
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
|
||||
}
|
||||
if (!on) { pSim._frictionWork = 0; pSim._energyScale = 0; }
|
||||
pSim.draw();
|
||||
}
|
||||
|
||||
/* ── collision ── */
|
||||
|
||||
|
||||
+58
-1
@@ -1624,6 +1624,18 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Newton graphs toggle -->
|
||||
<div style="margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.06)">
|
||||
<button id="btn-newton-graphs" onclick="newtonToggleGraphs()" style="width:100%;font-size:.75rem;padding:5px;border-radius:8px;border:1px solid rgba(6,214,224,0.3);background:rgba(6,214,224,0.08);color:#06D6E0;cursor:pointer">Графики x/v/a</button>
|
||||
</div>
|
||||
|
||||
<!-- Energy bars toggle — newton -->
|
||||
<div class="gp-section-title" style="margin-top:10px">Закон сохранения энергии</div>
|
||||
<button id="nwt-energy-btn" class="proj-preset-chip" onclick="dynToggleEnergy()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<span>Энергия: Выкл</span>
|
||||
</button>
|
||||
|
||||
</div><!-- /#dyn-newton-panel -->
|
||||
|
||||
<!-- ══ Sandbox controls (shown in sandbox mode) ══ -->
|
||||
@@ -1735,6 +1747,17 @@
|
||||
Пружина / Нить — кликни 2 тела
|
||||
</div>
|
||||
|
||||
<!-- Sandbox graphs toggle -->
|
||||
<div style="margin-top:8px">
|
||||
<button id="btn-sandbox-graphs" onclick="sandboxToggleGraphs()" style="width:100%;font-size:.75rem;padding:5px;border-radius:8px;border:1px solid rgba(6,214,224,0.3);background:rgba(6,214,224,0.08);color:#06D6E0;cursor:pointer">Графики тела</button>
|
||||
</div>
|
||||
|
||||
<!-- Energy bars toggle — sandbox -->
|
||||
<button id="fsb-energy-btn" class="proj-preset-chip" onclick="fsbToggleEnergy()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px;margin-top:6px">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<span>Энергия+: Выкл</span>
|
||||
</button>
|
||||
|
||||
</div><!-- /#dyn-sandbox-panel -->
|
||||
|
||||
</div><!-- /.proj-panel -->
|
||||
@@ -2001,6 +2024,13 @@
|
||||
<div id="proj-target-hud" style="font-size:.72rem;font-weight:700;color:#FFD166;margin-top:6px;text-align:center">Цели: 0/3 Попыток: 0</div>
|
||||
</div>
|
||||
|
||||
<!-- Energy bars toggle -->
|
||||
<div class="gp-section-title" style="margin-top:10px">Закон сохранения энергии</div>
|
||||
<button id="proj-energy-btn" class="proj-preset-chip" onclick="projToggleEnergy()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<span>Энергия: Выкл</span>
|
||||
</button>
|
||||
|
||||
<!-- Feature 2: Graphs toggle -->
|
||||
<div class="gp-section-title" style="margin-top:10px">Графики</div>
|
||||
<button id="proj-graphs-btn" class="proj-preset-chip" onclick="projToggleGraphs()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
|
||||
@@ -2189,6 +2219,18 @@
|
||||
<button class="proj-preset-chip" onclick="collPreset(6,6,10,10,0,0)">Слипание</button>
|
||||
</div>
|
||||
|
||||
<!-- graphs toggle -->
|
||||
<div style="margin-top:10px">
|
||||
<button id="btn-coll-graphs" onclick="collToggleGraphs()" style="width:100%;font-size:.75rem;padding:5px;border-radius:8px;border:1px solid rgba(6,214,224,0.3);background:rgba(6,214,224,0.08);color:#06D6E0;cursor:pointer">Графики скоростей</button>
|
||||
</div>
|
||||
|
||||
<!-- Energy bars toggle -->
|
||||
<div class="gp-section-title" style="margin-top:10px">Закон сохранения энергии</div>
|
||||
<button id="coll-energy-btn" class="proj-preset-chip" onclick="collToggleEnergy()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<span>Энергия: Выкл</span>
|
||||
</button>
|
||||
|
||||
<!-- launch button -->
|
||||
<div style="margin-top:auto; padding-top:16px; display:flex; flex-direction:column; gap:8px;">
|
||||
<button class="proj-launch-btn" id="coll-launch-main" onclick="collPlayPause()">
|
||||
@@ -2634,6 +2676,7 @@
|
||||
<button class="pend-mode-btn" data-mode="foucault" onclick="pendSetMode('foucault')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">Фуко</button>
|
||||
<button class="pend-mode-btn" data-mode="resonance" onclick="pendSetMode('resonance')" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer">Резонанс</button>
|
||||
<button id="btn-pend-phase" onclick="pendTogglePhase()" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.15);background:rgba(22,22,38,0.6);color:#ccc;cursor:pointer;margin-left:auto">Фаз. портрет</button>
|
||||
<button id="btn-pend-graphs" onclick="pendToggleGraphs()" style="font-size:.72rem;padding:3px 10px;border-radius:14px;border:1px solid rgba(6,214,224,0.3);background:rgba(6,214,224,0.08);color:#06D6E0;cursor:pointer">Графики</button>
|
||||
</div>
|
||||
<div class="sim-body-wrap">
|
||||
<div class="proj-panel" style="width:230px;gap:0;overflow-y:auto;max-height:100%">
|
||||
@@ -2795,6 +2838,13 @@
|
||||
<div class="pp-hint">Красная стрелка — вынуждающая сила. Пик при ω ≈ ω₀.</div>
|
||||
</div>
|
||||
|
||||
<!-- Energy bars toggle (shared across all pendulum modes) -->
|
||||
<div class="gp-section-title" style="margin-top:10px">Закон сохранения энергии</div>
|
||||
<button id="pend-energy-btn" class="proj-preset-chip" onclick="pendToggleEnergy()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<span>Энергия: Выкл</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="proj-canvas-outer">
|
||||
<canvas id="pendulum-canvas"></canvas>
|
||||
@@ -4052,10 +4102,15 @@
|
||||
<!-- result badge -->
|
||||
<div id="hydro-result" style="margin-top:8px;font-size:.82rem;font-weight:700;text-align:center;padding:8px;border-radius:8px;display:none"></div>
|
||||
|
||||
<!-- hydrostatics graphs toggle (Archimedes mode) -->
|
||||
<div id="hydro-graphs-row" style="margin-top:8px;display:none">
|
||||
<button id="btn-hydro-graphs" onclick="hydroToggleGraphs()" style="width:100%;font-size:.75rem;padding:5px;border-radius:8px;border:1px solid rgba(6,214,224,0.3);background:rgba(6,214,224,0.08);color:#06D6E0;cursor:pointer">График погружения</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /.proj-panel -->
|
||||
|
||||
<!-- canvas area -->
|
||||
<div style="flex:1;min-width:0;position:relative">
|
||||
<div style="flex:1;min-width:0;position:relative" id="hydro-canvas-wrap">
|
||||
<canvas id="hydro-canvas" style="width:100%;height:100%;display:block"></canvas>
|
||||
</div>
|
||||
|
||||
@@ -4612,8 +4667,10 @@
|
||||
<script src="/js/labs/_fx_motion.js"></script>
|
||||
<script src="/js/labs/_fx_sound.js"></script>
|
||||
<script src="/js/labs/graph.js"></script>
|
||||
<script src="/js/labs/_phys_visuals.js"></script>
|
||||
<script src="/js/labs/emfield.js"></script>
|
||||
<script src="/js/labs/triangle.js"></script>
|
||||
<script src="/js/labs/_graph_panel.js"></script>
|
||||
<script src="/js/labs/projectile.js"></script>
|
||||
<script src="/js/labs/collision.js"></script>
|
||||
<script src="/js/labs/gas.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user