feat(phys9 flagships): инфраструктура + F1 траектория + F2 гонка (Wave A pilot)
Phase 1 + Wave A пилоты — большие интерактивы Физики 9.
frontend/css/phys9-flagships.css — стили карточек флагманов
(.flag-card с бейджем «★ ФЛАГМАН», .flag-canvas, .flag-controls,
.flag-stats, .flag-sliders, .flag-feedback). Тёмная тема поддержана.
frontend/js/flagships/phys9_flag_base.js — общая инфраструктура:
- register(id, def) — регистрация флагмана
- mount/unmount/unmountAll — управление жизненным циклом
- makeCard(secId, title, desc, body) — создание карточки
- initCanvas(id) — высокий-DPI canvas
- startLoop(id, canvas, tick) — RAF с IntersectionObserver
(авто-пауза если canvas за экраном)
- arrow(ctx, ...) — стрелка на canvas
- saveRecord/getRecord — сохранение в localStorage
- хук на goTo: unmountAll при смене параграфа
Флагман F1. Конструктор траектории (§5):
- Canvas 600×320, рисуется мышкой/пальцем (touch support)
- Real-time расчёт пути s и перемещения |Δr|
- Шаблоны: прямая / полуокружность / замкнутая окружность
- Feedback: «прямая → s=|Δr|», «замкнутая → |Δr|→0», «кривая → s>|Δr|»
- Кнопка «Замкнуть петлю» соединяет начало и конец
Флагман F2. Гонка двух тел (§9):
- Двухпанельный canvas 640×360 (трасса слева, графики справа)
- 5 slider'ов: v₀₁, a₁, x₀₂, v₀₂, a₂
- Запуск/Пауза/Сброс/Случайный сценарий
- Реальная физика равноуск. движения, симуляция Эйлером (4 шага/кадр)
- Real-time графики x₁(t) и x₂(t), пересечение = встреча
- Автоматическое определение момента встречи (квадратное уравнение)
- При встрече — звёздочка на пересечении графиков + feedback с t и x
В physics_9_ch1.html:
- Подключены CSS + 3 JS
- Расширен хук ensureBuilt: на p5 → mount('F1','p5'), на p9 → mount('F2','p9')
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// phys9_flag_base.js — общая инфраструктура для всех флагман-интерактивов Физики 9.
|
||||
// Экспорт: window.PHYS9_FLAG_BASE = { register, unmount, ... }.
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
const C = () => window.PHYS9_COLORS || {};
|
||||
const _flags = {}; /* id → { init, cleanup, _raf, _mounted, _io } */
|
||||
|
||||
/* === Регистрация флагмана === */
|
||||
function register(id, def){
|
||||
_flags[id] = Object.assign({ _raf: 0, _mounted: false, _io: null }, def);
|
||||
}
|
||||
|
||||
/* === Размонтировать (вызывается при goTo другого §) === */
|
||||
function unmount(id){
|
||||
const f = _flags[id]; if (!f) return;
|
||||
if (f._raf) { cancelAnimationFrame(f._raf); f._raf = 0; }
|
||||
if (f._io) { try { f._io.disconnect(); } catch(e){} f._io = null; }
|
||||
if (f.cleanup) try { f.cleanup(); } catch(e){}
|
||||
f._mounted = false;
|
||||
}
|
||||
|
||||
/* === Размонтировать все === */
|
||||
function unmountAll(){
|
||||
for (const id in _flags) unmount(id);
|
||||
}
|
||||
|
||||
/* === Загрузка флагмана для секции pN === */
|
||||
function mount(id, secId){
|
||||
const f = _flags[id]; if (!f) return false;
|
||||
if (f._mounted) return true;
|
||||
const ok = f.init(secId);
|
||||
if (ok !== false) f._mounted = true;
|
||||
return ok !== false;
|
||||
}
|
||||
|
||||
/* === Обёртка SVG/canvas вставки в pN-body === */
|
||||
function makeCard(secId, title, desc, body){
|
||||
const flagBox = document.createElement('div');
|
||||
flagBox.className = 'flag-card phys9-flag-' + secId;
|
||||
flagBox.innerHTML =
|
||||
'<div class="flag-title">' + title + '</div>'
|
||||
+ '<div class="flag-desc">' + desc + '</div>'
|
||||
+ body;
|
||||
const host = document.getElementById(secId + '-body');
|
||||
if (!host) return null;
|
||||
if (host.querySelector('.phys9-flag-' + secId)) return null;
|
||||
host.appendChild(flagBox);
|
||||
try { if(window.renderMathInElement) window.renderMathInElement(flagBox, { delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}], throwOnError:false }); } catch(e){}
|
||||
return flagBox;
|
||||
}
|
||||
|
||||
/* === Анимационный цикл с IntersectionObserver для авто-паузы === */
|
||||
function startLoop(id, canvas, tick){
|
||||
const f = _flags[id]; if (!f) return;
|
||||
let visible = true;
|
||||
/* IntersectionObserver — если canvas вне экрана, паузим */
|
||||
try {
|
||||
f._io = new IntersectionObserver(entries => {
|
||||
visible = entries[0].isIntersecting;
|
||||
}, { threshold: 0.05 });
|
||||
f._io.observe(canvas);
|
||||
} catch(e){}
|
||||
|
||||
let lastT = performance.now();
|
||||
function loop(now){
|
||||
const dt = Math.min(50, now - lastT) / 1000;
|
||||
lastT = now;
|
||||
if (visible) {
|
||||
try { tick(dt); } catch(e){ console.warn('phys9 flag tick:', e.message); }
|
||||
}
|
||||
f._raf = requestAnimationFrame(loop);
|
||||
}
|
||||
f._raf = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
/* === Высокий-DPI canvas init === */
|
||||
function initCanvas(id){
|
||||
const cv = document.getElementById(id);
|
||||
if (!cv) return null;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = cv.offsetWidth || 600;
|
||||
const H = cv.offsetHeight || 400;
|
||||
cv.width = Math.round(W * dpr);
|
||||
cv.height = Math.round(H * dpr);
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return { cv, ctx, W, H };
|
||||
}
|
||||
|
||||
/* === Стрелка на canvas === */
|
||||
function arrow(ctx, x1, y1, x2, y2, color, width){
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = width || 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
|
||||
if (len < 1e-3) return;
|
||||
const ux = dx/len, uy = dy/len, h = 10, hw = 6;
|
||||
const bx = x2 - ux*h, by = y2 - uy*h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1); ctx.lineTo(bx, by);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(bx - uy*hw, by + ux*hw);
|
||||
ctx.lineTo(bx + uy*hw, by - ux*hw);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/* === Сохранение рекорда === */
|
||||
function saveRecord(key, value){
|
||||
try {
|
||||
const cur = +(localStorage.getItem('phys9_record_' + key) || -Infinity);
|
||||
if (value > cur) localStorage.setItem('phys9_record_' + key, String(value));
|
||||
return Math.max(cur, value);
|
||||
} catch(e){ return value; }
|
||||
}
|
||||
function getRecord(key, def){
|
||||
try { return +(localStorage.getItem('phys9_record_' + key) || (def || 0)); }
|
||||
catch(e){ return def || 0; }
|
||||
}
|
||||
|
||||
window.PHYS9_FLAG_BASE = {
|
||||
register: register,
|
||||
mount: mount,
|
||||
unmount: unmount,
|
||||
unmountAll: unmountAll,
|
||||
makeCard: makeCard,
|
||||
initCanvas: initCanvas,
|
||||
startLoop: startLoop,
|
||||
arrow: arrow,
|
||||
saveRecord: saveRecord,
|
||||
getRecord: getRecord,
|
||||
C: C
|
||||
};
|
||||
|
||||
/* === Хук на goTo: отменять анимации при переключении секций === */
|
||||
const _origGoTo = window.goTo;
|
||||
if (typeof _origGoTo === 'function') {
|
||||
window.goTo = function(id){
|
||||
unmountAll();
|
||||
return _origGoTo.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user