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

function clamp(value, minimum = 0, maximum = 1) {
  return Math.max(minimum, Math.min(maximum, value));
}

function smoothstep(value) {
  const amount = clamp(value);
  return amount * amount * (3 - 2 * amount);
}

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

function brandColor(x, width) {
  const position = clamp(x / Math.max(1, width));
  if (position < 0.5) return `hsl(${176 + position * 58} 92% 63%)`;
  return `hsl(${205 + (position - 0.5) * 86} 91% 70%)`;
}

function clearRevealStyle(visual) {
  visual.path.setAttribute("d", visual.originalPathData);
  visual.path.removeAttribute("stroke-dasharray");
  visual.path.removeAttribute("stroke-dashoffset");
  visual.path.style.removeProperty("opacity");
  visual.path.style.removeProperty("will-change");
  for (const via of [visual.startVia, visual.endVia]) {
    via?.style.removeProperty("opacity");
    via?.style.removeProperty("will-change");
  }
}

export function createTraceGrowthAnimation(svg, field, config, random, { traceLayer = null } = {}) {
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const staticLayer = traceLayer || svg.querySelector("[data-static-traces]");
  const glowLayer = svgElement("g", {
    "data-animation": "trace-growth",
    "aria-hidden": "true",
  });
  glowLayer.style.mixBlendMode = "screen";
  svg.append(glowLayer);

  const traceGroups = [...staticLayer.querySelectorAll("[data-circuit-trace]")];
  const visuals = field.traces.map((trace, index) => {
    const group = traceGroups[index];
    return {
      index,
      trace,
      group,
      path: group.querySelector("[data-trace-path]"),
      startVia: group.querySelector('[data-trace-via="start"]'),
      endVia: group.querySelector('[data-trace-via="end"]'),
      sourceVia: null,
      destinationVia: null,
      overlay: null,
      startedAt: 0,
      duration: 0,
      desiredStart: 0,
      length: Math.max(1, trace.length),
      originalPathData: group.querySelector("[data-trace-path]").getAttribute("d"),
      revealed: true,
    };
  });

  let ordered = [];
  let active = new Map();
  let nextIndex = 0;
  let timelineStart = 0;
  let frameRequest = 0;
  let frameCount = 0;
  let lastFrameTime = 0;
  let running = false;
  let completed = true;

  const scale = Math.max(0.25, config.featureScale || 1);
  const life = clamp(config.liveliness || 0, 0, 1.5);
  const speedFactor = clamp(0.72 + life * 0.36, 0.72, 1.26);
  const glowStrength = clamp((config.growthGlow ?? 0.82) * (0.68 + life * 0.25), 0.1, 1.15);
  const maxActive = clamp(Math.round(16 + Math.sqrt(visuals.length) * 1.55), 22, 54);
  const targetFrameRate = visuals.length > 1200 ? 30 : visuals.length > 600 ? 45 : 60;
  const frameInterval = 1000 / targetFrameRate;

  function removeOverlay(visual) {
    visual.overlay?.group.remove();
    visual.overlay = null;
  }

  function completeVisual(visual) {
    if (visual.revealed) return;
    clearRevealStyle(visual);
    removeOverlay(visual);
    visual.revealed = true;
  }

  function finish() {
    if (completed && !running && active.size === 0) return;
    window.cancelAnimationFrame(frameRequest);
    frameRequest = 0;
    running = false;
    completed = true;
    for (const visual of visuals) completeVisual(visual);
    glowLayer.replaceChildren();
    active.clear();
    nextIndex = ordered.length;
  }

  function prepareTimeline() {
    const requestedAngle = Number(config.growthAngle);
    const generatedAngle = Number(config.flowAngle);
    const angleDegrees = Number.isFinite(requestedAngle)
      ? requestedAngle
      : Number.isFinite(generatedAngle)
        ? generatedAngle
        : 24;
    const angle = (angleDegrees * Math.PI) / 180;
    const direction = [Math.cos(angle), Math.sin(angle)];
    const across = [-direction[1], direction[0]];
    const span = Math.max(field.width, field.height);
    const phase = random() * Math.PI * 2;
    const entries = visuals.map((visual) => {
      const points = visual.trace.points;
      const first = points[0];
      const last = points.at(-1);
      const midpoint = points[Math.floor(points.length / 2)];
      const potential = (point) => {
        const projection = point[0] * direction[0] + point[1] * direction[1];
        const cross = point[0] * across[0] + point[1] * across[1];
        return projection
          + Math.sin((cross / Math.max(1, span)) * Math.PI * 3.1 + phase) * span * 0.065;
      };
      const reverse = potential(first) > potential(last);
      const orientedPoints = reverse ? [...points].reverse() : points;
      visual.path.setAttribute("d", pathData(orientedPoints));
      visual.sourceVia = reverse ? visual.endVia : visual.startVia;
      visual.destinationVia = reverse ? visual.startVia : visual.endVia;

      const localVariation = (random() - 0.5) * span * 0.045;
      return { visual, key: potential(midpoint) + localVariation };
    });

    entries.sort((first, second) => first.key - second.key || first.visual.index - second.visual.index);
    const minimum = entries[0]?.key || 0;
    const maximum = entries.at(-1)?.key || minimum + 1;
    const duration = clamp(config.growthDuration ?? 7600, 2600, 18000) / speedFactor;
    const startWindow = Math.max(900, duration - 1750);
    const range = Math.max(1, maximum - minimum);
    const baseDurations = entries.map(({ visual }) => (
      clamp(260 + Math.pow(visual.length, 0.88) * 5.6, 420, 3200) / speedFactor
    ));
    const averageDuration = baseDurations.reduce((sum, value) => sum + value, 0)
      / Math.max(1, baseDurations.length);
    const targetAverage = maxActive * startWindow * 0.82 / Math.max(1, entries.length);
    const durationScale = clamp(targetAverage / Math.max(1, averageDuration), 0.05, 1);
    const minimumDuration = clamp(160 * Math.sqrt(360 / Math.max(1, entries.length)), 52, 180);

    for (const [rank, entry] of entries.entries()) {
      const spatial = clamp((entry.key - minimum) / range);
      const orderedRank = entries.length > 1 ? rank / (entries.length - 1) : 0;
      entry.visual.desiredStart = (spatial * 0.78 + orderedRank * 0.22) * startWindow;
      entry.visual.duration = clamp(
        baseDurations[rank] * durationScale,
        minimumDuration,
        3200 / speedFactor,
      );
    }
    return entries.map((entry) => entry.visual);
  }

  function prepareVisuals() {
    for (const visual of visuals) {
      removeOverlay(visual);
      visual.path.setAttribute("stroke-dasharray", `${visual.length} ${visual.length + 1}`);
      visual.path.setAttribute("stroke-dashoffset", String(visual.length));
      visual.path.style.opacity = "0";
      visual.revealed = false;
      for (const via of [visual.startVia, visual.endVia]) {
        if (!via) continue;
        via.style.opacity = "0";
      }
    }
  }

  function createOverlay(visual) {
    const color = brandColor(
      visual.trace.points[Math.floor(visual.trace.points.length / 2)][0],
      field.width,
    );
    const headLength = clamp(visual.length * 0.16, 12 * scale, 42 * scale);
    const pathAttributes = {
      d: visual.path.getAttribute("d"),
      fill: "none",
      stroke: color,
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      "stroke-dasharray": `${headLength} ${visual.length + headLength}`,
      "stroke-dashoffset": headLength,
    };
    const group = svgElement("g", { "data-growth-active": visual.index });
    const halo = svgElement("path", {
      ...pathAttributes,
      "stroke-width": config.traceWidth * scale * 4.2,
      opacity: 0,
    });
    const core = svgElement("path", {
      ...pathAttributes,
      "stroke-width": config.traceWidth * scale * 1.35,
      opacity: 0,
    });
    const source = visual.trace.points[0];
    const destination = visual.trace.points.at(-1);
    const sourcePoint = visual.sourceVia === visual.startVia ? source : destination;
    const destinationPoint = visual.destinationVia === visual.endVia ? destination : source;
    const sourceRing = svgElement("circle", {
      cx: sourcePoint[0], cy: sourcePoint[1], r: config.viaOuterRadius * scale,
      fill: "none", stroke: color, "stroke-width": Math.max(0.8, config.traceWidth * scale), opacity: 0,
    });
    const destinationRing = svgElement("circle", {
      cx: destinationPoint[0], cy: destinationPoint[1], r: config.viaOuterRadius * scale,
      fill: "none", stroke: color, "stroke-width": Math.max(0.8, config.traceWidth * scale), opacity: 0,
    });
    group.append(halo, core, sourceRing, destinationRing);
    glowLayer.append(group);
    return { group, halo, core, sourceRing, destinationRing, headLength };
  }

  function activate(visual, timestamp) {
    visual.startedAt = timestamp;
    visual.overlay = createOverlay(visual);
    active.set(visual.index, visual);
  }

  function drawVisual(visual, timestamp) {
    const progress = clamp((timestamp - visual.startedAt) / visual.duration);
    const routed = smoothstep(progress);
    const drawn = visual.length * routed;
    const energy = Math.sin(Math.PI * progress) * glowStrength;
    const sourceEnergy = 1 - smoothstep(progress / 0.28);
    const destinationEnergy = smoothstep((progress - 0.76) / 0.24);
    const overlay = visual.overlay;

    visual.path.setAttribute("stroke-dashoffset", String(visual.length - drawn));
    visual.path.style.opacity = String(clamp(0.46 + routed * 0.54));
    if (visual.sourceVia) visual.sourceVia.style.opacity = String(smoothstep(progress / 0.12));
    if (visual.destinationVia) visual.destinationVia.style.opacity = String(destinationEnergy);

    const dashOffset = overlay.headLength - drawn;
    overlay.halo.setAttribute("stroke-dashoffset", String(dashOffset));
    overlay.core.setAttribute("stroke-dashoffset", String(dashOffset));
    overlay.halo.setAttribute("opacity", String(energy * 0.16));
    overlay.core.setAttribute("opacity", String(energy * 0.92));
    overlay.sourceRing.setAttribute("r", String(config.viaOuterRadius * scale * (1 + sourceEnergy * 1.9)));
    overlay.sourceRing.setAttribute("opacity", String(sourceEnergy * glowStrength * 0.72));
    overlay.destinationRing.setAttribute("r", String(config.viaOuterRadius * scale * (1 + destinationEnergy * 1.35)));
    overlay.destinationRing.setAttribute("opacity", String((1 - destinationEnergy) * destinationEnergy * 3.2 * glowStrength));

    if (progress < 1) return false;
    completeVisual(visual);
    return true;
  }

  function renderFrame(timestamp) {
    frameRequest = window.requestAnimationFrame(renderFrame);
    const frameElapsed = lastFrameTime ? timestamp - lastFrameTime : frameInterval;
    if (frameElapsed < frameInterval) return;
    lastFrameTime = lastFrameTime
      ? timestamp - (frameElapsed % frameInterval)
      : timestamp;
    frameCount += 1;
    const elapsed = timestamp - timelineStart;

    while (
      nextIndex < ordered.length
      && ordered[nextIndex].desiredStart <= elapsed
      && active.size < maxActive
    ) {
      activate(ordered[nextIndex], timestamp);
      nextIndex += 1;
    }

    for (const [index, visual] of active) {
      if (!drawVisual(visual, timestamp)) continue;
      active.delete(index);
    }

    if (nextIndex >= ordered.length && active.size === 0) finish();
  }

  function start() {
    finish();
    ordered = prepareTimeline();
    nextIndex = 0;
    frameCount = 0;
    lastFrameTime = 0;
    if (!config.motion || reducedMotion || life <= 0.02 || ordered.length === 0) return false;
    prepareVisuals();
    running = true;
    completed = false;
    timelineStart = performance.now();
    frameRequest = window.requestAnimationFrame(renderFrame);
    return true;
  }

  function stop() {
    finish();
  }

  function destroy() {
    finish();
    document.removeEventListener("visibilitychange", onVisibilityChange);
    glowLayer.remove();
  }

  function onVisibilityChange() {
    if (document.hidden && running) finish();
  }

  return {
    start,
    stop,
    trigger: start,
    destroy,
    get stats() {
      return {
        system: "trace-growth",
        animationNodes: glowLayer.childElementCount,
        activeTraces: active.size,
        revealedTraces: nextIndex - active.size,
        traceCount: visuals.length,
        maximumActiveTraces: maxActive,
        targetFrameRate,
        frameCount,
        running,
        completed,
        reducedMotion,
      };
    },
  };

  document.addEventListener("visibilitychange", onVisibilityChange);
}