// Railway Simulation Master File (Consist-centric)

// ---------- Canvas ----------
const canvas = document.getElementById("railway");
const ctx = canvas.getContext("2d");

function resizeCanvasToCSS() {
  const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
  const cssW = canvas.clientWidth;
  const cssH = canvas.clientHeight;
  const needResize =
    canvas.width !== Math.round(cssW * dpr) ||
    canvas.height !== Math.round(cssH * dpr);
  if (needResize) {
    canvas.width = Math.round(cssW * dpr);
    canvas.height = Math.round(cssH * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
}
// ---------- World <-> Screen transform ----------
let worldScale = 1;
let worldOffsetX = 0;
let worldOffsetY = 0;
let worldRotation = 0;
let collisions = 0;
let followConsistId = null; // id of consist being followed, or null

function worldToScreen(wx, wy) {
  // 1. scale+pan
  let sx = wx * worldScale + worldOffsetX;
  let sy = wy * worldScale + worldOffsetY;

  // 2. rotate about canvas centre (CSS px)
  const cx = canvas.clientWidth / 2;
  const cy = canvas.clientHeight / 2;
  const dx = sx - cx;
  const dy = sy - cy;

  const cos = Math.cos(worldRotation),
    sin = Math.sin(worldRotation);
  return {
    x: cx + cos * dx - sin * dy,
    y: cy + sin * dx + cos * dy,
  };
}

function screenToWorld(sx, sy) {
  // 1. undo rotation about canvas centre (CSS px)
  const cx = canvas.clientWidth / 2;
  const cy = canvas.clientHeight / 2;
  const dx = sx - cx;
  const dy = sy - cy;

  const cos = Math.cos(-worldRotation),
    sin = Math.sin(-worldRotation);
  const ux = cos * dx - sin * dy;
  const uy = sin * dx + cos * dy;

  const unrotX = cx + ux;
  const unrotY = cy + uy;

  // 2. undo scale+pan
  return {
    x: (unrotX - worldOffsetX) / worldScale,
    y: (unrotY - worldOffsetY) / worldScale,
  };
}

function updateWorldTransform({ fit = true, margin = 80 } = {}) {
  if (!trackLayout || !fit) return; // preserve current pan/zoom when fit=false

  // Use CSS size (clientWidth/Height), *not* canvas.width/height (which are DPR-scaled)
  const W = canvas.clientWidth;
  const H = canvas.clientHeight;

  const { minX, minY, maxX, maxY } = getLayoutBounds();
  const layoutW = Math.max(1, maxX - minX);
  const layoutH = Math.max(1, maxY - minY);

  const scaleX = (W - margin) / layoutW;
  const scaleY = (H - margin) / layoutH;
  worldScale = Math.max(0.0001, Math.min(scaleX, scaleY));

  // Center the layout inside the canvas
  const worldW = layoutW * worldScale;
  const worldH = layoutH * worldScale;
  worldOffsetX = (W - worldW) / 2 - minX * worldScale;
  worldOffsetY = (H - worldH) / 2 - minY * worldScale;
}
// screen px -> world units (keeps screen-constant sizes)
function px(n) {
  return n / worldScale;
}

// ---------- Data ----------
let trackLayout = null;
let switchStates = {};
let bufferStates = {};
const images = {};

let rollingstock = { locomotives: [], carriages: [], tasks: [] };
let locos = []; // vehicle objects of type 'loco'
let cars = []; // vehicle objects of type 'car'

let showNNodes = false;

// === Consist-centric model ===
let consists = []; // each: { id, vehicles[], liveLocoId|null, dir:+1|-1, moving, waiting, status, statusAlpha }
let nextConsistId = 1;

let rafIds = {}; // animation frame IDs per consist.id

// ---------- Tuning (world units unless px(...) noted) ----------
const GAP = 1.7;
const SPEED = 0.3; // meters per frame (converted to fraction via physLen)
const STOP_MARGIN = 1; //  converted  in checks
const AUTOCONNECT_THROTTLE = 0.3; //throttle must be under this to autoconnect other wise collision
const EPS = 1e-4;
const LOCO_WIDTH = 2; // world units (meters)
const CAR_WIDTH = 2.5; // world units (meters)
const NODERADIUSPX = 17; // screen px for node hit radius
const TRACKWIDTH = 0.5;
const STOPPED_STRING = "Hit";
const FOLLOW_TRAIN_STRING = "View Train";
const FOLLOWING_TRAIN_STRING = "Viewing";

const STOP_COLOUR = "salmon";
const GO_COLOUR = "limegreen";

// meters (your lengths are in meters)
const BOGIE_INSET = 1.2; // how far in from each buffer the bogie sits
const BOGIE_LENGTH = 2.2; // visual bogie frame length
const BOGIE_WIDTH = 2.3; // visual bogie frame width
const WHEEL_OFFSET = 0.65; // wheel centers from bogie center along x (visual)
const WHEEL_RADIUS = 0.35; // visual wheel radius (meters)
const MAXLENGTHBEFOREBOGIES = 12;

// ---------- Load ----------
const params = new URLSearchParams(window.location.search);
const layoutFile = params.get("layout") || "layoutallschematic.json";
const rollingFile = params.get("rollingstock") || "rollingstockall.json";
const taskId = Math.max(0, parseInt(params.get("task") || 1) - 1);
let drawnodes = params.get("drawnodes") || 0;
let calculatestartsegments = params.get("startsegments") || 0;
showNNodes = drawnodes == 1;

Promise.all([
  fetch(layoutFile).then((r) => r.json()),
  fetch(rollingFile).then((r) => r.json()),
])
  .then(([layout, stock]) => {
    trackLayout = layout;
    normalizeConnections(trackLayout);
    const root = stock?.rollingstock ?? stock ?? {};
    rollingstock = {
      locomotives: root.locomotives || [],
      carriages: root.carriages || [],
      tasks: root.tasks || [],
    };

    initCompass();
    initSwitchStates();
    initBufferStates();    
    initStock();
    buildTaskList();
    loadTask(rollingstock.tasks[taskId]);
    //placeStartConsists(rollingstock.tasks[0]);
    resizeCanvasToCSS();
    updateWorldTransform({ fit: true });

    // preload feature SVGs, then continue
    return initFeatures();
  })
  .then(() => {
    buildControlsFromConsists();
    drawNetwork();
  })
  .catch((e) => console.error("JSON load error:", e));

function initFeatures() {
  if (!trackLayout.features) return Promise.resolve();

  const tasks = [];

  for (const f of Object.values(trackLayout.features)) {
    if (f.type === "svg" && f.src && !images[f.src]) {
      tasks.push(preloadSVG(f.src));
    }
  }

  return Promise.all(tasks);
}

function preloadSVG(src) {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      images[src] = img;
      resolve();
    };
    img.onerror = () => {
      console.warn("SVG failed to load:", src);
      images[src] = null; // mark as failed
      resolve(); // still resolve, don’t reject
    };
    img.src = src;
  });
}

// On first ResizeObserver/resize (or on orientation change) do a fresh fit:
let didInitialFit = false;
const ro = new ResizeObserver(() => {
  resizeCanvasToCSS();
  if (!didInitialFit) {
    updateWorldTransform({ fit: true });
    didInitialFit = true;
  }
  drawNetwork();
});
ro.observe(canvas);

// ---------- Init ----------
function initCompass() {
  worldRotation = rotationFromCompass(trackLayout.compass);
}

function rotationFromCompass(dir) {
  switch (dir) {
    case "N":
      return 0;
    case "NE":
      return Math.PI / 4;
    case "E":
      return Math.PI / 2;
    case "SE":
      return (3 * Math.PI) / 4;
    case "S":
      return Math.PI;
    case "SW":
      return (-3 * Math.PI) / 4;
    case "W":
      return -Math.PI / 2;
    case "NW":
      return -Math.PI / 4;
    default:
      return 0;
  }
}

function initSwitchStates() {
  for (const id in trackLayout.tracks)
    if (id.startsWith("P")) switchStates[id] = 0;
}

function initBufferStates() {
  bufferStates = {};
  for (const id in trackLayout.tracks) {
    if (!id.startsWith("S")) continue;
    /*    bufferStates[id] = (degree(id) === 1); // only ends are buffers by default
    bufferStates[id] = (degree(id) === 2); // signals defaultto off unless state set to Go in layout*/
    const node = trackLayout.tracks[id];
    if (node.state) {
      // use JSON state if provided
      bufferStates[id] = node.state.toUpperCase() !== "GO";
    } else {
      // fall back to old rule
      bufferStates[id] = true;
    }
  }
}

function initStock() {
  locos = [];
  cars = [];
  rollingstock.locomotives.forEach((ld) => {
    locos.push({
      id: ld.label,
      name: ld.name ?? ld.label,
      color: ld.color || "blue",
      type: "loco",
      neutral: true, // becomes false for the live loco in a consist
      length: +ld.length || 10, // meters
      leadPath: [],
      leadT: 0.5,
      trail: [],
      heading: 0,
      px: null,
      py: null,
    });
  });
  rollingstock.carriages.forEach((cd) => {
    cars.push({
      id: cd.label,
      name: cd.name ?? cd.label,
      color: cd.color || "darkgray",
      type: "car",
      length: +cd.length || 20,
      leadPath: [],
      leadT: 0.5,
      trail: [],
      heading: 0,
      px: null,
      py: null,
    });
  });
}

// ---------- Start positions -> Consists ----------
function getVehicleById(id) {
  return (
    locos.find((v) => v.id === id) || cars.find((v) => v.id === id) || null
  );
}

// Create a consist from an array of VEHICLE OBJECTS (front->back order)
function makeConsist(
  vehicles,
  liveLocoId = null,
  initialPath = null,
  initialT = 0.5,
  dir = +1,
  options = {}
) {
  const { pivotIndex = null, preserveWorld = false } = options;

  const vs = vehicles.slice();

  // Seed path/t only if we are NOT preserving current world positions
  if (!preserveWorld) {
    vs.forEach((v) => {
      v.leadPath = initialPath
        ? initialPath.slice()
        : v.leadPath?.length
        ? v.leadPath.slice()
        : [];
      v.leadT = initialT ?? 0.5;
    });
  }

  // Ensure a trail exists (safe no-op if already present)
  vs.forEach((v) => {
    if (!v.trail || !v.trail.length) {
      if (v.leadPath && v.leadPath.length === 2) {
        const [a, b] = v.leadPath;
        v.trail = [{ from: a, to: b, len: segmentVector(a, b).len }];
      } else {
        v.trail = [];
      }
    }
  });

  if (!liveLocoId) {
    const firstL = vs.find((x) => x.type === "loco");
    liveLocoId = firstL?.id ?? null;
  }
  vs.forEach((v) => {
    if (v.type === "loco") v.neutral = v.id !== liveLocoId;
  });

  const c = {
    id: `C${nextConsistId++}`,
    vehicles: vs,
    liveLocoId,
    dir,
    speed: SPEED,
    throttle: 0.5,
    moving: false,
    nudging: false,
    status: "",
    statusAlpha: 0,
    anchorIndex: typeof pivotIndex === "number" ? pivotIndex : null,
  };

  // Only recompute positions if we didn't ask to preserve
  if (!preserveWorld) solveConsistPositions(c);
  return c;
}

function doesSegmentExist(A, B) {
  const adj = trackLayout.adj;
  return !!(adj && adj[A] && adj[A].includes(B));
}

function oneHopVia(A, B) {
  const adj = trackLayout.adj || {};
  return (adj[A] || []).find(x => (adj[x] || []).includes(B)) || null;
}

function placeStartConsists(task) {
  consists = [];
  nextConsistId = 1;

  if (task) {
    for (const entry of task.startsegments) {
      const [A, B] = String(entry.segment || "").split("-");
      
            
      if (!trackLayout.tracks[A] || !trackLayout.tracks[B]) continue;

      // node existence
      if (!trackLayout.tracks[A] || !trackLayout.tracks[B]) {
        const missing = [A, B].filter(n => !trackLayout.tracks[n]).join(", ");
        console.error(`[startsegments] Unknown node(s) ${missing} in "${segStr}" for [${(entry.stock||[]).join(", ")}]; skipping.`);
        continue;
      }

      // segment existence (fast, via adj)
      if (!doesSegmentExist(A, B)) {
        const via = oneHopVia(A, B);
        console.error(`[startsegments] Segment "${A}-${B}" does not exist; skipping stock [${(entry.stock||[]).join(", ")}].`);
        if (via) console.info(`  Hint: now routed via "${via}". Try "${A}-${via}" or "${via}-${B}".`);
        continue;
      }

      const ids = (entry.stock || []).filter(Boolean);
      if (!ids.length) continue;

      const vehicles = ids.map(getVehicleById).filter(Boolean);
      if (!vehicles.length) continue;

      const firstLoco = vehicles.find((v) => v.type === "loco")?.id ?? null;

      // segment info
      const segLen = dist(A, B);
      const path = [A, B];

      // default: centre of segment
      let t = 0.5;

      // override with distance if provided
      if (typeof entry.distance === "number" && segLen > 0) {
        t = Math.max(0, Math.min(1, entry.distance / segLen));
      }

      // seed common path & trail for all vehicles
      vehicles.forEach((v) => {
        v.leadPath = path.slice();
        v.leadT = t;
        v.trail = [{ from: A, to: B, len: segLen }];
      });

      // make consist with anchor at t
      const c = makeConsist(vehicles, firstLoco, path, t, +1);
      // stash path + anchor so calculateStartSegments can use them
       c.path = path;
       c.anchorT = t;
      //ensureRoomBehindConsist(c);
      solveConsistPositions(c);
      consists.push(c);
    }
  }
  buildControlsFromConsists();
  drawNetwork();
}

