From 35849cf231a18ec20077a664515d75ffe40dad88 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 14 Apr 2026 09:40:41 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=B8=D1=8F=20=E2=80=94=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=BE=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D1=8F=20=D1=81=D0=B8=D0=BC=D1=83=D0=BB=D1=8F?= =?UTF-8?q?=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Новый файл 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 --- backend/src/db/migrate.js | 32 + frontend/admin.html | 1 + frontend/js/labs/geometry.js | 1209 ++++++++++++++++++++++++++++++++++ frontend/lab.html | 340 +++++++++- 4 files changed, 1580 insertions(+), 2 deletions(-) create mode 100644 frontend/js/labs/geometry.js diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index a757ff3..c0891f9 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -2867,3 +2867,35 @@ db.exec(` PRIMARY KEY (session_id, user_id) ) `); + +// ── Geometry (Planimetry) ──────────────────────────────────────────────────── +// Saved geometry constructions (teacher-created tasks/templates) +db.exec(` + CREATE TABLE IF NOT EXISTS geometry_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + teacher_id INTEGER NOT NULL REFERENCES users(id), + class_id INTEGER REFERENCES classes(id) ON DELETE SET NULL, + title TEXT NOT NULL DEFAULT 'Без названия', + description TEXT DEFAULT '', + state_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_geo_tasks_teacher ON geometry_tasks(teacher_id)'); + +// Student submissions for geometry tasks +db.exec(` + CREATE TABLE IF NOT EXISTS geometry_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL REFERENCES geometry_tasks(id) ON DELETE CASCADE, + student_id INTEGER NOT NULL REFERENCES users(id), + state_json TEXT NOT NULL DEFAULT '{}', + score REAL DEFAULT NULL, + feedback TEXT DEFAULT '', + submitted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(task_id, student_id) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_student ON geometry_submissions(student_id)'); diff --git a/frontend/admin.html b/frontend/admin.html index 2d59310..6c1108b 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -4446,6 +4446,7 @@ const ADMIN_SIMS = [ { id: 'graph', cat: 'Математика', title: 'График функции' }, { id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' }, + { id: 'geometry', cat: 'Математика', title: 'Планиметрия' }, { id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' }, { id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' }, { id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' }, diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js new file mode 100644 index 0000000..e95b992 --- /dev/null +++ b/frontend/js/labs/geometry.js @@ -0,0 +1,1209 @@ +/* ═══════════════════════════════════════════════════════════════════════ + 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) => + (xxmax?2:0) | (yymax?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'); + } +} diff --git a/frontend/lab.html b/frontend/lab.html index b4dc8da..954b778 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -641,6 +641,94 @@ .embed-mode #lab-sim { flex: 1; } .embed-mode .sim-body-wrap { height: 100vh; } .embed-mode .graph-panel { max-height: 100vh; } + + /* ════════════════════════════════ + GEOMETRY SIM STYLES + ════════════════════════════════ */ + .geo-panel { + width: 210px; flex-shrink: 0; + background: var(--surface); + border-right: 1.5px solid var(--border); + display: flex; flex-direction: column; + overflow-y: auto; padding: 12px 10px; gap: 4px; + } + .geo-tool-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 4px; + margin-bottom: 4px; + } + .geo-tool-btn { + display: flex; align-items: center; gap: 6px; + padding: 7px 9px; border-radius: 10px; + border: 1.5px solid var(--border); + background: transparent; color: var(--text-2); + font-family: 'Manrope', sans-serif; font-size: 0.73rem; font-weight: 700; + cursor: pointer; transition: all .14s; white-space: nowrap; + } + .geo-tool-btn svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2.2; flex-shrink: 0; } + .geo-tool-btn:hover { border-color: rgba(155,93,229,.4); color: var(--violet); background: rgba(155,93,229,.06); } + .geo-tool-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.12); } + + .geo-tool-wide { + grid-column: span 2; + } + + .geo-toggle-row { + display: flex; align-items: center; justify-content: space-between; + padding: 5px 4px; border-radius: 8px; cursor: pointer; + transition: background .13s; + } + .geo-toggle-row:hover { background: rgba(255,255,255,.04); } + .geo-toggle-label { + font-size: 0.73rem; font-weight: 600; color: var(--text-2); + display: flex; align-items: center; gap: 6px; + } + .geo-toggle-label svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; opacity: .7; } + .geo-toggle { + width: 28px; height: 16px; border-radius: 8px; + background: rgba(255,255,255,.1); border: 1.5px solid var(--border-h); + position: relative; transition: background .15s; flex-shrink: 0; + } + .geo-toggle::after { + content: ''; position: absolute; left: 2px; top: 50%; + transform: translateY(-50%); + width: 9px; height: 9px; border-radius: 50%; + background: rgba(255,255,255,.4); transition: all .15s; + } + .geo-toggle.on { background: var(--violet); border-color: var(--violet); } + .geo-toggle.on::after { left: calc(100% - 11px); background: #fff; } + + .geo-info-box { + background: rgba(155,93,229,.07); border: 1px solid rgba(155,93,229,.15); + border-radius: 10px; padding: 8px 10px; margin-top: 4px; + font-size: 0.72rem; line-height: 1.55; color: var(--text-2); + } + .geo-info-box .geo-info-key { color: var(--violet); font-weight: 700; } + + .geo-stat-row { + display: flex; justify-content: space-between; align-items: center; + font-size: 0.7rem; padding: 2px 0; + border-bottom: 1px dashed rgba(255,255,255,.05); + color: var(--text-2); + } + .geo-stat-row:last-child { border: none; } + .geo-stat-row b { color: var(--text); font-weight: 700; } + + .geo-canvas-outer { + flex: 1; min-width: 0; position: relative; background: #0a0718; + } + .geo-canvas-outer canvas { + display: block; position: absolute; top: 0; left: 0; + width: 100%; height: 100%; + } + + .geo-hint-bar { + position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); + background: rgba(10,7,24,.82); border: 1px solid rgba(255,255,255,.1); + border-radius: 20px; padding: 4px 14px; + font-size: 0.7rem; color: rgba(255,255,255,.5); + pointer-events: none; white-space: nowrap; + backdrop-filter: blur(6px); + } @@ -741,6 +829,26 @@ + + + + + +
@@ -4188,6 +4420,21 @@ stroke="#F15BB5" stroke-width="2.5" fill="none" opacity="0.9"/> v = \u03bbf \u00b7 y = A sin(\u03c9t \u2212 kx) \u00b7 \u0441\u0442\u043e\u044f\u0447\u0438\u0435 \u0432\u043e\u043b\u043d\u044b`); + /* Geometry (planimetry) preview */ + const P_GEOMETRY = _svg(`${_grid('rgba(255,255,255,0.04)')} + + + + + + + + + + A + B + C`); + const SIMS = [ /* ── Математика ── */ { id: 'graph', cat: 'math', @@ -4198,6 +4445,10 @@ title: 'Трансформации графиков', desc: 'Наблюдай, как сдвиги, растяжения и отражения меняют вид функции y = a·f(kx+b)+c.', preview: P_TRANSFORM }, + { id: 'geometry', cat: 'math', + title: 'Планиметрия', + desc: 'Интерактивная среда построений: точки, отрезки, прямые, окружности, многоугольники. Полноценный чертёж с привязкой и измерениями.', + preview: P_GEOMETRY }, { id: 'triangle', cat: 'math', title: 'Геометрия треугольника', desc: 'Интерактивный треугольник: медианы, высоты, биссектрисы, вписанная и описанная окружности.', @@ -4359,6 +4610,7 @@ let bohrSim = null; let elecSim = null; let wavesSim = null; + let geomSim = null; const ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-mag', 'sim-molphys', @@ -4368,11 +4620,12 @@ 'sim-quadratic','sim-normaldist','sim-graphtransform', 'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration', 'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis', - 'sim-waves','sim-hydro']; + 'sim-waves','sim-hydro','sim-geometry']; const ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-mag', 'ctrl-molphys', 'ctrl-coulomb','ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox', - 'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro']; + 'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro', + 'ctrl-geometry']; /* ── sim routing ── */ @@ -4425,6 +4678,7 @@ if (id === 'waves') _openWaves(); if (id === 'hydrostatics') _openHydro(); if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]); + if (id === 'geometry') _openGeometry(); } function _simShow(elId) { @@ -5012,6 +5266,87 @@ document.getElementById('tbar-Rr').textContent = f2(s.R) + ' / ' + f2(s.r); } + /* ── geometry (planimetry) ── */ + + const _GEO_HINTS = { + select: 'Клик — выбрать объект, перетащи точку для перемещения', + point: 'Клик — поставить точку', + segment: 'Кликни 2 точки для отрезка', + line: 'Кликни 2 точки для прямой', + ray: 'Кликни: начало, затем направление', + circle: 'Клик — центр; второй клик — радиус', + triangle: 'Кликни 3 точки для треугольника', + quad: 'Кликни 4 точки для четырёхугольника', + polygon: 'Кликай точки; двойной клик или Enter — завершить', + }; + + function geoSetTool(name, btnEl) { + if (!geomSim) return; + geomSim.setTool(name); + document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active')); + if (btnEl) btnEl.classList.add('active'); + const hint = document.getElementById('geo-hint'); + if (hint) hint.textContent = _GEO_HINTS[name] || ''; + } + + function geoToggle(prop, rowEl) { + if (!geomSim) return; + geomSim[prop] = !geomSim[prop]; + const tog = rowEl.querySelector('.geo-toggle'); + if (tog) tog.classList.toggle('on', geomSim[prop]); + geomSim.render(); + } + + 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; + } + + function _openGeometry() { + document.getElementById('sim-topbar-title').textContent = 'Планиметрия'; + _simShow('sim-geometry'); + _simShow('ctrl-geometry'); + + _registerSimState( + 'geometry', + () => geomSim?.exportState(), + st => { if (geomSim && st) { geomSim.importState(st); _geoUpdateStats(); } } + ); + if (_embedMode) _startStateEmit('geometry'); + + requestAnimationFrame(() => requestAnimationFrame(() => { + const canvas = document.getElementById('geo-canvas'); + if (!geomSim) { + geomSim = new GeoSim(canvas); + geomSim.onUpdate = _geoUpdateStats; + + // keyboard shortcuts + canvas.setAttribute('tabindex', '0'); + canvas.addEventListener('keydown', e => { + if (!geomSim) return; + if (e.key === 'Escape') { geoSetTool('select', document.getElementById('geo-btn-select')); } + if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.preventDefault(); geomSim.undo(); _geoUpdateStats(); } + if ((e.ctrlKey||e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key==='z'))) { e.preventDefault(); geomSim.redo(); _geoUpdateStats(); } + if (e.key === 'Delete' || e.key === 'Backspace') { geomSim.deleteSelected(); _geoUpdateStats(); } + if (e.key === 'Enter') { geomSim._finishPolygon?.(); _geoUpdateStats(); } + }); + } + geomSim.fit(); + geomSim.render(); + _geoUpdateStats(); + + // sync toggle UI to current state + ['showGrid','showAxes','showLabels','showLengths','showAngles'].forEach(p => { + const el = document.getElementById('geo-tog-' + p); + if (el) el.classList.toggle('on', !!geomSim[p]); + }); + })); + } + /* ── trig circle ── */ let trigSim = null; @@ -8320,5 +8655,6 @@ +