Phoenix LiveView Integration

GnuplotEx provides Phoenix LiveView support for real-time, interactive data visualization in web applications.

Installation

Add both dependencies to your mix.exs:

def deps do
  [
    {:gnuplot_ex, "~> 0.5"},
    {:phoenix_live_view, "~> 1.0"}
  ]
end

Then run:

mix deps.get

Quick Start

1. Basic Usage

The simplest way to render a plot in LiveView is using the live_gnuplot/1 component:

defmodule MyAppWeb.ChartLive do
  use Phoenix.LiveView
  import GnuplotEx.LiveView.Component

  def render(assigns) do
    ~H"""
    <div class="container">
      <h1>My Chart</h1>
      <.live_gnuplot plot={@plot} />
    </div>
    """
  end

  def mount(_params, _session, socket) do
    data = [[1, 2], [2, 4], [3, 6], [4, 8], [5, 10]]

    plot = GnuplotEx.new()
      |> GnuplotEx.scatter(data, label: "Points")
      |> GnuplotEx.title("My Plot")
      |> GnuplotEx.x_label("X Axis")
      |> GnuplotEx.y_label("Y Axis")

    {:ok, assign(socket, plot: plot)}
  end
end

2. Real-time Updates

Update the plot when data changes:

defmodule MyAppWeb.RealtimeChartLive do
  use Phoenix.LiveView
  import GnuplotEx.LiveView.Component

  def render(assigns) do
    ~H"""
    <.live_gnuplot plot={@plot} width={1200} height={400} />
    """
  end

  def mount(_params, _session, socket) do
    # Subscribe to data updates
    if connected?(socket) do
      :timer.send_interval(1000, self(), :tick)
    end

    socket = assign(socket,
      data: [],
      max_points: 50
    )

    {:ok, socket}
  end

  def handle_info(:tick, socket) do
    # Add new data point
    new_point = [
      System.system_time(:second),
      :rand.uniform() * 100
    ]

    # Keep only last N points (rolling window)
    data = ([new_point] ++ socket.assigns.data)
      |> Enum.take(socket.assigns.max_points)

    # Create updated plot
    plot = GnuplotEx.line(data,
      label: "Sensor Data",
      color: "#E95420"
    )
    |> GnuplotEx.x_label("Time")
    |> GnuplotEx.y_label("Value")
    |> GnuplotEx.theme(:dark)

    {:noreply, assign(socket, data: data, plot: plot)}
  end
end

3. Interactive 3D Plots

For 3D plots with mouse/touch controls, use the JavaScript hook:

Step 1: Add the hook to your app.js:

// assets/js/app.js
import { GnuplotInteractive } from "../../deps/gnuplot_ex/priv/static/gnuplot_ex_hooks"

let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: { GnuplotInteractive }
})

Step 2: Use the hook in your LiveView:

def render(assigns) do
  ~H"""
  <.live_gnuplot
    plot={@plot}
    id="interactive-3d"
    phx-hook="GnuplotInteractive"
    width={800}
    height={600}
  />
  """
end

def mount(_params, _session, socket) do
  plot = GnuplotEx.surface(
    fn x, y -> :math.sin(x) * :math.cos(y) end,
    x_range: -5..5,
    y_range: -5..5,
    title: "Interactive Surface"
  )
  |> GnuplotEx.palette(:viridis)

  {:ok, assign(socket, plot: plot)}
end

Controls:

  • Left-click drag: Rotate
  • Scroll wheel: Zoom
  • Right-click drag: Pan
  • Touch: Drag to rotate, pinch to zoom

Component Options

The live_gnuplot/1 component accepts the following attributes:

AttributeTypeDefaultDescription
plotGnuplotEx.PlotrequiredThe plot to render
format:svg | :png:svgOutput format
widthinteger800Plot width in pixels
heightinteger600Plot height in pixels
cachebooleantrueEnable plot caching
cache_ttlinteger60_000Cache TTL in milliseconds
classstring""CSS class for container
on_errorfunctionnilCustom error handler
restglobal-Additional HTML attributes

Examples

# Custom size
<.live_gnuplot plot={@plot} width={1200} height={600} />

# PNG format
<.live_gnuplot plot={@plot} format={:png} />

# Disable caching
<.live_gnuplot plot={@plot} cache={false} />

# Custom cache TTL (5 minutes)
<.live_gnuplot plot={@plot} cache_ttl={300_000} />

# Custom CSS class
<.live_gnuplot plot={@plot} class="my-chart shadow-lg" />

