charty 0.2.0 → 0.2.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +71 -0
  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 -4
  10. data/examples/Gemfile +1 -0
  11. data/examples/active_record.ipynb +1 -1
  12. data/examples/daru.ipynb +1 -1
  13. data/examples/iris_dataset.ipynb +1 -1
  14. data/examples/nmatrix.ipynb +1 -1
  15. data/examples/{numo-narray.ipynb → numo_narray.ipynb} +1 -1
  16. data/examples/palette.rb +71 -0
  17. data/examples/sample.png +0 -0
  18. data/examples/sample_images/hist_gruff.png +0 -0
  19. data/examples/sample_pyplot.ipynb +40 -38
  20. data/images/penguins_body_mass_g_flipper_length_mm_scatter_plot.png +0 -0
  21. data/images/penguins_body_mass_g_flipper_length_mm_species_scatter_plot.png +0 -0
  22. data/images/penguins_body_mass_g_flipper_length_mm_species_sex_scatter_plot.png +0 -0
  23. data/images/penguins_species_body_mass_g_bar_plot_h.png +0 -0
  24. data/images/penguins_species_body_mass_g_bar_plot_v.png +0 -0
  25. data/images/penguins_species_body_mass_g_box_plot_h.png +0 -0
  26. data/images/penguins_species_body_mass_g_box_plot_v.png +0 -0
  27. data/images/penguins_species_body_mass_g_sex_bar_plot_v.png +0 -0
  28. data/images/penguins_species_body_mass_g_sex_box_plot_v.png +0 -0
  29. data/lib/charty.rb +13 -1
  30. data/lib/charty/backend_methods.rb +8 -0
  31. data/lib/charty/backends.rb +26 -1
  32. data/lib/charty/backends/bokeh.rb +31 -31
  33. data/lib/charty/backends/{google_chart.rb → google_charts.rb} +75 -33
  34. data/lib/charty/backends/gruff.rb +14 -3
  35. data/lib/charty/backends/plotly.rb +774 -9
  36. data/lib/charty/backends/pyplot.rb +611 -34
  37. data/lib/charty/backends/rubyplot.rb +2 -2
  38. data/lib/charty/backends/unicode_plot.rb +79 -0
  39. data/lib/charty/dash_pattern_generator.rb +57 -0
  40. data/lib/charty/index.rb +213 -0
  41. data/lib/charty/linspace.rb +1 -1
  42. data/lib/charty/plot_methods.rb +254 -0
  43. data/lib/charty/plotter.rb +10 -10
  44. data/lib/charty/plotters.rb +12 -0
  45. data/lib/charty/plotters/abstract_plotter.rb +243 -0
  46. data/lib/charty/plotters/bar_plotter.rb +201 -0
  47. data/lib/charty/plotters/box_plotter.rb +79 -0
  48. data/lib/charty/plotters/categorical_plotter.rb +380 -0
  49. data/lib/charty/plotters/count_plotter.rb +7 -0
  50. data/lib/charty/plotters/estimation_support.rb +84 -0
  51. data/lib/charty/plotters/line_plotter.rb +300 -0
  52. data/lib/charty/plotters/random_support.rb +25 -0
  53. data/lib/charty/plotters/relational_plotter.rb +635 -0
  54. data/lib/charty/plotters/scatter_plotter.rb +80 -0
  55. data/lib/charty/plotters/vector_plotter.rb +6 -0
  56. data/lib/charty/statistics.rb +114 -0
  57. data/lib/charty/table.rb +161 -15
  58. data/lib/charty/table_adapters.rb +2 -0
  59. data/lib/charty/table_adapters/active_record_adapter.rb +17 -9
  60. data/lib/charty/table_adapters/base_adapter.rb +166 -0
  61. data/lib/charty/table_adapters/daru_adapter.rb +41 -3
  62. data/lib/charty/table_adapters/datasets_adapter.rb +17 -2
  63. data/lib/charty/table_adapters/hash_adapter.rb +143 -16
  64. data/lib/charty/table_adapters/narray_adapter.rb +25 -6
  65. data/lib/charty/table_adapters/nmatrix_adapter.rb +15 -5
  66. data/lib/charty/table_adapters/pandas_adapter.rb +163 -0
  67. data/lib/charty/util.rb +28 -0
  68. data/lib/charty/vector.rb +69 -0
  69. data/lib/charty/vector_adapters.rb +187 -0
  70. data/lib/charty/vector_adapters/array_adapter.rb +101 -0
  71. data/lib/charty/vector_adapters/daru_adapter.rb +163 -0
  72. data/lib/charty/vector_adapters/narray_adapter.rb +182 -0
  73. data/lib/charty/vector_adapters/nmatrix_adapter.rb +37 -0
  74. data/lib/charty/vector_adapters/numpy_adapter.rb +168 -0
  75. data/lib/charty/vector_adapters/pandas_adapter.rb +199 -0
  76. data/lib/charty/version.rb +1 -1
  77. metadata +121 -22
  78. data/.travis.yml +0 -10
data/lib/charty.rb CHANGED
@@ -1,8 +1,20 @@
1
1
  require_relative "charty/version"
2
2
 
3
+ require "colors"
4
+ require "palette"
5
+
6
+ require_relative "charty/util"
7
+ require_relative "charty/dash_pattern_generator"
3
8
  require_relative "charty/backends"
9
+ require_relative "charty/backend_methods"
4
10
  require_relative "charty/plotter"
11
+ require_relative "charty/index"
5
12
  require_relative "charty/layout"
6
13
  require_relative "charty/linspace"
