Tutorial

Realtime Apex Charts in Phoenix LiveView

Charts is a core part in any web application that displays data in any way. In this tutorial, I will go through how to take a popular charting library and make a configurable and reusable Phoenix component. I will also go through how to update the graphs in realtime with Phoenix LiveView. The charting library I will use it Apex Charts. It aims to be a modern charting library that helps developers to create beautiful and interactive visualizations for web pages.

In the applications where we will use charts, it should be as easy as possible and with some of the complex configurations abstracted away. The idea is that I want to have reusable components with a few easy configurations like:

<.line_graph
  id="line-chart-1"
  height={420}
  width={640}
  dataset={@dataset}
/>

Step 1 - Install the library

There are two recommended ways to install Apex Charts. Either through installing the node package with NPM or Yarn, or inluding a CDN delivered script in your root html. I guess you can copy the content of the script into your vendor folder just like topbar js-file.

However, I am adding the script to the root layout-file:

<!-- lib/tutorial_web/components/layouts/root.html.heex -->
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>

Step 2 - Make the first Chart

In this step we will create the first chart to see that everything is working. This example comes straight out of the Apex Charts documentation.

First add the div that will contain the chart:

<div id="chart"></div>

And then add the example javascript in the app.js file.

// assets/js/app.js
var options = {
  chart: {
    type: 'line'
  },
  series: [{
    name: 'sales',
    data: [30, 40, 35, 50, 49, 60, 70, 91, 125]
  }],
  xaxis: {
    categories: [1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999]
  }
}

var chart = new ApexCharts(document.querySelector("#chart"), options)

chart.render()

If you refresh the page, you should now be able to see the chart drawn on the page.

Step 3 - Make the line chart Phoenix component

The above example is neat and can work for some people to get started with. But I want to do this more in a Phoenix way, and what I mean by that it to wrap the code and options in a reusable Phoenix Component.

In the components folder, create a new file called charts.ex and that will hold the charts components.

defmodule TutorialWeb.Components.Charts do
  @moduledoc """
  Holds the charts components
  """
  use Phoenix.Component

  attr :id, :string, required: true
  def line_graph(assigns) do
    ~H"""
    <div
      id={@id}
    ></div>
    """
  end
end

With this component in place, I need to import it somewhere. For example, in the LiveView, import it like:

import TutorialWeb.Components.Charts

Now we can replace the initial div above with something like:

<.line_chart id="chart" />

That is a good start but we are far from where we should be. The next step is to move the javascript to a LiveView javascript hook.

  def line_graph(assigns) do
    ~H"""
    <div
      id={@id}
      phx-hook="Chart"
    ></div>
    """
  end

And move the code from app.js to a javascript hook called Chart and put it inside the mounted-function:

Hooks.Chart = {
  mounted() {
    const options = {
      chart: {
        type: 'line'
      },
      series: [{
        name: 'sales',
        data: [30,40,35,50,49,60,70,91,125]
      }],
      xaxis: {
        categories: [1991,1992,1993,1994,1995,1996,1997,1998,1999]
      }
    }

    let chart = new ApexCharts(this.el, options)
    chart.render()
  }
}

Now we have the chart wired up inside a Phoenix component and with the help of a Phoenix LiveView hook.

Step 4 - Make the Phoenix component dynamic

Above the line_graph/1 function, we can specify the attributes that we want pass in as assigns. The idea is that the configurations will be encoded as JSON and passed to javascript as a data-attribute.

attr :id, :string, required: true
  attr :type, :string, default: "line"
  attr :width, :integer, default: nil
  attr :height, :integer, default: nil
  attr :animated, :boolean, default: false
  attr :toolbar, :boolean, default: false
  attr :dataset, :list, default: []
  attr :categories, :list, default: []

  def line_graph(assigns) do
    ~H"""
    <div
      id={@id}
      class="[&>div]:mx-auto"
      phx-hook="Chart"
      data-config={Jason.encode!(trim %{
        height: @height,
        width: @width,
        type: @type,
        animations: %{
          enabled: @animated
        },
        toolbar: %{
          show: @toolbar
        }
      })}
      data-series={Jason.encode!(@dataset)}
      data-categories={Jason.encode!(@categories)}
    ></div>
    """
  end

  defp trim(map) do
    Map.reject(map, fn {_key, val} -> is_nil(val) || val == "" end)
  end

NOTE the trim/1 function. That is for removing empty values from the map when encoding it to JSON. Because in the javascript, the values from here will be merged with the default values that are set directly in the javascript.

Hooks.Chart = {
  mounted() {
    const chartConfig = JSON.parse(this.el.dataset.config)
    const seriesData = JSON.parse(this.el.dataset.series)
    const categoriesData = JSON.parse(this.el.dataset.categories)

    const options = {
      chart: Object.assign({
        background: 'transparent',
      }, chartConfig),
      series: seriesData,
      xaxis: {
        categories: categoriesData
      }
    }

    const chart = new ApexCharts(this.el, options);

    chart.render();
  }
}

Now I can test this out. Also note that I added a second serie of hardcoded data.

<.line_graph
  id="line-chart-1"
  height={420}
  width={640}
  dataset={[
    %{
      name: "sales",
      data: [30,40,35,50,49,60,70,91,125]
    },
    %{
      name: "profit",
      data: [23,27,30,38,41,47,51,58,67]
    }
  ]}
  categories={[1991,1992,1993,1994,1995,1996,1997,1998,1999]}
/>

If I refresh the page, I should see the second graph now.

Step 5 - Make the chart realtime

To make the chart realtime, we need to either use LiveView or Phoenix channels. For the purpose of this tutorial, and also the easiest way, is to use Phoenix LiveView. I am already using a LiveView but I will create a specific LiveComponent for this.

defmodule TutorialWeb.Live.ChartComponent do
  use TutorialWeb, :live_component

  def render(assigns) do
    ~H"""
    <div id={@id} class="max-w-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700">
      <div class="px-6 pt-6 pb-2">
        <h5 class="text-xl font-bold tracking-tight text-gray-900 dark:text-white">Random Data</h5>
      </div>
      <div class="p-1">
        <.line_chart
          id="line-chart-1"
          dataset={[
            %{
              name: "sales",
              data: [30,40,35,50,49,60,70,91,125]
            }
          ]}
          categories={[1991,1992,1993,1994,1995,1996,1997,1998,1999]}
        />
      </div>
    </div>
    """
  end

  def update(%{event: "update_chart"}, socket) do
    send_update_after(__MODULE__, [id: socket.assigns.id, event: "update_chart"], 3_000)

    dataset = [
      %{
        name: "sales",
        data: Enum.map(1..9, fn _ -> Enum.random(20..80) end)
      }
    ]

    {
      :ok,
      socket
      |> push_event("update-dataset", %{dataset: dataset})
    }
  end

  def update(assigns, socket) do
    send_update_after(__MODULE__, [id: assigns.id, event: "update_chart"], 3_000)

    {
      :ok,
      socket
      |> assign(assigns)
    }
  end
end

To fake the realtime data, I generate a random dataset every 3rd second and push it to the javascript hook.

The only thing I need to do there is to use built in chart.updateSeries

// rest of the chart code ..
chart.render()

this.handleEvent("update-dataset", data => {
  chart.updateSeries(data.dataset)
})

This will replace the entire series but there is also a function to just append data to the series.

Final Result

A chart with random data will rarely look natural but as you can see, it works. There are a lof of options and charts to explore on the apex charts website. https://apexcharts.com/docs/