# LinkModel

Import it from `@grafloria/engine`.

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

```ts
class LinkModel extends DiagramEntity
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `diagram?` | `DiagramModel` |  | . Back-reference to the owning diagram, set by `DiagramModel.installLink`. Without this, read-only would have been unenforceable on exactly the edits the interaction controller performs most. |
| `sourcePortId` | `string` |  |  |
| `targetPortId` | `string` |  |  |
| `sourceNodeId?` | `string` |  |  |
| `targetNodeId?` | `string` |  |  |
| `pathType` | `'direct' \| 'orthogonal' \| 'smooth' \| 'bezier'` | `'smooth'` |  |
| `router?` | `LinkRouterName` |  | WHERE the line goes. When unset, derived from pathType — see {@link effectiveRouter}. Setting it does NOT touch pathType, so legacy consumers keep working; the explicit field simply wins. |
| `connector?` | `LinkConnectorName` |  | HOW the polyline is drawn. Unset = derived from pathType. |
| `points` | `Point[]` | `[]` |  |
| `segments` | `PathSegment[]` | `[]` |  |
| `labels` | `LinkLabel[]` | `[]` |  |
| `state` | `'default' \| 'selected' \| 'hovered' \| 'highlighted'` | `'default'` |  |
| `style` | `Partial<LinkStyle>` | `{}` |  |
| `data` | `Record<string, any>` | `{}` |  |
| `isSourceEndpointSelected` | `boolean` | `false` | Whether source endpoint handle is selected Used for dragging endpoint to reconnect |
| `isTargetEndpointSelected` | `boolean` | `false` | Whether target endpoint handle is selected Used for dragging endpoint to reconnect |
| `DEFAULT_CURVATURE` (static) (not released yet) |  | `0.5` | The curve tightness of a smooth/bezier link. |

**Methods**

- `constructor( sourcePortId: string, targetPortId: string, pathType?: 'direct' | 'orthogonal' | 'smooth' | 'bezier' )`
- `isSelfLoop(): boolean` — Is this link a SELF-LOOP, i.e. do both
ends live on the same node?

Reads the cached owning-node ids, which `DiagramModel.installLink` backfills
for every link that reaches a diagram (including ones built with
`new LinkModel()` + `addLink`, which carry no ids of their own). A link that
has never been installed has no ids and is — correctly — not a self-loop as
far as anything can tell.

A self-loop between two DIFFERENT ports of the same node counts, and so does
one that starts and ends on the SAME port.
- `getNodePairKey(): string | null` — The unordered node pair this link connects, as a stable
key. UNORDERED on purpose: A→B and B→A are the same visual bundle and must
fan out together, or a bidirectional pair would draw both links on the same
centre line. Returns null when the owning nodes are unknown.
- `setSourcePort(portId: string, nodeId?: string): void` — Set source port
- `setTargetPort(portId: string, nodeId?: string): void` — Set target port
- `setPathType(pathType: 'direct' | 'orthogonal' | 'smooth' | 'bezier'): void` — Set path type
- `setRouter(router: LinkRouterName | undefined): void` — Set the routing geometry explicitly. Clears the cached route the
same way setPathType does — the old polyline belongs to the old router.
- `setConnector(connector: LinkConnectorName | undefined): void` — Set the polyline rendering explicitly. Pure re-render; the routed
points are still valid, so the cache is NOT cleared.
- `effectiveRouter(): LinkRouterName` — The router actually in force: the explicit field, else derived from
pathType exactly as the renderer always derived it (direct → straight,
orthogonal → orthogonal, smooth/bezier → straight-with-curved-rendering).
- `effectiveConnector(): LinkConnectorName` — The connector actually in force: the explicit field, else implied by an
explicitly axis-aligned ROUTER, else derived from pathType.

The router rung exists because `{ router: 'orthogonal' }` with the default
pathType used to draw a smooth SPLINE through Manhattan waypoints — the
route was orthogonal, the picture was wavy (the screenshot audit caught the
demo's own readout saying "orthogonal" over a curve). Asking for an
axis-aligned router IS asking for axis-aligned rendering; rounded corners
are that family's standard look. An explicit `connector` still overrides.
- `setPoints(points: Point[]): void` — Set points for custom path
- `addPoint(point: Point, index?: number): void` — Add point to path
- `removePoint(index: number): Point | undefined` — Remove point from path
- `generatePath( sourcePoint: Point, targetPoint: Point, sourceDirection?: 'left' | 'right' | 'top' | 'bottom', targetDirection?: 'left' | 'right' | 'top' | 'bottom' ): void` — Generate path based on type
- `getCurvature(): number`
- `addLabel( label: Partial<LinkLabel> & { text: string } & ( | { position: number } | { slot: NonNullable<LinkLabel['slot']> } ) ): void` — Add label

`position` is no longer required when the label names a
`slot` — and every other LinkLabel field (html, template, slot, autoOffset,
rotation…) is now carried through instead of being silently dropped. The old
body hand-copied five fields, so a label created here could not be an HTML
label, could not auto-rotate and could not opt into auto-placement.
- `removeLabel(labelId: string): LinkLabel | undefined` — Remove label by ID
- `removeLabelAt(index: number): LinkLabel | undefined` — Remove label by index
- `updateLabel(index: number, updates: Partial<LinkLabel>): void` — Update label by index
- `setLabels(labels: LinkLabel[]): void` — REPLACE the whole label collection — the write `addLabel`/`updateLabel` cannot express.

`SetLinkLabelsCommand` used to do this by assigning `link.labels` directly on BOTH
execute and undo. A plain field write does not pass `trackChange()` — the one funnel
collab captures from — so the command emitted ZERO ops in BOTH directions: authoring
a link's labels was invisible to every other peer, and so was taking it back. (The
`UpdateLinkStyleCommand` defect at least emitted one op on execute; this emitted none
at all.) `replaceStyle` is the same seam for the same reason.
- `override getLabel(): string | undefined` — Overloaded label read.

- `getLabel()` — no argument — is the CANONICAL display-label accessor
  inherited from DiagramEntity (metadata.label, with the legacy
  data['label'] fallback). It is what the DSL generator exports and what
  feeds the renderer's simple edge label (svg-renderer reads
  link.getMetadata('label')).
- `getLabel(labelId)` is the pre-existing lookup into the POSITIONED
  multi-label API (`labels: LinkLabel[]` / addLabel / updateLabel), a
  different feature that happens to share the verb. The overload keeps
  both callable without forking the canonical name.
- `override getLabel(labelId: string): LinkLabel | undefined` — Overloaded label read.

- `getLabel()` — no argument — is the CANONICAL display-label accessor
  inherited from DiagramEntity (metadata.label, with the legacy
  data['label'] fallback). It is what the DSL generator exports and what
  feeds the renderer's simple edge label (svg-renderer reads
  link.getMetadata('label')).
- `getLabel(labelId)` is the pre-existing lookup into the POSITIONED
  multi-label API (`labels: LinkLabel[]` / addLabel / updateLabel), a
  different feature that happens to share the verb. The overload keeps
  both callable without forking the canonical name.
- `override getLabel(labelId?: string): string | LinkLabel | undefined` — Overloaded label read.

- `getLabel()` — no argument — is the CANONICAL display-label accessor
  inherited from DiagramEntity (metadata.label, with the legacy
  data['label'] fallback). It is what the DSL generator exports and what
  feeds the renderer's simple edge label (svg-renderer reads
  link.getMetadata('label')).
- `getLabel(labelId)` is the pre-existing lookup into the POSITIONED
  multi-label API (`labels: LinkLabel[]` / addLabel / updateLabel), a
  different feature that happens to share the verb. The overload keeps
  both callable without forking the canonical name.
- `setState(state: 'default' | 'selected' | 'hovered' | 'highlighted'): void` — Set state
- `updateStyle(style: Partial<LinkStyle>): void` — Update style
- `replaceStyle(style: Partial<LinkStyle>): void` — REPLACE the whole style object — the write `updateStyle` cannot express.

`updateStyle` merges, so it can never REMOVE a key; restoring a snapshot has to
assign wholesale. `UpdateLinkStyleCommand.undo()` used to do that with a direct
field write (`link.style = restored`), which never passes `trackChange()` — the
single funnel collab captures from. Measured: execute emitted 1 op, undo emitted 0,
so every peer kept the styled link forever while the author saw it correctly
reverted. See collab/style-undo.spec.ts.
- `setData(key: string, value: any): void` — Set data property
- `getData(key: string): any` — Get data property
- `reconnectSource(newPortId: string, newNodeId?: string): void` — Reconnect source endpoint to new port
Used for link reconnection workflow
- `reconnectTarget(newPortId: string, newNodeId?: string): void` — Reconnect target endpoint to new port
Used for link reconnection workflow
- `getSourceEndpoint(): Point` — Get source endpoint position
Returns the first point in the path (source end)
- `getTargetEndpoint(): Point` — Get target endpoint position
Returns the last point in the path (target end)
- `selectSourceEndpoint(): void` — Select source endpoint handle
- `selectTargetEndpoint(): void` — Select target endpoint handle
- `deselectEndpoints(): void` — Deselect all endpoint handles
- `hasSelectedEndpoint(): boolean` — Check if any endpoint is selected
- `getPointAtPosition(t: number): Point | null` — Get point at position along link (0-1)
- `getTotalLength(): number` — Get total path length
- `getLength(): number` — Get path length (alias for getTotalLength for API consistency)
- `getTangentAt(t: number): Point | null` — Get tangent (direction vector) at position along path (0-1)
Returns normalized direction vector
- `getNormalAt(t: number): Point | null` — Get normal (perpendicular vector) at position along path (0-1)
Returns normalized perpendicular vector (90° counter-clockwise from tangent)
- `getClosestPoint(point: Point): { point: Point; distance: number; t: number } | null` — Get closest point on path to a given point
Returns the closest point, distance, and normalized position (t)
- `getAngleAt(t: number): number | null` — Get angle at position along path (0-1) in degrees
Useful for label rotation
- `override serialize(): SerializedLink` — Serialize to JSON
- `static fromJSON(data: SerializedLink): LinkModel` (static) — Deserialize from JSON
