Under the hood
Design decisions
Four decisions shape this package, and each one closes a door on purpose. Here is what is behind each door, and what you get for not opening it.
Strings, not components
The obvious API would be <StarSystem system={sol}/>. It would also pick a framework for you, and then a version of it, and then a rendering model.
A component is consumed in one place. A string is consumed everywhere.
What a string buys, concretely:
- It works on a server. No DOM, no jsdom, no headless browser — render where the data is and send an image.
- It works in a worker. Off the main thread, for a galaxy big enough to matter.
- It works as a file. A build script that writes assets needs no runtime at all.
- It works in every framework, including the one you switch to next year.
The cost is one line of glue at the boundary — innerHTML, dangerouslySetInnerHTML, Bun.write. See Four ways to embed it.
No interaction
Pan, zoom, selection, hover, the clock — none of it is here. Not because it is hard, but because it is application state, and every project holds it differently: React state, a store, a game loop, a server session.
A package that owned the camera would have to own the event handling, then the state container, then the framework. So instead it draws one frame for one moment and labels everything it draws:
<g data-layer="planets">
<g data-kind="planet" data-id="sol-p1">…</g>
</g>Three attributes are the whole interaction API. They are enough for click, hover, selection, layer toggling and pan-zoom — the guide builds all five, and none of them needs the package to change.
A drawing is a pure function
Same data, same t, same theme, same string — byte for byte. No clock, no Math.random(), no ambient state anywhere in the package.
That is not purity for its own sake. It is what makes four things possible:
- caching, including HTTP caching, because the same request really does produce the same bytes;
- snapshot tests, which would be worthless against a moving target;
- diffing two drawings to see what a change did;
- a server and a client agreeing without exchanging anything but
t.
Even the backdrop is deterministic
Why two packages
One line, and it is the reason this package exists at all:
No colour in the core, and no world fact in the theme.
A color field in the model looks convenient and forces a UI decision at the moment you are describing your world. It stops being usable the day the project needs a light theme, a colourblind mode, or a brand palette — and by then it is in every save file.
The other direction matters just as much. This package never asks for a field to be added to the model for the sake of a drawing. If a value describes the universe, it belongs to the core; if it describes the picture, it belongs to the theme.
Two mechanisms enforce it, so it does not rely on review
Math.random and Date. And the TypeScript config drops DOM from lib, so a stray document. does not compile.Why SVG
Canvas would be the other obvious choice. SVG wins here for reasons that are specific to what this draws:
- It is text. Which is what makes the whole strings-not-components decision available in the first place.
- It scales for free. One document is a thumbnail and a wall map — the
viewBoxdoes the work, and zooming costs one attribute write. - It is inspectable.
data-idin the DOM means the browser does your hit-testing. On a canvas you would write that yourself, in world coordinates, per object. - It is accessible. A
<title>and aroleare announced. A canvas is an opaque rectangle.
Canvas wins on raw object count. If you are drawing a hundred thousand stars at sixty frames a second, this is the wrong tool — and the core, which knows nothing about rendering, is still the right one.
What this costs you
Being honest about the other half of each decision:
- You write the glue. No component to drop in. One line, but it is a line.
- You write the interaction. Click, hover, pan and zoom are yours — the guide makes them short, not free.
- Rebuilding a string per frame has a ceiling. Fine for one system, not for twelve. Past that you keep the SVG in the DOM and move groups yourself, which costs you the determinism that made the package pleasant.
- SVG has a ceiling too. Tens of thousands of elements is where a browser starts to struggle, and a very large galaxy gets there.
- No layout engine. Labels are placed by a simple rule, and in a dense system they can overlap. There is no collision avoidance, because doing it properly is a package of its own.
If those trades sound wrong for what you are building, that is useful to know early — which is why they are written down rather than discovered.