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.
← Commit history
Publish 0.1.0
20 files changed
+6417
SKILL.md+55docs/field-router.pngdocs/hero.pngdocs/tiling-router.pnginstall.sh+10package.json+43src/circuit-animation-conductor.js+429src/circuit-animation-handshake.js+428src/circuit-background-v2.js+445src/circuit-background-v3.js+560src/circuit-background-v4.js+1062src/circuit-background-v5.js+334src/circuit-background.js+1830src/generate-tile.mjs+67src/index-classic.html+433src/index.html+570src/serve-classic.mjs+61src/serve.sh+26src/server.js+59uninstall.sh+5SKILL.mdadded+55@@ -0,0 +1,55 @@+---+name: circuit-trace-generators+description: Generate the Adom Nitrogen circuit-trace background as seeded SVG — either a seamless 900x450 tile you can repeat as a CSS background-image, or one continuous non-repeating field sized to a mount. Use when a page needs the PCB-trace backdrop, when asked to "generate a circuit background", "make a PCB trace pattern", "seamless circuit tile", "nitrogen hero background", or when tuning trace density, bus width, swirl, or the routed-light animation.+user-invocable: true+---++# Circuit trace backgrounds++Two engines. Pick by whether the output must repeat.++## Need a repeatable texture → tiling router++Emits a `900×450` tile routed on a torus, so it repeats seamlessly in both axes+and no traces cross the seam.++```bash+node src/generate-tile.mjs --seed traces --out tile.svg+```++Use it as `background-image` with `background-repeat: repeat`. Tuning flags are+kebab-case versions of the twelve generation knobs — `--turn-penalty` (higher =+longer, calmer runs), `--bus-width`, `--formations`, `--density`, `--weave`,+`--cramp`, `--horizontal-bias`, `--net-reach`, `--jog-bias`, `--stub-chance`,+`--inline-via-chance`, `--combs`. `--transparent` drops the board fill.++## Need a bespoke backdrop → field router++One continuous field at the mount's real size, packed with a flow field and+seeded vortices. Mount it in a page:++```js+import { createCircuitBackground } from "./src/circuit-background-v5.js";++const circuit = createCircuitBackground(document.querySelector("#hero"), {+ seed: "product-launch",+ animationSystem: "handshake", // "none" | "conductor" | "handshake"+ motion: true,+});+```++`animationSystem: "handshake"` is the one to reach for on a dense board — it caps+concurrent transactions at 3, so per-frame cost does not grow with geometry.+`"none"` mounts a static SVG with no animation layer at all.++For geometry without a DOM, call `generateCircuitField(options)` and render+`pathData(trace.points)` yourself; each trace carries `points`, `length`,+`viaStart`, `viaEnd`.++## Both++Seeded — the same seed and dimensions give identical geometry. Dependency-free ES+modules. Board `#0d1117`, traces `#151b25`, routed light+`#00e6dc`/`#64abff`/`#c5b3ff`. Both honour `prefers-reduced-motion`.++See the page README for the full parameter reference and the two live demos.
docs/field-router.pngadded⋯ 1 unchanged line ⋯
docs/hero.pngadded⋯ 1 unchanged line ⋯
docs/tiling-router.pngadded⋯ 1 unchanged line ⋯
install.shadded+10@@ -0,0 +1,10 @@+#!/bin/sh+set -e++# The engines are dependency-free ES modules — there is nothing to compile or+# link. Installing just puts them on disk; point your page's import at src/,+# or run the tile CLI straight out of the module directory.++echo "installed: circuit-trace-generators"+echo " engines: $PWD/src/circuit-background.js (tiling), $PWD/src/circuit-background-v5.js (field)"+echo " tile CLI: node $PWD/src/generate-tile.mjs --seed traces --out tile.svg"
package.jsonadded+43@@ -0,0 +1,43 @@+{+ "slug": "circuit-trace-generators",+ "title": "Circuit Trace Generators",+ "type": "app",+ "version": "0.1.0",+ "description": "Two dependency-free, seeded SVG generators for the Adom Nitrogen hero background: a tiling router that emits a seamless 900x450 PCB-trace tile from a torus occupancy grid, and a field router that packs one continuous non-repeating trace field using a flow field and seeded vortices.",+ "tags": [+ "svg",+ "generative",+ "background",+ "pcb",+ "brand"+ ],+ "dependencies": {},+ "discovery_triggers": [+ "generate a circuit background",+ "make a PCB trace background",+ "nitrogen hero background",+ "seamless circuit tile",+ "procedural trace pattern"+ ],+ "discovery_pitch": "Reach for this when a page needs the Adom circuit-trace backdrop — either a seamless tile you can repeat as a CSS background-image, or one continuous generated field sized to the mount.",+ "sample_prompts": [+ {+ "label": "Seamless tile",+ "prompt": "generate a seamless circuit-trace tile I can use as a repeating CSS background"+ },+ {+ "label": "Hero backdrop",+ "prompt": "put the Nitrogen circuit background behind this hero section"+ }+ ],+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "hero": {+ "headline": "Circuit Trace Generators",+ "subhead": "Two seeded SVG routers for the Nitrogen hero background — one tiles a torus, one packs a continuous field.",+ "screenshot": "docs/hero.png",+ "accent": "#00e6dc"+ }+}
src/circuit-animation-conductor.jsadded+429@@ -0,0 +1,429 @@+const SVG_NS = "http://www.w3.org/2000/svg";++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;+}++function pathData(points) {+ return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");+}++function integer(random, min, max) {+ return Math.floor(random() * (max - min + 1)) + min;+}++function weighted(random, entries) {+ let total = 0;+ for (const [, weight] of entries) total += weight;+ let roll = random() * total;+ for (const [value, weight] of entries) {+ roll -= weight;+ if (roll <= 0) return value;+ }+ return entries.at(-1)?.[0];+}++function shuffle(random, values) {+ for (let index = values.length - 1; index > 0; index -= 1) {+ const swap = integer(random, 0, index);+ [values[index], values[swap]] = [values[swap], values[index]];+ }+ return values;+}++export function createConductorAnimation(svg, field, config, random) {+ const ambientLayer = svgElement("g", {+ "data-animation": "conductor-ambient", fill: "none", "pointer-events": "none",+ });+ const signalLayer = svgElement("g", {+ "data-animation": "conductor-signals", fill: "none", "pointer-events": "none",+ });+ svg.append(ambientLayer, signalLayer);++ let timer;+ let waveTimer;+ let activeSignals = 0;+ let ambientCurrentCount = 0;+ let ambientNodeCount = 0;+ let wavesTriggered = 0;+ let pointer = null;+ let lastPointerWake = 0;+ const scheduled = new Set();+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;++ function life() {+ return Math.max(0, Math.min(1.5, config.liveliness));+ }++ function featureScale() {+ return Math.max(0.25, config.featureScale);+ }++ function trackTimeout(callback, delay) {+ const timeout = window.setTimeout(() => {+ scheduled.delete(timeout);+ callback();+ }, Math.max(0, delay));+ scheduled.add(timeout);+ return timeout;+ }++ function clearScheduled() {+ for (const timeout of scheduled) window.clearTimeout(timeout);+ scheduled.clear();+ }++ function nearestTrace(origin = pointer) {+ if (!origin || !field.traces.length) return null;+ let result = null;+ let best = Infinity;+ const attempts = Math.min(72, field.traces.length);+ for (let attempt = 0; attempt < attempts; attempt += 1) {+ const trace = field.traces[integer(random, 0, field.traces.length - 1)];+ const point = trace.points[Math.floor(trace.points.length / 2)];+ const distance = Math.hypot(point[0] - origin.x, point[1] - origin.y);+ if (distance < best) {+ best = distance;+ result = trace;+ }+ }+ return result;+ }++ function flashNode(point, color, delay = 0, intensity = 1) {+ if (delay > 0) {+ trackTimeout(() => flashNode(point, color, 0, intensity), delay);+ return;+ }+ if (!signalLayer.isConnected || reducedMotion || !config.motion || !config.nodeFlashes) return;+ const scale = featureScale();+ const radius = config.viaOuterRadius * scale;+ const ring = svgElement("circle", {+ cx: point[0], cy: point[1], r: radius * 0.7,+ fill: "none", stroke: color,+ "stroke-width": Math.max(0.8, config.traceWidth * scale * 0.85),+ opacity: 0,+ });+ ring.style.filter = `drop-shadow(0 0 ${3.5 * scale}px ${color})`;+ signalLayer.appendChild(ring);+ const duration = 480 + intensity * 220;+ ring.animate(+ [+ { opacity: 0, transform: "scale(.45)" },+ { offset: 0.18, opacity: Math.min(1, 0.55 + intensity * 0.25), transform: "scale(.9)" },+ { opacity: 0, transform: `scale(${2.2 + intensity * 0.7})` },+ ],+ { duration, easing: "cubic-bezier(.16,.8,.3,1)", fill: "forwards" },+ );+ ring.style.transformBox = "fill-box";+ ring.style.transformOrigin = "center";+ trackTimeout(() => ring.remove(), duration + 40);+ }++ function spawnSignal({+ nearPointer = false,+ trace = null,+ delay = 0,+ reverse,+ color,+ intensity = 1,+ lengthScale = 1,+ speedScale = 1,+ endpointFlash = false,+ } = {}) {+ if (delay > 0) {+ trackTimeout(() => spawnSignal({+ nearPointer, trace, reverse, color, intensity, lengthScale, speedScale, endpointFlash,+ }), delay);+ return true;+ }+ if (!config.motion || reducedMotion || activeSignals >= config.maxSignals || !field.traces.length) return false;+ const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];+ const reversed = reverse ?? random() < 0.5;+ const points = reversed ? [...selected.points].reverse() : selected.points;+ const selectedColor = color || weighted(random, config.signalColors);+ const scale = featureScale();+ const requestedLength = config.signalLength * scale * lengthScale;+ const signalLength = Math.max(12 * scale, Math.min(requestedLength, selected.length * 0.72));+ const signalSpeed = config.signalSpeed * scale * (0.78 + life() * 0.27) * speedScale;+ const duration = Math.max(460, ((selected.length + signalLength) / signalSpeed) * 1000);+ const dashGap = selected.length + signalLength;+ const group = svgElement("g", { "data-signal": "" });+ const halo = svgElement("path", {+ d: pathData(points), stroke: selectedColor,+ "stroke-width": (5.2 + intensity * 2.2) * scale,+ "stroke-linecap": "round",+ "stroke-dasharray": `${signalLength} ${dashGap}`,+ "stroke-dashoffset": signalLength,+ opacity: 0,+ });+ const core = svgElement("path", {+ d: pathData(points), stroke: selectedColor,+ "stroke-width": (1.25 + intensity * 1.25) * scale,+ "stroke-linecap": "round",+ "stroke-dasharray": `${signalLength} ${dashGap}`,+ "stroke-dashoffset": signalLength,+ opacity: 0,+ });+ halo.style.filter = `blur(${1.6 * scale}px) drop-shadow(0 0 ${4.5 * scale}px ${selectedColor})`;+ core.style.filter = `drop-shadow(0 0 ${2.8 * scale}px ${selectedColor})`;+ if (config.signalHalos) group.appendChild(halo);+ group.appendChild(core);+ signalLayer.appendChild(group);+ activeSignals += 1;+ const movement = [+ { strokeDashoffset: signalLength, opacity: 0 },+ { offset: 0.08, opacity: 0.92 },+ { offset: 0.84, opacity: 0.72 },+ { strokeDashoffset: -selected.length, opacity: 0 },+ ];+ if (config.signalHalos) {+ halo.animate(movement.map((frame) => ({ ...frame, opacity: (frame.opacity || 0) * 0.22 * intensity })), {+ duration, easing: "linear", fill: "forwards",+ });+ }+ core.animate(movement, { duration, easing: "linear", fill: "forwards" });+ if (endpointFlash) {+ flashNode(points[0], selectedColor, 0, intensity);+ flashNode(points.at(-1), selectedColor, duration * 0.87, intensity * 0.8);+ }+ trackTimeout(() => {+ group.remove();+ activeSignals = Math.max(0, activeSignals - 1);+ }, duration + 100);+ return true;+ }++ function spawnConvoy({ nearPointer = false, trace = null } = {}) {+ if (!config.motion || !config.signalConvoys || reducedMotion || !field.traces.length) return;+ const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];+ const count = Math.max(2, Math.min(4, Math.round(2 + life() + random())));+ const selectedColor = weighted(random, config.signalColors);+ const reverse = random() < 0.5;+ for (let index = 0; index < count; index += 1) {+ spawnSignal({+ trace: selected,+ reverse,+ color: selectedColor,+ delay: index * (78 + random() * 54),+ intensity: 0.62 + index * 0.08,+ lengthScale: 0.28 + random() * 0.22,+ speedScale: 0.92 + random() * 0.18,+ endpointFlash: index === 0,+ });+ }+ }++ function ripple(origin, color) {+ if (!signalLayer.isConnected) return;+ const scale = featureScale();+ const startRadius = config.pitch * scale * 0.8;+ const ring = svgElement("circle", {+ cx: origin.x, cy: origin.y, r: startRadius,+ fill: "none", stroke: color,+ "stroke-width": Math.max(0.7, config.traceWidth * scale * 0.72),+ opacity: 0.72,+ });+ ring.style.filter = `drop-shadow(0 0 ${4 * scale}px ${color})`;+ signalLayer.appendChild(ring);+ ring.animate(+ [+ { opacity: 0.72, transform: "scale(.35)" },+ { offset: 0.3, opacity: 0.34 },+ { opacity: 0, transform: "scale(6)" },+ ],+ { duration: 980, easing: "cubic-bezier(.12,.72,.18,1)", fill: "forwards" },+ );+ ring.style.transformBox = "fill-box";+ ring.style.transformOrigin = "center";+ trackTimeout(() => ring.remove(), 1040);+ }++ function spawnWave(origin = null) {+ if (!config.motion || !config.signalWaves || reducedMotion || !field.traces.length) return false;+ const source = origin || {+ x: field.width * (0.14 + random() * 0.72),+ y: field.height * (0.14 + random() * 0.72),+ };+ const selectedColor = weighted(random, config.signalColors);+ ripple(source, selectedColor);+ const ranked = field.traces.map((trace) => {+ const midpoint = trace.points[Math.floor(trace.points.length / 2)];+ return { trace, distance: Math.hypot(midpoint[0] - source.x, midpoint[1] - source.y) };+ }).sort((a, b) => a.distance - b.distance);+ const count = Math.min(ranked.length, Math.max(6, Math.round(6 + life() * 8)));+ const outerDistance = ranked[count - 1]?.distance || 1;+ for (let index = 0; index < count; index += 1) {+ const { trace, distance } = ranked[index];+ const start = trace.points[0];+ const end = trace.points.at(-1);+ const reverse = Math.hypot(end[0] - source.x, end[1] - source.y)+ < Math.hypot(start[0] - source.x, start[1] - source.y);+ spawnSignal({+ trace,+ reverse,+ color: selectedColor,+ delay: 90 + distance / outerDistance * 560 + index * 14,+ intensity: 0.64 + (1 - distance / outerDistance) * 0.34,+ lengthScale: 0.44 + random() * 0.2,+ speedScale: 1.02 + random() * 0.18,+ endpointFlash: index % 4 === 0,+ });+ }+ wavesTriggered += 1;+ return true;+ }++ function seedAmbientLife() {+ ambientCurrentCount = 0;+ ambientNodeCount = 0;+ if (!config.motion || reducedMotion || life() <= 0.02 || !field.traces.length) return;+ const scale = featureScale();+ const eligible = field.traces.filter((trace) => trace.length > 28 * scale);+ const currents = config.ambientCurrents+ ? Math.min(eligible.length, Math.max(1, Math.round(config.ambientCurrentCount * life())))+ : 0;+ shuffle(random, eligible);+ for (const trace of eligible.slice(0, currents)) {+ const color = weighted(random, config.signalColors);+ const dash = Math.max(12 * scale, trace.length * (0.07 + random() * 0.1));+ const current = svgElement("path", {+ d: pathData(random() < 0.5 ? [...trace.points].reverse() : trace.points),+ stroke: color,+ "stroke-width": config.traceWidth * scale * (0.9 + random() * 0.55),+ "stroke-linecap": "round",+ "stroke-dasharray": `${dash} ${trace.length + dash}`,+ "stroke-dashoffset": dash,+ opacity: 0,+ });+ current.style.filter = `drop-shadow(0 0 ${2.2 * scale}px ${color})`;+ ambientLayer.appendChild(current);+ const duration = (6400 + random() * 9000) / (0.72 + life() * 0.35);+ current.animate(+ [+ { strokeDashoffset: dash, opacity: 0 },+ { offset: 0.14, opacity: 0.08 + life() * 0.055 },+ { offset: 0.78, opacity: 0.05 + life() * 0.04 },+ { strokeDashoffset: -trace.length, opacity: 0 },+ ],+ { duration, delay: -random() * duration, iterations: Infinity, easing: "linear" },+ );+ ambientCurrentCount += 1;+ }++ const nodes = config.ambientNodes+ ? Math.min(field.traces.length, Math.max(1, Math.round(config.ambientNodeCount * life())))+ : 0;+ for (let index = 0; index < nodes; index += 1) {+ const trace = field.traces[integer(random, 0, field.traces.length - 1)];+ const point = random() < 0.5 ? trace.points[0] : trace.points.at(-1);+ const color = weighted(random, config.signalColors);+ const node = svgElement("circle", {+ cx: point[0], cy: point[1],+ r: config.viaOuterRadius * scale * (0.34 + random() * 0.18),+ fill: color, opacity: 0,+ });+ node.style.filter = `drop-shadow(0 0 ${3.2 * scale}px ${color})`;+ node.style.transformBox = "fill-box";+ node.style.transformOrigin = "center";+ ambientLayer.appendChild(node);+ const duration = 2600 + random() * 4300;+ node.animate(+ [+ { opacity: 0, transform: "scale(.65)" },+ { offset: 0.48, opacity: 0.16 + life() * 0.11, transform: "scale(1.22)" },+ { opacity: 0, transform: "scale(.72)" },+ ],+ { duration, delay: -random() * duration, iterations: Infinity, easing: "ease-in-out" },+ );+ ambientNodeCount += 1;+ }+ }++ function pointerMoved(origin) {+ pointer = origin;+ if (!pointer) return;+ const wakeInterval = config.pointerWakeInterval / Math.max(0.55, 0.55 + life() * 0.5);+ if (config.motion && config.pointerWake && !reducedMotion && pointer.at - lastPointerWake > wakeInterval) {+ lastPointerWake = pointer.at;+ spawnSignal({+ nearPointer: true,+ intensity: 0.5 + life() * 0.16,+ lengthScale: 0.34,+ speedScale: 1.3,+ });+ }+ }++ function clicked(origin) {+ pointer = origin;+ spawnWave(pointer);+ if (config.signalConvoys) trackTimeout(() => spawnConvoy({ nearPointer: true }), 160);+ }++ function stop() {+ window.clearInterval(timer);+ window.clearInterval(waveTimer);+ timer = undefined;+ waveTimer = undefined;+ clearScheduled();+ }++ function start() {+ stop();+ if (!config.motion || reducedMotion || life() <= 0.02) return;+ seedAmbientLife();+ const interval = Math.max(130, config.signalInterval / (0.55 + life() * 0.78));+ timer = window.setInterval(() => {+ if (document.hidden) return;+ const nearPointer = pointer && performance.now() - pointer.at < 3200;+ if (config.signalConvoys && random() < 0.12 + life() * 0.13) spawnConvoy({ nearPointer });+ else spawnSignal({+ nearPointer,+ intensity: 0.62 + random() * 0.34,+ lengthScale: 0.52 + random() * 0.58,+ speedScale: 0.86 + random() * 0.34,+ endpointFlash: random() < 0.12,+ });+ }, interval);+ if (config.signalWaves) {+ const intervalForWaves = Math.max(2800, config.waveInterval / (0.66 + life() * 0.48));+ waveTimer = window.setInterval(() => {+ if (!document.hidden) spawnWave();+ }, intervalForWaves);+ }+ if (config.signalConvoys) trackTimeout(() => spawnConvoy(), 360);+ if (config.signalWaves) trackTimeout(() => spawnWave(), 1450);+ }++ function destroy() {+ stop();+ ambientLayer.remove();+ signalLayer.remove();+ }++ return {+ start,+ stop,+ destroy,+ pointerMoved,+ clicked,+ trigger: spawnWave,+ get stats() {+ return {+ system: "original-conductor",+ activeSignals,+ ambientCurrentCount,+ ambientNodeCount,+ wavesTriggered,+ reducedMotion,+ signalHalos: config.signalHalos,+ signalConvoys: config.signalConvoys,+ signalWaves: config.signalWaves,+ pointerWake: config.pointerWake,+ };+ },+ };+}
src/circuit-animation-handshake.jsadded+428@@ -0,0 +1,428 @@+const TAU = Math.PI * 2;++function clamp(value, minimum = 0, maximum = 1) {+ return Math.max(minimum, Math.min(maximum, value));+}++function smoothstep(value) {+ const amount = clamp(value);+ return amount * amount * (3 - 2 * amount);+}++function cumulativeLengths(points) {+ 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 cumulative;+}++function orientedRoute(points) {+ const cumulative = cumulativeLengths(points);+ return { points, cumulative, length: cumulative.at(-1) || 0 };+}++function pointAlong(route, distance) {+ const target = clamp(distance, 0, route.length);+ let upper = 1;+ while (upper < route.cumulative.length && route.cumulative[upper] < target) upper += 1;+ if (upper >= route.points.length) return route.points.at(-1);+ const lower = upper - 1;+ const span = Math.max(0.0001, route.cumulative[upper] - route.cumulative[lower]);+ const progress = (target - route.cumulative[lower]) / span;+ return [+ route.points[lower][0] + (route.points[upper][0] - route.points[lower][0]) * progress,+ route.points[lower][1] + (route.points[upper][1] - route.points[lower][1]) * progress,+ ];+}++function strokeInterval(context, route, startDistance, endDistance) {+ const start = clamp(startDistance, 0, route.length);+ const end = clamp(endDistance, 0, route.length);+ if (end - start < 0.05) return false;++ context.beginPath();+ const first = pointAlong(route, start);+ context.moveTo(first[0], first[1]);+ for (let index = 1; index < route.points.length - 1; index += 1) {+ const distance = route.cumulative[index];+ if (distance > start && distance < end) {+ context.lineTo(route.points[index][0], route.points[index][1]);+ }+ }+ const last = pointAlong(route, end);+ context.lineTo(last[0], last[1]);+ context.stroke();+ return true;+}++function segmentTouchesRect(start, end, rect) {+ return Math.max(start[0], end[0]) >= rect.left+ && Math.min(start[0], end[0]) <= rect.right+ && Math.max(start[1], end[1]) >= rect.top+ && Math.min(start[1], end[1]) <= rect.bottom;+}++function routeTouchesRect(points, rect) {+ for (let index = 1; index < points.length; index += 1) {+ if (segmentTouchesRect(points[index - 1], points[index], rect)) return true;+ }+ return false;+}++function brandColor(x, width) {+ const position = x / Math.max(1, width);+ if (position < 0.42) return "#00e6dc";+ if (position < 0.74) return "#49a9ff";+ return "#a58cff";+}++export function createHandshakeAnimation(mount, field, config, random, { readingZone = null } = {}) {+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;+ const pixelWidth = Math.max(1, mount.clientWidth);+ const pixelHeight = Math.max(1, mount.clientHeight);+ const pixelBudgetRatio = Math.sqrt(1_450_000 / Math.max(1, pixelWidth * pixelHeight));+ const pixelRatio = Math.max(1, Math.min(window.devicePixelRatio || 1, 1.5, pixelBudgetRatio));+ const canvas = document.createElement("canvas");+ canvas.setAttribute("aria-hidden", "true");+ canvas.setAttribute("data-animation", "via-handshake-canvas");+ Object.assign(canvas.style, {+ position: "absolute",+ inset: "0",+ width: "100%",+ height: "100%",+ display: "block",+ pointerEvents: "none",+ zIndex: "1",+ contain: "strict",+ });+ canvas.width = Math.max(1, Math.round(pixelWidth * pixelRatio));+ canvas.height = Math.max(1, Math.round(pixelHeight * pixelRatio));+ mount.appendChild(canvas);++ const context = canvas.getContext("2d", { alpha: true, desynchronized: true })+ || canvas.getContext("2d");+ const targetFrameRate = canvas.width * canvas.height > 1_200_000 ? 30 : 45;+ const frameInterval = 1000 / targetFrameRate;+ const scale = Math.max(0.25, config.featureScale);+ const maxTransactions = Math.max(1, Math.min(4, Math.round(config.maxHandshakes || 3)));+ const routes = field.traces.map((trace, index) => {+ const forward = orientedRoute(trace.points);+ const reverse = orientedRoute([...trace.points].reverse());+ const midpoint = trace.points[Math.floor(trace.points.length / 2)];+ return { index, trace, forward, reverse, midpoint, length: forward.length };+ });++ function readingRect() {+ const mountRect = mount.getBoundingClientRect();+ const zoneRect = readingZone?.getBoundingClientRect?.();+ if (zoneRect && mountRect.width > 0 && mountRect.height > 0 && zoneRect.width > 0 && zoneRect.height > 0) {+ const scaleX = field.width / mountRect.width;+ const scaleY = field.height / mountRect.height;+ const paddingX = Math.max(38, zoneRect.width * 0.08) * scaleX;+ const paddingY = Math.max(28, zoneRect.height * 0.1) * scaleY;+ return {+ left: (zoneRect.left - mountRect.left) * scaleX - paddingX,+ right: (zoneRect.right - mountRect.left) * scaleX + paddingX,+ top: (zoneRect.top - mountRect.top) * scaleY - paddingY,+ bottom: (zoneRect.bottom - mountRect.top) * scaleY + paddingY,+ };+ }+ return {+ left: field.width * 0.13,+ right: field.width * 0.87,+ top: field.height * 0.26,+ bottom: field.height * 0.74,+ };+ }++ const quietZone = readingRect();+ const minimumLength = Math.max(28 * scale, Math.hypot(field.width, field.height) * 0.035);+ const maximumLength = Math.hypot(field.width, field.height) * 0.64;+ const inset = config.viaOuterRadius * scale * 1.4;+ const insetRoutes = routes.filter(({ trace, length }) => {+ const start = trace.points[0];+ const end = trace.points.at(-1);+ return trace.viaStart && trace.viaEnd+ && length >= minimumLength+ && length <= maximumLength+ && start[0] > inset && start[0] < field.width - inset+ && start[1] > inset && start[1] < field.height - inset+ && end[0] > inset && end[0] < field.width - inset+ && end[1] > inset && end[1] < field.height - inset;+ });+ const outsideCopy = insetRoutes.filter(({ trace }) => !routeTouchesRect(trace.points, quietZone));+ const outsideMidpoint = insetRoutes.filter(({ midpoint }) => !(+ midpoint[0] >= quietZone.left && midpoint[0] <= quietZone.right+ && midpoint[1] >= quietZone.top && midpoint[1] <= quietZone.bottom+ ));+ const eligibleRoutes = outsideCopy.length >= 12+ ? outsideCopy+ : outsideMidpoint.length >= 12 ? outsideMidpoint : insetRoutes.length ? insetRoutes : routes;++ let frameRequest;+ let running = false;+ let frameCount = 0;+ let transactionsCompleted = 0;+ let transactionsTriggered = 0;+ let lastPaintTime = 0;+ let nextSpawnAt = 0;+ let pointer = null;+ const transactions = [];+ const recentRoutes = [];++ function life() {+ return clamp(config.liveliness, 0, 1.5);+ }++ function activeRoute(index) {+ return transactions.some((transaction) => transaction.route.index === index);+ }++ function selectRoute(origin = null) {+ const available = eligibleRoutes.filter(({ index }) => !activeRoute(index));+ if (!available.length) return null;+ let selected = available[Math.floor(random() * available.length)];+ let bestScore = -Infinity;+ const attempts = Math.min(36, available.length);+ const source = origin || (pointer && performance.now() - pointer.at < 2400 ? pointer : null);+ for (let attempt = 0; attempt < attempts; attempt += 1) {+ const candidate = available[Math.floor(random() * available.length)];+ const targetLength = Math.hypot(field.width, field.height) * 0.16;+ const lengthScore = Math.exp(-Math.abs(candidate.length - targetLength) / Math.max(1, targetLength));+ const recentPenalty = recentRoutes.includes(candidate.index) ? 1.2 : 0;+ let proximityScore = 0;+ if (source) {+ const first = candidate.trace.points[0];+ const last = candidate.trace.points.at(-1);+ const distance = Math.min(+ Math.hypot(first[0] - source.x, first[1] - source.y),+ Math.hypot(last[0] - source.x, last[1] - source.y),+ );+ proximityScore = 1 - distance / Math.max(field.width, field.height);+ }+ const score = random() * 0.7 + lengthScore * 0.8 + proximityScore * 1.35 - recentPenalty;+ if (score > bestScore) {+ bestScore = score;+ selected = candidate;+ }+ }+ return selected;+ }++ function spawnTransaction(origin = null, timestamp = performance.now(), triggered = false) {+ if (!running || reducedMotion || !config.motion || transactions.length >= maxTransactions) return false;+ const route = selectRoute(origin);+ if (!route || route.length < 1) return false;+ const first = route.trace.points[0];+ const last = route.trace.points.at(-1);+ const reverse = origin+ ? Math.hypot(last[0] - origin.x, last[1] - origin.y) < Math.hypot(first[0] - origin.x, first[1] - origin.y)+ : random() < 0.5;+ const outbound = reverse ? route.reverse : route.forward;+ const acknowledgement = reverse ? route.forward : route.reverse;+ const source = outbound.points[0];+ const destination = outbound.points.at(-1);+ const packetLength = clamp(+ (config.handshakeLength || 48) * scale,+ route.length * 0.12,+ route.length * 0.42,+ );+ const speed = (config.handshakeSpeed || 230) * scale * (0.84 + life() * 0.2);+ const outboundDuration = clamp((route.length + packetLength) / speed * 1000, 940, 2350);+ transactions.push({+ route,+ outbound,+ acknowledgement,+ source,+ destination,+ packetLength,+ sourceColor: brandColor(source[0], field.width),+ destinationColor: brandColor(destination[0], field.width),+ startedAt: timestamp,+ chargeDuration: 320,+ outboundDuration,+ holdDuration: 190,+ acknowledgementDuration: Math.max(820, outboundDuration * 0.82),+ settleDuration: 360,+ triggered,+ });+ recentRoutes.push(route.index);+ if (recentRoutes.length > 7) recentRoutes.shift();+ if (triggered) transactionsTriggered += 1;+ return true;+ }++ function scheduleNext(timestamp, initial = false) {+ const interval = (config.handshakeInterval || 1650) / (0.82 + life() * 0.18);+ nextSpawnAt = timestamp + (initial ? 320 : interval * (0.72 + random() * 0.62));+ }++ function drawPacket(route, packetLength, progress, color, opacity) {+ const travel = progress * (route.length + packetLength);+ const head = clamp(travel, 0, route.length);+ const tail = clamp(travel - packetLength, 0, route.length);+ if (head - tail < 0.05) return false;+ context.strokeStyle = color;+ context.lineCap = "round";+ context.lineJoin = "round";+ context.lineWidth = config.traceWidth * scale * 4.4;+ context.globalAlpha = opacity * 0.1;+ strokeInterval(context, route, tail, head);+ context.lineWidth = config.traceWidth * scale * 1.3;+ context.globalAlpha = opacity * 0.82;+ strokeInterval(context, route, tail, head);+ return true;+ }++ function drawVia(point, color, energy) {+ if (energy <= 0.002) return;+ const radius = (config.viaOuterRadius + config.viaInnerRadius) * scale * 0.5;+ const ringWidth = Math.max(0.7 * scale, (config.viaOuterRadius - config.viaInnerRadius) * scale * 0.72);+ context.strokeStyle = color;+ context.lineCap = "round";+ context.lineWidth = ringWidth * 3.1;+ context.globalAlpha = energy * 0.08;+ context.beginPath();+ context.arc(point[0], point[1], radius, 0, TAU);+ context.stroke();+ context.lineWidth = ringWidth;+ context.globalAlpha = energy * 0.72;+ context.beginPath();+ context.arc(point[0], point[1], radius, 0, TAU);+ context.stroke();+ }++ function drawTransaction(transaction, timestamp) {+ const elapsed = timestamp - transaction.startedAt;+ const chargeEnd = transaction.chargeDuration;+ const outboundEnd = chargeEnd + transaction.outboundDuration;+ const holdEnd = outboundEnd + transaction.holdDuration;+ const acknowledgementEnd = holdEnd + transaction.acknowledgementDuration;+ const end = acknowledgementEnd + transaction.settleDuration;+ let sourceEnergy = 0;+ let destinationEnergy = 0;++ if (elapsed < chargeEnd) {+ sourceEnergy = smoothstep(elapsed / transaction.chargeDuration);+ } else if (elapsed < outboundEnd) {+ const progress = (elapsed - chargeEnd) / transaction.outboundDuration;+ drawPacket(transaction.outbound, transaction.packetLength, progress, transaction.sourceColor, 0.92);+ sourceEnergy = 1 - progress * 0.76;+ destinationEnergy = smoothstep((progress - 0.72) / 0.28) * 0.92;+ } else if (elapsed < holdEnd) {+ sourceEnergy = 0.18;+ destinationEnergy = 0.92;+ } else if (elapsed < acknowledgementEnd) {+ const progress = (elapsed - holdEnd) / transaction.acknowledgementDuration;+ drawPacket(transaction.acknowledgement, transaction.packetLength * 0.72, progress, transaction.destinationColor, 0.5);+ destinationEnergy = 0.92 - progress * 0.7;+ sourceEnergy = 0.18 + smoothstep((progress - 0.72) / 0.28) * 0.58;+ } else if (elapsed < end) {+ const progress = smoothstep((elapsed - acknowledgementEnd) / transaction.settleDuration);+ sourceEnergy = 0.76 * (1 - progress);+ destinationEnergy = 0.22 * (1 - progress);+ } else {+ return false;+ }++ drawVia(transaction.source, transaction.sourceColor, sourceEnergy);+ drawVia(transaction.destination, transaction.destinationColor, destinationEnergy);+ return true;+ }++ function renderFrame(timestamp) {+ if (!running || !canvas.isConnected || !context) return;+ frameRequest = window.requestAnimationFrame(renderFrame);+ if (document.hidden) return;+ const sincePaint = lastPaintTime ? timestamp - lastPaintTime : frameInterval;+ if (sincePaint < frameInterval) return;+ lastPaintTime = lastPaintTime ? timestamp - (sincePaint % frameInterval) : timestamp;+ frameCount += 1;++ if (timestamp >= nextSpawnAt) {+ spawnTransaction(null, timestamp);+ scheduleNext(timestamp);+ }++ context.setTransform(1, 0, 0, 1, 0, 0);+ context.clearRect(0, 0, canvas.width, canvas.height);+ context.setTransform(canvas.width / field.width, 0, 0, canvas.height / field.height, 0, 0);+ context.globalCompositeOperation = "source-over";++ for (let index = transactions.length - 1; index >= 0; index -= 1) {+ if (!drawTransaction(transactions[index], timestamp)) {+ transactions.splice(index, 1);+ transactionsCompleted += 1;+ }+ }+ context.globalAlpha = 1;+ }++ function pointerMoved(point) {+ pointer = point ? { ...point, at: performance.now() } : null;+ }++ function pointerLeft() {+ if (pointer) pointer.at = performance.now() - 3000;+ }++ function trigger(origin = null) {+ return spawnTransaction(origin, performance.now(), true);+ }++ function start() {+ if (running || reducedMotion || !config.motion || !context || config.liveliness <= 0.02) return;+ running = true;+ frameCount = 0;+ lastPaintTime = 0;+ const now = performance.now();+ scheduleNext(now, true);+ frameRequest = window.requestAnimationFrame(renderFrame);+ }++ function stop() {+ running = false;+ window.cancelAnimationFrame(frameRequest);+ frameRequest = undefined;+ lastPaintTime = 0;+ }++ function destroy() {+ stop();+ transactions.length = 0;+ canvas.remove();+ }++ return {+ start,+ stop,+ destroy,+ pointerMoved,+ pointerLeft,+ clicked: trigger,+ trigger,+ get stats() {+ return {+ system: "adom-handshake",+ renderer: "canvas-range-strokes",+ animationNodes: canvas.isConnected ? 1 : 0,+ activeTransactions: transactions.length,+ maxTransactions,+ eligibleRoutes: eligibleRoutes.length,+ totalRoutes: routes.length,+ transactionsCompleted,+ transactionsTriggered,+ frameCount,+ targetFrameRate,+ rendererPixelRatio: pixelRatio,+ quietZoneProtected: Boolean(readingZone),+ perFrameTraceBudget: maxTransactions,+ reducedMotion,+ };+ },+ };+}
src/circuit-background-v2.jsadded+445@@ -0,0 +1,445 @@+const SVG_NS = "http://www.w3.org/2000/svg";++const DEFAULTS = Object.freeze({+ seed: "nitrogen",+ tileWidth: 900,+ tileHeight: 450,+ 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: 520,+ signalSpeed: 800,+ signalLength: 150,+ maxSignals: 8,+});++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 choose(random, values) {+ return values[Math.floor(random() * values.length)];+}++function weighted(random, entries) {+ let roll = random();+ for (const [value, weight] of entries) {+ roll -= weight;+ if (roll <= 0) return value;+ }+ return entries[0][0];+}++function shuffle(random, values) {+ const result = [...values];+ for (let index = result.length - 1; index > 0; index -= 1) {+ const swap = integer(random, 0, index);+ [result[index], result[swap]] = [result[swap], result[index]];+ }+ return result;+}++function partition(random, total, count, minimum) {+ const values = Array(count).fill(Math.floor(total / count));+ for (let index = 0; index < total % count; index += 1) values[index] += 1;+ for (let turn = 0; turn < 10; turn += 1) {+ const from = integer(random, 0, count - 1);+ let to = integer(random, 0, count - 1);+ if (to === from) to = (to + 1) % count;+ if (values[from] > minimum) {+ values[from] -= 1;+ values[to] += 1;+ }+ }+ return values;+}++function lengthOf(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);+}++function compact(points) {+ const clean = [];+ for (const point of points) {+ const previous = clean.at(-1);+ if (!previous || point[0] !== previous[0] || point[1] !== previous[1]) clean.push(point);+ }+ return clean;+}++function addTrace(target, points, viaStart = true, viaEnd = true) {+ const clean = compact(points);+ target.push({ points: clean, length: lengthOf(clean), viaStart, viaEnd });+}++function horizontalBundle(target, box, count, random) {+ const firstBend = box.x + Math.max(3, Math.floor(box.w * 0.25));+ const secondBend = box.x + Math.min(box.w - 3, Math.floor(box.w * 0.75));+ const maximumDiagonal = Math.max(1, Math.floor((secondBend - firstBend - 1) / 2));+ const diagonal = Math.min(integer(random, 2, 3), maximumDiagonal);+ const shift = diagonal * (random() < 0.5 ? -1 : 1);+ const available = box.h - 4 - diagonal;+ const lanes = Math.max(3, Math.min(count, available));+ const yStart = shift > 0 ? box.y + 2 : box.y + box.h - 2 - lanes;++ for (let lane = 0; lane < lanes; lane += 1) {+ const y = yStart + lane;+ addTrace(target, [+ [box.x, y],+ [firstBend, y],+ [firstBend + diagonal, y + shift],+ [secondBend - diagonal, y + shift],+ [secondBend, y],+ [box.x + box.w, y],+ ], random() < 0.72, random() < 0.72);+ }+}++function verticalBundle(target, box, count, random) {+ const swapped = [];+ horizontalBundle(swapped, { x: box.y, y: box.x, w: box.h, h: box.w }, count, random);+ for (const trace of swapped) {+ addTrace(target, trace.points.map(([x, y]) => [y, x]), trace.viaStart, trace.viaEnd);+ }+}++function cornerBundle(target, box, count, random) {+ const lanes = Math.max(4, Math.min(count, box.w - 5, box.h - 5));+ const orientation = integer(random, 0, 3);+ for (let lane = 0; lane < lanes; lane += 1) {+ let points;+ if (orientation === 0) {+ const x = box.x + 2 + lane;+ const y = box.y + box.h - 2 - lane;+ points = [[x, box.y], [x, y - 1], [x + 1, y], [box.x + box.w, y]];+ } else if (orientation === 1) {+ const x = box.x + box.w - 2 - lane;+ const y = box.y + box.h - 2 - lane;+ points = [[x, box.y], [x, y - 1], [x - 1, y], [box.x, y]];+ } else if (orientation === 2) {+ const x = box.x + 2 + lane;+ const y = box.y + 2 + lane;+ points = [[x, box.y + box.h], [x, y + 1], [x + 1, y], [box.x + box.w, y]];+ } else {+ const x = box.x + box.w - 2 - lane;+ const y = box.y + 2 + lane;+ points = [[x, box.y + box.h], [x, y + 1], [x - 1, y], [box.x, y]];+ }+ addTrace(target, points, random() < 0.76, random() < 0.76);+ }+}++function hairpinBundle(target, box, count, random) {+ const loops = Math.max(3, Math.min(Math.floor(count * 0.62), Math.floor((box.h - 3) / 2), box.w - 6));+ const opensLeft = random() < 0.5;+ for (let lane = 0; lane < loops; lane += 1) {+ const top = box.y + 1 + lane;+ const bottom = box.y + box.h - 1 - lane;+ if (opensLeft) {+ const end = box.x + box.w - 1 - lane;+ addTrace(target, [+ [box.x, top],+ [end - 1, top],+ [end, top + 1],+ [end, bottom - 1],+ [end - 1, bottom],+ [box.x, bottom],+ ]);+ } else {+ const end = box.x + 1 + lane;+ addTrace(target, [+ [box.x + box.w, top],+ [end + 1, top],+ [end, top + 1],+ [end, bottom - 1],+ [end + 1, bottom],+ [box.x + box.w, bottom],+ ]);+ }+ }+}++function scaleTrace(trace, pitch) {+ const points = trace.points.map(([x, y]) => [x * pitch, y * pitch]);+ return { ...trace, points, length: lengthOf(points) };+}++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 rowHeights = partition(random, rows, 4, 12);+ const patterns = shuffle(random, [+ "horizontal", "corner", "hairpin", "vertical", "corner",+ "vertical", "horizontal", "corner", "hairpin", "horizontal",+ "corner", "vertical", "horizontal", "corner", "hairpin",+ "horizontal", "corner", "vertical", "hairpin", "horizontal",+ ]);+ const traces = [];+ const cells = [];+ let y = 0;+ let patternIndex = 0;++ for (const rowHeight of rowHeights) {+ const widths = partition(random, columns, 5, 18);+ let x = 0;+ for (const width of widths) {+ const box = { x: x + 1, y: y + 1, w: width - 2, h: rowHeight - 2 };+ const pattern = patterns[patternIndex++];+ const baseLanes = integer(random, 5, 8);+ const laneCount = Math.max(3, Math.round(baseLanes * config.density));+ if (pattern === "horizontal") horizontalBundle(traces, box, laneCount, random);+ if (pattern === "vertical") verticalBundle(traces, box, laneCount, random);+ if (pattern === "corner") cornerBundle(traces, box, laneCount, random);+ if (pattern === "hairpin") hairpinBundle(traces, box, laneCount, random);+ cells.push({ ...box, pattern });+ x += width;+ }+ y += rowHeight;+ }++ const scaled = traces.map((trace) => scaleTrace(trace, config.pitch));+ return {+ width: columns * config.pitch,+ height: rows * config.pitch,+ pitch: config.pitch,+ traces: scaled,+ cells,+ };+}++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("");+}++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-v2-${++instanceCount}`;+ let config = { ...DEFAULTS, ...options };+ let tile;+ let svg;+ let uses;+ let signals;+ let resizeObserver;+ let timer;+ let activeSignals = 0;+ let tilesAcross = 1;+ let tilesDown = 1;+ let pointer = null;+ let signalRandom = mulberry32(hashSeed(`${config.seed}:signals`));+ 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 via(parent, point) {+ parent.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 layout() {+ const width = Math.max(1, mount.clientWidth);+ const height = Math.max(1, mount.clientHeight);+ tilesAcross = Math.max(1, Math.ceil(width / (tile.width * config.scale)));+ tilesDown = Math.max(2, Math.ceil(height / (tile.height * config.scale)) + 1);+ 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 ${tile.height / 2} ${viewWidth} ${viewHeight}`);+ uses.replaceChildren();+ for (let row = 0; row < tilesDown; row += 1) {+ for (let column = 0; column < tilesAcross; column += 1) {+ uses.appendChild(svgElement("use", {+ href: `#${id}-tile`,+ transform: `translate(${column * tile.width}, ${row * tile.height})`,+ }));+ }+ }+ }++ function build() {+ svg?.remove();+ tile = generateCircuitTile(config);+ signalRandom = mulberry32(hashSeed(`${config.seed}:signals`));+ svg = svgElement("svg", { "aria-hidden": true, focusable: false, xmlns: SVG_NS });+ Object.assign(svg.style, {+ position: "absolute", left: "0", top: "0", display: "block",+ maxWidth: "none", pointerEvents: "none", overflow: "visible",+ });+ const defs = svgElement("defs");+ const tileGroup = svgElement("g", { id: `${id}-tile` });+ for (const trace of tile.traces) {+ tileGroup.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) via(tileGroup, trace.points[0]);+ if (trace.viaEnd) via(tileGroup, trace.points.at(-1));+ }+ defs.appendChild(tileGroup);+ svg.appendChild(defs);+ uses = svgElement("g");+ signals = svgElement("g", { "data-signals": "", fill: "none" });+ svg.append(uses, signals);+ mount.prepend(svg);+ layout();+ }++ function nearestTrace() {+ if (!pointer) return null;+ let result = null;+ let best = Infinity;+ for (let attempt = 0; attempt < 40; attempt += 1) {+ const trace = tile.traces[integer(signalRandom, 0, tile.traces.length - 1)];+ const point = trace.points[Math.floor(trace.points.length / 2)];+ const column = Math.floor(pointer.x / tile.width);+ const row = Math.floor(pointer.y / tile.height);+ const distance = Math.hypot(point[0] + column * tile.width - pointer.x, point[1] + row * tile.height - pointer.y);+ if (distance < best) {+ best = distance;+ result = { trace, column, row };+ }+ }+ return result;+ }++ function spawnSignal({ nearPointer = false } = {}) {+ if (!config.signals || reducedMotion || activeSignals >= config.maxSignals || !tile.traces.length) return;+ const selected = nearPointer ? nearestTrace() : null;+ const trace = selected?.trace || tile.traces[integer(signalRandom, 0, tile.traces.length - 1)];+ const column = selected?.column ?? integer(signalRandom, 0, tilesAcross - 1);+ const row = selected?.row ?? integer(signalRandom, 0, tilesDown - 1);+ if (column < 0 || column >= tilesAcross || row < 0 || row >= tilesDown) return;+ const points = signalRandom() < 0.5 ? [...trace.points].reverse() : trace.points;+ const d = pathData(points, column * tile.width, row * tile.height);+ const color = weighted(signalRandom, config.signalColors);+ const duration = ((trace.length + config.signalLength) / config.signalSpeed) * 1000;+ const group = svgElement("g");+ const pulse = svgElement("path", {+ d, 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})`;+ group.appendChild(pulse);+ signals.appendChild(group);+ activeSignals += 1;+ pulse.animate(+ [{ strokeDashoffset: config.signalLength }, { strokeDashoffset: -trace.length }],+ { duration, easing: "linear", fill: "forwards" },+ );+ window.setTimeout(() => {+ group.remove();+ activeSignals = Math.max(0, activeSignals - 1);+ }, duration + 150);+ }++ function onPointerMove(event) {+ const rect = mount.getBoundingClientRect();+ pointer = {+ x: (event.clientX - rect.left) / config.scale,+ y: (event.clientY - rect.top) / config.scale + tile.height / 2,+ at: performance.now(),+ };+ }++ function onClick() {+ for (let index = 0; index < 5; index += 1) {+ window.setTimeout(() => spawnSignal({ nearPointer: true }), index * 55);+ }+ }++ 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;+ build();+ 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);+ resizeObserver?.disconnect();+ interactionTarget.removeEventListener("pointermove", onPointerMove);+ interactionTarget.removeEventListener("click", onClick);+ svg?.remove();+ if (computedPosition === "static") mount.style.position = originalPosition;+ }++ const api = { regenerate, spawnSignal, exportSvg, destroy, get tile() { return tile; } };+ build();+ resizeObserver = new ResizeObserver(layout);+ resizeObserver.observe(mount);+ if (config.interactive) {+ interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+ interactionTarget.addEventListener("click", onClick);+ }+ startSignals();+ return api;+}++export { DEFAULTS };
src/circuit-background-v3.jsadded+560@@ -0,0 +1,560 @@+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 };
src/circuit-background-v4.jsadded+1062@@ -0,0 +1,1062 @@+import { createConductorAnimation } from "./circuit-animation-conductor.js?v=1";++const SVG_NS = "http://www.w3.org/2000/svg";++const DEFAULTS = Object.freeze({+ seed: "nitrogen",+ pitch: 7.895,+ scale: 1000 / 900,+ density: 1,+ fill: 0.668,+ routeLength: 0.78,+ horizontalFlow: 0.82,+ flowAngle: 0,+ swirl: 0.28,+ swirlScale: 0.8,+ diagonalFlow: 0.58,+ jogLength: 0.72,+ channelAttraction: 0.78,+ longFlow: 0.18,+ edgeDensity: 0.22,+ traceColor: "#151b25",+ traceWidth: 1.58,+ featureScale: 1,+ viaOuterRadius: 3.34,+ viaInnerRadius: 1.76,+ motion: true,+ interactive: true,+ animationSystem: "conductor",+ liveliness: 0.82,+ signalHalos: true,+ signalConvoys: true,+ signalWaves: true,+ pointerWake: true,+ nodeFlashes: true,+ ambientCurrents: true,+ ambientNodes: true,+ signalColors: [+ ["#00e6dc", 0.6],+ ["#64abff", 0.25],+ ["#c5b3ff", 0.15],+ ],+ signalInterval: 420,+ signalSpeed: 800,+ signalLength: 110,+ maxSignals: 28,+ ambientCurrentCount: 18,+ ambientNodeCount: 22,+ waveInterval: 6200,+ pointerWakeInterval: 170,+ sweepDuration: 15200,+ sweepAngle: 18,+ sweepWidth: 0.075,+ bloomInterval: 7600,+ bloomDuration: 4800,+ pointerRadius: 0.19,+ atmosphereStrength: 1,+});++const ANIMATION_PRESETS = Object.freeze({+ none: Object.freeze({+ animationSystem: "none",+ motion: false,+ liveliness: 0,+ atmosphereStrength: 0,+ }),+ conductor: Object.freeze({+ animationSystem: "conductor",+ motion: true,+ liveliness: 0.82,+ signalHalos: true,+ signalConvoys: true,+ signalWaves: true,+ pointerWake: true,+ nodeFlashes: true,+ ambientCurrents: true,+ ambientNodes: true,+ signalColors: [+ ["#00e6dc", 0.6],+ ["#64abff", 0.25],+ ["#c5b3ff", 0.15],+ ],+ signalInterval: 420,+ signalSpeed: 800,+ signalLength: 110,+ maxSignals: 28,+ ambientCurrentCount: 18,+ ambientNodeCount: 22,+ waveInterval: 6200,+ pointerWakeInterval: 170,+ atmosphereStrength: 1,+ }),+ adom: Object.freeze({+ animationSystem: "adom",+ motion: true,+ liveliness: 0.88,+ sweepDuration: 15200,+ sweepAngle: 18,+ sweepWidth: 0.075,+ bloomInterval: 7600,+ bloomDuration: 4800,+ pointerRadius: 0.19,+ atmosphereStrength: 1,+ }),+});++const DIRECTIONS = [[1, 0], [0, 1], [-1, 0], [0, -1]];+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 total = 0;+ for (const [, weight] of entries) total += weight;+ let roll = random() * total;+ for (const [value, weight] of entries) {+ roll -= weight;+ if (roll <= 0) return value;+ }+ return entries.at(-1)?.[0];+}++function shuffle(random, values) {+ for (let index = values.length - 1; index > 0; index -= 1) {+ const swap = integer(random, 0, index);+ [values[index], values[swap]] = [values[swap], values[index]];+ }+ return values;+}++function cellIndex(x, y, columns) {+ return y * columns + x;+}++function runLength(random, perpendicular, config, vector = [1, 0]) {+ const localDiagonalness = diagonalness(vector);+ let result;+ if (perpendicular) {+ result = random() < 0.22 ? integer(random, 4, 7) : integer(random, 1, 4);+ const scale = config.jogLength + (1 - config.jogLength) * localDiagonalness;+ return Math.max(1, Math.round(result * scale));+ }+ const roll = random();+ if (roll < 0.14) result = integer(random, 16, 28);+ else if (roll < 0.52) result = integer(random, 9, 16);+ else result = integer(random, 4, 10);+ const scale = 1 - localDiagonalness * config.diagonalFlow * 0.9;+ return Math.max(1, Math.round(result * scale));+}++function traceBudget(random, columns, rows, config) {+ const ceiling = Math.max(12, Math.min(86, Math.floor(Math.hypot(columns, rows) * 0.52)));+ const roll = random();+ let result;+ if (roll < 0.2) result = integer(random, Math.min(42, ceiling), ceiling);+ else if (roll < 0.65) result = integer(random, 24, Math.min(50, ceiling));+ else result = integer(random, 12, Math.min(28, ceiling));+ return Math.max(6, Math.round(result * config.routeLength));+}++function createFlowField(config, columns, rows) {+ const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}:flow-field`));+ const minimumDimension = Math.min(columns, rows);+ const count = Math.max(2, Math.min(5, Math.round(columns * rows / 5200)));+ const vortices = [];+ for (let index = 0; index < count; index += 1) {+ const radius = minimumDimension * config.swirlScale * (0.55 + random() * 0.45);+ vortices.push({+ x: columns * (0.08 + random() * 0.84),+ y: rows * (0.08 + random() * 0.84),+ radius: Math.max(8, radius),+ spin: random() < 0.5 ? -1 : 1,+ strength: 0.72 + random() * 0.56,+ });+ }+ return { vortices };+}++function flowVector(x, y, angleDegrees, field, config) {+ const radians = angleDegrees * Math.PI / 180;+ let vx = Math.cos(radians);+ let vy = Math.sin(radians);+ for (const vortex of field.vortices) {+ const dx = x - vortex.x;+ const dy = y - vortex.y;+ const distance = Math.hypot(dx, dy);+ if (distance < 0.0001) continue;+ const falloff = Math.exp(-(distance * distance) / (2 * vortex.radius * vortex.radius));+ const influence = config.swirl * vortex.strength * falloff;+ vx += -dy / distance * vortex.spin * influence;+ vy += dx / distance * vortex.spin * influence;+ }+ const length = Math.hypot(vx, vy) || 1;+ return [vx / length, vy / length];+}++function closestDirection(vector) {+ let bestDirection = 0;+ let bestScore = -Infinity;+ for (let direction = 0; direction < DIRECTIONS.length; direction += 1) {+ const score = DIRECTIONS[direction][0] * vector[0] + DIRECTIONS[direction][1] * vector[1];+ if (score > bestScore) {+ bestScore = score;+ bestDirection = direction;+ }+ }+ return bestDirection;+}++function diagonalness(vector) {+ return 1 - Math.abs(Math.abs(vector[0]) - Math.abs(vector[1]));+}++function lookAhead(x, y, direction, blocked, local, columns, rows) {+ const [dx, dy] = DIRECTIONS[direction];+ let open = 0;+ for (let step = 1; step <= 5; step += 1) {+ const nx = x + dx * step;+ const ny = y + dy * step;+ if (nx < 0 || ny < 0 || nx >= columns || ny >= rows) break;+ const index = cellIndex(nx, ny, columns);+ if (blocked[index] || local.has(index)) break;+ open += 1;+ }+ return open;+}++function growOne(start, initialDirection, limit, blocked, local, columns, rows, random, config, field, angleDegrees, travelSign = 1) {+ const points = [start];+ let [x, y] = start;+ let direction = initialDirection;+ const initialVector = flowVector(x, y, angleDegrees, field, config);+ const initialTarget = [initialVector[0] * travelSign, initialVector[1] * travelSign];+ let remaining = runLength(random, false, config, initialTarget);++ while (points.length <= limit) {+ const candidates = [];+ const directions = [direction, (direction + 1) % 4, (direction + 3) % 4];+ const sampledVector = flowVector(x, y, angleDegrees, field, config);+ const targetVector = [sampledVector[0] * travelSign, sampledVector[1] * travelSign];+ const primaryDirection = closestDirection(targetVector);+ for (const candidate of directions) {+ const [dx, dy] = DIRECTIONS[candidate];+ const nx = x + dx;+ const ny = y + dy;+ // Keep every endpoint (and therefore every via) fully inside the canvas.+ if (nx < 1 || ny < 1 || nx >= columns - 1 || ny >= rows - 1) continue;+ const index = cellIndex(nx, ny, columns);+ if (blocked[index] || local.has(index)) continue;+ const open = lookAhead(x, y, candidate, blocked, local, columns, rows);+ if (!open) continue;+ const straight = candidate === direction;+ let weight = straight ? (remaining > 0 ? 15 : 1.6) : (remaining > 0 ? 0.12 : 4.2);+ const alignment = dx * targetVector[0] + dy * targetVector[1];+ if (alignment < -0.16) continue;+ weight *= 0.45 + open * 0.34;+ let neighbors = 0;+ if (candidate % 2 === 0) {+ weight *= 0.45 + Math.max(0, alignment) * 2.2;+ if (ny > 0 && blocked[cellIndex(nx, ny - 1, columns)]) neighbors += 1;+ if (ny < rows - 1 && blocked[cellIndex(nx, ny + 1, columns)]) neighbors += 1;+ } else {+ if (nx > 0 && blocked[cellIndex(nx - 1, ny, columns)]) neighbors += 1;+ if (nx < columns - 1 && blocked[cellIndex(nx + 1, ny, columns)]) neighbors += 1;+ }+ weight *= 1 + neighbors * config.channelAttraction;+ candidates.push([candidate, weight]);+ }+ if (!candidates.length) break;+ const nextDirection = weighted(random, candidates);+ if (nextDirection !== direction) {+ direction = nextDirection;+ remaining = runLength(random, direction % 2 !== primaryDirection % 2, config, targetVector);+ } else {+ remaining -= 1;+ }+ x += DIRECTIONS[direction][0];+ y += DIRECTIONS[direction][1];+ const index = cellIndex(x, y, columns);+ local.add(index);+ points.push([x, y]);+ }+ return points;+}++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];+ if (ax * bx + ay * by === 0 && (ax !== bx || ay !== by)) 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 pathLength(points) {+ let length = 0;+ for (let index = 1; index < points.length; index += 1) {+ length += Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]);+ }+ return length;+}++function commitTrace(cells, state) {+ if (cells.length < 6) return false;+ const { blocked, columns, rows, pitch, traces } = state;+ for (const [x, y] of cells) blocked[cellIndex(x, y, columns)] = 1;+ state.occupied += cells.length;+ const shaped = simplify(chamfer(cells));+ const points = shaped.map(([x, y]) => [x * pitch, y * pitch]);+ traces.push({+ points,+ length: pathLength(points),+ viaStart: true,+ viaEnd: true,+ });+ return true;+}++function makeInteriorTrace(start, state, random, config) {+ const { blocked, columns, rows } = state;+ const startIndex = cellIndex(start[0], start[1], columns);+ if (blocked[startIndex]) return false;+ const followsPrimary = random() < config.horizontalFlow;+ const angleDegrees = config.flowAngle + (followsPrimary ? 0 : 90);+ const startVector = flowVector(start[0], start[1], angleDegrees, state.flowField, config);+ const direction = closestDirection(startVector);+ const budget = traceBudget(random, columns, rows, config);+ const forwardBudget = Math.max(3, Math.floor(budget * (0.42 + random() * 0.16)));+ const local = new Set([startIndex]);+ const forward = growOne(start, direction, forwardBudget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, 1);+ const backward = growOne(start, (direction + 2) % 4, budget - forwardBudget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, -1);+ const cells = backward.slice(1).reverse().concat(forward);+ return commitTrace(cells, state);+}++function makeEdgeTrace(start, direction, state, random, config, options = {}) {+ const { blocked, columns, rows } = state;+ const startIndex = cellIndex(start[0], start[1], columns);+ if (blocked[startIndex]) return false;+ const local = new Set([startIndex]);+ const baseBudget = integer(random, 10, Math.max(14, Math.min(48, Math.floor(Math.hypot(columns, rows) * 0.3))));+ const budget = options.budget ?? Math.max(6, Math.round(baseBudget * config.routeLength));+ const angleDegrees = options.angleDegrees ?? config.flowAngle + (random() < config.horizontalFlow ? 0 : 90);+ const sampledVector = flowVector(start[0], start[1], angleDegrees, state.flowField, config);+ const inward = DIRECTIONS[direction][0] * sampledVector[0] + DIRECTIONS[direction][1] * sampledVector[1];+ const cells = growOne(start, direction, budget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, inward >= 0 ? 1 : -1);+ if (cells.length < (options.minimumLength ?? 6)) return false;+ return commitTrace(cells, state);+}++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 featureScale = Math.max(0.25, config.featureScale);+ const pitch = config.pitch * featureScale;+ const columns = Math.max(30, Math.floor(width / pitch));+ const rows = Math.max(22, Math.floor(height / pitch));+ const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}`));+ const state = {+ blocked: new Uint8Array(columns * rows), columns, rows,+ pitch, traces: [], occupied: 0,+ flowField: createFlowField(config, columns, rows),+ };++ const flowRadians = config.flowAngle * Math.PI / 180;+ const absoluteX = Math.abs(Math.cos(flowRadians));+ const absoluteY = Math.abs(Math.sin(flowRadians));+ const primaryHorizontalWeight = absoluteX / (absoluteX + absoluteY || 1);+ const horizontalWeight = config.horizontalFlow * primaryHorizontalWeight + (1 - config.horizontalFlow) * (1 - primaryHorizontalWeight);+ const verticalWeight = 1 - horizontalWeight;+ const horizontalCarriers = [];+ for (let y = 1; y < rows - 1; y += 1) {+ horizontalCarriers.push([[1, y], 0], [[columns - 2, y], 2]);+ }+ shuffle(random, horizontalCarriers);+ const horizontalTarget = Math.round(rows * config.longFlow * horizontalWeight);+ let horizontalCarrierCount = 0;+ for (const [start, direction] of horizontalCarriers) {+ if (horizontalCarrierCount >= horizontalTarget) break;+ const minimumBudget = Math.max(12, Math.round(columns * 0.58 * config.routeLength));+ const maximumBudget = Math.max(minimumBudget, Math.round(columns * 1.02 * config.routeLength));+ const budget = integer(random, minimumBudget, maximumBudget);+ if (makeEdgeTrace(start, direction, state, random, config, {+ budget,+ angleDegrees: config.flowAngle,+ minimumLength: Math.round(columns * 0.3),+ })) horizontalCarrierCount += 1;+ }++ const verticalCarriers = [];+ for (let x = 1; x < columns - 1; x += 1) {+ verticalCarriers.push([[x, 1], 1], [[x, rows - 2], 3]);+ }+ shuffle(random, verticalCarriers);+ const verticalTarget = Math.round(columns * config.longFlow * verticalWeight * 0.34);+ let verticalCarrierCount = 0;+ for (const [start, direction] of verticalCarriers) {+ if (verticalCarrierCount >= verticalTarget) break;+ const minimumBudget = Math.max(12, Math.round(rows * 0.58 * config.routeLength));+ const maximumBudget = Math.max(minimumBudget, Math.round(rows * 1.02 * config.routeLength));+ const budget = integer(random, minimumBudget, maximumBudget);+ if (makeEdgeTrace(start, direction, state, random, config, {+ angleDegrees: config.flowAngle,+ budget,+ minimumLength: Math.round(rows * 0.3),+ })) verticalCarrierCount += 1;+ }++ const edgeSeeds = [];+ for (let x = 1; x < columns - 1; x += 1) {+ edgeSeeds.push([[x, 1], 1], [[x, rows - 2], 3]);+ }+ for (let y = 1; y < rows - 1; y += 1) {+ edgeSeeds.push([[1, y], 0], [[columns - 2, y], 2]);+ }+ shuffle(random, edgeSeeds);+ const edgeTarget = Math.round((columns + rows) * config.edgeDensity);+ let edgeCount = 0;+ for (const [start, direction] of edgeSeeds) {+ if (edgeCount >= edgeTarget) break;+ if (makeEdgeTrace(start, direction, state, random, config)) edgeCount += 1;+ }++ const interiorColumns = columns - 2;+ const interiorRows = rows - 2;+ const seeds = shuffle(random, Array.from(+ { length: interiorColumns * interiorRows },+ (_, index) => (Math.floor(index / interiorColumns) + 1) * columns + index % interiorColumns + 1,+ ));+ // Density controls occupied routing cells only. Feature scale changes the+ // physical size of the whole motif without secretly changing this target.+ const targetFill = Math.min(0.82, Math.max(0.18, config.fill * config.density));+ for (const index of seeds) {+ if (state.occupied / (columns * rows) >= targetFill) break;+ if (state.blocked[index]) continue;+ makeInteriorTrace([index % columns, Math.floor(index / columns)], state, random, config);+ }++ return {+ width: (columns - 1) * pitch,+ height: (rows - 1) * pitch,+ columns,+ rows,+ fill: state.occupied / (columns * rows),+ traces: state.traces,+ };+}++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 motionCanvas;+ let motionContext;+ let brandGradient;+ let conductor;+ let resizeObserver;+ let resizeTimer;+ let frameRequest;+ let frameCount = 0;+ let activeTraces = 0;+ let bloomsTriggered = 0;+ let visuals = [];+ let blooms = [];+ let pointer = null;+ let motionRandom;+ let lastSize = "";+ let sceneStart = 0;+ let lastFrameTime = 0;+ let lastPaintTime = 0;+ let nextBloomAt = 0;+ let bloomCursor = 0;+ let focus = { x: 0, y: 0 };+ let rendererPixelRatio = 1;+ let targetFrameRate = 60;+ let frameInterval = 1000 / targetFrameRate;+ let energyBudget = 0;+ let sparkBudget = 0;+ let nodeBudget = 0;+ let drawnEnergyTraces = 0;+ let drawnSparks = 0;+ let drawnNodes = 0;+ const energyCandidates = [];+ const sparkCandidates = [];+ const nodeCandidates = [];+ 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 life() {+ return Math.max(0, Math.min(1.5, config.liveliness));+ }++ function featureScale() {+ return Math.max(0.25, config.featureScale);+ }++ function clamp(value, minimum = 0, maximum = 1) {+ return Math.max(minimum, Math.min(maximum, value));+ }++ function colorAt(position) {+ if (position < 0.46) return "#00e6dc";+ if (position < 0.76) return "#39b8ff";+ return "#8c6bf7";+ }++ function addVia(group, point) {+ const scale = featureScale();+ group.append(+ svgElement("circle", { cx: point[0], cy: point[1], r: config.viaOuterRadius * scale, fill: config.traceColor }),+ svgElement("circle", { cx: point[0], cy: point[1], r: config.viaInnerRadius * scale, 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 pitch = config.pitch * featureScale();+ const size = `${Math.floor(worldWidth / pitch)}x${Math.floor(worldHeight / pitch)}`;+ if (!force && size === lastSize) return false;+ lastSize = size;+ activeTraces = 0;+ visuals = [];+ blooms = [];+ conductor?.destroy();+ conductor = undefined;+ motionCanvas?.remove();+ motionCanvas = undefined;+ motionContext = undefined;+ brandGradient = undefined;+ svg?.remove();+ field = generateCircuitField({ ...config, width: worldWidth, height: worldHeight });+ const motionSeed = config.animationSystem === "conductor"+ ? `${config.seed}:${size}:signals`+ : `${config.seed}:${size}:adom-motion`;+ motionRandom = mulberry32(hashSeed(motionSeed));+ 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 traceLayer = svgElement("g", { "data-static-traces": "" });+ for (const trace of field.traces) {+ traceLayer.appendChild(svgElement("path", {+ d: pathData(trace.points), fill: "none", stroke: config.traceColor,+ "stroke-width": config.traceWidth * config.featureScale,+ "stroke-linecap": "butt", "stroke-linejoin": "miter",+ }));+ if (trace.viaStart) addVia(traceLayer, trace.points[0]);+ if (trace.viaEnd) addVia(traceLayer, trace.points.at(-1));+ }++ if (config.animationSystem === "none") {+ svg.append(traceLayer);+ mount.prepend(svg);+ return true;+ }++ if (config.animationSystem === "conductor") {+ svg.append(traceLayer);+ mount.prepend(svg);+ conductor = createConductorAnimation(svg, field, config, motionRandom);+ return true;+ }++ const angle = config.sweepAngle * Math.PI / 180;+ const axisX = Math.cos(angle);+ const axisY = Math.sin(angle);+ let projectionMin = Infinity;+ let projectionMax = -Infinity;+ const prepared = field.traces.map((trace, index) => {+ const midpoint = trace.points[Math.floor(trace.points.length / 2)];+ const projection = midpoint[0] * axisX + midpoint[1] * axisY;+ projectionMin = Math.min(projectionMin, projection);+ projectionMax = Math.max(projectionMax, projection);+ return { trace, index, midpoint, projection };+ });+ const projectionSpan = Math.max(1, projectionMax - projectionMin);+ const scale = featureScale();++ for (const preparedTrace of prepared) {+ const { trace, index, midpoint } = preparedTrace;+ const normalizedProjection = (preparedTrace.projection - projectionMin) / projectionSpan;+ const directionBias = trace.points[0][0] <= trace.points.at(-1)[0] ? 1 : -1;+ const sparkLength = clamp(trace.length * (0.1 + motionRandom() * 0.06), 10 * scale, 58 * scale);+ const endpoint = trace.points[(index + Math.round(normalizedProjection * 5)) % 2 ? 0 : trace.points.length - 1];+ let canvasPath = null;+ if (typeof Path2D === "function") {+ canvasPath = new Path2D();+ trace.points.forEach(([x, y], pointIndex) => {+ if (pointIndex) canvasPath.lineTo(x, y);+ else canvasPath.moveTo(x, y);+ });+ }+ visuals.push({+ trace, midpoint, projection: normalizedProjection,+ canvasPath, endpoint, nodeColor: colorAt(normalizedProjection),+ sparkLength, directionBias,+ charge: 0, phase: motionRandom(), sparkEligible: motionRandom() > 0.34,+ energyOpacity: 0, sparkOpacity: 0, nodeOpacity: 0,+ });+ }++ svg.append(traceLayer);+ mount.prepend(svg);++ const traceCount = visuals.length;+ targetFrameRate = traceCount > 1500 ? 24 : traceCount > 900 ? 30 : traceCount > 420 ? 45 : 60;+ frameInterval = 1000 / targetFrameRate;+ energyBudget = traceCount > 1500 ? 420 : traceCount > 900 ? 500 : traceCount > 420 ? 620 : traceCount;+ sparkBudget = traceCount > 1500 ? 28 : traceCount > 900 ? 36 : traceCount > 420 ? 46 : 58;+ nodeBudget = traceCount > 1500 ? 48 : traceCount > 900 ? 64 : traceCount > 420 ? 82 : 104;+ const deviceRatio = Math.max(1, window.devicePixelRatio || 1);+ rendererPixelRatio = Math.min(deviceRatio, traceCount > 1200 ? 1 : traceCount > 600 ? 1.25 : 1.5);++ motionCanvas = document.createElement("canvas");+ motionCanvas.setAttribute("aria-hidden", "true");+ motionCanvas.setAttribute("data-animation", "routed-field-canvas");+ Object.assign(motionCanvas.style, {+ position: "absolute", inset: "0", width: "100%", height: "100%",+ display: "block", pointerEvents: "none", mixBlendMode: "screen", zIndex: "1",+ });+ motionCanvas.width = Math.max(1, Math.round(pixelWidth * rendererPixelRatio));+ motionCanvas.height = Math.max(1, Math.round(pixelHeight * rendererPixelRatio));+ mount.appendChild(motionCanvas);+ motionContext = motionCanvas.getContext("2d", { alpha: true, desynchronized: true })+ || motionCanvas.getContext("2d");+ if (motionContext) {+ motionContext.setTransform(+ motionCanvas.width / field.width, 0,+ 0, motionCanvas.height / field.height, 0, 0,+ );+ brandGradient = motionContext.createLinearGradient(+ 0, field.height * 0.15, field.width, field.height * 0.85,+ );+ brandGradient.addColorStop(0, "#00e6dc");+ brandGradient.addColorStop(0.6, "#39b8ff");+ brandGradient.addColorStop(1, "#8c6bf7");+ }+ focus = { x: field.width * 0.5, y: field.height * 0.5 };+ return true;+ }++ function spawnBloom(origin = null, intensity = 1) {+ if (config.animationSystem !== "adom" || !config.motion || reducedMotion || !field?.traces.length) return false;+ const source = origin || { x: field.width * 0.24, y: field.height * 0.62 };+ const angle = Math.atan2(source.y - field.height * 0.5, source.x - field.width * 0.5);+ blooms.push({+ x: clamp(source.x, 0, field.width),+ y: clamp(source.y, 0, field.height),+ startedAt: performance.now(),+ duration: config.bloomDuration / clamp(life(), 0.55, 1.35),+ intensity: clamp(intensity, 0.35, 1.4),+ spin: angle / (Math.PI * 2) + motionRandom() * 0.18,+ });+ if (blooms.length > 3) blooms.shift();+ bloomsTriggered += 1;+ return true;+ }++ function spawnAutomaticBloom() {+ if (!visuals.length) return;+ const stride = Math.max(1, Math.floor(visuals.length * 0.381966));+ bloomCursor = (bloomCursor + stride) % visuals.length;+ const anchor = visuals[bloomCursor].midpoint;+ spawnBloom({ x: anchor[0], y: anchor[1] }, 0.78 + motionRandom() * 0.18);+ }++ function energyFromBloom(visual, bloom, timestamp, maximumDimension) {+ const progress = (timestamp - bloom.startedAt) / bloom.duration;+ if (progress < 0 || progress > 1) return 0;+ const dx = visual.midpoint[0] - bloom.x;+ const dy = visual.midpoint[1] - bloom.y;+ const distance = Math.hypot(dx, dy) / maximumDimension;+ const angle = (Math.atan2(dy, dx) / (Math.PI * 2) + 1 + bloom.spin) % 1;+ const arrival = distance * 1.08 + angle * 0.105;+ const distanceFromFront = progress - arrival;+ const crest = Math.exp(-(distanceFromFront * distanceFromFront) / (2 * 0.052 * 0.052));+ const afterglow = distanceFromFront > 0 && distanceFromFront < 0.24+ ? (1 - distanceFromFront / 0.24) ** 2 * 0.23+ : 0;+ return (crest + afterglow) * bloom.intensity;+ }++ function onPointerMove(event) {+ if (!field) return;+ 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(),+ };+ conductor?.pointerMoved(pointer);+ }++ function onPointerLeave() {+ if (pointer) pointer.at = performance.now() - 1800;+ }++ function onClick(event) {+ onPointerMove(event);+ if (config.animationSystem === "conductor") conductor?.clicked(pointer);+ else spawnBloom(pointer, 1.18);+ }++ function strokeCanvasPath(context, visual) {+ if (visual.canvasPath) {+ context.stroke(visual.canvasPath);+ return;+ }+ context.beginPath();+ visual.trace.points.forEach(([x, y], index) => {+ if (index) context.lineTo(x, y);+ else context.moveTo(x, y);+ });+ context.stroke();+ }++ function renderFrame(timestamp) {+ if (!motionCanvas?.isConnected || !motionContext || !visuals.length) return;+ frameRequest = window.requestAnimationFrame(renderFrame);+ if (document.hidden) return;+ const sincePaint = lastPaintTime ? timestamp - lastPaintTime : frameInterval;+ if (sincePaint < frameInterval) return;+ lastPaintTime = lastPaintTime+ ? timestamp - (sincePaint % frameInterval)+ : timestamp;+ frameCount += 1;+ const frameDelta = lastFrameTime ? clamp(timestamp - lastFrameTime, 8, 50) : 16;+ lastFrameTime = timestamp;++ const strength = life();+ const maximumDimension = Math.max(field.width, field.height);+ const elapsed = timestamp - sceneStart;+ const sweepPhase = ((elapsed % config.sweepDuration) + config.sweepDuration) % config.sweepDuration / config.sweepDuration;+ const sweepActive = sweepPhase < 0.73;+ const sweepFront = sweepActive ? -0.14 + sweepPhase / 0.73 * 1.28 : 2;+ const sweepWidth = config.sweepWidth / clamp(strength, 0.65, 1.25);++ if (timestamp >= nextBloomAt) {+ spawnAutomaticBloom();+ nextBloomAt = timestamp + config.bloomInterval / clamp(0.72 + strength * 0.28, 0.72, 1.12);+ }+ blooms = blooms.filter((bloom) => timestamp - bloom.startedAt <= bloom.duration);++ const pointerAge = pointer ? timestamp - pointer.at : Infinity;+ const pointerPresence = clamp(1 - pointerAge / 1450);+ const automaticFocus = {+ x: field.width * (0.5 + Math.sin(elapsed * 0.000095) * 0.27),+ y: field.height * (0.48 + Math.sin(elapsed * 0.000071 + 1.4) * 0.21),+ };+ const focusTarget = pointerPresence > 0.02 ? pointer : automaticFocus;+ const focusEase = pointerPresence > 0.02 ? 0.105 : 0.018;+ focus.x += (focusTarget.x - focus.x) * focusEase;+ focus.y += (focusTarget.y - focus.y) * focusEase;++ energyCandidates.length = 0;+ sparkCandidates.length = 0;+ nodeCandidates.length = 0;+ let active = 0;+ for (const visual of visuals) {+ const delta = sweepFront - visual.projection;+ const crest = sweepActive+ ? Math.exp(-(delta * delta) / (2 * sweepWidth * sweepWidth))+ : 0;+ const trail = sweepActive && delta > 0 && delta < 0.28+ ? (1 - delta / 0.28) ** 2 * 0.24+ : 0;+ let bloomEnergy = 0;+ for (const bloom of blooms) {+ bloomEnergy = Math.max(bloomEnergy, energyFromBloom(visual, bloom, timestamp, maximumDimension));+ }+ let pointerEnergy = 0;+ if (pointerPresence > 0.001) {+ const distance = Math.hypot(visual.midpoint[0] - pointer.x, visual.midpoint[1] - pointer.y);+ const radius = maximumDimension * config.pointerRadius;+ pointerEnergy = Math.exp(-(distance * distance) / (2 * radius * radius)) * pointerPresence * 0.42;+ }+ const ambient = 0.018 + (Math.sin(timestamp * 0.0005 + visual.phase * Math.PI * 2) + 1) * 0.006;+ const target = clamp((crest * 0.96 + trail + bloomEnergy + pointerEnergy + ambient) * strength, 0, 1.18);+ const response = target > visual.charge ? 1 - Math.exp(-frameDelta / 72) : 1 - Math.exp(-frameDelta / 610);+ visual.charge += (target - visual.charge) * response;++ const energyOpacity = clamp((visual.charge - 0.018) * 0.72, 0, 0.74);+ const sparkOpacity = visual.sparkEligible+ ? clamp((visual.charge - 0.25) * 1.48, 0, 0.92)+ : 0;+ const nodePulse = 0.82 + Math.sin(timestamp * 0.0031 + visual.phase * Math.PI * 2) * 0.18;+ const nodeOpacity = clamp((visual.charge - 0.34) * 1.65 * nodePulse, 0, 0.88);++ visual.energyOpacity = energyOpacity;+ visual.sparkOpacity = sparkOpacity;+ visual.nodeOpacity = nodeOpacity;+ if (energyOpacity > 0.008) energyCandidates.push(visual);+ if (sparkOpacity > 0.01) sparkCandidates.push(visual);+ if (nodeOpacity > 0.01) nodeCandidates.push(visual);+ if (visual.charge > 0.13) active += 1;+ }+ activeTraces = active;++ energyCandidates.sort((a, b) => b.energyOpacity - a.energyOpacity);+ sparkCandidates.sort((a, b) => b.sparkOpacity - a.sparkOpacity);+ nodeCandidates.sort((a, b) => b.nodeOpacity - a.nodeOpacity);+ drawnEnergyTraces = Math.min(energyBudget, energyCandidates.length);+ drawnSparks = Math.min(sparkBudget, sparkCandidates.length);+ drawnNodes = Math.min(nodeBudget, nodeCandidates.length);++ const context = motionContext;+ context.setTransform(1, 0, 0, 1, 0, 0);+ context.clearRect(0, 0, motionCanvas.width, motionCanvas.height);+ context.setTransform(+ motionCanvas.width / field.width, 0,+ 0, motionCanvas.height / field.height, 0, 0,+ );++ const focusWash = context.createRadialGradient(+ focus.x, focus.y, 0,+ focus.x, focus.y, maximumDimension * 0.54,+ );+ focusWash.addColorStop(0, "rgba(0,230,220,.16)");+ focusWash.addColorStop(0.35, "rgba(57,184,255,.075)");+ focusWash.addColorStop(0.72, "rgba(140,107,247,.025)");+ focusWash.addColorStop(1, "rgba(140,107,247,0)");+ context.globalCompositeOperation = "source-over";+ context.globalAlpha = (0.58 + pointerPresence * 0.24) * clamp(strength, 0, 1.2);+ context.fillStyle = focusWash;+ context.fillRect(0, 0, field.width, field.height);++ context.globalCompositeOperation = "lighter";+ context.strokeStyle = brandGradient;+ context.lineWidth = config.traceWidth * featureScale() * 1.18;+ context.lineCap = "round";+ context.lineJoin = "miter";+ context.setLineDash([]);+ for (let index = 0; index < drawnEnergyTraces; index += 1) {+ const visual = energyCandidates[index];+ context.globalAlpha = visual.energyOpacity;+ strokeCanvasPath(context, visual);+ }++ context.lineCap = "round";+ context.lineJoin = "round";+ for (let index = 0; index < drawnSparks; index += 1) {+ const visual = sparkCandidates[index];+ const distance = visual.trace.length + visual.sparkLength;+ const travel = (timestamp * (0.052 + strength * 0.026) + visual.phase * distance) % distance;+ context.setLineDash([visual.sparkLength, distance]);+ context.lineDashOffset = -travel * visual.directionBias;+ context.lineWidth = config.traceWidth * featureScale() * 3.15;+ context.globalAlpha = visual.sparkOpacity * 0.16;+ strokeCanvasPath(context, visual);+ context.lineWidth = config.traceWidth * featureScale() * 1.42;+ context.globalAlpha = visual.sparkOpacity;+ strokeCanvasPath(context, visual);+ }+ context.setLineDash([]);+ context.lineDashOffset = 0;++ const nodeRadius = config.viaInnerRadius * featureScale() * 0.78;+ for (let index = 0; index < drawnNodes; index += 1) {+ const visual = nodeCandidates[index];+ context.fillStyle = visual.nodeColor;+ context.globalAlpha = visual.nodeOpacity * 0.14;+ context.beginPath();+ context.arc(visual.endpoint[0], visual.endpoint[1], nodeRadius * 2.65, 0, Math.PI * 2);+ context.fill();+ context.globalAlpha = visual.nodeOpacity;+ context.beginPath();+ context.arc(visual.endpoint[0], visual.endpoint[1], nodeRadius, 0, Math.PI * 2);+ context.fill();+ }+ context.globalAlpha = 1;+ context.globalCompositeOperation = "source-over";+ }++ function stopMotion() {+ window.cancelAnimationFrame(frameRequest);+ conductor?.stop();+ frameRequest = undefined;+ activeTraces = 0;+ lastFrameTime = 0;+ lastPaintTime = 0;+ }++ function startMotion() {+ stopMotion();+ if (config.animationSystem === "none" || !config.motion || reducedMotion || life() <= 0.02) return;+ if (config.animationSystem === "conductor") {+ conductor?.start();+ return;+ }+ const now = performance.now();+ sceneStart = now - 760;+ nextBloomAt = now + 1180;+ frameCount = 0;+ spawnBloom({ x: field.width * 0.2, y: field.height * 0.64 }, 0.88);+ frameRequest = window.requestAnimationFrame(renderFrame);+ }++ function regenerate(nextOptions = {}) {+ stopMotion();+ config = { ...config, ...nextOptions };+ lastSize = "";+ build(true);+ startMotion();+ return api;+ }++ function exportSvg() {+ const clone = svg.cloneNode(true);+ clone.querySelectorAll("[data-animation]").forEach((layer) => layer.remove());+ clone.removeAttribute("style");+ return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;+ }++ function destroy() {+ stopMotion();+ conductor?.destroy();+ conductor = undefined;+ window.clearTimeout(resizeTimer);+ resizeObserver?.disconnect();+ interactionTarget.removeEventListener("pointermove", onPointerMove);+ interactionTarget.removeEventListener("pointerleave", onPointerLeave);+ interactionTarget.removeEventListener("click", onClick);+ motionCanvas?.remove();+ motionCanvas = undefined;+ motionContext = undefined;+ svg?.remove();+ mount.style.position = originalPosition;+ }++ const api = {+ regenerate,+ triggerBloom: spawnBloom,+ triggerAnimation(origin = null) {+ return config.animationSystem === "conductor"+ ? conductor?.trigger(origin)+ : spawnBloom(origin, 1.12);+ },+ exportSvg,+ destroy,+ get field() { return field; },+ get animationStats() {+ if (config.animationSystem === "none") {+ return {+ system: "none",+ animationNodes: 0,+ frameCount: 0,+ reducedMotion,+ };+ }+ if (config.animationSystem === "conductor") return conductor?.stats;+ return {+ system: "adom-routed-field",+ activeTraces,+ activeBlooms: blooms.length,+ bloomsTriggered,+ frameCount,+ renderer: "canvas",+ animationNodes: motionCanvas ? 1 : 0,+ traceCount: visuals.length,+ targetFrameRate,+ rendererPixelRatio,+ energyBudget,+ sparkBudget,+ nodeBudget,+ drawnEnergyTraces,+ drawnSparks,+ drawnNodes,+ reducedMotion,+ };+ },+ };+ build(true);+ resizeObserver = new ResizeObserver(() => {+ window.clearTimeout(resizeTimer);+ resizeTimer = window.setTimeout(() => {+ if (build()) {+ stopMotion();+ startMotion();+ }+ }, 220);+ });+ resizeObserver.observe(mount);+ if (config.interactive) {+ interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+ interactionTarget.addEventListener("pointerleave", onPointerLeave, { passive: true });+ interactionTarget.addEventListener("click", onClick);+ }+ startMotion();+ return api;+}++export { DEFAULTS, ANIMATION_PRESETS };
src/circuit-background-v5.jsadded+334@@ -0,0 +1,334 @@+import { createConductorAnimation } from "./circuit-animation-conductor.js?v=1";+import { createHandshakeAnimation } from "./circuit-animation-handshake.js?v=2";+import {+ createCircuitBackground as createLegacyCircuitBackground,+ DEFAULTS as GEOMETRY_DEFAULTS,+ ANIMATION_PRESETS as LEGACY_ANIMATION_PRESETS,+ generateCircuitField,+ pathData,+} from "./circuit-background-v4.js?geometry=1";++const SVG_NS = "http://www.w3.org/2000/svg";++const DEFAULTS = Object.freeze({+ ...GEOMETRY_DEFAULTS,+ animationSystem: "none",+ motion: false,+ liveliness: 0,+ atmosphereStrength: 0,+ handshakeInterval: 1650,+ handshakeSpeed: 230,+ handshakeLength: 48,+ maxHandshakes: 3,+});++const ANIMATION_PRESETS = Object.freeze({+ none: LEGACY_ANIMATION_PRESETS.none,+ conductor: LEGACY_ANIMATION_PRESETS.conductor,+ routed: LEGACY_ANIMATION_PRESETS.adom,+ handshake: Object.freeze({+ animationSystem: "handshake",+ motion: true,+ liveliness: 0.72,+ handshakeInterval: 1650,+ handshakeSpeed: 230,+ handshakeLength: 48,+ maxHandshakes: 3,+ atmosphereStrength: 0,+ }),+});++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 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;+}++function createModernCircuitBackground(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 conductor;+ let handshake;+ let resizeObserver;+ let resizeTimer;+ let pointer = null;+ let lastSize = "";+ const interactionTarget = mount.parentElement || mount;+ const readingZone = interactionTarget.querySelector?.("[data-circuit-reading-zone]") || null;+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;+ const originalPosition = mount.style.position;+ const originalOverflow = mount.style.overflow;+ if (getComputedStyle(mount).position === "static") mount.style.position = "relative";+ mount.style.overflow = "hidden";++ function life() {+ return Math.max(0, Math.min(1.5, config.liveliness));+ }++ function featureScale() {+ return Math.max(0.25, config.featureScale);+ }++ function addVia(group, point) {+ const scale = featureScale();+ group.append(+ svgElement("circle", {+ cx: point[0], cy: point[1], r: config.viaOuterRadius * scale, fill: config.traceColor,+ }),+ svgElement("circle", {+ cx: point[0], cy: point[1], r: config.viaInnerRadius * scale,+ fill: "var(--circuit-hole, #0d1117)",+ }),+ );+ }++ function destroyAnimationSystems() {+ conductor?.destroy();+ handshake?.destroy();+ conductor = undefined;+ handshake = undefined;+ }++ 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 pitch = config.pitch * featureScale();+ const size = `${Math.floor(worldWidth / pitch)}x${Math.floor(worldHeight / pitch)}`;+ if (!force && size === lastSize) return false;+ lastSize = size;++ destroyAnimationSystems();+ svg?.remove();+ field = generateCircuitField({ ...config, width: worldWidth, height: worldHeight });+ const motionNamespace = config.animationSystem === "conductor" ? "signals" : "handshakes";+ const motionRandom = mulberry32(hashSeed(`${config.seed}:${size}:${motionNamespace}`));++ 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 traceLayer = svgElement("g", { "data-static-traces": "" });+ for (const trace of field.traces) {+ traceLayer.appendChild(svgElement("path", {+ d: pathData(trace.points),+ fill: "none",+ stroke: config.traceColor,+ "stroke-width": config.traceWidth * config.featureScale,+ "stroke-linecap": "butt",+ "stroke-linejoin": "miter",+ }));+ if (trace.viaStart) addVia(traceLayer, trace.points[0]);+ if (trace.viaEnd) addVia(traceLayer, trace.points.at(-1));+ }+ svg.append(traceLayer);+ mount.prepend(svg);++ if (config.animationSystem === "conductor") {+ conductor = createConductorAnimation(svg, field, config, motionRandom);+ } else if (+ config.animationSystem === "handshake"+ && config.motion+ && !reducedMotion+ && life() > 0.02+ ) {+ handshake = createHandshakeAnimation(mount, field, config, motionRandom, { readingZone });+ }+ return true;+ }++ function onPointerMove(event) {+ if (!field) return;+ 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(),+ };+ conductor?.pointerMoved(pointer);+ handshake?.pointerMoved(pointer);+ }++ function onPointerLeave() {+ if (pointer) pointer.at = performance.now() - 3000;+ handshake?.pointerLeft();+ }++ function onClick(event) {+ onPointerMove(event);+ if (config.animationSystem === "conductor") conductor?.clicked(pointer);+ else if (config.animationSystem === "handshake") handshake?.clicked(pointer);+ }++ function stopMotion() {+ conductor?.stop();+ handshake?.stop();+ }++ function startMotion() {+ stopMotion();+ if (config.animationSystem === "none" || !config.motion || reducedMotion || life() <= 0.02) return;+ if (config.animationSystem === "conductor") conductor?.start();+ else if (config.animationSystem === "handshake") handshake?.start();+ }++ function regenerate(nextOptions = {}) {+ stopMotion();+ config = { ...config, ...nextOptions };+ lastSize = "";+ build(true);+ startMotion();+ return api;+ }++ function triggerAnimation(origin = null) {+ if (config.animationSystem === "conductor") return conductor?.trigger(origin) || false;+ if (config.animationSystem === "handshake") return handshake?.trigger(origin) || false;+ return false;+ }++ function exportSvg() {+ const clone = svg.cloneNode(true);+ clone.querySelectorAll("[data-animation]").forEach((layer) => layer.remove());+ clone.removeAttribute("style");+ return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;+ }++ function destroy() {+ stopMotion();+ destroyAnimationSystems();+ window.clearTimeout(resizeTimer);+ resizeObserver?.disconnect();+ interactionTarget.removeEventListener("pointermove", onPointerMove);+ interactionTarget.removeEventListener("pointerleave", onPointerLeave);+ interactionTarget.removeEventListener("click", onClick);+ svg?.remove();+ mount.style.position = originalPosition;+ mount.style.overflow = originalOverflow;+ }++ const api = {+ regenerate,+ triggerAnimation,+ triggerBloom: triggerAnimation,+ exportSvg,+ destroy,+ get field() { return field; },+ get animationStats() {+ if (config.animationSystem === "none") {+ return { system: "none", animationNodes: 0, frameCount: 0, reducedMotion };+ }+ if (config.animationSystem === "conductor") return conductor?.stats;+ if (config.animationSystem === "handshake") {+ return handshake?.stats || {+ system: "adom-handshake",+ animationNodes: 0,+ activeTransactions: 0,+ frameCount: 0,+ reducedMotion,+ };+ }+ return { system: "unknown", animationNodes: 0, frameCount: 0, reducedMotion };+ },+ };++ build(true);+ resizeObserver = new ResizeObserver(() => {+ window.clearTimeout(resizeTimer);+ resizeTimer = window.setTimeout(() => {+ if (build()) {+ stopMotion();+ startMotion();+ }+ }, 220);+ });+ resizeObserver.observe(mount);+ if (config.interactive) {+ interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+ interactionTarget.addEventListener("pointerleave", onPointerLeave, { passive: true });+ interactionTarget.addEventListener("click", onClick);+ }+ startMotion();+ return api;+}++export function createCircuitBackground(mount, options = {}) {+ let config = { ...DEFAULTS, ...options };+ let destroyed = false;+ const usesRoutedField = (value) => value.animationSystem === "adom";+ const createEngine = () => usesRoutedField(config)+ ? createLegacyCircuitBackground(mount, config)+ : createModernCircuitBackground(mount, config);+ let engine = createEngine();++ const api = {+ regenerate(nextOptions = {}) {+ if (destroyed) throw new Error("Cannot regenerate a destroyed circuit background");+ const wasRouted = usesRoutedField(config);+ config = { ...config, ...nextOptions };+ if (wasRouted !== usesRoutedField(config)) {+ engine.destroy();+ engine = createEngine();+ } else {+ engine.regenerate(nextOptions);+ }+ return api;+ },+ triggerAnimation(origin = null) {+ return engine.triggerAnimation?.(origin) || false;+ },+ triggerBloom(origin = null) {+ return engine.triggerBloom?.(origin) || engine.triggerAnimation?.(origin) || false;+ },+ exportSvg() {+ return engine.exportSvg();+ },+ destroy() {+ if (destroyed) return;+ destroyed = true;+ engine.destroy();+ },+ get field() { return engine.field; },+ get animationStats() { return engine.animationStats; },+ };++ return api;+}++export { DEFAULTS, ANIMATION_PRESETS, generateCircuitField, pathData };
src/circuit-background.jsadded+1830@@ -0,0 +1,1830 @@+const SVG_NS = "http://www.w3.org/2000/svg";++// Geometry constants measured from the reference artwork (adom.inc hero):+// a 114 x 57 cell routing grid with a 150/19 ~= 7.895 unit pitch, 1.58 unit+// traces, and 3.34 / 1.76 annular vias sitting exactly on grid nodes.+const DEFAULTS = Object.freeze({+ seed: "traces",+ tileWidth: 900,+ tileHeight: 450,+ pitch: 150 / 19,+ scale: 1000 / 900,+ density: 1.05,+ traceColor: "#151b25",+ traceWidth: 1.58,+ viaOuterRadius: 3.34,+ viaInnerRadius: 1.76,+ // --- generation tuning -----------------------------------------------+ formations: 96, // terminal groups (via ladders/rows) per tile+ busWidth: 1, // scales pins per formation => bundle width+ turnPenalty: 0.55, // higher = longer, calmer runs+ weave: 0.22, // preference for 2-lane spacing between neighbors+ cramp: 0.4, // aversion to 1-lane spacing outside bundles+ horizontalBias: 0.6, // 0.5 = isotropic, higher = more horizontal flow+ netReach: 42, // max cells a net may span+ jogBias: 5, // preferred endpoint misalignment => staircase jogs+ stubChance: 0, // leftover pins that get short escape stubs+ inlineViaChance: 0.03, // per-step chance of a mid-trace via+ combs: 8, // max tap-comb motifs per tile+ signalColors: [+ ["#00e6dc", 0.6],+ ["#64abff", 0.25],+ ["#c5b3ff", 0.15],+ ],+ signals: true,+ interactive: true,+ signalInterval: 240, // passive spawn cadence — a fresh signal appears near the cursor this often+ maxSignals: 12, // fill target for the ambient fountain (maxBurstSignals is the hard ceiling)+ spawnRadius: 110, // spawn scatter around the pointer (soft jitter, not a hard cutoff)+ signalSpeed: 420,+ signalLength: 120,+ burstBoost: 7, // click: how many extra signals join at once+ hopSurvival: 0.4, // per-hop survival for PASSIVE signals; low = short-lived, high = long chains+ burstSurvival: 0.72, // per-hop survival for CLICKED signals — tuned independently of hopSurvival+ hopRadius: 440, // a surviving signal hops to a via within this range+ colorDrift: 0.18, // chance per hop that a chain crossfades to a new color+ maxBurstSignals: 25,+});++// 8 directions, clockwise from east; even indices are cardinal.+const DIR8 = [+ [1, 0],+ [1, 1],+ [0, 1],+ [-1, 1],+ [-1, 0],+ [-1, -1],+ [0, -1],+ [1, -1],+];++// Walker start-direction weights derived from horizontalBias (0.5 = even).+function startDirectionWeights(horizontalBias) {+ const ew = 0.48 * horizontalBias;+ const ns = 0.24 * (1 - horizontalBias);+ const diagonal = Math.max(0.005, (1 - 2 * ew - 2 * ns) / 4);+ return [+ [0, ew],+ [4, ew],+ [2, ns],+ [6, ns],+ [1, diagonal],+ [3, diagonal],+ [5, diagonal],+ [7, diagonal],+ ];+}++let instanceCount = 0;++function svgEl(name, attrs = {}) {+ const node = document.createElementNS(SVG_NS, name);+ for (const [key, value] of Object.entries(attrs)) {+ node.setAttribute(key, String(value));+ }+ return node;+}++function hashSeed(input) {+ let hash = 2166136261;+ for (const char of String(input)) {+ hash ^= char.charCodeAt(0);+ hash = Math.imul(hash, 16777619);+ }+ return hash >>> 0;+}++function mulberry32(seed) {+ return function random() {+ let value = (seed += 0x6d2b79f5);+ value = Math.imul(value ^ (value >>> 15), value | 1);+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;+ };+}++function weightedChoice(random, entries) {+ let roll = random();+ for (const [value, weight] of entries) {+ roll -= weight;+ if (roll <= 0) return value;+ }+ return entries[0][0];+}++// --- routing engine ---------------------------------------------------------+//+// Occupancy lives on a doubled ("half step") grid wrapped on a torus: every+// cell step claims its end node and the midpoint it crosses, which blocks+// same-lane overlap and any crossing (two diagonals share a midpoint, a+// diagonal crosses a cardinal only through a node). The torus wrap is what+// makes the finished tile seamless.++function createRouter(random, columns, rows) {+ const wide = columns * 2;+ const tall = rows * 2;+ // Occupancy as a flat typed array — this sits in the A* hot path.+ const occupied = new Uint8Array(wide * tall);+ const viaNodes = new Uint8Array(wide * tall);++ const halfKey = (hx, hy) => (((hy % tall) + tall) % tall) * wide + (((hx % wide) + wide) % wide);+ const nodeKey = (x, y) => halfKey(x * 2, y * 2);++ function claim(trace, key) {+ occupied[key] = 1;+ trace.claims.push(key);+ trace.ownKeys.add(key);+ }++ return {+ columns,+ rows,+ nodeKey,+ nodeFree: (x, y) => !occupied[nodeKey(x, y)],+ crowding(x, y) {+ let count = 0;+ for (let dy = -4; dy <= 4; dy += 2) {+ for (let dx = -4; dx <= 4; dx += 2) {+ if (occupied[nodeKey(x + dx, y + dy)]) count += 1;+ }+ }+ return count;+ },+ stepFree(x, y, direction) {+ const [dx, dy] = DIR8[direction];+ return (+ !occupied[halfKey(x * 2 + dx, y * 2 + dy)] &&+ !occupied[halfKey(x * 2 + dx * 2, y * 2 + dy * 2)]+ );+ },+ // Like stepFree, but the destination node may be the (already-claimed)+ // goal pin the route is allowed to land on.+ routeStepFree(x, y, direction, goalKey) {+ const [dx, dy] = DIR8[direction];+ if (occupied[halfKey(x * 2 + dx, y * 2 + dy)]) return false;+ const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);+ return node === goalKey || !occupied[node];+ },+ // How close the nearest parallel neighbor sits, perpendicular to travel:+ // 1 = one lane away (cramped), 2 = two lanes away (the reference's even+ // weave), 0 = open field. The A* cost prefers 2 and avoids 1.+ laneNeighbors(x, y, direction) {+ const [dx, dy] = DIR8[direction];+ if (occupied[nodeKey(x - dy, y + dx)] || occupied[nodeKey(x + dy, y - dx)]) return 1;+ if (occupied[nodeKey(x - dy * 2, y + dx * 2)] || occupied[nodeKey(x + dy * 2, y - dx * 2)]) return 2;+ return 0;+ },+ claimNode(trace, x, y) {+ claim(trace, nodeKey(x, y));+ },+ claimStep(trace, x, y, direction) {+ const [dx, dy] = DIR8[direction];+ claim(trace, halfKey(x * 2 + dx, y * 2 + dy));+ let count = 1;+ const node = halfKey(x * 2 + dx * 2, y * 2 + dy * 2);+ if (!occupied[node]) {+ claim(trace, node);+ count = 2;+ }+ trace.stepClaims.push(count);+ },+ // Remove the record's most recent step and exactly the claims it made.+ // Only valid while nothing later than that step has claimed (sealEnd+ // never trims past the record's own last via).+ unclaimLastStep(record) {+ const count = record.stepClaims.pop() || 0;+ for (let index = 0; index < count; index += 1) {+ const key = record.claims.pop();+ occupied[key] = 0;+ record.ownKeys.delete(key);+ }+ const step = record.steps.pop();+ record.end = [record.end[0] - DIR8[step][0], record.end[1] - DIR8[step][1]];+ },+ // A via may not sit on or cardinally beside another via (their annular+ // rings would overlap at one-pitch spacing), and the four diagonal+ // midpoints around it must not carry someone else's trace.+ viaAllowed(trace, x, y) {+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {+ if (viaNodes[nodeKey(x + dx, y + dy)]) return false;+ }+ for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {+ const key = halfKey(x * 2 + dx, y * 2 + dy);+ if (occupied[key] && !trace.ownKeys.has(key)) return false;+ }+ return true;+ },+ placeVia(trace, x, y) {+ viaNodes[nodeKey(x, y)] = 1;+ trace.viaCells.push([x, y]);+ trace.viaSteps.add(trace.steps.length);+ for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {+ const key = halfKey(x * 2 + dx, y * 2 + dy);+ if (!occupied[key]) claim(trace, key);+ }+ },+ rollback(trace) {+ for (const key of trace.claims) occupied[key] = 0;+ for (const [x, y] of trace.viaCells) viaNodes[nodeKey(x, y)] = 0;+ },+ };+}++function newTraceRecord(x, y) {+ return {+ start: [x, y],+ end: [x, y],+ steps: [],+ claims: [],+ stepClaims: [], // claims added per step, so ends can be trimmed precisely+ ownKeys: new Set(),+ viaCells: [],+ viaSteps: new Set(),+ };+}++// Every trace must end on a via. If the end position has no clearance, trim+// the trace back one step at a time (never past its own last via) until a+// via fits. Returns false when no compliant ending exists.+function sealEnd(router, record, minSteps) {+ for (;;) {+ if (record.viaSteps.has(record.steps.length)) return true;+ const [x, y] = record.end;+ if (router.viaAllowed(record, x, y)) {+ router.placeVia(record, x, y);+ return true;+ }+ const floor = Math.max(minSteps, record.viaSteps.size ? Math.max(...record.viaSteps) : 0);+ if (record.steps.length <= floor) return false;+ router.unclaimLastStep(record);+ }+}++// --- A* net router ----------------------------------------------------------+//+// Routes run between terminal formations the way a human lays out a board:+// they escape a pin, join a corridor beside existing traces (hug bonus), pay+// for every 45 degree turn (long calm runs), pay a small surcharge for+// sustained diagonals (the reference uses short 45 degree jogs), and finish+// into the far pin. States are (node, incoming direction) so turn costs are+// exact. Everything wraps on the torus.++class MinHeap {+ constructor() {+ this.costs = [];+ this.values = [];+ }++ get size() {+ return this.costs.length;+ }++ push(cost, value) {+ const { costs, values } = this;+ let index = costs.length;+ costs.push(cost);+ values.push(value);+ while (index > 0) {+ const parent = (index - 1) >> 1;+ if (costs[parent] <= costs[index]) break;+ [costs[parent], costs[index]] = [costs[index], costs[parent]];+ [values[parent], values[index]] = [values[index], values[parent]];+ index = parent;+ }+ }++ pop() {+ const { costs, values } = this;+ const top = values[0];+ const lastCost = costs.pop();+ const lastValue = values.pop();+ if (costs.length) {+ costs[0] = lastCost;+ values[0] = lastValue;+ let index = 0;+ for (;;) {+ const left = index * 2 + 1;+ const right = left + 1;+ let smallest = index;+ if (left < costs.length && costs[left] < costs[smallest]) smallest = left;+ if (right < costs.length && costs[right] < costs[smallest]) smallest = right;+ if (smallest === index) break;+ [costs[smallest], costs[index]] = [costs[index], costs[smallest]];+ [values[smallest], values[index]] = [values[index], values[smallest]];+ index = smallest;+ }+ }+ return top;+ }+}++function createPathfinder(router, columns, rows, config) {+ const turnPenalty = config.turnPenalty;+ const crampPenalty = config.cramp;+ const weaveBonus = config.weave;+ const stateCount = columns * rows * 8;+ const bestCost = new Float64Array(stateCount);+ const seen = new Int32Array(stateCount);+ const closed = new Int32Array(stateCount);+ const parent = new Int32Array(stateCount);+ let generation = 0;++ const wrapX = (value) => ((value % columns) + columns) % columns;+ const wrapY = (value) => ((value % rows) + rows) % rows;++ // start: {x, y, direction}; goal: {x, y, arrivals: Set<direction>}+ // bundleMode flips the lane preference: followers rejoining their bundle+ // WANT to ride one lane off their neighbor.+ return function route(random, start, goal, why = {}, bundleMode = false) {+ generation += 1;+ const heap = new MinHeap();+ const goalX = wrapX(goal.x);+ const goalY = wrapY(goal.y);+ const goalKey = router.nodeKey(goalX, goalY);++ const heuristic = (x, y) => {+ let dx = Math.abs(x - goalX);+ dx = Math.min(dx, columns - dx);+ let dy = Math.abs(y - goalY);+ dy = Math.min(dy, rows - dy);+ return Math.max(dx, dy) + 0.42 * Math.min(dx, dy);+ };++ const startX = wrapX(start.x);+ const startY = wrapY(start.y);+ // Seed the facing direction free, and the two perpendiculars at a small+ // cost — a pin whose lane is congested can still hook out sideways.+ const h0 = heuristic(startX, startY);+ for (const [turn, cost] of [[0, 0], [2, 0.9], [6, 0.9]]) {+ const startState = (startY * columns + startX) * 8 + ((start.direction + turn) % 8);+ seen[startState] = generation;+ bestCost[startState] = cost;+ parent[startState] = -1;+ heap.push(cost + h0, startState);+ }++ const straightestFirst = heuristic(startX, startY);+ let goalState = -1;+ let expansions = 0;+ const TURNS = [0, 1, 7];++ // Weighted A* (heuristic scaled below): meaningfully faster, and the mild+ // suboptimality reads as organic hand-routing rather than error.+ while (heap.size && expansions < 9000) {+ expansions += 1;+ const state = heap.pop();+ if (closed[state] === generation) continue;+ closed[state] = generation;+ const direction = state % 8;+ const nodeIndex = (state - direction) / 8;+ const x = nodeIndex % columns;+ const y = (nodeIndex - x) / columns;++ if (x === goalX && y === goalY) {+ if (goal.arrivals.has(direction)) {+ goalState = state;+ break;+ }+ continue;+ }++ const cost = bestCost[state];+ for (const turn of TURNS) {+ const next = (direction + turn) % 8;+ if (!router.routeStepFree(x, y, next, goalKey)) continue;+ const nx = wrapX(x + DIR8[next][0]);+ const ny = wrapY(y + DIR8[next][1]);+ let stepCost = next % 2 === 0 ? 1 : 1.414;+ if (turn !== 0) stepCost += turnPenalty;+ const lane = router.laneNeighbors(nx, ny, next);+ if (bundleMode) {+ if (lane === 1) stepCost -= 0.22;+ } else if (lane === 1) stepCost += crampPenalty;+ else if (lane === 2) stepCost -= weaveBonus;+ stepCost += random() * 0.05;+ const nextState = (ny * columns + nx) * 8 + next;+ const total = cost + stepCost;+ if (seen[nextState] === generation && bestCost[nextState] <= total) continue;+ seen[nextState] = generation;+ bestCost[nextState] = total;+ parent[nextState] = state;+ heap.push(total + heuristic(nx, ny) * 1.2, nextState);+ }+ }++ if (goalState < 0) {+ why.reason = heap.size ? "expansions" : "exhausted";+ return null;+ }+ const directions = [];+ let cursor = goalState;+ while (parent[cursor] >= 0) {+ directions.push(cursor % 8);+ cursor = parent[cursor];+ }+ directions.reverse();+ // Reject absurd detours (torus wrap can produce them when hemmed in).+ if (directions.length > straightestFirst * 2.4 + 12) {+ why.reason = "detour";+ return null;+ }+ return directions;+ };+}++// --- terminal formations ----------------------------------------------------+//+// The reference pattern's vias are not sprinkled at random: they sit in small+// deliberate formations — diagonal ladders (staggered bus fanouts), rows, and+// singles — and traces escape them on a consistent side. Formations are placed+// with best-candidate sampling so the tile keeps even coverage and breathing+// room between groups.++function placeFormations(random, router, columns, rows, count, config) {+ const formations = [];+ const centers = [];+ const busWidth = config.busWidth;+ const horizontalEscape = Math.min(0.95, config.horizontalBias + 0.02);+ const rowSplit = 0.52 + 0.26 * config.horizontalBias;+ const wrapDist = (a, b, m) => {+ const d = Math.abs(a - b) % m;+ return Math.min(d, m - d);+ };++ for (let index = 0; index < count; index += 1) {+ let best = null;+ let bestClearance = -1;+ for (let candidate = 0; candidate < 8; candidate += 1) {+ const x = Math.floor(random() * columns);+ const y = Math.floor(random() * rows);+ let clearance = Infinity;+ for (const [cx, cy] of centers) {+ clearance = Math.min(+ clearance,+ Math.hypot(wrapDist(x, cx, columns), wrapDist(y, cy, rows)),+ );+ }+ if (clearance > bestClearance) {+ bestClearance = clearance;+ best = [x, y];+ }+ }++ const [x, y] = best;+ const roll = random();+ const pins = [];+ let signature;+ if (roll < 0.52) {+ // Diagonal via ladder — the staggered bus-fanout motif.+ const length = Math.max(2, Math.min(8, Math.round((3 + random() * 4) * busWidth)));+ const slope = random() < 0.5 ? -1 : 1;+ const escape = random() < horizontalEscape ? 0 : 4;+ signature = `ladder${slope}`;+ for (let k = 0; k < length; k += 1) pins.push({ x: x + k, y: y + k * slope, escape });+ } else if (roll < rowSplit) {+ // Vertical via row (2-cell spacing), horizontal escapes.+ const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));+ const escape = random() < 0.5 ? 0 : 4;+ signature = "vrow";+ for (let k = 0; k < length; k += 1) pins.push({ x, y: y + k * 2, escape });+ } else if (roll < 0.78) {+ // Horizontal via row, vertical escapes.+ const length = Math.max(2, Math.min(6, Math.round((2 + random() * 2) * busWidth)));+ const escape = random() < 0.5 ? 2 : 6;+ signature = "hrow";+ for (let k = 0; k < length; k += 1) pins.push({ x: x + k * 2, y, escape });+ } else {+ signature = "solo";+ pins.push({ x, y, escape: [0, 4, 2, 6][Math.floor(random() * 4)] });+ }++ // Reserve the pin nodes now; the via graphics are only placed when a net+ // or stub actually connects, so a formation that stays unconnected leaves+ // no orphaned via row behind.+ const record = newTraceRecord(pins[0].x, pins[0].y);+ let placeable = true;+ for (const pin of pins) {+ if (!router.nodeFree(pin.x, pin.y)) {+ placeable = false;+ break;+ }+ router.claimNode(record, pin.x, pin.y);+ }+ if (!placeable) {+ router.rollback(record);+ continue;+ }+ centers.push([x, y]);+ formations.push({ pins: pins.map((pin) => ({ ...pin, used: false })), record, signature });+ }+ return formations;+}++// Start a trace record at a pin with its mandatory via already in place.+// Returns null when the pin has no via clearance (the pin is then skipped —+// every trace must begin on a via).+function startAtPin(router, pin) {+ const record = newTraceRecord(pin.x, pin.y);+ if (!router.viaAllowed(record, pin.x, pin.y)) return null;+ router.placeVia(record, pin.x, pin.y);+ return record;+}++function signedWrap(delta, modulus) {+ let d = ((delta % modulus) + modulus) % modulus;+ if (d > modulus / 2) d -= modulus;+ return d;+}++// A route may enter a pin from the facing side or hook in from up to 90+// degrees off — only a straight-through backside entry is forbidden.+const ARRIVALS = DIR8.map((_, escape) => {+ const reverse = (escape + 4) % 8;+ return new Set([+ (reverse + 6) % 8,+ (reverse + 7) % 8,+ reverse,+ (reverse + 1) % 8,+ (reverse + 2) % 8,+ ]);+});++// Route bundles of nets between compatible formations. Only the FIRST pin+// pair is routed with A*; the remaining pins get pure translated copies of+// the leader. Translation is what the reference does: neighbors that bend at+// the same places, producing nested chevrons and group staircases. Followers+// that hit an obstruction stop early and drop their via there — a natural+// fanout.+function routeNets(random, router, route, formations, addRecord, stats, config) {+ const majorFor = (escape) => (escape === 0 || escape === 4 ? 0 : 1);+ const sameNode = (x1, y1, x2, y2) =>+ router.nodeKey(x1, y1) === router.nodeKey(x2, y2);++ for (const formation of formations) {+ const minePins = formation.pins.filter((pin) => !pin.used);+ if (!minePins.length) continue;++ const escape = formation.pins[0].escape;+ const axis = majorFor(escape);+ // Find a partner formation whose pins face back toward this one; a+ // partner with the same shape nests perfectly under translation.+ let partner = null;+ let partnerScore = Infinity;+ for (const other of formations) {+ if (other === formation) continue;+ const otherFree = other.pins.filter((pin) => !pin.used);+ if (!otherFree.length) continue;+ if (other.pins[0].escape !== (escape + 4) % 8) continue;+ const dx = signedWrap(other.pins[0].x - formation.pins[0].x, router.columns);+ const dy = signedWrap(other.pins[0].y - formation.pins[0].y, router.rows);+ const major = axis === 0 ? dx : dy;+ const minor = axis === 0 ? dy : dx;+ const forward = escape === 0 || escape === 2 ? major : -major;+ if (forward < 8 || forward > config.netReach) continue;+ if (Math.abs(minor) > 12) continue;+ // Prefer a few cells of misalignment: it forces the staircase jogs the+ // reference is full of. Perfect alignment yields sterile laser lines.+ let score =+ Math.abs(Math.abs(minor) - config.jogBias) * 0.8 ++ Math.abs(forward - 22) * 0.3 ++ random() * 5;+ if (other.signature === formation.signature) score -= 4;+ if (score < partnerScore) {+ partnerScore = score;+ partner = other;+ }+ }+ if (!partner) continue;++ const minorOf = (pin) => (axis === 0 ? pin.y : pin.x);+ const mine = minePins.sort((a, b) => minorOf(a) - minorOf(b));+ const theirs = partner.pins+ .filter((pin) => !pin.used)+ .sort((a, b) => minorOf(a) - minorOf(b));+ const pairCount = Math.min(mine.length, theirs.length);+ if (!pairCount) continue;++ // Leader. Both pin vias go in before routing — every trace begins and+ // ends on a via, so a net is only viable if both terminals have+ // clearance.+ const leadFrom = mine[0];+ const leadTo = theirs[0];+ const leadRecord = startAtPin(router, leadFrom);+ if (!leadRecord) continue;+ if (!router.viaAllowed(leadRecord, leadTo.x, leadTo.y)) {+ router.rollback(leadRecord);+ continue;+ }+ router.placeVia(leadRecord, leadTo.x, leadTo.y);+ const why = {};+ const directions = route(+ random,+ { x: leadFrom.x, y: leadFrom.y, direction: leadFrom.escape },+ { x: leadTo.x, y: leadTo.y, arrivals: ARRIVALS[leadTo.escape] },+ why,+ );+ if (!directions) {+ router.rollback(leadRecord);+ stats.leaderFails += 1;+ stats[why.reason] = (stats[why.reason] || 0) + 1;+ continue;+ }+ stats.leaders += 1;+ stats.followerTries += pairCount - 1;+ {+ let x = leadFrom.x;+ let y = leadFrom.y;+ for (const step of directions) {+ router.claimStep(leadRecord, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ leadRecord.steps.push(step);+ }+ leadRecord.end = [x, y];+ leadFrom.used = true;+ leadTo.used = true;+ addRecord(leadRecord);+ }++ // Followers: translated copies pin-by-pin. A follower whose translated+ // corridor is blocked early gets rerouted with A* in bundle mode so it+ // wraps the obstacle and rejoins the group. Every follower starts on its+ // pin via and seals its far end with a via (trimming back if needed).+ for (let index = 1; index < pairCount; index += 1) {+ const from = mine[index];+ const to = theirs[index];+ const goalKey = router.nodeKey(to.x, to.y);++ const record = startAtPin(router, from);+ if (!record) {+ stats.followerFails += 1;+ continue;+ }++ const clearSteps = () => {+ while (record.steps.length) router.unclaimLastStep(record);+ };+ const layTranslation = () => {+ let x = from.x;+ let y = from.y;+ for (const step of directions) {+ if (!router.routeStepFree(x, y, step, goalKey)) break;+ router.claimStep(record, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ record.steps.push(step);+ if (sameNode(x, y, to.x, to.y)) break;+ }+ record.end = [x, y];+ };++ layTranslation();+ let reached = sameNode(record.end[0], record.end[1], to.x, to.y);+ if (!reached && record.steps.length < Math.max(6, Math.floor(directions.length * 0.6))) {+ clearSteps();+ const alt = route(+ random,+ { x: from.x, y: from.y, direction: from.escape },+ { x: to.x, y: to.y, arrivals: ARRIVALS[to.escape] },+ {},+ true,+ );+ if (alt) {+ let x = from.x;+ let y = from.y;+ for (const step of alt) {+ router.claimStep(record, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ record.steps.push(step);+ }+ record.end = [x, y];+ reached = true;+ stats.followersRerouted += 1;+ } else {+ layTranslation();+ reached = sameNode(record.end[0], record.end[1], to.x, to.y);+ }+ }++ if (record.steps.length < 4 || !sealEnd(router, record, 4)) {+ router.rollback(record);+ stats.followerFails += 1;+ continue;+ }+ from.used = true;+ if (sameNode(record.end[0], record.end[1], to.x, to.y)) {+ to.used = true;+ stats.followersFull += 1;+ } else {+ stats.followersPartial += 1;+ }+ addRecord(record);+ }+ }+}++// Tap combs: short perpendicular stubs dropping off a long run at a regular+// pitch, each ending in a via — the power-rail tap motif that punctuates the+// reference artwork.+function tapCombs(random, router, records, addRecord, config) {+ const runs = [];+ for (const record of records) {+ let x = record.start[0];+ let y = record.start[1];+ let runX = x;+ let runY = y;+ let runDir = -1;+ let runLen = 0;+ const flush = () => {+ if (runDir >= 0 && runDir % 2 === 0 && runLen >= 8) {+ runs.push({ x: runX, y: runY, dir: runDir, len: runLen });+ }+ };+ for (const step of record.steps) {+ if (step === runDir) runLen += 1;+ else {+ flush();+ runDir = step;+ runX = x;+ runY = y;+ runLen = 1;+ }+ x += DIR8[step][0];+ y += DIR8[step][1];+ }+ flush();+ }++ let combs = 0;+ for (const run of runs) {+ if (combs >= config.combs || random() >= 0.28) continue;+ const [dx, dy] = DIR8[run.dir];+ const side = random() < 0.5 ? 1 : -1;+ const px = -dy * side;+ const py = dx * side;+ const tapDir = DIR8.findIndex(([tx, ty]) => tx === px && ty === py);+ const teeth = 2 + Math.floor(random() * 3);+ // Depth 2-3: the junction via and tip via need a two-cell gap to clear+ // each other's annular rings.+ const depth = 2 + (random() < 0.35 ? 1 : 0);+ const span = teeth * 2;+ if (run.len < span + 2) continue;+ const offset = 1 + Math.floor(random() * (run.len - span - 1));+ const made = [];+ for (let tooth = 0; tooth < teeth; tooth += 1) {+ const bx = run.x + dx * (offset + tooth * 2);+ const by = run.y + dy * (offset + tooth * 2);+ // The tap starts ON the parent trace (a T junction, marked with its+ // own via) and steps outward to a tip via.+ const record = newTraceRecord(bx, by);+ if (!router.viaAllowed(record, bx, by)) continue;+ router.placeVia(record, bx, by);+ let cx = bx;+ let cy = by;+ for (let d = 0; d < depth; d += 1) {+ if (!router.stepFree(cx, cy, tapDir)) break;+ router.claimStep(record, cx, cy, tapDir);+ cx += px;+ cy += py;+ record.steps.push(tapDir);+ }+ record.end = [cx, cy];+ if (record.steps.length < 2 || !sealEnd(router, record, 2)) {+ router.rollback(record);+ continue;+ }+ made.push(record);+ }+ if (made.length >= 2) {+ for (const record of made) addRecord(record);+ combs += 1;+ } else {+ for (const record of made) router.rollback(record);+ }+ }+}++// Short bare-ended escape stubs for pins that never got a net — the reference+// is full of these little launches.+function routeStubs(random, router, formations, addRecord, config) {+ for (const formation of formations) {+ for (const pin of formation.pins) {+ if (pin.used || random() >= config.stubChance) continue;+ const record = startAtPin(router, pin);+ if (!record) continue;+ let x = pin.x;+ let y = pin.y;+ let direction = pin.escape;+ const length = 2 + Math.floor(random() * 5);+ for (let step = 0; step < length; step += 1) {+ if (step > 0 && random() < 0.2) {+ const jog = (direction + (random() < 0.5 ? 1 : 7)) % 8;+ if (router.stepFree(x, y, jog)) direction = jog;+ }+ if (!router.stepFree(x, y, direction)) break;+ router.claimStep(record, x, y, direction);+ x += DIR8[direction][0];+ y += DIR8[direction][1];+ record.steps.push(direction);+ }+ record.end = [x, y];+ if (record.steps.length < 2 || !sealEnd(router, record, 2)) {+ router.rollback(record);+ continue;+ }+ pin.used = true;+ addRecord(record);+ }+ }+}++// Translated companions for a filler walk — same idea as the net followers:+// staggered trims and via ladders at the ends, bending wherever the base+// bends. In the reference almost nothing travels alone.+function replicateWalk(random, router, base, addRecord) {+ if (random() >= 0.55 || base.steps.length < 8) return;+ let horizontalSteps = 0;+ let verticalSteps = 0;+ for (const step of base.steps) {+ if (step === 0 || step === 4) horizontalSteps += 1;+ else if (step === 2 || step === 6) verticalSteps += 1;+ }+ const horizontal = horizontalSteps >= verticalSteps;+ const spacing = random() < 0.7 ? 1 : 2;+ const side = random() < 0.5 ? 1 : -1;+ const shift = horizontal ? [0, spacing * side] : [spacing * side, 0];+ const copies = 1 + Math.floor(random() * 3);+ const trimStartUnit = random() < 0.5 ? 1 : 0;+ const trimEndUnit = random() < 0.8 ? 1 : 2;++ for (let copy = 1; copy <= copies; copy += 1) {+ const trimStart = copy * trimStartUnit;+ const trimEnd = copy * trimEndUnit;+ if (base.steps.length - trimStart - trimEnd < 5) break;+ let x = base.start[0] + shift[0] * copy;+ let y = base.start[1] + shift[1] * copy;+ for (let index = 0; index < trimStart; index += 1) {+ x += DIR8[base.steps[index]][0];+ y += DIR8[base.steps[index]][1];+ }+ if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;+ const record = newTraceRecord(x, y);+ router.claimNode(record, x, y);+ router.placeVia(record, x, y); // every trace starts on a via+ for (let index = trimStart; index < base.steps.length - trimEnd; index += 1) {+ const step = base.steps[index];+ if (!router.stepFree(x, y, step)) break;+ router.claimStep(record, x, y, step);+ x += DIR8[step][0];+ y += DIR8[step][1];+ record.steps.push(step);+ }+ record.end = [x, y];+ if (record.steps.length < 5 || !sealEnd(router, record, 5)) {+ router.rollback(record);+ continue;+ }+ addRecord(record);+ }+}++// --- filler walker ----------------------------------------------------------+//+// A small amount of free-roaming texture between the routed bundles, using the+// same collision rules. Kept from the earlier engine but used sparingly.++const SCRATCH_RECORD = { ownKeys: new Set() };++function walkTrace(random, router, config, startWeights) {+ let sx = -1;+ let sy = -1;+ let bestCrowding = Infinity;+ for (let attempt = 0, found = 0; attempt < 24 && found < 3; attempt += 1) {+ const x = Math.floor(random() * router.columns);+ const y = Math.floor(random() * router.rows);+ if (!router.nodeFree(x, y) || !router.viaAllowed(SCRATCH_RECORD, x, y)) continue;+ found += 1;+ const crowding = router.crowding(x, y);+ if (crowding < bestCrowding) {+ bestCrowding = crowding;+ sx = x;+ sy = y;+ }+ }+ if (sx < 0) return null;++ const trace = newTraceRecord(sx, sy);+ router.claimNode(trace, sx, sy);+ router.placeVia(trace, sx, sy); // every trace starts on a via++ let direction = weightedChoice(random, startWeights);+ let lastCardinal = direction % 2 === 0 ? direction : (direction + (random() < 0.5 ? 1 : 7)) % 8;+ const budget = 14 + Math.floor(random() * 22);+ let x = sx;+ let y = sy;+ let sinceVia = 4;+ let turnStreak = 0; // consecutive same-side turns; capped to avoid closed loops++ while (trace.steps.length < budget) {+ const cardinal = direction % 2 === 0;+ const runLength = cardinal+ ? 3 + Math.floor(random() * random() * 9)+ : 1 + Math.floor(random() * 3);++ for (let step = 0; step < runLength && trace.steps.length < budget; step += 1) {+ if (!router.stepFree(x, y, direction)) break;+ router.claimStep(trace, x, y, direction);+ x += DIR8[direction][0];+ y += DIR8[direction][1];+ trace.steps.push(direction);+ sinceVia += 1;+ if (+ cardinal &&+ sinceVia > 4 &&+ trace.steps.length < budget - 2 &&+ random() < config.inlineViaChance &&+ router.viaAllowed(trace, x, y)+ ) {+ router.placeVia(trace, x, y);+ sinceVia = 0;+ }+ }++ if (trace.steps.length >= budget) break;++ let next;+ if (cardinal) {+ // Curling the same way repeatedly closes octagon loops; alternate.+ const side = Math.abs(turnStreak) >= 3 ? -Math.sign(turnStreak) : random() < 0.5 ? 1 : -1;+ next = (direction + (side > 0 ? 1 : 7)) % 8;+ } else {+ const optionA = (direction + 1) % 8;+ const optionB = (direction + 7) % 8;+ const preferPrevious = (optionA === lastCardinal || optionB === lastCardinal) && random() < 0.7;+ next = preferPrevious ? lastCardinal : random() < 0.5 ? optionA : optionB;+ }+ if (!router.stepFree(x, y, next)) {+ const mirrored = (direction + (8 - ((next - direction + 8) % 8))) % 8;+ if (!router.stepFree(x, y, mirrored)) break;+ next = mirrored;+ }+ const turnSide = ((next - direction + 8) % 8) === 1 ? 1 : -1;+ turnStreak = Math.sign(turnStreak) === turnSide ? turnStreak + turnSide : turnSide;+ direction = next;+ if (direction % 2 === 0) lastCardinal = direction;+ }++ trace.end = [x, y];+ if (trace.steps.length < 7 || !sealEnd(router, trace, 7)) {+ router.rollback(trace);+ return null;+ }+ return trace;+}++function toTileTrace(record, pitch) {+ const points = [[record.start[0], record.start[1]]];+ let previous = -1;+ let [x, y] = record.start;+ for (const step of record.steps) {+ x += DIR8[step][0];+ y += DIR8[step][1];+ if (step === previous) points[points.length - 1] = [x, y];+ else points.push([x, y]);+ previous = step;+ }+ const scaled = points.map(([px, py]) => [px * pitch, py * pitch]);+ let length = 0;+ for (let index = 1; index < scaled.length; index += 1) {+ length += Math.hypot(+ scaled[index][0] - scaled[index - 1][0],+ scaled[index][1] - scaled[index - 1][1],+ );+ }+ return {+ points: scaled,+ length,+ vias: record.viaCells.map(([vx, vy]) => [vx * pitch, vy * pitch]),+ };+}++export function generateCircuitTile(options = {}) {+ const config = { ...DEFAULTS, ...options };+ const random = mulberry32(hashSeed(config.seed));+ const columns = Math.round(config.tileWidth / config.pitch);+ const rows = Math.round(config.tileHeight / config.pitch);+ const router = createRouter(random, columns, rows);+ const route = createPathfinder(router, columns, rows, config);+ const startWeights = startDirectionWeights(config.horizontalBias);++ const records = [];+ let routedSteps = 0;+ const addRecord = (record) => {+ records.push(record);+ routedSteps += record.steps.length;+ };++ // 1. Terminal formations: via ladders, rows, singles.+ const formations = placeFormations(+ random,+ router,+ columns,+ rows,+ Math.round(config.formations * config.density),+ config,+ );+ for (const formation of formations) records.push(formation.record);++ // 2. Nets between formations — the flow. Two passes so pins left over from+ // the first pairing find another partner.+ const stats = {+ formations: formations.length,+ leaders: 0,+ leaderFails: 0,+ followerTries: 0,+ followersFull: 0,+ followersPartial: 0,+ followersRerouted: 0,+ followerFails: 0,+ };+ routeNets(random, router, route, formations, addRecord, stats, config);+ routeNets(random, router, route, formations, addRecord, stats, config);++ // 3. Escape stubs for leftover pins.+ routeStubs(random, router, formations, addRecord, config);++ // 4. A sprinkle of free-roaming filler until the reference ink density.+ const targetSteps = Math.round(4800 * config.density);+ let attempts = 0;+ while (routedSteps < targetSteps && attempts < 3200) {+ attempts += 1;+ const trace = walkTrace(random, router, config, startWeights);+ if (trace) {+ addRecord(trace);+ replicateWalk(random, router, trace, addRecord);+ }+ }++ // 5. Tap combs hanging off the long runs (nets and filler alike).+ if (config.combs > 0) tapCombs(random, router, [...records], addRecord, config);++ return {+ width: columns * config.pitch,+ height: rows * config.pitch,+ pitch: config.pitch,+ stats,+ traces: records.map((record) => toTileTrace(record, config.pitch)),+ };+}++export function pathData(points, offsetX = 0, offsetY = 0) {+ return points+ .map(([x, y], index) => `${index ? "L" : "M"}${(x + offsetX).toFixed(2)},${(y + offsetY).toFixed(2)}`)+ .join("");+}++// Standalone seamless tile: the geometry is drawn 3 x 3 around the viewBox and+// clipped, so traces that wrap the torus edge reappear on the opposite side.+export function tileSvg(tile, options = {}) {+ const config = { ...DEFAULTS, ...options };+ const { width, height } = tile;+ const body = [];+ for (const trace of tile.traces) {+ if (trace.points.length < 2) continue;+ body.push(+ `<path d="${pathData(trace.points)}" fill="none" stroke="${config.traceColor}" ` ++ `stroke-width="${config.traceWidth}" stroke-linecap="butt" stroke-linejoin="miter"/>`,+ );+ }+ const vias = [];+ for (const trace of tile.traces) {+ for (const [cx, cy] of trace.vias) {+ vias.push(+ `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaOuterRadius}" fill="${config.traceColor}"/>` ++ `<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${config.viaInnerRadius}" fill="${config.holeColor || config.background || "#0d1117"}"/>`,+ );+ }+ }+ const uses = [];+ for (let row = -1; row <= 1; row += 1) {+ for (let column = -1; column <= 1; column += 1) {+ uses.push(`<use href="#tile" transform="translate(${column * width}, ${row * height})"/>`);+ }+ }+ const backgroundRect = config.background+ ? `<rect width="${width}" height="${height}" fill="${config.background}"/>`+ : "";+ return (+ `<?xml version="1.0" encoding="UTF-8"?>\n` ++ `<svg xmlns="${SVG_NS}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` ++ `<defs><g id="tile">${body.join("")}${vias.join("")}</g>` ++ `<clipPath id="edge"><rect width="${width}" height="${height}"/></clipPath></defs>` ++ `${backgroundRect}<g clip-path="url(#edge)">${uses.join("")}</g></svg>`+ );+}++// --- signal routing ---------------------------------------------------------+//+// Signals ride the real centerlines. Each trace is resampled to ~3.5 unit+// spacing and indexed in a spatial hash; when a pulse reaches the end of a+// trace it looks for a nearby, direction-aligned point on another trace (in+// this tile or a neighbor) and hops there, exactly like the reference site.++const SIGNAL_SAMPLE = 3.5;+const HOP_RADIUS = 3.5;+const HASH_CELL = 8;+const signalIndexCache = new WeakMap();++function ensureSignalIndex(tile) {+ let index = signalIndexCache.get(tile);+ if (index) return index;+ const dense = tile.traces.map((trace) => {+ if (trace.points.length < 2) return [];+ const points = [];+ for (let segment = 1; segment < trace.points.length; segment += 1) {+ const [ax, ay] = trace.points[segment - 1];+ const [bx, by] = trace.points[segment];+ const steps = Math.max(1, Math.round(Math.hypot(bx - ax, by - ay) / SIGNAL_SAMPLE));+ for (let step = segment === 1 ? 0 : 1; step <= steps; step += 1) {+ points.push([ax + ((bx - ax) * step) / steps, ay + ((by - ay) * step) / steps]);+ }+ }+ return points;+ });+ const buckets = new Map();+ dense.forEach((points, traceIndex) => {+ points.forEach((point, pointIndex) => {+ const key = `${Math.floor(point[0] / HASH_CELL)}:${Math.floor(point[1] / HASH_CELL)}`;+ let bucket = buckets.get(key);+ if (!bucket) buckets.set(key, (bucket = []));+ bucket.push([traceIndex, pointIndex]);+ });+ });+ // Trace endpoints (all mandated vias) — the launch pads for via hopping.+ const endpoints = [];+ dense.forEach((points, traceIndex) => {+ if (points.length < 6) return;+ endpoints.push({ trace: traceIndex, point: 0, direction: 1, x: points[0][0], y: points[0][1] });+ const last = points.length - 1;+ endpoints.push({ trace: traceIndex, point: last, direction: -1, x: points[last][0], y: points[last][1] });+ });+ index = { dense, buckets, endpoints };+ signalIndexCache.set(tile, index);+ return index;+}++function findHop(tile, absX, absY, dirX, dirY, visited) {+ const { dense, buckets } = ensureSignalIndex(tile);+ const baseTileX = Math.floor(absX / tile.width);+ const baseTileY = Math.floor(absY / tile.height);+ let best = null;++ for (let tileY = baseTileY - 2; tileY <= baseTileY + 2; tileY += 1) {+ for (let tileX = baseTileX - 2; tileX <= baseTileX + 2; tileX += 1) {+ const localX = absX - tileX * tile.width;+ const localY = absY - tileY * tile.height;+ const minCellX = Math.floor((localX - HOP_RADIUS) / HASH_CELL);+ const maxCellX = Math.floor((localX + HOP_RADIUS) / HASH_CELL);+ const minCellY = Math.floor((localY - HOP_RADIUS) / HASH_CELL);+ const maxCellY = Math.floor((localY + HOP_RADIUS) / HASH_CELL);+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {+ const bucket = buckets.get(`${cellX}:${cellY}`);+ if (!bucket) continue;+ for (const [traceIndex, pointIndex] of bucket) {+ if (visited.has(`${traceIndex}:${tileX}:${tileY}`)) continue;+ const point = dense[traceIndex][pointIndex];+ const distance = Math.hypot(point[0] - localX, point[1] - localY);+ if (distance >= HOP_RADIUS) continue;+ if (distance >= 1.2) {+ const toward =+ ((point[0] - localX) * dirX + (point[1] - localY) * dirY) / (distance || 1);+ if (toward < 0.35) continue;+ }+ for (const hopDirection of [1, -1]) {+ const ahead = pointIndex + hopDirection * 3;+ if (ahead < 0 || ahead >= dense[traceIndex].length) continue;+ const segX = dense[traceIndex][ahead][0] - point[0];+ const segY = dense[traceIndex][ahead][1] - point[1];+ const segLength = Math.hypot(segX, segY) || 1;+ const score = (segX * dirX + segY * dirY) / segLength - distance * 0.05;+ if (!best || score > best.score) {+ best = {+ trace: traceIndex,+ tileX,+ tileY,+ point: pointIndex,+ direction: hopDirection,+ score,+ };+ }+ }+ }+ }+ }+ }+ }+ return best && best.score > 0.1 ? best : null;+}++function buildSignalPath(tile, start, budget, minLength) {+ const { dense } = ensureSignalIndex(tile);+ const visited = new Set();+ const points = [];+ let travelled = 0;+ let cursor = { ...start };++ // The walk always finishes at a trace end (a mandated via) — the budget+ // only limits further mid-route hops, never cuts a pulse off mid-trace.+ for (;;) {+ const tracePoints = dense[cursor.trace];+ visited.add(`${cursor.trace}:${cursor.tileX}:${cursor.tileY}`);+ const offsetX = cursor.tileX * tile.width;+ const offsetY = cursor.tileY * tile.height;+ for (+ let index = cursor.point;+ index >= 0 && index < tracePoints.length;+ index += cursor.direction+ ) {+ const x = tracePoints[index][0] + offsetX;+ const y = tracePoints[index][1] + offsetY;+ if (points.length) {+ const last = points[points.length - 1];+ travelled += Math.hypot(x - last[0], y - last[1]);+ }+ points.push([x, y]);+ }+ if (points.length < 2 || travelled >= budget) break;++ const last = points[points.length - 1];+ const before = points[points.length - 2];+ const stepLength = Math.hypot(last[0] - before[0], last[1] - before[1]) || 1;+ const hop = findHop(+ tile,+ last[0],+ last[1],+ (last[0] - before[0]) / stepLength,+ (last[1] - before[1]) / stepLength,+ visited,+ );+ if (!hop) break;+ cursor = hop;+ }++ return travelled >= minLength ? { points, length: travelled } : null;+}++// --- DOM mount ---------------------------------------------------------------++export function createCircuitBackground(mount, options = {}) {+ if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");++ const id = `circuit-${++instanceCount}`;+ let config = { ...DEFAULTS, ...options };+ let random = mulberry32(hashSeed(`${config.seed}:signals`));+ let tile = generateCircuitTile(config);+ let svg;+ let usesGroup;+ let signalGroup;+ let resizeObserver;+ let activeSignals = 0;+ let signalTimer;+ let epoch = 0; // invalidates in-flight signal timeouts across regenerations+ let tilesAcross = 1;+ let tilesDown = 1;+ let topOffset = -config.tileHeight / 2;+ let pointer = null;+ const interactionTarget = mount.parentElement || mount;+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;++ const originalPosition = mount.style.position;+ const computedPosition = getComputedStyle(mount).position;+ if (computedPosition === "static") mount.style.position = "relative";+ mount.style.overflow = "hidden";++ function buildSvg() {+ svg?.remove();+ epoch += 1;+ activeSignals = 0;+ tile = generateCircuitTile(config);+ random = mulberry32(hashSeed(`${config.seed}:signals`));+ svg = svgEl("svg", {+ "aria-hidden": "true",+ focusable: "false",+ xmlns: SVG_NS,+ });+ svg.dataset.circuitBackground = "";+ Object.assign(svg.style, {+ position: "absolute",+ left: "0",+ top: "0",+ display: "block",+ maxWidth: "none",+ pointerEvents: "none",+ overflow: "visible",+ });++ const defs = svgEl("defs");+ const tileGroup = svgEl("g", { id: `${id}-tile` });+ const traceGroup = svgEl("g", {+ fill: "none",+ stroke: config.traceColor,+ "stroke-width": config.traceWidth,+ "stroke-linecap": "butt",+ "stroke-linejoin": "miter",+ });+ const viaGroup = svgEl("g");+ for (const trace of tile.traces) {+ if (trace.points.length >= 2) {+ traceGroup.appendChild(svgEl("path", { d: pathData(trace.points) }));+ }+ for (const [cx, cy] of trace.vias) {+ viaGroup.appendChild(+ svgEl("circle", { cx, cy, r: config.viaOuterRadius, fill: config.traceColor }),+ );+ viaGroup.appendChild(+ svgEl("circle", {+ cx,+ cy,+ r: config.viaInnerRadius,+ fill: "var(--circuit-hole, #0d1117)",+ }),+ );+ }+ }+ tileGroup.append(traceGroup, viaGroup);+ defs.appendChild(tileGroup);+ svg.appendChild(defs);++ usesGroup = svgEl("g");+ signalGroup = svgEl("g", { fill: "none" });+ svg.append(usesGroup, signalGroup);+ mount.prepend(svg);+ layout();+ }++ function layout() {+ const width = Math.max(1, mount.clientWidth);+ const height = Math.max(1, mount.clientHeight);+ const tilePixelWidth = tile.width * config.scale;+ const tilePixelHeight = tile.height * config.scale;+ tilesAcross = Math.max(1, Math.ceil(width / tilePixelWidth));+ tilesDown = Math.max(2, Math.ceil(height / tilePixelHeight) + 1);+ topOffset = -tile.height / 2;+ const viewWidth = tilesAcross * tile.width;+ const viewHeight = tilesDown * tile.height;++ svg.setAttribute("width", `${viewWidth * config.scale}`);+ svg.setAttribute("height", `${viewHeight * config.scale}`);+ svg.setAttribute("viewBox", `0 ${-topOffset} ${viewWidth} ${viewHeight}`);+ usesGroup.replaceChildren();+ // The extra ring of tiles paints the traces that wrap in from neighbors.+ for (let row = -1; row <= tilesDown; row += 1) {+ for (let column = -1; column <= tilesAcross; column += 1) {+ usesGroup.appendChild(+ svgEl("use", {+ href: `#${id}-tile`,+ transform: `translate(${column * tile.width}, ${row * tile.height})`,+ }),+ );+ }+ }+ }++ // Pointer-biased launch pad: candidates are scored by the distance from+ // the ACTUAL launch point (a trace end, not the trace middle) to the+ // cursor, plus spawnRadius worth of jitter — so spawns cluster within+ // roughly spawnRadius of the pointer without a hard cutoff.+ function pickStart() {+ const { dense } = ensureSignalIndex(tile);+ const nearPointer = pointer && performance.now() - pointer.at < 4000;+ let choice = null;+ let bestScore = Infinity;+ const tries = nearPointer ? 40 : 4;+ for (let attempt = 0; attempt < tries; attempt += 1) {+ const trace = Math.floor(random() * dense.length);+ const tileX = Math.floor(random() * tilesAcross);+ const tileY = Math.floor(random() * tilesDown);+ if (dense[trace].length < 6) continue;+ const fromStart = random() < 0.5;+ const candidate = {+ trace,+ tileX,+ tileY,+ point: fromStart ? 0 : dense[trace].length - 1,+ direction: fromStart ? 1 : -1,+ };+ if (!nearPointer) return candidate;+ const [padX, padY] = dense[trace][candidate.point];+ const score =+ Math.hypot(padX + tileX * tile.width - pointer.x, padY + tileY * tile.height - pointer.y) ++ config.spawnRadius * random();+ if (score < bestScore) {+ bestScore = score;+ choice = candidate;+ }+ }+ return choice;+ }++ // Launch pads (trace-end vias) near an ended signal, closest first, so the+ // replacement reads as the signal hopping vias onto a neighboring trace.+ function nearbyEndpointStarts(absX, absY, excludeTrace) {+ const { endpoints } = ensureSignalIndex(tile);+ const baseTileX = Math.floor(absX / tile.width);+ const baseTileY = Math.floor(absY / tile.height);+ const candidates = [];+ for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {+ for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {+ const localX = absX - tileX * tile.width;+ const localY = absY - tileY * tile.height;+ for (const endpoint of endpoints) {+ if (endpoint.trace === excludeTrace) continue;+ const distance = Math.hypot(endpoint.x - localX, endpoint.y - localY);+ if (distance > 2 && distance < config.hopRadius) {+ candidates.push({ endpoint, tileX, tileY, distance: distance + random() * 20 });+ }+ }+ }+ }+ candidates.sort((a, b) => a.distance - b.distance);+ return candidates.slice(0, 6).map((pick) => ({+ trace: pick.endpoint.trace,+ tileX: pick.tileX,+ tileY: pick.tileY,+ point: pick.endpoint.point,+ direction: pick.endpoint.direction,+ }));+ }++ // phase (0..1) fast-forwards the animation so boot signals are already+ // mid-journey. A chain keeps its speedFactor, color AND survival rate across+ // hops — the signal that entered blue exits blue, and a click-born chain+ // keeps decaying at burstSurvival while ambient chains use hopSurvival.+ // Occasionally (colorDrift) a leg crossfades to a new color over its whole+ // run, so palettes shift gradually, never pop.+ function launchSignal(path, phase = 0, speedFactor, chainColor, survival) {+ const factor = speedFactor ?? 0.85 + random() * 0.3;+ const surviveChance = survival ?? config.hopSurvival;+ const colorFrom = chainColor ?? weightedChoice(random, config.signalColors);+ const colorTo =+ random() < config.colorDrift ? weightedChoice(random, config.signalColors) : colorFrom;+ const d = pathData(path.points);+ const speed = config.signalSpeed * factor;+ const head = config.signalLength;+ const tail = head * 2.2;+ const length = path.length;+ const total = length + tail;+ const duration = (total / speed) * 1000;+ const group = svgEl("g");+ // Faint persistent wake along the whole route.+ const wake = svgEl("path", { d, stroke: colorFrom, "stroke-width": 2, opacity: 0 });+ // Soft glow under the head — a wide translucent stroke is far cheaper+ // than a drop-shadow filter re-rasterizing every frame.+ const glow = svgEl("path", {+ d,+ stroke: colorFrom,+ "stroke-width": 7,+ "stroke-linecap": "round",+ "stroke-opacity": 0.1,+ "stroke-dasharray": `${head} ${total}`,+ "stroke-dashoffset": head,+ });+ // Long dim comet tail behind the head.+ const comet = svgEl("path", {+ d,+ stroke: colorFrom,+ "stroke-width": 2,+ "stroke-linecap": "round",+ "stroke-opacity": 0.16,+ "stroke-dasharray": `${tail} ${total}`,+ "stroke-dashoffset": tail,+ });+ // Bright head.+ const pulse = svgEl("path", {+ d,+ stroke: colorFrom,+ "stroke-width": 2.4,+ "stroke-linecap": "round",+ "stroke-dasharray": `${head} ${total}`,+ "stroke-dashoffset": head,+ "stroke-opacity": 0.72,+ });+ group.append(wake, glow, comet, pulse);+ signalGroup.appendChild(group);+ activeSignals += 1;++ // All layers share the same leading edge; each runs from its own dash+ // length down to -(total - itsDash) so they exit together. Color rides+ // along in the same keyframes when this leg is a crossfade.+ const timing = { duration, easing: "linear", fill: "forwards" };+ const animations = [+ pulse.animate(+ [+ { strokeDashoffset: head, stroke: colorFrom },+ { strokeDashoffset: head - total, stroke: colorTo },+ ],+ timing,+ ),+ glow.animate(+ [+ { strokeDashoffset: head, stroke: colorFrom },+ { strokeDashoffset: head - total, stroke: colorTo },+ ],+ timing,+ ),+ comet.animate(+ [+ { strokeDashoffset: tail, stroke: colorFrom },+ { strokeDashoffset: tail - total, stroke: colorTo },+ ],+ timing,+ ),+ wake.animate(+ [+ { opacity: 0, stroke: colorFrom },+ { opacity: 0.06 },+ { opacity: 0.06 },+ { opacity: 0, stroke: colorTo },+ ],+ { duration, fill: "forwards" },+ ),+ ];+ const headArrival = (duration * length) / total;+ const skip = Math.min(phase, 0.95) * headArrival;+ if (skip > 0) for (const animation of animations) animation.currentTime = skip;++ // The signal is logically done when its head reaches the final via (the+ // tail after that is just residue): blip, release its population slot,+ // and — in the same beat — launch its successor from a nearby via. The+ // handoff is synchronous so motion never pauses.+ const [endX, endY] = path.points[path.points.length - 1];+ const myEpoch = epoch;+ window.setTimeout(() => {+ if (myEpoch !== epoch) return;+ activeSignals -= 1;+ const blip = svgEl("circle", {+ cx: endX,+ cy: endY,+ r: 2,+ fill: "none",+ stroke: colorTo,+ "stroke-width": 1.5,+ "stroke-opacity": 0.6,+ });+ group.appendChild(blip);+ blip.animate(+ [+ { r: 2, strokeOpacity: 0.6 },+ { r: 8, strokeOpacity: 0 },+ ],+ { duration: 500, easing: "ease-out", fill: "forwards" },+ );+ // The successor inherits the color the chain ARRIVED with.+ continueChain(endX, endY, path.startTrace, factor, colorTo, surviveChance);+ }, headArrival - skip);++ window.setTimeout(() => {+ group.remove();+ }, duration + 620 - skip);+ }++ // Every signal is mortal: on each via hop it rolls against its chain's+ // survival rate (hopSurvival for ambient chains, burstSurvival for+ // click-born ones — passed in so a chain keeps its own decay for life).+ // Survivors continue synchronously from a nearby via, inheriting speed and+ // color (arrive blue → depart blue); the rest just die with their arrival+ // blip. Replenishment is NOT done here — the ambient spawn timer refills+ // the population near the cursor, so a death simply lowers the census and+ // the next tick fountains a fresh signal in. Low survival => most signals+ // die after one leg => the fountain stays busy near the pointer.+ function continueChain(endX, endY, excludeTrace, speedFactor, chainColor, survival) {+ if (!config.signals || reducedMotion || document.hidden) return;+ if (random() >= (survival ?? config.hopSurvival)) return;+ if (activeSignals >= config.maxBurstSignals) return;+ spawnSignal(nearbyEndpointStarts(endX, endY, excludeTrace), 0, speedFactor, chainColor, survival);+ }++ // preferred: array of nearby launch pads tried in order (short legs are+ // fine for chain continuations); falls back to random picks elsewhere.+ function spawnSignal(preferred, phase = 0, speedFactor, chainColor, survival) {+ if (!config.signals || reducedMotion) return false;+ if (activeSignals >= config.maxBurstSignals) return false;+ const preferredCount = preferred ? preferred.length : 0;+ const candidates = preferred ? [...preferred] : [];+ for (let attempt = 0; attempt < 8; attempt += 1) {+ const start = pickStart();+ if (start) candidates.push(start);+ }+ for (let index = 0; index < candidates.length; index += 1) {+ const minLength = index < preferredCount ? 60 : 140;+ const path = buildSignalPath(tile, candidates[index], 450 + random() * 900, minLength);+ if (path) {+ path.startTrace = candidates[index].trace;+ launchSignal(path, phase, speedFactor, chainColor, survival);+ return true;+ }+ }+ return false;+ }++ // Fill the population up to maxSignals with fast-forwarded signals, so a+ // fresh board (or a tab returning to view after its chains drained) is+ // already mid-journey instead of visibly cold-starting.+ function prewarm() {+ if (!config.signals || reducedMotion || document.hidden) return;+ let guard = 0;+ while (activeSignals < config.maxSignals && guard < config.maxSignals + 2) {+ guard += 1;+ if (!spawnSignal(null, random() * 0.85)) break;+ }+ }++ // The passive fountain, like the reference site: a steady cadence of new+ // signals near the cursor (pickStart biases toward the pointer when it moved+ // recently, spreads across the board when idle), refilling toward+ // maxSignals. Because signals are mortal, the census keeps dipping below the+ // target and the timer keeps topping it up — so fresh signals keep appearing+ // at the pointer. The balance the user wants is fast spawn + short life:+ // a small signalInterval with a low hopSurvival gives a lively cursor+ // fountain; long life lets the population sit at the cap and spawn rarely.+ function startSignals() {+ window.clearInterval(signalTimer);+ if (!config.signals || reducedMotion) return;+ prewarm();+ signalTimer = window.setInterval(() => {+ if (!document.hidden && activeSignals < config.maxSignals) spawnSignal(null);+ }, config.signalInterval);+ }++ function burstAt(x, y) {+ const { dense } = ensureSignalIndex(tile);+ const candidates = [];+ const baseTileX = Math.floor(x / tile.width);+ const baseTileY = Math.floor(y / tile.height);+ for (let tileY = baseTileY - 1; tileY <= baseTileY + 1; tileY += 1) {+ for (let tileX = baseTileX - 1; tileX <= baseTileX + 1; tileX += 1) {+ if (tileX < -1 || tileX > tilesAcross || tileY < -1 || tileY > tilesDown) continue;+ const localX = x - tileX * tile.width;+ const localY = y - tileY * tile.height;+ for (let trace = 0; trace < dense.length; trace += 1) {+ let bestPoint = -1;+ let bestDistance = 40;+ for (let point = 0; point < dense[trace].length; point += 1) {+ const distance = Math.hypot(dense[trace][point][0] - localX, dense[trace][point][1] - localY);+ if (distance < bestDistance) {+ bestDistance = distance;+ bestPoint = point;+ }+ }+ if (bestPoint >= 0) candidates.push({ trace, tileX, tileY, point: bestPoint, distance: bestDistance });+ }+ }+ }+ candidates.sort((a, b) => a.distance - b.distance);+ let spawned = 0;+ for (const candidate of candidates.slice(0, Math.ceil(config.burstBoost / 2) + 1)) {+ for (const direction of [1, -1]) {+ if (activeSignals >= config.maxBurstSignals || spawned >= config.burstBoost) return;+ const path = buildSignalPath(+ tile,+ { trace: candidate.trace, tileX: candidate.tileX, tileY: candidate.tileY, point: candidate.point, direction },+ 260 + random() * 380,+ 50,+ );+ if (path) {+ path.startTrace = candidate.trace;+ // Clicked chains decay at burstSurvival, independent of the ambient+ // hopSurvival, so a click can ripple long even when passive life is short.+ launchSignal(path, 0, undefined, undefined, config.burstSurvival);+ spawned += 1;+ }+ }+ }+ }++ function onPointerMove(event) {+ const rect = mount.getBoundingClientRect();+ pointer = {+ x: (event.clientX - rect.left) / config.scale,+ y: (event.clientY - rect.top) / config.scale - topOffset,+ at: performance.now(),+ };+ }++ function onClick(event) {+ if (!config.interactive || !config.signals || reducedMotion) return;+ const rect = mount.getBoundingClientRect();+ burstAt(+ (event.clientX - rect.left) / config.scale,+ (event.clientY - rect.top) / config.scale - topOffset,+ );+ }++ function onVisibility() {+ if (!document.hidden) prewarm();+ }++ function regenerate(nextOptions = {}) {+ config = { ...config, ...nextOptions };+ buildSvg();+ startSignals();+ return api;+ }++ function exportSvg() {+ const clone = svg.cloneNode(true);+ clone.querySelectorAll("g[fill='none'] > g").forEach((node) => node.remove());+ clone.removeAttribute("style");+ clone.setAttribute("width", String(tilesAcross * tile.width * config.scale));+ clone.setAttribute("height", String(tilesDown * tile.height * config.scale));+ return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;+ }++ function exportTileSvg(tileOptions = {}) {+ return tileSvg(tile, { ...config, ...tileOptions });+ }++ function destroy() {+ epoch += 1;+ window.clearInterval(signalTimer);+ document.removeEventListener("visibilitychange", onVisibility);+ resizeObserver?.disconnect();+ interactionTarget.removeEventListener("pointermove", onPointerMove);+ interactionTarget.removeEventListener("click", onClick);+ svg?.remove();+ if (computedPosition === "static") mount.style.position = originalPosition;+ }++ const api = {+ regenerate,+ spawnSignal,+ exportSvg,+ exportTileSvg,+ destroy,+ get tile() {+ return tile;+ },+ };+ buildSvg();+ resizeObserver = new ResizeObserver(layout);+ resizeObserver.observe(mount);+ if (config.interactive) {+ interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });+ interactionTarget.addEventListener("click", onClick);+ }+ // Hidden tabs let the census drain (the timer no-ops); refill on return.+ document.addEventListener("visibilitychange", onVisibility);+ startSignals();+ return api;+}++// Named animation presets. Each is a partial config merged over the current+// settings; only signal/animation knobs are touched so generation geometry is+// left alone. `signals: false` gives a fully static board.+const ANIMATION_PRESETS = Object.freeze({+ "None": { signals: false },+ "Fireflies": {+ // A few long-lived signals drifting/bouncing across the whole screen.+ signals: true,+ signalInterval: 850,+ maxSignals: 5,+ spawnRadius: 200,+ hopSurvival: 0.92,+ burstSurvival: 0.9,+ colorDrift: 0.12,+ },+ "Swarm": {+ // Same persistent drift, but a dense population.+ signals: true,+ signalInterval: 360,+ maxSignals: 18,+ spawnRadius: 200,+ hopSurvival: 0.9,+ burstSurvival: 0.9,+ colorDrift: 0.18,+ },+ "Cursor sparks": {+ // Really short life + very fast spawning tight around the cursor.+ signals: true,+ signalInterval: 90,+ maxSignals: 14,+ spawnRadius: 35,+ hopSurvival: 0.1,+ burstSurvival: 0.6,+ colorDrift: 0.15,+ },+ "Slow streams": {+ // Longer-lived flowing chains at a relaxed cadence.+ signals: true,+ signalInterval: 560,+ maxSignals: 8,+ spawnRadius: 130,+ hopSurvival: 0.72,+ burstSurvival: 0.82,+ colorDrift: 0.2,+ },+});++export { DEFAULTS, ANIMATION_PRESETS };
src/generate-tile.mjsadded+67@@ -0,0 +1,67 @@+#!/usr/bin/env node+// Generate a seamless circuit-pattern tile SVG from the command line.+//+// node generate-tile.mjs --seed nitrogen --density 1 --out tile.svg+// node generate-tile.mjs --seed foo --transparent > tile.svg+//+// The output tiles seamlessly in both axes (the router runs on a torus), so+// it can be used directly as a repeating CSS background-image.++import { writeFileSync } from "node:fs";+import { generateCircuitTile, tileSvg } from "./circuit-background.js";++const args = process.argv.slice(2);+const flag = (name) => {+ const index = args.indexOf(`--${name}`);+ return index >= 0 ? args[index + 1] : undefined;+};++// Tunable generation parameters, exposed as kebab-case flags+// (e.g. --turn-penalty 0.8 --bus-width 1.4 --combs 0).+const TUNABLES = [+ "density",+ "formations",+ "busWidth",+ "turnPenalty",+ "weave",+ "cramp",+ "horizontalBias",+ "netReach",+ "jogBias",+ "stubChance",+ "inlineViaChance",+ "combs",+];+const kebab = (key) => key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);++if (args.includes("--help") || args.includes("-h")) {+ console.log(+ "Usage: node generate-tile.mjs [--seed <string>] [--background <css-color>] " ++ "[--transparent] [--out <file>]\n\nTuning flags (numbers):\n" ++ TUNABLES.map((key) => ` --${kebab(key)}`).join("\n"),+ );+ process.exit(0);+}++const seed = flag("seed") ?? "nitrogen";+const background = args.includes("--transparent") ? "" : flag("background") ?? "#0d1117";+const out = flag("out");++const options = { seed };+for (const key of TUNABLES) {+ const value = flag(kebab(key));+ if (value !== undefined) options[key] = Number(value);+}++const tile = generateCircuitTile(options);+const svg = tileSvg(tile, { background, holeColor: flag("background") ?? "#0d1117" });++if (out) {+ writeFileSync(out, svg);+ const vias = tile.traces.reduce((total, trace) => total + trace.vias.length, 0);+ console.error(`OK: wrote ${out} (seed=${seed} traces=${tile.traces.length} vias=${vias})`);+ console.error("Hint: the tile repeats seamlessly; use it as background-image with background-repeat: repeat.");+ console.error("Hint: run with --help to see the tuning flags (bus width, turn penalty, weave, ...).");+} else {+ process.stdout.write(svg);+}
src/index-classic.htmladded+433@@ -0,0 +1,433 @@+<!doctype html>+<html lang="en">+ <head>+ <meta charset="UTF-8" />+ <meta name="viewport" content="width=device-width, initial-scale=1" />+ <title>Procedural Circuit Background</title>+ <style>+ :root {+ color-scheme: dark;+ --bg: #0d1117;+ --surface: #161b22;+ --border: #30363d;+ --text: #e6edf3;+ --muted: #8b949e;+ --accent: #00b8b0;+ --accent-bright: #00e6dc;+ }++ * { box-sizing: border-box; }+ html, body { min-height: 100%; margin: 0; }+ body {+ background: var(--bg);+ color: var(--text);+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;+ }++ .hero {+ --circuit-hole: var(--bg);+ position: relative;+ min-height: 100svh;+ overflow: hidden;+ display: grid;+ place-items: center;+ isolation: isolate;+ }++ .circuit-layer {+ position: absolute;+ inset: 0;+ z-index: -2;+ }++ .glow {+ position: absolute;+ inset: 0;+ z-index: -1;+ pointer-events: none;+ background:+ radial-gradient(circle at 52% 44%, rgba(0, 230, 220, .06), transparent 34%),+ linear-gradient(to top, var(--bg) 0, transparent 12rem);+ }++ main {+ width: min(760px, calc(100% - 40px));+ text-align: center;+ padding: 80px 0 180px;+ }++ .eyebrow {+ margin: 0 0 20px;+ color: var(--accent-bright);+ font: 600 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ letter-spacing: .18em;+ text-transform: uppercase;+ }++ h1 {+ margin: 0;+ font-size: clamp(2.6rem, 8vw, 5.6rem);+ line-height: .9;+ letter-spacing: -.055em;+ color: transparent;+ background: linear-gradient(110deg, #e9ffff, #7bc9c9 62%, var(--accent-bright));+ background-clip: text;+ filter: drop-shadow(0 0 .4em rgba(255,255,255,.18));+ }++ .lede {+ max-width: 570px;+ margin: 28px auto 0;+ color: #bac6d3;+ font-size: clamp(1rem, 2vw, 1.25rem);+ line-height: 1.6;+ }++ .hint {+ margin-top: 24px;+ color: var(--muted);+ font: 500 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ }++ .controls {+ position: fixed;+ left: 50%;+ bottom: 12px;+ z-index: 5;+ width: min(940px, calc(100% - 24px));+ transform: translateX(-50%);+ display: grid;+ grid-template-columns: minmax(104px, .6fr) minmax(0, 3fr) auto auto auto;+ align-items: end;+ gap: 8px;+ padding: 10px 12px;+ border: 1px solid rgba(48, 54, 61, .9);+ border-radius: 12px;+ background: rgba(22, 27, 34, .88);+ box-shadow: 0 18px 70px rgba(0,0,0,.38);+ backdrop-filter: blur(18px);+ }++ label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }+ input[type="text"] {+ width: 100%;+ height: 32px;+ padding: 0 10px;+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 7px;+ outline: none;+ background: #0d1117;+ font: 500 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ }+ input[type="text"]:focus { border-color: var(--accent); }+ input[type="range"] { width: 100%; accent-color: var(--accent); }++ button {+ height: 32px;+ padding: 0 12px;+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 7px;+ background: #21262d;+ font: 700 13px system-ui, sans-serif;+ cursor: pointer;+ }+ button:hover { border-color: #59636e; background: #282e36; }+ button.primary { color: #062d2b; border-color: var(--accent); background: var(--accent); }+ button.primary:hover { background: var(--accent-bright); }++ /* Sits in the top row where Density + Live signals used to be, and is+ labelled like the other fields ("Seed" above its input). */+ .presets-field { display: grid; gap: 5px; min-width: 0; }+ .presets-label { color: var(--muted); font-size: 12px; }+ .presets {+ display: flex;+ flex-wrap: wrap;+ align-items: center;+ gap: 5px;+ }+ .presets button {+ height: 32px;+ padding: 0 9px;+ font: 600 12px system-ui, sans-serif;+ }+ .presets button.active {+ color: #062d2b;+ border-color: var(--accent);+ background: var(--accent);+ }++ .tuning {+ grid-column: 1 / -1;+ border-top: 1px solid rgba(48, 54, 61, .7);+ padding-top: 8px;+ }+ .tuning summary {+ cursor: pointer;+ color: var(--muted);+ font: 600 12px system-ui, sans-serif;+ user-select: none;+ }+ .tuning summary:hover { color: var(--text); }+ .tuning .knobs {+ display: grid;+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));+ gap: 7px 14px;+ padding-top: 9px;+ }+ .knob span {+ display: flex;+ justify-content: space-between;+ gap: 8px;+ }+ .knob output { color: var(--accent-bright); font-variant-numeric: tabular-nums; }+ .tuning .reset-row { margin-top: 9px; display: flex; justify-content: flex-end; }+ .tuning button { height: 27px; font-size: 12px; }++ @media (max-width: 760px) {+ .controls { grid-template-columns: 1fr 1fr; max-height: 60svh; overflow-y: auto; }+ /* Direct children only — preset chips and panel resets keep their size. */+ .controls > button { width: 100%; }+ .presets-field { grid-column: 1 / -1; }+ main { padding-bottom: 270px; }+ }+ </style>+ </head>+ <body>+ <section class="hero" id="hero">+ <div class="circuit-layer" id="circuit"></div>+ <div class="glow" aria-hidden="true"></div>+ <main>+ <p class="eyebrow">Seeded SVG generator · occupancy-grid engine</p>+ <h1>Live circuitry</h1>+ <p class="lede">+ Procedural generation of Nitrogen's trace pattern: collision-free routed traces,+ 45° chamfers, vias, and glowing signals that hop across the generated topology.+ </p>+ <p class="hint">Move the pointer to attract signals · click anywhere for a burst</p>+ </main>+ </section>++ <form class="controls" id="controls">+ <label>+ Seed+ <input id="seed" type="text" value="nitrogen" autocomplete="off" spellcheck="false" />+ </label>+ <div class="presets-field">+ <span class="presets-label">Animation</span>+ <div class="presets" id="presets"></div>+ </div>+ <button class="primary" type="submit">Generate</button>+ <button id="download" type="button">Download SVG</button>+ <button id="downloadTile" type="button">Download Tile</button>++ <details class="tuning">+ <summary>Generation tuning</summary>+ <div class="knobs">+ <label class="knob">+ <span>Density <output>1.05</output></span>+ <input type="range" data-key="density" min="0.35" max="1.45" step="0.05" value="1.05" />+ </label>+ <label class="knob">+ <span>Formations <output>96</output></span>+ <input type="range" data-key="formations" min="24" max="96" step="4" value="96" />+ </label>+ <label class="knob">+ <span>Bus width <output>1</output></span>+ <input type="range" data-key="busWidth" min="0.5" max="2" step="0.1" value="1" />+ </label>+ <label class="knob">+ <span>Turn penalty <output>0.55</output></span>+ <input type="range" data-key="turnPenalty" min="0.1" max="1.2" step="0.05" value="0.55" />+ </label>+ <label class="knob">+ <span>Weave spacing <output>0.22</output></span>+ <input type="range" data-key="weave" min="0" max="0.45" step="0.01" value="0.22" />+ </label>+ <label class="knob">+ <span>Cramp aversion <output>0.4</output></span>+ <input type="range" data-key="cramp" min="0" max="0.8" step="0.05" value="0.4" />+ </label>+ <label class="knob">+ <span>Horizontal bias <output>0.6</output></span>+ <input type="range" data-key="horizontalBias" min="0.35" max="0.9" step="0.05" value="0.6" />+ </label>+ <label class="knob">+ <span>Net reach <output>42</output></span>+ <input type="range" data-key="netReach" min="16" max="60" step="2" value="42" />+ </label>+ <label class="knob">+ <span>Jog bias <output>5</output></span>+ <input type="range" data-key="jogBias" min="0" max="10" step="1" value="5" />+ </label>+ <label class="knob">+ <span>Stub chance <output>0</output></span>+ <input type="range" data-key="stubChance" min="0" max="1" step="0.05" value="0" />+ </label>+ <label class="knob">+ <span>Inline vias <output>0.03</output></span>+ <input type="range" data-key="inlineViaChance" min="0" max="0.12" step="0.005" value="0.03" />+ </label>+ <label class="knob">+ <span>Tap combs <output>8</output></span>+ <input type="range" data-key="combs" min="0" max="16" step="1" value="8" />+ </label>+ </div>+ <div class="reset-row">+ <button class="reset-tuning" type="button">Reset generation</button>+ </div>+ </details>++ <details class="tuning" data-panel="animation">+ <summary>Animation tuning</summary>+ <div class="knobs">+ <label class="knob">+ <span>Signal speed <output>420</output></span>+ <input type="range" data-key="signalSpeed" min="100" max="1200" step="20" value="420" />+ </label>+ <label class="knob">+ <span>Spawn rate (ms) <output>240</output></span>+ <input type="range" data-key="signalInterval" min="80" max="900" step="20" value="240" />+ </label>+ <label class="knob">+ <span>Cursor range <output>110</output></span>+ <input type="range" data-key="spawnRadius" min="25" max="200" step="5" value="110" />+ </label>+ <label class="knob">+ <span>Max signals <output>12</output></span>+ <input type="range" data-key="maxSignals" min="2" max="20" step="1" value="12" />+ </label>+ <label class="knob">+ <span>Passive survival <output>0.4</output></span>+ <input type="range" data-key="hopSurvival" min="0" max="0.95" step="0.05" value="0.4" />+ </label>+ <label class="knob">+ <span>Click survival <output>0.72</output></span>+ <input type="range" data-key="burstSurvival" min="0" max="0.95" step="0.05" value="0.72" />+ </label>+ <label class="knob">+ <span>Click boost <output>7</output></span>+ <input type="range" data-key="burstBoost" min="0" max="11" step="1" value="7" />+ </label>+ </div>+ <div class="reset-row">+ <button class="reset-tuning" type="button">Reset animation</button>+ </div>+ </details>+ </form>++ <script type="module">+ import { createCircuitBackground, ANIMATION_PRESETS } from "./circuit-background.js";++ const mount = document.querySelector("#circuit");+ const controls = document.querySelector("#controls");+ const seed = document.querySelector("#seed");+ const download = document.querySelector("#download");+ const downloadTile = document.querySelector("#downloadTile");+ const presetBar = document.querySelector("#presets");++ // Animation on/off now lives entirely in the presets ("None" = off);+ // the page opens on None, so the board starts static.+ let signalsOn = false;++ const circuit = createCircuitBackground(mount, { seed: seed.value, signals: signalsOn });++ // Every slider (density included) is a data-key knob inside a tuning+ // panel, so `tuning()` collects them all straight into the config.+ const knobs = [...document.querySelectorAll(".tuning input[type=range]")];+ const knobDefaults = new Map(knobs.map((knob) => [knob, knob.value]));+ const knobByKey = new Map(knobs.map((knob) => [knob.dataset.key, knob]));+ const tuning = () =>+ Object.fromEntries(knobs.map((knob) => [knob.dataset.key, Number(knob.value)]));+ const settings = () => ({+ seed: seed.value.trim() || "nitrogen",+ signals: signalsOn,+ ...tuning(),+ });++ // --- animation presets --------------------------------------------------+ const presetButtons = new Map();+ const clearActivePreset = () => {+ for (const button of presetButtons.values()) button.classList.remove("active");+ };+ const syncKnob = (knob) => {+ knob.parentElement.querySelector("output").value = knob.value;+ };+ const applyPreset = (name) => {+ const preset = ANIMATION_PRESETS[name];+ for (const [key, value] of Object.entries(preset)) {+ if (key === "signals") { signalsOn = value; continue; }+ const knob = knobByKey.get(key);+ if (knob) { knob.value = value; syncKnob(knob); }+ }+ clearActivePreset();+ presetButtons.get(name).classList.add("active");+ circuit.regenerate(settings());+ };+ for (const name of Object.keys(ANIMATION_PRESETS)) {+ const button = document.createElement("button");+ button.type = "button";+ button.textContent = name;+ button.addEventListener("click", () => applyPreset(name));+ presetBar.appendChild(button);+ presetButtons.set(name, button);+ }+ // Reflect the initial "None" state in the preset bar.+ presetButtons.get("None").classList.add("active");++ // Only animation knobs invalidate the highlighted preset — retuning+ // generation (density, formations, ...) leaves the animation untouched.+ const isAnimationPanel = (node) => node.closest("details")?.dataset.panel === "animation";++ for (const knob of knobs) {+ const out = knob.parentElement.querySelector("output");+ const show = () => { out.value = knob.value; };+ show();+ knob.addEventListener("input", show);+ knob.addEventListener("change", () => {+ if (isAnimationPanel(knob)) clearActivePreset();+ circuit.regenerate(settings());+ });+ }++ // Each panel's reset button restores only that panel's own knobs.+ for (const resetButton of document.querySelectorAll(".reset-tuning")) {+ resetButton.addEventListener("click", () => {+ const panelKnobs = resetButton.closest("details").querySelectorAll("input[type=range]");+ for (const knob of panelKnobs) {+ knob.value = knobDefaults.get(knob);+ knob.parentElement.querySelector("output").value = knob.value;+ }+ if (isAnimationPanel(resetButton)) clearActivePreset();+ circuit.regenerate(settings());+ });+ }++ controls.addEventListener("submit", (event) => {+ event.preventDefault();+ circuit.regenerate(settings());+ });++ function saveBlob(text, name) {+ const blob = new Blob([text], { type: "image/svg+xml" });+ const url = URL.createObjectURL(blob);+ const link = document.createElement("a");+ link.href = url;+ link.download = name;+ link.click();+ URL.revokeObjectURL(url);+ }++ const slug = () => (seed.value || "seed").replace(/[^a-z0-9_-]+/gi, "-");++ download.addEventListener("click", () => {+ saveBlob(circuit.exportSvg(), `circuit-${slug()}.svg`);+ });++ downloadTile.addEventListener("click", () => {+ saveBlob(circuit.exportTileSvg(), `circuit-tile-${slug()}.svg`);+ });+ </script>+ </body>+</html>
src/index.htmladded+570@@ -0,0 +1,570 @@+<!doctype html>+<html lang="en">+ <head>+ <meta charset="UTF-8" />+ <meta name="viewport" content="width=device-width, initial-scale=1" />+ <title>Procedural Circuit Background</title>+ <style>+ :root {+ color-scheme: dark;+ --bg: #0d1117;+ --surface: #161b22;+ --border: #30363d;+ --text: #e6edf3;+ --muted: #8b949e;+ --accent: #00b8b0;+ --accent-bright: #00e6dc;+ }++ * { box-sizing: border-box; }+ html, body { min-height: 100%; margin: 0; }+ body {+ background: var(--bg);+ color: var(--text);+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;+ }++ .hero {+ --circuit-hole: var(--bg);+ position: relative;+ min-height: 100svh;+ overflow: hidden;+ display: grid;+ place-items: center;+ isolation: isolate;+ }++ .circuit-layer {+ position: absolute;+ inset: 0;+ z-index: 0;+ }++ .glow {+ --atmosphere-strength: 1;+ position: absolute;+ inset: 0;+ z-index: 1;+ pointer-events: none;+ background:+ radial-gradient(ellipse 46% 34% at 50% 45%, rgba(13, 17, 23, .64), rgba(13, 17, 23, .34) 48%, transparent 100%),+ linear-gradient(to top, var(--bg) 0, transparent 13rem);+ }++ .glow::before {+ content: "";+ position: absolute;+ inset: -12%;+ background:+ radial-gradient(ellipse 28% 38% at 22% 48%, rgba(0, 230, 220, .06), transparent 72%),+ radial-gradient(ellipse 34% 42% at 58% 46%, rgba(57, 184, 255, .045), transparent 72%),+ radial-gradient(ellipse 30% 38% at 84% 52%, rgba(140, 107, 247, .045), transparent 72%);+ mix-blend-mode: screen;+ opacity: calc(var(--atmosphere-strength) * .72);+ filter: blur(34px);+ }++ .hero[data-motion="on"][data-animation-system="conductor"] .glow {+ background:+ radial-gradient(circle at 52% 44%, rgba(0, 230, 220, .06), transparent 34%),+ linear-gradient(to top, var(--bg) 0, transparent 12rem);+ animation: atmosphere-breathe 8s ease-in-out infinite alternate;+ will-change: opacity, filter;+ }++ .hero[data-motion="on"][data-animation-system="conductor"] .glow::before {+ inset: -18%;+ background:+ radial-gradient(circle at 28% 38%, rgba(0, 230, 220, .075), transparent 20%),+ radial-gradient(circle at 72% 58%, rgba(100, 171, 255, .055), transparent 24%),+ radial-gradient(circle at 52% 82%, rgba(197, 179, 255, .04), transparent 18%);+ opacity: 1;+ filter: none;+ animation: atmosphere-drift 19s cubic-bezier(.45,.05,.55,.95) infinite alternate;+ will-change: transform;+ }++ @keyframes atmosphere-breathe {+ from { opacity: var(--atmosphere-low, .66); filter: saturate(.82); }+ to { opacity: var(--atmosphere-high, 1); filter: saturate(1.18); }+ }++ @keyframes atmosphere-drift {+ 0% { transform: translate3d(-3%, -2%, 0) scale(1); }+ 45% { transform: translate3d(4%, 1%, 0) scale(1.08); }+ 100% { transform: translate3d(-1%, 5%, 0) scale(1.03); }+ }++ main {+ position: relative;+ z-index: 2;+ width: min(760px, calc(100% - 40px));+ text-align: center;+ padding: 80px 0 180px;+ }++ .hero-copy { position: relative; }++ .eyebrow {+ margin: 0 0 20px;+ color: var(--accent-bright);+ font: 600 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ letter-spacing: .18em;+ text-transform: uppercase;+ }++ h1 {+ margin: 0;+ font-size: clamp(3.2rem, 10vw, 7.5rem);+ line-height: .9;+ letter-spacing: -.055em;+ color: transparent;+ background: linear-gradient(100deg, #e6edf3 4%, #00e6dc 38%, #39b8ff 66%, #8c6bf7 96%);+ background-clip: text;+ filter: drop-shadow(0 0 .4em rgba(255,255,255,.18));+ }++ .lede {+ max-width: 570px;+ margin: 28px auto 0;+ color: #bac6d3;+ font-size: clamp(1rem, 2vw, 1.25rem);+ line-height: 1.6;+ }++ .hint {+ margin-top: 24px;+ color: var(--muted);+ font: 500 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ }++ .controls {+ position: fixed;+ left: 50%;+ bottom: 12px;+ z-index: 5;+ width: min(1180px, calc(100% - 24px));+ transform: translateX(-50%);+ display: grid;+ grid-template-columns: repeat(12, minmax(0, 1fr));+ align-items: end;+ gap: 8px;+ padding: 10px;+ border: 1px solid rgba(48, 54, 61, .9);+ border-radius: 12px;+ background: rgba(22, 27, 34, .88);+ box-shadow: 0 18px 70px rgba(0,0,0,.38);+ backdrop-filter: blur(18px);+ }++ label { min-width: 0; display: grid; gap: 4px; color: var(--muted); font-size: 10px; }+ label > span { display: flex; justify-content: space-between; gap: 3px; white-space: nowrap; }+ input[type="text"] {+ width: 100%;+ height: 32px;+ padding: 0 8px;+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 8px;+ outline: none;+ background: #0d1117;+ font: 500 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ }+ input[type="text"]:focus { border-color: var(--accent); }+ input[type="range"] { width: 100%; accent-color: var(--accent); }++ button {+ height: 32px;+ padding: 0 11px;+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 8px;+ background: #21262d;+ font: 700 12px system-ui, sans-serif;+ cursor: pointer;+ }+ button:hover { border-color: #59636e; background: #282e36; }+ button.primary { color: #062d2b; border-color: var(--accent); background: var(--accent); }+ button.primary:hover { background: var(--accent-bright); }++ .preset-bar {+ grid-column: 1 / -1;+ display: flex;+ align-items: center;+ gap: 6px;+ min-width: 0;+ }+ .preset-title {+ margin-right: 3px;+ color: var(--muted);+ font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ letter-spacing: .12em;+ text-transform: uppercase;+ }+ .preset-bar button {+ height: 28px;+ padding: 0 10px;+ color: #aab5c1;+ font-size: 11px;+ }+ .preset-bar button[aria-pressed="true"] {+ color: #082f2d;+ border-color: var(--accent);+ background: var(--accent);+ }+ .parameter-panel {+ grid-column: 1 / -1;+ display: grid;+ grid-template-columns: repeat(12, minmax(0, 1fr));+ align-items: end;+ gap: 8px;+ }+ .parameter-panel[hidden] { display: none; }+ /* A quiet text toggle, not a button: it opens a drawer, it does not pick a preset. */+ .preset-bar .panel-toggle {+ display: inline-flex;+ align-items: center;+ gap: 4px;+ width: auto;+ height: 28px;+ padding: 0 2px;+ margin-left: 4px;+ color: var(--muted);+ border: 0;+ border-radius: 0;+ background: none;+ font: 600 11px system-ui, sans-serif;+ }+ .preset-bar .panel-toggle:hover { color: var(--text); border: 0; background: none; }+ .preset-bar .panel-toggle:focus-visible { outline: 1px solid var(--accent); outline-offset: 3px; }+ .preset-bar .panel-toggle[aria-expanded="true"] { color: var(--accent-bright); }+ .panel-toggle .chevron { display: inline-block; font-size: 9px; line-height: 1; transition: transform .15s ease; }+ .panel-toggle[aria-expanded="true"] .chevron { transform: rotate(180deg); }++ .animation-bar {+ padding-top: 8px;+ border-top: 1px solid rgba(48, 54, 61, .72);+ }+ .movement-power {+ flex: 1 1 150px;+ max-width: 180px;+ margin-left: 6px;+ }+ .movement-power input { margin: 0; }+ .trace-count {+ margin-left: auto;+ color: var(--accent-bright);+ font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;+ white-space: nowrap;+ }++ @media (max-width: 1180px) {+ .controls {+ grid-template-columns: repeat(6, minmax(0, 1fr));+ max-height: calc(100svh - 24px);+ overflow-y: auto;+ }+ .parameter-panel { grid-template-columns: repeat(6, minmax(0, 1fr)); }+ .preset-bar { flex-wrap: wrap; }+ }++ @media (max-width: 760px) {+ .controls { grid-template-columns: 1fr 1fr; bottom: 12px; }+ .parameter-panel { grid-template-columns: 1fr 1fr; }+ .controls button { width: 100%; }+ .preset-title { width: 100%; }+ .trace-count { margin-left: 0; }+ main { padding-bottom: 360px; }+ }++ @media (prefers-reduced-motion: reduce) {+ .glow, .glow::before { animation: none; }+ .panel-toggle .chevron { transition: none; }+ }+ </style>+ </head>+ <body>+ <section class="hero" id="hero">+ <div class="circuit-layer" id="circuit"></div>+ <div class="glow" aria-hidden="true"></div>+ <main>+ <div class="hero-copy" data-circuit-reading-zone>+ <p class="eyebrow">Seeded SVG generator</p>+ <h1>Live circuitry</h1>+ <p class="lede">+ A procedural version of the Nitrogen hero pattern: dense routed traces, 45° chamfers,+ annular vias, continuous trace packing, and three complete animation systems for the generated topology.+ </p>+ <p class="hint" id="motionHint">Static circuit · no animation layers</p>+ </div>+ </main>+ </section>++ <form class="controls" id="controls">+ <div class="preset-bar" aria-label="Generator presets">+ <span class="preset-title">Generation</span>+ <button type="button" data-preset="original" aria-pressed="true">Original match</button>+ <button type="button" data-preset="dense" aria-pressed="false">Dense</button>+ <button type="button" data-preset="diagonal" aria-pressed="false">Diagonal sweep</button>+ <button type="button" data-preset="swirl" aria-pressed="false">Broad swirl</button>+ <button type="button" data-preset="sparse" aria-pressed="false">Sparse</button>+ <button type="button" class="panel-toggle" id="parameterToggle" aria-expanded="false" aria-controls="parameterPanel">+ Parameters<span class="chevron" aria-hidden="true">▾</span>+ </button>+ <output class="trace-count" id="traceCount">235 traces</output>+ </div>+ <div class="parameter-panel" id="parameterPanel" hidden>+ <label>+ Seed+ <input id="seed" type="text" value="nitrogen" autocomplete="off" spellcheck="false" />+ </label>+ <label>+ <span>Density <output id="fillValue">74.4%</output></span>+ <input id="fill" type="range" min="0.35" max="0.76" step="0.001" value="0.744" />+ </label>+ <label>+ <span>Avg length <output id="routeLengthValue">1.14×</output></span>+ <input id="routeLength" type="range" min="0.55" max="2.25" step="0.01" value="1.14" />+ </label>+ <label>+ <span>Primary flow <output id="horizontalFlowValue">90%</output></span>+ <input id="horizontalFlow" type="range" min="0.3" max="0.98" step="0.01" value="0.9" />+ </label>+ <label>+ <span>Angle <output id="flowAngleValue">153°</output></span>+ <input id="flowAngle" type="range" min="0" max="180" step="1" value="153" />+ </label>+ <label>+ <span>Diagonal <output id="diagonalFlowValue">100%</output></span>+ <input id="diagonalFlow" type="range" min="0" max="1" step="0.01" value="1" />+ </label>+ <label>+ <span>Long flow <output id="longFlowValue">31%</output></span>+ <input id="longFlow" type="range" min="0" max="0.36" step="0.01" value="0.31" />+ </label>+ <label>+ <span>Swirl <output id="swirlValue">82%</output></span>+ <input id="swirl" type="range" min="0" max="1.4" step="0.01" value="0.82" />+ </label>+ <label>+ <span>Swirl scale <output id="swirlScaleValue">0.72×</output></span>+ <input id="swirlScale" type="range" min="0.25" max="1.4" step="0.01" value="0.72" />+ </label>+ <label>+ <span>Jog size <output id="jogLengthValue">0.42×</output></span>+ <input id="jogLength" type="range" min="0.3" max="1.8" step="0.01" value="0.42" />+ </label>+ <label>+ <span>Channel pull <output id="channelAttractionValue">150%</output></span>+ <input id="channelAttraction" type="range" min="0" max="1.5" step="0.01" value="1.5" />+ </label>+ <label>+ <span>Scale <output id="featureScaleValue">1.37×</output></span>+ <input id="featureScale" type="range" min="0.55" max="2" step="0.01" value="1.37" />+ </label>+ </div>+ <div class="preset-bar animation-bar" aria-label="Animation presets">+ <span class="preset-title">Animation</span>+ <button type="button" data-animation-preset="none" aria-pressed="true">None</button>+ <button type="button" data-animation-preset="conductor" aria-pressed="false">Conductor</button>+ <button type="button" data-animation-preset="routed" aria-pressed="false">Routed field</button>+ <button type="button" data-animation-preset="handshake" aria-pressed="false">Handshake</button>+ <label class="movement-power">+ <span>Movement power <output id="livelinessValue">0%</output></span>+ <input id="liveliness" type="range" min="0" max="1.5" step="0.01" value="0" />+ </label>+ <button class="primary" type="submit">Generate</button>+ <button id="download" type="button">Export SVG</button>+ </div>+ </form>++ <script type="module">+ import { ANIMATION_PRESETS, createCircuitBackground } from "./circuit-background-v5.js?v=3";++ const mount = document.querySelector("#circuit");+ const controls = document.querySelector("#controls");+ const generationPresetButtons = [...document.querySelectorAll("[data-preset]")];+ const animationPresetButtons = [...document.querySelectorAll("[data-animation-preset]")];+ const traceCount = document.querySelector("#traceCount");+ const hero = document.querySelector("#hero");+ const glow = document.querySelector(".glow");+ const seed = document.querySelector("#seed");+ const parameterToggle = document.querySelector("#parameterToggle");+ const parameterPanel = document.querySelector("#parameterPanel");+ const motionHint = document.querySelector("#motionHint");+ const download = document.querySelector("#download");++ const geometryParameterIds = ["fill", "routeLength", "horizontalFlow", "flowAngle", "diagonalFlow", "longFlow", "swirl", "swirlScale", "jogLength", "channelAttraction", "featureScale"];+ const animationParameterIds = ["liveliness"];+ const parameterIds = [...geometryParameterIds, ...animationParameterIds];+ const parameters = Object.fromEntries(parameterIds.map((id) => [id, document.querySelector(`#${id}`)]));+ const presets = {+ original: { fill: 0.744, routeLength: 1.14, horizontalFlow: 0.9, flowAngle: 153, diagonalFlow: 1, longFlow: 0.31, swirl: 0.82, swirlScale: 0.72, jogLength: 0.42, channelAttraction: 1.5, featureScale: 1.37 },+ dense: { fill: 0.76, routeLength: 1.29, horizontalFlow: 0.57, flowAngle: 138, diagonalFlow: 0.26, longFlow: 0.3, swirl: 0.43, swirlScale: 0.52, jogLength: 0.85, channelAttraction: 0.69, featureScale: 0.65 },+ diagonal: { fill: 0.591, routeLength: 1.51, horizontalFlow: 0.98, flowAngle: 45, diagonalFlow: 1, longFlow: 0, swirl: 0, swirlScale: 0.75, jogLength: 0.65, channelAttraction: 0.86, featureScale: 1.28 },+ swirl: { fill: 0.58, routeLength: 1.1, horizontalFlow: 0.82, flowAngle: 15, diagonalFlow: 0.72, longFlow: 0.2, swirl: 0.9, swirlScale: 0.9, jogLength: 0.75, channelAttraction: 0.8, featureScale: 1 },+ sparse: { fill: 0.35, routeLength: 1.9, horizontalFlow: 0.45, flowAngle: 0, diagonalFlow: 0.3, longFlow: 0.32, swirl: 0.08, swirlScale: 0.93, jogLength: 0.57, channelAttraction: 0.6, featureScale: 0.88 },+ };+ const animationHints = {+ none: "Static circuit · no animation layers",+ conductor: "Move to shape the current · click anywhere to activate the network",+ adom: "Move to steer the routed field · click to launch a local bloom",+ handshake: "Move to bias the next handshake · click to request a local exchange",+ };+ const formats = {+ fill: (value) => `${Number((value * 100).toFixed(1))}%`,+ routeLength: (value) => `${value.toFixed(2)}×`,+ horizontalFlow: (value) => `${Math.round(value * 100)}%`,+ flowAngle: (value) => `${Math.round(value)}°`,+ diagonalFlow: (value) => `${Math.round(value * 100)}%`,+ longFlow: (value) => `${Math.round(value * 100)}%`,+ swirl: (value) => `${Math.round(value * 100)}%`,+ swirlScale: (value) => `${value.toFixed(2)}×`,+ jogLength: (value) => `${value.toFixed(2)}×`,+ channelAttraction: (value) => `${Math.round(value * 100)}%`,+ featureScale: (value) => `${value.toFixed(2)}×`,+ liveliness: (value) => `${Math.round(value * 100)}%`,+ };++ const query = new URLSearchParams(location.search);+ const requestedAnimationPreset = query.get("animationPreset");+ const initialAnimationPreset = ANIMATION_PRESETS[requestedAnimationPreset]+ ? requestedAnimationPreset+ : "none";+ let animationOptions = { ...ANIMATION_PRESETS[initialAnimationPreset] };+ if (query.has("seed")) seed.value = query.get("seed").slice(0, 80);+ for (const id of parameterIds) {+ if (!query.has(id)) continue;+ const value = Number(query.get(id));+ if (Number.isFinite(value)) parameters[id].value = String(value);+ }+ // A deep link that tunes parameters should show them, not hide the reason the render differs.+ if (geometryParameterIds.some((id) => query.has(id))) {+ parameterToggle.setAttribute("aria-expanded", "true");+ parameterPanel.hidden = false;+ }+ if (!query.has("liveliness")) parameters.liveliness.value = String(animationOptions.liveliness);+ if (query.has("motion")) {+ animationOptions.motion = !["0", "false", "off"].includes(query.get("motion").toLowerCase());+ }++ function setActiveGenerationPreset(name) {+ for (const button of generationPresetButtons) {+ button.setAttribute("aria-pressed", String(button.dataset.preset === name));+ }+ }++ function setActiveAnimationPreset(name) {+ for (const button of animationPresetButtons) {+ button.setAttribute("aria-pressed", String(button.dataset.animationPreset === name));+ }+ }++ function applyAtmosphere(options) {+ const amount = Math.max(0, Math.min(1.25, options.atmosphereStrength));+ hero.dataset.animationSystem = options.animationSystem;+ hero.dataset.motion = options.motion ? "on" : "off";+ glow.style.setProperty("--atmosphere-strength", String(amount));+ glow.style.setProperty("--atmosphere-low", String(Math.min(1, amount * 0.66)));+ glow.style.setProperty("--atmosphere-high", String(Math.min(1, amount)));+ motionHint.textContent = animationHints[options.animationSystem] || animationHints.none;+ }++ if (geometryParameterIds.some((id) => query.has(id))) setActiveGenerationPreset(null);+ const animationIsCustom = animationParameterIds.some((id) => query.has(id)) || query.has("motion");+ setActiveAnimationPreset(animationIsCustom ? null : initialAnimationPreset);+ applyAtmosphere(animationOptions);++ function optionsFromControls() {+ const options = {+ ...animationOptions,+ seed: seed.value.trim() || "nitrogen",+ traceColor: "#1d2938",+ };+ for (const id of parameterIds) options[id] = Number(parameters[id].value);+ return options;+ }++ function updateOutput(id) {+ const value = Number(parameters[id].value);+ document.querySelector(`#${id}Value`).value = formats[id](value);+ }+ parameterIds.forEach(updateOutput);++ const circuit = createCircuitBackground(mount, optionsFromControls());+ let regenerationTimer;++ function updateTraceCount() {+ traceCount.value = `${circuit.field.traces.length} traces`;+ }+ updateTraceCount();++ function regenerate() {+ window.clearTimeout(regenerationTimer);+ circuit.regenerate(optionsFromControls());+ updateTraceCount();+ }++ for (const id of parameterIds) {+ parameters[id].addEventListener("input", () => {+ if (geometryParameterIds.includes(id)) setActiveGenerationPreset(null);+ else setActiveAnimationPreset(null);+ updateOutput(id);+ window.clearTimeout(regenerationTimer);+ regenerationTimer = window.setTimeout(regenerate, 140);+ });+ }+ for (const button of generationPresetButtons) {+ button.addEventListener("click", () => {+ const name = button.dataset.preset;+ const values = presets[name];+ for (const [id, value] of Object.entries(values)) {+ parameters[id].value = String(value);+ updateOutput(id);+ }+ setActiveGenerationPreset(name);+ regenerate();+ });+ }+ parameterToggle.addEventListener("click", () => {+ const expanded = parameterToggle.getAttribute("aria-expanded") === "true";+ parameterToggle.setAttribute("aria-expanded", String(!expanded));+ parameterPanel.hidden = expanded;+ });+ for (const button of animationPresetButtons) {+ button.addEventListener("click", () => {+ const name = button.dataset.animationPreset;+ animationOptions = { ...ANIMATION_PRESETS[name] };+ parameters.liveliness.value = String(animationOptions.liveliness);+ updateOutput("liveliness");+ applyAtmosphere(animationOptions);+ setActiveAnimationPreset(name);+ regenerate();+ });+ }+ seed.addEventListener("change", regenerate);++ controls.addEventListener("submit", (event) => {+ event.preventDefault();+ regenerate();+ });++ function saveBlob(text, name) {+ const blob = new Blob([text], { type: "image/svg+xml" });+ const url = URL.createObjectURL(blob);+ const link = document.createElement("a");+ link.href = url;+ link.download = name;+ link.click();+ URL.revokeObjectURL(url);+ }++ const slug = () => (seed.value || "seed").replace(/[^a-z0-9_-]+/gi, "-");++ download.addEventListener("click", () => {+ saveBlob(circuit.exportSvg(), `circuit-${slug()}.svg`);+ });++ </script>+ </body>+</html>
src/serve-classic.mjsadded+61@@ -0,0 +1,61 @@+// Static server for the occupancy-grid ("classic") engine demo.+// Separate from server.js so the two demo deployments can't interfere.+//+// node serve-classic.mjs # port 8930+// CLASSIC_PORT=9000 node serve-classic.mjs++import { createReadStream, statSync } from "node:fs";+import { createServer } from "node:http";+import { dirname, join } from "node:path";+import { fileURLToPath } from "node:url";++const directory = dirname(fileURLToPath(import.meta.url));+const port = Number.parseInt(process.env.CLASSIC_PORT || "8930", 10);+const files = new Map([+ ["/", ["index-classic.html", "text/html; charset=utf-8"]],+ ["/index.html", ["index-classic.html", "text/html; charset=utf-8"]],+ ["/circuit-background.js", ["circuit-background.js", "text/javascript; charset=utf-8"]],+]);++function headers(contentType) {+ return {+ "Content-Type": contentType,+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",+ Pragma: "no-cache",+ "Surrogate-Control": "no-store",+ "X-Content-Type-Options": "nosniff",+ };+}++const server = createServer((request, response) => {+ const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);++ if (url.pathname === "/health") {+ response.writeHead(200, headers("application/json; charset=utf-8"));+ response.end(+ JSON.stringify({ ok: true, service: "circuit-generator-classic", uptime: process.uptime() }),+ );+ return;+ }++ const match = files.get(url.pathname);+ if (!match) {+ response.writeHead(404, headers("text/plain; charset=utf-8"));+ response.end("Not found\n");+ return;+ }++ const [filename, contentType] = match;+ const path = join(directory, filename);+ const size = statSync(path).size;+ response.writeHead(200, { ...headers(contentType), "Content-Length": size });+ if (request.method === "HEAD") {+ response.end();+ return;+ }+ createReadStream(path).pipe(response);+});++server.listen(port, "0.0.0.0", () => {+ console.log(`[circuit-generator-classic] listening on http://0.0.0.0:${port}`);+});
src/serve.shadded+26@@ -0,0 +1,26 @@+#!/bin/bash+set -u++SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"+PORT="${CIRCUIT_PORT:-8124}"+LOG_FILE="/tmp/circuit-generator-serve.log"++if curl -fsS --max-time 2 "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then+ echo "[circuit-generator] already running on port ${PORT}"+ exit 0+fi++cd "$SCRIPT_DIR"+nohup /usr/bin/node server.js >>"$LOG_FILE" 2>&1 &+SERVER_PID=$!++for _ in 1 2 3 4 5; do+ if curl -fsS --max-time 2 "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then+ echo "[circuit-generator] started PID ${SERVER_PID} on port ${PORT}"+ exit 0+ fi+ sleep 1+done++echo "[circuit-generator] failed to start; see ${LOG_FILE}" >&2+exit 1
src/server.jsadded+59@@ -0,0 +1,59 @@+import { createReadStream, statSync } from "node:fs";+import { createServer } from "node:http";+import { dirname, join } from "node:path";+import { fileURLToPath } from "node:url";++const directory = dirname(fileURLToPath(import.meta.url));+const port = Number.parseInt(process.env.CIRCUIT_PORT || "8124", 10);+const files = new Map([+ ["/", ["index.html", "text/html; charset=utf-8"]],+ ["/index.html", ["index.html", "text/html; charset=utf-8"]],+ ["/circuit-animation-conductor.js", ["circuit-animation-conductor.js", "text/javascript; charset=utf-8"]],+ ["/circuit-animation-handshake.js", ["circuit-animation-handshake.js", "text/javascript; charset=utf-8"]],+ ["/circuit-background.js", ["circuit-background.js", "text/javascript; charset=utf-8"]],+ ["/circuit-background-v4.js", ["circuit-background-v4.js", "text/javascript; charset=utf-8"]],+ ["/circuit-background-v5.js", ["circuit-background-v5.js", "text/javascript; charset=utf-8"]],+ ["/circuit-background-v3.js", ["circuit-background-v3.js", "text/javascript; charset=utf-8"]],+ ["/circuit-background-v2.js", ["circuit-background-v2.js", "text/javascript; charset=utf-8"]],+]);++function headers(contentType) {+ return {+ "Content-Type": contentType,+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",+ Pragma: "no-cache",+ "Surrogate-Control": "no-store",+ "X-Content-Type-Options": "nosniff",+ };+}++const server = createServer((request, response) => {+ const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);++ if (url.pathname === "/health") {+ response.writeHead(200, headers("application/json; charset=utf-8"));+ response.end(JSON.stringify({ ok: true, service: "circuit-generator", uptime: process.uptime() }));+ return;+ }++ const match = files.get(url.pathname);+ if (!match) {+ response.writeHead(404, headers("text/plain; charset=utf-8"));+ response.end("Not found\n");+ return;+ }++ const [filename, contentType] = match;+ const path = join(directory, filename);+ const size = statSync(path).size;+ response.writeHead(200, { ...headers(contentType), "Content-Length": size });+ if (request.method === "HEAD") {+ response.end();+ return;+ }+ createReadStream(path).pipe(response);+});++server.listen(port, "0.0.0.0", () => {+ console.log(`[circuit-generator] listening on http://0.0.0.0:${port}`);+});
uninstall.shadded+5@@ -0,0 +1,5 @@+#!/bin/sh+set -e++# Undo what install.sh did (remove the symlinks you created).+echo "uninstalled"