'use strict';
/* ══════════════════════════════════════════════════════════════
   HeatEngineSim — тепловые двигатели
   Поддерживаемые циклы: Carnot, Otto, Diesel, Brayton
   n = 1 моль, R = 8.314 Дж/(моль·К), γ = 1.4

   Левая часть: PV-диаграмма (Canvas 2D) — замкнутый цикл A→B→C→D→A
   Правая часть: анимация поршня + частицы газа
   ══════════════════════════════════════════════════════════════ */

class HeatEngineSim {
  constructor(pvCanvas, pistonCanvas) {
    this._pv  = pvCanvas;
    this._pis = pistonCanvas;
    this._pvCtx  = pvCanvas.getContext('2d');
    this._pisCtx = pistonCanvas.getContext('2d');

    /* physics constants */
    this.n = 1;
    this.R = 8.314;
    this.gamma = 1.4;

    /* parameters */
    this.cycleType = 'carnot'; // 'carnot' | 'otto' | 'diesel' | 'brayton'
    this.Th = 800;  // K
    this.Tc = 300;  // K
    this.cr = 8;    // compression ratio (Otto/Diesel)

    /* animation state */
    this._running  = false;
    this._t        = 0;     // 0..1 progress through cycle
    this._speed    = 0.004; // dt per frame
    this._raf      = null;

    /* PV viewport */
    this._ML = 58; this._MB = 46; this._MT = 28; this._MR = 22;
    this._pvW = 0; this._pvH = 0;
    this._Vmin = 0; this._Vmax = 1;
    this._Pmin = 0; this._Pmax = 1;

    /* particles for piston view */
    this._particles = [];
    this._initParticles(40);

    /* hover state */
    this._hoverSeg = -1;
    this._pv.addEventListener('mousemove', e => this._onPvMove(e));
    this._pv.addEventListener('mouseleave', () => { this._hoverSeg = -1; this._drawPv(); });

    /* tooltip */
    this._tooltip = null;

    /* callbacks */
    this.onStats = null; // called with stats object

    new ResizeObserver(() => { this.fit(); this._drawPv(); this._drawPiston(); }).observe(pvCanvas.parentElement || pvCanvas);
  }

  /* ── public API ─────────────────────────────────────── */

  setCycle(type) { this.cycleType = type; this._recompute(); }
  setTh(v)  { this.Th = Math.max(this.Tc + 10, +v); this._recompute(); }
  setTc(v)  { this.Tc = Math.max(200, Math.min(this.Th - 10, +v)); this._recompute(); }
  setCR(v)  { this.cr = Math.max(2, Math.min(20, +v)); this._recompute(); }

  start() {
    if (!this._running) {
      this._running = true;
      this._lastPhase = null;
      this._fxDroneHandle = null;
      if (window.LabFX) {
        this._fxDroneHandle = LabFX.sound.startDrone('drone');
      }
      this._loop();
    }
  }
  pause() {
    this._running = false;
    cancelAnimationFrame(this._raf);
    if (window.LabFX && this._fxDroneHandle) {
      try { this._fxDroneHandle.stop(); } catch {}
      this._fxDroneHandle = null;
    }
  }
  stop()   { this.pause(); this._t = 0; this._drawPv(); this._drawPiston(); }
  step()   { this._t = (this._t + this._speed * 10) % 1; this._drawPv(); this._drawPiston(); }
  reset()  { this.stop(); this._recompute(); }

  fit() {
    const dpr = window.devicePixelRatio || 1;
    for (const [cv, ctx, side] of [
      [this._pv,  this._pvCtx,  'pv'],
      [this._pis, this._pisCtx, 'pis'],
    ]) {
      const w = cv.offsetWidth  || 400;
      const h = cv.offsetHeight || 300;
      cv.width  = w * dpr;
      cv.height = h * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      if (side === 'pv') { this._pvW = w; this._pvH = h; }
    }
  }

  /* ── cycle computation ───────────────────────────────── */

  _recompute() {
    this._nodes = this['_nodes_' + this.cycleType]();
    this._updateViewport();
    this._emitStats();
    this._drawPv();
    this._drawPiston();
  }

  /* Returns array of {V,P,T,label,phase} nodes.
     Segments: nodes[i] → nodes[(i+1)%n], phase type on segment start. */

