master
AdityaAngajala Publish 0.1.0 da40167 3mo ago
import { createConductorAnimation } from "./circuit-animation-conductor.js?v=1";
import { createHandshakeAnimation } from "./circuit-animation-handshake.js?v=2";
import {
  createCircuitBackground as createLegacyCircuitBackground,
  DEFAULTS as GEOMETRY_DEFAULTS,
  ANIMATION_PRESETS as LEGACY_ANIMATION_PRESETS,
  generateCircuitField,
  pathData,
} from "./circuit-background-v4.js?geometry=1";

const SVG_NS = "http://www.w3.org/2000/svg";

const DEFAULTS = Object.freeze({
  ...GEOMETRY_DEFAULTS,
  animationSystem: "none",
  motion: false,
  liveliness: 0,
  atmosphereStrength: 0,
  handshakeInterval: 1650,
  handshakeSpeed: 230,
  handshakeLength: 48,
  maxHandshakes: 3,
});

const ANIMATION_PRESETS = Object.freeze({
  none: LEGACY_ANIMATION_PRESETS.none,
  conductor: LEGACY_ANIMATION_PRESETS.conductor,
  routed: LEGACY_ANIMATION_PRESETS.adom,
  handshake: Object.freeze({
    animationSystem: "handshake",
    motion: true,
    liveliness: 0.72,
    handshakeInterval: 1650,
    handshakeSpeed: 230,
    handshakeLength: 48,
    maxHandshakes: 3,
    atmosphereStrength: 0,
  }),
});

let instanceCount = 0;

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

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

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

