'use strict';
/**
 * RaceSim — Гонка с задачами (кинематика 1D)
 * 2-3 движущихся объекта: x₀, v₀, a
 * Задачи: встреча, догон, кто первый финишировал
 * Студент вводит ответ → проверяет анимацией и графиками x(t), v(t)
 */
class RaceSim {
  static BG   = '#0b0b1a';
  static FONT = 'Manrope, system-ui, sans-serif';

  /* ─── Сценарии ─────────────────────────────────────────────── */
  static SCENARIOS = [
    {
      id: 'meeting1',
      title: 'Поезд встречает машину',
      text: 'Поезд выехал из A со скоростью 60 км/ч навстречу машине, выехавшей из B со скоростью 90 км/ч. Расстояние AB = 300 км. Через сколько часов они встретятся? Где?',
      movers: [
        { icon: 'train', x0: 0,   v0:  60, a: 0, color: '#06D6E0', label: 'Поезд' },
        { icon: 'car',   x0: 300, v0: -90, a: 0, color: '#EF476F', label: 'Машина' },
      ],
      track: { length: 320, unit: 'км', timeUnit: 'ч' },
      questions: [
        { var: 't', label: 'Время встречи (ч)', answer: 2,   tolerance: 0.1 },
        { var: 'x', label: 'Место встречи (км)', answer: 120, tolerance: 5 },
      ],
    },
    {
      id: 'meeting2',
      title: 'Лодки навстречу',
      text: 'Лодка А плывёт по течению со скоростью 12 км/ч. Лодка Б плывёт ей навстречу со скоростью 8 км/ч. Расстояние между ними 40 км. Когда встретятся?',
      movers: [
        { icon: 'boat', x0: 0,  v0: 12, a: 0, color: '#06D6E0', label: 'Лодка А' },
        { icon: 'boat', x0: 40, v0: -8, a: 0, color: '#FFD166', label: 'Лодка Б' },
      ],
      track: { length: 44, unit: 'км', timeUnit: 'ч' },
      questions: [
        { var: 't', label: 'Время встречи (ч)', answer: 2,   tolerance: 0.1 },
        { var: 'x', label: 'Место встречи (км)', answer: 24, tolerance: 2 },
      ],
    },
    {
      id: 'chase1',
      title: 'Мотоциклист догоняет',
      text: 'Велосипедист едет со скоростью 15 км/ч. Через 2 часа вслед ему отправляется мотоциклист со скоростью 60 км/ч. Когда мотоциклист догонит велосипедиста?',
      movers: [
        { icon: 'bike',  x0: 0, v0: 15, a: 0, color: '#06D6E0', label: 'Велосипед' },
        { icon: 'moto',  x0: 0, v0: 60, a: 0, color: '#EF476F', label: 'Мотоцикл', delay: 2 },
      ],
      track: { length: 130, unit: 'км', timeUnit: 'ч' },
      questions: [
        { var: 't', label: 'Время догона (ч от старта велос.)', answer: 2.667, tolerance: 0.1 },
        { var: 'x', label: 'Место догона (км)', answer: 40, tolerance: 3 },
      ],
    },
    {
      id: 'chase2',
      title: 'Поезд Б догоняет поезд А',
      text: 'Поезд А выехал в 7:00 со скоростью 60 км/ч. Поезд Б выехал из той же точки в 8:00 со скоростью 90 км/ч в том же направлении. Когда Б догонит А?',
      movers: [
        { icon: 'train', x0: 0, v0: 60, a: 0, color: '#9B5DE5', label: 'Поезд А' },
        { icon: 'train', x0: 0, v0: 90, a: 0, color: '#FFD166', label: 'Поезд Б', delay: 1 },
      ],
      track: { length: 200, unit: 'км', timeUnit: 'ч' },
      questions: [
        { var: 't', label: 'Время догона (ч от 7:00)', answer: 3, tolerance: 0.1 },
        { var: 'x', label: 'Место догона (км)', answer: 180, tolerance: 5 },
      ],
    },
    {
      id: 'first1',
      title: 'Кто быстрее?',
      text: 'Автомобиль едет 90 км/ч, поезд — 60 км/ч. Оба стартуют из одной точки одновременно. Кто первым проедет 100 км?',
      movers: [
        { icon: 'car',   x0: 0, v0: 90, a: 0, color: '#06D6E0', label: 'Авто' },
        { icon: 'train', x0: 0, v0: 60, a: 0, color: '#EF476F', label: 'Поезд' },
      ],
      track: { length: 110, unit: 'км', timeUnit: 'ч', finish: 100 },
      questions: [
        { var: 't', label: 'Время авто до финиша (ч)', answer: 1.111, tolerance: 0.05 },
        { var: 'x', label: 'Где поезд в момент финиша авто? (км)', answer: 66.7, tolerance: 3 },
      ],
    },
    {
      id: 'first2',
      title: 'Три спортсмена',
      text: '3 спортсмена бегут 100 м: А — 10 м/с, Б — 8 м/с, В стартует из той же точки с разгоном a = 2 м/с². Кто финиширует первым?',
      movers: [
        { icon: 'runner', x0: 0, v0: 10, a: 0,   color: '#06D6E0', label: 'Спортсмен А' },
        { icon: 'runner', x0: 0, v0: 8,  a: 0,   color: '#FFD166', label: 'Спортсмен Б' },
        { icon: 'runner', x0: 0, v0: 0,  a: 2,   color: '#EF476F', label: 'Спортсмен В' },
      ],
      track: { length: 110, unit: 'м', timeUnit: 'с', finish: 100 },
      questions: [
        { var: 't', label: 'Время А (с)', answer: 10,   tolerance: 0.3 },
        { var: 'x', label: 'Время В (с)', answer: 10,   tolerance: 0.3 },
      ],
    },
    {
      id: 'accel1',
      title: 'Свободное падение vs парашют',
      text: 'Тело падает свободно с высоты 100 м (g = 10 м/с²). Другое тело падает с той же высоты с постоянной скоростью 5 м/с. Какое тело достигнет земли первым?',
      movers: [
        { icon: 'ball', x0: 0, v0: 0,  a: 10, color: '#06D6E0', label: 'Свободное падение' },
        { icon: 'ball', x0: 0, v0: 5,  a: 0,  color: '#EF476F', label: 'Парашют (5 м/с)' },
      ],
      track: { length: 110, unit: 'м', timeUnit: 'с', finish: 100, vertical: true },
      questions: [
        { var: 't', label: 'Время свободного падения (с)', answer: 4.47, tolerance: 0.2 },
        { var: 'x', label: 'Время парашюта (с)',           answer: 20,   tolerance: 1 },
      ],
    },
    {
      id: 'accel2',
      title: 'Машина обгоняет мопед',
      text: 'Мопед едет равномерно 15 м/с. Машина стартует из той же точки одновременно с разгоном a = 3 м/с² (v₀ = 0). Когда машина обгонит мопед?',
      movers: [
        { icon: 'moto', x0: 0, v0: 15, a: 0, color: '#FFD166', label: 'Мопед' },
        { icon: 'car',  x0: 0, v0: 0,  a: 3, color: '#06D6E0', label: 'Машина' },
      ],
      track: { length: 180, unit: 'м', timeUnit: 'с' },
      questions: [
        { var: 't', label: 'Время обгона (с)', answer: 10, tolerance: 0.5 },
        { var: 'x', label: 'Место обгона (м)', answer: 150, tolerance: 5 },
      ],
    },
  ];

