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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/trackplot/index.d.ts +292 -0
- data/app/assets/javascripts/trackplot/index.js +1126 -60
- data/app/helpers/trackplot/chart_helper.rb +5 -0
- data/lib/generators/trackplot/install_generator.rb +96 -10
- data/lib/generators/trackplot/templates/trackplot_controller.js +52 -0
- data/lib/trackplot/chart_builder.rb +44 -4
- data/lib/trackplot/color_scale.rb +158 -0
- data/lib/trackplot/component.rb +21 -0
- data/lib/trackplot/components/area.rb +2 -1
- data/lib/trackplot/components/axis.rb +2 -1
- data/lib/trackplot/components/bar.rb +2 -1
- data/lib/trackplot/components/brush.rb +13 -0
- data/lib/trackplot/components/data_label.rb +30 -0
- data/lib/trackplot/components/drilldown.rb +12 -0
- data/lib/trackplot/components/heatmap.rb +16 -0
- data/lib/trackplot/components/line.rb +2 -1
- data/lib/trackplot/components/scatter.rb +2 -1
- data/lib/trackplot/components/treemap.rb +14 -0
- data/lib/trackplot/phlex_component.rb +45 -0
- data/lib/trackplot/sparkline_builder.rb +44 -0
- data/lib/trackplot/version.rb +1 -1
- data/lib/trackplot.rb +11 -0
- metadata +12 -1
|
@@ -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
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
|
@@ -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,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
|
|
@@ -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
|
data/lib/trackplot/version.rb
CHANGED