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 A number for how bright an object looks: smaller is brighter, and a difference of one magnitude is a factor of about 2.512 in brightness. Under a dark sky the naked eye reaches roughly 6 to 6.5. 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 atmospheric scattering — Sunlight bouncing off the molecules and particles of the air and spreading in every direction. Blue light scatters most, which is why the daytime sky is blue, and a low Sun, whose light crosses far more air, turns red. 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 The angle of an object above the observer's horizon: 0 degrees at the horizon, 90 degrees straight overhead, negative when the object is below it. 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: In this article, the name for the night-sky scene that opens on the site's front page. It draws the stars, constellation lines, the Milky Way, the Sun, Moon and planets through atmospheric scattering, seen from an observer standing on the ground. The name is there to tell it apart from the eight standalone 3D pages, separate pages such as the Moon globe and the projector which share no 3D code with it or with each other. 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 deep-sky object — A collective name for the diffuse things beyond the solar system that are not single stars: nebulae, star clusters and galaxies. Most of them need binoculars or a telescope., all seen through the atmosphere. - standalone 3D page — In Asterarium, a 3D page that shares no scene code with the main sky or with the other such pages, and there are eight of them: the Moon globe, the moonwalk, the Mars globe, the deep field, the orrery, the interstellar flight, the eclipse viewer and the projector. Each one ships as its own bundle of code, so a visitor downloads only the page they opened. — eight in all. Two of them stay at the Moon: a Moon globe (
/moon/, real phase and The slight rocking of the Moon as seen from Earth. Because of it, about 59 per cent of the lunar surface becomes visible over time.) and a moonwalk (/moonwalk/, one-sixth gravity). Three take the wider Solar System: a Mars globe (/mars/, the central meridian right now), an A mechanical model that shows the planets circling the Sun. Asterarium borrows the name for a standalone page that looks down on the planetary orbits and where each planet is now. (/orrery/, the planetary orbits seen from above, driven by a real A table of where a body is at each moment, or the model that computes those positions. Two different ephemerides give slightly different positions for the same moment.) 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 slow circling of Earth's rotation axis, one turn in about 26,000 years. It carries the vernal equinox, the zero point of sky coordinates, along with it, so every star's coordinates drift as well., 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 The standard JavaScript library layered over WebGL, the browser 3D interface. It expresses a picture as scenes, cameras, geometries and materials instead of raw draw calls.. - 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 nextConfigoutput: 'export' is Next.js's Building the whole site into HTML and JavaScript files ahead of time, so nothing is assembled per request; delivery is only the handing out of files that already exist. 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 Cloudflare's object storage, the place for large files such as textures and binaries. Unlike most comparable services it charges nothing for data leaving the bucket.. 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.
- 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.
- Persistence is localStorage, file export/import and the URL, nothing else. There are no user accounts and no server-side storage.
- Shared links are query strings. A dynamic path like
/share/abc123cannot be pre-generated, so the state is encoded as?lat=…&t=…&az=…and read back by the page itself. - 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. - 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 A value handed into a shader, the small program running on the GPU, that stays constant for the whole draw. The current time or the direction of the Sun are typical. name only that scene's A small program that runs on the GPU, the chip built for rendering, to compute where a vertex goes or what colour a pixel takes. It runs once for every vertex or every pixel it is handed. 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 frame — One image of a moving picture. Smooth motion needs roughly 60 of them a second, which leaves about 16 milliseconds to build each one. A coordinate frame is a different use of the same word. is deliberately kept outside it.
- three.js 0.185 — the 3D library on top of The web standard that lets a page draw 3D graphics with the GPU, the chip built for rendering, straight from the browser. No plugin is involved..
- A library that lets a three.js scene be written as React components, so React reconciles 3D objects rather than HTML elements. — 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, The stretch of time when the Sun is below the horizon but the sky is still lit. It deepens as the Sun sinks, so an evening runs civil twilight, down to 6 degrees below the horizon, then nautical to 12, then astronomical to 18. A morning takes the same stages in reverse, from astronomical through nautical and civil to sunrise., 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.
- A small state-management library for React: it builds the store, the single place where shared state lives. Each part of the interface declares which slice of that state it uses, so it is redrawn only when that slice changes. — 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), andgeomagnetism(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), andpostprocessingplus@react-three/postprocessing(the implementation of post-processing — A full-screen pass applied to the image after the scene has been drawn. Fitting brightness to what a display can show, and adjusting colour, both happen at this stage. In this article the sky itself is drawn as one of these passes. such as The step that maps the wide range of brightness held in real ratios into the narrow range a display can actually show. The shape of the curve decides how colour and highlights read., and the bridge that makes it usable from React Three Fiber).
The development-only dependencies include TypeScript in strict mode, Vitest, Playwright, A JavaScript library for tests that generate their inputs and check a property over all of them. for property-based testing — Testing that generates many inputs automatically and checks a property that should hold for all of them, instead of listing cases one by one. A failing input is shrunk down to its smallest form., 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.
| Layer | Role | May import | Must not import |
|---|---|---|---|
| Pure computation | Coordinate transforms, sidereal time, twilight, lunar phase — calculation only | astronomy-engine, its own types | three / React / the store / DOM — none of them |
| Data loading and conversion | Loading and converting the star catalogue, cities, time zones | fetch, date/time APIs, time-zone lookup | three / React / the store |
| State | The one home for settings and for time | Zustand, used only inside the store | three, and the layers downstream of it |
| 3D scene | Assembling the scene with React Three Fiber and updating it each frame | All of the above, plus three / React Three Fiber / takram | The UI layer |
| UI | The controls laid over the screen (the HUD), panels, modal windows | State, the pure computation layer, and the 3D scene layer through its published entry points only | The scene's internals |
| Build-time data generation | Turning raw source data into shipped files at build time | Node, 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 In Asterarium, the modules that reference neither three.js, nor React, nor any state store: the astronomy maths and the code around it. They run without a browser, so testing them is a matter of handing in an input and checking the value that comes back. 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.
*/Earth-Centered, Earth-Fixed: a rectangular coordinate system locked to the turning Earth, with its origin at Earth's centre, its Z axis along the rotation axis and its X axis towards longitude zero., The name of the axis convention Asterarium's 3D scene uses. With the observer at the origin, the X axis points true north, the Y axis to the zenith and the Z axis true east. The name is the initials of North, Up and East. and Measured from a point on Earth's surface rather than from Earth's centre. For a body as near as the Moon the two directions differ by up to about one degree. — 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 One of the reference epochs that say when a set of astronomical coordinates applies: noon Universal Time on 1 January 2000. Most star catalogues give their positions in it. inertial frame, the non-rotating reference in which star positions are recorded, and world is the scene's The coordinate system that places objects in Asterarium's 3D scene: one unit is about a metre, the origin is the observer, and the axis convention is called NUE, for North, Up and East. Distances here are drawing conveniences, not real ones: stars go on a sphere of radius 1000 (about 1 km), and the Sun, Moon and planets sit outside it at two million (about 2,000 km). The layer of air the sky model draws is some 60,000 units, or 60 km, thick., whose axes are the NUE directions. How the rotation matrix — A rectangular array of numbers. In 3D it holds a coordinate transform, a rotation or a displacement, as one object, and multiplying transforms together collapses a whole chain of them into a single matrix. 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 In astronomy, an imaginary sphere centred on the observer, used to describe the direction in which something appears. It carries no distance at all: it keeps the direction to an object and discards how far away it really is. In this article the word also names the container of 3D objects standing in for it, and since one scene unit is about a metre, the stars sit on a sphere of 1000 units, a kilometre in radius., 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 In Asterarium, a saved sequence of scenes that plays back on its own, each scene recording a place, a moment, a camera direction and the display settings. One scene is called a step, and every step carries its own transition and dwell time. 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 In Asterarium, a number from 0 to 1 saying how far into night the sky has gone: 0 with the Sun on the horizon, 1 once it is 18 degrees below and the sky is fully dark. It fades the stars, the constellation lines, the Milky Way and the deep-sky markers together. The Moon stays an opaque disc, but its brightness follows this number squeezed into the range 0.25 to 1, so it shows faintly by day, while the planets come out on a separate threshold in solar altitude of their own. 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.