// ensure there's room behind the pivot vehicle
function ensureRoomBehindConsist(consist) {
  const { pivotIndex, needBehind } = requiredDistancesConsist(consist);
  const pivot = consist.vehicles[pivotIndex];
  const seg = pivot?.trail?.slice(-1)[0];
  if (!seg) return;
  const segLen = seg.len;
  const available = (pivot.leadT ?? 0.5) * segLen;
  if (available < needBehind) {
    pivot.leadT = Math.min(0.98, needBehind / segLen + 0.02);
  }
}

// ---------- Controls ----------
function updateCollisionCounter() {
  const el = document.getElementById("collisionCounter");
  if (el) el.textContent = "Hits: " + collisions;
}

function buildControlsFromConsists() {
  const el = document.getElementById("controls");
  if (!el) return;
  el.innerHTML = "";

  // Consists that have at least one loco → Train numbering
  /*const trains = consists
    .map(c => ({ c, hasLoco: c.vehicles.some(v => v.type === "loco") }))
    .filter(x => x.hasLoco)
    .map((x, i) => ({ ...x, trainNo: i + 1 }));*/

  // Build an index lookup from loco label to its order
  const locoOrder = {};
  rollingstock.locomotives.forEach((loco, i) => {
    locoOrder[loco.label] = i;
  });

  const trains = consists
    .map((c) => {
      // collect all loco labels in this consist
      const locoIds = c.vehicles
        .filter((v) => v.type === "loco")
        .map((v) => v.id);
      if (locoIds.length === 0) return null;

      // find the earliest loco index from locoOrder
      let keyIndex = Infinity;
      for (const id of locoIds) {
        const idx = locoOrder[id];
        if (idx !== undefined && idx < keyIndex) {
          keyIndex = idx;
        }
      }

      return { c, locoIds, keyIndex };
    })
    .filter((x) => x !== null)
    .sort((a, b) => a.keyIndex - b.keyIndex)
    .map((x, i) => ({
      ...x,
      trainNo: i + 1, // sequential numbering after sort
    }));

  for (const { c, trainNo } of trains) {
    const row = document.createElement("div");
    row.style.margin = "4px";

    const live = c.vehicles.find(
      (v) => v.id === c.liveLocoId && v.type === "loco"
    );
    const firstLoco = c.vehicles.find((v) => v.type === "loco");
    const labelColor = live?.color || firstLoco?.color || "gray";
    //    const direction = c.dir === 1 ? "(Forward)" : "(Reverse)";
    const who = live
      ? live.name || live.id
      : firstLoco?.name || firstLoco?.id || "no live loco";

    // Clickable colored label (flips direction / promotes first loco if none)
    const label = document.createElement("span");
    label.style.backgroundColor = labelColor;
    label.style.color = bestTextColor(labelColor);
    label.style.padding = "8px 12px";
    label.style.marginRight = "6px";
    label.style.borderRadius = "4px";
    label.style.minHeight = "44px";
    label.title = "Click to flip direction";

    // Put the arrow visibly inside the label
    label.textContent = `Train ${trainNo}: ${who}`;
    /*
    label.onclick = (e) => {
      // If no live loco yet, promote the first loco in the consist
      if (!c.liveLocoId) {
        if (firstLoco) {
          c.liveLocoId = firstLoco.id;
          c.vehicles.forEach(x => { if (x.type === "loco") x.neutral = (x.id !== firstLoco.id); });
        }
      } else {
        // Flip direction
        c.dir *= -1;
        // Refresh UI no need to do a complete rebuild
        if(c.dir===1){
          e.target.textContent=e.target.textContent.replace("Reverse","Forward");
        }else{
          e.target.textContent=e.target.textContent.replace("Forward","Reverse");
        }

  
      }
      // Recompute headings immediately so the canvas arrow flips now
      if (typeof solveConsistPositions === "function") {
        solveConsistPositions(c);
      } else if (typeof solveConsist === "function") {
        // fallback if you're still using the loco-based solver
        const liveL = c.vehicles.find(v => v.id === c.liveLocoId);
        if (liveL) solveConsist(liveL);
      }

      drawNetwork();
    };*/

    row.appendChild(label);
    // === NEW: FWD REV buttons ===
    // --- Direction buttons ---
    const fwdBtn = document.createElement("button");
    fwdBtn.textContent = "Fwd ↑";
    fwdBtn.className = "fwd-btn";

    const revBtn = document.createElement("button");
    revBtn.textContent = "Rev ↓";
    revBtn.className = "rev-btn";

    // Initialise active state
    if (c.dir === 1) {
      fwdBtn.classList.add("active");
    } else {
      revBtn.classList.add("active");
    }

    // Handlers
    fwdBtn.onclick = () => {
      if (c.dir == -1) {
        c.dir = 1;
        drawNetwork();
      }

      fwdBtn.classList.add("active");
      revBtn.classList.remove("active");
    };

    revBtn.onclick = () => {
      if (c.dir == 1) {
        c.dir = -1;
        drawNetwork();
      }

      revBtn.classList.add("active");
      fwdBtn.classList.remove("active");
    };

    // Add them to row BEFORE throttle
    row.appendChild(fwdBtn);
    row.appendChild(revBtn);

    // === NEW: Throttle slider ===

    const throttleWrap = document.createElement("div");
    // Value label
    /*const throttleLabel = document.createElement("span");
throttleLabel.style.marginLeft = "6px";
throttleLabel.textContent = c.throttle.toFixed(2);  // show 2 decimals
throttleWrap.appendChild(throttleLabel);*/

    throttleWrap.style.position = "relative";
    throttleWrap.style.display = "inline-block";
    throttleWrap.style.width = "200px"; // fixed width so we can place the tick
    throttleWrap.style.marginRight = "10px";

    const throttle = document.createElement("input");
    throttle.type = "range";
    throttle.min = 0.1;
    throttle.max = 1;
    throttle.step = 0.05;
    throttle.value = c.throttle;
    throttle.style.width = "100%";
    throttle.title = "Throttle";
    throttle.className = "throttle";

    throttle.oninput = () => {
      c.throttle = parseFloat(throttle.value);
      //throttleLabel.textContent=c.throttle.toFixed(2);
    };
    throttleWrap.appendChild(throttle);

    // Add tick marker

    const safePct =
      ((AUTOCONNECT_THROTTLE - throttle.min) / (throttle.max - throttle.min)) *
      100;

    const marker = document.createElement("div");
    marker.style.position = "absolute";
    marker.style.top = "0";
    marker.style.bottom = "0";
    marker.style.left = safePct + "%";
    marker.style.left = `calc(${safePct}% + 8px)`; // shift left by half thumb width

    marker.style.width = "2px";
    marker.style.background = GO_COLOUR;
    marker.style.pointerEvents = "none"; // don’t block slider

    throttleWrap.appendChild(marker);

    row.appendChild(throttleWrap);

    // Run button
    const runBtn = document.createElement("button");
    runBtn.textContent = "Run";
    runBtn.className = "run-btn";
    const stopBtn = document.createElement("button");

    runBtn.onclick = () => {
      if (!c.liveLocoId && firstLoco) {
        c.liveLocoId = firstLoco.id;
        c.vehicles.forEach((x) => {
          if (x.type === "loco") x.neutral = x.id !== firstLoco.id;
        });
        if (typeof solveConsistPositions === "function")
          solveConsistPositions(c);
      }
      runBtn.classList.add("active");
      stopBtn.classList.remove("active");
      runConsist(c);
      //buildControlsFromConsists();
    };

    // Stop button

    stopBtn.textContent = "Stop";
    stopBtn.className = "stop-btn";

    if (c.moving) {
      runBtn.classList.add("active");
      stopBtn.classList.remove("active");
    } else {
      stopBtn.classList.add("active");
      runBtn.classList.remove("active");
    }
    row.appendChild(runBtn);
    row.appendChild(stopBtn);
    stopBtn.onclick = () => {
      panToFollow;
      stopBtn.classList.add("active");
      runBtn.classList.remove("active");
      //setStatusC(c, "Stopped: manual");
      stopConsist(c);
      //buildControlsFromConsists();
      //drawNetwork();
    };

    const followBtn = document.createElement("button");
    followBtn.className = "follow-btn";

    if (followConsistId === c.id) {
      followBtn.textContent = FOLLOWING_TRAIN_STRING;
      followBtn.classList.add("active");
    } else {
      followBtn.textContent = FOLLOW_TRAIN_STRING;
    }
    followBtn.textContent =
      followConsistId === c.id ? FOLLOWING_TRAIN_STRING : FOLLOW_TRAIN_STRING;

    followBtn.style.marginLeft = "5px";
    followBtn.onclick = () => {
      if (followConsistId === c.id) {
        followConsistId = null; // toggle off
        followBtn.textContent = FOLLOW_TRAIN_STRING;
        followBtn.classList.remove("active");
      } else {
        followConsistId = c.id;
        // reset all other follow buttons
        document.querySelectorAll(".follow-btn").forEach((b) => {
          b.textContent = FOLLOW_TRAIN_STRING;
          b.classList.remove("active");
        });
        followBtn.textContent = FOLLOWING_TRAIN_STRING;
        followBtn.classList.add("active");
        drawNetwork();
      }
    };
    followBtn.classList.add("follow-btn");
    row.appendChild(followBtn);

    el.appendChild(row);
  }

  /*
  const debugRow = document.createElement("div");
  debugRow.style.margin = "6px 0";

  const chk = document.createElement("input");
  chk.type = "checkbox";
  chk.id = "toggle-nnodes";
  chk.checked = showNNodes;
  chk.onchange = () => { showNNodes = chk.checked; drawNetwork(); };

  const lbl = document.createElement("label");
  lbl.htmlFor = "toggle-nnodes";
  lbl.textContent = " Show hidden nodes (for debugging and track construcion)";

  debugRow.appendChild(chk);
  debugRow.appendChild(lbl);
  el.appendChild(debugRow);
  */
}

function stopConsist(consist) {
  consist.moving = false;
  if (rafIds[consist.id]) cancelAnimationFrame(rafIds[consist.id]);
  buildControlsFromConsists();
  drawNetwork();
}

// ---------- Switch logic ----------
function getActiveLegs(pid) {
  // Use the JSON order for P*: [fixed, legA, legB]
  const c = trackLayout.tracks[pid].connections;
  if (!c || c.length < 3) return null;
  const fixed = c[0];
  const active = c[1 + (switchStates[pid] % 2)];
  const inactive = c.find((n) => n !== fixed && n !== active);
  return { fixed, active, inactive };
}

function chooseNextFrom(node, prev) {
  if (!trackLayout.tracks[node]) return null;
  if (bufferStates[node]) return null; // blocked buffer

  if (node.startsWith("P")) {
    const legs = getActiveLegs(node);
    if (!legs) return null;
    const { fixed, active, inactive } = legs;
    if (prev === inactive) return null; // can't enter via inactive
    let nxt = null;
    if (prev === fixed) nxt = active;
    else if (prev === active) nxt = fixed;
    else if (!prev) nxt = fixed; // starting on a point
    if (!nxt) return null;
    if (!isBogieTurnAllowed(node, prev, nxt)) return null;
    return nxt;
  }

  // Non-P: pick the neighbor that's not 'prev'
  const nbrs = trackLayout.adj[node] || [];
  // deg=1 → end; deg=2 → pass-through; deg>2 (shouldn't happen for non-P) → pick any not prev
  return nbrs.find((n) => n !== prev) || null;
}

function clamp(x, a, b) {
  return Math.max(a, Math.min(b, x));
}
function isBogieTurnAllowed(pid, prev, next) {
  if (!pid.startsWith("P") || !prev || !next) return true;
  const [px, py] = trackLayout.tracks[prev].position;
  const [cx, cy] = trackLayout.tracks[pid].position;
  const [nx, ny] = trackLayout.tracks[next].position;
  const v1 = [px - cx, py - cy],
    v2 = [nx - cx, ny - cy];
  const cos = clamp(
    (v1[0] * v2[0] + v1[1] * v2[1]) / (Math.hypot(...v1) * Math.hypot(...v2)),
    -1,
    1
  );
  return (Math.acos(cos) * 180) / Math.PI >= 90;
}

function buildLeg(at, from) {
  const nxt = chooseNextFrom(at, from);
  return nxt ? [at, nxt] : [];
}

