Skip to content
D
Documentation

DiagramModel

reference
11 min readUpdated

Import it from @grafloria/engine.

ts
class DiagramModel extends DiagramEntity

Properties

NameTypeDefaultDescription
namestring'Untitled Diagram'
nodesMap<string, NodeModel>
linksMap<string, LinkModel>
groupsMap<string, GroupModel>
strokesMap<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"? 'model' (the default): {@link removeNode} CASCADES — it removes the links attached to the node it removes. This is the right answer for an ordinary single-user document, and its absence was a real bug: deleting a node left its edges in getLinks() and on the screen, through every removal path there is. 'external': something wit
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

' 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()

Was this page helpful?