master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779
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,
  // --- reveal: the board etching itself in instead of appearing whole ----
  reveal: "flood", // off | etch | flood | sweep | bloom | scatter
  revealDuration: 3400, // ms from the first trace starting to the last one landing
  revealSpeed: 420, // units/sec a tip travels — the same pace as signalSpeed
  revealHead: 46, // units of lit copper behind the tip (a signal's head is 120)
  revealBlip: 500, // ms for a pad to land and ring out, matching the signal blip
  revealSpark: true, // white-hot tip riding the growing end of each trace
  // Copper reads as COOLING METAL, not a lit wire: a near-white tip, the zone's
  // colour just behind it, then an exponential fade to a dim settled trace.
  // Growth is coloured by a smooth screen-space field over this ramp, so the
  // board comes in as broad regions of hue instead of one flat neon sheet.
  revealPalette: ["#00e6dc", "#64abff", "#c5b3ff"], // the signal palette, exactly
  revealTip: "#e8fffd", // the hottest point, at the growing end
  revealZone: 380, // px scale of the colour regions (bigger = broader zones)
  revealSwell: 1.35, // how much fatter copper is while hot
  revealBloom: 0.18, // opacity of the wide glow around the tip
  // ms time constant for laid copper to fade to settled. 25 is the shortest the
  // engine can honour — the tail's tightening floors at 24 — and leaves a wake
  // of roughly a tip-length behind each route rather than a warm board.
  revealCool: 25,
  revealSettle: 1, // how far settled copper sits toward the board colour; 1 =
  // exactly the artwork, which is what makes the handoff invisible. Lower it
  // for a tinted board while it grows, at the cost of a visible swap.
  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;
}

// --- reveal choreography -----------------------------------------------------
//
// Instead of the finished board appearing at once, every trace can be hidden
// behind its own stroke-dash and then GROWN out of one of its end vias, with a
// bright spark riding the tip and each via lighting up as the tip reaches it.
// Because the geometry lives once in <defs> and every screen tile is a <use>
// of it, one animation per trace paints the growth across the whole background
// — ~235 animations, not 235 x (tiles on screen).
//
// A style only chooses the ORDER traces start in; the drawing itself is the
// same everywhere. Ranks come from SORTING the metric, not the raw value, so
// ink flows at an even rate no matter how lopsided the metric is.

const REVEAL_CELL = 12; // adjacency bucket for the flood front (~1.5 pitches)
const REVEAL_SAMPLE = 6; // polyline sampling step when bucketing
const easeOut = (t) => 1 - (1 - t) * (1 - t) * (1 - t);
const smoothstep = (t) => t * t * (3 - 2 * t);
const revealGraphCache = new WeakMap();

const clamp = (value, low, high) => (value < low ? low : value > high ? high : value);

// Distance along the trace at which each via sits, so a pad can light up
// exactly when the growing tip passes it (inline vias land mid-segment
// because collinear runs are merged into a single point pair).
function viaDistances(trace) {
  const points = trace.points;
  if (points.length < 2) return trace.vias.map(() => 0);
  const cumulative = [0];
  for (let index = 1; index < points.length; index += 1) {
    cumulative.push(
      cumulative[index - 1] +
        Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]),
    );
  }
  return trace.vias.map(([viaX, viaY]) => {
    let bestDistance = Infinity;
    let bestAlong = 0;
    for (let index = 1; index < points.length; index += 1) {
      const [ax, ay] = points[index - 1];
      const [bx, by] = points[index];
      const dx = bx - ax;
      const dy = by - ay;
      const span = dx * dx + dy * dy || 1;
      const t = clamp(((viaX - ax) * dx + (viaY - ay) * dy) / span, 0, 1);
      const px = ax + dx * t;
      const py = ay + dy * t;
      const distance = Math.hypot(px - viaX, py - viaY);
      if (distance < bestDistance) {
        bestDistance = distance;
        bestAlong = cumulative[index - 1] + Math.hypot(px - ax, py - ay);
      }
    }
    return bestAlong;
  });
}

function sampleTrace(trace, step, visit) {
  const points = trace.points;
  if (points.length < 2) {
    for (const [x, y] of trace.vias) visit(x, y);
    if (points.length === 1) visit(points[0][0], points[0][1]);
    return;
  }
  for (let index = 1; index < points.length; index += 1) {
    const [ax, ay] = points[index - 1];
    const [bx, by] = points[index];
    const steps = Math.max(1, Math.round(Math.hypot(bx - ax, by - ay) / step));
    for (let s = index === 1 ? 0 : 1; s <= steps; s += 1) {
      visit(ax + ((bx - ax) * s) / steps, ay + ((by - ay) * s) / steps);
    }
  }
}

const wrapIndex = (value, modulus) => ((value % modulus) + modulus) % modulus;

