Skip to content

eagerworks/trackplot

Repository files navigation

Trackplot

Drop-in D3.js charts for Rails. Write Ruby, get beautiful interactive visualizations. No JavaScript required.

Trackplot gives you a Recharts-like DSL that feels right at home in your .erb templates. Under the hood it renders a <trackplot-chart> custom element powered by D3.js — with animations, tooltips, theming, and Turbo support out of the box.

<%= trackplot_chart @monthly_sales do |c| %>
  <% c.line :revenue, color: "#6366f1", curve: true %>
  <% c.bar  :orders,  color: "#06b6d4", opacity: 0.6 %>
  <% c.axis :x, data_key: :month %>
  <% c.axis :y, format: :currency %>
  <% c.tooltip format: :currency %>
  <% c.reference_line y: 10_000, label: "Goal" %>
  <% c.legend %>
  <% c.grid %>
<% end %>

That's it. No JavaScript files to write, no chart config objects to manage, no build step drama.


Chart Types

Cartesian Radial Hierarchical Other
Line Pie / Donut Treemap Horizontal Bar
Bar (grouped + stacked) Radar Funnel
Area (+ stacked) Candlestick (OHLC)
Scatter Heatmap

Plus sparklines for inline mini-charts in tables and dashboards.

Mix and match freely — bars + lines on the same chart just work.

Installation

Add to your Gemfile:

gem "trackplot"

Then run the install generator:

bin/rails generate trackplot:install

The generator auto-detects your JS setup (importmap vs jsbundling), installs the right packages, and adds import "trackplot" to application.js.

Pass --stimulus to also get a Stimulus controller with auto-refresh polling and export actions:

bin/rails generate trackplot:install --stimulus

With importmap (default Rails 7+)

You're done. The engine auto-registers D3 from CDN and pins the trackplot module.

With esbuild / jsbundling-rails

Install D3 and trackplot from npm:

yarn add d3 trackplot

Then import it in your app/javascript/application.js:

import "trackplot"

Quick Start

Pass any array of hashes (or ActiveRecord collection) and describe what you want:

<%= trackplot_chart @data, height: "300px" do |c| %>
  <% c.line :temperature, color: "#ef4444", curve: true %>
  <% c.axis :x, data_key: :date %>
  <% c.axis :y, label: "Temp (F)" %>
  <% c.tooltip %>
  <% c.grid %>
<% end %>

Your data can use symbol or string keys — Trackplot normalizes both:

@data = [
  { date: "Mon", temperature: 72 },
  { date: "Tue", temperature: 68 },
  { date: "Wed", temperature: 75 }
]

Chart Types

Line

<% c.line :revenue, color: "#6366f1", curve: true, dashed: true %>

Options: color, curve (smooth), dashed, stroke_width, dot (true/false), dot_size, y_axis.

Bar

<% c.bar :sales, color: "#06b6d4", opacity: 0.8, radius: 6 %>

Multiple bar series render as grouped bars automatically. Options: color, opacity, radius (corner rounding), stack (group name for stacking), y_axis.

Stack bars by giving them the same stack name:

<% c.bar :revenue, stack: "main", color: "#6366f1" %>
<% c.bar :costs,   stack: "main", color: "#f59e0b" %>

Area

<% c.area :revenue, color: "#8b5cf6", curve: true %>

Renders a gradient fill with a stroke line. Stack multiple areas by giving them the same stack name:

<% c.area :revenue, stack: "main", color: "#10b981", curve: true %>
<% c.area :costs,   stack: "main", color: "#f59e0b", curve: true %>

Scatter

<% c.scatter :weight, color: "#ec4899", dot_size: 6 %>

Options: color, dot_size, opacity, x_key (override x-axis key), y_axis.

Pie / Donut

<% c.pie :value, label_key: :segment %>
<% c.pie :value, label_key: :segment, donut: true %>

Options: label_key, donut, pad_angle.

Radar

<% c.radar :player_a, color: "#6366f1" %>
<% c.radar :player_b, color: "#ef4444" %>

Options: color, opacity, stroke_width, dot, dot_size.

Horizontal Bar

<% c.horizontal_bar :popularity, color: "#14b8a6" %>

Same options as regular bar. The x-axis data_key becomes the category axis.

Candlestick

<% c.candlestick open: :open, high: :high, low: :low, close: :close %>

Options: up_color, down_color.

Funnel

<% c.funnel :count, label_key: :stage %>

Options: label_key.

Heatmap

Visualize density or intensity across two dimensions:

