/* ═══════════════════════════════════════════════════════════════════════ geometry.js — Интерактивная планиметрия для LearnSpace Phase 1: точки, отрезки, прямые, лучи, окружности, многоугольники Phase 2: инструменты построения (середина, биссектрисы, параллельные, перпендикуляры, пересечения) + система производных объектов ═══════════════════════════════════════════════════════════════════════ */ '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) }; } /** Вписанная окружность треугольника */ function gIncircle(A, B, C) { const a = gDist(B, C), b = gDist(A, C), c = gDist(A, B); const s = a + b + c; if (s < 1e-12) return null; const cx = (a*A.x + b*B.x + c*C.x) / s; const cy = (a*A.y + b*B.y + c*C.y) / s; const area = gPolygonArea([A, B, C]); const r = area / (s / 2); return { cx, cy, r }; } /** Точки касания двух касательных из внешней точки P к окружности (O, r) */ function gTangentPoints(O, P, r) { const d = gDist(O, P); if (d <= r + 1e-9) return null; // P внутри или на окружности const t = (r * r) / d; // |OM|, M = нога с O на хорду касания const h = r * Math.sqrt(d * d - r * r) / d; // |T₁M| = |T₂M| const vx = (P.x - O.x) / d, vy = (P.y - O.y) / d; // O→P const px = -vy, py = vx; // перпендикуляр const M = { x: O.x + vx * t, y: O.y + vy * t }; return [ { x: M.x + px * h, y: M.y + py * h }, { x: M.x - px * h, y: M.y - py * h }, ]; } /** Угол 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; } /** Ортоцентр треугольника ABC */ function gOrthocenter(A, B, C) { const Ha = gFoot(A, B, C); // основание высоты из A на BC const Hb = gFoot(B, A, C); // основание высоты из B на AC return gIntersectLines(A, Ha, B, Hb); } /** Площадь многоугольника (формула Гаусса) */ 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) { // BFS-каскад: собрать все транзитивно зависимые объекты, затем удалить const toDelete = new Set([id]); let changed = true; while (changed) { changed = false; for (const [oid, obj] of this._objects) { if (toDelete.has(oid)) continue; for (const did of toDelete) { if (this._dependsOn(obj, did)) { toDelete.add(oid); changed = true; break; } } } } for (const oid of toDelete) this._objects.delete(oid); } _dependsOn(obj, id) { switch (obj.type) { case 'segment': // Виртуальный отрезок-сторона полигона зависит от самого полигона if (obj.virtual && obj.polyId === id) return true; return obj.p1Id === id || obj.p2Id === id; case 'line': case 'ray': return obj.p1Id === id || obj.p2Id === id; case 'polygon': return obj.pointIds.includes(id); case 'circle': if (obj.derived) return obj.ptA === id || obj.ptB === id || obj.ptC === id; return obj.centerId === id || obj.edgeId === 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; if (obj.constr === 'centroid' || obj.constr === 'orthocenter') return obj.ptA === id || obj.ptB === id || obj.ptC === id; if (obj.constr === 'altitude_foot') return obj.ptA === id || obj.ptB === id || obj.ptC === id; if (obj.constr === 'parallelogram_d') return obj.ptA === id || obj.ptB === id || obj.ptC === id; if (obj.constr === 'scale') return obj.srcO === id || obj.srcPt === id; if (obj.constr === 'foot' || obj.constr === 'reflect') { if (obj.srcPt === id || obj.srcLine === id) return true; // Если srcLine — обычная прямая, зависим и от её точек const sl = this._objects.get(obj.srcLine); return !!(sl && (sl.p1Id === id || sl.p2Id === id)); } if (obj.constr === 'ngon_vertex') return obj.srcCenter === id || obj.srcVertex === id; if (obj.constr === 'translate') return obj.srcA === id || obj.srcB === id || obj.srcPt === id; if (obj.constr === 'on_segment') { if (obj.srcSeg === id) return true; // зависим и от перемещения конечных точек отрезка const seg = this._objects.get(obj.srcSeg); return !!(seg && (seg.p1Id === id || seg.p2Id === id)); } if (obj.constr === 'on_circle') { if (obj.srcCircle === id) return true; // если id — точка, задающая окружность, зависим транзитивно через саму окружность const circ = this._objects.get(obj.srcCircle); if (!circ) return false; if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id; return circ.centerId === id || circ.edgeId === id; } return false; case 'derived_line': switch (obj.constr) { 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; case 'tangent': { if (obj.srcPt === id || obj.srcCircle === id) return true; // Зависим и от точек, определяющих окружность const circ = this._objects.get(obj.srcCircle); if (!circ) return false; if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id; return circ.centerId === id || circ.edgeId === id; } } return false; case 'measure_length': return obj.srcSeg === id; case 'measure_angle': return obj.srcA === id || obj.srcVtx === id || obj.srcB === id; case 'measure_area': return obj.srcPoly === id; case 'locus': return obj.srcMover === id || obj.srcTarget === id; } return false; } /* Перевычислить производный объект из его источников */ 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.constr === 'foot' || obj.constr === 'reflect') { const srcPt = _g(obj.srcPt); const sl = _g(obj.srcLine); if (!srcPt || !sl) return; let L1, L2; if (sl.type === 'derived_line') { L1 = { x: sl.ptX, y: sl.ptY }; L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY }; } else { L1 = _g(sl.p1Id); L2 = _g(sl.p2Id); } if (L1 && L2) { const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2); if (obj.constr === 'foot') { obj.x = f.x; obj.y = f.y; } else { // reflect: P' = 2*foot - P obj.x = 2*f.x - srcPt.x; obj.y = 2*f.y - srcPt.y; } } } else if (obj.constr === 'ngon_vertex') { const center = _g(obj.srcCenter), v0 = _g(obj.srcVertex); if (!center || !v0) return; const angle = 2 * Math.PI * obj.k / obj.n; const dx = v0.x - center.x, dy = v0.y - center.y; const cos_a = Math.cos(angle), sin_a = Math.sin(angle); obj.x = center.x + dx*cos_a - dy*sin_a; obj.y = center.y + dx*sin_a + dy*cos_a; } else if (obj.constr === 'translate') { const pA = _g(obj.srcA), pB = _g(obj.srcB), pP = _g(obj.srcPt); if (pA && pB && pP) { obj.x = pP.x + (pB.x - pA.x); obj.y = pP.y + (pB.y - pA.y); } } else if (obj.constr === 'centroid') { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); if (pA && pB && pC) { obj.x = (pA.x + pB.x + pC.x) / 3; obj.y = (pA.y + pB.y + pC.y) / 3; } } else if (obj.constr === 'orthocenter') { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); if (pA && pB && pC) { const h = gOrthocenter(pA, pB, pC); if (h) { obj.x = h.x; obj.y = h.y; obj.valid = true; } else { obj.valid = false; } } } else if (obj.constr === 'altitude_foot') { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); if (pA && pB && pC) { const f = gFoot(pA, pB, pC); obj.x = f.x; obj.y = f.y; } } else if (obj.constr === 'parallelogram_d') { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); if (pA && pB && pC) { obj.x = pA.x + pC.x - pB.x; obj.y = pA.y + pC.y - pB.y; } } else if (obj.constr === 'scale') { const pO = _g(obj.srcO), pP = _g(obj.srcPt); if (pO && pP) { obj.x = pO.x + obj.k * (pP.x - pO.x); obj.y = pO.y + obj.k * (pP.y - pO.y); } } else if (obj.constr === 'on_segment') { const seg = _g(obj.srcSeg); if (seg) { const p1 = _g(seg.p1Id), p2 = _g(seg.p2Id); if (p1 && p2) { const t = Math.max(0, Math.min(1, obj._t)); obj.x = p1.x + t * (p2.x - p1.x); obj.y = p1.y + t * (p2.y - p1.y); } } } else if (obj.constr === 'on_circle') { const circ = _g(obj.srcCircle); if (circ) { let cx, cy, r; if (circ.derived && circ.cx != null) { cx = circ.cx; cy = circ.cy; r = circ.r; } else { const mc = _g(circ.centerId), me = _g(circ.edgeId); if (mc && me) { cx = mc.x; cy = mc.y; r = gDist(mc, me); } } if (cx != null) { obj.x = cx + r * Math.cos(obj._theta); obj.y = cy + r * Math.sin(obj._theta); } } } } else if (obj.type === 'circle' && obj.derived) { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); if (!pA || !pB || !pC) return; const A = { x: pA.x, y: pA.y }, B = { x: pB.x, y: pB.y }, C = { x: pC.x, y: pC.y }; if (obj.constr === 'circumcircle') { const cc = gCircumcircle(A, B, C); if (cc) { obj.cx = cc.cx; obj.cy = cc.cy; obj.r = cc.r; obj.valid = true; } else { obj.valid = false; } } else if (obj.constr === 'incircle') { const ic = gIncircle(A, B, C); if (ic) { obj.cx = ic.cx; obj.cy = ic.cy; obj.r = ic.r; 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; } else if (obj.constr === 'tangent') { const circ = _g(obj.srcCircle), pt = _g(obj.srcPt); if (!circ || !pt) return; let O, r; if (circ.derived && circ.cx != null) { O = { x: circ.cx, y: circ.cy }; r = circ.r; } else { const ctr = _g(circ.centerId), edg = _g(circ.edgeId); if (!ctr || !edg) return; O = { x: ctr.x, y: ctr.y }; r = gDist(O, { x: edg.x, y: edg.y }); } const P = { x: pt.x, y: pt.y }; const tpts = gTangentPoints(O, P, r); if (!tpts) { obj.valid = false; return; } const T = tpts[obj.which]; const dx = T.x - P.x, dy = T.y - P.y, len = Math.hypot(dx, dy); if (len < 1e-12) { obj.valid = false; return; } obj.ptX = P.x; obj.ptY = P.y; obj.dirX = dx/len; obj.dirY = dy/len; obj.valid = true; } } } /* Возвращает два математических точки на объекте-линии (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); } } } /* Прямые зависимые объекты (один уровень) */ getDependents(id) { const result = []; for (const obj of this._objects.values()) { if (obj.id !== id && this._dependsOn(obj, id)) result.push(obj); } return result; } 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.derived && !obj.locked) { obj.x = x; obj.y = y; this.propagateDeps(id); } } 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; // предпросмотр (курсор при рисовании) this._pendingLineRef = null; // первый кликнутый объект для parallel/perp/intersect/reflect/foot this._pendingCircRef = null; // первый кликнутый объект-окружность для tangent this._pendingScaleO = null; // центр подобия для инструмента scale this._pendingMover = null; // мовер-точка для инструмента locus this._scaleK = 2; // коэффициент подобия /* ── Состояние drag/pan ── */ 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.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки this.onDeleteRequest = null; // cb(obj, deps, softFn, cascadeFn) — подтвердить удаление this.onLocusError = null; // cb(msg) — ошибка при построении ГМТ this._labelCounter = 0; this._ngonSides = 6; // для инструмента правильного многоугольника this._bindEvents(); } setNgonSides(n) { this._ngonSides = Math.max(3, Math.min(20, n)); } setScaleK(k) { this._scaleK = +k || 2; } /* ── Инициализация ─────────────────────────────────────────── */ 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._pendingLineRef = null; this._pendingCircRef = null; this._pendingScaleO = null; this._pendingMover = 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('derived_line')) this._drawDerivedLine(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); // Отрезки (виртуальные стороны полигонов не рисуем — они нарисованы как polygon stroke) for (const obj of this.eng.byType('segment')) { if (!obj.virtual) 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); this._drawAngleMeasures(ctx); // всегда — для arcmark и прямых углов; showAngles управляет авто-подписями // Точки поверх всего (включая производные) for (const obj of this.eng.points()) this._drawPoint(ctx, obj); // Локусы (ГМТ) for (const obj of this.eng.byType('locus')) this._drawLocus(ctx, obj); // Измерительные чипы поверх всего this._drawMeasurements(ctx); // Предпросмотр строящегося объекта this._drawPreview(ctx); // Подсветка первого объекта при инструментах построения if (this._pendingLineRef) this._drawLineRefHighlight(ctx, this._pendingLineRef); if (this._pendingCircRef) this._drawLineRefHighlight(ctx, this._pendingCircRef); // Индикатор снапа if (this._snapPt) this._drawSnapIndicator(ctx); // LabFX particles if (window.LabFX) LabFX.particles.draw(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 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; 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 = 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); 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 (obj.tickMark) this._drawTickMark(ctx, p1, p2, obj.tickMark, col); if (obj.parallelMark) this._drawParallelMark(ctx, p1, p2, obj.parallelMark, col); if (this.showLabels && obj.label) this._drawObjLabel(ctx, obj.label, {x:(p1.x+p2.x)/2, y:(p1.y+p2.y)/2}, col); } /* Метки равных сторон (штрихи поперёк отрезка) */ _drawTickMark(ctx, p1, p2, count, col) { const dx = p2.x - p1.x, dy = p2.y - p1.y; const len = Math.hypot(dx, dy); if (len < 1e-9 || !count) return; const ux = dx / len, uy = dy / len; // вдоль отрезка const nx = -uy, ny = ux; // перпендикуляр const TICK = 7, SPACING = 5; const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.globalAlpha = 0.9; ctx.shadowColor = col; ctx.shadowBlur = 3; for (let k = 0; k < count; k++) { const off = (k - (count - 1) / 2) * SPACING; const cx = mx + ux * off, cy = my + uy * off; ctx.beginPath(); ctx.moveTo(cx + nx * TICK, cy + ny * TICK); ctx.lineTo(cx - nx * TICK, cy - ny * TICK); ctx.stroke(); } ctx.restore(); } /* Метки параллельных линий (шевроны >) */ _drawParallelMark(ctx, p1, p2, count, col) { const dx = p2.x - p1.x, dy = p2.y - p1.y; const len = Math.hypot(dx, dy); if (len < 1e-9 || !count) return; const ux = dx / len, uy = dy / len; const nx = -uy, ny = ux; const W = 5, H = 5, SPACING = 8; // полуширина, полувысота, отступ между шевронами const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.globalAlpha = 0.9; ctx.shadowColor = col; ctx.shadowBlur = 3; for (let k = 0; k < count; k++) { const off = (k - (count - 1) / 2) * SPACING; const cx = mx + ux * off, cy = my + uy * off; // Шеврон: два отрезка от "хвоста" к "острию" ctx.beginPath(); ctx.moveTo(cx - ux*W + nx*H, cy - uy*W + ny*H); ctx.lineTo(cx + ux*W, cy + uy*W); ctx.lineTo(cx - ux*W - nx*H, cy - uy*W - ny*H); ctx.stroke(); } ctx.restore(); } _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(); } /* ── Производная прямая (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/tangent/...) */ _drawLineRefHighlight(ctx, obj) { if (!obj) return; ctx.save(); ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.globalAlpha = 0.55; ctx.setLineDash([6, 4]); if (obj.type === 'circle') { // Подсветка окружности (для инструмента tangent) let cx, cy, r; if (obj.derived && obj.cx != null) { const c = this.vp.toCanvas(obj.cx, obj.cy); cx = c.x; cy = c.y; r = this.vp.toCanvasDist(obj.r); } else { const c = this._p(obj.centerId), e = this._p(obj.edgeId); if (!c || !e) { ctx.restore(); return; } cx = c.x; cy = c.y; r = gDist(c, e); } ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.stroke(); } else { 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) { ctx.restore(); 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.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke(); } ctx.restore(); } /* Найти окружность под курсором (для инструмента tangent) */ _hitTestCircle(px, py) { const HIT = 12, m = this.vp.toMath(px, py); for (const obj of this.eng.byType('circle')) { let O, r; if (obj.derived && obj.cx != null) { O = { x: obj.cx, y: obj.cy }; r = obj.r; } else { const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId); if (!mc || !me) continue; O = mc; r = gDist(mc, me); } if (Math.abs(gDist(m, O) - r) * this.vp.scale < HIT) return obj; } return null; } /* Вернуть две мат. точки на объекте (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)) { if (obj.virtual) continue; // виртуальные отрезки-стороны проверяем ниже через полигон 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; } // Стороны полигонов — ищем и при попадании создаём виртуальный отрезок for (const poly of this.eng.byType('polygon')) { const ids = poly.pointIds; for (let i = 0; i < ids.length; i++) { const j = (i + 1) % ids.length; const A = this._mpt(ids[i]), B = this._mpt(ids[j]); if (!A || !B) continue; if (gDistToSegment(m, A, B) * this.vp.scale < HIT) { return this._ensurePolySide(poly.id, ids[i], ids[j]); } } } return null; } /* Найти вершину полигона под курсором: {poly, idx} или null */ _findVertexOfPoly(px, py, nSides = 0) { const SNAP_PX = 16; for (const poly of this.eng.byType('polygon')) { if (nSides > 0 && poly.pointIds.length !== nSides) continue; for (let i = 0; i < poly.pointIds.length; i++) { const p = this._p(poly.pointIds[i]); if (p && Math.hypot(p.x - px, p.y - py) < SNAP_PX) return { poly, idx: i }; } } return null; } /* Найти треугольник под курсором (вершина, сторона или внутренность) */ _findTriangleNear(px, py) { for (const poly of this.eng.byType('polygon')) { if (poly.pointIds.length !== 3) continue; const pts = poly.pointIds.map(id => this._p(id)).filter(Boolean); if (pts.length !== 3) continue; // Вершины for (const p of pts) { if (Math.hypot(p.x - px, p.y - py) < 20) return poly; } // Внутренность (cross-product тест) const sign = (P, A, B) => (B.x-A.x)*(P.y-A.y) - (B.y-A.y)*(P.x-A.x); const P = {x:px, y:py}; const s0 = sign(P,pts[0],pts[1]), s1 = sign(P,pts[1],pts[2]), s2 = sign(P,pts[2],pts[0]); if ((s0>=0&&s1>=0&&s2>=0) || (s0<=0&&s1<=0&&s2<=0)) return poly; } return null; } /* Найти или создать виртуальный отрезок для стороны полигона */ _ensurePolySide(polyId, p1Id, p2Id) { for (const obj of this.eng.byType('segment')) { if (obj.virtual && obj.polyId === polyId && obj.p1Id === p1Id && obj.p2Id === p2Id) return obj; } return this.eng.add({ type: 'segment', virtual: true, polyId, p1Id, p2Id }); } _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(); // Метки равных сторон (штрихи) и параллельных сторон (шевроны) for (let i = 0; i < pts.length; i++) { const j = (i+1) % pts.length; if (obj.sideMarks?.[i]) this._drawTickMark(ctx, pts[i], pts[j], obj.sideMarks[i], col); if (obj.parallelSideMarks?.[i]) this._drawParallelMark(ctx, pts[i], pts[j], obj.parallelSideMarks[i], col); } } _drawCircle(ctx, obj) { let cx, cy, r; const isDerived = obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle'); if (isDerived) { if (!obj.valid) return; const c = this.vp.toCanvas(obj.cx, obj.cy); cx = c.x; cy = c.y; r = this.vp.toCanvasDist(obj.r); } else { const c = this._p(obj.centerId), e = this._p(obj.edgeId); if (!c || !e) return; cx = c.x; cy = c.y; 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 = isDerived ? 0.75 : 0.9; if (isDerived) ctx.setLineDash([7, 4]); ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.04)`; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill(); ctx.stroke(); ctx.restore(); if (this.showLabels && obj.label) this._drawObjLabel(ctx, obj.label, { x: cx, y: cy - 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) { const ARC_R = 22; // радиус дуги угла, пикселей const SQ_SZ = 11; // сторона квадрата прямого угла, пикселей const LBL_D = 14; // отступ подписи от дуги/квадрата, пикселей 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 Apx = this.vp.toCanvas(A.x, A.y); const Bpx = this.vp.toCanvas(B.x, B.y); const Cpx = this.vp.toCanvas(C.x, C.y); const col = poly.style?.color || '#FFE066'; // Единичные векторы B→A и B→C в пиксельных координатах const dAx = Apx.x - Bpx.x, dAy = Apx.y - Bpx.y; const dCx = Cpx.x - Bpx.x, dCy = Cpx.y - Bpx.y; const lenA = Math.hypot(dAx, dAy), lenC = Math.hypot(dCx, dCy); if (lenA < 1e-4 || lenC < 1e-4) continue; const uAx = dAx/lenA, uAy = dAy/lenA; const uCx = dCx/lenC, uCy = dCy/lenC; // Биссектриса угла в пиксельных координатах const bisX = uAx + uCx, bisY = uAy + uCy; const bisLen = Math.hypot(bisX, bisY); ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 1.5; const explicitMark = poly.angleMarks?.[i] || 0; // 0=авто, 1-3=явный if (explicitMark > 0 && bisLen > 1e-6) { // Явные метки: всегда рисуем 1-3 концентрические дуги const midAngle = Math.atan2(bisY, bisX); const halfSpread = Math.acos(Math.max(-1, Math.min(1, uAx*uCx + uAy*uCy))) / 2; ctx.globalAlpha = 0.85; ctx.lineWidth = 2; for (let k = 0; k < explicitMark; k++) { ctx.beginPath(); ctx.arc(Bpx.x, Bpx.y, ARC_R + k * 8, midAngle - halfSpread, midAngle + halfSpread, false); ctx.stroke(); } } else if (this.showAngles) { // Авто-режим: только если включено if (Math.abs(angle - 90) < 2) { ctx.globalAlpha = 0.8; ctx.beginPath(); ctx.moveTo(Bpx.x + uAx*SQ_SZ, Bpx.y + uAy*SQ_SZ); ctx.lineTo(Bpx.x + uAx*SQ_SZ + uCx*SQ_SZ, Bpx.y + uAy*SQ_SZ + uCy*SQ_SZ); ctx.lineTo(Bpx.x + uCx*SQ_SZ, Bpx.y + uCy*SQ_SZ); ctx.stroke(); } else if (bisLen > 1e-6) { const midAngle = Math.atan2(bisY, bisX); const halfSpread = Math.acos(Math.max(-1, Math.min(1, uAx*uCx + uAy*uCy))) / 2; ctx.globalAlpha = 0.7; ctx.beginPath(); ctx.arc(Bpx.x, Bpx.y, ARC_R, midAngle - halfSpread, midAngle + halfSpread, false); ctx.stroke(); } // Подпись угла if (bisLen > 1e-6) { const ldist = (Math.abs(angle - 90) < 2 ? SQ_SZ : ARC_R) + LBL_D; const bx = bisX / bisLen, by = bisY / bisLen; ctx.font = '10px Manrope,sans-serif'; ctx.fillStyle = col; ctx.globalAlpha = 0.9; ctx.textAlign = 'center'; ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 3; ctx.fillText(angle.toFixed(1) + '°', Bpx.x + bx*ldist, Bpx.y + by*ldist + 3); } } ctx.restore(); } } // Прямые углы для foot и altitude_foot конструкций for (const obj of this.eng.points()) { if (!obj.derived || (obj.constr !== 'foot' && obj.constr !== 'altitude_foot')) continue; const F = this.vp.toCanvas(obj.x, obj.y); let P, L1m, L2m; if (obj.constr === 'altitude_foot') { P = this.eng.get(obj.ptA); L1m = this._mpt(obj.ptB); L2m = this._mpt(obj.ptC); } else { P = this.eng.get(obj.srcPt); const sl = this.eng.get(obj.srcLine); if (!sl) continue; if (sl.type === 'derived_line') { L1m = { x: sl.ptX, y: sl.ptY }; L2m = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY }; } else { L1m = this._mpt(sl.p1Id); L2m = this._mpt(sl.p2Id); } } if (!P || !L1m || !L2m) continue; const L1 = this.vp.toCanvas(L1m.x, L1m.y); const L2 = this.vp.toCanvas(L2m.x, L2m.y); const Ppx = this.vp.toCanvas(P.x, P.y); // Единичный вектор вдоль линии const ldx = L2.x - L1.x, ldy = L2.y - L1.y; const llen = Math.hypot(ldx, ldy); if (llen < 1e-9) continue; const uLx = ldx / llen, uLy = ldy / llen; // Единичный вектор F → P (направление перпендикуляра) const fpx = Ppx.x - F.x, fpy = Ppx.y - F.y; const fpLen = Math.hypot(fpx, fpy); if (fpLen < 2) continue; // точка совпадает с основанием — пропустить const uPx = fpx / fpLen, uPy = fpy / fpLen; // Квадрат прямого угла ctx.save(); ctx.strokeStyle = '#4ADE80'; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.8; ctx.beginPath(); ctx.moveTo(F.x + uLx*SQ_SZ, F.y + uLy*SQ_SZ); ctx.lineTo(F.x + uLx*SQ_SZ + uPx*SQ_SZ, F.y + uLy*SQ_SZ + uPy*SQ_SZ); ctx.lineTo(F.x + uPx*SQ_SZ, F.y + uPy*SQ_SZ); ctx.stroke(); ctx.restore(); } } /* ── Измерительные чипы (measure_length / measure_angle / measure_area) ── */ _drawMeasurements(ctx) { const CHIP_PAD_X = 8, CHIP_PAD_Y = 4, CHIP_R = 6; ctx.save(); ctx.font = '11px Manrope,sans-serif'; for (const obj of this.eng.all()) { if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue; const text = this._measureText(obj); if (text === null) continue; const labelPx = this._measureLabelPos(obj); if (!labelPx) continue; const w = ctx.measureText(text).width + CHIP_PAD_X * 2; const h = 18; const x = labelPx.x - w / 2; const y = labelPx.y - h / 2; const isSelected = this._isSelected(obj); const col = obj.type === 'measure_length' ? '#9B5DE5' : obj.type === 'measure_angle' ? '#F15BB5' : '#22d55e'; ctx.globalAlpha = 0.92; ctx.fillStyle = 'rgba(10,7,24,0.82)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y, w, h, CHIP_R); else ctx.rect(x, y, w, h); ctx.fill(); ctx.strokeStyle = isSelected ? '#fff' : col; ctx.lineWidth = isSelected ? 1.8 : 1.2; ctx.globalAlpha = isSelected ? 0.9 : 0.7; ctx.stroke(); ctx.fillStyle = col; ctx.globalAlpha = 0.95; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3; ctx.fillText(text, labelPx.x, labelPx.y + 0.5); ctx.shadowBlur = 0; ctx.textBaseline = 'alphabetic'; } ctx.restore(); } _measureText(obj) { if (obj.type === 'measure_length') { const seg = this.eng.get(obj.srcSeg); if (!seg) return null; const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id); if (!m1 || !m2) return null; const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id); const lab1 = (p1 && p1.label) || ''; const lab2 = (p2 && p2.label) || ''; const name = lab1 && lab2 ? lab1 + lab2 : 'seg'; return name + ' = ' + gDist(m1, m2).toFixed(2); } if (obj.type === 'measure_angle') { const pA = this.eng.get(obj.srcA), pV = this.eng.get(obj.srcVtx), pB = this.eng.get(obj.srcB); if (!pA || !pV || !pB) return null; const ang = gAngleDeg(pA, pV, pB); const lA = (pA.label) || '', lV = (pV.label) || '', lB = (pB.label) || ''; const name = (lA && lV && lB) ? lA + lV + lB : 'ang'; return '∠' + name + ' = ' + ang.toFixed(1) + '°'; } if (obj.type === 'measure_area') { const poly = this.eng.get(obj.srcPoly); if (!poly) return null; const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean); if (pts.length < 3) return null; return 'S = ' + gPolygonArea(pts).toFixed(2); } return null; } /* Базовая позиция чипа в пикселях (без пользовательского offset) */ _measureLabelBasePos(obj) { if (obj.type === 'measure_length') { const seg = this.eng.get(obj.srcSeg); if (!seg) return null; const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id); if (!m1 || !m2) return null; const mid = this.vp.toCanvas((m1.x + m2.x) / 2, (m1.y + m2.y) / 2); return { x: mid.x, y: mid.y - 18 }; } if (obj.type === 'measure_angle') { const pV = this.eng.get(obj.srcVtx); if (!pV) return null; const vPx = this.vp.toCanvas(pV.x, pV.y); return { x: vPx.x, y: vPx.y - 28 }; } if (obj.type === 'measure_area') { const poly = this.eng.get(obj.srcPoly); if (!poly) return null; const pts = poly.pointIds.map(id => this._mpt(id)).filter(Boolean); if (pts.length < 3) return null; const sumX = pts.reduce((s, p) => s + p.x, 0) / pts.length; const sumY = pts.reduce((s, p) => s + p.y, 0) / pts.length; const c = this.vp.toCanvas(sumX, sumY); return { x: c.x, y: c.y }; } return null; } /* Позиция чипа в пикселях (базовая + пользовательский offset) */ _measureLabelPos(obj) { const base = this._measureLabelBasePos(obj); if (!base) return null; return { x: base.x + (obj.offX || 0), y: base.y + (obj.offY || 0) }; } /* ── Локус (ГМТ) ──────────────────────────────────────────── */ _drawLocus(ctx, obj) { const pts = obj.samples; if (!pts || pts.length < 2) return; const locusColor = obj.style && obj.style.color ? obj.style.color : '#F59E0B'; const drawPolyline = () => { ctx.save(); ctx.strokeStyle = locusColor; ctx.lineWidth = 2; ctx.globalAlpha = 0.65; ctx.setLineDash([]); ctx.beginPath(); const first = this.vp.toCanvas(pts[0].x, pts[0].y); ctx.moveTo(first.x, first.y); for (let i = 1; i < pts.length; i++) { const p = this.vp.toCanvas(pts[i].x, pts[i].y); ctx.lineTo(p.x, p.y); } ctx.stroke(); ctx.restore(); }; if (window.LabFX) { LabFX.glow.drawGlow(ctx, drawPolyline, { color: '#F59E0B', intensity: 8 }); } else { drawPolyline(); } } /* ── Предпросмотр (строящийся объект) ─────────────────────── */ _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); // ПКМ → отмена текущего построения if (e.button === 2) { this._pending = []; this._preview = null; this._pendingLineRef = null; this._pendingMover = 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, px, py); } _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; } } const isConstrained = found && found.derived && (found.constr === 'on_segment' || found.constr === 'on_circle'); if (found && !found.locked && (!found.derived || isConstrained)) { this._drag = { id: found.id, constrained: isConstrained }; this._selected = found; this.canvas.style.cursor = 'grabbing'; } else { // Проверить, не кликнули ли на чип измерения (для drag чипа) const hitObj = this._hitTest(px, py); this._selected = hitObj; if (hitObj && (hitObj.type === 'measure_length' || hitObj.type === 'measure_angle' || hitObj.type === 'measure_area')) { // drag чипа — запоминаем offset курсора относительно позиции чипа const lp = this._measureLabelPos(hitObj); this._drag = { id: hitObj.id, chipDrag: true, offX: px - (lp ? lp.x : px), offY: py - (lp ? lp.y : py) }; this.canvas.style.cursor = 'grabbing'; } else { this._drag = null; } } this.render(); } /** Hit-test для не-точечных объектов */ _hitTest(px, py) { const HIT = 8; // pixels const m = this.vp.toMath(px, py); // Измерительные чипы this.ctx.font = '11px Manrope,sans-serif'; for (const obj of this.eng.all()) { if (obj.type !== 'measure_length' && obj.type !== 'measure_angle' && obj.type !== 'measure_area') continue; const text = this._measureText(obj); if (!text) continue; const labelPx = this._measureLabelPos(obj); if (!labelPx) continue; const w = this.ctx.measureText(text).width + 16; const h = 18; if (px >= labelPx.x - w/2 - 2 && px <= labelPx.x + w/2 + 2 && py >= labelPx.y - h/2 - 2 && py <= labelPx.y + h/2 + 2) { return obj; } } // Локусы for (const obj of this.eng.byType('locus')) { const pts = obj.samples; if (!pts || pts.length < 2) continue; for (let i = 1; i < pts.length; i++) { const a = this.vp.toCanvas(pts[i-1].x, pts[i-1].y); const b = this.vp.toCanvas(pts[i].x, pts[i].y); const seg2D = { x: b.x-a.x, y: b.y-a.y }; const lenSq = seg2D.x*seg2D.x + seg2D.y*seg2D.y; if (lenSq < 1e-9) continue; const t = Math.max(0, Math.min(1, ((px-a.x)*seg2D.x + (py-a.y)*seg2D.y) / lenSq)); const dx = px - (a.x + t*seg2D.x), dy = py - (a.y + t*seg2D.y); if (dx*dx + dy*dy < HIT*HIT) return obj; } } // Полигоны (проверяем стороны) for (const obj of this.eng.byType('polygon')) { const ids = obj.pointIds; 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')) { if (obj.virtual) continue; 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; } // Производные прямые 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, px, py) { 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()); if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 }); } 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()); if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 }); } 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; /* ══ 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; } /* ══ Phase 3: foot, circumcircle, incircle ══ */ case 'foot': { if (!this._pendingLineRef) { // Первый клик: выбрать прямую const hit = this._hitTestLine(px, py); if (hit) { this._pendingLineRef = hit; if (this.onHintChange) this.onHintChange('foot', 2); } } else { // Второй клик: выбрать точку, с которой опускаем перпендикуляр this._pushUndo(); const srcPt = this._ensurePoint(snapped); const sl = this._pendingLineRef; let L1, L2; if (sl.type === 'derived_line') { L1 = { x: sl.ptX, y: sl.ptY }; L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY }; } else { L1 = this._mpt(sl.p1Id); L2 = this._mpt(sl.p2Id); } if (L1 && L2) { const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2); const lbl = this._nextLabel(); this.eng.add({ type: 'point', derived: true, constr: 'foot', srcLine: sl.id, srcPt: srcPt.id, x: f.x, y: f.y, label: lbl, style: { color: '#4ADE80', size: 4 } }); } this._pendingLineRef = null; this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } case 'circumcircle': case 'incircle': { this._pending.push(snapped); if (this._pending.length === 3) { this._pushUndo(); const pts = this._pending.map(p => this._ensurePoint(p)); const A = { x: pts[0].x, y: pts[0].y }; const B = { x: pts[1].x, y: pts[1].y }; const C = { x: pts[2].x, y: pts[2].y }; const cc = this.tool === 'circumcircle' ? gCircumcircle(A, B, C) : gIncircle(A, B, C); if (cc) { const sameConstr = this.eng.byType('circle').filter(c => c.constr === this.tool).length; const isCircum = this.tool === 'circumcircle'; this.eng.add({ type: 'circle', derived: true, constr: this.tool, ptA: pts[0].id, ptB: pts[1].id, ptC: pts[2].id, cx: cc.cx, cy: cc.cy, r: cc.r, valid: true, label: isCircum ? (sameConstr ? 'C' + (sameConstr+1) : 'C₁') : (sameConstr ? 'I' + (sameConstr+1) : 'I₁'), style: { color: isCircum ? '#38BDF8' : '#34D399' } }); } this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 4: reflect, ngon ══ */ case 'reflect': { if (!this._pendingLineRef) { // Первый клик: выбрать ось симметрии (прямую/отрезок) const hit = this._hitTestLine(px, py); if (hit) { this._pendingLineRef = hit; if (this.onHintChange) this.onHintChange('reflect', 2); } } else { // Второй клик: точка, которую отражаем this._pushUndo(); const srcPt = this._ensurePoint(snapped); const sl = this._pendingLineRef; let L1, L2; if (sl.type === 'derived_line') { L1 = { x: sl.ptX, y: sl.ptY }; L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY }; } else { L1 = this._mpt(sl.p1Id); L2 = this._mpt(sl.p2Id); } if (L1 && L2) { const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2); const lbl = this._nextLabel(); this.eng.add({ type: 'point', derived: true, constr: 'reflect', srcLine: sl.id, srcPt: srcPt.id, x: 2*f.x - srcPt.x, y: 2*f.y - srcPt.y, label: lbl, style: { color: '#F472B6', size: 4 } }); } this._pendingLineRef = null; this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } case 'ngon': { this._pending.push(snapped); if (this._pending.length === 2) { this._pushUndo(); const n = this._ngonSides; const center = this._ensurePoint(this._pending[0]); const v0 = this._ensurePoint(this._pending[1]); const col = '#D4B96B'; const ptIds = [v0.id]; for (let k = 1; k < n; k++) { const angle = 2 * Math.PI * k / n; const dx = v0.x - center.x, dy = v0.y - center.y; const cos_a = Math.cos(angle), sin_a = Math.sin(angle); const vk = this.eng.add({ type: 'point', derived: true, constr: 'ngon_vertex', srcCenter: center.id, srcVertex: v0.id, k, n, x: center.x + dx*cos_a - dy*sin_a, y: center.y + dx*sin_a + dy*cos_a, label: '', style: { color: col, size: 4 } }); ptIds.push(vk.id); } this.eng.add({ type: 'polygon', pointIds: ptIds, style: { color: col, fillColor: 'rgba(212,185,107,0.08)' } }); this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 5: tangent, translate ══ */ case 'tangent': { if (!this._pendingCircRef) { // Первый клик: выбрать окружность const hit = this._hitTestCircle(px, py); if (hit) { this._pendingCircRef = hit; if (this.onHintChange) this.onHintChange('tangent', 2); } } else { // Второй клик: внешняя точка → создать 2 касательные this._pushUndo(); const extPt = this._ensurePoint(snapped); const circ = this._pendingCircRef; let O, r; if (circ.derived && circ.cx != null) { O = { x: circ.cx, y: circ.cy }; r = circ.r; } else { const mc = this._mpt(circ.centerId), me = this._mpt(circ.edgeId); O = mc; r = mc && me ? gDist(mc, me) : 0; } if (O && r > 0) { const tpts = gTangentPoints(O, { x: extPt.x, y: extPt.y }, r); if (tpts) { const cnt = this.eng.byType('derived_line').filter(d => d.constr === 'tangent').length; for (let w = 0; w < 2; w++) { const T = tpts[w]; const dx = T.x - extPt.x, dy = T.y - extPt.y; const len = Math.hypot(dx, dy); if (len < 1e-12) continue; const lbl = cnt + w === 0 ? 't₁' : 't' + (cnt + w + 1); this.eng.add({ type: 'derived_line', derived: true, constr: 'tangent', srcCircle: circ.id, srcPt: extPt.id, which: w, ptX: extPt.x, ptY: extPt.y, dirX: dx/len, dirY: dy/len, valid: true, label: lbl, style: { color: '#FCD34D' } }); } } } this._pendingCircRef = null; this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } case 'translate': { this._pending.push(snapped); if (this._pending.length === 1) { if (this.onHintChange) this.onHintChange('translate', 2); } else if (this._pending.length === 2) { if (this.onHintChange) this.onHintChange('translate', 3); } else if (this._pending.length === 3) { this._pushUndo(); const ptA = this._ensurePoint(this._pending[0]); const ptB = this._ensurePoint(this._pending[1]); const ptP = this._ensurePoint(this._pending[2]); const lbl = this._nextLabel(); this.eng.add({ type: 'point', derived: true, constr: 'translate', srcA: ptA.id, srcB: ptB.id, srcPt: ptP.id, x: ptP.x + (ptB.x - ptA.x), y: ptP.y + (ptB.y - ptA.y), label: lbl, style: { color: '#60A5FA', size: 4 } }); this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 8: tick marks, arc marks ══ */ case 'tick': { // Клик на отрезок или сторону полигона → циклически меняет метку (0→1→2→3→0) const line = this._hitTestLine(px, py); if (line) { this._pushUndo(); if (line.virtual && line.polyId) { const poly = this.eng.get(line.polyId); if (poly) { if (!poly.sideMarks) poly.sideMarks = new Array(poly.pointIds.length).fill(0); const si = poly.pointIds.indexOf(line.p1Id); if (si >= 0) poly.sideMarks[si] = ((poly.sideMarks[si] || 0) + 1) % 4; } } else { const seg = this.eng.get(line.id); if (seg) seg.tickMark = ((seg.tickMark || 0) + 1) % 4; } if (this.onUpdate) this.onUpdate(this.getStats()); } break; } case 'arcmark': { // Клик на вершину полигона → циклически меняет метку дуги (0→1→2→3→0) const SNAP_PX = 14; let foundPoly = null, foundIdx = -1; outer8: for (const poly of this.eng.byType('polygon')) { for (let i = 0; i < poly.pointIds.length; i++) { const p = this._p(poly.pointIds[i]); if (p && Math.hypot(p.x - px, p.y - py) < SNAP_PX) { foundPoly = poly; foundIdx = i; break outer8; } } } if (foundPoly && foundIdx >= 0) { this._pushUndo(); if (!foundPoly.angleMarks) foundPoly.angleMarks = new Array(foundPoly.pointIds.length).fill(0); foundPoly.angleMarks[foundIdx] = (foundPoly.angleMarks[foundIdx] + 1) % 4; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 7: altitude, median, centroid, orthocenter ══ */ case 'altitude': { // 1 клик на вершину треугольника → высота из этой вершины на противоположную сторону const hitAlt = this._findVertexOfPoly(px, py, 3); if (hitAlt) { this._pushUndo(); const { poly: polyAlt, idx: iA } = hitAlt; const ids = polyAlt.pointIds; const iB = (iA+1)%3, iC = (iA+2)%3; const ptA = this.eng.get(ids[iA]), ptB = this.eng.get(ids[iB]), ptC = this.eng.get(ids[iC]); const A = {x:ptA.x,y:ptA.y}, B = {x:ptB.x,y:ptB.y}, C = {x:ptC.x,y:ptC.y}; const f = gFoot(A, B, C); const nF = this.eng.byType('point').filter(p=>p.constr==='altitude_foot').length; const foot = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:ids[iA], ptB:ids[iB], ptC:ids[iC], x:f.x, y:f.y, label:'H'+(nF ? String.fromCharCode(0x2080+nF+1) : '\u2081'), style:{color:'#4ADE80', size:4} }); this.eng.add({ type:'segment', p1Id:ids[iA], p2Id:foot.id, style:{color:'#4ADE80', width:1.5} }); if (this.onUpdate) this.onUpdate(this.getStats()); } break; } case 'median': { // 1 клик на вершину треугольника → медиана из этой вершины к середине противоположной стороны const hitMed = this._findVertexOfPoly(px, py, 3); if (hitMed) { this._pushUndo(); const { poly: polyMed, idx: iA } = hitMed; const ids = polyMed.pointIds; const iB = (iA+1)%3, iC = (iA+2)%3; const ptB = this.eng.get(ids[iB]), ptC = this.eng.get(ids[iC]); const nMid = this.eng.byType('point').filter(p=>p.constr==='midpoint').length; const mid = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:ids[iB], srcB:ids[iC], x:(ptB.x+ptC.x)/2, y:(ptB.y+ptC.y)/2, label:'M'+(nMid+1), style:{color:'#22d55e', size:4} }); this.eng.add({ type:'segment', p1Id:ids[iA], p2Id:mid.id, style:{color:'#22d55e', width:1.5} }); if (this.onUpdate) this.onUpdate(this.getStats()); } break; } case 'centroid': { // 1 клик на/внутри треугольника → 3 медианы + центроид G const polyCen = this._findTriangleNear(px, py); if (polyCen) { this._pushUndo(); const [idA, idB, idC] = polyCen.pointIds; const pA = this.eng.get(idA), pB = this.eng.get(idB), pC = this.eng.get(idC); const nG = this.eng.byType('point').filter(p=>p.constr==='centroid').length; const col = '#A78BFA'; const mBC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:idB, srcB:idC, x:(pB.x+pC.x)/2, y:(pB.y+pC.y)/2, label:'M₁', style:{color:col,size:3} }); const mAC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:idA, srcB:idC, x:(pA.x+pC.x)/2, y:(pA.y+pC.y)/2, label:'M₂', style:{color:col,size:3} }); const mAB = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:idA, srcB:idB, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2, label:'M₃', style:{color:col,size:3} }); this.eng.add({ type:'segment', p1Id:idA, p2Id:mBC.id, style:{color:col, width:1.5} }); this.eng.add({ type:'segment', p1Id:idB, p2Id:mAC.id, style:{color:col, width:1.5} }); this.eng.add({ type:'segment', p1Id:idC, p2Id:mAB.id, style:{color:col, width:1.5} }); this.eng.add({ type:'point', derived:true, constr:'centroid', ptA:idA, ptB:idB, ptC:idC, x:(pA.x+pB.x+pC.x)/3, y:(pA.y+pB.y+pC.y)/3, label: nG ? 'G'+(nG+1) : 'G', style:{color:col, size:6} }); if (this.onUpdate) this.onUpdate(this.getStats()); } break; } case 'orthocenter': { // 1 клик на/внутри треугольника → 3 высоты + ортоцентр H const polyOrt = this._findTriangleNear(px, py); if (polyOrt) { this._pushUndo(); const [idA, idB, idC] = polyOrt.pointIds; const pA = this.eng.get(idA), pB = this.eng.get(idB), pC = this.eng.get(idC); const nH = this.eng.byType('point').filter(p=>p.constr==='orthocenter').length; const col = '#F97316'; const A = {x:pA.x,y:pA.y}, B = {x:pB.x,y:pB.y}, C = {x:pC.x,y:pC.y}; const Ha = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:idA, ptB:idB, ptC:idC, ...gFoot(A,B,C), label:'H_a', style:{color:col,size:3} }); const Hb = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:idB, ptB:idA, ptC:idC, ...gFoot(B,A,C), label:'H_b', style:{color:col,size:3} }); const Hc = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:idC, ptB:idA, ptC:idB, ...gFoot(C,A,B), label:'H_c', style:{color:col,size:3} }); this.eng.add({ type:'segment', p1Id:idA, p2Id:Ha.id, style:{color:col, width:1.5} }); this.eng.add({ type:'segment', p1Id:idB, p2Id:Hb.id, style:{color:col, width:1.5} }); this.eng.add({ type:'segment', p1Id:idC, p2Id:Hc.id, style:{color:col, width:1.5} }); const orth = gOrthocenter(A, B, C); if (orth) { this.eng.add({ type:'point', derived:true, constr:'orthocenter', ptA:idA, ptB:idB, ptC:idC, x:orth.x, y:orth.y, valid:true, label: nH ? 'H'+(nH+1) : 'H', style:{color:col, size:6} }); } if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 8.3: Метка параллельности ══ */ case 'parallelmark': { // Клик на отрезок или сторону полигона → циклически меняет parallelMark (0→1→2→0) const line = this._hitTestLine(px, py); if (line) { this._pushUndo(); if (line.virtual && line.polyId) { const poly = this.eng.get(line.polyId); if (poly) { if (!poly.parallelSideMarks) poly.parallelSideMarks = new Array(poly.pointIds.length).fill(0); const si = poly.pointIds.indexOf(line.p1Id); if (si >= 0) poly.parallelSideMarks[si] = ((poly.parallelSideMarks[si] || 0) + 1) % 3; } } else { const seg = this.eng.get(line.id); if (seg) seg.parallelMark = ((seg.parallelMark || 0) + 1) % 3; } if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 9.1: Средняя линия треугольника ══ */ case 'midline': { // 3 клика: A, B, C → середины AB и AC, отрезок M₁M₂ параллельный BC this._pending.push(snapped); if (this._pending.length === 1) { if (this.onHintChange) this.onHintChange('midline', 2); } else if (this._pending.length === 2) { if (this.onHintChange) this.onHintChange('midline', 3); } else if (this._pending.length === 3) { this._pushUndo(); const pA = this._ensurePoint(this._pending[0]); const pB = this._ensurePoint(this._pending[1]); const pC = this._ensurePoint(this._pending[2]); const n = this.eng.byType('point').filter(p => p.constr === 'midpoint').length; const col = '#06D6E0'; const mAB = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:pA.id, srcB:pB.id, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2, label: n ? `M${n+1}` : 'M₁', style:{color:col, size:3} }); const mAC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:pA.id, srcB:pC.id, x:(pA.x+pC.x)/2, y:(pA.y+pC.y)/2, label: n ? `M${n+2}` : 'M₂', style:{color:col, size:3} }); this.eng.add({ type:'segment', p1Id:mAB.id, p2Id:mAC.id, style:{color:col, width:2} }); this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 9.2: Параллелограмм ══ */ case 'parallelogram': { // 3 клика: A, B, C → D = A + C - B, полигон ABCD this._pending.push(snapped); if (this._pending.length === 1) { if (this.onHintChange) this.onHintChange('parallelogram', 2); } else if (this._pending.length === 2) { if (this.onHintChange) this.onHintChange('parallelogram', 3); } else if (this._pending.length === 3) { this._pushUndo(); const pA = this._ensurePoint(this._pending[0]); const pB = this._ensurePoint(this._pending[1]); const pC = this._ensurePoint(this._pending[2]); const col = '#F97316'; const dx = pA.x + pC.x - pB.x, dy = pA.y + pC.y - pB.y; const pD = this.eng.add({ type:'point', derived:true, constr:'parallelogram_d', ptA:pA.id, ptB:pB.id, ptC:pC.id, x:dx, y:dy, label:this._nextLabel(), style:{color:col, size:5} }); this.eng.add({ type:'polygon', pointIds:[pA.id, pB.id, pC.id, pD.id], style:{color:col, fillColor:'rgba(249,115,22,0.08)'} }); this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 9.3: Диагонали полигона ══ */ case 'diagonal': { // Клик на полигон → добавляет все диагонали (соединяет несмежные вершины) const SNAP_PX = 18; let hitPoly = null; outer9: for (const poly of this.eng.byType('polygon')) { const pts = poly.pointIds.map(id => this._p(id)).filter(Boolean); // Проверяем попадание внутрь полигона (упрощённо — bbox) if (pts.length < 4) continue; const xs = pts.map(p => p.x), ys = pts.map(p => p.y); const bx = Math.min(...xs), bX = Math.max(...xs); const by = Math.min(...ys), bY = Math.max(...ys); if (px >= bx - SNAP_PX && px <= bX + SNAP_PX && py >= by - SNAP_PX && py <= bY + SNAP_PX) { hitPoly = poly; break outer9; } } if (hitPoly) { this._pushUndo(); const ids = hitPoly.pointIds; const n = ids.length; for (let i = 0; i < n - 2; i++) { for (let j = i + 2; j < n; j++) { if (i === 0 && j === n - 1) continue; // стороны, не диагонали this.eng.add({ type:'segment', p1Id:ids[i], p2Id:ids[j], style:{color:'#9CA3AF', width:1.5} }); } } if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 10.1: Теорема Фалеса ══ */ case 'thales': { // 3 клика: O (центр), A (точка на луче 1), B (точка на луче 2) // Строит A' = O + k*(A-O), B' = O + k*(B-O), AB ∥ A'B' this._pending.push(snapped); if (this._pending.length === 1) { if (this.onHintChange) this.onHintChange('thales', 2); } else if (this._pending.length === 2) { if (this.onHintChange) this.onHintChange('thales', 3); } else if (this._pending.length === 3) { this._pushUndo(); const pO = this._ensurePoint(this._pending[0]); const pA = this._ensurePoint(this._pending[1]); const pB = this._ensurePoint(this._pending[2]); const k = this._scaleK; const col = '#06D6E0'; // A' и B' — производные точки (constr:'scale') const pA2 = this.eng.add({ type:'point', derived:true, constr:'scale', srcO:pO.id, srcPt:pA.id, k, x: pO.x + k*(pA.x-pO.x), y: pO.y + k*(pA.y-pO.y), label:"A'", style:{color:col, size:4} }); const pB2 = this.eng.add({ type:'point', derived:true, constr:'scale', srcO:pO.id, srcPt:pB.id, k, x: pO.x + k*(pB.x-pO.x), y: pO.y + k*(pB.y-pO.y), label:"B'", style:{color:col, size:4} }); // Лучи O→A'→... (через A и A') this.eng.add({ type:'segment', p1Id:pO.id, p2Id:pA2.id, style:{color:'#9CA3AF', width:1, dash:[5,4]} }); this.eng.add({ type:'segment', p1Id:pO.id, p2Id:pB2.id, style:{color:'#9CA3AF', width:1, dash:[5,4]} }); // Отрезок AB (ближняя параллель) this.eng.add({ type:'segment', p1Id:pA.id, p2Id:pB.id, style:{color:'#FFE066', width:2} }); // Отрезок A'B' (дальняя параллель) this.eng.add({ type:'segment', p1Id:pA2.id, p2Id:pB2.id, style:{color:col, width:2} }); this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Phase 10.2: Подобие (масштаб) ══ */ case 'scale': { // Шаг 1: клик → центр подобия O // Шаг 2: клик → точка P → строит P' = O + k*(P - O) if (!this._pendingScaleO) { this._pendingScaleO = this._ensurePoint(snapped); if (this.onHintChange) this.onHintChange('scale', 2); } else { this._pushUndo(); const pO = this._pendingScaleO; const pP = this._ensurePoint(snapped); const k = this._scaleK; const nx = pO.x + k * (pP.x - pO.x); const ny = pO.y + k * (pP.y - pO.y); this.eng.add({ type:'point', derived:true, constr:'scale', srcO:pO.id, srcPt:pP.id, k, x:nx, y:ny, label:this._nextLabel(), style:{color:'#F15BB5', size:5} }); if (this.onUpdate) this.onUpdate(this.getStats()); // Продолжаем с тем же O — можно строить следующие точки if (this.onHintChange) this.onHintChange('scale', 2); } break; } /* ══ Точка на отрезке — для ГМТ ══ */ case 'point_on_segment': { const hitSeg = this._hitTestLine(px, py); if (!hitSeg || hitSeg.type !== 'segment' || hitSeg.virtual) break; this._pushUndo(); const p1 = this.eng.get(hitSeg.p1Id), p2 = this.eng.get(hitSeg.p2Id); if (!p1 || !p2) break; const dx = p2.x - p1.x, dy = p2.y - p1.y; const l2 = dx*dx + dy*dy; const t = l2 < 1e-12 ? 0.5 : Math.max(0, Math.min(1, ((snapped.x-p1.x)*dx + (snapped.y-p1.y)*dy) / l2)); const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1); this.eng.add({ type: 'point', derived: true, constr: 'on_segment', srcSeg: hitSeg.id, _t: t, x: p1.x + t*dx, y: p1.y + t*dy, label: lbl, style: { color: '#06D6E0', size: 4 } }); if (this.onUpdate) this.onUpdate(this.getStats()); break; } /* ══ Точка на окружности — для ГМТ ══ */ case 'point_on_circle': { const hitCirc = this._hitTestCircle(px, py); if (!hitCirc) break; this._pushUndo(); let cx, cy, r; if (hitCirc.derived && hitCirc.cx != null) { cx = hitCirc.cx; cy = hitCirc.cy; r = hitCirc.r; } else { const mc = this.eng.get(hitCirc.centerId), me = this.eng.get(hitCirc.edgeId); if (!mc || !me) break; cx = mc.x; cy = mc.y; r = gDist(mc, me); } const theta = Math.atan2(snapped.y - cy, snapped.x - cx); const lbl = 'P' + (this.eng.points().filter(p => p.constr === 'on_segment' || p.constr === 'on_circle').length + 1); this.eng.add({ type: 'point', derived: true, constr: 'on_circle', srcCircle: hitCirc.id, _theta: theta, x: cx + r * Math.cos(theta), y: cy + r * Math.sin(theta), label: lbl, style: { color: '#06D6E0', size: 4 } }); if (this.onUpdate) this.onUpdate(this.getStats()); break; } /* ══ Измерение длины — клик на отрезок ══ */ case 'measure_length': { const seg = this._hitTestLine(px, py); if (seg && (seg.type === 'segment') && !seg.virtual) { this._pushUndo(); this.eng.add({ type:'measure_length', srcSeg: seg.id, offX:0, offY:0 }); if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Измерение угла — 3 клика: сторона A, вершина, сторона B ══ */ case 'measure_angle': { this._pending.push(snapped); if (this._pending.length === 1) { if (this.onHintChange) this.onHintChange('measure_angle', 2); } else if (this._pending.length === 2) { if (this.onHintChange) this.onHintChange('measure_angle', 3); } else if (this._pending.length === 3) { this._pushUndo(); const ptA = this._ensurePoint(this._pending[0]); const ptVtx = this._ensurePoint(this._pending[1]); const ptB = this._ensurePoint(this._pending[2]); this.eng.add({ type:'measure_angle', srcA:ptA.id, srcVtx:ptVtx.id, srcB:ptB.id, offX:0, offY:0 }); this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ Измерение площади — клик на полигон ══ */ case 'measure_area': { const poly = this._hitTest(px, py); if (poly && poly.type === 'polygon') { this._pushUndo(); this.eng.add({ type:'measure_area', srcPoly: poly.id, offX:0, offY:0 }); if (this.onUpdate) this.onUpdate(this.getStats()); } break; } /* ══ ГМТ (локус) — шаг 1: мовер-точка, шаг 2: целевая точка ══ */ case 'locus': { if (!this._pendingMover) { // Первый клик: выбрать точку-мовер (должна быть constrained) const SNAP_PX = 12; let hitPt = null; for (const pt of this.eng.points()) { const pp = this.vp.toCanvas(pt.x, pt.y); if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; } } if (!hitPt) break; // Мовер должен быть constrained (точка на отрезке или на окружности по параметру) if (!hitPt.constr || (hitPt.constr !== 'on_segment' && hitPt.constr !== 'on_circle')) { if (this.onLocusError) this.onLocusError('Выбери точку, ограниченную на отрезке или окружности (тип: on_segment / on_circle)'); break; } this._pendingMover = hitPt; if (this.onHintChange) this.onHintChange('locus', 2); } else { // Второй клик: выбрать целевую точку const SNAP_PX = 12; let hitPt = null; for (const pt of this.eng.points()) { const pp = this.vp.toCanvas(pt.x, pt.y); if (Math.hypot(pp.x - px, pp.y - py) < SNAP_PX) { hitPt = pt; break; } } if (!hitPt || hitPt === this._pendingMover) break; // Проверим, что целевая зависит от мовера if (!this._isDownstreamOf(hitPt.id, this._pendingMover.id)) { if (this.onLocusError) this.onLocusError('Целевая точка не зависит от выбранного мовера'); this._pendingMover = null; break; } this._pushUndo(); const samples = this._sweepLocus(this._pendingMover, hitPt); const cnt = this.eng.byType('locus').length; this.eng.add({ type: 'locus', srcMover: this._pendingMover.id, srcTarget: hitPt.id, samples, style: { color: '#F59E0B' }, label: cnt ? 'L' + (cnt + 1) : 'L₁' }); this._pendingMover = null; this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; } } this.render(); } /* Проверяет, зависит ли targetId от moverId (BFS по графу зависимостей) */ _isDownstreamOf(targetId, moverId) { const visited = new Set(); const queue = [moverId]; while (queue.length) { const curr = queue.shift(); if (curr === targetId) return true; if (visited.has(curr)) continue; visited.add(curr); for (const obj of this.eng.all()) { if (!visited.has(obj.id) && this.eng._dependsOn(obj, curr)) { queue.push(obj.id); } } } return false; } /* Прогоняет мовер по его диапазону и записывает позиции цели */ _sweepLocus(moverPt, targetPt) { const N = 200; const samples = []; // Сохранить текущее состояние мовера const savedX = moverPt.x, savedY = moverPt.y; const savedT = moverPt._t; if (moverPt.constr === 'on_segment') { const seg = this.eng.get(moverPt.srcSeg); if (!seg) return samples; for (let i = 0; i <= N; i++) { const t = i / N; const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id); if (!p1 || !p2) continue; moverPt.x = p1.x + t * (p2.x - p1.x); moverPt.y = p1.y + t * (p2.y - p1.y); moverPt._t = t; this.eng.propagateDeps(moverPt.id); samples.push({ x: targetPt.x, y: targetPt.y }); } } else if (moverPt.constr === 'on_circle') { const circ = this.eng.get(moverPt.srcCircle); if (!circ) return samples; for (let i = 0; i <= N; i++) { const theta = 2 * Math.PI * i / N; let cx, cy, r; if (circ.derived && circ.cx != null) { cx = circ.cx; cy = circ.cy; r = circ.r; } else { const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId); if (!mc || !me) continue; cx = mc.x; cy = mc.y; r = gDist(mc, me); } moverPt.x = cx + r * Math.cos(theta); moverPt.y = cy + r * Math.sin(theta); this.eng.propagateDeps(moverPt.id); samples.push({ x: targetPt.x, y: targetPt.y }); } } // Восстановить состояние мовера moverPt.x = savedX; moverPt.y = savedY; if (savedT !== undefined) moverPt._t = savedT; else delete moverPt._t; this.eng.propagateDeps(moverPt.id); return samples; } _finishPolygon() { if (this._pending.length < 3) { this._pending = []; this._preview = null; this.render(); return; } this._pushUndo(); 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()); if (window.LabFX) { LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 }); const cp = this.vp.toCanvas(m.x, m.y); LabFX.particles.emit({ ctx: this.ctx, x: cp.x, y: cp.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 400, speed: 35, spread: Math.PI * 2, gravity: 0, glow: true }); if (this._fxFrames !== undefined) this._fxFrames = 60; } 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); } /** Переместить точку on_segment или on_circle — проецируем мышь на хост-геометрию */ _moveConstrainedPoint(id, mx, my) { const obj = this.eng.get(id); if (!obj) return; if (obj.constr === 'on_segment') { const seg = this.eng.get(obj.srcSeg); if (!seg) return; const p1 = this.eng.get(seg.p1Id), p2 = this.eng.get(seg.p2Id); if (!p1 || !p2) return; const dx = p2.x - p1.x, dy = p2.y - p1.y; const l2 = dx*dx + dy*dy; if (l2 < 1e-12) return; const t = Math.max(0, Math.min(1, ((mx-p1.x)*dx + (my-p1.y)*dy) / l2)); obj._t = t; obj.x = p1.x + t*dx; obj.y = p1.y + t*dy; } else if (obj.constr === 'on_circle') { const circ = this.eng.get(obj.srcCircle); if (!circ) return; let cx, cy, r; if (circ.derived && circ.cx != null) { cx = circ.cx; cy = circ.cy; r = circ.r; } else { const mc = this.eng.get(circ.centerId), me = this.eng.get(circ.edgeId); if (!mc || !me) return; cx = mc.x; cy = mc.y; r = gDist(mc, me); } const theta = Math.atan2(my - cy, mx - cx); obj._theta = theta; obj.x = cx + r * Math.cos(theta); obj.y = cy + r * Math.sin(theta); } this.eng.propagateDeps(id); } _onMove(e) { const { px, py } = this._evPos(e); 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) { if (this._drag.chipDrag) { // Перетаскивание чипа измерения const obj = this.eng.get(this._drag.id); if (obj) { const basePos = this._measureLabelBasePos(obj); if (basePos) { obj.offX = (px - this._drag.offX) - basePos.x; obj.offY = (py - this._drag.offY) - basePos.y; } } this.render(); return; } if (this._drag.constrained) { this._moveConstrainedPoint(this._drag.id, m.x, m.y); this.render(); return; } const snapped = this._computeSnap(m.x, m.y); this.eng.movePoint(this._drag.id, snapped.x, snapped.y); this.render(); return; } // Обновить 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; const obj = this._selected; const deps = this.eng.getDependents(obj.id).filter(d => !d.virtual); if (deps.length > 0 && this.onDeleteRequest) { // Есть зависимые — делегировать подтверждение наружу this.onDeleteRequest(obj, deps, () => this._doDeleteSoft(obj.id), () => this._doDeleteCascade(obj.id) ); } else { this._doDeleteCascade(obj.id); } } _doDeleteCascade(id) { this._pushUndo(); this.eng.remove(id); this._selected = null; this.render(); if (this.onUpdate) this.onUpdate(this.getStats()); } /* Мягкое удаление: derived-точки становятся свободными, остальное каскадируется */ _doDeleteSoft(id) { this._pushUndo(); for (const dep of this.eng.getDependents(id)) { if (dep.type === 'point' && dep.derived) { // Фиксируем текущие координаты и делаем точку свободной dep.derived = null; dep.constr = null; dep.srcLine = dep.srcPt = dep.srcA = dep.srcB = dep.srcCenter = dep.srcVertex = dep.srcDirPt1 = dep.srcDirPt2 = dep.ptA = dep.ptB = dep.ptC = undefined; } } this.eng.remove(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 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').filter(s=>!s.virtual).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 derivedCircles = this.eng.byType('circle').filter(c => c.derived).length; const constructions = this.eng.byType('derived_line').length + derivedPts + derivedCircles; // Статистика для выбранного объекта 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') { let r = null; if (obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle')) { if (obj.valid) r = obj.r; } else { const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId); if (mc && me) r = gDist(mc, me); } if (r != null) 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, constructions, 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'); } } /* ─── lab UI init ─────────────────────────────────── */ 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'); _geoShowHint(name); if (window.LabFX) LabFX.sound.play('click', { pitch: 1.1 }); } const _GEO_PHASE_HINTS = { parallel_2: 'Теперь кликни на точку — через неё проведём прямую', perpendicular_2: 'Теперь кликни на точку — через неё проведём перпендикуляр', intersect_2: 'Теперь кликни на вторую прямую', foot_2: 'Теперь кликни на точку — найдём основание перпендикуляра', reflect_2: 'Теперь кликни на точку — получишь её симметричное отражение', tangent_2: 'Теперь кликни на внешнюю точку — получишь две касательные', translate_2: 'Теперь кликни конец вектора B', translate_3: 'Теперь кликни точку P — она будет перенесена', midline_2: 'Кликни вершину B (конец первой стороны)', midline_3: 'Кликни вершину C (конец второй стороны) — построим среднюю линию', parallelogram_2: 'Кликни вершину B (смежная с A)', parallelogram_3: 'Кликни вершину C — построим параллелограмм ABCD', scale_2: 'Кликни точку P — построим P\' = O + k·(P − O)', thales_2: 'Кликни точку A (на первом луче)', thales_3: 'Кликни точку B (на втором луче) — построим A\'B\' ∥ AB', measure_angle_2: 'Кликни вершину угла', measure_angle_3: 'Кликни вторую точку на стороне угла — измерение готово', locus_2: 'Кликни целевую точку, зависящую от мовера — построим ГМТ', point_on_segment_1: 'Кликни на отрезок — точка прикрепится к нему и будет по нему скользить', point_on_circle_1: 'Кликни на окружность — точка прикрепится к ней и будет по ней скользить', }; function _geoShowHint(name, phase) { const hint = document.getElementById('geo-hint'); if (!hint) return; if (phase && phase > 1) { hint.textContent = _GEO_PHASE_HINTS[`${name}_${phase}`] || _GEO_HINTS[name] || ''; } else { hint.textContent = _GEO_HINTS[name] || ''; } } function geoNgonN(delta) { if (!geomSim) return; geomSim.setNgonSides(geomSim._ngonSides + delta); const el = document.getElementById('geo-ngon-n'); if (el) el.textContent = geomSim._ngonSides; } function geoScaleK(delta) { if (!geomSim) return; const k = Math.round((geomSim._scaleK + delta) * 10) / 10; if (k < 0.1) return; geomSim.setScaleK(k); const el = document.getElementById('geo-scale-k'); if (el) el.textContent = k; } 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; const cEl = document.getElementById('geo-st-constr'); if (cEl) cEl.textContent = s.constructions || 0; } /* Диалог подтверждения удаления объекта с зависимыми */ let _geoDelSoftFn = null, _geoDelHardFn = null; function _geoShowDeleteConfirm(obj, deps, softFn, hardFn) { const panel = document.getElementById('geo-del-confirm'); const msg = document.getElementById('geo-del-msg'); if (!panel || !msg) { hardFn(); return; } const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч', circle:'окружность', polygon:'многоугольник', derived_line:'построение', measure_length:'измерение длины', measure_angle:'измерение угла', measure_area:'измерение площади', locus:'ГМТ' }; const n = names[obj.type] || 'объект'; msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`; _geoDelSoftFn = softFn; _geoDelHardFn = hardFn; panel.classList.add('visible'); } function _geoHideDeleteConfirm() { document.getElementById('geo-del-confirm')?.classList.remove('visible'); _geoDelSoftFn = _geoDelHardFn = null; } /* Показать inline-сообщение об ошибке ГМТ (временно заменяет hint-bar) */ function _geoShowLocusError(msg) { const hint = document.getElementById('geo-hint'); if (!hint) return; const prev = hint.textContent; hint.textContent = msg; hint.style.color = '#f87171'; if (window.LabFX) LabFX.sound.play('fizz', { pitch: 0.5, volume: 0.2 }); setTimeout(() => { hint.textContent = prev; hint.style.color = ''; }, 2800); } // Кнопки диалога — подключаем после DOM ready document.addEventListener('DOMContentLoaded', () => { document.getElementById('geo-del-soft')?.addEventListener('click', () => { _geoDelSoftFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats(); }); document.getElementById('geo-del-hard')?.addEventListener('click', () => { _geoDelHardFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats(); }); document.getElementById('geo-del-cancel')?.addEventListener('click', _geoHideDeleteConfirm); }); 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; geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase); geomSim.onDeleteRequest = _geoShowDeleteConfirm; geomSim.onLocusError = _geoShowLocusError; // 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]); }); // LabFX particle RAF loop if (!geomSim._fxRaf && window.LabFX) { let _fxLast = performance.now(); geomSim._fxFrames = 0; const _fxLoop = (now) => { const dt = (now - _fxLast) / 1000; _fxLast = now; LabFX.particles.update(dt); if (geomSim._fxFrames > 0) { geomSim._fxFrames--; geomSim.render(); } geomSim._fxRaf = requestAnimationFrame(_fxLoop); }; geomSim._fxRaf = requestAnimationFrame(_fxLoop); } })); } /* ── trig circle ── */ /* ══════════════════════════════════════════════════════════════════════ ЗАДАЧНИК — challenge framework ══════════════════════════════════════════════════════════════════════ */ /** * Helper: get two math-coordinate points on any line-like object. * Works for 'segment', 'line', 'ray', 'derived_line'. */ function _challTwoPts(eng, 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 p1 = eng.get(obj.p1Id), p2 = eng.get(obj.p2Id); if (!p1 || !p2) return null; return [{ x: p1.x, y: p1.y }, { x: p2.x, y: p2.y }]; } /** * Find all "line-like" objects (line, ray, segment, derived_line). */ function _challLines(eng) { return eng.all().filter(o => o.type === 'line' || o.type === 'ray' || o.type === 'segment' || o.type === 'derived_line' ); } /** * Normalise direction: always returns { dx, dy } with dy >= 0 * (or dx > 0 when dy == 0), for comparing line directions. */ function _challNormDir(dx, dy) { const len = Math.hypot(dx, dy); if (len < 1e-12) return { dx: 0, dy: 0 }; let nx = dx / len, ny = dy / len; if (ny < 0 || (Math.abs(ny) < 1e-9 && nx < 0)) { nx = -nx; ny = -ny; } return { dx: nx, dy: ny }; } const CHALLENGES = [ /* ── C1: Серединный перпендикуляр ──────────────────────────────── */ { id: 'C1', title: 'Серединный перпендикуляр к AB', desc: 'Постройте серединный перпендикуляр к отрезку AB. ' + 'Используйте инструмент «⊥ биссект.» или постройте вручную ' + '(прямую через середину AB, перпендикулярную AB).', hint: 'Воспользуйтесь инструментом «⊥ биссект.» — кликните точки A и B.', setup(eng) { eng.clear(); const A = eng.add({ type:'point', x:-2, y:0, label:'A' }); const B = eng.add({ type:'point', x: 2, y:0, label:'B' }); eng.add({ type:'segment', p1Id:A.id, p2Id:B.id }); }, check(eng) { // Find A and B by label const pts = eng.points(); const A = pts.find(p => p.label === 'A'); const B = pts.find(p => p.label === 'B'); if (!A || !B) return { passed: false, hint: 'Не найдены точки A и B.' }; const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }; const segDx = B.x - A.x, segDy = B.y - A.y; const segLen = Math.hypot(segDx, segDy); if (segLen < 1e-9) return { passed: false }; // Perpendicular direction to AB const perpDx = -segDy / segLen, perpDy = segDx / segLen; for (const obj of _challLines(eng)) { const pts2 = _challTwoPts(eng, obj); if (!pts2) continue; const [P1, P2] = pts2; const dx = P2.x - P1.x, dy = P2.y - P1.y; const len2 = Math.hypot(dx, dy); if (len2 < 1e-9) continue; // Check direction is perpendicular to AB (dot product with AB dir ≈ 0) const dot = (dx / len2) * segDx / segLen + (dy / len2) * segDy / segLen; if (Math.abs(dot) > 0.02) continue; // not perpendicular // Check passes through midpoint const distToMid = gDistToLine(mid, P1, P2); if (distToMid < 0.05) return { passed: true }; } return { passed: false, hint: 'Нужна прямая, проходящая через середину AB и перпендикулярная AB.' }; } }, /* ── C2: Биссектриса угла ───────────────────────────────────────── */ { id: 'C2', title: 'Биссектриса угла', desc: 'Постройте биссектрису угла с вершиной V. ' + 'Используйте инструмент «∠ биссект.» (три клика: A, вершина V, B).', hint: 'Инструмент «∠ биссект.»: кликните точку A, затем V (вершину), затем B.', setup(eng) { eng.clear(); const V = eng.add({ type:'point', x:0, y:0, label:'V' }); const A = eng.add({ type:'point', x:-3, y:0, label:'A' }); const B = eng.add({ type:'point', x:0, y:3, label:'B' }); eng.add({ type:'ray', p1Id:V.id, p2Id:A.id }); eng.add({ type:'ray', p1Id:V.id, p2Id:B.id }); }, check(eng) { const pts = eng.points(); const V = pts.find(p => p.label === 'V'); const A = pts.find(p => p.label === 'A'); const B = pts.find(p => p.label === 'B'); if (!V || !A || !B) return { passed: false, hint: 'Не найдены точки V, A, B.' }; // Expected bisector direction const va = gNorm({ x: A.x - V.x, y: A.y - V.y }); const vb = gNorm({ x: B.x - V.x, y: B.y - V.y }); const bisDir = gNorm({ x: va.x + vb.x, y: va.y + vb.y }); if (Math.hypot(bisDir.x, bisDir.y) < 1e-9) return { passed: false, hint: 'Угол вырожден.' }; // Half-angle for tolerance: ±0.5° const halfAngleDeg = gAngleDeg(A, V, B) / 2; const TOL_DEG = 0.5; for (const obj of _challLines(eng)) { const pts2 = _challTwoPts(eng, obj); if (!pts2) continue; const [P1, P2] = pts2; // Must pass through V const distV = gDistToLine({ x: V.x, y: V.y }, P1, P2); if (distV > 0.08) continue; // Direction must match bisector const dx = P2.x - P1.x, dy = P2.y - P1.y; const len = Math.hypot(dx, dy); if (len < 1e-9) continue; const crossAbs = Math.abs((dx / len) * bisDir.y - (dy / len) * bisDir.x); // sin(angle between lines) = crossAbs; for ±0.5° sin(0.5°) ≈ 0.0087 if (crossAbs < Math.sin(TOL_DEG * Math.PI / 180)) return { passed: true }; } return { passed: false, hint: 'Нужен луч/прямая из V, делящая угол AVB пополам.' }; } }, /* ── C3: Описанная окружность вокруг треугольника ───────────────── */ { id: 'C3', title: 'Описанная окружность треугольника', desc: 'Постройте окружность, проходящую через все три вершины треугольника ABC. ' + 'Используйте инструмент «Описанная» (circumcircle).', hint: 'Инструмент «Описанная»: кликните три вершины A, B, C — окружность строится автоматически.', setup(eng) { eng.clear(); const A = eng.add({ type:'point', x:-2, y:-1.5, label:'A' }); const B = eng.add({ type:'point', x: 2, y:-1.5, label:'B' }); const C = eng.add({ type:'point', x: 0, y: 2, label:'C' }); eng.add({ type:'polygon', pointIds:[A.id, B.id, C.id] }); }, check(eng) { const pts = eng.points(); const A = pts.find(p => p.label === 'A'); const B = pts.find(p => p.label === 'B'); const C = pts.find(p => p.label === 'C'); if (!A || !B || !C) return { passed: false, hint: 'Не найдены вершины A, B, C.' }; // Look for any circle passing through A, B, C within 1% for (const circ of eng.byType('circle')) { let cx, cy, r; if (circ.derived && circ.cx != null) { cx = circ.cx; cy = circ.cy; r = circ.r; } else { const ctr = eng.get(circ.centerId); const edg = eng.get(circ.edgeId); if (!ctr || !edg) continue; cx = ctr.x; cy = ctr.y; r = gDist({ x: cx, y: cy }, { x: edg.x, y: edg.y }); } if (r < 1e-9) continue; const O = { x: cx, y: cy }; const rA = gDist(O, A), rB = gDist(O, B), rC = gDist(O, C); const tol = r * 0.05; // 5% — generous for hand-built circumcircles if (Math.abs(rA - r) < tol && Math.abs(rB - r) < tol && Math.abs(rC - r) < tol) return { passed: true }; } return { passed: false, hint: 'Постройте окружность, равноудалённую от A, B и C.' }; } }, /* ── C4: ГМТ — множество точек, равноудалённых от A и B ────────── */ { id: 'C4', title: 'ГМТ: равноудалённые от A и B', desc: 'Постройте геометрическое место точек, равноудалённых от точек A и B. ' + 'Подсказка: это серединный перпендикуляр AB. ' + 'Используйте инструмент «ГМТ» (locus): ' + 'создайте скользящую точку на окружности или отрезке, ' + 'затем постройте из неё точку-цель, равноудалённую от A и B.', hint: 'Самый простой способ: серединный перпендикуляр к AB — это и есть ГМТ. ' + 'Используйте инструмент «⊥ биссект.» или locus.', setup(eng) { eng.clear(); const A = eng.add({ type:'point', x:-2, y:0, label:'A' }); const B = eng.add({ type:'point', x: 2, y:0, label:'B' }); }, check(eng) { // Accept: any locus object OR any line that is the perpendicular bisector of AB const pts = eng.points(); const A = pts.find(p => p.label === 'A'); const B = pts.find(p => p.label === 'B'); if (!A || !B) return { passed: false }; // Accept a locus object (heuristic: it exists) if (eng.byType('locus').length > 0) return { passed: true }; // Accept a perpendicular bisector line/derived_line through midpoint const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }; const segLen = gDist(A, B); if (segLen < 1e-9) return { passed: false }; const segDx = (B.x - A.x) / segLen, segDy = (B.y - A.y) / segLen; for (const obj of _challLines(eng)) { const pts2 = _challTwoPts(eng, obj); if (!pts2) continue; const [P1, P2] = pts2; const dx = P2.x - P1.x, dy = P2.y - P1.y; const len = Math.hypot(dx, dy); if (len < 1e-9) continue; const dot = Math.abs((dx / len) * segDx + (dy / len) * segDy); if (dot > 0.02) continue; // not perpendicular to AB if (gDistToLine(mid, P1, P2) < 0.08) return { passed: true }; } return { passed: false, hint: 'Постройте серединный перпендикуляр к AB или используйте инструмент ГМТ.' }; } }, /* ── C5: Касательная к окружности ──────────────────────────────── */ { id: 'C5', title: 'Касательная к окружности', desc: 'Постройте касательную к окружности из внешней точки P. ' + 'Используйте инструмент «Касательные» (tangent): кликните на окружность, ' + 'затем на внешнюю точку P.', hint: 'Инструмент «Касательные»: сначала кликните на окружность, потом на точку P.', setup(eng) { eng.clear(); const center = eng.add({ type:'point', x: 0, y: 0, label:'O' }); const edge = eng.add({ type:'point', x: 2, y: 0, label:'R' }); eng.add({ type:'circle', centerId: center.id, edgeId: edge.id }); eng.add({ type:'point', x: 5, y: 0, label:'P' }); }, check(eng) { // Find the setup circle and P const pts = eng.points(); const O = pts.find(p => p.label === 'O'); const P = pts.find(p => p.label === 'P'); if (!O || !P) return { passed: false }; // Find circle with center O let circR = null; for (const circ of eng.byType('circle')) { const ctr = eng.get(circ.centerId); if (ctr && Math.abs(ctr.x - O.x) < 0.01 && Math.abs(ctr.y - O.y) < 0.01) { const edg = eng.get(circ.edgeId); if (edg) { circR = gDist({ x: O.x, y: O.y }, { x: edg.x, y: edg.y }); break; } } } if (!circR) return { passed: false, hint: 'Исходная окружность не найдена.' }; const Opt = { x: O.x, y: O.y }; const TOL = circR * 0.05; // 5% of radius // Look for any line/ray through P where distance from O to line ≈ radius for (const obj of _challLines(eng)) { const pts2 = _challTwoPts(eng, obj); if (!pts2) continue; const [P1, P2] = pts2; // Must pass through P (within tolerance) const distP = gDistToLine({ x: P.x, y: P.y }, P1, P2); if (distP > 0.15) continue; // Distance from center O to the line ≈ radius const distO = gDistToLine(Opt, P1, P2); if (Math.abs(distO - circR) < TOL) return { passed: true }; } return { passed: false, hint: 'Постройте прямую через P, касающуюся окружности с центром O.' }; } }, ]; /* ── Challenge state ─────────────────────────────────────────────── */ let _challState = CHALLENGES.map(() => 'locked'); _challState[0] = 'current'; // first challenge is unlocked let _challAttempts = CHALLENGES.map(() => 0); // fail attempt counter let _challPanelOpen = false; function geoToggleChallengePanel() { _challPanelOpen = !_challPanelOpen; const panel = document.getElementById('geo-challenge-panel'); if (panel) panel.classList.toggle('open', _challPanelOpen); if (_challPanelOpen) _geoChallRenderList(); } function _geoChallRenderList() { const list = document.getElementById('geo-chall-list'); if (!list) return; list.innerHTML = ''; const doneCount = _challState.filter(s => s === 'done').length; const countEl = document.getElementById('geo-chall-count'); if (countEl) countEl.textContent = doneCount + '/' + CHALLENGES.length; CHALLENGES.forEach((ch, idx) => { const state = _challState[idx]; const item = document.createElement('div'); item.className = 'geo-chall-item chall-' + state; item.dataset.idx = idx; // Status icon let statusContent = (idx + 1).toString(); if (state === 'done') { statusContent = ''; } else if (state === 'locked') { statusContent = ''; } item.innerHTML = `
${statusContent}
${ch.title}
${ch.desc}
${ch.hint}
`; // Allow expanding done items too item.querySelector('.geo-chall-head').addEventListener('click', () => { item.classList.toggle('geo-chall-expanded'); }); list.appendChild(item); }); } function geoChallSetup(idx) { if (!geomSim) return; const ch = CHALLENGES[idx]; if (!ch) return; ch.setup(geomSim.eng); // Recompute all derived objects after setup for (const obj of geomSim.eng.all()) { if (obj.derived) geomSim.eng.recompute(obj.id); } geomSim.fit(); geomSim.render(); _geoUpdateStats(); const fb = document.getElementById('geo-chall-fb-' + idx); if (fb) { fb.textContent = ''; fb.className = 'geo-chall-feedback'; } } function geoChallCheck(idx) { if (!geomSim) return; const ch = CHALLENGES[idx]; if (!ch || _challState[idx] === 'locked') return; const result = ch.check(geomSim.eng); const fb = document.getElementById('geo-chall-fb-' + idx); if (result.passed) { _challState[idx] = 'done'; // Unlock next if (idx + 1 < CHALLENGES.length && _challState[idx + 1] === 'locked') { _challState[idx + 1] = 'current'; } if (fb) { fb.textContent = 'Верно!'; fb.className = 'geo-chall-feedback ok'; } _geoChallSuccessBurst(); _geoChallRenderList(); } else { _challAttempts[idx]++; const msg = result.hint ? result.hint : 'Не совсем. Попробуй ещё раз.'; if (fb) { fb.textContent = msg; fb.className = 'geo-chall-feedback err'; } // Show hint after 2 fails if (_challAttempts[idx] >= 2) { const hintEl = document.getElementById('geo-chall-hint-' + idx); if (hintEl) hintEl.classList.add('visible'); } } } function _geoChallSuccessBurst() { const outer = document.querySelector('.geo-canvas-outer'); if (!outer) return; // "Молодец!" label const label = document.createElement('div'); label.className = 'geo-chall-success-label'; label.textContent = 'Молодец!'; outer.appendChild(label); setTimeout(() => label.remove(), 2400); if (!geomSim) return; if (window.LabFX) { // Migrate to LabFX particles const ctx = geomSim.ctx; const W = geomSim.vp.W, H = geomSim.vp.H; const confettiColors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6']; confettiColors.forEach(color => { LabFX.particles.emit({ ctx, x: W / 2, y: H / 2, count: 10, color, shape: 'spark', spread: Math.PI * 2, life: 1600, speed: 180, gravity: 200, glow: true }); }); LabFX.sound.play('chime'); LabFX.haptic([15, 30, 15, 30, 15]); if (geomSim._fxFrames !== undefined) geomSim._fxFrames = 120; } else { // Fallback: original confetti const canvas = geomSim.canvas; const ctx = geomSim.ctx; const W = canvas.width, H = canvas.height; const particles = []; const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6']; for (let i = 0; i < 60; i++) { particles.push({ x: W / 2 + (Math.random() - 0.5) * W * 0.4, y: H / 2 + (Math.random() - 0.5) * H * 0.3, vx: (Math.random() - 0.5) * 5, vy: (Math.random() - 0.6) * 6, r: 3 + Math.random() * 4, color: colors[Math.floor(Math.random() * colors.length)], alpha: 1, rot: Math.random() * Math.PI * 2, rotV: (Math.random() - 0.5) * 0.3, }); } let frame = 0; const maxFrames = 60; function burst() { if (frame >= maxFrames) { geomSim.render(); return; } geomSim.render(); for (const p of particles) { ctx.save(); ctx.globalAlpha = p.alpha; ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color; ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6); ctx.restore(); p.x += p.vx; p.y += p.vy; p.vy += 0.18; p.alpha -= 1 / maxFrames; p.rot += p.rotV; } frame++; requestAnimationFrame(burst); } requestAnimationFrame(burst); } }