app
Circuit Trace Generators
Public Made by Adomby adom
Two dependency-free, seeded circuit-background generators: a seamless torus tile with ambient or progressive reveal, and the v9 continuous field with obstacles, scroll reveal, and five ambient systems.
master
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 };