Files
Learn_System/frontend/js/labs/triangle.js
T
Maxim Dolgolyov 7f75c96acd feat(labs): planimetry locus + emfield merger + projectile graphs + UI cleanup
Геометрия (планиметрия):
- Живые измерения как объекты: длина / угол / площадь — auto-recompute, draggable chips
- Инструмент ГМТ: sweep мовера через параметр, рисует кривую места точек
- Новые типы точек: on_segment (скользит по отрезку, _t), on_circle (по окружности, _theta)
- Toolbar: «Длина», «Угол», «Площадь», «ГМТ», «На отрезке», «На окружности»

Электромагнитные поля (emfield):
- Merge magnetic.js + coulomb.js в один EMFieldSim с 3 режимами (E / B / комбинированное)
- Унифицированный pipeline: colormap, field lines, vectors, equipotentials, flux loop, test particle
- Combined-режим: полная сила Лоренца F=q(E+v×B)
- Backward compat: #coulomb и #magnetic хеши и ?sim= параметры редиректят в emfield
- Удалены: magnetic.js, coulomb.js. Добавлен: emfield.js

Бросок тела (projectile):
- Режим целей: 3 окна, hit-детекция, HUD «Цели: N/M / Попыток: K»
- Графики x(t), y(t), vx(t), vy(t) — 2×2 Canvas 2D, real-time
- Двойной бросок: одновременно 2 траектории для сравнения (cyan vs gold)

UI fixes (по результатам аудита):
- Заменены emoji/unicode на inline SVG .ic: switch ⌇, spring 〜 (5 мест), download ⬇ (2), camera 📷
- Убраны декоративные символы ☉ ○ из geometry tool labels
- Добавлены THEORY entries: geometry, hydrostatics (раньше показывали fallback)
- Стандартизирована ширина panel для sim-proj и sim-coll (240px)
- waves перенесён в физический блок SIMS catalog (был после биологии)
- Очищен дефолтный sim-topbar-title (был «График функции»)

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

