feat(math6): термометр (Гл.4 §1) — ±числа и модуль наглядно

Math6Anim.thermometer: вертикальный термометр на canvas, ртуть плавно
поднимается/опускается к значению (easing), выше нуля — красный, ниже — синий;
подпись поясняет знак и |x| как расстояние до нуля. Ползунок −10..10.
Вшит в Гл.4 §1. Headless-safe. Тесты math6: 20/20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-02 21:47:22 +03:00
parent 8edab2196f
commit f4ece6f5b1
3 changed files with 36 additions and 0 deletions
+27
View File
@@ -278,6 +278,33 @@ M.plotLive = function (host, opts) {
};
};
/* ============================ ДЕМО 7: ТЕРМОМЕТР (±числа, модуль) ============================ */
M.thermometer = function (host, opts) {
opts = opts || {}; var W0 = 220, H0 = 320; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var st = { v: opts.value != null ? opts.value : 5, target: opts.value != null ? opts.value : 5 };
var MIN = -10, MAX = 10, cx = 64, top = 26, bot = H0 - 56, bulbR = 18;
function Y(v) { return bot - (v - MIN) / (MAX - MIN) * (bot - top); }
function draw() {
var ctx = sc.ctx; if (!ctx) return; st.v += (st.target - st.v) * 0.15;
var mut = cssVar('--muted', '#64748b'), txt = cssVar('--text', '#0f172a');
ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = mut; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cx - 9, top); ctx.arc(cx, top, 9, Math.PI, 0); ctx.lineTo(cx + 9, bot); ctx.moveTo(cx - 9, top); ctx.lineTo(cx - 9, bot); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, bot + bulbR - 4, bulbR, 0, 2 * Math.PI); ctx.fillStyle = '#fee2e2'; ctx.fill(); ctx.strokeStyle = mut; ctx.stroke();
ctx.font = '11px JetBrains Mono, monospace'; ctx.textAlign = 'left';
for (var v = MIN; v <= MAX; v += 2) { var y = Y(v); ctx.strokeStyle = mut; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx + 12, y); ctx.lineTo(cx + 18, y); ctx.stroke(); ctx.fillStyle = (v === 0 ? txt : mut); ctx.fillText(v + '°', cx + 22, y + 4); }
var col = st.v >= 0 ? '#dc2626' : '#2563eb', y0 = Y(0), yv = Y(st.v);
ctx.fillStyle = col; ctx.fillRect(cx - 5, Math.min(y0, yv), 10, Math.abs(yv - y0));
ctx.beginPath(); ctx.arc(cx, bot + bulbR - 4, bulbR - 3, 0, 2 * Math.PI); ctx.fill();
ctx.beginPath(); ctx.arc(cx, yv, 4, 0, 2 * Math.PI); ctx.fill();
ctx.fillStyle = txt; ctx.font = 'bold 17px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(Math.round(st.v) + '°', cx, H0 - 14);
}
var L = loop(host, draw);
function setCap(v) { if (!cap) return; cap.innerHTML = '$' + v + '°$ — это ' + (v > 0 ? 'тепло, выше нуля' : (v < 0 ? 'мороз, ниже нуля' : 'ровно ноль')) + '. Модуль $|' + v + '| = ' + Math.abs(v) + '$ — это расстояние до нуля.'; if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} }
setCap(st.target);
return { stop: L.stop, set: function (v) { st.target = v; setCap(v); } };
};
/* ============================ КОМПОНЕНТ: ПОШАГОВЫЙ ПЛЕЕР (DOM, не canvas) ============================ */
M.stepPlayer = function (host, opts) {
opts = opts || {}; var steps = opts.steps || []; if (!steps.length) return { stop: function () {} };