Files
Learn_System/frontend/js/labs/triangle.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

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

1105 lines
41 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._lastHapticTs = 0;
this._lastRafTs = 0;
this._bindEvents();
this._startParticleLoop();
}
_startParticleLoop() {
let last = performance.now();
let fxFrames = 0; // countdown frames to keep drawing after last emit
const loop = (now) => {
const dt = (now - last) / 1000;
last = now;
if (window.LabFX) {
LabFX.particles.update(dt);
if (fxFrames > 0) { fxFrames--; this.draw(); }
}
requestAnimationFrame(loop);
};
// expose a trigger so emit events bump the counter
this._fxActivate = () => { fxFrames = 60; };
requestAnimationFrame(loop);
}
/* ── sizing ── */
fit() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = rect.width;
this.H = rect.height;
if (!this.pts) this._initPts();
else this._clampPts();
this.draw();
}
_initPts() {
const cx = this.W / 2, cy = this.H / 2;
const r = Math.min(this.W, this.H) * 0.30;
this.pts = [
{ x: cx, y: cy - r }, // A top
{ x: cx - r * 0.88, y: cy + r * 0.62 }, // B bottom-left
{ x: cx + r * 0.88, y: cy + r * 0.62 }, // C bottom-right
];
}
_clampPts() {
const pad = 48;
for (const p of this.pts) {
p.x = Math.max(pad, Math.min(this.W - pad, p.x));
p.y = Math.max(pad, Math.min(this.H - pad, p.y));
}
}
reset() {
this.pts = null;
this._initPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
}
/* ── pointer events ── */
_bindEvents() {
const c = this.canvas;
const pos = e => {
const r = c.getBoundingClientRect();
const s = e.touches ? e.touches[0] : e;
return { x: s.clientX - r.left, y: s.clientY - r.top };
};
const hit = p => {
if (!this.pts) return -1;
for (let i = 0; i < 3; i++) {
if (Math.hypot(p.x - this.pts[i].x, p.y - this.pts[i].y) < 20) return i;
}
return -1;
};
const drag = (p) => {
this.pts[this._dragging].x = p.x;
this.pts[this._dragging].y = p.y;
this._clampPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
const now = performance.now();
if (window.LabFX && now - (this._lastHapticTs || 0) > 100) {
this._lastHapticTs = now;
LabFX.haptic(5);
}
};
c.addEventListener('mousedown', e => {
const i = hit(pos(e));
if (i >= 0) { this._dragging = i; c.style.cursor = 'grabbing'; }
});
c.addEventListener('mousemove', e => {
const p = pos(e);
if (this._dragging !== null) { drag(p); return; }
const i = hit(p);
const was = this._hovered;
this._hovered = i >= 0 ? i : null;
c.style.cursor = i >= 0 ? 'grab' : 'default';
if (was !== this._hovered) this.draw();
});
c.addEventListener('mouseup', () => {
if (this._dragging !== null && this.pts) {
const p = this.pts[this._dragging];
if (window.LabFX) {
LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 });
LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 300, speed: 40, spread: Math.PI * 2, gravity: 0 });
if (this._fxActivate) this._fxActivate();
}
}
this._dragging = null;
c.style.cursor = this._hovered !== null ? 'grab' : 'default';
});
c.addEventListener('touchstart', e => { e.preventDefault(); const i = hit(pos(e)); if (i >= 0) this._dragging = i; }, { passive: false });
c.addEventListener('touchmove', e => { e.preventDefault(); if (this._dragging !== null) drag(pos(e)); }, { passive: false });
c.addEventListener('touchend', () => { this._dragging = null; });
}
/* ── layer toggles ── */
toggleLayer(name) { this.layers[name] = !this.layers[name]; this.draw(); }
setLayer(name, v) { this.layers[name] = v; this.draw(); }
/* ══════════════════════════════════════
Geometry helpers (canvas coords)
Scale: SCALE px = 1 unit for UI display
══════════════════════════════════════ */
static SCALE = 50; // px per unit
_len(P1, P2) { return Math.hypot(P2.x - P1.x, P2.y - P1.y); }
_sides() {
const [A, B, C] = this.pts;
return { a: this._len(B, C), b: this._len(A, C), c: this._len(A, B) };
}
_area() {
const [A, B, C] = this.pts;
return Math.abs((B.x - A.x) * (C.y - A.y) - (C.x - A.x) * (B.y - A.y)) / 2;
}
_angles() {
const { a, b, c } = this._sides();
const cl = v => Math.max(-1, Math.min(1, v));
const A = Math.acos(cl((b*b + c*c - a*a) / (2*b*c)));
const B = Math.acos(cl((a*a + c*c - b*b) / (2*a*c)));
const C = Math.PI - A - B;
return { A, B, C };
}
_centroid() {
const [A, B, C] = this.pts;
return { x: (A.x + B.x + C.x) / 3, y: (A.y + B.y + C.y) / 3 };
}
_circumcenter() {
const [A, B, C] = this.pts;
const D = 2 * (A.x*(B.y-C.y) + B.x*(C.y-A.y) + C.x*(A.y-B.y));
if (Math.abs(D) < 1e-8) return null;
const a2 = A.x*A.x + A.y*A.y, b2 = B.x*B.x + B.y*B.y, c2 = C.x*C.x + C.y*C.y;
return {
x: (a2*(B.y-C.y) + b2*(C.y-A.y) + c2*(A.y-B.y)) / D,
y: (a2*(C.x-B.x) + b2*(A.x-C.x) + c2*(B.x-A.x)) / D,
};
}
_circumR() {
const O = this._circumcenter();
return O ? this._len(this.pts[0], O) : 0;
}
_incenter() {
const [A, B, C] = this.pts;
const { a, b, c } = this._sides();
const s = a + b + c;
if (s < 1e-8) return null;
return { x: (a*A.x + b*B.x + c*C.x)/s, y: (a*A.y + b*B.y + c*C.y)/s };
}
_inR() {
const { a, b, c } = this._sides();
const s = a + b + c;
return s < 1e-8 ? 0 : (2 * this._area()) / s;
}
_orthocenter() {
const [A, B, C] = this.pts;
const bcDx = C.x - B.x, bcDy = C.y - B.y;
const acDx = C.x - A.x, acDy = C.y - A.y;
const denom = bcDy * (-acDx) - (-bcDx) * acDy;
if (Math.abs(denom) < 1e-8) return null;
const t = ((B.x-A.x)*(-acDy) - (B.y-A.y)*(-acDx)) / denom;
return { x: A.x + t*bcDy, y: A.y - t*bcDx };
}
_foot(P, L1, L2) {
const dx = L2.x - L1.x, dy = L2.y - L1.y;
const l2 = dx*dx + dy*dy;
if (l2 < 1e-10) return { ...L1 };
const t = ((P.x-L1.x)*dx + (P.y-L1.y)*dy) / l2;
return { x: L1.x + t*dx, y: L1.y + t*dy };
}
_mid(P1, P2) { return { x: (P1.x+P2.x)/2, y: (P1.y+P2.y)/2 }; }
/* bisector foot: divides opposite side by angle-bisector theorem */
_bisFoot(V, L1, L2) {
const d1 = this._len(V, L1), d2 = this._len(V, L2);
const s = d1 + d2;
if (s < 1e-8) return { ...L1 };
return { x: (d2*L1.x + d1*L2.x)/s, y: (d2*L1.y + d1*L2.y)/s };
}
stats() {
const S = TriangleSim.SCALE;
const { a, b, c } = this._sides();
const { A, B, C } = this._angles();
const area = this._area();
const perim = a + b + c;
const R = this._circumR();
const r = this._inR();
const deg = rad => rad * 180 / Math.PI;
const dA = deg(A), dB = deg(B), dC = deg(C);
let type = '';
const eps = 1.8; // degrees tolerance
const isRight = [dA, dB, dC].some(d => Math.abs(d - 90) < eps);
const isObtuse = [dA, dB, dC].some(d => d > 90 + eps);
const sidesArr = [a, b, c].sort((x, y) => x - y);
const isEquil = sidesArr[2] - sidesArr[0] < 2;
const isIsoc = !isEquil && (
Math.abs(a - b) < 2 || Math.abs(b - c) < 2 || Math.abs(a - c) < 2
);
if (isEquil) type = 'Равносторонний';
else if (isRight) type = isIsoc ? 'Прямоугольный равнобедр.' : 'Прямоугольный';
else if (isObtuse) type = isIsoc ? 'Тупоугольный равнобедр.' : 'Тупоугольный';
else type = isIsoc ? 'Остроугольный равнобедр.' : 'Остроугольный';
return {
a: a/S, b: b/S, c: c/S,
A: dA, B: dB, C: dC,
S: area / (S*S),
perim: perim / S,
R: R / S,
r: r / S,
type,
};
}
/* ══════════════════════════════════════
Drawing
══════════════════════════════════════ */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H || !this.pts) return;
ctx.clearRect(0, 0, W, H);
// Background
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, '#07071A');
bg.addColorStop(1, '#0D1830');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
if (this.layers.grid) this._drawGrid(ctx, W, H);
// Layer order: circles behind <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);
if (window.LabFX) LabFX.particles.draw(ctx);
}
/* helpers */
_line(ctx, x1, y1, x2, y2) {
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
_dot(ctx, x, y, r, fill, shadow) {
ctx.save();
ctx.shadowColor = shadow || fill; ctx.shadowBlur = 12;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2);
ctx.fillStyle = fill; ctx.fill(); ctx.restore();
}
_label(ctx, text, x, y, color, size=13) {
ctx.save();
ctx.font = `bold ${size}px Manrope, sans-serif`;
ctx.fillStyle = color;
ctx.shadowColor = color; ctx.shadowBlur = 8;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, x, y); ctx.restore();
}
/* ── grid ── */
_drawGrid(ctx, W, H) {
const step = 50;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.045)'; ctx.lineWidth = 1;
for (let x = 0; x <= W; x += step) this._line(ctx, x, 0, x, H);
for (let y = 0; y <= H; y += step) this._line(ctx, 0, y, W, y);
// axes
ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.2;
this._line(ctx, 0, H/2, W, H/2);
this._line(ctx, W/2, 0, W/2, H);
ctx.restore();
}
/* ── triangle fill ── */
_drawFill(ctx) {
const [A, B, C] = this.pts;
ctx.save();
const g = ctx.createLinearGradient(A.x, A.y, (B.x+C.x)/2, (B.y+C.y)/2);
g.addColorStop(0, 'rgba(155,93,229,0.20)');
g.addColorStop(1, 'rgba(6,214,224,0.07)');
ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath();
ctx.fillStyle = g; ctx.fill();
ctx.restore();
}
/* ── edges ── */
_drawEdges(ctx) {
const [A, B, C] = this.pts;
ctx.save();
ctx.shadowColor = 'rgba(155,93,229,0.55)'; ctx.shadowBlur = 10;
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath(); ctx.stroke();
ctx.restore();
}
/* ── vertices ── */
_drawVertices(ctx) {
const names = ['A', 'B', 'C'];
const colors = ['#9B5DE5', '#06D6E0', '#F15BB5'];
this.pts.forEach((p, i) => {
const active = this._hovered === i || this._dragging === i;
const col = colors[i];
ctx.save();
ctx.shadowColor = col; ctx.shadowBlur = active ? 24 : 14;
ctx.beginPath(); ctx.arc(p.x, p.y, active ? 13 : 10, 0, Math.PI*2);
ctx.fillStyle = active ? col : 'rgba(7,7,26,0.92)';
ctx.fill();
ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.stroke();
if (!active) {
ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI*2);
ctx.fillStyle = col; ctx.shadowBlur = 0; ctx.fill();
}
ctx.restore();
});
}
/* ── vertex name labels (outside) ── */
_drawSideLabels(ctx) {
const [A, B, C] = this.pts;
// sides: a=BC, b=AC, c=AB
const sides = [
{ from: B, to: C, label: 'a', col: '#9B5DE5' },
{ from: A, to: C, label: 'b', col: '#06D6E0' },
{ from: A, to: B, label: 'c', col: '#F15BB5' },
];
ctx.save();
sides.forEach(({ from, to, label, col }) => {
const mx = (from.x+to.x)/2, my = (from.y+to.y)/2;
const dx = to.x-from.x, dy = to.y-from.y;
const len = Math.hypot(dx, dy); if (len < 1) return;
// perpendicular outward — pick side toward centroid and invert
const cx_ = (this.pts[0].x+this.pts[1].x+this.pts[2].x)/3;
const cy_ = (this.pts[0].y+this.pts[1].y+this.pts[2].y)/3;
let nx = -dy/len, ny = dx/len;
if ((mx+nx*10-cx_)*nx + (my+ny*10-cy_)*ny < 0) { nx=-nx; ny=-ny; }
this._label(ctx, label, mx + nx*20, my + ny*20, col, 14);
});
ctx.restore();
}
/* ── vertex A/B/C labels ── */
_drawAngleLabels(ctx) {
const names = ['A', 'B', 'C'];
const colors = ['#9B5DE5', '#06D6E0', '#F15BB5'];
const nexts = [this.pts[1], this.pts[2], this.pts[0]];
const prevs = [this.pts[2], this.pts[0], this.pts[1]];
this.pts.forEach((V, i) => {
const P = nexts[i], Q = prevs[i];
// direction from vertex away from triangle (outward bisector)
const dp = { x: P.x-V.x, y: P.y-V.y }; const lp = Math.hypot(dp.x, dp.y);
const dq = { x: Q.x-V.x, y: Q.y-V.y }; const lq = Math.hypot(dq.x, dq.y);
if (lp < 1 || lq < 1) return;
// outward: negate inward bisector
const bx = dp.x/lp + dq.x/lq, by = dp.y/lp + dq.y/lq;
const bl = Math.hypot(bx, by) || 1;
const ox = -bx/bl * 26, oy = -by/bl * 26;
this._label(ctx, names[i], V.x + ox, V.y + oy, colors[i], 15);
});
}
/* ── angle arcs ── */
_drawAngleArcs(ctx) {
const nexts = [this.pts[1], this.pts[2], this.pts[0]];
const prevs = [this.pts[2], this.pts[0], this.pts[1]];
const colors= ['rgba(155,93,229,0.7)','rgba(6,214,224,0.7)','rgba(241,91,181,0.7)'];
const fills = ['rgba(155,93,229,0.12)','rgba(6,214,224,0.12)','rgba(241,91,181,0.12)'];
ctx.save();
this.pts.forEach((V, i) => {
const P = nexts[i], Q = prevs[i];
const r = 30;
const a1 = Math.atan2(P.y-V.y, P.x-V.x);
const a2 = Math.atan2(Q.y-V.y, Q.x-V.x);
let diff = a2 - a1;
while (diff > Math.PI) diff -= 2*Math.PI;
while (diff < -Math.PI) diff += 2*Math.PI;
const ccw = diff < 0;
ctx.strokeStyle = colors[i]; ctx.lineWidth = 1.5;
ctx.fillStyle = fills[i];
ctx.beginPath();
ctx.moveTo(V.x, V.y);
ctx.arc(V.x, V.y, r, a1, a2, ccw);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(V.x, V.y, r, a1, a2, ccw);
ctx.stroke();
});
ctx.restore();
}
/* ── right angle mark ── */
_drawRightAngleMark(ctx) {
const { A, B, C } = this._angles();
const verts = this.pts;
const angles = [A, B, C];
const nexts = [verts[1], verts[2], verts[0]];
const prevs = [verts[2], verts[0], verts[1]];
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
verts.forEach((V, i) => {
if (Math.abs(angles[i] - Math.PI/2) > 0.035) return;
const P = nexts[i], Q = prevs[i];
const sz = 14;
const lp = Math.hypot(P.x-V.x, P.y-V.y), lq = Math.hypot(Q.x-V.x, Q.y-V.y);
if (lp < 1 || lq < 1) return;
const d1 = { x:(P.x-V.x)/lp*sz, y:(P.y-V.y)/lp*sz };
const d2 = { x:(Q.x-V.x)/lq*sz, y:(Q.y-V.y)/lq*sz };
ctx.beginPath();
ctx.moveTo(V.x+d1.x, V.y+d1.y);
ctx.lineTo(V.x+d1.x+d2.x, V.y+d1.y+d2.y);
ctx.lineTo(V.x+d2.x, V.y+d2.y);
ctx.stroke();
});
ctx.restore();
}
/* ── medians (green) ── */
_drawMedians(ctx) {
const [A, B, C] = this.pts;
const mA = this._mid(B, C), mB = this._mid(A, C), mC = this._mid(A, B);
const G = this._centroid();
ctx.save();
ctx.strokeStyle = '#22d55e'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#22d55e'; ctx.shadowBlur = 7;
[[A, mA],[B, mB],[C, mC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
// midpoint ticks
[mA, mB, mC].forEach(m => this._dot(ctx, m.x, m.y, 4, '#22d55e'));
// centroid with glow
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, () => {
this._dot(ctx, G.x, G.y, 7, '#22d55e');
}, { color: '#F59E0B', intensity: 6 });
} else {
this._dot(ctx, G.x, G.y, 7, '#22d55e');
}
this._label(ctx, 'G', G.x + 14, G.y - 8, '#22d55e', 13);
ctx.restore();
}
/* ── altitudes (amber) ── */
_drawAltitudes(ctx) {
const [A, B, C] = this.pts;
const fA = this._foot(A, B, C);
const fB = this._foot(B, A, C);
const fC = this._foot(C, A, B);
const H = this._orthocenter();
ctx.save();
ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#f59e0b'; ctx.shadowBlur = 7;
[[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
// foot right-angle marks
[[A, B, C, fA],[B, A, C, fB],[C, A, B, fC]].forEach(([P, L1, L2, ft]) => {
const lp = Math.hypot(P.x-ft.x, P.y-ft.y);
const ll = Math.hypot(L2.x-L1.x, L2.y-L1.y);
if (lp < 1 || ll < 1) return;
const sz = 9;
const d1 = { x:(P.x-ft.x)/lp*sz, y:(P.y-ft.y)/lp*sz };
const d2 = { x:(L2.x-L1.x)/ll*sz, y:(L2.y-L1.y)/ll*sz };
ctx.strokeStyle = 'rgba(245,158,11,0.6)'; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(ft.x+d1.x, ft.y+d1.y);
ctx.lineTo(ft.x+d1.x+d2.x, ft.y+d1.y+d2.y);
ctx.lineTo(ft.x+d2.x, ft.y+d2.y);
ctx.stroke();
});
[fA, fB, fC].forEach(f => this._dot(ctx, f.x, f.y, 4, '#f59e0b'));
if (H) {
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, () => {
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
}, { color: '#F59E0B', intensity: 6 });
} else {
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
}
this._label(ctx, 'H', H.x + 14, H.y - 8, '#f59e0b', 13);
}
ctx.restore();
}
/* ── bisectors (pink) ── */
_drawBisectors(ctx) {
const [A, B, C] = this.pts;
const fA = this._bisFoot(A, B, C);
const fB = this._bisFoot(B, A, C);
const fC = this._bisFoot(C, A, B);
const I = this._incenter();
ctx.save();
ctx.strokeStyle = '#ec4899'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#ec4899'; ctx.shadowBlur = 7;
[[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
if (I) {
this._dot(ctx, I.x, I.y, 7, '#ec4899');
this._label(ctx, 'I', I.x + 14, I.y - 8, '#ec4899', 13);
}
ctx.restore();
}
/* ── circumscribed circle (pink dashed) ── */
_drawCircumcircle(ctx) {
const O = this._circumcenter();
if (!O) return;
const R = this._circumR();
ctx.save();
ctx.strokeStyle = 'rgba(241,91,181,0.75)'; ctx.lineWidth = 1.8;
ctx.setLineDash([7, 4]); ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(O.x, O.y, R, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
// center O
this._dot(ctx, O.x, O.y, 5, '#F15BB5');
this._label(ctx, 'O', O.x + 10, O.y - 10, '#F15BB5', 12);
// radii (faint)
ctx.strokeStyle = 'rgba(241,91,181,0.18)'; ctx.lineWidth = 1;
this.pts.forEach(P => {
ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(P.x, P.y); ctx.stroke();
});
ctx.restore();
}
/* ── inscribed circle (cyan dashed) ── */
_drawIncircle(ctx) {
const I = this._incenter();
if (!I) return;
const r = this._inR();
ctx.save();
ctx.strokeStyle = 'rgba(6,214,224,0.75)'; ctx.lineWidth = 1.8;
ctx.setLineDash([7, 4]); ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(6,214,224,0.05)'; ctx.fill();
this._dot(ctx, I.x, I.y, 5, '#06D6E0');
ctx.restore();
}
/* ── Euler line: O <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: 'Кликни на окружность — создаст скользящую точку для ГМТ',
};