// ---------- Geometry ----------
function pointInVehicle(mx, my, v) {
  if (v.px == null) return false;
  const dx = mx - v.px,
    dy = my - v.py;
  const c = Math.cos(v.heading),
    s = Math.sin(v.heading);
  const localX = dx * c + dy * s; // along length
  const localY = -dx * s + dy * c; // across width
  const halfL = v.length / 2;
  const isLoco = v.type === "loco";
  const halfW = (isLoco ? LOCO_WIDTH : CAR_WIDTH) / 2;
  return Math.abs(localX) <= halfL && Math.abs(localY) <= halfW;
}
function getLayoutBounds() {
  let minX = Infinity,
    minY = Infinity,
    maxX = -Infinity,
    maxY = -Infinity;
  for (const id in trackLayout.tracks) {
    const [x, y] = trackLayout.tracks[id].position;
    minX = Math.min(minX, x);
    minY = Math.min(minY, y);
    maxX = Math.max(maxX, x);
    maxY = Math.max(maxY, y);
  }
  return { minX, minY, maxX, maxY };
}
function dist(a, b) {
  if (trackLayout.lengths) {
    const k1 = `${a}-${b}`,
      k2 = `${b}-${a}`;
    if (trackLayout.lengths[k1] != null) return trackLayout.lengths[k1];
    if (trackLayout.lengths[k2] != null) return trackLayout.lengths[k2];
  }
  const [ax, ay] = trackLayout.tracks[a].position,
    [bx, by] = trackLayout.tracks[b].position;
  return Math.hypot(bx - ax, by - ay);
}
function segmentVector(a, b) {
  const [x1, y1] = trackLayout.tracks[a].position,
    [x2, y2] = trackLayout.tracks[b].position;
  const len = Math.hypot(x2 - x1, y2 - y1);
  const physLen = dist(a, b);
  return { dx: x2 - x1, dy: y2 - y1, len, physLen };
}
function pointOnSegment(a, b, t) {
  const [x1, y1] = trackLayout.tracks[a].position,
    [x2, y2] = trackLayout.tracks[b].position;
  return {
    px: x1 + (x2 - x1) * t,
    py: y1 + (y2 - y1) * t,
    heading: Math.atan2(y2 - y1, x2 - x1),
  };
}
function getVehicleRect(v, isLoco = false) {
  const w = isLoco ? LOCO_WIDTH : CAR_WIDTH;
  const l = v.length;
  const hw = w / 2,
    hl = l / 2,
    cos = Math.cos(v.heading),
    sin = Math.sin(v.heading);
  const pts = [
    { x: -hl, y: -hw },
    { x: hl, y: -hw },
    { x: hl, y: hw },
    { x: -hl, y: hw },
  ];
  return pts.map((p) => ({
    x: v.px + p.x * cos - p.y * sin,
    y: v.py + p.x * sin + p.y * cos,
  }));
}
function rectsIntersect(r1, r2) {
  const polys = [r1, r2];
  for (const poly of polys) {
    for (let i = 0; i < poly.length; i++) {
      const j = (i + 1) % poly.length;
      const edge = { x: poly[j].x - poly[i].x, y: poly[j].y - poly[i].y };
      const axis = { x: -edge.y, y: edge.x };
      const len = Math.hypot(axis.x, axis.y);
      const nx = axis.x / len,
        ny = axis.y / len;
      let min1 = Infinity,
        max1 = -Infinity;
      for (const p of r1) {
        const pr = p.x * nx + p.y * ny;
        min1 = Math.min(min1, pr);
        max1 = Math.max(max1, pr);
      }
      let min2 = Infinity,
        max2 = -Infinity;
      for (const p of r2) {
        const pr = p.x * nx + p.y * ny;
        min2 = Math.min(min2, pr);
        max2 = Math.max(max2, pr);
      }
      if (max1 < min2 || max2 < min1) return false;
    }
  }
  return true;
}

// ---------- Walking / Trail ----------
function walkAlong(path, t, d, dir) {
  let [a, b] = path.slice();
  let { len } = segmentVector(a, b);
  let pos = t * len + (dir === 1 ? d : -d);
  while (pos > len + EPS || pos < -EPS) {
    if (dir === +1 && pos > len + EPS) {
      const nxt = chooseNextFrom(b, a);
      if (!nxt) {
        pos = len;
        break;
      }
      pos -= len;
      a = b;
      b = nxt;
      ({ len } = segmentVector(a, b));
    } else if (dir === -1 && pos < -EPS) {
      const prev = chooseNextFrom(a, b);
      if (!prev) {
        pos = 0;
        break;
      }
      b = a;
      a = prev;
      ({ len } = segmentVector(a, b));
      pos += len;
    } else break;
  }
  const newT = clamp(pos / len, 0, 1);
  return { path: [a, b], t: newT, ...pointOnSegment(a, b, newT) };
}
function ensureTrailHas(v) {
  const [a, b] = v.leadPath;
  const len = segmentVector(a, b).len;
  const last = v.trail[v.trail.length - 1];
  if (!last || last.from !== a || last.to !== b || last.len !== len) {
    v.trail.push({ from: a, to: b, len });
  }
}
function trimBackTrail(v, needBack) {
  let acc = 0;
  for (let i = v.trail.length - 1; i >= 0; i--) {
    acc += v.trail[i].len;
    if (acc >= needBack) {
      v.trail = v.trail.slice(i);
      return;
    }
  }
}

// ---------- Consist solving ----------
function spacing(prev, curr) {
  return prev.length / 2 + GAP + curr.length / 2;
}

function getPivotIndex(consist) {
  if (consist.liveLocoId) {
    const i = consist.vehicles.findIndex((v) => v.id === consist.liveLocoId);
    if (i >= 0) return i;
  }
  if (typeof consist.anchorIndex === "number") {
    return Math.max(
      0,
      Math.min(consist.anchorIndex, consist.vehicles.length - 1)
    );
  }
  return 0;
}

function requiredDistancesConsist(consist) {
  const pivotIndex = getPivotIndex(consist);
  let needBehind = 0,
    needAhead = 0;
  for (let i = pivotIndex + 1; i < consist.vehicles.length; i++)
    needBehind += spacing(consist.vehicles[i - 1], consist.vehicles[i]);
  for (let i = pivotIndex - 1; i >= 0; i--)
    needAhead += spacing(consist.vehicles[i], consist.vehicles[i + 1]);
  return { pivotIndex, needBehind, needAhead };
}

function solveConsistPositions(consist) {
  if (!consist || !consist.vehicles || !consist.vehicles.length) return;

  const pivotIndex = getPivotIndex(consist);
  const pivot = consist.vehicles[pivotIndex];

  // Ensure the pivot has a valid segment to sit on (borrow from a neighbor if needed)
  if (!pivot.leadPath || pivot.leadPath.length !== 2) {
    const n1 = consist.vehicles[pivotIndex + 1];
    const n0 = consist.vehicles[pivotIndex - 1];
    const seed =
      n1 && n1.leadPath?.length === 2
        ? n1
        : n0 && n0.leadPath?.length === 2
        ? n0
        : null;
    if (seed) {
      pivot.leadPath = seed.leadPath.slice();
      pivot.leadT = seed.leadT;
    } else {
      // Nothing to anchor to yet
      return;
    }
  }

  // Place pivot
  ensureTrailHas(pivot);
  const lc = pointOnSegment(pivot.leadPath[0], pivot.leadPath[1], pivot.leadT);
  pivot.px = lc.px;
  pivot.py = lc.py;

  /*pivot.heading = (consist.dir === 1 ? lc.heading : lc.heading + Math.PI);  
  pivot.heading = (pivot.heading + 2 * Math.PI) % (2 * Math.PI);*/
  pivot.heading = lc.heading;
  pivot.heading = (pivot.heading + 2 * Math.PI) % (2 * Math.PI);

  computeBogies(pivot);

  // Place vehicles "behind" the pivot (array index increases away from pivot)
  for (let i = pivotIndex + 1; i < consist.vehicles.length; i++) {
    const prev = consist.vehicles[i - 1];
    const curr = consist.vehicles[i];
    const d = spacing(prev, curr); // half + gap + half (in meters)
    const pos = walkAlong(prev.leadPath, prev.leadT, d, -1); // walk backwards from prev
    curr.leadPath = pos.path;
    curr.leadT = pos.t;
    ensureTrailHas(curr);
    const cc = pointOnSegment(curr.leadPath[0], curr.leadPath[1], curr.leadT);
    curr.px = cc.px;
    curr.py = cc.py;
    curr.heading = cc.heading; // follows rail direction
    curr.heading = (curr.heading + 2 * Math.PI) % (2 * Math.PI);
    computeBogies(curr);
  }

  // Place vehicles "ahead" of the pivot (array index decreases toward the front)
  for (let i = pivotIndex - 1; i >= 0; i--) {
    const next = consist.vehicles[i + 1];
    const curr = consist.vehicles[i];
    const d = spacing(curr, next);
    const pos = walkAlong(next.leadPath, next.leadT, d, +1); // walk forwards from next
    curr.leadPath = pos.path;
    curr.leadT = pos.t;
    ensureTrailHas(curr);
    const cc = pointOnSegment(curr.leadPath[0], curr.leadPath[1], curr.leadT);
    curr.px = cc.px;
    curr.py = cc.py;
    curr.heading = cc.heading;
    curr.heading = (curr.heading + 2 * Math.PI) % (2 * Math.PI);
    computeBogies(curr);
  }
}

// ---------- Snapshot/restore (consist) ----------
function snapshotConsistStateC(consist) {
  return consist.vehicles.map((v) => ({
    id: v.id,
    leadPath: v.leadPath.slice(),
    leadT: v.leadT,
  }));
}
function restoreConsistStateC(consist, snap) {
  snap.forEach((s) => {
    const v = consist.vehicles.find((x) => x.id === s.id);
    if (v) {
      v.leadPath = s.leadPath.slice();
      v.leadT = s.leadT;
      ensureTrailHas(v);
    }
  });
  solveConsistPositions(consist);
}

// ---------- Run / Animate (consist) ----------
function setStatusC(consist, text, { hit = true } = {}) {
  if (hit) {
    collisions++;
    buildControlsFromConsists();
    updateCollisionCounter();
  }

  consist.status = text || "";
  consist.statusAlpha = consist.status ? 1 : 0;
}
function clearStatusC(consist) {
  if (consist.status && consist.statusAlpha === 0) consist.status = "";
}

function runConsist(consist) {
  if (consist.moving) return;
  const pivot = consist.vehicles[getPivotIndex(consist)];
  ensureTrailHas(pivot);
  consist.moving = true;
  clearStatusC(consist);
  if (rafIds[consist.id]) cancelAnimationFrame(rafIds[consist.id]);
  animateConsist(consist);
}
function vehicleFrontStop(v, dir) {
  const front = walkAlong(v.leadPath, v.leadT, v.length / 2 + STOP_MARGIN, dir);
  const [fa, fb] = front.path;
  if (dir === 1 && front.t >= 1 - EPS) {
    const node = fb,
      prev = fa;
    if (bufferStates[node])
      return { stop: true, reason: "buffer", node, vehicle: v.id };
    const nxt = chooseNextFrom(node, prev);
    if (!nxt) return { stop: true, reason: "end", node, vehicle: v.id };
  }
  if (dir === -1 && front.t <= EPS) {
    const node = fa,
      prev = fb;
    if (bufferStates[node])
      return { stop: true, reason: "buffer", node, vehicle: v.id };
    const nxt = chooseNextFrom(node, prev);
    if (!nxt) return { stop: true, reason: "end", node, vehicle: v.id };
  }
  return { stop: false };
}

