defmodule Bloccs.Web.Components.Graph do @moduledoc """ The shared topology SVG: places one `<.hex_glyph>` per node and one bezier `` 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 "?node=" 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 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