"use client";
import * as React from "react";
export interface GlobeMarker {
/** Latitude in degrees (−90 south … 90 north). */
lat: number;
/** Longitude in degrees (−180 west … 180 east). */
lon: number;
/** Shown next to the dot and included in the accessible description. */
label?: string;
/** Optional id that arcs can reference. */
id?: string;
}
export interface GlobeArc {
/** Marker index, id or label. */
from: number | string;
/** Marker index, id or label. */
to: number | string;
}
export interface WireframeGlobeProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** Pulsing points on the surface. Hoist or memoize the array to avoid re-initialising. */
markers?: GlobeMarker[];
/** Animated great-circle arcs between markers. */
arcs?: GlobeArc[];
/** Spin continuously around the polar axis. */
autoRotate?: boolean;
/** Auto-rotation speed multiplier (1 ≈ 9° per second). */
speed?: number;
/** Spacing of the latitude / longitude graticule, in degrees. */
gridStep?: number;
/** Initial tilt toward the viewer, in degrees (positive shows more of the north). */
tilt?: number;
/** Marker colour (any CSS colour). */
color?: string;
/** Arc colour (any CSS colour). */
arcColor?: string;
/** Show marker labels on the visible hemisphere. */
showLabels?: boolean;
/** Drag (mouse, pen, touch) and arrow keys rotate the globe. */
draggable?: boolean;
/** Globe radius as a fraction of the smaller container side. */
radius?: number;
/** Accessible name. Defaults to a summary of the markers and arcs. */
label?: string;
}
type RGB = [number, number, number];
type Vec3 = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.42)) as RGB;
}
const rgba = (c: RGB, a: number) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${Math.max(0, Math.min(1, a)).toFixed(3)})`;
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ---------------------------------- Maths ---------------------------------- */
const DEG = Math.PI / 180;
// Stable empty defaults so the render effect does not re-run on every parent render.
const NO_MARKERS: GlobeMarker[] = [];
const NO_ARCS: GlobeArc[] = [];
const PULSE_MS = 2400;
const ARC_MS = 3600;
const KEY_IMPULSE = 0.0009; // rad/ms added per arrow key press (≈ 17° after inertia)
const KEY_STEP = 15 * DEG; // reduced motion: rotate instantly by this much
/** Unit vector for a latitude/longitude pair (y up, z toward the viewer at lon 0). */
function toVec(lat: number, lon: number): Vec3 {
const la = lat * DEG;
const lo = lon * DEG;
return [Math.cos(la) * Math.sin(lo), Math.sin(la), Math.cos(la) * Math.cos(lo)];
}
function findMarker(markers: GlobeMarker[], ref: number | string) {
if (typeof ref === "number") return markers[ref];
return markers.find((m) => m.id === ref) ?? markers.find((m) => m.label === ref);
}
function describe(markers: GlobeMarker[], arcs: GlobeArc[], draggable: boolean) {
const names = markers.map((m) => m.label).filter(Boolean);
let text = `Wireframe globe with ${markers.length} marker${markers.length === 1 ? "" : "s"}`;
if (names.length) text += `: ${names.join(", ")}`;
if (arcs.length) text += `; ${arcs.length} connection${arcs.length === 1 ? "" : "s"}`;
text += ".";
if (draggable) text += " Use the arrow keys to rotate.";
return text;
}
/**
* A rotating wireframe globe on canvas 2D: depth-faded graticule, pulsing markers with labels
* and animated great-circle arcs. Drag with inertia (pointer capture) or use the arrow keys
* when focused. The loop pauses offscreen and in hidden tabs; reduced motion draws still frames
* and rotates only in direct response to input.
*/
export function WireframeGlobe({
markers = NO_MARKERS,
arcs = NO_ARCS,
autoRotate = true,
speed = 1,
gridStep = 15,
tilt = 18,
color = "#FF7448",
arcColor = "#B6F3D1",
showLabels = true,
draggable = true,
radius = 0.4,
label,
className = "",
...props
}: WireframeGlobeProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
// Yaw survives prop changes so tweaking a setting does not snap the globe back.
const yawRef = React.useRef(60 * DEG);
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const step = Math.max(5, Math.min(90, gridStep));
const view = { yaw: yawRef.current, pitch: Math.max(-70, Math.min(70, tilt)) * DEG, yawVel: 0, pitchVel: 0 };
const drag = { active: false, id: -1, x: 0, y: 0, t: 0 };
let focusVisible = false;
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let clock = 0;
let inView = false;
let ink: RGB = [24, 24, 27];
let accent: RGB = [255, 116, 72];
let arcRgb: RGB = [182, 243, 209];
let font = "10px ui-monospace, monospace";
// Graticule polylines as unit vectors, built once per gridStep.
const graticule: Vec3[][] = [];
for (let lat = -90 + step; lat < 90; lat += step) {
const line: Vec3[] = [];
for (let lon = -180; lon <= 180; lon += 4) line.push(toVec(lat, lon));
graticule.push(line);
}
for (let lon = -180; lon < 180; lon += step) {
const line: Vec3[] = [];
for (let lat = -90; lat <= 90; lat += 4) line.push(toVec(lat, lon));
graticule.push(line);
}
const points = markers.map((m) => toVec(m.lat, m.lon));
// Great-circle arcs (slerp), lifted above the surface in proportion to their length.
const arcPaths: Vec3[][] = [];
for (const arc of arcs) {
const a = findMarker(markers, arc.from);
const b = findMarker(markers, arc.to);
if (!a || !b) continue;
const va = toVec(a.lat, a.lon);
const vb = toVec(b.lat, b.lon);
const dot = Math.max(-1, Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]));
const omega = Math.acos(dot);
if (omega < 1e-4) continue;
const sinO = Math.sin(omega);
const rise = 0.06 + 0.28 * (omega / Math.PI);
const path: Vec3[] = [];
const samples = 48;
for (let i = 0; i <= samples; i++) {
const t = i / samples;
const ka = Math.sin((1 - t) * omega) / sinO;
const kb = Math.sin(t * omega) / sinO;
const lift = 1 + rise * Math.sin(Math.PI * t);
path.push([(va[0] * ka + vb[0] * kb) * lift, (va[1] * ka + vb[1] * kb) * lift, (va[2] * ka + vb[2] * kb) * lift]);
}
arcPaths.push(path);
}
/** Rotates a world vector by yaw (around y) then pitch (around x). Returns view space. */
const rotate = (v: Vec3, cy: number, sy: number, cp: number, sp: number): Vec3 => {
const x = v[0] * cy - v[2] * sy;
const z1 = v[0] * sy + v[2] * cy;
return [x, v[1] * cp - z1 * sp, v[1] * sp + z1 * cp];
};
const readTheme = () => {
ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
accent = legible(resolveColor(color, [255, 116, 72]), ink);
arcRgb = legible(resolveColor(arcColor, [182, 243, 209]), ink);
const mono = getComputedStyle(document.documentElement).getPropertyValue("--font-mono").trim();
font = `500 10px ${mono || "ui-monospace, SFMono-Regular, Menlo, monospace"}`;
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const R = Math.min(width, height) * Math.max(0.1, Math.min(0.5, radius));
if (R <= 4) return;
const cx = width / 2;
const cy = height / 2;
const cyaw = Math.cos(view.yaw);
const syaw = Math.sin(view.yaw);
const cp = Math.cos(view.pitch);
const sp = Math.sin(view.pitch);
const animated = !reduceMotion;
// Disc + silhouette.
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.fillStyle = rgba(ink, 0.025);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(ink, 0.32);
ctx.stroke();
// Graticule split into depth bands: back, limb and front.
const back = new Path2D();
const limb = new Path2D();
const front = new Path2D();
for (const line of graticule) {
let prev = rotate(line[0], cyaw, syaw, cp, sp);
for (let i = 1; i < line.length; i++) {
const cur = rotate(line[i], cyaw, syaw, cp, sp);
const z = (prev[2] + cur[2]) / 2;
const path = z < 0 ? back : z < 0.35 ? limb : front;
path.moveTo(cx + prev[0] * R, cy - prev[1] * R);
path.lineTo(cx + cur[0] * R, cy - cur[1] * R);
prev = cur;
}
}
ctx.lineWidth = 0.75;
ctx.strokeStyle = rgba(ink, 0.08);
ctx.stroke(back);
ctx.strokeStyle = rgba(ink, 0.18);
ctx.stroke(limb);
ctx.strokeStyle = rgba(ink, 0.3);
ctx.stroke(front);
// Arcs: faint full route plus a travelling bright segment with a head dot.
ctx.lineCap = "round";
arcPaths.forEach((path, index) => {
const projected = path.map((v) => rotate(v, cyaw, syaw, cp, sp));
// A lifted point is visible in front of the globe or outside its silhouette.
const visible = projected.map((p) => p[2] > 0 || p[0] * p[0] + p[1] * p[1] > 1);
const stroke = (from: number, to: number) => {
ctx.beginPath();
let open = false;
for (let i = from; i <= to; i++) {
const p = projected[i];
if (!visible[i]) {
open = false;
continue;
}
if (!open) ctx.moveTo(cx + p[0] * R, cy - p[1] * R);
else ctx.lineTo(cx + p[0] * R, cy - p[1] * R);
open = true;
}
ctx.stroke();
};
const n = projected.length - 1;
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(arcRgb, animated ? 0.22 : 0.7);
stroke(0, n);
if (!animated) return;
const phase = ((clock / ARC_MS + index * 0.29) % 1) * 1.5;
const head = Math.min(1, phase);
const tail = Math.max(0, phase - 0.5);
if (head <= tail) return;
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(arcRgb, 0.95);
stroke(Math.floor(tail * n), Math.ceil(head * n));
const hi = Math.round(head * n);
if (head < 1 && visible[hi]) {
ctx.beginPath();
ctx.arc(cx + projected[hi][0] * R, cy - projected[hi][1] * R, 2, 0, Math.PI * 2);
ctx.fillStyle = rgba(arcRgb, 1);
ctx.fill();
}
});
// Markers: faint on the far side, pulsing with labels in front.
ctx.font = font;
ctx.textBaseline = "middle";
if ("letterSpacing" in ctx) ctx.letterSpacing = "1.2px";
points.forEach((v, index) => {
const p = rotate(v, cyaw, syaw, cp, sp);
const x = cx + p[0] * R;
const y = cy - p[1] * R;
if (p[2] <= 0) {
ctx.beginPath();
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = rgba(accent, 0.22);
ctx.fill();
return;
}
const fade = Math.min(1, p[2] / 0.25);
if (animated) {
const ph = ((clock + index * 530) % PULSE_MS) / PULSE_MS;
ctx.beginPath();
ctx.arc(x, y, 3 + ph * 13, 0, Math.PI * 2);
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(accent, (1 - ph) * 0.65 * fade);
ctx.stroke();
}
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = rgba(accent, 0.35 + 0.65 * fade);
ctx.fill();
const name = markers[index]?.label;
if (showLabels && name && p[2] > 0.15) {
ctx.fillStyle = rgba(ink, 0.75 * Math.min(1, (p[2] - 0.15) / 0.2));
ctx.fillText(name.toUpperCase(), x + 9, y);
}
});
// Keyboard focus: a dashed accent ring around the silhouette.
if (focusVisible) {
ctx.beginPath();
ctx.setLineDash([3, 4]);
ctx.arc(cx, cy, R + 8, 0, Math.PI * 2);
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(accent, 0.9);
ctx.stroke();
ctx.setLineDash([]);
}
};
const clampPitch = () => {
view.pitch = Math.max(-1.2, Math.min(1.2, view.pitch));
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
clock += dt;
if (!drag.active) {
// Inertia decays frame-rate independently; auto-rotation continues underneath.
const decay = Math.pow(0.94, dt / 16.7);
view.yaw += view.yawVel * dt;
view.pitch += view.pitchVel * dt;
view.yawVel *= decay;
view.pitchVel *= decay;
if (Math.abs(view.yawVel) < 1e-6) view.yawVel = 0;
if (Math.abs(view.pitchVel) < 1e-6) view.pitchVel = 0;
if (autoRotate) view.yaw += dt * 0.00016 * speed;
clampPitch();
}
yawRef.current = view.yaw;
draw();
schedule();
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
draw();
schedule();
};
const onThemeChange = () => {
readTheme();
draw();
};
/* ------------------------------- Input -------------------------------- */
const radiusPx = () => Math.max(40, Math.min(width, height) * radius);
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
drag.active = true;
drag.id = e.pointerId;
drag.x = e.clientX;
drag.y = e.clientY;
drag.t = e.timeStamp;
view.yawVel = 0;
view.pitchVel = 0;
root.setPointerCapture(e.pointerId);
root.style.cursor = "grabbing";
};
const onPointerMove = (e: PointerEvent) => {
if (!drag.active || e.pointerId !== drag.id) return;
const r = radiusPx();
const dx = (e.clientX - drag.x) / r;
// Touch keeps vertical movement for page scrolling (touch-action: pan-y), so only mouse/pen tilt.
const dy = e.pointerType === "touch" ? 0 : (e.clientY - drag.y) / r;
const dt = Math.max(1, e.timeStamp - drag.t);
view.yaw += dx;
view.pitch += dy;
clampPitch();
// Smoothed velocity for the release fling.
view.yawVel = view.yawVel * 0.5 + (dx / dt) * 0.5;
view.pitchVel = view.pitchVel * 0.5 + (dy / dt) * 0.5;
drag.x = e.clientX;
drag.y = e.clientY;
drag.t = e.timeStamp;
yawRef.current = view.yaw;
if (reduceMotion) draw();
};
const onPointerUp = (e: PointerEvent) => {
if (!drag.active || e.pointerId !== drag.id) return;
drag.active = false;
root.style.cursor = "";
if (root.hasPointerCapture(e.pointerId)) root.releasePointerCapture(e.pointerId);
// A pause before release cancels the fling; reduced motion never flings.
if (reduceMotion || e.timeStamp - drag.t > 80) {
view.yawVel = 0;
view.pitchVel = 0;
}
};
const onKeyDown = (e: KeyboardEvent) => {
const dir =
e.key === "ArrowLeft" ? [-1, 0] : e.key === "ArrowRight" ? [1, 0] : e.key === "ArrowUp" ? [0, 1] : e.key === "ArrowDown" ? [0, -1] : null;
if (!dir) return;
e.preventDefault();
if (reduceMotion) {
view.yaw += dir[0] * KEY_STEP;
view.pitch += dir[1] * KEY_STEP;
clampPitch();
yawRef.current = view.yaw;
draw();
} else {
view.yawVel += dir[0] * KEY_IMPULSE;
view.pitchVel += dir[1] * KEY_IMPULSE;
}
};
const onFocus = () => {
focusVisible = root.matches(":focus-visible");
draw();
};
const onBlur = () => {
focusVisible = false;
draw();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) schedule();
else stop();
});
readTheme();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(root);
const unobserveTheme = observeTheme(root, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
if (draggable) {
root.addEventListener("pointerdown", onPointerDown);
root.addEventListener("pointermove", onPointerMove);
root.addEventListener("pointerup", onPointerUp);
root.addEventListener("pointercancel", onPointerUp);
root.addEventListener("keydown", onKeyDown);
root.addEventListener("focus", onFocus);
root.addEventListener("blur", onBlur);
}
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
root.removeEventListener("pointerdown", onPointerDown);
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerup", onPointerUp);
root.removeEventListener("pointercancel", onPointerUp);
root.removeEventListener("keydown", onKeyDown);
root.removeEventListener("focus", onFocus);
root.removeEventListener("blur", onBlur);
root.style.cursor = "";
};
}, [markers, arcs, autoRotate, speed, gridStep, tilt, color, arcColor, showLabels, draggable, radius, reduceMotion]);
return (
<div
ref={rootRef}
role="img"
aria-label={label ?? describe(markers, arcs, draggable)}
tabIndex={draggable ? 0 : undefined}
className={`relative h-full w-full select-none outline-none ${draggable ? "cursor-grab touch-pan-y" : ""} ${className}`}
{...props}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
</div>
);
}