empowered.house ↗

hero/agent-hub.ex

Agent hub

An isometric agent core calling a ring of tool tiles one after another, with a pulse on the current call.

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

<div class="h-96 rounded-xl border border-border">
  <.hero_agent_hub animated />
</div>
default
first call · hover
four tools · custom copy
eight tools · last call
light surface
fade out · no glow · still

Props

Prop Type Default What it does
caption string "Agent run · restock order 4812"
tools list ["ERP", "Mail", "Wiki", "Bank", "CRM", "People"] Three to eight tool names in call order, up to six characters each.
active integer 2 Index of the tool being called now. Earlier tools are done, later ones wait. A negative index marks none.
action string "Read return policy" What the agent asks the active tool, up to 20 characters.
core_label string "Agent"
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.
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/hero/agent-hub.ex
agent-hub.ex
defmodule EHVisuals.Hero.AgentHub do
  @moduledoc """
  An agent core in the middle of a ring of tool tiles, calling them one
  after another.

  The scene is drawn in isometric projection. Everything stands on one
  ground plane: plan `{x, y}` with the core at the origin goes to screen
  through `screen/3`, a face that lies flat is placed by one matrix
  (`plane/1`), and the traces between the core and the tools are screen
  lines computed here.
  """

  use EHVisuals.Composition,
    name: "hero/agent-hub",
    title: "Agent hub",
    description:
      "An isometric agent core calling a ring of tool tiles one after another, with a pulse on the current call."

  # Screen position of the plan origin, the middle of the core.
  @cx 340
  @cy 208

  # Plan radius of the ring and the side of a tool tile.
  @radius 172
  @tile 52

  attr(:caption, :string, default: "Agent run · restock order 4812")

  attr(:tools, :list,
    default: ~w(ERP Mail Wiki Bank CRM People),
    doc: "Three to eight tool names in call order, up to six characters each."
  )

  attr(:active, :integer,
    default: 2,
    doc:
      "Index of the tool being called now. Earlier tools are done, later ones wait. A negative index marks none."
  )

  attr(:action, :string,
    default: "Read return policy",
    doc: "What the agent asks the active tool, up to 20 characters."
  )

  attr(:core_label, :string, default: "Agent")

  attr(:label, :string,
    default: "An agent core calling six tools in turn, with the third call in progress"
  )

  frame_attrs(isometric: false)

  @spec hero_agent_hub(map()) :: Phoenix.LiveView.Rendered.t()
  def hero_agent_hub(assigns) do
    names = Enum.take(assigns.tools, 8)
    count = max(length(names), 1)

    tools =
      for {name, index} <- Enum.with_index(names) do
        angle = (195 + index * 360 / count) * :math.pi() / 180
        x = @radius * :math.cos(angle)
        y = @radius * :math.sin(angle)
        state = state(index, assigns.active)
        lift = if state == :now, do: 12, else: 6
        {sx, sy} = screen(x, y)

        %{
          name: name,
          index: index,
          number: index |> Kernel.+(1) |> Integer.to_string() |> String.pad_leading(2, "0"),
          state: state,
          x: r(x),
          y: r(y),
          sx: sx,
          sy: sy,
          top: r(sy - lift - @tile / 2),
          depth: x + y,
          box: box(x - @tile / 2, y - @tile / 2, @tile, @tile, 0, lift)
        }
      end
      |> Enum.sort_by(& &1.depth)

    now = Enum.find(tools, &(&1.state == :now))
    called = tools |> Enum.count(&(&1.state != :next)) |> Integer.to_string()

    assigns =
      assigns
      |> assign(:back, Enum.filter(tools, &(&1.depth < 0)))
      |> assign(:front, Enum.filter(tools, &(&1.depth >= 0)))
      |> assign(:traces, Enum.sort_by(tools, & &1.index))
      |> assign(:now, now)
      |> assign(:pill_w, r(18 + String.length(assigns.action) * 5.5))
      |> assign(
        :counter,
        "Call #{String.pad_leading(called, 2, "0")} / #{String.pad_leading(Integer.to_string(count), 2, "0")}"
      )
      |> assign(:cx, @cx)
      |> assign(:cy, @cy)
      |> assign(:rings, [{r(@radius * 1.414), r(@radius * 0.707)}, {136, 68}])
      |> assign(:base, box(-48, -48, 96, 96, 0, 6))
      |> assign(:core, box(-34, -34, 68, 68, 6, 24))
      |> assign(:ground, plane(0))

    ~H"""
    <.frame name="hero/agent-hub" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 680 360">
        <text class="ehv-mono ehv-a-fade" x="0" y="14" font-size="9">
          {String.upcase(@caption)}
        </text>
        <text
          class="ehv-mono ehv-t-2 ehv-a-fade ehv-i-2"
          x="680"
          y="14"
          font-size="9"
          text-anchor="end"
        >
          {String.upcase(@counter)}
        </text>

        <g class="ehv-h-lift">
          <ellipse
            :for={{{rx, ry}, index} <- Enum.with_index(@rings)}
            class={"ehv-line-faint ehv-a-fade ehv-i-#{index + 1}"}
            cx={@cx}
            cy={@cy}
            rx={rx}
            ry={ry}
          />

          <g :for={tool <- @traces}>
            <line
              :if={tool.state != :next}
              class={[trace(tool.state), "ehv-a-draw ehv-i-#{min(tool.index + 2, 24)}"]}
              x1={@cx}
              y1={@cy}
              x2={tool.sx}
              y2={tool.sy}
              pathLength="1"
            />
            <line
              :if={tool.state == :next}
              class={"ehv-line ehv-dash ehv-a-fade ehv-i-#{min(tool.index + 2, 24)}"}
              x1={@cx}
              y1={@cy}
              x2={tool.sx}
              y2={tool.sy}
            />
          </g>

          <g :if={@animated && @now} class="ehv-smil ehv-a-fade ehv-i-12">
            <circle class="ehv-fill-lime" r="3">
              <animate
                attributeName="cx"
                values={"#{@cx};#{@now.sx};#{@cx}"}
                dur="3s"
                repeatCount="indefinite"
              />
              <animate
                attributeName="cy"
                values={"#{@cy};#{@now.sy};#{@cy}"}
                dur="3s"
                repeatCount="indefinite"
              />
              <animate
                attributeName="opacity"
                values="0;1;1;1;0"
                keyTimes="0;0.12;0.5;0.88;1"
                dur="3s"
                repeatCount="indefinite"
              />
            </circle>
          </g>

          <g :for={tool <- @back} class={"ehv-a-rise ehv-i-#{min(tool.index + 3, 24)}"}>
            <.tool_tile tool={tool} />
          </g>

          <g class="ehv-a-rise ehv-i-0">
            <polygon class="ehv-hero-agent-hub__side" points={@base.left} />
            <polygon class="ehv-hero-agent-hub__side" points={@base.right} />
            <g class="ehv-hero-agent-hub__plane" transform={@base.plane}>
              <rect
                class="ehv-hero-agent-hub__base"
                x={@base.x}
                y={@base.y}
                width={@base.w}
                height={@base.h}
                rx="3"
              />
            </g>
            <polygon class="ehv-hero-agent-hub__side" points={@core.left} />
            <polygon class="ehv-hero-agent-hub__side" points={@core.right} />
            <g class="ehv-hero-agent-hub__plane" transform={@core.plane}>
              <rect
                class="ehv-hero-agent-hub__core"
                x={@core.x}
                y={@core.y}
                width={@core.w}
                height={@core.h}
                rx="3.5"
              />
              <.mark x="-25" y="-25" size="50" />
            </g>
            <g class="ehv-hero-agent-hub__plane" transform={@ground}>
              <text class="ehv-mono ehv-t-lime" x="-48" y="62" font-size="8">
                {String.upcase(@core_label)}
              </text>
            </g>
          </g>

          <g :for={tool <- @front} class={"ehv-a-rise ehv-i-#{min(tool.index + 3, 24)}"}>
            <.tool_tile tool={tool} />
          </g>

          <g :if={@now} class="ehv-a-fade ehv-i-12">
            <line
              class="ehv-line-lime ehv-dash"
              x1={@now.sx}
              x2={@now.sx}
              y1={@now.top}
              y2={@now.top - 16}
            />
            <rect
              class="ehv-fill-lime"
              x={@now.sx - @pill_w / 2}
              y={@now.top - 34}
              width={@pill_w}
              height="18"
              rx="5"
            />
            <text
              class="ehv-mono ehv-t-on-lime"
              x={@now.sx}
              y={@now.top - 22}
              font-size="8"
              text-anchor="middle"
            >
              {String.upcase(@action)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp tool_tile(assigns) do
    ~H"""
    <polygon class="ehv-hero-agent-hub__side" points={@tool.box.left} />
    <polygon class="ehv-hero-agent-hub__side" points={@tool.box.right} />
    <g class="ehv-hero-agent-hub__plane" transform={@tool.box.plane}>
      <rect
        :if={@tool.state == :now}
        class="ehv-line-lime ehv-a-ping"
        x={@tool.box.x}
        y={@tool.box.y}
        width={@tool.box.w}
        height={@tool.box.h}
        rx="2.5"
      />
      <rect
        class={[
          "ehv-hero-agent-hub__tile",
          @tool.state == :now && "ehv-hero-agent-hub__tile--on"
        ]}
        x={@tool.box.x}
        y={@tool.box.y}
        width={@tool.box.w}
        height={@tool.box.h}
        rx="2.5"
      />
      <text class="ehv-mono ehv-t-4" x={@tool.box.x + 6} y={@tool.box.y + 11} font-size="5.5">
        {@tool.number}
      </text>
      <text
        class={["ehv-mono", name_tone(@tool.state)]}
        x={@tool.x}
        y={@tool.y + 3}
        font-size="8.5"
        text-anchor="middle"
      >
        {String.upcase(@tool.name)}
      </text>
    </g>
    """
  end

  defp state(index, active) when index < active, do: :done
  defp state(index, active) when index == active, do: :now
  defp state(_index, _active), do: :next

  defp trace(:now), do: "ehv-line-lime"
  defp trace(_state), do: "ehv-line-ink"

  defp name_tone(:now), do: "ehv-t-lime"
  defp name_tone(:done), do: "ehv-t-2"
  defp name_tone(:next), do: "ehv-t-3"

  # Plan {x, y} at height z to screen.
  defp screen(x, y, z \\ 0), do: {r(@cx + x - y), r(@cy + (x + y) / 2 - z)}

  # The matrix of a flat face at height z.
  defp plane(z), do: "matrix(1 0.5 -1 0.5 #{@cx} #{r(@cy - z)})"

  # A block on the plan rectangle {x, y, w, h} between the heights z0 and z1:
  # its top face in plan coordinates and its two visible sides as screen points.
  defp box(x, y, w, h, z0, z1) do
    %{
      x: r(x),
      y: r(y),
      w: w,
      h: h,
      plane: plane(z1),
      left:
        points([
          screen(x, y + h, z1),
          screen(x + w, y + h, z1),
          screen(x + w, y + h, z0),
          screen(x, y + h, z0)
        ]),
      right:
        points([
          screen(x + w, y + h, z1),
          screen(x + w, y, z1),
          screen(x + w, y, z0),
          screen(x + w, y + h, z0)
        ])
    }
  end

  defp points(list), do: Enum.map_join(list, " ", fn {x, y} -> "#{x},#{y}" end)

  defp r(number), do: Float.round(number * 1.0, 1)
end
assets/css/hero/agent-hub.css
agent-hub.css
/* hero/agent-hub */
.ehv-hero-agent-hub { --ehv-max-w: 760px; --ehv-step: 110ms; --ehv-dur: 800ms; }

/* Hairlines stay one pixel wide on a face that lies in the ground plane. */
.ehv-hero-agent-hub__plane *:not(.ehv-mark__frame) { vector-effect: non-scaling-stroke; }

.ehv-hero-agent-hub__side {
  fill: var(--ehv-surface-2);
  stroke: var(--ehv-border-soft);
  stroke-width: 1;
  stroke-linejoin: round;
}

.ehv-hero-agent-hub__base {
  fill: var(--ehv-surface-1);
  stroke: var(--ehv-border-soft);
  stroke-width: 1;
}

.ehv-hero-agent-hub__core {
  fill: var(--ehv-surface-3);
  stroke: var(--ehv-border-strong);
  stroke-width: 1;
}

.ehv-hero-agent-hub__tile {
  fill: var(--ehv-surface-1);
  stroke: var(--ehv-border-strong);
  stroke-width: 1;
}
.ehv-hero-agent-hub__tile--on { stroke: var(--ehv-lime); }

.ehv--hover .ehv-hero-agent-hub__tile { transition: fill 200ms var(--ehv-ease), stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-hero-agent-hub__tile:hover { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); }