master
AdityaAngajala Publish 0.1.0 da40167 3mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830
const SVG_NS = "http://www.w3.org/2000/svg";

// Geometry constants measured from the reference artwork (adom.inc hero):
// a 114 x 57 cell routing grid with a 150/19 ~= 7.895 unit pitch, 1.58 unit
// traces, and 3.34 / 1.76 annular vias sitting exactly on grid nodes.
const DEFAULTS = Object.freeze({
  seed: "traces",
  tileWidth: 900,
  tileHeight: 450,
  pitch: 150 / 19,
  scale: 1000 / 900,
  density: 1.05,
  traceColor: "#151b25",
  traceWidth: 1.58,
  viaOuterRadius: 3.34,
  viaInnerRadius: 1.76,
  // --- generation tuning -----------------------------------------------
  formations: 96, // terminal groups (via ladders/rows) per tile
  busWidth: 1, // scales pins per formation => bundle width
  turnPenalty: 0.55, // higher = longer, calmer runs
  weave: 0.22, // preference for 2-lane spacing between neighbors
  cramp: 0.4, // aversion to 1-lane spacing outside bundles
  horizontalBias: 0.6, // 0.5 = isotropic, higher = more horizontal flow
  netReach: 42, // max cells a net may span
  jogBias: 5, // preferred endpoint misalignment => staircase jogs
  stubChance: 0, // leftover pins that get short escape stubs
  inlineViaChance: 0.03, // per-step chance of a mid-trace via
  combs: 8, // max tap-comb motifs per tile
  signalColors: [
    ["#00e6dc", 0.6],
    ["#64abff", 0.25],
    ["#c5b3ff", 0.15],
  ],
  signals: true,
  interactive: true,
  signalInterval: 240, // passive spawn cadence — a fresh signal appears near the cursor this often
  maxSignals: 12, // fill target for the ambient fountain (maxBurstSignals is the hard ceiling)
  spawnRadius: 110, // spawn scatter around the pointer (soft jitter, not a hard cutoff)
  signalSpeed: 420,
  signalLength: 120,
  burstBoost: 7, // click: how many extra signals join at once
  hopSurvival: 0.4, // per-hop survival for PASSIVE signals; low = short-lived, high = long chains
  burstSurvival: 0.72, // per-hop survival for CLICKED signals — tuned independently of hopSurvival
  hopRadius: 440, // a surviving signal hops to a via within this range
  colorDrift: 0.18, // chance per hop that a chain crossfades to a new color
  maxBurstSignals: 25,
});

// 8 directions, clockwise from east; even indices are cardinal.
const DIR8 = [
  [1, 0],
  [1, 1],
  [0, 1],
  [-1, 1],
  [-1, 0],
  [-1, -1],
  [0, -1],
  [1, -1],
];

// Walker start-direction weights derived from horizontalBias (0.5 = even).
function startDirectionWeights(horizontalBias) {
  const ew = 0.48 * horizontalBias;
  const ns = 0.24 * (1 - horizontalBias);
  const diagonal = Math.max(0.005, (1 - 2 * ew - 2 * ns) / 4);
  return [
    [0, ew],
    [4, ew],
    [2, ns],
    [6, ns],
    [1, diagonal],
    [3, diagonal],
    [5, diagonal],
    [7, diagonal],
  ];
}

let instanceCount = 0;

function svgEl(name, attrs = {}) {
  const node = document.createElementNS(SVG_NS, name);
  for (const [key, value] of Object.entries(attrs)) {
    node.setAttribute(key, String(value));
  }
  return node;
}

function hashSeed(input) {
  let hash = 2166136261;
  for (const char of String(input)) {
    hash ^= char.charCodeAt(0);
    hash = Math.imul(hash, 16777619);
  }
  return hash >>> 0;
}

function mulberry32(seed) {
  return function random() {
    let value = (seed += 0x6d2b79f5);
    value = Math.imul(value ^ (value >>> 15), value | 1);
    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
  };
}

function weightedChoice(random, entries) {
  let roll = random();
  for (const [value, weight] of entries) {
    roll -= weight;
    if (roll <= 0) return value;
  }
  return entries[0][0];
}

// --- routing engine ---------------------------------------------------------
//
// Occupancy lives on a doubled ("half step") grid wrapped on a torus: every
// cell step claims its end node and the midpoint it crosses, which blocks
// same-lane overlap and any crossing (two diagonals share a midpoint, a
// diagonal crosses a cardinal only through a node). The torus wrap is what
// makes the finished tile seamless.

function createRouter(random, columns, rows) {
  const wide = columns * 2;
  const tall = rows * 2;
  // Occupancy as a flat typed array — this sits in the A* hot path.
  const occupied = new Uint8Array(wide * tall);
  const viaNodes = new Uint8Array(wide * tall);

  const halfKey = (hx, hy) => (((hy % tall) + tall) % tall) * wide + (((hx % wide) + wide) % wide);
  const nodeKey = (x, y) => halfKey(x * 2, y * 2);

  function claim(trace, key) {
    occupied[key] = 1;
    trace.claims.push(key);
    trace.ownKeys.add(key);
  }

  return {
    columns,
    rows,
    nodeKey,
    nodeFree: (x, y) => !occupied[nodeKey(x, y)],
    crowding(x, y) {
      let count = 0;
      for (let dy = -4; dy <= 4; dy += 2) {
        for (let dx = -4; dx <= 4; dx += 2) {
          if (occupied[nodeKey(x + dx, y + dy)]) count += 1;
        }
      }
      return count;
    },
    stepFree(x, y, direction) {
      const [dx, dy] = DIR8[direction];
      return (
        !occupied[halfKey(x * 2 + dx, y * 2 + dy)] &&
        !occupied[halfKey(x * 2 + dx * 2, y * 2 + dy * 2)]
      );
    },
    // Like stepFree, but the destination node may be the (already-claimed)
    // goal pin the route is allowed to land on.
    routeStepFree(x, y, direction, goalKey) {
      const [dx, dy] = DIR8[direction];
      if (occupied[halfKey(x * 2 + dx, y * 2 + dy)]) return false;
      const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);
      return node === goalKey || !occupied[node];
    },
    // How close the nearest parallel neighbor sits, perpendicular to travel:
    // 1 = one lane away (cramped), 2 = two lanes away (the reference's even
    // weave), 0 = open field. The A* cost prefers 2 and avoids 1.
    laneNeighbors(x, y, direction) {
      const [dx, dy] = DIR8[direction];
      if (occupied[nodeKey(x - dy, y + dx)] || occupied[nodeKey(x + dy, y - dx)]) return 1;
      if (occupied[nodeKey(x - dy * 2, y + dx * 2)] || occupied[nodeKey(x + dy * 2, y - dx * 2)]) return 2;
      return 0;
    },
    claimNode(trace, x, y) {
      claim(trace, nodeKey(x, y));
    },
    claimStep(trace, x, y, direction) {
      const [dx, dy] = DIR8[direction];
      claim(trace, halfKey(x * 2 + dx, y * 2 + dy));
      let count = 1;
      const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);
      if (!occupied[node]) {
        claim(trace, node);
        count = 2;
      }
      trace.stepClaims.push(count);
    },
    // Remove the record's most recent step and exactly the claims it made.
    // Only valid while nothing later than that step has claimed (sealEnd
    // never trims past the record's own last via).
    unclaimLastStep(record) {
      const count = record.stepClaims.pop() || 0;
      for (let index = 0; index < count; index += 1) {
        const key = record.claims.pop();
        occupied[key] = 0;
        record.ownKeys.delete(key);
      }
      const step = record.steps.pop();
      record.end = [record.end[0] - DIR8[step][0], record.end[1] - DIR8[step][1]];
    },
    // A via may not sit on or cardinally beside another via (their annular
    // rings would overlap at one-pitch spacing), and the four diagonal
    // midpoints around it must not carry someone else's trace.
    viaAllowed(trace, x, y) {
      for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {
        if (viaNodes[nodeKey(x + dx, y + dy)]) return false;
      }
      for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
        const key = halfKey(x * 2 + dx, y * 2 + dy);
        if (occupied[key] && !trace.ownKeys.has(key)) return false;
      }
      return true;
    },
    placeVia(trace, x, y) {
      viaNodes[nodeKey(x, y)] = 1;
      trace.viaCells.push([x, y]);
      trace.viaSteps.add(trace.steps.length);
      for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
        const key = halfKey(x * 2 + dx, y * 2 + dy);
        if (!occupied[key]) claim(trace, key);
      }
    },
    rollback(trace) {
      for (const key of trace.claims) occupied[key] = 0;
      for (const [x, y] of trace.viaCells) viaNodes[nodeKey(x, y)] = 0;
    },
  };
}

