diff --git a/frontend/classroom.html b/frontend/classroom.html index f83e449..8be4c58 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -6733,6 +6733,7 @@ // Compact sims catalogue (id + title + category) mirrored from lab.html const CR_SIMS = [ + { id:'geometry', cat:'math', title:'Планиметрия' }, { id:'graph', cat:'math', title:'График функции' }, { id:'graphtransform',cat:'math', title:'Трансформации графиков' }, { id:'triangle', cat:'math', title:'Геометрия треугольника' }, diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index 0272fff..0890bb4 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -206,7 +206,9 @@ class GeoEngine { 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 === '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 — обычная прямая, зависим и от её точек @@ -309,6 +311,18 @@ class GeoEngine { 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.type === 'circle' && obj.derived) { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); @@ -455,6 +469,8 @@ class GeoSim { this._preview = null; // предпросмотр (курсор при рисовании) this._pendingLineRef = null; // первый кликнутый объект для parallel/perp/intersect/reflect/foot this._pendingCircRef = null; // первый кликнутый объект-окружность для tangent + this._pendingScaleO = null; // центр подобия для инструмента scale + this._scaleK = 2; // коэффициент подобия /* ── Состояние drag/pan ── */ this._drag = null; // { id, offX, offY } — перетаскиваем точку @@ -495,6 +511,10 @@ class GeoSim { this._ngonSides = Math.max(3, Math.min(20, n)); } + setScaleK(k) { + this._scaleK = +k || 2; + } + /* ── Инициализация ─────────────────────────────────────────── */ fit() { const c = this.canvas; @@ -512,6 +532,7 @@ class GeoSim { this._selected = null; this._pendingLineRef = null; this._pendingCircRef = null; + this._pendingScaleO = null; this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair'; this.render(); } @@ -762,7 +783,8 @@ 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 (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); } @@ -789,6 +811,31 @@ class GeoSim { 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; @@ -977,12 +1024,11 @@ 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); - } + // Метки равных сторон (штрихи) и параллельных сторон (шевроны) + 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); } } @@ -2096,6 +2142,147 @@ class GeoSim { } 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.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; + } } this.render(); } diff --git a/frontend/lab.html b/frontend/lab.html index 13b2412..972bd05 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3916,6 +3916,36 @@ +
Средняя линия и четырёхугольники
+
+ + + + +
+
+ k = + + 2 + +
+
Правильный многоугольник
+
@@ -5446,10 +5480,15 @@ translate: 'Кликни начало вектора A', tick: 'Кликни на отрезок или сторону — добавить штрих (1–3; ещё раз — убрать)', arcmark: 'Кликни на вершину полигона — добавить дугу (1–3; ещё раз — убрать)', + parallelmark: 'Кликни на отрезок или сторону — добавить метку параллельности (1–2; ещё раз — убрать)', altitude: 'Кликни на сторону треугольника (или прямую)', median: 'Кликни вершину A треугольника', centroid: 'Кликни первую вершину треугольника', orthocenter: 'Кликни первую вершину треугольника', + midline: 'Кликни вершину A треугольника', + parallelogram:'Кликни вершину A параллелограмма', + diagonal: 'Кликни внутри четырёхугольника — построим диагонали', + scale: 'Кликни центр подобия O', }; function geoSetTool(name, btnEl) { @@ -5476,6 +5515,11 @@ 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)', }; function _geoShowHint(name, phase) { @@ -5495,6 +5539,15 @@ 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];