Powered by AppSignal & Oban Pro

Custom Controls

notebooks/11_custom_controls.livemd

Custom Controls

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

Custom Controls > Add Two Dropdown Menus to a Chart

layout.updatemenus accepts a list — each entry becomes a separate control. Use type: "dropdown" for a select menu. method: "restyle" updates trace properties client-side; method: "relayout" updates layout properties.

Position with x, y, xanchor, yanchor (0–1 fractions of plot area).

alias Plotly.{Figure, Scatter}

xs = Enum.to_list(1..20)
ys_sin = Enum.map(xs, fn x -> :math.sin(x / 3.0) end)
ys_cos = Enum.map(xs, fn x -> :math.cos(x / 3.0) end)

Figure.new()
|> Figure.add_trace(Scatter.new(x: xs, y: ys_sin, name: "sin", mode: "lines"))
|> Figure.add_trace(Scatter.new(x: xs, y: ys_cos, name: "cos", mode: "lines", visible: false))
|> Figure.update_layout(
  title: "Two Dropdown Menus",
  updatemenus: [
    # Dropdown 1: select trace (left side)
    %{
      type: "dropdown",
      x: 0.1,
      y: 1.15,
      xanchor: "left",
      showactive: true,
      buttons: [
        %{label: "sin",      method: "update",
          args: [%{visible: [true, false]}, %{title: "sin(x/3)"}]},
        %{label: "cos",      method: "update",
          args: [%{visible: [false, true]}, %{title: "cos(x/3)"}]},
        %{label: "sin + cos", method: "update",
          args: [%{visible: [true, true]}, %{title: "sin and cos"}]}
      ]
    },
    # Dropdown 2: select line colour (right side)
    %{
      type: "dropdown",
      x: 0.55,
      y: 1.15,
      xanchor: "left",
      showactive: true,
      buttons: [
        %{label: "Blue",   method: "restyle", args: [%{line: %{color: "steelblue"}}]},
        %{label: "Red",    method: "restyle", args: [%{line: %{color: "crimson"}}]},
        %{label: "Green",  method: "restyle", args: [%{line: %{color: "seagreen"}}]}
      ]
    }
  ]
)
|> Plotly.show()

Custom Controls > Bind Dropdown Events to Charts

The idiomatic Elixir approach: use Kino.Control.form/2 with report_changes: true to drive chart updates from Elixir. Every dropdown change fires the listener with all current field values, so there’s no need to call Kino.Input.read/1 from the callback process (which Kino forbids).

This gives full Elixir logic in the callback — filter data, fetch from DB, transform — unlike the native JS approach which can only restyle/relayout the existing data.

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

# Dataset
categories = ~w[Mon Tue Wed Thu Fri Sat Sun]
data = %{
  "Sales"    => [120, 145, 110, 160, 200, 230, 180],
  "Traffic"  => [340, 390, 300, 420, 510, 600, 480],
  "Returns"  => [12, 8, 15, 10, 18, 22, 16]
}

make_fig = fn chart_type, metric ->
  key = metric |> to_string() |> String.capitalize()
  y = data[key]
  trace = case chart_type do
    :bar     -> Bar.new(x: categories, y: y, name: key)
    :line    -> Scatter.new(x: categories, y: y, mode: "lines+markers", name: key)
    :scatter -> Scatter.new(x: categories, y: y, mode: "markers", name: key,
                  marker: %{size: 12})
  end
  Figure.new()
  |> Figure.add_trace(trace)
  |> Figure.update_layout(title: "#{key}#{chart_type}")
end

form = Kino.Control.form(
  [
    chart_type: Kino.Input.select("Chart type", [bar: "Bar", line: "Line", scatter: "Scatter"]),
    metric: Kino.Input.select("Metric", [sales: "Sales", traffic: "Traffic", returns: "Returns"])
  ],
  report_changes: true
)

chart = make_fig.(:bar, :sales) |> PlotlyLive.new()