<%= trackplot_chart @activity_data, height: "300px" do |c| %>
  <% c.heatmap x_key: :day, y_key: :hour, value_key: :count,
               color_range: ["#f0f9ff", "#1e40af"] %>
  <% c.tooltip %>
<% end %>

Options: x_key, y_key, value_key, color_range (two-color array), radius.

Treemap

Show hierarchical data as nested rectangles:

<%= trackplot_chart @budget_data, height: "300px" do |c| %>
  <% c.treemap value_key: :amount, label_key: :name, parent_key: :department %>
  <% c.tooltip %>
<% end %>

Options: value_key, label_key, parent_key (optional — groups data into a hierarchy). Without parent_key, data is treated as flat.

Combined Charts

Layer different series types on the same chart:

<%= trackplot_chart @data do |c| %>
  <% c.bar  :revenue, color: "#06b6d4", opacity: 0.6 %>
  <% c.line :profit,  color: "#6366f1", curve: true %>
  <% c.axis :x, data_key: :month %>
  <% c.axis :y %>
  <% c.tooltip %>
  <% c.legend %>
  <% c.grid %>
<% end %>

Sparklines

Inline mini-charts for tables and dashboards — no axes, no labels, just the shape:

<%= trackplot_sparkline @trend_data, key: :revenue, type: :line, color: "#6366f1" %>

Types: :line (default, with last-dot indicator), :bar, :area.

Options: key: (required), type:, color:, width: (default "120px"), height: (default "32px"), stroke_width:, dot:.

Use them in tables:

<table>
  <% @metrics.each do |metric| %>
    <tr>
      <td><%= metric.name %></td>
      <td><%= trackplot_sparkline metric.history, key: :value, color: "#10b981" %></td>
      <td><%= metric.current_value %></td>
    </tr>
  <% end %>
</table>

Components

Axis

<% c.axis :x, data_key: :month %>
<% c.axis :y, label: "Revenue ($)", format: :currency, tick_count: 5 %>

Options: data_key, label, format, tick_count, tick_rotation, axis_id.

Dual Y-Axis

Compare series with different scales on the same chart:

<%= trackplot_chart @data do |c| %>
  <% c.bar  :revenue, color: "#6366f1" %>
  <% c.line :conversion_rate, color: "#ef4444", y_axis: :right %>
  <% c.axis :x, data_key: :month %>
  <% c.axis :y, format: :currency %>
  <% c.axis :y, axis_id: :right, format: :percent %>
<% end %>

Add axis_id: :right to a y-axis, then bind series to it with y_axis: :right. Works on line, bar, area, and scatter.

Tooltip

<% c.tooltip format: :currency %>

Options: format, label_format.

Legend

<% c.legend position: :top %>

Options: position (:top or :bottom), clickable.

Grid

<% c.grid horizontal: true, vertical: true %>

Options: horizontal (default true), vertical.

Reference Line

Draw horizontal or vertical lines for targets, thresholds, or annotations:

<% c.reference_line y: 5000, label: "Target", color: "#ef4444" %>
<% c.reference_line x: "Mar", label: "Launch", color: "#6366f1", dashed: false %>

Options: y or x (value), label, color, dashed (default true), stroke_width.

Data Labels

Show formatted values directly on bars, dots, and pie slices:

<% c.data_label format: :currency, position: :top %>

Options: format (any format preset or D3 format string), position (:top, :center, :outside), font_size (default 11).

Brush

Interactive range selection for exploring large datasets:

<% c.brush axis: :x %>

Renders a mini preview below the chart. Drag to select a range — the chart zooms in. Double-click to reset.

Options: axis (default :x), height (default 40).

Number Formatting

Both axes and tooltips accept format presets or raw D3 format strings:

Preset Output Example
:currency $1,234 format: :currency
:percent 42% format: :percent
:compact 1.2k format: :compact
:decimal 1,234.56 format: :decimal
:integer 1,234 format: :integer

Or pass a raw D3 format string: format: "$,.2f".

Theming

Four built-in themes, plus fully custom themes via Hash:

<%= trackplot_chart @data, theme: :dark do |c| %>
  ...
<% end %>

Available presets: :default, :dark, :vibrant, :minimal.

Custom theme (merges with defaults):

<%= trackplot_chart @data, theme: { colors: ["#ff0000", "#00ff00"], background: "#111" } do |c| %>
  ...
<% end %>

Theme properties: colors, background, text_color, axis_color, grid_color, tooltip_bg, tooltip_text, tooltip_border, font.

Color Scales

Generate color palettes programmatically instead of hand-picking every color:

