empowered.house ↗

hero/software-factory.ex

Software factory

Isometric input blocks feeding a build plate along traces that merge into one shipped app tile.

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

<div class="h-96 rounded-xl border border-border">
  <.hero_software_factory animated />
</div>
default
hover
custom copy
four inputs
light surface
fade out · no glow · still

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
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
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); }