function newTraceRecord(x, y) {
  return {
    start: [x, y],
    end: [x, y],
    steps: [],
    claims: [],
    stepClaims: [], // claims added per step, so ends can be trimmed precisely
    ownKeys: new Set(),
    viaCells: [],
    viaSteps: new Set(),
  };
}

// Every trace must end on a via. If the end position has no clearance, trim
// the trace back one step at a time (never past its own last via) until a
// via fits. Returns false when no compliant ending exists.
function sealEnd(router, record, minSteps) {
  for (;;) {
    if (record.viaSteps.has(record.steps.length)) return true;
    const [x, y] = record.end;
    if (router.viaAllowed(record, x, y)) {
      router.placeVia(record, x, y);
      return true;
    }
    const floor = Math.max(minSteps, record.viaSteps.size ? Math.max(...record.viaSteps) : 0);
    if (record.steps.length <= floor) return false;
    router.unclaimLastStep(record);
  }
}

// --- A* net router ----------------------------------------------------------
//
// Routes run between terminal formations the way a human lays out a board:
// they escape a pin, join a corridor beside existing traces (hug bonus), pay
// for every 45 degree turn (long calm runs), pay a small surcharge for
// sustained diagonals (the reference uses short 45 degree jogs), and finish
// into the far pin. States are (node, incoming direction) so turn costs are
// exact. Everything wraps on the torus.

class MinHeap {
  constructor() {
    this.costs = [];
    this.values = [];
  }

  get size() {
    return this.costs.length;
  }

  push(cost, value) {
    const { costs, values } = this;
    let index = costs.length;
    costs.push(cost);
    values.push(value);
    while (index > 0) {
      const parent = (index - 1) >> 1;
      if (costs[parent] <= costs[index]) break;
      [costs[parent], costs[index]] = [costs[index], costs[parent]];
      [values[parent], values[index]] = [values[index], values[parent]];
      index = parent;
    }
  }

  pop() {
    const { costs, values } = this;
    const top = values[0];
    const lastCost = costs.pop();
    const lastValue = values.pop();
    if (costs.length) {
      costs[0] = lastCost;
      values[0] = lastValue;
      let index = 0;
      for (;;) {
        const left = index * 2 + 1;
        const right = left + 1;
        let smallest = index;
        if (left < costs.length && costs[left] < costs[smallest]) smallest = left;
        if (right < costs.length && costs[right] < costs[smallest]) smallest = right;
        if (smallest === index) break;
        [costs[smallest], costs[index]] = [costs[index], costs[smallest]];
        [values[smallest], values[index]] = [values[index], values[smallest]];
        index = smallest;
      }
    }
    return top;
  }
}

function createPathfinder(router, columns, rows, config) {
  const turnPenalty = config.turnPenalty;
  const crampPenalty = config.cramp;
  const weaveBonus = config.weave;
  const stateCount = columns * rows * 8;
  const bestCost = new Float64Array(stateCount);
  const seen = new Int32Array(stateCount);
  const closed = new Int32Array(stateCount);
  const parent = new Int32Array(stateCount);
  let generation = 0;

  const wrapX = (value) => ((value % columns) + columns) % columns;
  const wrapY = (value) => ((value % rows) + rows) % rows;

  // start: {x, y, direction}; goal: {x, y, arrivals: Set<direction>}
  // bundleMode flips the lane preference: followers rejoining their bundle
  // WANT to ride one lane off their neighbor.
  return function route(random, start, goal, why = {}, bundleMode = false) {
    generation += 1;
    const heap = new MinHeap();
    const goalX = wrapX(goal.x);
    const goalY = wrapY(goal.y);
    const goalKey = router.nodeKey(goalX, goalY);

    const heuristic = (x, y) => {
      let dx = Math.abs(x - goalX);
      dx = Math.min(dx, columns - dx);
      let dy = Math.abs(y - goalY);
      dy = Math.min(dy, rows - dy);
      return Math.max(dx, dy) + 0.42 * Math.min(dx, dy);
    };

    const startX = wrapX(start.x);
    const startY = wrapY(start.y);
    // Seed the facing direction free, and the two perpendiculars at a small
    // cost — a pin whose lane is congested can still hook out sideways.
    const h0 = heuristic(startX, startY);
    for (const [turn, cost] of [[0, 0], [2, 0.9], [6, 0.9]]) {
      const startState = (startY * columns + startX) * 8 + ((start.direction + turn) % 8);
      seen[startState] = generation;
      bestCost[startState] = cost;
      parent[startState] = -1;
      heap.push(cost + h0, startState);
    }

    const straightestFirst = heuristic(startX, startY);
    let goalState = -1;
    let expansions = 0;
    const TURNS = [0, 1, 7];

    // Weighted A* (heuristic scaled below): meaningfully faster, and the mild
    // suboptimality reads as organic hand-routing rather than error.
    while (heap.size && expansions < 9000) {
      expansions += 1;
      const state = heap.pop();
      if (closed[state] === generation) continue;
      closed[state] = generation;
      const direction = state % 8;
      const nodeIndex = (state - direction) / 8;
      const x = nodeIndex % columns;
      const y = (nodeIndex - x) / columns;

      if (x === goalX && y === goalY) {
        if (goal.arrivals.has(direction)) {
          goalState = state;
          break;
        }
        continue;
      }

      const cost = bestCost[state];
      for (const turn of TURNS) {
        const next = (direction + turn) % 8;
        if (!router.routeStepFree(x, y, next, goalKey)) continue;
        const nx = wrapX(x + DIR8[next][0]);
        const ny = wrapY(y + DIR8[next][1]);
        let stepCost = next % 2 === 0 ? 1 : 1.414;
        if (turn !== 0) stepCost += turnPenalty;
        const lane = router.laneNeighbors(nx, ny, next);
        if (bundleMode) {
          if (lane === 1) stepCost -= 0.22;
        } else if (lane === 1) stepCost += crampPenalty;
        else if (lane === 2) stepCost -= weaveBonus;
        stepCost += random() * 0.05;
        const nextState = (ny * columns + nx) * 8 + next;
        const total = cost + stepCost;
        if (seen[nextState] === generation && bestCost[nextState] <= total) continue;
        seen[nextState] = generation;
        bestCost[nextState] = total;
        parent[nextState] = state;
        heap.push(total + heuristic(nx, ny) * 1.2, nextState);
      }
    }

    if (goalState < 0) {
      why.reason = heap.size ? "expansions" : "exhausted";
      return null;
    }
    const directions = [];
    let cursor = goalState;
    while (parent[cursor] >= 0) {
      directions.push(cursor % 8);
      cursor = parent[cursor];
    }
    directions.reverse();
    // Reject absurd detours (torus wrap can produce them when hemmed in).
    if (directions.length > straightestFirst * 2.4 + 12) {
      why.reason = "detour";
      return null;
    }
    return directions;
  };
}

// --- terminal formations ----------------------------------------------------
//
// The reference pattern's vias are not sprinkled at random: they sit in small
// deliberate formations — diagonal ladders (staggered bus fanouts), rows, and
// singles — and traces escape them on a consistent side. Formations are placed
// with best-candidate sampling so the tile keeps even coverage and breathing
// room between groups.

