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

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

let instanceCount = 0;

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

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

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

function choose(random, values) {
  return values[Math.floor(random() * values.length)];
}

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

function shuffle(random, values) {
  const result = [...values];
  for (let index = result.length - 1; index > 0; index -= 1) {
    const swap = integer(random, 0, index);
    [result[index], result[swap]] = [result[swap], result[index]];
  }
  return result;
}

function partition(random, total, count, minimum) {
  const values = Array(count).fill(Math.floor(total / count));
  for (let index = 0; index < total % count; index += 1) values[index] += 1;
  for (let turn = 0; turn < 10; turn += 1) {
    const from = integer(random, 0, count - 1);
    let to = integer(random, 0, count - 1);
    if (to === from) to = (to + 1) % count;
    if (values[from] > minimum) {
      values[from] -= 1;
      values[to] += 1;
    }
  }
  return values;
}

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

function compact(points) {
  const clean = [];
  for (const point of points) {
    const previous = clean.at(-1);
    if (!previous || point[0] !== previous[0] || point[1] !== previous[1]) clean.push(point);
  }
  return clean;
}

function addTrace(target, points, viaStart = true, viaEnd = true) {
  const clean = compact(points);
  target.push({ points: clean, length: lengthOf(clean), viaStart, viaEnd });
}

function horizontalBundle(target, box, count, random) {
  const firstBend = box.x + Math.max(3, Math.floor(box.w * 0.25));
  const secondBend = box.x + Math.min(box.w - 3, Math.floor(box.w * 0.75));
  const maximumDiagonal = Math.max(1, Math.floor((secondBend - firstBend - 1) / 2));
  const diagonal = Math.min(integer(random, 2, 3), maximumDiagonal);
  const shift = diagonal * (random() < 0.5 ? -1 : 1);
  const available = box.h - 4 - diagonal;
  const lanes = Math.max(3, Math.min(count, available));
  const yStart = shift > 0 ? box.y + 2 : box.y + box.h - 2 - lanes;

  for (let lane = 0; lane < lanes; lane += 1) {
    const y = yStart + lane;
    addTrace(target, [
      [box.x, y],
      [firstBend, y],
      [firstBend + diagonal, y + shift],
      [secondBend - diagonal, y + shift],
      [secondBend, y],
      [box.x + box.w, y],
    ], random() < 0.72, random() < 0.72);
  }
}

function verticalBundle(target, box, count, random) {
  const swapped = [];
  horizontalBundle(swapped, { x: box.y, y: box.x, w: box.h, h: box.w }, count, random);
  for (const trace of swapped) {
    addTrace(target, trace.points.map(([x, y]) => [y, x]), trace.viaStart, trace.viaEnd);
  }
}

function cornerBundle(target, box, count, random) {
  const lanes = Math.max(4, Math.min(count, box.w - 5, box.h - 5));
  const orientation = integer(random, 0, 3);
  for (let lane = 0; lane < lanes; lane += 1) {
    let points;
    if (orientation === 0) {
      const x = box.x + 2 + lane;
      const y = box.y + box.h - 2 - lane;
      points = [[x, box.y], [x, y - 1], [x + 1, y], [box.x + box.w, y]];
    } else if (orientation === 1) {
      const x = box.x + box.w - 2 - lane;
      const y = box.y + box.h - 2 - lane;
      points = [[x, box.y], [x, y - 1], [x - 1, y], [box.x, y]];
    } else if (orientation === 2) {
      const x = box.x + 2 + lane;
      const y = box.y + 2 + lane;
      points = [[x, box.y + box.h], [x, y + 1], [x + 1, y], [box.x + box.w, y]];
    } else {
      const x = box.x + box.w - 2 - lane;
      const y = box.y + 2 + lane;
      points = [[x, box.y + box.h], [x, y + 1], [x - 1, y], [box.x, y]];
    }
    addTrace(target, points, random() < 0.76, random() < 0.76);
  }
}

function hairpinBundle(target, box, count, random) {
  const loops = Math.max(3, Math.min(Math.floor(count * 0.62), Math.floor((box.h - 3) / 2), box.w - 6));
  const opensLeft = random() < 0.5;
  for (let lane = 0; lane < loops; lane += 1) {
    const top = box.y + 1 + lane;
    const bottom = box.y + box.h - 1 - lane;
    if (opensLeft) {
      const end = box.x + box.w - 1 - lane;
      addTrace(target, [
        [box.x, top],
        [end - 1, top],
        [end, top + 1],
        [end, bottom - 1],
        [end - 1, bottom],
        [box.x, bottom],
      ]);
    } else {
      const end = box.x + 1 + lane;
      addTrace(target, [
        [box.x + box.w, top],
        [end + 1, top],
        [end, top + 1],
        [end, bottom - 1],
        [end + 1, bottom],
        [box.x + box.w, bottom],
      ]);
    }
  }
}

function scaleTrace(trace, pitch) {
  const points = trace.points.map(([x, y]) => [x * pitch, y * pitch]);
  return { ...trace, points, length: lengthOf(points) };
}

