Files
Learn_System/frontend/js/flagships/phys9_flag_F18_master.js
Maxim Dolgolyov aa2e869b93 feat(phys9 flagships): F18 Магистр-симулятор (финал курса)
F18. Магистр-симулятор сценария движения (final5 в ch5):
- Конструктор из 4 типов этапов:
  - Равномерное (slider v)
  - Равноускоренное (slider a)
  - Свободное падение (a = -g)
  - Стоп/покой
- Drag&drop карточек этапов с inline-input'ами:
  - Δt длительность
  - Параметр (v / a / —)
- Кнопка [×] удалить этап
- Шаблон «разгон + равном. + торможение»
- Реальная физика: Эйлер dt=0.05 с
- 3 синхронных графика: x(t), v(t), a(t)
  - Автоматический масштаб по min/max
- Stats: число этапов, общая длительность, итог x и v
- Проверка согласованности скоростей между этапами (предупреждение
  о «рывке» если v не сшивается)
- Сохранение/загрузка сценария в localStorage
  (phys9_F18_scenario)

Подключение: ch5 + хук на final5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:29:42 +03:00

272 lines
12 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.
// F18. Магистр-симулятор (final5 в ch5) — конструктор сценария движения тела.
(function(){
'use strict';
const B = () => window.PHYS9_FLAG_BASE;
const C = () => window.PHYS9_COLORS || {};
const STAGE_TYPES = {
uniform: { name: 'Равномерное', color: '#0891b2' },
accel: { name: 'Равноуск.', color: '#ea580c' },
fall: { name: 'Своб. падение', color: '#2563eb' },
stop: { name: 'Стоп / покой', color: '#475569' }
};
function init(secId){
if (!B()) return false;
let typeBtns = '';
for (const t in STAGE_TYPES){
const s = STAGE_TYPES[t];
typeBtns += '<button class="flag-btn" data-add="'+t+'" style="border-color:'+s.color+';color:'+s.color+'">+ '+s.name+'</button>';
}
const body = ''
+ '<div style="margin-bottom:8px;font-size:.85rem;color:var(--muted)">Собери цепочку этапов движения как LEGO. Программа рассчитает $x(t)$, $v(t)$, $a(t)$.</div>'
+ '<div class="flag-controls" id="F18-toolbox">'
+ typeBtns
+ '<button class="flag-btn danger" id="F18-clear">Очистить</button>'
+ '<button class="flag-btn" id="F18-preset">Шаблон: разгон+торможение</button>'
+ '</div>'
+ '<div id="F18-stages" style="display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--sec-acc-soft,#dbeafe);border-radius:11px;min-height:60px;margin:10px 0"></div>'
+ '<canvas id="F18-cv" class="flag-canvas" width="700" height="440" style="height:440px"></canvas>'
+ '<div class="flag-stats">'
+ '<div class="flag-stat"><span class="lbl">Этапов</span><span class="val" id="F18-n">0</span></div>'
+ '<div class="flag-stat"><span class="lbl">Общая длительность</span><span class="val" id="F18-tt">0 с</span></div>'
+ '<div class="flag-stat"><span class="lbl">Итог $x$</span><span class="val" id="F18-xt">0 м</span></div>'
+ '<div class="flag-stat"><span class="lbl">Итог $v$</span><span class="val" id="F18-vt">0 м/с</span></div>'
+ '</div>'
+ '<div class="flag-controls">'
+ '<button class="flag-btn primary" id="F18-save">Сохранить сценарий</button>'
+ '<button class="flag-btn" id="F18-load">Загрузить мой</button>'
+ '</div>'
+ '<div class="flag-feedback" id="F18-fb"></div>';
const card = B().makeCard(secId,
'F18. Магистр-симулятор <span class="flag-medal">КОНСТРУКТОР</span>',
'Финальный конструктор сценариев. Каждый этап — кусок движения. Программа считает $x(t)$, $v(t)$, $a(t)$ и проверяет согласованность.',
body);
if (!card) return false;
const cv = document.getElementById('F18-cv');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
let stages = []; /* {type, duration, param} */
function makeStageCard(idx){
const s = stages[idx];
const type = STAGE_TYPES[s.type];
const paramLabel = {
uniform: 'v, м/с',
accel: 'a, м/с²',
fall: '— (g)',
stop: '—'
}[s.type];
const div = document.createElement('div');
div.style.cssText = 'background:var(--card);border:2px solid '+type.color+';border-radius:9px;padding:8px 10px;display:flex;gap:8px;align-items:center;font-size:.85rem';
div.innerHTML = ''
+ '<span style="font-weight:800;color:'+type.color+'">' + (idx+1) + '. ' + type.name + '</span>'
+ '<label>$\\Delta t$ с: <input type="number" min="0.5" max="60" step="0.5" value="'+s.duration+'" style="width:60px;padding:3px 6px;border:1px solid var(--border);border-radius:5px" data-i="'+idx+'" data-f="duration"></label>'
+ (paramLabel === '—' || paramLabel === '— (g)' ? '<span style="color:var(--muted)">'+paramLabel+'</span>' :
'<label>'+paramLabel+': <input type="number" step="0.5" value="'+s.param+'" style="width:70px;padding:3px 6px;border:1px solid var(--border);border-radius:5px" data-i="'+idx+'" data-f="param"></label>')
+ '<button data-rm="'+idx+'" style="background:transparent;border:1px solid #dc2626;color:#dc2626;border-radius:5px;padding:2px 8px;cursor:pointer;font-weight:700">×</button>';
return div;
}
function refreshStages(){
const host = document.getElementById('F18-stages');
host.innerHTML = '';
if (stages.length === 0){
host.innerHTML = '<span style="color:var(--muted);font-size:.85rem;align-self:center">Добавь этапы кнопками сверху…</span>';
} else {
stages.forEach((s, i) => host.appendChild(makeStageCard(i)));
}
host.querySelectorAll('input').forEach(inp => {
inp.addEventListener('input', e => {
const i = +e.target.dataset.i, f = e.target.dataset.f;
const val = +e.target.value;
if (!isNaN(val)) { stages[i][f] = val; recalculate(); }
});
});
host.querySelectorAll('[data-rm]').forEach(btn => {
btn.addEventListener('click', e => {
const i = +e.target.dataset.rm;
stages.splice(i, 1);
refreshStages();
recalculate();
});
});
try { if(window.renderMathInElement) window.renderMathInElement(host, { delimiters:[{left:'$',right:'$',display:false}], throwOnError:false }); } catch(e){}
recalculate();
}
function simulate(){
/* Возвращает массив точек {t, x, v, a} */
const pts = [];
let t = 0, x = 0, v = 0, a = 0;
pts.push({ t, x, v, a: 0 });
for (const s of stages){
const dt_step = 0.05;
const end = t + s.duration;
while (t < end - 1e-6){
const step = Math.min(dt_step, end - t);
if (s.type === 'uniform') { a = 0; v = s.param; }
else if (s.type === 'accel') { a = s.param; }
else if (s.type === 'fall') { a = -9.8; }
else if (s.type === 'stop') { a = 0; v = 0; }
v += a * step;
x += v * step;
t += step;
pts.push({ t, x, v, a });
}
}
return pts;
}
function recalculate(){
const pts = simulate();
const last = pts[pts.length-1];
document.getElementById('F18-n').textContent = stages.length;
document.getElementById('F18-tt').textContent = last.t.toFixed(1) + ' с';
document.getElementById('F18-xt').textContent = last.x.toFixed(2) + ' м';
document.getElementById('F18-vt').textContent = last.v.toFixed(2) + ' м/с';
/* проверки */
const fb = document.getElementById('F18-fb');
if (stages.length === 0){
fb.className = 'flag-feedback';
} else {
/* для разрывов: проверяем согласованность v между этапами */
let warning = '';
for (let i = 0; i < stages.length - 1; i++){
const a = stages[i], b = stages[i+1];
/* в реальности v сохраняется при переходе. Если b.type=uniform с фикс. v, может быть разрыв */
if (b.type === 'uniform'){
/* считаем v на конце a */
let v_end = 0;
if (a.type === 'uniform') v_end = a.param;
else if (a.type === 'stop') v_end = 0;
/* для accel и fall нужно знать v на начало a — пропускаем для упрощения */
if (Math.abs(v_end - b.param) > 0.1){
warning = '⚠ После этапа '+(i+1)+' скорость '+v_end.toFixed(1)+' м/с, но '+(i+2)+'-й этап задаёт '+b.param.toFixed(1)+' м/с — это рывок.';
break;
}
}
}
if (warning){
fb.className = 'flag-feedback warn show';
fb.innerHTML = warning;
} else {
fb.className = 'flag-feedback ok show';
fb.innerHTML = '&#10003; Сценарий согласован. Этапов: '+stages.length+', итог: x = '+last.x.toFixed(1)+' м, v = '+last.v.toFixed(1)+' м/с.';
}
}
draw(pts);
}
function draw(pts){
const col = C();
ctx.fillStyle = col.bg || '#fafafa';
ctx.fillRect(0, 0, W, H);
if (pts.length < 2){
ctx.fillStyle = col.textMuted || '#64748b';
ctx.font = '15px Inter,sans-serif';
ctx.fillText('Добавь этапы движения — увидь графики x(t), v(t), a(t)', 90, H/2);
return;
}
/* 3 графика горизонтально */
const tMax = pts[pts.length-1].t || 1;
let xMin = 0, xMax = 0, vMin = 0, vMax = 0, aMin = 0, aMax = 0;
for (const p of pts){
xMin = Math.min(xMin, p.x); xMax = Math.max(xMax, p.x);
vMin = Math.min(vMin, p.v); vMax = Math.max(vMax, p.v);
aMin = Math.min(aMin, p.a); aMax = Math.max(aMax, p.a);
}
if (xMax - xMin < 0.5) xMax = xMin + 1;
if (vMax - vMin < 0.5) vMax = vMin + 1;
if (aMax - aMin < 0.5) aMax = aMin + 1;
function plot(yOff, hOff, vals, vMin0, vMax0, color, label){
ctx.fillStyle = col.bgSubtle || '#f8fafc';
ctx.fillRect(40, yOff, W - 60, hOff);
ctx.strokeStyle = col.axis || '#1e293b';
ctx.lineWidth = 1.5;
ctx.strokeRect(40, yOff, W - 60, hOff);
/* zero-line */
const zeroY = yOff + hOff - (0 - vMin0)/(vMax0 - vMin0) * hOff;
ctx.strokeStyle = col.grid || '#e5e7eb';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(40, zeroY); ctx.lineTo(W - 20, zeroY);
ctx.stroke();
/* линия */
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let i = 0; i < pts.length; i++){
const t = pts[i].t;
const v = vals[i];
const px = 40 + (t / tMax) * (W - 60);
const py = yOff + hOff - (v - vMin0)/(vMax0 - vMin0) * hOff;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
/* label */
ctx.fillStyle = color;
ctx.font = 'bold 13px Inter,sans-serif';
ctx.fillText(label, 8, yOff + 16);
ctx.fillStyle = col.textMuted || '#64748b';
ctx.font = '10px Inter,sans-serif';
ctx.fillText(vMax0.toFixed(1), 8, yOff + 12);
ctx.fillText(vMin0.toFixed(1), 8, yOff + hOff - 2);
}
const blockH = 130, gap = 10;
plot(10, blockH, pts.map(p => p.x), xMin, xMax, col.displacement || '#2563eb', 'x(t), м');
plot(10 + blockH + gap, blockH, pts.map(p => p.v), vMin, vMax, col.velocity || '#0891b2', 'v(t), м/с');
plot(10 + 2*(blockH + gap), blockH, pts.map(p => p.a), aMin, aMax, col.acceleration || '#ea580c', 'a(t), м/с²');
/* x-axis label */
ctx.fillStyle = col.text || '#0f172a';
ctx.font = '11px Inter,sans-serif';
ctx.fillText('t = '+tMax.toFixed(1)+' с', W - 70, H - 4);
}
card.querySelectorAll('[data-add]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.add;
const def = { uniform: 5, accel: 1, fall: 0, stop: 0 }[type];
stages.push({ type, duration: 3, param: def });
refreshStages();
});
});
document.getElementById('F18-clear').addEventListener('click', () => { stages = []; refreshStages(); });
document.getElementById('F18-preset').addEventListener('click', () => {
stages = [
{ type:'accel', duration:5, param: 2 },
{ type:'uniform', duration:8, param:10 },
{ type:'accel', duration:4, param:-2.5 }
];
refreshStages();
});
document.getElementById('F18-save').addEventListener('click', () => {
try {
localStorage.setItem('phys9_F18_scenario', JSON.stringify(stages));
const fb = document.getElementById('F18-fb');
fb.className = 'flag-feedback ok show';
fb.innerHTML = '&#10003; Сценарий сохранён в браузере.';
} catch(e){}
});
document.getElementById('F18-load').addEventListener('click', () => {
try {
const s = JSON.parse(localStorage.getItem('phys9_F18_scenario') || 'null');
if (Array.isArray(s)) { stages = s; refreshStages(); }
} catch(e){}
});
refreshStages();
draw([{t:0,x:0,v:0,a:0}]);
return true;
}
if (window.PHYS9_FLAG_BASE) window.PHYS9_FLAG_BASE.register('F18', { init: init, cleanup: function(){} });
else document.addEventListener('DOMContentLoaded', ()=>{
if (window.PHYS9_FLAG_BASE) window.PHYS9_FLAG_BASE.register('F18', { init: init, cleanup: function(){} });
});
})();