# starmap — full documentation Every README and guide of both packages, concatenated. Generated from the Markdown in the repository; the rendered version is at https://starmap.industrieh.dev. Sections are separated by a rule and the path of the file they came from. --- # Package: @industrieh/starmap v0.1.0-beta.0 Describe, validate and query a star universe. Source: https://github.com/Industrieh/starmap/tree/main/packages/starmap --- # @industrieh/starmap Describe a star system in a plain object. Get back a checked, queryable universe. ```ts import { createGalaxy } from '@industrieh/starmap' const galaxy = createGalaxy({ systems: [ { id: 'sol', name: 'Sol', x: 0, y: 0, star: { class: 'G', radius: 18 }, planets: [{ name: 'Earth', orbit: 150, radius: 6, period: 24 }], }, { id: 'alpha', name: 'Alpha', x: 400, y: 300, star: { radius: 14 } }, ], lanes: [{ from: 'sol', to: 'alpha' }], }) galaxy.systems[0].planets[0].id // "sol-p0" — filled in for you galaxy.lanes[0].length // 500 — measured from the positions galaxy.meta.planetCount // 1 ``` No dependencies. No DOM. Nothing about rendering. The same code runs in a browser, in Node or Bun, in a worker, on a server, under React Native, or inside a game engine. ## Contents - [Install](#install) - [What it does](#what-it-does) - [Build a universe](#build-a-universe) - [Query it](#query-it) - [Animate it](#animate-it) - [Draw it](#draw-it) - [Generate it with a script](#generate-it-with-a-script) - [API](#api) - [Guides](#guides) - [Working with an AI](#working-with-an-ai) ## Install ```bash bun add @industrieh/starmap ``` Not on npm yet. Until then, install it from a path or from git: ```bash bun add @industrieh/starmap@file:../starmap/packages/starmap bun add github:industrieh/starmap ``` Nothing to install alongside it — no dependencies, no peer dependencies. ## What it does | It does | It does not | | ---------------------------------------------- | ------------------------------------- | | give you a typed schema to describe a universe | generate a universe for you | | check that your description holds together | judge whether your world is plausible | | fill in ids, counts, distances, boundaries | invent factions, types, or colors | | index it and answer questions about it | draw anything | | tell you where everything is at time `t` | run a clock or a render loop | Both columns are deliberate. Procedural generation is **game design**. The shape of your galaxy, your faction table, how many planets a system gets — those are your decisions, not a library's. Rendering is **UI design**. Every project redoes it to fit its own look. What the two have in common is the data model. That is what this package is. ## Build a universe `createGalaxy(input)` takes your description, fills in what it can compute, checks the result, and hands back a `Galaxy`. ```ts import { createGalaxy, type GalaxyInput } from '@industrieh/starmap' const input: GalaxyInput = { systems: [ { id: 'sol', name: 'Sol', x: 0, y: 0, faction: { id: 'consortium', name: 'Vega Consortium' }, security: 0.9, star: { class: 'G', radius: 18 }, planets: [ { name: 'Sol I', type: 'rocky', orbit: 90, radius: 5, period: 12 }, { id: 'earth', // give it a readable id — it becomes a game-state key name: 'Earth', type: 'terran', orbit: 150, radius: 6, angle: 30, period: 24, habitable: true, stations: [{ name: 'High Anchorage', type: 'trade', orbit: 14, period: 4, docks: 8 }], }, ], gates: [{ name: 'Sol Gate', orbit: 300, angle: 45, links: ['alpha'] }], }, { id: 'alpha', name: 'Alpha', x: 400, y: 300, star: { radius: 14 } }, ], lanes: [{ from: 'sol', to: 'alpha' }], } const galaxy = createGalaxy(input) ``` ### What you must provide | object | required fields | | --------- | ------------------------------------------------ | | system | `id`, `name`, `x`, `y`, `star` | | star | `radius` | | planet | `name`, `orbit`, `radius` | | station | `name`, `orbit` | | gate | `name`, `orbit` | | belt | `name`, `orbit`, `width`, `arcStart`, `arcSweep` | | cluster | `name`, `x`, `y`, `radius` | | hyperlane | `from`, `to` | Everything else is optional. ### What gets filled in | field | value | | ---------------------------------- | -------------------------------------------------- | | `star.id`, `star.name` | `-star`, and the system name | | `planet.id`, `planet.index` | `-p`, and its rank in the array | | `station.id`, `station.planetId` | `-st`, and its parent planet | | `gate.id`, `field.id` | `-gate`, `-ast` | | `localRoute.id` | `>` | | `lane.id`, `lane.length` | `lane--`, and the straight-line distance | | `system.outerLimit` | how far the system's contents reach, no margin | | `meta.*Count`, `meta.galaxyRadius` | counted and measured from the real contents | | `angle`, `period` | `0` — "sits still at its starting position" | | omitted arrays | `[]` | An id you provide is always kept. ### What never gets filled in `type`, `class`, `faction`, `security`, `resources`, `richness`, `docks`, `moons`, `habitable`, `density`. Leave one out and it stays `undefined`. That is the whole rule of the library: > **Compute what is derivable. Never invent a value that carries meaning.** The library can work out that your second planet is `sol-p1`. It cannot work out that it is a gas giant, so it will not pretend to. ### When your description is wrong `createGalaxy()` throws a `GalaxyValidationError` listing **every** problem it found, each with a path you can jump to. ```ts try { createGalaxy(input) } catch (e) { if (e instanceof GalaxyValidationError) { for (const issue of e.issues) console.error(`${issue.path}: ${issue.message}`) // systems[1].id: duplicate id: "sol" // lanes[0].to: unknown system: "vegaa" // systems[0].planets[0].orbit: must be strictly positive } } ``` It rejects anything that makes the universe **unusable**: a duplicate id, a lane pointing nowhere, a value out of range, a local route crossing into another system. It accepts anything merely **implausible**: orbits that shrink outward, overlapping planets, a gate inside its star, a network split in two. Those are design choices, and they are yours. The one exception you probably care about is connectivity — see below. ## Query it Build the indexes once after `createGalaxy()` and keep them. Each call walks the whole universe. ```ts import { connectedComponents, findRoute, indexGalaxy, stationsOf, systemOf, } from '@industrieh/starmap' // Is every system reachable? The library does not check this for you. const parts = connectedComponents(galaxy) if (parts.length > 1) { throw new Error(`Stranded systems: ${parts.slice(1).flat().join(', ')}`) } // Look up any object by id. `kind` narrows the type, no cast needed. const index = indexGalaxy(galaxy) const entry = index.get('earth-st0') if (entry?.kind === 'station') { entry.obj.docks // typed as Station entry.systemId // "sol" } // Shortest path, weighted by lane length. const route = findRoute(galaxy, 'sol', 'alpha') route?.jumps // 1 route?.systemIds // ["sol", "alpha"] stationsOf(galaxy.systems[0]) // every station across every planet // Walk ownership upwards: from any object id, its system — and everything in it. systemOf(galaxy, 'earth')?.gates // every gate Earth can fly to, not just one systemOf(index, 'earth')?.gates // same, reusing the index above ``` ## Animate it `systemPositions(system, t)` returns where everything is at time `t`, keyed by id. ```ts import { systemPositions } from '@industrieh/starmap' const pos = systemPositions(galaxy.systems[0], 12.5) pos.get('earth') // { x: -75, y: 130 } pos.get('earth-st0') // the station, which follows its planet ``` `t` is **simulation** seconds, compared against each object's `period`. The library keeps no clock: your loop decides how fast `t` moves, whether it pauses, and whether it runs backwards. Call it every frame. It costs one pass over the objects of one system — a few dozen — so there is nothing worth caching. ## Draw it The library gives you positions. The rest is yours. ```ts // Colors live in your code, keyed by a field of the model. const COLORS: Record = { terran: '#4fae6a', gas: '#d8a06a' } function draw(ctx: CanvasRenderingContext2D, system: StarSystem, t: number) { const pos = systemPositions(system, t) for (const p of system.planets) { const point = pos.get(p.id) if (!point) continue ctx.beginPath() ctx.arc(point.x, point.y, p.radius, 0, Math.PI * 2) ctx.fillStyle = COLORS[p.type ?? ''] ?? '#888' ctx.fill() } } ``` There is no `color` or `typeName` field in the model, on purpose. A color baked into the data forces a UI decision at the moment you describe your world, and it is useless the moment a project has a dark theme or a fixed palette. Mapping `planet.type` to an appearance is one line, written where you have the context to write it. If you want a picture rather than a drawing loop, [`@industrieh/starmap-render`](../starmap-render) turns a system or a galaxy into an SVG string, with the colors passed in as a theme. ## Generate it with a script The library does not generate universes, but it ships the tool that makes your generator deterministic. ```ts import { createGalaxy, createRng, type SystemInput } from '@industrieh/starmap' function generate(seed: string, count = 24) { const rng = createRng(seed) const systems: SystemInput[] = Array.from({ length: count }, (_, i) => { const angle = rng.next() * Math.PI * 2 const radius = 1000 * Math.pow(rng.next(), 0.65) // denser toward the middle const name = `Sector-${i}` return { id: `sys-${i}`, name, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius * 0.72, // squash it: a flat disc reads as a target star: { class: rng.pick(['G', 'K', 'M'] as const), radius: rng.randInt(12, 30) }, planets: Array.from({ length: rng.randInt(1, 8) }, (_, j) => ({ name: `${name} ${j + 1}`, orbit: 60 + j * rng.rand(38, 62), radius: rng.randInt(3, 12), angle: rng.rand(0, 360), period: 8 + Math.pow(j + 1, 1.5) * 9, // simplified Kepler: T² ∝ a³ })), } }) const lanes = systems.slice(1).map((s, i) => ({ from: systems[i].id, to: s.id })) // `seed` is copied to meta.seed, so you can rebuild this exact universe later. return createGalaxy({ seed, systems, lanes }) } ``` Two things to know once you go further: **The order of your draws becomes part of your contract.** Insert one extra `rng.next()` in the middle and everything after it shifts, so the same seed now produces a different universe. A snapshot test is the usual way to catch that. **Keep your sequences separate.** `` `${seed}:${id}:${domain}` `` gives each mechanic its own sequence, so adding one does not disturb the others. ## API ### Building and checking | function | what it does | | ------------------------------- | ------------------------------------------- | | `createGalaxy(input, options?)` | fill in, check, and return a `Galaxy` | | `validateGalaxy(galaxy)` | list the problems of a galaxy; never throws | | `assertValidGalaxy(galaxy)` | same, but throws a `GalaxyValidationError` | | `FORMAT_VERSION` | version of the data contract | Pass `{ validate: false }` to skip the check on a hot path whose input you have already validated. `validateGalaxy()` is the one to reach for after `JSON.parse` — TypeScript's guarantees do not survive serialization. ### Reading | function | what it does | | ----------------------------- | ---------------------------------------- | | `indexGalaxy(galaxy)` | `id → object`, every kind mixed together | | `indexSystems(galaxy)` | `id → system` | | `lanesOf(galaxy, id)` | lanes touching a system | | `neighboursOf(galaxy, id)` | systems one jump away | | `connectedComponents(galaxy)` | groups of mutually reachable systems | | `stationsOf(system)` | every station in a system | | `systemOf(source, id)` | the system an object belongs to | | `findRoute(galaxy, a, b)` | shortest weighted path (Dijkstra) | ### Geometry | function | what it does | | ----------------------------------- | ------------------------------- | | `systemPositions(system, t)` | every position at time `t` | | `orbitPos(orbit, angle, period, t)` | one position on one orbit | | `fieldCenter(field)` | the center of an asteroid field | | `toRad(deg)`, `round(n, d)` | small helpers | ### Randomness `createRng(seed)` returns `next`, `rand`, `randInt`, `pick`, `chance` and `weighted`. Use it for your own generator, or for reproducible game content — loot, encounters, events. ### Units and conventions | quantity | unit | convention | | --------------- | ---------------------- | -------------------------------- | | positions | arbitrary world units | Y points down, as in SVG | | angles | degrees | clockwise, `0` points right | | orbital periods | **simulation** seconds | not wall-clock seconds | | `security` | `0` to `1` | `0` lawless, `1` safe | | `density` | `0` to `1` | a hint; do with it what you like | A system's star always sits at `(0, 0)`. ## Guides - [docs/data-model.md](docs/data-model.md) — every field, one by one - [docs/recipes.md](docs/recipes.md) — save files, scaling, loading from JSON, attaching colors and labels - [docs/design.md](docs/design.md) — why the library draws its lines where it does - [docs/architecture.md](docs/architecture.md) — how the code is built, file by file - [CONTRIBUTING.md](CONTRIBUTING.md) — how to work on the package ## Working with an AI This library is younger than the training data of most assistants, so a model asked about it cold will invent an API that looks plausible and does not exist. Two files exist to prevent that: | file | what it is | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------- | | [`/llms.txt`](https://starmap.industrieh.dev/llms.txt) | the map — every documentation page with a one-line description, a few kilobytes | | [`/llms-full.txt`](https://starmap.industrieh.dev/llms-full.txt) | everything — both packages' READMEs and guides in one file, ~120 KB | In an agent that reads project instructions — Claude Code, Cursor, Copilot — one line does it: ```md When writing code against @industrieh/starmap, read https://starmap.industrieh.dev/llms-full.txt first. It is the complete documentation. ``` In a chat, paste the URL. Offline, download the file and attach it — both are plain text. Two things worth telling a model up front, because the type system will not enforce either: - **This package never draws.** If it suggests a `color` field or a `render()` here, it is thinking of `@industrieh/starmap-render`. - **It never invents a value that carries meaning.** A missing `type` stays `undefined`; the library fills in only what follows from what you wrote. ## Developing This package lives in a Bun monorepo. See the [root README](../../README.md) for the repo-wide commands. ``` packages/starmap/ src/ index.ts public API core/ types.ts the data contract (output) input.ts the input schema create.ts createGalaxy() and normalization validate.ts validateGalaxy() and errors geometry.ts orbital geometry query.ts indexes, neighbours, connectivity, routes rng.ts deterministic randomness tests/ creation, validation, queries, purity docs/ data model, recipes, design, architecture ``` [docs/architecture.md](docs/architecture.md) walks through those files. [CONTRIBUTING.md](CONTRIBUTING.md) has the checklists for adding a field, a query or a check. | command | what it does | | -------------------- | ---------------------------- | | `bun run build` | build to `dist/` with tsdown | | `bun run test` | Vitest | | `bun run test:watch` | Vitest in watch mode | | `bun run typecheck` | `tsc --noEmit` | **The house rule: `src/` knows nothing about UI, the DOM, or rendering.** No non-relative imports, no browser globals, no `Math.random`, no `Date`, no colors, no drawable shapes. `tests/purity.test.ts` checks that file by file, and `tsconfig.base.json` drops `DOM` from `lib` so a slip is a compile error. Second rule, repo-wide: `isolatedDeclarations` is on, so every exported declaration carries an explicit type. --- # Data model Every field, one by one. What you pass to `createGalaxy()` on the left, what you get back on the right. The types live in `src/core/input.ts` (input) and `src/core/types.ts` (output). Both are exported. ## How to read these tables | status | meaning | | ------------ | ---------------------------------------------------- | | **required** | leave it out and validation rejects the universe | | **derived** | computed for you; a value you provide is always kept | | **default** | replaced by a neutral value when absent | | **optional** | stays `undefined` when absent — nothing is invented | ## Conventions | quantity | unit | convention | | --------- | ---------------------- | --------------------------------------- | | positions | arbitrary world units | Y points down, as in SVG | | angles | degrees | clockwise, `0` points right | | orbits | world units | radius, measured from the parent body | | periods | **simulation** seconds | not wall-clock seconds; `0` never moves | A system's star always sits at `(0, 0)`. Derived numbers are rounded to 2 decimals. Without that, the JSON fills up with `0.30000000000000004` for no useful precision. ## Ids Provide your own — they are more readable in a save file, and they do not move when you reorder an array. Otherwise they are derived: | shape | example | object | | -------------------- | ---------------- | -------------- | | `-star` | `sol-star` | star | | `-p` | `sol-p2` | planet | | `-st` | `sol-p2-st0` | station | | `-gate` | `sol-gate1` | gate | | `-ast` | `sol-ast0` | asteroid field | | `lane--` | `lane-sol-alpha` | hyperlane | | `>` | `sol-p1>sol-p2` | local route | Ids must be unique across the whole galaxy, hyperlanes included. Watch out for collisions between an id you provide and one that gets derived. Validation catches them: ```ts planets: [ { name: 'P1', orbit: 50, radius: 2 }, // derives to 'a-p0' { id: 'a-p0', name: 'P2', orbit: 80, radius: 2 }, // collision, rejected ] ``` ## `GalaxyInput` → `Galaxy` | field | status | notes | | ------------- | ------------ | ------------------------------------- | | `systems` | **required** | may be empty | | `lanes` | default `[]` | the inter-system network | | `seed` | optional | free-form label, copied to `meta` | | `generatedAt` | optional | free-form timestamp, copied to `meta` | The output adds `meta`. ## `GalaxyMeta` | field | status | notes | | -------------------- | ------- | ----------------------------------------------------------- | | `seed` | copied | the library never reads it — put your generator's seed here | | `generatedAt` | copied | same | | `formatVersion` | derived | the `FORMAT_VERSION` in effect when the galaxy was built | | `systemCount` | derived | | | `laneCount` | derived | | | `planetCount` | derived | across every system | | `stationCount` | derived | across every planet | | `gateCount` | derived | | | `asteroidFieldCount` | derived | | | `galaxyRadius` | derived | radius of the disc enclosing every system | `validateGalaxy()` re-checks the counters, so they stay honest even for a galaxy read back from a hand-edited JSON file. Compare `meta.formatVersion` against the exported `FORMAT_VERSION` when loading a save, to detect that the data contract has moved on. ## `SystemInput` → `StarSystem` | field | status | notes | | ---------------- | ------------ | ---------------------------------------------------- | | `id` | **required** | unique across the whole galaxy | | `name` | **required** | | | `x`, `y` | **required** | position in the galaxy | | `star` | **required** | | | `planets` | default `[]` | **in orbital order** — the rank becomes `index` | | `asteroidFields` | default `[]` | | | `gates` | default `[]` | | | `localRoutes` | default `[]` | the in-system travel network | | `faction` | optional | `{ id, name }` | | `security` | optional | `0` lawless to `1` safe | | `outerLimit` | derived | how far the contents reach, **with no margin added** | `outerLimit` is `max(star radius, planet orbit + radius, gate orbits, field reach)`. If you want empty space past the outermost object, pass the value yourself — a margin is an aesthetic choice, not a calculation. ## `StarInput` → `Star` | field | status | notes | | -------- | ------------ | ------------------------------------------------ | | `radius` | **required** | in world units | | `id` | derived | `-star` | | `name` | derived | the system name | | `class` | optional | `O` `B` `A` `F` `G` `K` `M` are just suggestions | ## `PlanetInput` → `Planet` | field | status | notes | | ----------- | ------------ | --------------------------------------------- | | `name` | **required** | | | `orbit` | **required** | radius, measured from the star | | `radius` | **required** | | | `id` | derived | `-p` | | `index` | derived | rank in the array, `0` first | | `angle` | default `0` | where it sits at `t = 0` | | `period` | default `0` | simulation seconds per orbit; `0` never moves | | `stations` | default `[]` | | | `type` | optional | your classification | | `moons` | optional | **decorative** — no moon exists as an object | | `habitable` | optional | | | `resources` | optional | | ## `StationInput` → `Station` | field | status | notes | | ---------- | ------------ | --------------------------------------------- | | `name` | **required** | | | `orbit` | **required** | radius, measured from the **planet's** center | | `id` | derived | `-st` | | `planetId` | derived | always its parent planet | | `angle` | default `0` | | | `period` | default `0` | usually faster than the planet's own orbit | | `type` | optional | | | `docks` | optional | free-form; the library never reads it | Stations follow their planet: `systemPositions()` composes the two orbits. ## `GateInput` → `Gate` | field | status | notes | | -------- | ------------------- | ------------------------------------ | | `name` | **required** | | | `orbit` | **required** | radius around the star | | `id` | derived | `-gate` | | `angle` | default `0` | | | `links` | default `[]` | ids of the systems this gate reaches | | `status` | default `'nominal'` | `'unstable'` is just a suggestion | Validation checks that every id in `links` names an existing system, and never the gate's own system. An empty list is fine — a dead gate is a legitimate design choice. `status` has no mechanical effect in the library. It is a hook for your game: risky jump, random destination, higher fuel cost. ## `AsteroidFieldInput` → `AsteroidField` Discriminated by `kind`. Shared fields: | field | status | notes | | ---------- | ------------ | ----------------------- | | `name` | **required** | | | `kind` | **required** | `'belt'` or `'cluster'` | | `id` | derived | `-ast` | | `rocks` | default `[]` | pre-placed rocks | | `density` | optional | `0` to `1`, a hint | | `richness` | optional | | `kind: 'belt'` — an arc of a ring: | field | status | notes | | ---------- | ------------ | ------------------------------------------ | | `orbit` | **required** | radius of the ring | | `width` | **required** | radial thickness | | `arcStart` | **required** | where the arc starts, in degrees | | `arcSweep` | **required** | how far it spans, `0`–`360`; `360` is full | `kind: 'cluster'` — a local pocket: | field | status | notes | | -------- | ------------ | ----------------------------- | | `x`, `y` | **required** | center, in system coordinates | | `radius` | **required** | radius of the cluster | `Rock` is a `[x, y, radius]` tuple in system coordinates. A tuple rather than an object on purpose: a field can hold hundreds of rocks, and `{"x":1,"y":2,"r":3}` is four times the size of `[1,2,3]` in JSON. The library never generates rocks. If you want a field you can draw, place them yourself. ## `LaneInput` → `Hyperlane` | field | status | notes | | ------------ | ------------ | -------------------------------------------------- | | `from`, `to` | **required** | system ids. **Undirected** — order does not matter | | `id` | derived | `lane--` | | `length` | derived | straight-line distance between the two systems | Validation checks that both ends exist, that `from ≠ to`, and that the same pair appears only once in either direction. Provide `length` to decouple travel cost from on-screen distance — a "long" jump between two neighbours, for instance. `findRoute()` weights by this value. ## `LocalRouteInput` → `LocalRoute` | field | status | notes | | ------------ | ------------ | ------------------------------------------------------------------- | | `from`, `to` | **required** | `{ kind, id }` of objects in the **same** system | | `id` | derived | `>` | | `type` | optional | `orbital` `shortcut` `docking` `jump-lane` `mining` are suggestions | Validation checks that both ends exist, belong to the system holding the route, and match the `kind` you declared. Routes reference **ids**, never positions, so they follow objects as they orbit. Nothing to update per frame. ## Indexes `indexGalaxy()` returns a `Map`. `IndexEntry` is discriminated by `kind`: ```ts const entry = index.get('sol-p2-st0') if (entry?.kind === 'station') { entry.obj.docks // typed as Station, no cast entry.systemId // "sol" } ``` `systemId` lets you recover an object's context from its id alone — handy for a detail panel or for game logic. `systemOf()` wraps that round trip: it takes an id and hands back the whole `StarSystem`, so anything a planet shares with its neighbours — the gates, the asteroid fields, the other planets — is one call away. ```ts systemOf(galaxy, 'earth')?.gates // every gate in Earth's system systemOf(index, 'earth')?.gates // same, from an index you already built ``` It accepts a `Galaxy` or a `GalaxyIndex`, returns `null` on an unknown id, and returns the system itself if you hand it a system id. ## Open unions `FactionId`, `PlanetType`, `StationType`, `StarClass`, `Richness`, `GateStatus` and `RouteType` are open: ```ts type PlanetType = 'lava' | 'rocky' | … | (string & {}) ``` The listed values are **autocomplete suggestions only**. The library imposes no vocabulary and attaches no behaviour to any of these strings. Use your own. The trade-off: a `switch` over one of them is never exhaustive as far as TypeScript is concerned, so always write a default case. ## What is deliberately missing No `color`, no `typeName`, nothing about styling. Those are display concerns. Map them in your own code, keyed by a field of the model: ```ts const COLORS: Record = { terran: '#4fae6a', gas: '#d8a06a' } const color = COLORS[planet.type ?? ''] ?? '#888' ``` The fallback is not paranoia: `type` is optional, and the unions are open. See [design.md](design.md) for the reasoning. --- # Recipes Short answers to common needs. For field-level detail see [data-model.md](data-model.md); for the reasoning behind the design see [design.md](design.md). - [Write a universe by hand](#write-a-universe-by-hand) - [Generate one with a script](#generate-one-with-a-script) - [Load one from a file](#load-one-from-a-file) - [Check what the library does not](#check-what-the-library-does-not) - [Keep game state alongside](#keep-game-state-alongside) - [Attach colors and labels](#attach-colors-and-labels) - [Use the PRNG for your own content](#use-the-prng-for-your-own-content) - [Scale up](#scale-up) - [Change your universe without breaking saves](#change-your-universe-without-breaking-saves) ## Write a universe by hand The simplest case: a universe in a TypeScript file. Leave out anything derivable. ```ts import { createGalaxy, type GalaxyInput } from '@industrieh/starmap' const input: GalaxyInput = { systems: [ { id: 'sol', name: 'Sol', x: 0, y: 0, faction: { id: 'consortium', name: 'Vega Consortium' }, security: 0.9, star: { class: 'G', radius: 18 }, planets: [ { name: 'Sol I', type: 'rocky', orbit: 90, radius: 5, period: 12 }, { id: 'earth', // a readable id: it becomes a game-state key name: 'Earth', type: 'terran', orbit: 150, radius: 6, angle: 30, period: 24, habitable: true, stations: [{ name: 'High Anchorage', type: 'trade', orbit: 14, period: 4, docks: 8 }], }, ], gates: [{ name: 'Sol Gate', orbit: 300, angle: 45, links: ['alpha'] }], }, { id: 'alpha', name: 'Alpha', x: 400, y: 300, star: { radius: 14 } }, ], lanes: [{ from: 'sol', to: 'alpha' }], } export const galaxy = createGalaxy(input) ``` Autocomplete does the rest. `GalaxyInput` marks as required only what really is, and suggests known values for `type`, `class` and `status` without forcing them. ## Generate one with a script Your script builds the object; `createGalaxy()` turns it into checked terrain. `createRng()` gives you determinism. ```ts import { createGalaxy, createRng, type SystemInput } from '@industrieh/starmap' export function generate(seed: string, count = 24) { const rng = createRng(seed) const systems: SystemInput[] = Array.from({ length: count }, (_, i) => { const angle = rng.next() * Math.PI * 2 // The exponent packs systems toward the middle; 0.72 squashes the disc. const radius = 1000 * Math.pow(rng.next(), 0.65) const name = `Sector-${i}` return { id: `sys-${i}`, name, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius * 0.72, security: rng.next(), star: { class: rng.pick(['G', 'K', 'M'] as const), radius: rng.randInt(12, 30) }, planets: Array.from({ length: rng.randInt(1, 8) }, (_, j) => ({ name: `${name} ${j + 1}`, type: rng.pick(['rocky', 'terran', 'gas'] as const), orbit: 60 + j * rng.rand(38, 62), radius: rng.randInt(3, 12), angle: rng.rand(0, 360), period: 8 + Math.pow(j + 1, 1.5) * 9, // simplified Kepler: T² ∝ a³ })), } }) const lanes = systems.slice(1).map((s, i) => ({ from: systems[i].id, to: s.id })) // `seed` is copied to meta.seed, so you can rebuild this exact universe. return createGalaxy({ seed, systems, lanes }) } ``` Two rules once you go further: - **The order of your draws becomes part of your contract.** One extra `rng.next()` in the middle shifts everything after it, so the same seed now produces a different universe. Guard your generator with a snapshot test. - **Keep sequences separate** with `` `${seed}:${domain}` ``, so adding a mechanic does not disturb the existing ones. ## Load one from a file TypeScript's guarantees do not survive `JSON.parse`. `validateGalaxy()` picks up where the types stop. ```ts import { validateGalaxy, type Galaxy } from '@industrieh/starmap' export function load(json: string): Galaxy { const galaxy = JSON.parse(json) as Galaxy const issues = validateGalaxy(galaxy) if (issues.length) { throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join('\n')) } return galaxy } ``` `validateGalaxy()` **never throws**, not even on `null` or on a completely malformed object. It returns every problem it found, each located by its path, so you can fix a content file in one pass instead of ten. If you start from a serialized `GalaxyInput` rather than a `Galaxy`, just call `createGalaxy()` again: it normalizes **and** validates. ## Check what the library does not Validation guarantees structural integrity, not that your world makes sense. Game rules are yours. The one that bites most often: ```ts import { connectedComponents } from '@industrieh/starmap' const parts = connectedComponents(galaxy) if (parts.length > 1) { throw new Error(`Stranded systems: ${parts.slice(1).flat().join(', ')}`) } ``` Other checks worth writing, depending on your game: ```ts for (const s of galaxy.systems) { // Orbits strictly increasing, discs not overlapping. for (let i = 1; i < s.planets.length; i++) { const inner = s.planets[i - 1] const outer = s.planets[i] if (outer.orbit - outer.radius <= inner.orbit + inner.radius) { throw new Error(`${outer.id} overlaps ${inner.id}`) } } // Every neighbour on the network is served by a gate. const neighbours = new Set( galaxy.lanes .filter((l) => l.from === s.id || l.to === s.id) .map((l) => (l.from === s.id ? l.to : l.from)), ) const served = new Set(s.gates.flatMap((g) => g.links)) if (neighbours.size !== served.size) throw new Error(`${s.id}: gates do not match its lanes`) } ``` ## Keep game state alongside Never mutate the `Galaxy`. Keep a separate layer, keyed by the same ids. ```ts import { FORMAT_VERSION, type Galaxy } from '@industrieh/starmap' import { generate } from './generator' interface Save { seed: string formatVersion: number ownedStations: Record // stationId -> factionId discoveredGates: string[] position: { systemId: string } } function newGame(seed: string): Save { return { seed, formatVersion: FORMAT_VERSION, ownedStations: {}, discoveredGates: [], position: { systemId: 'sys-0' }, } } function load(save: Save): { galaxy: Galaxy; save: Save } { if (save.formatVersion !== FORMAT_VERSION) { throw new Error( `Save is v${save.formatVersion}, the library is v${FORMAT_VERSION}: ` + `the terrain no longer rebuilds identically.`, ) } // The terrain rebuilds from the seed, so there is nothing to store. return { galaxy: generate(save.seed), save } } ``` A save is a few kilobytes where a dump of the universe would be hundreds. If your universe is hand-written rather than generated, store a reference to the content file and its version instead of a seed. To enrich an object without mutating it: ```ts function stationWithState(station: Station, save: Save) { return { ...station, owner: save.ownedStations[station.id] ?? null } } ``` ## Attach colors and labels The model has no display fields. Map them in your own code, keyed by a field of the world. It is one line, and you keep control of theming, translation and special cases. ```ts const PLANET = { terran: { color: '#4fae6a', label: 'Habitable world' }, gas: { color: '#d8a06a', label: 'Gas giant' }, frozen: { color: '#cfe4f0', label: 'Frozen world' }, } as const const FALLBACK = { color: '#8b8377', label: 'Unknown' } const style = (p: Planet) => PLANET[p.type as keyof typeof PLANET] ?? FALLBACK ``` The fallback matters: `type` is optional, and the library's unions are **open**, so a `switch` over them is never exhaustive as far as TypeScript is concerned. ## Use the PRNG for your own content Reuse `createRng` so your content is reproducible too. ```ts import { createRng } from '@industrieh/starmap' /** A station's stock, stable for a given save and station. */ function stock(seed: string, stationId: string) { const rng = createRng(`${seed}:${stationId}:stock`) return { credits: rng.randInt(100, 5000), goods: rng.pick(['ore', 'food', 'weapons', 'data']), rare: rng.chance(0.05), } } ``` The `` `${seed}:${id}:${domain}` `` pattern is the point: each domain gets its own sequence, so adding a mechanic does not shift the others. **Never reuse your universe generator's `rng` for this** — you would shift the whole terrain. `hashSeed()` is there if you want an integer rather than a generator. ## Scale up `createGalaxy()` is linear in the number of objects: normalization and validation each make one pass. At scale, what costs is your generator, not the library. Two things to watch: **Validation walks everything.** On a hot path whose input you already validated upstream — a galaxy read back from your own cache, say — you can skip it: ```ts const galaxy = createGalaxy(input, { validate: false }) ``` Keep that for cases you have measured. Validation is what protects you from dangling references, and it is not expensive. **`findRoute()` is Dijkstra with a naive queue**, O(n²) in the number of systems, and it rebuilds its adjacency list on every call. Comfortable up to a few thousand systems. If you compute many routes, cache the results or index the graph yourself. **Generating in a worker** is fine — the library has no DOM dependency: ```ts // worker.ts import { createGalaxy } from '@industrieh/starmap' self.onmessage = (e) => self.postMessage(createGalaxy(e.data)) ``` ## Change your universe without breaking saves Two versions to track, and they are not the same thing. **`FORMAT_VERSION`** is the library's. It changes when the data contract changes in a breaking way. Compare it to `save.formatVersion` when loading. **Your content version** is yours. It changes when your universe changes — whether you edit your generator or your description file. This is the one that decides whether a save is still playable, because it decides whether the ids referenced by your game state still exist. ```ts const CONTENT_VERSION = 3 interface Save { formatVersion: number contentVersion: number // … } ``` **Safe changes:** appending a system, planet or station **at the end** of an array; adding an optional field; renaming an object. **Breaking changes:** removing an object your game state references; reordering an array whose ids are **derived** (every rank shifts, so every id shifts); changing the order of draws in your generator. The best protection is to **provide your ids explicitly**. `earth` stays `earth` whatever its orbital rank; `sol-p1` becomes `sol-p2` the moment you insert a planet before it. --- # Design Why the library draws its lines where it does. For field-level detail see [data-model.md](data-model.md); for how-to answers see [recipes.md](recipes.md). ## The shape of it ``` GalaxyInput (your object) │ ▼ normalize ──────────► derived ids, empty collections, (create.ts) index, planetId, lengths, outerLimit, meta │ ▼ validate ───────────► GalaxyIssue[] → GalaxyValidationError (validate.ts) │ ▼ Galaxy ──────────► query.ts (indexes, neighbours, routes) └────► geometry.ts (positions at time t) ``` One pass, no state, no side effects. `createGalaxy()` reads no clock, calls no `Math.random`, touches no filesystem, and does not mutate the object you hand it. Call it twice on the same input and you get two equal galaxies. ## Three lines we decided not to cross ### 1. The library does not generate Procedural generation is **game design**, not infrastructure. The shape of a galaxy, the faction table, how many planets a system gets — those are game decisions. Locking them into a library leaves two bad options: expose every knob, until the configuration is harder to write than the script it replaced; or pick one universe for everybody. So `createGalaxy()` takes the finished universe. The script that builds it lives in your app. The library keeps `createRng()`, which is the one genuinely reusable piece of a generator: a seeded PRNG. ### 2. The library does not draw No components, no hooks, no stylesheet — and, less obviously, **no display fields in the data model**. There is no `color`, no `typeName`, and no drawable shape. A color in the model looks convenient, and it forces a UI decision at the moment you describe your world. It also stops being usable the day a project needs a dark theme, a colorblind mode, or a fixed brand palette. The mapping `planet.type → color` is one line, written where the context to write it exists: ```ts const COLORS: Record = { terran: '#4fae6a', gas: '#d8a06a' } ``` What stays in the model are **facts about the world**: geometry, topology, identity, gameplay attributes (`security`, `docks`, `habitable`, `resources`, `richness`). Nothing that assumes how you look at them. Two mechanisms enforce this, so it does not rely on review: - `tests/purity.test.ts` walks every source file and rejects non-relative imports, browser globals, `Math.random`, `Date`, and any trace of `color`, `#rrggbb`, `svg`, `canvas` or `viewBox`; - `tsconfig.base.json` drops `DOM` from `lib`, so a stray `document.` does not compile. ### 3. The library checks, but does not judge See [Validation](#validation) below. The line between what is rejected and what is accepted is a choice, so it is written down. ## Normalization One rule governs the whole file: > **Compute what is derivable. Never invent a value that carries meaning.** That settles every "what if the user left X out?" question in advance. Two buckets, nothing in between. **Derived** — the value follows from what you gave, so the library computes it: ids (`sol-p2-st0`), `Planet.index`, `Station.planetId`, `Hyperlane.length`, `StarSystem.outerLimit`, every counter in `meta`, `meta.galaxyRadius`. An omitted collection becomes an empty array. **Left empty** — the value is a design decision, so the library abstains: `faction`, `security`, `type`, `class`, `resources`, `richness`, `docks`, `moons`, `habitable`, `density`. These are optional **in the output type too**: the contract tells the truth about what may be missing, rather than promising a made-up default. One exception, on purpose: `angle` and `period` default to `0`. That is not an invention but the literal reading of absence — "sits still at its starting position". Without it, every static universe would repeat `angle: 0, period: 0` on every object. ### Why `outerLimit` has no margin A system's edge is the furthest its contents reach: `max(star radius, planet orbit + radius, gate orbits, field reach)`. Nothing added. An earlier version added 40 units of empty space. That was right for a renderer and wrong for a library — a margin is an aesthetic decision. Pass `outerLimit` yourself if you want one; a value you provide is always kept. ### Derived ids can collide Derived ids follow the array position (`-p`), so they can collide with an id you provided elsewhere: ```ts planets: [ { name: 'P1', orbit: 50, radius: 2 }, // derives to 'a-p0' { id: 'a-p0', name: 'P2', orbit: 80, radius: 2 }, // collision ] ``` This is not silently patched up. It is **reported by validation**, because a duplicate id breaks indexing and therefore everything that references by id. The message points at the second occurrence. ## Validation ### What is rejected Structural and referential integrity — without which the galaxy is unusable: - runtime types of fields (`x` really is a finite number, `name` a non-empty string), because TypeScript's guarantees do not survive `JSON.parse`; - ids unique across the **whole** universe, hyperlanes included; - documented ranges: `security` and `density` in `[0, 1]`, `arcSweep` in `[0, 360]`, radii and orbits strictly positive; - **every reference by id**: a hyperlane links two systems that exist and does not loop on itself, a pair of systems is linked at most once, a gate reaches only existing systems and never its own, a local route links two objects of the **same** system and declares the right `kind`; - the counters in `meta` matching the real contents. ### What is accepted Whether the world is plausible is not checked. Orbits that shrink outward, overlapping planets, a gate inside its star, a system with no planets, a network split in two — all fine. This is the most arguable decision in the library, so here is the reasoning: those rules are game rules. Co-orbital planets are a bug in a simulator and a feature in a puzzle game. A library that decides for the game gets forked. The cost is that guarantees an earlier procedural generator provided are now yours to enforce. For the one that matters most — network connectivity — `connectedComponents()` exists so the check is two lines. ### All the errors, not the first `validateGalaxy()` **never throws**. It returns every problem, each with a `path` (`systems[3].planets[0].orbit`) and a message. That is what lets you fix a content file in one pass instead of ten round trips. `createGalaxy()` builds on it and throws a `GalaxyValidationError` carrying the whole list in `error.issues`. The consequence is that validation must survive **any** input — `null`, an array where an object belongs, a field missing halfway down. Hence the defensive helpers (`each()`, `isId()`), which tolerate nonsense so they can report it instead of crashing on it. ## Queries A `Galaxy` is a tree of nested objects: easy to serialize, awkward to query. Build an index **once** after `createGalaxy()` and keep it — each call walks the whole universe. `indexGalaxy()` returns a `Map` where `IndexEntry` is discriminated by `kind`. That is what makes a fully typed `switch` possible with no casting, and what lets you recover an object's system from its id alone (`entry.systemId`). `findRoute()` is **Dijkstra with a naive priority queue** (a linear scan): O(n²) in the number of systems. Fine up to a few thousand. Past that, index the graph once and swap the queue for a binary heap. It returns `null` when the two systems are in different components. That is a real case, not a theoretical one, because the library no longer guarantees connectivity. ## Geometry Conventions: star at the origin, Y pointing down (as in SVG), angles in degrees measured clockwise. `systemPositions(system, t)` recomputes a whole system at time `t` and returns a `Map`. Stations are composed onto their planet, so they follow it around its orbit; gates and asteroid fields never move. The cost is one pass over the objects of one system — a few dozen — so call it every frame and cache nothing. This is also why local routes reference **ids** rather than positions: nothing needs updating when planets move. `t` is **simulation** seconds. The library keeps no clock. Your render loop or your engine decides how fast it moves, which is also what makes all of this testable with no environment. ## Frozen terrain, separate game state A `Galaxy` is **terrain**, not a game. Nothing in it is mutable state: no station owner, no market, no fleet, no discovery flags. That is structural. Mutating the galaxy would give up the most useful property here: a save reduces to `{ formatVersion, deltas }` plus whatever rebuilds the terrain — a content file, or your generator's seed. The pattern is a separate layer keyed by the same ids. See [recipes.md](recipes.md#keep-game-state-alongside). `FORMAT_VERSION` moves on every breaking change to the data contract. A game compares `save.formatVersion` against it at load time and knows an old save no longer reads back as it did. ## Where this could go - **Dijkstra in O(n log n)** with a binary heap, and an adjacency graph built once instead of per call. Purely internal; the contract does not change. - **Freezing the galaxy** (`Object.freeze`, recursively) behind a development flag. Immutability is a documented convention today, not an enforced one. - **Multiple stars** per system: the contract assumes exactly one. - **A third dimension.** Everything is 2D. A Z coordinate would graft onto `Vec2` and onto system positions; orbits could stay planar with no change to `orbitPos`. - **Lazy validation.** `createGalaxy()` checks the whole universe at once. Per-system, on-demand checking would be possible for very large galaxies — worth deciding before any save files exist. --- # Architecture How the package is built, file by file. Read this before changing the code. For what the library does, see the [README](../README.md). For every field, see [data-model.md](data-model.md). For why the lines are drawn where they are, see [design.md](design.md). ## Contents - [The map](#the-map) - [The pipeline](#the-pipeline) - [Two layers of types](#two-layers-of-types) - [`types.ts` — the data contract](#typests--the-data-contract) - [`input.ts` — the input schema](#inputts--the-input-schema) - [`create.ts` — normalization](#createts--normalization) - [`validate.ts` — checking](#validatets--checking) - [`geometry.ts` — positions](#geometryts--positions) - [`query.ts` — reading a galaxy](#queryts--reading-a-galaxy) - [`rng.ts` — deterministic randomness](#rngts--deterministic-randomness) - [`index.ts` — the public surface](#indexts--the-public-surface) - [Invariants](#invariants) - [The rules the tooling enforces](#the-rules-the-tooling-enforces) - [The tests](#the-tests) - [Where to change what](#where-to-change-what) ## The map ``` packages/starmap/ src/ index.ts public API — the only file that re-exports core/ types.ts the output contract: Galaxy and everything in it input.ts the input schema: GalaxyInput and everything in it create.ts createGalaxy() and normalization validate.ts validateGalaxy(), assertValidGalaxy(), the error class geometry.ts orbital positions at time t query.ts indexes, neighbours, connectivity, routes rng.ts seeded PRNG tests/ fixture.ts one hand-written universe, shared by the tests creation.test.ts what normalization fills in and leaves alone validation.test.ts what is rejected and what is accepted queries.test.ts indexes, routes, connectivity, positions purity.test.ts the house rule, checked file by file docs/ data-model, recipes, design, this file ``` Around 1200 lines of source. Every file in `core/` is standalone except for the dependencies drawn below. ``` index.ts ──► create.ts ──► input.ts ──► types.ts │ ├──► geometry.ts (round) ──► types.ts └──► validate.ts ──────────► types.ts ──► query.ts ────────────────────► types.ts ──► rng.ts (depends on nothing) ``` `rng.ts` imports nothing. `query.ts` and `geometry.ts` import only types. The graph has no cycles and never will: `types.ts` is at the bottom and imports nothing. ## The pipeline ``` GalaxyInput ──► normalize ──► validate ──► Galaxy ──► query (your object) (create.ts) (validate.ts) ──► geometry ``` One pass, no state, no side effects. `createGalaxy()` reads no clock, calls no `Math.random`, and does not mutate the object you hand it. Call it twice on the same input and you get two equal galaxies. The two steps have opposite jobs, and that split is the core of the design: - **normalize** never rejects anything. It fills in ids, counts, lengths and empty arrays, and it copies through whatever else it was given. - **validate** never fixes anything. It walks the finished galaxy and reports what is wrong. That is why normalization tolerates broken input. `normalizeSystem` reads `input.star?.radius`, even though `SystemInput.star` is required, and `normalizeLane` falls back to a length of `0` when an endpoint does not exist: ```ts // create.ts const a = byId.get(input.from) const b = byId.get(input.to) // Unknown endpoint: validation reports it, so don't fail here. length = a && b ? round(Math.hypot(b.x - a.x, b.y - a.y)) : 0 ``` If normalization threw on the first missing field, you would get one error at a time. Instead it builds a galaxy that may be wrong, and validation reports **every** problem at once. The only thing `createGalaxy()` throws by itself is a `TypeError`, when the argument is not an object with a `systems` array. There is nothing to normalize in that case. ## Two layers of types Every object exists twice: `PlanetInput` and `Planet`, `SystemInput` and `StarSystem`, and so on. ```ts // input.ts — what you write interface PlanetInput { id?: string // derived if you leave it out name: string orbit: number radius: number angle?: number // 0 if you leave it out stations?: StationInput[] } // types.ts — what you get back interface Planet { id: string // always there name: string index: number // added orbit: number radius: number angle: number // always a number stations: Station[] // always an array, possibly empty } ``` The input type is forgiving. The output type tells the truth: a field is optional in `Planet` only when the library genuinely cannot know it (`type`, `moons`, `habitable`). Nothing is optional in the output just because it was optional in the input. Keep the two files in sync by hand. There is no generated mapping, on purpose — the differences between the layers are the whole point, and a mapped type would hide them. ## `types.ts` — the data contract Pure type declarations, no runtime code. Three things worth knowing. **Open unions.** `PlanetType`, `StarClass`, `StationType`, `FactionId`, `Richness`, `GateStatus` and `RouteType` all go through one helper: ```ts type Known = T | (string & {}) ``` The listed values show up in autocomplete, any other string is still accepted. `(string & {})` is what stops TypeScript from collapsing the union down to `string`. The library imposes no vocabulary; it only suggests one. **`Rock` is a tuple.** `[x, y, radius]`, not `{ x, y, r }`. A field can hold hundreds of rocks, and the object form is four times the size in JSON. **No rendering fields.** No `color`, no display label, no drawable shape. The model holds facts about the world: geometry, topology, identity, and gameplay attributes such as `security`, `docks`, `habitable`. See [design.md](design.md#2-the-library-does-not-draw). ## `input.ts` — the input schema The mirror of `types.ts`, with the derivable parts made optional. Pure type declarations again, no runtime code. Its file header carries the two tables users read first — required fields per object, and what gets filled in. Those tables also appear in the README and in [data-model.md](data-model.md). Change a required field and all three need the edit. ## `create.ts` — normalization `createGalaxy()` is 30 lines: normalize the systems, normalize the lanes against a `Map` of the systems, count everything into `meta`, then validate unless the caller opted out. Below it, one `normalizeX` function per object kind. They all have the same shape — take the input, take the parent id and the array index, return the finished object: ```ts function normalizePlanet(input: PlanetInput, systemId: string, index: number): Planet function normalizeStation(input: StationInput, planetId: string, index: number): Station function normalizeGate(input: GateInput, systemId: string, index: number): Gate ``` ### Derived ids | object | pattern | example | | ----------- | ----------------------- | ----------------- | | star | `-star` | `sol-star` | | planet | `-p` | `sol-p2` | | station | `-st` | `sol-p2-st0` | | gate | `-gate` | `sol-gate0` | | field | `-ast` | `sol-ast0` | | hyperlane | `lane--` | `lane-sol-vega` | | local route | `>` | `earth>sol-gate0` | `rank` is the index in the array. An id you provide is always kept, which means a derived id can collide with one you wrote by hand. That is reported by validation, not patched up silently. ### `outerLimit` `outerLimitOf()` returns how far a system's contents reach, with no margin: ```ts max(star.radius, planet.orbit + planet.radius, gate.orbit, field reach) ``` A belt reaches `orbit + width / 2`; a cluster reaches `hypot(x, y) + radius`. Non-finite values are filtered out, so a broken input still produces a number and validation gets to report the real problem. An `outerLimit` you provide is kept as is. ### Rounding Every computed length goes through `round()` from `geometry.ts`. Without it the serialized galaxy is full of `0.30000000000000004` and twice the size, for no useful precision. ## `validate.ts` — checking `validateGalaxy()` returns `GalaxyIssue[]` and never throws, whatever you pass it — including `null`, or an object parsed from a save file with the wrong shape. `assertValidGalaxy()` wraps it and throws `GalaxyValidationError`, whose `issues` property holds the full list. Every issue carries a path you can jump to: ``` systems[3].planets[0].orbit: must be strictly positive ``` The paths are built by string concatenation as the walk descends. There is no schema library and no reflection: the checks are hand-written, which is why the messages read the way they do. ### The four passes 1. **Claim every id.** One `Map` named `owners`, built over systems, stars, planets, stations, gates and fields. A second claim on the same id is a duplicate. This map is what later passes use to resolve references. 2. **Check each system's contents.** Types, ranges, and `planet.index` against the real array position. 3. **Check the network.** Lane ids, endpoints, self-links, duplicate pairs, lengths. 4. **Check `meta`.** Every counter is recomputed from the contents and compared. Pass 1 has to finish before pass 2 starts, because a gate in the first system may link to the last one. ### Lane ids are not in `owners` Hyperlane ids count towards global uniqueness but live in their own `Set`: ```ts if (owners.has(l.id) || laneIds.has(l.id)) add(`${p}.id`, `duplicate id: "${l.id}"`) else laneIds.add(l.id) ``` If lanes were in `owners`, a gate could "link" to a hyperlane and pass the check. Keeping them separate makes that impossible. ### The small helpers `number()`, `optionalNumber()`, `text()`, `isId()` and `each()` are the whole vocabulary. `each()` returns `[]` for anything that is not an array, so the walk never crashes on malformed input — the missing array is reported by its own check. `checkRef()` is the one with a rule you might not expect: a local route may only point at objects **in its own system**. A route from a gate in `sol` to a station in `vega` is rejected. What is rejected and what is accepted is a design decision, written down in [design.md](design.md#validation). Read it before adding a check. ## `geometry.ts` — positions Four functions, 75 lines, no state. ```ts orbitPos(orbit, angleDeg, period, t) // one position on one orbit systemPositions(system, t) // every position in a system, keyed by id fieldCenter(field) // middle of a belt's arc, or a cluster's center ;(toRad(deg), round(n, d)) // helpers ``` `orbitPos` is the only formula in the package: ```ts const a = toRad(angleDeg + (period ? (t / period) * 360 : 0)) return { x: Math.cos(a) * orbit, y: Math.sin(a) * orbit } ``` `period === 0` means "never moves", so it is guarded rather than divided by. `systemPositions` places the star at `(0, 0)`, then each planet on its orbit, then each station **relative to its planet** — a station's `x`/`y` is the sum of the two, so it follows the planet around. Gates and fields never move; they go through `orbitPos` with `period` and `t` set to `0`. `t` is simulation seconds, compared against each object's `period`. The library keeps no clock. One call costs one pass over one system's objects, so there is nothing worth caching. ## `query.ts` — reading a galaxy A `Galaxy` is a tree, which is good for serializing and bad for lookups. These helpers build the lookups a game needs constantly. Every one of them walks the whole structure, so callers build an index once and keep it. | function | cost | | --------------------- | ----------------------------- | | `indexSystems` | one pass over the systems | | `indexGalaxy` | one pass over everything | | `lanesOf` | one pass over the lanes | | `neighboursOf` | builds an index, then filters | | `stationsOf` | one pass over one system | | `systemOf` | two lookups, index or not | | `connectedComponents` | BFS over the whole network | | `findRoute` | Dijkstra, see below | `IndexEntry` is a discriminated union on `kind`, so `entry.kind === 'station'` narrows `entry.obj` to `Station` with no cast. `systemOf` is the one helper that reads ownership upwards. Only `station` holds a pointer to its parent, so the round trip goes through the index instead: `id → entry.systemId → the system entry`. It takes `Galaxy | GalaxyIndex` — the galaxy for a one-off call, an index you already built for a render loop. The two are told apart by `Array.isArray(source.systems)`, not `instanceof Map`, which would fail on a map built in another realm. `connectedComponents` builds an adjacency map, flood-fills from each unseen system, and returns the groups largest first. A healthy galaxy returns exactly one. The library never calls this itself: a split network is a design choice, and the check is yours to run. `findRoute` is Dijkstra with a naive priority queue — a linear scan over a `Set` of pending systems. That is `O(n²)`, fine up to a few thousand systems. If that ever becomes the bottleneck, the fix is to index the graph once and swap the scan for a binary heap; the signature does not have to change. ## `rng.ts` — deterministic randomness `hashSeed()` is FNV-1a: any string becomes an unsigned 32-bit integer. `mulberry32()` turns that integer into a generator. `createRng()` puts the two together and adds `rand`, `randInt`, `pick`, `chance` and `weighted`. It is not cryptographic, and it is not meant to be. It is 32 bits of state, very fast, and good enough for content generation. `weighted()` ends with a deliberate safety net: ```ts for (const e of table) if ((r -= e.w) <= 0) return e // Safety net: floating-point drift can leave `r > 0` past the last entry. return table[table.length - 1] ``` This module is the one piece of a procedural generator the library keeps. The generator itself belongs in your app — see [design.md](design.md#1-the-library-does-not-generate). ## `index.ts` — the public surface The only file that re-exports. It is grouped by role — building, input schema, checking, randomness, geometry, reading, data contract — and each group is one comment line. If a symbol is not listed here, it is not public. `core/` is an implementation detail: `package.json` exposes a single export path, so nobody can reach into it. ## Invariants The code relies on these. Break one and something downstream breaks quietly. - **Ids are unique across the whole galaxy**, hyperlanes included. Indexing, references and routing all assume it. - **`planet.index` equals its position in the array.** Validation checks it. - **`station.planetId` points at its parent planet.** Validation checks it. - **A star sits at `(0, 0)`.** All system coordinates are relative to it. - **A `Galaxy` is never mutated.** It is frozen terrain. Game state lives in a separate object keyed by the same ids — see [recipes.md](recipes.md#keep-game-state-alongside). - **Angles are degrees, clockwise, `0` points right. Y points down**, as in SVG. - **Periods are simulation seconds**, not wall-clock seconds. ## The rules the tooling enforces **`src/` knows nothing about UI, the DOM, or rendering.** This is the house rule. `tests/purity.test.ts` walks every source file and rejects: - imports that are not relative; - `document`, `window`, `navigator`, `localStorage`, `requestAnimationFrame`; - `Math.random`, `new Date(`, `Date.now(`; - `color`, `#rrggbb`, `svg`, `canvas`, `viewBox`; - any `.tsx`, `.jsx` or `.css` file under `src/`. It reads the sources, not the bundle, so it needs no build. Comments are stripped first — a JSDoc example may legitimately import `@industrieh/starmap`, and the word "document" is fine in prose. **`tsconfig.base.json` drops `DOM` from `lib`.** A stray `document.` does not compile, so the purity test is the second line of defence, not the first. **`isolatedDeclarations` is on, repo-wide.** Every exported declaration carries an explicit type. That keeps the public API readable, and it lets tsdown use the `oxc` declaration generator, which works in memory instead of writing `.d.ts` files next to the sources. ## The tests | file | covers | | -------------------- | --------------------------------------------------------- | | `fixture.ts` | one universe, written the way a user would write one | | `creation.test.ts` | what normalization fills in, and what it leaves alone | | `validation.test.ts` | each rejected case, and the accepted-but-implausible ones | | `queries.test.ts` | indexes, neighbours, components, routes, positions | | `purity.test.ts` | the house rule, file by file | The fixture is deliberately uneven: some ids given, some derived, some optional fields present and others left out. Those omissions are what exercise normalization. When you add a field, the fixture is usually where the test starts. Validation tests come in pairs — one that the check fires, one that a valid galaxy stays valid. A check that never passes is as broken as one that never fires. ## Where to change what | you want to | touch | | --------------------------------- | ------------------------------------------------------------------------ | | add a field to an object | `input.ts`, `types.ts`, `create.ts`, `validate.ts`, `docs/data-model.md` | | change what a field derives to | `create.ts`, `creation.test.ts`, `docs/data-model.md` | | add or relax a check | `validate.ts`, `validation.test.ts`, `docs/design.md` | | add a query helper | `query.ts`, `index.ts`, `queries.test.ts`, the API table in the README | | change the orbital model | `geometry.ts`, `queries.test.ts`, the conventions table in the README | | add a PRNG method | `rng.ts`, `index.ts`, `docs/recipes.md` | | break the shape of a saved galaxy | `FORMAT_VERSION` in `create.ts`, plus everything above | The checklists for each of these are in [CONTRIBUTING.md](../CONTRIBUTING.md). --- # Package: @industrieh/starmap-render v0.1.0-beta.0 Turn a universe into SVG. Strings, not components. Source: https://github.com/Industrieh/starmap/tree/main/packages/starmap-render --- # @industrieh/starmap-render Turn a [`@industrieh/starmap`](../starmap) universe into SVG. ```ts import { createGalaxy } from '@industrieh/starmap' import { renderSystem } from '@industrieh/starmap-render' const galaxy = createGalaxy({ systems: [ { id: 'sol', name: 'Sol', x: 0, y: 0, star: { class: 'G', radius: 18 }, planets: [{ name: 'Earth', type: 'terran', orbit: 150, radius: 6, period: 24 }], }, ], }) const svg = renderSystem(galaxy.systems[0], { t: 12.5 }) // '…' await Bun.write('sol.svg', svg) ``` It returns a **string**. No DOM, no framework, no stylesheet. One dependency: the core. ## Contents - [Install](#install) - [What it does](#what-it-does) - [Draw a system](#draw-a-system) - [Draw a galaxy](#draw-a-galaxy) - [Use the output](#use-the-output) - [Animate it](#animate-it) - [Theme it](#theme-it) - [Make it interactive](#make-it-interactive) - [API](#api) - [Guides](#guides) - [Working with an AI](#working-with-an-ai) ## Install ```bash bun add @industrieh/starmap @industrieh/starmap-render ``` Not on npm yet. Until then, install it from a path or from git: ```bash bun add @industrieh/starmap-render@file:../starmap/packages/starmap-render ``` The core is a real dependency, not a peer one, and it is never bundled in — your application ends up with one copy of it. ## What it does | It does | It does not | | ---------------------------------------------- | -------------------------- | | turn a system or a galaxy into an SVG string | touch the DOM | | place everything at a moment `t` you choose | run a clock | | take its colours from a theme you pass | ship a stylesheet | | tag every object with its id, for hit-testing | handle clicks, pan or zoom | | give you the layers without the `` around | pick a framework for you | A string is consumed everywhere: a `.svg` file, a `data:` URI, an ``, a server-rendered thumbnail, `innerHTML`, `dangerouslySetInnerHTML`. A component would only work in one framework. Interaction follows the same line. Pan, zoom, selection and the clock are application state, and every project handles them its own way. This package draws one frame. ## Draw a system `renderSystem(system, options)` returns a complete document. ```ts import { indexSystems } from '@industrieh/starmap' import { renderSystem } from '@industrieh/starmap-render' const svg = renderSystem(galaxy.systems[0], { t: 12.5, // simulation seconds — where the planets are width: 800, // optional; without it the drawing fills its container height: 800, title: 'The Sol system', // announced by a screen reader systems: indexSystems(galaxy), // lets a gate label say where it leads }) ``` What ends up in the drawing, back to front: the background, a field of decorative stars, orbit circles, asteroid fields, local routes, the star, planets with their stations, then gates. | option | default | what it does | | ----------------- | ----------- | ------------------------------------------------------ | | `t` | `0` | simulation time, in seconds | | `theme` | dark | colours, merged over the default | | `margin` | `0.45` | space around the system, as a fraction of `outerLimit` | | `labels` | `true` | draw names | | `backdrop` | `260` | how many background stars; `0` for none | | `idPrefix` | `'starmap'` | prefix of the gradient ids — see below | | `systems` | — | `indexSystems(galaxy)`, to name gate destinations | | `width`, `height` | — | pixel size attributes | | `title` | — | accessible name | **Two drawings on one page need two `idPrefix` values.** A page shares one id namespace, so two systems drawn with the same prefix would both use the first one's star colour. The system's own id makes a good prefix. ## Draw a galaxy `renderGalaxy(galaxy, options)` draws the network: systems as points, hyperlanes as the lines between them. ```ts import { findRoute } from '@industrieh/starmap' import { renderGalaxy } from '@industrieh/starmap-render' const route = findRoute(galaxy, 'sol', 'kepler') const svg = renderGalaxy(galaxy, { highlight: route?.systemIds }) ``` `highlight` takes an ordered list of system ids — exactly what `findRoute()` returns. Every system in it is emphasised, and so is every lane between two consecutive entries. Nothing moves here, so there is no `t`. At galaxy scale a planet is smaller than a rounding error. | option | default | what it does | | -------------------------- | ----------- | --------------------------------------- | | `theme` | dark | colours, merged over the default | | `margin` | `0.12` | space around the systems | | `labels` | `true` | draw system names | | `backdrop` | `400` | how many background stars; `0` for none | | `idPrefix` | `'starmap'` | prefix of the gradient id | | `highlight` | — | a path to bring forward | | `width`, `height`, `title` | — | same as above | ## Use the output ```ts // A file await Bun.write('sol.svg', renderSystem(system)) // An , with no second request const uri = `data:image/svg+xml,${encodeURIComponent(renderSystem(system))}` // A page, on the server or in the browser const html = `
${renderSystem(system)}
` ``` In React: `dangerouslySetInnerHTML={{ __html: renderSystem(system) }}`. The string is built from your own data, and every name in it is escaped on the way out. ## Animate it `t` is simulation seconds, compared against each object's `period`. The package keeps no clock: your loop decides how fast time moves, whether it pauses, and whether it runs backwards. ```ts let t = 0 const frame = (): void => { t += 1 / 60 container.innerHTML = renderSystem(system, { t }) requestAnimationFrame(frame) } ``` Rebuilding the whole string every frame is fine for a preview and wasteful for a real map. When you get there, draw the layers once with `renderSystemContent()` and move the objects yourself with `systemPositions()` from the core — the `data-id` attributes are there for exactly that. See [docs/recipes.md](docs/recipes.md). ## Theme it The core carries no colour: `planet.type → appearance` is a decision of the project that displays it. So it enters here. ```ts const svg = renderSystem(system, { theme: { background: '#000', planets: { terran: '#7ee787', shattered: '#7d7d8b' }, }, }) ``` A table you pass is merged over the default one, key by key — overriding one colour keeps the other twenty. Every table has a `default` entry, used for a value it does not list, so a project that invents a planet type still gets a complete drawing. The whole theme is in [docs/theming.md](docs/theming.md). ## Make it interactive Every object is drawn in a group that says what it is and which object it is: ```html ``` One listener on the container is enough — no per-object handlers, and the markup can be replaced wholesale on the next frame: ```ts container.addEventListener('click', (event) => { const node = (event.target as Element).closest('[data-id]') if (node) select(node.getAttribute('data-id')) }) ``` Layers carry `data-layer` (`backdrop`, `orbits`, `fields`, `routes`, `planets`, `gates`, `lanes`, `systems`), which is what you style or hide from CSS. For pan and zoom, take the layers without the document around them and own the `viewBox` yourself: ```ts import { renderSystemContent, systemViewBox, viewBoxAttr } from '@industrieh/starmap-render' const box = systemViewBox(system) svgElement.setAttribute('viewBox', viewBoxAttr(box)) // then change it as you zoom svgElement.innerHTML = renderSystemContent(system, { t }) ``` ## API ### Drawing | function | what it does | | ------------------------------------ | ------------------------------------ | | `renderSystem(system, options?)` | one system, as a full SVG document | | `renderSystemContent(system, opts?)` | the same layers, without the `` | | `renderGalaxy(galaxy, options?)` | the network, as a full SVG document | | `renderGalaxyContent(galaxy, opts?)` | the same layers, without the `` | | `svgDocument(view, body, options?)` | wrap your own layers in a document | ### Framing and shapes | function | what it does | | -------------------------------------------- | ---------------------------------- | | `systemViewBox(system, margin?)` | the box a system is drawn in | | `galaxyViewBox(galaxy, margin?)` | the box a galaxy is drawn in | | `viewBoxAttr(view)` | that box, as the attribute string | | `arcPath(r, startDeg, sweepDeg)` | the `d` of an arc — asteroid belts | | `backdropStars(count, radius, seed, scale?)` | a deterministic field of dots | ### Theme | export | what it is | | ---------------------------- | ---------------------------------------- | | `DEFAULT_THEME` | the dark theme, complete | | `resolveTheme(input?)` | fill a partial theme in from the default | | `pickColor(table, key)` | a colour, falling back to `default` | | `pickRouteStyle(table, key)` | a route style, same rule | ### SVG string building blocks `el`, `group`, `text`, `escapeXml`, `num`, `dash`, `background` — the functions this package builds every tag with. Use them to draw your own overlays in the same document. ```ts import { el, group } from '@industrieh/starmap-render' const ships = group( { 'data-layer': 'ships' }, fleet.map((s) => el('circle', { cx: s.x, cy: s.y, r: 3, fill: '#fff' })), ) const svg = renderSystem(system, { t }).replace('', `${ships}`) ``` ### Units and conventions Inherited from the core, unchanged: positions in world units, Y pointing down, angles in degrees measured clockwise with `0` pointing right, periods in simulation seconds. A system's star sits at `(0, 0)`. Sizes that belong to the drawing rather than to the world — glyphs, labels, stroke widths — are fractions of the view, so a system 300 units across and one 30 000 units across look the same. ## Guides - [docs/theming.md](docs/theming.md) — every colour, and how to replace it - [docs/drawing.md](docs/drawing.md) — how a star, a planet and a belt are built - [docs/recipes.md](docs/recipes.md) — files, thumbnails, React, pan and zoom, animation, overlays - [docs/architecture.md](docs/architecture.md) — how the code is built, file by file - [CONTRIBUTING.md](CONTRIBUTING.md) — how to work on the package ## Working with an AI This library is younger than the training data of most assistants, so a model asked about it cold will invent an API that looks plausible and does not exist. Two files exist to prevent that: | file | what it is | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------- | | [`/llms.txt`](https://starmap.industrieh.dev/llms.txt) | the map — every documentation page with a one-line description, a few kilobytes | | [`/llms-full.txt`](https://starmap.industrieh.dev/llms-full.txt) | everything — both packages' READMEs and guides in one file, ~120 KB | In an agent that reads project instructions — Claude Code, Cursor, Copilot — one line does it: ```md When writing code against @industrieh/starmap-render, read https://starmap.industrieh.dev/llms-full.txt first. It is the complete documentation of the renderer and of the core it draws. ``` In a chat, paste the URL. Offline, download the file and attach it — both are plain text. Two things worth telling a model up front, because nothing in the types says so: - **This package returns strings, not components.** If it suggests a `` or a hook, it is inventing. `renderGalaxy()` gives you SVG markup, and where it goes is your decision. - **It draws one frame, for one moment.** Pan, zoom, hover and selection are not here on purpose — `data-id` and `data-kind` are the hooks, the handlers are yours. ## Developing This package lives in a Bun monorepo. See the [root README](../../README.md) for the repo-wide commands. ``` packages/starmap-render/ src/ index.ts public API core/ svg.ts escaping, number formatting, element building theme.ts Theme, ThemeInput, DEFAULT_THEME geometry.ts framing, arcs, backdrop root.ts the element system.ts renderSystem() galaxy.ts renderGalaxy() tests/ svg, theme, geometry, system, galaxy, purity docs/ theming, drawing, recipes, architecture ``` | command | what it does | | -------------------- | ---------------------------- | | `bun run build` | build to `dist/` with tsdown | | `bun run test` | Vitest | | `bun run test:watch` | Vitest in watch mode | | `bun run typecheck` | `tsc --noEmit` | **The house rule: `src/` imports the core and nothing else.** No DOM, no framework, no stylesheet, no clock, no unseeded randomness. `tests/purity.test.ts` checks that file by file, and `tsconfig.base.json` drops `DOM` from `lib` so a slip is a compile error. --- # How things are drawn What the SVG is actually made of, object by object. Read this before changing what a drawing looks like, and when you want to draw something in the same style. Everything here uses native SVG primitives only. No filter, no texture, no bitmap, no drawing library. ## Contents - [Layers](#layers) - [The three gradients](#the-three-gradients) - [A star](#a-star) - [A planet](#a-planet) - [A ring](#a-ring) - [A station](#a-station) - [A gate](#a-gate) - [An asteroid field](#an-asteroid-field) - [A local route](#a-local-route) - [The galaxy map](#the-galaxy-map) - [Two scales](#two-scales) - [Where the coordinates come from](#where-the-coordinates-come-from) ## Layers A drawing is a flat list of groups, back to front. The order is the whole composition — there is no `z-index` in SVG, only document order. | system view | galaxy view | holds | | -------------- | ----------- | ------------------------------------------- | | the background | ✓ | one rectangle, three times the `viewBox` | | `backdrop` | ✓ | decorative stars | | `orbits` | — | planet circles, gate circles | | `fields` | — | belts, clusters and their rocks | | `routes` | — | local routes, so they pass under everything | | the star | — | halo and disc | | `planets` | — | planets, each with its stations | | `gates` | — | gates and their labels | | — | `lanes` | hyperlanes | | — | `systems` | system dots, rings and names | Each group carries `data-layer`, which is what an application styles or hides. A layer with nothing in it is not emitted at all: a system with no gates produces no gate group, rather than an empty one. ## The three gradients Declared once in `` and referenced by everything that needs them. They are what gives the drawing its volume — and they replace a blur filter, which is the expensive part of rasterizing an SVG. ``` the star's halo 0% star colour, opacity 0.85 35% star colour, opacity 0.22 100% star colour, opacity 0 the stellar disc 0% white 60% star colour 100% star colour, opacity 0.85 planet volume 0% white, opacity 0.28 45% black, opacity 0 100% black, opacity 0.6 ``` `shade` is off-centre on purpose: the lit side is up and to the left. The light does **not** follow the real position of the star. At these sizes the difference is invisible, and one shared gradient beats one gradient per planet. The ids are prefixed because a page shares one id namespace. Two drawings with the same prefix would both resolve `url(#starmap-core)` to the first one's gradient, so the second system would wear the first one's star colour. Hence `idPrefix`. ## A star Two circles, centred on the origin: ```html ``` The halo is six times the disc. That ratio is what reads as brightness — a smaller one looks like a flat dot with an outline. ## A planet A stack of circles inside a group translated to the planet's position: ```html Earth ``` The shading circle is exactly the size of the disc and sits on top of it. The habitable marker is a thin ring just outside the disc, in the theme's `habitable` colour. ## A ring An `` wider than it is tall, rotated slightly, with no fill and a thick stroke in the planet's own colour: ```html ``` It is drawn **before** the disc, so the disc hides its near half. That overlap is the entire trick that reads as perspective — nothing else about the drawing is three-dimensional. A planet gets one when its type is `gas` or `ice-giant` **and** it has three moons or more. Moons themselves are never drawn; the count is only used as a hint that the planet is a big one. ## A station A small square turned 45°, positioned **relative to its planet** — the enclosing group already carries the planet's translation, so the station only needs the difference between the two positions. That is what makes it follow its planet around the orbit for free. Its colour comes from the `stations` table, keyed by `station.type`. ## A gate Four parts, in order: a translucent disc, a square rotated by `45 + gate.angle`, an optional dashed warning ring when `status` is `'unstable'`, and a label. The square turns with the gate so that a ring of gates does not look stamped from one mould. The label is the gate's name, or `Sol Gate → Vega` when the `systems` option gives the renderer an index to resolve `gate.links` with. ## An asteroid field A belt is one thick stroke along an arc: ```html ``` `arcPath()` builds that `d`. A single SVG arc cannot describe a full circle — its end point would be its start point, and the arc would be empty — so past 359.5° it becomes two half circles. A cluster is a dashed circle instead, centred on its own `x`/`y`. Both then draw whatever `rocks` the model carries, as plain circles. The library never invents rocks: an empty `rocks` array draws an empty field, which is the honest picture. ## A local route A straight line between the two positions of this frame. Both ends are looked up by id in the same map the planets came from, so a route to a station follows it around its planet. Routes are drawn before every object, so they pass underneath rather than across. ## The galaxy map Each system is three circles and a name: a halo, a faction ring, and a dot coloured by the star's class. The faction ring is dashed, and becomes solid and thicker when the system is on the highlighted path — a difference that survives greyscale. Hyperlanes are dashed lines between the two system positions. A highlighted lane is solid, thicker and in the `highlight` colour. Nothing on this map moves, so there is no `t`. ## Two scales A system can be 300 world units across or 30 000. Anything sized in absolute units would fill the view in one and vanish in the other, so decorations are fractions of the view instead: | name | value | used for | | ------ | -------------- | ------------------------------- | | `u` | `view.w / 200` | glyph sizes, gaps, dash lengths | | `k` | `view.w / 800` | stroke widths | | `font` | `view.w / 65` | labels | What keeps its real size is everything that belongs to the world: star radius, planet radius, orbits, belt widths, rock positions. That is the line — if the model says how big it is, it is drawn that big. ## Where the coordinates come from `systemPositions(system, t)` in the core, always. This package computes no orbital position of its own; it asks for the whole map of them once per drawing and looks each object up by id. Everything is drawn in the system's own frame — star at `(0, 0)`, distances in world units — and the `viewBox` does the scaling. No coordinate is ever converted to pixels, which is what lets one string be a 32-pixel thumbnail and a full-screen map. --- # Theming Every colour in a drawing comes from a theme you pass. The core carries none — `planet.type → appearance` is a decision of the project that displays it, and a colour baked into the data is useless the day that project needs a light mode. For the shapes those colours are applied to, see [drawing.md](drawing.md). ## Contents - [Two layers](#two-layers) - [The tables](#the-tables) - [The single colours](#the-single-colours) - [Route styles](#route-styles) - [Every default](#every-default) - [A light theme](#a-light-theme) - [Reusing the theme in your own code](#reusing-the-theme-in-your-own-code) ## Two layers `ThemeInput` is what you write, `Theme` is what the drawing functions read, and `resolveTheme()` goes from one to the other. It is the same split the core makes between `GalaxyInput` and `Galaxy`. ```ts import { renderSystem, resolveTheme } from '@industrieh/starmap-render' // Inline: resolved on every call. renderSystem(system, { theme: { background: '#000' } }) // Resolved once, then reused. Do this if you render every frame. const theme = resolveTheme({ background: '#000' }) renderSystem(system, { theme }) ``` Merging goes one level deep. A table you pass is merged **key by key**, so overriding one planet colour keeps the other ten: ```ts resolveTheme({ planets: { terran: '#7ee787' } }).planets.gas // still '#d8a06a' ``` Nothing is merged deeper than that. A `RouteStyle` is replaced whole, because a dash pattern without its width is not a style. ## The tables Five fields are tables keyed by a value from the model: | table | keyed by | drawn as | | ---------- | ----------------- | --------------------------------------- | | `stars` | `star.class` | the stellar disc, its halo, system dots | | `planets` | `planet.type` | the planet disc and its ring | | `stations` | `station.type` | the station diamond | | `factions` | `faction.id` | the ring around a system on the map | | `routes` | `localRoute.type` | the stroke of a local route | Every table carries a `default` entry, and it is required. The core's vocabularies are open unions: a project may invent `'shattered'` and the model accepts it. A table can therefore never be exhaustive, and `default` is what keeps the drawing complete. ```ts const theme = resolveTheme({ planets: { shattered: '#7d7d8b' } }) pickColor(theme.planets, 'shattered') // '#7d7d8b' pickColor(theme.planets, 'crystal') // the default — nothing goes missing pickColor(theme.planets, undefined) // the same: the field was left out ``` The last line is the common case. `type`, `class`, `faction` and friends are all optional in the model, and a drawing still has to put something on screen. ## The single colours | field | where it shows | | -------------- | --------------------------------------------------- | | `background` | fills the drawing, behind everything | | `backdropStar` | the decorative star field, and the system halo | | `label` | every piece of text | | `font` | the font stack written on the label groups | | `orbit` | planet orbit circles | | `gateOrbit` | gate orbit circles, dashed | | `lane` | hyperlanes on the galaxy map | | `highlight` | systems and lanes on a highlighted path | | `habitable` | the ring around a planet whose `habitable` is true | | `gate` | the gate body | | `gateUnstable` | the ring around a gate whose `status` is `unstable` | | `field` | the outline of a belt or a cluster | | `rock` | the rocks inside them | ## Route styles A route style is three fields: ```ts { stroke: '#5f7fa8', dash: '6 8', width: 1.1 } ``` `dash` is an SVG `stroke-dasharray`, and each route type gets a distinct pattern on purpose: the map stays readable in greyscale, and to a colourblind reader. `width` and the dash lengths are scaled with the view before they are written out, so a route stays a thin line in a system of any size. Think of them as relative, not as world units. ## Every default ```ts DEFAULT_THEME = { background: '#070b16', backdropStar: '#cdd9ff', label: '#dbe4f5', font: 'ui-sans-serif, system-ui, sans-serif', orbit: '#2b3d63', gateOrbit: '#43356e', lane: '#39507a', highlight: '#9a7bff', habitable: '#4fd8a5', gate: '#9a7bff', gateUnstable: '#ff6b81', field: '#6b5a2a', rock: '#a89468', stars: { O: '#9bb0ff', B: '#aabfff', A: '#cad7ff', F: '#f8f7ff', G: '#ffe9a8', K: '#ffc16f', M: '#ff8f5a', default: '#ffe9a8', }, planets: { lava: '#e2542c', rocky: '#a08d7d', desert: '#d9a45b', barren: '#8b8377', terran: '#4fae6a', ocean: '#2f8fd8', toxic: '#9ac93b', gas: '#d8a06a', 'ice-giant': '#6fb6d9', frozen: '#cfe4f0', default: '#8b8377', }, stations: { trade: '#61d6c4', mining: '#c9a227', military: '#ff6b81', research: '#7fb3ff', shipyard: '#d0a6ff', default: '#9fb4d8', }, factions: { neutral: '#8e9bb3', consortium: '#4ea3ff', covenant: '#ff5c72', syndicate: '#c78bff', pilgrims: '#4fd8a5', default: '#8e9bb3', }, routes: { 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 star colours follow spectral class — blue for O, orange-red for M — and the planet colours follow the usual reading of each type. They are calibrated against each other, which is the only reason they are the default. Replace them freely. ## A light theme Nothing in the package assumes a dark background. The one thing to remember is that the star halo and the planet shading are drawn with fixed white and black gradients, so a very light background flattens the star. ```ts const light = resolveTheme({ background: '#f6f7fb', backdropStar: '#c3cadb', label: '#1c2333', orbit: '#c8d1e4', gateOrbit: '#cfc4e8', lane: '#a9b6cf', field: '#d8c89a', rock: '#b8a274', }) ``` Turning the backdrop off (`backdrop: 0`) usually reads better in light mode. ## Reusing the theme in your own code `pickColor` and `pickRouteStyle` are exported, so an overlay you draw yourself picks its colours the same way the package does: ```ts import { DEFAULT_THEME, el, pickColor } from '@industrieh/starmap-render' const marker = (planet: Planet) => el('circle', { r: planet.radius + 4, fill: 'none', stroke: pickColor(DEFAULT_THEME.planets, planet.type), }) ``` They are also the right way to build a legend: iterate over the table you care about, skip `default`, and you have every colour with its label. --- # Recipes Task by task. Every snippet assumes a `galaxy` built with `createGalaxy()`. - [Write a file](#write-a-file) - [Show it in a page](#show-it-in-a-page) - [Put it in an ``](#put-it-in-an-img) - [Render it on a server](#render-it-on-a-server) - [Use it in React](#use-it-in-react) - [Animate it](#animate-it) - [Animate it without rebuilding the string](#animate-it-without-rebuilding-the-string) - [Pan and zoom](#pan-and-zoom) - [Handle clicks](#handle-clicks) - [Draw your own overlay](#draw-your-own-overlay) - [Show a route on the map](#show-a-route-on-the-map) - [Make a thumbnail](#make-a-thumbnail) - [Put several drawings on one page](#put-several-drawings-on-one-page) - [Style it with CSS](#style-it-with-css) ## Write a file ```ts import { renderSystem } from '@industrieh/starmap-render' await Bun.write('sol.svg', renderSystem(galaxy.systems[0], { t: 0 })) ``` The output carries its `xmlns`, so the file opens in any viewer. ## Show it in a page ```ts document.querySelector('#map').innerHTML = renderSystem(system, { t }) ``` Without `width` and `height`, the drawing fills its container and keeps its aspect ratio. The background rectangle is deliberately larger than the `viewBox`, so the letterboxed bands are filled too. ## Put it in an `` ```ts const uri = `data:image/svg+xml,${encodeURIComponent(renderSystem(system))}` img.src = uri ``` Note that an SVG loaded through `` is isolated: your page's CSS does not reach inside it. Everything it needs is already in the string. ## Render it on a server ```ts Bun.serve({ routes: { '/system/:id.svg': (req) => { const system = index.get(req.params.id) if (!system) return new Response('unknown system', { status: 404 }) return new Response(renderSystem(system, { title: system.name }), { headers: { 'content-type': 'image/svg+xml' }, }) }, }, }) ``` Rendering is a pure function of `(system, t, theme)`, so the same request always produces the same bytes. Cache it as aggressively as you like. ## Use it in React ```tsx function SystemView({ system, t }: { system: StarSystem; t: number }) { const svg = useMemo(() => renderSystem(system, { t, idPrefix: system.id }), [system, t]) return
} ``` The `dangerously` prefix is about untrusted HTML. This string is built from your own data by this package, and every name it contains is escaped on the way out. ## Animate it ```ts let t = 0 let last = performance.now() const frame = (now: number): void => { t += (now - last) / 1000 // one simulation second per real second last = now container.innerHTML = renderSystem(system, { t }) requestAnimationFrame(frame) } requestAnimationFrame(frame) ``` Speed and pause are yours: multiply the increment, or stop adding to `t`. Negative works too. ## Animate it without rebuilding the string Replacing the whole markup 60 times a second is fine for a preview and wasteful for a real map. Draw the layers once, then move the objects — the `data-id` attributes are there for exactly this. ```ts import { systemPositions } from '@industrieh/starmap' import { renderSystemContent, systemViewBox, viewBoxAttr } from '@industrieh/starmap-render' svgElement.setAttribute('viewBox', viewBoxAttr(systemViewBox(system))) svgElement.innerHTML = renderSystemContent(system, { t: 0 }) const moving = new Map( [...system.planets, ...system.gates].map((o) => [ o.id, svgElement.querySelector(`[data-id="${o.id}"]`), ]), ) function update(t: number): void { const pos = systemPositions(system, t) for (const [id, node] of moving) { const at = pos.get(id) if (at) node.setAttribute('transform', `translate(${at.x} ${at.y})`) } } ``` Stations move with their planet on their own — their group sits inside the planet's, and its transform is relative. ## Pan and zoom Own the root element, and pan and zoom become one attribute: ```ts import { renderSystemContent, systemViewBox, viewBoxAttr } from '@industrieh/starmap-render' let view = systemViewBox(system) svgElement.innerHTML = renderSystemContent(system, { t }) function zoom(factor: number): void { const cx = view.x + view.w / 2 const cy = view.y + view.h / 2 view = { w: view.w * factor, h: view.h * factor, x: 0, y: 0 } view.x = cx - view.w / 2 view.y = cy - view.h / 2 svgElement.setAttribute('viewBox', viewBoxAttr(view)) } ``` Panning is the same idea: add your drag delta, in world units, to `view.x` and `view.y`. Nothing is re-rendered. ## Handle clicks One listener on the container, because the markup can be replaced at any time: ```ts import { indexGalaxy } from '@industrieh/starmap' const index = indexGalaxy(galaxy) container.addEventListener('click', (event) => { const node = (event.target as Element).closest('[data-id]') if (!node) return const entry = index.get(node.getAttribute('data-id')) if (entry?.kind === 'station') console.log(entry.obj.docks, 'docks') }) ``` `data-kind` is on the same group when you only need the kind, without a lookup. A small planet is hard to hit. If that matters, widen the target with CSS — `[data-kind="planet"] { stroke: transparent; stroke-width: 12 }` — rather than changing the drawing. ## Draw your own overlay The building blocks the package uses are exported, so an overlay comes out in the same style, in the same coordinate system. ```ts import { el, group, renderSystemContent, svgDocument, systemViewBox, } from '@industrieh/starmap-render' const ships = group( { 'data-layer': 'ships', fill: '#fff' }, fleet.map((s) => el('circle', { cx: s.x, cy: s.y, r: 3, 'data-id': s.id })), ) const svg = svgDocument(systemViewBox(system), `${renderSystemContent(system, { t })}\n${ships}`, { title: system.name, }) ``` Coordinates in an overlay are world units in the system's frame — the same ones `systemPositions()` returns. ## Show a route on the map ```ts import { findRoute } from '@industrieh/starmap' const route = findRoute(galaxy, 'sol', 'kepler') const svg = renderGalaxy(galaxy, { highlight: route?.systemIds }) ``` `highlight` is an ordered path: consecutive entries light the lane between them. Passing two systems that are not neighbours highlights the systems and no lane, which is exactly what it should look like. ## Make a thumbnail ```ts const thumb = renderSystem(system, { width: 64, height: 64, labels: false, // unreadable at this size backdrop: 0, // noise at this size margin: 0.1, // fill the frame }) ``` Labels and the backdrop are the two things that cost the most and read the least when small. ## Put several drawings on one page Give each one its own `idPrefix`: ```ts const svgs = galaxy.systems.map((s) => renderSystem(s, { idPrefix: s.id, width: 200, height: 200 })) ``` Gradient ids are global to the page. Without distinct prefixes, every drawing after the first would use the first one's star colour. ## Style it with CSS An inline SVG is styleable like anything else, and the layers are addressable: ```css [data-layer='backdrop'] { opacity: 0.4; } [data-layer='routes'] { display: none; } [data-kind='planet']:hover circle { stroke: #fff; stroke-width: 1; } ``` Attributes written by the package are presentation attributes, so any CSS rule wins over them. That is the cheap way to add a hover or a selection state without re-rendering. --- # Architecture How the package is built, file by file. Read this before changing the code. For what it does, see the [README](../README.md). For what a drawing is made of, see [drawing.md](drawing.md). ## Contents - [The map](#the-map) - [The pipeline](#the-pipeline) - [`svg.ts` — the string primitives](#svgts--the-string-primitives) - [`theme.ts` — the look](#themets--the-look) - [`geometry.ts` — framing and shapes](#geometryts--framing-and-shapes) - [`root.ts` — the `` element](#rootts--the-svg-element) - [`system.ts` and `galaxy.ts` — the drawings](#systemts-and-galaxyts--the-drawings) - [`index.ts` — the public surface](#indexts--the-public-surface) - [Rules the code holds itself to](#rules-the-code-holds-itself-to) - [The tests](#the-tests) - [Where to change what](#where-to-change-what) ## The map ``` packages/starmap-render/ src/ index.ts public API — the only file that re-exports core/ svg.ts escaping, number formatting, element building theme.ts Theme, ThemeInput, DEFAULT_THEME, resolveTheme geometry.ts framing, arcs, backdrop root.ts the element around a drawing system.ts renderSystem(), renderSystemContent() galaxy.ts renderGalaxy(), renderGalaxyContent() tests/ fixture.ts one universe with one of everything drawable xml.ts a thirty-line well-formedness check svg.test.ts the primitives theme.test.ts merging and fallbacks geometry.test.ts framing, arcs, determinism system.test.ts the system drawing galaxy.test.ts the galaxy drawing purity.test.ts the house rule, file by file docs/ theming, drawing, recipes, this file ``` ``` index.ts ──► system.ts ──┬──► root.ts ──► geometry.ts ──► @industrieh/starmap ──► galaxy.ts ──┤ └► svg.ts ├──► theme.ts ├──► geometry.ts └──► svg.ts ``` No cycles. `theme.ts` imports nothing at all; `svg.ts` imports the core's `round()` and one type. ## The pipeline A drawing is built in one pass, bottom up: ``` data + options │ ▼ resolveTheme() colours filled in from the default systemViewBox() the frame — everything else is a fraction of it systemPositions() where every object is at t (from the core) │ ▼ draw* functions one per layer, each returning a string │ ▼ join('\n') the layers → renderSystemContent() svgDocument() the around them → renderSystem() ``` Two properties fall out of that shape, and both are worth keeping: **A drawing is a pure function.** Same data, same `t`, same theme, same string — byte for byte. That is what makes a snapshot test, an HTTP cache or a diff possible downstream, and it is why `Math.random` and `Date` are banned rather than merely discouraged. **The layers exist without the document.** `renderSystemContent()` is not a convenience wrapped around `renderSystem()`; it is the other way round. An application that pans and zooms owns the root element, so it must be able to get the body alone. ## `svg.ts` — the string primitives Seven functions, and the only place in the package where a `<` is written: | function | what it does | | ------------------------ | -------------------------------------------------- | | `escapeXml(value)` | `&`, `<`, `>`, `"`, `'` — `&` first | | `num(value)` | two decimals, no exponent, `0` for non-finite | | `el(name, attrs, kids?)` | one element, self-closing when it has no children | | `group(attrs, parts)` | a ``, one part per line, empty when it is empty | | `text(content, attrs)` | a `` whose content is escaped | | `dash(...lengths)` | a `stroke-dasharray` | | `background(view, fill)` | the rectangle behind everything | Centralising them is what makes escaping and rounding unmissable: a drawing function cannot forget them, because it never builds a tag itself. Two details that look arbitrary and are not: - `el` drops attributes that are `undefined` or `false`, and keeps `0`. That is what lets a call site write `stroke-dasharray: on ? undefined : dash(...)` instead of branching around the whole element. - `dash` exists because a dash pattern is the one length that would otherwise be interpolated into a string, escaping `num` — and putting `3.2624999999999997` in the output. `background()` draws a rectangle three times the size of the `viewBox`. `preserveAspectRatio` letterboxes a drawing whose box does not match the space it is given, and those bands sit outside the `viewBox`: a rectangle of exactly the right size leaves them transparent, showing the page through them. ## `theme.ts` — the look `ThemeInput` → `resolveTheme()` → `Theme`, mirroring the core's `GalaxyInput` → `createGalaxy()` → `Galaxy`. Tables merge key by key; a `RouteStyle` is replaced whole. Every table carries a required `default`. The core's vocabularies are open unions, so a table can never be exhaustive, and `pickColor()` always has something to return. Fields like `planet.type` are optional in the model too, so the `undefined` key is the common case, not the edge case. `stripUndefined()` is the one subtlety: without it, `{ background: undefined }` would spread over the default and punch a hole in the theme. ## `geometry.ts` — framing and shapes Everything the core deliberately does not carry, because it is about drawing rather than about the world: - `systemViewBox`, `galaxyViewBox` — the frame. Both guard against degenerate input: a system whose `outerLimit` is `0`, a galaxy with one system or none, systems in a straight line. A box with no area renders as nothing at all. - `arcPath` — belts. Past 359.5° it emits two half circles, because one SVG arc cannot close a circle. - `backdropStars` — scenery, on its own seed. It reuses `mulberry32` and `hashSeed` from the core rather than carrying its own PRNG. The `scale` parameter on `backdropStars` is there because dots are sized in world units: a galaxy-wide view needs bigger ones than a single system for the sky to look the same. ## `root.ts` — the `` element One function, `svgDocument(view, body, options)`. It writes `xmlns` — which is what makes the string usable outside an HTML parser — `viewBox`, `preserveAspectRatio`, optional pixel dimensions, and an optional `` with `role="img"` beside it. The file is called `root.ts` and not `document.ts` because `purity.test.ts` rejects the word `document` anywhere in the sources, import paths included. The check is worth more than the better file name. ## `system.ts` and `galaxy.ts` — the drawings Both files have the same shape: a public pair (`renderX` / `renderXContent`), a `Scale` computed from the view, then one `draw*` function per layer, each returning a string and none of them touching anything outside its arguments. `system.ts` carries the `Scale` that matters most: | name | value | used for | | ------ | -------------- | ------------------------------- | | `u` | `view.w / 200` | glyph sizes, gaps, dash lengths | | `k` | `view.w / 800` | stroke widths | | `font` | `view.w / 65` | labels | A system may be 300 world units across or 30 000. Anything sized absolutely would fill the view in one and vanish in the other. What keeps its real size is everything the model states: star radius, planet radius, orbits, belt width, rock positions. `galaxy.ts` has its own `Scale`, expressed as fractions of the largest dimension, and one thing `system.ts` does not: `pathPairs()` turns the `highlight` path into the set of lane keys to light. A lane key is its two endpoints sorted, because a hyperlane is undirected — `['a','b']` and `['b','a']` are the same lane. Both draw functions tolerate references that resolve to nothing. A lane whose endpoint does not exist, a route pointing at a missing object: they are skipped rather than crashed on, because these functions also run on galaxies parsed from disk, which the type system says nothing about. ## `index.ts` — the public surface The only file that re-exports, grouped by role. Anything not listed there is an implementation detail — `package.json` exposes a single export path, so `core/` is unreachable from outside. ## Rules the code holds itself to - **Import the core, or something relative. Nothing else.** No DOM, no framework, no stylesheet. - **No clock, no unseeded randomness.** A drawing is a function of its arguments. - **Every string that comes from data is escaped.** Names are user data. - **Every number that reaches the output goes through `num()`.** - **An empty layer is not emitted.** - **The world keeps its units; the drawing scales with the view.** The first two are enforced by `tests/purity.test.ts`, and by `tsconfig.base.json` dropping `DOM` from `lib`. ## The tests | file | covers | | ------------------ | ------------------------------------------------------- | | `fixture.ts` | one universe with one of everything drawable | | `xml.ts` | tag balance and unescaped text, without a parser | | `svg.test.ts` | escaping, rounding, attribute dropping, empty groups | | `theme.test.ts` | merging, fallbacks, and that the default is not mutated | | `geometry.test.ts` | framing, degenerate galaxies, arcs, determinism | | `system.test.ts` | layers, ids, positions at `t`, options, labels | | `galaxy.test.ts` | lanes, highlighting, escaping, an empty galaxy | | `purity.test.ts` | the house rule, file by file | The assertions are on what ends up in the string — an object's group, an id, a colour — not on exact markup. Pinning the markup down would turn every visual improvement into a failing test. The fixture is built with `createGalaxy()`, so it is validated on the way in. A drawing test can never fail because the fixture drifted out of the data contract. ## Where to change what | you want to | touch | | --------------------------------- | ------------------------------------------------------------- | | change how an object looks | the `draw*` function in `system.ts`, `docs/drawing.md` | | add a colour | `theme.ts` (both `Theme` and `ThemeInput`), `docs/theming.md` | | add a render option | the options interface, the destructuring, the README table | | change the framing | `geometry.ts`, `geometry.test.ts` | | add an attribute to every element | `svg.ts`, and nowhere else | | draw a new kind of object | a new `draw*` function, plus its place in the layer order | The checklists for each are in [CONTRIBUTING.md](../CONTRIBUTING.md).