function animateConsist(consist) {
  if (!consist.moving) return;

  const pivotIndex = getPivotIndex(consist);
  const pivot = consist.vehicles[pivotIndex];
  if (!pivot || pivot.leadPath.length < 2) {
    //JD!!
    consist.moving = false;
    return;
  }

  // keep a copy for edge cases (we only revert T at boundaries)
  const prevPath = pivot.leadPath.slice();
  const prevT = pivot.leadT;

  const [a, b] = pivot.leadPath;
  const seg = segmentVector(a, b);
  const dt = ((consist.throttle * consist.speed) / seg.physLen) * consist.dir;
  let nextT = pivot.leadT + dt;

  // --- PRE-STOP CHECK: front edge of every vehicle ---
  for (const v of consist.vehicles) {
    const s = vehicleFrontStop(v, consist.dir);
    if (s.stop) {
      //consist.moving = false;
      const cause =
        s.reason === "buffer" ? bufferString(s.node) : "end of track";
      setStatusC(consist, `Stopped: ${v.id} at ${cause}`);
      stopConsist(consist);
      return;
    }
  }

  const tBeforeFwd = 1 - EPS;
  const tBeforeBack = EPS;

  if (consist.dir === 1) {
    // ----- FORWARD -----
    if (nextT >= 1) {
      const overshoot = nextT - 1;

      // stop just before the boundary and test collisions
      if (willCollide(consist, tBeforeFwd)) {
        consist.moving = false;
        pivot.leadT = tBeforeFwd;
        ensureTrailHas(pivot);
      } else {
        const leg = buildLeg(b, a);
        if (leg.length) {
          const tAfter = Math.max(EPS, Math.min(1 - EPS, overshoot));
          if (willCollide(consist, tAfter, leg)) {
            consist.moving = false;
            pivot.leadT = tBeforeFwd;
            ensureTrailHas(pivot);
          } else {
            pivot.leadPath = leg;
            pivot.leadT = tAfter;
            ensureTrailHas(pivot);
            clearStatusC(consist);
          }
        } else {
          // no next leg →  in this direction
          pivot.leadT = tBeforeFwd; // keep just before the end
          consist.moving = false;
          setStatusC(consist, `${STOPPED_STRING}: train on another line`);
        }
      }
    } else {
      // staying within current segment
      if (willCollide(consist, nextT)) {
        consist.moving = false;
      } else {
        pivot.leadT = nextT;
        ensureTrailHas(pivot);
        clearStatusC(consist);
      }
    }
  } else {
    // ----- REVERSE -----
    if (nextT <= 0) {
      const overshoot = -nextT;

      if (willCollide(consist, tBeforeBack)) {
        consist.moving = false;
        pivot.leadT = tBeforeBack;
        ensureTrailHas(pivot);
      } else {
        const leg = buildLeg(a, b);
        if (leg.length) {
          // reverse into previous leg
          const revLeg = [leg[1], leg[0]];
          const tAfter = Math.max(EPS, Math.min(1 - EPS, 1 - overshoot));

          // trim last trail segment if it matches this one (optional housekeeping)
          const last = pivot.trail[pivot.trail.length - 1];
          if (last && last.from === a && last.to === b) pivot.trail.pop();

          if (willCollide(consist, tAfter, revLeg)) {
            consist.moving = false;
            pivot.leadT = tBeforeBack;
            ensureTrailHas(pivot);
          } else {
            pivot.leadPath = revLeg;
            pivot.leadT = tAfter;
            ensureTrailHas(pivot);
            clearStatusC(consist);
          }
        } else {
          pivot.leadT = tBeforeBack; // keep just inside
          consist.moving = false;
          setStatusC(consist, `${STOPPED_STRING}: buffer/end`);
        }
      }
    } else {
      if (willCollide(consist, nextT)) {
        consist.moving = false;
      } else {
        pivot.leadT = nextT;
        ensureTrailHas(pivot);
        clearStatusC(consist);
      }
    }
  }

  // --- Recompute positions for the whole consist ---
  solveConsistPositions(consist);
  checkSignalEntry(consist);
  checkSignalClear(consist);

  // --- POST-STOP CHECK: once more after positions updated ---
  for (const v of consist.vehicles) {
    const s = vehicleFrontStop(v, consist.dir);
    if (s.stop) {
      consist.moving = false;
      const cause =
        s.reason === "buffer"
          ? bufferString(s.node)
          : s.node + " unable to proceed";
      setStatusC(consist, `${STOPPED_STRING}: ${v.id} at ${cause}`);
      drawNetwork();
      return;
    }
  }

  // --- Trim the pivot's trail to what we actually need behind us ---
  const { needBehind } = requiredDistancesConsist(consist);
  trimBackTrail(pivot, needBehind);
  // --- Fade status if moving ---
  /*  
  if (consist.statusAlpha > 0){
    console.log (consist.status,consist.statusAlpha)
    consist.statusAlpha = Math.max(0, consist.statusAlpha - 0.06);
    if (consist.statusAlpha === 0) consist.status = "";
  }*/

  drawNetwork();
  rafIds[consist.id] = requestAnimationFrame(() => animateConsist(consist));
}

// ---------- Collision & Auto-coupling (between consists) ----------
function isContinuousSameTrack(v, o, dir) {
  const [va, vb] = v.leadPath,
    [oa, ob] = o.leadPath;
  let shared = null;
  if (va === oa || va === ob) shared = va;
  else if (vb === oa || vb === ob) shared = vb;
  if (!shared) return false;
  const otherO = oa === shared ? ob : ob === shared ? oa : null;
  if (!otherO) return false;
  if (dir === 1) {
    if (shared !== vb) return false;
    const next = chooseNextFrom(vb, va);
    return next === otherO;
  } else {
    if (shared !== va) return false;
    const next = chooseNextFrom(va, vb);
    return next === otherO;
  }
}

function willCollide(consist, nextLeadT, nextLeadPath = null) {
  // preview pivot move
  const pivot = consist.vehicles[getPivotIndex(consist)];
  const snap = snapshotConsistStateC(consist);
  const oldPath = pivot.leadPath.slice();
  const oldT = pivot.leadT;

  if (nextLeadPath) pivot.leadPath = nextLeadPath;
  pivot.leadT = nextLeadT;
  ensureTrailHas(pivot);
  solveConsistPositions(consist);

  // collect other vehicles from OTHER consists
  const others = [];
  for (const c of consists) {
    if (c.id === consist.id) continue;
    for (const v of c.vehicles) {
      if (v.px != null && v.py != null) others.push({ v, owner: c });
    }
  }

  let collide = false;
  let toMerge = null;

  for (const v of consist.vehicles) {
    const rectV = getVehicleRect(
      v,
      v.type === "loco" && v.id === consist.liveLocoId
    );
    for (const { v: o, owner } of others) {
      const rectO = getVehicleRect(
        o,
        o.type === "loco" && o.id === owner.liveLocoId
      );
      if (!rectsIntersect(rectV, rectO)) continue;

      const sameSeg =
        (v.leadPath[0] === o.leadPath[0] && v.leadPath[1] === o.leadPath[1]) ||
        (v.leadPath[0] === o.leadPath[1] && v.leadPath[1] === o.leadPath[0]);
      const continuous = isContinuousSameTrack(v, o, consist.dir);

      if (sameSeg || continuous) {
        if (isApproachingConsist(consist, owner)) {
          toMerge = owner;
        }
      } else {
        collide = true;
      }
      break;
    }
    if (collide || toMerge) break;
  }

  // two moving trains collide → stop both, no merge
  if (consist.moving && toMerge && toMerge.moving) {
    //    toMerge.moving = false;
    //    consist.moving = false;
    stopConsist(toMerge);
    stopConsist(consist);
    setStatusC(consist, `${STOPPED_STRING}: collision with moving train`);
    setStatusC(toMerge, `${STOPPED_STRING}: collision with moving train`);
    return;
  }

  // only allow an auto connect under a certain throttle
  if (!consist.nudging && toMerge && consist.throttle > AUTOCONNECT_THROTTLE) {
    setStatusC(consist, `${STOPPED_STRING}: too fast for auto couple`);
    //consist.moving = false;

    stopConsist(consist);

    return;
  }

  // restore preview state
  restoreConsistStateC(consist, snap);
  pivot.leadPath = oldPath;
  pivot.leadT = oldT;

  if (toMerge) {
    const list = toMerge.vehicles.slice(); // snapshot for status

    // merge vehicles into this consist
    if (consist.dir === 1) {
      for (let i = list.length - 1; i >= 0; i--) {
        const v = list[i];
        if (!consist.vehicles.find((x) => x.id === v.id)) {
          consist.vehicles.unshift(v);
        }
      }
    } else {
      for (let i = 0; i < list.length; i++) {
        const v = list[i];
        if (!consist.vehicles.find((x) => x.id === v.id)) {
          consist.vehicles.push(v);
        }
      }
    }

    // decide loco + direction
    if (consist.liveLocoId) {
      // moving consist already had a loco → keep it, neutralise others
      consist.vehicles.forEach((v) => {
        if (v.type === "loco" && v.id !== consist.liveLocoId) {
          v.neutral = true;
        }
      });
      // dir unchanged
    } else {
      // moving consist had no loco → adopt other's loco and direction
      const locoFromOther = list.find((v) => v.type === "loco" && !v.neutral);
      if (locoFromOther) {
        consist.liveLocoId = locoFromOther.id;
      }
    }

    // drop merged consist
    consists = consists.filter((c) => c.id !== toMerge.id);

    solveConsistPositions(consist);
    updateNodeOccupancyFromConsists();
    consist.moving = false;
    if (consist.nudging) {
      consist.throttle = consist.oldThrottle;
    }
    setStatusC(consist, `Stopped: coupled ${list.length} vehicle(s) `, {
      hit: false,
    });
    buildControlsFromConsists();
    return true;
  }

  if (collide) {
    setStatusC(consist, `${STOPPED_STRING}: train ahead`);
    return true;
  }

  return false;
}

function nearestVehicle(movingConsist, stationaryConsist) {
  let nearest = null;
  let bestDist = Infinity;
  for (const v of stationaryConsist.vehicles) {
    const dx = v.px - movingConsist.px;
    const dy = v.py - movingConsist.py;
    const d2 = dx * dx + dy * dy;
    if (d2 < bestDist) {
      bestDist = d2;
      nearest = v;
    }
  }
  return nearest;
}

function isApproachingConsist(movingConsist, stationaryConsist) {
  if (!movingConsist?.vehicles?.length || !stationaryConsist?.vehicles?.length)
    return false;

  // Pick lead vehicle depending on consist.dir
  const lead =
    movingConsist.dir === 1
      ? movingConsist.vehicles[movingConsist.vehicles.length - 1]
      : movingConsist.vehicles[0];

  const other = nearestVehicle(lead, stationaryConsist);
  if (!other) return false;

  const curDx = other.px - lead.px;
  const curDy = other.py - lead.py;
  const curDist = Math.hypot(curDx, curDy);

  // Project lead forward one step in its direction of travel
  let heading = lead.heading;
  if (movingConsist.dir === -1) heading += Math.PI; // flip when reversed
  const step = 1; // 1 metre look-ahead
  const projX = lead.px + Math.cos(heading) * step;
  const projY = lead.py + Math.sin(heading) * step;

  const newDx = other.px - projX;
  const newDy = other.py - projY;
  const newDist = Math.hypot(newDx, newDy);

  // Debug
  /*console.log("Approach check", {
    lead: { x: lead.px, y: lead.py, heading },
    other: { x: other.px, y: other.py },
    curDist,
    newDist,
  });*/

  return newDist < curDist;
}

// Smooth anchor (screen-space) state
let followAnchorX = null;
let followAnchorY = null;
let followPrevId = null;

function panToFollow() {
  if (!followConsistId) {
    followAnchorX = followAnchorY = null;
    followPrevId = null;
    return;
  }

  const c = consists.find((cc) => cc.id === followConsistId);
  if (!c) {
    followConsistId = null;
    return;
  }

  // Pick live loco or lead vehicle
  let target = c.liveLocoId
    ? c.vehicles.find((v) => v.id === c.liveLocoId)
    : c.dir === 1
    ? c.vehicles[c.vehicles.length - 1]
    : c.vehicles[0];
  if (!target) return;

  const cx = canvas.clientWidth / 2;
  const cy = canvas.clientHeight / 2;
  const a = canvas.clientWidth / 4; // ellipse major radius (x)
  const b = canvas.clientHeight / 4; // ellipse minor radius (y)

  // Heading in world; flip if consist is reversed
  let ang = target.heading + (c.dir === 1 ? Math.PI : 0);

  // Convert world heading to a SCREEN-SPACE direction using two points
  const locoScr = worldToScreen(target.px, target.py);
  const fwdScr = worldToScreen(
    target.px + Math.cos(ang),
    target.py + Math.sin(ang)
  );
  let ux = fwdScr.x - locoScr.x;
  let uy = fwdScr.y - locoScr.y;
  const len = Math.hypot(ux, uy);
  if (len < 1e-6) {
    ux = 1;
    uy = 0;
  } else {
    ux /= len;
    uy /= len;
  }

  // Intersection of line from centre along (ux,uy) with ellipse (x/a)^2+(y/b)^2=1
  // t = 1/sqrt( (ux^2/a^2) + (uy^2/b^2) ), anchor = centre + t * (ux,uy)
  const denom = (ux * ux) / (a * a) + (uy * uy) / (b * b);
  const t = denom > 0 ? 1 / Math.sqrt(denom) : 0;
  const anchorTargetX = cx + t * ux;
  const anchorTargetY = cy + t * uy;

  // Ease the anchor (so direction flips don’t snap)
  const SMOOTH = 0.01;
  if (followPrevId !== c.id || followAnchorX == null) {
    followAnchorX = anchorTargetX;
    followAnchorY = anchorTargetY;
    followPrevId = c.id;
  } else {
    followAnchorX += (anchorTargetX - followAnchorX) * SMOOTH;
    followAnchorY += (anchorTargetY - followAnchorY) * SMOOTH;
  }

  // Screen delta to move loco → anchor
  const dx = followAnchorX - locoScr.x;
  const dy = followAnchorY - locoScr.y;

  // Convert that screen delta into worldOffset delta (your proven method)
  const cosR = Math.cos(-worldRotation);
  const sinR = Math.sin(-worldRotation);
  const wx = dx * cosR - dy * sinR;
  const wy = dx * sinR + dy * cosR;

  worldOffsetX += wx;
  worldOffsetY += wy;

  // Optional safety: keep the loco visible with a small margin
  const margin = 24;
  const after = worldToScreen(target.px, target.py);
  if (after.x < margin) worldOffsetX += margin - after.x;
  if (after.x > canvas.clientWidth - margin)
    worldOffsetX += canvas.clientWidth - margin - after.x;
  if (after.y < margin) worldOffsetY += margin - after.y;
  if (after.y > canvas.clientHeight - margin)
    worldOffsetY += canvas.clientHeight - margin - after.y;
}

