integrations/plugin-slot.ex
Plugin slot
An isometric board of integration cubes with one open slot and a new cube dropping into it.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.integrations_plugin_slot animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
systems |
list | ["ERP", "CRM", "Mail", "Sheets", "WMS", "HR", ...] |
Up to eight system names already on the board, six characters at most. |
incoming |
string | "Bank" |
The cube being added, six characters at most. |
slot |
integer | 8 |
The open cell, 0 to 8, counted row by row from the back corner. 8 is the front corner. |
board |
string | "Integrations" |
The name on the edge of the board. |
caption |
string | "Adding the bank feed" |
The status line in the top left corner. Empty hides it. |
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/integrations/plugin-slot.ex
defmodule EHVisuals.Integrations.PluginSlot do
@moduledoc """
An isometric board of integration cubes with one open slot and a new cube
dropping into it.
The scene is drawn in isometric projection. The board is a 200 by 200
square in plan, with nine cells. A cube is two side faces in screen
coordinates and a top face in the plane of the board, lifted by its height.
"""
use EHVisuals.Composition,
name: "integrations/plugin-slot",
title: "Plugin slot",
description:
"An isometric board of integration cubes with one open slot and a new cube dropping into it."
# Screen position of the top vertex of the board.
@cx 240
@top 72
@cells [22, 78, 134]
@size 44
@depth 26
# How far above its slot the new cube hovers. The stylesheet drops it by
# the same distance.
@lift 56
attr(:systems, :list,
default: ~w(ERP CRM Mail Sheets WMS HR BI Chat),
doc: "Up to eight system names already on the board, six characters at most."
)
attr(:incoming, :string, default: "Bank", doc: "The cube being added, six characters at most.")
attr(:slot, :integer,
default: 8,
doc: "The open cell, 0 to 8, counted row by row from the back corner. 8 is the front corner."
)
attr(:board, :string, default: "Integrations", doc: "The name on the edge of the board.")
attr(:caption, :string,
default: "Adding the bank feed",
doc: "The status line in the top left corner. Empty hides it."
)
attr(:label, :string,
default: "A board of eight integration cubes with a ninth dropping into the open slot"
)
frame_attrs(isometric: false)
@spec integrations_plugin_slot(map()) :: Phoenix.LiveView.Rendered.t()
def integrations_plugin_slot(assigns) do
slot = assigns.slot |> max(0) |> min(8)
free = Enum.reject(0..8, &(&1 == slot))
installed = assigns.systems |> Enum.take(8) |> Enum.zip(free)
cubes =
[{assigns.incoming, slot} | installed]
|> Enum.map(fn {name, cell} -> cube(name, cell, cell == slot) end)
|> Enum.sort_by(&(&1.x + &1.y))
|> Enum.with_index()
{slot_x, slot_y} = plan(slot)
assigns =
assigns
|> assign(:cubes, cubes)
|> assign(:sockets, for(cell <- 0..8, do: {plan(cell), cell == slot}))
|> assign(:guides, guides(slot_x, slot_y))
|> assign(:count, "#{length(installed)} of 9")
|> assign(:size, @size)
|> assign(:top, @top)
~H"""
<.frame name="integrations/plugin-slot" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 480 318">
<g :if={@caption != ""} class="ehv-a-fade ehv-i-10">
<circle class="ehv-fill-lime ehv-a-pulse" cx="13" cy="16" r="3" />
<text class="ehv-mono ehv-t-2" x="23" y="19" font-size="8.5">
{String.upcase(@caption)}
</text>
</g>
<g class="ehv-h-lift">
<g class="ehv-a-rise ehv-i-0">
<polygon class="ehv-integrations-plugin-slot__edge" points={left_edge()} />
<polygon class="ehv-integrations-plugin-slot__edge" points={right_edge()} />
<g class="ehv-integrations-plugin-slot__plane" transform={matrix(0)}>
<rect class="ehv-integrations-plugin-slot__board" width="200" height="200" rx="5" />
<rect
:for={{{x, y}, open} <- @sockets}
class={[
"ehv-integrations-plugin-slot__socket",
open && "ehv-integrations-plugin-slot__socket--open"
]}
x={x}
y={y}
width={@size}
height={@size}
rx="4"
/>
<text class="ehv-mono ehv-t-2" x="22" y="193" font-size="7">
{String.upcase(@board)}
</text>
<text class="ehv-mono" x="178" y="193" font-size="7" text-anchor="end">
{String.upcase(@count)}
</text>
</g>
</g>
<g :for={{cube, index} <- @cubes}>
<g :if={!cube.incoming} class={"ehv-a-rise ehv-i-#{index + 2}"}>
<polygon class="ehv-integrations-plugin-slot__side" points={cube.left} />
<polygon
class="ehv-integrations-plugin-slot__side ehv-integrations-plugin-slot__side--dark"
points={cube.right}
/>
<g class="ehv-integrations-plugin-slot__plane" transform={matrix(cube.z)}>
<rect
class="ehv-integrations-plugin-slot__lid"
x={cube.x}
y={cube.y}
width={@size}
height={@size}
rx="3"
/>
<text
class="ehv-mono ehv-t-2"
x={cube.x + 22}
y={cube.y + 24.5}
font-size="7.5"
text-anchor="middle"
>
{String.upcase(cube.name)}
</text>
</g>
</g>
<g :if={cube.incoming}>
<g class="ehv-integrations-plugin-slot__guides">
<line
:for={{x, y} <- @guides}
class="ehv-line-lime ehv-dash ehv-a-fade ehv-i-12"
x1={x}
x2={x}
y1={y}
y2={y - cube.z + 26}
/>
</g>
<g class="ehv-integrations-plugin-slot__incoming">
<g class="ehv-a-drop ehv-i-12">
<polygon
class="ehv-integrations-plugin-slot__side ehv-integrations-plugin-slot__side--new"
points={cube.left}
/>
<polygon
class="ehv-integrations-plugin-slot__side ehv-integrations-plugin-slot__side--dark ehv-integrations-plugin-slot__side--new"
points={cube.right}
/>
<g class="ehv-integrations-plugin-slot__plane" transform={matrix(cube.z)}>
<rect
class="ehv-integrations-plugin-slot__lid ehv-integrations-plugin-slot__lid--new"
x={cube.x}
y={cube.y}
width={@size}
height={@size}
rx="3"
/>
<text
class="ehv-mono ehv-t-lime"
x={cube.x + 22}
y={cube.y + 24.5}
font-size="7.5"
text-anchor="middle"
>
{String.upcase(cube.name)}
</text>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# Plan {x, y} of a cell, counted row by row from the back corner.
defp plan(cell), do: {Enum.at(@cells, rem(cell, 3)), Enum.at(@cells, div(cell, 3))}
# Plan {x, y} at height z to screen.
defp screen(x, y, z),
do: {Float.round(@cx + 1.1 * (x - y), 1), Float.round(@top + 0.55 * (x + y) - z, 1)}
# The plane of the board, lifted by z.
defp matrix(z), do: "matrix(1.1 0.55 -1.1 0.55 #{@cx} #{@top - z})"
defp cube(name, cell, incoming) do
{x, y} = plan(cell)
base = if incoming, do: @lift, else: 0
%{
name: name,
incoming: incoming,
x: x,
y: y,
z: base + @depth,
left: face({x, y + @size}, {x + @size, y + @size}, base),
right: face({x + @size, y + @size}, {x + @size, y}, base)
}
end
# A side face: the plan edge from `from` to `to`, standing on height `base`.
defp face({x1, y1}, {x2, y2}, base) do
[
screen(x1, y1, base),
screen(x2, y2, base),
screen(x2, y2, base + @depth),
screen(x1, y1, base + @depth)
]
|> Enum.map_join(" ", fn {x, y} -> "#{x},#{y}" end)
end
# The three near corners of the open slot, on the board.
defp guides(x, y) do
[screen(x, y + @size, 0), screen(x + @size, y + @size, 0), screen(x + @size, y, 0)]
end
# The two visible edges of the board, ten units thick.
defp left_edge,
do:
"#{@cx - 220},#{@top + 110} #{@cx},#{@top + 220} #{@cx},#{@top + 230} #{@cx - 220},#{@top + 120}"
defp right_edge,
do:
"#{@cx},#{@top + 220} #{@cx + 220},#{@top + 110} #{@cx + 220},#{@top + 120} #{@cx},#{@top + 230}"
end
assets/css/integrations/plugin-slot.css
/* integrations/plugin-slot */
.ehv-integrations-plugin-slot { --ehv-max-w: 540px; --ehv-step: 70ms; }
.ehv-integrations-plugin-slot .ehv__glow { background: radial-gradient(46% 40% at 50% 56%, var(--ehv-glow), transparent 72%); }
/* Hairlines stay one pixel wide in the skewed plane of the board. */
.ehv-integrations-plugin-slot__plane * { vector-effect: non-scaling-stroke; }
.ehv-integrations-plugin-slot__board { fill: var(--ehv-surface-1); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-integrations-plugin-slot__edge { fill: var(--ehv-surface-2); stroke: var(--ehv-border-soft); stroke-width: 1; stroke-linejoin: round; }
.ehv-integrations-plugin-slot__socket { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-integrations-plugin-slot__socket--open { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-dasharray: 3 3; }
/* A cube: the lid is the lightest face, the right side the darkest. */
.ehv-integrations-plugin-slot__lid { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-integrations-plugin-slot__side { fill: var(--ehv-surface-2); stroke: var(--ehv-border-strong); stroke-width: 1; stroke-linejoin: round; }
.ehv-integrations-plugin-slot__side--dark { fill: var(--ehv-surface-1); }
.ehv-integrations-plugin-slot__lid--new,
.ehv-integrations-plugin-slot__side--new { stroke: var(--ehv-lime); }
/* On the cream surface the tones run the other way. */
.ehv--light .ehv-integrations-plugin-slot__lid { fill: var(--ehv-surface-1); }
.ehv--light .ehv-integrations-plugin-slot__side--dark { fill: var(--ehv-surface-3); }
/* The new cube hovers 56 above its slot, drops in, sits, and starts over. */
@keyframes ehv-integrations-plugin-slot-drop {
0%, 18% { transform: translateY(0); opacity: 1; }
44%, 86% { transform: translateY(56px); opacity: 1; }
92% { transform: translateY(56px); opacity: 0; }
93% { transform: translateY(0); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@keyframes ehv-integrations-plugin-slot-guide {
0%, 18% { opacity: 1; }
42%, 93% { opacity: 0; }
100% { opacity: 1; }
}
.ehv--animated .ehv-integrations-plugin-slot__incoming {
animation: ehv-integrations-plugin-slot-drop 5.4s var(--ehv-ease) infinite;
animation-delay: calc(var(--ehv-delay, 0ms) + 12 * var(--ehv-step) + var(--ehv-dur));
}
.ehv--animated .ehv-integrations-plugin-slot__guides {
animation: ehv-integrations-plugin-slot-guide 5.4s var(--ehv-ease) infinite;
animation-delay: calc(var(--ehv-delay, 0ms) + 12 * var(--ehv-step) + var(--ehv-dur));
}
.ehv--hover .ehv-integrations-plugin-slot__lid { transition: fill 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-integrations-plugin-slot__lid--new { fill: var(--ehv-lime-soft); }