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.
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 })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
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.
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()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
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
})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 keyEvery table has a default entry
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()andrenderGalaxy()return complete SVG documents;- the
…Content()pair returns the layers alone, for when you own the root element; tis the only thing that changes between frames, and nothing is stored;data-layer,data-kindanddata-idare how an application finds anything;- the theme merges over the default, table by table, key by key.
Where to go next:
- Drawing — the layers, and how each object is constructed.
- Theming — every colour, with its default.
- Making it interactive — click, hover, pan and zoom.