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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction buildSelfLoopPoints(spec: SelfLoopSpec): FanoutPoint[]
buildShapeBody
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction clampValue(value: number, min?: number, max?: number): number
clearEdgeTemplates
Drop every registration (tests, hosts tearing a document down).
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction desiredNodeSize(
node: NodeModel,
opts: AutoSizeOptions = {}
): { width: number; height: number }
estimateTextWidth
Average-glyph width estimate. See the module header for the rationale.
tsfunction 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.
tsfunction fitCmdsToBox(
cmds: PathCmd[],
viewBox: PathViewBox,
width: number,
height: number
): PathCmd[]
fitFontSize
tsfunction fitFontSize(text: string, maxWidth: number, base: number): number
getEdgeTemplateVersion
Bumped on every mutation — renderers key cache invalidation off this.
tsfunction getEdgeTemplateVersion(): number
getHtmlContent
Read a node's HTML body spec, or null when it has none.
tsfunction 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.
tsfunction getInnerRect(def: ShapeDefinition, width: number, height: number): InnerRect
getLabelTemplate
tsfunction getLabelTemplate(name: string): LabelTemplate | undefined
getLinkTemplate
tsfunction getLinkTemplate(name: string): LinkTemplate | undefined
getMarker
tsfunction getMarker(name: string): MarkerDefinition | undefined
getNodePanel
Read a node's panel spec, or null when it has none.
tsfunction getNodePanel(node: NodeModel): PanelSpec | null
getNodeSizing
Read a node's sizing config (never null — an absent config is {}).
tsfunction getNodeSizing(node: NodeModel): NodeSizing
getNodeToolbar
Read a node's own toolbar config from metadata, or undefined.
tsfunction getNodeToolbar(node: NodeModel): NodeToolbarConfig | undefined
getPortLayout
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction getShapeRegistryVersion(): number
glyphHalfExtents
Half-extents of the glyph box, honouring size/width/height with radius fallback.
tsfunction 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.
tsfunction haloAllows(
config: NodeToolbarConfig,
action: ToolbarHaloAction,
def = true
): boolean
hashString
FNV-1a — small, fast, stable across runs. Only used to key VNodes.
tsfunction hashString(value: string): string
hasHtmlContent
True when the node renders an HTML body.
tsfunction hasHtmlContent(node: NodeModel): boolean
hasMarker
tsfunction hasMarker(name: string): boolean
hasPanel
True when the node carries a composite panel.
tsfunction hasPanel(node: NodeModel): boolean
hasPortLayout
tsfunction hasPortLayout(name: string): boolean
hasShape
Whether a shape type is registered (excludes the implicit rect fallback).
tsfunction hasShape(type: string): boolean
hitTestLink
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).
tsfunction hitTestLink(
options: LinkHitTestOptions,
query: Point,
tolerance: number
): LinkHitResult | null
Parameters
options: the link geometry to test againstquery: the world-space point to testtolerance: 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.
tsfunction htmlLabelVNode(options: HtmlLabelOptions): VNode
inflate
Grow a rect by pad on every side.
tsfunction inflate(r: Rect, pad: number): Rect
isAutoSized
True when the node opts into content-aware auto-sizing.
tsfunction 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).
tsfunction 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).
tsfunction linkHitAreaWidth(
literalStrokeWidth: number,
configWidth: number = DEFAULT_LINK_HIT_AREA_WIDTH
): number
listLabelTemplates
tsfunction listLabelTemplates(): string[]
listLinkTemplates
tsfunction listLinkTemplates(): string[]
listMarkers
tsfunction listMarkers(): string[]
listShapes
All registered shape type names (built-ins + extended library + aliases).
tsfunction listShapes(): string[]
mapPathCmds
Map every coordinate in a command list through fn (structure preserved).
tsfunction mapPathCmds(cmds: PathCmd[], fn: (x: number, y: number) => Point): PathCmd[]
markerTipOffset
Resolve a registered marker's tip offset for a concrete style.
tsfunction 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.
tsfunction 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.
tsfunction 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.
tsfunction notifyEdgeTemplatesChanged(): void
notifyShapeRegistered
Internal: let registerShape participate in the version/notify protocol.
tsfunction 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.
tsfunction 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.
tsfunction onEdgeTemplateChange(listener: () => void): () => void
onShapeRegistryChange
Subscribe to catalogue changes. Returns a disposer.
tsfunction 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.
tsfunction outerSizeForInner(
def: ShapeDefinition,
contentW: number,
contentH: number,
seed?: { width: number; height: number }
): { width: number; height: number }
overlapArea
tsfunction 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.
tsfunction 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.
tsfunction parallelOffsets(count: number, spacing = DEFAULT_PARALLEL_SPACING): number[]
Was this page helpful?