  /* ─── Конструктор ──────────────────────────────────────────── */
  constructor(container) {
    this._container = container;
    this._scenarioIdx = 0;
    this._scenario = null;
    this._movers   = [];

    // animation state
    this.playing   = false;
    this._raf      = null;
    this._lastTs   = null;
    this.t         = 0;
    this.speed     = 1;

    // display toggles
    this.showStrobe  = true;
    this.showGraphXT = true;
    this.showGraphVT = false;

    // computed meet point
    this._meetT  = null;
    this._meetX  = null;
    this._finishEvents = []; // { moverIdx, tFinish, xFinish }

    // answer state
    this._answerChecked = false;
    this._answerCorrect = false;

    // build DOM
    this._build();
    this.setScenario(0);
    this.fit();
  }

  /* ─── Public API ───────────────────────────────────────────── */
  fit() {
    if (!this._canvas) return;
    const outer = this._canvasOuter;
    if (!outer) return;
    const W = outer.clientWidth  || 600;
    const H = outer.clientHeight || 340;
    this._canvas.width  = W * (window.devicePixelRatio || 1);
    this._canvas.height = H * (window.devicePixelRatio || 1);
    this._canvas.style.width  = W + 'px';
    this._canvas.style.height = H + 'px';
    this._ctx = this._canvas.getContext('2d');
    this._ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
    this._W = W;
    this._H = H;

    if (this._graphCanvas) {
      const gW = this._graphCanvas.parentElement.clientWidth  || 600;
      const gH = this._graphCanvas.parentElement.clientHeight || 130;
      const dpr = window.devicePixelRatio || 1;
      this._graphCanvas.width  = gW * dpr;
      this._graphCanvas.height = gH * dpr;
      this._graphCanvas.style.width  = gW + 'px';
      this._graphCanvas.style.height = gH + 'px';
      this._gctx = this._graphCanvas.getContext('2d');
      this._gctx.scale(dpr, dpr);
      this._gW = gW;
      this._gH = gH;
    }
    this.draw();
    if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
  }

  destroy() {
    this.pause();
    if (this._raf) cancelAnimationFrame(this._raf);
    this._container.innerHTML = '';
  }

  play() {
    if (this.playing) return;
    this.playing  = true;
    this._lastTs  = null;
    this._tick();
    this._syncPlayBtn();
  }

  pause() {
    this.playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
    this._syncPlayBtn();
  }

  reset() {
    this.pause();
    this.t = 0;
    this._answerChecked = false;
    this._answerCorrect = false;
    this._hideVerdict();
    this._resetMoverPositions();
    this.draw();
    if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
    this._updateStatsBar();
  }

  setScenario(idx) {
    this.pause();
    this._scenarioIdx = idx;
    this._scenario    = RaceSim.SCENARIOS[idx];
    const sc = this._scenario;

    // deep clone movers
    this._movers = sc.movers.map(m => Object.assign({}, m));
    this._resetMoverPositions();

    // compute meet/finish events
    this._computeEvents();

    // update question text
    const qEl = this._container.querySelector('.race-question-text');
    if (qEl) qEl.textContent = sc.text;

    // update answer inputs
    this._updateAnswerInputs();

    // highlight active card
    this._container.querySelectorAll('.race-scene-card').forEach((c, i) => {
      c.classList.toggle('active', i === idx);
    });

    this.t = 0;
    this._answerChecked = false;
    this._hideVerdict();
    this.draw();
    if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
    this._updateStatsBar();
  }

  /* ─── Internal: DOM builder ────────────────────────────────── */
  _build() {
    const c = this._container;
    c.innerHTML = '';
    c.className = 'race-root';

    // ── Question bar ──
    const qBar = document.createElement('div');
    qBar.className = 'race-question-bar';
    qBar.innerHTML = '<span class="race-question-text"></span>';
    c.appendChild(qBar);

    // ── Main area ──
    const body = document.createElement('div');
    body.className = 'race-body';
    c.appendChild(body);

    // Left panel
    const panel = document.createElement('div');
    panel.className = 'race-panel';
    body.appendChild(panel);
    this._buildPanel(panel);

    // Scene area (canvas + answer bar)
    const sceneWrap = document.createElement('div');
    sceneWrap.className = 'race-scene-wrap';
    body.appendChild(sceneWrap);

    // canvas for track
    this._canvasOuter = document.createElement('div');
    this._canvasOuter.className = 'race-canvas-outer';
    sceneWrap.appendChild(this._canvasOuter);

    this._canvas = document.createElement('canvas');
    this._canvas.className = 'race-track-canvas';
    this._canvasOuter.appendChild(this._canvas);

    // graph canvas
    const graphWrap = document.createElement('div');
    graphWrap.className = 'race-graph-wrap';
    sceneWrap.appendChild(graphWrap);

    this._graphCanvas = document.createElement('canvas');
    this._graphCanvas.className = 'race-graph-canvas';
    graphWrap.appendChild(this._graphCanvas);

    // Answer bar
    this._answerBar = document.createElement('div');
    this._answerBar.className = 'race-answer-bar';
    sceneWrap.appendChild(this._answerBar);

    // Stats bar
    this._statsBar = document.createElement('div');
    this._statsBar.className = 'race-stats-bar';
    c.appendChild(this._statsBar);
    this._buildStatsBar();
  }