/*centre
function panToFollow() {
  if (!followConsistId) return;

  const c = consists.find(cc => cc.id === followConsistId);
  if (!c) return;

  // Prefer live loco
  let target = null;
  if (c.liveLocoId) {
    target = c.vehicles.find(v => v.id === c.liveLocoId);
  }

  // Fallback: use lead vehicle
  if (!target) {
    target = (c.dir === 1)
      ? c.vehicles[c.vehicles.length - 1]
      : c.vehicles[0];
  }

  if (!target) return;

  const screenCenterX = canvas.clientWidth / 2;
  const screenCenterY = canvas.clientHeight / 2;

  const worldX = target.px;
  const worldY = target.py;

  // Current screen position of target
  const screenPos = worldToScreen(worldX, worldY);

  // How far off-centre it is (in screen px)
  const dx = screenCenterX - screenPos.x;
  const dy = screenCenterY - screenPos.y;

  // Rotate that delta back into world space
  const cosR = Math.cos(-worldRotation);
  const sinR = Math.sin(-worldRotation);
  const wx = dx * cosR - dy * sinR;
  const wy = dx * sinR + dy * cosR;

  worldOffsetX += wx;
  worldOffsetY += wy;
}
*/
// ---------- Drawing ----------
function drawNetwork() {
  if (!trackLayout || !trackLayout.tracks) return;
  ctx.save();
  // Ensure DPR is applied (in case DPR changed)
  const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));

  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.translate(canvas.clientWidth / 2, canvas.clientHeight / 2); // CSS px
  ctx.rotate(worldRotation);
  ctx.translate(-canvas.clientWidth / 2, -canvas.clientHeight / 2);
  panToFollow();
  ctx.translate(worldOffsetX, worldOffsetY);
  ctx.scale(worldScale, worldScale);

  // === FEATURES BELOW TRACKS ===
  drawFeatures(ctx, 0);

  drawTracksAndNodes(ctx);

  // === FEATURES ABOVETRACKS ===
  drawFeatures(ctx, 1);

  // === CONSISTS (without status)===
  drawConsists(ctx);

  // === FEATURES ABOVETRIANS ===
  drawFeatures(ctx, 2);

  // === CONSISTS status above everything===
  drawConsists(ctx, { juststatus: true });

  drawCompass();
  ctx.restore();
  calculateStartSegments();
}

function drawTracksAndNodes(ctx) {
  // === TRACKS ===
  forEachEdge((a, b) => {
    const [ax, ay] = trackLayout.tracks[a].position;
    const [bx, by] = trackLayout.tracks[b].position;

    const k1 = `${a}-${b}`,
      k2 = `${b}-${a}`;
    const overrideLen = trackLayout.lengths?.[k1] ?? trackLayout.lengths?.[k2];

    ctx.beginPath();
    ctx.moveTo(ax, ay);
    ctx.lineTo(bx, by);
    if (overrideLen) {
      ctx.setLineDash([px(8), px(6)]);
      ctx.strokeStyle = "grey";
    } else {
      ctx.setLineDash([]);
      ctx.strokeStyle = "black";
    }
    ctx.lineWidth = TRACKWIDTH /*px(4)*/;
    ctx.stroke();

    if (overrideLen) {
      const midX = (ax + bx) / 2,
        midY = (ay + by) / 2;
      ctx.save();
      ctx.setLineDash([]);
      ctx.fillStyle = "grey";
      ctx.font = `${12 / worldScale}px sans-serif`;
      ctx.textAlign = "center";

      drawTextOutlinedScreen("long stretch", midX, midY, {
        fillStyle: "grey",
        size: 12,
        align: "center",
        dy: -6,
      });

      //ctx.fillText("long stretch", midX, midY - px(6));

      ctx.restore();
    }
  });
  ctx.setLineDash([]);

  // === NODES ===
  for (const id in trackLayout.tracks) {
    if (id.startsWith("N") && !showNNodes) continue;
    const [x, y] = trackLayout.tracks[id].position;

    ctx.beginPath();
    ctx.arc(x, y, px(NODERADIUSPX), 0, Math.PI * 2);

    if (id.startsWith("P")) {
      ctx.fillStyle = "lightgray";
    } else if (id.startsWith("S")) {
      const deg = degree(id);
      if (deg === 2) {
        // buffer end: red/green by state
        ctx.fillStyle = bufferStates[id] ? STOP_COLOUR : GO_COLOUR;
      } else {
        // signal in-path
        ctx.fillStyle = STOP_COLOUR;
      }
    } else if (id.startsWith("N")) {
      ctx.fillStyle = "lightblue"; // (debug)
    } else {
      ctx.fillStyle = "lightgray";
    }

    ctx.fill();
    ctx.lineWidth = px(1);
    ctx.strokeStyle = "black";
    ctx.stroke();

    /*
  ctx.fillStyle = "black";
  ctx.font = `${12 / worldScale}px Arial`;
  ctx.fillText(id, x - px(NODERADIUSPX/2), y - px(NODERADIUSPX));
*/
    let str = id;
    if (typeof trackLayout.tracks[id].label === "string") {
      str = trackLayout.tracks[id].label;
    }
    drawTextOutlinedScreen(str, x, y, {
      size: 12,
      align: "center",
      dy: -NODERADIUSPX + 12,
    });

    //drawTextOutlinedWorld(id,x - px(NODERADIUSPX/2), y - px(NODERADIUSPX)+px(12), { font:"Arial",size: 12,  align: "start",baseline: "alphabetic" });

    // Switch indicators for P*
    if (id.startsWith("P")) {
      const legs = getActiveLegs(id);
      if (legs) {
        const { fixed, active, inactive } = legs,
          r = px(NODERADIUSPX);
        [fixed, active].forEach((n) => {
          const [nx, ny] = trackLayout.tracks[n].position;
          const ang = Math.atan2(ny - y, nx - x);
          ctx.beginPath();
          ctx.moveTo(x, y);
          ctx.lineTo(x + Math.cos(ang) * r, y + Math.sin(ang) * r);
          ctx.lineWidth = TRACKWIDTH;
          ctx.strokeStyle = "black";
          ctx.stroke();
        });
        if (inactive) {
          const [nxIn, nyIn] = trackLayout.tracks[inactive].position;
          const [nxAct, nyAct] = trackLayout.tracks[active].position;

          const angInactive = Math.atan2(nyIn - y, nxIn - x);
          const angActive = Math.atan2(nyAct - y, nxAct - x);

          // normalize angle difference to [-π, π]
          let dAng = angInactive - angActive;
          while (dAng > Math.PI) dAng -= 2 * Math.PI;
          while (dAng < -Math.PI) dAng += 2 * Math.PI;

          const sweep = Math.PI / 4; // 45°
          const arcRadius = r + px(0);

          ctx.beginPath();

          if (dAng > 0) {
            // inactive is CCW from active → sweep further CCW (away from active)
            ctx.arc(x, y, arcRadius, angInactive, angInactive + sweep);
          } else {
            // inactive is CW from active → sweep further CW (away from active)
            ctx.arc(x, y, arcRadius, angInactive, angInactive - sweep, true);
          }

          ctx.strokeStyle = STOP_COLOUR;
          ctx.lineWidth = px(4);
          ctx.stroke();          
        }

        /*
        if (inactive) {

          const [nx, ny] = trackLayout.tracks[inactive].position;
          const ang = Math.atan2(ny - y, nx - x);
          ctx.beginPath();
          
          ctx.arc(
            x + Math.cos(ang) * r,
            y + Math.sin(ang) * r,            
            px(4),
            0,
            Math.PI * 2
          );
          ctx.fillStyle = STOP_COLOUR;
          ctx.fill();

        }*/
      }
    }
  }
}

function drawConsists(ctx, juststatus = false) {
  // === CONSISTS ===
  for (const c of consists) {
    if (!juststatus) {
      // bodies aligned to bogies (if present)
      for (const veh of c.vehicles) {
        if (veh.px == null) continue;

        const { cx, cy, ang } = getVisualPose(veh);
        const h = veh.type === "loco" ? LOCO_WIDTH : CAR_WIDTH;

        ctx.save();
        ctx.translate(cx, cy);
        ctx.rotate(ang);
        ctx.fillStyle =
          veh.color || (veh.type === "loco" ? "blue" : "darkgray");
        ctx.fillRect(-veh.length / 2, -h / 2, veh.length, h);
        ctx.lineWidth = px(1);
        ctx.strokeStyle = "black";
        ctx.strokeRect(-veh.length / 2, -h / 2, veh.length, h);

        // Neutral loco badge (bold N on black disc)
        if (veh.type === "loco" && veh.neutral) {
          const r = LOCO_WIDTH / 2;
          ctx.beginPath();
          ctx.fillStyle = "black";
          ctx.arc(0, 0, r, 0, Math.PI * 2);
          ctx.fill();
          /*ctx.fillStyle = "white";
        ctx.font = `bold ${LOCO_WIDTH*.9}px sans-serif`;
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText("N", 0, LOCO_WIDTH*.1);*/

          /*ctx.save();*/
          //ctx.globalAlpha = c.statusAlpha;
          /*ctx.fillStyle = "black";
      ctx.font = `${12 / worldScale}px sans-serif`;
      ctx.textAlign = "center";*/
          //ctx.fillText(c.status, cx, cy - CAR_WIDTH/2-px(3));

          //drawTextOutlinedWorld(c.status,cx, cy - CAR_WIDTH/2-px(3), { font:"Arial",globalAlpha:c.statusAlpha,size: 12,  align: "center",  baseline: "alphabetic" });
          drawTextOutlinedScreen("N", cx, cy, {
            outlinePx: 1,
            fillStyle: "white",
            size: LOCO_WIDTH * 0.9 * worldScale,
            textBaseline: "middle",
            align: "center",
            dy: LOCO_WIDTH * 0.35 * worldScale,
          });
        }
        ctx.restore();

        // bogies under the body
        if (veh.bogies && veh.bogies.length === 2) {
          drawBogie(veh.bogies[0]);
          drawBogie(veh.bogies[1]);
        }
      }

      // direction arrow at the live loco (using visual pose)
      if (c.liveLocoId) {
        const live = c.vehicles.find((v) => v.id === c.liveLocoId);
        if (live && live.px != null) {
          let { cx, cy, ang } = getVisualPose(live);
          if (c.dir === -1) {
            ang = ang + Math.PI;
          }
          drawArrow(cx, cy, ang);
        }
      }

      // coupling lines
      for (let i = 0; i < c.vehicles.length - 1; i++) {
        drawCouplingLine(c.vehicles[i], c.vehicles[i + 1]);
      }
    } else {
      // status above pivot
      const pivot = c.vehicles[getPivotIndex(c)];
      if (c.status && c.statusAlpha > 0 && pivot?.px != null) {
        const { cx, cy } = getVisualPose(pivot);
        /*ctx.save();*/
        //ctx.globalAlpha = c.statusAlpha;
        /*ctx.fillStyle = "black";
      ctx.font = `${12 / worldScale}px sans-serif`;
      ctx.textAlign = "center";*/
        //ctx.fillText(c.status, cx, cy - CAR_WIDTH/2-px(3));

        //drawTextOutlinedWorld(c.status,cx, cy - CAR_WIDTH/2-px(3), { font:"Arial",globalAlpha:c.statusAlpha,size: 12,  align: "center",  baseline: "alphabetic" });
        drawTextOutlinedScreen(c.status, cx, cy, {
          size: 12,
          align: "center",
          dy: -CAR_WIDTH * worldScale - 3,
        });

        /*      ctx.restore();*/
      }
      if (c.statusAlpha > 0) {
        //    console.log (c.status,c.statusAlpha)
        c.statusAlpha = Math.max(0, c.statusAlpha - 0.06);
        if (c.statusAlpha === 0) c.status = "";
      }
    }
  }
}

function drawArrow(cx, cy, ang) {
  const wing = LOCO_WIDTH * 0.8; /*px(10)*/
  const tip = [
    cx + Math.cos(ang) * LOCO_WIDTH,
    cy + Math.sin(ang) * LOCO_WIDTH,
  ];
  const left = [
    cx + Math.cos(ang + 2.5) * wing,
    cy + Math.sin(ang + 2.5) * wing,
  ];
  const right = [
    cx + Math.cos(ang - 2.5) * wing,
    cy + Math.sin(ang - 2.5) * wing,
  ];
  ctx.beginPath();
  ctx.moveTo(...tip);
  ctx.lineTo(...left);
  ctx.lineTo(...right);
  ctx.closePath();

  ctx.lineWidth = 2 / worldScale; //draw black bordder in case on a yellow or lowcontrast train color
  ctx.strokeStyle = "black";
  ctx.stroke();
  ctx.fillStyle = "yellow";
  ctx.fill();
}

function drawCouplingLine(a, b) {
  if (a.px == null || b.px == null) return;

  const pa = getVisualPose(a);
  const pb = getVisualPose(b);

  const uaX = Math.cos(pa.ang),
    uaY = Math.sin(pa.ang);
  const ubX = Math.cos(pb.ang),
    ubY = Math.sin(pb.ang);

  // vector a→b (world)
  const abX = pb.cx - pa.cx,
    abY = pb.cy - pa.cy;

  // choose facing ends
  const aUseFront = abX * uaX + abY * uaY > 0;
  const bUseFront = (pa.cx - pb.cx) * ubX + (pa.cy - pb.cy) * ubY > 0;

  // end-face centers
  const aEndX = pa.cx + (aUseFront ? +1 : -1) * uaX * (a.length / 2);
  const aEndY = pa.cy + (aUseFront ? +1 : -1) * uaY * (a.length / 2);
  const bEndX = pb.cx + (bUseFront ? +1 : -1) * ubX * (b.length / 2);
  const bEndY = pb.cy + (bUseFront ? +1 : -1) * ubY * (b.length / 2);

  if (Math.hypot(bEndX - aEndX, bEndY - aEndY) < 1e-6) return;

  ctx.save();
  ctx.beginPath();
  ctx.moveTo(aEndX, aEndY);
  ctx.lineTo(bEndX, bEndY);
  ctx.strokeStyle = "white";
  ctx.lineWidth = TRACKWIDTH * 0.5;
  ctx.lineCap = "round";
  ctx.stroke();
  ctx.restore();
}

