starmap

Guides

Theming

Every colour the renderer reads, where it comes from, and how to replace one without restating the other twenty.

Why colour lives here

The core carries no color and no display label. Mapping planet.type to an appearance is a decision of the project that displays it — and a colour written into the model stops being usable the day that project needs a light theme, a colourblind mode, or a fixed brand palette.

No colour in the core, and no world fact in the theme. That line is why the two packages are separate.

So the theme is a parameter. It enters at the call, and the same universe can be drawn three different ways on the same page.

How a theme merges

There are two layers, exactly like the core's GalaxyInput and Galaxy: ThemeInput is what you write and every field is optional; Theme is what the drawing functions read and everything is filled in.

Tables merge key by key, so overriding one colour keeps the rest:

renderSystem(system, {
  theme: { background: '#000', planets: { terran: '#7ee787' } },
})

// background        → '#000'      yours
// planets.terran    → '#7ee787'   yours
// planets.gas       → '#d8a06a'   still the default
// planets.default   → '#8b8377'   still the default
Aurelia Vesper Sol Gate
The default theme, untouched.
Aurelia Vesper Sol Gate
One key changed — { planets: { terran: '#f472b6' } }. Vesper is pink; Aurelia, the star, the belt and the gate are all still the default.

A RouteStyle is replaced whole

Merging stops at the table level. { routes: { mining: { … } } } replaces the whole mining style, because a dash pattern without its width is not a style. Give all three fields.

Setting a field to undefined does not erase it

{ background: undefined } falls back to the default rather than leaving a hole in the drawing. That is deliberate — a spread would otherwise let an optional variable blank out a colour and produce a transparent document.

The flat colours

Thirteen single values. Every one of them is a string, passed straight into SVG.

FieldTypeDescription
backgroundstringdefault #070b16Fills the whole view.
backdropStarstringdefault #cdd9ffThe decorative star field behind everything.
labelstringdefault #dbe4f5Text colour.
fontstringdefault ui-sans-serif, system-ui, sans-serifA font stack, written onto the label groups. Not a colour, but it belongs with them.
orbitstringdefault #2b3d63Planet orbit circles.
gateOrbitstringdefault #43356eGate orbit circles, which are dashed.
lanestringdefault #39507aHyperlanes, on the galaxy map.
highlightstringdefault #9a7bffSystems and lanes on the path passed as highlight.
habitablestringdefault #4fd8a5The ring around a planet whose habitable is true.
gatestringdefault #9a7bffThe gate body.
gateUnstablestringdefault #ff6b81The ring around a gate whose status is unstable.
fieldstringdefault #6b5a2aOutline of an asteroid belt or cluster.
rockstringdefault #a89468The rocks inside a field.

The keyed tables

Four colour tables, each keyed by a value from the model. Here they are with their defaults:

stars: {          // by star.class
  O: '#9bb0ff',  B: '#aabfff',  A: '#cad7ff',  F: '#f8f7ff',
  G: '#ffe9a8',  K: '#ffc16f',  M: '#ff8f5a',
  default: '#ffe9a8',
}

planets: {        // by planet.type
  lava: '#e2542c',      rocky: '#a08d7d',   desert: '#d9a45b',
  barren: '#8b8377',    terran: '#4fae6a',  ocean: '#2f8fd8',
  toxic: '#9ac93b',     gas: '#d8a06a',     'ice-giant': '#6fb6d9',
  frozen: '#cfe4f0',
  default: '#8b8377',
}

stations: {       // by station.type
  trade: '#61d6c4',     mining: '#c9a227',  military: '#ff6b81',
  research: '#7fb3ff',  shipyard: '#d0a6ff',
  default: '#9fb4d8',
}

factions: {       // by faction.id — drawn as the ring around a system
  neutral: '#8e9bb3',   consortium: '#4ea3ff',  covenant: '#ff5c72',
  syndicate: '#c78bff', pilgrims: '#4fd8a5',
  default: '#8e9bb3',
}

Every table needs a default

This is the part people trip on. The core's vocabularies are open unions: a project can invent type: 'shattered' and the model accepts it. A colour table therefore cannot be exhaustive.

import { pickColor, resolveTheme } from '@industrieh/starmap-render'

const theme = resolveTheme({ planets: { terran: '#7ee787' } })

pickColor(theme.planets, 'terran')      // '#7ee787'
pickColor(theme.planets, 'shattered')   // '#8b8377' — the default
pickColor(theme.planets, undefined)     // '#8b8377' — the default too

default is required by the type, so a table you build by hand cannot forget it. That is what guarantees a drawing is never missing a colour.

Route styles

Local routes are the one table whose values are objects rather than strings — a route needs a stroke, a dash pattern and a width.

routes: {         // by localRoute.type
  orbital:     { stroke: '#5f7fa8', dash: '6 8',  width: 1.1 },
  shortcut:    { stroke: '#43607f', dash: '2 10', width: 0.9 },
  docking:     { stroke: '#61d6c4', dash: '3 4',  width: 0.8 },
  'jump-lane': { stroke: '#9a7bff', dash: '10 6', width: 1.4 },
  mining:      { stroke: '#c9a227', dash: '4 6',  width: 0.9 },
  default:     { stroke: '#5f7fa8', dash: '6 8',  width: 1.1 },
}

The dash patterns are an accessibility decision

Each route type gets a distinct pattern, not just a distinct colour — so the map stays readable in greyscale, and to a reader who cannot tell two of the colours apart. Changing a colour is safe; collapsing two patterns into one costs that.

Writing a light theme

The default theme is dark. Nothing in the renderer assumes that — a light theme is the same object with different values.

theme-light.ts
import type { ThemeInput } from '@industrieh/starmap-render'

export const LIGHT: ThemeInput = {
  background: '#f8fafc',
  backdropStar: '#94a3b8',
  label: '#0f172a',
  orbit: '#cbd5e1',
  gateOrbit: '#ddd6fe',
  lane: '#94a3b8',
  highlight: '#7c3aed',
  habitable: '#059669',
  gate: '#7c3aed',
  gateUnstable: '#e11d48',
  field: '#d6c9a0',
  rock: '#a8a29e',
  // The tables merge, so only the colours that need to change are listed.
  planets: { frozen: '#7dd3fc', terran: '#16a34a' },
}

renderSystem(system, { theme: LIGHT })
Aurelia Vesper Sol Gate
The default, dark.
Aurelia Vesper Sol Gate
The theme above. Same system, same t — thirteen values and one table.

Check the backdrop when you go light

backdropStar is the one that catches people out. Left at its default it is a pale blue, which all but disappears on a pale background — and you get a drawing that looks oddly empty for no obvious reason.
Aurelia Vesper Sol Gate
The trap. Background, label and orbit changed — backdropStar forgotten. The star field is still there, and you can barely see it.
Aurelia Vesper Sol Gate
Fixed. One more line, backdropStar: '#94a3b8', and the sky comes back.

Resolving once

resolveTheme() is cheap but not free — it builds five merged tables. If you render every frame, resolve once outside the loop.

import { resolveTheme } from '@industrieh/starmap-render'

// Once.
const theme = resolveTheme(LIGHT)

// Then per frame — a resolved Theme is a valid ThemeInput, so it just passes through.
function frame(t: number) {
  container.innerHTML = renderSystemContent(system, { t, theme })
}
  • DEFAULT_THEME is exported, so you can read a value or spread from it.
  • pickColor(table, key) and pickRouteStyle(table, key) are exported too — useful when you draw your own overlay and want it to match.