Aller au contenu

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, optional position (or x/y), laneId, kind, payload, shape and icon. Standard shapes are round-rectangle (default), rectangle, ellipse, diamond and hexagon; none displays the icon without a geometric body. labelPosition is inside (default), above or below, 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, optional id, label, kind, payload, color, routing (straight or taxi), bidirectional and taxiTurn. The optional CSS color applies to the line and both arrowheads. With no routing the existing Bézier rendering remains the default. taxiTurn is a proportion from 0 to 1, defaulting to 0.5.
  • Nodes accept an optional subtitle, drawn on a second line under label. Edges accept lineStyle: solid (default), dashed or dotted.
  • Taxi edges accept taxiDirection: auto (default), horizontal or vertical. taxiTurn strictly 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 (resolveTaxiRoute in src/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 add ship, airplane, train, chip, package, shopping-cart, globe, container, handshake, money, document and flask.
  • Annotations (GraphAnnotation) are free texts drawn over the graph: { id, text, target?, offset, style?, color?, width? }. text supports **bold**, *italic*, line breaks and emoji. target is { type: "canvas" } (default) or { type: "node" | "edge" | "lane", id }; the text sits at offset from 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 emits annotationMoveEvent with { annotationId, offset }; the target never changes from the UI, only plugin handlers re-attach an annotation. Declare them with annotations (a value source) on panel.graph / graph views. A click without dragging emits annotationSelectEvent with { annotationId } (publish it as a selection to edit it in Properties); ids of selected annotations are ringed and sent as annotationIds in removeEvent on 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 onNodeMove is present, nodes can be dragged. If onEdgeCreate is present, the link button lets the user click a source node then a target node.
  • In ui.json, panel.graph accepts nodes, edges, optional lanes, selectedId, readonly, emptyText, className, moveEvent, createEdgeEvent, edgeRouteEvent and selectEvent. Graph data can come from slice paths or from named bindings registered with ctx.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 sous licence CC BY 4.0 · © les contributeurs de SCFlow ·Retour à supplychainflow.org