Drawing the Real Night Sky in a BrowserSection 4 of 10
The Data Pipeline
Stars ship as a fixed-layout binary, constellation lines and cities as JSON — all settled at build time, so the browser only loads and draws.
A site with no server-side code has to settle at build time everything it cannot compute at runtime. This page follows what that settling involves, in order. In what form do the positions, brightnesses and colours of some 38,000 stars reach the browser? The catalogue carries no colour at all, so where does a star get its blue-white or orange tint? How is a heavy catalogue split across two loading stages? How are the constellation lines and the city list produced, and what discipline binds them? And finally, how are the provenance and licences of all this data handled?
One rule: decide it at build time
Every dataset is finalised at build time. Build-only scripts read the raw catalogues and dumps and write their products into public/data/. The browser repacks those bytes into the layout the GPU wants and scales the directions onto 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., but it computes no new values: it only loads and draws.
That rule splits the code in two. One half runs in the browser, and the astronomy maths lives in its 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.. The other half runs only at build time, on Node.js, free to use CSV parsers and image tooling — and in exchange it never imports a single line of app source. The first prohibition keeps heavy tooling out of the browser; the second keeps the build from being dragged along by the app. They point in opposite directions, but both exist to stop code that ships to the browser from mixing with code that only ever runs at build time.
The consequence is deliberate duplication of the few helpers both sides need. The conversion from The longitude-like angle on the sky, measured eastward along the celestial equator, which is Earth's equator projected outward, starting from the vernal equinox where the Sun's yearly path crosses it. It is conventionally written in hours, where 24 hours is 360 degrees. and The latitude-like angle on the sky, measured from the celestial equator, which is Earth's equator projected outward: positive to the north, negative to the south, up to 90 degrees. into a A vector whose length is exactly 1, so that it carries a direction and nothing else. It is the natural way to hold a quantity like where a star appears, which is a direction without a distance. exists once in the app and once, separately, in the build. Keeping the build free of any dependency on app code was preferred over avoiding the duplication: a build script is run on its own from Node, as npx tsx scripts/build-stars.ts, and rebuilding the catalogue should not be dragged to a halt by a change in how the app arranges its files or in what the browser needs. The guard against drift is simple: each copy has its own test written against the same expected values, the axes of 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. A coordinate system built by extending Earth's equator and rotation axis outward onto the sky. A position is given as two angles: right ascension, which works like longitude, and declination, which works like latitude. It does not turn with the Earth, so a star's values stay nearly fixed. written as unit vectors — RA 0h, Dec 0 degrees is the +X axis, and Dec 90 degrees, the north celestial pole, is +Z. Change one copy alone and that copy's test fails.
The star catalogue binary
Positions and brightnesses come from the AT-HYG v4.0 "HYGLike" subset — a star catalogue by David Nash / astronexus, cut to the column layout of the long-standing HYG catalogue, distributed as a gzipped CSV of a dozen-odd megabytes and holding 119,670 stars before any 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. filtering. The build script reads it as a stream, discarding the Sun's row and any row whose magnitude or position is not a number. The rest is written out as two catalogue tier — One level of something divided into graded levels. In this article it names the brightness bands the star catalogue is cut into, and the band holding the brighter stars alone sits unchanged at the head of the band that goes down to the fainter ones. That banding of the catalogue is a separate idea from the quality tiers, the presets that change how heavy the rendering is., and the two are nested: the bright tier sits, unchanged, in the leading part of the full tier.
Three magnitudes are involved: 8.0 is the faint end of what goes into the catalogue, 6.8 the faint end of what is drawn on screen, and 6.5 the cut between the two tiers. A larger magnitude means a fainter star, so all three mark a limit on the faint side — and they link up into one chain. 6.8 is the figure usually quoted for what the naked eye reaches under a sky free of light pollution (raise the light pollution and that end moves back towards the bright side; how it was chosen is in the section "Rendering the Atmosphere and the Stars"). 6.5 is the usual rule of thumb for the naked-eye limit. And 8.0 is that drawing end of 6.8 plus 1.2 magnitudes of headroom. That headroom has no assigned use today; it is simply enough slack that the drawing end could later move a little fainter without rebuilding the catalogue.
Conversely, a setting that loads only the bright tier shows nothing fainter than 6.5, so the full 6.8 is only ever drawn when the full catalogue has been loaded — which settings load which file is covered under "Loading in two stages", later on this page. The current output is 8,037 stars brighter than magnitude 6.5 in the bright catalogue tier, and 38,168 brighter than magnitude 8 in the full one.
The format is not JSON but a small hand-rolled binary of fixed-length records, little-endian throughout. The first 16 bytes are a header of four 32-bit unsigned integers — a magic number, a version, the star count, and a fourth word left empty for future extensions (currently zero) — followed by one 28-byte record per star, seven 32-bit floats each. A record carries direction, brightness and colour: the direction as a unit vector in the equatorial frame of the J2000 The reference moment that says when a set of coordinates or orbital values applies. J2000 is the one most widely used for star positions., the brightness as the apparent V magnitude (V being the visual band, green to yellow) exactly as the catalogue gives it, and the colour as A way of holding colour in which the number is proportional to the amount of light. The sRGB values a display takes are bent to suit the eye, so light is added and multiplied in linear form and converted back at the very end. baked from the The difference between a star's brightness measured through a blue filter and through a yellow one. A larger value means a redder star and a smaller one a bluer star, which makes it a proxy for surface temperature.. The directions ship in that year-2000 frame and stay there. Bringing them to the sky of the current date happens in the browser, and no star is moved individually: the whole celestial sphere is turned by a single 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 carries the 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. from the 2000 frame to the frame of the day, as the section "Astronomy: Where the Stars Are" describes. The files are 225,052 bytes for the bright catalogue tier and 1,068,720 bytes for the full one.
export const MAGIC = 0x41535452 // "ASTR"
export const VERSION = 1
const HEADER_BYTES = 16
const RECORD_FLOATS = 7
const RECORD_BYTES = RECORD_FLOATS * 4 // 28| Offset in record | Field | Meaning |
|---|---|---|
| bytes 0, 4, 8 | x, y, z | Unit vector in the J2000 equatorial frame |
| byte 12 | mag | Apparent V magnitude, exactly as the catalogue gives it |
| bytes 16, 20, 24 | r, g, b | Linear RGB baked from the B−V colour index |
JSON was rejected for two reasons. First, the full catalogue as JSON allocates one object per star to parse, 38,168 of them, and every one of those costs something to create and something more to reclaim later through garbage collection. With the binary, the bytes that arrive are never copied at all: the same memory is simply viewed as an array of floats. Exactly one copy follows, and it is the one that splits that single run of numbers into the separate position, brightness and colour attributes the GPU wants. Second, fixed-length records make the offset of star i a single multiplication. The difference on the wire, by contrast, is small: rewrite the bright catalogue tier of 8,037 stars as the equivalent JSON and gzip both, since compressed bytes are what the browser actually downloads. After gzip the binary comes to about 154 KB and the JSON to about 163 KB, some 6 per cent apart. The guess here is that what the binary really buys is not bytes transferred but allocations after arrival — and it stays a guess, because neither the parse time nor the cost of those allocations has been measured.
The ordering is part of the design too: records are sorted by magnitude ascending, brightest first. So "draw only the 6,000 brightest" reduces to setting a The span of the already-prepared vertices that a given draw actually uses. It lets the number of things drawn change without rebuilding the data. over the leading 6,000 elements of the buffer already on the GPU. Star count becomes a cheap knob — no re-upload — which is what makes cutting quality cheap when frames get expensive.
Names live outside the binary, in a separate JSON file that only pairs a record index with a proper name, a Bayer designation (Greek letter plus constellation abbreviation, e.g. Alp CMa for Sirius in Canis Major) and a HIP number from the Hipparcos catalogue. Positions and magnitudes are needed 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.; names only when a label is drawn. That difference in frequency is exactly what the file split encodes. Japanese names are not in there either: the translation table keyed by English name or abbreviation lives in the app, so data and language never mix.
One practical correction sits around names. AT-HYG v4.0 gives each member of a multiple star its own row, so companions such as "Capella B" appear next to their primaries. Among the companions in this catalogue that carry a proper name, the separation from the primary is at most about a hundredth of a degree: 0.0093 degrees for Capella B. Acrux B is the degenerate case, separation zero — the catalogue gives it exactly the same position as its primary. Either way, labelling both produces two overlapping, unreadable strings. The build therefore looks for proper names shaped "base name, space, a single letter B, C or D" and drops one only when that base name is itself present in the same file. "Capella B" goes because "Capella" is there. The letters stop at D because the catalogue holds essentially nothing past D. In numbers: 69 proper names end in a space and a single capital, of which 60 end in B, seven in C, one in D and one in A, and nothing beyond D occurs at all. That single A, "Struve 2398 A", has no bare "Struve 2398" in the catalogue, so widening the rule to A would change nothing — and for the same reason its partner "Struve 2398 B" survives too.
The loading path in the app applies the same rule a second time. That looks redundant, but the two passes differ in job: the build pass takes the duplicates out of the shipped file itself, the app pass is insurance. It guards three cases: a future rebuild of the catalogue reintroducing names of the same shape, the name file being hand-edited, and an older copy being rolled back and shipped. In each of them the fix the build baked in is missing from the file that actually reaches the browser. Filtering again at load time means a duplicate label never reaches the screen. Hand-editing is possible at all because the name file is a shipped artefact with nothing standing between it and an editor. The city names later in this section work the other way round: if a city ends up without a label a Japanese reader can read, the build itself stops, so patching the output by hand is never an available escape.
Colour: from B−V to linear RGB
The catalogue carries no colour. What it carries is the B−V colour index, that difference between a star's brightness through a blue filter and through a yellow one. The build converts it into colour in three steps and bakes the result into each record.
- B−V to surface temperature, via the empirical Ballesteros (2012) relation.
- Temperature to sRGB, the standard display colour space, by linear interpolation over Mitchell Charity's published blackbody radiation — The light a heated object gives off, its colour and intensity fixed by temperature alone. Hotter is bluer and cooler is redder, which is what lets a star colour be approximated from a temperature. colour table.
- sRGB to linear RGB, because the shader — 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. work in linear light and the renderer applies its own 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. later.
Stars whose row carries no B−V — 58 of the 38,168 brighter than magnitude 8 — get the colour of a cool One rung of the scheme that sorts stars by surface temperature: a little hotter than the Sun and white to the eye, at roughly 6,000 to 7,500 K., a little hotter and whiter than the Sun and near the F/G boundary, around B−V 0.58 or 6,000 K. All three steps approximate blackbody radiation, where a heated body glows in a colour set by its temperature alone, rather than reproducing true stellar spectra; the judgement is that it is good enough for the tints on screen. In return, no colour maths at all is left to run in the browser.
Loading in two stages
The catalogue loads in two stages. The bright catalogue tier sits on the same origin as the page itself and is always fetched for the first frame. The full catalogue is fetched only when two conditions meet: the In Asterarium, a preset that changes the rendering load as a group. There are four, low, medium, high and ultra: low draws the 6,000 brightest stars, medium the 8,037 of the brighter catalogue, high and ultra all 38,168, and the tier also sets the pixel-density ceiling and whether stars twinkle. The level moves up or down on its own with the measured frame rate. asks for it, and the browser has gone idle. Note that these catalogue tiers are different from the quality tiers: a catalogue tier is a way of splitting the data across files, a quality tier is a setting for how expensive the rendering may be, and the latter decides which file gets loaded. Of the four quality tiers, only the top two, high and ultra, ask for the full catalogue; low and medium draw from the bright catalogue tier alone, so nothing fainter than magnitude 6.5 appears at all. The full-tier file also comes from somewhere else: 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., the object storage used for large files. What R2 does for the site is in the section "Delivery and the Standalone Pages". The first frame never waits on the faint stars.
// Only the 'full' tiers pull the big catalogue, and only when idle.
if (tierConfig(tier).starCount !== 'full' || !handlers.onUpgrade) return
// …
if (typeof ric === 'function') {
idleHandle = ric(runUpgrade, { timeout: 2500 })
} else {
idleHandle = setTimeout(runUpgrade, 1500) as unknown as number
}In the excerpt, ric is a local alias for requestIdleCallback. The 2,500 ms cap handed to it is not decoration: with the 3D canvas rendering continuously the browser may never consider itself idle at all, and without the cap the upgrade would simply never fire. A browser without requestIdleCallback cannot report idleness at all, so that branch gives up and uses a plain 1,500 ms timer.
What the second stage fetches is the whole catalogue, not a delta against what the browser already holds. The bright catalogue tier is its leading part, so the same 8,037 stars arrive a second time — refetching the full 1 MB catalogue was judged simpler than building and applying deltas. The swap itself is quiet: the star component rebuilds its vertex buffers, the per-vertex arrays it hands the GPU, from the new data, and it is not remounted, so the shaders survive untouched. Because no shader is rebuilt, none has to be recompiled for the GPU either.
Constellation lines and cities
The raw constellation data is a set of polylines joining star to star with straight segments. The build subdivides any segment wider than five degrees into roughly two-degree steps along the great circle — the circle a plane through the sphere's centre traces on its surface, which is the shortest path between two points on a sphere — before shipping it. The intermediate points come from Interpolation that walks from one point on a sphere to another along the circle a plane through the sphere's centre traces, at a constant rate. It is the standard way to blend two orientations smoothly., and the renderer is left with nothing to do but join the points it is given.
What the subdivision buys is not shape but distance — the value the GPU memory that records, for every pixel, the distance to whatever is currently drawn there. It is what lets a near object hide a far one. What it holds is not the distance itself but a normalised depth, mapped onto 0 to 1, with 1.0 meaning nothing nearer than the far limit. uses to decide which of two things is drawn in front. The observer's camera always sits at the centre of the celestial sphere, so a straight line between two endpoints points the same way as the arc, and the figures would not look deformed without it. The stars sit on a celestial sphere of radius 1000, and the lines just inside it, at radius 995 — a 0.5 per cent gap that keeps the lines and the bright-star billboard — A four-cornered flat mesh turned so that it always faces the camera. Because it sets its own size it can spread wider than a point drawn by the GPU, which is what a broad glow needs. from swapping places in In rendering, the distance from the camera to whatever is seen at a given pixel. Comparing these values is how a renderer works out what hides what.. The gap is kept no wider than that so the lines still read as sitting on the stars: 0.5 per cent of radius 1000 is 5 units; with the observer at the centre of the sphere that gap is invisible on screen and matters only to the The comparison that throws away a new pixel when what is already drawn there is nearer, so objects sort correctly whatever order they are drawn in. By default in three.js, the standard 3D library, a pixel at exactly the same distance still passes..
The raw data really does contain long segments: 345 of its 743 segments span more than five degrees, and the longest is about 26 degrees. Join the ends of that longest one with a straight line and its midpoint drops about 2.5 per cent inward (half of 26 degrees is 13, and 1 − cos 13° is that sag). Inward is towards the observer at the centre of the sphere, so sagging inward is moving forwards, and this sag is five times the gap: the line comes out in front of the stars. Subdivided, every vertex lies on the same shell, and a two-degree hop sags only 0.015 per cent of the radius at its midpoint (1 − cos 1°), about a thirtieth of the gap. The line therefore stays behind the stars along its whole length. The five-degree threshold follows from the same gap: a five-degree chord dips about 0.1 per cent of the radius at its midpoint (1 − cos 2.5°), comfortably inside the 0.5 per cent.
/** Segments wider than this (great-circle degrees) get densified. */
const MAX_STEP_DEG = 5
/** Target step size for the densified sub-segments. */
const SUBDIV_STEP_DEG = 2Two things about counting the output. A constellation is not always one polyline — wherever the figure breaks, it gets another — and the number of entries does not match the number of constellations. The entries differ because Serpens is split into a head and a tail, two separate pieces of sky, each its own entry. In numbers: the current output is 150 polylines and 2,169 points, 76,757 bytes uncompressed; there are 89 entries (one JSON record per constellation) against 88 constellations, and 35 of those entries hold more than one polyline. Here too no Japanese is emitted, only the three-letter IAU (International Astronomical Union) abbreviations and English names.
The city list behind the observer picker is built from the dump of settlements over 15,000 people published by GeoNames, an open database of place names carrying coordinates, populations and alternate names in many languages. Selection is the union of four rules: every national capital, the Japanese prefectural capitals plus Tokyo, the thousand most populous cities (a round figure chosen to size the list), and a hand-picked set that population alone would miss but that the map needs as reference points. That currently yields 1,154 cities.
One rule here is unusually strict. Because the interface is Japanese-first, every selected city must carry a label a Japanese reader can read. The build tries four fallbacks in order: the Japanese alternate name from GeoNames, the Chinese Han name for cities in China, Taiwan, Hong Kong and Macau, the Korean Hangul name for Korea, and finally a hand-curated katakana table. Han characters work as a substitute because they are read as Japanese characters as they stand. Hangul is the compromise of the set: the name is left in Hangul, in the knowledge that most Japanese readers cannot read it. If none of the four hits, the build does not warn and carry on: it throws an exception, so the city file is never written at all. The exception message is formatted as lines ready to paste straight into that katakana table. Because the build guarantees that a usable label always exists, no city-label code needs a "fall back to English" branch.