Kino.listen(form, fn %{data: %{chart_type: ct, metric: m}} ->
  PlotlyLive.push(chart, make_fig.(ct, m))
end)

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

Custom Controls > Restyle Button — Single Attribute

method: "restyle" updates one or more trace properties without replotting. The args list is [update_map] or [update_map, trace_indices].

Changing a single attribute — e.g. mode, marker.color, line.dash — is the simplest case.

alias Plotly.{Figure, Scatter}

xs = Enum.to_list(1..15)
ys = Enum.map(xs, fn x -> :math.sin(x / 2.5) end)

Figure.new()
|> Figure.add_trace(Scatter.new(x: xs, y: ys, mode: "lines+markers", name: "data",
    line: %{color: "steelblue", width: 2},
    marker: %{size: 8}))
|> Figure.update_layout(
  title: "Restyle — Single Attribute",
  updatemenus: [%{
    type: "buttons",
    direction: "right",
    x: 0.5,
    xanchor: "center",
    y: 1.1,
    showactive: true,
    buttons: [
      %{label: "Lines",    method: "restyle", args: [%{mode: "lines"}]},
      %{label: "Markers",  method: "restyle", args: [%{mode: "markers"}]},
      %{label: "Both",     method: "restyle", args: [%{mode: "lines+markers"}]},
      %{label: "Text",     method: "restyle",
        args: [%{mode: "text", text: Enum.map(xs, &to_string/1)}]}
    ]
  }]
)
|> Plotly.show()

Custom Controls > Restyle Button — Multiple Attributes

A single restyle call can update multiple trace properties at once by including all keys in the update map. This is more efficient than chaining multiple calls.

With multiple traces, pass a list of values — one per trace — or a scalar to apply to all.

alias Plotly.{Figure, Scatter}

xs = Enum.to_list(1..20)
ys_a = Enum.map(xs, fn x -> :math.sin(x / 3.0) end)
ys_b = Enum.map(xs, fn x -> :math.cos(x / 3.0) end)

Figure.new()
|> Figure.add_trace(Scatter.new(x: xs, y: ys_a, name: "Trace A", mode: "lines"))
|> Figure.add_trace(Scatter.new(x: xs, y: ys_b, name: "Trace B", mode: "lines"))
|> Figure.update_layout(
  title: "Restyle — Multiple Attributes",
  updatemenus: [%{
    type: "buttons",
    direction: "down",
    x: 1.1,
    y: 1.0,
    showactive: true,
    buttons: [
      %{
        label: "Default",
        method: "restyle",
        args: [%{
          mode: "lines",
          line: [%{color: "steelblue", width: 2}, %{color: "coral", width: 2}],
          opacity: 1.0
        }]
      },
      %{
        label: "Thick Dashed",
        method: "restyle",
        args: [%{
          mode: "lines",
          line: [%{color: "purple", width: 4, dash: "dash"}, %{color: "green", width: 4, dash: "dash"}],
          opacity: 0.9
        }]
      },
      %{
        label: "Markers Only",
        method: "restyle",
        args: [%{
          mode: "markers",
          marker: [%{size: 10, color: "steelblue", symbol: "circle"},
                   %{size: 10, color: "coral",     symbol: "diamond"}],
          opacity: 0.8
        }]
      }
    ]
  }]
)
|> Plotly.show()

Custom Controls > Relayout Button

method: "relayout" updates layout properties (title, axis ranges, background, annotations, etc.) without touching trace data.

alias Plotly.{Figure, Scatter}

xs = Enum.to_list(1..20)
ys = Enum.map(xs, fn x -> :math.sin(x / 3.0) * x / 10 end)

