master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago
import { createConductorAnimation } from "./circuit-animation-conductor.js?v=1";
import { createHandshakeAnimation } from "./circuit-animation-handshake.js?v=2";
import { createTraceGrowthAnimation } from "./circuit-animation-growth.js?v=1";
import {
  createReflectionAnimation,
  REFLECTION_DEFAULTS,
} from "./circuit-animation-reflection.js?v=1";
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,
  growthDuration: 7600,
  growthGlow: 0.82,
  ...REFLECTION_DEFAULTS,
});

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,
  }),
  growth: Object.freeze({
    animationSystem: "growth",
    motion: true,
    liveliness: 0.72,
    growthDuration: 7600,
    growthGlow: 0.88,
    atmosphereStrength: 0.16,
  }),
  reflection: Object.freeze({
    animationSystem: "reflection",
    motion: true,
    liveliness: 0.68,
    reflectionStrength: 0.62,
    reflectionCycle: 14800,
    reflectionWidth: 0.3,
    reflectionAngle: 18,
    reflectionDrift: 6,
    reflectionRest: 0.12,
    reflectionQuiet: 0.84,
    reflectionPointer: true,
    atmosphereStrength: 0.14,
  }),
});

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 growth;
  let reflection;
  let resizeObserver;
  let resizeTimer;
  let pointer = null;
  let lastSize = "";
  const interactionTarget = mount.parentElement || mount;
  const readingZone = interactionTarget.querySelector?.("[data-circuit-reading-zone]") || null;
  const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reducedMotion = motionPreference.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, role) {
    const scale = featureScale();
    const circles = [
      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)",
      }),
    ];
    if (!role) {
      group.append(...circles);
      return circles;
    }
    const via = svgElement("g", { "data-trace-via": role });
    via.append(...circles);
    group.append(via);
    return via;
  }

  function destroyAnimationSystems() {
    conductor?.destroy();
    handshake?.destroy();
    growth?.destroy();
    reflection?.destroy();
    conductor = undefined;
    handshake = undefined;
    growth = undefined;
    reflection = 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"
      : config.animationSystem === "handshake"
        ? "handshakes"
        : config.animationSystem;
    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": "" });
    const growthMarkup = config.animationSystem === "growth";
    for (const [index, trace] of field.traces.entries()) {
      const tracePath = svgElement("path", {
        ...(growthMarkup ? { "data-trace-path": "" } : {}),
        d: pathData(trace.points),
        fill: "none",
        stroke: config.traceColor,
        "stroke-width": config.traceWidth * config.featureScale,
        "stroke-linecap": "butt",
        "stroke-linejoin": "miter",
      });
      if (!growthMarkup) {
        traceLayer.append(tracePath);
        if (trace.viaStart) addVia(traceLayer, trace.points[0]);
        if (trace.viaEnd) addVia(traceLayer, trace.points.at(-1));
        continue;
      }
      const traceGroup = svgElement("g", { "data-circuit-trace": index });
      traceGroup.append(tracePath);
      if (trace.viaStart) addVia(traceGroup, trace.points[0], "start");
      if (trace.viaEnd) addVia(traceGroup, trace.points.at(-1), "end");
      traceLayer.append(traceGroup);
    }
    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 });
    } else if (config.animationSystem === "growth") {
      growth = createTraceGrowthAnimation(svg, field, config, motionRandom, { traceLayer });
    } else if (config.animationSystem === "reflection") {
      reflection = createReflectionAnimation(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);
    reflection?.pointerMoved(pointer);
  }

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

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

  function stopMotion() {
    conductor?.stop();
    handshake?.stop();
    growth?.stop();
    reflection?.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();
    else if (config.animationSystem === "growth") growth?.start();
    else if (config.animationSystem === "reflection") reflection?.start();
  }

  function onMotionPreferenceChange(event) {
    reducedMotion = event.matches;
    if (reducedMotion) {
      stopMotion();
      return;
    }
    lastSize = "";
    build(true);
    startMotion();
  }

  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;
    if (config.animationSystem === "growth") return growth?.trigger() || false;
    if (config.animationSystem === "reflection") return reflection?.trigger(origin) || false;
    return false;
  }

  function exportSvg() {
    const clone = svg.cloneNode(true);
    clone.querySelectorAll("[data-animation]").forEach((layer) => layer.remove());
    clone.querySelectorAll("[data-trace-path]").forEach((path) => {
      path.removeAttribute("stroke-dasharray");
      path.removeAttribute("stroke-dashoffset");
      path.style.removeProperty("opacity");
      path.style.removeProperty("will-change");
    });
    clone.querySelectorAll("[data-circuit-trace]").forEach((group, index) => {
      const path = group.querySelector("[data-trace-path]");
      const trace = field.traces[index];
      if (path && trace) path.setAttribute("d", pathData(trace.points));
    });
    clone.querySelectorAll("[data-trace-via]").forEach((via) => {
      via.style.removeProperty("opacity");
      via.style.removeProperty("will-change");
    });
    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);
    if (motionPreference.removeEventListener) {
      motionPreference.removeEventListener("change", onMotionPreferenceChange);
    } else {
      motionPreference.removeListener?.(onMotionPreferenceChange);
    }
    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,
        };
      }
      if (config.animationSystem === "growth") {
        return growth?.stats || {
          system: "trace-growth",
          animationNodes: 0,
          activeTraces: 0,
          frameCount: 0,
          reducedMotion,
        };
      }
      if (config.animationSystem === "reflection") {
        return reflection?.stats || {
          system: "adom-reflection",
          animationNodes: 0,
          activeReflections: 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);
  }
  if (motionPreference.addEventListener) {
    motionPreference.addEventListener("change", onMotionPreferenceChange);
  } else {
    motionPreference.addListener?.(onMotionPreferenceChange);
  }
  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 };