feat(labs): planimetry locus + emfield merger + projectile graphs + UI cleanup

Геометрия (планиметрия):
- Живые измерения как объекты: длина / угол / площадь — auto-recompute, draggable chips
- Инструмент ГМТ: sweep мовера через параметр, рисует кривую места точек
- Новые типы точек: on_segment (скользит по отрезку, _t), on_circle (по окружности, _theta)
- Toolbar: «Длина», «Угол», «Площадь», «ГМТ», «На отрезке», «На окружности»

Электромагнитные поля (emfield):
- Merge magnetic.js + coulomb.js в один EMFieldSim с 3 режимами (E / B / комбинированное)
- Унифицированный pipeline: colormap, field lines, vectors, equipotentials, flux loop, test particle
- Combined-режим: полная сила Лоренца F=q(E+v×B)
- Backward compat: #coulomb и #magnetic хеши и ?sim= параметры редиректят в emfield
- Удалены: magnetic.js, coulomb.js. Добавлен: emfield.js

Бросок тела (projectile):
- Режим целей: 3 окна, hit-детекция, HUD «Цели: N/M / Попыток: K»
- Графики x(t), y(t), vx(t), vy(t) — 2×2 Canvas 2D, real-time
- Двойной бросок: одновременно 2 траектории для сравнения (cyan vs gold)

