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

let instanceCount = 0;

export const SCROLL_REVEAL_DEFAULTS = Object.freeze({
  progressSource: "scroll",
  spawnMode: "flow",
  spawnAngle: 90,
  spawnSwirl: 0.075,
  spawnOriginX: 0.5,
  spawnOriginY: 0.5,
  traceWindow: DEFAULT_TRACE_WINDOW,
  unlockLead: 0.025,
  maxActiveTraces: 72,
  traceDuration: 950,
  revealLaunchRate: 24,
  scrollSpeedResponse: 0.6,
  revealGlow: 0.82,
  once: true,
  startOffset: 0,
  endOffset: 0,
  autoStart: true,
});

export const TRACE_SPAWN_PRESETS = Object.freeze({
  downstream: Object.freeze({
    spawnMode: "flow",
    spawnAngle: 90,
    spawnSwirl: 0.075,
    traceWindow: 0.085,
  }),
  diagonal: Object.freeze({
    spawnMode: "flow",
    spawnAngle: 32,
    spawnSwirl: 0.105,
    traceWindow: 0.1,
  }),
  radial: Object.freeze({
    spawnMode: "radial",
    spawnOriginX: 0.5,
    spawnOriginY: 0.46,
    traceWindow: 0.115,
  }),
  scatter: Object.freeze({
    spawnMode: "scatter",
    traceWindow: 0.075,
  }),
});

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 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 hashSeed(value) {
  let hash = 2166136261;
  for (const character of String(value)) {
    hash ^= character.charCodeAt(0);
    hash = Math.imul(hash, 16777619);
  }
  return hash >>> 0;
}

function hashUnit(seed, index, salt = 0) {
  let value = seed ^ Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 1, 0x85ebca6b);
  value = Math.imul(value ^ (value >>> 16), 0x7feb352d);
  value = Math.imul(value ^ (value >>> 15), 0x846ca68b);
  return ((value ^ (value >>> 16)) >>> 0) / 4294967296;
}

function pathData(points) {
  return points.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" ");
}

function polylineLength(points) {
  let length = 0;
  for (let index = 1; index < points.length; index += 1) {
    length += Math.hypot(
      points[index][0] - points[index - 1][0],
      points[index][1] - points[index - 1][1],
    );
  }
  return Math.max(1, length);
}

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 upperBound(values, target) {
  let low = 0;
  let high = values.length;
  while (low < high) {
    const middle = (low + high) >>> 1;
    if (values[middle] <= target) low = middle + 1;
    else high = middle;
  }
  return low;
}

function mediaListener(media, method, listener) {
  if (media[method]) {
    media[method]("change", listener);
    return;
  }
  const legacyMethod = method === "addEventListener" ? "addListener" : "removeListener";
  media[legacyMethod]?.(listener);
}

/**
 * Queues trace growth as scroll progressively unlocks an existing circuit field.
 *
 * The module owns only its reveal layer. It temporarily hides the source trace
 * layer and restores it on destroy, which keeps it independent from generation
 * and ambient animation systems.
 */
