master
AdityaAngajala Publish 0.1.0 da40167 3mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
const SVG_NS = "http://www.w3.org/2000/svg";

const DEFAULTS = Object.freeze({
  seed: "nitrogen",
  pitch: 7.895,
  scale: 1000 / 900,
  density: 1,
  traceColor: "#151b25",
  traceWidth: 1.58,
  viaOuterRadius: 3.34,
  viaInnerRadius: 1.76,
  signals: true,
  interactive: true,
  signalColors: [
    ["#00e6dc", 0.6],
    ["#64abff", 0.25],
    ["#c5b3ff", 0.15],
  ],
  signalInterval: 480,
  signalSpeed: 800,
  signalLength: 150,
  maxSignals: 9,
});

let instanceCount = 0;

function hashSeed(value) {
  let hash = 2166136261;
  for (const char of String(value)) {
    hash ^= char.charCodeAt(0);
    hash = Math.imul(hash, 16777619);
  }
  return hash >>> 0;
}

function mulberry32(seed) {
  return function random() {
    let value = (seed += 0x6d2b79f5);
    value = Math.imul(value ^ (value >>> 15), value | 1);
    value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
  };
}

function integer(random, min, max) {
  return Math.floor(random() * (max - min + 1)) + min;
}

function weighted(random, entries) {
  let roll = random();
  for (const [value, weight] of entries) {
    roll -= weight;
    if (roll <= 0) return value;
  }
  return entries[0][0];
}

class MinHeap {
  constructor() {
    this.values = [];
  }

  push(value) {
    const values = this.values;
    values.push(value);
    let index = values.length - 1;
    while (index > 0) {
      const parent = Math.floor((index - 1) / 2);
      if (values[parent].score <= value.score) break;
      values[index] = values[parent];
      index = parent;
    }
    values[index] = value;
  }

  pop() {
    const values = this.values;
    if (!values.length) return null;
    const root = values[0];
    const last = values.pop();
    if (!values.length) return root;
    let index = 0;
    while (true) {
      const left = index * 2 + 1;
      const right = left + 1;
      if (left >= values.length) break;
      let child = left;
      if (right < values.length && values[right].score < values[left].score) child = right;
      if (values[child].score >= last.score) break;
      values[index] = values[child];
      index = child;
    }
    values[index] = last;
    return root;
  }

  get size() {
    return this.values.length;
  }
}

const DIRECTIONS = [
  [1, 0], [0, 1], [-1, 0], [0, -1],
];

function key(x, y) {
  return (y + 32768) * 65536 + x + 32768;
}

function diagonalKey(x, y, nx, ny) {
  return `${Math.min(x, nx)}:${Math.min(y, ny)}:${(nx - x) * (ny - y) > 0 ? 1 : -1}`;
}

function distanceHeuristic(x, y, gx, gy) {
  return Math.abs(gx - x) + Math.abs(gy - y);
}

function turnCost(previous, next) {
  if (previous === 4 || previous === next) return 0;
  const difference = Math.min((next - previous + 4) % 4, (previous - next + 4) % 4);
  if (difference === 1) return 0.9;
  return 5;
}