1058 lines
39 KiB
JavaScript
Raw 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.
'use strict';
/* ══════════════════════════════════════════════════════
TriangleSim — interactive triangle geometry simulation
Draggable vertices A / B / C, toggleable layers:
medians, altitudes, bisectors, circumcircle, incircle
══════════════════════════════════════════════════════ */
class TriangleSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.pts = null; // [{x,y}, {x,y}, {x,y}]
this._dragging = null;
this._hovered = null;
// visible layers
this.layers = {
medians : false,
altitudes : false,
bisectors : false,
circumcircle: false,
incircle : false,
eulerLine : false,
sineLaw : false,
cosineLaw : false,
pythagorean : false,
grid : true,
};
this.onUpdate = null; // cb(stats)
this._bindEvents();
}
/* ── sizing ── */
fit() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = rect.width;
this.H = rect.height;
if (!this.pts) this._initPts();
else this._clampPts();
this.draw();
}
_initPts() {
const cx = this.W / 2, cy = this.H / 2;
const r = Math.min(this.W, this.H) * 0.30;
this.pts = [
{ x: cx, y: cy - r }, // A top
{ x: cx - r * 0.88, y: cy + r * 0.62 }, // B bottom-left
{ x: cx + r * 0.88, y: cy + r * 0.62 }, // C bottom-right
];
}
_clampPts() {
const pad = 48;
for (const p of this.pts) {
p.x = Math.max(pad, Math.min(this.W - pad, p.x));
p.y = Math.max(pad, Math.min(this.H - pad, p.y));
}
}
reset() {
this.pts = null;
this._initPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
}
/* ── pointer events ── */
_bindEvents() {
const c = this.canvas;
const pos = e => {
const r = c.getBoundingClientRect();
const s = e.touches ? e.touches[0] : e;
return { x: s.clientX - r.left, y: s.clientY - r.top };
};
const hit = p => {
if (!this.pts) return -1;
for (let i = 0; i < 3; i++) {
if (Math.hypot(p.x - this.pts[i].x, p.y - this.pts[i].y) < 20) return i;
}
return -1;
};
const drag = (p) => {
this.pts[this._dragging].x = p.x;
this.pts[this._dragging].y = p.y;
this._clampPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
};
c.addEventListener('mousedown', e => {
const i = hit(pos(e));
if (i >= 0) { this._dragging = i; c.style.cursor = 'grabbing'; }
});
c.addEventListener('mousemove', e => {
const p = pos(e);
if (this._dragging !== null) { drag(p); return; }
const i = hit(p);
const was = this._hovered;
this._hovered = i >= 0 ? i : null;
c.style.cursor = i >= 0 ? 'grab' : 'default';
if (was !== this._hovered) this.draw();
});
c.addEventListener('mouseup', () => {
this._dragging = null;
c.style.cursor = this._hovered !== null ? 'grab' : 'default';
});
c.addEventListener('touchstart', e => { e.preventDefault(); const i = hit(pos(e)); if (i >= 0) this._dragging = i; }, { passive: false });
c.addEventListener('touchmove', e => { e.preventDefault(); if (this._dragging !== null) drag(pos(e)); }, { passive: false });
c.addEventListener('touchend', () => { this._dragging = null; });
}
/* ── layer toggles ── */
toggleLayer(name) { this.layers[name] = !this.layers[name]; this.draw(); }
setLayer(name, v) { this.layers[name] = v; this.draw(); }
/* ══════════════════════════════════════
Geometry helpers (canvas coords)
Scale: SCALE px = 1 unit for UI display
══════════════════════════════════════ */
static SCALE = 50; // px per unit
_len(P1, P2) { return Math.hypot(P2.x - P1.x, P2.y - P1.y); }
_sides() {
const [A, B, C] = this.pts;
return { a: this._len(B, C), b: this._len(A, C), c: this._len(A, B) };
}
_area() {
const [A, B, C] = this.pts;
return Math.abs((B.x - A.x) * (C.y - A.y) - (C.x - A.x) * (B.y - A.y)) / 2;
}
_angles() {
const { a, b, c } = this._sides();
const cl = v => Math.max(-1, Math.min(1, v));
const A = Math.acos(cl((b*b + c*c - a*a) / (2*b*c)));
const B = Math.acos(cl((a*a + c*c - b*b) / (2*a*c)));
const C = Math.PI - A - B;
return { A, B, C };
}
_centroid() {
const [A, B, C] = this.pts;
return { x: (A.x + B.x + C.x) / 3, y: (A.y + B.y + C.y) / 3 };
}
_circumcenter() {
const [A, B, C] = this.pts;
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-8) 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;
return {
x: (a2*(B.y-C.y) + b2*(C.y-A.y) + c2*(A.y-B.y)) / D,
y: (a2*(C.x-B.x) + b2*(A.x-C.x) + c2*(B.x-A.x)) / D,
};
}
_circumR() {
const O = this._circumcenter();
return O ? this._len(this.pts[0], O) : 0;
}
_incenter() {
const [A, B, C] = this.pts;
const { a, b, c } = this._sides();
const s = a + b + c;
if (s < 1e-8) return null;
return { x: (a*A.x + b*B.x + c*C.x)/s, y: (a*A.y + b*B.y + c*C.y)/s };
}
_inR() {
const { a, b, c } = this._sides();
const s = a + b + c;
return s < 1e-8 ? 0 : (2 * this._area()) / s;
}
_orthocenter() {
const [A, B, C] = this.pts;
const bcDx = C.x - B.x, bcDy = C.y - B.y;
const acDx = C.x - A.x, acDy = C.y - A.y;
const denom = bcDy * (-acDx) - (-bcDx) * acDy;
if (Math.abs(denom) < 1e-8) return null;
const t = ((B.x-A.x)*(-acDy) - (B.y-A.y)*(-acDx)) / denom;
return { x: A.x + t*bcDy, y: A.y - t*bcDx };
}
_foot(P, L1, L2) {
const dx = L2.x - L1.x, dy = L2.y - L1.y;
const l2 = dx*dx + dy*dy;
if (l2 < 1e-10) return { ...L1 };
const t = ((P.x-L1.x)*dx + (P.y-L1.y)*dy) / l2;
return { x: L1.x + t*dx, y: L1.y + t*dy };
}
_mid(P1, P2) { return { x: (P1.x+P2.x)/2, y: (P1.y+P2.y)/2 }; }
/* bisector foot: divides opposite side by angle-bisector theorem */
_bisFoot(V, L1, L2) {
const d1 = this._len(V, L1), d2 = this._len(V, L2);
const s = d1 + d2;
if (s < 1e-8) return { ...L1 };
return { x: (d2*L1.x + d1*L2.x)/s, y: (d2*L1.y + d1*L2.y)/s };
}
stats() {
const S = TriangleSim.SCALE;
const { a, b, c } = this._sides();
const { A, B, C } = this._angles();
const area = this._area();
const perim = a + b + c;
const R = this._circumR();
const r = this._inR();
const deg = rad => rad * 180 / Math.PI;
const dA = deg(A), dB = deg(B), dC = deg(C);
let type = '';
const eps = 1.8; // degrees tolerance
const isRight = [dA, dB, dC].some(d => Math.abs(d - 90) < eps);
const isObtuse = [dA, dB, dC].some(d => d > 90 + eps);
const sidesArr = [a, b, c].sort((x, y) => x - y);
const isEquil = sidesArr[2] - sidesArr[0] < 2;
const isIsoc = !isEquil && (
Math.abs(a - b) < 2 || Math.abs(b - c) < 2 || Math.abs(a - c) < 2
);
if (isEquil) type = 'Равносторонний';
else if (isRight) type = isIsoc ? 'Прямоугольный равнобедр.' : 'Прямоугольный';
else if (isObtuse) type = isIsoc ? 'Тупоугольный равнобедр.' : 'Тупоугольный';
else type = isIsoc ? 'Остроугольный равнобедр.' : 'Остроугольный';
return {
a: a/S, b: b/S, c: c/S,
A: dA, B: dB, C: dC,
S: area / (S*S),
perim: perim / S,
R: R / S,
r: r / S,
type,
};
}
/* ══════════════════════════════════════
Drawing
══════════════════════════════════════ */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H || !this.pts) return;
ctx.clearRect(0, 0, W, H);
// Background
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, '#07071A');
bg.addColorStop(1, '#0D1830');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
if (this.layers.grid) this._drawGrid(ctx, W, H);
// Layer order: circles behind <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> fill <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> construction lines <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> edges <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> vertices <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> labels
if (this.layers.circumcircle) this._drawCircumcircle(ctx);
if (this.layers.incircle) this._drawIncircle(ctx);
this._drawFill(ctx);
if (this.layers.medians) this._drawMedians(ctx);
if (this.layers.altitudes) this._drawAltitudes(ctx);
if (this.layers.bisectors) this._drawBisectors(ctx);
if (this.layers.eulerLine) this._drawEulerLine(ctx);
if (this.layers.pythagorean) this._drawPythagorean(ctx);
if (this.layers.sineLaw) this._drawSineLaw(ctx);
if (this.layers.cosineLaw) this._drawCosineLaw(ctx);
this._drawAngleArcs(ctx);
this._drawEdges(ctx);
this._drawRightAngleMark(ctx);
this._drawVertices(ctx);
this._drawSideLabels(ctx);
this._drawAngleLabels(ctx);
}
/* helpers */
_line(ctx, x1, y1, x2, y2) {
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
_dot(ctx, x, y, r, fill, shadow) {
ctx.save();
ctx.shadowColor = shadow || fill; ctx.shadowBlur = 12;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2);
ctx.fillStyle = fill; ctx.fill(); ctx.restore();
}
_label(ctx, text, x, y, color, size=13) {
ctx.save();
ctx.font = `bold ${size}px Manrope, sans-serif`;
ctx.fillStyle = color;
ctx.shadowColor = color; ctx.shadowBlur = 8;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, x, y); ctx.restore();
}
/* ── grid ── */
_drawGrid(ctx, W, H) {
const step = 50;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.045)'; ctx.lineWidth = 1;
for (let x = 0; x <= W; x += step) this._line(ctx, x, 0, x, H);
for (let y = 0; y <= H; y += step) this._line(ctx, 0, y, W, y);
// axes
ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.2;
this._line(ctx, 0, H/2, W, H/2);
this._line(ctx, W/2, 0, W/2, H);
ctx.restore();
}
/* ── triangle fill ── */
_drawFill(ctx) {
const [A, B, C] = this.pts;
ctx.save();
const g = ctx.createLinearGradient(A.x, A.y, (B.x+C.x)/2, (B.y+C.y)/2);
g.addColorStop(0, 'rgba(155,93,229,0.20)');
g.addColorStop(1, 'rgba(6,214,224,0.07)');
ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath();
ctx.fillStyle = g; ctx.fill();
ctx.restore();
}
/* ── edges ── */
_drawEdges(ctx) {
const [A, B, C] = this.pts;
ctx.save();
ctx.shadowColor = 'rgba(155,93,229,0.55)'; ctx.shadowBlur = 10;
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath(); ctx.stroke();
ctx.restore();
}
/* ── vertices ── */
_drawVertices(ctx) {
const names = ['A', 'B', 'C'];
const colors = ['#9B5DE5', '#06D6E0', '#F15BB5'];
this.pts.forEach((p, i) => {
const active = this._hovered === i || this._dragging === i;
const col = colors[i];
ctx.save();
ctx.shadowColor = col; ctx.shadowBlur = active ? 24 : 14;
ctx.beginPath(); ctx.arc(p.x, p.y, active ? 13 : 10, 0, Math.PI*2);
ctx.fillStyle = active ? col : 'rgba(7,7,26,0.92)';
ctx.fill();
ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.stroke();
if (!active) {
ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI*2);
ctx.fillStyle = col; ctx.shadowBlur = 0; ctx.fill();
}
ctx.restore();
});
}
/* ── vertex name labels (outside) ── */
_drawSideLabels(ctx) {
const [A, B, C] = this.pts;
// sides: a=BC, b=AC, c=AB
const sides = [
{ from: B, to: C, label: 'a', col: '#9B5DE5' },
{ from: A, to: C, label: 'b', col: '#06D6E0' },
{ from: A, to: B, label: 'c', col: '#F15BB5' },
];
ctx.save();
sides.forEach(({ from, to, label, col }) => {
const mx = (from.x+to.x)/2, my = (from.y+to.y)/2;
const dx = to.x-from.x, dy = to.y-from.y;
const len = Math.hypot(dx, dy); if (len < 1) return;
// perpendicular outward — pick side toward centroid and invert
const cx_ = (this.pts[0].x+this.pts[1].x+this.pts[2].x)/3;
const cy_ = (this.pts[0].y+this.pts[1].y+this.pts[2].y)/3;
let nx = -dy/len, ny = dx/len;
if ((mx+nx*10-cx_)*nx + (my+ny*10-cy_)*ny < 0) { nx=-nx; ny=-ny; }
this._label(ctx, label, mx + nx*20, my + ny*20, col, 14);
});
ctx.restore();
}
/* ── vertex A/B/C labels ── */
_drawAngleLabels(ctx) {
const names = ['A', 'B', 'C'];
const colors = ['#9B5DE5', '#06D6E0', '#F15BB5'];
const nexts = [this.pts[1], this.pts[2], this.pts[0]];
const prevs = [this.pts[2], this.pts[0], this.pts[1]];
this.pts.forEach((V, i) => {
const P = nexts[i], Q = prevs[i];
// direction from vertex away from triangle (outward bisector)
const dp = { x: P.x-V.x, y: P.y-V.y }; const lp = Math.hypot(dp.x, dp.y);
const dq = { x: Q.x-V.x, y: Q.y-V.y }; const lq = Math.hypot(dq.x, dq.y);
if (lp < 1 || lq < 1) return;
// outward: negate inward bisector
const bx = dp.x/lp + dq.x/lq, by = dp.y/lp + dq.y/lq;
const bl = Math.hypot(bx, by) || 1;
const ox = -bx/bl * 26, oy = -by/bl * 26;
this._label(ctx, names[i], V.x + ox, V.y + oy, colors[i], 15);
});
}
/* ── angle arcs ── */
_drawAngleArcs(ctx) {
const nexts = [this.pts[1], this.pts[2], this.pts[0]];
const prevs = [this.pts[2], this.pts[0], this.pts[1]];
const colors= ['rgba(155,93,229,0.7)','rgba(6,214,224,0.7)','rgba(241,91,181,0.7)'];
const fills = ['rgba(155,93,229,0.12)','rgba(6,214,224,0.12)','rgba(241,91,181,0.12)'];
ctx.save();
this.pts.forEach((V, i) => {
const P = nexts[i], Q = prevs[i];
const r = 30;
const a1 = Math.atan2(P.y-V.y, P.x-V.x);
const a2 = Math.atan2(Q.y-V.y, Q.x-V.x);
let diff = a2 - a1;
while (diff > Math.PI) diff -= 2*Math.PI;
while (diff < -Math.PI) diff += 2*Math.PI;
const ccw = diff < 0;
ctx.strokeStyle = colors[i]; ctx.lineWidth = 1.5;
ctx.fillStyle = fills[i];
ctx.beginPath();
ctx.moveTo(V.x, V.y);
ctx.arc(V.x, V.y, r, a1, a2, ccw);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(V.x, V.y, r, a1, a2, ccw);
ctx.stroke();
});
ctx.restore();
}
/* ── right angle mark ── */
_drawRightAngleMark(ctx) {
const { A, B, C } = this._angles();
const verts = this.pts;
const angles = [A, B, C];
const nexts = [verts[1], verts[2], verts[0]];
const prevs = [verts[2], verts[0], verts[1]];
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
verts.forEach((V, i) => {
if (Math.abs(angles[i] - Math.PI/2) > 0.035) return;
const P = nexts[i], Q = prevs[i];
const sz = 14;
const lp = Math.hypot(P.x-V.x, P.y-V.y), lq = Math.hypot(Q.x-V.x, Q.y-V.y);
if (lp < 1 || lq < 1) return;
const d1 = { x:(P.x-V.x)/lp*sz, y:(P.y-V.y)/lp*sz };
const d2 = { x:(Q.x-V.x)/lq*sz, y:(Q.y-V.y)/lq*sz };
ctx.beginPath();
ctx.moveTo(V.x+d1.x, V.y+d1.y);
ctx.lineTo(V.x+d1.x+d2.x, V.y+d1.y+d2.y);
ctx.lineTo(V.x+d2.x, V.y+d2.y);
ctx.stroke();
});
ctx.restore();
}
/* ── medians (green) ── */
_drawMedians(ctx) {
const [A, B, C] = this.pts;
const mA = this._mid(B, C), mB = this._mid(A, C), mC = this._mid(A, B);
const G = this._centroid();
ctx.save();
ctx.strokeStyle = '#22d55e'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#22d55e'; ctx.shadowBlur = 7;
[[A, mA],[B, mB],[C, mC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
// midpoint ticks
[mA, mB, mC].forEach(m => this._dot(ctx, m.x, m.y, 4, '#22d55e'));
// centroid
this._dot(ctx, G.x, G.y, 7, '#22d55e');
this._label(ctx, 'G', G.x + 14, G.y - 8, '#22d55e', 13);
ctx.restore();
}
/* ── altitudes (amber) ── */
_drawAltitudes(ctx) {
const [A, B, C] = this.pts;
const fA = this._foot(A, B, C);
const fB = this._foot(B, A, C);
const fC = this._foot(C, A, B);
const H = this._orthocenter();
ctx.save();
ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#f59e0b'; ctx.shadowBlur = 7;
[[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
// foot right-angle marks
[[A, B, C, fA],[B, A, C, fB],[C, A, B, fC]].forEach(([P, L1, L2, ft]) => {
const lp = Math.hypot(P.x-ft.x, P.y-ft.y);
const ll = Math.hypot(L2.x-L1.x, L2.y-L1.y);
if (lp < 1 || ll < 1) return;
const sz = 9;
const d1 = { x:(P.x-ft.x)/lp*sz, y:(P.y-ft.y)/lp*sz };
const d2 = { x:(L2.x-L1.x)/ll*sz, y:(L2.y-L1.y)/ll*sz };
ctx.strokeStyle = 'rgba(245,158,11,0.6)'; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(ft.x+d1.x, ft.y+d1.y);
ctx.lineTo(ft.x+d1.x+d2.x, ft.y+d1.y+d2.y);
ctx.lineTo(ft.x+d2.x, ft.y+d2.y);
ctx.stroke();
});
[fA, fB, fC].forEach(f => this._dot(ctx, f.x, f.y, 4, '#f59e0b'));
if (H) {
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
this._label(ctx, 'H', H.x + 14, H.y - 8, '#f59e0b', 13);
}
ctx.restore();
}
/* ── bisectors (pink) ── */
_drawBisectors(ctx) {
const [A, B, C] = this.pts;
const fA = this._bisFoot(A, B, C);
const fB = this._bisFoot(B, A, C);
const fC = this._bisFoot(C, A, B);
const I = this._incenter();
ctx.save();
ctx.strokeStyle = '#ec4899'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#ec4899'; ctx.shadowBlur = 7;
[[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
if (I) {
this._dot(ctx, I.x, I.y, 7, '#ec4899');
this._label(ctx, 'I', I.x + 14, I.y - 8, '#ec4899', 13);
}
ctx.restore();
}
/* ── circumscribed circle (pink dashed) ── */
_drawCircumcircle(ctx) {
const O = this._circumcenter();
if (!O) return;
const R = this._circumR();
ctx.save();
ctx.strokeStyle = 'rgba(241,91,181,0.75)'; ctx.lineWidth = 1.8;
ctx.setLineDash([7, 4]); ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(O.x, O.y, R, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
// center O
this._dot(ctx, O.x, O.y, 5, '#F15BB5');
this._label(ctx, 'O', O.x + 10, O.y - 10, '#F15BB5', 12);
// radii (faint)
ctx.strokeStyle = 'rgba(241,91,181,0.18)'; ctx.lineWidth = 1;
this.pts.forEach(P => {
ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(P.x, P.y); ctx.stroke();
});
ctx.restore();
}
/* ── inscribed circle (cyan dashed) ── */
_drawIncircle(ctx) {
const I = this._incenter();
if (!I) return;
const r = this._inR();
ctx.save();
ctx.strokeStyle = 'rgba(6,214,224,0.75)'; ctx.lineWidth = 1.8;
ctx.setLineDash([7, 4]); ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(6,214,224,0.05)'; ctx.fill();
this._dot(ctx, I.x, I.y, 5, '#06D6E0');
ctx.restore();
}
/* ── Euler line: O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> G <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H ── */
_drawEulerLine(ctx) {
const O = this._circumcenter();
const G = this._centroid();
const H = this._orthocenter();
if (!O || !H) return;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,100,0.5)'; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]); ctx.shadowColor = 'rgba(255,255,100,0.4)'; ctx.shadowBlur = 6;
ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(H.x, H.y); ctx.stroke();
ctx.setLineDash([]);
// Label
const mx = (O.x + H.x)/2 + 16, my = (O.y + H.y)/2 - 8;
ctx.font = '11px Manrope, sans-serif';
ctx.fillStyle = 'rgba(255,255,100,0.6)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('прямая Эйлера', mx, my);
ctx.restore();
}
/* ══════════════════════════════════════════════════════
THEOREM VISUALIZATIONS
══════════════════════════════════════════════════════ */
/* ── Law of Sines: a/sinA = b/sinB = c/sinC = 2R ── */
_drawSineLaw(ctx) {
const [A, B, C] = this.pts;
const { a, b, c } = this._sides();
const angles = this._angles();
const S = TriangleSim.SCALE;
const O = this._circumcenter();
const R = this._circumR();
if (!O || R < 1) return;
ctx.save();
// Draw circumscribed circle (faint, if not already enabled)
if (!this.layers.circumcircle) {
ctx.strokeStyle = 'rgba(96,165,250,0.3)'; ctx.lineWidth = 1.2;
ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.arc(O.x, O.y, R, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
this._dot(ctx, O.x, O.y, 3, 'rgba(96,165,250,0.5)');
}
// Draw radii from O to each vertex with labels
const verts = [A, B, C];
const sideNames = ['a', 'b', 'c'];
const angNames = ['A', 'B', 'C'];
const angVals = [angles.A, angles.B, angles.C];
const sideVals = [a, b, c];
const colors = ['#60a5fa', '#34d399', '#fbbf24'];
// Draw radius lines from center to vertices
ctx.strokeStyle = 'rgba(96,165,250,0.25)'; ctx.lineWidth = 1;
verts.forEach(v => {
ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(v.x, v.y); ctx.stroke();
});
// Compute 2R
const twoR = 2 * R / S;
// For each side, show a/sinA value annotation near the side midpoint
for (let i = 0; i < 3; i++) {
const ratio = (sideVals[i] / S) / Math.sin(angVals[i]);
const from = verts[(i + 1) % 3], to = verts[(i + 2) % 3];
const mx = (from.x + to.x) / 2, my = (from.y + to.y) / 2;
// offset outward from centroid
const cx_ = (A.x + B.x + C.x) / 3, cy_ = (A.y + B.y + C.y) / 3;
const dx = mx - cx_, dy = my - cy_;
const dl = Math.hypot(dx, dy) || 1;
const ox = dx / dl * 38, oy = dy / dl * 38;
ctx.font = 'bold 11px Manrope, sans-serif';
ctx.fillStyle = colors[i];
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = colors[i]; ctx.shadowBlur = 4;
ctx.fillText(`${sideNames[i]}/sin${angNames[i]} = ${ratio.toFixed(2)}`, mx + ox, my + oy);
ctx.shadowBlur = 0;
}
// Formula box at bottom
this._drawFormulaBox(ctx, this.W, this.H,
`a/sinA = b/sinB = c/sinC = 2R = ${twoR.toFixed(2)}`,
'#60a5fa');
ctx.restore();
}
/* ── Law of Cosines: c² = a² + b² 2ab·cosC ── */
_drawCosineLaw(ctx) {
const [A, B, C] = this.pts;
const sides = this._sides();
const angles = this._angles();
const S = TriangleSim.SCALE;
// Pick the largest angle to demonstrate
const angArr = [angles.A, angles.B, angles.C];
const maxIdx = angArr.indexOf(Math.max(...angArr));
const angVertex = this.pts[maxIdx];
const oppFrom = this.pts[(maxIdx + 1) % 3];
const oppTo = this.pts[(maxIdx + 2) % 3];
const sNames = ['a', 'b', 'c'];
const aNames = ['A', 'B', 'C'];
const sVals = [sides.a, sides.b, sides.c];
// The opposite side to the chosen angle
const oppSide = sVals[maxIdx] / S;
const adjSide1Name = sNames[(maxIdx + 1) % 3]; // side opposite to vertex (maxIdx+1)
const adjSide2Name = sNames[(maxIdx + 2) % 3]; // side opposite to vertex (maxIdx+2)
const adjSide1 = sVals[(maxIdx + 1) % 3] / S;
const adjSide2 = sVals[(maxIdx + 2) % 3] / S;
const oppSideName = sNames[maxIdx];
const angName = aNames[maxIdx];
const angDeg = angArr[maxIdx] * 180 / Math.PI;
ctx.save();
// Highlight the two adjacent sides with thicker lines
ctx.lineWidth = 3.5; ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(251,191,36,0.7)';
ctx.shadowColor = '#fbbf24'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(angVertex.x, angVertex.y); ctx.lineTo(oppFrom.x, oppFrom.y); ctx.stroke();
ctx.strokeStyle = 'rgba(52,211,153,0.7)';
ctx.shadowColor = '#34d399'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(angVertex.x, angVertex.y); ctx.lineTo(oppTo.x, oppTo.y); ctx.stroke();
// Highlight the opposite side
ctx.strokeStyle = 'rgba(239,71,111,0.7)';
ctx.shadowColor = '#EF476F'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(oppFrom.x, oppFrom.y); ctx.lineTo(oppTo.x, oppTo.y); ctx.stroke();
ctx.shadowBlur = 0;
// Angle arc highlight at the chosen vertex
const r = 36;
const a1 = Math.atan2(oppFrom.y - angVertex.y, oppFrom.x - angVertex.x);
const a2 = Math.atan2(oppTo.y - angVertex.y, oppTo.x - angVertex.x);
let diff = a2 - a1;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
const ccw = diff < 0;
ctx.fillStyle = 'rgba(251,191,36,0.15)';
ctx.beginPath();
ctx.moveTo(angVertex.x, angVertex.y);
ctx.arc(angVertex.x, angVertex.y, r, a1, a2, ccw);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(251,191,36,0.6)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(angVertex.x, angVertex.y, r, a1, a2, ccw); ctx.stroke();
// Angle label
const aMid = a1 + diff / 2;
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.fillStyle = '#fbbf24'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(`${angDeg.toFixed(1)}°`, angVertex.x + Math.cos(aMid) * 50, angVertex.y + Math.sin(aMid) * 50);
// Compute c² vs a²+b²-2ab·cosC
const c2 = oppSide ** 2;
const check = adjSide1 ** 2 + adjSide2 ** 2 - 2 * adjSide1 * adjSide2 * Math.cos(angArr[maxIdx]);
// Formula
this._drawFormulaBox(ctx, this.W, this.H,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² \u2212 2\u00B7${adjSide1Name}\u00B7${adjSide2Name}\u00B7cos${angName} \u2192 ${c2.toFixed(2)} = ${check.toFixed(2)}`,
'#fbbf24');
ctx.restore();
}
/* ── Pythagorean theorem: c² = a² + b² ── */
_drawPythagorean(ctx) {
const [A, B, C] = this.pts;
const { a, b, c } = this._sides();
const angles = this._angles();
const S = TriangleSim.SCALE;
// Find the largest angle (closest to being right)
const angArr = [angles.A, angles.B, angles.C];
const maxIdx = angArr.indexOf(Math.max(...angArr));
const maxAngle = angArr[maxIdx];
const isRight = Math.abs(maxAngle - Math.PI / 2) < 0.035;
const hyp = this.pts[maxIdx]; // vertex at the largest angle
const p1 = this.pts[(maxIdx + 1) % 3];
const p2 = this.pts[(maxIdx + 2) % 3];
// Side lengths (the side opposite the right angle is the hypotenuse)
const sNames = ['a', 'b', 'c'];
const sVals = [a / S, b / S, c / S];
const hypSide = sVals[maxIdx];
const leg1 = sVals[(maxIdx + 1) % 3];
const leg2 = sVals[(maxIdx + 2) % 3];
const hypName = sNames[maxIdx];
const leg1Name = sNames[(maxIdx + 1) % 3];
const leg2Name = sNames[(maxIdx + 2) % 3];
ctx.save();
// Draw squares on each side
this._drawSideSquare(ctx, p1, p2, '#EF476F', 0.12); // hypotenuse
this._drawSideSquare(ctx, hyp, p2, '#06D6E0', 0.12); // leg 1
this._drawSideSquare(ctx, hyp, p1, '#9B5DE5', 0.12); // leg 2
// Labels on squares with areas
const hypArea = hypSide ** 2;
const leg1Area = leg1 ** 2;
const leg2Area = leg2 ** 2;
this._labelSquare(ctx, p1, p2, `${hypName}² = ${hypArea.toFixed(2)}`, '#EF476F');
this._labelSquare(ctx, hyp, p2, `${leg1Name}² = ${leg1Area.toFixed(2)}`, '#06D6E0');
this._labelSquare(ctx, hyp, p1, `${leg2Name}² = ${leg2Area.toFixed(2)}`, '#9B5DE5');
// Status: how close to Pythagorean
const diff = Math.abs(hypArea - (leg1Area + leg2Area));
const statusCol = isRight ? '#22d55e' : '#f59e0b';
const statusText = isRight
? `\u2713 ${leg1Name}\u00B2 + ${leg2Name}\u00B2 = ${hypName}\u00B2 (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
: `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)}${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`;
this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol);
// Hint if not right angle
if (!isRight) {
ctx.font = '11px Manrope, sans-serif';
ctx.fillStyle = 'rgba(245,158,11,0.7)';
ctx.textAlign = 'center';
ctx.fillText('Перетащи вершину чтобы ∠ ≈ 90°', this.W / 2, this.H - 16);
}
ctx.restore();
}
/* ── Helpers for theorem visuals ── */
_drawSideSquare(ctx, p1, p2, color, alpha) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
// perpendicular direction (outward from triangle centroid)
const cx_ = (this.pts[0].x + this.pts[1].x + this.pts[2].x) / 3;
const cy_ = (this.pts[0].y + this.pts[1].y + this.pts[2].y) / 3;
let nx = -dy, ny = dx; // perpendicular
// ensure outward
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
if ((mx + nx * 0.1 - cx_) * nx + (my + ny * 0.1 - cy_) * ny < 0) { nx = -nx; ny = -ny; }
const q1 = { x: p1.x + nx, y: p1.y + ny };
const q2 = { x: p2.x + nx, y: p2.y + ny };
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.lineTo(q2.x, q2.y);
ctx.lineTo(q1.x, q1.y);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.lineTo(q2.x, q2.y);
ctx.lineTo(q1.x, q1.y);
ctx.closePath();
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
}
_labelSquare(ctx, p1, p2, text, color) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const cx_ = (this.pts[0].x + this.pts[1].x + this.pts[2].x) / 3;
const cy_ = (this.pts[0].y + this.pts[1].y + this.pts[2].y) / 3;
let nx = -dy, ny = dx;
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
if ((mx + nx * 0.1 - cx_) * nx + (my + ny * 0.1 - cy_) * ny < 0) { nx = -nx; ny = -ny; }
const center = { x: mx + nx * 0.5, y: my + ny * 0.5 };
ctx.save();
ctx.font = 'bold 11px Manrope, sans-serif';
ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.7)'; ctx.shadowBlur = 4;
ctx.fillText(text, center.x, center.y);
ctx.restore();
}
_drawFormulaBox(ctx, W, H, text, color) {
ctx.save();
const bw = ctx.measureText(text).width || 300;
const pad = 12;
const boxW = Math.min(W - 40, bw + pad * 2 + 20);
const boxH = 28;
const bx = (W - boxW) / 2;
const by = H - 42;
ctx.font = 'bold 12px Manrope, monospace';
// background
ctx.fillStyle = 'rgba(7,7,26,0.85)';
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.globalAlpha = 0.9;
const r = 8;
ctx.beginPath();
ctx.moveTo(bx + r, by); ctx.lineTo(bx + boxW - r, by);
ctx.quadraticCurveTo(bx + boxW, by, bx + boxW, by + r);
ctx.lineTo(bx + boxW, by + boxH - r);
ctx.quadraticCurveTo(bx + boxW, by + boxH, bx + boxW - r, by + boxH);
ctx.lineTo(bx + r, by + boxH);
ctx.quadraticCurveTo(bx, by + boxH, bx, by + boxH - r);
ctx.lineTo(bx, by + r);
ctx.quadraticCurveTo(bx, by, bx + r, by);
ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.globalAlpha = 1;
// text
ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = color; ctx.shadowBlur = 6;
ctx.fillText(text, W / 2, by + boxH / 2);
ctx.restore();
}
}
/* ─── lab UI init ─────────────────────────────────── */
function _openTriangle() {
document.getElementById('sim-topbar-title').textContent = 'Геометрия треугольника';
_simShow('sim-tri');
_simShow('ctrl-tri');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!tSim) {
tSim = new TriangleSim(document.getElementById('tri-canvas'));
tSim.onUpdate = _triUpdateUI;
}
tSim.fit();
tSim.draw();
_triUpdateUI(tSim.stats());
}));
}
function triToggle(layer, rowEl) {
if (!tSim) return;
tSim.toggleLayer(layer);
rowEl.classList.toggle('active', tSim.layers[layer]);
}
function _triUpdateUI(s) {
const f2 = v => v.toFixed(2);
const deg = v => v.toFixed(1) + '°';
const unit = v => f2(v) + ' ед';
// panel
document.getElementById('ts-a').textContent = unit(s.a);
document.getElementById('ts-b').textContent = unit(s.b);
document.getElementById('ts-c').textContent = unit(s.c);
document.getElementById('ts-A').textContent = deg(s.A);
document.getElementById('ts-B').textContent = deg(s.B);
document.getElementById('ts-C').textContent = deg(s.C);
document.getElementById('ts-S').textContent = f2(s.S) + ' ед²';
document.getElementById('ts-P').textContent = unit(s.perim);
document.getElementById('ts-R').textContent = unit(s.R);
document.getElementById('ts-r').textContent = unit(s.r);
document.getElementById('ts-type').textContent = s.type;
// stats bar
document.getElementById('tbar-a').textContent = unit(s.a);
document.getElementById('tbar-b').textContent = unit(s.b);
document.getElementById('tbar-c').textContent = unit(s.c);
document.getElementById('tbar-S').textContent = f2(s.S) + ' ед²';
document.getElementById('tbar-P').textContent = unit(s.perim);
document.getElementById('tbar-Rr').textContent = f2(s.R) + ' / ' + f2(s.r);
}
/* ── geometry (planimetry) ── */
const _GEO_HINTS = {
select: 'Клик — выбрать объект, перетащи точку для перемещения',
point: 'Клик — поставить точку',
segment: 'Кликни 2 точки для отрезка',
line: 'Кликни 2 точки для прямой',
ray: 'Кликни: начало, затем направление',
circle: 'Клик — центр; второй клик — радиус',
triangle: 'Кликни 3 точки для треугольника',
quad: 'Кликни 4 точки для четырёхугольника',
polygon: 'Кликай точки; двойной клик или Enter — завершить',
midpoint: 'Кликни 2 точки — получи середину отрезка',
perpbisect: 'Кликни 2 точки — получи серединный перпендикуляр',
anglebisect: 'Кликни: точку A, затем вершину угла, затем точку B',
parallel: 'Сначала кликни на прямую/отрезок, затем на точку',
perpendicular:'Сначала кликни на прямую/отрезок, затем на точку',
intersect: 'Кликни на первую прямую, затем на вторую',
foot: 'Сначала кликни на прямую/отрезок',
circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
reflect: 'Сначала кликни на ось симметрии (прямую/отрезок)',
ngon: 'Клик — центр правильного многоугольника; второй клик — вершина',
tangent: 'Кликни на окружность — построим касательные',
translate: 'Кликни начало вектора A',
tick: 'Кликни на отрезок или сторону — добавить штрих (1–3; ещё раз — убрать)',
arcmark: 'Кликни на вершину полигона — добавить дугу (1–3; ещё раз — убрать)',
parallelmark: 'Кликни на отрезок или сторону — добавить метку параллельности (1–2; ещё раз — убрать)',
altitude: 'Кликни на вершину треугольника — построим высоту из неё',
median: 'Кликни на вершину треугольника — построим медиану из неё',
centroid: 'Кликни на треугольник или внутри него — построим все 3 медианы и центроид G',
orthocenter: 'Кликни на треугольник или внутри него — построим все 3 высоты и ортоцентр H',
thales: 'Кликни центр подобия O (начало лучей)',
midline: 'Кликни вершину A треугольника',
parallelogram:'Кликни вершину A параллелограмма',
diagonal: 'Кликни внутри четырёхугольника — построим диагонали',
scale: 'Кликни центр подобия O',
measure_length: 'Кликни на отрезок — прикрепит живой чип с длиной',
measure_angle: 'Кликни первую точку на стороне угла',
measure_area: 'Кликни на многоугольник — прикрепит живой чип с площадью',
locus: 'Кликни точку-мовер (должна быть on_segment или on_circle)',
point_on_segment: 'Кликни на отрезок — создаст скользящую точку для ГМТ',
point_on_circle: 'Кликни на окружность — создаст скользящую точку для ГМТ',
};