empowered.house ↗

connections/converge.ex

Converge

Several sources converging into one destination, with packets moving in.

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

<div class="h-96 rounded-xl border border-border">
  <.connections_converge animated />
</div>
default
isometric
two sources · custom copy
six sources · hover
light surface
fade out · no glow · still

Props

Prop Type Default What it does
sources list [%{name: "Mail", kind: "mail"}, %{name: "Spreadsheet", kind: "sheet"}, ...] Two to six `%{name, kind}`. `kind` picks the icon: `mail`, `sheet`, `erp`, `chat` or `docs`. Keep names under 12 characters.
kicker string "One record" The eyebrow of the destination card.
title string "Order 4812" Up to 12 characters.
status string "Up to date" Up to 14 characters.
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/converge.ex
converge.ex
defmodule EHVisuals.Connections.Converge do
  @moduledoc """
  Several sources feeding one record: each source sends its changes along a
  path, and the paths meet in front of the destination.
  """

  use EHVisuals.Composition,
    name: "connections/converge",
    title: "Converge",
    description: "Several sources converging into one destination, with packets moving in."

  @chip_w 132
  @chip_h 36
  @pitch 48

  @sources [
    %{name: "Mail", kind: "mail"},
    %{name: "Spreadsheet", kind: "sheet"},
    %{name: "ERP", kind: "erp"},
    %{name: "Chat", kind: "chat"}
  ]

  attr(:sources, :list,
    default: @sources,
    doc:
      "Two to six `%{name, kind}`. `kind` picks the icon: `mail`, `sheet`, `erp`, `chat` or `docs`. Keep names under 12 characters."
  )

  attr(:kicker, :string, default: "One record", doc: "The eyebrow of the destination card.")
  attr(:title, :string, default: "Order 4812", doc: "Up to 12 characters.")
  attr(:status, :string, default: "Up to date", doc: "Up to 14 characters.")

  attr(:label, :string, default: "Mail, a spreadsheet, the ERP and chat feeding one order record")

  frame_attrs()

  @spec connections_converge(map()) :: Phoenix.LiveView.Rendered.t()
  def connections_converge(assigns) do
    sources = Enum.take(assigns.sources, 6)
    count = max(length(sources), 1)
    height = max(48 + count * @chip_h + (count - 1) * (@pitch - @chip_h), 152)
    mid = height / 2
    first = mid - (count - 1) * @pitch / 2

    items =
      for {source, index} <- Enum.with_index(sources) do
        y = first + index * @pitch

        %{
          name: source.name,
          kind: Map.get(source, :kind, "other"),
          index: index,
          top: y - @chip_h / 2,
          y: y,
          d: "M#{14 + @chip_w} #{y} C204 #{y} 196 #{mid} 250 #{mid}",
          begin: "#{Float.round(index * 0.55, 2)}s"
        }
      end

    assigns =
      assigns
      |> assign(:items, items)
      |> assign(:count, count)
      |> assign(:height, height)
      |> assign(:mid, mid)
      |> assign(:chip, %{w: @chip_w, h: @chip_h, right: 14 + @chip_w})

    ~H"""
    <.frame name="connections/converge" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 400 #{@height}"}>
        <g class="ehv-h-lift">
          <path
            :for={item <- @items}
            class={"ehv-line ehv-a-draw ehv-i-#{item.index + 2}"}
            d={item.d}
            pathLength="1"
          />
          <line
            class="ehv-line-lime ehv-a-draw ehv-i-8"
            x1="250"
            x2="270"
            y1={@mid}
            y2={@mid}
            pathLength="1"
          />

          <g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-12">
            <circle :for={item <- @items} class="ehv-fill-lime" r="2.5" opacity="0">
              <animateMotion path={item.d} dur="2.6s" begin={item.begin} repeatCount="indefinite" />
              <animate
                attributeName="opacity"
                values="0;1;1;0"
                keyTimes="0;0.2;0.8;1"
                dur="2.6s"
                begin={item.begin}
                repeatCount="indefinite"
              />
            </circle>
          </g>

          <g :for={item <- @items} class={"ehv-a-slide ehv-i-#{item.index}"}>
            <rect
              class="ehv-card ehv-connections-converge__chip"
              x="14.5"
              y={item.top + 0.5}
              width={@chip.w - 1}
              height={@chip.h - 1}
              rx="9"
            />
            <rect class="ehv-card-2" x="21.5" y={item.top + 7.5} width="21" height="21" rx="6" />
            <g transform={"translate(26 #{item.top + 12})"}>
              <.icon kind={item.kind} />
            </g>
            <text class="ehv-t-strong" x="52" y={item.y + 4.5} font-size="12.5">{item.name}</text>
            <circle class="ehv-node-soft" cx={@chip.right} cy={item.y} r="2.5" />
          </g>

          <rect
            class="ehv-fill-lime ehv-a-pop ehv-i-8"
            x="245.5"
            y={@mid - 4.5}
            width="9"
            height="9"
            rx="2.5"
          />

          <g class="ehv-a-rise ehv-i-6">
            <rect class="ehv-card-lime" x="270.5" y={@mid - 47.5} width="115" height="95" rx="12" />
            <text class="ehv-mono ehv-t-lime" x="284" y={@mid - 26} font-size="7.5">
              {String.upcase(@kicker)}
            </text>
            <text class="ehv-t-strong" x="284" y={@mid - 6} font-size="14.5">{@title}</text>
            <line class="ehv-line-soft" x1="284" x2="372" y1={@mid + 7.5} y2={@mid + 7.5} />
            <text class="ehv-mono" x="284" y={@mid + 23} font-size="7.5">
              {@count} {if @count == 1, do: "SOURCE", else: "SOURCES"}
            </text>
            <circle class="ehv-fill-lime ehv-a-pulse" cx="287" cy={@mid + 34} r="2.5" />
            <text class="ehv-mono ehv-t-2" x="295" y={@mid + 37} font-size="7.5">
              {String.upcase(@status)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Icons live in a 12 by 12 box.
  defp icon(%{kind: "mail"} = assigns) do
    ~H"""
    <rect class="ehv-line-ink" y="1.5" width="12" height="9" rx="1.5" />
    <path class="ehv-line-ink ehv-round" d="M0.5 2.5 L6 7 L11.5 2.5" />
    """
  end

  defp icon(%{kind: "sheet"} = assigns) do
    ~H"""
    <rect class="ehv-line-ink" width="12" height="12" rx="1.5" />
    <path class="ehv-line-ink" d="M0 4 H12 M0 8 H12 M4.5 0 V12" />
    """
  end

  defp icon(%{kind: "erp"} = assigns) do
    ~H"""
    <ellipse class="ehv-line-ink" cx="6" cy="2.5" rx="5.5" ry="2" />
    <path
      class="ehv-line-ink"
      d="M0.5 2.5 V9.5 C0.5 12 11.5 12 11.5 9.5 V2.5 M0.5 6 C0.5 8.5 11.5 8.5 11.5 6"
    />
    """
  end

  defp icon(%{kind: "chat"} = assigns) do
    ~H"""
    <path class="ehv-line-ink ehv-round" d="M0.5 1 H11.5 V8.5 H6 L3 11.5 V8.5 H0.5 Z" />
    """
  end

  defp icon(%{kind: "docs"} = assigns) do
    ~H"""
    <path class="ehv-line-ink ehv-round" d="M1.5 0.5 H7.5 L10.5 3.5 V11.5 H1.5 Z M4 6 H8 M4 8.5 H8" />
    """
  end

  defp icon(assigns) do
    ~H"""
    <circle class="ehv-line-ink" cx="6" cy="6" r="5" />
    <circle class="ehv-fill-2" cx="6" cy="6" r="1.5" />
    """
  end
end
assets/css/connections/converge.css
converge.css
/* connections/converge */
.ehv-connections-converge { --ehv-max-w: 440px; }

.ehv--hover .ehv-connections-converge__chip { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-connections-converge__chip:hover { stroke: var(--ehv-lime); }