defmodule Bloccs.Web.Topology.Layout do
@moduledoc """
A pure, deterministic layered layout for a network's DAG — the server-side
alternative to a client layout library. Each node is placed in a column by its
longest path from a source, and in a row within that column; edges become
cubic-bezier paths between the right and left faces of the hexagons.
Returns plain data (coords + SVG path strings) so `Bloccs.Web.Panels.Topology`
is pure rendering and the layout is unit-testable without a browser. Live node
state is layered on at render time, not here.
"""
alias Bloccs.Introspect.Network
@col_w 220
@row_h 170
@margin 70
# half the hexagon's width (the glyph path spans ±52 around its center)
@half 52
@bend 60
# Room below the last node's center for its label (drawn at y + 74 in
# `Bloccs.Web.Components.Graph`) plus the text height and a little padding —
# otherwise a single-row graph's labels fall outside the SVG and get clipped.
@label_gap 100
@type placed_node :: %{
id: atom(),
glyph: atom(),
kind: atom(),
label: String.t(),
x: number(),
y: number()
}
@type placed_edge :: %{from: atom(), to: atom(), path: String.t()}
@type t :: %{
width: number(),
height: number(),
nodes: [placed_node()],
edges: [placed_edge()]
}
@doc "Compute the layout for a running network's topology."
@spec compute(Network.t()) :: t()
def compute(%Network{nodes: nodes, edges: edges}) do
ids = Enum.map(nodes, & &1.id)
pairs = edges |> Enum.map(fn %{from: {f, _}, to: {t, _}} -> {f, t} end) |> Enum.uniq()
layers = layer_map(ids, pairs)
positions = positions(ids, layers)
coord = fn id -> elem_coord(Map.fetch!(positions, id)) end
%{
width: width(layers),
height: height(positions),
nodes:
Enum.map(nodes, fn n ->
{x, y} = coord.(n.id)
%{id: n.id, glyph: n.glyph, kind: n.kind, label: Atom.to_string(n.id), x: x, y: y}
end),
edges:
Enum.map(pairs, fn {f, t} ->
%{from: f, to: t, path: edge_path(coord.(f), coord.(t))}
end)
}
end
# Longest-path layering by relaxation (the DAG guarantees convergence in ≤|V|
# passes; bloccs networks are acyclic).
defp layer_map(ids, pairs) do
init = Map.new(ids, &{&1, 0})
passes = max(length(ids), 1)
Enum.reduce(1..passes, init, fn _, acc ->
Enum.reduce(pairs, acc, fn {f, t}, a ->
Map.update!(a, t, fn cur -> max(cur, Map.fetch!(a, f) + 1) end)
end)
end)
end
# Assign a row within each column, ordered by id for a stable layout.
defp positions(ids, layers) do
ids
|> Enum.group_by(&Map.fetch!(layers, &1))
|> Enum.flat_map(fn {layer, layer_ids} ->
layer_ids
|> Enum.sort_by(&Atom.to_string/1)
|> Enum.with_index()
|> Enum.map(fn {id, row} -> {id, {layer, row}} end)
end)
|> Map.new()
end
defp elem_coord({layer, row}), do: {@margin + layer * @col_w, @margin + row * @row_h}
defp width(layers) do
max_layer = layers |> Map.values() |> Enum.max(fn -> 0 end)
@margin * 2 + max_layer * @col_w
end
defp height(positions) do
max_row = positions |> Map.values() |> Enum.map(fn {_l, r} -> r end) |> Enum.max(fn -> 0 end)
@margin + max_row * @row_h + @label_gap
end
defp edge_path({fx, fy}, {tx, ty}) do
x1 = fx + @half
x2 = tx - @half
"M#{x1},#{fy} C#{x1 + @bend},#{fy} #{x2 - @bend},#{ty} #{x2},#{ty}"
end
end