'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 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); } /* 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 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', };