Drawing the Real Night Sky in a BrowserSection 1 of 10

The Big Picture: No Server-Side Code

What Asterarium actually draws from real data, and how one constraint — not a single line of code runs on a server — shapes everything from the layer boundaries to the order of work in each frame.


This section puts up front what every later section assumes. It covers what makes Asterarium's sky "real"; the constraint of having no server-side code, and the five consequences that follow from it; the libraries that ship to the browser; how the code is layered, and which way the dependencies point; and how all per-frame work is gathered into one place. At the end it names the three principles that keep surfacing in the sections that follow.

What Makes the Sky "Real"

Asterarium is a web planetarium that renders the night sky in 3D, for any point on Earth and any moment in time. The arrangement of the stars comes from a real catalogue, AT-HYG — the long-standing HYG catalogue extended with observations such as Tycho photometry and Gaia parallaxes, published openly — trimmed to the roughly 38,000 stars brighter than 8. There are two faint ends here, at two different values. The faint end of the catalogue is magnitude 8; the faint end of what is actually drawn, under a sky free of light pollution, is magnitude 6.8. The catalogue reaches 1.2 magnitudes deeper, and that gap is deliberate headroom. It has no assigned use today: it is simply enough slack that the drawn end could later move a little deeper without the catalogue having to be rebuilt. The 6.8 is a value tuned by eye. The catalogue is split into a bright tier and a full one, and which of the two a device loads depends on how much work it can afford. Magnitude 6.8 is only ever reached at the upper quality levels, the ones that load the whole catalogue. How that split is made, and how the choice between the tiers is taken, is in the section "The Data Pipeline". The positions of the Sun, Moon and planets are computed for the requested instant, and the brightness of the sky is not painted artwork but the output of an simulation.

That has a practical consequence: the screen it produces can be checked against another astronomy simulator or against published almanac values. The question becomes "is the of Sirius right?" rather than "does this look starry?". How the tests pin the output down, both against known-correct values and against regression values, is in the section "Verification".

The site itself has three kinds of pages: as seen from the ground, a set of standalone 3D scenes, and long-form text pages. The route list lives in a single place in the code, where every page is registered as a Japanese/English pair. The pairing is what lets the type check catch a page that was only ever built in one of the two languages.

  • The main sky (/) — the view of an observer standing on the ground, looking around. Stars, constellation lines, the Milky Way, the Sun, Moon, planets and , all seen through the atmosphere.
  • — eight in all. Two of them stay at the Moon: a Moon globe (/moon/, real phase and ) and a moonwalk (/moonwalk/, one-sixth gravity). Three take the wider Solar System: a Mars globe (/mars/, the central meridian right now), an (/orrery/, the planetary orbits seen from above, driven by a real ) and eclipses (/eclipse/, paths of totality traced on a map). Two leave the Solar System altogether: a deep field (/deepfield/, flying through real JWST galaxies) and an interstellar flight (/starflight/, stars at their true distances, constellations falling apart). The last is a projector (/projector/, an optical projector turning on four axes: latitude, , the diurnal drive and the annual drive). None of them shares 3D scene code with the main sky or with each other. What they do share is the page chrome — the navigation and the other parts every page carries — together with shared parts that contain no .
  • Text pages — the introduction (/about/) and the user guide (/manual/, which has ten topic chapters under it). This technical article is a text page too, and it sits under the introduction, at /about/tech/.

The One Big Constraint: Nothing Runs on a Server

To be precise, "no server" does not mean there is nothing serving the site: the files are delivered by Cloudflare's edge servers. What is missing is any program that runs when a request arrives. There are no API routes, no database and no server-side rendering; what ships is the set of static files the build has already produced. That single fact shapes the design more than anything else. The Next.js configuration file, next.config.mjs, is barely a dozen lines long, simply because a project that uses none of the server-side features has almost nothing to configure.

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  images: { unoptimized: true },
  trailingSlash: true,
  env: {
    // Base URL for large assets served from Cloudflare R2 (custom domain).
    // Empty string = fall back to same-origin /public assets.
    NEXT_PUBLIC_R2_BASE_URL: process.env.NEXT_PUBLIC_R2_BASE_URL ?? '',
  },
}

export default nextConfig
next.config.mjs, in full

output: 'export' is Next.js's mode: the build emits a static site into out/, made only of HTML, JavaScript, CSS and data files. Turning image optimisation off and forcing a trailing slash on every URL are both consequences of that static hosting. The env entry is there for a different reason: it says where the large assets — textures and the star binaries — are fetched from, namely . Beyond static export itself, then, three settings remain: image optimisation turned off, the trailing slash, and the R2 base URL baked in as an environment variable. What those three mean on the delivery side is taken up in the section "Delivery and the Standalone Pages".

