StatifierBlocks.Map (StatifierBlocks v0.41.0)

Copy Markdown View Source

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:

OptionWhat it isDefault
:selectedthe block id the host has selected, or nilnil
:phrasea function from an event name to the words a reader reads for it, answering nil for a name it has no words forno 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 nodeWhat it isWhere its children come from
a blockone ViewModel.Node, keyed by its block idits slots, below
a slotone of a block's slots, keyed <block id>/<slot name>the slot's blocks, in slot order
an empty markera slot with nothing in it, keyed <slot id>/empty, carrying parent and slotnothing

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.branch carries 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: true and 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/1 of its type (see "Captions"), and caption_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:

EndWhere it comes fromMarkEdge into it
donethe :exit edge from the last step of the root's flow into the root's exita solid ringfrom that step, captioned done
abandonan abandon interrupt rule of a group that is that last step, which leaves the group with nothing after ita dashed ringfrom 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.group or core.resumable_group draws its body slot as a slot node with style: "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/1 of 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

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.

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.forceNodeModelOrder is set on EVERY container, because under hierarchyHandling: INCLUDE_CHILDREN a value set on the root does not reach a nested graph;
  • considerModelOrder.strategy is 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 options graph/2 takes; see the moduledoc's "Its input".

t()

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

graph_node()

@type graph_node() :: %{required(String.t()) => term()}

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.

option()

@type option() ::
  {:selected, String.t() | nil} | {:phrase, (String.t() -> String.t() | nil)}

The options graph/2 takes; see the moduledoc's "Its input".

t()

@type t() :: %{required(String.t()) => term()}

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

caption(type)

@spec caption(String.t()) :: String.t() | nil

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".

empty_text()

@spec empty_text() :: String.t()

The text an empty slot's marker carries.

graph(view_model, opts \\ [])

@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".

interrupts(node)

@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"}.

nodes(node)

@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.

start_text()

@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.

timers(graph)

@spec timers(t()) :: [%{required(String.t()) => String.t()}]

Every timer edge in graph, in the order it is drawn, as %{"from" => send, "to" => rule_or_await, "event" => event, "delay" => delay}.