Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-06-16

### Added
- **Custom series labels**: `name:` option on `line`, `bar`, `area`, `scatter`, `radar`, and `horizontal_bar` series to set the label shown in the legend, tooltips, and ARIA text. Defaults to the data key, so existing charts render unchanged.

## [0.2.0] - 2026-02-09

### Added
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,18 @@ Your data can use symbol or string keys — Trackplot normalizes both:
### Line

```erb
<% c.line :revenue, color: "#6366f1", curve: true, dashed: true %>
<% c.line :revenue, name: "Total Revenue", color: "#6366f1", curve: true, dashed: true %>
```

Options: `color`, `curve` (smooth), `dashed`, `stroke_width`, `dot` (true/false), `dot_size`, `y_axis`.
Options: `color`, `curve` (smooth), `dashed`, `stroke_width`, `dot` (true/false), `dot_size`, `y_axis`, `name` (legend/tooltip label — defaults to the data key).

### Bar

```erb
<% 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`.
Multiple bar series render as grouped bars automatically. Options: `color`, `opacity`, `radius` (corner rounding), `stack` (group name for stacking), `y_axis`, `name`.

Stack bars by giving them the same `stack` name:

Expand All @@ -129,7 +129,7 @@ Stack bars by giving them the same `stack` name:
<% 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:
Renders a gradient fill with a stroke line. Options: `color`, `curve`, `opacity`, `stroke_width`, `stack`, `y_axis`, `name`. Stack multiple areas by giving them the same `stack` name:

```erb
<% c.area :revenue, stack: "main", color: "#10b981", curve: true %>
Expand All @@ -142,7 +142,7 @@ Renders a gradient fill with a stroke line. Stack multiple areas by giving them
<% c.scatter :weight, color: "#ec4899", dot_size: 6 %>
```

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

### Pie / Donut

