diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f33a05..592d955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 61afb50..ef31d75 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,10 @@ 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 @@ -114,7 +114,7 @@ Options: `color`, `curve` (smooth), `dashed`, `stroke_width`, `dot` (true/false) <% 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: @@ -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 %> @@ -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 @@ -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 diff --git a/app/assets/javascripts/trackplot/index.d.ts b/app/assets/javascripts/trackplot/index.d.ts index 195cd69..fc85f1f 100644 --- a/app/assets/javascripts/trackplot/index.d.ts +++ b/app/assets/javascripts/trackplot/index.d.ts @@ -17,6 +17,7 @@ export interface ThemeConfig { export interface LineConfig { type: "line"; data_key: string; + name?: string; color?: string; stroke_width?: number; curve?: boolean; @@ -29,6 +30,7 @@ export interface LineConfig { export interface BarConfig { type: "bar"; data_key: string; + name?: string; color?: string; opacity?: number; radius?: number; @@ -39,6 +41,7 @@ export interface BarConfig { export interface AreaConfig { type: "area"; data_key: string; + name?: string; color?: string; opacity?: number; stroke_width?: number; @@ -50,6 +53,7 @@ export interface AreaConfig { export interface ScatterConfig { type: "scatter"; data_key: string; + name?: string; x_key?: string; color?: string; opacity?: number; @@ -68,6 +72,7 @@ export interface PieConfig { export interface RadarConfig { type: "radar"; data_key: string; + name?: string; color?: string; opacity?: number; stroke_width?: number; @@ -78,6 +83,7 @@ export interface RadarConfig { export interface HorizontalBarConfig { type: "horizontal_bar"; data_key: string; + name?: string; color?: string; opacity?: number; radius?: number; diff --git a/app/assets/javascripts/trackplot/index.js b/app/assets/javascripts/trackplot/index.js index 894031f..01ba05e 100644 --- a/app/assets/javascripts/trackplot/index.js +++ b/app/assets/javascripts/trackplot/index.js @@ -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 @@ -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) @@ -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) @@ -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) @@ -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) { @@ -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) @@ -1468,7 +1472,7 @@ function setupCartesianTooltip(element, g, data, xScale, yScale, xKey, series, c const formatted = isNaN(+val) ? val : fmtValue(+val) html += `
` html += `` - html += `${s.data_key}` + html += `${seriesLabel(s)}` html += `${formatted}` html += `
` } @@ -1554,7 +1558,7 @@ function setupRadarTooltip(element, g, data, radarSeries, labelKey, config, them let html = `
${cat}
` html += `
` html += `` - html += `${series.data_key}` + html += `${seriesLabel(series)}` html += `${d[series.data_key]}` html += `
` tooltip.innerHTML = html @@ -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) @@ -2000,7 +2004,7 @@ class Chart { const formatted = isNaN(+val) ? val : fmtValue(+val) html += `
` html += `` - html += `${s.data_key}` + html += `${seriesLabel(s)}` html += `${formatted}` html += `
` } diff --git a/lib/trackplot/components/area.rb b/lib/trackplot/components/area.rb index 53e63bd..3d5b0e4 100644 --- a/lib/trackplot/components/area.rb +++ b/lib/trackplot/components/area.rb @@ -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 diff --git a/lib/trackplot/components/bar.rb b/lib/trackplot/components/bar.rb index 47bd44c..d4b4a03 100644 --- a/lib/trackplot/components/bar.rb +++ b/lib/trackplot/components/bar.rb @@ -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 diff --git a/lib/trackplot/components/horizontal_bar.rb b/lib/trackplot/components/horizontal_bar.rb index f4e5fe5..f101f7b 100644 --- a/lib/trackplot/components/horizontal_bar.rb +++ b/lib/trackplot/components/horizontal_bar.rb @@ -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 diff --git a/lib/trackplot/components/line.rb b/lib/trackplot/components/line.rb index fa8811d..282ca89 100644 --- a/lib/trackplot/components/line.rb +++ b/lib/trackplot/components/line.rb @@ -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 diff --git a/lib/trackplot/components/radar.rb b/lib/trackplot/components/radar.rb index 2a67038..1627ce9 100644 --- a/lib/trackplot/components/radar.rb +++ b/lib/trackplot/components/radar.rb @@ -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 diff --git a/lib/trackplot/components/scatter.rb b/lib/trackplot/components/scatter.rb index a0235ff..d599e77 100644 --- a/lib/trackplot/components/scatter.rb +++ b/lib/trackplot/components/scatter.rb @@ -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 diff --git a/lib/trackplot/version.rb b/lib/trackplot/version.rb index e927404..55ceb36 100644 --- a/lib/trackplot/version.rb +++ b/lib/trackplot/version.rb @@ -1,3 +1,3 @@ module Trackplot - VERSION = "0.2.0" + VERSION = "0.3.0" end diff --git a/package.json b/package.json index 732a62e..4cd901f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/javascript/chart_rendering.test.js b/test/javascript/chart_rendering.test.js index 978f5cf..e311003 100644 --- a/test/javascript/chart_rendering.test.js +++ b/test/javascript/chart_rendering.test.js @@ -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: [], diff --git a/test/trackplot/chart_builder_test.rb b/test/trackplot/chart_builder_test.rb index e8ebde2..3abd1ba 100644 --- a/test/trackplot/chart_builder_test.rb +++ b/test/trackplot/chart_builder_test.rb @@ -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