'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();
}