Status: Implemented

This document specifies the layout algorithms under Visualize.Layout: the Hierarchy node structure shared by every hierarchical layout; the hierarchical layouts Tree, Cluster, Pack, Partition and Treemap; the relational layouts Chord and Sankey; the force-directed system (Force, Force.Forces, Force.Simulation); and the hexagonal binning Hexbin. Every layout is a pure function of a configuration struct and its input, with the exception of the force simulation, which additionally offers a GenServer that advances the same pure tick function on a timer. Coordinates are in user units (pixels unless stated otherwise); the y axis points down, as in SVG.

1. Hierarchy

Visualize.Layout.Hierarchy is the tree structure consumed by Tree, Cluster, Pack, Partition and Treemap. A hierarchy is an immutable tree of %Hierarchy{} structs; every operation returns a new root.

1.1 Node structure

FieldTypeMeaning
dataany()The datum the node was built from.
parentt() | nilThe parent node as it was when this node was built (see 1.1.1). nil for the root.
children[t()] | nilChild nodes in input order. nil for a leaf; an empty list MUST be treated as a leaf wherever it occurs.
depthnon_neg_integer()Distance from the root; the root has depth 0.
heightnon_neg_integer()Greatest distance to a descendant leaf; a leaf has height 0.
valuenumber() | nilAggregate value set by sum/2 or count/1; nil until then.
idany()data[:id], else data[:name], else nil (atom keys only).
x, ynumber() | nilPosition set by Tree, Cluster and Pack.
x0, y0, x1, y1number() | nilRectangle set by Partition and Treemap.
rnumber() | nilRadius set by Pack.

1.1.1 Parent references

parent holds the parent struct as it existed at construction time: its children is nil, its height is 0, and its value and layout fields are unset. Layouts and aggregations do not rewrite parent links. Consequently ancestors/1 and path/2 return construction-time snapshots, and two siblings always have structurally equal parent values, which is what the default separation functions of Tree and Cluster rely on.

1.2 Construction

new/1 and new/2 build a hierarchy from nested data.

OptionDefaultMeaning
:childrenreads :children, else "children"Function returning a node's child data, nil or [] for a leaf.

data MUST be a map. An empty child list produces children: nil.

stratify/1 and stratify/2 build a hierarchy from a flat list of maps.

OptionDefaultMeaning
:idreads :id, else "id"Function returning a node's identifier.
:parent_idreads :parent, "parent", :parent_id, "parent_id" in that orderFunction returning the parent identifier; nil or "" marks the root.

The root is the first datum (in list order) whose parent identifier is nil or "". If there is none, stratify/2 raises ArgumentError. Children are attached in list order. A datum whose parent identifier names no datum is silently dropped. stratify/2 attaches every datum reachable from the root regardless of the order in which parents and children appear in the input (D-41): the parent links are resolved over the whole list first, as d3's stratify does, and the tree is then built from the root down; for the input [root, c1, c2, gc(parent: c2)] the tree has four nodes and height 2.

1.3 Aggregation

  • sum/2 sets every node's value to value_fn.(node.data) plus the sum of its children's values (post-order); value_fn is evaluated for internal nodes as well as leaves. A nil child value counts as 0.
  • count/1 sets every leaf's value to 1 and every internal node's value to its number of leaves.
  • sort/2 sorts the children of every node with Enum.sort/2 using comparator.(a, b) (true when a sorts before b); leaves are returned unchanged.
  • copy/1 and copy/2 rebuild the tree with fresh parent links, copying every field; when transform is given, data becomes transform.(data) on every node.

1.4 Traversal and queries

  • each/2 visits nodes in pre-order (parent before children) and returns :ok.
  • each_after/2 visits nodes in post-order and returns :ok.
  • descendants/1 returns the node followed by all descendants in pre-order.
  • ancestors/1 returns the parent, grandparent, and so on up to the root (root excluded from its own list; the root yields []).
  • leaves/1 returns the descendants whose children is nil or [], in pre-order.
  • links/1 returns {parent, child} tuples for every parent-child pair in pre-order of the parent.
  • path/2 returns the shortest path from source to target: [source, ..., lca, ..., target] where lca is the lowest common ancestor, each endpoint included once, as d3's node.path does (D-41); for two cousins A1 and B2 under root it returns [A1, A, root, B, B2], and path(n, n) is [n]. Nodes are matched by the data of their ancestor chain from the root, so a node equals the snapshot of itself that a descendant's parent field holds; two siblings with identical data are indistinguishable.

1.5 Functions

