hero/software-factory.ex
Software factory
Isometric input blocks feeding a build plate along traces that merge into one shipped app tile.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.hero_software_factory animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Software factory" |
|
inputs |
list | [%{name: "Process map", kind: "map"}, %{name: "Rules", kind: "rules"}, ...] |
One to four `%{name, kind}`. `kind` picks the drawing on the block: `map`, `rules` or `data`. Keep names under 12 characters. |
plate |
string | "Build plate" |
|
stages |
list | ["Plan", "Build", "Test"] |
One to four short stage names on the line across the plate. |
app |
string | "Order desk" |
The name under the app tile. |
status |
string | "Shipped" |
The tag over the app tile. |
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/software-factory.ex
defmodule EHVisuals.Hero.SoftwareFactory do
@moduledoc """
Input blocks feeding a build plate along traces that merge into one line,
which ends in a shipped app tile.
The scene is drawn in isometric projection. Everything lies on one plane:
the inputs stand in a column along the plan y axis, the line runs along x
across the plate to the app. Flat faces are placed by `plane/2`, and the
traces are screen lines computed here.
"""
use EHVisuals.Composition,
name: "hero/software-factory",
title: "Software factory",
description:
"Isometric input blocks feeding a build plate along traces that merge into one shipped app tile."
# Plan geometry. The inputs stand at x = 0, the plate and the app follow along x.
@block 54
@pitch 82
@plate_x 96
@plate_w 204
@merge_x 140
@app_x 346
@app 74
@inputs [
%{name: "Process map", kind: "map"},
%{name: "Rules", kind: "rules"},
%{name: "Data", kind: "data"}
]
attr(:caption, :string, default: "Software factory")
attr(:inputs, :list,
default: @inputs,
doc:
"One to four `%{name, kind}`. `kind` picks the drawing on the block: `map`, `rules` or `data`. Keep names under 12 characters."
)
attr(:plate, :string, default: "Build plate")
attr(:stages, :list,
default: ~w(Plan Build Test),
doc: "One to four short stage names on the line across the plate."
)
attr(:app, :string, default: "Order desk", doc: "The name under the app tile.")
attr(:status, :string, default: "Shipped", doc: "The tag over the app tile.")
attr(:label, :string,
default:
"A process map, rules and data feeding a build plate, merging into one shipped business app"
)
frame_attrs(isometric: false)
@spec hero_software_factory(map()) :: Phoenix.LiveView.Rendered.t()
def hero_software_factory(assigns) do
inputs = Enum.take(assigns.inputs, 4)
count = max(length(inputs), 1)
reach = (count - 1) / 2 * @pitch + @block / 2
half = max(105, reach + 10)
# The screen position of the plan origin, so the scene sits in the middle.
origin = {r((680 - (@app_x + @app + @app / 2 + reach)) / 2 + reach), r(34 + reach / 2)}
height = r(elem(origin, 1) + max((@app_x + @app + @app / 2) / 2 + 18, (300 + half) / 2 + 24))
blocks =
for {input, index} <- Enum.with_index(inputs) do
y = (index - (count - 1) / 2) * @pitch
%{
name: input.name,
kind: Map.get(input, :kind, "other"),
index: index,
x: 0,
y: r(y - @block / 2),
mid: r(y),
box: box(origin, 0, y - @block / 2, @block, @block, 0, 8),
trace: segment(origin, {@block, y}, {@merge_x, y}),
route: route(origin, [{@block, y}, {@merge_x, y}, {@merge_x, 0}, {@app_x, 0}]),
begin: "#{r(index * 0.9)}s"
}
end
stage_names = Enum.take(assigns.stages, 4)
stage_count = length(stage_names)
stages =
for {name, index} <- Enum.with_index(stage_names) do
x = if stage_count > 1, do: 176 + index * 100 / (stage_count - 1), else: 226
%{name: name, index: index, x: r(x)}
end
first = List.first(blocks)
last = List.last(blocks)
{tag_x, tag_y} = screen(origin, @app_x, -@app / 2, 12)
assigns =
assigns
|> assign(:blocks, blocks)
|> assign(:stage_items, stages)
|> assign(:height, height)
|> assign(:ground, plane(origin, 0))
|> assign(:lid, plane(origin, 8))
|> assign(:slab, box(origin, @plate_x, -half, @plate_w, 2 * half, -12, 0))
|> assign(:half, half)
|> assign(:bus, first && segment(origin, {@merge_x, first.mid}, {@merge_x, last.mid}))
|> assign(:trunk, segment(origin, {@merge_x, 0}, {@app_x, 0}))
|> assign(:tile, box(origin, @app_x, -@app / 2, @app, @app, 0, 12))
|> assign(:tag, %{x: tag_x, y: tag_y, w: r(18 + String.length(assigns.status) * 5.5)})
|> assign(:merge_x, @merge_x)
|> assign(:app_x, @app_x)
~H"""
<.frame name="hero/software-factory" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 680 #{@height}"}>
<text class="ehv-mono ehv-a-fade" x="0" y="14" font-size="9">
{String.upcase(@caption)}
</text>
<g class="ehv-h-lift">
<g class="ehv-a-rise ehv-i-0">
<polygon class="ehv-hero-software-factory__side" points={@slab.left} />
<polygon class="ehv-hero-software-factory__side" points={@slab.right} />
<g class="ehv-hero-software-factory__plane" transform={@slab.plane}>
<rect
class="ehv-hero-software-factory__plate"
x={@slab.x}
y={@slab.y}
width={@slab.w}
height={@slab.h}
rx="5"
/>
<text class="ehv-mono ehv-t-2" x={@slab.x + 10} y={@half - 9} font-size="7.5">
{String.upcase(@plate)}
</text>
</g>
</g>
<line
:for={block <- @blocks}
class={"ehv-line-ink ehv-a-draw ehv-i-#{block.index + 4}"}
x1={block.trace.x1}
y1={block.trace.y1}
x2={block.trace.x2}
y2={block.trace.y2}
pathLength="1"
/>
<line
:if={@bus}
class="ehv-line-ink ehv-a-draw ehv-i-7"
x1={@bus.x1}
y1={@bus.y1}
x2={@bus.x2}
y2={@bus.y2}
pathLength="1"
/>
<line
class="ehv-line-lime ehv-a-draw ehv-i-8"
x1={@trunk.x1}
y1={@trunk.y1}
x2={@trunk.x2}
y2={@trunk.y2}
pathLength="1"
/>
<g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-12">
<circle :for={block <- @blocks} class="ehv-fill-lime" r="3" opacity="0">
<animateMotion
path={block.route}
dur="4.2s"
begin={block.begin}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.1;0.9;1"
dur="4.2s"
begin={block.begin}
repeatCount="indefinite"
/>
</circle>
</g>
<g class="ehv-a-fade ehv-i-9">
<g class="ehv-hero-software-factory__plane" transform={@ground}>
<rect class="ehv-fill-lime" x={@merge_x - 4.5} y="-4.5" width="9" height="9" rx="2" />
<g :for={stage <- @stage_items}>
<rect class="ehv-node-lime" x={stage.x - 4.5} y="-4.5" width="9" height="9" rx="2" />
<text class="ehv-mono ehv-t-2" x={stage.x} y="17" font-size="6.5" text-anchor="middle">
{String.upcase(stage.name)}
</text>
</g>
</g>
</g>
<g :for={block <- @blocks} class={"ehv-a-rise ehv-i-#{block.index + 1}"}>
<polygon class="ehv-hero-software-factory__side" points={block.box.left} />
<polygon class="ehv-hero-software-factory__side" points={block.box.right} />
<g class="ehv-hero-software-factory__plane" transform={@lid}>
<rect
class="ehv-hero-software-factory__block"
x="0"
y={block.y}
width={block.box.w}
height={block.box.h}
rx="2.5"
/>
<.glyph kind={block.kind} y={block.y} />
</g>
<g class="ehv-hero-software-factory__plane" transform={@ground}>
<text class="ehv-mono ehv-t-2" x="0" y={block.y + 63.5} font-size="6.5">
{String.upcase(block.name)}
</text>
</g>
</g>
<g class="ehv-a-rise ehv-i-10">
<polygon class="ehv-hero-software-factory__side" points={@tile.left} />
<polygon class="ehv-hero-software-factory__side" points={@tile.right} />
<g class="ehv-hero-software-factory__plane" transform={@tile.plane}>
<rect
class="ehv-hero-software-factory__app"
x={@tile.x}
y={@tile.y}
width={@tile.w}
height={@tile.h}
rx="3"
/>
<circle
:for={dot <- [0, 1, 2]}
class="ehv-fill-3"
cx={@tile.x + 10 + dot * 6}
cy={@tile.y + 9}
r="1.6"
/>
<line
class="ehv-line"
x1={@tile.x}
x2={@tile.x + @tile.w}
y1={@tile.y + 17}
y2={@tile.y + 17}
/>
<rect
class="ehv-fill-strong"
x={@tile.x + 9}
y={@tile.y + 26}
width="40"
height="5"
rx="2.5"
/>
<rect
class="ehv-fill-soft"
x={@tile.x + 9}
y={@tile.y + 37}
width="56"
height="5"
rx="2.5"
/>
<rect
class="ehv-fill-soft"
x={@tile.x + 9}
y={@tile.y + 48}
width="48"
height="5"
rx="2.5"
/>
<rect
class="ehv-fill-lime"
x={@tile.x + 9}
y={@tile.y + 59}
width="22"
height="8"
rx="2.5"
/>
</g>
<g class="ehv-hero-software-factory__plane" transform={@ground}>
<text class="ehv-mono ehv-t-lime" x={@app_x} y={@tile.y + @tile.h + 11} font-size="7.5">
{String.upcase(@app)}
</text>
</g>
</g>
<g class="ehv-a-fade ehv-i-12">
<line class="ehv-line-lime ehv-dash" x1={@tag.x} x2={@tag.x} y1={@tag.y} y2={@tag.y - 16} />
<rect
class="ehv-fill-lime"
x={@tag.x - @tag.w / 2}
y={@tag.y - 34}
width={@tag.w}
height="18"
rx="5"
/>
<text
class="ehv-mono ehv-t-on-lime"
x={@tag.x}
y={@tag.y - 22}
font-size="8"
text-anchor="middle"
>
{String.upcase(@status)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
# What an input block carries, drawn flat on its top face.
defp glyph(%{kind: "map"} = assigns) do
~H"""
<rect class="ehv-line-ink" x="8" y={@y + 10} width="14" height="10" rx="2" />
<rect class="ehv-line-ink" x="32" y={@y + 10} width="14" height="10" rx="2" />
<rect class="ehv-line-ink" x="20" y={@y + 34} width="14" height="10" rx="2" />
<path class="ehv-line-ink" d={"M22 #{@y + 15} H32 M39 #{@y + 20} V#{@y + 39} H34"} />
"""
end
defp glyph(%{kind: "rules"} = assigns) do
~H"""
<g :for={row <- [0, 1, 2]}>
<rect class="ehv-line-ink" x="8" y={@y + 10 + row * 13} width="7" height="7" rx="1.5" />
<rect class="ehv-fill-3" x="21" y={@y + 12 + row * 13} width={25 - row * 5} height="3" rx="1.5" />
</g>
"""
end
defp glyph(%{kind: "data"} = assigns) do
~H"""
<rect class="ehv-line-ink" x="8" y={@y + 10} width="38" height="34" rx="2" />
<path
class="ehv-line-ink"
d={"M8 #{@y + 21} H46 M8 #{@y + 32.5} H46 M21 #{@y + 10} V#{@y + 44}"}
/>
"""
end
defp glyph(assigns) do
~H"""
<rect class="ehv-fill-3" x="8" y={@y + 14} width="30" height="3" rx="1.5" />
<rect class="ehv-fill-3" x="8" y={@y + 25} width="38" height="3" rx="1.5" />
<rect class="ehv-fill-3" x="8" y={@y + 36} width="22" height="3" rx="1.5" />
"""
end
defp segment(origin, {x1, y1}, {x2, y2}) do
{sx1, sy1} = screen(origin, x1, y1)
{sx2, sy2} = screen(origin, x2, y2)
%{x1: sx1, y1: sy1, x2: sx2, y2: sy2}
end
# A path through plan points, in screen coordinates, for a packet to follow.
defp route(origin, [first | rest]) do
{x, y} = screen(origin, elem(first, 0), elem(first, 1))
Enum.reduce(rest, "M#{x} #{y}", fn {px, py}, path ->
{sx, sy} = screen(origin, px, py)
path <> " L#{sx} #{sy}"
end)
end
# Plan {x, y} at height z to screen, with the plan origin at `origin`.
defp screen({cx, cy}, 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({cx, cy}, 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(origin, x, y, w, h, z0, z1) do
%{
x: r(x),
y: r(y),
w: w,
h: h,
plane: plane(origin, z1),
left:
points([
screen(origin, x, y + h, z1),
screen(origin, x + w, y + h, z1),
screen(origin, x + w, y + h, z0),
screen(origin, x, y + h, z0)
]),
right:
points([
screen(origin, x + w, y + h, z1),
screen(origin, x + w, y, z1),
screen(origin, x + w, y, z0),
screen(origin, 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/software-factory.css
/* hero/software-factory */
.ehv-hero-software-factory { --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-software-factory__plane * { vector-effect: non-scaling-stroke; }
.ehv-hero-software-factory__side {
fill: var(--ehv-surface-2);
stroke: var(--ehv-border-soft);
stroke-width: 1;
stroke-linejoin: round;
}
.ehv-hero-software-factory__plate {
fill: var(--ehv-surface-1);
stroke: var(--ehv-border-strong);
stroke-width: 1;
}
.ehv-hero-software-factory__block {
fill: var(--ehv-surface-3);
stroke: var(--ehv-ink-3);
stroke-width: 1;
}
.ehv-hero-software-factory__app {
fill: var(--ehv-surface-1);
stroke: var(--ehv-lime);
stroke-width: 1;
}
.ehv--hover .ehv-hero-software-factory__block { transition: fill 200ms var(--ehv-ease), stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-hero-software-factory__block { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); }