Prism
← All primitives

DottedGlobe

Map

A 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.json
Dependencies: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>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.