FunctionContract
Visualize.Layout.Hierarchy.new/1new(data, []).
Visualize.Layout.Hierarchy.new/2Builds a hierarchy from nested map data using the :children accessor (1.2).
Visualize.Layout.Hierarchy.stratify/1stratify(data, []).
Visualize.Layout.Hierarchy.stratify/2Builds a hierarchy from a flat list using :id and :parent_id accessors (1.2); raises ArgumentError when no root exists.
Visualize.Layout.Hierarchy.each/2Pre-order traversal; returns :ok.
Visualize.Layout.Hierarchy.each_after/2Post-order traversal; returns :ok.
Visualize.Layout.Hierarchy.descendants/1Node and all descendants, pre-order.
Visualize.Layout.Hierarchy.ancestors/1Parent chain up to the root; [] for the root.
Visualize.Layout.Hierarchy.leaves/1All leaves, pre-order.
Visualize.Layout.Hierarchy.path/2Shortest path from source to target through their lowest common ancestor (1.4).
Visualize.Layout.Hierarchy.links/1All {parent, child} pairs.
Visualize.Layout.Hierarchy.sum/2Sets value to own value plus descendants' values.
Visualize.Layout.Hierarchy.count/1Sets value to the number of leaves.
Visualize.Layout.Hierarchy.sort/2Sorts children at every level with a boolean comparator.
Visualize.Layout.Hierarchy.copy/1copy(node, nil).
Visualize.Layout.Hierarchy.copy/2Deep copy with fresh parent links, optionally transforming data.

2. Tree and cluster

Visualize.Layout.Tree draws node-link diagrams; Visualize.Layout.Cluster draws dendrograms in which every leaf sits at the same depth. Both share one configuration shape.

2.1 Configuration

FieldDefaultMeaning
sizenil{width, height} to scale the finished layout into.
node_sizenil{dx, dy} fixed spacing per separation unit and per depth level.
separationsiblings 1, otherwise 2fn a, b -> number end giving the horizontal gap between adjacent nodes a and b.

new/0 sets only the default separation. size/2 and node_size/2 accept a two-element list or a 2-tuple, store a tuple, and each clears the other field: the two modes are mutually exclusive and the last one set wins. separation/2 requires an arity-2 function.

With neither size nor node_size, coordinates are raw: x in separation units with the root at 0 (children spread to either side, so x may be negative) and y in depth levels. With size, x is normalised so the leftmost node is at 0 and the rightmost at width, and y so the root is at 0 and the deepest level at height; a zero extent maps to 0. With node_size, x and y are multiplied by dx and dy respectively without normalisation.

2.2 Tree.generate/2

generate/2 sets x and y on every node and returns the new root. y is the node's depth (before scaling). x is assigned by the Reingold–Tilford tidy-tree algorithm in the linear-time form of Buchheim, Jünger and Leipert, ported from d3-hierarchy's tree (D-41): a post-order walk computes preliminary positions and modifiers, comparing the contours of each subtree with those to its left through threads and pushing the subtree right by the greatest violation of separation (the shift is spread evenly over the intermediate siblings); a pre-order walk then accumulates the modifiers. Children of one parent are placed left to right with the gap given by separation, each parent is centred over its first and last child, and no two nodes at the same depth are closer than separation allows: for root -> [A -> [A1, A2], B -> [B1, B2]] the leaves are at -2, -1, 1, 2. test/support/tree_golden.txt records the layout of two fixed trees in every scaling mode.

2.3 Cluster.generate/2

generate/2 positions all leaves first, left to right in pre-order, at x = 0, s1, s1 + s2, ... where each s is separation.(leaf, previous_leaf). Every internal node is placed at the midpoint of its first and last child. y is root.height for every leaf and depth for every internal node, so all leaves share the bottom row. Scaling then applies as in 2.1. Leaves are matched to their positions by {data, depth}; two leaves with equal data at the same depth share a position.

2.4 Functions

FunctionContract
Visualize.Layout.Tree.new/0Creates a tree layout with no size, no node size and the default separation.
Visualize.Layout.Tree.size/2Sets size from a list or tuple and clears node_size.
Visualize.Layout.Tree.node_size/2Sets node_size from a list or tuple and clears size.
Visualize.Layout.Tree.separation/2Sets the arity-2 separation function.
Visualize.Layout.Tree.generate/2Sets x and y on every node of the hierarchy (2.2).
Visualize.Layout.Cluster.new/0Creates a cluster layout with no size, no node size and the default separation.
Visualize.Layout.Cluster.size/2Sets size from a list or tuple and clears node_size.
Visualize.Layout.Cluster.node_size/2Sets node_size from a list or tuple and clears size.
Visualize.Layout.Cluster.separation/2Sets the arity-2 separation function, applied between consecutive leaves.
Visualize.Layout.Cluster.generate/2Sets x and y with all leaves at the same depth (2.3).

3. Pack

Visualize.Layout.Pack places every node as a circle: leaves sized by value, internal nodes enclosing their children.

3.1 Configuration

FieldDefaultMeaning
size{1, 1}{width, height} of the target area.
padding0Number, or fn node -> number end evaluated on each internal node.
radiusnilfn leaf -> number end; when nil, a leaf's radius is sqrt(value), or 1 when value is nil.

size/2 accepts a list or tuple. radius/2 requires an arity-1 function.

