connections/layer-stack.ex
Layer stack
Pipeline steps as labelled isometric slabs stacked one on another, the active one in lime.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.connections_layer_stack animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Month-end close · pipeline" |
|
steps |
list | ["Collect", "Clean", "Match", "Post"] |
Two to six step names, bottom to top, up to ten characters each. |
active |
integer | 2 |
Index of the step that runs now, counted from the bottom. A negative index marks none. |
status |
string | "Running" |
The word on the active slab, up to ten 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. |
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/layer-stack.ex
defmodule EHVisuals.Connections.LayerStack do
@moduledoc """
A pipeline as a stack of slabs, one step on another, with the step that
runs now in lime.
The scene is drawn in isometric projection. Every slab is the same square
in plan; `plane/2` places its top face at its height, and its two visible
sides and the posts between the slabs are screen points computed here.
"""
use EHVisuals.Composition,
name: "connections/layer-stack",
title: "Layer stack",
description:
"Pipeline steps as labelled isometric slabs stacked one on another, the active one in lime."
# Screen x of the plan origin, the side of a slab, its thickness and the
# distance from one slab to the next.
@cx 180
@side 130
@thick 12
@pitch 40
attr(:caption, :string, default: "Month-end close · pipeline")
attr(:steps, :list,
default: ~w(Collect Clean Match Post),
doc: "Two to six step names, bottom to top, up to ten characters each."
)
attr(:active, :integer,
default: 2,
doc: "Index of the step that runs now, counted from the bottom. A negative index marks none."
)
attr(:status, :string,
default: "Running",
doc: "The word on the active slab, up to ten characters."
)
attr(:label, :string,
default: "A four step pipeline as stacked slabs, with the third step running"
)
frame_attrs(isometric: false)
@spec connections_layer_stack(map()) :: Phoenix.LiveView.Rendered.t()
def connections_layer_stack(assigns) do
steps = Enum.take(assigns.steps, 6)
count = max(length(steps), 1)
cy = 34 + (count - 1) * @pitch + @thick
layers =
for {name, index} <- Enum.with_index(steps) do
base = index * @pitch
top = base + @thick
%{
name: name,
index: index,
number: index |> Kernel.+(1) |> Integer.to_string() |> String.pad_leading(2, "0"),
on: index == assigns.active,
last: index == count - 1,
plane: plane(cy, top),
left:
points([
screen(cy, 0, @side, top),
screen(cy, @side, @side, top),
screen(cy, @side, @side, base),
screen(cy, 0, @side, base)
]),
right:
points([
screen(cy, @side, @side, top),
screen(cy, @side, 0, top),
screen(cy, @side, 0, base),
screen(cy, @side, @side, base)
]),
posts:
if(index < count - 1,
do: [post(cy, 10, @side - 10, top), post(cy, @side - 10, 10, top)],
else: []
)
}
end
assigns =
assigns
|> assign(:layers, layers)
|> assign(:height, cy + @side + 18)
|> assign(:side, @side)
|> assign(:feed, Enum.find(layers, &(&1.index == assigns.active - 1)))
~H"""
<.frame name="connections/layer-stack" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 360 #{@height}"}>
<text class="ehv-mono ehv-a-fade" x="14" y="18" font-size="9">
{String.upcase(@caption)}
</text>
<g class="ehv-h-lift">
<g :for={layer <- @layers}>
<g class={"ehv-a-rise ehv-i-#{layer.index * 2}"}>
<polygon
class={[
"ehv-connections-layer-stack__side",
layer.on && "ehv-connections-layer-stack__side--on"
]}
points={layer.left}
/>
<polygon
class={[
"ehv-connections-layer-stack__side",
layer.on && "ehv-connections-layer-stack__side--on"
]}
points={layer.right}
/>
<g class="ehv-connections-layer-stack__plane" transform={layer.plane}>
<rect
class={[
"ehv-connections-layer-stack__slab",
layer.on && "ehv-connections-layer-stack__slab--on"
]}
width={@side}
height={@side}
rx="3"
/>
<rect
:if={layer.on}
class="ehv-fill-lime-soft ehv-a-pulse"
width={@side}
height={@side}
rx="3"
/>
<g :if={layer.last}>
<rect class="ehv-fill-strong" x="24" y="26" width="58" height="5" rx="2.5" />
<rect class="ehv-fill-soft" x="24" y="40" width="82" height="5" rx="2.5" />
<rect class="ehv-fill-soft" x="24" y="54" width="70" height="5" rx="2.5" />
<rect class="ehv-fill-soft" x="24" y="68" width="76" height="5" rx="2.5" />
</g>
<text
class={["ehv-mono", if(layer.on, do: "ehv-t-lime", else: "ehv-t-2")]}
x="24"
y={@side - 9}
font-size="8.5"
>
{layer.number}  {String.upcase(layer.name)}
</text>
<text
:if={layer.on}
class="ehv-mono ehv-t-lime"
x={@side - 8}
y={@side - 12}
font-size="7"
transform={"rotate(-90 #{@side - 8} #{@side - 12})"}
>
{String.upcase(@status)}
</text>
</g>
</g>
<g class={"ehv-a-fade ehv-i-#{layer.index * 2 + 1}"}>
<line
:for={post <- layer.posts}
class="ehv-line ehv-dash"
x1={post.x}
x2={post.x}
y1={post.y}
y2={post.y - 18}
/>
</g>
</g>
<g :if={@animated && @feed} class="ehv-smil ehv-a-fade ehv-i-12">
<circle :for={post <- @feed.posts} class="ehv-fill-lime" cx={post.x} r="2.5">
<animate
attributeName="cy"
from={post.y}
to={post.y - 18}
dur="1.6s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.2;0.7;1"
dur="1.6s"
repeatCount="indefinite"
/>
</circle>
</g>
</g>
</svg>
</.frame>
"""
end
# A post stands on the top of a slab and reaches the underside of the next.
defp post(cy, x, y, z) do
{sx, sy} = screen(cy, x, y, z)
%{x: sx, y: sy}
end
# Plan {x, y} at height z to screen, for a stack whose plan origin is at screen y `cy`.
defp screen(cy, x, y, z), do: {r(@cx + x - y), r(cy + (x + y) / 2 - z)}
# The matrix of a flat face at height z.
defp plane(cy, z), do: "matrix(1 0.5 -1 0.5 #{@cx} #{cy - z})"
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/connections/layer-stack.css
/* connections/layer-stack */
.ehv-connections-layer-stack { --ehv-max-w: 400px; --ehv-step: 110ms; }
/* Hairlines stay one pixel wide on the skewed top of a slab. */
.ehv-connections-layer-stack__plane * { vector-effect: non-scaling-stroke; }
.ehv-connections-layer-stack__slab {
fill: var(--ehv-surface-1);
stroke: var(--ehv-border-strong);
stroke-width: 1;
}
.ehv-connections-layer-stack__slab--on { stroke: var(--ehv-lime); }
.ehv-connections-layer-stack__side {
fill: var(--ehv-surface-2);
stroke: var(--ehv-border-soft);
stroke-width: 1;
stroke-linejoin: round;
}
.ehv-connections-layer-stack__side--on { stroke: var(--ehv-lime); }
.ehv--hover .ehv-connections-layer-stack__slab { transition: fill 200ms var(--ehv-ease); }
.ehv--hover .ehv-connections-layer-stack__slab:hover { fill: var(--ehv-surface-3); }