feat(phys7): Phase 0 — фундамент учебника Физики 7

Полная инфраструктура: hub, 5 ch-скелетов, lab-скелет, миграция 039,
расширение phys.js на 11 хелперов + 2 класса симуляций для новых тем 7-го класса.

ФАЙЛЫ:
- backend/src/db/migrations/039_physics_7_hub.sql — self-sufficient миграция
  (parent physics-7 + 6 children: ch1..ch5 + lab). Palette: sky/blue для hub,
  глав: indigo/violet/red/amber/emerald/cyan.
- frontend/textbooks/physics_7_hub.html (862 строки) — hub с прогресс-картами
  6 разделов, шпаргалкой курса в 5 mini-карточках, 10 интегрированных боссов
  финала курса (через ачивку «Магистр физики 7», +150 XP), темой/lang storage
  через ключи physics7_*. Sidebar-фикс на десктопе встроен.
- frontend/textbooks/physics_7_ch1..ch5.html (350-390 строк каждый) —
  скелеты глав с header, paragraph selector, sidebar, прогресс/XP, goTo,
  search-модалом, KaTeX с delimiters, sidebar-фиксом, cache-busting ?v=20260530.
  Каждая глава имеет правильное число параграфов (7/6/14/8/7) + sec-finalN.
- frontend/textbooks/physics_7_lab.html (306 строк) — скелет лаб. практикума
  на 6 ЛР с teal/cyan палитрой и ачивкой «Лаборант 7 класса» (+80 XP).
- backend/scripts/gen_phys7_ch.js / gen_phys7_lab.js — генераторы из единого
  шаблона (для регенерации при правках инфраструктуры).

PHYS.JS НОВЫЕ ХЕЛПЕРЫ (всё работает, smoke-test пройден):
- forceVector(x,y,F,angle,color,label) — стрелка силы с подписью
- dynamometer(x,y,h,Fmax,F) — динамометр с пружиной и шкалой
- blockOnSurface(x,y,w,h,label,weights) — брусок со стопкой гирь
- connectedVessels(x,y,kindA,kindB,levelY) — сообщающиеся сосуды
- hydraulicPress(x,y,sSmall,sLarge,fSmall) — гидравлический пресс
- mercuryBarometer(x,y,hMm) — ртутный барометр Торричелли
- aneroidBarometer(cx,cy,r,p) — стрелочный барометр-анероид
- uManometer(x,y,w,h,deltaH) — U-образный жидкостный манометр
- rulerWithError(x,y,lenCm,mmPerDiv) — линейка со шкалой и ценой деления
- bimetal(x,y,w,h,deltaT) — биметаллическая пластина (гнётся от ΔT)
- expandingRod(x,y,l0,alpha,deltaT) — стержень с тепловым расширением
- class HillSlideSim — тележка на горке (§42, закон сохранения; графики Ek/Ep/Etot)
- class PendulumSim — математический маятник (§42, осцилляции)

Все 13 экспортированы в window.PHYS, smoke-test показал физически разумные
значения энергий. Parse-check + node --check проходят.

Уроки phys 9 учтены сразу: cache-busting на phys.js, sidebar-фикс @media
min-width:981px, delimiters для renderMathInElement.

