Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The flow kit — a node-and-edge canvas whose nodes are your own Osy#

FlowCanvas(nodes: steps, edges: links, nodeTypes: types, nodeMoved: Move, connected: Link) { slot Task { s => … } }

A modelling canvas — pan, zoom, drag nodes, draw edges between ports, select and delete, by mouse and by keyboard — shipped as an optional KIT. Nodes and edges are your app's own rows, and each node is rendered by a template you write in Osy#, so a node can hold anything a page can. It lays a graph out itself and can draw a run's path over it. The dragging is React Flow's framework-free core; the layout is the kit's own.

preview1 example compiled by CIuicontrolscanvasdiagram

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.

autoLayoutwhen the canvas lays out
initial (default)once, for a graph nobody has placed yet (every node at the same point)
alwaysevery time the nodes or edges change, ignoring the position fields — a picture of a structure
nonenever 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.

keyondoes
Tabthe canvasmoves between nodes and their ports — not edges (see E)
← → ↑ ↓a nodemoves it 10 units (50 with Shift) and raises nodeMoved
Alt + ← → ↑ ↓a nodegoes to the nearest node in that direction
E / Shift + Ea node or an edgegoes to the node's next / previous edge (outgoing first)
S / Tan edgegoes to the edge's source / target node
Escapean edge, a nodebacks out: to the node the edge was reached from, then to the canvas; cancels a pending connection
Enter / Spacea source port, then a target portdraws an edge (connected)
Delete / Backspacea node or an edgeraises nodeRemoved / edgeRemoved; focus moves to a neighbour, never lost
Spacea nodeadds it to the selection or takes it out
Ctrl/Cmd + Athe canvasselects every node
Shift + Lthe canvastidies the layout (not in a read-only picture)
+ / − / 0the canvaszoom in / zoom out / fit the picture
?the canvasreads 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#

Related

Laying a graph out — ranks, routed edges and a run's path on the flow canvas

How the flow canvas draws a directed graph by itself: nodes in ranks from the starts to the ends, the main path on one…

control — foreign UI controls (charts, grids, maps)

A `control` block declares the contract of a foreign UI widget — a chart, a data grid, a map — that a small JavaScript…

chunks — assets a control loads on demand

A `chunks { }` block declares assets a control ships but does not need at mount — a maths renderer, a diagram engine, a…

probe — what a control says about itself

A `probe { }` block declares the facts a control publishes about its OWN internal state, so an app's tests can ask for…

Slot(item) — let the caller decide what each row looks like

`Slot(item)` renders the caller's template once for that item. It is how a component owns the list — the layout, the…

The barcode kit — a QR and barcode scanner you opt into

A live camera scanner for QR codes and barcodes, shipped as an optional KIT you depend on with one line. It owns the…

UnitOfWork savepoints — Mark, Undo, Redo, RollbackTo

Marks form a stack on the unit of work. UnitOfWork.Mark(label) records everything staged so far and answers a…