DEV Community

grepzero
grepzero

Posted on

A spinning globe in 471 lines: no WebGL, no three.js, no CDN

I needed a globe for an internet weather page: live outage markers on a slowly turning Earth. The obvious answer is three.js or globe.gl. I could not use either, and the reasons might apply to you too:

  • The page ships with a strict Content-Security-Policy. No runtime fetches from CDNs, which kills the libraries that pull textures at runtime.
  • It is a statically exported Next.js site. I wanted zero new dependencies and no WebGL context to babysit.

It turns out a 2D canvas is plenty. Here is what I learned.

Orthographic projection is 20 lines

A globe seen from space is an orthographic projection: convert lat/lon to a 3D unit vector, rotate it, and draw x and y. Points with z > 0 face you; the rest are behind the planet.

function toSphere(lat, lon, spin) {
  const phi = lat * DEG;
  const theta = (lon + spin) * DEG;
  const cp = Math.cos(phi);
  let x = cp * Math.sin(theta);
  let y = Math.sin(phi);
  let z = cp * Math.cos(theta);
  // tip the north pole toward the viewer
  return {
    x,
    y: y * Math.cos(TILT) - z * Math.sin(TILT),
    z: y * Math.sin(TILT) + z * Math.cos(TILT),
  };
}
Enter fullscreen mode Exit fullscreen mode

Increment spin in a requestAnimationFrame loop and the world turns.

The data can live in your bundle

Natural Earth's coastlines are public domain. Simplified to 0.35 degrees, all the world's land is 74 polygon rings totaling about 2,000 points. That is small enough to commit as a generated source file and ship in the bundle. No fetch, no texture, nothing for the CSP to block.

The hard part: continents rotating off the edge

Naively skipping back-facing points tears your polygons open: a continent half past the horizon becomes a scribble. The fix is to collapse back-facing points onto the limb (the circle's edge) instead of dropping them:

const proj = (lat, lon) => {
  const p = toSphere(lat, lon, spin);
  if (p.z > 0) return { x: cx + R * p.x, y: cy - R * p.y };
  const m = Math.hypot(p.x, p.y);
  return { x: cx + (R * p.x) / m, y: cy - (R * p.y) / m };
};
Enter fullscreen mode Exit fullscreen mode

Every ring stays a closed shape, so fills work, and land slides off the edge of the world the way it should. Clip everything to the globe circle and the collapsed points are invisible.

A real day/night terminator for one dot product

The terminator is not a cosmetic gradient; it is where the sun's elevation crosses the horizon. For every surface point, shade by the dot product of its normal with the subsolar direction. Compute the subsolar point from the current UTC time (declination from day of year, longitude from the hour), and the night side lands exactly where it is right now in the real world.

Doing that per output pixel would be a few hundred thousand samples per frame. Instead I sample it into an 80x80 offscreen buffer and let the browser's bilinear upscale smooth it. The terminator gets a soft dusk band from a smoothstep between two elevation thresholds, and the whole shading pass costs a few thousand multiply-adds. The subsolar point only moves 0.125 degrees in 30 seconds, so it is refreshed on a timer, not per frame.

Small things that mattered

  • Cap devicePixelRatio at 2. Retina at DPR 3 triples your pixel work for no visible gain at this size.
  • prefers-reduced-motion stops the auto-spin. Drag still works.
  • Pause the spin on hover. People want to read the marker they are aiming at.
  • Outage markers are just more lat/lon points through the same projection, with alpha faded by z so they dim as they rotate away.

The result is 471 lines of TypeScript, one <canvas>, and no dependencies. You can see it live, with real outage and traffic-anomaly data plotted on it, at https://synapse.mdlc.ai/weather

If your CSP, bundle budget, or dependency allergy has been keeping you off the 3D-globe libraries: the 2D canvas path is shorter than you think.

Top comments (0)