  _nodes_carnot() {
    const { n, R, gamma, Th, Tc } = this;
    /* choose V_A so diagarm looks good; derive B,C,D from Carnot chain */
    const VA = 0.01; // m³ — 10 L
    const PA = n * R * Th / VA;

    /* A→B: isothermal at Th; pick V_B = 3*VA */
    const VB = 3 * VA;
    const PB = n * R * Th / VB;

    /* B→C: adiabatic expansion to Tc
       Tc/Th = (VB/VC)^(γ-1)  → VC = VB*(Th/Tc)^(1/(γ-1)) */
    const VC = VB * Math.pow(Th / Tc, 1 / (gamma - 1));
    const PC = n * R * Tc / VC;

    /* C→D: isothermal at Tc, pick VD so D→A is adiabatic
       PD*VD^γ = PA*VA^γ and PD = nRTc/VD → VD = VA*(Th/Tc)^(1/(γ-1)) */
    const VD = VA * Math.pow(Th / Tc, 1 / (gamma - 1));
    const PD = n * R * Tc / VD;

    return [
      { V: VA, P: PA, T: Th, label: 'A', phase: 'isotherm_hot' },
      { V: VB, P: PB, T: Th, label: 'B', phase: 'adiabat_exp' },
      { V: VC, P: PC, T: Tc, label: 'C', phase: 'isotherm_cold' },
      { V: VD, P: PD, T: Tc, label: 'D', phase: 'adiabat_comp' },
    ];
  }

