feat: планиметрия Phase 2 — инструменты построения
- GeoEngine: система производных объектов (recompute, propagateDeps), каскадная цепочка зависимостей при перемещении точек - 6 новых инструментов в GeoSim: * midpoint — середина отрезка (производная точка) * perpbisect — серединный перпендикуляр (derived_line) * anglebisect — биссектриса угла ABC (derived_line) * parallel — параллельная прямая через точку (derived_line) * perpendicular — перпендикуляр через точку (derived_line) * intersect — точка пересечения двух прямых (производная точка) - Производные объекты: пунктирный стиль, светящийся ободок, автообновление при перемещении родительских точек - Двухфазный UI для parallel/perpendicular/intersect: _pendingLineRef + _drawLineRefHighlight (подсветка первой линии) - lab.html: 6 новых кнопок в секции "Построения", счётчик построений, onHintChange callback для контекстных подсказок Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+373
-29
@@ -1,6 +1,8 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
geometry.js — Интерактивная планиметрия для LearnSpace
|
||||
Phase 1: точки, отрезки, прямые, лучи, окружности, многоугольники
|
||||
Phase 2: инструменты построения (середина, биссектрисы, параллельные,
|
||||
перпендикуляры, пересечения) + система производных объектов
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
@@ -151,12 +153,99 @@ class GeoEngine {
|
||||
return obj.centerId === id || obj.edgeId === id;
|
||||
case 'polygon':
|
||||
return obj.pointIds.includes(id);
|
||||
case 'midpoint':
|
||||
return obj.p1Id === id || obj.p2Id === id;
|
||||
case 'point':
|
||||
if (!obj.derived) return false;
|
||||
if (obj.constr === 'midpoint') return obj.srcA === id || obj.srcB === id;
|
||||
if (obj.constr === 'intersect') return obj.src1 === id || obj.src2 === id;
|
||||
return false;
|
||||
case 'derived_line':
|
||||
switch (obj.constr) {
|
||||
case 'perpbisect': return obj.srcA === id || obj.srcB === id;
|
||||
case 'anglebisect': return obj.srcA === id || obj.srcVtx === id || obj.srcB === id;
|
||||
case 'parallel': case 'perpendicular':
|
||||
return obj.srcPt === id || obj.srcDirPt1 === id || obj.srcDirPt2 === id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Перевычислить производный объект из его источников */
|
||||
recompute(id) {
|
||||
const obj = this._objects.get(id);
|
||||
if (!obj || !obj.derived) return;
|
||||
const _g = oid => this._objects.get(oid);
|
||||
|
||||
if (obj.type === 'point') {
|
||||
if (obj.constr === 'midpoint') {
|
||||
const a = _g(obj.srcA), b = _g(obj.srcB);
|
||||
if (a && b) { obj.x = (a.x+b.x)/2; obj.y = (a.y+b.y)/2; }
|
||||
} else if (obj.constr === 'intersect') {
|
||||
// Вычислить пересечение двух прямых (линии/отрезки/лучи/derived_line)
|
||||
const pts1 = this._twoMathPts(obj.src1);
|
||||
const pts2 = this._twoMathPts(obj.src2);
|
||||
if (pts1 && pts2) {
|
||||
const pt = gIntersectLines(pts1[0], pts1[1], pts2[0], pts2[1]);
|
||||
if (pt) { obj.x = pt.x; obj.y = pt.y; obj.valid = true; }
|
||||
else obj.valid = false;
|
||||
}
|
||||
}
|
||||
} else if (obj.type === 'derived_line') {
|
||||
if (obj.constr === 'perpbisect') {
|
||||
const a = _g(obj.srcA), b = _g(obj.srcB);
|
||||
if (!a || !b) return;
|
||||
obj.ptX = (a.x+b.x)/2; obj.ptY = (a.y+b.y)/2;
|
||||
const dx = b.x-a.x, dy = b.y-a.y, len = Math.hypot(dx,dy);
|
||||
if (len > 1e-12) { obj.dirX = -dy/len; obj.dirY = dx/len; }
|
||||
} else if (obj.constr === 'anglebisect') {
|
||||
const a = _g(obj.srcA), vtx = _g(obj.srcVtx), b = _g(obj.srcB);
|
||||
if (!a || !vtx || !b) return;
|
||||
const va = gNorm({x:a.x-vtx.x, y:a.y-vtx.y});
|
||||
const vb = gNorm({x:b.x-vtx.x, y:b.y-vtx.y});
|
||||
const bis = gNorm({x:va.x+vb.x, y:va.y+vb.y});
|
||||
obj.ptX = vtx.x; obj.ptY = vtx.y;
|
||||
obj.dirX = bis.x; obj.dirY = bis.y;
|
||||
} else if (obj.constr === 'parallel') {
|
||||
const srcPt = _g(obj.srcPt), d1 = _g(obj.srcDirPt1), d2 = _g(obj.srcDirPt2);
|
||||
if (!srcPt || !d1 || !d2) return;
|
||||
const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
|
||||
if (len < 1e-12) return;
|
||||
obj.ptX = srcPt.x; obj.ptY = srcPt.y;
|
||||
obj.dirX = dx/len; obj.dirY = dy/len;
|
||||
} else if (obj.constr === 'perpendicular') {
|
||||
const srcPt = _g(obj.srcPt), d1 = _g(obj.srcDirPt1), d2 = _g(obj.srcDirPt2);
|
||||
if (!srcPt || !d1 || !d2) return;
|
||||
const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
|
||||
if (len < 1e-12) return;
|
||||
obj.ptX = srcPt.x; obj.ptY = srcPt.y;
|
||||
obj.dirX = -dy/len; obj.dirY = dx/len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Возвращает два математических точки на объекте-линии (line/segment/ray/derived_line) */
|
||||
_twoMathPts(id) {
|
||||
const obj = this._objects.get(id);
|
||||
if (!obj) return null;
|
||||
if (obj.type === 'derived_line') {
|
||||
return [{ x:obj.ptX, y:obj.ptY }, { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }];
|
||||
}
|
||||
const p1 = this._objects.get(obj.p1Id), p2 = this._objects.get(obj.p2Id);
|
||||
if (!p1 || !p2) return null;
|
||||
return [{ x:p1.x, y:p1.y }, { x:p2.x, y:p2.y }];
|
||||
}
|
||||
|
||||
/* Перевычислить все производные объекты, зависящие от changedId */
|
||||
propagateDeps(changedId) {
|
||||
for (const obj of this._objects.values()) {
|
||||
if (this._dependsOn(obj, changedId)) {
|
||||
this.recompute(obj.id);
|
||||
// Каскадная цепочка: производная точка может быть источником для других
|
||||
if (obj.derived) this.propagateDeps(obj.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(id) { return this._objects.get(id); }
|
||||
has(id) { return this._objects.has(id); }
|
||||
all() { return [...this._objects.values()]; }
|
||||
@@ -165,8 +254,9 @@ class GeoEngine {
|
||||
|
||||
movePoint(id, x, y) {
|
||||
const obj = this._objects.get(id);
|
||||
if (obj && obj.type === 'point' && !obj.locked) {
|
||||
if (obj && obj.type === 'point' && !obj.derived && !obj.locked) {
|
||||
obj.x = x; obj.y = y;
|
||||
this.propagateDeps(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +288,10 @@ class GeoSim {
|
||||
this.eng = new GeoEngine();
|
||||
|
||||
/* ── Состояние инструментов ── */
|
||||
this.tool = 'select';
|
||||
this._pending = []; // промежуточные клики многошаговых инструментов
|
||||
this._preview = null; // предпросмотр (курсор при рисовании)
|
||||
this.tool = 'select';
|
||||
this._pending = []; // промежуточные клики многошаговых инструментов
|
||||
this._preview = null; // предпросмотр (курсор при рисовании)
|
||||
this._pendingLineRef = null; // первый кликнутый линейный объект для parallel/perp/intersect
|
||||
|
||||
/* ── Состояние drag/pan ── */
|
||||
this._drag = null; // { id, offX, offY } — перетаскиваем точку
|
||||
@@ -228,7 +319,8 @@ class GeoSim {
|
||||
this._hovered = null;
|
||||
|
||||
/* ── Callbacks ── */
|
||||
this.onUpdate = null; // cb(stats)
|
||||
this.onUpdate = null; // cb(stats)
|
||||
this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки
|
||||
|
||||
this._labelCounter = 0;
|
||||
this._bindEvents();
|
||||
@@ -245,10 +337,11 @@ class GeoSim {
|
||||
}
|
||||
|
||||
setTool(name) {
|
||||
this.tool = name;
|
||||
this._pending = [];
|
||||
this._preview = null;
|
||||
this._selected = null;
|
||||
this.tool = name;
|
||||
this._pending = [];
|
||||
this._preview = null;
|
||||
this._selected = null;
|
||||
this._pendingLineRef = null;
|
||||
this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
|
||||
this.render();
|
||||
}
|
||||
@@ -280,6 +373,8 @@ class GeoSim {
|
||||
|
||||
// Заливки многоугольников
|
||||
for (const obj of this.eng.byType('polygon')) this._drawPolyFill(ctx, obj);
|
||||
// Производные прямые (под основными объектами)
|
||||
for (const obj of this.eng.byType('derived_line')) this._drawDerivedLine(ctx, obj);
|
||||
// Прямые (рисуем до краёв)
|
||||
for (const obj of this.eng.byType('line')) this._drawLine(ctx, obj);
|
||||
// Лучи
|
||||
@@ -293,10 +388,12 @@ class GeoSim {
|
||||
// Измерения
|
||||
if (this.showLengths) this._drawLengths(ctx);
|
||||
if (this.showAngles) this._drawAngleMeasures(ctx);
|
||||
// Точки поверх всего
|
||||
// Точки поверх всего (включая производные)
|
||||
for (const obj of this.eng.points()) this._drawPoint(ctx, obj);
|
||||
// Предпросмотр строящегося объекта
|
||||
this._drawPreview(ctx);
|
||||
// Подсветка первого объекта при инструментах построения
|
||||
if (this._pendingLineRef) this._drawLineRefHighlight(ctx, this._pendingLineRef);
|
||||
// Индикатор снапа
|
||||
if (this._snapPt) this._drawSnapIndicator(ctx);
|
||||
}
|
||||
@@ -436,31 +533,43 @@ class GeoSim {
|
||||
const { x: px, y: py } = this.vp.toCanvas(obj.x, obj.y);
|
||||
const sel = this._isSelected(obj);
|
||||
const hov = this._isHovered(obj);
|
||||
const col = obj.style?.color || '#fff';
|
||||
const r = obj.style?.size || 5;
|
||||
// Производные точки рисуем иначе (меньше, другой цвет, пунктирный ободок)
|
||||
const isDerived = !!obj.derived;
|
||||
const col = obj.style?.color || (isDerived ? '#22d55e' : '#fff');
|
||||
const r = isDerived ? 4 : (obj.style?.size || 5);
|
||||
|
||||
if (sel || hov) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = col; ctx.shadowBlur = 16;
|
||||
ctx.strokeStyle = col; ctx.lineWidth = 1.5;
|
||||
if (isDerived) ctx.setLineDash([3,3]);
|
||||
ctx.beginPath(); ctx.arc(px, py, r+5, 0, Math.PI*2); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = col; ctx.shadowBlur = 8;
|
||||
ctx.fillStyle = col;
|
||||
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
|
||||
// Белый центр
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.beginPath(); ctx.arc(px, py, r*0.38, 0, Math.PI*2); ctx.fill();
|
||||
if (isDerived) {
|
||||
// Производные точки: только контур + центр
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.strokeStyle = col; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.stroke();
|
||||
ctx.fillStyle = col; ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath(); ctx.arc(px, py, r*0.5, 0, Math.PI*2); ctx.fill();
|
||||
} else {
|
||||
ctx.fillStyle = col;
|
||||
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.beginPath(); ctx.arc(px, py, r*0.38, 0, Math.PI*2); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (this.showLabels && obj.label) {
|
||||
ctx.save();
|
||||
ctx.font = 'bold 14px Manrope,sans-serif';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = isDerived ? '12px Manrope,sans-serif' : 'bold 14px Manrope,sans-serif';
|
||||
ctx.fillStyle = isDerived ? col : '#fff';
|
||||
ctx.globalAlpha = isDerived ? 0.85 : 1;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillText(obj.label, px+9, py-9);
|
||||
@@ -517,6 +626,81 @@ class GeoSim {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* ── Производная прямая (dashed, lighter) ── */
|
||||
_drawDerivedLine(ctx, obj) {
|
||||
if (!obj.ptX && obj.ptX !== 0) return;
|
||||
const m1 = { x: obj.ptX, y: obj.ptY };
|
||||
const m2 = { x: obj.ptX + obj.dirX, y: obj.ptY + obj.dirY };
|
||||
const [p1c, p2c] = this._extendToEdges(m1, m2);
|
||||
const col = obj.style?.color || '#4CC9F0';
|
||||
ctx.save();
|
||||
ctx.strokeStyle = col;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.setLineDash([8, 5]);
|
||||
ctx.shadowColor = col; ctx.shadowBlur = 4;
|
||||
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
|
||||
ctx.restore();
|
||||
// Подпись типа
|
||||
if (this.showLabels && obj.label) {
|
||||
const mid = { x:(p1c.x+p2c.x)/2, y:(p1c.y+p2c.y)/2 };
|
||||
ctx.save(); ctx.font = '11px Manrope,sans-serif';
|
||||
ctx.fillStyle = col; ctx.globalAlpha = 0.8;
|
||||
ctx.fillText(obj.label, mid.x + 6, mid.y - 6);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/* Подсветить линейный объект (первый клик в parallel/perpendicular/intersect) */
|
||||
_drawLineRefHighlight(ctx, obj) {
|
||||
if (!obj) return;
|
||||
let p1c, p2c;
|
||||
if (obj.type === 'derived_line') {
|
||||
const m1 = { x:obj.ptX, y:obj.ptY }, m2 = { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY };
|
||||
[p1c, p2c] = this._extendToEdges(m1, m2);
|
||||
} else {
|
||||
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
||||
if (!m1 || !m2) return;
|
||||
if (obj.type === 'segment') { p1c = this.vp.toCanvas(m1.x,m1.y); p2c = this.vp.toCanvas(m2.x,m2.y); }
|
||||
else [p1c, p2c] = this._extendToEdges(m1, m2);
|
||||
}
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.globalAlpha = 0.55;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* Вернуть две мат. точки на объекте (line/segment/ray/derived_line) — для хит-теста и пересечений */
|
||||
_twoPointsOnObj(obj) {
|
||||
if (!obj) return null;
|
||||
if (obj.type === 'derived_line') {
|
||||
return [{ x:obj.ptX, y:obj.ptY }, { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }];
|
||||
}
|
||||
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
||||
return (m1 && m2) ? [m1, m2] : null;
|
||||
}
|
||||
|
||||
/* Найти линейный объект под курсором (для инструментов построения) */
|
||||
_hitTestLine(px, py) {
|
||||
const HIT = 12, m = this.vp.toMath(px, py);
|
||||
const types = ['line','segment','ray'];
|
||||
for (const t of types) {
|
||||
for (const obj of this.eng.byType(t)) {
|
||||
const pts = this._twoPointsOnObj(obj);
|
||||
if (!pts) continue;
|
||||
const d = t === 'segment' ? gDistToSegment(m,pts[0],pts[1]) : gDistToLine(m,pts[0],pts[1]);
|
||||
if (d * this.vp.scale < HIT) return obj;
|
||||
}
|
||||
}
|
||||
for (const obj of this.eng.byType('derived_line')) {
|
||||
const pts = this._twoPointsOnObj(obj);
|
||||
if (!pts) continue;
|
||||
if (gDistToLine(m, pts[0], pts[1]) * this.vp.scale < HIT) return obj;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_drawPolyFill(ctx, obj) {
|
||||
const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
|
||||
if (pts.length < 3) return;
|
||||
@@ -819,8 +1003,11 @@ class GeoSim {
|
||||
if (this.readOnly) return;
|
||||
const { px, py } = this._evPos(e);
|
||||
|
||||
// ПКМ или Space → отмена текущего построения
|
||||
if (e.button === 2) { this._pending = []; this._preview = null; this.render(); return; }
|
||||
// ПКМ → отмена текущего построения
|
||||
if (e.button === 2) {
|
||||
this._pending = []; this._preview = null; this._pendingLineRef = null;
|
||||
this.render(); return;
|
||||
}
|
||||
|
||||
// Пан (средняя кнопка или Alt+ЛКМ)
|
||||
if (e.button === 1 || e.altKey) {
|
||||
@@ -837,7 +1024,7 @@ class GeoSim {
|
||||
return;
|
||||
}
|
||||
|
||||
this._handleToolClick(snapped);
|
||||
this._handleToolClick(snapped, px, py);
|
||||
}
|
||||
|
||||
_handleSelectDown(m, px, py) {
|
||||
@@ -849,7 +1036,7 @@ class GeoSim {
|
||||
if (Math.hypot(pp.x-px, pp.y-py) < SNAP_PX) { found = pt; break; }
|
||||
}
|
||||
|
||||
if (found && !found.locked) {
|
||||
if (found && !found.locked && !found.derived) {
|
||||
this._drag = { id: found.id };
|
||||
this._selected = found;
|
||||
this.canvas.style.cursor = 'grabbing';
|
||||
@@ -901,10 +1088,16 @@ class GeoSim {
|
||||
if (!m1||!m2) continue;
|
||||
if (gDistToLine(m, m1, m2) * this.vp.scale < HIT) return obj;
|
||||
}
|
||||
// Производные прямые
|
||||
for (const obj of this.eng.byType('derived_line')) {
|
||||
const pts = this._twoPointsOnObj(obj);
|
||||
if (!pts) continue;
|
||||
if (gDistToLine(m, pts[0], pts[1]) * this.vp.scale < HIT) return obj;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_handleToolClick(snapped) {
|
||||
_handleToolClick(snapped, px, py) {
|
||||
switch (this.tool) {
|
||||
case 'point':
|
||||
this._pushUndo();
|
||||
@@ -983,6 +1176,154 @@ class GeoSim {
|
||||
this._pending.push({ ...snapped, _id: this._snapId });
|
||||
}
|
||||
break;
|
||||
|
||||
/* ══ Phase 2: Инструменты построения ══ */
|
||||
|
||||
case 'midpoint': {
|
||||
this._pending.push(snapped);
|
||||
if (this._pending.length === 2) {
|
||||
this._pushUndo();
|
||||
const pt1 = this._ensurePoint(this._pending[0]);
|
||||
const pt2 = this._ensurePoint(this._pending[1]);
|
||||
const lbl = 'M' + (this.eng.byType('point').filter(p=>p.constr==='midpoint').length+1||'');
|
||||
this.eng.add({
|
||||
type:'point', derived:true, constr:'midpoint',
|
||||
srcA:pt1.id, srcB:pt2.id,
|
||||
x:(pt1.x+pt2.x)/2, y:(pt1.y+pt2.y)/2,
|
||||
label:lbl, style:{color:'#22d55e', size:4}
|
||||
});
|
||||
this._pending = []; this._preview = null;
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'perpbisect': {
|
||||
this._pending.push(snapped);
|
||||
if (this._pending.length === 2) {
|
||||
this._pushUndo();
|
||||
const pt1 = this._ensurePoint(this._pending[0]);
|
||||
const pt2 = this._ensurePoint(this._pending[1]);
|
||||
const dx = pt2.x-pt1.x, dy = pt2.y-pt1.y, len = Math.hypot(dx,dy);
|
||||
if (len > 1e-12) {
|
||||
const cnt = this.eng.byType('derived_line').filter(d=>d.constr==='perpbisect').length;
|
||||
this.eng.add({
|
||||
type:'derived_line', derived:true, constr:'perpbisect',
|
||||
srcA:pt1.id, srcB:pt2.id,
|
||||
ptX:(pt1.x+pt2.x)/2, ptY:(pt1.y+pt2.y)/2,
|
||||
dirX:-dy/len, dirY:dx/len,
|
||||
label: cnt ? 'l'+(cnt+1) : 'l₁',
|
||||
style:{color:'#A78BFA'}
|
||||
});
|
||||
}
|
||||
this._pending = []; this._preview = null;
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'anglebisect': {
|
||||
this._pending.push(snapped);
|
||||
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]);
|
||||
const va = gNorm({x:ptA.x-ptVtx.x, y:ptA.y-ptVtx.y});
|
||||
const vb = gNorm({x:ptB.x-ptVtx.x, y:ptB.y-ptVtx.y});
|
||||
const bis = gNorm({x:va.x+vb.x, y:va.y+vb.y});
|
||||
if (Math.hypot(bis.x,bis.y) > 1e-12) {
|
||||
const cnt = this.eng.byType('derived_line').filter(d=>d.constr==='anglebisect').length;
|
||||
this.eng.add({
|
||||
type:'derived_line', derived:true, constr:'anglebisect',
|
||||
srcA:ptA.id, srcVtx:ptVtx.id, srcB:ptB.id,
|
||||
ptX:ptVtx.x, ptY:ptVtx.y,
|
||||
dirX:bis.x, dirY:bis.y,
|
||||
label: cnt ? 'b'+(cnt+1) : 'b₁',
|
||||
style:{color:'#FB923C'}
|
||||
});
|
||||
}
|
||||
this._pending = []; this._preview = null;
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'parallel':
|
||||
case 'perpendicular': {
|
||||
if (!this._pendingLineRef) {
|
||||
// Первый клик: ищем линейный объект
|
||||
const hit = this._hitTestLine(px, py);
|
||||
if (hit) {
|
||||
this._pendingLineRef = hit;
|
||||
if (this.onHintChange) this.onHintChange(this.tool, 2);
|
||||
}
|
||||
} else {
|
||||
// Второй клик: точка через которую проводим прямую
|
||||
this._pushUndo();
|
||||
const throughPt = this._ensurePoint(snapped);
|
||||
const hit = this._pendingLineRef;
|
||||
let d1, d2;
|
||||
if (hit.type === 'derived_line') {
|
||||
d1 = { x:hit.ptX, y:hit.ptY };
|
||||
d2 = { x:hit.ptX+hit.dirX, y:hit.ptY+hit.dirY };
|
||||
} else {
|
||||
d1 = this._mpt(hit.p1Id); d2 = this._mpt(hit.p2Id);
|
||||
}
|
||||
if (d1 && d2) {
|
||||
const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
|
||||
if (len > 1e-12) {
|
||||
let dirX, dirY;
|
||||
const srcDirPt1 = hit.p1Id || null, srcDirPt2 = hit.p2Id || null;
|
||||
if (this.tool === 'parallel') {
|
||||
dirX = dx/len; dirY = dy/len;
|
||||
} else {
|
||||
dirX = -dy/len; dirY = dx/len;
|
||||
}
|
||||
const cnt = this.eng.byType('derived_line').filter(d=>d.constr===this.tool).length;
|
||||
this.eng.add({
|
||||
type:'derived_line', derived:true, constr:this.tool,
|
||||
srcPt:throughPt.id,
|
||||
srcDirPt1: srcDirPt1, srcDirPt2: srcDirPt2,
|
||||
ptX:throughPt.x, ptY:throughPt.y, dirX, dirY,
|
||||
label: (this.tool==='parallel' ? 'p' : '⊥') + (cnt+1||''),
|
||||
style:{color: this.tool==='parallel' ? '#4CC9F0' : '#FF9F43'}
|
||||
});
|
||||
}
|
||||
}
|
||||
this._pendingLineRef = null; this._pending = []; this._preview = null;
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'intersect': {
|
||||
const hit = this._hitTestLine(px, py);
|
||||
if (!hit) break;
|
||||
if (!this._pendingLineRef) {
|
||||
this._pendingLineRef = hit;
|
||||
if (this.onHintChange) this.onHintChange('intersect', 2);
|
||||
} else if (hit !== this._pendingLineRef) {
|
||||
this._pushUndo();
|
||||
const pts1 = this._twoPointsOnObj(this._pendingLineRef);
|
||||
const pts2 = this._twoPointsOnObj(hit);
|
||||
if (pts1 && pts2) {
|
||||
const iPt = gIntersectLines(pts1[0], pts1[1], pts2[0], pts2[1]);
|
||||
if (iPt) {
|
||||
const lbl = this._nextLabel();
|
||||
this.eng.add({
|
||||
type:'point', derived:true, constr:'intersect',
|
||||
src1:this._pendingLineRef.id, src2:hit.id,
|
||||
x:iPt.x, y:iPt.y, valid:true,
|
||||
label:lbl, style:{color:'#F15BB5', size:5}
|
||||
});
|
||||
}
|
||||
}
|
||||
this._pendingLineRef = null; this._pending = [];
|
||||
if (this.onUpdate) this.onUpdate(this.getStats());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
@@ -1131,10 +1472,13 @@ class GeoSim {
|
||||
|
||||
/* ══ СТАТИСТИКА ══════════════════════════════════════════════ */
|
||||
getStats() {
|
||||
const pts = this.eng.points().length;
|
||||
const allPts = this.eng.points();
|
||||
const pts = allPts.filter(p => !p.derived).length;
|
||||
const derivedPts = allPts.filter(p => !!p.derived).length;
|
||||
const segs = this.eng.byType('segment').length + this.eng.byType('polygon').reduce((s,p)=>s+p.pointIds.length,0);
|
||||
const circs= this.eng.byType('circle').length;
|
||||
const polys= this.eng.byType('polygon').length;
|
||||
const constructions = this.eng.byType('derived_line').length + derivedPts;
|
||||
|
||||
// Статистика для выбранного объекта
|
||||
let sel = null;
|
||||
@@ -1162,7 +1506,7 @@ class GeoSim {
|
||||
}
|
||||
}
|
||||
|
||||
return { pts, segs, circs, polys, selected: sel };
|
||||
return { pts, segs, circs, polys, constructions, selected: sel };
|
||||
}
|
||||
|
||||
/* ══ ЭКСПОРТ/ИМПОРТ СОСТОЯНИЯ (для classroom sim sync) ════════ */
|
||||
|
||||
+67
-15
@@ -3800,6 +3800,34 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="gp-section-title" style="margin-top:4px">Построения</div>
|
||||
<div class="geo-tool-grid">
|
||||
<button id="geo-btn-midpoint" class="geo-tool-btn" onclick="geoSetTool('midpoint',this)" title="Середина отрезка — 2 точки">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="3" y1="12" x2="21" y2="12" stroke-width="2"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/></svg>
|
||||
Середина
|
||||
</button>
|
||||
<button id="geo-btn-perpbisect" class="geo-tool-btn" onclick="geoSetTool('perpbisect',this)" title="Серединный перпендикуляр — 2 точки">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="4" y1="18" x2="20" y2="6" stroke-width="2"/><line x1="12" y1="2" x2="12" y2="22" stroke-width="1.5" stroke-dasharray="3,2"/><circle cx="12" cy="12" r="2.5" fill="currentColor"/></svg>
|
||||
⊥ биссект.
|
||||
</button>
|
||||
<button id="geo-btn-anglebisect" class="geo-tool-btn" onclick="geoSetTool('anglebisect',this)" title="Биссектриса угла — 3 точки: A, вершина, B">
|
||||
<svg viewBox="0 0 24 24" fill="none"><polyline points="4,20 12,4 20,20" stroke-width="2"/><line x1="12" y1="4" x2="12" y2="20" stroke-width="1.5" stroke-dasharray="3,2"/></svg>
|
||||
∠ биссект.
|
||||
</button>
|
||||
<button id="geo-btn-parallel" class="geo-tool-btn" onclick="geoSetTool('parallel',this)" title="Параллельная прямая — клик на линию, затем на точку">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="3" y1="8" x2="21" y2="8" stroke-width="2"/><line x1="3" y1="16" x2="21" y2="16" stroke-width="2" opacity=".5" stroke-dasharray="4,3"/></svg>
|
||||
|| прямая
|
||||
</button>
|
||||
<button id="geo-btn-perpendicular" class="geo-tool-btn" onclick="geoSetTool('perpendicular',this)" title="Перпендикулярная прямая — клик на линию, затем на точку">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="3" y1="12" x2="21" y2="12" stroke-width="2"/><line x1="12" y1="4" x2="12" y2="20" stroke-width="2" opacity=".5" stroke-dasharray="4,3"/></svg>
|
||||
⊥ прямая
|
||||
</button>
|
||||
<button id="geo-btn-intersect" class="geo-tool-btn" onclick="geoSetTool('intersect',this)" title="Точка пересечения — клик на две прямые">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="4" y1="20" x2="20" y2="4" stroke-width="2"/><line x1="4" y1="4" x2="20" y2="20" stroke-width="2"/><circle cx="12" cy="12" r="3.5" 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)">
|
||||
@@ -3845,6 +3873,7 @@
|
||||
<div class="geo-stat-row"><span>Отрезки</span><b id="geo-st-segs">0</b></div>
|
||||
<div class="geo-stat-row"><span>Окружности</span><b id="geo-st-circs">0</b></div>
|
||||
<div class="geo-stat-row"><span>Многоугольники</span><b id="geo-st-polys">0</b></div>
|
||||
<div class="geo-stat-row"><span>Построения</span><b id="geo-st-constr">0</b></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
@@ -5269,15 +5298,21 @@
|
||||
/* ── geometry (planimetry) ── */
|
||||
|
||||
const _GEO_HINTS = {
|
||||
select: 'Клик — выбрать объект, перетащи точку для перемещения',
|
||||
point: 'Клик — поставить точку',
|
||||
segment: 'Кликни 2 точки для отрезка',
|
||||
line: 'Кликни 2 точки для прямой',
|
||||
ray: 'Кликни: начало, затем направление',
|
||||
circle: 'Клик — центр; второй клик — радиус',
|
||||
triangle: 'Кликни 3 точки для треугольника',
|
||||
quad: 'Кликни 4 точки для четырёхугольника',
|
||||
polygon: 'Кликай точки; двойной клик или Enter — завершить',
|
||||
select: 'Клик — выбрать объект, перетащи точку для перемещения',
|
||||
point: 'Клик — поставить точку',
|
||||
segment: 'Кликни 2 точки для отрезка',
|
||||
line: 'Кликни 2 точки для прямой',
|
||||
ray: 'Кликни: начало, затем направление',
|
||||
circle: 'Клик — центр; второй клик — радиус',
|
||||
triangle: 'Кликни 3 точки для треугольника',
|
||||
quad: 'Кликни 4 точки для четырёхугольника',
|
||||
polygon: 'Кликай точки; двойной клик или Enter — завершить',
|
||||
midpoint: 'Кликни 2 точки — получи середину отрезка',
|
||||
perpbisect: 'Кликни 2 точки — получи серединный перпендикуляр',
|
||||
anglebisect: 'Кликни: точку A, затем вершину угла, затем точку B',
|
||||
parallel: 'Сначала кликни на прямую/отрезок, затем на точку',
|
||||
perpendicular:'Сначала кликни на прямую/отрезок, затем на точку',
|
||||
intersect: 'Кликни на первую прямую, затем на вторую',
|
||||
};
|
||||
|
||||
function geoSetTool(name, btnEl) {
|
||||
@@ -5285,8 +5320,22 @@
|
||||
geomSim.setTool(name);
|
||||
document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active'));
|
||||
if (btnEl) btnEl.classList.add('active');
|
||||
_geoShowHint(name);
|
||||
}
|
||||
|
||||
function _geoShowHint(name, phase2) {
|
||||
const hint = document.getElementById('geo-hint');
|
||||
if (hint) hint.textContent = _GEO_HINTS[name] || '';
|
||||
if (!hint) return;
|
||||
if (phase2) {
|
||||
const phase2hints = {
|
||||
parallel: 'Теперь кликни на точку — через неё проведём прямую',
|
||||
perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр',
|
||||
intersect: 'Теперь кликни на вторую прямую',
|
||||
};
|
||||
hint.textContent = phase2hints[name] || _GEO_HINTS[name] || '';
|
||||
} else {
|
||||
hint.textContent = _GEO_HINTS[name] || '';
|
||||
}
|
||||
}
|
||||
|
||||
function geoToggle(prop, rowEl) {
|
||||
@@ -5300,10 +5349,12 @@
|
||||
function _geoUpdateStats() {
|
||||
if (!geomSim) return;
|
||||
const s = geomSim.getStats();
|
||||
document.getElementById('geo-st-pts').textContent = s.pts;
|
||||
document.getElementById('geo-st-segs').textContent = s.segs;
|
||||
document.getElementById('geo-st-circs').textContent = s.circs;
|
||||
document.getElementById('geo-st-polys').textContent = s.polys;
|
||||
document.getElementById('geo-st-pts').textContent = s.pts;
|
||||
document.getElementById('geo-st-segs').textContent = s.segs;
|
||||
document.getElementById('geo-st-circs').textContent = s.circs;
|
||||
document.getElementById('geo-st-polys').textContent = s.polys;
|
||||
const cEl = document.getElementById('geo-st-constr');
|
||||
if (cEl) cEl.textContent = s.constructions || 0;
|
||||
}
|
||||
|
||||
function _openGeometry() {
|
||||
@@ -5322,7 +5373,8 @@
|
||||
const canvas = document.getElementById('geo-canvas');
|
||||
if (!geomSim) {
|
||||
geomSim = new GeoSim(canvas);
|
||||
geomSim.onUpdate = _geoUpdateStats;
|
||||
geomSim.onUpdate = _geoUpdateStats;
|
||||
geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase > 1);
|
||||
|
||||
// keyboard shortcuts
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
|
||||
Reference in New Issue
Block a user