# DiagramModel

Import it from `@grafloria/engine`.

Also has every member of `DiagramEntity`, listed on its own entry.

```ts
class DiagramModel extends DiagramEntity
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | `'Untitled Diagram'` |  |
| `nodes` | `Map<string, NodeModel>` |  |  |
| `links` | `Map<string, LinkModel>` |  |  |
| `groups` | `Map<string, GroupModel>` |  |  |
| `strokes` | `Map<string, StrokeModel>` |  | Freehand ink strokes. See StrokeModel for why these are not nodes. |
| `linkIntegrityOwner` | `'model' \| 'external'` | `'model'` | Who owns the invariant "a link whose node is gone is not a link"? |
| `viewport` |  |  |  |

**Methods**

- `get comments(): CommentRegisterTree` — The comment register tree. Read-only in spirit: write through writeCommentRegister. The landing pad for `applyOp`'s whole-tree write (remote ops, replay, load).

Coarse ON PURPOSE at this level: applyOp hands us the rebuilt tree, and all we owe
the world is "the comments changed". The FINE-grained registers — the thing that
decides whether two concurrent replies both survive — are cut in the OPS, not here
(see writeCommentRegister and comments/types.ts).
- `set comments(next: CommentRegisterTree)` — The comment register tree. Read-only in spirit: write through writeCommentRegister. The landing pad for `applyOp`'s whole-tree write (remote ops, replay, load).

Coarse ON PURPOSE at this level: applyOp hands us the rebuilt tree, and all we owe
the world is "the comments changed". The FINE-grained registers — the thing that
decides whether two concurrent replies both survive — are cut in the OPS, not here
(see writeCommentRegister and comments/types.ts).
- `writeCommentRegister(path: string, value: unknown): boolean` — Write ONE comment register — `t1.status`, `t1.messages.m3` — and nothing else.

This is the LOCAL authoring path, and the granularity here becomes the granularity
of the emitted op (OpCapture turns `trackChange('comments.t1.messages.m3')` into
`set(diagram, path='comments.t1.messages.m3')`), which becomes the granularity of
the LWW register, which is what decides whether your colleague's simultaneous reply
survives. The dotted path is not a convenience. It is the concurrency semantics.

Returns false when the write changes nothing — so a redundant resolve is not an op,
not a broadcast, and not a frame.
- `readCommentRegister(path: string): unknown` — Read one register. Returns undefined for any missing segment — never throws.
- `constructor( name?: string, options?: { lodConfig?: LODConfig; id?: string; uuid?: string } )`
- `refreshLinkBounds(link: LinkModel): void` — Re-index a link after its geometry changed WITHOUT a `change:points` event.

Renderers route links per frame and assign `link.points` directly (using
`setPoints()` would emit `change` → `link:changed` → another render, i.e. a
render loop). The spatial index therefore never hears about the routed path,
and its grid cells would keep pointing at the geometry the link had when it
was added. Call this after writing points directly.
- `isReadonly(): boolean` — Is this document locked against edits?
- `setReadonly(value: boolean): void` — Lock / unlock the document. Normally driven by `DiagramEngine.setMode()` —
VIEW and PRESENTATION lock, DESIGNER unlocks — so `DiagramMode` finally means
something. Can also be set directly for a host that has no mode concept.
- `blocksDocumentWrite(): boolean` — True when a document mutation must be refused right now.
- `inSystemWrite(): boolean` — True while a SYSTEM write is in flight. Read by the PER-NODE geometry lock
(`NodeState.locked`) so it exempts measured writes exactly as the document
lock does — see readonly-lock.ts.
- `runSystemWrite<T>(fn: () => T): T` — Run a SYSTEM write — a derived/measured value (auto-size, portal placement)
the engine needs in order to render the document as it already is. Permitted
even while locked. NOT reachable from user input; see readonly-lock.ts.
- `addNode(node: NodeModel): void`
- `removeNode(nodeId: string): NodeModel | undefined` — Remove node from diagram — AND every link attached to it.

It matters because EVERY removal path funnels through here:
  - `deleteSelected()` — what the Delete key calls;
  - `applyNodes()` — what `setNodes()` calls, so dropping a node from a React-shaped
    spec left the dangling links behind;
  - `RemoveNodeCommand`.

A link's endpoints are the invariant that makes it a link; a link to nowhere is not
a link. So the cascade belongs at the choke point, not at each of the three call
sites that would each have to remember it.

UNLESS someone better owns that invariant — see {@link linkIntegrityOwner}.
- `getLinksForNode(nodeId: string): LinkModel[]` — Every link with an endpoint on `nodeId` (either end, including a self-loop).

Resolved through the port index rather than the cached `sourceNodeId`/`targetNodeId`,
because those are a backfill that can be absent on a link built by hand.
- `restoreNode(data: any): NodeModel | undefined` — Restore node from serialized data
- `getNode(nodeId: string): NodeModel | undefined` — Get node by ID
- `getNodes(): NodeModel[]` — Get all nodes
- `getNodeByPortId(portId: string): NodeModel | undefined` — Get node that owns a specific port
Used for connection group validation and other port-based queries. O(1) via the portIndex (was an O(nodes×ports) linear scan).
- `getPortById(portId: string): PortModel | undefined` — Get the port model for a port id, O(1) via the portIndex. Companion to getNodeByPortId for callers that need the port itself.
- `getDetachedAnchor(nodeId: string): DetachedParentAnchor | undefined` — The last-known anchor of a REMOVED node, or undefined if the id is
live, was never here, or the anchor was wholesale-cleared. The tolerant readers in
NodeModel (getWorldPosition / getGlobalPosition / getGlobalTransformMatrix /
setGlobalPosition) resolve an unresolvable parent through this so orphaned relative
children freeze in place instead of jumping to their raw offsets. See
{@link detachedAnchors} and the design argument on {@link removeNode}.
- `clearNodes(): void` — Clear all nodes
- `addLink(link: LinkModel): void` — Add link to diagram
- `removeLink(linkId: string): LinkModel | undefined` — Remove link from diagram
- `restoreLink(data: any): LinkModel | undefined` — Restore link from serialized data
- `getLink(linkId: string): LinkModel | undefined` — Get link by ID
- `getLinks(): LinkModel[]` — Get all links
- `getLinksForPort(portId: string): LinkModel[]` — Get all links connected to a specific port
- `clearLinks(): void` — Clear all links
- `createSmartLink( sourceNode: NodeModel, targetNode: NodeModel, pathType: 'direct' | 'orthogonal' | 'smooth' | 'bezier' = 'smooth' ): LinkModel | undefined` — Create a smart link with automatic port selection

This high-level API simplifies link creation by:
- Automatically selecting optimal ports based on node geometry
- Creating the link with proper port connections
- Registering connections in port models
- Generating the initial path
- `connectNodes( sourceNode: NodeModel, targetNode: NodeModel, pathType: 'direct' | 'orthogonal' | 'smooth' | 'bezier' = 'smooth' ): boolean` — High-level API to connect two nodes

Convenience method that creates a smart link and returns success status. This is the simplest way to connect nodes.
- `getNodeConnections(node: NodeModel): { incoming: LinkModel[]; outgoing: LinkModel[]; all: LinkModel[]; }` — Get all connections for a node

Returns all links where the node is either source or target. Useful for querying node connectivity.
- `disconnectNodes(sourceNode: NodeModel, targetNode: NodeModel): number` — Disconnect two nodes

Removes all links between the specified nodes. Handles cleanup of port connections.
- `addGroup(group: GroupModel): void` — Add group
- `removeGroup(groupId: string): GroupModel | undefined` — Remove group
- `restoreGroup(data: any): GroupModel | undefined` — Restore group from serialized data
- `getGroup(groupId: string): GroupModel | undefined` — Get group by ID
- `getGroups(): GroupModel[]` — Get all groups
- `getGroupsInRenderOrder(): GroupModel[]` — Groups in deterministic back-to-front stacking order —
ascending `zIndex`, ties broken by Map insertion order (a STABLE sort keeps
it). This is the model-level z-order story that replaces "stacking == Map
insertion order" as the only determinant; a renderer paints groups in this
order (behind their members) instead of relying on iteration order.
- `getProxyNodeForGroup(groupId: string): NodeModel | undefined` — The placeholder "group-as-node" for a collapsed group, if
present. Placeholder nodes are ordinary NodeModels tagged with the group id
so callers can filter them out of exports / counts.
- `isProxyNode(node: NodeModel): boolean` — Is this node a collapsed-group placeholder?
- `clearGroups(): void` — Clear all groups
- `addStroke(stroke: StrokeModel): void`
- `removeStroke(strokeId: string): StrokeModel | undefined`
- `restoreStroke(data: SerializedStroke): StrokeModel | undefined`
- `getStroke(strokeId: string): StrokeModel | undefined`
- `getStrokes(): StrokeModel[]`
- `clearStrokes(): void`
- `getVisibleStrokes(viewport: Rectangle): StrokeModel[]` — Strokes whose bounds overlap `viewport`. The ink layer's culling query.
- `getStrokesAlongSegment(a: Point, b: Point, tolerance = 0): StrokeModel[]` — Every stroke the pointer swept across travelling `a`→`b`. THE ERASER'S QUERY.

Segment-based, not point-based: a fast flick puts 80px between two pointermove
samples, and an eraser that only tested the samples would jump clean over a stroke
it visibly wiped through. Ask what the pointer TRAVELLED THROUGH, not where it
happened to land.
- `getAncestors(groupId: string): GroupModel[]` — Get a group's ancestor chain (nearest parent first), walking parentGroupId
upward. Robust against malformed self/looping pointers.
- `getDescendants(groupId: string): GroupModel[]` — Get every group nested (directly or transitively) inside `groupId`. Breadth-first over the parentGroupId back-pointers.
- `getDepth(groupId: string): number` — Nesting depth of a group: number of ancestors (0 for a top-level group).
- `getSelectedNodes(): NodeModel[]` — Get all selected nodes
- `selectNode(node: NodeModel): void` — Select a single node (clears previous selection)
- `addToSelection(node: NodeModel): void` — Add node to selection (multi-select)
- `removeFromSelection(node: NodeModel): void` — Remove node from selection
- `toggleNodeSelection(node: NodeModel): void` — Toggle node selection (add if not selected, remove if selected)
- `clearSelection(): void` — Clear all selections
- `selectAll(): void` — Select all nodes
- `deleteSelected(): number` — Delete all selected nodes and their connected links
- `getNodeAtPosition(x: number, y: number): NodeModel | undefined` — Get node at position (for click detection)
Returns the topmost node at the given position
- `isPointCoveredAbove(x: number, y: number, nodeId: string): boolean` — Is (x, y) inside any node ABOVE `nodeId` in z-order?

Same z contract as {@link getNodeAtPosition} (array order, topmost last),
same shape-aware containment. This is the occlusion oracle for PORTS: a
port whose anchor a higher node covers must neither paint nor accept
input — pre-fix, an overlapped node's port glyphs floated on top of the
covering node's body, and its hidden ports still won the hover/press race
through it (live report from stacked pasted nodes).
- `lockSelected(): number` — Option 3: Lock/pin selected nodes
Locked nodes will not move during layout operations
- `unlockSelected(): number` — Option 3: Unlock selected nodes
- `getLockedNodes(): NodeModel[]` — Option 3: Get locked nodes
- `unlockAll(): number` — Option 3: Unlock all nodes
- `setViewport(x: number, y: number, width: number, height: number, zoom?: number): void` — Set viewport
- `getViewport(): { x: number; y: number; width: number; height: number; zoom: number }` — Get current viewport
- `pan(dx: number, dy: number): void` — Pan viewport
- `zoom(delta: number, center?: Point): void` — Zoom viewport (relative adjustment)
- `setZoom(level: number, center?: Point): void` — Set absolute zoom level
Pan/Zoom controls
- `fitToView(padding: number = 100): void` — Fit viewport to show all nodes (without changing zoom level)
Pan/Zoom controls
- `zoomToFit(targetWidth: number, targetHeight: number, padding: number = 100): void` — Fit viewport to show all nodes AND adjust zoom to fit screen
Pan/Zoom controls
- `clear(): void` — Clear all nodes, links, and groups
- `getVisibleNodes(viewport: Rectangle): NodeModel[]` — Get nodes visible in viewport
This enables viewport virtualization - only render visible nodes
- `getVisibleLinks(viewport: Rectangle): LinkModel[]` — Get links visible in viewport
This enables viewport virtualization - only render visible links
- `findNearestPort( point: Point, options?: NearestPortOptions ): NearestPortHit | null` — The nearest port to a world point, served BY THE SPATIAL INDEX.

. This is the query a link drag makes on every
pointermove, so it is the one query that must never be a scan: the existing
answer (`PortModel.findNearestPort`) could only search ONE node — the one the
pointer happened to be over — because searching more would have meant walking
every node in the diagram. The index turns "which port am I near" into a
bounded region query, so a drag can snap to a port it is merely NEAR, not one
it is already on top of, without paying O(nodes) sixty times a second.

`portPosition` is injectable because THE ENGINE DOES NOT KNOW WHERE PORTS ARE. Its default (`getAbsolutePosition`) walks the bounding box — edge midpoints,
blind to the silhouette and to how many ports share a side — while the
renderer draws them shape-aware (`portWorldPosition`).
- `getVisibleBounds(viewport: Rectangle): Rectangle | null` — Get bounding box of all visible entities
Useful for "fit to viewport" operations
- `getDirtyNodes(): NodeModel[]` — Get all dirty nodes
Returns nodes that need re-rendering
- `getDirtyLinks(): LinkModel[]` — Get all dirty links
Returns links that need re-rendering
- `getDirtyGroups(): GroupModel[]` — Get all dirty groups
Returns groups that need re-rendering
- `markAllClean(): void` — Mark all entities as clean
Call this after rendering to reset dirty flags
- `getDirtyCount(): number` — Get total count of dirty entities
Useful for monitoring render performance
- `getVisibleDirtyNodes(viewport: Rectangle): NodeModel[]` — Get visible dirty nodes
Combines viewport virtualization with dirty marking
Only returns nodes that are both visible AND need re-rendering
- `getVisibleDirtyLinks(viewport: Rectangle): LinkModel[]` — Get visible dirty links
Combines viewport virtualization with dirty marking
Only returns links that are both visible AND need re-rendering
- `getLODLevel(zoom: number): LODLevel` — Get LOD level based on zoom

Driven by the declarative {@link LODConfig}. Picks the
tier whose `minZoom` the zoom crosses — tiers are pre-sorted highest-first,
so the first match wins. With the default config this is exactly:
  zoom >= 1.0        -> 'high'
  0.5 <= zoom < 1.0  -> 'medium'
  zoom <  0.5        -> 'low'
- `shouldRender(feature: LODFeature, lod: LODLevel): boolean` — Single feature gate that reads the active LOD tier's
feature set. Renderers call this instead of hardcoding `lod === 'high'`
checks, so custom tiers work automatically.
- `getLODConfig(): LODConfig` — Get the current Level-of-Detail policy.
- `setLODConfig(config: LODConfig): void` — Replace the Level-of-Detail policy wholesale. Apps use this to define
their own tiers (names, breakpoints and feature sets).
- `registerLODTier(tier: LODTier): void` — Register (or replace, by name) a single LOD tier. Lets apps extend the
default policy with an extra tier without rebuilding the whole config.
- `getNodesWithLOD(viewport: Rectangle, zoom: number): EntityWithLOD<NodeModel>[]` — Get visible nodes with LOD information
Combines viewport virtualization with Level of Detail
- `getLinksWithLOD(viewport: Rectangle, zoom: number): EntityWithLOD<LinkModel>[]` — Get visible links with LOD information
Combines viewport virtualization with Level of Detail
- `shouldRenderLabels(lod: LODLevel): boolean` — Check if labels should be rendered at this LOD level
Now reads the LOD tier's feature set.
- `shouldRenderIcons(lod: LODLevel): boolean` — Check if icons should be rendered at this LOD level
Now reads the LOD tier's feature set.
- `shouldRenderBorders(lod: LODLevel): boolean` — Check if borders should be rendered at this LOD level
Now reads the LOD tier's feature set.
- `shouldRenderShadows(lod: LODLevel): boolean` — Check if shadows should be rendered at this LOD level
Now reads the LOD tier's feature set.
- `getLayoutManager(): LayoutManager` — Get the layout manager for this diagram
- `setLayoutAlgorithm(type: LayoutAlgorithmType, config?: LayoutConfiguration): void` — Set layout algorithm
- `getLayoutAlgorithm(): LayoutAlgorithmType` — Get current layout algorithm type
- `configureLayout(config: LayoutConfiguration): void` — Configure current layout algorithm
- `getLayoutConfiguration(): LayoutConfiguration` — Get layout configuration
- `setAutoLayout(enabled: boolean): void` — Enable or disable automatic layout for new nodes
When enabled, newly added nodes will be automatically positioned using the current layout algorithm
- `isAutoLayoutEnabled(): boolean` — Check if auto-layout is enabled
- `async reLayout(config?: LayoutConfiguration): Promise<void>` — Re-layout all nodes using current algorithm
This will recalculate positions for all nodes in the diagram
- `override endBatch(): void` — Override: End batch update mode
Fires accumulated events when all batches complete
- `serialize(): SerializedDiagram` — Serialize to JSON
- `reconcilePortConnections(): Array<{ linkId: string; portId: string; end: 'source' | 'target' }>` — Rebuild the derived port-connection registries from the diagram's links.

`PortModel.currentConnections` is DERIVED state (which links touch this
port). It is never serialized; instead it is reconstructed
deterministically here so `canConnect()` / `maxConnections` enforcement
survive save/load. Runs inside fromJSON, and is safe to re-run at any
time — existing registries are reset first, so the result is always
exactly the current links.

A self-loop (source === target port) registers once — the Set dedupes —
so it counts as ONE connection on that port.
- `applyIncremental(patch: DiagramIncremental): void` — Apply an incremental patch (see serialization/Incremental.ts) — the
receive side of toIncremental/apply. Added entities are installed through
the SAME unified restore path as document load (fully wired); modified
entities are updated IN PLACE so object identity is preserved for
renderers holding references; normal change events fire (an applied
patch IS a mutation, unlike a document load).
- `static fromJSON(data: SerializedDiagram, options?: DiagramLoadOptions): DiagramModel` (static) — Deserialize from JSON — THE document load path.

Contract: a loaded diagram behaves identically to an authored one. Every entity is installed through the same install* wiring as
interactive creation (diagram back-refs, spatial indices, port index,
change-forwarding listeners), port registries are reconciled, and the
document is migrated to the current schema first. The load itself is
NOT a user mutation: per-entity events queued during the restore are
dropped, the change log ends empty, and `version` reports the SAVED
counter — then a single 'diagram:loaded' event fires.
- `override dispose(): void` — Dispose diagram and all child entities
Prevents memory leaks by:
- Disposing all nodes, links, and groups
- Breaking circular references
- Clearing spatial indices
- Calling parent dispose()
