Files
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

3750 lines
154 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═══════════════════════════════════════════════════════════════════════
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) =>
(x<xmin?1:x>xmax?2:0) | (y<ymin?4:y>ymax?8:0);
let c0=code(x0,y0), c1=code(x1,y1);
while (true) {
if (!(c0|c1)) return [{x:x0,y:y0},{x:x1,y:y1}];
if (c0&c1) return null;
const c = c0||c1;
let x,y;
if (c&8) { x=x0+(x1-x0)*(ymax-y0)/(y1-y0); y=ymax; }
else if (c&4) { x=x0+(x1-x0)*(ymin-y0)/(y1-y0); y=ymin; }
else if (c&2) { y=y0+(y1-y0)*(xmax-x0)/(x1-x0); x=xmax; }
else { y=y0+(y1-y0)*(xmin-x0)/(x1-x0); x=xmin; }
if (c===c0) { x0=x;y0=y;c0=code(x,y); }
else { x1=x;y1=y;c1=code(x,y); }
}
}
/* ══ SNAP ════════════════════════════════════════════════════ */
_computeSnap(mx, my) {
const SNAP_DIST_PX = 14; // радиус снапа в пикселях
const snapDist = this.vp.toMathDist(SNAP_DIST_PX);
this._snapPt = null;
this._snapId = null;
// 1. Снап к существующим точкам
let best = Infinity, bestId = null;
for (const pt of this.eng.points()) {
const d = Math.hypot(pt.x - mx, pt.y - my);
if (d < snapDist && d < best) { best = d; bestId = pt.id; }
}
if (bestId) {
const pt = this.eng.get(bestId);
this._snapPt = { x: pt.x, y: pt.y };
this._snapId = bestId;
return { x: pt.x, y: pt.y };
}
// 2. Снап к сетке
if (this.showGrid) {
const step = this._gridStep();
const sx = Math.round(mx / step) * step;
const sy = Math.round(my / step) * step;
const dpx = this.vp.toCanvasDist(Math.hypot(sx-mx, sy-my));
if (dpx < SNAP_DIST_PX * 0.7) {
this._snapPt = { x: sx, y: sy };
return { x: sx, y: sy };
}
}
return { x: mx, y: my };
}
_gridStep() {
const rawStep = 80 / this.vp.scale;
const exp = Math.floor(Math.log10(rawStep));
const frac = rawStep / Math.pow(10, exp);
let step = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
return step * Math.pow(10, exp);
}
/* ══ СОБЫТИЯ ══════════════════════════════════════════════════ */
_bindEvents() {
const c = this.canvas;
c.addEventListener('pointerdown', e => this._onDown(e));
c.addEventListener('pointermove', e => this._onMove(e));
c.addEventListener('pointerup', e => this._onUp(e));
c.addEventListener('pointerleave', e => this._onLeave(e));
c.addEventListener('wheel', e => this._onWheel(e), { passive: false });
c.addEventListener('contextmenu', e => e.preventDefault());
}
_evPos(e) {
const r = this.canvas.getBoundingClientRect();
return { px: e.clientX - r.left, py: e.clientY - r.top };
}
_onDown(e) {
e.preventDefault();
if (this.readOnly) return;
const { px, py } = this._evPos(e);
// ПКМ → отмена текущего построения
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 = '<svg viewBox="0 0 24 24" fill="none" stroke="#4ADE80" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><polyline points="20 6 9 17 4 12"/></svg>';
} else if (state === 'locked') {
statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:10px;height:10px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
}
item.innerHTML = `
<div class="geo-chall-head">
<div class="geo-chall-status">${statusContent}</div>
<div class="geo-chall-name">${ch.title}</div>
</div>
<div class="geo-chall-body">
<div class="geo-chall-desc">${ch.desc}</div>
<div class="geo-chall-actions">
<button class="geo-chall-btn geo-chall-btn-check" onclick="geoChallCheck(${idx})">Проверить</button>
<button class="geo-chall-btn geo-chall-btn-reset" onclick="geoChallSetup(${idx})" title="Начать заново">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.95"/></svg>
</button>
</div>
<div class="geo-chall-hint${_challAttempts[idx] >= 2 ? ' visible' : ''}" id="geo-chall-hint-${idx}">${ch.hint}</div>
<div class="geo-chall-feedback" id="geo-chall-fb-${idx}"></div>
</div>`;
// 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);
}
}