UI fixes (по результатам аудита):
- Заменены emoji/unicode на inline SVG .ic: switch ⌇, spring 〜 (5 мест), download ⬇ (2), camera 📷
- Убраны декоративные символы ☉ ○ из geometry tool labels
- Добавлены THEORY entries: geometry, hydrostatics (раньше показывали fallback)
- Стандартизирована ширина panel для sim-proj и sim-coll (240px)
- waves перенесён в физический блок SIMS catalog (был после биологии)
- Очищен дефолтный sim-topbar-title (был «График функции»)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 12:09:44 +03:00
parent 085b7322cf
commit 7f75c96acd
11 changed files with 3037 additions and 2239 deletions
+12
View File
@@ -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%;
+1 -2
View File
@@ -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: 'Тонкая линза' },
-812
View File
@@ -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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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; // 030 red-orange
else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220240 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;
}
/* ════════════════════════════════
ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
════════════════════════════════ */
File diff suppressed because it is too large Load Diff
+519 -7
View File
@@ -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');
+10 -12
View File
@@ -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;
+45 -6
View File
@@ -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: [
File diff suppressed because it is too large Load Diff
+609 -3
View File
@@ -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 12.5 m
const th = 1.0 + Math.random() * 1.5; // window height 12.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 ── */
+7 -1
View File
@@ -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: 'Кликни на окружность — создаст скользящую точку для ГМТ',
};
+293 -237
View File
@@ -63,7 +63,7 @@
<svg viewBox="0 0 24 24" fill="none"><polyline points="15 18 9 12 15 6"/></svg>
Назад
</button>
<div class="sim-topbar-title" id="sim-topbar-title">График функции</div>
<div class="sim-topbar-title" id="sim-topbar-title"></div>
<!-- graph controls -->
<div id="ctrl-graph" class="sim-zoom-btns">
@@ -91,11 +91,13 @@
<button class="zoom-btn" onclick="projClearGhosts()" title="Очистить следы" style="font-size:.65rem"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<!-- magnetic controls -->
<div id="ctrl-mag" class="sim-zoom-btns" style="display:none">
<button class="zoom-btn" id="mag-add-out" onclick="magMode('out')" title="Добавить провод • (ток на нас)" style="font-size:1rem"></button>
<button class="zoom-btn" id="mag-add-in" onclick="magMode('in')" title="Добавить провод × (ток от нас)" style="font-size:1rem">×</button>
<button class="zoom-btn" onclick="mSim && mSim.clearAll()" title="Очистить">
<!-- emfield controls -->
<div id="ctrl-emfield" class="sim-zoom-btns" style="display:none">
<button class="zoom-btn" id="em-sign-pos" onclick="emSign(1)" title="Добавлять + заряды" style="font-size:1.1rem;font-weight:900;color:#EF476F">+</button>
<button class="zoom-btn" id="em-sign-neg" onclick="emSign(-1)" title="Добавлять заряды" style="font-size:1.1rem;font-weight:900;color:#4CC9F0"></button>
<button class="zoom-btn" id="em-dir-out" onclick="emWireDir('out')" title="Провод • (ток на нас)" style="font-size:1rem"></button>
<button class="zoom-btn" id="em-dir-in" onclick="emWireDir('in')" title="Провод × (ток от нас)" style="font-size:1rem">×</button>
<button class="zoom-btn" onclick="emSim && emSim.clearAll()" title="Очистить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
</button>
</div>
@@ -157,14 +159,7 @@
</button>
</div>
<!-- coulomb controls -->
<div id="ctrl-coulomb" class="sim-zoom-btns" style="display:none">
<button class="zoom-btn" id="csign-pos" onclick="coulombSign(1)" title="Добавить + заряд" style="font-size:1.1rem;font-weight:900;color:#EF476F">+</button>
<button class="zoom-btn" id="csign-neg" onclick="coulombSign(-1)" title="Добавить − заряд" style="font-size:1.1rem;font-weight:900;color:#4CC9F0"></button>
<button class="zoom-btn" onclick="csSim && csSim.reset(); csSim && csSim.draw(); _coulombUpdateUI(csSim&&csSim.info())" title="Очистить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
</button>
</div>
<!-- (coulomb merged into ctrl-emfield) -->
<!-- circuit controls -->
<div id="ctrl-circuit" class="sim-zoom-btns" style="display:none">
@@ -175,7 +170,7 @@
<button class="zoom-btn circ-top-btn" id="ctool-diode" onclick="circTool('diode',this)" title="Диод (D)" style="font-size:.75rem"><svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>|</button>
<button class="zoom-btn circ-top-btn" id="ctool-led" onclick="circTool('led',this)" title="LED" style="font-size:.6rem;font-weight:800">LED</button>
<button class="zoom-btn circ-top-btn" id="ctool-ac" onclick="circTool('ac',this)" title="AC источник" style="font-size:.65rem;font-weight:800">AC</button>
<button class="zoom-btn circ-top-btn" id="ctool-switch" onclick="circTool('switch',this)" title="Выключатель (S)" style="font-size:.7rem"></button>
<button class="zoom-btn circ-top-btn" id="ctool-switch" onclick="circTool('switch',this)" title="Выключатель (S)" style="font-size:.7rem"><svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/><line x1="9" y1="12" x2="17" y2="6"/></svg></button>
<button class="zoom-btn circ-top-btn" id="ctool-lamp" onclick="circTool('lamp',this)" title="Лампа (L)" style="font-size:.75rem"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="zoom-btn circ-top-btn" id="ctool-ammeter" onclick="circTool('ammeter',this)" title="Амперметр (A)" style="font-size:.6rem;font-weight:800">А</button>
<button class="zoom-btn circ-top-btn" id="ctool-voltmeter" onclick="circTool('voltmeter',this)" title="Вольтметр (V)" style="font-size:.6rem;font-weight:800">V</button>
@@ -199,7 +194,7 @@
</span>
<!-- flask tools -->
<span id="ctrl-chem-flask" style="display:none">
<button class="zoom-btn" onclick="flaskSim && flaskSim.dropMetal()" title="Бросить металл" style="font-size:.65rem;font-weight:800"> Металл</button>
<button class="zoom-btn" onclick="flaskSim && flaskSim.dropMetal()" title="Бросить металл" style="font-size:.65rem;font-weight:800"><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> Металл</button>
<button class="zoom-btn" id="flask-flame-btn" onclick="flaskToggleFlame()" title="Поджечь H₂" style="font-size:.75rem"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M12 2c.5 3.5-1.5 6-1.5 6 1 1.5 3 2 3 5a4 4 0 01-8 0c0-2 .5-3 1.5-4.5C8.5 6.5 7 4.5 7 4.5S9.5 2 12 2z"/></svg></button>
<button class="zoom-btn" id="flask-pause-btn" onclick="flaskTogglePause()" title="Пауза" style="font-size:.68rem;font-weight:800;font-family:Manrope,sans-serif"><svg class="ic" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg></button>
</span>
@@ -224,7 +219,7 @@
<span id="ctrl-dyn-sb" style="display:contents">
<button class="zoom-btn sb-tool-btn active" id="sbt-box" onclick="sbTool('box',this)" style="font-size:.65rem;font-weight:800"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/></svg> Блок</button>
<button class="zoom-btn sb-tool-btn" id="sbt-ball" onclick="sbTool('ball',this)" style="font-size:.65rem;font-weight:800"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg> Шар</button>
<button class="zoom-btn sb-tool-btn" id="sbt-spring" onclick="sbTool('spring',this)" style="font-size:.65rem;font-weight:800"> Пружина</button>
<button class="zoom-btn sb-tool-btn" id="sbt-spring" onclick="sbTool('spring',this)" style="font-size:.65rem;font-weight:800"><svg class="ic" viewBox="0 0 24 24" fill="none"><path d="M3 12 L6 8 L9 16 L12 8 L15 16 L18 8 L21 12"/></svg> Пружина</button>
<button class="zoom-btn sb-tool-btn" id="sbt-rope" onclick="sbTool('rope',this)" style="font-size:.65rem;font-weight:800">— Нить</button>
<button class="zoom-btn sb-tool-btn" id="sbt-anchor" onclick="sbTool('anchor',this)" style="font-size:.65rem;font-weight:800"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2 2 12 12 22 22 12Z"/></svg> Якорь</button>
<button class="zoom-btn sb-tool-btn" id="sbt-erase" onclick="sbTool('erase',this)" style="font-size:.65rem;font-weight:800"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Ластик</button>
@@ -456,156 +451,204 @@
</div><!-- /#sim-graph -->
<!-- ── MAGNETIC sim body ── -->
<div id="sim-mag" class="sim-proj-wrap" style="display:none">
<!-- ══ ЭЛЕКТРОМАГНИТНЫЕ ПОЛЯ (EMField) ══
replaces sim-mag + sim-coulomb -->
<div id="sim-emfield" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<div class="proj-panel" style="width:248px;gap:0">
<div class="proj-panel" style="width:258px;gap:0">
<!-- Mode -->
<div class="gp-section-title" style="margin-bottom:8px">Режим добавления</div>
<div style="display:flex;gap:6px;margin-bottom:12px">
<button id="mag-mode-out" class="mag-mode-btn active" onclick="magMode('out')">
<span style="font-size:1.2rem;font-weight:900;color:#06D6E0"></span>
Ток на нас
</button>
<button id="mag-mode-in" class="mag-mode-btn" onclick="magMode('in')">
<span style="font-size:1.1rem;font-weight:900;color:#F15BB5">×</span>
Ток от нас
</button>
<!-- Mode tabs -->
<div style="display:flex;gap:4px;margin-bottom:12px">
<button id="em-tab-E" class="mag-mode-btn active" onclick="emSwitchMode('E')" style="flex:1;font-size:.72rem">Электрическое</button>
<button id="em-tab-B" class="mag-mode-btn" onclick="emSwitchMode('B')" style="flex:1;font-size:.72rem">Магнитное</button>
<button id="em-tab-combined" class="mag-mode-btn" onclick="emSwitchMode('combined')" style="flex:1;font-size:.72rem">Комбо</button>
</div>
<!-- Current -->
<div class="param-block">
<div class="param-header">
<span class="param-name">Сила тока I</span>
<span class="param-val" id="m-curI">6 А</span>
<!-- E controls -->
<div id="em-ctrl-E">
<div class="gp-section-title" style="margin-bottom:6px">Заряд</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="mag-mode-btn active" id="em-sign-pos" onclick="emSign(1)" style="flex:1">
<span style="font-size:1.2rem;font-weight:900;color:#EF476F">+</span> Полож.
</button>
<button class="mag-mode-btn" id="em-sign-neg" onclick="emSign(-1)" style="flex:1">
<span style="font-size:1.2rem;font-weight:900;color:#4CC9F0">&#8722;</span> Отриц.
</button>
</div>
<input type="range" class="param-slider" id="sl-curI" min="1" max="20" value="6" oninput="magCurrentChange()">
</div>
<!-- Layers -->
<div class="gp-section-title" style="margin-top:4px;margin-bottom:8px">Визуализация</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:10px">
<label class="tri-layer-row active" id="ml-colormap" onclick="magLayer('colormap',this)">
<span class="tri-dot" style="background:linear-gradient(90deg,#9B5DE5,#06D6E0,#F15BB5)"></span>
<span class="tri-layer-name">Карта поля</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.4)">hue = направление</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="content:'';display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row active" id="ml-fieldlines" onclick="magLayer('fieldlines',this)">
<span class="tri-dot" style="background:var(--cyan);box-shadow:0 0 5px var(--cyan)"></span>
<span class="tri-layer-name">Силовые линии</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.4)">+ стрелки</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="content:'';display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row" id="ml-vectors" onclick="magLayer('vectors',this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Векторное поле</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.4)">сетка стрелок</span>
<div class="gp-section-title" style="margin-bottom:6px">Слои E</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:10px">
<label class="tri-layer-row active" onclick="emLayer('E','colormap',this)">
<span class="tri-dot" style="background:linear-gradient(90deg,#EF476F,#9B5DE5,#4CC9F0)"></span>
<span class="tri-layer-name">Карта потенциала</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.4)">V</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row active" onclick="emLayer('E','fieldlines',this)">
<span class="tri-dot" style="background:rgba(255,255,255,0.8)"></span>
<span class="tri-layer-name">Линии поля E</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.4)">E</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row" onclick="emLayer('E','vectors',this)">
<span class="tri-dot" style="background:rgba(255,255,255,0.4)"></span>
<span class="tri-layer-name">Векторы E</span>
<span class="tri-toggle"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px"></span></span>
</label>
<label class="tri-layer-row active" onclick="emLayer('E','equipotentials',this)">
<span class="tri-dot" style="background:rgba(255,255,255,0.5)"></span>
<span class="tri-layer-name">Эквипотенциали</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.3)">V=const</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row" onclick="emLayer('E','forces',this)">
<span class="tri-dot" style="background:#FFD166;box-shadow:0 0 5px #FFD166"></span>
<span class="tri-layer-name">Силы Кулона</span>
<span class="tri-layer-hint" style="color:#FFD166">F</span>
<span class="tri-toggle"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px"></span></span>
</label>
</div>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты E</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip" onclick="emPresetE('dipole')">Диполь &#177;</button>
<button class="proj-preset-chip" onclick="emPresetE('equal')">Два + заряда</button>
<button class="proj-preset-chip" onclick="emPresetE('quadrupole')">Квадруполь</button>
<button class="proj-preset-chip" onclick="emPresetE('ring')">Кольцо</button>
</div>
</div><!-- /#em-ctrl-E -->
<!-- B controls -->
<div id="em-ctrl-B" style="display:none">
<div class="gp-section-title" style="margin-bottom:6px">Провод</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="mag-mode-btn active" id="em-dir-out" onclick="emWireDir('out')" style="flex:1">
<span style="font-size:1.2rem;font-weight:900;color:#06D6E0">&#8226;</span> Ток на нас
</button>
<button class="mag-mode-btn" id="em-dir-in" onclick="emWireDir('in')" style="flex:1">
<span style="font-size:1.1rem;font-weight:900;color:#F15BB5">&#215;</span> Ток от нас
</button>
</div>
<div class="param-block" style="margin-bottom:10px">
<div class="param-header">
<span class="param-name">Сила тока I</span>
<span class="param-val" id="em-curI-val">6 А</span>
</div>
<input type="range" class="param-slider" id="sl-emI" min="1" max="20" value="6" oninput="emCurrentChange()">
</div>
<div class="gp-section-title" style="margin-bottom:6px">Слои B</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:10px">
<label class="tri-layer-row active" onclick="emLayer('B','colormap',this)">
<span class="tri-dot" style="background:linear-gradient(90deg,#9B5DE5,#06D6E0,#F15BB5)"></span>
<span class="tri-layer-name">Карта поля B</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.4)">hue=угол</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row active" onclick="emLayer('B','fieldlines',this)">
<span class="tri-dot" style="background:var(--cyan);box-shadow:0 0 5px var(--cyan)"></span>
<span class="tri-layer-name">Силовые линии B</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,.4)">+ стрелки</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row" onclick="emLayer('B','vectors',this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Векторное поле B</span>
<span class="tri-toggle"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px"></span></span>
</label>
</div>
<div class="gp-section-title" style="margin-bottom:6px">Проводник в поле</div>
<label class="tri-layer-row" id="em-cond-row" onclick="emCondToggle(this)" style="margin-bottom:6px">
<span class="tri-dot" style="background:#fbbf24;box-shadow:0 0 5px #fbbf24"></span>
<span class="tri-layer-name">Проводник (Ампер)</span>
<span class="tri-layer-hint" style="color:#fbbf24">F=IL&#215;B</span>
<span class="tri-toggle"></span>
</label>
<div class="param-block" id="em-cond-I-block" style="display:none;margin-bottom:10px">
<div class="param-header">
<span class="param-name">Ток проводника I&#42;</span>
<span class="param-val" id="em-condI-val">8 А</span>
</div>
<input type="range" class="param-slider" id="sl-emCondI" min="1" max="20" value="8" oninput="emCondCurrentChange()">
</div>
<div class="gp-section-title" style="margin-bottom:6px">Магнитный поток</div>
<label class="tri-layer-row" id="em-flux-row" onclick="emFluxToggle(this)" style="margin-bottom:10px">
<span class="tri-dot" style="background:#34d399;box-shadow:0 0 5px #34d399"></span>
<span class="tri-layer-name">Индикатор потока</span>
<span class="tri-layer-hint" style="color:#34d399">&#934;=B&#183;S</span>
<span class="tri-toggle"></span>
</label>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты B</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip" onclick="emPresetB('single')">Один провод</button>
<button class="proj-preset-chip" onclick="emPresetB('parallel')">Параллельные</button>
<button class="proj-preset-chip" onclick="emPresetB('anti')">Антипарал.</button>
<button class="proj-preset-chip" onclick="emPresetB('solenoid')">Соленоид</button>
<button class="proj-preset-chip" onclick="emPresetB('quadrupole')">Квадруполь</button>
<button class="proj-preset-chip" onclick="emPresetB('ring')">Кольцо</button>
</div>
</div><!-- /#em-ctrl-B -->
<!-- combined extra -->
<div id="em-ctrl-combined" style="display:none">
<div class="gp-section-title" style="margin-bottom:6px">Тип добавляемого</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="mag-mode-btn active" id="em-add-charge" onclick="emAddTypeSwitch('charge')" style="flex:1;font-size:.72rem">Заряд</button>
<button class="mag-mode-btn" id="em-add-wire" onclick="emAddTypeSwitch('wire')" style="flex:1;font-size:.72rem">Провод</button>
</div>
</div>
<!-- Particle -->
<div class="gp-section-title" style="margin-bottom:8px">Частица</div>
<label class="tri-layer-row" id="ml-particle" onclick="magParticle(this)" style="margin-bottom:10px">
<div class="gp-section-title" style="margin-bottom:6px">Частица</div>
<label class="tri-layer-row" id="em-particle-row" onclick="emParticle(this)" style="margin-bottom:10px">
<span class="tri-dot" style="background:#ffff50;box-shadow:0 0 5px #ffff50"></span>
<span class="tri-layer-name">Заряженная частица</span>
<span class="tri-layer-hint" style="color:#ffff50">Сила Лоренца</span>
<span class="tri-toggle" id="ml-particle-toggle"></span>
<span class="tri-layer-hint" style="color:#ffff50">Лоренц</span>
<span class="tri-toggle"></span>
</label>
<!-- Conductor -->
<div class="gp-section-title" style="margin-bottom:8px">Проводник в поле</div>
<label class="tri-layer-row" id="ml-cond" onclick="magCondToggle(this)" style="margin-bottom:6px">
<span class="tri-dot" style="background:#fbbf24;box-shadow:0 0 5px #fbbf24"></span>
<span class="tri-layer-name">Проводник (Ампер)</span>
<span class="tri-layer-hint" style="color:#fbbf24">F = I·L×B</span>
<span class="tri-toggle" id="ml-cond-toggle"></span>
</label>
<div class="param-block" id="cond-I-block" style="display:none;margin-bottom:10px">
<div class="param-header">
<span class="param-name">Ток проводника I꜀</span>
<span class="param-val" id="m-condI">8 А</span>
</div>
<input type="range" class="param-slider" id="sl-condI" min="1" max="20" value="8" oninput="magCondCurrentChange()">
</div>
<!-- Flux -->
<div class="gp-section-title" style="margin-bottom:8px">Магнитный поток</div>
<label class="tri-layer-row" id="ml-flux" onclick="magFluxToggle(this)" style="margin-bottom:10px">
<span class="tri-dot" style="background:#34d399;box-shadow:0 0 5px #34d399"></span>
<span class="tri-layer-name">Индикатор потока</span>
<span class="tri-layer-hint" style="color:#34d399">Φ = B·S</span>
<span class="tri-toggle" id="ml-flux-toggle"></span>
</label>
<!-- Presets -->
<div class="gp-section-title" style="margin-bottom:8px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip" onclick="mSim && mSim.preset('single')">Один провод</button>
<button class="proj-preset-chip" onclick="mSim && mSim.preset('parallel')">Параллельные <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="proj-preset-chip" onclick="mSim && mSim.preset('anti')">Антипараллельные <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></button>
<button class="proj-preset-chip" onclick="mSim && mSim.preset('solenoid')">Соленоид</button>
<button class="proj-preset-chip" onclick="mSim && mSim.preset('quadrupole')">Квадруполь</button>
<button class="proj-preset-chip" onclick="mSim && mSim.preset('ring')">Кольцо</button>
<button class="proj-preset-chip" onclick="mSim && mSim.preset('dipole')">Диполь</button>
</div>
<!-- Stats -->
<div style="margin-top:auto;padding-top:6px;display:flex;flex-direction:column;gap:5px">
<div class="tri-stats-grid" style="grid-template-columns:1fr 1fr">
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Провода •</div>
<div class="tri-stat-v" id="ms-out" style="text-align:center;color:var(--cyan)">0</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Провода ×</div>
<div class="tri-stat-v" id="ms-in" style="text-align:center;color:var(--pink)">0</div>
<div class="tri-stats-grid" style="grid-template-columns:auto 1fr">
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Зарядов</div>
<div class="tri-stat-v" id="embar-charges" style="color:#EF476F">0</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Проводов</div>
<div class="tri-stat-v" id="embar-wires" style="color:var(--cyan)">0</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Курсор |E|</div>
<div class="tri-stat-v" id="embar-curE" style="color:rgba(255,255,255,0.6)">&#8212;</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Курсор V</div>
<div class="tri-stat-v" id="embar-curV" style="color:rgba(255,255,255,0.5)">&#8212;</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Курсор |B|</div>
<div class="tri-stat-v" id="embar-curB" style="color:var(--cyan)">&#8212;</div>
</div>
<div style="font-size:0.68rem;color:var(--text-3);text-align:center;line-height:1.6;margin-top:4px">
Клик добавить &nbsp;·&nbsp; ПКМ / 2×клик — удалить<br>
Перетащи провод для перемещения
Клик &#8212; добавить &nbsp;&#183;&nbsp; ПКМ / 2&#215;клик &#8212; удалить<br>
Перетащи источник для перемещения
</div>
</div>
</div><!-- /.proj-panel -->
<div class="proj-canvas-outer">
<canvas id="mag-canvas"></canvas>
<canvas id="emfield-canvas" style="display:block;position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair"></canvas>
</div>
</div><!-- /.sim-body-wrap -->
<div class="proj-stats-bar">
<div class="pstat">
<div class="pstat-label">Проводов</div>
<div class="pstat-val" id="mbar-total">0</div>
</div>
<div class="pstat">
<div class="pstat-label">• Ток на нас</div>
<div class="pstat-val" id="mbar-out" style="color:var(--cyan)">0</div>
</div>
<div class="pstat">
<div class="pstat-label">× Ток от нас</div>
<div class="pstat-val" id="mbar-in" style="color:var(--pink)">0</div>
</div>
<div class="pstat">
<div class="pstat-label">Ток I</div>
<div class="pstat-val" id="mbar-I">6 А</div>
</div>
<div class="pstat">
<div class="pstat-label">Частица</div>
<div class="pstat-val" id="mbar-particle">выкл</div>
</div>
<div class="pstat">
<div class="pstat-label">Сила Ампера</div>
<div class="pstat-val" id="mbar-ampere" style="color:#fbbf24"></div>
</div>
<div class="pstat">
<div class="pstat-label">Поток Φ</div>
<div class="pstat-val" id="mbar-flux" style="color:#34d399"></div>
</div>
<div class="pstat"><div class="pstat-label">Зарядов</div><div class="pstat-val" id="embar-charges-bar" style="color:#EF476F">0</div></div>
<div class="pstat"><div class="pstat-label">Проводов</div><div class="pstat-val" id="embar-wires-bar" style="color:var(--cyan)">0</div></div>
<div class="pstat"><div class="pstat-label">Частица</div><div class="pstat-val" id="embar-particle">выкл</div></div>
<div class="pstat"><div class="pstat-label">|E| курсора</div><div class="pstat-val" id="embar-curE-bar" style="color:#EF476F">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">|B| курсора</div><div class="pstat-val" id="embar-curB-bar" style="color:var(--cyan)">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">Сила Ампера</div><div class="pstat-val" id="embar-ampere" style="color:#fbbf24">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">Поток &#934;</div><div class="pstat-val" id="embar-flux" style="color:#34d399">&#8212;</div></div>
</div>
</div><!-- /#sim-mag -->
</div><!-- /#sim-emfield -->
<!-- ── TRIANGLE sim body ── -->
<div id="sim-tri" class="sim-proj-wrap" style="display:none">
@@ -1045,94 +1088,7 @@
<!-- ══════════════════════════════════════════════
ЗАКОН КУЛОНА
══════════════════════════════════════════════ -->
<div id="sim-coulomb" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<div class="proj-panel" style="width:240px;gap:0">
<div class="gp-section-title" style="margin-bottom:8px">Знак заряда</div>
<div style="display:flex;gap:6px;margin-bottom:12px">
<button class="mag-mode-btn active" id="cbtn-pos" onclick="coulombSign(1)" style="flex:1" title="Добавить положительный заряд">
<span style="font-size:1.3rem;font-weight:900;color:#EF476F">+</span> Положит.
</button>
<button class="mag-mode-btn" id="cbtn-neg" onclick="coulombSign(-1)" style="flex:1" title="Добавить отрицательный заряд">
<span style="font-size:1.3rem;font-weight:900;color:#4CC9F0"></span> Отрицат.
</button>
</div>
<div class="gp-section-title" style="margin-bottom:8px">Слои</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:10px">
<label class="tri-layer-row active" id="cl-colormap" onclick="coulombLayer('colormap',this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Карта потенциала</span>
<span class="tri-layer-hint" style="color:var(--violet)">V</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row active" id="cl-fieldlines" onclick="coulombLayer('fieldlines',this)">
<span class="tri-dot" style="background:rgba(255,255,255,0.8);box-shadow:0 0 5px rgba(255,255,255,0.6)"></span>
<span class="tri-layer-name">Линии поля</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,0.5)">E</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row" id="cl-vectors" onclick="coulombLayer('vectors',this)">
<span class="tri-dot" style="background:rgba(255,255,255,0.4)"></span>
<span class="tri-layer-name">Векторы E</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,0.3)"><svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></span>
<span class="tri-toggle"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px"></span></span>
</label>
<label class="tri-layer-row active" id="cl-equipotentials" onclick="coulombLayer('equipotentials',this)">
<span class="tri-dot" style="background:rgba(255,255,255,0.5)"></span>
<span class="tri-layer-name">Эквипотенциали</span>
<span class="tri-layer-hint" style="color:rgba(255,255,255,0.3)">V=const</span>
<span class="tri-toggle" style="background:var(--violet)"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:14px"></span></span>
</label>
<label class="tri-layer-row" id="cl-forces" onclick="coulombLayer('forces',this)">
<span class="tri-dot" style="background:#FFD166;box-shadow:0 0 5px #FFD166"></span>
<span class="tri-layer-name">Силы Кулона</span>
<span class="tri-layer-hint" style="color:#FFD166">F</span>
<span class="tri-toggle"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px"></span></span>
</label>
</div>
<div class="gp-section-title" style="margin-bottom:8px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip" onclick="coulombPreset('dipole')">Диполь ±</button>
<button class="proj-preset-chip" onclick="coulombPreset('equal')">Два + заряда</button>
<button class="proj-preset-chip" onclick="coulombPreset('quadrupole')">Квадруполь</button>
<button class="proj-preset-chip" onclick="coulombPreset('ring')">Кольцо</button>
</div>
<div style="margin-top:auto;display:flex;flex-direction:column;gap:5px">
<div class="tri-stats-grid" style="grid-template-columns:auto 1fr">
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Зарядов</div>
<div class="tri-stat-v" id="cs-total" style="color:var(--violet)">0</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Курсор E</div>
<div class="tri-stat-v" id="cs-curE" style="color:rgba(255,255,255,0.6)"></div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Курсор V</div>
<div class="tri-stat-v" id="cs-curV" style="color:rgba(255,255,255,0.5)"></div>
</div>
<div style="font-size:0.68rem;color:var(--text-3);text-align:center;line-height:1.6;margin-top:4px">
Клик — добавить &nbsp;·&nbsp; ПКМ — удалить<br>
Перетащи заряд для перемещения
</div>
</div>
</div><!-- /.proj-panel -->
<div class="proj-canvas-outer">
<canvas id="coulomb-canvas" style="display:block;position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair"></canvas>
</div>
</div><!-- /.sim-body-wrap -->
<div class="proj-stats-bar">
<div class="pstat"><div class="pstat-label">Зарядов</div><div class="pstat-val" id="csbar-total">0</div></div>
<div class="pstat"><div class="pstat-label">+ Позитивных</div><div class="pstat-val" id="csbar-pos" style="color:#EF476F">0</div></div>
<div class="pstat"><div class="pstat-label"> Негативных</div><div class="pstat-val" id="csbar-neg" style="color:#4CC9F0">0</div></div>
<div class="pstat"><div class="pstat-label">max |E|</div><div class="pstat-val" id="csbar-maxE"></div></div>
<div class="pstat"><div class="pstat-label">E курсора</div><div class="pstat-val" id="csbar-curE" style="color:rgba(255,255,255,0.7)"></div></div>
</div>
</div><!-- /#sim-coulomb -->
<!-- sim-coulomb removed: merged into sim-emfield -->
<!-- ══════════════════════════════════════════════
ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
@@ -1310,7 +1266,7 @@
<div style="display:flex;gap:6px;margin-bottom:10px;margin-top:2px">
<button class="proj-preset-chip" style="flex:1;background:rgba(239,71,111,0.18);border-color:rgba(239,71,111,0.4)"
onclick="flaskSim && flaskSim.dropMetal()"> Бросить металл</button>
onclick="flaskSim && flaskSim.dropMetal()"><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> Бросить металл</button>
<button class="proj-preset-chip" id="flask-flame-panel" style="flex:0 0 auto"
onclick="flaskToggleFlame()"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M12 2c.5 3.5-1.5 6-1.5 6 1 1.5 3 2 3 5a4 4 0 01-8 0c0-2 .5-3 1.5-4.5C8.5 6.5 7 4.5 7 4.5S9.5 2 12 2z"/></svg></button>
</div>
@@ -1454,7 +1410,7 @@
<button class="mag-mode-btn sb-panel-tool" id="sbpt-erase" onclick="sbTool('erase',this)" style="flex:1;font-size:.72rem"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div style="display:flex;gap:5px;margin-bottom:10px">
<button class="mag-mode-btn sb-panel-tool" id="sbpt-spring" onclick="sbTool('spring',this)" style="flex:1;font-size:.72rem"> Пружина</button>
<button class="mag-mode-btn sb-panel-tool" id="sbpt-spring" onclick="sbTool('spring',this)" style="flex:1;font-size:.72rem"><svg class="ic" viewBox="0 0 24 24" fill="none"><path d="M3 12 L6 8 L9 16 L12 8 L15 16 L18 8 L21 12"/></svg> Пружина</button>
<button class="mag-mode-btn sb-panel-tool" id="sbpt-rope" onclick="sbTool('rope',this)" style="flex:1;font-size:.72rem">— Нить</button>
<button class="mag-mode-btn sb-panel-tool" id="sbpt-anchor" onclick="sbTool('anchor',this)" style="flex:1;font-size:.72rem"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2 2 12 12 22 22 12Z"/></svg> Якорь</button>
</div>
@@ -1529,17 +1485,17 @@
<button class="proj-preset-chip" onclick="sbPreset('ramp_slide')">Горка</button>
<button class="proj-preset-chip" onclick="sbPreset('ramp_angle')"><svg class="ic" viewBox="0 0 24 24"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg> Крутой спуск</button>
<button class="proj-preset-chip" onclick="sbPreset('ramp_friction')"><svg class="ic" viewBox="0 0 24 24"><rect width="20" height="5" x="2" y="3" rx="1"/><rect width="8" height="5" x="2" y="11" rx="1"/><rect width="8" height="5" x="14" y="11" rx="1"/><rect width="20" height="5" x="2" y="19" rx="1"/></svg> Трение на горке</button>
<button class="proj-preset-chip" onclick="sbPreset('spring_bounce')"> Пружина</button>
<button class="proj-preset-chip" onclick="sbPreset('spring_chain')"> Цепочка</button>
<button class="proj-preset-chip" onclick="sbPreset('spring_bounce')"><svg class="ic" viewBox="0 0 24 24" fill="none"><path d="M3 12 L6 8 L9 16 L12 8 L15 16 L18 8 L21 12"/></svg> Пружина</button>
<button class="proj-preset-chip" onclick="sbPreset('spring_chain')"><svg class="ic" viewBox="0 0 24 24" fill="none"><path d="M3 12 L6 8 L9 16 L12 8 L15 16 L18 8 L21 12"/></svg> Цепочка</button>
<button class="proj-preset-chip" onclick="sbPreset('pendulum')">⬤ Маятник</button>
<button class="proj-preset-chip" onclick="sbPreset('atwood')"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93 17.66 6.34M21 12h-2M19.07 19.07l-1.41-1.41M12 21v-2M6.34 17.66 4.93 19.07M3 12h2M4.93 4.93l1.41 1.41M12 3v2"/><circle cx="12" cy="12" r="7"/></svg> Машина Атвуда</button>
<button class="proj-preset-chip" onclick="sbPreset('two_body')"><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="21" x2="12" y2="3"/><polyline points="7 8 12 3 17 8"/><polyline points="17 16 12 21 7 16"/></svg> Два тела</button>
<button class="proj-preset-chip" onclick="sbPreset('elastic_collision')"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Упругий удар</button>
<button class="proj-preset-chip" onclick="sbPreset('inelastic_collision')"><svg class="ic" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></svg> Неупругий</button>
<button class="proj-preset-chip" onclick="sbPreset('newton_cradle')"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93 17.66 6.34M21 12h-2M19.07 19.07l-1.41-1.41M12 21v-2M6.34 17.66 4.93 19.07M3 12h2M4.93 4.93l1.41 1.41M12 3v2"/><circle cx="12" cy="12" r="7"/></svg> Колыбель Ньютона</button>
<button class="proj-preset-chip" onclick="sbPreset('harmonic_oscillator')"> Осциллятор</button>
<button class="proj-preset-chip" onclick="sbPreset('harmonic_oscillator')"><svg class="ic" viewBox="0 0 24 24" fill="none"><path d="M3 12 L6 8 L9 16 L12 8 L15 16 L18 8 L21 12"/></svg> Осциллятор</button>
<button class="proj-preset-chip" onclick="sbPreset('double_pendulum')">⬤⬤ Двойной маятник</button>
<button class="proj-preset-chip" onclick="sbPreset('coupled_oscillators')">〜〜 Связанные</button>
<button class="proj-preset-chip" onclick="sbPreset('coupled_oscillators')"><svg class="ic" viewBox="0 0 24 24" fill="none"><path d="M3 12 L6 8 L9 16 L12 8 L15 16 L18 8 L21 12"/></svg> Связанные</button>
<button class="proj-preset-chip" onclick="sbPreset('stacked_boxes')"><svg class="ic" viewBox="0 0 24 24"><rect width="20" height="5" x="2" y="3" rx="1"/><rect width="8" height="5" x="2" y="11" rx="1"/><rect width="8" height="5" x="14" y="11" rx="1"/><rect width="20" height="5" x="2" y="19" rx="1"/></svg> Стопка</button>
<button class="proj-preset-chip" onclick="sbPreset('pulley_ramp')"><svg class="ic" viewBox="0 0 24 24"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg> Горка+блок</button>
<button class="proj-preset-chip" onclick="sbPreset('circular_motion')">⭕ Круговое</button>
@@ -1578,7 +1534,7 @@
<div class="sim-body-wrap">
<!-- controls panel -->
<div class="proj-panel">
<div class="proj-panel" style="width:240px">
<div class="gp-section-title">Параметры</div>
<div class="param-block">
@@ -1694,6 +1650,58 @@
</div>
<div style="font-size:.6rem;color:var(--text-3);margin-top:4px">Сохрани траекторию, измени параметры и сравни</div>
<!-- Feature 1: Target mode -->
<div class="gp-section-title" style="margin-top:10px">Режим целей</div>
<button id="proj-target-btn" class="proj-preset-chip" onclick="projToggleTargetMode()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<span>Режим целей: Выкл</span>
</button>
<div id="proj-target-panel" style="display:none;margin-top:6px">
<button class="proj-preset-chip" onclick="projGenTargets()" style="width:100%;text-align:center">
<svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Новые цели
</button>
<div id="proj-target-hud" style="font-size:.72rem;font-weight:700;color:#FFD166;margin-top:6px;text-align:center">Цели: 0/3 Попыток: 0</div>
</div>
<!-- Feature 2: Graphs toggle -->
<div class="gp-section-title" style="margin-top:10px">Графики</div>
<button id="proj-graphs-btn" class="proj-preset-chip" onclick="projToggleGraphs()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
<svg class="ic" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
<span>x(t), y(t), vx(t), vy(t)</span>
</button>
<!-- Feature 3: Dual throw -->
<div class="gp-section-title" style="margin-top:10px">Двойной бросок</div>
<button id="proj-dual-btn" class="proj-preset-chip" onclick="projToggleDual()" style="width:100%;text-align:left;display:flex;align-items:center;gap:6px">
<svg class="ic" viewBox="0 0 24 24"><circle cx="8" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><path d="M8 15V9l10-6"/></svg>
<span>Двойной: Выкл</span>
</button>
<div id="proj-dual-panel" style="display:none;margin-top:6px">
<div style="font-size:.65rem;color:rgba(0,230,255,.8);font-weight:700;margin-bottom:4px">Тело 2 (cyan)</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Скорость v₀₂</span>
<span class="param-val" id="p2-v0" style="color:#00E6FF">25 м/с</span>
</div>
<input type="range" class="param-slider proj-dual-slider" id="sl-p2-v0" min="1" max="100" value="25" oninput="projP2Param()">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Угол θ₂</span>
<span class="param-val" id="p2-angle" style="color:#00E6FF">30°</span>
</div>
<input type="range" class="param-slider proj-dual-slider" id="sl-p2-angle" min="0" max="90" value="30" oninput="projP2Param()">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Высота h₀₂</span>
<span class="param-val" id="p2-h0" style="color:#00E6FF">0 м</span>
</div>
<input type="range" class="param-slider proj-dual-slider" id="sl-p2-h0" min="0" max="50" value="0" oninput="projP2Param()">
</div>
</div>
<!-- LAUNCH BUTTON -->
<div style="margin-top:auto; padding-top:16px; display:flex; flex-direction:column; gap:8px;">
<button class="proj-launch-btn" id="proj-launch-main" onclick="projPlayPause()">
@@ -1718,6 +1726,11 @@
</div><!-- /.sim-body-wrap -->
<!-- graphs panel (Feature 2) -->
<div id="proj-graphs-panel" style="display:none;background:#05050e;border-top:1px solid rgba(255,255,255,.07)">
<canvas id="proj-graphs-canvas" class="proj-graphs-canvas"></canvas>
</div>
<!-- stats bar -->
<div class="proj-stats-bar">
<div class="pstat">
@@ -1748,6 +1761,15 @@
<div class="pstat-label">Δ дальность</div>
<div class="pstat-val" id="ps-loss"></div>
</div>
<!-- dual throw stats (Feature 3) -->
<div class="pstat" id="ps-p2-wrap" style="display:none">
<div class="pstat-label" style="color:rgba(0,230,255,.7)">Дальн.&#8202;2</div>
<div class="pstat-val" id="ps-p2-range" style="color:#00E6FF"></div>
</div>
<div class="pstat" id="ps-p2-tf-wrap" style="display:none">
<div class="pstat-label" style="color:rgba(0,230,255,.7)">Время&#8202;2</div>
<div class="pstat-val" id="ps-p2-tf" style="color:#00E6FF"></div>
</div>
</div>
</div><!-- /#sim-proj -->
@@ -1756,7 +1778,7 @@
<div class="sim-body-wrap">
<!-- controls panel -->
<div class="proj-panel">
<div class="proj-panel" style="width:240px">
<div class="gp-section-title">Параметры</div>
<div class="param-block">
@@ -2415,7 +2437,7 @@
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-zones" checked onchange="mirrorToggle('zones',this.checked)"> Зоны</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer;grid-column:span 2"><input type="checkbox" id="mtog-point" onchange="mirrorSetPointMode(this.checked)"> Точечный объект</label>
</div>
<button onclick="if(mirrorSim)mirrorSim.exportPng()" style="width:100%;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.72rem;cursor:pointer;margin-bottom:8px">📷 Экспорт PNG</button>
<button onclick="if(mirrorSim)mirrorSim.exportPng()" style="width:100%;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.72rem;cursor:pointer;margin-bottom:8px"><svg class="ic" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Экспорт PNG</button>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="mirrorPreset('flat')">Плоское</button>
@@ -3245,11 +3267,11 @@
</button>
<button id="geo-btn-circumcircle" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('circumcircle',this)" title="Описанная окружность — 3 точки треугольника">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke-width="1.5" stroke-dasharray="4,3"/><polygon points="6,18 18,18 12,6" stroke-width="1.5" fill="none"/></svg>
Описанная
Описанная
</button>
<button id="geo-btn-incircle" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('incircle',this)" title="Вписанная окружность — 3 точки треугольника">
<svg viewBox="0 0 24 24" fill="none"><polygon points="4,20 20,20 12,4" stroke-width="1.5" fill="none"/><circle cx="12" cy="15" r="5" stroke-width="1.5" stroke-dasharray="4,3"/></svg>
Вписанная
Вписанная
</button>
</div>
@@ -3362,6 +3384,40 @@
</button>
</div>
<!-- Constrained points for locus -->
<div class="gp-section-title" style="margin-top:4px">Точки на объектах</div>
<div class="geo-tool-grid">
<button id="geo-btn-point_on_segment" class="geo-tool-btn" onclick="geoSetTool('point_on_segment',this)" title="Точка на отрезке — кликни на отрезок, получишь скользящую точку">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="20" x2="21" y2="4"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>
На отрезке
</button>
<button id="geo-btn-point_on_circle" class="geo-tool-btn" onclick="geoSetTool('point_on_circle',this)" title="Точка на окружности — кликни на окружность, получишь скользящую точку">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="3" r="2.5" fill="currentColor" stroke="none"/></svg>
На окружности
</button>
</div>
<!-- Measurement + Locus tools -->
<div class="gp-section-title" style="margin-top:4px">Измерения и ГМТ</div>
<div class="geo-tool-grid">
<button id="geo-btn-measure_length" class="geo-tool-btn" onclick="geoSetTool('measure_length',this)" title="Длина отрезка — кликни на отрезок, получишь живой чип">
<svg viewBox="0 0 24 24" fill="none"><line x1="3" y1="12" x2="21" y2="12" stroke-width="2"/><line x1="3" y1="8" x2="3" y2="16" stroke-width="2" stroke-linecap="round"/><line x1="21" y1="8" x2="21" y2="16" stroke-width="2" stroke-linecap="round"/></svg>
Длина
</button>
<button id="geo-btn-measure_angle" class="geo-tool-btn" onclick="geoSetTool('measure_angle',this)" title="Угол — 3 клика: точка A, вершина, точка B">
<svg viewBox="0 0 24 24" fill="none"><path d="M3 20 L20 20" stroke-width="2" stroke-linecap="round"/><path d="M3 20 L14 6" stroke-width="2" stroke-linecap="round"/><path d="M7 20 A10 10 0 0 1 11.5 12" stroke-width="1.5" fill="none"/></svg>
Угол
</button>
<button id="geo-btn-measure_area" class="geo-tool-btn" onclick="geoSetTool('measure_area',this)" title="Площадь многоугольника — кликни на полигон">
<svg viewBox="0 0 24 24" fill="none"><polygon points="12,3 21,9 18,20 6,20 3,9" stroke-width="2" fill="none"/></svg>
Площадь
</button>
<button id="geo-btn-locus" class="geo-tool-btn" onclick="geoSetTool('locus',this)" title="ГМТ — выбери точку-мовер (on_segment/on_circle), затем целевую точку">
<svg viewBox="0 0 24 24" fill="none"><path d="M4 20 Q8 4 12 12 Q16 20 20 6" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="8" cy="14" r="2" fill="currentColor"/></svg>
ГМТ
</button>
</div>
<!-- Display options -->
<div class="gp-section-title" style="margin-top:6px">Параметры</div>
<label class="geo-toggle-row" onclick="geoToggle('showGrid',this)">
@@ -3449,7 +3505,7 @@
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js"></script>
<script src="/js/labs/graph.js"></script>
<script src="/js/labs/magnetic.js"></script>
<script src="/js/labs/emfield.js"></script>
<script src="/js/labs/triangle.js"></script>
<script src="/js/labs/projectile.js"></script>
<script src="/js/labs/collision.js"></script>
@@ -3457,7 +3513,7 @@
<script src="/js/labs/states.js"></script>
<script src="/js/labs/brownian.js"></script>
<script src="/js/labs/diffusion.js"></script>
<script src="/js/labs/coulomb.js"></script>
<!-- coulomb.js removed: merged into emfield.js -->
<script src="/js/labs/circuit.js"></script>
<script src="/js/labs/reactions.js"></script>
<script src="/js/labs/flask.js"></script>