diff --git a/frontend/css/lab.css b/frontend/css/lab.css
index fe82b49..47a384d 100644
--- a/frontend/css/lab.css
+++ b/frontend/css/lab.css
@@ -2293,3 +2293,309 @@ canvas[data-draggable]:active { cursor: grabbing; }
border-color: #06D6E0 !important;
box-shadow: 0 0 8px rgba(6,214,224,0.3);
}
+
+/* ═══════════════════════════════════════════════════════════
+ RACE SIM — Гонка с задачами (кинематика 1D)
+ ═══════════════════════════════════════════════════════════ */
+.race-sim-host {
+ flex-direction: column;
+}
+
+.race-root {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ background: #0b0b1a;
+ font-family: 'Manrope', system-ui, sans-serif;
+ overflow: hidden;
+}
+
+/* Question bar */
+.race-question-bar {
+ flex: 0 0 auto;
+ padding: 10px 16px 8px;
+ background: rgba(155,93,229,0.07);
+ border-bottom: 1px solid rgba(155,93,229,0.18);
+ font-size: .88rem;
+ color: rgba(255,255,255,0.88);
+ line-height: 1.45;
+}
+
+.race-question-text {
+ display: block;
+}
+
+/* Body: panel + scene */
+.race-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* Left panel */
+.race-panel {
+ width: 260px;
+ flex: 0 0 260px;
+ overflow-y: auto;
+ background: rgba(255,255,255,0.018);
+ border-right: 1px solid var(--border);
+ padding: 8px 10px 12px;
+ font-size: .82rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+/* Quick bar */
+.race-quick-bar {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+.race-quick-bar .mag-mode-btn {
+ flex: 1;
+ font-size: .80rem;
+ padding: 8px 4px;
+ font-weight: 600;
+}
+
+/* Accordion */
+.race-acc {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ margin-bottom: 6px;
+ background: rgba(255,255,255,0.02);
+ flex: 0 0 auto;
+}
+.race-acc > summary {
+ cursor: pointer;
+ list-style: none;
+ padding: 9px 12px 9px 30px;
+ font-family: 'Unbounded', sans-serif;
+ font-size: .78rem;
+ font-weight: 700;
+ color: var(--text);
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ position: relative;
+ user-select: none;
+ transition: background .15s;
+ border-radius: 10px;
+}
+.race-acc[open] > summary { border-radius: 10px 10px 0 0; }
+.race-acc > summary:hover { background: rgba(255,255,255,0.04); }
+.race-acc > summary::-webkit-details-marker { display: none; }
+.race-acc > summary::before {
+ content: '';
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ width: 0;
+ height: 0;
+ border-left: 5px solid currentColor;
+ border-top: 4px solid transparent;
+ border-bottom: 4px solid transparent;
+ transform: translateY(-50%);
+ transition: transform .18s;
+ opacity: .65;
+}
+.race-acc[open] > summary::before {
+ transform: translateY(-50%) rotate(90deg);
+}
+.race-acc-body {
+ padding: 8px 10px 10px;
+ border-top: 1px solid var(--border);
+}
+
+/* Scenario cards */
+.race-scenarios-list {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+.race-scene-card {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ border-radius: 8px;
+ border: 1.5px solid transparent;
+ background: rgba(255,255,255,0.025);
+ cursor: pointer;
+ transition: border-color .15s, background .15s;
+}
+.race-scene-card:hover {
+ background: rgba(255,255,255,0.05);
+ border-color: rgba(155,93,229,0.25);
+}
+.race-scene-card.active {
+ border-color: rgba(155,93,229,0.55);
+ background: rgba(155,93,229,0.1);
+}
+.race-scene-icons {
+ display: flex;
+ gap: 3px;
+ flex-shrink: 0;
+}
+.race-scene-title {
+ font-size: .78rem;
+ font-weight: 600;
+ color: var(--text);
+ line-height: 1.3;
+}
+
+/* Mover param card */
+.race-mover-card {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 10px;
+ margin-bottom: 8px;
+ background: rgba(255,255,255,0.02);
+}
+.race-mover-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 6px;
+ font-size: .80rem;
+}
+
+/* Scene: canvas + graph + answer */
+.race-scene-wrap {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-width: 0;
+}
+.race-canvas-outer {
+ flex: 1;
+ position: relative;
+ min-height: 0;
+ overflow: hidden;
+}
+.race-track-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* Graph area */
+.race-graph-wrap {
+ flex: 0 0 120px;
+ border-top: 1px solid var(--border);
+ background: rgba(8,8,20,0.92);
+ overflow: hidden;
+}
+.race-graph-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* Answer bar */
+.race-answer-bar {
+ flex: 0 0 auto;
+ padding: 10px 16px;
+ border-top: 1px solid var(--border);
+ background: rgba(255,255,255,0.018);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+.race-answer-inputs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ flex: 1;
+}
+.race-ans-label {
+ font-size: .82rem;
+ color: rgba(255,255,255,0.75);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.race-ans-inp {
+ width: 80px;
+ padding: 5px 8px;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid var(--border-h);
+ border-radius: 6px;
+ color: var(--text);
+ font-family: 'Manrope', sans-serif;
+ font-size: .85rem;
+}
+.race-ans-inp:focus {
+ outline: none;
+ border-color: rgba(155,93,229,0.55);
+ background: rgba(155,93,229,0.07);
+}
+.race-answer-btns {
+ display: flex;
+ gap: 8px;
+}
+.race-check-btn {
+ background: rgba(155,93,229,0.15) !important;
+ border-color: rgba(155,93,229,0.4) !important;
+ color: var(--violet) !important;
+}
+.race-check-btn:hover {
+ background: rgba(155,93,229,0.28) !important;
+}
+
+/* Verdict */
+.race-verdict {
+ width: 100%;
+ padding: 7px 12px;
+ border-radius: 8px;
+ font-size: .83rem;
+ line-height: 1.5;
+}
+.race-verdict-ok {
+ background: rgba(74,222,128,0.12);
+ border: 1px solid rgba(74,222,128,0.35);
+ color: #4ADE80;
+}
+.race-verdict-err {
+ background: rgba(239,71,111,0.12);
+ border: 1px solid rgba(239,71,111,0.35);
+ color: #EF476F;
+}
+.race-ok { color: #4ADE80; margin-right: 8px; }
+.race-err { color: #EF476F; margin-right: 8px; }
+
+/* Stats bar */
+.race-stats-bar {
+ flex: 0 0 auto;
+ display: flex;
+ gap: 0;
+ border-top: 1px solid var(--border);
+ background: rgba(255,255,255,0.02);
+}
+.race-stats-bar .pstat {
+ flex: 1;
+ text-align: center;
+ padding: 6px 4px;
+ border-right: 1px solid var(--border);
+ min-width: 0;
+}
+.race-stats-bar .pstat:last-child { border-right: none; }
+.race-stats-bar .pstat-label {
+ font-size: .68rem;
+ color: rgba(255,255,255,0.4);
+ text-transform: uppercase;
+ letter-spacing: .04em;
+ margin-bottom: 2px;
+}
+.race-stats-bar .pstat-val {
+ font-size: .82rem;
+ font-weight: 700;
+ font-family: 'Unbounded', sans-serif;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/frontend/js/admin/sections/sims.js b/frontend/js/admin/sections/sims.js
index 2ce4298..1e6fcaf 100644
--- a/frontend/js/admin/sections/sims.js
+++ b/frontend/js/admin/sections/sims.js
@@ -27,6 +27,7 @@
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
{ id: 'heatengine', cat: 'Физика', title: 'Тепловые двигатели' },
{ id: 'radioactive', cat: 'Физика', title: 'Радиоактивный распад' },
+ { id: 'race', cat: 'Физика', title: 'Гонка с задачами' },
{ id: 'logic', cat: 'Физика', title: 'Логические схемы' },
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js
index 474ddba..221d586 100644
--- a/frontend/js/labs/lab-glue.js
+++ b/frontend/js/labs/lab-glue.js
@@ -652,6 +652,18 @@
C`);
+ /* Race sim preview — two objects on a track, x(t) lines */
+ const P_RACE = _svg(`${_grid('rgba(255,255,255,0.05)')}
+
+
+
+
+
+
+
+ встреча
+ x = x₀ + v₀t + at²/2`);
+
/* Logic Circuits preview */
const P_LOGIC = _svg(`${_grid('rgba(255,255,255,0.04)')}
@@ -824,6 +836,10 @@
title: 'Радиоактивный распад',
desc: 'Период полураспада, цепочки распадов, активность. Визуализация ядер + кривая N(t). Радиоуглеродное датирование.',
preview: P_RADIOACTIVE },
+ { id: 'race', cat: 'phys',
+ title: 'Гонка с задачами',
+ desc: 'Кинематика 1D: встреча, догон, кто первый. Реши задачу — проверь анимацией и графиком x(t).',
+ preview: P_RACE },
{ id: 'heatengine', cat: 'phys',
title: 'Тепловые двигатели',
desc: 'Циклы Карно, Отто, Дизеля, Брайтона. PV-диаграмма, поршень, КПД.',
diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js
index a9b0a17..53c2e0c 100644
--- a/frontend/js/labs/lab-init.js
+++ b/frontend/js/labs/lab-init.js
@@ -38,6 +38,7 @@
'sim-quadratic','sim-normaldist','sim-graphtransform',
'sim-pendulum','sim-equilibrium','sim-opticsbench','sim-titration',
'sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
+ 'sim-race',
'sim-waves','sim-hydro','sim-radioactive','sim-geometry','sim-heatengine','sim-logic',
'sim-qualanalysis','sim-periodic','sim-organic','sim-solutions'];
var ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-emfield',
@@ -98,6 +99,7 @@
if (id === 'probability') _openProbability();
if (id === 'bohratom') _openBohrAtom();
if (id === 'electrolysis') _openElectrolysis();
+ if (id === 'race') _openRace();
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
diff --git a/frontend/js/labs/race.js b/frontend/js/labs/race.js
new file mode 100644
index 0000000..8aff163
--- /dev/null
+++ b/frontend/js/labs/race.js
@@ -0,0 +1,1357 @@
+'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 = '';
+ 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 = `
+
+
+
+
+
+
+
+ Сценарии
+
+
+
+
+ Параметры
+
+
+
+
+
+ Отображение
+
+
+ `;
+
+ // 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 = `${icons}
`;
+ 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 = '';
+ sc.questions.forEach((q, i) => {
+ html += ``;
+ });
+ html += '
';
+ html += '';
+ html += '';
+ html += '';
+ html += '
';
+ html += '';
+ bar.innerHTML = html;
+ }
+
+ _buildStatsBar() {
+ const sb = this._statsBar;
+ sb.innerHTML = `
+
+
+
+
+
+ `;
+ }
+
+ /* ─── 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 += `
+ `;
+ });
+ html += ``;
+ 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 += `${q.label}: ${ok ? 'верно' : 'нет, правильно: ' + this._fmtNum(q.answer)} `;
+ });
+
+ verdictEl.style.display = 'block';
+ verdictEl.className = 'race-verdict ' + (correct ? 'race-verdict-ok' : 'race-verdict-err');
+ verdictEl.innerHTML = (correct
+ ? 'Верно! '
+ : 'Не верно. ')
+ + 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: ``,
+ train: ``,
+ bike: ``,
+ moto: ``,
+ runner: ``,
+ ball: ``,
+ boat: ``,
+ };
+ 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();
+}
diff --git a/frontend/lab.html b/frontend/lab.html
index de63ad2..4ec31e3 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -3657,6 +3657,11 @@
+
+
+