# DiagramCanvasComponent

Import it from `@grafloria/angular`.

DiagramCanvasComponent

Standalone, OnPush, **signal-based** Angular canvas over the framework-agnostic
`SVGRenderer` + engine. A thin shell on purpose: every decision it makes is
delegated down into `@grafloria/renderer` (`InteractionController`, `SVGRenderer`,
`applyNodes`/`applyEdges`) or `@grafloria/engine` (commands, `IncrementalCapture`).

Inputs are `input()` / `model()` signals, outputs are `output()`: **no NgZone
dependency, no EventEmitter**, so the component runs under
`provideZonelessChangeDetection()`. Everything the *template* binds is a signal
(`marquee`, `htmlNodes`, `htmlLayerTransform`, `linkToolbarTarget`) — that is
what lets the zoneless scheduler see a change with no zone tick. The SVG layer
is painted imperatively (VNode → DOM patcher) and never went through change
detection at all.

`viewport` and `zoom` are `model()` signals because the canvas WRITES them (pan,
cursor-anchored zoom, fit-to-content), so `[(zoom)]` / `[(viewport)]` round-trip. `zoomChanged` / `viewportChanged` are kept alongside for backwards compatibility
(`viewportChanged` emits the VISIBLE world rect — the viewBox — whereas the
`model`'s `viewportChange` emits the camera rect the `viewport` input IS).

Two modes, both supported:

- **Uncontrolled (legacy):** bind `[engine]` and mutate the engine yourself. Unchanged behaviour.
- **Controlled:** bind `[(nodes)]` / `[(edges)]` — the same `NodeSpec` /
  `EdgeSpec` data the React wrapper and `<grafloria-flow>` take. They are reconciled
  against the live model by `applyNodes` / `applyEdges` **from `@grafloria/renderer`**
  (the shared reconciler — Angular does not get a second diff algorithm), and
  model mutations come back out as `nodesChange` / `edgesChange` (the next array
  — Angular's two-way contract) plus `modelChange` (a `DiagramIncremental`:
  precisely which entities were added / removed / modified — GoJS's
  `IncrementalData`). `[skipModelUpdate]="true"` suspends the inbound half
  (GoJS's `skipsDiagramUpdate`).

```ts
@Component({
    selector: 'grafloria-diagram-canvas',
    imports: [CommonModule, HtmlNodeRendererDirective, GrafloriaHandleDirective, LinkToolbarComponent],
    templateUrl: './diagram-canvas.component.html',
    styleUrls: ['./diagram-canvas.component.css'],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class DiagramCanvasComponent implements AfterViewInit, OnDestroy
```

Use it as `<grafloria-diagram-canvas>` in a template.

**Inputs**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `engine?` | `DiagramEngine \| undefined` |  | Diagram engine instance. Optional: in controlled mode (`nodes`/`edges` bound) |
| `nodes?` | `readonly (NodeSpec \| NodeModel)[] \| undefined` |  | Controlled node data — the shared `NodeSpec` shape. `undefined` = Two-way: also emits its change. |
| `edges?` | `readonly (EdgeSpec \| LinkModel)[] \| undefined` |  | Controlled edge data. Two-way: `[(edges)]`. Two-way: also emits its change. |
| `skipModelUpdate?` |  |  | Suspend the INBOUND half of the controlled binding (GoJS's |
| `viewport?` | `Rectangle` |  | Camera rectangle. Two-way: `[(viewport)]` (the canvas pans/zooms it). Two-way: also emits its change. |
| `zoom?` |  |  | Zoom level. Two-way: `[(zoom)]` (the canvas writes it on wheel/fit/keys). Two-way: also emits its change. |
| `theme?` | `Theme \| undefined` |  | Theme configuration. |
| `collab?` | `GrafloriaCollabOptions \| undefined` |  | Real-time collaboration: a transport (BroadcastChannelTransport, |
| `comments?` | `boolean \| CommentStore \| undefined` |  | Anchored comment threads. `true` creates a store (viewer 'local'); pass a |
| `plugins?` | `boolean \| CanvasPluginOptions \| undefined` |  | `[plugins]="true"` mounts minimap + zoom/fit controls + background grid |
| `layout?` | `string \| GrafloriaLayoutRequest \| undefined` |  | Declarative auto-layout: `[layout]="'elk'"` or |
| `colorMode?` | `ColorMode \| undefined` |  | Card "colorMode". |
| `themes?` | `ThemeSet \| undefined` |  | The themes `colorMode` chooses between. Defaults to the built-in set. |
| `tokenBridge?` | `TokenBridge \| undefined` |  | Card "design-token bridge". |
| `rendererConfig?` | `Partial<SVGRendererConfig>` |  | Extra SVGRenderer options (e.g. smartConnectionPoints, linkHitAreaWidth). Merged over the component defaults; changing it recreates the renderer. |
| `enableMouseWheelZoom?` |  |  | Enable ctrl/⌘ + wheel zoom. |
| `enablePan?` |  |  | Enable pan (middle-drag, space-drag, wheel-scroll). |
| `zoomSensitivity?` |  |  | Relative zoom step per wheel notch / keyboard zoom. |
| `minZoom?` |  |  | Minimum zoom level. |
| `maxZoom?` |  |  | Maximum zoom level. |
| `enableLinkToolbar?` |  |  | Show the floating edge toolbar on link hover/selection. |
| `linkToolbarActions?` | `LinkToolbarAction[] \| undefined` |  | Buttons on the edge toolbar. Defaults to delete + insert-node-on-edge. |
| `linkToolbarAnchor?` |  |  | Fraction along the link the toolbar is glued to (0.5 = midpoint). |
| `enableSelectionTools?` |  |  | Resize/rotate handles, Halo, link endpoint + vertex tools. |
| `enableSnapping?` |  |  | Alignment snaplines, equal spacing, grid snap, keep-in-bounds. |
| `enableProximityConnect?` |  |  | Drop a node near a compatible port → auto-link it. |
| `enableKeyboardNavigation?` |  |  | Tab/arrow focus, nudge, keyboard connect, ARIA announcements. |
| `canvasBounds?` | `Rectangle \| null` |  | Keep-in-bounds for dragging. A signal input, not |
| `enableInPlaceEditing?` |  |  | Double-click a node to edit its label in place. |
| `highlighterConfig?` | `boolean \| Partial<HighlighterConfig>` |  | Hover / selection / validation / connect-target overlay decorations (the |

**Outputs**

- `nodesChange`
- `edgesChange`
- `viewportChange`
- `zoomChange`
- `collabReady` — The live SyncAdapter, right after `join()`.
- `layoutDone` — Fires after each declarative or imperative layout completes.
- `viewportChanged` — The VISIBLE world rect (the SVG viewBox) after a pan/zoom. NOT the same as the
- `zoomChanged` — Zoom after a pan/zoom gesture. (`zoomChange` is the two-way twin.)
- `modelChange` — The incremental patch describing what the MODEL just changed — added /

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `engine` |  |  | Diagram engine instance. Optional: in controlled mode (`nodes`/`edges` bound) the canvas creates and owns one if you do not supply it. |
| `nodes` |  |  | Controlled node data — the shared `NodeSpec` shape. `undefined` = uncontrolled: the engine's node set is left alone. Two-way: `[(nodes)]`. |
| `edges` |  |  | Controlled edge data. Two-way: `[(edges)]`. |
| `skipModelUpdate` |  |  | Suspend the INBOUND half of the controlled binding (GoJS's `skipsDiagramUpdate`): incoming `nodes`/`edges` are not pushed into the model while this is true. Flipping it back to false re-syncs immediately. Outbound emissions are unaffected. |
| `viewport` |  |  | Camera rectangle. Two-way: `[(viewport)]` (the canvas pans/zooms it). |
| `zoom` |  |  | Zoom level. Two-way: `[(zoom)]` (the canvas writes it on wheel/fit/keys). |
| `theme` |  |  | Theme configuration. |
| `effectiveTheme` |  |  | The theme the canvas actually renders with (see `theme` for precedence). |
| `nodeDefMap` |  |  | type → template; '' is the wildcard fallback for HTML-layer nodes. |
| `collab` |  |  | Real-time collaboration: a transport (BroadcastChannelTransport, WebSocketTransport, MemoryTransport, …) + actor id — the canvas joins a CRDT sync session once the diagram exists and leaves on destroy. Fixed for the life of the canvas. |
| `collabReady` |  |  | The live SyncAdapter, right after `join()`. |
| `comments` |  |  | Anchored comment threads. `true` creates a store (viewer 'local'); pass a `CommentStore` to share one. Pins render inside the SVG via the overlay. |
| `plugins` |  |  | `[plugins]="true"` mounts minimap + zoom/fit controls + background grid with defaults; an object picks and configures them. The plugins drive and follow the SAME camera as `[(zoom)]`/`[(viewport)]` via a persistent two-way-synced ViewportController. |
| `layout` |  |  | Declarative auto-layout: `[layout]="'elk'"` or `[layout]="{ name: 'auto', options: { spacing: 60 } }"` — any name in the engine's layout registry (elk, dagre, force, tree, grid, auto, …). |
| `layoutDone` |  |  | Fires after each declarative or imperative layout completes. |
| `colorMode` |  |  | Card "colorMode". |
| `themes` |  |  | The themes `colorMode` chooses between. Defaults to the built-in set. |
| `tokenBridge` |  |  | Card "design-token bridge". |
| `rendererConfig` |  |  | Extra SVGRenderer options (e.g. smartConnectionPoints, linkHitAreaWidth). Merged over the component defaults; changing it recreates the renderer. |
| `enableMouseWheelZoom` |  |  | Enable ctrl/⌘ + wheel zoom. |
| `enablePan` |  |  | Enable pan (middle-drag, space-drag, wheel-scroll). |
| `zoomSensitivity` |  |  | Relative zoom step per wheel notch / keyboard zoom. |
| `minZoom` |  |  | Minimum zoom level. |
| `maxZoom` |  |  | Maximum zoom level. |
| `viewportChanged` |  |  | The VISIBLE world rect (the SVG viewBox) after a pan/zoom. NOT the same as the `viewport` model's `viewportChange`, which emits the camera rect. |
| `zoomChanged` |  |  | Zoom after a pan/zoom gesture. (`zoomChange` is the two-way twin.) |
| `modelChange` |  |  | The incremental patch describing what the MODEL just changed — added / removed / modified nodes, links and groups (GoJS `IncrementalData`). Produced by the engine's `IncrementalCapture`, so it replays exactly. Emitted for engine-originated changes only: a change you pushed in through `[nodes]` / `[edges]` is not echoed back at you. |
| `activeEngine` |  |  | The engine actually in use: the bound one, else the one we own. |
| `enableLinkToolbar` |  |  | Show the floating edge toolbar on link hover/selection. |
| `linkToolbarActions` |  |  | Buttons on the edge toolbar. Defaults to delete + insert-node-on-edge. |
| `linkToolbarAnchor` |  |  | Fraction along the link the toolbar is glued to (0.5 = midpoint). |
| `linkToolbarTarget` |  |  | Link the edge toolbar is currently attached to (null = no toolbar). |
| `enableSelectionTools` |  |  | Resize/rotate handles, Halo, link endpoint + vertex tools. |
| `enableSnapping` |  |  | Alignment snaplines, equal spacing, grid snap, keep-in-bounds. |
| `enableProximityConnect` |  |  | Drop a node near a compatible port → auto-link it. |
| `enableKeyboardNavigation` |  |  | Tab/arrow focus, nudge, keyboard connect, ARIA announcements. |
| `canvasBounds` |  |  | Keep-in-bounds for dragging. A signal input, not |
| `enableInPlaceEditing` |  |  | Double-click a node to edit its label in place. |
| `highlighterConfig` |  |  | Hover / selection / validation / connect-target overlay decorations (the `.grafloria-highlighter-*` layer). `false` hides all kinds — no host CSS required. `true` (default) shows all. Pass a partial {@link HighlighterConfig} to toggle individual kinds or padding. |
| `toolLayer` | `SelectionToolLayer` |  | Live tool layer for the current selection (template-bound). |
| `highlighters` | `Highlighter[]` | `[]` | Hover / selection / validation / drop-target decorations (template-bound). |
| `alignmentGuides` | `AlignmentGuide[]` | `[]` | Live alignment snaplines + equal-spacing guides during a drag/resize. |
| `spacingGuides` | `SpacingGuide[]` | `[]` |  |
| `focusRing` | `FocusRing \| null` | `null` | The visible focus ring (keyboard focus ≠ selection). |
| `liveMessage` |  | `''` | Text of the ARIA live region. |
| `livePoliteness` | `'polite' \| 'assertive'` | `'polite'` |  |
| `containerRef` | `ElementRef<HTMLDivElement>` |  | Main container reference |
| `svgLayerRef` | `ElementRef<HTMLDivElement>` |  | SVG layer reference |
| `htmlLayerRef` | `ElementRef<HTMLDivElement>` |  | HTML layer reference |
| `marquee` |  |  | Live marquee overlay rectangle in SCREEN px (relative to the container), or null when no marquee is active. Bound by the template's SVG overlay. |
| `htmlLayerTransform` |  |  | HTML layer transform Synced with viewport to keep HTML nodes aligned with SVG |
| `htmlNodes` |  |  | HTML nodes to render (DECLARATIVE APPROACH - React Flow style) Exposed as a public property for template binding |

**Methods**

- `nodeTemplateFor(node: any): TemplateRef<GrafloriaNodeTemplateContext> | null`
- `nodeTemplateContext(node: any): GrafloriaNodeTemplateContext`
- `getCommentStore(): CommentStore | null` — The live comment store, when `[comments]` is enabled.
- `viewportController(): ViewportController | undefined` — The live viewport controller — the world↔screen transform this canvas is
painting with. Use it to anchor HTML overlays (floating toolbars, badges,
callouts) to world coordinates: `viewportController().worldToClient(x, y,
hostRect)` returns client pixels, and `onChange` fires on every pan/zoom so
the overlay can re-anchor. Same instance the minimap/controls plugins drive,
so overlays and plugins stay in lockstep. Returns undefined only before the
view initialises.
- `async applyLayout(request?: string | GrafloriaLayoutRequest): Promise<unknown | undefined>` — Re-run the bound layout, or run any registry layout imperatively.
- `exportDiagram(format: 'svg' | 'png' | 'jpeg' | 'webp' | 'pdf' = 'svg', options: any = {}): Promise<string>` — Async export — the full pipeline, including async custom-node capture.
- `exportSvg(options: any = {}): any` — Synchronous SVG string export.
- `exportPdf(options: any = {}): any` — Synchronous vector-PDF export.
- `snapshot(): SerializedDiagram | null` — Serialize the current diagram — feed the result back to `loadSnapshot`.
- `loadSnapshot(data: SerializedDiagram): void` — Restore a `snapshot()`-ed document by reconciling INTO the live diagram —
`applyNodes`/`applyEdges` are full reconcilers, so removals happen and the
renderer, listeners, and plugins stay attached to the same model.
- `exportText(options?: unknown): string` — Mermaid-compatible text export (lossless sidecar by default).
- `loadText(text: string, options?: unknown): unknown` — Parse Mermaid-compatible text (sidecar-aware) and reconcile it into the
live diagram — same mechanics as `loadSnapshot`.
- `get effectiveLinkToolbarActions(): LinkToolbarAction[]`
- `onLinkToolbarPointerOver(isOver: boolean): void`
- `constructor()`
- `ngAfterViewInit(): void`
- `ngOnDestroy(): void`
- `flushModelChange(): void` — Force the pending outbound emission to happen NOW (tests, imperative hosts).
- `get overlayViewBox(): string` — viewBox for the world-space overlay <svg> — identical to the renderer's.
- `get overlayStroke(): number` — Stroke width that stays 1 CSS px in a world-space overlay.
- `handleSide(handle: ToolHandle): number` — Square side of a tool handle in world units (constant on screen).
- `get overlayFontSize(): number` — Font size for overlay glyphs/labels, constant on screen.
- `toolGlyph(handle: ToolHandle): string` — Glyph drawn inside a click-tool button.
- `get toolFrameTransform(): string | null` — SVG transform that rotates the selection frame with a rotated node.
- `highlighterTransform(h: Highlighter): string | null` — Rotation transform for one highlighter box (rotated nodes).
- `pointsAttr(points?: Point[]): string` — Polyline `points` attribute for a link highlighter / focus ring.
- `spacingLabelX(segment: { x1: number; x2: number }): number` — Midpoint of a spacing segment (where its distance label is drawn).
- `spacingLabelY(segment: { y1: number; y2: number }): number`
- `undo(): Promise<void>` — Undo the last command (Ctrl/Cmd+Z).
- `redo(): Promise<void>` — Redo the last undone command (Ctrl/Cmd+Shift+Z or Ctrl+Y).
- `copySelection(): Promise<void>` — Copy the selection to the clipboard (Ctrl/Cmd+C).
- `cutSelection(): Promise<void>` — Cut the selection: clipboard + delete, as ONE undo step (Ctrl/Cmd+X).
- `deleteSelection(): Promise<void>` — Delete the selection as ONE undo step (Delete / Backspace).
- `pasteClipboard(): Promise<void>` — Paste the clipboard, dropping it under the cursor (Ctrl/Cmd+V).

PasteCommand's `offset` is a DELTA added to every pasted node's stored
position, so "paste at the cursor" = cursor − centre of the copied bbox. With
no known cursor (never moved over the canvas) we fall back to a small nudge so
repeated pastes still stack visibly instead of landing on top of the source.

The pasted link endpoints stay valid because PasteCommand re-ids every port
via remapNodePortIds() and remaps the links through that map (verified by
CutCommand.spec + ClipboardCommands.spec).
- `scheduleRender(): void` — The ONE coalescing entry point for every re-render.

Marks the canvas dirty and queues a SINGLE requestAnimationFrame. Any
number of scheduleRender() calls in the same tick collapse into one frame,
so a burst of engine events (node:changed ×N, a drag's mousemoves, several
- `getPerformanceMetrics(): { fps: number; frameTime: number; droppedFrames: number; sampleCount: number; }` — Real render-loop metrics, computed from the ring buffers
(replaces any hardcoded/estimated FPS).
- fps:          rolling frames-per-second across the last N painted frames
- frameTime:    average render duration (ms) over the same window
- droppedFrames: cumulative frames whose render blew the ~60fps budget
- sampleCount:  number of frames currently in the window
- `@HostListener('window:keyup', ['$event']) onKeyUp(event: KeyboardEvent): void` — Handle keyup to exit pan mode (Space key)
- `@HostListener('wheel', ['$event']) onWheel(event: WheelEvent): void` — Wheel: ctrl/⌘ (and trackpad pinch, which the browser reports as ctrl+wheel)
ZOOMS at the cursor; a plain wheel SCROLLS the canvas (shift → horizontal). This is the Figma/Miro/VS Code convention — the previous behaviour zoomed on
every wheel event around the viewport CENTRE, which is the "feels wrong"
competitors were compared against.
- `zoomAtClient(targetZoom: number, clientX: number, clientY: number): void` — Zoom to `targetZoom` keeping the world point under (clientX, clientY) pinned
to that same screen pixel.

DERIVATION against the center-anchored convention (see the block above
calculateActualViewport). With `s` the cursor's screen offset from the canvas'
LEFT edge, `W` the canvas px width and `c = viewport.x + W/2` the world-space
centre (which is what the viewBox is anchored on):

world(s) = c + (s − W/2) / zoom

Pinning world(s) across z0 → z1 means solving for the new centre c₁:

c₁ = world − (s − W/2) / z₁
  ⇒ viewport.x₁ = world − (s − W/2)/z₁ − W/2

(`W` does not change with zoom, so the centre is the only free variable.)
Zooming at the exact canvas centre leaves viewport.x/y untouched — which is
precisely the old behaviour, now a special case rather than the only one.
- `zoomBy(factor: number): void` — Zoom keeping the canvas centre fixed (keyboard zoom, toolbar buttons).
- `zoomIn(): void` — Ctrl/⌘ + '='
- `zoomOut(): void` — Ctrl/⌘ + '-'
- `resetZoom(): void` — Ctrl/⌘ + '0' — back to 100%, canvas centre unchanged.
- `fitToContent(padding = 40): void` — Fit every node in the diagram into view (Shift+1). Picks the largest zoom (within [minZoom, maxZoom]) at which the content's
bounding box fits inside the canvas with `padding` screen px to spare, then
centres the viewport on that box.
- `zoomToSelection(padding = 40): void` — Fit the CURRENT SELECTION into view (Shift+2); falls back to everything.
- `@HostListener('pointerdown', ['$event']) onPointerDown(event: PointerEvent): void` — Pointer events — the primary pipeline (mouse, pen AND touch), mirroring
DomEventBinder.onPointerDown/Move/Up/Cancel. Touch forks to the shared
gesture controller; mouse/pen falls through to the existing ladder
(a PointerEvent IS a MouseEvent, so the methods take it as-is).
- `@HostListener('pointermove', ['$event']) onPointerMove(event: PointerEvent): void`
- `@HostListener('pointerup', ['$event']) onPointerUp(event: PointerEvent): void`
- `@HostListener('pointercancel', ['$event']) onPointerCancel(event: PointerEvent): void`
- `@HostListener('contextmenu', ['$event']) onContextMenu(event: MouseEvent): void` — The native context menu: on touch the long-press already produced our own
gesture, so the OS menu would sit on top of the canvas mid-interaction.
- `@HostListener('mousedown', ['$event']) onCompatMouseDown(event: MouseEvent): void`
- `@HostListener('mousemove', ['$event']) onCompatMouseMove(event: MouseEvent): void`
- `@HostListener('mouseup', ['$event']) onCompatMouseUp(event: MouseEvent): void`
- `onMouseDown(event: MouseEvent): void` — Handle mouse down for panning and node selection
Supports:
- Left click: Select/drag nodes
- Ctrl + Left click: Multi-select
- Middle mouse button: Pan
- Space + Left click: Pan
- `onMouseMove(event: MouseEvent): void` — Handle mouse move for panning, node dragging, and hover
- `onMouseUp(event: MouseEvent): void` — Handle mouse up to stop panning, node dragging, and connections
- `@HostListener('mouseleave') onCompatMouseLeave(): void` — mouseleave stays UN-gated on `sawPointerEvent` (mirrors DomEventBinder: its
cleanup is idempotent and there is no pointerleave twin wired), but it must
never abort a live TOUCH gesture — the touch resize path drives the SAME
SelectionToolsController this handler cancels.
- `onMouseLeave(): void` — Handle mouse leave to stop panning and node dragging
- `@HostListener('dblclick', ['$event']) onDoubleClick(event: MouseEvent): void` — Double-click on a link.
- On a label: open an inline text editor in place.
- On the link body: insert a waypoint at the double-clicked point.
- `worldToScreen(worldX: number, worldY: number): { screenX: number; screenY: number }` — Inverse of {@link clientToWorld}: world → canvas-local screen px. Exposed (public) because it is the invariant the cursor-anchored zoom is
defined by, and the zoom tests assert on it.
- `@HostListener('window:keydown', ['$event']) onKeyDown(event: KeyboardEvent): void` — Handle keyboard events (Option 1: Node Interaction)
- Space: Pan mode cursor
- Delete/Backspace: Delete selection (undoable)
- Escape: Clear selection
- Ctrl+A: Select all

- Ctrl/⌘+Z undo, Ctrl/⌘+Shift+Z or Ctrl+Y redo
- Ctrl/⌘+X / +C / +V cut / copy / paste-at-cursor
- Ctrl/⌘ +'=' / '-' / '0' zoom in / out / reset; Shift+1 fit, Shift+2 fit selection
- `getAbsoluteX(node: any): number` — Get absolute X position for a node (including parent offset and transforms)
CRITICAL FIX: Use getWorldPosition() for simple cases, getGlobalPosition() for transforms
This properly handles rotation, scale, and nested hierarchies
- `getAbsoluteY(node: any): number` — Get absolute Y position for a node (including parent offset and transforms)
CRITICAL FIX: Use getWorldPosition() for simple cases, getGlobalPosition() for transforms
This properly handles rotation, scale, and nested hierarchies
- `getNodeX(node: any): number` — Get node X position for HTML rendering — in WORLD units.

The HTML layer's transform is
`translate(−viewBox.origin·zoom) scale(zoom)`, so a child positioned at its
world coordinate lands at (world − origin)·zoom — byte-for-byte the SVG map. The old `/ zoom` cancelled the layer's scale, which pinned HTML nodes to a
zoom-independent offset while the SVG around them scaled: the desync.
- `getNodeY(node: any): number` — Get node Y position for HTML rendering — in WORLD units (see getNodeX).
- `shouldRenderPort(port: PortModel, node: NodeModel): boolean` — Check if a port should be rendered as an HTML handle
Respects port visibility settings and template configuration
- `getPortPosition(port: PortModel, node: NodeModel, axis: 'top' | 'left'): string` — Get port position CSS value for top or left
CRITICAL FIX: Use shape-aware positioning from getPortPositionForShape()
This ensures HTML ports align with SVG ports for all shape types

**Example**

```html
<!-- uncontrolled -->
<grafloria-diagram-canvas [engine]="engine" [(zoom)]="zoom" />

<!-- controlled -->
<grafloria-diagram-canvas
  [(nodes)]="nodes"
  [(edges)]="edges"
  (modelChange)="persist($event)" />
```
