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
| Field | Type | Description |
|---|---|---|
systemViewBox | (system, margin?) => ViewBoxdefault SYSTEM_MARGIN = 0.45 | Square, 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.12 | The 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'margin: 0— cropped to the outermost object, which here is the gate's orbit.0.45. The same drawing, with room to breathe around it.A bigger margin is how you leave room for your own overlay
renderSystem(system, { margin: 1.2 })Two degenerate cases are handled rather than left to break:
- a system whose
outerLimitis0or nonsense frames at100; - 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 pixelsThe 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 viewBoxThe 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
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' },
})| Field | Type | Description |
|---|---|---|
File | standalone | Assets, exports, documentation. Open it in anything. |
Inline | in the DOM | The only one where data-id is reachable. Costs a parse per render. |
data: URI | isolated | Cheap and safe, but a sealed box: no clicks, no hover, no styling from outside. |
Response | server-rendered | Cacheable like any image. The client ships no renderer at all. |
encodeURIComponent, not raw
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
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>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.