rbplotly

Interactive Plotly.js charts from Ruby. Every chart below is drawn by the Ruby code under it (plotly.js 4.1.2, rbplotly 1.0.1).

gem install rbplotly · GitHub · API docs

Animation

A moving point with named frames and a replay button. Open full size · Source

# A moving point with named frames and a replay button.
# frozen_string_literal: true

require "rbplotly"

fig = Plotly::Figure.new.add_scatter(x: [0], y: [0], mode: :markers, marker_size: 18)
fig.update_layout(title_text: "A moving point", xaxis_range: [-0.5, 4.5], yaxis_range: [-0.5, 4.5],
  updatemenus: [{type: :buttons, buttons: [{label: "Replay", method: :animate,
                                            args: [nil, {mode: "immediate", fromcurrent: false, frame: {duration: 600}, transition: {duration: 300}}]}]}])
[0, 1, 4, 2, 3].each_with_index do |y, x|
  fig.add_frame(name: "step-#{x}", data: [{x: [x], y: [y]}])
end

fig.write_html("animation.html", open: true) if __FILE__ == $PROGRAM_NAME

Bar

Revenue by region, stacked per quarter, with totals in the hover label. Open full size · Source

# Revenue by region, stacked per quarter, with totals in the hover label.
require "rbplotly"

quarters = %w[Q1 Q2 Q3 Q4]
revenue = {
  "Americas" => [42, 48, 51, 63],
  "Europe" => [31, 29, 35, 40],
  "Asia Pacific" => [18, 24, 30, 38]
}

fig = Plotly::Figure.new(layout: {barmode: :stack, title_text: "Revenue by region (USD million)"})
revenue.each do |region, values|
  fig.add_bar(x: quarters, y: values, name: region, hovertemplate: "%{x}: $%{y}M<extra>#{region}</extra>")
end
fig.update_layout(yaxis_tickprefix: "$", yaxis_ticksuffix: "M")

fig.write_html("bar.html", open: true) if __FILE__ == $PROGRAM_NAME

Distributions

Response times of three services: histograms on top, box plots below, sharing the x axis. Open full size · Source

# Response times of three services: histograms on top, box plots below, sharing the x axis.
require "rbplotly"

rng = Random.new(3)
lognormal = ->(mu, sigma) { Math.exp(mu + sigma * Math.sqrt(-2 * Math.log(1 - rng.rand)) * Math.cos(2 * Math::PI * rng.rand)) }
services = {"search" => [4.0, 0.35], "checkout" => [4.6, 0.25], "profile" => [3.6, 0.5]}
colors = {"search" => "#636efa", "checkout" => "#ef553b", "profile" => "#00cc96"}
samples = services.transform_values { |(mu, sigma)| Array.new(800) { lognormal.call(mu, sigma) } }

fig = Plotly.make_subplots(rows: 2, cols: 1, shared_xaxes: true, vertical_spacing: 0.05)
samples.each do |name, values|
  fig.add_histogram(x: values, name: name, legendgroup: name, marker_color: colors[name], opacity: 0.6, nbinsx: 60, row: 1, col: 1)
  fig.add_box(x: values, name: name, legendgroup: name, marker_color: colors[name], showlegend: false, boxpoints: false, row: 2, col: 1)
end
fig.update_layout(barmode: :overlay, title_text: "Response time (ms)")
fig.update_yaxes({domain: [0.3, 1.0]}, row: 1, col: 1)
fig.update_yaxes({domain: [0.0, 0.25]}, row: 2, col: 1)

fig.write_html("distributions.html", open: true) if __FILE__ == $PROGRAM_NAME

Heatmap

Commits by weekday and hour, as an annotated heatmap. Open full size · Source

# Commits by weekday and hour, as an annotated heatmap.
require "rbplotly"

rng = Random.new(11)
weekdays = %w[Mon Tue Wed Thu Fri Sat Sun]
hours = (0..23).map { |h| format("%02d:00", h) }
counts = weekdays.each_index.map do |d|
  hours.each_index.map do |h|
    base = (d < 5) ? 12 * Math.exp(-((h - 14)**2) / 18.0) : 3 * Math.exp(-((h - 16)**2) / 30.0)
    (base + rng.rand(0..2)).round
  end
end

fig = Plotly::Figure.new
  .add_heatmap(z: counts, x: hours, y: weekdays, colorscale: "Blues", reversescale: true, texttemplate: "%{z}", hoverongaps: false)
  .update_layout(title_text: "Commits by weekday and hour", yaxis_autorange: "reversed")

