The Map: the block document drawn as boxes in boxes, laid out by ELK in the browser. This module builds the graph a layout hook lays out and draws; it computes no position itself and calls no layout engine. Its input is the view model and the host's two options below; it never reads the document itself.
A reader of the view model, never a second model
The graph is derived from StatifierBlocks.ViewModel on every change
and is never stored: no coordinate, no connector and no hand-placed
position reaches the document or any table. What an author edits is the
block tree, and the map is one more way of reading it, beside the
indented list a host draws from the same
StatifierBlocks.ViewModel.outline/1. The two cannot disagree about what
is in the document, because nodes/1 below is the outline's own block
list and the test beside this module holds them equal. It is not a
layout mode of the editor: it reads the struct the editor reads, and
changes nothing in it.
Its input
graph/2 takes the view model and two options, both the host's:
| Option | What it is | Default |
|---|---|---|
:selected | the block id the host has selected, or nil | nil |
:phrase | a function from an event name to the words a reader reads for it, answering nil for a name it has no words for | no words: every name as authored |
The selection marks one block's node "selected" => true and changes
nothing else in the graph, so a host whose selection moves hands the hook
the same boxes. A selection naming no block marks nothing.
The shape
| Graph node | What it is | Where its children come from |
|---|---|---|
| a block | one ViewModel.Node, keyed by its block id | its slots, below |
| a slot | one of a block's slots, keyed <block id>/<slot name> | the slot's blocks, in slot order |
| an empty marker | a slot with nothing in it, keyed <slot id>/empty, carrying parent and slot | nothing |
Every block but the root carries gap: true: it sits in a slot, so there
is a place right after it an insert can target, the same place the list's
"+" under its row targets. The root sits in no slot. An empty marker is
the other kind of gap: the head of the slot it stands for.
A block whose only body slot stacks (ViewModel.arrangement/1 is
:stack) holds that slot's blocks directly, because a sequence drawn
inside a box inside a box says nothing the one box does not. Every other
slot is a node of its own: a branch's arms side by side, a parallel's
lanes, a group's interrupt rules and a drafts shelf. A group is the one
exception the other way: its body stacks, but it is drawn as a slot node
of its own all the same (see "The Group" below). An empty slot is
drawn as a marker rather than left out - an outcome nobody has written a
step for yet is exactly what a reader of the map has to be able to see.
Consecutive blocks in one body slot are joined by a sequence edge, in
ViewModel.flow_children/1 order (a rejoin edge where the first is a
branch; see "The Branch" below); besides the edges out of the start
dot and into the end marks (below), nothing else is an edge the layout
sees (a group's interrupt edges are drawn after it, below). A rail's
blocks are not joined - a group's interrupt rules are alternatives that
each watch the whole body, not steps that run one after another - and
neither is a tray's. Connectors are drawn, never authored.
A box's title is its type's name and the line under it is the block's
sentence, with the event names the host's :phrase has words for read
as words: a send reads "Send word that" and the words, a wait "Wait
until" and the words, a name anywhere else the words alone, and a name
the host has no words for stays as authored. Where the title and the
sentence are the same words ("Invoke", "Raise") the line is left off
rather than said twice.
The Branch
A branch is one decision, and its box says so in three ways.
- The header names every arm, in order. Where every other container
carries its sentence, a
core.branchcarries the package's own "one of" and then one numbered entry per arm, in slot order, each the arm's label and its condition as authored (When "renew": copy.holds == 0 AND loan.renewals < 2), the arms with no condition ("Otherwise", "Cannot be decided") by their label alone. This header is the map's own: the branch type's sentence, which the list draws, is not changed by it. - The arms sit under one band. The branch's node carries
band: trueand room above its arms; the hook draws one band spanning every arm, with a small fork mark at its left, from the boxes the layout placed. The band and the mark are drawn inside the branch's own box, so a click on either is a click on the branch. - The arms rejoin at its bottom edge. The edge from a branch to the
step after it carries
kind: "rejoin", and the hook draws it with a join dot where it leaves the branch. A branch that is the last step of the document's own flow rejoins into the document's end (see "The end"); a branch that ends a nested flow needs none, since the flow it ends is itself joined to what follows it. - The band carries a caption. The branch's node carries
caption,caption/1of its type (see "Captions"), andcaption_width, the room the caption and the fork mark need; the hook writes the caption inside the band, after the fork mark, and never draws the band narrower than that room.
The start
A document starts when its host starts an execution of it, and the map
says where that execution begins the way a state chart does: one filled
dot, the initial mark, with no text, and one edge from it into the first
step of the document's own flow. The dot is a node of kind: "start",
keyed <root id>/start, drawn first inside the root's box; its edge
carries kind: "start" and one ELK label, start_text/0, so the layout
leaves the caption its room. The caption reads the same on every
document: what the document listens for while it runs is the description
region's to say, not the edge's.
A root whose flow is not drawn inside it has no first step there, so its dot stands above the root's box and the edge goes into the box itself. Like the end marks, the dot is not a block: it is in no outline, selects nothing and arms no insert.
The end
Where the document finishes is drawn the way a state chart draws it too:
a final mark, a dot inside a ring, at every end of the document's own
flow, one per way it finishes, with the outcome named on the edge into
it. The ends are the ones StatifierBlocks.Describe.outline/3 answers,
read here off the same view model, and the test beside this module holds
the two equal for every fixture:
| End | Where it comes from | Mark | Edge into it |
|---|---|---|---|
done | the :exit edge from the last step of the root's flow into the root's exit | a solid ring | from that step, captioned done |
abandon | an abandon interrupt rule of a group that is that last step, which leaves the group with nothing after it | a dashed ring | from the group, dashed, captioned abandon |
Each mark is a node of kind: "end" carrying its outcome, keyed
<root id>/end/<outcome>, drawn right after the last step; its edge
carries the same outcome and one ELK label, the outcome's name, so the
layout leaves the caption its room. The edge out of a branch stays a
rejoin (see "The Branch"); any other edge into a mark is of
kind: "end". done is written here rather than read from the palette:
it is the outcome every block type declaring no outcomes of its own has,
the one a root that runs its steps finishes with, and the test holds it
equal to the outline's own outcome for every fixture's root.
Only the document's own flow ends. A step that is last in a nested flow
- a Send at the foot of an arm or of a group's body - hands back to the block around it, which goes on, so nothing is drawn after it and it does not read as an end. A root whose flow is not drawn inside it, or that has no step yet, draws no end. Like the start dot, an end mark is not a block: it is in no outline, selects nothing and arms no insert.
The Group
A group's body is what it is for, and its interrupt rules watch that body from beside it, so the map draws the body first and larger.
- The body is a pane. A
core.grouporcore.resumable_groupdraws its body slot as a slot node withstyle: "body", first among its children, rather than holding the body's blocks directly. The pane is held wider than the rules column and at least as tall, at the same per-character estimate as every other size here, so the body is the larger of the two whatever the rules say. - The rules are a side column. The interrupt rules' slot node is
laid out on its own, in one column, rules top to bottom in rail order,
and ELK places it beside the pane. It is laid out apart from the rest
of the graph (
hierarchyHandling: SEPARATE_CHILDREN) because nothing joins a rule to anything ELK sees: the interrupt edges are the hook's (below), so no edge crosses into the column. It is laid out left to right, where rules nothing joins share one layer, and a layer runs top to bottom. Its minimum size is written width first, the documented way round: a layout of its own is not transposed (see "Sizes"). - The column carries a caption. The rules' slot node carries
caption,caption/1of the group's type (see "Captions"), which the hook draws under the column's label.
Captions
A structural container the map draws apart says what its type does in
one line: a branch on its band, a group on its rules column, under the
column's label. Nothing else carries a caption, and a leaf never does.
The words are the type's own: caption/1 is the first sentence of
StatifierBlocks.BlockType.explain/1, the paragraph the package's
optional explain/0 callback answers, and the description region shows
the whole paragraph. A caption is one line, so a first sentence wider
than a leaf's widest line is cut at a word and ends in "...": the
caption is the one text on the map that is cut, because the region
carries all of it.
Interrupt edges
A group's interrupt rules are not joined to each other, but each one does
lead somewhere: an abandon rule leaves the group by its exit and a
resume rule goes back to the head of its body. The group's node carries
those as interrupts, one per rule, in rail order, and the hook draws
each one dashed from the rule's box to where it leads; the head of the
body is the first node drawn in the body's pane. They are handed to
the hook beside the graph's edges, not among them, and drawn after the
layout from the boxes it placed, so an interrupt edge never moves a box.
They are the edges StatifierBlocks.Describe.outline/3 answers with
kind: :interrupt, read here off the same view model - a rule's
outcome, on the interrupts rail of a core.group or a
core.resumable_group - because the map is built from the view model
alone; the test beside this module holds the two equal for every fixture.
Timer edges
A send with a delay arms an event that arrives later, and the interrupt
rule or the wait that names that event is what it arms. The graph's root
carries those as timers, one per delayed send and rule or await naming
its event, sends in reading order and each send's targets in reading
order, each with its event and its delay as the send's config holds
it; the hook draws each one dotted from the send's box to the rule's or
the await's, labelled with the delay. Like the interrupt edges they are
handed to the hook beside the graph's edges, not among them, and drawn
after the layout, so a timer edge never moves a box. They are the edges
StatifierBlocks.Describe.outline/3 answers with kind: :timer, read
here off the same view model - only blocks the palette resolved, and
nothing on a drafts shelf - and the test beside this module holds the two
equal for every fixture. A timer edge is not a transition: it says which
block hears the event a send arms, so it is dotted where an interrupt
edge is dashed.
Timer marks
Two blocks wait on a clock in different ways, and a box says which with a
small mark at its top right: a core.await carries the wait mark (it
waits, in its own step, for an event or its timeout), and a core.send
with a delay carries the clock mark (the event it arms fires later, after
the step has moved on). A delay counts when
StatifierBlocks.Core.Duration.duration?/1 accepts it, the test the
send's own sentence and the timer edges use; a send with no delay
carries neither mark. A mark is drawn inside its block's box, so a click
on it is a click on the box.
The happy path runs straight
A reader follows the steps that run when nothing goes wrong, so those are drawn on one vertical line, and what can interrupt them sits to the side. Inside a group's pane the body's steps already share a line: ELK centres a chain of steps on one another, against the pane's left edge. What would bend the line is the group itself, which is wider than its body by the rules column: an edge attached to the middle of the group lands between the body and the column, and the steps before and after the group follow it there.
So a group attaches its edges where its body's line is. Its node
carries two ports, one on its top side for the edge in and one on its
bottom side for the edge out, at a fixed x (portConstraints: FIXED_POS): the group's and the pane's left padding plus half the
widest step in the body. The steps before and after the group line up
under that point, and the rules column and its dashed abandon edges
stand to the right of it. The edges themselves still join block to
block: the hook moves an edge onto its node's port only for the layout,
and hands the edge back with its own ends.
The line is placed by estimate, from the widths this module gives its leaves, so a group puts out its ports only when every step in its body is a leaf or an empty marker. A body that holds a container, whose width is ELK's to find, keeps its edges at the middle of the group, as before.
Order is semantic, so it is forced
A branch evaluates its arms in slot order and a sequence runs its steps in order, so a layout that swapped two arms to save a crossing would draw a different chart. Two ELK options keep the model order, and the two are set differently on purpose:
crossingMinimization.forceNodeModelOrderis set on EVERY container, because underhierarchyHandling: INCLUDE_CHILDRENa value set on the root does not reach a nested graph;considerModelOrder.strategyis set on the ROOT ONLY. The root value is all the order this map needs, and a per-container value has been seen to throw inside elkjs on a differently shaped graph, so it is kept off the containers rather than found out again.
The test beside this module pins both.
A group's rules column is laid out on its own, and there
forceNodeModelOrder alone does not hold the order: rules that nothing
joins are each a connected component, and ELK packs separate components
by size, not in model order. The column keeps its components together
(separateConnectedComponents: false), so its rules share one layer and
that layer keeps model order.
Sizes
Text is measured by estimate, a fixed width per character, rather than by the browser: the graph is built on the server, and a node sized after the page measured it would be a layout that depends on which fonts loaded. The estimate errs wide, and no box is narrower than the widest line it carries, its title included: a single word longer than a line widens its box rather than running out of it, and a container is held at least that wide whatever its children need.
Summary
Types
One graph node, in elkjs's JSON shape plus the fields the hook draws
from: kind (with outcome, all the start dot and an end mark carry),
title and lines, on a slot style, on a timer block
mark, on a group with interrupt rules interrupts, on a branch band,
caption and caption_width, on a group's rules column caption, on
a group whose body is all leaves the ports its edges attach to, and
on the selected block selected.
The whole graph: the ELK root, with the document's root block as its one
child, and timers, the timer edges the hook draws after the layout.
Functions
The one-line caption the map draws for a block of type, or nil: the
first sentence of the type's explanation for a branch and the two group
types, cut at a word to one line; see the moduledoc's "Captions".
The text an empty slot's marker carries.
The graph for view_model, ready to be JSON-encoded onto the hook.
Every interrupt edge in graph, groups in the order a pre-order walk
meets them and each group's rules in rail order, as
%{"from" => rule, "group" => group, "to" => "exit" | "body"}.
The block ids in graph, in the order a pre-order walk of it meets them.
What starts a document, in the words the map's start edge carries and the description region says: the one place the sentence is written.
Every timer edge in graph, in the order it is drawn, as
%{"from" => send, "to" => rule_or_await, "event" => event, "delay" => delay}.
Types
One graph node, in elkjs's JSON shape plus the fields the hook draws
from: kind (with outcome, all the start dot and an end mark carry),
title and lines, on a slot style, on a timer block
mark, on a group with interrupt rules interrupts, on a branch band,
caption and caption_width, on a group's rules column caption, on
a group whose body is all leaves the ports its edges attach to, and
on the selected block selected.
The options graph/2 takes; see the moduledoc's "Its input".
The whole graph: the ELK root, with the document's root block as its one
child, and timers, the timer edges the hook draws after the layout.
Functions
The one-line caption the map draws for a block of type, or nil: the
first sentence of the type's explanation for a branch and the two group
types, cut at a word to one line; see the moduledoc's "Captions".
@spec empty_text() :: String.t()
The text an empty slot's marker carries.
@spec graph(StatifierBlocks.ViewModel.t(), [option()]) :: t()
The graph for view_model, ready to be JSON-encoded onto the hook.
The root carries the layout options every container inherits and the two
it alone may carry; see the moduledoc on why considerModelOrder is one
of them. opts are the host's selection and its words for event names;
see the moduledoc's "Its input".
@spec interrupts(t() | graph_node()) :: [%{required(String.t()) => String.t()}]
Every interrupt edge in graph, groups in the order a pre-order walk
meets them and each group's rules in rail order, as
%{"from" => rule, "group" => group, "to" => "exit" | "body"}.
@spec nodes(t() | graph_node()) :: [String.t()]
The block ids in graph, in the order a pre-order walk of it meets them.
The map's claim to be the document is that this list is the outline's: every block exactly once, in reading order. Slot nodes and empty markers are not blocks and are not in it.
@spec start_text() :: String.t()
What starts a document, in the words the map's start edge carries and the description region says: the one place the sentence is written.
Every timer edge in graph, in the order it is drawn, as
%{"from" => send, "to" => rule_or_await, "event" => event, "delay" => delay}.