Graphs
Graphs
Section titled “Graphs”A graph is a shell-rendered view of positioned nodes, directed edges and optional swimlanes. The graph does not write state: dragging a node, selecting a node or drawing an edge reports a user gesture, and the plugin handler decides what to store.
const { Graph } = ctx.ui;
<Graph nodes={[{ id: "a", label: "A", position: { x: 120, y: 120 }, laneId: "todo", shape: "diamond", icon: { name: "check" }, labelPosition: "below", }]} edges={[{ id: "a-b", source: "a", target: "b", kind: "primary", color: "#16a34a", routing: "taxi", bidirectional: true, taxiTurn: 0.6, }]} lanes={[{ id: "todo", title: "To do", x: 40, y: 40, width: 320, height: 480 }]} onNodeMove={(nodeId, position) => ctx.emit("tasks/move", { nodeId, position })} onEdgeCreate={({ source, target }) => ctx.emit("tasks/link", { source, target })} onEdgeRouteChange={(edgeId, route) => ctx.emit("tasks/route", { edgeId, ...route })}/>- Nodes have
id,label, optionalposition(orx/y),laneId,kind,payload,shapeandicon. Standard shapes areround-rectangle(default),rectangle,ellipse,diamondandhexagon;nonedisplays the icon without a geometric body.labelPositionisinside(default),aboveorbelow, and is set independently on every node. An icon is either a shared shell icon ({ name: "check" }) or an image ({ url: "https://…" }); an unknown or empty icon is ignored. - Edges have
source,target, optionalid,label,kind,payload,color,routing(straightortaxi),bidirectionalandtaxiTurn. The optional CSScolorapplies to the line and both arrowheads. With no routing the existing Bézier rendering remains the default.taxiTurnis a proportion from0to1, defaulting to0.5. - Nodes accept an optional
subtitle, drawn on a second line underlabel. Edges acceptlineStyle:solid(default),dashedordotted. - Taxi edges accept
taxiDirection:auto(default),horizontalorvertical.taxiTurnstrictly between 0 and 1 is a Z route whose break sits at that ratio between the node centres; exactly 0 or 1 is an L route with one corner (axis x: 1 → corner at (target.x, source.y), 0 → at (source.x, target.y); axis y the other way round). The shell resolves the drawn route from the rendered node boxes (resolveTaxiRouteinsrc/kernel/graph-taxi.ts): a Z break is kept inside the free gap between the nodes; when no gap exists on that axis an automatic direction uses the other axis, otherwise the nearest L is drawn; intersecting nodes get a straight line, so a route is never hidden inside a node. - Shared icons include business objects (
ship,plane,train,truck,warehouse,factory,cpu,package,container,globe,store,shoppingCart,handshake,banknote…); silhouettes addship,airplane,train,chip,package,shopping-cart,globe,container,handshake,money,documentandflask. - Annotations (
GraphAnnotation) are free texts drawn over the graph:{ id, text, target?, offset, style?, color?, width? }.textsupports**bold**,*italic*, line breaks and emoji.targetis{ type: "canvas" }(default) or{ type: "node" | "edge" | "lane", id }; the text sits atoffsetfrom the anchor (node centre, edge middle, lane top-left corner) and follows it when that element moves. An annotation whose target is missing is hidden. Dragging one emitsannotationMoveEventwith{ annotationId, offset }; the target never changes from the UI, only plugin handlers re-attach an annotation. Declare them withannotations(a value source) onpanel.graph/ graph views. A click without dragging emitsannotationSelectEventwith{ annotationId }(publish it as a selection to edit it in Properties); ids of selected annotations are ringed and sent asannotationIdsinremoveEventon Delete. - Lanes are HTML overlays (
id,title,x,y,width,height) aligned with the graph viewport; they are visual containers, not graph parents.
Graph legend. A graph view’s config (or panel.graph) accepts an optional
legend. Plugins define its meaning explicitly; the shell does not infer
categories from nodes or edges. The legend stays fixed in a viewport corner,
independent of zoom and pan. Users can hide/show it, choose any of the four
corners and fold each section. These preferences belong to the individual tab’s
view state and are included in ctx.views.capture() / restore().
Fit All frames nodes, edges and lanes in an unobstructed region beside or below
the visible legend (above it for bottom corners), choosing the region with the
largest usable scale. Opening, closing, repositioning or resizing the legend
automatically refits the graph; a hidden legend restores the full viewport.
"legend": { "title": "legend.title", "position": "top-right", "defaultVisible": true, "sections": [ { "id": "facilities", "title": "legend.facilities", "items": [ { "id": "factory", "label": "legend.factory", "kind": "node", "shape": "rectangle", "icon": { "name": "factory" }, "color": "#16a34a" } ] }, { "id": "flows", "title": "legend.flows", "defaultCollapsed": false, "items": [ { "id": "road", "label": "legend.road", "kind": "edge", "lineStyle": "dashed", "color": "#2563eb", "bidirectional": true } ] } ]}Public contracts: GraphLegendDefinition, GraphLegendSection,
GraphLegendItem, GraphLegendPosition, GraphLegendState, exported from
@app/kernel/plugin-api. position is top-left, top-right, bottom-left
or bottom-right (default top-right); defaultVisible defaults to true.
Section items may also be { "ref": "legendItems" } or another value selector,
registered under ctx.ui.define({ values: { legendItems: ... } }); TypeScript
declarations accept an array or ValueSource<GraphLegendItem[]>. Sources receive
the view context, including document parameters. Titles are i18n keys. Static
item labels/descriptions are qualified by the manifest; reactive item text must
use qualified keys, or set translate: false on the section for literal text.
The entire section list can also be reactive: legend: { sections: { ref: "graphLegendSections" } } in ui.json, or sections: ValueSource<GraphLegendSection[]> in TypeScript. Register the source under
ctx.ui.define({ values: { graphLegendSections: ... } }). It receives the same
view context (tabId, viewId, pluginId, document params), so the plugin can
project only sections and items relevant to the active graph. Returning []
removes all sections without removing the show/hide and position controls.
Returned sections are runtime contracts, not manifest fragments: use qualified
i18n titles, and item arrays or actual ValueSource<GraphLegendItem[]> instances
(not nested { ref: ... } selectors). Keep section IDs stable to preserve collapse
preferences across updates; new IDs start from defaultCollapsed.
ctx.ui.define({ values: { graphLegendSections: ctx.states.valueFrom(slice, (state, context) => projectLegendSections(state, context)),} });An item’s kind is node (default, shape/icon preview), edge (stroke and
arrows), or color (swatch); description is optional. Legend entries are
read-only, not graph elements, and never emit selection or mutation events.
Notes includes a minimal task/precedence legend as a working example.
- If
onNodeMoveis present, nodes can be dragged. IfonEdgeCreateis present, the link button lets the user click a source node then a target node. - In
ui.json,panel.graphacceptsnodes,edges, optionallanes,selectedId,readonly,emptyText,className,moveEvent,createEdgeEvent,edgeRouteEventandselectEvent. Graph data can come from slice paths or from named bindings registered withctx.ui.define({ graphNodes, graphEdges, graphLanes }). - Try it: install
/plugins/notes-tsx/index.js, create a note, add tasks, then open Graph under that note. Tasks become draggable nodes; the link button creates precedence arrows between tasks. - Saving that note writes one JSON file containing the text, the tasks (with
their graph positions) and the precedence edges — see
packages/notes-core/note-file.ts
Lane selection. Declare laneSelectEvent to receive
{ laneId, context, ...params } when a lane header is clicked with less than
5 CSS pixels of pointer movement. Dragging still moves the lane/group and
does not select it. The direct Graph callback is onLaneSelect(laneId).
Selection works in read-only graphs too; plugins decide whether properties
are editable. Include lane IDs in the existing selectedIds source to
highlight their frames. Publish the owning entity, not shell state:
ctx.registerHandler<{ laneId: string; docId: string; context: { tabId: string } }>( "example/lane.select", ({ payload }) => { ctx.selection.set(payload.context, { kind: "example:status", ids: [payload.laneId], context: { docId: payload.docId }, }); },);The existing propertyForms for that selection kind then edit the lane’s
entity. Notes demonstrates this with notes-tsx/status.selectLane and its
status form, shared with the palette.
Swimlanes accept an optional color for their header and a lighter tint of
the same colour is applied to their body. onLaneMove and
onLaneResize make the header draggable and expose a resize handle. Declarative
graphs use laneMoveEvent and laneResizeEvent. A node with a laneId is
attached to that lane: dragging it keeps it clamped inside the lane, unless
Shift is held, in which case it is dropped freely. The move reports
laneId: string | null — the node’s own lane without Shift, the lane under the
drop point (or null for none) with Shift. Moving a lane drags its nodes
along live; onLaneMove(laneId, position, { delta, nodeIds }) (declarative
payload { laneId, position, delta, nodeIds }) lets the plugin shift those
nodes by delta to persist their relative positions. Resizing never moves nodes
of the resized lane.
Lane groups. A lane may carry group: { id }. Lanes sharing a group id
are a block of side-by-side lanes, with no frame or title of their own. A
grouped lane never moves alone: dragging its header moves the whole block and
reports onLaneGroupMove(groupId, { delta, laneIds, nodeIds }) (declarative
laneGroupMoveEvent). Shift-drag of a lane edits groups: inside a group the
block splits just left of the dragged lane (nothing splits for the first lane);
near another lane or block (LANE_SNAP_DISTANCE) the target is highlighted and
the drop snaps beside it, top-aligned at its height — creating, extending or
merging a group. The pure laneGroupDrop(lanes, nodes, laneId, delta) computes
it; onLaneGroupChange(laneId, layout) (declarative laneGroupEvent, payload
{ laneId, layout }) carries layout: { lanes: (GraphLaneGeometry & { groupId: string | null })[], nodeMoves } to apply as-is. Resizing a grouped lane gives
every lane of the group the new height and shifts the lanes on its right;
onLaneResize(laneId, size, layout) carries the same kind of layout.
The shell persists nothing; groups are plugin data.
When onEdgeRouteChange is present, the selected taxi edge shows a handle:
round on a Z break, square on an L corner. A Z break follows the pointer inside
the gap and stops at the borders; moving 24 screen px past a border switches to
the nearest L corner, and moving 24 px inside the gap switches back to a Z (the
hysteresis avoids jumps). Double-clicking the handle toggles Z ↔ nearest L.
Releasing a drag reports (edgeId, { taxiTurn }); a plain click reports nothing.
Declarative graphs emit { edgeId, taxiTurn, context, ...params } through
edgeRouteEvent; the plugin remains responsible for storing the new route.
Curved edges (routing: "curved", drawn as a Cytoscape unbundled bezier) carry
curveDistance: the signed offset in graph pixels of the single control point
from the chord midpoint (default 40; geometry in src/kernel/graph-curve.ts).
The selected edge shows a round handle on the curve’s middle; dragging it
reports (edgeId, { curveDistance }) through the same edgeRouteEvent, and a
double-click flips the bend to the other side.
Edges with an id are selectable: a tap emits edgeSelectEvent with { edgeId }, and selectedId may hold a node or an edge id. Enum config.options[].label values in tables, forms and widgets are plugin i18n keys, like field labels.
(serializeNoteFile / parseNoteFile). Opening a plain .txt/.md file
still works: anything that is not a valid note document is read as text.
Documentation under CC BY 4.0 · © SCFlow contributors ·Back to supplychainflow.org