# NodeModel

Import it from `@grafloria/engine`.

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

```ts
class NodeModel extends DiagramEntity
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `diagram?` | `DiagramModel` |  |  |
| `position` | `Point` |  |  |
| `size` | `Size` |  |  |
| `rotation` | `number` | `0` |  |
| `scale` | `Point` |  |  |
| `positionMode` | `PositioningMode` | `'absolute'` |  |
| `transformOrigin` | `Point` |  |  |
| `zIndex?` | `number` |  | C — model-level stacking order (lower renders further back). |
| `flexConfig?` | `FlexItemConfig` |  |  |
| `gridConfig?` | `GridItemConfig` |  |  |
| `portRenderingConfig?` | `any` |  |  |
| `dragHandlerConfig?` | `any` |  |  |
| `connectionGroup?` | `string` |  |  |
| `type` | `string` |  |  |
| `systemType?` | `string` |  |  |
| `definitionId?` | `string` |  |  |
| `parentId?` | `string` |  |  |
| `children` | `Set<string>` |  |  |
| `depth` | `number` | `0` |  |
| `ports` | `Map<string, PortModel>` |  |  |
| `state` | `NodeState` |  |  |
| `behavior` | `NodeBehavior` |  |  |
| `style` | `Partial<NodeStyle>` | `{}` |  |
| `classes` | `Set<string>` |  |  |
| `data` | `Record<string, any>` | `{}` |  |
| `computed` | `Map<string, any>` |  |  |

**Methods**

- `constructor(config: { id?: string; type: string; position: Point; size?: Size; systemType?: string; definitionId?: string; })`
- `setPosition(x: number, y: number, z?: number): void` — Set position
- `move(dx: number, dy: number, dz?: number): void` — Move by delta
- `setSize(width: number, height: number, depth?: number): void` — Set size.

A node growing inside a
flex/grid container is a layout-invalidating event: its siblings have to move.

It notifies its LAYOUT CONTAINERS, and deliberately NOT the transform chain
`setPosition` uses: a parent's size does not move a relative child (the child's
offset is measured from the parent ORIGIN), so emitting `transform-propagated`
here would be noise that says something untrue.
- `resize(dw: number, dh: number, dd?: number): void` — Resize by delta
- `setRotation(degrees: number): void` — Set rotation
- `rotate(degrees: number): void` — Rotate by delta
- `setScale(x: number, y: number): void` — Set scale
- `addPort(port: PortModel): void` — Add port
- `removePort(portId: string): PortModel | undefined` — Remove port
- `getPort(portId: string): PortModel | undefined` — Get port by ID
- `getPorts(): PortModel[]` — Get all ports
- `getIncomingLinks(): import('./LinkModel').LinkModel[]` — Links arriving at this node (resolved through the owning diagram; empty
when the node isn't attached to a diagram yet)
- `getOutgoingLinks(): import('./LinkModel').LinkModel[]` — Links leaving this node (resolved through the owning diagram)
- `getPortsByType(type: 'input' | 'output' | 'bi'): PortModel[]` — Get ports by type
- `getPortBySide(side: 'top' | 'right' | 'bottom' | 'left'): PortModel | undefined` — Get port by side
Returns the first port found on the specified side
- `getPortsBySide(side: 'top' | 'right' | 'bottom' | 'left'): PortModel[]` — Get all ports on a specific side
Useful for nodes with multiple ports per side
- `getAvailablePorts(type?: 'input' | 'output' | 'bi'): PortModel[]` — Get available ports that can accept connections
- `getConnectedPorts(): PortModel[]` — Get ports that have active connections
- `setState(state: Partial<NodeState>): void` — Set state property
- `replaceState(state: Partial<NodeState>): void` — REPLACE the DOCUMENT half of `state`, keeping THIS viewer's own view half.

The write `setState` cannot express, and the collab reducer's write path.

## Why a merge was wrong

`state` is a value register: the op carries the whole (projected) object the author
now holds. Applying it with the merging `setState` meant a peer could GAIN a key and
never LOSE one — `NodeState.error`, `warning`, `status` and `animateStatus` are all
optional, so the author clears an error badge and every other peer keeps it FOREVER,
with no later edit able to correct it. Node `style` had exactly this defect and was
fixed with `replaceStyle`; this is the same fix for the register next to it.

## Why it is not a plain wholesale replace either

`selected` / `hovered` / `highlighted` / `focused` are facts about a VIEWER, not about
the document. Capture strips them (see collab/capture.ts — syncing them meant your
cursor lit up my node and your click deselected it), so an incoming register value
never carries them. Replacing wholesale would therefore BLANK the receiving user's own
selection on every remote state edit — reintroducing the very bug through the back
door. So: durable keys replaced wholesale, view keys taken from what this replica
already had.

## The read-only posture, deliberately UNCHANGED

This refuses outright while the document is locked, exactly like `setPosition`,
`setStyle` and `replaceStyle`.

• That filter exists so a LOCAL user can still select, hover and keyboard-navigate a
    presentation-mode diagram. It is about input, not about the wire.
  • It would be a no-op here anyway: capture strips the view keys, so an incoming
    `state` value contains none of the keys the filter admits — a locked replica
    already dropped remote state ops entirely, before this method existed. Behaviour
    is therefore identical, and `setState` is left untouched.
  • Making `state` the one register that DID reach a locked replica would be
    incoherent: a read-only replica currently applies no remote document write at all
    (verified — a locked peer ignores remote `position` and `style` too). That gap is
    real and systemic, and it belongs to the lock, not to this register.
- `setBehavior(behavior: Partial<NodeBehavior>): void` — Set behavior property
- `isSelected(): boolean` — Check if node is selected
- `setSelected(selected: boolean): void` — Set selection state
- `isHighlighted(): boolean` — Check if node is highlighted (attention state, independent of selection)
- `setHighlighted(highlighted: boolean): void` — Set highlight state (attention emphasis without selecting the node)
- `isSelectable(): boolean` — Check if node is selectable (based on behavior)
Note: Locked nodes are still selectable so users can unlock them
- `isDraggable(): boolean` — Check if node is draggable (based on behavior and state)
Note: Locked nodes cannot be dragged
- `setStyle(style: Partial<NodeStyle>): void` — Set style property
- `replaceStyle(style: Partial<NodeStyle>): void` — REPLACE the whole style object — the write `setStyle` cannot express.

`setStyle` merges, so it can add a key and overwrite a key but can never REMOVE
one. Restoring a snapshot therefore has to assign wholesale, and the obvious way to
do that — `node.style = snapshot` — is a plain field write that never passes
`trackChange()`. That funnel is what collab captures from, so the direct assignment
reaches the renderer (via markDirty) and reaches no other peer at all. That is not
hypothetical: it is exactly how an undone LINK style stayed applied on every peer
but the one that pressed Ctrl+Z. See collab/style-undo.spec.ts.

Undo paths must use this, not the field.
- `addClass(className: string): void` — Add CSS class
- `removeClass(className: string): void` — Remove CSS class
- `setClasses(classNames: string[]): void` — REPLACE the whole class collection.

`classes` is a `Set`, so it needs the same treatment `members` and `ports` needed: a
register whose in-memory form is a collection and whose wire form is an array must be
REBUILT on the receiving side, never assigned. The collab reducer writes it through
here.

A non-array is REFUSED rather than read as "no classes" — logs persisted before the
funnel fix carry a bare class name (the old addClass) or a bare `null` (the old
removeClass), and assigning either is what corrupted the Set in the first place.
- `setData(key: string, value: any): void` — Set data property
- `getData(key: string): any` — Get data property
- `setComputed(key: string, value: any): void` — Set computed property
- `getComputed(key: string): any` — Get computed property
- `getWorldPosition(): Point` — Get world position (absolute coordinates accounting for parent chain)
For nodes without parents, this is the same as position
For child nodes, this walks up the parent chain and accumulates offsets
- `getBoundingBox(): BoundingBox` — Get bounding box in world coordinates
For child nodes, this accounts for parent position
- `getCenter(): Point` — Get center point in world coordinates
- `containsPoint(point: Point): boolean` — Check if point is inside node
- `intersectsBounds(bounds: BoundingBox): boolean` — Check if intersects with bounding box
- `setParent(parentId: string | undefined): void` — Set parent
- `addChild(childId: string): void` — Add child
- `removeChild(childId: string): void` — Remove child
- `setChildren(childIds: string[]): void` — REPLACE the whole child collection. The collab reducer's write path.

Same contract as {@link setClasses}: a Set in memory, an array on the wire, rebuilt
rather than assigned, and a non-array refused so a pre-fix log degrades instead of
destroying the collection.

This maintains only its own half of the hierarchy, exactly as `addChild`/`removeChild`
do — the child's `parentId` is its own register with its own op.
- `setTransformOrigin(x: number, y: number): void` — Set transform origin
- `getAbsoluteTransformOrigin(): Point` — Get absolute transform origin in pixels
- `getLocalPosition(): Point` — Get local position
Returns the position property as-is
- `getGlobalPosition(): Point` — Get global position
In absolute mode: returns position as-is
In relative mode: transforms position by parent's hierarchy transform
- `setLocalPosition(x: number, y: number, z?: number): void` — Set local position
Sets position directly and switches to relative mode
- `setGlobalPosition(x: number, y: number, z?: number): void` — Set global position
Converts global coordinates to local if parent exists
- `getLocalTransformMatrix(): TransformMatrix` — Get local transform matrix
Composes translation, rotation, and scale relative to transform origin
- `getGlobalTransformMatrix(): TransformMatrix` — Get global transform matrix
In absolute mode: returns local matrix
In relative mode: composes parent's global matrix with local matrix
- `getGlobalBounds(): BoundingBox` — Get global bounding box
Calculates bounds by transforming all 4 corners through global matrix
- `getChildren(): NodeModel[]` — Get direct children nodes
- `getParent(): NodeModel | undefined` — Get parent node
Public version of getParentNode
- `getAncestors(): NodeModel[]` — Get all ancestor nodes up to root
Returns array with direct parent first, then grandparent, etc.
- `getDescendants(): NodeModel[]` — Get all descendant nodes recursively
- `getRoot(): NodeModel` — Get root node of hierarchy
Returns self if this is the root
- `getSiblings(): NodeModel[]` — Get sibling nodes (same parent, excluding self)
- `isAncestorOf(nodeId: string): boolean` — Check if this node is an ancestor of another node
- `getDepth(): number` — Get depth in hierarchy
Root nodes have depth 0, their children have depth 1, etc.
- `validateHierarchy(): boolean` — Validate hierarchy for circular references
- `updateHierarchyDepth(): void` — Update depth for this node and all descendants
Recalculates depth values based on current hierarchy
- `getAffectedByTransform(): NodeModel[]` — Get all nodes affected by transform changes
Returns this node plus all descendants in relative positioning mode
- `getEffectiveZIndex(): number` — The stacking index a renderer should paint by.

Precedence is explicit-model-field → legacy `style.zIndex` → 0. That ordering
is the whole compatibility story: diagrams that restacked via style keep
working untouched, and the moment a node states a model z-index it wins — so
`setZIndex(1)` on a node styled `zIndex: 4` does what it says instead of
silently losing to a stylesheet.
- `setZIndex(z: number | undefined): void` — Set the stacking index (lower renders further back). Tracked for undo/diff.
- `bringToFront(diagram?: DiagramModel): void` — Bring this node in front of every other node in the diagram. Falls back to a relative bump when the node is detached, exactly as
`GroupModel.bringToFront` does.
- `sendToBack(diagram?: DiagramModel): void` — Send this node behind every other node in the diagram.
- `setFlexItem(config: FlexItemConfig): void` — Set flexbox item configuration
- `clearFlexItem(): void` — Clear flexbox item configuration
- `getFlexItem(): FlexItemConfig | undefined` — Get flexbox item configuration
- `hasFlexItem(): boolean` — Check if node has flex item configuration
- `setGridItem(config: GridItemConfig): void` — Set grid item configuration
- `clearGridItem(): void` — Clear grid item configuration
- `getGridItem(): GridItemConfig | undefined` — Get grid item configuration
- `hasGridItem(): boolean` — Check if node has grid item configuration
- `setPortRenderingConfig(config: any): void` — Set port rendering configuration
- `getPortRenderingConfig(): any | undefined` — Get port rendering configuration
- `getPortRenderingMode(): 'svg' | 'html' | 'auto'` — Get port rendering mode
Auto-detects based on configuration and metadata
- `setDragHandlerConfig(config: any): void` — Set drag handler configuration
- `getDragHandlerConfig(): any | undefined` — Get drag handler configuration
- `isDragHandler(): boolean` — Check if this node is a drag handler
- `setConnectionGroup(group: string): void` — Set connection group
- `getConnectionGroup(): string | undefined` — Get connection group
- `serialize(): SerializedNode` — Serialize to JSON
- `static fromJSON(data: SerializedNode): NodeModel` (static) — Deserialize from JSON