function route(start, goal, context, noiseSeed) {
  const { columns, rows, occupied, reserved, diagonalEdges } = context;
  const stateCount = columns * rows * 5;
  const costs = new Float64Array(stateCount);
  costs.fill(Infinity);
  const parents = new Int32Array(stateCount);
  parents.fill(-1);
  const heap = new MinHeap();
  const startCell = start[1] * columns + start[0];
  const startState = startCell * 5 + 4;
  costs[startState] = 0;
  heap.push({ state: startState, score: distanceHeuristic(start[0], start[1], goal[0], goal[1]) });
  const span = Math.hypot(goal[0] - start[0], goal[1] - start[1]);
  const padding = 8 + Math.floor(span * 0.1);
  const minimumX = Math.max(0, Math.min(start[0], goal[0]) - padding);
  const maximumX = Math.min(columns - 1, Math.max(start[0], goal[0]) + padding);
  const minimumY = Math.max(0, Math.min(start[1], goal[1]) - padding);
  const maximumY = Math.min(rows - 1, Math.max(start[1], goal[1]) + padding);
  let goalState = -1;
  let expansions = 0;

  while (heap.size && expansions < 9000) {
    const current = heap.pop();
    const state = current.state;
    const cell = Math.floor(state / 5);
    const previousDirection = state % 5;
    const x = cell % columns;
    const y = Math.floor(cell / columns);
    const knownScore = costs[state] + distanceHeuristic(x, y, goal[0], goal[1]);
    if (current.score > knownScore + 1e-8) continue;
    if (x === goal[0] && y === goal[1]) {
      goalState = state;
      break;
    }
    expansions += 1;

    for (let direction = 0; direction < DIRECTIONS.length; direction += 1) {
      const [dx, dy] = DIRECTIONS[direction];
      const nx = x + dx;
      const ny = y + dy;
      if (nx < minimumX || nx > maximumX || ny < minimumY || ny > maximumY) continue;
      const nextKey = key(nx, ny);
      const isGoal = nx === goal[0] && ny === goal[1];
      if (!isGoal && (occupied.has(nextKey) || reserved.has(nextKey))) continue;

      const diagonal = dx !== 0 && dy !== 0;
      if (diagonal && previousDirection < 4 && DIRECTIONS[previousDirection][0] !== 0 && DIRECTIONS[previousDirection][1] !== 0) continue;
      if (diagonal) {
        const edge = diagonalKey(x, y, nx, ny);
        const pieces = edge.split(":");
        const opposite = `${pieces[0]}:${pieces[1]}:${-Number(pieces[2])}`;
        if (diagonalEdges.has(opposite)) continue;
      }

      let nearby = 0;
      for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
        if (occupied.has(key(nx + ox, ny + oy))) nearby += 1;
      }
      const step = diagonal ? 1.7 : 1;
      const attraction = Math.max(0, 0.28 - nearby * 0.08);
      const noise = (((nx * 73856093) ^ (ny * 19349663) ^ noiseSeed) >>> 0) % 97 / 9700;
      const nextCost = costs[state] + step + turnCost(previousDirection, direction) + attraction + noise;
      const nextCell = ny * columns + nx;
      const nextState = nextCell * 5 + direction;
      if (nextCost >= costs[nextState]) continue;
      costs[nextState] = nextCost;
      parents[nextState] = state;
      heap.push({
        state: nextState,
        score: nextCost + distanceHeuristic(nx, ny, goal[0], goal[1]),
      });
    }
  }

  if (goalState < 0) return null;
  const cells = [];
  let state = goalState;
  while (state >= 0) {
    const cell = Math.floor(state / 5);
    cells.push([cell % columns, Math.floor(cell / columns)]);
    state = parents[state];
  }
  cells.reverse();
  return cells;
}

function chamfer(path) {
  if (path.length < 3) return path;
  const result = [path[0]];
  for (let index = 1; index < path.length - 1; index += 1) {
    const previous = path[index - 1];
    const current = path[index];
    const next = path[index + 1];
    const ax = current[0] - previous[0];
    const ay = current[1] - previous[1];
    const bx = next[0] - current[0];
    const by = next[1] - current[1];
    const firstCardinal = (ax === 0) !== (ay === 0);
    const secondCardinal = (bx === 0) !== (by === 0);
    if (firstCardinal && secondCardinal && ax * bx + ay * by === 0) continue;
    result.push(current);
  }
  result.push(path.at(-1));
  return result;
}

function simplify(path) {
  if (path.length < 3) return path;
  const result = [path[0]];
  for (let index = 1; index < path.length - 1; index += 1) {
    const previous = result.at(-1);
    const current = path[index];
    const next = path[index + 1];
    const ax = Math.sign(current[0] - previous[0]);
    const ay = Math.sign(current[1] - previous[1]);
    const bx = Math.sign(next[0] - current[0]);
    const by = Math.sign(next[1] - current[1]);
    if (ax !== bx || ay !== by) result.push(current);
  }
  result.push(path.at(-1));
  return result;
}

