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

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 cumulativeLengths(points) {
  const cumulative = [0];
  for (let index = 1; index < points.length; index += 1) {
    cumulative.push(cumulative[index - 1] + Math.hypot(
      points[index][0] - points[index - 1][0],
      points[index][1] - points[index - 1][1],
    ));
  }
  return cumulative;
}

function orientedRoute(points) {
  const cumulative = cumulativeLengths(points);
  return { points, cumulative, length: cumulative.at(-1) || 0 };
}

function pointAlong(route, distance) {
  const target = clamp(distance, 0, route.length);
  let upper = 1;
  while (upper < route.cumulative.length && route.cumulative[upper] < target) upper += 1;
  if (upper >= route.points.length) return route.points.at(-1);
  const lower = upper - 1;
  const span = Math.max(0.0001, route.cumulative[upper] - route.cumulative[lower]);
  const progress = (target - route.cumulative[lower]) / span;
  return [
    route.points[lower][0] + (route.points[upper][0] - route.points[lower][0]) * progress,
    route.points[lower][1] + (route.points[upper][1] - route.points[lower][1]) * progress,
  ];
}

function strokeInterval(context, route, startDistance, endDistance) {
  const start = clamp(startDistance, 0, route.length);
  const end = clamp(endDistance, 0, route.length);
  if (end - start < 0.05) return false;

  context.beginPath();
  const first = pointAlong(route, start);
  context.moveTo(first[0], first[1]);
  for (let index = 1; index < route.points.length - 1; index += 1) {
    const distance = route.cumulative[index];
    if (distance > start && distance < end) {
      context.lineTo(route.points[index][0], route.points[index][1]);
    }
  }
  const last = pointAlong(route, end);
  context.lineTo(last[0], last[1]);
  context.stroke();
  return true;
}

function segmentTouchesRect(start, end, rect) {
  return Math.max(start[0], end[0]) >= rect.left
    && Math.min(start[0], end[0]) <= rect.right
    && Math.max(start[1], end[1]) >= rect.top
    && Math.min(start[1], end[1]) <= rect.bottom;
}

function routeTouchesRect(points, rect) {
  for (let index = 1; index < points.length; index += 1) {
    if (segmentTouchesRect(points[index - 1], points[index], rect)) return true;
  }
  return false;
}

function brandColor(x, width) {
  const position = x / Math.max(1, width);
  if (position < 0.42) return "#00e6dc";
  if (position < 0.74) return "#49a9ff";
  return "#a58cff";
}