function placeFormations(random, router, columns, rows, count, config) {
  const formations = [];
  const centers = [];
  const busWidth = config.busWidth;
  const horizontalEscape = Math.min(0.95, config.horizontalBias + 0.02);
  const rowSplit = 0.52 + 0.26 * config.horizontalBias;
  const wrapDist = (a, b, m) => {
    const d = Math.abs(a - b) % m;
    return Math.min(d, m - d);
  };

  for (let index = 0; index < count; index += 1) {
    let best = null;
    let bestClearance = -1;
    for (let candidate = 0; candidate < 8; candidate += 1) {
      const x = Math.floor(random() * columns);
      const y = Math.floor(random() * rows);
      let clearance = Infinity;
      for (const [cx, cy] of centers) {
        clearance = Math.min(
          clearance,
          Math.hypot(wrapDist(x, cx, columns), wrapDist(y, cy, rows)),
        );
      }
      if (clearance > bestClearance) {
        bestClearance = clearance;
        best = [x, y];
      }
    }

    const [x, y] = best;
    const roll = random();
    const pins = [];
    let signature;
    if (roll < 0.52) {
      // Diagonal via ladder — the staggered bus-fanout motif.
      const length = Math.max(2, Math.min(8, Math.round((3 + random() * 4) * busWidth)));
      const slope = random() < 0.5 ? -1 : 1;
      const escape = random() < horizontalEscape ? 0 : 4;
      signature = `ladder${slope}`;
      for (let k = 0; k < length; k += 1) pins.push({ x: x + k, y: y + k * slope, escape });
    } else if (roll < rowSplit) {
      // Vertical via row (2-cell spacing), horizontal escapes.
      const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));
      const escape = random() < 0.5 ? 0 : 4;
      signature = "vrow";
      for (let k = 0; k < length; k += 1) pins.push({ x, y: y + k * 2, escape });
    } else if (roll < 0.78) {
      // Horizontal via row, vertical escapes.
      const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));
      const escape = random() < 0.5 ? 2 : 6;
      signature = "hrow";
      for (let k = 0; k < length; k += 1) pins.push({ x: x + k * 2, y, escape });
    } else {
      signature = "solo";
      pins.push({ x, y, escape: [0, 4, 2, 6][Math.floor(random() * 4)] });
    }

    // Reserve the pin nodes now; the via graphics are only placed when a net
    // or stub actually connects, so a formation that stays unconnected leaves
    // no orphaned via row behind.
    const record = newTraceRecord(pins[0].x, pins[0].y);
    let placeable = true;
    for (const pin of pins) {
      if (!router.nodeFree(pin.x, pin.y)) {
        placeable = false;
        break;
      }
      router.claimNode(record, pin.x, pin.y);
    }
    if (!placeable) {
      router.rollback(record);
      continue;
    }
    centers.push([x, y]);
    formations.push({ pins: pins.map((pin) => ({ ...pin, used: false })), record, signature });
  }
  return formations;
}

// Start a trace record at a pin with its mandatory via already in place.
// Returns null when the pin has no via clearance (the pin is then skipped —
// every trace must begin on a via).
function startAtPin(router, pin) {
  const record = newTraceRecord(pin.x, pin.y);
  if (!router.viaAllowed(record, pin.x, pin.y)) return null;
  router.placeVia(record, pin.x, pin.y);
  return record;
}

function signedWrap(delta, modulus) {
  let d = ((delta % modulus) + modulus) % modulus;
  if (d > modulus / 2) d -= modulus;
  return d;
}

// A route may enter a pin from the facing side or hook in from up to 90
// degrees off — only a straight-through backside entry is forbidden.
const ARRIVALS = DIR8.map((_, escape) => {
  const reverse = (escape + 4) % 8;
  return new Set([
    (reverse + 6) % 8,
    (reverse + 7) % 8,
    reverse,
    (reverse + 1) % 8,
    (reverse + 2) % 8,
  ]);
});

// Route bundles of nets between compatible formations. Only the FIRST pin
// pair is routed with A*; the remaining pins get pure translated copies of
// the leader. Translation is what the reference does: neighbors that bend at
// the same places, producing nested chevrons and group staircases. Followers
// that hit an obstruction stop early and drop their via there — a natural
// fanout.
function routeNets(random, router, route, formations, addRecord, stats, config) {
  const majorFor = (escape) => (escape === 0 || escape === 4 ? 0 : 1);
  const sameNode = (x1, y1, x2, y2) =>
    router.nodeKey(x1, y1) === router.nodeKey(x2, y2);

  for (const formation of formations) {
    const minePins = formation.pins.filter((pin) => !pin.used);
    if (!minePins.length) continue;

    const escape = formation.pins[0].escape;
    const axis = majorFor(escape);
    // Find a partner formation whose pins face back toward this one; a
    // partner with the same shape nests perfectly under translation.
    let partner = null;
    let partnerScore = Infinity;
    for (const other of formations) {
      if (other === formation) continue;
      const otherFree = other.pins.filter((pin) => !pin.used);
      if (!otherFree.length) continue;
      if (other.pins[0].escape !== (escape + 4) % 8) continue;
      const dx = signedWrap(other.pins[0].x - formation.pins[0].x, router.columns);
      const dy = signedWrap(other.pins[0].y - formation.pins[0].y, router.rows);
      const major = axis === 0 ? dx : dy;
      const minor = axis === 0 ? dy : dx;
      const forward = escape === 0 || escape === 2 ? major : -major;
      if (forward < 8 || forward > config.netReach) continue;
      if (Math.abs(minor) > 12) continue;
      // Prefer a few cells of misalignment: it forces the staircase jogs the
      // reference is full of. Perfect alignment yields sterile laser lines.
      let score =
        Math.abs(Math.abs(minor) - config.jogBias) * 0.8 +
        Math.abs(forward - 22) * 0.3 +
        random() * 5;
      if (other.signature === formation.signature) score -= 4;
      if (score < partnerScore) {
        partnerScore = score;
        partner = other;
      }
    }
    if (!partner) continue;

    const minorOf = (pin) => (axis === 0 ? pin.y : pin.x);
    const mine = minePins.sort((a, b) => minorOf(a) - minorOf(b));
    const theirs = partner.pins
      .filter((pin) => !pin.used)
      .sort((a, b) => minorOf(a) - minorOf(b));
    const pairCount = Math.min(mine.length, theirs.length);
    if (!pairCount) continue;

    // Leader. Both pin vias go in before routing — every trace begins and
    // ends on a via, so a net is only viable if both terminals have
    // clearance.
    const leadFrom = mine[0];
    const leadTo = theirs[0];
    const leadRecord = startAtPin(router, leadFrom);
    if (!leadRecord) continue;
    if (!router.viaAllowed(leadRecord, leadTo.x, leadTo.y)) {
      router.rollback(leadRecord);
      continue;
    }
    router.placeVia(leadRecord, leadTo.x, leadTo.y);
    const why = {};
    const directions = route(
      random,
      { x: leadFrom.x, y: leadFrom.y, direction: leadFrom.escape },
      { x: leadTo.x, y: leadTo.y, arrivals: ARRIVALS[leadTo.escape] },
      why,
    );
    if (!directions) {
      router.rollback(leadRecord);
      stats.leaderFails += 1;
      stats[why.reason] = (stats[why.reason] || 0) + 1;
      continue;
    }
    stats.leaders += 1;
    stats.followerTries += pairCount - 1;
    {
      let x = leadFrom.x;
      let y = leadFrom.y;
      for (const step of directions) {
        router.claimStep(leadRecord, x, y, step);
        x += DIR8[step][0];
        y += DIR8[step][1];
        leadRecord.steps.push(step);
      }
      leadRecord.end = [x, y];
      leadFrom.used = true;
      leadTo.used = true;
      addRecord(leadRecord);
    }

    // Followers: translated copies pin-by-pin. A follower whose translated
    // corridor is blocked early gets rerouted with A* in bundle mode so it
    // wraps the obstacle and rejoins the group. Every follower starts on its
    // pin via and seals its far end with a via (trimming back if needed).
    for (let index = 1; index < pairCount; index += 1) {
      const from = mine[index];
      const to = theirs[index];
      const goalKey = router.nodeKey(to.x, to.y);

      const record = startAtPin(router, from);
      if (!record) {
        stats.followerFails += 1;
        continue;
      }

      const clearSteps = () => {
        while (record.steps.length) router.unclaimLastStep(record);
      };
      const layTranslation = () => {
        let x = from.x;
        let y = from.y;
        for (const step of directions) {
          if (!router.routeStepFree(x, y, step, goalKey)) break;
          router.claimStep(record, x, y, step);
          x += DIR8[step][0];
          y += DIR8[step][1];
          record.steps.push(step);
          if (sameNode(x, y, to.x, to.y)) break;
        }
        record.end = [x, y];
      };

      layTranslation();
      let reached = sameNode(record.end[0], record.end[1], to.x, to.y);
      if (!reached && record.steps.length < Math.max(6, Math.floor(directions.length * 0.6))) {
        clearSteps();
        const alt = route(
          random,
          { x: from.x, y: from.y, direction: from.escape },
          { x: to.x, y: to.y, arrivals: ARRIVALS[to.escape] },
          {},
          true,
        );
        if (alt) {
          let x = from.x;
          let y = from.y;
          for (const step of alt) {
            router.claimStep(record, x, y, step);
            x += DIR8[step][0];
            y += DIR8[step][1];
            record.steps.push(step);
          }
          record.end = [x, y];
          reached = true;
          stats.followersRerouted += 1;
        } else {
          layTranslation();
          reached = sameNode(record.end[0], record.end[1], to.x, to.y);
        }
      }

      if (record.steps.length < 4 || !sealEnd(router, record, 4)) {
        router.rollback(record);
        stats.followerFails += 1;
        continue;
      }
      from.used = true;
      if (sameNode(record.end[0], record.end[1], to.x, to.y)) {
        to.used = true;
        stats.followersFull += 1;
      } else {
        stats.followersPartial += 1;
      }
      addRecord(record);
    }
  }
}

