charty 0.2.1 → 0.2.7

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +56 -23
  3. data/.github/workflows/nmatrix.yml +67 -0
  4. data/.github/workflows/pycall.yml +86 -0
  5. data/Dockerfile.dev +9 -1
  6. data/Gemfile +18 -0
  7. data/README.md +177 -9
  8. data/Rakefile +4 -5
  9. data/charty.gemspec +10 -5
  10. data/examples/palette.rb +1 -1
  11. data/examples/sample_images/hist_gruff.png +0 -0
  12. data/images/penguins_body_mass_g_flipper_length_mm_scatter_plot.png +0 -0
  13. data/images/penguins_body_mass_g_flipper_length_mm_species_scatter_plot.png +0 -0
  14. data/images/penguins_body_mass_g_flipper_length_mm_species_sex_scatter_plot.png +0 -0
  15. data/images/penguins_species_body_mass_g_bar_plot_h.png +0 -0
  16. data/images/penguins_species_body_mass_g_bar_plot_v.png +0 -0
  17. data/images/penguins_species_body_mass_g_box_plot_h.png +0 -0
  18. data/images/penguins_species_body_mass_g_box_plot_v.png +0 -0
  19. data/images/penguins_species_body_mass_g_sex_bar_plot_v.png +0 -0
  20. data/images/penguins_species_body_mass_g_sex_box_plot_v.png +0 -0
  21. data/lib/charty.rb +9 -2
  22. data/lib/charty/backends.rb +1 -0
  23. data/lib/charty/backends/bokeh.rb +2 -2
  24. data/lib/charty/backends/google_charts.rb +1 -1
  25. data/lib/charty/backends/gruff.rb +14 -3
  26. data/lib/charty/backends/plotly.rb +731 -32
  27. data/lib/charty/backends/plotly_helpers/html_renderer.rb +203 -0
  28. data/lib/charty/backends/plotly_helpers/notebook_renderer.rb +86 -0
  29. data/lib/charty/backends/plotly_helpers/plotly_renderer.rb +121 -0
  30. data/lib/charty/backends/pyplot.rb +515 -67
  31. data/lib/charty/backends/rubyplot.rb +1 -1
  32. data/lib/charty/backends/unicode_plot.rb +79 -0
  33. data/lib/charty/cache_dir.rb +27 -0
  34. data/lib/charty/dash_pattern_generator.rb +57 -0
  35. data/lib/charty/index.rb +213 -0
  36. data/lib/charty/iruby_helper.rb +18 -0
  37. data/lib/charty/linspace.rb +1 -1
  38. data/lib/charty/plot_methods.rb +283 -8
  39. data/lib/charty/plotter.rb +2 -2
  40. data/lib/charty/plotters.rb +11 -0
  41. data/lib/charty/plotters/abstract_plotter.rb +188 -18
  42. data/lib/charty/plotters/bar_plotter.rb +189 -7
  43. data/lib/charty/plotters/box_plotter.rb +64 -11
  44. data/lib/charty/plotters/categorical_plotter.rb +272 -40
  45. data/lib/charty/plotters/count_plotter.rb +7 -0
  46. data/lib/charty/plotters/distribution_plotter.rb +143 -0
  47. data/lib/charty/plotters/estimation_support.rb +84 -0
  48. data/lib/charty/plotters/histogram_plotter.rb +182 -0
  49. data/lib/charty/plotters/line_plotter.rb +300 -0
  50. data/lib/charty/plotters/random_support.rb +25 -0
  51. data/lib/charty/plotters/relational_plotter.rb +635 -0
  52. data/lib/charty/plotters/scatter_plotter.rb +80 -0
  53. data/lib/charty/plotters/vector_plotter.rb +6 -0
  54. data/lib/charty/statistics.rb +96 -2
  55. data/lib/charty/table.rb +160 -15
  56. data/lib/charty/table_adapters.rb +2 -0
  57. data/lib/charty/table_adapters/active_record_adapter.rb +17 -9
  58. data/lib/charty/table_adapters/base_adapter.rb +166 -0
  59. data/lib/charty/table_adapters/daru_adapter.rb +39 -3
  60. data/lib/charty/table_adapters/datasets_adapter.rb +13 -2
  61. data/lib/charty/table_adapters/hash_adapter.rb +141 -16
  62. data/lib/charty/table_adapters/narray_adapter.rb +25 -6
  63. data/lib/charty/table_adapters/nmatrix_adapter.rb +15 -5
  64. data/lib/charty/table_adapters/pandas_adapter.rb +163 -0
  65. data/lib/charty/util.rb +28 -0
  66. data/lib/charty/vector.rb +69 -0
  67. data/lib/charty/vector_adapters.rb +187 -0
  68. data/lib/charty/vector_adapters/array_adapter.rb +101 -0
  69. data/lib/charty/vector_adapters/daru_adapter.rb +163 -0
  70. data/lib/charty/vector_adapters/narray_adapter.rb +182 -0
  71. data/lib/charty/vector_adapters/nmatrix_adapter.rb +37 -0
  72. data/lib/charty/vector_adapters/numpy_adapter.rb +168 -0
  73. data/lib/charty/vector_adapters/pandas_adapter.rb +199 -0
  74. data/lib/charty/version.rb +1 -1
  75. metadata +105 -24
  76. data/lib/charty/palette.rb +0 -235
