Skip to content
D
Documentation

Svg — functions a–p

reference
10 min readUpdated

Functions A–P

Import these from @grafloria/renderer.

Functions

applySpread

Slide an attachment point along the port's EDGE (its tangent) by one lane.

Along the tangent, deliberately — sliding along the NORMAL would push the endpoint off the node and leave a visible gap between the link and the port it is supposed to be touching.

ts
function applySpread(
  point: { x: number; y: number },
  side: Side,
  laneOffset: number
): { x: number; y: number }

assignSpreadLanes

The lane assignment for every link on one port: linkId → offset.

Lanes are ordered by a STABLE key (the id of the link's other endpoint, then the link's own id) rather than by insertion order, so the fan doesn't reshuffle itself every time an unrelated link is added or the diagram is reloaded.

ts
function assignSpreadLanes(
  entries: Array<{ linkId: string; sortKey: string }>,
  spec: PortSpreadSpec | undefined
): Map<string, number>

autoSizeDiagram

Auto-size every opted-in node in a diagram. Returns the count that changed — a host can skip a re-route when it is 0. Intended to run just before a frame so routing consumes the settled bounds.

ts
function autoSizeDiagram(
  nodes: Iterable<NodeModel>,
  opts: AutoSizeOptions = {}
): number

autoSizeNode

Auto-size ONE node in place. No-op (returns false) when the node hasn't opted in, or is already within half a pixel of its desired size — the idempotence that keeps this safe to call every frame. Mutates strictly via setSize, so the spatial index + routing observe the new bounds.

ts
function autoSizeNode(node: NodeModel, opts: AutoSizeOptions = {}): boolean

buildHtmlForeignObject

Build the <foreignObject> body VNode for an HTML node, sized to the node. Returns null when the node has no HTML content. The wrapper div is pointer-events: none unless the content opts into interactivity, so the shape background beneath keeps receiving selection / drag hits.

ts
function buildHtmlForeignObject(
  node: NodeModel,
  width: number,
  height: number
): VNode | null

buildPathShapeDefinition

BUILD a path shape's definition without storing it anywhere.

Extracted from {@link registerPathShape} so a PER-DIAGRAM registry can offer registerPathShape too. Everything expensive and everything subtle — path parsing, the one-slot outline sample memo, the derived boundary and port anchors — lives here, so the global and scoped paths cannot drift into producing different geometry for the same path.

ts
function buildPathShapeDefinition(
  type: string,
  path: PathGeometry,
  opts: PathShapeOptions = {}
): ShapeDefinition

buildSelfLoopPoints

Route a self-loop as a polyline: out of the source port, around, back into the target port. EVERY segment is axis-aligned, which is what lets the three path emitters each do the right thing with the same points — rounded rectangle for orthogonal, a smooth closed-looking curve for smooth/bezier, a hard polygon for direct.

Three cases, and they cover every port pairing:

A. SAME side (incl. the same port twice — the common case). The loop bulges straight out and spans laterally. When the two attachment points coincide (or sit closer together than width) they are SPREAD apart along the side by width, clamped to the node's own span: a loop whose feet are the same point has zero area and cannot be drawn at all.

B. PERPENDICULAR sides (right → top, …). Out of the source, one corner, into the target. An L that wraps the node's corner.

C. OPPOSITE sides (left → right, …). Out of the source, over/around the node body in a lane size clear of it, and back in the far side.

ts
function buildSelfLoopPoints(spec: SelfLoopSpec): FanoutPoint[]

buildShapeBody

ts
function buildShapeBody(
  def: ShapeDefinition,
  width: number,
  height: number,
  cornerRadius: number | undefined,
  // `any` (not Record<string, any>) so the inline `style` string below is
  // accepted — matches the original render helpers' loosely-typed styles arg.
  styles: any
): VNode

buildShapeSelection

Selection-highlight VNode: the outline grown by padding, plus baseProps. radius is the corner of the GROWN outline — the renderer passes the node's own corner + padding, so the ring stays concentric with a rounded card; 6 is the corner a node that declares none has always had.

ts
function buildShapeSelection(
  def: ShapeDefinition,
  width: number,
  height: number,
  padding: number,
  baseProps: Record<string, any>,
  radius = 6
): VNode

buildShapeShadow

Drop-shadow VNode: the outline offset by (offset, offset), plus baseProps.

ts
function buildShapeShadow(
  def: ShapeDefinition,
  width: number,
  height: number,
  offset: number,
  borderRadius: number,
  baseProps: Record<string, any>
): VNode

bundleNormal

The unit LEFT normal of a → b, i.e. the axis a parallel bundle fans along.

