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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 256aeff9d84f681b225b1a35cd613367c65e2406caf8ba1815214e690dc462a1
4
+ data.tar.gz: a2de1b9873a98beaa5e7d0cec25042a8a59d36ed6998d9f8005f48f9eb20e4c3
5
+ SHA512:
6
+ metadata.gz: 531cd70794162b29f5b87c79ef64ea87ceb43f6b60d5f2967dfff5cbb723d9315084615bb353324fdc80c25d3f6dffea42913ac1254dd48ff89aba0b49cd80c2
7
+ data.tar.gz: 907ab23d72f376f6c8f15584ce4e54bf0386d7e2d85affb9fa0576ec01374866c04db8bd0a1c1afad98ede4b94f7ddfc6f698bad1e65889fdc866d38b9786aff
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Justin Malčić
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # RCharts
2
+
3
+ RCharts is a charting library for Action View which produces JS-agnostic SVG charts.
4
+
5
+ In the time since popular JS charting libraries were created, significant advances in CSS have made it possible
6
+ to use the browser's rendering engine for more and more things where JS was previously unavoidable.
7
+ Rather than starting from the assumption that client-side rendering in JS is inevitable for charts, RCharts builds
8
+ on the premise that decent baseline functionality is now possible just with server-side rendering.
9
+
10
+ Being able to lean on the browser's rendering engine results in visibly improved client-side rendering performance
11
+ and eliminates the scope for undesirable interactions with other rendering in JS. This makes it possible to layer
12
+ interactivity on top of charts much more reliably, because the chart is no different from the rest of the page.
13
+ One possible progressive enhancement would be panning and zooming, using a Stimulus controller to send a request
14
+ to the server and reload the chart with different parameters.
15
+
16
+ RCharts was conceived specifically with Turbo morphing in mind. Because morphing is able to update pages and frames
17
+ at the level of individual attributes, when CSS transitions are added to SVG elements, properties determining placement
18
+ and size of elements can be animated like any other. Like other styling, you can control all aspects of the transitions
19
+ using CSS, by overriding variables in the stylesheet provided or by using your own styles.
20
+
21
+ To be useful to a wider audience, charting libraries need to be flexible enough to cover a wide range of use cases.
22
+ RCharts aims to provide a comprehensive set of features that cover the most common use cases, with more to come.
23
+ It currently supports:
24
+
25
+ * Different chart types
26
+ * Line charts
27
+ * Bar charts
28
+ * Horizontal bar charts
29
+ * Area charts
30
+ * Scatter plots
31
+ * Combinations of the above
32
+ * Proper value handling
33
+ * Stacked area and bar charts
34
+ * Smoothing for line and area charts (you can choose by how much but not the method)
35
+ * Negative values (e.g. with split series for stacked area charts)
36
+ * Masks for missing values
37
+ * Various axis options
38
+ * Automatic axis ticks based on the type and values of provided data
39
+ * Axis labels formatted however you want
40
+ * Axis labels rotation and hiding to avoid overlap
41
+ * Axis titles
42
+ * Multiple parallel axes based on potentially different data
43
+ * Tooltips and legends
44
+ * Tooltips containing whatever you want
45
+ * Legend with placement in all directions
46
+ * Custom series symbols and colours
47
+ * Sparklines, by only rendering the chart itself
48
+ * CSS that you can customise to your liking, both by setting variables and/or using your own styles
49
+
50
+ ## Usage
51
+
52
+ The API is inspired by the venerable `ActionView::Helpers::FormBuilder`.
53
+
54
+ To get started, take a look at the WIP docs for `RCharts::GraphHelper`, and also run the dummy app which shows
55
+ the different chart types and how to combine them with morphing.
56
+
57
+ ```bash
58
+ $ git clone "https://github.com/BuildingAtlas/rcharts.git"
59
+ $ cd rcharts/test/dummy
60
+ $ rails s
61
+ ```
62
+
63
+ ## Installation
64
+
65
+ Add this line to your application's Gemfile:
66
+
67
+ ```ruby
68
+ gem 'rcharts'
69
+ ```
70
+
71
+ And then execute:
72
+
73
+ ```bash
74
+ $ bundle
75
+ ```
76
+
77
+ Install the stylesheet:
78
+
79
+ ```bash
80
+ $ rails rcharts:install
81
+ ```
82
+
83
+ Finally, include the helper in `ApplicationHelper`:
84
+
85
+ ```ruby
86
+ # app/helpers/application_helper.rb
87
+
88
+ module ApplicationHelper
89
+ include RCharts::GraphHelper
90
+ end
91
+ ```
92
+
93
+ ## Contributing
94
+
95
+ Contributions are welcome, but please open an issue first to discuss what you would like to add or change.
96
+
97
+ ## License
98
+
99
+ The gem is available as open source under the terms of the [MIT Licence](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
6
+ load 'rails/tasks/engine.rake'
7
+
8
+ require 'bundler/gem_tasks'
9
+
10
+ require 'sdoc'
11
+ require 'rdoc/task'
12
+
13
+ RDoc::Task.new do |rdoc|
14
+ rdoc.rdoc_files.include('README.md', 'app/**/*.rb', 'lib/**/*.rb')
15
+ rdoc.rdoc_dir = 'doc'
16
+ rdoc.options << '--format=sdoc'
17
+ rdoc.options << '--github'
18
+ rdoc.options << '--title=RCharts'
19
+ rdoc.options << '--main=README.md'
20
+ rdoc.template = 'rails'
21
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Axes
6
+ class AxisElement
7
+ module Styles # :nodoc:
8
+ DEFAULT_BREAKPOINTS = { hiding: { even: 1.1, odd: 0.6 }, rotation: { half: 1.0, full: 0.9 } }.freeze
9
+
10
+ extend ActiveSupport::Concern
11
+
12
+ ROTATION_CSS = <<~CSS
13
+ @container (inline-size < calc(<%= min_row_characters %> * <%= half_rotation_breakpoint %>)) {
14
+ .rcharts-chart .axis[data-name="x"] {
15
+ .axis-ticks[data-axis="<%= axis_id %>"] .axis-tick-text {
16
+ dominant-baseline: middle;
17
+ rotate: -45deg;
18
+ text-anchor: end;
19
+ }
20
+ .axis-ticks[data-axis="<%= axis_id %>"] {
21
+ height: calc(<%= max_label_characters %> * sin(45deg));
22
+ }
23
+ }
24
+ }
25
+
26
+ @container (inline-size < calc(<%= min_row_characters %> * <%= full_rotation_breakpoint %>)) {
27
+ .rcharts-chart .axis[data-name="x"] {
28
+ .axis-ticks[data-axis="<%= axis_id %>"] .axis-tick-text {
29
+ rotate: -90deg;
30
+ }
31
+ .axis-ticks[data-axis="<%= axis_id %>"] {
32
+ height: <%= max_label_characters %>;
33
+ }
34
+ }
35
+ }
36
+ CSS
37
+
38
+ HIDING_CSS = <<~CSS
39
+ @container (inline-size < calc(<%= tick_count %> * <%= even_hiding_breakpoint %>)) {
40
+ .rcharts-chart .axis[data-name="x"] {
41
+ .axis-ticks[data-axis="<%= axis_id %>"] .axis-tick:nth-child(even) .axis-tick-text {
42
+ opacity: 0;
43
+ }
44
+ }
45
+ }
46
+
47
+ @container (inline-size < calc(<%= tick_count %> * <%= odd_hiding_breakpoint %>)) {
48
+ .rcharts-chart .axis[data-name="x"] {
49
+ .axis-ticks[data-axis="<%= axis_id %>"] .axis-tick:nth-child(4n + 3) .axis-tick-text {
50
+ opacity: 0;
51
+ }
52
+ }
53
+ }
54
+ CSS
55
+
56
+ included do
57
+ attribute :breakpoints, default: -> { {} }
58
+
59
+ private
60
+
61
+ def rendered_css
62
+ return unless horizontal?
63
+
64
+ ERB.new(ROTATION_CSS + HIDING_CSS)
65
+ .result_with_hash(min_row_characters: "#{min_row_characters}ch",
66
+ max_label_characters: "#{max_label_characters}ch",
67
+ tick_count: "#{tick_count}lh",
68
+ **breakpoints,
69
+ axis_id:)
70
+ .html_safe # rubocop:disable Rails/OutputSafety
71
+ end
72
+
73
+ def breakpoints
74
+ flatten_breakpoints(DEFAULT_BREAKPOINTS.deep_merge(super))
75
+ end
76
+
77
+ def flatten_breakpoints(hash)
78
+ hash.each_with_object({}) do |(key, value), result|
79
+ next result[key] = value unless value.is_a?(Hash)
80
+
81
+ value.each do |nested_key, nested_value|
82
+ result[:"#{nested_key}_#{key}_breakpoint"] = nested_value
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Axes
6
+ class AxisElement < Element # :nodoc:
7
+ include Styles
8
+
9
+ attribute :name
10
+ attribute :index, :integer, default: 0
11
+ attribute :ticks, default: -> { {} }
12
+ attribute :label
13
+ attribute :horizontal, :boolean, default: false
14
+ attribute :character_scaling_factor, :float, default: 1.0
15
+
16
+ private
17
+
18
+ attr_accessor :max_label_characters
19
+
20
+ alias horizontal? horizontal
21
+
22
+ def tags(&block)
23
+ with_max_label_characters_from block do
24
+ style_tag + container_tag(&block)
25
+ end
26
+ end
27
+
28
+ def container_tag(&)
29
+ tag.div class: 'axis', data: { name:, index: } do
30
+ svg_tag(&) + label_tag
31
+ end
32
+ end
33
+
34
+ def svg_tag(&)
35
+ tag.svg class: 'axis-ticks', width: max_column_characters.try { "#{it * character_scaling_factor}ch" },
36
+ data: { axis: axis_id } do
37
+ ticks.each do |position, value|
38
+ concat tick_tag_for(position, value, &)
39
+ end
40
+ end
41
+ end
42
+
43
+ def label_tag
44
+ return ''.html_safe unless label
45
+
46
+ render LabelElement.new(label:, horizontal: horizontal?)
47
+ end
48
+
49
+ def tick_tag_for(position, value, &)
50
+ render TickElement.new(inline_axis: name, inline_axis_index: index,
51
+ value:, position:), &
52
+ end
53
+
54
+ def style_tag
55
+ tag.style rendered_css
56
+ end
57
+
58
+ def max_column_characters
59
+ return if horizontal?
60
+
61
+ max_label_characters
62
+ end
63
+
64
+ def min_row_characters
65
+ max_label_characters.to_i * tick_count
66
+ end
67
+
68
+ def with_max_label_characters_from(proc, &block)
69
+ self.max_label_characters = ticks.values
70
+ .collect { |value| capture_length { proc&.call(value) || value.to_s } }
71
+ .max
72
+ block&.call.tap { self.max_label_characters = nil }
73
+ end
74
+
75
+ def capture_length(&)
76
+ strip_tags(capture(&)).squish.length
77
+ end
78
+
79
+ def axis_id
80
+ hash.abs
81
+ end
82
+
83
+ def tick_count
84
+ ticks.count + 1
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Axes
6
+ class LabelElement < Element # :nodoc:
7
+ attribute :label
8
+ attribute :horizontal, :boolean
9
+
10
+ private
11
+
12
+ alias horizontal? horizontal
13
+
14
+ def tags
15
+ tag.svg class: 'axis-label', overflow: 'visible', width:, height: do
16
+ tag.svg x: '50%', y: '50%', overflow: 'visible' do
17
+ tag.text label, class: 'axis-label-text'
18
+ end
19
+ end
20
+ end
21
+
22
+ def width
23
+ horizontal? ? Percentage::MAX : '1lh'
24
+ end
25
+
26
+ def height
27
+ horizontal? ? '1lh' : Percentage::MAX
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Axes
6
+ class TickElement < Element # :nodoc:
7
+ attribute :value
8
+ attribute :position, :'rcharts/percentage', default: Percentage::MIN
9
+ attribute :inline_axis, :'rcharts/symbol'
10
+ attribute :inline_axis_index, :integer, default: 0
11
+
12
+ private
13
+
14
+ def tags(&block)
15
+ tag.svg class: 'axis-tick', x:, y: do
16
+ tag.text class: 'axis-tick-text', data: { inline_axis:, inline_axis_index: } do
17
+ block&.call(value) || value
18
+ end
19
+ end
20
+ end
21
+
22
+ def x
23
+ case inline_axis
24
+ when :x then position
25
+ else inline_axis_index.zero? ? Percentage::MAX : Percentage::MIN
26
+ end
27
+ end
28
+
29
+ def y
30
+ case inline_axis
31
+ when :x then Percentage::MIN
32
+ else Percentage::MAX - position
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Axes # :nodoc:
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Categories
6
+ # = Bar Builder
7
+ class BarBuilder < ElementBuilder
8
+ attribute :name
9
+ attribute :inline_size, :'rcharts/percentage', default: Percentage::MAX
10
+ attribute :block_size, :'rcharts/percentage', default: Percentage::MAX
11
+ attribute :block_position, :'rcharts/percentage', default: Percentage::MIN
12
+ attribute :inline_index, :integer, default: 0
13
+ attribute :series_index, :integer, default: 0
14
+ attribute :horizontal, :boolean, default: false
15
+ attribute :series_options, default: -> { {} }
16
+
17
+ # Renders a bar segment. Passes through <tt>:data</tt>, and <tt>:aria</tt>, and <tt>:class</tt> options to the tag builder.
18
+ # <%= graph_for @annual_sales do |graph| %>
19
+ # <%= graph.category do |category| %>
20
+ # <%= category.series do |series| %>
21
+ # <%= series.bar %>
22
+ # <% end %>
23
+ # <% end %>
24
+ # <% end %>
25
+ def bar(**)
26
+ render BarSegmentElement.new(inline_size:, block_size:, block_position:, inline_index:, series_index:,
27
+ horizontal:, series_options:, **)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Categories
6
+ class BarSegmentElement < Element # :nodoc:
7
+ attribute :horizontal, :boolean, default: false
8
+ attribute :inline_size, :'rcharts/percentage', default: Percentage::MAX
9
+ attribute :block_size, :'rcharts/percentage', default: Percentage::MAX
10
+ attribute :block_position, :'rcharts/percentage', default: Percentage::MIN
11
+ attribute :inline_index, :integer, default: 0
12
+ attribute :series_index, :integer, default: 0
13
+ attribute :series_options, default: -> { {} }
14
+
15
+ private
16
+
17
+ alias horizontal? horizontal
18
+
19
+ def tags
20
+ tag.rect x:, y:, height:, width:, class: ['series-shape', class_names, color_class], data:, aria:
21
+ end
22
+
23
+ def x
24
+ horizontal? ? block_position : inline_position
25
+ end
26
+
27
+ def y
28
+ horizontal? ? inline_position : Percentage::MAX - block_position
29
+ end
30
+
31
+ def height
32
+ horizontal? ? inline_size : block_size
33
+ end
34
+
35
+ def width
36
+ horizontal? ? block_size : inline_size
37
+ end
38
+
39
+ def inline_position
40
+ inline_size * inline_index
41
+ end
42
+
43
+ def color_class
44
+ series_options.fetch(:color_class, RCharts.color_class_for(series_index))
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Categories
6
+ class BarsElement < Element # :nodoc:
7
+ SPACING_FACTOR = 0.6
8
+
9
+ attribute :index, :integer, default: 0
10
+ attribute :values_count, :integer, default: 1
11
+ attribute :inline_position, :'rcharts/percentage', default: Percentage::MIN
12
+ attribute :horizontal, :boolean, default: false
13
+
14
+ private
15
+
16
+ alias horizontal? horizontal
17
+
18
+ def tags(&)
19
+ tag.svg x:, y:, width:, height:, class: 'category', &
20
+ end
21
+
22
+ def x
23
+ adjusted_inline_position if horizontal?
24
+ end
25
+
26
+ def y
27
+ Percentage::MAX - adjusted_inline_position unless horizontal?
28
+ end
29
+
30
+ def height
31
+ horizontal? ? Percentage::MAX : inline_size
32
+ end
33
+
34
+ def width
35
+ horizontal? ? inline_size : Percentage::MAX
36
+ end
37
+
38
+ def inline_size
39
+ (Percentage::MAX / values_count) * SPACING_FACTOR
40
+ end
41
+
42
+ def adjusted_inline_position
43
+ inline_position + inline_adjustment
44
+ end
45
+
46
+ def inline_adjustment
47
+ (inline_size / 2) * (horizontal? ? -1 : 1)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCharts
4
+ module GraphHelper
5
+ module Categories
6
+ # = Category Builder
7
+ class CategoryBuilder < ElementBuilder
8
+ ##
9
+ # :attr_accessor:
10
+ attribute :name
11
+
12
+ ##
13
+ # :attr_accessor: index
14
+ attribute :index, :integer, default: 0
15
+
16
+ attribute :category, default: -> { {} }
17
+ attribute :values_count, :integer, default: 1
18
+ attribute :layout_axes, default: -> { Graph::Axes.new }
19
+ attribute :series_options, default: -> { {} }
20
+
21
+ # Shortcut to render a bar for every series. Useful when you don't need to differentiate between different
22
+ # series. Arguments and options are passed to #series, see there for more details.
23
+ def bar(*, **)
24
+ series(*, **, &:bar)
25
+ end
26
+
27
+ # Renders one or more series present in the data. For each series yields a BarBuilder which
28
+ # contains the series:
29
+ # <%= graph_for @annual_sales do |graph| %>
30
+ # <%= graph.category do |category| %>
31
+ # <%= category.series do |series| %>
32
+ # <%= series.bar %>
33
+ # <% end %>
34
+ # <% end %>
35
+ # <% end %>
36
+ # The default is to iterate over all series, but you can specify a subset by passing the series names as
37
+ # arguments.
38
+ #
39
+ # ==== Options
40
+ # [<tt>:axis</tt>] The axis for the series. Defaults to the first continuous axis.
41
+ # [<tt>:inline_axis</tt>] The axis for the categories. Defaults to the first discrete axis.
42
+ def series(*names, axis: nil, inline_axis: nil, **, &)
43
+ bars_tag(axis: inline_axis) do
44
+ filtered_series(*names).each_with_index do |(name, value), index|
45
+ concat bar_for(name, value, index, filtered_series(*names)&.count, axis:, **, &)
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def bars_tag(axis: nil, **, &)
53
+ resolve_axis :discrete, axis do |discrete_axis|
54
+ render BarsElement.new(index:, values_count:, inline_position: discrete_axis.position_at(index),
55
+ horizontal: discrete_axis.horizontal?, **), &
56
+ end
57
+ end
58
+
59
+ def bar_for(name, value, index, series_count, axis: nil, **, &) # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
60
+ resolve_axis :continuous, axis do |continuous_axis|
61
+ render BarBuilder.new(name:, inline_index: (continuous_axis.stacked? ? 0 : index),
62
+ series_index: category.keys.index(name),
63
+ inline_size: inline_size_for(continuous_axis, series_count),
64
+ series_options: series_options.fetch(name, {}),
65
+ horizontal: continuous_axis.horizontal?,
66
+ block_size: continuous_axis.length_between(0, value.to_f),
67
+ block_position: block_position_for(continuous_axis, name, value),
68
+ **),
69
+ &
70
+ end
71
+ end
72
+
73
+ def inline_size_for(continuous_axis, series_count = nil)
74
+ Percentage::MAX / (continuous_axis.stacked? ? 1 : series_count || category.keys.count)
75
+ end
76
+
77
+ def block_position_for(continuous_axis, name, value)
78
+ continuous_axis.position_for(adjusted_value_for(continuous_axis, name, value))
79
+ end
80
+
81
+ def adjusted_value_for(continuous_axis, name, value)
82
+ previous_value_for(name, continuous_axis).to_f + value_offset_for(continuous_axis, value)
83
+ end
84
+
85
+ def value_offset_for(axis, value)
86
+ (axis.horizontal? && value.to_f.positive?) || (!axis.horizontal? && value.to_f.negative?) ? 0 : value.to_f
87
+ end
88
+
89
+ def previous_value_for(key, continuous_axis)
90
+ index_of(key).then do |index|
91
+ return 0 if !continuous_axis.stacked? || index.zero?
92
+
93
+ filtered_series.values
94
+ .slice(0...index)
95
+ .compact
96
+ .filter(&(filtered_series.values[index].to_f.positive? ? :positive? : :negative?))
97
+ .sum
98
+ end
99
+ end
100
+
101
+ def index_of(key)
102
+ filtered_series.keys.index(key)
103
+ end
104
+
105
+ def filtered_series(*names)
106
+ category.reject { names.presence&.exclude?(it) }
107
+ end
108
+
109
+ def resolve_axis(type, name = nil, &)
110
+ (name ? layout_axes.fetch(*name) : layout_axes.public_send(type)).then(&)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end