starmap

Guides

Recipes

Task-by-task answers. Everything here is a consequence of the same fact — the output is a string, so it goes wherever a string goes.

Export a set of files

The whole universe as browsable assets: one file per system, one for the map. No browser involved.

export.ts
import { indexSystems } from '@industrieh/starmap'
import { renderGalaxy, renderSystem } from '@industrieh/starmap-render'

const systems = indexSystems(galaxy)

await Bun.write('out/galaxy.svg', renderGalaxy(galaxy, { title: 'The known galaxy' }))

for (const system of galaxy.systems) {
  await Bun.write(
    `out/${system.id}.svg`,
    renderSystem(system, {
      title: system.name,
      idPrefix: system.id,   // each file is its own document, but this keeps them safe to inline later
      systems,               // so gate labels name where they lead
    }),
  )
}

Render on a server

The client ships no renderer at all — it receives an image. Because the output is deterministic, it caches like a static asset.

Bun.serve({
  routes: {
    '/system/:id.svg': (request) => {
      const system = systems.get(request.params.id)
      if (!system) return new Response('Not found', { status: 404 })

      return new Response(renderSystem(system, { title: system.name }), {
        headers: {
          'content-type': 'image/svg+xml',
          // Same input, same bytes — so this is safe.
          'cache-control': 'public, max-age=3600',
        },
      })
    },
  },
})

A moving drawing is still cacheable

Round t to the granularity you actually need — Math.floor(t) gives you one distinct image per second, and a whole minute of animation is sixty cache entries.

Use it in React

There is no component to import, and that is the point. Two ways in, depending on whether you need to click things.

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

// Interactive: the SVG becomes part of the DOM, so data-id is reachable.
function SystemView({ system, t }: { system: StarSystem; t: number }) {
  const svg = useMemo(() => renderSystem(system, { idPrefix: system.id, t }), [system, t])
  return <div className="h-full" dangerouslySetInnerHTML={{ __html: svg }} />
}

// Static: cheaper, isolated from your CSS, and nothing inside is clickable.
function SystemThumb({ system }: { system: StarSystem }) {
  const src = useMemo(
    () => `data:image/svg+xml;utf8,${encodeURIComponent(renderSystem(system, { backdrop: 0 }))}`,
    [system],
  )
  return <img src={src} alt={system.name} width={96} height={96} />
}

dangerouslySetInnerHTML is fine here

The name is about untrusted HTML. Every name in the galaxy goes through XML escaping before it reaches the string, so a system called <script> comes out as text. The danger the prop warns about is the one the package already removed.

Draw your own overlay

Ships, range circles, a selection ring — anything the model does not know about. The string primitives are exported for this, and your overlay goes last so it is drawn on top.

import { systemPositions } from '@industrieh/starmap'
import {
  el, group, text, renderSystemContent, systemViewBox, viewBoxAttr,
} from '@industrieh/starmap-render'

function draw(system: StarSystem, t: number, ships: Ship[]) {
  const at = systemPositions(system, t)

  const fleet = group(
    { 'data-layer': 'fleet' },              // your own layer name
    ships.map((ship) =>
      group({ 'data-kind': 'ship', 'data-id': ship.id }, [
        el('circle', { cx: ship.x, cy: ship.y, r: 3, fill: '#fff' }),
        text(ship.name, { x: ship.x + 6, y: ship.y, 'font-size': 8, fill: '#fff' }),
      ]),
    ),
  )

  return `<svg viewBox="${viewBoxAttr(systemViewBox(system))}">` +
    renderSystemContent(system, { t }) +
    fleet +
    '</svg>'
}
  • el()formats numbers to two decimals and escapes attributes, so your overlay gets the same treatment as the package's own output;
  • group() drops empty parts — no ships means no fleet layer at all;
  • text() escapes its content, which matters the moment a ship is named Ohm & Sons.

Match the theme

resolveTheme() and pickColor() are exported too. Colour your overlay from the same table and it stays consistent when someone switches to a light theme.

Show a route on the map

The core returns exactly what the renderer expects, which is not a coincidence — it is why Route.systemIds is an ordered array.

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

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

const map = renderGalaxy(galaxy, {
  highlight: route?.systemIds,
  title: route ? `${route.jumps} jumps, ${Math.round(route.distance)} units` : 'No route',
})
Sol Vega Rigel Altair Lyra
Altair → Sol → Rigel → Lyra. The three lanes of the path are brought forward; the two that are not on it stay dim.

Order matters: consecutive entries light the lane between them. Passing an unordered set of ids highlights the systems and none of the lanes.

Thumbnails

The same drawing at 64 pixels. Two options are worth turning off, and neither has anything to do with size:

renderSystem(system, {
  width: 64,
  height: 64,
  labels: false,    // unreadable at this size, and a third of the string
  backdrop: 0,      // a few hundred circles nobody will see
  margin: 0.1,      // crop closer — a thumbnail wants the subject, not the void
})
Aurelia Vesper Sol Gate
The full drawing.
The thumbnail settings. Note that nothing shrank — the objects keep their real sizes; what changed is what is drawn and how tightly it is framed.

Everything else scales on its own, because sizes inside the drawing are fractions of the view rather than pixels. See Two kinds of size.

Test a drawing

Assert on what must be true, never on the markup. A test that pins a stroke width fails the next time someone tunes it, and tells you nothing.

// Good — survives any visual change that keeps the meaning.
expect(svg).toContain('data-kind="planet" data-id="earth"')
expect(svg.match(/data-kind="planet"/g)).toHaveLength(3)
expect(svg).not.toContain('data-layer="backdrop"')   // when backdrop: 0

// Bad — breaks on the next tweak, for no benefit.
expect(svg).toContain('<circle r="6" fill="#4fae6a" stroke-width="0.87"/>')

Because the output is deterministic, a snapshot test is also viable — expect(renderSystem(sol, { t: 3 })).toMatchSnapshot(). It will fail on every visual change, which is either exactly what you want or exactly what you do not.

Cover the degenerate cases

The core makes most fields optional, so the renderer meets them constantly. A system with no planets, a planet with no type, an empty galaxy, a non-square width/height. That last one is what catches a background that does not cover the letterboxed bands.

Actually look at it

Tests do not tell you whether a drawing looks right. This is the shortest path from a change to a picture:

bun -e '
  import { renderSystem } from "@industrieh/starmap-render"
  import { galaxy } from "./universe"
  await Bun.write("/tmp/sol.svg", renderSystem(galaxy.systems[0], { t: 3, width: 700, height: 700 }))
' && open /tmp/sol.svg

Worth a look every time you change something visual:

  • a large system and a small one — the two ends of the scale problem;
  • a system with no planets;
  • the drawing at 64 pixels;
  • a non-square width/height — the letterboxing case.