  _nodes_otto() {
    const { n, R, gamma, Th, Tc, cr } = this;
    /* Otto: 1→2 adiabatic comp, 2→3 isochoric heat, 3→4 adiabatic exp, 4→1 isochoric cool */
    const V1 = 0.02;        // m³ — BDC
    const V2 = V1 / cr;     // TDC
    const T1 = Tc;
    const P1 = n * R * T1 / V1;
    /* adiabatic compression 1→2 */
    const T2 = T1 * Math.pow(cr, gamma - 1);
    const P2 = P1 * Math.pow(cr, gamma);
    /* isochoric heat 2→3: peak temp = Th */
    const T3 = Th;
    const P3 = P2 * (T3 / T2);
    const V3 = V2;
    /* adiabatic expansion 3→4 */
    const T4 = T3 / Math.pow(cr, gamma - 1);
    const P4 = P3 / Math.pow(cr, gamma);
    const V4 = V1;

    return [
      { V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
      { V: V2, P: P2, T: T2, label: 'B', phase: 'isochoric_hot' },
      { V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
      { V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
    ];
  }

  _nodes_diesel() {
    const { n, R, gamma, Th, Tc, cr } = this;
    /* Diesel: 1→2 adiabatic comp, 2→3 isobaric heat, 3→4 adiabatic exp, 4→1 isochoric cool */
    const V1 = 0.02;
    const V2 = V1 / cr;
    const T1 = Tc;
    const P1 = n * R * T1 / V1;
    /* adiabatic comp 1→2 */
    const T2 = T1 * Math.pow(cr, gamma - 1);
    const P2 = P1 * Math.pow(cr, gamma);
    /* isobaric heat 2→3 to Th */
    const T3 = Th;
    const V3 = V2 * (T3 / T2);
    const P3 = P2;
    /* cutoff ratio */
    const rc = V3 / V2;
    /* adiabatic exp 3→4 */
    const V4 = V1;
    const P4 = P3 * Math.pow(V3 / V4, gamma);
    const T4 = P4 * V4 / (n * R);

    return [
      { V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
      { V: V2, P: P2, T: T2, label: 'B', phase: 'isobar_hot' },
      { V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
      { V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
    ];
  }

  _nodes_brayton() {
    const { n, R, gamma, Th, Tc, cr } = this;
    /* Brayton (gas turbine): 1→2 adiabatic comp, 2→3 isobaric heat, 3→4 adiabatic exp, 4→1 isobaric cool */
    const V1 = 0.025;
    const T1 = Tc;
    const P1 = n * R * T1 / V1;
    const P2 = P1 * cr;   // pressure ratio
    /* adiabatic comp */
    const T2 = T1 * Math.pow(cr, (gamma - 1) / gamma);
    const V2 = n * R * T2 / P2;
    /* isobaric heat to Th */
    const T3 = Th;
    const V3 = n * R * T3 / P2;
    const P3 = P2;
    /* adiabatic exp */
    const P4 = P1;
    const T4 = T3 / Math.pow(cr, (gamma - 1) / gamma);
    const V4 = n * R * T4 / P4;

    return [
      { V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
      { V: V2, P: P2, T: T2, label: 'B', phase: 'isobar_hot' },
      { V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
      { V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
    ];
  }

  /* ── viewport & stats ─────────────────────────────── */

  _updateViewport() {
    const ns = this._nodes;
    if (!ns || !ns.length) return;
    const Vs = ns.map(n => n.V), Ps = ns.map(n => n.P);
    const Vmin = Math.min(...Vs), Vmax = Math.max(...Vs);
    const Pmin = Math.min(...Ps), Pmax = Math.max(...Ps);
    const dV = Vmax - Vmin || 0.001;
    const dP = Pmax - Pmin || 1;
    this._Vmin = Vmin - dV * 0.18;
    this._Vmax = Vmax + dV * 0.18;
    this._Pmin = Pmin - dP * 0.18;
    this._Pmax = Pmax + dP * 0.18;
  }

  _emitStats() {
    if (!this.onStats) return;
    const ns = this._nodes;
    if (!ns) return;

    /* calculate Q_hot, Q_cold, W_net by summing segment works */
    let W = 0, Qh = 0, Qc = 0;
    const { n: nn, R, gamma } = this;
    for (let i = 0; i < ns.length; i++) {
      const a = ns[i], b = ns[(i + 1) % ns.length];
      const seg = a.phase;
      const dW = this._segWork(a, b, seg);
      const dQ = this._segHeat(a, b, seg, dW);
      W += dW;
      if (dQ > 0) Qh += dQ; else Qc += dQ;
    }

    const etaCarnot = 1 - this.Tc / this.Th;
    const eta = Qh > 0 ? W / Qh : 0;

    this.onStats({
      Th: Math.round(this.Th),
      Tc: Math.round(this.Tc),
      etaCarnot: (etaCarnot * 100).toFixed(1),
      eta: (Math.max(0, eta) * 100).toFixed(1),
      Qh: Math.round(Qh),
      Qc: Math.round(Math.abs(Qc)),
      W: Math.round(W),
    });
  }

  _segWork(a, b, phase) {
    const { n, R, gamma } = this;
    if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
      return n * R * a.T * Math.log(b.V / a.V);
    }
    if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
      return (a.P * a.V - b.P * b.V) / (gamma - 1);
    }
    if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
      return 0;
    }
    if (phase === 'isobar_hot' || phase === 'isobar_cold' || phase === 'isochoric_cold') {
      return a.P * (b.V - a.V);
    }
    return 0;
  }

  _segHeat(a, b, phase, W) {
    const { n, R, gamma } = this;
    const Cv = R / (gamma - 1);
    const dU = n * Cv * (b.T - a.T);
    if (phase === 'adiabat_exp' || phase === 'adiabat_comp') return 0;
    return dU + W;
  }

  /* ── animation loop ──────────────────────────────── */

  _loop() {
    if (!this._running) return;
    this._t = (this._t + this._speed) % 1;
    this._updateParticles();
    this._fxUpdate();
    this._drawPv();
    this._drawPiston();
    this._raf = requestAnimationFrame(() => this._loop());
  }

  _fxUpdate() {
    if (!window.LabFX) return;
    const st = this._stateAt(this._t);
    if (!st) return;

    const pisCtx = this._pisCtx;
    const W = this._pis.offsetWidth || 300;
    const H = this._pis.offsetHeight || 300;

    /* phase change tick */
    if (this._lastPhase !== null && st.phase !== this._lastPhase) {
      LabFX.sound.play('tick', { pitch: 0.8, volume: 0.2 });
      /* BDC / TDC bounce — detect segment boundaries (u≈0) */
      LabFX.sound.play('bounce', { pitch: 0.5, volume: 0.15 });
    }
    this._lastPhase = st.phase;

    /* compute piston position for particle emit */
    const ns = this._nodes;
    const Vmin = Math.min(...ns.map(n => n.V));
    const Vmax = Math.max(...ns.map(n => n.V));
    const Vfrac = (st.V - Vmin) / (Vmax - Vmin || 1);
    const cylX = W * 0.2, cylW = W * 0.6;
    const cylTop = H * 0.12, cylBot = H * 0.92;
    const cylH = cylBot - cylTop;
    const pistonY = cylTop + cylH * (1 - Vfrac);
    const resX = W * 0.05, resW = W * 0.12;

    const isHot  = st.phase === 'isotherm_hot'  || st.phase === 'isochoric_hot'  || st.phase === 'isobar_hot';
    const isCold = st.phase === 'isotherm_cold' || st.phase === 'isochoric_cold' || st.phase === 'isobar_cold';

    if (isHot) {
      /* red smoke upward from hot reservoir */
      LabFX.particles.emit({
        ctx: pisCtx, x: resX + resW / 2, y: pistonY - 5,
        count: 1, color: 'rgba(255,80,40,0.3)', speed: 15,
        spread: 0.6, angle: -Math.PI / 2, gravity: -50,
        life: 1500, shape: 'smoke', size: 6,
      });
    } else if (isCold) {
      /* blue dust downward from cold reservoir */
      LabFX.particles.emit({
        ctx: pisCtx, x: resX + resW / 2, y: pistonY + 10,
        count: 1, color: 'rgba(100,150,255,0.3)', speed: 10,
        spread: 0.6, angle: Math.PI / 2, gravity: 30,
        life: 800, shape: 'dust', size: 4,
      });
    }

    LabFX.particles.update(this._speed);
  }

  /* ── interpolated state ──────────────────────────── */

  _stateAt(t) {
    const ns = this._nodes;
    if (!ns || ns.length < 2) return null;
    const N = ns.length;
    const seg = Math.floor(t * N);
    const u   = (t * N) - seg;
    const a   = ns[seg % N];
    const b   = ns[(seg + 1) % N];
    const phase = a.phase;

    let V, P, T;
    if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
      /* isothermal: PV = const  → V = Va + u*(Vb-Va), P = const/V */
      V = a.V + u * (b.V - a.V);
      P = a.P * a.V / V;
      T = a.T;
    } else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
      /* adiabatic: PV^γ = const → parametric by V */
      V = a.V * Math.pow(b.V / a.V, u);
      P = a.P * Math.pow(a.V / V, this.gamma);
      T = P * V / (this.n * this.R);
    } else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
      /* isochoric: V = const */
      V = a.V;
      P = a.P + u * (b.P - a.P);
      T = P * V / (this.n * this.R);
    } else {
      /* isobaric */
      V = a.V + u * (b.V - a.V);
      P = a.P;
      T = P * V / (this.n * this.R);
    }
    return { V, P, T, phase, seg };
  }

  /* ── PV diagram ──────────────────────────────────── */

  _vx(v) {
    const pw = this._pvW - this._ML - this._MR;
    return this._ML + (v - this._Vmin) / (this._Vmax - this._Vmin) * pw;
  }
  _py(p) {
    const ph = this._pvH - this._MT - this._MB;
    return this._MT + (1 - (p - this._Pmin) / (this._Pmax - this._Pmin)) * ph;
  }

  _drawPv() {
    const ctx = this._pvCtx;
    const W = this._pvW, H = this._pvH;
    if (!W || !H) return;

    ctx.fillStyle = '#0D0D1A';
    ctx.fillRect(0, 0, W, H);

    if (!this._nodes) return;

    this._pvDrawGrid(ctx, W, H);
    this._pvDrawCycle(ctx);
    this._pvDrawCurrentPoint(ctx);
    this._pvDrawPhaseLabel(ctx, W);
    this._pvDrawHoverTooltip(ctx, W, H);
  }

  _pvDrawGrid(ctx, W, H) {
    const { _ML: ML, _MT: MT, _MB: MB, _MR: MR } = this;
    const pw = W - ML - MR, ph = H - MT - MB;

    ctx.fillStyle = 'rgba(255,255,255,0.018)';
    ctx.fillRect(ML, MT, pw, ph);

    ctx.strokeStyle = 'rgba(255,255,255,0.05)';
    ctx.lineWidth = 1; ctx.setLineDash([]);
    const vSteps = 6, pSteps = 5;
    for (let i = 0; i <= vSteps; i++) {
      const v = this._Vmin + (this._Vmax - this._Vmin) * i / vSteps;
      const x = this._vx(v);
      ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
    }
    for (let i = 0; i <= pSteps; i++) {
      const p = this._Pmin + (this._Pmax - this._Pmin) * i / pSteps;
      const y = this._py(p);
      ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke();
    }

    /* axes */
    ctx.strokeStyle = 'rgba(255,255,255,0.3)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph);
    ctx.stroke();

    /* axis labels */
    ctx.fillStyle = 'rgba(255,255,255,0.55)';
    ctx.font = '12px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('V, м³', ML + pw / 2, MT + ph + 32);
    ctx.save();
    ctx.translate(13, MT + ph / 2);
    ctx.rotate(-Math.PI / 2);
    ctx.fillText('P, Па', 0, 0);
    ctx.restore();

    /* tick labels */
    ctx.font = '9px Manrope, system-ui, sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.38)';
    ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    for (let i = 0; i <= pSteps; i++) {
      const p = this._Pmin + (this._Pmax - this._Pmin) * i / pSteps;
      const y = this._py(p);
      if (y < MT + 2 || y > MT + ph - 2) continue;
      ctx.fillText(this._fmtSci(p), ML - 5, y);
    }
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    for (let i = 0; i <= vSteps; i++) {
      const v = this._Vmin + (this._Vmax - this._Vmin) * i / vSteps;
      const x = this._vx(v);
      ctx.fillText(this._fmtSci(v), x, MT + ph + 5);
    }
  }

  _pvDrawCycle(ctx) {
    const ns = this._nodes;
    const N = ns.length;
    const segColors = {
      isotherm_hot:   '#EF476F',
      adiabat_exp:    '#FFD166',
      isotherm_cold:  '#06D6E0',
      adiabat_comp:   '#7BF5A4',
      isochoric_hot:  '#F15BB5',
      isochoric_cold: '#4CC9F0',
      isobar_hot:     '#F15BB5',
      isobar_cold:    '#4CC9F0',
    };

    /* filled area — net work */
    ctx.beginPath();
    for (let i = 0; i < N; i++) {
      const a = ns[i], b = ns[(i + 1) % N];
      this._pvSegPath(ctx, a, b, i === 0);
    }
    ctx.closePath();
    ctx.fillStyle = 'rgba(155,93,229,0.12)';
    ctx.fill();

    /* W label inside */
    const cV = ns.reduce((s, nd) => s + nd.V, 0) / N;
    const cP = ns.reduce((s, nd) => s + nd.P, 0) / N;
    const cx = this._vx(cV), cy = this._py(cP);
    ctx.fillStyle = 'rgba(155,93,229,0.75)';
    ctx.font = 'bold 10px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('Wцикл', cx, cy - 6);

    /* segments */
    for (let i = 0; i < N; i++) {
      const a = ns[i], b = ns[(i + 1) % N];
      const col = segColors[a.phase] || '#9B5DE5';
      const hovered = this._hoverSeg === i;
      ctx.save();
      ctx.strokeStyle = col;
      ctx.lineWidth = hovered ? 3.5 : 2.2;
      ctx.globalAlpha = hovered ? 1 : 0.82;
      ctx.setLineDash([]);
      ctx.beginPath();
      this._pvSegPath(ctx, a, b, true);
      ctx.stroke();
      ctx.restore();

      /* arrow at midpoint */
      this._pvArrow(ctx, a, b, col);
    }

    /* node circles & labels */
    for (let i = 0; i < N; i++) {
      const nd = ns[i];
      const x = this._vx(nd.V), y = this._py(nd.P);
      ctx.beginPath();
      ctx.arc(x, y, 5, 0, Math.PI * 2);
      ctx.fillStyle = '#fff';
      ctx.fill();
      ctx.strokeStyle = segColors[nd.phase] || '#9B5DE5';
      ctx.lineWidth = 1.5;
      ctx.stroke();
      ctx.fillStyle = '#fff';
      ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      /* offset label away from center */
      const dv = nd.V - (this._Vmin + this._Vmax) / 2;
      const dp = nd.P - (this._Pmin + this._Pmax) / 2;
      const ox = dv > 0 ? 13 : -13;
      const oy = dp > 0 ? -12 : 12;
      ctx.fillText(nd.label, x + ox, y + oy);
    }
  }

  _pvSegPath(ctx, a, b, isFirst) {
    const STEPS = 80;
    const phase = a.phase;
    for (let i = 0; i <= STEPS; i++) {
      const u = i / STEPS;
      let V, P;
      if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
        V = a.V + u * (b.V - a.V);
        P = a.P * a.V / V;
      } else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
        V = a.V * Math.pow(b.V / a.V, u);
        P = a.P * Math.pow(a.V / V, this.gamma);
      } else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
        V = a.V;
        P = a.P + u * (b.P - a.P);
      } else {
        V = a.V + u * (b.V - a.V);
        P = a.P;
      }
      const x = this._vx(V), y = this._py(P);
      if (i === 0 && isFirst) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
  }

  _pvArrow(ctx, a, b, col) {
    /* midpoint at u=0.55 */
    const phase = a.phase;
    const u0 = 0.52, u1 = 0.56;
    const [x0, y0] = this._pvPoint(a, b, phase, u0);
    const [x1, y1] = this._pvPoint(a, b, phase, u1);
    const ang = Math.atan2(y1 - y0, x1 - x0);
    const sz = 7;
    ctx.save();
    ctx.fillStyle = col;
    ctx.globalAlpha = 0.85;
    ctx.beginPath();
    ctx.translate(x1, y1);
    ctx.rotate(ang);
    ctx.moveTo(sz, 0);
    ctx.lineTo(-sz * 0.6, -sz * 0.4);
    ctx.lineTo(-sz * 0.6, sz * 0.4);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  _pvPoint(a, b, phase, u) {
    let V, P;
    if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
      V = a.V + u * (b.V - a.V); P = a.P * a.V / V;
    } else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
      V = a.V * Math.pow(b.V / a.V, u); P = a.P * Math.pow(a.V / V, this.gamma);
    } else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
      V = a.V; P = a.P + u * (b.P - a.P);
    } else {
      V = a.V + u * (b.V - a.V); P = a.P;
    }
    return [this._vx(V), this._py(P)];
  }

  _pvDrawCurrentPoint(ctx) {
    if (!this._running && this._t === 0) return;
    const st = this._stateAt(this._t);
    if (!st) return;
    const x = this._vx(st.V), y = this._py(st.P);
    ctx.save();
    ctx.beginPath();
    ctx.arc(x, y, 7, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.globalAlpha = 0.9;
    ctx.fill();
    ctx.strokeStyle = '#9B5DE5';
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.restore();
  }

  _pvDrawPhaseLabel(ctx, W) {
    const st = this._stateAt(this._t);
    if (!st) return;
    const names = {
      isotherm_hot:   'Изотермическое расширение (Tгор)',
      adiabat_exp:    'Адиабатическое расширение',
      isotherm_cold:  'Изотермическое сжатие (Tхол)',
      adiabat_comp:   'Адиабатическое сжатие',
      isochoric_hot:  'Изохорное нагревание',
      isochoric_cold: 'Изохорное охлаждение',
      isobar_hot:     'Изобарное нагревание',
      isobar_cold:    'Изобарное охлаждение',
    };
    const label = names[st.phase] || '';
    if (!label) return;
    ctx.save();
    ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'top';
    ctx.fillStyle = 'rgba(255,255,255,0.75)';
    ctx.fillText(label, W / 2, this._MT - 18);
    ctx.restore();
  }

  _pvDrawHoverTooltip(ctx, W, H) {
    if (this._hoverSeg < 0 || !this._tooltipX) return;
    const ns = this._nodes;
    const i = this._hoverSeg;
    const a = ns[i];
    const phase = a.phase;

    /* formulas per phase */
    const formulas = {
      isotherm_hot:   'W = nRTгор · ln(V₂/V₁)',
      isotherm_cold:  'W = nRTхол · ln(V₂/V₁)',
      adiabat_exp:    'W = (P₁V₁ - P₂V₂) / (γ - 1)',
      adiabat_comp:   'W = (P₁V₁ - P₂V₂) / (γ - 1)',
      isochoric_hot:  'W = 0,  Q = νCᵥΔT',
      isochoric_cold: 'W = 0,  Q = νCᵥΔT',
      isobar_hot:     'W = PΔV,  Q = νCₚΔT',
      isobar_cold:    'W = PΔV,  Q = νCₚΔT',
    };
    const txt = formulas[phase] || '';
    if (!txt) return;

    const tx = Math.min(this._tooltipX, W - 180);
    const ty = Math.max(this._MT + 4, this._tooltipY - 36);

    ctx.save();
    ctx.fillStyle = 'rgba(13,13,26,0.92)';
    ctx.strokeStyle = 'rgba(155,93,229,0.5)';
    ctx.lineWidth = 1;
    const tw = ctx.measureText(txt).width + 20;
    const th = 24;
    ctx.beginPath();
    ctx.roundRect(tx, ty, tw, th, 6);
    ctx.fill(); ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,0.82)';
    ctx.font = '10px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText(txt, tx + 10, ty + th / 2);
    ctx.restore();
  }

  /* ── hover detection ─────────────────────────────── */

  _onPvMove(e) {
    const rect = this._pv.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    this._tooltipX = mx;
    this._tooltipY = my;
    const ns = this._nodes;
    if (!ns) return;
    let found = -1;
    for (let i = 0; i < ns.length; i++) {
      const a = ns[i], b = ns[(i + 1) % ns.length];
      if (this._nearSeg(mx, my, a, b)) { found = i; break; }
    }
    if (found !== this._hoverSeg) {
      this._hoverSeg = found;
      this._drawPv();
    }
  }

  _nearSeg(mx, my, a, b) {
    const STEPS = 40, THRESH = 10;
    const phase = a.phase;
    for (let i = 0; i <= STEPS; i++) {
      const [x, y] = this._pvPoint(a, b, phase, i / STEPS);
      if (Math.hypot(x - mx, y - my) < THRESH) return true;
    }
    return false;
  }

  /* ── piston animation ───────────────────────────── */

  _initParticles(N) {
    this._particles = [];
    for (let i = 0; i < N; i++) {
      this._particles.push({
        x: Math.random(), y: Math.random(),
        vx: (Math.random() - 0.5), vy: (Math.random() - 0.5),
      });
    }
  }

  _updateParticles() {
    const st = this._stateAt(this._t);
    const speedScale = st ? Math.sqrt(st.T / 300) * 0.012 : 0.008;
    for (const p of this._particles) {
      p.x += p.vx * speedScale;
      p.y += p.vy * speedScale;
      if (p.x < 0) { p.x = 0; p.vx = Math.abs(p.vx); }
      if (p.x > 1) { p.x = 1; p.vx = -Math.abs(p.vx); }
      if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy); }
      if (p.y > 1) { p.y = 1; p.vy = -Math.abs(p.vy); }
    }
  }

  _drawPiston() {
    const ctx = this._pisCtx;
    const W = this._pis.offsetWidth  || 300;
    const H = this._pis.offsetHeight || 300;
    if (!W || !H) return;

    ctx.fillStyle = '#0D0D1A';
    ctx.fillRect(0, 0, W, H);

    const ns = this._nodes;
    if (!ns) return;

    const st = this._stateAt(this._t) || { V: ns[0].V, T: ns[0].T, phase: ns[0].phase };

    /* cylinder geometry */
    const cylX = W * 0.2, cylW = W * 0.6;
    const cylTop = H * 0.12, cylBot = H * 0.92;
    const cylH = cylBot - cylTop;

    /* piston position from volume */
    const Vmin = Math.min(...ns.map(n => n.V));
    const Vmax = Math.max(...ns.map(n => n.V));
    const Vfrac = (st.V - Vmin) / (Vmax - Vmin || 1);
    const pistonY = cylTop + cylH * (1 - Vfrac);  // bottom = max V, top = min V

    /* heat source color indicator */
    const phase = st.phase;
    const isHot  = phase === 'isotherm_hot'   || phase === 'isochoric_hot'  || phase === 'isobar_hot';
    const isCold = phase === 'isotherm_cold'  || phase === 'isochoric_cold' || phase === 'isobar_cold';
    const isAdia = phase === 'adiabat_exp'    || phase === 'adiabat_comp';

    /* reservoir strip on left */
    const resW = W * 0.12;
    const resX = W * 0.05;
    if (isHot) {
      ctx.fillStyle = 'rgba(239,71,111,0.25)';
      ctx.beginPath();
      ctx.roundRect(resX, cylTop, resW, cylH, 5);
      ctx.fill();
      ctx.strokeStyle = '#EF476F';
      ctx.lineWidth = 1.5;
      ctx.stroke();
      ctx.fillStyle = '#EF476F';
      ctx.font = 'bold 9px Manrope, system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText('Tгор', resX + resW / 2, cylTop + 14);
      /* heat flow arrows */
      this._drawHeatArrows(ctx, resX + resW, pistonY, cylX, '#EF476F', true);
    } else if (isCold) {
      ctx.fillStyle = 'rgba(6,214,224,0.18)';
      ctx.beginPath();
      ctx.roundRect(resX, cylTop, resW, cylH, 5);
      ctx.fill();
      ctx.strokeStyle = '#06D6E0';
      ctx.lineWidth = 1.5;
      ctx.stroke();
      ctx.fillStyle = '#06D6E0';
      ctx.font = 'bold 9px Manrope, system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText('Tхол', resX + resW / 2, cylTop + 14);
      this._drawHeatArrows(ctx, cylX, pistonY, resX + resW, '#06D6E0', false);
    } else if (isAdia) {
      /* insulation hatching */
      ctx.save();
      ctx.strokeStyle = 'rgba(255,200,0,0.35)';
      ctx.lineWidth = 1;
      ctx.setLineDash([4, 4]);
      for (let y = cylTop; y < cylBot; y += 8) {
        ctx.beginPath(); ctx.moveTo(resX, y); ctx.lineTo(resX + resW, y); ctx.stroke();
      }
      ctx.setLineDash([]);
      ctx.restore();
      ctx.strokeStyle = 'rgba(255,200,0,0.4)';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.roundRect(resX, cylTop, resW, cylH, 5);
      ctx.stroke();
      ctx.fillStyle = 'rgba(255,200,0,0.7)';
      ctx.font = 'bold 8px Manrope, system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText('Q=0', resX + resW / 2, cylTop + 14);
    }

    /* cylinder walls */
    ctx.strokeStyle = 'rgba(200,200,220,0.5)';
    ctx.lineWidth = 2.5;
    ctx.setLineDash([]);
    /* left wall */
    ctx.beginPath(); ctx.moveTo(cylX, cylTop); ctx.lineTo(cylX, cylBot); ctx.stroke();
    /* right wall */
    ctx.beginPath(); ctx.moveTo(cylX + cylW, cylTop); ctx.lineTo(cylX + cylW, cylBot); ctx.stroke();
    /* bottom */
    ctx.beginPath(); ctx.moveTo(cylX, cylBot); ctx.lineTo(cylX + cylW, cylBot); ctx.stroke();

    /* gas region */
    const gasGrad = ctx.createLinearGradient(cylX, pistonY, cylX, cylBot);
    const hotness = Math.min(1, (st.T - this.Tc) / Math.max(1, this.Th - this.Tc));
    const r = Math.round(13 + hotness * 220);
    const g = Math.round(13 + (1 - hotness) * 100);
    const b = Math.round(26 + (1 - hotness) * 180);
    gasGrad.addColorStop(0, `rgba(${r},${g},${b},0.12)`);
    gasGrad.addColorStop(1, `rgba(${r},${g},${b},0.28)`);
    ctx.fillStyle = gasGrad;
    ctx.fillRect(cylX, pistonY, cylW, cylBot - pistonY);

    /* particles */
    const gasH = cylBot - pistonY;
    for (const p of this._particles) {
      const px = cylX + p.x * cylW;
      const py = pistonY + p.y * gasH;
      ctx.beginPath();
      ctx.arc(px, py, 2.5, 0, Math.PI * 2);
      ctx.fillStyle = `rgb(${r},${g},${b})`;
      ctx.globalAlpha = 0.7;
      ctx.fill();
      ctx.globalAlpha = 1;
    }

    /* piston */
    const pisH = Math.max(12, H * 0.05);
    ctx.fillStyle = 'rgba(100,120,180,0.85)';
    ctx.strokeStyle = 'rgba(160,180,255,0.7)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.roundRect(cylX + 2, pistonY - pisH, cylW - 4, pisH, 4);
    ctx.fill(); ctx.stroke();

    /* piston rod */
    const rodW = cylW * 0.1;
    ctx.fillStyle = 'rgba(130,140,200,0.7)';
    ctx.fillRect(cylX + cylW / 2 - rodW / 2, cylTop, rodW, pistonY - pisH - cylTop);

    /* labels */
    ctx.fillStyle = 'rgba(255,255,255,0.65)';
    ctx.font = '10px Manrope, system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('T = ' + Math.round(st.T) + ' K', W * 0.75, cylTop + 16);
    ctx.fillText('V = ' + this._fmtSci(st.V) + ' m³', W * 0.75, cylTop + 30);

    /* adiabatic shimmer on insulation lines */
    const isAdia2 = phase === 'adiabat_exp' || phase === 'adiabat_comp';
    if (isAdia2 && window.LabFX) {
      const pulse = LabFX.glow.pulse(performance.now() / 1000, 0.6);
      ctx.save();
      ctx.globalAlpha = 0.1 + pulse * 0.25;
      ctx.fillStyle = 'rgba(255,200,0,0.6)';
      ctx.fillRect(resX, cylTop, resW, cylH);
      ctx.restore();
    }

    if (window.LabFX) LabFX.particles.draw(ctx);
  }

  _drawHeatArrows(ctx, x0, y0, x1, col, rightward) {
    const N = 3;
    ctx.save();
    ctx.strokeStyle = col;
    ctx.lineWidth = 1.5;
    ctx.globalAlpha = 0.7;
    for (let i = 0; i < N; i++) {
      const yo = y0 - 15 + i * 15;
      const dx = rightward ? 8 : -8;
      ctx.beginPath();
      ctx.moveTo(x0, yo);
      ctx.lineTo(x1, yo);
      /* arrowhead */
      const ax = x1, ay = yo;
      ctx.moveTo(ax, ay);
      ctx.lineTo(ax - dx, ay - 4);
      ctx.moveTo(ax, ay);
      ctx.lineTo(ax - dx, ay + 4);
      ctx.stroke();
    }
    ctx.restore();
  }

  /* ── util ────────────────────────────────────────── */

  _fmtSci(v) {
    if (Math.abs(v) >= 1e4 || (Math.abs(v) < 0.01 && v !== 0)) {
      return v.toExponential(1);
    }
    if (Math.abs(v) >= 1000) return Math.round(v).toString();
    if (Math.abs(v) >= 10)   return v.toFixed(1);
    return v.toFixed(3);
  }
}

