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:
Maxim Dolgolyov
2026-04-14 10:00:00 +03:00
parent 35849cf231
commit 95cca89dfc
2 changed files with 440 additions and 44 deletions
+373 -29
View File
@@ -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
View File
@@ -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');