charty 0.2.0 → 0.2.6

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 (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