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 mapThat 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.
| Field | Type | Description |
|---|---|---|
svg.ts | imports: the core | Only for round(). The bottom of the stack. |
theme.ts | imports nothing | Entirely standalone — colours know nothing about drawing. |
geometry.ts | imports: the core | hashSeed, mulberry32, round, toRad. Framing and arcs. |
root.ts | geometry, svg | Wraps finished layers. Knows nothing about what is inside them. |
system.ts | all of the above | The largest file, and the only one that knows what a planet looks like. |
galaxy.ts | all of the above | Its sibling. Shares no drawing code with it, on purpose. |
system.ts and galaxy.ts share nothing but primitives
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 itNo 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 — includingNaN, which becomes0rather than the stringNaNthat 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 & <Sons>'
group({ id: 'x' }, ['', '']) // '' — an empty group is not emittedA change here reaches every drawing
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
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 Scale — u, 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.