git/branch-graph.ex
Branch graph
A commit rail where feature branches fork off, carry their own commits and merge back, the latest merge in lime.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.git_branch_graph animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "orders-app" |
|
main |
string | "main" |
Name of the rail, up to 7 characters. |
branches |
list | [%{name: "feature/invoice-ocr", merged: true, commits: 3, pr: "#212"}, %{...}, ...] |
One to four `%{name, commits, pr, merged}`. Names up to 22 characters, one to four commits. `merged: false` leaves the branch open. |
head |
string | "v2.14" |
Tag at the tip of the rail, up to 6 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. |
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/git/branch-graph.ex
defmodule EHVisuals.Git.BranchGraph do
@moduledoc """
A commit rail with feature branches that fork off, carry their own commits
and merge back. Branches alternate above and below the rail, so they never
cross.
"""
use EHVisuals.Composition,
name: "git/branch-graph",
title: "Branch graph",
description:
"A commit rail where feature branches fork off, carry their own commits and merge back, the latest merge in lime."
@branches [
%{name: "feature/invoice-ocr", commits: 3, pr: "#212", merged: true},
%{name: "fix/vat-rounding", commits: 2, pr: "#213", merged: true},
%{name: "feature/approval-limit", commits: 3, pr: "#214", merged: true}
]
# Screen y of the rail and of the two branch lanes.
@rail 134
@lane_up 62
@lane_down 206
# Branches fork and merge between these two x positions.
@first 92
@last 412
attr(:caption, :string, default: "orders-app")
attr(:main, :string, default: "main", doc: "Name of the rail, up to 7 characters.")
attr(:branches, :list,
default: @branches,
doc:
"One to four `%{name, commits, pr, merged}`. Names up to 22 characters, one to four commits. `merged: false` leaves the branch open."
)
attr(:head, :string, default: "v2.14", doc: "Tag at the tip of the rail, up to 6 characters.")
attr(:label, :string,
default: "A main branch with three feature branches that fork off and merge back"
)
frame_attrs()
@spec git_branch_graph(map()) :: Phoenix.LiveView.Rendered.t()
def git_branch_graph(assigns) do
list = Enum.take(assigns.branches, 4)
count = max(length(list), 1)
step = (@last - @first) / (count - 1 + 1.6)
span = step * 1.6
latest =
list
|> Enum.with_index()
|> Enum.filter(fn {branch, _index} -> Map.get(branch, :merged, true) end)
|> List.last()
|> case do
nil -> nil
{_branch, index} -> index
end
items =
for {branch, index} <- Enum.with_index(list) do
fork = Float.round(@first + index * step, 1)
merge = Float.round(fork + span, 1)
up = rem(index, 2) == 0
lane = if up, do: @lane_up, else: @lane_down
merged = Map.get(branch, :merged, true)
dots = branch |> Map.get(:commits, 2) |> max(1) |> min(4)
from = fork + 38
to = if merged, do: merge - 38, else: merge - 30
%{
index: index,
name: branch.name,
pr: Map.get(branch, :pr),
merged: merged,
latest: index == latest,
up: up,
fork: fork,
merge: merge,
lane: lane,
step: min(index * 3 + 2, 22),
path: path(fork, merge, lane, merged),
commits:
for dot <- 0..(dots - 1) do
Float.round(
if(dots == 1, do: (from + to) / 2, else: from + dot * (to - from) / (dots - 1)),
1
)
end
}
end
rail_x = Float.round(min(16 + String.length(assigns.main) * 5.8 + 8, 64.0), 1)
last = List.last(items)
assigns =
assigns
|> assign(:items, items)
|> assign(:rail, @rail)
|> assign(:rail_x, rail_x)
|> assign(:head_y, if(last && not last.up, do: @rail - 11, else: @rail + 18))
~H"""
<.frame name="git/branch-graph" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 468 240">
<g class="ehv-h-lift">
<text class="ehv-mono ehv-a-fade" x="16" y="22" font-size="9">
{String.upcase(@caption)}
</text>
<text class="ehv-mono ehv-t-2 ehv-a-fade" x="16" y={@rail + 3} font-size="8.5">
{String.upcase(@main)}
</text>
<line
class="ehv-line-ink ehv-a-draw ehv-i-1"
x1={@rail_x}
x2="440"
y1={@rail}
y2={@rail}
pathLength="1"
/>
<path class="ehv-fill-2 ehv-a-fade ehv-i-12" d={"M440 #{@rail - 4.5} l8 4.5 l-8 4.5 z"} />
<text
class="ehv-mono ehv-a-fade ehv-i-12"
x="430"
y={@head_y}
font-size="8"
>
{String.upcase(@head)}
</text>
<g :for={item <- @items}>
<path
class={[
if(item.latest, do: "ehv-line-lime", else: "ehv-line-ink"),
"ehv-a-draw ehv-i-#{item.step}"
]}
d={item.path}
pathLength="1"
/>
<line
:if={not item.merged}
class={"ehv-line ehv-dash ehv-a-fade ehv-i-#{item.step + 2}"}
x1={item.merge - 24}
x2={item.merge - 2}
y1={item.lane}
y2={item.lane}
/>
<text
class={[
"ehv-mono ehv-a-fade ehv-i-#{item.step}",
if(item.latest, do: "ehv-t-lime", else: "ehv-t-2")
]}
x={item.fork + 30}
y={if item.up, do: item.lane - 12, else: item.lane + 20}
font-size="8.5"
>
{String.upcase(item.name)}
</text>
<g class={"ehv-a-pop ehv-i-#{item.step + 1}"}>
<circle
:for={x <- item.commits}
class={if item.latest, do: "ehv-node-lime", else: "ehv-node"}
cx={x}
cy={item.lane}
r="4"
/>
</g>
<circle
class={"ehv-node ehv-a-pop ehv-i-#{item.step}"}
cx={item.fork}
cy={@rail}
r="4"
/>
<g :if={item.merged}>
<circle
:if={item.latest}
class="ehv-fill-lime ehv-a-ping"
cx={item.merge}
cy={@rail}
r="5.5"
/>
<circle
class={[
if(item.latest, do: "ehv-fill-lime", else: "ehv-fill-ink"),
"ehv-a-pop ehv-i-#{item.step + 2}"
]}
cx={item.merge}
cy={@rail}
r="5"
/>
<text
:if={item.pr}
class={[
"ehv-mono ehv-a-fade ehv-i-#{item.step + 2}",
item.latest && "ehv-t-lime"
]}
x={item.merge}
y={if item.up, do: @rail + 18, else: @rail - 11}
font-size="8"
text-anchor="middle"
>
{String.upcase(item.pr)}
</text>
</g>
</g>
<circle class="ehv-node ehv-a-pop ehv-i-1" cx={@rail_x + 6} cy={@rail} r="4" />
</g>
</svg>
</.frame>
"""
end
# A branch leaves the rail in an S curve, runs along its lane and, when it
# is merged, comes back the same way.
defp path(fork, merge, lane, merged) do
out =
"M#{fork} #{@rail} C#{r(fork + 16)} #{@rail} #{r(fork + 10)} #{lane} #{r(fork + 26)} #{lane}"
if merged do
out <>
" H#{r(merge - 26)} C#{r(merge - 10)} #{lane} #{r(merge - 16)} #{@rail} #{merge} #{@rail}"
else
out <> " H#{r(merge - 30)}"
end
end
defp r(number), do: Float.round(number * 1.0, 1)
end
assets/css/git/branch-graph.css
/* git/branch-graph */
.ehv-git-branch-graph { --ehv-max-w: 520px; --ehv-iso-scale: 0.82; --ehv-step: 120ms; }