// Tap combs: short perpendicular stubs dropping off a long run at a regular
// pitch, each ending in a via — the power-rail tap motif that punctuates the
// reference artwork.
function tapCombs(random, router, records, addRecord, config) {
  const runs = [];
  for (const record of records) {
    let x = record.start[0];
    let y = record.start[1];
    let runX = x;
    let runY = y;
    let runDir = -1;
    let runLen = 0;
    const flush = () => {
      if (runDir >= 0 && runDir % 2 === 0 && runLen >= 8) {
        runs.push({ x: runX, y: runY, dir: runDir, len: runLen });
      }
    };
    for (const step of record.steps) {
      if (step === runDir) runLen += 1;
      else {
        flush();
        runDir = step;
        runX = x;
        runY = y;
        runLen = 1;
      }
      x += DIR8[step][0];
      y += DIR8[step][1];
    }
    flush();
  }

  let combs = 0;
  for (const run of runs) {
    if (combs >= config.combs || random() >= 0.28) continue;
    const [dx, dy] = DIR8[run.dir];
    const side = random() < 0.5 ? 1 : -1;
    const px = -dy * side;
    const py = dx * side;
    const tapDir = DIR8.findIndex(([tx, ty]) => tx === px && ty === py);
    const teeth = 2 + Math.floor(random() * 3);
    // Depth 2-3: the junction via and tip via need a two-cell gap to clear
    // each other's annular rings.
    const depth = 2 + (random() < 0.35 ? 1 : 0);
    const span = teeth * 2;
    if (run.len < span + 2) continue;
    const offset = 1 + Math.floor(random() * (run.len - span - 1));
    const made = [];
    for (let tooth = 0; tooth < teeth; tooth += 1) {
      const bx = run.x + dx * (offset + tooth * 2);
      const by = run.y + dy * (offset + tooth * 2);
      // The tap starts ON the parent trace (a T junction, marked with its
      // own via) and steps outward to a tip via.
      const record = newTraceRecord(bx, by);
      if (!router.viaAllowed(record, bx, by)) continue;
      router.placeVia(record, bx, by);
      let cx = bx;
      let cy = by;
      for (let d = 0; d < depth; d += 1) {
        if (!router.stepFree(cx, cy, tapDir)) break;
        router.claimStep(record, cx, cy, tapDir);
        cx += px;
        cy += py;
        record.steps.push(tapDir);
      }
      record.end = [cx, cy];
      if (record.steps.length < 2 || !sealEnd(router, record, 2)) {
        router.rollback(record);
        continue;
      }
      made.push(record);
    }
    if (made.length >= 2) {
      for (const record of made) addRecord(record);
      combs += 1;
    } else {
      for (const record of made) router.rollback(record);
    }
  }
}

// Short bare-ended escape stubs for pins that never got a net — the reference
// is full of these little launches.
function routeStubs(random, router, formations, addRecord, config) {
  for (const formation of formations) {
    for (const pin of formation.pins) {
      if (pin.used || random() >= config.stubChance) continue;
      const record = startAtPin(router, pin);
      if (!record) continue;
      let x = pin.x;
      let y = pin.y;
      let direction = pin.escape;
      const length = 2 + Math.floor(random() * 5);
      for (let step = 0; step < length; step += 1) {
        if (step > 0 && random() < 0.2) {
          const jog = (direction + (random() < 0.5 ? 1 : 7)) % 8;
          if (router.stepFree(x, y, jog)) direction = jog;
        }
        if (!router.stepFree(x, y, direction)) break;
        router.claimStep(record, x, y, direction);
        x += DIR8[direction][0];
        y += DIR8[direction][1];
        record.steps.push(direction);
      }
      record.end = [x, y];
      if (record.steps.length < 2 || !sealEnd(router, record, 2)) {
        router.rollback(record);
        continue;
      }
      pin.used = true;
      addRecord(record);
    }
  }
}

// Translated companions for a filler walk — same idea as the net followers:
// staggered trims and via ladders at the ends, bending wherever the base
// bends. In the reference almost nothing travels alone.
function replicateWalk(random, router, base, addRecord) {
  if (random() >= 0.55 || base.steps.length < 8) return;
  let horizontalSteps = 0;
  let verticalSteps = 0;
  for (const step of base.steps) {
    if (step === 0 || step === 4) horizontalSteps += 1;
    else if (step === 2 || step === 6) verticalSteps += 1;
  }
  const horizontal = horizontalSteps >= verticalSteps;
  const spacing = random() < 0.7 ? 1 : 2;
  const side = random() < 0.5 ? 1 : -1;
  const shift = horizontal ? [0, spacing * side] : [spacing * side, 0];
  const copies = 1 + Math.floor(random() * 3);
  const trimStartUnit = random() < 0.5 ? 1 : 0;
  const trimEndUnit = random() < 0.8 ? 1 : 2;

  for (let copy = 1; copy <= copies; copy += 1) {
    const trimStart = copy * trimStartUnit;
    const trimEnd = copy * trimEndUnit;
    if (base.steps.length - trimStart - trimEnd < 5) break;
    let x = base.start[0] + shift[0] * copy;
    let y = base.start[1] + shift[1] * copy;
    for (let index = 0; index < trimStart; index += 1) {
      x += DIR8[base.steps[index]][0];
      y += DIR8[base.steps[index]][1];
    }
    if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;
    const record = newTraceRecord(x, y);
    router.claimNode(record, x, y);
    router.placeVia(record, x, y); // every trace starts on a via
    for (let index = trimStart; index < base.steps.length - trimEnd; index += 1) {
      const step = base.steps[index];
      if (!router.stepFree(x, y, step)) break;
      router.claimStep(record, x, y, step);
      x += DIR8[step][0];
      y += DIR8[step][1];
      record.steps.push(step);
    }
    record.end = [x, y];
    if (record.steps.length < 5 || !sealEnd(router, record, 5)) {
      router.rollback(record);
      continue;
    }
    addRecord(record);
  }
}

// --- filler walker ----------------------------------------------------------
//
// A small amount of free-roaming texture between the routed bundles, using the
// same collision rules. Kept from the earlier engine but used sparingly.

const SCRATCH_RECORD = { ownKeys: new Set() };

