"use client"; import { useEffect, useRef, useState } from "react"; import { useInView, useReducedMotion } from "motion/react"; type PaperPlanePathProps = { /** Routes the plane flies, as SVG path data in a 520 x 320 box. */ routes?: string[]; /** Flying speed in box units per second. */ speed?: number; /** Called when the plane lands. */ onLand?: () => void; /** Read out by screen readers. */ label?: string; className?: string; }; const W = 520; const H = 320; const ROUTES = [ "M92 246C176 268 262 244 304 176C338 118 286 66 240 98C196 130 234 204 318 202C392 200 440 150 462 86", "M92 90C150 60 176 250 236 232C292 214 268 84 326 78C384 72 372 246 462 226", "M92 256C230 300 448 260 440 160C434 74 214 44 180 136C156 208 300 232 334 164C352 122 292 112 272 146", ]; const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a)); const ease = (t: number) => t * t * (3 - 2 * t); /* Midpoint curves turn a shaky hand line into one smooth stroke. */ function smooth(points: { x: number; y: number }[]) { let d = `M${points[0].x.toFixed(1)} ${points[0].y.toFixed(1)}`; for (let i = 1; i < points.length - 1; i++) { const mx = (points[i].x + points[i + 1].x) / 2; const my = (points[i].y + points[i + 1].y) / 2; d += `Q${points[i].x.toFixed(1)} ${points[i].y.toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`; } const end = points[points.length - 1]; return `${d}L${end.x.toFixed(1)} ${end.y.toFixed(1)}`; } const startOf = (d: string) => (d.match(/-?\d+(\.\d+)?/g) ?? ["0", "0"]).slice(0, 2).map(Number); /* PaperPlanePath: a coral paper plane on a ruled notebook page. It flies the pencil route, banks through the bends and rolls over on a loop. Draw any line with your finger and it flies that instead. */ export function PaperPlanePath({ routes = ROUTES, speed = 200, onLand, label = "Paper plane flying a pencil route", className = "" }: PaperPlanePathProps) { const reduce = useReducedMotion(); const svg = useRef(null); const guide = useRef(null); const reveal = useRef(null); const plane = useRef(null); const shadow = useRef(null); const mark = useRef(null); const hand = useRef(null); const points = useRef<{ x: number; y: number }[]>([]); const landed = useRef(onLand); const [flight, setFlight] = useState({ d: routes[0], next: 1, count: 0 }); const [status, setStatus] = useState(""); const [drawing, setDrawing] = useState(false); const inView = useInView(svg, { once: true, amount: 0.3 }); const [startX, startY] = startOf(flight.d); useEffect(() => { landed.current = onLand; }, [onLand]); useEffect(() => { const path = guide.current!; const total = path.getTotalLength(); const end = path.getPointAtLength(total); mark.current?.setAttribute("transform", `translate(${end.x.toFixed(1)} ${end.y.toFixed(1)})`); const at = (s: number) => { const p = path.getPointAtLength(s); const q = path.getPointAtLength(Math.min(total, s + 2)); const o = path.getPointAtLength(Math.max(0, s - 2)); return { x: p.x, y: p.y, turn: Math.atan2(q.y - o.y, q.x - o.x) }; }; let heading = at(0).turn; let roll = Math.cos(heading) >= 0 ? 1 : -1; const place = (s: number, lift: number) => { const p = at(s); heading += wrap(p.turn - heading) * (s === 0 ? 1 : 0.32); roll += ((Math.cos(heading) >= 0 ? 1 : -1) - roll) * 0.1; const flat = Math.abs(roll) < 0.14 ? 0.14 * (roll < 0 ? -1 : 1) : roll; const pose = `rotate(${((heading * 180) / Math.PI).toFixed(1)}) scale(${(0.92 + lift * 0.14).toFixed(3)} ${(flat * (0.92 + lift * 0.14)).toFixed(3)})`; plane.current?.setAttribute("transform", `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)}) ${pose}`); shadow.current?.setAttribute("transform", `translate(${(p.x + 4 + lift * 12).toFixed(1)} ${(p.y + 6 + lift * 20).toFixed(1)}) ${pose}`); reveal.current?.setAttribute("stroke-dasharray", `${s.toFixed(1)} ${(total + 10).toFixed(1)}`); }; const land = () => { place(total, 0); setStatus(`Landed after ${Math.round(total)} units`); landed.current?.(); }; place(0, 0); if (reduce) { if (flight.count) land(); return; } if (!inView) return; const seconds = Math.max(1.2, total / speed); const began = performance.now(); let raf = 0; const tick = (now: number) => { const t = Math.min(1, (now - began) / (seconds * 1000)); if (t === 1) { land(); return; } place(total * (t * 0.45 + ease(t) * 0.55), Math.sin(Math.PI * t)); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [flight, inView, reduce, speed]); const local = (e: React.PointerEvent) => { const m = svg.current!.getScreenCTM()!.inverse(); return { x: Math.max(0, Math.min(W, m.a * e.clientX + m.c * e.clientY + m.e)), y: Math.max(0, Math.min(H, m.b * e.clientX + m.d * e.clientY + m.f)) }; }; const onDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); points.current = [local(e)]; hand.current?.setAttribute("d", ""); setDrawing(true); }; const onMove = (e: React.PointerEvent) => { if (!drawing) return; const p = local(e); const last = points.current[points.current.length - 1]; if (Math.hypot(p.x - last.x, p.y - last.y) < 7 || points.current.length > 420) return; points.current.push(p); hand.current?.setAttribute("d", smooth(points.current)); }; const onUp = () => { if (!drawing) return; setDrawing(false); const line = points.current; if (line.length >= 6) setFlight((f) => ({ d: smooth(line), next: f.next, count: f.count + 1 })); hand.current?.setAttribute("d", ""); }; const nextRoute = () => { setStatus(""); setFlight((f) => ({ d: routes[f.next % routes.length], next: f.next + 1, count: f.count + 1 })); }; return (
{/* The notebook page. */} {Array.from({ length: 12 }, (_, i) => ( ))} {[62, 160, 258].map((y) => ( ))} draw a route, let go {/* Pencil route: faint ahead, firm where the plane has been. */} {status}
); }