Files
Learn_System/backend/scripts/redesign_p8_ch1.cjs
Maxim Dolgolyov a6a9fb858c feat(phys8 ch1): Phase 1 visual hero + IV-6 §1 drag-thermometer
Визуальный редизайн ch1 Тепловые явления:
- Hero: заменён старый .hdr на новый .p8-hero с анимированным
  градиентом (thermal-shift 14s), огненным SVG-watermark
  справа (дышащая анимация 6s), live-meter в углу с пульсацией
  и плавной анимацией значения 37 → 100 → 0 → -10 → 25 → 80 °C.
- Eyebrow 'Глава 1 · 11 параграфов', крупный title, sub-описание.
- Section watermarks: в каждой <section sec-pN> добавлены
  тематические SVG (атом, конвекция, солнце, сосуд, фазовый
  переход, пузыри и т.д.) с opacity .07 на правой стороне.

IV-6 §1 flagship interactive — Drag thermometer:
- SVG-sandbox 560×320 с 4 телами (лёд, вода, чай, пар) разной
  T и относительной U.
- Draggable термометр (P8Drag.attach + P8Helpers.svg).
- При наведении на тело — изменяется цвет термометра по
  P8Helpers.thermal.tempColor(), readout табло показывают
  T (°C) и U (отн.).
- +5 XP за 12 сек исследования.

IV-6 stubs для §2-§11: 'Coming soon' плашки с тематическим
SVG-иконкой clock. Расширим в Phase 1.2.
2026-05-30 09:58:11 +03:00

