Files
Learn_System/frontend/js/trainer/figures.js
T
Maxim Dolgolyov ff9900bdcc feat(trainer): геометрические чертежи задач — движок фигур + иллюстрации во всех геом. темах
TrainerFigures (frontend/js/trainer/figures.js) — безопасный SVG-рендер
«фигуры как данные» (модель SimForge): 11 типов — прямоугольный треугольник,
углы треугольника/смежные/внешний, прямоугольник, квадрат, треугольник по
основанию и высоте, трапеция, параллелограмм, ромб, правильный n-угольник,
подобные треугольники. Чертёж строится из чисел (params),  без eval/Function,
подписи экранируются, искомая величина — «?». Белые штрихи под индиго-сцену.

- generators.js: figure-спека на всех 15 геом-генераторах (Углы, Пифагор,
  Площади, Многоугольники, Подобие) — привязка размеров к параметрам задачи.
- _trainer_engine.js: figure прокидывается в problem.
- trainer.html: контейнер #tr-figure в шапке-герое, renderFigure() в newProblem,
  скрыт для текстовых задач, скрипт-тег, CSS.

Верификация: headless-смоук 5489 проверок / 900 рендеров (нет NaN/<script>/
обработчиков, «?» на искомой); адверсариал-ревью 4/4 группы clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:23:03 +03:00

451 lines
25 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
};
/* ════════════════ ТИПЫ ФИГУР ════════════════ */
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, fmt(w), {});
body += edgeLabel(Bs, Cs, cen, fmt(h), {});
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);
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) : num(p, spec.side);
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') {
body += txt(P(c1.x, c1.y), (known != null ? 'P=' + fmt(known) : 'P'), { 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, '?', { fill: UNK, size: 15, weight: 800 });
}
// коэффициент подобия между фигурами
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;
}
};
// Опции подписи: искомая величина — амбер/крупнее.
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);