# SVGRenderer

Import it from `@grafloria/renderer`.

```ts
class SVGRenderer implements IRenderer
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` |  |  | Renderer mode |
| `capabilities` | `RendererCapabilities` |  | What this renderer can actually do — so callers can ask instead of assuming. `supportsExport` is now TRUE (see `export()` below); hit-testing and text measurement still are not implemented here, and saying so is the point. |

**Methods**

- `getRegistry(): DiagramRegistry` — Register shapes / styles / markers / link-pipeline stages for THIS renderer
alone. The module-level `registerShape()` & friends remain the process-wide
registry, and this one shadows it — see `ext/diagram-registry.ts`.
- `constructor( private engine: DiagramEngine, config: SVGRendererConfig = {}, theme?: Theme )`
- `render(viewport: Rectangle, zoom: number): VNode` — Render diagram to VNode tree.

THE REGISTRY SCOPE IS ACTIVATED HERE, around the whole pass, and this is the
only place it needs to be for the picture to be right. Every contribution
registry the frame consults — shapes, named styles, link/label templates,
markers, anchors, connection points, connectors — resolves this diagram's
own table first and the process-global one second, including from the read
sites that are pure free functions three frames down (`shapeStrategy` in
port-layout, `resolveNodeStyle` in the style cascade) and could not have been
handed an instance without rewriting their signatures. See
`ext/registry-scope.ts`.

A diagram that contributed nothing activates nothing, so the single-diagram
path is byte-identical to what it was.
- `getQualityState(): { tier: LODLevel; governor?: GovernorState }` — The tier actually rendered last frame, and the governor's reasoning for it.

Exposed because an invisible governor is indistinguishable from a bug: if the
picture silently simplifies, the only honest thing to do is be able to say WHY. The perf HUD reads this; so can an application that wants to tell the user "this
diagram is being drawn at reduced detail to stay responsive".
- `invalidateFrame(): void` — Drop the cached frame. Call from anything whose effect on the picture the
mutation epoch cannot see — a topology event, a style/theme invalidation, a
registry swap. Cheap and idempotent: the cost of calling it when you did not
need to is one rebuilt frame; the cost of NOT calling it when you did is a
stale picture, so when in doubt, call it.
- `getInvalidationEpoch(): number` — How many times this renderer has been told "the picture you have is no longer
the picture you would draw".

A HOST'S idle-skip must consult this, not just the model's mutation epoch. The two are not the same, and the gap between them is a real bug: when the
off-thread route solver answers, the MODEL has not changed — the epoch does
not move — but the renderer's own answer about it has improved. A scheduler
keyed only on the model would drop that repaint before `render()` was ever
called, and the refined routes would never reach the screen. The renderer's
internal gate cannot save you there; it never gets asked.

So: model epoch says "did the world change", this says "did MY picture of it
change". Skip a frame only when both say no.
- `getFrameCoverage(): FrameCoverage | null` — The {@link FrameCoverage} of the most recent `render()` pass.

CAPTURE IT IMMEDIATELY after the render() call whose frame you patched into
the DOM — do not hold the renderer and ask later. Exports run through the
same render pass with their own viewport (see exportSvg callers), so this
field describes whatever rendered LAST, which is not necessarily what is on
screen. `createDiagram`'s paint() takes its own copy for exactly this
reason.
- `getFrameStats(): { built: number; skipped: number }` — The incrementality is only real if these move. `framesSkipped`
counts frames served from the previous root (zero DOM work); `framesBuilt`
counts frames actually walked. An idle canvas should build ONE.
- `getTheme(): Theme` — Get current theme
- `setHighlightConnected(value: boolean | HighlightConnectedOptions | undefined): void` — Switch `highlightConnected` live: `false` turns it off, `true` takes the
defaults, an object tunes it. The next frame redraws every line.
- `getHighlightConnected(): boolean | HighlightConnectedOptions`
- `getLineOverlay(): VNode | null` — The lifted lines, as an `<svg>` in WORLD coordinates for the overlay the
instance keeps at the end of the HTML layer — above SVG nodes and HTML
custom nodes alike, moved by the same camera transform. `null` when
`highlightConnected` is off; an empty `<svg>` when nothing is lifted.
- `setTheme(theme: Theme): void`
- `applyThemeVariables(theme: Theme): void` — THE HOT-SWAP. Re-theme by rewriting this instance's `--grafloria-*` variables.

