master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago
const SVG_NS = "http://www.w3.org/2000/svg";

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 pathData(points) {
  return points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");
}

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 compareTraceDistance(a, b) {
  return a.distance - b.distance || a.index - b.index;
}

function compareTraceKey(distance, index, ranked) {
  return distance - ranked.distance || index - ranked.index;
}

function siftDownTraceDistance(heap, index) {
  while (true) {
    const left = index * 2 + 1;
    if (left >= heap.length) return;
    const right = left + 1;
    let largest = left;
    if (right < heap.length && compareTraceDistance(heap[left], heap[right]) < 0) largest = right;
    if (compareTraceDistance(heap[index], heap[largest]) >= 0) return;
    [heap[index], heap[largest]] = [heap[largest], heap[index]];
    index = largest;
  }
}

function nearestTraces(traces, origin, count) {
  if (!(count > 0)) return [];
  const nearest = [];
  for (let index = 0; index < traces.length; index += 1) {
    const trace = traces[index];
    const midpoint = trace.points[Math.floor(trace.points.length / 2)];
    const distance = Math.hypot(midpoint[0] - origin.x, midpoint[1] - origin.y);
    if (nearest.length < count) {
      nearest.push({ trace, distance, index });
      let child = nearest.length - 1;
      while (child > 0) {
        const parent = Math.floor((child - 1) / 2);
        if (compareTraceDistance(nearest[parent], nearest[child]) >= 0) break;
        [nearest[parent], nearest[child]] = [nearest[child], nearest[parent]];
        child = parent;
      }
    } else if (compareTraceKey(distance, index, nearest[0]) < 0) {
      nearest[0] = { trace, distance, index };
      siftDownTraceDistance(nearest, 0);
    }
  }
  return nearest.sort(compareTraceDistance);
}

