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:
@@ -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%;
|
||||
|
||||
@@ -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: 'Тонкая линза' },
|
||||
|
||||
@@ -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; // 0–30 red-orange
|
||||
else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220–240 blue
|
||||
else hue = 0;
|
||||
|
||||
const sat = 80;
|
||||
const lit = Math.tanh(mag / 3000) * 40
|
||||
+ Math.tanh(Math.abs(v) / 50000) * 25;
|
||||
|
||||
const [r, g, b] = this._hslToRgb(hue, sat, lit);
|
||||
const idx = (py * imgW + px) * 4;
|
||||
d[idx] = r;
|
||||
d[idx + 1] = g;
|
||||
d[idx + 2] = b;
|
||||
d[idx + 3] = 200;
|
||||
}
|
||||
}
|
||||
|
||||
this._cmCache = { img, imgW, imgH, STEP };
|
||||
this._cmDirty = false;
|
||||
}
|
||||
|
||||
const { img, imgW, imgH } = this._cmCache;
|
||||
|
||||
/* draw scaled up */
|
||||
const oc = document.createElement('canvas');
|
||||
oc.width = imgW;
|
||||
oc.height = imgH;
|
||||
oc.getContext('2d').putImageData(img, 0, 0);
|
||||
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'medium';
|
||||
ctx.drawImage(oc, 0, 0, W, H);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Equipotentials ─────────────────────────────────────── */
|
||||
_drawEquipotentials(ctx) {
|
||||
const W = this.W, H = this.H;
|
||||
const GRID = 8;
|
||||
const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000];
|
||||
|
||||
const cols = Math.ceil(W / GRID) + 1;
|
||||
const rows = Math.ceil(H / GRID) + 1;
|
||||
|
||||
/* build V grid */
|
||||
const vGrid = new Float64Array(cols * rows);
|
||||
for (let r = 0; r < rows; r++)
|
||||
for (let c = 0; c < cols; c++)
|
||||
vGrid[r * cols + c] = this._fieldAt(c * GRID, r * GRID).v;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
|
||||
for (const level of LEVELS) {
|
||||
for (let r = 0; r < rows - 1; r++) {
|
||||
for (let c = 0; c < cols - 1; c++) {
|
||||
const v00 = vGrid[ r * cols + c ];
|
||||
const v10 = vGrid[ r * cols + c + 1];
|
||||
const v01 = vGrid[(r + 1) * cols + c ];
|
||||
const v11 = vGrid[(r + 1) * cols + c + 1];
|
||||
|
||||
/* crossed edges: top, right, bottom, left */
|
||||
const pts = [];
|
||||
const interp = (va, vb, xa, ya, xb, yb) => {
|
||||
const t = (level - va) / (vb - va);
|
||||
return [xa + t * (xb - xa), ya + t * (yb - ya)];
|
||||
};
|
||||
|
||||
if ((v00 - level) * (v10 - level) < 0)
|
||||
pts.push(interp(v00, v10, c * GRID, r * GRID, (c + 1) * GRID, r * GRID));
|
||||
if ((v10 - level) * (v11 - level) < 0)
|
||||
pts.push(interp(v10, v11, (c + 1) * GRID, r * GRID, (c + 1) * GRID, (r + 1) * GRID));
|
||||
if ((v01 - level) * (v11 - level) < 0)
|
||||
pts.push(interp(v01, v11, c * GRID, (r + 1) * GRID, (c + 1) * GRID, (r + 1) * GRID));
|
||||
if ((v00 - level) * (v01 - level) < 0)
|
||||
pts.push(interp(v00, v01, c * GRID, r * GRID, c * GRID, (r + 1) * GRID));
|
||||
|
||||
if (pts.length >= 2) {
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
ctx.lineTo(pts[1][0], pts[1][1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Vector arrows ──────────────────────────────────────── */
|
||||
_drawVectors(ctx) {
|
||||
const GRID = 45;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = GRID / 2; x < this.W; x += GRID) {
|
||||
for (let y = GRID / 2; y < this.H; y += GRID) {
|
||||
const { ex, ey, mag } = this._fieldAt(x, y);
|
||||
if (mag < 1e-6) continue;
|
||||
const len = Math.tanh(mag / 8000) * 18;
|
||||
const nx = ex / mag, ny = ey / mag;
|
||||
const x2 = x + nx * len, y2 = y + ny * len;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
/* arrowhead */
|
||||
const ax = -ny * 3, ay = nx * 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(x2 - nx * 6 + ax, y2 - ny * 6 + ay);
|
||||
ctx.lineTo(x2 - nx * 6 - ax, y2 - ny * 6 - ay);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Field lines ────────────────────────────────────────── */
|
||||
_drawFieldLines(ctx) {
|
||||
const W = this.W, H = this.H, sim = this;
|
||||
const RAYS = 12;
|
||||
const STEP = 2.5;
|
||||
const MAX = 2500;
|
||||
const MARGIN = 5;
|
||||
const HIT_R = 12;
|
||||
const START_R = 18;
|
||||
|
||||
function rkStep(x, y, h) {
|
||||
const f = (px, py) => {
|
||||
const e = sim._fieldAt(px, py);
|
||||
const m = Math.hypot(e.ex, e.ey) || 1e-10;
|
||||
return [e.ex / m, e.ey / m];
|
||||
};
|
||||
const [k1x, k1y] = f(x, y);
|
||||
const [k2x, k2y] = f(x + h * k1x / 2, y + h * k1y / 2);
|
||||
const [k3x, k3y] = f(x + h * k2x / 2, y + h * k2y / 2);
|
||||
const [k4x, k4y] = f(x + h * k3x, y + h * k3y);
|
||||
return [
|
||||
x + h * (k1x + 2 * k2x + 2 * k3x + k4x) / 6,
|
||||
y + h * (k1y + 2 * k2y + 2 * k3y + k4y) / 6,
|
||||
];
|
||||
}
|
||||
|
||||
const traceLine = (startX, startY, dir) => {
|
||||
const pts = [[startX, startY]];
|
||||
let px = startX, py = startY;
|
||||
for (let s = 0; s < MAX; s++) {
|
||||
const [nx, ny] = rkStep(px, py, dir * STEP);
|
||||
if (nx < -MARGIN || nx > W + MARGIN || ny < -MARGIN || ny > H + MARGIN) break;
|
||||
|
||||
/* stop near negative charges */
|
||||
let hitNeg = false;
|
||||
for (const c of sim.charges) {
|
||||
if (c.q < 0 && Math.hypot(nx - c.x, ny - c.y) < HIT_R) { hitNeg = true; break; }
|
||||
}
|
||||
if (hitNeg) break;
|
||||
|
||||
pts.push([nx, ny]);
|
||||
px = nx; py = ny;
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1.2;
|
||||
|
||||
for (const charge of this.charges) {
|
||||
const dir = charge.q > 0 ? 1 : -1;
|
||||
for (let i = 0; i < RAYS; i++) {
|
||||
const angle = (i / RAYS) * Math.PI * 2;
|
||||
const sx = charge.x + START_R * Math.cos(angle);
|
||||
const sy = charge.y + START_R * Math.sin(angle);
|
||||
const pts = traceLine(sx, sy, dir);
|
||||
if (pts.length < 2) continue;
|
||||
|
||||
const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length - 1][0], pts[pts.length - 1][1]);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,0.75)');
|
||||
grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
|
||||
grad.addColorStop(1, 'rgba(255,255,255,0.0)');
|
||||
ctx.strokeStyle = grad;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Force arrows ───────────────────────────────────────── */
|
||||
_drawForceArrows(ctx) {
|
||||
ctx.save();
|
||||
for (let i = 0; i < this.charges.length; i++) {
|
||||
const ci = this.charges[i];
|
||||
let fx = 0, fy = 0;
|
||||
for (let j = 0; j < this.charges.length; j++) {
|
||||
if (i === j) continue;
|
||||
const cj = this.charges[j];
|
||||
const dx = ci.x - cj.x, dy = ci.y - cj.y;
|
||||
const r2 = dx * dx + dy * dy;
|
||||
if (r2 < 1) continue;
|
||||
const r3 = r2 * Math.sqrt(r2);
|
||||
const F = this.K * ci.q * cj.q;
|
||||
fx += F * dx / r3;
|
||||
fy += F * dy / r3;
|
||||
}
|
||||
const mag = Math.hypot(fx, fy);
|
||||
if (mag < 1e-6) continue;
|
||||
|
||||
const len = Math.tanh(mag / 50000) * 55;
|
||||
const nx = fx / mag, ny = fy / mag;
|
||||
const x2 = ci.x + nx * len, y2 = ci.y + ny * len;
|
||||
|
||||
ctx.strokeStyle = '#FFD166';
|
||||
ctx.fillStyle = '#FFD166';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = '#FFD166';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ci.x, ci.y);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
/* arrowhead */
|
||||
const ax = -ny * 5, ay = nx * 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(x2 - nx * 10 + ax, y2 - ny * 10 + ay);
|
||||
ctx.lineTo(x2 - nx * 10 - ax, y2 - ny * 10 - ay);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Draw charges ───────────────────────────────────────── */
|
||||
_drawCharges(ctx) {
|
||||
for (let i = 0; i < this.charges.length; i++) {
|
||||
const c = this.charges[i];
|
||||
const r = 14 + Math.tanh(Math.abs(c.q) / 5) * 4;
|
||||
const pos = c.q > 0;
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0';
|
||||
|
||||
/* hovered outer ring */
|
||||
if (this._hovered === i) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, r + 6, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* body gradient */
|
||||
const grd = ctx.createRadialGradient(c.x - r * 0.3, c.y - r * 0.3, r * 0.1, c.x, c.y, r);
|
||||
if (pos) {
|
||||
grd.addColorStop(0, '#FF7FA3');
|
||||
grd.addColorStop(1, '#EF476F');
|
||||
} else {
|
||||
grd.addColorStop(0, '#90E0FF');
|
||||
grd.addColorStop(1, '#4CC9F0');
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fill();
|
||||
|
||||
/* label */
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(pos ? '+' : '−', c.x, c.y + 1);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cursor E display ───────────────────────────────────── */
|
||||
_drawCursorE(ctx) {
|
||||
const { ex, ey, mag, v } = this._cursorE;
|
||||
const { x, y } = this._mousePos;
|
||||
if (mag < 1e-6) return;
|
||||
|
||||
const nx = ex / mag, ny = ey / mag;
|
||||
const len = 20;
|
||||
const x2 = x + nx * len, y2 = y + ny * len;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
const ax = -ny * 4, ay = nx * 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(x2 - nx * 8 + ax, y2 - ny * 8 + ay);
|
||||
ctx.lineTo(x2 - nx * 8 - ax, y2 - ny * 8 - ay);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
/* text */
|
||||
const eStr = mag >= 1000 ? (mag / 1000).toFixed(1) + 'k' : mag.toFixed(0);
|
||||
const vStr = Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0);
|
||||
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.shadowColor = '#000';
|
||||
ctx.fillText(`|E| = ${eStr}`, x + 6, y - 14);
|
||||
ctx.fillText(`V = ${vStr}`, x + 6, y - 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Hint ───────────────────────────────────────────────── */
|
||||
_drawHint(ctx) {
|
||||
const W = this.W, H = this.H;
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.fillText('Нажмите чтобы добавить заряд', W / 2, H / 2 + 30);
|
||||
|
||||
/* simple circle icon */
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2, H / 2 - 14, 18, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.font = 'bold 22px sans-serif';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.fillText('+', W / 2, H / 2 - 13);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Main draw ──────────────────────────────────────────── */
|
||||
draw() {
|
||||
const ctx = this.ctx, W = this.W, H = this.H;
|
||||
if (!W || !H) return;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
/* 1. background radial gradient */
|
||||
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) / 2);
|
||||
bg.addColorStop(0, '#0D0D1A');
|
||||
bg.addColorStop(1, '#050508');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
/* 2. subtle grid */
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.025)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < W; x += 30) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
|
||||
for (let y = 0; y < H; y += 30) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
if (this.charges.length > 0) {
|
||||
/* 3. colormap */
|
||||
if (this.layers.colormap) this._drawColormap(ctx);
|
||||
/* 4. equipotentials */
|
||||
if (this.layers.equipotentials) this._drawEquipotentials(ctx);
|
||||
/* 5. vectors */
|
||||
if (this.layers.vectors) this._drawVectors(ctx);
|
||||
/* 6. field lines */
|
||||
if (this.layers.fieldlines) this._drawFieldLines(ctx);
|
||||
/* 7. force arrows */
|
||||
if (this.layers.forces) this._drawForceArrows(ctx);
|
||||
}
|
||||
|
||||
/* 8. charges */
|
||||
this._drawCharges(ctx);
|
||||
|
||||
/* 9. cursor E */
|
||||
if (this._cursorE && this._mousePos && this.charges.length > 0)
|
||||
this._drawCursorE(ctx);
|
||||
|
||||
/* 10. hint if empty */
|
||||
if (this.charges.length === 0) this._drawHint(ctx);
|
||||
}
|
||||
|
||||
/* ── Events ─────────────────────────────────────────────── */
|
||||
_bindEvents() {
|
||||
const canvas = this.canvas;
|
||||
|
||||
const pos = e => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const s = e.touches ? e.touches[0] : e;
|
||||
return { x: s.clientX - r.left, y: s.clientY - r.top };
|
||||
};
|
||||
|
||||
const hitIdx = p => {
|
||||
for (let i = this.charges.length - 1; i >= 0; i--)
|
||||
if (Math.hypot(p.x - this.charges[i].x, p.y - this.charges[i].y) < 20) return i;
|
||||
return -1;
|
||||
};
|
||||
|
||||
/* ── mousedown ── */
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
if (e.button !== 0) return;
|
||||
const p = pos(e);
|
||||
const hi = hitIdx(p);
|
||||
this._downPos = p;
|
||||
if (hi >= 0) this._drag = hi;
|
||||
});
|
||||
|
||||
/* ── mousemove ── */
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
const p = pos(e);
|
||||
this._mousePos = p;
|
||||
if (this._drag !== null) {
|
||||
this.charges[this._drag].x = p.x;
|
||||
this.charges[this._drag].y = p.y;
|
||||
this._cmDirty = true;
|
||||
this._cursorE = this._fieldAt(p.x, p.y);
|
||||
this.draw();
|
||||
} else {
|
||||
this._hovered = hitIdx(p);
|
||||
this._cursorE = this.charges.length > 0 ? this._fieldAt(p.x, p.y) : null;
|
||||
this.draw();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── mouseup ── */
|
||||
canvas.addEventListener('mouseup', e => {
|
||||
if (e.button !== 0) return;
|
||||
const p = pos(e);
|
||||
const wasDragging = this._drag !== null;
|
||||
if (wasDragging) {
|
||||
this._drag = null;
|
||||
this._cmDirty = true;
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
return;
|
||||
}
|
||||
/* click (no drag) */
|
||||
const dp = this._downPos || p;
|
||||
const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
|
||||
if (dist < 5) {
|
||||
const hi = hitIdx(p);
|
||||
if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
|
||||
}
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
});
|
||||
|
||||
/* ── contextmenu (remove) ── */
|
||||
canvas.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
const p = pos(e);
|
||||
const hi = hitIdx(p);
|
||||
if (hi >= 0) this.removeCharge(hi);
|
||||
});
|
||||
|
||||
/* ── dblclick (remove) ── */
|
||||
canvas.addEventListener('dblclick', e => {
|
||||
const p = pos(e);
|
||||
const hi = hitIdx(p);
|
||||
if (hi >= 0) this.removeCharge(hi);
|
||||
});
|
||||
|
||||
/* ── mouseleave ── */
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
this._cursorE = null;
|
||||
this._mousePos = null;
|
||||
this._hovered = null;
|
||||
this.draw();
|
||||
});
|
||||
|
||||
/* ── touch support ── */
|
||||
canvas.addEventListener('touchstart', e => {
|
||||
e.preventDefault();
|
||||
const p = pos(e);
|
||||
const hi = hitIdx(p);
|
||||
this._downPos = p;
|
||||
if (hi >= 0) this._drag = hi;
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchmove', e => {
|
||||
e.preventDefault();
|
||||
const p = pos(e);
|
||||
this._mousePos = p;
|
||||
if (this._drag !== null) {
|
||||
this.charges[this._drag].x = p.x;
|
||||
this.charges[this._drag].y = p.y;
|
||||
this._cmDirty = true;
|
||||
this._cursorE = this._fieldAt(p.x, p.y);
|
||||
this.draw();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchend', e => {
|
||||
e.preventDefault();
|
||||
const wasDragging = this._drag !== null;
|
||||
if (wasDragging) {
|
||||
this._drag = null;
|
||||
this._cmDirty = true;
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
return;
|
||||
}
|
||||
const p = pos({ touches: e.changedTouches });
|
||||
const dp = this._downPos || p;
|
||||
const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
|
||||
if (dist < 10) {
|
||||
const hi = hitIdx(p);
|
||||
if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
|
||||
}
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
}, { passive: false });
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── lab UI init ─────────────────────────────────── */
|
||||
var csSim = null;
|
||||
|
||||
function _openCoulomb() {
|
||||
document.getElementById('sim-topbar-title').textContent = 'Закон Кулона';
|
||||
_simShow('sim-coulomb');
|
||||
_simShow('ctrl-coulomb');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const canvas = document.getElementById('coulomb-canvas');
|
||||
if (!csSim) {
|
||||
csSim = new CoulombSim(canvas);
|
||||
csSim.onUpdate = _coulombUpdateUI;
|
||||
}
|
||||
csSim.fit();
|
||||
if (csSim.charges.length === 0) csSim.preset('dipole');
|
||||
_coulombUpdateUI(csSim.info());
|
||||
}));
|
||||
}
|
||||
|
||||
function coulombSign(s) {
|
||||
if (!csSim) return;
|
||||
csSim.setSign(s);
|
||||
document.getElementById('cbtn-pos').classList.toggle('active', s > 0);
|
||||
document.getElementById('cbtn-neg').classList.toggle('active', s < 0);
|
||||
document.getElementById('csign-pos').style.opacity = s > 0 ? '1' : '0.45';
|
||||
document.getElementById('csign-neg').style.opacity = s < 0 ? '1' : '0.45';
|
||||
}
|
||||
|
||||
function coulombLayer(name, rowEl) {
|
||||
if (!csSim) return;
|
||||
csSim.toggleLayer(name);
|
||||
const on = csSim.layers[name];
|
||||
rowEl.classList.toggle('active', on);
|
||||
const tog = rowEl.querySelector('.tri-toggle');
|
||||
if (tog) {
|
||||
tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
|
||||
const dot = tog.querySelector('span');
|
||||
if (dot) dot.style.marginLeft = on ? '14px' : '2px';
|
||||
}
|
||||
csSim.draw();
|
||||
}
|
||||
|
||||
function coulombPreset(name) {
|
||||
if (!csSim) return;
|
||||
csSim.preset(name);
|
||||
}
|
||||
|
||||
function _coulombUpdateUI(info) {
|
||||
if (!info) return;
|
||||
document.getElementById('cs-total').textContent = info.total;
|
||||
document.getElementById('cs-curE').textContent = info.cursorE;
|
||||
document.getElementById('cs-curV').textContent = info.cursorV;
|
||||
document.getElementById('csbar-total').textContent = info.total;
|
||||
document.getElementById('csbar-pos').textContent = info.positive;
|
||||
document.getElementById('csbar-neg').textContent = info.negative;
|
||||
document.getElementById('csbar-maxE').textContent = info.maxE;
|
||||
document.getElementById('csbar-curE').textContent = info.cursorE;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════
|
||||
ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
|
||||
════════════════════════════════ */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -1,10 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
ProjectileSim v2 — physics simulation
|
||||
ProjectileSim v3 — physics simulation
|
||||
Features: air drag (RK4) · wind · bounce · speed multiplier
|
||||
ghost trail comparison · velocity vector labels
|
||||
range arrow · landing angle · canvas click play/pause
|
||||
target challenge mode · x/y/vx/vy graphs · dual throw
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
class ProjectileSim {
|
||||
@@ -67,6 +68,24 @@ class ProjectileSim {
|
||||
this._hover = null; // { t, s } | null
|
||||
this._viewParams = null; // coordinate transform params (set in draw)
|
||||
|
||||
/* ── Feature 1: target challenge mode ── */
|
||||
this.targetMode = false;
|
||||
this._targets = []; // [{x,y,w,h,hit,flashTs}]
|
||||
this._targetAttempts = 0;
|
||||
this.onTargetUpdate = null; // callback → ({hits, total, attempts})
|
||||
|
||||
/* ── Feature 2: graphs panel ── */
|
||||
this._graphsCanvas = null; // set by attachGraphsCanvas()
|
||||
this._graphsVisible = false;
|
||||
|
||||
/* ── Feature 3: dual throw ── */
|
||||
this.dualMode = false;
|
||||
this._p2 = { // second projectile params + live state
|
||||
v0: 25, angle: 30, h0: 0,
|
||||
path: null, pathTf: 0,
|
||||
t: 0, trail: [],
|
||||
};
|
||||
|
||||
canvas.addEventListener('click', () => {
|
||||
if (this.onPlayPause) this.onPlayPause();
|
||||
});
|
||||
@@ -105,6 +124,7 @@ class ProjectileSim {
|
||||
if (bounce !== undefined) this.bounce = !!bounce;
|
||||
if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution));
|
||||
this._computePath();
|
||||
if (this.dualMode) this._computeP2Path();
|
||||
this._resetFX();
|
||||
this.draw();
|
||||
this._emit();
|
||||
@@ -118,6 +138,8 @@ class ProjectileSim {
|
||||
this._launchFlash = 1;
|
||||
this.playing = true;
|
||||
this._lastTs = null;
|
||||
/* reset p2 at launch so both start simultaneously */
|
||||
if (this.dualMode) { this._p2.t = 0; this._p2.trail = []; }
|
||||
this._tick();
|
||||
}
|
||||
|
||||
@@ -162,6 +184,363 @@ class ProjectileSim {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/* ── Feature 1: target mode ── */
|
||||
|
||||
toggleTargetMode() {
|
||||
this.targetMode = !this.targetMode;
|
||||
if (this.targetMode && this._targets.length === 0) this.genTargets();
|
||||
this._emitTargets();
|
||||
this.draw();
|
||||
return this.targetMode;
|
||||
}
|
||||
|
||||
genTargets() {
|
||||
const st = this.stats();
|
||||
const range = Math.max(st.range, 10);
|
||||
const hMax = Math.max(st.hMax, 5);
|
||||
const count = 3;
|
||||
this._targets = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tw = 1.0 + Math.random() * 1.5; // window width 1–2.5 m
|
||||
const th = 1.0 + Math.random() * 1.5; // window height 1–2.5 m
|
||||
/* spread windows across [10%, 90%] of range so they're reachable */
|
||||
const x = range * (0.1 + 0.8 * (i + Math.random() * 0.5) / count);
|
||||
const y = 1.0 + Math.random() * Math.max(1, hMax * 0.7);
|
||||
this._targets.push({ x, y, w: tw, h: th, hit: false, flashTs: -999 });
|
||||
}
|
||||
this._targetAttempts = 0;
|
||||
this._emitTargets();
|
||||
this.draw();
|
||||
}
|
||||
|
||||
_checkTargetHits(prevT, nextT) {
|
||||
if (!this.targetMode || this._targets.length === 0) return;
|
||||
/* sample a few sub-steps between prevT and nextT for precision */
|
||||
const steps = 8;
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = prevT + (nextT - prevT) * (s / steps);
|
||||
const st = this._curState(t);
|
||||
if (st.y < 0) break;
|
||||
for (const tgt of this._targets) {
|
||||
if (tgt.hit) continue;
|
||||
if (st.x >= tgt.x && st.x <= tgt.x + tgt.w &&
|
||||
st.y >= tgt.y && st.y <= tgt.y + tgt.h) {
|
||||
tgt.hit = true;
|
||||
tgt.flashTs = performance.now();
|
||||
this._emitTargets();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_emitTargets() {
|
||||
if (!this.onTargetUpdate) return;
|
||||
const hits = this._targets.filter(t => t.hit).length;
|
||||
this.onTargetUpdate({ hits, total: this._targets.length, attempts: this._targetAttempts });
|
||||
}
|
||||
|
||||
_drawTargets(ctx, tpx, tpy) {
|
||||
if (!this.targetMode) return;
|
||||
const now = performance.now();
|
||||
for (const tgt of this._targets) {
|
||||
const cx = tpx(tgt.x);
|
||||
const cy = tpy(tgt.y + tgt.h); // top edge in canvas coords
|
||||
const cw = tpx(tgt.x + tgt.w) - tpx(tgt.x);
|
||||
const ch = tpy(tgt.y) - tpy(tgt.y + tgt.h); // positive height
|
||||
|
||||
const flashAge = (now - tgt.flashTs) / 1000;
|
||||
const flashing = flashAge < 1.2;
|
||||
|
||||
if (tgt.hit) {
|
||||
/* gold fill on hit */
|
||||
const alpha = flashing ? 0.25 + 0.2 * Math.sin(flashAge * 18) : 0.18;
|
||||
ctx.fillStyle = `rgba(255,214,102,${alpha})`;
|
||||
ctx.fillRect(cx, cy, cw, ch);
|
||||
ctx.strokeStyle = flashing
|
||||
? `rgba(255,214,102,${0.7 + 0.3 * Math.sin(flashAge * 18)})`
|
||||
: 'rgba(255,214,102,.6)';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeRect(cx, cy, cw, ch);
|
||||
|
||||
/* checkmark */
|
||||
const mx = cx + cw / 2, my = cy + ch / 2;
|
||||
ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mx - cw * 0.22, my);
|
||||
ctx.lineTo(mx - cw * 0.05, my + ch * 0.22);
|
||||
ctx.lineTo(mx + cw * 0.28, my - ch * 0.28);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
/* inactive window: translucent blue rect with dashed border */
|
||||
ctx.fillStyle = 'rgba(6,214,224,.06)';
|
||||
ctx.fillRect(cx, cy, cw, ch);
|
||||
ctx.strokeStyle = 'rgba(6,214,224,.5)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.strokeRect(cx, cy, cw, ch);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
/* small cross in top-right corner to look like a window frame */
|
||||
ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(cx + cw / 2, cy + ch);
|
||||
ctx.moveTo(cx, cy + ch / 2); ctx.lineTo(cx + cw, cy + ch / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Feature 2: graphs canvas attachment ── */
|
||||
|
||||
attachGraphsCanvas(canvas) {
|
||||
this._graphsCanvas = canvas;
|
||||
}
|
||||
|
||||
drawGraphs() {
|
||||
const gc = this._graphsCanvas;
|
||||
if (!gc || !this._graphsVisible) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = gc.clientWidth || gc.width / dpr;
|
||||
const H = gc.clientHeight || gc.height / dpr;
|
||||
if (!W || !H) return;
|
||||
|
||||
/* keep physical pixel size in sync */
|
||||
if (gc.width !== Math.round(W * dpr) || gc.height !== Math.round(H * dpr)) {
|
||||
gc.width = Math.round(W * dpr);
|
||||
gc.height = Math.round(H * dpr);
|
||||
}
|
||||
const gctx = gc.getContext('2d');
|
||||
gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
gctx.clearRect(0, 0, W, H);
|
||||
|
||||
const tf = this._curTFlight();
|
||||
if (tf <= 0) {
|
||||
gctx.fillStyle = 'rgba(255,255,255,.2)';
|
||||
gctx.font = '11px Manrope, sans-serif';
|
||||
gctx.textAlign = 'center'; gctx.textBaseline = 'middle';
|
||||
gctx.fillText('Запустите симуляцию', W / 2, H / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
/* collect full trajectory data for plotting */
|
||||
const N = 200;
|
||||
const pts = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = (i / N) * tf;
|
||||
const s = this._curState(t);
|
||||
pts.push({ t, x: s.x, y: Math.max(0, s.y), vx: s.vx, vy: s.vy });
|
||||
}
|
||||
|
||||
const plots = [
|
||||
{ key: 'x', label: 'x(t)', unit: 'м', color: '#06D6E0' },
|
||||
{ key: 'y', label: 'y(t)', unit: 'м', color: '#7BF5A4' },
|
||||
{ key: 'vx', label: 'vx(t)', unit: 'м/с', color: '#9B5DE5' },
|
||||
{ key: 'vy', label: 'vy(t)', unit: 'м/с', color: '#F15BB5' },
|
||||
];
|
||||
|
||||
const cols = 2, rows = 2;
|
||||
const PL = 36, PR = 10, PT = 20, PB = 22;
|
||||
const cw = W / cols, ch = H / rows;
|
||||
const pw = cw - PL - PR, ph = ch - PT - PB;
|
||||
|
||||
/* current time marker fraction */
|
||||
const curFrac = tf > 0 ? Math.min(1, this.t / tf) : 0;
|
||||
|
||||
for (let pi = 0; pi < plots.length; pi++) {
|
||||
const col = pi % cols, row = Math.floor(pi / cols);
|
||||
const ox = col * cw, oy = row * ch;
|
||||
const plot = plots[pi];
|
||||
|
||||
const vals = pts.map(p => p[plot.key]);
|
||||
const vMin = Math.min(...vals), vMax = Math.max(...vals);
|
||||
const vRange = Math.max(vMax - vMin, 0.1);
|
||||
|
||||
const tx = t => ox + PL + (t / tf) * pw;
|
||||
const ty = v => oy + PT + ph - ((v - vMin) / vRange) * ph;
|
||||
|
||||
/* background */
|
||||
gctx.fillStyle = 'rgba(5,5,20,.85)';
|
||||
gctx.fillRect(ox + PL, oy + PT, pw, ph);
|
||||
|
||||
/* grid lines */
|
||||
gctx.strokeStyle = 'rgba(255,255,255,.05)'; gctx.lineWidth = 1;
|
||||
for (let gi = 1; gi < 4; gi++) {
|
||||
const gv = vMin + (gi / 4) * vRange;
|
||||
const gy = ty(gv);
|
||||
gctx.beginPath(); gctx.moveTo(ox + PL, gy); gctx.lineTo(ox + PL + pw, gy); gctx.stroke();
|
||||
}
|
||||
|
||||
/* axes */
|
||||
gctx.strokeStyle = 'rgba(255,255,255,.25)'; gctx.lineWidth = 1.2;
|
||||
gctx.beginPath();
|
||||
gctx.moveTo(ox + PL, oy + PT); gctx.lineTo(ox + PL, oy + PT + ph);
|
||||
gctx.lineTo(ox + PL + pw, oy + PT + ph);
|
||||
gctx.stroke();
|
||||
|
||||
/* axis labels */
|
||||
gctx.font = '9px Manrope, sans-serif';
|
||||
gctx.fillStyle = 'rgba(255,255,255,.35)';
|
||||
gctx.textAlign = 'right'; gctx.textBaseline = 'middle';
|
||||
gctx.fillText(_projFmt(vMax) + ' ' + plot.unit, ox + PL - 3, oy + PT + 4);
|
||||
gctx.fillText(_projFmt(vMin) + ' ' + plot.unit, ox + PL - 3, oy + PT + ph - 2);
|
||||
gctx.textAlign = 'center'; gctx.textBaseline = 'top';
|
||||
gctx.fillText(_projFmt(tf) + ' с', ox + PL + pw, oy + PT + ph + 4);
|
||||
|
||||
/* data line */
|
||||
gctx.strokeStyle = plot.color; gctx.lineWidth = 2;
|
||||
gctx.beginPath();
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const px = tx(pts[i].t), py = ty(pts[i][plot.key]);
|
||||
i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
|
||||
}
|
||||
gctx.stroke();
|
||||
|
||||
/* second projectile overlay */
|
||||
if (this.dualMode && this._p2.pathTf > 0) {
|
||||
const tf2 = this._p2.pathTf;
|
||||
const pts2 = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t2 = (i / N) * tf2;
|
||||
const s2 = this._p2.path ? this._p2PathStateAt(t2) : this._p2StateAnalytical(t2);
|
||||
pts2.push({ t: t2, x: s2.x, y: Math.max(0, s2.y), vx: s2.vx, vy: s2.vy });
|
||||
}
|
||||
gctx.strokeStyle = 'rgba(0,230,255,.55)'; gctx.lineWidth = 1.5;
|
||||
gctx.setLineDash([4, 3]);
|
||||
gctx.beginPath();
|
||||
for (let i = 0; i < pts2.length; i++) {
|
||||
const px = tx(pts2[i].t), py = ty(pts2[i][plot.key]);
|
||||
i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
|
||||
}
|
||||
gctx.stroke(); gctx.setLineDash([]);
|
||||
}
|
||||
|
||||
/* current time indicator */
|
||||
if (curFrac > 0) {
|
||||
const curX = tx(this.t);
|
||||
gctx.strokeStyle = 'rgba(255,214,102,.7)'; gctx.lineWidth = 1.5;
|
||||
gctx.setLineDash([3, 3]);
|
||||
gctx.beginPath(); gctx.moveTo(curX, oy + PT); gctx.lineTo(curX, oy + PT + ph); gctx.stroke();
|
||||
gctx.setLineDash([]);
|
||||
|
||||
/* dot on the line */
|
||||
const curV = this._curState(this.t)[plot.key];
|
||||
const curVclamped = Math.min(vMax, Math.max(vMin, curV));
|
||||
gctx.fillStyle = '#FFD166';
|
||||
gctx.beginPath(); gctx.arc(curX, ty(curVclamped), 3.5, 0, Math.PI * 2); gctx.fill();
|
||||
}
|
||||
|
||||
/* label */
|
||||
gctx.font = 'bold 11px Manrope, sans-serif';
|
||||
gctx.fillStyle = plot.color;
|
||||
gctx.textAlign = 'left'; gctx.textBaseline = 'top';
|
||||
gctx.fillText(plot.label, ox + PL + 5, oy + PT + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Feature 3: second projectile helpers ── */
|
||||
|
||||
_p2StateAnalytical(t) {
|
||||
const p2 = this._p2;
|
||||
const rad = p2.angle * Math.PI / 180;
|
||||
const vx = p2.v0 * Math.cos(rad);
|
||||
const vy0 = p2.v0 * Math.sin(rad);
|
||||
return {
|
||||
x: vx * t,
|
||||
y: p2.h0 + vy0 * t - 0.5 * this.g * t * t,
|
||||
vx,
|
||||
vy: vy0 - this.g * t,
|
||||
};
|
||||
}
|
||||
|
||||
_p2PathStateAt(t) {
|
||||
const path = this._p2.path;
|
||||
if (!path || path.length < 2) return { x: 0, y: this._p2.h0, vx: 0, vy: 0 };
|
||||
if (t <= 0) return path[0];
|
||||
if (t >= this._p2.pathTf) return path[path.length - 1];
|
||||
let lo = 0, hi = path.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (path[mid].t <= t) lo = mid; else hi = mid;
|
||||
}
|
||||
const a = path[lo], b = path[hi];
|
||||
const frac = (t - a.t) / (b.t - a.t);
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * frac,
|
||||
y: a.y + (b.y - a.y) * frac,
|
||||
vx: a.vx + (b.vx - a.vx) * frac,
|
||||
vy: a.vy + (b.vy - a.vy) * frac,
|
||||
};
|
||||
}
|
||||
|
||||
_p2CurState(t) {
|
||||
return this._p2.path ? this._p2PathStateAt(t) : this._p2StateAnalytical(t);
|
||||
}
|
||||
|
||||
/* recompute second projectile path using same RK4 if drag/wind/bounce active */
|
||||
_computeP2Path() {
|
||||
const p2 = this._p2;
|
||||
if (!this._needsNumerical()) {
|
||||
const rad = p2.angle * Math.PI / 180;
|
||||
const vy0 = p2.v0 * Math.sin(rad);
|
||||
const disc = vy0 * vy0 + 2 * this.g * p2.h0;
|
||||
p2.path = null;
|
||||
p2.pathTf = disc < 0 ? 0 : Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
|
||||
return;
|
||||
}
|
||||
|
||||
const rho = 1.225, A = 0.00785;
|
||||
const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0;
|
||||
const g = this.g, W = this.wind, e = this.restitution;
|
||||
const maxBounces = this.bounce ? 7 : 0;
|
||||
const rad = p2.angle * Math.PI / 180;
|
||||
let x = 0, y = p2.h0;
|
||||
let vx = p2.v0 * Math.cos(rad), vy = p2.v0 * Math.sin(rad);
|
||||
const dt = 0.005;
|
||||
const path = [{ x, y, vx, vy, t: 0 }];
|
||||
let bounces = 0;
|
||||
|
||||
const deriv = (sx, sy, svx, svy) => {
|
||||
const rvx = svx - W;
|
||||
const speed = Math.sqrt(rvx * rvx + svy * svy);
|
||||
const dragF = speed > 0 ? k * speed : 0;
|
||||
const wAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
|
||||
return { dx: svx, dy: svy, dvx: -dragF * rvx + wAcc, dvy: -g - dragF * svy };
|
||||
};
|
||||
|
||||
for (let step = 0; step < 200000; step++) {
|
||||
const k1 = deriv(x, y, vx, vy);
|
||||
const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2);
|
||||
const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2);
|
||||
const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt);
|
||||
x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6;
|
||||
y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6;
|
||||
vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6;
|
||||
vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6;
|
||||
const t2 = (step + 1) * dt;
|
||||
if (y <= 0) {
|
||||
const prev = path[path.length - 1];
|
||||
if (prev && prev.y > 0) {
|
||||
const frac = prev.y / (prev.y - y);
|
||||
const lx = prev.x + (x - prev.x) * frac;
|
||||
const lvx = prev.vx + (vx - prev.vx) * frac;
|
||||
const lvy = prev.vy + (vy - prev.vy) * frac;
|
||||
const lt = prev.t + dt * frac;
|
||||
path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt });
|
||||
if (this.bounce && bounces < maxBounces && Math.abs(lvy) > 0.4) {
|
||||
vy = -e * lvy; vx = lvx * 0.96; y = 0.001; x = lx;
|
||||
bounces++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
path.push({ x, y, vx, vy, t: t2 });
|
||||
}
|
||||
p2.path = path;
|
||||
p2.pathTf = path[path.length - 1].t;
|
||||
}
|
||||
|
||||
/* ── physics ── */
|
||||
|
||||
/* pure analytical solution (no drag/wind/bounce) */
|
||||
@@ -345,6 +724,7 @@ class ProjectileSim {
|
||||
|
||||
this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);
|
||||
|
||||
const prevT = this.t;
|
||||
const cur = this._curState(this.t);
|
||||
this._trail.push({ mx: cur.x, my: cur.y });
|
||||
if (this._trail.length > 80) this._trail.shift();
|
||||
@@ -355,9 +735,24 @@ class ProjectileSim {
|
||||
this.t = tf;
|
||||
this.playing = false;
|
||||
this._triggerImpact();
|
||||
if (this.targetMode) this._targetAttempts++;
|
||||
}
|
||||
|
||||
/* target hit detection on this step interval */
|
||||
this._checkTargetHits(prevT, Math.min(this.t, tf));
|
||||
|
||||
/* advance second projectile */
|
||||
if (this.dualMode) {
|
||||
const p2 = this._p2;
|
||||
const p2cur = this._p2CurState(p2.t);
|
||||
p2.trail.push({ mx: p2cur.x, my: p2cur.y });
|
||||
if (p2.trail.length > 80) p2.trail.shift();
|
||||
p2.t = Math.min(p2.t + rawDt * this.speed, p2.pathTf);
|
||||
}
|
||||
|
||||
this.draw();
|
||||
this._emit();
|
||||
if (this._graphsVisible) this.drawGraphs();
|
||||
if (this.playing) this._tick();
|
||||
});
|
||||
}
|
||||
@@ -391,6 +786,13 @@ class ProjectileSim {
|
||||
this._impactTs = -999;
|
||||
this._launchFlash = 0;
|
||||
this._computePath();
|
||||
if (this.dualMode) {
|
||||
this._p2.t = 0;
|
||||
this._p2.trail = [];
|
||||
this._computeP2Path();
|
||||
}
|
||||
/* clear target hits so player can retry */
|
||||
for (const tgt of this._targets) tgt.hit = false;
|
||||
}
|
||||
|
||||
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
|
||||
@@ -521,6 +923,25 @@ class ProjectileSim {
|
||||
ctx.fillText(gh.label, lx, ly + 10);
|
||||
}
|
||||
|
||||
/* ── 6.7. Target windows ── */
|
||||
this._drawTargets(ctx, tpx, tpy);
|
||||
|
||||
/* ── 6.8. HUD: target counter (top-right inside canvas) ── */
|
||||
if (this.targetMode && this._targets.length > 0) {
|
||||
const hits = this._targets.filter(t => t.hit).length;
|
||||
const hudText = `Цели: ${hits}/${this._targets.length} Попыток: ${this._targetAttempts}`;
|
||||
ctx.font = 'bold 11px Manrope, sans-serif';
|
||||
const tw = ctx.measureText(hudText).width;
|
||||
const hx = W - PR - 8 - tw - 20, hy = PT + 30;
|
||||
ctx.fillStyle = 'rgba(5,5,20,.75)';
|
||||
ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,214,102,.4)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.stroke();
|
||||
ctx.fillStyle = '#FFD166';
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(hudText, hx + 4, hy + 7);
|
||||
}
|
||||
|
||||
/* ── 7. Launch platform ── */
|
||||
if (this.h0 > 0.2) {
|
||||
const px0 = tpx(0), py0 = tpy(0), pyH = tpy(this.h0);
|
||||
@@ -601,6 +1022,93 @@ class ProjectileSim {
|
||||
ctx.beginPath(); ctx.arc(tpx(tr.mx), tpy(tr.my), frac * 5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
/* ── 10.5. Dual throw — second projectile ── */
|
||||
if (this.dualMode && this._p2.pathTf > 0) {
|
||||
const p2 = this._p2;
|
||||
const tf2 = p2.pathTf;
|
||||
|
||||
/* full reference trajectory */
|
||||
ctx.strokeStyle = 'rgba(0,230,255,.25)'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
|
||||
ctx.beginPath();
|
||||
const step2 = Math.max(1, p2.path ? Math.floor(p2.path.length / 250) : 1);
|
||||
if (p2.path) {
|
||||
for (let i = 0; i < p2.path.length; i += step2) {
|
||||
const pp = p2.path[i];
|
||||
i === 0 ? ctx.moveTo(tpx(pp.x), tpy(pp.y)) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i <= 250; i++) {
|
||||
const s2 = this._p2StateAnalytical((i / 250) * tf2);
|
||||
i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
|
||||
}
|
||||
}
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
|
||||
/* flown path */
|
||||
if (p2.t > 0) {
|
||||
const s2_0 = this._p2CurState(0), s2_1 = this._p2CurState(Math.min(p2.t, tf2));
|
||||
const g2 = ctx.createLinearGradient(tpx(s2_0.x), tpy(s2_0.y), tpx(s2_1.x), tpy(s2_1.y));
|
||||
g2.addColorStop(0, 'rgba(0,230,255,.3)');
|
||||
g2.addColorStop(1, '#00E6FF');
|
||||
ctx.strokeStyle = g2; ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
if (p2.path) {
|
||||
let first = true;
|
||||
for (const pp of p2.path) {
|
||||
if (pp.t > p2.t) break;
|
||||
first ? (ctx.moveTo(tpx(pp.x), tpy(pp.y)), first = false) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
|
||||
}
|
||||
const ps2 = this._p2PathStateAt(p2.t);
|
||||
ctx.lineTo(tpx(ps2.x), tpy(Math.max(0, ps2.y)));
|
||||
} else {
|
||||
const steps2 = Math.max(2, Math.ceil((p2.t / tf2) * 250));
|
||||
for (let i = 0; i <= steps2; i++) {
|
||||
const s2 = this._p2StateAnalytical((i / 250) * tf2);
|
||||
i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* p2 trail dots */
|
||||
for (let i = 0; i < p2.trail.length; i++) {
|
||||
const frac = i / p2.trail.length;
|
||||
const tr2 = p2.trail[i];
|
||||
ctx.fillStyle = `rgba(0,230,255,${frac * 0.45})`;
|
||||
ctx.beginPath(); ctx.arc(tpx(tr2.mx), tpy(tr2.my), frac * 4.5, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
/* p2 ball */
|
||||
const c2 = this._p2CurState(Math.min(p2.t, tf2));
|
||||
const b2x = tpx(c2.x), b2y = tpy(Math.max(0, c2.y));
|
||||
|
||||
const glo2 = ctx.createRadialGradient(b2x, b2y, 2, b2x, b2y, 28);
|
||||
glo2.addColorStop(0, 'rgba(0,230,255,.45)');
|
||||
glo2.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = glo2;
|
||||
ctx.beginPath(); ctx.arc(b2x, b2y, 28, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
const bg2 = ctx.createRadialGradient(b2x - 3, b2y - 3, 1, b2x, b2y, 10);
|
||||
bg2.addColorStop(0, '#ffffff');
|
||||
bg2.addColorStop(0.25, '#00E6FF');
|
||||
bg2.addColorStop(1, '#0891b2');
|
||||
ctx.fillStyle = bg2;
|
||||
ctx.beginPath(); ctx.arc(b2x, b2y, 10, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
|
||||
/* p2 landing marker */
|
||||
const end2 = this._p2CurState(tf2);
|
||||
const lx2 = tpx(end2.x), ly2 = tpy(0);
|
||||
ctx.strokeStyle = 'rgba(0,230,255,.6)'; ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx2 - 6, ly2 - 6); ctx.lineTo(lx2 + 6, ly2 + 6);
|
||||
ctx.moveTo(lx2 + 6, ly2 - 6); ctx.lineTo(lx2 - 6, ly2 + 6);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(0,230,255,.8)';
|
||||
ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||
ctx.fillText(_projFmt(end2.x) + ' м', lx2, ly2 + 8);
|
||||
}
|
||||
|
||||
/* ── 11. Max height marker ── */
|
||||
if (st.hMax > this.h0 + 0.2 && tf > 0) {
|
||||
let mpx, mpy;
|
||||
@@ -1073,8 +1581,11 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (!pSim) {
|
||||
pSim = new ProjectileSim(document.getElementById('proj-canvas'));
|
||||
pSim.onUpdate = _projUpdateUI;
|
||||
pSim.onPlayPause = projPlayPause;
|
||||
pSim.onUpdate = _projUpdateUI;
|
||||
pSim.onPlayPause = projPlayPause;
|
||||
pSim.onTargetUpdate = _projUpdateTargetHUD;
|
||||
const gc = document.getElementById('proj-graphs-canvas');
|
||||
if (gc) pSim.attachGraphsCanvas(gc);
|
||||
}
|
||||
pSim.fit();
|
||||
projParam(); // sync sliders → sim
|
||||
@@ -1226,6 +1737,91 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
if (pSim) pSim.clearGhosts();
|
||||
}
|
||||
|
||||
/* ── Feature 1: target mode UI ── */
|
||||
|
||||
function projToggleTargetMode() {
|
||||
if (!pSim) return;
|
||||
const on = pSim.toggleTargetMode();
|
||||
const btn = document.getElementById('proj-target-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', on);
|
||||
btn.querySelector('span').textContent = on ? 'Режим целей: Вкл' : 'Режим целей: Выкл';
|
||||
}
|
||||
const panel = document.getElementById('proj-target-panel');
|
||||
if (panel) panel.style.display = on ? '' : 'none';
|
||||
_projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
|
||||
}
|
||||
|
||||
function projGenTargets() {
|
||||
if (!pSim) return;
|
||||
pSim.genTargets();
|
||||
_projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
|
||||
}
|
||||
|
||||
function _projUpdateTargetHUD(info) {
|
||||
const el = document.getElementById('proj-target-hud');
|
||||
if (!el) return;
|
||||
el.textContent = `Цели: ${info.hits}/${info.total} Попыток: ${info.attempts}`;
|
||||
}
|
||||
|
||||
/* ── Feature 2: graphs panel UI ── */
|
||||
|
||||
function projToggleGraphs() {
|
||||
if (!pSim) return;
|
||||
pSim._graphsVisible = !pSim._graphsVisible;
|
||||
const panel = document.getElementById('proj-graphs-panel');
|
||||
const btn = document.getElementById('proj-graphs-btn');
|
||||
if (panel) panel.style.display = pSim._graphsVisible ? '' : 'none';
|
||||
if (btn) btn.classList.toggle('active', pSim._graphsVisible);
|
||||
if (pSim._graphsVisible) {
|
||||
if (!pSim._graphsCanvas) {
|
||||
const gc = document.getElementById('proj-graphs-canvas');
|
||||
if (gc) pSim.attachGraphsCanvas(gc);
|
||||
}
|
||||
pSim.drawGraphs();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Feature 3: dual throw UI ── */
|
||||
|
||||
function projToggleDual() {
|
||||
if (!pSim) return;
|
||||
pSim.dualMode = !pSim.dualMode;
|
||||
const on = pSim.dualMode;
|
||||
const btn = document.getElementById('proj-dual-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', on);
|
||||
btn.querySelector('span').textContent = on ? 'Двойной: Вкл' : 'Двойной: Выкл';
|
||||
}
|
||||
const panel = document.getElementById('proj-dual-panel');
|
||||
if (panel) panel.style.display = on ? '' : 'none';
|
||||
/* show/hide dual stats cells */
|
||||
const w1 = document.getElementById('ps-p2-wrap');
|
||||
const w2 = document.getElementById('ps-p2-tf-wrap');
|
||||
if (w1) w1.style.display = on ? '' : 'none';
|
||||
if (w2) w2.style.display = on ? '' : 'none';
|
||||
if (on) {
|
||||
pSim._computeP2Path();
|
||||
projP2Param();
|
||||
}
|
||||
pSim.draw();
|
||||
}
|
||||
|
||||
function projP2Param() {
|
||||
if (!pSim) return;
|
||||
const v0 = +document.getElementById('sl-p2-v0').value;
|
||||
const angle = +document.getElementById('sl-p2-angle').value;
|
||||
const h0 = +document.getElementById('sl-p2-h0').value;
|
||||
document.getElementById('p2-v0').textContent = v0 + ' м/с';
|
||||
document.getElementById('p2-angle').textContent = angle + '°';
|
||||
document.getElementById('p2-h0').textContent = h0 + ' м';
|
||||
pSim._p2.v0 = v0;
|
||||
pSim._p2.angle = angle;
|
||||
pSim._p2.h0 = h0;
|
||||
pSim._computeP2Path();
|
||||
pSim.draw();
|
||||
}
|
||||
|
||||
function _projUpdateUI(s) {
|
||||
const fmt = (n, unit) => n < 10000 ? n.toFixed(2) + ' ' + unit : (n/1000).toFixed(2) + ' к' + unit;
|
||||
document.getElementById('ps-range').textContent = fmt(s.range, 'м');
|
||||
@@ -1243,7 +1839,17 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
|
||||
lossEl.style.color = s.rangeLoss < 0 ? '#EF476F' : '#7BF5A4';
|
||||
}
|
||||
}
|
||||
/* update dual stats row */
|
||||
if (pSim && pSim.dualMode && pSim._p2.pathTf > 0) {
|
||||
const p2end = pSim._p2CurState(pSim._p2.pathTf);
|
||||
const d2El = document.getElementById('ps-p2-range');
|
||||
if (d2El) d2El.textContent = fmt(Math.max(0, p2end.x), 'м');
|
||||
const d2tf = document.getElementById('ps-p2-tf');
|
||||
if (d2tf) d2tf.textContent = pSim._p2.pathTf.toFixed(2) + ' с';
|
||||
}
|
||||
_projSyncPlayBtn();
|
||||
/* redraw graphs if open and not in flight (flight loop handles it) */
|
||||
if (pSim && pSim._graphsVisible && !pSim.playing) pSim.drawGraphs();
|
||||
}
|
||||
|
||||
/* ── collision ── */
|
||||
|
||||
@@ -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
@@ -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">−</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')">Диполь ±</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">•</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">×</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×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*</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">Φ=B·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)">—</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)">—</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)">—</div>
|
||||
</div>
|
||||
<div style="font-size:0.68rem;color:var(--text-3);text-align:center;line-height:1.6;margin-top:4px">
|
||||
Клик — добавить · ПКМ / 2×клик — удалить<br>
|
||||
Перетащи провод для перемещения
|
||||
Клик — добавить · ПКМ / 2×клик — удалить<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">—</div></div>
|
||||
<div class="pstat"><div class="pstat-label">|B| курсора</div><div class="pstat-val" id="embar-curB-bar" style="color:var(--cyan)">—</div></div>
|
||||
<div class="pstat"><div class="pstat-label">Сила Ампера</div><div class="pstat-val" id="embar-ampere" style="color:#fbbf24">—</div></div>
|
||||
<div class="pstat"><div class="pstat-label">Поток Φ</div><div class="pstat-val" id="embar-flux" style="color:#34d399">—</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">
|
||||
Клик — добавить · ПКМ — удалить<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)">Дальн. 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)">Время 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>
|
||||
|
||||
Reference in New Issue
Block a user