Figure.new()
|> Figure.add_trace(Scatter.new(x: xs, y: ys, mode: "lines+markers"))
|> Figure.update_layout(
  title: "Relayout Button Examples",
  xaxis: %{title: "x"},
  yaxis: %{title: "y"},
  updatemenus: [%{
    type: "buttons",
    direction: "right",
    x: 0.5,
    xanchor: "center",
    y: 1.12,
    buttons: [
      %{label: "Default",
        method: "relayout",
        args: [%{plot_bgcolor: "white", paper_bgcolor: "white",
                 font: %{color: "#444"},
                 "xaxis.title.text": "x", "yaxis.title.text": "y"}]},
      %{label: "Dark",
        method: "relayout",
        args: [%{plot_bgcolor: "#1a1a2e", paper_bgcolor: "#16213e",
                 font: %{color: "white"}}]},
      %{label: "Zoom In",
        method: "relayout",
        args: [%{"xaxis.range": [5, 15], "yaxis.range": [-1, 1]}]},
      %{label: "Reset",
        method: "relayout",
        args: [%{"xaxis.autorange": true, "yaxis.autorange": true}]}
    ]
  }]
)
|> Plotly.show()

Custom Controls > Update Button

method: "update" is a combined restyle + relayout in one call. args is [trace_update_map, layout_update_map]. Use it when a single button should change both trace properties and layout simultaneously.

alias Plotly.{Figure, Scatter}

xs = Enum.to_list(1..30)
ys_sin  = Enum.map(xs, fn x -> :math.sin(x / 5.0) end)
ys_cos  = Enum.map(xs, fn x -> :math.cos(x / 5.0) end)

Figure.new()
|> Figure.add_trace(Scatter.new(x: xs, y: ys_sin, name: "sin", mode: "lines"))
|> Figure.add_trace(Scatter.new(x: xs, y: ys_cos, name: "cos", mode: "lines", visible: false))
|> Figure.update_layout(
  title: "Update Button — Sin View",
  xaxis: %{title: "x"},
  yaxis: %{range: [-1.2, 1.2], title: "sin(x/5)"},
  updatemenus: [%{
    type: "buttons",
    direction: "right",
    x: 0.5,
    xanchor: "center",
    y: 1.12,
    showactive: true,
    buttons: [
      %{
        label: "Sin",
        method: "update",
        args: [
          %{visible: [true, false]},
          %{title: "Update Button — Sin View", "yaxis.title.text": "sin(x/5)"}
        ]
      },
      %{
        label: "Cos",
        method: "update",
        args: [
          %{visible: [false, true]},
          %{title: "Update Button — Cos View", "yaxis.title.text": "cos(x/5)"}
        ]
      },
      %{
        label: "Both",
        method: "update",
        args: [
          %{visible: [true, true]},
          %{title: "Update Button — Both Traces", "yaxis.title.text": "value"}
        ]
      }
    ]
  }]
)
|> Plotly.show()

Custom Controls > Animate Button

method: "animate" triggers Plotly.animate when clicked. This is the same mechanism used in the notebooks/animations/ series. Here we show how to place Play/Pause buttons inside layout.updatemenus as a custom control panel.

Multiple buttons can target different frame subsets or animation speeds.

alias Plotly.{Figure, Scatter}

n = 40
frames =
  for i <- 1..n do
    xs = Enum.to_list(0..i)
    ys = Enum.map(xs, fn x -> :math.sin(x * 0.5) * :math.exp(-x * 0.05) end)
    %{name: "f#{i}", data: [%{x: xs, y: ys}]}
  end

Figure.new(
  data: [Scatter.new(x: [0], y: [0.0], mode: "lines+markers",
           line: %{color: "steelblue"}, marker: %{size: 6})],
  layout: %{
    title: "Animate Button — Damped Wave",
    xaxis: %{range: [0, n], autorange: false},
    yaxis: %{range: [-1.1, 1.1], autorange: false},
    updatemenus: [%{
      type: "buttons",
      direction: "right",
      x: 0.5,
      xanchor: "center",
      y: 0,
      yanchor: "top",
      showactive: false,
      buttons: [
        %{label: "▶ Play (normal)",
          method: "animate",
          args: [nil, %{frame: %{duration: 80, redraw: false}, transition: %{duration: 40}, fromcurrent: false}]},
        %{label: "▶ Play (fast)",
          method: "animate",
          args: [nil, %{frame: %{duration: 20, redraw: false}, transition: %{duration: 0}}]},
        %{label: "⏸ Pause",
          method: "animate",
          args: [[nil], %{mode: "immediate", frame: %{duration: 0}}]}
      ]
    }]
  },
  frames: frames
)
|> Plotly.show()

