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. An entry wider than a line wraps, and its later lines are indented under its label, by the width of its number, so a wrapped condition does not read as an entry of its own. 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, carrying the word Start, 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, and the graph's first node,
ahead of the root's box rather than inside it: the layout puts it above
that box, so the dot and its caption come first in reading order, above
the root's title and every box under it. The word is the dot's one ELK
label, which the node asks the layout to place outside it, on its
right and level with it, so the layout leaves the word its room and the
edge still leaves the dot itself; an end mark carries End the same way.
Its edge is the graph's own, carries
kind: "start" and one ELK label, start_text/0, so the layout leaves
the caption its room. The caption names who starts the document, "Starts
when the system is told to", and 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 edge goes into the root's 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 in tail position - that last step, or the last step of a flow it runs, and so on down - which leaves the group with nothing after it | a dashed ring | from each such group, dashed, captioned abandon |
The root's last step is in tail position, and so is the last step of the
body of a sequence or a group in tail position, or of any arm of a
branch in tail position: the steps the outline's :exit edges chain
from, container by container, into the root's exit. However many such
groups abandon, the document has one abandon end, and each of them
has its own edge into it.
Each mark is a node of kind: "end" carrying its outcome, keyed
<root id>/end/<outcome>, drawn right after the last step, and carrying
the word End as its one ELK label, whichever way it finishes: the
outcome stays on the edge into it, not on the mark. 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, the words "Rules: watch the steps while they run", which the hook draws under the column's label, andexplanation_lines,caption/1of the group's type (see "Captions") wrapped to lines, which the hook draws under that, one line each.
Captions
A structural container the map draws apart says what its type does: a
branch on its band, a group on its rules column, under the column's
label and its caption. Nothing else carries one, 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. The sentence is whole: never cut, never ended in
"...". A branch's band carries it as one line and is held wide enough
for it; a group's rules column carries it as explanation_lines, the
sentence wrapped to lines no wider than a box's (a word longer than a
line keeps a line of its own), the way the key carries a mark's line
as its lines, and the column is held wide and tall enough for every
one of them.
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 what it arms
is the interrupt rule or the hourglass step that hears it. 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 tests beside this module hold the two
equal for every fixture and for a delayed send that is the document's
root, from which neither draws one. 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
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), a core.wait
carries the clock mark (time passes: it holds its step for its
duration, which it always has, since the duration is required), and a
core.send with a delay carries the clock mark too (the event it arms
fires later, after the step has moved on). The clock says time passes;
the hourglass says a step waits for an event. The clock on a wait is a
mark only: a wait hears no timer edge. 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 has a width only ELK can find, so this module gives that group no ports and places nothing: the hook lays such a graph out twice. The first layout says where the body's steps stand; the hook then puts the group's two ports at the centre of the widest of them, in the same shape as the ones above, and lays the graph out again. A graph whose every group carries its ports is laid out once. The first layout is already a whole drawing, its line bent at the group, so when the second layout fails the hook draws the first rather than the error pane.
The drafts shelf
A drafts shelf holds steps an author has put down but not wired into the
flow, so the path is read without it. Its block's node carries drafts,
%{"count" => count, "label" => label}: count is how many steps sit on
the shelf, the blocks in its slot, and label is the row its drawer
reads, "Drafts: 2 steps not wired in" ("1 step" for one). The node stays
where the outline visits it, so nodes/1 is unchanged; the hook takes it
out of the drawing before the layout and lays it out on its own, so the
path is laid out as though the shelf were not there.
The hook draws the shelf in a drawer under the drawing and outside it: a
native disclosure element whose row reads label, holding the shelf as
its own drawing. On a page that cannot edit the drawer is closed, and a
click on its row opens it in place; on a page that can edit it is drawn
open, in the same place. Whether it is open lives in that element, not in
the hook. The shelf's entry in the key (below) is the same row.
The key
The graph's root carries legend, the key the hook draws beside the
drawing: one entry per kind of mark the drawing uses, each
%{"mark" => mark, "line" => line, "lines" => lines}, in a fixed
order, and none for a mark the drawing does not use. line is what the
mark means, in words, and lines is the same words wrapped to a line
somewhat wider than a box's, which is what the hook writes beside the
mark's glyph.
| Mark | Drawn where the graph has |
|---|---|
start | the start dot, which every graph has |
end | a done end mark |
end-abandon | an abandon end mark |
arrow | a solid edge: the start edge, a connector, or an edge into the done end |
fork | a branch's band, with its fork mark |
join | a rejoin edge, with its join dot |
interrupt | a dashed edge: an interrupt edge, or an edge into the abandon end |
wait | a block carrying the wait mark |
clock | a block carrying the clock mark |
timer | a timer edge |
drafts | a drafts shelf, its line the drawer's own row, label |
The key is data in the graph, read off the graph itself, so the Map stays
pure and the key cannot name a mark the drawing lacks. The map region is
hidden from assistive technology, so the same lines reach the description
region as text: StatifierBlocks.Map.Info.idle/4 carries them as its
"Legend" fact.
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
beside the ELK labels holding their word and the layoutOptions
placing it),
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 and
explanation_lines, on
a group whose body is all leaves the ports its edges attach to, on a
drafts shelf drafts, and on the selected block selected.
The whole graph: the ELK root, with the start dot and the document's root
block as its two children, the start edge as its one edge, timers, the
timer edges the hook draws after the layout, and legend, the key the
hook draws beside the drawing.
Functions
The caption the map draws for a block of type, or nil: the whole
first sentence of the type's explanation for a branch and the two group
types, never cut; 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
beside the ELK labels holding their word and the layoutOptions
placing it),
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 and
explanation_lines, on
a group whose body is all leaves the ports its edges attach to, on a
drafts shelf drafts, 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 start dot and the document's root
block as its two children, the start edge as its one edge, timers, the
timer edges the hook draws after the layout, and legend, the key the
hook draws beside the drawing.
Functions
The caption the map draws for a block of type, or nil: the whole
first sentence of the type's explanation for a branch and the two group
types, never cut; 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}.