export function createConductorAnimation(svg, field, config, random) {
  const ambientLayer = svgElement("g", {
    "data-animation": "conductor-ambient", fill: "none", "pointer-events": "none",
  });
  const signalLayer = svgElement("g", {
    "data-animation": "conductor-signals", fill: "none", "pointer-events": "none",
  });
  svg.append(ambientLayer, signalLayer);

  let timer;
  let waveTimer;
  let activeSignals = 0;
  let ambientCurrentCount = 0;
  let ambientNodeCount = 0;
  let wavesTriggered = 0;
  let pointer = null;
  let lastPointerWake = 0;
  const scheduled = new Set();
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

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

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

  function trackTimeout(callback, delay) {
    const timeout = window.setTimeout(() => {
      scheduled.delete(timeout);
      callback();
    }, Math.max(0, delay));
    scheduled.add(timeout);
    return timeout;
  }

  function clearScheduled() {
    for (const timeout of scheduled) window.clearTimeout(timeout);
    scheduled.clear();
  }

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

  function flashNode(point, color, delay = 0, intensity = 1) {
    if (delay > 0) {
      trackTimeout(() => flashNode(point, color, 0, intensity), delay);
      return;
    }
    if (!signalLayer.isConnected || reducedMotion || !config.motion || !config.nodeFlashes) return;
    const scale = featureScale();
    const radius = config.viaOuterRadius * scale;
    const ring = svgElement("circle", {
      cx: point[0], cy: point[1], r: radius * 0.7,
      fill: "none", stroke: color,
      "stroke-width": Math.max(0.8, config.traceWidth * scale * 0.85),
      opacity: 0,
    });
    ring.style.filter = `drop-shadow(0 0 ${3.5 * scale}px ${color})`;
    signalLayer.appendChild(ring);
    const duration = 480 + intensity * 220;
    ring.animate(
      [
        { opacity: 0, transform: "scale(.45)" },
        { offset: 0.18, opacity: Math.min(1, 0.55 + intensity * 0.25), transform: "scale(.9)" },
        { opacity: 0, transform: `scale(${2.2 + intensity * 0.7})` },
      ],
      { duration, easing: "cubic-bezier(.16,.8,.3,1)", fill: "forwards" },
    );
    ring.style.transformBox = "fill-box";
    ring.style.transformOrigin = "center";
    trackTimeout(() => ring.remove(), duration + 40);
  }

  function spawnSignal({
    nearPointer = false,
    trace = null,
    delay = 0,
    reverse,
    color,
    intensity = 1,
    lengthScale = 1,
    speedScale = 1,
    endpointFlash = false,
  } = {}) {
    if (delay > 0) {
      trackTimeout(() => spawnSignal({
        nearPointer, trace, reverse, color, intensity, lengthScale, speedScale, endpointFlash,
      }), delay);
      return true;
    }
    if (!config.motion || reducedMotion || activeSignals >= config.maxSignals || !field.traces.length) return false;
    const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];
    const reversed = reverse ?? random() < 0.5;
    const points = reversed ? [...selected.points].reverse() : selected.points;
    const selectedColor = color || weighted(random, config.signalColors);
    const scale = featureScale();
    const requestedLength = config.signalLength * scale * lengthScale;
    const signalLength = Math.max(12 * scale, Math.min(requestedLength, selected.length * 0.72));
    const signalSpeed = config.signalSpeed * scale * (0.78 + life() * 0.27) * speedScale;
    const duration = Math.max(460, ((selected.length + signalLength) / signalSpeed) * 1000);
    const dashGap = selected.length + signalLength;
    const group = svgElement("g", { "data-signal": "" });
    const halo = svgElement("path", {
      d: pathData(points), stroke: selectedColor,
      "stroke-width": (5.2 + intensity * 2.2) * scale,
      "stroke-linecap": "round",
      "stroke-dasharray": `${signalLength} ${dashGap}`,
      "stroke-dashoffset": signalLength,
      opacity: 0,
    });
    const core = svgElement("path", {
      d: pathData(points), stroke: selectedColor,
      "stroke-width": (1.25 + intensity * 1.25) * scale,
      "stroke-linecap": "round",
      "stroke-dasharray": `${signalLength} ${dashGap}`,
      "stroke-dashoffset": signalLength,
      opacity: 0,
    });
    halo.style.filter = `blur(${1.6 * scale}px) drop-shadow(0 0 ${4.5 * scale}px ${selectedColor})`;
    core.style.filter = `drop-shadow(0 0 ${2.8 * scale}px ${selectedColor})`;
    if (config.signalHalos) group.appendChild(halo);
    group.appendChild(core);
    signalLayer.appendChild(group);
    activeSignals += 1;
    const movement = [
      { strokeDashoffset: signalLength, opacity: 0 },
      { offset: 0.08, opacity: 0.92 },
      { offset: 0.84, opacity: 0.72 },
      { strokeDashoffset: -selected.length, opacity: 0 },
    ];
    if (config.signalHalos) {
      halo.animate(movement.map((frame) => ({ ...frame, opacity: (frame.opacity || 0) * 0.22 * intensity })), {
        duration, easing: "linear", fill: "forwards",
      });
    }
    core.animate(movement, { duration, easing: "linear", fill: "forwards" });
    if (endpointFlash) {
      flashNode(points[0], selectedColor, 0, intensity);
      flashNode(points.at(-1), selectedColor, duration * 0.87, intensity * 0.8);
    }
    trackTimeout(() => {
      group.remove();
      activeSignals = Math.max(0, activeSignals - 1);
    }, duration + 100);
    return true;
  }

  function spawnConvoy({ nearPointer = false, trace = null } = {}) {
    if (!config.motion || !config.signalConvoys || reducedMotion || !field.traces.length) return;
    const selected = trace || (nearPointer && nearestTrace()) || field.traces[integer(random, 0, field.traces.length - 1)];
    const count = Math.max(2, Math.min(4, Math.round(2 + life() + random())));
    const selectedColor = weighted(random, config.signalColors);
    const reverse = random() < 0.5;
    for (let index = 0; index < count; index += 1) {
      spawnSignal({
        trace: selected,
        reverse,
        color: selectedColor,
        delay: index * (78 + random() * 54),
        intensity: 0.62 + index * 0.08,
        lengthScale: 0.28 + random() * 0.22,
        speedScale: 0.92 + random() * 0.18,
        endpointFlash: index === 0,
      });
    }
  }

  function ripple(origin, color) {
    if (!signalLayer.isConnected) return;
    const scale = featureScale();
    const startRadius = config.pitch * scale * 0.8;
    const ring = svgElement("circle", {
      cx: origin.x, cy: origin.y, r: startRadius,
      fill: "none", stroke: color,
      "stroke-width": Math.max(0.7, config.traceWidth * scale * 0.72),
      opacity: 0.72,
    });
    ring.style.filter = `drop-shadow(0 0 ${4 * scale}px ${color})`;
    signalLayer.appendChild(ring);
    ring.animate(
      [
        { opacity: 0.72, transform: "scale(.35)" },
        { offset: 0.3, opacity: 0.34 },
        { opacity: 0, transform: "scale(6)" },
      ],
      { duration: 980, easing: "cubic-bezier(.12,.72,.18,1)", fill: "forwards" },
    );
    ring.style.transformBox = "fill-box";
    ring.style.transformOrigin = "center";
    trackTimeout(() => ring.remove(), 1040);
  }

  function spawnWave(origin = null) {
    if (!config.motion || !config.signalWaves || reducedMotion || !field.traces.length) return false;
    const source = origin || {
      x: field.width * (0.14 + random() * 0.72),
      y: field.height * (0.14 + random() * 0.72),
    };
    const selectedColor = weighted(random, config.signalColors);
    ripple(source, selectedColor);
    const count = Math.min(field.traces.length, Math.max(6, Math.round(6 + life() * 8)));
    const ranked = nearestTraces(field.traces, source, count);
    const outerDistance = ranked[count - 1]?.distance || 1;
    for (let index = 0; index < count; index += 1) {
      const { trace, distance } = ranked[index];
      const start = trace.points[0];
      const end = trace.points.at(-1);
      const reverse = Math.hypot(end[0] - source.x, end[1] - source.y)
        < Math.hypot(start[0] - source.x, start[1] - source.y);
      spawnSignal({
        trace,
        reverse,
        color: selectedColor,
        delay: 90 + distance / outerDistance * 560 + index * 14,
        intensity: 0.64 + (1 - distance / outerDistance) * 0.34,
        lengthScale: 0.44 + random() * 0.2,
        speedScale: 1.02 + random() * 0.18,
        endpointFlash: index % 4 === 0,
      });
    }
    wavesTriggered += 1;
    return true;
  }

  function seedAmbientLife() {
    ambientCurrentCount = 0;
    ambientNodeCount = 0;
    if (!config.motion || reducedMotion || life() <= 0.02 || !field.traces.length) return;
    const scale = featureScale();
    const eligible = field.traces.filter((trace) => trace.length > 28 * scale);
    const currents = config.ambientCurrents
      ? Math.min(eligible.length, Math.max(1, Math.round(config.ambientCurrentCount * life())))
      : 0;
    shuffle(random, eligible);
    for (const trace of eligible.slice(0, currents)) {
      const color = weighted(random, config.signalColors);
      const dash = Math.max(12 * scale, trace.length * (0.07 + random() * 0.1));
      const current = svgElement("path", {
        d: pathData(random() < 0.5 ? [...trace.points].reverse() : trace.points),
        stroke: color,
        "stroke-width": config.traceWidth * scale * (0.9 + random() * 0.55),
        "stroke-linecap": "round",
        "stroke-dasharray": `${dash} ${trace.length + dash}`,
        "stroke-dashoffset": dash,
        opacity: 0,
      });
      current.style.filter = `drop-shadow(0 0 ${2.2 * scale}px ${color})`;
      ambientLayer.appendChild(current);
      const duration = (6400 + random() * 9000) / (0.72 + life() * 0.35);
      current.animate(
        [
          { strokeDashoffset: dash, opacity: 0 },
          { offset: 0.14, opacity: 0.08 + life() * 0.055 },
          { offset: 0.78, opacity: 0.05 + life() * 0.04 },
          { strokeDashoffset: -trace.length, opacity: 0 },
        ],
        { duration, delay: -random() * duration, iterations: Infinity, easing: "linear" },
      );
      ambientCurrentCount += 1;
    }

    const nodes = config.ambientNodes
      ? Math.min(field.traces.length, Math.max(1, Math.round(config.ambientNodeCount * life())))
      : 0;
    for (let index = 0; index < nodes; index += 1) {
      const trace = field.traces[integer(random, 0, field.traces.length - 1)];
      const point = random() < 0.5 ? trace.points[0] : trace.points.at(-1);
      const color = weighted(random, config.signalColors);
      const node = svgElement("circle", {
        cx: point[0], cy: point[1],
        r: config.viaOuterRadius * scale * (0.34 + random() * 0.18),
        fill: color, opacity: 0,
      });
      node.style.filter = `drop-shadow(0 0 ${3.2 * scale}px ${color})`;
      node.style.transformBox = "fill-box";
      node.style.transformOrigin = "center";
      ambientLayer.appendChild(node);
      const duration = 2600 + random() * 4300;
      node.animate(
        [
          { opacity: 0, transform: "scale(.65)" },
          { offset: 0.48, opacity: 0.16 + life() * 0.11, transform: "scale(1.22)" },
          { opacity: 0, transform: "scale(.72)" },
        ],
        { duration, delay: -random() * duration, iterations: Infinity, easing: "ease-in-out" },
      );
      ambientNodeCount += 1;
    }
  }

  function pointerMoved(origin) {
    pointer = origin;
    if (!pointer) return;
    const wakeInterval = config.pointerWakeInterval / Math.max(0.55, 0.55 + life() * 0.5);
    if (config.motion && config.pointerWake && !reducedMotion && pointer.at - lastPointerWake > wakeInterval) {
      lastPointerWake = pointer.at;
      spawnSignal({
        nearPointer: true,
        intensity: 0.5 + life() * 0.16,
        lengthScale: 0.34,
        speedScale: 1.3,
      });
    }
  }

  function clicked(origin) {
    pointer = origin;
    spawnWave(pointer);
    if (config.signalConvoys) trackTimeout(() => spawnConvoy({ nearPointer: true }), 160);
  }

  function stop() {
    window.clearInterval(timer);
    window.clearInterval(waveTimer);
    timer = undefined;
    waveTimer = undefined;
    clearScheduled();
  }

  function start() {
    stop();
    if (!config.motion || reducedMotion || life() <= 0.02) return;
    seedAmbientLife();
    const interval = Math.max(130, config.signalInterval / (0.55 + life() * 0.78));
    timer = window.setInterval(() => {
      if (document.hidden) return;
      const nearPointer = pointer && performance.now() - pointer.at < 3200;
      if (config.signalConvoys && random() < 0.12 + life() * 0.13) spawnConvoy({ nearPointer });
      else spawnSignal({
        nearPointer,
        intensity: 0.62 + random() * 0.34,
        lengthScale: 0.52 + random() * 0.58,
        speedScale: 0.86 + random() * 0.34,
        endpointFlash: random() < 0.12,
      });
    }, interval);
    if (config.signalWaves) {
      const intervalForWaves = Math.max(2800, config.waveInterval / (0.66 + life() * 0.48));
      waveTimer = window.setInterval(() => {
        if (!document.hidden) spawnWave();
      }, intervalForWaves);
    }
    if (config.signalConvoys) trackTimeout(() => spawnConvoy(), 360);
    if (config.signalWaves) trackTimeout(() => spawnWave(), 1450);
  }

  function destroy() {
    stop();
    ambientLayer.remove();
    signalLayer.remove();
  }

  return {
    start,
    stop,
    destroy,
    pointerMoved,
    clicked,
    trigger: spawnWave,
    get stats() {
      return {
        system: "original-conductor",
        activeSignals,
        ambientCurrentCount,
        ambientNodeCount,
        wavesTriggered,
        reducedMotion,
        signalHalos: config.signalHalos,
        signalConvoys: config.signalConvoys,
        signalWaves: config.signalWaves,
        pointerWake: config.pointerWake,
      };
    },
  };
}