af46290ca3
race.js (1357 строк): - 8 сценариев: встречи (поезд+машина, 2 лодки), догон (мотоциклист, поезда), кто первый (авто vs поезд, 3 спортсмена), свободное падение vs парашют, обгон с разгоном - Иконки movers inline SVG: car, train, bike, moto, runner, ball, boat - Аналитический поиск точки встречи: линейный + квадратный + численный (если задержка) - Стробоскоп положений каждые 0.5-1 с - Canvas-графики x(t) и v(t) с маркером встречи (красная точка + бейдж) - Проверка ответа с tolerance ±5%, verdict зелёный/красный - Слайдеры x₀/v₀/a для каждого мовера + кнопка 'Сброс к сценарию' - Stats bar 5 ячеек: Время, t_встречи, x_встречи, Лидер, Расстояние между UI (lab.html): - Sticky quick-bar: Старт/Пауза/Сброс - Карточка вопроса вверху + answer-bar внизу с input + verdict - Collapsible-секции (race-acc): Параметры мовера 1, 2, 3, Настройки Интеграция: - lab-init.js: 'sim-race' в ALL_SIM_BODIES + роутинг _openRace - admin/sims.js: запись в ADMIN_SIMS (cat: Физика, title: 'Гонка с задачами') - lab-glue.js: P_RACE preset с SVG-превью (дорожка + кривые x(t)) - lab.css: ~200 строк стилей .race-* по паттерну elec/geo/dyn-acc
1358 lines
48 KiB
JavaScript
1358 lines
48 KiB
JavaScript
'use strict';
|
||
/**
|
||
* RaceSim — Гонка с задачами (кинематика 1D)
|
||
* 2-3 движущихся объекта: x₀, v₀, a
|
||
* Задачи: встреча, догон, кто первый финишировал
|
||
* Студент вводит ответ → проверяет анимацией и графиками x(t), v(t)
|
||
*/
|
||
class RaceSim {
|
||
static BG = '#0b0b1a';
|
||
static FONT = 'Manrope, system-ui, sans-serif';
|
||
|
||
/* ─── Сценарии ─────────────────────────────────────────────── */
|
||
static SCENARIOS = [
|
||
{
|
||
id: 'meeting1',
|
||
title: 'Поезд встречает машину',
|
||
text: 'Поезд выехал из A со скоростью 60 км/ч навстречу машине, выехавшей из B со скоростью 90 км/ч. Расстояние AB = 300 км. Через сколько часов они встретятся? Где?',
|
||
movers: [
|
||
{ icon: 'train', x0: 0, v0: 60, a: 0, color: '#06D6E0', label: 'Поезд' },
|
||
{ icon: 'car', x0: 300, v0: -90, a: 0, color: '#EF476F', label: 'Машина' },
|
||
],
|
||
track: { length: 320, unit: 'км', timeUnit: 'ч' },
|
||
questions: [
|
||
{ var: 't', label: 'Время встречи (ч)', answer: 2, tolerance: 0.1 },
|
||
{ var: 'x', label: 'Место встречи (км)', answer: 120, tolerance: 5 },
|
||
],
|
||
},
|
||
{
|
||
id: 'meeting2',
|
||
title: 'Лодки навстречу',
|
||
text: 'Лодка А плывёт по течению со скоростью 12 км/ч. Лодка Б плывёт ей навстречу со скоростью 8 км/ч. Расстояние между ними 40 км. Когда встретятся?',
|
||
movers: [
|
||
{ icon: 'boat', x0: 0, v0: 12, a: 0, color: '#06D6E0', label: 'Лодка А' },
|
||
{ icon: 'boat', x0: 40, v0: -8, a: 0, color: '#FFD166', label: 'Лодка Б' },
|
||
],
|
||
track: { length: 44, unit: 'км', timeUnit: 'ч' },
|
||
questions: [
|
||
{ var: 't', label: 'Время встречи (ч)', answer: 2, tolerance: 0.1 },
|
||
{ var: 'x', label: 'Место встречи (км)', answer: 24, tolerance: 2 },
|
||
],
|
||
},
|
||
{
|
||
id: 'chase1',
|
||
title: 'Мотоциклист догоняет',
|
||
text: 'Велосипедист едет со скоростью 15 км/ч. Через 2 часа вслед ему отправляется мотоциклист со скоростью 60 км/ч. Когда мотоциклист догонит велосипедиста?',
|
||
movers: [
|
||
{ icon: 'bike', x0: 0, v0: 15, a: 0, color: '#06D6E0', label: 'Велосипед' },
|
||
{ icon: 'moto', x0: 0, v0: 60, a: 0, color: '#EF476F', label: 'Мотоцикл', delay: 2 },
|
||
],
|
||
track: { length: 130, unit: 'км', timeUnit: 'ч' },
|
||
questions: [
|
||
{ var: 't', label: 'Время догона (ч от старта велос.)', answer: 2.667, tolerance: 0.1 },
|
||
{ var: 'x', label: 'Место догона (км)', answer: 40, tolerance: 3 },
|
||
],
|
||
},
|
||
{
|
||
id: 'chase2',
|
||
title: 'Поезд Б догоняет поезд А',
|
||
text: 'Поезд А выехал в 7:00 со скоростью 60 км/ч. Поезд Б выехал из той же точки в 8:00 со скоростью 90 км/ч в том же направлении. Когда Б догонит А?',
|
||
movers: [
|
||
{ icon: 'train', x0: 0, v0: 60, a: 0, color: '#9B5DE5', label: 'Поезд А' },
|
||
{ icon: 'train', x0: 0, v0: 90, a: 0, color: '#FFD166', label: 'Поезд Б', delay: 1 },
|
||
],
|
||
track: { length: 200, unit: 'км', timeUnit: 'ч' },
|
||
questions: [
|
||
{ var: 't', label: 'Время догона (ч от 7:00)', answer: 3, tolerance: 0.1 },
|
||
{ var: 'x', label: 'Место догона (км)', answer: 180, tolerance: 5 },
|
||
],
|
||
},
|
||
{
|
||
id: 'first1',
|
||
title: 'Кто быстрее?',
|
||
text: 'Автомобиль едет 90 км/ч, поезд — 60 км/ч. Оба стартуют из одной точки одновременно. Кто первым проедет 100 км?',
|
||
movers: [
|
||
{ icon: 'car', x0: 0, v0: 90, a: 0, color: '#06D6E0', label: 'Авто' },
|
||
{ icon: 'train', x0: 0, v0: 60, a: 0, color: '#EF476F', label: 'Поезд' },
|
||
],
|
||
track: { length: 110, unit: 'км', timeUnit: 'ч', finish: 100 },
|
||
questions: [
|
||
{ var: 't', label: 'Время авто до финиша (ч)', answer: 1.111, tolerance: 0.05 },
|
||
{ var: 'x', label: 'Где поезд в момент финиша авто? (км)', answer: 66.7, tolerance: 3 },
|
||
],
|
||
},
|
||
{
|
||
id: 'first2',
|
||
title: 'Три спортсмена',
|
||
text: '3 спортсмена бегут 100 м: А — 10 м/с, Б — 8 м/с, В стартует из той же точки с разгоном a = 2 м/с². Кто финиширует первым?',
|
||
movers: [
|
||
{ icon: 'runner', x0: 0, v0: 10, a: 0, color: '#06D6E0', label: 'Спортсмен А' },
|
||
{ icon: 'runner', x0: 0, v0: 8, a: 0, color: '#FFD166', label: 'Спортсмен Б' },
|
||
{ icon: 'runner', x0: 0, v0: 0, a: 2, color: '#EF476F', label: 'Спортсмен В' },
|
||
],
|
||
track: { length: 110, unit: 'м', timeUnit: 'с', finish: 100 },
|
||
questions: [
|
||
{ var: 't', label: 'Время А (с)', answer: 10, tolerance: 0.3 },
|
||
{ var: 'x', label: 'Время В (с)', answer: 10, tolerance: 0.3 },
|
||
],
|
||
},
|
||
{
|
||
id: 'accel1',
|
||
title: 'Свободное падение vs парашют',
|
||
text: 'Тело падает свободно с высоты 100 м (g = 10 м/с²). Другое тело падает с той же высоты с постоянной скоростью 5 м/с. Какое тело достигнет земли первым?',
|
||
movers: [
|
||
{ icon: 'ball', x0: 0, v0: 0, a: 10, color: '#06D6E0', label: 'Свободное падение' },
|
||
{ icon: 'ball', x0: 0, v0: 5, a: 0, color: '#EF476F', label: 'Парашют (5 м/с)' },
|
||
],
|
||
track: { length: 110, unit: 'м', timeUnit: 'с', finish: 100, vertical: true },
|
||
questions: [
|
||
{ var: 't', label: 'Время свободного падения (с)', answer: 4.47, tolerance: 0.2 },
|
||
{ var: 'x', label: 'Время парашюта (с)', answer: 20, tolerance: 1 },
|
||
],
|
||
},
|
||
{
|
||
id: 'accel2',
|
||
title: 'Машина обгоняет мопед',
|
||
text: 'Мопед едет равномерно 15 м/с. Машина стартует из той же точки одновременно с разгоном a = 3 м/с² (v₀ = 0). Когда машина обгонит мопед?',
|
||
movers: [
|
||
{ icon: 'moto', x0: 0, v0: 15, a: 0, color: '#FFD166', label: 'Мопед' },
|
||
{ icon: 'car', x0: 0, v0: 0, a: 3, color: '#06D6E0', label: 'Машина' },
|
||
],
|
||
track: { length: 180, unit: 'м', timeUnit: 'с' },
|
||
questions: [
|
||
{ var: 't', label: 'Время обгона (с)', answer: 10, tolerance: 0.5 },
|
||
{ var: 'x', label: 'Место обгона (м)', answer: 150, tolerance: 5 },
|
||
],
|
||
},
|
||
];
|
||
|
||
/* ─── Конструктор ──────────────────────────────────────────── */
|
||
constructor(container) {
|
||
this._container = container;
|
||
this._scenarioIdx = 0;
|
||
this._scenario = null;
|
||
this._movers = [];
|
||
|
||
// animation state
|
||
this.playing = false;
|
||
this._raf = null;
|
||
this._lastTs = null;
|
||
this.t = 0;
|
||
this.speed = 1;
|
||
|
||
// display toggles
|
||
this.showStrobe = true;
|
||
this.showGraphXT = true;
|
||
this.showGraphVT = false;
|
||
|
||
// computed meet point
|
||
this._meetT = null;
|
||
this._meetX = null;
|
||
this._finishEvents = []; // { moverIdx, tFinish, xFinish }
|
||
|
||
// answer state
|
||
this._answerChecked = false;
|
||
this._answerCorrect = false;
|
||
|
||
// build DOM
|
||
this._build();
|
||
this.setScenario(0);
|
||
this.fit();
|
||
}
|
||
|
||
/* ─── Public API ───────────────────────────────────────────── */
|
||
fit() {
|
||
if (!this._canvas) return;
|
||
const outer = this._canvasOuter;
|
||
if (!outer) return;
|
||
const W = outer.clientWidth || 600;
|
||
const H = outer.clientHeight || 340;
|
||
this._canvas.width = W * (window.devicePixelRatio || 1);
|
||
this._canvas.height = H * (window.devicePixelRatio || 1);
|
||
this._canvas.style.width = W + 'px';
|
||
this._canvas.style.height = H + 'px';
|
||
this._ctx = this._canvas.getContext('2d');
|
||
this._ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
|
||
this._W = W;
|
||
this._H = H;
|
||
|
||
if (this._graphCanvas) {
|
||
const gW = this._graphCanvas.parentElement.clientWidth || 600;
|
||
const gH = this._graphCanvas.parentElement.clientHeight || 130;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
this._graphCanvas.width = gW * dpr;
|
||
this._graphCanvas.height = gH * dpr;
|
||
this._graphCanvas.style.width = gW + 'px';
|
||
this._graphCanvas.style.height = gH + 'px';
|
||
this._gctx = this._graphCanvas.getContext('2d');
|
||
this._gctx.scale(dpr, dpr);
|
||
this._gW = gW;
|
||
this._gH = gH;
|
||
}
|
||
this.draw();
|
||
if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
|
||
}
|
||
|
||
destroy() {
|
||
this.pause();
|
||
if (this._raf) cancelAnimationFrame(this._raf);
|
||
this._container.innerHTML = '';
|
||
}
|
||
|
||
play() {
|
||
if (this.playing) return;
|
||
this.playing = true;
|
||
this._lastTs = null;
|
||
this._tick();
|
||
this._syncPlayBtn();
|
||
}
|
||
|
||
pause() {
|
||
this.playing = false;
|
||
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
||
this._syncPlayBtn();
|
||
}
|
||
|
||
reset() {
|
||
this.pause();
|
||
this.t = 0;
|
||
this._answerChecked = false;
|
||
this._answerCorrect = false;
|
||
this._hideVerdict();
|
||
this._resetMoverPositions();
|
||
this.draw();
|
||
if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
|
||
this._updateStatsBar();
|
||
}
|
||
|
||
setScenario(idx) {
|
||
this.pause();
|
||
this._scenarioIdx = idx;
|
||
this._scenario = RaceSim.SCENARIOS[idx];
|
||
const sc = this._scenario;
|
||
|
||
// deep clone movers
|
||
this._movers = sc.movers.map(m => Object.assign({}, m));
|
||
this._resetMoverPositions();
|
||
|
||
// compute meet/finish events
|
||
this._computeEvents();
|
||
|
||
// update question text
|
||
const qEl = this._container.querySelector('.race-question-text');
|
||
if (qEl) qEl.textContent = sc.text;
|
||
|
||
// update answer inputs
|
||
this._updateAnswerInputs();
|
||
|
||
// highlight active card
|
||
this._container.querySelectorAll('.race-scene-card').forEach((c, i) => {
|
||
c.classList.toggle('active', i === idx);
|
||
});
|
||
|
||
this.t = 0;
|
||
this._answerChecked = false;
|
||
this._hideVerdict();
|
||
this.draw();
|
||
if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
|
||
this._updateStatsBar();
|
||
}
|
||
|
||
/* ─── Internal: DOM builder ────────────────────────────────── */
|
||
_build() {
|
||
const c = this._container;
|
||
c.innerHTML = '';
|
||
c.className = 'race-root';
|
||
|
||
// ── Question bar ──
|
||
const qBar = document.createElement('div');
|
||
qBar.className = 'race-question-bar';
|
||
qBar.innerHTML = '<span class="race-question-text"></span>';
|
||
c.appendChild(qBar);
|
||
|
||
// ── Main area ──
|
||
const body = document.createElement('div');
|
||
body.className = 'race-body';
|
||
c.appendChild(body);
|
||
|
||
// Left panel
|
||
const panel = document.createElement('div');
|
||
panel.className = 'race-panel';
|
||
body.appendChild(panel);
|
||
this._buildPanel(panel);
|
||
|
||
// Scene area (canvas + answer bar)
|
||
const sceneWrap = document.createElement('div');
|
||
sceneWrap.className = 'race-scene-wrap';
|
||
body.appendChild(sceneWrap);
|
||
|
||
// canvas for track
|
||
this._canvasOuter = document.createElement('div');
|
||
this._canvasOuter.className = 'race-canvas-outer';
|
||
sceneWrap.appendChild(this._canvasOuter);
|
||
|
||
this._canvas = document.createElement('canvas');
|
||
this._canvas.className = 'race-track-canvas';
|
||
this._canvasOuter.appendChild(this._canvas);
|
||
|
||
// graph canvas
|
||
const graphWrap = document.createElement('div');
|
||
graphWrap.className = 'race-graph-wrap';
|
||
sceneWrap.appendChild(graphWrap);
|
||
|
||
this._graphCanvas = document.createElement('canvas');
|
||
this._graphCanvas.className = 'race-graph-canvas';
|
||
graphWrap.appendChild(this._graphCanvas);
|
||
|
||
// Answer bar
|
||
this._answerBar = document.createElement('div');
|
||
this._answerBar.className = 'race-answer-bar';
|
||
sceneWrap.appendChild(this._answerBar);
|
||
|
||
// Stats bar
|
||
this._statsBar = document.createElement('div');
|
||
this._statsBar.className = 'race-stats-bar';
|
||
c.appendChild(this._statsBar);
|
||
this._buildStatsBar();
|
||
}
|
||
|
||
_buildPanel(panel) {
|
||
// Quick bar
|
||
panel.innerHTML = `
|
||
<div class="race-quick-bar">
|
||
<button class="mag-mode-btn race-btn-play" id="race-btn-play" onclick="racePlay()">Старт</button>
|
||
<button class="mag-mode-btn" onclick="racePause()">Пауза</button>
|
||
<button class="mag-mode-btn" onclick="raceReset()">Сброс</button>
|
||
</div>
|
||
|
||
<details class="race-acc" open>
|
||
<summary>Сценарии</summary>
|
||
<div class="race-acc-body race-scenarios-list" id="race-scenarios-list"></div>
|
||
</details>
|
||
|
||
<details class="race-acc" id="race-acc-params">
|
||
<summary>Параметры</summary>
|
||
<div class="race-acc-body" id="race-params-body">
|
||
</div>
|
||
</details>
|
||
|
||
<details class="race-acc">
|
||
<summary>Отображение</summary>
|
||
<div class="race-acc-body">
|
||
<div class="dyn-checks">
|
||
<label>
|
||
<input type="checkbox" id="race-chk-strobe" checked onchange="raceToggle('strobe',this.checked)">
|
||
Стробоскоп
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="race-chk-xt" checked onchange="raceToggle('xt',this.checked)">
|
||
График x(t)
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" id="race-chk-vt" onchange="raceToggle('vt',this.checked)">
|
||
График v(t)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
`;
|
||
|
||
// Fill scenario list
|
||
const listEl = panel.querySelector('#race-scenarios-list');
|
||
if (listEl) this._buildScenarioCards(listEl);
|
||
}
|
||
|
||
_buildScenarioCards(container) {
|
||
container.innerHTML = '';
|
||
RaceSim.SCENARIOS.forEach((sc, i) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'race-scene-card';
|
||
card.dataset.idx = i;
|
||
const icons = sc.movers.map(m => this._iconSVG(m.icon, m.color, 14)).join('');
|
||
card.innerHTML = `<div class="race-scene-icons">${icons}</div><div class="race-scene-info"><div class="race-scene-title">${sc.title}</div></div>`;
|
||
card.addEventListener('click', () => {
|
||
if (raceSim) raceSim.setScenario(i);
|
||
});
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
_updateAnswerInputs() {
|
||
const bar = this._answerBar;
|
||
if (!bar) return;
|
||
const sc = this._scenario;
|
||
let html = '<div class="race-answer-inputs">';
|
||
sc.questions.forEach((q, i) => {
|
||
html += `<label class="race-ans-label">${q.label}: <input type="number" class="race-ans-inp" id="race-ans-${i}" step="any" placeholder="?"></label>`;
|
||
});
|
||
html += '</div>';
|
||
html += '<div class="race-answer-btns">';
|
||
html += '<button class="mag-mode-btn race-check-btn" onclick="raceCheck()">Проверить</button>';
|
||
html += '<button class="mag-mode-btn" onclick="racePlay()">Анимация</button>';
|
||
html += '</div>';
|
||
html += '<div class="race-verdict" id="race-verdict" style="display:none"></div>';
|
||
bar.innerHTML = html;
|
||
}
|
||
|
||
_buildStatsBar() {
|
||
const sb = this._statsBar;
|
||
sb.innerHTML = `
|
||
<div class="pstat"><div class="pstat-label">Время</div><div class="pstat-val" id="racebar-t" style="color:#FFD166">0</div></div>
|
||
<div class="pstat"><div class="pstat-label">t встречи</div><div class="pstat-val" id="racebar-mt" style="color:var(--violet)">—</div></div>
|
||
<div class="pstat"><div class="pstat-label">x встречи</div><div class="pstat-val" id="racebar-mx" style="color:var(--cyan)">—</div></div>
|
||
<div class="pstat"><div class="pstat-label">Лидер</div><div class="pstat-val" id="racebar-lead" style="color:#EF476F">—</div></div>
|
||
<div class="pstat"><div class="pstat-label">Расстояние</div><div class="pstat-val" id="racebar-dist" style="color:rgba(200,220,255,0.8)">—</div></div>
|
||
`;
|
||
}
|
||
|
||
/* ─── Physics ──────────────────────────────────────────────── */
|
||
_pos(mover, t) {
|
||
const eff_t = Math.max(0, t - (mover.delay || 0));
|
||
return mover.x0 + mover.v0 * eff_t + 0.5 * (mover.a || 0) * eff_t * eff_t;
|
||
}
|
||
|
||
_vel(mover, t) {
|
||
const eff_t = Math.max(0, t - (mover.delay || 0));
|
||
return mover.v0 + (mover.a || 0) * eff_t;
|
||
}
|
||
|
||
_computeEvents() {
|
||
const sc = this._scenario;
|
||
this._meetT = null;
|
||
this._meetX = null;
|
||
this._finishEvents = [];
|
||
|
||
// find meeting time of first two movers
|
||
if (sc.movers.length >= 2) {
|
||
const m0 = sc.movers[0];
|
||
const m1 = sc.movers[1];
|
||
const mt = this._findMeetTime(m0, m1);
|
||
if (mt !== null && mt >= 0) {
|
||
this._meetT = mt;
|
||
this._meetX = this._pos(m0, mt);
|
||
}
|
||
}
|
||
|
||
// find finish events if track has finish line
|
||
if (sc.track.finish) {
|
||
sc.movers.forEach((m, i) => {
|
||
const tf = this._findFinishTime(m, sc.track.finish);
|
||
if (tf !== null) {
|
||
this._finishEvents.push({ moverIdx: i, tFinish: tf, xFinish: sc.track.finish });
|
||
}
|
||
});
|
||
this._finishEvents.sort((a, b) => a.tFinish - b.tFinish);
|
||
}
|
||
}
|
||
|
||
_findMeetTime(m0, m1) {
|
||
// x0(t) = x1(t) → solve quadratic or linear
|
||
const delay0 = m0.delay || 0;
|
||
const delay1 = m1.delay || 0;
|
||
|
||
// Use numerical approach for simplicity (handles delay, quadratic)
|
||
// Also provide analytical for special cases
|
||
const a0 = m0.a || 0;
|
||
const a1 = m1.a || 0;
|
||
|
||
if (delay0 === 0 && delay1 === 0) {
|
||
// x0 + v0·t + 0.5·a0·t² = x1 + v1·t + 0.5·a1·t²
|
||
const dA = 0.5 * (a0 - a1);
|
||
const dV = m0.v0 - m1.v0;
|
||
const dX = m0.x0 - m1.x0;
|
||
|
||
if (Math.abs(dA) < 1e-12) {
|
||
// linear
|
||
if (Math.abs(dV) < 1e-12) return null;
|
||
const t = -dX / dV;
|
||
return t >= 0 ? t : null;
|
||
}
|
||
// quadratic: dA·t² + dV·t + dX = 0
|
||
const D = dV * dV - 4 * dA * dX;
|
||
if (D < 0) return null;
|
||
const sqD = Math.sqrt(D);
|
||
const t1 = (-dV - sqD) / (2 * dA);
|
||
const t2 = (-dV + sqD) / (2 * dA);
|
||
const valid = [t1, t2].filter(t => t > 1e-9);
|
||
if (!valid.length) return null;
|
||
return Math.min(...valid);
|
||
}
|
||
|
||
// Numerical search when delays involved
|
||
return this._numericalMeet(m0, m1);
|
||
}
|
||
|
||
_numericalMeet(m0, m1) {
|
||
const sc = this._scenario;
|
||
const tMax = (sc.track.timeUnit === 'ч') ? 24 : 200;
|
||
let prev = this._pos(m0, 0) - this._pos(m1, 0);
|
||
for (let i = 1; i <= 2000; i++) {
|
||
const t = tMax * i / 2000;
|
||
const cur = this._pos(m0, t) - this._pos(m1, t);
|
||
if (prev * cur <= 0 && i > 1) {
|
||
// binary search
|
||
let lo = tMax * (i - 1) / 2000;
|
||
let hi = t;
|
||
for (let k = 0; k < 50; k++) {
|
||
const mid = (lo + hi) / 2;
|
||
const v = this._pos(m0, mid) - this._pos(m1, mid);
|
||
if (Math.abs(v) < 1e-9) return mid;
|
||
if (prev * v < 0) hi = mid; else lo = mid;
|
||
}
|
||
return (lo + hi) / 2;
|
||
}
|
||
prev = cur;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_findFinishTime(m, finishX) {
|
||
// x0 + v0·t + 0.5·a·t² = finishX
|
||
const delay = m.delay || 0;
|
||
// after delay t_eff = t - delay
|
||
// x0 + v0·t_eff + 0.5·a·t_eff² = finishX
|
||
const a = m.a || 0;
|
||
const dX = finishX - m.x0;
|
||
|
||
if (Math.abs(a) < 1e-12) {
|
||
if (Math.abs(m.v0) < 1e-12) return null;
|
||
const te = dX / m.v0;
|
||
if (te < 0) return null;
|
||
return te + delay;
|
||
}
|
||
// quadratic: 0.5·a·te² + v0·te - dX = 0
|
||
const D = m.v0 * m.v0 + 2 * a * dX;
|
||
if (D < 0) return null;
|
||
const sqD = Math.sqrt(D);
|
||
const te1 = (-m.v0 + sqD) / a;
|
||
const te2 = (-m.v0 - sqD) / a;
|
||
const valid = [te1, te2].filter(te => te > 1e-9);
|
||
if (!valid.length) return null;
|
||
return Math.min(...valid) + delay;
|
||
}
|
||
|
||
/* ─── Animation loop ───────────────────────────────────────── */
|
||
_tick() {
|
||
if (!this.playing) return;
|
||
this._raf = requestAnimationFrame(ts => {
|
||
if (!this.playing) return;
|
||
if (this._lastTs === null) this._lastTs = ts;
|
||
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
|
||
this._lastTs = ts;
|
||
|
||
this.t += rawDt * this.speed;
|
||
|
||
// Check for meeting flash
|
||
const sc = this._scenario;
|
||
if (this._meetT !== null && !this._meetFlashed) {
|
||
if (this.t >= this._meetT) {
|
||
this._meetFlashed = true;
|
||
this._flashTimer = 0.6;
|
||
if (window.LabFX) LabFX.sound.play('chime');
|
||
}
|
||
}
|
||
if (this._flashTimer > 0) this._flashTimer -= rawDt;
|
||
|
||
// auto-stop
|
||
const tEnd = this._tEnd();
|
||
if (this.t >= tEnd) {
|
||
this.t = tEnd;
|
||
this.playing = false;
|
||
this._syncPlayBtn();
|
||
if (window.LabFX) LabFX.sound.play('impact', { volume: 0.25 });
|
||
}
|
||
|
||
this.draw();
|
||
if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
|
||
this._updateStatsBar();
|
||
if (this.playing) this._tick();
|
||
});
|
||
}
|
||
|
||
_tEnd() {
|
||
const sc = this._scenario;
|
||
// time for all movers to traverse the track
|
||
const tUnit = sc.track.timeUnit;
|
||
let tMax = tUnit === 'ч' ? 10 : 30;
|
||
|
||
if (this._meetT !== null) tMax = Math.max(tMax, this._meetT * 1.5);
|
||
if (this._finishEvents.length) {
|
||
const last = this._finishEvents[this._finishEvents.length - 1];
|
||
tMax = Math.max(tMax, last.tFinish * 1.5);
|
||
}
|
||
return tMax;
|
||
}
|
||
|
||
_resetMoverPositions() {
|
||
if (!this._scenario) return;
|
||
this._movers = this._scenario.movers.map(m => Object.assign({}, m));
|
||
this._meetFlashed = false;
|
||
this._flashTimer = 0;
|
||
// rebuild param sliders
|
||
this._rebuildParams();
|
||
}
|
||
|
||
_rebuildParams() {
|
||
const body = this._container.querySelector('#race-params-body');
|
||
if (!body || !this._scenario) return;
|
||
const sc = this._scenario;
|
||
let html = '';
|
||
this._movers.forEach((m, i) => {
|
||
const trackLen = sc.track.length;
|
||
const vMax = Math.max(Math.abs(m.v0) * 2, 30, trackLen);
|
||
const aMax = Math.max(Math.abs(m.a || 0) * 2, 10);
|
||
const colorStyle = `color:${m.color}`;
|
||
html += `
|
||
<div class="race-mover-card">
|
||
<div class="race-mover-header">
|
||
${this._iconSVG(m.icon, m.color, 16)}
|
||
<span style="${colorStyle};font-weight:700">${m.label}</span>
|
||
</div>
|
||
<div class="param-block">
|
||
<div class="param-header"><span class="param-name">x₀ (${sc.track.unit})</span>
|
||
<span class="param-val" id="race-x0-val-${i}" style="${colorStyle}">${m.x0}</span></div>
|
||
<input type="range" class="param-slider" min="0" max="${trackLen}" step="1" value="${m.x0}"
|
||
oninput="raceParam(${i},'x0',this.value)">
|
||
</div>
|
||
<div class="param-block">
|
||
<div class="param-header"><span class="param-name">v₀ (${sc.track.unit}/${sc.track.timeUnit})</span>
|
||
<span class="param-val" id="race-v0-val-${i}" style="${colorStyle}">${m.v0}</span></div>
|
||
<input type="range" class="param-slider" min="${-vMax}" max="${vMax}" step="1" value="${m.v0}"
|
||
oninput="raceParam(${i},'v0',this.value)">
|
||
</div>
|
||
<div class="param-block">
|
||
<div class="param-header"><span class="param-name">a (${sc.track.unit}/${sc.track.timeUnit}²)</span>
|
||
<span class="param-val" id="race-a-val-${i}" style="${colorStyle}">${m.a || 0}</span></div>
|
||
<input type="range" class="param-slider" min="${-aMax}" max="${aMax}" step="0.5" value="${m.a || 0}"
|
||
oninput="raceParam(${i},'a',this.value)">
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += `<button class="mag-mode-btn" style="width:100%;margin-top:6px;font-size:.78rem" onclick="raceResetToScenario()">Сброс к сценарию</button>`;
|
||
body.innerHTML = html;
|
||
}
|
||
|
||
/* ─── Drawing ──────────────────────────────────────────────── */
|
||
draw() {
|
||
const ctx = this._ctx;
|
||
if (!ctx) return;
|
||
const W = this._W, H = this._H;
|
||
const sc = this._scenario;
|
||
if (!sc) return;
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// background
|
||
ctx.fillStyle = RaceSim.BG;
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
const isVert = !!(sc.track.vertical);
|
||
if (isVert) {
|
||
this._drawVertical(ctx, W, H, sc);
|
||
} else {
|
||
this._drawHorizontal(ctx, W, H, sc);
|
||
}
|
||
}
|
||
|
||
_drawHorizontal(ctx, W, H, sc) {
|
||
const PAD_L = 40, PAD_R = 30, PAD_T = 30, PAD_B = 60;
|
||
const trackW = W - PAD_L - PAD_R;
|
||
const trackLen = sc.track.length;
|
||
const scale = trackW / trackLen;
|
||
|
||
const trackY = H / 2 - 10;
|
||
const roadH = 28;
|
||
|
||
// grid / axis
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||
ctx.lineWidth = 1;
|
||
const step = this._niceStep(trackLen, 8);
|
||
for (let x = 0; x <= trackLen; x += step) {
|
||
const px = PAD_L + x * scale;
|
||
ctx.beginPath(); ctx.moveTo(px, PAD_T); ctx.lineTo(px, H - PAD_B);
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
|
||
// road
|
||
const roadGrad = ctx.createLinearGradient(0, trackY - roadH / 2, 0, trackY + roadH / 2);
|
||
roadGrad.addColorStop(0, 'rgba(40,42,60,0.9)');
|
||
roadGrad.addColorStop(1, 'rgba(20,22,38,0.95)');
|
||
ctx.fillStyle = roadGrad;
|
||
ctx.beginPath();
|
||
ctx.roundRect(PAD_L, trackY - roadH / 2, trackW, roadH, 6);
|
||
ctx.fill();
|
||
|
||
// road dashes
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([14, 12]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(PAD_L, trackY);
|
||
ctx.lineTo(PAD_L + trackW, trackY);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
|
||
// finish line
|
||
if (sc.track.finish) {
|
||
const fx = PAD_L + sc.track.finish * scale;
|
||
ctx.save();
|
||
ctx.strokeStyle = '#FFD166';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.setLineDash([6, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(fx, trackY - roadH / 2 - 12);
|
||
ctx.lineTo(fx, trackY + roadH / 2 + 12);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = '#FFD166';
|
||
ctx.font = `bold 11px ${RaceSim.FONT}`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Финиш', fx, trackY - roadH / 2 - 16);
|
||
ctx.restore();
|
||
}
|
||
|
||
// distance marks
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.font = `10px ${RaceSim.FONT}`;
|
||
ctx.textAlign = 'center';
|
||
for (let x = 0; x <= trackLen; x += step) {
|
||
const px = PAD_L + x * scale;
|
||
ctx.fillText(x + (x === 0 ? '' : ' ' + sc.track.unit), px, H - PAD_B + 14);
|
||
}
|
||
ctx.restore();
|
||
|
||
// strobe positions
|
||
if (this.showStrobe) {
|
||
this._drawStrobe(ctx, scale, trackY, PAD_L, sc, false);
|
||
}
|
||
|
||
// meeting flash
|
||
if (this._meetT !== null && this._flashTimer > 0) {
|
||
const meetPx = PAD_L + this._meetX * scale;
|
||
const alpha = this._flashTimer / 0.6;
|
||
ctx.save();
|
||
ctx.strokeStyle = `rgba(255,80,80,${alpha * 0.9})`;
|
||
ctx.lineWidth = 3;
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(meetPx, PAD_T);
|
||
ctx.lineTo(meetPx, H - PAD_B);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
|
||
// meeting marker (always if computed and t >= meetT)
|
||
if (this._meetT !== null && this.t >= this._meetT) {
|
||
this._drawMeetMarker(ctx, PAD_L + this._meetX * scale, trackY - roadH / 2 - 8, sc);
|
||
}
|
||
|
||
// draw movers
|
||
this._movers.forEach((m, i) => {
|
||
const x = this._pos(m, this.t);
|
||
const px = PAD_L + x * scale;
|
||
this._drawMoverIcon(ctx, m, px, trackY, 22, false);
|
||
});
|
||
|
||
// speed vectors (small arrows above movers)
|
||
this._movers.forEach((m, i) => {
|
||
const x = this._pos(m, this.t);
|
||
const px = PAD_L + x * scale;
|
||
const v = this._vel(m, this.t);
|
||
const vLen = Math.min(Math.abs(v) / (trackLen / trackW * 2 + 1) * 15, 50);
|
||
if (Math.abs(v) > 0.01) {
|
||
this._drawArrow(ctx, px, trackY - roadH / 2 - 28, vLen * Math.sign(v), 0, m.color);
|
||
}
|
||
});
|
||
}
|
||
|
||
_drawVertical(ctx, W, H, sc) {
|
||
const PAD_L = 80, PAD_R = 30, PAD_T = 20, PAD_B = 30;
|
||
const trackH = H - PAD_T - PAD_B;
|
||
const trackLen = sc.track.length;
|
||
const scale = trackH / trackLen;
|
||
const trackX = W / 2;
|
||
const roadW = 28;
|
||
|
||
// road
|
||
const roadGrad = ctx.createLinearGradient(trackX - roadW / 2, 0, trackX + roadW / 2, 0);
|
||
roadGrad.addColorStop(0, 'rgba(20,22,38,0.95)');
|
||
roadGrad.addColorStop(1, 'rgba(40,42,60,0.9)');
|
||
ctx.fillStyle = roadGrad;
|
||
ctx.beginPath();
|
||
ctx.roundRect(trackX - roadW / 2, PAD_T, roadW, trackH, 6);
|
||
ctx.fill();
|
||
|
||
// distance marks left
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.font = `10px ${RaceSim.FONT}`;
|
||
ctx.textAlign = 'right';
|
||
const step = this._niceStep(trackLen, 8);
|
||
for (let x = 0; x <= trackLen; x += step) {
|
||
const py = PAD_T + x * scale;
|
||
ctx.fillText(x + ' ' + sc.track.unit, trackX - roadW / 2 - 4, py + 4);
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(trackX - roadW / 2, py); ctx.lineTo(trackX + roadW / 2, py);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
ctx.restore();
|
||
|
||
// finish line
|
||
if (sc.track.finish) {
|
||
const fy = PAD_T + sc.track.finish * scale;
|
||
ctx.save();
|
||
ctx.strokeStyle = '#FFD166';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.setLineDash([6, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(trackX - roadW / 2 - 10, fy);
|
||
ctx.lineTo(trackX + roadW / 2 + 10, fy);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = '#FFD166';
|
||
ctx.font = `bold 10px ${RaceSim.FONT}`;
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('Финиш ' + sc.track.finish + ' ' + sc.track.unit, trackX + roadW / 2 + 14, fy + 4);
|
||
ctx.restore();
|
||
}
|
||
|
||
// strobe
|
||
if (this.showStrobe) {
|
||
this._drawStrobe(ctx, scale, trackX, PAD_T, sc, true);
|
||
}
|
||
|
||
// movers
|
||
const totalMovers = this._movers.length;
|
||
this._movers.forEach((m, i) => {
|
||
const y = this._pos(m, this.t);
|
||
const py = PAD_T + y * scale;
|
||
const offsetX = trackX - 30 + i * 32;
|
||
this._drawMoverIcon(ctx, m, offsetX, py, 18, true);
|
||
});
|
||
}
|
||
|
||
_drawStrobe(ctx, scale, baseCoord, pad, sc, vertical) {
|
||
const tCur = this.t;
|
||
const tEnd = this._tEnd();
|
||
const sInterval = tEnd / 20; // 20 ghost positions
|
||
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.18;
|
||
for (let st = sInterval; st < tCur; st += sInterval) {
|
||
this._movers.forEach(m => {
|
||
const pos = this._pos(m, st);
|
||
if (vertical) {
|
||
const py = pad + pos * scale;
|
||
this._drawMoverIcon(ctx, m, baseCoord, py, 10, true);
|
||
} else {
|
||
const px = pad + pos * scale;
|
||
this._drawMoverIcon(ctx, m, px, baseCoord, 10, false);
|
||
}
|
||
});
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawMoverIcon(ctx, mover, cx, cy, size, vertical) {
|
||
const icon = mover.icon || 'car';
|
||
const color = mover.color || '#06D6E0';
|
||
ctx.save();
|
||
ctx.translate(cx, cy);
|
||
// flip icon direction based on velocity sign for horizontal
|
||
if (!vertical && this._vel(mover, this.t) < 0) {
|
||
ctx.scale(-1, 1);
|
||
}
|
||
this._drawIcon(ctx, icon, color, size);
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawIcon(ctx, icon, color, size) {
|
||
const s = size;
|
||
ctx.strokeStyle = color;
|
||
ctx.fillStyle = color + '44';
|
||
ctx.lineWidth = 1.5;
|
||
|
||
switch (icon) {
|
||
case 'car':
|
||
// simple car shape
|
||
ctx.beginPath();
|
||
ctx.rect(-s, -s * 0.5, s * 2, s);
|
||
ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(-s * 0.5, s * 0.5, s * 0.28, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.arc(s * 0.5, s * 0.5, s * 0.28, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
break;
|
||
|
||
case 'train':
|
||
ctx.beginPath();
|
||
ctx.rect(-s, -s * 0.6, s * 2, s * 1.1);
|
||
ctx.fill(); ctx.stroke();
|
||
// windows
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.rect(-s * 0.6, -s * 0.4, s * 0.5, s * 0.4);
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.rect(s * 0.1, -s * 0.4, s * 0.5, s * 0.4);
|
||
ctx.fill();
|
||
break;
|
||
|
||
case 'bike':
|
||
// bicycle wheel-wheel
|
||
ctx.beginPath();
|
||
ctx.arc(-s * 0.55, 0, s * 0.45, 0, Math.PI * 2);
|
||
ctx.fill(); ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(s * 0.55, 0, s * 0.45, 0, Math.PI * 2);
|
||
ctx.fill(); ctx.stroke();
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(-s * 0.55, 0); ctx.lineTo(0, -s * 0.5); ctx.lineTo(s * 0.55, 0);
|
||
ctx.stroke();
|
||
break;
|
||
|
||
case 'moto':
|
||
ctx.beginPath();
|
||
ctx.arc(-s * 0.5, 0, s * 0.4, 0, Math.PI * 2);
|
||
ctx.fill(); ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(s * 0.5, 0, s * 0.4, 0, Math.PI * 2);
|
||
ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(-s * 0.2, -s * 0.5);
|
||
ctx.lineTo(s * 0.5, -s * 0.2);
|
||
ctx.lineTo(s * 0.5, 0);
|
||
ctx.lineTo(-s * 0.2, 0);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
break;
|
||
|
||
case 'runner':
|
||
// stick figure
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.arc(0, -s, s * 0.3, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -s * 0.7); ctx.lineTo(0, s * 0.3);
|
||
ctx.moveTo(-s * 0.5, s * 0.1); ctx.lineTo(0, -s * 0.1); ctx.lineTo(s * 0.5, s * 0.1);
|
||
ctx.moveTo(-s * 0.4, s); ctx.lineTo(0, s * 0.3); ctx.lineTo(s * 0.4, s);
|
||
ctx.stroke();
|
||
break;
|
||
|
||
case 'ball':
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, s * 0.65, 0, Math.PI * 2);
|
||
ctx.fill(); ctx.stroke();
|
||
break;
|
||
|
||
case 'boat':
|
||
// hull
|
||
ctx.beginPath();
|
||
ctx.moveTo(-s, 0);
|
||
ctx.quadraticCurveTo(-s, s * 0.6, 0, s * 0.7);
|
||
ctx.quadraticCurveTo(s, s * 0.6, s, 0);
|
||
ctx.closePath();
|
||
ctx.fill(); ctx.stroke();
|
||
// mast
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, 0); ctx.lineTo(0, -s);
|
||
ctx.stroke();
|
||
// sail
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -s * 0.9); ctx.lineTo(s * 0.9, -s * 0.3); ctx.lineTo(0, -s * 0.15);
|
||
ctx.closePath();
|
||
ctx.fillStyle = color + '88';
|
||
ctx.fill();
|
||
break;
|
||
|
||
default: // circle fallback
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, s * 0.65, 0, Math.PI * 2);
|
||
ctx.fill(); ctx.stroke();
|
||
}
|
||
}
|
||
|
||
_drawMeetMarker(ctx, px, py, sc) {
|
||
ctx.save();
|
||
ctx.strokeStyle = '#EF476F';
|
||
ctx.lineWidth = 2;
|
||
ctx.setLineDash([4, 3]);
|
||
const H = this._H;
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, 20);
|
||
ctx.lineTo(px, H - 50);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// badge
|
||
const txt = `t=${this._fmtNum(this._meetT)} ${sc.track.timeUnit} x=${this._fmtNum(this._meetX)} ${sc.track.unit}`;
|
||
ctx.font = `bold 11px ${RaceSim.FONT}`;
|
||
const tw = ctx.measureText(txt).width;
|
||
const bx = Math.max(4, Math.min(px - tw / 2 - 6, this._W - tw - 16));
|
||
const by = py - 20;
|
||
ctx.fillStyle = 'rgba(239,71,111,0.18)';
|
||
ctx.strokeStyle = '#EF476F';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath();
|
||
ctx.roundRect(bx, by, tw + 12, 20, 5);
|
||
ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = '#EF476F';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(txt, bx + 6, by + 14);
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawArrow(ctx, x, y, dx, dy, color) {
|
||
if (Math.abs(dx) < 2 && Math.abs(dy) < 2) return;
|
||
ctx.save();
|
||
ctx.strokeStyle = color;
|
||
ctx.fillStyle = color;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, y);
|
||
ctx.lineTo(x + dx, y + dy);
|
||
ctx.stroke();
|
||
// arrowhead
|
||
const angle = Math.atan2(dy, dx);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + dx, y + dy);
|
||
ctx.lineTo(x + dx - 7 * Math.cos(angle - 0.4), y + dy - 7 * Math.sin(angle - 0.4));
|
||
ctx.lineTo(x + dx - 7 * Math.cos(angle + 0.4), y + dy - 7 * Math.sin(angle + 0.4));
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ─── Graphs ───────────────────────────────────────────────── */
|
||
_drawGraphs() {
|
||
const gctx = this._gctx;
|
||
if (!gctx) return;
|
||
const W = this._gW, H = this._gH;
|
||
const sc = this._scenario;
|
||
if (!sc) return;
|
||
|
||
gctx.clearRect(0, 0, W, H);
|
||
gctx.fillStyle = 'rgba(8,8,20,0.92)';
|
||
gctx.fillRect(0, 0, W, H);
|
||
|
||
const tEnd = this._tEnd();
|
||
const PAD_L = 42, PAD_R = 12, PAD_T = 14, PAD_B = 24;
|
||
const gW = W - PAD_L - PAD_R;
|
||
const gH = H - PAD_T - PAD_B;
|
||
|
||
// determine y range
|
||
let yMin = Infinity, yMax = -Infinity;
|
||
const N = 200;
|
||
this._movers.forEach(m => {
|
||
for (let i = 0; i <= N; i++) {
|
||
const t = tEnd * i / N;
|
||
const v = this.showGraphVT ? this._vel(m, t) : this._pos(m, t);
|
||
if (v < yMin) yMin = v;
|
||
if (v > yMax) yMax = v;
|
||
}
|
||
});
|
||
const yRange = yMax - yMin || 1;
|
||
yMin -= yRange * 0.05;
|
||
yMax += yRange * 0.05;
|
||
|
||
// axes
|
||
gctx.save();
|
||
gctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||
gctx.lineWidth = 1;
|
||
gctx.beginPath();
|
||
gctx.moveTo(PAD_L, PAD_T);
|
||
gctx.lineTo(PAD_L, PAD_T + gH);
|
||
gctx.lineTo(PAD_L + gW, PAD_T + gH);
|
||
gctx.stroke();
|
||
gctx.restore();
|
||
|
||
// axis labels
|
||
gctx.save();
|
||
gctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
gctx.font = `9px ${RaceSim.FONT}`;
|
||
gctx.textAlign = 'right';
|
||
const yLabel = this.showGraphVT ? 'v(t)' : 'x(t)';
|
||
gctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||
gctx.font = `bold 10px ${RaceSim.FONT}`;
|
||
gctx.textAlign = 'center';
|
||
gctx.fillText(yLabel, PAD_L - 6, PAD_T - 3);
|
||
|
||
// y grid
|
||
gctx.font = `9px ${RaceSim.FONT}`;
|
||
gctx.textAlign = 'right';
|
||
gctx.fillStyle = 'rgba(255,255,255,0.28)';
|
||
const yStep = this._niceStep(yMax - yMin, 4);
|
||
const yStart = Math.ceil(yMin / yStep) * yStep;
|
||
for (let yv = yStart; yv <= yMax; yv += yStep) {
|
||
const py = PAD_T + gH - (yv - yMin) / (yMax - yMin) * gH;
|
||
gctx.save();
|
||
gctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||
gctx.lineWidth = 1;
|
||
gctx.beginPath(); gctx.moveTo(PAD_L, py); gctx.lineTo(PAD_L + gW, py); gctx.stroke();
|
||
gctx.restore();
|
||
gctx.fillText(this._fmtNum(yv), PAD_L - 3, py + 3);
|
||
}
|
||
|
||
// x axis labels (time)
|
||
gctx.fillStyle = 'rgba(255,255,255,0.28)';
|
||
gctx.textAlign = 'center';
|
||
const tStep = this._niceStep(tEnd, 5);
|
||
for (let tv = 0; tv <= tEnd; tv += tStep) {
|
||
const px = PAD_L + (tv / tEnd) * gW;
|
||
gctx.fillText(this._fmtNum(tv), px, PAD_T + gH + 14);
|
||
}
|
||
gctx.restore();
|
||
|
||
// curves
|
||
this._movers.forEach(m => {
|
||
gctx.save();
|
||
gctx.strokeStyle = m.color;
|
||
gctx.lineWidth = 2;
|
||
gctx.beginPath();
|
||
let first = true;
|
||
for (let i = 0; i <= N; i++) {
|
||
const t = tEnd * i / N;
|
||
const yv = this.showGraphVT ? this._vel(m, t) : this._pos(m, t);
|
||
const px = PAD_L + (t / tEnd) * gW;
|
||
const py = PAD_T + gH - (yv - yMin) / (yMax - yMin) * gH;
|
||
if (first) { gctx.moveTo(px, py); first = false; }
|
||
else gctx.lineTo(px, py);
|
||
}
|
||
gctx.stroke();
|
||
gctx.restore();
|
||
});
|
||
|
||
// meeting point dot
|
||
if (this._meetT !== null) {
|
||
const m0 = this._movers[0];
|
||
const yv = this.showGraphVT ? this._vel(m0, this._meetT) : this._pos(m0, this._meetT);
|
||
const px = PAD_L + (this._meetT / tEnd) * gW;
|
||
const py = PAD_T + gH - (yv - yMin) / (yMax - yMin) * gH;
|
||
gctx.save();
|
||
gctx.fillStyle = '#EF476F';
|
||
gctx.strokeStyle = '#fff';
|
||
gctx.lineWidth = 1.5;
|
||
gctx.beginPath();
|
||
gctx.arc(px, py, 5, 0, Math.PI * 2);
|
||
gctx.fill(); gctx.stroke();
|
||
// label
|
||
gctx.fillStyle = '#EF476F';
|
||
gctx.font = `bold 9px ${RaceSim.FONT}`;
|
||
gctx.textAlign = 'left';
|
||
gctx.fillText(`t=${this._fmtNum(this._meetT)}`, px + 7, py - 3);
|
||
gctx.restore();
|
||
}
|
||
|
||
// current time cursor
|
||
const curPx = PAD_L + (this.t / tEnd) * gW;
|
||
gctx.save();
|
||
gctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||
gctx.lineWidth = 1;
|
||
gctx.setLineDash([3, 3]);
|
||
gctx.beginPath();
|
||
gctx.moveTo(curPx, PAD_T);
|
||
gctx.lineTo(curPx, PAD_T + gH);
|
||
gctx.stroke();
|
||
gctx.restore();
|
||
}
|
||
|
||
/* ─── Stats bar ────────────────────────────────────────────── */
|
||
_updateStatsBar() {
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||
const sc = this._scenario;
|
||
if (!sc) return;
|
||
const u = sc.track.unit;
|
||
const tu = sc.track.timeUnit;
|
||
|
||
v('racebar-t', this._fmtNum(this.t) + ' ' + tu);
|
||
v('racebar-mt', this._meetT !== null ? this._fmtNum(this._meetT) + ' ' + tu : '—');
|
||
v('racebar-mx', this._meetX !== null ? this._fmtNum(this._meetX) + ' ' + u : '—');
|
||
|
||
if (this._movers.length >= 2) {
|
||
const pos0 = this._pos(this._movers[0], this.t);
|
||
const pos1 = this._pos(this._movers[1], this.t);
|
||
const lead = pos0 > pos1 ? this._movers[0].label : pos1 > pos0 ? this._movers[1].label : '=';
|
||
const dist = Math.abs(pos0 - pos1);
|
||
v('racebar-lead', lead);
|
||
v('racebar-dist', this._fmtNum(dist) + ' ' + u);
|
||
}
|
||
}
|
||
|
||
/* ─── Check answer ─────────────────────────────────────────── */
|
||
checkAnswer() {
|
||
const sc = this._scenario;
|
||
const verdictEl = document.getElementById('race-verdict');
|
||
if (!verdictEl) return;
|
||
|
||
// recalculate correct answers
|
||
let correct = true;
|
||
let details = '';
|
||
|
||
sc.questions.forEach((q, i) => {
|
||
const inp = document.getElementById('race-ans-' + i);
|
||
if (!inp) return;
|
||
const userVal = parseFloat(inp.value);
|
||
const ok = !isNaN(userVal) && Math.abs(userVal - q.answer) <= q.tolerance + Math.abs(q.answer) * 0.05;
|
||
if (!ok) correct = false;
|
||
details += `<span class="${ok ? 'race-ok' : 'race-err'}">${q.label}: ${ok ? 'верно' : 'нет, правильно: ' + this._fmtNum(q.answer)}</span> `;
|
||
});
|
||
|
||
verdictEl.style.display = 'block';
|
||
verdictEl.className = 'race-verdict ' + (correct ? 'race-verdict-ok' : 'race-verdict-err');
|
||
verdictEl.innerHTML = (correct
|
||
? '<b>Верно!</b> '
|
||
: '<b>Не верно.</b> ')
|
||
+ details;
|
||
|
||
this._answerChecked = true;
|
||
this._answerCorrect = correct;
|
||
|
||
if (window.LabFX) {
|
||
LabFX.sound.play(correct ? 'chime' : 'impact', { volume: 0.4 });
|
||
}
|
||
}
|
||
|
||
_hideVerdict() {
|
||
const el = document.getElementById('race-verdict');
|
||
if (el) el.style.display = 'none';
|
||
}
|
||
|
||
/* ─── Param change ─────────────────────────────────────────── */
|
||
setParam(moverIdx, param, val) {
|
||
if (moverIdx < 0 || moverIdx >= this._movers.length) return;
|
||
const numVal = parseFloat(val);
|
||
this._movers[moverIdx][param] = numVal;
|
||
// update display
|
||
const el = document.getElementById(`race-${param}-val-${moverIdx}`);
|
||
if (el) el.textContent = this._fmtNum(numVal);
|
||
// recompute events
|
||
this._computeEvents();
|
||
this.reset();
|
||
}
|
||
|
||
resetToScenario() {
|
||
this.setScenario(this._scenarioIdx);
|
||
}
|
||
|
||
/* ─── Helpers ──────────────────────────────────────────────── */
|
||
_syncPlayBtn() {
|
||
const btn = document.getElementById('race-btn-play');
|
||
if (btn) btn.textContent = this.playing ? 'Пауза' : 'Старт';
|
||
}
|
||
|
||
_fmtNum(v) {
|
||
if (v === null || v === undefined) return '—';
|
||
if (Math.abs(v) < 0.001) return '0';
|
||
if (Math.abs(v) < 10) return parseFloat(v.toFixed(3)).toString();
|
||
if (Math.abs(v) < 100) return parseFloat(v.toFixed(2)).toString();
|
||
return parseFloat(v.toFixed(1)).toString();
|
||
}
|
||
|
||
_niceStep(range, maxTicks) {
|
||
const raw = range / maxTicks;
|
||
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
||
const norm = raw / mag;
|
||
let nice;
|
||
if (norm < 1.5) nice = 1;
|
||
else if (norm < 3.5) nice = 2;
|
||
else if (norm < 7.5) nice = 5;
|
||
else nice = 10;
|
||
return nice * mag;
|
||
}
|
||
|
||
_iconSVG(icon, color, size) {
|
||
const s = size || 16;
|
||
const c = color || '#06D6E0';
|
||
const icons = {
|
||
car: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="8" width="22" height="10" rx="3"/><path d="M5 8l3-5h8l3 5"/><circle cx="7" cy="18" r="2" fill="${c}"/><circle cx="17" cy="18" r="2" fill="${c}"/></svg>`,
|
||
train: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><rect x="4" y="3" width="16" height="16" rx="3"/><path d="M4 11h16"/><circle cx="9" cy="19" r="2" fill="${c}"/><circle cx="15" cy="19" r="2" fill="${c}"/><path d="M9 21l-2 2M15 21l2 2"/></svg>`,
|
||
bike: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2"><circle cx="6" cy="15" r="5"/><circle cx="18" cy="15" r="5"/><path d="M6 15l5-8 4 5h3m-9-5l2 8"/></svg>`,
|
||
moto: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2"><circle cx="5" cy="17" r="4"/><circle cx="19" cy="17" r="4"/><path d="M5 17l5-10h7l3 5-4 3H9"/></svg>`,
|
||
runner: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><circle cx="13" cy="4" r="3"/><path d="M9 17l1-4 3 3 4-5"/><path d="M7 22l3-5m6 5l-3-5"/><path d="M6 11l4-4 5 2 3-3"/></svg>`,
|
||
ball: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12a4 4 0 0 1 8 0"/></svg>`,
|
||
boat: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><path d="M3 17l1 3h16l1-3"/><path d="M12 3v14"/><path d="M12 3l8 8H12"/></svg>`,
|
||
};
|
||
return icons[icon] || icons['ball'];
|
||
}
|
||
}
|
||
|
||
if (typeof module !== 'undefined') module.exports = RaceSim;
|
||
|
||
/* ─── Lab UI glue ───────────────────────────────────────────── */
|
||
|
||
var raceSim = null;
|
||
|
||
function _openRace() {
|
||
document.getElementById('sim-topbar-title').textContent = 'Гонка';
|
||
_simShow('sim-race');
|
||
_registerSimState('race', () => raceSim ? { scenarioIdx: raceSim._scenarioIdx } : null,
|
||
st => { if (raceSim && st) raceSim.setScenario(st.scenarioIdx || 0); });
|
||
if (typeof _embedMode !== 'undefined' && _embedMode) _startStateEmit('race');
|
||
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
const wrap = document.getElementById('race-wrap');
|
||
if (!wrap) return;
|
||
if (!raceSim) {
|
||
raceSim = new RaceSim(wrap);
|
||
} else {
|
||
raceSim.fit();
|
||
}
|
||
}));
|
||
}
|
||
|
||
function racePlay() {
|
||
if (!raceSim) return;
|
||
if (raceSim.playing) raceSim.pause();
|
||
else { raceSim.reset(); raceSim.play(); }
|
||
}
|
||
|
||
function racePause() {
|
||
if (raceSim) raceSim.pause();
|
||
}
|
||
|
||
function raceReset() {
|
||
if (raceSim) raceSim.reset();
|
||
}
|
||
|
||
function raceCheck() {
|
||
if (raceSim) raceSim.checkAnswer();
|
||
}
|
||
|
||
function raceParam(moverIdx, param, val) {
|
||
if (raceSim) raceSim.setParam(moverIdx, param, val);
|
||
}
|
||
|
||
function raceResetToScenario() {
|
||
if (raceSim) raceSim.resetToScenario();
|
||
}
|
||
|
||
function raceToggle(name, checked) {
|
||
if (!raceSim) return;
|
||
if (name === 'strobe') raceSim.showStrobe = checked;
|
||
if (name === 'xt') { raceSim.showGraphXT = checked; raceSim.showGraphVT = false; }
|
||
if (name === 'vt') { raceSim.showGraphVT = checked; raceSim.showGraphXT = false; }
|
||
raceSim.draw();
|
||
if (raceSim.showGraphXT || raceSim.showGraphVT) raceSim._drawGraphs();
|
||
}
|