# With fallback content
<.live_gnuplot plot={@plot}>
  <:fallback>
    <div class="loading">Rendering plot...</div>
  </:fallback>
</.live_gnuplot>

# Custom error handler
<.live_gnuplot
  plot={@plot}
  on_error={fn reason ->
    Phoenix.HTML.Tag.content_tag(:div, "Error: #{reason}", class: "error")
  end}
/>

Advanced Patterns

Multiple Plots in a Dashboard

defmodule MyAppWeb.DashboardLive do
  use Phoenix.LiveView
  import GnuplotEx.LiveView.Component

  def render(assigns) do
    ~H"""
    <div class="grid grid-cols-2 gap-4">
      <.live_gnuplot plot={@plot1} />
      <.live_gnuplot plot={@plot2} />
      <.live_gnuplot plot={@plot3} />
      <.live_gnuplot plot={@plot4} />
    </div>
    """
  end

  def mount(_params, _session, socket) do
    socket = assign(socket,
      plot1: create_plot1(),
      plot2: create_plot2(),
      plot3: create_plot3(),
      plot4: create_plot4()
    )

    {:ok, socket}
  end
end

Debouncing Rapid Updates

For high-frequency data, debounce updates to avoid overwhelming the renderer:

def handle_info({:data_point, point}, socket) do
  # Cancel previous debounce timer if exists
  if socket.assigns[:debounce_timer] do
    Process.cancel_timer(socket.assigns.debounce_timer)
  end

  # Add point to buffer
  data = [point | socket.assigns.data] |> Enum.take(100)

  # Schedule update in 100ms
  timer = Process.send_after(self(), :update_plot, 100)

  {:noreply, assign(socket, data: data, debounce_timer: timer)}
end

def handle_info(:update_plot, socket) do
  plot = GnuplotEx.line(socket.assigns.data)

  {:noreply, assign(socket, plot: plot, debounce_timer: nil)}
end

User-Controlled Plot Options

Let users customize the plot:

def render(assigns) do
  ~H"""
  <div>
    <form phx-change="update_options">
      <select name="theme">
        <option value="default">Default</option>
        <option value="dark">Dark</option>
        <option value="publication">Publication</option>
      </select>

      <select name="palette">
        <option value="viridis">Viridis</option>
        <option value="plasma">Plasma</option>
        <option value="magma">Magma</option>
      </select>
    </form>

    <.live_gnuplot plot={@plot} />
  </div>
  """
end

def handle_event("update_options", %{"theme" => theme, "palette" => palette}, socket) do
  plot = socket.assigns.base_plot
    |> GnuplotEx.theme(String.to_atom(theme))
    |> GnuplotEx.palette(String.to_atom(palette))

  {:noreply, assign(socket, plot: plot)}
end

Streaming Data with Channels

Receive real-time data via Phoenix Channels:

def mount(_params, _session, socket) do
  if connected?(socket) do
    MyAppWeb.Endpoint.subscribe("sensor:#{socket.assigns.sensor_id}")
  end

  {:ok, assign(socket, data: [], plot: initial_plot())}
end

def handle_info(%{event: "measurement", payload: measurement}, socket) do
  data = [measurement | socket.assigns.data] |> Enum.take(100)
  plot = GnuplotEx.line(data, label: "Sensor #{socket.assigns.sensor_id}")

  {:noreply, assign(socket, data: data, plot: plot)}
end

Performance Tips

1. Enable Caching

The component caches rendered plots by default to avoid redundant gnuplot executions:

# Cache enabled (default) - renders once, reuses SVG
<.live_gnuplot plot={@plot} cache={true} cache_ttl={60_000} />

2. Limit Data Points

For real-time plots, use rolling windows to limit data size:

# Keep only last 100 points
data = ([new_point] ++ data) |> Enum.take(100)

3. Debounce High-Frequency Updates

Batch rapid updates to reduce render frequency:

# Update plot every 100ms instead of every data point
Process.send_after(self(), :update_plot, 100)

4. Use Appropriate Formats

  • SVG: Best for web display, scales well, larger file size
  • PNG: Smaller file size, fixed resolution, faster transfer
# Use PNG for large complex plots
<.live_gnuplot plot={@plot} format={:png} />

5. Monitor Cache Statistics

# In IEx
GnuplotEx.LiveView.Cache.stats()
# => %{entries: 42, memory_bytes: 1_048_576}

# Clear cache if needed
GnuplotEx.LiveView.Cache.clear()

Testing

Component Testing

