'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._lastHapticTs = 0;
this._lastRafTs = 0;
this._bindEvents();
this._startParticleLoop();
}
_startParticleLoop() {
let last = performance.now();
let fxFrames = 0; // countdown frames to keep drawing after last emit
const loop = (now) => {
const dt = (now - last) / 1000;
last = now;
if (window.LabFX) {
LabFX.particles.update(dt);
if (fxFrames > 0) { fxFrames--; this.draw(); }
}
requestAnimationFrame(loop);
};
// expose a trigger so emit events bump the counter
this._fxActivate = () => { fxFrames = 60; };
requestAnimationFrame(loop);
}
/* ── 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());
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
}
/* ── 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());
const now = performance.now();
if (window.LabFX && now - (this._lastHapticTs || 0) > 100) {
this._lastHapticTs = now;
LabFX.haptic(5);
}
};
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', () => {
if (this._dragging !== null && this.pts) {
const p = this.pts[this._dragging];
if (window.LabFX) {
LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 });
LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 300, speed: 40, spread: Math.PI * 2, gravity: 0 });
if (this._fxActivate) this._fxActivate();
}
}
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 fill construction lines edges vertices 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);
if (window.LabFX) LabFX.particles.draw(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 with glow
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, () => {
this._dot(ctx, G.x, G.y, 7, '#22d55e');
}, { color: '#F59E0B', intensity: 6 });
} else {
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) {
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, () => {
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
}, { color: '#F59E0B', intensity: 6 });
} else {
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 G 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: 'Кликни на окружность — создаст скользящую точку для ГМТ',
};