starmap

Get started

Quickstart

Ten minutes from a galaxy to a picture that moves. Every string and number on this page is real — it came from running the code.

The mental model

One idea, and it explains every decision in this package:

It is a function from data to a string. It draws one frame, for one moment, and hands it back. What happens to that string is entirely yours.

Three consequences worth having in mind before you start:

  • No components. A component would pick a framework for you. A string goes in a file, a page, a data: URI, a server response, a worker message.
  • No interaction. Pan, zoom, hover, selection and the clock are application state. The package gives you the hooks — data-id, data-kind, data-layer — and stays out of the way.
  • Same input, same bytes. No clock, no unseeded randomness. Which means you can cache it, diff it, or snapshot-test it.

Your first drawing

Build a universe

The renderer draws what the core produces, so everything starts with a Galaxy. If this part is unfamiliar, the core Quickstart covers it.

universe.ts
import { createGalaxy } from '@industrieh/starmap'

const galaxy = createGalaxy({
  systems: [
    {
      id: 'sol', name: 'Sol', x: 0, y: 0,
      faction: { id: 'consortium', name: 'Consortium' },
      star: { class: 'G', radius: 18 },
      planets: [
        { name: 'Aurelia', type: 'rocky', orbit: 95, radius: 5, period: 12 },
        {
          name: 'Vesper', type: 'terran', orbit: 165, radius: 7,
          angle: 40, period: 26, habitable: true,
          stations: [{ name: 'High Anchorage', type: 'trade', orbit: 18, period: 5 }],
        },
      ],
      asteroidFields: [
        { kind: 'belt', name: 'Inner Belt', orbit: 235, width: 22, arcStart: 20, arcSweep: 250 },
        { kind: 'cluster', name: 'The Shoals', x: -150, y: 120, radius: 34 },
      ],
      gates: [{ name: 'Sol Gate', orbit: 300, angle: 315, links: ['vega'] }],
      localRoutes: [
        { from: { kind: 'planet', id: 'sol-p1' }, to: { kind: 'station', id: 'sol-p1-st0' },
          type: 'docking' },
      ],
    },
    { id: 'vega',   name: 'Vega',   x: 420,  y: 160,  star: { class: 'A', radius: 22 },
      faction: { id: 'covenant', name: 'Covenant' } },
    { id: 'rigel',  name: 'Rigel',  x: 250,  y: -220, star: { class: 'B', radius: 26 } },
    { id: 'altair', name: 'Altair', x: -310, y: 90,   star: { class: 'K', radius: 14 } },
    { id: 'lyra',   name: 'Lyra',   x: 620,  y: -60,  star: { class: 'M', radius: 12 } },
  ],
  lanes: [
    { from: 'sol', to: 'vega' },
    { from: 'sol', to: 'rigel' },
    { from: 'sol', to: 'altair' },
    { from: 'vega', to: 'lyra' },
    { from: 'rigel', to: 'lyra' },
  ],
})

Draw one system

One call. It returns a complete, standalone SVG document.

import { renderSystem } from '@industrieh/starmap-render'

const svg = renderSystem(galaxy.systems[0], { t: 3 })
Aurelia Vesper Sol Gate
That string, rendered. Every drawing on this page was produced by the call above it while the page was being built — none of them is a screenshot.

Put it somewhere

Because it is a string, there is no single right answer — pick whichever fits where you are:

// A file
await Bun.write('sol.svg', svg)

// A page
container.innerHTML = svg