function walkTrace(random, router, config, startWeights) {
  let sx = -1;
  let sy = -1;
  let bestCrowding = Infinity;
  for (let attempt = 0, found = 0; attempt < 24 && found < 3; attempt += 1) {
    const x = Math.floor(random() * router.columns);
    const y = Math.floor(random() * router.rows);
    if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;
    found += 1;
    const crowding = router.crowding(x, y);
    if (crowding < bestCrowding) {
      bestCrowding = crowding;
      sx = x;
      sy = y;
    }
  }
  if (sx < 0) return null;

  const trace = newTraceRecord(sx, sy);
  router.claimNode(trace, sx, sy);
  router.placeVia(trace, sx, sy); // every trace starts on a via

  let direction = weightedChoice(random, startWeights);
  let lastCardinal = direction % 2 === 0 ? direction : (direction + (random() < 0.5 ? 1 : 7)) % 8;
  const budget = 14 + Math.floor(random() * 22);
  let x = sx;
  let y = sy;
  let sinceVia = 4;
  let turnStreak = 0; // consecutive same-side turns; capped to avoid closed loops

  while (trace.steps.length < budget) {
    const cardinal = direction % 2 === 0;
    const runLength = cardinal
      ? 3 + Math.floor(random() * random() * 9)
      : 1 + Math.floor(random() * 3);

    for (let step = 0; step < runLength && trace.steps.length < budget; step += 1) {
      if (!router.stepFree(x, y, direction)) break;
      router.claimStep(trace, x, y, direction);
      x += DIR8[direction][0];
      y += DIR8[direction][1];
      trace.steps.push(direction);
      sinceVia += 1;
      if (
        cardinal &&
        sinceVia > 4 &&
        trace.steps.length < budget - 2 &&
        random() < config.inlineViaChance &&
        router.viaAllowed(trace, x, y)
      ) {
        router.placeVia(trace, x, y);
        sinceVia = 0;
      }
    }

    if (trace.steps.length >= budget) break;

    let next;
    if (cardinal) {
      // Curling the same way repeatedly closes octagon loops; alternate.
      const side = Math.abs(turnStreak) >= 3 ? -Math.sign(turnStreak) : random() < 0.5 ? 1 : -1;
      next = (direction + (side > 0 ? 1 : 7)) % 8;
    } else {
      const optionA = (direction + 1) % 8;
      const optionB = (direction + 7) % 8;
      const preferPrevious = (optionA === lastCardinal || optionB === lastCardinal) && random() < 0.7;
      next = preferPrevious ? lastCardinal : random() < 0.5 ? optionA : optionB;
    }
    if (!router.stepFree(x, y, next)) {
      const mirrored = (direction + (8 - ((next - direction + 8) % 8))) % 8;
      if (!router.stepFree(x, y, mirrored)) break;
      next = mirrored;
    }
    const turnSide = ((next - direction + 8) % 8) === 1 ? 1 : -1;
    turnStreak = Math.sign(turnStreak) === turnSide ? turnStreak + turnSide : turnSide;
    direction = next;
    if (direction % 2 === 0) lastCardinal = direction;
  }

  trace.end = [x, y];
  if (trace.steps.length < 7 || !sealEnd(router, trace, 7)) {
    router.rollback(trace);
    return null;
  }
  return trace;
}

function toTileTrace(record, pitch) {
  const points = [[record.start[0], record.start[1]]];
  let previous = -1;
  let [x, y] = record.start;
  for (const step of record.steps) {
    x += DIR8[step][0];
    y += DIR8[step][1];
    if (step === previous) points[points.length - 1] = [x, y];
    else points.push([x, y]);
    previous = step;
  }
  const scaled = points.map(([px, py]) => [px * pitch, py * pitch]);
  let length = 0;
  for (let index = 1; index < scaled.length; index += 1) {
    length += Math.hypot(
      scaled[index][0] - scaled[index - 1][0],
      scaled[index][1] - scaled[index - 1][1],
    );
  }
  return {
    points: scaled,
    length,
    vias: record.viaCells.map(([vx, vy]) => [vx * pitch, vy * pitch]),
  };
}

export function generateCircuitTile(options = {}) {
  const config = { ...DEFAULTS, ...options };
  const random = mulberry32(hashSeed(config.seed));
  const columns = Math.round(config.tileWidth / config.pitch);
  const rows = Math.round(config.tileHeight / config.pitch);
  const router = createRouter(random, columns, rows);
  const route = createPathfinder(router, columns, rows, config);
  const startWeights = startDirectionWeights(config.horizontalBias);

  const records = [];
  let routedSteps = 0;
  const addRecord = (record) => {
    records.push(record);
    routedSteps += record.steps.length;
  };

  // 1. Terminal formations: via ladders, rows, singles.
  const formations = placeFormations(
    random,
    router,
    columns,
    rows,
    Math.round(config.formations * config.density),
    config,
  );
  for (const formation of formations) records.push(formation.record);

  // 2. Nets between formations — the flow. Two passes so pins left over from
  // the first pairing find another partner.
  const stats = {
    formations: formations.length,
    leaders: 0,
    leaderFails: 0,
    followerTries: 0,
    followersFull: 0,
    followersPartial: 0,
    followersRerouted: 0,
    followerFails: 0,
  };
  routeNets(random, router, route, formations, addRecord, stats, config);
  routeNets(random, router, route, formations, addRecord, stats, config);

  // 3. Escape stubs for leftover pins.
  routeStubs(random, router, formations, addRecord, config);

  // 4. A sprinkle of free-roaming filler until the reference ink density.
  const targetSteps = Math.round(4800 * config.density);
  let attempts = 0;
  while (routedSteps < targetSteps && attempts < 3200) {
    attempts += 1;
    const trace = walkTrace(random, router, config, startWeights);
    if (trace) {
      addRecord(trace);
      replicateWalk(random, router, trace, addRecord);
    }
  }

  // 5. Tap combs hanging off the long runs (nets and filler alike).
  if (config.combs > 0) tapCombs(random, router, [...records], addRecord, config);

  return {
    width: columns * config.pitch,
    height: rows * config.pitch,
    pitch: config.pitch,
    stats,
    traces: records.map((record) => toTileTrace(record, config.pitch)),
  };
}

export function pathData(points, offsetX = 0, offsetY = 0) {
  return points
    .map(([x, y], index) => `${index ? "L" : "M"}${(x + offsetX).toFixed(2)},${(y + offsetY).toFixed(2)}`)
    .join("");
}

