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>
This commit is contained in:
@@ -0,0 +1,271 @@
|
|||||||
|
// 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 = '✓ Сценарий согласован. Этапов: '+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 = '✓ Сценарий сохранён в браузере.';
|
||||||
|
} 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(){} });
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<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="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/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/katex.min.js"></script>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
<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>
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
|
||||||
@@ -797,7 +798,7 @@ function _injectTasks(id){
|
|||||||
var body = document.getElementById(id + '-body');
|
var body = document.getElementById(id + '-body');
|
||||||
if(!body || body.querySelector('.legacy-tasks')) return;
|
if(!body || body.querySelector('.legacy-tasks')) return;
|
||||||
body.insertAdjacentHTML('beforeend', _makeTaskBlock(id));
|
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_CH5_WIDGETS && window.PHYS9_CH5_WIDGETS[id]) window.PHYS9_CH5_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_CH5_WIDGETS && window.PHYS9_CH5_WIDGETS[id]) window.PHYS9_CH5_WIDGETS[id](); } catch(e){ console.warn('phys9 widget init:', e.message); } try { if(window.PHYS9_FLAG_BASE){ if(id==='final5') window.PHYS9_FLAG_BASE.mount('F18','final5'); } } catch(e){ console.warn('phys9 flag init:', e.message); } }, 60);
|
||||||
}
|
}
|
||||||
var _origEnsureBuilt = ensureBuilt;
|
var _origEnsureBuilt = ensureBuilt;
|
||||||
ensureBuilt = function(id){ _origEnsureBuilt(id); _injectTasks(id); };
|
ensureBuilt = function(id){ _origEnsureBuilt(id); _injectTasks(id); };
|
||||||
|
|||||||
Reference in New Issue
Block a user