Powered by AppSignal & Oban Pro

Chart Events

notebooks/13_chart_events.livemd

Chart Events

Mix.install([
  {:plotly_ex, "~> 0.1"},
  {:kino, "~> 0.18"}
])

Chart Events > Click Event Data

PlotlyLive.subscribe/2 registers the current process (or a given pid) to receive {tag, event_map} messages whenever a Plotly event fires in the browser.

Click a data point in the chart below, then evaluate the receive cell to see the event payload.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: [1, 2, 3, 4, 5],
    y: [1, 4, 9, 16, 25],
    mode: "markers",
    marker: %{size: 15, color: "steelblue"},
    text: ["A", "B", "C", "D", "E"]
  ))
  |> Figure.update_layout(title: "Click any point — then run the next cell")
  |> PlotlyLive.new()
PlotlyLive.subscribe(chart, :click_data, :click)
chart
# Click a point above, then evaluate this cell
receive do
  {:click_data, event} ->
    IO.inspect(event, label: "click event", pretty: true)
after
  10_000 -> "No click within 10 s — click a point first, then re-run this cell"
end

Chart Events > Binding to Click Events

Spawn a dedicated listener process and subscribe it so click events stream into a Kino.Frame in real time — no need to re-evaluate cells.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

output = Kino.Frame.new()

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: [1, 2, 3, 4, 5],
    y: [2, 4, 1, 5, 3],
    mode: "markers+text",
    text: ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"],
    textposition: "top center",
    marker: %{size: 12}
  ))
  |> Figure.update_layout(title: "Click points — output appears below")
  |> PlotlyLive.new()

# Spawn a persistent listener process and subscribe IT to click events
listener =
  spawn(fn ->
    for _ <- 1..1000 do
      receive do
        {:click, event} ->
          [point | _] = event["data"]["points"] || []
          text = "**Clicked:** x=#{point["x"]}, y=#{point["y"]}, label=#{point["text"]}"
          Kino.Frame.render(output, Kino.Markdown.new(text))
      after
        60_000 -> :timeout
      end
    end
  end)

PlotlyLive.subscribe(chart, listener, :click, :click)

Kino.Layout.grid([chart, output])

Chart Events > Create Annotation on Click Event

On each click, add a text annotation at the clicked point by updating the figure with a new layout.annotations list via PlotlyLive.push/2.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

# Initial scatter trace
base_fig =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: Enum.to_list(1..10),
    y: [5, 2, 8, 1, 6, 3, 9, 4, 7, 2],
    mode: "markers",
    marker: %{size: 12, color: "coral"}
  ))
  |> Figure.update_layout(title: "Click to annotate points")

chart = PlotlyLive.new(base_fig)

# Listener: accumulates annotations and pushes updated figure
defmodule AnnotationLoop do
  def run(chart, base_fig, annotations) do
    receive do
      {:annotate, event} ->
        [point | _] = event["data"]["points"] || []
        new_ann = %{
          x: point["x"],
          y: point["y"],
          text: "#{point["x"]}, #{point["y"]}",
          showarrow: true,
          arrowhead: 4,
          ax: 30,
          ay: -30
        }
        updated_annotations = [new_ann | annotations]
        updated_fig = Figure.update_layout(base_fig, annotations: updated_annotations)
        PlotlyLive.push(chart, updated_fig)
        run(chart, base_fig, updated_annotations)
    after
      60_000 -> :ok
    end
  end
end

listener = spawn(fn -> AnnotationLoop.run(chart, base_fig, []) end)

PlotlyLive.subscribe(chart, listener, :annotate, :click)
chart

Chart Events > Hover Event Data

plotly_hover fires when the cursor enters a data point’s hover region. The event payload contains a "points" list — same structure as click events.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: [1, 2, 3, 4, 5],
    y: [10, 15, 13, 17, 12],
    mode: "lines+markers",
    marker: %{size: 10}
  ))
  |> Figure.update_layout(title: "Hover over points")
  |> PlotlyLive.new()

PlotlyLive.subscribe(chart, :hover_data, :hover)
chart
# Hover over the chart above, then run this cell
receive do
  {:hover_data, event} ->
    IO.inspect(event, label: "hover event", pretty: true)
after
  10_000 -> "No hover within 10 s"
end

Chart Events > Capturing Hover Events: Data

Display x/y values, trace name, and point index from hover events in a live table.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

output = Kino.Frame.new()

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: Enum.to_list(1..10),
    y: [5, 2, 8, 1, 6, 3, 9, 4, 7, 2],
    name: "Series A",
    mode: "lines+markers"
  ))
  |> Figure.add_trace(Scatter.new(
    x: Enum.to_list(1..10),
    y: [3, 6, 2, 7, 4, 8, 1, 5, 3, 9],
    name: "Series B",
    mode: "lines+markers"
  ))
  |> Figure.update_layout(title: "Hover to capture data", hovermode: "closest")
  |> PlotlyLive.new()

listener =
  spawn(fn ->
    for _ <- 1..1000 do
      receive do
        {:hover, event} ->
          [pt | _] = event["data"]["points"] || []
          md = """
          | Field | Value |
          |-------|-------|
          | x | #{pt["x"]} |
          | y | #{pt["y"]} |
          | trace | #{pt["name"]} |
          | curveNumber | #{pt["curveNumber"]} |
          | pointIndex | #{pt["pointIndex"]} |
          """
          Kino.Frame.render(output, Kino.Markdown.new(md))
      after 60_000 -> :timeout
      end
    end
  end)

PlotlyLive.subscribe(chart, listener, :hover, :hover)
Kino.Layout.grid([chart, output])

Chart Events > Capturing Hover Events: Pixels

The plotly_hover event data includes bbox with pixel bounding box of the hovered point — useful for positioning custom tooltips or UI elements.

