Skip to main content

lib/bloccs/web/components/chart.ex

defmodule Bloccs.Web.Components.Chart do
  @moduledoc """
  Server-rendered SVG charts for the Messages panel — no client charting library.
  `throughput/1` is an events-per-second volume chart (one bar per 1s bucket,
  failures stacked in red, à la Sentry); `sparkline/1` is a tiny inline line.
  The series is computed by `Bloccs.Web.Telemetry.Flow` and redrawn each frame.
  """

  use Phoenix.Component

  @w 720
  @h 80
  @pad_top 14
  @pad_bottom 1

  attr :series, :list, required: true

  def throughput(assigns) do
    series = assigns.series
    max = series |> Enum.map(& &1.total) |> Enum.max(fn -> 0 end) |> max(1)
    n = max(length(series), 1)
    bw = @w / n
    plot = @h - @pad_top - @pad_bottom

    bars =
      series
      |> Enum.with_index()
      |> Enum.map(fn {b, i} ->
        total_h = b.total / max * plot
        fail_h = (b[:failed] || 0) / max * plot

        %{
          x: Float.round(i * bw + bw * 0.12, 2),
          w: Float.round(bw * 0.76, 2),
          total_y: Float.round(@h - @pad_bottom - total_h, 2),
          total_h: Float.round(total_h, 2),
          fail_y: Float.round(@h - @pad_bottom - fail_h, 2),
          fail_h: Float.round(fail_h, 2),
          total: b.total,
          failed: b[:failed] || 0
        }
      end)

    assigns =
      assign(assigns,
        w: @w,
        h: @h,
        max: max,
        bars: bars,
        empty: max <= 1 and total_of(series) == 0
      )

    ~H"""
    <svg
      class="bloccs-chart"
      viewBox={"0 0 #{@w} #{@h}"}
      width="100%"
      height={@h}
      preserveAspectRatio="none"
      role="img"
      aria-label="events per second"
    >
      <line class="bloccs-chart__baseline" x1="0" y1={@h - 1} x2={@w} y2={@h - 1} />
      <g :for={b <- @bars}>
        <title>{b.total}/s{if b.failed > 0, do: " · #{b.failed} failed"}</title>
        <rect
          :if={b.total_h > 0}
          class="bloccs-chart__bar"
          x={b.x}
          y={b.total_y}
          width={b.w}
          height={b.total_h}
          rx="1"
        />
        <rect
          :if={b.fail_h > 0}
          class="bloccs-chart__bar-fail"
          x={b.x}
          y={b.fail_y}
          width={b.w}
          height={b.fail_h}
          rx="1"
        />
      </g>
      <text class="bloccs-chart__peak" x="8" y="14">peak {@max}/s</text>
      <text :if={@empty} class="bloccs-chart__peak" x={@w / 2} y={@h / 2} text-anchor="middle">
        waiting for traffic
      </text>
    </svg>
    """
  end

  defp total_of(series), do: series |> Enum.map(& &1.total) |> Enum.sum()

  @sw 84
  @sh 22

  attr :values, :list, required: true

  @doc "A tiny inline sparkline for a list of numbers (per-node throughput, etc.)."
  def sparkline(assigns) do
    values = assigns.values || []
    max = values |> Enum.max(fn -> 0 end) |> max(1)
    n = max(length(values), 1)

    pts =
      values
      |> Enum.with_index()
      |> Enum.map_join(" ", fn {v, i} ->
        sx = if n == 1, do: @sw, else: Float.round(i / (n - 1) * @sw, 1)
        sy = Float.round(@sh - 1 - v / max * (@sh - 2), 1)
        "#{sx},#{sy}"
      end)

    assigns =
      assign(assigns, sw: @sw, sh: @sh, pts: pts, empty: values == [] or Enum.sum(values) == 0)

    ~H"""
    <svg
      class="bloccs-spark"
      viewBox={"0 0 #{@sw} #{@sh}"}
      width={@sw}
      height={@sh}
      aria-hidden="true"
    >
      <polyline :if={not @empty} class="bloccs-spark__line" points={@pts} fill="none" />
      <line :if={@empty} class="bloccs-spark__zero" x1="0" y1={@sh - 1} x2={@sw} y2={@sh - 1} />
    </svg>
    """
  end
end