diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index 0890bb4..54dab81 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -580,7 +580,7 @@ class GeoSim { for (const obj of this.eng.byType('circle')) this._drawCircle(ctx, obj); // Измерения if (this.showLengths) this._drawLengths(ctx); - if (this.showAngles) this._drawAngleMeasures(ctx); + this._drawAngleMeasures(ctx); // всегда — для arcmark и прямых углов; showAngles управляет авто-подписями // Точки поверх всего (включая производные) for (const obj of this.eng.points()) this._drawPoint(ctx, obj); // Предпросмотр строящегося объекта @@ -989,6 +989,36 @@ class GeoSim { 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')) { @@ -1143,45 +1173,46 @@ class GeoSim { ctx.strokeStyle = col; ctx.lineWidth = 1.5; - const explicitMark = poly.angleMarks?.[i] || 0; // 0=auto, 1-3=явный + const explicitMark = poly.angleMarks?.[i] || 0; // 0=авто, 1-3=явный if (explicitMark > 0 && bisLen > 1e-6) { - // Явные метки: 1-3 концентрические дуги + // Явные метки: всегда рисуем 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; + 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 * 7, midAngle - halfSpread, midAngle + halfSpread, false); + ctx.arc(Bpx.x, Bpx.y, ARC_R + k * 8, midAngle - halfSpread, midAngle + halfSpread, false); ctx.stroke(); } - } else 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) { - // Дуга угла — от midAngle-halfSpread до midAngle+halfSpread через биссектрису - 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 = (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; - 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); + } 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(); @@ -2009,135 +2040,96 @@ class GeoSim { /* ══ 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 { + // 1 клик на вершину треугольника → высота из этой вершины на противоположную сторону + const hitAlt = this._findVertexOfPoly(px, py, 3); + if (hitAlt) { 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; + 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': { - // 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) { + // 1 клик на вершину треугольника → медиана из этой вершины к середине противоположной стороны + const hitMed = this._findVertexOfPoly(px, py, 3); + if (hitMed) { this._pushUndo(); - const ptA = this._ensurePoint(this._pending[0]); - const ptB = this._ensurePoint(this._pending[1]); - const ptC = this._ensurePoint(this._pending[2]); + 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: 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; + 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': { - // 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) { + // 1 клик на/внутри треугольника → 3 медианы + центроид G + const polyCen = this._findTriangleNear(px, py); + if (polyCen) { 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 [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'; - // 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} }); - // Центроид + 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:pA.id, ptB:pB.id, ptC:pC.id, + 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} }); - 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) { + // 1 клик на/внутри треугольника → 3 высоты + ортоцентр H + const polyOrt = this._findTriangleNear(px, py); + if (polyOrt) { 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 [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: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 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:pA.id, ptB:pB.id, ptC:pC.id, + 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} }); } - this._pending = []; this._preview = null; if (this.onUpdate) this.onUpdate(this.getStats()); } break; @@ -2257,6 +2249,49 @@ class GeoSim { 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': { diff --git a/frontend/lab.html b/frontend/lab.html index 972bd05..b68256e 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3963,6 +3963,15 @@ + +
Теорема Фалеса
+
+ +
+
Метки
@@ -5481,10 +5490,11 @@ tick: 'Кликни на отрезок или сторону — добавить штрих (1–3; ещё раз — убрать)', arcmark: 'Кликни на вершину полигона — добавить дугу (1–3; ещё раз — убрать)', parallelmark: 'Кликни на отрезок или сторону — добавить метку параллельности (1–2; ещё раз — убрать)', - altitude: 'Кликни на сторону треугольника (или прямую)', - median: 'Кликни вершину A треугольника', - centroid: 'Кликни первую вершину треугольника', - orthocenter: 'Кликни первую вершину треугольника', + altitude: 'Кликни на вершину треугольника — построим высоту из неё', + median: 'Кликни на вершину треугольника — построим медиану из неё', + centroid: 'Кликни на треугольник или внутри него — построим все 3 медианы и центроид G', + orthocenter: 'Кликни на треугольник или внутри него — построим все 3 высоты и ортоцентр H', + thales: 'Кликни центр подобия O (начало лучей)', midline: 'Кликни вершину A треугольника', parallelogram:'Кликни вершину A параллелограмма', diagonal: 'Кликни внутри четырёхугольника — построим диагонали', @@ -5508,18 +5518,13 @@ tangent_2: 'Теперь кликни на внешнюю точку — получишь две касательные', translate_2: 'Теперь кликни конец вектора B', translate_3: 'Теперь кликни точку P — она будет перенесена', - altitude_2: 'Теперь кликни вершину — опустим из неё высоту', - median_2: 'Теперь кликни вершину B (один конец основания)', - median_3: 'Теперь кликни вершину C (второй конец основания)', - centroid_2: 'Кликни вершину B', - centroid_3: 'Кликни вершину C — построим центроид', - orthocenter_2: 'Кликни вершину B', - orthocenter_3: 'Кликни вершину C — построим ортоцентр', 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', }; function _geoShowHint(name, phase) {