#206: Charts with Plotto

Published September 7, 2026 15m 34s

Plotto

Follow along with the episode starter on GitHub


Sponsor

This episode is brought to you by Potions

With Potions you can deploy Elixir apps to a VPS you own, with the convenience of a PaaS.

Connect Hetzner or DigitalOcean, push to GitHub, and Potions handles the server, Postgres, TLS, and zero-downtime deploys for you.

Start a free trial at Potions.io.

Here we have a little record store app where customers can buy albums in different formats: vinyl, CD, and digital. When a user buys an album it records a sale, which is then displayed on this dashboard. Right now our dashboard has a summary of our sales data, along with a table that breaks that data down by month. In this episode we’ll update our dashboard to include charts that display our sales data.

To create our charts we’ll use Plotto, a new charting library that generates SVG charts we can display in our app, and it has PNG support too. What I like about Plotto is that it’s 100% Elixir - there are no NIFs or external dependencies to install. It is a new library though, so keep that in mind while using it. We’ll use it to add three charts to our dashboard: one for albums sold, one for revenue and refunds, and one for cumulative revenue.

Alright, let’s get started. We’ll grab the Plotto config from Hex, then open our app’s mix.exs and add Plotto to our list of dependencies.

File path: mix.exs

defp deps do
  [
    # ...
    {:plotto, "~> 0.4.0"},
    # ...
  ]
end

Then let’s go to the command line and fetch it.

$ mix deps.get

And notice that Plotto has no dependencies of its own - it’s the only thing added to our app.

Now before we start integrating Plotto into our app, let’s get an idea for how it works. We’ll start an IEx session with our application.

$ iex -S mix

Now let’s create a bar chart, which we can do with Plotto.BarChart.new!. Plotto needs your data grouped into series - each series is one list of points. In a bar chart, each series gets its own set of bars, one for every label along the bottom of the chart. Each series has a name and a list of data points, and each point is a label and a value. I’ll paste in our data here - that’s one series with two months, July and August - and add a title of “Units sold”.

> chart =
    Plotto.BarChart.new!(
      [%{name: "Vinyl", data: [%{label: "Jul", value: 39}, %{label: "Aug", value: 39}]}],
      title: "Units sold"
    )
%Plotto.BarChart{...}

Now this returns a chart struct - nothing’s rendered yet. If we want an SVG we just need to call Plotto.to_svg!, passing in our chart.

> Plotto.to_svg!(chart)
"<svg class= ..."

Great, that returned our chart SVG. Then we can use that same struct to render a PNG with Plotto.to_png!. And let’s also use File.write! to store it locally as a file called units.png so we can inspect the rendered PNG.

> File.write!("units.png", Plotto.to_png!(chart))
:ok

Perfect! It generated a PNG for us and we didn’t have to install anything extra to get it.

With that, let’s start integrating Plotto into our application. We’ll create a new module called Charts to build our charts, and because this is concerned with presentation we’ll put it in lib/teacher_web/charts.ex so that it lives next to our other view related modules.

For our first chart we’ll render a bar chart that breaks down each album sold by its format. Let’s create a new public function units_by_format. It will take a report that’s generated by the Sales.monthly_report function - this is the same table of data that the dashboard already uses - and we’ll want our function to return a Plotto.BarChart.new!.

Now we need to format our data for Plotto. The report that’s being passed in is a list of sales data, so we’ll need to format it in a way that returns sales data for each format. To do that let’s loop over each format, which we can get with the Sale.formats function to return our three formats: vinyl, cd, and digital.

We’ll return one series for each format. This will include the name, which we’ll format with the CoreComponents.format_label function - this just turns our label into a string and formats it. Then for the data we’ll use a new function we’ll create called point to return a report row as a Plotto data point, with the month’s name as the label.

One thing to keep in mind when using Plotto is that it requires every series in a chart to have the same labels in the same order. That’s fine for us, because the Sales.monthly_report data that’s being passed into this function always returns every month in the window.

We’ll define the point private function, passing in the row - which is one month from the report - and then value, which for this chart is the number of albums sold.