258 lines
15 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 1 — визуальный редизайн ch1 (Тепловые явления):
// 1. Hero: заменяет старый <header class="hdr"> на p8-hero с
// eyebrow, title, sub, live meter, watermark (огонь SVG).
// 2. Section watermarks: в каждом <section id="sec-pN"> добавляет
// тематический SVG-watermark (пламя/термометр/снежинка).
// 3. Inject IV-6 (drag-thermometer) в §1 — flagship интерактив.
// Остальные §2-11 получают IV-6 stub-placeholder с заголовком.
'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');
// === 1. Replace .hdr block with p8-hero ===
const FIRE_WM = `<svg viewBox="0 0 100 100" aria-hidden="true">
<path d="M50 8 C 52 22 65 30 64 46 C 63 56 56 60 55 48 C 53 56 48 60 42 58 C 36 56 32 50 34 42 C 30 52 22 60 24 72 C 26 84 36 92 50 92 C 64 92 76 84 76 70 C 76 50 60 40 56 22 C 54 14 52 10 50 8 Z"/>
</svg>`;
const NEW_HERO = `<header class="p8-hero">
<div class="p8-hero-wm">${FIRE_WM}</div>
<div class="p8-hero-meter" id="p8-meter-ch1"><span id="p8-meter-val">37</span>°C</div>
<div class="p8-hero-inner">
<div class="p8-hero-eyebrow">Глава 1 · 11 параграфов</div>
<h1 class="p8-hero-title">Тепловые явления</h1>
<div class="p8-hero-sub">Внутренняя энергия, способы теплопередачи, плавление и кипение. Перетаскивайте термометры, нагреватели и материалы — наблюдайте поведение тепла в реальном времени.</div>
<div class="hdr-side" style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap;position:relative;z-index:1">
<a href="/textbook/physics-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 8</a>
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>`;
const oldHdrRegex = /<header class="hdr">[\s\S]*?<\/header>/;
if (h.match(oldHdrRegex)) {
h = h.replace(oldHdrRegex, NEW_HERO);
console.log('Hero replaced');
}
// === 2. Update meter live: добавим скрипт, который анимирует значение в углу ===
const METER_SCRIPT = `
<script>
/* P8 hero meter — анимированный счётчик в углу (Phase 1 thermal) */
(function(){
function init(){
const el = document.getElementById('p8-meter-val');
if (!el || !window.P8Anim) return;
const targets = [37, 100, 0, -10, 25, 80];
let i = 0;
function step(){
const from = parseInt(el.textContent) || 0;
const to = targets[i % targets.length];
P8Anim.tween({
from, to, duration: 1400, easing: 'cubicInOut',
onUpdate: v => { el.textContent = Math.round(v); },
onComplete: () => { i++; setTimeout(step, 1800); }
});
}
setTimeout(step, 1200);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script>
`;
if (!h.includes('P8 hero meter')) {
h = h.replace('</body>', METER_SCRIPT + '\n</body>');
console.log('Meter animation script added');
}
// === 3. Section watermarks — добавить data-attribute, CSS подхватит ===
// Используем pseudo-element через inline стиль не получится; вместо этого
// инжектим <div class="p8-sec-wm"> в каждую секцию.
// Watermark SVG-символы по §
const SEC_SYMBOLS = {
p1: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="28" stroke="currentColor" stroke-width="6" fill="none"/><path d="M50 22 v56 M22 50 h56" stroke="currentColor" stroke-width="3"/></svg>', // атом
p2: '<svg viewBox="0 0 100 100"><path d="M50 12 v76 M50 12 l-14 16 M50 12 l14 16 M50 88 l-14-16 M50 88 l14-16" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // вверх/вниз
p3: '<svg viewBox="0 0 100 100"><path d="M14 50 h72 M86 50 l-14-14 M86 50 l-14 14" stroke="currentColor" stroke-width="5" fill="none"/></svg>', // правая стрелка
p4: '<svg viewBox="0 0 100 100"><path d="M30 80 C 30 50, 70 50, 70 30 M30 30 C 30 60, 70 60, 70 80" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // спирали (конвекция)
p5: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="14" fill="currentColor"/><g stroke="currentColor" stroke-width="4" fill="none"><line x1="50" y1="6" x2="50" y2="22"/><line x1="50" y1="78" x2="50" y2="94"/><line x1="6" y1="50" x2="22" y2="50"/><line x1="78" y1="50" x2="94" y2="50"/><line x1="18" y1="18" x2="30" y2="30"/><line x1="70" y1="70" x2="82" y2="82"/><line x1="82" y1="18" x2="70" y2="30"/><line x1="30" y1="70" x2="18" y2="82"/></g></svg>', // солнце
p6: '<svg viewBox="0 0 100 100"><rect x="20" y="35" width="60" height="35" rx="4" stroke="currentColor" stroke-width="4" fill="none"/><path d="M28 35 v-8 M50 35 v-8 M72 35 v-8" stroke="currentColor" stroke-width="3"/></svg>', // сосуд
p7: '<svg viewBox="0 0 100 100"><path d="M28 78 L50 22 L72 78 Z" stroke="currentColor" stroke-width="4" fill="none"/><path d="M40 60 L60 60" stroke="currentColor" stroke-width="3"/></svg>', // пламя/огонь
p8: '<svg viewBox="0 0 100 100"><path d="M30 30 L70 30 L70 70 L30 70 Z" stroke="currentColor" stroke-width="4" fill="none"/><path d="M30 50 L70 50" stroke="currentColor" stroke-width="3" stroke-dasharray="4 3"/></svg>', // фазовый переход
p9: '<svg viewBox="0 0 100 100"><path d="M50 14 L70 50 L50 86 L30 50 Z" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // ромб
p10: '<svg viewBox="0 0 100 100"><path d="M20 70 Q 35 50, 50 65 T 80 60" stroke="currentColor" stroke-width="4" fill="none"/><circle cx="78" cy="32" r="6" fill="currentColor"/></svg>', // капля + пар
p11: '<svg viewBox="0 0 100 100"><circle cx="35" cy="55" r="6" fill="currentColor"/><circle cx="55" cy="45" r="8" fill="currentColor"/><circle cx="65" cy="65" r="5" fill="currentColor"/><circle cx="50" cy="75" r="4" fill="currentColor"/></svg>', // пузыри
};
let secWmInjected = 0;
for (const pid of Object.keys(SEC_SYMBOLS)) {
const symbol = SEC_SYMBOLS[pid];
const secOpenRegex = new RegExp(`(<section[^>]+id="sec-${pid}"[^>]*>)`);
if (h.match(secOpenRegex) && !h.includes(`p8-sec-wm-${pid}`)) {
const wmDiv = `<div class="p8-sec-wm" id="p8-sec-wm-${pid}" aria-hidden="true">${symbol}</div>`;
h = h.replace(secOpenRegex, '$1\n ' + wmDiv);
secWmInjected++;
}
}
console.log('Section watermarks injected:', secWmInjected);
// Обеспечиваем что section имеет position:relative — добавляем inline-стиль
// (или полагаемся на существующий CSS .sec, который позиционирован)
// Проверим: ищем `.sec{` в style
// (Это уже есть в существующем CSS, sec — позиционирован относительно)
// === 4. IV-6 flagship: для §1 добавляем drag-thermometer интерактив ===
// Patch build_p1 — добавим IV-6 widget HTML + _initP1_iv6 функцию.
const IV6_P1_WIDGET = `
/* IV6 — Drag thermometer (Phase 1 flagship interactive) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Перетащи термометр</div></div>'
+'<div class="wg-help">Перетащи термометр на одно из четырёх тел и наблюдай, как меняется его температурный отсчёт. Тела разной массы и при разных условиях — оцени, в каком из них больше внутренней энергии.</div>'
+'<div class="p8-sandbox" id="p1-iv6-sandbox" style="height:320px"></div>'
+'<div class="actions" style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
+'<div class="p8-readout"><span class="p8-readout-label">T</span><span class="p8-readout-value" id="p1-iv6-T">—</span><span class="p8-readout-unit">°C</span></div>'
+'<div class="p8-readout"><span class="p8-readout-label">U отн.</span><span class="p8-readout-value" id="p1-iv6-U">—</span></div>'
+'</div>'
+'</div>';
`;
const IV6_P1_INIT = `
function _initP1_iv6(){
const sandbox = document.getElementById('p1-iv6-sandbox');
if (!sandbox || !window.P8Helpers || !window.P8Drag) return;
const svg = P8Helpers.svg.create(560, 320);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.display = 'block';
sandbox.appendChild(svg);
/* 4 тела: имя, T (°C), относительная U */
const bodies = [
{ name:'Лёд 1 кг', cx: 95, cy: 200, T: -10, U: 14, color:'#bfdbfe' },
{ name:'Вода 1 кг', cx: 230, cy: 200, T: 20, U: 100, color:'#7dd3fc' },
{ name:'Чай 0,3 кг',cx: 365, cy: 200, T: 80, U: 80, color:'#fb923c' },
{ name:'Пар 0,5 кг',cx: 500, cy: 200, T: 110, U: 200, color:'#ef4444' }
];
bodies.forEach(b => {
const g = P8Helpers.svg.el('g', { transform: 'translate('+b.cx+','+b.cy+')' });
g.appendChild(P8Helpers.svg.el('rect', {
x: -50, y: -55, width: 100, height: 110, rx: 12,
fill: b.color, stroke: '#0f172a', 'stroke-width': 1.5, opacity: 0.88
}));
g.appendChild(P8Helpers.svg.el('text', {
x: 0, y: -68,
'font-family': "'JetBrains Mono', monospace",
'font-size': 11, 'font-weight': 700,
fill: '#0f172a', 'text-anchor': 'middle',
text: b.name
}));
g.dataset = b;
svg.appendChild(g);
});
/* Термометр (draggable group) */
let thermoX = 50, thermoY = 70;
const thermoG = P8Helpers.svg.el('g', { transform: 'translate('+thermoX+','+thermoY+')', 'class': 'p8-draggable' });
/* Drop shadow rect */
thermoG.appendChild(P8Helpers.svg.el('rect', { x: -22, y: -10, width: 44, height: 130, fill: 'transparent' }));
/* Tube */
thermoG.appendChild(P8Helpers.svg.el('rect', {
x: -5, y: 0, width: 10, height: 100, rx: 5,
fill: '#f3f4f6', stroke: '#475569', 'stroke-width': 1.5
}));
const fill = P8Helpers.svg.el('rect', {
x: -3, y: 70, width: 6, height: 30, rx: 2, fill: '#f97316'
});
thermoG.appendChild(fill);
/* Bulb */
const bulb = P8Helpers.svg.el('circle', { cx: 0, cy: 110, r: 12, fill: '#f97316', stroke: '#475569', 'stroke-width': 1.5 });
thermoG.appendChild(bulb);
thermoG.appendChild(P8Helpers.svg.el('text', {
x: 0, y: -2,
'font-family': "'Inter', sans-serif", 'font-size': 10, 'font-weight': 700,
fill: '#0f172a', 'text-anchor': 'middle', text: 'Drag'
}));
svg.appendChild(thermoG);
/* Show current readout */
const tEl = document.getElementById('p1-iv6-T');
const uEl = document.getElementById('p1-iv6-U');
function checkHit(cx, cy){
for (const b of bodies){
if (Math.abs(cx - b.cx) < 50 && Math.abs(cy - b.cy) < 55){
const tColor = P8Helpers.thermal.tempColor((b.T + 20) / 130);
fill.setAttribute('fill', tColor);
bulb.setAttribute('fill', tColor);
if (tEl) tEl.textContent = b.T;
if (uEl) uEl.textContent = b.U;
return b;
}
}
fill.setAttribute('fill', '#94a3b8');
bulb.setAttribute('fill', '#94a3b8');
if (tEl) tEl.textContent = '—';
if (uEl) uEl.textContent = '—';
return null;
}
P8Drag.attach(thermoG, {
container: svg,
onMove: (ev, pos) => {
thermoX = Math.max(20, Math.min(540, pos.x));
thermoY = Math.max(10, Math.min(200, pos.y));
thermoG.setAttribute('transform', 'translate('+thermoX+','+thermoY+')');
checkHit(thermoX, thermoY + 110);
}
});
if (window.addXp) {
setTimeout(() => addXp(5, 'p1-iv6-explore'), 12000);
}
}
`;
const insertMarker = `box.innerHTML = h + secNavFor('p1') + readButton('p1');`;
if (!h.includes('p1-iv6-sandbox') && h.includes(insertMarker)) {
h = h.replace(insertMarker, IV6_P1_WIDGET.trim() + '\n\n ' + insertMarker);
// Add init call after wireReadBtn
h = h.replace(`wireReadBtn('p1');`, `wireReadBtn('p1');\n _initP1_iv6();`);
// Append init function after build_p1
const p1Start = h.indexOf('function build_p1()');
const p1End = h.indexOf('\n}\n', p1Start);
h = h.slice(0, p1End + 3) + '\n' + IV6_P1_INIT.trim() + '\n' + h.slice(p1End + 3);
console.log('IV-6 §1 drag-thermometer injected');
}
// === 5. Stub IV-6 placeholders для §2-11 ===
for (let n = 2; n <= 11; n++) {
const pid = 'p' + n;
const stubHtml = `
/* IV6 — flagship интерактив (заглушка Phase 1, наполнение в Phase 1.${n}) */
h += '<div class="wg p8-iv6">'
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">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 1.${n} — coming soon</div>'
+'</div>'
+'</div>';
`;
const marker = `box.innerHTML = h + secNavFor('${pid}') + readButton('${pid}');`;
if (!h.includes(`p8-iv6-${pid}`) && !h.includes(`Новый интерактив §${n}`) && h.includes(marker)) {
h = h.replace(marker, stubHtml.trim() + '\n\n ' + marker);
console.log(` ${pid}: IV-6 stub added`);
}
}
fs.writeFileSync(DST, h);
console.log('ch1 final size:', h.length);
// Sanity parse
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, 100)); process.exit(1); }
}
console.log('inline JS parses OK');