Files
Learn_System/backend/scripts/redesign_p8_ch2_2.cjs
Maxim Dolgolyov da6dd96aac feat(phys8 ch2): Phase 2.2 — 6 флагман-интерактивов
§12 Charge Sandbox: canvas с динамическим добавлением зарядов.
Click → +заряд (или - через кнопку), drag для перемещения,
стрелки взаимодействия по Кулону (красные=отталкивание,
зелёные=притяжение). Кнопки '+/-', 'Очистить'.

§17 Field Visualizer: drag-зарядов с live перерисовкой
силовых линий. От каждого + рисуются 16 линий, идущих
по полю E через интегрирование шагами. Линии останавливаются
у − зарядов или вылетают за canvas.

§22 Закон Ома: SVG цепь батарея + резистор + лампа.
Scrubbers U (0.5-12 В), R (1-100 Ом). I=U/R обновляется
live, яркость лампы ∝ I (glow при I>0.3).

§25 Параллельные резисторы: SVG цепь с разветвлением.
Scrubbers R₁, R₂. Live расчёт R_общ = R₁R₂/(R₁+R₂),
I₁, I₂ для каждой ветви, общий I.

§28 Магниты: canvas с 2 drag-магнитами (N-S полюса).
При сближении inner полюсов (S-N) рисуются стрелки
притяжения с величиной по F~1/d².

§30 Опыт Эрстеда: SVG провод с током (scrubber -5..+5 А)
и компас под ним. Силовые линии магн. поля вокруг провода
(концентрические штриховые круги) с opacity ∝ |I|.
Стрелка компаса отклоняется по arctan(I), угол выводится.
2026-05-30 10:17:23 +03:00