3.2 Algorithm

generate/2 expects value to have been set (by sum/2 or count/1) unless radius is given; a leaf radius below 0 is clamped to 0. Siblings are packed bottom-up by d3-hierarchy's packSiblings (D-42), in child order (no sorting): the first circle sits at the origin, the second tangent to its right, the third tangent to both, and every further circle is placed tangent to the current pair (a, b) of the front chain — the circular list of circles still exposed on the outside — then tested against the chain, walking forward from b and backward from a along whichever side has accumulated the smaller radius so far; when the candidate intersects a chain circle, the chain is shortened to that circle and the placement retried. After a placement the pair whose weighted midpoint is nearest the origin becomes the next (a, b). The layout is a pure function of the input: every tie is broken by order and nothing is drawn from :rand.

The enclosing circle of a set of siblings is the minimal enclosing circle of the front chain by Welzl's algorithm (d3's packEnclose), over a Fisher–Yates shuffle of the chain driven by d3's linear congruential generator (a = 1664525, c = 1013904223, m = 2^32, seed 1), so the shuffle — and therefore the result — is fixed. The siblings are translated so that this circle is centred on the origin, and children are stored relative to the parent's centre until the final pass.

Padding is in output units and is applied as d3 does: a first, unpadded, pass finds the root radius r0; a second pass inflates every child of a node by padding(node) * r0 / min(width, height) before packing, deflates it afterwards and adds the inflation to the parent's radius, so the parent's boundary also keeps that distance from its children. With r1 the padded root radius, the gap between tangent siblings after scaling is padding * r0 / r1 output units — the padding, less the amount by which the padding itself enlarged the layout.

Finally the root is scaled by k = min(width, height) / (2 * r1) and centred at {width / 2, height / 2}; every x, y and r is multiplied by k and positions are made absolute. A root with radius 0 (or nil) is returned without scaling and with leaf positions unset. test/support/pack_golden.txt records a five-leaf hierarchy with and without padding.

3.3 Functions

FunctionContract
Visualize.Layout.Pack.new/0Creates a pack layout with size {1, 1}, padding 0 and no radius function.
Visualize.Layout.Pack.size/2Sets size from a list or tuple.
Visualize.Layout.Pack.padding/2Sets a numeric or per-node padding.
Visualize.Layout.Pack.radius/2Sets the arity-1 leaf radius function.
Visualize.Layout.Pack.generate/2Sets x, y (centre) and r on every node (3.2).

4. Partition

Visualize.Layout.Partition produces adjacency diagrams (icicles; sunbursts when size is {2π, radius} and x0/x1 are read as angles in radians).

4.1 Configuration

FieldDefaultMeaning
size{1, 1}{width, height}; each depth level receives height / (root.height + 1).
padding0Horizontal gap between adjacent siblings, in the units of width.
round?falseWhen true, x0, y0, x1, y1 are rounded with Kernel.round/1.

4.2 Algorithm

The root occupies x0 = 0, x1 = width, y0 = 0, y1 = dy. Each child of a node receives a share of the parent's horizontal extent, minus padding * (n - 1) for its n children, proportional to weight(child) / sum(weights), laid left to right in child order, one level lower, where weight is the child's value when that is a number greater than 0 and 0 otherwise. Every child is retained (D-41): a child of weight 0 receives a zero-width rectangle at the position it would occupy (x0 = x1 at the running offset, so with padding the zero-width children of a weightless parent sit padding apart), and its own children are laid out below it in the same way. A node whose children all weigh 0 therefore keeps its rectangle and gives each child a zero-width slot.

4.3 Functions

FunctionContract
Visualize.Layout.Partition.new/0Creates a partition layout with size {1, 1}, padding 0 and rounding off.
Visualize.Layout.Partition.size/2Sets size from a list or tuple.
Visualize.Layout.Partition.padding/2Sets the numeric sibling gap.
Visualize.Layout.Partition.round/2Enables or disables integer rounding of rectangle coordinates.
Visualize.Layout.Partition.generate/2Sets x0, y0, x1, y1 on every node (4.2).

5. Treemap

Visualize.Layout.Treemap nests rectangles whose areas are proportional to value.

5.1 Configuration

FieldDefaultMeaning
size{1, 1}{width, height} of the root rectangle.
tile:squarifyOne of :squarify, :binary, :slice, :dice, :slice_dice.
padding0Outer padding applied on all four sides of every node before tiling its children.
padding_top, padding_right, padding_bottom, padding_leftnilPer-side overrides of padding when not nil.
padding_innernilGap between sibling tiles: each tile is inset by padding_inner / 2 on every side (so tiles on the edge of the parent are also inset). nil means 0.
round?falseWhen true, rectangle coordinates are rounded with Kernel.round/1.

tile/2 rejects any atom outside the five listed. padding/2 requires a number.

5.2 Algorithm

