Drawing the Real Night Sky in a BrowserSection 2 of 10

Astronomy: Where the Stars Are

Turning a place and a time into a direction in the sky, along two deliberately different paths: stars are fixed at build time, solar-system bodies are computed every frame.


Drawing the real night sky comes down to one computation: at this place, at this instant, in which direction does this object appear? This section covers the quantities that answer that question, why stars and solar-system bodies travel two completely different code paths, and where the line between library and hand-written code was drawn.

All of it lives in the . The reason for keeping it separate is testing. Because its functions touch no outside state and need neither a browser nor , the layer runs directly under a Node test runner and can be checked against the figures printed in an astronomical almanac — a yearbook that tabulates computed positions for the year — and against independent implementations.

The Celestial Sphere and Two Coordinate Systems

Every object is treated as a point on the — an imaginary sphere of fixed radius centred on the observer. That sphere is literally present in the scene: stars sit on a sphere of radius 1000. Scene lengths are counted in units of one metre (the convention is set out in the section "Coordinate Frames and the Orientation of the Sky"), so that is a sphere of radius about a kilometre. The number changes nothing about how the sky looks, because the radius only has to preserve each star's direction. The one condition is that it falls inside what the camera draws. That range starts at the , 2 units or two metres away. It ends at the , at 1e7 units — ten million of them, about ten thousand kilometres.

A point on that sphere can be named by many different pairs of angles. Two are used here. are anchored to the Earth's rotation axis and use and . are anchored to the observer's own horizon: (north = 0 degrees, east = 90 degrees) and (horizon = 0 degrees, zenith = +90 degrees).

What separates the two is how they behave in time. A star's right ascension and declination barely change: the Earth's rotation changes where the observer is pointing, not where the star is. Horizontal coordinates, by contrast, drift visibly within minutes. That is why star catalogues are published in equatorial coordinates, and why Asterarium consumes those numbers as they come.

is the bridge between them: the Earth's rotation angle relative to the . The Earth turns once every 24 hours with respect to the Sun, but once every 23 hours 56 minutes with respect to the distant stars. Because the sidereal turn is about four minutes shorter, a given star reaches about four minutes earlier each night, and the offset works its way almost all the way around the clock over a year. The pure computation layer takes Greenwich sidereal time from the library and adds the observer's longitude to get local sidereal time. The path that puts stars on screen, however, never reads that value: the that turns the celestial sphere is built from the date by the library, @takram/three-atmosphere. Computing how the atmosphere looks requires the orientation of the Earth itself, so that library already holds a matrix for that orientation. Sidereal time is used explicitly by the simple conversion behind the info panel, object search and aiming, and by the projector page (/projector/).

Three Effects That Bend the Apparent Position

Where an object geometrically is and where it appears to be are not quite the same. Three effects account for most of the gap, and each is handled differently depending on what is being drawn. The sizes below are given in and , and there are two thresholds to keep apart. One is where a single point looks displaced, at roughly one arcminute, which is 60 arcseconds. The other is where a constellation looks reshaped. That judgement is made across a pattern tens of degrees wide, in which neighbouring stars joined by a constellation line sit a few degrees apart as a rough guide, so a few arcminutes still go unnoticed and it takes tens of arcminutes before the shape itself looks different.

  • — the observer's own motion tilts the incoming light slightly forward; the Earth's orbital contribution reaches about 20 arcseconds. It is included for solar-system bodies. Stars keep their catalogue positions, uncorrected.
  • — the atmosphere lifts objects above their true altitude, by about 34 arcminutes near the horizon, comparable to the apparent diameter of the Sun or Moon, roughly half a degree. Standard refraction is applied when converting solar-system bodies to horizontal coordinates. It is not applied to the star field.
  • — the observer stands on the surface, not at the centre of the Earth. For the Moon this shifts the direction by up to about 1 degree, so latitude, longitude and elevation are all passed in and positions are computed topocentrically. For stars the effect is utterly negligible.

Aberration and refraction are left out of the star field for different reasons. Aberration's 20 arcseconds is a third of the displacement that makes a single point look moved, so it is simply invisible. Refraction is not: about one arcminute even at 45 degrees altitude, and more than thirty times that near the horizon, about 34 arcminutes. It is left out because the amount of refraction depends on altitude, and a shift that differs at every altitude cannot be expressed by the current path, which turns the whole celestial sphere through one rotation. Tens of thousands of stars reach the screen by exactly that single rotation matrix. Its content is the from the year 2000 reference to the reference of the day, followed by the Earth rotation (how it is assembled is in the section "Coordinate Frames and the Orientation of the Sky").

The door is not closed, though. The that draws the stars already computes each star's direction after the celestial-sphere rotation has been applied, and the vertical part of that direction is the sine of the altitude. It uses that value already, to make the twinkle stronger low in the sky than overhead. Deriving the altitude-dependent lift from the same number and nudging each star upward is possible in principle. It simply has not been implemented.

