← Back
Canvas

Globe

Fibonacci-sphere point cloud projected onto 2D canvas with Y-axis rotation, depth shading, auto-rotation, and drag-to-spin inertia.

fibonacci sphere · drag to spin · inertia

Globe.tsx122 lines
"use client";

import { useEffect, useRef } from "react";

const POINTS = 700;
const SIZE = 320;
const RADIUS = 120;
const TILT = 0.35;

export default function Globe() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const stateRef = useRef({ rotY: 0, vel: 0.004, dragging: false, lastX: 0 });

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;

    const dpr = window.devicePixelRatio || 1;
    canvas.width = SIZE * dpr;
    canvas.height = SIZE * dpr;
    ctx.scale(dpr, dpr);

    // Fibonacci sphere: evenly distributed points
    const golden = Math.PI * (1 + Math.sqrt(5));
    const points = Array.from({ length: POINTS }, (_, i) => {
      const phi = Math.acos(1 - (2 * (i + 0.5)) / POINTS);
      const theta = golden * i;
      return {
        x: Math.sin(phi) * Math.cos(theta),
        y: Math.cos(phi),
        z: Math.sin(phi) * Math.sin(theta),
      };
    });

    const cx = SIZE / 2;
    const cy = SIZE / 2;
    let raf = 0;

    function frame() {
      if (!ctx) return;
      const s = stateRef.current;
      if (!s.dragging) {
        s.rotY += s.vel;
        // ease inertia back to the idle speed
        s.vel += (0.004 - s.vel) * 0.02;
      }

      ctx.clearRect(0, 0, SIZE, SIZE);

      // atmosphere ring
      ctx.beginPath();
      ctx.arc(cx, cy, RADIUS + 8, 0, Math.PI * 2);
      ctx.strokeStyle = "rgba(139,92,246,0.15)";
      ctx.lineWidth = 1;
      ctx.stroke();

      const cosY = Math.cos(s.rotY);
      const sinY = Math.sin(s.rotY);
      const cosT = Math.cos(TILT);
      const sinT = Math.sin(TILT);

      for (const p of points) {
        const x1 = p.x * cosY + p.z * sinY;
        const z1 = -p.x * sinY + p.z * cosY;
        const y2 = p.y * cosT - z1 * sinT;
        const z2 = p.y * sinT + z1 * cosT;
        const depth = (z2 + 1) / 2; // 0 = back, 1 = front

        ctx.beginPath();
        ctx.arc(cx + x1 * RADIUS, cy + y2 * RADIUS, 0.7 + depth * 1.5, 0, Math.PI * 2);
        ctx.fillStyle =
          depth > 0.5
            ? `rgba(139,92,246,${0.25 + (depth - 0.5) * 1.5})`
            : `rgba(139,92,246,${0.06 + depth * 0.3})`;
        ctx.fill();
      }

      raf = requestAnimationFrame(frame);
    }

    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, []);

  function onPointerDown(e: React.PointerEvent<HTMLCanvasElement>) {
    e.currentTarget.setPointerCapture(e.pointerId);
    stateRef.current.dragging = true;
    stateRef.current.lastX = e.clientX;
  }

  function onPointerMove(e: React.PointerEvent) {
    const s = stateRef.current;
    if (!s.dragging) return;
    const dx = e.clientX - s.lastX;
    s.lastX = e.clientX;
    s.rotY += dx * 0.006;
    s.vel = dx * 0.0012; // carry momentum on release
  }

  function endDrag() {
    stateRef.current.dragging = false;
  }

  return (
    <div className="flex flex-col items-center gap-6 py-8 w-full">
      <canvas
        ref={canvasRef}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        style={{ width: SIZE, height: SIZE }}
        className="cursor-grab active:cursor-grabbing touch-none select-none"
      />
      <p className="text-xs text-zinc-400 dark:text-zinc-600 font-mono">
        fibonacci sphere · drag to spin · inertia
      </p>
    </div>
  );
}

More in Canvas