empowered.house ↗

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.

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

<div class="h-96 rounded-xl border border-border">
  <.git_branch_graph animated />
</div>
default
isometric
four branches · the last one still open
one branch · hover · no glow
light surface
fade out · no glow · still

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
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
branch-graph.css
/* git/branch-graph */
.ehv-git-branch-graph { --ehv-max-w: 520px; --ehv-iso-scale: 0.82; --ehv-step: 120ms; }