Timetate — a world clock built on a real-time day/night terminator

Hi all — I’ve been building Timetate, a world clock where the interface is the globe rather than a table of numbers. You add cities, and the daylight band tells you who’s awake before you message them.

The rendering side is three concentric spheres:

  • Earth (r=2, 64×64) with a custom ShaderMaterial — day, city lights, specular and normal maps, blended by the terminator.
  • Clouds (r=2.015) — plain meshStandardMaterial, additive, opacity: 0.18, depthWrite: false, rotating at 0.0001 rad/frame.
  • Atmosphere (r=2.03) — side: BackSide, additive, fresnel-only shader.

The terminator comes down to one dot product and a smoothstep:

float intensity = dot(worldNormal, sunDirection);
float blend     = smoothstep(-0.05, 0.08, intensity);

Two things I’d flag for anyone doing the same:

1. World space, not view space. The vertex shader carries both:

vNormal      = normalize(normalMatrix * normal);              // view space
vWorldNormal = normalize(vec3(modelMatrix * vec4(normal, 0.0))); // world space

sunDirection is a world vector, so every sun-dependent term uses vWorldNormal. Using normalMatrix there renders fine and looks fine — until you orbit, and the sun follows the camera.

2. The twilight band is a product of two smoothsteps.

float sunsetFactor = smoothstep(0.02, -0.02, intensity)
                   * smoothstep(-0.06, 0.02, intensity);
vec3 sunsetColor   = vec3(0.8, 0.3, 0.1) * sunsetFactor * 0.1;

One ramps down, one ramps up, and the product peaks only in the overlap — a bump function without an exp(). Note the * 0.1: at full strength the planet looks like it’s on fire.

The asymmetric range in blend (-0.05 to 0.08) is deliberate too. A symmetric band lets city lights bleed through ground that’s still lit; biasing it toward the day side fixes that.

Sun position is a plain analytic approximation, no library:

const declination = 23.44 * Math.sin((2 * Math.PI * (dayOfYear - 80)) / 365.25);
const subsolarLon = (12 - utcHours) * 15;

That ignores the equation of time, so it can be ~16 minutes off at the extremes of the year — invisible on a terminator that’s a soft band tens of pixels wide.

Country borders are line meshes with their own small shader that gets brighter on the night side, since a fixed cyan that reads well over dark ocean vanishes over city lights. Building ~200 of them blocks for a while, so they’re chunked into requestIdleCallback with a timeRemaining() > 4 guard — the globe is interactive immediately and borders fill in behind it.

Stack: React Three Fiber, Next.js App Router with static generation for the ~1000 non-globe pages, Zustand, Tailwind. Textures are five 1024px WebP maps — deliberately modest so it stays usable on mid-range phones.

Live: https://timetate.com

Happy to go deeper on any of it — especially interested if anyone has a better approach to the border-mesh build cost, that’s still the slowest part of startup.

1 Like