From there the consequences cascade, in the five ways below.

  1. All astronomy runs in the browser. Star positions, Sun/Moon/planet positions, sunrise and sunset times — there is nothing to ask. The calculation library ships to the client and the work is done in JavaScript.
  2. Persistence is localStorage, file export/import and the URL, nothing else. There are no user accounts and no server-side storage.
  3. Shared links are query strings. A dynamic path like /share/abc123 cannot be pre-generated, so the state is encoded as ?lat=…&t=…&az=… and read back by the page itself.
  4. Japanese and English are separate routes. / and /en/ are pre-generated separately. Swapping only the dictionary inside a single route would also work, but then each language would have no URL of its own, and a search engine could not see the two versions as two pages. The main sky is the one exception. / opens in whichever language the viewer last chose with the on-screen language switch — the choice is kept in the browser's localStorage, and on a first visit it is Japanese. /en/, by contrast, is an entry point pinned to English, and that is the page search engines are shown as the English version. The exception exists so that switching language while looking at the sky is not undone by the next reload, which would otherwise drop back to Japanese.
  5. Every 3D scene is drawn on the client, and fetched late. This setup has no path by which a server could draw it. On top of that, the scenes are large to download, so each one is fetched only when the page that needs it is opened.

The split between the two languages has a tail. The wording itself lives in two dictionaries, one per language, and the two are not equal partners: the Japanese dictionary is the reference, and the English one is declared, in its type, as having exactly the key set of the Japanese one. Because the declaration runs in that one direction, both a key missing from English and a key only English has fail the type check TypeScript runs before the build.

Of those five, the split between Japanese and English routes has now been settled in full, in the account of routes and dictionaries above: it is the one consequence with no section of its own. The remaining four — astronomy in the browser, persistence in localStorage and elsewhere, shared links as query strings, and the late loading of the 3D scenes — each get a later section. Of those four, the late loading of the 3D scenes is the one that follows from more than the missing server: sheer download size decided it too. That consequence fits in a single line of source: every 3D scene is pulled in with dynamic(…, { ssr: false }). As a side effect, that import becomes a code-splitting point, so the scene ends up in a chunk of its own.

That split point is the main tool for keeping the standalone 3D pages isolated from one another. The split point appears on its own, but it can also disappear on its own: a static import added later is resolved at build time into the same chunk as the file that wrote it, which routes around the lazy-loading split point and joins the two sides again, and a bundler may hoist code shared by several pages into one common chunk. So the built JavaScript is read back, and a check confirms that strings unique to one scene — a name only that scene's has, or a DOM marker only that page sets — never turn up in a chunk some other page loads. The section "Delivery and the Standalone Pages" describes what that check actually reads.

The Stack

