lib/kino_progress_bar.ex

defmodule KinoProgressBar do
  @moduledoc """
  Documentation for `KinoProgressBar`.
  """

  use Kino.JS, assets_path: "lib/assets"
  use Kino.JS.Live

  # TODO Add rangeless option

  @doc """
  Create a new progress bar.

  ## Options:

  * `range`: The range of values for the progress bar.  Defaults to
  `{0, 100}`.

  * `current`: Current value.  Defaults to 0.

  * `width`: The width of the progress bar in pixels.  Defaults to
  600.

  * `format_current`: How to display the current value.  Defaults to
  appending a percent symbol.

  * `color`: The color scheme for the current bar.  Defaults to
  `:blue`.  Possible values: `:red`, `:blue`, `:gray`, `:green`,
  `{:color, CSS_COLOR_STRING}`.

  * `throttle`: Elixir can handle 10_000 messages per second without
    issue, but browsers react poorly if you try to update the DOM
    10_000 times per second.  So, this kino throttles the rate at
    which client updates are sent to at most one every `throttle`
    milliseconds.  The default is 500, so at most two updates are sent
    per second.
  """
  def new(title, opts \\ []) do
    width = Keyword.get(opts, :width, 600)
    range = Keyword.get(opts, :range, {0, 100})
    format_current = Keyword.get(opts, :format_current, fn x -> "#{x}%" end)
    color = Keyword.get(opts, :color, :blue) |> color_to_css()
    throttle = Keyword.get(opts, :throttle, 500)
    current = Keyword.get(opts, :current, 0)

    Kino.JS.Live.new(__MODULE__, %{
      title: title,
      width: width,
      range: range,
      current: current,
      format_current: format_current,
      color: color,
      throttle: throttle,
      last_client_update: System.monotonic_time(:millisecond)
    })
  end

  @doc """
  Update the current value of the progress bar.

  This is throttled per the `throttle` parameter set when the progress
  bar was created.
  """
  def set_current(kino, current) do
    Kino.JS.Live.cast(kino, {:set_current, current})
  end

  @impl true
  def init(data, ctx) do
    {:ok, assign(ctx, data)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok,
     %{
       title: ctx.assigns.title,
       width: ctx.assigns.width,
       current_px: current_px(ctx.assigns),
       current_text: ctx.assigns.format_current.(ctx.assigns.current),
       color: ctx.assigns.color
     }, ctx}
  end

  @impl true
  def handle_cast({:set_current, current}, ctx) do
    ctx = assign(ctx, current: current)

    now = System.monotonic_time(:millisecond)

    cond do
      now - ctx.assigns.last_client_update >= ctx.assigns.throttle ->
        {:noreply, ctx |> broadcast_set_current()}

      true ->
        # Kino.JS.Live doesn't support timeouts like GenServer
        :timer.send_after(
          ctx.assigns.throttle - (now - ctx.assigns.last_client_update),
          :broadcast_set_current
        )

        {:noreply, ctx}
    end
  end

  @impl true
  def handle_info(:broadcast_set_current, ctx) do
    {:noreply, ctx |> broadcast_set_current()}
  end

  defp current_px(%{width: width, current: current, range: range}) do
    floor(width * bar_fraction(current, range))
  end

  defp bar_fraction(x, {lower, upper}) do
    x = min(max(lower, x), upper)
    (x - lower) / (upper - lower)
  end

  defp color_to_css(:red), do: "rgb(239 68 68)"
  defp color_to_css(:green), do: "rgb(34 197 94)"
  defp color_to_css(:blue), do: "rgb(59 130 246)"
  defp color_to_css(:gray), do: "rgb(107 114 128)"
  defp color_to_css({:color, color}), do: color

  defp broadcast_set_current(ctx) do
    broadcast_event(ctx, "set_current", %{
      current_px: current_px(ctx.assigns),
      current_text: ctx.assigns.format_current.(ctx.assigns.current)
    })

    assign(ctx, last_client_update: System.monotonic_time(:millisecond))
  end
end