defmodule MyAppWeb.ChartLiveTest do
  use MyAppWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  test "renders plot", %{conn: conn} do
    {:ok, view, html} = live(conn, "/charts")

    assert html =~ "gnuplot-container"
    assert html =~ "<svg"
  end

  test "updates plot on data change", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/charts")

    # Send update event
    send(view.pid, {:new_data, [[1, 2], [3, 4]]})

    html = render(view)
    assert html =~ "<svg"
  end
end

Unit Testing Plots

test "creates valid plot struct" do
  plot = GnuplotEx.new()
    |> GnuplotEx.scatter([[1, 2], [3, 4]])
    |> GnuplotEx.title("Test")

  assert %GnuplotEx.Plot{} = plot
  assert plot.title == "Test"
  assert length(plot.series) == 1
end

Troubleshooting

Plot Not Rendering

  1. Check gnuplot installation:

    gnuplot --version
    # Should be 6.0+
    
  2. Check logs for errors:

    # In LiveView
    require Logger
    Logger.debug("Rendering plot: #{inspect(@plot)}")
  3. Disable cache to debug:

    <.live_gnuplot plot={@plot} cache={false} />

Interactive Controls Not Working

  1. Verify hook is loaded:

    // app.js should have:
    import { GnuplotInteractive } from "..."
    hooks: { GnuplotInteractive }
  2. Check element has phx-hook attribute:

    <.live_gnuplot plot={@plot} id="my-plot" phx-hook="GnuplotInteractive" />
  3. Ensure plot has an id: The id attribute is required for hooks to attach properly.

High Memory Usage

  1. Clear cache periodically:

    # Schedule cache cleanup
    Process.send_after(self(), :clear_cache, 300_000)
    
    def handle_info(:clear_cache, socket) do
      GnuplotEx.LiveView.Cache.clear()
      {:noreply, socket}
    end
  2. Reduce cache TTL:

    <.live_gnuplot plot={@plot} cache_ttl={10_000} />  # 10 seconds
  3. Use PNG for large plots:

    <.live_gnuplot plot={@plot} format={:png} />

Examples

Real-time Sensor Dashboard

Complete example of a real-time monitoring dashboard:

defmodule MyAppWeb.SensorDashboardLive do
  use Phoenix.LiveView
  import GnuplotEx.LiveView.Component

  def mount(_params, _session, socket) do
    if connected?(socket) do
      :timer.send_interval(1000, self(), :tick)
    end

    socket = assign(socket,
      temperature: [],
      humidity: [],
      pressure: [],
      max_points: 100
    )

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <div class="sensor-dashboard">
      <h1>Sensor Dashboard</h1>

      <div class="grid grid-cols-3 gap-4">
        <div>
          <h2>Temperature</h2>
          <.live_gnuplot plot={temperature_plot(@temperature)} />
        </div>

        <div>
          <h2>Humidity</h2>
          <.live_gnuplot plot={humidity_plot(@humidity)} />
        </div>

        <div>
          <h2>Pressure</h2>
          <.live_gnuplot plot={pressure_plot(@pressure)} />
        </div>
      </div>
    </div>
    """
  end

  def handle_info(:tick, socket) do
    # Simulate sensor readings
    temp = [System.system_time(:second), 20 + :rand.uniform() * 10]
    humid = [System.system_time(:second), 40 + :rand.uniform() * 20]
    press = [System.system_time(:second), 1000 + :rand.uniform() * 50]

    socket = socket
      |> update(:temperature, &append_point(&1, temp, socket.assigns.max_points))
      |> update(:humidity, &append_point(&1, humid, socket.assigns.max_points))
      |> update(:pressure, &append_point(&1, press, socket.assigns.max_points))

    {:noreply, socket}
  end

  defp append_point(data, point, max) do
    ([point] ++ data) |> Enum.take(max)
  end

  defp temperature_plot(data) do
    GnuplotEx.line(data,
      label: "°C",
      color: "#E95420"
    )
    |> GnuplotEx.y_label("Temperature (°C)")
    |> GnuplotEx.y_range(0..40)
  end

  defp humidity_plot(data) do
    GnuplotEx.line(data,
      label: "%",
      color: "#0066CC"
    )
    |> GnuplotEx.y_label("Humidity (%)")
    |> GnuplotEx.y_range(0..100)
  end

  defp pressure_plot(data) do
    GnuplotEx.line(data,
      label: "hPa",
      color: "#00AA00"
    )
    |> GnuplotEx.y_label("Pressure (hPa)")
    |> GnuplotEx.y_range(950..1050)
  end
end

Further Reading