Files
Learn_System/frontend/js/labs/geometry.js
T
Maxim Dolgolyov 35849cf231 feat: планиметрия — интерактивная геометрическая симуляция
- Новый файл frontend/js/labs/geometry.js (~1200 строк):
  GeoEngine (граф объектов с каскадным удалением),
  GeoViewport (система координат math↔canvas, зум/пан),
  GeoSim (полный движок: точки, отрезки, прямые, лучи,
  окружности, треугольники, многоугольники, привязка к сетке
  и точкам, undo/redo, экспорт PNG, classroom sync)
- frontend/lab.html: карточка, ctrl, sim-geometry секция,
  функции geoSetTool/geoToggle/_openGeometry, скрипт-тег
- frontend/admin.html: geometry в ADMIN_SIMS
- backend/src/db/migrate.js: таблицы geometry_tasks,
  geometry_submissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 09:40:41 +03:00

1210 lines
42 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════════════
geometry.js — Интерактивная планиметрия для LearnSpace
Phase 1: точки, отрезки, прямые, лучи, окружности, многоугольники
═══════════════════════════════════════════════════════════════════════ */
'use strict';
/* ── Утилиты ─────────────────────────────────────────────────────────── */
function gDist(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }
function gMid(a, b) { return { x: (a.x+b.x)/2, y: (a.y+b.y)/2 }; }
function gDot(a, b) { return a.x*b.x + a.y*b.y; }
function gNorm(v) { const l = Math.hypot(v.x,v.y); return l<1e-12?{x:0,y:0}:{x:v.x/l,y:v.y/l}; }
function gSub(a, b) { return { x: a.x-b.x, y: a.y-b.y }; }
function gAdd(a, b) { return { x: a.x+b.x, y: a.y+b.y }; }
function gScale(v, s) { return { x: v.x*s, y: v.y*s }; }
/** Проекция точки P на прямую через L1-L2 */
function gFoot(P, L1, L2) {
const dx = L2.x-L1.x, dy = L2.y-L1.y;
const l2 = dx*dx + dy*dy;
if (l2 < 1e-12) return { x: L1.x, y: L1.y };
const t = ((P.x-L1.x)*dx + (P.y-L1.y)*dy) / l2;
return { x: L1.x + t*dx, y: L1.y + t*dy };
}
/** Расстояние от точки до прямой через L1-L2 */
function gDistToLine(P, L1, L2) {
return gDist(P, gFoot(P, L1, L2));
}
/** Расстояние от точки до отрезка L1-L2 */
function gDistToSegment(P, L1, L2) {
const dx = L2.x-L1.x, dy = L2.y-L1.y;
const l2 = dx*dx + dy*dy;
if (l2 < 1e-12) return gDist(P, L1);
const t = Math.max(0, Math.min(1, ((P.x-L1.x)*dx+(P.y-L1.y)*dy)/l2));
return gDist(P, { x: L1.x+t*dx, y: L1.y+t*dy });
}
/** Пересечение двух прямых (через A1-A2 и B1-B2) */
function gIntersectLines(A1, A2, B1, B2) {
const dx1 = A2.x-A1.x, dy1 = A2.y-A1.y;
const dx2 = B2.x-B1.x, dy2 = B2.y-B1.y;
const det = dx1*dy2 - dy1*dx2;
if (Math.abs(det) < 1e-10) return null;
const t = ((B1.x-A1.x)*dy2 - (B1.y-A1.y)*dx2) / det;
return { x: A1.x + t*dx1, y: A1.y + t*dy1 };
}
/** Описанная окружность треугольника */
function gCircumcircle(A, B, C) {
const D = 2*(A.x*(B.y-C.y) + B.x*(C.y-A.y) + C.x*(A.y-B.y));
if (Math.abs(D) < 1e-10) return null;
const a2=A.x*A.x+A.y*A.y, b2=B.x*B.x+B.y*B.y, c2=C.x*C.x+C.y*C.y;
const cx = (a2*(B.y-C.y)+b2*(C.y-A.y)+c2*(A.y-B.y))/D;
const cy = (a2*(C.x-B.x)+b2*(A.x-C.x)+c2*(B.x-A.x))/D;
return { cx, cy, r: gDist({x:cx,y:cy}, A) };
}
/** Угол ABC (в градусах, вершина B) */
function gAngleDeg(A, B, C) {
const v1 = gSub(A, B), v2 = gSub(C, B);
const cos = gDot(v1, v2) / (gDist(A,B) * gDist(C,B));
return Math.acos(Math.max(-1, Math.min(1, cos))) * 180 / Math.PI;
}
/** Площадь многоугольника (формула Гаусса) */
function gPolygonArea(pts) {
let area = 0;
for (let i = 0, n = pts.length; i < n; i++) {
const j = (i+1)%n;
area += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
}
return Math.abs(area) / 2;
}
/* ── Viewport (система координат) ───────────────────────────────────── */
class GeoViewport {
constructor() {
this.cx = 0; // центр в мат. координатах
this.cy = 0;
this.scale = 60; // пикселей на единицу
this.W = 800;
this.H = 600;
}
/** Математические → пиксельные */
toCanvas(mx, my) {
return {
x: this.W/2 + (mx - this.cx) * this.scale,
y: this.H/2 - (my - this.cy) * this.scale,
};
}
/** Пиксельные → математические */
toMath(px, py) {
return {
x: (px - this.W/2) / this.scale + this.cx,
y: -((py - this.H/2) / this.scale) + this.cy,
};
}
zoom(factor, px, py) {
const m = this.toMath(px, py);
this.scale = Math.max(10, Math.min(400, this.scale * factor));
// Зафиксировать точку под курсором
this.cx = m.x - (px - this.W/2) / this.scale;
this.cy = m.y + (py - this.H/2) / this.scale;
}
pan(dpx, dpy) {
this.cx -= dpx / this.scale;
this.cy += dpy / this.scale;
}
/** Расстояние в пикселях между двумя мат. точками */
toCanvasDist(md) { return md * this.scale; }
/** Пиксельное расстояние → мат. единицы */
toMathDist(pd) { return pd / this.scale; }
}
/* ── GeoEngine (хранилище объектов) ─────────────────────────────────── */
class GeoEngine {
constructor() {
this._objects = new Map();
this._counter = 0;
}
_newId() { return 'g' + (++this._counter); }
add(obj) {
if (!obj.id) obj.id = this._newId();
obj.style = obj.style || {};
this._objects.set(obj.id, obj);
return obj;
}
remove(id) {
this._objects.delete(id);
// Удалить объекты, зависящие от этой точки
for (const [oid, obj] of this._objects) {
if (this._dependsOn(obj, id)) this._objects.delete(oid);
}
}
_dependsOn(obj, id) {
switch (obj.type) {
case 'segment': case 'line': case 'ray':
return obj.p1Id === id || obj.p2Id === id;
case 'circle':
return obj.centerId === id || obj.edgeId === id;
case 'polygon':
return obj.pointIds.includes(id);
case 'midpoint':
return obj.p1Id === id || obj.p2Id === id;
}
return false;
}
get(id) { return this._objects.get(id); }
has(id) { return this._objects.has(id); }
all() { return [...this._objects.values()]; }
byType(t) { return this.all().filter(o => o.type === t); }
points() { return this.byType('point'); }
movePoint(id, x, y) {
const obj = this._objects.get(id);
if (obj && obj.type === 'point' && !obj.locked) {
obj.x = x; obj.y = y;
}
}
clear() {
this._objects.clear();
this._counter = 0;
}
serialize() {
return JSON.stringify([...this._objects.values()]);
}
deserialize(json) {
this._objects.clear();
const arr = JSON.parse(json);
for (const obj of arr) this._objects.set(obj.id, obj);
this._counter = Math.max(...arr.map(o => parseInt(o.id.slice(1))||0), 0);
}
}
/* ══════════════════════════════════════════════════════════════════════
GeoSim — главный класс интерактивной планиметрии
══════════════════════════════════════════════════════════════════════ */
class GeoSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.vp = new GeoViewport();
this.eng = new GeoEngine();
/* ── Состояние инструментов ── */
this.tool = 'select';
this._pending = []; // промежуточные клики многошаговых инструментов
this._preview = null; // предпросмотр (курсор при рисовании)
/* ── Состояние drag/pan ── */
this._drag = null; // { id, offX, offY } — перетаскиваем точку
this._panning = false;
this._panLast = null;
/* ── Snap ── */
this._snapPt = null; // { x, y } в мат. координатах
this._snapId = null; // ID точки-снапа
/* ── Undo/Redo ── */
this._undoStack = [];
this._redoStack = [];
/* ── Опции ── */
this.showGrid = true;
this.showAxes = true;
this.showLabels = true;
this.showLengths = false;
this.showAngles = false;
this.readOnly = false;
/* ── Выбранный объект ── */
this._selected = null;
this._hovered = null;
/* ── Callbacks ── */
this.onUpdate = null; // cb(stats)
this._labelCounter = 0;
this._bindEvents();
}
/* ── Инициализация ─────────────────────────────────────────── */
fit() {
const c = this.canvas;
c.width = c.offsetWidth || c.parentElement?.offsetWidth || 800;
c.height = c.offsetHeight || c.parentElement?.offsetHeight || 600;
this.vp.W = c.width;
this.vp.H = c.height;
this.render();
}
setTool(name) {
this.tool = name;
this._pending = [];
this._preview = null;
this._selected = null;
this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
this.render();
}
setReadOnly(v) {
this.readOnly = v;
if (v) { this.setTool('select'); this.canvas.style.cursor = 'default'; }
}
/* ── Следующая буква-метка ─────────────────────────────────── */
_nextLabel() {
const used = new Set(this.eng.points().map(p => p.label));
for (let i = 0; i < 26; i++) {
const l = String.fromCharCode(65+i);
if (!used.has(l)) return l;
}
return String(++this._labelCounter);
}
/* ══ РЕНДЕР ══════════════════════════════════════════════════ */
render() {
const ctx = this.ctx;
const { W, H } = this.vp;
if (!W || !H) return;
ctx.clearRect(0, 0, W, H);
this._drawBg(ctx, W, H);
if (this.showGrid) this._drawGrid(ctx, W, H);
// Заливки многоугольников
for (const obj of this.eng.byType('polygon')) this._drawPolyFill(ctx, obj);
// Прямые (рисуем до краёв)
for (const obj of this.eng.byType('line')) this._drawLine(ctx, obj);
// Лучи
for (const obj of this.eng.byType('ray')) this._drawRay(ctx, obj);
// Отрезки
for (const obj of this.eng.byType('segment')) this._drawSegment(ctx, obj);
// Стороны многоугольников
for (const obj of this.eng.byType('polygon')) this._drawPolyStroke(ctx, obj);
// Окружности
for (const obj of this.eng.byType('circle')) this._drawCircle(ctx, obj);
// Измерения
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._snapPt) this._drawSnapIndicator(ctx);
}
_drawBg(ctx, W, H) {
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, '#0a0718');
bg.addColorStop(1, '#0e0d22');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
}
_drawGrid(ctx, W, H) {
const vp = this.vp;
// Определяем шаг сетки в мат. единицах (красивые числа)
const rawStep = 80 / vp.scale; // примерно 80px между линиями
const exp = Math.floor(Math.log10(rawStep));
const frac = rawStep / Math.pow(10, exp);
let step = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
step *= Math.pow(10, exp);
step = Math.max(0.001, step);
const pxStep = step * vp.scale;
// Математические границы видимой области
const mLeft = vp.toMath(0, 0).x;
const mRight = vp.toMath(W, 0).x;
const mBot = vp.toMath(0, H).y;
const mTop = vp.toMath(0, 0).y;
const x0 = Math.floor(mLeft / step) * step;
const y0 = Math.floor(mBot / step) * step;
// Подсетка (× 0.2)
if (pxStep > 30) {
ctx.strokeStyle = 'rgba(255,255,255,0.025)';
ctx.lineWidth = 0.5;
const sub = step / 5;
for (let mx = Math.floor(mLeft/sub)*sub; mx <= mRight+sub; mx += sub) {
const px = vp.toCanvas(mx, 0).x;
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let my = Math.floor(mBot/sub)*sub; my <= mTop+sub; my += sub) {
const py = vp.toCanvas(0, my).y;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
}
// Основная сетка
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 1;
for (let mx = x0; mx <= mRight + step; mx += step) {
const px = vp.toCanvas(mx, 0).x;
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let my = y0; my <= mTop + step; my += step) {
const py = vp.toCanvas(0, my).y;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
if (!this.showAxes) return;
// Оси
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1.5;
const ox = vp.toCanvas(0, 0).x, oy = vp.toCanvas(0, 0).y;
if (ox >= 0 && ox <= W) {
ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox, H); ctx.stroke();
}
if (oy >= 0 && oy <= H) {
ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(W, oy); ctx.stroke();
}
// Подписи осей
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = '11px Manrope,sans-serif';
ctx.textAlign = 'center';
// Подписи по X
for (let mx = x0; mx <= mRight + step; mx += step) {
if (Math.abs(mx) < step*0.01) continue;
const px = vp.toCanvas(mx, 0).x;
const py = Math.min(H-10, Math.max(14, oy + 14));
ctx.fillText(String(Math.round(mx*1000)/1000), px, py);
}
// Подписи по Y
ctx.textAlign = 'right';
for (let my = y0; my <= mTop + step; my += step) {
if (Math.abs(my) < step*0.01) continue;
const py = vp.toCanvas(0, my).y;
const px = Math.max(24, Math.min(W-4, ox - 6));
ctx.fillText(String(Math.round(my*1000)/1000), px, py + 4);
}
// Стрелки на концах осей
ctx.fillStyle = 'rgba(255,255,255,0.25)';
if (ox >= 0 && ox <= W) {
ctx.beginPath(); ctx.moveTo(ox,4); ctx.lineTo(ox-4,14); ctx.lineTo(ox+4,14); ctx.closePath(); ctx.fill();
}
if (oy >= 0 && oy <= H) {
ctx.beginPath(); ctx.moveTo(W-4,oy); ctx.lineTo(W-14,oy-4); ctx.lineTo(W-14,oy+4); ctx.closePath(); ctx.fill();
}
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = 'italic 13px serif';
ctx.textAlign = 'left';
if (oy > 4 && oy < H) ctx.fillText('y', ox+6, 14);
if (ox > 0 && ox < W) ctx.fillText('x', W-14, oy-6);
}
/* ── Отрисовка объектов ──────────────────────────────────────── */
_p(id) {
const o = this.eng.get(id);
return o ? this.vp.toCanvas(o.x, o.y) : null;
}
_mpt(id) {
const o = this.eng.get(id);
return o ? { x: o.x, y: o.y } : null;
}
_lineColor(obj) {
return obj.style?.color || this._catColor(obj._cat || 'default');
}
_catColor(cat) {
const MAP = {
segment: '#9B5DE5', line: '#06D6E0', ray: '#F15BB5',
circle: '#FFB347', polygon: '#22d55e', default: '#9B5DE5',
};
return MAP[cat] || MAP.default;
}
_isSelected(obj) { return this._selected?.id === obj.id; }
_isHovered(obj) { return this._hovered?.id === obj.id; }
_drawPoint(ctx, obj) {
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;
if (sel || hov) {
ctx.save();
ctx.shadowColor = col; ctx.shadowBlur = 16;
ctx.strokeStyle = col; ctx.lineWidth = 1.5;
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();
ctx.restore();
if (this.showLabels && obj.label) {
ctx.save();
ctx.font = 'bold 14px Manrope,sans-serif';
ctx.fillStyle = '#fff';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
ctx.fillText(obj.label, px+9, py-9);
ctx.restore();
}
}
_drawSegment(ctx, obj) {
const p1 = this._p(obj.p1Id), p2 = this._p(obj.p2Id);
if (!p1 || !p2) return;
const col = obj.style?.color || '#9B5DE5';
const sel = this._isSelected(obj);
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
if (obj.style?.dash) ctx.setLineDash(obj.style.dash);
ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
ctx.restore();
if (this.showLabels && obj.label) this._drawObjLabel(ctx, obj.label, {x:(p1.x+p2.x)/2, y:(p1.y+p2.y)/2}, col);
}
_drawLine(ctx, obj) {
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (!m1 || !m2) return;
const { W, H, vp } = { W: this.vp.W, H: this.vp.H, vp: this.vp };
// Расширить до границ экрана
const [p1c, p2c] = this._extendToEdges(m1, m2);
if (!p1c || !p2c) return;
const col = obj.style?.color || '#06D6E0';
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = obj.style?.width || 1.5;
ctx.globalAlpha = 0.8;
ctx.shadowColor = col; ctx.shadowBlur = 3;
ctx.setLineDash([6, 6]);
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
ctx.restore();
}
_drawRay(ctx, obj) {
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (!m1 || !m2) return;
const endC = this._extendOneWay(m1, m2);
const p1c = this.vp.toCanvas(m1.x, m1.y);
if (!endC) return;
const col = obj.style?.color || '#F15BB5';
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = obj.style?.width || 1.5;
ctx.globalAlpha = 0.8;
ctx.shadowColor = col; ctx.shadowBlur = 3;
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(endC.x, endC.y); ctx.stroke();
ctx.restore();
}
_drawPolyFill(ctx, obj) {
const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
if (pts.length < 3) return;
const col = obj.style?.fillColor || 'rgba(155,93,229,0.12)';
ctx.save();
ctx.fillStyle = col;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath(); ctx.fill();
ctx.restore();
}
_drawPolyStroke(ctx, obj) {
const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
if (pts.length < 2) return;
const col = obj.style?.color || '#22d55e';
const sel = this._isSelected(obj);
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath(); ctx.stroke();
ctx.restore();
}
_drawCircle(ctx, obj) {
const c = this._p(obj.centerId), e = this._p(obj.edgeId);
if (!c || !e) return;
const r = gDist(c, e);
const col = obj.style?.color || '#FFB347';
const sel = this._isSelected(obj);
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
ctx.globalAlpha = 0.9;
// Лёгкая заливка
ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.05)`;
ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI*2);
ctx.fill(); ctx.stroke();
ctx.restore();
if (this.showLabels && obj.label)
this._drawObjLabel(ctx, obj.label, { x: c.x, y: c.y - r - 8 }, col);
}
_drawObjLabel(ctx, label, pos, col) {
ctx.save();
ctx.font = '12px Manrope,sans-serif';
ctx.fillStyle = col || '#fff';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 4;
ctx.fillText(label, pos.x, pos.y);
ctx.restore();
}
_drawLengths(ctx) {
for (const seg of this.eng.byType('segment')) {
const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id);
if (!m1 || !m2) continue;
const len = gDist(m1, m2);
const mid = this.vp.toCanvas((m1.x+m2.x)/2, (m1.y+m2.y)/2);
const dx = m2.x-m1.x, dy = m2.y-m1.y;
const nx = -dy / Math.hypot(dx,dy) * 14;
const ny = dx / Math.hypot(dx,dy) * 14; // отступ перпендикулярно
ctx.save();
ctx.font = '11px Manrope,sans-serif';
ctx.fillStyle = seg.style?.color || '#9B5DE5';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 4;
ctx.fillText(len.toFixed(2), mid.x + nx, mid.y - ny + 4);
ctx.restore();
}
for (const poly of this.eng.byType('polygon')) {
const ids = poly.pointIds;
for (let i = 0; i < ids.length; i++) {
const m1 = this._mpt(ids[i]), m2 = this._mpt(ids[(i+1)%ids.length]);
if (!m1 || !m2) continue;
const len = gDist(m1, m2);
const mid = this.vp.toCanvas((m1.x+m2.x)/2, (m1.y+m2.y)/2);
ctx.save();
ctx.font = '11px Manrope,sans-serif';
ctx.fillStyle = poly.style?.color || '#22d55e';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 4;
ctx.fillText(len.toFixed(2), mid.x, mid.y - 8);
ctx.restore();
}
}
}
_drawAngleMeasures(ctx) {
for (const poly of this.eng.byType('polygon')) {
const ids = poly.pointIds;
const n = ids.length;
for (let i = 0; i < n; i++) {
const A = this._mpt(ids[(i-1+n)%n]);
const B = this._mpt(ids[i]);
const C = this._mpt(ids[(i+1)%n]);
if (!A||!B||!C) continue;
const angle = gAngleDeg(A, B, C);
const Bpx = this.vp.toCanvas(B.x, B.y);
ctx.save();
ctx.font = '10px Manrope,sans-serif';
ctx.fillStyle = '#FFE066';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 3;
ctx.fillText(angle.toFixed(1)+'°', Bpx.x, Bpx.y + 18);
ctx.restore();
}
}
}
/* ── Предпросмотр (строящийся объект) ─────────────────────── */
_drawPreview(ctx) {
if (this._pending.length === 0 || !this._preview) return;
const lastM = this._pending[this._pending.length-1];
const curM = this._preview;
const p1c = this.vp.toCanvas(lastM.x, lastM.y);
const p2c = this.vp.toCanvas(curM.x, curM.y);
ctx.save();
ctx.globalAlpha = 0.55;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
if (this.tool === 'circle') {
// Предпросмотр окружности
const r = gDist(p1c, p2c);
ctx.beginPath(); ctx.arc(p1c.x, p1c.y, r, 0, Math.PI*2); ctx.stroke();
} else if (this.tool === 'line') {
// Расширить до краёв
const ext = this._extendToEdges(lastM, curM);
if (ext) {
ctx.beginPath(); ctx.moveTo(ext[0].x, ext[0].y); ctx.lineTo(ext[1].x, ext[1].y); ctx.stroke();
}
} else if (this.tool === 'ray') {
const end = this._extendOneWay(lastM, curM);
if (end) {
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(end.x, end.y); ctx.stroke();
}
} else {
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
}
// Для полигона — показать цепочку
if ((this.tool === 'polygon' || this.tool === 'triangle' || this.tool === 'quad') && this._pending.length > 1) {
ctx.setLineDash([]);
ctx.strokeStyle = '#22d55e';
ctx.globalAlpha = 0.4;
ctx.beginPath();
const p0 = this.vp.toCanvas(this._pending[0].x, this._pending[0].y);
ctx.moveTo(p0.x, p0.y);
for (let i = 1; i < this._pending.length; i++) {
const pp = this.vp.toCanvas(this._pending[i].x, this._pending[i].y);
ctx.lineTo(pp.x, pp.y);
}
ctx.lineTo(p2c.x, p2c.y);
ctx.stroke();
}
ctx.restore();
// Фантомная точка в позиции курсора
ctx.save();
ctx.globalAlpha = 0.5;
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(p2c.x, p2c.y, 4, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
_drawSnapIndicator(ctx) {
const p = this.vp.toCanvas(this._snapPt.x, this._snapPt.y);
ctx.save();
ctx.strokeStyle = '#FFE066';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#FFE066'; ctx.shadowBlur = 8;
// Крестик
const s = 8;
ctx.beginPath();
ctx.moveTo(p.x-s, p.y); ctx.lineTo(p.x+s, p.y);
ctx.moveTo(p.x, p.y-s); ctx.lineTo(p.x, p.y+s);
ctx.stroke();
// Кольцо
ctx.beginPath(); ctx.arc(p.x, p.y, s+2, 0, Math.PI*2); ctx.stroke();
ctx.restore();
}
/* ── Геом. вспомогательные ───────────────────────────────────── */
/** Расширить прямую через m1-m2 до границ экрана */
_extendToEdges(m1, m2) {
const vp = this.vp;
const W = vp.W, H = vp.H;
const dx = m2.x-m1.x, dy = m2.y-m1.y;
if (Math.abs(dx)<1e-12 && Math.abs(dy)<1e-12) return null;
const big = 1e6;
const A = { x: m1.x - dx*big, y: m1.y - dy*big };
const B = { x: m1.x + dx*big, y: m1.y + dy*big };
const Apx = vp.toCanvas(A.x, A.y);
const Bpx = vp.toCanvas(B.x, B.y);
// Обрезаем по viewport
const clipped = this._clipSegment(Apx.x, Apx.y, Bpx.x, Bpx.y, 0, 0, W, H);
return clipped;
}
_extendOneWay(m1, m2) {
const vp = this.vp;
const dx = m2.x-m1.x, dy = m2.y-m1.y;
if (Math.abs(dx)<1e-12 && Math.abs(dy)<1e-12) return null;
const big = 1e6;
const B = { x: m1.x + dx*big, y: m1.y + dy*big };
return vp.toCanvas(B.x, B.y);
}
/** Cohen-Sutherland line clipping */
_clipSegment(x0,y0,x1,y1,xmin,ymin,xmax,ymax) {
const code = (x,y) =>
(x<xmin?1:x>xmax?2:0) | (y<ymin?4:y>ymax?8:0);
let c0=code(x0,y0), c1=code(x1,y1);
while (true) {
if (!(c0|c1)) return [{x:x0,y:y0},{x:x1,y:y1}];
if (c0&c1) return null;
const c = c0||c1;
let x,y;
if (c&8) { x=x0+(x1-x0)*(ymax-y0)/(y1-y0); y=ymax; }
else if (c&4) { x=x0+(x1-x0)*(ymin-y0)/(y1-y0); y=ymin; }
else if (c&2) { y=y0+(y1-y0)*(xmax-x0)/(x1-x0); x=xmax; }
else { y=y0+(y1-y0)*(xmin-x0)/(x1-x0); x=xmin; }
if (c===c0) { x0=x;y0=y;c0=code(x,y); }
else { x1=x;y1=y;c1=code(x,y); }
}
}
/* ══ SNAP ════════════════════════════════════════════════════ */
_computeSnap(mx, my) {
const SNAP_DIST_PX = 14; // радиус снапа в пикселях
const snapDist = this.vp.toMathDist(SNAP_DIST_PX);
this._snapPt = null;
this._snapId = null;
// 1. Снап к существующим точкам
let best = Infinity, bestId = null;
for (const pt of this.eng.points()) {
const d = Math.hypot(pt.x - mx, pt.y - my);
if (d < snapDist && d < best) { best = d; bestId = pt.id; }
}
if (bestId) {
const pt = this.eng.get(bestId);
this._snapPt = { x: pt.x, y: pt.y };
this._snapId = bestId;
return { x: pt.x, y: pt.y };
}
// 2. Снап к сетке
if (this.showGrid) {
const step = this._gridStep();
const sx = Math.round(mx / step) * step;
const sy = Math.round(my / step) * step;
const dpx = this.vp.toCanvasDist(Math.hypot(sx-mx, sy-my));
if (dpx < SNAP_DIST_PX * 0.7) {
this._snapPt = { x: sx, y: sy };
return { x: sx, y: sy };
}
}
return { x: mx, y: my };
}
_gridStep() {
const rawStep = 80 / this.vp.scale;
const exp = Math.floor(Math.log10(rawStep));
const frac = rawStep / Math.pow(10, exp);
let step = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
return step * Math.pow(10, exp);
}
/* ══ СОБЫТИЯ ══════════════════════════════════════════════════ */
_bindEvents() {
const c = this.canvas;
c.addEventListener('pointerdown', e => this._onDown(e));
c.addEventListener('pointermove', e => this._onMove(e));
c.addEventListener('pointerup', e => this._onUp(e));
c.addEventListener('pointerleave', e => this._onLeave(e));
c.addEventListener('wheel', e => this._onWheel(e), { passive: false });
c.addEventListener('contextmenu', e => e.preventDefault());
}
_evPos(e) {
const r = this.canvas.getBoundingClientRect();
return { px: e.clientX - r.left, py: e.clientY - r.top };
}
_onDown(e) {
e.preventDefault();
if (this.readOnly) return;
const { px, py } = this._evPos(e);
// ПКМ или Space → отмена текущего построения
if (e.button === 2) { this._pending = []; this._preview = null; this.render(); return; }
// Пан (средняя кнопка или Alt+ЛКМ)
if (e.button === 1 || e.altKey) {
this._panning = true; this._panLast = { px, py };
this.canvas.style.cursor = 'grabbing';
return;
}
const m = this.vp.toMath(px, py);
const snapped = this._computeSnap(m.x, m.y);
if (this.tool === 'select') {
this._handleSelectDown(snapped, px, py);
return;
}
this._handleToolClick(snapped);
}
_handleSelectDown(m, px, py) {
// Найти точку под курсором
const SNAP_PX = 12;
let found = 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) { found = pt; break; }
}
if (found && !found.locked) {
this._drag = { id: found.id };
this._selected = found;
this.canvas.style.cursor = 'grabbing';
} else {
// Выбрать объект (отрезок, окружность, полигон...)
this._selected = this._hitTest(px, py);
this._drag = null;
}
this.render();
}
/** Hit-test для не-точечных объектов */
_hitTest(px, py) {
const HIT = 8; // pixels
const m = this.vp.toMath(px, py);
// Полигоны (проверяем стороны)
for (const obj of this.eng.byType('polygon')) {
const ids = obj.pointIds;
for (let i = 0; i < ids.length; i++) {
const m1 = this._mpt(ids[i]), m2 = this._mpt(ids[(i+1)%ids.length]);
if (!m1||!m2) continue;
if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj;
}
}
// Отрезки
for (const obj of this.eng.byType('segment')) {
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (!m1||!m2) continue;
if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj;
}
// Окружности
for (const obj of this.eng.byType('circle')) {
const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
if (!mc||!me) continue;
const r = gDist(mc, me);
const d = Math.abs(gDist(m, mc) - r);
if (d * this.vp.scale < HIT) return obj;
}
// Прямые
for (const obj of this.eng.byType('line')) {
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (!m1||!m2) continue;
if (gDistToLine(m, m1, m2) * this.vp.scale < HIT) return obj;
}
// Лучи
for (const obj of this.eng.byType('ray')) {
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (!m1||!m2) continue;
if (gDistToLine(m, m1, m2) * this.vp.scale < HIT) return obj;
}
return null;
}
_handleToolClick(snapped) {
switch (this.tool) {
case 'point':
this._pushUndo();
this._addPoint(snapped);
break;
case 'segment':
case 'line':
case 'ray': {
this._pending.push(snapped);
if (this._pending.length === 2) {
this._pushUndo();
const [p1, p2] = this._pending;
const pt1 = this._ensurePoint(p1);
const pt2 = this._ensurePoint(p2);
if (this.tool === 'segment') {
this.eng.add({ type:'segment', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#9B5DE5'} });
} else if (this.tool === 'line') {
this.eng.add({ type:'line', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#06D6E0'} });
} else {
this.eng.add({ type:'ray', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#F15BB5'} });
}
this._pending = [];
this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
case 'circle':
this._pending.push(snapped);
if (this._pending.length === 2) {
this._pushUndo();
const [c, e] = this._pending;
const pc = this._ensurePoint(c);
const pe = this._ensurePoint(e);
this.eng.add({ type:'circle', centerId:pc.id, edgeId:pe.id, style:{color:'#FFB347'} });
this._pending = [];
this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
case 'triangle':
this._pending.push(snapped);
if (this._pending.length === 3) {
this._pushUndo();
const pts = this._pending.map(p => this._ensurePoint(p));
this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
this._pending = [];
this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
case 'quad':
this._pending.push(snapped);
if (this._pending.length === 4) {
this._pushUndo();
const pts = this._pending.map(p => this._ensurePoint(p));
this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
this._pending = [];
this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
case 'polygon':
// Незамкнутый многоугольник — двойной клик или клик на первую точку замыкает
if (this._pending.length > 0 && this._snapId === this._pending[0]._id) {
// Замкнуть
this._finishPolygon();
} else {
this._pending.push({ ...snapped, _id: this._snapId });
}
break;
}
this.render();
}
_finishPolygon() {
if (this._pending.length < 3) { this._pending = []; this._preview = null; this.render(); return; }
this._pushUndo();
const pts = this._pending.map(p => this._ensurePoint(p));
this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
this._pending = [];
this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
this.render();
}
_addPoint(m) {
const pt = this.eng.add({ type:'point', x:m.x, y:m.y, label:this._nextLabel(),
style:{color:'#9B5DE5', size:5} });
if (this.onUpdate) this.onUpdate(this.getStats());
return pt;
}
/** Найти или создать точку в мат. координатах */
_ensurePoint(m) {
if (m._id && this.eng.has(m._id)) return this.eng.get(m._id);
// Ищем существующую точку в этой позиции (snap)
for (const pt of this.eng.points()) {
if (Math.abs(pt.x - m.x) < 1e-9 && Math.abs(pt.y - m.y) < 1e-9) return pt;
}
return this._addPoint(m);
}
_onMove(e) {
const { px, py } = this._evPos(e);
if (this._panning) {
this.vp.pan(px - this._panLast.px, py - this._panLast.py);
this._panLast = { px, py };
this.render(); return;
}
const m = this.vp.toMath(px, py);
if (this._drag) {
const snapped = this._computeSnap(m.x, m.y);
this.eng.movePoint(this._drag.id, snapped.x, snapped.y);
this.render(); return;
}
// Обновить snap для предпросмотра
const snapped = this._computeSnap(m.x, m.y);
this._preview = snapped;
// Hover
if (this.tool === 'select') {
let h = 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) < 12) { h = pt; break; }
}
if (!h) h = this._hitTest(px, py);
this._hovered = h;
this.canvas.style.cursor = h ? 'pointer' : 'default';
}
this.render();
}
_onUp(e) {
if (this._panning) {
this._panning = false;
this.canvas.style.cursor = this.tool === 'select' ? 'default' : 'crosshair';
}
if (this._drag) {
this._drag = null;
this.canvas.style.cursor = 'default';
if (this.onUpdate) this.onUpdate(this.getStats());
}
}
_onLeave(e) {
this._preview = null;
this._panning = false;
this._snapPt = null;
this.render();
}
_onWheel(e) {
e.preventDefault();
const { px, py } = this._evPos(e);
const factor = e.deltaY < 0 ? 1.12 : 1/1.12;
this.vp.zoom(factor, px, py);
this.render();
}
/* ══ UNDO/REDO ════════════════════════════════════════════════ */
_pushUndo() {
this._undoStack.push(this.eng.serialize());
this._redoStack = [];
if (this._undoStack.length > 80) this._undoStack.shift();
}
undo() {
if (!this._undoStack.length) return;
this._redoStack.push(this.eng.serialize());
this.eng.deserialize(this._undoStack.pop());
this._selected = null; this._pending = []; this._preview = null;
this.render();
if (this.onUpdate) this.onUpdate(this.getStats());
}
redo() {
if (!this._redoStack.length) return;
this._undoStack.push(this.eng.serialize());
this.eng.deserialize(this._redoStack.pop());
this._selected = null;
this.render();
if (this.onUpdate) this.onUpdate(this.getStats());
}
/* ── Удалить выбранный объект ── */
deleteSelected() {
if (!this._selected) return;
this._pushUndo();
this.eng.remove(this._selected.id);
this._selected = null;
this.render();
if (this.onUpdate) this.onUpdate(this.getStats());
}
/* ── Очистить всё ── */
reset() {
this._pushUndo();
this.eng.clear();
this._pending = []; this._preview = null; this._selected = null;
this.render();
if (this.onUpdate) this.onUpdate(this.getStats());
}
/* ── Центрировать вид ── */
resetView() {
this.vp.cx = 0; this.vp.cy = 0; this.vp.scale = 60;
this.render();
}
/* ══ СТАТИСТИКА ══════════════════════════════════════════════ */
getStats() {
const pts = this.eng.points().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;
// Статистика для выбранного объекта
let sel = null;
if (this._selected) {
const obj = this._selected;
if (obj.type === 'segment') {
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (m1&&m2) sel = { type:'segment', len: gDist(m1,m2).toFixed(3), mid: gMid(m1,m2) };
} else if (obj.type === 'circle') {
const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
if (mc&&me) {
const r = gDist(mc,me);
sel = { type:'circle', r:r.toFixed(3), perimeter:(2*Math.PI*r).toFixed(3), area:(Math.PI*r*r).toFixed(3) };
}
} else if (obj.type === 'polygon') {
const pts2 = obj.pointIds.map(id=>this._mpt(id)).filter(Boolean);
if (pts2.length >= 3) {
const perimeter = pts2.reduce((s,p,i)=>s+gDist(p,pts2[(i+1)%pts2.length]),0);
const area = gPolygonArea(pts2);
const angles = pts2.map((_,i)=>gAngleDeg(pts2[(i-1+pts2.length)%pts2.length],pts2[i],pts2[(i+1)%pts2.length]));
sel = { type:'polygon', n:pts2.length, perimeter:perimeter.toFixed(3), area:area.toFixed(3), angles };
}
} else if (obj.type === 'point') {
sel = { type:'point', x:obj.x.toFixed(3), y:obj.y.toFixed(3), label:obj.label };
}
}
return { pts, segs, circs, polys, selected: sel };
}
/* ══ ЭКСПОРТ/ИМПОРТ СОСТОЯНИЯ (для classroom sim sync) ════════ */
exportState() {
return {
objects: this.eng.serialize(),
viewport: { cx: this.vp.cx, cy: this.vp.cy, scale: this.vp.scale },
showGrid: this.showGrid,
showAxes: this.showAxes,
showLabels: this.showLabels,
showLengths:this.showLengths,
showAngles: this.showAngles,
};
}
importState(st) {
if (!st) return;
try {
if (st.objects) this.eng.deserialize(st.objects);
if (st.viewport) {
this.vp.cx = st.viewport.cx;
this.vp.cy = st.viewport.cy;
this.vp.scale = st.viewport.scale;
}
if (st.showGrid !== undefined) this.showGrid = st.showGrid;
if (st.showAxes !== undefined) this.showAxes = st.showAxes;
if (st.showLabels !== undefined) this.showLabels = st.showLabels;
if (st.showLengths !== undefined) this.showLengths = st.showLengths;
if (st.showAngles !== undefined) this.showAngles = st.showAngles;
this._selected = null; this._pending = []; this._preview = null;
this.render();
} catch(err) { console.warn('GeoSim.importState error:', err); }
}
/* ── Экспорт PNG ── */
exportPNG() {
this.canvas.toBlob(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'geometry.png';
a.click();
}, 'image/png');
}
}