diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index fe10e6a..0272fff 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -92,6 +92,13 @@ function gAngleDeg(A, B, C) { 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; @@ -195,8 +202,11 @@ class GeoEngine { 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 === '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 === 'foot' || obj.constr === 'reflect') { if (obj.srcPt === id || obj.srcLine === id) return true; // Если srcLine — обычная прямая, зависим и от её точек @@ -280,6 +290,25 @@ class GeoEngine { 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.type === 'circle' && obj.derived) { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); @@ -733,9 +762,33 @@ class GeoSim { 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 (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(); + } + _drawLine(ctx, obj) { const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id); if (!m1 || !m2) return; @@ -924,6 +977,13 @@ class GeoSim { for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); ctx.closePath(); ctx.stroke(); ctx.restore(); + // Метки равных сторон + if (obj.sideMarks) { + for (let i = 0; i < pts.length; i++) { + const mark = obj.sideMarks[i] || 0; + if (mark > 0) this._drawTickMark(ctx, pts[i], pts[(i+1)%pts.length], mark, col); + } + } } _drawCircle(ctx, obj) { @@ -1037,7 +1097,18 @@ class GeoSim { ctx.strokeStyle = col; ctx.lineWidth = 1.5; - if (Math.abs(angle - 90) < 2) { + const explicitMark = poly.angleMarks?.[i] || 0; // 0=auto, 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.7; + for (let k = 0; k < explicitMark; k++) { + ctx.beginPath(); + ctx.arc(Bpx.x, Bpx.y, ARC_R + k * 7, midAngle - halfSpread, midAngle + halfSpread, false); + ctx.stroke(); + } + } else if (Math.abs(angle - 90) < 2) { // Прямой угол — маленький квадрат ctx.globalAlpha = 0.8; ctx.beginPath(); @@ -1057,7 +1128,7 @@ class GeoSim { // Подпись угла вдоль биссектрисы if (bisLen > 1e-6) { - const ldist = (Math.abs(angle - 90) < 2 ? SQ_SZ : ARC_R) + LBL_D; + const ldist = (explicitMark > 0 ? ARC_R + (explicitMark-1)*7 : 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; @@ -1071,21 +1142,26 @@ class GeoSim { } } - // Прямые углы для foot-конструкций (основание высоты всегда 90°) + // Прямые углы для foot и altitude_foot конструкций for (const obj of this.eng.points()) { - if (!obj.derived || obj.constr !== 'foot') continue; + if (!obj.derived || (obj.constr !== 'foot' && obj.constr !== 'altitude_foot')) continue; const F = this.vp.toCanvas(obj.x, obj.y); - const P = this.eng.get(obj.srcPt); - const sl = this.eng.get(obj.srcLine); - if (!P || !sl) continue; - let L1m, L2m; - if (sl.type === 'derived_line') { - L1m = { x: sl.ptX, y: sl.ptY }; - L2m = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY }; + 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 { - L1m = this._mpt(sl.p1Id); L2m = this._mpt(sl.p2Id); + 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 (!L1m || !L2m) continue; + 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); @@ -1838,6 +1914,188 @@ class GeoSim { } 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: выбрать прямую (сторону), Шаг 2: выбрать вершину + if (!this._pendingLineRef) { + const hit = this._hitTestLine(px, py); + if (hit) { + this._pendingLineRef = hit; + if (this.onHintChange) this.onHintChange('altitude', 2); + } + } else { + this._pushUndo(); + const vertex = 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: vertex.x, y: vertex.y }, L1, L2); + const nFoot = this.eng.byType('point').filter(p=>p.constr==='foot').length; + const foot = this.eng.add({ + type: 'point', derived: true, constr: 'foot', + srcLine: sl.id, srcPt: vertex.id, + x: f.x, y: f.y, + label: 'H' + (nFoot ? String.fromCharCode(0x2081 + nFoot) : '\u2081'), + style: { color: '#4ADE80', size: 4 } + }); + // Отрезок-высота: вершина → основание + this.eng.add({ type: 'segment', p1Id: vertex.id, p2Id: foot.id, + style: { color: '#4ADE80', width: 1.5 } }); + } + this._pendingLineRef = null; this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } + + case 'median': { + // 3 клика: вершина A, затем B и C (концы стороны) → середина M + отрезок AM + this._pending.push(snapped); + if (this._pending.length === 1) { + if (this.onHintChange) this.onHintChange('median', 2); + } else if (this._pending.length === 2) { + if (this.onHintChange) this.onHintChange('median', 3); + } else if (this._pending.length === 3) { + this._pushUndo(); + const ptA = this._ensurePoint(this._pending[0]); + const ptB = this._ensurePoint(this._pending[1]); + const ptC = this._ensurePoint(this._pending[2]); + const nMid = this.eng.byType('point').filter(p=>p.constr==='midpoint').length; + const mid = this.eng.add({ + type: 'point', derived: true, constr: 'midpoint', + srcA: ptB.id, srcB: ptC.id, + 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: ptA.id, p2Id: mid.id, + style: { color: '#22d55e', width: 1.5 } }); + this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } + + case 'centroid': { + // 3 клика: A, B, C → 3 медианы + точка центроид G + this._pending.push(snapped); + if (this._pending.length === 1) { + if (this.onHintChange) this.onHintChange('centroid', 2); + } else if (this._pending.length === 2) { + if (this.onHintChange) this.onHintChange('centroid', 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 nG = this.eng.byType('point').filter(p=>p.constr==='centroid').length; + const col = '#A78BFA'; + // 3 середины сторон + const mBC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:pB.id, srcB:pC.id, 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:pA.id, srcB:pC.id, 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:pA.id, srcB:pB.id, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2, label:'M₃', style:{color:col,size:3} }); + // 3 медианы + this.eng.add({ type:'segment', p1Id:pA.id, p2Id:mBC.id, style:{color:col, width:1.5} }); + this.eng.add({ type:'segment', p1Id:pB.id, p2Id:mAC.id, style:{color:col, width:1.5} }); + this.eng.add({ type:'segment', p1Id:pC.id, p2Id:mAB.id, style:{color:col, width:1.5} }); + // Центроид + this.eng.add({ type:'point', derived:true, constr:'centroid', + ptA:pA.id, ptB:pB.id, ptC:pC.id, + 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} }); + this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } + + case 'orthocenter': { + // 3 клика: A, B, C → 3 высоты + точка ортоцентра H + this._pending.push(snapped); + if (this._pending.length === 1) { + if (this.onHintChange) this.onHintChange('orthocenter', 2); + } else if (this._pending.length === 2) { + if (this.onHintChange) this.onHintChange('orthocenter', 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 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:pA.id, ptB:pB.id, ptC:pC.id, ...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:pB.id, ptB:pA.id, ptC:pC.id, ...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:pC.id, ptB:pA.id, ptC:pB.id, ...gFoot(C,A,B), label:'H_c', style:{color:col,size:3} }); + // Отрезки-высоты + this.eng.add({ type:'segment', p1Id:pA.id, p2Id:Ha.id, style:{color:col, width:1.5} }); + this.eng.add({ type:'segment', p1Id:pB.id, p2Id:Hb.id, style:{color:col, width:1.5} }); + this.eng.add({ type:'segment', p1Id:pC.id, 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:pA.id, ptB:pB.id, ptC:pC.id, + x:orth.x, y:orth.y, valid:true, + label: nH ? 'H'+(nH+1) : 'H', style:{color:col, size:6} }); + } + this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } } this.render(); } diff --git a/frontend/lab.html b/frontend/lab.html index 2d39cc4..13b2412 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3896,6 +3896,26 @@ +
Элементы треугольника
+
+ + + + +
+
Правильный многоугольник
+ +
Метки
+
+ + +
+
Параметры