  _buildPanel(panel) {
    // Quick bar
    panel.innerHTML = `
      <div class="race-quick-bar">
        <button class="mag-mode-btn race-btn-play" id="race-btn-play" onclick="racePlay()">Старт</button>
        <button class="mag-mode-btn" onclick="racePause()">Пауза</button>
        <button class="mag-mode-btn" onclick="raceReset()">Сброс</button>
      </div>

      <details class="race-acc" open>
        <summary>Сценарии</summary>
        <div class="race-acc-body race-scenarios-list" id="race-scenarios-list"></div>
      </details>

      <details class="race-acc" id="race-acc-params">
        <summary>Параметры</summary>
        <div class="race-acc-body" id="race-params-body">
        </div>
      </details>

      <details class="race-acc">
        <summary>Отображение</summary>
        <div class="race-acc-body">
          <div class="dyn-checks">
            <label>
              <input type="checkbox" id="race-chk-strobe" checked onchange="raceToggle('strobe',this.checked)">
              Стробоскоп
            </label>
            <label>
              <input type="checkbox" id="race-chk-xt" checked onchange="raceToggle('xt',this.checked)">
              График x(t)
            </label>
            <label>
              <input type="checkbox" id="race-chk-vt" onchange="raceToggle('vt',this.checked)">
              График v(t)
            </label>
          </div>
        </div>
      </details>
    `;

    // Fill scenario list
    const listEl = panel.querySelector('#race-scenarios-list');
    if (listEl) this._buildScenarioCards(listEl);
  }

  _buildScenarioCards(container) {
    container.innerHTML = '';
    RaceSim.SCENARIOS.forEach((sc, i) => {
      const card = document.createElement('div');
      card.className = 'race-scene-card';
      card.dataset.idx = i;
      const icons = sc.movers.map(m => this._iconSVG(m.icon, m.color, 14)).join('');
      card.innerHTML = `<div class="race-scene-icons">${icons}</div><div class="race-scene-info"><div class="race-scene-title">${sc.title}</div></div>`;
      card.addEventListener('click', () => {
        if (raceSim) raceSim.setScenario(i);
      });
      container.appendChild(card);
    });
  }

  _updateAnswerInputs() {
    const bar = this._answerBar;
    if (!bar) return;
    const sc = this._scenario;
    let html = '<div class="race-answer-inputs">';
    sc.questions.forEach((q, i) => {
      html += `<label class="race-ans-label">${q.label}: <input type="number" class="race-ans-inp" id="race-ans-${i}" step="any" placeholder="?"></label>`;
    });
    html += '</div>';
    html += '<div class="race-answer-btns">';
    html += '<button class="mag-mode-btn race-check-btn" onclick="raceCheck()">Проверить</button>';
    html += '<button class="mag-mode-btn" onclick="racePlay()">Анимация</button>';
    html += '</div>';
    html += '<div class="race-verdict" id="race-verdict" style="display:none"></div>';
    bar.innerHTML = html;
  }

  _buildStatsBar() {
    const sb = this._statsBar;
    sb.innerHTML = `
      <div class="pstat"><div class="pstat-label">Время</div><div class="pstat-val" id="racebar-t" style="color:#FFD166">0</div></div>
      <div class="pstat"><div class="pstat-label">t встречи</div><div class="pstat-val" id="racebar-mt" style="color:var(--violet)">—</div></div>
      <div class="pstat"><div class="pstat-label">x встречи</div><div class="pstat-val" id="racebar-mx" style="color:var(--cyan)">—</div></div>
      <div class="pstat"><div class="pstat-label">Лидер</div><div class="pstat-val" id="racebar-lead" style="color:#EF476F">—</div></div>
      <div class="pstat"><div class="pstat-label">Расстояние</div><div class="pstat-val" id="racebar-dist" style="color:rgba(200,220,255,0.8)">—</div></div>
    `;
  }

  /* ─── Physics ──────────────────────────────────────────────── */
  _pos(mover, t) {
    const eff_t = Math.max(0, t - (mover.delay || 0));
    return mover.x0 + mover.v0 * eff_t + 0.5 * (mover.a || 0) * eff_t * eff_t;
  }

  _vel(mover, t) {
    const eff_t = Math.max(0, t - (mover.delay || 0));
    return mover.v0 + (mover.a || 0) * eff_t;
  }

  _computeEvents() {
    const sc = this._scenario;
    this._meetT = null;
    this._meetX = null;
    this._finishEvents = [];

    // find meeting time of first two movers
    if (sc.movers.length >= 2) {
      const m0 = sc.movers[0];
      const m1 = sc.movers[1];
      const mt = this._findMeetTime(m0, m1);
      if (mt !== null && mt >= 0) {
        this._meetT = mt;
        this._meetX = this._pos(m0, mt);
      }
    }

    // find finish events if track has finish line
    if (sc.track.finish) {
      sc.movers.forEach((m, i) => {
        const tf = this._findFinishTime(m, sc.track.finish);
        if (tf !== null) {
          this._finishEvents.push({ moverIdx: i, tFinish: tf, xFinish: sc.track.finish });
        }
      });
      this._finishEvents.sort((a, b) => a.tFinish - b.tFinish);
    }
  }

  _findMeetTime(m0, m1) {
    // x0(t) = x1(t)  →  solve quadratic or linear
    const delay0 = m0.delay || 0;
    const delay1 = m1.delay || 0;

    // Use numerical approach for simplicity (handles delay, quadratic)
    // Also provide analytical for special cases
    const a0 = m0.a || 0;
    const a1 = m1.a || 0;

    if (delay0 === 0 && delay1 === 0) {
      // x0 + v0·t + 0.5·a0·t² = x1 + v1·t + 0.5·a1·t²
      const dA = 0.5 * (a0 - a1);
      const dV = m0.v0 - m1.v0;
      const dX = m0.x0 - m1.x0;

      if (Math.abs(dA) < 1e-12) {
        // linear
        if (Math.abs(dV) < 1e-12) return null;
        const t = -dX / dV;
        return t >= 0 ? t : null;
      }
      // quadratic: dA·t² + dV·t + dX = 0
      const D = dV * dV - 4 * dA * dX;
      if (D < 0) return null;
      const sqD = Math.sqrt(D);
      const t1 = (-dV - sqD) / (2 * dA);
      const t2 = (-dV + sqD) / (2 * dA);
      const valid = [t1, t2].filter(t => t > 1e-9);
      if (!valid.length) return null;
      return Math.min(...valid);
    }

    // Numerical search when delays involved
    return this._numericalMeet(m0, m1);
  }

  _numericalMeet(m0, m1) {
    const sc = this._scenario;
    const tMax = (sc.track.timeUnit === 'ч') ? 24 : 200;
    let prev = this._pos(m0, 0) - this._pos(m1, 0);
    for (let i = 1; i <= 2000; i++) {
      const t = tMax * i / 2000;
      const cur = this._pos(m0, t) - this._pos(m1, t);
      if (prev * cur <= 0 && i > 1) {
        // binary search
        let lo = tMax * (i - 1) / 2000;
        let hi = t;
        for (let k = 0; k < 50; k++) {
          const mid = (lo + hi) / 2;
          const v = this._pos(m0, mid) - this._pos(m1, mid);
          if (Math.abs(v) < 1e-9) return mid;
          if (prev * v < 0) hi = mid; else lo = mid;
        }
        return (lo + hi) / 2;
      }
      prev = cur;
    }
    return null;
  }