The root receives x0 = 0, y0 = 0, x1 = width, y1 = height. For every node with children the inner rectangle is the node's rectangle shrunk by the four paddings; a side the paddings have inverted collapses to its midpoint, as d3's positionNode does. Every child is tiled into the inner rectangle by the selected algorithm and then laid out recursively (D-41). A child's weight is its value when that is a number greater than 0 and 0 otherwise; a child of weight 0 receives a zero-area rectangle at the position it would occupy — it joins the row, prefix or slice its neighbours form and takes no extent along the direction the algorithm shares out. When the children have no weight at all, or the inner rectangle has no area, every child sits at the inner rectangle's top-left corner with a zero-area rectangle. After tiling, any rectangle that padding_inner has inverted collapses to its midpoint on that axis, so a tile narrower than the gap becomes a point rather than a negative extent. Every descendant of the root therefore carries x0, y0, x1, y1. test/support/treemap_golden.txt records every algorithm and the partition over a six-node hierarchy with a zero-valued child.

Tiling algorithms, with children in input order and areas scaled so the total equals the inner rectangle's area:

  • :squarify — rows are filled greedily along the shorter side of the remaining rectangle (a row across the top when the rectangle is taller than wide, otherwise down the left); a child is added to the current row while the worst aspect ratio in the row does not increase.
  • :binary — children are split into a prefix and suffix whose value sums are as close to equal as possible (cumulative, in order); the split is horizontal (along x) when the rectangle is wider than tall and vertical otherwise, with the split position proportional to the prefix's share; each part is tiled recursively.
  • :slice — children are stacked top to bottom, each taking the full width and a height proportional to its value.
  • :dice — children are placed left to right, each taking the full height and a width proportional to its value.
  • :slice_dice — :slice when the children's depth is even, :dice when odd.

5.3 Functions

FunctionContract
Visualize.Layout.Treemap.new/0Creates a treemap with size {1, 1}, :squarify tiling, zero padding and rounding off.
Visualize.Layout.Treemap.size/2Sets size from a list or tuple.
Visualize.Layout.Treemap.tile/2Sets the tiling algorithm; only the five listed atoms are accepted.
Visualize.Layout.Treemap.padding/2Sets the uniform outer padding.
Visualize.Layout.Treemap.padding_inner/2Sets the sibling gap.
Visualize.Layout.Treemap.padding_top/2Overrides the top outer padding.
Visualize.Layout.Treemap.padding_right/2Overrides the right outer padding.
Visualize.Layout.Treemap.padding_bottom/2Overrides the bottom outer padding.
Visualize.Layout.Treemap.padding_left/2Overrides the left outer padding.
Visualize.Layout.Treemap.round/2Enables or disables integer rounding.
Visualize.Layout.Treemap.generate/2Sets x0, y0, x1, y1 on every node (5.2).

6. Chord

Visualize.Layout.Chord lays out a square flow matrix as arcs on a circle with ribbons between them.

6.1 Configuration

FieldDefaultMeaning
pad_angle0Gap between consecutive groups, radians.
sort_groupsnilfn %{value: a}, %{value: b} -> boolean end ordering groups around the circle; receives maps holding only :value, the group's row sum plus column sum.
sort_subgroupsnilfn a, b -> boolean end ordering the entries within a group's arc by their numeric values.
sort_chordsnilfn chord, chord -> boolean end ordering the returned chord list.

6.2 Input and output

generate/2 takes matrix, a list of n rows of n numbers where matrix[i][j] is the flow from group i to group j, and returns %{groups: [group], chords: [chord]}; an empty matrix yields empty lists.

Angles are in radians, measured clockwise from 12 o'clock (a point at angle a and radius r is at {r * sin(a), -r * cos(a)}). With total the sum of all matrix entries, each unit of value spans k = (2π - n * pad_angle) / total radians (0 when total is 0). Groups are laid out consecutively in sorted order (input order when sort_groups is nil), each spanning row_sum * k followed by pad_angle.

A group is %{index, start_angle, end_angle, value} with value the row sum. Within group i, entry j occupies a subgroup arc of matrix[i][j] * k radians, in column order or in sort_subgroups order.

One chord is produced for every pair i <= j with matrix[i][j] > 0 or matrix[j][i] > 0, in row-major order: %{source: %{index: i, start_angle, end_angle, value: matrix[i][j]}, target: %{index: j, start_angle, end_angle, value: matrix[j][i]}}, where each end's angles are the subgroup arc of the other index within its group. When i == j the chord is a self-loop.

6.3 Path generators

arc_path/3 returns an SVG path for a group between inner_radius and outer_radius, centred on the origin: an inner arc from start to end angle (sweep 1), a line to the outer end point, the outer arc back (sweep 0), then Z. The large-arc flag is set when the span exceeds π.

ribbon_path/2 returns an SVG path for a chord at radius: the source arc, a quadratic curve through the origin to the target arc's start, the target arc, a quadratic curve through the origin back to the source start, then Z. A self-loop draws the source arc and a single quadratic return.

