Files
Learn_System/frontend/js/trainer/figures.js
T
Maxim Dolgolyov 16ddb27013 feat(trainer): V4.1 группа 6 — 20 геом-генераторов + 10 новых типов фигур (ЗАВЕРШЕНИЕ V4.1)
Финальная, шестая волна плана v4. Все compute «корень-вперёд» (пифагоровы
тройки/четвёрки, целые/конечные ответы) с чертежами.

figures.js — 10 НОВЫХ типов: parallel-lines-transversal, isosceles,
vertical-angles, points-distance, space-diagonal-box, l-shape, thales-parallel,
inscribed-central-angle, chord-circle, tangent-circle; + расширены rectangle
(diagonal/unknown/area-метка) и two-similar (side2/mode area/hideK); хелперы
tick (равные стороны) и circleSvg.

Генераторы:
- Углы: ang-parallel-transversal (соответственные), ang-isosceles-base,
  ang-vertical.
- Пифагор: pyth-perimeter, pyth-distance (расстояние между точками),
  pyth-rect-diagonal, pyth-space-diagonal (3D, пифагорова четвёрка ×s).
- Площади: area-rect-inverse (сторона по площади), area-l-shape (составная),
  area-sector (π≈3,14).
- Многоугольники: poly-diagonals (n(n−3)/2), poly-find-n (по углу),
  poly-exterior-sum (360/n).
- Подобие: sim-scale-factor (k по сторонам), sim-area-ratio (k²), sim-thales,
  sim-map-scale.
- Окружность: circ-inscribed-angle (вписанный=½центрального), circ-chord-pyth
  (хорда через радиус), circ-tangent-len (касательная).

Итого 175 генераторов (V4.1 закрыта: 91 новый генератор за фазу). Смоук v41
71386 проверок (вкл. рендер всех фигур: нет NaN/<script>, «?» на искомой);
figures-смоук 13846 проверок / 2280 рендеров на 38 геом-генераторах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:04:50 +03:00

