'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}
${sc.title}
`; 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 = `
Время
0
t встречи
x встречи
Лидер
Расстояние
`; } /* ─── 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 += `
${this._iconSVG(m.icon, m.color, 16)} ${m.label}
x₀ (${sc.track.unit}) ${m.x0}
v₀ (${sc.track.unit}/${sc.track.timeUnit}) ${m.v0}
a (${sc.track.unit}/${sc.track.timeUnit}²) ${m.a || 0}
`; }); 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(); }