feat(phys8): Phase 0 — skeleton hub + 3 chapters + lab + phys.js/optics.js

Полная инфраструктура курса «Физика 8» (Исаченкова, 2018):
- physics_8_hub.html: палитра violet/indigo, 3 главы + ЛР + финал курса
  с 10 интегрированными боссами и ачивкой «Магистр физики 8» (+150 XP)
- physics_8_ch1.html (Тепловые, §§1–11): красный акцент
- physics_8_ch2.html (Электромагнитные, §§12–31): янтарный акцент
- physics_8_ch3.html (Световые, §§32–40): голубой акцент
- physics_8_lab.html (7 ЛР): зелёный акцент
- Расширение phys.js: tempColor, thermometer, calorimeter, createHeatBar,
  phaseGraphTT, Rseries, Rparallel
- Новый модуль optics.js: ray, refractRay, reflectRay, mirrorPlane,
  mirrorSpherical, thinLens, buildLensImage, goldenRays, eyeDiagram,
  lightObject, shadowTriangle
- Миграция 037: replace legacy children (thermal/electro/optics) на
  physics-8-ch1/ch2/ch3 + physics-8-lab; обновлён hub до 47 пунктов

BUILDERS всех § рендерят stub с указанием Phase/Wave из PLAN_PHYSICS_8.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 22:41:37 +03:00
parent 8e8988ec23
commit 33a91900a8
8 changed files with 4022 additions and 0 deletions
+150
View File
@@ -225,8 +225,158 @@ function paToAtm(p) { return p / 101325; }
function litersToM3(V) { return V / 1000; }
function m3ToLiters(V) { return V * 1000; }
// === Расширения для Физики 8 (тепловые явления) ===
// === Цвет по температуре: 240° (синий) → 0° (красный) ===
function tempColor(t, tMin, tMax) {
const u = Math.max(0, Math.min(1, (t - tMin) / (tMax - tMin)));
const hue = 240 * (1 - u);
return `hsl(${hue.toFixed(0)},72%,52%)`;
}
// === Термометр (вертикальная шкала, столбик ртути по температуре) ===
function thermometer(x, y, h, tMin, tMax, tValue) {
// x, y — верх корпуса; h — высота столбика
const reservoirR = 11;
const tubeW = 8;
const u = Math.max(0, Math.min(1, (tValue - tMin) / (tMax - tMin)));
const fillH = h * u;
const color = tempColor(tValue, tMin, tMax);
let s = '';
// Корпус (стекло)
s += `<rect x="${x - tubeW/2}" y="${y}" width="${tubeW}" height="${h}" rx="${tubeW/2}" fill="#fff" stroke="#0f172a" stroke-width="1.4"/>`;
// Столбик ртути
s += `<rect x="${x - tubeW/2 + 1.5}" y="${y + h - fillH}" width="${tubeW - 3}" height="${fillH}" rx="${(tubeW-3)/2}" fill="${color}"/>`;
// Резервуар
s += `<circle cx="${x}" cy="${y + h + reservoirR - 1}" r="${reservoirR}" fill="${color}" stroke="#0f172a" stroke-width="1.4"/>`;
// Деления (5)
for (let i = 0; i <= 5; i++) {
const ty = y + h * i / 5;
const tv = tMax - (tMax - tMin) * i / 5;
s += `<line x1="${x + tubeW/2 + 1}" y1="${ty}" x2="${x + tubeW/2 + 6}" y2="${ty}" stroke="#0f172a" stroke-width="1"/>`;
s += `<text x="${x + tubeW/2 + 9}" y="${ty + 3.5}" font-family="JetBrains Mono,monospace" font-size="10" fill="#0f172a">${tv.toFixed(0)}</text>`;
}
// Подпись текущего значения
s += `<text x="${x - tubeW/2 - 4}" y="${y - 4}" text-anchor="end" font-family="JetBrains Mono,monospace" font-size="11" font-weight="700" fill="${color}">${tValue.toFixed(0)} &#176;C</text>`;
return s;
}
// === Калориметр-стакан с жидкостью ===
function calorimeter(x, y, w, h, fillFrac, liquidColor) {
fillFrac = Math.max(0, Math.min(1, fillFrac || 0.6));
liquidColor = liquidColor || '#60a5fa';
const wall = 4;
const innerH = h - wall;
const liqH = innerH * fillFrac;
let s = '';
// Внешний контур
s += `<path d="M ${x} ${y} L ${x} ${y+h} Q ${x} ${y+h+6} ${x+8} ${y+h+6} L ${x+w-8} ${y+h+6} Q ${x+w} ${y+h+6} ${x+w} ${y+h} L ${x+w} ${y} L ${x+w-wall} ${y} L ${x+w-wall} ${y+h-2} L ${x+wall} ${y+h-2} L ${x+wall} ${y} Z" fill="#e0f2fe" stroke="#0f172a" stroke-width="1.5"/>`;
// Жидкость
s += `<rect x="${x+wall+1}" y="${y + innerH - liqH + wall/2}" width="${w - 2*wall - 2}" height="${liqH}" fill="${liquidColor}" opacity="0.78"/>`;
// Мениск
s += `<line x1="${x+wall+1}" y1="${y + innerH - liqH + wall/2}" x2="${x+w-wall-1}" y2="${y + innerH - liqH + wall/2}" stroke="${liquidColor}" stroke-width="1.6" opacity="0.95"/>`;
return s;
}
// === Симуляция теплопроводности по стержню (одномерное уравнение тепла) ===
// Возвращает объект с .step(dt), .render(x, y, w, h), .reset(), .setTHot(t), .setTCold(t)
function createHeatBar(opts) {
opts = opts || {};
const N = opts.N || 30;
const tHot = opts.tHot != null ? opts.tHot : 200;
const tCold = opts.tCold != null ? opts.tCold : 0;
const alpha = opts.alpha || 0.25; // коэф. диффузии (отн. ед.)
const T = new Array(N);
for (let i = 0; i < N; i++) T[i] = tCold;
return {
N: N, alpha: alpha, T: T,
_tHot: tHot, _tCold: tCold,
setTHot(v) { this._tHot = v; },
setTCold(v) { this._tCold = v; },
reset() {
for (let i = 0; i < this.N; i++) this.T[i] = this._tCold;
},
step(dt) {
// Конечно-разностное уравнение теплопроводности с граничными условиями Дирихле.
const Tnew = new Array(this.N);
Tnew[0] = this._tHot;
Tnew[this.N - 1] = this._tCold;
for (let i = 1; i < this.N - 1; i++) {
Tnew[i] = this.T[i] + this.alpha * dt * (this.T[i-1] - 2*this.T[i] + this.T[i+1]);
}
this.T = Tnew;
},
render(x, y, w, h) {
const segW = w / this.N;
const tMin = Math.min(this._tCold, this._tHot);
const tMax = Math.max(this._tCold, this._tHot);
let s = '';
for (let i = 0; i < this.N; i++) {
const c = tempColor(this.T[i], tMin, tMax);
s += `<rect x="${(x + i*segW).toFixed(1)}" y="${y}" width="${(segW + 0.5).toFixed(1)}" height="${h}" fill="${c}"/>`;
}
// Контур стержня
s += `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="#0f172a" stroke-width="1.5"/>`;
return s;
}
};
}
// === График фазовых переходов T(t) с горизонтальными плато ===
// points: массив сегментов [{tStart, tEnd, Tstart, Tend, label?}]
// W, H, pad — размеры графика; tMaxAll, TminAll, TmaxAll — диапазоны
function phaseGraphTT(W, H, pad, points, tMaxAll, TminAll, TmaxAll) {
const toX = t => pad + (W - 2*pad) * t / tMaxAll;
const toY = T => H - pad - (H - 2*pad) * (T - TminAll) / (TmaxAll - TminAll);
let s = '';
// Оси
s += `<line x1="${pad}" y1="${H-pad}" x2="${W-pad}" y2="${H-pad}" stroke="#0f172a" stroke-width="1.5"/>`;
s += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${H-pad}" stroke="#0f172a" stroke-width="1.5"/>`;
s += `<text x="${W-pad+4}" y="${H-pad+4}" font-family="Inter,sans-serif" font-size="11" fill="#0f172a">t</text>`;
s += `<text x="${pad-4}" y="${pad-4}" text-anchor="end" font-family="Inter,sans-serif" font-size="11" fill="#0f172a">T,&#176;C</text>`;
// Сегменты
let d = '';
let first = true;
for (const seg of points) {
if (first) { d += `M ${toX(seg.tStart).toFixed(1)} ${toY(seg.Tstart).toFixed(1)} `; first = false; }
d += `L ${toX(seg.tEnd).toFixed(1)} ${toY(seg.Tend).toFixed(1)} `;
}
s += `<path d="${d}" stroke="#dc2626" stroke-width="2.5" fill="none" stroke-linejoin="round" stroke-linecap="round"/>`;
// Подписи сегментов
for (const seg of points) {
if (seg.label) {
const mx = (toX(seg.tStart) + toX(seg.tEnd)) / 2;
const my = (toY(seg.Tstart) + toY(seg.Tend)) / 2 - 8;
s += `<text x="${mx.toFixed(1)}" y="${my.toFixed(1)}" text-anchor="middle" font-family="Inter,sans-serif" font-size="11" font-weight="600" fill="#475569">${seg.label}</text>`;
}
}
return { svg: s, toX: toX, toY: toY };
}
// === Электронные хелперы для электрических задач ===
// Параллельное и последовательное сопротивление
function Rseries() {
let R = 0;
for (let i = 0; i < arguments.length; i++) R += arguments[i];
return R;
}
function Rparallel() {
let inv = 0;
for (let i = 0; i < arguments.length; i++) {
if (arguments[i] > 0) inv += 1 / arguments[i];
}
return inv > 0 ? 1 / inv : Infinity;
}
// === Экспорт ===
window.PHYS = {
tempColor: tempColor,
thermometer: thermometer,
calorimeter: calorimeter,
createHeatBar: createHeatBar,
phaseGraphTT: phaseGraphTT,
Rseries: Rseries,
Rparallel: Rparallel,
drawArrow: drawArrow,
fieldLinesPointCharge: fieldLinesPointCharge,
chargeMark: chargeMark,