function createModernCircuitBackground(mount, options = {}) {
  if (!(mount instanceof Element)) throw new TypeError("mount must be a DOM element");
  const id = `circuit-field-${++instanceCount}`;
  let config = { ...DEFAULTS, ...options };
  let field;
  let svg;
  let conductor;
  let handshake;
  let resizeObserver;
  let resizeTimer;
  let pointer = null;
  let lastSize = "";
  const interactionTarget = mount.parentElement || mount;
  const readingZone = interactionTarget.querySelector?.("[data-circuit-reading-zone]") || null;
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const originalPosition = mount.style.position;
  const originalOverflow = mount.style.overflow;
  if (getComputedStyle(mount).position === "static") mount.style.position = "relative";
  mount.style.overflow = "hidden";

  function life() {
    return Math.max(0, Math.min(1.5, config.liveliness));
  }

  function featureScale() {
    return Math.max(0.25, config.featureScale);
  }

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

  function destroyAnimationSystems() {
    conductor?.destroy();
    handshake?.destroy();
    conductor = undefined;
    handshake = undefined;
  }

  function build(force = false) {
    const pixelWidth = Math.max(1, mount.clientWidth);
    const pixelHeight = Math.max(1, mount.clientHeight);
    const worldWidth = pixelWidth / config.scale;
    const worldHeight = pixelHeight / config.scale;
    const pitch = config.pitch * featureScale();
    const size = `${Math.floor(worldWidth / pitch)}x${Math.floor(worldHeight / pitch)}`;
    if (!force && size === lastSize) return false;
    lastSize = size;

    destroyAnimationSystems();
    svg?.remove();
    field = generateCircuitField({ ...config, width: worldWidth, height: worldHeight });
    const motionNamespace = config.animationSystem === "conductor" ? "signals" : "handshakes";
    const motionRandom = mulberry32(hashSeed(`${config.seed}:${size}:${motionNamespace}`));

    svg = svgElement("svg", {
      id,
      "aria-hidden": true,
      focusable: false,
      viewBox: `0 0 ${field.width} ${field.height}`,
      preserveAspectRatio: "none",
    });
    Object.assign(svg.style, {
      position: "absolute",
      inset: "0",
      width: "100%",
      height: "100%",
      display: "block",
      pointerEvents: "none",
    });

    const traceLayer = svgElement("g", { "data-static-traces": "" });
    for (const trace of field.traces) {
      traceLayer.appendChild(svgElement("path", {
        d: pathData(trace.points),
        fill: "none",
        stroke: config.traceColor,
        "stroke-width": config.traceWidth * config.featureScale,
        "stroke-linecap": "butt",
        "stroke-linejoin": "miter",
      }));
      if (trace.viaStart) addVia(traceLayer, trace.points[0]);
      if (trace.viaEnd) addVia(traceLayer, trace.points.at(-1));
    }
    svg.append(traceLayer);
    mount.prepend(svg);

    if (config.animationSystem === "conductor") {
      conductor = createConductorAnimation(svg, field, config, motionRandom);
    } else if (
      config.animationSystem === "handshake"
      && config.motion
      && !reducedMotion
      && life() > 0.02
    ) {
      handshake = createHandshakeAnimation(mount, field, config, motionRandom, { readingZone });
    }
    return true;
  }

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

  function onPointerLeave() {
    if (pointer) pointer.at = performance.now() - 3000;
    handshake?.pointerLeft();
  }

  function onClick(event) {
    onPointerMove(event);
    if (config.animationSystem === "conductor") conductor?.clicked(pointer);
    else if (config.animationSystem === "handshake") handshake?.clicked(pointer);
  }

  function stopMotion() {
    conductor?.stop();
    handshake?.stop();
  }

  function startMotion() {
    stopMotion();
    if (config.animationSystem === "none" || !config.motion || reducedMotion || life() <= 0.02) return;
    if (config.animationSystem === "conductor") conductor?.start();
    else if (config.animationSystem === "handshake") handshake?.start();
  }

  function regenerate(nextOptions = {}) {
    stopMotion();
    config = { ...config, ...nextOptions };
    lastSize = "";
    build(true);
    startMotion();
    return api;
  }

  function triggerAnimation(origin = null) {
    if (config.animationSystem === "conductor") return conductor?.trigger(origin) || false;
    if (config.animationSystem === "handshake") return handshake?.trigger(origin) || false;
    return false;
  }

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

  function destroy() {
    stopMotion();
    destroyAnimationSystems();
    window.clearTimeout(resizeTimer);
    resizeObserver?.disconnect();
    interactionTarget.removeEventListener("pointermove", onPointerMove);
    interactionTarget.removeEventListener("pointerleave", onPointerLeave);
    interactionTarget.removeEventListener("click", onClick);
    svg?.remove();
    mount.style.position = originalPosition;
    mount.style.overflow = originalOverflow;
  }

  const api = {
    regenerate,
    triggerAnimation,
    triggerBloom: triggerAnimation,
    exportSvg,
    destroy,
    get field() { return field; },
    get animationStats() {
      if (config.animationSystem === "none") {
        return { system: "none", animationNodes: 0, frameCount: 0, reducedMotion };
      }
      if (config.animationSystem === "conductor") return conductor?.stats;
      if (config.animationSystem === "handshake") {
        return handshake?.stats || {
          system: "adom-handshake",
          animationNodes: 0,
          activeTransactions: 0,
          frameCount: 0,
          reducedMotion,
        };
      }
      return { system: "unknown", animationNodes: 0, frameCount: 0, reducedMotion };
    },
  };

  build(true);
  resizeObserver = new ResizeObserver(() => {
    window.clearTimeout(resizeTimer);
    resizeTimer = window.setTimeout(() => {
      if (build()) {
        stopMotion();
        startMotion();
      }
    }, 220);
  });
  resizeObserver.observe(mount);
  if (config.interactive) {
    interactionTarget.addEventListener("pointermove", onPointerMove, { passive: true });
    interactionTarget.addEventListener("pointerleave", onPointerLeave, { passive: true });
    interactionTarget.addEventListener("click", onClick);
  }
  startMotion();
  return api;
}

export function createCircuitBackground(mount, options = {}) {
  let config = { ...DEFAULTS, ...options };
  let destroyed = false;
  const usesRoutedField = (value) => value.animationSystem === "adom";
  const createEngine = () => usesRoutedField(config)
    ? createLegacyCircuitBackground(mount, config)
    : createModernCircuitBackground(mount, config);
  let engine = createEngine();

  const api = {
    regenerate(nextOptions = {}) {
      if (destroyed) throw new Error("Cannot regenerate a destroyed circuit background");
      const wasRouted = usesRoutedField(config);
      config = { ...config, ...nextOptions };
      if (wasRouted !== usesRoutedField(config)) {
        engine.destroy();
        engine = createEngine();
      } else {
        engine.regenerate(nextOptions);
      }
      return api;
    },
    triggerAnimation(origin = null) {
      return engine.triggerAnimation?.(origin) || false;
    },
    triggerBloom(origin = null) {
      return engine.triggerBloom?.(origin) || engine.triggerAnimation?.(origin) || false;
    },
    exportSvg() {
      return engine.exportSvg();
    },
    destroy() {
      if (destroyed) return;
      destroyed = true;
      engine.destroy();
    },
    get field() { return engine.field; },
    get animationStats() { return engine.animationStats; },
  };

  return api;
}

export { DEFAULTS, ANIMATION_PRESETS, generateCircuitField, pathData };