export function createScrollTraceReveal(svg, field, config = {}, options = {}) {
  if (!(svg instanceof SVGElement)) throw new TypeError("svg must be an SVG element");
  if (!field || !Array.isArray(field.traces)) {
    throw new TypeError("field must contain a traces array");
  }

  const settings = {
    ...SCROLL_REVEAL_DEFAULTS,
    ...options,
  };
  const sourceLayer = settings.traceLayer || svg.querySelector("[data-static-traces]");
  if (!sourceLayer) throw new Error("A static circuit trace layer is required");

  const rangeElement = settings.rangeElement || svg.parentElement || svg;
  const scrollRoot = settings.scrollRoot || window;
  const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
  const seed = hashSeed(`${config.seed ?? "adom"}:scroll-reveal:${settings.seed ?? ""}`);
  const featureScale = Math.max(0.25, Number(config.featureScale) || 1);
  const traceWidth = Math.max(0.1, Number(config.traceWidth) || 1);
  const viaOuterRadius = Math.max(traceWidth, Number(config.viaOuterRadius) || traceWidth * 2.2);
  const viaInnerRadius = clamp(
    Number(config.viaInnerRadius) || viaOuterRadius * 0.48,
    0,
    viaOuterRadius * 0.92,
  );
  const traceColor = config.traceColor || "currentColor";
  const holeColor = settings.holeColor || "var(--circuit-hole, #0d1117)";
  const configuredUnlockLead = Number(settings.unlockLead);
  const unlockLead = clamp(
    Number.isFinite(configuredUnlockLead) ? configuredUnlockLead : 0.025,
    0,
    0.25,
  );
  const instanceId = `circuit-scroll-reveal-${++instanceCount}`;
  const viaSymbolId = `${instanceId}-via`;
  const layer = svgElement("g", {
    "data-animation": "scroll-trace-reveal",
    "data-spawn-mode": settings.spawnMode,
    "aria-hidden": "true",
  });
  const definitions = svgElement("defs");
  const viaSymbol = svgElement("g", { id: viaSymbolId });
  viaSymbol.append(
    svgElement("circle", {
      cx: 0,
      cy: 0,
      r: viaOuterRadius * featureScale,
      fill: "currentColor",
    }),
    svgElement("circle", {
      cx: 0,
      cy: 0,
      r: viaInnerRadius * featureScale,
      fill: holeColor,
    }),
  );
  definitions.append(viaSymbol);
  layer.append(definitions);

  const sourceDisplay = sourceLayer.style.getPropertyValue("display");
  const sourceDisplayPriority = sourceLayer.style.getPropertyPriority("display");
  sourceLayer.style.setProperty("display", "none");
  sourceLayer.after(layer);

  const fieldWidth = Math.max(1, Number(field.width) || 1);
  const fieldHeight = Math.max(1, Number(field.height) || 1);
  const span = Math.max(fieldWidth, fieldHeight);
  const angle = (Number(settings.spawnAngle) || 0) * Math.PI / 180;
  const direction = [Math.cos(angle), Math.sin(angle)];
  const across = [-direction[1], direction[0]];
  const phase = hashUnit(seed, 0, 11) * Math.PI * 2;
  const origin = [
    clamp(Number(settings.spawnOriginX), 0, 1) * fieldWidth,
    clamp(Number(settings.spawnOriginY), 0, 1) * fieldHeight,
  ];

  function potential(point, index) {
    if (settings.spawnMode === "scatter") return hashUnit(seed, index, 19);
    if (settings.spawnMode === "radial") {
      const x = (point[0] - origin[0]) / fieldWidth;
      const y = (point[1] - origin[1]) / fieldHeight;
      return Math.hypot(x, y) + (hashUnit(seed, index, 23) - 0.5) * 0.025;
    }
    if (settings.spawnMode === "edges") {
      const edgeDistance = Math.min(
        point[0] / fieldWidth,
        (fieldWidth - point[0]) / fieldWidth,
        point[1] / fieldHeight,
        (fieldHeight - point[1]) / fieldHeight,
      );
      return edgeDistance + (hashUnit(seed, index, 29) - 0.5) * 0.018;
    }
    if (settings.spawnMode === "index") return index;

    const projection = point[0] * direction[0] + point[1] * direction[1];
    const cross = point[0] * across[0] + point[1] * across[1];
    const swirl = clamp(Number(settings.spawnSwirl) || 0, 0, 0.35);
    return projection
      + Math.sin(cross / span * Math.PI * 3.2 + phase) * span * swirl
      + Math.sin((projection + cross * 0.42) / span * Math.PI * 1.7 - phase) * span * swirl * 0.34
      + (hashUnit(seed, index, 31) - 0.5) * span * 0.012;
  }

  const entries = field.traces.map((trace, index) => {
    const originalPoints = trace.points || [];
    if (originalPoints.length < 2) return null;
    const first = originalPoints[0];
    const last = originalPoints.at(-1);
    const middle = originalPoints[Math.floor(originalPoints.length / 2)];
    const orientByPotential = !["scatter", "index"].includes(settings.spawnMode);
    const reverse = orientByPotential
      && potential(first, index) > potential(last, index);
    const points = reverse ? [...originalPoints].reverse() : originalPoints;
    const length = Math.max(1, Number(trace.length) || polylineLength(points));
    return {
      index,
      trace,
      points,
      length,
      key: potential(middle, index),
      color: brandColor(middle[0], fieldWidth),
    };
  }).filter(Boolean);

  entries.sort((first, second) => first.key - second.key || first.index - second.index);
  const maximumActiveTraces = Math.round(clamp(Number(settings.maxActiveTraces) || 72, 12, 160));
  const startDenominator = Math.max(1, entries.length - 1);
  const starts = [];
  const visuals = entries.map((entry, rank) => {
    const start = rank / startDenominator;
    starts.push(start);
    const path = svgElement("path", {
      d: pathData(entry.points),
      fill: "none",
      stroke: traceColor,
      "stroke-width": traceWidth * featureScale,
      "stroke-linecap": "butt",
      "stroke-linejoin": "miter",
      "stroke-dasharray": `${entry.length} ${entry.length + 1}`,
      "stroke-dashoffset": entry.length,
      opacity: 0,
      "data-scroll-trace": entry.index,
    });
    const startVia = svgElement("use", {
      href: `#${viaSymbolId}`,
      transform: `translate(${entry.points[0][0]} ${entry.points[0][1]})`,
      opacity: 0,
      "data-scroll-via": "start",
      color: traceColor,
    });
    const endVia = svgElement("use", {
      href: `#${viaSymbolId}`,
      transform: `translate(${entry.points.at(-1)[0]} ${entry.points.at(-1)[1]})`,
      opacity: 0,
      "data-scroll-via": "end",
      color: traceColor,
    });
    layer.append(path, startVia, endVia);
    return {
      ...entry,
      start,
      path,
      startVia,
      endVia,
      state: "hidden",
      localProgress: -1,
      startedAt: 0,
      duration: 0,
    };
  });

  let running = false;
  let destroyed = false;
  let frameRequest = 0;
  let scrollUpdatePending = false;
  let progress = 0;
  let maximumProgress = 0;
  let visualProgress = 0;
  let reportedProgress = -1;
  let unlockedEnd = 0;
  let launchedEnd = 0;
  let completedCount = 0;
  let settledEnd = 0;
  const pendingSettleRanges = [];
  let pendingSettleCount = 0;
  let launchCredit = 1;
  let previousFrameTime = 0;
  let lastVisualUpdateTime = 0;
  let stoppedAt = 0;
  let hiddenAt = 0;
  let lastScrollPosition;
  let lastScrollTime = 0;
  let scrollVelocity = 0;
  const active = new Set();
  let frameCount = 0;
  let resizeObserver;
  const configuredDurationValue = Number(config.revealDuration);
  const configuredDuration = Number.isFinite(configuredDurationValue) && configuredDurationValue > 0
    ? configuredDurationValue
    : 7600;
  const configuredPower = Number(config.revealPower);
  const revealPower = clamp(Number.isFinite(configuredPower) ? configuredPower : 0.72);
  const revealMotion = config.revealMotion !== false;
  const densityDurationFactor = clamp(
    Math.sqrt(600 / Math.max(1, visuals.length)),
    0.55,
    1.15,
  );
  const requestedTraceDuration = Number(settings.traceDuration);
  const baseTraceDuration = Number.isFinite(requestedTraceDuration)
    ? clamp(requestedTraceDuration, 240, 4000)
    : clamp(
      configuredDuration / 8 * (1.08 - revealPower * 0.18) * densityDurationFactor,
      440,
      1450,
    );
  const baseLaunchRate = clamp(Number(settings.revealLaunchRate) || 24, 1, 180);
  const speedResponse = clamp(Number(settings.scrollSpeedResponse) || 0, 0, 1.5);
  const glowStrength = clamp(Number(settings.revealGlow) || 0, 0, 1.5);
  const activeFrameInterval = visuals.length > 1200
    ? 1000 / 30
    : visuals.length > 600
      ? 1000 / 45
      : 0;

  function setHidden(visual) {
    if (visual.state === "hidden") return;
    visual.path.setAttribute("stroke-dashoffset", String(visual.length));
    visual.path.setAttribute("opacity", "0");
    visual.path.style.removeProperty("filter");
    visual.startVia.setAttribute("opacity", "0");
    visual.endVia.setAttribute("opacity", "0");
    visual.state = "hidden";
    visual.localProgress = 0;
    visual.startedAt = 0;
    visual.duration = 0;
  }

  function setComplete(visual) {
    if (visual.state === "complete") return;
    visual.path.setAttribute("stroke-dashoffset", "0");
    visual.path.setAttribute("stroke", traceColor);
    visual.startVia.setAttribute("color", traceColor);
    visual.endVia.setAttribute("color", traceColor);
    visual.path.setAttribute("opacity", "1");
    visual.path.style.removeProperty("filter");
    visual.startVia.setAttribute("opacity", "1");
    visual.endVia.setAttribute("opacity", "1");
    visual.state = "complete";
    visual.localProgress = 1;
  }

  function setPartial(visual, amount) {
    const local = smoothstep(amount);
    if (visual.state === "partial" && Math.abs(local - visual.localProgress) < 0.001) return;
    visual.path.setAttribute("stroke-dashoffset", String(visual.length * (1 - local)));
    visual.path.setAttribute("stroke", visual.color);
    visual.startVia.setAttribute("color", visual.color);
    visual.endVia.setAttribute("color", visual.color);
    visual.path.setAttribute("opacity", String(clamp(0.42 + local * 0.58)));
    visual.path.style.filter = glowStrength > 0.01
      ? "drop-shadow(0 0 " + (1.5 + glowStrength * 4 * featureScale) + "px " + visual.color + ")"
      : "none";
    visual.startVia.setAttribute("opacity", String(smoothstep(local / 0.12)));
    visual.endVia.setAttribute("opacity", String(smoothstep((local - 0.82) / 0.18)));
    visual.state = "partial";
    visual.localProgress = local;
  }

  function advanceSettledEnd() {
    while (settledEnd < visuals.length && visuals[settledEnd].state === "complete") {
      settledEnd += 1;
    }
  }

  function reportVisualProgress(force = false) {
    advanceSettledEnd();
    const frontier = visuals[settledEnd];
    const frontierProgress = frontier?.state === "partial"
      ? clamp(frontier.localProgress)
      : 0;
    const nextVisual = visuals.length > 0
      ? (settledEnd + frontierProgress) / visuals.length
      : 1;
    visualProgress = force && nextVisual < visualProgress
      ? nextVisual
      : Math.max(visualProgress, nextVisual);
    if (force || Math.abs(visualProgress - reportedProgress) >= 0.0001) {
      reportedProgress = visualProgress;
      settings.onProgress?.(visualProgress, api);
    }
  }

  function unlock(nextProgress) {
    const nextUnlockedEnd = upperBound(starts, clamp(nextProgress + unlockLead));
    unlockedEnd = Math.max(unlockedEnd, nextUnlockedEnd);
  }

  function launch(visual, now) {
    const speedFactor = 1 - clamp(scrollVelocity / 2.4) * speedResponse * 0.42;
    const lengthFactor = 0.8 + clamp(visual.length / span, 0, 0.55);
    const jitter = 0.88 + hashUnit(seed, visual.index, 41) * 0.24;
    visual.startedAt = now;
    visual.duration = clamp(
      baseTraceDuration * lengthFactor * jitter * speedFactor,
      360,
      1750,
    );
    visual.localProgress = -1;
    setPartial(visual, 0);
    active.add(visual);
    launchedEnd += 1;
  }

  function updateActive(now) {
    for (const visual of active) {
      const amount = clamp((now - visual.startedAt) / visual.duration);
      if (amount >= 1) {
        setComplete(visual);
        active.delete(visual);
        completedCount += 1;
      } else {
        setPartial(visual, amount);
      }
    }
  }

  function queueExcessBacklog() {
    const retainedBacklog = maximumActiveTraces * 2;
    const excess = Math.max(0, unlockedEnd - launchedEnd - retainedBacklog);
    if (!excess) return;
    const start = launchedEnd;
    const end = start + excess;
    const previousRange = pendingSettleRanges.at(-1);
    if (previousRange?.end === start) previousRange.end = end;
    else pendingSettleRanges.push({ next: start, end });
    pendingSettleCount += excess;
    launchedEnd = end;
  }

  function materializePendingSettles(limit = 120) {
    let remaining = limit;
    while (remaining > 0 && pendingSettleRanges.length > 0) {
      const range = pendingSettleRanges[0];
      const visual = visuals[range.next];
      setComplete(visual);
      completedCount += 1;
      pendingSettleCount -= 1;
      range.next += 1;
      remaining -= 1;
      if (range.next >= range.end) pendingSettleRanges.shift();
    }
  }

  function launchQueued(now, elapsed) {
    queueExcessBacklog();
    materializePendingSettles(120);
    const speed = clamp(scrollVelocity / 2.4);
    const backlog = clamp(
      (unlockedEnd - launchedEnd) / Math.max(1, maximumActiveTraces * 2),
    );
    const launchRate = Math.min(240,
      baseLaunchRate * (1 + speed * speedResponse * 2.1 + backlog * 0.9),
    );
    launchCredit = Math.min(12, launchCredit + elapsed * launchRate / 1000);
    while (
      launchCredit >= 1
      && launchedEnd < unlockedEnd
      && active.size < maximumActiveTraces
    ) {
      launch(visuals[launchedEnd], now);
      launchCredit -= 1;
    }
    if (launchedEnd >= unlockedEnd) launchCredit = Math.min(1, launchCredit);
  }


  function scrollMetrics() {
    const rootIsWindow = scrollRoot === window;
    const rootRect = rootIsWindow
      ? { top: 0, height: window.innerHeight }
      : scrollRoot.getBoundingClientRect();
    const elementRect = rangeElement.getBoundingClientRect();
    const scrollPosition = rootIsWindow ? window.scrollY : scrollRoot.scrollTop;
    const viewportHeight = rootIsWindow ? window.innerHeight : scrollRoot.clientHeight;
    const elementTop = elementRect.top - rootRect.top + scrollPosition;
    const elementHeight = elementRect.height;
    const start = Number.isFinite(settings.scrollStart)
      ? settings.scrollStart
      : elementTop + Number(settings.startOffset || 0);
    let end = Number.isFinite(settings.scrollEnd)
      ? settings.scrollEnd
      : elementTop + elementHeight - viewportHeight + Number(settings.endOffset || 0);
    if (end <= start) end = start + Math.max(elementHeight, viewportHeight, 1);
    return {
      start,
      end,
      scrollPosition,
      progress: clamp((scrollPosition - start) / (end - start)),
    };
  }

  function complete() {
    if (destroyed) return api;
    if (
      settledEnd === visuals.length
      && completedCount === visuals.length
      && visualProgress === 1
    ) return api;
    active.clear();
    pendingSettleRanges.length = 0;
    pendingSettleCount = 0;
    unlockedEnd = visuals.length;
    launchedEnd = visuals.length;
    completedCount = visuals.length;
    settledEnd = visuals.length;
    for (const visual of visuals) setComplete(visual);
    maximumProgress = 1;
    progress = 1;
    visualProgress = 1;
    reportVisualProgress(true);
    return api;
  }

  function updateScrollTarget(now = performance.now()) {
    if (destroyed || settings.progressSource === "manual") return;
    const metrics = scrollMetrics();
    if (lastScrollPosition !== undefined && lastScrollTime) {
      const elapsed = Math.max(1, now - lastScrollTime);
      const forwardDelta = Math.max(0, metrics.scrollPosition - lastScrollPosition);
      const instantVelocity = forwardDelta / elapsed;
      scrollVelocity = scrollVelocity * 0.58 + instantVelocity * 0.42;
    }
    lastScrollPosition = metrics.scrollPosition;
    lastScrollTime = now;
    const next = metrics.progress;
    progress = settings.once ? Math.max(maximumProgress, next) : next;
    maximumProgress = Math.max(maximumProgress, next);
    if (!revealMotion || motionPreference.matches) {
      complete();
      return;
    }
    unlock(maximumProgress);
  }

  function renderFrame(now) {
    frameRequest = 0;
    if (destroyed || !running) return;

    if (scrollUpdatePending && settings.progressSource !== "manual") {
      scrollUpdatePending = false;
      updateScrollTarget(now);
    }

    const frameElapsed = previousFrameTime ? clamp(now - previousFrameTime, 0, 64) : 16;
    previousFrameTime = now;
    scrollVelocity *= Math.pow(0.91, frameElapsed / 16.67);

    const visualFrameDue = !activeFrameInterval
      || !lastVisualUpdateTime
      || now - lastVisualUpdateTime >= activeFrameInterval;
    if (visualFrameDue) {
      const visualElapsed = lastVisualUpdateTime
        ? clamp(now - lastVisualUpdateTime, 0, 100)
        : 16;
      lastVisualUpdateTime = now;
      updateActive(now);
      launchQueued(now, visualElapsed);
      reportVisualProgress();
      frameCount += 1;
    }

    if (
      scrollUpdatePending
      || active.size > 0
      || launchedEnd < unlockedEnd
      || pendingSettleCount > 0
    ) {
      scheduleFrame();
    } else {
      previousFrameTime = 0;
      lastVisualUpdateTime = 0;
    }
  }

  function scheduleFrame() {
    if (frameRequest || destroyed || !running) return;
    frameRequest = window.requestAnimationFrame(renderFrame);
  }

  function requestUpdate() {
    if (destroyed || !running) return;
    scrollUpdatePending = true;
    scheduleFrame();
  }

  function setProgress(nextProgress, { remember = settings.once } = {}) {
    if (destroyed) return api;
    const next = clamp(Number(nextProgress) || 0);
    progress = remember ? Math.max(maximumProgress, next) : next;
    maximumProgress = Math.max(maximumProgress, progress);
    if (!revealMotion || motionPreference.matches) {
      complete();
    } else {
      unlock(maximumProgress);
      scheduleFrame();
    }
    return api;
  }

  function refresh() {
    if (destroyed) return api;
    if (settings.progressSource === "manual") {
      if (!revealMotion || motionPreference.matches) complete();
      else {
        unlock(maximumProgress);
        scheduleFrame();
      }
    } else {
      updateScrollTarget();
      scheduleFrame();
    }
    return api;
  }

  function reset() {
    if (destroyed) return api;
    active.clear();
    pendingSettleRanges.length = 0;
    pendingSettleCount = 0;
    for (const visual of visuals) setHidden(visual);
    maximumProgress = 0;
    progress = 0;
    visualProgress = 0;
    reportedProgress = -1;
    unlockedEnd = 0;
    launchedEnd = 0;
    completedCount = 0;
    settledEnd = 0;
    launchCredit = 1;
    previousFrameTime = 0;
    lastVisualUpdateTime = 0;
    scrollVelocity = 0;
    lastScrollPosition = undefined;
    lastScrollTime = 0;
    if (!revealMotion || motionPreference.matches) return complete();
    reportVisualProgress(true);
    return api;
  }

  function onMotionPreferenceChange() {
    if (!revealMotion || motionPreference.matches) complete();
    else refresh();
  }

  function onVisibilityChange() {
    const now = performance.now();
    if (document.hidden) {
      window.cancelAnimationFrame(frameRequest);
      frameRequest = 0;
      hiddenAt = now;
      return;
    }
    if (!hiddenAt) return;
    const pausedFor = now - hiddenAt;
    for (const visual of active) visual.startedAt += pausedFor;
    hiddenAt = 0;
    previousFrameTime = 0;
    lastVisualUpdateTime = 0;
    scheduleFrame();
  }

  function start() {
    if (destroyed || running) return api;
    if (stoppedAt) {
      const pausedFor = performance.now() - stoppedAt;
      for (const visual of active) visual.startedAt += pausedFor;
      stoppedAt = 0;
    }
    running = true;
    hiddenAt = document.hidden ? performance.now() : 0;
    document.addEventListener("visibilitychange", onVisibilityChange);
    if (settings.progressSource !== "manual") {
      scrollRoot.addEventListener("scroll", requestUpdate, { passive: true });
      window.addEventListener("resize", requestUpdate, { passive: true });
      resizeObserver = new ResizeObserver(requestUpdate);
      resizeObserver.observe(rangeElement);
    }
    mediaListener(motionPreference, "addEventListener", onMotionPreferenceChange);
    refresh();
    return api;
  }

  function stop() {
    if (!running) return api;
    running = false;
    const stoppedNow = performance.now();
    if (hiddenAt) {
      const hiddenFor = stoppedNow - hiddenAt;
      for (const visual of active) visual.startedAt += hiddenFor;
      hiddenAt = 0;
    }
    stoppedAt = stoppedNow;
    window.cancelAnimationFrame(frameRequest);
    frameRequest = 0;
    scrollRoot.removeEventListener("scroll", requestUpdate);
    window.removeEventListener("resize", requestUpdate);
    resizeObserver?.disconnect();
    resizeObserver = undefined;
    mediaListener(motionPreference, "removeEventListener", onMotionPreferenceChange);
    document.removeEventListener("visibilitychange", onVisibilityChange);
    scrollUpdatePending = false;
    previousFrameTime = 0;
    lastVisualUpdateTime = 0;
    return api;
  }

  function destroy() {
    if (destroyed) return;
    stop();
    destroyed = true;
    active.clear();
    pendingSettleRanges.length = 0;
    pendingSettleCount = 0;
    layer.remove();
    if (sourceDisplay) {
      sourceLayer.style.setProperty("display", sourceDisplay, sourceDisplayPriority);
    } else {
      sourceLayer.style.removeProperty("display");
    }
  }

  const api = {
    start,
    stop,
    refresh,
    reset,
    complete,
    setProgress,
    destroy,
    get progress() {
      return progress;
    },
    get visualProgress() {
      return visualProgress;
    },
    get layer() {
      return layer;
    },
    get stats() {
      return {
        system: "scroll-trace-reveal",
        spawnMode: settings.spawnMode,
        progress,
        targetProgress: maximumProgress,
        visualProgress,
        traceCount: visuals.length,
        maximumActiveTraces,
        unlockLead,
        unlockedTraces: unlockedEnd,
        queuedTraces: Math.max(0, unlockedEnd - launchedEnd),
        catchUpTraces: pendingSettleCount,
        hiddenTraces: Math.max(0, visuals.length - completedCount - active.size),
        activeTraces: active.size,
        completedTraces: completedCount,
        settledTraces: settledEnd,
        scrollVelocity,
        animationNodes: layer.childElementCount,
        frameCount,
        running,
        revealMotion,
        reducedMotion: motionPreference.matches,
      };
    },
  };

  if (!revealMotion || motionPreference.matches) complete();
  else reportVisualProgress(true);
  if (settings.autoStart) start();
  return api;
}