Solar-system bodies, computed one at a time, do get refraction. That split cuts both ways. The altitude of the Sun itself comes out closer to the real sky — the same effect that lifts a setting Sun above the horizon. But its position relative to the stars behind it does not, because the star field carries no refraction: near the horizon the Sun or Moon is drawn up to about 34 arcminutes higher than the star pattern around it. That second half is a known trade-off, left in deliberately.

Two Paths: Stars at Build Time, Solar-System Bodies Every Frame

Stars and solar-system bodies get their positions in completely different ways. There are tens of thousands of stars, and for this purpose they can be treated as not moving at all. The solar-system bodies this app draws number just nine — the Sun, the Moon, and the seven planets other than Earth: Mercury, Venus, Mars, Jupiter, Saturn, Uranus and Neptune — but they move fast and they need more precision than the stars do. That level is the arcminute, which is where the naked eye gives out. They need it because, unlike a star drawn as a point, they have a visible size: the Moon is a disc half a degree across, and how it is lit, or how closely it approaches a planet or a star, shifts visibly with a few arcminutes. That asymmetry is the design.

Star positions are settled before anything ships. They are fixed at build time, before any visitor opens the page. A build script reads the star catalogue AT-HYG (the long-standing HYG catalogue extended with observations such as Tycho photometry and Gaia parallaxes, published openly), converts each star's J2000 equatorial coordinates into a , and bakes the result into a binary file. At runtime no star is positioned individually; the whole sphere is turned by a single matrix. Whether there are ten thousand stars or a hundred thousand, the positioning work stays the same: assemble one matrix. Drawing them does scale with their number, so the count drawn is trimmed to suit the device. The decides that count, and the data layout that makes trimming possible — stars ordered brightest first, so the renderer takes as many as it needs from the front — is described in the section "The Data Pipeline".

Solar-system bodies go the other way: they are recomputed . For the Sun, the Moon and the seven planets from Mercury to Neptune, Earth excluded — nine bodies in all — a function called bodyState() returns horizontal coordinates, equatorial coordinates of date, and for a given place and instant; for the eight bodies other than the Sun it also returns and . All nine calls together take about 0.12 ms — positions only, no drawing, timed on the development machine, and the figure will differ on other hardware. What matters is the ratio, not the absolute figure: the number to weigh it against is the budget for a whole frame, drawing included: 16.7 ms at 60 frames per second. The positions take under one per cent of that, so no throttling or frame-skipping was needed.

const time = MakeTime(new Date(t))
const observer = new Observer(loc.latDeg, loc.lonDeg, loc.elevationM)
const aeBody = BODY_MAP[body]

// Topocentric equatorial coordinates, of-date, with aberration.
const eq = Equator(aeBody, time, observer, true, true)
const hor = Horizon(time, observer, eq.ra, eq.dec, 'normal')
const illum = Illumination(aeBody, time)

// eq.dist is topocentric distance in AU. Angular radius = asin(R / d).
const distKm = eq.dist * KM_PER_AU
const angularRadiusDeg = (Math.asin(RADIUS_KM[body] / distKm) * 180) / Math.PI

const state: BodyState = {
  horizontal: { azDeg: hor.azimuth, altDeg: hor.altitude },
  equatorialOfDate: { raHours: eq.ra, decDeg: eq.dec },
  mag: illum.mag,
  angularRadiusDeg,
}
// Then: phase angle and illuminated fraction, for every body but the Sun.
Computing a solar-system body position (excerpt)

It is short, but the three corrections named above are all in it. The observer is built from latitude, longitude and elevation, so positions come out , which is the parallax correction. The two trailing true arguments ask for — referred to the equator and equinox of the moment, not of the year 2000 — and for aberration. The normal argument on the horizontal conversion selects the standard refraction model. Only the apparent radius is hand-computed, from the body radius and the distance.

That leaves an obvious question: the stars keep the equator and equinox of the year 2000 while the bodies use those of the moment, so do the two disagree? They do not. They arrive at the same present-day sky by two separate routes. Stars pass through the matrix that turns the whole celestial sphere, and that matrix carries them from the year 2000 reference to the reference of the day. Solar-system bodies never touch that matrix at all: the horizontal coordinates bodyState() returns are converted straight into a direction vector in . Horizontal coordinates are referred to the sky of the moment already, so nothing has to be added.

The matrix itself comes from the atmospheric scattering library, built from the date. The atmosphere calculation needs the orientation of the Earth, so the library holds that matrix anyway and simply exposes it. Its content, as noted above, is the precession from the year 2000 reference to the reference of the day, followed by the Earth rotation. The precession amounts to about 0.4 degrees as of 2026, enough to move a constellation by an amount the naked eye can pick out, so it matters that the matrix carries it.

ObjectsWhen position is fixedSource of positionCorrections applied
StarsOnce, at build timeCatalogue J2000 equatorial coordinatesNo proper motion, aberration or refraction, parallax ignored; precession absorbed by the scene rotation matrix
Sun, Moon, planetsEvery frameLibrary computation for that instantAberration, refraction, topocentric parallax

