"use client"; import { useEffect, useRef } from "react"; import { useInView, useReducedMotion } from "motion/react"; export type GlobeMarker = { lat: number; lng: number; label: string }; type InteractiveGlobeProps = { /** Pins on the map. Each stays named for screen readers. */ markers?: GlobeMarker[]; /** Degrees per second when left alone. 0 stands still. */ speed?: number; /** Longitude facing you at the start. */ start?: number; /** Latitude facing you at the start. */ tilt?: number; color?: string; markerColor?: string; /** Name read out by screen readers. */ label?: string; className?: string; }; /* 120 x 60 land map, one bit per 3 degree cell, from Natural Earth (public domain). */ const LAND = [ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgH/wHwAAAAAAAAAAAAAA8Mf/PwALAAAYAAAAAACY9OP/HwAAAATAAQAA", "AAAYfQP+HwAAQID/LwgAwAPA0x/8BwAwAOz///8BHgAAAIoD/v8BDBAAAAAAwP///z04YAD3//////9/4P//HwwwAMD7//////86", "AMH/H1wAAETz/////wcCAAD/f/wAAAT5/////wEDAAD+//0BAPr//////w8BAAD4/z8AAPj//////w8AAAD4/z8AAPi/3v///wsA", "AAD4/x8AALof7v///wkAAAD4/wcAAA7O3///bwAAAAD4/wcAAA7p3///XwQAAADw/wMAAHwA////DwMAAADg/wEAAP4Z////nwAA", "AACADwEAAP9/7///HwAAAAAADwAAgP//H///DwAAAAAABgIAgP///vj7AwAAAAAAThAAgP//fPB4AQAAAAAAeAAAwP//PXD4EAAA", "AAAAwAAAwP//D2DgAAAAAAAAgLgAgP//H2CAAAAAAAAAAPwAAP//HwAAIAAAAAAAAPwHAML/DwAQDAAAAAAAAPwHAID/BwBgBwAA", "AAAAAP4fAID/AwBAFgEAAAAAAP5/AAD/AQBAAA4AAAAAAP7/AAD/AQAAAxwAAAAAAPz/AAD/AQAAAAAAAAAAAPh/AAD/EwAAgAkA", "/////w+A//8A5v//H+T/AAAAAOB/AAD/CAAA8B8AAAAAAOA/AAD+DAAA/D8AAAAAAPAPAAB+AAAA/H8AAAAAAPAPAAB+AAAA/H8A", "AAAAAPAHAAA8AAAA+H8AAAAAAPADAAAEAAAACDwAAAAAAPgBAAAAAAAAADhAAAAAAHgAAAAAAAAAAABAAAAAADAAAAAAAAAAAAAg", "AAAAADgAAAAAAAAAAAAQAAAAABgAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAACAAAAAA8ID//x8AAAAAAHAAAED3//f///8DAAAI9PIAgP////////8P", "APj//x8A+P////////8HQPz//wM4/P////////8DAP7////8//////////8HAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", ].join(""); const DOTS = 7000; const DEG = Math.PI / 180; const MAX_TILT = 70 * DEG; const STEP = 8 * DEG; let landDots: Float32Array | null = null; /* Even dots over the sphere, kept only where the map says land. */ function getLandDots() { if (landDots) return landDots; const bits = Uint8Array.from(atob(LAND), (c) => c.charCodeAt(0)); const found: number[] = []; for (let i = 0; i < DOTS; i++) { const y = 1 - (2 * (i + 0.5)) / DOTS; const around = Math.sqrt(1 - y * y); const turn = i * Math.PI * (3 - Math.sqrt(5)); const x = Math.sin(turn) * around; const z = Math.cos(turn) * around; const row = Math.min(59, Math.floor(((90 - Math.asin(y) / DEG) / 180) * 60)); const col = Math.min(119, Math.floor(((Math.atan2(x, z) / DEG + 180) / 360) * 120)); const bit = row * 120 + col; if ((bits[bit >> 3] >> (bit & 7)) & 1) found.push(x, y, z); } landDots = new Float32Array(found); return landDots; } /* InteractiveGlobe: a dotted Earth with real continents on a canvas. It turns by itself, drags with inertia, and arrow keys turn it too. */ export function InteractiveGlobe({ markers = [], speed = 6, start = 78, tilt = 15, color = "#a78bfa", markerColor = "#f0abfc", label = "Globe", className = "aspect-square w-full", }: InteractiveGlobeProps) { const reduce = useReducedMotion(); const canvas = useRef(null); const seen = useInView(canvas); const view = useRef({ yaw: -start * DEG, tilt: tilt * DEG, spin: 0, radius: 100, held: false, dirty: true, lastX: 0, lastY: 0 }); useEffect(() => { const el = canvas.current; const ctx = el?.getContext("2d"); if (!el || !ctx || !seen) return; const dots = getLandDots(); const v = view.current; const dpr = Math.min(window.devicePixelRatio || 1, 2); let width = 0; let height = 0; const fit = () => { width = el.clientWidth; height = el.clientHeight; el.width = width * dpr; el.height = height * dpr; v.dirty = true; }; fit(); const observer = new ResizeObserver(fit); observer.observe(el); const draw = (now: number) => { const radius = Math.min(width, height) / 2 - 10; if (radius <= 0) return; v.radius = radius; const cx = width / 2; const cy = height / 2; const sinY = Math.sin(v.yaw); const cosY = Math.cos(v.yaw); const sinT = Math.sin(v.tilt); const cosT = Math.cos(v.tilt); const place = (x: number, y: number, z: number) => { const z1 = z * cosY - x * sinY; return [cx + (x * cosY + z * sinY) * radius, cy - (y * cosT - z1 * sinT) * radius, y * sinT + z1 * cosT]; }; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.globalAlpha = 0.06; ctx.fill(); ctx.globalAlpha = 0.3; ctx.stroke(); const size = Math.max(1.2, radius / 90); for (let i = 0; i < dots.length; i += 3) { const [px, py, pz] = place(dots[i], dots[i + 1], dots[i + 2]); if (pz <= 0) continue; ctx.globalAlpha = 0.25 + 0.75 * pz; ctx.fillRect(px - size / 2, py - size / 2, size, size); } ctx.font = "500 12px system-ui, sans-serif"; ctx.textBaseline = "middle"; ctx.strokeStyle = markerColor; const pulse = reduce ? 0 : (now % 1800) / 1800; for (const marker of markers) { const lat = marker.lat * DEG; const lng = marker.lng * DEG; const [px, py, pz] = place(Math.cos(lat) * Math.sin(lng), Math.sin(lat), Math.cos(lat) * Math.cos(lng)); if (pz <= 0.1) continue; const fade = Math.min(1, pz * 3); ctx.globalAlpha = fade * (1 - pulse); ctx.beginPath(); ctx.arc(px, py, 3.5 + pulse * 12, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = fade; ctx.fillStyle = markerColor; ctx.beginPath(); ctx.arc(px, py, 3.5, 0, Math.PI * 2); ctx.fill(); const wide = ctx.measureText(marker.label).width; ctx.fillStyle = "#ffffff"; ctx.fillText(marker.label, px + 10 + wide > width ? px - 10 - wide : px + 10, py); } ctx.globalAlpha = 1; v.dirty = false; }; const moving = !reduce && (speed !== 0 || markers.length > 0); let frame = 0; let last = performance.now(); const loop = (now: number) => { frame = requestAnimationFrame(loop); const delta = Math.min(0.05, (now - last) / 1000); last = now; if (!v.held) { v.spin *= Math.exp(-delta * 3); if (Math.abs(v.spin) < 0.002) v.spin = 0; v.yaw += ((reduce ? 0 : speed * DEG) + v.spin) * delta; } if (moving || v.spin !== 0 || v.dirty) draw(now); }; frame = requestAnimationFrame(loop); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, [seen, reduce, markers, speed, color, markerColor]); const onDown = (e: React.PointerEvent) => { const v = view.current; v.held = true; v.spin = 0; v.lastX = e.clientX; v.lastY = e.clientY; e.currentTarget.setPointerCapture(e.pointerId); }; const onMove = (e: React.PointerEvent) => { const v = view.current; if (!v.held) return; const turn = (e.clientX - v.lastX) / v.radius; v.yaw += turn; v.spin = reduce ? 0 : turn * 60; v.tilt = Math.max(-MAX_TILT, Math.min(MAX_TILT, v.tilt + (e.clientY - v.lastY) / v.radius)); v.lastX = e.clientX; v.lastY = e.clientY; v.dirty = true; }; const onUp = () => { view.current.held = false; }; const onKey = (e: React.KeyboardEvent) => { const v = view.current; if (e.key === "ArrowLeft") v.yaw -= STEP; else if (e.key === "ArrowRight") v.yaw += STEP; else if (e.key === "ArrowUp") v.tilt = Math.max(-MAX_TILT, v.tilt - STEP); else if (e.key === "ArrowDown") v.tilt = Math.min(MAX_TILT, v.tilt + STEP); else return; e.preventDefault(); v.dirty = true; }; return (
{markers.length > 0 && (
    {markers.map((marker) => (
  • {marker.label}
  • ))}
)}
); }