// Standalone seamless tile: the geometry is drawn 3 x 3 around the viewBox and
// clipped, so traces that wrap the torus edge reappear on the opposite side.
export function tileSvg(tile, options = {}) {
  const config = { ...DEFAULTS, ...options };
  const { width, height } = tile;
  const body = [];
  for (const trace of tile.traces) {
    if (trace.points.length < 2) continue;
    body.push(
      `<path d="${pathData(trace.points)}" fill="none" stroke="${config.traceColor}" ` +
        `stroke-width="${config.traceWidth}" stroke-linecap="butt" stroke-linejoin="miter"/>`,
    );
  }
  const vias = [];
  for (const trace of tile.traces) {
    for (const [cx, cy] of trace.vias) {
      vias.push(
        `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaOuterRadius}" fill="${config.traceColor}"/>` +
          `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaInnerRadius}" fill="${config.holeColor || config.background || "#0d1117"}"/>`,
      );
    }
  }
  const uses = [];
  for (let row = -1; row <= 1; row += 1) {
    for (let column = -1; column <= 1; column += 1) {
      uses.push(`<use href="#tile" transform="translate(${column * width}, ${row * height})"/>`);
    }
  }
  const backgroundRect = config.background
    ? `<rect width="${width}" height="${height}" fill="${config.background}"/>`
    : "";
  return (
    `<?xml version="1.0" encoding="UTF-8"?>\n` +
    `<svg xmlns="${SVG_NS}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
    `<defs><g id="tile">${body.join("")}${vias.join("")}</g>` +
    `<clipPath id="edge"><rect width="${width}" height="${height}"/></clipPath></defs>` +
    `${backgroundRect}<g clip-path="url(#edge)">${uses.join("")}</g></svg>`
  );
}

// --- signal routing ---------------------------------------------------------
//
// Signals ride the real centerlines. Each trace is resampled to ~3.5 unit
// spacing and indexed in a spatial hash; when a pulse reaches the end of a
// trace it looks for a nearby, direction-aligned point on another trace (in
// this tile or a neighbor) and hops there, exactly like the reference site.

const SIGNAL_SAMPLE = 3.5;
const HOP_RADIUS = 3.5;
const HASH_CELL = 8;
const signalIndexCache = new WeakMap();

function ensureSignalIndex(tile) {
  let index = signalIndexCache.get(tile);
  if (index) return index;
  const dense = tile.traces.map((trace) => {
    if (trace.points.length < 2) return [];
    const points = [];
    for (let segment = 1; segment < trace.points.length; segment += 1) {
      const [ax, ay] = trace.points[segment - 1];
      const [bx, by] = trace.points[segment];
      const steps = Math.max(1, Math.round(Math.hypot(bx - ax, by - ay) / SIGNAL_SAMPLE));
      for (let step = segment === 1 ? 0 : 1; step <= steps; step += 1) {
        points.push([ax + ((bx - ax) * step) / steps, ay + ((by - ay) * step) / steps]);
      }
    }
    return points;
  });
  const buckets = new Map();
  dense.forEach((points, traceIndex) => {
    points.forEach((point, pointIndex) => {
      const key = `${Math.floor(point[0] / HASH_CELL)}:${Math.floor(point[1] / HASH_CELL)}`;
      let bucket = buckets.get(key);
      if (!bucket) buckets.set(key, (bucket = []));
      bucket.push([traceIndex, pointIndex]);
    });
  });
  // Trace endpoints (all mandated vias) — the launch pads for via hopping.
  const endpoints = [];
  dense.forEach((points, traceIndex) => {
    if (points.length < 6) return;
    endpoints.push({ trace: traceIndex, point: 0, direction: 1, x: points[0][0], y: points[0][1] });
    const last = points.length - 1;
    endpoints.push({ trace: traceIndex, point: last, direction: -1, x: points[last][0], y: points[last][1] });
  });
  index = { dense, buckets, endpoints };
  signalIndexCache.set(tile, index);
  return index;
}

function findHop(tile, absX, absY, dirX, dirY, visited) {
  const { dense, buckets } = ensureSignalIndex(tile);
  const baseTileX = Math.floor(absX / tile.width);
  const baseTileY = Math.floor(absY / tile.height);
  let best = null;

  for (let tileY = baseTileY - 2; tileY <= baseTileY + 2; tileY += 1) {
    for (let tileX = baseTileX - 2; tileX <= baseTileX + 2; tileX += 1) {
      const localX = absX - tileX * tile.width;
      const localY = absY - tileY * tile.height;
      const minCellX = Math.floor((localX - HOP_RADIUS) / HASH_CELL);
      const maxCellX = Math.floor((localX + HOP_RADIUS) / HASH_CELL);
      const minCellY = Math.floor((localY - HOP_RADIUS) / HASH_CELL);
      const maxCellY = Math.floor((localY + HOP_RADIUS) / HASH_CELL);
      for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
        for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
          const bucket = buckets.get(`${cellX}:${cellY}`);
          if (!bucket) continue;
          for (const [traceIndex, pointIndex] of bucket) {
            if (visited.has(`${traceIndex}:${tileX}:${tileY}`)) continue;
            const point = dense[traceIndex][pointIndex];
            const distance = Math.hypot(point[0] - localX, point[1] - localY);
            if (distance >= HOP_RADIUS) continue;
            if (distance >= 1.2) {
              const toward =
                ((point[0] - localX) * dirX + (point[1] - localY) * dirY) / (distance || 1);
              if (toward < 0.35) continue;
            }
            for (const hopDirection of [1, -1]) {
              const ahead = pointIndex + hopDirection * 3;
              if (ahead < 0 || ahead >= dense[traceIndex].length) continue;
              const segX = dense[traceIndex][ahead][0] - point[0];
              const segY = dense[traceIndex][ahead][1] - point[1];
              const segLength = Math.hypot(segX, segY) || 1;
              const score = (segX * dirX + segY * dirY) / segLength - distance * 0.05;
              if (!best || score > best.score) {
                best = {
                  trace: traceIndex,
                  tileX,
                  tileY,
                  point: pointIndex,
                  direction: hopDirection,
                  score,
                };
              }
            }
          }
        }
      }
    }
  }
  return best && best.score > 0.1 ? best : null;
}

function buildSignalPath(tile, start, budget, minLength) {
  const { dense } = ensureSignalIndex(tile);
  const visited = new Set();
  const points = [];
  let travelled = 0;
  let cursor = { ...start };

  // The walk always finishes at a trace end (a mandated via) — the budget
  // only limits further mid-route hops, never cuts a pulse off mid-trace.
  for (;;) {
    const tracePoints = dense[cursor.trace];
    visited.add(`${cursor.trace}:${cursor.tileX}:${cursor.tileY}`);
    const offsetX = cursor.tileX * tile.width;
    const offsetY = cursor.tileY * tile.height;
    for (
      let index = cursor.point;
      index >= 0 && index < tracePoints.length;
      index += cursor.direction
    ) {
      const x = tracePoints[index][0] + offsetX;
      const y = tracePoints[index][1] + offsetY;
      if (points.length) {
        const last = points[points.length - 1];
        travelled += Math.hypot(x - last[0], y - last[1]);
      }
      points.push([x, y]);
    }
    if (points.length < 2 || travelled >= budget) break;

    const last = points[points.length - 1];
    const before = points[points.length - 2];
    const stepLength = Math.hypot(last[0] - before[0], last[1] - before[1]) || 1;
    const hop = findHop(
      tile,
      last[0],
      last[1],
      (last[0] - before[0]) / stepLength,
      (last[1] - before[1]) / stepLength,
      visited,
    );
    if (!hop) break;
    cursor = hop;
  }

  return travelled >= minLength ? { points, length: travelled } : null;
}

// --- DOM mount ---------------------------------------------------------------

export function createCircuitBackground(mount, options = {}) {
  if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");

  const id = `circuit-${++instanceCount}`;
  let config = { ...DEFAULTS, ...options };
  let random = mulberry32(hashSeed(`${config.seed}:signals`));
  let tile = generateCircuitTile(config);
  let svg;
  let usesGroup;
  let signalGroup;
  let resizeObserver;
  let activeSignals = 0;
  let signalTimer;
  let epoch = 0; // invalidates in-flight signal timeouts across regenerations
  let tilesAcross = 1;
  let tilesDown = 1;
  let topOffset = -config.tileHeight / 2;
  let pointer = null;
  const interactionTarget = mount.parentElement || mount;
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  const originalPosition = mount.style.position;
  const computedPosition = getComputedStyle(mount).position;
  if (computedPosition === "static") mount.style.position = "relative";
  mount.style.overflow = "hidden";

  function buildSvg() {
    svg?.remove();
    epoch += 1;
    activeSignals = 0;
    tile = generateCircuitTile(config);
    random = mulberry32(hashSeed(`${config.seed}:signals`));
    svg = svgEl("svg", {
      "aria-hidden": "true",
      focusable: "false",
      xmlns: SVG_NS,
    });
    svg.dataset.circuitBackground = "";
    Object.assign(svg.style, {
      position: "absolute",
      left: "0",
      top: "0",
      display: "block",
      maxWidth: "none",
      pointerEvents: "none",
      overflow: "visible",
    });

    const defs = svgEl("defs");
    const tileGroup = svgEl("g", { id: `${id}-tile` });
    const traceGroup = svgEl("g", {
      fill: "none",
      stroke: config.traceColor,
      "stroke-width": config.traceWidth,
      "stroke-linecap": "butt",
      "stroke-linejoin": "miter",
    });
    const viaGroup = svgEl("g");
    for (const trace of tile.traces) {
      if (trace.points.length >= 2) {
        traceGroup.appendChild(svgEl("path", { d: pathData(trace.points) }));
      }
      for (const [cx, cy] of trace.vias) {
        viaGroup.appendChild(
          svgEl("circle", { cx, cy, r: config.viaOuterRadius, fill: config.traceColor }),
        );
        viaGroup.appendChild(
          svgEl("circle", {
            cx,
            cy,
            r: config.viaInnerRadius,
            fill: "var(--circuit-hole, #0d1117)",
          }),
        );
      }
    }
    tileGroup.append(traceGroup, viaGroup);
    defs.appendChild(tileGroup);
    svg.appendChild(defs);

    usesGroup = svgEl("g");
    signalGroup = svgEl("g", { fill: "none" });
    svg.append(usesGroup, signalGroup);
    mount.prepend(svg);
    layout();
  }

  function layout() {
    const width = Math.max(1, mount.clientWidth);
    const height = Math.max(1, mount.clientHeight);
    const tilePixelWidth = tile.width * config.scale;
    const tilePixelHeight = tile.height * config.scale;
    tilesAcross = Math.max(1, Math.ceil(width / tilePixelWidth));
    tilesDown = Math.max(2, Math.ceil(height / tilePixelHeight) + 1);
    topOffset = -tile.height / 2;
    const viewWidth = tilesAcross * tile.width;
    const viewHeight = tilesDown * tile.height;

    svg.setAttribute("width", `${viewWidth * config.scale}`);
    svg.setAttribute("height", `${viewHeight * config.scale}`);
    svg.setAttribute("viewBox", `0 ${-topOffset} ${viewWidth} ${viewHeight}`);
    usesGroup.replaceChildren();
    // The extra ring of tiles paints the traces that wrap in from neighbors.
    for (let row = -1; row <= tilesDown; row += 1) {
      for (let column = -1; column <= tilesAcross; column += 1) {
        usesGroup.appendChild(
          svgEl("use", {
            href: `#${id}-tile`,
            transform: `translate(${column * tile.width}, ${row * tile.height})`,
          }),
        );
      }
    }
  }

  // Pointer-biased launch pad: candidates are scored by the distance from
  // the ACTUAL launch point (a trace end, not the trace middle) to the
  // cursor, plus spawnRadius worth of jitter — so spawns cluster within
  // roughly spawnRadius of the pointer without a hard cutoff.
  function pickStart() {
    const { dense } = ensureSignalIndex(tile);
    const nearPointer = pointer && performance.now() - pointer.at < 4000;
    let choice = null;
    let bestScore = Infinity;
    const tries = nearPointer ? 40 : 4;
    for (let attempt = 0; attempt < tries; attempt += 1) {
      const trace = Math.floor(random() * dense.length);
      const tileX = Math.floor(random() * tilesAcross);
      const tileY = Math.floor(random() * tilesDown);
      if (dense[trace].length < 6) continue;
      const fromStart = random() < 0.5;
      const candidate = {
        trace,
        tileX,
        tileY,
        point: fromStart ? 0 : dense[trace].length - 1,
        direction: fromStart ? 1 : -1,
      };
      if (!nearPointer) return candidate;
      const [padX, padY] = dense[trace][candidate.point];
      const score =
        Math.hypot(padX + tileX * tile.width - pointer.x, padY + tileY * tile.height - pointer.y) +
        config.spawnRadius * random();
      if (score < bestScore) {
        bestScore = score;
        choice = candidate;
      }
    }
    return choice;
  }

  // Launch pads (trace-end vias) near an ended signal, closest first, so the
  // replacement reads as the signal hopping vias onto a neighboring trace.
  function nearbyEndpointStarts(absX, absY, excludeTrace) {
    const { endpoints } = ensureSignalIndex(tile);
    const baseTileX = Math.floor(absX / tile.width);
    const baseTileY = Math.floor(absY / tile.height);
    const candidates = [];
    for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {
      for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {
        const localX = absX - tileX * tile.width;
        const localY = absY - tileY * tile.height;
        for (const endpoint of endpoints) {
          if (endpoint.trace === excludeTrace) continue;
          const distance = Math.hypot(endpoint.x - localX, endpoint.y - localY);
          if (distance > 2 && distance < config.hopRadius) {
            candidates.push({ endpoint, tileX, tileY, distance: distance + random() * 20 });
          }
        }
      }
    }
    candidates.sort((a, b) => a.distance - b.distance);
    return candidates.slice(0, 6).map((pick) => ({
      trace: pick.endpoint.trace,
      tileX: pick.tileX,
      tileY: pick.tileY,
      point: pick.endpoint.point,
      direction: pick.endpoint.direction,
    }));
  }

  // phase (0..1) fast-forwards the animation so boot signals are already
  // mid-journey. A chain keeps its speedFactor, color AND survival rate across
  // hops — the signal that entered blue exits blue, and a click-born chain
  // keeps decaying at burstSurvival while ambient chains use hopSurvival.
  // Occasionally (colorDrift) a leg crossfades to a new color over its whole
  // run, so palettes shift gradually, never pop.
  function launchSignal(path, phase = 0, speedFactor, chainColor, survival) {
    const factor = speedFactor ?? 0.85 + random() * 0.3;
    const surviveChance = survival ?? config.hopSurvival;
    const colorFrom = chainColor ?? weightedChoice(random, config.signalColors);
    const colorTo =
      random() < config.colorDrift ? weightedChoice(random, config.signalColors) : colorFrom;
    const d = pathData(path.points);
    const speed = config.signalSpeed * factor;
    const head = config.signalLength;
    const tail = head * 2.2;
    const length = path.length;
    const total = length + tail;
    const duration = (total / speed) * 1000;
    const group = svgEl("g");
    // Faint persistent wake along the whole route.
    const wake = svgEl("path", { d, stroke: colorFrom, "stroke-width": 2, opacity: 0 });
    // Soft glow under the head — a wide translucent stroke is far cheaper
    // than a drop-shadow filter re-rasterizing every frame.
    const glow = svgEl("path", {
      d,
      stroke: colorFrom,
      "stroke-width": 7,
      "stroke-linecap": "round",
      "stroke-opacity": 0.1,
      "stroke-dasharray": `${head} ${total}`,
      "stroke-dashoffset": head,
    });
    // Long dim comet tail behind the head.
    const comet = svgEl("path", {
      d,
      stroke: colorFrom,
      "stroke-width": 2,
      "stroke-linecap": "round",
      "stroke-opacity": 0.16,
      "stroke-dasharray": `${tail} ${total}`,
      "stroke-dashoffset": tail,
    });
    // Bright head.
    const pulse = svgEl("path", {
      d,
      stroke: colorFrom,
      "stroke-width": 2.4,
      "stroke-linecap": "round",
      "stroke-dasharray": `${head} ${total}`,
      "stroke-dashoffset": head,
      "stroke-opacity": 0.72,
    });
    group.append(wake, glow, comet, pulse);
    signalGroup.appendChild(group);
    activeSignals += 1;

    // All layers share the same leading edge; each runs from its own dash
    // length down to -(total - itsDash) so they exit together. Color rides
    // along in the same keyframes when this leg is a crossfade.
    const timing = { duration, easing: "linear", fill: "forwards" };
    const animations = [
      pulse.animate(
        [
          { strokeDashoffset: head, stroke: colorFrom },
          { strokeDashoffset: head - total, stroke: colorTo },
        ],
        timing,
      ),
      glow.animate(
        [
          { strokeDashoffset: head, stroke: colorFrom },
          { strokeDashoffset: head - total, stroke: colorTo },
        ],
        timing,
      ),
      comet.animate(
        [
          { strokeDashoffset: tail, stroke: colorFrom },
          { strokeDashoffset: tail - total, stroke: colorTo },
        ],
        timing,
      ),
      wake.animate(
        [
          { opacity: 0, stroke: colorFrom },
          { opacity: 0.06 },
          { opacity: 0.06 },
          { opacity: 0, stroke: colorTo },
        ],
        { duration, fill: "forwards" },
      ),
    ];
    const headArrival = (duration * length) / total;
    const skip = Math.min(phase, 0.95) * headArrival;
    if (skip > 0) for (const animation of animations) animation.currentTime = skip;

    // The signal is logically done when its head reaches the final via (the
    // tail after that is just residue): blip, release its population slot,
    // and — in the same beat — launch its successor from a nearby via. The
    // handoff is synchronous so motion never pauses.
    const [endX, endY] = path.points[path.points.length - 1];
    const myEpoch = epoch;
    window.setTimeout(() => {
      if (myEpoch !== epoch) return;
      activeSignals -= 1;
      const blip = svgEl("circle", {
        cx: endX,
        cy: endY,
        r: 2,
        fill: "none",
        stroke: colorTo,
        "stroke-width": 1.5,
        "stroke-opacity": 0.6,
      });
      group.appendChild(blip);
      blip.animate(
        [
          { r: 2, strokeOpacity: 0.6 },
          { r: 8, strokeOpacity: 0 },
        ],
        { duration: 500, easing: "ease-out", fill: "forwards" },
      );
      // The successor inherits the color the chain ARRIVED with.
      continueChain(endX, endY, path.startTrace, factor, colorTo, surviveChance);
    }, headArrival - skip);

    window.setTimeout(() => {
      group.remove();
    }, duration + 620 - skip);
  }

  // Every signal is mortal: on each via hop it rolls against its chain's
  // survival rate (hopSurvival for ambient chains, burstSurvival for
  // click-born ones — passed in so a chain keeps its own decay for life).
  // Survivors continue synchronously from a nearby via, inheriting speed and
  // color (arrive blue → depart blue); the rest just die with their arrival
  // blip. Replenishment is NOT done here — the ambient spawn timer refills
  // the population near the cursor, so a death simply lowers the census and
  // the next tick fountains a fresh signal in. Low survival => most signals
  // die after one leg => the fountain stays busy near the pointer.
  function continueChain(endX, endY, excludeTrace, speedFactor, chainColor, survival) {
    if (!config.signals || reducedMotion || document.hidden) return;
    if (random() >= (survival ?? config.hopSurvival)) return;
    if (activeSignals >= config.maxBurstSignals) return;
    spawnSignal(nearbyEndpointStarts(endX, endY, excludeTrace), 0, speedFactor, chainColor, survival);
  }

  // preferred: array of nearby launch pads tried in order (short legs are
  // fine for chain continuations); falls back to random picks elsewhere.
  function spawnSignal(preferred, phase = 0, speedFactor, chainColor, survival) {
    if (!config.signals || reducedMotion) return false;
    if (activeSignals >= config.maxBurstSignals) return false;
    const preferredCount = preferred ? preferred.length : 0;
    const candidates = preferred ? [...preferred] : [];
    for (let attempt = 0; attempt < 8; attempt += 1) {
      const start = pickStart();
      if (start) candidates.push(start);
    }
    for (let index = 0; index < candidates.length; index += 1) {
      const minLength = index < preferredCount ? 60 : 140;
      const path = buildSignalPath(tile, candidates[index], 450 + random() * 900, minLength);
      if (path) {
        path.startTrace = candidates[index].trace;
        launchSignal(path, phase, speedFactor, chainColor, survival);
        return true;
      }
    }
    return false;
  }

  // Fill the population up to maxSignals with fast-forwarded signals, so a
  // fresh board (or a tab returning to view after its chains drained) is
  // already mid-journey instead of visibly cold-starting.
  function prewarm() {
    if (!config.signals || reducedMotion || document.hidden) return;
    let guard = 0;
    while (activeSignals < config.maxSignals && guard < config.maxSignals + 2) {
      guard += 1;
      if (!spawnSignal(null, random() * 0.85)) break;
    }
  }

  // The passive fountain, like the reference site: a steady cadence of new
  // signals near the cursor (pickStart biases toward the pointer when it moved
  // recently, spreads across the board when idle), refilling toward
  // maxSignals. Because signals are mortal, the census keeps dipping below the
  // target and the timer keeps topping it up — so fresh signals keep appearing
  // at the pointer. The balance the user wants is fast spawn + short life:
  // a small signalInterval with a low hopSurvival gives a lively cursor
  // fountain; long life lets the population sit at the cap and spawn rarely.
  function startSignals() {
    window.clearInterval(signalTimer);
    if (!config.signals || reducedMotion) return;
    prewarm();
    signalTimer = window.setInterval(() => {
      if (!document.hidden && activeSignals < config.maxSignals) spawnSignal(null);
    }, config.signalInterval);
  }

  function burstAt(x, y) {
    const { dense } = ensureSignalIndex(tile);
    const candidates = [];
    const baseTileX = Math.floor(x / tile.width);
    const baseTileY = Math.floor(y / tile.height);
    for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {
      for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {
        if (tileX < -1 || tileX > tilesAcross || tileY < -1 || tileY > tilesDown) continue;
        const localX = x - tileX * tile.width;
        const localY = y - tileY * tile.height;
        for (let trace = 0; trace < dense.length; trace += 1) {
          let bestPoint = -1;
          let bestDistance = 40;
          for (let point = 0; point < dense[trace].length; point += 1) {
            const distance = Math.hypot(dense[trace][point][0] - localX, dense[trace][point][1] - localY);
            if (distance < bestDistance) {
              bestDistance = distance;
              bestPoint = point;
            }
          }
          if (bestPoint >= 0) candidates.push({ trace, tileX, tileY, point: bestPoint, distance: bestDistance });
        }
      }
    }
    candidates.sort((a, b) => a.distance - b.distance);
    let spawned = 0;
    for (const candidate of candidates.slice(0, Math.ceil(config.burstBoost / 2) + 1)) {
      for (const direction of [1, -1]) {
        if (activeSignals >= config.maxBurstSignals || spawned >= config.burstBoost) return;
        const path = buildSignalPath(
          tile,
          { trace: candidate.trace, tileX: candidate.tileX, tileY: candidate.tileY, point: candidate.point, direction },
          260 + random() * 380,
          50,
        );
        if (path) {
          path.startTrace = candidate.trace;
          // Clicked chains decay at burstSurvival, independent of the ambient
          // hopSurvival, so a click can ripple long even when passive life is short.
          launchSignal(path, 0, undefined, undefined, config.burstSurvival);
          spawned += 1;
        }
      }
    }
  }

  function onPointerMove(event) {
    const rect = mount.getBoundingClientRect();
    pointer = {
      x: (event.clientX - rect.left) / config.scale,
      y: (event.clientY - rect.top) / config.scale - topOffset,
      at: performance.now(),
    };
  }

  function onClick(event) {
    if (!config.interactive || !config.signals || reducedMotion) return;
    const rect = mount.getBoundingClientRect();
    burstAt(
      (event.clientX - rect.left) / config.scale,
      (event.clientY - rect.top) / config.scale - topOffset,
    );
  }

  function onVisibility() {
    if (!document.hidden) prewarm();
  }

  function regenerate(nextOptions = {}) {
    config = { ...config, ...nextOptions };
    buildSvg();
    startSignals();
    return api;
  }

  function exportSvg() {
    const clone = svg.cloneNode(true);
    clone.querySelectorAll("g[fill='none'] > g").forEach((node) => node.remove());
    clone.removeAttribute("style");
    clone.setAttribute("width", String(tilesAcross * tile.width * config.scale));
    clone.setAttribute("height", String(tilesDown * tile.height * config.scale));
    return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;
  }

  function exportTileSvg(tileOptions = {}) {
    return tileSvg(tile, { ...config, ...tileOptions });
  }

  function destroy() {
    epoch += 1;
    window.clearInterval(signalTimer);
    document.removeEventListener("visibilitychange", onVisibility);
    resizeObserver?.disconnect();
    interactionTarget.removeEventListener("pointermove", onPointerMove);
    interactionTarget.removeEventListener("click", onClick);
    svg?.remove();
    if (computedPosition === "static") mount.style.position = originalPosition;
  }

  const api = {
    regenerate,
    spawnSignal,
    exportSvg,
    exportTileSvg,
    destroy,
    get tile() {
      return tile;
    },
  };
  buildSvg();
  resizeObserver = new ResizeObserver(layout);
  resizeObserver.observe(mount);
  if (config.interactive) {
    interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });
    interactionTarget.addEventListener("click", onClick);
  }
  // Hidden tabs let the census drain (the timer no-ops); refill on return.
  document.addEventListener("visibilitychange", onVisibility);
  startSignals();
  return api;
}

