trackplot 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,5 +5,10 @@ module Trackplot
5
5
  capture(builder, &block) if block_given?
6
6
  builder.render(self)
7
7
  end
8
+
9
+ def trackplot_sparkline(data, key:, **options)
10
+ builder = SparklineBuilder.new(data, key: key, **options)
11
+ builder.render(self)
12
+ end
8
13
  end
9
14
  end
@@ -1,21 +1,107 @@
1
1
  module Trackplot
2
2
  module Generators
3
3
  class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+ class_option :stimulus, type: :boolean, default: false,
6
+ desc: "Copy a Stimulus controller for auto-refresh polling and export actions"
7
+
4
8
  desc "Install Trackplot into your Rails application"
5
9
 
6
- def add_import
7
- js_file = Rails.root.join("app/javascript/application.js")
10
+ def detect_and_install_js
11
+ if importmap?
12
+ install_importmap
13
+ elsif jsbundling?
14
+ install_jsbundling
15
+ else
16
+ say "Could not detect JS setup (no config/importmap.rb or package.json found).", :red
17
+ say "Please add `import \"trackplot\"` to your JavaScript entrypoint manually."
18
+ end
19
+ end
20
+
21
+ def copy_stimulus_controller
22
+ return unless options[:stimulus]
23
+
24
+ dest = Rails.root.join("app/javascript/controllers/trackplot_controller.js")
25
+ if File.exist?(dest)
26
+ say "Stimulus controller already exists at #{dest}", :yellow
27
+ else
28
+ copy_file "trackplot_controller.js", dest
29
+ say "Copied Stimulus controller to #{dest}", :green
30
+ end
31
+ end
32
+
33
+ def print_post_install
34
+ say ""
35
+ say "Trackplot installed successfully!", :green
36
+ say ""
37
+ say "Quick start — add this to any ERB view:"
38
+ say ""
39
+ say ' <%= trackplot_chart([{month: "Jan", revenue: 100}, {month: "Feb", revenue: 200}]) do |c| %>'
40
+ say " <% c.axis :x, data_key: :month %>"
41
+ say " <% c.axis :y, format: :currency %>"
42
+ say " <% c.line :revenue, curve: true %>"
43
+ say " <% c.tooltip format: :currency %>"
44
+ say " <% c.legend %>"
45
+ say " <% end %>"
46
+ say ""
47
+ end
48
+
49
+ private
50
+
51
+ def importmap?
52
+ File.exist?(Rails.root.join("config/importmap.rb"))
53
+ end
54
+
55
+ def jsbundling?
56
+ File.exist?(Rails.root.join("package.json")) && !importmap?
57
+ end
58
+
59
+ def install_importmap
60
+ say "Detected importmap setup", :cyan
61
+ # Engine auto-registers pins via config/importmap.rb in the engine,
62
+ # so we just need the import in application.js
63
+ ensure_import_in_application_js
64
+ end
65
+
66
+ def install_jsbundling
67
+ say "Detected jsbundling setup", :cyan
68
+ pm = detect_package_manager
69
+ say "Using #{pm} to install packages...", :cyan
70
+
71
+ install_cmd = case pm
72
+ when "yarn" then "yarn add d3 trackplot"
73
+ when "pnpm" then "pnpm add d3 trackplot"
74
+ else "npm install d3 trackplot"
75
+ end
76
+
77
+ run install_cmd
78
+ ensure_import_in_application_js
79
+ end
8
80
 
9
- if File.exist?(js_file)
10
- content = File.read(js_file)
11
- if content.include?('import "trackplot"')
12
- say "trackplot import already present in application.js", :yellow
13
- else
14
- append_to_file js_file, "\nimport \"trackplot\"\n"
15
- say "Added trackplot import to application.js", :green
16
- end
81
+ def detect_package_manager
82
+ if File.exist?(Rails.root.join("yarn.lock"))
83
+ "yarn"
84
+ elsif File.exist?(Rails.root.join("pnpm-lock.yaml"))
85
+ "pnpm"
17
86
  else
87
+ "npm"
88
+ end
89
+ end
90
+
91
+ def ensure_import_in_application_js
92
+ js_file = Rails.root.join("app/javascript/application.js")
93
+
94
+ unless File.exist?(js_file)
18
95
  say "Could not find app/javascript/application.js — please add `import \"trackplot\"` manually", :red
