← All primitives
DottedGlobe
MapA 3D Earth: continents as a field of glowing dots sampled from open Natural Earth data, great-circle arcs between cities, and a Fresnel atmosphere. No tiles, no token; composes with bloom.
Imports world-atlas land-110m.json for continents (bundled, free, no token). Render via next/dynamic ssr:false.
$
npx shadcn@latest add https://prism.icglabs.co/r/dotted-globe.jsonDependencies:three@react-three/fiber@react-three/drei@react-three/postprocessingtopojson-clientd3-geoworld-atlas
View raw manifest →components/prism/DottedGlobe.tsx
"use client";
import { useMemo, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import * as THREE from "three";
import { feature } from "topojson-client";
import { geoContains } from "d3-geo";
import landTopo from "world-atlas/land-110m.json";
import { useDeviceTier } from "@/lib/useDeviceTier";
import { AutoDpr, PauseWhenOffscreen } from "@/components/showcase/CanvasPerf";
/**
* DottedGlobe — a 3D Earth: continents rendered as a field of glowing dots
* (sampled from Natural Earth land data, no tiles/token), glowing great-circle
* arcs between cities, and a back-side Fresnel atmosphere. All emissive, so it
* blooms through Prism's post pipeline. Slow auto-rotate; drag to orbit.
*/
const R = 2;
// Build the land polygon once (pure data; topojson → GeoJSON). `never` casts keep
// tsc happy without resolving the topojson-specification types.
const topo = landTopo as unknown as { objects: { land: object } };
const LAND = feature(topo as never, topo.objects.land as never) as never;
function latLngToVec3(lat: number, lng: number, r = R) {
const phi = ((90 - lat) * Math.PI) / 180;
const th = ((lng + 180) * Math.PI) / 180;
return new THREE.Vector3(
-r * Math.sin(phi) * Math.cos(th),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(th)
);
}
function Dots({ color, samples }: { color: string; samples: number }) {
const geo = useMemo(() => {
const pos: number[] = [];
const golden = Math.PI * (1 + Math.sqrt(5));
for (let i = 0; i < samples; i++) {
const t = i / samples;
const phi = Math.acos(1 - 2 * t);
const theta = golden * i;
const lat = 90 - (phi * 180) / Math.PI;
const lng = (((theta * 180) / Math.PI) % 360) - 180;
if (geoContains(LAND, [lng, lat])) {
const v = latLngToVec3(lat, lng, R + 0.006);
pos.push(v.x, v.y, v.z);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
return g;
}, [samples]);
return (
<points geometry={geo}>
<pointsMaterial size={0.028} color={color} sizeAttenuation toneMapped={false} />
</points>
);
}
function Atmosphere({ color }: { color: string }) {
const mat = useMemo(
() =>
new THREE.ShaderMaterial({
side: THREE.BackSide,
blending: THREE.AdditiveBlending,
transparent: true,
depthWrite: false,
uniforms: { c: { value: new THREE.Color(color) } },
vertexShader: `varying vec3 vN; void main(){ vN=normalize(normalMatrix*normal); gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader: `varying vec3 vN; uniform vec3 c; void main(){ float i=pow(0.72-dot(vN,vec3(0.0,0.0,1.0)),3.0); gl_FragColor=vec4(c,1.0)*i; }`,
}),
[color]
);
return (
<mesh material={mat}>
<sphereGeometry args={[R * 1.18, 48, 48]} />
</mesh>
);
}
function Arc({ a, b, color }: { a: [number, number]; b: [number, number]; color: string }) {
const obj = useMemo(() => {
const s = latLngToVec3(a[0], a[1]);
const e = latLngToVec3(b[0], b[1]);
const mid = s.clone().add(e).multiplyScalar(0.5).setLength(R * 1.5);
const pts = new THREE.QuadraticBezierCurve3(s, mid, e).getPoints(60);
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineDashedMaterial({
color: new THREE.Color(color),
dashSize: 0.18,
gapSize: 0.12,
transparent: true,
toneMapped: false,
});
const line = new THREE.Line(geo, mat);
line.computeLineDistances();
return line;
}, [a, b, color]);
useFrame(({ clock }) => {
(obj.material as THREE.LineDashedMaterial).opacity =
0.4 + 0.45 * Math.sin(clock.elapsedTime * 1.4 + a[1] * 0.1);
});
return <primitive object={obj} />;
}
const ARCS: { a: [number, number]; b: [number, number] }[] = [
{ a: [37.77, -122.42], b: [40.71, -74.0] },
{ a: [51.5, -0.12], b: [35.68, 139.69] },
{ a: [-33.86, 151.2], b: [1.35, 103.82] },
{ a: [48.85, 2.35], b: [-23.55, -46.63] },
];
function Scene({ samples, colors }: { samples: number; colors: [string, string, string] }) {
const g = useRef<THREE.Group>(null);
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
useFrame((_, d) => {
if (g.current && !reduced) g.current.rotation.y += d * 0.06;
});
return (
<group ref={g}>
<mesh>
<sphereGeometry args={[R, 64, 64]} />
<meshBasicMaterial color="#0a1120" />
</mesh>
<Dots color={colors[0]} samples={samples} />
<Atmosphere color={colors[1]} />
{ARCS.map((arc, i) => (
<Arc key={i} a={arc.a} b={arc.b} color={colors[2]} />
))}
</group>
);
}
export function DottedGlobe({
colors = ["#67e8f9", "#38bdf8", "#f0abfc"],
className,
}: {
/** [land dots, atmosphere, arcs] hex. */
colors?: [string, string, string];
className?: string;
}) {
const q = useDeviceTier();
const samples = Math.round(11000 * q.scale);
return (
<div className={className} style={{ width: "100%", height: "100%" }}>
<Canvas dpr={q.dpr} gl={{ antialias: true, powerPreference: "high-performance" }} camera={{ position: [0, 0, 6], fov: 45 }}>
<color attach="background" args={["#05070f"]} />
<Scene samples={samples} colors={colors} />
<OrbitControls enableZoom={false} enablePan={false} autoRotate={false} />
<AutoDpr />
<PauseWhenOffscreen />
{q.bloom && (
<EffectComposer>
<Bloom intensity={1.1} luminanceThreshold={0.12} luminanceSmoothing={0.9} mipmapBlur radius={0.7} />
</EffectComposer>
)}
</Canvas>
</div>
);
}