Guides
Animating orbits
Orbits are computed, never stored. You give a time in seconds; you get back where everything is at that instant. There is no simulation to step and no state to keep.
The conventions
Three of them, and they hold everywhere in the library:
- The star sits at the origin. Positions inside a system are relative to
(0, 0). Where the system itself is in the galaxy issystem.xandsystem.y— a separate coordinate space. - The Y axis points down, as in SVG and Canvas. If you are drawing with a maths convention instead, negate
yonce at the boundary. - Angles are degrees, measured clockwise, starting from the positive X axis.
angle: 0is to the right;angle: 90is straight down.
Every position at time t
systemPositions() is the one you will use. It returns a Map from id to { x, y }, covering everything in the system.
import { systemPositions } from '@industrieh/starmap'
const at = systemPositions(system, 0) // t = 0 seconds
at.get('sol-star') // { x: 0, y: 0 } — always the origin
at.get('sol-p0') // { x: 100, y: 0 } — angle 0, to the right
at.get('sol-p1') // { x: 0, y: 200 } — angle 90, straight down
at.get('sol-p1-st0') // { x: 20, y: 200 } — its station, offset from it
at.get('sol-gate0') // { x: 212.13, y: 212.13 }
at.get('sol-ast0') // { x: 176.78, y: 176.78 } — a belt: the middle of its arc
at.get('sol-ast1') // { x: -120, y: 60 } — a cluster: its centreGates and asteroid fields are included even though they never move — so the map is the one place to look, whatever you are drawing.
Stations follow their planet
A station's orbit is measured from its planet, and systemPositions() composes the two rotations for you. Watch what happens as time advances:
// t = 0
at.get('sol-p1') // { x: 0, y: 200 }
at.get('sol-p1-st0') // { x: 20, y: 200 } — 20 units to the right of its planet
// t = 2 — the planet has moved, and carried the station with it
at.get('sol-p1') // { x: -141.42, y: 141.42 }
at.get('sol-p1-st0') // { x: -161.42, y: 141.42 }The station is on its own four-second orbit andon its planet's sixteen-second one. You never compose those yourself.
Only one level of nesting
A render loop
Because positions are derived, the loop is just: advance a number, ask for the map, draw.
import { systemPositions } from '@industrieh/starmap'
let t = 0
let last = performance.now()
function frame(now: number) {
const dt = (now - last) / 1000
last = now
t += dt * timeScale // your own speed control, pause = 0
const at = systemPositions(system, t)
ctx.clearRect(0, 0, width, height)
drawStar(0, 0, system.star.radius)
for (const planet of system.planets) {
const p = at.get(planet.id)!
drawOrbit(planet.orbit)
drawPlanet(p.x, p.y, planet.radius, planet.type)
for (const station of planet.stations) {
const s = at.get(station.id)!
drawStation(s.x, s.y)
}
}
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)Nothing to cache, nothing to memoise
Note that only one system is animated at a time. That is the usual case: the galaxy view shows static system positions, and orbits only matter once you are inside one.
One orbit at a time
orbitPos() is the primitive underneath. Reach for it when you are placing something the model does not know about — a ship, a projectile, a waypoint.
import { orbitPos } from '@industrieh/starmap'
// orbitPos(orbit, angleDeg, period, t)
orbitPos(100, 0, 0, 0) // { x: 100, y: 0 } — period 0: never moves
orbitPos(100, 90, 0, 0) // { x: 0, y: 100 } — 90° is straight down
orbitPos(100, 0, 8, 2) // { x: 0, y: 100 } — a quarter of an 8s orbit
// Your own ship, parked in a holding pattern:
const ship = orbitPos(planet.orbit + 30, 210, 40, t)And fieldCenter() gives the anchor point of an asteroid field — the middle of the arc for a belt, the centre for a cluster:
import { fieldCenter } from '@industrieh/starmap'
fieldCenter(belt) // { x: 176.78, y: 176.78 } — mid-arc
fieldCenter(cluster) // { x: -120, y: 60 } — its own centreFloating point, and round()
Ask for the cosine of ninety degrees and you get this:
orbitPos(100, 90, 0, 0)
// { x: 6.123233995736766e-15, y: 100 }
// ↑ zero, as far as anything visual is concernedThat is not a bug, it is binary floating point, and every library that does trigonometry produces it. It is invisible on screen. It becomes a nuisance the moment you serialize — 0.30000000000000004 in a save file is noise that doubles your JSON for no precision anyone can use.
So the library exports the rounding helper it uses internally, and you can use it at the same boundary:
import { round } from '@industrieh/starmap'
round(0.30000000000000004) // 0.3
round(1.23456, 3) // 1.235 — the second argument is decimal placesRound at the edges, not in the middle
t is the only input
systemPositions() is a pure function of the system and a number. Nothing is accumulated, nothing is stored between calls. Which quietly buys you a lot:
- Rewind and fast-forward are free. Pass
t - 60to see where everything was a minute ago. There is no simulation to run backwards. - A save file is one number. Store
tand the universe resumes exactly where it was. - Server and client agree without talking. Same galaxy, same
t, same positions — no synchronisation of orbital state. - Tests are trivial. No fake timers, no waiting. Assert on a position at
t = 12.
Nothing in this library accumulates. Every position is recomputed from scratch, and that is why none of them can drift.
The same principle drives the other half of the library: Deterministic randomness, where a seed plays the role that t plays here.