app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
master
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112
import { createConductorAnimation } from "./circuit-animation-conductor.js?v=1";
import {
createObstacleField,
obstacleEdgeWeight,
obstacleOutlineTraces,
} from "./circuit-obstacles-v9.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,
obstaclePreset: "none",
obstacleMode: "avoid",
obstacleScale: 0.66,
obstacleX: 0.5,
obstacleY: 0.66,
obstacleClearance: 1.1,
obstaclePull: 0.82,
obstacles: Object.freeze([]),
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, obstacle = null) {
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;
weight *= obstacleEdgeWeight(
obstacle,
nx,
ny,
dx,
dy,
columns,
Math.max(0, Number(config.obstaclePull) || 0),
);
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, state.obstacle);
const backward = growOne(start, (direction + 2) % 4, budget - forwardBudget, blocked, local, columns, rows, random, config, state.flowField, angleDegrees, -1, state.obstacle);
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, state.obstacle);
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 normalizedWidth = (columns - 1) * pitch;
const normalizedHeight = (rows - 1) * pitch;
const obstacle = createObstacleField(config, {
width: normalizedWidth,
height: normalizedHeight,
pitch,
columns,
rows,
});
const state = {
blocked: obstacle ? obstacle.blocked.slice() : new Uint8Array(columns * rows),
columns,
rows,
pitch,
traces: [],
occupied: 0,
obstacle,
flowField: createFlowField(config, columns, rows),
};
const outlines = obstacleOutlineTraces(obstacle, {
pitch,
viaOuterRadius: config.viaOuterRadius * featureScale,
});
state.traces.push(...outlines);
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));
const fillCapacity = obstacle
? Math.max(1, interiorColumns * interiorRows - obstacle.blockedCells)
: columns * rows;
for (const index of seeds) {
if (state.occupied / fillCapacity >= targetFill) break;
if (state.blocked[index]) continue;
makeInteriorTrace([index % columns, Math.floor(index / columns)], state, random, config);
}
return {
width: normalizedWidth,
height: normalizedHeight,
columns,
rows,
fill: state.occupied / fillCapacity,
traces: state.traces,
obstacle: obstacle ? Object.freeze({
count: obstacle.count,
blockedCells: obstacle.blockedCells,
outlineTraceCount: outlines.length,
mode: obstacle.mode,
}) : null,
};
}
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 };