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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
const SVG_NS = "http://www.w3.org/2000/svg";
const DEFAULTS = Object.freeze({
seed: "nitrogen",
pitch: 7.895,
scale: 1000 / 900,
density: 1,
traceColor: "#151b25",
traceWidth: 1.58,
viaOuterRadius: 3.34,
viaInnerRadius: 1.76,
signals: true,
interactive: true,
signalColors: [
["#00e6dc", 0.6],
["#64abff", 0.25],
["#c5b3ff", 0.15],
],
signalInterval: 480,
signalSpeed: 800,
signalLength: 150,
maxSignals: 9,
});
let instanceCount = 0;
function hashSeed(value) {
let hash = 2166136261;
for (const char of String(value)) {
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 integer(random, min, max) {
return Math.floor(random() * (max - min + 1)) + min;
}
function weighted(random, entries) {
let roll = random();
for (const [value, weight] of entries) {
roll -= weight;
if (roll <= 0) return value;
}
return entries[0][0];
}
class MinHeap {
constructor() {
this.values = [];
}
push(value) {
const values = this.values;
values.push(value);
let index = values.length - 1;
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (values[parent].score <= value.score) break;
values[index] = values[parent];
index = parent;
}
values[index] = value;
}
pop() {
const values = this.values;
if (!values.length) return null;
const root = values[0];
const last = values.pop();
if (!values.length) return root;
let index = 0;
while (true) {
const left = index * 2 + 1;
const right = left + 1;
if (left >= values.length) break;
let child = left;
if (right < values.length && values[right].score < values[left].score) child = right;
if (values[child].score >= last.score) break;
values[index] = values[child];
index = child;
}
values[index] = last;
return root;
}
get size() {
return this.values.length;
}
}
const DIRECTIONS = [
[1, 0], [0, 1], [-1, 0], [0, -1],
];
function key(x, y) {
return (y + 32768) * 65536 + x + 32768;
}
function diagonalKey(x, y, nx, ny) {
return `${Math.min(x, nx)}:${Math.min(y, ny)}:${(nx - x) * (ny - y) > 0 ? 1 : -1}`;
}
function distanceHeuristic(x, y, gx, gy) {
return Math.abs(gx - x) + Math.abs(gy - y);
}
function turnCost(previous, next) {
if (previous === 4 || previous === next) return 0;
const difference = Math.min((next - previous + 4) % 4, (previous - next + 4) % 4);
if (difference === 1) return 0.9;
return 5;
}
function route(start, goal, context, noiseSeed) {
const { columns, rows, occupied, reserved, diagonalEdges } = context;
const stateCount = columns * rows * 5;
const costs = new Float64Array(stateCount);
costs.fill(Infinity);
const parents = new Int32Array(stateCount);
parents.fill(-1);
const heap = new MinHeap();
const startCell = start[1] * columns + start[0];
const startState = startCell * 5 + 4;
costs[startState] = 0;
heap.push({ state: startState, score: distanceHeuristic(start[0], start[1], goal[0], goal[1]) });
const span = Math.hypot(goal[0] - start[0], goal[1] - start[1]);
const padding = 8 + Math.floor(span * 0.1);
const minimumX = Math.max(0, Math.min(start[0], goal[0]) - padding);
const maximumX = Math.min(columns - 1, Math.max(start[0], goal[0]) + padding);
const minimumY = Math.max(0, Math.min(start[1], goal[1]) - padding);
const maximumY = Math.min(rows - 1, Math.max(start[1], goal[1]) + padding);
let goalState = -1;
let expansions = 0;
while (heap.size && expansions < 9000) {
const current = heap.pop();
const state = current.state;
const cell = Math.floor(state / 5);
const previousDirection = state % 5;
const x = cell % columns;
const y = Math.floor(cell / columns);
const knownScore = costs[state] + distanceHeuristic(x, y, goal[0], goal[1]);
if (current.score > knownScore + 1e-8) continue;
if (x === goal[0] && y === goal[1]) {
goalState = state;
break;
}
expansions += 1;
for (let direction = 0; direction < DIRECTIONS.length; direction += 1) {
const [dx, dy] = DIRECTIONS[direction];
const nx = x + dx;
const ny = y + dy;
if (nx < minimumX || nx > maximumX || ny < minimumY || ny > maximumY) continue;
const nextKey = key(nx, ny);
const isGoal = nx === goal[0] && ny === goal[1];
if (!isGoal && (occupied.has(nextKey) || reserved.has(nextKey))) continue;
const diagonal = dx !== 0 && dy !== 0;
if (diagonal && previousDirection < 4 && DIRECTIONS[previousDirection][0] !== 0 && DIRECTIONS[previousDirection][1] !== 0) continue;
if (diagonal) {
const edge = diagonalKey(x, y, nx, ny);
const pieces = edge.split(":");
const opposite = `${pieces[0]}:${pieces[1]}:${-Number(pieces[2])}`;
if (diagonalEdges.has(opposite)) continue;
}
let nearby = 0;
for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
if (occupied.has(key(nx + ox, ny + oy))) nearby += 1;
}
const step = diagonal ? 1.7 : 1;
const attraction = Math.max(0, 0.28 - nearby * 0.08);
const noise = (((nx * 73856093) ^ (ny * 19349663) ^ noiseSeed) >>> 0) % 97 / 9700;
const nextCost = costs[state] + step + turnCost(previousDirection, direction) + attraction + noise;
const nextCell = ny * columns + nx;
const nextState = nextCell * 5 + direction;
if (nextCost >= costs[nextState]) continue;
costs[nextState] = nextCost;
parents[nextState] = state;
heap.push({
state: nextState,
score: nextCost + distanceHeuristic(nx, ny, goal[0], goal[1]),
});
}
}
if (goalState < 0) return null;
const cells = [];
let state = goalState;
while (state >= 0) {
const cell = Math.floor(state / 5);
cells.push([cell % columns, Math.floor(cell / columns)]);
state = parents[state];
}
cells.reverse();
return cells;
}
function chamfer(path) {
if (path.length < 3) return path;
const result = [path[0]];
for (let index = 1; index < path.length - 1; index += 1) {
const previous = path[index - 1];
const current = path[index];
const next = path[index + 1];
const ax = current[0] - previous[0];
const ay = current[1] - previous[1];
const bx = next[0] - current[0];
const by = next[1] - current[1];
const firstCardinal = (ax === 0) !== (ay === 0);
const secondCardinal = (bx === 0) !== (by === 0);
if (firstCardinal && secondCardinal && ax * bx + ay * by === 0) continue;
result.push(current);
}
result.push(path.at(-1));
return result;
}
function simplify(path) {
if (path.length < 3) return path;
const result = [path[0]];
for (let index = 1; index < path.length - 1; index += 1) {
const previous = result.at(-1);
const current = path[index];
const next = path[index + 1];
const ax = Math.sign(current[0] - previous[0]);
const ay = Math.sign(current[1] - previous[1]);
const bx = Math.sign(next[0] - current[0]);
const by = Math.sign(next[1] - current[1]);
if (ax !== bx || ay !== by) result.push(current);
}
result.push(path.at(-1));
return result;
}
function randomBank(random, count, columns, rows, occupied, reserved) {
for (let attempt = 0; attempt < 80; attempt += 1) {
const boundary = random() < 0.34;
let points;
let side = "interior";
if (boundary) {
side = ["left", "right", "top", "bottom"][integer(random, 0, 3)];
if (side === "left" || side === "right") {
const x = side === "left" ? 0 : columns - 1;
const y = integer(random, 2, rows - count - 2);
points = Array.from({ length: count }, (_, index) => [x, y + index]);
} else {
const x = integer(random, 2, columns - count - 2);
const y = side === "top" ? 0 : rows - 1;
points = Array.from({ length: count }, (_, index) => [x + index, y]);
}
} else {
const vertical = random() < 0.5;
if (vertical) {
const x = integer(random, 4, columns - 5);
const y = integer(random, 4, rows - count - 5);
points = Array.from({ length: count }, (_, index) => [x, y + index]);
} else {
const x = integer(random, 4, columns - count - 5);
const y = integer(random, 4, rows - 5);
points = Array.from({ length: count }, (_, index) => [x + index, y]);
}
}
if (points.every(([x, y]) => !occupied.has(key(x, y)) && !reserved.has(key(x, y)))) {
return { points, side };
}
}
return null;
}
function bankCenter(bank) {
const first = bank.points[0];
const last = bank.points.at(-1);
return [(first[0] + last[0]) / 2, (first[1] + last[1]) / 2];
}
function pathLength(points) {
return points.slice(1).reduce((total, point, index) => {
const previous = points[index];
return total + Math.hypot(point[0] - previous[0], point[1] - previous[1]);
}, 0);
}
export function generateCircuitField(options = {}) {
const config = { ...DEFAULTS, ...options };
const width = Math.max(240, config.width || 900);
const height = Math.max(180, config.height || 450);
const columns = Math.max(30, Math.floor(width / config.pitch));
const rows = Math.max(22, Math.floor(height / config.pitch));
const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}`));
let occupied = new Set();
let reserved = new Set();
let diagonalEdges = new Set();
const traces = [];
const target = Math.round((columns * rows) / 220 * config.density);
let attempts = 0;
while (traces.length < target && attempts < 55) {
attempts += 1;
const remaining = target - traces.length;
const count = Math.min(remaining, integer(random, 4, 7));
const startBank = randomBank(random, count, columns, rows, occupied, reserved);
if (!startBank) continue;
for (const point of startBank.points) reserved.add(key(...point));
let endBank = null;
for (let pairAttempt = 0; pairAttempt < 45; pairAttempt += 1) {
const candidate = randomBank(random, count, columns, rows, occupied, reserved);
if (!candidate) continue;
const a = bankCenter(startBank);
const b = bankCenter(candidate);
if (Math.hypot(a[0] - b[0], a[1] - b[1]) < Math.min(columns, rows) * 0.26) continue;
endBank = candidate;
break;
}
if (!endBank) {
for (const point of startBank.points) reserved.delete(key(...point));
continue;
}
for (const point of endBank.points) reserved.add(key(...point));
const occupiedBefore = occupied;
const edgesBefore = diagonalEdges;
occupied = new Set(occupied);
diagonalEdges = new Set(diagonalEdges);
const bundle = [];
const routeOrder = Array.from({ length: count }, (_, index) => index);
if (random() < 0.5) routeOrder.reverse();
for (const lane of routeOrder) {
const start = startBank.points[lane];
const goal = endBank.points[lane];
const cells = route(start, goal, { columns, rows, occupied, reserved, diagonalEdges }, hashSeed(`${config.seed}:${attempts}:${lane}`));
if (!cells || cells.length < 8) continue;
const shaped = simplify(chamfer(cells));
for (const point of cells) occupied.add(key(...point));
for (let index = 1; index < shaped.length; index += 1) {
const previous = shaped[index - 1];
const current = shaped[index];
if (previous[0] !== current[0] && previous[1] !== current[1]) {
diagonalEdges.add(diagonalKey(previous[0], previous[1], current[0], current[1]));
}
}
const points = shaped.map(([x, y]) => [x * config.pitch, y * config.pitch]);
bundle.push({
points,
length: pathLength(points),
viaStart: startBank.side === "interior",
viaEnd: endBank.side === "interior",
});
}
for (const point of startBank.points) reserved.delete(key(...point));
for (const point of endBank.points) reserved.delete(key(...point));
if (bundle.length < 3) {
occupied = occupiedBefore;
diagonalEdges = edgesBefore;
continue;
}
traces.push(...bundle);
}
return {
width: (columns - 1) * config.pitch,
height: (rows - 1) * config.pitch,
columns,
rows,
traces: traces.slice(0, target),
};
}
export function pathData(points) {
return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");
}
function svgElement(name, attributes = {}) {
const element = document.createElementNS(SVG_NS, name);
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));
return element;
}
export function createCircuitBackground(mount, options = {}) {
if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");
const id = `circuit-field-${++instanceCount}`;
let config = { ...DEFAULTS, ...options };
let field;
let svg;
let signalLayer;
let timer;
let resizeObserver;
let resizeTimer;
let activeSignals = 0;
let pointer = null;
let signalRandom;
let lastSize = "";
const interactionTarget = mount.parentElement || mount;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const originalPosition = mount.style.position;
if (getComputedStyle(mount).position === "static") mount.style.position = "relative";
mount.style.overflow = "hidden";
function addVia(group, point) {
group.append(
svgElement("circle", { cx: point[0], cy: point[1], r: config.viaOuterRadius, fill: config.traceColor }),
svgElement("circle", { cx: point[0], cy: point[1], r: config.viaInnerRadius, fill: "var(--circuit-hole, #0d1117)" }),
);
}
function build(force = false) {
const pixelWidth = Math.max(1, mount.clientWidth);
const pixelHeight = Math.max(1, mount.clientHeight);
const worldWidth = pixelWidth / config.scale;
const worldHeight = pixelHeight / config.scale;
const size = `${Math.floor(worldWidth / config.pitch)}x${Math.floor(worldHeight / config.pitch)}`;
if (!force && size === lastSize) return;
lastSize = size;
svg?.remove();
field = generateCircuitField({ ...config, width: worldWidth, height: worldHeight });
signalRandom = mulberry32(hashSeed(`${config.seed}:${size}:signals`));
svg = svgElement("svg", {
id,
"aria-hidden": true,
focusable: false,
viewBox: `0 0 ${field.width} ${field.height}`,
preserveAspectRatio: "none",
});
Object.assign(svg.style, {
position: "absolute", inset: "0", width: "100%", height: "100%",
display: "block", pointerEvents: "none",
});
const traces = svgElement("g");
for (const trace of field.traces) {
traces.appendChild(svgElement("path", {
d: pathData(trace.points), fill: "none", stroke: config.traceColor,
"stroke-width": config.traceWidth, "stroke-linecap": "butt", "stroke-linejoin": "miter",
}));
if (trace.viaStart) addVia(traces, trace.points[0]);
if (trace.viaEnd) addVia(traces, trace.points.at(-1));
}
signalLayer = svgElement("g", { "data-signals": "", fill: "none" });
svg.append(traces, signalLayer);
mount.prepend(svg);
}
function nearestTrace() {
if (!pointer || !field?.traces.length) return null;
let result = null;
let best = Infinity;
for (let attempt = 0; attempt < 42; attempt += 1) {
const trace = field.traces[integer(signalRandom, 0, field.traces.length - 1)];
const point = trace.points[Math.floor(trace.points.length / 2)];
const distance = Math.hypot(point[0] - pointer.x, point[1] - pointer.y);
if (distance < best) {
best = distance;
result = trace;
}
}
return result;
}
function spawnSignal({ nearPointer = false } = {}) {
if (!config.signals || reducedMotion || activeSignals >= config.maxSignals || !field?.traces.length) return;
const trace = (nearPointer && nearestTrace()) || field.traces[integer(signalRandom, 0, field.traces.length - 1)];
const points = signalRandom() < 0.5 ? [...trace.points].reverse() : trace.points;
const color = weighted(signalRandom, config.signalColors);
const duration = ((trace.length + config.signalLength) / config.signalSpeed) * 1000;
const pulse = svgElement("path", {
d: pathData(points), stroke: color, "stroke-width": 2.4, "stroke-linecap": "round",
"stroke-dasharray": `${config.signalLength} ${trace.length + config.signalLength}`,
"stroke-dashoffset": config.signalLength, "stroke-opacity": 0.62,
});
pulse.style.filter = `drop-shadow(0 0 3px ${color})`;
signalLayer.appendChild(pulse);
activeSignals += 1;
pulse.animate(
[{ strokeDashoffset: config.signalLength }, { strokeDashoffset: -trace.length }],
{ duration, easing: "linear", fill: "forwards" },
);
window.setTimeout(() => {
pulse.remove();
activeSignals = Math.max(0, activeSignals - 1);
}, duration + 150);
}
function onPointerMove(event) {
const rect = mount.getBoundingClientRect();
pointer = {
x: (event.clientX - rect.left) / rect.width * field.width,
y: (event.clientY - rect.top) / rect.height * field.height,
at: performance.now(),
};
}
function onClick() {
for (let index = 0; index < 6; index += 1) {
window.setTimeout(() => spawnSignal({ nearPointer: true }), index * 50);
}
}
function startSignals() {
window.clearInterval(timer);
if (!config.signals || reducedMotion) return;
timer = window.setInterval(() => {
if (!document.hidden) spawnSignal({ nearPointer: pointer && performance.now() - pointer.at < 4000 });
}, config.signalInterval);
}
function regenerate(nextOptions = {}) {
config = { ...config, ...nextOptions };
activeSignals = 0;
lastSize = "";
build(true);
startSignals();
return api;
}
function exportSvg() {
const clone = svg.cloneNode(true);
clone.querySelector("[data-signals]")?.replaceChildren();
clone.removeAttribute("style");
return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;
}
function destroy() {
window.clearInterval(timer);
window.clearTimeout(resizeTimer);
resizeObserver?.disconnect();
interactionTarget.removeEventListener("pointermove", onPointerMove);
interactionTarget.removeEventListener("click", onClick);
svg?.remove();
mount.style.position = originalPosition;
}
const api = { regenerate, spawnSignal, exportSvg, destroy, get field() { return field; } };
build(true);
resizeObserver = new ResizeObserver(() => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => build(), 220);
});
resizeObserver.observe(mount);
if (config.interactive) {
interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });
interactionTarget.addEventListener("click", onClick);
}
startSignals();
return api;
}
export { DEFAULTS };