Note: the bbox field ({x0, x1, y0, y1}) gives the bounding box of the hovered element in pixel coordinates relative to the plot div.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

output = Kino.Frame.new()

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: [1, 2, 3, 4, 5],
    y: [4, 2, 6, 1, 5],
    mode: "markers",
    marker: %{size: 20, color: "steelblue"}
  ))
  |> Figure.update_layout(title: "Hover — pixel coordinates shown below")
  |> PlotlyLive.new()

listener =
  spawn(fn ->
    for _ <- 1..1000 do
      receive do
        {:hover_px, event} ->
          [pt | _] = event["data"]["points"] || []
          bbox = pt["bbox"] || %{}
          md = "**Hover bbox (pixels):** x0=#{bbox["x0"]}, x1=#{bbox["x1"]}, y0=#{bbox["y0"]}, y1=#{bbox["y1"]}"
          Kino.Frame.render(output, Kino.Markdown.new(md))
      after 60_000 -> :timeout
      end
    end
  end)

PlotlyLive.subscribe(chart, listener, :hover_px, :hover)
Kino.Layout.grid([chart, output])

Chart Events > Triggering Hover Events

N/A — JavaScript-only API

The JS example calls Plotly.Fx.hover(graphDiv, [{curveNumber:0, pointNumber:0}]) to programmatically trigger a hover — a browser DOM API with no Elixir equivalent.

For Livebook, hover effects are handled automatically by the browser when the user moves the mouse over data points. No Elixir code required.

Chart Events > Coupled Hover Events

N/A — JavaScript-only linked subplot hover

The JS example synchronises hover across subplots using Plotly.Fx.hover called inside a plotly_hover callback — requires direct access to multiple graph divs.

In Livebook, layout.hovermode: "x unified" achieves a similar visual effect natively (all traces at the same x show in one tooltip) without custom JS.

Chart Events > Combined Click and Hover Events

A single subscriber can differentiate events by checking event["type"]. This example listens for both plotly_click and plotly_hover on the same chart.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

output = Kino.Frame.new()

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: [1, 2, 3, 4, 5],
    y: [2, 4, 1, 5, 3],
    mode: "markers",
    marker: %{size: 14, color: "purple"}
  ))
  |> Figure.update_layout(title: "Hover or click the points")
  |> PlotlyLive.new()

listener =
  spawn(fn ->
    for _ <- 1..1000 do
      receive do
        {:chart_event, %{"type" => type} = event} ->
          [pt | _] = event["data"]["points"] || []
          label = if type == "plotly_click", do: "click", else: "hover"
          Kino.Frame.render(
            output,
            Kino.Markdown.new("**#{label}** — x: #{pt["x"]}, y: #{pt["y"]}")
          )
      after 60_000 -> :timeout
      end
    end
  end)

PlotlyLive.subscribe(chart, listener, :chart_event)
Kino.Layout.grid([chart, output])

Chart Events > Binding to Zoom Events

plotly_relayout fires on zoom, pan, and axis range changes. The event data is a flat map of the changed layout keys, e.g. %{"xaxis.range[0]" => 1.5, "xaxis.range[1]" => 3.2}.

Use layout.yaxis.fixedrange: true to constrain zooming to one axis.

alias Plotly.{Figure, Scatter}
alias Plotly.Kino.PlotlyLive

output = Kino.Frame.new()

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: Enum.to_list(1..20),
    y: Enum.map(1..20, fn i -> :math.sin(i / 3.0) end),
    mode: "lines+markers"
  ))
  |> Figure.update_layout(
    title: "Zoom or pan — relayout events appear below",
    xaxis: %{title: "x"},
    yaxis: %{title: "y", fixedrange: true}
  )
  |> PlotlyLive.new()

listener =
  spawn(fn ->
    for _ <- 1..1000 do
      receive do
        {:relayout, %{"data" => data}} ->
          changes =
            data
            |> Map.drop(["_index"])
            |> Enum.map(fn {k, v} -> "#{k} = #{inspect(v)}" end)
            |> Enum.join(", ")
          Kino.Frame.render(output, Kino.Markdown.new("**relayout:** #{changes}"))
      after 60_000 -> :timeout
      end
    end
  end)

PlotlyLive.subscribe(chart, listener, :relayout, :relayout)
Kino.Layout.grid([chart, output])

Chart Events > Disabling Zoom Events for X Axis

xaxis.fixedrange: true prevents zooming/panning on the X axis. The Y axis remains zoomable. No event subscription needed — this is a layout config.

alias Plotly.{Figure, Scatter}

Figure.new()
|> Figure.add_trace(Scatter.new(
  x: Enum.to_list(1..20),
  y: Enum.map(1..20, fn i -> :math.sin(i / 3.0) end),
  mode: "lines+markers"
))
|> Figure.update_layout(
  title: "X zoom disabled — try to zoom on each axis",
  xaxis: %{fixedrange: true, title: "X — fixed range"},
  yaxis: %{title: "Y — zoomable"}
)
|> Plotly.show()

Chart Events > Disabling Zoom Events for X and Y Axis

Set fixedrange: true on both axes to disable all zooming and panning. The chart becomes a static display — useful for dashboards where chart proportions must stay fixed.

alias Plotly.{Figure, Scatter}

Figure.new()
|> Figure.add_trace(Scatter.new(
  x: Enum.to_list(1..20),
  y: Enum.map(1..20, fn i -> :math.cos(i / 3.0) end),
  mode: "lines+markers"
))
|> Figure.update_layout(
  title: "All zoom disabled — drag or scroll has no effect",
  xaxis: %{fixedrange: true},
  yaxis: %{fixedrange: true}
)
|> Plotly.show()