96
+ return
97
+ end
98
+
99
+ content = File.read(js_file)
100
+ if content.include?('import "trackplot"')
101
+ say "trackplot import already present in application.js", :yellow
102
+ else
103
+ append_to_file js_file, "\nimport \"trackplot\"\n"
104
+ say "Added trackplot import to application.js", :green
19
105
  end
20
106
  end
21
107
  end
@@ -0,0 +1,52 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Auto-refreshing chart controller for Trackplot.
4
+ //
5
+ // Usage:
6
+ // <div data-controller="trackplot"
7
+ // data-trackplot-url-value="/api/chart_data.json"
8
+ // data-trackplot-interval-value="5000">
9
+ // <trackplot-chart config='<%= chart_config_json %>'></trackplot-chart>
10
+ // </div>
11
+ //
12
+ // Actions:
13
+ // <button data-action="trackplot#exportPng">Download PNG</button>
14
+ // <button data-action="trackplot#exportSvg">Download SVG</button>
15
+
16
+ export default class extends Controller {
17
+ static values = {
18
+ url: String,
19
+ interval: { type: Number, default: 0 }
20
+ }
21
+
22
+ connect() {
23
+ this.chart = this.element.querySelector("trackplot-chart")
24
+ if (this.urlValue && this.intervalValue > 0) {
25
+ this.poll = setInterval(() => this.refresh(), this.intervalValue)
26
+ }
27
+ }
28
+
29
+ disconnect() {
30
+ if (this.poll) clearInterval(this.poll)
31
+ }
32
+
33
+ async refresh() {
34
+ if (!this.urlValue || !this.chart) return
35
+ try {
36
+ const response = await fetch(this.urlValue)
37
+ if (!response.ok) return
38
+ const data = await response.json()
39
+ this.chart.updateData(data)
40
+ } catch (e) {
41
+ console.warn("Trackplot: refresh failed", e)
42
+ }
43
+ }
44
+
45
+ exportPng() {
46
+ this.chart?.exportPNG()
47
+ }
48
+
49
+ exportSvg() {
50
+ this.chart?.exportSVG()
51
+ }
52
+ }
@@ -81,29 +81,69 @@ module Trackplot
81
81
  nil
82
82
  end
83
83
 
84
+ def data_label(**opts)
85
+ @components << Components::DataLabel.new(**opts)
86
+ nil
87
+ end
88
+
89
+ def brush(**opts)
90
+ @components << Components::Brush.new(**opts)
91
+ nil
92
+ end
93
+
94
+ def heatmap(**opts)
95
+ @components << Components::Heatmap.new(**opts)
96
+ nil
97
+ end
98
+
99
+ def treemap(**opts)
100
+ @components << Components::Treemap.new(**opts)
101
+ nil
102
+ end
103
+
104
+ def drilldown(key, **opts)
105
+ @components << Components::Drilldown.new(key: key, **opts)
106
+ nil
107
+ end
108
+
84
109
  def render(view_context)
85
110
  chart_id = options[:id] || "trackplot-#{SecureRandom.hex(8)}"
86
111
  config = build_config
87
112
 