Then let’s take our formatted series and store it as a new variable series, and pass that into BarChart.new! along with a title for our chart. Then let’s set the legend to :top_left and tooltip: :native, which puts a <title> element inside each bar.

Then let’s set the width and the height as module attributes. I’ll set the width to 800 pixels and the height to 320. And let’s import two helper functions from CoreComponents: the first is money, which we’ll need a little later, and then format_label, which we’re using below. We’ll also need to alias the Sale module so we can use it without the prefix.

File path: lib/teacher_web/charts.ex

defmodule TeacherWeb.Charts do
  import TeacherWeb.CoreComponents, only: [money: 1, format_label: 1]

  alias Teacher.Sales.Sale

  @width 800
  @height 320

  def units_by_format(report) do
    series =
      for format <- Sale.formats() do
        %{name: format_label(format), data: Enum.map(report, &point(&1, &1.by_format[format]))}
      end

    Plotto.BarChart.new!(series,
      title: "Units sold",
      legend: :top_left,
      tooltip: :native,
      width: @width,
      height: @height
    )
  end

  # One Plotto data point for a month.
  defp point(row, value) do
    %{label: Calendar.strftime(row.month, "%b"), value: value}
  end
end

Now let’s create a chart component to make it easy to render our chart. We’ll open lib/teacher_web/components/core_components.ex and define a new chart function that takes some assigns. Then we’ll define the chart attribute, which we’ll make required, and we’ll document that it needs to be a Plotto.BarChart, LineChart, or CandlestickChart. Then we’ll define the HEEx, and in it call Phoenix.HTML.raw to tell HEEx that the returned SVG - from Plotto.to_svg! - is safe to render as markup.

File path: lib/teacher_web/components/core_components.ex

@doc """
Renders a Plotto chart as inline SVG.

## Examples

    <.chart chart={Charts.units_by_format(@report)} />
"""
attr :chart, :any, required: true, doc: "a Plotto.BarChart, LineChart, or CandlestickChart"

def chart(assigns) do
  ~H"""
  {Phoenix.HTML.raw(Plotto.to_svg!(@chart))}
  """
end

With that added let’s open lib/teacher_web/live/dashboard_live.ex and alias the new TeacherWeb.Charts module we created. Then let’s remove the table of album sales data that we were displaying - we’ll replace it with a chart. We’ll wrap it in a div with the Tailwind space-y-6 class to give it some spacing, then I’ll paste in the new chart component. And for the chart attribute it just calls Charts.units_by_format, passing in the @report.

Now the report data is already being assigned to this LiveView in the load_metrics function, but it’s currently called rows since it was feeding the old table. Let’s rename it to :report to match the name we’re using in Charts.units_by_format.

File path: lib/teacher_web/live/dashboard_live.ex

alias Teacher.Sales
alias TeacherWeb.Charts

# ...

<div class="space-y-6">
  <.chart chart={Charts.units_by_format(@report)} />
</div>

# ...

defp load_metrics(socket) do
  socket
  |> assign(:summary, Sales.summary())
  |> assign(:report, Sales.monthly_report(@months))
  |> assign(:top_albums, Sales.top_albums())
end

Now let’s start our server.

$ mix phx.server

And if we go back to the Dashboard - there’s our chart. We can see the breakdown of sales for each month by their format.

Now if we resize the browser, our chart doesn’t resize. This is because it’s set to a fixed 800 pixels wide, so let’s fix this. Plotto gives every element a semantic CSS class, so let’s open assets/css/app.css and add the plotto-chart class, updating the width to 100% and the height to auto.

File path: assets/css/app.css

.plotto-chart {
  width: 100%;
  height: auto;
}

Now when we resize our browser, our chart scales up and down.

Now let’s add a new chart for revenue and refunds. A nice feature of Plotto is that it handles negative values with an automatic zero baseline, so we can plot revenue going up and refunds going down from the same axis.

