railsui_charts 0.1.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.
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ module MetricHelper
5
+ # A compact label / value / delta stack with an optional sparkline.
6
+ def railsui_metric(label:, value:, change: nil, comparison: nil, history: nil, format: :number, **options)
7
+ change ||= comparison
8
+ sparkline = metric_sparkline(history, options)
9
+
10
+ content_tag(:div, class: "railsui-metric") do
11
+ safe_join([
12
+ content_tag(:p, label, class: "railsui-metric-label"),
13
+ content_tag(:div, class: "railsui-metric-value-row") do
14
+ safe_join([
15
+ content_tag(:span, format_metric_value(value, format), class: "railsui-metric-value"),
16
+ change ? metric_delta(change) : nil
17
+ ].compact)
18
+ end,
19
+ sparkline
20
+ ].compact)
21
+ end
22
+ end
23
+
24
+ # The full dashboard card: label, value, delta, previous-period line, a
25
+ # comparison chart, and a footer. This is the piece a Rails app actually
26
+ # drops into a dashboard — the chart alone is rarely the whole job.
27
+ # `expand: true` turns the footer link into an expanded view of the same
28
+ # series — a card chart is 180px tall, which is enough to read a trend and
29
+ # not enough to read a value. Takes precedence over `details_path`.
30
+ def railsui_metric_card(label:, value:, previous: nil, change: nil, history: nil, compare: nil,
31
+ format: :number, updated_at: nil, details_path: nil,
32
+ details_label: "More details", positive_is_good: true,
33
+ chart_height: 180, expand: false, **options)
34
+ change ||= percentage_change(value, previous)
35
+ expand &&= history.present?
36
+ # The chart cannot know that a falling churn rate is a win, so the card
37
+ # tells it — the tooltip colours its delta the same way the value does.
38
+ chart_options = options.merge(label: options[:label] || label, trend_up_is_good: positive_is_good)
39
+
40
+ card = content_tag(:div, class: "railsui-metric-card", data: expand ? { controller: "railsui-metric-dialog" } : {}) do
41
+ safe_join([
42
+ metric_card_head(label, value, change, previous, format, positive_is_good),
43
+ metric_card_chart(history, compare, format, chart_height, chart_options),
44
+ metric_card_footer(updated_at, details_path, details_label, expand: expand),
45
+ expand ? metric_dialog(label, value, change, previous, history, compare, format, positive_is_good, chart_options) : nil
46
+ ].compact)
47
+ end
48
+
49
+ card
50
+ end
51
+
52
+ # The card's own placeholder. It stands in for the text that is coming and
53
+ # leaves the plot area empty: a grey slab where the chart goes claims more
54
+ # about the shape of the data than a loading state can know.
55
+ def railsui_metric_card_skeleton(chart_height: 180, footer: true, label: "Loading metric")
56
+ content_tag(:div, class: "railsui-metric-card", role: "status", aria: { busy: true, label: label }) do
57
+ safe_join([
58
+ content_tag(:div, class: "railsui-metric-card__head") do
59
+ safe_join(%w[label value meta].map do |part|
60
+ content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--#{part}", aria: { hidden: true })
61
+ end)
62
+ end,
63
+ content_tag(:div, "", class: "railsui-metric-card__chart", style: "min-height: #{chart_height.to_i}px"),
64
+ footer ? content_tag(:div, class: "railsui-metric-card__footer") do
65
+ content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--footer", aria: { hidden: true })
66
+ end : nil
67
+ ].compact)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def metric_card_head(label, value, change, previous, format, positive_is_good)
74
+ content_tag(:div, class: "railsui-metric-card__head") do
75
+ safe_join([
76
+ content_tag(:p, label, class: "railsui-metric-card__label"),
77
+ content_tag(:div, class: "railsui-metric-card__value-row") do
78
+ safe_join([
79
+ content_tag(:span, format_metric_value(value, format), class: "railsui-metric-card__value"),
80
+ change ? metric_delta(change, positive_is_good: positive_is_good) : nil
81
+ ].compact)
82
+ end,
83
+ previous ? content_tag(:p, "#{format_metric_value(previous, format)} previous period", class: "railsui-metric-card__previous") : nil
84
+ ].compact)
85
+ end
86
+ end
87
+
88
+ def metric_card_chart(history, compare, format, height, options)
89
+ # No early return on blank history: railsui_chart renders its empty panel
90
+ # at the same height, so a card with nothing to plot keeps its shape
91
+ # instead of collapsing and shuffling the grid around it.
92
+ content_tag(:div, class: "railsui-metric-card__chart") do
93
+ safe_join([
94
+ railsui_chart(
95
+ history,
96
+ type: :line,
97
+ compare: compare,
98
+ # The value above is already spelled out in full, so the axis takes
99
+ # the compact form: $19K rather than $19,000.00.
100
+ format: format == :currency ? :short_currency : format,
101
+ height: height,
102
+ # The end labels are rendered below as plain text instead. Apex
103
+ # centres a label on its data point and clips at the canvas edge, so
104
+ # an edge label either loses its first characters or has to be
105
+ # bought with padding that eats into the plot.
106
+ xaxis: { labels: { show: false } },
107
+ grid: { padding: { left: 0, right: 4, top: 0, bottom: 0 } },
108
+ # Scale on the right, so the plot starts flush with the card edge.
109
+ axis: :right,
110
+ # The "previous period" line above already names the comparison, so a
111
+ # legend box would only restate it and eat the card's height.
112
+ legend: { show: false },
113
+ **options
114
+ ),
115
+ metric_card_axis(history)
116
+ ].compact)
117
+ end
118
+ end
119
+
120
+ # First and last labels, flush to the card edges. Nothing to clip, and the
121
+ # plot keeps its full width.
122
+ def metric_card_axis(history)
123
+ labels = Array(history).filter_map { |point| point[:x] || point["x"] if point.is_a?(Hash) }
124
+ return if labels.length < 2
125
+
126
+ content_tag(:div, class: "railsui-metric-card__axis") do
127
+ safe_join([content_tag(:span, labels.first), content_tag(:span, labels.last)])
128
+ end
129
+ end
130
+
131
+ def metric_card_footer(updated_at, details_path, details_label, expand: false)
132
+ return if updated_at.blank? && details_path.blank? && !expand
133
+
134
+ action = if expand
135
+ content_tag(:button, details_label, type: "button", class: "railsui-metric-card__details",
136
+ data: { action: "railsui-metric-dialog#open" })
137
+ elsif details_path.present?
138
+ link_to(details_label, details_path, class: "railsui-metric-card__details")
139
+ end
140
+
141
+ content_tag(:div, class: "railsui-metric-card__footer") do
142
+ safe_join([content_tag(:span, updated_at, class: "railsui-metric-card__updated"), action].compact)
143
+ end
144
+ end
145
+
146
+ def metric_dialog(label, value, change, previous, history, compare, format, positive_is_good, options)
147
+ content_tag(:dialog,
148
+ class: "railsui-metric-dialog",
149
+ data: { "railsui-metric-dialog-target": "dialog", action: "click->railsui-metric-dialog#closeOnBackdrop" }) do
150
+ content_tag(:div, class: "railsui-metric-dialog__panel") do
151
+ safe_join([
152
+ content_tag(:div, class: "railsui-metric-dialog__head") do
153
+ safe_join([
154
+ metric_card_head(label, value, change, previous, format, positive_is_good),
155
+ content_tag(:button, "&times;".html_safe, type: "button", class: "railsui-metric-dialog__close",
156
+ aria: { label: "Close" }, data: { action: "railsui-metric-dialog#close" })
157
+ ])
158
+ end,
159
+ # Room to read values rather than just a trend, so the axis labels
160
+ # the card suppresses come back. They centre on their data point,
161
+ # so the outermost two need room or Apex clips them at the canvas.
162
+ railsui_chart(history, type: :line, compare: compare, format: format, height: 420,
163
+ axis: :right, legend: { show: compare.present? },
164
+ grid: { padding: { left: 28, right: 12 } }, **options)
165
+ ])
166
+ end
167
+ end
168
+ end
169
+
170
+ def format_metric_value(value, format)
171
+ case format
172
+ when :currency, :short_currency
173
+ number_to_currency(value)
174
+ when :percentage
175
+ number_to_percentage(value, precision: 1)
176
+ when :human
177
+ number_to_human(value, units: { thousand: "K", million: "M", billion: "B" }, format: "%n%u", precision: 3)
178
+ else
179
+ number_with_delimiter(value)
180
+ end
181
+ end
182
+
183
+ # Plain coloured text, not a pill. A filled badge competes with the value
184
+ # it is annotating.
185
+ def metric_delta(change, positive_is_good: true)
186
+ return if change.nil?
187
+
188
+ trend = trend_direction(change, positive_is_good: positive_is_good)
189
+ sign = change.to_f.positive? ? "+" : ""
190
+
191
+ content_tag(:span, "#{sign}#{format_change(change)}%", class: "railsui-metric-delta railsui-metric-delta--#{trend}")
192
+ end
193
+
194
+ def format_change(change)
195
+ rounded = change.to_f.round(2)
196
+ rounded == rounded.to_i ? rounded.to_i : rounded
197
+ end
198
+
199
+ # Direction and goodness are separate: a falling churn rate is a win, so the
200
+ # colour follows meaning rather than the sign.
201
+ def trend_direction(change, positive_is_good: true)
202
+ return "neutral" if change.nil? || change.to_f.zero?
203
+
204
+ change.to_f.positive? == positive_is_good ? "positive" : "negative"
205
+ end
206
+
207
+ def percentage_change(value, previous)
208
+ return if value.nil? || previous.nil? || previous.to_f.zero?
209
+
210
+ ((value.to_f - previous.to_f) / previous.to_f * 100).round(2)
211
+ end
212
+
213
+ def metric_sparkline(history, options)
214
+ return if history.blank?
215
+
216
+ railsui_chart(history, type: :sparkline, height: 40, accessible: false, **options.except(:id))
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "railsui_charts/version"
4
+ require "active_support/core_ext/object/json"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/date/calculations"
7
+ require "active_support/core_ext/time/calculations"
8
+
9
+ module RailsuiCharts
10
+ autoload :Configuration, "railsui_charts/configuration"
11
+ autoload :ApexOptionsBuilder, "railsui_charts/apex_options_builder"
12
+ autoload :ChartHelper, "railsui_charts/chart_helper"
13
+ autoload :MetricHelper, "railsui_charts/metric_helper"
14
+ autoload :FilterHelper, "railsui_charts/filter_helper"
15
+ autoload :Filters, "railsui_charts/filters"
16
+ autoload :Interval, "railsui_charts/interval"
17
+
18
+ mattr_accessor :config
19
+ @@config = RailsuiCharts::Configuration.new
20
+
21
+ def self.configure
22
+ yield(config)
23
+ end
24
+ end
25
+
26
+ require "railsui_charts/engine" if defined?(Rails)
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: railsui_charts
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy Leverenz
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: Production-ready chart and metric components for Rails, built on ApexCharts.
27
+ Server-rendered, accessible, and designed for Tailwind CSS.
28
+ email:
29
+ - railsui@justalever.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.md
36
+ - README.md
37
+ - Rakefile
38
+ - app/assets/stylesheets/railsui_charts.css
39
+ - app/controllers/railsui_charts/demo_controller.rb
40
+ - app/javascript/controllers/railsui_chart_controller.js
41
+ - app/javascript/controllers/railsui_chart_filters_controller.js
42
+ - app/javascript/controllers/railsui_metric_dialog_controller.js
43
+ - app/views/railsui_charts/demo/index.html.erb
44
+ - config/routes.rb
45
+ - lib/generators/railsui_charts/install/install_generator.rb
46
+ - lib/generators/railsui_charts/install/templates/railsui_chart_controller.js
47
+ - lib/generators/railsui_charts/install/templates/railsui_chart_filters_controller.js
48
+ - lib/generators/railsui_charts/install/templates/railsui_metric_dialog_controller.js
49
+ - lib/railsui_charts.rb
50
+ - lib/railsui_charts/apex_options_builder.rb
51
+ - lib/railsui_charts/chart_helper.rb
52
+ - lib/railsui_charts/configuration.rb
53
+ - lib/railsui_charts/engine.rb
54
+ - lib/railsui_charts/filter_helper.rb
55
+ - lib/railsui_charts/filters.rb
56
+ - lib/railsui_charts/interval.rb
57
+ - lib/railsui_charts/metric_helper.rb
58
+ - lib/railsui_charts/version.rb
59
+ homepage: https://railsui.com/charts
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://railsui.com/charts
64
+ documentation_uri: https://railsui.com/docs/charts
65
+ source_code_uri: https://github.com/getrailsui/railsui_charts
66
+ changelog_uri: https://github.com/getrailsui/railsui_charts/blob/main/CHANGELOG.md
67
+ bug_tracker_uri: https://github.com/getrailsui/railsui_charts/issues
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 2.7.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.6.9
84
+ specification_version: 4
85
+ summary: Rails-native chart components for RailsUI
86
+ test_files: []