connections/flow.ex
Flow
A small network of nodes joined by data paths that branch and rejoin, with packets moving along them.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.connections_flow animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Order data · live" |
|
stages |
list | [["Orders"], ["Validate", "Enrich"], [...], ...] |
Two to five columns, each a list of one to four node names of up to eight characters. Neighbouring columns are joined. |
route |
list | [0, 1, 1, 0, 0] |
The row of the lime route in each column. An empty list marks none. |
Frame props, shared by every composition
| Prop | Type | Default | What it does |
|---|---|---|---|
animated |
boolean | false |
Turns motion on. Without it the composition renders in its final, still state. |
trigger |
"mount" | "in-view" | "in-view-repeat" | "in-view" |
When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view. |
hover |
boolean | false |
Enables the hover micro-interactions the composition supports. |
glow |
boolean | true |
The warm incandescent halo behind the composition. |
fade_out |
boolean | false |
Fades the bottom edge into the surface underneath. |
isometric |
boolean | false |
Tilts the composition into the isometric plane of the platform stack. |
theme |
"dark" | "light" | "dark" |
`dark` for the site canvas, `light` for the cream reversed surface. |
class |
string | nil |
Extra classes for the frame. |
rest |
global | — |
Any other HTML attribute, set on the frame. |
Source
Two files. Copy them into your app if you would rather own the code than depend on the library.
lib/eh_visuals/connections/flow.ex
defmodule EHVisuals.Connections.Flow do
@moduledoc """
A small network: nodes in columns, joined by data paths that branch and
rejoin, with one route picked out and packets moving along every path.
"""
use EHVisuals.Composition,
name: "connections/flow",
title: "Flow",
description:
"A small network of nodes joined by data paths that branch and rejoin, with packets moving along them."
@width 480
@left 46
@pitch 52
@node_h 26
attr(:caption, :string, default: "Order data · live")
attr(:stages, :list,
default: [~w(Orders), ~w(Validate Enrich), ~w(Stock Price Credit), ~w(Approve), ~w(ERP)],
doc:
"Two to five columns, each a list of one to four node names of up to eight characters. Neighbouring columns are joined."
)
attr(:route, :list,
default: [0, 1, 1, 0, 0],
doc: "The row of the lime route in each column. An empty list marks none."
)
attr(:label, :string,
default: "Order data branching through checks and rejoining before it reaches the ERP"
)
frame_attrs()
@spec connections_flow(map()) :: Phoenix.LiveView.Rendered.t()
def connections_flow(assigns) do
stages = assigns.stages |> Enum.take(5) |> Enum.map(&Enum.take(&1, 4))
columns = max(length(stages), 1)
rows = stages |> Enum.map(&length/1) |> Enum.max(fn -> 1 end)
height = 34 + @node_h + (rows - 1) * @pitch + 30
mid = 34 + (height - 34 - 4) / 2
step = if columns > 1, do: (@width - 2 * @left) / (columns - 1), else: 0
nodes =
for {names, column} <- Enum.with_index(stages) do
for {name, row} <- Enum.with_index(names) do
%{
name: name,
column: column,
row: row,
x: Float.round(if(columns > 1, do: @left + column * step, else: @width / 2), 1),
y: Float.round(mid + (row - (length(names) - 1) / 2) * @pitch, 1),
w: Float.round(18 + String.length(name) * 5.9, 1),
on: Enum.at(assigns.route, column) == row
}
end
end
edges =
for {from, to} <- Enum.zip(nodes, Enum.drop(nodes, 1)),
a <- from,
b <- to,
joined?(a.row, length(from), b.row, length(to)) do
x1 = a.x + a.w / 2
x2 = b.x - b.w / 2
bend = Float.round((x1 + x2) / 2, 1)
%{
d: "M#{x1} #{a.y} C#{bend} #{a.y} #{bend} #{b.y} #{x2} #{b.y}",
on: a.on and b.on,
step: min(a.column * 2 + 2, 24),
begin: "#{Float.round(a.column * 0.6 + b.row * 0.2, 1)}s"
}
end
assigns =
assigns
|> assign(:nodes, List.flatten(nodes))
|> assign(:edges, Enum.sort_by(edges, & &1.on))
|> assign(:height, height)
|> assign(:node_h, @node_h)
~H"""
<.frame name="connections/flow" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 480 #{@height}"}>
<text class="ehv-mono ehv-a-fade" x="14" y="18" font-size="9">
{String.upcase(@caption)}
</text>
<g class="ehv-h-lift">
<path
:for={edge <- @edges}
class={[
if(edge.on, do: "ehv-line-lime", else: "ehv-line"),
"ehv-a-draw ehv-i-#{edge.step}"
]}
d={edge.d}
pathLength="1"
/>
<g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-12">
<circle
:for={edge <- @edges}
class={if edge.on, do: "ehv-fill-lime", else: "ehv-fill-3"}
r={if edge.on, do: "2.8", else: "2"}
opacity="0"
>
<animateMotion path={edge.d} dur="2.4s" begin={edge.begin} repeatCount="indefinite" />
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.2;0.8;1"
dur="2.4s"
begin={edge.begin}
repeatCount="indefinite"
/>
</circle>
</g>
<g :for={node <- @nodes} class={"ehv-a-pop ehv-i-#{min(node.column * 2 + 1, 24)}"}>
<rect
class={[
if(node.on, do: "ehv-card-lime", else: "ehv-card"),
"ehv-connections-flow__node"
]}
x={node.x - node.w / 2}
y={node.y - @node_h / 2}
width={node.w}
height={@node_h}
rx="8"
/>
<text
class={["ehv-mono", if(node.on, do: "ehv-t-ink", else: "ehv-t-2")]}
x={node.x}
y={node.y + 3}
font-size="8.5"
text-anchor="middle"
>
{String.upcase(node.name)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
# Row `a` of a column of `from` nodes is joined to row `b` of the next
# column of `to` nodes when their shares of the height overlap. One node
# fans out to all, all fan in to one, and equal columns run in parallel.
defp joined?(a, from, b, to) do
a * to < (b + 1) * from and b * from < (a + 1) * to
end
end
assets/css/connections/flow.css
/* connections/flow */
.ehv-connections-flow { --ehv-max-w: 540px; --ehv-iso-scale: 0.82; }
.ehv--hover .ehv-connections-flow__node { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-connections-flow__node:hover { stroke: var(--ehv-lime); }