feat: планиметрия фазы 8.3–10.2 — метки параллельности, средняя линия, параллелограмм, диагонали, подобие; geometry в онлайн-уроке
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:'Геометрия треугольника' },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -3916,6 +3916,36 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="gp-section-title" style="margin-top:4px">Средняя линия и четырёхугольники</div>
|
||||
<div class="geo-tool-grid">
|
||||
<button id="geo-btn-midline" class="geo-tool-btn" onclick="geoSetTool('midline',this)" title="Средняя линия треугольника — 3 вершины A, B, C">
|
||||
<svg viewBox="0 0 24 24" fill="none"><polygon points="12,3 3,20 21,20" stroke-width="1.5" fill="none"/><line x1="7.5" y1="11.5" x2="16.5" y2="11.5" stroke-width="2"/><circle cx="7.5" cy="11.5" r="2" fill="currentColor"/><circle cx="16.5" cy="11.5" r="2" fill="currentColor"/></svg>
|
||||
Средняя линия
|
||||
</button>
|
||||
<button id="geo-btn-parallelogram" class="geo-tool-btn" onclick="geoSetTool('parallelogram',this)" title="Параллелограмм — 3 точки A, B, C → вычисляет D">
|
||||
<svg viewBox="0 0 24 24" fill="none"><polygon points="6,19 3,5 18,5 21,19" stroke-width="1.5" fill="none"/></svg>
|
||||
Параллелограмм
|
||||
</button>
|
||||
<button id="geo-btn-diagonal" class="geo-tool-btn" onclick="geoSetTool('diagonal',this)" title="Диагонали — клик внутри многоугольника (4+ вершин)">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" stroke-width="1.5" fill="none"/><line x1="3" y1="3" x2="21" y2="21" stroke-width="1.5" stroke-dasharray="4,2"/><line x1="21" y1="3" x2="3" y2="21" stroke-width="1.5" stroke-dasharray="4,2"/></svg>
|
||||
Диагонали
|
||||
</button>
|
||||
<button id="geo-btn-scale" class="geo-tool-btn" onclick="geoSetTool('scale',this)" title="Подобие — клик центр O, затем клик точку P → P' = O + k·(P − O)">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="2" fill="currentColor"/><line x1="12" y1="12" x2="20" y2="6" stroke-width="1.5"/><circle cx="20" cy="6" r="2.5" stroke-width="1.5"/><line x1="12" y1="12" x2="17" y2="18" stroke-width="1.5" stroke-dasharray="3,2"/><circle cx="17" cy="18" r="2.5" stroke-width="1.5" stroke-dasharray="3,2"/></svg>
|
||||
Подобие
|
||||
</button>
|
||||
</div>
|
||||
<div class="geo-ngon-ctrl" id="geo-scale-ctrl" style="gap:6px;padding:2px 0 4px">
|
||||
<span style="font-size:11px;opacity:.7">k =</span>
|
||||
<button class="geo-ngon-btn" onclick="geoScaleK(-0.5)">
|
||||
<svg viewBox="0 0 16 16" fill="none"><line x1="3" y1="8" x2="13" y2="8" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<span id="geo-scale-k">2</span>
|
||||
<button class="geo-ngon-btn" onclick="geoScaleK(+0.5)">
|
||||
<svg viewBox="0 0 16 16" fill="none"><line x1="8" y1="3" x2="8" y2="13" stroke-width="2" stroke-linecap="round"/><line x1="3" y1="8" x2="13" y2="8" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="gp-section-title" style="margin-top:4px">Правильный многоугольник</div>
|
||||
<div class="geo-tool-grid">
|
||||
<button id="geo-btn-ngon" class="geo-tool-btn" onclick="geoSetTool('ngon',this)" title="Правильный n-угольник — клик центр, клик вершина">
|
||||
@@ -3944,6 +3974,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M4 20 L20 20 L20 4" stroke-width="1.5" fill="none"/><path d="M8 20 A12 12 0 0 1 20 8" stroke-width="1.5" fill="none"/><path d="M11 20 A9 9 0 0 1 20 11" stroke-width="1.5" fill="none"/></svg>
|
||||
Дуги
|
||||
</button>
|
||||
<button id="geo-btn-parallelmark" class="geo-tool-btn" onclick="geoSetTool('parallelmark',this)" title="Метки параллельных сторон — клик на отрезок (1–2 стрелки)">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="3" y1="8" x2="21" y2="8" stroke-width="1.5"/><polyline points="9,5 13,8 9,11" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/><line x1="3" y1="16" x2="21" y2="16" stroke-width="1.5"/><polyline points="9,13 13,16 9,19" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Параллельность
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Display options -->
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user