Files
Learn_System/frontend/js/flagships/phys9_flag_F2_race.js
T
Maxim Dolgolyov 4bcc47e5be 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>
2026-05-30 10:06:37 +03:00

285 lines
12 KiB
JavaScript
Raw 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.
// 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(){} });
});
})();