Skip to content

10. Layer 7 — Layout

Contents of DISL 0.5

Automatic layout arranges nodes and routes edges. DISL names layout algorithms, passes options, and declares when layout runs and how it interacts with user placement and bound coordinates. It does not define the algorithms it names, with three exceptions (DISL 0.3): rowPacked, rows and tidyTree are standard algorithms, defined in 10.1 to 10.3, whose results are normative so that every runtime draws the same diagram; each takes its options in a LayoutConfig property of its own name. (DISL 0.4) force with tiers (10.4) is not fully defined, but its result MUST have the properties 10.4 lists.

{
  "layout": {
    "algorithms": {
      "flow": {
        "algorithm": "layered",
        "direction": "right",
        "spacing": { "node": 40, "layer": 80, "edge": 12 },
        "edgeRouting": "orthogonal",
        "options": { "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP" },
        "doc": "Left-to-right flow; transitions become orthogonal."
      }
    },
    "default": "flow",
    "trigger": "manual",
    "respect": "pinned",
    "animate": { "durationMs": 300 }
  }
}
Property Type Description
algorithms map → LayoutConfig Named configurations.
default name Used by the "Arrange" command and trigger.
containers map TypeRef → name or LayoutConfig Layout inside containers of a type (in addition to ContainerSpec's simple layouts).
trigger "manual", "onCreate", "onChange", "onLoadIfMissing", "always" When layout runs automatically. onLoadIfMissing: when a DID definition lacks view data (for example generated by a program). always: positions are never user-controlled (pure layout-driven diagrams). Default "manual".
respect "none", "pinned", "all" Which user positions survive automatic layout: none, only pinned elements (users can pin), or all existing (incremental layout places new elements only). Default "pinned".
incremental bool Prefer stability: minimise movement of existing elements.
animate {durationMs, easing} or false
doc Doc

LayoutConfig

Property Type Description
algorithm string "none", "layered" (Sugiyama), "tree", "mrtree", "radial", "force", "stress", "orthogonal", "box", "grid", "circular", "rectpacking", "lanes" (assign nodes to ordinal bands, then layer within bands), "sequence" (sequence diagrams), "elk:<id>" (any ELK algorithm id), "plugin:<name>"; and the standard algorithms "rowPacked" (10.1), "rows" (10.2) and "tidyTree" (10.3), DISL 0.3.
direction "right", "down", "left", "up" Main flow direction.
spacing {node, layer, edge, component, port} Canvas units.
edgeRouting Routing Routing applied to edges with routing: "layout" or when overrideRouting is true.
overrideRouting bool Layout replaces user bendpoints.
options map string → JSON Pass-through options for the algorithm (ELK option ids recommended for interoperability). Unknown options are ignored.
scope "all", "selection", "component" Default scope when invoked.
includeViewOnly bool Whether notes and annotations take part.
rowPacked, rows, tidyTree RowPacked, RowsLayout, TidyTree (DISL 0.3) The options of the standard algorithm of that name (10.1 to 10.3); given only with that algorithm.
force ForceLayout (DISL 0.4) The options of force (10.4); given only with that algorithm.
doc Doc

Interaction with coordinate systems. Layout only assigns coordinates whose PlacementSource is free or layout. Bound coordinates (a task's start date) are never changed by layout; algorithms receive them as fixed constraints. For example, "lanes" on a schedule keeps x (time) fixed and only packs y within bands. Layout results are snapped with the rules of the coordinate system unless options["dedl.snap"] is false.

The one exception is an algorithm that declares the attributes it writes, as rows does with writes (10.2): it changes those attributes, and only those, as a model edit in one transaction and one undo step, with constraints and hooks as for any edit. Every other bound coordinate stays fixed.

10.1 Row-packed layout (rowPacked) (DISL 0.3)

rowPacked keeps every element on its row and packs each row along the main axis (direction, default "right"; the rows run across it). It suits a variant that shortens what a true-scale view draws, such as a hype cycle's compact view. RowPacked ($defs/RowPacked), the LayoutConfig property rowPacked:

Property Type Default Description
keepRows true true States that rows are kept; it cannot be switched off.
order "start", "document" required The order within a row. start: by the element's start along the packed axis as its base placement gives it (in a viewpoint variant, the placement of the viewpoint it varies, 3.5), ties in model order. document: model order.
gap number required The least distance between two elements on a row, in canvas units.
followConnections TypeRef – A relation type whose targets start after their sources (below).
targetAfter "sourceStart", "sourceMiddle", "sourceEnd" "sourceEnd" The point of the source a target starts no earlier than.
rowsCovered Expression → int 1 The number of rows an element covers from its own row down; context element.
doc Doc
  • rowPacked MUST NOT change an element's row coordinate.
  • Within a row it places elements in the order order gives, each at the smallest main-axis position at which it is at least gap after the previous element on every row it covers. An element that covers several rows (rowsCovered) occupies each of them.
  • With followConnections, the target of a relation of that type MUST start no earlier than the source's targetAfter point; where that is impossible because of a cycle, the cycle is broken at the element earliest in order.
  • LayoutConfig scope: "all" lays out the whole model, not only what is in the viewport.
"compactRows": { "algorithm": "rowPacked", "scope": "all",
  "rowPacked": { "keepRows": true, "order": "start", "gap": 4, "followConnections": "Influence",
                 "targetAfter": "sourceMiddle", "rowsCovered": "int(math.ceil(self.height / 56.0))" } }

In a hype cycle's compact view, Steam engine on row 0 influences Railways on row 1, so Railways starts after the middle of Steam engine's 96-unit banner even if row 1 is otherwise empty.

10.2 Rows-only arrangement (rows) (DISL 0.3)

rows assigns rows and nothing else, for diagrams whose other axis is data, such as a hype cycle's time. It is invoked through a layout action (9.4), typically from an Arrange operation, rather than run automatically. RowsLayout ($defs/RowsLayout), the LayoutConfig property rows:

Property Type Default Description
writes attribute name[] required The attributes the algorithm writes, the row first.
extent map TypeRef → {from, to, rows} required Per type, the extent an element occupies on its row: from and to along the main axis and rows, the rows it covers from its own row down (default 1). GeomExpr in the layoutExtent context (12.3). Elements of types not listed do not take part.
clearance number 0 The least distance between two extents on one row, in canvas units.
affinity TypeRef – A relation type that draws linked elements to nearby rows.
ties "lower", "upper" "lower" Between two free rows equally near the wanted one: the smaller row number (lower) or the larger.
swapPasses int 0 The most passes of neighbouring-row swaps (below).
doc Doc

What takes part. An element takes part when it has a drawable extent: its type is in extent and its extent evaluates, which it does not for an element missing a value it is computed from (a trend without a span, a trigger without a date). An element with no id, or with an id an element earlier in model order already has, does not take part either. An element that does not take part keeps its row.

The assignment. The algorithm takes the elements in the order of their extents' from, then to, then model order. It puts each on a free top row: one from which every row its extent covers keeps clearance from every extent already there, among the rows already in use. With affinity, that is the free top row nearest the average row of its already-placed linked elements, ties deciding between two equally near; otherwise, and for an element with no placed link, the first free top row. When no row in use is free, it takes the lowest top row from which its rows are free, counting the rows beyond the last in use as free, so that rows are opened below. With swapPasses, it then swaps whole neighbouring rows, from the top down, keeping a swap only when it strictly shortens the total row distance of the affinity links, for at most that many passes or until a pass keeps none. A row covered by an element that covers more than one row is pinned: a swap MUST NOT move it, because that would tear the element apart.

  • rows MUST change only the attributes in writes, and MUST assign rows as the assignment above describes.
  • When every extent covers one row, the result uses the fewest rows any assignment of the extents could use, because a row is opened only where that many extents meet. With an extent that covers several rows it need not, and a runtime MUST NOT use fewer rows than the assignment gives.
  • It MUST be deterministic: it reads no row, so running it on its own result changes nothing.
  • The changed values MUST be written in one transaction and one undo step, and only where they change.
  • While no element in its scope has a drawable extent, the layout action is refused with refusals.nothingDrawn; otherwise, when it would change nothing, with refusals.unchanged (9.4).
"arrangeRows": { "algorithm": "rows",
  "rows": { "writes": ["row"], "clearance": 16, "affinity": "Influence", "ties": "lower", "swapPasses": 64,
    "extent": { "Trend":   { "from": "x - 8.0 - textWidth(self.name, 12.0)", "to": "x2" },
                "Trigger": { "from": "x - 8.0 - textWidth(self.name + ' · ' + formatWhen(self.date, diagram.unit, false), 12.0)",
                             "to": "x2" },
                "Note":    { "from": "x", "to": "x + width", "rows": "int(math.ceil(height / 56.0))" } } } }
"arrange": { "label": "Arrange diagram", "for": "diagram",
  "actions": [ { "layout": { "algorithm": "arrangeRows",
    "refusals": { "nothingDrawn": "There is nothing to arrange until this graph has a trend.",
                  "unchanged": "This graph is already arranged." } } } ] }

Three trends from 1800 to 1850, 1840 to 1900 and 1860 to 1900 on rows 0, 1 and 2 are arranged onto rows 0, 1 and 0. A trend's extent starts at its name, 8 units before its banner, and a trigger's at its label, its name and date, 8 units before its circle; x is the left edge of the element's bounds. A note two rows tall pins both its rows, so the rows it spans are never swapped apart. A graph whose only trend has no span has nothing drawable and is refused with "There is nothing to arrange until this graph has a trend.".

10.3 Tidy tree (tidyTree) (DISL 0.3)

tidyTree lays out containment as a tidy tree (Reingold and Tilford): each element's children in model order along the cross axis, each subtree packed as close as its outline allows. direction gives the main axis (default "down", the root at the top), and spacing.node, spacing.layer and spacing.component the distances below. TidyTree ($defs/TidyTree), the LayoutConfig property tidyTree:

Property Type Default Description
order "children" "children" Each element's children in their model order.
parentAlign "firstLastCentre", "childrenCentre" "firstLastCentre" A parent centred over its first and last child, or over all its children's extent.
origin "leftmostAtZero", "rootAtZero" "leftmostAtZero" The whole is shifted so its leftmost node, or its first root, is at cross-axis 0.
rows {stored: "none" | "y", minGap: number} {stored: "none"} Main-axis positions stored per row (below).
drag {carry: "subtree" | "node", across: "reorder" | "none", down: "row" | "none"} {carry: "node", across: "none", down: "none"} What dragging an element does (below).
doc Doc
  • tidyTree MUST place each element's children in their model order along the cross axis, MUST pack each subtree so that no node is closer than spacing.node to a node of a neighbouring subtree at the same depth, measured between their facing edges, and MUST place each parent centred over its first and last child (firstLastCentre) or over all its children's extent (childrenCentre). Top-level elements MUST be packed the same way, spacing.component apart.
  • Distances on the main axis are gaps between edges, not between positions: children MUST lie with their near edge spacing.layer beyond their parent's far edge (with direction: "down", their top spacing.layer below the parent's bottom). The top-level elements MUST share one row, at main-axis position 0, so every depth is one row.
  • With rows.stored: "y", the top-level elements MUST share one position on the main axis, the first stored for any of them in model order, else 0; and all children of one parent MUST share one position on the main axis: the first stored for any of them in model order, else with their near edge spacing.layer beyond the parent's far edge as the parent is drawn, and never with it closer to that edge than rows.minGap. A row's position is the one everything beneath it hangs from, so a moved row carries the rows beneath that store none. The cross-axis position MUST always be the computed one. The stored positions are view data (11.6), or a registration's layout when the model lives in a format binding.
  • With drag.across: "reorder", dropping a dragged element past a sibling's centre MUST reorder it among its siblings in the model; with drag.down: "row", the dropped main-axis position MUST be stored for every element of the row and beneath it; with drag.carry: "subtree", everything beneath the dragged element MUST move with it while dragged. A drag MUST be one transaction and one undo step.
"layout": { "algorithms": { "tree": { "algorithm": "tidyTree", "direction": "down",
  "spacing": { "node": 28, "layer": 56, "component": 56 },
  "tidyTree": { "order": "children", "parentAlign": "firstLastCentre", "origin": "leftmostAtZero",
    "rows": { "stored": "y", "minGap": 16 },
    "drag": { "carry": "subtree", "across": "reorder", "down": "row" } } } },
  "default": "tree", "trigger": "always", "respect": "pinned" }

An agent behavior model draws its tree top-down, its nodes 200 by 60: neighbouring subtrees 28 apart, each row of children 56 below its parent's bottom edge, and several roots side by side on one row, 56 apart. A node dragged past its sibling's centre swaps places with it in the Markdown, and a row dragged down keeps its new height for itself and everything beneath it; a row dragged up stops with its top 16 below its parent's bottom.


10.4 Force layout in tiers (force with tiers) (DISL 0.4)

force pulls related elements together and pushes all elements apart. With tiers it also radiates by element type, from the centre outwards, as a picture of projects, then their work, then who does it and where, reads. ForceLayout ($defs/ForceLayout), the LayoutConfig property force:

Property Type Default Description
tiers (TypeRef or TypeRef[])[] – Node types from the centre outwards: the first entry is the centre, each next one a ring further out. An entry that is a list puts several types on one ring. A node type in no entry is placed by forces alone.
gap number 24 The least distance, in canvas units, between the bounds of any two nodes the layout places.
doc Doc

The algorithm itself is not defined here: a specification that uses tiers SHOULD describe a reference algorithm, with its numbers, where a second host can read it (its companion document), so that hosts draw close pictures. Whatever algorithm a runtime uses, its result MUST have these properties:

  1. Tiers. For two nodes of different tiers, both placed by the layout, the one of the inner tier is no further from the centre than the one of the outer tier, measured from the centre of their bounds to the centroid of the first tier's nodes (or the origin when the first tier is empty). A node without relations is still placed on its tier's ring.
  2. No overlap. No two nodes' bounds, each grown by gap / 2, intersect, whatever their sizes; this MUST hold again after a node's size changes (a group expanded, 6.9), by running the layout again.
  3. Pinned nodes stay. Under respect: "pinned", a pinned node keeps its stored position and acts on the others as a fixed point; the layout MUST NOT move it, and property 2 is met by moving the others.
  4. The same input, the same picture. Opened from the same model and view data, the result MUST be the same on every run and every machine: the start positions follow from the tiers and the model order, the number of iterations is fixed, and nothing is random.
  5. Little movement. When the model changes while the view is open, the layout MUST start from the positions on screen rather than from the computed start, so that nodes the change does not touch move as little as the forces allow. The result after a run of live changes may therefore differ from the result the same model gives when opened (property 4 is about opening).
  6. Positions are not data. The positions the layout computes are not view data: they MUST NOT be written, unless the user pins a node (7.3).

A drag pins. Under respect: "pinned" with a trigger other than manual, dragging a node the layout placed MUST pin it where it was dropped, as one edit with the move; dropping a new node from the toolbox pins it at the drop point. A node added by any other means (an edit of the file, an operation) is not pinned.

"layout": {
  "algorithms": { "rings": { "algorithm": "force", "force": { "tiers": ["Board", "Item", ["Person", "Team"]], "gap": 32 } } },
  "default": "rings", "trigger": "onChange", "respect": "pinned", "animate": { "durationMs": 250 }
}

Boards sit in the middle, their items around them and the people and teams who work on them outermost; a person dragged into a corner stays there and the others arrange themselves around.

Illustrated by

Referenced from