Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

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

FlowCanvas(nodes: states, edges: transitions, readOnly: true, autoLayout: always, visited: [ … ], highlighted: [ … ]) { s => … }

How the flow canvas draws a directed graph by itself: nodes in ranks from the starts to the ends, the main path on one straight row, every edge routed on a line of its own with an arrowhead, returns and loops drawn as what they are, the whole picture fitted to its box and turned top-to-bottom on a phone — and a run's path drawn over it.

preview1 example compiled by CIuicontrolscanvasdiagram

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#

  1. 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.
  2. 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.
  3. The main path is kept on one straight row — see below.
  4. 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.
  5. 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.
  6. 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.
  7. 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 itselfa small loop beside the node
two edges between the same two nodestwo lines, side by side
edgeLabelFieldthe label on the edge's first straight run, beside the node it leaves

A run on the picture#

propmeansdrawn as
highlightedwhere the run isa thick ring that breathes (still, for someone who prefers reduced motion)
visitedwhere it has been, in orderan accent outline and a tick
failedwhere it faileda broken outline in the danger colour and a !
(everything else)not reacheddrawn 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:

fieldtypewhat it is
directionstringright, down, left or up
ranksinthow many ranks
crossingsintplaces where two routed edges cross
overlapsintplaces where two routed edges lie on one another
throughNodesintrouted edges that pass through a node
nodeOverlapsintpairs of nodes that overlap
labelsOnNodesintedge labels lying on a node
backwardintedges that are not returns and yet do not lead to a later rank
returnsintedges drawn as returns
endsNotLastintends that are not in the last rank
mainStraightboolthe main path lies on one straight line
mainPathstringthe main path's node keys, joined by " > "
stableboollaid out once more just now, it came out the same
layoutstringa fingerprint of every position and route
layoutMsdoublehow long the layout took to work out
takenintedges drawn as taken by the run
outlinestringthe 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#

Related

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

A modelling canvas — pan, zoom, drag nodes, draw edges between ports, select and delete, by mouse and by keyboard —…

Graph — every state and every transition of a workflow

The whole shape of a workflow — every state (with its label, and whether it starts or ends a run) and every transition…

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…

accessibility

Tags already give an element its role, focus and keyboard behaviour. The semantic props say the rest: `role:` for a…

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…