@@ -0,0 +1,203 @@
1
+ require "datasets/downloader"
2
+ require "json"
3
+ require "securerandom"
4
+
5
+ module Charty
6
+ module Backends
7
+ module PlotlyHelpers
8
+ class HtmlRenderer
9
+ def initialize(use_cdn: true,
10
+ full_html: false,
11
+ requirejs: true)
12
+ @use_cdn = use_cdn
13
+ @full_html = full_html
14
+ @requirejs = requirejs
15
+ end
16
+
17
+ PLOTLY_URL = "https://plot.ly".freeze
18
+ PLOTLY_LATEST_CDN_URL = "https://cdn.plot.ly/plotly-latest.min.js".freeze
19
+ MATHJAX_CDN_URL = ("https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js").freeze
20
+
21
+ DEFAULT_WIDTH = "100%".freeze
22
+ DEFAULT_HEIGHT = 525
23
+
24
+ def render(figure, element_id: nil, post_script: nil)
25
+ element_id = SecureRandom.uuid if element_id.nil?
26
+ plotly_html_div = build_plotly_html_div(figure, element_id, post_script)
27
+
28
+ if @full_html
29
+ <<~END_HTML % {div: plotly_html_div}
30
+ <!DOCTYPE html>
31
+ <html>
32
+ <head><meta charset="utf-8" /></head>
33
+ <body>
34
+ %{div}
35
+ </body>
36
+ </html>
37
+ END_HTML
38
+ else
39
+ plotly_html_div
40
+ end
41
+ end
42
+
43
+ private def build_plotly_html_div(figure, element_id, post_script)
44
+ layout = figure.fetch(:layout, {})
45
+
46
+ json_data = JSON.dump(figure.fetch(:data, []))
47
+ json_layout = JSON.dump(layout)
48
+ json_frames = JSON.dump(figure[:frames]) if figure.key?(:frames)
49
+
50
+ # TODO: config and responsive support
51
+
52
+ template = layout.fetch(:template, {}).fetch(:layout, {})
53
+ div_width = layout.fetch(:width, template.fetch(:width, DEFAULT_WIDTH))
54
+ div_height = layout.fetch(:height, template.fetch(:height, DEFAULT_HEIGHT))
55
+
56
+ div_width = "#{div_width}px" if Float(div_width, exception: false)
57
+ div_height = "#{div_height}px" if Float(div_height, exception: false)
58
+
59
+ # TODO: showLink and showSendToCloud support
60
+ base_url_line = "window.PLOTLYENV.BASE_URL = '%{url}';" % {url: PLOTLY_URL}
61
+
62
+ ## build script body
63
+
64
+ # TODO: post_script support
65
+ then_post_script = ""
66
+ if post_script
67
+ ary = Array.try_convert(post_script)
68
+ post_script = ary || [post_script]
69
+ post_script.each do |ps|
70
+ next if ps.nil?
71
+ then_post_script << '.then(function(){ %{post_script} })' % {
72
+ post_script: ps % {plot_id: element_id}
73
+ }
74
+ end
75
+ end
76
+
77
+ then_addframes = ""
78
+ then_animate = ""
79
+ if json_frames
80
+ then_addframes = <<~END_ADDFRAMES % {id: element_id, frames: json_frames}
81
+ .then(function(){
82
+ Plotly.addFrames('%{id}', {frames});
83
+ })
84
+ END_ADDFRAMES
85
+
86
+ # TODO: auto_play support
87
+ end
88
+
89
+ json_config = JSON.dump({}) # TODO: config support
90
+
91
+ script = <<~END_SCRIPT
92
+ if (document.getElementById("%{id}")) {
93
+ Plotly.newPlot("%{id}", %{data}, %{layout}, %{config})%{then_addframes}%{then_animate}%{then_post_script};
94
+ }
95
+ END_SCRIPT
96
+ script = script % {
97
+ id: element_id,
98
+ data: json_data,
99
+ layout: json_layout,
100
+ config: json_config,
101
+ then_addframes: then_addframes,
102
+ then_animate: then_animate,
103
+ then_post_script: then_post_script
104
+ }
105
+
106
+ ## Handle loading/initializing plotlyjs
107
+
108
+ case
109
+ when @requirejs
110
+ include_plotlyjs = :require
111
+ include_mathjax = false
112
+ when @use_cdn
113
+ include_plotlyjs = :cdn
114
+ include_mathjax = :cdn
115
+ else
116
+ include_plotlyjs = true
117
+ include_mathjax = :cdn
118
+ end
119
+
120
+ case include_plotlyjs
121
+ when :require
122
+ require_start = 'require(["plotly"], function (Plotly) {'
123
+ require_end = '});'
124
+ when :cdn
125
+ load_plotlyjs = <<~END_LOAD_PLOTLYJS % {win_config: window_plotly_config, url: PLOTLY_LATEST_CDN_URL}
126
+ %{win_config}
127
+ <script src="%{url}"></script>
128
+ END_LOAD_PLOTLYJS
129
+ when true
130
+ load_plotlyjs = <<~END_LOAD_PLOTLYJS % {win_config: window_plotly_config, script: get_plotlyjs}
131
+ %{win_config}
132
+ <script type="text/javascript">%{script}</script>
133
+ END_LOAD_PLOTLYJS
134
+ end
135
+
136
+ ## Handle loading/initializing MathJax
137
+
138
+ mathjax_tmplate = %Q[<script src="%{url}?config=TeX-AMS-MML_SVG"></script>]
139
+ case include_mathjax
140
+ when :cdn
141
+ mathjax_script = mathjax_tmplate % {url: MATHJAX_CDN_URL}
142
+ mathjax_script << <<~END_SCRIPT % {mathjax_config: mathjax_config}
143
+ <script type="text/javascript">%{mathjax_config}</script>
144
+ END_SCRIPT
145
+ else
146
+ mathjax_script = ""
147
+ end
148
+
149
+ div_template = <<~END_DIV
150
+ <div>
151
+ %{mathjax_script}
152
+ %{load_plotlyjs}
153
+ <div id="%{id}" class="plotly-graph-div" style="height: %{height}; width: %{width};"></div>
154
+ <script type="text/javascript">
155
+ %{require_start}
156
+ window.PLOTLYENV = window.PLOTLYENV || {};
157
+ %{base_url_line}
158
+ %{script}
159
+ %{require_end}
160
+ </script>
161
+ </div>
162
+ END_DIV
163
+
164
+ plotly_html_div = div_template % {
165
+ mathjax_script: mathjax_script,
166
+ load_plotlyjs: load_plotlyjs,
167
+ id: element_id,
168
+ height: div_height,
169
+ width: div_width,
170
+ require_start: require_start,
171
+ base_url_line: base_url_line,
172
+ script: script,
173
+ require_end: require_end
174
+ }
175
+ plotly_html_div.strip!
176
+
177
+ plotly_html_div
178
+ end
179
+
180
+ private def window_plotly_config
181
+ %Q(window.PlotlyConfig = {MathJaxConfig: 'local'};)
182
+ end
183
+
184
+ private def mathjax_config
185
+ %Q(if (window.MathJax) { MathJax.Hub.Config({SVG: {font: "STIX-Web"}}); })
186
+ end
187
+
188
+ private def get_plotlyjs
189
+ cache_path = CacheDir.path("plotly.min.js")
190
+ unless cache_path.exist?
191
+ download_plotlyjs(cache_path)
192
+ end
193
+ cache_path.read
194
+ end
195
+
196
+ private def download_plotlyjs(output_path)
197
+ downloader = Datasets::Downloader.new(PLOTLY_LATEST_CDN_URL)
198
+ downloader.download(output_path)
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,86 @@
1
+ module Charty
2
+ module Backends
3
+ module PlotlyHelpers
4
+ class NotebookRenderer < HtmlRenderer
5
+ def initialize(use_cdn: false)
6
+ super(use_cdn: use_cdn, full_html: false, requirejs: true)
7
+ @initialized = false
8
+ end
9
+
10
+ def activate()
11
+ return if @initialized
12
+
13
+ unless IRubyHelper.iruby_notebook?
14
+ raise "IRuby is unavailable"
15
+ end
16
+
17
+ if @use_cdn
18
+ script = <<~END_SCRIPT % {win_config: window_plotly_config, mathjax_config: mathjax_config}
19
+ <script type="text/javascript">
20
+ %{win_config}
21
+ %{mathjax_config}
22
+ if (typeof require !== 'undefined') {
23
+ require.undef("plotly");
24
+ requirejs.config({
25
+ paths: {
26
+ 'plotly': ['https://cdn.plot.ly/plotly-latest.min']
27
+ }
28
+ });
29
+ require(['plotly'], function (Plotly) {
30
+ window._Plotly = Plotly;
31
+ });
32
+ }
33
+ </script>
34
+ END_SCRIPT
35
+ else
36
+ script = <<~END_SCRIPT % {script: get_plotlyjs, win_config: window_plotly_config, mathjax_config: mathjax_config}
37
+ <script type="text/javascript">
38
+ %{win_config}
39
+ %{mathjax_config}
40
+ if (typeof require !== 'undefined') {
41
+ require.undef("plotly");
42
+ define('plotly', function (require, exports, module) {
43
+ %{script}
44
+ });
45
+ require(['plotly'], function (Plotly) {
46
+ window._Plotly = Plotly;
47
+ });
48
+ }
49
+ </script>
50
+ END_SCRIPT
51
+ end
52
+ end
53
+
54
+ def render(figure, element_id: nil, post_script: nil)
55
+ ary = Array.try_convert(post_script)
56
+ post_script = ary || [post_script]
57
+ post_script.unshift(<<~END_POST_SCRIPT)
58
+ var gd = document.getElementById('%{plot_id}');
59
+ var x = new MutationObserver(function (mutations, observer) {
60
+ var display = window.getComputedStyle(gd).display;
61
+ if (!display || display === 'none') {
62
+ console.log([gd, 'removed']);
63
+ Plotly.purge(gd);
64
+ observer.disconnect();
65
+ }
66
+ });
67
+
68
+ // Listen for the removal of the full notebook cell
69
+ var notebookContainer = gd.closest('#notebook-container');
70
+ if (notebookContainer) {
71
+ x.observe(notebookContainer, {childList: true});
72
+ }
73
+
74
+ // Listen for the clearing of the current output cell
75
+ var outputEl = gd.closest('.output');
76
+ if (outputEl) {
77
+ x.observe(outputEl, {childList: true});
78
+ }
79
+ END_POST_SCRIPT
80
+
81
+ super(figure, element_id: element_id, post_script: post_script)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,121 @@
1
+ require "date"
2
+ require "json"
3
+ require "time"
4
+
5
+ module Charty
6
+ module Backends
7
+ module PlotlyHelpers
8
+ class PlotlyRenderer
9
+ def render(figure)
10
+ json = JSON.generate(figure, allow_nan: true)
11
+ case json
12
+ when /\b(?:Infinity|NaN)\b/
13
+ visit(figure)
14
+ else
15
+ JSON.load(json)
16
+ end
17
+ end
18
+
19
+ private def visit(obj)
20
+ case obj
21
+ when Integer, String, Symbol, true, false, nil
22
+ obj
23
+
24
+ when Numeric
25
+ visit_float(obj)
26
+
27
+ when Time
28
+ visit_time(obj)
29
+
30
+ when Date
31
+ visit_date(obj)
32
+
33
+ when DateTime
34
+ visit_datetime(obj)
35
+
36
+ when Array
37
+ visit_array(obj)
38
+
39
+ when Hash
40
+ visit_hash(obj)
41
+
42
+ when ->(x) { defined?(Numo::NArray) && obj.is_a?(Numo::NArray) }
43
+ visit_array(obj.to_a)
44
+
45
+ when ->(x) { defined?(NMatrix) && obj.is_a?(NMatrix) }
46
+ visit_array(obj.to_a)
47
+
48
+ when ->(x) { defined?(Numpy::NDArray) && obj.is_a?(Numpy::NDArray) }
49
+ visit_array(obj.to_a)
50
+
51
+ when ->(x) { defined?(PyCall::List) && obj.is_a?(PyCall::List) }
52
+ visit_array(obj.to_a)
53
+
54
+ when ->(x) { defined?(PyCall::Tuple) && obj.is_a?(PyCall::Tuple) }
55
+ visit_array(obj.to_a)
56
+
57
+ when ->(x) { defined?(PyCall::Dict) && obj.is_a?(PyCall::Dict) }
58
+ visit_hash(obj.to_h)
59
+
60
+ when ->(x) { defined?(Pandas::Series) && obj.is_a?(Pandas::Series) }
61
+ visit_array(obj.to_a)
62
+
63
+ else
64
+ str = String.try_convert(obj)
65
+ return str unless str.nil?
66
+
67
+ ary = Array.try_convert(obj)
68
+ return visit_array(ary) unless ary.nil?
69
+
70
+ hsh = Hash.try_convert(obj)
71
+ return visit_hash(hsh) unless hsh.nil?
72
+
73
+ type_error(obj)
74
+ end
75
+ end
76
+
77
+ private def visit_float(obj)
78
+ obj = obj.to_f
79
+ rescue RangeError
80
+ type_error(obj)
81
+ else
82
+ case
83
+ when obj.finite?
84
+ obj
85
+ else
86
+ nil
87
+ end
88
+ end
89
+
90
+ private def visit_time(obj)
91
+ obj.iso8601(6)
92
+ end
93
+
94
+ private def visit_date(obj)
95
+ obj.iso8601(6)
96
+ end
97
+
98
+ private def visit_datetime(obj)
99
+ obj.iso8601(6)
100
+ end
101
+
102
+ private def visit_array(obj)
103
+ obj.map {|x| visit(x) }
104
+ end
105
+
106
+ private def visit_hash(obj)
107
+ obj.map { |key, value|
108
+ [
109
+ key,
110
+ visit(value)
111
+ ]
112
+ }.to_h
113
+ end
114
+
115
+ private def type_error(obj)
116
+ raise TypeError, "Unable to convert to JSON: %p" % obj
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -8,11 +8,14 @@ module Charty
8
8
  class << self
