DEV Community

Furiosa Studio
Furiosa Studio

Posted on • Originally published at deathvault.app

Rendering historical events on a Three.js globe with React Three Fiber

The hard part isn't the dots

Plotting points on a 3D globe is the easy afternoon. Making them readable — labels you can actually parse, markers that don't z-fight into a shimmering mess, camera moves that frame an event instead of flinging you into space, and all of it holding 60fps on a mid-range phone — is where the real work lives. Here is how that breaks down.

Lat/long to a point on the sphere

The core conversion is spherical-to-cartesian. The two gotchas: latitude runs from the equator but the polar angle runs from the pole, and longitude needs a sign flip to match most equirectangular textures.

function latLngToVec3(lat, lng, radius = 1) {
  const phi = (90 - lat) * (Math.PI / 180);   // polar angle from +Y
  const theta = (lng + 180) * (Math.PI / 180); // azimuth, texture-aligned
  return new THREE.Vector3(
    -radius * Math.sin(phi) * Math.cos(theta),
    radius * Math.cos(phi),
    radius * Math.sin(phi) * Math.sin(theta)
  );
}
Enter fullscreen mode Exit fullscreen mode

If your markers land in the ocean off the coast of where they should be, you have a sign or offset wrong here — not in your data.

React Three Fiber, not raw Three.js

R3F lets the scene be a function of state. When the selected event or the timeline year changes, the globe re-renders declaratively instead of you hand-mutating a scene graph inside a useEffect. You keep React's data flow and lose the imperative bookkeeping.

<Canvas camera={{ position: [0, 0, 2.6], fov: 45 }}>
  <ambientLight intensity={0.6} />
  <directionalLight position={[5, 3, 5]} />
  <mesh>
    <sphereGeometry args={[1, 64, 64]} />
    <meshStandardMaterial map={earthTexture} />
  </mesh>
  <Events data={events} />
</Canvas>
Enter fullscreen mode Exit fullscreen mode

drei gives you OrbitControls, Html, and Billboard so you are not reinventing camera math.

Keeping labels legible as the globe turns

Two problems compound as the sphere rotates: labels rotate with their anchor and become unreadable, and markers on the far side bleed through. Billboarding fixes the first — the label always faces the camera. For the second, hide anything whose normal points away from the viewer:

useFrame(({ camera }) => {
  const toCam = camera.position.clone().sub(worldPos).normalize();
  const facing = toCam.dot(worldNormal) > 0;
  label.visible = facing;
});
Enter fullscreen mode Exit fullscreen mode

Cap visible labels to what fits. We show labels only for events near the camera's focus and within the current timeline window; the rest stay as bare markers until selected. A globe with 200 simultaneous labels is noise, not information — and with this data, noise is the wrong register entirely.

Wiring markers to the timeline scrubber

Selecting an event (from a marker click or the scrubber) should animate the camera to frame it, not teleport. Compute a target position along the vector from the globe center through the event, then ease toward it each frame:

useFrame(() => {
  if (!target) return;
  camera.position.lerp(target, 0.08);
  controls.target.lerp(eventPos, 0.08);
  controls.update();
});
Enter fullscreen mode Exit fullscreen mode

A lerp factor around 0.06–0.1 reads as deliberate. Drive target from the same state the scrubber writes to, so dragging the year forward sweeps the camera across the events of that period in order.

Performance: one draw call, not a thousand

Each marker as its own mesh means a draw call each, and a phone GPU will stall. Use InstancedMesh: one geometry, one material, N transforms.

const ref = useRef();
useLayoutEffect(() => {
  const m = new THREE.Matrix4();
  events.forEach((e, i) => {
    m.setPosition(latLngToVec3(e.lat, e.lng, 1.01));
    ref.current.setMatrixAt(i, m);
  });
  ref.current.instanceMatrix.needsUpdate = true;
}, [events]);

return (
  <instancedMesh ref={ref} args={[null, null, events.length]}>
    <sphereGeometry args={[0.012, 8, 8]} />
    <meshBasicMaterial />
  </instancedMesh>
);
Enter fullscreen mode Exit fullscreen mode

Budget rules that held up on real devices: keep the steady-state under ~100 draw calls, cap pixel ratio with dpr={[1, 2]}, use low-poly marker geometry (8 segments is plenty at that scale), and gate the render loop with frameloop="demand" so you only redraw when something actually moves.

Making the data citable for LLMs

A canvas is opaque to crawlers and language models — they cannot read a WebGL buffer. So every figure rendered on the globe also exists as server-rendered, sourced text. Each event carries structured metadata (Event / Place schema.org JSON-LD with startDate, location, and a numeric toll) and a visible citation to the source for that figure. Death tolls vary across historiography, so we render a range with its source rather than a single confident number. That way a model summarizing the period quotes a figure it can attribute, and a reader can trace it back. The GEO win and the editorial integrity are the same discipline.

Closing

This is the engine behind DeathVault and its sibling PlagueAtlas — free, interactive maps of historical death tolls from wars, disasters, and pandemics, built to be read carefully rather than scrolled past. The techniques above are general; the care taken with the numbers is not optional.

Built by Furiosa Studio.

Top comments (0)