  _findFinishTime(m, finishX) {
    // x0 + v0·t + 0.5·a·t² = finishX
    const delay = m.delay || 0;
    // after delay t_eff = t - delay
    // x0 + v0·t_eff + 0.5·a·t_eff² = finishX
    const a = m.a || 0;
    const dX = finishX - m.x0;

    if (Math.abs(a) < 1e-12) {
      if (Math.abs(m.v0) < 1e-12) return null;
      const te = dX / m.v0;
      if (te < 0) return null;
      return te + delay;
    }
    // quadratic: 0.5·a·te² + v0·te - dX = 0
    const D = m.v0 * m.v0 + 2 * a * dX;
    if (D < 0) return null;
    const sqD = Math.sqrt(D);
    const te1 = (-m.v0 + sqD) / a;
    const te2 = (-m.v0 - sqD) / a;
    const valid = [te1, te2].filter(te => te > 1e-9);
    if (!valid.length) return null;
    return Math.min(...valid) + delay;
  }

  /* ─── Animation loop ───────────────────────────────────────── */
  _tick() {
    if (!this.playing) return;
    this._raf = requestAnimationFrame(ts => {
      if (!this.playing) return;
      if (this._lastTs === null) this._lastTs = ts;
      const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
      this._lastTs = ts;

      this.t += rawDt * this.speed;

      // Check for meeting flash
      const sc = this._scenario;
      if (this._meetT !== null && !this._meetFlashed) {
        if (this.t >= this._meetT) {
          this._meetFlashed = true;
          this._flashTimer = 0.6;
          if (window.LabFX) LabFX.sound.play('chime');
        }
      }
      if (this._flashTimer > 0) this._flashTimer -= rawDt;

      // auto-stop
      const tEnd = this._tEnd();
      if (this.t >= tEnd) {
        this.t = tEnd;
        this.playing = false;
        this._syncPlayBtn();
        if (window.LabFX) LabFX.sound.play('impact', { volume: 0.25 });
      }

      this.draw();
      if (this.showGraphXT || this.showGraphVT) this._drawGraphs();
      this._updateStatsBar();
      if (this.playing) this._tick();
    });
  }

  _tEnd() {
    const sc = this._scenario;
    // time for all movers to traverse the track
    const tUnit = sc.track.timeUnit;
    let tMax = tUnit === 'ч' ? 10 : 30;

    if (this._meetT !== null) tMax = Math.max(tMax, this._meetT * 1.5);
    if (this._finishEvents.length) {
      const last = this._finishEvents[this._finishEvents.length - 1];
      tMax = Math.max(tMax, last.tFinish * 1.5);
    }
    return tMax;
  }

  _resetMoverPositions() {
    if (!this._scenario) return;
    this._movers = this._scenario.movers.map(m => Object.assign({}, m));
    this._meetFlashed = false;
    this._flashTimer  = 0;
    // rebuild param sliders
    this._rebuildParams();
  }

  _rebuildParams() {
    const body = this._container.querySelector('#race-params-body');
    if (!body || !this._scenario) return;
    const sc = this._scenario;
    let html = '';
    this._movers.forEach((m, i) => {
      const trackLen = sc.track.length;
      const vMax = Math.max(Math.abs(m.v0) * 2, 30, trackLen);
      const aMax = Math.max(Math.abs(m.a || 0) * 2, 10);
      const colorStyle = `color:${m.color}`;
      html += `
        <div class="race-mover-card">
          <div class="race-mover-header">
            ${this._iconSVG(m.icon, m.color, 16)}
            <span style="${colorStyle};font-weight:700">${m.label}</span>
          </div>
          <div class="param-block">
            <div class="param-header"><span class="param-name">x₀ (${sc.track.unit})</span>
              <span class="param-val" id="race-x0-val-${i}" style="${colorStyle}">${m.x0}</span></div>
            <input type="range" class="param-slider" min="0" max="${trackLen}" step="1" value="${m.x0}"
              oninput="raceParam(${i},'x0',this.value)">
          </div>
          <div class="param-block">
            <div class="param-header"><span class="param-name">v₀ (${sc.track.unit}/${sc.track.timeUnit})</span>
              <span class="param-val" id="race-v0-val-${i}" style="${colorStyle}">${m.v0}</span></div>
            <input type="range" class="param-slider" min="${-vMax}" max="${vMax}" step="1" value="${m.v0}"
              oninput="raceParam(${i},'v0',this.value)">
          </div>
          <div class="param-block">
            <div class="param-header"><span class="param-name">a (${sc.track.unit}/${sc.track.timeUnit}²)</span>
              <span class="param-val" id="race-a-val-${i}" style="${colorStyle}">${m.a || 0}</span></div>
            <input type="range" class="param-slider" min="${-aMax}" max="${aMax}" step="0.5" value="${m.a || 0}"
              oninput="raceParam(${i},'a',this.value)">
          </div>
        </div>`;
    });
    html += `<button class="mag-mode-btn" style="width:100%;margin-top:6px;font-size:.78rem" onclick="raceResetToScenario()">Сброс к сценарию</button>`;
    body.innerHTML = html;
  }

  /* ─── Drawing ──────────────────────────────────────────────── */
  draw() {
    const ctx = this._ctx;
    if (!ctx) return;
    const W = this._W, H = this._H;
    const sc = this._scenario;
    if (!sc) return;

    ctx.clearRect(0, 0, W, H);

    // background
    ctx.fillStyle = RaceSim.BG;
    ctx.fillRect(0, 0, W, H);

    const isVert = !!(sc.track.vertical);
    if (isVert) {
      this._drawVertical(ctx, W, H, sc);
    } else {
      this._drawHorizontal(ctx, W, H, sc);
    }
  }

  _drawHorizontal(ctx, W, H, sc) {
    const PAD_L = 40, PAD_R = 30, PAD_T = 30, PAD_B = 60;
    const trackW = W - PAD_L - PAD_R;
    const trackLen = sc.track.length;
    const scale = trackW / trackLen;

    const trackY = H / 2 - 10;
    const roadH  = 28;

    // grid / axis
    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.05)';
    ctx.lineWidth = 1;
    const step = this._niceStep(trackLen, 8);
    for (let x = 0; x <= trackLen; x += step) {
      const px = PAD_L + x * scale;
      ctx.beginPath(); ctx.moveTo(px, PAD_T); ctx.lineTo(px, H - PAD_B);
      ctx.stroke();
    }
    ctx.restore();