713 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';
/* ════════════════════════════════════════════════════════════════════════
TrainerFigures — чертежи геометрических задач тренажёра. ДАННЫЕ, не код.
Идея (та же модель безопасности, что у SimForge «объекты — это данные» и
math6-svg): генератор задачи НЕ содержит SVG/кода — он лишь ссылается на
ИМЕНОВАННЫЙ тип фигуры и привязки её размеров к параметрам задачи:
figure: { type:'right-triangle', a:'a', b:'b', c:'c', unknown:'c' }
Здесь 'a'/'b'/'c' — имена параметров уже материализованной задачи
(problem.params, числа). Рендерер сам строит SVG ИЗ ЧИСЕЛ → ⛔ без eval/
new Function, без пользовательских строк в разметке (текст-подписи
экранируются). Чертёж — ИЛЛЮСТРАЦИЯ рядом с условием: показывает данные
величины и «?» на искомой; математику по-прежнему считает движок (SimExpr).
Цвета подобраны под индиго-сцену героя (белые штрихи на тёмном фоне; при
верном ответе сцена зеленеет, при неверном — краснеет, белое читается на всех).
API (window.TrainerFigures):
render(figureSpec, params) -> svgString | null (null, если тип неизвестен)
has(type) -> bool
TYPES -> { type: fn }
Контракт типа: fn(spec, params, U) -> body-строка (внутренности <svg>).
U — утилиты (num/lbl/fit/ln/pgon/txt/arc/rightAngle/…).
════════════════════════════════════════════════════════════════════════ */
(function (global) {
// ── размер холста и поля под подписи ──
var VB_W = 268, VB_H = 184, MARGIN = 38;
// ── палитра (только в SVG-стоки: stroke/fill — мусор не исполняется) ──
var STROKE = 'rgba(255,255,255,.94)'; // основные линии
var FILLSH = 'rgba(255,255,255,.10)'; // лёгкая заливка фигуры
var DASH = 'rgba(255,255,255,.78)'; // вспомогательные (высоты, диагонали)
var ARC = '#fde68a'; // дуги углов (тёплый, виден на всех фонах)
var UNK = '#fde68a'; // подпись искомой величины «?»
var VERTEX = 'rgba(255,255,255,.96)'; // точки-вершины
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Аккуратное число для подписи (целое без хвоста, иначе до 2 знаков).
function fmt(v) {
if (typeof v !== 'number' || !isFinite(v)) return '';
if (Math.abs(v - Math.round(v)) < 1e-9) return String(Math.round(v));
return String(Math.round(v * 100) / 100);
}
// Разрешение привязки: число → как есть; строка-имя параметра → params[name];
// числовая строка → parseFloat; иначе undefined.
function num(params, ref) {
if (typeof ref === 'number') return isFinite(ref) ? ref : undefined;
if (typeof ref === 'string') {
if (params && Object.prototype.hasOwnProperty.call(params, ref)) {
var v = params[ref];
return (typeof v === 'number' && isFinite(v)) ? v : undefined;
}
var f = parseFloat(ref);
if (isFinite(f)) return f;
}
return undefined;
}
// Подпись величины: «?» (искомая) или число. unknownKey сравнивается с key.
function lbl(val, key, unknownKey) {
if (unknownKey != null && key === unknownKey) return '?';
return fmt(val);
}
// ── геометрия в МАТЕМАТИЧЕСКИХ координатах (y вверх), затем fit→экран ──
function P(x, y) { return { x: x, y: y }; }
function add(a, b) { return P(a.x + b.x, a.y + b.y); }
function sub(a, b) { return P(a.x - b.x, a.y - b.y); }
function mul(a, k) { return P(a.x * k, a.y * k); }
function len(a) { return Math.hypot(a.x, a.y); }
function norm(a) { var l = len(a) || 1; return P(a.x / l, a.y / l); }
function mid(a, b) { return P((a.x + b.x) / 2, (a.y + b.y) / 2); }
function deg2rad(d) { return d * Math.PI / 180; }
// Подгонка набора мат-точек в холст: возвращает f.px(p) → экранная точка (y вниз).
function fit(pts) {
var minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
for (var i = 0; i < pts.length; i++) {
var p = pts[i];
if (p.x < minx) minx = p.x; if (p.x > maxx) maxx = p.x;
if (p.y < miny) miny = p.y; if (p.y > maxy) maxy = p.y;
}
var w = Math.max(1e-6, maxx - minx), h = Math.max(1e-6, maxy - miny);
var availW = VB_W - 2 * MARGIN, availH = VB_H - 2 * MARGIN;
var s = Math.min(availW / w, availH / h);
var drawW = w * s, drawH = h * s;
var ox = (VB_W - drawW) / 2, oy = (VB_H - drawH) / 2;
return {
s: s,
px: function (p) { return P(ox + (p.x - minx) * s, oy + (maxy - p.y) * s); }
};
}
// ── примитивы рисования (принимают ЭКРАННЫЕ точки) ──
function ln(a, b, opt) {
opt = opt || {};
return '<line x1="' + r1(a.x) + '" y1="' + r1(a.y) + '" x2="' + r1(b.x) + '" y2="' + r1(b.y) +
'" stroke="' + (opt.stroke || STROKE) + '" stroke-width="' + (opt.w || 2.4) +
'" stroke-linecap="round"' + (opt.dash ? ' stroke-dasharray="5 5"' : '') + '/>';
}
function pgon(ptsScreen, opt) {
opt = opt || {};
var d = ptsScreen.map(function (p) { return r1(p.x) + ',' + r1(p.y); }).join(' ');
return '<polygon points="' + d + '" fill="' + (opt.fill || FILLSH) + '" stroke="' +
(opt.stroke || STROKE) + '" stroke-width="' + (opt.w || 2.4) + '" stroke-linejoin="round"/>';
}
function dot(p, rr) {
return '<circle cx="' + r1(p.x) + '" cy="' + r1(p.y) + '" r="' + (rr || 2.6) + '" fill="' + VERTEX + '"/>';
}
// Текст с тёмным гало (paint-order) для читаемости на любом фоне сцены.
function txt(p, s, opt) {
opt = opt || {};
var fill = opt.fill || '#fff';
var size = opt.size || 13.5;
var anchor = opt.anchor || 'middle';
var weight = opt.weight || 700;
return '<text x="' + r1(p.x) + '" y="' + r1(p.y) + '" text-anchor="' + anchor +
'" dominant-baseline="middle" font-family="Manrope, system-ui, sans-serif" font-size="' + size +
'" font-weight="' + weight + '" fill="' + fill +
'" style="paint-order:stroke;stroke:rgba(15,23,42,.45);stroke-width:3.2px;stroke-linejoin:round">' +
esc(s) + '</text>';
}
// Подпись величины у середины ребра a-b, отодвинутая НАРУЖУ от точки away.
function edgeLabel(a, b, away, text, opt) {
opt = opt || {};
var m = mid(a, b);
var n = norm(sub(m, away)); // от центра наружу
var off = opt.off || 16;
var pos = P(m.x + n.x * off, m.y + n.y * off);
return txt(pos, text, opt);
}
function r1(n) { return Math.round(n * 10) / 10; }
// Дуга угла в вершине V между лучами на A и B; рисует короткую (внутреннюю) дугу
// радиуса rad экранных px. Возвращает { path, labelPos } (labelPos — для подписи).
function angleArc(Vs, As, Bs, rad) {
var a0 = Math.atan2(As.y - Vs.y, As.x - Vs.x);
var a1 = Math.atan2(Bs.y - Vs.y, Bs.x - Vs.x);
var d = a1 - a0;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
var n = 14, pts = [];
for (var i = 0; i <= n; i++) {
var a = a0 + d * (i / n);
pts.push(P(Vs.x + Math.cos(a) * rad, Vs.y + Math.sin(a) * rad));
}
var path = '<path d="M ' + pts.map(function (p) { return r1(p.x) + ' ' + r1(p.y); }).join(' L ') +
'" fill="none" stroke="' + ARC + '" stroke-width="2.4" stroke-linecap="round"/>';
var amid = a0 + d / 2;
var labelPos = P(Vs.x + Math.cos(amid) * (rad + 14), Vs.y + Math.sin(amid) * (rad + 14));
return { path: path, labelPos: labelPos };
}
// Маркер прямого угла в вершине Vs, стороны к As и Bs (экранные), размер m px.
function rightAngle(Vs, As, Bs, m) {
m = m || 13;
var u = norm(sub(As, Vs)), w = norm(sub(Bs, Vs));
var p1 = add(Vs, mul(u, m)), p3 = add(Vs, mul(w, m)), p2 = add(p1, mul(w, m));
return '<path d="M ' + r1(p1.x) + ' ' + r1(p1.y) + ' L ' + r1(p2.x) + ' ' + r1(p2.y) +
' L ' + r1(p3.x) + ' ' + r1(p3.y) + '" fill="none" stroke="' + STROKE +
'" stroke-width="2"/>';
}
var U = {
num: num, lbl: lbl, fmt: fmt, P: P, add: add, sub: sub, mul: mul, len: len,
norm: norm, mid: mid, deg2rad: deg2rad, fit: fit, ln: ln, pgon: pgon, dot: dot,
txt: txt, edgeLabel: edgeLabel, angleArc: angleArc, rightAngle: rightAngle,
STROKE: STROKE, FILLSH: FILLSH, DASH: DASH, ARC: ARC, UNK: UNK
};
/* ════════════════ ТИПЫ ФИГУР ════════════════ */
// Штрих равенства на середине отрезка a-b (метка равных сторон).
function tick(a, b) {
var m = mid(a, b), u = norm(sub(b, a)), nrm = P(-u.y, u.x);
return '<line x1="' + r1(m.x + nrm.x * 5) + '" y1="' + r1(m.y + nrm.y * 5) +
'" x2="' + r1(m.x - nrm.x * 5) + '" y2="' + r1(m.y - nrm.y * 5) +
'" stroke="' + STROKE + '" stroke-width="2"/>';
}
// Окружность как строка SVG (центр-экран Cs, радиус rad px).
function circleSvg(Cs, rad, opt) {
opt = opt || {};
return '<circle cx="' + r1(Cs.x) + '" cy="' + r1(Cs.y) + '" r="' + r1(rad) +
'" fill="' + (opt.fill || 'none') + '" stroke="' + (opt.stroke || STROKE) +
'" stroke-width="' + (opt.w || 2.4) + '"/>';
}
var TYPES = {
/* Прямоугольный треугольник (Пифагор).
a — вертикальный катет, b — горизонтальный катет, c — гипотенуза.
unknown ∈ {a,b,c} — какая величина искомая (рисуется «?»). */
'right-triangle': function (spec, p) {
var a = num(p, spec.a), b = num(p, spec.b), c = num(p, spec.c);
if (!(a > 0) || !(b > 0)) return null;
// мат-координаты: прямой угол в A (0,0), горизонт. катет B(b,0), верт. катет C(0,a)
var A = P(0, 0), B = P(b, 0), C = P(0, a);
var f = fit([A, B, C]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C);
var body = pgon([As, Bs, Cs]);
body += rightAngle(As, Bs, Cs, 13);
body += dot(As) + dot(Bs) + dot(Cs);
// подписи: горизонт. катет (A-B) ← b; верт. катет (A-C) ← a; гипотенуза (B-C) ← c
body += edgeLabel(As, Bs, Cs, lbl(b, 'b', spec.unknown), unkOpt('b', spec.unknown));
body += edgeLabel(As, Cs, Bs, lbl(a, 'a', spec.unknown), unkOpt('a', spec.unknown));
body += edgeLabel(Bs, Cs, As, (c != null ? lbl(c, 'c', spec.unknown) : '?'), unkOpt('c', spec.unknown));
return body;
},
/* Углы треугольника. angA — левый, angB — правый базовые углы.
Без ext: вершина (апекс) — искомый угол «?».
ext:true — внешний угол при правой вершине = angA+angB (рисуется «?»),
апекс получает angB, правый внутренний = 180−angAangB. */
'triangle-angles': function (spec, p) {
var ax = num(p, spec.angA), bx = num(p, spec.angB);
if (!(ax > 0) || !(bx > 0)) return null;
var ext = !!spec.ext;
var alpha = ax; // левый внутренний угол
var gamma = ext ? (180 - ax - bx) : bx; // правый внутренний угол
if (!(gamma > 0) || alpha + gamma >= 179.5) return null;
// апекс T из L,R по внутренним углам alpha (слева), gamma (справа)
var L = P(0, 0), R = P(1, 0);
var s = Math.sin(deg2rad(alpha)) / Math.sin(deg2rad(alpha + gamma)); // s = |R→…| масштаб
var T = add(R, mul(P(-Math.cos(deg2rad(gamma)), Math.sin(deg2rad(gamma))), s));
var basePts = [L, R, T];
var E = null;
if (ext) { E = P(R.x + (R.x - L.x) * 0.55, 0); basePts.push(E); } // продолжение базы за R
var f = fit(basePts);
var Ls = f.px(L), Rs = f.px(R), Ts = f.px(T);
var body = pgon([Ls, Rs, Ts]);
if (ext) {
var Es = f.px(E);
body += ln(Rs, Es, { dash: false, w: 2.2 }); // продолжение стороны (базы)
}
body += dot(Ls) + dot(Rs) + dot(Ts);
// дуги+подписи углов
var arcL = angleArc(Ls, Rs, Ts, 22);
body += arcL.path + txt(arcL.labelPos, fmt(ax) + '°', { fill: '#fff', size: 12.5 });
if (ext) {
var arcT = angleArc(Ts, Ls, Rs, 22);
body += arcT.path + txt(arcT.labelPos, fmt(bx) + '°', { fill: '#fff', size: 12.5 });
// внешний угол при R: между продолжением базы (Es) и стороной R→T
var arcE = angleArc(Rs, f.px(E), Ts, 22);
body += arcE.path + txt(arcE.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
} else {
var arcR = angleArc(Rs, Ts, Ls, 22);
body += arcR.path + txt(arcR.labelPos, fmt(bx) + '°', { fill: '#fff', size: 12.5 });
var arcTa = angleArc(Ts, Ls, Rs, 24);
body += arcTa.path + txt(arcTa.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
}
return body;
},
/* Смежные углы: прямая через O, луч вверх под углом ang к правой части.
Известный угол ang (справа от луча) и искомый «?» (слева, = 180ang). */
'adjacent-angles': function (spec, p) {
var ang = num(p, spec.ang);
if (!(ang > 0) || ang >= 180) return null;
var O = P(0, 0), Lp = P(-1, 0), Rp = P(1, 0);
var ray = P(Math.cos(deg2rad(ang)), Math.sin(deg2rad(ang))); // луч вверх
var f = fit([Lp, Rp, O, ray]);
var Os = f.px(O), Ls = f.px(Lp), Rs = f.px(Rp), Rays = f.px(ray);
var body = ln(Ls, Rs, { w: 2.6 }); // прямая
body += ln(Os, Rays, { w: 2.6 }); // луч
body += dot(Os);
var arcR = angleArc(Os, Rs, Rays, 26); // известный угол (справа)
body += arcR.path + txt(arcR.labelPos, fmt(ang) + '°', { fill: '#fff', size: 12.5 });
var arcL = angleArc(Os, Rays, Ls, 26); // искомый (слева)
body += arcL.path + txt(arcL.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
return body;
},
/* Прямоугольник: w (горизонталь), h (вертикаль). Обе стороны подписаны. */
'rectangle': function (spec, p) {
var w = num(p, spec.w), h = num(p, spec.h);
if (!(w > 0) || !(h > 0)) return null;
var A = P(0, 0), B = P(w, 0), C = P(w, h), D = P(0, h);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(w / 2, h / 2));
var body = pgon([As, Bs, Cs, Ds]);
body += edgeLabel(As, Bs, cen, lbl(w, 'w', spec.unknown), unkOpt('w', spec.unknown));
body += edgeLabel(Bs, Cs, cen, lbl(h, 'h', spec.unknown), unkOpt('h', spec.unknown));
if (spec.diagonal) { // пунктирная диагональ с «?»
body += ln(As, Cs, { dash: true, stroke: DASH, w: 1.8 });
body += txt(P((As.x + Cs.x) / 2 + 8, (As.y + Cs.y) / 2 - 8), '?', { fill: UNK, size: 15, weight: 800, anchor: 'start' });
}
var area = num(p, spec.area); // подпись площади в центре (для обратной задачи)
if (area != null) body += txt(cen, 'S = ' + fmt(area), { fill: '#fff', size: 12.5 });
return body;
},
/* Квадрат со стороной a (подписаны две смежные стороны). */
'square': function (spec, p) {
var a = num(p, spec.a);
if (!(a > 0)) return null;
var A = P(0, 0), B = P(a, 0), C = P(a, a), D = P(0, a);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(a / 2, a / 2));
var body = pgon([As, Bs, Cs, Ds]);
body += edgeLabel(As, Bs, cen, fmt(a), {});
body += edgeLabel(As, Ds, cen, fmt(a), {});
return body;
},
/* Треугольник по основанию и высоте: base (горизонт.), height (пунктирная высота). */
'triangle-base-height': function (spec, p) {
var base = num(p, spec.base), h = num(p, spec.height);
if (!(base > 0) || !(h > 0)) return null;
// апекс смещён (не равнобедренный, чтобы высота была наглядна), но проекция внутри основания
var apexX = base * 0.62;
var A = P(0, 0), B = P(base, 0), T = P(apexX, h), F = P(apexX, 0); // F — основание высоты
var f = fit([A, B, T]);
var As = f.px(A), Bs = f.px(B), Ts = f.px(T), Fs = f.px(F), cen = f.px(P(base / 2, h / 3));
var body = pgon([As, Bs, Ts]);
body += ln(Ts, Fs, { dash: true, stroke: DASH, w: 2 }); // высота
body += rightAngle(Fs, Bs, Ts, 11); // прямой угол у основания высоты
body += dot(As) + dot(Bs) + dot(Ts);
body += edgeLabel(As, Bs, Ts, fmt(base), {}); // основание
body += txt(P((Ts.x + Fs.x) / 2 + 14, (Ts.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'start' });
return body;
},
/* Трапеция: основания spec.bottom и spec.top (порядок неважен — оба основания),
height (пунктирная высота). Длинное основание рисуем СНИЗУ → ножка высоты
всегда внутри, подписи = реальные длины. */
'trapezoid': function (spec, p) {
var x1 = num(p, spec.bottom), x2 = num(p, spec.top), h = num(p, spec.height);
if (!(x1 > 0) || !(x2 > 0) || !(h > 0)) return null;
var bot = Math.max(x1, x2), top = Math.min(x1, x2);
var offset = (bot - top) / 2; // верхнее основание по центру
var A = P(0, 0), B = P(bot, 0), C = P(offset + top, h), D = P(offset, h);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(bot / 2, h / 2));
var body = pgon([As, Bs, Cs, Ds]);
var Fs = f.px(P(offset, 0)); // ножка высоты на нижнем основании
body += ln(Ds, Fs, { dash: true, stroke: DASH, w: 2 });
body += rightAngle(Fs, Bs, Ds, 10);
body += edgeLabel(As, Bs, cen, fmt(bot), {}); // нижнее основание
body += edgeLabel(Ds, Cs, cen, fmt(top), {}); // верхнее основание
body += txt(P((Ds.x + Fs.x) / 2 - 13, (Ds.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'end' });
return body;
},
/* Параллелограмм: base (нижняя сторона), height (пунктирная высота к ней). */
'parallelogram': function (spec, p) {
var base = num(p, spec.base), h = num(p, spec.height);
if (!(base > 0) || !(h > 0)) return null;
var skew = base * 0.32;
var A = P(0, 0), B = P(base, 0), C = P(base + skew, h), D = P(skew, h);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(base / 2 + skew / 2, h / 2));
var body = pgon([As, Bs, Cs, Ds]);
// высота от D перпендикулярно основанию (на проекцию D=skew)
var Fs = f.px(P(skew, 0));
body += ln(Ds, Fs, { dash: true, stroke: DASH, w: 2 });
body += rightAngle(Fs, Bs, Ds, 10);
body += edgeLabel(As, Bs, cen, fmt(base), {});
body += txt(P((Ds.x + Fs.x) / 2 - 13, (Ds.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'end' });
return body;
},
/* Ромб по диагоналям d1 (горизонт.), d2 (верт.) — диагонали пунктиром. */
'rhombus': function (spec, p) {
var d1 = num(p, spec.d1), d2 = num(p, spec.d2);
if (!(d1 > 0) || !(d2 > 0)) return null;
var R = P(d1 / 2, 0), L = P(-d1 / 2, 0), Tp = P(0, d2 / 2), Bt = P(0, -d2 / 2);
var f = fit([R, L, Tp, Bt]);
var Rs = f.px(R), Ls = f.px(L), Ts = f.px(Tp), Bs = f.px(Bt), Os = f.px(P(0, 0));
var body = pgon([Rs, Ts, Ls, Bs]);
body += ln(Ls, Rs, { dash: true, stroke: DASH, w: 1.8 }); // горизонтальная диагональ
body += ln(Bs, Ts, { dash: true, stroke: DASH, w: 1.8 }); // вертикальная диагональ
body += dot(Os, 2.2);
body += txt(P((Os.x + Rs.x) / 2, Os.y - 11), fmt(d1), { fill: '#fff', size: 12.5 });
body += txt(P(Os.x + 13, (Os.y + Ts.y) / 2), fmt(d2), { fill: '#fff', size: 12.5, anchor: 'start' });
return body;
},
/* Правильный n-угольник; markAngle:true — отметить один внутренний угол «?». */
'regular-polygon': function (spec, p) {
var n = Math.round(num(p, spec.n));
if (!(n >= 3) || n > 24) return null;
var pts = [];
var start = Math.PI / 2 + (n % 2 === 0 ? Math.PI / n : 0); // плоской стороной вниз
for (var i = 0; i < n; i++) {
var a = start + i * 2 * Math.PI / n;
pts.push(P(Math.cos(a), Math.sin(a)));
}
var f = fit(pts);
var sp = pts.map(function (pt) { return f.px(pt); });
var body = pgon(sp);
for (var j = 0; j < sp.length; j++) body += dot(sp[j], 2.3);
body += txt(f.px(P(0, 0)), 'n = ' + n, { fill: 'rgba(255,255,255,.92)', size: 12, weight: 700 }); // число сторон — читается с чертежа
if (spec.markAngle) {
var v = sp[0], prev = sp[(n - 1) % n], next = sp[1];
var arc = angleArc(v, prev, next, 16);
body += arc.path + txt(arc.labelPos, '?', { fill: UNK, size: 15, weight: 800 });
}
return body;
},
/* Две подобные фигуры (треугольники): слева — оригинал, справа — увеличенный в k раз.
mode:'side' — подписаны сходственные стороны (a и «?»);
mode:'perimeter' — подписаны периметры (P и «?»). k подписан между ними. */
'two-similar': function (spec, p) {
var k = num(p, spec.k);
if (!(k > 0)) return null;
var mode = spec.mode || 'side';
var known = (mode === 'perimeter') ? num(p, spec.perim) : (mode === 'area' ? num(p, spec.area) : num(p, spec.side));
var side2 = num(p, spec.side2); // если задана — правая сторона подписана числом, а не «?»
var vk = Math.min(1.85, Math.max(1.15, k)); // визуальный масштаб (не буквальный k)
// базовый треугольник (форма), две копии бок о бок
var shape = [P(0, 0), P(1.0, 0), P(0.35, 0.85)];
function place(scale, dx) {
return shape.map(function (pt) { return P(pt.x * scale + dx, pt.y * scale); });
}
var t1 = place(1, 0);
var gap = 0.6;
var t2 = place(vk, 1.0 + gap);
var f = fit(t1.concat(t2));
var s1 = t1.map(function (pt) { return f.px(pt); });
var s2 = t2.map(function (pt) { return f.px(pt); });
var body = pgon(s1) + pgon(s2);
var c1 = f.px(P((t1[0].x + t1[1].x + t1[2].x) / 3, (t1[0].y + t1[1].y + t1[2].y) / 3));
var c2 = f.px(P((t2[0].x + t2[1].x + t2[2].x) / 3, (t2[0].y + t2[1].y + t2[2].y) / 3));
if (mode === 'perimeter' || mode === 'area') {
var pref = (mode === 'area') ? 'S=' : 'P=';
body += txt(P(c1.x, c1.y), (known != null ? pref + fmt(known) : pref.charAt(0)), { fill: '#fff', size: 12.5 });
body += txt(P(c2.x, c2.y), '?', { fill: UNK, size: 16, weight: 800 });
} else {
// подпись нижней (сходственной) стороны каждого треугольника
body += edgeLabel(s1[0], s1[1], c1, (known != null ? fmt(known) : ''), {});
body += edgeLabel(s2[0], s2[1], c2, (side2 != null ? fmt(side2) : '?'),
side2 != null ? { fill: '#fff', size: 13 } : { fill: UNK, size: 15, weight: 800 });
}
// коэффициент подобия между фигурами (скрывается, если k — искомая величина)
if (!spec.hideK) {
var between = P((c1.x + c2.x) / 2, Math.min(c1.y, c2.y) - 6);
body += txt(between, 'k = ' + fmt(k), { fill: ARC, size: 12.5, weight: 800 });
}
return body;
},
/* Окружность/круг. r ИЛИ d задают размер. show:
'radius' — отрезок-радиус, подпись r; 'diameter' — отрезок-диаметр, подпись d;
'area' — лёгкая заливка круга + радиус. (Искомая величина — длина/площадь — на
чертеже не отмечается, как у площадей: фигура показывает данные.) */
'circle': function (spec, p) {
var r = num(p, spec.r), d = num(p, spec.d);
var radius = (r != null) ? r : (d != null ? d / 2 : null);
if (!(radius > 0)) return null;
var show = spec.show || 'radius';
var f = fit([P(-1, 0), P(1, 0), P(0, -1), P(0, 1)]);
var Cs = f.px(P(0, 0)), rad = f.s;
var body = '<circle cx="' + r1(Cs.x) + '" cy="' + r1(Cs.y) + '" r="' + r1(rad) +
'" fill="' + (show === 'area' ? FILLSH : 'none') + '" stroke="' + STROKE + '" stroke-width="2.6"/>';
body += dot(Cs, 2.6);
if (show === 'diameter') {
var Ld = P(Cs.x - rad, Cs.y), Rd = P(Cs.x + rad, Cs.y);
body += ln(Ld, Rd, { dash: true, stroke: DASH, w: 1.8 });
body += dot(Ld, 2.2) + dot(Rd, 2.2);
body += txt(P(Cs.x, Cs.y - 11), 'd = ' + fmt(d != null ? d : radius * 2), { fill: '#fff', size: 12.5 });
} else {
var ang = -Math.PI / 4; // радиус в верхне-правый сектор
var E = P(Cs.x + Math.cos(ang) * rad, Cs.y + Math.sin(ang) * rad);
body += ln(Cs, E, { w: 2 });
body += txt(P((Cs.x + E.x) / 2 + 5, (Cs.y + E.y) / 2 - 7), 'r = ' + fmt(radius), { fill: '#fff', size: 12.5, anchor: 'start' });
}
return body;
},
/* Сектор/дуга: окружность (бледная) + два радиуса под центральным углом angle°,
дуга выделена; подписаны угол и радиус r. (Длина дуги — искомая, на чертеже нет.) */
'circle-arc': function (spec, p) {
var r = num(p, spec.r), nAng = num(p, spec.angle);
if (!(r > 0) || !(nAng > 0) || nAng >= 360) return null;
var f = fit([P(-1, 0), P(1, 0), P(0, -1), P(0, 1)]);
var Cs = f.px(P(0, 0)), rad = f.s;
function onC(deg) { var a = deg2rad(deg); return P(Cs.x + Math.cos(a) * rad, Cs.y - Math.sin(a) * rad); }
var P0 = onC(0), P1 = onC(nAng);
var body = '<circle cx="' + r1(Cs.x) + '" cy="' + r1(Cs.y) + '" r="' + r1(rad) +
'" fill="none" stroke="rgba(255,255,255,.4)" stroke-width="1.8"/>';
var seg = 28, ap = [];
for (var i = 0; i <= seg; i++) ap.push(onC(nAng * i / seg));
body += '<path d="M ' + ap.map(function (q) { return r1(q.x) + ' ' + r1(q.y); }).join(' L ') +
'" fill="none" stroke="' + ARC + '" stroke-width="3.4" stroke-linecap="round"/>';
body += ln(Cs, P0, { w: 2 }) + ln(Cs, P1, { w: 2 });
body += dot(Cs, 2.6);
var amid = deg2rad(nAng / 2);
body += txt(P(Cs.x + Math.cos(amid) * rad * 0.36, Cs.y - Math.sin(amid) * rad * 0.36), fmt(nAng) + '°', { fill: '#fff', size: 12 });
body += txt(P((Cs.x + P0.x) / 2, (Cs.y + P0.y) / 2 - 9), 'r = ' + fmt(r), { fill: '#fff', size: 12 });
return body;
},
/* ── V4.1 группа 6: новые геометрические фигуры ── */
/* Параллельные прямые и секущая; данный угол a° сверху, искомый «?» снизу. */
'parallel-lines-transversal': function (spec, p) {
var a = num(p, spec.given);
if (!(a > 0)) return null;
var rel = spec.rel || 'corresponding';
var L1a = P(-1.3, 1), L1b = P(1.3, 1), L2a = P(-1.3, -1), L2b = P(1.3, -1);
var Sx = P(-0.8, -1.7), Ex = P(0.8, 1.7);
var T1 = P(0.5, 1), T2 = P(-0.5, -1);
var f = fit([L1a, L1b, L2a, L2b, Sx, Ex]);
var body = ln(f.px(L1a), f.px(L1b), { w: 2.4 }) + ln(f.px(L2a), f.px(L2b), { w: 2.4 }) + ln(f.px(Sx), f.px(Ex), { w: 2.2 });
var t1 = f.px(T1), t2 = f.px(T2);
body += dot(t1) + dot(t2);
var a1 = angleArc(t1, f.px(P(1.3, 1)), f.px(Ex), 20);
body += a1.path + txt(a1.labelPos, fmt(a) + '°', { fill: '#fff', size: 12 });
var arm = (rel === 'alternate') ? f.px(P(-1.3, -1)) : f.px(P(1.3, -1));
var arm2 = (rel === 'cointerior') ? f.px(Sx) : f.px(Ex);
var a2 = angleArc(t2, arm, arm2, 20);
body += a2.path + txt(a2.labelPos, '?', { fill: UNK, size: 15, weight: 800 });
return body;
},
/* Равнобедренный треугольник: угол при вершине apex°, базовый угол «?», штрихи на равных сторонах. */
'isosceles': function (spec, p) {
var apex = num(p, spec.apex);
if (!(apex > 0)) return null;
var T = P(0, 1), Lp = P(-0.75, -0.7), Rp = P(0.75, -0.7);
var f = fit([T, Lp, Rp]);
var Ts = f.px(T), Ls = f.px(Lp), Rs = f.px(Rp);
var body = pgon([Ts, Ls, Rs]) + dot(Ts) + dot(Ls) + dot(Rs);
var aA = angleArc(Ts, Ls, Rs, 20);
body += aA.path + txt(aA.labelPos, fmt(apex) + '°', { fill: '#fff', size: 12 });
var aL = angleArc(Ls, Rs, Ts, 18);
body += aL.path + txt(aL.labelPos, '?', { fill: UNK, size: 15, weight: 800 });
body += tick(Ts, Ls) + tick(Ts, Rs);
return body;
},
/* Вертикальные углы: две пересекающиеся прямые; данный угол a°, противоположный «?». */
'vertical-angles': function (spec, p) {
var a = num(p, spec.given);
if (!(a > 0)) return null;
var A1 = P(-1, -0.55), A2 = P(1, 0.55), B1 = P(-1, 0.55), B2 = P(1, -0.55);
var f = fit([A1, A2, B1, B2]);
var a1 = f.px(A1), a2 = f.px(A2), b1 = f.px(B1), b2 = f.px(B2), O = f.px(P(0, 0));
var body = ln(a1, a2, { w: 2.4 }) + ln(b1, b2, { w: 2.4 }) + dot(O);
var arcR = angleArc(O, a2, b2, 20);
body += arcR.path + txt(arcR.labelPos, fmt(a) + '°', { fill: '#fff', size: 12 });
var arcL = angleArc(O, a1, b1, 20);
body += arcL.path + txt(arcL.labelPos, '?', { fill: UNK, size: 15, weight: 800 });
return body;
},
/* Расстояние между точками A,B на координатной сетке: катеты пунктиром, гипотенуза «?». */
'points-distance': function (spec, p) {
var x1 = num(p, spec.x1), y1 = num(p, spec.y1), x2 = num(p, spec.x2), y2 = num(p, spec.y2);
if (x1 == null || y1 == null || x2 == null || y2 == null) return null;
var pad = 1;
var minx = Math.min(x1, x2, 0) - pad, maxx = Math.max(x1, x2, 0) + pad;
var miny = Math.min(y1, y2, 0) - pad, maxy = Math.max(y1, y2, 0) + pad;
var f = fit([P(minx, miny), P(maxx, maxy)]);
var A = f.px(P(x1, y1)), B = f.px(P(x2, y2)), K = f.px(P(x2, y1));
var body = ln(f.px(P(minx, 0)), f.px(P(maxx, 0)), { stroke: 'rgba(255,255,255,.45)', w: 1.5 });
body += ln(f.px(P(0, miny)), f.px(P(0, maxy)), { stroke: 'rgba(255,255,255,.45)', w: 1.5 });
body += ln(A, K, { dash: true, stroke: DASH, w: 1.8 }) + ln(K, B, { dash: true, stroke: DASH, w: 1.8 });
body += ln(A, B, { w: 2.6 }) + rightAngle(K, A, B, 10) + dot(A) + dot(B);
body += txt(P(A.x - 6, A.y + 12), 'A', { fill: '#fff', size: 12, anchor: 'end' });
body += txt(P(B.x + 6, B.y - 10), 'B', { fill: '#fff', size: 12, anchor: 'start' });
body += txt(P((A.x + B.x) / 2 + 8, (A.y + B.y) / 2 - 8), '?', { fill: UNK, size: 14, weight: 800, anchor: 'start' });
return body;
},
/* Прямоугольный параллелепипед (кабинетная проекция): рёбра a,b,c; диагональ «?». */
'space-diagonal-box': function (spec, p) {
var a = num(p, spec.a), b = num(p, spec.b), c = num(p, spec.c);
if (!(a > 0) || !(b > 0) || !(c > 0)) return null;
var ox = b * 0.5, oy = b * 0.42;
var A = P(0, 0), B = P(a, 0), C = P(a, c), D = P(0, c);
var A2 = P(ox, oy), B2 = P(a + ox, oy), C2 = P(a + ox, c + oy), D2 = P(ox, c + oy);
var f = fit([A, B, C, D, A2, B2, C2, D2]);
function g(q) { return f.px(q); }
var as = g(A), bs = g(B), cs = g(C), ds = g(D), a2 = g(A2), b2 = g(B2), c2 = g(C2), d2 = g(D2);
var body = pgon([as, bs, cs, ds]);
body += ln(bs, b2, { w: 2 }) + ln(cs, c2, { w: 2 }) + ln(ds, d2, { w: 2 });
body += ln(b2, c2, { w: 2 }) + ln(c2, d2, { w: 2 });
body += ln(a2, b2, { w: 1.8, stroke: DASH }) + ln(a2, d2, { w: 1.8, stroke: DASH }) + ln(as, a2, { w: 1.8, stroke: DASH });
body += ln(as, c2, { dash: true, stroke: ARC, w: 2.4 });
body += dot(as) + dot(c2);
body += edgeLabel(as, bs, cs, fmt(a), {});
body += edgeLabel(as, ds, bs, fmt(c), {});
body += txt(P((as.x + a2.x) / 2 - 6, (as.y + a2.y) / 2 + 8), fmt(b), { fill: '#fff', size: 12, anchor: 'end' });
body += txt(P((as.x + c2.x) / 2 + 8, (as.y + c2.y) / 2), '?', { fill: UNK, size: 14, weight: 800, anchor: 'start' });
return body;
},
/* L-образная фигура: размеры W,H и вырез cw×ch (правый верхний угол). */
'l-shape': function (spec, p) {
var W = num(p, spec.W), H = num(p, spec.H), cw = num(p, spec.cw), ch = num(p, spec.ch);
if (!(W > 0) || !(H > 0) || !(cw > 0) || !(ch > 0) || cw >= W || ch >= H) return null;
var pts = [P(0, 0), P(W, 0), P(W, H - ch), P(W - cw, H - ch), P(W - cw, H), P(0, H)];
var f = fit(pts);
var sp = pts.map(function (q) { return f.px(q); });
var cen = f.px(P(W * 0.32, H * 0.4));
var body = pgon(sp);
body += edgeLabel(sp[0], sp[1], cen, fmt(W), {}); // низ
body += edgeLabel(sp[0], sp[5], cen, fmt(H), {}); // лево
body += edgeLabel(sp[2], sp[3], cen, fmt(cw), {}); // вырез — горизонталь
body += edgeLabel(sp[3], sp[4], cen, fmt(ch), {}); // вырез — вертикаль
return body;
},
/* Теорема Фалеса: треугольник + параллельная база; отрезки a,b слева, c и «?» справа. */
'thales-parallel': function (spec, p) {
var a = num(p, spec.a), b = num(p, spec.b), c = num(p, spec.c);
if (!(a > 0) || !(b > 0) || !(c > 0)) return null;
var T = P(0, 1.1), Lp = P(-0.95, -0.7), Rp = P(0.95, -0.7), fr = 0.45;
var PL = P(T.x + (Lp.x - T.x) * fr, T.y + (Lp.y - T.y) * fr);
var PR = P(T.x + (Rp.x - T.x) * fr, T.y + (Rp.y - T.y) * fr);
var f = fit([T, Lp, Rp]);
var ts = f.px(T), ls = f.px(Lp), rs = f.px(Rp), pl = f.px(PL), pr = f.px(PR);
var body = pgon([ts, ls, rs]) + ln(pl, pr, { w: 2.4, stroke: ARC }) + dot(pl) + dot(pr);
body += txt(P((ts.x + pl.x) / 2 - 8, (ts.y + pl.y) / 2), fmt(a), { fill: '#fff', size: 12, anchor: 'end' });
body += txt(P((pl.x + ls.x) / 2 - 8, (pl.y + ls.y) / 2), fmt(b), { fill: '#fff', size: 12, anchor: 'end' });
body += txt(P((ts.x + pr.x) / 2 + 8, (ts.y + pr.y) / 2), fmt(c), { fill: '#fff', size: 12, anchor: 'start' });
body += txt(P((pr.x + rs.x) / 2 + 8, (pr.y + rs.y) / 2), '?', { fill: UNK, size: 14, weight: 800, anchor: 'start' });
return body;
},
/* Вписанный и центральный угол: центральный a° при O, вписанный «?» при M. */
'inscribed-central-angle': function (spec, p) {
var a = num(p, spec.central);
if (!(a > 0) || a >= 320) return null;
var f = fit([P(-1, 0), P(1, 0), P(0, -1), P(0, 1)]);
var O = f.px(P(0, 0)), rad = f.s;
function onC(deg) { var t = deg2rad(deg); return P(O.x + Math.cos(t) * rad, O.y - Math.sin(t) * rad); }
var Pp = onC(90 - a / 2), Qp = onC(90 + a / 2), M = onC(-90);
var body = circleSvg(O, rad);
body += ln(O, Pp, { w: 2 }) + ln(O, Qp, { w: 2 }) + ln(M, Pp, { w: 2 }) + ln(M, Qp, { w: 2 });
body += dot(O, 2.6) + dot(Pp) + dot(Qp) + dot(M);
var arcO = angleArc(O, Pp, Qp, 18);
body += arcO.path + txt(arcO.labelPos, fmt(a) + '°', { fill: '#fff', size: 12 });
var arcM = angleArc(M, Pp, Qp, 22);
body += arcM.path + txt(arcM.labelPos, '?', { fill: UNK, size: 14, weight: 800 });
return body;
},
/* Хорда и расстояние до центра: перпендикуляр (пунктир) + прямой угол; хорда «?». */
'chord-circle': function (spec, p) {
var r = num(p, spec.r), dd = num(p, spec.dd);
if (!(r > 0) || !(dd > 0) || dd >= r) return null;
var f = fit([P(-1, 0), P(1, 0), P(0, -1), P(0, 1)]);
var O = f.px(P(0, 0)), rad = f.s;
var dpx = rad * dd / r, half = Math.sqrt(Math.max(0, rad * rad - dpx * dpx));
var Fc = P(O.x, O.y + dpx), Ch1 = P(Fc.x - half, Fc.y), Ch2 = P(Fc.x + half, Fc.y);
var body = circleSvg(O, rad);
body += ln(Ch1, Ch2, { w: 2.6 }) + ln(O, Fc, { dash: true, stroke: DASH, w: 1.8 });
body += ln(O, Ch2, { w: 1.8, stroke: 'rgba(255,255,255,.7)' }) + rightAngle(Fc, Ch2, O, 9);
body += dot(O, 2.6) + dot(Ch1) + dot(Ch2);
body += txt(P((O.x + Fc.x) / 2 + 7, (O.y + Fc.y) / 2), fmt(dd), { fill: '#fff', size: 11, anchor: 'start' });
body += txt(P((O.x + Ch2.x) / 2 + 6, (O.y + Ch2.y) / 2 - 6), 'r=' + fmt(r), { fill: '#fff', size: 11, anchor: 'start' });
body += txt(P(Fc.x, Ch2.y + 13), '?', { fill: UNK, size: 14, weight: 800 });
return body;
},
/* Касательная из внешней точки: радиус ⟂ касательной; касательная «?». */
'tangent-circle': function (spec, p) {
var r = num(p, spec.r), op = num(p, spec.op);
if (!(r > 0) || !(op > 0) || op <= r) return null;
var f = fit([P(-1, 0), P(1.6, 0), P(0, -1), P(0, 1)]);
var O = f.px(P(0, 0)), rad = f.s;
var Tang = P(O.x + Math.cos(deg2rad(60)) * rad, O.y - Math.sin(deg2rad(60)) * rad);
var ur = norm(sub(Tang, O)), tdir = P(-ur.y, ur.x);
var Pt = P(Tang.x + tdir.x * rad * 1.3, Tang.y + tdir.y * rad * 1.3);
var body = circleSvg(O, rad);
body += ln(O, Tang, { w: 1.8, stroke: 'rgba(255,255,255,.7)' }) + ln(Tang, Pt, { w: 2.6 });
body += ln(O, Pt, { dash: true, stroke: DASH, w: 1.8 }) + rightAngle(Tang, O, Pt, 9);
body += dot(O, 2.6) + dot(Tang) + dot(Pt);
body += txt(P((O.x + Tang.x) / 2 - 6, (O.y + Tang.y) / 2), 'r=' + fmt(r), { fill: '#fff', size: 11, anchor: 'end' });
body += txt(P((O.x + Pt.x) / 2, (O.y + Pt.y) / 2 + 13), fmt(op), { fill: '#fff', size: 11 });
body += txt(P((Tang.x + Pt.x) / 2 + 4, (Tang.y + Pt.y) / 2 - 8), '?', { fill: UNK, size: 14, weight: 800, anchor: 'start' });
return body;
}
};
// Опции подписи: искомая величина — амбер/крупнее.
function unkOpt(key, unknownKey) {
if (unknownKey != null && key === unknownKey) return { fill: UNK, size: 16, weight: 800 };
return { fill: '#fff', size: 13 };
}
function render(figureSpec, params) {
if (!figureSpec || typeof figureSpec !== 'object') return null;
var fn = TYPES[figureSpec.type];
if (typeof fn !== 'function') return null;
var body;
try { body = fn(figureSpec, params || {}, U); }
catch (e) { return null; }
if (!body) return null;
return '<svg class="tr-fig-svg" viewBox="0 0 ' + VB_W + ' ' + VB_H +
'" role="img" aria-hidden="true" preserveAspectRatio="xMidYMid meet" ' +
'style="overflow:visible">' + body + '</svg>';
}
global.TrainerFigures = {
render: render,
has: function (type) { return typeof TYPES[type] === 'function'; },
TYPES: TYPES,
_util: U
};
})(typeof window !== 'undefined' ? window : globalThis);