Skip to main content

lib/bloccs/web/components/graph.ex

defmodule Bloccs.Web.Components.Graph do
  @moduledoc """
  The shared topology SVG: places one `<.hex_glyph>` per node and one bezier
  `<path>` per edge from a server-computed `Bloccs.Web.Topology.Layout`. Used by
  the topology panel (live node state + activity) and the coverage panel
  (reached/unreached overlay). Pure rendering — no data loading, no layout math.

  Live extras (all optional, off by default so the coverage panel is unchanged):

    * `states` — `%{node_id => :idle|:running|:ok|:failed}` drives the glyph colour
    * `rates` — `%{node_id => events/sec}` shown under the label when > 0
    * `active_edges` — a `MapSet` of `{from, to}` that have recent flow; those
      edges animate (marching dashes)
    * `link_base` — when set, each node links to that node's filtered messages
  """

  use Bloccs.Web, :html

  alias Bloccs.Web.Topology.Layout

  attr :network, :any, required: true
  attr :states, :map, default: %{}
  attr :rates, :map, default: %{}
  attr :active_edges, :any, default: nil
  # when set, each node patches to "<link_base>?node=<id>" to select it
  attr :link_base, :string, default: nil
  # the currently-selected node id (highlighted)
  attr :selected, :any, default: nil
  # %{node_id => tooltip string} shown as the node's hover <title>
  attr :titles, :map, default: %{}
  # false hides labels/rates (for compact thumbnails)
  attr :labels, :boolean, default: true
  # nil = no coverage overlay; a MapSet of {from_node, to_node} = reached edges
  attr :reached_edges, :any, default: nil

  def graph(assigns) do
    assigns = assign(assigns, :layout, Layout.compute(assigns.network))

    ~H"""
    <div class="bloccs-graph" id={"graph-#{@network.id}"}>
      <svg
        class="bloccs-graph__svg"
        viewBox={"0 0 #{@layout.width} #{@layout.height}"}
        width={@layout.width}
        height={@layout.height}
        role="img"
        aria-label={"Topology of #{@network.id}"}
      >
        <g class="bloccs-graph__edges">
          <path
            :for={e <- @layout.edges}
            id={"#{@network.id}-#{e.from}-#{e.to}"}
            class={["bloccs-edge", edge_class(@reached_edges, e), active_class(@active_edges, e)]}
            d={e.path}
            fill="none"
          />
        </g>
        <%!-- packets: a dot rides each active edge from source to target --%>
        <g class="bloccs-graph__packets">
          <%= for e <- @layout.edges, active?(@active_edges, e), p <- packets(@rates, e) do %>
            <circle class="bloccs-packet" r="4">
              <animateMotion dur={p.dur} begin={p.begin} repeatCount="indefinite" rotate="auto">
                <mpath href={"##{@network.id}-#{e.from}-#{e.to}"} />
              </animateMotion>
            </circle>
          <% end %>
        </g>
        <g class="bloccs-graph__nodes">
          <g
            :for={n <- @layout.nodes}
            class={["bloccs-graph__node", @selected == n.id && "is-selected"]}
          >
            <%= if @link_base do %>
              <.link patch={"#{@link_base}?node=#{n.id}"}>
                <.node_cell
                  n={n}
                  state={Map.get(@states, n.id, :idle)}
                  rate={Map.get(@rates, n.id)}
                  title={Map.get(@titles, n.id, n.label)}
                  labels={@labels}
                />
              </.link>
            <% else %>
              <.node_cell
                n={n}
                state={Map.get(@states, n.id, :idle)}
                rate={Map.get(@rates, n.id)}
                title={Map.get(@titles, n.id, n.label)}
                labels={@labels}
              />
            <% end %>
          </g>
        </g>
      </svg>
    </div>
    """
  end

  attr :n, :map, required: true
  attr :state, :atom, default: :idle
  attr :rate, :any, default: nil
  attr :title, :string, default: nil
  attr :labels, :boolean, default: true

  defp node_cell(assigns) do
    ~H"""
    <.hex_glyph glyph={@n.glyph} state={@state} label={@title || @n.label} x={@n.x} y={@n.y} />
    <text :if={@labels} class="bloccs-graph__label" x={@n.x} y={@n.y + 70} text-anchor="middle">
      {@n.label}
    </text>
    <text
      :if={@labels && @rate && @rate > 0}
      class="bloccs-graph__rate"
      x={@n.x}
      y={@n.y + 88}
      text-anchor="middle"
    >
      {rate_label(@rate)}/s
    </text>
    """
  end

  defp rate_label(r) when r >= 10, do: round(r)
  defp rate_label(r), do: Float.round(r / 1, 1)

  defp edge_class(nil, _edge), do: nil

  defp edge_class(reached, %{from: f, to: t}) do
    if MapSet.member?(reached, {f, t}), do: "bloccs-edge--reached", else: "bloccs-edge--unreached"
  end

  defp active_class(nil, _edge), do: nil

  defp active_class(active, %{from: f, to: t}) do
    if MapSet.member?(active, {f, t}), do: "bloccs-edge--active", else: nil
  end

  defp active?(nil, _edge), do: false
  defp active?(active, %{from: f, to: t}), do: MapSet.member?(active, {f, t})

  # One or more packets per active edge, their count and speed scaled by the
  # source node's throughput, staggered so they form a steady stream.
  defp packets(rates, %{from: from}) do
    rate = Map.get(rates, from, 0)
    count = 1 + min(2, trunc(rate / 4))
    dur = Float.round(max(0.9, 2.2 - rate * 0.08), 2)
    spacing = dur / count

    for i <- 0..(count - 1), do: %{dur: "#{dur}s", begin: "#{Float.round(i * spacing, 2)}s"}
  end
end