// Traces never touch (the router is crossing-free), so "connected" here means
// physically adjacent: sharing a coarse bucket with a neighbor. Buckets wrap
// on the torus, so the flood front crosses the tile seam like the traces do.
function revealAdjacency(tile) {
  let graph = revealGraphCache.get(tile);
  if (graph) return graph;
  const columns = Math.max(1, Math.round(tile.width / REVEAL_CELL));
  const rows = Math.max(1, Math.round(tile.height / REVEAL_CELL));
  const cellWidth = tile.width / columns;
  const cellHeight = tile.height / rows;
  const buckets = new Map();
  tile.traces.forEach((trace, index) => {
    sampleTrace(trace, REVEAL_SAMPLE, (x, y) => {
      const key =
        wrapIndex(Math.floor(y / cellHeight), rows) * columns +
        wrapIndex(Math.floor(x / cellWidth), columns);
      let bucket = buckets.get(key);
      if (!bucket) buckets.set(key, (bucket = new Set()));
      bucket.add(index);
    });
  });
  const neighbours = tile.traces.map(() => new Set());
  for (const [key, members] of buckets) {
    const cellX = key % columns;
    const cellY = (key - cellX) / columns;
    const near = new Set();
    for (let dy = -1; dy <= 1; dy += 1) {
      for (let dx = -1; dx <= 1; dx += 1) {
        const bucket = buckets.get(
          wrapIndex(cellY + dy, rows) * columns + wrapIndex(cellX + dx, columns),
        );
        if (bucket) for (const index of bucket) near.add(index);
      }
    }
    for (const index of members) {
      for (const other of near) if (other !== index) neighbours[index].add(other);
    }
  }
  graph = neighbours.map((set) => [...set]);
  revealGraphCache.set(tile, graph);
  return graph;
}

function torusDelta(a, b, modulus) {
  const delta = Math.abs(a - b) % modulus;
  return delta > modulus / 2 ? modulus - delta : delta;
}

// Hop count from the trace nearest the seed point, walking the adjacency
// graph — the front spreads through the routing itself, like current wetting
// the copper, rather than as a clean geometric shape.
function floodDepths(tile, anchors, seed) {
  const graph = revealAdjacency(tile);
  const depth = new Array(tile.traces.length).fill(Infinity);
  let start = 0;
  let best = Infinity;
  anchors.forEach(([x, y], index) => {
    const distance = Math.hypot(
      torusDelta(x, seed[0], tile.width),
      torusDelta(y, seed[1], tile.height),
    );
    if (distance < best) {
      best = distance;
      start = index;
    }
  });
  depth[start] = 0;
  const queue = [start];
  for (let head = 0; head < queue.length; head += 1) {
    for (const next of graph[queue[head]]) {
      if (depth[next] !== Infinity) continue;
      depth[next] = depth[queue[head]] + 1;
      queue.push(next);
    }
  }
  let deepest = 0;
  for (const value of depth) if (value !== Infinity && value > deepest) deepest = value;
  return depth.map((value) => (value === Infinity ? deepest + 1 : value));
}

