diff --git a/backend/scripts/redesign_p8_ch2_2.cjs b/backend/scripts/redesign_p8_ch2_2.cjs
new file mode 100644
index 0000000..def72c8
--- /dev/null
+++ b/backend/scripts/redesign_p8_ch2_2.cjs
@@ -0,0 +1,575 @@
+// 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 += '
'
+ +''
+ +'
Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.
'
+ +'
'
+ +'
'
+ +'
Phase 2.${n} — coming soon
'
+ +'
'
+ +'
';`;
+}
+
+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 += ''
+ +''
+ +'
Клик ЛКМ → добавить +заряд, клик ПКМ → добавить -заряд. Перетаскивай существующие. Стрелки показывают силы взаимодействия (закон Кулона $F = k|q_1 q_2|/r^2$).
'
+ +'
'
+ +'
'
+ +'
'
+ +'
'
+ +'
'
+ +'
Зарядов0
'
+ +'
'
+ +'
';`;
+
+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 += ''
+ +''
+ +'
Перетаскивай заряды. Силовые линии рисуются live: выходят из + и заходят в −. Густота линий = напряжённость $E$.
'
+ +'
'
+ +'
'
+ +''
+ +''
+ +''
+ +'
'
+ +'
';`;
+
+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 += ''
+ +''
+ +'
Двигай напряжение $U$ и сопротивление $R$. Ток $I = U/R$ обновляется в реальном времени. Лампочка светится ярче с ростом тока.
'
+ +'
'
+ +'
'
+ +'
'
+ +'
';`;
+
+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 += ''
+ +''
+ +'
Двигай $R_1, R_2$ — наблюдай как ток делится между ветвями ($I = I_1 + I_2$) и какое получается общее $R$.
'
+ +'
'
+ +'
'
+ +'
'
+ +'
R_общ12Ом
'
+ +'
I₁0.6А
'
+ +'
I₂0.4А
'
+ +'
'
+ +'
';`;
+
+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 += ''
+ +''
+ +'
Перетаскивай магниты. При сближении одноимённых полюсов (N-N или S-S) — отталкивание (зелёные стрелки). Разноимённых (N-S) — притяжение (красные стрелки).
'
+ +'
'
+ +'
';`;
+
+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 += ''
+ +''
+ +'
Включи ток в проводнике скрубером — стрелка компаса отклоняется. Направление поля вокруг провода определяется правилом правой руки.
'
+ +'
'
+ +'
'
+ +'
Ток0.0А
'
+ +'
Угол0°
'
+ +'
'
+ +'
';`;
+
+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(/