The caller must derive a/b from the bundle's CANONICAL node order (e.g. lower node id first), not from each link's own source → target. Otherwise the two halves of a bidirectional pair compute opposite normals, their opposite lane offsets cancel out, and both links land back on top of each other — the exact bug the card exists to fix.

Degenerate input (coincident points) falls back to "up", so a bundle between two concentric nodes still fans instead of collapsing to NaN.

ts
function bundleNormal(a: FanoutPoint, b: FanoutPoint): FanoutPoint

clampSizeToConstraints

Clamp a candidate width/height to a node's sizing constraints. The per-node min/max win; a global floor (the resizer's minWidth/minHeight) fills in when the node sets none. Used identically by the resizer and the auto-sizer, so both cannot disagree about the legal range.

ts
function clampSizeToConstraints(
  width: number,
  height: number,
  sizing: NodeSizing,
  opts: ClampOptions = {}
): { width: number; height: number }

clampValue

Clamp a scalar into [min, max], tolerating an inverted or absent bound.

ts
function clampValue(value: number, min?: number, max?: number): number

clearEdgeTemplates

Drop every registration (tests, hosts tearing a document down).

ts
function clearEdgeTemplates(): void

coalesce

Merge dirty rects that overlap or nearly touch, so a drag of one node does not fire N separate spatial queries. Cheap and approximate: one pass, absorbing into the first box that already covers the candidate's neighbourhood. The result is a SUPERSET of the input regions, which is the safe direction — it can only invalidate more links, never fewer.

ts
function coalesce(rects: Rect[], maxBoxes = 16): Rect[]

compensateForRotation

Rotate point about the node's centre by -degrees, so a port on a rotated node keeps its WORLD-space arrangement (a compensateRotation group on a node spun 90° still shows its inputs on the left of the screen).

Node-local in, node-local out: the node's own transform then rotates it BACK, and the two cancel.

ts
function compensateForRotation(
  point: { x: number; y: number },
  width: number,
  height: number,
  degrees: number
): { x: number; y: number }

defaultInnerRect

Padded-bbox label box: inset by up to 8px, clamped so it never inverts.

ts
function defaultInnerRect(width: number, height: number): InnerRect

desiredNodeSize

Compute the desired OUTER size of an auto-sized node from its label + reserved panel content, clamped to the node's sizing constraints and any aspect lock. Pure — returns the size, mutates nothing.

ts
function desiredNodeSize(
  node: NodeModel,
  opts: AutoSizeOptions = {}
): { width: number; height: number }

estimateTextWidth

Average-glyph width estimate. See the module header for the rationale.

ts
function estimateTextWidth(text: string, fontSize: number): number

fitCmdsToBox

Rescale a parsed static path from its viewBox reference frame into a width × height box anchored at the origin. Degenerate viewBox dimensions fall back to 1 so a zero-width authoring box can't divide by zero.

ts
function fitCmdsToBox(
  cmds: PathCmd[],
  viewBox: PathViewBox,
  width: number,
  height: number
): PathCmd[]

fitFontSize

ts
function fitFontSize(text: string, maxWidth: number, base: number): number

getEdgeTemplateVersion

Bumped on every mutation — renderers key cache invalidation off this.

ts
function getEdgeTemplateVersion(): number

getHtmlContent

Read a node's HTML body spec, or null when it has none.

ts
function getHtmlContent(node: NodeModel): HtmlNodeContent | null

getInnerRect

The label box for a shape in local coords. Falls back to a padded bounding box when the shape doesn't override innerRect.

ts
function getInnerRect(def: ShapeDefinition, width: number, height: number): InnerRect

getLabelTemplate

ts
function getLabelTemplate(name: string): LabelTemplate | undefined

getLinkTemplate

ts
function getLinkTemplate(name: string): LinkTemplate | undefined

getMarker

ts
function getMarker(name: string): MarkerDefinition | undefined

getNodePanel

Read a node's panel spec, or null when it has none.

ts
function getNodePanel(node: NodeModel): PanelSpec | null

getNodeSizing

Read a node's sizing config (never null — an absent config is {}).

ts
function getNodeSizing(node: NodeModel): NodeSizing

getNodeToolbar

Read a node's own toolbar config from metadata, or undefined.

ts
function getNodeToolbar(node: NodeModel): NodeToolbarConfig | undefined

getPortLayout

ts
function getPortLayout(name: string | undefined): PortLayoutStrategy

getPortPositionForShape

Calculate port position based on node shape Returns position relative to node's local coordinate system (0,0 = top-left)

Shape-aware port positioning

  • Rectangle: ports spread evenly along the edge (single port = midpoint)
  • Circle/Ellipse: ports on the perimeter, fanned symmetrically per side
  • Diamond: ports at vertices
  • Hexagon: ports spread along the flat edges

