Use declared ports when your editor needs named inputs and outputs rather than interchangeable attachment points. The example renders a number pipeline, A → B → C, plus a string input. Number ports paint blue; the string port paints purple. Matching types connect, full inputs refuse another wire, and a cycle validator prevents C → A.
Ports decide where links attach and which connections are legal: declare them in specs, let the engine enforce the rules, and let the renderer draw the glyphs.
1. Declare the ports and their rules
In your browser application's source directory, create ports.ts. All five bindings below import this file. Use the library's NodeSpec and PortSpec vocabulary rather than constructing live ports yourself. EdgeSpec names the ports used by the initial pipeline through sourceHandle and targetHandle.
The two inputs on B inherit a square glyph, inside labels, and an evenly spaced left-edge column from metadata.portGroups.inputs. Each member supplies its own id and label text. A and C use diamond outputs. The setup function receives the mounted DiagramEngine so the cycle rule queries its current diagram.
Known issue: Declaring
type: 'input'ortype: 'output'alone does not enforce start-only/end-only direction: the connection rule rejects equal non-bitypes, but does not reject an input-to-output wire. Until it is fixed, also setgating.isConnectableStart: falseon inputs andgating.isConnectableEnd: falseon outputs, as below.
tsimport { portTypeRegistry, type DiagramEngine } from '@grafloria/engine';
import {
registerConnectionValidator,
type NodeSpec,
type PortSpec,
type EdgeSpec,
type DiagramInstance,
} from '@grafloria/renderer';
function input(id: string, dataType: string): PortSpec {
return {
id, side: 'left', type: 'input', dataType,
shape: { shape: 'square', size: 12 },
label: { text: dataType, layout: 'inside' },
gating: { isConnectableStart: false, toMaxLinks: 1 },
};
}
function output(id: string): PortSpec {
return {
id, side: 'right', type: 'output', dataType: 'number',
shape: { shape: 'diamond', size: 14 },
label: { text: 'out', layout: 'inside' },
gating: { isConnectableEnd: false },
};
}
export const nodes: NodeSpec[] = [
{
id: 'a', label: 'A', position: { x: 60, y: 100 },
size: { width: 150, height: 100 },
ports: [input('a-in', 'number'), output('a-out')],
},
{
id: 'b', label: 'B', position: { x: 290, y: 100 },
size: { width: 150, height: 100 },
metadata: {
portGroups: {
inputs: {
id: 'inputs', side: 'left',
shape: { shape: 'square', size: 12 },
label: { layout: 'inside' },
layout: { strategy: 'sideLinear', args: { padding: 10 } },
},
},
},
ports: [
{
id: 'b-x', group: 'inputs', type: 'input', dataType: 'number',
label: { text: 'x' },
gating: { isConnectableStart: false, toMaxLinks: 1 },
},
{
id: 'b-y', group: 'inputs', type: 'input', dataType: 'number',
label: { text: 'y' },
gating: { isConnectableStart: false, toMaxLinks: 1 },
},
output('b-out'),
],
},
{
id: 'c', label: 'C', position: { x: 520, y: 100 },
size: { width: 150, height: 100 },
ports: [input('c-in', 'number'), output('c-out')],
},
{
id: 's', label: 'String input', position: { x: 520, y: 290 },
size: { width: 150, height: 100 },
ports: [input('s-in', 'string')],
},
];
export const edges: EdgeSpec[] = [
{
id: 'ab', source: 'a', target: 'b',
sourceHandle: 'a-out', targetHandle: 'b-x',
},
{
id: 'bc', source: 'b', target: 'c',
sourceHandle: 'b-out', targetHandle: 'c-in',
},
];
export function installPortRules(engine: DiagramEngine): () => void {
engine.setInteractionConfig({ enableLinkReconnection: false });
portTypeRegistry.registerAll([
{ name: 'number', color: '#2563eb', compatibleWith: ['number'] },
{ name: 'string', color: '#9333ea', compatibleWith: ['string'] },
]);
const model = engine.getDiagram();
if (!model) throw new Error('Mount the canvas before installing port rules.');
return registerConnectionValidator(({ sourceNode, targetNode }) => {
// The registry is global. Apply this rule only to this mounted model.
if (model.getNode(sourceNode.id) !== sourceNode ||
model.getNode(targetNode.id) !== targetNode) return true;
const seen = new Set<string>();
const stack = [targetNode.id];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === sourceNode.id) return 'Refused: would create a cycle';
if (seen.has(current)) continue;
seen.add(current);
for (const edge of model.getLinks()) {
const from = model.getNodeByPortId(edge.sourcePortId)?.id;
const to = model.getNodeByPortId(edge.targetPortId)?.id;
if (from === current && to !== undefined) stack.push(to);
}
}
return true;
});
}
export function configurePorts(instance: DiagramInstance): () => void {
const dispose = installPortRules(instance.getEngine());
instance.renderNow();
return dispose;
}
portTypeRegistry supplies both the type palette and compatibility rules. Identical type names match; an untyped endpoint imposes no type restriction. compatibleWith permits additional target types in the source-to-target direction—it does not automatically permit the reverse conversion.
registerConnectionValidator returns a disposer. Its callback receives both nodes and both ports for new connections. Return true to allow the candidate, or false or a reason string to veto it. Every registered validator must pass for a new connection.
Known issue: Endpoint reconnection does not invoke registered validators, despite the callback type's optional
linkfield intended for that task. Until it is fixed, disable endpoint reconnection withengine.setInteractionConfig({ enableLinkReconnection: false }), asinstallPortRules()does, so reconnection cannot bypass the cycle rule.
The cycle rule traverses the mounted instance's live links, not the initial edges array. It rejects a proposed edge when its target already reaches its source. Moving the boxes does not change that graph rule.
2. Mount the same editor in your framework
Choose one installation command for your existing project:
bash# JavaScript
npm install @grafloria/element @grafloria/engine @grafloria/renderer
# React
npm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom
# Vue
npm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue
# Qwik
npm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer @builder.io/qwik
# Angular
npm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
Add configurePorts() (or installPortRules() in Angular) to apply the type colors and cycle rule to this editor; see Instance and lifecycle for mounting and ready callbacks.
Each wrapper has a resolved height. Ports stay visible through interaction.portVisibility in JavaScript, React, Vue and Qwik. The Angular sample keeps the default hover visibility. Keep the returned validator disposer until unmount; the framework binding owns its canvas teardown.
ts// main.ts
import { render } from '@grafloria/element';
import { nodes, edges, configurePorts } from './ports';
export function mountPorts(container: HTMLElement): () => void {
container.style.height = '460px';
const instance = render(
{ nodes, edges }, container,
{ interaction: { portVisibility: 'always' } },
);
const disposeRules = configurePorts(instance);
return () => {
disposeRules();
instance.dispose();
};
}
const container = document.createElement('div');
document.body.append(container);
export const unmountPorts = mountPorts(container);
// Call unmountPorts() when your host removes this view.
tsx// App.tsx
import { useCallback, useEffect, useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, configurePorts } from './ports';
export default function App() {
const instanceRef = useRef<DiagramInstance | null>(null);
useEffect(() => {
const instance = instanceRef.current;
if (!instance) return;
return configurePorts(instance);
}, []);
const onInit = useCallback((instance: DiagramInstance) => {
instanceRef.current = instance;
}, []);
return (
<div style={{ height: '460px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
interaction={{ portVisibility: 'always' }} onInit={onInit} />
</div>
);
}
vue<!-- App.vue --> <script setup lang="ts"> import { onBeforeUnmount } from 'vue'; import { GrafloriaFlow } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, configurePorts } from './ports'; let disposeRules: (() => void) | undefined; function onInit(instance: DiagramInstance) { disposeRules?.(); disposeRules = configurePorts(instance); } onBeforeUnmount(() => disposeRules?.()); </script> <template> <div style="height:460px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :interaction="{ portVisibility: 'always' }" @init="onInit" /> </div> </template>
tsx// App.tsx
import {
component$, noSerialize, useSignal, useVisibleTask$,
type NoSerialize,
} from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, configurePorts } from './ports';
export default component$(() => {
const instance = useSignal<NoSerialize<DiagramInstance>>();
useVisibleTask$(({ track, cleanup }) => {
const mounted = track(() => instance.value);
if (!mounted) return;
const disposeRules = configurePorts(mounted);
cleanup(disposeRules);
});
return (
<div style={{ height: '460px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
interaction={{ portVisibility: 'always' }}
onInit$={(mounted: DiagramInstance) => {
instance.value = noSerialize(mounted);
}} />
</div>
);
});
ts// app.component.ts
import { Component, viewChild, type AfterViewInit, type OnDestroy } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { nodes, edges, installPortRules } from './ports';
@Component({
selector: 'app-root',
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
style="display:block; height:460px" />
`,
})
export class AppComponent implements AfterViewInit, OnDestroy {
readonly canvas = viewChild.required(DiagramCanvasComponent);
nodes: NodeSpec[] = structuredClone(nodes);
edges: EdgeSpec[] = structuredClone(edges);
private disposeRules: (() => void) | undefined;
ngAfterViewInit(): void {
const engine = this.canvas().activeEngine();
if (!engine) return;
this.disposeRules = installPortRules(engine);
this.canvas().scheduleRender();
}
ngOnDestroy(): void {
this.disposeRules?.();
}
}
The JavaScript canvas starts with A → B → C and a separate purple string input.
The React canvas displays the same initial pipeline.
The Qwik canvas displays the blue number ports and purple string port.
Qwik registers the types and validator in a browser visible task, after the ready callback supplies the instance. The live instance stays in noSerialize() state rather than entering the server's serialized state.
3. Try the connection rules
The initial canvas contains two wires. Test each layer by dragging from the diamond output on A:
- Drop on B's
yinput. A number-to-number wire appears, and that input becomes full. - Try that same input again. No second wire appears; the one incoming-link cap is per port, not per node.
- Drop on the purple input of String input. No wire appears because
numbercannot flow intostring. - Drag from C's output to A's input. No wire appears: A already reaches C, so the proposed edge closes a cycle.
To use hover ports instead, omit the interaction prop/options in JavaScript, React, Vue and Qwik. Angular already uses hover ports in this sample. Hovering a node then reveals its ports. For container sizing, see Theme a canvas.
Options that matter
Use the built-in glyphs and port layouts before registering custom geometry. These options belong to each port unless noted otherwise.
| Option | Type | Default | What it does |
|---|---|---|---|
shape.shape | 'circle' | 'square' | 'diamond' | 'triangle' | 'path' | 'circle' | Selects the glyph; path needs SVG path data in shape.path. |
shape.size | number | Twice portDefaultRadius | Sets the glyph box width and height; for circles, this is the diameter. |
label.layout | 'inside' | 'outside' | 'orthogonal' | 'radial' | 'outside' | Places text toward the body, away from it, across the normal, or radially from the node center. |
label.offset | number | 6 | Sets the gap from the glyph edge in pixels. |
layout.strategy | 'shape' | 'absolute' | 'line' | 'sideLinear' | 'ellipse' | 'ellipseSpread' | Shape anchor | Uses the shape silhouette, a fixed point, a segment, an edge column, or an ellipse arrangement. |
group | string | Unset | Inherits configuration from the named group in metadata.portGroups; port-level layout, shape and label fields override it. |
dataType | string | Unset | Names the data-flow type used for color and compatibility. |
gating.isConnectableStart, gating.isConnectableEnd | boolean | true | Permit or veto the corresponding end of a proposed wire. |
gating.fromMaxLinks, gating.toMaxLinks | number | null | Unlimited | Cap outgoing or incoming links separately. |
maxConnections | number | Unlimited | Caps all connections on this port. |
gating.allowedTypes | string[] | No restriction | Requires the opposite port's data type or system type to appear in the list. |
gating.allowSelfLink | boolean | false | Permits a same-node link only when both endpoints allow it. |
gating.allowDuplicateLinks | boolean | true | When false on either endpoint, rejects another link between the same two ports, including the reverse direction. |
interaction.portVisibility | 'always' | 'on-hover' | 'hidden' | 'on-hover' | Controls canvas-wide port visibility; hidden ports disable drag-to-connect. |
For many-port nodes, sideLinear spaces ports along an edge, line spaces them along args.start → args.end in node-local pixels, and ellipseSpread fans them around the inscribed ellipse. The port arrangement travels with the node. Route and label edges covers the wires attached to those ports.
Clean up global validators
Connection validators are process-global, not per canvas. Keep each registration's disposer and call it on unmount, as the samples do. The identity check in the cycle callback limits its rule to the intended live model; the disposer removes the registration itself.
Use clearConnectionValidators only when you intend a clean slate for the whole process. It removes every registered validator, including registrations owned by other canvases. Do not substitute it for disposing one view's rule.
Live demos and related pages
- Typed ports: compare matching and mismatched types.
- Port groups and layouts: compare columns, segments and rings.
- Connection limit: try a second wire on a full port.
- Preventing cycles and its source: follow directed reachability through live links.
- Specs and live models: understand the data queried by the cycle callback.
- Instance and lifecycle: own a mounted instance and its teardown.
Was this page helpful?