# Light-to-dark ramp from a single color
Trackplot::ColorScale.sequential("#6366f1", count: 6)
# => ["#d8d9fb", "#b1b2f7", "#8a8cf3", "#6366f1", "#3c3de0", "#2627a0"]

# Two-color diverging gradient (light midpoint)
Trackplot::ColorScale.diverging("#ef4444", "#3b82f6", count: 7)

# Evenly-spaced hues preserving saturation and lightness
Trackplot::ColorScale.categorical("#6366f1", count: 8)

Use them as theme colors:

<%= trackplot_chart @data, theme: { colors: Trackplot::ColorScale.sequential("#6366f1") } do |c| %>
  ...
<% end %>
Method Description
.sequential(hex, count: 8) Light-to-dark palette varying lightness from 0.90 to 0.25
.diverging(hex1, hex2, count: 8) Two-color gradient with light midpoint
.categorical(hex, count: 8) Evenly-spaced hues (360°/count steps)

All methods accept standard #RGB or #RRGGBB hex strings. count: 0 returns [], count: 1 returns a single color, and invalid hex raises ArgumentError.

Accessibility

Charts support ARIA attributes for screen readers:

<%= trackplot_chart @data, title: "Monthly Revenue", description: "Revenue trend from Jan to Jul" do |c| %>
  <% c.line :revenue %>
  <% c.axis :x, data_key: :month %>
  <% c.axis :y %>
<% end %>

When title: is set:

  • The <trackplot-chart> element gets role="img" and aria-label
  • The SVG includes <title> and <desc> elements
  • Decorative elements (grid lines, crosshair) are marked aria-hidden="true"
  • Data points get aria-label attributes with their values

Empty State

Charts gracefully handle empty data with a centered message:

<%= trackplot_chart [], empty_message: "No sales data for this period" do |c| %>
  <% c.line :revenue %>
  <% c.axis :x, data_key: :month %>
  <% c.axis :y %>
<% end %>

The default message is "No data available". Customize it with empty_message:.

Drill-Down

Click a bar, pie slice, heatmap cell, or treemap rectangle to zoom into nested sub-data. All drill levels are pre-loaded in the JSON config — no server round-trips required. A breadcrumb appears for navigation back.

<%= trackplot_chart @data do |c| %>
  <% c.bar :revenue %>
  <% c.axis :x, data_key: :region %>
  <% c.axis :y, format: :currency %>
  <% c.drilldown :breakdown %>
<% end %>

Nest sub-data under the drill key. Multi-level drilling is supported:

@data = [
  { region: "North", revenue: 500, breakdown: [
    { region: "NYC", revenue: 200, breakdown: [
      { region: "Manhattan", revenue: 120 },
      { region: "Brooklyn", revenue: 80 }
    ]},
    { region: "Boston", revenue: 300 }
  ]},
  { region: "South", revenue: 300, breakdown: [
    { region: "Atlanta", revenue: 300 }
  ]}
]

Works with pie/donut charts too:

<% c.pie :value, label_key: :name %>
<% c.drilldown :breakdown %>

Items without the drill key (leaf nodes) fire a normal trackplot:click event instead.

Drill-Down JavaScript API

Navigate drill levels programmatically:

const chart = document.querySelector("trackplot-chart")

chart.drillUp()    // Go back one level (returns false if at root)
chart.drillReset() // Return to root from any depth (returns false if at root)

Drill-Down Events

// Fires after drilling into sub-data
chart.addEventListener("trackplot:drilldown", (e) => {
  e.detail.level  // current drill depth (1, 2, ...)
  e.detail.datum  // the clicked datum
  e.detail.label  // label of the drilled item ("North", "NYC", ...)
})

// Fires after drilling back up
chart.addEventListener("trackplot:drillup", (e) => {
  e.detail.level  // new drill depth (0 = root)
})

Click Events

Every interactive element (bars, dots, pie slices, funnel stages...) dispatches a trackplot:click CustomEvent that bubbles up the DOM:

document.addEventListener("trackplot:click", function(e) {
  console.log(e.detail)
  // => { chartType: "bar", dataKey: "revenue", datum: {...}, index: 2, value: 4200 }
})

Works great with Stimulus:

<div data-controller="chart" data-action="trackplot:click->chart#onClick">
  <%= trackplot_chart @data do |c| %>
    ...
  <% end %>
</div>

Export to PNG / SVG

Download charts as images from JavaScript:

const chart = document.querySelector("trackplot-chart")

