starmap

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

FieldTypeDescription
data-layeron each <g>backdrop, orbits, fields, routes, planets, gates for a system; backdrop, lanes, systems for a galaxy.
data-kindon each objectstar, planet, station, gate, field, system.
data-idon each objectThe 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

A click gives you 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.

interaction.ts
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

A drawing embedded as a 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

The positions come from the core, in the same units the drawing uses. There is no conversion step — which is the point of never using pixels internally.

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.

camera.ts
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

A drag gives you pixels. Multiply by 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 bodies

Or 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 its viewBox.
  • 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 transform on each planet's group from systemPositions(). 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

One system is a few dozen objects and a 17 KB string. The naive loop is fast enough far longer than you would expect, and every optimisation above costs you the property that made the package pleasant: same input, same output.