Published on

ECharts in Phoenix LiveView

To display charts in Phoenix, we can either generate them server-side (e.g., using Contex) or client-side with JavaScript. I would prefer a server-side solution with pure SVG/CSS, but currently, nothing beats JavaScript charting libraries. However, we can still have the backend do most of the work and live update the charts with a minimal JS hook.

In this post, I’ll go over how to implement ECharts in a Phoenix LiveView app with live updates.

ECharts in PhoenixLiveView

Set up ECharts

Add echarts.min.js to your assets/vendor directory, and set up assets/js/hooks.js to render charts on events pushed from the backend:

// assets/js/hooks.js
let Hooks = {};

import * as echarts from "../vendor/echarts.min"

Hooks.Chart = {
  render(chart, option) {
    // The legend selection should not be overridden with subsequent updates
    if (chart.getOption() && option.legend && option.legend.selected) {
      delete option.legend.selected
    }

    chart.setOption(option)
  },

  mounted() {
    let chart = echarts.init(this.el)

    this.handleEvent(`chart-option-${this.el.id}`, (option) =>
      this.render(chart, option)
    )
  }
}

export default Hooks;

Initialize the LiveSocket in assets/js/app.js with the hooks:

// assets/js/app.js

// ...

import "phoenix_html";
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import topbar from "../vendor/topbar"
import Hooks from "./hooks" // 1. Import Hooks

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
  hooks: Hooks // 2. Add it to the liveSocket
});

// ...

This is all the JavaScript we need. Our backend will push the chart options used to render the graphs.

Implement some dummy data that we want to chart:

# lib/my_app/data.ex
defmodule MyApp.Data do
  def get_line_stack_data(cumulative \\ nil) do
    {
      ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
      Enum.map([
        %{
          name: "Email",
          values: [120, 132, 101, 134, 90, 230, 210]
        },
        %{
          name: "Union Ads",
          values: [220, 182, 191, 234, 290, 330, 310]
        },
        %{
          name: "Video Ads",
          values: [150, 232, 201, 154, 190, 330, 410]
        },
        %{
          name: "Direct",
          values: [320, 332, 301, 334, 390, 330, 320],
        },
        %{
          name: "Search Engine",
          values: [820, 932, 901, 934, 1290, 1330, 1320]
        }
      ], fn line_data ->
        values = Enum.map(line_data.values, & Float.round(&1 * :rand.uniform(), 0))
        values = cumulative && Enum.scan(values, 0, & &2 + &1) || values

        %{line_data | values: values}
      end)
    }
  end

  def get_gauge_multi_title_data do
    %{
      good: Float.round(:rand.uniform() * 100, 2),
      better: Float.round(:rand.uniform() * 100, 2),
      perfect: Float.round(:rand.uniform() * 100, 2)
    }
  end

  def get_process_count, do: :erlang.system_info(:process_count)
end

Remember to add a route to DashboardLive in the router:

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  scope "/", MyAppWeb do
    pipe_through :browser

    live "/", DashboardLive
  end
end

Now onto the DashboardLive LiveView module:

# lib/my_app_web/live/dashboard_live.ex
defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  alias MyApp.Data
  alias Phoenix.LiveView.AsyncResult

  @impl true
  def handle_params(_params, _uri, socket) do
    {:noreply,
      socket
      |> assign(:options, %{line_stack: %{cumulative: true}})
      |> assign(:reload_timers, %{})
      |> assign_async_schedule([:line_stack, :gauge_multi_title], fn opts ->
        %{
          line_stack: Data.get_line_stack_data(opts[:line_stack][:cumulative]),
          gauge_multi_title: Data.get_gauge_multi_title_data()
        }
      end)
      |> assign_async_schedule(:process_gauge, fn _opts ->
        %{process_gauge: Data.get_process_count()}
      end)}
  end

  # ...
end

In the above, we are setting an options assign so we can switch between cumulative and single data points for our stacked line graph. We also assign the reload timers so we can cancel the scheduled poll when the options change. The assign_async_schedule/4 function works the same way as assign_async/3, but also polls new data every 5 seconds.

The reason we use polling instead of PubSub is that chart data is usually an aggregate of a lot of data, and it doesn’t make sense to listen to individual records. We only need to re-render the charts every once in a while.

Continue to write out the assign_async_schedule/4 logic:

# lib/my_app_web/live/dashboard_live.ex
defmodule MyAppWeb.DashboardLive do
  # ...

  defp assign_async_schedule(socket, key_or_keys, fun, update_in \\ :timer.seconds(5)) do
    keys = List.wrap(key_or_keys)

    options =
      keys
      |> Enum.map(& {&1, %{}})
      |> Enum.into(%{})
      |> Map.merge(socket.assigns.options)

    socket =
      socket
      |> start_async_schedule(key_or_keys, fun, update_in)
      |> assign(:options, options)

    Enum.reduce(keys, socket, fn key, socket ->
      assign(socket, key, AsyncResult.loading())
    end)
  end

  defp start_async_schedule(socket, key_or_keys, fun, update_in) do
    options = Map.take(socket.assigns.options, List.wrap(key_or_keys))

    start_async(socket, key_or_keys, fn ->
      {fun.(options), fun, update_in}
    end)
  end
  
  @impl true
  def handle_async(key_or_keys, {:ok, {values, fun, update_in}}, socket) do
    socket =
      key_or_keys
      |> List.wrap()
      |> Enum.reduce(socket, fn key, socket ->
        async = Map.fetch!(socket.assigns, key)
        value = Map.fetch!(values, key)

        socket
        |> push_chart_event(key, value)
        |> assign(key, AsyncResult.ok(async, value))
      end)
      |> assign(:reload_timers, Map.put(socket.assigns.reload_timers, key_or_keys, start_timer(key_or_keys, fun, update_in)))

    {:noreply, socket}
  end

  def handle_async(key_or_keys, {:exit, reason} , socket) do
    socket =
      key_or_keys
      |> List.wrap()
      |> Enum.reduce(socket, fn key, socket ->
        async = Map.fetch!(socket.assigns, key)

        assign(socket, key, AsyncResult.failed(async, reason))
      end)

    {:noreply, socket}
  end

  defp start_timer(key_or_keys, fun, update_in, send_after \\ nil) do
    ref = Process.send_after(self(), {:async, key_or_keys, fun, update_in}, send_after || update_in)

    {ref, {fun, update_in}}
  end

  @impl true
  def handle_info({:async, key_or_keys, fun, update_in}, socket) do
    socket = start_async_schedule(socket, key_or_keys, fun, update_in)

    {:noreply, socket}
  end

  # ...
end

We also need to handle the options by resetting the timer and loading new data immediately:

# lib/my_app_web/live/dashboard_live.ex
defmodule MyAppWeb.DashboardLive do
  # ...

  @impl true
  def handle_event("options", %{"name" => "line_stack", "cumulative" => cumulative}, socket) do
    key_or_keys = [:line_stack, :gauge_multi_title]
    key_options = Map.put(socket.assigns.options[:line_stack], :cumulative, cumulative == "true")
    {timer, {fun, update_in}} = Map.fetch!(socket.assigns.reload_timers, key_or_keys)

    Process.cancel_timer(timer)

    socket =
      socket
      |> assign(:options, Map.put(socket.assigns.options, :line_stack, key_options))
      |> assign(:reload_timers, Map.put(socket.assigns.reload_timers, key_or_keys, start_timer(key_or_keys, fun, update_in, 1)))

    {:noreply, socket}
  end

  # ...
end

All that’s left to do is to push the ECharts options:

