Drawing the Real Night Sky in a BrowserSection 7 of 10
State and the Frame Loop
Values that change sixty times a second and settings that change when someone clicks belong in different places. Here is where the line falls, and the rules that grew out of it.
Where do you put a value that changes sixty times a second? That is the question this section answers. What follows is not astronomy but housekeeping: where a value lives, and who is allowed to write it.
In an astronomy simulation running inside a browser, simulation time and camera orientation change every single frame, while the observer location or the constellation-line toggle only change when somebody presses something. Asterarium keeps those two kinds of state in separate machinery, and has from the start.
Feeding per-frame values into React state makes the rendering fight you. Writing to React state triggers a re-render. Put simulation time there and you get sixty rebuilds per second. Each one allocates fresh objects, and garbage collection can then cost you frames. Put the time high in the tree and a single clock label can re-render the whole scene below it.
So per-frame values live outside React. The owner is a module singleton. It depends on no framework, and the per-frame code in the 3D scene reads and writes what it holds directly. Only the UI that actually needs to show a number looks at it, through a In Asterarium, a way of carrying a value that changes every frame into the interface at a fixed low rate. To subscribe is to ask to be told whenever a value changes; here the interface side, the one being told, names the rate it wants, such as twice a second. The clock readout re-reads twice a second by default, so it is not rebuilt once per frame..
Four Places for State
State lives in four places, and the criterion for choosing between them is not what the value means but how often it changes and how long it should survive. The observer latitude and longitude appear in three of the four — the 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. store, the URL and localStorage — for exactly that reason.
Three terms in the table are worth a phrase each too. The "copy" of the camera orientation is the direction the screen is currently facing, kept aside so that other code can read it later. A "camera request" is a message in which something outside the camera hands over a target orientation. The "time scale" is how many simulated seconds pass per second of real time. The observer the URL carries is the same group of fields the store keeps, and the time zone is part of it.
| Where | How often it changes | What it holds | Lifetime |
|---|---|---|---|
| Zustand store | At the seams of a user interaction — the moments an interaction settles: a drag ending, a burst of wheel clicks stopping | Observer, display toggles, quality, language, selected object, copy of the camera orientation, saved sky tours | Until the tab closes; only the part marked for saving also survives in localStorage |
| Module singleton | Every frame | Simulation time, camera requests, sky-tour playback position | Until you leave the page; never saved |
| URL query string | When a share link is made | Observer latitude, longitude, elevation and time zone; time; time scale; camera orientation; display toggles; light-pollution level; language | As long as the link exists |
| localStorage (the Zustand persistence layer) | Rarely | Observer, display toggles, quality, language, saved sky tours | Across visits |
The Singleton That Owns Time
Simulation time has exactly one owner: the clock singleton. Its state is five fields. simTimeMs is the instant, as milliseconds since 1970 (Unix time) counted in UTC. timeScale is how many simulated seconds pass per real second, negative for running backwards. running says whether time flows at all. When anchoredToNow is true the clock snaps to the real current instant 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., which is the live sky. And driven is true only while the playback engine of 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. has borrowed ownership of time.
advance(wallDeltaMs: number): void {
// Driven mode: the tour sequencer owns time - advance() must do nothing.
if (state.driven) return
if (state.anchoredToNow) {
state.simTimeMs = Date.now()
} else if (state.running) {
state.simTimeMs += wallDeltaMs * state.timeScale
}
emit(false)
},Notification is deliberately asymmetric between the per-frame advance of time and a setting changed by a user action. The emit(false) above is what notifies subscribers, and the false means "do not force". So the notification from advance, which runs every frame, is throttled to whatever rate the subscriber asked for — twice a second by default. The methods a user action calls — jumpTo, setScale and the other setters — pass true instead, bypassing the throttle to notify immediately. A button press must feel instant; a seconds readout does not need sixty updates a second.
The React side does not use the standard hook useSyncExternalStore. That hook requires the same object back on every read as long as nothing changed, and a new one on every call sends React into re-renders that never stop. The clock singleton, however, builds a fresh frozen object out of the live values on every read. It keeps nothing prepared in advance because the drawing code, which reads the time every frame, must always get the current value. Neither side has been measured, but that allocation amounts to a few small objects per frame, so it should stay far below what re-rendering would produce. Caching a snapshot and rebuilding it only when the value changes would not help either: simulation time changes every frame, so the rebuild would happen every frame too, and a fresh object would come back every time all the same. Nor does the standard hook offer any way to give each subscriber its own notification rate. So the singleton exposes the two separately — get, which returns the current state, and subscribe, which takes a callback and a rate — and a small hook reads get once in a useState initialiser and updates from subscribe afterwards. The 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., such as the 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. and the interstellar flight view, expose the same two and reuse the hook unchanged.
The Camera Flows One Way
Only one component may write the live view — the orientation the screen is actually showing — and that is the camera owner. Its The angle measured along the observer's horizon eastward from due north: east is 90 degrees, south 180, west 270., 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. and field of view — how much sky the screen covers, in degrees, so that a narrower angle means a closer look — sit in React refs and are applied to the camera every frame. They are written back to the store only at the seams of an interaction: when the inertia that keeps the sky turning after you lift your finger settles, roughly 300 ms after a wheel or pinch, and on key release. The 300 ms is long enough that a burst of wheel clicks counts as one gesture. The timing, speed and angle constants in this section, that one included, were all set by eye while watching the screen rather than measured. One more thing holds in a production build: the owner reads the store camera exactly once, at mount, and never again. So writing a new camera value into the store from outside moves nothing in production.
Development builds — and only those — add a subscription that watches the store camera. It is the way to aim the camera from outside while checking the page by eye on the development server. The automated browser tests do not use it. They run against the same production static output that gets served, where the subscription does not exist, so the tests pin the observer, the instant and the camera through share-link query parameters instead. Because a plain store subscription fires on every change to the store, it is conditioned to react only when the camera value itself changed, and to do nothing while a smooth move is in flight. Without those conditions, merely choosing a search result would cancel the move in progress and snap the view back. Dropping the subscription entirely in production keeps exactly one path that writes the live view.
The camera orientation in the store is only a copy. Three things read it: the code that builds a share link, the code that records the current scene into a sky tour, and the owner itself, once, at startup. What is marked for saving is the settings — the observer, the display toggles — and the camera orientation is not among them, so a returning visitor always starts from the same default view.
Moving the camera from outside is a request, not a command. Hand a target azimuth, altitude and field of view to a small relay singleton that passes requests along, and the owner's per-frame loop eases towards it. Any drag, wheel or key press cancels the approach. If the 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. scene has not started yet, the relay simply returns failure. The ?star= share link carries the view to one named star, and it cannot ask until the star catalogue has been fetched. Nothing sequences that fetch relative to the loading of the 3D scene, so either may finish first — and the link re-offers its request about every 120 ms until someone takes it, giving up after fifteen seconds. The 120 ms is a trade between wasted retries and a visible delay; the fifteen-second cutoff exists because WebGL may never come up at all on a given device, in which case nobody would ever take the request. Even then the star stays selected, so the card naming it and saying when it next rises is still open.
Drags and wheel gestures are handled by the owner itself. Beyond that there are four purposes for moving the camera: aiming, the automatic pan of In Asterarium, a full-screen state that hides every control and shows nothing but the sky. The interface fades out after a few seconds without input and comes back when the viewer moves., the orientation sensor of the device, and sky-tour playback. Aiming has several callers — choosing a search result, a share link — but they all do the same thing, sending the view to a target, so they count as one purpose. All four follow the same protocol: they ask. That protocol is one rule: the owner publishes one small entry point per purpose, and everyone else — including a subsystem that writes on every single frame — calls the entry point and never touches the refs that hold the live view. Entry points are one per purpose, so there are four. Three of them — the screening pan, the orientation sensor and sky-tour playback — are published by the owner itself; only the fourth, aiming, belongs to the relay singleton.
- Aiming calls
slewToon the relay singleton — a request to ease towards a target. Choosing a search result, sending the view to the Moon or a planet from an object card, recentring on the middle of the sky, resetting the zoom, and the?star=share link all go through this one entry point. - Screening mode pans on its own by calling
nudgeAz, which adds a little azimuth, every frame, at 0.4° per second. It stops the moment you touch anything and resumes five seconds after you stop — a rate that reads as calm, and a pause long enough not to fight you. - Device orientation writes the tilt and heading of the device into its own entry point every frame. It is ignored while a finger is on the screen — whoever is dragging wins.
- Sky-tour playback writes the target azimuth, altitude and field of view themselves every frame, never a relative increment. It brackets them with explicit begin and end signals.
One owner, everyone else a requester. Sky-tour playback gets one extra privilege: when it ends it may hand a view back to the owner to return to. That is not a fifth entry point but an argument of the request that ends playback. Restoring through the store would be invisible in production, so the restore target has to travel as an argument of the request itself.
A Sky Tour Borrows Time and Camera
Sky-tour playback works by borrowing ownership. During a time-lapse transition the playback engine raises the clock's driven flag, which turns the per-frame advance into a no-op; time then moves only by the engine writing the instant it computed, never by incrementing. One flag keeps the per-frame addition and the whole-value writes from fighting over the same field.
The engine's per-frame tick runs before the clock's advance, so that the instant it writes is the one the same frame's sky is drawn at. All the per-frame work is called in order from that one place: sky-tour playback, then the clock advancing, then the atmosphere update, then the orientation of 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., then the positions of the Solar System bodies. The order cannot be shuffled, because each step reads what the step before it produced. What each step does is set out in the section "Coordinate Frames and the Orientation of the Sky".
When playback starts, the viewer's own state is put aside: observer, display toggles, camera and clock, all four, restored when playback ends. The camera goes back as a restore request to the camera owner rather than through the store, and the clock is restored down to whether it had been anchored to the live instant. The point is that someone who watches a tour can be put back in front of the sky they left.
Whether touching the screen stops playback is handled differently during a transition, where the view is moving, and during a dwell on a scene. The decision rests on a single value: the camera owner records the moment of every drag, wheel and key press, and the playback engine reads that timestamp each frame. During a transition the test has two conditions, and they do different work. One excludes a timestamp left over from before the transition began; the other discards a timestamp that falls inside the transition but has since gone stale, leaving only the fact that you are touching the screen right now. Concretely, playback pauses if the timestamp is newer than the one taken when the transition began and is less than 100 ms old — you touched the screen while the view was moving, so you meant to intervene. The check runs every frame, so a touch during a transition stops playback on the next one. During the dwell on a scene, interaction does not pause anything — looking around is the point of dwelling. But if less than 1.5 s has passed since your last input when the dwell expires, the tour waits until you settle before moving on, rather than yanking the view out of your hands.
Saving and Sharing: the Shallow-Merge Trap
Settings persist through Zustand's built-in persistence, but its default merge is a trap. What it does by default is a shallow merge: the saved values are laid over the current state one top-level field at a time, so a nested object is replaced wholesale. Add a new entry to the object holding the display toggles, and for anyone whose saved values predate it, that entry simply vanishes. The new feature ships broken for returning users only — the hardest kind of bug to notice. The fix is a hand-written merge that lays the defaults down first and puts saved values on top, field by field: partialize picks what gets written, and merge is where the hand-written function is installed.
partialize: (s) => ({
observer: s.observer,
display: s.display,
quality: s.quality,
qualityAuto: s.qualityAuto,
lang: s.lang,
}),
merge: (persisted, current) =>
mergePersisted(current as SceneStore, persisted),Transient flags are guarded twice. Session-only state — screening mode, or the sensor mode where you hold the phone up to the sky — is excluded from what gets saved, and the merge function overwrites it with the current value as well. Either guard alone would do. The point of having both is the day someone adds a new flag and forgets one of the two lists: if either guard is missed, a returning visitor would still not start up in screening mode. The same discipline covers saved sky tours, each of which is re-validated on load.
Share links are parsed defensively throughout. A query string is something anyone can hand-edit, so unreadable values are dropped rather than thrown, and numbers are clamped into range: latitude to ±90°, longitude to ±180°, elevation to −500…9,000 m, field of view to 1°…120°, time scale to ±3,600×, and so on. The elevation range comfortably brackets the lowest land on Earth, the shore of the Dead Sea at about 430 m below sea level, and the highest, Everest at 8,849 m. A time scale of ±3,600× means one second of real time advances simulated time by at most one hour, or rewinds it by one hour when negative.
Light pollution is clamped the same way, to 0…8. Those nine steps are a scale of the project's own, where 0 is a sky no town light reaches and 8 is the brightest. It is not the A nine-step scale, widely used by amateur astronomers, for how dark a night sky is. Step 1 is the darkest sky, where the Milky Way shows in full, and step 9 is an inner-city sky., the common nine-step measure of how dark a night sky is; the step counts merely coincide, the steps do not correspond, and no conversion table between the two is kept. The limiting 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. used for drawing follows from the step, by a rule set out in the section "Rendering the Atmosphere and the Stars". If no time parameter is present the clock stays anchored to the real instant — a link without a time opens on the sky as it is right now. Encoding and decoding live in one module, and no component reads the query string on its own.
To sum up: what decides where a value lives is not its meaning but how fast it changes and how long it should survive. Per-frame values go outside React, interaction-driven values go in the store, shareable values go in the URL, and remembered values go in localStorage. Then every value gets exactly one owner, and everyone else has to ask. Those two rules are very nearly the whole of this section.