function randomBank(random, count, columns, rows, occupied, reserved) {
  for (let attempt = 0; attempt < 80; attempt += 1) {
    const boundary = random() < 0.34;
    let points;
    let side = "interior";
    if (boundary) {
      side = ["left", "right", "top", "bottom"][integer(random, 0, 3)];
      if (side === "left" || side === "right") {
        const x = side === "left" ? 0 : columns - 1;
        const y = integer(random, 2, rows - count - 2);
        points = Array.from({ length: count }, (_, index) => [x, y + index]);
      } else {
        const x = integer(random, 2, columns - count - 2);
        const y = side === "top" ? 0 : rows - 1;
        points = Array.from({ length: count }, (_, index) => [x + index, y]);
      }
    } else {
      const vertical = random() < 0.5;
      if (vertical) {
        const x = integer(random, 4, columns - 5);
        const y = integer(random, 4, rows - count - 5);
        points = Array.from({ length: count }, (_, index) => [x, y + index]);
      } else {
        const x = integer(random, 4, columns - count - 5);
        const y = integer(random, 4, rows - 5);
        points = Array.from({ length: count }, (_, index) => [x + index, y]);
      }
    }
    if (points.every(([x, y]) => !occupied.has(key(x, y)) && !reserved.has(key(x, y)))) {
      return { points, side };
    }
  }
  return null;
}

function bankCenter(bank) {
  const first = bank.points[0];
  const last = bank.points.at(-1);
  return [(first[0] + last[0]) / 2, (first[1] + last[1]) / 2];
}

function pathLength(points) {
  return points.slice(1).reduce((total, point, index) => {
    const previous = points[index];
    return total + Math.hypot(point[0] - previous[0], point[1] - previous[1]);
  }, 0);
}

export function generateCircuitField(options = {}) {
  const config = { ...DEFAULTS, ...options };
  const width = Math.max(240, config.width || 900);
  const height = Math.max(180, config.height || 450);
  const columns = Math.max(30, Math.floor(width / config.pitch));
  const rows = Math.max(22, Math.floor(height / config.pitch));
  const random = mulberry32(hashSeed(`${config.seed}:${columns}x${rows}`));
  let occupied = new Set();
  let reserved = new Set();
  let diagonalEdges = new Set();
  const traces = [];
  const target = Math.round((columns * rows) / 220 * config.density);
  let attempts = 0;

  while (traces.length < target && attempts < 55) {
    attempts += 1;
    const remaining = target - traces.length;
    const count = Math.min(remaining, integer(random, 4, 7));
    const startBank = randomBank(random, count, columns, rows, occupied, reserved);
    if (!startBank) continue;
    for (const point of startBank.points) reserved.add(key(...point));
    let endBank = null;
    for (let pairAttempt = 0; pairAttempt < 45; pairAttempt += 1) {
      const candidate = randomBank(random, count, columns, rows, occupied, reserved);
      if (!candidate) continue;
      const a = bankCenter(startBank);
      const b = bankCenter(candidate);
      if (Math.hypot(a[0] - b[0], a[1] - b[1]) < Math.min(columns, rows) * 0.26) continue;
      endBank = candidate;
      break;
    }
    if (!endBank) {
      for (const point of startBank.points) reserved.delete(key(...point));
      continue;
    }
    for (const point of endBank.points) reserved.add(key(...point));

    const occupiedBefore = occupied;
    const edgesBefore = diagonalEdges;
    occupied = new Set(occupied);
    diagonalEdges = new Set(diagonalEdges);
    const bundle = [];
    const routeOrder = Array.from({ length: count }, (_, index) => index);
    if (random() < 0.5) routeOrder.reverse();

    for (const lane of routeOrder) {
      const start = startBank.points[lane];
      const goal = endBank.points[lane];
      const cells = route(start, goal, { columns, rows, occupied, reserved, diagonalEdges }, hashSeed(`${config.seed}:${attempts}:${lane}`));
      if (!cells || cells.length < 8) continue;
      const shaped = simplify(chamfer(cells));
      for (const point of cells) occupied.add(key(...point));
      for (let index = 1; index < shaped.length; index += 1) {
        const previous = shaped[index - 1];
        const current = shaped[index];
        if (previous[0] !== current[0] && previous[1] !== current[1]) {
          diagonalEdges.add(diagonalKey(previous[0], previous[1], current[0], current[1]));
        }
      }
      const points = shaped.map(([x, y]) => [x * config.pitch, y * config.pitch]);
      bundle.push({
        points,
        length: pathLength(points),
        viaStart: startBank.side === "interior",
        viaEnd: endBank.side === "interior",
      });
    }

    for (const point of startBank.points) reserved.delete(key(...point));
    for (const point of endBank.points) reserved.delete(key(...point));
    if (bundle.length < 3) {
      occupied = occupiedBefore;
      diagonalEdges = edgesBefore;
      continue;
    }
    traces.push(...bundle);
  }

  return {
    width: (columns - 1) * config.pitch,
    height: (rows - 1) * config.pitch,
    columns,
    rows,
    traces: traces.slice(0, target),
  };
}