function drawFeatures(ctx, zOrder = 2) {
  //not done yeat but Zorder 2 is above trains
  //could add 0 (below eveerything)
  //could add 1 (above tracks below trains)

  if (!trackLayout.features) return;

  for (const [id, f] of Object.entries(trackLayout.features)) {
    if (f.zOrder == zOrder) {
      ctx.save();
      ctx.translate(f.position[0], f.position[1]);
      if (f.angle) ctx.rotate((f.angle * Math.PI) / 180);

      const img = f.src ? images[f.src] : null;

      if (f.type === "svg" && img) {
        // Draw SVG fitted into the world-space rectangle
        ctx.drawImage(img, 0, 0, f.size[0], -f.size[1]);

        if (f.drawTextLabel) {
          drawTextOutlinedScreen(
            f.drawTextLabel,
            f.position[0] + f.size[0] / 2,
            f.position[1] - f.size[1] / 2,
            { fillStyle: "grey", size: 12, align: "center", dy: 4 }
          );
        }
      } else {
        // Fallback rectangle
        ctx.fillStyle = f.fallbackColor || "lightgray";
        ctx.fillRect(0, 0, f.size[0], -f.size[1]);

        // Optional feature ID label
        ctx.fillStyle = "black";
        /*ctx.font = "10px sans-serif";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";*/
        ctx.font = `${12 / worldScale}px sans-serif`;
        ctx.textAlign = "center";

        if (f.drawTextLabel) {
          drawTextOutlinedScreen(
            f.drawTextLabel,
            f.position[0] + f.size[0] / 2,
            f.position[1] - f.size[1] / 2,
            { fillStyle: "grey", size: 12, align: "center", dy: 4 }
          );
        }

        /*      ctx.fillText(id, 0, 0);*/
      }

      ctx.restore();
    }
  }
}

// Allow multi-touch on the canvas
canvas.style.touchAction = "none";

canvas.style.touchAction = "none"; // ensure multi-touch reaches canvas

document.addEventListener("DOMContentLoaded", () => {
  const select = document.getElementById("layoutSelect");

  // Set current dropdown from query string
  const params = new URLSearchParams(window.location.search);
  const layout = params.get("layout");
  const rolling = params.get("rollingstock");

  if (layout && rolling) {
    for (let i = 0; i < select.options.length; i++) {
      const opt = select.options[i];
      if (
        opt.dataset.layout === layout &&
        opt.dataset.rollingstock === rolling
      ) {
        select.selectedIndex = i;
        break;
      }
    }
  }

  // Change event → build URL and force reload
  select.addEventListener("change", () => {
    const opt = select.options[select.selectedIndex];
    const newLayout = opt.dataset.layout;
    const newRolling = opt.dataset.rollingstock;

    const url = new URL(window.location.href);
    url.searchParams.set("layout", newLayout);
    url.searchParams.set("rollingstock", newRolling);

    window.location.href = url.toString(); // ← this reloads the page
  });
});

// ---- Mouse wheel zoom (also handles trackpad pinch on macOS) ----
const WHEEL_ZOOM_SENSITIVITY = 1.0018; // lower = faster zoom

function wheelDeltaPixels(e) {
  // Normalize deltaY regardless of deltaMode (pixels/lines/pages)
  // 0: pixels, 1: lines, 2: pages
  if (e.deltaMode === 1) return e.deltaY * 16; // ~16px per line
  if (e.deltaMode === 2) return e.deltaY * 800; // rough page
  return e.deltaY; // pixels
}

canvas.addEventListener(
  "wheel",
  (e) => {
    // If you want ONLY ctrl+wheel to zoom, uncomment the next two lines:
    // if (!e.ctrlKey) return;
    // e.preventDefault();

    // Prevent page scroll when wheel is over the canvas
    e.preventDefault();

    const r = canvas.getBoundingClientRect();
    const sx = e.clientX - r.left;
    const sy = e.clientY - r.top;

    // Smooth exponential zoom
    const delta = wheelDeltaPixels(e);
    const factor = Math.pow(WHEEL_ZOOM_SENSITIVITY, -delta);

    applyScaleAtCanvasPoint(sx, sy, worldScale * factor); // keeps (sx,sy) pinned
    drawNetwork();
  },
  { passive: false }
);

// ---------- Pan & Pinch (with gesture state) ----------
const fingers = new Map(); // pointerId -> {sx, sy} canvas-local
let gesture = "idle"; // 'idle' | 'panning' | 'pinching'
let panFingerId = null;
let panStart = null; // {sx, sy}
let startOffset = null; // {x, y}

let pinchStartDist = 0;
let pinchStartScale = 1;
let pinchCenter = null; // {sx, sy}

function clientToCanvas(e) {
  const r = canvas.getBoundingClientRect();
  return { sx: e.clientX - r.left, sy: e.clientY - r.top };
}

// Keep world point under (sx,sy) fixed while scaling
/*function applyScaleAtCanvasPoint(sx, sy, newScale){
  // use your global limits if you have them
  const ZOOM_MIN = 0.2, ZOOM_MAX = 30;
  if (newScale < ZOOM_MIN) newScale = ZOOM_MIN;
  if (newScale > ZOOM_MAX) newScale = ZOOM_MAX;

  const w = screenToWorld(sx, sy);
  worldScale = newScale;
  worldOffsetX = sx - w.x * worldScale;
  worldOffsetY = sy - w.y * worldScale;
}

*/

function applyScaleAtCanvasPoint(sx, sy, newScale) {
  const ZOOM_MIN = 0.2,
    ZOOM_MAX = 30;
  if (newScale < ZOOM_MIN) newScale = ZOOM_MIN;
  if (newScale > ZOOM_MAX) newScale = ZOOM_MAX;

  // 1. World coordinate under the mouse BEFORE zoom
  const worldBefore = screenToWorld(sx, sy);

  // 2. Apply the new scale
  worldScale = newScale;

  // 3. Where would that world point map AFTER zoom+rotation?
  const screenAfter = worldToScreen(worldBefore.x, worldBefore.y);

  // 4. Correction delta in *screen* coords
  const dx = sx - screenAfter.x;
  const dy = sy - screenAfter.y;

  // 5. Rotate correction back into world offset space
  const cosR = Math.cos(-worldRotation);
  const sinR = Math.sin(-worldRotation);

  const wx = dx * cosR - dy * sinR;
  const wy = dx * sinR + dy * cosR;

  worldOffsetX += wx;
  worldOffsetY += wy;
}

canvas.addEventListener(
  "pointerdown",
  (e) => {
    canvas.setPointerCapture(e.pointerId);
    const p = clientToCanvas(e);
    fingers.set(e.pointerId, p);

    if (fingers.size === 1) {
      if (followConsistId) {
        popupMessage(
          `Unable to pan because a train has ${FOLLOWING_TRAIN_STRING} set`
        );
        return; // don’t start panning
      }

      // begin PAN
      gesture = "panning";
      panFingerId = e.pointerId;
      panStart = { sx: p.sx, sy: p.sy };
      startOffset = { x: worldOffsetX, y: worldOffsetY };
    } else if (fingers.size === 2) {
      // transition to PINCH
      const pts = [...fingers.values()];
      pinchStartDist = Math.hypot(pts[0].sx - pts[1].sx, pts[0].sy - pts[1].sy);
      pinchStartScale = worldScale;
      pinchCenter = {
        sx: (pts[0].sx + pts[1].sx) / 2,
        sy: (pts[0].sy + pts[1].sy) / 2,
      };
      gesture = "pinching";
      panFingerId = null; // not panning anymore
    }
  },
  { passive: true }
);

canvas.addEventListener(
  "pointermove",
  (e) => {
    if (!fingers.has(e.pointerId)) return;
    const p = clientToCanvas(e);
    fingers.set(e.pointerId, p);

    if (
      gesture === "panning" &&
      fingers.size === 1 &&
      e.pointerId === panFingerId
    ) {
      const dx = p.sx - panStart.sx;
      const dy = p.sy - panStart.sy;
      /*worldOffsetX = startOffset.x + dx;
    worldOffsetY = startOffset.y + dy;*/

      // rotate screen delta into world space
      const cosR = Math.cos(-worldRotation);
      const sinR = Math.sin(-worldRotation);

      const wx = dx * cosR - dy * sinR;
      const wy = dx * sinR + dy * cosR;

      worldOffsetX = startOffset.x + wx;
      worldOffsetY = startOffset.y + wy;
      drawNetwork();
    } else if (
      gesture === "pinching" &&
      fingers.size === 2 &&
      pinchStartDist > 0
    ) {
      const pts = [...fingers.values()];
      const dist = Math.hypot(pts[0].sx - pts[1].sx, pts[0].sy - pts[1].sy);
      const scale = pinchStartScale * (dist / pinchStartDist);
      applyScaleAtCanvasPoint(pinchCenter.sx, pinchCenter.sy, scale);
      drawNetwork();
    }
  },
  { passive: true }
);

function endPointer(e) {
  // remove this finger
  if (!fingers.has(e.pointerId)) return;
  const wasPinching = gesture === "pinching";
  fingers.delete(e.pointerId);

  if (wasPinching) {
    if (fingers.size === 1) {
      // Pinch ended but one finger remains: start *fresh* pan anchored at that finger
      const [[id, p]] = fingers; // get the remaining finger
      gesture = "panning";
      panFingerId = id;
      panStart = { sx: p.sx, sy: p.sy };
      startOffset = { x: worldOffsetX, y: worldOffsetY }; // reset anchor ⇒ no jump
    } else {
      // no fingers left
      gesture = "idle";
      panFingerId = null;
      panStart = null;
      startOffset = null;
    }
    pinchStartDist = 0;
    pinchCenter = null;
    return;
  }

  if (gesture === "panning" && e.pointerId === panFingerId) {
    if (fingers.size === 1) {
      // continue panning with the remaining finger, but reset anchor to prevent drift
      const [[id, p]] = fingers;
      panFingerId = id;
      panStart = { sx: p.sx, sy: p.sy };
      startOffset = { x: worldOffsetX, y: worldOffsetY };
    } else {
      // no fingers left
      gesture = "idle";
      panFingerId = null;
      panStart = null;
      startOffset = null;
    }
  }
}
canvas.addEventListener("pointerup", endPointer, { passive: true });
canvas.addEventListener("pointercancel", endPointer, { passive: true });

