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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user