starmap

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 is system.x and system.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 y once at the boundary.
  • Angles are degrees, measured clockwise, starting from the positive X axis. angle: 0 is to the right; angle: 90 is 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 centre

Gates 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

Stations orbit planets; nothing orbits stations. Moons are a count on the planet, not objects — which keeps this composition to exactly two terms and the whole thing predictable.

A render loop

Because positions are derived, the loop is just: advance a number, ask for the map, draw.

render.ts
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

One call costs a single pass over the objects of one system — a few dozen at most. Wrapping it in a memo would cost more than it saves, and would introduce a staleness bug you did not have.

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 centre

Floating 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 concerned

That 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 places

Round at the edges, not in the middle

Round when you write to disk or hand a number to a renderer. Rounding inside a computation accumulates error instead of removing it.

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 - 60 to see where everything was a minute ago. There is no simulation to run backwards.
  • A save file is one number. Store t and 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.