In CSS mode every value the built-in stylesheet paints resolves through those
variables, and every `themeRef`-bound property that lands in an inline CSS
style string is emitted as `var(--grafloria-…)`. So for all of them, *this one
string write IS the re-theme*: no VNode is rebuilt, no element is touched,
the browser simply recomputes.

What it cannot cover, it does not pretend to: the entities recorded in
`themeBoundNodes` / `themeBoundLinks` (see the field docs) baked a theme
literal into their VNode, so they — and only they — are marked dirty and
re-resolve on the next frame. An idle diagram re-themes with zero restyles;
a diagram with three selected nodes restyles three nodes.

Programmatic (Canvas) mode has no stylesheet at all, so there is nothing to
rebind and the full invalidation is the only correct answer.
- `getColorMode(): ColorMode | undefined` — The colorMode in force, or undefined when the host never asked for one.
- `setColorMode(mode: ColorMode, themes?: ThemeSet): void` — Switch colour mode at runtime. `'system'` starts following the OS; the other
two pin it. Creates the media-query subscription on first use, so a host can
opt in after construction.
- `setTokenBridge(bridge: TokenBridge | null | undefined): void` — Point this diagram's variables at the host design system's tokens
(`shadcnBridge()`, `muiBridge()`, `tailwindBridge()`, or a hand-written map). `null` removes the bridge.

Pure CSS: the values are the host's own `var(--…)` expressions, so when the
host app flips ITS theme, this diagram follows with no code at all — and no
VNode is rebuilt here either, for exactly the reason the hot-swap works.
- `getTokenBridge(): TokenBridge | undefined` — The bridge currently applied, if any.
- `getInstanceId(): string` — This renderer's instance id (`grafloria-3`). It is the value of the
`data-grafloria-instance` attribute on the root `<svg>`, the scope of this
diagram's CSS variables, and the suffix of its `<style>` element id.
- `getStyleElementId(): string` — Id of the `<style>` element holding THIS renderer's theme variables.
- `getOverrideElementId(): string` — Id of the `<style>` element holding THIS renderer's bridge + a11y overrides.
- `getStyleSheet(): string` — The complete stylesheet this renderer would inject into `<head>`: the shared
theme-independent rules, the animation rules, and THIS instance's
`--grafloria-*` variable block.

Needed by the SSR path. In CSS mode the theme lives entirely in CSS
variables, so the emitted SVG is theme-INDEPENDENT — which is exactly what
makes hydration cheap, but it also means a server-rendered diagram is
unstyled until some stylesheet arrives. `renderToStaticSVG()` returns this
string so the server can ship it in a `<style>` tag; the client renderer then
re-injects byte-identical content under the same ids, so hydration is still a
no-op visually.
- `applyInstanceScope(element: Element | null | undefined): void` — Put this diagram's scope on a host element.

The root `<svg>` carries it automatically, and `foreignObject` content
inherits from it (it lives inside the SVG). Nodes rendered on an HTML LAYER
(`metadata.useHTMLLayer`) do NOT — they are siblings of the SVG. Call this
with the element that wraps BOTH layers (the canvas host) so those nodes
inherit the `--grafloria-*` variables and match the scoped rules too.
- `setViewLifecycle(lifecycle: ViewLifecycle | null): void` — Install the freeze / lazy-mount gate.

With no lifecycle installed the renderer behaves exactly as it always has —
every entity culling admits gets a view, on the frame it is admitted. That is
the default, and it stays the default: laziness is something a host asks for.
- `getViewLifecycle(): ViewLifecycle | null`
- `getDeferredEntities(): ReadonlyArray<readonly [LazyEntityKind, string]>` — What culling admitted on the last frame and the gate held back.

This is the progressive mounter's work queue, and it comes from the renderer
rather than being recomputed by the mounter because the viewport→viewBox→cull
maths (zoom, link margin) lives here and must not be reimplemented anywhere it
could drift.
- `getThemeBoundEntityCount(): number` — How many entities the NEXT theme swap would have to restyle. Zero means the
swap is a pure variable rebind. Exposed because "no restyle of every VNode"
is a claim that should be measurable, not taken on trust — the tests assert
on it, and so can a host.
- `getPerformanceMetrics(): PerformanceMetrics` — Get performance metrics
- `async export(format: ExportFormat = 'svg', options: ExportOptions = {}): Promise<string>` — Export the diagram.

- `'svg'` → a STANDALONE, styles-inlined SVG document. Pure, deterministic,
  and DOM-free: it runs in plain Node (an SSR pass, a thumbnail worker).