Expand All @@ -160,7 +160,7 @@ Options: `label_key`, `donut`, `pad_angle`.
<% c.radar :player_b, color: "#ef4444" %>
```

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

### Horizontal Bar

Expand Down
6 changes: 6 additions & 0 deletions app/assets/javascripts/trackplot/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ThemeConfig {
export interface LineConfig {
type: "line";
data_key: string;
name?: string;
color?: string;
stroke_width?: number;
curve?: boolean;
Expand All @@ -29,6 +30,7 @@ export interface LineConfig {
export interface BarConfig {
type: "bar";
data_key: string;
name?: string;
color?: string;
opacity?: number;
radius?: number;
Expand All @@ -39,6 +41,7 @@ export interface BarConfig {
export interface AreaConfig {
type: "area";
data_key: string;
name?: string;
color?: string;
opacity?: number;
stroke_width?: number;
Expand All @@ -50,6 +53,7 @@ export interface AreaConfig {
export interface ScatterConfig {
type: "scatter";
data_key: string;
name?: string;
x_key?: string;
color?: string;
opacity?: number;
Expand All @@ -68,6 +72,7 @@ export interface PieConfig {
export interface RadarConfig {
type: "radar";
data_key: string;
name?: string;
color?: string;
opacity?: number;
stroke_width?: number;
Expand All @@ -78,6 +83,7 @@ export interface RadarConfig {
export interface HorizontalBarConfig {
type: "horizontal_bar";
data_key: string;
name?: string;
color?: string;
opacity?: number;
radius?: number;
Expand Down
22 changes: 13 additions & 9 deletions app/assets/javascripts/trackplot/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ function sanitizeClass(str) {
return String(str).replace(/[^a-zA-Z0-9_-]/g, "_")
}

function seriesLabel(s) {
return s.name || s.data_key
}

function getXKey(components) {
const xAxis = components.find(c => c.type === "axis" && c.direction === "x")
return xAxis ? xAxis.data_key : null
Expand Down Expand Up @@ -510,7 +514,7 @@ function renderLine(g, data, xScale, yScale, xKey, series, animate, chartElement
.attr("stroke", series.color)
.attr("stroke-width", 2)
.style("cursor", "pointer")
.attr("aria-label", d => `${series.data_key}: ${d[series.data_key]}`)
.attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)

if (animate) {
dots.attr("r", 0).transition().delay(DURATION).duration(300).attr("r", dotR)
Expand Down Expand Up @@ -582,7 +586,7 @@ function renderBars(g, data, xScale, yScale, xKey, barSeries, animate, chartElem
.attr("opacity", series.opacity ?? 1)
.attr("y", animate ? yScale(0) : d => yScale(+d[series.data_key] || 0))
.attr("height", animate ? 0 : d => Math.max(0, yScale(0) - yScale(+d[series.data_key] || 0)))
.attr("aria-label", d => `${series.data_key}: ${d[series.data_key]}`)
.attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)
.style("cursor", "pointer")
.transition().duration(animate ? DURATION : 0).ease(EASE)
.delay((_, i) => animate ? i * 40 : 0)
Expand Down Expand Up @@ -637,7 +641,7 @@ function renderStackedBars(g, data, xScale, yScale, xKey, stackedSeries, animate
.attr("opacity", series.opacity ?? 1)
.attr("y", animate ? yScale(0) : d => yScale(d[1]))
.attr("height", animate ? 0 : d => Math.max(0, yScale(d[0]) - yScale(d[1])))
.attr("aria-label", (d, i) => `${series.data_key}: ${data[i][series.data_key]}`)
.attr("aria-label", (d, i) => `${seriesLabel(series)}: ${data[i][series.data_key]}`)
.style("cursor", "pointer")
.transition().duration(animate ? DURATION : 0).ease(EASE)
.delay((_, i) => animate ? i * 40 : 0)
Expand Down Expand Up @@ -771,7 +775,7 @@ function renderScatter(g, data, xScale, yScale, xKey, series, animate, chartElem
.attr("fill-opacity", series.opacity || 0.7)
.attr("stroke", "white")
.attr("stroke-width", 1.5)
.attr("aria-label", d => `${series.data_key}: ${d[series.data_key]}`)
.attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)
.style("cursor", "pointer")

if (animate) {
Expand Down Expand Up @@ -829,7 +833,7 @@ function renderHorizontalBars(g, data, xScale, yScale, yKey, barSeries, animate,
.attr("ry", radius)
.attr("fill", series.color)
.attr("opacity", series.opacity ?? 1)
.attr("aria-label", d => `${series.data_key}: ${d[series.data_key]}`)
.attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)
.style("cursor", "pointer")
.attr("width", animate ? 0 : d => Math.max(0, xScale(+d[series.data_key] || 0)))
.transition().duration(animate ? DURATION : 0).ease(EASE)
Expand Down Expand Up @@ -1468,7 +1472,7 @@ function setupCartesianTooltip(element, g, data, xScale, yScale, xKey, series, c
const formatted = isNaN(+val) ? val : fmtValue(+val)
html += `<div style="display:flex;align-items:center;gap:8px">`
html += `<span style="width:8px;height:8px;border-radius:50%;background:${s.color};flex-shrink:0"></span>`
html += `<span style="color:${t.text_color}">${s.data_key}</span>`
html += `<span style="color:${t.text_color}">${seriesLabel(s)}</span>`
html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${formatted}</span>`
html += `</div>`
}
Expand Down Expand Up @@ -1554,7 +1558,7 @@ function setupRadarTooltip(element, g, data, radarSeries, labelKey, config, them
let html = `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:2px">${cat}</div>`
html += `<div style="display:flex;align-items:center;gap:8px">`
html += `<span style="width:8px;height:8px;border-radius:50%;background:${series.color};flex-shrink:0"></span>`
html += `<span style="color:${t.text_color}">${series.data_key}</span>`
html += `<span style="color:${t.text_color}">${seriesLabel(series)}</span>`
html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${d[series.data_key]}</span>`
html += `</div>`
tooltip.innerHTML = html
Expand Down Expand Up @@ -1685,7 +1689,7 @@ function renderLegend(element, items, config, theme) {
background: item.color, flexShrink: "0"
})
const label = document.createElement("span")
label.textContent = item.data_key
label.textContent = seriesLabel(item)
label.style.color = t.text_color

el.appendChild(dot)
Expand Down Expand Up @@ -2000,7 +2004,7 @@ class Chart {
const formatted = isNaN(+val) ? val : fmtValue(+val)
html += `<div style="display:flex;align-items:center;gap:8px">`
html += `<span style="width:8px;height:8px;border-radius:50%;background:${s.color};flex-shrink:0"></span>`
html += `<span style="color:${t.text_color}">${s.data_key}</span>`
html += `<span style="color:${t.text_color}">${seriesLabel(s)}</span>`
html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${formatted}</span>`
html += `</div>`
}
Expand Down
3 changes: 2 additions & 1 deletion lib/trackplot/components/area.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def to_config
opacity: options[:opacity] || 0.3,
stroke_width: options[:stroke_width] || 2,
stack: options[:stack],
y_axis: options[:y_axis]&.to_s
y_axis: options[:y_axis]&.to_s,
name: options[:name] || data_key
}.compact
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/trackplot/components/bar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ def to_config
opacity: options[:opacity],
radius: options[:radius] || 4,
stack: options[:stack],
y_axis: options[:y_axis]&.to_s
y_axis: options[:y_axis]&.to_s,
name: options[:name] || data_key
}.compact
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/trackplot/components/horizontal_bar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ def to_config
data_key: data_key,
color: options[:color],
opacity: options[:opacity],
radius: options[:radius] || 4
radius: options[:radius] || 4,
name: options[:name] || data_key
}.compact
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/trackplot/components/line.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def to_config
stroke_width: options[:stroke_width] || 2,
dot: options.fetch(:dot, true),
dot_size: options[:dot_size] || 4,
y_axis: options[:y_axis]&.to_s
y_axis: options[:y_axis]&.to_s,
name: options[:name] || data_key
}.compact
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/trackplot/components/radar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ def to_config
opacity: options[:opacity] || 0.15,
stroke_width: options[:stroke_width] || 2,
dot: options.fetch(:dot, true),
dot_size: options[:dot_size] || 4
dot_size: options[:dot_size] || 4,
name: options[:name] || data_key
}.compact
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/trackplot/components/scatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def to_config
dot_size: options[:dot_size] || 5,
opacity: options[:opacity] || 0.7,
shape: options[:shape] || "circle",
y_axis: options[:y_axis]&.to_s
y_axis: options[:y_axis]&.to_s,
name: options[:name] || data_key
}.compact
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/trackplot/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Trackplot
VERSION = "0.2.0"
VERSION = "0.3.0"
end
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "trackplot",
"version": "0.2.0",
"version": "0.3.0",
"description": "Beautiful D3.js charts for Rails with a Recharts-like DSL",
"module": "app/assets/javascripts/trackplot/index.js",
"main": "app/assets/javascripts/trackplot/index.js",
Expand Down
21 changes: 21 additions & 0 deletions test/javascript/chart_rendering.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,27 @@ describe("Chart Rendering", () => {
expect(legendItems.length).toBe(4)
})

