// ============================================================================
// RAILWAY SIMULATION MASTER FILE (CONSIST-CENTRIC)
// © Dial Solutions 2025
// ============================================================================
/*
url params ...
     rollingstock=rollingstock.json
     layout=layour.json
     task=n (index start=1) to load that task number
     drawnodes=1 to url paramaters to see all Nodes
     option=n (index start=1) to load that singal options
     compass=N to voeride comass setting in layout
     startsegments=1 to url to calculate JSON startsegments and nodestates for tasks (save and drop to load)
     debug=1 to url for more debug in console
</ul>
*/

// ============================================================================
// 1. CONFIGURATION & CONSTANTS
// ============================================================================
const version = "0.0.0.5"; //witch from chagpt to cursor

// Physics and simulation constants
const GAP = 1;
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 MAX_STEPS = 20; // safety to avoid loops on bad data and lopps on good data lookinf ahead (don't get back to same point withint 20 nodes!)

// Vehicle dimensions (world units - meters)
const LOCO_WIDTH = 2.25; // world units (meters)
const CAR_WIDTH = 2.8; // world units (meters)
const TRACKWIDTH = 0.5;
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;

// UI constants
const NODERADIUSPX = 17; // screen px for node hit radius
const COMPASS_DIAMETER = 60;
const COMPASS_SCALE = 1.5;

// Status strings
const STOPPED_STRING = "Hit";
const FOLLOW_TRAIN_STRING = "View Train";
const FOLLOWING_TRAIN_STRING = "Viewing";

// Colors
const colorpicker = [
  "#E74C3C",
  "#F39C12",
  "#56B4E9",
  "#009E73",
  "#6A5ACD",
  "#E69F00",
  "#1F77B4",
  "#CC79A7",
  "#F0E442",
  "#8E44AD",
  "#00B5E2",
  "#D55E00",
  "#2ECC71",
  "#4A90E2",
  "#A0522D",
  "#E91E63",
  "#3F51B5",
  "#20B2AA",
  "#9B59B6",
  "#2E7D32",
  "#FF7F0E",
  "#0072B2",
  "#17A589",
  "#607D8B",
  "#D62728",
  "#BDBDBD",
  "#8D6E63",
  "#00BCD4",
  "#FFC107",
  "#FF6F61",
];

const BUFFER_COLOUR = "red";
const STOP_COLOUR = "red";
const GO_COLOUR = "limegreen";

// URL parameters
const params = new URLSearchParams(window.location.search);
const layoutFile = params.get("layout") || "layoutallschematic.json";
const rollingFile = params.get("rollingstock") || "rollingstockall.json";
const compassParam = params.get("compass") || null;
const taskId = Math.max(0, parseInt(params.get("task") || 1) - 1);
const optionId = Math.max(0, parseInt(params.get("option") || 1) - 1);

// Hover and tooltip constants
const HOVER_DELAY_MS = 600; // how long the mouse must be still
const HOVER_SLOP_PX = 4; // movement tolerance to count as "still"
const HOVER_RADIUS_PX = 18; // hit radius to find a vehicle (in screen px)

// UI interaction constants
const WHEEL_ZOOM_SENSITIVITY = 1.0018; // lower = faster zoom

// ============================================================================
// 2. CANVAS & RENDERING SETUP
// ============================================================================
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);
  }
}
// ============================================================================
// 3. GLOBAL STATE VARIABLES
// ============================================================================
// World transform state
let worldScale = 1;
let worldOffsetX = 0;
let worldOffsetY = 0;
let worldRotation = 0;

// Simulation state
let collisions = 0;
let followConsistId = null; // id of consist being followed, or null
let justMerged = false;

// ============================================================================
// 4. CANVAS & WORLD TRANSFORM FUNCTIONS
// ============================================================================
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 = 40 } = {}) {
  if (!trackLayout || !fit) return; // keep current pan/zoom when fit=false

  const W = canvas.clientWidth;
  const H = canvas.clientHeight;

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

  // rotate about the layout center (so the picture doesn't "orbit")
  const cx = minX + layoutW / 2;
  const cy = minY + layoutH / 2;

  // rotate the 4 corners and take the axis-aligned bounds
  const a = worldRotation || 0; // radians
  const ca = Math.cos(a),
    sa = Math.sin(a);

  function rotX(x, y) {
    const dx = x - cx,
      dy = y - cy;
    return cx + dx * ca - dy * sa;
  }
  function rotY(x, y) {
    const dx = x - cx,
      dy = y - cy;
    return cy + dx * sa + dy * ca;
  }

  const corners = [
    [minX, minY],
    [maxX, minY],
    [maxX, maxY],
    [minX, maxY],
  ];

  let rminX = +Infinity,
    rminY = +Infinity,
    rmaxX = -Infinity,
    rmaxY = -Infinity;
  for (const [x, y] of corners) {
    const rx = rotX(x, y);
    const ry = rotY(x, y);
    if (rx < rminX) rminX = rx;
    if (rx > rmaxX) rmaxX = rx;
    if (ry < rminY) rminY = ry;
    if (ry > rmaxY) rmaxY = ry;
  }

  const rW = Math.max(1, rmaxX - rminX);
  const rH = Math.max(1, rmaxY - rminY);

  // fit the *rotated* bounds
  const scaleX = (W - margin) / rW;
  const scaleY = (H - margin) / rH;
  worldScale = Math.max(0.0001, Math.min(scaleX, scaleY));

  // center the *rotated* bounds in screen space
  const worldW = rW * worldScale;
  const worldH = rH * worldScale;
  worldOffsetX = (W - worldW) / 2 - rminX * worldScale;
  worldOffsetY = (H - worldH) / 2 - rminY * worldScale;
}

// screen px -> world units (keeps screen-constant sizes)
function px(n) {
  return n / worldScale;
}

// ============================================================================
// 5. DATA LOADING & INITIALIZATION
// ============================================================================
let switchStates = {};
let signalStates = {};
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

let drawnodes = params.get("drawnodes") || 0;
let calculatestartsegments = Number(params.get("startsegments")) || 0;
let debug = Number(params.get("debug")) || 0;
showNNodes = drawnodes == 1;

Promise.all([
  fetch(layoutFile).then((r) => r.json()),
  fetch(rollingFile).then((r) => r.json()),
])
  .then(([layout, stock]) => {
    trackLayout = layout;

    //trackLayout = cleanTrackLayout(layout);

    if (debug) {
      buildSegments(layout); //hopintg this is whaty i have
    }
    normaliseConnections(trackLayout);
    buildCoactingGroups(trackLayout);

    if (debug) {
      //does adj match segments??

      for (const k of Object.keys(trackLayout.adj)) {
        debugLog(`adj[${k}] =`, trackLayout.adj?.[k]);
      }
    }
    const root = stock?.rollingstock ?? stock ?? {};
    rollingstock = {
      locomotives: root.locomotives || [],
      carriages: root.carriages || [],
      tasks: root.tasks || [],
    };

    initCompass();
    initSwitchStates();
    initSignalStates();
    initStock();

    buildOptionList();
    buildTaskList();
    if (trackLayout.options && trackLayout.options.length) {
      loadOption(trackLayout.options[optionId]);
    }
    loadTask(rollingstock.tasks[taskId]);
    //placeStartConsists(rollingstock.tasks[0]);
    resizeCanvasToCSS();
    updateWorldTransform({ fit: true });

    // preload feature SVGs, then continue
    return initFeatures();
  })
  .then(() => {
    buildControlsFromConsists();
    drawNetwork("1");
  })
  .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("2");
});
ro.observe(canvas);