    // road
    const roadGrad = ctx.createLinearGradient(0, trackY - roadH / 2, 0, trackY + roadH / 2);
    roadGrad.addColorStop(0, 'rgba(40,42,60,0.9)');
    roadGrad.addColorStop(1, 'rgba(20,22,38,0.95)');
    ctx.fillStyle = roadGrad;
    ctx.beginPath();
    ctx.roundRect(PAD_L, trackY - roadH / 2, trackW, roadH, 6);
    ctx.fill();

    // road dashes
    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.12)';
    ctx.lineWidth = 2;
    ctx.setLineDash([14, 12]);
    ctx.beginPath();
    ctx.moveTo(PAD_L, trackY);
    ctx.lineTo(PAD_L + trackW, trackY);
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.restore();

    // finish line
    if (sc.track.finish) {
      const fx = PAD_L + sc.track.finish * scale;
      ctx.save();
      ctx.strokeStyle = '#FFD166';
      ctx.lineWidth = 2.5;
      ctx.setLineDash([6, 4]);
      ctx.beginPath();
      ctx.moveTo(fx, trackY - roadH / 2 - 12);
      ctx.lineTo(fx, trackY + roadH / 2 + 12);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = '#FFD166';
      ctx.font = `bold 11px ${RaceSim.FONT}`;
      ctx.textAlign = 'center';
      ctx.fillText('Финиш', fx, trackY - roadH / 2 - 16);
      ctx.restore();
    }

    // distance marks
    ctx.save();
    ctx.fillStyle = 'rgba(255,255,255,0.35)';
    ctx.font = `10px ${RaceSim.FONT}`;
    ctx.textAlign = 'center';
    for (let x = 0; x <= trackLen; x += step) {
      const px = PAD_L + x * scale;
      ctx.fillText(x + (x === 0 ? '' : ' ' + sc.track.unit), px, H - PAD_B + 14);
    }
    ctx.restore();

    // strobe positions
    if (this.showStrobe) {
      this._drawStrobe(ctx, scale, trackY, PAD_L, sc, false);
    }

    // meeting flash
    if (this._meetT !== null && this._flashTimer > 0) {
      const meetPx = PAD_L + this._meetX * scale;
      const alpha = this._flashTimer / 0.6;
      ctx.save();
      ctx.strokeStyle = `rgba(255,80,80,${alpha * 0.9})`;
      ctx.lineWidth = 3;
      ctx.setLineDash([]);
      ctx.beginPath();
      ctx.moveTo(meetPx, PAD_T);
      ctx.lineTo(meetPx, H - PAD_B);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.restore();
    }

    // meeting marker (always if computed and t >= meetT)
    if (this._meetT !== null && this.t >= this._meetT) {
      this._drawMeetMarker(ctx, PAD_L + this._meetX * scale, trackY - roadH / 2 - 8, sc);
    }

    // draw movers
    this._movers.forEach((m, i) => {
      const x = this._pos(m, this.t);
      const px = PAD_L + x * scale;
      this._drawMoverIcon(ctx, m, px, trackY, 22, false);
    });

    // speed vectors (small arrows above movers)
    this._movers.forEach((m, i) => {
      const x  = this._pos(m, this.t);
      const px = PAD_L + x * scale;
      const v  = this._vel(m, this.t);
      const vLen = Math.min(Math.abs(v) / (trackLen / trackW * 2 + 1) * 15, 50);
      if (Math.abs(v) > 0.01) {
        this._drawArrow(ctx, px, trackY - roadH / 2 - 28, vLen * Math.sign(v), 0, m.color);
      }
    });
  }

  _drawVertical(ctx, W, H, sc) {
    const PAD_L = 80, PAD_R = 30, PAD_T = 20, PAD_B = 30;
    const trackH = H - PAD_T - PAD_B;
    const trackLen = sc.track.length;
    const scale = trackH / trackLen;
    const trackX = W / 2;
    const roadW = 28;

    // road
    const roadGrad = ctx.createLinearGradient(trackX - roadW / 2, 0, trackX + roadW / 2, 0);
    roadGrad.addColorStop(0, 'rgba(20,22,38,0.95)');
    roadGrad.addColorStop(1, 'rgba(40,42,60,0.9)');
    ctx.fillStyle = roadGrad;
    ctx.beginPath();
    ctx.roundRect(trackX - roadW / 2, PAD_T, roadW, trackH, 6);
    ctx.fill();

    // distance marks left
    ctx.save();
    ctx.fillStyle = 'rgba(255,255,255,0.35)';
    ctx.font = `10px ${RaceSim.FONT}`;
    ctx.textAlign = 'right';
    const step = this._niceStep(trackLen, 8);
    for (let x = 0; x <= trackLen; x += step) {
      const py = PAD_T + x * scale;
      ctx.fillText(x + ' ' + sc.track.unit, trackX - roadW / 2 - 4, py + 4);
      ctx.save();
      ctx.strokeStyle = 'rgba(255,255,255,0.08)';
      ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(trackX - roadW / 2, py); ctx.lineTo(trackX + roadW / 2, py);
      ctx.stroke();
      ctx.restore();
    }
    ctx.restore();

    // finish line
    if (sc.track.finish) {
      const fy = PAD_T + sc.track.finish * scale;
      ctx.save();
      ctx.strokeStyle = '#FFD166';
      ctx.lineWidth = 2.5;
      ctx.setLineDash([6, 4]);
      ctx.beginPath();
      ctx.moveTo(trackX - roadW / 2 - 10, fy);
      ctx.lineTo(trackX + roadW / 2 + 10, fy);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = '#FFD166';
      ctx.font = `bold 10px ${RaceSim.FONT}`;
      ctx.textAlign = 'left';
      ctx.fillText('Финиш ' + sc.track.finish + ' ' + sc.track.unit, trackX + roadW / 2 + 14, fy + 4);
      ctx.restore();
    }

    // strobe
    if (this.showStrobe) {
      this._drawStrobe(ctx, scale, trackX, PAD_T, sc, true);
    }

    // movers
    const totalMovers = this._movers.length;
    this._movers.forEach((m, i) => {
      const y = this._pos(m, this.t);
      const py = PAD_T + y * scale;
      const offsetX = trackX - 30 + i * 32;
      this._drawMoverIcon(ctx, m, offsetX, py, 18, true);
    });
  }

  _drawStrobe(ctx, scale, baseCoord, pad, sc, vertical) {
    const tCur  = this.t;
    const tEnd  = this._tEnd();
    const sInterval = tEnd / 20; // 20 ghost positions

    ctx.save();
    ctx.globalAlpha = 0.18;
    for (let st = sInterval; st < tCur; st += sInterval) {
      this._movers.forEach(m => {
        const pos = this._pos(m, st);
        if (vertical) {
          const py = pad + pos * scale;
          this._drawMoverIcon(ctx, m, baseCoord, py, 10, true);
        } else {
          const px = pad + pos * scale;
          this._drawMoverIcon(ctx, m, px, baseCoord, 10, false);
        }
      });
    }
    ctx.globalAlpha = 1;
    ctx.restore();
  }

  _drawMoverIcon(ctx, mover, cx, cy, size, vertical) {
    const icon = mover.icon || 'car';
    const color = mover.color || '#06D6E0';
    ctx.save();
    ctx.translate(cx, cy);
    // flip icon direction based on velocity sign for horizontal
    if (!vertical && this._vel(mover, this.t) < 0) {
      ctx.scale(-1, 1);
    }
    this._drawIcon(ctx, icon, color, size);
    ctx.restore();
  }

  _drawIcon(ctx, icon, color, size) {
    const s = size;
    ctx.strokeStyle = color;
    ctx.fillStyle = color + '44';
    ctx.lineWidth = 1.5;

    switch (icon) {
      case 'car':
        // simple car shape
        ctx.beginPath();
        ctx.rect(-s, -s * 0.5, s * 2, s);
        ctx.fill(); ctx.stroke();
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.arc(-s * 0.5, s * 0.5, s * 0.28, 0, Math.PI * 2);
        ctx.fill();
        ctx.beginPath();
        ctx.arc(s * 0.5, s * 0.5, s * 0.28, 0, Math.PI * 2);
        ctx.fill();
        break;

      case 'train':
        ctx.beginPath();
        ctx.rect(-s, -s * 0.6, s * 2, s * 1.1);
        ctx.fill(); ctx.stroke();
        // windows
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.rect(-s * 0.6, -s * 0.4, s * 0.5, s * 0.4);
        ctx.fill();
        ctx.beginPath();
        ctx.rect(s * 0.1, -s * 0.4, s * 0.5, s * 0.4);
        ctx.fill();
        break;

      case 'bike':
        // bicycle wheel-wheel
        ctx.beginPath();
        ctx.arc(-s * 0.55, 0, s * 0.45, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
        ctx.beginPath();
        ctx.arc(s * 0.55, 0, s * 0.45, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(-s * 0.55, 0); ctx.lineTo(0, -s * 0.5); ctx.lineTo(s * 0.55, 0);
        ctx.stroke();
        break;

      case 'moto':
        ctx.beginPath();
        ctx.arc(-s * 0.5, 0, s * 0.4, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
        ctx.beginPath();
        ctx.arc(s * 0.5, 0, s * 0.4, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.moveTo(-s * 0.2, -s * 0.5);
        ctx.lineTo(s * 0.5, -s * 0.2);
        ctx.lineTo(s * 0.5, 0);
        ctx.lineTo(-s * 0.2, 0);
        ctx.closePath();
        ctx.fill();
        break;

      case 'runner':
        // stick figure
        ctx.strokeStyle = color;
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.arc(0, -s, s * 0.3, 0, Math.PI * 2);
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(0, -s * 0.7); ctx.lineTo(0, s * 0.3);
        ctx.moveTo(-s * 0.5, s * 0.1); ctx.lineTo(0, -s * 0.1); ctx.lineTo(s * 0.5, s * 0.1);
        ctx.moveTo(-s * 0.4, s); ctx.lineTo(0, s * 0.3); ctx.lineTo(s * 0.4, s);
        ctx.stroke();
        break;

      case 'ball':
        ctx.beginPath();
        ctx.arc(0, 0, s * 0.65, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
        break;

      case 'boat':
        // hull
        ctx.beginPath();
        ctx.moveTo(-s, 0);
        ctx.quadraticCurveTo(-s, s * 0.6, 0, s * 0.7);
        ctx.quadraticCurveTo(s, s * 0.6, s, 0);
        ctx.closePath();
        ctx.fill(); ctx.stroke();
        // mast
        ctx.strokeStyle = color;
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.moveTo(0, 0); ctx.lineTo(0, -s);
        ctx.stroke();
        // sail
        ctx.beginPath();
        ctx.moveTo(0, -s * 0.9); ctx.lineTo(s * 0.9, -s * 0.3); ctx.lineTo(0, -s * 0.15);
        ctx.closePath();
        ctx.fillStyle = color + '88';
        ctx.fill();
        break;

      default: // circle fallback
        ctx.beginPath();
        ctx.arc(0, 0, s * 0.65, 0, Math.PI * 2);
        ctx.fill(); ctx.stroke();
    }
  }

  _drawMeetMarker(ctx, px, py, sc) {
    ctx.save();
    ctx.strokeStyle = '#EF476F';
    ctx.lineWidth = 2;
    ctx.setLineDash([4, 3]);
    const H = this._H;
    ctx.beginPath();
    ctx.moveTo(px, 20);
    ctx.lineTo(px, H - 50);
    ctx.stroke();
    ctx.setLineDash([]);

    // badge
    const txt = `t=${this._fmtNum(this._meetT)} ${sc.track.timeUnit}  x=${this._fmtNum(this._meetX)} ${sc.track.unit}`;
    ctx.font = `bold 11px ${RaceSim.FONT}`;
    const tw = ctx.measureText(txt).width;
    const bx = Math.max(4, Math.min(px - tw / 2 - 6, this._W - tw - 16));
    const by = py - 20;
    ctx.fillStyle = 'rgba(239,71,111,0.18)';
    ctx.strokeStyle = '#EF476F';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([]);
    ctx.beginPath();
    ctx.roundRect(bx, by, tw + 12, 20, 5);
    ctx.fill(); ctx.stroke();
    ctx.fillStyle = '#EF476F';
    ctx.textAlign = 'left';
    ctx.fillText(txt, bx + 6, by + 14);
    ctx.restore();
  }

  _drawArrow(ctx, x, y, dx, dy, color) {
    if (Math.abs(dx) < 2 && Math.abs(dy) < 2) return;
    ctx.save();
    ctx.strokeStyle = color;
    ctx.fillStyle   = color;
    ctx.lineWidth   = 2;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + dx, y + dy);
    ctx.stroke();
    // arrowhead
    const angle = Math.atan2(dy, dx);
    ctx.beginPath();
    ctx.moveTo(x + dx, y + dy);
    ctx.lineTo(x + dx - 7 * Math.cos(angle - 0.4), y + dy - 7 * Math.sin(angle - 0.4));
    ctx.lineTo(x + dx - 7 * Math.cos(angle + 0.4), y + dy - 7 * Math.sin(angle + 0.4));
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  /* ─── Graphs ───────────────────────────────────────────────── */
  _drawGraphs() {
    const gctx = this._gctx;
    if (!gctx) return;
    const W = this._gW, H = this._gH;
    const sc = this._scenario;
    if (!sc) return;

    gctx.clearRect(0, 0, W, H);
    gctx.fillStyle = 'rgba(8,8,20,0.92)';
    gctx.fillRect(0, 0, W, H);

    const tEnd = this._tEnd();
    const PAD_L = 42, PAD_R = 12, PAD_T = 14, PAD_B = 24;
    const gW = W - PAD_L - PAD_R;
    const gH = H - PAD_T - PAD_B;

    // determine y range
    let yMin = Infinity, yMax = -Infinity;
    const N = 200;
    this._movers.forEach(m => {
      for (let i = 0; i <= N; i++) {
        const t = tEnd * i / N;
        const v = this.showGraphVT ? this._vel(m, t) : this._pos(m, t);
        if (v < yMin) yMin = v;
        if (v > yMax) yMax = v;
      }
    });
    const yRange = yMax - yMin || 1;
    yMin -= yRange * 0.05;
    yMax += yRange * 0.05;

    // axes
    gctx.save();
    gctx.strokeStyle = 'rgba(255,255,255,0.2)';
    gctx.lineWidth = 1;
    gctx.beginPath();
    gctx.moveTo(PAD_L, PAD_T);
    gctx.lineTo(PAD_L, PAD_T + gH);
    gctx.lineTo(PAD_L + gW, PAD_T + gH);
    gctx.stroke();
    gctx.restore();

    // axis labels
    gctx.save();
    gctx.fillStyle = 'rgba(255,255,255,0.3)';
    gctx.font = `9px ${RaceSim.FONT}`;
    gctx.textAlign = 'right';
    const yLabel = this.showGraphVT ? 'v(t)' : 'x(t)';
    gctx.fillStyle = 'rgba(255,255,255,0.5)';
    gctx.font = `bold 10px ${RaceSim.FONT}`;
    gctx.textAlign = 'center';
    gctx.fillText(yLabel, PAD_L - 6, PAD_T - 3);

    // y grid
    gctx.font = `9px ${RaceSim.FONT}`;
    gctx.textAlign = 'right';
    gctx.fillStyle = 'rgba(255,255,255,0.28)';
    const yStep = this._niceStep(yMax - yMin, 4);
    const yStart = Math.ceil(yMin / yStep) * yStep;
    for (let yv = yStart; yv <= yMax; yv += yStep) {
      const py = PAD_T + gH - (yv - yMin) / (yMax - yMin) * gH;
      gctx.save();
      gctx.strokeStyle = 'rgba(255,255,255,0.06)';
      gctx.lineWidth = 1;
      gctx.beginPath(); gctx.moveTo(PAD_L, py); gctx.lineTo(PAD_L + gW, py); gctx.stroke();
      gctx.restore();
      gctx.fillText(this._fmtNum(yv), PAD_L - 3, py + 3);
    }

    // x axis labels (time)
    gctx.fillStyle = 'rgba(255,255,255,0.28)';
    gctx.textAlign = 'center';
    const tStep = this._niceStep(tEnd, 5);
    for (let tv = 0; tv <= tEnd; tv += tStep) {
      const px = PAD_L + (tv / tEnd) * gW;
      gctx.fillText(this._fmtNum(tv), px, PAD_T + gH + 14);
    }
    gctx.restore();

    // curves
    this._movers.forEach(m => {
      gctx.save();
      gctx.strokeStyle = m.color;
      gctx.lineWidth = 2;
      gctx.beginPath();
      let first = true;
      for (let i = 0; i <= N; i++) {
        const t = tEnd * i / N;
        const yv = this.showGraphVT ? this._vel(m, t) : this._pos(m, t);
        const px = PAD_L + (t / tEnd) * gW;
        const py = PAD_T + gH - (yv - yMin) / (yMax - yMin) * gH;
        if (first) { gctx.moveTo(px, py); first = false; }
        else gctx.lineTo(px, py);
      }
      gctx.stroke();
      gctx.restore();
    });

    // meeting point dot
    if (this._meetT !== null) {
      const m0 = this._movers[0];
      const yv  = this.showGraphVT ? this._vel(m0, this._meetT) : this._pos(m0, this._meetT);
      const px  = PAD_L + (this._meetT / tEnd) * gW;
      const py  = PAD_T + gH - (yv - yMin) / (yMax - yMin) * gH;
      gctx.save();
      gctx.fillStyle = '#EF476F';
      gctx.strokeStyle = '#fff';
      gctx.lineWidth = 1.5;
      gctx.beginPath();
      gctx.arc(px, py, 5, 0, Math.PI * 2);
      gctx.fill(); gctx.stroke();
      // label
      gctx.fillStyle = '#EF476F';
      gctx.font = `bold 9px ${RaceSim.FONT}`;
      gctx.textAlign = 'left';
      gctx.fillText(`t=${this._fmtNum(this._meetT)}`, px + 7, py - 3);
      gctx.restore();
    }

    // current time cursor
    const curPx = PAD_L + (this.t / tEnd) * gW;
    gctx.save();
    gctx.strokeStyle = 'rgba(255,255,255,0.35)';
    gctx.lineWidth = 1;
    gctx.setLineDash([3, 3]);
    gctx.beginPath();
    gctx.moveTo(curPx, PAD_T);
    gctx.lineTo(curPx, PAD_T + gH);
    gctx.stroke();
    gctx.restore();
  }

  /* ─── Stats bar ────────────────────────────────────────────── */
  _updateStatsBar() {
    const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
    const sc = this._scenario;
    if (!sc) return;
    const u = sc.track.unit;
    const tu = sc.track.timeUnit;

    v('racebar-t', this._fmtNum(this.t) + ' ' + tu);
    v('racebar-mt', this._meetT !== null ? this._fmtNum(this._meetT) + ' ' + tu : '—');
    v('racebar-mx', this._meetX !== null ? this._fmtNum(this._meetX) + ' ' + u  : '—');

    if (this._movers.length >= 2) {
      const pos0 = this._pos(this._movers[0], this.t);
      const pos1 = this._pos(this._movers[1], this.t);
      const lead = pos0 > pos1 ? this._movers[0].label : pos1 > pos0 ? this._movers[1].label : '=';
      const dist = Math.abs(pos0 - pos1);
      v('racebar-lead', lead);
      v('racebar-dist', this._fmtNum(dist) + ' ' + u);
    }
  }

  /* ─── Check answer ─────────────────────────────────────────── */
  checkAnswer() {
    const sc = this._scenario;
    const verdictEl = document.getElementById('race-verdict');
    if (!verdictEl) return;

    // recalculate correct answers
    let correct = true;
    let details = '';

    sc.questions.forEach((q, i) => {
      const inp = document.getElementById('race-ans-' + i);
      if (!inp) return;
      const userVal = parseFloat(inp.value);
      const ok = !isNaN(userVal) && Math.abs(userVal - q.answer) <= q.tolerance + Math.abs(q.answer) * 0.05;
      if (!ok) correct = false;
      details += `<span class="${ok ? 'race-ok' : 'race-err'}">${q.label}: ${ok ? 'верно' : 'нет, правильно: ' + this._fmtNum(q.answer)}</span>  `;
    });

    verdictEl.style.display = 'block';
    verdictEl.className = 'race-verdict ' + (correct ? 'race-verdict-ok' : 'race-verdict-err');
    verdictEl.innerHTML = (correct
      ? '<b>Верно!</b> '
      : '<b>Не верно.</b> ')
      + details;

    this._answerChecked = true;
    this._answerCorrect = correct;

    if (window.LabFX) {
      LabFX.sound.play(correct ? 'chime' : 'impact', { volume: 0.4 });
    }
  }

  _hideVerdict() {
    const el = document.getElementById('race-verdict');
    if (el) el.style.display = 'none';
  }

  /* ─── Param change ─────────────────────────────────────────── */
  setParam(moverIdx, param, val) {
    if (moverIdx < 0 || moverIdx >= this._movers.length) return;
    const numVal = parseFloat(val);
    this._movers[moverIdx][param] = numVal;
    // update display
    const el = document.getElementById(`race-${param}-val-${moverIdx}`);
    if (el) el.textContent = this._fmtNum(numVal);
    // recompute events
    this._computeEvents();
    this.reset();
  }

  resetToScenario() {
    this.setScenario(this._scenarioIdx);
  }

  /* ─── Helpers ──────────────────────────────────────────────── */
  _syncPlayBtn() {
    const btn = document.getElementById('race-btn-play');
    if (btn) btn.textContent = this.playing ? 'Пауза' : 'Старт';
  }

  _fmtNum(v) {
    if (v === null || v === undefined) return '—';
    if (Math.abs(v) < 0.001) return '0';
    if (Math.abs(v) < 10) return parseFloat(v.toFixed(3)).toString();
    if (Math.abs(v) < 100) return parseFloat(v.toFixed(2)).toString();
    return parseFloat(v.toFixed(1)).toString();
  }

  _niceStep(range, maxTicks) {
    const raw = range / maxTicks;
    const mag = Math.pow(10, Math.floor(Math.log10(raw)));
    const norm = raw / mag;
    let nice;
    if (norm < 1.5) nice = 1;
    else if (norm < 3.5) nice = 2;
    else if (norm < 7.5) nice = 5;
    else nice = 10;
    return nice * mag;
  }

  _iconSVG(icon, color, size) {
    const s = size || 16;
    const c = color || '#06D6E0';
    const icons = {
      car:    `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="8" width="22" height="10" rx="3"/><path d="M5 8l3-5h8l3 5"/><circle cx="7" cy="18" r="2" fill="${c}"/><circle cx="17" cy="18" r="2" fill="${c}"/></svg>`,
      train:  `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><rect x="4" y="3" width="16" height="16" rx="3"/><path d="M4 11h16"/><circle cx="9" cy="19" r="2" fill="${c}"/><circle cx="15" cy="19" r="2" fill="${c}"/><path d="M9 21l-2 2M15 21l2 2"/></svg>`,
      bike:   `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2"><circle cx="6" cy="15" r="5"/><circle cx="18" cy="15" r="5"/><path d="M6 15l5-8 4 5h3m-9-5l2 8"/></svg>`,
      moto:   `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2"><circle cx="5" cy="17" r="4"/><circle cx="19" cy="17" r="4"/><path d="M5 17l5-10h7l3 5-4 3H9"/></svg>`,
      runner: `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><circle cx="13" cy="4" r="3"/><path d="M9 17l1-4 3 3 4-5"/><path d="M7 22l3-5m6 5l-3-5"/><path d="M6 11l4-4 5 2 3-3"/></svg>`,
      ball:   `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12a4 4 0 0 1 8 0"/></svg>`,
      boat:   `<svg width="${s}" height="${s}" viewBox="-1 -1 26 26" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><path d="M3 17l1 3h16l1-3"/><path d="M12 3v14"/><path d="M12 3l8 8H12"/></svg>`,
    };
    return icons[icon] || icons['ball'];
  }
}

if (typeof module !== 'undefined') module.exports = RaceSim;

/* ─── Lab UI glue ───────────────────────────────────────────── */

var raceSim = null;

function _openRace() {
  document.getElementById('sim-topbar-title').textContent = 'Гонка';
  _simShow('sim-race');
  _registerSimState('race', () => raceSim ? { scenarioIdx: raceSim._scenarioIdx } : null,
    st => { if (raceSim && st) raceSim.setScenario(st.scenarioIdx || 0); });
  if (typeof _embedMode !== 'undefined' && _embedMode) _startStateEmit('race');

  requestAnimationFrame(() => requestAnimationFrame(() => {
    const wrap = document.getElementById('race-wrap');
    if (!wrap) return;
    if (!raceSim) {
      raceSim = new RaceSim(wrap);
    } else {
      raceSim.fit();
    }
  }));
}

function racePlay() {
  if (!raceSim) return;
  if (raceSim.playing) raceSim.pause();
  else { raceSim.reset(); raceSim.play(); }
}

function racePause() {
  if (raceSim) raceSim.pause();
}

function raceReset() {
  if (raceSim) raceSim.reset();
}

function raceCheck() {
  if (raceSim) raceSim.checkAnswer();
}

function raceParam(moverIdx, param, val) {
  if (raceSim) raceSim.setParam(moverIdx, param, val);
}

function raceResetToScenario() {
  if (raceSim) raceSim.resetToScenario();
}

function raceToggle(name, checked) {
  if (!raceSim) return;
  if (name === 'strobe') raceSim.showStrobe  = checked;
  if (name === 'xt')     { raceSim.showGraphXT = checked; raceSim.showGraphVT = false; }
  if (name === 'vt')     { raceSim.showGraphVT = checked; raceSim.showGraphXT = false; }
  raceSim.draw();
  if (raceSim.showGraphXT || raceSim.showGraphVT) raceSim._drawGraphs();
}
