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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +99 -0
  4. data/Rakefile +21 -0
  5. data/app/helpers/rcharts/graph_helper/axes/axis_element/styles.rb +91 -0
  6. data/app/helpers/rcharts/graph_helper/axes/axis_element.rb +89 -0
  7. data/app/helpers/rcharts/graph_helper/axes/label_element.rb +32 -0
  8. data/app/helpers/rcharts/graph_helper/axes/tick_element.rb +38 -0
  9. data/app/helpers/rcharts/graph_helper/axes.rb +8 -0
  10. data/app/helpers/rcharts/graph_helper/categories/bar_builder.rb +32 -0
  11. data/app/helpers/rcharts/graph_helper/categories/bar_segment_element.rb +49 -0
  12. data/app/helpers/rcharts/graph_helper/categories/bars_element.rb +52 -0
  13. data/app/helpers/rcharts/graph_helper/categories/category_builder.rb +115 -0
  14. data/app/helpers/rcharts/graph_helper/element.rb +65 -0
  15. data/app/helpers/rcharts/graph_helper/element_builder.rb +42 -0
  16. data/app/helpers/rcharts/graph_helper/graph/axes.rb +41 -0
  17. data/app/helpers/rcharts/graph_helper/graph/axis/caster.rb +45 -0
  18. data/app/helpers/rcharts/graph_helper/graph/axis/positioning.rb +66 -0
  19. data/app/helpers/rcharts/graph_helper/graph/axis/ticks.rb +66 -0
  20. data/app/helpers/rcharts/graph_helper/graph/axis.rb +86 -0
  21. data/app/helpers/rcharts/graph_helper/graph/calculator.rb +91 -0
  22. data/app/helpers/rcharts/graph_helper/graph/composition.rb +35 -0
  23. data/app/helpers/rcharts/graph_helper/graph/options.rb +33 -0
  24. data/app/helpers/rcharts/graph_helper/graph.rb +8 -0
  25. data/app/helpers/rcharts/graph_helper/graph_builder.rb +270 -0
  26. data/app/helpers/rcharts/graph_helper/legend_entry_builder.rb +46 -0
  27. data/app/helpers/rcharts/graph_helper/rule_element.rb +68 -0
  28. data/app/helpers/rcharts/graph_helper/series/area_element.rb +50 -0
  29. data/app/helpers/rcharts/graph_helper/series/path.rb +153 -0
  30. data/app/helpers/rcharts/graph_helper/series/path_element.rb +72 -0
  31. data/app/helpers/rcharts/graph_helper/series/point.rb +58 -0
  32. data/app/helpers/rcharts/graph_helper/series/scatter_element.rb +87 -0
  33. data/app/helpers/rcharts/graph_helper/series/series_builder.rb +145 -0
  34. data/app/helpers/rcharts/graph_helper/tooltips/entry_builder.rb +47 -0
  35. data/app/helpers/rcharts/graph_helper/tooltips/foreign_object_element.rb +84 -0
  36. data/app/helpers/rcharts/graph_helper/tooltips/hover_target_element.rb +39 -0
  37. data/app/helpers/rcharts/graph_helper/tooltips/marker_element.rb +38 -0
  38. data/app/helpers/rcharts/graph_helper/tooltips/tooltip_builder.rb +44 -0
  39. data/app/helpers/rcharts/graph_helper/tooltips/tooltip_element.rb +45 -0
  40. data/app/helpers/rcharts/graph_helper.rb +249 -0
  41. data/lib/generators/rcharts/install/install_generator.rb +13 -0
  42. data/lib/generators/rcharts/install/templates/rcharts.css +392 -0
  43. data/lib/rcharts/engine.rb +25 -0
  44. data/lib/rcharts/percentage.rb +36 -0
  45. data/lib/rcharts/type/percentage.rb +20 -0
  46. data/lib/rcharts/type/symbol.rb +29 -0
  47. data/lib/rcharts/type.rb +9 -0
  48. data/lib/rcharts/version.rb +6 -0
  49. data/lib/rcharts.rb +52 -0
  50. data/lib/tasks/rcharts_tasks.rake +6 -0
  51. 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