// An <img>, with no parsing at all
img.src = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`

// A server response
return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } })

Give it a size, or let it fill its box

By default the drawing carries no width or height, so it fills whatever container it is put in. Pass them when you need a fixed size — renderSystem(system, { width: 700, height: 700 }).

What came out

Open the string and it is readable. Here is how it starts:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="-435 -435 870 870"
     width="700" height="700" preserveAspectRatio="xMidYMid meet" role="img">
<title>Sol</title>
<defs>…</defs>
<rect x="-1305" y="-1305" width="2610" height="2610" fill="#070b16"/>
<g data-layer="backdrop"></g>
<g data-layer="orbits"></g>
<g data-layer="planets">
  <g data-kind="planet" data-id="sol-p1"></g>
</g>
<g data-layer="gates"></g>
</svg>

Three things in there are worth noticing right away.

The viewBox is in world units, not pixels. Sol's outerLimit is 300, and the default margin adds 45% — hence -435 -435 870 870. Nothing in this package is ever converted to pixels, which is exactly why the same string works as a 32-pixel thumbnail and as a full-screen map.

Every layer and every object is labelled. data-layer on the groups, data-kind and data-id on the objects. That is the entire interaction API — one querySelector and you have your planet.

Empty layers do not exist. A system with no asteroid fields produces no fields group at all, rather than an empty one. In the document above there is no routes layer, because that system declares no local routes.

Make it move

t is simulation time in seconds, and it is the only thing that changes between frames. Nothing is stored — render at t, render again at t + dt, and the planets have moved.

animate.ts
import { renderSystemContent, systemViewBox, viewBoxAttr } from '@industrieh/starmap-render'

// The framing does not change, so compute it once.
const box = viewBoxAttr(systemViewBox(system))
let t = 0

function frame() {
  t += 1 / 60
  // Only the layers — the application owns the <svg> element.
  container.innerHTML = `<svg viewBox="${box}">${renderSystemContent(system, { t })}</svg>`
  requestAnimationFrame(frame)
}

frame()
Aurelia Vesper Sol Gate
The same system at t = 3.
Aurelia Vesper Sol Gate
And at t = 16 — Aurelia has come round, Vesper has moved on, and its station went with it.

Content, or document?

renderSystem() gives you a whole <svg> — right for a file or an <img>. renderSystemContent() gives you the layers alone, so you own the root element and its viewBox — which is what pan and zoom need. Same drawing, two wrappings.

Rebuilding a string every frame is not free

It is fine for one system at sixty frames a second, and it is not fine for a dozen. If you get there, keep the SVG in the DOM and move the groups by their data-id instead of re-rendering — Recipes shows both.

Draw the whole map

The other renderer. Systems become dots, hyperlanes become the network between them, and nothing moves — at galaxy scale a planet is smaller than a rounding error, so this drawing takes no t at all.

import { findRoute } from '@industrieh/starmap'
import { renderGalaxy } from '@industrieh/starmap-render'

const route = findRoute(galaxy, 'sol', 'vega')

const map = renderGalaxy(galaxy, {
  highlight: route?.systemIds,   // exactly what findRoute returns
})
Sol Vega Rigel Altair Lyra
The route from Altair to Lyra, brought forward. Every system carries a ring: dashed and in its faction's colour normally, solid and in the highlight colour when it is on the path. Vega is the only one here that is not.

highlight takes an ordered list of system ids. Every listed system is brought forward, and so is every lane between two consecutive entries — which is why the order matters: ['a', 'b'] lights the lane between them, ['a', 'z'] lights nothing if they are not neighbours.

Change a colour

The theme is a parameter, and it is merged over the default one. Changing one colour does not mean restating the other twenty.

const svg = renderSystem(system, {
  theme: { background: '#000', planets: { terran: '#7ee787' } },
})

// background  → '#000'      your value
// terran      → '#7ee787'   your value
// gas         → '#d8a06a'   still the default — tables merge key by key
Aurelia Vesper Sol Gate
The default theme.
Aurelia Vesper Sol Gate
Three values changed — the background, and two planet colours. Everything else, including the star and the orbits, is still the default.

Every table has a default entry

The core's vocabularies are open unions: a project can invent type: 'shattered' and the model accepts it. A colour table therefore cannot be exhaustive, and every one of them carries a default used for any key it does not list. That is why a drawing is never missing a colour.

What you just learned

  • renderSystem() and renderGalaxy() return complete SVG documents;
  • the …Content() pair returns the layers alone, for when you own the root element;
  • t is the only thing that changes between frames, and nothing is stored;
  • data-layer, data-kind and data-id are how an application finds anything;
  • the theme merges over the default, table by table, key by key.

Where to go next: