Summary#
Osysharp.Diagrams is a canvas for things that are boxes joined by arrows: the steps of a process, the stages of a
pipeline, an approval chain, an org chart, a live picture of a workflow. You depend on it in one line:
app Designer {
use Osysharp.Ui;
use Osysharp.Diagrams@0;
model "model/**/*.osy";
}Three things make it different from a diagramming widget:
- Nodes and edges are your rows. The canvas reads them; a drag or a new edge comes back as an event carrying the
row, and your action writes it through the unit of work like any other edit — so it is committed, undone
(
UnitOfWork.Discard()), secured and synced exactly as the rest of your app. - A node is your own Osy#. You write one template per node type (
slot Task { s => … }); the platform renders it into the node, so a node can show a field, a badge, a button — anything a page can — and it stays live. - It works without a mouse. Tab to a node, move it with the arrow keys, press Enter on one port and Enter on another to connect them, Delete to remove.
⚠️ Preview (0.x). Everything below is built and proven in a real browser, but the surface may still change before 1.0.
Every …Field prop is a typed member selector — xField: n => n.X, labelField: n => n.Title — checked against
your row type when the page compiles. A misspelt member is a compile error naming the member and the type (with a
did-you-mean); a member of the wrong type, a path (n => n.Owner.Name) or a computed value is refused too, because
the canvas reads that one member off each row itself. A string (xField: "X") is refused with the selector to write.
Signature#
FlowCanvas(
nodes: steps, // your rows — positioned by xField/yField (default n => n.X / n => n.Y)
edges: links, // your rows — joined by sourceField/targetField (default e => e.Source / e => e.Target)
nodeTypes: [ new FlowNodeType { Name = "Task", Ports = [
new FlowPort { Id = "in", Kind = "target", Side = "left" },
new FlowPort { Id = "out", Kind = "source", Side = "right" } ] } ],
typeField: n => n.Kind, // which field names each node's type (omitted: every node is the first type)
sourcePortField: e => e.SourcePort, targetPortField: e => e.TargetPort, // which fields name an edge's ports
labelField: n => n.Title, // a node's accessible name (omitted: the words its template rendered)
edgeLabelField: e => e.Label, // drawn on the edge and read in its name
readOnly: false, // true: a picture — pan and zoom only
edgeShape: bezier, // bezier | smoothstep | straight — an editor's edges; a picture routes its own
snap: 20, // drops and arrow keys land on a 20-unit grid (0 = free)
background: dots, // dots | lines | none — the grid pans and zooms with the canvas
controls: true, // zoom in / zoom out / fit buttons
minimap: true, // an overview in the corner; drag it to pan
autoLayout: initial, // initial | always | none — see "Laying a graph out"
layoutDirection: auto, // auto | right | down | left | up
initialField: n => n.IsInitial, terminalField: n => n.IsTerminal, // which node fields mark a START and an END
mainPath: [ … ], // node keys of the path to keep on one straight row (omitted: worked out)
nodeSizing: equal, // equal | natural — a picture's nodes at one size, or each at its own
tools: [ … ], // FlowTool { Label, Run } — the app's own buttons in the corner toolbar
highlighted: [ … ], // node keys that are CURRENT (a run's state) — see "A live picture of a workflow run"
visited: [ … ], // node keys a run has passed through, in order
path: [ … ], // the exact way it went, repeats included, when you know it
failed: [ … ], // node keys where it failed
gesture: Gesture, // (string label) — ONCE before each edit's events; mark the unit of work here
nodeMoved: Move, // (TNode node, double x, double y)
connected: Link, // (TNode source, TNode target, string sourcePort, string targetPort)
edgeRemoved: Unlink, // (TEdge edge)
nodeRemoved: Remove, // (TNode node)
nodeSelected: Pick, // (TNode node) — the primary selection
selectionChanged: Selected // (TNode[] nodes, TEdge[] edges) — the whole selection, as rows
) {
slot Task { s => … } // one template per node type — the names are checked against nodeTypes
}The canvas has no size of its own: it declares sizing fill, so the platform makes it fill the box you put it in —
give that box a height (sizing — how the platform sizes a control's box).
Description#
The whole thing, at a call site#
// ⚠ This example DECLARES the control inline, because a documentation example compiles on its own with no manifest
// to carry a `use`. In your app you write `use Osysharp.Diagrams@0;` and `using Osysharp.Diagrams;` instead and delete this
// block — the declaration arrives with the kit. What follows is `kits/diagrams/flow.osy`, trimmed to what this page uses.
public class FlowPort { public string Id; public string Kind; public string Side; }
public class FlowNodeType { public string Name; public FlowPort[] Ports; }
control FlowCanvas<TNode, TEdge> {
contractVersion "1.1"
participation headless
sizing fill
props {
TNode[] nodes;
TEdge[] edges;
FlowNodeType[] nodeTypes;
Func<TNode, string>? typeField;
Func<TNode, double> xField = n => n.X;
Func<TNode, double> yField = n => n.Y;
Func<TEdge, object> sourceField = e => e.Source;
Func<TEdge, object> targetField = e => e.Target;
bool readOnly = false;
}
events {
nodeMoved(TNode node, double x, double y);
connected(TNode source, TNode target, string sourcePort, string targetPort);
edgeRemoved(TEdge edge);
}
slots from nodeTypes.Name
chunks { XY; }
}
[Principal] entity Planner { [Required] string Email; }
entity Step {
[MaxLength(80)] string Title;
double X;
double Y;
security { allow read, create, update, delete when IsAuthenticated; }
}
entity Link {
Step Source;
Step Target;
security { allow read, create, update, delete when IsAuthenticated; }
}
[Route("/process")]
component Process() {
live var steps = Step.ToList();
live var links = Link.ToList();
// A drop is one event, carrying the ROW — never one per frame.
action Move(Step s, double x, double y) { s.X = x; s.Y = y; UnitOfWork.Commit(); }
action Connect(Step from, Step to, string fromPort, string toPort) {
new Link { Source = from, Target = to };
UnitOfWork.Commit();
}
action Unlink(Link l) { l.Delete(); UnitOfWork.Commit(); }
render {
Box(h: "480px") {
FlowCanvas(
nodes: steps,
edges: links,
nodeTypes: [ new FlowNodeType { Name = "Task", Ports = [
new FlowPort { Id = "in", Kind = "target", Side = "left" },
new FlowPort { Id = "out", Kind = "source", Side = "right" } ] } ],
nodeMoved: Move,
connected: Connect,
edgeRemoved: Unlink
) {
slot Task { s => Text(s.Title); }
}
}
}
}Why a move is an event and not a binding#
The canvas never writes your data. It moves the node on screen at once (no round trip while the pointer is down), and
on the drop raises nodeMoved(row, x, y) — once. Your action decides what that means: store it, snap it,
refuse it. The same holds for connected: a new edge is a row you create, so your model can carry whatever an edge
means to it (a label, a condition, a rule) and your security { } governs who may draw one.
Undo, one gesture at a time#
Because every edit is an ordinary write through your unit of work, undo is the unit of work's: UnitOfWork.Discard()
throws a whole draft away, and its SAVEPOINTS (UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo) take it back step by step. The
canvas raises gesture(label) once, before the events of each edit — a drag, an arrow-key press, a new connection,
a Delete, a lay-out — with a sentence naming it ("Move Approve?", "Connect Receive request to Fulfil", "Remove 2 nodes
and 1 edge"). Mark there, and one Ctrl+Z takes back one whole gesture, however many rows it moved:
action Gesture(string what) { UnitOfWork.Mark(what); }
action Undo() { UnitOfWork.Undo(); }
action Redo() { UnitOfWork.Redo(); }
…
Button(UnitOfWork.CanUndo() ? "Undo " + UnitOfWork.UndoLabel() : "Undo", onPress: Undo, shortcut: "Mod+Z",
disabled: !UnitOfWork.CanUndo());
Button(UnitOfWork.CanRedo() ? "Redo " + UnitOfWork.RedoLabel() : "Redo", onPress: Redo, shortcut: "Mod+Shift+Z",
disabled: !UnitOfWork.CanRedo());
FlowCanvas(nodes: steps, edges: links, gesture: Gesture, nodeMoved: Moved, connected: Link, edgeRemoved: Unlink,
nodeRemoved: Remove) { … }The shortcuts work with focus on the canvas — it lets them through — and the canvas redraws whatever the rows are
after an undo, so it keeps no history of its own. A Save (UnitOfWork.Commit()) clears the marks. demo/flow-demo's
/draft page is this, whole.
Where edges attach — node types and their ports#
Each FlowNodeType names a type and its ports — where edges attach. A port's Kind is source (edges leave)
or target (edges arrive); its Side is left, right, top or bottom, and several ports on one side are
spread along it. slots from nodeTypes.Name makes each type's name a template you write at the call site, and a
misspelt slot Tsak is a compile error naming the types you passed. A single anonymous template ({ s => … })
renders every type the same way.
The canvas draws the ports itself, on the node's border — your template never has to place them.
Selecting many#
Click selects one node; Shift, Ctrl or Cmd-click adds or removes one; Shift + drag on the empty pane
draws a box and selects every node inside it and the edges between them; Ctrl/Cmd + A selects every node; from the
keyboard, Space on a node adds or removes it. A drag, an arrow key or Delete on a node that is in the selection
acts on the whole selection — each node still arrives as its own nodeMoved / nodeRemoved (edges first, so an app
whose node delete cascades its edges never hears about an edge that is already gone). Escape clears it.
selectionChanged(TNode[] nodes, TEdge[] edges) hands your action the selection as rows — live entities, not
copies — so it can read and write them like any others:
action Selected(Step[] steps, Transition[] links) {
selection = string.Join(", ", steps.Select(s => s.Title));
}Grid, snapping and the view#
snap: 20 puts every drop on a 20-unit grid and makes an arrow press one grid step. The background grid (dots,
lines or none) follows the snap and pans and zooms with the canvas, so what you see is where a node will land.
The controls in the corner zoom and fit the whole picture (the keys are +, − and 0); every view change
glides over the theme's Motion.Normal, and not at all for someone who prefers reduced motion. minimap: true adds an
overview that marks the current view; drag it to move around a large diagram.
Laying a graph out#
The canvas can place the nodes itself — in ranks from the starts to the ends, edges flowing one way, the main path on
one straight row, nothing overlapping. The layout is the kit's own and its own chunk (Layout, ~15 KB), downloaded the
first time a canvas lays itself out; a canvas that only draws stored positions never fetches it.
autoLayout | when the canvas lays out |
|---|---|
initial (default) | once, for a graph nobody has placed yet (every node at the same point) |
always | every time the nodes or edges change, ignoring the position fields — a picture of a structure |
none | never on its own |
layoutDirection is auto (default — along the page's reading direction, or top to bottom when that shows the
picture larger), right, down, left or up. The Tidy button in the corner (and Shift + L) lays out on
demand. In an editor, every node that moves raises nodeMoved, so a layout is a batch of ordinary edits: the app
stores it, and can discard it like any other. Nodes glide to their places over the theme's motion.
With always the canvas draws a picture: it also routes every edge on a line of its own, keeps the whole of it
fitted to its box, and holds its nodes to one size. How the layout works, what it promises, and how a test asserts it
are on their own page: Laying a graph out — ranks, routed edges and a run's path on the flow canvas.
A layout — or any other verb — can also be one of the app's own tools, the text buttons it adds to that corner:
FlowCanvas(…, tools: [
new FlowTool { Label = "Lay out", Run = FlowCanvas.Layout }, // one of the canvas's commands
new FlowTool { Label = "Save", Run = Save } // one of yours
])The canvas's commands are Layout and FitView.
A live picture of a workflow run#
Any workflow can be drawn, with a run's place in it, from three ordinary reads — nothing in the canvas knows what a
workflow is:
component OrderRun(Order order) {
live var times = Fulfilment.For(order).TimeInStates; // where this run has BEEN (a server read, live)
render {
FlowCanvas(
nodes: Fulfilment.Graph.States, // every state — built by the compiler, no server read
edges: Fulfilment.Graph.Transitions, // every declared transition
sourceField: e => e.From, targetField: e => e.To,
labelField: n => n.Label, edgeLabelField: e => e.Trigger,
readOnly: true,
autoLayout: always, // states have no positions: the canvas lays them out
highlighted: [order.Status.ToString()], // where it IS — the row's own tracked field, so live
visited: times.Select(t => t.State).ToList()
) { s => Text(s.Label); }
}
}highlighted marks the current node — a ring that breathes (still, for someone who prefers reduced motion), "Current"
to a screen reader — and visited marks the ones the run passed through, with a tick. The edges between one visited
node and the next are drawn as taken — heavier, in the accent colour — and the one that brought the run to where it
is now flows; everything the run has not reached is drawn quietly. failed marks where a run failed, and path gives
the exact way it went when it passed a node more than once ([[ui-flow-layout#run]]). When the run moves, the row
changes, so the ring moves with it and the move is announced ("Now at Packed."). See Graph — every state and every transition of a workflow for what the
graph holds.
Rows here are values (WorkflowStateInfo), not entities: the canvas keys a row by its id, or by an Id field
when it has none — so any list of the app's own class values can be drawn, not only table rows.
A state's Outcome (empty, or Success/Cancel/Error) makes a good typeField: give the terminal types only an
incoming port, and the picture shows that nothing leaves them.
Read-only — a picture of a process#
readOnly: true turns the canvas into a view: nothing moves, nothing connects, nothing is deleted, and pan and zoom
still work. It is the shape for showing where a running workflow is, beside the designer that edits it.
Using it without a mouse, and with a screen reader#
The canvas is fully usable without a pointer, and everything it does is said to a screen reader.
| key | on | does |
|---|---|---|
| Tab | the canvas | moves between nodes and their ports — not edges (see E) |
| ← → ↑ ↓ | a node | moves it 10 units (50 with Shift) and raises nodeMoved |
| Alt + ← → ↑ ↓ | a node | goes to the nearest node in that direction |
| E / Shift + E | a node or an edge | goes to the node's next / previous edge (outgoing first) |
| S / T | an edge | goes to the edge's source / target node |
| Escape | an edge, a node | backs out: to the node the edge was reached from, then to the canvas; cancels a pending connection |
| Enter / Space | a source port, then a target port | draws an edge (connected) |
| Delete / Backspace | a node or an edge | raises nodeRemoved / edgeRemoved; focus moves to a neighbour, never lost |
| Space | a node | adds it to the selection or takes it out |
| Ctrl/Cmd + A | the canvas | selects every node |
| Shift + L | the canvas | tidies the layout (not in a read-only picture) |
| + / − / 0 | the canvas | zoom in / zoom out / fit the picture |
| ? | the canvas | reads these keys aloud |
Edges are out of the Tab order on purpose: a graph has more edges than nodes, and the question a person asks is "what is this connected to?", which is E on the node.
Names. Give the canvas labelField: n => n.Title and each node is announced by that field; without it, the words its
template rendered are used (buttons included), so name one when you can. edgeLabelField draws a label on each edge
and reads it as part of the edge's name — "Edge from Approve? to Fulfil, yes". A node is also described by its type
and how it is connected ("Decision. 1 edge out, 1 in."), and moves, connections and removals are announced.
Keys typed inside a node's own content (an input in your template) are left alone.
What it is built on#
The gestures and geometry are @xyflow/system — the framework-independent core of React Flow and Svelte Flow, MIT —
with d3-zoom and d3-drag (ISC). They ship as one chunk (XY, ~84 KB), loaded when a canvas first mounts. The layout
and the edge routing are the kit's own code, a second chunk (Layout, ~15 KB) loaded only when used. The kit contains
no React.
See also#
- Laying a graph out — ranks, routed edges and a run's path on the flow canvas — how the canvas lays a graph out, routes its edges and draws a run's path
- control — foreign UI controls (charts, grids, maps) — the foreign-control ABI the canvas is built on, including per-row templates and
slots from - chunks — assets a control loads on demand — how the
XYchunk ships and loads - Slot(item) — let the caller decide what each row looks like — the same caller-owns-the-row idea for a component
- The barcode kit — a QR and barcode scanner you opt into — another optional control kit
- Graph — every state and every transition of a workflow —
<Wf>.Graph, a workflow's states and transitions, for a picture of it - sizing — how the platform sizes a control's box —
sizing fill, how the canvas takes the box you give it - UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo — Mark / Undo / Redo, what the canvas's step-by-step undo is made of