88
- view_context.content_tag(
89
- "trackplot-chart",
90
- nil,
113
+ html_options = {
91
114
  id: chart_id,
92
115
  config: config.to_json,
93
116
  style: chart_style,
94
117
  class: css_classes
118
+ }
119
+
120
+ if options[:title]
121
+ html_options[:role] = "img"
122
+ html_options[:"aria-label"] = options[:title]
123
+ end
124
+
125
+ view_context.content_tag(
126
+ "trackplot-chart",
127
+ nil,
128
+ html_options
95
129
  )
96
130
  end
97
131
 
98
132
  private
99
133
 
100
134
  def build_config
101
- {
135
+ config = {
102
136
  data: data,
103
137
  components: components.map(&:to_config),
104
138
  animate: options.fetch(:animate, true),
105
139
  theme: Theme.resolve(options[:theme])
106
140
  }
141
+
142
+ config[:title] = options[:title] if options[:title]
143
+ config[:description] = options[:description] if options[:description]
144
+ config[:empty_message] = options[:empty_message] if options[:empty_message]
145
+
146
+ config
107
147
  end
108
148
 
109
149
  def chart_style
@@ -0,0 +1,158 @@
1
+ module Trackplot
2
+ module ColorScale
3
+ module_function
4
+
5
+ # Light-to-dark palette from a single base color.
6
+ # Extracts hue + saturation, varies lightness from 0.90 to 0.25.
7
+ def sequential(hex, count: 8)
8
+ validate_hex!(hex)
9
+ return [] if count == 0
10
+
11
+ h, s, _ = hex_to_hsl(hex)
12
+ if count == 1
13
+ return [hsl_to_hex(h, s, 0.5)]
14
+ end
15
+
16
+ (0...count).map do |i|
17
+ l = lerp(0.90, 0.25, i.to_f / (count - 1))
18
+ hsl_to_hex(h, s, l)
19
+ end
20
+ end
21
+
22
+ # Two-color gradient with parabolic lightness curve peaking at 0.95 at midpoint.
23
+ def diverging(hex1, hex2, count: 8)
24
+ validate_hex!(hex1)
25
+ validate_hex!(hex2)
26
+ return [] if count == 0
27
+
28
+ h1, s1, _ = hex_to_hsl(hex1)
29
+ h2, s2, _ = hex_to_hsl(hex2)
30
+
31
+ if count == 1
32
+ return [hsl_to_hex(lerp_hue(h1, h2, 0.5), lerp(s1, s2, 0.5), 0.95)]
33
+ end
34
+
35
+ (0...count).map do |i|
36
+ t = i.to_f / (count - 1)
37
+ h = lerp_hue(h1, h2, t)
38
+ s = lerp(s1, s2, t)
39
+ # Parabolic curve: l = 0.95 at t=0.5, dropping to ~0.35 at edges
40
+ l = 0.95 - 2.4 * (t - 0.5)**2
41
+ hsl_to_hex(h, s, l)
42
+ end
43
+ end
44
+
45
+ # Evenly-spaced hues, preserving base color's saturation and lightness.
46
+ def categorical(hex, count: 8)
47
+ validate_hex!(hex)
48
+ return [] if count == 0
49
+
50
+ h, s, l = hex_to_hsl(hex)
51
+ step = 360.0 / count
52
+
53
+ (0...count).map do |i|
54
+ hue = (h + step * i) % 360
55
+ hsl_to_hex(hue, s, l)
56
+ end
57
+ end
58
+
59
+ # ── Private helpers ──────────────────────────────────────
60
+
61
+ def validate_hex!(hex)
62
+ unless hex.is_a?(String) && hex.match?(/\A#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/)
63
+ raise ArgumentError, "Invalid hex color: #{hex.inspect}. Expected format: #RGB or #RRGGBB"
64
+ end
65
+ end
66
+
67
+ def hex_to_rgb(hex)
68
+ hex = hex.delete_prefix("#")
69
+ if hex.length == 3
70
+ hex = hex.chars.map { |c| c * 2 }.join
71
+ end
72
+ [hex[0..1], hex[2..3], hex[4..5]].map { |c| c.to_i(16) }
73
+ end
74
+
75
+ def rgb_to_hsl(r, g, b)
76
+ r /= 255.0
77
+ g /= 255.0
78
+ b /= 255.0
79
+
80
+ max = [r, g, b].max
81
+ min = [r, g, b].min
82
+ l = (max + min) / 2.0
83
+
84
+ if max == min
85
+ h = s = 0.0
86
+ else
87
+ d = max - min
88
+ s = (l > 0.5) ? d / (2.0 - max - min) : d / (max + min)
89
+
90
+ h = case max
91
+ when r then ((g - b) / d + ((g < b) ? 6 : 0)) / 6.0
92
+ when g then ((b - r) / d + 2) / 6.0
93
+ when b then ((r - g) / d + 4) / 6.0
94
+ end
95
+ end
96
+
97
+ [h * 360.0, s, l]
98
+ end
99
+
100
+ def hsl_to_rgb(h, s, l)
101
+ h /= 360.0
102
+
103
+ if s == 0
104
+ val = (l * 255).round
105
+ return [val, val, val]
106
+ end
107
+
108
+ q = (l < 0.5) ? l * (1 + s) : l + s - l * s
109
+ p = 2 * l - q
110
+
111
+ r = hue_to_rgb(p, q, h + 1.0 / 3)
112
+ g = hue_to_rgb(p, q, h)
113
+ b = hue_to_rgb(p, q, h - 1.0 / 3)
114
+
115
+ [(r * 255).round, (g * 255).round, (b * 255).round]
116
+ end
117
+
118
+ def hue_to_rgb(p, q, t)
119
+ t += 1.0 if t < 0
120
+ t -= 1.0 if t > 1
121
+
122
+ return p + (q - p) * 6 * t if t < 1.0 / 6
123
+ return q if t < 1.0 / 2
124
+ return p + (q - p) * (2.0 / 3 - t) * 6 if t < 2.0 / 3
125
+ p
126
+ end
127
+
128
+ def rgb_to_hex(r, g, b)
129
+ "#%02x%02x%02x" % [r.clamp(0, 255), g.clamp(0, 255), b.clamp(0, 255)]
130
+ end
131
+
132
+ def hex_to_hsl(hex)
133
+ r, g, b = hex_to_rgb(hex)
134
+ rgb_to_hsl(r, g, b)
135
+ end
136
+
137
+ def hsl_to_hex(h, s, l)
138
+ r, g, b = hsl_to_rgb(h, s, l)
139
+ rgb_to_hex(r, g, b)
140
+ end
141
+
142
+ def lerp(a, b, t)
143
+ a + (b - a) * t
144
+ end
145
+
146
+ def lerp_hue(h1, h2, t)
147
+ diff = h2 - h1
148
+ if diff.abs > 180
149
+ diff += (diff > 0) ? -360 : 360
150
+ end
151
+ (h1 + diff * t) % 360
152
+ end
153
+
154
+ private_class_method :validate_hex!, :hex_to_rgb, :rgb_to_hsl, :hsl_to_rgb,
155
+ :hue_to_rgb, :rgb_to_hex, :hex_to_hsl, :hsl_to_hex,
156
+ :lerp, :lerp_hue
157
+ end
158
+ end
@@ -0,0 +1,21 @@
1
+ begin
2
+ require "view_component"
3
+ rescue LoadError
4
+ # ViewComponent is an optional dependency
5
+ end
6
+
7
+ module Trackplot
8
+ if defined?(ViewComponent::Base)
9
+ class Component < ViewComponent::Base
10
+ def initialize(data:, **options, &block)
11
+ @builder = ChartBuilder.new(data, **options)
12
+ @block = block
13
+ end
14
+
15
+ def call
16
+ @block&.call(@builder)
17
+ @builder.render(self)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -16,7 +16,8 @@ module Trackplot
16
16
  curve: options.fetch(:curve, false),
17
17
  opacity: options[:opacity] || 0.3,
18
18
  stroke_width: options[:stroke_width] || 2,
19
- stack: options[:stack]
19
+ stack: options[:stack],
20
+ y_axis: options[:y_axis]&.to_s
20
21
  }.compact
21
22
  end
22
23
  end
@@ -24,7 +24,8 @@ module Trackplot
24
24
  label: options[:label],
25
25
  format: resolve_format(options[:format]),
26
26
  tick_count: options[:tick_count],
27
- tick_rotation: options[:tick_rotation]
27
+ tick_rotation: options[:tick_rotation],
28
+ axis_id: options[:axis_id]&.to_s
28
29
  }.compact
29
30
  end
30
31
 
@@ -15,7 +15,8 @@ module Trackplot
15
15
  color: options[:color],
16
16
  opacity: options[:opacity],
17
17
  radius: options[:radius] || 4,
18
- stack: options[:stack]
18
+ stack: options[:stack],
19
+ y_axis: options[:y_axis]&.to_s
19
20
  }.compact
