diff --git a/frontend/css/lab.css b/frontend/css/lab.css index c1b5ad9..a595f5e 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -498,6 +498,18 @@ } #sl-speed::-moz-range-thumb { background: var(--cyan); } + /* graphs panel canvas (Feature 2) */ + .proj-graphs-canvas { + display: block; width: 100%; height: 200px; + } + + /* dual-throw slider — cyan thumb */ + .proj-dual-slider::-webkit-slider-thumb { + background: #00E6FF; + box-shadow: 0 0 6px rgba(0,230,255,.5); + } + .proj-dual-slider::-moz-range-thumb { background: #00E6FF; } + /* magnetic canvas */ #mag-canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; diff --git a/frontend/js/admin/sections/sims.js b/frontend/js/admin/sections/sims.js index 8bd21db..6210ef9 100644 --- a/frontend/js/admin/sections/sims.js +++ b/frontend/js/admin/sections/sims.js @@ -18,9 +18,8 @@ { id: 'projectile', cat: 'Физика', title: 'Бросок тела' }, { id: 'pendulum', cat: 'Физика', title: 'Маятник' }, { id: 'collision', cat: 'Физика', title: 'Столкновение шаров' }, - { id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' }, + { id: 'emfield', cat: 'Физика', title: 'Электромагнитные поля' }, { id: 'circuit', cat: 'Физика', title: 'Электрические цепи' }, - { id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' }, { id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' }, { id: 'dynamics', cat: 'Физика', title: 'Динамика' }, { id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' }, diff --git a/frontend/js/labs/coulomb.js b/frontend/js/labs/coulomb.js deleted file mode 100644 index 82c9452..0000000 --- a/frontend/js/labs/coulomb.js +++ /dev/null @@ -1,812 +0,0 @@ -'use strict'; -/* ══════════════════════════════════════════════════════════ - CoulombSim — Coulomb's Law interactive simulation - • Click canvas to place charge (+ or −) - • Drag to reposition, double-click / right-click to remove - • Layers: colormap, field lines, vector arrows, - equipotentials, force arrows - Electric field of point charge q at (cx,cy): - Ex = K·q·(x-cx)/r³, Ey = K·q·(y-cy)/r³ - Potential: V = K·q/r -══════════════════════════════════════════════════════════ */ - -class CoulombSim { - constructor(canvas) { - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - - this.charges = []; // [{x, y, q, id}] - this._nextId = 0; - this.addSign = +1; - - /* layers */ - this.layers = { - colormap: true, - fieldlines: true, - vectors: false, - equipotentials: true, - forces: false, - }; - - /* interaction */ - this._drag = null; // charge index being dragged - this._hovered = null; // charge index under mouse - this._downPos = null; // mousedown position for click vs drag detection - this._mousePos = null; // {x, y} - - /* colormap cache */ - this._cmDirty = true; - this._cmCache = null; // ImageData - - /* cursor reading */ - this._cursorE = null; // {ex, ey, mag, v} - - /* visual Coulomb constant */ - this.K = 60000; - - /* dimensions */ - this.W = 0; - this.H = 0; - - /* callback */ - this.onUpdate = null; - - this._bindEvents(); - } - - /* ── Resize ─────────────────────────────────────────────── */ - fit() { - this.W = this.canvas.offsetWidth; - this.H = this.canvas.offsetHeight; - this.canvas.width = this.W * devicePixelRatio; - this.canvas.height = this.H * devicePixelRatio; - this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); - this._cmDirty = true; - this._cmCache = null; - this.draw(); - } - - /* ── Reset ──────────────────────────────────────────────── */ - reset() { - this.charges = []; - this._nextId = 0; - this._cmDirty = true; - this._cmCache = null; - this._drag = null; - this._hovered = null; - } - - /* ── Charge management ──────────────────────────────────── */ - addCharge(x, y, q) { - this.charges.push({ x, y, q, id: this._nextId++ }); - this._cmDirty = true; - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - } - - removeCharge(i) { - if (i < 0 || i >= this.charges.length) return; - this.charges.splice(i, 1); - this._cmDirty = true; - this._drag = null; - this._hovered = null; - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - } - - toggleLayer(name) { - if (name in this.layers) { - this.layers[name] = !this.layers[name]; - this.draw(); - } - } - - setSign(s) { - this.addSign = s >= 0 ? +1 : -1; - } - - /* ── Presets ────────────────────────────────────────────── */ - preset(name) { - this.reset(); - const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2; - if (name === 'dipole') { - this.addCharge(cx - d, cy, 1); - this.addCharge(cx + d, cy, -1); - } else if (name === 'equal') { - this.addCharge(cx - d, cy, 1); - this.addCharge(cx + d, cy, 1); - } else if (name === 'quadrupole') { - this.addCharge(cx - d, cy - d, 1); - this.addCharge(cx + d, cy - d, -1); - this.addCharge(cx + d, cy + d, 1); - this.addCharge(cx - d, cy + d, -1); - } else if (name === 'ring') { - for (let i = 0; i < 6; i++) { - const a = i * Math.PI / 3; - this.addCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1); - } - } - this._cmDirty = true; - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - } - - /* ── Info ───────────────────────────────────────────────── */ - info() { - const pos = this.charges.filter(c => c.q > 0).length; - const neg = this.charges.filter(c => c.q < 0).length; - let maxE = 0; - for (let x = 20; x < this.W; x += 40) - for (let y = 20; y < this.H; y += 40) { - const f = this._fieldAt(x, y); - if (f.mag > maxE) maxE = f.mag; - } - const ce = this._cursorE; - return { - total: this.charges.length, - positive: pos, - negative: neg, - maxE: maxE.toFixed(0), - cursorE: ce ? ce.mag.toFixed(0) : '—', - cursorV: ce ? ce.v.toFixed(0) : '—', - }; - } - - /* ── Physics ────────────────────────────────────────────── */ - _fieldAt(x, y) { - let ex = 0, ey = 0, v = 0; - for (const c of this.charges) { - const dx = x - c.x, dy = y - c.y; - const r2 = dx * dx + dy * dy; - if (r2 < 1) continue; - const r = Math.sqrt(r2); - const r3 = r2 * r; - ex += this.K * c.q * dx / r3; - ey += this.K * c.q * dy / r3; - v += this.K * c.q / r; - } - return { ex, ey, mag: Math.hypot(ex, ey), v }; - } - - /* ── HSL RGB helper ───────────────────────────────────── */ - _hslToRgb(h, s, l) { - h = ((h % 360) + 360) % 360; - s /= 100; l /= 100; - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs((h / 60) % 2 - 1)); - const m = l - c / 2; - let r = 0, g = 0, b = 0; - if (h < 60) { r = c; g = x; b = 0; } - else if (h < 120) { r = x; g = c; b = 0; } - else if (h < 180) { r = 0; g = c; b = x; } - else if (h < 240) { r = 0; g = x; b = c; } - else if (h < 300) { r = x; g = 0; b = c; } - else { r = c; g = 0; b = x; } - return [ - Math.round((r + m) * 255), - Math.round((g + m) * 255), - Math.round((b + m) * 255), - ]; - } - - /* ── Colormap ───────────────────────────────────────────── */ - _drawColormap(ctx) { - const W = this.W, H = this.H; - const STEP = 3; - - if (this._cmDirty || !this._cmCache) { - const imgW = Math.ceil(W / STEP); - const imgH = Math.ceil(H / STEP); - const img = ctx.createImageData(imgW, imgH); - const d = img.data; - - for (let py = 0; py < imgH; py++) { - for (let px = 0; px < imgW; px++) { - const x = px * STEP + STEP / 2; - const y = py * STEP + STEP / 2; - const { mag, v } = this._fieldAt(x, y); - - /* hue based on potential sign */ - let hue; - if (v > 0) hue = 0 + (v / (v + 30000)) * 30; // 0–30 red-orange - else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220–240 blue - else hue = 0; - - const sat = 80; - const lit = Math.tanh(mag / 3000) * 40 - + Math.tanh(Math.abs(v) / 50000) * 25; - - const [r, g, b] = this._hslToRgb(hue, sat, lit); - const idx = (py * imgW + px) * 4; - d[idx] = r; - d[idx + 1] = g; - d[idx + 2] = b; - d[idx + 3] = 200; - } - } - - this._cmCache = { img, imgW, imgH, STEP }; - this._cmDirty = false; - } - - const { img, imgW, imgH } = this._cmCache; - - /* draw scaled up */ - const oc = document.createElement('canvas'); - oc.width = imgW; - oc.height = imgH; - oc.getContext('2d').putImageData(img, 0, 0); - - ctx.save(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'medium'; - ctx.drawImage(oc, 0, 0, W, H); - ctx.restore(); - } - - /* ── Equipotentials ─────────────────────────────────────── */ - _drawEquipotentials(ctx) { - const W = this.W, H = this.H; - const GRID = 8; - const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000]; - - const cols = Math.ceil(W / GRID) + 1; - const rows = Math.ceil(H / GRID) + 1; - - /* build V grid */ - const vGrid = new Float64Array(cols * rows); - for (let r = 0; r < rows; r++) - for (let c = 0; c < cols; c++) - vGrid[r * cols + c] = this._fieldAt(c * GRID, r * GRID).v; - - ctx.save(); - ctx.strokeStyle = 'rgba(255,255,255,0.22)'; - ctx.lineWidth = 0.8; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - - for (const level of LEVELS) { - for (let r = 0; r < rows - 1; r++) { - for (let c = 0; c < cols - 1; c++) { - const v00 = vGrid[ r * cols + c ]; - const v10 = vGrid[ r * cols + c + 1]; - const v01 = vGrid[(r + 1) * cols + c ]; - const v11 = vGrid[(r + 1) * cols + c + 1]; - - /* crossed edges: top, right, bottom, left */ - const pts = []; - const interp = (va, vb, xa, ya, xb, yb) => { - const t = (level - va) / (vb - va); - return [xa + t * (xb - xa), ya + t * (yb - ya)]; - }; - - if ((v00 - level) * (v10 - level) < 0) - pts.push(interp(v00, v10, c * GRID, r * GRID, (c + 1) * GRID, r * GRID)); - if ((v10 - level) * (v11 - level) < 0) - pts.push(interp(v10, v11, (c + 1) * GRID, r * GRID, (c + 1) * GRID, (r + 1) * GRID)); - if ((v01 - level) * (v11 - level) < 0) - pts.push(interp(v01, v11, c * GRID, (r + 1) * GRID, (c + 1) * GRID, (r + 1) * GRID)); - if ((v00 - level) * (v01 - level) < 0) - pts.push(interp(v00, v01, c * GRID, r * GRID, c * GRID, (r + 1) * GRID)); - - if (pts.length >= 2) { - ctx.moveTo(pts[0][0], pts[0][1]); - ctx.lineTo(pts[1][0], pts[1][1]); - } - } - } - } - - ctx.stroke(); - ctx.restore(); - } - - /* ── Vector arrows ──────────────────────────────────────── */ - _drawVectors(ctx) { - const GRID = 45; - ctx.save(); - ctx.strokeStyle = 'rgba(255,255,255,0.5)'; - ctx.fillStyle = 'rgba(255,255,255,0.5)'; - ctx.lineWidth = 1; - - for (let x = GRID / 2; x < this.W; x += GRID) { - for (let y = GRID / 2; y < this.H; y += GRID) { - const { ex, ey, mag } = this._fieldAt(x, y); - if (mag < 1e-6) continue; - const len = Math.tanh(mag / 8000) * 18; - const nx = ex / mag, ny = ey / mag; - const x2 = x + nx * len, y2 = y + ny * len; - - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x2, y2); - ctx.stroke(); - - /* arrowhead */ - const ax = -ny * 3, ay = nx * 3; - ctx.beginPath(); - ctx.moveTo(x2, y2); - ctx.lineTo(x2 - nx * 6 + ax, y2 - ny * 6 + ay); - ctx.lineTo(x2 - nx * 6 - ax, y2 - ny * 6 - ay); - ctx.closePath(); - ctx.fill(); - } - } - ctx.restore(); - } - - /* ── Field lines ────────────────────────────────────────── */ - _drawFieldLines(ctx) { - const W = this.W, H = this.H, sim = this; - const RAYS = 12; - const STEP = 2.5; - const MAX = 2500; - const MARGIN = 5; - const HIT_R = 12; - const START_R = 18; - - function rkStep(x, y, h) { - const f = (px, py) => { - const e = sim._fieldAt(px, py); - const m = Math.hypot(e.ex, e.ey) || 1e-10; - return [e.ex / m, e.ey / m]; - }; - const [k1x, k1y] = f(x, y); - const [k2x, k2y] = f(x + h * k1x / 2, y + h * k1y / 2); - const [k3x, k3y] = f(x + h * k2x / 2, y + h * k2y / 2); - const [k4x, k4y] = f(x + h * k3x, y + h * k3y); - return [ - x + h * (k1x + 2 * k2x + 2 * k3x + k4x) / 6, - y + h * (k1y + 2 * k2y + 2 * k3y + k4y) / 6, - ]; - } - - const traceLine = (startX, startY, dir) => { - const pts = [[startX, startY]]; - let px = startX, py = startY; - for (let s = 0; s < MAX; s++) { - const [nx, ny] = rkStep(px, py, dir * STEP); - if (nx < -MARGIN || nx > W + MARGIN || ny < -MARGIN || ny > H + MARGIN) break; - - /* stop near negative charges */ - let hitNeg = false; - for (const c of sim.charges) { - if (c.q < 0 && Math.hypot(nx - c.x, ny - c.y) < HIT_R) { hitNeg = true; break; } - } - if (hitNeg) break; - - pts.push([nx, ny]); - px = nx; py = ny; - } - return pts; - }; - - ctx.save(); - ctx.lineWidth = 1.2; - - for (const charge of this.charges) { - const dir = charge.q > 0 ? 1 : -1; - for (let i = 0; i < RAYS; i++) { - const angle = (i / RAYS) * Math.PI * 2; - const sx = charge.x + START_R * Math.cos(angle); - const sy = charge.y + START_R * Math.sin(angle); - const pts = traceLine(sx, sy, dir); - if (pts.length < 2) continue; - - const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length - 1][0], pts[pts.length - 1][1]); - grad.addColorStop(0, 'rgba(255,255,255,0.75)'); - grad.addColorStop(0.5, 'rgba(255,255,255,0.35)'); - grad.addColorStop(1, 'rgba(255,255,255,0.0)'); - ctx.strokeStyle = grad; - - ctx.beginPath(); - ctx.moveTo(pts[0][0], pts[0][1]); - for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]); - ctx.stroke(); - } - } - ctx.restore(); - } - - /* ── Force arrows ───────────────────────────────────────── */ - _drawForceArrows(ctx) { - ctx.save(); - for (let i = 0; i < this.charges.length; i++) { - const ci = this.charges[i]; - let fx = 0, fy = 0; - for (let j = 0; j < this.charges.length; j++) { - if (i === j) continue; - const cj = this.charges[j]; - const dx = ci.x - cj.x, dy = ci.y - cj.y; - const r2 = dx * dx + dy * dy; - if (r2 < 1) continue; - const r3 = r2 * Math.sqrt(r2); - const F = this.K * ci.q * cj.q; - fx += F * dx / r3; - fy += F * dy / r3; - } - const mag = Math.hypot(fx, fy); - if (mag < 1e-6) continue; - - const len = Math.tanh(mag / 50000) * 55; - const nx = fx / mag, ny = fy / mag; - const x2 = ci.x + nx * len, y2 = ci.y + ny * len; - - ctx.strokeStyle = '#FFD166'; - ctx.fillStyle = '#FFD166'; - ctx.lineWidth = 2; - ctx.shadowBlur = 10; - ctx.shadowColor = '#FFD166'; - - ctx.beginPath(); - ctx.moveTo(ci.x, ci.y); - ctx.lineTo(x2, y2); - ctx.stroke(); - - /* arrowhead */ - const ax = -ny * 5, ay = nx * 5; - ctx.beginPath(); - ctx.moveTo(x2, y2); - ctx.lineTo(x2 - nx * 10 + ax, y2 - ny * 10 + ay); - ctx.lineTo(x2 - nx * 10 - ax, y2 - ny * 10 - ay); - ctx.closePath(); - ctx.fill(); - - ctx.shadowBlur = 0; - } - ctx.restore(); - } - - /* ── Draw charges ───────────────────────────────────────── */ - _drawCharges(ctx) { - for (let i = 0; i < this.charges.length; i++) { - const c = this.charges[i]; - const r = 14 + Math.tanh(Math.abs(c.q) / 5) * 4; - const pos = c.q > 0; - - ctx.save(); - ctx.shadowBlur = 18; - ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0'; - - /* hovered outer ring */ - if (this._hovered === i) { - ctx.beginPath(); - ctx.arc(c.x, c.y, r + 6, 0, Math.PI * 2); - ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)'; - ctx.lineWidth = 2; - ctx.stroke(); - } - - /* body gradient */ - const grd = ctx.createRadialGradient(c.x - r * 0.3, c.y - r * 0.3, r * 0.1, c.x, c.y, r); - if (pos) { - grd.addColorStop(0, '#FF7FA3'); - grd.addColorStop(1, '#EF476F'); - } else { - grd.addColorStop(0, '#90E0FF'); - grd.addColorStop(1, '#4CC9F0'); - } - - ctx.beginPath(); - ctx.arc(c.x, c.y, r, 0, Math.PI * 2); - ctx.fillStyle = grd; - ctx.fill(); - - /* label */ - ctx.shadowBlur = 0; - ctx.fillStyle = '#fff'; - ctx.font = 'bold 14px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(pos ? '+' : '−', c.x, c.y + 1); - - ctx.restore(); - } - } - - /* ── Cursor E display ───────────────────────────────────── */ - _drawCursorE(ctx) { - const { ex, ey, mag, v } = this._cursorE; - const { x, y } = this._mousePos; - if (mag < 1e-6) return; - - const nx = ex / mag, ny = ey / mag; - const len = 20; - const x2 = x + nx * len, y2 = y + ny * len; - - ctx.save(); - ctx.strokeStyle = 'rgba(255,255,255,0.8)'; - ctx.fillStyle = 'rgba(255,255,255,0.8)'; - ctx.lineWidth = 1.5; - - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x2, y2); - ctx.stroke(); - - const ax = -ny * 4, ay = nx * 4; - ctx.beginPath(); - ctx.moveTo(x2, y2); - ctx.lineTo(x2 - nx * 8 + ax, y2 - ny * 8 + ay); - ctx.lineTo(x2 - nx * 8 - ax, y2 - ny * 8 - ay); - ctx.closePath(); - ctx.fill(); - - /* text */ - const eStr = mag >= 1000 ? (mag / 1000).toFixed(1) + 'k' : mag.toFixed(0); - const vStr = Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0); - - ctx.font = '11px monospace'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'bottom'; - ctx.fillStyle = 'rgba(255,255,255,0.85)'; - ctx.shadowBlur = 4; - ctx.shadowColor = '#000'; - ctx.fillText(`|E| = ${eStr}`, x + 6, y - 14); - ctx.fillText(`V = ${vStr}`, x + 6, y - 2); - ctx.restore(); - } - - /* ── Hint ───────────────────────────────────────────────── */ - _drawHint(ctx) { - const W = this.W, H = this.H; - ctx.save(); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = '16px sans-serif'; - ctx.fillStyle = 'rgba(255,255,255,0.35)'; - ctx.fillText('Нажмите чтобы добавить заряд', W / 2, H / 2 + 30); - - /* simple circle icon */ - ctx.strokeStyle = 'rgba(255,255,255,0.25)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.arc(W / 2, H / 2 - 14, 18, 0, Math.PI * 2); - ctx.stroke(); - ctx.font = 'bold 22px sans-serif'; - ctx.fillStyle = 'rgba(255,255,255,0.3)'; - ctx.fillText('+', W / 2, H / 2 - 13); - ctx.restore(); - } - - /* ── Main draw ──────────────────────────────────────────── */ - draw() { - const ctx = this.ctx, W = this.W, H = this.H; - if (!W || !H) return; - ctx.clearRect(0, 0, W, H); - - /* 1. background radial gradient */ - const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) / 2); - bg.addColorStop(0, '#0D0D1A'); - bg.addColorStop(1, '#050508'); - ctx.fillStyle = bg; - ctx.fillRect(0, 0, W, H); - - /* 2. subtle grid */ - ctx.save(); - ctx.strokeStyle = 'rgba(255,255,255,0.025)'; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let x = 0; x < W; x += 30) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } - for (let y = 0; y < H; y += 30) { ctx.moveTo(0, y); ctx.lineTo(W, y); } - ctx.stroke(); - ctx.restore(); - - if (this.charges.length > 0) { - /* 3. colormap */ - if (this.layers.colormap) this._drawColormap(ctx); - /* 4. equipotentials */ - if (this.layers.equipotentials) this._drawEquipotentials(ctx); - /* 5. vectors */ - if (this.layers.vectors) this._drawVectors(ctx); - /* 6. field lines */ - if (this.layers.fieldlines) this._drawFieldLines(ctx); - /* 7. force arrows */ - if (this.layers.forces) this._drawForceArrows(ctx); - } - - /* 8. charges */ - this._drawCharges(ctx); - - /* 9. cursor E */ - if (this._cursorE && this._mousePos && this.charges.length > 0) - this._drawCursorE(ctx); - - /* 10. hint if empty */ - if (this.charges.length === 0) this._drawHint(ctx); - } - - /* ── Events ─────────────────────────────────────────────── */ - _bindEvents() { - const canvas = this.canvas; - - const pos = e => { - const r = canvas.getBoundingClientRect(); - const s = e.touches ? e.touches[0] : e; - return { x: s.clientX - r.left, y: s.clientY - r.top }; - }; - - const hitIdx = p => { - for (let i = this.charges.length - 1; i >= 0; i--) - if (Math.hypot(p.x - this.charges[i].x, p.y - this.charges[i].y) < 20) return i; - return -1; - }; - - /* ── mousedown ── */ - canvas.addEventListener('mousedown', e => { - if (e.button !== 0) return; - const p = pos(e); - const hi = hitIdx(p); - this._downPos = p; - if (hi >= 0) this._drag = hi; - }); - - /* ── mousemove ── */ - canvas.addEventListener('mousemove', e => { - const p = pos(e); - this._mousePos = p; - if (this._drag !== null) { - this.charges[this._drag].x = p.x; - this.charges[this._drag].y = p.y; - this._cmDirty = true; - this._cursorE = this._fieldAt(p.x, p.y); - this.draw(); - } else { - this._hovered = hitIdx(p); - this._cursorE = this.charges.length > 0 ? this._fieldAt(p.x, p.y) : null; - this.draw(); - } - }); - - /* ── mouseup ── */ - canvas.addEventListener('mouseup', e => { - if (e.button !== 0) return; - const p = pos(e); - const wasDragging = this._drag !== null; - if (wasDragging) { - this._drag = null; - this._cmDirty = true; - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - return; - } - /* click (no drag) */ - const dp = this._downPos || p; - const dist = Math.hypot(p.x - dp.x, p.y - dp.y); - if (dist < 5) { - const hi = hitIdx(p); - if (hi < 0) this.addCharge(p.x, p.y, this.addSign); - } - if (this.onUpdate) this.onUpdate(this.info()); - }); - - /* ── contextmenu (remove) ── */ - canvas.addEventListener('contextmenu', e => { - e.preventDefault(); - const p = pos(e); - const hi = hitIdx(p); - if (hi >= 0) this.removeCharge(hi); - }); - - /* ── dblclick (remove) ── */ - canvas.addEventListener('dblclick', e => { - const p = pos(e); - const hi = hitIdx(p); - if (hi >= 0) this.removeCharge(hi); - }); - - /* ── mouseleave ── */ - canvas.addEventListener('mouseleave', () => { - this._cursorE = null; - this._mousePos = null; - this._hovered = null; - this.draw(); - }); - - /* ── touch support ── */ - canvas.addEventListener('touchstart', e => { - e.preventDefault(); - const p = pos(e); - const hi = hitIdx(p); - this._downPos = p; - if (hi >= 0) this._drag = hi; - }, { passive: false }); - - canvas.addEventListener('touchmove', e => { - e.preventDefault(); - const p = pos(e); - this._mousePos = p; - if (this._drag !== null) { - this.charges[this._drag].x = p.x; - this.charges[this._drag].y = p.y; - this._cmDirty = true; - this._cursorE = this._fieldAt(p.x, p.y); - this.draw(); - } - }, { passive: false }); - - canvas.addEventListener('touchend', e => { - e.preventDefault(); - const wasDragging = this._drag !== null; - if (wasDragging) { - this._drag = null; - this._cmDirty = true; - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - return; - } - const p = pos({ touches: e.changedTouches }); - const dp = this._downPos || p; - const dist = Math.hypot(p.x - dp.x, p.y - dp.y); - if (dist < 10) { - const hi = hitIdx(p); - if (hi < 0) this.addCharge(p.x, p.y, this.addSign); - } - if (this.onUpdate) this.onUpdate(this.info()); - }, { passive: false }); - } -} - -/* ─── lab UI init ─────────────────────────────────── */ - var csSim = null; - - function _openCoulomb() { - document.getElementById('sim-topbar-title').textContent = 'Закон Кулона'; - _simShow('sim-coulomb'); - _simShow('ctrl-coulomb'); - requestAnimationFrame(() => requestAnimationFrame(() => { - const canvas = document.getElementById('coulomb-canvas'); - if (!csSim) { - csSim = new CoulombSim(canvas); - csSim.onUpdate = _coulombUpdateUI; - } - csSim.fit(); - if (csSim.charges.length === 0) csSim.preset('dipole'); - _coulombUpdateUI(csSim.info()); - })); - } - - function coulombSign(s) { - if (!csSim) return; - csSim.setSign(s); - document.getElementById('cbtn-pos').classList.toggle('active', s > 0); - document.getElementById('cbtn-neg').classList.toggle('active', s < 0); - document.getElementById('csign-pos').style.opacity = s > 0 ? '1' : '0.45'; - document.getElementById('csign-neg').style.opacity = s < 0 ? '1' : '0.45'; - } - - function coulombLayer(name, rowEl) { - if (!csSim) return; - csSim.toggleLayer(name); - const on = csSim.layers[name]; - rowEl.classList.toggle('active', on); - const tog = rowEl.querySelector('.tri-toggle'); - if (tog) { - tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)'; - const dot = tog.querySelector('span'); - if (dot) dot.style.marginLeft = on ? '14px' : '2px'; - } - csSim.draw(); - } - - function coulombPreset(name) { - if (!csSim) return; - csSim.preset(name); - } - - function _coulombUpdateUI(info) { - if (!info) return; - document.getElementById('cs-total').textContent = info.total; - document.getElementById('cs-curE').textContent = info.cursorE; - document.getElementById('cs-curV').textContent = info.cursorV; - document.getElementById('csbar-total').textContent = info.total; - document.getElementById('csbar-pos').textContent = info.positive; - document.getElementById('csbar-neg').textContent = info.negative; - document.getElementById('csbar-maxE').textContent = info.maxE; - document.getElementById('csbar-curE').textContent = info.cursorE; - } - - /* ════════════════════════════════ - ЭЛЕКТРИЧЕСКИЕ ЦЕПИ - ════════════════════════════════ */ - diff --git a/frontend/js/labs/emfield.js b/frontend/js/labs/emfield.js new file mode 100644 index 0000000..f64b64c --- /dev/null +++ b/frontend/js/labs/emfield.js @@ -0,0 +1,1541 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════ + EMFieldSim — unified electromagnetic field simulation + Modes: + 'E' — electric only (charge sources) + 'B' — magnetic only (wire sources) + 'combined' — both fields, full Lorentz force + + Source kinds: + charge → {kind:'charge', x, y, q} — point charge + wireOut → {kind:'wireOut', x, y, I} — wire current toward viewer (•) + wireIn → {kind:'wireIn', x, y, I} — wire current away (×) + conductor→ special overlay, not in sources[] + + Physics (visual units, no SI conversion): + E: Ex = K_E·q·dx/r³, Ey = K_E·q·dy/r³ + B: Bx = -K_B·I·dy/r², By = K_B·I·dx/r² + Lorentz (2-D projection): + F_E = q_test · E + F_B: treat |B_xy| as Bz → Fx=q·vy·Bz, Fy=-q·vx·Bz +══════════════════════════════════════════════════════════ */ + +class EMFieldSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.mode = 'E'; // 'E' | 'B' | 'combined' + + /* source list — mixed kinds */ + this.sources = []; + this._nextId = 1; + + /* add-mode per kind */ + this.addSign = +1; // charge sign (+1 / -1) + this.addDir = 'out'; // wire direction 'out' | 'in' + this.curI = 6; // wire current magnitude + + /* layers — per-field toggles */ + this.layers = { + E_colormap: true, + E_fieldlines: true, + E_vectors: false, + E_equipotentials: true, + E_forces: false, + B_colormap: true, + B_fieldlines: true, + B_vectors: false, + }; + + /* conductor overlay (magnetic feature) */ + this._cond = { + on: false, + x1: 0, y1: 0, x2: 0, y2: 0, + I: 8, + _dragEndpoint: null, + }; + + /* flux indicator (magnetic feature) */ + this._flux = { + on: false, + x: 0, y: 0, + r: 55, + _dragging: false, + }; + + /* test particle */ + this._particle = null; + this.particleOn = false; + this._pRaf = null; + this._pLast = 0; + + /* cursor readout */ + this._cursorE = null; // {ex, ey, mag, v} + this._cursorB = null; // {bx, by, mag} + this._mousePos = null; + + /* interaction */ + this._drag = null; + this._hovered = null; + this._downPos = null; + + /* offscreen canvas for B colormap */ + this._ocB = null; + this._ocBW = 0; + this._ocBH = 0; + + /* colormap dirty flags */ + this._cmBDirty = true; + this._cmECache = null; + this._cmEDirty = true; + + /* visual constants */ + this.K_E = 60000; // Coulomb visual constant + this.K_B = 8000; // Biot-Savart visual constant + + this.W = 0; this.H = 0; + this.onUpdate = null; + + this._bindEvents(); + } + + /* ────────────────────────────── + Sizing + ────────────────────────────── */ + + fit() { + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = rect.width; + this.H = rect.height; + + const DS = 4; + this._ocBW = Math.ceil(this.W / DS); + this._ocBH = Math.ceil(this.H / DS); + this._ocB = document.createElement('canvas'); + this._ocB.width = this._ocBW; + this._ocB.height = this._ocBH; + + if (this.W) { + this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5; + this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5; + this._flux.x = this.W * 0.5; this._flux.y = this.H * 0.35; + } + + this._cmBDirty = true; + this._cmEDirty = true; + this._cmECache = null; + this.draw(); + } + + /* ────────────────────────────── + Mode switching + ────────────────────────────── */ + + setMode(mode) { + this.mode = mode; + /* remove incompatible sources */ + if (mode === 'E') { + this.sources = this.sources.filter(s => s.kind === 'charge'); + } else if (mode === 'B') { + this.sources = this.sources.filter(s => s.kind !== 'charge'); + } + this._invalidateAll(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ────────────────────────────── + Source management + ────────────────────────────── */ + + addCharge(x, y, q) { + if (this.mode === 'B') return; + this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q }); + this._invalidateAll(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + addWire(x, y, dir) { + if (this.mode === 'E') return; + const kind = dir === 'out' ? 'wireOut' : 'wireIn'; + const I = dir === 'out' ? +this.curI : -this.curI; + this.sources.push({ kind, id: this._nextId++, x, y, I }); + this._invalidateAll(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + removeSource(id) { + this.sources = this.sources.filter(s => s.id !== id); + this._invalidateAll(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + clearAll() { + this.sources = []; + this._particle = null; + this.particleOn = false; + if (this._pRaf) { cancelAnimationFrame(this._pRaf); this._pRaf = null; } + this._cond.on = false; + this._flux.on = false; + this._invalidateAll(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + setCurrentAll(I) { + this.curI = I; + this.sources.forEach(s => { + if (s.kind === 'wireOut') s.I = I; + if (s.kind === 'wireIn') s.I = -I; + }); + this._invalidateAll(); + this.draw(); + } + + _invalidateAll() { + this._cmBDirty = true; + this._cmEDirty = true; + this._cmECache = null; + } + + /* ────────────────────────────── + Conductor & flux (B-mode) + ────────────────────────────── */ + + toggleConductor() { + this._cond.on = !this._cond.on; + this.draw(); + } + + setConductorI(I) { + this._cond.I = I; + this.draw(); + } + + toggleFlux() { + this._flux.on = !this._flux.on; + this.draw(); + } + + /* ────────────────────────────── + Particle + ────────────────────────────── */ + + toggleParticle() { + this.particleOn = !this.particleOn; + if (this.particleOn) { + this._initParticle(); + this._pLast = performance.now(); + this._tickParticle(); + } else { + if (this._pRaf) cancelAnimationFrame(this._pRaf); + this._pRaf = null; + this._particle = null; + this.draw(); + } + if (this.onUpdate) this.onUpdate(this.info()); + } + + _initParticle() { + this._particle = { + x: this.W * 0.18, y: this.H * 0.5, + vx: 2.2, vy: 0, + q: 1, + trail: [], + }; + } + + _tickParticle() { + if (!this.particleOn || !this._particle) return; + const now = performance.now(); + const dt = Math.min((now - this._pLast) * 0.06, 2.5); + this._pLast = now; + + const p = this._particle; + + /* electric force: F = q·E (push particle) */ + if (this.mode !== 'B') { + const { ex, ey } = this._eField(p.x, p.y); + const EScale = 0.000008; + p.vx += p.q * ex * EScale * dt; + p.vy += p.q * ey * EScale * dt; + } + + /* magnetic force: Lorentz (2D) using B magnitude as Bz */ + if (this.mode !== 'E') { + const spd = Math.hypot(p.vx, p.vy); + const { mag } = this._bField(p.x, p.y); + const Bz = mag * 0.00012 * p.q; + p.vx += p.q * p.vy * Bz * dt; + p.vy -= p.q * p.vx * Bz * dt; + /* conserve speed when only B acts */ + if (this.mode === 'B') { + const newSpd = Math.hypot(p.vx, p.vy); + if (newSpd > 1e-6) { p.vx = p.vx / newSpd * spd; p.vy = p.vy / newSpd * spd; } + } + } + + /* clamp speed to avoid runaway */ + const maxSpd = 6; + const spd2 = Math.hypot(p.vx, p.vy); + if (spd2 > maxSpd) { p.vx = p.vx / spd2 * maxSpd; p.vy = p.vy / spd2 * maxSpd; } + + p.x += p.vx * dt; + p.y += p.vy * dt; + + /* bounce walls */ + if (p.x < 4) { p.vx = Math.abs(p.vx); p.x = 4; } + if (p.x > this.W - 4) { p.vx = -Math.abs(p.vx); p.x = this.W - 4; } + if (p.y < 4) { p.vy = Math.abs(p.vy); p.y = 4; } + if (p.y > this.H - 4) { p.vy = -Math.abs(p.vy); p.y = this.H - 4; } + + p.trail.push({ x: p.x, y: p.y }); + if (p.trail.length > 350) p.trail.shift(); + + this.draw(); + this._pRaf = requestAnimationFrame(() => this._tickParticle()); + } + + /* ────────────────────────────── + Presets + ────────────────────────────── */ + + presetE(name) { + this.sources = this.sources.filter(s => s.kind !== 'charge'); + const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2; + if (name === 'dipole') { + this._pushCharge(cx - d, cy, 1); + this._pushCharge(cx + d, cy, -1); + } else if (name === 'equal') { + this._pushCharge(cx - d, cy, 1); + this._pushCharge(cx + d, cy, 1); + } else if (name === 'quadrupole') { + this._pushCharge(cx - d, cy - d, 1); + this._pushCharge(cx + d, cy - d, -1); + this._pushCharge(cx + d, cy + d, 1); + this._pushCharge(cx - d, cy + d, -1); + } else if (name === 'ring') { + for (let i = 0; i < 6; i++) { + const a = i * Math.PI / 3; + this._pushCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1); + } + } + this._invalidateAll(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + presetB(name) { + this.sources = this.sources.filter(s => s.kind === 'charge'); + const cx = this.W / 2, cy = this.H / 2, d = 90; + switch (name) { + case 'single': + this._pushWire(cx, cy, 'out'); break; + case 'parallel': + this._pushWire(cx - d, cy, 'out'); + this._pushWire(cx + d, cy, 'out'); break; + case 'anti': + this._pushWire(cx - d, cy, 'out'); + this._pushWire(cx + d, cy, 'in'); break; + case 'solenoid': { + const cols = 5, gx = 60, gy = 70; + for (let c = 0; c < cols; c++) { + const x = cx + (c - (cols - 1) / 2) * gx; + this._pushWire(x, cy - gy / 2, 'out'); + this._pushWire(x, cy + gy / 2, 'in'); + } + break; + } + case 'quadrupole': + this._pushWire(cx - d, cy, 'out'); + this._pushWire(cx + d, cy, 'out'); + this._pushWire(cx, cy - d, 'in'); + this._pushWire(cx, cy + d, 'in'); break; + case 'ring': { + const n = 8, r = 110; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + this._pushWire(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i % 2 === 0 ? 'out' : 'in'); + } + break; + } + } + this._invalidateAll(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + _pushCharge(x, y, q) { + this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q }); + } + + _pushWire(x, y, dir) { + const kind = dir === 'out' ? 'wireOut' : 'wireIn'; + const I = dir === 'out' ? +this.curI : -this.curI; + this.sources.push({ kind, id: this._nextId++, x, y, I }); + } + + /* ────────────────────────────── + Physics + ────────────────────────────── */ + + _eField(px, py) { + let ex = 0, ey = 0, v = 0; + for (const s of this.sources) { + if (s.kind !== 'charge') continue; + const dx = px - s.x, dy = py - s.y; + const r2 = dx * dx + dy * dy; + if (r2 < 1) continue; + const r = Math.sqrt(r2); + const r3 = r2 * r; + ex += this.K_E * s.q * dx / r3; + ey += this.K_E * s.q * dy / r3; + v += this.K_E * s.q / r; + } + return { ex, ey, mag: Math.hypot(ex, ey), v }; + } + + _bField(px, py) { + let bx = 0, by = 0; + for (const s of this.sources) { + if (s.kind === 'charge') continue; + const dx = px - s.x, dy = py - s.y; + const r2 = dx * dx + dy * dy; + if (r2 < 4) continue; + const k = this.K_B * s.I / r2; + bx -= k * dy; + by += k * dx; + } + return { bx, by, mag: Math.hypot(bx, by) }; + } + + _bFieldNorm(px, py) { + const { bx, by, mag } = this._bField(px, py); + if (mag < 1e-12) return { nx: 0, ny: 0, mag: 0 }; + return { nx: bx / mag, ny: by / mag, mag }; + } + + _bRk4(x, y, step) { + const f = (xx, yy) => this._bFieldNorm(xx, yy); + const k1 = f(x, y); + const k2 = f(x + step * k1.nx * 0.5, y + step * k1.ny * 0.5); + const k3 = f(x + step * k2.nx * 0.5, y + step * k2.ny * 0.5); + const k4 = f(x + step * k3.nx, y + step * k3.ny); + return { + nx: (k1.nx + 2*k2.nx + 2*k3.nx + k4.nx) / 6, + ny: (k1.ny + 2*k2.ny + 2*k3.ny + k4.ny) / 6, + }; + } + + /* Ampere force on conductor */ + _ampereForce() { + const c = this._cond; + const Lx = c.x2 - c.x1, Ly = c.y2 - c.y1; + const L = Math.hypot(Lx, Ly); + if (L < 1) return { Fz: 0, L, B: 0 }; + const mx = (c.x1 + c.x2) / 2, my = (c.y1 + c.y2) / 2; + const { bx, by, mag } = this._bField(mx, my); + const Fz = c.I * (Lx * by - Ly * bx) * 0.0001; + return { Fz, L: L / 100, B: mag, bx, by, mx, my }; + } + + /* Magnetic flux through indicator circle */ + _fluxValue() { + const f = this._flux; + const { mag } = this._bField(f.x, f.y); + return mag * Math.PI * f.r * f.r * 0.000001; + } + + /* ────────────────────────────── + Info + ────────────────────────────── */ + + info() { + const charges = this.sources.filter(s => s.kind === 'charge'); + const wires = this.sources.filter(s => s.kind !== 'charge'); + const pos = charges.filter(c => c.q > 0).length; + const neg = charges.filter(c => c.q < 0).length; + const out = wires.filter(w => w.I > 0).length; + const inn = wires.filter(w => w.I < 0).length; + + const condOn = this._cond.on; + const fluxOn = this._flux.on; + const ampere = condOn ? this._ampereForce() : null; + const Fz = ampere ? ampere.Fz : 0; + const flux = fluxOn ? this._fluxValue() : 0; + + return { + total: this.sources.length, + charges: charges.length, pos, neg, + wires: wires.length, out, inn, + particleOn: this.particleOn, + condOn, fluxOn, Fz, flux, + cursorE: this._cursorE ? this._cursorE.mag.toFixed(0) : '—', + cursorV: this._cursorE ? this._cursorE.v.toFixed(0) : '—', + cursorB: this._cursorB ? this._cursorB.mag.toFixed(0) : '—', + }; + } + + /* ────────────────────────────── + Events + ────────────────────────────── */ + + _bindEvents() { + const c = this.canvas; + + const pos = e => { + const r = c.getBoundingClientRect(); + const s = e.touches ? e.touches[0] : e; + return { x: s.clientX - r.left, y: s.clientY - r.top }; + }; + + const hitSource = p => { + for (let i = this.sources.length - 1; i >= 0; i--) { + if (Math.hypot(p.x - this.sources[i].x, p.y - this.sources[i].y) < 22) return i; + } + return -1; + }; + + const hitCond = p => { + if (!this._cond.on) return null; + const { x1, y1, x2, y2 } = this._cond; + if (Math.hypot(p.x - x1, p.y - y1) < 16) return 0; + if (Math.hypot(p.x - x2, p.y - y2) < 16) return 1; + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + if (Math.hypot(p.x - mx, p.y - my) < 14) return 'body'; + return null; + }; + + const hitFlux = p => { + if (!this._flux.on) return false; + return Math.hypot(p.x - this._flux.x, p.y - this._flux.y) < this._flux.r + 12; + }; + + let _condDragOffset = null; + + c.addEventListener('mousedown', e => { + if (e.button !== 0) return; + const p = pos(e); + this._downPos = p; + + const ch = hitCond(p); + if (ch !== null) { + this._cond._dragEndpoint = ch; + if (ch === 'body') { + _condDragOffset = { + dx: p.x - this._cond.x1, dy: p.y - this._cond.y1, + len: Math.hypot(this._cond.x2 - this._cond.x1, this._cond.y2 - this._cond.y1), + }; + } + c.style.cursor = 'grabbing'; return; + } + + if (hitFlux(p)) { + this._flux._dragging = true; + c.style.cursor = 'grabbing'; return; + } + + const i = hitSource(p); + if (i >= 0) { this._drag = i; c.style.cursor = 'grabbing'; } + }); + + c.addEventListener('mousemove', e => { + const p = pos(e); + this._mousePos = p; + + /* cursor field readout */ + if (!e.buttons) { + if (this.mode !== 'B' && this.sources.some(s => s.kind === 'charge')) { + this._cursorE = this._eField(p.x, p.y); + } else { + this._cursorE = null; + } + if (this.mode !== 'E' && this.sources.some(s => s.kind !== 'charge')) { + this._cursorB = this._bField(p.x, p.y); + } else { + this._cursorB = null; + } + if (this.onUpdate) this.onUpdate(this.info()); + } + + if (this._cond._dragEndpoint !== null) { + const ep = this._cond._dragEndpoint; + if (ep === 0) { this._cond.x1 = p.x; this._cond.y1 = p.y; } + else if (ep === 1) { this._cond.x2 = p.x; this._cond.y2 = p.y; } + else if (ep === 'body') { + const L = _condDragOffset.len; + const dx = this._cond.x2 - this._cond.x1, dy = this._cond.y2 - this._cond.y1; + const nh = Math.hypot(dx, dy); + const nx = dx / nh, ny = dy / nh; + this._cond.x1 = p.x - _condDragOffset.dx; + this._cond.y1 = p.y - _condDragOffset.dy; + this._cond.x2 = this._cond.x1 + nx * L; + this._cond.y2 = this._cond.y1 + ny * L; + } + this.draw(); return; + } + + if (this._flux._dragging) { + this._flux.x = p.x; this._flux.y = p.y; + this.draw(); return; + } + + if (this._drag !== null) { + this.sources[this._drag].x = p.x; + this.sources[this._drag].y = p.y; + this._invalidateAll(); + this.draw(); return; + } + + const i = hitSource(p); + const ch = hitCond(p); + const fh = hitFlux(p); + this._hovered = i >= 0 ? i : null; + c.style.cursor = (i >= 0 || ch !== null || fh) ? 'grab' : 'crosshair'; + this.draw(); + }); + + c.addEventListener('mouseup', e => { + const p = pos(e); + const moved = this._downPos && + Math.hypot(p.x - this._downPos.x, p.y - this._downPos.y) > 5; + + if (this._cond._dragEndpoint !== null) { + this._cond._dragEndpoint = null; c.style.cursor = 'crosshair'; this.draw(); return; + } + if (this._flux._dragging) { + this._flux._dragging = false; c.style.cursor = 'crosshair'; return; + } + if (this._drag !== null) { + this._invalidateAll(); + this._drag = null; c.style.cursor = 'crosshair'; + this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; + } + + /* click on empty canvas — add source based on mode */ + if (!moved && e.button === 0 && + hitSource(p) < 0 && hitCond(p) === null && !hitFlux(p)) { + if (this.mode === 'E') { + this.addCharge(p.x, p.y, this.addSign); + } else if (this.mode === 'B') { + this.addWire(p.x, p.y, this.addDir); + } else { + /* combined: user picks via active add-type button */ + if (this._addType === 'charge') this.addCharge(p.x, p.y, this.addSign); + else this.addWire(p.x, p.y, this.addDir); + } + } + }); + + c.addEventListener('dblclick', e => { + const p = pos(e); + const i = hitSource(p); + if (i >= 0) this.removeSource(this.sources[i].id); + }); + + c.addEventListener('contextmenu', e => { + e.preventDefault(); + const p = pos(e); + const i = hitSource(p); + if (i >= 0) this.removeSource(this.sources[i].id); + }); + + c.addEventListener('mouseleave', () => { + this._cursorE = null; + this._cursorB = null; + this._mousePos = null; + this._hovered = null; + this.draw(); + }); + + c.addEventListener('touchstart', e => { + e.preventDefault(); + this._downPos = pos(e); + const i = hitSource(this._downPos); + if (i >= 0) this._drag = i; + }, { passive: false }); + + c.addEventListener('touchmove', e => { + e.preventDefault(); + if (this._drag === null) return; + const p = pos(e); + this.sources[this._drag].x = p.x; + this.sources[this._drag].y = p.y; + this._invalidateAll(); + this.draw(); + }, { passive: false }); + + c.addEventListener('touchend', e => { + const p = e.changedTouches ? pos({ ...e, touches: e.changedTouches }) : null; + const moved = this._downPos && p && + Math.hypot(p.x - this._downPos.x, p.y - this._downPos.y) > 8; + if (this._drag === null && !moved && p) { + if (this.mode === 'E') this.addCharge(p.x, p.y, this.addSign); + else if (this.mode === 'B') this.addWire(p.x, p.y, this.addDir); + else if (this._addType === 'charge') this.addCharge(p.x, p.y, this.addSign); + else this.addWire(p.x, p.y, this.addDir); + } + this._drag = null; + if (this.onUpdate) this.onUpdate(this.info()); + }, { passive: false }); + } + + /* ────────────────────────────── + Drawing + ────────────────────────────── */ + + draw() { + const ctx = this.ctx; + const W = this.W, H = this.H; + if (!W || !H) return; + + ctx.clearRect(0, 0, W, H); + + /* background */ + const bg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.7); + bg.addColorStop(0, '#080818'); + bg.addColorStop(1, '#030308'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + this._drawGrid(ctx); + + const hasE = this.sources.some(s => s.kind === 'charge'); + const hasB = this.sources.some(s => s.kind !== 'charge'); + + /* E layers */ + if (hasE && this.mode !== 'B') { + if (this.layers.E_colormap) this._drawColormapE(ctx); + if (this.layers.E_equipotentials) this._drawEquipotentials(ctx); + if (this.layers.E_vectors) this._drawVectorsE(ctx); + if (this.layers.E_fieldlines) this._drawFieldLinesE(ctx); + if (this.layers.E_forces) this._drawForceArrows(ctx); + } + + /* B layers */ + if (hasB && this.mode !== 'E') { + if (this.layers.B_colormap) this._drawColormapB(ctx); + if (this.layers.B_fieldlines) this._drawFieldLinesB(ctx); + if (this.layers.B_vectors) this._drawVectorsB(ctx); + } + + /* overlays */ + if (this._flux.on && this.mode !== 'E') this._drawFlux(ctx); + if (this._cond.on && this.mode !== 'E') this._drawConductor(ctx); + if (this._particle) this._drawParticle(ctx); + + /* sources */ + this._drawSources(ctx); + + /* cursor readout */ + if (this._mousePos) { + if (this._cursorE && this.mode !== 'B' && hasE) this._drawCursorE(ctx); + if (this._cursorB && this.mode !== 'E' && hasB) this._drawCursorB(ctx); + } + + if (this.sources.length === 0) this._drawHint(ctx); + } + + /* ── grid ── */ + _drawGrid(ctx) { + ctx.save(); + ctx.strokeStyle = 'rgba(155,93,229,0.055)'; ctx.lineWidth = 1; + for (let x = 0; x <= this.W; x += 50) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.H); ctx.stroke(); + } + for (let y = 0; y <= this.H; y += 50) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.W, y); ctx.stroke(); + } + ctx.restore(); + } + + /* ── E colormap (hue = potential sign, brightness = |E|) ── */ + _drawColormapE(ctx) { + const W = this.W, H = this.H; + const STEP = 3; + + if (this._cmEDirty || !this._cmECache) { + const imgW = Math.ceil(W / STEP); + const imgH = Math.ceil(H / STEP); + const img = ctx.createImageData(imgW, imgH); + const d = img.data; + + for (let py = 0; py < imgH; py++) { + for (let px = 0; px < imgW; px++) { + const x = px * STEP + STEP / 2; + const y = py * STEP + STEP / 2; + const { mag, v } = this._eField(x, y); + + let hue; + if (v > 0) hue = 0 + (v / (v + 30000)) * 30; + else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; + else hue = 0; + + const sat = 80; + const lit = Math.tanh(mag / 3000) * 40 + + Math.tanh(Math.abs(v) / 50000) * 25; + + const [r, g, b] = this._hslToRgb(hue, sat, lit); + const idx = (py * imgW + px) * 4; + d[idx] = r; + d[idx + 1] = g; + d[idx + 2] = b; + d[idx + 3] = 200; + } + } + + this._cmECache = { img, imgW, imgH, STEP }; + this._cmEDirty = false; + } + + const { img, imgW, imgH } = this._cmECache; + const oc = document.createElement('canvas'); + oc.width = imgW; + oc.height = imgH; + oc.getContext('2d').putImageData(img, 0, 0); + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'medium'; + ctx.drawImage(oc, 0, 0, W, H); + ctx.restore(); + } + + /* ── B colormap (hue = angle of B, brightness = log|B|) ── */ + _drawColormapB(ctx) { + if (!this._ocB) return; + const DS = 4; + const oc = this._ocB; + const oct = oc.getContext('2d'); + const w = this._ocBW, h = this._ocBH; + + if (this._cmBDirty) { + const imgData = oct.createImageData(w, h); + const data = imgData.data; + + for (let py = 0; py < h; py++) { + for (let px = 0; px < w; px++) { + const { bx, by, mag } = this._bField(px * DS, py * DS); + if (mag < 0.5) continue; + const angle = Math.atan2(by, bx); + const hue = ((angle / (2 * Math.PI) + 1) % 1) * 360; + const bright = Math.min(1, Math.log10(1 + mag * 0.005) * 0.55); + const alpha = Math.round(bright * 210); + const [r, g, b] = this._hsl(hue / 360, 0.90, 0.38 + bright * 0.28); + const idx = (py * w + px) * 4; + data[idx] = r; data[idx+1] = g; data[idx+2] = b; data[idx+3] = alpha; + } + } + oct.putImageData(imgData, 0, 0); + this._cmBDirty = false; + } + + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(oc, 0, 0, w * DS, h * DS); + ctx.restore(); + } + + /* ── E equipotentials ── */ + _drawEquipotentials(ctx) { + const W = this.W, H = this.H; + const GRID = 8; + const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000]; + const cols = Math.ceil(W / GRID) + 1; + const rows = Math.ceil(H / GRID) + 1; + const vGrid = new Float64Array(cols * rows); + for (let r = 0; r < rows; r++) + for (let col = 0; col < cols; col++) + vGrid[r * cols + col] = this._eField(col * GRID, r * GRID).v; + + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.22)'; + ctx.lineWidth = 0.8; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + + const interp = (va, vb, xa, ya, xb, yb, level) => { + const t = (level - va) / (vb - va); + return [xa + t * (xb - xa), ya + t * (yb - ya)]; + }; + + for (const level of LEVELS) { + for (let r = 0; r < rows - 1; r++) { + for (let col = 0; col < cols - 1; col++) { + const v00 = vGrid[ r * cols + col ]; + const v10 = vGrid[ r * cols + col + 1]; + const v01 = vGrid[(r + 1) * cols + col ]; + const v11 = vGrid[(r + 1) * cols + col + 1]; + const pts = []; + if ((v00-level)*(v10-level) < 0) pts.push(interp(v00,v10, col*GRID,r*GRID, (col+1)*GRID,r*GRID, level)); + if ((v10-level)*(v11-level) < 0) pts.push(interp(v10,v11, (col+1)*GRID,r*GRID, (col+1)*GRID,(r+1)*GRID, level)); + if ((v01-level)*(v11-level) < 0) pts.push(interp(v01,v11, col*GRID,(r+1)*GRID, (col+1)*GRID,(r+1)*GRID, level)); + if ((v00-level)*(v01-level) < 0) pts.push(interp(v00,v01, col*GRID,r*GRID, col*GRID,(r+1)*GRID, level)); + if (pts.length >= 2) { ctx.moveTo(pts[0][0], pts[0][1]); ctx.lineTo(pts[1][0], pts[1][1]); } + } + } + } + ctx.stroke(); + ctx.restore(); + } + + /* ── E vectors ── */ + _drawVectorsE(ctx) { + const GRID = 45; + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1; + for (let x = GRID / 2; x < this.W; x += GRID) { + for (let y = GRID / 2; y < this.H; y += GRID) { + const { ex, ey, mag } = this._eField(x, y); + if (mag < 1e-6) continue; + const len = Math.tanh(mag / 8000) * 18; + const nx = ex / mag, ny = ey / mag; + const x2 = x + nx * len, y2 = y + ny * len; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x2, y2); ctx.stroke(); + const ax = -ny * 3, ay = nx * 3; + ctx.beginPath(); ctx.moveTo(x2, y2); + ctx.lineTo(x2 - nx*6+ax, y2 - ny*6+ay); + ctx.lineTo(x2 - nx*6-ax, y2 - ny*6-ay); + ctx.closePath(); ctx.fill(); + } + } + ctx.restore(); + } + + /* ── E field lines ── */ + _drawFieldLinesE(ctx) { + const W = this.W, H = this.H, sim = this; + const RAYS = 12, STEP = 2.5, MAX = 2500, MARGIN = 5, HIT_R = 12, START_R = 18; + + function rkStep(x, y, h) { + const f = (px, py) => { + const e = sim._eField(px, py); + const m = Math.hypot(e.ex, e.ey) || 1e-10; + return [e.ex / m, e.ey / m]; + }; + const [k1x, k1y] = f(x, y); + const [k2x, k2y] = f(x + h*k1x/2, y + h*k1y/2); + const [k3x, k3y] = f(x + h*k2x/2, y + h*k2y/2); + const [k4x, k4y] = f(x + h*k3x, y + h*k3y); + return [x + h*(k1x+2*k2x+2*k3x+k4x)/6, y + h*(k1y+2*k2y+2*k3y+k4y)/6]; + } + + const traceLine = (sx, sy, dir) => { + const pts = [[sx, sy]]; let px = sx, py = sy; + for (let s = 0; s < MAX; s++) { + const [nx, ny] = rkStep(px, py, dir * STEP); + if (nx < -MARGIN || nx > W+MARGIN || ny < -MARGIN || ny > H+MARGIN) break; + let hitNeg = false; + for (const c of sim.sources) { + if (c.kind === 'charge' && c.q < 0 && Math.hypot(nx-c.x, ny-c.y) < HIT_R) { hitNeg = true; break; } + } + if (hitNeg) break; + pts.push([nx, ny]); px = nx; py = ny; + } + return pts; + }; + + ctx.save(); + ctx.lineWidth = 1.2; + for (const charge of this.sources) { + if (charge.kind !== 'charge') continue; + const dir = charge.q > 0 ? 1 : -1; + for (let i = 0; i < RAYS; i++) { + const angle = (i / RAYS) * Math.PI * 2; + const sx = charge.x + START_R * Math.cos(angle); + const sy = charge.y + START_R * Math.sin(angle); + const pts = traceLine(sx, sy, dir); + if (pts.length < 2) continue; + const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length-1][0], pts[pts.length-1][1]); + grad.addColorStop(0, 'rgba(255,255,255,0.75)'); + grad.addColorStop(0.5, 'rgba(255,255,255,0.35)'); + grad.addColorStop(1, 'rgba(255,255,255,0.0)'); + ctx.strokeStyle = grad; + ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); + for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]); + ctx.stroke(); + } + } + ctx.restore(); + } + + /* ── Coulomb force arrows ── */ + _drawForceArrows(ctx) { + ctx.save(); + for (let i = 0; i < this.sources.length; i++) { + const ci = this.sources[i]; + if (ci.kind !== 'charge') continue; + let fx = 0, fy = 0; + for (let j = 0; j < this.sources.length; j++) { + if (i === j) continue; + const cj = this.sources[j]; + if (cj.kind !== 'charge') continue; + const dx = ci.x - cj.x, dy = ci.y - cj.y; + const r2 = dx*dx + dy*dy; + if (r2 < 1) continue; + const r3 = r2 * Math.sqrt(r2); + const F = this.K_E * ci.q * cj.q; + fx += F * dx / r3; fy += F * dy / r3; + } + const mag = Math.hypot(fx, fy); + if (mag < 1e-6) continue; + const len = Math.tanh(mag / 50000) * 55; + const nx = fx / mag, ny = fy / mag; + const x2 = ci.x + nx * len, y2 = ci.y + ny * len; + ctx.strokeStyle = '#FFD166'; ctx.fillStyle = '#FFD166'; + ctx.lineWidth = 2; ctx.shadowBlur = 10; ctx.shadowColor = '#FFD166'; + ctx.beginPath(); ctx.moveTo(ci.x, ci.y); ctx.lineTo(x2, y2); ctx.stroke(); + const ax = -ny*5, ay = nx*5; + ctx.beginPath(); ctx.moveTo(x2, y2); + ctx.lineTo(x2-nx*10+ax, y2-ny*10+ay); ctx.lineTo(x2-nx*10-ax, y2-ny*10-ay); + ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0; + } + ctx.restore(); + } + + /* ── B field lines ── */ + _drawFieldLinesB(ctx) { + const maxSteps = 700, step = 5, killR = 24; + ctx.save(); + for (const src of this.sources) { + if (src.kind === 'charge') continue; + const isOut = src.I > 0; + const col = isOut ? '6,214,224' : '241,91,181'; + const nLines = 14, seedR = 26; + for (let li = 0; li < nLines; li++) { + const ang = (li / nLines) * Math.PI * 2; + let x = src.x + Math.cos(ang) * seedR; + let y = src.y + Math.sin(ang) * seedR; + const pts = [{ x, y }]; + for (let st = 0; st < maxSteps; st++) { + const { nx, ny } = this._bRk4(x, y, step); + x += step * nx; y += step * ny; + if (x < -60 || x > this.W+60 || y < -60 || y > this.H+60) break; + let nearOther = false; + for (const s2 of this.sources) { + if (s2 === src || s2.kind === 'charge') continue; + if (Math.hypot(x-s2.x, y-s2.y) < killR) { nearOther = true; break; } + } + if (nearOther) { pts.push({ x, y }); break; } + if (st > 20 && Math.hypot(x-src.x, y-src.y) < killR) break; + pts.push({ x, y }); + } + if (pts.length < 3) continue; + ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7; + ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); + for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y); + ctx.stroke(); + this._drawBArrows(ctx, pts, col); + } + } + ctx.restore(); + } + + _drawBArrows(ctx, pts, col) { + let acc = 0, next = 80; + for (let i = 1; i < pts.length; i++) { + const dx = pts[i].x - pts[i-1].x, dy = pts[i].y - pts[i-1].y; + acc += Math.hypot(dx, dy); + if (acc < next) continue; + next += 85; + const ang = Math.atan2(dy, dx); + ctx.save(); + ctx.translate(pts[i].x, pts[i].y); ctx.rotate(ang); + ctx.shadowColor = `rgba(${col},0.9)`; ctx.shadowBlur = 6; + ctx.fillStyle = `rgba(${col},0.90)`; + ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-5,-4); ctx.lineTo(-3,0); ctx.lineTo(-5,4); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + } + + /* ── B vector field ── */ + _drawVectorsB(ctx) { + const step = 42; + ctx.save(); + for (let px = step*0.5; px < this.W; px += step) { + for (let py = step*0.5; py < this.H; py += step) { + const { bx, by, mag } = this._bField(px, py); + if (mag < 1) continue; + const t = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4); + const len = 8 + t * 14; + const nx = bx / mag, ny = by / mag; + const alp = 0.28 + t * 0.6; + ctx.save(); + ctx.translate(px, py); ctx.rotate(Math.atan2(ny, nx)); + ctx.globalAlpha = alp; + ctx.strokeStyle = `rgba(${Math.round(155+t*100)},${Math.round(93+t*121)},229,1)`; + ctx.lineWidth = 1.1 + t * 0.6; + ctx.beginPath(); ctx.moveTo(-len/2, 0); ctx.lineTo(len/2, 0); ctx.stroke(); + ctx.fillStyle = ctx.strokeStyle; + ctx.beginPath(); ctx.moveTo(len/2,0); ctx.lineTo(len/2-5,-2.5); ctx.lineTo(len/2-5,2.5); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + } + ctx.restore(); + } + + /* ── draw all sources ── */ + _drawSources(ctx) { + this.sources.forEach((s, i) => { + if (s.kind === 'charge') this._drawCharge(ctx, s, i); + else this._drawWire(ctx, s, i); + }); + } + + _drawCharge(ctx, s, i) { + const r = 14 + Math.tanh(Math.abs(s.q) / 5) * 4; + const pos = s.q > 0; + ctx.save(); + ctx.shadowBlur = 18; + ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0'; + if (this._hovered === i) { + ctx.beginPath(); ctx.arc(s.x, s.y, r+6, 0, Math.PI*2); + ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)'; + ctx.lineWidth = 2; ctx.stroke(); + } + const grd = ctx.createRadialGradient(s.x-r*0.3, s.y-r*0.3, r*0.1, s.x, s.y, r); + if (pos) { grd.addColorStop(0,'#FF7FA3'); grd.addColorStop(1,'#EF476F'); } + else { grd.addColorStop(0,'#90E0FF'); grd.addColorStop(1,'#4CC9F0'); } + ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI*2); + ctx.fillStyle = grd; ctx.fill(); + ctx.shadowBlur = 0; + ctx.fillStyle = '#fff'; ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(pos ? '+' : '−', s.x, s.y + 1); + ctx.restore(); + } + + _drawWire(ctx, s, i) { + const isOut = s.I > 0; + const col = isOut ? '#06D6E0' : '#F15BB5'; + const rgb = isOut ? '6,214,224' : '241,91,181'; + const isHov = this._hovered === i || this._drag === i; + const R = isHov ? 19 : 16; + ctx.save(); + ctx.shadowColor = col; ctx.shadowBlur = isHov ? 32 : 18; + ctx.beginPath(); ctx.arc(s.x, s.y, R+6, 0, Math.PI*2); + ctx.fillStyle = `rgba(${rgb},0.08)`; ctx.fill(); + ctx.beginPath(); ctx.arc(s.x, s.y, R, 0, Math.PI*2); + ctx.fillStyle = isHov ? `rgba(${rgb},0.25)` : 'rgba(5,5,20,0.9)'; ctx.fill(); + ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.stroke(); + if (isOut) { + ctx.beginPath(); ctx.arc(s.x, s.y, 5, 0, Math.PI*2); + ctx.fillStyle = col; ctx.shadowBlur = 8; ctx.fill(); + } else { + const d = 5.5; + ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.shadowBlur = 6; + ctx.beginPath(); + ctx.moveTo(s.x-d, s.y-d); ctx.lineTo(s.x+d, s.y+d); + ctx.moveTo(s.x+d, s.y-d); ctx.lineTo(s.x-d, s.y+d); + ctx.stroke(); + } + ctx.shadowBlur = 0; + ctx.font = '10px Manrope, sans-serif'; + ctx.fillStyle = `rgba(${rgb},0.75)`; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText((isOut ? '↑' : '↓') + ' ' + Math.abs(s.I).toFixed(0) + ' А', s.x, s.y+R+5); + ctx.restore(); + } + + /* ── conductor ── */ + _drawConductor(ctx) { + const c = this._cond; + const Lx = c.x2-c.x1, Ly = c.y2-c.y1; + const L = Math.hypot(Lx, Ly); + if (L < 2) return; + const { Fz, B, bx, by, mx, my } = this._ampereForce(); + const Fabs = Math.abs(Fz); + const fOut = Fz > 0; + ctx.save(); + ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 14; + ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 5; ctx.globalAlpha = 0.35; + ctx.beginPath(); ctx.moveTo(c.x1,c.y1); ctx.lineTo(c.x2,c.y2); ctx.stroke(); + ctx.globalAlpha = 1; ctx.shadowBlur = 6; + ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(c.x1,c.y1); ctx.lineTo(c.x2,c.y2); ctx.stroke(); + const steps = Math.floor(L / 55); + ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 5; + for (let s = 0; s <= steps; s++) { + const t = (s + 0.5) / (steps + 1); + const ax = c.x1+Lx*t, ay = c.y1+Ly*t; + const ang = c.I > 0 ? Math.atan2(Ly,Lx) : Math.atan2(-Ly,-Lx); + ctx.save(); ctx.translate(ax,ay); ctx.rotate(ang); + ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-5,-4); ctx.lineTo(-5,4); ctx.closePath(); + ctx.fill(); ctx.restore(); + } + [[c.x1,c.y1],[c.x2,c.y2]].forEach(([ex,ey]) => { + ctx.beginPath(); ctx.arc(ex,ey,8,0,Math.PI*2); + ctx.fillStyle='#F15BB5'; ctx.shadowBlur=10; ctx.fill(); + ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.stroke(); + }); + if (B > 0.5) { + const bScale = Math.min(40, Math.log10(1+B*0.02)*50); + const bNorm = Math.hypot(bx,by); + const bnx = bx/bNorm, bny = by/bNorm; + ctx.strokeStyle='#22d55e'; ctx.lineWidth=1.5; ctx.shadowColor='#22d55e'; + ctx.beginPath(); ctx.moveTo(mx,my); ctx.lineTo(mx+bnx*bScale,my+bny*bScale); ctx.stroke(); + ctx.fillStyle='#22d55e'; ctx.font='10px Manrope'; + ctx.textAlign='center'; ctx.textBaseline='bottom'; + ctx.fillText('B', mx+bnx*(bScale+10), my+bny*(bScale+10)); + } + if (Fabs > 1e-6) { + const sym = fOut ? '⊙' : '⊗'; + const symCol = fOut ? '#06D6E0' : '#ff6060'; + const perpX = -Ly/L, perpY = Lx/L; + ctx.font = `${Math.min(22,8+Fabs*200)}px Manrope`; + ctx.fillStyle=symCol; ctx.shadowColor=symCol; ctx.shadowBlur=10; + ctx.textAlign='center'; ctx.textBaseline='middle'; + const symCount = Math.max(1, Math.min(5, Math.floor(L/80))); + for (let s = 0; s < symCount; s++) { + const t = (s+0.5)/symCount; + ctx.fillText(sym, c.x1+Lx*t+perpX*22, c.y1+Ly*t+perpY*22); + } + ctx.font='bold 11px Manrope'; ctx.shadowBlur=5; ctx.fillStyle=symCol; + ctx.textAlign='left'; ctx.textBaseline='middle'; + ctx.fillText('F = '+Fabs.toFixed(3)+' (ед)', c.x2+12, c.y2); + } + ctx.shadowBlur = 0; + ctx.font = '10px Manrope'; ctx.fillStyle = 'rgba(241,91,181,0.8)'; + ctx.textAlign='center'; ctx.textBaseline='bottom'; + const ang2 = Math.atan2(Ly,Lx); + ctx.fillText('I = '+c.I+' А', mx-Math.sin(ang2)*20, my+Math.cos(ang2)*(-20)); + ctx.restore(); + } + + /* ── flux circle ── */ + _drawFlux(ctx) { + const f = this._flux; + const Phi = this._fluxValue(); + const { mag } = this._bField(f.x, f.y); + const brightness = Math.min(1, Math.log10(1+mag*0.003)*0.7); + ctx.save(); + const grad = ctx.createRadialGradient(f.x,f.y,0,f.x,f.y,f.r); + grad.addColorStop(0, `rgba(255,220,50,${brightness*0.4})`); + grad.addColorStop(0.6, `rgba(155,93,229,${brightness*0.15})`); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle=grad; ctx.beginPath(); ctx.arc(f.x,f.y,f.r,0,Math.PI*2); ctx.fill(); + ctx.strokeStyle='rgba(255,220,50,0.7)'; ctx.lineWidth=1.8; + ctx.setLineDash([6,4]); ctx.shadowColor='#ffdc32'; ctx.shadowBlur=8; + ctx.beginPath(); ctx.arc(f.x,f.y,f.r,0,Math.PI*2); ctx.stroke(); ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(f.x,f.y,4,0,Math.PI*2); ctx.fillStyle='#ffdc32'; ctx.fill(); + ctx.font='bold 11px Manrope'; ctx.fillStyle='#ffdc32'; + ctx.shadowColor='#ffdc32'; ctx.shadowBlur=6; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText('Φ = '+Phi.toFixed(4)+' Вб', f.x, f.y+f.r+6); + ctx.fillText('|B| = '+mag.toFixed(1)+' (ед)', f.x, f.y+f.r+20); + ctx.restore(); + } + + /* ── particle ── */ + _drawParticle(ctx) { + const p = this._particle; + if (!p) return; + if (p.trail.length > 1) { + ctx.save(); + for (let i = 1; i < p.trail.length; i++) { + const t = i / p.trail.length; + ctx.beginPath(); + ctx.moveTo(p.trail[i-1].x, p.trail[i-1].y); + ctx.lineTo(p.trail[i].x, p.trail[i].y); + ctx.strokeStyle = `rgba(255,255,80,${t*0.55})`; + ctx.lineWidth = t * 2.5; ctx.stroke(); + } + ctx.restore(); + } + const grd = ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,16); + grd.addColorStop(0,'rgba(255,255,80,0.35)'); grd.addColorStop(1,'rgba(255,255,80,0)'); + ctx.save(); ctx.fillStyle=grd; ctx.beginPath(); ctx.arc(p.x,p.y,16,0,Math.PI*2); ctx.fill(); ctx.restore(); + ctx.save(); ctx.shadowColor='#ffff50'; ctx.shadowBlur=18; + ctx.beginPath(); ctx.arc(p.x,p.y,6,0,Math.PI*2); + ctx.fillStyle='#ffff50'; ctx.fill(); ctx.strokeStyle='#fff'; ctx.lineWidth=1.8; ctx.stroke(); ctx.restore(); + const spd = Math.hypot(p.vx, p.vy); + if (spd > 0.01) { + const s = 22; + ctx.save(); ctx.strokeStyle='rgba(255,255,80,0.7)'; ctx.lineWidth=1.8; + ctx.shadowColor='#ffff50'; ctx.shadowBlur=8; + ctx.beginPath(); ctx.moveTo(p.x,p.y); + ctx.lineTo(p.x+p.vx/spd*s, p.y+p.vy/spd*s); ctx.stroke(); ctx.restore(); + } + } + + /* ── cursor E ── */ + _drawCursorE(ctx) { + const { ex, ey, mag, v } = this._cursorE; + const { x, y } = this._mousePos; + if (mag < 1e-6) return; + const nx = ex/mag, ny = ey/mag; + const len = 20, x2 = x+nx*len, y2 = y+ny*len; + ctx.save(); + ctx.strokeStyle = 'rgba(239,71,111,0.8)'; ctx.fillStyle = 'rgba(239,71,111,0.8)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x2,y2); ctx.stroke(); + const ax=-ny*4, ay=nx*4; + ctx.beginPath(); ctx.moveTo(x2,y2); + ctx.lineTo(x2-nx*8+ax, y2-ny*8+ay); ctx.lineTo(x2-nx*8-ax, y2-ny*8-ay); + ctx.closePath(); ctx.fill(); + const eStr = mag>=1000 ? (mag/1000).toFixed(1)+'k' : mag.toFixed(0); + const vStr = Math.abs(v)>=1000 ? (v/1000).toFixed(1)+'k' : v.toFixed(0); + ctx.font='11px monospace'; ctx.textAlign='left'; ctx.textBaseline='bottom'; + ctx.fillStyle='rgba(255,255,255,0.85)'; ctx.shadowBlur=4; ctx.shadowColor='#000'; + ctx.fillText(`|E| = ${eStr}`, x+6, y-14); + ctx.fillText(`V = ${vStr}`, x+6, y-2); + ctx.restore(); + } + + /* ── cursor B ── */ + _drawCursorB(ctx) { + const b = this._cursorB; + if (!b || !this._mousePos) return; + const { bx, by, mag } = b; + const { x, y } = this._mousePos; + if (mag < 0.5) return; + ctx.save(); + ctx.strokeStyle='rgba(255,255,255,0.35)'; ctx.lineWidth=1; + ctx.setLineDash([3,3]); ctx.beginPath(); ctx.arc(x,y,14,0,Math.PI*2); ctx.stroke(); ctx.setLineDash([]); + const bNorm = Math.hypot(bx,by); + const len = Math.min(28, Math.log10(1+mag*0.01)*35); + const bnx=bx/bNorm, bny=by/bNorm; + ctx.strokeStyle='rgba(6,214,224,0.7)'; ctx.lineWidth=1.2; + ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+bnx*len,y+bny*len); ctx.stroke(); + ctx.fillStyle='rgba(6,214,224,0.7)'; + const a=Math.atan2(bny,bnx), tx=x+bnx*len, ty=y+bny*len; + ctx.beginPath(); ctx.moveTo(tx,ty); + ctx.lineTo(tx-6*Math.cos(a-0.4), ty-6*Math.sin(a-0.4)); + ctx.lineTo(tx-6*Math.cos(a+0.4), ty-6*Math.sin(a+0.4)); + ctx.closePath(); ctx.fill(); + ctx.font='9px Manrope'; ctx.fillStyle='rgba(255,255,255,0.6)'; + ctx.textAlign='left'; ctx.textBaseline='middle'; + ctx.fillText('|B|='+mag.toFixed(0), x+18, y+8); + ctx.restore(); + } + + /* ── empty hint ── */ + _drawHint(ctx) { + const W = this.W, H = this.H; + ctx.save(); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.font='16px Manrope, sans-serif'; ctx.fillStyle='rgba(155,93,229,0.45)'; + if (this.mode === 'E' || this.mode === 'combined') { + ctx.fillText('Нажмите — добавьте заряд (+/−)', W/2, H/2-18); + } else { + ctx.fillText('Нажмите — добавьте провод с током', W/2, H/2-18); + } + ctx.font='13px Manrope, sans-serif'; ctx.fillStyle='rgba(255,255,255,0.22)'; + ctx.fillText('ПКМ / двойной клик — удалить · перетащи для перемещения', W/2, H/2+14); + ctx.restore(); + } + + /* ────────────────────────────── + Colour helpers + ────────────────────────────── */ + + /* HSL (0-360, 0-100, 0-100) → [r, g, b] — used by E colormap */ + _hslToRgb(h, s, l) { + h = ((h % 360) + 360) % 360; + s /= 100; l /= 100; + const c = (1 - Math.abs(2*l-1)) * s; + const x = c * (1 - Math.abs((h/60) % 2 - 1)); + const m = l - c/2; + let r=0, g=0, b=0; + if (h < 60) { r=c; g=x; b=0; } + else if (h < 120) { r=x; g=c; b=0; } + else if (h < 180) { r=0; g=c; b=x; } + else if (h < 240) { r=0; g=x; b=c; } + else if (h < 300) { r=x; g=0; b=c; } + else { r=c; g=0; b=x; } + return [Math.round((r+m)*255), Math.round((g+m)*255), Math.round((b+m)*255)]; + } + + /* HSL (0-1, 0-1, 0-1) → [r, g, b] — used by B colormap */ + _hsl(h, s, l) { + let r, g, b; + if (s === 0) { r = g = b = l; } + else { + const q = l < 0.5 ? l*(1+s) : l+s-l*s, p = 2*l - q; + const hue2 = (p, q, t) => { + t = ((t % 1) + 1) % 1; + if (t < 1/6) return p + (q-p)*6*t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q-p)*(2/3-t)*6; + return p; + }; + r = hue2(p, q, h+1/3); g = hue2(p, q, h); b = hue2(p, q, h-1/3); + } + return [Math.round(r*255), Math.round(g*255), Math.round(b*255)]; + } +} + +/* ═══════════════════════════════════════ + Lab UI glue — EMField +═══════════════════════════════════════ */ + +var emSim = null; + +function _openEMField(defaultMode) { + const mode = defaultMode || 'E'; + document.getElementById('sim-topbar-title').textContent = 'Электромагнитные поля'; + _simShow('sim-emfield'); + _simShow('ctrl-emfield'); + + requestAnimationFrame(() => requestAnimationFrame(() => { + const canvas = document.getElementById('emfield-canvas'); + if (!emSim) { + emSim = new EMFieldSim(canvas); + emSim.onUpdate = _emUpdateUI; + } + emSim.fit(); + emSwitchMode(mode, true); + if (emSim.sources.length === 0) _emDefaultPreset(mode); + _emUpdateUI(emSim.info()); + })); +} + +function _emDefaultPreset(mode) { + if (mode === 'E' || mode === 'combined') emSim.presetE('dipole'); + else emSim.presetB('anti'); +} + +function emSwitchMode(mode, silent) { + if (!emSim) return; + emSim.setMode(mode); + + /* tab styling */ + ['E','B','combined'].forEach(m => { + const btn = document.getElementById('em-tab-' + m); + if (btn) btn.classList.toggle('active', m === mode); + }); + + /* show/hide control sections */ + const eCtrl = document.getElementById('em-ctrl-E'); + const bCtrl = document.getElementById('em-ctrl-B'); + const abCtrl = document.getElementById('em-ctrl-combined'); + if (eCtrl) eCtrl.style.display = (mode === 'E' || mode === 'combined') ? '' : 'none'; + if (bCtrl) bCtrl.style.display = (mode === 'B' || mode === 'combined') ? '' : 'none'; + if (abCtrl) abCtrl.style.display = mode === 'combined' ? '' : 'none'; + + if (!silent) { + if (emSim.sources.length === 0) _emDefaultPreset(mode); + _emUpdateUI(emSim.info()); + } +} + +function emAddTypeSwitch(type) { + if (!emSim) return; + emSim._addType = type; + document.getElementById('em-add-charge').classList.toggle('active', type === 'charge'); + document.getElementById('em-add-wire').classList.toggle('active', type === 'wire'); +} + +function emSign(s) { + if (!emSim) return; + emSim.addSign = s >= 0 ? +1 : -1; + document.getElementById('em-sign-pos').classList.toggle('active', s > 0); + document.getElementById('em-sign-neg').classList.toggle('active', s < 0); +} + +function emWireDir(dir) { + if (!emSim) return; + emSim.addDir = dir; + document.getElementById('em-dir-out').classList.toggle('active', dir === 'out'); + document.getElementById('em-dir-in').classList.toggle('active', dir === 'in'); +} + +function emCurrentChange() { + const I = +document.getElementById('sl-emI').value; + const lbl = document.getElementById('em-curI-val'); + if (lbl) lbl.textContent = I + ' А'; + if (emSim) emSim.setCurrentAll(I); +} + +function emLayer(field, name, rowEl) { + if (!emSim) return; + const key = field + '_' + name; + emSim.layers[key] = !emSim.layers[key]; + const on = emSim.layers[key]; + rowEl.classList.toggle('active', on); + const tog = rowEl.querySelector('.tri-toggle'); + if (tog) { + tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)'; + const dot = tog.querySelector('span'); + if (dot) dot.style.marginLeft = on ? '14px' : '2px'; + } + if (field === 'B') emSim._cmBDirty = true; + if (field === 'E') emSim._cmEDirty = true; + emSim.draw(); +} + +function emParticle(rowEl) { + if (!emSim) return; + emSim.toggleParticle(); + rowEl.classList.toggle('active', emSim.particleOn); + _emUpdateUI(emSim.info()); +} + +function emCondToggle(rowEl) { + if (!emSim) return; + emSim.toggleConductor(); + const on = emSim._cond.on; + rowEl.classList.toggle('active', on); + const block = document.getElementById('em-cond-I-block'); + if (block) block.style.display = on ? '' : 'none'; + _emUpdateUI(emSim.info()); +} + +function emCondCurrentChange() { + if (!emSim) return; + const I = parseFloat(document.getElementById('sl-emCondI').value); + const lbl = document.getElementById('em-condI-val'); + if (lbl) lbl.textContent = I + ' А'; + emSim.setConductorI(I); +} + +function emFluxToggle(rowEl) { + if (!emSim) return; + emSim.toggleFlux(); + rowEl.classList.toggle('active', emSim._flux.on); + _emUpdateUI(emSim.info()); +} + +function emPresetE(name) { if (emSim) emSim.presetE(name); } +function emPresetB(name) { if (emSim) emSim.presetB(name); } + +function _emUpdateUI(info) { + if (!info) return; + const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + + set('embar-charges', info.charges); + set('embar-wires', info.wires); + set('embar-curE', info.cursorE); + set('embar-curV', info.cursorV); + set('embar-curB', info.cursorB); + set('embar-particle', info.particleOn ? 'вкл' : 'выкл'); + + const pEl = document.getElementById('embar-particle'); + if (pEl) pEl.style.color = info.particleOn ? '#ffff50' : ''; + + const fEl = document.getElementById('embar-ampere'); + if (fEl) { + if (info.condOn && info.Fz !== 0) { + fEl.textContent = (info.Fz > 0 ? '⊙ ' : '⊗ ') + Math.abs(info.Fz).toFixed(3); + fEl.style.color = '#fbbf24'; + } else { + fEl.textContent = '—'; fEl.style.color = '#fbbf24'; + } + } + const phEl = document.getElementById('embar-flux'); + if (phEl) { + if (info.fluxOn) { phEl.textContent = info.flux.toExponential(2) + ' Вб'; phEl.style.color = '#34d399'; } + else { phEl.textContent = '—'; phEl.style.color = '#34d399'; } + } +} diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index 55813f0..adc437d 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -217,6 +217,20 @@ class GeoEngine { } if (obj.constr === 'ngon_vertex') return obj.srcCenter === id || obj.srcVertex === id; if (obj.constr === 'translate') return obj.srcA === id || obj.srcB === id || obj.srcPt === id; + if (obj.constr === 'on_segment') { + if (obj.srcSeg === id) return true; + // зависим и от перемещения конечных точек отрезка + const seg = this._objects.get(obj.srcSeg); + return !!(seg && (seg.p1Id === id || seg.p2Id === id)); + } + if (obj.constr === 'on_circle') { + if (obj.srcCircle === id) return true; + // если id — точка, задающая окружность, зависим транзитивно через саму окружность + const circ = this._objects.get(obj.srcCircle); + if (!circ) return false; + if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id; + return circ.centerId === id || circ.edgeId === id; + } return false; case 'derived_line': switch (obj.constr) { @@ -234,6 +248,14 @@ class GeoEngine { } } return false; + case 'measure_length': + return obj.srcSeg === id; + case 'measure_angle': + return obj.srcA === id || obj.srcVtx === id || obj.srcB === id; + case 'measure_area': + return obj.srcPoly === id; + case 'locus': + return obj.srcMover === id || obj.srcTarget === id; } return false; } @@ -323,6 +345,31 @@ class GeoEngine { obj.x = pO.x + obj.k * (pP.x - pO.x); obj.y = pO.y + obj.k * (pP.y - pO.y); } + } else if (obj.constr === 'on_segment') { + const seg = _g(obj.srcSeg); + if (seg) { + const p1 = _g(seg.p1Id), p2 = _g(seg.p2Id); + if (p1 && p2) { + const t = Math.max(0, Math.min(1, obj._t)); + obj.x = p1.x + t * (p2.x - p1.x); + obj.y = p1.y + t * (p2.y - p1.y); + } + } + } else if (obj.constr === 'on_circle') { + const circ = _g(obj.srcCircle); + if (circ) { + let cx, cy, r; + if (circ.derived && circ.cx != null) { + cx = circ.cx; cy = circ.cy; r = circ.r; + } else { + const mc = _g(circ.centerId), me = _g(circ.edgeId); + if (mc && me) { cx = mc.x; cy = mc.y; r = gDist(mc, me); } + } + if (cx != null) { + obj.x = cx + r * Math.cos(obj._theta); + obj.y = cy + r * Math.sin(obj._theta); + } + } } } else if (obj.type === 'circle' && obj.derived) { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); @@ -470,6 +517,7 @@ class GeoSim { this._pendingLineRef = null; // первый кликнутый объект для parallel/perp/intersect/reflect/foot this._pendingCircRef = null; // первый кликнутый объект-окружность для tangent this._pendingScaleO = null; // центр подобия для инструмента scale + this._pendingMover = null; // мовер-точка для инструмента locus this._scaleK = 2; // коэффициент подобия /* ── Состояние drag/pan ── */ @@ -501,6 +549,7 @@ class GeoSim { this.onUpdate = null; // cb(stats) this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки this.onDeleteRequest = null; // cb(obj, deps, softFn, cascadeFn) — подтвердить удаление + this.onLocusError = null; // cb(msg) — ошибка при построении ГМТ this._labelCounter = 0; this._ngonSides = 6; // для инструмента правильного многоугольника @@ -533,6 +582,7 @@ class GeoSim { this._pendingLineRef = null; this._pendingCircRef = null; this._pendingScaleO = null; + this._pendingMover = null; this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair'; this.render(); } @@ -583,6 +633,10 @@ class GeoSim { this._drawAngleMeasures(ctx); // всегда — для arcmark и прямых углов; showAngles управляет авто-подписями // Точки поверх всего (включая производные) for (const obj of this.eng.points()) this._drawPoint(ctx, obj); + // Локусы (ГМТ) + for (const obj of this.eng.byType('locus')) this._drawLocus(ctx, obj); + // Измерительные чипы поверх всего + this._drawMeasurements(ctx); // Предпросмотр строящегося объекта this._drawPreview(ctx); // Подсветка первого объекта при инструментах построения @@ -1266,6 +1320,139 @@ class GeoSim { } } + /* ── Измерительные чипы (measure_length / measure_angle / measure_area) ── */ + _drawMeasurements(ctx) { + const CHIP_PAD_X = 8, CHIP_PAD_Y = 4, CHIP_R = 6; + ctx.save(); + ctx.font = '11px Manrope,sans-serif'; + for (const obj of this.eng.all()) { + if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue; + const text = this._measureText(obj); + if (text === null) continue; + + const labelPx = this._measureLabelPos(obj); + if (!labelPx) continue; + + const w = ctx.measureText(text).width + CHIP_PAD_X * 2; + const h = 18; + const x = labelPx.x - w / 2; + const y = labelPx.y - h / 2; + + const isSelected = this._isSelected(obj); + const col = obj.type === 'measure_length' ? '#9B5DE5' + : obj.type === 'measure_angle' ? '#F15BB5' + : '#22d55e'; + + ctx.globalAlpha = 0.92; + ctx.fillStyle = 'rgba(10,7,24,0.82)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x, y, w, h, CHIP_R); + else ctx.rect(x, y, w, h); + ctx.fill(); + + ctx.strokeStyle = isSelected ? '#fff' : col; + ctx.lineWidth = isSelected ? 1.8 : 1.2; + ctx.globalAlpha = isSelected ? 0.9 : 0.7; + ctx.stroke(); + + ctx.fillStyle = col; + ctx.globalAlpha = 0.95; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3; + ctx.fillText(text, labelPx.x, labelPx.y + 0.5); + ctx.shadowBlur = 0; + ctx.textBaseline = 'alphabetic'; + } + ctx.restore(); + } + + _measureText(obj) { + if (obj.type === 'measure_length') { + const seg = this.eng.get(obj.srcSeg); + if (!seg) return null; + const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id); + if (!m1 || !m2) return null; + const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id); + const lab1 = (p1 && p1.label) || ''; + const lab2 = (p2 && p2.label) || ''; + const name = lab1 && lab2 ? lab1 + lab2 : 'seg'; + return name + ' = ' + gDist(m1, m2).toFixed(2); + } + if (obj.type === 'measure_angle') { + const pA = this.eng.get(obj.srcA), pV = this.eng.get(obj.srcVtx), pB = this.eng.get(obj.srcB); + if (!pA || !pV || !pB) return null; + const ang = gAngleDeg(pA, pV, pB); + const lA = (pA.label) || '', lV = (pV.label) || '', lB = (pB.label) || ''; + const name = (lA && lV && lB) ? lA + lV + lB : 'ang'; + return '∠' + name + ' = ' + ang.toFixed(1) + '°'; + } + if (obj.type === 'measure_area') { + const poly = this.eng.get(obj.srcPoly); + if (!poly) return null; + const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean); + if (pts.length < 3) return null; + return 'S = ' + gPolygonArea(pts).toFixed(2); + } + return null; + } + + /* Базовая позиция чипа в пикселях (без пользовательского offset) */ + _measureLabelBasePos(obj) { + if (obj.type === 'measure_length') { + const seg = this.eng.get(obj.srcSeg); + if (!seg) return null; + const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id); + if (!m1 || !m2) return null; + const mid = this.vp.toCanvas((m1.x + m2.x) / 2, (m1.y + m2.y) / 2); + return { x: mid.x, y: mid.y - 18 }; + } + if (obj.type === 'measure_angle') { + const pV = this.eng.get(obj.srcVtx); + if (!pV) return null; + const vPx = this.vp.toCanvas(pV.x, pV.y); + return { x: vPx.x, y: vPx.y - 28 }; + } + if (obj.type === 'measure_area') { + const poly = this.eng.get(obj.srcPoly); + if (!poly) return null; + const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean); + if (pts.length < 3) return null; + const sumX = pts.reduce((s, p) => s + p.x, 0) / pts.length; + const sumY = pts.reduce((s, p) => s + p.y, 0) / pts.length; + const c = this.vp.toCanvas(sumX, sumY); + return { x: c.x, y: c.y }; + } + return null; + } + + /* Позиция чипа в пикселях (базовая + пользовательский offset) */ + _measureLabelPos(obj) { + const base = this._measureLabelBasePos(obj); + if (!base) return null; + return { x: base.x + (obj.offX || 0), y: base.y + (obj.offY || 0) }; + } + + /* ── Локус (ГМТ) ──────────────────────────────────────────── */ + _drawLocus(ctx, obj) { + const pts = obj.samples; + if (!pts || pts.length < 2) return; + ctx.save(); + ctx.strokeStyle = obj.style && obj.style.color ? obj.style.color : '#F59E0B'; + ctx.lineWidth = 2; + ctx.globalAlpha = 0.65; + ctx.setLineDash([]); + ctx.beginPath(); + const first = this.vp.toCanvas(pts[0].x, pts[0].y); + ctx.moveTo(first.x, first.y); + for (let i = 1; i < pts.length; i++) { + const p = this.vp.toCanvas(pts[i].x, pts[i].y); + ctx.lineTo(p.x, p.y); + } + ctx.stroke(); + ctx.restore(); + } + /* ── Предпросмотр (строящийся объект) ─────────────────────── */ _drawPreview(ctx) { if (this._pending.length === 0 || !this._preview) return; @@ -1454,7 +1641,7 @@ class GeoSim { // ПКМ → отмена текущего построения if (e.button === 2) { - this._pending = []; this._preview = null; this._pendingLineRef = null; + this._pending = []; this._preview = null; this._pendingLineRef = null; this._pendingMover = null; this.render(); return; } @@ -1485,14 +1672,24 @@ class GeoSim { if (Math.hypot(pp.x-px, pp.y-py) < SNAP_PX) { found = pt; break; } } - if (found && !found.locked && !found.derived) { - this._drag = { id: found.id }; + const isConstrained = found && found.derived && + (found.constr === 'on_segment' || found.constr === 'on_circle'); + if (found && !found.locked && (!found.derived || isConstrained)) { + this._drag = { id: found.id, constrained: isConstrained }; this._selected = found; this.canvas.style.cursor = 'grabbing'; } else { - // Выбрать объект (отрезок, окружность, полигон...) - this._selected = this._hitTest(px, py); - this._drag = null; + // Проверить, не кликнули ли на чип измерения (для drag чипа) + const hitObj = this._hitTest(px, py); + this._selected = hitObj; + if (hitObj && (hitObj.type === 'measure_length' || hitObj.type === 'measure_angle' || hitObj.type === 'measure_area')) { + // drag чипа — запоминаем offset курсора относительно позиции чипа + const lp = this._measureLabelPos(hitObj); + this._drag = { id: hitObj.id, chipDrag: true, offX: px - (lp ? lp.x : px), offY: py - (lp ? lp.y : py) }; + this.canvas.style.cursor = 'grabbing'; + } else { + this._drag = null; + } } this.render(); } @@ -1502,6 +1699,37 @@ class GeoSim { const HIT = 8; // pixels const m = this.vp.toMath(px, py); + // Измерительные чипы + this.ctx.font = '11px Manrope,sans-serif'; + for (const obj of this.eng.all()) { + if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue; + const text = this._measureText(obj); + if (!text) continue; + const labelPx = this._measureLabelPos(obj); + if (!labelPx) continue; + const w = this.ctx.measureText(text).width + 16; + const h = 18; + if (px >= labelPx.x - w/2 - 2 && px <= labelPx.x + w/2 + 2 && + py >= labelPx.y - h/2 - 2 && py <= labelPx.y + h/2 + 2) { + return obj; + } + } + // Локусы + for (const obj of this.eng.byType('locus')) { + const pts = obj.samples; + if (!pts || pts.length < 2) continue; + for (let i = 1; i < pts.length; i++) { + const a = this.vp.toCanvas(pts[i-1].x, pts[i-1].y); + const b = this.vp.toCanvas(pts[i].x, pts[i].y); + const seg2D = { x: b.x-a.x, y: b.y-a.y }; + const lenSq = seg2D.x*seg2D.x + seg2D.y*seg2D.y; + if (lenSq < 1e-9) continue; + const t = Math.max(0, Math.min(1, ((px-a.x)*seg2D.x + (py-a.y)*seg2D.y) / lenSq)); + const dx = px - (a.x + t*seg2D.x), dy = py - (a.y + t*seg2D.y); + if (dx*dx + dy*dy < HIT*HIT) return obj; + } + } + // Полигоны (проверяем стороны) for (const obj of this.eng.byType('polygon')) { const ids = obj.pointIds; @@ -2318,10 +2546,221 @@ class GeoSim { } break; } + + /* ══ Точка на отрезке — для ГМТ ══ */ + + case 'point_on_segment': { + const hitSeg = this._hitTestLine(px, py); + if (!hitSeg || hitSeg.type !== 'segment' || hitSeg.virtual) break; + this._pushUndo(); + const p1 = this.eng.get(hitSeg.p1Id), p2 = this.eng.get(hitSeg.p2Id); + if (!p1 || !p2) break; + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const l2 = dx*dx + dy*dy; + const t = l2 < 1e-12 ? 0.5 + : Math.max(0, Math.min(1, ((snapped.x-p1.x)*dx + (snapped.y-p1.y)*dy) / l2)); + const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1); + this.eng.add({ + type: 'point', derived: true, constr: 'on_segment', + srcSeg: hitSeg.id, _t: t, + x: p1.x + t*dx, y: p1.y + t*dy, + label: lbl, style: { color: '#06D6E0', size: 4 } + }); + if (this.onUpdate) this.onUpdate(this.getStats()); + break; + } + + /* ══ Точка на окружности — для ГМТ ══ */ + + case 'point_on_circle': { + const hitCirc = this._hitTestCircle(px, py); + if (!hitCirc) break; + this._pushUndo(); + let cx, cy, r; + if (hitCirc.derived && hitCirc.cx != null) { + cx = hitCirc.cx; cy = hitCirc.cy; r = hitCirc.r; + } else { + const mc = this.eng.get(hitCirc.centerId), me = this.eng.get(hitCirc.edgeId); + if (!mc || !me) break; + cx = mc.x; cy = mc.y; r = gDist(mc, me); + } + const theta = Math.atan2(snapped.y - cy, snapped.x - cx); + const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1); + this.eng.add({ + type: 'point', derived: true, constr: 'on_circle', + srcCircle: hitCirc.id, _theta: theta, + x: cx + r * Math.cos(theta), + y: cy + r * Math.sin(theta), + label: lbl, style: { color: '#06D6E0', size: 4 } + }); + if (this.onUpdate) this.onUpdate(this.getStats()); + break; + } + + /* ══ Измерение длины — клик на отрезок ══ */ + + case 'measure_length': { + const seg = this._hitTestLine(px, py); + if (seg && (seg.type === 'segment') && !seg.virtual) { + this._pushUndo(); + this.eng.add({ type:'measure_length', srcSeg: seg.id, offX:0, offY:0 }); + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } + + /* ══ Измерение угла — 3 клика: сторона A, вершина, сторона B ══ */ + + case 'measure_angle': { + this._pending.push(snapped); + if (this._pending.length === 1) { + if (this.onHintChange) this.onHintChange('measure_angle', 2); + } else if (this._pending.length === 2) { + if (this.onHintChange) this.onHintChange('measure_angle', 3); + } else if (this._pending.length === 3) { + this._pushUndo(); + const ptA = this._ensurePoint(this._pending[0]); + const ptVtx = this._ensurePoint(this._pending[1]); + const ptB = this._ensurePoint(this._pending[2]); + this.eng.add({ type:'measure_angle', srcA:ptA.id, srcVtx:ptVtx.id, srcB:ptB.id, offX:0, offY:0 }); + this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } + + /* ══ Измерение площади — клик на полигон ══ */ + + case 'measure_area': { + const poly = this._hitTest(px, py); + if (poly && poly.type === 'polygon') { + this._pushUndo(); + this.eng.add({ type:'measure_area', srcPoly: poly.id, offX:0, offY:0 }); + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } + + /* ══ ГМТ (локус) — шаг 1: мовер-точка, шаг 2: целевая точка ══ */ + + case 'locus': { + if (!this._pendingMover) { + // Первый клик: выбрать точку-мовер (должна быть constrained) + const SNAP_PX = 12; + let hitPt = null; + for (const pt of this.eng.points()) { + const pp = this.vp.toCanvas(pt.x, pt.y); + if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; } + } + if (!hitPt) break; + // Мовер должен быть constrained (точка на отрезке или на окружности по параметру) + if (!hitPt.constr || (hitPt.constr !== 'on_segment' && hitPt.constr !== 'on_circle')) { + if (this.onLocusError) this.onLocusError('Выбери точку, ограниченную на отрезке или окружности (тип: on_segment / on_circle)'); + break; + } + this._pendingMover = hitPt; + if (this.onHintChange) this.onHintChange('locus', 2); + } else { + // Второй клик: выбрать целевую точку + const SNAP_PX = 12; + let hitPt = null; + for (const pt of this.eng.points()) { + const pp = this.vp.toCanvas(pt.x, pt.y); + if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; } + } + if (!hitPt || hitPt === this._pendingMover) break; + // Проверим, что целевая зависит от мовера + if (!this._isDownstreamOf(hitPt.id, this._pendingMover.id)) { + if (this.onLocusError) this.onLocusError('Целевая точка не зависит от выбранного мовера'); + this._pendingMover = null; + break; + } + this._pushUndo(); + const samples = this._sweepLocus(this._pendingMover, hitPt); + const cnt = this.eng.byType('locus').length; + this.eng.add({ + type: 'locus', + srcMover: this._pendingMover.id, + srcTarget: hitPt.id, + samples, + style: { color: '#F59E0B' }, + label: cnt ? 'L' + (cnt + 1) : 'L₁' + }); + this._pendingMover = null; this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } } this.render(); } + /* Проверяет, зависит ли targetId от moverId (BFS по графу зависимостей) */ + _isDownstreamOf(targetId, moverId) { + const visited = new Set(); + const queue = [moverId]; + while (queue.length) { + const curr = queue.shift(); + if (curr === targetId) return true; + if (visited.has(curr)) continue; + visited.add(curr); + for (const obj of this.eng.all()) { + if (!visited.has(obj.id) && this.eng._dependsOn(obj, curr)) { + queue.push(obj.id); + } + } + } + return false; + } + + /* Прогоняет мовер по его диапазону и записывает позиции цели */ + _sweepLocus(moverPt, targetPt) { + const N = 200; + const samples = []; + // Сохранить текущее состояние мовера + const savedX = moverPt.x, savedY = moverPt.y; + const savedT = moverPt._t; + + if (moverPt.constr === 'on_segment') { + const seg = this.eng.get(moverPt.srcSeg); + if (!seg) return samples; + for (let i = 0; i <= N; i++) { + const t = i / N; + const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id); + if (!p1 || !p2) continue; + moverPt.x = p1.x + t * (p2.x - p1.x); + moverPt.y = p1.y + t * (p2.y - p1.y); + moverPt._t = t; + this.eng.propagateDeps(moverPt.id); + samples.push({ x: targetPt.x, y: targetPt.y }); + } + } else if (moverPt.constr === 'on_circle') { + const circ = this.eng.get(moverPt.srcCircle); + if (!circ) return samples; + for (let i = 0; i <= N; i++) { + const theta = 2 * Math.PI * i / N; + let cx, cy, r; + if (circ.derived && circ.cx != null) { + cx = circ.cx; cy = circ.cy; r = circ.r; + } else { + const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId); + if (!mc || !me) continue; + cx = mc.x; cy = mc.y; r = gDist(mc, me); + } + moverPt.x = cx + r * Math.cos(theta); + moverPt.y = cy + r * Math.sin(theta); + this.eng.propagateDeps(moverPt.id); + samples.push({ x: targetPt.x, y: targetPt.y }); + } + } + + // Восстановить состояние мовера + moverPt.x = savedX; moverPt.y = savedY; + if (savedT !== undefined) moverPt._t = savedT; else delete moverPt._t; + this.eng.propagateDeps(moverPt.id); + return samples; + } + _finishPolygon() { if (this._pending.length < 3) { this._pending = []; this._preview = null; this.render(); return; } this._pushUndo(); @@ -2351,6 +2790,41 @@ class GeoSim { return this._addPoint(m); } + /** Переместить точку on_segment или on_circle — проецируем мышь на хост-геометрию */ + _moveConstrainedPoint(id, mx, my) { + const obj = this.eng.get(id); + if (!obj) return; + if (obj.constr === 'on_segment') { + const seg = this.eng.get(obj.srcSeg); + if (!seg) return; + const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id); + if (!p1 || !p2) return; + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const l2 = dx*dx + dy*dy; + if (l2 < 1e-12) return; + const t = Math.max(0, Math.min(1, ((mx-p1.x)*dx + (my-p1.y)*dy) / l2)); + obj._t = t; + obj.x = p1.x + t*dx; + obj.y = p1.y + t*dy; + } else if (obj.constr === 'on_circle') { + const circ = this.eng.get(obj.srcCircle); + if (!circ) return; + let cx, cy, r; + if (circ.derived && circ.cx != null) { + cx = circ.cx; cy = circ.cy; r = circ.r; + } else { + const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId); + if (!mc || !me) return; + cx = mc.x; cy = mc.y; r = gDist(mc, me); + } + const theta = Math.atan2(my - cy, mx - cx); + obj._theta = theta; + obj.x = cx + r * Math.cos(theta); + obj.y = cy + r * Math.sin(theta); + } + this.eng.propagateDeps(id); + } + _onMove(e) { const { px, py } = this._evPos(e); @@ -2363,6 +2837,22 @@ class GeoSim { const m = this.vp.toMath(px, py); if (this._drag) { + if (this._drag.chipDrag) { + // Перетаскивание чипа измерения + const obj = this.eng.get(this._drag.id); + if (obj) { + const basePos = this._measureLabelBasePos(obj); + if (basePos) { + obj.offX = (px - this._drag.offX) - basePos.x; + obj.offY = (py - this._drag.offY) - basePos.y; + } + } + this.render(); return; + } + if (this._drag.constrained) { + this._moveConstrainedPoint(this._drag.id, m.x, m.y); + this.render(); return; + } const snapped = this._computeSnap(m.x, m.y); this.eng.movePoint(this._drag.id, snapped.x, snapped.y); this.render(); return; @@ -2607,6 +3097,11 @@ class GeoSim { scale_2: 'Кликни точку P — построим P\' = O + k·(P − O)', thales_2: 'Кликни точку A (на первом луче)', thales_3: 'Кликни точку B (на втором луче) — построим A\'B\' ∥ AB', + measure_angle_2: 'Кликни вершину угла', + measure_angle_3: 'Кликни вторую точку на стороне угла — измерение готово', + locus_2: 'Кликни целевую точку, зависящую от мовера — построим ГМТ', + point_on_segment_1: 'Кликни на отрезок — точка прикрепится к нему и будет по нему скользить', + point_on_circle_1: 'Кликни на окружность — точка прикрепится к ней и будет по ней скользить', }; function _geoShowHint(name, phase) { @@ -2661,7 +3156,9 @@ class GeoSim { const msg = document.getElementById('geo-del-msg'); if (!panel || !msg) { hardFn(); return; } const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч', - circle:'окружность', polygon:'многоугольник', derived_line:'построение' }; + circle:'окружность', polygon:'многоугольник', derived_line:'построение', + measure_length:'измерение длины', measure_angle:'измерение угла', + measure_area:'измерение площади', locus:'ГМТ' }; const n = names[obj.type] || 'объект'; msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`; _geoDelSoftFn = softFn; @@ -2672,6 +3169,20 @@ class GeoSim { document.getElementById('geo-del-confirm')?.classList.remove('visible'); _geoDelSoftFn = _geoDelHardFn = null; } + + /* Показать inline-сообщение об ошибке ГМТ (временно заменяет hint-bar) */ + function _geoShowLocusError(msg) { + const hint = document.getElementById('geo-hint'); + if (!hint) return; + const prev = hint.textContent; + hint.textContent = msg; + hint.style.color = '#f87171'; + setTimeout(() => { + hint.textContent = prev; + hint.style.color = ''; + }, 2800); + } + // Кнопки диалога — подключаем после DOM ready document.addEventListener('DOMContentLoaded', () => { document.getElementById('geo-del-soft')?.addEventListener('click', () => { @@ -2702,6 +3213,7 @@ class GeoSim { geomSim.onUpdate = _geoUpdateStats; geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase); geomSim.onDeleteRequest = _geoShowDeleteConfirm; + geomSim.onLocusError = _geoShowLocusError; // keyboard shortcuts canvas.setAttribute('tabindex', '0'); diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js index 1f12cb9..39cdb3c 100644 --- a/frontend/js/labs/lab-glue.js +++ b/frontend/js/labs/lab-glue.js @@ -586,18 +586,14 @@ title: 'Столкновение шаров', desc: 'Упругий и неупругий удар двух тел: законы сохранения импульса и энергии.', preview: P_COLLISION }, - { id: 'magnetic', cat: 'phys', - title: 'Магнитное поле токов', - desc: 'Размести провода с током — наблюдай суперпозицию полей: карта, силовые линии, вектора. Заряженная частица в поле.', + { id: 'emfield', cat: 'phys', + title: 'Электромагнитные поля', + desc: 'Электрическое и магнитное поля в одной симуляции: заряды, токи, силовые линии, эквипотенциали, частица Лоренца.', preview: P_MAGNETIC }, { id: 'circuit', cat: 'phys', title: 'Электрические цепи', desc: 'Конструктор цепей из резисторов и конденсаторов. Законы Ома и Кирхгофа наглядно.', preview: P_CIRCUIT }, - { id: 'coulomb', cat: 'phys', - title: 'Закон Кулона', - desc: 'Силовые линии и эквипотенциальные поверхности для системы точечных зарядов.', - preview: P_FIELD }, { id: 'hydrostatics', cat: 'phys', title: 'Гидростатика', desc: 'Давление жидкости P=ρgh, закон Архимеда, сообщающиеся сосуды, поверхностное натяжение и капиллярность.', @@ -622,6 +618,10 @@ title: 'Изопроцессы', desc: 'PV-диаграмма для четырёх изопроцессов идеального газа. Расчёт работы, теплоты и внутренней энергии.', preview: P_ISOPROCESS }, + { id: 'waves', cat: 'phys', + title: 'Волны и звук', + desc: 'Поперечные и продольные волны, суперпозиция, стоячие волны. Частота, амплитуда, фаза, гармоники.', + preview: P_WAVES }, /* ── Химия / Молекулярная физика ── */ { id: 'molphys', cat: 'chem', title: 'Молекулярная физика', @@ -676,11 +676,6 @@ title: 'Angry Birds Physics', desc: 'Запускай птиц из рогатки, разрушай блоки, побеждай свиней. Реальная физика: гравитация, ветер, импульс. 6 уровней.', preview: P_ANGRYBIRDS }, - /* ── Физика: Волны ── */ - { id: 'waves', cat: 'phys', - title: 'Волны и звук', - desc: 'Поперечные и продольные волны, суперпозиция, стоячие волны. Частота, амплитуда, фаза, гармоники.', - preview: P_WAVES }, ]; var _theoryOpen = false; @@ -836,6 +831,9 @@ // Build valid-id set from SIMS catalogue (filters out "coming soon" entries) const _SIM_HASH_MAP = {}; SIMS.forEach(function(s) { if (s.id) { _SIM_HASH_MAP[s.id] = s.id; } }); + // backward-compat aliases: old URLs redirect to unified emfield sim + _SIM_HASH_MAP['magnetic'] = 'magnetic'; + _SIM_HASH_MAP['coulomb'] = 'coulomb'; var _routerNavigating = false; diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index 4a4987a..0584b57 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -31,18 +31,18 @@ var wavesSim = null; var geomSim = null; - var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-mag', + var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield', 'sim-molphys', - 'sim-coulomb','sim-circuit','sim-chemistry','sim-dynamics', + 'sim-circuit','sim-chemistry','sim-dynamics', 'sim-crystal','sim-orbitals','sim-stereo','sim-chemsandbox', 'sim-celldivision','sim-photosynthesis','sim-angrybirds', 'sim-quadratic','sim-normaldist','sim-graphtransform', 'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration', 'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis', 'sim-waves','sim-hydro','sim-geometry']; - var ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-mag', + var ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-emfield', 'ctrl-molphys', - 'ctrl-coulomb','ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox', + 'ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox', 'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro', 'ctrl-geometry']; @@ -65,10 +65,12 @@ if (id === 'collision') _openCollision(); if (id === 'triangle') _openTriangle(); if (id === 'trigcircle') _openTrigCircle(); - if (id === 'magnetic') _openMagnetic(); + if (id === 'magnetic') _openEMField('B'); // backward compat: #magnetic → emfield B-mode + if (id === 'coulomb') _openEMField('E'); // backward compat: #coulomb → emfield E-mode + if (id === 'emfield') _openEMField('E'); + if (id.startsWith('emfield:')) { _openEMField(id.split(':')[1]); } if (id === 'molphys') _openMolPhys(); if (id.startsWith('molphys:')) { _openMolPhys(id.split(':')[1]); } - if (id === 'coulomb') _openCoulomb(); if (id === 'circuit') _openCircuit(); if (id === 'chemistry') _openChemistry(); if (id.startsWith('chemistry:')) { _openChemistry(id.split(':')[1]); } @@ -222,6 +224,19 @@ { head: 'Коэффициент восстановления', formula: 'e = \\frac{v_2\' - v_1\'}{v_1 - v_2}', text: 'e=1 — упругий, e=0 — абсолютно неупругий удар.' }, ] }, + emfield: { + title: 'Электромагнитные поля', + sections: [ + { head: 'Закон Кулона', formula: 'F = k \\frac{|q_1 q_2|}{r^2}', vars: [['k','8.99·10⁹ Н·м²/Кл²'],['q','заряд, Кл'],['r','расстояние, м']] }, + { head: 'Напряжённость E', formula: '\\vec{E} = k \\frac{q}{r^2} \\hat{r}', text: 'Вектор направлен от «+» и к «−» заряду.' }, + { head: 'Потенциал', formula: '\\varphi = k \\frac{q}{r}', text: 'Эквипотенциальные линии — окружности вокруг заряда.' }, + { head: 'Поле прямого тока', formula: 'B = \\frac{\\mu_0 I}{2\\pi r}', vars: [['μ₀','4π·10⁻⁷ Тл·м/А'],['I','сила тока, А'],['r','расстояние от провода, м']] }, + { head: 'Суперпозиция B', formula: '\\vec{B} = \\sum_i \\vec{B}_i', text: 'Результирующее поле — векторная сумма полей всех проводов.' }, + { head: 'Сила Лоренца', formula: '\\vec{F} = q(\\vec{E} + \\vec{v} \\times \\vec{B})', text: 'Полная электромагнитная сила на движущийся заряд.' }, + { head: 'Сила Ампера', formula: 'F = I L B \\sin\\theta', text: 'Сила на проводник с током в магнитном поле.' }, + ] + }, + /* backward-compat aliases — loadTheory() maps these to emfield */ magnetic: { title: 'Магнитное поле', sections: [ @@ -275,6 +290,30 @@ { head: 'Теорема Пифагора', formula: 'a^2 + b^2 = c^2', text: 'В прямоугольном треугольнике квадрат гипотенузы равен сумме квадратов катетов.' }, ] }, + geometry: { + title: 'Планиметрия', + sections: [ + { head: 'Базовые объекты', text: 'Точка, прямая, луч, отрезок, окружность, многоугольник — основные фигуры планиметрии. Каждая прямая однозначно задаётся двумя точками.' }, + { head: 'Параллельность и перпендикулярность', text: 'Прямые параллельны, если не пересекаются. Перпендикулярны — если угол между ними 90°.' }, + { head: 'Теорема Фалеса', text: 'Если на одной из двух прямых отложить равные отрезки и провести через их концы параллельные прямые, они высекут равные отрезки и на второй прямой.' }, + { head: 'Признаки подобия треугольников', text: 'По двум углам, по двум пропорциональным сторонам и углу между ними, по трём пропорциональным сторонам.' }, + { head: 'Площадь треугольника', formula: 'S = \\frac{1}{2} a h_a', text: 'Также S = ½·a·b·sin C; формула Герона: S = √(p(p-a)(p-b)(p-c)).' }, + { head: 'Площадь параллелограмма', formula: 'S = a h_a = a b \\sin\\alpha' }, + { head: 'Длина окружности', formula: 'C = 2\\pi r', text: 'Площадь круга: S = π·r².' }, + { head: 'Геометрическое место точек (ГМТ)', text: 'Множество точек, удовлетворяющих заданному условию. Эллипс — ГМТ, сумма расстояний от которых до двух фокусов постоянна. Окружность — ГМТ, равноудалённых от центра.' }, + ] + }, + hydrostatics: { + title: 'Гидростатика', + sections: [ + { head: 'Гидростатическое давление', formula: 'P = \\rho g h', vars: [['ρ','плотность жидкости, кг/м³'],['g','ускорение свободного падения, 9.81 м/с²'],['h','глубина под поверхностью, м']] }, + { head: 'Закон Паскаля', text: 'Давление в покоящейся жидкости передаётся одинаково во все стороны. Основа гидравлического пресса: F₁/S₁ = F₂/S₂.' }, + { head: 'Закон Архимеда', formula: 'F_A = \\rho_{ж} g V_{погр}', text: 'Сила, выталкивающая тело из жидкости, равна весу вытесненной жидкости. Условие плавания: ρ_тела ≤ ρ_жидкости.' }, + { head: 'Сообщающиеся сосуды', text: 'Уровни однородной жидкости в сообщающихся сосудах одинаковы. Для двух разных жидкостей: ρ₁·h₁ = ρ₂·h₂.' }, + { head: 'Поверхностное натяжение', formula: '\\sigma = \\frac{F}{l}', text: 'Сила, действующая по касательной к поверхности жидкости на единицу длины. Капиллярная высота: h = 2σ·cos θ / (ρ g r).' }, + { head: 'Капиллярность', text: 'В тонких трубках жидкость поднимается (смачивает) или опускается (не смачивает) относительно общего уровня. Зависит от угла смачивания θ.' }, + ] + }, molphys: { title: 'Молекулярная физика', sections: [ diff --git a/frontend/js/labs/magnetic.js b/frontend/js/labs/magnetic.js deleted file mode 100644 index ced9fda..0000000 --- a/frontend/js/labs/magnetic.js +++ /dev/null @@ -1,1159 +0,0 @@ -'use strict'; -/* ══════════════════════════════════════════════════════════ - MagneticSim — magnetic field of current-carrying wires - • Click canvas to place wire (• out / × in) - • Drag to reposition, double-click / right-click to remove - • Layers: colour map, field lines, vector arrows - • Particle: charged particle with Lorentz force (circular) - B field of wire at (x0,y0) with current I: - Bx = -k·I·(y-y0)/r², By = k·I·(x-x0)/r² - Colour map maps angle of B hue, magnitude brightness -══════════════════════════════════════════════════════════ */ - -class MagneticSim { - constructor(canvas) { - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - - this.sources = []; // {id, x, y, I} - this._nextId = 1; - this.curI = 6; // current magnitude (user-adjustable) - this.addMode = 'out'; // 'out' | 'in' - - /* particle */ - this._particle = null; - this.particleOn = false; - this._pRaf = null; - this._pLast = 0; - - /* layers */ - this.layers = { colormap: true, fieldlines: true, vectors: false }; - - /* conductor (проводник в поле) */ - this._cond = { - on: false, - x1: 0, y1: 0, x2: 0, y2: 0, // set in fit() - I: 8, // conductor current - _dragEndpoint: null, // 0 | 1 | 'body' | null - }; - - /* magnetic flux indicator (круг потока) */ - this._flux = { - on: false, - x: 0, y: 0, // set in fit() - r: 55, - _dragging: false, - }; - - /* cursor B reading */ - this._cursorB = null; // {x, y, bx, by, mag} - this._mousePos = null; - - /* interaction */ - this._drag = null; // index into sources[] - this._hovered = null; - - /* offscreen canvas for pixel-level colour map */ - this._oc = null; // created in fit() - this._ocW = 0; - this._ocH = 0; - - this.W = 0; this.H = 0; - this.onUpdate = null; - - this._bindEvents(); - } - - /* ──────────────────────────────── - Sizing - ──────────────────────────────── */ - - fit() { - const rect = this.canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - this.canvas.width = rect.width * dpr; - this.canvas.height = rect.height * dpr; - this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - this.W = rect.width; - this.H = rect.height; - - const DS = 4; // downsample factor for colour map - this._ocW = Math.ceil(this.W / DS); - this._ocH = Math.ceil(this.H / DS); - this._oc = document.createElement('canvas'); - this._oc.width = this._ocW; - this._oc.height = this._ocH; - - this._initOverlays(); - - /* position conductor + flux relative to canvas */ - if (this.W) { - this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5; - this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5; - this._flux.x = this.W * 0.5; this._flux.y = this.H * 0.35; - } - - this.draw(); - } - - /* init conductor / flux positions on first fit */ - _initOverlays() { - this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5; - this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5; - this._flux.x = this.W * 0.5; this._flux.y = this.H * 0.35; - } - - /* ──────────────────────────────── - Field calculation - ──────────────────────────────── */ - - _K = 8000; // visual scaling constant - - _field(px, py) { - let bx = 0, by = 0; - for (const s of this.sources) { - const dx = px - s.x, dy = py - s.y; - const r2 = dx * dx + dy * dy; - if (r2 < 4) continue; - const k = this._K * s.I / r2; - bx -= k * dy; - by += k * dx; - } - const mag = Math.hypot(bx, by); - return { bx, by, mag }; - } - - _fieldNorm(px, py) { - const { bx, by, mag } = this._field(px, py); - if (mag < 1e-12) return { nx: 0, ny: 0, mag: 0 }; - return { nx: bx / mag, ny: by / mag, mag }; - } - - /* RK4 step for field-line tracing */ - _rk4(x, y, step) { - const f = (xx, yy) => this._fieldNorm(xx, yy); - const k1 = f(x, y); - const k2 = f(x + step * k1.nx * 0.5, y + step * k1.ny * 0.5); - const k3 = f(x + step * k2.nx * 0.5, y + step * k2.ny * 0.5); - const k4 = f(x + step * k3.nx, y + step * k3.ny); - return { - nx: (k1.nx + 2*k2.nx + 2*k3.nx + k4.nx) / 6, - ny: (k1.ny + 2*k2.ny + 2*k3.ny + k4.ny) / 6, - }; - } - - /* ──────────────────────────────── - Source management - ──────────────────────────────── */ - - addSource(x, y, dir) { - this.sources.push({ - id: this._nextId++, - x, y, - I: dir === 'out' ? +this.curI : -this.curI, - }); - this._invalidateCache(); - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - } - - removeSource(id) { - this.sources = this.sources.filter(s => s.id !== id); - this._invalidateCache(); - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - } - - clearAll() { - this.sources = []; - this._particle = null; - this._invalidateCache(); - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - } - - setCurrentAll(I) { - this.curI = I; - this.sources.forEach(s => { s.I = s.I > 0 ? I : -I; }); - this._invalidateCache(); - this.draw(); - } - - /* Invalidate precomputed colour map cache */ - _invalidateCache() { this._cmapDirty = true; } - - /* ── conductor & flux toggles ── */ - - toggleConductor() { - this._cond.on = !this._cond.on; - this.draw(); - } - - setConductorI(I) { - this._cond.I = I; - this.draw(); - } - - toggleFlux() { - this._flux.on = !this._flux.on; - this.draw(); - } - - /* Ampere force on conductor: F = I·(L×B) - L = conductor vector, B from wire sources at midpoint - In 3D with B in xy-plane: F = (0, 0, I*(Lx*By - Ly*Bx)) [force in z] - We display Fz magnitude + direction (⊙ out / ⊗ in) */ - _ampereForce() { - const c = this._cond; - const Lx = c.x2 - c.x1, Ly = c.y2 - c.y1; - const L = Math.hypot(Lx, Ly); - if (L < 1) return { Fz: 0, L, B: 0 }; - const mx = (c.x1 + c.x2) / 2, my = (c.y1 + c.y2) / 2; - const { bx, by, mag } = this._field(mx, my); - // F_z = I*(Lx*By - Ly*Bx) — in "visual units" - const Fz = c.I * (Lx * by - Ly * bx) * 0.0001; - return { Fz, L: L / 100, B: mag, bx, by, mx, my }; - } - - /* Magnetic flux through indicator circle: Φ ≈ |B_avg|·πr² */ - _fluxValue() { - const f = this._flux; - const { mag } = this._field(f.x, f.y); - return mag * Math.PI * f.r * f.r * 0.000001; // visual units - } - - /* Preset arrangements */ - preset(name) { - this.sources = []; - const cx = this.W / 2, cy = this.H / 2, d = 90; - switch (name) { - case 'single': - this.sources.push({ id: this._nextId++, x: cx, y: cy, I: +this.curI }); - break; - case 'parallel': - this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI }); - this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: +this.curI }); - break; - case 'anti': - this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI }); - this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: -this.curI }); - break; - case 'solenoid': { - const cols = 5, rows = 2, gx = 60, gy = 70; - for (let c = 0; c < cols; c++) { - const x = cx + (c - (cols-1)/2) * gx; - this.sources.push({ id: this._nextId++, x, y: cy - gy/2, I: +this.curI }); - this.sources.push({ id: this._nextId++, x, y: cy + gy/2, I: -this.curI }); - } - break; - } - case 'quadrupole': - this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI }); - this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: +this.curI }); - this.sources.push({ id: this._nextId++, x: cx, y: cy - d, I: -this.curI }); - this.sources.push({ id: this._nextId++, x: cx, y: cy + d, I: -this.curI }); - break; - case 'ring': { - const n = 8, r = 110; - for (let i = 0; i < n; i++) { - const a = (i / n) * Math.PI * 2; - const dir = i % 2 === 0 ? +this.curI : -this.curI; - this.sources.push({ id: this._nextId++, - x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, I: dir }); - } - break; - } - case 'dipole': - this.sources.push({ id: this._nextId++, x: cx - 60, y: cy, I: +this.curI * 1.5 }); - this.sources.push({ id: this._nextId++, x: cx + 60, y: cy, I: -this.curI * 1.5 }); - this.sources.push({ id: this._nextId++, x: cx - 60, y: cy - 50, I: +this.curI * 0.5 }); - this.sources.push({ id: this._nextId++, x: cx + 60, y: cy - 50, I: -this.curI * 0.5 }); - this.sources.push({ id: this._nextId++, x: cx - 60, y: cy + 50, I: +this.curI * 0.5 }); - this.sources.push({ id: this._nextId++, x: cx + 60, y: cy + 50, I: -this.curI * 0.5 }); - break; - } - this._invalidateCache(); - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - } - - /* ──────────────────────────────── - Particle (Lorentz force) - F = q(v × B) - Treat |B_xy| as Bz (educational approximation for 2D): - Fx = q·vy·Bz, Fy = -q·vx·Bz - ──────────────────────────────── */ - - toggleParticle() { - this.particleOn = !this.particleOn; - if (this.particleOn) { - this._initParticle(); - this._pLast = performance.now(); - this._tickParticle(); - } else { - if (this._pRaf) cancelAnimationFrame(this._pRaf); - this._pRaf = null; - this._particle = null; - this.draw(); - } - if (this.onUpdate) this.onUpdate(this.info()); - } - - _initParticle() { - this._particle = { - x: this.W * 0.18, y: this.H * 0.5, - vx: 2.2, vy: 0, - q: 1, - trail: [], - }; - } - - _tickParticle() { - if (!this.particleOn || !this._particle) return; - const now = performance.now(); - const dt = Math.min((now - this._pLast) * 0.06, 2.5); - this._pLast = now; - - const p = this._particle; - const { mag } = this._field(p.x, p.y); - const Bz = mag * 0.00012 * p.q; - - const spd = Math.hypot(p.vx, p.vy); - - // Lorentz (2D): Fx = q·vy·Bz, Fy = -q·vx·Bz - p.vx += p.q * p.vy * Bz * dt; - p.vy -= p.q * p.vx * Bz * dt; - - // Conserve speed (magnetic force does no work) - const newSpd = Math.hypot(p.vx, p.vy); - if (newSpd > 1e-6) { p.vx = p.vx / newSpd * spd; p.vy = p.vy / newSpd * spd; } - - p.x += p.vx * dt; - p.y += p.vy * dt; - - // Bounce walls - if (p.x < 4) { p.vx = Math.abs(p.vx); p.x = 4; } - if (p.x > this.W - 4) { p.vx = -Math.abs(p.vx); p.x = this.W - 4; } - if (p.y < 4) { p.vy = Math.abs(p.vy); p.y = 4; } - if (p.y > this.H - 4) { p.vy = -Math.abs(p.vy); p.y = this.H - 4; } - - p.trail.push({ x: p.x, y: p.y }); - if (p.trail.length > 350) p.trail.shift(); - - this.draw(); - this._pRaf = requestAnimationFrame(() => this._tickParticle()); - } - - /* ──────────────────────────────── - Info - ──────────────────────────────── */ - - info() { - const out = this.sources.filter(s => s.I > 0).length; - const inn = this.sources.filter(s => s.I < 0).length; - const condOn = this._cond.on; - const fluxOn = this._flux.on; - const ampere = condOn ? this._ampereForce() : null; - const Fz = ampere ? ampere.Fz : 0; - const flux = fluxOn ? this._fluxValue() : 0; - const cursorB = this._cursorB ? this._cursorB.mag : null; - return { total: this.sources.length, out, inn, particleOn: this.particleOn, - condOn, fluxOn, Fz, flux, cursorB }; - } - - /* ──────────────────────────────── - Events - ──────────────────────────────── */ - - _bindEvents() { - const c = this.canvas; - - const pos = e => { - const r = c.getBoundingClientRect(); - const s = e.touches ? e.touches[0] : e; - return { x: s.clientX - r.left, y: s.clientY - r.top }; - }; - - const hitIdx = p => { - for (let i = this.sources.length - 1; i >= 0; i--) { - if (Math.hypot(p.x - this.sources[i].x, p.y - this.sources[i].y) < 22) return i; - } - return -1; - }; - - /* hit test conductor endpoints / body */ - const hitCond = p => { - if (!this._cond.on) return null; - const { x1, y1, x2, y2 } = this._cond; - if (Math.hypot(p.x - x1, p.y - y1) < 16) return 0; - if (Math.hypot(p.x - x2, p.y - y2) < 16) return 1; - // check midpoint drag - const mx = (x1+x2)/2, my = (y1+y2)/2; - if (Math.hypot(p.x - mx, p.y - my) < 14) return 'body'; - return null; - }; - - /* hit test flux circle */ - const hitFlux = p => { - if (!this._flux.on) return false; - return Math.hypot(p.x - this._flux.x, p.y - this._flux.y) < this._flux.r + 12; - }; - - let _mousedownPos = null; - let _condDragOffset = null; - - c.addEventListener('mousedown', e => { - if (e.button !== 0) return; - const p = pos(e); - _mousedownPos = p; - - /* conductor endpoint drag */ - const ch = hitCond(p); - if (ch !== null) { - this._cond._dragEndpoint = ch; - if (ch === 'body') { - _condDragOffset = { dx: p.x - this._cond.x1, dy: p.y - this._cond.y1, - len: Math.hypot(this._cond.x2-this._cond.x1, this._cond.y2-this._cond.y1) }; - } - c.style.cursor = 'grabbing'; - return; - } - - /* flux drag */ - if (hitFlux(p)) { - this._flux._dragging = true; - c.style.cursor = 'grabbing'; - return; - } - - const i = hitIdx(p); - if (i >= 0) { - this._drag = i; - c.style.cursor = 'grabbing'; - } - }); - - c.addEventListener('mousemove', e => { - const p = pos(e); - - /* update cursor B reading */ - if (!e.buttons) { - if (this.sources.length > 0) { - const f = this._field(p.x, p.y); - this._cursorB = { x: p.x, y: p.y, ...f }; - if (this.onUpdate) this.onUpdate(this.info()); - } - this._mousePos = p; - } - - /* conductor drag */ - if (this._cond._dragEndpoint !== null) { - const ep = this._cond._dragEndpoint; - if (ep === 0) { this._cond.x1 = p.x; this._cond.y1 = p.y; } - else if (ep === 1) { this._cond.x2 = p.x; this._cond.y2 = p.y; } - else if (ep === 'body') { - const L = _condDragOffset.len; - const dx = this._cond.x2 - this._cond.x1, dy = this._cond.y2 - this._cond.y1; - const nx = dx / Math.hypot(dx, dy), ny = dy / Math.hypot(dx, dy); - this._cond.x1 = p.x - _condDragOffset.dx; - this._cond.y1 = p.y - _condDragOffset.dy; - this._cond.x2 = this._cond.x1 + nx * L; - this._cond.y2 = this._cond.y1 + ny * L; - } - this.draw(); - return; - } - - /* flux drag */ - if (this._flux._dragging) { - this._flux.x = p.x; this._flux.y = p.y; - this.draw(); return; - } - - if (this._drag !== null) { - this.sources[this._drag].x = p.x; - this.sources[this._drag].y = p.y; - this._invalidateCache(); - this.draw(); - return; - } - const i = hitIdx(p); - const ch = hitCond(p); - const fh = hitFlux(p); - this._hovered = i >= 0 ? i : null; - c.style.cursor = (i >= 0 || ch !== null || fh) ? 'grab' : 'crosshair'; - }); - - c.addEventListener('mouseup', e => { - const p = pos(e); - const moved = _mousedownPos && - Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.y) > 5; - - if (this._cond._dragEndpoint !== null) { - this._cond._dragEndpoint = null; c.style.cursor = 'crosshair'; this.draw(); return; - } - if (this._flux._dragging) { - this._flux._dragging = false; c.style.cursor = 'crosshair'; return; - } - - if (this._drag !== null) { - this._invalidateCache(); - this._drag = null; - c.style.cursor = 'crosshair'; - this.draw(); - if (this.onUpdate) this.onUpdate(this.info()); - return; - } - - // Click (not drag) on empty space add source - if (!moved && e.button === 0 && hitIdx(p) < 0 && - hitCond(p) === null && !hitFlux(p)) { - this.addSource(p.x, p.y, this.addMode); - } - }); - - c.addEventListener('dblclick', e => { - const p = pos(e); - const i = hitIdx(p); - if (i >= 0) this.removeSource(this.sources[i].id); - }); - - c.addEventListener('contextmenu', e => { - e.preventDefault(); - const p = pos(e); - const i = hitIdx(p); - if (i >= 0) this.removeSource(this.sources[i].id); - }); - - c.addEventListener('touchstart', e => { - e.preventDefault(); - _mousedownPos = pos(e); - const i = hitIdx(_mousedownPos); - if (i >= 0) this._drag = i; - }, { passive: false }); - - c.addEventListener('touchmove', e => { - e.preventDefault(); - if (this._drag === null) return; - const p = pos(e); - this.sources[this._drag].x = p.x; - this.sources[this._drag].y = p.y; - this._invalidateCache(); - this.draw(); - }, { passive: false }); - - c.addEventListener('touchend', e => { - const p = e.changedTouches ? pos({ ...e, touches: e.changedTouches }) : null; - const moved = _mousedownPos && p && - Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.y) > 8; - if (this._drag === null && !moved && p) { - this.addSource(p.x, p.y, this.addMode); - } - this._drag = null; - if (this.onUpdate) this.onUpdate(this.info()); - }); - } - - /* ──────────────────────────────── - Drawing - ──────────────────────────────── */ - - draw() { - const ctx = this.ctx; - const W = this.W, H = this.H; - if (!W || !H) return; - - ctx.clearRect(0, 0, W, H); - - // Background - const bg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.7); - bg.addColorStop(0, '#080818'); - bg.addColorStop(1, '#030308'); - ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); - - this._drawGrid(ctx, W, H); - - if (this.sources.length > 0) { - if (this.layers.colormap) this._drawColormap(ctx); - if (this.layers.fieldlines) this._drawFieldLines(ctx); - if (this.layers.vectors) this._drawVectors(ctx); - } - - if (this._flux.on) this._drawFlux(ctx); - if (this._cond.on) this._drawConductor(ctx); - if (this._particle) this._drawParticle(ctx); - this._drawSources(ctx); - if (this._cursorB && this.sources.length > 0) this._drawCursorB(ctx); - - if (this.sources.length === 0) this._drawHint(ctx, W, H); - } - - /* ── grid ── */ - _drawGrid(ctx, W, H) { - ctx.save(); - ctx.strokeStyle = 'rgba(155,93,229,0.055)'; ctx.lineWidth = 1; - for (let x = 0; x <= W; x += 50) { - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); - } - for (let y = 0; y <= H; y += 50) { - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); - } - ctx.restore(); - } - - /* ── colour map (hue = angle of B, brightness = log|B|) ── */ - _cmapDirty = true; - - _drawColormap(ctx) { - if (!this._oc) return; - const DS = 4; - const oc = this._oc; - const oct = oc.getContext('2d'); - const w = this._ocW, h = this._ocH; - - if (this._cmapDirty) { - const imgData = oct.createImageData(w, h); - const data = imgData.data; - - for (let py = 0; py < h; py++) { - for (let px = 0; px < w; px++) { - const wx = px * DS, wy = py * DS; - const { bx, by, mag } = this._field(wx, wy); - if (mag < 0.5) continue; - - const angle = Math.atan2(by, bx); // -π…π - const hue = ((angle / (2 * Math.PI) + 1) % 1) * 360; - const bright = Math.min(1, Math.log10(1 + mag * 0.005) * 0.55); - const alpha = Math.round(bright * 210); - - const [r, g, b] = this._hsl(hue / 360, 0.90, 0.38 + bright * 0.28); - const idx = (py * w + px) * 4; - data[idx] = r; - data[idx+1] = g; - data[idx+2] = b; - data[idx+3] = alpha; - } - } - oct.putImageData(imgData, 0, 0); - this._cmapDirty = false; - } - - ctx.save(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - ctx.drawImage(oc, 0, 0, w * DS, h * DS); - ctx.restore(); - } - - _hsl(h, s, l) { - let r, g, b; - if (s === 0) { r = g = b = l; } - else { - const q = l < 0.5 ? l*(1+s) : l+s-l*s, p = 2*l - q; - const hue2 = (p, q, t) => { - t = ((t % 1) + 1) % 1; - if (t < 1/6) return p + (q-p)*6*t; - if (t < 1/2) return q; - if (t < 2/3) return p + (q-p)*(2/3-t)*6; - return p; - }; - r = hue2(p, q, h + 1/3); g = hue2(p, q, h); b = hue2(p, q, h - 1/3); - } - return [Math.round(r*255), Math.round(g*255), Math.round(b*255)]; - } - - /* ── field lines ── */ - _drawFieldLines(ctx) { - if (!this.sources.length) return; - const maxSteps = 700; - const step = 5; - const killR = 24; - - ctx.save(); - - for (const src of this.sources) { - const isOut = src.I > 0; - const col = isOut ? '6,214,224' : '241,91,181'; - const nLines = 14; - const seedR = 26; - - for (let li = 0; li < nLines; li++) { - const ang = (li / nLines) * Math.PI * 2; - let x = src.x + Math.cos(ang) * seedR; - let y = src.y + Math.sin(ang) * seedR; - - const pts = [{ x, y }]; - let travelledSq = 0; - - for (let st = 0; st < maxSteps; st++) { - const { nx, ny } = this._rk4(x, y, step); - x += step * nx; - y += step * ny; - travelledSq += step * step; - - if (x < -60 || x > this.W + 60 || y < -60 || y > this.H + 60) break; - - /* stop near another source */ - let nearOther = false; - for (const s2 of this.sources) { - if (s2 === src) continue; - if (Math.hypot(x - s2.x, y - s2.y) < killR) { nearOther = true; break; } - } - if (nearOther) { pts.push({ x, y }); break; } - - /* stop looping back to origin */ - if (st > 20 && Math.hypot(x - src.x, y - src.y) < killR) break; - - pts.push({ x, y }); - } - - if (pts.length < 3) continue; - - /* draw with glow */ - ctx.shadowColor = `rgba(${col},0.5)`; - ctx.shadowBlur = 7; - ctx.strokeStyle = `rgba(${col},0.65)`; - ctx.lineWidth = 1.6; - - ctx.beginPath(); - ctx.moveTo(pts[0].x, pts[0].y); - for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y); - ctx.stroke(); - - /* arrowheads every ~85 px */ - this._drawArrows(ctx, pts, col); - } - } - ctx.restore(); - } - - _drawArrows(ctx, pts, col) { - let acc = 0, next = 80; - for (let i = 1; i < pts.length; i++) { - const dx = pts[i].x - pts[i-1].x, dy = pts[i].y - pts[i-1].y; - acc += Math.hypot(dx, dy); - if (acc < next) continue; - next += 85; - const ang = Math.atan2(dy, dx); - ctx.save(); - ctx.translate(pts[i].x, pts[i].y); - ctx.rotate(ang); - ctx.shadowColor = `rgba(${col},0.9)`; ctx.shadowBlur = 6; - ctx.fillStyle = `rgba(${col},0.90)`; - ctx.beginPath(); - ctx.moveTo(7, 0); ctx.lineTo(-5, -4); ctx.lineTo(-3, 0); ctx.lineTo(-5, 4); - ctx.closePath(); ctx.fill(); - ctx.restore(); - } - } - - /* ── vector field ── */ - _drawVectors(ctx) { - if (!this.sources.length) return; - const step = 42; - ctx.save(); - for (let px = step * 0.5; px < this.W; px += step) { - for (let py = step * 0.5; py < this.H; py += step) { - const { bx, by, mag } = this._field(px, py); - if (mag < 1) continue; - const t = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4); - const len = 8 + t * 14; - const nx = bx / mag, ny = by / mag; - const alp = 0.28 + t * 0.6; - - ctx.save(); - ctx.translate(px, py); - ctx.rotate(Math.atan2(ny, nx)); - ctx.globalAlpha = alp; - ctx.strokeStyle = `rgba(${Math.round(155+t*100)},${Math.round(93+t*121)},229,1)`; - ctx.lineWidth = 1.1 + t * 0.6; - ctx.beginPath(); ctx.moveTo(-len/2, 0); ctx.lineTo(len/2, 0); ctx.stroke(); - ctx.fillStyle = ctx.strokeStyle; - ctx.beginPath(); - ctx.moveTo(len/2, 0); ctx.lineTo(len/2-5, -2.5); ctx.lineTo(len/2-5, 2.5); - ctx.closePath(); ctx.fill(); - ctx.restore(); - } - } - ctx.restore(); - } - - /* ── sources ── */ - _drawSources(ctx) { - this.sources.forEach((s, i) => { - const isOut = s.I > 0; - const col = isOut ? '#06D6E0' : '#F15BB5'; - const rgb = isOut ? '6,214,224' : '241,91,181'; - const isHov = this._hovered === i || this._drag === i; - const R = isHov ? 19 : 16; - - ctx.save(); - ctx.shadowColor = col; ctx.shadowBlur = isHov ? 32 : 18; - - /* halo ring */ - ctx.beginPath(); ctx.arc(s.x, s.y, R + 6, 0, Math.PI * 2); - ctx.fillStyle = `rgba(${rgb},0.08)`; ctx.fill(); - - /* body disc */ - ctx.beginPath(); ctx.arc(s.x, s.y, R, 0, Math.PI * 2); - ctx.fillStyle = isHov ? `rgba(${rgb},0.25)` : 'rgba(5,5,20,0.9)'; - ctx.fill(); - ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.stroke(); - - /* symbol */ - if (isOut) { - /* dot = current toward viewer */ - ctx.beginPath(); ctx.arc(s.x, s.y, 5, 0, Math.PI * 2); - ctx.fillStyle = col; ctx.shadowBlur = 8; ctx.fill(); - } else { - /* × = current away from viewer */ - const d = 5.5; - ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.shadowBlur = 6; - ctx.beginPath(); - ctx.moveTo(s.x - d, s.y - d); ctx.lineTo(s.x + d, s.y + d); - ctx.moveTo(s.x + d, s.y - d); ctx.lineTo(s.x - d, s.y + d); - ctx.stroke(); - } - - /* current label below */ - ctx.shadowBlur = 0; - ctx.font = '10px Manrope, sans-serif'; - ctx.fillStyle = `rgba(${rgb},0.75)`; - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText((isOut ? '↑' : '↓') + ' ' + Math.abs(s.I).toFixed(0) + ' А', s.x, s.y + R + 5); - - ctx.restore(); - }); - } - - /* ── particle ── */ - _drawParticle(ctx) { - const p = this._particle; - if (!p) return; - - /* trail */ - if (p.trail.length > 1) { - ctx.save(); - for (let i = 1; i < p.trail.length; i++) { - const t = i / p.trail.length; - ctx.beginPath(); - ctx.moveTo(p.trail[i-1].x, p.trail[i-1].y); - ctx.lineTo(p.trail[i].x, p.trail[i].y); - ctx.strokeStyle = `rgba(255,255,80,${t * 0.55})`; - ctx.lineWidth = t * 2.5; - ctx.stroke(); - } - ctx.restore(); - } - - /* glow aura */ - const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 16); - grad.addColorStop(0, 'rgba(255,255,80,0.35)'); - grad.addColorStop(1, 'rgba(255,255,80,0)'); - ctx.save(); ctx.fillStyle = grad; - ctx.beginPath(); ctx.arc(p.x, p.y, 16, 0, Math.PI*2); ctx.fill(); ctx.restore(); - - /* body */ - ctx.save(); - ctx.shadowColor = '#ffff50'; ctx.shadowBlur = 18; - ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI*2); - ctx.fillStyle = '#ffff50'; ctx.fill(); - ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.8; ctx.stroke(); - ctx.restore(); - - /* velocity arrow */ - const spd = Math.hypot(p.vx, p.vy); - if (spd > 0.01) { - const s = 22; - ctx.save(); - ctx.strokeStyle = 'rgba(255,255,80,0.7)'; ctx.lineWidth = 1.8; - ctx.shadowColor = '#ffff50'; ctx.shadowBlur = 8; - ctx.beginPath(); - ctx.moveTo(p.x, p.y); - ctx.lineTo(p.x + p.vx / spd * s, p.y + p.vy / spd * s); - ctx.stroke(); - ctx.restore(); - } - } - - /* ── empty hint ── */ - /* ── conductor (проводник в поле — Сила Ампера) ── */ - _drawConductor(ctx) { - const c = this._cond; - const Lx = c.x2 - c.x1, Ly = c.y2 - c.y1; - const L = Math.hypot(Lx, Ly); - if (L < 2) return; - - const { Fz, B, bx, by, mx, my } = this._ampereForce(); - const Fabs = Math.abs(Fz); - const fOut = Fz > 0; // force out of screen (⊙) vs into screen (⊗) - - ctx.save(); - - /* glow under conductor */ - ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 14; - ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 5; - ctx.globalAlpha = 0.35; - ctx.beginPath(); ctx.moveTo(c.x1, c.y1); ctx.lineTo(c.x2, c.y2); ctx.stroke(); - - /* main conductor line */ - ctx.globalAlpha = 1; ctx.shadowBlur = 6; - ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; - ctx.beginPath(); ctx.moveTo(c.x1, c.y1); ctx.lineTo(c.x2, c.y2); ctx.stroke(); - - /* current direction arrows along conductor */ - const steps = Math.floor(L / 55); - ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 5; - for (let s = 0; s <= steps; s++) { - const t = (s + 0.5) / (steps + 1); - const ax = c.x1 + Lx * t, ay = c.y1 + Ly * t; - const ang = c.I > 0 ? Math.atan2(Ly, Lx) : Math.atan2(-Ly, -Lx); - ctx.save(); ctx.translate(ax, ay); ctx.rotate(ang); - ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-5,-4); ctx.lineTo(-5,4); ctx.closePath(); - ctx.fill(); ctx.restore(); - } - - /* endpoints handle dots */ - [[c.x1, c.y1], [c.x2, c.y2]].forEach(([ex, ey]) => { - ctx.beginPath(); ctx.arc(ex, ey, 8, 0, Math.PI*2); - ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 10; ctx.fill(); - ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); - }); - - /* B vector at midpoint */ - if (B > 0.5 && this.sources.length) { - const bScale = Math.min(40, Math.log10(1 + B * 0.02) * 50); - const bNorm = Math.hypot(bx, by); - const bnx = bx/bNorm, bny = by/bNorm; - ctx.strokeStyle = '#22d55e'; ctx.lineWidth = 1.5; ctx.shadowColor = '#22d55e'; - ctx.beginPath(); ctx.moveTo(mx, my); - ctx.lineTo(mx + bnx*bScale, my + bny*bScale); ctx.stroke(); - ctx.fillStyle = '#22d55e'; - ctx.font = '10px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - ctx.fillText('B⃗', mx + bnx*(bScale+10), my + bny*(bScale+10)); - } - - /* Ampere force symbols along conductor */ - if (Fabs > 1e-6) { - const sym = fOut ? '⊙' : '⊗'; - const symCol = fOut ? '#06D6E0' : '#ff6060'; - const symSize = Math.min(22, 8 + Fabs * 200); - const perpX = -Ly / L, perpY = Lx / L; // perpendicular to conductor - const offset = fOut ? -35 : 35; // visual direction hint - - ctx.font = `${symSize}px Manrope`; - ctx.fillStyle = symCol; ctx.shadowColor = symCol; ctx.shadowBlur = 10; - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const symCount = Math.max(1, Math.min(5, Math.floor(L / 80))); - for (let s = 0; s < symCount; s++) { - const t = (s + 0.5) / symCount; - ctx.fillText(sym, - c.x1 + Lx*t + perpX * 22, - c.y1 + Ly*t + perpY * 22); - } - - /* force magnitude label */ - ctx.font = 'bold 11px Manrope'; ctx.shadowBlur = 5; - ctx.fillStyle = symCol; - ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - ctx.fillText('F = ' + Fabs.toFixed(3) + ' (ед)', c.x2 + 12, c.y2); - } - - /* current label */ - ctx.shadowBlur = 0; - ctx.font = '10px Manrope'; ctx.fillStyle = 'rgba(241,91,181,0.8)'; - ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - const ang2 = Math.atan2(Ly, Lx); - ctx.fillText('I = ' + c.I + ' А', mx - Math.sin(ang2)*20, my + Math.cos(ang2)*(-20)); - - ctx.restore(); - } - - /* ── flux circle (магнитный поток) ── */ - _drawFlux(ctx) { - const f = this._flux; - const Phi = this._fluxValue(); - const { mag } = this._field(f.x, f.y); - const brightness = Math.min(1, Math.log10(1 + mag * 0.003) * 0.7); - - ctx.save(); - - /* filled circle — colour by field strength */ - const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r); - grad.addColorStop(0, `rgba(255,220,50,${brightness * 0.4})`); - grad.addColorStop(0.6, `rgba(155,93,229,${brightness * 0.15})`); - grad.addColorStop(1, 'transparent'); - ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI*2); ctx.fill(); - - /* dashed border */ - ctx.strokeStyle = 'rgba(255,220,50,0.7)'; ctx.lineWidth = 1.8; - ctx.setLineDash([6, 4]); ctx.shadowColor = '#ffdc32'; ctx.shadowBlur = 8; - ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI*2); ctx.stroke(); - ctx.setLineDash([]); - - /* centre dot */ - ctx.beginPath(); ctx.arc(f.x, f.y, 4, 0, Math.PI*2); - ctx.fillStyle = '#ffdc32'; ctx.fill(); - - /* flux label */ - ctx.font = 'bold 11px Manrope'; ctx.fillStyle = '#ffdc32'; - ctx.shadowColor = '#ffdc32'; ctx.shadowBlur = 6; - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText('Φ = ' + Phi.toFixed(4) + ' Вб', f.x, f.y + f.r + 6); - ctx.fillText('|B| = ' + mag.toFixed(1) + ' (ед)', f.x, f.y + f.r + 20); - - ctx.restore(); - } - - /* ── B value at cursor ── */ - _drawCursorB(ctx) { - const b = this._cursorB; - if (!b || !this._mousePos) return; - const { x, y, mag, bx, by } = b; - if (mag < 0.5) return; - - ctx.save(); - /* small circle */ - ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; - ctx.setLineDash([3,3]); ctx.beginPath(); ctx.arc(x, y, 14, 0, Math.PI*2); ctx.stroke(); - ctx.setLineDash([]); - - /* B direction arrow */ - const bNorm = Math.hypot(bx, by); - const len = Math.min(28, Math.log10(1 + mag * 0.01) * 35); - const bnx = bx/bNorm, bny = by/bNorm; - const col = 'rgba(255,255,255,0.6)'; - ctx.strokeStyle = col; ctx.lineWidth = 1.2; - ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + bnx*len, y + bny*len); ctx.stroke(); - ctx.fillStyle = col; - const a = Math.atan2(bny, bnx); - const tx = x + bnx*len, ty = y + bny*len; - ctx.beginPath(); ctx.moveTo(tx,ty); - ctx.lineTo(tx - 6*Math.cos(a-0.4), ty - 6*Math.sin(a-0.4)); - ctx.lineTo(tx - 6*Math.cos(a+0.4), ty - 6*Math.sin(a+0.4)); - ctx.closePath(); ctx.fill(); - - /* label */ - ctx.font = '9px Manrope'; ctx.fillStyle = 'rgba(255,255,255,0.6)'; - ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - ctx.fillText('|B|=' + mag.toFixed(0), x + 18, y - 8); - - ctx.restore(); - } - - _drawHint(ctx, W, H) { - ctx.save(); - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.font = '16px Manrope, sans-serif'; - ctx.fillStyle = 'rgba(155,93,229,0.45)'; - ctx.fillText('Нажми на канвас — добавь провод с током', W/2, H/2 - 18); - ctx.font = '13px Manrope, sans-serif'; - ctx.fillStyle = 'rgba(255,255,255,0.22)'; - ctx.fillText('• Ток на нас × Ток от нас ПКМ / двойной клик — удалить', W/2, H/2 + 14); - ctx.restore(); - } -} - -/* ─── lab UI init ─────────────────────────────────── */ - function _openMagnetic() { - document.getElementById('sim-topbar-title').textContent = 'Магнитное поле токов'; - _simShow('sim-mag'); - _simShow('ctrl-mag'); - - requestAnimationFrame(() => requestAnimationFrame(() => { - if (!mSim) { - mSim = new MagneticSim(document.getElementById('mag-canvas')); - mSim.onUpdate = _magUpdateUI; - } - mSim.fit(); - // default preset on first open - if (mSim.sources.length === 0) mSim.preset('anti'); - _magUpdateUI(mSim.info()); - })); - } - - function magMode(dir) { - if (!mSim) return; - mSim.addMode = dir; - document.getElementById('mag-add-out').classList.toggle('active', dir === 'out'); - document.getElementById('mag-add-in').classList.toggle('active', dir === 'in'); - document.getElementById('mag-mode-out').classList.toggle('active', dir === 'out'); - document.getElementById('mag-mode-in').classList.toggle('active', dir === 'in'); - } - - function magCurrentChange() { - const I = +document.getElementById('sl-curI').value; - document.getElementById('m-curI').textContent = I + ' А'; - document.getElementById('mbar-I').textContent = I + ' А'; - if (mSim) mSim.setCurrentAll(I); - } - - function magLayer(name, rowEl) { - if (!mSim) return; - mSim.layers[name] = !mSim.layers[name]; - rowEl.classList.toggle('active', mSim.layers[name]); - mSim._invalidateCache(); - mSim.draw(); - } - - function magParticle(rowEl) { - if (!mSim) return; - mSim.toggleParticle(); - rowEl.classList.toggle('active', mSim.particleOn); - _magUpdateUI(mSim.info()); - } - - function magCondToggle(rowEl) { - if (!mSim) return; - mSim.toggleConductor(); - const on = mSim._cond.on; - rowEl.classList.toggle('active', on); - document.getElementById('cond-I-block').style.display = on ? '' : 'none'; - _magUpdateUI(mSim.info()); - } - - function magCondCurrentChange() { - if (!mSim) return; - const I = parseFloat(document.getElementById('sl-condI').value); - document.getElementById('m-condI').textContent = I + ' А'; - mSim.setConductorI(I); - } - - function magFluxToggle(rowEl) { - if (!mSim) return; - mSim.toggleFlux(); - rowEl.classList.toggle('active', mSim._flux.on); - _magUpdateUI(mSim.info()); - } - - function _magUpdateUI(info) { - document.getElementById('ms-out').textContent = info.out; - document.getElementById('ms-in').textContent = info.inn; - document.getElementById('mbar-total').textContent = info.total; - document.getElementById('mbar-out').textContent = info.out; - document.getElementById('mbar-in').textContent = info.inn; - document.getElementById('mbar-particle').textContent = info.particleOn ? 'вкл' : 'выкл'; - document.getElementById('mbar-particle').style.color = info.particleOn ? '#ffff50' : ''; - // Ampere force - const fEl = document.getElementById('mbar-ampere'); - if (info.condOn && info.Fz !== 0) { - const dir = info.Fz > 0 ? '⊙' : '⊗'; - fEl.textContent = dir + ' ' + Math.abs(info.Fz).toFixed(3); - fEl.style.color = '#fbbf24'; - } else { - fEl.textContent = '—'; - fEl.style.color = '#fbbf24'; - } - // Flux - const phEl = document.getElementById('mbar-flux'); - if (info.fluxOn) { - phEl.textContent = info.flux.toExponential(2) + ' Вб'; - phEl.style.color = '#34d399'; - } else { - phEl.textContent = '—'; - phEl.style.color = '#34d399'; - } - } - - /* ── triangle ── */ - diff --git a/frontend/js/labs/projectile.js b/frontend/js/labs/projectile.js index da29f42..cb95ac4 100644 --- a/frontend/js/labs/projectile.js +++ b/frontend/js/labs/projectile.js @@ -1,10 +1,11 @@ 'use strict'; /* ═══════════════════════════════════════════════════════════════════ - ProjectileSim v2 — physics simulation + ProjectileSim v3 — 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 ═══════════════════════════════════════════════════════════════════ */ class ProjectileSim { @@ -67,6 +68,24 @@ class ProjectileSim { this._hover = null; // { t, s } | null this._viewParams = null; // coordinate transform params (set in draw) + /* ── Feature 1: target challenge mode ── */ + this.targetMode = false; + this._targets = []; // [{x,y,w,h,hit,flashTs}] + this._targetAttempts = 0; + this.onTargetUpdate = null; // callback → ({hits, total, attempts}) + + /* ── Feature 2: graphs panel ── */ + this._graphsCanvas = null; // set by attachGraphsCanvas() + this._graphsVisible = false; + + /* ── Feature 3: dual throw ── */ + this.dualMode = false; + this._p2 = { // second projectile params + live state + v0: 25, angle: 30, h0: 0, + path: null, pathTf: 0, + t: 0, trail: [], + }; + canvas.addEventListener('click', () => { if (this.onPlayPause) this.onPlayPause(); }); @@ -105,6 +124,7 @@ class ProjectileSim { if (bounce !== undefined) this.bounce = !!bounce; if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution)); this._computePath(); + if (this.dualMode) this._computeP2Path(); this._resetFX(); this.draw(); this._emit(); @@ -118,6 +138,8 @@ class ProjectileSim { this._launchFlash = 1; this.playing = true; this._lastTs = null; + /* reset p2 at launch so both start simultaneously */ + if (this.dualMode) { this._p2.t = 0; this._p2.trail = []; } this._tick(); } @@ -162,6 +184,363 @@ class ProjectileSim { this.draw(); } + /* ── Feature 1: target mode ── */ + + toggleTargetMode() { + this.targetMode = !this.targetMode; + if (this.targetMode && this._targets.length === 0) this.genTargets(); + this._emitTargets(); + this.draw(); + return this.targetMode; + } + + genTargets() { + const st = this.stats(); + const range = Math.max(st.range, 10); + const hMax = Math.max(st.hMax, 5); + const count = 3; + this._targets = []; + for (let i = 0; i < count; i++) { + const tw = 1.0 + Math.random() * 1.5; // window width 1–2.5 m + const th = 1.0 + Math.random() * 1.5; // window height 1–2.5 m + /* spread windows across [10%, 90%] of range so they're reachable */ + const x = range * (0.1 + 0.8 * (i + Math.random() * 0.5) / count); + const y = 1.0 + Math.random() * Math.max(1, hMax * 0.7); + this._targets.push({ x, y, w: tw, h: th, hit: false, flashTs: -999 }); + } + this._targetAttempts = 0; + this._emitTargets(); + this.draw(); + } + + _checkTargetHits(prevT, nextT) { + if (!this.targetMode || this._targets.length === 0) return; + /* sample a few sub-steps between prevT and nextT for precision */ + const steps = 8; + for (let s = 0; s <= steps; s++) { + const t = prevT + (nextT - prevT) * (s / steps); + const st = this._curState(t); + if (st.y < 0) break; + for (const tgt of this._targets) { + if (tgt.hit) continue; + if (st.x >= tgt.x && st.x <= tgt.x + tgt.w && + st.y >= tgt.y && st.y <= tgt.y + tgt.h) { + tgt.hit = true; + tgt.flashTs = performance.now(); + this._emitTargets(); + } + } + } + } + + _emitTargets() { + if (!this.onTargetUpdate) return; + const hits = this._targets.filter(t => t.hit).length; + this.onTargetUpdate({ hits, total: this._targets.length, attempts: this._targetAttempts }); + } + + _drawTargets(ctx, tpx, tpy) { + if (!this.targetMode) return; + const now = performance.now(); + for (const tgt of this._targets) { + const cx = tpx(tgt.x); + const cy = tpy(tgt.y + tgt.h); // top edge in canvas coords + const cw = tpx(tgt.x + tgt.w) - tpx(tgt.x); + const ch = tpy(tgt.y) - tpy(tgt.y + tgt.h); // positive height + + const flashAge = (now - tgt.flashTs) / 1000; + const flashing = flashAge < 1.2; + + if (tgt.hit) { + /* gold fill on hit */ + const alpha = flashing ? 0.25 + 0.2 * Math.sin(flashAge * 18) : 0.18; + ctx.fillStyle = `rgba(255,214,102,${alpha})`; + ctx.fillRect(cx, cy, cw, ch); + ctx.strokeStyle = flashing + ? `rgba(255,214,102,${0.7 + 0.3 * Math.sin(flashAge * 18)})` + : 'rgba(255,214,102,.6)'; + ctx.lineWidth = 2.5; + ctx.strokeRect(cx, cy, cw, ch); + + /* checkmark */ + const mx = cx + cw / 2, my = cy + ch / 2; + ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(mx - cw * 0.22, my); + ctx.lineTo(mx - cw * 0.05, my + ch * 0.22); + ctx.lineTo(mx + cw * 0.28, my - ch * 0.28); + ctx.stroke(); + } else { + /* inactive window: translucent blue rect with dashed border */ + ctx.fillStyle = 'rgba(6,214,224,.06)'; + ctx.fillRect(cx, cy, cw, ch); + ctx.strokeStyle = 'rgba(6,214,224,.5)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 3]); + ctx.strokeRect(cx, cy, cw, ch); + ctx.setLineDash([]); + + /* small cross in top-right corner to look like a window frame */ + ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(cx + cw / 2, cy + ch); + ctx.moveTo(cx, cy + ch / 2); ctx.lineTo(cx + cw, cy + ch / 2); + ctx.stroke(); + } + } + } + + /* ── Feature 2: graphs canvas attachment ── */ + + attachGraphsCanvas(canvas) { + this._graphsCanvas = canvas; + } + + drawGraphs() { + const gc = this._graphsCanvas; + if (!gc || !this._graphsVisible) return; + + const dpr = window.devicePixelRatio || 1; + const W = gc.clientWidth || gc.width / dpr; + const H = gc.clientHeight || gc.height / dpr; + if (!W || !H) return; + + /* keep physical pixel size in sync */ + if (gc.width !== Math.round(W * dpr) || gc.height !== Math.round(H * dpr)) { + gc.width = Math.round(W * dpr); + gc.height = Math.round(H * dpr); + } + const gctx = gc.getContext('2d'); + gctx.setTransform(dpr, 0, 0, dpr, 0, 0); + gctx.clearRect(0, 0, W, H); + + const tf = this._curTFlight(); + if (tf <= 0) { + gctx.fillStyle = 'rgba(255,255,255,.2)'; + gctx.font = '11px Manrope, sans-serif'; + gctx.textAlign = 'center'; gctx.textBaseline = 'middle'; + gctx.fillText('Запустите симуляцию', W / 2, H / 2); + return; + } + + /* collect full trajectory data for plotting */ + const N = 200; + const pts = []; + for (let i = 0; i <= N; i++) { + const t = (i / N) * tf; + const s = this._curState(t); + pts.push({ t, x: s.x, y: Math.max(0, s.y), vx: s.vx, vy: s.vy }); + } + + const plots = [ + { key: 'x', label: 'x(t)', unit: 'м', color: '#06D6E0' }, + { key: 'y', label: 'y(t)', unit: 'м', color: '#7BF5A4' }, + { key: 'vx', label: 'vx(t)', unit: 'м/с', color: '#9B5DE5' }, + { key: 'vy', label: 'vy(t)', unit: 'м/с', color: '#F15BB5' }, + ]; + + const cols = 2, rows = 2; + const PL = 36, PR = 10, PT = 20, PB = 22; + const cw = W / cols, ch = H / rows; + const pw = cw - PL - PR, ph = ch - PT - PB; + + /* current time marker fraction */ + const curFrac = tf > 0 ? Math.min(1, this.t / tf) : 0; + + for (let pi = 0; pi < plots.length; pi++) { + const col = pi % cols, row = Math.floor(pi / cols); + const ox = col * cw, oy = row * ch; + const plot = plots[pi]; + + const vals = pts.map(p => p[plot.key]); + const vMin = Math.min(...vals), vMax = Math.max(...vals); + const vRange = Math.max(vMax - vMin, 0.1); + + const tx = t => ox + PL + (t / tf) * pw; + const ty = v => oy + PT + ph - ((v - vMin) / vRange) * ph; + + /* background */ + gctx.fillStyle = 'rgba(5,5,20,.85)'; + gctx.fillRect(ox + PL, oy + PT, pw, ph); + + /* grid lines */ + gctx.strokeStyle = 'rgba(255,255,255,.05)'; gctx.lineWidth = 1; + for (let gi = 1; gi < 4; gi++) { + const gv = vMin + (gi / 4) * vRange; + const gy = ty(gv); + gctx.beginPath(); gctx.moveTo(ox + PL, gy); gctx.lineTo(ox + PL + pw, gy); gctx.stroke(); + } + + /* axes */ + gctx.strokeStyle = 'rgba(255,255,255,.25)'; gctx.lineWidth = 1.2; + gctx.beginPath(); + gctx.moveTo(ox + PL, oy + PT); gctx.lineTo(ox + PL, oy + PT + ph); + gctx.lineTo(ox + PL + pw, oy + PT + ph); + gctx.stroke(); + + /* axis labels */ + gctx.font = '9px Manrope, sans-serif'; + gctx.fillStyle = 'rgba(255,255,255,.35)'; + gctx.textAlign = 'right'; gctx.textBaseline = 'middle'; + gctx.fillText(_projFmt(vMax) + ' ' + plot.unit, ox + PL - 3, oy + PT + 4); + gctx.fillText(_projFmt(vMin) + ' ' + plot.unit, ox + PL - 3, oy + PT + ph - 2); + gctx.textAlign = 'center'; gctx.textBaseline = 'top'; + gctx.fillText(_projFmt(tf) + ' с', ox + PL + pw, oy + PT + ph + 4); + + /* data line */ + gctx.strokeStyle = plot.color; gctx.lineWidth = 2; + gctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const px = tx(pts[i].t), py = ty(pts[i][plot.key]); + i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py); + } + gctx.stroke(); + + /* second projectile overlay */ + if (this.dualMode && this._p2.pathTf > 0) { + const tf2 = this._p2.pathTf; + const pts2 = []; + for (let i = 0; i <= N; i++) { + const t2 = (i / N) * tf2; + const s2 = this._p2.path ? this._p2PathStateAt(t2) : this._p2StateAnalytical(t2); + pts2.push({ t: t2, x: s2.x, y: Math.max(0, s2.y), vx: s2.vx, vy: s2.vy }); + } + gctx.strokeStyle = 'rgba(0,230,255,.55)'; gctx.lineWidth = 1.5; + gctx.setLineDash([4, 3]); + gctx.beginPath(); + for (let i = 0; i < pts2.length; i++) { + const px = tx(pts2[i].t), py = ty(pts2[i][plot.key]); + i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py); + } + gctx.stroke(); gctx.setLineDash([]); + } + + /* current time indicator */ + if (curFrac > 0) { + const curX = tx(this.t); + gctx.strokeStyle = 'rgba(255,214,102,.7)'; gctx.lineWidth = 1.5; + gctx.setLineDash([3, 3]); + gctx.beginPath(); gctx.moveTo(curX, oy + PT); gctx.lineTo(curX, oy + PT + ph); gctx.stroke(); + gctx.setLineDash([]); + + /* dot on the line */ + const curV = this._curState(this.t)[plot.key]; + const curVclamped = Math.min(vMax, Math.max(vMin, curV)); + gctx.fillStyle = '#FFD166'; + gctx.beginPath(); gctx.arc(curX, ty(curVclamped), 3.5, 0, Math.PI * 2); gctx.fill(); + } + + /* label */ + gctx.font = 'bold 11px Manrope, sans-serif'; + gctx.fillStyle = plot.color; + gctx.textAlign = 'left'; gctx.textBaseline = 'top'; + gctx.fillText(plot.label, ox + PL + 5, oy + PT + 4); + } + } + + /* ── Feature 3: second projectile helpers ── */ + + _p2StateAnalytical(t) { + const p2 = this._p2; + const rad = p2.angle * Math.PI / 180; + const vx = p2.v0 * Math.cos(rad); + const vy0 = p2.v0 * Math.sin(rad); + return { + x: vx * t, + y: p2.h0 + vy0 * t - 0.5 * this.g * t * t, + vx, + vy: vy0 - this.g * t, + }; + } + + _p2PathStateAt(t) { + const path = this._p2.path; + if (!path || path.length < 2) return { x: 0, y: this._p2.h0, vx: 0, vy: 0 }; + if (t <= 0) return path[0]; + if (t >= this._p2.pathTf) return path[path.length - 1]; + let lo = 0, hi = path.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (path[mid].t <= t) lo = mid; else hi = mid; + } + const a = path[lo], b = path[hi]; + const frac = (t - a.t) / (b.t - a.t); + return { + x: a.x + (b.x - a.x) * frac, + y: a.y + (b.y - a.y) * frac, + vx: a.vx + (b.vx - a.vx) * frac, + vy: a.vy + (b.vy - a.vy) * frac, + }; + } + + _p2CurState(t) { + return this._p2.path ? this._p2PathStateAt(t) : this._p2StateAnalytical(t); + } + + /* recompute second projectile path using same RK4 if drag/wind/bounce active */ + _computeP2Path() { + const p2 = this._p2; + if (!this._needsNumerical()) { + const rad = p2.angle * Math.PI / 180; + const vy0 = p2.v0 * Math.sin(rad); + const disc = vy0 * vy0 + 2 * this.g * p2.h0; + p2.path = null; + p2.pathTf = disc < 0 ? 0 : Math.max(0, (vy0 + Math.sqrt(disc)) / this.g); + return; + } + + const rho = 1.225, 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; + const rad = p2.angle * Math.PI / 180; + let x = 0, y = p2.h0; + let vx = p2.v0 * Math.cos(rad), vy = p2.v0 * Math.sin(rad); + const dt = 0.005; + const path = [{ x, y, vx, vy, t: 0 }]; + let bounces = 0; + + const deriv = (sx, sy, svx, svy) => { + const rvx = svx - W; + const speed = Math.sqrt(rvx * rvx + svy * svy); + const dragF = speed > 0 ? k * speed : 0; + const wAcc = (!this.drag && W !== 0) ? W * 0.05 : 0; + return { dx: svx, dy: svy, dvx: -dragF * rvx + wAcc, dvy: -g - dragF * svy }; + }; + + 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); + 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 t2 = (step + 1) * dt; + if (y <= 0) { + const prev = path[path.length - 1]; + if (prev && prev.y > 0) { + const frac = prev.y / (prev.y - y); + const lx = prev.x + (x - prev.x) * frac; + 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 }); + if (this.bounce && bounces < maxBounces && Math.abs(lvy) > 0.4) { + vy = -e * lvy; vx = lvx * 0.96; y = 0.001; x = lx; + bounces++; + continue; + } + } + break; + } + path.push({ x, y, vx, vy, t: t2 }); + } + p2.path = path; + p2.pathTf = path[path.length - 1].t; + } + /* ── physics ── */ /* pure analytical solution (no drag/wind/bounce) */ @@ -345,6 +724,7 @@ class ProjectileSim { this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5); + const prevT = this.t; const cur = this._curState(this.t); this._trail.push({ mx: cur.x, my: cur.y }); if (this._trail.length > 80) this._trail.shift(); @@ -355,9 +735,24 @@ class ProjectileSim { this.t = tf; this.playing = false; this._triggerImpact(); + if (this.targetMode) this._targetAttempts++; } + + /* target hit detection on this step interval */ + this._checkTargetHits(prevT, Math.min(this.t, tf)); + + /* advance second projectile */ + if (this.dualMode) { + const p2 = this._p2; + const p2cur = this._p2CurState(p2.t); + p2.trail.push({ mx: p2cur.x, my: p2cur.y }); + if (p2.trail.length > 80) p2.trail.shift(); + p2.t = Math.min(p2.t + rawDt * this.speed, p2.pathTf); + } + this.draw(); this._emit(); + if (this._graphsVisible) this.drawGraphs(); if (this.playing) this._tick(); }); } @@ -391,6 +786,13 @@ class ProjectileSim { this._impactTs = -999; this._launchFlash = 0; this._computePath(); + if (this.dualMode) { + this._p2.t = 0; + this._p2.trail = []; + this._computeP2Path(); + } + /* clear target hits so player can retry */ + for (const tgt of this._targets) tgt.hit = false; } _emit() { if (this.onUpdate) this.onUpdate(this.stats()); } @@ -521,6 +923,25 @@ class ProjectileSim { ctx.fillText(gh.label, lx, ly + 10); } + /* ── 6.7. Target windows ── */ + this._drawTargets(ctx, tpx, tpy); + + /* ── 6.8. HUD: target counter (top-right inside canvas) ── */ + if (this.targetMode && this._targets.length > 0) { + const hits = this._targets.filter(t => t.hit).length; + const hudText = `Цели: ${hits}/${this._targets.length} Попыток: ${this._targetAttempts}`; + ctx.font = 'bold 11px Manrope, sans-serif'; + const tw = ctx.measureText(hudText).width; + const hx = W - PR - 8 - tw - 20, hy = PT + 30; + ctx.fillStyle = 'rgba(5,5,20,.75)'; + ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.fill(); + ctx.strokeStyle = 'rgba(255,214,102,.4)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.stroke(); + ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(hudText, hx + 4, hy + 7); + } + /* ── 7. Launch platform ── */ if (this.h0 > 0.2) { const px0 = tpx(0), py0 = tpy(0), pyH = tpy(this.h0); @@ -601,6 +1022,93 @@ class ProjectileSim { ctx.beginPath(); ctx.arc(tpx(tr.mx), tpy(tr.my), frac * 5, 0, Math.PI * 2); ctx.fill(); } + /* ── 10.5. Dual throw — second projectile ── */ + if (this.dualMode && this._p2.pathTf > 0) { + const p2 = this._p2; + const tf2 = p2.pathTf; + + /* full reference trajectory */ + ctx.strokeStyle = 'rgba(0,230,255,.25)'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); + ctx.beginPath(); + const step2 = Math.max(1, p2.path ? Math.floor(p2.path.length / 250) : 1); + if (p2.path) { + for (let i = 0; i < p2.path.length; i += step2) { + const pp = p2.path[i]; + i === 0 ? ctx.moveTo(tpx(pp.x), tpy(pp.y)) : ctx.lineTo(tpx(pp.x), tpy(pp.y)); + } + } else { + for (let i = 0; i <= 250; i++) { + const s2 = this._p2StateAnalytical((i / 250) * tf2); + i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y)); + } + } + ctx.stroke(); ctx.setLineDash([]); + + /* flown path */ + if (p2.t > 0) { + const s2_0 = this._p2CurState(0), s2_1 = this._p2CurState(Math.min(p2.t, tf2)); + const g2 = ctx.createLinearGradient(tpx(s2_0.x), tpy(s2_0.y), tpx(s2_1.x), tpy(s2_1.y)); + g2.addColorStop(0, 'rgba(0,230,255,.3)'); + g2.addColorStop(1, '#00E6FF'); + ctx.strokeStyle = g2; ctx.lineWidth = 3; + ctx.beginPath(); + if (p2.path) { + let first = true; + for (const pp of p2.path) { + if (pp.t > p2.t) break; + first ? (ctx.moveTo(tpx(pp.x), tpy(pp.y)), first = false) : ctx.lineTo(tpx(pp.x), tpy(pp.y)); + } + const ps2 = this._p2PathStateAt(p2.t); + ctx.lineTo(tpx(ps2.x), tpy(Math.max(0, ps2.y))); + } else { + const steps2 = Math.max(2, Math.ceil((p2.t / tf2) * 250)); + for (let i = 0; i <= steps2; i++) { + const s2 = this._p2StateAnalytical((i / 250) * tf2); + i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y)); + } + } + ctx.stroke(); + } + + /* p2 trail dots */ + for (let i = 0; i < p2.trail.length; i++) { + const frac = i / p2.trail.length; + const tr2 = p2.trail[i]; + ctx.fillStyle = `rgba(0,230,255,${frac * 0.45})`; + ctx.beginPath(); ctx.arc(tpx(tr2.mx), tpy(tr2.my), frac * 4.5, 0, Math.PI * 2); ctx.fill(); + } + + /* p2 ball */ + const c2 = this._p2CurState(Math.min(p2.t, tf2)); + const b2x = tpx(c2.x), b2y = tpy(Math.max(0, c2.y)); + + const glo2 = ctx.createRadialGradient(b2x, b2y, 2, b2x, b2y, 28); + glo2.addColorStop(0, 'rgba(0,230,255,.45)'); + glo2.addColorStop(1, 'transparent'); + ctx.fillStyle = glo2; + ctx.beginPath(); ctx.arc(b2x, b2y, 28, 0, Math.PI * 2); ctx.fill(); + + const bg2 = ctx.createRadialGradient(b2x - 3, b2y - 3, 1, b2x, b2y, 10); + bg2.addColorStop(0, '#ffffff'); + bg2.addColorStop(0.25, '#00E6FF'); + bg2.addColorStop(1, '#0891b2'); + ctx.fillStyle = bg2; + ctx.beginPath(); ctx.arc(b2x, b2y, 10, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke(); + + /* p2 landing marker */ + const end2 = this._p2CurState(tf2); + const lx2 = tpx(end2.x), ly2 = tpy(0); + ctx.strokeStyle = 'rgba(0,230,255,.6)'; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(lx2 - 6, ly2 - 6); ctx.lineTo(lx2 + 6, ly2 + 6); + ctx.moveTo(lx2 + 6, ly2 - 6); ctx.lineTo(lx2 - 6, ly2 + 6); + ctx.stroke(); + ctx.fillStyle = 'rgba(0,230,255,.8)'; + ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(_projFmt(end2.x) + ' м', lx2, ly2 + 8); + } + /* ── 11. Max height marker ── */ if (st.hMax > this.h0 + 0.2 && tf > 0) { let mpx, mpy; @@ -1073,8 +1581,11 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) { requestAnimationFrame(() => requestAnimationFrame(() => { if (!pSim) { pSim = new ProjectileSim(document.getElementById('proj-canvas')); - pSim.onUpdate = _projUpdateUI; - pSim.onPlayPause = projPlayPause; + pSim.onUpdate = _projUpdateUI; + pSim.onPlayPause = projPlayPause; + pSim.onTargetUpdate = _projUpdateTargetHUD; + const gc = document.getElementById('proj-graphs-canvas'); + if (gc) pSim.attachGraphsCanvas(gc); } pSim.fit(); projParam(); // sync sliders → sim @@ -1226,6 +1737,91 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) { if (pSim) pSim.clearGhosts(); } + /* ── Feature 1: target mode UI ── */ + + function projToggleTargetMode() { + if (!pSim) return; + const on = pSim.toggleTargetMode(); + const btn = document.getElementById('proj-target-btn'); + if (btn) { + btn.classList.toggle('active', on); + btn.querySelector('span').textContent = on ? 'Режим целей: Вкл' : 'Режим целей: Выкл'; + } + const panel = document.getElementById('proj-target-panel'); + if (panel) panel.style.display = on ? '' : 'none'; + _projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 }); + } + + function projGenTargets() { + if (!pSim) return; + pSim.genTargets(); + _projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 }); + } + + function _projUpdateTargetHUD(info) { + const el = document.getElementById('proj-target-hud'); + if (!el) return; + el.textContent = `Цели: ${info.hits}/${info.total} Попыток: ${info.attempts}`; + } + + /* ── Feature 2: graphs panel UI ── */ + + function projToggleGraphs() { + if (!pSim) return; + pSim._graphsVisible = !pSim._graphsVisible; + const panel = document.getElementById('proj-graphs-panel'); + const btn = document.getElementById('proj-graphs-btn'); + if (panel) panel.style.display = pSim._graphsVisible ? '' : 'none'; + if (btn) btn.classList.toggle('active', pSim._graphsVisible); + if (pSim._graphsVisible) { + if (!pSim._graphsCanvas) { + const gc = document.getElementById('proj-graphs-canvas'); + if (gc) pSim.attachGraphsCanvas(gc); + } + pSim.drawGraphs(); + } + } + + /* ── Feature 3: dual throw UI ── */ + + function projToggleDual() { + if (!pSim) return; + pSim.dualMode = !pSim.dualMode; + const on = pSim.dualMode; + const btn = document.getElementById('proj-dual-btn'); + if (btn) { + btn.classList.toggle('active', on); + btn.querySelector('span').textContent = on ? 'Двойной: Вкл' : 'Двойной: Выкл'; + } + const panel = document.getElementById('proj-dual-panel'); + if (panel) panel.style.display = on ? '' : 'none'; + /* show/hide dual stats cells */ + const w1 = document.getElementById('ps-p2-wrap'); + const w2 = document.getElementById('ps-p2-tf-wrap'); + if (w1) w1.style.display = on ? '' : 'none'; + if (w2) w2.style.display = on ? '' : 'none'; + if (on) { + pSim._computeP2Path(); + projP2Param(); + } + pSim.draw(); + } + + function projP2Param() { + if (!pSim) return; + const v0 = +document.getElementById('sl-p2-v0').value; + const angle = +document.getElementById('sl-p2-angle').value; + const h0 = +document.getElementById('sl-p2-h0').value; + document.getElementById('p2-v0').textContent = v0 + ' м/с'; + document.getElementById('p2-angle').textContent = angle + '°'; + document.getElementById('p2-h0').textContent = h0 + ' м'; + pSim._p2.v0 = v0; + pSim._p2.angle = angle; + pSim._p2.h0 = h0; + pSim._computeP2Path(); + pSim.draw(); + } + function _projUpdateUI(s) { const fmt = (n, unit) => n < 10000 ? n.toFixed(2) + ' ' + unit : (n/1000).toFixed(2) + ' к' + unit; document.getElementById('ps-range').textContent = fmt(s.range, 'м'); @@ -1243,7 +1839,17 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) { lossEl.style.color = s.rangeLoss < 0 ? '#EF476F' : '#7BF5A4'; } } + /* update dual stats row */ + if (pSim && pSim.dualMode && pSim._p2.pathTf > 0) { + const p2end = pSim._p2CurState(pSim._p2.pathTf); + const d2El = document.getElementById('ps-p2-range'); + if (d2El) d2El.textContent = fmt(Math.max(0, p2end.x), 'м'); + const d2tf = document.getElementById('ps-p2-tf'); + if (d2tf) d2tf.textContent = pSim._p2.pathTf.toFixed(2) + ' с'; + } _projSyncPlayBtn(); + /* redraw graphs if open and not in flight (flight loop handles it) */ + if (pSim && pSim._graphsVisible && !pSim.playing) pSim.drawGraphs(); } /* ── collision ── */ diff --git a/frontend/js/labs/triangle.js b/frontend/js/labs/triangle.js index 82a34ad..f2c770e 100644 --- a/frontend/js/labs/triangle.js +++ b/frontend/js/labs/triangle.js @@ -1046,6 +1046,12 @@ class TriangleSim { midline: 'Кликни вершину A треугольника', parallelogram:'Кликни вершину A параллелограмма', diagonal: 'Кликни внутри четырёхугольника — построим диагонали', - scale: 'Кликни центр подобия O', + scale: 'Кликни центр подобия O', + measure_length: 'Кликни на отрезок — прикрепит живой чип с длиной', + measure_angle: 'Кликни первую точку на стороне угла', + measure_area: 'Кликни на многоугольник — прикрепит живой чип с площадью', + locus: 'Кликни точку-мовер (должна быть on_segment или on_circle)', + point_on_segment: 'Кликни на отрезок — создаст скользящую точку для ГМТ', + point_on_circle: 'Кликни на окружность — создаст скользящую точку для ГМТ', }; diff --git a/frontend/lab.html b/frontend/lab.html index 7285d95..344425c 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -63,7 +63,7 @@ Назад -
+