From e46548d06b5a939c5d7b6a52bcbb78942928d824 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 26 May 2026 14:14:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(labs):=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20V2=20=E2=80=94=204=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=B2=D1=8B=D0=B5=20=D1=81=D0=B8=D0=BC=D1=8B=20?= =?UTF-8?q?=D1=88=D0=BA=D0=BE=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D1=84=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D0=BA=D0=B8=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pendulum V2 (472 → 1651 строк): - Математический (default, сохранён) - Двойной маятник (Lagrangian RK4, ghost-копия для демо хаоса) - Связанные маятники (биения, чарт θ₁/θ₂) - Пружинный (вертикальный/горизонтальный, T=2π√(m/k)) - Физический (4 формы: стержень/обруч/диск/прямоугольник, с моментом инерции) - Маятник Фуко (Кориолис, slider широты, период прецессии) - Резонанс (внешняя F₀·cos(ω_d·t), резонансная кривая A(ω)) - Фазовый портрет (универсальный toggle для всех режимов) collision V2 (~1000 → 2416 строк): - 1D (default, сохранён) - 2D под углом (импульс по осям, slider e, до/после стат) - Multi-ball (N=2-10, стены с отскоками, перемешать) - Бильярдный стол (6 луз, кий с прицелом, треугольник шаров, реалистичное трение) - Реф.фрейм ЦМ (universal toggle) newton V2 (1693 → 2585 строк): - 4-й закон-таб «Классические задачи» - Машина Атвуда (a=(m₂-m₁)g/(m₁+m₂), идеальный/массивный блок) - Тело на наклонной плоскости (FBD, статика/скольжение, slider α/μ/F_app) - Скатывание шара/цилиндра/обруча (момент инерции, гонка, наглядно почему обруч медленнее) projectile V2 (1900 → 2400 строк): - Парашют: F_d = ½C_d·ρ·A·v² с терминальной скоростью v_t = √(2mg/(C_d·ρ·A)) - C_d selector: парашют/куб/сфера/полусфера/диск; раскрытие парашюта на заданной высоте - Горка-катапульта: v_0 = √(2gL(sinα-μcosα)) автомат - 10 планет: Земля/Луна/Марс/Юпитер/Меркурий/Венера/Сатурн/Уран/Нептун/Плутон с реальными g + плотностью атмосферы (для drag) - Сравнительный режим: 3 планеты одновременно с разными цветами Все 4 симы — additive, существующая функциональность сохранена. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/js/labs/collision.js | 1323 ++++++++++++++++++++++++++- frontend/js/labs/newton.js | 976 +++++++++++++++++++- frontend/js/labs/pendulum.js | 1569 ++++++++++++++++++++++++++++---- frontend/js/labs/projectile.js | 583 ++++++++++-- frontend/lab.html | 383 +++++++- 5 files changed, 4485 insertions(+), 349 deletions(-) diff --git a/frontend/js/labs/collision.js b/frontend/js/labs/collision.js index f3d02cb..315b7ed 100644 --- a/frontend/js/labs/collision.js +++ b/frontend/js/labs/collision.js @@ -999,6 +999,995 @@ class CollisionSim { } } +/* ═══════════════════════════════════════════════ + Collision2DSim — 2D angle collision (mode 2D) + ═══════════════════════════════════════════════ */ +class Collision2DSim { + constructor(canvas) { + this.c = canvas; + this.ctx = canvas.getContext('2d'); + this.m1 = 4; this.m2 = 4; + this.v1 = 8; this.v2 = 8; + this.a1 = 0; this.a2 = 180; // angles in degrees + this.e = 1; + this.speed = 1; + this.cmFrame = false; // centre-of-mass reference frame toggle + + this.playing = false; + this._raf = null; + this._lastTs = null; + this._b = []; + this._sparks = []; + this._rings = []; + this._colCount = 0; + this._cooldown = 0; + this._impactPt = null; + this._snapBefore = null; + this._snapAfter = null; + this.onUpdate = null; + this.onPlayPause = null; + + this._activeMode = false; + canvas.addEventListener('click', () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); }); + new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement); + } + + fit() { + const r = this.c.parentElement.getBoundingClientRect(); + this.c.width = r.width || 700; + this.c.height = r.height || 420; + } + + getParams() { return { m1:this.m1, m2:this.m2, v1:this.v1, v2:this.v2, a1:this.a1, a2:this.a2, e:this.e }; } + setParams(p) { + if (p.m1 !== undefined) this.m1 = +p.m1; + if (p.m2 !== undefined) this.m2 = +p.m2; + if (p.v1 !== undefined) this.v1 = +p.v1; + if (p.v2 !== undefined) this.v2 = +p.v2; + if (p.a1 !== undefined) this.a1 = +p.a1; + if (p.a2 !== undefined) this.a2 = +p.a2; + if (p.e !== undefined) this.e = +p.e; + this.reset(); + } + setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); } + + _r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); } + + _initBalls() { + const W = this.c.width || 700, H = this.c.height || 420; + const r1 = this._r(this.m1), r2 = this._r(this.m2); + const cx = W / 2, cy = H / 2; + const gap = Math.max(r1 + r2 + 80, W * 0.35); + const rad1 = this.a1 * Math.PI / 180; + const rad2 = this.a2 * Math.PI / 180; + + this._b = [ + { id:1, m:this.m1, r:r1, + x: cx - gap/2 * Math.cos(rad1), y: cy - gap/2 * Math.sin(rad1), + vx: this.v1 * Math.cos(rad1), vy: this.v1 * Math.sin(rad1), + color:'#9B5DE5', rgb:'155,93,229', trail:[] }, + { id:2, m:this.m2, r:r2, + x: cx - gap/2 * Math.cos(rad2), y: cy - gap/2 * Math.sin(rad2), + vx: this.v2 * Math.cos(rad2), vy: this.v2 * Math.sin(rad2), + color:'#06D6E0', rgb:'6,214,224', trail:[] }, + ]; + this._cooldown = 0; + this._colCount = 0; + this._snapBefore = null; + this._snapAfter = null; + this._sparks = []; + this._rings = []; + this._impactPt = null; + } + + play() { + if (this.playing) return; + this.playing = true; this._lastTs = null; + this._tick(); + } + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + reset() { + this.pause(); + this._sparks = []; this._rings = []; this._impactPt = null; + this._initBalls(); this.draw(); this._emit(); + } + + stats() { + if (this._b.length < 2) return { colCount:0, before:null, after:null }; + return { colCount: this._colCount, before: this._snapBefore, after: this._snapAfter }; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.stats()); } + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (!this.playing) return; + 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); + this._step(dt); + this.draw(); + this._emit(); + if (this.playing) this._tick(); + }); + } + + _step(dt) { + const W = this.c.width, H = this.c.height; + if (this._cooldown > 0) this._cooldown--; + + /* trails */ + for (const b of this._b) { + b.trail.push({ x:b.x, y:b.y }); + if (b.trail.length > 80) b.trail.shift(); + } + + /* integrate */ + for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; } + + /* wall bounces */ + const eW = Math.max(0.5, this.e); + for (const b of this._b) { + if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'L'); } + if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'R'); } + if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'T'); } + if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'B'); } + } + + /* ball-ball collision */ + if (this._cooldown === 0) { + const [b1, b2] = this._b; + const dx = b2.x - b1.x, dy = b2.y - b1.y; + const dist = Math.hypot(dx, dy); + const min = b1.r + b2.r; + if (dist < min && dist > 0.01) { + const nx = dx / dist, ny = dy / dist; + // relative velocity along normal + const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny; + if (dvn > 0) { + if (this._colCount === 0) this._snapBefore = this._snapshot(); + + /* 2D elastic/inelastic: decompose into normal + tangential */ + // v_n1, v_n2 (scalars along n̂) + const v1n = b1.vx * nx + b1.vy * ny; + const v2n = b2.vx * nx + b2.vy * ny; + // tangential components (unchanged) + const v1tx = b1.vx - v1n * nx, v1ty = b1.vy - v1n * ny; + const v2tx = b2.vx - v2n * nx, v2ty = b2.vy - v2n * ny; + // new normal velocities (1D formula with e) + const M = b1.m + b2.m; + const v1nNew = ((b1.m - b2.m * this.e) * v1n + b2.m * (1 + this.e) * v2n) / M; + const v2nNew = ((b2.m - b1.m * this.e) * v2n + b1.m * (1 + this.e) * v1n) / M; + b1.vx = v1tx + v1nNew * nx; b1.vy = v1ty + v1nNew * ny; + b2.vx = v2tx + v2nNew * nx; b2.vy = v2ty + v2nNew * ny; + + this._snapAfter = this._snapshot(); + this._colCount++; + this._cooldown = 8; + const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2; + this._impactPt = { x:ix, y:iy, ts:performance.now() }; + _c2dSpawnFx(this, ix, iy); + } + /* overlap fix */ + const ov = min - dist; + b1.x -= nx * ov / 2; b1.y -= ny * ov / 2; + b2.x += nx * ov / 2; b2.y += ny * ov / 2; + } + } + + const now = performance.now(); + this._rings = this._rings.filter(r => (now - r.ts) < (r.life || 900)); + this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life || 800)); + } + + _snapshot() { + return this._b.map(b => { + const spd = Math.hypot(b.vx, b.vy); + return { m:b.m, vx:b.vx, vy:b.vy, spd, ke: 0.5 * b.m * spd * spd }; + }); + } + + draw() { + const W = this.c.width, H = this.c.height; + if (!W || !H || this._b.length < 2) return; + const ctx = this.ctx; + const now = performance.now(); + + /* bg */ + const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7); + bg.addColorStop(0,'#130e22'); bg.addColorStop(1,'#080812'); + ctx.fillStyle = bg; ctx.fillRect(0,0,W,H); + + /* grid */ + ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; + for (let x = 60; x < W; x += 60) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); } + for (let y = 60; y < H; y += 60) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); } + ctx.strokeStyle = 'rgba(155,93,229,.18)'; ctx.lineWidth = 2; + ctx.strokeRect(2,2,W-4,H-4); + + /* rings */ + for (const ring of this._rings) { + const el = (now - ring.ts) / ring.life; + if (el >= 1) continue; + ctx.strokeStyle = `rgba(${ring.col},${Math.pow(1-el,1.6) * 0.7})`; + ctx.lineWidth = 2.8 * (1 - el * 0.75); + ctx.beginPath(); ctx.arc(ring.x, ring.y, el * (ring.maxR || 90), 0, Math.PI*2); ctx.stroke(); + } + + /* sparks */ + ctx.lineCap = 'round'; + for (const sp of this._sparks) { + const el = (now - sp.ts) / sp.life; + if (el >= 1) continue; + const dist = sp.spd * el; + const grav = (sp.grav || 0) * el * el; + const ex = sp.x + Math.cos(sp.ang) * dist; + const ey = sp.y + Math.sin(sp.ang) * dist + grav; + ctx.strokeStyle = `rgba(${sp.col},${Math.pow(1-el,1.4) * 0.9})`; + ctx.lineWidth = 2 * (1 - el * 0.4); + ctx.beginPath(); ctx.moveTo(sp.x, sp.y); ctx.lineTo(ex, ey); ctx.stroke(); + } + ctx.lineCap = 'butt'; + + /* trails */ + for (const b of this._b) { + for (let i = 1; i < b.trail.length; i++) { + const frac = i / b.trail.length; + ctx.fillStyle = `rgba(${b.rgb},${frac * 0.45})`; + ctx.beginPath(); + ctx.arc(b.trail[i].x, b.trail[i].y, frac * 4, 0, Math.PI*2); + ctx.fill(); + } + } + + /* balls */ + for (const b of this._b) { + const [r, g, bl] = b.rgb.split(',').map(Number); + /* glow */ + const glo = ctx.createRadialGradient(b.x,b.y, b.r*0.2, b.x,b.y, b.r*3.2); + glo.addColorStop(0, `rgba(${r},${g},${bl},.5)`); + glo.addColorStop(1, 'transparent'); + ctx.fillStyle = glo; ctx.beginPath(); ctx.arc(b.x,b.y, b.r*3.2, 0, Math.PI*2); ctx.fill(); + /* body */ + const bodyG = ctx.createRadialGradient(-b.r*0.33+b.x, -b.r*0.33+b.y, b.r*0.06, b.x, b.y, b.r); + bodyG.addColorStop(0,'#ffffff'); bodyG.addColorStop(0.18, b.color); + bodyG.addColorStop(1, `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`); + ctx.fillStyle = bodyG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill(); + ctx.strokeStyle='rgba(255,255,255,.5)'; ctx.lineWidth=1.5; ctx.stroke(); + /* label */ + ctx.fillStyle='rgba(255,255,255,.95)'; + ctx.font = `bold ${Math.max(10,Math.round(b.r*0.6))}px Manrope`; + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText(b.m + ' кг', b.x, b.y); + /* velocity arrow */ + const spd = Math.hypot(b.vx, b.vy); + if (spd > 0.05) { + const nx = b.vx/spd, ny = b.vy/spd; + const pLen = Math.min(68, spd * b.m * 0.75 + 8); + _colArrow(ctx, b.x+nx*(b.r+7), b.y+ny*(b.r+7), + b.x+nx*(b.r+7+pLen), b.y+ny*(b.r+7+pLen), b.color, 2.5); + } + } + + /* HUD — momentum px,py + KE before/after */ + _c2dDrawHUD(ctx, this, W, H, now); + + /* CM frame label */ + if (this.cmFrame) { + ctx.fillStyle = 'rgba(255,200,50,.7)'; + ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('Система ЦМ', W/2, 8); + } + + if (window.LabFX) LabFX.particles.draw(this.ctx); + } +} + +/* ═══ Collision2DSim helpers ═══ */ +function _c2dWallFx(sim, b, side) { + const now = performance.now(); + const baseAng = { L:0, R:Math.PI, T:Math.PI/2, B:-Math.PI/2 }[side] ?? 0; + for (let k = 0; k < 6; k++) { + sim._sparks.push({ ang: baseAng + (Math.random()-0.5)*Math.PI, spd: 10+Math.random()*20, + x:b.x, y:b.y, ts:now, col:b.rgb, life:400 }); + } +} + +function _c2dSpawnFx(sim, ix, iy) { + const now = performance.now(); + if (window.LabFX) { LabFX.sound.play('bounce'); } + const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181']; + for (let k = 0; k < 30; k++) { + sim._sparks.push({ ang:(k/30)*Math.PI*2+(Math.random()-0.5)*0.4, spd:25+Math.random()*75, + x:ix, y:iy, ts:now, col:pal[k%pal.length], life:800, grav:30+Math.random()*50 }); + } + const ringDefs = [ + {life:1200,col:'255,255,255',maxR:130},{life:800,col:'155,93,229',maxR:80},{life:600,col:'6,214,224',maxR:55} + ]; + for (const rd of ringDefs) sim._rings.push({ x:ix, y:iy, ts:now, life:rd.life, col:rd.col, maxR:rd.maxR }); +} + +function _c2dDrawHUD(ctx, sim, W, H, now) { + const before = sim._snapBefore, after = sim._snapAfter; + const rows = []; + if (before) { + const px = before.reduce((s,b)=>s+b.m*b.vx,0), py = before.reduce((s,b)=>s+b.m*b.vy,0); + const ke = before.reduce((s,b)=>s+b.ke,0); + rows.push({ label:'p_x до:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' }); + rows.push({ label:'p_y до:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' }); + rows.push({ label:'KE до:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' }); + } + if (after) { + const px = after.reduce((s,b)=>s+b.m*b.vx,0), py = after.reduce((s,b)=>s+b.m*b.vy,0); + const ke = after.reduce((s,b)=>s+b.ke,0); + rows.push({ label:'p_x после:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' }); + rows.push({ label:'p_y после:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' }); + rows.push({ label:'KE после:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' }); + } + if (rows.length === 0) return; + const lh = 17, pad = 8, tw = 188, th = pad*2 + rows.length * lh; + const bx = W - tw - 10, by = H - th - 10; + ctx.fillStyle = 'rgba(8,8,18,.88)'; + ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.fill(); + ctx.strokeStyle = 'rgba(155,93,229,.4)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.stroke(); + ctx.font = '10px Manrope'; + for (let i = 0; i < rows.length; i++) { + const ry = by + pad + i * lh + lh/2; + ctx.fillStyle = 'rgba(255,255,255,.4)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(rows[i].label, bx + pad, ry); + ctx.fillStyle = rows[i].c; ctx.textAlign = 'right'; + ctx.fillText(rows[i].val, bx + tw - pad, ry); + } +} + +/* ═══════════════════════════════════════════════ + MultiBodySim — N bodies (mode multi) + ═══════════════════════════════════════════════ */ +class MultiBodySim { + constructor(canvas) { + this.c = canvas; + this.ctx = canvas.getContext('2d'); + this.N = 5; + this.e = 1; + this.speed = 1; + this.cmFrame = false; + + this.playing = false; + this._raf = null; + this._lastTs = null; + this._b = []; + this._sparks = []; + this._rings = []; + this._colCount = 0; + this.onUpdate = null; + this.onPlayPause = null; + this._activeMode = false; + + canvas.addEventListener('click', () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); }); + new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement); + } + + fit() { + const r = this.c.parentElement.getBoundingClientRect(); + this.c.width = r.width || 700; + this.c.height = r.height || 420; + } + + setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); } + + _r(m) { return Math.max(14, Math.min(36, 12 + m * 2)); } + + _initBalls() { + const W = this.c.width || 700, H = this.c.height || 420; + const COLORS = [ + { color:'#9B5DE5', rgb:'155,93,229' }, { color:'#06D6E0', rgb:'6,214,224' }, + { color:'#F15BB5', rgb:'241,91,181' }, { color:'#FFD166', rgb:'255,209,102' }, + { color:'#06D6A0', rgb:'6,214,160' }, { color:'#EF476F', rgb:'239,71,111' }, + { color:'#118AB2', rgb:'17,138,178' }, { color:'#FFB347', rgb:'255,179,71' }, + { color:'#B5EAD7', rgb:'181,234,215' }, { color:'#C7CEEA', rgb:'199,206,234' }, + ]; + const masses = [3,4,5,6,5,4,3,5,4,6]; + this._b = []; + for (let i = 0; i < this.N; i++) { + const m = masses[i % masses.length]; + const r = this._r(m); + const col = COLORS[i % COLORS.length]; + let x, y, tries = 0; + do { + x = r + Math.random() * (W - 2*r); + y = r + Math.random() * (H - 2*r); + tries++; + } while (tries < 50 && this._b.some(b => Math.hypot(b.x-x, b.y-y) < b.r + r + 5)); + const ang = Math.random() * Math.PI * 2; + const spd = 5 + Math.random() * 10; + this._b.push({ id:i, m, r, x, y, vx:Math.cos(ang)*spd, vy:Math.sin(ang)*spd, + color:col.color, rgb:col.rgb, trail:[] }); + } + this._colCount = 0; + this._sparks = []; + this._rings = []; + } + + shuffle() { + this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); + } + + play() { + if (this.playing) return; + this.playing = true; this._lastTs = null; this._tick(); + } + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); } + + stats() { + const totalP = { x:0, y:0 }, totalKE = { v:0 }; + for (const b of this._b) { + totalP.x += b.m * b.vx; totalP.y += b.m * b.vy; + totalKE.v += 0.5 * b.m * (b.vx*b.vx + b.vy*b.vy); + } + return { colCount: this._colCount, px: totalP.x, py: totalP.y, ke: totalKE.v }; + } + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (!this.playing) return; + 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); + this._step(dt); + this.draw(); + if (this.onUpdate) this.onUpdate(this.stats()); + if (this.playing) this._tick(); + }); + } + + _step(dt) { + const W = this.c.width, H = this.c.height; + + /* apply CM frame: subtract v_cm from display velocities if toggled */ + let vcmx = 0, vcmy = 0; + if (this.cmFrame) { + let M = 0; + for (const b of this._b) { M += b.m; vcmx += b.m*b.vx; vcmy += b.m*b.vy; } + vcmx /= M; vcmy /= M; + } + + /* trails */ + for (const b of this._b) { + b.trail.push({ x:b.x, y:b.y }); + if (b.trail.length > 60) b.trail.shift(); + } + + /* integrate */ + for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; } + + /* walls */ + const eW = Math.max(0.5, this.e); + for (const b of this._b) { + if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * eW; } + if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; } + if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * eW; } + if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; } + } + + /* pair collisions */ + const now = performance.now(); + for (let i = 0; i < this._b.length; i++) { + for (let j = i+1; j < this._b.length; j++) { + const b1 = this._b[i], b2 = this._b[j]; + const dx = b2.x - b1.x, dy = b2.y - b1.y; + const dist = Math.hypot(dx, dy); + const min = b1.r + b2.r; + if (dist < min && dist > 0.01) { + const nx = dx/dist, ny = dy/dist; + const v1n = b1.vx*nx + b1.vy*ny; + const v2n = b2.vx*nx + b2.vy*ny; + const dvn = v1n - v2n; + if (dvn > 0) { + const v1tx = b1.vx - v1n*nx, v1ty = b1.vy - v1n*ny; + const v2tx = b2.vx - v2n*nx, v2ty = b2.vy - v2n*ny; + const M = b1.m + b2.m; + const v1nNew = ((b1.m - b2.m*this.e)*v1n + b2.m*(1+this.e)*v2n)/M; + const v2nNew = ((b2.m - b1.m*this.e)*v2n + b1.m*(1+this.e)*v1n)/M; + b1.vx = v1tx + v1nNew*nx; b1.vy = v1ty + v1nNew*ny; + b2.vx = v2tx + v2nNew*nx; b2.vy = v2ty + v2nNew*ny; + this._colCount++; + const ix = (b1.x+b2.x)/2, iy = (b1.y+b2.y)/2; + if (window.LabFX) LabFX.sound.play('bounce'); + for (let k = 0; k < 3; k++) { + this._rings.push({ x:ix, y:iy, ts:now, life:600, col:'255,255,255', maxR:50 }); + } + } + const ov = min - dist; + b1.x -= nx*ov/2; b1.y -= ny*ov/2; + b2.x += nx*ov/2; b2.y += ny*ov/2; + } + } + } + + this._rings = this._rings.filter(r => (now - r.ts) < (r.life||900)); + this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life||800)); + } + + draw() { + const W = this.c.width, H = this.c.height; + if (!W || !H) return; + const ctx = this.ctx; + const now = performance.now(); + + /* bg */ + const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7); + bg.addColorStop(0,'#0d0a1a'); bg.addColorStop(1,'#060610'); + ctx.fillStyle = bg; ctx.fillRect(0,0,W,H); + ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1; + for (let x=60;x=1) continue; + ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.6})`; + ctx.lineWidth=2*(1-el*0.7); + ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||50),0,Math.PI*2); ctx.stroke(); + } + + /* trails */ + for (const b of this._b) { + for (let i=1;i 0.1) { + const nx=dvx/spd, ny=dvy/spd; + const pLen=Math.min(55,spd*1.8+6); + _colArrow(ctx, b.x+nx*(b.r+5), b.y+ny*(b.r+5), b.x+nx*(b.r+5+pLen), b.y+ny*(b.r+5+pLen), b.color, 2); + } + } + + /* HUD: total p + KE */ + const st = this.stats(); + const pMag = Math.hypot(st.px, st.py); + const hudLines = [ + { l:'p суммарный:', v: pMag.toFixed(2)+' кг·м/с', c:'#F15BB5' }, + { l:'KE суммарная:', v: st.ke.toFixed(1)+' Дж', c:'#FFD166' }, + { l:'Столкновений:', v: String(st.colCount), c:'#fff' }, + ]; + const tw=200, lh=18, pad=8, th=pad*2+hudLines.length*lh; + ctx.fillStyle='rgba(8,8,18,.88)'; + ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.fill(); + ctx.strokeStyle='rgba(241,91,181,.35)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.stroke(); + ctx.font='10px Manrope'; + for (let i=0;i { if (this._activeMode) this._onDown(e); }); + canvas.addEventListener('mousemove', e => { if (this._activeMode) this._onMove(e); }); + canvas.addEventListener('mouseup', e => { if (this._activeMode) this._onUp(e); }); + canvas.addEventListener('mouseleave',() => { if (this._activeMode) { this._drag = null; this._aimEnd = null; this.draw(); } }); + new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement); + } + + fit() { + const r = this.c.parentElement.getBoundingClientRect(); + this.c.width = r.width || 700; + this.c.height = r.height || 420; + } + + setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); } + + /* Table geometry helpers */ + _table() { + const W=this.c.width||700, H=this.c.height||420; + const pad = 36; + return { x:pad, y:pad, w:W-2*pad, h:H-2*pad, W, H, pad }; + } + + _pockets() { + const t = this._table(); + return [ + { x:t.x, y:t.y }, + { x:t.x+t.w/2, y:t.y }, + { x:t.x+t.w, y:t.y }, + { x:t.x, y:t.y+t.h }, + { x:t.x+t.w/2, y:t.y+t.h }, + { x:t.x+t.w, y:t.y+t.h }, + ]; + } + + /* Build billiard rack */ + _initBalls() { + const t = this._table(); + const R = 11; + this._b = []; + this._pocketed = []; + this._rings = []; this._sparks = []; + this._drag = null; this._aimEnd = null; this._cueShot = false; + this._colCount = 0; + + /* Cue ball */ + this._b.push({ id:0, m:5, r:R, x:t.x+t.w*0.28, y:t.y+t.h*0.5, + vx:0, vy:0, color:'#FFFFFF', rgb:'255,255,255', + isCue:true, pocketed:false, trail:[] }); + + /* Triangle rack: 6 colored balls */ + const COLORS_RACK = ['#F15BB5','#FFD166','#06D6A0','#EF476F','#118AB2','#9B5DE5']; + const RGBS = ['241,91,181','255,209,102','6,214,160','239,71,111','17,138,178','155,93,229']; + const apex = { x: t.x + t.w*0.65, y: t.y + t.h*0.5 }; + const rows = [[0],[1,2],[3,4,5]]; + let idx = 0; + for (let row = 0; row < rows.length; row++) { + for (let col = 0; col < rows[row].length; col++) { + const px = apex.x + row * (R*2 + 1); + const py = apex.y + (col - (rows[row].length-1)/2) * (R*2 + 1); + this._b.push({ id:idx+1, m:5, r:R, x:px, y:py, vx:0, vy:0, + color:COLORS_RACK[idx], rgb:RGBS[idx], + isCue:false, pocketed:false, trail:[] }); + idx++; + } + } + } + + reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); } + + stats() { return { colCount:this._colCount, pocketed:this._pocketed.length }; } + + play() { + if (this.playing) return; + this.playing = true; this._lastTs = null; this._tick(); + } + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + _canvasXY(e) { + const r = this.c.getBoundingClientRect(); + return { x: (e.clientX - r.left)*(this.c.width/r.width), + y: (e.clientY - r.top)*(this.c.height/r.height) }; + } + + _cueBall() { return this._b.find(b => b.isCue && !b.pocketed); } + + _onDown(e) { + const cue = this._cueBall(); + if (!cue) return; + const p = this._canvasXY(e); + if (Math.hypot(p.x - cue.x, p.y - cue.y) < cue.r + 20) { + this._drag = { x:cue.x, y:cue.y }; + this._aimEnd = p; + this.draw(); + } + } + _onMove(e) { + if (!this._drag) return; + this._aimEnd = this._canvasXY(e); + this.draw(); + } + _onUp(e) { + if (!this._drag) return; + const cue = this._cueBall(); + if (!cue) { this._drag = null; return; } + const p = this._canvasXY(e); + /* shoot in opposite direction of drag (pull-back mechanic) */ + const dx = this._drag.x - p.x, dy = this._drag.y - p.y; + const dist = Math.hypot(dx, dy); + if (dist > 4) { + const forceMag = Math.min(dist * 0.35, this.cueForce * 1.2) * (this.cueForce / 15); + cue.vx = (dx / dist) * forceMag; + cue.vy = (dy / dist) * forceMag; + this._cueShot = true; + if (!this.playing) this.play(); + } + this._drag = null; this._aimEnd = null; + this.draw(); + } + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (!this.playing) return; + if (this._lastTs === null) this._lastTs = ts; + const rawDt = Math.min((ts - this._lastTs)/1000, 0.04); + this._lastTs = ts; + const dt = rawDt * this.speed; + if (window.LabFX) LabFX.particles.update(rawDt); + this._step(dt); + this.draw(); + if (this.onUpdate) this.onUpdate(this.stats()); + /* stop when all balls nearly still */ + const moving = this._b.some(b => !b.pocketed && Math.hypot(b.vx,b.vy) > 0.3); + if (!moving) { this.pause(); this.draw(); } + else if (this.playing) this._tick(); + }); + } + + _step(dt) { + const t = this._table(); + const now = performance.now(); + const friction = 0.985; // per frame rolling friction + + /* trails */ + for (const b of this._b) { + if (b.pocketed) continue; + b.trail.push({ x:b.x, y:b.y }); + if (b.trail.length > 50) b.trail.shift(); + } + + /* integrate + friction */ + for (const b of this._b) { + if (b.pocketed) continue; + b.x += b.vx * dt; b.y += b.vy * dt; + b.vx *= friction; b.vy *= friction; + if (Math.hypot(b.vx,b.vy) < 0.05) { b.vx = 0; b.vy = 0; } + } + + /* wall bounces (cushion) */ + for (const b of this._b) { + if (b.pocketed) continue; + if (b.x - b.r < t.x) { b.x = t.x + b.r; b.vx = Math.abs(b.vx)*0.8; } + if (b.x + b.r > t.x + t.w) { b.x = t.x+t.w - b.r; b.vx = -Math.abs(b.vx)*0.8; } + if (b.y - b.r < t.y) { b.y = t.y + b.r; b.vy = Math.abs(b.vy)*0.8; } + if (b.y + b.r > t.y + t.h) { b.y = t.y+t.h - b.r; b.vy = -Math.abs(b.vy)*0.8; } + } + + /* ball-ball collisions */ + const active = this._b.filter(b => !b.pocketed); + for (let i = 0; i < active.length; i++) { + for (let j = i+1; j < active.length; j++) { + const b1 = active[i], b2 = active[j]; + const dx = b2.x-b1.x, dy = b2.y-b1.y; + const dist = Math.hypot(dx,dy); + const min = b1.r + b2.r; + if (dist < min && dist > 0.01) { + const nx=dx/dist, ny=dy/dist; + const dvn = (b1.vx-b2.vx)*nx + (b1.vy-b2.vy)*ny; + if (dvn > 0) { + const M=b1.m+b2.m; + const J = (1+0.85)*dvn / (1/b1.m + 1/b2.m); + b1.vx -= J*nx/b1.m; b1.vy -= J*ny/b1.m; + b2.vx += J*nx/b2.m; b2.vy += J*ny/b2.m; + this._colCount++; + if (window.LabFX) LabFX.sound.play('bounce'); + const ix=(b1.x+b2.x)/2, iy=(b1.y+b2.y)/2; + this._rings.push({x:ix,y:iy,ts:now,life:500,col:'255,255,255',maxR:35}); + } + const ov=min-dist; + b1.x-=nx*ov/2; b1.y-=ny*ov/2; + b2.x+=nx*ov/2; b2.y+=ny*ov/2; + } + } + } + + /* pocket detection */ + const pockets = this._pockets(); + const PR = 14; // pocket radius + for (const b of this._b) { + if (b.pocketed) continue; + for (const p of pockets) { + if (Math.hypot(b.x-p.x, b.y-p.y) < PR) { + b.pocketed = true; b.vx=0; b.vy=0; + this._pocketed.push(b.id); + if (window.LabFX) LabFX.sound.play('chime'); + this._rings.push({x:p.x,y:p.y,ts:now,life:700,col:'255,220,50',maxR:40}); + break; + } + } + } + + this._rings = this._rings.filter(r=>(now-r.ts)<(r.life||900)); + this._sparks = this._sparks.filter(s=>(now-s.ts)<(s.life||800)); + } + + draw() { + const W=this.c.width||700, H=this.c.height||420; + const ctx=this.ctx, now=performance.now(); + const t=this._table(); + + /* bg */ + ctx.fillStyle='#1a1020'; ctx.fillRect(0,0,W,H); + + /* table felt */ + ctx.fillStyle='#1a5c2e'; + ctx.beginPath(); ctx.roundRect(t.x,t.y,t.w,t.h,6); ctx.fill(); + /* felt texture lines */ + ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1; + for (let x=t.x+30;x=1) continue; + ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.8})`; + ctx.lineWidth=2.5*(1-el*0.7); + ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||40),0,Math.PI*2); ctx.stroke(); + } + + /* trails */ + for (const b of this._b) { + if (b.pocketed) continue; + for (let i=1;i 4) { + const nx=dx/dist, ny=dy/dist; + /* cue stick */ + ctx.strokeStyle='rgba(210,160,80,.9)'; ctx.lineWidth=4; ctx.lineCap='round'; + ctx.beginPath(); + ctx.moveTo(cue.x + nx*(cue.r+8), cue.y + ny*(cue.r+8)); + ctx.lineTo(cue.x + nx*(cue.r+8+Math.min(dist*1.2,120)), cue.y + ny*(cue.r+8+Math.min(dist*1.2,120))); + ctx.stroke(); ctx.lineCap='butt'; + /* aim trajectory dash */ + ctx.strokeStyle='rgba(255,255,255,.3)'; ctx.lineWidth=1; ctx.setLineDash([6,6]); + ctx.beginPath(); + ctx.moveTo(cue.x - nx*(cue.r+4), cue.y - ny*(cue.r+4)); + ctx.lineTo(cue.x - nx*180, cue.y - ny*180); + ctx.stroke(); ctx.setLineDash([]); + /* power indicator */ + const power = Math.min(1, dist * 0.35 / (this.cueForce * 1.2) * (15/this.cueForce)); + const pw = 80, ph = 8, px2 = 8, py2 = H - 28; + ctx.fillStyle='rgba(8,8,18,.75)'; + ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.fill(); + ctx.fillStyle=`hsl(${120-power*120},85%,55%)`; + ctx.beginPath(); ctx.roundRect(px2,py2,pw*power,ph,4); ctx.fill(); + ctx.strokeStyle='rgba(255,255,255,.2)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.stroke(); + ctx.fillStyle='rgba(255,255,255,.6)'; ctx.font='9px Manrope'; + ctx.textAlign='left'; ctx.textBaseline='top'; + ctx.fillText('Сила', px2, py2-12); + } + } + } + + /* HUD */ + const active = this._b.filter(b=>!b.pocketed&&!b.isCue).length; + const hudTxt = `Шаров в лузе: ${this._pocketed.length} | Осталось: ${active}`; + ctx.fillStyle='rgba(8,8,18,.75)'; + const htw=ctx.measureText(hudTxt).width+20; + ctx.beginPath(); ctx.roundRect(W-htw-8,8,htw,24,6); ctx.fill(); + ctx.fillStyle='rgba(255,255,255,.7)'; ctx.font='11px Manrope'; + ctx.textAlign='right'; ctx.textBaseline='middle'; + ctx.fillText(hudTxt, W-18, 20); + + /* "drag cue" hint */ + const cue=this._cueBall(); + if (cue && !this.playing && !this._drag) { + ctx.fillStyle='rgba(255,255,255,.4)'; ctx.font='11px Manrope'; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText('Тяните от биткa для удара', W/2, t.y+t.h+6); + } + + if (window.LabFX) LabFX.particles.draw(this.ctx); + } +} + /* ═══ helpers ═══ */ function _colArrow(ctx, x1, y1, x2, y2, color, lw) { @@ -1027,6 +2016,22 @@ function _roundRect(ctx, x, y, w, h, r) { } /* ─── lab UI init ─────────────────────────────────── */ + + /* active collision simulation instances (cSim declared in lab-init.js) */ + var cSim2D = null; /* 2D mode (Collision2DSim) */ + var cSimMB = null; /* multi-body mode (MultiBodySim) */ + var cSimBL = null; /* billiard mode (BilliardSim) */ + var _collMode = '1d'; /* '1d' | '2d' | 'multi' | 'billiard' */ + + /* Return whichever sim is currently active */ + function _activeSim() { + if (_collMode === '1d') return cSim; + if (_collMode === '2d') return cSim2D; + if (_collMode === 'multi') return cSimMB; + if (_collMode === 'billiard') return cSimBL; + return null; + } + function _openCollision() { document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров'; _simShow('sim-coll'); @@ -1035,32 +2040,217 @@ function _roundRect(ctx, x, y, w, h, r) { if (_embedMode) _startStateEmit('collision'); requestAnimationFrame(() => requestAnimationFrame(() => { + const canvas = document.getElementById('coll-canvas'); + if (!cSim) { - cSim = new CollisionSim(document.getElementById('coll-canvas')); + cSim = new CollisionSim(canvas); cSim.onUpdate = _collUpdateUI; cSim.onPlayPause = collPlayPause; } - cSim.fit(); - cSim.setSpeed(+document.getElementById('sl-speed').value); - collParam(); - cSim.draw(); - _collUpdateUI(cSim.stats()); + if (!cSim2D) { + cSim2D = new Collision2DSim(canvas); + cSim2D.onUpdate = _collUpdateUI; + cSim2D.onPlayPause = collPlayPause; + } + if (!cSimMB) { + cSimMB = new MultiBodySim(canvas); + cSimMB.onUpdate = _collUpdateUI; + cSimMB.onPlayPause = collPlayPause; + } + if (!cSimBL) { + cSimBL = new BilliardSim(canvas); + cSimBL.onUpdate = _collUpdateUI; + cSimBL.onPlayPause = collPlayPause; + } + + /* inject mode-selector + mode panels if not yet present */ + if (!document.getElementById('coll-mode-bar')) { + _collInjectModeUI(); + } + + _collMode = '1d'; + _collApplyMode(); })); } + /* Build the mode selector row + mode-specific panels */ + function _collInjectModeUI() { + const simBody = document.getElementById('sim-coll'); + const bodyWrap = simBody.querySelector('.sim-body-wrap'); + const projPanel = bodyWrap ? bodyWrap.querySelector('.proj-panel') : null; + if (!projPanel) return; + + /* ── mode selector bar (injected above params) ── */ + const modeBar = document.createElement('div'); + modeBar.id = 'coll-mode-bar'; + modeBar.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px'; + modeBar.innerHTML = + '' + + '' + + '' + + ''; + projPanel.insertBefore(modeBar, projPanel.firstChild); + + /* ── CM-frame toggle (universal) ── */ + const cmRow = document.createElement('div'); + cmRow.id = 'coll-cm-row'; + cmRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin:6px 0;'; + cmRow.innerHTML = + ''; + projPanel.insertBefore(cmRow, projPanel.firstChild.nextSibling); + + /* ── 2D-specific panel ── */ + const p2d = document.createElement('div'); + p2d.id = 'coll-panel-2d'; + p2d.style.display = 'none'; + p2d.innerHTML = + '
2D — углы движения
' + + '
Угол v₁
' + + '
' + + '
Угол v₂180°
' + + '
' + + '
Масса m₁4 кг
' + + '
' + + '
Масса m₂4 кг
' + + '
' + + '
|v₁|8 м/с
' + + '
' + + '
|v₂|8 м/с
' + + '
' + + '
Упругость e1.00
' + + '
'; + projPanel.appendChild(p2d); + + /* ── Multi-body panel ── */ + const pmb = document.createElement('div'); + pmb.id = 'coll-panel-multi'; + pmb.style.display = 'none'; + pmb.innerHTML = + '
Multi-ball
' + + '
Шаров N5
' + + '
' + + '
Упругость e1.00
' + + '
' + + '
' + + '' + + '
'; + projPanel.appendChild(pmb); + + /* ── Billiard panel ── */ + const pbl = document.createElement('div'); + pbl.id = 'coll-panel-billiard'; + pbl.style.display = 'none'; + pbl.innerHTML = + '
Бильярд
' + + '
Сила удара15
' + + '
' + + '
' + + '' + + '
'; + projPanel.appendChild(pbl); + + /* first fit + draw */ + cSim.fit(); cSim.setSpeed(+document.getElementById('sl-speed').value); + collParam(); cSim.draw(); _collUpdateUI(cSim.stats()); + } + + /* Switch active mode */ + function collSetMode(mode, btn) { + /* pause all */ + if (cSim) cSim.pause(); + if (cSim2D) cSim2D.pause(); + if (cSimMB) cSimMB.pause(); + if (cSimBL) cSimBL.pause(); + + _collMode = mode; + + /* update tab buttons */ + document.querySelectorAll('.coll-mode-btn').forEach(b => b.classList.remove('active')); + if (btn) btn.classList.add('active'); + + /* show/hide mode-specific panels */ + const show = id => { const el=document.getElementById(id); if(el) el.style.display=''; }; + const hide = id => { const el=document.getElementById(id); if(el) el.style.display='none'; }; + const p1d = document.getElementById('coll-params-1d'); /* original param blocks */ + const presets1d = document.getElementById('coll-presets-1d'); + + /* toggle visibility of original 1D controls */ + const orig1d = ['sl-m1','sl-m2','sl-cv1','sl-cv2','sl-cangle','sl-e'].map(id => { + const el = document.getElementById(id); + return el ? el.closest('.param-block') : null; + }).filter(Boolean); + orig1d.forEach(el => el.style.display = (mode === '1d') ? '' : 'none'); + + /* hide presets section if not 1d (find by traversal) */ + const presetsDiv = document.querySelector('#sim-coll .gp-section-title + div[style*="flex-wrap"]'); + if (presetsDiv) presetsDiv.style.display = (mode === '1d') ? '' : 'none'; + const presetsTitle = presetsDiv ? presetsDiv.previousElementSibling : null; + if (presetsTitle) presetsTitle.style.display = (mode === '1d') ? '' : 'none'; + + /* mode-specific panels */ + hide('coll-panel-2d'); hide('coll-panel-multi'); hide('coll-panel-billiard'); + if (mode === '2d') show('coll-panel-2d'); + if (mode === 'multi') show('coll-panel-multi'); + if (mode === 'billiard') show('coll-panel-billiard'); + + /* show/hide launch+reset buttons (not needed for billiard) */ + const launchWrap = document.querySelector('#sim-coll .proj-launch-btn')?.parentElement; + if (launchWrap) launchWrap.style.display = (mode === 'billiard') ? 'none' : ''; + + /* CM toggle only for 1d/2d/multi */ + const cmRow = document.getElementById('coll-cm-row'); + if (cmRow) cmRow.style.display = (mode === 'billiard') ? 'none' : ''; + + _collApplyMode(); + } + + function _collApplyMode() { + const canvas = document.getElementById('coll-canvas'); + if (!canvas) return; + const mode = _collMode; + + /* Mark which sim is active so ResizeObserver callbacks skip inactive sims */ + if (cSim) cSim._activeMode = (mode === '1d'); + if (cSim2D) cSim2D._activeMode = (mode === '2d'); + if (cSimMB) cSimMB._activeMode = (mode === 'multi'); + if (cSimBL) cSimBL._activeMode = (mode === 'billiard'); + + if (mode === '1d') { + cSim.fit(); collParam(); cSim.draw(); _collUpdateUI(cSim.stats()); + } else if (mode === '2d') { + cSim2D.fit(); coll2DParam(); cSim2D.draw(); _collUpdateUI(cSim2D.stats()); + } else if (mode === 'multi') { + cSimMB.fit(); cSimMB._initBalls(); collMBParam(); cSimMB.draw(); _collUpdateUI(cSimMB.stats()); + } else if (mode === 'billiard') { + cSimBL.fit(); cSimBL._initBalls(); cSimBL.draw(); _collUpdateUI(cSimBL.stats()); + } + _collSyncBtn(); + } + + /* CM-frame toggle (universal) */ + function collCMFrame(on) { + if (cSim2D) cSim2D.cmFrame = on; + if (cSimMB) cSimMB.cmFrame = on; + const act = _activeSim(); + if (act && !act.playing) act.draw(); + } + function collPlayPause() { - if (!cSim) return; - if (cSim.playing) { cSim.pause(); } else { cSim.play(); } + const sim = _activeSim(); + if (!sim) return; + if (sim.playing) { sim.pause(); } else { sim.play(); } _collSyncBtn(); } function _collSyncBtn() { + const sim = _activeSim(); + const playing = sim ? sim.playing : false; const tb = document.getElementById('coll-play-btn'); const lb = document.getElementById('coll-launch-main'); const lbl = document.getElementById('coll-launch-label'); const lic = document.getElementById('coll-launch-icon'); - if (!cSim) return; - const playing = cSim.playing; if (tb) { tb.innerHTML = playing @@ -1069,7 +2259,6 @@ function _roundRect(ctx, x, y, w, h, r) { tb.title = playing ? 'Пауза' : 'Запустить'; tb.classList.toggle('active', playing); } - if (lb && lbl && lic) { lb.classList.toggle('paused', playing); lb.classList.remove('done'); @@ -1083,6 +2272,7 @@ function _roundRect(ctx, x, y, w, h, r) { } } + /* ── 1D param handler (existing) ── */ function collParam() { const m1 = +document.getElementById('sl-m1').value; const m2 = +document.getElementById('sl-m2').value; @@ -1092,19 +2282,18 @@ function _roundRect(ctx, x, y, w, h, r) { const e = +document.getElementById('sl-e').value; const spd = +document.getElementById('sl-speed').value; - document.getElementById('c-m1').textContent = m1 + ' кг'; - document.getElementById('c-m2').textContent = m2 + ' кг'; - document.getElementById('c-v1').textContent = v1 + ' м/с'; - document.getElementById('c-v2').textContent = v2 + ' м/с'; - document.getElementById('c-angle').textContent = angle + '°'; - document.getElementById('c-e').textContent = e.toFixed(2); - document.getElementById('c-speed').textContent = spd.toFixed(2) + '×'; + const el = id => document.getElementById(id); + if (el('c-m1')) el('c-m1').textContent = m1 + ' кг'; + if (el('c-m2')) el('c-m2').textContent = m2 + ' кг'; + if (el('c-v1')) el('c-v1').textContent = v1 + ' м/с'; + if (el('c-v2')) el('c-v2').textContent = v2 + ' м/с'; + if (el('c-angle')) el('c-angle').textContent = angle + '°'; + if (el('c-e')) el('c-e').textContent = e.toFixed(2); + if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×'; if (cSim) { - /* speed change doesn't require a reset */ const speedChanged = Math.abs(cSim.speed - spd) > 0.001; if (speedChanged) cSim.setSpeed(spd); - const physChanged = cSim.m1 !== m1 || cSim.m2 !== m2 || cSim.v1 !== v1 || cSim.v2 !== v2 || cSim.angle !== angle || cSim.e !== e; @@ -1113,6 +2302,65 @@ function _roundRect(ctx, x, y, w, h, r) { } } + /* ── 2D param handler ── */ + function coll2DParam() { + if (!cSim2D) return; + const el = id => document.getElementById(id); + const a1 = +(el('sl-2da1')?.value ?? 0); + const a2 = +(el('sl-2da2')?.value ?? 180); + const m1 = +(el('sl-2dm1')?.value ?? 4); + const m2 = +(el('sl-2dm2')?.value ?? 4); + const v1 = +(el('sl-2dv1')?.value ?? 8); + const v2 = +(el('sl-2dv2')?.value ?? 8); + const e = +(el('sl-2de')?.value ?? 1); + const spd = +(el('sl-speed')?.value ?? 1); + + if (el('c2d-a1')) el('c2d-a1').textContent = a1 + '°'; + if (el('c2d-a2')) el('c2d-a2').textContent = a2 + '°'; + if (el('c2d-m1')) el('c2d-m1').textContent = m1 + ' кг'; + if (el('c2d-m2')) el('c2d-m2').textContent = m2 + ' кг'; + if (el('c2d-v1')) el('c2d-v1').textContent = v1 + ' м/с'; + if (el('c2d-v2')) el('c2d-v2').textContent = v2 + ' м/с'; + if (el('c2d-e')) el('c2d-e').textContent = e.toFixed(2); + if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×'; + + cSim2D.setSpeed(spd); + const changed = cSim2D.a1!==a1||cSim2D.a2!==a2||cSim2D.m1!==m1||cSim2D.m2!==m2|| + cSim2D.v1!==v1||cSim2D.v2!==v2||cSim2D.e!==e; + if (changed) cSim2D.setParams({ a1, a2, m1, m2, v1, v2, e }); + _collSyncBtn(); + } + + /* ── Multi-body param handler ── */ + function collMBParam() { + if (!cSimMB) return; + const el = id => document.getElementById(id); + const N = +(el('sl-mb-n')?.value ?? 5); + const e = +(el('sl-mb-e')?.value ?? 1); + const spd = +(el('sl-speed')?.value ?? 1); + + if (el('cmb-n')) el('cmb-n').textContent = N; + if (el('cmb-e')) el('cmb-e').textContent = e.toFixed(2); + if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×'; + + cSimMB.setSpeed(spd); + const changed = cSimMB.N !== N || cSimMB.e !== e; + if (changed) { cSimMB.N = N; cSimMB.e = e; cSimMB.reset(); } + _collSyncBtn(); + } + + /* ── Billiard param handler ── */ + function collBLParam() { + if (!cSimBL) return; + const el = id => document.getElementById(id); + const force = +(el('sl-bl-force')?.value ?? 15); + const spd = +(el('sl-speed')?.value ?? 1); + if (el('cbl-force')) el('cbl-force').textContent = force; + if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×'; + cSimBL.cueForce = force; + cSimBL.setSpeed(spd); + } + function collPreset(m1, m2, v1, v2, angle, e) { document.getElementById('sl-m1').value = m1; document.getElementById('sl-m2').value = m2; @@ -1124,7 +2372,28 @@ function _roundRect(ctx, x, y, w, h, r) { } function _collUpdateUI(s) { - // before/after are arrays [{m, vx, vy, ke}, ...] + if (_collMode === 'billiard') { + /* billiard: show pocketed count */ + const el = id => document.getElementById(id); + if (el('cs-pbefore')) el('cs-pbefore').textContent = '—'; + if (el('cs-pafter')) el('cs-pafter').textContent = '—'; + if (el('cs-kebefore')) el('cs-kebefore').textContent = '—'; + if (el('cs-keafter')) el('cs-keafter').textContent = '—'; + if (el('cs-count')) el('cs-count').textContent = 'Луз: ' + (s.pocketed || 0); + _collSyncBtn(); + return; + } + if (_collMode === 'multi') { + const el = id => document.getElementById(id); + if (el('cs-pbefore')) el('cs-pbefore').textContent = s.px ? s.px.toFixed(2)+' кг·м/с' : '—'; + if (el('cs-pafter')) el('cs-pafter').textContent = s.py ? s.py.toFixed(2)+' кг·м/с' : '—'; + if (el('cs-kebefore')) el('cs-kebefore').textContent = s.ke ? s.ke.toFixed(1)+' Дж' : '—'; + if (el('cs-keafter')) el('cs-keafter').textContent = '—'; + if (el('cs-count')) el('cs-count').textContent = s.colCount || 0; + _collSyncBtn(); + return; + } + /* 1D / 2D: same before/after display */ function snapKE(arr) { return arr ? arr.reduce((t, b) => t + b.ke, 0) : null; } function snapP(arr) { if (!arr) return null; @@ -1134,12 +2403,12 @@ function _roundRect(ctx, x, y, w, h, r) { const bKE = snapKE(s.before), bP = snapP(s.before); const aKE = snapKE(s.after), aP = snapP(s.after); const f2 = v => v !== null ? v.toFixed(2) : '—'; - - document.getElementById('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—'; - document.getElementById('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—'; - document.getElementById('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—'; - document.getElementById('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—'; - document.getElementById('cs-count').textContent = s.colCount; + const el = id => document.getElementById(id); + if (el('cs-pbefore')) el('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—'; + if (el('cs-pafter')) el('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—'; + if (el('cs-kebefore')) el('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—'; + if (el('cs-keafter')) el('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—'; + if (el('cs-count')) el('cs-count').textContent = s.colCount || 0; _collSyncBtn(); } diff --git a/frontend/js/labs/newton.js b/frontend/js/labs/newton.js index d96929f..32d578f 100644 --- a/frontend/js/labs/newton.js +++ b/frontend/js/labs/newton.js @@ -25,6 +25,16 @@ class NewtonSim { this.mass2 = 12; // кг — сравниваемый блок / пушка this.force = 30; // Н — приложенная сила (закон II) + /* Классические задачи (law 4) */ + this.atwM1 = 5; // кг + this.atwM2 = 8; // кг + this.atwMassive = false; // идеальный блок по умолчанию + this.rampAlpha = 30; // градусы + this.rampMu = 0.20; + this.rampForce = 0; // Н внешней силы вдоль горки + this.rollAlpha = 20; // градусы + this.rollFriction = false; + /* Состояние сцен */ this._1A = {}; this._1B = {}; @@ -32,6 +42,10 @@ class NewtonSim { this._3A = {}; this._3B = {}; this._3C = {}; + /* Классические задачи */ + this._atw = {}; + this._ramp = {}; + this._roll = {}; /* Петля */ this._raf = null; @@ -72,6 +86,7 @@ class NewtonSim { this._reset1A(); this._reset1B(); this._reset2(); this._reset3A(); this._reset3B(); this._reset3C(); + this._resetAtwood(); this._resetRamp(); this._resetRoll(); } /* ── Сброс каждой сцены ──────────────────────────────────── */ @@ -149,13 +164,33 @@ class NewtonSim { /* ── Публичный API ───────────────────────────────────────── */ - setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); if (this.onModeChange) this.onModeChange(); } + setLaw(n) { + this.law = n; + this.scene = (n === 4) ? 'atwood' : 'A'; + this._resetAll(); + if (window.LabFX) LabFX.sound.play('click'); + if (this.onModeChange) this.onModeChange(); + } setScene(s) { this.scene = s; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); } setMu(v) { this.mu = v; } - setMass1(v) { this.mass1 = v; this._reset3B(); } + setMass1(v) { this.mass1 = v; this._reset3B(); if (this.law === 4) { this._resetRamp(); } } setMass2(v) { this.mass2 = v; this._reset3B(); } setForce(v) { this.force = v; } + /* ── Сеттеры для классических задач ─────────────────────────── */ + setAtwM1(v) { this.atwM1 = v; this._resetAtwood(); } + setAtwM2(v) { this.atwM2 = v; this._resetAtwood(); } + setAtwMassive(v) { this.atwMassive = v; this._resetAtwood(); } + setRampAlpha(v) { this.rampAlpha = v; this._resetRamp(); } + setRampMu(v) { this.rampMu = v; this._resetRamp(); } + setRampForce(v) { this.rampForce = v; this._resetRamp(); } + setRollAlpha(v) { this.rollAlpha = v; this._resetRoll(); } + setRollFriction(v) { this.rollFriction = v; this._resetRoll(); } + + startAtwood() { if (this._atw) { this._atw.running = !this._atw.running; if (window.LabFX) LabFX.sound.play('tick'); } } + startRamp() { if (this._ramp) { this._ramp.running = !this._ramp.running; if (window.LabFX) LabFX.sound.play('tick'); } } + startRoll() { if (this._roll) { this._roll.running = !this._roll.running; if (window.LabFX) LabFX.sound.play('tick'); } } + cutString() { this._1B.cut = true; this._1B.bvx = -Math.sin(this._1B.angle) * this._g.orbitR * this._1B.omega; @@ -220,6 +255,9 @@ class NewtonSim { if (this.law === 1 && this.scene === 'A') this._step1A(dt); else if (this.law === 1) this._step1B(dt); else if (this.law === 2) this._step2(dt); + else if (this.law === 4 && this.scene === 'atwood') this._stepAtwood(dt); + else if (this.law === 4 && this.scene === 'ramp') this._stepRamp(dt); + else if (this.law === 4 && this.scene === 'roll') this._stepRoll(dt); else if (this.scene === 'A') this._step3A(dt); else if (this.scene === 'B') this._step3B(dt); else this._step3C(dt); @@ -500,13 +538,17 @@ class NewtonSim { ctx.font = 'bold 78px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.023)'; ctx.textAlign = 'center'; - ctx.fillText(['', 'ЗАКОН I', 'ЗАКОН II', 'ЗАКОН III'][this.law], W / 2, H * 0.60); + const wmarks = ['', 'ЗАКОН I', 'ЗАКОН II', 'ЗАКОН III', 'КЛАССИКА']; + ctx.fillText(wmarks[this.law] || '', W / 2, H * 0.60); ctx.textAlign = 'left'; ctx.restore(); if (this.law === 1 && this.scene === 'A') this._drawL1A(ctx); else if (this.law === 1) this._drawL1B(ctx); else if (this.law === 2) this._drawL2(ctx); + else if (this.law === 4 && this.scene === 'atwood') this._drawAtwood(ctx); + else if (this.law === 4 && this.scene === 'ramp') this._drawRamp(ctx); + else if (this.law === 4 && this.scene === 'roll') this._drawRoll(ctx); else if (this.scene === 'A') this._drawL3A(ctx); else if (this.scene === 'B') this._drawL3B(ctx); else this._drawL3C(ctx); @@ -1165,6 +1207,8 @@ class NewtonSim { /* ── Info ──────────────────────────────────────────────────── */ info() { + if (this.law === 4) return this._infoClassic(); + const S = NewtonSim.SCALE; const base = { law: this.law, scene: this.scene }; @@ -1208,6 +1252,678 @@ class NewtonSim { m: s.rmass.toFixed(1), }; } + + /* ═══════════════════════════════════════════════════════════════ + КЛАССИЧЕСКИЕ ЗАДАЧИ (law === 4) + Сцены: 'atwood' | 'ramp' | 'roll' + ═══════════════════════════════════════════════════════════════ */ + + /* ── Сброс сцен ─────────────────────────────────────────────── */ + + _resetAtwood() { + const { W, H } = this; + /* Параметры */ + const m1 = this.atwM1; // кг + const m2 = this.atwM2; // кг + const G = NewtonSim.G; + const massive = this.atwMassive; // bool: массивный блок? + /* Формула: a = (m2-m1)*g / (m1+m2) для идеального блока + с массивным блоком (I = 0.5*M*R², M=2кг, R=0.04м): + a = (m2-m1)*g / (m1+m2 + I/R²) = (m2-m1)*g / (m1+m2+1) */ + const denom = m1 + m2 + (massive ? 1 : 0); + const aPhys = (m2 - m1) * G / denom; // м/с² (may be negative) + const T = 2 * m1 * m2 * G / denom; // Н + + this._atw = { + /* визуальные позиции грузов (px) — блок по центру сверху */ + pulleyX: W * 0.50, + pulleyY: H * 0.14, + ropeLen: H * 0.60, // общая длина нити (px) + y1: H * 0.30, // верх груза 1 (px) + y2: H * 0.30, // верх груза 2 (px) + vy: 0, // скорость грузов (px/s, + вниз для груза 2) + running: false, + aPhys, T, + finished: false, + t: 0, + /* история скоростей */ + history: [], + }; + } + + _resetRamp() { + const { W, H } = this; + this._ramp = { + /* угол горки в радианах */ + alpha: this.rampAlpha * Math.PI / 180, + /* состояние */ + bx: 0, // позиция по склону (м) + bv: 0, // скорость по склону (м/с) + running: false, + t: 0, + history: [], // v(t) — для графика + finished: false, + }; + } + + _resetRoll() { + const { W, H } = this; + this._roll = { + alpha: this.rollAlpha * Math.PI / 180, + L: 3.0, // длина горки, м + /* позиции трёх тел (по склону, м) */ + sBall: 0, sCyl: 0, sHoop: 0, + /* скорости */ + vBall: 0, vCyl: 0, vHoop: 0, + /* угловые скорости для вращения */ + wBall: 0, wCyl: 0, wHoop: 0, + running: false, + t: 0, + winner: null, // 'ball'|'cyl'|'hoop' + finishTimes: {}, + withFriction: this.rollFriction, + }; + } + + /* ── Шаг физики — Машина Атвуда ─────────────────────────────── */ + + _stepAtwood(dt) { + const s = this._atw; + if (!s.running || s.finished) return; + + const S = NewtonSim.SCALE; // px/м + const { H } = this; + + /* Интеграция */ + s.vy += s.aPhys * S * dt; // px/s² → px/s + s.y1 -= s.vy * dt; // груз 1 движется вверх если vy>0 + s.y2 += s.vy * dt; // груз 2 вниз + + s.t += dt; + + /* Ограничение хода */ + const minY = s.pulleyY + 18; + const maxY = H * 0.88; + if (s.y1 < minY || s.y2 > maxY || s.y1 > maxY || s.y2 < minY) { + s.running = false; + s.finished = true; + if (window.LabFX) LabFX.sound.play('chime'); + } + + /* История для HUD */ + if (s.t % 0.05 < dt + 0.001) { + const vMs = Math.abs(s.vy) / S; + s.history.push(vMs); + if (s.history.length > 120) s.history.shift(); + } + } + + /* ── Шаг физики — Наклонная плоскость ──────────────────────── */ + + _stepRamp(dt) { + const s = this._ramp; + if (!s.running || s.finished) return; + + const G = NewtonSim.G; + const alpha = this.rampAlpha * Math.PI / 180; + const mu = this.rampMu; + const Fapp = this.rampForce; + + const sinA = Math.sin(alpha); + const cosA = Math.cos(alpha); + const Fdrive = G * sinA + Fapp / (this.mass1); // Н/кг = м/с² + const Ffrmax = mu * G * cosA; + + let accel = 0; + if (Math.abs(Fdrive) <= Ffrmax) { + /* Покоится */ + accel = 0; + s.bv = 0; + } else { + accel = Fdrive - Math.sign(Fdrive) * Ffrmax; + } + + s.bv += accel * dt; + /* Не скользить назад если нет внешней силы и v~0 */ + if (s.bv < 0 && Fapp <= 0) s.bv = 0; + s.bx += s.bv * dt; + s.t += dt; + + const Lramp = 3.5; // м + if (s.bx >= Lramp) { + s.bx = Lramp; s.bv = 0; s.running = false; s.finished = true; + if (window.LabFX) LabFX.sound.play('chime'); + } + + /* История v(t) */ + if (s.t % 0.05 < dt + 0.001) { + s.history.push(s.bv); + if (s.history.length > 120) s.history.shift(); + } + } + + /* ── Шаг физики — Скатывание тел ──────────────────────────── */ + + _stepRoll(dt) { + const s = this._roll; + if (!s.running) return; + + const G = NewtonSim.G; + const alpha = this.rollAlpha * Math.PI / 180; + const sinA = Math.sin(alpha); + const mu = 0.30; // коэффициент сцепления для проскальзывания + + /* Моменты инерции: k = I/(mR²) + Шар: k=2/5, Цилиндр: k=1/2, Обруч: k=1 */ + const bodies = [ + { key: 'Ball', k: 2/5, color: '#EF476F', label: 'Шар' }, + { key: 'Cyl', k: 1/2, color: '#4CC9F0', label: 'Цилиндр' }, + { key: 'Hoop', k: 1, color: '#FFD166', label: 'Обруч' }, + ]; + + s.t += dt; + + for (const b of bodies) { + if (s.finishTimes[b.key] !== undefined) continue; // уже финишировал + + let accel; + if (!s.withFriction) { + /* Чистое качение: a = g*sinα / (1 + k) */ + accel = G * sinA / (1 + b.k); + } else { + /* С возможным проскальзыванием: ограничиваем трением */ + const aRoll = G * sinA / (1 + b.k); + const aSlide = G * (sinA - mu * Math.cos(alpha)); + accel = Math.max(0, Math.max(aRoll, aSlide)); + } + + s['v' + b.key] += accel * dt; + s['s' + b.key] += s['v' + b.key] * dt; + /* угловая скорость для рисовки */ + s['w' + b.key] += (accel / 0.08) * dt; // R=0.08м + + if (s['s' + b.key] >= s.L) { + s['s' + b.key] = s.L; + s.finishTimes[b.key] = s.t; + if (!s.winner) { + s.winner = b.key; + if (window.LabFX) LabFX.sound.play('chime'); + } + } + } + + /* Все финишировали */ + if (Object.keys(s.finishTimes).length === 3) { + s.running = false; + } + } + + /* ── Отрисовка — Машина Атвуда ─────────────────────────────── */ + + _drawAtwood(ctx) { + const { W, H } = this; + const s = this._atw; + const S = NewtonSim.SCALE; + const m1 = this.atwM1; + const m2 = this.atwM2; + + /* Потолок */ + ctx.fillStyle = '#2a3040'; + ctx.fillRect(s.pulleyX - 50, 0, 100, s.pulleyY - 10); + ctx.fillStyle = '#3a4560'; + ctx.fillRect(0, 0, W, 10); + + /* Блок (шкив) */ + ctx.save(); + ctx.strokeStyle = '#8899cc'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(s.pulleyX, s.pulleyY, 14, 0, Math.PI * 2); + ctx.fillStyle = '#1e2840'; + ctx.fill(); ctx.stroke(); + ctx.restore(); + if (this.atwMassive) { + ctx.save(); + ctx.font = 'bold 9px Manrope,sans-serif'; + ctx.fillStyle = '#aabbff'; + ctx.textAlign = 'center'; + ctx.fillText('M', s.pulleyX, s.pulleyY + 4); + ctx.restore(); + } + + /* Нити */ + ctx.strokeStyle = '#c0c8e0'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(s.pulleyX - 12, s.pulleyY); + ctx.lineTo(s.pulleyX - 12, s.y1); + ctx.moveTo(s.pulleyX + 12, s.pulleyY); + ctx.lineTo(s.pulleyX + 12, s.y2); + ctx.stroke(); + + /* Вспомогательные размеры грузов */ + const bw1 = 18 + m1 * 2.2; + const bh1 = 18 + m1 * 2.2; + const bw2 = 18 + m2 * 2.2; + const bh2 = 18 + m2 * 2.2; + + /* Груз 1 */ + _nwt_rrect(ctx, s.pulleyX - 12 - bw1 / 2, s.y1, bw1, bh1, 4); + ctx.fillStyle = '#EF476F'; + ctx.fill(); + ctx.strokeStyle = _nwt_lighten('#EF476F', 60); + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.font = `bold ${Math.min(12, bw1 * 0.5)}px Manrope,sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(m1 + ' кг', s.pulleyX - 12, s.y1 + bh1 * 0.62); + + /* Груз 2 */ + _nwt_rrect(ctx, s.pulleyX + 12 - bw2 / 2, s.y2, bw2, bh2, 4); + ctx.fillStyle = '#4CC9F0'; + ctx.fill(); + ctx.strokeStyle = _nwt_lighten('#4CC9F0', 60); + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.font = `bold ${Math.min(12, bw2 * 0.5)}px Manrope,sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(m2 + ' кг', s.pulleyX + 12, s.y2 + bh2 * 0.62); + + ctx.textAlign = 'left'; + + /* HUD: расчёты */ + const vMs = (Math.abs(s.vy) / S).toFixed(2); + const aDisp = s.aPhys.toFixed(3); + const TDisp = s.T.toFixed(2); + const eq = m1 === m2; + + this._atwHUD(ctx, W, H, vMs, aDisp, TDisp, eq, s); + + /* График скорости */ + if (s.history.length > 2) { + this._graph(ctx, s.history, W - 155, H - 90, 140, 65, '#7BF5A4', 'v (м/с)'); + } + } + + _atwHUD(ctx, W, H, vMs, aDisp, TDisp, eq, s) { + const lines = eq + ? ['Равновесие', `a = 0 м/с²`, `T = ${TDisp} Н`] + : [`a = ${aDisp} м/с²`, `T = ${TDisp} Н`, `v = ${vMs} м/с`]; + ctx.save(); + ctx.font = '13px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + _nwt_rrect(ctx, 14, H - 80, 160, 64, 8); + ctx.fill(); + ctx.fillStyle = '#c8d8ff'; + lines.forEach((l, i) => ctx.fillText(l, 22, H - 58 + i * 18)); + ctx.restore(); + } + + /* ── Отрисовка — Наклонная плоскость ───────────────────────── */ + + _drawRamp(ctx) { + const { W, H } = this; + const s = this._ramp; + const S = NewtonSim.SCALE; + const alpha = this.rampAlpha * Math.PI / 180; + const mu = this.rampMu; + const m = this.mass1; + const Fapp = this.rampForce; + const G = NewtonSim.G; + + /* Геометрия горки */ + const baseX = W * 0.12; + const baseY = H * 0.84; + const Lpx = W * 0.78; + const Hpx = Lpx * Math.tan(alpha); + const topX = baseX; + const topY = baseY - Hpx; + + /* Наклонная плоскость */ + ctx.beginPath(); + ctx.moveTo(baseX, baseY); + ctx.lineTo(baseX + Lpx, baseY); + ctx.lineTo(topX, topY); + ctx.closePath(); + ctx.fillStyle = '#1e2840'; + ctx.fill(); + ctx.strokeStyle = '#3a4d7a'; + ctx.lineWidth = 2; + ctx.stroke(); + + /* Отметки угла */ + ctx.save(); + ctx.strokeStyle = '#7BF5A4'; + ctx.lineWidth = 1.5; + const arcR = 36; + ctx.beginPath(); + ctx.arc(baseX + Lpx, baseY, arcR, Math.PI, Math.PI + alpha, false); + ctx.stroke(); + ctx.fillStyle = '#7BF5A4'; + ctx.font = '11px Manrope,sans-serif'; + ctx.fillText(this.rampAlpha + '°', baseX + Lpx - arcR * 1.55, baseY - 8); + ctx.restore(); + + /* Поверхность горки */ + ctx.save(); + ctx.strokeStyle = 'rgba(200,220,255,0.18)'; + ctx.lineWidth = 1; + const Nlines = 7; + for (let i = 1; i < Nlines; i++) { + const frac = i / Nlines; + const sx = topX + (baseX + Lpx - topX) * frac; + const sy = topY + (baseY - topY) * frac; + const nx = Math.sin(alpha) * 8; + const ny = -Math.cos(alpha) * 8; + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx + nx, sy + ny); + ctx.stroke(); + } + ctx.restore(); + + /* Тело на горке */ + const bSize = 28 + m * 0.8; + const Spos = s.bx * S; // px от начала горки + /* Вектор вдоль горки */ + const ux = Math.cos(Math.PI - alpha); + const uy = -Math.sin(Math.PI - alpha); + const bCx = baseX + Lpx - Spos * Math.cos(alpha) - (bSize / 2) * Math.sin(alpha); + const bCy = baseY - Spos * Math.sin(alpha) + (bSize / 2) * Math.cos(alpha) - bSize / 2; + + ctx.save(); + ctx.translate(bCx + bSize / 2, bCy + bSize / 2); + ctx.rotate(-alpha); + _nwt_rrect(ctx, -bSize / 2, -bSize / 2, bSize, bSize, 5); + ctx.fillStyle = '#EF476F'; + ctx.fill(); + ctx.strokeStyle = _nwt_lighten('#EF476F', 50); + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 10px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(m + ' кг', 0, 4); + + /* Векторы сил на теле */ + const sinA = Math.sin(alpha), cosA = Math.cos(alpha); + const Fgrav = m * G; + const Fnorm = Fgrav * cosA; + const Fdrive = Fgrav * sinA; + const Ffrmax = mu * Fnorm; + const sliding = Fdrive > Ffrmax; + const Ffr = sliding ? Ffrmax : Fdrive; + const arrowScale = 2.5; + + /* Сила тяжести (вниз) */ + this._arrow(ctx, 0, 0, 0, Fgrav * arrowScale, '#FFD166', 'mg'); + /* Нормальная реакция (перп. горке = вверх в ЛСО) */ + this._arrow(ctx, 0, 0, 0, -Fnorm * arrowScale, '#4CC9F0', 'N'); + /* Компонент по горке (вдоль горки = влево в ЛСО) */ + this._arrow(ctx, 0, 0, -Fdrive * arrowScale, 0, '#EF476F', 'mg·sinα'); + /* Трение (вправо вдоль горки если скользит) */ + if (Ffr > 0.5) { + const frColor = sliding ? '#7BF5A4' : '#8899cc'; + this._arrow(ctx, 0, 0, Ffr * arrowScale, 0, frColor, sliding ? 'F_тр' : 'F_ст'); + } + if (Fapp > 0.5) { + this._arrow(ctx, 0, 0, Fapp * arrowScale / m, 0, '#FF9F1C', 'F_пр'); + } + + ctx.restore(); + + /* Состояние */ + const state = sliding + ? `Скользит a = ${(G * (sinA - mu * cosA)).toFixed(2)} м/с²` + : `Покоится F_ст = ${(m * G * sinA).toFixed(1)} Н`; + + ctx.save(); + ctx.font = '12px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + _nwt_rrect(ctx, 14, H - 100, 230, 82, 8); + ctx.fill(); + ctx.fillStyle = '#c8d8ff'; + ctx.fillText(state, 22, H - 79); + ctx.fillText(`mg·sinα = ${(m * G * sinA).toFixed(1)} Н`, 22, H - 61); + ctx.fillText(`μ·mg·cosα = ${(mu * m * G * cosA).toFixed(1)} Н`, 22, H - 43); + ctx.fillText(`v = ${s.bv.toFixed(2)} м/с t = ${s.t.toFixed(1)} с`, 22, H - 25); + ctx.restore(); + + /* График v(t) */ + if (s.history.length > 2) { + this._graph(ctx, s.history, W - 155, H - 90, 140, 65, '#EF476F', 'v (м/с)'); + } + } + + /* ── Отрисовка — Скатывание тел ─────────────────────────────── */ + + _drawRoll(ctx) { + const { W, H } = this; + const s = this._roll; + const S = NewtonSim.SCALE; + const alpha = this.rollAlpha * Math.PI / 180; + const G = NewtonSim.G; + + /* Геометрия горки */ + const baseX = W * 0.10; + const baseY = H * 0.82; + const Lpx = W * 0.82; + const Hpx = Lpx * Math.tan(alpha); + + ctx.beginPath(); + ctx.moveTo(baseX, baseY); + ctx.lineTo(baseX + Lpx, baseY); + ctx.lineTo(baseX, baseY - Hpx); + ctx.closePath(); + ctx.fillStyle = '#1a2035'; + ctx.fill(); + ctx.strokeStyle = '#3a4d6a'; + ctx.lineWidth = 2; + ctx.stroke(); + + /* Отметка угла */ + ctx.save(); + ctx.strokeStyle = '#aabbcc'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(baseX, baseY, 32, -alpha, 0, false); + ctx.stroke(); + ctx.fillStyle = '#aabbcc'; + ctx.font = '11px Manrope,sans-serif'; + ctx.fillText(this.rollAlpha + '°', baseX + 38, baseY - 6); + ctx.restore(); + + /* Линия финиша */ + const finishFrac = 0.96; + const finX = baseX + Lpx * (1 - finishFrac * Math.cos(alpha) * Math.cos(alpha)); + const finY = baseY - Lpx * finishFrac * Math.sin(alpha) * Math.cos(alpha); + ctx.save(); + ctx.strokeStyle = '#FFD16688'; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(finX, finY - 12); + ctx.lineTo(finX, finY + 12); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + /* Три тела */ + const bodies = [ + { key: 'Ball', k: 2/5, color: '#EF476F', label: 'Шар', radius: 10 }, + { key: 'Cyl', k: 1/2, color: '#4CC9F0', label: 'Цилиндр', radius: 11 }, + { key: 'Hoop', k: 1, color: '#FFD166', label: 'Обруч', radius: 12 }, + ]; + + for (let i = 0; i < 3; i++) { + const b = bodies[i]; + const sPos = s['s' + b.key]; // позиция по склону (м) + const wAng = s['w' + b.key]; // угол поворота + const Spos = sPos * S; // px + + /* Центр тела вдоль наклонной */ + const cx = baseX + Spos * Math.cos(alpha) + b.radius * Math.sin(alpha); + const cy = (baseY - Hpx) + Spos * Math.cos(alpha) * Math.tan(alpha) + - Spos * Math.sin(alpha) + (Hpx - Spos * Math.sin(alpha)) + + b.radius * Math.cos(alpha) - Hpx + Spos * Math.sin(alpha); + + /* Пересчитаем проще: точка на поверхности горки */ + const sx0 = baseX + Spos * Math.cos(alpha); + const sy0 = baseY - Spos * Math.sin(alpha); + /* Смещение от поверхности (нормаль к горке) */ + const nx = -Math.sin(alpha); + const ny = -Math.cos(alpha); + const bx = sx0 + nx * (b.radius + i * 1.5); + const by = sy0 + ny * (b.radius + i * 1.5); + + ctx.save(); + ctx.translate(bx, by); + ctx.rotate(-alpha + wAng); + + if (b.key === 'Hoop') { + /* Обруч — только контур */ + ctx.beginPath(); + ctx.arc(0, 0, b.radius, 0, Math.PI * 2); + ctx.strokeStyle = b.color; + ctx.lineWidth = 3; + ctx.stroke(); + /* спицы */ + ctx.strokeStyle = b.color + '80'; + ctx.lineWidth = 1; + for (let a = 0; a < Math.PI * 2; a += Math.PI / 3) { + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(Math.cos(a) * b.radius, Math.sin(a) * b.radius); + ctx.stroke(); + } + } else if (b.key === 'Cyl') { + /* Цилиндр — закрашенный круг + линия диаметра */ + ctx.beginPath(); + ctx.arc(0, 0, b.radius, 0, Math.PI * 2); + ctx.fillStyle = b.color + 'aa'; + ctx.fill(); + ctx.strokeStyle = b.color; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(-b.radius, 0); ctx.lineTo(b.radius, 0); + ctx.strokeStyle = '#ffffffaa'; + ctx.lineWidth = 1; + ctx.stroke(); + } else { + /* Шар — градиентная заливка */ + const grad = ctx.createRadialGradient(-b.radius * 0.3, -b.radius * 0.3, 1, + 0, 0, b.radius); + grad.addColorStop(0, _nwt_lighten(b.color, 60)); + grad.addColorStop(1, b.color); + ctx.beginPath(); + ctx.arc(0, 0, b.radius, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + ctx.strokeStyle = _nwt_lighten(b.color, 40); + ctx.lineWidth = 1.5; + ctx.stroke(); + } + ctx.restore(); + + /* Подпись */ + ctx.fillStyle = b.color; + ctx.font = '9px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(b.label, bx, by - b.radius - 4); + } + + ctx.textAlign = 'left'; + + /* HUD */ + const sinA = Math.sin(alpha); + const aBall = (G * sinA / (1 + 2/5)).toFixed(3); + const aCyl = (G * sinA / (1 + 1/2)).toFixed(3); + const aHoop = (G * sinA / (1 + 1)).toFixed(3); + + ctx.save(); + ctx.font = '11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + _nwt_rrect(ctx, 14, H - 102, 210, 88, 8); + ctx.fill(); + const lines = [ + ['Шар (k=2/5):', `a=${aBall} м/с²`, '#EF476F'], + ['Цилиндр (k=1/2):', `a=${aCyl} м/с²`, '#4CC9F0'], + ['Обруч (k=1):', `a=${aHoop} м/с²`, '#FFD166'], + ]; + lines.forEach(([lbl, val, col], i) => { + ctx.fillStyle = col; + ctx.fillText(lbl, 22, H - 82 + i * 20); + ctx.fillStyle = '#e0e8ff'; + ctx.fillText(val, 130, H - 82 + i * 20); + }); + /* Победитель */ + if (s.winner) { + const wLabel = bodies.find(b => b.key === s.winner)?.label || s.winner; + ctx.fillStyle = '#7BF5A4'; + ctx.font = 'bold 12px Manrope,sans-serif'; + ctx.fillText('Первым: ' + wLabel, 22, H - 18); + } else { + ctx.fillStyle = '#8899aa'; + ctx.font = '11px Manrope,sans-serif'; + ctx.fillText('t = ' + s.t.toFixed(1) + ' с', 22, H - 18); + } + ctx.restore(); + } + + /* ── info() расширение для law=4 ──────────────────────────── */ + + _infoClassic() { + const scene = this.scene; + const base = { law: 4, scene }; + const G = NewtonSim.G; + const S = NewtonSim.SCALE; + + if (scene === 'atwood') { + const s = this._atw; + return { ...base, + a: s.aPhys.toFixed(3), + T: s.T.toFixed(2), + v: (Math.abs(s.vy) / S).toFixed(2), + eq: this.atwM1 === this.atwM2, + m1: this.atwM1, + m2: this.atwM2, + }; + } + if (scene === 'ramp') { + const s = this._ramp; + const alpha = this.rampAlpha * Math.PI / 180; + const sinA = Math.sin(alpha), cosA = Math.cos(alpha); + const m = this.mass1; + const Fdrive = m * G * sinA; + const Ffrmax = this.rampMu * m * G * cosA; + const sliding = (Fdrive + this.rampForce) > Ffrmax; + const accel = sliding + ? G * (sinA - this.rampMu * cosA) + this.rampForce / m + : 0; + return { ...base, + alpha: this.rampAlpha, + accel: accel.toFixed(3), + v: s.bv.toFixed(2), + Fdrive: Fdrive.toFixed(1), + Ffrmax: Ffrmax.toFixed(1), + sliding, + }; + } + /* roll */ + const s = this._roll; + const alpha = this.rollAlpha * Math.PI / 180; + const sinA = Math.sin(alpha); + return { ...base, + aBall: (G * sinA / (1 + 2/5)).toFixed(3), + aCyl: (G * sinA / (1 + 1/2)).toFixed(3), + aHoop: (G * sinA / (1 + 1 )).toFixed(3), + winner: s.winner, + t: s.t.toFixed(1), + }; + } } /* ── Утилиты ─────────────────────────────────────────────────── */ @@ -1287,7 +2003,7 @@ function _nwt_lighten(hex, d) { } else { // stop sandbox, switch newton law if (sandboxSim) sandboxSim.stop(); - const lawN = mode === 'law1' ? 1 : mode === 'law2' ? 2 : 3; + const lawN = mode === 'law1' ? 1 : mode === 'law2' ? 2 : mode === 'law4' ? 4 : 3; if (newtonSim) { newtonSim.setLaw(lawN); newtonSim.fit(); @@ -1330,6 +2046,11 @@ function _nwt_lighten(hex, d) { B: { desc: 'Третий закон: два шара сталкиваются — силы равны и противоположны.', action: ' Столкнуть' }, C: { desc: 'Реактивное движение: ракета выбрасывает газ — летит в обратную сторону.', action: 'Двигатель' }, }, + 4: { + atwood: { desc: 'Машина Атвуда: два груза на нити через блок. a = (m₂-m₁)g/(m₁+m₂).', action: ' Старт' }, + ramp: { desc: 'Наклонная плоскость: тело скользит если mg·sinα > μ·mg·cosα.', action: ' Старт' }, + roll: { desc: 'Скатывание тел: шар, цилиндр, обруч. a = g·sinα/(1+k). Кто быстрее?', action: ' Гонка' }, + }, }; const _NEWTON_PRESETS = { @@ -1380,50 +2101,120 @@ function _nwt_lighten(hex, d) { document.getElementById('newton-action-label').innerHTML = lbl; document.getElementById('newton-action-top').innerHTML = lbl; - // show/hide sliders - document.getElementById('newton-mu-block').style.display = law === 1 && scene === 'A' ? '' : 'none'; - document.getElementById('newton-mass1-block').style.display = (law === 2 || law === 3) ? '' : 'none'; - document.getElementById('newton-mass2-block').style.display = law === 3 ? '' : 'none'; - document.getElementById('newton-force-block').style.display = law === 2 ? '' : 'none'; + /* ── Показ/скрытие панелей ── */ + const isClassic = (law === 4); - // sync slider values from sim - document.getElementById('sl-newton-mu').value = newtonSim.mu; - document.getElementById('newton-mu-val').textContent = newtonSim.mu.toFixed(2); - document.getElementById('sl-newton-m1').value = newtonSim.mass1; - document.getElementById('newton-m1-val').textContent = newtonSim.mass1 + ' кг'; - document.getElementById('sl-newton-m2').value = newtonSim.mass2; - document.getElementById('newton-m2-val').textContent = newtonSim.mass2 + ' кг'; - document.getElementById('sl-newton-F').value = newtonSim.force; - document.getElementById('newton-F-val').textContent = newtonSim.force + ' Н'; + // classic panel + const classicPanel = document.getElementById('newton-classic-panel'); + if (classicPanel) classicPanel.style.display = isClassic ? '' : 'none'; - // sync scene highlight buttons in both topbar and panel - ['A','B','C'].forEach(s => { - const tb = document.getElementById('nscn-' + s); - const pb = document.getElementById('nscn-panel-' + s); - const on = s === scene; - if (tb) tb.classList.toggle('active', on); - if (pb) pb.classList.toggle('active', on); - }); + // standard scene row + const sceneTitle = document.getElementById('newton-scene-title'); + const sceneRow = document.getElementById('newton-scene-row'); + const presetsTitle = document.querySelector('#dyn-newton-panel .gp-section-title[style*="margin-top"]'); + if (sceneTitle) sceneTitle.style.display = isClassic ? 'none' : ''; + if (sceneRow) sceneRow.style.display = isClassic ? 'none' : ''; - // presets + // show/hide standard sliders (hidden for law 4) + document.getElementById('newton-mu-block').style.display = (!isClassic && law === 1 && scene === 'A') ? '' : 'none'; + document.getElementById('newton-mass1-block').style.display = (!isClassic && (law === 2 || law === 3)) ? '' : 'none'; + document.getElementById('newton-mass2-block').style.display = (!isClassic && law === 3) ? '' : 'none'; + document.getElementById('newton-force-block').style.display = (!isClassic && law === 2) ? '' : 'none'; + + if (!isClassic) { + // sync slider values from sim + document.getElementById('sl-newton-mu').value = newtonSim.mu; + document.getElementById('newton-mu-val').textContent = newtonSim.mu.toFixed(2); + document.getElementById('sl-newton-m1').value = newtonSim.mass1; + document.getElementById('newton-m1-val').textContent = newtonSim.mass1 + ' кг'; + document.getElementById('sl-newton-m2').value = newtonSim.mass2; + document.getElementById('newton-m2-val').textContent = newtonSim.mass2 + ' кг'; + document.getElementById('sl-newton-F').value = newtonSim.force; + document.getElementById('newton-F-val').textContent = newtonSim.force + ' Н'; + } + + // sync scene highlight buttons — classic uses atwood/ramp/roll + if (isClassic) { + ['atwood','ramp','roll'].forEach(s => { + const tb = document.getElementById('nscn-cl-' + s); + const pb = document.getElementById('nscn-panel-cl-' + s); + const on = s === scene; + if (tb) tb.classList.toggle('active', on); + if (pb) pb.classList.toggle('active', on); + }); + // update classic sub-panel visibility + ['atwood','ramp','roll'].forEach(s => { + const el = document.getElementById('cl-sub-' + s); + if (el) el.style.display = (s === scene) ? '' : 'none'; + }); + // sync classic sliders + _syncClassicSliders(); + } else { + // sync scene highlight buttons in both topbar and panel (standard A/B/C) + ['A','B','C'].forEach(s => { + const tb = document.getElementById('nscn-' + s); + const pb = document.getElementById('nscn-panel-' + s); + const on = s === scene; + if (tb) tb.classList.toggle('active', on); + if (pb) pb.classList.toggle('active', on); + }); + } + + // presets (only for laws 1-3) const presetsEl = document.getElementById('newton-presets'); - const presets = _NEWTON_PRESETS[law] || []; - presetsEl.innerHTML = presets.map(p => - `` - ).join(''); + const presetsSection = presetsEl && presetsEl.previousElementSibling; + if (presetsEl) { + presetsEl.style.display = isClassic ? 'none' : ''; + if (presetsSection && presetsSection.classList.contains('gp-section-title')) + presetsSection.style.display = isClassic ? 'none' : ''; + const presets = (!isClassic && _NEWTON_PRESETS[law]) || []; + presetsEl.innerHTML = presets.map(p => + `` + ).join(''); + } - // scene B/C visibility for law I (B = orbital, C = space — but law I only has A,B) - // scene C doesn't exist for law I/II panel scene picker visibility - const cBtn = document.getElementById('nscn-panel-C'); + // scene B/C visibility for standard mode + const cBtn = document.getElementById('nscn-panel-C'); const cTopBtn = document.getElementById('nscn-C'); - const showC = law === 3; - if (cBtn) cBtn.style.display = showC ? '' : 'none'; - if (cTopBtn) cTopBtn.style.display = showC ? '' : 'none'; - const bBtn = document.getElementById('nscn-panel-B'); + const bBtn = document.getElementById('nscn-panel-B'); const bTopBtn = document.getElementById('nscn-B'); - const showB = law !== 2 || true; // law 2 has compare scene B - if (bBtn) bBtn.style.display = ''; - if (bTopBtn) bTopBtn.style.display = ''; + const aBtn = document.getElementById('nscn-panel-A'); + const aTopBtn = document.getElementById('nscn-A'); + + // topbar scene buttons (A/B/C) hidden for classic; cl-* shown instead + if (aBtn) aBtn.style.display = isClassic ? 'none' : ''; + if (aTopBtn) aTopBtn.style.display = isClassic ? 'none' : ''; + if (bBtn) bBtn.style.display = isClassic ? 'none' : ''; + if (bTopBtn) bTopBtn.style.display = isClassic ? 'none' : ''; + const showC = !isClassic && law === 3; + if (cBtn) cBtn.style.display = showC ? '' : 'none'; + if (cTopBtn) cTopBtn.style.display = showC ? '' : 'none'; + + // classic topbar scene buttons + ['atwood','ramp','roll'].forEach(s => { + const tb = document.getElementById('nscn-cl-' + s); + if (tb) tb.style.display = isClassic ? '' : 'none'; + }); + } + + function _syncClassicSliders() { + if (!newtonSim) return; + /* Atwood */ + _setVal('sl-atw-m1', 'atw-m1-val', newtonSim.atwM1, v => v + ' кг'); + _setVal('sl-atw-m2', 'atw-m2-val', newtonSim.atwM2, v => v + ' кг'); + /* Ramp */ + _setVal('sl-ramp-alpha','ramp-alpha-val', newtonSim.rampAlpha, v => v + '°'); + _setVal('sl-ramp-mu', 'ramp-mu-val', newtonSim.rampMu, v => (+v).toFixed(2)); + _setVal('sl-ramp-force','ramp-force-val',newtonSim.rampForce, v => v + ' Н'); + /* Roll */ + _setVal('sl-roll-alpha','roll-alpha-val', newtonSim.rollAlpha, v => v + '°'); + } + + function _setVal(sliderId, valId, val, fmt) { + const sl = document.getElementById(sliderId); + const vl = document.getElementById(valId); + if (sl) sl.value = val; + if (vl) vl.textContent = fmt(val); } function newtonAction() { @@ -1435,6 +2226,9 @@ function _nwt_lighten(hex, d) { else if (law === 3 && scene === 'A') newtonSim.fireCannon(); else if (law === 3 && scene === 'B') newtonSim._reset3B ? newtonSim._reset3B() : null; else if (law === 3 && scene === 'C') newtonSim.toggleRocket(); + else if (law === 4 && scene === 'atwood') newtonSim.startAtwood(); + else if (law === 4 && scene === 'ramp') newtonSim.startRamp(); + else if (law === 4 && scene === 'roll') newtonSim.startRoll(); _newtonUpdateUI(newtonSim.info()); } @@ -1442,13 +2236,76 @@ function _nwt_lighten(hex, d) { if (!newtonSim) return; const law = newtonSim.law; const scene = newtonSim.scene; - if (law === 1 && scene === 'A') newtonSim.preset('ice'); + if (law === 4) { + if (scene === 'atwood') newtonSim._resetAtwood(); + else if (scene === 'ramp') newtonSim._resetRamp(); + else newtonSim._resetRoll(); + } else if (law === 1 && scene === 'A') newtonSim.preset('ice'); else if (law === 1) newtonSim.setScene(scene); else if (law === 2) newtonSim.resetL2 ? newtonSim.resetL2() : newtonSim.setScene(scene); else newtonSim.setScene(scene); _newtonUpdateUI(newtonSim.info()); } + /* ── classic scene selector ─────────────────────────────────── */ + function classicScene(s) { + if (!newtonSim) return; + newtonSim.setScene(s); + document.querySelectorAll('.cl-scene-btn').forEach(b => { + b.classList.toggle('active', b.dataset.scene === s); + }); + _newtonSyncUI(); + _newtonUpdateUI(newtonSim.info()); + } + + /* ── classic param change handlers ──────────────────────────── */ + function atwM1Change() { + const v = +document.getElementById('sl-atw-m1').value; + document.getElementById('atw-m1-val').textContent = v + ' кг'; + if (newtonSim) newtonSim.setAtwM1(v); + } + function atwM2Change() { + const v = +document.getElementById('sl-atw-m2').value; + document.getElementById('atw-m2-val').textContent = v + ' кг'; + if (newtonSim) newtonSim.setAtwM2(v); + } + function atwMassiveToggle(massive) { + if (newtonSim) newtonSim.setAtwMassive(massive); + document.querySelectorAll('.atw-massive-btn').forEach(b => + b.classList.toggle('active', b.dataset.val === (massive ? 'true' : 'false'))); + } + function rampMassChange() { + const v = +document.getElementById('sl-ramp-mass').value; + const vl = document.getElementById('newton-m1-ramp-val'); + if (vl) vl.textContent = v + ' кг'; + if (newtonSim) { newtonSim.mass1 = v; newtonSim._resetRamp(); } + } + function rampAlphaChange() { + const v = +document.getElementById('sl-ramp-alpha').value; + document.getElementById('ramp-alpha-val').textContent = v + '°'; + if (newtonSim) newtonSim.setRampAlpha(v); + } + function rampMuChange() { + const v = +document.getElementById('sl-ramp-mu').value; + document.getElementById('ramp-mu-val').textContent = (+v).toFixed(2); + if (newtonSim) newtonSim.setRampMu(v); + } + function rampForceChange() { + const v = +document.getElementById('sl-ramp-force').value; + document.getElementById('ramp-force-val').textContent = v + ' Н'; + if (newtonSim) newtonSim.setRampForce(v); + } + function rollAlphaChange() { + const v = +document.getElementById('sl-roll-alpha').value; + document.getElementById('roll-alpha-val').textContent = v + '°'; + if (newtonSim) newtonSim.setRollAlpha(v); + } + function rollFrictionToggle(val) { + if (newtonSim) newtonSim.setRollFriction(val); + document.querySelectorAll('.roll-friction-btn').forEach(b => + b.classList.toggle('active', b.dataset.val === (val ? 'true' : 'false'))); + } + function newtonMuChange() { const v = +document.getElementById('sl-newton-mu').value; document.getElementById('newton-mu-val').textContent = v.toFixed(2); @@ -1485,7 +2342,40 @@ function _nwt_lighten(hex, d) { const law = info.law; const scene = info.scene; - if (law === 1 && scene === 'A') { + if (law === 4 && scene === 'atwood') { + document.getElementById('dbar-l1').textContent = 'Машина Атвуда'; + document.getElementById('dbar-v1').textContent = info.eq ? 'Равновесие' : 'Движение'; + document.getElementById('dbar-l2').textContent = 'Ускорение a'; + document.getElementById('dbar-v2').textContent = info.a + ' м/с²'; + document.getElementById('dbar-l3').textContent = 'Натяжение T'; + document.getElementById('dbar-v3').textContent = info.T + ' Н'; + document.getElementById('dbar-l4').textContent = 'Скорость v'; + document.getElementById('dbar-v4').textContent = info.v + ' м/с'; + document.getElementById('dbar-l5').textContent = 'm₁ / m₂'; + document.getElementById('dbar-v5').textContent = info.m1 + ' / ' + info.m2 + ' кг'; + } else if (law === 4 && scene === 'ramp') { + document.getElementById('dbar-l1').textContent = 'Наклонная'; + document.getElementById('dbar-v1').textContent = info.sliding ? 'Скользит' : 'Покой'; + document.getElementById('dbar-l2').textContent = 'α'; + document.getElementById('dbar-v2').textContent = info.alpha + '°'; + document.getElementById('dbar-l3').textContent = 'Ускорение'; + document.getElementById('dbar-v3').textContent = info.accel + ' м/с²'; + document.getElementById('dbar-l4').textContent = 'mg·sinα'; + document.getElementById('dbar-v4').textContent = info.Fdrive + ' Н'; + document.getElementById('dbar-l5').textContent = 'μmg·cosα'; + document.getElementById('dbar-v5').textContent = info.Ffrmax + ' Н'; + } else if (law === 4 && scene === 'roll') { + document.getElementById('dbar-l1').textContent = 'Скатывание'; + document.getElementById('dbar-v1').textContent = info.winner ? 'Финиш: ' + info.winner : 't=' + info.t + ' с'; + document.getElementById('dbar-l2').textContent = 'a(шар)'; + document.getElementById('dbar-v2').textContent = info.aBall + ' м/с²'; + document.getElementById('dbar-l3').textContent = 'a(цилиндр)'; + document.getElementById('dbar-v3').textContent = info.aCyl + ' м/с²'; + document.getElementById('dbar-l4').textContent = 'a(обруч)'; + document.getElementById('dbar-v4').textContent = info.aHoop + ' м/с²'; + document.getElementById('dbar-l5').textContent = 'α'; + document.getElementById('dbar-v5').textContent = (newtonSim ? newtonSim.rollAlpha : '—') + '°'; + } else if (law === 1 && scene === 'A') { document.getElementById('dbar-l1').textContent = 'Закон I-A'; document.getElementById('dbar-v1').textContent = 'Скольжение'; document.getElementById('dbar-l2').textContent = 'Скорость'; diff --git a/frontend/js/labs/pendulum.js b/frontend/js/labs/pendulum.js index 3e05cab..58bf920 100644 --- a/frontend/js/labs/pendulum.js +++ b/frontend/js/labs/pendulum.js @@ -1,8 +1,15 @@ -'use strict'; +'use strict'; /* ══════════════════════════════════════════════════════════════ - PendulumSim — simple pendulum simulation - θ'' = -(g/L)sin(θ) − γ·θ' - RK4 integration · energy bar · trail · phase portrait + PendulumSim — 8-mode pendulum simulation + Modes: + math — simple mathematical pendulum (default) + double — double pendulum (chaotic, Lagrangian mechanics) + coupled — two coupled pendulums (energy transfer) + spring — spring pendulum (vertical / horizontal) + physical — physical pendulum (rod / hoop / disk / rect) + foucault — Foucault pendulum (latitude slider) + resonance— driven oscillation + resonance curve + Phase portrait overlay available for all modes. ══════════════════════════════════════════════════════════════ */ class PendulumSim { @@ -11,12 +18,94 @@ class PendulumSim { this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; - /* physics */ - this.L = 200; // px length - this.g = 9.81; - this.theta = Math.PI / 4; // angle (rad) - this.omega = 0; // angular velocity - this.damping = 0; // damping coefficient γ + /* current mode */ + this.mode = 'math'; + + /* ── MODE: math ──────────────────────────── */ + this.L = 200; + this.g = 9.81; + this.theta = Math.PI / 4; + this.omega = 0; + this.damping = 0; + + /* ── MODE: double ────────────────────────── */ + this.d = { + L1: 130, L2: 100, + m1: 1.5, m2: 1.0, + th1: Math.PI * 0.6, om1: 0, + th2: Math.PI * 0.4, om2: 0, + trail: [], // [{x,y}] + maxTrail: 500, + // ghost for chaos comparison + showGhost: false, + gth1: 0, gom1: 0, gth2: 0, gom2: 0, + ghostTrail: [], + }; + + /* ── MODE: coupled ───────────────────────── */ + this.cp = { + L: 160, g: 9.81, + k: 0.3, // spring coupling + th1: Math.PI / 5, om1: 0, + th2: 0, om2: 0, + hist1: [], hist2: [], + }; + + /* ── MODE: spring ────────────────────────── */ + this.sp = { + mode: 'vert', // 'vert' | 'horiz' + k: 20, // N/m + m: 1, // kg + x: 0.08, // displacement (m) + v: 0, + hist: [], + restLen: 0.2, // natural length (m) + // driven resonance on spring + drive: false, dOmega: 0, dF: 0, + }; + + /* ── MODE: physical ──────────────────────── */ + this.ph = { + shape: 'rod', // 'rod'|'hoop'|'disk'|'rect' + L: 200, // px (total length / radius) + theta: Math.PI / 5, + omega: 0, + g: 9.81, + damping: 0, + }; + + /* ── MODE: foucault ──────────────────────── */ + this.fc = { + phi: Math.PI / 4, // latitude (rad) + L: 150, // pendulum length (px) + // 2D state in rotating frame: x, y, vx, vy + x: 60, y: 0, vx: 0, vy: 0, + trail: [], + maxTrail: 800, + tSim: 0, + // scaled Omega_z = Omega_earth * sin(phi) — for demo speed up + timeScale: 200, // how many Earth-hours pass per sim-second + }; + + /* ── MODE: resonance ─────────────────────── */ + this.rs = { + L: 180, + g: 9.81, + gamma: 0.3, // damping + F0: 0.8, // driving amplitude (rad/s²) + dOmega: 1.5, // driving frequency + theta: 0.1, + omega: 0, + tSim: 0, + // resonance curve data (precomputed on param change) + curve: [], // [{w, A}] + curveDirty: true, + }; + + /* ── phase portrait ─── */ + this.showPhase = false; + this._phaseTrail = []; // [{x,y}] = [{theta, omega}] + this._maxPhase = 1000; /* animation */ this.playing = false; @@ -24,17 +113,16 @@ class PendulumSim { this._lastTs = null; this.speed = 1; - /* trail */ - this._trail = []; // [{x, y, age}] + /* trail (math mode) */ + this._trail = []; this._maxTrail = 200; - /* energy chart (bottom) */ - this._eHistory = []; // [{t, ke, pe}] - this._tSim = 0; + /* energy history (math mode) */ + this._eHistory = []; + this._tSim = 0; this.onUpdate = null; - - this._drag = null; + this._drag = null; this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } @@ -43,7 +131,7 @@ class PendulumSim { fit() { const dpr = window.devicePixelRatio || 1; - const w = this.canvas.offsetWidth || 600; + const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; @@ -51,9 +139,18 @@ class PendulumSim { this.W = w; this.H = h; } - getParams() { - return { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping }; + setMode(m) { + this.mode = m; + this.pause(); + this._clearAll(); + this.draw(); + this._emit(); } + + getParams() { + return { mode: this.mode, L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping }; + } + setParams({ L, g, theta, damping } = {}) { if (L !== undefined) this.L = +L; if (g !== undefined) this.g = +g; @@ -77,11 +174,37 @@ class PendulumSim { reset() { this.pause(); - this.theta = Math.PI / 4; - this.omega = 0; - this._tSim = 0; - this._clearTrail(); - this._eHistory = []; + this._clearAll(); + // reset mode state to defaults + switch (this.mode) { + case 'math': + this.theta = Math.PI / 4; this.omega = 0; this._tSim = 0; this._eHistory = []; + break; + case 'double': + this.d.th1 = Math.PI * 0.6; this.d.om1 = 0; + this.d.th2 = Math.PI * 0.4; this.d.om2 = 0; + this.d.trail = []; this.d.ghostTrail = []; + if (this.d.showGhost) this._initDoubleGhost(); + break; + case 'coupled': + this.cp.th1 = Math.PI / 5; this.cp.om1 = 0; + this.cp.th2 = 0; this.cp.om2 = 0; + this.cp.hist1 = []; this.cp.hist2 = []; + break; + case 'spring': + this.sp.x = 0.08; this.sp.v = 0; this.sp.hist = []; + break; + case 'physical': + this.ph.theta = Math.PI / 5; this.ph.omega = 0; + break; + case 'foucault': + this.fc.x = 60; this.fc.y = 0; this.fc.vx = 0; this.fc.vy = 0; + this.fc.trail = []; this.fc.tSim = 0; + break; + case 'resonance': + this.rs.theta = 0.1; this.rs.omega = 0; this.rs.tSim = 0; + break; + } if (window.LabFX) LabFX.sound.play('click'); this.draw(); this._emit(); @@ -91,23 +214,92 @@ class PendulumSim { stop() { this.pause(); } info() { - const T = 2 * Math.PI * Math.sqrt(this.L / (this.g * 100)); // L in px approx - const KE = 0.5 * this.omega * this.omega * this.L * this.L; - const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); - const total = KE + PE; - return { - angle: (this.theta * 180 / Math.PI).toFixed(1) + '°', - omega: this.omega.toFixed(3) + ' рад/с', - period: T.toFixed(2) + ' с', - energy: total > 0 ? Math.round(KE / total * 100) + '% KE' : '—', - }; + switch (this.mode) { + case 'math': { + const T = 2 * Math.PI * Math.sqrt(this.L / (this.g * 100)); + const KE = 0.5 * this.omega * this.omega * this.L * this.L; + const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); + const total = KE + PE; + return { + angle: (this.theta * 180 / Math.PI).toFixed(1) + '°', + omega: this.omega.toFixed(3) + ' рад/с', + period: T.toFixed(2) + ' с', + energy: total > 0 ? Math.round(KE / total * 100) + '% KE' : '—', + }; + } + case 'double': { + const T = 2 * Math.PI * Math.sqrt(this.d.L1 / (9.81 * 100)); + return { + angle: (this.d.th1 * 180 / Math.PI).toFixed(1) + '° / ' + (this.d.th2 * 180 / Math.PI).toFixed(1) + '°', + omega: this.d.om1.toFixed(2) + ' / ' + this.d.om2.toFixed(2), + period: T.toFixed(2) + ' с (звено)', + energy: 'хаос', + }; + } + case 'coupled': { + const T = 2 * Math.PI * Math.sqrt(this.cp.L / (this.cp.g * 100)); + return { + angle: 'θ1=' + (this.cp.th1 * 180 / Math.PI).toFixed(1) + '°', + omega: 'θ2=' + (this.cp.th2 * 180 / Math.PI).toFixed(1) + '°', + period: T.toFixed(2) + ' с', + energy: 'k=' + this.cp.k.toFixed(2), + }; + } + case 'spring': { + const T = 2 * Math.PI * Math.sqrt(this.sp.m / this.sp.k); + const KE = 0.5 * this.sp.m * this.sp.v * this.sp.v; + const PE = 0.5 * this.sp.k * this.sp.x * this.sp.x; + const total = KE + PE || 1; + return { + angle: 'x=' + (this.sp.x * 100).toFixed(1) + ' см', + omega: 'v=' + this.sp.v.toFixed(2) + ' м/с', + period: T.toFixed(2) + ' с', + energy: Math.round(KE / total * 100) + '% KE', + }; + } + case 'physical': { + const { I, d } = this._physInertia(); + const T = 2 * Math.PI * Math.sqrt(I / (this.ph.g * 100 * d)); + return { + angle: (this.ph.theta * 180 / Math.PI).toFixed(1) + '°', + omega: this.ph.omega.toFixed(3) + ' рад/с', + period: T.toFixed(2) + ' с', + energy: this.ph.shape, + }; + } + case 'foucault': { + const phiDeg = (this.fc.phi * 180 / Math.PI).toFixed(0); + const sinPhi = Math.sin(this.fc.phi); + const Trot = sinPhi > 0.001 ? (24 / sinPhi).toFixed(1) + ' ч' : '∞'; + return { + angle: 'φ=' + phiDeg + '°', + omega: Trot, + period: (2 * Math.PI * Math.sqrt(this.fc.L / (9.81 * 100))).toFixed(2) + ' с', + energy: 'вращение', + }; + } + case 'resonance': { + const omega0 = Math.sqrt(this.rs.g * 100 / this.rs.L); + const T = 2 * Math.PI / omega0; + return { + angle: (this.rs.theta * 180 / Math.PI).toFixed(1) + '°', + omega: 'ω=' + this.rs.dOmega.toFixed(2) + ' рад/с', + period: T.toFixed(2) + ' с (собст)', + energy: 'ω₀=' + omega0.toFixed(2), + }; + } + default: + return { angle: '—', omega: '—', period: '—', energy: '—' }; + } } /* ── internals ─────────────────────────────── */ _emit() { if (this.onUpdate) this.onUpdate(this.info()); } - _clearTrail() { this._trail = []; } + _clearTrail() { this._trail = []; } + _clearPhase() { this._phaseTrail = []; } + _clearAll() { this._clearTrail(); this._clearPhase(); } _tick() { if (!this.playing) return; @@ -117,65 +309,361 @@ class PendulumSim { this._lastTs = ts; const dt = rawDt * this.speed; - const prevOmega = this.omega; - this._step(dt); - this._tSim += dt; - if (window.LabFX) LabFX.particles.update(rawDt); - /* LabFX: tick sound at maximum extension (velocity sign flip) */ - if (window.LabFX && prevOmega !== 0 && Math.sign(this.omega) !== Math.sign(prevOmega)) { - LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 }); - } - - // trail - const { bx, by } = this._bobPos(); - this._trail.push({ x: bx, y: by }); - if (this._trail.length > this._maxTrail) this._trail.shift(); - - // energy history - 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(); - + this._stepMode(dt); this.draw(); this._emit(); this._tick(); }); } - /* RK4 step for θ'' = -(g/L)sinθ - γ·ω */ - _step(dt) { - const gL = this.g * 100 / this.L; // scale g for px units - const c = this.damping; + _stepMode(dt) { + switch (this.mode) { + case 'math': this._stepMath(dt); break; + case 'double': this._stepDouble(dt); break; + case 'coupled': this._stepCoupled(dt); break; + case 'spring': this._stepSpring(dt); break; + case 'physical': this._stepPhysical(dt); break; + case 'foucault': this._stepFoucault(dt); break; + case 'resonance': this._stepResonance(dt);break; + } + } - const deriv = (th, om) => ({ - dth: om, - dom: -gL * Math.sin(th) - c * om, + /* ─── MODE: math ─────────────────────────────── */ + + _stepMath(dt) { + const prevOmega = this.omega; + const gL = this.g * 100 / this.L; + const c = this.damping; + const deriv = (th, om) => ({ dth: om, dom: -gL * Math.sin(th) - c * om }); + const rk4 = (th, om) => { + const k1 = deriv(th, om); + const k2 = deriv(th + k1.dth * dt / 2, om + k1.dom * dt / 2); + const k3 = deriv(th + k2.dth * dt / 2, om + k2.dom * dt / 2); + const k4 = deriv(th + k3.dth * dt, om + k3.dom * dt); + return { + th: th + dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth), + om: om + dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom), + }; + }; + const r = rk4(this.theta, this.omega); + this.theta = r.th; this.omega = r.om; + this._tSim += dt; + + if (window.LabFX && prevOmega !== 0 && Math.sign(this.omega) !== Math.sign(prevOmega)) { + LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 }); + } + + const { bx, by } = this._bobPos(); + this._trail.push({ x: bx, y: by }); + if (this._trail.length > this._maxTrail) this._trail.shift(); + + 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(); + + if (this.showPhase) { + this._phaseTrail.push({ x: this.theta, y: this.omega }); + if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift(); + } + } + + /* ─── MODE: double ───────────────────────────── */ + + _stepDouble(dt) { + // Use smaller sub-steps for stability + const steps = 8; + const h = dt / steps; + for (let s = 0; s < steps; s++) { + this._rk4Double(h, false); + if (this.d.showGhost) this._rk4Double(h, true); + } + + // trail for lower bob + 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.d.showGhost) { + const { bx: gx, by: gy } = this._doubleBobPos(true); + this.d.ghostTrail.push({ x: gx, y: gy }); + if (this.d.ghostTrail.length > this.d.maxTrail) this.d.ghostTrail.shift(); + } + + if (this.showPhase) { + this._phaseTrail.push({ x: this.d.th1, y: this.d.om1 }); + if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift(); + } + } + + _rk4Double(h, ghost) { + const d = this.d; + const g = 9.81 * 100; // px/s² + const { L1, L2, m1, m2 } = d; + + const derivD = (th1, om1, th2, om2) => { + const dth = th1 - th2; + const denom = L1 * (2 * m1 + m2 - m2 * Math.cos(2 * dth)); + const dom1 = ( + -g * (2 * m1 + m2) * Math.sin(th1) + - m2 * g * Math.sin(th1 - 2 * th2) + - 2 * Math.sin(dth) * m2 * (om2 * om2 * L2 + om1 * om1 * L1 * Math.cos(dth)) + ) / denom; + const dom2 = ( + 2 * Math.sin(dth) * ( + om1 * om1 * L1 * (m1 + m2) + + g * (m1 + m2) * Math.cos(th1) + + om2 * om2 * L2 * m2 * Math.cos(dth) + ) + ) / (L2 * (2 * m1 + m2 - m2 * Math.cos(2 * dth))); + return { dom1, dth1: om1, dom2, dth2: om2 }; + }; + + let th1, om1, th2, om2; + if (ghost) { + th1 = d.gth1; om1 = d.gom1; th2 = d.gth2; om2 = d.gom2; + } else { + th1 = d.th1; om1 = d.om1; th2 = d.th2; om2 = d.om2; + } + + const k1 = derivD(th1, om1, th2, om2); + const k2 = derivD(th1 + k1.dth1 * h / 2, om1 + k1.dom1 * h / 2, th2 + k1.dth2 * h / 2, om2 + k1.dom2 * h / 2); + const k3 = derivD(th1 + k2.dth1 * h / 2, om1 + k2.dom1 * h / 2, th2 + k2.dth2 * h / 2, om2 + k2.dom2 * h / 2); + const k4 = derivD(th1 + k3.dth1 * h, om1 + k3.dom1 * h, th2 + k3.dth2 * h, om2 + k3.dom2 * h); + + const nth1 = th1 + h / 6 * (k1.dth1 + 2 * k2.dth1 + 2 * k3.dth1 + k4.dth1); + const nom1 = om1 + h / 6 * (k1.dom1 + 2 * k2.dom1 + 2 * k3.dom1 + k4.dom1); + const nth2 = th2 + h / 6 * (k1.dth2 + 2 * k2.dth2 + 2 * k3.dth2 + k4.dth2); + const nom2 = om2 + h / 6 * (k1.dom2 + 2 * k2.dom2 + 2 * k3.dom2 + k4.dom2); + + if (ghost) { + d.gth1 = nth1; d.gom1 = nom1; d.gth2 = nth2; d.gom2 = nom2; + } else { + d.th1 = nth1; d.om1 = nom1; d.th2 = nth2; d.om2 = nom2; + } + } + + _initDoubleGhost() { + const eps = 0.001; + this.d.gth1 = this.d.th1 + eps; + this.d.gom1 = this.d.om1; + this.d.gth2 = this.d.th2; + this.d.gom2 = this.d.om2; + this.d.ghostTrail = []; + } + + /* ─── MODE: coupled ──────────────────────────── */ + + _stepCoupled(dt) { + const { L, g, k } = this.cp; + const gL = g * 100 / L; + // equations: th1'' = -gL*sin(th1) - k*(th1-th2) + // th2'' = -gL*sin(th2) + k*(th1-th2) + const derivC = (th1, om1, th2, om2) => ({ + dth1: om1, dom1: -gL * Math.sin(th1) - k * (th1 - th2), + dth2: om2, dom2: -gL * Math.sin(th2) + k * (th1 - th2), }); - const k1 = deriv(this.theta, this.omega); - const k2 = deriv(this.theta + k1.dth * dt / 2, this.omega + k1.dom * dt / 2); - const k3 = deriv(this.theta + k2.dth * dt / 2, this.omega + k2.dom * dt / 2); - const k4 = deriv(this.theta + k3.dth * dt, this.omega + k3.dom * dt); + let { th1, om1, th2, om2 } = this.cp; + const k1 = derivC(th1, om1, th2, om2); + const k2 = derivC(th1 + k1.dth1 * dt / 2, om1 + k1.dom1 * dt / 2, th2 + k1.dth2 * dt / 2, om2 + k1.dom2 * dt / 2); + const k3 = derivC(th1 + k2.dth1 * dt / 2, om1 + k2.dom1 * dt / 2, th2 + k2.dth2 * dt / 2, om2 + k2.dom2 * dt / 2); + const k4 = derivC(th1 + k3.dth1 * dt, om1 + k3.dom1 * dt, th2 + k3.dth2 * dt, om2 + k3.dom2 * dt); - this.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth); - this.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom); + this.cp.th1 += dt / 6 * (k1.dth1 + 2 * k2.dth1 + 2 * k3.dth1 + k4.dth1); + this.cp.om1 += dt / 6 * (k1.dom1 + 2 * k2.dom1 + 2 * k3.dom1 + k4.dom1); + this.cp.th2 += dt / 6 * (k1.dth2 + 2 * k2.dth2 + 2 * k3.dth2 + k4.dth2); + this.cp.om2 += dt / 6 * (k1.dom2 + 2 * k2.dom2 + 2 * k3.dom2 + k4.dom2); + + this.cp.hist1.push(this.cp.th1); + this.cp.hist2.push(this.cp.th2); + if (this.cp.hist1.length > 400) { this.cp.hist1.shift(); this.cp.hist2.shift(); } + + if (this.showPhase) { + this._phaseTrail.push({ x: this.cp.th1, y: this.cp.om1 }); + if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift(); + } } + /* ─── MODE: spring ───────────────────────────── */ + + _stepSpring(dt) { + const { k, m } = this.sp; + let accBase; + if (this.sp.mode === 'vert') { + // vertical: x is displacement from equilibrium (eq at mg/k below natural) + accBase = -k / m * this.sp.x; + } else { + accBase = -k / m * this.sp.x; + } + + let driveAcc = 0; + if (this.sp.drive) { + driveAcc = this.sp.dF * Math.cos(this.sp.dOmega * (this._tSim || 0)); + } + + const deriv = (x, v) => ({ dx: v, dv: accBase + driveAcc - 0.1 * v }); + const k1 = deriv(this.sp.x, this.sp.v); + const k2 = deriv(this.sp.x + k1.dx * dt / 2, this.sp.v + k1.dv * dt / 2); + const k3 = deriv(this.sp.x + k2.dx * dt / 2, this.sp.v + k2.dv * dt / 2); + const k4 = deriv(this.sp.x + k3.dx * dt, this.sp.v + k3.dv * dt); + + this.sp.x += dt / 6 * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx); + this.sp.v += dt / 6 * (k1.dv + 2 * k2.dv + 2 * k3.dv + k4.dv); + this._tSim = (this._tSim || 0) + dt; + + this.sp.hist.push(this.sp.x); + if (this.sp.hist.length > 400) this.sp.hist.shift(); + + if (this.showPhase) { + this._phaseTrail.push({ x: this.sp.x, y: this.sp.v }); + if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift(); + } + } + + /* ─── MODE: physical ─────────────────────────── */ + + _physInertia() { + // Returns {I, d} in px units (I=kg*px², d=px) + // We set mass=1 and treat L as length in px + const L = this.ph.L; + switch (this.ph.shape) { + case 'rod': return { I: L * L / 3, d: L / 2 }; // rod pivoted at end + case 'hoop': return { I: 2 * L * L, d: L }; // hoop radius L, pivot at rim + case 'disk': return { I: 1.5 * L * L, d: L }; // disk radius L, pivot at rim: I=3/2*mr² + case 'rect': return { I: L * L * 4 / 3, d: L }; // rect height 2L, pivot at top + default: return { I: L * L / 3, d: L / 2 }; + } + } + + _stepPhysical(dt) { + const { I, d } = this._physInertia(); + const gL = this.ph.g * 100 * d / I; // torque ratio + const c = this.ph.damping; + + const deriv = (th, om) => ({ dth: om, dom: -gL * Math.sin(th) - c * om }); + const k1 = deriv(this.ph.theta, this.ph.omega); + const k2 = deriv(this.ph.theta + k1.dth * dt / 2, this.ph.omega + k1.dom * dt / 2); + const k3 = deriv(this.ph.theta + k2.dth * dt / 2, this.ph.omega + k2.dom * dt / 2); + const k4 = deriv(this.ph.theta + k3.dth * dt, this.ph.omega + k3.dom * dt); + + this.ph.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth); + this.ph.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom); + + if (this.showPhase) { + this._phaseTrail.push({ x: this.ph.theta, y: this.ph.omega }); + if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift(); + } + } + + /* ─── MODE: foucault ─────────────────────────── */ + + _stepFoucault(dt) { + // Top-down view. In rotating frame: + // x'' = -omega0²·x + 2*Omega_z*y' + // y'' = -omega0²·y - 2*Omega_z*x' + // omega0 = sqrt(g/L) (in px units) + // Omega_z = Omega_earth * sin(phi) — sped up by timeScale + const fc = this.fc; + const omega0sq = 9.81 * 100 / fc.L; + // Earth rotation: 2pi / (24*3600) rad/s ≈ 7.27e-5 rad/s + // We speed up by timeScale (e.g. 200 sim-hours/real-second) + const OmegaEarth = (2 * Math.PI / 86400) * fc.timeScale; + const Omz = OmegaEarth * Math.sin(fc.phi); + + const deriv = (x, vx, y, vy) => ({ + dx: vx, dvx: -omega0sq * x + 2 * Omz * vy, + dy: vy, dvy: -omega0sq * y - 2 * Omz * vx, + }); + + let { x, vx, y, vy } = fc; + const k1 = deriv(x, vx, y, vy); + const k2 = deriv(x + k1.dx * dt / 2, vx + k1.dvx * dt / 2, y + k1.dy * dt / 2, vy + k1.dvy * dt / 2); + const k3 = deriv(x + k2.dx * dt / 2, vx + k2.dvx * dt / 2, y + k2.dy * dt / 2, vy + k2.dvy * dt / 2); + const k4 = deriv(x + k3.dx * dt, vx + k3.dvx * dt, y + k3.dy * dt, vy + k3.dvy * dt); + + fc.x += dt / 6 * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx); + fc.vx += dt / 6 * (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx); + fc.y += dt / 6 * (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy); + fc.vy += dt / 6 * (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy); + fc.tSim += dt; + + fc.trail.push({ x: fc.x, y: fc.y }); + if (fc.trail.length > fc.maxTrail) fc.trail.shift(); + } + + /* ─── MODE: resonance ────────────────────────── */ + + _stepResonance(dt) { + // θ'' = -(g/L)sinθ - γ·ω + F0·cos(ω_d·t) + const rs = this.rs; + const gL = rs.g * 100 / rs.L; + + const deriv = (th, om, t) => ({ + dth: om, + dom: -gL * Math.sin(th) - rs.gamma * om + rs.F0 * Math.cos(rs.dOmega * t), + }); + + const k1 = deriv(rs.theta, rs.omega, rs.tSim); + const k2 = deriv(rs.theta + k1.dth * dt / 2, rs.omega + k1.dom * dt / 2, rs.tSim + dt / 2); + const k3 = deriv(rs.theta + k2.dth * dt / 2, rs.omega + k2.dom * dt / 2, rs.tSim + dt / 2); + const k4 = deriv(rs.theta + k3.dth * dt, rs.omega + k3.dom * dt, rs.tSim + dt); + + rs.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth); + rs.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom); + rs.tSim += dt; + + if (this.showPhase) { + this._phaseTrail.push({ x: rs.theta, y: rs.omega }); + if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift(); + } + } + + _buildResonanceCurve() { + // Steady-state amplitude: A(w) = F0 / sqrt((w0²-w²)² + (γ·w)²) + const rs = this.rs; + const w0 = Math.sqrt(rs.g * 100 / rs.L); + const curve = []; + for (let i = 0; i <= 100; i++) { + const w = 0.05 + i * w0 * 3 / 100; + const denom = Math.sqrt(Math.pow(w0 * w0 - w * w, 2) + Math.pow(rs.gamma * w, 2)); + const A = denom > 0.001 ? rs.F0 / denom : rs.F0 * 999; + curve.push({ w, A: Math.min(A, 5) }); + } + rs.curve = curve; + rs.curveDirty = false; + } + + /* ─── bob/pivot positions ─────────────────────── */ + _bobPos() { const cx = this.W / 2; const cy = Math.min(this.H * 0.18, 80); return { - px: cx, - py: cy, + px: cx, py: cy, bx: cx + this.L * Math.sin(this.theta), by: cy + this.L * Math.cos(this.theta), }; } - /* ── draw ──────────────────────────────────── */ + _doubleBobPos(ghost) { + const d = this.d; + const cx = this.W / 2; + const cy = Math.min(this.H * 0.22, 80); + const th1 = ghost ? d.gth1 : d.th1; + const th2 = ghost ? d.gth2 : d.th2; + const x1 = cx + d.L1 * Math.sin(th1); + const y1 = cy + d.L1 * Math.cos(th1); + const bx = x1 + d.L2 * Math.sin(th2); + const by = y1 + d.L2 * Math.cos(th2); + return { px: cx, py: cy, mx: x1, my: y1, bx, by }; + } + + /* ══════════════════════════════════════════════ + DRAW + ══════════════════════════════════════════════ */ draw() { const ctx = this.ctx, W = this.W, H = this.H; @@ -184,16 +672,37 @@ class PendulumSim { ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); + const mainW = this.showPhase ? Math.floor(W * 0.6) : W; + + switch (this.mode) { + case 'math': this._drawMath(ctx, mainW, H); break; + case 'double': this._drawDouble(ctx, mainW, H); break; + case 'coupled': this._drawCoupled(ctx, mainW, H); break; + case 'spring': this._drawSpring(ctx, mainW, H); break; + case 'physical': this._drawPhysical(ctx, mainW, H); break; + case 'foucault': this._drawFoucault(ctx, mainW, H); break; + case 'resonance': this._drawResonance(ctx, mainW, H); break; + } + + if (this.showPhase) { + this._drawPhasePortrait(ctx, mainW, 0, W - mainW, H); + } + + if (window.LabFX) LabFX.particles.draw(ctx); + } + + /* ── draw: math ──────────────────────────────── */ + + _drawMath(ctx, W, H) { const { px, py, bx, by } = this._bobPos(); - // trail - this._drawTrail(ctx); + this._drawTrailPts(ctx, this._trail, '#9B5DE5'); // support ctx.fillStyle = 'rgba(255,255,255,0.25)'; - ctx.fillRect(W / 2 - 30, py - 4, 60, 4); + ctx.fillRect(this.W / 2 - 30, py - 4, 60, 4); - // string + // rod ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(bx, by); ctx.stroke(); @@ -202,72 +711,577 @@ class PendulumSim { ctx.fillStyle = '#666'; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); - // bob - const bobR = 18; - const _drawBob = () => { - ctx.fillStyle = '#9B5DE5'; - ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill(); - ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke(); + this._drawBobGlow(ctx, bx, by, 18, '#9B5DE5'); + this._drawAngleArc(ctx, px, py, this.theta); + this._drawEnergyBar(ctx, W, H); + this._drawEnergyChart(ctx, W, H); + } - // glow - const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2); - grad.addColorStop(0, 'rgba(155,93,229,0.25)'); - grad.addColorStop(1, 'rgba(155,93,229,0)'); + /* ── draw: double ────────────────────────────── */ + + _drawDouble(ctx, W, H) { + // ghost trail (comparison) — draw first so main is on top + if (this.d.showGhost && this.d.ghostTrail.length > 1) { + this._drawTrailPts(ctx, this.d.ghostTrail, '#EF476F'); + } + + // main trail + this._drawTrailPts(ctx, this.d.trail, '#FFD166'); + + const { px, py, mx, my, bx, by } = this._doubleBobPos(false); + + // support + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.fillRect(px - 30, py - 4, 60, 4); + + // rods + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(mx, my); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(mx, my); ctx.lineTo(bx, by); ctx.stroke(); + + // pivot points + ctx.fillStyle = '#666'; + ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); + this._drawBobGlow(ctx, mx, my, 12, '#06D6E0'); + this._drawBobGlow(ctx, bx, by, 16, '#FFD166'); + + // ghost pendulum (arms) + if (this.d.showGhost) { + const g = this._doubleBobPos(true); + ctx.strokeStyle = 'rgba(239,71,111,0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(g.px, g.py); ctx.lineTo(g.mx, g.my); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(g.mx, g.my); ctx.lineTo(g.bx, g.by); ctx.stroke(); + ctx.fillStyle = 'rgba(239,71,111,0.6)'; + ctx.beginPath(); ctx.arc(g.bx, g.by, 10, 0, Math.PI * 2); ctx.fill(); + } + + // chaos label + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = '11px Manrope, sans-serif'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('Двойной маятник — хаос', 12, 12); + if (this.d.showGhost) { + ctx.fillStyle = '#EF476F'; ctx.fillText('• смещение +0.001 рад', 12, 26); + } + } + + /* ── draw: coupled ───────────────────────────── */ + + _drawCoupled(ctx, W, H) { + const cy = Math.min(H * 0.2, 80); + const L = this.cp.L; + const x1 = W * 0.35; + const x2 = W * 0.65; + + // spring connector at mid-rod + const sY1 = cy + L / 2; + const bX1 = x1 + L * Math.sin(this.cp.th1) * 0.5 + x1 * 0; // mid-rod + const bX2 = x2 + L * Math.sin(this.cp.th2) * 0.5; + // mid-points of rods + const mid1x = x1 + (L / 2) * Math.sin(this.cp.th1); + const mid1y = cy + (L / 2) * Math.cos(this.cp.th1); + const mid2x = x2 + (L / 2) * Math.sin(this.cp.th2); + const mid2y = cy + (L / 2) * Math.cos(this.cp.th2); + + // draw spring between mid-points + this._drawSpringLine(ctx, mid1x, mid1y, mid2x, mid2y, '#FFD166'); + + // supports + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.fillRect(x1 - 20, cy - 4, 40, 4); + ctx.fillRect(x2 - 20, cy - 4, 40, 4); + + // pendulum 1 + const b1x = x1 + L * Math.sin(this.cp.th1); + const b1y = cy + L * Math.cos(this.cp.th1); + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x1, cy); ctx.lineTo(b1x, b1y); ctx.stroke(); + ctx.fillStyle = '#666'; + ctx.beginPath(); ctx.arc(x1, cy, 5, 0, Math.PI * 2); ctx.fill(); + this._drawBobGlow(ctx, b1x, b1y, 16, '#9B5DE5'); + + // pendulum 2 + const b2x = x2 + L * Math.sin(this.cp.th2); + const b2y = cy + L * Math.cos(this.cp.th2); + ctx.beginPath(); ctx.moveTo(x2, cy); ctx.lineTo(b2x, b2y); ctx.stroke(); + ctx.fillStyle = '#666'; + ctx.beginPath(); ctx.arc(x2, cy, 5, 0, Math.PI * 2); ctx.fill(); + this._drawBobGlow(ctx, b2x, b2y, 16, '#06D6E0'); + + // bottom graph: θ1 and θ2 vs time + this._drawCoupledChart(ctx, W, H); + } + + _drawSpringLine(ctx, x1, y1, x2, y2, color) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.sqrt(dx * dx + dy * dy); + const nx = -dy / len, ny = dx / len; // normal + const coils = 10; + const amp = 6; + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x1, y1); + for (let i = 0; i <= coils * 2; i++) { + const t = i / (coils * 2); + const side = (i % 2 === 0) ? amp : -amp; + const px = x1 + t * dx + nx * side; + const py = y1 + t * dy + ny * side; + ctx.lineTo(px, py); + } + ctx.lineTo(x2, y2); + ctx.stroke(); + } + + _drawCoupledChart(ctx, W, H) { + const h1 = this.cp.hist1, h2 = this.cp.hist2; + if (h1.length < 2) return; + const cw = Math.min(W * 0.7, 340); + const ch = 70; + const cx = (W - cw) / 2; + const cy = H - ch - 16; + + ctx.fillStyle = 'rgba(22,22,38,0.75)'; + ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill(); + + let maxA = 0; + for (const v of h1) maxA = Math.max(maxA, Math.abs(v)); + for (const v of h2) maxA = Math.max(maxA, Math.abs(v)); + if (maxA < 0.001) return; + + const drawLine = (data, color) => { + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath(); + for (let i = 0; i < data.length; i++) { + const x = cx + (i / (data.length - 1)) * cw; + const y = cy + ch / 2 - (data[i] / maxA) * (ch / 2 - 4); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + }; + drawLine(h1, '#9B5DE5'); + drawLine(h2, '#06D6E0'); + + ctx.font = '10px Manrope, sans-serif'; ctx.textBaseline = 'bottom'; + ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'left'; ctx.fillText('θ₁', cx + 2, cy); + ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'left'; ctx.fillText('θ₂', cx + 18, cy); + } + + /* ── draw: spring ────────────────────────────── */ + + _drawSpring(ctx, W, H) { + const sp = this.sp; + const isVert = sp.mode === 'vert'; + + if (isVert) { + this._drawSpringVert(ctx, W, H); + } else { + this._drawSpringHoriz(ctx, W, H); + } + + // displacement chart + this._drawSpringChart(ctx, W, H); + + // period label + const T = 2 * Math.PI * Math.sqrt(sp.m / sp.k); + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '12px Manrope, sans-serif'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('T = 2π√(m/k) = ' + T.toFixed(2) + ' с', 12, 12); + if (!isVert) { + ctx.fillText('(T не зависит от g)', 12, 28); + } + } + + _drawSpringVert(ctx, W, H) { + const sp = this.sp; + const anchorX = W / 2; + const anchorY = H * 0.15; + const eqY = anchorY + sp.restLen * 300; // equilibrium position in px + const bobY = eqY + sp.x * 300; + const springEndY = bobY - 20; + + // ceiling + 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'); + + // equilibrium dashed line + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(anchorX - 50, eqY); ctx.lineTo(anchorX + 50, eqY); ctx.stroke(); + ctx.setLineDash([]); + + this._drawBobGlow(ctx, anchorX, bobY, 20, '#06D6E0'); + } + + _drawSpringHoriz(ctx, W, H) { + const sp = this.sp; + const anchorX = W * 0.25; + const baseY = H * 0.5; + const eqX = anchorX + sp.restLen * 300; + const bobX = eqX + sp.x * 300; + + // wall + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.fillRect(anchorX - 8, baseY - 30, 8, 60); + + // floor line + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + 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'); + + // equilibrium dashed + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(eqX, baseY - 40); ctx.lineTo(eqX, baseY + 40); ctx.stroke(); + ctx.setLineDash([]); + + this._drawBobGlow(ctx, bobX, baseY, 20, '#06D6E0'); + } + + _drawSpringCoils(ctx, x1, y1, x2, y2, coils, amp, color) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.sqrt(dx * dx + dy * dy); + if (len < 1) return; + const ux = dx / len, uy = dy / len; + const nx = -uy, ny = ux; + ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); + ctx.moveTo(x1, y1); + const seg = coils * 2 + 2; + for (let i = 1; i < seg; i++) { + const t = i / seg; + const side = (i % 2 === 0) ? amp : -amp; + ctx.lineTo(x1 + t * dx + nx * side, y1 + t * dy + ny * side); + } + ctx.lineTo(x2, y2); + ctx.stroke(); + } + + _drawSpringChart(ctx, W, H) { + const data = this.sp.hist; + if (data.length < 2) return; + const cw = Math.min(W * 0.55, 280); + const ch = 70; + const cx = W - cw - 16; + const cy = H - ch - 16; + + ctx.fillStyle = 'rgba(22,22,38,0.75)'; + ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill(); + + let maxX = 0; + for (const v of data) maxX = Math.max(maxX, Math.abs(v)); + if (maxX < 0.0001) return; + + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.beginPath(); + for (let i = 0; i < data.length; i++) { + const x = cx + (i / (data.length - 1)) * cw; + const y = cy + ch / 2 - (data[i] / maxX) * (ch / 2 - 4); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + + // zero line + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(cx, cy + ch / 2); ctx.lineTo(cx + cw, cy + ch / 2); ctx.stroke(); + ctx.setLineDash([]); + + ctx.font = '10px Manrope, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillText('x(t)', cx + 2, cy); + } + + /* ── draw: physical ──────────────────────────── */ + + _drawPhysical(ctx, W, H) { + const ph = this.ph; + const px = W / 2; + const py = Math.min(H * 0.15, 70); + const th = ph.theta; + const L = ph.L; + + // pivot + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.fillRect(px - 30, py - 4, 60, 4); + ctx.fillStyle = '#666'; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); + + ctx.save(); + ctx.translate(px, py); + ctx.rotate(th); + + switch (ph.shape) { + case 'rod': + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 6; + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, L); ctx.stroke(); + ctx.fillStyle = '#9B5DE5'; + ctx.beginPath(); ctx.arc(0, L, 10, 0, Math.PI * 2); ctx.fill(); + break; + case 'hoop': { + const R = L * 0.5; + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 6; + ctx.beginPath(); ctx.arc(0, L, R, 0, Math.PI * 2); ctx.stroke(); + // line from pivot to hoop center + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, L); ctx.stroke(); + break; + } + case 'disk': { + const R = L * 0.4; + ctx.fillStyle = 'rgba(255,209,102,0.25)'; + ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(0, L, R, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, L); ctx.stroke(); + break; + } + case 'rect': { + const rw = 40, rh = L * 1.8; + ctx.fillStyle = 'rgba(6,214,224,0.15)'; + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.roundRect(-rw / 2, 0, rw, rh, 4); ctx.fill(); ctx.stroke(); + break; + } + } + + ctx.restore(); + + this._drawAngleArc(ctx, px, py, th); + + // period comparison box + const { I, d } = this._physInertia(); + const Tphys = 2 * Math.PI * Math.sqrt(I / (ph.g * 100 * d)); + const Tmath = 2 * Math.PI * Math.sqrt(L / (ph.g * 100)); + + ctx.fillStyle = 'rgba(22,22,38,0.8)'; + ctx.beginPath(); ctx.roundRect(12, 12, 210, 52, 8); ctx.fill(); + ctx.font = '11px Manrope, sans-serif'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillStyle = '#FFD166'; + ctx.fillText('T_физ = ' + Tphys.toFixed(2) + ' с (' + ph.shape + ')', 20, 18); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fillText('T_мат = ' + Tmath.toFixed(2) + ' с (матем.)', 20, 34); + + if (this.showPhase) return; // skip energy bar when phase portrait active + } + + /* ── draw: foucault ──────────────────────────── */ + + _drawFoucault(ctx, W, H) { + const fc = this.fc; + const cx = W / 2; + const cy = H / 2; + const R = Math.min(W, H) * 0.38; + + // sand floor circle + ctx.fillStyle = 'rgba(180,150,100,0.12)'; + ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(180,150,100,0.3)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke(); + + // compass directions + ctx.font = '12px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillText('N', cx, cy - R + 14); + ctx.fillText('S', cx, cy + R - 14); + ctx.fillText('W', cx - R + 14, cy); + ctx.fillText('E', cx + R - 14, cy); + + // trail + const trail = fc.trail; + if (trail.length > 1) { + for (let i = 1; i < trail.length; i++) { + const a = (i / trail.length) * 0.7; + ctx.strokeStyle = 'rgba(255,209,102,' + a.toFixed(2) + ')'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(cx + trail[i - 1].x, cy + trail[i - 1].y); + ctx.lineTo(cx + trail[i].x, cy + trail[i].y); + ctx.stroke(); + } + } + + // current bob + this._drawBobGlow(ctx, cx + fc.x, cy + fc.y, 10, '#FFD166'); + + // center dot + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill(); + + // info overlay + const phiDeg = (fc.phi * 180 / Math.PI).toFixed(0); + const sinPhi = Math.sin(fc.phi); + const Trot = sinPhi > 0.001 ? (24 / sinPhi).toFixed(1) + ' ч' : '∞'; + ctx.fillStyle = 'rgba(22,22,38,0.8)'; + ctx.beginPath(); ctx.roundRect(12, 12, 210, 52, 8); ctx.fill(); + ctx.font = '11px Manrope, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillStyle = '#FFD166'; + ctx.fillText('широта φ = ' + phiDeg + '°', 20, 18); + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.fillText('T_поворота = ' + Trot, 20, 34); + } + + /* ── draw: resonance ─────────────────────────── */ + + _drawResonance(ctx, W, H) { + const rs = this.rs; + + // pendulum animation (left half) + const animW = Math.floor(W * 0.5); + const px = animW / 2; + const py = Math.min(H * 0.18, 80); + const bx = px + rs.L * Math.sin(rs.theta); + const by = py + rs.L * Math.cos(rs.theta); + + // driving force arrow + const Fy = rs.F0 * Math.cos(rs.dOmega * rs.tSim) * 40; + ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + Fy * 0.5, by); ctx.stroke(); + // arrowhead + const arrowDir = Fy > 0 ? 1 : -1; + ctx.fillStyle = '#EF476F'; + ctx.beginPath(); + ctx.moveTo(bx + Fy * 0.5, by); + ctx.lineTo(bx + Fy * 0.5 - arrowDir * 8, by - 5); + ctx.lineTo(bx + Fy * 0.5 - arrowDir * 8, by + 5); + ctx.closePath(); ctx.fill(); + + // support & rod + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.fillRect(px - 30, py - 4, 60, 4); + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(bx, by); ctx.stroke(); + ctx.fillStyle = '#666'; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); + this._drawBobGlow(ctx, bx, by, 18, '#9B5DE5'); + + // resonance curve (right half) + this._drawResonanceCurve(ctx, animW, 0, W - animW, H); + } + + _drawResonanceCurve(ctx, offX, offY, cw, ch) { + const rs = this.rs; + if (rs.curveDirty) this._buildResonanceCurve(); + const data = rs.curve; + if (data.length < 2) return; + + const pad = 40; + const iw = cw - pad * 2; + const ih = ch - pad * 2 - 16; + const ox = offX + pad; + const oy = offY + pad; + + ctx.fillStyle = 'rgba(22,22,38,0.7)'; + ctx.beginPath(); ctx.roundRect(offX + 8, offY + 8, cw - 16, ch - 16, 10); ctx.fill(); + + let maxA = 0; + for (const p of data) maxA = Math.max(maxA, p.A); + if (maxA < 0.001) return; + + const maxW = data[data.length - 1].w; + + // axes + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(ox, oy + ih); ctx.lineTo(ox + iw, oy + ih); ctx.stroke(); + + // curve + ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i < data.length; i++) { + const x = ox + (data[i].w / maxW) * iw; + const y = oy + ih - (data[i].A / maxA) * ih; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + + // current omega marker + const omega0 = Math.sqrt(rs.g * 100 / rs.L); + const curX = ox + (rs.dOmega / maxW) * iw; + ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(curX, oy); ctx.lineTo(curX, oy + ih); ctx.stroke(); + ctx.setLineDash([]); + + // omega0 line + const w0x = ox + (omega0 / maxW) * iw; + ctx.strokeStyle = 'rgba(6,214,224,0.5)'; ctx.lineWidth = 1; ctx.setLineDash([2, 4]); + ctx.beginPath(); ctx.moveTo(w0x, oy); ctx.lineTo(w0x, oy + ih); ctx.stroke(); + ctx.setLineDash([]); + + // labels + ctx.font = '10px Manrope, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ω', ox + iw / 2, oy + ih + 4); + ctx.fillStyle = '#06D6E0'; ctx.fillText('ω₀', w0x, oy + 2); + ctx.fillStyle = '#EF476F'; ctx.fillText('ω′', curX, oy + 12); + ctx.fillStyle = '#FFD166'; ctx.textAlign = 'right'; + ctx.fillText('A(ω)', ox + iw, oy + 4); + } + + /* ── draw helpers ─────────────────────────────── */ + + _drawBobGlow(ctx, bx, by, r, color) { + const draw = () => { + ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(bx, by, r, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke(); + const grad = ctx.createRadialGradient(bx, by, 0, bx, by, r * 2); + grad.addColorStop(0, color.replace(')', ',0.25)').replace('rgb', 'rgba')); + grad.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = grad; - ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(bx, by, r * 2, 0, Math.PI * 2); ctx.fill(); }; if (window.LabFX) { - LabFX.glow.drawGlow(ctx, _drawBob, { color: '#9B5DE5', intensity: 8 }); + LabFX.glow.drawGlow(ctx, draw, { color, intensity: 8 }); } else { - _drawBob(); + draw(); } - - // angle arc - if (Math.abs(this.theta) > 0.02) { - ctx.strokeStyle = 'rgba(6,214,224,0.5)'; - ctx.lineWidth = 1.5; - const arcR = 40; - const startAngle = Math.PI / 2; - const endAngle = Math.PI / 2 + this.theta; - ctx.beginPath(); - ctx.arc(px, py, arcR, Math.min(startAngle, endAngle), Math.max(startAngle, endAngle)); - ctx.stroke(); - - ctx.fillStyle = '#06D6E0'; - ctx.font = '12px Manrope, sans-serif'; - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const labelAngle = startAngle + this.theta / 2; - ctx.fillText( - (this.theta * 180 / Math.PI).toFixed(1) + '°', - px + (arcR + 16) * Math.cos(labelAngle), - py + (arcR + 16) * Math.sin(labelAngle) - ); - } - - // energy bar - this._drawEnergyBar(ctx, W, H); - - // energy chart - this._drawEnergyChart(ctx, W, H); - - /* LabFX: particles overlay */ - if (window.LabFX) LabFX.particles.draw(ctx); } - _drawTrail(ctx) { - const n = this._trail.length; + _drawTrailPts(ctx, pts, color) { + const n = pts.length; if (n < 2) return; for (let i = 1; i < n; i++) { - const a = i / n * 0.6; - ctx.strokeStyle = `rgba(155,93,229,${a})`; + const a = (i / n) * 0.7; + ctx.strokeStyle = color.startsWith('#') + ? color + Math.round(a * 255).toString(16).padStart(2, '0') + : `rgba(155,93,229,${a})`; ctx.lineWidth = 1.5; ctx.beginPath(); - ctx.moveTo(this._trail[i - 1].x, this._trail[i - 1].y); - ctx.lineTo(this._trail[i].x, this._trail[i].y); + ctx.moveTo(pts[i - 1].x, pts[i - 1].y); + ctx.lineTo(pts[i].x, pts[i].y); ctx.stroke(); } } + _drawAngleArc(ctx, px, py, theta) { + if (Math.abs(theta) < 0.02) return; + ctx.strokeStyle = 'rgba(6,214,224,0.5)'; + ctx.lineWidth = 1.5; + const arcR = 40; + const startAngle = Math.PI / 2; + const endAngle = Math.PI / 2 + theta; + ctx.beginPath(); + ctx.arc(px, py, arcR, Math.min(startAngle, endAngle), Math.max(startAngle, endAngle)); + ctx.stroke(); + ctx.fillStyle = '#06D6E0'; + ctx.font = '12px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const labelAngle = startAngle + theta / 2; + ctx.fillText( + (theta * 180 / Math.PI).toFixed(1) + '°', + px + (arcR + 16) * Math.cos(labelAngle), + py + (arcR + 16) * Math.sin(labelAngle) + ); + } + + /* ── draw: energy bar (math mode) ───────────────── */ + _drawEnergyBar(ctx, W, H) { const KE = 0.5 * this.omega * this.omega * this.L * this.L; const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); @@ -278,27 +1292,24 @@ class PendulumSim { ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(x - 8, y - 6, bw + 16, bh + 32, 8); ctx.fill(); - // KE bar const kw = (KE / total) * bw; ctx.fillStyle = '#EF476F'; ctx.beginPath(); ctx.roundRect(x, y, Math.max(2, kw), bh, 4); ctx.fill(); - - // PE bar ctx.fillStyle = '#06D6E0'; ctx.beginPath(); ctx.roundRect(x + kw, y, Math.max(2, bw - kw), bh, 4); ctx.fill(); - ctx.font = '10px Manrope, sans-serif'; - ctx.textBaseline = 'top'; + ctx.font = '10px Manrope, sans-serif'; ctx.textBaseline = 'top'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; ctx.fillText('KE ' + Math.round(KE / total * 100) + '%', x, y + bh + 4); ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right'; ctx.fillText('PE ' + Math.round(PE / total * 100) + '%', x + bw, y + bh + 4); } + /* ── draw: energy chart (math mode) ─────────────── */ + _drawEnergyChart(ctx, W, H) { const data = this._eHistory; if (data.length < 2) return; - const cw = Math.min(300, W * 0.4); const ch = 80; const cx = W - cw - 20; @@ -311,22 +1322,16 @@ class PendulumSim { for (const d of data) maxE = Math.max(maxE, d.ke + d.pe); if (maxE < 0.01) return; - // PE filled area ctx.fillStyle = 'rgba(6,214,224,0.2)'; - ctx.beginPath(); - ctx.moveTo(cx, cy + ch); + ctx.beginPath(); ctx.moveTo(cx, cy + ch); for (let i = 0; i < data.length; i++) { const x = cx + (i / (data.length - 1)) * cw; const y = cy + ch - (data[i].pe / maxE) * ch; ctx.lineTo(x, y); } - ctx.lineTo(cx + cw, cy + ch); - ctx.closePath(); ctx.fill(); + ctx.lineTo(cx + cw, cy + ch); ctx.closePath(); ctx.fill(); - // KE line - ctx.strokeStyle = '#EF476F'; - ctx.lineWidth = 1.5; - ctx.beginPath(); + ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < data.length; i++) { const x = cx + (i / (data.length - 1)) * cw; const y = cy + ch - (data[i].ke / maxE) * ch; @@ -334,33 +1339,88 @@ class PendulumSim { } ctx.stroke(); - // total line - ctx.strokeStyle = 'rgba(255,255,255,0.25)'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); for (let i = 0; i < data.length; i++) { const x = cx + (i / (data.length - 1)) * cw; const y = cy + ch - ((data[i].ke + data[i].pe) / maxE) * ch; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } - ctx.stroke(); - ctx.setLineDash([]); + ctx.stroke(); ctx.setLineDash([]); - // labels - ctx.font = '10px Manrope, sans-serif'; - ctx.textBaseline = 'bottom'; + ctx.font = '10px Manrope, sans-serif'; ctx.textBaseline = 'bottom'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; ctx.fillText('KE', cx + 2, cy); ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.fillText('PE', cx + 30, cy); ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.textAlign = 'right'; ctx.fillText('Total', cx + cw, cy); } + /* ── draw: phase portrait ─────────────────────── */ + + _drawPhasePortrait(ctx, offX, offY, panelW, panelH) { + const pts = this._phaseTrail; + const pad = 30; + const iw = panelW - pad * 2; + const ih = panelH - pad * 2 - 30; + const ox = offX + pad; + const oy = offY + pad; + + ctx.fillStyle = 'rgba(13,13,26,0.92)'; + ctx.fillRect(offX, offY, panelW, panelH); + + // axes + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(ox, oy); ctx.lineTo(ox, oy + ih); ctx.lineTo(ox + iw, oy + ih); + ctx.stroke(); + // center lines + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(ox + iw / 2, oy); ctx.lineTo(ox + iw / 2, oy + ih); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(ox, oy + ih / 2); ctx.lineTo(ox + iw, oy + ih / 2); ctx.stroke(); + ctx.setLineDash([]); + + if (pts.length > 1) { + let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity; + for (const p of pts) { + xMin = Math.min(xMin, p.x); xMax = Math.max(xMax, p.x); + yMin = Math.min(yMin, p.y); yMax = Math.max(yMax, p.y); + } + const xRange = Math.max(xMax - xMin, 0.1); + const yRange = Math.max(yMax - yMin, 0.1); + const mapX = (x) => ox + ((x - xMin) / xRange) * iw; + const mapY = (y) => oy + ih - ((y - yMin) / yRange) * ih; + + for (let i = 1; i < pts.length; i++) { + const a = (i / pts.length) * 0.8; + ctx.strokeStyle = `rgba(155,93,229,${a.toFixed(2)})`; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(mapX(pts[i - 1].x), mapY(pts[i - 1].y)); + ctx.lineTo(mapX(pts[i].x), mapY(pts[i].y)); + ctx.stroke(); + } + + // current point + const last = pts[pts.length - 1]; + ctx.fillStyle = '#FFD166'; + ctx.beginPath(); ctx.arc(mapX(last.x), mapY(last.y), 4, 0, Math.PI * 2); ctx.fill(); + } + + // labels + ctx.font = '10px Manrope, sans-serif'; ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.textBaseline = 'top'; ctx.fillText('Фазовый портрет', offX + panelW / 2, offY + 6); + ctx.textBaseline = 'bottom'; ctx.fillText('θ', offX + panelW / 2, offY + panelH - 4); + ctx.save(); ctx.translate(offX + 12, offY + panelH / 2); ctx.rotate(-Math.PI / 2); + ctx.fillText('ω', 0, 0); ctx.restore(); + } + /* ── events ─────────────────────────────────── */ _bindEvents() { const cv = this.canvas; cv.addEventListener('mousedown', e => { + if (this.mode !== 'math') return; const { bx, by } = this._bobPos(); const r = cv.getBoundingClientRect(); const mx = (e.clientX - r.left) * (this.W / r.width); @@ -385,15 +1445,11 @@ class PendulumSim { }); window.addEventListener('mouseup', () => { - if (this._drag) { - this._drag = false; - this.play(); - } + if (this._drag) { this._drag = false; this.play(); } }); - // touch cv.addEventListener('touchstart', e => { - if (e.touches.length !== 1) return; + if (this.mode !== 'math' || e.touches.length !== 1) return; const { bx, by } = this._bobPos(); const r = cv.getBoundingClientRect(); const mx = (e.touches[0].clientX - r.left) * (this.W / r.width); @@ -403,6 +1459,7 @@ class PendulumSim { this.pause(); } }, { passive: true }); + cv.addEventListener('touchmove', e => { if (!this._drag) return; e.preventDefault(); @@ -416,56 +1473,178 @@ class PendulumSim { this.draw(); this._emit(); }, { passive: false }); + cv.addEventListener('touchend', () => { if (this._drag) { this._drag = false; this.play(); } }); } } -/* ─── lab UI init ─────────────────────────────────── */ - var pendSim = null; +/* ═══════════════════════════════════════════════════════════════ + lab UI init +═══════════════════════════════════════════════════════════════ */ - function _openPendulum() { - document.getElementById('sim-topbar-title').textContent = 'Маятник'; - _simShow('sim-pendulum'); - _registerSimState('pendulum', () => pendSim?.getParams(), st => pendSim?.setParams(st)); - if (_embedMode) _startStateEmit('pendulum'); - requestAnimationFrame(() => requestAnimationFrame(() => { - if (!pendSim) { - pendSim = new PendulumSim(document.getElementById('pendulum-canvas')); - pendSim.onUpdate = _pendUpdateUI; - } - pendSim.fit(); - pendSim.play(); - })); - } +var pendSim = null; - function pendParam(name, val) { - const v = parseFloat(val); - const ids = { theta: 'pend-theta-val', L: 'pend-L-val', g: 'pend-g-val', damping: 'pend-damp-val' }; - const el = document.getElementById(ids[name]); - if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(name === 'g' ? 2 : 1); - if (pendSim) pendSim.setParams({ [name]: v }); - } - - function pendPreset(theta, L, g, damp) { - document.getElementById('sl-pend-theta').value = theta; document.getElementById('pend-theta-val').textContent = theta; - document.getElementById('sl-pend-L').value = L; document.getElementById('pend-L-val').textContent = L; - document.getElementById('sl-pend-g').value = g; document.getElementById('pend-g-val').textContent = g; - document.getElementById('sl-pend-damp').value = damp; document.getElementById('pend-damp-val').textContent = damp; - if (pendSim) { - pendSim.setParams({ theta, L, g, damping: damp }); - pendSim.play(); +function _openPendulum() { + document.getElementById('sim-topbar-title').textContent = 'Маятник'; + _simShow('sim-pendulum'); + _registerSimState('pendulum', () => pendSim?.getParams(), st => pendSim?.setParams(st)); + if (_embedMode) _startStateEmit('pendulum'); + requestAnimationFrame(() => requestAnimationFrame(() => { + if (!pendSim) { + pendSim = new PendulumSim(document.getElementById('pendulum-canvas')); + pendSim.onUpdate = _pendUpdateUI; } + pendSim.fit(); + pendSim.setMode(pendSim.mode || 'math'); + pendSim.play(); + })); +} + +function pendSetMode(m) { + if (!pendSim) return; + pendSim.setMode(m); + // toggle button highlight + document.querySelectorAll('.pend-mode-btn').forEach(b => { + b.classList.toggle('active', b.dataset.mode === m); + }); + // show/hide param panels + document.querySelectorAll('.pend-params').forEach(el => { + el.style.display = el.dataset.mode === m ? '' : 'none'; + }); + pendSim.play(); +} + +function pendTogglePhase() { + if (!pendSim) return; + pendSim.showPhase = !pendSim.showPhase; + pendSim._clearPhase(); + const btn = document.getElementById('btn-pend-phase'); + if (btn) btn.classList.toggle('active', pendSim.showPhase); +} + +function pendToggleGhost() { + if (!pendSim) return; + pendSim.d.showGhost = !pendSim.d.showGhost; + if (pendSim.d.showGhost) pendSim._initDoubleGhost(); + else pendSim.d.ghostTrail = []; + const btn = document.getElementById('btn-pend-ghost'); + if (btn) btn.classList.toggle('active', pendSim.d.showGhost); +} + +function pendParam(name, val) { + const v = parseFloat(val); + const ids = { theta: 'pend-theta-val', L: 'pend-L-val', g: 'pend-g-val', damping: 'pend-damp-val' }; + const el = document.getElementById(ids[name]); + if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(name === 'g' ? 2 : 1); + if (pendSim) pendSim.setParams({ [name]: v }); +} + +function pendPreset(theta, L, g, damp) { + document.getElementById('sl-pend-theta').value = theta; document.getElementById('pend-theta-val').textContent = theta; + document.getElementById('sl-pend-L').value = L; document.getElementById('pend-L-val').textContent = L; + document.getElementById('sl-pend-g').value = g; document.getElementById('pend-g-val').textContent = g; + document.getElementById('sl-pend-damp').value = damp; document.getElementById('pend-damp-val').textContent = damp; + if (pendSim) { + pendSim.setParams({ theta, L, g, damping: damp }); + pendSim.play(); } +} - function _pendUpdateUI(info) { - const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; - v('pendbar-v1', info.angle); - v('pendbar-v2', info.omega); - v('pendbar-v3', info.period); - v('pendbar-v4', info.energy); +/* double pendulum params */ +function pendDoubleParam(name, val) { + const v = parseFloat(val); + const el = document.getElementById('pend-d-' + name + '-val'); + if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(1); + if (!pendSim) return; + if (name === 'L1') { pendSim.d.L1 = v; } + else if (name === 'L2') { pendSim.d.L2 = v; } + else if (name === 'm1') { pendSim.d.m1 = v; } + else if (name === 'm2') { pendSim.d.m2 = v; } + else if (name === 'th1') { pendSim.d.th1 = v * Math.PI / 180; pendSim.d.om1 = 0; pendSim.d.trail = []; } + else if (name === 'th2') { pendSim.d.th2 = v * Math.PI / 180; pendSim.d.om2 = 0; pendSim.d.trail = []; } + if (pendSim.d.showGhost) pendSim._initDoubleGhost(); +} + +/* coupled pendulum params */ +function pendCoupledParam(name, val) { + const v = parseFloat(val); + const el = document.getElementById('pend-cp-' + name + '-val'); + if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(2); + if (!pendSim) return; + if (name === 'k') { pendSim.cp.k = v; } + else if (name === 'L') { pendSim.cp.L = v; pendSim.cp.hist1 = []; pendSim.cp.hist2 = []; } + else if (name === 'th1') { pendSim.cp.th1 = v * Math.PI / 180; pendSim.cp.om1 = 0; } +} + +/* spring params */ +function pendSpringParam(name, val) { + const v = parseFloat(val); + const el = document.getElementById('pend-sp-' + name + '-val'); + if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(1); + if (!pendSim) return; + if (name === 'k') { pendSim.sp.k = v; } + else if (name === 'm') { pendSim.sp.m = v; } + else if (name === 'x0') { pendSim.sp.x = v / 100; pendSim.sp.v = 0; pendSim.sp.hist = []; } +} + +function pendSpringMode(m) { + if (!pendSim) return; + pendSim.sp.mode = m; + pendSim.sp.x = 0.08; pendSim.sp.v = 0; pendSim.sp.hist = []; + document.querySelectorAll('.sp-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.m === m)); +} + +/* physical pendulum params */ +function pendPhysShape(s) { + if (!pendSim) return; + pendSim.ph.shape = s; + document.querySelectorAll('.ph-shape-btn').forEach(b => b.classList.toggle('active', b.dataset.s === s)); +} + +function pendPhysParam(name, val) { + const v = parseFloat(val); + const el = document.getElementById('pend-ph-' + name + '-val'); + if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(1); + if (!pendSim) return; + if (name === 'L') { pendSim.ph.L = v; } + else if (name === 'theta') { pendSim.ph.theta = v * Math.PI / 180; pendSim.ph.omega = 0; } + else if (name === 'damping') { pendSim.ph.damping = v; } +} + +/* foucault params */ +function pendFoucaultParam(name, val) { + const v = parseFloat(val); + const el = document.getElementById('pend-fc-' + name + '-val'); + if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(0); + if (!pendSim) return; + if (name === 'phi') { + pendSim.fc.phi = v * Math.PI / 180; + pendSim.fc.trail = []; pendSim.fc.tSim = 0; + pendSim.fc.x = 60; pendSim.fc.y = 0; pendSim.fc.vx = 0; pendSim.fc.vy = 0; } +} - /* ── equilibrium ── */ +/* resonance params */ +function pendResonanceParam(name, val) { + const v = parseFloat(val); + const el = document.getElementById('pend-rs-' + name + '-val'); + if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(2); + if (!pendSim) return; + if (name === 'dOmega') { pendSim.rs.dOmega = v; pendSim.rs.curveDirty = true; } + else if (name === 'F0') { pendSim.rs.F0 = v; pendSim.rs.curveDirty = true; } + else if (name === 'gamma') { pendSim.rs.gamma = v; pendSim.rs.curveDirty = true; } + else if (name === 'L') { + pendSim.rs.L = v; pendSim.rs.curveDirty = true; + pendSim.rs.theta = 0.1; pendSim.rs.omega = 0; + } +} +function _pendUpdateUI(info) { + const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; + v('pendbar-v1', info.angle); + v('pendbar-v2', info.omega); + v('pendbar-v3', info.period); + v('pendbar-v4', info.energy); +} diff --git a/frontend/js/labs/projectile.js b/frontend/js/labs/projectile.js index aff077d..7479517 100644 --- a/frontend/js/labs/projectile.js +++ b/frontend/js/labs/projectile.js @@ -1,11 +1,12 @@ 'use strict'; /* ═══════════════════════════════════════════════════════════════════ - ProjectileSim v3 — physics simulation + ProjectileSim v4 — physics simulation Features: air drag (RK4) · wind · bounce · speed multiplier ghost trail comparison · velocity vector labels range arrow · landing angle · canvas click play/pause target challenge mode · x/y/vx/vy graphs · dual throw + parachute physics · ramp launch · multi-planet gravity ═══════════════════════════════════════════════════════════════════ */ class ProjectileSim { @@ -86,6 +87,43 @@ class ProjectileSim { t: 0, trail: [], }; + /* ── Feature 4: parachute ── */ + this.parachute = false; // parachute mode on/off + this.chuteArea = 1.0; // A m² cross-section + this.chuteCd = 1.5; // drag coefficient (preset: parachute) + this.chuteOpenHeight = -1; // -1 = immediate; >=0 = open at this altitude + this._chuteOpen = false; // runtime: is chute deployed? + this._chuteOpenedTs = -999; // perf.now when deployed + this._chimeEmitted = false; // v_t chime fired once per run + + /* ── Feature 5: ramp launch ── */ + this.ramp = false; // ramp/slope mode on/off + this.rampAngle = 30; // degrees + this.rampLength = 10; // m + this.rampMu = 0.1; // friction coefficient + this._rampV0 = 0; // computed launch speed from ramp + + /* ── Feature 6: planet gravity ── */ + // planets table: { name, g, rho } (rho = atmospheric density kg/m³) + this.planets = [ + { id: 'earth', name: 'Земля', g: 9.81, rho: 1.225 }, + { id: 'moon', name: 'Луна', g: 1.62, rho: 0 }, + { id: 'mars', name: 'Марс', g: 3.71, rho: 0.020 }, + { id: 'venus', name: 'Венера', g: 8.87, rho: 65 }, + { id: 'jupiter', name: 'Юпитер', g: 24.79, rho: 1.3 }, + { id: 'mercury', name: 'Меркурий', g: 3.7, rho: 0 }, + { id: 'saturn', name: 'Сатурн', g: 10.44, rho: 0.19 }, + { id: 'uranus', name: 'Уран', g: 8.69, rho: 0.42 }, + { id: 'neptune', name: 'Нептун', g: 11.15, rho: 0.45 }, + { id: 'pluto', name: 'Плутон', g: 0.62, rho: 0.0001 }, + ]; + this.planetId = 'earth'; // active planet + this.rho = 1.225; // air density (set by planet or override) + + /* ── Feature 6b: multi-planet compare ── */ + this.planetCompare = false; // show 3 planet trajectories simultaneously + this.comparePlanets = ['earth', 'moon', 'mars']; // which 3 + canvas.addEventListener('click', () => { if (this.onPlayPause) this.onPlayPause(); }); @@ -110,19 +148,42 @@ class ProjectileSim { getParams() { return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g, drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind, - bounce: this.bounce, restitution: this.restitution }; + bounce: this.bounce, restitution: this.restitution, + parachute: this.parachute, chuteArea: this.chuteArea, chuteCd: this.chuteCd, + chuteOpenHeight: this.chuteOpenHeight, + ramp: this.ramp, rampAngle: this.rampAngle, rampLength: this.rampLength, rampMu: this.rampMu, + planetId: this.planetId }; } - setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) { - if (v0 !== undefined) this.v0 = +v0; - if (angle !== undefined) this.angle = +angle; - if (h0 !== undefined) this.h0 = +h0; - if (g !== undefined) this.g = +g; - if (drag !== undefined) this.drag = !!drag; - if (Cd !== undefined) this.Cd = +Cd; - if (mass !== undefined) this.mass = Math.max(0.1, +mass); - if (wind !== undefined) this.wind = +wind; - if (bounce !== undefined) this.bounce = !!bounce; - if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution)); + setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution, + parachute, chuteArea, chuteCd, chuteOpenHeight, + ramp, rampAngle, rampLength, rampMu, + planetId } = {}) { + if (v0 !== undefined) this.v0 = +v0; + if (angle !== undefined) this.angle = +angle; + if (h0 !== undefined) this.h0 = +h0; + if (g !== undefined) this.g = +g; + if (drag !== undefined) this.drag = !!drag; + if (Cd !== undefined) this.Cd = +Cd; + if (mass !== undefined) this.mass = Math.max(0.1, +mass); + if (wind !== undefined) this.wind = +wind; + if (bounce !== undefined) this.bounce = !!bounce; + if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution)); + if (parachute !== undefined) this.parachute = !!parachute; + if (chuteArea !== undefined) this.chuteArea = Math.max(0.1, +chuteArea); + if (chuteCd !== undefined) this.chuteCd = +chuteCd; + if (chuteOpenHeight !== undefined) this.chuteOpenHeight = +chuteOpenHeight; + if (ramp !== undefined) this.ramp = !!ramp; + if (rampAngle !== undefined) this.rampAngle = Math.max(1, Math.min(89, +rampAngle)); + if (rampLength !== undefined) this.rampLength = Math.max(1, +rampLength); + if (rampMu !== undefined) this.rampMu = Math.max(0, Math.min(1, +rampMu)); + if (planetId !== undefined) { + this.planetId = planetId; + const pl = this.planets.find(p => p.id === planetId); + if (pl) { + this.g = pl.g; + this.rho = pl.rho; + } + } this._computePath(); if (this.dualMode) this._computeP2Path(); this._resetFX(); @@ -135,7 +196,10 @@ class ProjectileSim { play() { if (this.playing) return; if (this._pathTf > 0 && this.t >= this._pathTf) this._resetFX(); - this._launchFlash = 1; + this._launchFlash = 1; + this._chuteOpen = this.parachute && this.chuteOpenHeight < 0; + this._chuteOpenedTs = this._chuteOpen ? performance.now() : -999; + this._chimeEmitted = false; this.playing = true; this._lastTs = null; /* reset p2 at launch so both start simultaneously */ @@ -517,7 +581,7 @@ class ProjectileSim { return; } - const rho = 1.225, A = 0.00785; + const rho = this.rho, A = 0.00785; const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0; const g = this.g, W = this.wind, e = this.restitution; const maxBounces = this.bounce ? 7 : 0; @@ -573,12 +637,13 @@ class ProjectileSim { /* pure analytical solution (no drag/wind/bounce) */ _stateAnalytical(t) { - const rad = this.angle * Math.PI / 180; - const vx = this.v0 * Math.cos(rad); - const vy0 = this.v0 * Math.sin(rad); + const launch = this._effectiveLaunch(); + const rad = launch.angle * Math.PI / 180; + const vx = launch.v0 * Math.cos(rad); + const vy0 = launch.v0 * Math.sin(rad); return { x: vx * t, - y: this.h0 + vy0 * t - 0.5 * this.g * t * t, + y: launch.h0 + vy0 * t - 0.5 * this.g * t * t, vx, vy: vy0 - this.g * t, }; @@ -586,18 +651,43 @@ class ProjectileSim { /* analytical flight time (for reference / no-effect comparison) */ _tFlightAnalytical() { - const rad = this.angle * Math.PI / 180; - const vy0 = this.v0 * Math.sin(rad); - const disc = vy0 * vy0 + 2 * this.g * this.h0; + const launch = this._effectiveLaunch(); + const rad = launch.angle * Math.PI / 180; + const vy0 = launch.v0 * Math.sin(rad); + const disc = vy0 * vy0 + 2 * this.g * launch.h0; if (disc < 0) return 0; return Math.max(0, (vy0 + Math.sqrt(disc)) / this.g); } _needsNumerical() { - return this.drag || this.wind !== 0 || this.bounce; + return this.drag || this.parachute || this.wind !== 0 || this.bounce || this.ramp; } - /* RK4 integration — handles drag, wind, bounce */ + /* compute launch speed from ramp: v = sqrt(2·g·L·sinα·(1-μ·cosα/sinα)) + v = sqrt(2·g·L·(sinα - μ·cosα)) assuming μ < tanα else no motion */ + _rampComputeV0() { + const a = this.rampAngle * Math.PI / 180; + const sin = Math.sin(a), cos = Math.cos(a); + const net = sin - this.rampMu * cos; + if (net <= 0) return 0; + return Math.sqrt(2 * this.g * this.rampLength * net); + } + + /* effective launch angle = ramp angle when ramp is active */ + _effectiveLaunch() { + if (this.ramp) { + const v = this._rampComputeV0(); + return { v0: v, angle: this.rampAngle, h0: this.h0 }; + } + return { v0: this.v0, angle: this.angle, h0: this.h0 }; + } + + /* terminal velocity for current parachute config */ + _terminalVelocity() { + return Math.sqrt(2 * this.mass * this.g / (this.chuteCd * this.rho * this.chuteArea)); + } + + /* RK4 integration — handles drag, parachute, wind, bounce, ramp */ _computePath() { if (!this._needsNumerical()) { this._path = null; @@ -605,28 +695,47 @@ class ProjectileSim { return; } - const rho = 1.225, A = 0.00785; // air density, ball cross-section - const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0; - const g = this.g; - const W = this.wind; - const e = this.restitution; + const rho = this.rho; // air density (planet-aware) + const A_ball = 0.00785; // small ball cross-section m² + const g = this.g; + const W = this.wind; + const e = this.restitution; const maxBounces = this.bounce ? 7 : 0; + const mass = Math.max(0.1, this.mass); - const rad = this.angle * Math.PI / 180; - let x = 0, y = this.h0; - let vx = this.v0 * Math.cos(rad); - let vy = this.v0 * Math.sin(rad); + /* simple-drag k factor (ball drag, legacy mode) */ + const kBall = this.drag && !this.parachute + ? 0.5 * this.Cd * rho * A_ball / mass + : 0; + + /* parachute: open immediately if chuteOpenHeight < 0, else on altitude trigger */ + const chuteAutoOpen = this.parachute && this.chuteOpenHeight < 0; + const chuteThreshold = this.parachute ? Math.max(0, this.chuteOpenHeight) : Infinity; + + const launch = this._effectiveLaunch(); + const rad = launch.angle * Math.PI / 180; + let x = 0, y = launch.h0; + let vx = launch.v0 * Math.cos(rad); + let vy = launch.v0 * Math.sin(rad); + let chuteOpen = chuteAutoOpen; const dt = 0.005; - const path = [{ x, y, vx, vy, t: 0 }]; + const path = [{ x, y, vx, vy, t: 0, chuteOpen }]; let bounceCount = 0; - const deriv = (sx, sy, svx, svy) => { - const rvx = svx - W; // velocity relative to wind - const rvy = svy; + const deriv = (sx, sy, svx, svy, chute) => { + const rvx = svx - W; + const rvy = svy; const speed = Math.sqrt(rvx * rvx + rvy * rvy); - const dragF = speed > 0 ? k * speed : 0; - // wind-only pseudo-force when drag is off (simplified model) - const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0; + let dragF = 0; + if (chute) { + /* parachute: F_d = 0.5 * Cd * rho * A * v² / m → acceleration */ + dragF = speed > 0 + ? 0.5 * this.chuteCd * rho * this.chuteArea * speed / mass + : 0; + } else if (kBall > 0) { + dragF = speed > 0 ? kBall * speed : 0; + } + const windAcc = (!this.drag && !chute && W !== 0) ? W * 0.05 : 0; return { dx: svx, dy: svy, @@ -636,10 +745,15 @@ class ProjectileSim { }; for (let step = 0; step < 200000; step++) { - const k1 = deriv(x, y, vx, vy); - const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2); - const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2); - const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt); + /* check if chute should open by altitude trigger */ + if (this.parachute && !chuteOpen && y <= chuteThreshold && y > 0) { + chuteOpen = true; + } + + const k1 = deriv(x, y, vx, vy, chuteOpen); + const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2, chuteOpen); + const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2, chuteOpen); + const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt, chuteOpen); x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6; y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6; @@ -655,11 +769,11 @@ class ProjectileSim { const lvx = prev.vx + (vx - prev.vx) * frac; const lvy = prev.vy + (vy - prev.vy) * frac; const lt = prev.t + dt * frac; - path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt }); + path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt, chuteOpen }); if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) { vy = -e * lvy; - vx = lvx * (1 - 0.04); // small horizontal friction + vx = lvx * (1 - 0.04); y = 0.001; x = lx; bounceCount++; @@ -669,13 +783,72 @@ class ProjectileSim { break; } - path.push({ x, y, vx, vy, t }); + path.push({ x, y, vx, vy, t, chuteOpen }); } this._path = path; this._pathTf = path[path.length - 1].t; } + /* compute a trajectory for a given planet (for compare mode) */ + _computePlanetPath(planetId) { + const pl = this.planets.find(p => p.id === planetId) || this.planets[0]; + const rho = pl.rho; + const g = pl.g; + const W = this.wind; + const mass = Math.max(0.1, this.mass); + const A_ball = 0.00785; + const kBall = this.drag ? 0.5 * this.Cd * rho * A_ball / mass : 0; + + const rad = this.angle * Math.PI / 180; + let x = 0, y = this.h0; + let vx = this.v0 * Math.cos(rad); + let vy = this.v0 * Math.sin(rad); + const dt = 0.005; + const path = [{ x, y, vx, vy, t: 0 }]; + + const deriv2 = (sx, sy, svx, svy) => { + const rvx = svx - W; + const rvy = svy; + const speed = Math.sqrt(rvx * rvx + rvy * rvy); + const dragF = speed > 0 ? kBall * speed : 0; + const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0; + return { + dx: svx, dy: svy, + dvx: -dragF * rvx + windAcc, + dvy: -g - dragF * rvy, + }; + }; + + for (let step = 0; step < 100000; step++) { + const k1 = deriv2(x, y, vx, vy); + const k2 = deriv2(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2); + const k3 = deriv2(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2); + const k4 = deriv2(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt); + x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6; + y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6; + vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6; + vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6; + const t = (step + 1) * dt; + if (y <= 0) { + const prev = path[path.length - 1]; + if (prev && prev.y > 0) { + const frac = prev.y / (prev.y - y); + path.push({ + x: prev.x + (x - prev.x) * frac, + y: 0, + vx: prev.vx + (vx - prev.vx) * frac, + vy: prev.vy + (vy - prev.vy) * frac, + t: prev.t + dt * frac, + }); + } + break; + } + path.push({ x, y, vx, vy, t }); + } + return path; + } + _pathStateAt(t) { const path = this._path; if (!path || path.length < 2) return { x: 0, y: this.h0, vx: 0, vy: 0 }; @@ -768,6 +941,39 @@ class ProjectileSim { if (this.targetMode) this._targetAttempts++; } + /* parachute: check altitude-triggered deployment */ + if (this.parachute && !this._chuteOpen && this.chuteOpenHeight >= 0) { + const cs = this._curState(this.t); + if (cs.y <= this.chuteOpenHeight && cs.y > 0) { + this._chuteOpen = true; + this._chuteOpenedTs = performance.now(); + if (window.LabFX) { + LabFX.sound.play('whoosh'); + const _vp = this._viewParams; + if (_vp) { + const scX = (_vp.W - _vp.PL - _vp.PR) / _vp.xMax; + const scY = (_vp.H - _vp.PB - _vp.PT) / _vp.yMax; + LabFX.particles.emit({ + ctx: this.ctx, + x: _vp.PL + cs.x * scX, y: _vp.H - _vp.PB - cs.y * scY, + count: 30, color: ['#06D6E0', '#FFD166'], speed: 90, + spread: Math.PI, angle: -Math.PI / 2, life: 800, glow: true, shape: 'spark', + }); + } + } + } + } + + /* parachute: chime when ~90% terminal velocity reached */ + if (this.parachute && this._chuteOpen && !this._chimeEmitted) { + const vt = this._terminalVelocity(); + const spd = Math.sqrt(cur.vx ** 2 + cur.vy ** 2); + if (spd <= vt * 1.1) { + this._chimeEmitted = true; + if (window.LabFX) LabFX.sound.play('chime'); + } + } + /* target hit detection on this step interval */ this._checkTargetHits(prevT, Math.min(this.t, tf)); @@ -830,11 +1036,14 @@ class ProjectileSim { } _resetFX() { - this.t = 0; - this._trail = []; - this._sparks = []; - this._impactTs = -999; - this._launchFlash = 0; + this.t = 0; + this._trail = []; + this._sparks = []; + this._impactTs = -999; + this._launchFlash = 0; + this._chuteOpen = this.parachute && this.chuteOpenHeight < 0; + this._chuteOpenedTs = -999; + this._chimeEmitted = false; this._computePath(); if (this.dualMode) { this._p2.t = 0; @@ -966,6 +1175,33 @@ class ProjectileSim { for (let y = stY; y < yMax * 0.97; y += stY) ctx.fillText(_projFmt(y) + ' м', PL - 6, tpy(y)); + /* ── 6.4. Planet compare trajectories ── */ + if (this.planetCompare) { + const PCOLORS = ['#06D6E0', '#7BF5A4', '#F15BB5']; + for (let ci = 0; ci < this.comparePlanets.length; ci++) { + const pid = this.comparePlanets[ci]; + const pl = this.planets.find(p => p.id === pid); + if (!pl) continue; + const ppath = this._computePlanetPath(pid); + const col = PCOLORS[ci % PCOLORS.length]; + ctx.strokeStyle = col; ctx.lineWidth = 1.8; ctx.setLineDash([5, 3]); + ctx.beginPath(); + for (let i = 0; i < ppath.length; i++) { + const pp = ppath[i]; + i === 0 ? ctx.moveTo(tpx(pp.x), tpy(Math.max(0, pp.y))) + : ctx.lineTo(tpx(pp.x), tpy(Math.max(0, pp.y))); + } + ctx.stroke(); ctx.setLineDash([]); + /* label at landing */ + const plast = ppath[ppath.length - 1]; + const plx = tpx(plast.x), ply = tpy(0); + ctx.fillStyle = col; + ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(pl.name, plx, ply + 8); + ctx.fillText(_projFmt(plast.x) + ' м', plx, ply + 20); + } + } + /* ── 6.5. Ghost trails ── */ for (const gh of this._ghosts) { ctx.strokeStyle = gh.color; ctx.lineWidth = 2; @@ -1009,7 +1245,7 @@ class ProjectileSim { } /* ── 7. Launch platform ── */ - if (this.h0 > 0.2) { + if (this.h0 > 0.2 && !this.ramp) { const px0 = tpx(0), py0 = tpy(0), pyH = tpy(this.h0); ctx.strokeStyle = 'rgba(255,200,60,.35)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); @@ -1022,6 +1258,40 @@ class ProjectileSim { ctx.fillText(_projFmt(this.h0) + ' м', px0 - 14, pyH); } + /* ── 7.5. Ramp visualization ── */ + if (this.ramp) { + const rA = this.rampAngle * Math.PI / 180; + const rL = this.rampLength; + /* ramp starts at (0, h0) going left-down at angle rA */ + const rxStart = -rL * Math.cos(rA); + const ryStart = this.h0; + const rxEnd = 0; + const ryEnd = this.h0 + rL * Math.sin(rA); /* ramp bottom */ + + /* clamp start x to left edge */ + const sx = Math.max(PL, tpx(rxStart)); + const sy = tpy(ryStart); + const ex = tpx(rxEnd); + const ey = tpy(ryEnd); + + /* ramp surface */ + ctx.strokeStyle = 'rgba(255,180,50,.7)'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); + + /* angle arc */ + ctx.strokeStyle = 'rgba(255,200,60,.5)'; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.arc(ex, ey, 22, -Math.PI / 2, -rA - Math.PI / 2, true); ctx.stroke(); + ctx.fillStyle = 'rgba(255,200,60,.8)'; + ctx.font = '9px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; + ctx.fillText(this.rampAngle + '°', ex + 25, ey - 2); + + /* ramp speed label */ + const rv = this._rampComputeV0(); + ctx.fillStyle = 'rgba(255,214,102,.7)'; + ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText('v = ' + rv.toFixed(1) + ' м/с', (sx + ex) / 2, (sy + ey) / 2 - 4); + } + /* ── 8. Reference / full trajectories ── */ if (tf > 0) { // analytical reference (always shown as faint dashed) @@ -1311,6 +1581,36 @@ class ProjectileSim { ctx.beginPath(); ctx.arc(bx, by, 10, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke(); + /* ── 14.5. Parachute ── */ + if (this.parachute && this._chuteOpen && this.t < tf && cur.y > 0) { + this._drawParachute(ctx, bx, by); + } + + /* ── 14.6. Parachute HUD ── */ + if (this.parachute) { + const vt = this._terminalVelocity(); + const pct = Math.min(100, Math.round((1 - (speed - vt) / Math.max(vt, 0.01)) * 100)); + const pctC = Math.min(100, Math.round(speed / (vt * 2 + 0.01) * 100)); + const hudRows = [ + 'v = ' + speed.toFixed(1) + ' м/с', + 'v_t = ' + vt.toFixed(1) + ' м/с', + (this._chuteOpen ? 'Откр' : 'Закр') + ' ' + Math.max(0, Math.min(100, Math.round(vt / Math.max(speed, 0.01) * 100))) + '%', + ]; + const hudX = W - PR - 8; + const hudY = PT + 34; + ctx.font = '9px Manrope, sans-serif'; + const maxW = Math.max(...hudRows.map(r => ctx.measureText(r).width)); + ctx.fillStyle = 'rgba(5,5,20,.8)'; + ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.fill(); + ctx.strokeStyle = 'rgba(6,214,224,.4)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.stroke(); + for (let ri = 0; ri < hudRows.length; ri++) { + ctx.fillStyle = ri === 2 ? (this._chuteOpen ? '#7BF5A4' : '#FFD166') : '#06D6E0'; + ctx.textAlign = 'right'; ctx.textBaseline = 'top'; + ctx.fillText(hudRows[ri], hudX - 6, hudY + ri * 16); + } + } + /* ── 15. Velocity arrows + labels ── */ if (speed > 0.3 && this.t < tf) { const VX_LEN = Math.min(55, 50 * Math.abs(cur.vx) / Math.max(1, this.v0)); @@ -1380,10 +1680,14 @@ class ProjectileSim { /* ── 18. Info badges (top-right) ── */ let bRight = W - PR - 8; - if (this.drag) { + if (this.drag && !this.parachute) { this._drawBadge(ctx, bRight, PT + 6, 'Cd=' + this.Cd.toFixed(2) + ' m=' + this.mass + 'кг', 'rgba(239,71,111,.15)', 'rgba(239,71,111,.75)'); bRight -= 130; } + if (this.parachute) { + this._drawBadge(ctx, bRight, PT + 6, 'A=' + this.chuteArea.toFixed(1) + 'м² Cd=' + this.chuteCd, 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)'); + bRight -= 150; + } if (this.wind !== 0) { const dir = this.wind > 0 ? '→' : '←'; this._drawBadge(ctx, bRight, PT + 6, dir + ' ветер ' + Math.abs(this.wind) + 'м/с', 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)'); @@ -1391,6 +1695,17 @@ class ProjectileSim { } if (this.bounce) { this._drawBadge(ctx, bRight, PT + 6, '↩ e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)'); + bRight -= 100; + } + if (this.ramp) { + this._drawBadge(ctx, bRight, PT + 6, 'Горка ' + this.rampAngle + '° L=' + this.rampLength + 'м', 'rgba(255,180,50,.12)', 'rgba(255,180,50,.85)'); + bRight -= 140; + } + if (this.planetId !== 'earth') { + const pl = this.planets.find(p => p.id === this.planetId); + if (pl) { + this._drawBadge(ctx, bRight, PT + 6, pl.name + ' g=' + pl.g, 'rgba(123,245,164,.1)', 'rgba(123,245,164,.8)'); + } } /* speed badge bottom-right */ @@ -1607,6 +1922,47 @@ class ProjectileSim { } ctx.restore(); } + + /* Draw parachute dome above the ball */ + _drawParachute(ctx, bx, by) { + const now = performance.now(); + const age = (now - this._chuteOpenedTs) / 1000; + /* deploy animation: scale from 0 to 1 over 0.3 s */ + const scale = Math.min(1, age / 0.3); + const R = 26 * scale; /* dome radius */ + const cy = by - R - 12; /* centre of dome */ + + ctx.save(); + /* dome fill */ + const fill = ctx.createRadialGradient(bx, cy, 0, bx, cy, R); + fill.addColorStop(0, 'rgba(6,214,224,0.55)'); + fill.addColorStop(0.7, 'rgba(6,214,224,0.25)'); + fill.addColorStop(1, 'rgba(6,214,224,0.05)'); + ctx.fillStyle = fill; + ctx.beginPath(); + ctx.arc(bx, cy, R, Math.PI, 0); + ctx.lineTo(bx + R, cy); + ctx.closePath(); + ctx.fill(); + + /* dome border */ + ctx.strokeStyle = 'rgba(6,214,224,0.75)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(bx, cy, R, Math.PI, 0); + ctx.stroke(); + + /* suspension lines (4) */ + ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 0.8; + for (let li = 0; li < 4; li++) { + const a = Math.PI + (li + 0.5) / 4 * Math.PI; + ctx.beginPath(); + ctx.moveTo(bx + Math.cos(a) * R, cy + Math.sin(a) * R); + ctx.lineTo(bx, by - 10); + ctx.stroke(); + } + ctx.restore(); + } } /* ── module helpers ── */ @@ -1921,5 +2277,124 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) { if (pSim && pSim._graphsVisible && !pSim.playing) pSim.drawGraphs(); } + /* ── Feature 4: Parachute UI ── */ + + function projToggleParachute(rowEl) { + if (!pSim) return; + pSim.parachute = !pSim.parachute; + const on = pSim.parachute; + if (rowEl) rowEl.classList.toggle('active', on); + const tog = document.getElementById('chute-toggle'); + if (tog) { + tog.style.background = on ? 'var(--cyan,#06D6E0)' : 'rgba(255,255,255,0.12)'; + tog.querySelector('span').style.marginLeft = on ? '14px' : '2px'; + } + document.getElementById('chute-params').style.display = on ? '' : 'none'; + /* parachute and simple drag are mutually exclusive */ + if (on) pSim.setParams({ parachute: true, drag: false }); + else pSim.setParams({ parachute: false }); + /* also reflect drag row */ + const dragRow = document.getElementById('drag-row'); + if (dragRow) dragRow.classList.toggle('active', false); + const dragTog = document.getElementById('drag-toggle'); + if (dragTog) { + dragTog.style.background = 'rgba(255,255,255,0.12)'; + dragTog.querySelector('span').style.marginLeft = '2px'; + } + document.getElementById('drag-params').style.display = 'none'; + } + + function projChuteAreaChange() { + const A = +document.getElementById('sl-chute-area').value / 10; + document.getElementById('p-chute-area').textContent = A.toFixed(1) + ' м²'; + if (pSim) pSim.setParams({ chuteArea: A }); + } + + function projChuteCdChange() { + const sel = document.getElementById('sel-chute-cd'); + if (!sel || !pSim) return; + const cd = +sel.value; + pSim.setParams({ chuteCd: cd }); + } + + function projChuteHeightChange() { + const val = +document.getElementById('sl-chute-height').value; + const h = val <= 0 ? -1 : val; + const lbl = document.getElementById('p-chute-height'); + if (lbl) lbl.textContent = h < 0 ? 'Сразу' : h.toFixed(0) + ' м'; + if (pSim) pSim.setParams({ chuteOpenHeight: h }); + } + + /* ── Feature 5: Ramp UI ── */ + + function projToggleRamp(rowEl) { + if (!pSim) return; + pSim.ramp = !pSim.ramp; + const on = pSim.ramp; + if (rowEl) rowEl.classList.toggle('active', on); + const tog = document.getElementById('ramp-toggle'); + if (tog) { + tog.style.background = on ? 'rgba(255,180,50,.9)' : 'rgba(255,255,255,0.12)'; + tog.querySelector('span').style.marginLeft = on ? '14px' : '2px'; + } + document.getElementById('ramp-params').style.display = on ? '' : 'none'; + pSim.setParams({ ramp: on }); + } + + function projRampChange() { + const angle = +document.getElementById('sl-ramp-angle').value; + const length = +document.getElementById('sl-ramp-length').value; + const mu = +document.getElementById('sl-ramp-mu').value / 100; + document.getElementById('p-ramp-angle').textContent = angle + '°'; + document.getElementById('p-ramp-length').textContent = length + ' м'; + document.getElementById('p-ramp-mu').textContent = mu.toFixed(2); + if (pSim) pSim.setParams({ rampAngle: angle, rampLength: length, rampMu: mu }); + } + + /* ── Feature 6: Planet UI ── */ + + function projPlanetChange() { + const sel = document.getElementById('sel-planet'); + if (!sel || !pSim) return; + const planetId = sel.value; + pSim.planetId = planetId; + const pl = pSim.planets.find(p => p.id === planetId); + if (pl) { + pSim.g = pl.g; + pSim.rho = pl.rho; + /* sync g slider */ + const gSl = document.getElementById('sl-g'); + if (gSl) { + gSl.value = Math.min(+gSl.max, pl.g); + document.getElementById('p-g').textContent = pl.g.toFixed(2) + ' м/с²'; + } + } + pSim._computePath(); + if (pSim.dualMode) pSim._computeP2Path(); + pSim._resetFX(); + pSim.draw(); + pSim._emit(); + } + + function projTogglePlanetCompare() { + if (!pSim) return; + pSim.planetCompare = !pSim.planetCompare; + const on = pSim.planetCompare; + const btn = document.getElementById('proj-planet-compare-btn'); + if (btn) { + btn.classList.toggle('active', on); + btn.querySelector('span').textContent = on ? 'Сравн.планет: Вкл' : 'Сравн.планет: Выкл'; + } + const panel = document.getElementById('proj-planet-compare-panel'); + if (panel) panel.style.display = on ? '' : 'none'; + pSim.draw(); + } + + function projPlanetCompareChange(idx, val) { + if (!pSim) return; + pSim.comparePlanets[idx] = val; + pSim.draw(); + } + /* ── collision ── */ diff --git a/frontend/lab.html b/frontend/lab.html index 35558d3..ce47284 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -141,7 +141,7 @@ - @@ -237,6 +237,10 @@ + + + +
@@ -1499,19 +1503,84 @@ + + +
Парашют
+ + + + +
Наклонная горка
+ + + + +
Планета / гравитация
+
+ +
+ + + +
Пресеты
@@ -2017,7 +2197,7 @@ Запустить - + + + + + + + +
-
-
Параметры
-
- - +
+ + +
+
Математич. маятник
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Пресеты
+
+ + + + +
+
Тащи грузик мышью для установки угла
-
- - + + + -
- - + + + -
- - + + + -
-
Пресеты
-
- - - - + + + -
Тащи грузик мышью для установки угла
+ + + + + + +
-
Угол
45°
-
ω
0
-
Период T
-
Энергия
+
Угол / коорд.
45°
+
ω
0
+
Период T
+
Режим