empowered.house ↗

connections/layer-stack.ex

Layer stack

Pipeline steps as labelled isometric slabs stacked one on another, the active one in lime.

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

<div class="h-96 rounded-xl border border-border">
  <.connections_layer_stack animated />
</div>
default
first step · hover
custom copy · three steps
six steps · top one running
light surface
nothing running · fade out · still

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
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} &#160;{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
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); }