// ---------- click handler ----------
canvas.addEventListener("click", (e) => {
  if (isInCompass(e)) {
    updateCompassRotation(e, { step: Math.PI / 4 });
  }
  const r = canvas.getBoundingClientRect();
  const sx = e.clientX - r.left,
    sy = e.clientY - r.top;
  const { x: mx, y: my } = screenToWorld(sx, sy);

  // --- helpers ---
  function localAlong(mx, my, v) {
    const dx = mx - v.px,
      dy = my - v.py;
    const c = Math.cos(v.heading),
      s = Math.sin(v.heading);
    // along = local X, across = local Y
    return { localX: dx * c + dy * s, localY: -dx * s + dy * c };
  }
  function isClickOnDirection(mx, my, veh) {
    const { localX } = localAlong(mx, my, veh);
    return Math.abs(localX) <= LOCO_WIDTH;
  }

  // seam world point between adjacent vehicles A|B (at their facing ends)
  function seamPointBetween(A, B) {
    const ux = B.px - A.px,
      uy = B.py - A.py;
    const d = Math.hypot(ux, uy) || 1;
    const uX = ux / d,
      uY = uy / d; // A -> B
    const aEndX = A.px + uX * (A.length / 2); // A's end facing B
    const aEndY = A.py + uY * (A.length / 2);
    const bEndX = B.px - uX * (B.length / 2); // B's end facing A
    const bEndY = B.py - uY * (B.length / 2);
    return { x: (aEndX + bEndX) / 2, y: (aEndY + bEndY) / 2 };
  }
  // nearest seam around vehicle index i in consist c
  function pickNearestSeam(mx, my, c, i) {
    const seams = [];
    if (i > 0) {
      const P = seamPointBetween(c.vehicles[i - 1], c.vehicles[i]); // (i-1|i)
      seams.push({
        kind: "before",
        idx: i,
        x: P.x,
        y: P.y,
        dist: Math.hypot(mx - P.x, my - P.y),
      });
    }
    if (i < c.vehicles.length - 1) {
      const P = seamPointBetween(c.vehicles[i], c.vehicles[i + 1]); // (i|i+1)
      seams.push({
        kind: "after",
        idx: i,
        x: P.x,
        y: P.y,
        dist: Math.hypot(mx - P.x, my - P.y),
      });
    }
    if (!seams.length) return null;
    seams.sort((a, b) => a.dist - b.dist);
    return seams[0];
  }

  // split utility (anchors both sides at the seam; preserves world pose)
  function splitAtChosenSeam(c, chosen) {
    let front, back;
    if (chosen.kind === "before") {
      front = c.vehicles.slice(0, chosen.idx);
      back = c.vehicles.slice(chosen.idx);
    } else {
      front = c.vehicles.slice(0, chosen.idx + 1);
      back = c.vehicles.slice(chosen.idx + 1);
    }

    c.moving = false;
    consists = consists.filter((x) => x.id !== c.id);

    if (front.length) {
      const liveFront = front.some((x) => x.id === c.liveLocoId)
        ? c.liveLocoId
        : null;
      const c1 = makeConsist(front, liveFront, null, null, c.dir, {
        pivotIndex: front.length - 1,
        preserveWorld: true,
      });
      solveConsistPositions(c1);
      consists.push(c1);
    }
    if (back.length) {
      const liveBack = back.some((x) => x.id === c.liveLocoId)
        ? c.liveLocoId
        : null;
      const c2 = makeConsist(back, liveBack, null, null, c.dir, {
        pivotIndex: 0,
        preserveWorld: true,
      });
      solveConsistPositions(c2);
      consists.push(c2);
    }
    buildControlsFromConsists();
    drawNetwork();
  }

  // --- pass once over consists/vehicles; handle first hit ---
  for (const c of consists) {
    for (let i = 0; i < c.vehicles.length; i++) {
      const v = c.vehicles[i];
      const isLoco = v.type === "loco";
      if (!pointInVehicle(mx, my, v)) continue;

      // 1) If a loco that's not live yet → make it live and stop.
      if (isLoco && c.liveLocoId !== v.id) {
        c.liveLocoId = v.id;
        c.vehicles.forEach((x) => {
          if (x.type === "loco") x.neutral = x.id !== v.id;
        });
        solveConsistPositions(c);
        buildControlsFromConsists();
        drawNetwork();
        return;
      }

      // 2) If click on direction arrow: flip
      if (isClickOnDirection(mx, my, v)) {
        if (isLoco && c.liveLocoId === v.id) {
          c.dir *= -1;
          solveConsistPositions(c);
          buildControlsFromConsists();
          drawNetwork();
        } else {
          // middle of a carriage (or neutral loco) → no action
          drawNetwork();
        }
        return;
      }

      // 3) Otherwise uncouple at the NEAREST COUPLER to the tap
      const end = getClickedEnd(mx, my, v);
      const connected = isEndConnected(v, end);

      if (connected) {
        //only spli if connected at that end
        const chosen = pickNearestSeam(mx, my, c, i);
        if (!chosen) {
          drawNetwork();
          return;
        } // single-vehicle consist
        splitAtChosenSeam(c, chosen);
        updateNodeOccupancyFromConsists();
        return;
      } else {
        // end is unconnected → nudge GAP metres in that direction
        nudgeConsist(c, GAP, end); //reconnect anything that's been disconnected
        drawNetwork();
        return;
      }
    }
  }

  // --- nodes (buffers / switches) ---
  for (const id in bufferStates) {
    const [x, y] = trackLayout.tracks[id].position;
    if (Math.hypot(mx - x, my - y) < px(NODERADIUSPX)) {
      if (!isNodeOccupied(id)) {
        bufferStates[id] = !bufferStates[id];
        trackLayout.tracks[id].occupiedBy = null;
        drawNetwork();
      }
      return;
    }
  }
  for (const pid in switchStates) {
    const [x, y] = trackLayout.tracks[pid].position;
    if (Math.hypot(mx - x, my - y) < px(NODERADIUSPX)) {
      if (!isNodeOccupied(pid)) {
        switchStates[pid] = (switchStates[pid] + 1) % 2;
        trackLayout.tracks[pid].occupiedBy = null;
        drawNetwork();
      }
      return;
    }
  }
});

// ---------- end of click handler ----------

// ---------- Fit on resize ----------
//window.addEventListener("resize", () => {   updateWorldTransform({ fit: true}); drawNetwork(); });

function computeBogies(v) {
  v.bogies = [];
  if (
    !v.leadPath ||
    v.leadPath.length !== 2 ||
    v.length <= MAXLENGTHBEFOREBOGIES
  )
    return;

  const half = v.length / 2;
  const inset = Math.max(0.1, Math.min(BOGIE_INSET, half - 0.1)); // clamp
  const d = half - inset; // distance from center to bogie along the track

  // front bogie (dir +1 from center)
  const f = walkAlong(v.leadPath, v.leadT, d, +1);
  // rear bogie (dir -1 from center)
  const r = walkAlong(v.leadPath, v.leadT, d, -1);

  v.bogies.push({
    px: f.px,
    py: f.py,
    heading: f.heading,
    path: f.path,
    t: f.t,
  });
  v.bogies.push({
    px: r.px,
    py: r.py,
    heading: r.heading,
    path: r.path,
    t: r.t,
  });
}

function drawBogie(b) {
  return;
  if (!b) return;
  ctx.save();
  ctx.translate(b.px, b.py);
  ctx.rotate(b.heading);

  // bogie frame
  ctx.fillStyle = "#333";
  ctx.fillRect(-BOGIE_LENGTH / 2, -BOGIE_WIDTH / 2, BOGIE_LENGTH, BOGIE_WIDTH);
  ctx.lineWidth = px(1);
  ctx.strokeStyle = "#111";
  ctx.strokeRect(
    -BOGIE_LENGTH / 2,
    -BOGIE_WIDTH / 2,
    BOGIE_LENGTH,
    BOGIE_WIDTH
  );

  // wheels (purely visual; radii are meters so they scale with zoom)
  ctx.fillStyle = "#222";
  const wx = WHEEL_OFFSET;
  ctx.beginPath();
  ctx.arc(+wx, 0, WHEEL_RADIUS, 0, Math.PI * 2);
  ctx.arc(-wx, 0, WHEEL_RADIUS, 0, Math.PI * 2);
  ctx.fill();

  ctx.restore();
}

// Use bogies (if present) to define the visual pose of the vehicle
function getVisualPose(v) {
  if (v.bogies && v.bogies.length === 2) {
    const front = v.bogies[0]; // computeBogies() pushes [front, rear]
    const rear = v.bogies[1];
    const cx = (front.px + rear.px) / 2;
    const cy = (front.py + rear.py) / 2;
    const ang = Math.atan2(front.py - rear.py, front.px - rear.px); // rear→front
    return { cx, cy, ang };
  }
  return { cx: v.px, cy: v.py, ang: v.heading };
}

function normalizeConnections(layout) {
  const tracks = layout.tracks;
  // Build symmetric adjacency with unique neighbors
  const adj = {};
  for (const id in tracks) adj[id] = new Set();

  for (const a in tracks) {
    const conns = tracks[a].connections || [];
    for (const b of conns) {
      if (!tracks[b]) continue; // ignore dangling refs
      adj[a].add(b);
      adj[b].add(a); // ensure reverse edge exists
    }
  }

  // Freeze as arrays for use everywhere
  layout.adj = {};
  for (const id in adj) layout.adj[id] = Array.from(adj[id]);
}

function degree(id) {
  return trackLayout.adj?.[id]?.length || 0;
}

function forEachEdge(cb) {
  const seen = new Set();
  for (const a in trackLayout.adj) {
    for (const b of trackLayout.adj[a]) {
      const key = a < b ? `${a}|${b}` : `${b}|${a}`;
      if (seen.has(key)) continue;
      seen.add(key);
      cb(a, b);
    }
  }
}

function drawTextOutlinedScreen(
  text,
  worldX,
  worldY,
  {
    size = 12, // px on screen
    fill = "#000",
    outline = "#fff",
    outlinePx = 3, // px on screen
    font = "sans-serif",
    align = "center",
    fillStyle = "black",
    baseline = "alphabetic",
    globalAlpha = 1,
    dx = 0, // px offset in SCREEN coords (right +)
    dy = -16, // px offset in SCREEN coords (up -)
  } = {}
) {
  // 1) world → screen
  const { x: sx, y: sy } = worldToScreen(worldX, worldY);

  // 2) draw in SCREEN space with the same DPR transform
  const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
  ctx.save();
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // <-- keep DPR
  ctx.globalAlpha = globalAlpha;

  ctx.font = `${size}px ${font}`;
  ctx.textAlign = align;
  ctx.fillStyle = fillStyle;
  ctx.textBaseline = baseline;
  ctx.lineJoin = "round";
  ctx.lineWidth = outlinePx;

  const tx = sx + dx;
  const ty = sy + dy;

  ctx.strokeStyle = outline;
  ctx.strokeText(text, tx, ty);
  ctx.fillStyle = fillStyle;
  ctx.fillText(text, tx, ty);

  ctx.restore();
}

function drawTextOutlinedWorld(
  text,
  x,
  y,
  {
    size = 12, // desired screen px height
    fill = "#000",
    outline = "#fff",
    outlinePx = 3, // outline thickness in screen px
    font = "sans-serif",
    align = "start",
    globalAlpha = 1,
    baseline = "alphabetic",
  } = {}
) {
  ctx.save();
  ctx.font = `${size / worldScale}px ${font}`;
  ctx.globalAlpha = globalAlpha;
  ctx.textAlign = align;
  ctx.textBaseline = baseline;
  ctx.lineJoin = "round";
  ctx.lineWidth = px(outlinePx); // keeps outline ~constant on screen
  ctx.strokeStyle = outline;
  ctx.strokeText(text, x, y); // halo behind
  ctx.fillStyle = fill;
  ctx.fillText(text, x, y); // text on top
  ctx.restore();
}

function bufferString(id) {
  const t = trackLayout.tracks[id];
  if (!t) return "buff";
  const conns = trackLayout.adj ? trackLayout.adj[id] : t.connections || [];
  return id.startsWith("S") && conns.length === 2 ? "signal" : "buffer";
}

function getClickedEnd(mx, my, car) {
  let heading = car.heading;
  /*  if(car.type==='loco'){//may have to do this just for live loco??
      for (const c of consists) {
    const idx = c.vehicles.findIndex(v => v.id === car.id);
    if (idx !== -1) {
     if(c.dir==-1){
      heading=heading+Math.PI;
     }
    }
  }
}*/
  const dx = Math.cos(heading);
  const dy = Math.sin(heading);
  // project click onto car's longitudinal axis
  const relx = (mx - car.px) * dx + (my - car.py) * dy;
  const half = car.length / 2;

  if (relx >= half / 4) {
    return "front";
  }
  if (relx <= -half / 4) {
    return "rear";
  }

  return null; // clicked somewhere in the middle
}

function isEndConnected(car, end) {
  for (const c of consists) {
    const idx = c.vehicles.findIndex((v) => v.id === car.id);
    if (idx !== -1) {
      let neighbour = null;

      if (end === "rear" && idx < c.vehicles.length - 1) {
        neighbour = c.vehicles[idx + 1];
      }
      if (end === "front" && idx > 0) {
        neighbour = c.vehicles[idx - 1];
      }

      if (neighbour) {
        //console.log(`Car ${car.id} ${end} end is connected to ${neighbour.id} (${neighbour.heading})`);
        return true;
      } else {
        //console.log(`Car ${car.id} ${end} end is UNCONNECTED`);
        return false;
      }
    }
  }

  //  console.log(`Car ${car.id} not found in any consist`);
  return false;
}

function nudgeConsist(consist, metres, end) {
  if (!consist || consist.moving) return false;

  const oldDir = consist.dir;
  const oldSpeed = consist.speed;
  const oldMoving = consist.moving;
  consist.oldThrottle = consist.throttle;

  // Temporarily configure for one-step move
  consist.dir = end === "front" ? +1 : -1;

  consist.speed = metres; // force distance per step = metres

  consist.moving = true;
  consist.nudging = true;
  consist.throttle = 1;

  // Run one frame of the existing animation logic
  animateConsist(consist);
  consist.nudging = false;

  // Restore state
  consist.speed = oldSpeed;
  consist.moving = oldMoving;
  consist.dir = oldDir;
  consist.throttle = consist.oldThrottle;

  return true;
}
/*
function isNodeOccupied(nodeId) {
  const node = trackLayout.tracks[nodeId];
  if (!node) return false;

  for (const c of consists) {
    if (!c.vehicles.length) continue;

    // 1. Compute span of consist
    let spanMin = Infinity,
      spanMax = -Infinity;
    for (const v of c.vehicles) {
      for (const bogie of v.bogies) {
        if (!bogie.trail) continue;
        const d = bogie.trailPos; // distance along trail
        spanMin = Math.min(spanMin, d);
        spanMax = Math.max(spanMax, d);
      }
    }

    if (spanMin === Infinity) continue; // no trails yet

    // 2. Project node position onto same trail
    const dNode = projectNodeOntoTrail(nodeId, c.vehicles[0].bogies[0].trail);
    if (dNode === null) continue;

    // 3. Check overlap
    if (dNode >= spanMin && dNode <= spanMax) {
      return true;
    }
  }
  return false;
}*/
/*---prevent switches and points been clicked if a train is spannign them--*/
function projectNodeT(node, rear, front) {
  // both rear/front have .path [a,b], same segment
  const A = trackLayout.tracks[rear.path[0]];
  const B = trackLayout.tracks[rear.path[1]];
  const vx = B.x - A.x,
    vy = B.y - A.y;
  const segLen2 = vx * vx + vy * vy;
  if (segLen2 === 0) return null;

  const nx = node.x - A.x,
    ny = node.y - A.y;
  return (nx * vx + ny * vy) / segLen2; // 0..1 fraction
}