PHASE 0 DONE. Дальше: Phase 1 Wave 1 — §§1-2 (Физика как наука + Тело/явление/величина).
This commit is contained in:
Maxim Dolgolyov
2026-05-30 10:32:37 +03:00
parent 29a2bae7d9
commit e76485cadc
11 changed files with 4210 additions and 1 deletions
+328 -1
View File
@@ -443,6 +443,319 @@ function Rparallel() {
return inv > 0 ? 1 / inv : Infinity;
}
// ======================================================================
// PHYSICS 7 HELPERS — силы, давление, гидростатика, барометры, энергия.
// Все размеры в SVG-пикселях; физические величины в СИ.
// ======================================================================
function forceVector(x, y, F, angleDeg, color, label, scale) {
scale = scale || 1.5;
const a = -angleDeg * Math.PI / 180;
const x2 = x + F * scale * Math.cos(a);
const y2 = y + F * scale * Math.sin(a);
let s = drawArrow(x, y, x2, y2, color || '#0f172a', 2.5, 9);
if (label) {
const lx = x2 + 8 * Math.cos(a);
const ly = y2 + 8 * Math.sin(a) + 4;
s += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" font-family="Inter,sans-serif" font-size="12" font-weight="700" fill="${color || '#0f172a'}">${label}</text>`;
}
return s;
}
function dynamometer(x, y, h, Fmax, Fcurr) {
const w = 30;
const springTop = y + 14, springBot = y + h - 28;
const stretch = Math.max(0, Math.min(1, Fcurr / Fmax)) * (springBot - springTop);
const pointerY = springTop + stretch;
let s = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="#fef3c7" stroke="#92400e" stroke-width="1.5" rx="4"/>`;
const coils = 8;
const coilH = (pointerY - springTop) / coils;
let path = `M ${x + w/2} ${springTop}`;
for (let i = 0; i < coils; i++) {
path += ` L ${x + (i%2 ? w-6 : 6)} ${springTop + (i+0.5)*coilH}`;
}
path += ` L ${x + w/2} ${pointerY}`;
s += `<path d="${path}" fill="none" stroke="#92400e" stroke-width="1.5"/>`;
const ticks = 10;
for (let i = 0; i <= ticks; i++) {
const ty = springTop + (springBot - springTop) * i / ticks;
s += `<line x1="${x + w}" y1="${ty}" x2="${x + w + 6}" y2="${ty}" stroke="#92400e" stroke-width="1.2"/>`;
if (i % 2 === 0) {
s += `<text x="${x + w + 10}" y="${ty + 4}" font-family="Inter,sans-serif" font-size="9" fill="#92400e">${(Fmax * i / ticks).toFixed(Fmax < 5 ? 1 : 0)}</text>`;
}
}
s += `<line x1="${x + 5}" y1="${pointerY}" x2="${x + w - 5}" y2="${pointerY}" stroke="#dc2626" stroke-width="2.5"/>`;
s += `<circle cx="${x + w/2}" cy="${pointerY + 8}" r="4" fill="#374151"/>`;
s += `<text x="${x + w/2}" y="${y + h + 18}" text-anchor="middle" font-family="Inter,sans-serif" font-size="11" font-weight="700" fill="#374151">F = ${Fcurr.toFixed(1)} Н</text>`;
return s;
}
function blockOnSurface(x, y, w, h, label, weights) {
let s = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="#a78bfa" stroke="#5b21b6" stroke-width="1.5" rx="3"/>`;
if (label) s += `<text x="${x + w/2}" y="${y + h/2 + 4}" text-anchor="middle" font-family="Inter,sans-serif" font-size="11" font-weight="700" fill="#fff">${label}</text>`;
if (weights && weights > 0) {
const wH = 10, wW = w * 0.7;
for (let i = 0; i < weights; i++) {
const wy = y - (i + 1) * (wH + 2);
s += `<rect x="${x + (w - wW)/2}" y="${wy}" width="${wW}" height="${wH}" fill="#475569" stroke="#1e293b" stroke-width="1" rx="2"/>`;
}
}
return s;
}
function connectedVessels(x, y, kindA, kindB, levelY, fluidColor) {
fluidColor = fluidColor || '#60a5fa';
const widths = { cylinder: 60, wide: 100, narrow: 35 };
const wA = widths[kindA] || 60, wB = widths[kindB] || 60;
const h = 140, gap = 50;
const xA = x, xB = x + wA + gap;
let s = '';
s += `<line x1="${xA}" y1="${y}" x2="${xA}" y2="${y + h}" stroke="#0f172a" stroke-width="2"/>`;
s += `<line x1="${xA + wA}" y1="${y}" x2="${xA + wA}" y2="${y + h}" stroke="#0f172a" stroke-width="2"/>`;
s += `<line x1="${xB}" y1="${y}" x2="${xB}" y2="${y + h}" stroke="#0f172a" stroke-width="2"/>`;
s += `<line x1="${xB + wB}" y1="${y}" x2="${xB + wB}" y2="${y + h}" stroke="#0f172a" stroke-width="2"/>`;
s += `<line x1="${xA}" y1="${y + h}" x2="${xA + wA}" y2="${y + h}" stroke="#0f172a" stroke-width="2"/>`;
s += `<line x1="${xB}" y1="${y + h}" x2="${xB + wB}" y2="${y + h}" stroke="#0f172a" stroke-width="2"/>`;
s += `<line x1="${xA + wA}" y1="${y + h - 6}" x2="${xB}" y2="${y + h - 6}" stroke="#0f172a" stroke-width="2"/>`;
s += `<line x1="${xA + wA}" y1="${y + h}" x2="${xB}" y2="${y + h}" stroke="#0f172a" stroke-width="2"/>`;
const fillH = (y + h) - levelY;
s += `<rect x="${xA + 1}" y="${levelY}" width="${wA - 2}" height="${fillH}" fill="${fluidColor}" opacity="0.75"/>`;
s += `<rect x="${xB + 1}" y="${levelY}" width="${wB - 2}" height="${fillH}" fill="${fluidColor}" opacity="0.75"/>`;
s += `<rect x="${xA + wA}" y="${y + h - 6}" width="${gap}" height="6" fill="${fluidColor}" opacity="0.75"/>`;
s += `<line x1="${xA - 8}" y1="${levelY}" x2="${xB + wB + 8}" y2="${levelY}" stroke="#dc2626" stroke-width="1" stroke-dasharray="4 3"/>`;
return s;
}
function hydraulicPress(x, y, sSmall, sLarge, fSmall, fluidColor) {
fluidColor = fluidColor || '#60a5fa';
const fLarge = fSmall * sLarge / sSmall;
const scale = 6;
const wSmall = Math.max(20, Math.sqrt(sSmall) * scale);
const wLarge = Math.max(40, Math.sqrt(sLarge) * scale);
const cylH = 60, baseY = y + 110, gap = 40;
const xS = x, xL = x + wSmall + gap;
let s = '';
s += `<rect x="${xS}" y="${y + 50}" width="${wSmall}" height="${cylH}" fill="${fluidColor}" stroke="#0f172a" stroke-width="1.5" opacity="0.7"/>`;
s += `<rect x="${xS - 3}" y="${y + 40}" width="${wSmall + 6}" height="14" fill="#374151" stroke="#0f172a" stroke-width="1.5" rx="2"/>`;
s += `<rect x="${xL}" y="${y + 50}" width="${wLarge}" height="${cylH}" fill="${fluidColor}" stroke="#0f172a" stroke-width="1.5" opacity="0.7"/>`;
s += `<rect x="${xL - 3}" y="${y + 40}" width="${wLarge + 6}" height="14" fill="#374151" stroke="#0f172a" stroke-width="1.5" rx="2"/>`;
s += `<rect x="${xS}" y="${baseY}" width="${(xL + wLarge) - xS}" height="10" fill="${fluidColor}" stroke="#0f172a" stroke-width="1.5" opacity="0.7"/>`;
s += drawArrow(xS + wSmall/2, y + 20, xS + wSmall/2, y + 40, '#dc2626', 2.5, 8);
s += drawArrow(xL + wLarge/2, y + 20, xL + wLarge/2, y + 40, '#10b981', 2.5, 8);
s += `<text x="${xS + wSmall/2}" y="${y + 12}" text-anchor="middle" font-family="Inter,sans-serif" font-size="11" font-weight="700" fill="#dc2626">F_1 = ${fSmall.toFixed(0)} Н</text>`;
s += `<text x="${xL + wLarge/2}" y="${y + 12}" text-anchor="middle" font-family="Inter,sans-serif" font-size="11" font-weight="700" fill="#10b981">F_2 = ${fLarge.toFixed(0)} Н</text>`;
s += `<text x="${xS + wSmall/2}" y="${y + 80}" text-anchor="middle" font-family="Inter,sans-serif" font-size="10" fill="#0f172a">S_1 = ${sSmall} см²</text>`;
s += `<text x="${xL + wLarge/2}" y="${y + 80}" text-anchor="middle" font-family="Inter,sans-serif" font-size="10" fill="#0f172a">S_2 = ${sLarge} см²</text>`;
return s;
}
function mercuryBarometer(x, y, hMm) {
hMm = hMm == null ? 760 : hMm;
const maxMm = 800, scale = 0.4;
const tubeH = maxMm * scale, tubeW = 14;
const cupY = y + tubeH + 10, cupW = 70, cupH = 26;
const colH = hMm * scale, colTop = y + tubeH - colH;
let s = '';
s += `<rect x="${x - cupW/2 + tubeW/2}" y="${cupY}" width="${cupW}" height="${cupH}" fill="#94a3b8" stroke="#0f172a" stroke-width="1.5" rx="3"/>`;
s += `<rect x="${x - cupW/2 + tubeW/2 + 3}" y="${cupY + 5}" width="${cupW - 6}" height="${cupH - 8}" fill="#475569" opacity="0.85"/>`;
s += `<rect x="${x}" y="${y}" width="${tubeW}" height="${tubeH + 12}" fill="#e0f2fe" stroke="#0f172a" stroke-width="1.5"/>`;
s += `<rect x="${x + 1}" y="${colTop}" width="${tubeW - 2}" height="${colH + 12}" fill="#475569"/>`;
s += `<text x="${x + tubeW + 8}" y="${y + 14}" font-family="Inter,sans-serif" font-size="10" font-style="italic" fill="#64748b">вакуум</text>`;
for (let mm = 0; mm <= maxMm; mm += 50) {
const ty = y + tubeH - mm * scale;
s += `<line x1="${x + tubeW}" y1="${ty}" x2="${x + tubeW + 6}" y2="${ty}" stroke="#0f172a" stroke-width="1"/>`;
if (mm % 100 === 0) s += `<text x="${x + tubeW + 10}" y="${ty + 3}" font-family="Inter,sans-serif" font-size="9" fill="#0f172a">${mm}</text>`;
}
s += `<line x1="${x - 12}" y1="${colTop}" x2="${x}" y2="${colTop}" stroke="#dc2626" stroke-width="2"/>`;
s += `<text x="${x - 14}" y="${colTop + 4}" text-anchor="end" font-family="Inter,sans-serif" font-size="11" font-weight="700" fill="#dc2626">${hMm} мм</text>`;
return s;
}
function aneroidBarometer(cx, cy, r, pressurePa) {
const pMm = pressurePa / 133.322;
const pMin = 720, pMax = 800;
const angDeg = -210 + 240 * Math.max(0, Math.min(1, (pMm - pMin) / (pMax - pMin)));
const angRad = angDeg * Math.PI / 180;
let s = '';
s += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#fff" stroke="#0f172a" stroke-width="2"/>`;
s += `<circle cx="${cx}" cy="${cy}" r="${r - 8}" fill="none" stroke="#cbd5e1" stroke-width="1"/>`;
for (let p = pMin; p <= pMax; p += 10) {
const a = (-210 + 240 * (p - pMin) / (pMax - pMin)) * Math.PI / 180;
const r1 = r - 4, r2 = r - 12;
const x1 = cx + r1 * Math.cos(a), y1 = cy + r1 * Math.sin(a);
const x2 = cx + r2 * Math.cos(a), y2 = cy + r2 * Math.sin(a);
s += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#0f172a" stroke-width="${p % 20 === 0 ? 2 : 1}"/>`;
if (p % 20 === 0) {
const rT = r - 22;
const xT = cx + rT * Math.cos(a), yT = cy + rT * Math.sin(a) + 3;
s += `<text x="${xT.toFixed(1)}" y="${yT.toFixed(1)}" text-anchor="middle" font-family="Inter,sans-serif" font-size="9" fill="#0f172a">${p}</text>`;
}
}
const tipX = cx + (r - 18) * Math.cos(angRad), tipY = cy + (r - 18) * Math.sin(angRad);
s += `<line x1="${cx}" y1="${cy}" x2="${tipX.toFixed(1)}" y2="${tipY.toFixed(1)}" stroke="#dc2626" stroke-width="3" stroke-linecap="round"/>`;
s += `<circle cx="${cx}" cy="${cy}" r="4" fill="#374151"/>`;
s += `<text x="${cx}" y="${cy + r + 16}" text-anchor="middle" font-family="Inter,sans-serif" font-size="11" font-weight="700" fill="#374151">${pMm.toFixed(0)} мм рт. ст.</text>`;
return s;
}
function uManometer(x, y, w, h, deltaH, fluidColor) {
fluidColor = fluidColor || '#0891b2';
const tube = 14;
const xL = x, xR = x + w - tube;
const baseY = y + h;
let s = `<path d="M ${xL} ${y} L ${xL} ${baseY - tube/2} Q ${xL} ${baseY} ${xL + tube/2} ${baseY} L ${xR + tube/2} ${baseY} Q ${xR + tube} ${baseY} ${xR + tube} ${baseY - tube/2} L ${xR + tube} ${y}" fill="none" stroke="#0f172a" stroke-width="1.5"/>`;
s += `<path d="M ${xL + tube} ${y} L ${xL + tube} ${baseY - tube/2 - 8}" fill="none" stroke="#0f172a" stroke-width="1.5" opacity="0.4"/>`;
s += `<path d="M ${xR} ${y} L ${xR} ${baseY - tube/2 - 8}" fill="none" stroke="#0f172a" stroke-width="1.5" opacity="0.4"/>`;
const lLevel = y + h * 0.45 + deltaH / 2;
const rLevel = y + h * 0.45 - deltaH / 2;
s += `<rect x="${xL + 1}" y="${lLevel}" width="${tube - 2}" height="${baseY - lLevel}" fill="${fluidColor}" opacity="0.85"/>`;
s += `<rect x="${xR + 1}" y="${rLevel}" width="${tube - 2}" height="${baseY - rLevel}" fill="${fluidColor}" opacity="0.85"/>`;
if (Math.abs(deltaH) > 1) {
s += `<line x1="${xR + tube + 6}" y1="${lLevel}" x2="${xR + tube + 6}" y2="${rLevel}" stroke="#dc2626" stroke-width="1.5"/>`;
s += `<text x="${xR + tube + 10}" y="${(lLevel + rLevel)/2 + 4}" font-family="Inter,sans-serif" font-size="10" font-weight="700" fill="#dc2626">Δh</text>`;
}
return s;
}
function rulerWithError(x, y, lenCm, mmPerDiv) {
mmPerDiv = mmPerDiv || 1;
const pxPerCm = 30;
const w = lenCm * pxPerCm, h = 28;
let s = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="#fef3c7" stroke="#92400e" stroke-width="1.5" rx="2"/>`;
const totalDivs = (lenCm * 10) / mmPerDiv;
for (let i = 0; i <= totalDivs; i++) {
const tx = x + (i * mmPerDiv / 10) * pxPerCm;
const mm = i * mmPerDiv;
const isCm = mm % 10 === 0, isHalfCm = mm % 5 === 0;
const tickH = isCm ? 12 : (isHalfCm ? 8 : 5);
s += `<line x1="${tx}" y1="${y}" x2="${tx}" y2="${y + tickH}" stroke="#0f172a" stroke-width="${isCm ? 1.5 : 1}"/>`;
if (isCm) s += `<text x="${tx}" y="${y + h - 4}" text-anchor="middle" font-family="Inter,sans-serif" font-size="9" font-weight="600" fill="#0f172a">${mm/10}</text>`;
}
s += `<text x="${x + w + 6}" y="${y + h/2 + 4}" font-family="Inter,sans-serif" font-size="10" font-weight="700" fill="#92400e">см</text>`;
return s;
}
function bimetal(x, y, w, h, deltaT) {
const ang = Math.max(-30, Math.min(30, deltaT * 0.3));
let s = `<g transform="translate(${x},${y}) rotate(${ang} 0 ${h/2})">`;
s += `<rect x="0" y="0" width="${w}" height="${h/2}" fill="#fbbf24" stroke="#92400e" stroke-width="1"/>`;
s += `<rect x="0" y="${h/2}" width="${w}" height="${h/2}" fill="#94a3b8" stroke="#374151" stroke-width="1"/>`;
s += `<text x="${w/2}" y="${h + 14}" text-anchor="middle" font-family="Inter,sans-serif" font-size="10" fill="#374151">ΔT = ${deltaT.toFixed(0)} °C</text>`;
s += `</g>`;
return s;
}
function expandingRod(x, y, l0, alpha, deltaT) {
const dl = l0 * alpha * deltaT * 1000;
const len = l0 + dl;
const h = 16;
const color = tempColor(deltaT, 0, 100);
let s = `<rect x="${x}" y="${y}" width="${len.toFixed(1)}" height="${h}" fill="${color}" stroke="#0f172a" stroke-width="1.5" rx="2"/>`;
s += `<line x1="${x + l0}" y1="${y - 4}" x2="${x + l0}" y2="${y + h + 4}" stroke="#0f172a" stroke-width="1" stroke-dasharray="3 2"/>`;
if (dl > 1) {
s += drawArrow(x + l0, y + h + 14, x + len, y + h + 14, '#dc2626', 1.8, 6);
s += `<text x="${x + l0 + dl/2}" y="${y + h + 28}" text-anchor="middle" font-family="Inter,sans-serif" font-size="10" font-weight="700" fill="#dc2626">Δl = ${dl.toFixed(1)} px</text>`;
}
return s;
}
// === HillSlideSim — тележка скатывается с горки (для §42, закон сохранения) ===
class HillSlideSim {
constructor(opts) {
opts = opts || {};
this.x0 = opts.x0 || 30;
this.y0 = opts.y0 || 200;
this.hStart = opts.hStart != null ? opts.hStart : 5;
this.m = opts.mass || 1;
this.g = opts.g || 9.8;
this.friction = opts.friction || 0;
this.scale = opts.scale || 30;
this.reset();
}
reset() { this.t = 0; this.x = 0; this.v = 0; this.h = this.hStart; }
step(dt) {
const gEff = this.g * (1 - this.friction);
this.t += dt;
const dropped = this.hStart - this.h;
if (this.h <= 0) { this.h = 0; this.v = Math.sqrt(2 * gEff * this.hStart); return; }
this.v = Math.sqrt(2 * gEff * Math.max(0, dropped));
this.x += this.v * dt;
const L = this.hStart * 4;
const xRel = Math.min(this.x, L) / L;
this.h = this.hStart * Math.pow(1 - xRel, 2);
}
getEnergies() {
return {
Ek: 0.5 * this.m * this.v * this.v,
Ep: this.m * this.g * this.h,
Etot: 0.5 * this.m * this.v * this.v + this.m * this.g * this.h
};
}
renderProfile() {
const L = this.hStart * 4;
const pxL = L * this.scale, pxH = this.hStart * this.scale;
const baseY = this.y0;
let path = `M ${this.x0} ${baseY - pxH}`;
const N = 40;
for (let i = 1; i <= N; i++) {
const xRel = i / N;
const yRel = Math.pow(1 - xRel, 2);
path += ` L ${(this.x0 + pxL * xRel).toFixed(1)} ${(baseY - pxH * yRel).toFixed(1)}`;
}
let s = '';
s += `<line x1="${this.x0 - 10}" y1="${baseY}" x2="${this.x0 + pxL + 30}" y2="${baseY}" stroke="#0f172a" stroke-width="2"/>`;
s += `<path d="${path} L ${this.x0 + pxL} ${baseY} L ${this.x0} ${baseY} Z" fill="#86efac" opacity="0.6" stroke="#10b981" stroke-width="1.5"/>`;
const cartX = this.x0 + this.x * this.scale;
const cartY = baseY - this.h * this.scale;
s += `<rect x="${(cartX - 10).toFixed(1)}" y="${(cartY - 14).toFixed(1)}" width="20" height="12" fill="#dc2626" stroke="#0f172a" stroke-width="1.5" rx="2"/>`;
s += `<circle cx="${(cartX - 5).toFixed(1)}" cy="${(cartY - 2).toFixed(1)}" r="3" fill="#0f172a"/>`;
s += `<circle cx="${(cartX + 5).toFixed(1)}" cy="${(cartY - 2).toFixed(1)}" r="3" fill="#0f172a"/>`;
return s;
}
}
// === PendulumSim — математический маятник (для §42) ===
class PendulumSim {
constructor(opts) {
opts = opts || {};
this.cx = opts.cx || 150;
this.cy = opts.cy || 40;
this.L = opts.length || 1.2;
this.m = opts.mass || 0.5;
this.g = opts.g || 9.8;
this.phi0 = (opts.angleDeg || 25) * Math.PI / 180;
this.scale = opts.scale || 80;
this.reset();
}
reset() { this.t = 0; this.phi = this.phi0; this.omega = 0; }
step(dt) {
const alpha = -(this.g / this.L) * Math.sin(this.phi);
this.omega += alpha * dt;
this.phi += this.omega * dt;
this.t += dt;
}
getEnergies() {
const h = this.L * (1 - Math.cos(this.phi));
const v = Math.abs(this.omega) * this.L;
return {
Ek: 0.5 * this.m * v * v,
Ep: this.m * this.g * h,
Etot: 0.5 * this.m * v * v + this.m * this.g * h
};
}
render() {
const pxL = this.L * this.scale;
const bx = this.cx + pxL * Math.sin(this.phi);
const by = this.cy + pxL * Math.cos(this.phi);
let s = '';
s += `<rect x="${this.cx - 30}" y="${this.cy - 8}" width="60" height="8" fill="#475569" stroke="#0f172a" stroke-width="1"/>`;
s += `<line x1="${this.cx}" y1="${this.cy}" x2="${bx.toFixed(1)}" y2="${by.toFixed(1)}" stroke="#0f172a" stroke-width="1.5"/>`;
s += `<circle cx="${bx.toFixed(1)}" cy="${by.toFixed(1)}" r="9" fill="#dc2626" stroke="#7f1d1d" stroke-width="1.5"/>`;
s += `<circle cx="${this.cx}" cy="${this.cy}" r="3" fill="#0f172a"/>`;
return s;
}
}
// === Экспорт ===
window.PHYS = {
tempColor: tempColor,
@@ -473,6 +786,20 @@ window.PHYS = {
atmToPa: atmToPa,
paToAtm: paToAtm,
litersToM3: litersToM3,
m3ToLiters: m3ToLiters
m3ToLiters: m3ToLiters,
// Physics 7 — Phase 0
forceVector: forceVector,
dynamometer: dynamometer,
blockOnSurface: blockOnSurface,
connectedVessels: connectedVessels,
hydraulicPress: hydraulicPress,
mercuryBarometer: mercuryBarometer,
aneroidBarometer: aneroidBarometer,
uManometer: uManometer,
rulerWithError: rulerWithError,
bimetal: bimetal,
expandingRod: expandingRod,
HillSlideSim: HillSlideSim,
PendulumSim: PendulumSim
};
})();