// ---------- Init ----------
function initCompass() {
  worldRotation = rotationFromCompass(compassParam || 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() {
  switchStates = {}
  for (const id in trackLayout.tracks)
    if (trackLayout.tracks[id].type == "point") switchStates[id] = 0;
}

function initSignalStates() {
  signalStates = {};
  for (const id in trackLayout.tracks) {
    if (
      trackLayout.tracks[id].type != "signal" &&
      trackLayout.tracks[id].type != "pointsignal"
    )
      continue;
    /*    signalStates[id] = (degree(id) === 1); // only ends are buffers by default
    signalStates[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
      signalStates[id] = node.state.toUpperCase() !== "GO";
    } else {
      // fall back to old rule
      signalStates[id] = true;
    }
  }
}

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

      leadPath: [],
      leadT: 0.5,
      trail: [],
      heading: 0,
      px: null,
      py: null,
    });
  });
}

// ============================================================================
// 6. VEHICLE & CONSIST MANAGEMENT
// ============================================================================
function getVehicleById(id) {
  let car =
    locos.find((v) => v.id === id) || cars.find((v) => v.id === id) || null;
  if (car == null) {
    console.error(`unable to locate ${id} in rollingstock `);
  }

  return car;
}

// 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 only what was given (pivot) unless preserving world
  if (!preserveWorld) {
    vs.forEach((v) => {
      if (v.leadPath && v.leadPath.length === 2) {
        // keep the path seeded in placeStartConsists (pivot)
        v.leadPath = v.leadPath.slice();
        v.leadT = v.leadT ?? initialT ?? 0.5;
      } else {
        // leave unseeded; will be solved from pivot later
        v.leadPath = null;
        v.leadT = null;
      }
    });
  }

  // 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;
        const seg = segmentVector(a, b);
        v.trail = seg ? [{ from: a, to: b, len: seg.len }] : [];
      } else {
        v.trail = [];
      }
    }
  });

  // Choose live loco if none explicitly given
  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: `T${nextConsistId++}`,
    vehicles: vs,
    liveLocoId,
    dir,
    speed: SPEED,
    throttle: 0.2,
    moving: false,
    nudging: false,
    status: "",
    statusAlpha: 0,
    anchorIndex: typeof pivotIndex === "number" ? pivotIndex : null,
  };

  // Solve trailing vehicle positions if we didn’t 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;
}

// Call this at the very start of placeStartConsists(task)
// Resolve "L*", "C*", "V*", "W*", etc. in task.startsegments
function resolveStartSegmentWildcards(startsegments) {
  const locoLabels = ((rollingstock && rollingstock.locomotives) || []).map(
    (x) => x.label
  );
  const carrLabels = ((rollingstock && rollingstock.carriages) || []).map(
    (x) => x.label
  );

  // Reserve all explicit labels so wildcards can't take them
  const reserved = new Set();
  for (const seg of startsegments || []) {
    for (const lab of seg.stock || []) {
      if (typeof lab === "string" && !lab.endsWith("*")) reserved.add(lab);
    }
  }

  // Track what we've actually assigned to avoid duplicates anywhere
  const seen = new Set();

  function pickRandom(candidates, token) {
    const pool = candidates.filter(
      (lab) => !reserved.has(lab) && !seen.has(lab)
    );
    if (!pool.length) {
      console.warn(`[startsegments] no candidates for ${token}`);
      return null;
    }
    const choice = pool[Math.floor(Math.random() * pool.length)];
    //    console.log(`[startsegments] ${token} → ${choice}`);
    seen.add(choice);
    return choice;
  }

  function candidatesForToken(token) {
    const prefix = token[0];
    if (prefix === "L") return locoLabels.slice();
    // carriages by family letter (C*, V*, W*, …)
    const arr = carrLabels.filter((lab) => lab.startsWith(prefix));
    if (!arr.length)
      console.warn(`[startsegments] unknown family for ${token}`);
    return arr;
  }

  const resolved = (startsegments || []).map((seg) => {
    const out = [];
    const local = new Set(); // prevent duplicates within this one segment

    for (const tok of seg.stock || []) {
      if (typeof tok === "string" && tok.endsWith("*")) {
        const cands = candidatesForToken(tok);
        const pick = pickRandom(
          cands.filter((lab) => !local.has(lab)), // also exclude already used in this segment
          tok
        );
        if (pick) {
          out.push(pick);
          local.add(pick);
        }
      } else {
        // explicit label: always honor the first occurrence
        if (seen.has(tok) || local.has(tok)) {
          console.warn(
            `[startsegments] duplicate explicit ${tok} — skipping later occurrence`
          );
          continue;
        }
        out.push(tok);
        seen.add(tok);
        local.add(tok);
      }
    }

    return { ...seg, stock: out };
  });

  return resolved;
}

function placeStartConsists(task) {
  consists = [];

  // Resolve wildcards like "L*", "C*", etc.
  const startList = resolveStartSegmentWildcards(task.startsegments || []);

  // Small wrapper so we always use travelAcross to walk along the graph
  function moveBy(seg, t, delta) {
    if (!seg || seg.length !== 2) return { seg, t };
    if (!isFinite(delta) || delta === 0) return { seg: seg.slice(), t };
    const dir = delta >= 0 ? +1 : -1;
    const distAbs = Math.abs(delta);
    const res = travelAcross(seg.slice(), t, distAbs, dir);
    // travelAcross returns { seg, t } at the landing point
    return {
      seg: res && res.seg ? res.seg.slice() : seg.slice(),
      t: res && typeof res.t === "number" ? res.t : t,
    };
  }

  for (const entry of startList) {
    const [rawA, rawB] = String(entry.segment || "").split("-");
    if (!trackLayout?.tracks?.[rawA] || !trackLayout?.tracks?.[rawB]) {
      console.error("[placeStartConsists] bad segment", entry.segment);
      continue;
    }

    // sanity: A and B must be adjacent (either way)
    const adj = trackLayout.adj || {};
    const aConns = adj[rawA] || trackLayout.tracks[rawA].connections || [];
    const bConns = adj[rawB] || trackLayout.tracks[rawB].connections || [];
    if (!aConns.includes(rawB) && !bConns.includes(rawA)) {
      console.error(
        `[placeStartConsists] invalid segment: ${rawA}-${rawB} are not directly connected`
      );
      continue;
    }

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

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

    // Canonical forward seg for [A,B]
    const canonKey = [rawA, rawB].sort().join("-");
    const forward = trackLayout.canonicalSegments?.[canonKey]?.forward;
    let seg0 = forward && forward.length === 2 ? forward.slice() : [rawA, rawB];

    // Saved distance is to the *front* of vehicle[0], measured from seg0[0] toward seg0[1]
    const frontVeh = vehicles[0];
    const halfLen = (+frontVeh.length || 0) / 2;

    const L0 = dist(seg0[0], seg0[1]);
    let frontDist =
      typeof entry.distance === "number" && L0 > 0 ? entry.distance : L0 * 0.5;

    // Normalise the "front" point onto the correct segment using travelAcross:
    // pick a start t in [0,1], then walk the remainder (positive => towards B, negative => towards A).
    let startT = 0;
    if (frontDist <= 0) {
      startT = 0;
    } else if (frontDist >= L0) {
      startT = 1;
    } else {
      startT = frontDist / L0;
    }
    const remainder = frontDist - startT * L0;
    let frontPos = moveBy(seg0, startT, remainder); // now the FRONT of vehicles[0]

    // Step back by half the front vehicle length to get the *centre* of vehicles[0]
    let centrePos = moveBy(frontPos.seg, frontPos.t, -halfLen);

    // Seed pivot (vehicle[0]) at that exact centre
    frontVeh.leadPath = centrePos.seg.slice();
    frontVeh.leadT = centrePos.t;

    // Place the rest of the consist *behind* in the order given by entry.stock
    for (let i = 1; i < vehicles.length; i++) {
      const car = vehicles[i];
      const ahead = vehicles[i - 1];

      const back = spacing(ahead, car); // your existing spacing()
      const landed = moveBy(ahead.leadPath, ahead.leadT, -back);

      car.leadPath = landed.seg.slice();
      car.leadT = landed.t;
    }

    // Choose live loco (don’t reorder vehicles)
    const firstLoco = vehicles.find((v) => v.type === "loco")?.id ?? null;

    // Build the consist; preserve per-vehicle world positions we’ve just seeded
    const consist = makeConsist(
      vehicles,
      firstLoco,
      seg0, // any default path; each vehicle already has leadPath/leadT
      0.5, // ignored when preserveWorld is true
      +1,
      { preserveWorld: true }
    );

    // Compute px/py
    solveConsistPositions(consist);

    consists.push(consist);

    if (debug) {
      console.table(
        vehicles.map((v) => ({
          id: v.id,
          leadPath: v.leadPath ? v.leadPath.join("-") : null,
          leadT: v.leadT,
          px: v.px,
          py: v.py,
        }))
      );
    }
  }

  buildControlsFromConsists();
  drawNetwork("3");
}

// 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;
}

// ============================================================================
// 9. USER INTERFACE & CONTROLS
// ============================================================================
function getconsistrowhtml(c) {
  const row = document.createElement("div");
  row.className = "consist";

  // Decide display order based on canvas coords of first vs last
  let displayVehicles = c.vehicles.slice();
  try {
    const first = c.vehicles[0],
      last = c.vehicles[c.vehicles.length - 1];
    if (first && last) {
      const fx = worldToScreen(first.px, first.py).x;
      const lx = worldToScreen(last.px, last.py).x;
      if (lx < fx) displayVehicles.reverse(); // show left→right as on canvas
    }
  } catch (e) {
    // if worldToScreen not ready, keep original order
  }

  // Map original indices so seams are correct even when displayed reversed
  const idxMap = new Map(c.vehicles.map((vv, i) => [vv.id, i]));

  for (let i = 0; i < displayVehicles.length; i++) {
    const v = displayVehicles[i];

    const labelColor = v.color;
    const who = v.name || v.id; // (fixed fallback)

    const label = document.createElement("span");
    label.className = "car";
    label.style.backgroundColor = labelColor;
    label.style.color = bestTextColor(labelColor);
    label.title = `${who} (${v.id})`;
    label.textContent = `${who}`;
    attachVehicleTooltipHandlers(label, v.id);
    row.appendChild(label);

    // Add seam link between this and the next displayed vehicle
    if (i < displayVehicles.length - 1) {
      const next = displayVehicles[i + 1];

      const lnkBtn = document.createElement("img");
      lnkBtn.className = "link";
      lnkBtn.src = "link.png";

      lnkBtn.onclick = () => {
        if (!c.moving) {
          // Seam index should be between these two in the ORIGINAL order
          const ia = idxMap.get(v.id);
          const ib = idxMap.get(next.id);
          if (ia == null || ib == null) return;

          const seamIdx = Math.min(ia, ib); // split AFTER the left one in original order
          const chosen = { kind: "after", idx: seamIdx };
          splitAtChosenSeam(c, chosen);
        }
      };

      row.appendChild(lnkBtn);
    }
  }

  return row;
}

function buildControlsFromConsists() {
  let 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;
  });

  let trains = consists
    .map((c) => {
      //debugLog(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.className = "train-label ";
    label.style.backgroundColor = labelColor;
    label.style.color = bestTextColor(labelColor);
    label.title = "Train";

    const vehicleIdForLabel = live?.id || firstLoco?.id;
    attachVehicleTooltipHandlers(label, vehicleIdForLabel);

    // Put the arrow visibly inside the label
    //label.textContent = `Train ${trainNo}: ${who}`;
    label.textContent = `${who}`;

    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
    let flipgears = 1;
    if (live) {
      flipgears = live.flipgears;
    }
    if (c.dir * flipgears === 1) {
      fwdBtn.classList.add("active");
    } else {
      revBtn.classList.add("active");
    }

    // Handlers
    fwdBtn.onclick = () => {
      const flip = liveLocoFlip(c);

      if (flip * c.dir == -1) {
        c.dir = 1 * flip;
        drawNetwork("4");
      }

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

    revBtn.onclick = () => {
      const flip = liveLocoFlip(c);
      if (flip * c.dir == 1) {
        c.dir = flip * -1;
        drawNetwork("5");
      }

      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

    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 = `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;
        });
        solveConsistPositions(c);
      }
      runBtn.classList.add("active");
      stopBtn.classList.remove("active");
      runConsist(c);
    };

    // 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");
      stopConsist(c);
    };

    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.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("6");
      }
    };

    row.appendChild(followBtn);

    el.appendChild(row);
  }
  el = document.getElementById("consists");
  if (!el) return;
  el.innerHTML = "";

  // --- now collect the "orphan" consists (no locos) ---
  const noLocoTrains = consists
    .filter((c) => !c.vehicles.some((v) => v.type === "loco"))
    .map((c, j) => ({
      c,
      locoIds: [],
      keyIndex: Infinity,
      trainNo: trains.length + j + 1, // continue numbering
    }));
  let row;
  for (const { c } of trains) {
    if (c.vehicles.length > 1) {
      row = getconsistrowhtml(c);
      el.appendChild(row);
    }
  }
  for (const { c } of noLocoTrains) {
    row = getconsistrowhtml(c);
    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);
  */
}

// ============================================================================
// 10. ANIMATION & SIMULATION
// ============================================================================
function stopConsist(consist) {
  consist.moving = false;
  if (rafIds[consist.id]) {
    cancelAnimationFrame(rafIds[consist.id]);
    delete rafIds[consist.id];
  }
  buildControlsFromConsists();
  drawNetwork("7");
}

// ---------- 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 signalTriggerState(node) {
  //if this node is a triggernode for a regular signal return teh signal state
  for (const id in signalStates) {
    const signal = trackLayout.tracks[id];
    if (signal && signal.type === "signal" && signal.triggernode === node) {
      return signalStates[id];
    }
  }

  return false;
}

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

  if (trackLayout.tracks[node].type == "point") {
    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;
}

// ============================================================================
// 7. GEOMETRY & MATH UTILITIES
// ============================================================================
function clamp(x, a, b) {
  return Math.max(a, Math.min(b, x));
}
function isBogieTurnAllowed(pid, prev, next) {
  if (trackLayout.tracks[pid].type !== "point" || !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 adjList = trackLayout.adj?.[at]; // define it first
  const nxt = chooseNextFrom(at, from);
  //console.log("[buildLeg]", at, "adj=", adjList, "from=", from, "→ nxt=", nxt);

  return nxt ? [at, nxt] : null;
  // 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 ? v.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);
    if (typeof trackLayout.tracks[id].label === "string") {
      //bodge a bit more for buffertext
      const margin = trackLayout.tracks[id].label.length * 1.5;
      minX = Math.min(minX, x - margin);
      minY = Math.min(minY, y - margin);
      maxX = Math.max(maxX, x + margin);
      maxY = Math.max(maxY, y + margin);
    }
  }
  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 };
}

// --- STRICT point sampler ---
// Never reorders the segment, never flips t.
// Always treats [A,B] literally as "A → B".
function pointOnSegStrict(A, B, t) {
  // clamp t just to be safe
  if (t < 0) t = 0;
  if (t > 1) t = 1;

  const [ax, ay] = trackLayout.tracks[A].position;
  const [bx, by] = trackLayout.tracks[B].position;

  const px = ax + (bx - ax) * t;
  const py = ay + (by - ay) * t;
  const heading = Math.atan2(by - ay, bx - ax); // orientation A→B

  return { px, py, heading };
}

//afterfixing track heading
function pointOnSegment(a, b, t) {
  const key = [a, b].sort().join("-");
  const canon = trackLayout.canonicalSegments?.[key];
  let [ca, cb] = canon ? canon.forward : [a, b];

  const [x1, y1] = trackLayout.tracks[ca].position;
  const [x2, y2] = trackLayout.tracks[cb].position;

  return {
    px: x1 + (x2 - x1) * t,
    py: y1 + (y2 - y1) * t,
    heading: Math.atan2(y2 - y1, x2 - x1), // stable, canonical
  };
}

function getVehicleRect(v, isLoco = false) {
  const w = isLoco ? v.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 ----------
//after fixing heading
// N.B travelAcross function often teh better thing to call
function walkAlong(path, t, dist, dir) {
  if (!Array.isArray(path) || path.length !== 2) {
    console.warn("[walkAlong] bad seg:", path);
    return null;
  }
  if (dir !== 1 && dir !== -1) {
    console.warn("[walkAlong] bad dir (must be ±1):", dir);
    return null;
  }

  const [A, B] = path;
  const seg = segmentVector(A, B);
  if (!seg || seg.physLen <= 0) {
    console.warn("[walkAlong] invalid segment:", A, B);
    return null;
  }

  // distance → fraction of segment
  const deltaT = (dist / seg.physLen) * dir;
  let newT = t + deltaT;

  // clamp for now
  if (newT < 0) newT = 0;
  if (newT > 1) newT = 1;

  // interpolate coords
  const [ax, ay] = trackLayout.tracks[A].position;
  const [bx, by] = trackLayout.tracks[B].position;
  const px = ax + (bx - ax) * newT;
  const py = ay + (by - ay) * newT;

  // heading along A→B, flip if dir is backwards
  let heading = Math.atan2(by - ay, bx - ax);
  if (dir === -1) heading = (heading + Math.PI) % (2 * Math.PI);

  return { seg: [A, B], t: newT, px, py, heading, stepDir: dir };
}

function ensureTrailHas(v) {
  if (!v || !Array.isArray(v.leadPath) || v.leadPath.length < 2) {
    console.warn("ensureTrailHas: bad leadPath for", v?.id, v?.leadPath);
    return;
  }

  const [a, b] = v.leadPath;
  const seg = segmentVector(a, b);
  if (!seg) {
    console.warn("ensureTrailHas: no segment vector for", a, b, "on", v?.id);
    return;
  }

  const len = seg.len;
  const last = v.trail?.[v.trail.length - 1];

  if (!last || last.from !== a || last.to !== b || last.len !== len) {
    if (!v.trail) v.trail = [];
    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;

  let oldPivotLength = consist.vehicles[pivotIndex].length;
  if (justMerged) {
    consist.vehicles[pivotIndex].length = oldPivotLength / 2; //only half the pivot required
  }

  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]);
  consist.vehicles[pivotIndex].length = oldPivotLength; //put it back
  needBehind -= GAP;
  needAhead -= GAP;
  return { pivotIndex, needBehind, needAhead };
}

function travelAcross(seg, t, dist, dir) {
  // seg = [A,B], t in [0,1], dist in world units, dir = ±1
  let [A, B] = seg; // ✅ must be let (we update these as we hop)

  if (!trackLayout || !trackLayout.tracks) {
    console.error("[travelAcross] trackLayout missing");
    return { x: 0, y: 0, dx: 0, dy: 0 };
  }

  // sanity
  if (!trackLayout.tracks[A] || !trackLayout.tracks[B]) {
    console.error("[travelAcross] missing node(s):", { A, B });
    return { seg: seg.slice(), t };
  }

  const EPS_T = 1e-3;
  let remaining = dist;
  let currSeg = [A, B];
  let currT = t;

  while (remaining > 0) {
    // distance available in current segment in the chosen direction
    const dx =
      trackLayout.tracks[currSeg[1]].position[0] -
      trackLayout.tracks[currSeg[0]].position[0];
    const dy =
      trackLayout.tracks[currSeg[1]].position[1] -
      trackLayout.tracks[currSeg[0]].position[1];
    const segLen = Math.hypot(dx, dy);

    const avail = dir > 0 ? (1 - currT) * segLen : currT * segLen;

    if (remaining <= avail + 1e-9) {
      // ✅ finish within this segment
      const step = (remaining / segLen) * dir;
      const newT = Math.max(EPS_T, Math.min(1 - EPS_T, currT + step));

      const ax = trackLayout.tracks[currSeg[0]].position[0];
      const ay = trackLayout.tracks[currSeg[0]].position[1];
      const bx = trackLayout.tracks[currSeg[1]].position[0];
      const by = trackLayout.tracks[currSeg[1]].position[1];
      const wx = ax + (bx - ax) * newT;
      const wy = ay + (by - ay) * newT;

      debugLog(
        `[travel-debug] FINISH seg=${currSeg[0]}-${
          currSeg[1]
        }, dir=${dir}, t=${newT.toFixed(3)}, coords=(${wx.toFixed(
          2
        )},${wy.toFixed(2)}), remaining=0`
      );
      return { seg: currSeg.slice(), t: newT };
    }

    // consume this segment fully and hop to next
    remaining -= avail;

    const exitNode = dir > 0 ? currSeg[1] : currSeg[0];
    const fromNode = dir > 0 ? currSeg[0] : currSeg[1];

    const nextLeg = buildLeg(exitNode, fromNode);
    if (!nextLeg) {
      const nodeCoords = trackLayout.tracks[exitNode].position;
      debugLog(
        `[travel-debug] DEAD END at ${exitNode}, seg=${currSeg[0]}-${
          currSeg[1]
        }, dir=${dir}, coords=(${nodeCoords[0].toFixed(
          2
        )},${nodeCoords[1].toFixed(2)})`
      );
      // park at boundary
      const stopT = dir > 0 ? 1 - EPS_T : EPS_T;
      return { seg: currSeg.slice(), t: stopT };
    }

    // ✅ Orient and move to the new leg
    let oriented;
    if (dir > 0) {
      oriented = nextLeg[0] === exitNode ? nextLeg : [nextLeg[1], nextLeg[0]];
      currSeg = oriented;
      [A, B] = currSeg; // ✅ keep A/B in sync
      currT = EPS_T;
    } else {
      oriented = nextLeg[1] === exitNode ? nextLeg : [nextLeg[1], nextLeg[0]];
      currSeg = oriented;
      [A, B] = currSeg; // ✅ keep A/B in sync
      currT = 1 - EPS_T;
    }

    // safety guard: ensure next segment nodes exist
    if (!trackLayout.tracks[A] || !trackLayout.tracks[B]) {
      console.warn(`[travelAcross] ${seg} missing track(s) after hop:`, {
        A,
        B,
      });
      break;
    }

    const ax = trackLayout.tracks[A].position[0];
    const ay = trackLayout.tracks[A].position[1];
    const bx = trackLayout.tracks[B].position[0];
    const by = trackLayout.tracks[B].position[1];
    const wx = ax + (bx - ax) * currT;
    const wy = ay + (by - ay) * currT;

    debugLog(
      `[travel-debug] HOP seg=${A}-${B}, dir=${dir}, seeded t=${currT.toFixed(
        3
      )}, coords=(${wx.toFixed(2)},${wy.toFixed(
        2
      )}), remaining=${remaining.toFixed(3)}`
    );
  }

  return { seg: currSeg.slice(), t: currT };
}

function smoothHeading(oldHeading, newHeading) {
  if (oldHeading == null) return (newHeading + 2 * Math.PI) % (2 * Math.PI);

  let delta = newHeading - oldHeading;

  // wrap delta into [-π, π]
  if (delta > Math.PI) delta -= 2 * Math.PI;
  if (delta < -Math.PI) delta += 2 * Math.PI;

  return (oldHeading + delta + 2 * Math.PI) % (2 * Math.PI);
}

function solveConsistPositions(consist, index = null) {
  if (!consist || !consist.vehicles || !consist.vehicles.length) return;

  const pivotIndex = index != null ? index : getPivotIndex(consist);
  const pivot = consist.vehicles[pivotIndex];

  if (!pivot.leadPath || pivot.leadPath.length !== 2) {
    console.error(
      `[solveConsistPositions] ${consist.id}: pivot ${pivot?.id} has no valid leadPath`
    );
    return;
  }

  // --- place pivot ---
  ensureTrailHas(pivot);
  {
    const [A, B] = pivot.leadPath;
    const lc = pointOnSegStrict(A, B, pivot.leadT);
    pivot.px = lc.px;
    pivot.py = lc.py;
    pivot.heading = (lc.heading + 2 * Math.PI) % (2 * Math.PI);
    pivot.heading = smoothHeading(pivot.heading, lc.heading);

    computeBogies(pivot);

    if (pivot.bogies?.length === 2) {
      const fb = pivot.bogies[0];
      const bb = pivot.bogies[1];
      debugLog(
        `[bogie-debug] ${consist.id} ${pivot.id} ` +
          `F seg=${fb.leadPath?.join("-")} t=${fb.leadT?.toFixed(3)} ` +
          `B seg=${bb.leadPath?.join("-")} t=${bb.leadT?.toFixed(3)}`
      );
    }

    debugLog(
      `[solve-debug] ${consist.id} PIVOT ${
        pivot.id
      } seg=${A}-${B}, t=${pivot.leadT.toFixed(3)}, coords=(${pivot.px.toFixed(
        2
      )},${pivot.py.toFixed(2)})`
    );
  }

  // --- vehicles behind pivot (tail) ---
  for (let i = pivotIndex + 1; i < consist.vehicles.length; i++) {
    const prev = consist.vehicles[i - 1];
    const curr = consist.vehicles[i];
    if (!prev.leadPath || prev.leadPath.length !== 2) continue;

    const d = spacing(prev, curr);
    const pos = travelAcross(prev.leadPath, prev.leadT, d, -1);
    curr.leadPath = pos.seg;
    curr.leadT = pos.t;

    ensureTrailHas(curr);

    const [A, B] = curr.leadPath;
    const cc = pointOnSegStrict(A, B, curr.leadT);
    curr.px = cc.px;
    curr.py = cc.py;
    curr.heading = smoothHeading(curr.heading, cc.heading);

    computeBogies(curr);

    if (curr.bogies?.length === 2) {
      const fb = curr.bogies[0];
      const bb = curr.bogies[1];
      debugLog(
        `[bogie-debug] ${consist.id} ${curr.id} ` +
          `F seg=${fb.leadPath?.join("-")} t=${fb.leadT?.toFixed(3)} ` +
          `B seg=${bb.leadPath?.join("-")} t=${bb.leadT?.toFixed(3)}`
      );
    }

    debugLog(
      `[solve-debug] ${consist.id} TAIL ${
        curr.id
      } seg=${A}-${B}, t=${curr.leadT.toFixed(3)}, coords=(${curr.px.toFixed(
        2
      )},${curr.py.toFixed(2)}), d=${d.toFixed(2)}`
    );
  }

  // --- vehicles ahead of pivot (front) ---
  for (let i = pivotIndex - 1; i >= 0; i--) {
    const next = consist.vehicles[i + 1];
    const curr = consist.vehicles[i];
    if (!next.leadPath || next.leadPath.length !== 2) continue;

    const d = spacing(curr, next);
    const pos = travelAcross(next.leadPath, next.leadT, d, +1);
    curr.leadPath = pos.seg;
    curr.leadT = pos.t;

    ensureTrailHas(curr);

    const [A, B] = curr.leadPath;
    const cc = pointOnSegStrict(A, B, curr.leadT);
    curr.px = cc.px;
    curr.py = cc.py;

    curr.heading = smoothHeading(curr.heading, cc.heading);

    computeBogies(curr);

    if (curr.bogies?.length === 2) {
      const fb = curr.bogies[0];
      const bb = curr.bogies[1];
      debugLog(
        `[bogie-debug] ${consist.id} ${curr.id} ` +
          `F seg=${fb.leadPath?.join("-")} t=${fb.leadT?.toFixed(3)} ` +
          `B seg=${bb.leadPath?.join("-")} t=${bb.leadT?.toFixed(3)}`
      );
    }

    debugLog(
      `[solve-debug] ${consist.id} FRONT ${
        curr.id
      } seg=${A}-${B}, t=${curr.leadT.toFixed(3)}, coords=(${curr.px.toFixed(
        2
      )},${curr.py.toFixed(2)}), d=${d.toFixed(2)}`
    );
  }
}

// ---------- 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 && !consist.nudging) {
    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]);
    delete 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.seg;

  if (dir === 1 && front.t >= 1 - EPS) {
    const node = fb,
      prev = fa;
    if (signalStates[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 (signalStates[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) {
  // Clear any existing RAF for this consist first
  if (rafIds[consist.id]) {
    cancelAnimationFrame(rafIds[consist.id]);
  }

  if (!consists.find((c) => c.id === consist.id)) return;
  if (!consist.moving) return;

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

  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;

  const EPS_T = EPS || 1e-3;
  const tBeforeFwd = 1 - EPS_T;
  const tBeforeBack = EPS_T;

  debugLog(
    `[anim-debug] tick ${consist.id} seg=${a}-${b}, dir=${
      consist.dir
    }, t=${pivot.leadT.toFixed(3)} dt=${dt.toFixed(
      4
    )} segLen=${seg.physLen.toFixed(3)} thr=${consist.throttle.toFixed(
      3
    )} spd=${consist.speed.toFixed(3)} nextT=${nextT.toFixed(3)}`
  );

  let overshoot = 0;
  let boundaryT = null;

  if (nextT >= 1) {
    overshoot = nextT - 1;
    boundaryT = 1;
  } else if (nextT <= 0) {
    overshoot = -nextT;
    boundaryT = 0;
  }

  if (boundaryT !== null && overshoot > 0) {
    // about to cross a node
    const stopT = boundaryT === 1 ? tBeforeFwd : tBeforeBack;

    // collision check at the boundary
    if (willCollide(consist, stopT)) {
      consist.moving = false;
      pivot.leadT = stopT;
      ensureTrailHas(pivot);
      solveConsistPositions(consist);
      drawNetwork("stop-collision");
      return;
    }

    const exitNode = boundaryT === 1 ? b : a;
    const fromNode = boundaryT === 1 ? a : b;

    // leftover distance in world units across the boundary
    const leftoverDist = overshoot * seg.physLen;

    debugLog(
      `[hop-debug] ${consist.id} exiting ${a}-${b} at ${exitNode}, dir=${
        consist.dir
      }, overshootT=${overshoot.toFixed(
        4
      )}, leftoverDist=${leftoverDist.toFixed(3)}`
    );

    // Build next leg
    const leg = buildLeg(exitNode, fromNode);

    if (leg && leg.length === 2) {
      // ✅ Orient by direction (same policy as travelAcross)
      let oriented;
      let seedT;
      if (consist.dir > 0) {
        oriented = leg[0] === exitNode ? leg : [leg[1], leg[0]];
        seedT = EPS_T;
      } else {
        oriented = leg[1] === exitNode ? leg : [leg[1], leg[0]];
        seedT = 1 - EPS_T;
      }

      // Walk any remaining distance *inside* the next segment using travelAcross,
      // starting at the boundary-seeded t.
      const hopped = travelAcross(oriented, seedT, leftoverDist, consist.dir);

      const [HA, HB] = hopped.seg;
      const ps = pointOnSegStrict(HA, HB, hopped.t);
      debugLog(
        `[hop-debug] oriented leg=${oriented[0]}-${
          oriented[1]
        }, hop.t=${hopped.t.toFixed(3)} → clamped=(${ps.px.toFixed(
          2
        )},${ps.py.toFixed(2)})`
      );

      if (willCollide(consist, hopped.t, hopped.seg)) {
        consist.moving = false;
        pivot.leadT = stopT;
        ensureTrailHas(pivot);
      } else {
        pivot.leadPath = hopped.seg;
        pivot.leadT = hopped.t;
        ensureTrailHas(pivot);
        clearStatusC(consist);
      }
    } else {
      // end of line
      pivot.leadT = stopT;
      consist.moving = false;
      setStatusC(consist, `${STOPPED_STRING}: end of line`);
    }
  } else {
    // Normal advance within the same segment
    const clampedNextT = Math.max(EPS_T, Math.min(1 - EPS_T, nextT));
    if (willCollide(consist, clampedNextT)) {
      consist.moving = false;
    } else {
      pivot.leadT = clampedNextT;
      ensureTrailHas(pivot);
      clearStatusC(consist);
    }
  }

  // Solve vehicle positions
  solveConsistPositions(consist);

  // Position debug after solve
  debugLog(
    `[solve-debug] ${consist.id} PIVOT ${pivot.id} seg=${pivot.leadPath[0]}-${
      pivot.leadPath[1]
    }, t=${pivot.leadT.toFixed(3)}, coords=(${pivot.px?.toFixed(
      2
    )},${pivot.py?.toFixed(2)})`
  );

  checkSignalEntry(consist);
  checkSignalClear(consist);
  if (!consist.moving) buildControlsFromConsists();

  // Front stop checks
  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.name} at ${cause}`);
      drawNetwork("veh-stop");
      return;
    }
  }

  const { needBehind } = requiredDistancesConsist(consist);
  trimBackTrail(pivot, needBehind);

  if (!consists.find((c) => c.id === consist.id)) return;
  drawNetwork("anim");
  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 isContinuousSameTrack(v, o, dir) {
  const [va, vb] = v.leadPath || [];
  const [oa, ob] = o.leadPath || [];
  if (!va || !vb || !oa || !ob) return false;

  // start from v’s forward end
  let cur = dir === 1 ? vb : va;
  let prev = dir === 1 ? va : vb;

  // walk ahead, respecting chooseNextFrom (points/signals), up to a sane bound

  for (let i = 0; i < MAX_STEPS; i++) {
    // reached either end of the other consist?
    if (cur === oa || cur === ob) return true;

    // hard stop if current is a RED signal
    const node = trackLayout.tracks?.[cur];
    if (node && (node.type === "signal" || node.type === "pointsignal")) {
      if (signalStates && signalStates[cur]) return false; // red blocks continuity
    }

    const next = chooseNextFrom(cur, prev);
    if (!next) return false; // dead end or blocked ahead

    prev = cur;
    cur = next;
  }

  return false; // didn’t reach within bound (prevents loops)
}

// ============================================================================
// 11. COLLISION DETECTION & PHYSICS
// ============================================================================
function willCollide(consist, nextLeadT, nextLeadPath = null) {
  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;

  debugLog(
    `[willCollide] BEFORE trail solve: leadPath=${pivot.leadPath.join(
      "-"
    )}, t=${pivot.leadT.toFixed(3)}`
  );

  ensureTrailHas(pivot);
  solveConsistPositions(consist);

  debugLog(
    `[willCollide] AFTER trail solve:  leadPath=${pivot.leadPath.join(
      "-"
    )}, t=${pivot.leadT.toFixed(3)}, coords=(${pivot.px?.toFixed(
      2
    )},${pivot.py?.toFixed(2)})`
  );

  const pass = isConsistTryingToPassRedPointSignal(consist);
  if (pass.blocked) {
    stopConsist(consist);
    setStatusC(
      consist,
      `${STOPPED_STRING}: can't pass signal ${
        trackLayout.tracks[pass.signalId].label || pass.signalId
      } towards point`
    );

    return true;
  }
  // collect 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;
  let hitInfo = null;

  // detect overlap and approach
  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) {
        const approach = isApproachingConsist(consist, owner);
        if (approach.isApproaching) {
          toMerge = owner;
          hitInfo = approach;
          debugLog(approach);
        }
      } else {
        // Only treat as collision if we're not already merging with this consist
        debugLog(rectV, rectO, v, o);
        collide = true;
      }
      break;
    }
    if (collide || toMerge) break;
  }

  // both moving → stop
  if (consist.moving && toMerge && toMerge.moving) {
    stopConsist(toMerge);
    stopConsist(consist);
    setStatusC(consist, `${STOPPED_STRING}: collision with moving train`);
    setStatusC(toMerge, `${STOPPED_STRING}: collision with moving train`);
    return true;
  }

  // too fast for auto-couple
  if (!consist.nudging && toMerge && consist.throttle > AUTOCONNECT_THROTTLE) {
    setStatusC(consist, `${STOPPED_STRING}: too fast for auto couple`);
    stopConsist(consist);
    return true;
  }

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

  // --- AUTO COUPLE ---
  if (toMerge && hitInfo) {
    // cancel RAFs
    try {
      cancelAnimationFrame(rafIds[consist.id]);
      delete rafIds[consist.id];
    } catch {}
    try {
      cancelAnimationFrame(rafIds[toMerge.id]);
      delete rafIds[toMerge.id];
    } catch {}
    let follownew = false;
    if (followConsistId === toMerge.id || followConsistId === consist.id) {
      follownew = true;
    }

    const staticList = toMerge.vehicles.slice();
    const movingList = consist.vehicles.slice();

    const sIdx = staticList.findIndex((v) => v.id === hitInfo.stationaryId);
    const mIdx = movingList.findIndex((v) => v.id === hitInfo.movingId);

    const staticAtEnd = sIdx === 0 || sIdx === staticList.length - 1;
    const movingAtEnd = mIdx === 0 || mIdx === movingList.length - 1;
    if (!staticAtEnd || !movingAtEnd) {
      console.log("1");
      setStatusC(consist, `${STOPPED_STRING}: train ahead`);
      return true;
    }

    // Build deterministic order
    const staticSeq =
      sIdx === 0 ? staticList.slice().reverse() : staticList.slice();
    const movingSeq =
      mIdx === 0 ? movingList.slice() : movingList.slice().reverse();
    const mergedVehicles = [...staticSeq, ...movingSeq];

    // remove moving consists
    consists = consists.filter((c) => c.id !== consist.id);
    debugLog("caclulate direction from frontbacks and directions", hitInfo);
    debugLog("moinving dir", consist.dir, "stationary dir ", toMerge.dir);
    let dir = consist.dir;
    if (
      consist.dir == toMerge.dir &&
      hitInfo.movingEnd == hitInfo.stationaryEnd
    ) {
      debugLog(
        "//merging with a consist that has a different idea of what forward direction is"
      );
      dir *= -1;
    }

    //    addCarsToConsist(toMerge, movingSeq,hitInfo.stationaryEnd,consist.liveLocoId,dir);
    /*addCarsToConsist(toMerge, movingSeq,hitInfo.stationaryEnd,consist.liveLocoId,toMerge.dir);    */

    addCarsToConsist(
      toMerge,
      movingSeq,
      hitInfo.stationaryEnd,
      consist.liveLocoId,
      consist.dir
    );
    stopConsist(toMerge);

    toMerge.throttle = consist.throttle;
    if (follownew) {
      followConsistId = toMerge.id;
    }

    setStatusC(toMerge, `Stopped: coupled ${staticList.length} vehicle(s)`, {
      hit: false,
    });
    buildControlsFromConsists();
    drawNetwork("after-couple");

    return true;
  }

  if (collide) {
    console.log("2");
    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 debugConsistPositions(consist, label = "") {
  
  for (let i = 0; i < consist.vehicles.length; i++) {
    const v = consist.vehicles[i];
  //  debugLog(`  Vehicle ${v.id}: px=${v.px?.toFixed(2)}, py=${v.py?.toFixed(2)}, heading=${(v.heading*180/Math.PI).toFixed(1)}°` );

    if (i > 0) {
      const prev = consist.vehicles[i - 1];
      const dx = v.px - prev.px;
      const dy = v.py - prev.py;
      const dist = Math.hypot(dx, dy);

      // expected distance = half length of prev + half length of this + GAP
      const expected = (prev.length / 2) + (v.length / 2) + GAP;

      if (Math.abs(dist - expected) < 1.0) {
//        debugLog(`     ↳ roughly OK (dist=${dist.toFixed(2)}, expected=${expected.toFixed(2)})`);
      } else {
        debugLog(`Consist ${consist.id} debug positions ${label}`);
        debugLog(`     ↳ definitely way off! (dist=${dist.toFixed(2)}, expected=${expected.toFixed(2)})`);
        
      }
    }
  }
}*/

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

  const mvCount = movingConsist.vehicles.length;
  const stCount = stationaryConsist.vehicles.length;

  // --- determine the "front" vehicle of the moving consist ---
  const movingFront =
    movingConsist.dir === 1
      ? movingConsist.vehicles[0] // forward = first
      : movingConsist.vehicles[mvCount - 1]; // reversed = last

  // --- get stationary ends ---
  const stationaryFront = stationaryConsist.vehicles[0];
  const stationaryBack = stationaryConsist.vehicles[stCount - 1];

  // --- find nearest vehicle pair ---
  const nearest = nearestVehicle(movingFront, stationaryConsist);
  if (!nearest) return { isApproaching: false };

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

  // --- predict movement a short step forward along dir ---
  let heading = movingFront.heading;
  if (movingConsist.dir === -1) heading += Math.PI; // flipped direction

  const step = GAP; // look-ahead distance
  const projX = movingFront.px + Math.cos(heading) * step;
  const projY = movingFront.py + Math.sin(heading) * step;

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

  const approaching = newDist < curDist;

  // --- decide which end of stationary consist we’re approaching ---
  let stationaryEnd;

  if (stCount === 1) {
    //just use heading to decide fron or back it has nothing to do with forward and reverse
    const st = stationaryConsist.vehicles[0];
    let stHeading = st.heading;
    //    if (stationaryConsist.dir === -1) stHeading += Math.PI;

    const relAngle = Math.atan2(movingFront.py - st.py, movingFront.px - st.px);
    const dAngle = Math.abs(
      ((stHeading - relAngle + Math.PI * 3) % (Math.PI * 2)) - Math.PI
    );
    stationaryEnd = dAngle < Math.PI / 2 ? "front" : "back";
  } else {
    // Multi-car: whichever end is closer in world space
    const frontDist = Math.hypot(
      movingFront.px - stationaryFront.px,
      movingFront.py - stationaryFront.py
    );
    const backDist = Math.hypot(
      movingFront.px - stationaryBack.px,
      movingFront.py - stationaryBack.py
    );
    stationaryEnd = frontDist < backDist ? "front" : "back";
  }

  // --- determine which end of the moving consist is the one hitting ---
  const movingEnd =
    mvCount > 1
      ? movingConsist.dir === 1
        ? "front"
        : "back" // multi-car
      : movingConsist.dir === 1
      ? "front"
      : "back"; // single-car same rule

  return {
    isApproaching: approaching,
    movingId: movingFront.id,
    stationaryId: other.id,
    stationaryEnd,
    movingEnd,
  };
}

// 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;

  // --- Clamp follow panning to keep the real world inside view ---
  
  const { minX, minY, maxX, maxY } = getLayoutBounds();

  // Convert layout corners to screen space after proposed pan
  const tl = worldToScreen(minX, minY);
  const br = worldToScreen(maxX, maxY);

  // Current visible world edges in screen coords
  const leftEdge = Math.min(tl.x, br.x);
  const rightEdge = Math.max(tl.x, br.x);
  const topEdge = Math.min(tl.y, br.y);
  const bottomEdge = Math.max(tl.y, br.y);

  const viewW = canvas.clientWidth;
  const viewH = canvas.clientHeight;

  // Clamp in screen space (prevents world drifting into empty space)
  let clampDX = 0, clampDY = 0;

  // too much empty space on left/right?
  if (leftEdge > margin) clampDX = margin - leftEdge; // world moved too far right
  else if (rightEdge < viewW - margin) clampDX = (viewW - margin) - rightEdge; // too far left

  // too much empty space on top/bottom?
  if (topEdge > margin) clampDY = margin - topEdge; // world moved too far down
  else if (bottomEdge < viewH - margin) clampDY = (viewH - margin) - bottomEdge; // too far up

  // Apply the correction, converted to world space respecting rotation
  if (clampDX || clampDY) {
    const cosR = Math.cos(-worldRotation);
    const sinR = Math.sin(-worldRotation);
    const wx = clampDX * cosR - clampDY * sinR;
    const wy = clampDX * sinR + clampDY * cosR;
    worldOffsetX += wx;
    worldOffsetY += wy;
  }
}

*/
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;
  }

  // --- Clamp follow panning to keep the real world inside view (UNCHANGED) ---
  const margin = 40;

  const { minX, minY, maxX, maxY } = getLayoutBounds();
  const tl = worldToScreen(minX, minY);
  const br = worldToScreen(maxX, maxY);

  const leftEdge = Math.min(tl.x, br.x);
  const rightEdge = Math.max(tl.x, br.x);
  const topEdge = Math.min(tl.y, br.y);
  const bottomEdge = Math.max(tl.y, br.y);

  const viewW = canvas.clientWidth;
  const viewH = canvas.clientHeight;

  let clampDX = 0,
    clampDY = 0;

  // too much empty space on left/right?
  if (leftEdge > margin)
    clampDX = margin - leftEdge; // world moved too far right
  else if (rightEdge < viewW - margin) clampDX = viewW - margin - rightEdge; // too far left

  // too much empty space on top/bottom?
  if (topEdge > margin) clampDY = margin - topEdge; // world moved too far down
  else if (bottomEdge < viewH - margin) clampDY = viewH - margin - bottomEdge; // too far up

  if (clampDX || clampDY) {
    const cosR = Math.cos(-worldRotation);
    const sinR = Math.sin(-worldRotation);
    const wx = clampDX * cosR - clampDY * sinR;
    const wy = clampDX * sinR + clampDY * cosR;
    worldOffsetX += wx;
    worldOffsetY += wy;
  }

  // --- Ensure the followed loco is visible AFTER the world clamp (NEW ORDER ONLY) ---
  // (Using a slightly larger margin gives “plenty of track in front”.)
  const followMargin = 64;
  const after = worldToScreen(target.px, target.py);
  if (after.x < followMargin) worldOffsetX += followMargin - after.x;
  if (after.x > viewW - followMargin)
    worldOffsetX += viewW - followMargin - after.x;
  if (after.y < followMargin) worldOffsetY += followMargin - after.y;
  if (after.y > viewH - followMargin)
    worldOffsetY += viewH - followMargin - after.y;
}

// ============================================================================
// 8. RENDERING & DRAWING
// ============================================================================
function drawNetwork(label = null) {
  if (false && debug && label) {
    debugLog("drawnetwork", label);
  }
  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 });

  // Tooltip HUD (in screen space, inside the same frame)
  drawVehicleTooltipHUD(ctx);

  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, {
        fill: "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) {
    const [x, y] = trackLayout.tracks[id].position;

    switch (trackLayout.tracks[id].type) {
      case "buffer":
        drawBuffer(ctx, id);
        break;
      case "signal":
        ctx.fillStyle = signalStates[id] ? STOP_COLOUR : GO_COLOUR;
        drawNode(ctx, id, x, y);
        const pt = getNodeTriggerPoint(id); // world coords {x,y}
        /*if (pt) {
    const size = 0.7 * (TRACKWIDTH / 2); // half the cross size
    ctx.beginPath();
    ctx.moveTo(pt.x - size, pt.y - size);
    ctx.lineTo(pt.x + size, pt.y + size);
    ctx.moveTo(pt.x - size, pt.y + size);
    ctx.lineTo(pt.x + size, pt.y - size);
    ctx.strokeStyle = "white";
    ctx.lineWidth = px(1);
    ctx.stroke();
  }*/
        break;
      case "point":
        ctx.fillStyle = "lightgray";
        drawNode(ctx, id, x, y);
        drawPointLegs(ctx, id, x, y);
        break;
      case "pass":
        if (showNNodes) {
          ctx.fillStyle = "lightblue";
          drawNode(ctx, id, x, y);
        }
        break;
      case "pointsignal":
        drawPointSignal(ctx, id);
        break;

      default:
        debugLog(`node ${id} of unknown type {trackLayout.tracks[id].type}`);
        /*        ctx.fillStyle = "yellow"; //not delt with yet!
        drawNode(ctx, id, x, y);
        */
        break;
    }
  }
}

function drawConsists(ctx, juststatus = false) {
  // === CONSISTS ===
  for (const c of consists) {
    if (!juststatus) {
      //debugConsistPositions(c,"calling in drawConsists");
      // 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" ? veh.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 = veh.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,
            fill: "white",
            size: veh.width * 0.9 * worldScale,
            textBaseline: "middle",
            align: "center",
            dy: veh.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]);
        }

        if (debug) {
          const cos = Math.cos(veh.heading);
          const sin = Math.sin(veh.heading);

          // half length along heading vector
          const halfL = veh.length / 2;
          const hx = cos * halfL;
          const hy = sin * halfL;

          const frontX = veh.px + hx;
          const frontY = veh.py + hy;
          const backX = veh.px - hx;
          const backY = veh.py - hy;

          drawTextOutlinedScreen("F", frontX, frontY, {
            size: 8,
            fill: "red",
            outlinePx: 2,
            align: "center",
            dy: 0,
            textBaseline: "middle",
          });

          drawTextOutlinedScreen("B", backX, backY, {
            size: 8,
            fill: "blue",
            outlinePx: 2,
            align: "center",
            dy: 0,
            textBaseline: "middle",
          });
        }
      }

      // 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, { width: live.width || LOCO_WIDTH });
        }
      }

      // 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);
        drawTextOutlinedScreen(c.status, cx, cy, {
          size: 12,
          align: "center",
          dy: -CAR_WIDTH * worldScale - 3,

          globalAlpha: c.statusAlpha,
          background: "rgba(220,220,220,1)",
          boxStroke: "rgba(180,180,180,0.9)",
        });
      }
      if (c.statusAlpha > 0) {
        //    debugLog (c.status,c.statusAlpha)
        c.statusAlpha = Math.max(0, c.statusAlpha - 0.06);
        if (c.statusAlpha === 0) c.status = "";
      }
    }
  }
}

function drawArrow(cx, cy, ang, { width = LOCO_WIDTH } = {}) {
  const wing = width * 0.8; //px(10)
  const tip = [
    cx + Math.cos(ang) * wing * 0.8,
    cy + Math.sin(ang) * wing * 0.8,
  ];
  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.globalAlpha = 1;

  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 drawBuffer(ctx, id) {
  const node = trackLayout.tracks[id];
  if (!node) return;

  const [x, y] = node.position;
  const connId = (node.connections || [])[0];
  if (!connId) return;

  const [nx, ny] = trackLayout.tracks[connId].position;

  // direction vector (neighbor → buffer)
  let dx = x - nx;
  let dy = y - ny;
  const len = Math.hypot(dx, dy);
  if (len === 0) return;
  dx /= len;
  dy /= len;

  // perpendicular vector (rotate 90°)
  const pxv = -dy;
  const pyv = dx;

  let width = 3;
  const bufferLength = -STOP_MARGIN + px(width); // how far beyond node
  const bufferWidth = 1.2 * CAR_WIDTH; // width of crossbar

  // anchor point just past the node
  const bx = x + dx * bufferLength;
  const by = y + dy * bufferLength;

  // crossbar endpoints
  const x1 = bx + (pxv * bufferWidth) / 2;
  const y1 = by + (pyv * bufferWidth) / 2;
  const x2 = bx - (pxv * bufferWidth) / 2;
  const y2 = by - (pyv * bufferWidth) / 2;

  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.lineTo(x2, y2);
  ctx.lineWidth = px(width);
  ctx.strokeStyle = BUFFER_COLOUR;
  ctx.stroke();

  // --- label ---

  let labelText = id;
  if (!showNNodes && typeof node.label === "string") {
    labelText = node.label;
  }

  // measure text width in SCREEN px
  ctx.save();
  ctx.font = `12px sans-serif`;
  const textWidth = ctx.measureText(labelText).width;
  ctx.restore();

  // base margin in screen px
  const marginPx = 2;

  // total offset in screen px = margin + half text length
  const offsetPx = marginPx + textWidth / 2;

  // now convert that into WORLD units
  const offsetWorld = px(offsetPx);

  // push label outward along the track
  const lx = x + dx * offsetWorld;
  const ly = y + dy * offsetWorld;

  drawTextOutlinedScreen(labelText, lx, ly, {
    size: 12,
    fill: "#000",
    outline: "#fff",
    outlinePx: 3,
    align: "center",
    baseline: "middle",
    dy: 0,
  });
}

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,
            { fill: "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,
            { fill: "grey", size: 12, align: "center", dy: 4 }
          );
        }

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

      ctx.restore();
    }
  }
}

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

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

  // 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) ----
// WHEEL_ZOOM_SENSITIVITY moved to Configuration section

function wheelDeltaPixels(e) {
  // normalise 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("10");
  },
  { 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) {
      panFingerId = e.pointerId;
      panStart = { sx: p.sx, sy: p.sy };
      startOffset = { x: worldOffsetX, y: worldOffsetY };

      if (followConsistId) {
        gesture = "pandisabled";
        return; // don’t start panning
      }
      gesture = "panning";
    } 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 === "pandisabled" /*followConsistId*/) {
      const dx = p.sx - panStart.sx;
      const dy = p.sy - panStart.sy;
      const MOVETOLERANCE = 10;
      if (Math.abs(dx) > MOVETOLERANCE || Math.abs(dy) > MOVETOLERANCE) {
        popupMessage(
          `Unable to pan because a train has ${FOLLOWING_TRAIN_STRING} set`
        );
        return; // don’t pan
      }
    } else 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("11");
    } 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("12");
    }
  },
  { 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 });

// debuggin mouse move
if (debug) {
  const debugDiv = document.querySelector(".debug"); // optional debug div

  canvas.addEventListener("mousemove", (e) => {
    const rect = canvas.getBoundingClientRect();

    // screen coords relative to canvas
    const sx = e.clientX - rect.left;
    const sy = e.clientY - rect.top;

    // convert to world coords
    const { x: wx, y: wy } = screenToWorld(sx, sy);

    // log or display
    if (debugDiv) {
      debugDiv.textContent = `World: ${wx.toFixed(2)}, ${wy.toFixed(2)}`;
    } else {
      debugLog("World:", wx, wy);
    }
  });
}

// split utility (anchors both sides at the seam; preserves world pose)
function splitAtChosenSeam(c, chosen) {
  if (c.moving) return;
  let front, back;

  const oldThrottle = c.throttle;

  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);
  try {
    cancelAnimationFrame(rafIds[consist.id]);
    delete rafIds[consist.id];
  } catch {} //drop id from animationloops

  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);
    c1.throttle = oldThrottle;
    consists.push(c1);
    setStatusC(c1, "uncoupled", { hit: false });
  }
  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);
    c2.throttle = oldThrottle;
    consists.push(c2);
    setStatusC(c2, "uncoupled", { hit: false });
  }
  buildControlsFromConsists();
  drawNetwork("13");
}

// ---------- 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];
  }

  // --- pass once over consists/vehicles; handle first hit ---
  for (const c of consists) {
    // --- check for click between carriages first ---
    for (let i = 0; i < c.vehicles.length - 1; i++) {
      const seam = seamPointBetween(c.vehicles[i], c.vehicles[i + 1]);
      const d = Math.hypot(mx - seam.x, my - seam.y);
      if (d < GAP * 0.5) {
        // widen/contract detection radius as you like
        debugLog(
          "clicked between cars",
          c.vehicles[i].label,
          "|",
          c.vehicles[i + 1].label
        );
        splitAtChosenSeam(c, { kind: "after", idx: i, x: seam.x, y: seam.y });
        updateNodeOccupancyFromConsists();
        drawNetwork("seam click");
        return;
      }
    }

    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("14");
        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("15");
        } else {
          // middle of a carriage (or neutral loco) → no action
          drawNetwork("16");
        }
        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("17");
          return;
        } // single-vehicle consist
        splitAtChosenSeam(c, chosen);
        updateNodeOccupancyFromConsists();
        return;
      } else {
        // end is unconnected → nudge GAP metres in that direction
        nudgeConsist(c, GAP * 1.005, end); //reconnect anything that's been disconnected
        drawNetwork("18");
        return;
      }
    }
  }

  // --- nodes (buffers / switches) ---
  for (const id in signalStates) {
    if (trackLayout.tracks[id].type === "pointsignal") {
      if (isClickInPointSignal(id, mx, my)) {
        const sig = trackLayout.tracks[id];

        const pointId = sig.point; // e.g. "P1"
        const legId = sig.pointleg; // e.g. "N2"

        // don't allow clearing if the point is occupied
        // need to walk around any coacting
        //was if (isNodeOccupied(pointId)) {
        if (!canOperatePoint(pointId)) {
          // keep / force STOP
          labels = getPointLabels(pointId);
          //signalStates[id] = true;  better to keep green so you don't have to revers out of teh situation if teh points are on your leg
          popupMessage(`Unable to switch signal (point ${labels} occupied)`);

          debugLog(
            `Pointsignal ${id} blocked (point ${pointId} or anotehr in group occupied).`
          );
          drawNetwork("19");
          return;
        }

        // align the point to the governed leg (flip if needed)
        //needtowalk coacting

        const ok = alignPointToLeg(pointId, legId);
        if (!ok) {
          // couldn't align (unknown leg, fixed, or some guard failed) → stay STOP
          signalStates[id] = true;
          debugLog(
            `Pointsignal ${id} could not align point ${pointId} to ${legId}.`
          );
          drawNetwork("20");
          return;
        }

        // clear the pointsignal to GO
        signalStates[id] = !signalStates[id];
        //        debugLog(`Pointsignal ${id} clear so can flip; point ${pointId} aligned to ${legId}.` );

        // force ALL other signals on this point to red
        if (!signalStates[id]) {
          for (const sid in trackLayout.tracks) {
            const other = trackLayout.tracks[sid];
            if (
              other?.type === "pointsignal" &&
              other.point === pointId &&
              sid !== id
            ) {
              signalStates[sid] = true; // STOP
            }
          }
        }
        drawNetwork("21");
        return;
      }
    } else {
      //check signal on track
      const [x, y] = trackLayout.tracks[id].position;
      if (Math.hypot(mx - x, my - y) < px(NODERADIUSPX)) {
        if (!isNodeOccupied(id)) {
          signalStates[id] = !signalStates[id];
          trackLayout.tracks[id].occupiedBy = null;
          drawNetwork("22");
        }
        return;
      }
    }
  }
  for (const pid in switchStates) {
    const [x, y] = trackLayout.tracks[pid].position;
    if (Math.hypot(mx - x, my - y) < px(NODERADIUSPX)) {
      //need to walk coacting
      //wasif (!isNodeOccupied(pid)) {
      if (canOperatePoint(pid)) {
        const legs = getActiveLegs(pid);
        alignPointToLeg(pid, legs.inactive);
        /*switchStates[pid] = (switchStates[pid] + 1) % 2;*/

        updatePointSignalsForPoint(pid);

        trackLayout.tracks[pid].occupiedBy = null;
        drawNetwork("23");
      } else {
        const labels = getPointLabels(pid);
        popupMessage(`Unable to swicth point (point ${labels} occupied)`);
      }
      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
  ) {
    // too short, no bogies
    return;
  }

  const offset = v.length / 2 - BOGIE_INSET;

  // front bogie
  const f = travelAcross(v.leadPath, v.leadT, offset, +1);
  if (f) {
    const [A, B] = f.seg;
    const p = pointOnSegStrict(A, B, f.t);
    v.bogies.push({
      px: p.px,
      py: p.py,
      heading: p.heading,
      seg: [A, B],
      t: f.t,
      leadPath: [A, B],
      leadT: f.t,
    });
  }

  // back bogie
  const b = travelAcross(v.leadPath, v.leadT, offset, -1);
  if (b) {
    const [A, B] = b.seg;
    const p = pointOnSegStrict(A, B, b.t);
    v.bogies.push({
      px: p.px,
      py: p.py,
      heading: p.heading,
      seg: [A, B],
      t: b.t,
      leadPath: [A, B],
      leadT: b.t,
    });
  }

  // set draw centre at midpoint between bogies
  if (v.bogies.length === 2) {
    const fb = v.bogies[0];
    const bb = v.bogies[1];
    v.px = (fb.px + bb.px) / 2;
    v.py = (fb.py + bb.py) / 2;
    v.heading = fb.heading;
  }
}

function drawBogie(b) {
  if (debug) {
    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 = "#999";
    //const wx = WHEEL_OFFSET;
    ctx.beginPath();
    ctx.arc(0, 0, BOGIE_WIDTH / 2, 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];
    if (
      !isFinite(front.px) ||
      !isFinite(front.py) ||
      !isFinite(rear.px) ||
      !isFinite(rear.py)
    ) {
      console.error(
        "[getVisualPose] invalid bogie positions for",
        v.id,
        front,
        rear
      );
    }
    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 coincidentNodes(layout, tol = typeof EPS === "number" ? EPS : 0) {
  const tracks = layout?.tracks || {};
  const ids = Object.keys(tracks).filter((id) => {
    const n = tracks[id];
    return Array.isArray(n?.connections) && n.connections.length > 0;
  });
  if (!ids.length) return false;

  let found = false;

  // exact coincidences
  const exactMap = new Map();
  for (const id of ids) {
    const p = tracks[id]?.position;
    if (!p || p.length < 2) continue;
    const key = `${Number(p[0])},${Number(p[1])}`;
    (exactMap.get(key) || exactMap.set(key, []).get(key)).push(id);
  }
  for (const [key, group] of exactMap.entries()) {
    if (group.length > 1) {
      found = true;
      console.warn(`[coincidentNodes] exact: ${group.join(", ")} at [${key}]`);
    }
  }

  // near coincidences (only if tol > 0)
  if (tol > 0) {
    const reported = new Set();
    for (let i = 0; i < ids.length; i++) {
      const a = ids[i],
        pa = tracks[a]?.position;
      if (!pa) continue;
      for (let j = i + 1; j < ids.length; j++) {
        const b = ids[j],
          pb = tracks[b]?.position;
        if (!pb) continue;
        const dx = Math.abs(Number(pa[0]) - Number(pb[0]));
        const dy = Math.abs(Number(pa[1]) - Number(pb[1]));
        if (dx <= tol && dy <= tol) {
          const key = `${a}|${b}`;
          if (!reported.has(key)) {
            reported.add(key);
            found = true;
            const cx = (Number(pa[0]) + Number(pb[0])) / 2;
            const cy = (Number(pa[1]) + Number(pb[1])) / 2;
            console.warn(
              `[coincidentNodes] near (≤${tol}): ${a}, ${b} around [${cx}, ${cy}] (dx=${dx}, dy=${dy})`
            );
          }
        }
      }
    }
  }

  return found;
}

function normaliseConnections(layout) {
  const tracks = layout.tracks;

  if (coincidentNodes(layout)) {
    delete trackLayout.tracks;
    return;
  }

  const adj = {};
  for (const id in tracks) adj[id] = new Set();

  // Build symmetric adjacency
  for (const a in tracks) {
    const conns = tracks[a].connections || [];
    for (const b of conns) {
      if (!tracks[b]) continue;
      adj[a].add(b);
      adj[b].add(a);
    }
  }

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

  // Build east→west canonical ordering — informational only
  const canon = {};
  for (const a in adj) {
    for (const b of adj[a]) {
      if (a === b) continue;
      const key = [a, b].sort().join("-");
      if (canon[key]) continue;

      const posA = tracks[a].position;
      const posB = tracks[b].position;
      if (!posA || !posB) continue;

      // east→west ordering
      const forward = posA[0] >= posB[0] ? [a, b] : [b, a];
      canon[key] = { forward };
    }
  }

  layout.canonicalSegments = canon;
}

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",
    baseline = "alphabetic",
    globalAlpha = 1,
    dx = 0, // px offset in SCREEN coords (right +)
    dy = -16, // px offset in SCREEN coords (up -)
    lineHeight = 1, // multiplier of font size
  } = {}
) {
  // 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.textBaseline = baseline;
  ctx.lineJoin = "round";
  ctx.lineWidth = outlinePx;

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

  const lines = String(text).split(/\r?\n/);

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];

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

    ty += size * lineHeight; // advance for next line
  }

  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 "unknown";

  return trackLayout.tracks[id].type == "buffer" ? "buffer" : "signal";
}

function getClickedEnd(mx, my, car) {
  let heading = car.heading;

  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) {
        //debugLog(`Car ${car.id} ${end} end is connected to ${neighbour.id} (${neighbour.heading})`);
        return true;
      } else {
        //debugLog(`Car ${car.id} ${end} end is UNCONNECTED`);
        return false;
      }
    }
  }

  //  debugLog(`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;
}

/*---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
}

//// c  optionally  pass a consist if you don't want to check all consists
function isNodeOccupied(nodeId, c) {
  let node = trackLayout.tracks[nodeId];
  let nodeOnTrack = nodeId;

  if (/*Jd!node.type == "pointsignal" &&*/ node.triggernode) {
    nodeOnTrack = node.triggernode;
    node = trackLayout.tracks[node.triggernode];
  }

  // Build the pool of consists to test

  const pool = c && Array.isArray(c.vehicles) ? [c] : consists;

  // Check with triggernode if set for pointsignal
  for (const c of pool) {
    if (isConsistOverNode(c, nodeOnTrack)) {
      //console.log(`${c.id} over ${nodeOnTrack}`);
      return true;
    }
  }

  // Geometry x,y,check if leg not not spanned
  const pt = getNodeTriggerPoint(nodeId);
  if (!pt) {
    console.error(
      `[isNodeOccupied] getNodeTriggerPoint returned null or undefined for node ${nodeId}`
    );
    return false;
  }

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

  // --- buffer check for first & last car of each consist in pool ---
  for (const consist of pool) {
    const first = consist.vehicles[0];
    const last = consist.vehicles[consist.vehicles.length - 1];

    for (const v of [first, last]) {
      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) {
        //debugLog(`Node ${nodeId} under car body ${v.id}`);
        return true;
      }

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

  return false;
}

//very good not great with bogies
/*
function isConsistOverNode(c, nodeId) {
  if (!c?.vehicles?.length) return false;

  const first = c.vehicles[0];
  const last  = c.vehicles[c.vehicles.length - 1];
  if (!first?.leadPath || !last?.leadPath) return false;

  const [fA, fB] = first.leadPath;   // front vehicle’s segment endpoints
  const [lA, lB] = last.leadPath;    // rear  vehicle’s segment endpoints

  // If the whole consist lies on a single edge, it does NOT span any node
  if (edgeKey(fA, fB) === edgeKey(lA, lB)) {
    debugLog(`[occ-span] ${c.id}: all vehicles on ${fA}-${fB}, not spanning nodes`);
    return false;
  }

  // Walk along the actual routed path from the rear segment toward the front,
  // using buildLeg(at, from). We try both possible rear directions and accept
  // the first that reaches either front endpoint.
  const MAX_STEPS = 200; // safety to avoid loops on bad data

  function walkPath(start, prev, goalA, goalB) {
    // set of nodes we pass through (includes start)
    const visitedNodes = new Set([start]);
    // to prevent cycles, track directed edges (prev->curr)
    const seenDirected = new Set([`${prev}->${start}`]);

    let cur = start;
    let from = prev;

    for (let steps = 0; steps < MAX_STEPS; steps++) {
      // If we've arrived at either of the front segment nodes, success
      if (cur === goalA || cur === goalB) {
        return { found: true, nodes: visitedNodes };
      }

      const leg = buildLeg(cur, from);
      if (!leg || leg.length !== 2) break;

      // orient the leg so that leg[0] === cur
      const nxt = (leg[0] === cur) ? leg[1] : leg[0];

      // guard against oscillation / loops
      const sig = `${cur}->${nxt}`;
      if (seenDirected.has(sig)) break;
      seenDirected.add(sig);

      from = cur;
      cur  = nxt;
      visitedNodes.add(cur);
    }
    return { found: false, nodes: visitedNodes };
  }

  // Try both possible exits from the rear segment
  const a = walkPath(lA, lB, fA, fB);
  const b = walkPath(lB, lA, fA, fB);

  const path = a.found ? a : (b.found ? b : null);
  if (!path) {
    debugLog(`[occ-span] ${c.id}: could not route from ${lA}/${lB} to ${fA}/${fB}`);
    return false;
  }

  // If the routed chain includes nodeId anywhere (including interior nodes
  // and the front/back endpoints), then the consist topologically spans it.
  const result = path.nodes.has(nodeId);
  if(debug){
   const pathNodes = Array.from(path.nodes);
     debugLog(`[occupied-nodes] ${c.id}: ${pathNodes.join(" → ")}`);
  }
  if (result) {
    debugLog(`[occ-span] ${c.id}: ${nodeId} lies on routed chain from rear to front`);
  } else {
    debugLog(`[occ-span] ${c.id}: ${nodeId} not on routed chain`);
  }
  return result;
}
  */

function getOutwardEnd(car, neighbor, label) {
  if (!car?.leadPath || car.leadT == null) return null;
  let result = {};

  const EXT = car.length / 2 + GAP / 2;

  const fwd = travelAcross(car.leadPath.slice(), car.leadT, EXT, +1);
  const back = travelAcross(car.leadPath.slice(), car.leadT, EXT, -1);

  const fwdPos = pointOnSegStrict(fwd.seg[0], fwd.seg[1], fwd.t);
  const backPos = pointOnSegStrict(back.seg[0], back.seg[1], back.t);

  let outward = fwd,
    inward = back;

  if (
    neighbor &&
    Number.isFinite(neighbor.px) &&
    Number.isFinite(neighbor.py)
  ) {
    const dFwd = Math.hypot(fwdPos.px - neighbor.px, fwdPos.py - neighbor.py);
    const dBack = Math.hypot(
      backPos.px - neighbor.px,
      backPos.py - neighbor.py
    );
    if (dBack > dFwd) [outward, inward] = [back, fwd];
    debugLog(
      `[consistRealEndSegments] ${car.id} ${label}: ` +
        `dFwd=${dFwd.toFixed(2)} dBack=${dBack.toFixed(
          2
        )} → outward=${outward.seg.join("-")}`
    );
  } else {
    debugLog(
      `[consistRealEndSegments] ${car.id} ${label}: single vehicle (no neighbor)`
    );
  }

  debugLog(
    `   inward=${inward.seg.join("-")} t=${inward.t.toFixed(
      3
    )}  outward=${outward.seg.join("-")} t=${outward.t.toFixed(3)}`
  );
  debugLog("returning early something consistrealend");
  return { outward, inward };
}

function consistRealEndSegments(c) {
  if (!c?.vehicles?.length) {
    debugLog("no cars?");
    return null;
  }

  const n = c.vehicles.length;
  const first = c.vehicles[0];
  const last = c.vehicles[n - 1];
  const second = n > 1 ? c.vehicles[1] : null;
  const penult = n > 1 ? c.vehicles[n - 2] : null;

  const frontEnds = getOutwardEnd(first, second, "front");
  const rearEnds =
    last !== first ? getOutwardEnd(last, penult, "rear") : frontEnds;

  if (!frontEnds || !rearEnds) {
    console.error("[consistRealEndSegments] returning early end missing!");
    return null;

    let result = {};
  }

  if (n == 1) {
    result = {
      frontSeg: frontEnds.outward.seg,
      rearSeg: frontEnds.inward.seg,
    };
  } else {
    result = {
      frontSeg: frontEnds.outward.seg,
      rearSeg: rearEnds.outward.seg,
    };
  }

  debugLog(
    `[consistRealEndSegments] ${c.id} result:` +
      `\n  FRONT: seg=${result.frontSeg.join("-")} ` +
      `\n  REAR:  seg=${result.rearSeg.join("-")} `
  );

  return result;
}

function isConsistOverNode(c, nodeId) {
  if (!c?.vehicles?.length) return false;

  const endSegs = consistRealEndSegments(c);
  const [fA, fB] = endSegs.frontSeg;
  const [lA, lB] = endSegs.rearSeg;
  // If the whole consist lies on a single edge, it does NOT span any node
  if (edgeKey(fA, fB) === edgeKey(lA, lB)) {
    debugLog(
      `[occ-span] ${c.id}: all vehicles on ${fA}-${fB}, not spanning nodes`
    );
    return false;
  }

  // Walk along the actual routed path from the rear segment toward the front,
  // using buildLeg(at, from). We try both possible rear directions and accept
  // the first that reaches either front endpoint.

  function walkPath(start, prev, goalA, goalB) {
    // set of nodes we pass through (includes start)
    const visitedNodes = new Set([start]);
    // to prevent cycles, track directed edges (prev->curr)
    const seenDirected = new Set([`${prev}->${start}`]);

    let cur = start;
    let from = prev;

    for (let steps = 0; steps < MAX_STEPS; steps++) {
      // If we've arrived at either of the front segment nodes, success
      if (cur === goalA || cur === goalB) {
        return { found: true, nodes: visitedNodes };
      }

      const leg = buildLeg(cur, from);
      if (!leg || leg.length !== 2) break;

      // orient the leg so that leg[0] === cur
      const nxt = leg[0] === cur ? leg[1] : leg[0];

      // guard against oscillation / loops
      const sig = `${cur}->${nxt}`;
      if (seenDirected.has(sig)) break;
      seenDirected.add(sig);

      from = cur;
      cur = nxt;
      visitedNodes.add(cur);
    }
    return { found: false, nodes: visitedNodes };
  }

  // Try both possible exits from the rear segment
  const a = walkPath(lA, lB, fA, fB);
  const b = walkPath(lB, lA, fA, fB);

  const path = a.found ? a : b.found ? b : null;
  if (!path) {
    debugLog(
      `[occ-span] ${c.id}: could not route from ${lA}/${lB} to ${fA}/${fB}`
    );
    return false;
  }

  // If the routed chain includes nodeId anywhere (including interior nodes
  // and the front/back endpoints), then the consist topologically spans it.
  const result = path.nodes.has(nodeId);
  if (debug) {
    const pathNodes = Array.from(path.nodes);
    debugLog(`[occupied-nodes] ${c.id}: ${pathNodes.join(" → ")}`);
  }
  if (result) {
    debugLog(
      `[occ-span] ${c.id}: ${nodeId} lies on routed chain from rear to front`
    );
  } else {
    debugLog(`[occ-span] ${c.id}: ${nodeId} not on routed chain`);
  }
  return result;
}

function carEndsWorld(v) {
  ensureTrailHas(v);

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

  return { rear, front };
}

function drawCompass() {
  const scale = Math.min(COMPASS_SCALE, window.devicePixelRatio || 1);
  const size = COMPASS_DIAMETER * scale; // 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)
  let w = 6 * scale;
  ctx.beginPath();
  ctx.moveTo(0, size / 2 - 12);
  ctx.lineTo(w, 0);
  ctx.lineTo(-w, 0);
  ctx.closePath();
  ctx.fillStyle = "grey";
  ctx.fill();

  // north arrow (rotates with worldRotation)
  ctx.beginPath();
  ctx.moveTo(0, -size / 2 + 12);
  ctx.lineTo(w, 0);
  ctx.lineTo(-w, 0);
  ctx.closePath();
  ctx.fillStyle = "red";
  ctx.fill();
  let fontsize = 10 * scale;
  ctx.font = `${fontsize}px 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 = COMPASS_DIAMETER;
    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("24");
}

/*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 * Math.min(COMPASS_SCALE, window.devicePixelRatio || 1);
  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);
}

// ============================================================================
// 12. TASK & SCENARIO MANAGEMENT
// ============================================================================
function loadOption(option) {
  // --- Highlight the active option button ---
  const optionButtons = document.querySelectorAll("#optionList button");
  optionButtons.forEach((b) => b.classList.remove("active"));

  const activeBtn = Array.from(optionButtons).find(
    (b) => b.textContent === option.title
  );
  if (activeBtn) activeBtn.classList.add("active");

  for (const c of consists) {
    if (c.moving) stopConsist(c);
  }
  if (!trackLayout?.tracks) return;

  // --- 1) Restore any previously removed signals ---
  for (const id in trackLayout.tracks) {
    const node = trackLayout.tracks[id];
    if (node.removed) {
      node.type = node.removed; // restore original type
      delete node.removed;
    }
  }

  // --- 2) Apply removals for this option ---
  const removes = option.removesignals || [];
  for (const sid of removes) {
    const node = trackLayout.tracks[sid];
    if (!node) continue;

    if (node.type === "signal" || node.type === "pointsignal") {
      node.removed = node.type; // remember what it was
      node.type = "pass"; // demote to plain track node
      delete signalStates[sid]; // not a live signal anymore
    }
  }

  // --- 3) Rebuild signal state map for remaining signals ---
  initSignalStates();
  updateNodeOccupancyFromConsists();

  // --- 4) Redraw and update everything ---
  drawNetwork("option-change");
}

function loadTask(task) {
  const taskButtons = document.querySelectorAll("#taskList button");
  taskButtons.forEach((b) => b.classList.remove("active"));

  const activeBtn = Array.from(taskButtons).find(
    (b) => b.textContent === task.title
  );
  if (activeBtn) activeBtn.classList.add("active");

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

  // Display task info
  if (task) {
    document.getElementById("taskTitle").innerHTML = sanitizeHTML(task.title);
    document.getElementById("taskDesc").innerHTML = sanitizeHTML(
      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("25");
}

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");
  if (!taskList) return;

  // Remove only task buttons; keep any other content in #taskList
  taskList.querySelectorAll("button").forEach((btn) => btn.remove());

  if (!Array.isArray(rollingstock?.tasks)) {
    console.warn("no tasks in rollingstock");
    return;
  }

  const frag = document.createDocumentFragment();
  rollingstock.tasks.forEach((task) => {
    const btn = document.createElement("button");
    btn.className = "task-btn"; // optional: tag them for styling
    btn.textContent = task.title;
    btn.onclick = () => loadTask(task);
    frag.appendChild(btn);
  });
  taskList.appendChild(frag);
}

function buildOptionList() {
  const optionList = document.getElementById("optionList");
  // Remove only task buttons; keep any other content in #taskList
  optionList.querySelectorAll("button").forEach((btn) => btn.remove());

  if (trackLayout.options) {
    trackLayout.options.forEach((option, i) => {
      const btn = document.createElement("button");
      btn.textContent = option.title;

      btn.onclick = () => loadOption(option);
      optionList.appendChild(btn);
    });
  } else {
    console.warn("no options in layout");
  }
}

function cancelAllRafs() {
  for (const id in rafIds) {
    if (rafIds[id]) {
      cancelAnimationFrame(rafIds[id]);
      delete 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) {
  if (!consist.moving) return;

  for (const sid in signalStates) {
    const node = trackLayout.tracks[sid];
    if (!node) continue;

    // Normal signal logic
    if (node.type === "signal") {
      if (node.occupiedBy) continue; // skip if locked

      if (signalStates[sid] === false) {
        // GO (green) in your scheme
        const occ = isNodeOccupied(sid, consist);
        if (occ) {
          node.occupiedBy = consist.id;
        } else {
          node.occupiedBy = null;
        }
      }
    }

    // Point signal logic
    else if (node.type === "pointsignal") {
      if (node.occupiedBy) continue;
      const occ = isNodeOccupied(sid, consist);
      if (!occ) {
        //if(sid=='S10')debugLog("train not over  poitnsignal trigger ",sid );
        continue;
      }

      // Only relevant if train would actually meet the point via this leg
      const approaching = isApproachingPoint(
        consist,
        node.point,
        node.pointleg
      );
      if (!approaching) {
        continue;
      } else {
        node.occupiedBy = consist.id;
        if (node.triggernode) {
          trackLayout.tracks[node.triggernode].occupiedBy = consist.id;
        }
      }
    }
  }
}

function checkSignalClear(consist) {
  //  if(consist.moving) ???? JD
  for (const sid in signalStates) {
    const node = trackLayout.tracks[sid];
    if (!node) continue;

    if (node.type == "signal" || node.type == "pointsignal") {
      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) {
        debugLog(`Signal ${sid} cleared by consist ${consist.id}`);
        signalStates[sid] = true; // back to red
        node.occupiedBy = null; // release lock
        if (node.triggernode) {
          trackLayout.tracks[node.triggernode].occupiedBy = null;
        }
      }
    }
  }
}

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

  // The first connection is the always-live leg.
  // Remaining legs are selectable, choose based on state (0/1).
  const legs = node.connections.slice(1);

  if (legs.length === 2) {
    return legs[state];
  }

  return null;
}

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

  // The first connection is the always-live leg.
  // Remaining legs are selectable, choose based on state (0/1).
  const legs = node.connections.slice(1);
  const idx = legs.indexOf(legId);
  if (idx >= 0) {
    switchStates[id] = idx;
  }
}

function applyNodeStates(nodestates) {
  for (const entry of nodestates) {
    if (!trackLayout.tracks[entry.id]) {
      console.warn(
        "task setting node that doesn't exist in this layout " + entry.id
      );
      continue;
    }
    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 signalStates
      if (entry.signal === "STOP") signalStates[entry.id] = true;
      if (entry.signal === "GO") signalStates[entry.id] = false;
    }
  }

  drawNetwork("26");
}

// ---- one-time UI creator (singleton) ----
let _startSegUI = null;
function ensureStartSegmentsUI() {
  if (_startSegUI) return _startSegUI;

  const 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 72px 8px 8px",
    borderTop: "2px solid #444",
    zIndex: "2147483647",
  });
  document.body.appendChild(outDiv);

  // --- NEW: invisible spacer that reserves scroll space below content ---
  let spacer = document.getElementById("startsegments-spacer");
  if (!spacer) {
    spacer = document.createElement("div");
    spacer.id = "startsegments-spacer";
    Object.assign(spacer.style, {
      width: "100%",
      height: "0px",
      pointerEvents: "none",
      flexShrink: "0",
    });
    document.body.appendChild(spacer);
  }

  // Keep spacer equal to the panel height (+ a tiny gap)
  const updateBottomSpace = () => {
    const h = outDiv.getBoundingClientRect().height || 0;
    const px = Math.ceil(h + 8) + "px";
    spacer.style.height = px;
    // belts & braces in case the root is the scroll container:
    document.body.style.paddingBottom = px;
    document.documentElement.style.scrollPaddingBottom = px;
  };
  const ro = new ResizeObserver(updateBottomSpace);
  ro.observe(outDiv);
  window.addEventListener("resize", updateBottomSpace, { passive: true });
  updateBottomSpace();
  outDiv.__updateBottomSpace = updateBottomSpace;

  const textEl = document.createElement("pre");
  textEl.id = "startsegments-text";
  Object.assign(textEl.style, { margin: "0", whiteSpace: "pre" });
  outDiv.appendChild(textEl);

  const saveBtn = document.createElement("button");
  saveBtn.id = "save-task-btn";
  saveBtn.textContent = "Save task";
  Object.assign(saveBtn.style, {
    position: "absolute",
    top: "8px",
    right: "8px",
    padding: "6px 10px",
    fontSize: "12px",
    border: "1px solid #666",
    borderRadius: "6px",
    background: "#fff",
    color: "#111",
    cursor: "pointer",
  });
  saveBtn.addEventListener("click", () => {
    const text = textEl.textContent || "";
    const blob = new Blob([text], { type: "application/json;charset=utf-8" });
    const a = document.createElement("a");
    const slug = (document.getElementById("taskTitle")?.textContent || "task")
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-+|-+$/g, "");
    a.download = `${slug || "task"}.json`;
    a.href = URL.createObjectURL(blob);
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      URL.revokeObjectURL(a.href);
      a.remove();
    }, 0);
  });
  outDiv.appendChild(saveBtn);

  _startSegUI = { outDiv, textEl, saveBtn, spacer };
  return _startSegUI;
}

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

  // One-time UI (panel + <pre> + button)
  const { outDiv, textEl } = ensureStartSegmentsUI();

  // --- collect data ---
  const title = document.getElementById("taskTitle").textContent;
  const description = document.getElementById("taskDesc").textContent;
  const startsegments = [];
  const nodestates = [];

  // -------- trains → startsegments (front-of-lead semantics with canonical segments) --------
  for (const consist of consists) {
    if (!consist?.vehicles?.length) continue;

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

    // Current segment under the lead's CENTRE
    let [A, B] = lead.leadPath;
    let t = typeof lead.leadT === "number" ? lead.leadT : 0;

    // Canonicalise the segment and flip t if needed so that [cA,cB] matches canonical forward
    const key = [A, B].slice().sort().join("-");
    const cf = trackLayout.canonicalSegments?.[key]?.forward;
    let cA = A,
      cB = B,
      cT = t;
    if (cf && cf.length === 2) {
      if (cf[0] === A && cf[1] === B) {
        // already canonical
      } else if (cf[0] === B && cf[1] === A) {
        // centre stayed at same world point but parameter flips
        cA = B;
        cB = A;
        cT = 1 - cT;
      }
    }

    // Distance to FRONT of the lead measured from cA along cA→cB
    const leadLen = +lead.length || 0;
    let segLen = dist(cA, cB);
    let frontDist = cT * segLen + leadLen / 2;

    // If the front falls outside [0, segLen], walk across legs until it's inside
    // dir +1 means move out of B; dir -1 means move out of A.
    // We don't assume a consist direction here — we just place the FRONT point correctly.
    const MAX_HOPS = 200;
    let hops = 0;
    let curA = cA,
      curB = cB;

    // Walk backwards via A while frontDist < 0
    while (frontDist < 0 && hops++ < MAX_HOPS) {
      const prevLeg = buildLeg(curA, curB); // enter at curA, came-from curB → returns [curA, prevOther]
      if (!prevLeg || prevLeg.length !== 2) {
        // Can't go further back; clamp
        frontDist = 0;
        break;
      }
      const prevOther = prevLeg[0] === curA ? prevLeg[1] : prevLeg[0];
      // New segment is [prevOther, curA]
      curB = curA;
      curA = prevOther;
      segLen = dist(curA, curB);
      frontDist += segLen; // moved one segment back
    }

    // Walk forwards via B while frontDist > segLen
    hops = 0;
    while (frontDist > segLen && hops++ < MAX_HOPS) {
      frontDist -= segLen; // consume current leg
      const nextLeg = buildLeg(curB, curA); // enter at curB, came-from curA → returns [curB, nextOther]
      if (!nextLeg || nextLeg.length !== 2) {
        // Can't go further forward; clamp
        frontDist = segLen;
        break;
      }
      const nextOther = nextLeg[0] === curB ? nextLeg[1] : nextLeg[0];
      // New segment is [curB, nextOther]
      curA = curB;
      curB = nextOther;
      segLen = dist(curA, curB);
    }

    // Ensure exported segment string is in canonical forward orientation
    const key2 = [curA, curB].slice().sort().join("-");
    const cf2 = trackLayout.canonicalSegments?.[key2]?.forward;
    let expA = curA,
      expB = curB,
      expDist = frontDist;
    if (cf2 && cf2.length === 2) {
      if (cf2[0] === curA && cf2[1] === curB) {
        // ok
      } else if (cf2[0] === curB && cf2[1] === curA) {
        // flip the measure to remain from expA (cf2[0])
        expA = curB;
        expB = curA;
        expDist = segLen - frontDist;
      }
    }

    const distance = Math.round(100 * expDist) / 100;

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

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

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

  // --- build compressed JSON ---
  const exportObj = { title, description, startsegments, nodestates };
  const json =
    `{\n` +
    Object.entries(exportObj)
      .map(([key, val]) => `${pad(1)}"${key}": ${renderJsonValue(val, 1)}`)
      .join(",\n") +
    `\n}`;

  // Update text (not the container)
  textEl.textContent = json;

  // Keep bottom spacing in sync if helper provided
  if (typeof outDiv.__updateBottomSpace === "function") {
    outDiv.__updateBottomSpace();
  }
}

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

  // One-time UI (panel + <pre> + button)
  const { outDiv, textEl } = ensureStartSegmentsUI();

  // --- collect data ---
  const title = document.getElementById("taskTitle").textContent;
  const description = document.getElementById("taskDesc").textContent;
  const startsegments = [];
  const nodestates = [];

  // trains → startsegments
  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(100 * (t * segLen + lead.length / 2)) / 100;

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

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

  // signals → nodestates
  if (signalStates) {
    for (const [id, state] of Object.entries(signalStates)) {
      const sig = state ? "STOP" : "GO";
      nodestates.push({ id, signal: sig });
    }
  }

  // --- build compressed JSON ---
  const exportObj = { title, description, startsegments, nodestates };
  const json =
    `{\n` +
    Object.entries(exportObj)
      .map(([key, val]) => `${pad(1)}"${key}": ${renderJsonValue(val, 1)}`)
      .join(",\n") +
    `\n}`;

  // Update text (not the container)
  textEl.textContent = json;

  // Recompute page bottom spacing if helper is present
  if (typeof outDiv.__updateBottomSpace === "function") {
    outDiv.__updateBottomSpace();
  }
}
*/

const IND = "  ";
const pad = (n) => IND.repeat(n);

const isPrimitive = (x) =>
  x === null || (typeof x !== "object" && typeof x !== "function");

function renderJsonValue(v, level = 0, inline = false) {
  // Arrays
  if (Array.isArray(v)) {
    if (v.length === 0) return "[]";

    // Inline arrays of primitives: ["a", "b", "c"]
    if (v.every(isPrimitive)) {
      return `[${v.map((x) => JSON.stringify(x)).join(", ")}]`;
    }

    // Arrays with objects/arrays: each item on one line, inline object form
    const items = v.map(
      (item) => `${pad(level + 1)}${renderJsonValue(item, level + 1, true)}`
    );
    return `[\n${items.join(",\n")}\n${pad(level)}]`;
  }

  // Objects
  if (v && typeof v === "object") {
    const entries = Object.entries(v);
    if (entries.length === 0) return "{}";

    // Inline object form (used for elements inside arrays)
    if (inline) {
      return `{ ${entries
        .map(([k, val]) => `"${k}": ${renderJsonValue(val, level, true)}`)
        .join(", ")} }`;
    }

    // Multiline object (top-level / normal)
    const lines = entries.map(
      ([k, val]) =>
        `${pad(level + 1)}"${k}": ${renderJsonValue(val, level + 1)}`
    );
    return `{\n${lines.join(",\n")}\n${pad(level)}}`;
  }

  // Primitives
  return JSON.stringify(v);
}

function getPointLabels(pointId) {
  const g = (trackLayout.pointGroups || []).find((gr) => gr.includes(pointId));
  const members = g || [pointId];
  let labels = "";
  let comma = "";
  for (const pid of members) {
    labels += comma + getNodeLabel(pid);
    comma = ",";
  }

  return labels;
}

function getNodeLabel(id) {
  let str = id; //drawing node id when in 'drawnodes' mode is more useful for working out roollingsstock/layouts
  if (!showNNodes && typeof trackLayout.tracks[id].label === "string") {
    str = trackLayout.tracks[id].label;
  }
  return str;
}

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

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

  if (trackLayout.tracks[id].type === "pointsignal") {
    //drawpointlabel for leg
    const sig = trackLayout.tracks[id];
    const pointId = sig.point; // e.g. "P1"
    const legs = getActiveLegs(pointId);

    if (sig.leglabels) {
      if (!signalStates[id] && sig.leglabels && sig.leglabels[legs.active]) {
        //console.log("draw a "+sig.leglabels[legs.active]);
        drawTextOutlinedScreen(sig.leglabels[legs.active], x, y, {
          size: 18,
          align: "center",
          dy: 5,
          fill: "#fff",
          outline: "#000",
        });
        return;
      }
    }
  }

  const str = getNodeLabel(id);
  drawTextOutlinedScreen(str, x, y, {
    size: 11,
    align: "center",
    dy: -NODERADIUSPX + 10,
  });
}

function drawPointLegs(ctx, id, x, y) {
  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);

      // normalise 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();
    }
  }
}

function drawPointSignal(ctx, id) {
  const node = trackLayout.tracks[id];
  if (!node || !node.directionvector) return;

  const [x, y] = node.position;

  // parse directionvector "N2-P1"
  const [fromId, toId] = node.directionvector.split("-");
  const fromNode = trackLayout.tracks[fromId];
  const toNode = trackLayout.tracks[toId];
  if (!fromNode || !toNode) return;

  // direction vector from 'fromId' → 'toId'
  let dx = toNode.position[0] - fromNode.position[0];
  let dy = toNode.position[1] - fromNode.position[1];
  const len = Math.hypot(dx, dy);
  if (len === 0) return;
  dx /= len;
  dy /= len;

  // arrow sizes
  const totalLength = px(NODERADIUSPX * 2);
  const headLength = px(NODERADIUSPX * 0.8);
  const headWidth = px(NODERADIUSPX * 0.2);

  // draw arrow with black colour + white outline
  drawSignalPointArrow(
    ctx,
    x,
    y,
    dx,
    dy,
    totalLength,
    headLength,
    headWidth,
    "black"
  );

  // projection (for perpendicular offset direction)
  const ax = fromNode.position[0],
    ay = fromNode.position[1];
  const vx = x - ax,
    vy = y - ay;
  const s = vx * dx + vy * dy;
  const cx = ax + dx * s;
  const cy = ay + dy * s;

  let nx = x - cx,
    ny = y - cy;
  let nlen = Math.hypot(nx, ny);
  if (nlen === 0) {
    nx = -dy;
    ny = dx;
    nlen = 1;
  }
  nx /= nlen;
  ny /= nlen;

  // midpoint across the entire arrow (shaft + head)
  const tx = x + dx * totalLength;
  const ty = y + dy * totalLength;
  const mx = (x + tx) / 2;
  const my = (y + ty) / 2;

  // perpendicular offset for the signal disc
  const margin = px(5);
  const offset = px(NODERADIUSPX) + margin;
  const cx2 = mx + nx * offset;
  const cy2 = my + ny * offset;

  // draw the signal disc
  ctx.fillStyle = signalStates[id] ? STOP_COLOUR : GO_COLOUR;
  drawNode(ctx, id, cx2, cy2);

  // optional: trigger cross overlay
  const pt = getNodeTriggerPoint(id); // world coords {x,y}
  if (pt) {
    const size = 0.7 * (TRACKWIDTH / 2); // half the cross size
    ctx.beginPath();
    ctx.moveTo(pt.x - size, pt.y - size);
    ctx.lineTo(pt.x + size, pt.y + size);
    ctx.moveTo(pt.x - size, pt.y + size);
    ctx.lineTo(pt.x + size, pt.y - size);
    ctx.strokeStyle = "white";
    ctx.lineWidth = px(1);
    ctx.stroke();
  }
}

function getPointSignalCirclePosition(id) {
  const node = trackLayout.tracks[id];
  if (!node || node.type !== "pointsignal" || !node.directionvector)
    return null;

  const [x, y] = node.position;
  const [fromId, toId] = node.directionvector.split("-");
  const fromNode = trackLayout.tracks[fromId];
  const toNode = trackLayout.tracks[toId];
  if (!fromNode || !toNode) return null;

  // direction vector (from → to)
  let dx = toNode.position[0] - fromNode.position[0];
  let dy = toNode.position[1] - fromNode.position[1];
  const len = Math.hypot(dx, dy);
  if (len === 0) return null;
  dx /= len;
  dy /= len;

  // arrow shaft midpoint
  const totalLength = px(NODERADIUSPX * 2);
  const headLength = px(NODERADIUSPX * 0.8);
  const shaftLength = Math.max(totalLength - headLength, 0);
  const sx = x + dx * shaftLength;
  const sy = y + dy * shaftLength;
  const mx = (x + sx) / 2;
  const my = (y + sy) / 2;

  // track direction (same as above) → outward normal
  let tdx = dx,
    tdy = dy;
  const ax = fromNode.position[0],
    ay = fromNode.position[1];
  const vx = x - ax,
    vy = y - ay;
  const s = vx * tdx + vy * tdy;
  const cx = ax + tdx * s;
  const cy = ay + tdy * s;
  let nx = x - cx,
    ny = y - cy;
  let nlen = Math.hypot(nx, ny);
  if (nlen === 0) {
    nx = -tdy;
    ny = tdx;
    nlen = 1;
  }
  nx /= nlen;
  ny /= nlen;

  // final offset: radius + margin
  const margin = px(4);
  const offset = px(NODERADIUSPX) + margin;
  return [mx + nx * offset, my + ny * offset];
}

function isClickInPointSignal(id, mx, my) {
  const pos = getPointSignalCirclePosition(id);
  if (!pos) return false;

  const [cx, cy] = pos;
  return Math.hypot(mx - cx, my - cy) < px(NODERADIUSPX);
}
/*
function alignPointToLeg(pointId, legId) {
  const legs = getActiveLegs(pointId);
  if (!legs) return false;

  // If already aligned, nothing to do.
  if (legs.active === legId) return true;

  // If the requested leg is the "inactive" branch, flip once.
  if (legs.inactive === legId) {
    if (isNodeOccupied(pointId)) return false; // safety – shouldn't happen because we checked earlier
    switchStates[pointId] = (switchStates[pointId] + 1) % 2;
    trackLayout.tracks[pointId].occupiedBy = null;
    return true;
  }

  // If someone asked for the fixed side or an unknown leg, we can't "align".
  // (In your model, pointleg should be one of the two throw legs.)
  return false;
}
  */
function alignPointToLeg(pointId, legId) {
  const legs = getActiveLegs(pointId);
  if (!legs) return false;

  //simple case fo pointsginal on always active leg
  if (legId == legs.fixed) {
    return true;
  }

  // --- handle coacting group lookup ---
  const group = (trackLayout.pointGroups || []).find((g) =>
    g.includes(pointId)
  ) || [pointId];

  // --- safety: don't move if any point in the group is occupied ---
  for (const pid of group) {
    if (isNodeOccupied(pid)) return false;
  }

  // --- if already aligned, nothing to do ---
  if (legs.active === legId) return true;

  // --- if leg is the inactive one, flip all points in the group ---
  if (legs.inactive === legId) {
    for (const pid of group) {
      switchStates[pid] = (switchStates[pid] + 1) % 2;
      trackLayout.tracks[pid].occupiedBy = null;
    }
    return true;
  }

  // --- any other leg (fixed/invalid) can't be aligned ---
  return false;
}

function updatePointSignalsForPoint(id) {
  const g = (trackLayout.pointGroups || []).find((gr) => gr.includes(id));
  const members = g || [id];

  for (const pointId of members) {
    const legs = getActiveLegs(pointId);
    if (!legs) return;

    for (const sid in trackLayout.tracks) {
      const n = trackLayout.tracks[sid];
      if (n?.type === "pointsignal" && n.point === pointId) {
        if (n.pointleg !== legs.active) {
          // leg not active → STOP
          signalStates[sid] = true;
        }
        // if n.pointleg === legs.active:
        // do nothing → stays whatever it was (usually red until user clicks it)
      }
    }
  }
}
function drawSignalPointArrow(
  ctx,
  x,
  y,
  dx,
  dy,
  totalLength,
  headLength,
  headWidth,
  colour
) {
  // shaft length
  const shaftLength = Math.max(totalLength - headLength, 0);

  // shaft end (start of arrowhead)
  const sx = x + dx * shaftLength;
  const sy = y + dy * shaftLength;

  // tip
  const tx = x + dx * totalLength;
  const ty = y + dy * totalLength;

  // shaft with outline
  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.lineTo(sx, sy);
  ctx.lineWidth = 3 / worldScale; // outline width
  ctx.strokeStyle = "white"; // outline first
  ctx.stroke();

  // arrowhead (triangle points)
  const leftX = tx - dx * headLength + dy * headWidth;
  const leftY = ty - dy * headLength - dx * headWidth;
  const rightX = tx - dx * headLength - dy * headWidth;
  const rightY = ty - dy * headLength + dx * headWidth;

  // arrowhead with outline
  ctx.beginPath();
  ctx.moveTo(tx, ty);
  ctx.lineTo(leftX, leftY);
  ctx.lineTo(rightX, rightY);
  ctx.closePath();

  ctx.lineWidth = 3 / worldScale;
  ctx.strokeStyle = "white";
  ctx.stroke();

  ctx.fillStyle = colour;
  ctx.fill();

  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.lineTo(sx, sy);
  ctx.lineWidth = 2 / worldScale; // inner line
  ctx.strokeStyle = colour;
  ctx.stroke();
}

function cleanTrackLayout(layout) {
  const validTypes = new Set([
    "buffer",
    "signal",
    "pointsignal",
    "point",
    "pass",
  ]);
  const cleaned = { ...layout, tracks: {} };

  for (const id in layout.tracks) {
    const node = layout.tracks[id];
    if (node && validTypes.has(node.type)) {
      cleaned.tracks[id] = node;
    } else {
      console.warn(`Dropping node ${id} with unknown type: ${node?.type}`);
    }
  }

  return cleaned;
}

function getNodeTriggerPoint(nodeId) {
  const node = trackLayout.tracks[nodeId];
  if (!node) {
    console.error(`[getnodeTriggerPoint] node not found ${nodeId}`);
    return null;
  }

  // plain signals and normal nodes → just use their position
  if (!node.triggernode) {
    return { x: node.position[0], y: node.position[1] };
  }
  const trigger = trackLayout.tracks[node.triggernode];
  if (!trigger) {
    console.error(
      `[getnodeTriggerPoint] triggernode not found ${node.triggernode}`
    );
    return null;
  }
  return { x: trigger.position[0], y: trigger.position[1] };
}

function isConsistTryingToPassRedPointSignal(consist) {
  if (consist.moving) {
    for (const sid in trackLayout.tracks) {
      const node = trackLayout.tracks[sid];
      if (
        node.occupiedBy === consist.id &&
        node.type === "pointsignal" &&
        signalStates[sid]
      ) {
        if (isApproachingPoint(consist, node.point, node.pointleg)) {
          return { blocked: true, signalId: sid };
        }
      }
    }
  }
  return { blocked: false, signalId: null };
}

function isApproachingPoint(consist, pointId, legId, opts = {}) {
  const { maxSteps = 30, returnPath = false } = opts;
  if (!consist?.moving)
    return returnPath ? { approaching: false, path: [] } : false;

  if (!trackLayout.tracks[pointId] || !trackLayout.adj?.[legId]) {
    return returnPath ? { approaching: false, path: [] } : false;
  }

  // use the nose vehicle just to seed the edge
  const nose =
    consist.dir === 1
      ? consist.vehicles[consist.vehicles.length - 1]
      : consist.vehicles[0];
  if (!nose?.leadPath)
    return returnPath ? { approaching: false, path: [] } : false;

  // orient edge
  let [a, b] = nose.leadPath;
  let from = consist.dir === 1 ? a : b;
  let to = consist.dir === 1 ? b : a;

  const path = [from, to];

  for (let step = 0; step < maxSteps; step++) {
    // success if we’re entering the point via the protected leg
    if (to === pointId && from === legId) {
      return returnPath ? { approaching: true, path } : true;
    }

    // choose continuation
    let next = null;
    const node = trackLayout.tracks[to];
    if (node.type === "point") {
      const legs = getActiveLegs(to);
      if (legs) {
        if (legs.active && legs.active !== from) next = legs.active;
        else if (legs.fixed && legs.fixed !== from) next = legs.fixed;
      }
    } else {
      next = (trackLayout.adj[to] || []).find((n) => n !== from);
    }

    if (!next) break;

    from = to;
    to = next;
    path.push(to);
  }

  return returnPath ? { approaching: false, path } : false;
}

function computeDirFromLoco(consist) {
  if (!consist.liveLocoId) return 1; // default
  const loco = consist.vehicles.find((v) => v.id === consist.liveLocoId);
  if (!loco || !loco.leadPath || loco.leadPath.length < 2) return 1;

  const [a, b] = loco.leadPath;
  const nodeA = trackLayout.tracks[a];
  const nodeB = trackLayout.tracks[b];
  if (!nodeA || !nodeB) return 1;

  const dx = nodeB.position[0] - nodeA.position[0];
  const dy = nodeB.position[1] - nodeA.position[1];
  const pathAngle = Math.atan2(dy, dx);

  // Compare loco.heading to pathAngle
  const d = Math.abs(normaliseAngle(loco.heading - pathAngle));
  // If heading is within 90° of path → forwards
  return d < Math.PI / 2 ? 1 : -1;
}

function normaliseAngle(a) {
  while (a <= -Math.PI) a += 2 * Math.PI;
  while (a > Math.PI) a -= 2 * Math.PI;
  return a;
}

function addCarsToConsist(consist, newCars, end = "back", live, dir = 1) {
  if (!consist || !newCars?.length) return;

  const GAP_DIST = GAP || 1.0;

  // --- determine reference vehicle (anchor) ---
  const anchor =
    end === "front"
      ? consist.vehicles[0] // add to front
      : consist.vehicles[consist.vehicles.length - 1]; // add to back

  if (!anchor || anchor.px == null || anchor.py == null) {
    console.error("[addCarsToConsist] anchor has no valid position");
    return;
  }

  // --- calculate the direction vector of the anchor end ---
  let heading = anchor.heading;
  if (consist.vehicles.length === 1) {
    // single-car: direction depends on requested end & dir
    heading = consist.dir === 1 ? anchor.heading : anchor.heading + Math.PI;
    if (end === "back") heading += Math.PI; // add to back of facing dir
  } else {
    // multi-car: invert heading when adding to front
    if (end === "front") heading += Math.PI;
  }

  // --- convert heading to a normalised vector ---
  const dx = Math.cos(heading);
  const dy = Math.sin(heading);

  // --- determine placement start point (front/back edge) ---
  const anchorEdgeX = anchor.px + dx * (anchor.length / 2);
  const anchorEdgeY = anchor.py + dy * (anchor.length / 2);

  // --- place each new car sequentially away from the anchor ---
  let cursorX = anchorEdgeX;
  let cursorY = anchorEdgeY;
  let anchorIndex = 0;
  const placed = [];

  for (const car of newCars) {
    // move one gap + half car length further out
    cursorX += dx * (GAP_DIST + car.length / 2);
    cursorY += dy * (GAP_DIST + car.length / 2);

    // assign approximate world position
    car.px = cursorX;
    car.py = cursorY;
    car.heading = heading;
    car.leadPath = anchor.leadPath.slice();
    car.leadT = anchor.leadT;
    car.trail = anchor.trail ? anchor.trail.slice() : [];

    // move cursor for next car’s far edge
    cursorX += dx * (car.length / 2);
    cursorY += dy * (car.length / 2);

    placed.push(car);
  }

  // --- merge into consist list ---
  if (end === "front") {
    consist.vehicles = [...placed.reverse(), ...consist.vehicles];
    anchorIndex = consist.vehicles.length - 1; //make sure it's on the satic side
  } else {
    consist.vehicles = [...consist.vehicles, ...placed];
    anchorIndex = 0; //make sure it's on teh satic side
  }

  // --- recompute consist geometry but keep anchor fixed ---

  consist.liveLocoId = live;
  consist.dir = dir;
  consist.vehicles.forEach((x) => {
    if (x.type === "loco") {
      if (!live) {
        ///set it to first loco if no loco in current one;
        live = x.id;
        consist.liveLocoId = live;
      }
      x.neutral = x.id !== live;
    }
  });

  solveConsistPositions(consist, anchorIndex);

  updateNodeOccupancyFromConsists();
  consist.id = `T${nextConsistId++}`; //give it a new id (other id being canclled out of animaiton callback)

  debugLog(
    `[addCarsToConsist] added ${newCars.length} cars to ${end}`,
    placed.map((v) => v.id),
    `new is `,
    consist.vehicles.map((v) => v.id)
  );
}

function normaliseConsistDirections(consist, anchorIdx) {
  if (!consist || !consist.vehicles?.length) return;

  const desiredDir = consist.dir;
  //  const anchorIdx = getPivotIndex(consist);
  const anchor = consist.vehicles[anchorIdx];

  // Snapshot anchor world position before touching it
  const anchorPose = {
    px: anchor.px,
    py: anchor.py,
    heading: anchor.heading,
    leadPath: anchor.leadPath ? anchor.leadPath.slice() : null,
    leadT: anchor.leadT,
  };

  // normalise all vehicles except the anchor
  for (let i = 0; i < consist.vehicles.length; i++) {
    const v = consist.vehicles[i];
    if (!v.leadPath || v.leadPath.length < 2) continue;
    if (i === anchorIdx) continue;

    const [a, b] = v.leadPath;
    const nodeA = trackLayout.tracks[a];
    const nodeB = trackLayout.tracks[b];
    if (!nodeA || !nodeB) continue;

    const dx = nodeB.position[0] - nodeA.position[0];
    const dy = nodeB.position[1] - nodeA.position[1];
    const segAngle = Math.atan2(dy, dx);

    let diff = normaliseAngle(v.heading - segAngle);
    const facingForward = Math.abs(diff) < Math.PI / 2;
    const shouldFlip = desiredDir === 1 ? !facingForward : facingForward;

    if (shouldFlip) {
      v.leadPath = [b, a];
      v.leadT = 1 - v.leadT;
      v.heading = normaliseAngle(v.heading + Math.PI);
    }
  }

  // Re-anchor the consist using the saved world position
  solveConsistPositions(consist, anchorIdx);

  // Restore anchor’s physical placement exactly
  anchor.px = anchorPose.px;
  anchor.py = anchorPose.py;
  anchor.heading = anchorPose.heading;
  anchor.leadPath = anchorPose.leadPath;
  anchor.leadT = anchorPose.leadT;

  // Re-solve once more to re-chain other cars around the fixed anchor
  solveConsistPositions(consist, anchorIdx);
}

function liveLocoFlip(c) {
  let flip = 1;
  if (c.liveLocoId) {
    const live = c.vehicles.find(
      (v) => v.id === c.liveLocoId && v.type === "loco"
    );
    flip = live.flipgears;
    debugLog(live.flipgears);
  }
  return flip;
}

// ============================================================================
// 14. UTILITY FUNCTIONS
// ============================================================================
function debugLog(...args) {
  if (debug) {
    console.log(...args);
  }
}

function edgeKey(a, b) {
  return a < b ? `${a}-${b}` : `${b}-${a}`;
}

function occupiedEdgeSetForConsist(c) {
  const edges = new Set();
  const nodes = trackLayout.tracks;
  for (const v of c.vehicles) {
    const p = v.leadPath;
    if (p && p.length === 2 && nodes[p[0]] && nodes[p[1]]) {
      edges.add(edgeKey(p[0], p[1]));
    }
  }
  return edges;
}

function buildSegments(layout) {
  //  not actually used but if i eve want to finally fix layout jsons
  //  this does quite a good job of geenerating track segments from the nodes(trackes in teh json file)
  const nodes = layout.tracks;
  const adj = {};
  for (const id in nodes) {
    for (const c of nodes[id].connections || []) {
      (adj[id] ||= []).includes(c) || adj[id].push(c);
      (adj[c] ||= []).includes(id) || adj[c].push(id);
    }
  }

  const isTerminator = (id) => {
    const t = nodes[id]?.type;
    return t === "buffer" || t === "point";
  };
  const edgeKey = (a, b) => [a, b].sort().join("-");
  const visitedEdges = new Set();
  const segments = {};

  function xPos(id) {
    return nodes[id]?.position?.[0] ?? 0;
  }

  for (const start of Object.keys(nodes)) {
    if (!isTerminator(start)) continue;

    for (const next of adj[start] || []) {
      const firstEdge = edgeKey(start, next);
      if (visitedEdges.has(firstEdge)) continue;

      const path = [start];
      let prev = start;
      let curr = next;

      while (curr && !isTerminator(curr)) {
        path.push(curr);
        const out = (adj[curr] || []).filter((n) => n !== prev);
        if (out.length !== 1) break;
        prev = curr;
        curr = out[0];
      }

      if (curr && isTerminator(curr)) {
        path.push(curr);

        // Orient east → west
        const eastX = xPos(path[0]);
        const westX = xPos(path[path.length - 1]);
        if (westX > eastX) path.reverse();

        // Deterministic key east-west
        let key = path[0] + "-" + path[path.length - 1];
        if (segments[key]) {
          let n = 2;
          while (segments[key + "-alt" + n]) n++;
          key = key + "-alt" + n;
        }

        segments[key] = path;

        // mark visited edges
        for (let i = 0; i < path.length - 1; i++) {
          visitedEdges.add(edgeKey(path[i], path[i + 1]));
        }
      }
    }
  }
  console.log("[buidsegments]");
  console.log(segments);
  return segments;
}

function buildCoactingGroups(layout) {
  const groups = [];
  const visited = new Set();

  for (const id in layout.tracks) {
    const t = layout.tracks[id];
    if (t.type === "point" && Array.isArray(t.coacting) && t.coacting.length) {
      const group = new Set([id, ...t.coacting]);
      // merge with existing if overlapping
      let merged = false;
      for (const g of groups) {
        if ([...group].some((x) => g.has(x))) {
          for (const x of group) g.add(x);
          merged = true;
          break;
        }
      }
      if (!merged) groups.push(group);
    }
  }

  layout.pointGroups = groups.map((g) => Array.from(g));
}

function canOperatePoint(id) {
  // find which group (if any) this point belongs to
  const g = (trackLayout.pointGroups || []).find((gr) => gr.includes(id));
  const members = g || [id];

  for (const pid of members) {
    const node = trackLayout.tracks[pid];
    // Check each leg node or connected section for occupancy
    if (isNodeOccupied(pid)) return false;
  }
  return true;
}

/*-------------*/
/* task loader */
/*-------------*/
// --- Drag & Drop tasks onto the page (dedupe + replace-by-title) ---
(() => {
  if (window.__taskDropInit) return;
  window.__taskDropInit = true;

  const normTitle = (s) =>
    (s ?? "").toString().normalize("NFKC").replace(/\s+/g, " ").trim();

  // Last-wins in-place dedupe by normalized title
  function dedupeTasksInPlace() {
    if (!rollingstock || !Array.isArray(rollingstock.tasks)) return;
    const seen = new Set();
    const uniqueRev = [];
    for (let i = rollingstock.tasks.length - 1; i >= 0; i--) {
      const t = rollingstock.tasks[i];
      const key = normTitle(t && t.title);
      if (!key || seen.has(key)) continue;
      seen.add(key);
      uniqueRev.push(t); // keep the last occurrence
    }
    uniqueRev.reverse();
    rollingstock.tasks.length = 0;
    rollingstock.tasks.push(...uniqueRev);
  }

  const root = document.body;

  root.addEventListener("dragover", (e) => {
    e.preventDefault();
    if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
  });
  /*
  root.addEventListener("drop", async (e) => {
    e.preventDefault();
    const files = Array.from(e.dataTransfer?.files || []);
    for (const file of files) {
      if (!/\.json$/i.test(file.name)) continue;

      let text = "";
      try { text = await file.text(); }
      catch (err) { console.warn("[tasks-drop] read error:", err); continue; }

      let parsed;
      try { parsed = JSON.parse(text); }
      catch (err) { console.warn("[tasks-drop] JSON parse error:", err); continue; }

      // Normalize to array of tasks
      let incoming = [];
      if (Array.isArray(parsed)) incoming = parsed;
      else if (parsed && Array.isArray(parsed.tasks)) incoming = parsed.tasks;
      else if (parsed && parsed.title) incoming = [parsed];
      else { console.warn("[tasks-drop] no tasks found in", file.name); continue; }

      if (!rollingstock || !Array.isArray(rollingstock.tasks)) {
        console.warn("[tasks-drop] rollingstock not ready.");
        continue;
      }

      let added = 0, replaced = 0;

      for (const t of incoming) {
        if (!t || !t.title) { console.warn("[tasks-drop] skipped task without title:", t); continue; }
        const key = normTitle(t.title);
        const idx = rollingstock.tasks.findIndex(x => normTitle(x?.title) === key);
        if (idx >= 0) { rollingstock.tasks[idx] = t; replaced++; }
        else { rollingstock.tasks.push(t); added++; }
      }

      // Clean up any legacy duplicates
      dedupeTasksInPlace();

      if (typeof buildTaskList === "function") buildTaskList();

      // If exactly one incoming task, load it immediately
      if (incoming.length === 1 && typeof loadTask === "function") {
        const key = normTitle(incoming[0].title);
        const chosen = rollingstock.tasks.find(x => normTitle(x.title) === key) || incoming[0];
        loadTask(chosen);
      }

      console.log(`[tasks-drop] "${file.name}" processed: added=${added}, replaced=${replaced}`);
    }
  });
  */
  /*----*/
  /*-- json loaders  tasks/layouts/rollingstock --*/

  root.addEventListener("drop", async (e) => {
    e.preventDefault();
    const files = Array.from(e.dataTransfer?.files || []);

    for (const file of files) {
      if (!/\.json$/i.test(file.name)) continue;

      let text = "";
      try {
        text = await file.text();
      } catch (err) {
        console.warn("[drop] read error:", err);
        continue;
      }

      let parsed;
      try {
        parsed = JSON.parse(text);
      } catch (err) {
        console.warn("[drop] JSON parse error:", err);
        continue;
      }

      const { kind, payload } = classifyDroppedJSON(parsed);

      try {
        if (kind === "layout") {
          console.log(`[drop] applying layout from ${file.name}`);
          await applyLayout(payload);
        } else if (kind === "rollingstock") {
          console.log(`[drop] applying rollingstock from ${file.name}`);
          applyRollingstock(payload);
        } else if (kind === "tasks") {
          console.log(`[drop] merging tasks from ${file.name}`);
          const res = mergeTasksIntoRollingstock(payload);
          console.log(
            `[drop] tasks added=${res.added}, replaced=${res.replaced}`
          );
        } else if (kind === "task") {
          console.log(`[drop] loading single task from ${file.name}`);
          mergeTasksIntoRollingstock(payload); // reuses merge+load-one logic
        } else {
          // Fallback to your previous “tasks-only” flow if totally unknown
          console.warn("[drop] unknown JSON shape; leaving as-is");
        }
      } catch (err) {
        console.error(`[drop] error applying ${kind}:`, err);
      }
    }
  });
})();

// ---------- Drop helpers ----------
function classifyDroppedJSON(obj) {
  // Normalize possible roots
  const root = obj?.rollingstock || obj;

  // Layout: must have a tracks object with positions + connections
  if (
    root &&
    root.tracks &&
    typeof root.tracks === "object" &&
    Object.keys(root.tracks).length
  ) {
    return { kind: "layout", payload: root };
  }

  // Rollingstock: has locomotives / carriages arrays (possibly nested under .rollingstock)
  if (Array.isArray(root?.locomotives) || Array.isArray(root?.carriages)) {
    return { kind: "rollingstock", payload: root };
  }

  // Explicit tasks array wrapper
  if (Array.isArray(obj?.tasks)) {
    return { kind: "tasks", payload: obj.tasks };
  }

  // Bare array of tasks
  if (
    Array.isArray(obj) &&
    obj.length &&
    obj.every((t) => t && (t.title || t.startsegments || t.nodestates))
  ) {
    return { kind: "tasks", payload: obj };
  }

  // Single task object
  if (obj && obj.title && (obj.startsegments || obj.nodestates)) {
    return { kind: "task", payload: obj };
  }

  return { kind: "unknown", payload: obj };
}

async function applyLayout(newLayout) {
  // Stop animations & clear consists
  for (const c of consists || [])
    try {
      stopConsist(c);
    } catch {}
  consists = [];
  nextConsistId = 1;

  trackLayout = newLayout;

  if (debug) buildSegments(trackLayout);
  normaliseConnections(trackLayout);
  buildCoactingGroups(trackLayout);

  initCompass();
  initSwitchStates();
  initSignalStates();

  // (Re)build UI that depends on layout
  buildOptionList();
  buildTaskList();

  // If there are options, respect current optionId
  if (trackLayout.options && trackLayout.options.length) {
    loadOption(trackLayout.options[optionId]);
  }

  // Try to reload the currently selected task; fall back to first
  const t = rollingstock.tasks[taskId] || rollingstock.tasks[0] || null;
  if (t) loadTask(t);

  resizeCanvasToCSS();
  updateWorldTransform({ fit: true });
  await initFeatures();

  buildControlsFromConsists();
  drawNetwork("drop-layout");
}

function applyRollingstock(root) {
  // Keep layout; swap the stock
  rollingstock = {
    locomotives: root.locomotives || [],
    carriages: root.carriages || [],
    tasks: root.tasks || rollingstock.tasks || [],
  };

  initStock();
  buildTaskList();

  // Try to keep current task index; fall back gracefully
  const t = rollingstock.tasks[taskId] || rollingstock.tasks[0] || null;
  if (t) loadTask(t);

  buildControlsFromConsists();
  drawNetwork("drop-rollingstock");
}

function mergeTasksIntoRollingstock(newTasks, { autoLoad = true } = {}) {
  // Ensure rollingstock + tasks array exists
  if (!rollingstock) rollingstock = {};
  if (!Array.isArray(rollingstock.tasks)) rollingstock.tasks = [];

  // Accept single task or array
  const arr = Array.isArray(newTasks) ? newTasks : [newTasks];

  // Safe normalizer: use existing normTitle if present, else a fallback
  const safeNorm = (s) => {
    if (typeof normTitle === "function") return normTitle(s);
    return String(s ?? "")
      .normalize("NFKD")
      .replace(/[\u0300-\u036f]/g, "")
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, " ")
      .trim()
      .replace(/\s+/g, " ");
  };

  let added = 0,
    replaced = 0;

  for (const t of arr) {
    if (!t || !t.title) {
      console.warn("[tasks-drop] skipped task without title:", t);
      continue;
    }
    const key = safeNorm(t.title);
    const idx = rollingstock.tasks.findIndex((x) => safeNorm(x?.title) === key);
    if (idx >= 0) {
      rollingstock.tasks[idx] = t;
      replaced++;
    } else {
      rollingstock.tasks.push(t);
      added++;
    }
  }

  // Dedupe (prefer built-in; otherwise fallback)
  if (typeof dedupeTasksInPlace === "function") {
    dedupeTasksInPlace();
  } else {
    const seen = new Set();
    rollingstock.tasks = rollingstock.tasks.filter((t) => {
      const k = safeNorm(t?.title);
      if (!k || seen.has(k)) return false;
      seen.add(k);
      return true;
    });
  }

  // Rebuild the UI list
  if (typeof buildTaskList === "function") buildTaskList();

  // If exactly one incoming task, load it
  if (autoLoad && arr.length === 1 && typeof loadTask === "function") {
    const k = safeNorm(arr[0].title);
    const chosen =
      rollingstock.tasks.find((x) => safeNorm(x.title) === k) || arr[0];
    console.log(`[drop] loading single task from merge: "${arr[0].title}"`);
    loadTask(chosen);
  }

  return { added, replaced };
}

/*-----------*/
// Tooltip state (click sets sticky=true; hover can set sticky=false with a TTL if you want)
let tooltipVehicleId = null;
let tooltipSticky = false;
let tooltipHideAt = 0; // ms timestamp; ignored when sticky

// Internal hover timers/state
let _hoverTimer = null;
let _hoverPos = { x: 0, y: 0 };
let _hoverLastMoveTime = 0;

// Tunables (constants moved to Configuration section)

// ============================================================================
// 13. TOOLTIP & HOVER SYSTEM
// ============================================================================
function showVehicleTooltipById(id, { sticky = false, ttl = 2000 } = {}) {
  tooltipVehicleId = id || null;
  tooltipSticky = !!sticky;
  tooltipHideAt = sticky || !id ? 0 : performance.now() + ttl;
}

function hideVehicleTooltip() {
  tooltipVehicleId = null;
  tooltipSticky = false;
  tooltipHideAt = 0;
  drawNetwork();
}

function maybeExpireTooltip(now = performance.now()) {
  if (
    !tooltipSticky &&
    tooltipVehicleId &&
    tooltipHideAt &&
    now > tooltipHideAt
  ) {
    tooltipVehicleId = null;
    tooltipHideAt = 0;
  }
}

// Draw a sticky/hover vehicle label very near the body, in WORLD coords
function drawVehicleTooltipHUD(ctx) {
  // expects globals you already have: tooltipVehicleId, tooltipSticky, canvas, worldScale, trackLayout

  maybeExpireTooltip();
  if (!tooltipVehicleId) return;
  const v = getVehicleById(tooltipVehicleId);
  if (!v || v.px == null || v.py == null) return;

  const text = v.name || v.id || "";

  // --- choose an angle to orient offsets ---
  // Prefer v.heading; otherwise derive from the segment under the centre.
  let ang = typeof v.heading === "number" ? v.heading : 0;
  if (!v.heading && v.leadPath && v.leadPath.length === 2) {
    const [A, B] = v.leadPath;
    const a = trackLayout.tracks[A]?.position,
      b = trackLayout.tracks[B]?.position;
    if (a && b) {
      ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
      // If the centre sits closer to A than B, flip so "forward" is roughly from centre → front.
      if (typeof v.leadT === "number" && v.leadT < 0.5) ang += Math.PI;
    }
  }

  // Screen-space heuristics to pick which side to place the label
  const scr = worldToScreen(v.px, v.py);
  const vw = canvas.clientWidth,
    vh = canvas.clientHeight;
  let side = 1; // +1 = place on left-hand normal, -1 = right-hand normal
  if (scr.x > vw * 0.66) side = -1; // too far right → place left
  else if (scr.x < vw * 0.34) side = 1; // far left → place right

  // Normal to the heading (perp), choose side to keep on-screen
  const nx = -Math.sin(ang) * side;
  const ny = Math.cos(ang) * side;

  // Tiny forward offset so the label is just ahead of the body
  const fx = Math.cos(ang);
  const fy = Math.sin(ang);

  // Offsets in *screen px* converted to world units via your px()
  const away = px(16); // ~16px away from the vehicle body
  const forward = px(6); // ~6px along heading

  const lx = v.px + nx * away + fx * forward;
  const ly = v.py + ny * away + fy * forward;

  drawTextOutlinedScreen(text, v.px, v.py, {
    fill: "red",
    outline: "yellow",

    size: 14,
    align: "center",
    dy: -2 * worldScale,
  });
}

function attachVehicleTooltipHandlers(el, vehicleId) {
  if (!el || !vehicleId) return;

  // mark for CSS / delegated behavior if you add it later
  el.classList.add("popupame");
  el.dataset.vehicleId = vehicleId;

  // click → toggle sticky tooltip at that vehicle
  el.addEventListener("click", (e) => {
    e.stopPropagation();

    const same =
      typeof tooltipVehicleId !== "undefined" && tooltipVehicleId === vehicleId;

    if (same) {
      hideVehicleTooltip();
    } else {
      showVehicleTooltipById(vehicleId, { sticky: false });
    }
    // ensure the label is drawn this frame (if you’re not animating)
    drawNetwork("tooltip-click");
  });
}

// Helper: find the topmost vehicle under the mouse using pointInVehicle
function findVehicleUnderMouse(xScr, yScr) {
  // If your pointInVehicle expects WORLD coords, uncomment this block:

  const w = screenToWorld(xScr, yScr);
  xScr = w.x;
  yScr = w.y;

  let best = null;
  let bestDist = Infinity;

  for (const c of consists) {
    if (!c?.vehicles) continue;
    for (const v of c.vehicles) {
      // quick reject: needs a position
      if (v.px == null || v.py == null) continue;

      if (pointInVehicle(xScr, yScr, v)) {
        // prefer the one whose centre is closest to the pointer
        const s = worldToScreen(v.px, v.py);
        const d = Math.hypot(s.x - xScr, s.y - yScr);
        if (d < bestDist) {
          best = v;
          bestDist = d;
        }
      }
    }
  }
  return best;
}

// Init once
(function initVehicleHoverOnce() {
  if (!canvas || window.__vehicleHoverInit) return;
  window.__vehicleHoverInit = true;

  function evaluateHover() {
    if (tooltipSticky) return; // don't override sticky
    const now = performance.now();
    if (now - _hoverLastMoveTime < HOVER_DELAY_MS - 16) {
      _hoverTimer = setTimeout(evaluateHover, HOVER_DELAY_MS);
      return;
    }

    const v = findVehicleUnderMouse(_hoverPos.x, _hoverPos.y);
    tooltipVehicleId = v ? v.id : null;
    drawNetwork("hover");
  }

  canvas.addEventListener("mousemove", (e) => {
    const r = canvas.getBoundingClientRect();
    const x = e.clientX - r.left;
    const y = e.clientY - r.top;

    if (Math.hypot(x - _hoverPos.x, y - _hoverPos.y) > HOVER_SLOP_PX) {
      _hoverPos.x = x;
      _hoverPos.y = y;
      _hoverLastMoveTime = performance.now();

      if (!tooltipSticky && tooltipVehicleId) {
        //tooltipVehicleId = null;
        //drawNetwork("hover-move");
      }
      if (_hoverTimer) clearTimeout(_hoverTimer);
      _hoverTimer = setTimeout(evaluateHover, HOVER_DELAY_MS);
    }
  });

  canvas.addEventListener("mouseleave", () => {
    if (_hoverTimer) {
      clearTimeout(_hoverTimer);
      _hoverTimer = null;
    }
    if (!tooltipSticky && tooltipVehicleId) {
      tooltipVehicleId = null;
      drawNetwork("hover-leave");
    }
  });

  // Optional: click toggles sticky nearest-under-mouse using pointInVehicle
  /*canvas.addEventListener("click", (e) => {
    const r = canvas.getBoundingClientRect();
    const x = e.clientX - r.left;
    const y = e.clientY - r.top;

    const v = findVehicleUnderMouse(x, y);
    if (v) {
      tooltipVehicleId = v.id;
      tooltipSticky = false;
    } else {
      tooltipSticky = false;
      tooltipVehicleId = null;
    }
    drawNetwork("hover-click");
  });*/

  // Optional: Esc clears sticky tooltip
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && tooltipSticky) {
      tooltipSticky = false;
      tooltipVehicleId = null;
      drawNetwork("hover-esc");
    }
  });
})();

// Minimal, strict allowlist sanitizer without external libs
function sanitizeHTML(
  unsafeHtml,
  {
    allowedTags = [
      "b",
      "i",
      "em",
      "strong",
      "u",
      "br",
      "p",
      "ul",
      "ol",
      "li",
      "a",
      "span",
      "h3",
      "h4",
    ],
    allowedAttrs = {
      a: ["href", "title", "target", "rel"],
      span: [],
      p: [],
      b: [],
      i: [],
      em: [],
      strong: [],
      u: [],
      br: [],
      ul: [],
      ol: [],
      li: [],
      h3: [],
      h4: [],
    },
    allowedProtocols = ["http:", "https:", "mailto:", "tel:"],
  } = {}
) {
  if (!unsafeHtml) return "";

  const t = document.createElement("template");
  t.innerHTML = unsafeHtml;

  const isAllowedTag = (tag) => allowedTags.includes(tag);

  const sanitizeAttr = (el, attr) => {
    // drop event handlers and style outright
    if (/^on/i.test(attr.name) || attr.name === "style") {
      el.removeAttribute(attr.name);
      return;
    }
    const tag = el.tagName.toLowerCase();
    const tagAllow = allowedAttrs[tag] || [];
    if (!tagAllow.includes(attr.name)) {
      el.removeAttribute(attr.name);
      return;
    }
    // URLs: only allow safe protocols
    if (
      (attr.name === "href" || attr.name === "src") &&
      el.getAttribute(attr.name)
    ) {
      const v = el.getAttribute(attr.name).trim();
      // data:, javascript:, vbscript: blocked; allow relative URLs and allowedProtocols
      let ok = false;
      try {
        const u = new URL(v, document.baseURI);
        ok = allowedProtocols.includes(u.protocol);
      } catch {
        // Not a valid absolute URL: treat as relative → allow
        ok = !/^\s*(?:javascript|data|vbscript):/i.test(v);
      }
      if (!ok) el.removeAttribute(attr.name);
    }
    // target=_blank → enforce rel
    if (
      tag === "a" &&
      attr.name === "target" &&
      el.getAttribute("target") === "_blank"
    ) {
      const rel = (el.getAttribute("rel") || "").toLowerCase();
      if (!/\bnoopener\b/.test(rel) || !/\bnoreferrer\b/.test(rel)) {
        el.setAttribute("rel", "noopener noreferrer");
      }
    }
  };

  const walk = (node) => {
    // Remove comment nodes
    if (node.nodeType === Node.COMMENT_NODE) {
      node.parentNode && node.parentNode.removeChild(node);
      return;
    }
    if (node.nodeType === Node.ELEMENT_NODE) {
      const tag = node.tagName.toLowerCase();

      if (!isAllowedTag(tag)) {
        // Replace disallowed element with its text content (or children)
        // If it has children, lift them up; otherwise remove it
        const parent = node.parentNode;
        if (!parent) return;
        if (node.childNodes.length) {
          while (node.firstChild) parent.insertBefore(node.firstChild, node);
        }
        parent.removeChild(node);
        return; // children already re-walked via insertion order
      }

      // Sanitize attributes
      // Copy list first because we may remove during iteration
      Array.from(node.attributes).forEach((attr) => sanitizeAttr(node, attr));
    }
    // Recurse safely (children may change)
    let child = node.firstChild;
    while (child) {
      const next = child.nextSibling;
      walk(child);
      child = next;
    }
  };

  walk(t.content);
  return t.innerHTML;
}
