Guides
Making it interactive
The package has no click handler, no hover state, no pan and no zoom. This page is how you build all four on top of it — which is easier than it sounds, because the drawing tells you what everything is.
None of it is in the package
That is a decision, not an omission. Selection, hover, the camera and the clock are application state, and every project holds them differently — in React state, in a store, in a game loop, on a server.
The package draws one frame for one moment. What is selected, where the camera is and what time it is are yours.
What it does instead is label everything it draws, so your code can find any object without knowing how the drawing is built.
The three hooks
| Field | Type | Description |
|---|---|---|
data-layer | on each <g> | backdrop, orbits, fields, routes, planets, gates for a system; backdrop, lanes, systems for a galaxy. |
data-kind | on each object | star, planet, station, gate, field, system. |
data-id | on each object | The model's own id — the same string you would pass to indexGalaxy(). That is what makes the two libraries line up. |
<g data-layer="planets">
<g data-kind="planet" data-id="sol-p1">
…
<g data-kind="station" data-id="sol-p1-st0">…</g>
</g>
</g>The id is the join between the two packages
sol-p1. indexGalaxy(galaxy).get('sol-p1') gives you the object, its kind and its system. You never have to thread anything through the drawing.Click and hover
One listener on the container, and closest() to find which object was hit. No listener per element, and nothing to re-attach when you re-render.
import { indexGalaxy } from '@industrieh/starmap'
import { renderSystem } from '@industrieh/starmap-render'
const index = indexGalaxy(galaxy) // built once
container.innerHTML = renderSystem(system, { t: 0 })
container.addEventListener('click', (event) => {
const target = (event.target as Element).closest('[data-id]')
if (!target) return
const id = target.getAttribute('data-id')!
const entry = index.get(id)
if (entry?.kind === 'planet') {
console.log(`${entry.obj.name} — ${entry.obj.stations.length} stations`)
}
})Hover is the same shape with mouseover. Because the handler sits on the container, it keeps working after every re-render — which matters when you are re-rendering sixty times a second.
An <img> is not clickable
data: URI or an <img src> is a sealed document — the browser never puts its contents in your DOM, so there is nothing to click. Inline it if you want interaction.Showing a selection
Two ways, and the difference is worth understanding because it decides how much work a selection costs.
With CSS — nothing is re-rendered. The drawing is already in the DOM, so a stylesheet reaches it:
/* Your stylesheet, not the package's — it ships none. */
[data-kind='planet'][data-selected] circle {
stroke: #fff;
stroke-width: 2;
}// Then just move an attribute around.
function select(id: string) {
container.querySelector('[data-selected]')?.removeAttribute('data-selected')
container.querySelector(`[data-id="${id}"]`)?.setAttribute('data-selected', '')
}By drawing it yourself — when the highlight is a shape rather than a style. The string primitives are exported for exactly this:
import { systemPositions } from '@industrieh/starmap'
import { el, renderSystemContent, systemViewBox, viewBoxAttr } from '@industrieh/starmap-render'
const at = systemPositions(system, t)
const p = at.get(selectedId)
const ring = p
? el('circle', { cx: p.x, cy: p.y, r: 18, fill: 'none', stroke: '#fff', 'stroke-width': 2 })
: ''
// Your overlay goes last, so it is drawn on top.
container.innerHTML =
`<svg viewBox="${viewBoxAttr(systemViewBox(system))}">` +
renderSystemContent(system, { t }) +
ring +
'</svg>'Your overlay is in world units too
Pan and zoom
This is where renderSystemContent() earns its place. Pan and zoom are nothing but a different viewBox — so the application owns the root element, and the drawing inside it never changes.
import { galaxyViewBox, renderGalaxyContent, viewBoxAttr } from '@industrieh/starmap-render'
const base = galaxyViewBox(galaxy)
const svg = document.querySelector('svg')!
// The layers are rendered once — they do not depend on the camera.
svg.innerHTML = renderGalaxyContent(galaxy)
let camera = { ...base }
function apply() {
svg.setAttribute('viewBox', viewBoxAttr(camera))
}
function zoom(factor: number, cx: number, cy: number) {
const w = camera.w * factor
const h = camera.h * factor
// Keep the point under the cursor fixed.
camera = { x: cx - (cx - camera.x) * factor, y: cy - (cy - camera.y) * factor, w, h }
apply()
}
function pan(dx: number, dy: number) {
camera = { ...camera, x: camera.x - dx, y: camera.y - dy }
apply()
}
apply()Zooming costs one attribute write. Nothing is re-rendered, no string is rebuilt, and the browser scales vector geometry it already has — which is why this stays smooth on a large galaxy.
Screen pixels to world units
camera.w / svg.clientWidth to get world units, and pan by that. The ratio is also your current zoom level.Toggling a layer
data-layer makes this a one-liner, and it needs no re-render either:
function setLayer(name: string, visible: boolean) {
const layer = container.querySelector(`[data-layer="${name}"]`)
if (layer instanceof SVGElement) layer.style.display = visible ? '' : 'none'
}
setLayer('backdrop', false) // a cleaner map for a screenshot
setLayer('orbits', false) // just the bodiesOr skip it at render time — { backdrop: 0 } and { labels: false } produce a smaller string in the first place.
When re-rendering gets expensive
Rebuilding the string every frame is fine for one system. It stops being fine when you have several on screen, or a galaxy with thousands of systems. In order of how much they buy you:
- Do not re-render what did not change. The galaxy map takes no
t— render it once and only touch itsviewBox. - Turn the backdrop off when animating.
{ backdrop: 0 }removes a few hundred circles that never move. - Move groups instead of re-rendering. Keep the SVG in the DOM and set a
transformon each planet's group fromsystemPositions(). The package is no longer involved per frame. - Resolve the theme once, outside the loop.
// The last one, concretely.
container.innerHTML = renderSystem(system, { t: 0 }) // once
function frame(t: number) {
const at = systemPositions(system, t)
for (const planet of system.planets) {
const p = at.get(planet.id)!
const g = container.querySelector(`[data-id="${planet.id}"]`)
g?.setAttribute('transform', `translate(${p.x} ${p.y})`)
}
}Measure before you do any of this