7
- require_relative "charty/table_adapters"
14
+ require_relative "charty/plotters"
15
+ require_relative "charty/plot_methods"
8
16
  require_relative "charty/table"
17
+ require_relative "charty/table_adapters"
18
+ require_relative "charty/statistics"
19
+ require_relative "charty/vector_adapters"
20
+ require_relative "charty/vector"
@@ -0,0 +1,8 @@
1
+ module Charty
2
+ module BackendMethods
3
+ def use_backend(backend)
4
+ end
5
+ end
6
+
7
+ extend BackendMethods
8
+ end
@@ -6,6 +6,30 @@ module Charty
6
6
  module Backends
7
7
  @backends = {}
8
8
 
9
+ @current = nil
10
+
11
+ def self.current
12
+ @current
13
+ end
14
+
15
+ def self.current=(backend_name)
16
+ backend_class = Backends.find_backend_class(backend_name)
17
+ @current = backend_class.new
18
+ end
19
+
20
+ def self.use(backend)
21
+ if block_given?
22
+ begin
23
+ saved, self.current = self.current, backend
24
+ yield
25
+ ensure
26
+ self.current = saved
27
+ end
28
+ else
29
+ self.current = backend
30
+ end
31
+ end
32
+
9
33
  def self.names
10
34
  @backends.keys
11
35
  end
@@ -48,8 +72,9 @@ module Charty
48
72
  end
49
73
 
50
74
  require "charty/backends/bokeh"
51
- require "charty/backends/google_chart"
75
+ require "charty/backends/google_charts"
52
76
  require "charty/backends/gruff"
53
77
  require "charty/backends/plotly"
54
78
  require "charty/backends/pyplot"
55
79
  require "charty/backends/rubyplot"
80
+ require "charty/backends/unicode_plot"
@@ -17,13 +17,13 @@ module Charty
17
17
  @series = series
18
18
  end
19
19
 
20
- def render(context, filename)
20
+ def old_style_render(context, filename)
21
21
  plot = plot(context)
22
22
  save(plot, context, filename)
23
23
  PyCall.import_module('bokeh.io').show(plot)
24
24
  end
25
25
 
26
- def save(plot, context, filename)
26
+ def old_style_save(plot, context, filename)
27
27
  if filename
28
28
  PyCall.import_module('bokeh.io').save(plot, filename)
29
29
  end
@@ -37,42 +37,42 @@ module Charty
37
37
  plot.yaxis[0].axis_label = context&.ylabel
38
38
 
39
39
  case context.method
40
- when :bar
41
- context.series.each do |data|
42
- diffs = data.xs.to_a.each_cons(2).map {|n, m| (n - m).abs }
43
- width = diffs.min * 0.8
44
- plot.vbar(data.xs.to_a, width, data.ys.to_a)
45
- end
40
+ when :bar
41
+ context.series.each do |data|
42
+ diffs = data.xs.to_a.each_cons(2).map {|n, m| (n - m).abs }
43
+ width = diffs.min * 0.8
44
+ plot.vbar(data.xs.to_a, width, data.ys.to_a)
45
+ end
46
46
 
47
- when :barh
48
- context.series.each do |data|
49
- diffs = data.xs.to_a.each_cons(2).map {|n, m| (n - m).abs }
50
- height = diffs.min * 0.8
51
- plot.hbar(data.xs.to_a, height, data.ys.to_a)
52
- end
47
+ when :barh
48
+ context.series.each do |data|
49
+ diffs = data.xs.to_a.each_cons(2).map {|n, m| (n - m).abs }
50
+ height = diffs.min * 0.8
51
+ plot.hbar(data.xs.to_a, height, data.ys.to_a)
52
+ end
53
53
 
54
- when :boxplot
55
- raise NotImplementedError
54
+ when :boxplot
55
+ raise NotImplementedError
56
56
 
57
- when :bubble
58
- raise NotImplementedError
57
+ when :bubble
58
+ raise NotImplementedError
59
59
 
60
- when :curve
61
- context.series.each do |data|
62
- plot.line(data.xs.to_a, data.ys.to_a)
63
- end
60
+ when :curve
61
+ context.series.each do |data|
62
+ plot.line(data.xs.to_a, data.ys.to_a)
63
+ end
64
64
 
65
- when :scatter
66
- context.series.each do |data|
67
- plot.scatter(data.xs.to_a, data.ys.to_a)
68
- end
65
+ when :scatter
66
+ context.series.each do |data|
67
+ plot.scatter(data.xs.to_a, data.ys.to_a)
68
+ end
69
69
 
70
- when :error_bar
71
- raise NotImplementedError
70
+ when :error_bar
71
+ raise NotImplementedError
72
72
 
73
- when :hist
74
- raise NotImplementedError
75
- end
73
+ when :hist
74
+ raise NotImplementedError
75
+ end
76
76
  plot
77
77
  end
78
78
  end
@@ -1,7 +1,7 @@
1
1
  module Charty
2
2
  module Backends
3
- class GoogleChart
4
- Backends.register(:google_chart, self)
3
+ class GoogleCharts
4
+ Backends.register(:google_charts, self)
5
5
 
6
6
  attr_reader :context
7
7
 
@@ -33,7 +33,7 @@ module Charty
33
33
  @series = series
34
34
  end
35
35
 
36
- def render(context, filename)
36
+ def old_style_render(context, filename)
37
37
  plot(nil, context)
38
38
  end
39
39
 
@@ -70,47 +70,65 @@ module Charty
70
70
  def data_column_js
71
71
  case context.method
72
72
  when :bubble
