From eaee79dc8a76ffb609b20aa2560a17efd030d18a Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 10:03:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(phys8=20ch1):=20Phase=201.2=20=E2=80=94=20?= =?UTF-8?q?IV-6=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D1=8B=20=C2=A73,=20=C2=A76,=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменены stub'ы 'coming soon' на полноценные drag-and-drop виджеты: §3 Тепловая лавочка (Heat Conductor Bench): - SVG-sandbox 560×300 с горелкой (drop zone) и 4 стержнями (медь λ=400, серебро λ=430, стекло λ=0.8, дерево λ=0.15). - P8Drag.attach на каждый стержень → drop на горелку. - При drop'е sim запускается: P8Anim.raf обновляет цвет каждого сегмента стержня через P8Helpers.thermal.tempColor() по log-нормализованной λ. Тепловая волна идёт по стержню. - Readouts: материал, λ, T дальнего конца. §6 Heat Mixer (Q=cmΔT): - 2 ёмкости (m₁, T₁), (m₂, T₂) — рисуются с цветом по T. - 4 scrubber'a (m₁, T₁, m₂, T₂) с live update SVG. - Кнопка 'Смешать' → tween анимация в 1.2 с → итоговая T через формулу теплового баланса (m₁T₁+m₂T₂)/(m₁+m₂). - Readout T_итог, кнопка 'Сброс'. §8 График плавления (Phase Diagram T(t)): - T-t график 560×280 с осями (-20 до 120°C, 0 до 300 с). - Фазовые области: лёд (синий), вода (голубой), пар (жёлтый). - Реальная симуляция: c_льда=2100, c_воды=4200, λ=330000, r=2300000. P8Anim.raf вычисляет накопление энергии и фазовые переходы — плато на 0°C (плавление) и 100°C (кипение). - Scrubber мощности 100-2000 Вт. Кнопки Старт/Сброс. - Readouts: фаза, T. +10 XP за каждое успешное взаимодействие. --- backend/scripts/redesign_p8_ch1_2.cjs | 439 +++++++ frontend/textbooks/physics_8_ch1.html | 1676 ++++++------------------- 2 files changed, 806 insertions(+), 1309 deletions(-) create mode 100644 backend/scripts/redesign_p8_ch1_2.cjs diff --git a/backend/scripts/redesign_p8_ch1_2.cjs b/backend/scripts/redesign_p8_ch1_2.cjs new file mode 100644 index 0000000..7bc90bf --- /dev/null +++ b/backend/scripts/redesign_p8_ch1_2.cjs @@ -0,0 +1,439 @@ +// Phase 1.2 — Заменяет IV-6 stubs в §3, §6, §8 на полноценные интерактивы: +// §3 Heat Conductor Bench — drag-стержни разных материалов +// §6 Heat Mixer — drag двух ёмкостей с T1, T2 → T_итог +// §8 Phase Diagram T(t) — анимированный график плавления льда +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch1.html'); +let h = fs.readFileSync(DST, 'utf8'); + +// === Замена stub'а для одного § на реальный виджет + init === +function replaceStub(pid, widgetHtml, initFn) { + const stubMatch = h.match(new RegExp( + `\\/\\* IV6 — flagship интерактив[^\\n]*\\)[^\\n]*\\*\\/[\\s\\S]*?\\+'';\\s*\\n\\s*box\\.innerHTML = h \\+ secNavFor\\('${pid}'\\)` + )); + if (!stubMatch) { console.warn(`${pid}: stub not found`); return false; } + const stubText = stubMatch[0]; + // Replace stub HTML portion (everything before the box.innerHTML line) + const lastBox = stubText.lastIndexOf("box.innerHTML"); + const stubHtmlPart = stubText.slice(0, lastBox).trimEnd(); + const newStubHtml = widgetHtml.trim() + '\n\n '; + h = h.replace(stubHtmlPart, newStubHtml); + // Add init call after wireReadBtn + h = h.replace(`wireReadBtn('${pid}');`, `wireReadBtn('${pid}');\n _init${pid.toUpperCase()}_iv6();`); + // Append init function after build_pN + 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 stub with real IV-6`); + return true; +} + +// ===================================================================== +// §3 — Heat Conductor Bench +// ===================================================================== +const P3_HTML = ` + /* IV6 — Heat Conductor Bench (Phase 1.2) */ + h += '
' + +'
IV-6
Тепловая лавочка — какой материал быстрее проводит тепло?
' + +'
Перетащи один из стержней (медь, дерево, стекло, серебро) на горелку. Цветовая карта покажет, как тепло движется по стержню. Чем больше λ — тем быстрее.
' + +'
' + +'
' + +'
Материал
' + +'
λВт/(м·К)
' + +'
T дальнего конца°C
' + +'
' + +'
'; +`; + +const P3_INIT = ` +function _initP3_iv6(){ + const sb = document.getElementById('p3-iv6-sandbox'); + if (!sb || !window.P8Helpers || !window.P8Drag || !window.P8Anim) return; + const svg = P8Helpers.svg.create(560, 300); + svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block'; + sb.appendChild(svg); + /* Горелка (drop zone) */ + const burner = P8Helpers.svg.el('g', { transform: 'translate(80, 240)' }); + burner.appendChild(P8Helpers.svg.el('rect', { x:-32, y:-8, width:64, height:32, rx:4, fill:'#475569' })); + burner.appendChild(P8Helpers.svg.el('rect', { x:-26, y:-22, width:52, height:14, rx:7, fill:'#dc2626' })); + burner.appendChild(P8Helpers.svg.el('text', { x:0, y:48, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#475569', 'text-anchor':'middle', text:'Горелка (drop)' })); + svg.appendChild(burner); + /* Палитра 4 стержней */ + const rods = [ + { name:'Медь', lam:400, color:'#b45309', x:200, y:50 }, + { name:'Серебро', lam:430, color:'#9ca3af', x:300, y:50 }, + { name:'Стекло', lam:0.8, color:'#bae6fd', x:400, y:50 }, + { name:'Дерево', lam:0.15,color:'#a16207', x:500, y:50 } + ]; + const rodEls = []; + rods.forEach((rod, i) => { + const g = P8Helpers.svg.el('g', { transform: 'translate('+rod.x+','+rod.y+')' }); + /* Sections of rod, each will be colored by T gradient when active */ + const segments = 12; + const segs = []; + for (let s = 0; s < segments; s++) { + const r = P8Helpers.svg.el('rect', { + x: -55 + s * (110/segments), y: -10, width: 110/segments, height: 20, + fill: rod.color, stroke: 'none' + }); + g.appendChild(r); + segs.push(r); + } + /* Frame */ + g.appendChild(P8Helpers.svg.el('rect', { x:-55, y:-10, width:110, height:20, rx:3, fill:'none', stroke:'#0f172a', 'stroke-width':1.5 })); + g.appendChild(P8Helpers.svg.el('text', { x:0, y:-18, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: rod.name })); + g.appendChild(P8Helpers.svg.el('text', { x:0, y:30, 'font-family':"'JetBrains Mono',monospace", 'font-size':9, 'font-weight':600, fill:'var(--p8-muted, #64748b)', 'text-anchor':'middle', text: 'λ='+rod.lam })); + svg.appendChild(g); + rodEls.push({ rod, g, segs, x: rod.x, y: rod.y }); + }); + /* Active sim state */ + let activeIdx = -1; + let simLoop = null; + let simTime = 0; + const matEl = document.getElementById('p3-iv6-mat'); + const lamEl = document.getElementById('p3-iv6-lam'); + const tendEl = document.getElementById('p3-iv6-tend'); + function resetColors(rodObj){ + rodObj.segs.forEach(s => s.setAttribute('fill', rodObj.rod.color)); + } + function startSim(rodObj){ + if (simLoop) simLoop.stop(); + simTime = 0; + /* λ нормализованный 0..1: log scale (15 -> 430) */ + const lamNorm = Math.min(1, Math.log10(rodObj.rod.lam + 1) / Math.log10(500)); + simLoop = P8Anim.raf((dt, t) => { + simTime += dt; + /* Diffusion-like: каждый сегмент i прогревается со скоростью lamNorm */ + const speed = lamNorm * 0.8 + 0.04; + rodObj.segs.forEach((seg, i) => { + const pos = i / (rodObj.segs.length - 1); + const wave = speed * simTime; + const heat = Math.max(0, Math.min(1, wave - pos)); + seg.setAttribute('fill', P8Helpers.thermal.tempColor(heat * 0.85 + 0.1)); + }); + /* T-end value */ + const endHeat = Math.max(0, Math.min(1, speed * simTime - 0.95)); + const tEnd = Math.round(20 + endHeat * 80); + if (tendEl) tendEl.textContent = tEnd; + if (simTime > 30) simLoop.stop(); + }); + simLoop.start(); + if (matEl) matEl.textContent = rodObj.rod.name; + if (lamEl) lamEl.textContent = rodObj.rod.lam; + } + /* Attach drag to each rod */ + rodEls.forEach((rodObj, i) => { + P8Drag.attach(rodObj.g, { + container: svg, + onMove: (ev, pos) => { + rodObj.x = pos.x; + rodObj.y = pos.y; + rodObj.g.setAttribute('transform', 'translate('+rodObj.x+','+rodObj.y+')'); + }, + onEnd: (ev, pos) => { + /* Check if dropped near burner */ + if (Math.abs(pos.x - 80) < 70 && Math.abs(pos.y - 240) < 50) { + /* Snap to position above burner */ + P8Anim.tween({ + from: 0, to: 1, duration: 320, easing: 'cubicOut', + onUpdate: k => { + rodObj.x = pos.x + (80 + 55 - pos.x) * k; + rodObj.y = pos.y + (220 - pos.y) * k; + rodObj.g.setAttribute('transform', 'translate('+rodObj.x+','+rodObj.y+')'); + } + }); + /* Reset other rods to original */ + rodEls.forEach((other, j) => { + if (j === i) return; + resetColors(other); + }); + activeIdx = i; + startSim(rodObj); + if (window.addXp) addXp(10, 'p3-iv6-conduct'); + } + } + }); + }); + /* Help text */ + svg.appendChild(P8Helpers.svg.el('text', { + x: 280, y: 290, + 'font-family': "'Inter', sans-serif", 'font-size': 10, + fill: 'var(--p8-muted, #64748b)', 'text-anchor': 'middle', + text: 'Перетащи стержень на горелку • Чем выше λ — тем быстрее цвет дойдёт до конца' + })); +} +`; +replaceStub('p3', P3_HTML, P3_INIT); + +// ===================================================================== +// §6 — Heat Mixer (Q = cmΔT) +// ===================================================================== +const P6_HTML = ` + /* IV6 — Heat Mixer (Phase 1.2) */ + h += '
' + +'
IV-6
Смесь двух жидкостей — рассчитай конечную T
' + +'
Перетащи ёмкости друг к другу — они смешаются. Масса и начальная температура каждой — на скрубберах ниже. Конечная температура считается по уравнению теплового баланса $c m_1 (T_1 - T) = c m_2 (T - T_2)$.
' + +'
' + +'
' + +'
m₁0.5кг
' + +'
T₁80°C
' + +'
m₂1.0кг
' + +'
T₂20°C
' + +'
' + +'
' + +'
T_итог°C
' + +'' + +'' + +'
' + +'
'; +`; + +const P6_INIT = ` +function _initP6_iv6(){ + const sb = document.getElementById('p6-iv6-sandbox'); + if (!sb || !window.P8Helpers || !window.P8Anim) return; + const svg = P8Helpers.svg.create(560, 240); + svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block'; + sb.appendChild(svg); + /* Vessel positions (will animate to centre on mix) */ + const v1 = { x: 140, y: 130, m: 0.5, T: 80, color: '#fb923c' }; + const v2 = { x: 420, y: 130, m: 1.0, T: 20, color: '#7dd3fc' }; + const finalState = { active: false, T: 50, fillFraction: 0.5 }; + function drawVessel(x, y, m, T, color){ + const g = P8Helpers.svg.el('g', { transform: 'translate('+x+','+y+')' }); + const h = 30 + m * 50; + const w = 70; + /* Glass */ + g.appendChild(P8Helpers.svg.el('rect', { x:-w/2, y:-h, width:w, height:h, rx:6, fill:'rgba(255,255,255,.6)', stroke:'#0f172a', 'stroke-width':1.5 })); + /* Liquid */ + g.appendChild(P8Helpers.svg.el('rect', { x:-w/2+3, y:-h+5, width:w-6, height:h-8, rx:4, fill: P8Helpers.thermal.tempColor(T/100) })); + /* Label */ + g.appendChild(P8Helpers.svg.el('text', { x:0, y:18, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'm='+m.toFixed(1)+' кг' })); + g.appendChild(P8Helpers.svg.el('text', { x:0, y:32, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'T='+Math.round(T)+'°C' })); + return g; + } + let v1G = drawVessel(v1.x, v1.y, v1.m, v1.T, v1.color); + let v2G = drawVessel(v2.x, v2.y, v2.m, v2.T, v2.color); + svg.appendChild(v1G); svg.appendChild(v2G); + function redraw(){ + svg.innerHTML = ''; + if (!finalState.active) { + v1G = drawVessel(v1.x, v1.y, v1.m, v1.T, v1.color); + v2G = drawVessel(v2.x, v2.y, v2.m, v2.T, v2.color); + svg.appendChild(v1G); svg.appendChild(v2G); + } else { + /* Single combined vessel */ + const cv = drawVessel(280, 130, v1.m + v2.m, finalState.T, P8Helpers.thermal.tempColor(finalState.T/100)); + svg.appendChild(cv); + /* Result label */ + svg.appendChild(P8Helpers.svg.el('text', { x:280, y:60, 'font-family':"'Unbounded',sans-serif", 'font-size':14, 'font-weight':800, fill:'var(--th-mid,#f97316)', 'text-anchor':'middle', text: 'T_итог = '+Math.round(finalState.T)+' °C' })); + } + } + /* Hook up scrubbers */ + function bindScrub(inputId, valId, obj, prop){ + const input = document.getElementById(inputId); + const lab = document.getElementById(valId); + if (!input || !lab) return; + input.addEventListener('input', () => { + const v = parseFloat(input.value); + obj[prop] = v; + lab.textContent = v.toFixed(prop === 'm' ? 1 : 0); + if (finalState.active) { + finalState.active = false; + document.getElementById('p6-iv6-tf').textContent = '—'; + } + redraw(); + }); + } + bindScrub('p6-iv6-m1', 'p6-iv6-m1-val', v1, 'm'); + bindScrub('p6-iv6-t1', 'p6-iv6-t1-val', v1, 'T'); + bindScrub('p6-iv6-m2', 'p6-iv6-m2-val', v2, 'm'); + bindScrub('p6-iv6-t2', 'p6-iv6-t2-val', v2, 'T'); + /* Mix button */ + document.getElementById('p6-iv6-mix').onclick = () => { + const T = (v1.m * v1.T + v2.m * v2.T) / (v1.m + v2.m); + finalState.active = true; + finalState.T = T; + P8Anim.tween({ + from: v1.T, to: T, duration: 1200, easing: 'cubicInOut', + onUpdate: t => { + finalState.T = t; + redraw(); + document.getElementById('p6-iv6-tf').textContent = Math.round(t); + } + }); + if (window.addXp) addXp(10, 'p6-iv6-mix'); + }; + document.getElementById('p6-iv6-reset').onclick = () => { + finalState.active = false; + document.getElementById('p6-iv6-tf').textContent = '—'; + redraw(); + }; + redraw(); +} +`; +replaceStub('p6', P6_HTML, P6_INIT); + +// ===================================================================== +// §8 — Phase Diagram T(t) +// ===================================================================== +const P8_HTML = ` + /* IV6 — Phase Diagram T(t) (Phase 1.2) */ + h += '
' + +'
IV-6
График плавления — почему T не растёт?
' + +'
Запусти нагрев льда и наблюдай за графиком T(t). При плавлении энергия идёт на разрушение кристаллической решётки — T держится постоянной (плато при 0°C). Двигай ползунок мощности нагревателя — крутизна меняется.
' + +'
' + +'
' + +'
Мощность500Вт
' + +'
Фазалёд
' + +'
T-20°C
' + +'' + +'' + +'
' + +'
'; +`; + +const P8_INIT = ` +function _initP8_iv6(){ + const sb = document.getElementById('p8-iv6-sandbox'); + if (!sb || !window.P8Helpers || !window.P8Anim) return; + const W = 560, H = 280; + const svg = P8Helpers.svg.create(W, H); + svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block'; + sb.appendChild(svg); + /* Sim state */ + const m = 0.5; // kg ice + const c_ice = 2100; + const c_water = 4200; + const lambda = 330000; + const r_vap = 2300000; + let power = 500; + let energyAccumulated = 0; + let running = false; + let raf = null; + let points = [{ t: 0, T: -20 }]; + /* Axes */ + const pad = { l: 50, r: 18, t: 22, b: 32 }; + const plotW = W - pad.l - pad.r; + const plotH = H - pad.t - pad.b; + /* Background */ + svg.appendChild(P8Helpers.svg.el('rect', { x: pad.l, y: pad.t, width: plotW, height: plotH, fill: '#fafafa', stroke: '#e5e7eb' })); + /* Y axis: -20 to 120 °C */ + const yMin = -20, yMax = 120; + function yToPx(T) { return pad.t + plotH * (1 - (T - yMin) / (yMax - yMin)); } + function tToPx(t) { return pad.l + plotW * Math.min(1, t / 300); } // 300 s scale + /* Y ticks */ + [-20, 0, 20, 40, 60, 80, 100, 120].forEach(t => { + const y = yToPx(t); + svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: y, x2: pad.l + plotW, y2: y, stroke: '#e5e7eb' })); + svg.appendChild(P8Helpers.svg.el('text', { x: pad.l - 6, y: y + 3, 'font-family':"'JetBrains Mono',monospace", 'font-size': 10, fill: 'var(--p8-muted,#64748b)', 'text-anchor':'end', text: t+'°' })); + }); + /* Phase regions overlays (transparent) */ + const phaseRegions = [ + { from: -20, to: 0, fill: '#bfdbfe', name: 'лёд' }, + { from: 0, to: 100, fill: '#7dd3fc', name: 'вода' }, + { from: 100, to: 120, fill: '#fde68a', name: 'пар' } + ]; + phaseRegions.forEach(r => { + const y1 = yToPx(r.from), y2 = yToPx(r.to); + svg.appendChild(P8Helpers.svg.el('rect', { x: pad.l, y: y2, width: plotW, height: y1 - y2, fill: r.fill, opacity: 0.18 })); + svg.appendChild(P8Helpers.svg.el('text', { x: pad.l + plotW - 6, y: (y1 + y2) / 2 + 3, 'font-family':"'Inter',sans-serif", 'font-size': 10, 'font-weight': 700, fill: 'var(--p8-text)', 'text-anchor': 'end', text: r.name })); + }); + /* Phase lines (0 and 100) */ + svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: yToPx(0), x2: pad.l + plotW, y2: yToPx(0), stroke: '#0f172a', 'stroke-width': 1, 'stroke-dasharray': '3 3' })); + svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: yToPx(100), x2: pad.l + plotW, y2: yToPx(100), stroke: '#0f172a', 'stroke-width': 1, 'stroke-dasharray': '3 3' })); + /* X axis */ + svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: pad.t + plotH, x2: pad.l + plotW, y2: pad.t + plotH, stroke: '#0f172a' })); + svg.appendChild(P8Helpers.svg.el('text', { x: pad.l + plotW / 2, y: H - 6, 'font-family':"'Inter',sans-serif", 'font-size': 11, 'font-weight': 700, fill: 'var(--p8-text)', 'text-anchor': 'middle', text: 'Время, с' })); + /* Curve path (will be updated) */ + const path = P8Helpers.svg.el('path', { d: '', fill: 'none', stroke: 'var(--th-mid, #f97316)', 'stroke-width': 3, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }); + svg.appendChild(path); + function updatePath(){ + if (!points.length) return; + const d = points.map((p, i) => (i === 0 ? 'M' : 'L') + tToPx(p.t).toFixed(1) + ',' + yToPx(p.T).toFixed(1)).join(' '); + path.setAttribute('d', d); + } + function currentT(){ return points[points.length-1].T; } + function currentPhase(T){ + if (T < 0) return 'лёд'; + if (T < 100) return T === 0 ? 'плавление' : 'вода'; + if (T === 100) return 'кипение'; + return 'пар'; + } + function tick(dt){ + if (!running) return; + const energy = power * dt; // J + let T = currentT(); + let newT = T; + if (T < 0) { + /* heating ice */ + const dT = energy / (c_ice * m); + newT = T + dT; + if (newT > 0) newT = 0; + } else if (T < 0.001 && energyAccumulated < lambda * m) { + /* phase transition (melting) */ + energyAccumulated += energy; + newT = 0; + if (energyAccumulated >= lambda * m) { + newT = 0.001; + } + } else if (T < 100) { + /* heating water */ + const dT = energy / (c_water * m); + newT = T + dT; + if (newT > 100) newT = 100; + } else if (T < 100.001 && energyAccumulated < (lambda + r_vap) * m) { + /* phase transition (boiling) */ + energyAccumulated += energy; + newT = 100; + if (energyAccumulated >= (lambda + r_vap) * m) { + newT = 100.001; + } + } else if (T < 120) { + const dT = energy / (c_water * m); // simplified for steam + newT = T + dT; + if (newT > 120) newT = 120; + } else { + running = false; + } + const lastP = points[points.length-1]; + points.push({ t: lastP.t + dt, T: newT }); + if (points.length > 600) points.shift(); + updatePath(); + document.getElementById('p8-iv6-temp').textContent = Math.round(newT); + document.getElementById('p8-iv6-phase').textContent = currentPhase(newT); + if (lastP.t > 300) running = false; + } + raf = P8Anim.raf(dt => tick(Math.min(dt * 4, 0.5))); // accelerate 4x for demo + /* Bind controls */ + const pwrInp = document.getElementById('p8-iv6-pwr'); + const pwrLab = document.getElementById('p8-iv6-pwr-val'); + pwrInp.oninput = () => { power = +pwrInp.value; pwrLab.textContent = power; }; + document.getElementById('p8-iv6-play').onclick = () => { + if (!running) { running = true; raf.start(); if (window.addXp) addXp(10, 'p8-iv6-melt'); } + }; + document.getElementById('p8-iv6-reset').onclick = () => { + running = false; raf.stop(); + energyAccumulated = 0; + points = [{ t: 0, T: -20 }]; + updatePath(); + document.getElementById('p8-iv6-temp').textContent = '-20'; + document.getElementById('p8-iv6-phase').textContent = 'лёд'; + }; + updatePath(); +} +`; +replaceStub('p8', P8_HTML, P8_INIT); + +fs.writeFileSync(DST, h); +console.log('ch1 size:', h.length); + +const scripts = [...h.matchAll(/