master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
const TAU = Math.PI * 2;
const CANVAS_PIXEL_BUDGET = 1_500_000;
const CONTINUOUS_CANVAS_MAX_DIMENSION = 8192;

export const REFLECTION_DEFAULTS = Object.freeze({
  reflectionStrength: 0.58,
  reflectionCycle: 14800,
  reflectionWidth: 0.3,
  reflectionAngle: 18,
  reflectionDrift: 6,
  reflectionRest: 0.12,
  reflectionQuiet: 0.84,
  reflectionPointer: true,
  reflectionColors: Object.freeze(["#00e6dc", "#49a9ff", "#a58cff"]),
});

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 colorWithAlpha(color, alpha) {
  const amount = clamp(alpha);
  const hexadecimal = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(color).trim());
  if (hexadecimal) {
    const value = hexadecimal[1].length === 3
      ? hexadecimal[1].split("").map((character) => `${character}${character}`).join("")
      : hexadecimal[1];
    const red = Number.parseInt(value.slice(0, 2), 16);
    const green = Number.parseInt(value.slice(2, 4), 16);
    const blue = Number.parseInt(value.slice(4, 6), 16);
    return `rgba(${red}, ${green}, ${blue}, ${amount})`;
  }

  const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(String(color));
  if (rgb) return `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${amount})`;
  return amount < 0.001 ? "rgba(0, 0, 0, 0)" : color;
}

function paletteFor(settings) {
  const colors = Array.isArray(settings.reflectionColors)
    ? settings.reflectionColors.filter(Boolean)
    : [];
  return [
    colors[0] || REFLECTION_DEFAULTS.reflectionColors[0],
    colors[1] || colors[0] || REFLECTION_DEFAULTS.reflectionColors[1],
    colors[2] || colors[1] || colors[0] || REFLECTION_DEFAULTS.reflectionColors[2],
  ];
}

function tracePath(context, points) {
  if (!points?.length) return false;
  context.beginPath();
  context.moveTo(points[0][0], points[0][1]);
  for (let index = 1; index < points.length; index += 1) {
    context.lineTo(points[index][0], points[index][1]);
  }
  context.stroke();
  return true;
}

function projectionRange(width, height, xAxis, yAxis) {
  const projections = [
    0,
    width * xAxis,
    height * yAxis,
    width * xAxis + height * yAxis,
  ];
  return {
    minimum: Math.min(...projections),
    maximum: Math.max(...projections),
  };
}

