starmap

Guides

Framing & embedding

How a drawing decides what to show, how it scales to the space it is given, and the four places a string can go.

Nothing is ever a pixel

Every coordinate the renderer writes is in world units — the same units your galaxy is described in. A planet at orbit: 150 is drawn at 150.

The viewBox does the scaling. That is the whole reason one string is both a 32-pixel thumbnail and a full-screen map.

Which means resizing costs nothing: the same document, in a bigger box, is bigger. There is no re-render, no recalculation, no second string.

The two viewBoxes

Each renderer frames differently, because a system and a galaxy are shaped differently.

Margins

FieldTypeDescription
systemViewBox(system, margin?) => ViewBoxdefault SYSTEM_MARGIN = 0.45Square, centred on the origin — a system's star is always at (0, 0). The span is outerLimit plus the margin, as a fraction of it.
galaxyViewBox(galaxy, margin?) => ViewBoxdefault GALAXY_MARGIN = 0.12The bounding box of the systems, plus a margin as a fraction of the largest dimension. Not centred — a galaxy keeps the coordinates you gave it.
import { systemViewBox, galaxyViewBox, viewBoxAttr } from '@industrieh/starmap-render'

// Sol's outerLimit is 300, and the default margin adds 45%.
systemViewBox(system)
// { x: -435, y: -435, w: 870, h: 870 }

systemViewBox(system, 0)          // crop to the outermost object
// { x: -300, y: -300, w: 600, h: 600 }

// Two systems at (0,0) and (420,160).
galaxyViewBox(galaxy)
// { x: -50.4, y: -50.4, w: 520.8, h: 260.8 }   ← wider than tall, like the galaxy

viewBoxAttr(systemViewBox(system))   // '-435 -435 870 870'
Aurelia Vesper Sol Gate
margin: 0— cropped to the outermost object, which here is the gate's orbit.
Aurelia Vesper Sol Gate
The default 0.45. The same drawing, with room to breathe around it.

A bigger margin is how you leave room for your own overlay

Ship names, range circles, a selection ring — anything you draw yourself needs somewhere to go. Raising the margin gives it space without touching the model.
renderSystem(system, { margin: 1.2 })
Aurelia Vesper Sol Gate
margin: 1.2 — the system now occupies less than half the frame, and everything outside it is yours.

Two degenerate cases are handled rather than left to break:

  • a system whose outerLimit is 0 or nonsense frames at 100;
  • a galaxy with one system — or several in a straight line — would have a box with zero width or height, which renders as nothing. Both are clamped to at least 1.

Giving it a size

By default the root <svg> carries no width and no height, so it fills whatever container it is put in. Pass them when you want a fixed size:

renderSystem(system)                              // fills its box
renderSystem(system, { width: 700, height: 700 }) // exactly 700×700 pixels

The document is always preserveAspectRatio="xMidYMid meet": the drawing is centred, and scaled until it fits entirely. It is never cropped and never stretched.

Why the background is oversized

This is the one piece of the output that looks like a mistake and is not. The background rectangle is three times the viewBox, centred on it:

viewBox="-435 -435 870 870"
<rect x="-1305" y="-1305" width="2610" height="2610" fill="#070b16"/>
       ↑ three times as wide, and offset by one viewBox

The reason is preserveAspectRatio. When the box you give a drawing does not have the same shape as its viewBox, the renderer letterboxes it — and those bands sit outside the viewBox. A rectangle of exactly the right size would leave them transparent, showing the page through them.

What you would see without it

A square system drawn in a wide container would have two vertical strips of your page's own background down the sides. It reads as a rendering bug, and it is the first thing you notice on a non-square container — which is why it is worth testing one.
Aurelia Vesper Sol Gate
Every frame on this page is wider than it is tall, and the system inside it is square — so all of them are letterboxed. The reason you cannot tell is the oversized rectangle: the dark background runs edge to edge instead of stopping at the viewBox.

Four ways to embed it

It is a string, so there is no single right answer.

// 1. A file — the drawing is standalone, xmlns and all.
await Bun.write('sol.svg', renderSystem(system))

// 2. Inline in a page — the SVG becomes part of the DOM, so CSS and
//    event listeners reach inside it. This is what you want for interaction.
container.innerHTML = renderSystem(system)

// 3. An <img> — the browser never parses it into your document. Cheapest,
//    isolated from your CSS, and nothing inside is clickable.
img.src = `data:image/svg+xml;utf8,${encodeURIComponent(renderSystem(system))}`

// 4. A server response — rendered where the data is, cached like an image.
return new Response(renderSystem(system), {
  headers: { 'content-type': 'image/svg+xml' },
})
FieldTypeDescription
FilestandaloneAssets, exports, documentation. Open it in anything.
Inlinein the DOMThe only one where data-id is reachable. Costs a parse per render.
data: URIisolatedCheap and safe, but a sealed box: no clicks, no hover, no styling from outside.
Responseserver-renderedCacheable like any image. The client ships no renderer at all.

encodeURIComponent, not raw

A raw SVG in a data: URI breaks on the first # — which every hex colour in the theme contains. Encode it, always.

Two drawings on one page

A drawing declares gradients in <defs>, and SVG ids are global to the page. Two systems inlined side by side with the same prefix would both reference the first one's gradients — so the second one wears the first one's star colour.

// Wrong — both drawings declare #starmap-glow.
container.innerHTML = renderSystem(sol) + renderSystem(vega)

// Right — the system's own id is a natural prefix.
container.innerHTML =
  renderSystem(sol,  { idPrefix: sol.id }) +
  renderSystem(vega, { idPrefix: vega.id })

It only matters when inlining

A data: URI and an <img> each get their own document, so ids cannot collide. This is an inline-SVG problem exclusively.

Accessibility

Pass title and the document gains a <title> child and role="img" — which is what makes a screen reader announce it instead of skipping it.

renderSystem(system, { title: `${system.name} — ${system.planets.length} planets` })

// <svg … role="img">
//   <title>Sol — 2 planets</title>
Sol — 2 planets Aurelia Vesper Sol Gate
Visually identical, and announced as “Sol — 2 planets” instead of skipped. Hover it and your browser will show the title too.

Without it there is no role and no title, which is the right default for a purely decorative drawing. Say something useful, or say nothing.

Colour is not the only channel: route styles carry distinct dash patterns for the same reason.