export function pathData(points) {
  return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");
}

function svgElement(name, attributes = {}) {
  const element = document.createElementNS(SVG_NS, name);
  for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));
  return element;
}

export function createCircuitBackground(mount, options = {}) {
  if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");
  const id = `circuit-field-${++instanceCount}`;
  let config = { ...DEFAULTS, ...options };
  let field;
  let svg;
  let signalLayer;
  let timer;
  let resizeObserver;
  let resizeTimer;
  let activeSignals = 0;
  let pointer = null;
  let signalRandom;
  let lastSize = "";
  const interactionTarget = mount.parentElement || mount;
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const originalPosition = mount.style.position;
  if (getComputedStyle(mount).position === "static") mount.style.position = "relative";
  mount.style.overflow = "hidden";

  function addVia(group, point) {
    group.append(
      svgElement("circle", { cx: point[0], cy: point[1], r: config.viaOuterRadius, fill: config.traceColor }),
      svgElement("circle", { cx: point[0], cy: point[1], r: config.viaInnerRadius, fill: "var(--circuit-hole, #0d1117)" }),
    );
  }

  function build(force = false) {
    const pixelWidth = Math.max(1, mount.clientWidth);
    const pixelHeight = Math.max(1, mount.clientHeight);
    const worldWidth = pixelWidth / config.scale;
    const worldHeight = pixelHeight / config.scale;
    const size = `${Math.floor(worldWidth / config.pitch)}x${Math.floor(worldHeight / config.pitch)}`;
    if (!force && size === lastSize) return;
    lastSize = size;
    svg?.remove();
    field = generateCircuitField({ ...config, width: worldWidth, height: worldHeight });
    signalRandom = mulberry32(hashSeed(`${config.seed}:${size}:signals`));
    svg = svgElement("svg", {
      id,
      "aria-hidden": true,
      focusable: false,
      viewBox: `0 0 ${field.width} ${field.height}`,
      preserveAspectRatio: "none",
    });
    Object.assign(svg.style, {
      position: "absolute", inset: "0", width: "100%", height: "100%",
      display: "block", pointerEvents: "none",
    });
    const traces = svgElement("g");
    for (const trace of field.traces) {
      traces.appendChild(svgElement("path", {
        d: pathData(trace.points), fill: "none", stroke: config.traceColor,
        "stroke-width": config.traceWidth, "stroke-linecap": "butt", "stroke-linejoin": "miter",
      }));
      if (trace.viaStart) addVia(traces, trace.points[0]);
      if (trace.viaEnd) addVia(traces, trace.points.at(-1));
    }
    signalLayer = svgElement("g", { "data-signals": "", fill: "none" });
    svg.append(traces, signalLayer);
    mount.prepend(svg);
  }

  function nearestTrace() {
    if (!pointer || !field?.traces.length) return null;
    let result = null;
    let best = Infinity;
    for (let attempt = 0; attempt < 42; attempt += 1) {
      const trace = field.traces[integer(signalRandom, 0, field.traces.length - 1)];
      const point = trace.points[Math.floor(trace.points.length / 2)];
      const distance = Math.hypot(point[0] - pointer.x, point[1] - pointer.y);
      if (distance < best) {
        best = distance;
        result = trace;
      }
    }
    return result;
  }

  function spawnSignal({ nearPointer = false } = {}) {
    if (!config.signals || reducedMotion || activeSignals >= config.maxSignals || !field?.traces.length) return;
    const trace = (nearPointer && nearestTrace()) || field.traces[integer(signalRandom, 0, field.traces.length - 1)];
    const points = signalRandom() < 0.5 ? [...trace.points].reverse() : trace.points;
    const color = weighted(signalRandom, config.signalColors);
    const duration = ((trace.length + config.signalLength) / config.signalSpeed) * 1000;
    const pulse = svgElement("path", {
      d: pathData(points), stroke: color, "stroke-width": 2.4, "stroke-linecap": "round",
      "stroke-dasharray": `${config.signalLength} ${trace.length + config.signalLength}`,
      "stroke-dashoffset": config.signalLength, "stroke-opacity": 0.62,
    });
    pulse.style.filter = `drop-shadow(0 0 3px ${color})`;
    signalLayer.appendChild(pulse);
    activeSignals += 1;
    pulse.animate(
      [{ strokeDashoffset: config.signalLength }, { strokeDashoffset: -trace.length }],
      { duration, easing: "linear", fill: "forwards" },
    );
    window.setTimeout(() => {
      pulse.remove();
      activeSignals = Math.max(0, activeSignals - 1);
    }, duration + 150);
  }

  function onPointerMove(event) {
    const rect = mount.getBoundingClientRect();
    pointer = {
      x: (event.clientX - rect.left) / rect.width * field.width,
      y: (event.clientY - rect.top) / rect.height * field.height,
      at: performance.now(),
    };
  }

  function onClick() {
    for (let index = 0; index < 6; index += 1) {
      window.setTimeout(() => spawnSignal({ nearPointer: true }), index * 50);
    }
  }

  function startSignals() {
    window.clearInterval(timer);
    if (!config.signals || reducedMotion) return;
    timer = window.setInterval(() => {
      if (!document.hidden) spawnSignal({ nearPointer: pointer && performance.now() - pointer.at < 4000 });
    }, config.signalInterval);
  }

  function regenerate(nextOptions = {}) {
    config = { ...config, ...nextOptions };
    activeSignals = 0;
    lastSize = "";
    build(true);
    startSignals();
    return api;
  }

  function exportSvg() {
    const clone = svg.cloneNode(true);
    clone.querySelector("[data-signals]")?.replaceChildren();
    clone.removeAttribute("style");
    return `<?xml version="1.0" encoding="UTF-8"?>\n${clone.outerHTML}`;
  }

  function destroy() {
    window.clearInterval(timer);
    window.clearTimeout(resizeTimer);
    resizeObserver?.disconnect();
    interactionTarget.removeEventListener("pointermove", onPointerMove);
    interactionTarget.removeEventListener("click", onClick);
    svg?.remove();
    mount.style.position = originalPosition;
  }

  const api = { regenerate, spawnSignal, exportSvg, destroy, get field() { return field; } };
  build(true);
  resizeObserver = new ResizeObserver(() => {
    window.clearTimeout(resizeTimer);
    resizeTimer = window.setTimeout(() => build(), 220);
  });
  resizeObserver.observe(mount);
  if (config.interactive) {
    interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });
    interactionTarget.addEventListener("click", onClick);
  }
  startSignals();
  return api;
}

export { DEFAULTS };