it("uses series name for legend label when provided, else data_key", async () => {
const el = await createChart({
data: SAMPLE_DATA,
animate: false,
components: [
{ type: "bar", data_key: "revenue", name: "Total Revenue" },
{ type: "bar", data_key: "cost" },
{ type: "axis", direction: "x", data_key: "month" },
{ type: "axis", direction: "y" },
{ type: "legend" }
]
})
const labelTexts = Array.from(el.querySelectorAll("div > div > span"))
.map(s => s.textContent)
.filter(t => t.length > 0)
// Custom name overrides data_key; series without a name falls back to data_key
expect(labelTexts).toContain("Total Revenue")
expect(labelTexts).toContain("cost")
expect(labelTexts).not.toContain("revenue")
})

it("renders empty state message when data is empty", async () => {
const el = await createChart({
data: [],
Expand Down
39 changes: 39 additions & 0 deletions test/trackplot/chart_builder_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,45 @@ def test_line_without_y_axis_excludes_it
refute config.key?(:y_axis)
end

# ─── Series Name (custom label) Tests ──────────────────────

def test_line_accepts_custom_name
builder = Trackplot::ChartBuilder.new(sample_data)
builder.line(:revenue, name: "Total Revenue")

assert_equal "Total Revenue", builder.components[0].to_config[:name]
end

def test_series_name_defaults_to_data_key
builder = Trackplot::ChartBuilder.new(sample_data)
builder.line(:revenue)

assert_equal :revenue, builder.components[0].to_config[:name]
end

def test_custom_name_supported_on_all_series_types
builder = Trackplot::ChartBuilder.new(sample_data)
builder.line(:revenue, name: "Rev")
builder.bar(:revenue, name: "Rev")
builder.area(:revenue, name: "Rev")
builder.scatter(:revenue, name: "Rev")
builder.radar(:revenue, name: "Rev")
builder.horizontal_bar(:revenue, name: "Rev")

builder.components.each do |component|
assert_equal "Rev", component.to_config[:name], "#{component.to_config[:type]} should forward :name"
end
end

def test_series_name_serializes_as_string
builder = Trackplot::ChartBuilder.new(sample_data)
builder.line(:revenue, name: "Total Revenue")

parsed = JSON.parse(builder.send(:build_config).to_json)
line = parsed["components"].find { |c| c["type"] == "line" }
assert_equal "Total Revenue", line["name"]
end

# ─── Brush Tests ────────────────────────────────────────────

def test_collects_brush_component
Expand Down
Loading