Both generators are about the origin, so where the circle sits is the caller's. The chart layer's :chord step (spec/14 §5.4.3) places them at the point an :arc mark is about — the plot's middle — and its size sets the radius only, never the centre (#488), so the ring and the ribbons of one design share one centre.

6.4 Functions

FunctionContract
Visualize.Layout.Chord.new/0Creates a chord layout with pad_angle 0 and no sort functions.
Visualize.Layout.Chord.pad_angle/2Sets the inter-group gap in radians; requires a number.
Visualize.Layout.Chord.sort_groups/2Sets or clears the group comparator.
Visualize.Layout.Chord.sort_subgroups/2Sets or clears the subgroup value comparator.
Visualize.Layout.Chord.sort_chords/2Sets or clears the chord comparator.
Visualize.Layout.Chord.generate/2Computes %{groups, chords} from a square matrix (6.2).
Visualize.Layout.Chord.arc_path/3SVG path for a group annulus sector (6.3).
Visualize.Layout.Chord.ribbon_path/2SVG path for a chord ribbon at a radius (6.3).

7. Sankey

Visualize.Layout.Sankey positions the nodes and links of a directed weighted graph as a flow diagram.

7.1 Configuration

FieldDefaultMeaning
width, height400, 300Diagram size.
node_width24Horizontal extent of every node.
node_padding8Vertical gap between nodes in the same layer.
iterations6Number of vertical relaxation passes.
node_align:justifyOne of :left, :right, :center, :justify.
link_sortnilfn link, link -> boolean end ordering the links within every node (true when the first comes first); nil orders them by the vertical position of the node at their other end.
nodes, links[]Output of compute/3.

7.2 Input

compute(sankey, nodes, links) takes nodes as maps with a required :id (any term) and links as maps with required :source and :target node identifiers and a numeric :value. Extra keys are preserved.

7.3 Algorithm