# lib/my_app_web/live/dashboard_live.ex
defmodule MyAppWeb.DashboardLive do
  # ...

  defp push_chart_event(socket, :line_stack, {columns, data}) do
    option =
      %{
        tooltip: %{
          trigger: "axis"
        },
        legend: %{
          data: Enum.map(data, & &1.name),
          selected: %{"Email" => false}
        },
        grid: %{
          left: "3%",
          right: "4%",
          bottom: "3%",
          containLabel: true
        },
        toolbox: %{
          feature: %{
            saveAsImage: %{}
          }
        },
        xAxis: %{
          type: "category",
          boundaryGap: false,
          data: columns
        },
        yAxis: %{
          type: "value"
        },
        series: Enum.map(data, &%{
            name: &1.name,
            type: "line",
            stack: "Total",
            data: &1.values
          })
      }

    push_event(socket, "chart-option-line_stack", option)
  end

  defp push_chart_event(socket, :gauge_multi_title, data) do
    option = %{
      series: [
        %{
          type: "gauge",
          anchor: %{
            show: true,
            showAbove: true,
            size: 18,
            itemStyle: %{
              color: "#FAC858"
            }
          },
          pointer: %{
            icon: "path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v115c0,1.4-1.2,2.6-2.6,2.6l0,0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.4,0.7,2.9,0.7z",
            width: 8,
            length: "80%",
            offsetCenter: [0, "8%"]
          },
          progress: %{
            show: true,
            overlap: true,
            roundCap: true
          },
          axisLine: %{
            roundCap: true
          },
          data: [
            %{
              value: data.good,
              name: "Good",
              title: %{
                offsetCenter: ["-40%", "80%"],
                fontSize: 10
              },
              detail: %{
                offsetCenter: ["-40%", "95%"],
                fontSize: 10,
                height: 10,
                width: 20
              }
            },
            %{
              value: data.better,
              name: "Better",
              title: %{
                offsetCenter: ["0%", "80%"],
                fontSize: 10
              },
              detail: %{
                offsetCenter: ["0%", "95%"],
                fontSize: 10,
                height: 10,
                width: 20
              }
            },
            %{
              value: data.perfect,
              name: "Perfect",
              title: %{
                offsetCenter: ["40%", "80%"],
                fontSize: 10
              },
              detail: %{
                offsetCenter: ["40%", "95%"],
                fontSize: 10,
                height: 10,
                width: 20
              }
            }
          ],
          title: %{
            fontSize: 14
          },
          detail: %{
            width: 40,
            height: 14,
            fontSize: 14,
            color: "#fff",
            backgroundColor: "inherit",
            borderRadius: 3,
            formatter: "{value}%"
          }
        }
      ]
    }

    push_event(socket, "chart-option-guage_multi_title", option)
  end

  defp push_chart_event(socket, :process_gauge, data) do
    option = %{
      series: [
        %{
          name: "Processes",
          type: "gauge",
          data: [
            %{
              value: data
            }
          ]
        }
      ]
    }

    push_event(socket, "chart-option-process_gauge", option)
  end
end

We’re setting up all the chart options here and pushing them as an event. This allows us to dynamically configure everything from the backend. We can even switch the chart type ad-hoc.

All that’s left to do is to set up the template:

<%!-- # lib/my_app_web/live/dashboard_live.html.heex --%>
<div class="w-full grid grid-row gap-4">
  <div class="w-full grid grid-row gap-4 p-4">
    <.header>
      Line stack
      <:subtitle>
        <.button :if={@options[:line_stack][:cumulative]} phx-click="options" phx-value-name={:line_stack} phx-value-cumulative="false">
          Single
        </.button>
        <.button :if={@options[:line_stack][:cumulative] != true} phx-click="options" phx-value-name={:line_stack} phx-value-cumulative="true">
          Rolling
        </.button>
      </:subtitle>
    </.header>

    <.async_result assign={@line_stack}>
      <:loading>Loading...</:loading>
      <:failed><.error>Failed to load chart</.error></:failed>
    </.async_result>

    <div id="line_stack" phx-hook="Chart" class="w-full h-[20rem]" phx-update="ignore" />
  </div>

  <div class="grid w-full grid-cols-1 gap-4 xl:grid-cols-2">
    <div class="grid grid-row gap-4 p-4">
      <.header>
        Gauge multi title
      </.header>

      <.async_result assign={@gauge_multi_title}>
        <:loading>Loading..</:loading>
        <:failed><.error>Failed to load chart</.error></:failed>
      </.async_result>

      <div id="guage_multi_title" phx-hook="Chart" class="w-full h-[20rem]" phx-update="ignore" />
    </div>

    <div class="grid grid-row gap-4 p-4">
      <.header>
        Processes
      </.header>

      <.async_result assign={@process_gauge}>
        <:loading>Loading...</:loading>
        <:failed><.error>Failed to load chart</.error></:failed>
      </.async_result>

      <div id="process_gauge" phx-hook="Chart" class="w-full h-[20rem]" phx-update="ignore" />
    </div>
  </div>
</div>

Now we have live updating charts managed entirely by the LiveView module!

Next you’ll want to produce good chart data.

Tests for MyAppWeb.DashboardLive.

The test for the LiveView is straightforward. We just need to assert the async load and trigger the button.

defmodule MyAppWeb.DashboardLiveTest do
  use MyAppWeb.ConnCase

  import Phoenix.LiveViewTest

  # You'll want to seed the database for chart data from the database to catch
  # any issues with transforming the data to ECharts options:
  # 
  #   setup :seed_data
  #
  #   defp seed_data(_) do
  #     for n <- 1..100, do: record_fixture(%{value: n})
  #
  #     :ok
  #   end

  test "renders", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/dashboard")

    assert html =~ "Loading..."
    refute render_async(view) =~ "Loading..."

    assert view |> element("button", "Single") |> render_click() =~ "Rolling"
  end
end

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process