Explanation
Architecture
How the facts, the positions, the line drawing, the shaded look and the element fit together, and why the pure side runs anywhere.
Wakusei Planets is built in layers, and the lowest layers know nothing about browsers. A line drawing is a string; a shaded body is the same numbers handed to a shader.
The layers
| Layer | Where | What it holds |
|---|---|---|
| Facts | src/facts.ts | Every body's real size, flattening, axial tilt, pole, rotation, colours and features. |
| Positions | src/ephemeris.ts, src/data/orbits.ts | Where a planet is, where a moon is round its planet, and a body's pose as the Earth sees it at a time. |
| The drawing | src/pose.ts, src/line.ts | portrait and lineSvg: a pose, then an SVG string. |
| Moving pictures | src/live.ts, src/gate.ts | The maths of a body that turns, at a low rate, and a gate that keeps frames to thirty a second. |
| The look | src/scene/ | Shader code, each body's recipe, rings, and the objects for three.js. |
| One body each | src/bodies/ | jupiter.ts, saturn.ts and the moons: each registers its look. |
| The element | src/element.ts, src/embed.ts | <wakusei-body>, and embedCode. |
The first four rows are pure: the same arguments give the same markup, and they import with no browser. Only the element and the scene code need a DOM, and only the shaded looks need three.js.
import { apparent, BODIES, lineSvg } from "@johnmorrisdotca/wakusei-planets";
const saturn = apparent("saturn", Date.UTC(2026, 10, 2));
console.log(BODIES.saturn.radiusKm, saturn.subObserverLat.toFixed(1));
console.log(lineSvg("saturn", { size: 120, time: Date.UTC(2026, 10, 2) }).startsWith("<svg"));60268 -6.5
trueOne pose for every look
For a date, the package works out the pole, the prime meridian and the Sun's direction in the viewer's frame (x to the right, y up, z toward you). The line drawing and the shaders both use that one pose, so a body cannot be tipped one way in the line look and another in the shaded one. Light travel time is allowed for.
import { portrait } from "@johnmorrisdotca/wakusei-planets";
const pose = portrait("uranus");
console.log(pose.pole.map((n) => n.toFixed(2)).join(", "));0.92, -0.13, 0.37Each body is its own entry point
A shaded look is code and data for one body. It would be wasteful to ship every body to a page that draws only Saturn, so each body has its own import (/saturn, /jupiter, /moons-jupiter), and /all imports them all. An entry point does one thing: it calls registerLook with the body's shader and map. The element finds the look by the body's name. A body whose entry has not been imported is drawn as a plain lit ball in its colour.
The table of looks lives on globalThis, because a CDN's bundler can give each entry point its own copy of a file and the copies must still share the looks.
One WebGL context for the page
Every shaded <wakusei-body> takes the page's one renderer from kazoku-ui. The body in focus is live and the others show pictures (live="hover" gives the context to whichever the reader points at). The last element to leave lets the renderer go. Nothing is drawn while nothing changes, a moving picture is drawn at most thirty times a second, and the canvas is held to 4096 pixels a side and six million pixels in all.
import { BODIES, MOONS_OF } from "@johnmorrisdotca/wakusei-planets";
const bodies = Object.keys(BODIES).length;
const moons = Object.values(MOONS_OF).reduce((sum, list) => sum + list.length, 0);
console.log(bodies, "bodies;", moons, "moons");47 bodies; 29 moonsA moving picture is mostly carried forward
The pose and the moons are worked out in full about once a second. In between, the body's turn and each moon's walk round its planet are carried forward from there, so a frame costs a few turns of vectors and never an ephemeris (src/live.ts).
Your own scene
painter.make hands a body to a three.js scene of your own as an object of radius 1, lit in the scene's own axes. It holds no renderer, no loop and no clock: you place it, size it, light it and call update. See In your own three.js scene.
The data
Sizes, flattening, poles and rotation are the IAU's with NASA's fact sheets; Mercury to Neptune are VSOP87B, cut down, and Pluto is a table fitted to Horizons; the Moon's are the lunar solution ELP2000-82B cut down; the other moons are fitted to JPL Horizons' positions. The moon table is generated by scripts/make-moon-data.py and never edited by hand, and test/accuracy.test.ts holds every body to its limit (Accuracy). A fact with no source is left out, never guessed. How it works has the sources.
Scientific frames and model choice
The clock converts UTC to TT. src/sidereal.ts owns mean obliquity, the IAU 1980 nutation terms and the unwrapped mean sidereal angle. src/sky/frames.ts owns frame turns: mean ecliptic J2000 → mean equatorial J2000 → mean equatorial of date (IAU 1976 precession) → true equatorial of date (nutation). The lunar theory retains its own ELP ecliptic precession: it is a different model, not a duplicate of equatorial precession.
The sky engine adds light time and aberration where appropriate, then topocentric parallax, then the true-equator horizon turn. Refraction is a separate operation. /coordinates rotates matching mean frames only and uses mean sidereal time for its horizon turn. It adds no parallax, nutation or refraction. SunCalc 1 and 2 units, azimuth origins, missing-event representations and day conventions stay in their wrappers; the scientific engine imports neither wrapper.
CoordinateObserver and the engine's Observer name the same shape: geodetic latitude and longitude in degrees and optional heightM above the WGS 84 ellipsoid. Right ascension and declination share one degree-valued direction type; distances remain explicitly distanceKm or distanceAu. /coordinates validates height but ignores it. The sky engine uses it for parallax. For compatibility, SunCalc getTimes also treats the supplied height as height above the visible horizon when computing dip. No geoid or terrain correction is inferred; those two height references are an existing approximation, not an automatic conversion.
import { heliocentricModel } from "@johnmorrisdotca/wakusei-planets";
const when = Date.UTC(2026, 10, 7);
console.log(heliocentricModel("ceres", when));
await import("@johnmorrisdotca/wakusei-planets/tables/ceres");
console.log(heliocentricModel("ceres", when));{ theory: 'osculating-elements', worstArcseconds: null }
{ theory: 'chebyshev', worstArcseconds: 0.4981 }heliocentricModel(body, milliseconds) reports the theory that would answer now, with the currently registered tables and their TT segment coverage. Mercury to Neptune use vsop87b; Pluto prefers its built-in chebyshev table; a registered dwarf table is used only within its coverage. Outside coverage the result is osculating-elements for dwarf planets or approximate-elements for Pluto. Query again after importing a table or changing the instant. Positions and import behavior are unchanged; no optional table is loaded by the query.
worstArcseconds is the table's measured maximum on samples held back from its fit, not a guarantee over every date. For Pluto it is 0.14637″. It is null for VSOP87B and the element fallbacks because no corresponding held-out fit bound is available. The accuracy comparison gives separate sparse-grid measurements against Horizons; it is not a universal worst-case bound either.