ai/neural-net.ex
Neural net
A layered network of nodes with one path lit in lime as a pulse travels from input to output.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.ai_neural_net animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Order risk model" |
|
result |
string | "Approve · 0.94" |
What the lit output node says. |
layers |
list | [3, 5, 4, 2] |
Two to five columns, each the number of nodes in it (one to six). |
path |
list | [1, 2, 1, 0] |
The lit node of each column, counted from the top. Missing entries are 0. |
labels |
list | ["Input", "Hidden", "Hidden", "Output"] |
A short name under each column. |
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. |
isometric |
boolean | false |
Tilts the composition into the isometric plane of the platform stack. |
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/ai/neural-net.ex
defmodule EHVisuals.Ai.NeuralNet do
@moduledoc """
A layered network: nodes in columns, every node joined to the next column,
and one path lit as a signal travels from input to output.
"""
use EHVisuals.Composition,
name: "ai/neural-net",
title: "Neural net",
description:
"A layered network of nodes with one path lit in lime as a pulse travels from input to output."
attr(:caption, :string, default: "Order risk model")
attr(:result, :string, default: "Approve · 0.94", doc: "What the lit output node says.")
attr(:layers, :list,
default: [3, 5, 4, 2],
doc: "Two to five columns, each the number of nodes in it (one to six)."
)
attr(:path, :list,
default: [1, 2, 1, 0],
doc: "The lit node of each column, counted from the top. Missing entries are 0."
)
attr(:labels, :list,
default: ~w(Input Hidden Hidden Output),
doc: "A short name under each column."
)
attr(:label, :string,
default: "A four layer network with one path from input to output lit in lime"
)
frame_attrs()
@spec ai_neural_net(map()) :: Phoenix.LiveView.Rendered.t()
def ai_neural_net(assigns) do
columns = columns(assigns.layers, assigns.path)
lit = Enum.map(columns, fn column -> Enum.find(column.nodes, & &1.lit) end)
last = List.last(lit)
assigns =
assigns
|> assign(:columns, columns)
|> assign(:edges, edges(columns))
|> assign(:hops, lit |> Enum.zip(Enum.drop(lit, 1)) |> Enum.with_index())
|> assign(:last, last)
|> assign(:midway, midway(lit))
|> assign(:xs, Enum.map_join(lit, ";", & &1.x))
|> assign(:ys, Enum.map_join(lit, ";", & &1.y))
|> assign(:dur, "#{Float.round(max(length(lit) - 1, 1) * 0.9, 1)}s")
|> assign(:names, Enum.zip(columns, assigns.labels))
~H"""
<.frame name="ai/neural-net" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 420 280">
<g class="ehv-h-lift">
<text class="ehv-mono ehv-a-fade" x="8" y="14" font-size="9">
{String.upcase(@caption)}
</text>
<text
class="ehv-mono ehv-t-lime ehv-a-fade ehv-i-12"
x="412"
y="14"
font-size="9"
text-anchor="end"
>
{String.upcase(@result)}
</text>
<line
:for={edge <- @edges}
class={"ehv-line-soft ehv-a-fade ehv-i-#{edge.layer + 2}"}
x1={edge.x1}
y1={edge.y1}
x2={edge.x2}
y2={edge.y2}
/>
<line
:for={{{from, to}, index} <- @hops}
class={"ehv-line-lime ehv-a-draw ehv-i-#{index * 3 + 5}"}
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
pathLength="1"
/>
<g :for={column <- @columns} class={"ehv-a-pop ehv-i-#{column.index + 1}"}>
<g :for={node <- column.nodes}>
<circle
:if={node.lit}
class="ehv-fill-lime-soft"
cx={node.x}
cy={node.y}
r="13"
/>
<circle
class={if node.lit, do: "ehv-node-lime", else: "ehv-ai-neural-net__node"}
cx={node.x}
cy={node.y}
r="7.5"
/>
<circle
:if={node.lit}
class="ehv-ai-neural-net__core"
cx={node.x}
cy={node.y}
r="3.5"
/>
</g>
</g>
<circle class="ehv-ai-neural-net__core ehv-a-ping ehv-i-14" cx={@last.x} cy={@last.y} r="7" />
<circle
:if={!@animated}
class="ehv-ai-neural-net__core"
cx={elem(@midway, 0)}
cy={elem(@midway, 1)}
r="3"
/>
<g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-14">
<circle class="ehv-ai-neural-net__core" r="3.5">
<animate attributeName="cx" values={@xs} dur={@dur} repeatCount="indefinite" />
<animate attributeName="cy" values={@ys} dur={@dur} repeatCount="indefinite" />
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.1;0.85;1"
dur={@dur}
repeatCount="indefinite"
/>
</circle>
</g>
<text
:for={{column, name} <- @names}
class={[
"ehv-mono ehv-a-fade ehv-i-#{column.index + 3}",
column.index == length(@columns) - 1 && "ehv-t-lime"
]}
x={column.x}
y="270"
font-size="8.5"
text-anchor="middle"
>
{String.upcase(name)}
</text>
</g>
</svg>
</.frame>
"""
end
# Columns sit evenly between x = 48 and x = 372, nodes 36 apart around y = 138.
defp columns(layers, path) do
sizes = layers |> Enum.take(5) |> Enum.map(&(&1 |> max(1) |> min(6)))
sizes = if sizes == [], do: [1], else: sizes
count = length(sizes)
gap = if count > 1, do: 324 / (count - 1), else: 0
for {size, index} <- Enum.with_index(sizes) do
x = Float.round(if(count > 1, do: 48 + index * gap, else: 210.0), 1)
chosen = path |> Enum.at(index, 0) |> max(0) |> min(size - 1)
nodes =
for row <- 0..(size - 1) do
%{x: x, y: Float.round(138 + (row - (size - 1) / 2) * 36, 1), lit: row == chosen}
end
%{index: index, x: x, nodes: nodes}
end
end
# Every node is joined to every node of the next column.
defp edges(columns) do
for {left, right} <- Enum.zip(columns, Enum.drop(columns, 1)),
from <- left.nodes,
to <- right.nodes do
%{layer: left.index, x1: from.x, y1: from.y, x2: to.x, y2: to.y}
end
end
# Where the pulse rests in the still frame: halfway along the middle hop.
defp midway([only]), do: {only.x, only.y}
defp midway(lit) do
hop = div(length(lit) - 1, 2)
from = Enum.at(lit, hop)
to = Enum.at(lit, hop + 1)
{Float.round((from.x + to.x) / 2, 1), Float.round((from.y + to.y) / 2, 1)}
end
end
assets/css/ai/neural-net.css
/* ai/neural-net */
.ehv-ai-neural-net { --ehv-max-w: 470px; --ehv-step: 110ms; }
.ehv-ai-neural-net__node { fill: var(--ehv-surface-2); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-ai-neural-net__core { fill: var(--ehv-lime); }
.ehv--hover .ehv-ai-neural-net__node { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-ai-neural-net__node:hover { stroke: var(--ehv-lime); }