73
- column_js = <<-COLUMN_JS
74
- data.addColumn('string', 'ID');
75
- data.addColumn('number', 'X');
76
- data.addColumn('number', 'Y');
77
- data.addColumn('string', 'GROUP');
78
- data.addColumn('number', 'SIZE');
79
- COLUMN_JS
73
+ schema = [
74
+ ["string", "ID"],
75
+ ["number", "X"],
76
+ ["number", "Y"],
77
+ ["string", "GROUP"],
78
+ ["number", "SIZE"],
79
+ ]
80
80
  when :curve
81
- column_js = "data.addColumn('number', '#{context.xlabel}');"
81
+ schema = []
82
+ schema << [detect_type(context.series.first.xs), context.xlabel]
82
83
  context.series.to_a.each_with_index do |series_data, index|
83
- column_js << "data.addColumn('number', '#{series_data.label || index}');"
84
+ schema << ["number", series_data.label || index]
84
85
  end
85
86
  else
86
- column_js = "data.addColumn('string', '#{context.xlabel}');"
87
+ schema = ["string", context.xlabel]
87
88
  context.series.to_a.each_with_index do |series_data, index|
88
- column_js << "data.addColumn('number', '#{series_data.label || index}');"
89
+ schema << ["number", series_data.label || index]
89
90
  end
90
91
  end
91
92
 
92
- column_js
93
+ columns = schema.collect do |type, label|
94
+ "data.addColumn(#{type.to_json}, #{label.to_s.to_json});"
95
+ end
96
+ columns.join
97
+ end
98
+
99
+ def detect_type(values)
100
+ case values.first
101
+ when Time
102
+ "date"
103
+ when String
104
+ "string"
105
+ else
106
+ "number"
107
+ end
93
108
  end
94
109
 
95
110
  def x_labels
96
- [].tap do |label|
97
- context.series.each do |series|
98
- xs_series = if series.xs.detect { |xs_data| xs_data.is_a?(String) }
99
- series.xs.sort_by {|x| format('%10s', "#{x}")}
100
- else
101
- series.xs.sort
102
- end
103
- xs_series.each do |xs_data|
104
- label << xs_data unless label.any? { |label| label == xs_data }
105
- end
111
+ labels = {}
112
+ have_string = false
113
+ context.series.each do |series|
114
+ series.xs.each do |x|
115
+ next if labels.key?(x)
116
+ have_string = true if x.is_a?(String)
117
+ labels[x] = true
106
118
  end
107
119
  end
120
+ if have_string
121
+ labels.keys.sort_by {|label| "%10s" % x.to_s}
122
+ else
123
+ labels.keys.sort
124
+ end
108
125
  end
109
126
 
110
127
  def data_hash
111
128
  {}.tap do |hash|
129
+ _x_labels = x_labels
112
130
  context.series.to_a.each_with_index do |series_data, series_index|
113
- x_labels.each do |x_label|
131
+ _x_labels.each do |x_label|
114
132
  unless hash[x_label]
115
133
  hash[x_label] = []
116
134
  end
@@ -118,14 +136,14 @@ module Charty
118
136
  if data_index = series_data.xs.to_a.index(x_label)
119
137
  hash[x_label] << series_data.ys.to_a[data_index]
120
138
  else
121
- hash[x_label] << "null"
139
+ hash[x_label] << nil
122
140
  end
123
141
  end
124
142
  end
125
143
  end
126
144
  end
127
145
 
128
- def formatted_data_array
146
+ def rows
129
147
  case context.method
130
148
  when :bubble
131
149
  [].tap do |data_array|
@@ -133,10 +151,10 @@ module Charty
133
151
  series_data.xs.to_a.each_with_index do |data, data_index|
134
152
  data_array << [
135
153
  "",
136
- series_data.xs.to_a[data_index] || "null",
137
- series_data.ys.to_a[data_index] || "null",
154
+ series_data.xs.to_a[data_index],
155
+ series_data.ys.to_a[data_index],
138
156
  series_data[:label] || series_index.to_s,
139
- series_data.zs.to_a[data_index] || "null",
157
+ series_data.zs.to_a[data_index],
140
158
  ]
141
159
  end
142
160
  end
@@ -150,12 +168,36 @@ module Charty
150
168
  else
151
169
  [].tap do |data_array|
152
170
  data_hash.each do |k, v|
153
- data_array << [k.to_s, v].flatten
171
+ data_array << [k, v].flatten
154
172
  end
155
173
  end
156
174
  end
157
175
  end
158
176
 
177
+ def convert_to_javascript(data)
178
+ case data
179
+ when Array
180
+ converted_data = data.collect do |element|
181
+ convert_to_javascript(element)
182
+ end
183
+ "[#{converted_data.join(", ")}]"
184
+ when Time
185
+ time = data.dup.utc
186
+ args = [
187
+ time.year,
188
+ time.month - 1,
189
+ time.day,
190
+ time.hour,
191
+ time.min,
192
+ time.sec,
193
+ time.nsec / 1000 / 1000,
194
+ ]
195
+ "new Date(Date.UTC(#{args.join(", ")}))"
196
+ else
197
+ data.to_json
198
+ end
199
+ end
200
+
159
201
  def x_range_option
160
202
  x_range = if context.method != :barh
161
203
  context&.range&.fetch(:x, nil)
