TheBird

warp.ts

5.9 kB · typescript · 176 lines

1const IDLE = 0.015;2const MAX = 14;3const WIND = 0.28;4const RUN = 1;5const FLASH = 0.22;6const END = WIND + RUN + FLASH;7const BLOOM = WIND + RUN * 0.82;8const GIVE_UP = END + 6;9const TAU = Math.PI * 2;1011type Star = { x: number; y: number; z: number; m: number; r: number; f: number; p: number; h: string };1213const calm = matchMedia("(prefers-reduced-motion: reduce)").matches;14const rnd = (a: number, b: number) => a + Math.random() * (b - a);15const clamp = (v: number, a: number, b: number) => Math.min(Math.max(v, a), b);16const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();1718let canvas: HTMLCanvasElement | null = null;1920function fly(href: string) {21  const black = token("--black") || "#000000";22  const white = token("--white") || "#ffffff";23  const cyan = token("--cyan") || "#1ec9f3";24  const blue = token("--blue") || "#008cff";25  canvas = document.createElement("canvas");26  canvas.className = "warp";27  document.body.append(canvas);28  const ctx = canvas.getContext("2d")!;29  let W = 0;30  let H = 0;31  let dpr = 1;32  let edge = 0;33  let roll = 0;34  let sent = false;35  let stars: Star[] = [];36  const t0 = performance.now();37  let last = t0;3839  const spawn = (s: Partial<Star>, far: boolean): Star => {40    const sx = rnd(-0.55, 0.55) * W;41    const sy = rnd(-0.55, 0.55) * H;42    const c = Math.cos(roll);43    const n = Math.sin(roll);44    s.z = far ? rnd(1, 1.15) : rnd(0.08, 1);45    s.x = (sx * c + sy * n) * s.z;46    s.y = (sy * c - sx * n) * s.z;47    s.m = rnd(0.25, 1);48    s.r = 0.5 + Math.random() ** 3 * 2.2;49    s.f = rnd(0.5, 2.5);50    s.p = rnd(0, TAU);51    s.h = Math.random() < 0.35 ? blue : cyan;52    return s as Star;53  };5455  const size = () => {56    if (!canvas) return;57    W = innerWidth;58    H = innerHeight;59    dpr = Math.min(devicePixelRatio || 1, 2);60    canvas.width = Math.round(W * dpr);61    canvas.height = Math.round(H * dpr);62    edge = Math.hypot(W, H) * 0.54;63    stars = Array.from({ length: clamp(Math.round((W * H) / 1100), 400, 1400) }, () => spawn({}, false));64  };6566  const speedAt = (t: number) => {67    if (t < WIND) return IDLE - 0.4 * Math.sin((Math.PI * t) / WIND);68    if (t > END) return IDLE + MAX * Math.max(0, 1 - (t - END) * 4);69    return IDLE + MAX * Math.min((t - WIND) / RUN, 1) ** 2.5;70  };7172  const streak = (tx: number, ty: number, hx: number, hy: number, w: number, color: string, alpha: number) => {73    const dx = hx - tx;74    const dy = hy - ty;75    const len = Math.hypot(dx, dy);76    ctx.globalAlpha = alpha;77    ctx.fillStyle = color;78    ctx.beginPath();79    if (len < w) ctx.arc(hx, hy, w / 2, 0, TAU);80    else {81      const ang = Math.atan2(dy, dx);82      ctx.moveTo(tx, ty);83      ctx.arc(hx, hy, w / 2, ang + Math.PI / 2, ang - Math.PI / 2, true);84    }85    ctx.fill();86  };8788  const frame = () => {89    if (!canvas) return;90    const now = performance.now();91    const dt = Math.min((now - last) / 1000, 0.05);92    last = now;93    const t = (now - t0) / 1000;94    if (t > GIVE_UP) return reset();95    const p = t > END ? 0 : clamp((t - WIND) / RUN, 0, 1);96    const speed = speedAt(t);97    const tail = p > 0 ? speed * (0.04 + 0.1 * p) : 0;98    const q = clamp((p - 0.75) / 0.25, 0, 1) ** 2;99    const glow = 1 + 0.8 * p;100    const shake = p > 0.5 ? 3 * ((p - 0.5) * 2) ** 2 : 0;101    roll += dt * 0.012;102    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);103    ctx.globalCompositeOperation = "source-over";104    ctx.globalAlpha = 1;105    ctx.fillStyle = black;106    ctx.fillRect(0, 0, W, H);107    ctx.translate(W / 2 + rnd(-shake, shake), H / 2 + rnd(-shake, shake));108    ctx.rotate(roll);109    ctx.globalCompositeOperation = "lighter";110    for (const s of stars) {111      s.z -= speed * dt;112      if (s.z < 0.04) spawn(s, true);113      const hx = s.x / s.z;114      const hy = s.y / s.z;115      if (hx * hx + hy * hy > edge * edge) {116        spawn(s, true);117        continue;118      }119      const zt = Math.min(s.z + tail, 2);120      const tx = s.x / zt;121      const ty = s.y / zt;122      const near = 1 - Math.min(s.z, 1);123      const tw = 0.75 + 0.25 * Math.sin((now / 1000) * s.f + s.p);124      const a = Math.min(1, s.m * (0.3 + 0.7 * near) * tw * glow);125      const w = Math.min(s.r * (0.5 + 1.5 * near), 5);126      streak(tx, ty, hx, hy, w * (3.2 + 6 * q), s.h, a * (0.22 + 0.4 * q));127      streak(tx, ty, hx, hy, w, white, a);128    }129    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);130    const f = clamp((t - BLOOM) / (END - BLOOM), 0, 1);131    if (t > END) {132      ctx.globalCompositeOperation = "source-over";133      ctx.globalAlpha = 1;134      ctx.fillStyle = white;135      ctx.fillRect(0, 0, W, H);136    } else if (f > 0) {137      const g = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, edge * (0.35 + 1.3 * f));138      g.addColorStop(0, white);139      g.addColorStop(0.25, "rgba(30, 201, 243, 0.8)");140      g.addColorStop(0.65, "rgba(0, 140, 255, 0.35)");141      g.addColorStop(1, "rgba(0, 140, 255, 0)");142      ctx.globalAlpha = Math.min(1, 3 * f * f);143      ctx.fillStyle = g;144      ctx.fillRect(0, 0, W, H);145      ctx.globalCompositeOperation = "source-over";146      ctx.globalAlpha = clamp((f - 0.55) / 0.45, 0, 1) ** 2;147      ctx.fillStyle = white;148      ctx.fillRect(0, 0, W, H);149    }150    if (!sent && t >= BLOOM) {151      sent = true;152      location.assign(href);153    }154    requestAnimationFrame(frame);155  };156157  addEventListener("resize", size);158  size();159  requestAnimationFrame(() => canvas?.classList.add("on"));160  requestAnimationFrame(frame);161}162163function reset() {164  canvas?.remove();165  canvas = null;166}167168document.addEventListener("click", (event) => {169  const buy = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>("a[data-buy]") : null;170  if (!buy || !buy.href || buy.getAttribute("aria-disabled") === "true") return;171  if (calm || canvas || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;172  event.preventDefault();173  fly(buy.href);174});175176addEventListener("pageshow", (event) => event.persisted && reset());