diff --git a/frontend/css/lab.css b/frontend/css/lab.css index f0ab663..2de00b2 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -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); +} diff --git a/frontend/js/labs/_graph_panel.js b/frontend/js/labs/_graph_panel.js new file mode 100644 index 0000000..49bab26 --- /dev/null +++ b/frontend/js/labs/_graph_panel.js @@ -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
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 = ''; + 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 = ''; + 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; diff --git a/frontend/js/labs/_phys_visuals.js b/frontend/js/labs/_phys_visuals.js new file mode 100644 index 0000000..0498b40 --- /dev/null +++ b/frontend/js/labs/_phys_visuals.js @@ -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
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 = ''; + const IC_PLAY = ''; + const IC_BACK = ''; + const IC_FWD = ''; + + 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); diff --git a/frontend/js/labs/collision.js b/frontend/js/labs/collision.js index 315b7ed..0797624 100644 --- a/frontend/js/labs/collision.js +++ b/frontend/js/labs/collision.js @@ -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 = ' Следы'; + 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); +} \ No newline at end of file diff --git a/frontend/js/labs/forcesandbox.js b/frontend/js/labs/forcesandbox.js index 2856cdc..1e7b7a9 100644 --- a/frontend/js/labs/forcesandbox.js +++ b/frontend/js/labs/forcesandbox.js @@ -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); +} \ No newline at end of file diff --git a/frontend/js/labs/hydrostatics.js b/frontend/js/labs/hydrostatics.js index d2e5909..52f510b 100644 --- a/frontend/js/labs/hydrostatics.js +++ b/frontend/js/labs/hydrostatics.js @@ -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')); diff --git a/frontend/js/labs/newton.js b/frontend/js/labs/newton.js index 32d578f..03ac03c 100644 --- a/frontend/js/labs/newton.js +++ b/frontend/js/labs/newton.js @@ -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 = ' Следы'; + 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 ── */ diff --git a/frontend/js/labs/pendulum.js b/frontend/js/labs/pendulum.js index 58bf920..4bfcf9a 100644 --- a/frontend/js/labs/pendulum.js +++ b/frontend/js/labs/pendulum.js @@ -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 = ' Следы'; + 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(); +} diff --git a/frontend/js/labs/projectile.js b/frontend/js/labs/projectile.js index 7479517..7b0aa47 100644 --- a/frontend/js/labs/projectile.js +++ b/frontend/js/labs/projectile.js @@ -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 ── */ diff --git a/frontend/lab.html b/frontend/lab.html index ce47284..92a6e9b 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -1624,6 +1624,18 @@
+ +
+ +
+ + +
Закон сохранения энергии
+ +
@@ -1735,6 +1747,17 @@ Пружина / Нить — кликни 2 тела + +
+ +
+ + + + @@ -2001,6 +2024,13 @@
Цели: 0/3 Попыток: 0
+ +
Закон сохранения энергии
+ +
Графики
+ +
+ +
+ + +
Закон сохранения энергии
+ +
+
@@ -2795,6 +2838,13 @@
Красная стрелка — вынуждающая сила. Пик при ω ≈ ω₀.
+ +
Закон сохранения энергии
+ +
@@ -4052,10 +4102,15 @@ + + +
-
+
@@ -4612,8 +4667,10 @@ + +