Globus
Ein Punkte-Globus in echtem CSS-3D mit erfundenem Land, Bögen, die sich zwischen Orten zeichnen, und pulsierenden Pins.
Eine von 3 Components in Daten-Animationen
Details
Was Globus mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Daten-Animationen
- Bereich
- Visuals
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/globe.tsx
- Bringt mit
- utilskitmotion-scene
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle GlobeProps aus components/ui/globe.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von SceneMotionProps an.
arcsoptionalGlobeArc[]Dotted arcs between two places that light up one after another
pinsoptionalGlobePoint[]Places with a soft pulse
dotsoptionalnumberDensity of the dot grid. Default 1500. Sea dots are thinned to a third.
seedoptionalnumberSeed of the invented land
durationoptionalnumberSeconds per turn at speed 1. Default 50.
rotateoptionalnumberStart angle in degrees, also the still picture. Default -20.
tiltoptionalnumberTilt of the axis towards the viewer in degrees. Default 16.
sizeoptionalnumberLargest size in px, it shrinks with its container. Default 520.
interactiveoptionalbooleanLeans a few degrees towards a mouse pointer. Default true.
labeloptionalstringAccessible description. Without it the globe is decorative.
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle label.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 258 Zeilen
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';
import { PlayToggle, SCENE_CSS, keyframes, rand, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* A dotted globe. Every dot is a point on a sphere, projected flat and moved
* with transform only; dots on the far side fade out with opacity. The land
* is invented from a few seeded blobs, no map data of anyone. Arcs are
* dotted great circles lifted off the surface that light up one after
* another. The server renders the globe at its start angle, so without
* JavaScript and with reduced motion it stands still and complete.
*/
export interface GlobePoint {
/** Latitude in degrees, north is positive */
lat: number;
/** Longitude in degrees, east is positive */
lon: number;
}
export interface GlobeArc {
from: GlobePoint;
to: GlobePoint;
}
export interface GlobeProps extends SceneMotionProps {
/** Dotted arcs between two places that light up one after another */
arcs?: GlobeArc[];
/** Places with a soft pulse */
pins?: GlobePoint[];
/** Density of the dot grid. Default 1500. Sea dots are thinned to a third. */
dots?: number;
/** Seed of the invented land */
seed?: number;
/** Seconds per turn at speed 1. Default 50. */
duration?: number;
/** Start angle in degrees, also the still picture. Default -20. */
rotate?: number;
/** Tilt of the axis towards the viewer in degrees. Default 16. */
tilt?: number;
/** Largest size in px, it shrinks with its container. Default 520. */
size?: number;
/** Leans a few degrees towards a mouse pointer. Default true. */
interactive?: boolean;
/** Accessible description. Without it the globe is decorative. */
label?: string;
className?: string;
}
const R = 40; // sphere radius in percent of the size
const DEG = Math.PI / 180;
type V3 = [number, number, number];
const vec = (lat: number, lon: number): V3 => [Math.cos(lat * DEG) * Math.sin(lon * DEG), Math.sin(lat * DEG), Math.cos(lat * DEG) * Math.cos(lon * DEG)];
/** Rotate around the vertical axis by a, then tilt around the horizontal axis by b. Returns screen x, y (y down) and depth z. */
function project(p: V3, a: number, b: number): V3 {
const ca = Math.cos(a);
const sa = Math.sin(a);
const x1 = p[0] * ca + p[2] * sa;
const z1 = -p[0] * sa + p[2] * ca;
const cb = Math.cos(b);
const sb = Math.sin(b);
const y2 = p[1] * cb - z1 * sb;
const z2 = p[1] * sb + z1 * cb;
return [x1, -y2, z2];
}
function landBlobs(seed: number) {
return Array.from({ length: 9 }, (_, i) => ({ c: vec(-50 + rand(seed, i * 3 + 1) * 112, -180 + rand(seed, i * 3 + 2) * 360), r: (15 + rand(seed, i * 3 + 3) * 22) * DEG }));
}
function slerp(a: V3, b: V3, t: number): V3 {
const d = Math.min(1, Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));
const o = Math.acos(d);
if (o < 1e-4) return a;
const s = Math.sin(o);
const k1 = Math.sin((1 - t) * o) / s;
const k2 = Math.sin(t * o) / s;
return [a[0] * k1 + b[0] * k2, a[1] * k1 + b[1] * k2, a[2] * k1 + b[2] * k2];
}
interface Dot {
p: V3;
kind: 'land' | 'sea' | 'arc' | 'pin';
arc?: number;
step?: number;
}
const place = (q: V3) => ({
transform: `translate(${round(q[0] * R, 2)}cqw, ${round(q[1] * R, 2)}cqw)`,
opacity: q[2] <= -0.04 ? 0 : round(Math.min(1, 0.2 + q[2] * 1.15), 3),
});
/**
* A slowly turning dotted globe with arcs and pins.
* <Globe arcs={[{ from: { lat: 52.5, lon: 13.4 }, to: { lat: 40.7, lon: -74 } }]} pins={[…]} />
*/
export function Globe({ arcs = [], pins = [], dots = 1500, seed = 3, duration = 50, rotate = -20, tilt = 16, size = 520, interactive = true, speed = 1, autoPlay = 'in-view', controls = true, label, className }: GlobeProps) {
const sid = useSceneId('kvgl');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const layer = useRef<HTMLDivElement>(null);
const blobs = landBlobs(seed);
const golden = Math.PI * (3 - Math.sqrt(5));
const all: Dot[] = [];
for (let i = 0; i < dots; i++) {
const y = 1 - (2 * (i + 0.5)) / dots;
const rr = Math.sqrt(1 - y * y);
const phi = i * golden;
const p: V3 = [rr * Math.sin(phi), y, rr * Math.cos(phi)];
const near = Math.min(...blobs.map((b) => Math.acos(Math.min(1, p[0] * b.c[0] + p[1] * b.c[1] + p[2] * b.c[2])) / b.r)) + (rand(seed + 11, i) - 0.5) * 0.3;
if (near < 1 && Math.abs(y) < 0.94) all.push({ p, kind: 'land' });
else if (i % 3 === 0) all.push({ p, kind: 'sea' });
}
arcs.forEach((a, k) => {
const A = vec(a.from.lat, a.from.lon);
const B = vec(a.to.lat, a.to.lon);
const n = 26;
for (let s = 0; s <= n; s++) {
const t = s / n;
const q = slerp(A, B, t);
const lift = 1 + 0.2 * Math.sin(Math.PI * t);
all.push({ p: [q[0] * lift, q[1] * lift, q[2] * lift], kind: 'arc', arc: k, step: t });
}
});
pins.forEach((pin) => all.push({ p: vec(pin.lat, pin.lon), kind: 'pin' }));
const a0 = rotate * DEG;
const b0 = tilt * DEG;
useEffect(() => {
const el = layer.current;
const root = play.ref.current;
if (!el || !root || prefersReducedMotion()) return;
const nodes = Array.from(el.children) as HTMLElement[];
let angle = a0;
let tb = b0;
let tx = 0;
let target = { x: 0, y: 0 };
let last = 0;
let frame = 0;
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
if (root.dataset.kv !== 'on') {
last = 0;
return;
}
const dt = last ? Math.min(64, now - last) / 1000 : 0;
last = now;
angle += ((2 * Math.PI) / Math.max(4, duration)) * Math.max(0.05, speed) * dt;
tx += (target.x * 0.35 - tx) * Math.min(1, dt * 4);
tb += (b0 - target.y * 0.25 - tb) * Math.min(1, dt * 4);
for (let i = 0; i < all.length && i < nodes.length; i++) {
const s = place(project(all[i].p, angle + tx, tb));
nodes[i].style.transform = s.transform;
nodes[i].style.opacity = String(s.opacity);
}
};
frame = requestAnimationFrame(tick);
const move = (e: PointerEvent) => {
if (!interactive || e.pointerType !== 'mouse') return;
const r = root.getBoundingClientRect();
target = { x: (e.clientX - r.left) / r.width - 0.5, y: (e.clientY - r.top) / r.height - 0.5 };
};
const leave = () => {
target = { x: 0, y: 0 };
};
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', leave);
return () => {
cancelAnimationFrame(frame);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerleave', leave);
};
// The dot list follows from these props
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dots, seed, duration, speed, rotate, tilt, interactive, arcs.length, pins.length]);
const gap = 2.4 / Math.max(0.1, speed);
const cycle = round(Math.max(1, arcs.length) * gap + 2 / Math.max(0.1, speed), 2);
const share = ((2.6 / Math.max(0.1, speed)) / cycle) * 100;
const css = `${SCENE_CSS}
${keyframes(`${sid}-arc`, [[0, 'opacity:.16;transform:scale(.6)'], [share * 0.06, 'opacity:.16;transform:scale(.6)'], [share * 0.18, 'opacity:1;transform:scale(1.3)'], [share * 0.55, 'opacity:1;transform:scale(1)'], [share, 'opacity:.16;transform:scale(.6)'], [100, 'opacity:.16;transform:scale(.6)']])}
${keyframes(`${sid}-pulse`, [[0, 'opacity:.75;transform:scale(1)'], [70, 'opacity:0;transform:scale(3.2)'], [100, 'opacity:0;transform:scale(3.2)']])}
.${sid}:not([data-kv="off"]) .${sid}-arc { animation: ${sid}-arc ${cycle}s linear var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-pulse { animation: ${sid}-pulse 2.8s ease-out var(--d) infinite both; }
.${sid} .${sid}-d { position:absolute; left:50%; top:50%; }
.${sid} .${sid}-d > i { position:absolute; border-radius:999px; }
`;
let pinIndex = 0;
return (
<div className={cn('relative mx-auto w-full', className)} style={{ maxWidth: size }}>
<div
ref={play.ref}
data-kv={play.state}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(sid, 'relative aspect-square w-full [container-type:inline-size]')}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<div className="absolute rounded-full" style={{ inset: `${round(50 - R * 1.24, 2)}%`, background: 'radial-gradient(closest-side, color-mix(in oklab, var(--primary) 20%, transparent) 70%, color-mix(in oklab, var(--primary) 6%, transparent) 85%, transparent)' }} />
<div
className="absolute rounded-full"
style={{
inset: `${50 - R}%`,
background: 'radial-gradient(circle at 34% 28%, color-mix(in oklab, var(--primary) 14%, var(--surface-2)), color-mix(in oklab, var(--primary) 5%, var(--background)) 64%, var(--background) 100%)',
boxShadow: 'inset 0 0 0 1px color-mix(in oklab, var(--foreground) 9%, transparent)',
}}
/>
<div ref={layer} className="absolute inset-0">
{all.map((d, i) => {
const s = place(project(d.p, a0, b0));
if (d.kind === 'pin') {
const k = pinIndex++;
return (
<span key={i} className={`${sid}-d`} style={s}>
<i className={`${sid}-pulse`} style={{ width: '3.6cqw', height: '3.6cqw', left: '-1.8cqw', top: '-1.8cqw', border: '0.32cqw solid var(--primary)', opacity: 0, '--d': `${round(k * 0.5, 2)}s` } as React.CSSProperties} />
<i style={{ width: '1.9cqw', height: '1.9cqw', left: '-0.95cqw', top: '-0.95cqw', background: 'var(--primary)', boxShadow: '0 0 0 0.5cqw color-mix(in oklab, var(--primary) 28%, transparent), 0 0 3cqw color-mix(in oklab, var(--primary) 70%, transparent)' }} />
</span>
);
}
const w = d.kind === 'land' ? 0.82 : d.kind === 'arc' ? 0.95 : 0.42;
const bg = d.kind === 'land' ? 'color-mix(in oklab, var(--primary) 50%, var(--foreground))' : d.kind === 'arc' ? 'var(--primary)' : 'color-mix(in oklab, var(--foreground) 24%, transparent)';
return (
<span key={i} className={`${sid}-d`} style={s}>
<i
className={d.kind === 'arc' ? `${sid}-arc` : undefined}
style={{
width: `${w}cqw`,
height: `${w}cqw`,
left: `${round(-w / 2, 3)}cqw`,
top: `${round(-w / 2, 3)}cqw`,
background: bg,
...(d.kind === 'arc' ? ({ boxShadow: '0 0 1.2cqw color-mix(in oklab, var(--primary) 75%, transparent)', '--d': `${round((d.arc ?? 0) * gap + (d.step ?? 0) * 0.9, 2)}s` } as React.CSSProperties) : null),
}}
/>
</span>
);
})}
</div>
<div
className="pointer-events-none absolute rounded-full"
style={{ inset: `${round(50 - R - 0.3, 2)}%`, background: 'radial-gradient(circle at 30% 24%, color-mix(in oklab, #fff 8%, transparent), transparent 44%), radial-gradient(circle at 50% 50%, transparent 64%, color-mix(in oklab, var(--background) 50%, transparent) 100%)' }}
/>
</div>
{controls && (
<div className="absolute bottom-0 right-0">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
);
}Häufige Fragen
Wie installiere ich Globus?
Mit aiw add @ai-website/globe. Der Befehl legt die Datei components/ui/globe.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Globus?
3 Varianten, Bögen, Pins und Ruhiger Globus.
Kann ein KI-Assistent Globus einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Globus mit aiw_components und installieren sie mit aiw_add @ai-website/globe.