export function generateCircuitTile(options = {}) {
  const config = { ...DEFAULTS, ...options };
  const random = mulberry32(hashSeed(config.seed));
  const columns = Math.round(config.tileWidth / config.pitch);
  const rows = Math.round(config.tileHeight / config.pitch);
  const rowHeights = partition(random, rows, 4, 12);
  const patterns = shuffle(random, [
    "horizontal", "corner", "hairpin", "vertical", "corner",
    "vertical", "horizontal", "corner", "hairpin", "horizontal",
    "corner", "vertical", "horizontal", "corner", "hairpin",
    "horizontal", "corner", "vertical", "hairpin", "horizontal",
  ]);
  const traces = [];
  const cells = [];
  let y = 0;
  let patternIndex = 0;

  for (const rowHeight of rowHeights) {
    const widths = partition(random, columns, 5, 18);
    let x = 0;
    for (const width of widths) {
      const box = { x: x + 1, y: y + 1, w: width - 2, h: rowHeight - 2 };
      const pattern = patterns[patternIndex++];
      const baseLanes = integer(random, 5, 8);
      const laneCount = Math.max(3, Math.round(baseLanes * config.density));
      if (pattern === "horizontal") horizontalBundle(traces, box, laneCount, random);
      if (pattern === "vertical") verticalBundle(traces, box, laneCount, random);
      if (pattern === "corner") cornerBundle(traces, box, laneCount, random);
      if (pattern === "hairpin") hairpinBundle(traces, box, laneCount, random);
      cells.push({ ...box, pattern });
      x += width;
    }
    y += rowHeight;
  }

  const scaled = traces.map((trace) => scaleTrace(trace, config.pitch));
  return {
    width: columns * config.pitch,
    height: rows * config.pitch,
    pitch: config.pitch,
    traces: scaled,
    cells,
  };
}

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

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

export function createCircuitBackground(mount, options = {}) {
  if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");

  const id = `circuit-v2-${++instanceCount}`;
  let config = { ...DEFAULTS, ...options };
  let tile;
  let svg;
  let uses;
  let signals;
  let resizeObserver;
  let timer;
  let activeSignals = 0;
  let tilesAcross = 1;
  let tilesDown = 1;
  let pointer = null;
  let signalRandom = mulberry32(hashSeed(`${config.seed}:signals`));
  const interactionTarget = mount.parentElement || mount;
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const originalPosition = mount.style.position;
  const computedPosition = getComputedStyle(mount).position;
  if (computedPosition === "static") mount.style.position = "relative";
  mount.style.overflow = "hidden";

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

  function layout() {
    const width = Math.max(1, mount.clientWidth);
    const height = Math.max(1, mount.clientHeight);
    tilesAcross = Math.max(1, Math.ceil(width / (tile.width * config.scale)));
    tilesDown = Math.max(2, Math.ceil(height / (tile.height * config.scale)) + 1);
    const viewWidth = tilesAcross * tile.width;
    const viewHeight = tilesDown * tile.height;
    svg.setAttribute("width", viewWidth * config.scale);
    svg.setAttribute("height", viewHeight * config.scale);
    svg.setAttribute("viewBox", `0 ${tile.height / 2} ${viewWidth} ${viewHeight}`);
    uses.replaceChildren();
    for (let row = 0; row < tilesDown; row += 1) {
      for (let column = 0; column < tilesAcross; column += 1) {
        uses.appendChild(svgElement("use", {
          href: `#${id}-tile`,
          transform: `translate(${column * tile.width}, ${row * tile.height})`,
        }));
      }
    }
  }

  function build() {
    svg?.remove();
    tile = generateCircuitTile(config);
    signalRandom = mulberry32(hashSeed(`${config.seed}:signals`));
    svg = svgElement("svg", { "aria-hidden": true, focusable: false, xmlns: SVG_NS });
    Object.assign(svg.style, {
      position: "absolute", left: "0", top: "0", display: "block",
      maxWidth: "none", pointerEvents: "none", overflow: "visible",
    });
    const defs = svgElement("defs");
    const tileGroup = svgElement("g", { id: `${id}-tile` });
    for (const trace of tile.traces) {
      tileGroup.appendChild(svgElement("path", {
        d: pathData(trace.points), fill: "none", stroke: config.traceColor,
        "stroke-width": config.traceWidth, "stroke-linecap": "butt", "stroke-linejoin": "miter",
      }));
      if (trace.viaStart) via(tileGroup, trace.points[0]);
      if (trace.viaEnd) via(tileGroup, trace.points.at(-1));
    }
    defs.appendChild(tileGroup);
    svg.appendChild(defs);
    uses = svgElement("g");
    signals = svgElement("g", { "data-signals": "", fill: "none" });
    svg.append(uses, signals);
    mount.prepend(svg);
    layout();
  }

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

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

  function onPointerMove(event) {
    const rect = mount.getBoundingClientRect();
    pointer = {
      x: (event.clientX - rect.left) / config.scale,
      y: (event.clientY - rect.top) / config.scale + tile.height / 2,
      at: performance.now(),
    };
  }

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

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

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

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

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

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

export { DEFAULTS };