export function createReflectionAnimation(
  mount,
  field,
  config = {},
  random = Math.random,
  { readingZone = null } = {},
) {
  if (!mount?.appendChild) throw new TypeError("mount must be a DOM element");
  if (!field?.traces || !Number.isFinite(field.width) || !Number.isFinite(field.height)) {
    throw new TypeError("field must contain width, height, and traces");
  }

  let settings = { ...REFLECTION_DEFAULTS, ...config };
  const randomValue = typeof random === "function" ? random : Math.random;
  const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reducedMotion = motionPreference.matches;
  let destroyed = false;
  let requested = false;
  let running = false;
  let frameRequest;
  let frameCount = 0;
  let lastPaintTime = 0;
  let targetFrameRate = 45;
  let frameInterval = 1000 / targetFrameRate;
  let pixelRatio = 1;
  let pointer = null;
  let sweepVisible = false;
  const pulses = [];
  const phaseOffset = randomValue() * Math.max(1000, settings.reflectionCycle);

  const canvas = document.createElement("canvas");
  canvas.setAttribute("aria-hidden", "true");
  canvas.setAttribute("data-animation", "adom-reflection-canvas");
  Object.assign(canvas.style, {
    position: "absolute",
    inset: "0",
    width: "100%",
    height: "100%",
    display: "block",
    pointerEvents: "none",
    zIndex: "1",
    contain: "strict",
    mixBlendMode: config.reflectionBlendMode || "screen",
  });
  mount.appendChild(canvas);

  const context = canvas.getContext("2d", { alpha: true, desynchronized: true })
    || canvas.getContext("2d");
  const maskCanvas = document.createElement("canvas");
  let maskContext = maskCanvas.getContext("2d", { alpha: true });

  function life() {
    return clamp(Number(settings.liveliness) || 0, 0, 1.5);
  }

  function featureScale() {
    return Math.max(0.25, Number(settings.featureScale) || 1);
  }

  function shouldRun() {
    return !destroyed
      && !reducedMotion
      && settings.motion !== false
      && life() > 0.02
      && Boolean(context);
  }

  function readingEllipse() {
    const mountRect = mount.getBoundingClientRect();
    const zoneRect = readingZone?.getBoundingClientRect?.();
    if (
      zoneRect
      && mountRect.width > 0
      && mountRect.height > 0
      && zoneRect.width > 0
      && zoneRect.height > 0
    ) {
      const scaleX = field.width / mountRect.width;
      const scaleY = field.height / mountRect.height;
      const paddingX = Math.max(52, zoneRect.width * 0.14);
      const paddingY = Math.max(38, zoneRect.height * 0.17);
      return {
        x: (zoneRect.left + zoneRect.width * 0.5 - mountRect.left) * scaleX,
        y: (zoneRect.top + zoneRect.height * 0.5 - mountRect.top) * scaleY,
        radiusX: (zoneRect.width * 0.5 + paddingX) * scaleX,
        radiusY: (zoneRect.height * 0.5 + paddingY) * scaleY,
      };
    }

    return {
      x: field.width * 0.5,
      y: field.height * 0.48,
      radiusX: field.width * 0.34,
      radiusY: field.height * 0.28,
    };
  }

  function removeViaHoles() {
    const innerRadius = Math.max(0, Number(settings.viaInnerRadius) || 0) * featureScale();
    if (innerRadius <= 0) return;
    maskContext.globalCompositeOperation = "destination-out";
    for (const trace of field.traces) {
      const endpoints = [];
      if (trace.viaStart && trace.points?.length) endpoints.push(trace.points[0]);
      if (trace.viaEnd && trace.points?.length) endpoints.push(trace.points.at(-1));
      for (const point of endpoints) {
        maskContext.beginPath();
        maskContext.arc(point[0], point[1], innerRadius, 0, TAU);
        maskContext.fill();
      }
    }
  }

  function softenReadingZone() {
    const strength = clamp(Number(settings.reflectionQuiet), 0, 0.98);
    if (strength <= 0) return;
    const quiet = readingEllipse();
    const layers = 10;
    const layerAlpha = 1 - ((1 - strength) ** (1 / layers));
    maskContext.globalCompositeOperation = "destination-out";
    maskContext.fillStyle = "#000";
    maskContext.globalAlpha = layerAlpha;
    for (let index = 0; index < layers; index += 1) {
      const amount = index / (layers - 1);
      const size = 1 - amount * 0.58;
      maskContext.beginPath();
      maskContext.ellipse(
        quiet.x,
        quiet.y,
        Math.max(1, quiet.radiusX * size),
        Math.max(1, quiet.radiusY * size),
        0,
        0,
        TAU,
      );
      maskContext.fill();
    }
    maskContext.globalAlpha = 1;
  }

  function buildMask() {
    if (!maskContext || !maskCanvas.width || !maskCanvas.height) return;
    maskContext.setTransform(1, 0, 0, 1, 0, 0);
    maskContext.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
    maskContext.setTransform(
      maskCanvas.width / field.width,
      0,
      0,
      maskCanvas.height / field.height,
      0,
      0,
    );
    maskContext.globalCompositeOperation = "source-over";
    maskContext.globalAlpha = 1;
    maskContext.strokeStyle = "#fff";
    maskContext.fillStyle = "#fff";
    maskContext.lineWidth = Math.max(0.2, Number(settings.traceWidth) || 1.58) * featureScale();
    maskContext.lineCap = "butt";
    maskContext.lineJoin = "miter";

    for (const trace of field.traces) tracePath(maskContext, trace.points);

    const outerRadius = Math.max(0, Number(settings.viaOuterRadius) || 0) * featureScale();
    if (outerRadius > 0) {
      for (const trace of field.traces) {
        const endpoints = [];
        if (trace.viaStart && trace.points?.length) endpoints.push(trace.points[0]);
        if (trace.viaEnd && trace.points?.length) endpoints.push(trace.points.at(-1));
        for (const point of endpoints) {
          maskContext.beginPath();
          maskContext.arc(point[0], point[1], outerRadius, 0, TAU);
          maskContext.fill();
        }
      }
    }

    removeViaHoles();
    softenReadingZone();
    maskContext.setTransform(1, 0, 0, 1, 0, 0);
    maskContext.globalCompositeOperation = "source-over";
    maskContext.globalAlpha = 1;
  }

  function sizeCanvases() {
    const width = Math.max(1, mount.clientWidth);
    const height = Math.max(1, mount.clientHeight);
    const pixelBudgetRatio = Math.sqrt(CANVAS_PIXEL_BUDGET / Math.max(1, width * height));
    const legacyPixelRatio = clamp(
      Math.min(window.devicePixelRatio || 1, 1.5, pixelBudgetRatio),
      0.45,
      1.5,
    );
    const continuousGeneration = settings.continuousGeneration === true;
    pixelRatio = continuousGeneration
      ? Math.min(
        window.devicePixelRatio || 1,
        1.5,
        pixelBudgetRatio,
        CONTINUOUS_CANVAS_MAX_DIMENSION / width,
        CONTINUOUS_CANVAS_MAX_DIMENSION / height,
      )
      : legacyPixelRatio;
    const roundDimension = continuousGeneration ? Math.floor : Math.round;
    const nextWidth = Math.max(1, roundDimension(width * pixelRatio));
    const nextHeight = Math.max(1, roundDimension(height * pixelRatio));
    if (canvas.width !== nextWidth) canvas.width = nextWidth;
    if (canvas.height !== nextHeight) canvas.height = nextHeight;
    if (maskCanvas.width !== nextWidth) maskCanvas.width = nextWidth;
    if (maskCanvas.height !== nextHeight) maskCanvas.height = nextHeight;
    if (context) {
      context.setTransform(1, 0, 0, 1, 0, 0);
      context.clearRect(0, 0, canvas.width, canvas.height);
    }
    const frameRatePixelCount = Math.max(1, Math.round(width * legacyPixelRatio))
      * Math.max(1, Math.round(height * legacyPixelRatio));
    targetFrameRate = frameRatePixelCount > 1_100_000 ? 30 : 45;
    frameInterval = 1000 / targetFrameRate;
    buildMask();
  }

  function reflectionStrength() {
    const configured = clamp(Number(settings.reflectionStrength), 0, 1.4);
    return configured * clamp(0.56 + life() * 0.34, 0.32, 1);
  }

  function drawSweep(timestamp) {
    const cycle = Math.max(4800, Number(settings.reflectionCycle) || REFLECTION_DEFAULTS.reflectionCycle)
      / clamp(0.84 + life() * 0.12, 0.72, 1.1);
    const rest = clamp(Number(settings.reflectionRest), 0, 0.42);
    const travelPortion = 1 - rest;
    const phase = ((timestamp + phaseOffset) % cycle) / cycle;
    if (phase >= travelPortion) {
      sweepVisible = false;
      return;
    }

    sweepVisible = true;
    const progress = phase / travelPortion;
    const easedProgress = clamp(progress + Math.sin(progress * TAU) * 0.038);
    const drift = (Number(settings.reflectionDrift) || 0)
      * Math.sin(timestamp / 23000 * TAU + phaseOffset / cycle * TAU);
    const angle = ((Number(settings.reflectionAngle) || 0) + drift) * Math.PI / 180;
    const xAxis = Math.cos(angle);
    const yAxis = Math.sin(angle);
    const range = projectionRange(field.width, field.height, xAxis, yAxis);
    const diagonal = Math.hypot(field.width, field.height);
    const width = diagonal * clamp(Number(settings.reflectionWidth), 0.08, 0.72);
    const center = range.minimum - width
      + easedProgress * (range.maximum - range.minimum + width * 2);
    const palette = paletteFor(settings);
    const strength = reflectionStrength();

    const start = center - width * 0.5;
    const end = center + width * 0.5;
    const sheen = context.createLinearGradient(
      xAxis * start,
      yAxis * start,
      xAxis * end,
      yAxis * end,
    );
    sheen.addColorStop(0, colorWithAlpha(palette[0], 0));
    sheen.addColorStop(0.14, colorWithAlpha(palette[0], 0.12));
    sheen.addColorStop(0.31, colorWithAlpha(palette[0], 0.82));
    sheen.addColorStop(0.5, colorWithAlpha(palette[1], 1));
    sheen.addColorStop(0.7, colorWithAlpha(palette[2], 0.76));
    sheen.addColorStop(0.88, colorWithAlpha(palette[2], 0.1));
    sheen.addColorStop(1, colorWithAlpha(palette[2], 0));
    context.globalAlpha = strength;
    context.fillStyle = sheen;
    context.fillRect(0, 0, field.width, field.height);

    const edgeWidth = width * 0.1;
    const edgeCenter = center - width * 0.035;
    const edge = context.createLinearGradient(
      xAxis * (edgeCenter - edgeWidth),
      yAxis * (edgeCenter - edgeWidth),
      xAxis * (edgeCenter + edgeWidth),
      yAxis * (edgeCenter + edgeWidth),
    );
    edge.addColorStop(0, colorWithAlpha(palette[0], 0));
    edge.addColorStop(0.44, colorWithAlpha(palette[1], 0.28));
    edge.addColorStop(0.5, colorWithAlpha("#c9ffff", 0.88));
    edge.addColorStop(0.57, colorWithAlpha(palette[2], 0.24));
    edge.addColorStop(1, colorWithAlpha(palette[2], 0));
    context.globalAlpha = strength * 0.24;
    context.fillStyle = edge;
    context.fillRect(0, 0, field.width, field.height);
  }

  function drawPointerReflection(timestamp) {
    if (!settings.reflectionPointer || !pointer) return;
    const age = timestamp - pointer.at;
    if (age < 0 || age > 1800) return;
    const fade = 1 - smoothstep(age / 1800);
    const radius = Math.hypot(field.width, field.height) * 0.085;
    const palette = paletteFor(settings);
    const glow = context.createRadialGradient(
      pointer.x,
      pointer.y,
      0,
      pointer.x,
      pointer.y,
      radius,
    );
    glow.addColorStop(0, colorWithAlpha(palette[0], 0.7));
    glow.addColorStop(0.42, colorWithAlpha(palette[1], 0.32));
    glow.addColorStop(1, colorWithAlpha(palette[2], 0));
    context.globalAlpha = reflectionStrength() * fade * 0.2;
    context.fillStyle = glow;
    context.fillRect(
      pointer.x - radius,
      pointer.y - radius,
      radius * 2,
      radius * 2,
    );
  }

  function drawPulses(timestamp) {
    const diagonal = Math.hypot(field.width, field.height);
    const palette = paletteFor(settings);
    for (let index = pulses.length - 1; index >= 0; index -= 1) {
      const pulse = pulses[index];
      const progress = (timestamp - pulse.startedAt) / pulse.duration;
      if (progress >= 1) {
        pulses.splice(index, 1);
        continue;
      }
      const eased = smoothstep(progress);
      const radius = diagonal * (0.025 + eased * 0.22);
      const ring = context.createRadialGradient(
        pulse.x,
        pulse.y,
        0,
        pulse.x,
        pulse.y,
        radius,
      );
      ring.addColorStop(0, colorWithAlpha(pulse.color, 0));
      ring.addColorStop(0.58, colorWithAlpha(pulse.color, 0));
      ring.addColorStop(0.78, colorWithAlpha(pulse.color, 0.64));
      ring.addColorStop(0.9, colorWithAlpha(palette[1], 0.34));
      ring.addColorStop(1, colorWithAlpha(palette[2], 0));
      context.globalAlpha = reflectionStrength() * (1 - smoothstep(progress)) * 0.5;
      context.fillStyle = ring;
      context.fillRect(
        pulse.x - radius,
        pulse.y - radius,
        radius * 2,
        radius * 2,
      );
    }
  }

  function paint(timestamp) {
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.setTransform(
      canvas.width / field.width,
      0,
      0,
      canvas.height / field.height,
      0,
      0,
    );
    context.globalCompositeOperation = "source-over";
    drawSweep(timestamp);
    drawPointerReflection(timestamp);
    drawPulses(timestamp);

    context.setTransform(1, 0, 0, 1, 0, 0);
    context.globalAlpha = 1;
    context.globalCompositeOperation = "destination-in";
    context.drawImage(maskCanvas, 0, 0);
    context.globalCompositeOperation = "source-over";
  }

  function renderFrame(timestamp) {
    if (!running || destroyed || !canvas.isConnected) return;
    frameRequest = window.requestAnimationFrame(renderFrame);
    const elapsed = lastPaintTime ? timestamp - lastPaintTime : frameInterval;
    if (elapsed < frameInterval) return;
    lastPaintTime = lastPaintTime ? timestamp - (elapsed % frameInterval) : timestamp;
    frameCount += 1;
    paint(timestamp);
  }

  function halt(clear = true) {
    running = false;
    window.cancelAnimationFrame(frameRequest);
    frameRequest = undefined;
    lastPaintTime = 0;
    if (clear && context) {
      context.setTransform(1, 0, 0, 1, 0, 0);
      context.clearRect(0, 0, canvas.width, canvas.height);
    }
  }

  function begin() {
    if (running || !requested || !shouldRun() || document.hidden) return;
    running = true;
    lastPaintTime = 0;
    frameRequest = window.requestAnimationFrame(renderFrame);
  }

  function start() {
    requested = true;
    begin();
  }

  function stop() {
    requested = false;
    halt();
  }

  function pointerMoved(point) {
    pointer = point ? { ...point, at: performance.now() } : null;
  }

  function pointerLeft() {
    if (pointer) pointer.at = performance.now() - 1800;
  }

  function trigger(origin = null) {
    if (!running || reducedMotion || settings.motion === false) return false;
    const point = origin || {
      x: field.width * (0.2 + randomValue() * 0.6),
      y: field.height * (0.22 + randomValue() * 0.56),
    };
    const palette = paletteFor(settings);
    const colorIndex = clamp(Math.floor(point.x / Math.max(1, field.width) * 3), 0, 2);
    if (pulses.length >= 2) pulses.shift();
    pulses.push({
      x: clamp(point.x, 0, field.width),
      y: clamp(point.y, 0, field.height),
      color: palette[colorIndex],
      startedAt: performance.now(),
      duration: 1180,
    });
    return true;
  }

  function update(nextConfig = {}) {
    if (destroyed) return api;
    settings = { ...settings, ...nextConfig };
    canvas.style.mixBlendMode = settings.reflectionBlendMode || "screen";
    sizeCanvases();
    if (!shouldRun()) halt();
    else if (requested) begin();
    return api;
  }

  function resize() {
    if (!destroyed) sizeCanvases();
    return api;
  }

  function onPreferenceChange(event) {
    reducedMotion = event.matches;
    if (reducedMotion) halt();
    else if (requested) begin();
  }

  function onVisibilityChange() {
    if (document.hidden) halt(false);
    else if (requested) begin();
  }

  function destroy() {
    if (destroyed) return;
    destroyed = true;
    stop();
    pulses.length = 0;
    pointer = null;
    document.removeEventListener("visibilitychange", onVisibilityChange);
    if (motionPreference.removeEventListener) {
      motionPreference.removeEventListener("change", onPreferenceChange);
    } else {
      motionPreference.removeListener?.(onPreferenceChange);
    }
    canvas.remove();
    canvas.width = 0;
    canvas.height = 0;
    maskCanvas.width = 0;
    maskCanvas.height = 0;
    maskContext = null;
  }

  const api = {
    start,
    stop,
    destroy,
    update,
    resize,
    pointerMoved,
    pointerLeft,
    clicked: trigger,
    trigger,
    get stats() {
      return {
        system: "adom-reflection",
        renderer: "canvas-precomposed-trace-mask",
        animationNodes: canvas.isConnected ? 1 : 0,
        activeReflections: Number(sweepVisible) + pulses.length,
        activePulses: pulses.length,
        frameCount,
        targetFrameRate,
        rendererPixelRatio: pixelRatio,
        maskTraceCount: field.traces.length,
        perFrameTraceBudget: 0,
        quietZoneProtected: true,
        readingZoneTracked: Boolean(readingZone),
        reducedMotion,
        running,
      };
    },
  };

  sizeCanvases();
  document.addEventListener("visibilitychange", onVisibilityChange);
  if (motionPreference.addEventListener) {
    motionPreference.addEventListener("change", onPreferenceChange);
  } else {
    motionPreference.addListener?.(onPreferenceChange);
  }
  return api;
}