// Named animation presets. Each is a partial config merged over the current
// settings; only signal/animation knobs are touched so generation geometry is
// left alone. `signals: false` gives a fully static board.
const ANIMATION_PRESETS = Object.freeze({
  "None": { signals: false },
  "Fireflies": {
    // A few long-lived signals drifting/bouncing across the whole screen.
    signals: true,
    signalInterval: 850,
    maxSignals: 5,
    spawnRadius: 200,
    hopSurvival: 0.92,
    burstSurvival: 0.9,
    colorDrift: 0.12,
  },
  "Swarm": {
    // Same persistent drift, but a dense population.
    signals: true,
    signalInterval: 360,
    maxSignals: 18,
    spawnRadius: 200,
    hopSurvival: 0.9,
    burstSurvival: 0.9,
    colorDrift: 0.18,
  },
  "Cursor sparks": {
    // Really short life + very fast spawning tight around the cursor.
    signals: true,
    signalInterval: 90,
    maxSignals: 14,
    spawnRadius: 35,
    hopSurvival: 0.1,
    burstSurvival: 0.6,
    colorDrift: 0.15,
  },
  "Slow streams": {
    // Longer-lived flowing chains at a relaxed cadence.
    signals: true,
    signalInterval: 560,
    maxSignals: 8,
    spawnRadius: 130,
    hopSurvival: 0.72,
    burstSurvival: 0.82,
    colorDrift: 0.2,
  },
});

export { DEFAULTS, ANIMATION_PRESETS };