An interactive map of an agent operating system — Memory, Applications, Routines, Skills — rendered to a single self-contained HTML file.
No CDN. No build step. No runtime dependencies. Python in, one .html out, and it
works from file:// with the network off.
python demo.py --open # sample data, runs anywhere
python demo.py --live # map your own Claude Code installation| Layout | Shows | How it works |
|---|---|---|
| Galaxy | Organic clusters | Force simulation: repulsion, weighted springs, centre gravity |
| Orbit | Groups side by side | Domains on a circle, children fanned into arcs |
| Sector Grid | Structured topology | Domains claim hex territories, children pack into free cells |
| Solar System | Hierarchy | Concentric bands, first domain nearest the core |
Galaxy runs the simulation. The other three compute target positions that the same integrator eases toward, so switching layouts moves the nodes already on screen rather than rebuilding the scene — the canvas never goes blank between modes.
The morph timing was measured rather than guessed. Error decays as (1-k)^n; at
k=0.075 the layout is within 5% of its target after ~38 frames, about 640 ms. The
first attempt used k=0.12, settled in 339 ms, and read as a snap rather than a move.
That the four layouts are genuinely different is verified rather than assumed: a radial histogram of drawn pixels separates the closest pair by 12× the frame-to-frame animation noise. Comparing raw canvas hashes would not prove it — the ambient particles guarantee any two frames differ.
A map that shows nine of seventy connectors, silently, is stating something false about how much there is. So caps are drawn:
g.mark("applications", shown=9, total=70) # renders "9/70" under the domain label
g.mark("routines", shown=2, total=2) # renders no badge — nothing is hiddenA domain that hides nothing spends no pixels saying so. This is the one behaviour the test suite guards hardest, because the bug it prevents is invisible: the map looks perfectly reasonable while being wrong.
The same principle killed a subtler bug in the Claude Code adapter. Connectors were
counted by each .mcp.json's parent directory — but in the versioned cache layout
the parent is the version, so the map drew nodes labelled 0.1.10 and 0.0.4 and
counted one plugin once per cached version. It had looked fine for weeks. Writing an
honest denominator is what forced the question of what the numerator counted.
test_connectors_are_not_version_strings now rejects any connector whose name parses
as a version.
Pointer — drag a node, drag empty space to pan, wheel to zoom, click to inspect, double-click empty space to fit.
Keyboard — the canvas is focusable and describes its own keys. Arrows walk to the
nearest node inside a 60° cone; Tab roves in order but leaves the ends open so it can
still escape the canvas; Enter opens details; Escape clears; + - 0 drive the
camera. Every move is announced through a polite live region.
Search — matches keep their labels at any zoom and take a highlight ring; Enter flies the camera to the first hit.
Filters — one chip per domain. A hidden domain fades rather than vanishing, and becomes unpickable, untabbable and unsearchable. If it held the selection or the keyboard cursor, those are dropped instead of left pointing at something invisible.
Observatory — one button takes the whole component fullscreen, controls
included; fullscreening the canvas alone would strand search, filters and pause and
make the mode a dead end. The exit is the same button that entered and stays on
screen throughout; Escape and F11 also work. The camera is saved on the way in and
restored exactly on the way out. Where the Fullscreen API is refused — an iframe
without allow="fullscreen" — it falls back to a fixed overlay rather than leaving
the button inert.
Motion — prefers-reduced-motion drops the particles, the pulse rings and the
morph easing. There is a pause control, and a hidden tab freezes the simulation on
its own. Verified frozen: three consecutive frames hash identical.
from mars import Graph, render_page
g = Graph(hub="CORE")
g.domain("memory", "MEMORY", "#b88cff")
g.leaf("memory", "m:notes", "notes", "#b88cff", meta="182 entries")
g.mark("memory", 1, 12)
open("map.html", "w", encoding="utf-8").write(
render_page(g.payload(), title="My OS", size=760)
)render_map(graph) returns just the fragment (markup + inline script) if you are
embedding into a page you already own; pair it with mars.CSS. Domains are whatever
you pass — the hex anchors and ring radii are derived from how many there are, so the
four MARS domains are a default, not a constraint.
from mars.sources import claude_code
graph = claude_code() # defaults to ~/.claudeScans skills, slash commands, per-project memory stores, and installed MCP
connectors. Routines are passed in by the caller rather than discovered — scheduled
work lives in the OS scheduler, not the installation, and inventing entries for it
would be exactly the decoration this map refuses. With none supplied the domain
honestly renders as none found.
prettify(name, rename=...) controls how encoded project folders are labelled.
At a few dozen nodes the whole simulation is a few dozen lines of integrator. Pulling
in D3 or Cytoscape would cost the offline guarantee that makes this useful as a local
dashboard — and a CDN <script> on a page that lists your own systems is a trade
worth refusing.
python -m pytest -q # 37 testsThey cover the payload shape, the rendered markup, and the behaviours pinned inside
the script: labels escaped before reaching innerHTML, reduced motion honoured,
motion frozen when paused or hidden, filtered nodes unpickable, and the closed
inspector hidden rather than parked over whatever is in the next column.
That last one is a real bug this suite now prevents. .mapwrap cannot use
overflow:hidden, because the tooltip is meant to float above the top row of nodes —
so the closed drawer sat opaque over the neighbouring column, still showing the last
node inspected. Visibility does the hiding instead, delayed until the slide finishes.
Built alongside a local agent command centre. A note for anyone testing canvas work
headlessly: in headless Chrome, getComputedStyle and getBoundingClientRect
returned stale values and focus events never fired at all. The drawer's open and
closed states had to be confirmed from screenshots, not from the DOM.
MIT.