9
9
  def prepare
10
10
  require 'matplotlib/pyplot'
11
+ require 'numpy'
11
12
  end
12
13
  end
13
14
 
14
15
  def initialize
15
16
  @pyplot = ::Matplotlib::Pyplot
17
+ @default_line_width = ::Matplotlib.rcParams["lines.linewidth"]
18
+ @default_marker_size = ::Matplotlib.rcParams["lines.markersize"]
16
19
  end
17
20
 
18
21
  def self.activate_iruby_integration
@@ -38,7 +41,7 @@ module Charty
38
41
  @pyplot.show
39
42
  end
40
43
 
41
- def render(context, filename)
44
+ def old_style_render(context, filename)
42
45
  plot(@pyplot, context)
43
46
  if filename
44
47
  FileUtils.mkdir_p(File.dirname(filename))
@@ -47,7 +50,7 @@ module Charty
47
50
  @pyplot.show
48
51
  end
49
52
 
50
- def save(context, filename, finish: true)
53
+ def old_style_save(context, filename, finish: true)
51
54
  plot(context)
52
55
  if filename
53
56
  FileUtils.mkdir_p(File.dirname(filename))
@@ -73,7 +76,7 @@ module Charty
73
76
  ax.ylabel(context.ylabel) if context.ylabel