@@ -189,7 +231,7 @@ module Charty
189
231
  function drawChart() {
190
232
  const data = new google.visualization.DataTable();
191
233
  #{data_column_js}
192
- data.addRows(#{formatted_data_array})
234
+ data.addRows(#{convert_to_javascript(rows)})
193
235
 
194
236
  const view = new google.visualization.DataView(data);
195
237
 
@@ -26,7 +26,7 @@ module Charty
26
26
  raise NotImplementedError
27
27
  end
28
28
 
29
- def render(context, filename="")
29
+ def old_style_render(context, filename="")
30
30
  FileUtils.mkdir_p(File.dirname(filename))
31
31
  plot(@plot, context).write(filename)
32
32
  end
@@ -83,7 +83,7 @@ module Charty
83
83
  p.x_axis_label = context.xlabel if context.xlabel
84
84
  p.y_axis_label = context.ylabel if context.ylabel
85
85
  context.series.each do |data|
86
- p.data(data.label, data.xs.to_a)
86
+ p.dataxy(data.label, data.xs.to_a, data.ys.to_a)
87
87
  end
88
88
  p
89
89
  when :scatter
@@ -99,7 +99,18 @@ module Charty
99
99
  # refs. https://github.com/topfunky/gruff/issues/163
100
100
  raise NotImplementedError
101
101
  when :hist
102
- raise NotImplementedError
102
+ p = plot::Histogram.new
103
+ p.title = context.title if context.title
104
+ p.x_axis_label = context.xlabel if context.xlabel
105
+ p.y_axis_label = context.ylabel if context.ylabel
106
+ if context.range_x
107
+ p.minimum_bin = context.range_x.first
108
+ p.maximum_bin = context.range_x.last
109
+ end
110
+ context.data.each do |data|
111
+ p.data('', data.to_a)
112
+ end
113
+ p
103
114
  end
104
115
  end
105
116
  end
@@ -1,4 +1,6 @@
1
- require 'json'
1
+ require "json"
2
+ require "securerandom"
3
+ require "tmpdir"
2
4
 
3
5
  module Charty
4
6
  module Backends
@@ -35,7 +37,7 @@ module Charty
35
37
  @series = series
36
38
  end
37
39
 
38
- def render(context, filename)
40
+ def old_style_render(context, filename)
39
41
  plot(nil, context)
40
42
  end
41
43
 
@@ -55,24 +57,22 @@ module Charty
55
57
  end
56
58
  end
57
59
 
58
- private
59
-
60
- def plotly_load_tag
60
+ private def plotly_load_tag
61
61
  if self.class.with_api_load_tag
62
62
  "<script type='text/javascript' src='#{self.class.plotly_src}'></script>"
63
63
  else
64
64
  end
65
65
  end
66
66
 
67
- def div_id
67
+ private def div_id
68
68
  "charty-plotly-#{self.class.chart_id}"
69
69
  end
70
70
 
71
- def div_style
71
+ private def div_style
72
72
  "width: 100%;height: 100%;"
73
73
  end
74
74
 
75
- def render_graph(context, type, options: {})
75
+ private def render_graph(context, type, options: {})
76
76
  data = context.series.map do |series|
77
77
  {
78
78
  type: type,
@@ -95,7 +95,7 @@ module Charty
95
95
  render_html(data, layout)
96
96
  end
97
97
 
98
- def render_html(data, layout)
98
+ private def render_html(data, layout)
99
99
  <<~FRAGMENT
100
100
  #{plotly_load_tag unless self.class.chart_id > 1}
101
101
  <div id="#{div_id}" style="#{div_style}"></div>
@@ -104,6 +104,771 @@ module Charty
104
104
  </script>
105
105
  FRAGMENT
106
106
  end
107
+
108
+ # ==== NEW PLOTTING API ====
109
+
110
+ class HTML
111
+ def initialize(html)
112
+ @html = html
113
+ end
114
+
115
+ def to_iruby
116
+ ["text/html", @html]
117
+ end
118
+ end
119
+
120
+ def begin_figure
121
+ @traces = []
122
+ @layout = {showlegend: false}
123
+ end
124
+
125
+ def bar(bar_pos, group_names, values, colors, orient, label: nil, width: 0.8r,
126
+ align: :center, conf_int: nil, error_colors: nil, error_width: nil, cap_size: nil)
127
+ bar_pos = Array(bar_pos)
128
+ values = Array(values)
129
+ colors = Array(colors).map(&:to_hex_string)
130
+
131
+ if orient == :v
132
+ x, y = bar_pos, values
133
+ x = group_names unless group_names.nil?
134
+ else
135
+ x, y = values, bar_pos
136
+ y = group_names unless group_names.nil?
137
+ end
138
+
139
+ trace = {
140
+ type: :bar,
141
+ orientation: orient,
142
+ x: x,
143
+ y: y,
144
+ width: width,
145
+ marker: {color: colors}
146
+ }
147
+ trace[:name] = label unless label.nil?
148
+
149
+ unless conf_int.nil?
150
+ errors_low = conf_int.map.with_index {|(low, _), i| values[i] - low }
151
+ errors_high = conf_int.map.with_index {|(_, high), i| high - values[i] }
152
+
153
+ error_bar = {
154
+ type: :data,
155
+ visible: true,
156
+ symmetric: false,
157
+ array: errors_high,
158
+ arrayminus: errors_low,
159
+ color: error_colors[0].to_hex_string
160
+ }
161
+ error_bar[:thickness] = error_width unless error_width.nil?
162
+ error_bar[:width] = cap_size unless cap_size.nil?
163
+
164
+ error_bar_key = orient == :v ? :error_y : :error_x
165
+ trace[error_bar_key] = error_bar
166
+ end
167
+
168
+ @traces << trace
169
+
170
+ if group_names
171
+ @layout[:barmode] = :group
172
+ end
173
+ end
174
+
175
+ def box_plot(plot_data, group_names,
176
+ orient:, colors:, gray:, dodge:, width: 0.8r,
177
+ flier_size: 5, whisker: 1.5, notch: false)
178
+ colors = Array(colors).map(&:to_hex_string)
179
+ gray = gray.to_hex_string
180
+ width = Float(width)
181
+ flier_size = Float(width)
182
+ whisker = Float(whisker)
183
+
184
+ traces = plot_data.map.with_index do |group_data, i|
185
+ group_data = Array(group_data)
186
+ trace = {
187
+ type: :box,
188
+ orientation: orient,
189
+ name: group_names[i],
190
+ marker: {color: colors[i]}
191
+ }
192
+ if orient == :v
193
+ trace.update(y: group_data)
194
+ else
195
+ trace.update(x: group_data)
196
+ end
197
+
198
+ trace
199
+ end
200
+
201
+ traces.reverse! if orient == :h
202
+
203
+ @traces.concat(traces)
204
+ end
205
+
206
+ def grouped_box_plot(plot_data, group_names, color_names,
207
+ orient:, colors:, gray:, dodge:, width: 0.8r,
208
+ flier_size: 5, whisker: 1.5, notch: false)
209
+ colors = Array(colors).map(&:to_hex_string)
210
+ gray = gray.to_hex_string
211
+ width = Float(width)
212
+ flier_size = Float(width)
213
+ whisker = Float(whisker)
214
+
215
+ @layout[:boxmode] = :group
216
+
217
+ if orient == :h
218
+ @layout[:xaxis] ||= {}
219
+ @layout[:xaxis][:zeroline] = false
220
+
221
+ plot_data = plot_data.map {|d| d.reverse }
222
+ group_names = group_names.reverse
223
+ end
224
+
225
+ traces = color_names.map.with_index do |color_name, i|
226
+ group_keys = group_names.flat_map.with_index { |name, j|
227
+ Array.new(plot_data[i][j].length, name)
228
+ }.flatten
229
+
230
+ values = plot_data[i].flat_map {|d| Array(d) }
231
+
232
+ trace = {
233
+ type: :box,
234
+ orientation: orient,
235
+ name: color_name,
236
+ marker: {color: colors[i]}
237
+ }
238
+
239
+ if orient == :v
240
+ trace.update(y: values, x: group_keys)
241
+ else
242
+ trace.update(x: values, y: group_keys)
243
+ end
244
+
245
+ trace
246
+ end
247
+
248
+ @traces.concat(traces)
249
+ end
250
+
251
+ def scatter(x, y, variables, color:, color_mapper:,
252
+ style:, style_mapper:, size:, size_mapper:)
253
+ orig_x, orig_y = x, y
254
+
255
+ x = case x
256
+ when Charty::Vector
257
+ x.to_a
258
+ else
259
+ Array.try_convert(x)
260
+ end
261
+ if x.nil?
262
+ raise ArgumentError, "Invalid value for x: %p" % orig_x
263
+ end
264
+
265
+ y = case y
266
+ when Charty::Vector
267
+ y.to_a
268
+ else
269
+ Array.try_convert(y)
270
+ end
271
+ if y.nil?
272
+ raise ArgumentError, "Invalid value for y: %p" % orig_y
273
+ end
274
+
275
+ unless color.nil? && style.nil?
276
+ grouped_scatter(x, y, variables,
277
+ color: color, color_mapper: color_mapper,
278
+ style: style, style_mapper: style_mapper,
279
+ size: size, size_mapper: size_mapper)
280
+ return
281
+ end
282
+
283
+ trace = {
284
+ type: :scatter,
285
+ mode: :markers,
286
+ x: x,
287
+ y: y,
288
+ marker: {
289
+ line: {
290
+ width: 1,
291
+ color: "#fff"
292
+ },
293
+ size: 10
294
+ }
295
+ }
296
+
297
+ unless size.nil?
298
+ trace[:marker][:size] = size_mapper[size].map {|x| 6.0 + x * 6.0 }
299
+ end
300
+
301
+ @traces << trace
302
+ end
303
+
304
+ private def grouped_scatter(x, y, variables, color:, color_mapper:,
305
+ style:, style_mapper:, size:, size_mapper:)
306
+ @layout[:showlegend] = true
307
+
308
+ groups = (0 ... x.length).group_by do |i|
309
+ key = {}
310
+ key[:color] = color[i] unless color.nil?
311
+ key[:style] = style[i] unless style.nil?
312
+ key
313
+ end
314
+
315
+ groups.each do |group_key, indices|
316
+ trace = {
317
+ type: :scatter,
318
+ mode: :markers,
319
+ x: x.values_at(*indices),
320
+ y: y.values_at(*indices),
321
+ marker: {
322
+ line: {
323
+ width: 1,
324
+ color: "#fff"
325
+ },
326
+ size: 10
327
+ }
328
+ }
329
+
330
+ unless size.nil?
331
+ vals = size.values_at(*indices)
332
+ trace[:marker][:size] = size_mapper[vals].map do |x|
333
+ scale_scatter_point_size(x).to_f
334
+ end
335
+ end
336
+
337
+ name = []
338
+ legend_title = []
339
+
340
+ if group_key.key?(:color)
341
+ trace[:marker][:color] = color_mapper[group_key[:color]].to_hex_string
342
+ name << group_key[:color]
343
+ legend_title << variables[:color]
344
+ end
345
+
346
+ if group_key.key?(:style)
347
+ trace[:marker][:symbol] = style_mapper[group_key[:style], :marker]
348
+ name << group_key[:style]
349
+ legend_title << variables[:style]
350
+ end
351
+
352
+ trace[:name] = name.uniq.join(", ") unless name.empty?
353
+
354
+ @traces << trace
355
+
356
+ unless legend_title.empty?
357
+ @layout[:legend] ||= {}
358
+ @layout[:legend][:title] = {text: legend_title.uniq.join(", ")}
359
+ end
360
+ end
361
+ end
362
+
363
+ def add_scatter_plot_legend(variables, color_mapper, size_mapper, style_mapper, legend)
364
+ if legend == :full
365
+ warn("Plotly backend does not support full verbosity legend")
366
+ end
367
+ end
368
+
369
+ private def scale_scatter_point_size(x)
370
+ min = 6
371
+ max = 12
372
+
373
+ min + x * (max - min)
374
+ end
375
+
376
+ def line(x, y, variables, color:, color_mapper:, size:, size_mapper:, style:, style_mapper:, ci_params:)
377
+ x = case x
378
+ when Charty::Vector
379
+ x.to_a
380
+ else
381
+ orig_x, x = x, Array.try_convert(x)
382
+ if x.nil?
383
+ raise ArgumentError, "Invalid value for x: %p" % orig_x
384
+ end
385
+ end
386
+
387
+ y = case y
388
+ when Charty::Vector
389
+ y.to_a
390
+ else
391
+ orig_y, y = y, Array.try_convert(y)
392
+ if y.nil?
393
+ raise ArgumentError, "Invalid value for y: %p" % orig_y
394
+ end
395
+ end
396
+
397
+ name = []
398
+ legend_title = []
399
+
400
+ if color.nil?
401
+ # TODO: do not hard code this
402
+ line_color = Colors["#1f77b4"] # the first color of D3's category10 palette
403
+ else
404
+ line_color = color_mapper[color].to_rgb
405
+ name << color
406
+ legend_title << variables[:color]
407
+ end
408
+
409
+ unless style.nil?
410
+ marker, dashes = style_mapper[style].values_at(:marker, :dashes)
411
+ name << style
412
+ legend_title << variables[:style]
413
+ end
414
+
415
+ trace = {
416
+ type: :scatter,
417
+ mode: marker.nil? ? "lines" : "lines+markers",
418
+ x: x,
419
+ y: y,
420
+ line: {
421
+ shape: :linear,
422
+ color: line_color.to_hex_string
423
+ }
424
+ }
425
+
426
+ default_line_width = 2.0
427
+ unless size.nil?
428
+ line_width = default_line_width + 2.0 * size_mapper[size]
429
+ trace[:line][:width] = line_width
430
+ end
431
+
432
+ unless dashes.nil?
433
+ trace[:line][:dash] = convert_dash_pattern(dashes, line_width || default_line_width)
434
+ end
435
+
436
+ unless marker.nil?
437
+ trace[:marker] = {
438
+ line: {
439
+ width: 1,
440
+ color: "#fff"
441
+ },
442
+ symbol: marker,
443
+ size: 10
444
+ }
445
+ end
446
+
447
+ unless ci_params.nil?
448
+ case ci_params[:style]
449
+ when :band
450
+ y_min = ci_params[:y_min].to_a
451
+ y_max = ci_params[:y_max].to_a
452
+ @traces << {
453
+ type: :scatter,
454
+ x: x,
455
+ y: y_max,
456
+ mode: :lines,
457
+ line: { shape: :linear, width: 0 },
458
+ showlegend: false
459
+ }
460
+ @traces << {
461
+ type: :scatter,
462
+ x: x,
463
+ y: y_min,
464
+ mode: :lines,
465
+ line: { shape: :linear, width: 0 },
466
+ fill: :tonexty,
467
+ fillcolor: line_color.to_rgba(alpha: 0.2).to_hex_string,
468
+ showlegend: false
469
+ }
470
+ when :bars
471
+ y_min = ci_params[:y_min].map.with_index {|v, i| y[i] - v }
472
+ y_max = ci_params[:y_max].map.with_index {|v, i| v - y[i] }
473
+ trace[:error_y] = {
474
+ visible: true,
475
+ type: :data,
476
+ array: y_max,
477
+ arrayminus: y_min
478
+ }
479
+ unless line_color.nil?
480
+ trace[:error_y][:color] = line_color
481
+ end
482
+ unless line_width.nil?
483
+ trace[:error_y][:thickness] = line_width
484
+ end
485
+ end
486
+ end
487
+
488
+ trace[:name] = name.uniq.join(", ") unless name.empty?
489
+
490
+ @traces << trace
491
+
492
+ unless legend_title.empty?
493
+ @layout[:showlegend] = true
494
+ @layout[:legend] ||= {}
495
+ @layout[:legend][:title] = {text: legend_title.uniq.join(", ")}
496
+ end
497
+ end
498
+
499
+ def add_line_plot_legend(variables, color_mapper, size_mapper, style_mapper, legend)
500
+ if legend == :full
501
+ warn("Plotly backend does not support full verbosity legend")
502
+ end
503
+
504
+ legend_order = if variables.key?(:color)
505
+ if variables.key?(:style)
506
+ # both color and style
507
+ color_mapper.levels.product(style_mapper.levels)
508
+ else
509
+ # only color
510
+ color_mapper.levels
511
+ end
512
+ elsif variables.key?(:style)
513
+ # only style
514
+ style_mapper.levels
515
+ else
516
+ # no legend entries
517
+ nil
518
+ end
519
+
520
+ if legend_order
521
+ # sort traces
522
+ legend_index = legend_order.map.with_index { |name, i|
523
+ [Array(name).uniq.join(", "), i]
524
+ }.to_h
525
+ @traces = @traces.each_with_index.sort_by { |trace, trace_index|
526
+ index = legend_index.fetch(trace[:name], legend_order.length)
527
+ [index, trace_index]
528
+ }.map(&:first)
529
+
530
+ # remove duplicated legend entries
531
+ names = {}
532
+ @traces.each do |trace|
533
+ if trace[:showlegend] != false
534
+ name = trace[:name]
535
+ if name
536
+ if names.key?(name)
537
+ # Hide duplications
538
+ trace[:showlegend] = false
539
+ else
540
+ trace[:showlegend] = true
541
+ names[name] = true
542
+ end
543
+ else
544
+ # Hide no name trace in legend
545
+ trace[:showlegend] = false
546
+ end
547
+ end
548
+ end
549
+ end
550
+ end
551
+
552
+ private def convert_dash_pattern(pattern, line_width)
553
+ case pattern
554
+ when ""
555
+ :solid
556
+ else
557
+ pattern.map {|d| "#{line_width * d}px" }.join(",")
558
+ end
559
+ end
560
+
561
+ def set_xlabel(label)
562
+ @layout[:xaxis] ||= {}
563
+ @layout[:xaxis][:title] = label
564
+ end
565
+
566
+ def set_ylabel(label)
567
+ @layout[:yaxis] ||= {}
568
+ @layout[:yaxis][:title] = label
569
+ end
570
+
571
+ def set_xticks(values)
572
+ @layout[:xaxis] ||= {}
573
+ @layout[:xaxis][:tickmode] = "array"
574
+ @layout[:xaxis][:tickvals] = values
575
+ end
576
+
577
+ def set_yticks(values)
578
+ @layout[:yaxis] ||= {}
579
+ @layout[:yaxis][:tickmode] = "array"
580
+ @layout[:yaxis][:tickvals] = values
581
+ end
582
+
583
+ def set_xtick_labels(labels)
584
+ @layout[:xaxis] ||= {}
585
+ @layout[:xaxis][:tickmode] = "array"
586
+ @layout[:xaxis][:ticktext] = labels
587
+ end
588
+
589
+ def set_ytick_labels(labels)
590
+ @layout[:yaxis] ||= {}
591
+ @layout[:yaxis][:tickmode] = "array"
592
+ @layout[:yaxis][:ticktext] = labels
593
+ end
594
+
595
+ def set_xlim(min, max)
596
+ @layout[:xaxis] ||= {}
597
+ @layout[:xaxis][:range] = [min, max]
598
+ end
599
+
600
+ def set_ylim(min, max)
601
+ @layout[:yaxis] ||= {}
602
+ @layout[:yaxis][:range] = [min, max]
603
+ end
604
+
605
+ def disable_xaxis_grid
606
+ # do nothing
607
+ end
608
+
609
+ def disable_yaxis_grid
610
+ # do nothing
611
+ end
612
+
613
+ def invert_yaxis
614
+ @traces.each do |trace|
615
+ case trace[:type]
616
+ when :bar
617
+ trace[:y].reverse!
618
+ end
619
+ end
620
+
621
+ if @layout[:boxmode] == :group
622
+ @traces.reverse!
623
+ end
624
+
625
+ if @layout[:yaxis] && @layout[:yaxis][:ticktext]
626
+ @layout[:yaxis][:ticktext].reverse!
627
+ end
628
+ end
629
+
630
+ def legend(loc:, title:)
631
+ @layout[:showlegend] = true
632
+ @layout[:legend] = {
633
+ title: {
634
+ text: title
635
+ }
636
+ }
637
+ # TODO: Handle loc
638
+ end
639
+
640
+ def save(filename, format: nil, title: nil, width: 700, height: 500, **kwargs)
641
+ format = detect_format(filename) if format.nil?
642
+
643
+ case format
644
+ when nil, :html, "text/html"
645
+ save_html(filename, title: title, **kwargs)
646
+ when :png, "png", "image/png",
647
+ :jpeg, "jpeg", "image/jpeg"
648
+ render_image(format, filename: filename, notebook: false, title: title, width: width, height: height, **kwargs)
649
+ end
650
+ nil
651
+ end
652
+
653
+ private def detect_format(filename)
654
+ case File.extname(filename).downcase
655
+ when ".htm", ".html"
656
+ :html
657
+ when ".png"
658
+ :png
659
+ when ".jpg", ".jpeg"
660
+ :jpeg
661
+ else
662
+ raise ArgumentError,
663
+ "Unable to infer file type from filename: %p" % filename
664
+ end
665
+ end
666
+
667
+ private def save_html(filename, title:, element_id: nil)
668
+ html = <<~HTML
669
+ <!DOCTYPE html>
670
+ <html>
671
+ <head>
672
+ <meta charset="utf-8">
673
+ <title>%{title}</title>
674
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
675
+ </head>
676
+ <body>
677
+ <div id="%{id}" style="width: 100%%; height:100%%;"></div>
678
+ <script type="text/javascript">
679
+ Plotly.newPlot("%{id}", %{data}, %{layout});
680
+ </script>
681
+ </body>
682
+ </html>
683
+ HTML
684
+
685
+ element_id = SecureRandom.uuid if element_id.nil?
686
+
687
+ html %= {
688
+ title: title || default_html_title,
689
+ id: element_id,
690
+ data: JSON.dump(@traces),
691
+ layout: JSON.dump(@layout)
692
+ }
693
+ File.write(filename, html)
694
+ end
695
+
696
+ private def default_html_title
697
+ "Charty plot"
698
+ end
699
+
700
+ def render(element_id: nil, format: nil, notebook: false)
701
+ case format
702
+ when :html, "html"
703
+ format = "text/html"
704
+ when :png, "png"
705
+ format = "image/png"
706
+ when :jpeg, "jpeg"
707
+ format = "image/jpeg"
708
+ end
709
+
710
+ case format
711
+ when "text/html", nil
712
+ # render html after this case cause
713
+ when "image/png", "image/jpeg"
714
+ image_data = render_image(format, element_id: element_id, notebook: false)
715
+ if notebook
716
+ return [format, image_data]
717
+ else
718
+ return image_data
719
+ end
720
+ else
721
+ raise ArgumentError,
722
+ "Unsupported mime type to render: %p" % format
723
+ end
724
+
725
+ # TODO: size should be customizable
726
+ html = <<~HTML
727
+ <div id="%{id}" style="width: 100%%; height:525px;"></div>
728
+ <script type="text/javascript">
729
+ requirejs(["plotly"], function (Plotly) {
730
+ Plotly.newPlot("%{id}", %{data}, %{layout});
731
+ });
732
+ </script>
733
+ HTML
734
+
735
+ element_id = SecureRandom.uuid if element_id.nil?
736
+
737
+ html %= {
738
+ id: element_id,
739
+ data: JSON.dump(@traces),
740
+ layout: JSON.dump(@layout)
741
+ }
742
+
743
+ if notebook
744
+ IRubyOutput.prepare
745
+ ["text/html", html]
746
+ else
747
+ html
748
+ end
749
+ end
750
+
751
+ private def render_image(format=nil, filename: nil, element_id: nil, notebook: false,
752
+ title: nil, width: nil, height: nil)
753
+ format = "image/png" if format.nil?
754
+ case format
755
+ when :png, "png", :jpeg, "jpeg"
756
+ image_type = format.to_s
757
+ when "image/png", "image/jpeg"
758
+ image_type = format.split("/").last
759
+ else
760
+ raise ArgumentError,
761
+ "Unsupported mime type to render image: %p" % format
762
+ end
763
+
764
+ height = 525 if height.nil?
765
+ width = (height * Math.sqrt(2)).to_i if width.nil?
766
+ title = "Charty plot" if title.nil?
767
+
768
+ element_id = SecureRandom.uuid if element_id.nil?
769
+ element_id = "charty-plotly-#{element_id}"
770
+ Dir.mktmpdir do |tmpdir|
771
+ html_filename = File.join(tmpdir, "%s.html" % element_id)
772
+ save_html(html_filename, title: title, element_id: element_id)
773
+ return self.class.render_image(html_filename, filename, image_type, element_id, width, height)
774
+ end
775
+ end
776
+
777
+ module IRubyOutput
778
+ @prepared = false
779
+
780
+ def self.prepare
781
+ return if @prepared
782
+
783
+ html = <<~HTML
784
+ <script type="text/javascript">
785
+ %{win_config}
786
+ %{mathjax_config}
787
+ require.config({
788
+ paths: {
789
+ plotly: "https://cdn.plot.ly/plotly-latest.min"
790
+ }
791
+ });
792
+ </script>
793
+ HTML
794
+
795
+ html %= {
796
+ win_config: window_plotly_config,
797
+ mathjax_config: mathjax_config
798
+ }
799
+
800
+ IRuby.display(html, mime: "text/html")
801
+ @prepared = true
802
+ end
803
+
804
+ def self.window_plotly_config
805
+ <<~END
806
+ window.PlotlyConfig = {MathJaxConfig: 'local'};
807
+ END
808
+ end
809
+
810
+
811
+ def self.mathjax_config
812
+ <<~END
813
+ if (window.MathJax) {MathJax.Hub.Config({SVG: {font: "STIX-Web"}});}
814
+ END
815
+ end
816
+ end
817
+
818
+ @playwright_fiber = nil
819
+
820
+ def self.ensure_playwright
821
+ if @playwright_fiber.nil?
822
+ begin
823
+ require "playwright"
824
+ rescue LoadError
825
+ $stderr.puts "ERROR: You need to install playwright and playwright-ruby-client before using Plotly renderer"
826
+ raise
827
+ end
828
+
829
+ @playwright_fiber = Fiber.new do
830
+ playwright_cli_executable_path = ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", "npx playwright")
831
+ Playwright.create(playwright_cli_executable_path: playwright_cli_executable_path) do |playwright|
832
+ playwright.chromium.launch(headless: true) do |browser|
833
+ request = Fiber.yield
834
+ loop do
835
+ result = nil
836
+ case request.shift
837
+ when :finish
838
+ break
839
+ when :render
840
+ input, output, format, element_id, width, height = request
841
+
842
+ page = browser.new_page
843
+ page.set_viewport_size(width: width, height: height)
844
+ page.goto("file://#{input}")
845
+ element = page.query_selector("\##{element_id}")
846
+
847
+ kwargs = {type: format}
848
+ kwargs[:path] = output unless output.nil?
849
+ result = element.screenshot(**kwargs)
850
+ end
851
+ request = Fiber.yield(result)
852
+ end
853
+ end
854
+ end
855
+ end
856
+ @playwright_fiber.resume
857
+ end
858
+ end
859
+
860
+ def self.terminate_playwright
861
+ return if @playwright_fiber.nil?
862
+
863
+ @playwright_fiber.resume([:finish])
864
+ end
865
+
866
+ at_exit { terminate_playwright }
867
+
868
+ def self.render_image(input, output, format, element_id, width, height)
869
+ ensure_playwright if @playwright_fiber.nil?
870
+ @playwright_fiber.resume([:render, input, output, format.to_s, element_id, width, height])
871
+ end
107
872
  end
108
873
  end
109
874
  end