empowered.house ↗

connections/flow.ex

Flow

A small network of nodes joined by data paths that branch and rejoin, with packets moving along them.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.connections_flow animated />
</div>
default
isometric
another route · hover
custom copy · four columns
light surface
no route · fade out · still

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
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
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); }