576 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 2.2 — флагман-интерактивы для критических §:
// §12 Charge sandbox, §17 Field visualizer, §22 Ohm's law,
// §25 Parallel resistors, §28 Magnet polarity, §30 Эрстед.
'use strict';
const fs = require('fs');
const path = require('path');
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch2.html');
let h = fs.readFileSync(DST, 'utf8');
function makeStubText(n) {
return `/* IV6 — flagship интерактив (заглушка Phase 2, наполнение в Phase 2.${n}) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
+'<div style="margin-top:8px;font-size:.86rem">Phase 2.${n} — coming soon</div>'
+'</div>'
+'</div>';`;
}
function replaceStub(pid, n, widgetHtml, initFn) {
const stubLF = makeStubText(n);
const stubCRLF = stubLF.replace(/\n/g, '\r\n');
let stubText = null;
if (h.includes(stubLF)) stubText = stubLF;
else if (h.includes(stubCRLF)) stubText = stubCRLF;
if (!stubText) { console.warn(`${pid}: stub not found`); return false; }
const eol = stubText === stubCRLF ? '\r\n' : '\n';
const widget = widgetHtml.trim().replace(/\n/g, eol);
h = h.replace(stubText, widget);
h = h.replace(`wireReadBtn('${pid}');`, `wireReadBtn('${pid}');\n _init${pid.toUpperCase()}_iv6();`);
const fnStart = h.indexOf(`function build_${pid}()`);
const fnEnd = h.indexOf('\n}\n', fnStart);
h = h.slice(0, fnEnd + 3) + '\n' + initFn.trim() + '\n' + h.slice(fnEnd + 3);
console.log(`${pid}: replaced`);
return true;
}
// ============================================================
// §12 — Charge sandbox: click anywhere to add charge
// ============================================================
const P12_HTML = `/* IV6 — Charge Sandbox (Phase 2.2) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Песочница зарядов — наблюдай взаимодействие</div></div>'
+'<div class="wg-help">Клик ЛКМ → добавить +заряд, клик ПКМ → добавить -заряд. Перетаскивай существующие. Стрелки показывают силы взаимодействия (закон Кулона $F = k|q_1 q_2|/r^2$).</div>'
+'<div class="p8-sandbox" id="p12-iv6-sandbox" style="height:300px"></div>'
+'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
+'<button class="btn primary" id="p12-iv6-add-pos">+ Добавить +</button>'
+'<button class="btn primary" id="p12-iv6-add-neg" style="background:#2563eb;border-color:#2563eb">+ Добавить </button>'
+'<button class="btn" id="p12-iv6-clear">Очистить</button>'
+'<div class="p8-readout"><span class="p8-readout-label">Зарядов</span><span class="p8-readout-value" id="p12-iv6-count">0</span></div>'
+'</div>'
+'</div>';`;
const P12_INIT = `
function _initP12_iv6(){
const sb = document.getElementById('p12-iv6-sandbox');
if (!sb || !window.P8Helpers || !window.P8Drag) return;
const W = 560, H = 300;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.display = 'block';
sb.appendChild(canvas);
const ctx = canvas.getContext('2d');
const charges = [];
let nextSign = 1;
function draw(){
ctx.fillStyle = '#fafafa';
ctx.fillRect(0, 0, W, H);
/* Forces between pairs */
for (let i = 0; i < charges.length; i++) {
for (let j = i + 1; j < charges.length; j++) {
const a = charges[i], b = charges[j];
const dx = b.x - a.x, dy = b.y - a.y;
const r2 = dx*dx + dy*dy;
if (r2 < 100) continue;
const r = Math.sqrt(r2);
const F = 4e6 * a.sign * b.sign / r2;
const fx = F * dx / r, fy = F * dy / r;
/* Arrow from a in direction (-fx, -fy) means: force on a from b */
const len = Math.min(80, Math.abs(F) * 5);
const dir = a.sign * b.sign > 0 ? -1 : 1;
const aex = a.x + dir * fx / Math.abs(F) * len;
const aey = a.y + dir * fy / Math.abs(F) * len;
ctx.strokeStyle = a.sign * b.sign > 0 ? '#dc2626' : '#16a34a';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(aex, aey);
ctx.stroke();
/* Arrowhead */
const ang = Math.atan2(aey - a.y, aex - a.x);
ctx.beginPath();
ctx.moveTo(aex, aey);
ctx.lineTo(aex - 7 * Math.cos(ang - 0.3), aey - 7 * Math.sin(ang - 0.3));
ctx.lineTo(aex - 7 * Math.cos(ang + 0.3), aey - 7 * Math.sin(ang + 0.3));
ctx.closePath();
ctx.fillStyle = ctx.strokeStyle;
ctx.fill();
}
}
/* Charges */
charges.forEach(c => {
const color = c.sign > 0 ? '#dc2626' : '#2563eb';
const fill = c.sign > 0 ? '#fecaca' : '#bfdbfe';
ctx.fillStyle = fill;
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(c.x, c.y, 18, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
ctx.fillStyle = color;
ctx.font = "bold 18px sans-serif";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.sign > 0 ? '+' : '', c.x, c.y + 1);
});
document.getElementById('p12-iv6-count').textContent = charges.length;
}
const drag = P8Drag.attachCanvas(canvas, {
objects: charges.map(c => ({ ...c, r: 22 })),
onPickup: c => {},
onDrag: (c, pos) => {
/* Sync back to charges by id */
const orig = charges.find(ch => ch === c || (ch.id === c.id));
if (orig) { orig.x = pos.x; orig.y = pos.y; }
draw();
},
onClick: (pos) => {
charges.push({ x: pos.x, y: pos.y, sign: nextSign, id: Date.now() + Math.random() });
drag.updateObjects(charges.map(c => ({ ...c, r: 22 })));
draw();
if (window.addXp && charges.length === 2) addXp(10, 'p12-iv6-first');
}
});
document.getElementById('p12-iv6-add-pos').onclick = () => {
nextSign = 1;
charges.push({ x: 80 + Math.random() * (W - 160), y: 80 + Math.random() * (H - 160), sign: 1, id: Date.now() + Math.random() });
drag.updateObjects(charges.map(c => ({ ...c, r: 22 })));
draw();
};
document.getElementById('p12-iv6-add-neg').onclick = () => {
nextSign = -1;
charges.push({ x: 80 + Math.random() * (W - 160), y: 80 + Math.random() * (H - 160), sign: -1, id: Date.now() + Math.random() });
drag.updateObjects(charges.map(c => ({ ...c, r: 22 })));
draw();
};
document.getElementById('p12-iv6-clear').onclick = () => {
charges.length = 0;
drag.updateObjects([]);
draw();
};
draw();
}
`;
replaceStub('p12', 12, P12_HTML, P12_INIT);
// ============================================================
// §17 — Field visualizer
// ============================================================
const P17_HTML = `/* IV6 — Field Visualizer (Phase 2.2) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Силовые линии — карта поля</div></div>'
+'<div class="wg-help">Перетаскивай заряды. Силовые линии рисуются live: выходят из + и заходят в −. Густота линий = напряжённость $E$.</div>'
+'<div class="p8-sandbox" id="p17-iv6-sandbox" style="height:320px"></div>'
+'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
+'<button class="btn" id="p17-iv6-add-pos">+ Заряд</button>'
+'<button class="btn" id="p17-iv6-add-neg"> Заряд</button>'
+'<button class="btn" id="p17-iv6-clear">Сброс</button>'
+'</div>'
+'</div>';`;
const P17_INIT = `
function _initP17_iv6(){
const sb = document.getElementById('p17-iv6-sandbox');
if (!sb || !window.P8Drag) return;
const W = 560, H = 320;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.display = 'block';
sb.appendChild(canvas);
const ctx = canvas.getContext('2d');
let charges = [
{ x: 200, y: 160, sign: 1, r: 22 },
{ x: 360, y: 160, sign: -1, r: 22 }
];
function E(x, y) {
let ex = 0, ey = 0;
charges.forEach(c => {
const dx = x - c.x, dy = y - c.y;
const r2 = dx*dx + dy*dy;
if (r2 < 200) return;
const r = Math.sqrt(r2);
const k = 5000 * c.sign / r2;
ex += k * dx / r; ey += k * dy / r;
});
return { ex, ey, mag: Math.sqrt(ex*ex + ey*ey) };
}
function draw(){
ctx.fillStyle = '#fafafa';
ctx.fillRect(0, 0, W, H);
/* Draw field lines starting from + charges */
charges.filter(c => c.sign > 0).forEach(c => {
for (let i = 0; i < 16; i++) {
const a = i * 2 * Math.PI / 16;
let x = c.x + 25 * Math.cos(a);
let y = c.y + 25 * Math.sin(a);
ctx.strokeStyle = '#dc2626';
ctx.lineWidth = 1.2;
ctx.globalAlpha = 0.75;
ctx.beginPath();
ctx.moveTo(x, y);
for (let step = 0; step < 200; step++) {
const e = E(x, y);
if (e.mag < 0.01) break;
const dx = e.ex / e.mag * 3;
const dy = e.ey / e.mag * 3;
x += dx; y += dy;
if (x < 0 || x > W || y < 0 || y > H) break;
/* Stop near - charge */
let nearNeg = false;
for (const neg of charges) {
if (neg.sign < 0 && (x - neg.x)**2 + (y - neg.y)**2 < 600) { nearNeg = true; break; }
}
ctx.lineTo(x, y);
if (nearNeg) break;
}
ctx.stroke();
ctx.globalAlpha = 1;
}
});
/* Charges */
charges.forEach(c => {
const color = c.sign > 0 ? '#dc2626' : '#2563eb';
const fill = c.sign > 0 ? '#fecaca' : '#bfdbfe';
ctx.fillStyle = fill;
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(c.x, c.y, 20, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
ctx.fillStyle = color;
ctx.font = "bold 20px sans-serif";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.sign > 0 ? '+' : '', c.x, c.y + 1);
});
}
const drag = P8Drag.attachCanvas(canvas, {
objects: charges,
onDrag: () => draw()
});
document.getElementById('p17-iv6-add-pos').onclick = () => {
charges.push({ x: 100 + Math.random() * (W - 200), y: 80 + Math.random() * (H - 160), sign: 1, r: 22 });
drag.updateObjects(charges);
draw();
};
document.getElementById('p17-iv6-add-neg').onclick = () => {
charges.push({ x: 100 + Math.random() * (W - 200), y: 80 + Math.random() * (H - 160), sign: -1, r: 22 });
drag.updateObjects(charges);
draw();
};
document.getElementById('p17-iv6-clear').onclick = () => {
charges.length = 0;
charges.push({ x: 200, y: 160, sign: 1, r: 22 }, { x: 360, y: 160, sign: -1, r: 22 });
drag.updateObjects(charges);
draw();
};
draw();
}
`;
replaceStub('p17', 17, P17_HTML, P17_INIT);
// ============================================================
// §22 — Ohm's law sandbox
// ============================================================
const P22_HTML = `/* IV6 — Ohm's Law (Phase 2.2) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Закон Ома: $I = U/R$</div></div>'
+'<div class="wg-help">Двигай напряжение $U$ и сопротивление $R$. Ток $I = U/R$ обновляется в реальном времени. Лампочка светится ярче с ростом тока.</div>'
+'<div class="p8-sandbox" id="p22-iv6-sandbox" style="height:220px"></div>'
+'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px">'
+'<div class="p8-scrubber"><span class="p8-scrubber-label">U</span><input type="range" id="p22-iv6-u" min="0.5" max="12" step="0.1" value="6"><span class="p8-scrubber-value"><span id="p22-iv6-u-val">6.0</span><span class="p8-unit">В</span></span></div>'
+'<div class="p8-scrubber"><span class="p8-scrubber-label">R</span><input type="range" id="p22-iv6-r" min="1" max="100" step="1" value="12"><span class="p8-scrubber-value"><span id="p22-iv6-r-val">12</span><span class="p8-unit">Ом</span></span></div>'
+'</div>'
+'<div style="margin-top:8px"><div class="p8-readout"><span class="p8-readout-label">I = U/R</span><span class="p8-readout-value" id="p22-iv6-i">0.50</span><span class="p8-readout-unit">А</span></div></div>'
+'</div>';`;
const P22_INIT = `
function _initP22_iv6(){
const sb = document.getElementById('p22-iv6-sandbox');
if (!sb || !window.P8Helpers) return;
const svg = P8Helpers.svg.create(560, 220);
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
sb.appendChild(svg);
let U = 6, R = 12;
function render(){
svg.innerHTML = '';
const I = U / R;
/* Circuit */
/* Battery */
svg.appendChild(P8Helpers.em.circuitComponent('battery', 120, 110, 'h', U+' В'));
/* Resistor */
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 280, 110, 'h', R+' Ом'));
/* Lamp (brightness varies with I) */
const lampG = P8Helpers.svg.el('g', { transform: 'translate(440, 110)' });
const brightness = Math.min(1, I / 1.5);
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 26, fill: '#fef3c7', opacity: brightness * 0.6 + 0.1 }));
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 16, fill: '#fef3c7', stroke: '#0f172a', 'stroke-width': 2 }));
if (brightness > 0.3) {
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 30, fill: 'none', stroke: '#facc15', 'stroke-width': 3, opacity: brightness }));
}
lampG.appendChild(P8Helpers.svg.el('line', { x1: -10, y1: -10, x2: 10, y2: 10, stroke: '#0f172a', 'stroke-width': 1.5 }));
lampG.appendChild(P8Helpers.svg.el('line', { x1: -10, y1: 10, x2: 10, y2: -10, stroke: '#0f172a', 'stroke-width': 1.5 }));
svg.appendChild(lampG);
/* Connect wires */
svg.appendChild(P8Helpers.svg.el('line', { x1: 150, y1: 110, x2: 250, y2: 110, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 310, y1: 110, x2: 414, y2: 110, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 466, y1: 110, x2: 510, y2: 110, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 110, x2: 510, y2: 170, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 90, y1: 110, x2: 90, y2: 170, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 90, y1: 170, x2: 510, y2: 170, stroke: '#0f172a', 'stroke-width': 2 }));
/* Current label */
svg.appendChild(P8Helpers.svg.el('text', { x: 300, y: 195, 'font-family':"'JetBrains Mono',monospace", 'font-size':14, 'font-weight':800, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I = '+I.toFixed(2)+' А' }));
document.getElementById('p22-iv6-i').textContent = I.toFixed(2);
}
document.getElementById('p22-iv6-u').oninput = ev => { U = +ev.target.value; document.getElementById('p22-iv6-u-val').textContent = U.toFixed(1); render(); };
document.getElementById('p22-iv6-r').oninput = ev => { R = +ev.target.value; document.getElementById('p22-iv6-r-val').textContent = R; render(); };
render();
}
`;
replaceStub('p22', 22, P22_HTML, P22_INIT);
// ============================================================
// §25 — Parallel resistors
// ============================================================
const P25_HTML = `/* IV6 — Parallel resistors (Phase 2.2) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Параллельные резисторы: $1/R = 1/R_1 + 1/R_2$</div></div>'
+'<div class="wg-help">Двигай $R_1, R_2$ — наблюдай как ток делится между ветвями ($I = I_1 + I_2$) и какое получается общее $R$.</div>'
+'<div class="p8-sandbox" id="p25-iv6-sandbox" style="height:240px"></div>'
+'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px">'
+'<div class="p8-scrubber"><span class="p8-scrubber-label">R₁</span><input type="range" id="p25-iv6-r1" min="1" max="100" step="1" value="20"><span class="p8-scrubber-value"><span id="p25-iv6-r1-val">20</span><span class="p8-unit">Ом</span></span></div>'
+'<div class="p8-scrubber"><span class="p8-scrubber-label">R₂</span><input type="range" id="p25-iv6-r2" min="1" max="100" step="1" value="30"><span class="p8-scrubber-value"><span id="p25-iv6-r2-val">30</span><span class="p8-unit">Ом</span></span></div>'
+'</div>'
+'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap">'
+'<div class="p8-readout"><span class="p8-readout-label">R_общ</span><span class="p8-readout-value" id="p25-iv6-r">12</span><span class="p8-readout-unit">Ом</span></div>'
+'<div class="p8-readout"><span class="p8-readout-label">I₁</span><span class="p8-readout-value" id="p25-iv6-i1">0.6</span><span class="p8-readout-unit">А</span></div>'
+'<div class="p8-readout"><span class="p8-readout-label">I₂</span><span class="p8-readout-value" id="p25-iv6-i2">0.4</span><span class="p8-readout-unit">А</span></div>'
+'</div>'
+'</div>';`;
const P25_INIT = `
function _initP25_iv6(){
const sb = document.getElementById('p25-iv6-sandbox');
if (!sb || !window.P8Helpers) return;
const svg = P8Helpers.svg.create(560, 240);
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
sb.appendChild(svg);
const U = 12;
let R1 = 20, R2 = 30;
function render(){
svg.innerHTML = '';
const R = 1 / (1/R1 + 1/R2);
const I1 = U / R1, I2 = U / R2, I = I1 + I2;
/* Battery left */
svg.appendChild(P8Helpers.em.circuitComponent('battery', 80, 120, 'h', U+' В'));
/* Branch split */
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 120, x2: 200, y2: 120, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 60, x2: 200, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 60, x2: 380, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 60, x2: 290, y2: 60, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 320, y1: 60, x2: 380, y2: 60, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 180, x2: 290, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 320, y1: 180, x2: 380, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
/* R1 (top) */
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 305, 60, 'h', R1+' Ом'));
/* R2 (bottom) */
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 305, 180, 'h', R2+' Ом'));
/* Right wire */
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 120, x2: 510, y2: 120, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 120, x2: 510, y2: 210, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 120, x2: 50, y2: 210, stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 210, x2: 510, y2: 210, stroke: '#0f172a', 'stroke-width': 2 }));
/* Current labels */
svg.appendChild(P8Helpers.svg.el('text', { x: 290, y: 48, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I₁ = '+I1.toFixed(2)+' А' }));
svg.appendChild(P8Helpers.svg.el('text', { x: 290, y: 218, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I₂ = '+I2.toFixed(2)+' А' }));
svg.appendChild(P8Helpers.svg.el('text', { x: 150, y: 138, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#dc2626', 'text-anchor':'middle', text: 'I = '+I.toFixed(2)+' А' }));
document.getElementById('p25-iv6-r').textContent = R.toFixed(1);
document.getElementById('p25-iv6-i1').textContent = I1.toFixed(2);
document.getElementById('p25-iv6-i2').textContent = I2.toFixed(2);
}
document.getElementById('p25-iv6-r1').oninput = ev => { R1 = +ev.target.value; document.getElementById('p25-iv6-r1-val').textContent = R1; render(); };
document.getElementById('p25-iv6-r2').oninput = ev => { R2 = +ev.target.value; document.getElementById('p25-iv6-r2-val').textContent = R2; render(); };
render();
}
`;
replaceStub('p25', 25, P25_HTML, P25_INIT);
// ============================================================
// §28 — Magnet polarity demo
// ============================================================
const P28_HTML = `/* IV6 — Magnet polarity (Phase 2.2) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Магниты: разноимённые притягиваются</div></div>'
+'<div class="wg-help">Перетаскивай магниты. При сближении одноимённых полюсов (N-N или S-S) — отталкивание (зелёные стрелки). Разноимённых (N-S) — притяжение (красные стрелки).</div>'
+'<div class="p8-sandbox" id="p28-iv6-sandbox" style="height:240px"></div>'
+'</div>';`;
const P28_INIT = `
function _initP28_iv6(){
const sb = document.getElementById('p28-iv6-sandbox');
if (!sb || !window.P8Drag) return;
const W = 560, H = 240;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
canvas.style.width='100%'; canvas.style.height='100%'; canvas.style.display='block';
sb.appendChild(canvas);
const ctx = canvas.getContext('2d');
const magnets = [
{ x: 140, y: 120, angle: 0, r: 50 },
{ x: 420, y: 120, angle: 0, r: 50 }
];
function drawMagnet(m){
const w = 100, h = 32;
ctx.save();
ctx.translate(m.x, m.y);
ctx.rotate(m.angle);
/* N half (red) */
ctx.fillStyle = '#dc2626';
ctx.fillRect(-w/2, -h/2, w/2, h);
/* S half (blue) */
ctx.fillStyle = '#2563eb';
ctx.fillRect(0, -h/2, w/2, h);
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 2;
ctx.strokeRect(-w/2, -h/2, w, h);
ctx.fillStyle = '#fff';
ctx.font = "bold 18px sans-serif";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', -w/4, 0);
ctx.fillText('S', w/4, 0);
ctx.restore();
}
function draw(){
ctx.fillStyle = '#fafafa';
ctx.fillRect(0, 0, W, H);
/* Compute interaction between the two magnets — their inner poles */
/* Magnet 1: right side is S (blue, at +50), Magnet 2: left side is N (red, at -50) */
const m1S_x = magnets[0].x + 50 * Math.cos(magnets[0].angle);
const m1S_y = magnets[0].y + 50 * Math.sin(magnets[0].angle);
const m2N_x = magnets[1].x - 50 * Math.cos(magnets[1].angle);
const m2N_y = magnets[1].y - 50 * Math.sin(magnets[1].angle);
const dx = m2N_x - m1S_x;
const dy = m2N_y - m1S_y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 250 && dist > 30) {
/* N-S → attraction */
const F = 5000 / (dist * dist);
const ux = dx / dist, uy = dy / dist;
const len = Math.min(50, F * 50);
const color = '#dc2626';
/* Arrow 1 from m1S toward m2N */
ctx.strokeStyle = color; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(m1S_x, m1S_y);
ctx.lineTo(m1S_x + ux * len, m1S_y + uy * len);
ctx.stroke();
/* Arrow 2 from m2N back */
ctx.beginPath();
ctx.moveTo(m2N_x, m2N_y);
ctx.lineTo(m2N_x - ux * len, m2N_y - uy * len);
ctx.stroke();
ctx.fillStyle = color;
ctx.font = "bold 12px sans-serif";
ctx.textAlign = 'center';
ctx.fillText('притяжение', (m1S_x + m2N_x)/2, (m1S_y + m2N_y)/2 - 12);
}
magnets.forEach(drawMagnet);
}
/* Drag */
const dragObjs = magnets.map((m, i) => ({ x: m.x, y: m.y, r: 50, idx: i }));
const drag = P8Drag.attachCanvas(canvas, {
objects: dragObjs,
onDrag: (obj, pos) => {
magnets[obj.idx].x = pos.x;
magnets[obj.idx].y = pos.y;
draw();
}
});
draw();
}
`;
replaceStub('p28', 28, P28_HTML, P28_INIT);
// ============================================================
// §30 — Эрстед: wire + compass
// ============================================================
const P30_HTML = `/* IV6 — Эрстед (Phase 2.2) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Опыт Эрстеда: ток отклоняет стрелку</div></div>'
+'<div class="wg-help">Включи ток в проводнике скрубером — стрелка компаса отклоняется. Направление поля вокруг провода определяется правилом правой руки.</div>'
+'<div class="p8-sandbox" id="p30-iv6-sandbox" style="height:240px"></div>'
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap">'
+'<div class="p8-scrubber" style="flex:1;min-width:240px"><span class="p8-scrubber-label">Ток</span><input type="range" id="p30-iv6-i" min="-5" max="5" step="0.1" value="0"><span class="p8-scrubber-value"><span id="p30-iv6-i-val">0.0</span><span class="p8-unit">А</span></span></div>'
+'<div class="p8-readout"><span class="p8-readout-label">Угол</span><span class="p8-readout-value" id="p30-iv6-ang">0</span><span class="p8-readout-unit">°</span></div>'
+'</div>'
+'</div>';`;
const P30_INIT = `
function _initP30_iv6(){
const sb = document.getElementById('p30-iv6-sandbox');
if (!sb || !window.P8Helpers) return;
const svg = P8Helpers.svg.create(560, 240);
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
sb.appendChild(svg);
let I = 0;
function render(){
svg.innerHTML = '';
/* Wire (horizontal) */
svg.appendChild(P8Helpers.svg.el('line', { x1: 40, y1: 120, x2: 520, y2: 120, stroke: '#0f172a', 'stroke-width': 5 }));
/* Current arrow direction */
if (Math.abs(I) > 0.05) {
const dir = I > 0 ? 1 : -1;
const arrowX = 320;
svg.appendChild(P8Helpers.svg.el('polygon', {
points: dir > 0 ? (arrowX+8)+',120 '+(arrowX-12)+',114 '+(arrowX-12)+',126' : (arrowX-8)+',120 '+(arrowX+12)+',114 '+(arrowX+12)+',126',
fill: '#dc2626'
}));
svg.appendChild(P8Helpers.svg.el('text', { x: 100, y: 110, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#dc2626', text: 'I = '+I.toFixed(1)+' А' }));
}
/* Field lines around wire (concentric circles) */
const intensity = Math.abs(I) / 5;
if (intensity > 0.05) {
[30, 50, 70, 90].forEach((r, i) => {
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 120, r, fill: 'none', stroke: '#7c3aed', 'stroke-width': 1.5, opacity: intensity * (1 - i * 0.15), 'stroke-dasharray': '5 3' }));
});
}
/* Compass below wire (initially N up = 0°) */
const angle = Math.atan2(0, 1) * 180 / Math.PI; /* baseline */
/* Angle deflection ∝ I (sign determines direction) */
const deflection = Math.atan(I * 0.5) * 60; /* approx */
/* Compass body */
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 195, r: 28, fill: '#fff', stroke: '#0f172a', 'stroke-width': 2 }));
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 172, 'font-family':"'Unbounded',sans-serif", 'font-size':10, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'N' }));
/* Needle */
const needleG = P8Helpers.svg.el('g', { transform: 'translate(280, 195) rotate('+deflection+')' });
needleG.appendChild(P8Helpers.svg.el('polygon', { points: '-2,-22 2,-22 0,-2', fill: '#dc2626' }));
needleG.appendChild(P8Helpers.svg.el('polygon', { points: '-2,22 2,22 0,2', fill: '#475569' }));
needleG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 3, fill: '#0f172a' }));
svg.appendChild(needleG);
document.getElementById('p30-iv6-ang').textContent = Math.round(deflection);
}
document.getElementById('p30-iv6-i').oninput = ev => { I = +ev.target.value; document.getElementById('p30-iv6-i-val').textContent = I.toFixed(1); render(); };
render();
}
`;
replaceStub('p30', 30, P30_HTML, P30_INIT);
fs.writeFileSync(DST, h);
console.log('ch2 size:', h.length);
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
for (const m of scripts) {
try { new Function(m[1]); }
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 200)); process.exit(1); }
}
console.log('inline JS parses OK');
const fns = [...h.matchAll(/function build_p(\d+)\(\)/g)].map(m => parseInt(m[1]));
console.log('Builders:', fns.length, fns);