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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.md +21 -0
- data/README.md +540 -0
- data/Rakefile +34 -0
- data/app/assets/stylesheets/railsui_charts.css +710 -0
- data/app/controllers/railsui_charts/demo_controller.rb +27 -0
- data/app/javascript/controllers/railsui_chart_controller.js +438 -0
- data/app/javascript/controllers/railsui_chart_filters_controller.js +14 -0
- data/app/javascript/controllers/railsui_metric_dialog_controller.js +27 -0
- data/app/views/railsui_charts/demo/index.html.erb +45 -0
- data/config/routes.rb +4 -0
- data/lib/generators/railsui_charts/install/install_generator.rb +52 -0
- data/lib/generators/railsui_charts/install/templates/railsui_chart_controller.js +438 -0
- data/lib/generators/railsui_charts/install/templates/railsui_chart_filters_controller.js +14 -0
- data/lib/generators/railsui_charts/install/templates/railsui_metric_dialog_controller.js +27 -0
- data/lib/railsui_charts/apex_options_builder.rb +1027 -0
- data/lib/railsui_charts/chart_helper.rb +195 -0
- data/lib/railsui_charts/configuration.rb +89 -0
- data/lib/railsui_charts/engine.rb +19 -0
- data/lib/railsui_charts/filter_helper.rb +90 -0
- data/lib/railsui_charts/filters.rb +130 -0
- data/lib/railsui_charts/interval.rb +110 -0
- data/lib/railsui_charts/metric_helper.rb +219 -0
- data/lib/railsui_charts/version.rb +5 -0
- data/lib/railsui_charts.rb +26 -0
- metadata +86 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
module ChartHelper
|
|
5
|
+
# Forms whose placeholder should be a disc rather than a plotting area.
|
|
6
|
+
CIRCULAR_TYPES = %i[pie donut polar_area radar].freeze
|
|
7
|
+
|
|
8
|
+
def railsui_chart(data, type: :line, **options)
|
|
9
|
+
# An empty dataset is a normal day one, not an error. Rendering axes
|
|
10
|
+
# around nothing looks like a chart that failed rather than a chart with
|
|
11
|
+
# nothing to show yet.
|
|
12
|
+
return railsui_chart_empty(**empty_options(options)) if ApexOptionsBuilder.blank?(data)
|
|
13
|
+
|
|
14
|
+
config = ApexOptionsBuilder.new(data, type: type, **options).build
|
|
15
|
+
id = options[:id] || "rui-chart-#{SecureRandom.hex(4)}"
|
|
16
|
+
|
|
17
|
+
table = options[:accessible] != false ? accessibility_table(config, id: id) : nil
|
|
18
|
+
|
|
19
|
+
content_tag(:div,
|
|
20
|
+
id: id,
|
|
21
|
+
class: "railsui-chart",
|
|
22
|
+
# Charts draw when they scroll into view, so the space has to
|
|
23
|
+
# be held from the start. Without it the page is short on
|
|
24
|
+
# load and grows under the reader as they scroll.
|
|
25
|
+
style: reserved_height(config),
|
|
26
|
+
data: { controller: "railsui-chart", "railsui-chart-options-value": config.to_json }) do
|
|
27
|
+
table
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# One small chart per series, sharing a y-scale.
|
|
32
|
+
#
|
|
33
|
+
# This is the honest answer when there are more categories than a single
|
|
34
|
+
# chart can hold. Eight lines on one axis is a plate of spaghetti, and a
|
|
35
|
+
# ninth colour is not distinguishable from the others anyway — facets scale
|
|
36
|
+
# where colour does not.
|
|
37
|
+
#
|
|
38
|
+
# <%= railsui_small_multiples @plans, type: :area, columns: 3 %>
|
|
39
|
+
def railsui_small_multiples(series, type: :line, height: 120, columns: 3, **options)
|
|
40
|
+
return railsui_chart_empty(**empty_options(options)) if ApexOptionsBuilder.blank?(series)
|
|
41
|
+
|
|
42
|
+
entries = Array(series)
|
|
43
|
+
shared = shared_scale(entries)
|
|
44
|
+
|
|
45
|
+
content_tag(:div, class: "railsui-small-multiples", style: "--rui-small-multiple-columns: #{columns.to_i}") do
|
|
46
|
+
safe_join(entries.each_with_index.map do |entry, index|
|
|
47
|
+
content_tag(:div, class: "railsui-small-multiple") do
|
|
48
|
+
safe_join([
|
|
49
|
+
content_tag(:p, entry[:name] || entry["name"] || "Series #{index + 1}", class: "railsui-small-multiple__title"),
|
|
50
|
+
# Every facet takes the same colour. The title carries identity
|
|
51
|
+
# here, so spending a hue on it would say nothing extra.
|
|
52
|
+
railsui_chart(entry[:data] || entry["data"], type: type, height: height,
|
|
53
|
+
yaxis: shared.merge(tickAmount: 3), **options)
|
|
54
|
+
])
|
|
55
|
+
end
|
|
56
|
+
end)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Facets only compare if they share a scale. Left to themselves each one
|
|
61
|
+
# would fit its own data and a small series would look like a large one.
|
|
62
|
+
def shared_scale(entries)
|
|
63
|
+
values = entries.flat_map { |entry| Array(entry[:data] || entry["data"]).map { |point| ApexOptionsBuilder.value_of(point) } }.compact
|
|
64
|
+
return {} if values.empty?
|
|
65
|
+
|
|
66
|
+
min, max = values.minmax
|
|
67
|
+
return {} if min == max
|
|
68
|
+
|
|
69
|
+
{ min: min, max: max }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Holds the chart's footprint so a card keeps its shape whether or not the
|
|
73
|
+
# query came back with anything.
|
|
74
|
+
def railsui_chart_empty(title: nil, description: nil, height: nil)
|
|
75
|
+
chart_state(:empty, title: title || "No data", description: description, height: height)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def railsui_chart_error(title: nil, description: nil, height: nil)
|
|
79
|
+
chart_state(:error, title: title || "Couldn't load this chart", description: description, height: height)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Server-rendered placeholder for a chart whose data has not arrived yet —
|
|
83
|
+
# the thing a Turbo frame shows before it swaps in the real one. Pass the
|
|
84
|
+
# `type:` it will become so the placeholder is the shape being waited on.
|
|
85
|
+
def railsui_chart_skeleton(height: nil, type: :line, label: "Loading chart")
|
|
86
|
+
classes = ["railsui-chart-skeleton"]
|
|
87
|
+
classes << "railsui-chart-skeleton--circular" if CIRCULAR_TYPES.include?(type.to_sym)
|
|
88
|
+
|
|
89
|
+
content_tag(:div,
|
|
90
|
+
class: "railsui-chart-state railsui-chart-state--loading",
|
|
91
|
+
style: state_height(height),
|
|
92
|
+
role: "status",
|
|
93
|
+
aria: { label: label, busy: true }) do
|
|
94
|
+
content_tag(:span, "", class: classes.join(" "), aria: { hidden: true })
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def chart_state(kind, title:, description:, height:)
|
|
101
|
+
content_tag(:div,
|
|
102
|
+
class: "railsui-chart-state railsui-chart-state--#{kind}",
|
|
103
|
+
style: state_height(height),
|
|
104
|
+
role: "status") do
|
|
105
|
+
content_tag(:div, class: "railsui-chart-state__body") do
|
|
106
|
+
safe_join([
|
|
107
|
+
content_tag(:p, title, class: "railsui-chart-state__title"),
|
|
108
|
+
description.present? ? content_tag(:p, description, class: "railsui-chart-state__description") : nil
|
|
109
|
+
].compact)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reserved_height(config)
|
|
115
|
+
height = config.dig(:chart, :height)
|
|
116
|
+
return if height.blank?
|
|
117
|
+
|
|
118
|
+
"min-height: #{height.to_i}px"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def state_height(height)
|
|
122
|
+
height ||= RailsuiCharts.config.default_height
|
|
123
|
+
"min-height: #{height.to_i}px"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `empty:` takes a string for the headline, or a hash to say more.
|
|
127
|
+
def empty_options(options)
|
|
128
|
+
given = options[:empty]
|
|
129
|
+
base = { height: options[:height] }
|
|
130
|
+
|
|
131
|
+
case given
|
|
132
|
+
when String then base.merge(title: given)
|
|
133
|
+
when Hash then base.merge(given.symbolize_keys)
|
|
134
|
+
else base
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Every chart ships a visually hidden table so no value is reachable only by
|
|
139
|
+
# hovering a mark.
|
|
140
|
+
def accessibility_table(config, id:)
|
|
141
|
+
series = config[:series]
|
|
142
|
+
return if series.blank?
|
|
143
|
+
|
|
144
|
+
if series.is_a?(Array) && series.first.is_a?(Numeric)
|
|
145
|
+
# Pie / donut / polar area: a flat array of values with separate labels.
|
|
146
|
+
columns = [{ name: "Value", data: series }]
|
|
147
|
+
categories = config[:labels] || []
|
|
148
|
+
else
|
|
149
|
+
columns = Array(series).map { |s| { name: s[:name] || "Value", data: s[:data] } }
|
|
150
|
+
categories = config.dig(:xaxis, :categories) || []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
rows = columns.first&.dig(:data)
|
|
154
|
+
return if rows.blank?
|
|
155
|
+
|
|
156
|
+
content_tag(:table, class: "sr-only", aria: { label: "Chart data" }) do
|
|
157
|
+
safe_join([
|
|
158
|
+
content_tag(:caption, "Data for chart #{id}"),
|
|
159
|
+
content_tag(:thead) do
|
|
160
|
+
content_tag(:tr) do
|
|
161
|
+
safe_join(
|
|
162
|
+
[content_tag(:th, "Category")] +
|
|
163
|
+
columns.map { |column| content_tag(:th, column[:name]) }
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end,
|
|
167
|
+
content_tag(:tbody) do
|
|
168
|
+
safe_join(
|
|
169
|
+
rows.each_with_index.map do |_, index|
|
|
170
|
+
content_tag(:tr) do
|
|
171
|
+
safe_join(
|
|
172
|
+
[content_tag(:td, categories[index] || index + 1)] +
|
|
173
|
+
columns.map { |column| content_tag(:td, cell_value(column[:data][index])) }
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
])
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def cell_value(value)
|
|
184
|
+
case value
|
|
185
|
+
when Array then value.join(", ")
|
|
186
|
+
when Hash
|
|
187
|
+
# A bubble carries three numbers that all mean something here. Anything
|
|
188
|
+
# else keeps its label in the category column already, so repeating it
|
|
189
|
+
# beside the value just reads as "A, 6" to a screen reader.
|
|
190
|
+
value.key?(:z) ? value.values_at(:x, :y, :z).compact.join(", ") : value[:y]
|
|
191
|
+
else value
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :default_height, :default_currency, :colors, :series_colors, :theme_css_prefix, :extra_types
|
|
6
|
+
attr_accessor :labelled_point_types, :typography, :geometry
|
|
7
|
+
|
|
8
|
+
# Categorical hues carry identity, so the order is the colourblind-safety
|
|
9
|
+
# mechanism rather than a style choice — it was picked by validating every
|
|
10
|
+
# ordering of these hues and keeping one that clears the lightness band,
|
|
11
|
+
# chroma floor, CVD separation, normal-vision floor, and 3:1 contrast in
|
|
12
|
+
# both light and dark. Reorder it and those guarantees go with it.
|
|
13
|
+
#
|
|
14
|
+
# Forms where any two marks can sit side by side (pie, donut, polar area,
|
|
15
|
+
# scatter, bubble) hold to a stricter all-pairs test, which these hues clear
|
|
16
|
+
# for the first four slots. Past four, fold the tail into "Other".
|
|
17
|
+
SERIES_COUNT = 8
|
|
18
|
+
|
|
19
|
+
SERIES_FALLBACKS = %w[
|
|
20
|
+
#6366f1 #ea580c #db2777 #c026d3
|
|
21
|
+
#65a30d #0284c7 #dc2626 #0d9488
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@default_height = 250
|
|
26
|
+
@default_currency = "$"
|
|
27
|
+
# Chart types registered by an extension, such as Rails UI Charts Pro.
|
|
28
|
+
@extra_types = []
|
|
29
|
+
@labelled_point_types = []
|
|
30
|
+
@theme_css_prefix = "--rui-chart"
|
|
31
|
+
@series_colors = (1..SERIES_COUNT).map { |i| "var(--rui-chart-series-#{i}, #{SERIES_FALLBACKS[i - 1]})" }
|
|
32
|
+
@colors = {
|
|
33
|
+
primary: "var(--rui-chart-primary, #4f46e5)",
|
|
34
|
+
secondary: "var(--rui-chart-secondary, #0ea5e9)",
|
|
35
|
+
accent: "var(--rui-chart-accent, #10b981)",
|
|
36
|
+
muted: "var(--rui-chart-muted, #94a3b8)",
|
|
37
|
+
grid: "var(--rui-chart-grid, rgba(148, 163, 184, 0.2))",
|
|
38
|
+
text: "var(--rui-chart-text, #64748b)",
|
|
39
|
+
# The card colour behind a chart. Touching marks separate with a gap in
|
|
40
|
+
# this colour rather than a stroke drawn around them.
|
|
41
|
+
surface: "var(--rui-chart-surface, #ffffff)"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Type rides the same channel the colours do, because Apex takes these as
|
|
45
|
+
# CSS strings and the controller resolves `var()` against the chart's own
|
|
46
|
+
# computed style before handing the options over. That is worth more than
|
|
47
|
+
# a Ruby setting here: a dense card can shrink the type on its own charts
|
|
48
|
+
# without a second configuration, and a media query can do it per theme.
|
|
49
|
+
#
|
|
50
|
+
# The defaults are the sizes that were hardcoded before, so nothing moves
|
|
51
|
+
# until someone sets a variable.
|
|
52
|
+
@typography = {
|
|
53
|
+
family: "var(--rui-chart-font-family, inherit)",
|
|
54
|
+
size: "var(--rui-chart-font-size, 12px)",
|
|
55
|
+
# Legend, annotation labels, and the compact breakpoint under 640px.
|
|
56
|
+
size_sm: "var(--rui-chart-font-size-sm, 11px)"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Numbers, and that is the whole reason they are here rather than in the
|
|
60
|
+
# CSS. A resolved CSS variable arrives as a string, and Apex does
|
|
61
|
+
# arithmetic on these — "4" + 1 is "41", so a radius set that way would
|
|
62
|
+
# silently produce a chart with corners nobody asked for.
|
|
63
|
+
@geometry = {
|
|
64
|
+
bar_radius: 4,
|
|
65
|
+
stroke_width: 2,
|
|
66
|
+
marker_size: 0,
|
|
67
|
+
marker_hover_size: 6
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
# Register a chart type this gem does not ship, so it passes validation and
|
|
71
|
+
# is drawn through the same options builder as everything else rather than
|
|
72
|
+
# around it.
|
|
73
|
+
#
|
|
74
|
+
# RailsuiCharts.config.register_type :treemap, points: :labelled
|
|
75
|
+
#
|
|
76
|
+
# `points: :labelled` keeps each point as {x:, y:}. Most types put the label
|
|
77
|
+
# on the axis and send bare values, but a treemap draws its labels inside
|
|
78
|
+
# the rectangles, so they have to stay in the data.
|
|
79
|
+
def register_type(type, points: :values)
|
|
80
|
+
type = type.to_sym
|
|
81
|
+
|
|
82
|
+
@extra_types |= [type]
|
|
83
|
+
@labelled_point_types |= [type] if points == :labelled
|
|
84
|
+
|
|
85
|
+
type
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace RailsuiCharts
|
|
6
|
+
|
|
7
|
+
initializer "railsui_charts.helpers" do
|
|
8
|
+
ActiveSupport.on_load :action_controller do
|
|
9
|
+
helper RailsuiCharts::ChartHelper
|
|
10
|
+
helper RailsuiCharts::MetricHelper
|
|
11
|
+
helper RailsuiCharts::FilterHelper
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "railsui_charts.assets" do |app|
|
|
16
|
+
app.config.assets.precompile << "railsui_charts.css"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
module FilterHelper
|
|
5
|
+
# Renders the single filter row that scopes every chart below it.
|
|
6
|
+
#
|
|
7
|
+
# <%= railsui_chart_filters @filters, url: dashboard_path %>
|
|
8
|
+
#
|
|
9
|
+
# Submits as a plain GET form, so it works without JavaScript and each
|
|
10
|
+
# slice gets a shareable URL. With Turbo, the controller submits on change
|
|
11
|
+
# and only the frame re-renders.
|
|
12
|
+
#
|
|
13
|
+
# The controls are deliberately bare — text and a chevron, with the pill
|
|
14
|
+
# only appearing on hover. Filter chrome sits above the data all day, so it
|
|
15
|
+
# earns its weight only while someone is reaching for it.
|
|
16
|
+
#
|
|
17
|
+
# An app that would rather use its own form styles passes them in. Doing so
|
|
18
|
+
# drops the built-in chevron and switch, since those belong to this
|
|
19
|
+
# treatment:
|
|
20
|
+
#
|
|
21
|
+
# <%= railsui_chart_filters @filters, url: dashboard_path,
|
|
22
|
+
# select_class: "form-select w-auto", checkbox_class: "form-input-checkbox" %>
|
|
23
|
+
def railsui_chart_filters(filters, url: nil, frame: nil, select_class: nil, checkbox_class: nil)
|
|
24
|
+
bare_selects = select_class.nil?
|
|
25
|
+
switch = checkbox_class.nil?
|
|
26
|
+
select_class ||= "railsui-chart-filters__select"
|
|
27
|
+
checkbox_class ||= "railsui-chart-filters__checkbox"
|
|
28
|
+
|
|
29
|
+
form_tag(url, method: :get, class: "railsui-chart-filters", data: filter_form_data(frame)) do
|
|
30
|
+
safe_join([
|
|
31
|
+
filter_field("Date range",
|
|
32
|
+
select_tag(:range, options_for_select(filters.presets.map { |preset| [preset.label, preset.key] }, filters.preset.key), **filter_select_options(select_class)),
|
|
33
|
+
chevron: bare_selects),
|
|
34
|
+
filter_field("Interval",
|
|
35
|
+
select_tag(:interval, options_for_select(filters.intervals, filters.interval.to_s), **filter_select_options(select_class)),
|
|
36
|
+
chevron: bare_selects),
|
|
37
|
+
filter_compare(filters, checkbox_class, switch: switch),
|
|
38
|
+
# Without Turbo or Stimulus the selects still need a way to apply.
|
|
39
|
+
content_tag(:noscript, submit_tag("Apply", class: "railsui-chart-filters__apply", data: { disable_with: nil }))
|
|
40
|
+
])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def filter_form_data(frame)
|
|
47
|
+
data = { controller: "railsui-chart-filters" }
|
|
48
|
+
data[:turbo_frame] = frame if frame.present?
|
|
49
|
+
data
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def filter_field(label, control, chevron:)
|
|
53
|
+
content_tag(:div, class: "railsui-chart-filters__field") do
|
|
54
|
+
safe_join([
|
|
55
|
+
content_tag(:span, label, class: "railsui-chart-filters__label"),
|
|
56
|
+
# The chevron rides a wrapper rather than a background image on the
|
|
57
|
+
# select, so it inherits currentColor and follows the theme.
|
|
58
|
+
chevron ? content_tag(:span, control, class: "railsui-chart-filters__control") : control
|
|
59
|
+
])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def filter_select_options(select_class)
|
|
64
|
+
{
|
|
65
|
+
class: select_class,
|
|
66
|
+
data: { action: "change->railsui-chart-filters#submit" }
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def filter_compare(filters, checkbox_class, switch:)
|
|
71
|
+
classes = ["railsui-chart-filters__compare"]
|
|
72
|
+
classes << "railsui-chart-filters__compare--switch" if switch
|
|
73
|
+
|
|
74
|
+
content_tag(:label, class: classes.join(" ")) do
|
|
75
|
+
safe_join([
|
|
76
|
+
# Paired hidden field so unchecking sends an explicit value rather
|
|
77
|
+
# than dropping the param and falling back to the default.
|
|
78
|
+
hidden_field_tag(:compare, "0", id: nil),
|
|
79
|
+
check_box_tag(:compare, "1", filters.compare?,
|
|
80
|
+
class: switch ? "railsui-chart-filters__switch" : checkbox_class,
|
|
81
|
+
data: { action: "change->railsui-chart-filters#submit" }),
|
|
82
|
+
# Adjacent sibling of the input, which is what drives the checked and
|
|
83
|
+
# focus states.
|
|
84
|
+
switch ? content_tag(:span, "", class: "railsui-chart-filters__track", aria: { hidden: true }) : nil,
|
|
85
|
+
content_tag(:span, "Compare to previous period")
|
|
86
|
+
].compact)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
# Resolves request params into the window every chart on a page shares.
|
|
5
|
+
#
|
|
6
|
+
# Filters belong above the charts, not inside them: one row scopes the whole
|
|
7
|
+
# view so every chart re-renders against the same slice. A per-card date
|
|
8
|
+
# picker invites two cards to disagree about what "this week" means.
|
|
9
|
+
#
|
|
10
|
+
# filters = RailsuiCharts::Filters.new(params)
|
|
11
|
+
# filters.range # => Mon, 06 Aug..Mon, 12 Aug
|
|
12
|
+
# filters.interval # => :day
|
|
13
|
+
# filters.previous_range # => the preceding 7 days, when comparing
|
|
14
|
+
class Filters
|
|
15
|
+
Preset = Struct.new(:key, :label, :length, :interval, :unit, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
PRESETS = [
|
|
18
|
+
Preset.new(key: "24h", label: "Last 24 hours", length: 24, interval: :hour, unit: :hour),
|
|
19
|
+
Preset.new(key: "7d", label: "Last 7 days", length: 7, interval: :day, unit: :day),
|
|
20
|
+
Preset.new(key: "30d", label: "Last 30 days", length: 30, interval: :day, unit: :day),
|
|
21
|
+
Preset.new(key: "90d", label: "Last 90 days", length: 13, interval: :week, unit: :week),
|
|
22
|
+
Preset.new(key: "12m", label: "Last 12 months", length: 12, interval: :month, unit: :month),
|
|
23
|
+
Preset.new(key: "mtd", label: "Month to date", length: nil, interval: :day, unit: :day)
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
DEFAULT_PRESET = "7d"
|
|
27
|
+
|
|
28
|
+
INTERVAL_LABELS = { hour: "Hourly", day: "Daily", week: "Weekly", month: "Monthly" }.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :preset
|
|
31
|
+
|
|
32
|
+
def initialize(params = {}, today: nil)
|
|
33
|
+
params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
34
|
+
params = params.transform_keys(&:to_s)
|
|
35
|
+
|
|
36
|
+
@today = today || current_date
|
|
37
|
+
@preset = find_preset(params["range"])
|
|
38
|
+
@interval = coerce_interval(params["interval"])
|
|
39
|
+
@compare = compare_param?(params["compare"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def range
|
|
43
|
+
@range ||= @preset.key == "mtd" ? (@today.beginning_of_month..@today) : trailing_range
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def interval
|
|
47
|
+
@interval || @preset.interval
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def compare?
|
|
51
|
+
@compare
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def previous_range
|
|
55
|
+
return unless compare?
|
|
56
|
+
|
|
57
|
+
Interval.preceding(range, interval: interval)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def presets
|
|
61
|
+
PRESETS
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def intervals
|
|
65
|
+
available = case @preset.unit
|
|
66
|
+
when :hour then %i[hour day]
|
|
67
|
+
when :month then %i[month]
|
|
68
|
+
when :week then %i[week month]
|
|
69
|
+
else %i[day week month]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
available.map { |key| [INTERVAL_LABELS.fetch(key), key.to_s] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def label
|
|
76
|
+
@preset.label
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def interval_label
|
|
80
|
+
INTERVAL_LABELS.fetch(interval)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def summary
|
|
84
|
+
parts = [label, interval_label]
|
|
85
|
+
parts << "Compared to previous period" if compare?
|
|
86
|
+
parts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_params
|
|
90
|
+
{ range: @preset.key, interval: interval.to_s, compare: compare? ? "1" : "0" }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def trailing_range
|
|
96
|
+
finish = @preset.unit == :hour ? current_time : @today
|
|
97
|
+
start = (@preset.length - 1).times.inject(finish) { |value, _| Interval.step_back(value, @preset.interval) }
|
|
98
|
+
|
|
99
|
+
start..finish
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def find_preset(key)
|
|
103
|
+
PRESETS.find { |preset| preset.key == key.to_s } || PRESETS.find { |preset| preset.key == DEFAULT_PRESET }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# An explicit interval only sticks if it makes sense for the window: hourly
|
|
107
|
+
# buckets across twelve months is 8,760 points nobody can read.
|
|
108
|
+
def coerce_interval(value)
|
|
109
|
+
return if value.blank?
|
|
110
|
+
|
|
111
|
+
candidate = value.to_sym
|
|
112
|
+
allowed = intervals.map { |(_, key)| key.to_sym }
|
|
113
|
+
allowed.include?(candidate) ? candidate : nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def compare_param?(value)
|
|
117
|
+
return true if value.nil?
|
|
118
|
+
|
|
119
|
+
%w[1 true yes on].include?(value.to_s.downcase)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def current_date
|
|
123
|
+
Time.respond_to?(:zone) && Time.zone ? Time.zone.today : Date.today
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def current_time
|
|
127
|
+
Time.respond_to?(:zone) && Time.zone ? Time.zone.now : Time.now
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
# Date arithmetic for the supported bucket sizes: truncating a value to the
|
|
5
|
+
# bucket it belongs in, stepping between buckets, and finding the window
|
|
6
|
+
# before a window.
|
|
7
|
+
#
|
|
8
|
+
# This is plumbing rather than product. Filters needs it to resolve a preset
|
|
9
|
+
# into a range, so it stays here even though the bucketing that builds on it
|
|
10
|
+
# lives in Rails UI Charts Pro.
|
|
11
|
+
module Interval
|
|
12
|
+
SUPPORTED = %i[hour day week month].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def validate!(interval)
|
|
16
|
+
interval = interval.to_sym
|
|
17
|
+
raise ArgumentError, "Unsupported interval: #{interval}. Supported: #{SUPPORTED.join(', ')}" unless SUPPORTED.include?(interval)
|
|
18
|
+
|
|
19
|
+
interval
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def truncate(value, interval)
|
|
23
|
+
time = coerce(value)
|
|
24
|
+
return if time.nil?
|
|
25
|
+
|
|
26
|
+
case interval.to_sym
|
|
27
|
+
when :hour then time.change(min: 0, sec: 0)
|
|
28
|
+
when :week then time.to_date.beginning_of_week
|
|
29
|
+
when :month then time.to_date.beginning_of_month
|
|
30
|
+
else time.to_date
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def coerce(value)
|
|
35
|
+
case value
|
|
36
|
+
when Time, DateTime then in_zone(value)
|
|
37
|
+
when Date then value
|
|
38
|
+
when String then parse(value)
|
|
39
|
+
when Numeric then in_zone(Time.at(value))
|
|
40
|
+
else value.respond_to?(:to_time) ? in_zone(value.to_time) : nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The equal-length window immediately before this one.
|
|
45
|
+
def preceding(range, interval: :day)
|
|
46
|
+
return if range.nil?
|
|
47
|
+
|
|
48
|
+
length = count(range, interval)
|
|
49
|
+
finish = step_back(range.first, interval)
|
|
50
|
+
start = length.pred.times.inject(finish) { |value, _| step_back(value, interval) }
|
|
51
|
+
|
|
52
|
+
start..finish
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def count(range, interval)
|
|
56
|
+
total = 0
|
|
57
|
+
cursor = range.first
|
|
58
|
+
|
|
59
|
+
while cursor <= range.last
|
|
60
|
+
total += 1
|
|
61
|
+
cursor = step_forward(cursor, interval)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
total
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def step_forward(value, interval)
|
|
68
|
+
shift(value, interval, 1)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def step_back(value, interval)
|
|
72
|
+
shift(value, interval, -1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Every bucket start between the two ends, inclusive.
|
|
76
|
+
def each(range, interval)
|
|
77
|
+
return enum_for(:each, range, interval) unless block_given?
|
|
78
|
+
|
|
79
|
+
cursor = truncate(range.first, interval)
|
|
80
|
+
finish = truncate(range.last, interval)
|
|
81
|
+
|
|
82
|
+
while cursor <= finish
|
|
83
|
+
yield cursor
|
|
84
|
+
cursor = step_forward(cursor, interval)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def shift(value, interval, direction)
|
|
91
|
+
case interval.to_sym
|
|
92
|
+
when :hour then value + (direction * 3600)
|
|
93
|
+
when :week then value + (direction * 7)
|
|
94
|
+
when :month then value >> direction
|
|
95
|
+
else value + direction
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def in_zone(time)
|
|
100
|
+
Time.respond_to?(:zone) && Time.zone ? time.in_time_zone : time
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse(value)
|
|
104
|
+
Time.respond_to?(:zone) && Time.zone ? Time.zone.parse(value) : Time.parse(value)
|
|
105
|
+
rescue ArgumentError, TypeError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|