feat: Phase 5 планиметрии — касательные (tangent) + параллельный перенос (translate)

- gTangentPoints(O, P, r): касательные через полярно-полярную точку M=O+v*r²/d, h=r√(d²-r²)/d
- tangent: 2 derived_line (which=0/1) из внешней точки к окружности; оба пересчитываются
  при движении точки или изменении радиуса/центра; _pendingCircRef хранит окружность-источник
- translate: derived point P'=P+(B-A) по вектору AB; 3-фазный ввод с onHintChange(tool,2/3)
- _hitTestCircle(): найти окружность под курсором (HIT=12px)
- _drawLineRefHighlight(): расширен для circle (рисует дугу подсветки)
- _pendingCircRef очищается в setTool()
- lab.html: кнопки Симметрия/Перенос/Касательные, _GEO_PHASE_HINTS словарь,
  _geoShowHint(name, phase) принимает числовой phase вместо boolean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 10:28:09 +03:00
parent e2e351d9c2
commit 0523734898
2 changed files with 196 additions and 27 deletions
+25 -14
View File
@@ -3861,10 +3861,18 @@
<div class="gp-section-title" style="margin-top:4px">Преобразования</div>
<div class="geo-tool-grid">
<button id="geo-btn-reflect" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('reflect',this)" title="Симметрия точки относительно прямой — клик на ось, затем на точку">
<button id="geo-btn-reflect" class="geo-tool-btn" onclick="geoSetTool('reflect',this)" title="Симметрия точки относительно прямой — клик на ось, затем на точку">
<svg viewBox="0 0 24 24" fill="none"><line x1="12" y1="2" x2="12" y2="22" stroke-width="1.5" stroke-dasharray="3,2"/><circle cx="6" cy="12" r="3" stroke-width="1.5"/><circle cx="18" cy="12" r="2.5" stroke-width="1.5" opacity=".5"/><line x1="9" y1="12" x2="15" y2="12" stroke-width="1" opacity=".6"/></svg>
Симметрия
</button>
<button id="geo-btn-translate" class="geo-tool-btn" onclick="geoSetTool('translate',this)" title="Параллельный перенос — вектор AB, затем точка P">
<svg viewBox="0 0 24 24" fill="none"><circle cx="6" cy="18" r="2.5" stroke-width="1.5"/><circle cx="18" cy="6" r="2.5" stroke-width="1.5"/><line x1="6" y1="18" x2="18" y2="6" stroke-width="1.5" marker-end="url(#arrow)"/><circle cx="14" cy="18" r="2.5" stroke-width="1.5" opacity=".4"/><circle cx="21" cy="9" r="2" stroke-width="1.5" stroke-dasharray="3,2"/></svg>
Перенос
</button>
<button id="geo-btn-tangent" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('tangent',this)" title="Касательные из точки к окружности — клик на окружность, затем на точку">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="7" stroke-width="1.5"/><line x1="4" y1="4" x2="19" y2="12" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="4" y1="20" x2="19" y2="12" stroke-width="1.5" stroke-dasharray="4,3"/><circle cx="4" cy="12" r="2.5" fill="currentColor"/></svg>
Касательные
</button>
</div>
<div class="gp-section-title" style="margin-top:4px">Правильный многоугольник</div>
@@ -5374,6 +5382,8 @@
incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
reflect: 'Сначала кликни на ось симметрии (прямую/отрезок)',
ngon: 'Клик — центр правильного многоугольника; второй клик — вершина',
tangent: 'Кликни на окружность — построим касательные',
translate: 'Кликни начало вектора A',
};
function geoSetTool(name, btnEl) {
@@ -5384,24 +5394,25 @@
_geoShowHint(name);
}
function _geoShowHint(name, phase2) {
const _GEO_PHASE_HINTS = {
parallel_2: 'Теперь кликни на точку — через неё проведём прямую',
perpendicular_2: 'Теперь кликни на точку — через неё проведём перпендикуляр',
intersect_2: 'Теперь кликни на вторую прямую',
foot_2: 'Теперь кликни на точку — найдём основание перпендикуляра',
reflect_2: 'Теперь кликни на точку — получишь её симметричное отражение',
tangent_2: 'Теперь кликни на внешнюю точку — получишь две касательные',
translate_2: 'Теперь кликни конец вектора B',
translate_3: 'Теперь кликни точку P — она будет перенесена',
};
function _geoShowHint(name, phase) {
const hint = document.getElementById('geo-hint');
if (!hint) return;
if (phase2) {
const phase2hints = {
parallel: 'Теперь кликни на точку — через неё проведём прямую',
perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр',
intersect: 'Теперь кликни на вторую прямую',
foot: 'Теперь кликни на точку — найдём основание перпендикуляра',
reflect: 'Теперь кликни на точку — получишь её симметричное отражение',
};
hint.textContent = phase2hints[name] || _GEO_HINTS[name] || '';
if (phase && phase > 1) {
hint.textContent = _GEO_PHASE_HINTS[`${name}_${phase}`] || _GEO_HINTS[name] || '';
} else {
hint.textContent = _GEO_HINTS[name] || '';
}
// Показываем/скрываем n-selector только для ngon
const ngonCtrl = document.getElementById('geo-ngon-ctrl');
// ngon-ctrl всегда виден — расположен рядом с кнопкой в grid
}
function geoNgonN(delta) {