function isNodeOccupied(nodeId) {
  const node = trackLayout.tracks[nodeId];
  if (!node) return false;

  const nodeX = node.position[0];
  const nodeY = node.position[1];
  const tol = GAP / 2; // metres tolerance

  for (const c of consists) {
    for (const v of c.vehicles) {
      const { rear, front } = carEndsWorld(v);

      // --- body span check ---
      const dNodeRear = Math.hypot(nodeX - rear.px, nodeY - rear.py);
      const dNodeFront = Math.hypot(nodeX - front.px, nodeY - front.py);
      const dFrontRear = Math.hypot(front.px - rear.px, front.py - rear.py);

      if (Math.abs(dNodeRear + dNodeFront - dFrontRear) < tol) {
        //console.log(`Node ${nodeId} under car body ${v.id}`);
        return true;
      }

      // --- coupler check (rear & front) ---
      if (dNodeRear < tol) {
        //console.log(`Node ${nodeId} at REAR coupler of ${v.id}`);
        return true;
      }
      if (dNodeFront < tol) {
        //console.log(`Node ${nodeId} at FRONT coupler of ${v.id}`);
        return true;
      }
    }
  }

  return false;
}

function carEndsWorld(v) {
  ensureTrailHas(v);

  const rear = walkAlong(v.leadPath, v.leadT, v.length / 2, -1);
  const front = walkAlong(v.leadPath, v.leadT, v.length / 2, +1);

  return { rear, front };
}

function drawCompass() {
  const size = 60; // compass diameter in px
  const margin = 10;
  const cx = canvas.width - size / 2 - margin;
  const cy = size / 2 + margin;

  ctx.save();
  ctx.resetTransform();
  ctx.translate(cx, cy);

  // outer circle
  ctx.beginPath();
  ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
  ctx.fillStyle = "rgba(255,255,255,0.7)";
  ctx.fill();
  ctx.strokeStyle = "black";
  ctx.stroke();

  ctx.rotate(worldRotation);
  // south arrow (rotates with worldRotation)
  ctx.beginPath();
  ctx.moveTo(0, size / 2 - 12);
  ctx.lineTo(6, 0);
  ctx.lineTo(-6, 0);
  ctx.closePath();
  ctx.fillStyle = "grey";
  ctx.fill();

  // north arrow (rotates with worldRotation)
  ctx.beginPath();
  ctx.moveTo(0, -size / 2 + 12);
  ctx.lineTo(6, 0);
  ctx.lineTo(-6, 0);
  ctx.closePath();
  ctx.fillStyle = "red";
  ctx.fill();

  ctx.font = `10px sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "alphabetic";
  ctx.fillStyle = "black";
  ctx.fillText("N", 0, -size / 2 + 12);

  ctx.restore();
}

function updateCompassRotation(e, { step = 0 } = {}) {
  const rect = canvas.getBoundingClientRect();
  if (step) {
    worldRotation = (worldRotation + step) % (2 * Math.PI);
  } else {
    const size = 60;
    const margin = 10;
    const cx = rect.width - size / 2 - margin;
    const cy = size / 2 + margin;
    const sx = e.clientX - rect.left;
    const sy = e.clientY - rect.top;
    const angle = Math.atan2(sy - cy, sx - cx); // radians
    worldRotation = angle + Math.PI / 2; // align so arrow points north
  }
  drawNetwork();
}

/*let compassDragging = false;

canvas.addEventListener("mousedown", (e) => {
  if (isInCompass(e)) {
    compassDragging = true;
  }
});
canvas.addEventListener("mouseup", () => {
  compassDragging = false;
});
canvas.addEventListener("mousemove", (e) => {
  if (!compassDragging) return;
  updateCompassRotation(e);
});*/
function isInCompass(e) {
  const rect = canvas.getBoundingClientRect();
  const size = 60;
  const margin = 10;
  const cx = rect.width - size / 2 - margin;
  const cy = size / 2 + margin;

  const sx = e.clientX - rect.left;
  const sy = e.clientY - rect.top;

  return Math.hypot(sx - cx, sy - cy) <= size / 2;
}
function bestTextColor(bgColor) {
  // bgColor can be like "#rrggbb" or "rgb(r,g,b)"
  let r, g, b;

  if (!bgColor.startsWith("#")) {
    bgColor = toHexColor(bgColor);
  }

  const hex = bgColor.substring(1);
  const bigint = parseInt(hex, 16);
  if (hex.length === 6) {
    r = (bigint >> 16) & 255;
    g = (bigint >> 8) & 255;
    b = bigint & 255;
  }

  // Perceived brightness (YIQ formula)
  const yiq = (r * 299 + g * 587 + b * 114) / 1000;

  return yiq >= 128 ? "black" : "white";
}

function toHexColor(colorStr) {
  // Create a dummy element
  const el = document.createElement("div");
  el.style.color = colorStr;
  document.body.appendChild(el);

  // Let the browser resolve it
  const cs = getComputedStyle(el).color;
  document.body.removeChild(el);

  // cs will be in form "rgb(r, g, b)" or "rgba(r, g, b, a)"
  const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!m) return null;

  const r = parseInt(m[1]).toString(16).padStart(2, "0");
  const g = parseInt(m[2]).toString(16).padStart(2, "0");
  const b = parseInt(m[3]).toString(16).padStart(2, "0");

  return `#${r}${g}${b}`;
}

function popupMessage(msgText) {
  // Avoid duplicates (same message still visible)
  if (document.getElementById("popupMsg")) return;
  const scale = Math.min(2.5, window.devicePixelRatio || 1);

  const msg = document.createElement("div");
  msg.id = "popupMsg";
  msg.textContent = msgText;
  msg.style.position = "absolute";
  msg.style.top = "10px";
  msg.style.left = "50%";
  msg.style.transform = "translateX(-50%)";
  msg.style.background = "rgba(0,0,0,0.7)";
  msg.style.color = "white";
  msg.style.zIndex = 9999;
  msg.style.opacity = "1";
  msg.style.transition = "opacity 1s ease";
  msg.style.fontSize = `${12 * scale}px`;
  msg.style.padding = `${6 * scale}px ${10 * scale}px`;
  msg.style.borderRadius = `${4 * scale}px`;

  document.body.appendChild(msg);

  // Fade out after 1.5s, then remove at 2s
  setTimeout(() => {
    msg.style.opacity = "0";
    setTimeout(() => msg.remove(), 1000);
  }, 3500);
}

function loadTask(task) {
  // Clear the world
  collisions = 0;
  updateCollisionCounter();
  consists = [];
  nextConsistId = 1;
  cancelAllRafs();

  // Display task info
  if (task) {
    document.getElementById("taskTitle").textContent = task.title;
    document.getElementById("taskDesc").textContent = task.description;
  } else {
    document.getElementById("taskTitle").textContent = "";
    document.getElementById("taskDesc").textContent = "";
  }
  // restore node states (points + signals) if present
  if (task && task.nodestates) {
    applyNodeStates(task.nodestates);
  }
  placeStartConsists(task);
  updateNodeOccupancyFromConsists();
  buildControlsFromConsists();
  drawNetwork();
}

function findVehicleDef(label) {
  const loco = rollingstock.locomotives.find((l) => l.label === label);
  if (loco) return { ...loco };
  const car = rollingstock.carriages.find((c) => c.label === label);
  if (car) return { ...car };
  throw new Error("Unknown stock: " + label);
}

function buildTaskList() {
  const taskList = document.getElementById("taskList");
  taskList.innerHTML = "";

  rollingstock.tasks.forEach((task, i) => {
    const btn = document.createElement("button");
    btn.textContent = task.title;
    btn.onclick = () => loadTask(task);
    taskList.appendChild(btn);
  });
}

function cancelAllRafs() {
  for (const id in rafIds) {
    if (rafIds[id]) {
      cancelAnimationFrame(rafIds[id]);
    }
  }
  rafIds = {}; // reset
}

function updateNodeOccupancyFromConsists() {
  for (const sid in trackLayout.tracks) {
    const node = trackLayout.tracks[sid];
    node.occupiedBy = null;

    if (isNodeOccupied(sid)) {
      // find which consist is actually on this node
      for (const c of consists) {
        for (const v of c.vehicles) {
          const { rear, front } = carEndsWorld(v);
          const [nx, ny] = node.position;

          const tol = GAP / 2;
          const dRear = Math.hypot(nx - rear.px, ny - rear.py);
          const dFront = Math.hypot(nx - front.px, ny - front.py);
          const dBody = Math.hypot(nx - v.px, ny - v.py);

          if (dRear < tol || dFront < tol || dBody < tol) {
            node.occupiedBy = c.id;
            break;
          }
        }
        if (node.occupiedBy) break;
      }
    }
  }
}

function checkSignalEntry(consist) {
  for (const sid in bufferStates) {
    const node = trackLayout.tracks[sid];
    // If already locked by another consist, skip
    if (node.occupiedBy /* && node.occupiedBy !== consist.id*/) continue;

    // Is this node physically under *any* part of this consist?
    const occ = isNodeOccupied(sid);
    if (occ) {
      node.occupiedBy = consist.id;
      bufferStates[sid] = false; // set to green while occupied
      console.log("is occupied ", consist);
    } else {
      node.occupiedBy = null;
    }
  }
}

function checkSignalClear(consist) {
  for (const sid in bufferStates) {
    const node = trackLayout.tracks[sid];
    if (node.occupiedBy !== consist.id) continue; // only care about nodes this consist touched

    // Re-check if anything is still physically on this node
    const occ = isNodeOccupied(sid);

    if (!occ) {
      console.log(`Signal ${sid} cleared by consist ${consist.id}`);
      bufferStates[sid] = true; // back to red
      node.occupiedBy = null; // release lock
    }
  }
}


function getPointLeg(id, state) {
  const node = trackLayout.tracks[id];
  if (!node || !node.connections) return null;

  // filter out the always-live leg (the one starting with "S")
  const legs = node.connections.filter(c => !c.startsWith("S"));

  // default order: state=0 → first leg, state=1 → second leg
  if (legs.length >= 2) {
    return legs[state % legs.length];
  }

  return null;
}

function setPointLeg(id, legId) {
  const node = trackLayout.tracks[id];
  if (!node || !node.connections) return;

  const legs = node.connections.filter(c => !c.startsWith("S"));

  const idx = legs.indexOf(legId);
  if (idx >= 0) {
    switchStates[id] = idx;
  }
}

function applyNodeStates(nodestates) {
  for (const entry of nodestates) {
    if (entry.state && typeof entry.state === "string") {
      // point by leg ID
      setPointLeg(entry.id, entry.state);
    } else if (typeof entry.state === "number") {
      // numeric fallback
      switchStates[entry.id] = entry.state;
    } else if (entry.signal) {
      // signals from bufferStates
      if (entry.signal === "STOP") bufferStates[entry.id] = true;
      if (entry.signal === "GO")   bufferStates[entry.id] = false;
    }
  }
  drawNetwork();
}


function calculateStartSegments() {
  if (!calculatestartsegments) return;

  // ensure output div exists
  let outDiv = document.getElementById("startsegments-output");
  if (!outDiv) {
    outDiv = document.createElement("div");
    outDiv.id = "startsegments-output";
    Object.assign(outDiv.style, {
      position: "fixed",
      bottom: "0",
      left: "0",
      right: "0",
      maxHeight: "200px",
      overflow: "auto",
      background: "#111",
      color: "#0f0",
      fontFamily: "monospace",
      fontSize: "12px",
      padding: "8px",
      borderTop: "2px solid #444",
      whiteSpace: "pre"
    });
    document.body.appendChild(outDiv);
    // push up the page so content isn't hidden
  document.body.style.marginBottom = "220px";
  }

  const startsegments = [];
  const nodestates = [];

  // --- trains ---
  for (const consist of consists) {
    if (!consist.vehicles?.length) continue;

    const lead = consist.vehicles[0];
    if (!lead.leadPath || lead.leadPath.length < 2) continue;

    const [A, B] = lead.leadPath;
    const segLen = dist(A, B);

    const t = typeof lead.leadT === "number" ? lead.leadT : 0;
    const distance = Math.round(t * segLen);

    startsegments.push({
      segment: `${A}-${B}`,
      stock: consist.vehicles.map(v => v.id),
      distance
    });
  }

  // --- points (from switchStates) ---
if (switchStates) {
  for (const [id, state] of Object.entries(switchStates)) {
    const leg = getPointLeg(id, state);
    if (leg) {
      nodestates.push({ id, state: leg });
    }
  }
}


  // --- signals (only degree = 2, from bufferStates) ---
  if (bufferStates) {
    for (const [id, state] of Object.entries(bufferStates)) {
      if (degree(id) === 2) {
        const sig = state ? "STOP" : "GO";
        nodestates.push({ id, signal: sig });
      }
    }
  }

  // --- compressed JSON output ---
  const exportObj = { startsegments, nodestates };

  const json = "{\n" +
    Object.entries(exportObj).map(([key, arr]) =>
      `  "${key}": [\n` +
      arr.map(s => "    " + JSON.stringify(s)).join(",\n") +
      "\n  ]"
    ).join(",\n") +
    "\n}";

  outDiv.textContent = json;
}