Multiple ports on the same side are distributed by their rank among that side's ports (ordered by index, then declaration order) so they never stack on the same point.

ts
function getPortPositionForShape(
  port: PortModel,
  node: NodeModel
): { x: number; y: number }

getShape

The rect shape is the default fallback for unknown / unset shape types.

Resolution order is DIAGRAM-FIRST, then process-global. A diagram that contributed its own badge sees its own; every other diagram — and this one, for every name it did not claim — still sees the global registry. See ext/registry-scope.ts for why the lookup is ambient rather than threaded.

ts
function getShape(type: string | undefined): ShapeDefinition

getShapeDefinition

Snapshot the current catalogue, so a caller can restore it later. Used by the ExtensionHost to give a scoped extension an exact "undo" even when it OVERWROTE an existing shape rather than adding a new one.

ts
function getShapeDefinition(type: string): ShapeDefinition | undefined

getShapeRegistryVersion

Monotonic counter, incremented on every add/remove. Cheap cache key. NOTE: registerShape itself bumps it — see the wrapper installed below.

ts
function getShapeRegistryVersion(): number

glyphHalfExtents

Half-extents of the glyph box, honouring size/width/height with radius fallback.

ts
function glyphHalfExtents(
  shape: PortShapeSpec | undefined,
  radius: number
): { hw: number; hh: number }

haloAllows

Whether a Halo action is enabled: undefined → def, false → never, true → always, an array → membership.

ts
function haloAllows(
  config: NodeToolbarConfig,
  action: ToolbarHaloAction,
  def = true
): boolean

hashString

FNV-1a — small, fast, stable across runs. Only used to key VNodes.

ts
function hashString(value: string): string

hasHtmlContent

True when the node renders an HTML body.

ts
function hasHtmlContent(node: NodeModel): boolean

hasMarker

ts
function hasMarker(name: string): boolean

hasPanel

True when the node carries a composite panel.

ts
function hasPanel(node: NodeModel): boolean

hasPortLayout

ts
function hasPortLayout(name: string): boolean

hasShape

Whether a shape type is registered (excludes the implicit rect fallback).

ts
function hasShape(type: string): boolean

Part-aware link hit-test.

Precedence (highest first): endpoint / arrow handles (small grab radius) > labels (bounding box) > body (near the path). Within the handle tier the nearest handle wins, tie-broken by declaration order (source-endpoint, target-endpoint, source-arrow, target-arrow).

ts
function hitTestLink(
  options: LinkHitTestOptions,
  query: Point,
  tolerance: number
): LinkHitResult | null

Parameters

  • options: the link geometry to test against
  • query: the world-space point to test
  • tolerance: grab distance for the body (and label box padding)

Returns the hit part with local info, or null when nothing is within reach

htmlLabelVNode

A foreignObject VNode carrying arbitrary HTML, positioned so anchor is its CENTRE (labels are centred on the path, unlike nodes which are top-left).

THE KEY CARRIES A CONTENT HASH, and that is load-bearing. The VNode patcher treats foreignObject subtrees as OPAQUE: it patches their props but NEVER diffs into their children, so that whatever a framework mounted inside stays alive. That is exactly right for host-mounted content — and exactly wrong for content we own, because an edited label would keep rendering its old HTML forever. Folding the content into the key means changed HTML is a DIFFERENT VNode identity, which the patcher replaces wholesale. Unchanged HTML keeps the same key and the same live DOM.

ts
function htmlLabelVNode(options: HtmlLabelOptions): VNode

inflate

Grow a rect by pad on every side.

ts
function inflate(r: Rect, pad: number): Rect

isAutoSized

True when the node opts into content-aware auto-sizing.

ts
function isAutoSized(node: NodeModel): boolean

linkBodyHitTolerance

The grab distance for a link BODY — the one number the painted invitation and the accepted press must both derive from.

The SVG renderer paints a transparent hit-area stroke linkHitAreaWidth wide, but the interaction layer used to accept only the flat 5px floor: the ring between them was DEAD — the DOM caught the pointer (cursor, native focus — the "rectangle around the line" report) while the press selected nothing. Every consumer of "how close is close enough to a link" now calls this: the interaction controller (SVG mode), the canvas pick-buffer stroke, and the hit-area painter (at exactly 2 x this, minus slop).