/* ─── lab UI init ─────────────────────────────────── */
var heSim = null;

function _openHeatEngine() {
  document.getElementById('sim-topbar-title').textContent = 'Тепловые двигатели';
  _simShow('sim-heatengine');

  requestAnimationFrame(() => requestAnimationFrame(() => {
    if (!heSim) {
      heSim = new HeatEngineSim(
        document.getElementById('he-pv-canvas'),
        document.getElementById('he-piston-canvas')
      );
      heSim.onStats = _heUpdateUI;
    }
    heSim.fit();
    heSim._recompute();
  }));
}

function heSetCycle(type, el) {
  document.querySelectorAll('.he-cycle-btn').forEach(b => b.classList.remove('active'));
  if (el) el.classList.add('active');
  /* show/hide CR slider */
  const crRow = document.getElementById('he-cr-row');
  if (crRow) crRow.style.display = (type === 'carnot') ? 'none' : '';
  if (heSim) heSim.setCycle(type);
}

function heParam(name, val) {
  const v = parseFloat(val);
  if (name === 'Th') {
    document.getElementById('he-th-val').textContent = Math.round(v);
    if (heSim) heSim.setTh(v);
  } else if (name === 'Tc') {
    document.getElementById('he-tc-val').textContent = Math.round(v);
    if (heSim) heSim.setTc(v);
  } else if (name === 'cr') {
    document.getElementById('he-cr-val').textContent = Math.round(v);
    if (heSim) heSim.setCR(v);
  }
}

function heStart()  { if (heSim) heSim.start(); }
function hePause()  { if (heSim) heSim.pause(); }
function heStep()   { if (heSim) heSim.step(); }
function heReset()  { if (heSim) heSim.reset(); }

function _heUpdateUI(s) {
  const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
  v('hebar-th',  s.Th + ' K');
  v('hebar-tc',  s.Tc + ' K');
  v('hebar-eta', s.eta + '%');
  v('hebar-qh',  s.Qh + ' Дж');
  v('hebar-qc',  s.Qc + ' Дж');
  v('hebar-w',   s.W  + ' Дж');
}
