rcharts 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/MIT-LICENSE +20 -0
- data/README.md +99 -0
- data/Rakefile +21 -0
- data/app/helpers/rcharts/graph_helper/axes/axis_element/styles.rb +91 -0
- data/app/helpers/rcharts/graph_helper/axes/axis_element.rb +89 -0
- data/app/helpers/rcharts/graph_helper/axes/label_element.rb +32 -0
- data/app/helpers/rcharts/graph_helper/axes/tick_element.rb +38 -0
- data/app/helpers/rcharts/graph_helper/axes.rb +8 -0
- data/app/helpers/rcharts/graph_helper/categories/bar_builder.rb +32 -0
- data/app/helpers/rcharts/graph_helper/categories/bar_segment_element.rb +49 -0
- data/app/helpers/rcharts/graph_helper/categories/bars_element.rb +52 -0
- data/app/helpers/rcharts/graph_helper/categories/category_builder.rb +115 -0
- data/app/helpers/rcharts/graph_helper/element.rb +65 -0
- data/app/helpers/rcharts/graph_helper/element_builder.rb +42 -0
- data/app/helpers/rcharts/graph_helper/graph/axes.rb +41 -0
- data/app/helpers/rcharts/graph_helper/graph/axis/caster.rb +45 -0
- data/app/helpers/rcharts/graph_helper/graph/axis/positioning.rb +66 -0
- data/app/helpers/rcharts/graph_helper/graph/axis/ticks.rb +66 -0
- data/app/helpers/rcharts/graph_helper/graph/axis.rb +86 -0
- data/app/helpers/rcharts/graph_helper/graph/calculator.rb +91 -0
- data/app/helpers/rcharts/graph_helper/graph/composition.rb +35 -0
- data/app/helpers/rcharts/graph_helper/graph/options.rb +33 -0
- data/app/helpers/rcharts/graph_helper/graph.rb +8 -0
- data/app/helpers/rcharts/graph_helper/graph_builder.rb +270 -0
- data/app/helpers/rcharts/graph_helper/legend_entry_builder.rb +46 -0
- data/app/helpers/rcharts/graph_helper/rule_element.rb +68 -0
- data/app/helpers/rcharts/graph_helper/series/area_element.rb +50 -0
- data/app/helpers/rcharts/graph_helper/series/path.rb +153 -0
- data/app/helpers/rcharts/graph_helper/series/path_element.rb +72 -0
- data/app/helpers/rcharts/graph_helper/series/point.rb +58 -0
- data/app/helpers/rcharts/graph_helper/series/scatter_element.rb +87 -0
- data/app/helpers/rcharts/graph_helper/series/series_builder.rb +145 -0
- data/app/helpers/rcharts/graph_helper/tooltips/entry_builder.rb +47 -0
- data/app/helpers/rcharts/graph_helper/tooltips/foreign_object_element.rb +84 -0
- data/app/helpers/rcharts/graph_helper/tooltips/hover_target_element.rb +39 -0
- data/app/helpers/rcharts/graph_helper/tooltips/marker_element.rb +38 -0
- data/app/helpers/rcharts/graph_helper/tooltips/tooltip_builder.rb +44 -0
- data/app/helpers/rcharts/graph_helper/tooltips/tooltip_element.rb +45 -0
- data/app/helpers/rcharts/graph_helper.rb +249 -0
- data/lib/generators/rcharts/install/install_generator.rb +13 -0
- data/lib/generators/rcharts/install/templates/rcharts.css +392 -0
- data/lib/rcharts/engine.rb +25 -0
- data/lib/rcharts/percentage.rb +36 -0
- data/lib/rcharts/type/percentage.rb +20 -0
- data/lib/rcharts/type/symbol.rb +29 -0
- data/lib/rcharts/type.rb +9 -0
- data/lib/rcharts/version.rb +6 -0
- data/lib/rcharts.rb +52 -0
- data/lib/tasks/rcharts_tasks.rake +6 -0
- metadata +107 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCharts
|
|
4
|
+
module GraphHelper
|
|
5
|
+
# = \RCharts \Graph Builder
|
|
6
|
+
#
|
|
7
|
+
# A GraphBuilder object contains a hash of data and allows you to render the various parts of a graph using the
|
|
8
|
+
# contained data. This is the object yielded when using GraphHelper#graph_for. For example:
|
|
9
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
10
|
+
# <%= graph.series do |series| %>
|
|
11
|
+
# <%= series.line %>
|
|
12
|
+
# <% end %>
|
|
13
|
+
# <% end %>
|
|
14
|
+
# Here, the <tt>graph</tt> variable is a yielded GraphBuilder object. You can then render
|
|
15
|
+
# a line chart from the annual sales data by calling #series to obtain a Series::SeriesBuilder which can iterate
|
|
16
|
+
# over the series present in the data, and then calling Series::SeriesBuilder#line to render a line for the series
|
|
17
|
+
# points.
|
|
18
|
+
#
|
|
19
|
+
# The GraphBuilder object allows rendering as many or few elements of the graph as necessary while still maintaining
|
|
20
|
+
# coherence between them.
|
|
21
|
+
#
|
|
22
|
+
# Like form builders, you can subclass GraphBuilder to extend it with custom behaviour. Perhaps you want the legend
|
|
23
|
+
# to be placed at the top by default, for example:
|
|
24
|
+
# class MyGraphBuilder < RCharts::GraphHelper::GraphBuilder
|
|
25
|
+
# def legend(**options, &)
|
|
26
|
+
# super(**options.merge(placement: :top, &)
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# Then you can use the new builder with GraphHelper#graph_for:
|
|
30
|
+
# <%= graph_for @annual_sales, builder: MyGraphBuilder do |graph| %>
|
|
31
|
+
# <%= graph.legend do |series| %>
|
|
32
|
+
# <%= series.symbol %>
|
|
33
|
+
# <%= series.name %>
|
|
34
|
+
# <% end %>
|
|
35
|
+
# <% end %>
|
|
36
|
+
class GraphBuilder < ElementBuilder
|
|
37
|
+
##
|
|
38
|
+
# :attr_accessor:
|
|
39
|
+
# data used to render the graph
|
|
40
|
+
attribute :graphable, default: -> { {} }
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# :attr_accessor: gutter
|
|
44
|
+
# value determining the length of short ticks
|
|
45
|
+
attribute :gutter, :float, default: 10.0
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# :attr_accessor:
|
|
49
|
+
# options for the layout axes
|
|
50
|
+
attribute :axis_options, default: -> { {} }
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# :attr_accessor:
|
|
54
|
+
# display options for each series
|
|
55
|
+
attribute :series_options, default: -> { {} }
|
|
56
|
+
|
|
57
|
+
# Renders one or more series present in the data. For each series yields a Series::SeriesBuilder which
|
|
58
|
+
# contains the series:
|
|
59
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
60
|
+
# <%= graph.series do |series| %>
|
|
61
|
+
# <%= series.line %>
|
|
62
|
+
# <% end %>
|
|
63
|
+
# <% end %>
|
|
64
|
+
# The default is to iterate over all series, but you can specify a subset by passing the series names as
|
|
65
|
+
# arguments.
|
|
66
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
67
|
+
# <%= graph.series :baseline_prediction, :actual do |series| %>
|
|
68
|
+
# <%= series.line %>
|
|
69
|
+
# <% end %>
|
|
70
|
+
# <% end %>
|
|
71
|
+
# Like GraphHelper#graph_for, you can use the <tt>:builder</tt> key to use a custom builder subclass,
|
|
72
|
+
# <tt>:data</tt> to set data attributes, and <tt>:html</tt> to set other HTML attributes, while other options are
|
|
73
|
+
# passed through to the builder.
|
|
74
|
+
#
|
|
75
|
+
# See Series::SeriesBuilder for more information.
|
|
76
|
+
def series(*names, builder: Series::SeriesBuilder, data: {}, html: {}, **, &)
|
|
77
|
+
tag.svg class: 'series-container', data:, **html do
|
|
78
|
+
selected_series(only: names).each do |key|
|
|
79
|
+
concat render builder.new(name: key, index: series_options_with_defaults.keys.index(key),
|
|
80
|
+
series_options: series_options_with_defaults.fetch(key, {}),
|
|
81
|
+
composition:,
|
|
82
|
+
**), &
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Renders the categories present in the data. For each category yields a Categories::CategoryBuilder which
|
|
88
|
+
# contains the category:
|
|
89
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
90
|
+
# <%= graph.categories do |category| %>
|
|
91
|
+
# <%= category.bar %>
|
|
92
|
+
# <% end %>
|
|
93
|
+
# <% end %>
|
|
94
|
+
# Like GraphHelper#graph_for, you can use the <tt>:builder</tt> key to use a custom builder subclass,
|
|
95
|
+
# <tt>:data</tt> to set data attributes, and <tt>:html</tt> to set other HTML attributes, while other options are
|
|
96
|
+
# passed through to the builder.
|
|
97
|
+
#
|
|
98
|
+
# See Categories::CategoryBuilder for more information.
|
|
99
|
+
def categories(builder: Categories::CategoryBuilder, data: {}, html: {}, **, &)
|
|
100
|
+
tag.svg class: 'category-container', data:, **html do
|
|
101
|
+
composition.values.each_with_index do |(name, category), index|
|
|
102
|
+
concat render builder.new(layout_axes: composition.axes, name:, category:, index:,
|
|
103
|
+
values_count: composition.values.count, series_options:, **), &
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Renders an axis for a set of points. The name is a key such as <tt>:x</tt> and optionally an index
|
|
109
|
+
# that defaults to <tt>0</tt> if not provided. Yields the value of each tick on the axis so you can format it
|
|
110
|
+
# appropriately.
|
|
111
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
112
|
+
# <%= graph.axis :y, label: 'Primary Y axis' do |value| %>
|
|
113
|
+
# <%= number_with_delimiter value %>
|
|
114
|
+
# <% end %>
|
|
115
|
+
# <%= graph.axis :y, 1, label: 'Secondary Y axis' do |value| %>
|
|
116
|
+
# <%= number_with_delimiter value %>
|
|
117
|
+
# <% end %>
|
|
118
|
+
# <%= graph.axis :x, label: 'Primary X axis' do |value| %>
|
|
119
|
+
# <%= value %>
|
|
120
|
+
# <% end %>
|
|
121
|
+
# <%= graph.axis :x, 1, label: 'Secondary X axis' do |value| %>
|
|
122
|
+
# <%= value %>
|
|
123
|
+
# <% end %>
|
|
124
|
+
# <% end %>
|
|
125
|
+
# ==== Options
|
|
126
|
+
# [<tt>:label</tt>] The axis title
|
|
127
|
+
# [<tt>:character_scaling_factor</tt>] The scaling factor for character width on a vertical axis. In this case,
|
|
128
|
+
# label width is calculated using the {width of zero in the current
|
|
129
|
+
# font}[https://meyerweb.com/eric/thoughts/2018/06/28/what-is-the-css-ch-unit/]
|
|
130
|
+
# multiplied by the maximum number of text characters for any label,
|
|
131
|
+
# so you can use this option to make adjustments according to your average
|
|
132
|
+
# character width ratio to zero. The default is <tt>1.0</tt>.
|
|
133
|
+
# [<tt>:breakpoints</tt>] Related to the character scaling factor, these are used to determine the point at which
|
|
134
|
+
# labels rotate or disappear. The defaults are: <tt>hiding: { even: 1.1, odd: 0.6 },
|
|
135
|
+
# rotation: { half: 1.0, full: 0.9 }</tt>.
|
|
136
|
+
def axis(*name, **, &)
|
|
137
|
+
axes.fetch(*name).then do |axis|
|
|
138
|
+
render Axes::AxisElement.new(name: axis.name, index: axis.index, ticks: axis.ticks, horizontal: axis.horizontal?, **), &
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Renders rules for an axis. The name is a key such as <tt>:x</tt> and optionally an index that defaults to
|
|
143
|
+
# <tt>0</tt> if not provided. Rules are both the full-length lines that span the entire width or height of
|
|
144
|
+
# the plot, as well as the much shorter lines that mark each category (e.g. for a bar chart). Rules can also be
|
|
145
|
+
# emphasized, e.g. for <tt>0</tt> in a chart which has both positive and negative values.
|
|
146
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
147
|
+
# <%= graph.rules :y, emphasis: :zero? %>
|
|
148
|
+
# <%= graph.rules :x, short: true %>
|
|
149
|
+
# <%= graph.rules :x, 1, short: true %>
|
|
150
|
+
# <% end %>
|
|
151
|
+
# ==== Options
|
|
152
|
+
# [<tt>:short</tt>] Whether to render short rules (e.g. for bar chart categories)
|
|
153
|
+
# [<tt>:emphasis</tt>] A callable predicate that determines whether a rule should be emphasized
|
|
154
|
+
def rules(*name, short: false, emphasis: nil, **)
|
|
155
|
+
tag.svg class: 'grid' do
|
|
156
|
+
axes.fetch(*name).then do |axis|
|
|
157
|
+
axis.ticks.each do |position, value|
|
|
158
|
+
concat render RuleElement.new(short:, position:, value:, emphasis:, gutter:, axis_index: axis.index,
|
|
159
|
+
horizontal_axis: axis.horizontal?, **)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Renders the legend. For each series yields a LegendEntryBuilder.
|
|
166
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
167
|
+
# <%= graph.legend do |series| %>
|
|
168
|
+
# <%= series.symbol %>
|
|
169
|
+
# <%= series.name %>
|
|
170
|
+
# <% end %>
|
|
171
|
+
# <% end %>
|
|
172
|
+
#
|
|
173
|
+
# See LegendEntryBuilder for more information.
|
|
174
|
+
#
|
|
175
|
+
# ==== Options
|
|
176
|
+
# [<tt>:placement</tt>] The placement of the legend (top, bottom, left, right). Defaults to <tt>'bottom'</tt>.
|
|
177
|
+
def legend(placement: 'bottom', **, &)
|
|
178
|
+
tag.ul class: 'legend', data: { placement: } do
|
|
179
|
+
series_options_with_defaults.each_key.with_index do |key, index|
|
|
180
|
+
concat legend_item_tag_for(key, index, **, &)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Renders the tooltips. For each category yields a Tooltips::TooltipBuilder.
|
|
186
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
187
|
+
# <%= graph.tooltips do |category| %>
|
|
188
|
+
# <div class="tooltip-inner-content">
|
|
189
|
+
# <div class="tooltip-title">
|
|
190
|
+
# <%= category.name %>
|
|
191
|
+
# </div>
|
|
192
|
+
# <dl class="tooltip-items">
|
|
193
|
+
# <%= category.series class: 'tooltip-item' do |series| %>
|
|
194
|
+
# <dt>
|
|
195
|
+
# <%= series.symbol %>
|
|
196
|
+
# <%= series.name %>
|
|
197
|
+
# </dt>
|
|
198
|
+
# <dd>
|
|
199
|
+
# <%= number_to_human series.value %>
|
|
200
|
+
# </dd>
|
|
201
|
+
# <% end %>
|
|
202
|
+
# </dl>
|
|
203
|
+
# </div>
|
|
204
|
+
# <% end %>
|
|
205
|
+
# <% end %>
|
|
206
|
+
#
|
|
207
|
+
# See Tooltips::TooltipBuilder for more information.
|
|
208
|
+
def tooltips(**, &)
|
|
209
|
+
tag.svg class: 'tooltips', width: '100%', xmlns: 'http://www.w3.org/2000/svg' do
|
|
210
|
+
composition.values.each_key { concat tooltip_tag_for(it, **, &) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
delegate :axes, :values, to: :composition, private: true
|
|
217
|
+
|
|
218
|
+
def series_options_with_defaults
|
|
219
|
+
series_options.with_defaults(series_names.index_with { {} })
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def composition
|
|
223
|
+
@composition ||= Graph::Composition.new(graphable, axis_options)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def series_names
|
|
227
|
+
return [] if graphable.empty?
|
|
228
|
+
|
|
229
|
+
case graphable.values.first
|
|
230
|
+
in Hash => graphable_hash then graphable_hash.keys
|
|
231
|
+
in Array => graphable_array then (0...graphable_array.count).to_a
|
|
232
|
+
else [nil]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def selected_series(only: nil)
|
|
237
|
+
series_options_with_defaults.keys.reject { only.presence&.exclude?(it) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def legend_item_tag_for(key, index, **, &)
|
|
241
|
+
tag.li class: 'legend-item' do
|
|
242
|
+
render LegendEntryBuilder.new(name: key, index:, series_options: series_options_with_defaults[key], **), &
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def tooltip_tag_for(key, **, &)
|
|
247
|
+
render Tooltips::TooltipElement.new(inline_axis: inline_axis.name,
|
|
248
|
+
inline_position: inline_axis.position_for(key) || Percentage::MIN,
|
|
249
|
+
inline_size:,
|
|
250
|
+
index: index_for(key),
|
|
251
|
+
values_count: values.count) do
|
|
252
|
+
render Tooltips::TooltipBuilder.new(series_options: series_options_with_defaults, values: values[key],
|
|
253
|
+
name: key, **), &
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def inline_size
|
|
258
|
+
Percentage::MAX / values.count
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def index_for(key)
|
|
262
|
+
composition.keys.index(key)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def inline_axis
|
|
266
|
+
composition.axes.discrete
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCharts
|
|
4
|
+
module GraphHelper
|
|
5
|
+
# = \Legend Entry Builder
|
|
6
|
+
class LegendEntryBuilder < ElementBuilder
|
|
7
|
+
##
|
|
8
|
+
# :attr_accessor:
|
|
9
|
+
attribute :name
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# :attr_accessor: index
|
|
13
|
+
attribute :index, :integer, default: 0
|
|
14
|
+
|
|
15
|
+
attribute :series_options, default: -> { {} }
|
|
16
|
+
|
|
17
|
+
# Renders the symbol associated with the series. You can override this on a per-series basis using
|
|
18
|
+
# <tt>:series_options</tt> with GraphHelper#graph_for.
|
|
19
|
+
# To change the global set of symbols and colors, see RCharts.symbol_for and RCharts.color_class_for.
|
|
20
|
+
# <%= graph_for @annual_sales do |graph| %>
|
|
21
|
+
# <%= graph.tooltips do |category| %>
|
|
22
|
+
# <h4><%= category.name %></h4>
|
|
23
|
+
# <%= category.series do |series| %>
|
|
24
|
+
# <%= series.symbol %>
|
|
25
|
+
# <%= series.name %>
|
|
26
|
+
# <% end %>
|
|
27
|
+
# <% end %>
|
|
28
|
+
# <% end %>
|
|
29
|
+
def symbol
|
|
30
|
+
tag.span symbol_character, class: ['series-symbol', color_class]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
delegate :color_class_for, :symbol_for, to: RCharts, private: true
|
|
36
|
+
|
|
37
|
+
def color_class
|
|
38
|
+
series_options.fetch(:color_class, color_class_for(index))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def symbol_character
|
|
42
|
+
series_options.fetch(:symbol, symbol_for(index))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCharts
|
|
4
|
+
module GraphHelper
|
|
5
|
+
class RuleElement < Element # :nodoc:
|
|
6
|
+
attribute :horizontal_axis, :boolean, default: false
|
|
7
|
+
attribute :axis_index, :integer, default: 0
|
|
8
|
+
attribute :short, :boolean, default: false
|
|
9
|
+
attribute :gutter, :float, default: 0
|
|
10
|
+
attribute :position, :'rcharts/percentage', default: Percentage::MIN
|
|
11
|
+
attribute :emphasis
|
|
12
|
+
attribute :value
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
alias horizontal_axis? horizontal_axis
|
|
17
|
+
alias short? short
|
|
18
|
+
|
|
19
|
+
def tags
|
|
20
|
+
tag.svg y:, x:, class: 'grid-rule-container' do
|
|
21
|
+
tag.line x1:, x2:, y1:, y2:, class: ['grid-rule', { 'emphasis' => emphasis_value }]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def y
|
|
26
|
+
'100%' if short? && horizontal_axis? && axis_index.zero?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def x
|
|
30
|
+
'100%' if short? && !horizontal_axis? && axis_index.positive?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def x1
|
|
34
|
+
horizontal_axis? ? position : start
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def x2
|
|
38
|
+
horizontal_axis? ? position : finish
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def y1
|
|
42
|
+
horizontal_axis? ? start : Percentage::MAX - position
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def y2
|
|
46
|
+
horizontal_axis? ? finish : Percentage::MAX - position
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def finish
|
|
50
|
+
return Percentage::MAX unless short?
|
|
51
|
+
return Percentage::MIN unless horizontal_axis?
|
|
52
|
+
|
|
53
|
+
(gutter / 2) * (axis_index.zero? ? 1 : -1)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def start
|
|
57
|
+
return Percentage::MIN unless short?
|
|
58
|
+
return Percentage::MIN if horizontal_axis?
|
|
59
|
+
|
|
60
|
+
(gutter / 2) * (axis_index.zero? ? -1 : 1)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def emphasis_value
|
|
64
|
+
emphasis.respond_to?(:to_proc) ? value.then(&emphasis) : emphasis
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCharts
|
|
4
|
+
module GraphHelper
|
|
5
|
+
module Series
|
|
6
|
+
class AreaElement < PathElement # :nodoc:
|
|
7
|
+
attribute :previous_series, default: -> { {} }
|
|
8
|
+
attribute :mask_series, default: -> { {} }
|
|
9
|
+
attribute :block_position, :float, default: 0
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
delegate :mask_spans, to: :mask_path, private: true
|
|
14
|
+
|
|
15
|
+
def path_tag
|
|
16
|
+
tag.path d: path_data, style: "d:path(\"#{path_data}\")", class: ['series-shape', color_class], data:, aria:,
|
|
17
|
+
mask: "url(##{mask_id})"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def path_data
|
|
21
|
+
return '' if series.values.compact.all?(&:zero?)
|
|
22
|
+
|
|
23
|
+
@path_data ||= area_path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def previous_points
|
|
27
|
+
return [] if previous_series == series
|
|
28
|
+
|
|
29
|
+
previous_series.collect { |key, value| Point.new(key, 100.0 - (value || block_position)) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def points
|
|
33
|
+
series.collect { |key, value| Point.new(key, 100.0 - (value || block_position)) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mask_points
|
|
37
|
+
mask_series.collect { |key, value| Point.new(key, value.try { 100.0 - it }) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def path
|
|
41
|
+
Path.new(points, previous_points, origin: block_position, smoothing: smooth)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def mask_path
|
|
45
|
+
Path.new(mask_points, origin: block_position, smoothing: smooth)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCharts
|
|
4
|
+
module GraphHelper
|
|
5
|
+
module Series
|
|
6
|
+
class Path # :nodoc:
|
|
7
|
+
class PathBuffer < String # :nodoc:
|
|
8
|
+
SEPARATOR = ' '
|
|
9
|
+
|
|
10
|
+
def concat(*objects)
|
|
11
|
+
return super if empty? || objects.empty?
|
|
12
|
+
|
|
13
|
+
super(SEPARATOR, objects.compact_blank.join(SEPARATOR))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def <<(object)
|
|
17
|
+
return super if object.blank?
|
|
18
|
+
|
|
19
|
+
concat object
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def prepend(*other_strings)
|
|
23
|
+
return super if empty? || other_strings.empty?
|
|
24
|
+
|
|
25
|
+
super(other_strings.compact_blank.join(SEPARATOR), SEPARATOR)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
POINT_SIZE = 38
|
|
30
|
+
|
|
31
|
+
def initialize(points = [], previous_points = [], origin: 0, smoothing: nil)
|
|
32
|
+
@points = points
|
|
33
|
+
@previous_points = previous_points
|
|
34
|
+
@smoothing = smoothing
|
|
35
|
+
@origin = origin
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def line_path
|
|
39
|
+
if compacted_points.empty?
|
|
40
|
+
''
|
|
41
|
+
else
|
|
42
|
+
line_or_curve_data.prepend(initial_line_data)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def area_path
|
|
47
|
+
if compacted_points.empty?
|
|
48
|
+
''
|
|
49
|
+
else
|
|
50
|
+
line_or_curve_data.prepend(*initial_area_data).concat(*final_area_data, previous_line_or_curve_data)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mask_spans
|
|
55
|
+
points.compact.chunk(&:complete?).filter_map do |complete, chunk|
|
|
56
|
+
next unless complete
|
|
57
|
+
|
|
58
|
+
chunk.sort_by!(&:x)
|
|
59
|
+
chunk.first => { x:, y: }
|
|
60
|
+
chunk.last - chunk.first => { x: width, y: height }
|
|
61
|
+
{ x:, y:, width:, height: }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :points, :previous_points, :smoothing, :origin
|
|
68
|
+
attr_accessor :buffer
|
|
69
|
+
|
|
70
|
+
def line_or_curve_data
|
|
71
|
+
smoothing ? curve_data : line_data
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def line_data
|
|
75
|
+
capture capacity: estimated_serialized_length do
|
|
76
|
+
compacted_points.each { concat line_to(it) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def curve_data
|
|
81
|
+
capture capacity: estimated_serialized_length * 3 do
|
|
82
|
+
compacted_points.values_at(0, 0..-1, -1).each_cons(4) { concat curve_for(*it, smoothing) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def previous_line_or_curve_data
|
|
87
|
+
smoothing ? previous_curve_data : previous_line_data
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def previous_line_data
|
|
91
|
+
capture capacity: estimated_serialized_length do
|
|
92
|
+
compacted_previous_points.reverse_each { concat line_to(it) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def previous_curve_data
|
|
97
|
+
capture capacity: estimated_serialized_length * 3 do
|
|
98
|
+
compacted_previous_points.values_at(0, 0..-1, -1)
|
|
99
|
+
.each_cons(4)
|
|
100
|
+
.reverse_each { concat curve_for(*it.reverse, smoothing) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def initial_line_data
|
|
105
|
+
move_to compacted_points.first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def initial_area_data
|
|
109
|
+
[move_to(Point.new(compacted_points.first.x, 100 - origin)), line_to(compacted_points.first)]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def final_area_data
|
|
113
|
+
[line_to(Point.new(compacted_points.last.x, 100 - origin)), line_to(compacted_previous_points.last)]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def move_to(point)
|
|
117
|
+
"M #{point}" if point
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def line_to(point)
|
|
121
|
+
"L #{point}" if point
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def curve_for(previous_point, current, next_point, subsequent_point, smoothing)
|
|
125
|
+
"C #{current.control(previous_point, next_point, smoothing:)} " \
|
|
126
|
+
"#{next_point.control(current, subsequent_point, smoothing:, reverse: true)} " \
|
|
127
|
+
"#{next_point}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def estimated_serialized_length
|
|
131
|
+
compacted_points.size * POINT_SIZE
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def estimated_previous_serialized_length
|
|
135
|
+
compacted_previous_points.size * POINT_SIZE
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def capture(capacity: nil)
|
|
139
|
+
self.buffer = PathBuffer.new(capacity:)
|
|
140
|
+
yield
|
|
141
|
+
buffer
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def concat(*objects)
|
|
145
|
+
objects.each { buffer.concat it }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def compacted_points = points.compact.select(&:complete?)
|
|
149
|
+
def compacted_previous_points = previous_points.compact.select(&:complete?)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCharts
|
|
4
|
+
module GraphHelper
|
|
5
|
+
module Series
|
|
6
|
+
class PathElement < Element # :nodoc:
|
|
7
|
+
attribute :series_options, default: -> { {} }
|
|
8
|
+
attribute :series, default: -> { {} }
|
|
9
|
+
attribute :index, :integer, default: 0
|
|
10
|
+
attribute :smoothing, :float
|
|
11
|
+
attribute :horizontal, :boolean, default: false
|
|
12
|
+
attribute :id_hash, :string, default: -> { SecureRandom.hex(4) }
|
|
13
|
+
|
|
14
|
+
alias_attribute :smooth, :smoothing
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
alias horizontal? horizontal
|
|
19
|
+
|
|
20
|
+
delegate :line_path, :area_path, :mask_spans, to: :path, private: true
|
|
21
|
+
|
|
22
|
+
def tags
|
|
23
|
+
tag.svg class: 'series', height: '100%', width: '100%',
|
|
24
|
+
'viewBox' => '0 0 100 100', 'preserveAspectRatio' => 'none' do
|
|
25
|
+
series_tag
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def series_tag
|
|
30
|
+
mask_tag + path_tag
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def path_tag
|
|
34
|
+
tag.path d: path_data, style: "d:path(\"#{path_data}\")", class: ['series-path', color_class, class_names], data:, aria:,
|
|
35
|
+
mask: "url(##{mask_id})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def path_data
|
|
39
|
+
@path_data ||= line_path
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def color_class
|
|
43
|
+
series_options.fetch(:color_class, RCharts.color_class_for(index))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def points
|
|
47
|
+
series.collect { |key, value| Point.new(key, value.try { 100.0 - it }) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def path
|
|
51
|
+
Path.new(points, smoothing:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mask_tag
|
|
55
|
+
tag.mask id: mask_id, maskUnits: 'userSpaceOnUse', maskContentUnits: 'userSpaceOnUse' do
|
|
56
|
+
concat tag.rect x: '-50%', y: '-50%', width: '200%', height: '200%', fill: 'black'
|
|
57
|
+
mask_spans.each { concat mask_rect_tag_for(it) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mask_rect_tag_for(span)
|
|
62
|
+
tag.rect x: '-50%', y: '-50%', width: '200%', height: '200%', fill: 'white',
|
|
63
|
+
**span.slice(*(horizontal? ? %i[x width] : %i[y height]))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def mask_id
|
|
67
|
+
['series-mask', id_hash].join('-')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|