74
77
  end
75
78
 
76
- palette = Charty::Palette.default
79
+ palette = Palette.default
77
80
  colors = palette.colors.map {|c| c.to_rgb.to_hex_string }.cycle
78
81
  case context.method
79
82
  when :bar
@@ -90,7 +93,16 @@ module Charty
90
93
  min_l = palette.colors.map {|c| c.to_rgb.to_hsl.l }.min
91
94
  lum = min_l*0.6
92
95
  gray = Colors::RGB.new(lum, lum, lum).to_hex_string
93
- draw_box_plot(context, subplot, colors, gray)
96
+ Array(context.data).each_with_index do |group_data, i|
97
+ next if group_data.empty?
98
+
99
+ box_data = group_data.compact
100
+ next if box_data.empty?
101
+
102
+ color = colors.next
103
+ draw_box_plot(box_data, vert: "v", position: i, color: color,
104
+ gray: gray, width: 0.8, whisker: 1.5, flier_size: 5)
105
+ end
94
106
  when :bubble
95
107
  context.series.each do |data|
96
108
  ax.scatter(data.xs.to_a, data.ys.to_a, s: data.zs.to_a, alpha: 0.5,
@@ -125,93 +137,485 @@ module Charty
125
137
  end
126
138
  end
127
139
 
128
- private def draw_box_plot(context, subplot, colors, gray)
129
- Array(context.data).each_with_index do |group_data, i|
130
- next if group_data.empty?
131
-
132
- box_data = group_data.compact
133
- next if box_data.empty?
134
-
135
- artist_dict = @pyplot.boxplot(box_data, vert: "v", patch_artist: true,
136
- positions: [i], widths: 0.8)
137
-
138
- color = colors.next
139
- artist_dict["boxes"].each do |box|
140
- box.update({facecolor: color, zorder: 0.9, edgecolor: gray}, {})
141
- end
142
- artist_dict["whiskers"].each do |whisker|
143
- whisker.update({color: gray, linestyle: "-"}, {})
144
- end
145
- artist_dict["caps"].each do |cap|
146
- cap.update({color: gray}, {})
147
- end
148
- artist_dict["medians"].each do |median|
149
- median.update({color: gray}, {})
150
- end
151
- artist_dict["fliers"].each do |flier|
152
- flier.update({
153
- markerfacecolor: gray,
154
- marker: "d",
155
- markeredgecolor: gray,
156
- markersize: 5
157
- }, {})
158
- end
159
- end
160
- end
161
-
162
140
  # ==== NEW PLOTTING API ====
163
141
 
164
142
  def begin_figure
165
- # do nothing
143
+ @legend_keys = []
144
+ @legend_labels = []
166
145
  end
167
146
 
168
- def bar(bar_pos, values, color: nil, width: 0.8r, align: :center, orient: :v)
147
+ def bar(bar_pos, _group_names, values, colors, orient, label: nil, width: 0.8r,
148
+ align: :center, conf_int: nil, error_colors: nil, error_width: nil, cap_size: nil)
169
149
  bar_pos = Array(bar_pos)
170
150
  values = Array(values)
171
- color = Array(color).map(&:to_hex_string)
151
+ colors = Array(colors).map(&:to_hex_string)
172
152
  width = Float(width)
153
+
154
+ ax = @pyplot.gca
155
+ kw = {color: colors, align: align}
156
+ kw[:label] = label unless label.nil?
157
+
173
158
  if orient == :v
174
- @pyplot.bar(bar_pos, values, width: width, color: color, align: align)
159
+ ax.bar(bar_pos, values, width, **kw)
175
160
  else
176
- @pyplot.barh(bar_pos, values, width: width, color: color, align: align)
161
+ ax.barh(bar_pos, values, width, **kw)
162
+ end
163
+
164
+ if conf_int
165
+ error_colors = Array(error_colors).map(&:to_hex_string)
166
+ confidence_intervals(ax, bar_pos, conf_int, orient, error_colors, error_width, cap_size)
177
167
  end
178
168
  end
179
169
 
180
- def box_plot(plot_data, positions, color:, gray:,
181
- width: 0.8r, flier_size: 5, whisker: 1.5, notch: false)
182
- color = Array(color).map(&:to_hex_string)
170
+ private def confidence_intervals(ax, at_group, conf_int, orient, colors, error_width=nil, cap_size=nil, **options)
171
+ options[:lw] = error_width || @default_line_width * 1.8
172
+
173
+ at_group.each_index do |i|
174
+ at = at_group[i]
175
+ ci_low, ci_high = conf_int[i]
176
+ color = colors[i]
177
+
178
+ if orient == :v
179
+ ax.plot([at, at], [ci_low, ci_high], color: color, **options)
180
+ unless cap_size.nil?
181
+ ax.plot([at - cap_size / 2.0, at + cap_size / 2.0], [ci_low, ci_low], color: color, **options)
182
+ ax.plot([at - cap_size / 2.0, at + cap_size / 2.0], [ci_high, ci_high], color: color, **options)
183
+ end
184
+ else
185
+ ax.plot([ci_low, ci_high], [at, at], color: color, **options)
186
+ unless cap_size.nil?
187
+ ax.plot([ci_low, ci_low], [at - cap_size / 2.0, at + cap_size / 2.0], color: color, **options)
188
+ ax.plot([ci_high, ci_high], [at - cap_size / 2.0, at + cap_size / 2.0], color: color, **options)
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ def box_plot(plot_data, group_names,
195
+ orient:, colors:, gray:, dodge:, width: 0.8r,
196
+ flier_size: 5, whisker: 1.5, notch: false)
197
+ colors = Array(colors).map(&:to_hex_string)
183
198
  gray = gray.to_hex_string
184
199
  width = Float(width)
185
200
  flier_size = Float(flier_size)
186
201
  whisker = Float(whisker)
202
+
187
203
  plot_data.each_with_index do |group_data, i|
188
- next if group_data.nil? || group_data.empty?
204
+ unless group_data.nil?
205
+ draw_box_plot(group_data,
206
+ vert: (orient == :v),
207
+ position: i,
208
+ color: colors[i],
209
+ gray: gray,
210
+ width: width,
211
+ whisker: whisker,
212
+ flier_size: flier_size)
213
+ end
214
+ end
215
+ end
189
216
 
190
- artist_dict = @pyplot.boxplot(group_data, vert: :v,
191
- patch_artist: true,
192
- positions: [i],
193
- widths: width,
194
- whis: whisker, )
217
+ def grouped_box_plot(plot_data, group_names, color_names,
218
+ orient:, colors:, gray:, dodge:, width: 0.8r,
219
+ flier_size: 5, whisker: 1.5, notch: false)
220
+ colors = Array(colors).map(&:to_hex_string)
221
+ gray = gray.to_hex_string
222
+ width = Float(width)
223
+ flier_size = Float(flier_size)
224
+ whisker = Float(whisker)
225
+
226
+ offsets = color_offsets(color_names, dodge, width)
227
+ orig_width = width
228
+ width = Float(nested_width(color_names, dodge, width))
229
+
230
+ color_names.each_with_index do |color_name, i|
231
+ add_box_plot_legend(gray, colors[i], color_names[i])
232
+
233
+ plot_data[i].each_with_index do |group_data, j|
234
+ next if group_data.empty?
195
235
 
196
- artist_dict["boxes"].each do |box|
197
- box.update({facecolor: color[i], zorder: 0.9, edgecolor: gray}, {})
236
+ position = j + offsets[i]
237
+ draw_box_plot(group_data,
238
+ vert: (orient == :v),
239
+ position: position,
240
+ color: colors[i],
241
+ gray: gray,
242
+ width: width,
243
+ whisker: whisker,
244
+ flier_size: flier_size)
198
245
  end
199
- artist_dict["whiskers"].each do |whisker|
200
- whisker.update({color: gray, linestyle: "-"}, {})
246
+ end
247
+ end
248
+
249
+ private def add_box_plot_legend(gray, color, name)
250
+ patch = @pyplot.Rectangle.new([0, 0], 0, 0, edgecolor: gray, facecolor: color, label: name)
251
+ @pyplot.gca.add_patch(patch)
252
+ end
253
+
254
+ private def draw_box_plot(group_data, vert:, position:, color:, gray:, width:, whisker:, flier_size:)
255
+ # TODO: Do not convert to Array when group_data is Pandas::Series or Numpy::NDArray,
256
+ # and use MemoryView if available when group_data is Numo::NArray
257
+ artist_dict = @pyplot.boxplot(Array(group_data),
258
+ vert: vert,
259
+ patch_artist: true,
260
+ positions: [position],
261
+ widths: width,
262
+ whis: whisker)
263
+
264
+ artist_dict["boxes"].each do |box|
265
+ box.update({facecolor: color, zorder: 0.9, edgecolor: gray}, {})
266
+ end
267
+ artist_dict["whiskers"].each do |whisker|
268
+ whisker.update({color: gray, linestyle: "-"}, {})
269
+ end
270
+ artist_dict["caps"].each do |cap|
271
+ cap.update({color: gray}, {})
272
+ end
273
+ artist_dict["medians"].each do |median|
274
+ median.update({color: gray}, {})
275
+ end
276
+ artist_dict["fliers"].each do |flier|
277
+ flier.update({
278
+ markerfacecolor: gray,
279
+ marker: "d",
280
+ markeredgecolor: gray,
281
+ markersize: flier_size
282
+ }, {})
283
+ end
284
+ end
285
+
286
+ private def color_offsets(color_names, dodge, width)
287
+ n_names = color_names.length
288
+ if dodge
289
+ each_width = width / n_names
290
+ offsets = Charty::Linspace.new(0 .. (width - each_width), n_names).to_a
291
+ mean = Statistics.mean(offsets)
292
+ offsets.map {|x| x - mean }
293
+ else
294
+ Array.new(n_names, 0)
295
+ end
296
+ end
297
+
298
+ private def nested_width(color_names, dodge, width)
299
+ if dodge
300
+ width.to_r / color_names.length * 0.98r
301
+ else
302
+ width
303
+ end
304
+ end
305
+
306
+ def scatter(x, y, variables, color:, color_mapper:,
307
+ style:, style_mapper:, size:, size_mapper:)
308
+ kwd = {}
309
+ kwd[:edgecolor] = "w"
310
+
311
+ ax = @pyplot.gca
312
+ points = ax.scatter(x.to_a, y.to_a, **kwd)
313
+
314
+ unless color.nil?
315
+ color = color_mapper[color].map(&:to_hex_string)
316
+ points.set_facecolors(color)
317
+ end
318
+
319
+ unless size.nil?
320
+ size = size_mapper[size].map {|x| scale_scatter_point_size(x).to_f }
321
+ points.set_sizes(size)
322
+ end
323
+
324
+ unless style.nil?
325
+ paths = style_mapper[style, :marker].map(&method(:marker_to_path))
326
+ points.set_paths(paths)
327
+ end
328
+
329
+ sizes = points.get_sizes
330
+ points.set_linewidths(0.08 * Numpy.sqrt(Numpy.percentile(sizes, 10)))
331
+ end
332
+
333
+ def add_scatter_plot_legend(variables, color_mapper, size_mapper, style_mapper, legend)
334
+ ax = @pyplot.gca
335
+ add_relational_plot_legend(
336
+ ax, variables, color_mapper, size_mapper, style_mapper,
337
+ legend, [:color, :s, :marker]
338
+ ) do |label, kwargs|
339
+ ax.scatter([], [], label: label, **kwargs)
340
+ end
341
+ end
342
+
343
+ PYPLOT_MARKERS = {
344
+ circle: "o",
345
+ x: "X",
346
+ cross: "P",
347
+ triangle_up: "^",
348
+ triangle_down: "v",
349
+ square: [4, 0, 45].freeze,
350
+ diamond: [4, 0, 0].freeze,
351
+ star: [5, 1, 0].freeze,
352
+ star_diamond: [4, 1, 0].freeze,
353
+ star_square: [4, 1, 45].freeze,
354
+ pentagon: [5, 0, 0].freeze,
355
+ hexagon: [6, 0, 0].freeze,
356
+ }.freeze
357
+
358
+ private def marker_to_path(marker)
359
+ @path_cache ||= {}
360
+ if @path_cache.key?(marker)
361
+ @path_cache[marker]
362
+ elsif PYPLOT_MARKERS.key?(marker)
363
+ val = PYPLOT_MARKERS[marker]
364
+ ms = Matplotlib.markers.MarkerStyle.new(val)
365
+ @path_cache[marker] = ms.get_path().transformed(ms.get_transform())
366
+ else
367
+ raise ArgumentError, "Unknown marker name: %p" % marker
368
+ end
369
+ end
370
+
371
+ RELATIONAL_PLOT_LEGEND_BRIEF_TICKS = 6
372
+
373
+ private def add_relational_plot_legend(ax, variables, color_mapper, size_mapper, style_mapper,
374
+ verbosity, legend_attributes, &func)
375
+ brief_ticks = RELATIONAL_PLOT_LEGEND_BRIEF_TICKS
376
+ verbosity = :auto if verbosity == true
377
+
378
+ legend_titles = Util.filter_map([:color, :size, :style]) {|v| variables[v] }
379
+ legend_title = legend_titles.pop if legend_titles.length == 1
380
+
381
+ legend_kwargs = {}
382
+ update_legend = ->(var_name, val_name, **kw) do
383
+ key = [var_name, val_name]
384
+ if legend_kwargs.key?(key)
385
+ legend_kwargs[key].update(kw)
386
+ else
387
+ legend_kwargs[key] = kw
201
388
  end
202
- artist_dict["caps"].each do |cap|
203
- cap.update({color: gray}, {})
389
+ end
390
+
391
+ title_kwargs = {visible: false, color: "w", s: 0, linewidth: 0, marker: "", dashes: ""}
392
+
393
+ # color legend
394
+
395
+ brief_color = (color_mapper.map_type == :numeric) && (
396
+ (verbosity == :brief) || (
397
+ verbosity == :auto && color_mapper.levels.length > brief_ticks
398
+ )
399
+ )
400
+ case
401
+ when brief_color
402
+ # TODO: Also support LogLocator
403
+ # locator = Matplotlib.ticker.LogLocator.new(numticks: brief_ticks)
404
+ locator = Matplotlib.ticker.MaxNLocator.new(nbins: brief_ticks)
405
+ limits = color_mapper.levels.minmax
406
+ color_levels, color_formatted_levels = locator_to_legend_entries(locator, limits)
407
+ when color_mapper.levels.nil?
408
+ color_levels = color_formatted_levels = []
409
+ else
410
+ color_levels = color_formatted_levels = color_mapper.levels
411
+ end
412
+
413
+ if legend_title.nil? && variables.key?(:color)
414
+ update_legend.([variables[:color], :title], variables[:color], **title_kwargs)
415
+ end
416
+
417
+ color_levels.length.times do |i|
418
+ next if color_levels[i].nil?
419
+ color_value = color_mapper[color_levels[i]].to_rgb.to_hex_string
420
+ update_legend.(variables[:color], color_formatted_levels[i], color: color_value)
421
+ end
422
+
423
+ brief_size = (size_mapper.map_type == :numeric) && (
424
+ verbosity == :brief ||
425
+ (verbosity == :auto && size_mapper.levels.length > brief_ticks)
426
+ )
427
+ case
428
+ when brief_size
429
+ # TODO: Also support LogLocator
430
+ # locator = Matplotlib.ticker.LogLocator(numticks: brief_ticks)
431
+ locator = Matplotlib.ticker.MaxNLocator.new(nbins: brief_ticks)
432
+ limits = size_mapper.levels.minmax
433
+ size_levels, size_formatted_levels = locator_to_legend_entries(locator, limits)
434
+ when size_mapper.levels.nil?
435
+ size_levels = size_formatted_levels = []
436
+ else
437
+ size_levels = size_formatted_levels = size_mapper.levels
438
+ end
439
+
440
+ if legend_title.nil? && variables.key?(:size)
441
+ update_legend.([variables[:size], :title], variables[:size], **title_kwargs)
442
+ end
443
+
444
+ size_levels.length.times do |i|
445
+ next if size_levels[i].nil?
446
+ line_width = scale_line_width(size_mapper[size_levels[i]])
447
+ point_size = scale_scatter_point_size(size_mapper[size_levels[i]])
448
+ update_legend.(variables[:size], size_formatted_levels[i], linewidth: line_width, s: point_size)
449
+ end
450
+
451
+ if legend_title.nil? && variables.key?(:style)
452
+ update_legend.([variables[:style], :title], variables[:style], **title_kwargs)
453
+ end
454
+
455
+ unless style_mapper.levels.nil?
456
+ style_mapper.levels.each do |level|
457
+ next if level.nil?
458
+ attrs = style_mapper[level]
459
+ marker = if attrs.key?(:marker)
460
+ PYPLOT_MARKERS[attrs[:marker]]
461
+ else
462
+ ""
463
+ end
464
+ dashes = if attrs.key?(:dashes)
465
+ attrs[:dashes]
466
+ else
467
+ ""
468
+ end
469
+ update_legend.(variables[:style], level, marker: marker, dashes: dashes)
204
470
  end
205
- artist_dict["medians"].each do |median|
206
- median.update({color: gray}, {})
471
+ end
472
+
473
+ legend_kwargs.each do |key, kw|
474
+ _, label = key
475
+ kw[:color] ||= ".2"
476
+ use_kw = Util.filter_map(legend_attributes) {|attr|
477
+ [attr, kw[attr]] if kw.key?(attr)
478
+ }.to_h
479
+ use_kw[:visible] = kw[:visible] if kw.key?(:visible)
480
+ func.(label, use_kw)
481
+ end
482
+
483
+ handles = ax.get_legend_handles_labels()[0].to_a
484
+ unless handles.empty?
485
+ legend = ax.legend(title: legend_title || "")
486
+ adjust_legend_subtitles(legend)
487
+ end
488
+ end
489
+
490
+ private def scale_scatter_point_size(x)
491
+ min = 0.5 * @default_marker_size**2
492
+ max = 2.0 * @default_marker_size**2
493
+
494
+ min + x * (max - min)
495
+ end
496
+
497
+ def line(x, y, variables, color:, color_mapper:, size:, size_mapper:, style:, style_mapper:, ci_params:)
498
+ kws = {
499
+ markeredgewidth: 0.75,
500
+ markeredgecolor: "w",
501
+ }
502
+ ax = @pyplot.gca
503
+
504
+ x = x.to_a
505
+ y = y.to_a
506
+ lines = ax.plot(x, y, **kws)
507
+
508
+ lines.each do |line|
509
+ unless color.nil?
510
+ line.set_color(color_mapper[color].to_rgb.to_hex_string)
511
+ end
512
+
513
+ unless size.nil?
514
+ scaled_size = scale_line_width(size_mapper[size])
515
+ line.set_linewidth(scaled_size.to_f)
516
+ end
517
+
518
+ unless style.nil?
519
+ attributes = style_mapper[style]
520
+ if attributes.key?(:dashes)
521
+ line.set_dashes(attributes[:dashes])
522
+ end
523
+ if attributes.key?(:marker)
524
+ line.set_marker(PYPLOT_MARKERS[attributes[:marker]])
525
+ end
526
+ end
527
+ end
528
+
529
+ # TODO: support color, size, and style
530
+
531
+ line = lines[0]
532
+ line_color = line.get_color
533
+ line_alpha = line.get_alpha
534
+ line_capstyle = line.get_solid_capstyle
535
+
536
+ unless ci_params.nil?
537
+ y_min = ci_params[:y_min].to_a
538
+ y_max = ci_params[:y_max].to_a
539
+ case ci_params[:style]
540
+ when :band
541
+ # TODO: support to supply `alpha` via `err_kws`
542
+ ax.fill_between(x, y_min, y_max, color: line_color, alpha: 0.2)
543
+ when :bars
544
+ error_deltas = [
545
+ y.zip(y_min).map {|v, v_min| v - v_min },
546
+ y.zip(y_max).map {|v, v_max| v_max - v }
547
+ ]
548
+ ebars = ax.errorbar(x, y, error_deltas,
549
+ linestyle: "", color: line_color, alpha: line_alpha)
550
+ ebars.get_children.each do |bar|
551
+ case bar
552
+ when Matplotlib.collections.LineCollection
553
+ bar.set_capstyle(line_capstyle)
554
+ end
555
+ end
207
556
  end
208
- artist_dict["fliers"].each do |flier|
209
- flier.update({
210
- markerfacecolor: gray,
211
- marker: "d",
212
- markeredgecolor: gray,
213
- markersize: flier_size
214
- }, {})
557
+ end
558
+ end
559
+
560
+ def add_line_plot_legend(variables, color_mapper, size_mapper, style_mapper, legend)
561
+ ax = @pyplot.gca
562
+ add_relational_plot_legend(
563
+ ax, variables, color_mapper, size_mapper, style_mapper,
564
+ legend, [:color, :linewidth, :marker, :dashes]
565
+ ) do |label, kwargs|
566
+ ax.plot([], [], label: label, **kwargs)
567
+ end
568
+ end
569
+
570
+
571
+ private def scale_line_width(x)
572
+ min = 0.5 * @default_line_width
573
+ max = 2.0 * @default_line_width
574
+
575
+ min + x * (max - min)
576
+ end
577
+
578
+ private def locator_to_legend_entries(locator, limits)
579
+ vmin, vmax = limits
580
+ dtype = case vmin
581
+ when Numeric
582
+ :float64
583
+ else
584
+ :object
585
+ end
586
+ raw_levels = locator.tick_values(vmin, vmax).astype(dtype).to_a
587
+ raw_levels.reject! {|v| v < limits[0] || limits[1] < v }
588
+
589
+ formatter = case locator
590
+ when Matplotlib.ticker.LogLocator
591
+ Matplotlib.ticker.LogFormatter.new
592
+ else
593
+ Matplotlib.ticker.ScalarFormatter.new
594
+ end
595
+
596
+ dummy_axis = Object.new
597
+ dummy_axis.define_singleton_method(:get_view_interval) { limits }
598
+ formatter.axis = dummy_axis
599
+
600
+ formatter.set_locs(raw_levels)
601
+ formatted_levels = raw_levels.map {|x| formatter.(x) }
602
+
603
+ return raw_levels, formatted_levels
604
+ end
605
+
606
+ private def adjust_legend_subtitles(legend)
607
+ font_size = Matplotlib.rcParams.get("legend.title_fontsize", nil)
608
+ hpackers = legend.findobj(Matplotlib.offsetbox.VPacker)[0].get_children()
609
+ hpackers.each do |hpack|
610
+ draw_area, text_area = hpack.get_children()
611
+ handles = draw_area.get_children()
612
+ unless handles.all? {|a| a.get_visible() }
613
+ draw_area.set_width(0)
614
+ unless font_size.nil?
615
+ text_area.get_children().each do |text|
616
+ text.set_size(font_size)
617
+ end
618
+ end
215
619
  end
216
620
  end
217
621
  end
@@ -228,18 +632,62 @@ module Charty
228
632
  @pyplot.gca.set_xticks(Array(values))
229
633
  end
230
634
 
635
+ def set_yticks(values)
636
+ @pyplot.gca.set_yticks(Array(values))
637
+ end
638
+
231
639
  def set_xtick_labels(labels)
232
640
  @pyplot.gca.set_xticklabels(Array(labels).map(&method(:String)))
233
641
  end
234
642
 
643
+ def set_ytick_labels(labels)
644
+ @pyplot.gca.set_yticklabels(Array(labels).map(&method(:String)))
645
+ end
646
+
235
647
  def set_xlim(min, max)
236
648
  @pyplot.gca.set_xlim(Float(min), Float(max))
237
649
  end
238
650
 
651
+ def set_ylim(min, max)
652
+ @pyplot.gca.set_ylim(Float(min), Float(max))
653
+ end
654
+
239
655
  def disable_xaxis_grid
240
656
  @pyplot.gca.xaxis.grid(false)
241
657
  end
242
658
 
659
+ def disable_yaxis_grid
660
+ @pyplot.gca.xaxis.grid(false)
661
+ end
662
+
663
+ def invert_yaxis
664
+ @pyplot.gca.invert_yaxis
665
+ end
666
+
667
+ def legend(loc:, title:)
668
+ @pyplot.gca.legend(loc: loc, title: title)
669
+ end
670
+
671
+ def render(notebook: false)
672
+ show
673
+ nil
674
+ end
675
+
676
+ SAVEFIG_OPTIONAL_PARAMS = [
677
+ :dpi, :quality, :optimize, :progressive, :facecolor, :edgecolor,
678
+ :orientation, :papertype, :transparent, :bbox_inches, :pad_inches,
679
+ :bbox_extra_artists, :backend, :metadata, :pil_kwargs
680
+ ].freeze
681
+
682
+ def save(filename, format: nil, title: nil, width: 700, height: 500, **kwargs)
683
+ params = {}
684
+ params[:format] = format unless format.nil?
685
+ SAVEFIG_OPTIONAL_PARAMS.each do |key|
686
+ params[key] = kwargs[key] if kwargs.key?(key)
687
+ end
688
+ @pyplot.savefig(filename, **params)
689
+ end
690
+
243
691
  def show
244
692
  @pyplot.show
245
693
  end