Custom Controls > Style the Buttons

N/A — HTML/CSS styling only

The JS example adds CSS classes and inline styles to the layout.updatemenus button container (position, font, background colour, border).

In Livebook, button appearance is controlled by Plotly.js’s own rendering — there is no HTML DOM to style. The visual look of native buttons can be adjusted via layout.updatemenus properties: bgcolor, font, bordercolor, borderwidth.

For Elixir-driven controls using Kino.Input, styling is determined by Livebook’s own UI framework.

alias Plotly.{Figure, Scatter}

# Example: style native buttons via updatemenus layout properties
Figure.new()
|> Figure.add_trace(Scatter.new(
  x: [1, 2, 3, 4, 5],
  y: [1, 4, 9, 16, 25],
  mode: "lines+markers"
))
|> Figure.update_layout(
  title: "Styled Buttons (via layout properties)",
  updatemenus: [%{
    type: "buttons",
    bgcolor: "#2c7bb6",
    font: %{color: "white", size: 13},
    bordercolor: "#1a5276",
    borderwidth: 2,
    buttons: [
      %{label: "Lines",    method: "restyle", args: [%{mode: "lines"}]},
      %{label: "Markers",  method: "restyle", args: [%{mode: "markers"}]},
      %{label: "Both",     method: "restyle", args: [%{mode: "lines+markers"}]}
    ]
  }]
)
|> Plotly.show()

Custom Controls > Basic Slider

layout.sliders adds a scrub bar to the chart. Each step can call method: "restyle", "relayout", or "animate". The currentvalue section shows the active label.

This example uses method: "restyle" to show one trace at a time — no frames needed since we pre-build all traces and toggle visible.

alias Plotly.{Figure, Scatter}

freqs = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
xs = Enum.map(0..100, fn i -> i / 10.0 end)

traces =
  for {freq, i} <- Enum.with_index(freqs) do
    ys = Enum.map(xs, fn x -> :math.sin(freq * x) end)
    Scatter.new(x: xs, y: ys, mode: "lines", name: "f=#{freq}", visible: i == 0)
  end

steps =
  for {freq, i} <- Enum.with_index(freqs) do
    visibility = Enum.map(0..(length(freqs) - 1), fn j -> j == i end)
    %{
      method: "restyle",
      label: "#{freq}",
      args: [%{visible: visibility}]
    }
  end

Enum.reduce(traces, Figure.new(), &Figure.add_trace(&2, &1))
|> Figure.update_layout(
  title: "Basic Slider — Sin Frequency",
  yaxis: %{range: [-1.2, 1.2], autorange: false},
  sliders: [%{
    steps: steps,
    active: 0,
    currentvalue: %{prefix: "Frequency: ", visible: true, xanchor: "center"},
    pad: %{t: 50, b: 10}
  }]
)
|> Plotly.show()

Custom Controls > Bind Components to the Appearance of a Plot

The idiomatic Livebook approach: bind multiple Kino.Input controls to chart appearance. Each input change triggers an Elixir callback that rebuilds and pushes a new figure. This allows full Elixir logic — no limit to what can be changed.

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

xs = Enum.map(0..100, fn i -> i / 10.0 end)

make_fig = fn freq, amp, color, mode ->
  color_map = %{blue: "steelblue", red: "crimson", green: "seagreen"}
  mode_map  = %{lines: "lines", markers: "markers", both: "lines+markers"}
  ys = Enum.map(xs, fn x -> amp * :math.sin(freq * x / 5.0) end)
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: xs, y: ys,
    mode: mode_map[mode],
    line: %{color: color_map[color], width: 2},
    marker: %{color: color_map[color], size: 4}
  ))
  |> Figure.update_layout(
    title: "f=#{freq}, A=#{amp}",
    yaxis: %{range: [-6, 6], autorange: false}
  )
