master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago
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 };