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