The layout is a port of d3-sankey (D-42). Links naming a node that is not in nodes take no part in it. Every node's value is the greater of its total outgoing and total incoming link value. A node's depth is the length of the longest path to it from a node with no incoming link, and its height the length of the longest path from it to a node with no outgoing link (d3's computeNodeDepths and computeNodeHeights: repeated frontier walks in which a node keeps the round it was last reached in). A cycle never settles; the walk stops after as many rounds as there are nodes, so the nodes of a cycle keep that count as their depth.

Horizontal position: with L the greatest depth and xs = (width - node_width) / L (0 when L is 0), every node is assigned a column in 0..L by the alignment function, clamped to that range, and layer is that column; x0 = layer * xs, x1 = x0 + node_width. The alignment functions are d3-sankey's: :left is depth; :right is L - height, so every sink shares the last column; :justify is L for a node without outgoing links and depth otherwise; :center is depth for a node with incoming links, one less than the least depth among its targets for a node with only outgoing links, and 0 for an isolated node.

Vertical position: with n the size of the largest column, the effective padding is py = min(node_padding, height / (n - 1)) (node_padding when n is 1), and one scale ky — the least over the columns of (height - (count - 1) * py) / column_total, columns of total 0 ignored — sizes both nodes and links: a node's height is value * ky and a link's width is value * ky, so the links leaving a node tile its height exactly and no minimum width applies. Each column is stacked in input order from the top and then centred by spreading the remaining height evenly above, between and below its nodes. Then iterations relaxation passes, i from 0, with alpha = 0.99^i and beta = max(1 - alpha, (i + 1) / iterations), sweep the columns right to left and then left to right; each sweep moves every node by alpha times the distance from its y0 to the value-weighted mean of the y0 each of its links would want for a straight ribbon (weights are value * (target.layer - source.layer)), re-sorts the column by y0, and resolves collisions with strength beta: outward from the middle node, then back in from the bottom and the top of the diagram. When link_sort is nil, the links of every node are ordered by the y0 of the node at their other end (ties by input index) and re-ordered whenever a neighbour moves; otherwise they are sorted once by link_sort and kept.

Links: y0 is the running offset from the top of the source node and y1 the running offset from the top of the target node, in the settled link order, so a link's ribbon occupies [y0, y0 + width] at the source and [y1, y1 + width] at the target. Each link receives source_node, target_node (position snapshots), source_index, target_index, y0, y1, width and path, in input order. A link naming an unknown node keeps width 0, path nil and nil endpoints.

Output nodes carry their input keys plus index, depth, height, layer, value, source_links, target_links (the raw input links, in the settled order), x0, x1, y0, y1. test/support/sankey_golden.txt records a five-node graph under every alignment.

7.4 Paths

generate_link_path/1 draws, for a link with source_node, target_node, y0, y1, width: move to {source.x1, y0}, cubic curve to {target.x0, y1} with both control points at the midpoint x, line down by width, cubic curve back, Z. Any other map yields "". link_path/1 returns the link's path when it is a binary, else generate_link_path/1. link_paths/1 maps link_path/1 over sankey.links. nodes_by_layer/1 returns the nodes grouped into lists by ascending layer.

7.5 Functions

FunctionContract
Visualize.Layout.Sankey.new/0Creates a layout with the defaults of 7.1.
Visualize.Layout.Sankey.size/3Sets width and height.
Visualize.Layout.Sankey.node_width/2Sets node_width.
Visualize.Layout.Sankey.node_padding/2Sets node_padding.
Visualize.Layout.Sankey.iterations/2Sets the relaxation pass count.
Visualize.Layout.Sankey.node_align/2Sets the alignment atom; any other atom raises FunctionClauseError.
Visualize.Layout.Sankey.link_sort/2Sets or clears the link comparator applied within every node (7.3).
Visualize.Layout.Sankey.compute/3Computes node and link positions; returns the layout with nodes and links filled (7.3).
Visualize.Layout.Sankey.link_path/1The link's stored path, else a freshly generated one.
Visualize.Layout.Sankey.link_paths/1Paths for every link of a computed layout.
Visualize.Layout.Sankey.generate_link_path/1Cubic-Bezier ribbon path for a positioned link; "" otherwise (7.4).
Visualize.Layout.Sankey.nodes_by_layer/1Nodes grouped by ascending layer.

8. Force

The force system positions graph nodes by simulated physics. Visualize.Layout.Force is the public facade: run/1 computes a layout synchronously, and every other function delegates to Visualize.Layout.Force.Simulation, a GenServer. Visualize.Layout.Force.Forces holds the pure force functions used by both.

A node is a map with a required :id. initialize_nodes (in both run/1 and the GenServer) fills in, without overwriting existing keys: x, y from a phyllotaxis spiral (radius = sqrt(0.5 + i) * 10, angle = i * π * (3 - sqrt(5)) for the node's index i), vx = 0, vy = 0, fx = nil, fy = nil. A non-nil fx (fy) pins the node's x (y) and zeroes the corresponding velocity at integration time.

A link is a map with :source and :target holding node ids (or maps with an :id), and optional :distance and :strength overriding the link force's defaults for that link.

Integration: after all forces have been applied, each free coordinate is updated as v = v * velocity_decay; p = p + v. velocity_decay is therefore the fraction of velocity retained per tick (0.4 by default), not the fraction removed.

8.2 Forces

forces is a list of {type, opts} tuples applied in order each tick; Forces.apply/5 dispatches on type and raises CaseClauseError for any other atom. alpha is the simulation temperature.

TypeOptionDefaultEffect
:center:x, :y0, 0Translates every node's position (not velocity) so the mean position moves toward {x, y} by strength; ignores alpha.
:strength1
:many_body:strength-30Every pair is evaluated directly, O(n²) per tick, with no Barnes–Hut approximation. For each other node at distance d, v += (other - node) * strength * alpha / d²; negative strength repels.
:distance_min1d is clamped to at least this value.
:distance_max:infinityPairs at d >= distance_max are skipped.
:link:distance30Target length.
:strength1Spring stiffness.
:iterations1Passes per tick. Per link, with d the distance between pos + vel of the endpoints, k = (d - distance) / d * alpha * strength; the full displacement delta * k is added to the source velocity and subtracted from the target velocity. Links whose endpoints are not found are ignored.
:collision:radius10Number or fn node -> number end.
:strength1
:iterations1Passes per tick. For each overlapping pair (distance d less than the sum of radii r, with d floored at 0.001) the pair is pushed apart along its axis, each node by half of (r - d) / d * strength * alpha in opposite directions (D-44). Displacements are summed over every pair before any velocity changes, so the result does not depend on node order and the total momentum imparted is zero. Unlike d3, the split is equal rather than weighted by the squared radii.
:x:x0Number or fn node -> number end; vx += (target - x) * strength * alpha.
:strength0.1
:y:y0Number or function; vy += (target - y) * strength * alpha.
:strength0.1
:radial:radius100Number or function; v += (node - centre) * (radius - d) / d * strength * alpha.
:x, :y0, 0Circle centre.
:strength0.1

The default force list, used when :forces is absent, is [{:center, x: 0, y: 0}, {:many_body, strength: -30}, {:link, distance: 30}].

8.3 Synchronous run

Force.run/1 accepts a keyword list:

OptionDefaultMeaning
:nodesrequiredNode maps (8.1).
:links[]Link maps.
:forcesdefault listForce configuration (8.2).
:iterations300Maximum ticks.
:alpha1.0The starting temperature: below 1 a reheat, d3's alpha(a).restart(), for nodes that already carry their positions (#527).
:alpha_decay1 - alpha_min^(1 / iterations)The fraction of alpha lost per tick; d3's own default is 1 - 0.001^(1 / 300), about 0.0228.

It fixes alpha_min = 0.001 and velocity_decay = 0.4. Each tick first sets alpha = alpha * (1 - alpha_decay), then applies the forces and integrates; the loop halts before a tick when alpha < alpha_min or after iterations ticks. It returns %{nodes: nodes, links: links} with the input links unchanged, every node carrying its final x, y, vx and vy.

A run continues a layout (#527, D-132). A node given with x and y starts there, and with vx and vy keeps that velocity, since initialize_nodes fills in only what is absent (8.1); so a caller that passes the previous run's nodes, a lower :alpha and a few :iterations advances the layout from where it stood, as a d3 simulation reheated and run for those ticks does. With every option at its default the run is unchanged: the cold layout, from the spiral, at alpha 1 over 300 ticks. The declarative :force step (spec/14 §5.4.3) is the caller that does this, once per tick of a compiled chart.

8.4 Simulation GenServer

Simulation.start_link/1 starts an unnamed GenServer with these options:

OptionDefaultMeaning
:nodes[]Node maps (8.1).
:links[]Link maps.
:forcesdefault listForce configuration.
:alpha1.0Initial temperature.
:alpha_min0.001The simulation stops when alpha falls below this.
:alpha_decay0.0228Per-tick approach rate toward alpha_target (about 300 ticks to cool).
:alpha_target0Value alpha converges to.
:velocity_decay0.4Velocity retained per tick (8.1).
:auto_starttrueWhen true the server sends itself :start_simulation from init/1, which arms the first tick.
:subscribers[]A list of pids registered and monitored in init/1, before :start_simulation arms the first tick, so a subscriber given here receives every tick from the first. start_link/1 raises ArgumentError in the caller when the value is not a list of pids.

The tick interval is 16 ms. A tick computes alpha = alpha + (alpha_target - alpha) * alpha_decay, applies the forces, integrates, and sends every subscriber {:force_tick, %{nodes: nodes, links: links}} where nodes carry the updated positions and links are the stored (unresolved) link maps. After a timer-driven tick the server re-arms the timer if alpha >= alpha_min, otherwise it clears running.

Client functions: start/1, stop/1, restart/1, tick/1, set_nodes/2, set_links/2, set_alpha/2, set_alpha_target/2, fix_node/4, unfix_node/2, subscribe/2 and unsubscribe/2 are casts returning :ok; nodes/1, links/1 and alpha/1 are calls. links/1 returns the links with :source and :target replaced by the matching node maps (unmatched ids are left as they are). set_nodes/2 re-runs node initialisation. tick/1 advances one step regardless of whether the simulation is running and does not touch the timer. subscribe/2 monitors the subscriber and removes it when it exits; a pid given in :subscribers is held the same way. restart/1 resets alpha to 1.0.

A subscriber that must see every tick is given at start (#483). subscribe/2 is a cast the caller sends after start_link/1 has returned, and by then the first tick is already armed: a caller descheduled between the two for longer than the simulation runs — 16 ms a tick, four ticks for alpha: 0.01, alpha_decay: 0.5 — finds every tick sent to an empty subscriber set and the simulation stopped. Making subscribe/2 a call would not close that window, since the delay falls before the caller sends anything. :subscribers closes it: init/1 registers them before start_link/1 returns, so no tick precedes them. subscribe/2 stays for a process that joins a running simulation, which receives the ticks after its cast is handled.

8.5 State machine

The server state holds running :: boolean() and tick_ref :: reference() | nil. The following invariants MUST hold after every message:

  1. running is true if and only if a tick timer is armed (tick_ref is non-nil).
  2. At most one tick timer is armed at any time.
  3. alpha and alpha_target lie in [0, 1]; set_alpha/2 and set_alpha_target/2 clamp rather than raise.
  4. A timer-driven tick that leaves alpha < alpha_min stops the simulation; in steady state this occurs if and only if alpha_target < alpha_min, so a target at or above alpha_min keeps the simulation running until stop/1.
  5. start/1 on a running simulation is a no-op; stop/1 cancels the timer and clears running.
  6. restart/1 on a stopped simulation MUST NOT start it: it resets alpha and re-arms the timer only when running is already true.

Tick messages are {:tick, tag} where tag is the reference the server generated when it armed the timer; the server keeps the current tag alongside the timer reference and MUST ignore a tick carrying any other tag. That is what makes a stop/1 whose Process.cancel_timer/1 came too late harmless: the orphaned tick arrives, matches nothing, and the chain a later start/1 armed stays the only one. Visualize.Specs.ForceSimulation (spec/12 §5) verifies invariants 1, 2 and 6 and the cooling-down property; test/visualize/layout/force/simulation_test.exs and test/specs/force_simulation_conformance_test.exs hold the implementation to it (D-14).

8.6 Functions

FunctionContract
Visualize.Layout.Force.run/1Synchronously simulates to equilibrium; returns %{nodes, links} (8.3).
Visualize.Layout.Force.start_link/1Delegates to Simulation.start_link/1.
Visualize.Layout.Force.start/1Delegates to Simulation.start/1.
Visualize.Layout.Force.stop/1Delegates to Simulation.stop/1.
Visualize.Layout.Force.restart/1Delegates to Simulation.restart/1.
Visualize.Layout.Force.tick/1Delegates to Simulation.tick/1.
Visualize.Layout.Force.nodes/1Delegates to Simulation.nodes/1.
Visualize.Layout.Force.links/1Delegates to Simulation.links/1.
Visualize.Layout.Force.set_nodes/2Delegates to Simulation.set_nodes/2.
Visualize.Layout.Force.set_links/2Delegates to Simulation.set_links/2.
Visualize.Layout.Force.alpha/1Delegates to Simulation.alpha/1.
Visualize.Layout.Force.set_alpha/2Delegates to Simulation.set_alpha/2.
Visualize.Layout.Force.set_alpha_target/2Delegates to Simulation.set_alpha_target/2.
Visualize.Layout.Force.fix_node/4Delegates to Simulation.fix_node/4.
Visualize.Layout.Force.unfix_node/2Delegates to Simulation.unfix_node/2.
Visualize.Layout.Force.subscribe/2Delegates to Simulation.subscribe/2.
Visualize.Layout.Force.unsubscribe/2Delegates to Simulation.unsubscribe/2.
Visualize.Layout.Force.Forces.apply/5apply(type, nodes, links, alpha, opts); dispatches to the force named by type (8.2).
Visualize.Layout.Force.Forces.apply_center/3apply_center(nodes, alpha, opts): recentres positions.
Visualize.Layout.Force.Forces.apply_many_body/3apply_many_body(nodes, alpha, opts): pairwise charge, O(n²).
Visualize.Layout.Force.Forces.apply_link/4apply_link(nodes, links, alpha, opts): spring constraints.
Visualize.Layout.Force.Forces.apply_collision/3apply_collision(nodes, alpha, opts): separates overlapping circles.
Visualize.Layout.Force.Forces.apply_x/3apply_x(nodes, alpha, opts): attracts toward an x.
Visualize.Layout.Force.Forces.apply_y/3apply_y(nodes, alpha, opts): attracts toward a y.
Visualize.Layout.Force.Forces.apply_radial/3apply_radial(nodes, alpha, opts): attracts toward a circle.
Visualize.Layout.Force.Simulation.start_link/1Starts the GenServer with the options of 8.4.
Visualize.Layout.Force.Simulation.child_spec/1Supervisor child specification wrapping start_link/1.
Visualize.Layout.Force.Simulation.start/1Arms the tick timer if not running.
Visualize.Layout.Force.Simulation.stop/1Cancels the timer and clears running.
Visualize.Layout.Force.Simulation.restart/1Resets alpha to 1.0; the timer discipline is untouched, so a running simulation keeps its chain and a stopped one stays stopped (8.5).
Visualize.Layout.Force.Simulation.tick/1Advances one tick immediately, independent of the timer.
Visualize.Layout.Force.Simulation.nodes/1Returns the current node maps.
Visualize.Layout.Force.Simulation.links/1Returns links with source/target resolved to node maps.
Visualize.Layout.Force.Simulation.set_nodes/2Replaces and re-initialises the nodes.
Visualize.Layout.Force.Simulation.set_links/2Replaces the links.
Visualize.Layout.Force.Simulation.alpha/1Returns the current alpha.
Visualize.Layout.Force.Simulation.set_alpha/2Sets alpha, clamped into [0, 1].
Visualize.Layout.Force.Simulation.set_alpha_target/2Sets alpha_target, clamped into [0, 1].
Visualize.Layout.Force.Simulation.fix_node/4Sets fx, fy on the node with the given id.
Visualize.Layout.Force.Simulation.unfix_node/2Clears fx, fy on the node with the given id.
Visualize.Layout.Force.Simulation.subscribe/2Adds and monitors a subscriber pid.
Visualize.Layout.Force.Simulation.unsubscribe/2Removes a subscriber pid.

9. Hexbin

Status: Implemented (#348)

Visualize.Layout.Hexbin bins points in the plane into a lattice of flat-topped hexagons, d3-hexbin's: for a circumradius r, the columns are dx = 2 r sin(π/3) apart and the rows dy = 1.5 r, every odd row offset by half a column. A point {x, y} belongs to the cell whose centre is nearest: with row = round(y / dy) and col = round(x / dx − offset) where offset is 0.5 for an odd row and 0 otherwise, the centre is {(col + offset) dx, row dy}. The lattice is in the coordinates of the points, so a chart bins pixels.

9.1 Functions

FunctionContract
Visualize.Layout.Hexbin.bin/2bin(points, radius): [{x, y}] to one map per non-empty cell — x, y (the centre), count, points (the cell's points in input order) — in lattice order (row, then column).
Visualize.Layout.Hexbin.hexagon/3hexagon(cx, cy, radius): the closed flat-topped hexagon at the centre as a Visualize.IR.Path, its first vertex at bearing −30° (upper right), then clockwise on screen.