20
21
  end
21
22
  end
@@ -0,0 +1,13 @@
1
+ module Trackplot
2
+ module Components
3
+ class Brush < Base
4
+ def to_config
5
+ {
6
+ type: "brush",
7
+ axis: (options[:axis] || :x).to_s,
8
+ height: options[:height] || 40
9
+ }.compact
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ module Trackplot
2
+ module Components
3
+ class DataLabel < Base
4
+ FORMAT_SYMBOLS = {
5
+ currency: "currency",
6
+ percent: "percent",
7
+ compact: "compact",
8
+ decimal: "decimal",
9
+ integer: "integer"
10
+ }.freeze
11
+
12
+ def to_config
13
+ {
14
+ type: "data_label",
15
+ format: resolve_format(options[:format]),
16
+ position: (options[:position] || :top).to_s,
17
+ font_size: options[:font_size] || 11
18
+ }.compact
19
+ end
20
+
21
+ private
22
+
23
+ def resolve_format(fmt)
24
+ return nil if fmt.nil?
25
+
26
+ FORMAT_SYMBOLS.fetch(fmt, fmt).to_s
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,12 @@
1
+ module Trackplot
2
+ module Components
3
+ class Drilldown < Base
4
+ def to_config
5
+ {
6
+ type: "drilldown",
7
+ key: options[:key].to_s
8
+ }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ module Trackplot
2
+ module Components
3
+ class Heatmap < Base
4
+ def to_config
5
+ {
6
+ type: "heatmap",
7
+ x_key: options[:x_key],
8
+ y_key: options[:y_key],
9
+ value_key: options[:value_key],
10
+ color_range: options[:color_range] || ["#f0f9ff", "#1e40af"],
11
+ radius: options[:radius] || 2
12
+ }.compact
13
+ end
14
+ end
15
+ end
16
+ end
@@ -17,7 +17,8 @@ module Trackplot
17
17
  dashed: options.fetch(:dashed, false),