// --- 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 revealCanvas; // the growth is painted here, then handed off to the SVG
  let revealCtx;
  let settledCanvas; // copper at rest — persistent, never cleared
  let settledCtx;
  let heatCanvases = []; // two generations of glow: one accumulates, one retires
  let heatContexts = [];
  let tipCanvas; // the white-hot heads and the landing pads — cleared every frame
  let tipCtx;
  let viewWidth = 0; // the growth layers' size in CSS px — the backing store
  let viewHeight = 0; // may be denser, so never measure with canvas.width
  let resizeObserver;
  let activeSignals = 0;
  let signalTimer;
  let signalStartTimer;
  let revealPlan = null;
  let revealFrame = 0;
  let revealEndsAt = 0;
  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() {
    finishReveal(); // a reveal in flight belongs to the board being replaced
    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();
    runReveal();
  }

  // Pointer position in tile coordinates — the seed the radial/flood fronts
  // start from, so a reveal triggered under the cursor blooms out of it.
  function revealSeed() {
    if (!pointer || performance.now() - pointer.at > 6000) return [tile.width / 2, tile.height / 2];
    return [
      ((pointer.x % tile.width) + tile.width) % tile.width,
      ((pointer.y % tile.height) + tile.height) % tile.height,
    ];
  }

  // --- the reveal renderer ---------------------------------------------------
  //
  // The growth is painted on a CANVAS, not by animating the SVG. The board is
  // one tile in <defs>, instanced ~25x by <use>. Chrome mirrors only DOM and
  // attribute state into those instances — Web Animations on referenced content
  // run on the original and paint nothing on screen, and writing attributes
  // every frame costs ~260ms/frame because each mutation resyncs every
  // instance. Canvas also lets the reveal run in SCREEN space, so every tile of
  // the pattern grows on its own schedule and the board arrives as one front
  // instead of the same blob repeated per tile.
  //
  // Three layers, because copper should read as cooling metal, not a lit wire:
  //
  //   settled  persistent, never cleared — the trace at its resting colour
  //   heat     persistent but faded a little every frame — the glow of recently
  //            laid copper, decaying exponentially into the settled layer
  //   tip      cleared every frame — the white-hot heads, the pads landing, and
  //            the arrival rings, all of which move
  //
  // Only the newly grown piece of each route is ever stroked into the two
  // persistent layers, so per-frame cost tracks what is moving, not the board.
  //
  // MOTION LANGUAGE — deliberately the same as the live signals, so the reveal
  // and the board's idle animation read as one system:
  //
  //   * tips travel at a CONSTANT speed (revealSpeed, in the same units/sec as
  //     signalSpeed) with linear timing — never eased, never length-scaled
  //   * the same stroke stack: ~2.4 wide core, 7 wide glow beneath it
  //   * the same palette, and the same arrival blip when a pad is reached
  //     (a ring from the pad radius out to ~8, fading over revealBlip ms)
  //
  // Nothing appears or vanishes in one frame: heads fade in as a route starts
  // and fade out after it lands, pads scale up and are only stamped into the
  // settled layer once their pop has finished, and the board itself fades out
  // at the start and back in at the end.

  const REVEAL_FADE_IN = 130; // ms for a head to reach full brightness
  const REVEAL_FADE_OUT = 260; // ms for a head to fade after its route lands
  const REVEAL_CLEAR = 260; // ms for the finished board to fade out at the start
  const REVEAL_JITTER = 70; // ms of scatter on start times, so landings never sync up
  const REVEAL_HANDOFF = 260; // ms to dissolve the (by then heat-free) canvas
  const REVEAL_CATCHUP = 40; // ms of growth a route may be 'born' with after a stall

  function ensureRevealCanvas() {
    if (revealCanvas) return;
    revealCanvas = document.createElement("canvas");
    Object.assign(revealCanvas.style, {
      position: "absolute",
      left: "0",
      top: "0",
      display: "block",
      pointerEvents: "none",
    });
    revealCtx = revealCanvas.getContext("2d");
    settledCanvas = document.createElement("canvas");
    settledCtx = settledCanvas.getContext("2d");
    heatCanvases = [document.createElement("canvas"), document.createElement("canvas")];
    heatContexts = heatCanvases.map((canvas) => canvas.getContext("2d"));
    tipCanvas = document.createElement("canvas");
    tipCtx = tipCanvas.getContext("2d");
    mount.prepend(revealCanvas);
  }

  // The growth layers run at CSS resolution: they are a transient overlay, and
  // full-screen backing stores at devicePixelRatio would cost four times the
  // memory for detail the cool-down immediately replaces with the crisp SVG.
  function sizeRevealCanvas() {
    const width = Math.max(1, mount.clientWidth);
    const height = Math.max(1, mount.clientHeight);
    // Match the display's density (capped, since these are full-screen buffers)
    // or the growth layer hands a soft board over to a crisp SVG.
    const ratio = clamp(window.devicePixelRatio || 1, 1, 2);
    for (const canvas of [revealCanvas, settledCanvas, ...heatCanvases, tipCanvas]) {
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      // Draw in CSS pixels; the extra density is just resolution.
      canvas.getContext("2d").setTransform(ratio, 0, 0, ratio, 0, 0);
    }
    revealCanvas.style.width = `${width}px`;
    revealCanvas.style.height = `${height}px`;
    viewWidth = width;
    viewHeight = height;
  }

  // --- colour ----------------------------------------------------------------

  const parseHex = (hex) => [
    parseInt(hex.slice(1, 3), 16),
    parseInt(hex.slice(3, 5), 16),
    parseInt(hex.slice(5, 7), 16),
  ];

  function mixRgb(from, to, t) {
    return [
      Math.round(from[0] + (to[0] - from[0]) * t),
      Math.round(from[1] + (to[1] - from[1]) * t),
      Math.round(from[2] + (to[2] - from[2]) * t),
    ];
  }

  const rgbCss = (rgb) => `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
  const rgbaCss = (rgb, alpha) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha.toFixed(3)})`;

  // Position on the palette ramp, 0..1 across all its stops.
  function rampAt(stops, t) {
    const span = (stops.length - 1) * clamp(t, 0, 1);
    const index = Math.min(stops.length - 2, Math.floor(span));
    return mixRgb(stops[index], stops[index + 1], span - index);
  }

  // A smooth screen-space value-noise field: neighbouring routes land on
  // similar values, so hue drifts in broad regions rather than per trace.
  function makeZoneField(width, height, cell, random) {
    const columns = Math.max(2, Math.ceil(width / cell) + 2);
    const rows = Math.max(2, Math.ceil(height / cell) + 2);
    const values = Array.from({ length: columns * rows }, () => random());
    const smooth = (t) => t * t * (3 - 2 * t);
    return (x, y) => {
      const gx = clamp(x / cell + 0.5, 0, columns - 1.001);
      const gy = clamp(y / cell + 0.5, 0, rows - 1.001);
      const x0 = Math.floor(gx);
      const y0 = Math.floor(gy);
      const fx = smooth(gx - x0);
      const fy = smooth(gy - y0);
      const at = (cx, cy) => values[cy * columns + cx];
      const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * fx;
      const bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * fx;
      return top + (bottom - top) * fy;
    };
  }

  // --- geometry --------------------------------------------------------------

  function segmentBetween(geometry, from, to, offsetX, offsetY) {
    const { points, cumulative } = geometry;
    const out = [];
    for (let index = 1; index < points.length; index += 1) {
      const start = cumulative[index - 1];
      const end = cumulative[index];
      if (end < from || start > to) continue;
      const span = end - start || 1;
      const lerp = (at) => [
        offsetX + points[index - 1][0] + ((points[index][0] - points[index - 1][0]) * (at - start)) / span,
        offsetY + points[index - 1][1] + ((points[index][1] - points[index - 1][1]) * (at - start)) / span,
      ];
      if (!out.length) out.push(lerp(Math.max(from, start)));
      out.push(lerp(Math.min(to, end)));
    }
    return out;
  }

  function strokePolyline(ctx, points) {
    if (points.length < 2) return;
    ctx.beginPath();
    ctx.moveTo(points[0][0], points[0][1]);
    for (let index = 1; index < points.length; index += 1) {
      ctx.lineTo(points[index][0], points[index][1]);
    }
    ctx.stroke();
  }

  // Tile geometry in canvas pixels, shared by every instance of the tile.
  function buildRevealGeometry() {
    const scale = config.scale;
    return tile.traces.map((trace) => {
      const points = trace.points.map(([x, y]) => [x * scale, y * scale]);
      const cumulative = [0];
      for (let index = 1; index < points.length; index += 1) {
        cumulative.push(
          cumulative[index - 1] +
            Math.hypot(
              points[index][0] - points[index - 1][0],
              points[index][1] - points[index - 1][1],
            ),
        );
      }
      const anchor = trace.points[0] || trace.vias[0] || [0, 0];
      return {
        points,
        cumulative,
        length: cumulative[cumulative.length - 1] || 0,
        drawable: points.length >= 2,
        vias: trace.vias.map(([x, y]) => [x * scale, y * scale]),
        viaAt: viaDistances(trace).map((at) => at * scale),
        anchor: [anchor[0] * scale, anchor[1] * scale],
      };
    });
  }

  function finishReveal() {
    if (revealFrame) window.cancelAnimationFrame(revealFrame);
    revealFrame = 0;
    revealPlan = null;
    revealEndsAt = 0;
    if (svg) svg.style.opacity = "";
    if (revealCanvas) {
      revealCtx.clearRect(0, 0, viewWidth, viewHeight);
      revealCanvas.style.display = "none";
      // hand the backing stores back; runReveal sizes them again
      for (const canvas of [settledCanvas, ...heatCanvases, tipCanvas]) {
        canvas.width = 0;
        canvas.height = 0;
      }
    }
  }

  // Where the front starts: the pointer if it moved recently, else the middle.
  function revealFocus() {
    if (!pointer || performance.now() - pointer.at > 6000) {
      return [viewWidth / 2, viewHeight * 0.42];
    }
    return [pointer.x * config.scale, (pointer.y + topOffset) * config.scale];
  }

  // The stretch of a run between two arc lengths, in the direction it grows.
  function runPiece(run, from, to) {
    const length = run.geometry.length;
    return run.reverse
      ? segmentBetween(run.geometry, length - to, length - from, run.x, run.y)
      : segmentBetween(run.geometry, from, to, run.x, run.y);
  }

  // The lit stretch behind a route's leading edge — the same shape as a
  // signal's head, a fixed length travelling at a fixed speed.
  function headPiece(run, at, share = 1) {
    const geometry = run.geometry;
    const head = config.revealHead * config.scale * share;
    return run.reverse
      ? segmentBetween(
          geometry,
          geometry.length - at,
          geometry.length - Math.max(0, at - head),
          run.x,
          run.y,
        )
      : segmentBetween(geometry, Math.max(0, at - head), at, run.x, run.y);
  }

  function tickReveal(now) {
    const plan = revealPlan;
    if (!plan) return;
    const time = now - plan.start;
    const elapsed = Math.min(64, now - plan.frameAt);
    plan.frameAt = now;

    // A route starts growing when it is first DRAWN, not at the wall-clock
    // moment it was scheduled. Deriving growth from the schedule means any slow
    // frame — a GC pause, a busy tab, a laptop waking up — releases every route
    // whose start time has passed ALREADY FULLY GROWN: they pop into existence
    // instead of animating. Clamping the catch-up costs nothing when frames are
    // healthy (time == delay) and simply slides the schedule when they are not.
    while (plan.next < plan.instances.length && plan.instances[plan.next].delay <= time) {
      const entering = plan.instances[plan.next];
      entering.startedAt = Math.max(entering.delay, time - REVEAL_CATCHUP);
      entering.viaNext = 0; // its pads are timed off the same start
      plan.live.add(entering);
      plan.next += 1;
    }

    // The glow accumulates in a buffer and is faded a little every frame — one
    // fill, regardless of how much copper is warm. But a multiplicative fade
    // never reaches zero: at 8 bits a pixel stops moving as soon as
    // alpha x decay rounds back to itself (at a 150ms fade it sticks at 9/255 —
    // 3.5% of the hot colour sitting on the copper forever, which is why the
    // growing board read lighter and bluer than the finished one), and canvas
    // has no subtractive alpha op.
    //
    // So the buffer is kept in two GENERATIONS. New glow always goes into the
    // current one; the previous one keeps decaying but is also composited
    // through a falling ramp, and once that ramp reaches zero the buffer is
    // cleared outright — a multiply by zero, applied at the moment it changes
    // nothing on screen. Every pixel therefore ends at exactly zero, and the
    // per-frame cost stays independent of how much of the board is glowing.
    if (
      plan.allLandedAt === undefined &&
      plan.next >= plan.instances.length &&
      plan.live.size === 0
    ) {
      plan.allLandedAt = time + config.revealBlip; // let the last pads ring out
      // a slid schedule moves the real end, and the signals wait on that
      revealEndsAt =
        plan.start + Math.max(plan.last, plan.allLandedAt) + plan.handoff + REVEAL_HANDOFF;
    }
    const tail = time - Math.max(plan.last, plan.allLandedAt ?? Infinity);
    const heatOut = tail > 0 ? clamp(tail / plan.handoff, 0, 1) : 0;
    // during the tail the time constant tightens, so whatever glow is left
    // finishes cooling instead of being cut off by the handoff
    const constant = Math.max(24, config.revealCool * (1 - 0.92 * heatOut));
    const fade = 1 - Math.exp(-elapsed / constant);
    for (const ctx of plan.heat) {
      ctx.globalCompositeOperation = "destination-out";
      ctx.fillStyle = `rgba(0,0,0,${fade})`;
      ctx.fillRect(0, 0, viewWidth, viewHeight);
      ctx.globalCompositeOperation = "source-over";
    }

    // Rotate generations once the older one is contributing nothing. The window
    // is several time constants wide: by the time a pixel's generation starts
    // ramping out, its own decay has it near zero anyway, so the ramp costs no
    // visible wake — it only guarantees the buffer can be cleared outright.
    const span = clamp(constant * 4, 300, 8000);
    let phase = clamp((time - plan.heatSwappedAt) / span, 0, 1);
    if (phase >= 1) {
      const retiring = plan.heat[1 - plan.heatIndex];
      retiring.clearRect(0, 0, viewWidth, viewHeight); // x0, and invisible by now
      plan.heatIndex = 1 - plan.heatIndex;
      plan.heatSwappedAt = time;
      phase = 0;
    }
    const heatCtx = plan.heat[plan.heatIndex];

    // Copper is laid at a constant speed, exactly like a signal travelling a
    // trace: linear, never eased, never scaled by the route's length.
    //
    // Only the GLOW is stroked incrementally — it is transient, so its edges
    // blending over each other costs nothing. The copper that SURVIVES is not:
    // a route is stamped into the persistent layer exactly once, when it lands,
    // with the artwork's own butt caps and miter joins. Laying it in per-frame
    // pieces blended each piece's antialiased edge over the last one, which made
    // the finished canvas board ~1% denser than the SVG it hands over to — not a
    // colour difference, but enough extra weight to read as one.
    settledCtx.lineWidth = config.traceWidth * config.scale;
    settledCtx.lineCap = "butt";
    settledCtx.lineJoin = "miter";
    heatCtx.lineWidth = config.traceWidth * config.revealSwell * config.scale;
    heatCtx.lineCap = "round";
    heatCtx.lineJoin = "round";
    for (const run of plan.live) {
      const geometry = run.geometry;
      const grown = Math.min(geometry.length, (time - run.startedAt) * plan.speed);
      if (grown > run.drawn) {
        heatCtx.strokeStyle = run.hot;
        strokePolyline(heatCtx, runPiece(run, run.drawn, grown));
        run.drawn = grown;
      }
      if (grown >= geometry.length) {
        settledCtx.strokeStyle = run.settled;
        strokePolyline(settledCtx, runPiece(run, 0, geometry.length));
        run.landedAt = run.startedAt + geometry.length / plan.speed;
        plan.live.delete(run);
        plan.fading.add(run); // its head fades out instead of vanishing
      }
    }
    for (const run of plan.fading) {
      if (time - run.landedAt > REVEAL_FADE_OUT) plan.fading.delete(run);
    }

    // Pads land as the tip passes them — timed against the route's actual
    // start, so a route that began late does not sprinkle its pads early.
    for (const run of [...plan.live, ...plan.fading]) {
      const pads = run.geometry.pads;
      while (run.viaNext < pads.length && run.startedAt + pads[run.viaNext].offset <= time) {
        const pad = pads[run.viaNext];
        plan.popping.add({
          x: run.x + pad.x,
          y: run.y + pad.y,
          at: run.startedAt + pad.offset,
          hot: run.hot,
          settled: run.settled,
        });
        run.viaNext += 1;
      }
    }
    // Pad-only formations have no route to follow, so they keep their schedule.
    while (plan.nextVia < plan.vias.length && plan.vias[plan.nextVia].at <= time) {
      plan.popping.add(plan.vias[plan.nextVia]);
      plan.nextVia += 1;
    }

    tipCtx.clearRect(0, 0, viewWidth, viewHeight);
    tipCtx.lineCap = "round";
    tipCtx.lineJoin = "round";

    // Copper of routes still growing, redrawn whole each frame — never in
    // per-frame pieces, whose antialiased edges would blend over each other.
    // `destination-over` puts it BEHIND the glow already in the buffer: a
    // growing trace has to read as hot metal, and copper painted over its own
    // glow reads as an outline. Redrawing also tops it back up against the
    // buffer's fade, so it holds full strength until it lands.
    heatCtx.globalCompositeOperation = "destination-over";
    heatCtx.lineWidth = config.traceWidth * config.scale;
    heatCtx.lineCap = "round";
    for (const run of plan.live) {
      heatCtx.strokeStyle = run.settled;
      strokePolyline(heatCtx, runPiece(run, 0, run.drawn));
    }
    heatCtx.globalCompositeOperation = "source-over";

    // Heads: a wide soft glow and a white-hot core, matching a signal's stack.
    // Alpha eases in as a route starts and out after it lands, so no head ever
    // appears or disappears in a single frame.
    const brightness = (run) =>
      run.landedAt === undefined
        ? clamp((time - run.startedAt) / REVEAL_FADE_IN, 0, 1)
        : 1 - clamp((time - run.landedAt) / REVEAL_FADE_OUT, 0, 1);
    if (config.revealSpark) {
      for (const pass of [
        { width: 7 * config.scale, alpha: config.revealBloom, colour: null },
        { width: 2.4 * config.scale, alpha: 0.85, colour: config.revealTip, share: 0.45 },
      ]) {
        tipCtx.lineWidth = pass.width;
        if (pass.colour) tipCtx.strokeStyle = pass.colour;
        for (const run of [...plan.live, ...plan.fading]) {
          const alpha = pass.alpha * brightness(run);
          if (alpha <= 0.01) continue;
          tipCtx.globalAlpha = alpha;
          if (!pass.colour) tipCtx.strokeStyle = run.hot;
          strokePolyline(tipCtx, headPiece(run, run.drawn, pass.share || 1));
        }
      }
      tipCtx.globalAlpha = 1;
    }

    // A pad grows into place and rings out — the same arrival blip the live
    // signals use — and is only stamped into the settled layer once it has
    // finished, so it never pops.
    const viaRadius = config.viaOuterRadius * config.scale;
    const holeRadius = config.viaInnerRadius * config.scale;
    for (const via of plan.popping) {
      const progress = clamp((time - via.at) / config.revealBlip, 0, 1);
      const pop = clamp((time - via.at) / (config.revealBlip * 0.42), 0, 1);
      const eased = easeOut(pop);
      tipCtx.globalAlpha = eased;
      tipCtx.fillStyle = via.hot;
      tipCtx.beginPath();
      tipCtx.arc(via.x, via.y, viaRadius * (0.25 + 0.75 * eased), 0, Math.PI * 2);
      tipCtx.fill();
      // The pad is annular from the first frame: drilling the hole only when
      // the pop finished made every via change shape as it landed.
      tipCtx.globalCompositeOperation = "destination-out";
      tipCtx.beginPath();
      tipCtx.arc(via.x, via.y, holeRadius * (0.25 + 0.75 * eased), 0, Math.PI * 2);
      tipCtx.fill();
      tipCtx.globalCompositeOperation = "source-over";
      // The ring: from the pad out to ~8 units, fading, ease-out over revealBlip.
      const ring = easeOut(progress);
      tipCtx.globalAlpha = 0.6 * (1 - progress) * Math.min(1, progress * 6);
      tipCtx.lineWidth = 1.5 * config.scale;
      tipCtx.strokeStyle = via.hot;
      tipCtx.beginPath();
      tipCtx.arc(via.x, via.y, (config.viaOuterRadius + 5 * ring) * config.scale, 0, Math.PI * 2);
      tipCtx.stroke();
      if (progress >= 1) {
        settledCtx.fillStyle = via.settled;
        settledCtx.beginPath();
        settledCtx.arc(via.x, via.y, viaRadius, 0, Math.PI * 2);
        settledCtx.fill();
        settledCtx.fillStyle = plan.hole;
        settledCtx.beginPath();
        settledCtx.arc(via.x, via.y, holeRadius, 0, Math.PI * 2);
        settledCtx.fill();
        plan.popping.delete(via);
      }
    }
    tipCtx.globalAlpha = 1;

    // The board fades out under the growth at the start. At the end the REAL
    // board comes up to full opacity the instant growth finishes — invisibly,
    // under a canvas that is still opaque and holds the same copper in the same
    // colour — and only then does the canvas leave. Cross-fading the two
    // against each other instead dips the copper to ~75% mid-fade (alpha
    // compositing is not additive), which is what read as a hard swap.
    const opening = clamp(time / REVEAL_CLEAR, 0, 1);
    const settleOut = clamp((tail - plan.handoff) / REVEAL_HANDOFF, 0, 1);
    const canvasAlpha = 1 - smoothstep(settleOut);
    revealCtx.clearRect(0, 0, viewWidth, viewHeight);
    // Bottom to top: landed copper, the retiring glow generation on its way out,
    // the current one, then the heads and pads. The heat buffers are not
    // decoration — a route's copper lives ONLY there until it lands, so leaving
    // them out of this composite makes every route invisible while it grows and
    // then appear whole the instant it is stamped into the settled layer.
    revealCtx.globalAlpha = canvasAlpha;
    revealCtx.drawImage(settledCanvas, 0, 0, viewWidth, viewHeight);
    revealCtx.globalAlpha = canvasAlpha * (1 - smoothstep(phase));
    revealCtx.drawImage(plan.heatCanvas[1 - plan.heatIndex], 0, 0, viewWidth, viewHeight);
    revealCtx.globalAlpha = canvasAlpha;
    revealCtx.drawImage(plan.heatCanvas[plan.heatIndex], 0, 0, viewWidth, viewHeight);
    revealCtx.drawImage(tipCanvas, 0, 0, viewWidth, viewHeight);
    revealCtx.globalAlpha = 1;
    if (svg) svg.style.opacity = tail > 0 ? "1" : `${1 - opening}`;

    if (settleOut >= 1) {
      finishReveal();
      return;
    }
    revealFrame = window.requestAnimationFrame(tickReveal);
  }

  // Hide the finished board and grow it back in.
  function runReveal() {
    finishReveal();
    if (config.reveal === "off" || reducedMotion || !tile.traces.length) return;
    ensureRevealCanvas();
    sizeRevealCanvas();
    revealCanvas.style.display = "block";
    for (const ctx of [settledCtx, ...heatContexts, tipCtx]) {
      ctx.clearRect(0, 0, viewWidth, viewHeight);
    }

    const revealRandom = mulberry32(hashSeed(`${config.seed}:reveal:${config.reveal}`));
    const geometry = buildRevealGeometry();
    const depths =
      config.reveal === "flood"
        ? floodDepths(
            tile,
            tile.traces.map((trace) => trace.points[0] || trace.vias[0] || [0, 0]),
            revealSeed(),
          )
        : null;
    const [focusX, focusY] = revealFocus();
    const tileWidth = tile.width * config.scale;
    const tileHeight = tile.height * config.scale;
    const hop = tile.pitch * config.scale * 3; // how far one flood hop reaches

    const ramp = config.revealPalette.map(parseHex);
    const board = parseHex(config.traceColor);
    const zoneAt = makeZoneField(viewWidth, viewHeight, config.revealZone, revealRandom);

    // One run per (route x screen tile): the whole visible background, each
    // piece scheduled and coloured by where it actually sits on screen.
    const runs = [];
    for (let row = -1; row <= tilesDown; row += 1) {
      for (let column = -1; column <= tilesAcross; column += 1) {
        const x = column * tileWidth;
        const y = row * tileHeight + topOffset * config.scale;
        if (x > viewWidth || y > viewHeight) continue;
        if (x + tileWidth < 0 || y + tileHeight < 0) continue;
        geometry.forEach((entry, index) => {
          // Clamped to the visible area: the ring of tiles that only exists to
          // paint wrap-around copper sits off screen, and scheduling it by its
          // true position spends the first beat of the reveal out of frame.
          const anchorX = clamp(x + entry.anchor[0], 0, viewWidth);
          const anchorY = clamp(y + entry.anchor[1], 0, viewHeight);
          let metric;
          if (config.reveal === "sweep") {
            metric = anchorX * 0.94 + anchorY * 0.34;
          } else if (config.reveal === "scatter") {
            metric = revealRandom() * 1e6;
          } else if (config.reveal === "etch") {
            // The router's own passes, replayed everywhere at once.
            metric = index * 1e3 + Math.hypot(anchorX - focusX, anchorY - focusY) * 0.05;
          } else if (config.reveal === "flood") {
            // Outward through the copper: distance from the seed, nudged by how
            // many hops of routing separate this trace from its tile's seed.
            metric = Math.hypot(anchorX - focusX, anchorY - focusY) + depths[index] * hop;
          } else {
            metric = Math.hypot(anchorX - focusX, anchorY - focusY);
          }
          const zone = rampAt(ramp, zoneAt(anchorX, anchorY));
          runs.push({
            geometry: entry,
            x,
            y,
            metric,
            jitter: revealRandom(),
            nudge: revealRandom(),
            hot: rgbCss(zone),
            settled: rgbCss(mixRgb(zone, board, config.revealSettle)),
          });
        });
      }
    }
    runs.sort((a, b) => a.metric - b.metric || a.jitter - b.jitter);

    // Every tip travels at the same speed, so a route's duration is simply its
    // length. The longest route sets how much of the window is left for
    // staggering starts, which keeps revealDuration honest end to end.
    const speed = (config.revealSpeed * config.scale) / 1000; // px per ms
    let longest = 0;
    for (const run of runs) longest = Math.max(longest, run.geometry.length / speed);

    // Spread start times by INK rather than by count: a third of the records
    // are pad-only formations, and pacing by count spends a third of the reveal
    // sprinkling vias with nothing drawing.
    const lengths = geometry.map((entry) => entry.length).sort((a, b) => a - b);
    const median = Math.max(1, lengths[Math.floor(lengths.length / 2)] || 1);
    const padWeight = median * 0.15;
    let total = 0;
    for (const run of runs) total += run.geometry.length + padWeight;
    const window_ = Math.max(0, config.revealDuration - longest);

    let cursor = 0;
    for (const run of runs) {
      run.duration = run.geometry.length / speed;
      run.delay = Math.max(0, (cursor / total) * window_ + (run.nudge - 0.5) * REVEAL_JITTER);
      run.reverse = run.jitter < 0.5;
      run.drawn = 0;
      run.landedAt = undefined;
      cursor += run.geometry.length + padWeight;
    }

    // Stretch the schedule so the last route lands exactly on revealDuration.
    // Reserving the longest route's travel time up front only guarantees the
    // board finishes no LATER than promised — and since the longest route
    // usually starts early, the board was landing well before the knob's time,
    // making the same setting mean different things for different seeds.
    let stretch = Infinity;
    for (const run of runs) {
      if (run.delay > 0) {
        stretch = Math.min(stretch, (config.revealDuration - run.duration) / run.delay);
      }
    }
    if (Number.isFinite(stretch) && stretch > 0) {
      for (const run of runs) run.delay *= stretch;
    }

    const vias = [];
    let last = 0;
    for (const run of runs) {
      last = Math.max(last, run.delay + run.duration);
      const drawable = run.geometry.drawable;
      const pads = drawable ? [] : null;
      run.geometry.viaAt.forEach((at, viaIndex) => {
        const along = run.geometry.length ? clamp(at / run.geometry.length, 0, 1) : 0;
        const offset = run.duration * (run.reverse ? 1 - along : along);
        if (drawable) {
          pads.push({ x: run.geometry.vias[viaIndex][0], y: run.geometry.vias[viaIndex][1], offset });
        } else {
          vias.push({
            x: run.x + run.geometry.vias[viaIndex][0],
            y: run.y + run.geometry.vias[viaIndex][1],
            at: run.delay + offset,
            hot: run.hot,
            settled: run.settled,
          });
        }
      });
      if (drawable) {
        pads.sort((a, b) => a.offset - b.offset);
        // the geometry object is shared by every screen tile, so the schedule
        // lives on the run's own copy
        run.geometry = { ...run.geometry, pads };
      }
    }
    vias.sort((a, b) => a.at - b.at);

    revealPlan = {
      start: performance.now(),
      frameAt: performance.now(),
      // Sorted by DELAY, not by metric. The release loop walks this queue with a
      // single cursor, so an unsorted queue head-of-line blocks: jitter is ~80x
      // wider than the gap between neighbouring start times, which left 84% of
      // the queue out of order and held routes back up to 71ms behind the head,
      // then let them out in one burst — the opposite of what the jitter is for.
      instances: runs.filter((run) => run.geometry.drawable).sort((a, b) => a.delay - b.delay),
      vias,
      next: 0,
      nextVia: 0,
      live: new Set(),
      fading: new Set(),
      popping: new Set(),
      heat: heatContexts,
      heatCanvas: heatCanvases,
      heatIndex: 0,
      heatSwappedAt: 0,
      speed,
      // Long enough for the remaining glow to finish cooling as glow, never a
      // wait. The floor used to be 260ms, which at a very short heat fade left
      // the finished board sitting there for ~150ms with nothing left to cool.
      handoff: clamp(config.revealCool * 1.6, 120, 1100),
      // the SVG fills via holes with var(--circuit-hole); read the same value
      // rather than assuming the default
      hole:
        getComputedStyle(mount).getPropertyValue("--circuit-hole").trim() ||
        config.holeColor ||
        "#0d1117",
      // The last pad still has to ring out before the board settles.
      last: last + config.revealBlip,
    };
    revealEndsAt = revealPlan.start + revealPlan.last + revealPlan.handoff + REVEAL_HANDOFF;
    revealFrame = window.requestAnimationFrame(tickReveal);
  }

  function layout() {
    // The observer also fires when the box did not actually change; only a real
    // resize should interrupt the growth (the copper already laid would no
    // longer line up), and it settles rather than restarting.
    if (
      revealPlan &&
      (Math.abs(mount.clientWidth - viewWidth) > 2 || Math.abs(mount.clientHeight - viewHeight) > 2)
    ) {
      finishReveal();
    }
    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.
  // warm: fast-forward a full population in (right for a cold page). After a
  // reveal it is passed false, so the fountain fills from empty at its own
  // cadence instead of a dozen signals appearing mid-flight the instant the
  // board settles.
  function startSignals(warm = true) {
    window.clearInterval(signalTimer);
    window.clearTimeout(signalStartTimer);
    if (!config.signals || reducedMotion) return;
    // Signals wait for the board they ride to finish growing in — a pulse
    // running down a trace that is not there yet reads as a glitch.
    const pending = revealEndsAt - performance.now();
    if (pending > 0) {
      const myEpoch = epoch;
      signalStartTimer = window.setTimeout(() => {
        if (myEpoch === epoch) startSignals(false);
      }, pending + 80);
      return;
    }
    if (warm) 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() {
    finishReveal(); // never export a half-grown board
    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;
    finishReveal();
    revealCanvas?.remove();
    window.clearInterval(signalTimer);
    window.clearTimeout(signalStartTimer);
    document.removeEventListener("visibilitychange", onVisibility);
    resizeObserver?.disconnect();
    interactionTarget.removeEventListener("pointermove", onPointerMove);
    interactionTarget.removeEventListener("click", onClick);
    svg?.remove();
    if (computedPosition === "static") mount.style.position = originalPosition;
  }

  // Replay the reveal on the board that is already up — no re-routing, so
  // the same artwork grows back in (optionally in a different style).
  function replayReveal(style) {
    if (style) config.reveal = style;
    runReveal();
    startSignals();
    return api;
  }

  const api = {
    regenerate,
    replayReveal,
    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,
  },
});

// Named reveal styles: how the finished board grows in. Only the order and
// the pacing differ — the drawing mechanic (each trace out of an end via,
// spark on the tip, pads popping as it passes) is shared.
const REVEAL_PRESETS = Object.freeze({
  "Instant": { reveal: "off" },
  "Etch": {
    // Build order: pads and formations, then the nets between them, then
    // filler, then the tap combs — the router's own passes, replayed.
    reveal: "etch",
    revealDuration: 3200,
  },
  "Flood": {
    // The front spreads through the routing's own adjacency, so the board
    // wets outward from a seed like current finding copper.
    reveal: "flood",
    revealDuration: 3400,
  },
  "Sweep": {
    // A shallow diagonal scan line crossing the tile.
    reveal: "sweep",
    revealDuration: 3000,
  },
  "Bloom": {
    // Rings expanding from the cursor (or the tile centre when idle).
    reveal: "bloom",
    revealDuration: 3200,
  },
  "Scatter": {
    // No order at all — the whole board stipples in at once.
    reveal: "scatter",
    revealDuration: 2600,
  },
});

export { DEFAULTS, ANIMATION_PRESETS, REVEAL_PRESETS };