starmap

Under the hood

Contributing

Thanks for taking the time. Everything below is specific to the renderer — the core has its own guide, and most of what makes a change good here comes down to one thing: a visual change needs eyes on it.

Get set up

git clone git@github.com:Industrieh/starmap.git
cd starmap
bun install
bun run check

Bun is the only thing you need installed — bun install resolves the whole workspace.

You never have to build the core first

This package depends on the core through workspace:*, and its tests alias @industrieh/starmap to the core's sources. Change something in the core and the renderer's tests see it immediately.

The commands

FieldTypeDescription
bun run testfrom the packageVitest, this package only.
bun run test:watchfrom the packageThe same, in watch mode.
bun run typecheckfrom the packagetsc --noEmit.
bun run buildfrom the packageTypecheck, then build dist/ with tsdown.
bun run checkfrom the rootFormat, lint, types and tests over everything. Run it before you push.

The house rule

src/ imports @industrieh/starmap, or something relative. Nothing else.

No DOM, no framework, no stylesheet, no clock, no unseeded randomness, no .tsx, no .css. A purity test checks every source file, and the TypeScript config drops DOM from lib — so a stray document. does not even compile.

Two consequences worth stating on their own.

A drawing is a pure function of its arguments. Same data, same t, same theme, same string — byte for byte. That is what lets a caller cache it, diff it or snapshot it, and it is why Math.random() and Date.now() are banned rather than discouraged.

The package returns strings and never touches the page.If a change needs an element, a listener or a stylesheet, it belongs in the application — and the package's job is to give it the hooks it needs, which today are data-id, data-kind and data-layer.

What belongs here

FieldTypeDescription
Drawingbelongs hereShapes, layers, framing, colours.
Interactiondoes notPan, zoom, selection, hover, the clock — application state, handled differently by every project. The package draws one frame for one moment.
Anything about the worlddoes notIf a value describes the universe rather than its picture, it goes in the core's data model. This package reads the model; it never asks for a field to be added to it for the sake of a drawing.

The line, in one sentence

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

Checklists

Read the code first, in this order: Architecture for the module map, Drawing for what each object is made of, Theming for the colours. Then the sources: svg.ts, theme.ts, geometry.ts, root.ts, system.ts, galaxy.ts.

Change how an object looks

Edit the draw function

src/core/system.ts or galaxy.ts, the matching draw*.

Look at it

Below. A visual change needs eyes on it, not only a green test run.

Assert on what must be true

In tests/system.test.ts — that a ring is there, that an id is there. Not on the exact markup.

Update the description

docs/drawing.md, if the construction changed.

Sizes go through the file's Scale

u, k, font — never absolute units. A drawing has to hold up in a system 300 units across and in one 30 000 units across.

Add a colour

  • src/core/theme.ts — add it to both Theme and ThemeInput, and give it a value in DEFAULT_THEME.
  • Use it in the drawing.
  • docs/theming.md — add the row, and the value in the defaults block.

A new keyed table also needs merging in resolveTheme() and a required default entry.

Add a render option

  • The options interface in system.ts or galaxy.ts, with a @default tag.
  • The destructuring at the top of renderXContent(), with the default value.
  • One test that it does something, one that the default is unchanged.
  • The option table in README.md.

An option that only some drawings honour 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, SVG has no z-index.
  • data-kind and data-id on its group, so an application can find it.
  • Its colour in the theme.
  • tests/fixture.ts — one of them, so every other test sees it too.
  • docs/drawing.md — how it is built.

Touch the string primitives

src/core/svg.ts is the only file that writes a tag. A change there reaches every drawing, so it comes with tests in tests/svg.test.ts first, and the change to the callers second.

Looking at what you changed

Tests do not tell you whether a drawing looks right. Render one and open it:

# from packages/starmap-render/
bun -e '
  import { renderSystem } from "./src/index"
  import { sol } from "./tests/fixture"
  await Bun.write("/tmp/sol.svg", renderSystem(sol, { t: 3, width: 700, height: 700 }))
' && open /tmp/sol.svg

Worth a look every time:

  • a large system and a small one;
  • a system with no planets;
  • the drawing at 64 pixels;
  • a non-square width/height — that last one is what catches a background that does not cover the letterboxed bands.

Writing tests

Vitest, in tests/. Assert on what must be true, not on the markup:

// good — survives any visual change that keeps the meaning
expect(svg).toContain('data-kind="planet" data-id="earth"')
expect(svg.match(/data-kind="planet"/g)).toHaveLength(3)

// bad — fails the next time a stroke width is tuned
expect(svg).toContain('<circle r="6" fill="#4fae6a" stroke-width="0.87"/>')

tests/fixture.ts holds one universe carrying one of everything drawable. Start there; build a galaxy inline only for a case the fixture cannot express — a degenerate one, usually.

Two habits that pay for themselves:

  • Check well-formedness with xmlProblems() whenever you add markup. It catches an unclosed tag and an unescaped name, which are the two ways a string renderer breaks.
  • Cover the degenerate case. An empty galaxy, a system with nothing in it, a planet with no type. The model makes most fields optional, so the drawing meets them.

Comments and docs

Every exported symbol has JSDoc: what it does in one line, then what a caller needs to know — what it returns when it finds nothing, what it costs, what has to be unique.

Comments in the body explain why. In this package that usually means why a number is what it is:

// The halo is six times the disc. It is what reads as brightness.
el('circle', { r: star.radius * 6, fill: `url(#${prefix}-glow)` })

A magic number with no comment

It is the one thing a reviewer will ask about, every time. Write the sentence while you still remember the answer.

The prose style is short sentences, code example first, explanation after. One idea per sentence.

FieldTypeDescription
README.mdfileWhat the package is, and how to use it.
docs/theming.mdfileEvery colour, and how to replace it.
docs/drawing.mdfileHow each object is constructed.
docs/recipes.mdfileHow-to answers, task by task.
docs/architecture.mdfileHow the code is built, for contributors.

Style

Formatting and lint are automatic — oxfmt and oxlint, configured at the root. No semicolons, single quotes, 100 columns. Run bun run fmt.

Two things the tools cannot decide:

  • Explicit types on exports. isolatedDeclarations is on, so every exported declaration carries its type — including a parameter with a default value.
  • One dependency. The core, and nothing else. A change that adds a second one needs a very good reason.

Sending a change

git checkout -b feat/short-description
bun run check
git commit -m "feat: draw rings on ice giants"

Commits follow Conventional Commits.

Show the drawing in the pull request

Before and after. A visual change is not reviewable from a diff — nobody can tell what r * 6 becoming r * 5.5 looks like by reading it.

Reporting a bug

Include the smallest galaxy that reproduces it, and say what you expected to see:

const galaxy = createGalaxy({
  systems: [{ id: 'a', name: 'A', x: 0, y: 0, star: { radius: 10 } }],
})
renderSystem(galaxy.systems[0])   // expected …, got …

If the output is malformed, paste it. If it merely looks wrong, attach the rendered image — that is faster than describing it.

Two things that are not bugs:

  • A field left undefined drawing as the fallback. The core never invents a value, so the drawing has one for every table.
  • A drawing that does not react to a click. There is no interaction here on purpose — data-id is the hook, and the handler is yours.