starmap

Under the hood

Architecture

How the source is laid out and where a change belongs. Six files, about 1,400 lines including the comments.

The map

src/
  index.ts          the public surface exports, and nothing else
  core/
    svg.ts          the string primitives: el, group, text, num, escapeXml
    theme.ts        the colours, and how a ThemeInput resolves
    geometry.ts     framing, arcs, the decorative backdrop
    root.ts         the <svg> element that wraps a drawing
    system.ts       drawing one system
    galaxy.ts       drawing the whole map

That is also the reading order. Each file opens with a header comment explaining what it is for, and each one only knows about the ones above it.

FieldTypeDescription
svg.tsimports: the coreOnly for round(). The bottom of the stack.
theme.tsimports nothingEntirely standalone — colours know nothing about drawing.
geometry.tsimports: the corehashSeed, mulberry32, round, toRad. Framing and arcs.
root.tsgeometry, svgWraps finished layers. Knows nothing about what is inside them.
system.tsall of the aboveThe largest file, and the only one that knows what a planet looks like.
galaxy.tsall of the aboveIts sibling. Shares no drawing code with it, on purpose.

system.ts and galaxy.ts share nothing but primitives

They draw different things at different scales — a planet has a real radius, a system dot is a fraction of the view. Factoring their draw* functions together would produce one function with a flag, which is how a renderer becomes unreadable.

The pipeline

Both renderers have the same shape, and it is four steps long:

options

     ├─► resolveTheme(input)        a complete Theme
     ├─► systemViewBox(system)      the frame, in world units
     └─► systemPositions(system, t) where everything is from the core


     draw each layer, in order      an array of strings


     drop the empty ones, join      the content


     svgDocument(view, content)     the <svg> around it

No state, no mutation, no second pass. A layer that produces nothing is dropped before the join, which is why an empty group is never emitted.

One file writes tags

svg.ts is the only place in the package where a < character is written. That is deliberate, and it is what makes two mistakes impossible rather than merely discouraged:

  • Forgetting to escape. Every name in a galaxy is user data. A drawing function never builds a tag itself, so it cannot skip escapeXml().
  • Forgetting to round. Coordinates come out of trigonometry. num() catches them all — including NaN, which becomes 0 rather than the string NaN that no SVG reader accepts.
el('circle', { r: 4.005, fill: '#fff' })      // '<circle r="4" fill="#fff"/>'
el('circle', { r: 1, hidden: false })         // '<circle r="1"/>'  — false drops the attribute
num(130.00000000000003)                       // '130'
num(NaN)                                      // '0'
dash(3.2624999999999997, 4)                   // '3.26 4'
escapeXml('Ohm & <Sons>')                     // 'Ohm &amp; &lt;Sons&gt;'
group({ id: 'x' }, ['', ''])                  // ''  — an empty group is not emitted

A change here reaches every drawing

Which is why a change to svg.ts comes with its tests first, and the change to the callers second.

Why every renderer comes in two

renderSystem() and renderSystemContent() are the same drawing, wrapped differently. The split exists because of one use case:

  • An application that pans and zooms owns the root element, because pan and zoom are the viewBox. It needs the layers, not a document.
  • Everything else wants a document — a file, an <img>, a server response.

So the content function is the real one, and the document function is three lines around it:

export function renderSystem(system: StarSystem, options: RenderSystemOptions = {}): string {
  const view = systemViewBox(system, options.margin ?? SYSTEM_MARGIN)
  return svgDocument(view, renderSystemContent(system, options), options)
}

systemViewBox() is exported for the same reason — an application owning the root element still wants the framing the package would have chosen.

A file named root.ts

It wraps a drawing in an <svg> element, so it should be called document.ts. It is not, and the reason is worth knowing before you rename it:

The purity test rejects the word document

Anywhere in the sources, import paths included — because that is how you catch a stray document.querySelector in a package that must never touch the DOM. A file named document.ts would make every import of it a false positive, and the check is worth more than the better file name.

Adding something

Four common changes, and what each one touches.

Change how an object looks — the matching draw* function in system.ts or galaxy.ts. Then look at it: a visual change needs eyes on it, not only a green test run. Sizes go through the file's Scaleu, k, font — never in absolute units.

Add a colour — it goes in theme.ts, in both Theme and ThemeInput, with a value in DEFAULT_THEME. A new keyed table also needs merging in resolveTheme() and a required default entry.

Add a render option — the options interface, then the destructuring at the top of renderXContent() with its default value. An option that only one of the two renderers honours is a smell; if it applies to both, put it in both.

Add a drawable object — a draw* function returning a string, and its place in the layer order. Order is the composition. Give its group a data-kind and a data-id so an application can find it, and its colour a home in the theme.

The full checklists, with the tests and docs each change needs, are in Contributing.