feat(trainer): E — визуальные опоры вне геометрии (3 типа фигур + 4 задачи)

figures.js — 3 новых «читаемых» типа (SVG из чисел, без eval/XSS):
- number-line — ось min..max со штрихами/стрелками и отмеченной точкой
  (markLabel:'?' прячет значение — ученик читает по шкале).
- fraction-bar — полоса из parts равных клеток, filled закрашено.
- pie — круговая диаграмма из parts секторов, filled закрашено.

Подключены к арифметике (режим «читать с рисунка», figurePrompt):
- frac-read-bar / frac-read-pie — какая доля закрашена → дробь (L1).
- neg-read-line — координата точки на числовой прямой (L1).
- pct-read-pie — сколько процентов круга закрашено (L2, q∈{2,4,5,10}).

Смоук: 3 типа фигур 33/33; 4 задачи 2525 ассертов 0 ошибок (self-check,
рендер SVG без NaN/script, метка '?' скрывает ответ, LaTeX-шаги). Итого 234.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-29 22:25:32 +03:00
parent b1ffba633c
commit b79c7c4b5f
2 changed files with 136 additions and 0 deletions
+67
View File
@@ -740,6 +740,73 @@
var hA = deg2rad(90 - H * 30);
body += ln(O, P(O.x + Math.cos(hA) * rad * 0.55, O.y - Math.sin(hA) * rad * 0.55), { w: 3.4, stroke: ARC }); // часовая
return body;
},
/* Числовая прямая: ось min..max со штрихами, стрелками и отмеченными точками.
mark/mark2 — координаты точек (вторая необязательна); markLabel/mark2Label —
подписи (по умолчанию само число). Для сравнения чисел/координаты на прямой. */
'number-line': function (spec, p) {
var lo = num(p, spec.min), hi = num(p, spec.max);
if (lo == null || hi == null || !(hi > lo)) return null;
var step = num(p, spec.tickStep); if (!(step > 0)) step = 1;
if ((hi - lo) / step > 40) step = (hi - lo) / 20;
var y = VB_H / 2, x0 = MARGIN, x1 = VB_W - MARGIN;
var sx = function (v) { return x0 + (v - lo) / (hi - lo) * (x1 - x0); };
var body = ln(P(x0 - 12, y), P(x1 + 12, y), { w: 2.4 });
body += '<path d="M ' + r1(x1 + 12) + ' ' + r1(y) + ' l -9 -5 l 0 10 z" fill="' + STROKE + '"/>';
body += '<path d="M ' + r1(x0 - 12) + ' ' + r1(y) + ' l 9 -5 l 0 10 z" fill="' + STROKE + '"/>';
var start = Math.ceil(lo / step - 1e-9) * step;
for (var v = start, guard = 0; v <= hi + 1e-9 && guard < 60; v += step, guard++) {
var x = sx(v);
body += ln(P(x, y - 6), P(x, y + 6), { w: 1.8, stroke: 'rgba(255,255,255,.7)' });
body += txt(P(x, y + 20), fmt(v), { size: 11.5, fill: 'rgba(255,255,255,.85)', weight: 600 });
}
function drawMark(mv, color, label) {
if (mv == null) return '';
var mx = sx(mv);
return '<circle cx="' + r1(mx) + '" cy="' + r1(y) + '" r="5.5" fill="' + color + '"/>' +
txt(P(mx, y - 18), (label != null ? label : fmt(mv)), { size: 14, fill: color, weight: 800 });
}
body += drawMark(num(p, spec.mark), ARC, spec.markLabel);
body += drawMark(num(p, spec.mark2), '#fff', spec.mark2Label);
return body;
},
/* Полоса-модель дроби: прямоугольник из parts равных клеток, filled закрашено.
showLabel:true печатает «filled/parts» (по умолчанию НЕ печатает — ученик читает с рисунка). */
'fraction-bar': function (spec, p) {
var parts = num(p, spec.parts), filled = num(p, spec.filled);
if (!(parts >= 1) || parts > 24 || filled == null || filled < 0 || filled > parts) return null;
var W = VB_W - 2 * MARGIN, H = 50, x0 = MARGIN, y0 = (VB_H - H) / 2, cw = W / parts, body = '';
for (var i = 0; i < parts; i++) {
var fillc = (i < filled) ? 'rgba(253,230,138,.55)' : 'rgba(255,255,255,.06)';
body += '<rect x="' + r1(x0 + i * cw) + '" y="' + r1(y0) + '" width="' + r1(cw) + '" height="' + H +
'" fill="' + fillc + '" stroke="' + STROKE + '" stroke-width="1.8"/>';
}
body += '<rect x="' + r1(x0) + '" y="' + r1(y0) + '" width="' + r1(W) + '" height="' + H +
'" fill="none" stroke="' + STROKE + '" stroke-width="2.6"/>';
if (spec.showLabel) body += txt(P(VB_W / 2, y0 + H + 22), fmt(filled) + ' / ' + fmt(parts), { size: 15, weight: 800 });
return body;
},
/* Круговая диаграмма: parts равных секторов, filled закрашено (доля/проценты). */
'pie': function (spec, p) {
var parts = num(p, spec.parts), filled = num(p, spec.filled);
if (!(parts >= 2) || parts > 24 || filled == null || filled < 0 || filled > parts) return null;
var O = P(VB_W / 2, VB_H / 2), rad = Math.min(VB_W, VB_H) / 2 - MARGIN, body = '';
var large = (360 / parts > 180) ? 1 : 0;
for (var i = 0; i < parts; i++) {
var a0 = deg2rad(-90 + i * 360 / parts), a1 = deg2rad(-90 + (i + 1) * 360 / parts);
var px0 = O.x + Math.cos(a0) * rad, py0 = O.y + Math.sin(a0) * rad;
var px1 = O.x + Math.cos(a1) * rad, py1 = O.y + Math.sin(a1) * rad;
var fillc = (i < filled) ? 'rgba(253,230,138,.55)' : 'rgba(255,255,255,.06)';
body += '<path d="M ' + r1(O.x) + ' ' + r1(O.y) + ' L ' + r1(px0) + ' ' + r1(py0) +
' A ' + r1(rad) + ' ' + r1(rad) + ' 0 ' + large + ' 1 ' + r1(px1) + ' ' + r1(py1) +
' Z" fill="' + fillc + '" stroke="' + STROKE + '" stroke-width="1.8"/>';
}
body += circleSvg(O, rad, { w: 2.4 });
if (spec.showLabel) body += txt(P(O.x, VB_H - 8), fmt(filled) + ' / ' + fmt(parts), { size: 14, weight: 800 });
return body;
}
};
+69
View File
@@ -242,6 +242,24 @@
]
},
/* прочитать процент с круговой диаграммы (визуальная опора) */
{
id: 'pct-read-pie', topic: 'percents', order: 12, subject: 'algebra', grade: 6, kind: 'compute',
title: 'Процент по диаграмме', answerSym: 'p',
figure: { type: 'pie', parts: 'q', filled: 'pf' },
figurePrompt: 'Сколько процентов круга закрашено?',
pick: { qi: [1, 4], pf: [1, 9] },
derive: { q: 'qi == 1 ? 2 : (qi == 2 ? 4 : (qi == 3 ? 5 : 10))', val: '(pf*100)/(qi == 1 ? 2 : (qi == 2 ? 4 : (qi == 3 ? 5 : 10)))' },
require: 'pf < q',
lhs: 'x', rhs: '{pf}*100/{q}', display: 'Сколько процентов круга закрашено?',
answerVar: 'x', answer: 'val', integerAnswer: true,
solution: [
{ note: 'Круг разделён на {q} равных частей, из них закрашено {pf}.', tex: '' },
{ note: 'Доля закрашенного — {pf}/{q}. Переводим в проценты, умножая на 100.', tex: 'p = {pf}/{q} * 100' },
{ note: 'Считаем.', tex: 'x = {ans}' }
]
},
/* сколько % составляет a от b */
{
id: 'pct-what', topic: 'percents', order: 2, subject: 'algebra', grade: 7, kind: 'compute',
@@ -2560,6 +2578,40 @@
]
},
/* прочитать дробь с полосы-модели (визуальная опора) */
{
id: 'frac-read-bar', topic: 'fractions', order: 11, subject: 'algebra', grade: 5, kind: 'compute',
title: 'Дробь по рисунку',
figure: { type: 'fraction-bar', parts: 'q', filled: 'pf' },
figurePrompt: 'Какая часть полосы закрашена? Ответ запишите дробью (например 2/5).',
pick: { q: [3, 8], pf: [1, 7] }, constraint: 'pf < q',
derive: { val: 'pf/q' },
lhs: 'x', rhs: '{pf}/{q}', display: 'Какая часть полосы закрашена? Запишите дробью (например 2/5).',
answerVar: 'x', answer: 'val',
solution: [
{ note: 'Полоса разделена на {q} равных долей, из них закрашено {pf}.', tex: '' },
{ note: 'Закрашенная часть — это закрашенные доли от всех.', tex: 'x = {pf}/{q}' },
{ note: 'При необходимости сократите дробь.', tex: 'x = {ans}' }
]
},
/* прочитать дробь с круговой диаграммы */
{
id: 'frac-read-pie', topic: 'fractions', order: 12, subject: 'algebra', grade: 5, kind: 'compute',
title: 'Дробь по диаграмме',
figure: { type: 'pie', parts: 'q', filled: 'pf' },
figurePrompt: 'Какая часть круга закрашена? Ответ запишите дробью.',
pick: { q: [3, 8], pf: [1, 7] }, constraint: 'pf < q',
derive: { val: 'pf/q' },
lhs: 'x', rhs: '{pf}/{q}', display: 'Какая часть круга закрашена? Запишите дробью.',
answerVar: 'x', answer: 'val',
solution: [
{ note: 'Круг разделён на {q} равных секторов, закрашено {pf}.', tex: '' },
{ note: 'Закрашенная часть — закрашенные секторы от всех.', tex: 'x = {pf}/{q}' },
{ note: 'При необходимости сократите дробь.', tex: 'x = {ans}' }
]
},
/* сравнить дроби (код 1/2) */
{
id: 'frac-compare', topic: 'fractions', order: 6, subject: 'algebra', grade: 6, kind: 'compute',
@@ -2776,6 +2828,22 @@
]
},
/* прочитать координату точки на числовой прямой (визуальная опора) */
{
id: 'neg-read-line', topic: 'negatives', order: 9, subject: 'algebra', grade: 6, kind: 'compute',
title: 'Координата на прямой',
figure: { type: 'number-line', min: 'lo', max: 'hi', mark: 'm', markLabel: '?' },
figurePrompt: 'Какое число отмечено точкой на числовой прямой?',
pick: { m: [-8, 8] }, constraint: 'm != 0',
derive: { lo: 'min(m, 0) - 2', hi: 'max(m, 0) + 2', val: 'm' },
lhs: 'x', rhs: '{m}', display: 'Какое число отмечено точкой на числовой прямой?',
answerVar: 'x', answer: 'val', integerAnswer: true,
solution: [
{ note: 'Считываем положение точки по шкале — каждое деление равно единице.', tex: '' },
{ note: 'Точка стоит на этом числе.', tex: 'x = {ans}' }
]
},
/* ═══════════════════════════════════════════════════════════════════════
V4.1 — Группа 6: геометрия (углы, Пифагор, площади, многоугольники,
подобие, окружность). Все compute «корень-вперёд» + чертежи (figures.js).
@@ -3750,6 +3818,7 @@
'gcd-pair': 1, 'lcm-pair': 2,
'frac-of-number': 1, 'frac-add-same': 2,
'frac-sub-same': 2, 'frac-sub-unlike': 3, 'frac-div': 3, 'frac-mixed-improper': 1,
'frac-read-bar': 1, 'frac-read-pie': 1, 'neg-read-line': 1, 'pct-read-pie': 2,
'dec-add': 1, 'dec-sub': 1, 'dec-mult': 2,
'neg-add': 1, 'neg-sub': 2, 'neg-mult': 2
};