StatifierUI.Diagram (StatifierUI v0.9.1)

Copy Markdown View Source

Renders a compiled Statifier.Machine and an active configuration as Mermaid stateDiagram-v2 source, for display via Kino.Mermaid (or any other Mermaid consumer - this module is pure and depends on neither Kino nor LiveView).

render/2 is a pure function: machine and configuration in, diagram source out. That interface is the stable part. The Mermaid backend is the Livebook inspector's first rendering (statifier-ui ADR-0008 fixes the destination stack as client-side elkjs producing SVG, and explicitly leaves this first cut to the inspector epic); when the elkjs renderer arrives it replaces the body of this module, not its callers.

The accepted Mermaid compromise

Mermaid cannot draw a transition between internal states of two different composite states - the cross-hierarchy edges SCXML's LCCA semantics make routine, and the limitation that disqualified Mermaid as the destination renderer (ADR-0008). Rather than drop those transitions, render/2 lifts each one to the pair of composite siblings under the endpoints' least common ancestor and draws the edge there, with a [lifted: <source> -> <target>] marker appended to the label naming the real endpoints. The same lifting applies to a composite's [*] initial marker when its resolved initial state is a deep descendant rather than a direct child - the marker there is [deep: <target>].

What the source contains

  • Every state except the synthesized :scxml root is declared with a stable s<index> alias (the engine's document-order state index, the identity vocabulary of statifier ADR-0012), so tests and callers can address nodes without depending on how labels are escaped.
  • Compound states are composite blocks; parallel states are composite blocks whose children are separated by Mermaid's -- region divider.
  • <final> states carry a (final) label suffix; history states carry (H) (shallow) or (H*) (deep) - Mermaid has no native pseudo-state notation for either.
  • Every compound state carries a [*] --> sN marker per resolved initial state, including one written without an initial attribute (the compiler resolves it to the first child). A parallel state carries none, because entering it enters every region at once.
  • Transitions are labeled with their event descriptors; a guarded transition carries a [cond] marker (the Machine retains the compiled expression, not its source text).
  • A transition label is written with : escaped as Mermaid's #58; entity code (and # as #35; ahead of it). Mermaid's grammar ends the label at the next : and offers no quoted form for it, so a prefixed event descriptor - myapp:authorize - would otherwise make the whole source unparseable. The escape renders as a colon.
  • A targetless transition - spec-legal, and the way a chart runs executable content without changing configuration - is drawn as a self-edge marked [internal]. UML puts one inside the state's box; Mermaid has no in-box notation, and dropping the transition entirely is worse: it renders a state that handles an event as one that ignores it.
  • A transition written type="internal" also carries [internal]. SCXML's external default is left unmarked, so the marker means "this edge does not exit and re-enter its source".
  • A history state's default transition (State.history_default) is drawn with a [default] marker. It is not selectable, so it is not in State.transitions; without this the (H) / (H*) label would name a pseudo-state whose fallback target is invisible.
  • Active states - every index in the configuration, ancestors included, per the full-configuration convention of statifier-ui ADR-0005 - are assigned the active Mermaid class. Out-of-range indexes and the root are ignored, so a stale or empty configuration degrades to an unhighlighted chart rather than an error.

Theming the active highlight

The active class is styled by a classDef line the source carries, and by default that line is the shipped light palette. A host whose chrome is dark passes active_style: :none to render/3, which drops the classDef and leaves the class sN active assignment: the rendered nodes still carry the class, so the host's own stylesheet or Mermaid theme reaches them. A host that would rather keep the styling inside the source passes its own classDef body as a binary instead. Neither path asks a host to post-process the source it was handed.

StatifierUI.Inspector.diagram/3 takes the same option and passes it through, and StatifierUI.Live.diagram/1 takes it as an attribute. The Livebook inspector (StatifierUI.Kino) builds its own fold options and forwards none, so it always draws the default palette.

Known limits of this projection

These are accepted, not defects to file. Each is a thing the Mermaid backend cannot express; the destination elkjs renderer (ADR-0008) is where they get fixed, and none of them makes the source silently wrong - every one is either marked in the output or listed here.

  • Lifted edges lose their real endpoints in the picture. A lifted edge is drawn composite-to-composite and the true endpoints survive only in the [lifted: ...] label text, not in the geometry. This also covers an edge between two regions of the same parallel state: it is lifted to the two regions, which reads as leaving one lane for another when the semantics are an exit and re-entry within them.
  • Internal transitions are drawn as self-edges. The arrow implies a round trip through exit and entry that an internal transition does not make; only the [internal] marker says otherwise.
  • Pseudo-states are ordinary nodes. History and final states are drawn as boxes with a label suffix, so a chart with many of them reads as having more real states than it has.
  • Shallow and deep history differ only in the label. (H) versus (H*) is the whole distinction; what each restores is not drawable.
  • Executable content is not drawn at all. onentry, onexit, transition content, <invoke> and donedata have no notation here. An edge label says which event fires a transition, never what it does.
  • Nothing is laid out by this module. Mermaid decides geometry, so region order within a parallel is document order and nothing keeps a deeply nested chart from rendering wider than it is readable.

Summary

Types

How the active class is styled in the emitted source.

Options accepted by render/3.

Functions

Renders machine with configuration highlighted, as Mermaid stateDiagram-v2 source.

Types

active_style()

@type active_style() :: :default | :none | String.t()

How the active class is styled in the emitted source.

  • :default - the shipped light palette, emitted as a classDef.
  • :none - emit no classDef at all. The class sN active assignment stays, so the rendered nodes still carry the class and the host's own stylesheet or Mermaid theme decides how they look.
  • a binary - the body of the classDef, verbatim: Mermaid style declarations separated by commas, as in "fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe".

opt()

@type opt() :: {:active_style, active_style()}

Options accepted by render/3.

Functions

render(machine, configuration, opts \\ [])

@spec render(Statifier.Machine.t(), Enumerable.t(), [opt()]) :: String.t()

Renders machine with configuration highlighted, as Mermaid stateDiagram-v2 source.

configuration is any enumerable of state indexes - typically the full active configuration (MapSet.t(non_neg_integer())) a trace message or Statifier.MachineState carries. Pass [] (or an empty set) for a chart that is not running.

Options

  • :active_style - how the active-configuration highlight is styled; active_style/0. Defaults to :default, which emits the shipped light palette unchanged.

A host under a dark theme passes :none and styles .active from its own stylesheet, or passes its own classDef body as a binary. Neither requires post-processing the returned source.

Examples

iex> {:ok, machine} =
...>   Statifier.compile("""
...>       <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="pending">
...>         <state id="pending">
...>           <transition event="authorize.approved" target="authorized"/>
...>         </state>
...>         <state id="authorized"/>
...>       </scxml>
...>   """)
iex> source = StatifierUI.Diagram.render(machine, [1])
iex> String.starts_with?(source, "stateDiagram-v2")
true
iex> source =~ "class s1 active"
true
iex> source =~ "classDef active"
true

iex> {:ok, machine} =
...>   Statifier.compile("""
...>       <scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="pending">
...>         <state id="pending"/>
...>       </scxml>
...>   """)
iex> source = StatifierUI.Diagram.render(machine, [1], active_style: :none)
iex> source =~ "class s1 active"
true
iex> source =~ "classDef"
false