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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 12:09:44 +03:00
parent 085b7322cf
commit 7f75c96acd
11 changed files with 3037 additions and 2239 deletions
+519 -7
View File
@@ -217,6 +217,20 @@ class GeoEngine {
}
if (obj.constr === 'ngon_vertex') return obj.srcCenter === id || obj.srcVertex === id;
if (obj.constr === 'translate') return obj.srcA === id || obj.srcB === id || obj.srcPt === id;
if (obj.constr === 'on_segment') {
if (obj.srcSeg === id) return true;
// зависим и от перемещения конечных точек отрезка
const seg = this._objects.get(obj.srcSeg);
return !!(seg && (seg.p1Id === id || seg.p2Id === id));
}
if (obj.constr === 'on_circle') {
if (obj.srcCircle === id) return true;
// если id — точка, задающая окружность, зависим транзитивно через саму окружность
const circ = this._objects.get(obj.srcCircle);
if (!circ) return false;
if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id;
return circ.centerId === id || circ.edgeId === id;
}
return false;
case 'derived_line':
switch (obj.constr) {
@@ -234,6 +248,14 @@ class GeoEngine {
}
}
return false;
case 'measure_length':
return obj.srcSeg === id;
case 'measure_angle':
return obj.srcA === id || obj.srcVtx === id || obj.srcB === id;
case 'measure_area':
return obj.srcPoly === id;
case 'locus':
return obj.srcMover === id || obj.srcTarget === id;
}
return false;
}
@@ -323,6 +345,31 @@ class GeoEngine {
obj.x = pO.x + obj.k * (pP.x - pO.x);
obj.y = pO.y + obj.k * (pP.y - pO.y);
}
} else if (obj.constr === 'on_segment') {
const seg = _g(obj.srcSeg);
if (seg) {
const p1 = _g(seg.p1Id), p2 = _g(seg.p2Id);
if (p1 && p2) {
const t = Math.max(0, Math.min(1, obj._t));
obj.x = p1.x + t * (p2.x - p1.x);
obj.y = p1.y + t * (p2.y - p1.y);
}
}
} else if (obj.constr === 'on_circle') {
const circ = _g(obj.srcCircle);
if (circ) {
let cx, cy, r;
if (circ.derived && circ.cx != null) {
cx = circ.cx; cy = circ.cy; r = circ.r;
} else {
const mc = _g(circ.centerId), me = _g(circ.edgeId);
if (mc && me) { cx = mc.x; cy = mc.y; r = gDist(mc, me); }
}
if (cx != null) {
obj.x = cx + r * Math.cos(obj._theta);
obj.y = cy + r * Math.sin(obj._theta);
}
}
}
} else if (obj.type === 'circle' && obj.derived) {
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
@@ -470,6 +517,7 @@ class GeoSim {
this._pendingLineRef = null; // первый кликнутый объект для parallel/perp/intersect/reflect/foot
this._pendingCircRef = null; // первый кликнутый объект-окружность для tangent
this._pendingScaleO = null; // центр подобия для инструмента scale
this._pendingMover = null; // мовер-точка для инструмента locus
this._scaleK = 2; // коэффициент подобия
/* ── Состояние drag/pan ── */
@@ -501,6 +549,7 @@ class GeoSim {
this.onUpdate = null; // cb(stats)
this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки
this.onDeleteRequest = null; // cb(obj, deps, softFn, cascadeFn) — подтвердить удаление
this.onLocusError = null; // cb(msg) — ошибка при построении ГМТ
this._labelCounter = 0;
this._ngonSides = 6; // для инструмента правильного многоугольника
@@ -533,6 +582,7 @@ class GeoSim {
this._pendingLineRef = null;
this._pendingCircRef = null;
this._pendingScaleO = null;
this._pendingMover = null;
this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
this.render();
}
@@ -583,6 +633,10 @@ class GeoSim {
this._drawAngleMeasures(ctx); // всегда — для arcmark и прямых углов; showAngles управляет авто-подписями
// Точки поверх всего (включая производные)
for (const obj of this.eng.points()) this._drawPoint(ctx, obj);
// Локусы (ГМТ)
for (const obj of this.eng.byType('locus')) this._drawLocus(ctx, obj);
// Измерительные чипы поверх всего
this._drawMeasurements(ctx);
// Предпросмотр строящегося объекта
this._drawPreview(ctx);
// Подсветка первого объекта при инструментах построения
@@ -1266,6 +1320,139 @@ class GeoSim {
}
}
/* ── Измерительные чипы (measure_length / measure_angle / measure_area) ── */
_drawMeasurements(ctx) {
const CHIP_PAD_X = 8, CHIP_PAD_Y = 4, CHIP_R = 6;
ctx.save();
ctx.font = '11px Manrope,sans-serif';
for (const obj of this.eng.all()) {
if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue;
const text = this._measureText(obj);
if (text === null) continue;
const labelPx = this._measureLabelPos(obj);
if (!labelPx) continue;
const w = ctx.measureText(text).width + CHIP_PAD_X * 2;
const h = 18;
const x = labelPx.x - w / 2;
const y = labelPx.y - h / 2;
const isSelected = this._isSelected(obj);
const col = obj.type === 'measure_length' ? '#9B5DE5'
: obj.type === 'measure_angle' ? '#F15BB5'
: '#22d55e';
ctx.globalAlpha = 0.92;
ctx.fillStyle = 'rgba(10,7,24,0.82)';
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, w, h, CHIP_R);
else ctx.rect(x, y, w, h);
ctx.fill();
ctx.strokeStyle = isSelected ? '#fff' : col;
ctx.lineWidth = isSelected ? 1.8 : 1.2;
ctx.globalAlpha = isSelected ? 0.9 : 0.7;
ctx.stroke();
ctx.fillStyle = col;
ctx.globalAlpha = 0.95;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
ctx.fillText(text, labelPx.x, labelPx.y + 0.5);
ctx.shadowBlur = 0;
ctx.textBaseline = 'alphabetic';
}
ctx.restore();
}
_measureText(obj) {
if (obj.type === 'measure_length') {
const seg = this.eng.get(obj.srcSeg);
if (!seg) return null;
const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id);
if (!m1 || !m2) return null;
const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id);
const lab1 = (p1 && p1.label) || '';
const lab2 = (p2 && p2.label) || '';
const name = lab1 && lab2 ? lab1 + lab2 : 'seg';
return name + ' = ' + gDist(m1, m2).toFixed(2);
}
if (obj.type === 'measure_angle') {
const pA = this.eng.get(obj.srcA), pV = this.eng.get(obj.srcVtx), pB = this.eng.get(obj.srcB);
if (!pA || !pV || !pB) return null;
const ang = gAngleDeg(pA, pV, pB);
const lA = (pA.label) || '', lV = (pV.label) || '', lB = (pB.label) || '';
const name = (lA && lV && lB) ? lA + lV + lB : 'ang';
return '∠' + name + ' = ' + ang.toFixed(1) + '°';
}
if (obj.type === 'measure_area') {
const poly = this.eng.get(obj.srcPoly);
if (!poly) return null;
const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean);
if (pts.length < 3) return null;
return 'S = ' + gPolygonArea(pts).toFixed(2);
}
return null;
}
/* Базовая позиция чипа в пикселях (без пользовательского offset) */
_measureLabelBasePos(obj) {
if (obj.type === 'measure_length') {
const seg = this.eng.get(obj.srcSeg);
if (!seg) return null;
const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id);
if (!m1 || !m2) return null;
const mid = this.vp.toCanvas((m1.x + m2.x) / 2, (m1.y + m2.y) / 2);
return { x: mid.x, y: mid.y - 18 };
}
if (obj.type === 'measure_angle') {
const pV = this.eng.get(obj.srcVtx);
if (!pV) return null;
const vPx = this.vp.toCanvas(pV.x, pV.y);
return { x: vPx.x, y: vPx.y - 28 };
}
if (obj.type === 'measure_area') {
const poly = this.eng.get(obj.srcPoly);
if (!poly) return null;
const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean);
if (pts.length < 3) return null;
const sumX = pts.reduce((s, p) => s + p.x, 0) / pts.length;
const sumY = pts.reduce((s, p) => s + p.y, 0) / pts.length;
const c = this.vp.toCanvas(sumX, sumY);
return { x: c.x, y: c.y };
}
return null;
}
/* Позиция чипа в пикселях (базовая + пользовательский offset) */
_measureLabelPos(obj) {
const base = this._measureLabelBasePos(obj);
if (!base) return null;
return { x: base.x + (obj.offX || 0), y: base.y + (obj.offY || 0) };
}
/* ── Локус (ГМТ) ──────────────────────────────────────────── */
_drawLocus(ctx, obj) {
const pts = obj.samples;
if (!pts || pts.length < 2) return;
ctx.save();
ctx.strokeStyle = obj.style && obj.style.color ? obj.style.color : '#F59E0B';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.65;
ctx.setLineDash([]);
ctx.beginPath();
const first = this.vp.toCanvas(pts[0].x, pts[0].y);
ctx.moveTo(first.x, first.y);
for (let i = 1; i < pts.length; i++) {
const p = this.vp.toCanvas(pts[i].x, pts[i].y);
ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.restore();
}
/* ── Предпросмотр (строящийся объект) ─────────────────────── */
_drawPreview(ctx) {
if (this._pending.length === 0 || !this._preview) return;
@@ -1454,7 +1641,7 @@ class GeoSim {
// ПКМ → отмена текущего построения
if (e.button === 2) {
this._pending = []; this._preview = null; this._pendingLineRef = null;
this._pending = []; this._preview = null; this._pendingLineRef = null; this._pendingMover = null;
this.render(); return;
}
@@ -1485,14 +1672,24 @@ class GeoSim {
if (Math.hypot(pp.x-px, pp.y-py) < SNAP_PX) { found = pt; break; }
}
if (found && !found.locked && !found.derived) {
this._drag = { id: found.id };
const isConstrained = found && found.derived &&
(found.constr === 'on_segment' || found.constr === 'on_circle');
if (found && !found.locked && (!found.derived || isConstrained)) {
this._drag = { id: found.id, constrained: isConstrained };
this._selected = found;
this.canvas.style.cursor = 'grabbing';
} else {
// Выбрать объект (отрезок, окружность, полигон...)
this._selected = this._hitTest(px, py);
this._drag = null;
// Проверить, не кликнули ли на чип измерения (для drag чипа)
const hitObj = this._hitTest(px, py);
this._selected = hitObj;
if (hitObj && (hitObj.type === 'measure_length' || hitObj.type === 'measure_angle' || hitObj.type === 'measure_area')) {
// drag чипа — запоминаем offset курсора относительно позиции чипа
const lp = this._measureLabelPos(hitObj);
this._drag = { id: hitObj.id, chipDrag: true, offX: px - (lp ? lp.x : px), offY: py - (lp ? lp.y : py) };
this.canvas.style.cursor = 'grabbing';
} else {
this._drag = null;
}
}
this.render();
}
@@ -1502,6 +1699,37 @@ class GeoSim {
const HIT = 8; // pixels
const m = this.vp.toMath(px, py);
// Измерительные чипы
this.ctx.font = '11px Manrope,sans-serif';
for (const obj of this.eng.all()) {
if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue;
const text = this._measureText(obj);
if (!text) continue;
const labelPx = this._measureLabelPos(obj);
if (!labelPx) continue;
const w = this.ctx.measureText(text).width + 16;
const h = 18;
if (px >= labelPx.x - w/2 - 2 && px <= labelPx.x + w/2 + 2 &&
py >= labelPx.y - h/2 - 2 && py <= labelPx.y + h/2 + 2) {
return obj;
}
}
// Локусы
for (const obj of this.eng.byType('locus')) {
const pts = obj.samples;
if (!pts || pts.length < 2) continue;
for (let i = 1; i < pts.length; i++) {
const a = this.vp.toCanvas(pts[i-1].x, pts[i-1].y);
const b = this.vp.toCanvas(pts[i].x, pts[i].y);
const seg2D = { x: b.x-a.x, y: b.y-a.y };
const lenSq = seg2D.x*seg2D.x + seg2D.y*seg2D.y;
if (lenSq < 1e-9) continue;
const t = Math.max(0, Math.min(1, ((px-a.x)*seg2D.x + (py-a.y)*seg2D.y) / lenSq));
const dx = px - (a.x + t*seg2D.x), dy = py - (a.y + t*seg2D.y);
if (dx*dx + dy*dy < HIT*HIT) return obj;
}
}
// Полигоны (проверяем стороны)
for (const obj of this.eng.byType('polygon')) {
const ids = obj.pointIds;
@@ -2318,10 +2546,221 @@ class GeoSim {
}
break;
}
/* ══ Точка на отрезке — для ГМТ ══ */
case 'point_on_segment': {
const hitSeg = this._hitTestLine(px, py);
if (!hitSeg || hitSeg.type !== 'segment' || hitSeg.virtual) break;
this._pushUndo();
const p1 = this.eng.get(hitSeg.p1Id), p2 = this.eng.get(hitSeg.p2Id);
if (!p1 || !p2) break;
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const l2 = dx*dx + dy*dy;
const t = l2 < 1e-12 ? 0.5
: Math.max(0, Math.min(1, ((snapped.x-p1.x)*dx + (snapped.y-p1.y)*dy) / l2));
const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1);
this.eng.add({
type: 'point', derived: true, constr: 'on_segment',
srcSeg: hitSeg.id, _t: t,
x: p1.x + t*dx, y: p1.y + t*dy,
label: lbl, style: { color: '#06D6E0', size: 4 }
});
if (this.onUpdate) this.onUpdate(this.getStats());
break;
}
/* ══ Точка на окружности — для ГМТ ══ */
case 'point_on_circle': {
const hitCirc = this._hitTestCircle(px, py);
if (!hitCirc) break;
this._pushUndo();
let cx, cy, r;
if (hitCirc.derived && hitCirc.cx != null) {
cx = hitCirc.cx; cy = hitCirc.cy; r = hitCirc.r;
} else {
const mc = this.eng.get(hitCirc.centerId), me = this.eng.get(hitCirc.edgeId);
if (!mc || !me) break;
cx = mc.x; cy = mc.y; r = gDist(mc, me);
}
const theta = Math.atan2(snapped.y - cy, snapped.x - cx);
const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1);
this.eng.add({
type: 'point', derived: true, constr: 'on_circle',
srcCircle: hitCirc.id, _theta: theta,
x: cx + r * Math.cos(theta),
y: cy + r * Math.sin(theta),
label: lbl, style: { color: '#06D6E0', size: 4 }
});
if (this.onUpdate) this.onUpdate(this.getStats());
break;
}
/* ══ Измерение длины — клик на отрезок ══ */
case 'measure_length': {
const seg = this._hitTestLine(px, py);
if (seg && (seg.type === 'segment') && !seg.virtual) {
this._pushUndo();
this.eng.add({ type:'measure_length', srcSeg: seg.id, offX:0, offY:0 });
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
/* ══ Измерение угла — 3 клика: сторона A, вершина, сторона B ══ */
case 'measure_angle': {
this._pending.push(snapped);
if (this._pending.length === 1) {
if (this.onHintChange) this.onHintChange('measure_angle', 2);
} else if (this._pending.length === 2) {
if (this.onHintChange) this.onHintChange('measure_angle', 3);
} else if (this._pending.length === 3) {
this._pushUndo();
const ptA = this._ensurePoint(this._pending[0]);
const ptVtx = this._ensurePoint(this._pending[1]);
const ptB = this._ensurePoint(this._pending[2]);
this.eng.add({ type:'measure_angle', srcA:ptA.id, srcVtx:ptVtx.id, srcB:ptB.id, offX:0, offY:0 });
this._pending = []; this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
/* ══ Измерение площади — клик на полигон ══ */
case 'measure_area': {
const poly = this._hitTest(px, py);
if (poly && poly.type === 'polygon') {
this._pushUndo();
this.eng.add({ type:'measure_area', srcPoly: poly.id, offX:0, offY:0 });
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
/* ══ ГМТ (локус) — шаг 1: мовер-точка, шаг 2: целевая точка ══ */
case 'locus': {
if (!this._pendingMover) {
// Первый клик: выбрать точку-мовер (должна быть constrained)
const SNAP_PX = 12;
let hitPt = null;
for (const pt of this.eng.points()) {
const pp = this.vp.toCanvas(pt.x, pt.y);
if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; }
}
if (!hitPt) break;
// Мовер должен быть constrained (точка на отрезке или на окружности по параметру)
if (!hitPt.constr || (hitPt.constr !== 'on_segment' && hitPt.constr !== 'on_circle')) {
if (this.onLocusError) this.onLocusError('Выбери точку, ограниченную на отрезке или окружности (тип: on_segment / on_circle)');
break;
}
this._pendingMover = hitPt;
if (this.onHintChange) this.onHintChange('locus', 2);
} else {
// Второй клик: выбрать целевую точку
const SNAP_PX = 12;
let hitPt = null;
for (const pt of this.eng.points()) {
const pp = this.vp.toCanvas(pt.x, pt.y);
if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; }
}
if (!hitPt || hitPt === this._pendingMover) break;
// Проверим, что целевая зависит от мовера
if (!this._isDownstreamOf(hitPt.id, this._pendingMover.id)) {
if (this.onLocusError) this.onLocusError('Целевая точка не зависит от выбранного мовера');
this._pendingMover = null;
break;
}
this._pushUndo();
const samples = this._sweepLocus(this._pendingMover, hitPt);
const cnt = this.eng.byType('locus').length;
this.eng.add({
type: 'locus',
srcMover: this._pendingMover.id,
srcTarget: hitPt.id,
samples,
style: { color: '#F59E0B' },
label: cnt ? 'L' + (cnt + 1) : 'L₁'
});
this._pendingMover = null; this._pending = []; this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
}
this.render();
}
/* Проверяет, зависит ли targetId от moverId (BFS по графу зависимостей) */
_isDownstreamOf(targetId, moverId) {
const visited = new Set();
const queue = [moverId];
while (queue.length) {
const curr = queue.shift();
if (curr === targetId) return true;
if (visited.has(curr)) continue;
visited.add(curr);
for (const obj of this.eng.all()) {
if (!visited.has(obj.id) && this.eng._dependsOn(obj, curr)) {
queue.push(obj.id);
}
}
}
return false;
}
/* Прогоняет мовер по его диапазону и записывает позиции цели */
_sweepLocus(moverPt, targetPt) {
const N = 200;
const samples = [];
// Сохранить текущее состояние мовера
const savedX = moverPt.x, savedY = moverPt.y;
const savedT = moverPt._t;
if (moverPt.constr === 'on_segment') {
const seg = this.eng.get(moverPt.srcSeg);
if (!seg) return samples;
for (let i = 0; i <= N; i++) {
const t = i / N;
const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id);
if (!p1 || !p2) continue;
moverPt.x = p1.x + t * (p2.x - p1.x);
moverPt.y = p1.y + t * (p2.y - p1.y);
moverPt._t = t;
this.eng.propagateDeps(moverPt.id);
samples.push({ x: targetPt.x, y: targetPt.y });
}
} else if (moverPt.constr === 'on_circle') {
const circ = this.eng.get(moverPt.srcCircle);
if (!circ) return samples;
for (let i = 0; i <= N; i++) {
const theta = 2 * Math.PI * i / N;
let cx, cy, r;
if (circ.derived && circ.cx != null) {
cx = circ.cx; cy = circ.cy; r = circ.r;
} else {
const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId);
if (!mc || !me) continue;
cx = mc.x; cy = mc.y; r = gDist(mc, me);
}
moverPt.x = cx + r * Math.cos(theta);
moverPt.y = cy + r * Math.sin(theta);
this.eng.propagateDeps(moverPt.id);
samples.push({ x: targetPt.x, y: targetPt.y });
}
}
// Восстановить состояние мовера
moverPt.x = savedX; moverPt.y = savedY;
if (savedT !== undefined) moverPt._t = savedT; else delete moverPt._t;
this.eng.propagateDeps(moverPt.id);
return samples;
}
_finishPolygon() {
if (this._pending.length < 3) { this._pending = []; this._preview = null; this.render(); return; }
this._pushUndo();
@@ -2351,6 +2790,41 @@ class GeoSim {
return this._addPoint(m);
}
/** Переместить точку on_segment или on_circle — проецируем мышь на хост-геометрию */
_moveConstrainedPoint(id, mx, my) {
const obj = this.eng.get(id);
if (!obj) return;
if (obj.constr === 'on_segment') {
const seg = this.eng.get(obj.srcSeg);
if (!seg) return;
const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id);
if (!p1 || !p2) return;
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const l2 = dx*dx + dy*dy;
if (l2 < 1e-12) return;
const t = Math.max(0, Math.min(1, ((mx-p1.x)*dx + (my-p1.y)*dy) / l2));
obj._t = t;
obj.x = p1.x + t*dx;
obj.y = p1.y + t*dy;
} else if (obj.constr === 'on_circle') {
const circ = this.eng.get(obj.srcCircle);
if (!circ) return;
let cx, cy, r;
if (circ.derived && circ.cx != null) {
cx = circ.cx; cy = circ.cy; r = circ.r;
} else {
const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId);
if (!mc || !me) return;
cx = mc.x; cy = mc.y; r = gDist(mc, me);
}
const theta = Math.atan2(my - cy, mx - cx);
obj._theta = theta;
obj.x = cx + r * Math.cos(theta);
obj.y = cy + r * Math.sin(theta);
}
this.eng.propagateDeps(id);
}
_onMove(e) {
const { px, py } = this._evPos(e);
@@ -2363,6 +2837,22 @@ class GeoSim {
const m = this.vp.toMath(px, py);
if (this._drag) {
if (this._drag.chipDrag) {
// Перетаскивание чипа измерения
const obj = this.eng.get(this._drag.id);
if (obj) {
const basePos = this._measureLabelBasePos(obj);
if (basePos) {
obj.offX = (px - this._drag.offX) - basePos.x;
obj.offY = (py - this._drag.offY) - basePos.y;
}
}
this.render(); return;
}
if (this._drag.constrained) {
this._moveConstrainedPoint(this._drag.id, m.x, m.y);
this.render(); return;
}
const snapped = this._computeSnap(m.x, m.y);
this.eng.movePoint(this._drag.id, snapped.x, snapped.y);
this.render(); return;
@@ -2607,6 +3097,11 @@ class GeoSim {
scale_2: 'Кликни точку P — построим P\' = O + k·(P O)',
thales_2: 'Кликни точку A (на первом луче)',
thales_3: 'Кликни точку B (на втором луче) — построим A\'B\' ∥ AB',
measure_angle_2: 'Кликни вершину угла',
measure_angle_3: 'Кликни вторую точку на стороне угла — измерение готово',
locus_2: 'Кликни целевую точку, зависящую от мовера — построим ГМТ',
point_on_segment_1: 'Кликни на отрезок — точка прикрепится к нему и будет по нему скользить',
point_on_circle_1: 'Кликни на окружность — точка прикрепится к ней и будет по ней скользить',
};
function _geoShowHint(name, phase) {
@@ -2661,7 +3156,9 @@ class GeoSim {
const msg = document.getElementById('geo-del-msg');
if (!panel || !msg) { hardFn(); return; }
const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч',
circle:'окружность', polygon:'многоугольник', derived_line:'построение' };
circle:'окружность', polygon:'многоугольник', derived_line:'построение',
measure_length:'измерение длины', measure_angle:'измерение угла',
measure_area:'измерение площади', locus:'ГМТ' };
const n = names[obj.type] || 'объект';
msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`;
_geoDelSoftFn = softFn;
@@ -2672,6 +3169,20 @@ class GeoSim {
document.getElementById('geo-del-confirm')?.classList.remove('visible');
_geoDelSoftFn = _geoDelHardFn = null;
}
/* Показать inline-сообщение об ошибке ГМТ (временно заменяет hint-bar) */
function _geoShowLocusError(msg) {
const hint = document.getElementById('geo-hint');
if (!hint) return;
const prev = hint.textContent;
hint.textContent = msg;
hint.style.color = '#f87171';
setTimeout(() => {
hint.textContent = prev;
hint.style.color = '';
}, 2800);
}
// Кнопки диалога — подключаем после DOM ready
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('geo-del-soft')?.addEventListener('click', () => {
@@ -2702,6 +3213,7 @@ class GeoSim {
geomSim.onUpdate = _geoUpdateStats;
geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase);
geomSim.onDeleteRequest = _geoShowDeleteConfirm;
geomSim.onLocusError = _geoShowLocusError;
// keyboard shortcuts
canvas.setAttribute('tabindex', '0');