literalStrokeWidth must be the resolved numeric width (fall back to 2 for theme-bound var(--…) widths, matching the renderer's own literal fallback).

ts
function linkBodyHitTolerance(
  literalStrokeWidth: number,
  configWidth: number = DEFAULT_LINK_HIT_AREA_WIDTH
): number

linkHitAreaWidth

Width of the invisible "interaction stroke" painted along a link.

Sized from the LITERAL stroke width, never a var(--…) expression — the renderer resolves its literals before calling this. The + 8 keeps a grab margin around fat strokes (a 10px casing would otherwise be pixel-hunting).

ts
function linkHitAreaWidth(
  literalStrokeWidth: number,
  configWidth: number = DEFAULT_LINK_HIT_AREA_WIDTH
): number

listLabelTemplates

ts
function listLabelTemplates(): string[]

listLinkTemplates

ts
function listLinkTemplates(): string[]

listMarkers

ts
function listMarkers(): string[]

listShapes

All registered shape type names (built-ins + extended library + aliases).

ts
function listShapes(): string[]

mapPathCmds

Map every coordinate in a command list through fn (structure preserved).

ts
function mapPathCmds(cmds: PathCmd[], fn: (x: number, y: number) => Point): PathCmd[]

markerTipOffset

Resolve a registered marker's tip offset for a concrete style.

ts
function markerTipOffset(definition: MarkerDefinition, style: ArrowStyle): number

measureLabelContent

The content box a label occupies: the widest wrapped line × the line count. Uses the same estimateTextWidth heuristic the label renderer wraps with, so the measured box and the rendered text agree about where lines break.

ts
function measureLabelContent(text: string, opts: MeasureLabelOptions = {}): ContentSize

measurePanelReserve

Extra content space a panel reserves so content-aware auto-sizing grows the shape to fit the WHOLE body. top = header + image stacked above the label; bottom = the stacked rows below it; width = the widest text the panel must show.

ts
function measurePanelReserve(
  node: NodeModel
): { top?: number; bottom?: number; width?: number } | undefined

notifyEdgeTemplatesChanged

Internal: let a PER-DIAGRAM registry participate in the version/notify protocol. A scoped registration must invalidate cached VNodes for exactly the reason a global one must — the definition is baked into the cache. Notifying every renderer (not just the contributing one) is deliberate: over-invalidation costs a repaint, under-invalidation shows a stale picture.

ts
function notifyEdgeTemplatesChanged(): void

notifyShapeRegistered

Internal: let registerShape participate in the version/notify protocol.

ts
function notifyShapeRegistered(): void

nudgePortLabels

Collision-aware nudging: when several port labels crowd, push them apart along the axis they stack on.

Deliberately a ONE-AXIS resolver over labels that share a side. That is the shape the crowding actually has — a column of inputs down the left edge whose labels overlap vertically — and a general 2-D label-placement solver would be both slower and less predictable (labels would visibly hop sideways as you dragged a node). Returns a per-label nudge, so the caller can decide whether to apply it.

heights are the labels' rendered heights, centres their unnudged centre coordinates on the stacking axis, both in the SAME order.

ts
function nudgePortLabels(centres: number[], heights: number[], gap = 2): number[]

onEdgeTemplateChange

Subscribe to registry changes. Renderers use this to drop cached link VNodes when a template is (re)defined — the template's OUTPUT is baked into the cached VNode, so a redefinition that did not invalidate would never show up.

ts
function onEdgeTemplateChange(listener: () => void): () => void

onShapeRegistryChange

Subscribe to catalogue changes. Returns a disposer.

ts
function onShapeRegistryChange(listener: () => void): () => void

outerSizeForInner

Invert a shape's innerRect: the OUTER width/height whose label box is at least contentW × contentH. innerRect is a monotone function of the outer size (fractional insets for curved shapes, additive padding for the rect default), so a few fixed-point steps converge — one step for the fractional shapes, a handful for additive padding.

ts
function outerSizeForInner(
  def: ShapeDefinition,
  contentW: number,
  contentH: number,
  seed?: { width: number; height: number }
): { width: number; height: number }

overlapArea

ts
function overlapArea(a: OptimizerRect, b: OptimizerRect): number

panelAdjustedInnerRect

The body inner rect available to the node LABEL once the panel's header/image (top) and rows (bottom) have taken their space. Keeps the label from overlapping the panel bands. Given the shape's own inner rect.

ts
function panelAdjustedInnerRect(
  node: NodeModel,
  inner: { x: number; y: number; w: number; h: number },
  width: number,
  height: number
): { x: number; y: number; w: number; h: number }

parallelOffsets

The signed lane offsets for a bundle of count parallel links, spaced spacing apart and centred on the un-separated route.

count 1 → [0] (a lone link NEVER moves — this is what keeps every existing diagram pixel-identical) count 2 → [-s/2, +s/2] count 3 → [-s, 0, +s]

Centred rather than one-sided so a bundle stays visually anchored on the line the single link used to occupy: adding a second relationship between two entities nudges both apart instead of shunting the diagram sideways.

ts
function parallelOffsets(count: number, spacing = DEFAULT_PARALLEL_SPACING): number[]

Was this page helpful?

Svg — functions a–p — Grafloria