is left out of the star field for the same reason: at this scale it is invisible. Most stars move less than 0.1 arcseconds per year, so 26 years past the catalogue's year 2000 , as of 2026, they have shifted by a few arcseconds. The slice taken from AT-HYG is about 38,000 stars brighter than magnitude 8; it reaches to 8 because that is the faintest magnitude actually drawn, 6.8, plus 1.2 magnitudes of headroom. (How the catalogue is split into two tiers, and which settings draw how deep, is covered in the section "The Data Pipeline".) Within that slice the fastest is Groombridge 1830 at about 7.1 arcseconds per year, and among the bright stars Arcturus moves about 2.3 arcseconds per year. Over 26 years that comes to roughly 3 arcminutes and 1 arcminute — nothing that changes the shape of a constellation spanning tens of degrees.

The same judgement flips on a page with a different purpose. The interstellar flight page (/starflight/) is about proper motion, so it builds a separate catalogue carrying measured proper motion and radial velocity and extrapolates 100,000 years in either direction. Whether an effect is negligible is not a property of the star; it is a property of what the page is trying to show.

What Was Delegated, and What Was Not

A single engine, astronomy-engine, provides every body position. Three properties settled the choice. It is MIT-licensed, so it can be shipped with the site. It is self-contained in , so there is no precomputed table of positions to download. And the accuracy its authors publish for solar-system bodies is around one arcminute, roughly the limit of the naked eye. Of the three, the first two are what fit a site with no server-side code: everything runs inside the browser and adds no extra download. Accuracy has nothing to do with servers; it is judged on a different scale entirely, whether it suffices for a sky meant to match what the eye sees. That said, when the accuracy needed and the accuracy published are both around an arcminute, the margin is thin. So the eclipse page (/eclipse/), where an arcminute shows up directly in the picture, does not use these general-purpose positions at all: it calls the library's dedicated eclipse search to get the contact times for a chosen place. That runs once when the place is picked, not once per frame.

The library supplies topocentric right ascension and declination in coordinates of date, the conversion to horizontal coordinates with refraction, magnitude, phase angle and illuminated fraction, sidereal time, rise/set and searches, lunar phase and lunar . What is hand-written is a thin layer above it: angle-to-unit-vector conversions, the position angle of the Moon's , the assembly of a day's worth of events, and a low-precision horizontal conversion.

That low-precision path exists because it answers a different question. Info-panel readouts, object search and the aiming of the camera at a chosen object all deal with stars and clusters, which are effectively fixed, so the standard rotation from equatorial to horizontal coordinates is enough for an altitude and an azimuth. That rotation is fixed by the observer's latitude and the local sidereal time alone; no refraction, no aberration. Solar-system bodies never take that path; they always go through bodyState(). A simple horizontal conversion for the fixed stars and bodyState() for the solar-system bodies sit side by side in the codebase. The rule about which one serves which purpose therefore has to be settled, or the two get mixed up later.

Magnitude, the Moon, and Twilight

Stellar brightness is expressed as magnitude: smaller numbers are brighter, and a difference of five magnitudes is defined as exactly a factor of 100 in flux. One magnitude is therefore the fifth root of that, about 2.512 — the Pogson ratio. Turning the catalogue's magnitudes into a linear intensity the renderer can use takes a single expression, the return line of the function below. In words: each step of one magnitude divides the intensity by about 2.512, and the reference magnitude comes out at exactly 1. Which magnitude serves as that reference is chosen by the caller. The star uses the same formula with 6.5 as its reference, putting a star at roughly the naked-eye limit under a dark sky at intensity 1.

const POGSON = Math.pow(100, 1 / 5) // 2.511886431509…

export function magToIntensity(mag: number, refMag = 0): number {
  return Math.pow(POGSON, refMag - mag)
}
Magnitude to linear intensity (the whole module)

For the Moon, beyond phase angle and illuminated fraction, two more quantities are computed: the position angle of the bright limb and libration. Which way the lit side of a crescent points on screen is decided by that bright-limb position angle, which follows the classical formula from the equatorial coordinates of Sun and Moon. Phase angle, however, is never used to decide waxing versus waning: it is 0 near full moon and 180 near new moon, and it reads about 90 at both first and last quarter, so the way out and the way back cannot be told apart. The difference in between Moon and Sun is used instead — 0 degrees at new moon, 180 at full, 360 at the next new moon, increasing in one direction throughout.

Twilight is split into three stages by solar altitude: civil at −6 degrees, nautical at −12, astronomical at −18. Alongside sunrise, sunset, moonrise and moonset for the day, the routine that assembles a day of events searches for six twilight instants — the evening crossing of each threshold on the way down, and the morning crossing on the way up. How dark the sky actually looks is decided by the atmospheric scattering; the question of when things happen belongs here.

At high latitudes there are days when the Sun never sets and days when it never rises. On such a day the event simply has no time. The pure computation layer refuses to paper over that with a zero or an end-of-day timestamp: the field is left undefined, so the caller can see that the event did not occur. The display therefore holds up under a midnight sun or a polar night. The discipline is to never invent a value that does not exist.

Every decision in this section answers the same question: how much accuracy is needed, and how often? Differences the eye cannot resolve are not computed at all, and even a visible one is skipped when the drawing path has no room for it. That line is what leaves enough room to draw the sky sixty times a second in a browser, from a static page with no server-side code behind it.