We’ll go back to our Charts module and create a new function to return our chart. We’ll call it revenue_and_refunds and it will take the same report data. We’re building two series here: revenue and refunds. To format the data we’ll map over the report and call the point function, just like we did for our first chart, only for the value we’ll pass the row’s revenue_cents through a new function - dollars - that converts cents into dollars. Then for refunds we’ll do the same thing, only we’ll use the refunds_cents field and display it as a negative value. For the dollars function, it takes an amount in cents and returns whole dollars, which we’ll get by dividing the cents by 100.

Then we’ll return our BarChart. For this one we’ll set the mode to :stacked, to stack the two series in the same column so revenue rises above zero and refunds drop below it. And we’ll add a title, a legend of :top_left, and let’s specify colors for our two bars. Then label, which puts text above each column - in stacked mode that’s one label per month, showing the total of the positive segments, so here it’s the revenue figure. This can be :value for the raw number, but it also takes a function, so let’s take our dollar value and format it with the money function we imported from CoreComponents earlier, to display the value as a dollar amount. Then we’ll set the tooltip and the same width and height values.

And I’m now seeing that we have an extra curly bracket in our series data, and that we need to correctly return the revenue_cents and refunds_cents from the row, so let’s fix those.

File path: lib/teacher_web/charts.ex

def revenue_and_refunds(report) do
  series = [
    %{name: "Revenue", data: Enum.map(report, &point(&1, dollars(&1.revenue_cents)))},
    %{name: "Refunds", data: Enum.map(report, &point(&1, -dollars(&1.refunds_cents)))}
  ]

  Plotto.BarChart.new!(series,
    mode: :stacked,
    title: "Revenue and refunds",
    legend: :top_left,
    colors: ["#4E79A7", "#E15759"],
    label: fn %{value: dollars} -> money(dollars * 100) end,
    tooltip: :native,
    width: @width,
    height: @height
  )
end

defp dollars(cents), do: div(cents, 100)

Now we can go back to DashboardLive and add it to the page below our first chart.

File path: lib/teacher_web/live/dashboard_live.ex

<div class="space-y-6">
  <.chart chart={Charts.units_by_format(@report)} />
  <.chart chart={Charts.revenue_and_refunds(@report)} />
</div>

Then if we go back to the Dashboard we see our new “Revenue and refunds” chart displayed below our “Units sold” chart. In it we see the revenue displayed for each month in blue going up and the refunds in red below it. And the dollar figure over every month is being correctly formatted.

Now let’s add one last chart: the cumulative net revenue as a line chart. Back in our Charts module we’ll add a function to return it called cumulative_revenue, and just like before it will take the report data. Then we’ll return our series data with a name of “Net revenue” and then the data, which we’ll map over just like before, only for this chart we’ll pass in the row’s cumulative_cents value, using the dollars function here as well. Then to build a line chart we’ll call Plotto.LineChart.new!, and we’ll include options for the title, the tooltip, and the same width and height.

File path: lib/teacher_web/charts.ex

@doc """
Cumulative net revenue at the end of each month.
"""
def cumulative_revenue(report) do
  series = [
    %{name: "Net revenue", data: Enum.map(report, &point(&1, dollars(&1.cumulative_cents)))}
  ]

  Plotto.LineChart.new!(series,
    title: "Cumulative net revenue",
    tooltip: :native,
    width: @width,
    height: @height
  )
end

Then we’ll open lib/teacher_web/live/dashboard_live.ex and add our third chart.

File path: lib/teacher_web/live/dashboard_live.ex

<div class="space-y-6">
  <.chart chart={Charts.units_by_format(@report)} />
  <.chart chart={Charts.revenue_and_refunds(@report)} />
  <.chart chart={Charts.cumulative_revenue(@report)} />
</div>

And then from our Dashboard we can see all three charts live.

These charts look great, but another nice feature of Plotto is that it works with LiveView. Every data point can carry an attrs map, and whatever’s in it is copied straight onto the SVG element. Let’s make use of this and add a phx-click to every data point, so that when it’s clicked we render that specific sales data.

