4bcc47e5be
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>
285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
// 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 = '✓ Встреча! $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(){} });
|
||
});
|
||
|
||
})();
|