Files
Learn_System/frontend/js/labs/race.js
T
Maxim Dolgolyov af46290ca3 feat(labs): новая симуляция «Гонка с задачами» — кинематика 1D с геймификацией
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
2026-05-26 19:49:08 +03:00

1358 lines
48 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.
'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();
}