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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user