// PNG (default 2x resolution)
chart.exportPNG()
chart.exportPNG(3, "revenue.png")  // custom scale and filename

// SVG
chart.exportSVG()
chart.exportSVG("revenue.svg")

Both methods return a Promise that resolves with the Blob.

Turbo Support

Trackplot is built for Turbo. Charts clean up before Turbo caches pages, survive morphing, and re-render cleanly on Turbo Stream updates.

Stable IDs for Turbo Streams

Pass id: so turbo_stream.replace can target your chart:

<%= trackplot_chart @data, id: "revenue-chart" do |c| %>
  <% c.line :revenue, curve: true %>
  <% c.axis :x, data_key: :month %>
  <% c.axis :y %>
<% end %>

Then push updates from your server:

<%= turbo_stream.replace "revenue-chart" do %>
  <%= trackplot_chart @fresh_data, id: "revenue-chart" do |c| %>
    <% c.line :revenue, curve: true %>
    <% c.axis :x, data_key: :month %>
    <% c.axis :y %>
  <% end %>
<% end %>

Programmatic Data Updates

Update chart data from JavaScript (e.g., from a Stimulus controller receiving ActionCable broadcasts):

const chart = document.getElementById("revenue-chart")
chart.updateData(newDataArray)

Real-time Data Append

Push new data points without a full re-render — great for live dashboards:

const chart = document.getElementById("live-chart")
chart.appendData(
  [{ time: "12:05", value: 42 }],
  { maxPoints: 50 }  // sliding window
)

Dispatches a trackplot:data-update event after each append.

The trackplot:render event fires after every render:

document.addEventListener("trackplot:render", function(e) {
  console.log("Chart ready:", e.target.id)
})

Stimulus Controller

Run bin/rails generate trackplot:install --stimulus to get a controller with auto-refresh polling and export actions:

<div data-controller="trackplot"
     data-trackplot-url-value="/api/chart_data.json"
     data-trackplot-interval-value="5000">
  <%= trackplot_chart @data do |c| %>
    <% c.line :revenue, curve: true %>
    <% c.axis :x, data_key: :month %>
    <% c.axis :y %>
  <% end %>

  <button data-action="trackplot#exportPng">Download PNG</button>
  <button data-action="trackplot#exportSvg">Download SVG</button>
</div>
Value Type Description
url String JSON endpoint to poll for fresh data
interval Number Polling interval in ms (0 = disabled)

Actions: exportPng, exportSvg, refresh (manual trigger).

ViewComponent / Phlex Support

If you use ViewComponent or Phlex, Trackplot provides optional integrations.

ViewComponent

# In your view
render Trackplot::Component.new(data: @data, height: "300px") { |c|
  c.line :revenue, color: "#6366f1"
  c.axis :x, data_key: :month
  c.axis :y
}

Requires view_component in your Gemfile. Trackplot loads the component class only when ViewComponent is available.

Phlex

render Trackplot::PhlexComponent.new(data: @data, height: "300px") { |c|
  c.line :revenue, color: "#6366f1"
  c.axis :x, data_key: :month
  c.axis :y
}

Requires phlex in your Gemfile.

Chart Options

Pass options directly to trackplot_chart:

Option Default Description
id: auto-generated Stable DOM id for Turbo targeting
width: "100%" CSS width
height: "400px" CSS height
animate: true Entry animations
theme: :default Theme preset or custom Hash
class: nil Additional CSS classes
title: nil Accessibility label (adds ARIA attributes)
description: nil Accessibility description (requires title:)
empty_message: "No data available" Message shown when data is empty

TypeScript

Trackplot ships TypeScript declarations for the npm package. Type-checked querySelector narrows to the correct element:

const chart = document.querySelector("trackplot-chart")
// => TrackplotElement | null

chart?.addEventListener("trackplot:click", (e) => {
  e.detail.chartType  // string
  e.detail.dataKey    // string
  e.detail.value      // unknown
})

chart?.exportPNG(2, "report.png")  // Promise<Blob | null>

All config interfaces are exported: ChartConfig, LineConfig, BarConfig, ThemeConfig, SparklineConfig, etc.

Development

Run the Ruby test suite:

ruby -Ilib -Itest -e "Dir['test/**/*_test.rb'].each { |f| require File.expand_path(f) }"

Run the JavaScript test suite (Vitest + jsdom):

npm test

Boot the demo app:

cd test/dummy && bin/rails server

See CONTRIBUTING.md for development setup and contribution guidelines.

License

MIT License. See LICENSE.txt.


Made️ by eagerworks

About

No description, website, or topics provided.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published