export function createHandshakeAnimation(mount, field, config, random, { readingZone = null } = {}) {
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const pixelWidth = Math.max(1, mount.clientWidth);
  const pixelHeight = Math.max(1, mount.clientHeight);
  const pixelBudgetRatio = Math.sqrt(CANVAS_PIXEL_BUDGET / Math.max(1, pixelWidth * pixelHeight));
  const legacyPixelRatio = Math.max(0.45, Math.min(window.devicePixelRatio || 1, 1.5, pixelBudgetRatio));
  const continuousGeneration = config.continuousGeneration === true;
  const pixelRatio = continuousGeneration
    ? Math.min(
      window.devicePixelRatio || 1,
      1.5,
      pixelBudgetRatio,
      CONTINUOUS_CANVAS_MAX_DIMENSION / pixelWidth,
      CONTINUOUS_CANVAS_MAX_DIMENSION / pixelHeight,
    )
    : legacyPixelRatio;
  const roundDimension = continuousGeneration ? Math.floor : Math.round;
  const canvas = document.createElement("canvas");
  canvas.setAttribute("aria-hidden", "true");
  canvas.setAttribute("data-animation", "via-handshake-canvas");
  Object.assign(canvas.style, {
    position: "absolute",
    inset: "0",
    width: "100%",
    height: "100%",
    display: "block",
    pointerEvents: "none",
    zIndex: "1",
    contain: "strict",
  });
  canvas.width = Math.max(1, roundDimension(pixelWidth * pixelRatio));
  canvas.height = Math.max(1, roundDimension(pixelHeight * pixelRatio));
  mount.appendChild(canvas);

  const context = canvas.getContext("2d", { alpha: true, desynchronized: true })
    || canvas.getContext("2d");
  const frameRatePixelCount = Math.max(1, Math.round(pixelWidth * legacyPixelRatio))
    * Math.max(1, Math.round(pixelHeight * legacyPixelRatio));
  const targetFrameRate = frameRatePixelCount > 1_200_000 ? 30 : 45;
  const frameInterval = 1000 / targetFrameRate;
  const scale = Math.max(0.25, config.featureScale);
  const maxTransactions = Math.max(1, Math.min(4, Math.round(config.maxHandshakes || 3)));
  const routes = field.traces.map((trace, index) => {
    const forward = orientedRoute(trace.points);
    const reverse = orientedRoute([...trace.points].reverse());
    const midpoint = trace.points[Math.floor(trace.points.length / 2)];
    return { index, trace, forward, reverse, midpoint, length: forward.length };
  });

  function readingRect() {
    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(38, zoneRect.width * 0.08) * scaleX;
      const paddingY = Math.max(28, zoneRect.height * 0.1) * scaleY;
      return {
        left: (zoneRect.left - mountRect.left) * scaleX - paddingX,
        right: (zoneRect.right - mountRect.left) * scaleX + paddingX,
        top: (zoneRect.top - mountRect.top) * scaleY - paddingY,
        bottom: (zoneRect.bottom - mountRect.top) * scaleY + paddingY,
      };
    }
    return {
      left: field.width * 0.13,
      right: field.width * 0.87,
      top: field.height * 0.26,
      bottom: field.height * 0.74,
    };
  }

  const quietZone = readingRect();
  const minimumLength = Math.max(28 * scale, Math.hypot(field.width, field.height) * 0.035);
  const maximumLength = Math.hypot(field.width, field.height) * 0.64;
  const inset = config.viaOuterRadius * scale * 1.4;
  const insetRoutes = routes.filter(({ trace, length }) => {
    const start = trace.points[0];
    const end = trace.points.at(-1);
    return trace.viaStart && trace.viaEnd
      && length >= minimumLength
      && length <= maximumLength
      && start[0] > inset && start[0] < field.width - inset
      && start[1] > inset && start[1] < field.height - inset
      && end[0] > inset && end[0] < field.width - inset
      && end[1] > inset && end[1] < field.height - inset;
  });
  const outsideCopy = insetRoutes.filter(({ trace }) => !routeTouchesRect(trace.points, quietZone));
  const outsideMidpoint = insetRoutes.filter(({ midpoint }) => !(
    midpoint[0] >= quietZone.left && midpoint[0] <= quietZone.right
    && midpoint[1] >= quietZone.top && midpoint[1] <= quietZone.bottom
  ));
  const eligibleRoutes = outsideCopy.length >= 12
    ? outsideCopy
    : outsideMidpoint.length >= 12 ? outsideMidpoint : insetRoutes.length ? insetRoutes : routes;

  let frameRequest;
  let running = false;
  let frameCount = 0;
  let transactionsCompleted = 0;
  let transactionsTriggered = 0;
  let lastPaintTime = 0;
  let nextSpawnAt = 0;
  let pointer = null;
  const transactions = [];
  const recentRoutes = [];

  function life() {
    return clamp(config.liveliness, 0, 1.5);
  }

  function activeRoute(index) {
    return transactions.some((transaction) => transaction.route.index === index);
  }

  function selectRoute(origin = null) {
    const available = eligibleRoutes.filter(({ index }) => !activeRoute(index));
    if (!available.length) return null;
    let selected = available[Math.floor(random() * available.length)];
    let bestScore = -Infinity;
    const attempts = Math.min(36, available.length);
    const source = origin || (pointer && performance.now() - pointer.at < 2400 ? pointer : null);
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      const candidate = available[Math.floor(random() * available.length)];
      const targetLength = Math.hypot(field.width, field.height) * 0.16;
      const lengthScore = Math.exp(-Math.abs(candidate.length - targetLength) / Math.max(1, targetLength));
      const recentPenalty = recentRoutes.includes(candidate.index) ? 1.2 : 0;
      let proximityScore = 0;
      if (source) {
        const first = candidate.trace.points[0];
        const last = candidate.trace.points.at(-1);
        const distance = Math.min(
          Math.hypot(first[0] - source.x, first[1] - source.y),
          Math.hypot(last[0] - source.x, last[1] - source.y),
        );
        proximityScore = 1 - distance / Math.max(field.width, field.height);
      }
      const score = random() * 0.7 + lengthScore * 0.8 + proximityScore * 1.35 - recentPenalty;
      if (score > bestScore) {
        bestScore = score;
        selected = candidate;
      }
    }
    return selected;
  }

  function spawnTransaction(origin = null, timestamp = performance.now(), triggered = false) {
    if (!running || reducedMotion || !config.motion || transactions.length >= maxTransactions) return false;
    const route = selectRoute(origin);
    if (!route || route.length < 1) return false;
    const first = route.trace.points[0];
    const last = route.trace.points.at(-1);
    const reverse = origin
      ? Math.hypot(last[0] - origin.x, last[1] - origin.y) < Math.hypot(first[0] - origin.x, first[1] - origin.y)
      : random() < 0.5;
    const outbound = reverse ? route.reverse : route.forward;
    const acknowledgement = reverse ? route.forward : route.reverse;
    const source = outbound.points[0];
    const destination = outbound.points.at(-1);
    const packetLength = clamp(
      (config.handshakeLength || 48) * scale,
      route.length * 0.12,
      route.length * 0.42,
    );
    const speed = (config.handshakeSpeed || 230) * scale * (0.84 + life() * 0.2);
    const outboundDuration = clamp((route.length + packetLength) / speed * 1000, 940, 2350);
    transactions.push({
      route,
      outbound,
      acknowledgement,
      source,
      destination,
      packetLength,
      sourceColor: brandColor(source[0], field.width),
      destinationColor: brandColor(destination[0], field.width),
      startedAt: timestamp,
      chargeDuration: 320,
      outboundDuration,
      holdDuration: 190,
      acknowledgementDuration: Math.max(820, outboundDuration * 0.82),
      settleDuration: 360,
      triggered,
    });
    recentRoutes.push(route.index);
    if (recentRoutes.length > 7) recentRoutes.shift();
    if (triggered) transactionsTriggered += 1;
    return true;
  }

  function scheduleNext(timestamp, initial = false) {
    const interval = (config.handshakeInterval || 1650) / (0.82 + life() * 0.18);
    nextSpawnAt = timestamp + (initial ? 320 : interval * (0.72 + random() * 0.62));
  }

  function drawPacket(route, packetLength, progress, color, opacity) {
    const travel = progress * (route.length + packetLength);
    const head = clamp(travel, 0, route.length);
    const tail = clamp(travel - packetLength, 0, route.length);
    if (head - tail < 0.05) return false;
    context.strokeStyle = color;
    context.lineCap = "round";
    context.lineJoin = "round";
    context.lineWidth = config.traceWidth * scale * 4.4;
    context.globalAlpha = opacity * 0.1;
    strokeInterval(context, route, tail, head);
    context.lineWidth = config.traceWidth * scale * 1.3;
    context.globalAlpha = opacity * 0.82;
    strokeInterval(context, route, tail, head);
    return true;
  }

  function drawVia(point, color, energy) {
    if (energy <= 0.002) return;
    const radius = (config.viaOuterRadius + config.viaInnerRadius) * scale * 0.5;
    const ringWidth = Math.max(0.7 * scale, (config.viaOuterRadius - config.viaInnerRadius) * scale * 0.72);
    context.strokeStyle = color;
    context.lineCap = "round";
    context.lineWidth = ringWidth * 3.1;
    context.globalAlpha = energy * 0.08;
    context.beginPath();
    context.arc(point[0], point[1], radius, 0, TAU);
    context.stroke();
    context.lineWidth = ringWidth;
    context.globalAlpha = energy * 0.72;
    context.beginPath();
    context.arc(point[0], point[1], radius, 0, TAU);
    context.stroke();
  }

  function drawTransaction(transaction, timestamp) {
    const elapsed = timestamp - transaction.startedAt;
    const chargeEnd = transaction.chargeDuration;
    const outboundEnd = chargeEnd + transaction.outboundDuration;
    const holdEnd = outboundEnd + transaction.holdDuration;
    const acknowledgementEnd = holdEnd + transaction.acknowledgementDuration;
    const end = acknowledgementEnd + transaction.settleDuration;
    let sourceEnergy = 0;
    let destinationEnergy = 0;

    if (elapsed < chargeEnd) {
      sourceEnergy = smoothstep(elapsed / transaction.chargeDuration);
    } else if (elapsed < outboundEnd) {
      const progress = (elapsed - chargeEnd) / transaction.outboundDuration;
      drawPacket(transaction.outbound, transaction.packetLength, progress, transaction.sourceColor, 0.92);
      sourceEnergy = 1 - progress * 0.76;
      destinationEnergy = smoothstep((progress - 0.72) / 0.28) * 0.92;
    } else if (elapsed < holdEnd) {
      sourceEnergy = 0.18;
      destinationEnergy = 0.92;
    } else if (elapsed < acknowledgementEnd) {
      const progress = (elapsed - holdEnd) / transaction.acknowledgementDuration;
      drawPacket(transaction.acknowledgement, transaction.packetLength * 0.72, progress, transaction.destinationColor, 0.5);
      destinationEnergy = 0.92 - progress * 0.7;
      sourceEnergy = 0.18 + smoothstep((progress - 0.72) / 0.28) * 0.58;
    } else if (elapsed < end) {
      const progress = smoothstep((elapsed - acknowledgementEnd) / transaction.settleDuration);
      sourceEnergy = 0.76 * (1 - progress);
      destinationEnergy = 0.22 * (1 - progress);
    } else {
      return false;
    }

    drawVia(transaction.source, transaction.sourceColor, sourceEnergy);
    drawVia(transaction.destination, transaction.destinationColor, destinationEnergy);
    return true;
  }

  function renderFrame(timestamp) {
    if (!running || !canvas.isConnected || !context) return;
    frameRequest = window.requestAnimationFrame(renderFrame);
    if (document.hidden) return;
    const sincePaint = lastPaintTime ? timestamp - lastPaintTime : frameInterval;
    if (sincePaint < frameInterval) return;
    lastPaintTime = lastPaintTime ? timestamp - (sincePaint % frameInterval) : timestamp;
    frameCount += 1;

    if (timestamp >= nextSpawnAt) {
      spawnTransaction(null, timestamp);
      scheduleNext(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";

    for (let index = transactions.length - 1; index >= 0; index -= 1) {
      if (!drawTransaction(transactions[index], timestamp)) {
        transactions.splice(index, 1);
        transactionsCompleted += 1;
      }
    }
    context.globalAlpha = 1;
  }

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

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

  function trigger(origin = null) {
    return spawnTransaction(origin, performance.now(), true);
  }

  function start() {
    if (running || reducedMotion || !config.motion || !context || config.liveliness <= 0.02) return;
    running = true;
    frameCount = 0;
    lastPaintTime = 0;
    const now = performance.now();
    scheduleNext(now, true);
    frameRequest = window.requestAnimationFrame(renderFrame);
  }

  function stop() {
    running = false;
    window.cancelAnimationFrame(frameRequest);
    frameRequest = undefined;
    lastPaintTime = 0;
  }

  function destroy() {
    stop();
    transactions.length = 0;
    canvas.remove();
  }

  return {
    start,
    stop,
    destroy,
    pointerMoved,
    pointerLeft,
    clicked: trigger,
    trigger,
    get stats() {
      return {
        system: "adom-handshake",
        renderer: "canvas-range-strokes",
        animationNodes: canvas.isConnected ? 1 : 0,
        activeTransactions: transactions.length,
        maxTransactions,
        eligibleRoutes: eligibleRoutes.length,
        totalRoutes: routes.length,
        transactionsCompleted,
        transactionsTriggered,
        frameCount,
        targetFrameRate,
        rendererPixelRatio: pixelRatio,
        quietZoneProtected: Boolean(readingZone),
        perFrameTraceBudget: maxTransactions,
        reducedMotion,
      };
    },
  };
}