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
- Load pipeline (editor)
- Layout engine (core)
- Runtime state and mutations
- Placement APIs — when to use which
- End-to-end use cases
- Package boundaries
- Camera (pan/zoom)
- Known gaps
Bird’s-eye view
flowchart LR
subgraph IN[External inputs]
YT[YAML text]
JF[.neted.json file]
CLK[User click / paste]
DRG[User drag]
BTN[SideToolbar buttons]
end
subgraph EDITOR[@shumoku/editor]
LP[Load pipeline]
RT[Runtime state]
UI[Diagram UI]
end
subgraph CORE[@shumoku/core]
LN[computeNetworkLayout<br/>tiered / composite]
PN[placeNode<br/>collision]
PP[placePorts]
RE[routeEdges<br/>port-anchored edges]
end
subgraph RNDR[@shumoku/renderer]
SR[ShumokuRenderer<br/>SVG]
end
YT --> LP
JF --> LP
LP --> RT
RT --> SR
SR --> UI
CLK --> PN
DRG --> SR
BTN --> LN
PN --> RT
LN --> RT
PP --> RT
RE --> RT
RT -->|export| JF
Reading guide:
- User input flows in from the left (YAML text, JSON file, direct UI interactions).
- The editor’s load pipeline converts external input into runtime state; runtime state is also the sink for every interactive edit.
- Core exposes the pure-function primitives (layout, placement, port placement, edge derivation) that the editor calls into. Drawn edge geometry is cubic Béziers computed from port positions in the renderer — there is no routing solver.
- The renderer reads runtime state via
$bindableand emits events back when the user drags or clicks.
Load pipeline (editor)
Every route to runtime state is a linear pipeline — conversion on each
step, loadProject as the single terminal that resets state and
applies project data.
flowchart TD
subgraph EXT[External inputs]
YT[YAML text]
JSTR[JSON string]
JOBJ[NetedProject object]
SPF[sampleProject const]
end
YT -->|readNetworkModel| NM1[NetworkModel]
NM1 --> IMD[importDiagram]
IMD -->|wrap as NetedProject| IPJ[importProject]
JSTR -->|JSON.parse| JOBJ
JOBJ --> IPJ
IPJ -->|loadProject 'imported' data| LP
SPF -->|loadProject 'sample'| LP
LP[loadProject<br/>TERMINAL]
LP -->|1 reset state| RST[reset everything<br/>maps / arrays / status / initialized]
LP -->|2 apply| APP[applyProject]
LP -->|3 status 'Ready'| READY((Ready))
APP -->|toNetworkGraph model| APG[applyGraph]
APP --> SPB[sanitizePaletteAndBom]
APG --> SG[sanitizeGraph<br/>drop orphan refs + dups]
SG --> BR{any node<br/>unpositioned?}
BR -->|yes YAML case| FULL[computeNetworkLayout<br/>full auto-layout pass]
BR -->|no all positioned| PPS[placePorts]
FULL --> STT
PPS --> REE[rerouteEdges<br/>rebuild port-anchored edges]
REE --> STT
SPB --> STT[Runtime state<br/>model + the graph drawn from it<br/>nodes / subgraphs / links / ports / edges / palette / bom]
STT -->|commit: applyGraphEdits| STT
Key properties:
- One entry point per input shape, never multiple (
importDiagramis the only YAML entry;importProjectthe only project entry). The code panel edits the open project’s YAML in place withreplaceModel. - The network model is the project’s truth. The canvas and panels edit
the graph drawn from it, and each commit writes what changed back into
the model with
applyGraphEdits, so YAML-only fields survive editing. - State reset happens exactly once per load, inside
loadProject. - Any fix to load-time derivation (port placement, edge routing,
bounds) lands in
applyGraphand benefits every path.
Layout engine (core)
computeNetworkLayout() (libs/@shumoku/core/src/layout/unified-engine.ts)
is the single entry point. It dispatches between the flat-tree /
compound layouts and the composite zone layout, all built on the
spatial-rule engine in layout/engine/ (LayoutRules for sizing /
separation / framing, PlacementPolicy for tryPlace / snapTo,
injectable TextMeasurer). It returns both a ResolvedLayout and a
legacy LayoutResult.
flowchart TD
IN[NetworkGraph input] --> CNL[computeNetworkLayout]
CNL --> DISP{composite?<br/>shouldUseComposite:<br/>broad zone metadata}
DISP -->|yes — discovered networks| SCL[searchCompositeLayout<br/>layout/composite/]
SCL --> LPI[buildLayoutProblem<br/>LayoutProblem IR<br/>role-tiers TierHint]
LPI --> PLC[layered quotient placement<br/>zones from location metadata]
PLC --> OCT[octilinear edge routing<br/>place-and-route search:<br/>routed-geometry score arbitrates]
OCT --> RES
DISP -->|no — hand-drawn / flat| ENG[createEngine<br/>layout/engine/ spatial rules]
ENG --> FT{opts.compound?}
FT -->|yes| LC[layoutCompound<br/>fold subgraphs into boxes]
FT -->|no| ALF[autoLayoutFlatTree]
subgraph FLAT[Flat-tree pipeline]
direction TB
S1["1. decidePortSides<br/>direction-aware + tier flips"]
S2["2. node footprints via engine<br/>TextMeasurer for port labels"]
S3["3. layoutFlatTree<br/>tidy-tree placement"]
S4["4. placePorts"]
S5["5. honour fixed / hints options"]
S1 --> S2 --> S3 --> S4 --> S5
end
ALF --> FLAT
LC --> FLAT
FLAT --> RTE[routeEdges<br/>2-point port-anchored ResolvedEdge<br/>+ lane offsets + bus routing]
RTE --> RES[assertLayoutConstraints<br/>→ ResolvedLayout + LayoutResult]
Highlights:
- No routing solver — the previous libavoid-js WASM router was
removed (PR #227).
routeEdgesis a trivial pass that attaches port-anchoredResolvedEdgerecords with 2-point polylines; the renderer draws cubic Béziers from the port positions and sides. The points remain only for non-rendering consumers (label midpoint, hit testing, cable length). Post-processes fan out edges sharing a port (lane offsets) and merge same-layer fans into T-shaped buses. - Composite auto-select —
shouldUseCompositeenables the composite zone layout for graphs with broad zone (location) metadata, i.e. discovered networks. It builds aLayoutProblemIR (layout/problem.ts, PR #533) with role-tier hints (layout/role-tiers.ts, sparse 0–100 device-role tiers), places layered zone bands, then runs a place-and-route search where routed octilinear geometry scores the placement variants. - Engine as policy authority — sizing, gaps, and label widths come
from one
createEngine()instance shared conceptually with manual placement (engine.tryPlace), so auto-layout and drag-snap stay consistent. - Constraint assertion —
assertLayoutConstraints(#482) throws on BLOCKING violations in dev/test and logs in production, so a broken figure never ships silently.
Runtime state and mutations
The editor’s diagram state is intentionally reactive-friendly. Nodes,
subgraphs, ports, and edges live in SvelteMaps inside a single
$state object; ports and edges are treated as derived and rebuilt
via rerouteEdges.
flowchart LR
subgraph STATE["Runtime state (context.svelte.ts / diagram object)"]
direction TB
N[SvelteMap<id, Node>]
SG[SvelteMap<id, Subgraph>]
L[Link array]
P[SvelteMap<id, ResolvedPort><br/>DERIVED]
E[SvelteMap<id, ResolvedEdge><br/>DERIVED]
B[bounds]
end
subgraph AUX["Separate $state"]
PAL[palette array]
BOM[bomItems array]
POE[poeBudgets<br/>$derived]
end
subgraph MUT[Mutation API]
direction TB
AL[addLink]
UL[updateLink]
RL[removeLink]
UN[updateNode]
US[updateSubgraph]
MG[moveNodeToGroup]
UB[unbindNodes]
RB[removeBomItem]
AA[autoArrange]
end
subgraph RE[Re-derivation]
REJ[rerouteEdges<br/>async, rebuilds port-anchored edges]
end
subgraph RND[Rendering]
SR[ShumokuRenderer SVG]
end
AL --> L
UL --> L
RL --> L
UN --> N
US --> SG
MG --> N
MG --> REJ
UB --> N
RB --> BOM
RB --> N
AA -->|computeNetworkLayout| N
AA --> SG
AA --> P
AA --> E
AA --> B
AL --> REJ
UL --> REJ
RL --> REJ
REJ --> E
N <-->|$bindable| SR
SG <-->|$bindable| SR
P <-->|$bindable| SR
E <-->|$bindable| SR
B <-->|$bindable| SR
N --> POE
L --> POE
BOM --> POE
PAL --> POE
Notes:
SvelteMap.set()/.delete()trigger Svelte 5 reactivity directly — no copy-on-write needed.- Ports and edges are “derived” conceptually; operationally they’re
rebuilt by explicit calls. A future PR could move this to
$effectonce the drag-path’s atomicity concerns are resolved. ResolvedPortremains node-owned, whileResolvedEdgecarriesfromPort/toPortendpoint references. This keeps Port ownership canonical on the node side, but lets link rendering and link-level overlays consume the resolved endpoint ports without reverse-looking them up from the ports map.$bindableon the renderer is bidirectional: the canvas writes back directly when the user drags or creates a link.
Placement APIs — when to use which
Two primitives, different intents, deliberately kept separate:
flowchart TD
NEED{What do you need?}
NEED -->|place one node at a specific point| PN_CASE
NEED -->|re-layout the whole diagram| LN_AUTO
NEED -->|re-layout but keep some nodes pinned| LN_FIXED
NEED -->|nudge some nodes toward specific x| LN_HINTS
subgraph PN_CASE[Geometric]
PN[placeNode node, graph, initial, gap]
PN --> PNR[Returns collision-free position<br/>near initial, ignores link flow]
end
subgraph LN_AUTO[Structural — auto-arrange]
LN1[computeNetworkLayout graph]
LN1 --> LN1R[Full auto-layout pass,<br/>all positions recomputed]
end
subgraph LN_FIXED[Structural — partial]
LN2[autoLayoutFlatTree graph, engine,<br/>opts.fixed Set]
LN2 --> LN2R[Layout + post-process snap<br/>hard pin listed nodes]
end
subgraph LN_HINTS[Structural — guided]
LN3[autoLayoutFlatTree graph, engine,<br/>opts.hints Map]
LN3 --> LN3R[Layout with preferred x<br/>soft nudge, packing wins on overlap]
end
subgraph USE_PN[Typical callers of placeNode]
ANN[ShumokuRenderer.addNewNode<br/>SideToolbar Add button]
PST[context menu Paste]
PNB[placeNodeForBom<br/>BOM → diagram]
end
subgraph USE_LN[Typical callers of computeNetworkLayout]
AUA[autoArrange<br/>SideToolbar button]
YML[YAML import fallback]
ASL[Future: arrange-selection]
end
USE_PN --> PN
USE_LN --> LN1
Rule of thumb:
- Does the user know exactly where they want the node? →
placeNode. - Do you want the algorithm to decide based on graph topology? →
computeNetworkLayout. - Somewhere in between? The lower-level
autoLayoutFlatTreeacceptsfixed(hard pin) andhints(soft x-nudge) options; note thatcomputeNetworkLayoutdoes not currently plumb these through — its options are{ compound?, composite? }.
End-to-end use cases
Add node via SideToolbar
sequenceDiagram
actor User
participant STB as SideToolbar
participant Page as diagram/+page.svelte
participant SR as ShumokuRenderer
participant DS as diagramState
participant Core as @shumoku/core
User->>STB: click "Add Router"
STB->>Page: onaddnode({kind:'hardware', type:'router'})
Page->>SR: addNewNode({id:newId('node'), spec})
SR->>Core: placeNode(node, graph, initial, gap)
Core-->>SR: { x, y }
SR->>DS: diagram.nodes.set(id, {...node, position})
DS-->>SR: reactive update
SR-->>User: node appears on canvas
SR->>Page: onnodeadd(id)
Page->>DS: addBomItem({id:newId('bom'), nodeId:id})
Drag node
sequenceDiagram
actor User
participant SR as ShumokuRenderer
participant Core as @shumoku/core
participant DS as diagramState
User->>SR: drag node
SR->>Core: moveNode(id, x, y, {nodes, ports, subgraphs}, links)
Core->>Core: resolve collisions, shift ports, rebalance, routeEdges
Core-->>SR: {nodes, ports, edges, subgraphs}
SR->>DS: replaceMap nodes/ports/edges/subgraphs
DS-->>SR: $bindable update
SR-->>User: diagram re-renders
Save to JSON
sequenceDiagram
actor User
participant Menu as ExportMenu
participant DS as diagramState
participant Blob as BrowserBlob/Download
User->>Menu: click "Export JSON"
Menu->>DS: exportProject('diagram-name')
DS->>DS: exportGraph() → NetworkGraph
DS->>DS: wrap w/ palette + bom → NetedProject
DS-->>Menu: JSON string
Menu->>Blob: URL.createObjectURL + download
Blob-->>User: .neted.json file
Import JSON
sequenceDiagram
actor User
participant Top as Top page
participant DS as diagramState
participant Route as route layout
User->>Top: drop .neted.json
Top->>DS: importProject(jsonString)
DS->>DS: JSON.parse
DS->>DS: loadProject('imported', data)
DS->>DS: reset all state
DS->>DS: applyProject(data)
DS->>DS: applyGraph + sanitizePaletteAndBom
DS-->>Top: state populated, status='Ready'
Top->>Route: goto('/project/imported/diagram')
Route->>DS: loadProject('imported')<br/>sees initialized=true, skips
Auto-arrange
sequenceDiagram
actor User
participant STB as SideToolbar
participant Page as diagram/+page.svelte
participant DS as diagramState
participant Core as @shumoku/core
User->>STB: click Auto-arrange
STB->>Page: onautoarrange()
Page->>DS: autoArrange()
DS->>DS: exportGraph() and strip all positions
DS->>Core: computeNetworkLayout(strippedGraph)
Core->>Core: autoLayoutFlatTree (or composite search)
Core->>Core: placePorts + routeEdges
Core-->>DS: { nodes, ports, edges, subgraphs, bounds }
DS->>DS: replaceMap everything
DS-->>User: diagram re-rendered with fresh layout
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/>Next.js, playground]
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
DOC --> COR
DOC --> RSV
DOC --> RHT
CLI --> COR
CLI --> RND
CLI --> RPN
CLI --> RHT
SRV --> COR
SRV --> RSV
SRV --> RHT
SRV --> RND
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 now delegates to the canonical static renderer. - Server consumes renderers, not just core — API static export and
the web UI use the canonical
renderer; legacy icon preparation still uses the compatibility renderer. - CLI SVG, PNG, and standalone HTML share the canonical static renderer —
renderer-htmladds the navigation, tooltip, and pan/zoom runtime shell without regenerating resolved SVG geometry. - Playground and public SVG pipeline functions use the same path — the
old
SVGRenderer/ synchronoussvg.rendersurface is isolated as a deprecatedLayoutResultcompatibility layer. - Apps don’t cross-depend — editor doesn’t import from docs, etc.
The canonical data shape at every boundary is NetworkGraph
(core’s type). YAML and the project JSON (NetedProject, which wraps
NetworkGraph) are boundary formats; everything inside the system
speaks NetworkGraph.
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 drag (edit mode) | move node (handled per-element in SvgNode) |
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
@shumoku/core ships a buildHierarchicalSheets() function that
generates one sheet per top-level subgraph with boundary pins for
cross-sheet links. @shumoku/renderer-html uses this for static
multi-page export with click-through navigation.
The editor does not yet drive the interactive flavour. The Svelte
renderer (@shumoku/renderer) has no concept of “active sheet”; it
always renders the whole graph. The editor’s runtime state now tracks
currentSheetId and the SheetBar lets users switch between the
root and each top-level subgraph, but the renderer keeps showing the
full graph regardless.
Landed:
diagramState.currentSheetId: string | nulldiagramState.availableSheets— root + top-level subgraphsdiagramState.switchSheet(id | null)— runsbuildHierarchicalSheetsfor non-null ids, populatessheetViewdiagramState.activeView— root state orsheetView, bound by the editor’s diagram pageSheetBarrenders the real tabs, click drills KiCad-style
When drilled in, the renderer sees the subgraph’s filtered graph
with export-connector nodes for cross-boundary links. The editor
forces View mode while drilled in so the renderer’s $bindable
writes don’t land on the ephemeral sheet maps — edits stay on the
root sheet only for now.
Not yet landed:
- Write-through editing on a sub-sheet. Requires the renderer to
stop relying on
$bindablewrites and become a pure view that emits events, which the editor then routes to the canonical root state (tracked under #98 “operations separation”). Until then, edits happen on the root sheet; sub-sheets are read-only. - Nested drill-down. Clicking into a subgraph that is itself
inside a sub-sheet would require another level of
buildHierarchicalSheetsor equivalent. Top-level only for now. - Sheet-specific autoarrange / add-node defaults / sheet-local positions. Polish once write-through lands.
Why this wasn’t finished earlier: the editor’s MVP was a single
diagram, SheetBar was a UI placeholder during early iteration, and
subsequent refactors (#130-#142) focused on the single-diagram happy
path. No principled decision to defer — it was plain technical debt,
now partly paid down.