- `'png' | 'jpeg' | 'webp'` → a `data:` URL. Needs an SVG rasterizer: a
  canvas one is used automatically in a browser/worker; in bare Node you must
  pass `options.rasterBackend` (and you get a clear error if you don't).

Defaults to the whole diagram (content bounds + 20px), not the current
viewport — a thumbnail of the visible slice is rarely what anyone means.
- `exportSvgString(options: ExportOptions = {}): SvgExportResult` — The synchronous, fully headless SVG path — what `export('svg')` returns, plus
the fidelity `warnings` (foreignObject, unresolved theme vars) that the
string-only `IRenderer.export` signature has nowhere to put.
- `exportPdf(options: ExportOptions = {}): PdfExportResult` — A TRUE VECTOR PDF: paths stay paths and text stays text, so it is selectable,
searchable and scales without pixelation.

Painted straight from the VNode tree — the same tree the screen gets — so there is no
SVG→DOM→PDF round trip and no second rendering path. See `export/pdf/` for the
dependency decision (we do NOT use svg2pdf.js + jsPDF, and why) and for the honest
list of what a base-14-font PDF cannot do.
- `exportPages(pagination: PaginationOptions, options: ExportOptions = {}): PagedSvgResult` — Slice the diagram into pages.

Returns one standalone SVG per page — a tile grid you can print, or lay out as a poster. Breaks are snapped so they do not cut a node in half; see `export/pagination.ts`.
- `exportPaginatedPdf(pagination: PaginationOptions, options: ExportOptions = {}): PdfExportResult` — A multi-page PDF of a diagram too big for one sheet.

The paginator supplies the page grid; the PDF writer honours each page's `clip`, so a
break pulled back to spare a node leaves white space rather than half a box.
- `collectExportImageUrls(options: ExportOptions = {}): string[]` — Every EXTERNAL image URL the exported tree will reference — a panel node's
avatar/logo/icon (`<image href="https://…">` painted by the renderer itself), or
any registered shape that emits one. Widget (HTML-layer) captures are NOT in this
tree; their URLs are collected from the captures by the instance layer.

This is the LOOK-BEFORE-YOU-EXPORT half of the async image pass: `await export(…)`
calls this first, fetches the URLs through the tiers, and hands the resolved map
back down as {@link ExportOptions.resolvedAssets} for the pure sync substitution. The tree is enumerated from the SAME `render()` the export will serialize (VNode
caching makes the second call cheap), so what is collected and what is substituted
cannot drift. Synchronous and network-free.
- `getRoutingStats(): { routed: number; reused: number; cached: number }` — How many links this frame actually had to route, and how
many were served from the previous frame.

Public because a cache that silently stops hitting is indistinguishable from
no cache at all — it just gets slow again, and nothing says so. Tests assert
on this; the benchmark harness prints it.
- `dispose(): void` — Dispose renderer and clean up resources
- `getRouteSolverStats(): RouteSolverStats | null` — What the off-thread solver has been doing.
- `setAccessibleFocus(target: { type: 'node' | 'link' | 'comment'; id: string } | null): void` — Tell the renderer which entity the keyboard controller has focused, so the
next frame emits `tabindex=0` on it (and `-1` on everything else).

Marks only the OLD and NEW focus targets dirty — moving focus must not
invalidate the whole diagram.
- `getAccessibleFocus(): { type: 'node' | 'link' | 'comment'; id: string } | null`
- `setCommentSource(source: CommentSource | null): void` — Attach (or detach) the source of comment pins.

Installing a source CHANGES THE PICTURE while moving nothing the frame gate watches —
not the model epoch, not the viewport. Without the explicit invalidation the gate would
keep serving back the last frame, which has no pins in it, forever. This is the trap
this wave was warned about, and it is one line.
- `getCommentSource(): CommentSource | null`
- `getContainerId(nodeId: string): string | undefined` — Get container ID for a node (if it uses foreignObject)
- `isUsingForeignObject(nodeId: string): boolean` — Check if a node uses foreignObject rendering
- `getAnimationService(): AnimationService` — The animation service — global animation enable/speed, reduced-motion and
battery-saver policy. Public because these are HOST decisions: the service
existed with a full config surface and no accessor, so nothing outside
this class could e.g. opt out of the battery auto-toggle
(`respectBatteryStatus: false`) — a laptop under 20% silently killed every
edge animation with no way back.
