"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; export type GlobeRoute = { /** [latitude, longitude] */ from: [number, number]; /** [latitude, longitude] */ to: [number, number]; }; type GlobeMotionProps = { /** Lights fly along these. */ routes?: GlobeRoute[]; /** Degrees it turns each second. */ speed?: number; /** Longitude facing you at the start. */ center?: number; /** Degrees the north pole leans toward you. */ tilt?: number; /** Six-digit hex colour of the land and the air around it. */ color?: string; /** Six-digit hex colour of the sea. */ sea?: string; /** Six-digit hex colour of the routes and the city lights. */ flight?: string; /** Name read out by screen readers. */ label?: string; /** Set the size here. */ 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///8B4f///3X8AQD+8+//////wP///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", "AAAAAPB/AAD/GQAA4BsAAAAAAOB/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 MAX_PIXELS = 1400000; const FILL = 0.86; const DEG = Math.PI / 180; const STEPS = 48; const TAIL = 15; const ROUTES: GlobeRoute[] = [ { from: [13.08, 80.27], to: [51.51, -0.13] }, { from: [1.35, 103.82], to: [35.68, 139.69] }, { from: [25.2, 55.27], to: [40.71, -74.01] }, { from: [-33.87, 151.21], to: [13.08, 80.27] }, { from: [51.51, -0.13], to: [-23.55, -46.63] }, ]; const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`; const FRAGMENT = `precision highp float; uniform vec2 res; uniform mat3 turn; uniform sampler2D land; uniform vec3 ground,water,lamp; const vec3 sun=vec3(-.76,.3,.38); const float PI=3.14159265; float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} void main(){ float side=min(res.x,res.y); vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*side*${FILL}); float r=length(uv),px=2./(side*${FILL}); float aura=pow(clamp(1.-(r-1.)/.16,0.,1.),3.)*.55; vec4 col=vec4(ground*aura,aura); if(r<1.){ vec3 n=vec3(uv,sqrt(1.-r*r)),q=turn*n; vec2 spot=vec2(atan(q.x,q.z)/(2.*PI)+.5,.5-asin(clamp(q.y,-1.,1.))/PI); float solid=smoothstep(.36,.62,texture2D(land,spot).r); float day=smoothstep(-.12,.5,dot(n,sun)); vec3 c=mix(water,ground*.52,solid)*(.2+.75*day); vec2 g=spot*vec2(300.,150.),cell=floor(g); float lights=step(.72,hash(cell))*(1.-smoothstep(.1,.36,length(fract(g)-.5)))*solid*(1.-day); c+=lamp*lights*1.3; c+=ground*pow(1.-n.z,3.)*.7*(.3+.7*day); float cover=1.-smoothstep(1.-px*1.6,1.,r); col=col*(1.-cover)+vec4(c*cover,cover); } gl_FragColor=col; }`; const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); function program(gl: WebGLRenderingContext) { const made = gl.createProgram(); for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) { const shader = gl.createShader(type); if (!made || !shader) return null; gl.shaderSource(shader, text); gl.compileShader(shader); gl.attachShader(made, shader); } if (!made) return null; gl.linkProgram(made); return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null; } /* One byte per map cell, softened with its neighbours so the coast is not a staircase. */ function landTexture() { const bits = Uint8Array.from(atob(LAND), (c) => c.charCodeAt(0)); const isLand = (row: number, col: number) => { const bit = Math.max(0, Math.min(59, row)) * 120 + (col + 120) % 120; return (bits[bit >> 3] >> (bit & 7)) & 1; }; return Uint8Array.from({ length: 7200 }, (_, i) => { const row = Math.floor(i / 120); const col = i % 120; let sum = 0; for (const y of [-1, 0, 1]) for (const x of [-1, 0, 1]) sum += (2 - Math.abs(x)) * (2 - Math.abs(y)) * isLand(row + y, col + x); return (sum / 16) * 255; }); } const toPoint = ([lat, lng]: [number, number]) => [ Math.cos(lat * DEG) * Math.sin(lng * DEG), Math.sin(lat * DEG), Math.cos(lat * DEG) * Math.cos(lng * DEG), ]; /* Points along the short way round, lifted off the ground in the middle. */ function arc(route: GlobeRoute) { const a = toPoint(route.from); const b = toPoint(route.to); const angle = Math.acos(Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]))); if (angle < 0.01) return []; const lift = 0.12 + angle * 0.13; return Array.from({ length: STEPS + 1 }, (_, i) => { const s = i / STEPS; const p = Math.sin((1 - s) * angle) / Math.sin(angle); const q = Math.sin(s * angle) / Math.sin(angle); const up = 1 + lift * Math.sin(Math.PI * s); return [0, 1, 2].map((k) => (p * a[k] + q * b[k]) * up); }); } /* GlobeMotion: the night side of the Earth with real continents. City lights glow, and lights fly the routes you give it. Runs only while on screen. */ export function GlobeMotion({ routes = ROUTES, speed = 7, center = 70, tilt = 16, color = "#a78bfa", sea = "#120f2e", flight = "#fde68a", label = "A turning globe with flights between cities", className = "aspect-square w-96", }: GlobeMotionProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const plainRef = useRef(null); const paths = JSON.stringify(routes); useEffect(() => { const box = boxRef.current; const plain = plainRef.current; if (!box || !plain) return; const canvas = document.createElement("canvas"); const lines = document.createElement("canvas"); for (const layer of [canvas, lines]) { layer.setAttribute("aria-hidden", "true"); layer.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none"; } const gl = canvas.getContext("webgl", { alpha: true, antialias: false }); const pen = lines.getContext("2d"); const made = gl && program(gl); if (!gl || !pen || !made) return; gl.useProgram(made); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const corner = gl.getAttribLocation(made, "p"); gl.enableVertexAttribArray(corner); gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0); const at = (name: string) => gl.getUniformLocation(made, name); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); for (const wrap of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T]) gl.texParameteri(gl.TEXTURE_2D, wrap, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 120, 60, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, landTexture()); gl.uniform3fv(at("ground"), rgb(color)); gl.uniform3fv(at("water"), rgb(sea)); gl.uniform3fv(at("lamp"), rgb(flight)); const trips = (JSON.parse(paths) as GlobeRoute[]).map(arc).filter((trip) => trip.length); const [red, green, blue] = rgb(flight).map((v) => Math.round(v * 255)); const ink = (alpha: number) => `rgba(${red},${green},${blue},${alpha})`; const ct = Math.cos(tilt * DEG); const st = Math.sin(tilt * DEG); let raf = 0; let last = 0; let inView = false; let time = 0; let wide = 0; let high = 0; const draw = () => { const turn = (-center + time * speed) * DEG; const c = Math.cos(turn), s = Math.sin(turn); const m = [c, 0, s, st * s, ct, -st * c, -ct * s, st, ct * c]; gl.uniformMatrix3fv(at("turn"), false, m); gl.drawArrays(gl.TRIANGLES, 0, 3); const size = (Math.min(wide, high) / 2) * FILL; const place = ([x, y, z]: number[]) => { const vx = m[0] * x + m[1] * y + m[2] * z; const vy = m[3] * x + m[4] * y + m[5] * z; const vz = m[6] * x + m[7] * y + m[8] * z; return { x: wide / 2 + vx * size, y: high / 2 - vy * size, seen: vz >= 0 || vx * vx + vy * vy >= 1 }; }; const stroke = (a: ReturnType, b: ReturnType) => { if (!a.seen || !b.seen) return; pen.beginPath(); pen.moveTo(a.x, a.y); pen.lineTo(b.x, b.y); pen.stroke(); }; pen.clearRect(0, 0, wide, high); pen.lineCap = "round"; trips.forEach((trip, n) => { const dots = trip.map(place); pen.lineWidth = 1; pen.strokeStyle = ink(0.22); for (let i = 1; i <= STEPS; i++) stroke(dots[i - 1], dots[i]); const clock = reduce ? 0.5 : (time / 5 + n / trips.length) % 1; const head = clock * 1.3 * STEPS; pen.lineWidth = 2; for (let i = Math.max(1, Math.ceil(head - TAIL)); i <= Math.min(STEPS, head); i++) { pen.strokeStyle = ink(1 - (head - i) / TAIL); stroke(dots[i - 1], dots[i]); } if (head > STEPS && dots[STEPS].seen) { const grown = (head - STEPS) / (0.3 * STEPS); pen.lineWidth = 1.5; pen.strokeStyle = ink(1 - grown); pen.beginPath(); pen.arc(dots[STEPS].x, dots[STEPS].y, 2 + grown * 11, 0, Math.PI * 2); pen.stroke(); } for (const end of [dots[0], dots[STEPS]]) { if (!end.seen) continue; pen.fillStyle = ink(0.9); pen.beginPath(); pen.arc(end.x, end.y, 2, 0, Math.PI * 2); pen.fill(); } }); }; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; time += dt; draw(); raf = inView ? requestAnimationFrame(frame) : 0; }; const start = () => { if (inView && !raf && !reduce) raf = requestAnimationFrame(frame); }; const fit = () => { const { width, height } = box.getBoundingClientRect(); if (!width || !height) return; wide = width; high = height; const dpr = Math.min(window.devicePixelRatio, 2); const scale = Math.min(Math.max(dpr, 1.5), Math.sqrt(MAX_PIXELS / (width * height))); canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(at("res"), canvas.width, canvas.height); lines.width = Math.round(width * dpr); lines.height = Math.round(height * dpr); pen.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); }; fit(); box.append(canvas, lines); plain.hidden = true; const sized = new ResizeObserver(fit); sized.observe(box); const watch = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; start(); }); watch.observe(box); const lost = () => { canvas.remove(); lines.remove(); plain.hidden = false; }; canvas.addEventListener("webglcontextlost", lost); return () => { cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); lines.remove(); plain.hidden = false; }; }, [paths, speed, center, tilt, color, sea, flight, reduce]); return (
); }