end

form = Kino.Control.form(
  [
    freq:  Kino.Input.range("Frequency", min: 1, max: 10, default: 3, step: 1),
    amp:   Kino.Input.range("Amplitude", min: 1, max: 5,  default: 2, step: 1),
    color: Kino.Input.select("Color", [blue: "Steelblue", red: "Crimson", green: "Seagreen"]),
    mode:  Kino.Input.select("Mode",  [lines: "Lines", markers: "Markers", both: "Lines+Markers"])
  ],
  report_changes: true
)

chart = make_fig.(3, 2, :blue, :lines) |> PlotlyLive.new()

Kino.listen(form, fn %{data: %{freq: freq, amp: amp, color: color, mode: mode}} ->
  PlotlyLive.push(chart, make_fig.(freq, amp, color, mode))
end)

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

Custom Controls > Add a Play Button to Control a Slider

A Play button (method: "animate") combined with layout.sliders gives users both manual scrubbing (drag the slider) and automatic playback (click Play). This is the same pattern as notebooks/animations/08_animating_with_slider.livemd — see that notebook for the full slider animation reference.

Here we show the minimal pattern: Play/Pause buttons + a year slider.

alias Plotly.{Figure, Scatter}

years = 2010..2023
n = Enum.count(years)

:rand.seed(:exsplus, {7, 8, 9})
# Simulated annual temperature anomaly
base_anomaly = 0.5
temps = Enum.map(years, fn y -> base_anomaly + (y - 2010) * 0.025 + (:rand.normal() * 0.15) end)

frames =
  Enum.with_index(years, fn year, i ->
    %{
      name: "#{year}",
      data: [%{
        x: Enum.slice(Enum.to_list(years), 0, i + 1),
        y: Enum.slice(temps, 0, i + 1)
      }],
      layout: %{title: %{text: "Temperature Anomaly through #{year}"}}
    }
  end)

slider_steps =
  Enum.map(years, fn year ->
    %{
      label: "#{year}",
      method: "animate",
      args: [["#{year}"], %{mode: "immediate", frame: %{duration: 400}, transition: %{duration: 200}}]
    }
  end)

Figure.new(
  data: [Scatter.new(
    x: [Enum.at(Enum.to_list(years), 0)],
    y: [Enum.at(temps, 0)],
    mode: "lines+markers",
    line: %{color: "tomato", width: 2},
    marker: %{size: 8}
  )],
  layout: %{
    title: "Temperature Anomaly through 2010",
    xaxis: %{range: [2009, 2024], autorange: false, title: "Year"},
    yaxis: %{range: [0.2, 1.2], autorange: false, title: "°C anomaly"},
    updatemenus: [%{
      type: "buttons",
      showactive: false,
      y: 0,
      x: 0.5,
      xanchor: "center",
      yanchor: "top",
      pad: %{t: 60},
      buttons: [
        %{label: "▶ Play", method: "animate",
          args: [nil, %{frame: %{duration: 500}, transition: %{duration: 200}, fromcurrent: true}]},
        %{label: "⏸ Pause", method: "animate",
          args: [[nil], %{mode: "immediate", frame: %{duration: 0}}]}
      ]
    }],
    sliders: [%{
      steps: slider_steps,
      active: 0,
      currentvalue: %{prefix: "Year: ", visible: true, xanchor: "center"},
      pad: %{t: 50},
      x: 0.05,
      len: 0.9
    }]
  },
  frames: frames
)
|> Plotly.show()

Custom Controls > Lasso Selection

Lasso and box select are built into the Plotly.js toolbar. In Livebook, selected points can be captured via the plotly_selected event using PlotlyLive.subscribe/2 from Batch 1.

Click the lasso or box-select toolbar icon, draw a selection on the chart, then evaluate the receive cell to see the selected points.

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

