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());