Cross-cutting overview of how the shumoku monorepo fits together.
Editor-specific data model details live in
apps/editor/docs/design/data-model.md;
link composition (port / plug / module / cable) is in
apps/editor/docs/design/connection-model.md;
this doc focuses on the flows that span packages.
How the input network model is being redesigned is in
network-model.ja.md.
Contents
- Bird’s-eye view
- Drawing (core + renderer)
- Editor state and edits
- Server
- Package boundaries
- Camera (pan/zoom)
- Known gaps
Bird’s-eye view
flowchart LR
subgraph IN[Inputs]
YT[YAML text]
ZIP[.neted.zip project]
UI[Panels and canvas edits]
SRC[Server: resolved sources]
end
subgraph CORE[@shumoku/core]
NM[NetworkModel<br/>config + layers]
DN[drawNetwork<br/>structure, ranks, order,<br/>routes, coordinates]
SC[Scene<br/>boxes, wires, ports, hulls]
end
subgraph RNDR[@shumoku/renderer]
KIT[kit: Diagram<br/>one SVG component]
SR[ShumokuRenderer<br/>selection, drop, overlays]
ST[static: renderScene]
end
YT -->|readNetworkModel| NM
ZIP --> NM
SRC -->|resolveNetwork| NM
UI -->|applyGraphEdits| NM
NM --> DN --> SC
SC --> KIT
KIT --> SR
KIT --> ST
Reading guide:
- The
NetworkModelis the truth. The Scene is only its projection, made fresh bydrawNetworkeach time; no position is ever stored (the old engine keptposition/bounds/ ports / edges as state; they are gone). - Everything discrete (ranks, left-right order, corridors, routes, counts)
is decided from relations before any coordinate; coordinates are solved
once per axis at the end (
libs/@shumoku/core/src/drawing/). - The kit (
@shumoku/renderer/src/kit) draws a Scene. The interactiveShumokuRenderer, the server’s static output (renderScene), the CLI, PNG and HTML exports all draw with that one component, so they cannot differ.
Drawing (core + renderer)
NetworkModel ─ drawNetwork(model, { metrics, hints? }) ─► Scene
deriveStages(config, { boundary }) boxes / groups / wires / ports / hulls
layoutDrawing(config, structure, metrics, layers)
metricsis a function,(model) => Metrics: the lengths the drawing works with. They come from the renderer’s kit (drawingMetrics(kit, model)), derived from a few bases (text size, stroke), never from hardcoded px.drawNetworkScene(model)in@shumoku/renderersupplies the kit’s metrics and is the one-call form to use.boundary(where the network meets what lies beyond it, which up and down are measured from) is decided byboundaryOf(drawing/draw.ts), in this order; an earlier step wins:drawing.settings.boundary. No YAML field or UI sets it today; only code that builds theNetworkModelcan.- The structure’s candidates: nodes that have both a cable and a virtual
link (
structure/orientation.ts). - Nodes of type
externalorcloud, and the routing domains that links reach.type: internetis not among them. - For each connected part of the network that none of 1-3 reaches, a
guessed upstream (
drawing/upstream.ts): by node type (router / firewall / vpn / cpe, then l3-switch / load-balancer, then l2-switch, then access-point), else the node(s) ending the fastest link, else the graph centre. This applies per part, also when other parts have a boundary. A lone node gets nothing.
hintscarry what a person dragged into place:hints.siblings, the order of siblings, keyed by group id (ROOT_FRAME,'', for the top level). The engine honours them (layout.ts).drawNetworkdoes not readdrawing.orderitself: the caller converts it. The editor does (apps/editor/src/lib/context.svelte.ts); the server bake passes none (apps/server/api/src/services/derive-worker.ts), so the server’s drawing ignoresdrawing.order.
Sheets (one page per group, with file / pins) were dropped: nested groups
are drawn on one page. Revisit as a view (a sub-scene cut out of a group)
when large networks need it.
Editor state and edits
The editor keeps the project’s NetworkModel (model in
apps/editor/src/lib/context.svelte.ts) and, beside it, the graph drawn
from it in SvelteMaps (diagram.nodes, diagram.subgraphs, a link
array). The panels edit that graph in place. It holds no layout: no
positions, ports or routed edges.
flowchart LR
M[NetworkModel] -->|toNetworkGraph| G[diagram maps<br/>nodes / subgraphs / links]
G -->|panel edits, commit| G
G -->|applyGraphEdits| M
M -->|currentModel| D[drawNetworkScene]
D --> S[Scene]
S --> R[ShumokuRenderer]
R -->|ondrop| H[readDrop]
H -->|parent + order| M
- A commit snapshots the model for undo, runs the edit, and writes what
changed on the graph back into the model (
applyGraphEdits), so YAML-only fields survive. Undo and redo replace the model and draw the graph again. - The diagram page draws
currentModel()(the model with the edits so far applied) throughdrawNetworkSceneafter a short pause, and hands the Scene toShumokuRenderer. - Drag (edit mode):
ShumokuRendererreports where a node or group was dropped (ondrop).readDrop(state/order-hints.ts) reads two things from it: the group frame the element’s centre landed in (this isconfig.group, edited through the graph) and its place among the siblings of that frame, left to right. The order is stored asdrawing.order({ [groupId | ROOT_FRAME]: id[] }). Coordinates are never stored. “Auto-arrange” drops the hints. - Load: every input ends in
loadProject, which resets state once and applies the project (applyProject: sanitize, product fix-ups, legacy scene migrations), then writes the result back into the model. - The floor-plan Scene mode (
routes/project/[id]/scene, the editor’s ownScenetype) is separate from the drawing’sScene: it places nodes by coordinates on a plan and is unchanged. Its scope is the?focus=group.
Server
The bake (apps/server/api/src/services/derive-worker.ts) resolves the
sources into a model, draws it with drawNetworkScene in a worker, and
stores { scene }; RESOLVER_VERSION must be bumped when the resolve or
drawing algorithm changes. The mapping (metrics bindings, bandwidth
overrides) is not part of the baked artifact: it is read on every request and
put on the graph’s and the scene’s links when served.
Package boundaries
What each package owns, and what it doesn’t:
flowchart TB
subgraph apps[apps/]
ED[editor<br/>SvelteKit UI, state, routes]
DOC[docs<br/>Astro]
CLI[cli<br/>shumoku render]
SRV[server<br/>topology API]
end
subgraph libs[libs/@shumoku/]
COR[core<br/>models, layout, parser]
CAT[catalog<br/>device/service catalog]
SDK[plugin-sdk<br/>HTTP client, pagination]
RND[renderer<br/>Svelte + static SVG]
RSV[renderer-svg<br/>compatibility SVG]
RHT[renderer-html<br/>embeddable]
RPN[renderer-png<br/>resvg]
SHU[shumoku<br/>umbrella]
end
subgraph libp[libs/plugins/]
PAI[aruba-instant-on]
PGF[grafana]
PNB[netbox]
PNS[network-scan]
PPR[prometheus]
PZB[zabbix]
end
ED --> COR
ED --> CAT
ED --> RND
ED --> RSV
CLI --> COR
CLI --> RND
CLI --> RPN
CLI --> RHT
SRV --> COR
SRV --> RHT
SRV --> RND
SRV --> CAT
SRV --> RPN
CAT --> COR
RND --> COR
RSV --> COR
RSV --> RND
RHT --> RSV
RHT --> RND
RPN --> RND
RPN --> RSV
SHU --> COR
SHU --> RSV
SHU --> RHT
PAI --> COR
PGF --> COR
PNB --> COR
PNB --> SDK
PNS --> COR
PNS --> CAT
PPR --> COR
PZB --> COR
PZB --> SDK
Invariants:
- Plugins depend only on
core(plus the sharedplugin-sdkruntime helpers, andcatalogwhere device identification needs it) — never on renderers, never on editor. Keeps them embeddable anywhere. - Renderers depend on
core— never on editor. Core models are the lingua franca. - Editor depends on core + catalog + renderer — its
renderer-svgexport facade delegates to the static renderer. - Server consumes renderers, not just core — the bake draws a
Scene(drawNetwork), exports render it with the kit, and the web UI shows it withShumokuRenderer. - CLI SVG, PNG, and standalone HTML share one drawing —
drawNetworkmakes the scene,@shumoku/renderer/staticrenders it with the kit, andrenderer-htmladds the tooltip and pan/zoom runtime shell around the same SVG. - Apps don’t cross-depend — editor doesn’t import from docs, etc.
The canonical data shape is NetworkModel (the configuration as the YAML
writes it, plus the observation / design / drawing layers). NetworkGraph is a
display projection (toNetworkGraph) that the panels edit and the drawing
reads its nodes and links from; it is not an input format.
Plugin contract
Data-source plugins (Zabbix, NetBox, Prometheus, Grafana, Aruba
Instant On, network-scan) connect shumoku to external systems through a small
contract in @shumoku/core. The rule is core defines the display
contract; plugins conform. Concretely:
- Core types describe what the UI consumes —
Host,Alert,LinkMetrics, etc. — and contain no plugin-name enums. - Plugins translate upstream vocabularies (Zabbix priorities, Prometheus severities, Aruba health tokens) into core vocab at their own boundary — never the other way.
- The web app renders plugins generically via
configSchema; it must not branch onplugin.type. (#270 is resolved — the invariant is now enforced by a vitest guard,apps/server/api/src/plugins/host-branch-guard.test.ts, which fails the build if atype === '<plugin>'branch reappears in the config surfaces.)
For the full author-facing reference — capability mixins, data
shapes, severity translation table, the three node-state axes, the
passthrough discoverMetrics pattern, dev-only nativeApi — see
plugin-authoring.md.
Camera (pan/zoom)
Camera behaviour — how wheel events, trackpad gestures, pinch and
pointer drags translate into viewport transforms — is deliberately
not baked into @shumoku/renderer. Different apps want different
policies (the editor wants mouse-wheel-zoom like a CAD tool; a static
share preview wants no camera at all; SSR/CLI has no DOM to attach
to), and a renderer that picks a default for everyone ends up either
wrong-by-default or stuffed with opt-out props.
flowchart LR
subgraph RND[@shumoku/renderer]
SVG[SVG output<br/>stable DOM:<br/>g.viewport, g.node[data-id], path.link]
AC[attachCamera<br/>utility<br/>opt-in]
end
subgraph WG[wheel-gestures]
START[isStart marker]
MOM[isMomentum marker]
end
subgraph D3[d3-zoom]
ST[__zoom state]
TRF[transform on g.viewport]
end
subgraph APP[Host app]
EDT[editor]
WID[dashboard widget]
DET[detail page]
SHR[share page]
end
SVG -.- AC
AC --> WG
AC --> D3
D3 --> TRF
WG --> AC
EDT --> AC
WID --> AC
DET --> AC
SHR --> AC
API shape
import { attachCamera } from '@shumoku/renderer'
const camera = attachCamera(svgEl, {
scaleExtent: [0.2, 10], // zoom bounds
panFilter: (e) => e.altKey, // which pointer-down events pan
wheelZoomSensitivity: 1.0015, // mouse tick feel
pinchZoomSensitivity: 1.01, // trackpad pinch feel
})
camera.zoomBy(1.5)
camera.panToNode('device-42')
camera.reset()
camera.detach() // cleanup on unmount
The renderer always emits a <g class="viewport"> as its zoom target;
attachCamera throws if that element isn’t present. Apps that want
no camera simply don’t call attachCamera.
UX policy (Figma / Miro style)
| Input | Result |
|---|---|
| Mouse wheel (plain) | zoom at cursor |
| Mouse ctrl+wheel | zoom at cursor (explicit) |
| Trackpad two-finger | pan (with natural momentum) |
| Trackpad pinch | zoom at cursor (browser synthesises ctrlKey=true) |
| Middle-click drag / Alt+left-drag | pan (via panFilter) |
| Node / group drag (edit mode) | reported as a drop; becomes order hints (handled in ShumokuRenderer) |
Why we need three layers (d3-zoom + wheel-gestures + sticky detection)
Each layer solves a problem the others can’t:
-
d3-zoom owns the transform state (
svg.__zoom). It’s a stable base: attaching to the svg once and routing all transform changes throughzoomBehaviorkeeps state consistent regardless of whether a change came from a wheel event, imperativezoomBy, or externalpanToNodecall. A previous version bypassed d3-zoom by writingtransform=directly on the viewport — d3-zoom’s state went stale and the next gesture jumped to wherever d3-zoom last remembered. -
wheel-gestures classifies each event as “user input”, “momentum tail”, or “gesture start”. We use two specific signals:
state.isStart— the first event of a gesture. That’s when we decide “mouse or trackpad” and stick with the verdict. Per-event classification doesn’t work because Chrome’s smooth-scrolling makes mouse wheel ticks indistinguishable from trackpad scrolls frame-by-frame (both can emit fractional deltaY with varying magnitudes).state.isMomentum— OS-generated inertia events that continue after the user’s fingers have lifted. Critically, these often dropctrlKeypartway through a pinch’s decay. Skipping momentum for zoom prevents phantom pans after a pinch; keeping it for pan preserves the natural trackpad feel.
-
Sticky device detection (inside
attachCamera) runs atisStartonly, usingdeltaMode, presence ofdeltaX, and magnitude ofdeltaYas signals. Once picked —mouseortrackpad— the whole gesture uses that mode, so mid-flight event ambiguity can’t flip the verdict.
Coordinate-system discipline
The renderer’s SVG uses a viewBox sized to the layout bounds with
width="100%" — so one viewBox user-space unit is NOT one CSS pixel.
d3-zoom’s transform is applied as an SVG transform= attribute on
the viewport group, which is interpreted in user-space. The wheel
event’s clientX/Y is in screen pixels. Passing the cursor to
d3-zoom directly drifts the zoom focus by the viewBox scale factor.
attachCamera converts cursor to user-space via
svg.getScreenCTM().inverse() and declares zoom.extent in user-space
too, so d3-zoom’s internal math and the applied transform agree.
Consumer summary
- editor —
attachCamera(svg)with defaults (Miro/Figma UX). - docs/editor — same, attached after the WebComponent’s
customElements.whenDefined('shumoku-renderer')resolves. - server/web TopologyViewer — takes
camera?: CameraOptions | falseas a prop; passes through toattachCamera. Widget, detail page and share page all use this. - CLI / PNG / SSR — don’t mount a Svelte component, so never see
attachCamera. d3-zoom isn’t loaded at all in those paths.
Known gaps
Hierarchical / multi-sheet editing
Sheets are removed: the drawing engine has no sheet or pin concept, so
nested groups are drawn together on one page and the viewer, the HTML export,
the server and the editor no longer navigate between sheets. Revisit as a view (a
sub-scene cut out of a group) when large networks need it. The order hints a
drag leaves (drawing.order) are honoured only where the caller passes them
as hints.siblings (the editor does; the server bake does not).