fig.write_html("heatmap.html", open: true) if __FILE__ == $PROGRAM_NAME

Line

Daily active users over two quarters, with a 7-day moving average. Open full size · Source

# Daily active users over two quarters, with a 7-day moving average.
require "date"
require "rbplotly"

rng = Random.new(1)
days = (Date.new(2026, 1, 1)..Date.new(2026, 6, 30)).to_a
users = days.each_with_index.map do |day, i|
  (1200 + 4 * i + 300 * Math.sin(i / 9.0) + ((day.saturday? || day.sunday?) ? -250 : 0) + rng.rand(-60..60)).round
end
average = users.each_index.map { |i| (users[[0, i - 6].max..i].sum.to_f / (i - [0, i - 6].max + 1)).round }

fig = Plotly::Figure.new
  .add_scatter(x: days, y: users, mode: :lines, name: "Daily", line_color: "rgba(99, 110, 250, 0.35)")
  .add_scatter(x: days, y: average, mode: :lines, name: "7-day average", line_width: 3)
  .update_layout(
    title_text: "Daily active users",
    hovermode: "x unified",
    yaxis_title_text: "Users",
    legend: {orientation: "h", y: -0.15}
  )

fig.write_html("line.html", open: true) if __FILE__ == $PROGRAM_NAME

Sankey

Where visitors go after landing: a Sankey diagram of page flows. Open full size · Source

# Where visitors go after landing: a Sankey diagram of page flows.
require "rbplotly"

pages = ["Landing", "Docs", "Pricing", "Sign up", "Left"]
colors = ["#636efa", "#ab63fa", "#ffa15a", "#00cc96", "#b0b7c3"]
translucent = ->(hex) { "rgba(#{hex[1..].scan(/../).map(&:hex).join(", ")}, 0.35)" }
flows = [
  ["Landing", "Docs", 420], ["Landing", "Pricing", 310], ["Landing", "Left", 270],
  ["Docs", "Sign up", 160], ["Docs", "Left", 260], ["Pricing", "Sign up", 190], ["Pricing", "Left", 120]
]

fig = Plotly::Figure.new
  .add_sankey(
    node: {label: pages, color: colors, pad: 20, thickness: 18},
    link: {
      color: flows.map { |from, _, _| translucent.call(colors[pages.index(from)]) },
      source: flows.map { |from, _, _| pages.index(from) },
      target: flows.map { |_, to, _| pages.index(to) },
      value: flows.map(&:last)
    }
  )
  .update_layout(title_text: "Visitor flow")

fig.write_html("sankey.html", open: true) if __FILE__ == $PROGRAM_NAME

Scatter

Two clusters of points, colored by a third value on a continuous scale. Open full size · Source

# Two clusters of points, colored by a third value on a continuous scale.
require "rbplotly"

rng = Random.new(7)
gaussian = -> { Math.sqrt(-2 * Math.log(1 - rng.rand)) * Math.cos(2 * Math::PI * rng.rand) }
points = 400.times.map do |i|
  cx, cy = i.even? ? [2.0, 3.0] : [5.0, 1.5]
  [cx + gaussian.call, cy + gaussian.call * 0.7]
end
x = points.map(&:first)
y = points.map(&:last)

fig = Plotly::Figure.new
  .add_scatter(
    x: x, y: y, mode: :markers,
    marker: {color: x.zip(y).map { |a, b| a * b }, colorscale: "Viridis", showscale: true, size: 8, opacity: 0.8},
    marker_colorbar_title_text: "x × y",
    hovertemplate: "x=%{x:.2f}<br>y=%{y:.2f}<extra></extra>"
  )
  .update_layout(title_text: "Clusters", xaxis_title_text: "x", yaxis_title_text: "y")

fig.write_html("scatter.html", open: true) if __FILE__ == $PROGRAM_NAME

Secondary axes

Orders and conversion on independent scales, beside a smaller product-mix panel. Open full size · Source

# Orders and conversion on independent scales, beside a smaller product-mix panel.
# frozen_string_literal: true

require "rbplotly"

months = %w[Jan Feb Mar Apr May Jun]
fig = Plotly.make_subplots(cols: 2, column_widths: [2, 1], horizontal_spacing: 0.18,
  specs: [[{secondary_y: true}, {}]], subplot_titles: ["Orders and conversion", "Product mix"])