We’ll go back to our point/2 function and include an attrs field with a phx-click that has a value of select-month, and a phx-value-month with a month value so we know what month was clicked. Now the row’s month is a Date struct, so we’ll call Date.to_iso8601 to format it as a string that we can read in the LiveView.

File path: lib/teacher_web/charts.ex

# One Plotto data point for a month. Clicking it selects that month.
defp point(row, value) do
  %{
    label: Calendar.strftime(row.month, "%b"),
    value: value,
    attrs: %{
      "phx-click" => "select-month",
      "phx-value-month" => Date.to_iso8601(row.month)
    }
  }
end

Now every point on all three charts sends a select-month event with that month’s date. We just need to handle it in the LiveView.

We’ll add a handle_event callback, pattern matching on the select-month event, and then we’ll pattern match on the month to get the month string value. Inside the callback we’ll return a :noreply tuple, and we’ll call a new function select_month, passing it the socket and the month - converting that string back into a Date struct with Date.from_iso8601! on the way in.

In our first select_month function, if the month is nil we’ll return a socket with the selected_month as nil and month_sales set to an empty list. Then we’ll create another select_month function to match on any non-nil month value, and in that case we’ll return a socket with the selected_month set to the month passed in. And then for the month_sales we can use the Sales.sales_in function to give us that month’s sales with their albums preloaded. And if we open the Sales module we can see how it works by querying for all sales for a given month.

File path: lib/teacher_web/live/dashboard_live.ex

@impl true
def handle_event("select-month", %{"month" => month}, socket) do
  {:noreply, select_month(socket, Date.from_iso8601!(month))}
end

defp select_month(socket, nil), do: assign(socket, selected_month: nil, month_sales: [])

defp select_month(socket, month) do
  assign(socket, selected_month: month, month_sales: Sales.sales_in(month))
end

Let’s also call select_month at the end of load_metrics, so that if a month is selected when a sale comes in over PubSub, the list refreshes too.

File path: lib/teacher_web/live/dashboard_live.ex

defp load_metrics(socket) do
  socket
  |> assign(:summary, Sales.summary())
  |> assign(:report, Sales.monthly_report(@months))
  |> assign(:top_albums, Sales.top_albums())
  |> select_month(socket.assigns[:selected_month])
end

With this we’ll need a table to display the sales data, so let’s create a new section under the charts to show it. And we’ll set it to display only if there’s a @selected_month. I’ll paste in a header for our section that will read “Sales in” and then the selected month, along with a subtitle telling the user to click the chart to load monthly data. Then below that I’ll paste in the table data with columns for the date it was sold, the album’s title, format, buyer’s name, and the price.

File path: lib/teacher_web/live/dashboard_live.ex

<section :if={@selected_month} class="mt-8">
  <.header>
    Sales in {month_name(@selected_month)}
    <:subtitle>{length(@month_sales)} sales - click a bar or point to pick a month</:subtitle>
  </.header>

  <div class="max-h-96 overflow-y-auto">
    <.table id="month-sales" rows={@month_sales}>
      <:col :let={sale} label="Date">{sale.sold_on}</:col>
      <:col :let={sale} label="Album">{sale.album.title}</:col>
      <:col :let={sale} label="Format">{format_label(sale.format)}</:col>
      <:col :let={sale} label="Buyer">{sale.buyer_name}</:col>
      <:col :let={sale} label="Price">
        <span class={sale.refunded_on && "line-through text-gray-400"}>
          {price(sale.price_cents)}
        </span>
      </:col>
    </.table>
  </div>
</section>

And let’s add a little bit of CSS so the bars feel more clickable.

File path: assets/css/app.css

.plotto-bar,
.plotto-point {
  cursor: pointer;
}

.plotto-bar:hover,
.plotto-point:hover {
  opacity: 0.75;
}

Now if we go back to our charts we can click on any point in any of our charts and the sales data for that month is loaded on the page. And it works for all our charts.

Now what’s really cool is that because this is set up to broadcast changes over PubSub, if we open another window and buy some albums from it, we can see our charts update in real time!

Ready to Learn More?

Subscribe to get access to all episodes and exclusive content.

Subscribe Now