From af46290ca3549877723d90ebbbfd0f77dd3126ec Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 26 May 2026 19:49:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(labs):=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BC=D1=83=D0=BB=D1=8F=D1=86=D0=B8=D1=8F=20=C2=AB?= =?UTF-8?q?=D0=93=D0=BE=D0=BD=D0=BA=D0=B0=20=D1=81=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D0=BC=D0=B8=C2=BB=20=E2=80=94=20=D0=BA=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BC=D0=B0=D1=82=D0=B8=D0=BA=D0=B0=201D=20=D1=81?= =?UTF-8?q?=20=D0=B3=D0=B5=D0=B9=D0=BC=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit race.js (1357 строк): - 8 сценариев: встречи (поезд+машина, 2 лодки), догон (мотоциклист, поезда), кто первый (авто vs поезд, 3 спортсмена), свободное падение vs парашют, обгон с разгоном - Иконки movers inline SVG: car, train, bike, moto, runner, ball, boat - Аналитический поиск точки встречи: линейный + квадратный + численный (если задержка) - Стробоскоп положений каждые 0.5-1 с - Canvas-графики x(t) и v(t) с маркером встречи (красная точка + бейдж) - Проверка ответа с tolerance ±5%, verdict зелёный/красный - Слайдеры x₀/v₀/a для каждого мовера + кнопка 'Сброс к сценарию' - Stats bar 5 ячеек: Время, t_встречи, x_встречи, Лидер, Расстояние между UI (lab.html): - Sticky quick-bar: Старт/Пауза/Сброс - Карточка вопроса вверху + answer-bar внизу с input + verdict - Collapsible-секции (race-acc): Параметры мовера 1, 2, 3, Настройки Интеграция: - lab-init.js: 'sim-race' в ALL_SIM_BODIES + роутинг _openRace - admin/sims.js: запись в ADMIN_SIMS (cat: Физика, title: 'Гонка с задачами') - lab-glue.js: P_RACE preset с SVG-превью (дорожка + кривые x(t)) - lab.css: ~200 строк стилей .race-* по паттерну elec/geo/dyn-acc --- frontend/css/lab.css | 306 +++++++ frontend/js/admin/sections/sims.js | 1 + frontend/js/labs/lab-glue.js | 16 + frontend/js/labs/lab-init.js | 2 + frontend/js/labs/race.js | 1357 ++++++++++++++++++++++++++++ frontend/lab.html | 6 + 6 files changed, 1688 insertions(+) create mode 100644 frontend/js/labs/race.js 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}
${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(); +} 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 @@ + + +