charty 0.2.3 → 0.2.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) 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/Gemfile +18 -0
  6. data/README.md +172 -4
  7. data/Rakefile +4 -5
  8. data/charty.gemspec +10 -6
  9. data/examples/sample_images/hist_gruff.png +0 -0
  10. data/images/penguins_body_mass_g_flipper_length_mm_scatter_plot.png +0 -0
  11. data/images/penguins_body_mass_g_flipper_length_mm_species_scatter_plot.png +0 -0
  12. data/images/penguins_body_mass_g_flipper_length_mm_species_sex_scatter_plot.png +0 -0
  13. data/images/penguins_species_body_mass_g_bar_plot_h.png +0 -0
  14. data/images/penguins_species_body_mass_g_bar_plot_v.png +0 -0
  15. data/images/penguins_species_body_mass_g_box_plot_h.png +0 -0
  16. data/images/penguins_species_body_mass_g_box_plot_v.png +0 -0
  17. data/images/penguins_species_body_mass_g_sex_bar_plot_v.png +0 -0
  18. data/images/penguins_species_body_mass_g_sex_box_plot_v.png +0 -0
  19. data/lib/charty.rb +8 -1
  20. data/lib/charty/backends/bokeh.rb +2 -2
  21. data/lib/charty/backends/google_charts.rb +1 -1
  22. data/lib/charty/backends/gruff.rb +14 -3
  23. data/lib/charty/backends/plotly.rb +731 -32
  24. data/lib/charty/backends/plotly_helpers/html_renderer.rb +203 -0
  25. data/lib/charty/backends/plotly_helpers/notebook_renderer.rb +87 -0
  26. data/lib/charty/backends/plotly_helpers/plotly_renderer.rb +121 -0
  27. data/lib/charty/backends/pyplot.rb +514 -66
  28. data/lib/charty/backends/rubyplot.rb +1 -1
  29. data/lib/charty/cache_dir.rb +27 -0
  30. data/lib/charty/dash_pattern_generator.rb +57 -0
  31. data/lib/charty/index.rb +213 -0
  32. data/lib/charty/iruby_helper.rb +18 -0
  33. data/lib/charty/linspace.rb +1 -1
  34. data/lib/charty/plot_methods.rb +283 -8
  35. data/lib/charty/plotter.rb +2 -2
  36. data/lib/charty/plotters.rb +11 -0
  37. data/lib/charty/plotters/abstract_plotter.rb +186 -16
  38. data/lib/charty/plotters/bar_plotter.rb +189 -7
  39. data/lib/charty/plotters/box_plotter.rb +64 -11
  40. data/lib/charty/plotters/categorical_plotter.rb +272 -40
  41. data/lib/charty/plotters/count_plotter.rb +7 -0
  42. data/lib/charty/plotters/distribution_plotter.rb +143 -0
  43. data/lib/charty/plotters/estimation_support.rb +84 -0
  44. data/lib/charty/plotters/histogram_plotter.rb +186 -0
  45. data/lib/charty/plotters/line_plotter.rb +300 -0
  46. data/lib/charty/plotters/random_support.rb +25 -0
  47. data/lib/charty/plotters/relational_plotter.rb +635 -0
  48. data/lib/charty/plotters/scatter_plotter.rb +80 -0
  49. data/lib/charty/plotters/vector_plotter.rb +6 -0
  50. data/lib/charty/statistics.rb +96 -2
  51. data/lib/charty/table.rb +160 -15
  52. data/lib/charty/table_adapters.rb +2 -0
  53. data/lib/charty/table_adapters/active_record_adapter.rb +17 -9
  54. data/lib/charty/table_adapters/base_adapter.rb +166 -0
  55. data/lib/charty/table_adapters/daru_adapter.rb +39 -3
  56. data/lib/charty/table_adapters/datasets_adapter.rb +13 -2
  57. data/lib/charty/table_adapters/hash_adapter.rb +141 -16
  58. data/lib/charty/table_adapters/narray_adapter.rb +25 -6
  59. data/lib/charty/table_adapters/nmatrix_adapter.rb +15 -5
  60. data/lib/charty/table_adapters/pandas_adapter.rb +163 -0
  61. data/lib/charty/util.rb +28 -0
  62. data/lib/charty/vector.rb +69 -0
  63. data/lib/charty/vector_adapters.rb +187 -0
  64. data/lib/charty/vector_adapters/array_adapter.rb +101 -0
  65. data/lib/charty/vector_adapters/daru_adapter.rb +163 -0
  66. data/lib/charty/vector_adapters/narray_adapter.rb +182 -0
  67. data/lib/charty/vector_adapters/nmatrix_adapter.rb +37 -0
  68. data/lib/charty/vector_adapters/numpy_adapter.rb +168 -0
  69. data/lib/charty/vector_adapters/pandas_adapter.rb +199 -0
  70. data/lib/charty/version.rb +1 -1
  71. metadata +92 -25
@@ -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,87 @@
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
+ IRuby.display(script, mime: "text/html")
53
+ end
54
+
55
+ def render(figure, element_id: nil, post_script: nil)
56
+ ary = Array.try_convert(post_script)
57
+ post_script = ary || [post_script]
58
+ post_script.unshift(<<~END_POST_SCRIPT)
59
+ var gd = document.getElementById('%{plot_id}');
60
+ var x = new MutationObserver(function (mutations, observer) {
61
+ var display = window.getComputedStyle(gd).display;
62
+ if (!display || display === 'none') {
63
+ console.log([gd, 'removed']);
64
+ Plotly.purge(gd);
65
+ observer.disconnect();
66
+ }
67
+ });
68
+
69
+ // Listen for the removal of the full notebook cell
70
+ var notebookContainer = gd.closest('#notebook-container');
71
+ if (notebookContainer) {
72
+ x.observe(notebookContainer, {childList: true});
73
+ }
74
+
75
+ // Listen for the clearing of the current output cell
76
+ var outputEl = gd.closest('.output');
77
+ if (outputEl) {
78
+ x.observe(outputEl, {childList: true});
79
+ }
80
+ END_POST_SCRIPT
81
+
82
+ super(figure, element_id: element_id, post_script: post_script)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ 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))
@@ -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