The code that ships to the browser depends on fourteen external libraries. By role:

  • Next.js 16 — routing and build, used strictly in static-export mode.
  • React 19 — builds the interface. Anything that changes is deliberately kept outside it.
  • three.js 0.185 — the 3D library on top of .
  • — the bridge that lets a three.js scene be written as React components.
  • @takram/three-atmosphere — a physically based simulation of atmospheric scattering. It owns the colour of the sky, , and the dimming near the horizon. Takram is the organisation that publishes the library, not a term of art.
  • astronomy-engine 2.1 — body positions, rise and set times, lunar phase.
  • — where state lives: the settings, observer location and display toggles that only ever change discretely.
  • @js-temporal/polyfill (a date/time API polyfill), @photostructure/tz-lookup (latitude and longitude to time zone), and geomagnetism (a geomagnetic model, used to correct the magnetic heading a device sensor reports to true north).
  • The remaining four are supporting parts: react-dom (the counterpart that puts React's output into the real DOM), @react-three/drei (a toolbox of parts most 3D scenes need), and postprocessing plus @react-three/postprocessing (the implementation of such as , and the bridge that makes it usable from React Three Fiber).

The development-only dependencies include TypeScript in strict mode, Vitest, Playwright, for , tsx to run the data-generation scripts, and sharp for image processing. There are two test environments: Vitest runs in a plain Node environment with no DOM, and Playwright runs against the built out/ directory, served statically rather than a dev server.

Layers, and Which Way They Point

The code is layered so that the further upstream a layer sits, the more framework-independent and pure it is, and the further downstream it sits, the more it depends on React and three.js. Every import points upstream: a layer may import only the layers above it, and never knows about the layers below. In the table below, the top rows are the upstream, pure end and the bottom rows the downstream end, closest to the screen. The "may import" column gives representative examples rather than an exhaustive list; the "must not import" column gives the main prohibitions on that layer. The build-time data-generation scripts sit outside that stack entirely: none of their code reaches the browser.

LayerRoleMay importMust not import
Pure computationCoordinate transforms, sidereal time, twilight, lunar phase — calculation onlyastronomy-engine, its own typesthree / React / the store / DOM — none of them
Data loading and conversionLoading and converting the star catalogue, cities, time zonesfetch, date/time APIs, time-zone lookupthree / React / the store
StateThe one home for settings and for timeZustand, used only inside the storethree, and the layers downstream of it
3D sceneAssembling the scene with React Three Fiber and updating it each frameAll of the above, plus three / React Three Fiber / takramThe UI layer
UIThe controls laid over the screen (the HUD), panels, modal windowsState, the pure computation layer, and the 3D scene layer through its published entry points onlyThe scene's internals
Build-time data generationTurning raw source data into shipped files at build timeNode, a CSV parser, sharp(no restriction)

Three details do not fit in the table. Within the state layer, the clock that owns time is plain TypeScript and touches neither three nor React. The build-time data-generation scripts may use anything Node offers, precisely because nothing that ships to the browser imports them. And when the UI layer wants to move something in the scene, it either writes a value into the store or calls one of the small entry points the scene exposes, such as the one that accepts a request to move the camera. The section "State and the Frame Loop" follows that path in detail.

The strongest prohibition sits on the and the data layer: at runtime they may not import three, React, or the store (type-only imports are fine, since they vanish at build). The reason is that astronomy is mathematically deterministic and depends on neither the UI nor the renderer, so it can be tested without drawing anything. Let the boundary leak and that goes away: a test running without a DOM can no longer load the file, and a fatal mix-up in a coordinate convention — north for south, east for west — stops being catchable until someone actually launches the app and looks.

All Per-Frame Work in One Place

Any 3D application needs code that updates something on every frame. React Three Fiber writes that with a React hook called useFrame — and since any component may call it, left alone the code that advances time scatters across the scene and the order of updates stops being guaranteed. In the main sky, SkyController — a React component mounted inside the 3D scene — is the single per-frame hub, and all per-frame updates start there.

/**
 * SkyController — the single per-frame hub for the scene.
 *
 * Each frame:
 *   0. tourSequencer.tick(delta*1000)   — drive time/camera while tour-driven
 *   1. clock.advance(delta*1000)        — advance sim time
 *   2. atmosphere.updateByDate(date)    — sun/moon/sky + inertialToECEF for date
 *   3. CelestialGroup.matrix = worldToECEF⁻¹ · inertialToECEF  — orient stars
 *   4. bodyState(body) topocentric alt/az → world NUE dir for sun/moon/planets
 *   5. nightFactor from sun altitude → star + planet visibility
 *
 * // …
 *
 * Allocation-free: all Matrix4/Vector3 instances are reused across frames.
 */
The header comment of the component that owns all per-frame work

, and — three of the names in that comment — are three different answers to the question of what a direction is measured against, and each frame moves the data from one to the next. Two more names appear there: inertial is the inertial frame, the non-rotating reference in which star positions are recorded, and world is the scene's , whose axes are the NUE directions. How the rotation that connect them are built is in the section "Coordinate Frames and the Orientation of the Sky".

The order matters. It is a chain of dependencies: step 1 advances the time, step 2 hands that date to the atmosphere, step 3 uses the matrix the atmosphere computed to orient the , and only then does step 4 place the Sun, Moon and planets. Reorder it in a way that breaks one of those dependencies and something ends up using last frame's value. Step 0, the one placed ahead of the clock, runs only while a is playing, when the playback engine drives time and camera. There is exactly one exception to the numbering: the quoted comment puts the body positions of step 4 ahead of the of step 5, while the implementation computes the night factor first. The two do not depend on each other, so the difference in order does not change the result. The night factor of step 5 is a value between 0 and 1 derived from the Sun's altitude: it decides together how visible the stars, the constellation lines, the Milky Way and the deep-sky objects are, and it dims the Moon in daylight. Planets alone tolerate a bright twilight. Many of them are brighter than most stars, and Venus, in good conditions, is visible to the naked eye in a daylit sky. Rather than putting planets on the same band as the stars, then, they get a different, brighter pair of thresholds on the Sun's altitude: stars come up between 0 and -18 degrees, planets between +2 and -10 degrees, which is to say before the Sun has even set. None of it can be computed before the Sun has been placed.

The "allocation-free" rule in the same comment is another demand specific to code that runs sixty times a second. Every matrix or vector allocated inside it adds to what the garbage collector must eventually sweep, which can show up as intermittent dropped frames. So the scratch instances are allocated once and shared across frames.

The sections that follow all tackle different problems, and the same three principles keep surfacing. All three already have a concrete example here. Where a piece of state lives is decided by how often it changes, not by what it means: the time, which changes sixty times a second, lives outside React in a module singleton. Every value has exactly one owner allowed to write it: only the single per-frame hub advances time. Dependencies flow one way, the sharpest case being the pure calculation layer, which may import neither three nor React. The closing section "Design Principles, in Summary" adds two more — deliberate duplication in the name of isolation, and writing each bug that actually happened into the code — and pulls all five together.