Skip to content
D
Documentation

SVGRenderer

reference
9 min readUpdated

Import it from @grafloria/renderer.

ts
class SVGRenderer implements IRenderer

Properties

NameTypeDefaultDescription
modeRenderer mode
capabilitiesRendererCapabilitiesWhat 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.

Was this page helpful?

SVGRenderer — Grafloria