:rand.seed(:exsplus, {42, 0, 0})
n = 80
x = Enum.map(1..n, fn _ -> :rand.normal() * 2 end)
y = Enum.map(1..n, fn _ -> :rand.normal() * 2 end)

chart =
  Figure.new()
  |> Figure.add_trace(Scatter.new(
    x: x,
    y: y,
    mode: "markers",
    marker: %{size: 10, color: "steelblue", opacity: 0.7}
  ))
  |> Figure.update_layout(
    title: "Use lasso/box select tool, then run next cell",
    dragmode: "lasso"
  )
  |> PlotlyLive.new()

PlotlyLive.subscribe(chart, :selection, :selected)
chart
# Draw a selection above, then evaluate this cell
receive do
  {:selection, %{"data" => %{"points" => pts}}} ->
    IO.puts("Selected #{length(pts)} points")
    Enum.each(pts, fn p -> IO.puts("  x=#{p["x"]}, y=#{p["y"]}") end)
after
  15_000 -> "No selection within 15 s — use the lasso tool first"
end

Custom Controls > Basic Range Slider on Time Series

xaxis.rangeslider: %{visible: true} adds a minimap below the chart for panning and zooming time series data. xaxis.rangeselector adds preset range buttons.

Combined, these give a full time-series navigation experience.

alias Plotly.{Figure, Scatter}

# 3 years of daily close prices (random walk)
:rand.seed(:exsplus, {1, 2, 3})
dates = for i <- 0..1094, do: Date.add(~D[2022-01-01], i) |> Date.to_string()
prices =
  Enum.scan(1..1095, 150.0, fn _, acc ->
    change = :rand.normal() * 2.5
    max(10.0, acc + change)
  end)

Figure.new()
|> Figure.add_trace(Scatter.new(
  x: dates,
  y: prices,
  mode: "lines",
  name: "Price",
  line: %{color: "steelblue", width: 1.5}
))
|> Figure.update_layout(
  title: "Stock Price with Range Slider",
  xaxis: %{
    title: "Date",
    rangeslider: %{visible: true},
    rangeselector: %{
      buttons: [
        %{count: 1,  label: "1M", step: "month", stepmode: "backward"},
        %{count: 3,  label: "3M", step: "month", stepmode: "backward"},
        %{count: 6,  label: "6M", step: "month", stepmode: "backward"},
        %{count: 1,  label: "YTD", step: "year", stepmode: "todate"},
        %{count: 1,  label: "1Y",  step: "year", stepmode: "backward"},
        %{step: "all", label: "All"}
      ]
    },
    type: "date"
  },
  yaxis: %{title: "Price ($)", fixedrange: false},
  height: 500
)
|> Plotly.show()

Kino.Input Approach

For Elixir-driven range selection, use PlotlyLive.subscribe/2 to capture plotly_relayout zoom events and respond with new data:

alias Plotly.Kino.PlotlyLive

# (Reuse `chart` and `dates`/`prices` from the cell above if running in sequence)
output = Kino.Frame.new()

chart2 =
  Figure.new()
  |> Figure.add_trace(Scatter.new(x: dates, y: prices, mode: "lines", name: "Price"))
  |> Figure.update_layout(
    title: "Range Slider + Zoom Event Capture",
    xaxis: %{rangeslider: %{visible: true}, type: "date"},
    yaxis: %{title: "Price ($)"}
  )
  |> PlotlyLive.new()

listener =
  spawn(fn ->
    for _ <- 1..1000 do
      receive do
        {:zoom, %{"data" => data}} ->
          x0 = data["xaxis.range[0]"] || data["xaxis.range[0] "] || "—"
          x1 = data["xaxis.range[1]"] || "—"
          Kino.Frame.render(output, Kino.Markdown.new("**Zoomed to:** #{x0}#{x1}"))
      after 60_000 -> :ok
      end
    end
  end)

PlotlyLive.subscribe(chart2, listener, :zoom)
Kino.Layout.grid([chart2, output])