Hand-placed system maps that people can actually follow.
Typed data in, a React Flow canvas out: cards that say what they are, wires that
say what they carry, and the whole graph exported as PNG or SVG in either theme.
Live demo • Website • Overview • Quick start • Make it yours • Cards and wires • Export • Audits • Architecture • Deployment
Most architecture diagrams are drawn once in a whiteboard tool and rot the week after. Auto-layout tools stay current but lay the graph out for the algorithm, not for the reader. System Map sits between the two: the positions are yours, written as typed data you can review in a pull request, and a set of audits checks that the result stays readable — no wire through a card, no label on a stroke, no page that scrolls sideways on a phone.
The project stays deliberately:
- hand-placed — no layout engine. You decide where the eye starts and where it ends.
- data, not an editor — a map is four TypeScript files. There is no canvas to click around in and no file format to lose.
- honest about colour — every colour has a second cue: status carries a word, wires carry a dash pattern, bars print their value.
- static — no database, no runtime data source, no analytics, no authentication, no telemetry.
The included map is fictional. It uses
example.comand IANA-reserved documentation addresses (192.0.2.0/24,2001:db8::/32), so the repository can be published or forked without exposing a real network.
- Ten card kinds, three wire kinds — from a plain service to the one primary card a map is about, a frame for a host, and a card whose body is the output itself. See Cards and wires.
- Identity apart from state — names, roles and addresses live in the topology; latency, status and throughput live in a separate file, ready to be fed by something live.
- Full-canvas export — PNG or SVG at 1×, 2× or 3×, dark or light, filled or transparent, or straight to the clipboard. The whole graph, not the part on screen.
- Four layout audits — one reads the data without a browser and runs in CI; three measure the rendered page.
- Keyboard, trackpad and reduced motion — arrow keys pan,
+/-zoom,0fits; the drifting wires stop underprefers-reduced-motion. - Light and dark — both themes resolve through the same semantic tokens, and an export can be either without touching the page theme.
- A container image for amd64 and arm64 on GHCR, and a hardened
compose.yaml.
Requires Node.js 22.18 or newer. Nothing else — no database, no account.
git clone https://github.com/nichtlegacy/system-map.git
cd system-map
npm ci
npm run devExpected: http://localhost:3000 shows the landing page, and
http://localhost:3000/example the map above. Edit anything in
data/example/ and the canvas updates on save.
Every green CI run on main publishes an image to the GitHub Container
Registry; a release adds its version tags.
docker run --rm -p 3000:3000 ghcr.io/nichtlegacy/system-map:latestThe map is compiled into the image, so the published one only ever shows the example. To serve your own, build from your checkout:
docker compose up -d --buildcompose.yaml runs the container read-only, with every
capability dropped, no-new-privileges, and a 256 MB memory limit. The health
check polls / every 30 seconds.
A map is four files, and each one has one job:
| File | Holds | Changes when |
|---|---|---|
topology.ts |
Identities, positions, frames, lanes and wires | The system changes shape |
state.ts |
Status, latency, capacity — what a live source would provide | The numbers change |
index.ts |
Merges both into React Flow nodes and edges, with their sizes | You add a card kind to the map |
counts.ts |
Every number the page header and legend print, derived from the topology | Never by hand |
A card is an identity with a position; its numbers sit in state under the same id:
// topology.ts
export const services: Placed<ServiceIdentity>[] = [
{
id: "worker",
position: { x: 920, y: 240 },
name: "Worker",
category: "Background process",
description: "Claims a job, performs the work, and records its result.",
host: "2001:db8::10",
icon: Cpu,
},
];
// state.ts
export const serviceState: Record<string, ServiceState> = {
worker: {
status: "online",
metrics: [
{ label: "Active", value: "3" },
{ label: "Queue", value: "12" },
],
},
};A wire names both ends, the side of each card it leaves and enters, and what it carries:
export const links: FlowLink[] = [
{
id: "api-worker",
stage: 2,
source: "api",
target: "worker",
sourceHandle: "right",
targetHandle: "left",
flavor: "data",
label: "job",
},
];The full model — every card's fields, label nudges, curvature overrides — is
documented inline in types/system-map.ts.
Copy data/example/ and app/example/, rename
both, and point the new route at the new folder. Map components never import
from a data folder, so a new map costs a folder and a route and nothing else.
The project name and repository URL live in data/site.ts.
The renderer is React Flow (@xyflow/react). If an
agent draws or extends your map, give it the community
react-flow skill,
which covers custom nodes, handles, edge labels and the viewport:
npx skills add existential-birds/beagle --skill react-flowPoint it at data/example/ and docs/design.md, and let the
audits judge the result.
Each card kind is a React component with a fixed size, so layouts stay on a grid and the audits know what they measure. A normal card is 232 × 200, a compact one 232 × 116, the primary card 420 × 364.
| Kind | Use |
|---|---|
service |
A normal running service |
primaryService |
The main decision point — one per map |
external |
Something outside the system boundary |
storage |
A path or capacity-bearing store, with a fill bar |
artefact |
The output itself — a report, a render — as a picture |
infra |
A host frame with its listeners and facts |
cluster |
A labelled frame around related nodes, which a wire can attach to |
lane |
A reading-order label across the top |
stat |
One derived figure and the step that produced it |
bars |
Ranked values with visible numbers |
Wires come in three flavours. Colour and dash pattern come from CSS tokens, not from the graph data. Hovering or focusing a legend entry lights up that flavour and dims the rest.
| Flavour | Carries |
|---|---|
data |
Payloads and stored records |
management |
Control, state and health signals |
external |
Anything crossing the system boundary |
Labels sit beside the curve, not on it: FlowEdge computes the curve's normal
and takes a side override or a small nudge where the corridor is narrow. The
rules behind all of this — tokens, type, spacing, motion — are in
docs/design.md.
The export panel captures an off-screen clone of the whole viewport. The live view stays still, cards outside the current viewport are included, and a light export works from a dark page.
- Format — PNG or SVG, or a PNG copied to the clipboard (HTTPS or localhost only; browsers withhold the clipboard elsewhere).
- Theme — dark or light, independent of the page.
- Background — the canvas colour, or transparent with pre-composited wire colours so lines keep their contrast on any backdrop.
- Scale — 1×, 2× or 3×. The example at 2× is about 4900 px across.
| Input | Does |
|---|---|
| Drag, wheel, pinch | Pan and zoom |
| Shift + wheel | Pan sideways |
| ← ↑ → ↓ | Pan |
| + / - | Zoom |
| 0 | Fit the whole graph |
| Esc | Close the export panel |
The images in this README and on the landing page come out of that same panel — see Regenerating images.
Positions are hand-authored, so the checks measure whether the result is sound; they never choose a layout.
| Audit | Needs | Fails on |
|---|---|---|
audit:geometry |
Nothing — reads topology.ts |
A wire through a card, two wires on one handle, a card outside its frame, an arc the first view would crop |
audit:map |
The running app | Frame containment, card overlap, edge crossings, label collisions, clipped content |
audit:labels |
The running app | A label too close to a stroke or a plate, ink spilling past a card, navigation that overflows |
audit:mobile |
The running app | Any page that scrolls sideways on a phone, naming the element responsible |
npm run typecheck
npm run audit:geometry # no browser; CI runs this on every pushThe browser audits drive Playwright against http://127.0.0.1:3000. Run them
against a production server — next dev blocks its dev resources for
127.0.0.1, and the page never hydrates:
npx playwright install chromium # once
npm run build && npm start
# in a second terminal
npm run audit:map
npm run audit:labels
npm run audit:mobileaudit:map and audit:labels check 2560 × 1440 by default; pass
-- --vp 1920x1080 or -- --vp 1440x900 for the other sizes the design is held
to.
flowchart TB
subgraph data["data/example/ — your map"]
TOPO["topology.ts<br/>identity · position · wires"]
STATE["state.ts<br/>status · metrics · capacity"]
INDEX["index.ts<br/>React Flow nodes + edges"]
COUNTS["counts.ts<br/>derived figures"]
end
TYPES["types/system-map.ts<br/>the graph model"]
subgraph render["components/ — the renderer"]
CANVAS["MapCanvas<br/>pan · zoom · keyboard · fit"]
NODES["ten node components<br/>fixed sizes"]
EDGE["FlowEdge<br/>curve · label normal"]
EXPORT["ExportPanel<br/>html-to-image"]
SHELL["AppShell<br/>header · legend · theme"]
end
TYPES -.-> TOPO & STATE
TOPO --> INDEX
STATE --> INDEX
TOPO --> COUNTS
INDEX --> CANVAS
CANVAS --> NODES & EDGE
COUNTS --> SHELL
CANVAS --> EXPORT
EXPORT -->|"PNG · SVG · clipboard"| OUT(["1× · 2× · 3×<br/>dark · light"])
TOPO -.->|"no browser"| GEO["audit:geometry"]
CANVAS -.->|"rendered page"| BROWSER["audit:map · labels · mobile"]
The dependency only points one way: components know the graph model, never a map. That is what lets a second map cost a folder and a route.
There is no runtime configuration. Everything below is read at build time.
| Variable | Default | Used by | Purpose |
|---|---|---|---|
SITE_URL |
http://localhost:3000 |
docker compose build |
Your public URL, passed through as NEXT_PUBLIC_SITE_URL |
NEXT_PUBLIC_SITE_URL |
http://localhost:3000 |
npm run build |
Base for the absolute URLs in link previews |
HOST_PORT |
3000 |
compose.yaml |
Port published on the host |
STATIC_EXPORT |
unset | next.config.ts |
1 writes plain files instead of the standalone server |
NEXT_PUBLIC_BASE_PATH |
unset | next.config.ts |
Serve the app under a sub-path, e.g. /demo |
Link previews use absolute URLs, and those are baked into the pages — so set
SITE_URL (or NEXT_PUBLIC_SITE_URL) before building for your own host:
SITE_URL=https://maps.example.com docker compose up -d --buildflowchart LR
PUSH["push to main"] --> CI["CI<br/>typecheck · geometry · build"]
CI -->|"green"| IMG["Publish Image"]
IMG --> LATEST[("ghcr.io/nichtlegacy/system-map<br/>latest · sha-‹short›")]
PUSH --> RP["release-please<br/>release PR + CHANGELOG"]
RP -->|"PR merged"| TAG["tag vX.Y.Z"]
TAG --> IMG2["Publish Image"]
IMG2 --> VER[("X.Y.Z · X.Y")]
PUSH --> PAGES["Deploy Pages<br/>scripts/build-site.sh"]
PAGES --> SITE["system-map.nichtlegacy.com<br/>site/ + static export under /demo/"]
- Container —
Dockerfilebuilds once on the runner's platform and ships the same standalone output to amd64 and arm64. The runtime stage has nonode_modulesand no npm, runs asnode, and serves on port 3000. - Releases — commits follow
Conventional Commits.
release-please keeps one
release PR open with the next version and
CHANGELOG.md; merging it tags the release and publishes the versioned image. - Project page —
site/is a plain HTML landing page. The Pages workflow puts it at the root and a static export of the app under/demo/. Preview both locally withnpm run site, served on http://localhost:8000.
Every image here is cut from the running app, so it can never show something the app does not render. With the dev server on port 3000:
| Command | Writes |
|---|---|
npm run posters |
public/posters/ (transparent, for the landing page) and .github/images/ (solid, for this README) |
npm run site:images |
The card and export-panel shots in site/screenshots/ |
npm run social |
The 1200 × 630 link preview and touch icons, for site/ and app/ — run posters first |
system-map/
├── app/ # landing page, /example route, icons, link preview
├── components/
│ ├── map/ # canvas, the ten node kinds, FlowEdge, export
│ ├── shell/ # header, legend and edge filter, theme, page frame
│ └── landing/ # hero, spine and reveal pieces of the landing page
├── data/
│ ├── example/ # the fictional map: topology, state, index, counts
│ └── site.ts # project name and repository URL
├── types/system-map.ts # the public graph model
├── docs/design.md # visual and interaction rules
├── public/ # posters and the example's rendered artefact
├── scripts/ # audits, image export, site build
├── site/ # the project page served on GitHub Pages
└── .github/ # CI, image, release and Pages workflows; README images
Not a dashboard, not a diagram editor, not a live monitor. state.ts is shaped
like something a live source could fill, but nothing here fetches it. A map is
drawn once, so the next person can find their way without asking you.
- React Flow — the canvas, the handles and the viewport everything else sits on.
- html-to-image — the PNG and SVG export.
- Geist and Lucide — type and icons.
- The token hierarchy and typography were informed by Vercel's published design guidance.
Dependency licenses are listed in
THIRD_PARTY_NOTICES.md.
MIT. Draw whatever you like with it.




