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.
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
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
<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 nofleetlayer at all;text()escapes its content, which matters the moment a ship is namedOhm & 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',
})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
})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
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.svgWorth 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.