18
18
  stroke_width: options[:stroke_width] || 2,
19
19
  dot: options.fetch(:dot, true),
20
- dot_size: options[:dot_size] || 4
20
+ dot_size: options[:dot_size] || 4,
21
+ y_axis: options[:y_axis]&.to_s
21
22
  }.compact
22
23
  end
23
24
  end
@@ -16,7 +16,8 @@ module Trackplot
16
16
  color: options[:color],
17
17
  dot_size: options[:dot_size] || 5,
18
18
  opacity: options[:opacity] || 0.7,
19
- shape: options[:shape] || "circle"
19
+ shape: options[:shape] || "circle",
20
+ y_axis: options[:y_axis]&.to_s
20
21
  }.compact
21
22
  end
22
23
  end
@@ -0,0 +1,14 @@
1
+ module Trackplot
2
+ module Components
3
+ class Treemap < Base
4
+ def to_config
5
+ {
6
+ type: "treemap",
7
+ value_key: options[:value_key],
8
+ label_key: options[:label_key],
9
+ parent_key: options[:parent_key]
10
+ }.compact
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ begin
2
+ require "phlex"
3
+ rescue LoadError
4
+ # Phlex is an optional dependency
5
+ end
6
+
7
+ module Trackplot
8
+ if defined?(Phlex::HTML)
9
+ class PhlexComponent < Phlex::HTML
10
+ def initialize(data:, **options, &block)
11
+ @data = data
12
+ @options = options
13
+ @block = block
14
+ end
15
+
16
+ def view_template
17
+ builder = ChartBuilder.new(@data, **@options)
18
+ @block&.call(builder)
19
+ config = builder.send(:build_config)
20
+
21
+ chart_id = @options[:id] || "trackplot-#{SecureRandom.hex(8)}"
22
+ width = @options[:width] || "100%"
23
+ height = @options[:height] || "400px"
24
+
25
+ tag(
26
+ "trackplot-chart",
27
+ id: chart_id,
28
+ config: config.to_json,
29
+ style: "display:block;width:#{width};height:#{height};",
30
+ class: ["trackplot-chart", @options[:class]].compact.join(" "),
31
+ role: @options[:title] ? "img" : nil,
32
+ **aria_attributes
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def aria_attributes
39
+ attrs = {}
40
+ attrs[:"aria-label"] = @options[:title] if @options[:title]
41
+ attrs
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ require "securerandom"
2
+ require "json"
3
+
4
+ module Trackplot
5
+ class SparklineBuilder
6
+ attr_reader :data, :options
7
+
8
+ def initialize(data, **options)
9
+ @data = DataAdapter.normalize(data)
10
+ @options = options
11
+ end
12
+
13
+ def render(view_context)
14
+ config = build_config
15
+
16
+ view_context.content_tag(
17
+ "trackplot-sparkline",
18
+ nil,
19
+ config: config.to_json,
20
+ style: sparkline_style
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def build_config
27
+ {
28
+ data: data,
29
+ key: options[:key].to_s,
30
+ type: (options[:type] || :line).to_s,
31
+ color: options[:color] || "#6366f1",
32
+ fill: options[:fill],
33
+ stroke_width: options[:stroke_width] || 1.5,
34
+ dot: options.fetch(:dot, false)
35
+ }.compact
36
+ end
37
+
38
+ def sparkline_style
39
+ width = options[:width] || "120px"
40
+ height = options[:height] || "32px"
41
+ "display:inline-block;width:#{width};height:#{height};"
42
+ end
43
+ end
44
+ end
@@ -1,3 +1,3 @@
1
1
  module Trackplot
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end