Files
Learn_System/frontend/js/labs/triangle.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

962 lines
33 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();
}
}