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.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.hero_agent_hub animated />
</div>
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
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
/* 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); }