fig.add_bar(x: months, y: [100, 130, 170, 160, 210, 240], name: "Orders", row: 1, col: 1)
fig.add_scatter(x: months, y: [2.1, 2.4, 2.2, 2.9, 3.1, 3.4], name: "Conversion",
  mode: :"lines+markers", row: 1, col: 1, secondary_y: true)
fig.add_pie(labels: %w[Basic Pro Team], values: [50, 35, 15], hole: 0.5, row: 1, col: 2)
fig.update_yaxes(title_text: "Orders", secondary_y: false, row: 1, col: 1)
fig.update_yaxes(title_text: "Conversion", ticksuffix: "%", showgrid: false, secondary_y: true)
fig.update_layout(title_text: "Monthly performance", height: 480, legend_orientation: "h")

fig.write_html("secondary_axes.html", open: true) if __FILE__ == $PROGRAM_NAME

Subplots

A small dashboard: four kinds of chart in one figure. Open full size · Source

# A small dashboard: four kinds of chart in one figure.
require "rbplotly"

months = %w[Jan Feb Mar Apr May Jun]
fig = Plotly.make_subplots(
  rows: 2, cols: 2,
  subplot_titles: ["Signups", "Conversion", "Plan mix", "Churn"],
  horizontal_spacing: 0.12, vertical_spacing: 0.2
)
fig.add_bar(x: months, y: [120, 150, 170, 160, 210, 240], name: "Signups", row: 1, col: 1)
fig.add_scatter(x: months, y: [2.1, 2.4, 2.2, 2.9, 3.1, 3.4], mode: :"lines+markers", name: "Conversion %", row: 1, col: 2)
fig.add_pie(labels: %w[Free Pro Team], values: [62, 28, 10], hole: 0.5, name: "Plans", row: 2, col: 1)
fig.add_scatter(x: months, y: [5.2, 4.8, 4.9, 4.1, 3.8, 3.5], fill: :tozeroy, name: "Churn %", row: 2, col: 2)
fig.update_layout(title_text: "Product metrics", showlegend: false, height: 620)
fig.update_yaxes({ticksuffix: "%"}, row: 1, col: 2)
fig.update_yaxes({ticksuffix: "%", rangemode: :tozero}, row: 2, col: 2)

fig.write_html("subplots.html", open: true) if __FILE__ == $PROGRAM_NAME

Sunburst

Where a (made-up) engineering budget goes, as a sunburst you can click to zoom into. Open full size · Source

# Where a (made-up) engineering budget goes, as a sunburst you can click to zoom into.
require "rbplotly"

budget = {
  "Product" => {"Web" => 420, "Mobile" => 310, "Design" => 140},
  "Platform" => {"Infrastructure" => 380, "Data" => 260, "Security" => 120},
  "Operations" => {"Support" => 180, "Tooling" => 90}
}

ids = []
labels = []
parents = []
values = []
budget.each do |group, teams|
  ids << group
  labels << group
  parents << ""
  values << teams.values.sum
  teams.each do |team, amount|
    ids << "#{group}/#{team}"
    labels << team
    parents << group
    values << amount
  end
end

fig = Plotly::Figure.new
  .add_sunburst(ids: ids, labels: labels, parents: parents, values: values, branchvalues: :total,
    hovertemplate: "%{label}: $%{value}k<extra></extra>")
  .update_layout(title_text: "Engineering budget (USD thousand)", margin: {t: 60, l: 10, r: 10, b: 10})

fig.write_html("sunburst.html", open: true) if __FILE__ == $PROGRAM_NAME

Surface

A 3D surface you can rotate, with contours projected onto the floor. Open full size · Source

# A 3D surface you can rotate, with contours projected onto the floor.
require "rbplotly"

xs = (-30..30).map { |i| i / 10.0 }
ys = xs
z = ys.map { |y| xs.map { |x| 4 * Math.sin(x**2 + y**2) / (1 + x**2 + y**2) } }

fig = Plotly::Figure.new
  .add_surface(x: xs, y: ys, z: z, colorscale: "Viridis", contours_z: {show: true, usecolormap: true, project_z: true})
  .update_layout(title_text: "z = 4 sin(x² + y²) / (1 + x² + y²)", scene_camera_eye: {x: 1.6, y: 1.4, z: 0.9}, height: 600)

fig.write_html("surface.html", open: true) if __FILE__ == $PROGRAM_NAME