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:
Maxim Dolgolyov
2026-05-30 10:06:37 +03:00
parent eaee79dc8a
commit 4bcc47e5be
6 changed files with 2122 additions and 368 deletions
+193
View File
@@ -0,0 +1,193 @@
/* phys9-flagships.css — стили для крупных интерактивов Физики 9 (флагманы F1-F19). */
.flag-card{
background: linear-gradient(135deg, var(--card,#fff), var(--sec-acc-soft,#dbeafe));
border: 2px solid var(--sec-acc,#2563eb);
border-radius: 16px;
padding: 18px 20px;
margin: 18px 0;
box-shadow: 0 4px 14px rgba(15,23,42,.08);
position: relative;
overflow: hidden;
}
.flag-card::before{
content: '★ ФЛАГМАН';
position: absolute; top: 10px; right: 14px;
font-family: 'Unbounded', sans-serif;
font-size: .68rem; font-weight: 800;
color: #fff;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
padding: 4px 10px;
border-radius: 99px;
letter-spacing: .06em;
}
.flag-title{
font-family: 'Unbounded', sans-serif;
font-size: 1.18rem;
font-weight: 800;
color: var(--sec-acc-d, #1d4ed8);
margin-bottom: 4px;
padding-right: 90px;
}
.flag-desc{
font-size: .92rem;
color: var(--text);
opacity: .85;
margin-bottom: 14px;
line-height: 1.5;
}
.flag-canvas{
display: block;
width: 100%;
max-width: 720px;
height: auto;
background: var(--bg-subtle, #f8fafc);
border: 1.5px solid var(--border, #e2e8f0);
border-radius: 12px;
margin-bottom: 12px;
touch-action: none;
}
.flag-svg{
display: block;
width: 100%;
max-width: 720px;
height: auto;
background: var(--bg-subtle, #f8fafc);
border: 1.5px solid var(--border, #e2e8f0);
border-radius: 12px;
margin-bottom: 12px;
}
.flag-controls{
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 10px 0;
align-items: center;
}
.flag-btn{
padding: 8px 16px;
border-radius: 9px;
border: 1.5px solid var(--sec-acc, #2563eb);
background: var(--card, #fff);
color: var(--sec-acc-d, #1d4ed8);
font-weight: 700;
font-size: .88rem;
cursor: pointer;
transition: transform .1s, background .15s;
font-family: inherit;
display: inline-flex;
align-items: center;
gap: 6px;
}
.flag-btn:hover{ background: var(--sec-acc-soft, #dbeafe); }
.flag-btn:active{ transform: scale(.96); }
.flag-btn:disabled{ opacity: .5; cursor: not-allowed; }
.flag-btn.primary{
background: linear-gradient(135deg, var(--sec-acc,#2563eb), var(--sec-acc-d,#1d4ed8));
color: #fff;
border-color: transparent;
}
.flag-btn.primary:hover{ filter: brightness(1.1); }
.flag-btn.success{
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
border-color: transparent;
}
.flag-btn.danger{
background: var(--card,#fff);
color: var(--fail,#dc2626);
border-color: var(--fail,#dc2626);
}
.flag-stats{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
padding: 12px 14px;
background: var(--sec-acc-soft, #dbeafe);
border-radius: 11px;
margin: 10px 0;
}
.flag-stat{
font-size: .85rem;
color: var(--text);
display: flex;
flex-direction: column;
gap: 2px;
}
.flag-stat .lbl{
font-size: .72rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .05em;
}
.flag-stat .val{
font-family: 'JetBrains Mono', monospace;
font-size: 1.05rem;
font-weight: 800;
color: var(--sec-acc-d, #1d4ed8);
}
.flag-sliders{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin: 10px 0;
}
.flag-sliders label{
display: block;
font-size: .88rem;
color: var(--muted);
background: var(--card, #fff);
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border, #e2e8f0);
}
.flag-sliders label b{
font-family: 'JetBrains Mono', monospace;
color: var(--sec-acc-d, #1d4ed8);
margin-left: 4px;
}
.flag-sliders input[type="range"]{
display: block;
width: 100%;
margin-top: 6px;
accent-color: var(--sec-acc, #2563eb);
}
.flag-feedback{
padding: 10px 14px;
border-radius: 9px;
font-weight: 600;
font-size: .9rem;
margin-top: 10px;
display: none;
}
.flag-feedback.show{ display: block; }
.flag-feedback.ok{
background: var(--ok-bg, #d1fae5);
color: #065f46;
border-left: 4px solid var(--ok, #10b981);
}
.flag-feedback.warn{
background: var(--warn-bg, #fef3c7);
color: #78350f;
border-left: 4px solid var(--warn, #f59e0b);
}
.flag-feedback.fail{
background: var(--fail-bg, #fee2e2);
color: #7f1d1d;
border-left: 4px solid var(--fail, #dc2626);
}
.flag-medal{
display: inline-block;
padding: 4px 11px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: #fff;
border-radius: 99px;
font-family: 'Unbounded', sans-serif;
font-size: .76rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
}
html.dark .flag-canvas, html.dark .flag-svg{ background: #1a1f2e; border-color: #374151; }
@@ -0,0 +1,186 @@
// F1. Конструктор траектории (§5) — рисуй мышкой, видишь s vs |Δr|.
(function(){
'use strict';
const B = () => window.PHYS9_FLAG_BASE;
const C = () => window.PHYS9_COLORS || {};
function init(secId){
if (!B()) return false;
const body = ''
+ '<canvas id="F1-cv" class="flag-canvas" width="600" height="320" style="height:320px;cursor:crosshair"></canvas>'
+ '<div class="flag-controls">'
+ '<button class="flag-btn primary" id="F1-clear">Очистить</button>'
+ '<button class="flag-btn" id="F1-close">Замкнуть петлю</button>'
+ '<button class="flag-btn" id="F1-line">Прямая</button>'
+ '<button class="flag-btn" id="F1-arc">Полуокружность</button>'
+ '<button class="flag-btn" id="F1-circle">Замкнутая окружность</button>'
+ '</div>'
+ '<div class="flag-stats">'
+ '<div class="flag-stat"><span class="lbl">Путь $s$</span><span class="val" id="F1-s">0</span></div>'
+ '<div class="flag-stat"><span class="lbl">|Δ<i>r</i>| перемещение</span><span class="val" id="F1-dr">0</span></div>'
+ '<div class="flag-stat"><span class="lbl">Отношение $s/|Δ r|$</span><span class="val" id="F1-ratio">—</span></div>'
+ '</div>'
+ '<div class="flag-feedback" id="F1-fb"></div>';
const card = B().makeCard(secId,
'F1. Конструктор траектории',
'Нарисуй мышкой/пальцем кривую — посмотри, как соотносятся путь $s$ и перемещение $|Δ\\vec r|$. Они равны только для прямой.',
body);
if (!card) return false;
const cv = document.getElementById('F1-cv');
const ctx = cv.getContext('2d');
let points = []; /* [{x, y}] */
let drawing = false;
function getPos(e){
const rect = cv.getBoundingClientRect();
const sx = cv.width / rect.width;
const sy = cv.height / rect.height;
const tx = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const ty = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
return { x: tx * sx, y: ty * sy };
}
function calc(){
let s = 0;
for (let i = 1; i < points.length; i++){
s += Math.hypot(points[i].x - points[i-1].x, points[i].y - points[i-1].y);
}
let dr = 0;
if (points.length >= 2) {
const a = points[0], b = points[points.length-1];
dr = Math.hypot(b.x - a.x, b.y - a.y);
}
/* px → м: считаем что canvas 600px = 6 м */
const m_per_px = 6 / 600;
return { s: s * m_per_px, dr: dr * m_per_px };
}
function draw(){
ctx.clearRect(0, 0, cv.width, cv.height);
/* сетка */
const col = C();
ctx.strokeStyle = col.grid || '#e5e7eb';
ctx.lineWidth = 1;
for (let x = 0; x < cv.width; x += 50){ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cv.height); ctx.stroke(); }
for (let y = 0; y < cv.height; y += 50){ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cv.width, y); ctx.stroke(); }
if (points.length === 0){
ctx.fillStyle = col.textMuted || '#64748b';
ctx.font = '15px Inter,sans-serif';
ctx.fillText('Нажми и проведи курсором/пальцем — нарисуй траекторию', 60, cv.height/2);
return;
}
/* траектория */
ctx.strokeStyle = col.velocity || '#0891b2';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
/* перемещение */
if (points.length >= 2){
const a = points[0], b = points[points.length-1];
ctx.strokeStyle = col.displacement || '#2563eb';
ctx.lineWidth = 2.5;
ctx.setLineDash([8, 5]);
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.setLineDash([]);
/* стрелка перемещения */
B().arrow(ctx, a.x, a.y, b.x, b.y, col.displacement || '#2563eb', 2.5);
/* точки start/end */
ctx.fillStyle = '#10b981';
ctx.beginPath(); ctx.arc(a.x, a.y, 7, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#dc2626';
ctx.beginPath(); ctx.arc(b.x, b.y, 7, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = col.text || '#0f172a';
ctx.font = 'bold 13px Inter,sans-serif';
ctx.fillText('старт', a.x + 10, a.y - 8);
ctx.fillText('конец', b.x + 10, b.y - 8);
}
/* обновить статы */
const r = calc();
document.getElementById('F1-s').textContent = r.s.toFixed(2) + ' м';
document.getElementById('F1-dr').textContent = r.dr.toFixed(2) + ' м';
const ratio = r.dr > 0.01 ? (r.s / r.dr).toFixed(2) : '∞';
document.getElementById('F1-ratio').textContent = ratio;
/* feedback */
const fb = document.getElementById('F1-fb');
if (r.dr > 0.01 && Math.abs(r.s/r.dr - 1) < 0.05){
fb.className = 'flag-feedback ok show';
fb.innerHTML = '&#10003; Прямая траектория: $s = |Δ\\vec r|$ — это единственный случай равенства!';
} else if (r.dr < 0.05 && r.s > 0.1){
fb.className = 'flag-feedback warn show';
fb.innerHTML = 'Замкнутая или почти замкнутая кривая: $|Δ\\vec r| \\to 0$, но путь $s$ остался.';
} else if (r.s > 0.1) {
fb.className = 'flag-feedback ok show';
fb.innerHTML = 'Криволинейное движение: $s > |Δ\\vec r|$ всегда (в '+ratio+' раз больше).';
} else {
fb.className = 'flag-feedback';
}
try { if(window.renderMathInElement) window.renderMathInElement(card, { delimiters:[{left:'$',right:'$',display:false}], throwOnError:false }); } catch(e){}
}
function start(e){
drawing = true;
points = [getPos(e)];
draw();
e.preventDefault();
}
function move(e){
if (!drawing) return;
const p = getPos(e);
const last = points[points.length-1];
if (Math.hypot(p.x - last.x, p.y - last.y) > 3) {
points.push(p);
draw();
}
e.preventDefault();
}
function end(){ drawing = false; }
cv.addEventListener('mousedown', start);
cv.addEventListener('mousemove', move);
cv.addEventListener('mouseup', end);
cv.addEventListener('mouseleave', end);
cv.addEventListener('touchstart', start, {passive:false});
cv.addEventListener('touchmove', move, {passive:false});
cv.addEventListener('touchend', end);
document.getElementById('F1-clear').addEventListener('click', ()=>{ points = []; draw(); });
document.getElementById('F1-close').addEventListener('click', ()=>{
if (points.length < 2) return;
points.push(Object.assign({}, points[0])); draw();
});
document.getElementById('F1-line').addEventListener('click', ()=>{
points = [{x:80, y:160}, {x:520, y:160}]; draw();
});
document.getElementById('F1-arc').addEventListener('click', ()=>{
points = [];
const cx = 300, cy = 250, r = 180;
for (let a = Math.PI; a >= 0; a -= 0.05) points.push({ x: cx + r*Math.cos(a), y: cy - r*Math.sin(a)*0.6 });
draw();
});
document.getElementById('F1-circle').addEventListener('click', ()=>{
points = [];
const cx = 300, cy = 160, r = 110;
for (let a = 0; a <= 2*Math.PI + 0.05; a += 0.05) points.push({ x: cx + r*Math.cos(a), y: cy + r*Math.sin(a)*0.7 });
draw();
});
draw();
return true;
}
if (window.PHYS9_FLAG_BASE) window.PHYS9_FLAG_BASE.register('F1', { init: init, cleanup: function(){} });
else {
/* base ещё не загружен — отложить регистрацию */
document.addEventListener('DOMContentLoaded', ()=>{
if (window.PHYS9_FLAG_BASE) window.PHYS9_FLAG_BASE.register('F1', { init: init, cleanup: function(){} });
});
}
})();
+284
View File
@@ -0,0 +1,284 @@
// F2. Гонка двух тел (§9) — симуляция + real-time графики x₁(t), x₂(t).
(function(){
'use strict';
const B = () => window.PHYS9_FLAG_BASE;
const C = () => window.PHYS9_COLORS || {};
function init(secId){
if (!B()) return false;
const body = ''
+ '<div class="flag-sliders">'
+ '<label>$v_{01}$ м/с: <b id="F2-v1v">10</b><input type="range" id="F2-v1" min="-15" max="25" step="1" value="10"></label>'
+ '<label>$a_1$ м/с²: <b id="F2-a1v">0</b><input type="range" id="F2-a1" min="-5" max="5" step="0.5" value="0"></label>'
+ '<label>$x_{02}$ м: <b id="F2-x2v">80</b><input type="range" id="F2-x2" min="40" max="200" step="5" value="80"></label>'
+ '<label>$v_{02}$ м/с: <b id="F2-v2v">-3</b><input type="range" id="F2-v2" min="-15" max="25" step="1" value="-3"></label>'
+ '<label>$a_2$ м/с²: <b id="F2-a2v">0</b><input type="range" id="F2-a2" min="-5" max="5" step="0.5" value="0"></label>'
+ '</div>'
+ '<canvas id="F2-cv" class="flag-canvas" width="640" height="360" style="height:360px"></canvas>'
+ '<div class="flag-controls">'
+ '<button class="flag-btn primary" id="F2-go">Старт</button>'
+ '<button class="flag-btn" id="F2-reset">Сброс</button>'
+ '<button class="flag-btn" id="F2-random">Случайный сценарий</button>'
+ '</div>'
+ '<div class="flag-stats">'
+ '<div class="flag-stat"><span class="lbl">Время</span><span class="val" id="F2-t">0 с</span></div>'
+ '<div class="flag-stat"><span class="lbl">$x_1$ ($v_1$)</span><span class="val" id="F2-x1">0 м (10 м/с)</span></div>'
+ '<div class="flag-stat"><span class="lbl">$x_2$ ($v_2$)</span><span class="val" id="F2-x2disp">80 м (3 м/с)</span></div>'
+ '<div class="flag-stat"><span class="lbl">Встреча</span><span class="val" id="F2-meet">—</span></div>'
+ '</div>'
+ '<div class="flag-feedback" id="F2-fb"></div>';
const card = B().makeCard(secId,
'F2. Гонка двух тел',
'Меняй параметры до старта. Слева — реальная гонка, справа — графики $x(t)$. Пересечение графиков = встреча. Кнопка «Случайный сценарий» — для тренировки.',
body);
if (!card) return false;
const cv = document.getElementById('F2-cv');
const ctx = cv.getContext('2d');
/* layout: левая половина — гонка, правая — графики */
const W = cv.width, H = cv.height;
const RACE_W = Math.floor(W * 0.5);
const GRAPH_X = RACE_W + 10;
const GRAPH_W = W - GRAPH_X - 10;
/* state */
let st = { x1: 0, x2: 80, v1: 10, v2: -3, a1: 0, a2: 0, t: 0, running: false, met: false, tMet: -1, xMet: -1, history: [] };
function readSliders(){
st.v1 = +document.getElementById('F2-v1').value;
st.v2 = +document.getElementById('F2-v2').value;
st.a1 = +document.getElementById('F2-a1').value;
st.a2 = +document.getElementById('F2-a2').value;
st.x2 = +document.getElementById('F2-x2').value;
document.getElementById('F2-v1v').textContent = st.v1;
document.getElementById('F2-a1v').textContent = st.a1;
document.getElementById('F2-x2v').textContent = st.x2;
document.getElementById('F2-v2v').textContent = st.v2;
document.getElementById('F2-a2v').textContent = st.a2;
/* теоретическая точка встречи (при текущих параметрах) */
/* x1(t) = v1*t + 0.5*a1*t² , x2(t) = x2_0 + v2*t + 0.5*a2*t² */
/* Δ = 0.5*(a1-a2)*t² + (v1-v2)*t - x2_0 = 0 */
const A = 0.5*(st.a1 - st.a2);
const Bcoeff = st.v1 - st.v2;
const Ccoeff = -st.x2;
let tMeet = -1;
if (Math.abs(A) < 1e-6){
if (Math.abs(Bcoeff) > 1e-6) tMeet = -Ccoeff / Bcoeff;
} else {
const D = Bcoeff*Bcoeff - 4*A*Ccoeff;
if (D >= 0){
const t1 = (-Bcoeff + Math.sqrt(D)) / (2*A);
const t2 = (-Bcoeff - Math.sqrt(D)) / (2*A);
const tCand = [t1, t2].filter(x => x > 0.01).sort((a,b)=>a-b);
if (tCand.length) tMeet = tCand[0];
}
}
document.getElementById('F2-meet').textContent = (tMeet > 0 && tMeet < 60) ? tMeet.toFixed(2)+' с' : '—';
}
function reset(){
st.t = 0; st.x1 = 0; st.met = false; st.tMet = -1; st.xMet = -1; st.history = [];
readSliders();
st.running = false;
document.getElementById('F2-go').textContent = 'Старт';
document.getElementById('F2-fb').className = 'flag-feedback';
}
function tick(dt){
if (!st.running) { draw(); return; }
/* Эйлер по dt */
const N = 4;
const ddt = dt/N;
for (let i = 0; i < N; i++){
st.v1 += st.a1 * ddt;
st.v2 += st.a2 * ddt;
st.x1 += st.v1 * ddt;
st.x2 += st.v2 * ddt;
st.t += ddt;
if (!st.met && Math.abs(st.x1 - st.x2) < 0.6){
st.met = true; st.tMet = st.t; st.xMet = st.x1;
st.running = false;
document.getElementById('F2-go').textContent = 'Старт';
const fb = document.getElementById('F2-fb');
fb.className = 'flag-feedback ok show';
fb.innerHTML = '&#10003; Встреча! $t = $ '+st.tMet.toFixed(2)+' с, $x = $ '+st.xMet.toFixed(1)+' м.';
break;
}
st.history.push({ t: st.t, x1: st.x1, x2: st.x2 });
if (st.history.length > 600) st.history.shift();
}
if (st.t > 60) { st.running = false; document.getElementById('F2-go').textContent='Старт'; }
draw();
}
function draw(){
const col = C();
ctx.fillStyle = col.bg || '#fafafa';
ctx.fillRect(0, 0, W, H);
/* === ЛЕВАЯ ПОЛОВИНА: гонка === */
/* трасса */
const trackY = 80;
const trackH = 100;
ctx.fillStyle = col.surface || '#a16207';
ctx.fillRect(0, trackY, RACE_W, trackH);
/* разметка */
ctx.strokeStyle = '#fff';
ctx.setLineDash([12, 8]);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, trackY + trackH/2);
ctx.lineTo(RACE_W, trackY + trackH/2);
ctx.stroke();
ctx.setLineDash([]);
/* шкала на трассе: 0..200 м → 0..RACE_W */
const x2px = x => (x / 200) * RACE_W;
/* отметки 0, 50, 100, 150, 200 */
ctx.fillStyle = col.text || '#0f172a';
ctx.font = '11px Inter,sans-serif';
for (let k = 0; k <= 200; k += 50){
const px = x2px(k);
ctx.fillRect(px-1, trackY + trackH, 2, 8);
ctx.fillText(k+' м', px - 12, trackY + trackH + 22);
}
/* машины */
const car1x = Math.max(8, Math.min(RACE_W - 32, x2px(st.x1)));
const car2x = Math.max(8, Math.min(RACE_W - 32, x2px(st.x2)));
/* car 1 — голубая */
ctx.fillStyle = col.velocity || '#0891b2';
ctx.fillRect(car1x - 12, trackY + 18, 24, 14);
ctx.fillRect(car1x - 8, trackY + 13, 16, 7);
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px Inter,sans-serif';
ctx.fillText('1', car1x - 3, trackY + 28);
/* car 2 — красная */
ctx.fillStyle = col.fail || '#dc2626';
ctx.fillRect(car2x - 12, trackY + 68, 24, 14);
ctx.fillRect(car2x - 8, trackY + 63, 16, 7);
ctx.fillStyle = '#fff';
ctx.fillText('2', car2x - 3, trackY + 78);
/* подпись времени */
ctx.fillStyle = col.text || '#0f172a';
ctx.font = 'bold 14px Inter,sans-serif';
ctx.fillText('t = ' + st.t.toFixed(1) + ' с', 12, 30);
ctx.font = '12px Inter,sans-serif';
/* === ПРАВАЯ ПОЛОВИНА: графики x(t) === */
const gx = GRAPH_X, gy = 30, gw = GRAPH_W, gh = H - 70;
/* фон */
ctx.fillStyle = col.bgSubtle || '#f8fafc';
ctx.fillRect(gx, gy, gw, gh);
/* оси */
ctx.strokeStyle = col.axis || '#1e293b';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(gx + 30, gy); ctx.lineTo(gx + 30, gy + gh);
ctx.lineTo(gx + gw - 5, gy + gh);
ctx.stroke();
/* шкала: x от 0..210 м, t от 0..max(20, текущ.) */
const tMax = Math.max(20, st.t + 2);
const xMax = 220;
const toGx = t => gx + 30 + (t / tMax) * (gw - 40);
const toGy = xx => gy + gh - (xx / xMax) * (gh - 10);
/* сетка */
ctx.strokeStyle = col.grid || '#e5e7eb';
ctx.lineWidth = 1;
for (let k = 0; k <= tMax; k += Math.max(2, Math.floor(tMax/8))){
const px = toGx(k);
ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px, gy+gh); ctx.stroke();
}
for (let k = 0; k <= xMax; k += 50){
const py = toGy(k);
ctx.beginPath(); ctx.moveTo(gx+30, py); ctx.lineTo(gx+gw-5, py); ctx.stroke();
}
/* подписи осей */
ctx.fillStyle = col.textMuted || '#64748b';
ctx.font = '10px Inter,sans-serif';
ctx.fillText('t, с', gx + gw - 22, gy + gh - 4);
ctx.fillText('x, м', gx + 4, gy + 10);
for (let k = 0; k <= tMax; k += Math.max(5, Math.floor(tMax/5))){
const px = toGx(k);
ctx.fillText(k.toFixed(0), px - 6, gy + gh + 14);
}
for (let k = 0; k <= xMax; k += 50){
const py = toGy(k);
ctx.fillText(k, gx + 6, py + 3);
}
/* линия x1(t) — голубая */
if (st.history.length > 1){
ctx.strokeStyle = col.velocity || '#0891b2';
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let i = 0; i < st.history.length; i++){
const p = st.history[i];
const px = toGx(p.t), py = toGy(Math.max(0, Math.min(xMax, p.x1)));
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
/* линия x2(t) — красная */
ctx.strokeStyle = col.fail || '#dc2626';
ctx.beginPath();
for (let i = 0; i < st.history.length; i++){
const p = st.history[i];
const px = toGx(p.t), py = toGy(Math.max(0, Math.min(xMax, p.x2)));
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
}
/* момент встречи */
if (st.tMet > 0){
const px = toGx(st.tMet), py = toGy(st.xMet);
ctx.fillStyle = '#fbbf24';
ctx.beginPath(); ctx.arc(px, py, 7, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#0f172a'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = col.text || '#0f172a';
ctx.font = 'bold 11px Inter,sans-serif';
ctx.fillText('★ встреча', px + 10, py - 6);
}
/* легенда */
ctx.font = '11px Inter,sans-serif';
ctx.fillStyle = col.velocity || '#0891b2';
ctx.fillRect(gx + 36, gy + 4, 12, 4);
ctx.fillStyle = col.text || '#0f172a';
ctx.fillText('тело 1', gx + 52, gy + 10);
ctx.fillStyle = col.fail || '#dc2626';
ctx.fillRect(gx + 90, gy + 4, 12, 4);
ctx.fillStyle = col.text || '#0f172a';
ctx.fillText('тело 2', gx + 106, gy + 10);
/* обновить статы текстом */
document.getElementById('F2-t').textContent = st.t.toFixed(1) + ' с';
document.getElementById('F2-x1').textContent = st.x1.toFixed(1) + ' м ('+st.v1.toFixed(1)+' м/с)';
document.getElementById('F2-x2disp').textContent = st.x2.toFixed(1) + ' м ('+st.v2.toFixed(1)+' м/с)';
}
document.getElementById('F2-go').addEventListener('click', ()=>{
if (st.met) reset();
if (st.t === 0) readSliders();
st.running = !st.running;
document.getElementById('F2-go').textContent = st.running ? 'Пауза' : 'Старт';
});
document.getElementById('F2-reset').addEventListener('click', reset);
document.getElementById('F2-random').addEventListener('click', ()=>{
document.getElementById('F2-v1').value = Math.round(5 + Math.random()*15);
document.getElementById('F2-a1').value = (Math.round((Math.random()-0.3)*8))/2;
document.getElementById('F2-x2').value = 60 + Math.round(Math.random()*120);
document.getElementById('F2-v2').value = Math.round(-8 + Math.random()*10);
document.getElementById('F2-a2').value = (Math.round((Math.random()-0.5)*6))/2;
reset();
});
['F2-v1','F2-a1','F2-x2','F2-v2','F2-a2'].forEach(id =>
document.getElementById(id).addEventListener('input', ()=>{ if (!st.running) reset(); else readSliders(); })
);
readSliders();
draw();
B().startLoop('F2', cv, tick);
return true;
}
if (window.PHYS9_FLAG_BASE) window.PHYS9_FLAG_BASE.register('F2', { init: init, cleanup: function(){} });
else document.addEventListener('DOMContentLoaded', ()=>{
if (window.PHYS9_FLAG_BASE) window.PHYS9_FLAG_BASE.register('F2', { init: init, cleanup: function(){} });
});
})();
+148
View File
@@ -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);
};
}
})();
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -9,6 +9,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
<link rel="stylesheet" href="/css/phys9-flagships.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
@@ -805,7 +806,7 @@ function _injectTasks(id){
var body = document.getElementById(id + '-body');
if(!body || body.querySelector('.legacy-tasks')) return;
body.insertAdjacentHTML('beforeend', _makeTaskBlock(id));
setTimeout(function(){ try { if(window.renderTask) window.renderTask(id); if(window.renderNav) window.renderNav(id); } catch(e){} try { if(window.PHYS9_FINALS_INIT && /^final\d+$/.test(id)) window.PHYS9_FINALS_INIT(id); } catch(e){ console.warn("phys9 final init:", e.message); } try { if(window.PHYS9_CH1_WIDGETS && window.PHYS9_CH1_WIDGETS[id]) window.PHYS9_CH1_WIDGETS[id](); } catch(e){ console.warn('phys9 widget init:', e.message); } }, 60);
setTimeout(function(){ try { if(window.renderTask) window.renderTask(id); if(window.renderNav) window.renderNav(id); } catch(e){} try { if(window.PHYS9_FINALS_INIT && /^final\d+$/.test(id)) window.PHYS9_FINALS_INIT(id); } catch(e){ console.warn("phys9 final init:", e.message); } try { if(window.PHYS9_CH1_WIDGETS && window.PHYS9_CH1_WIDGETS[id]) window.PHYS9_CH1_WIDGETS[id](); } catch(e){ console.warn('phys9 widget init:', e.message); } try { if(window.PHYS9_FLAG_BASE){ if(id==='p5') window.PHYS9_FLAG_BASE.mount('F1','p5'); else if(id==='p9') window.PHYS9_FLAG_BASE.mount('F2','p9'); } } catch(e){ console.warn('phys9 flag init:', e.message); } }, 60);
}
var _origEnsureBuilt = ensureBuilt;
ensureBuilt = function(id){ _origEnsureBuilt(id); _injectTasks(id); };