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>
This commit is contained in:
Maxim Dolgolyov
2026-06-26 12:23:03 +03:00
parent 393de56c42
commit ff9900bdcc
4 changed files with 482 additions and 0 deletions
+1
View File
@@ -369,6 +369,7 @@
skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан
title: gen.title,
kind: kind,
figure: gen.figure || null, // спека чертежа (данные) — рисует TrainerFigures по params
lhsExpr: lhsExpr,
rhsExpr: rhsExpr,
display: (kind === 'system' && system)
+450
View File
@@ -0,0 +1,450 @@
'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);
+15
View File
@@ -500,6 +500,7 @@
{
id: 'ang-triangle', topic: 'g-angles', order: 1, subject: 'geometry', grade: 7, kind: 'compute',
title: 'Третий угол треугольника',
figure: { type: 'triangle-angles', angA: 'a', angB: 'b' },
pick: { a: [20, 80], b: [20, 80] }, derive: { val: '180 - a - b' }, require: 'val >= 15 && val <= 150',
lhs: 'x', rhs: '180 - {a} - {b}', display: 'В треугольнике два угла равны {a}° и {b}°. Найдите третий угол (в градусах).',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -513,6 +514,7 @@
{
id: 'ang-adjacent', topic: 'g-angles', order: 2, subject: 'geometry', grade: 7, kind: 'compute',
title: 'Смежный угол',
figure: { type: 'adjacent-angles', ang: 'a' },
pick: { a: [25, 155] }, derive: { val: '180 - a' },
lhs: 'x', rhs: '180 - {a}', display: 'Один из смежных углов равен {a}°. Найдите другой смежный с ним угол.',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -526,6 +528,7 @@
{
id: 'ang-exterior', topic: 'g-angles', order: 3, subject: 'geometry', grade: 7, kind: 'compute',
title: 'Внешний угол треугольника',
figure: { type: 'triangle-angles', angA: 'a', angB: 'b', ext: true },
pick: { a: [20, 80], b: [20, 80] }, derive: { val: 'a + b' }, require: 'val <= 160',
lhs: 'x', rhs: '{a} + {b}', display: 'Внешний угол треугольника равен сумме двух не смежных с ним внутренних углов. Эти углы равны {a}° и {b}°. Найдите внешний угол.',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -541,6 +544,7 @@
{
id: 'pyth-hyp', topic: 'g-pyth', order: 1, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Гипотенуза (Пифагор)',
figure: { type: 'right-triangle', a: 'a', b: 'b', c: 'c', unknown: 'c' },
pick: { m: [2, 5], n: [1, 4] }, constraint: 'm > n',
derive: { a: 'm*m - n*n', b: '2*m*n', c: 'm*m + n*n' },
lhs: 'x', rhs: 'sqrt({a}^2 + {b}^2)', display: 'Катеты прямоугольного треугольника равны {a} и {b}. Найдите гипотенузу.',
@@ -555,6 +559,7 @@
{
id: 'pyth-leg', topic: 'g-pyth', order: 2, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Катет (Пифагор)',
figure: { type: 'right-triangle', a: 'a', b: 'b', c: 'c', unknown: 'b' },
pick: { m: [2, 5], n: [1, 4] }, constraint: 'm > n',
derive: { a: 'm*m - n*n', b: '2*m*n', c: 'm*m + n*n' },
lhs: 'x', rhs: 'sqrt({c}^2 - {a}^2)', display: 'Гипотенуза прямоугольного треугольника {c}, один катет {a}. Найдите второй катет.',
@@ -571,6 +576,7 @@
{
id: 'area-rect', topic: 'g-area', order: 1, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Площадь прямоугольника',
figure: { type: 'rectangle', w: 'a', h: 'b' },
pick: { a: [2, 16], b: [2, 16] }, derive: { val: 'a*b' },
lhs: 'x', rhs: '{a}*{b}', display: 'Стороны прямоугольника {a} и {b}. Найдите его площадь.',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -584,6 +590,7 @@
{
id: 'area-triangle', topic: 'g-area', order: 2, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Площадь треугольника',
figure: { type: 'triangle-base-height', base: 'a', height: 'h' },
pick: { a: [2, 16], h: [2, 16] }, require: 'mod(a*h, 2) == 0',
derive: { val: 'a*h/2' },
lhs: 'x', rhs: '{a}*{h}/2', display: 'Основание треугольника {a}, высота к нему {h}. Найдите площадь.',
@@ -598,6 +605,7 @@
{
id: 'area-square', topic: 'g-area', order: 3, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Площадь квадрата',
figure: { type: 'square', a: 'a' },
pick: { a: [2, 20] }, derive: { val: 'a*a' },
lhs: 'x', rhs: '{a}^2', display: 'Сторона квадрата {a}. Найдите его площадь.',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -724,6 +732,7 @@
{
id: 'area-trapezoid', topic: 'g-area', order: 4, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Площадь трапеции',
figure: { type: 'trapezoid', bottom: 'a', top: 'b', height: 'h' },
pick: { a: [2, 14], b: [2, 14], h: [2, 12] }, require: 'mod((a + b)*h, 2) == 0',
derive: { val: '(a + b)*h/2' },
lhs: 'x', rhs: '({a} + {b})*{h}/2', display: 'Основания трапеции {a} и {b}, высота {h}. Найдите площадь.',
@@ -738,6 +747,7 @@
{
id: 'area-parallelogram', topic: 'g-area', order: 5, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Площадь параллелограмма',
figure: { type: 'parallelogram', base: 'a', height: 'h' },
pick: { a: [2, 16], h: [2, 14] }, derive: { val: 'a*h' },
lhs: 'x', rhs: '{a}*{h}', display: 'Сторона параллелограмма {a}, высота к ней {h}. Найдите площадь.',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -751,6 +761,7 @@
{
id: 'area-rhombus', topic: 'g-area', order: 6, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Площадь ромба',
figure: { type: 'rhombus', d1: 'd1', d2: 'd2' },
pick: { d1: [2, 16], d2: [2, 16] }, require: 'mod(d1*d2, 2) == 0',
derive: { val: 'd1*d2/2' },
lhs: 'x', rhs: '{d1}*{d2}/2', display: 'Диагонали ромба {d1} и {d2}. Найдите площадь.',
@@ -767,6 +778,7 @@
{
id: 'poly-angles-sum', topic: 'g-poly', order: 1, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Сумма углов многоугольника',
figure: { type: 'regular-polygon', n: 'n' },
pick: { n: [3, 12] }, derive: { val: '180*(n - 2)' },
lhs: 'x', rhs: '180*({n} - 2)', display: 'Найдите сумму углов выпуклого {n}-угольника (в градусах).',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -780,6 +792,7 @@
{
id: 'poly-regular-angle', topic: 'g-poly', order: 2, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Угол правильного многоугольника',
figure: { type: 'regular-polygon', n: 'n', markAngle: true },
pick: { n: [3, 20] }, require: 'mod(180*(n - 2), n) == 0',
derive: { val: '180*(n - 2)/n' },
lhs: 'x', rhs: '180*({n} - 2)/{n}', display: 'Найдите величину угла правильного {n}-угольника (в градусах).',
@@ -796,6 +809,7 @@
{
id: 'sim-side', topic: 'g-sim', order: 1, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Сторона по коэффициенту',
figure: { type: 'two-similar', side: 'a', k: 'k', mode: 'side' },
pick: { a: [2, 15], k: [2, 5] }, derive: { val: 'a*k' },
lhs: 'x', rhs: '{a}*{k}', display: 'Треугольники подобны с коэффициентом {k}. Сторона первого равна {a}. Найдите сходственную сторону второго.',
answerVar: 'x', answer: 'val', integerAnswer: true,
@@ -809,6 +823,7 @@
{
id: 'sim-perimeter', topic: 'g-sim', order: 2, subject: 'geometry', grade: 8, kind: 'compute',
title: 'Периметр подобной фигуры',
figure: { type: 'two-similar', perim: 'p', k: 'k', mode: 'perimeter' },
pick: { p: [5, 30], k: [2, 5] }, derive: { val: 'p*k' },
lhs: 'x', rhs: '{p}*{k}', display: 'Фигуры подобны с коэффициентом {k}. Периметр первой равен {p}. Найдите периметр второй.',
answerVar: 'x', answer: 'val', integerAnswer: true,
+16
View File
@@ -217,6 +217,10 @@
/* текстовый prompt (проценты/упрощение) — компактнее уравнения, на сцене белым */
.tr-eq.tr-eq-text { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: clamp(1.2rem, 3vw, 1.7rem); line-height: 1.45; color: #fff; }
/* чертёж геометрической задачи — белыми штрихами на сцене-герое */
.tr-figure { margin: 16px auto 0; max-width: 320px; }
.tr-figure .tr-fig-svg { width: 100%; height: auto; display: block; filter: drop-shadow(0 4px 14px rgba(0,0,0,.18)); }
.tr-work { padding: 24px 28px 28px; }
/* ── уровни сложности ── */
@@ -441,6 +445,7 @@
<div class="tr-stage">
<div class="tr-skill" id="tr-skill"></div>
<div class="tr-eq" id="tr-eq"></div>
<div class="tr-figure" id="tr-figure" style="display:none"></div>
</div>
<div class="tr-work">
<div class="tr-difficulty" id="tr-difficulty"></div>
@@ -538,6 +543,7 @@
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/trainer/_trainer_engine.js"></script>
<script src="/js/trainer/generators.js"></script>
<script src="/js/trainer/figures.js"></script>
<script src="/js/trainer/adaptive.js"></script>
<!-- KaTeX для рендера уравнений и шагов решения -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
@@ -586,6 +592,14 @@
var h = kat(latex, display);
if (h) el.innerHTML = h; else el.textContent = fallbackText;
}
// Чертёж задачи (геометрия): TrainerFigures строит безопасный SVG по cur.figure + params.
function renderFigure(problem) {
var box = $('tr-figure'); if (!box) return;
var svg = (problem && problem.figure && window.TrainerFigures)
? window.TrainerFigures.render(problem.figure, problem.params) : null;
if (svg) { box.innerHTML = svg; box.style.display = ''; }
else { box.innerHTML = ''; box.style.display = 'none'; }
}
var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]);
var isTeacher = !!(ip && ip.isTeacher);
@@ -626,6 +640,7 @@
}
function serveWordProblem() {
var eq = $('tr-eq'); eq.classList.add('tr-eq-text');
renderFigure(null); // текстовые задачи — без чертежа
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
if (!wordPool.length) {
@@ -929,6 +944,7 @@
var eq = $('tr-eq');
eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты/упрощение) — другим шрифтом
setMath(eq, cur.latex, cur.display, true);
renderFigure(cur); // чертёж для геометрии (иначе скрыт)
applyInputMode();
var inp = $('tr-input');
inp.value = ''; inp.disabled = false;