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).
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