Summary#
Give FlowCanvas nodes and edges with no positions and it places them itself. With
autoLayout: always it goes further and draws a picture: it also routes every edge, keeps the picture fitted to
its box, and — given where a run has been — draws the run's path over it.
FlowCanvas(
nodes: Fulfilment.Graph.States, edges: Fulfilment.Graph.Transitions,
sourceField: e => e.From, targetField: e => e.To, labelField: n => n.Label,
readOnly: true, autoLayout: always,
visited: ["Placed", "Packed"], highlighted: ["Shipped"]
) { s => Text(s.Label); }The same graph is always the same picture: nothing in the layout is random, and every tie is broken by the order in which you listed the nodes and edges.
Signature#
FlowCanvas(
…,
autoLayout: always, // initial | always | none — `always` is a picture: positions AND routes are the canvas's
layoutDirection: auto, // auto | right | down | left | up
initialField: n => n.IsInitial, // which field of a node row says it is a START
terminalField: n => n.IsTerminal, // which field says it is an END — ends share the last rank
mainPath: ["Placed", "Packed", "Shipped", "Delivered"], // the path kept on one straight row (omit: worked out)
nodeSizing: equal, // equal | natural — in a picture, every node the size of the largest, or its own
highlighted: ["Shipped"], // where a run IS
visited: ["Placed", "Packed"],// where it has BEEN, in order
path: [ … ], // the exact way it went, repeats included, when you know it
failed: [ … ] // where it FAILED
)Description#
What the layout does, step by step#
- Cycles. It walks the graph from the starts, edges in the order you listed them. An edge that leads back to a node the walk is still inside closes a cycle: it is a return. Returns are set aside for ranking and drawn against the flow, dashed.
- Ranks. Every node gets the lowest rank that still puts it after everything that leads to it, so no edge that is not a return ever points backwards. Ends share the last rank. A start that nothing leads to sits one rank before what it feeds.
- The main path is kept on one straight row — see below.
- Order within a rank. An edge that spans several ranks gets a waypoint in each rank it passes. Ranks are then reordered by where each node's neighbours are, sweeping down and up a fixed number of times from two starting orders, keeping the order with the fewest crossings and finishing with swaps of neighbours.
- Positions. Across the flow, nodes are placed by priority — the main path first, exactly straight; then the waypoints, so a long edge runs straight; then the nodes with the most neighbours — each as near the middle of its neighbours as the ones already placed allow. A place is always chosen inside the room that is left, so two nodes cannot overlap.
- Ports. Every edge leaves and enters a node at a point of its own, in the order of where the other ends are, so lines do not cross as they leave a node.
- Routes. Between two ranks every bending edge gets its own track. Tracks are ordered so that edges cross as little as they can and never lie on one another; corners are rounded and each edge ends in an arrowhead.
autoLayout: initial and the Tidy button use steps 1–5 to place the nodes of an editor (and report each move as a
nodeMoved event); edges there keep running from port to port, because the person will move the nodes next.
autoLayout: always uses all seven.
What a picture promises#
For any graph, in any direction:
- no two nodes overlap;
- no edge passes through a node, and no two edges lie on one another — where two lines meet, they cross;
- every edge that is not a return leads to a later rank;
- every end is in the last rank;
- the main path is one straight line;
- an edge from a node to itself is a loop beside the node; two edges between the same two nodes are two lines;
- an edge's label sits on its own edge, beside the node the edge leaves, and never on a node.
These are what the kit's own tests assert, and what the probe lets yours assert.
Starts, ends and the main path#
The layout begins at the nodes marked as starts and puts every node marked as an end in the last rank. It reads
both off the node rows: initialField (default n => n.IsInitial) and terminalField (default n => n.IsTerminal)
select two bool members. A workflow's states have both, so a workflow's picture needs neither prop. Rows
without those fields are simply neither: the layout then starts from the nodes nothing leads to.
The main path is the row the eye follows. Name it — mainPath: ["Placed", "Packed", "Shipped", "Delivered"], node
keys in order — or let the canvas work it out: from the first start to the first end you listed, by the longest way
there. A declaration usually names the outcome it is about first, so that is the row most pictures want.
Which way it flows, and how it fits#
layoutDirection: auto (the default) lays the graph out along the page's reading direction — left to right, or right
to left on a right-to-left page — unless it would be drawn clearly larger flowing down, which is what a phone's
narrow box comes to. It is decided by the two pictures themselves, so a wide box that happens to be short still gets
the layout that fits it. right, down, left and up say it outright.
A picture is fitted to its box: routes, loops and labels included, centred, and re-fitted when the box changes — until the person pans or zooms, after which the view is theirs (the fit button gives it back). It is never enlarged past its own size, so three nodes in a wide box stay the size you drew them. The zoom / fit toolbar takes whichever corner the picture leaves empty.
How big is each node?#
A node is as wide as what your template wants to be, so give its outer box a maxW: and a long name wraps inside it
rather than being cut. In a picture, nodeSizing: equal (the default) then holds every node to the size of the
largest — the same width and height when the picture flows sideways; the same height, each its own width, when it
flows down, because width is what a phone has least of. nodeSizing: natural leaves every node its own size.
Edges — routes, returns, loops and labels#
In a picture, edges are routed: horizontal and vertical pieces with rounded corners, each edge on its own track
between two ranks. (edgeShape is for an editor, where edges run from port to port.) Every edge, in either mode, ends
in an arrowhead.
| drawn as | |
|---|---|
| an edge that closes a cycle (a retry, a way back) | a return: dashed, running against the flow, its arrow at the node it returns to |
| an edge from a node to itself | a small loop beside the node |
| two edges between the same two nodes | two lines, side by side |
edgeLabelField | the label on the edge's first straight run, beside the node it leaves |
A run on the picture#
| prop | means | drawn as |
|---|---|---|
highlighted | where the run is | a thick ring that breathes (still, for someone who prefers reduced motion) |
visited | where it has been, in order | an accent outline and a tick |
failed | where it failed | a broken outline in the danger colour and a ! |
| (everything else) | not reached | drawn quietly |
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. Only those: an edge between two places the run has been, which the run
did not take, stays quiet. When a run passed through a node more than once, path gives the exact way — every node key
in order, repeats included — and decides which edges are taken.
Every state has a mark that does not depend on colour: weight for a taken edge, dashes for a return, a ring, a tick, a broken outline. All colours are the app's theme tokens (theme tokens), so the picture follows light and dark.
What does a screen reader get?#
Beside the canvas, for a screen reader, is the same picture as text: how many nodes and edges and which way it runs, then every node in the order it is drawn — whether it is a start or an end, where the run stands with it, and where its edges lead ("Assessing risk (visited): to Assessed by hand (taken); to Settling terms; to Withdrawn."). It is kept up to date as the run moves. Nodes and edges are also reachable one by one from the keyboard ([[ui-flow-kit#keyboard]]).
Asserting a layout in a test#
The canvas publishes what it counted about its last layout as probe fields:
| field | type | what it is |
|---|---|---|
direction | string | right, down, left or up |
ranks | int | how many ranks |
crossings | int | places where two routed edges cross |
overlaps | int | places where two routed edges lie on one another |
throughNodes | int | routed edges that pass through a node |
nodeOverlaps | int | pairs of nodes that overlap |
labelsOnNodes | int | edge labels lying on a node |
backward | int | edges that are not returns and yet do not lead to a later rank |
returns | int | edges drawn as returns |
endsNotLast | int | ends that are not in the last rank |
mainStraight | bool | the main path lies on one straight line |
mainPath | string | the main path's node keys, joined by " > " |
stable | bool | laid out once more just now, it came out the same |
layout | string | a fingerprint of every position and route |
layoutMs | double | how long the layout took to work out |
taken | int | edges drawn as taken by the run |
outline | string | the picture in words |
Ui.Visit("/orders/A-1001");
Assert.Probe("FlowCanvas", "crossings", 0);
Assert.Probe("FlowCanvas", "mainPath", "Placed > Packed > Shipped > Delivered");
Assert.Probe("FlowCanvas", "edgesTaken", 2);A test that runs without a browser has nothing to measure, so there the canvas lays a picture out at a nominal node
size: the ranks, the order and the routes are the real ones, and every count above is real. osy test --pixels runs
the same test at the sizes the nodes really have.
How long it takes#
A graph of twenty nodes and thirty-five edges is laid out — in both directions, to choose between them — in about ten milliseconds in a browser. The layout is a chunk of its own (about 15 KB), downloaded the first time a canvas lays itself out; a canvas that only draws stored positions never fetches it.
Examples#
// ⚠ 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<TEdge, object> sourceField = e => e.Source;
Func<TEdge, object> targetField = e => e.Target;
Func<TNode, string>? labelField;
Func<TEdge, string>? edgeLabelField;
bool readOnly = false;
[Values(initial, always, none)] string autoLayout = "initial";
[Values(auto, right, down, left, up)] string layoutDirection = "auto";
Func<TNode, bool> initialField = n => n.IsInitial;
Func<TNode, bool> terminalField = n => n.IsTerminal;
string[] mainPath = [];
string[] highlighted = [];
string[] visited = [];
string[] failed = [];
}
slots from nodeTypes.Name
chunks { XY; Layout; }
probe {
int crossings;
string mainPath;
int edgesTaken;
}
}
// A stage of a repair, and a way from one stage to another — plain values, no table behind them.
public class Stage {
public string Id;
public string Label;
public bool IsInitial;
public bool IsTerminal;
}
public class Move {
public string Id;
public string From;
public string To;
public string When;
}
Stage[] Stages() {
return [
new Stage { Id = "Booked", Label = "Booked in", IsInitial = true, IsTerminal = false },
new Stage { Id = "Looking", Label = "Finding the fault", IsInitial = false, IsTerminal = false },
new Stage { Id = "Parts", Label = "Waiting for a part", IsInitial = false, IsTerminal = false },
new Stage { Id = "Mending", Label = "Mending", IsInitial = false, IsTerminal = false },
new Stage { Id = "Returned", Label = "Returned, working", IsInitial = false, IsTerminal = true },
new Stage { Id = "Scrapped", Label = "Beyond repair", IsInitial = false, IsTerminal = true }
];
}
Move[] Moves() {
return [
new Move { Id = "m1", From = "Booked", To = "Looking", When = "Opened" },
new Move { Id = "m2", From = "Looking", To = "Mending", When = "Found" },
new Move { Id = "m3", From = "Looking", To = "Parts", When = "Part ordered" },
new Move { Id = "m4", From = "Parts", To = "Mending", When = "Part arrived" },
new Move { Id = "m5", From = "Mending", To = "Returned", When = "Tested" },
new Move { Id = "m6", From = "Mending", To = "Looking", When = "Still faulty" }, // a way back: drawn as a return
new Move { Id = "m7", From = "Looking", To = "Scrapped", When = "Written off" }
];
}
[Route("/repair")] [AllowAnonymous]
component Repair() {
render {
Box(h: "360px") {
FlowCanvas(
nodes: Stages(), edges: Moves(),
nodeTypes: [ new FlowNodeType { Name = "Stage", Ports = [
new FlowPort { Id = "in", Kind = "target", Side = "left" },
new FlowPort { Id = "out", Kind = "source", Side = "right" } ] } ],
sourceField: e => e.From, targetField: e => e.To, labelField: n => n.Label, edgeLabelField: e => e.When,
readOnly: true,
autoLayout: always, // a picture: the canvas places every node and routes every edge
visited: ["Booked", "Looking", "Parts"], // where this repair has been, in order
highlighted: ["Mending"] // where it is
) { s =>
Stack(justify: Justify.Center, px: 3, py: 2, minW: "104px", maxW: "152px", bg: Colors.Surface, borderW: 1,
border: Colors.Border, rounded: s.IsTerminal ? Radius.Pill : Radius.Md) {
Text(s.Label, fontSize: FontSize.Label, fontWeight: FontWeight.Semibold, textAlign: TextAlign.Center);
}
}
}
}
}That page draws six stages in four ranks — Booked in → Finding the fault → Waiting for a part → Mending on one row, the two ends in the last rank — with Still faulty dashed and running back, the three moves the repair made drawn heavy, and Mending ringed.
See also#
- The flow kit — a node-and-edge canvas whose nodes are your own Osy# — the canvas itself: nodes as your own Osy#, editing, selection, the keyboard
- Graph — every state and every transition of a workflow —
<Wf>.Graph, a workflow's states and transitions, ready to draw - probe — what a control says about itself — asserting what a control publishes about itself
- theme tokens — the tokens the picture is painted with
- accessibility — naming and describing what is on a page