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
data/Rakefile CHANGED
@@ -1,10 +1,9 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rake/testtask"
3
3
 
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList['test/**/*_test.rb']
4
+ desc "Run tests"
5
+ task :test do
6
+ ruby("test/run.rb")
8
7
  end
9
8
 
10
- task :default => :test
9
+ task default: :test
data/charty.gemspec CHANGED
@@ -26,16 +26,20 @@ Gem::Specification.new do |spec|
26
26
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
27
  spec.require_paths = ["lib"]
28
28
 
29
- spec.add_dependency "red-colors"
30
- spec.add_dependency "red-palette", ">= 0.2.0"
29
+ spec.add_dependency "red-colors", ">= 0.3.0"
30
+ spec.add_dependency "red-datasets", ">= 0.1.2"
31
+ spec.add_dependency "red-palette", ">= 0.5.0"
32
+
33
+ spec.add_dependency "matplotlib", ">= 1.2.0"
34
+ spec.add_dependency "pandas", ">= 0.3.5"
35
+ spec.add_dependency "playwright-ruby-client"
36
+
31
37
  spec.add_development_dependency "bundler", ">= 1.16"
32
38
  spec.add_development_dependency "rake"
33
39
  spec.add_development_dependency "test-unit"
34
- spec.add_development_dependency "numo-narray"
35
- spec.add_development_dependency "nmatrix"
36
- spec.add_development_dependency "red-datasets", ">= 0.0.9"
37
40
  spec.add_development_dependency "daru"
41
+ spec.add_development_dependency "matrix" # need for daru on Ruby > 3.0
38
42
  spec.add_development_dependency "activerecord"
39
43
  spec.add_development_dependency "sqlite3"
40
- spec.add_development_dependency "matplotlib"
44
+ spec.add_development_dependency "iruby", ">= 0.7.0"
41
45
  end
data/lib/charty.rb CHANGED
@@ -3,13 +3,20 @@ require_relative "charty/version"
3
3
  require "colors"
4
4
  require "palette"
5
5
 
6
+ require_relative "charty/cache_dir"
7
+ require_relative "charty/util"
8
+ require_relative "charty/iruby_helper"
9
+ require_relative "charty/dash_pattern_generator"
6
10
  require_relative "charty/backends"
7
11
  require_relative "charty/backend_methods"
8
12
  require_relative "charty/plotter"
13
+ require_relative "charty/index"
9
14
  require_relative "charty/layout"
10
15
  require_relative "charty/linspace"
11
16
  require_relative "charty/plotters"
12
17
  require_relative "charty/plot_methods"
13
- require_relative "charty/table_adapters"
14
18
  require_relative "charty/table"
19
+ require_relative "charty/table_adapters"
15
20
  require_relative "charty/statistics"
21
+ require_relative "charty/vector_adapters"
22
+ require_relative "charty/vector"
@@ -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
@@ -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
 
@@ -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,10 @@
1
- require 'json'
1
+ require "json"
2
+ require "securerandom"
3
+ require "tmpdir"
4
+
5
+ require_relative "plotly_helpers/html_renderer"
6
+ require_relative "plotly_helpers/notebook_renderer"
7
+ require_relative "plotly_helpers/plotly_renderer"
2
8
 
3
9
  module Charty
4
10
  module Backends
@@ -35,7 +41,7 @@ module Charty
35
41
  @series = series
36
42
  end
37
43
 
38
- def render(context, filename)
44
+ def old_style_render(context, filename)
39
45
  plot(nil, context)
40
46
  end
41
47
 
@@ -117,32 +123,489 @@ module Charty
117
123
 
118
124
  def begin_figure
119
125
  @traces = []
120
- @layout = {}
126
+ @layout = {showlegend: false}
121
127
  end
122
128
 
123
- def bar(bar_pos, values, color: nil, width: 0.8r, align: :center, orient: :v)
124
- color = Array(color).map(&:to_hex_string)
125
- @traces << {
129
+ def bar(bar_pos, group_names, values, colors, orient, label: nil, width: 0.8r,
130
+ align: :center, conf_int: nil, error_colors: nil, error_width: nil, cap_size: nil)
131
+ bar_pos = Array(bar_pos)
132
+ values = Array(values)
133
+ colors = Array(colors).map(&:to_hex_string)
134
+
135
+ if orient == :v
136
+ x, y = bar_pos, values
137
+ x = group_names unless group_names.nil?
138
+ else
139
+ x, y = values, bar_pos
140
+ y = group_names unless group_names.nil?
141
+ end
142
+
143
+ trace = {
126
144
  type: :bar,
127
- x: bar_pos,
128
- y: values,
129
- marker: {color: color}
145
+ orientation: orient,
146
+ x: x,
147
+ y: y,
148
+ width: width,
149
+ marker: {color: colors}
130
150
  }
131
- @layout[:showlegend] = false
151
+ trace[:name] = label unless label.nil?
152
+
153
+ unless conf_int.nil?
154
+ errors_low = conf_int.map.with_index {|(low, _), i| values[i] - low }
155
+ errors_high = conf_int.map.with_index {|(_, high), i| high - values[i] }
156
+
157
+ error_bar = {
158
+ type: :data,
159
+ visible: true,
160
+ symmetric: false,
161
+ array: errors_high,
162
+ arrayminus: errors_low,
163
+ color: error_colors[0].to_hex_string
164
+ }
165
+ error_bar[:thickness] = error_width unless error_width.nil?
166
+ error_bar[:width] = cap_size unless cap_size.nil?
167
+
168
+ error_bar_key = orient == :v ? :error_y : :error_x
169
+ trace[error_bar_key] = error_bar
170
+ end
171
+
172
+ @traces << trace
173
+
174
+ if group_names
175
+ @layout[:barmode] = :group
176
+ end
177
+ end
178
+
179
+ def box_plot(plot_data, group_names,
180
+ orient:, colors:, gray:, dodge:, width: 0.8r,
181
+ flier_size: 5, whisker: 1.5, notch: false)
182
+ colors = Array(colors).map(&:to_hex_string)
183
+ gray = gray.to_hex_string
184
+ width = Float(width)
185
+ flier_size = Float(width)
186
+ whisker = Float(whisker)
187
+
188
+ traces = plot_data.map.with_index do |group_data, i|
189
+ group_data = Array(group_data)
190
+ trace = {
191
+ type: :box,
192
+ orientation: orient,
193
+ name: group_names[i],
194
+ marker: {color: colors[i]}
195
+ }
196
+ if orient == :v
197
+ trace.update(y: group_data)
198
+ else
199
+ trace.update(x: group_data)
200
+ end
201
+
202
+ trace
203
+ end
204
+
205
+ traces.reverse! if orient == :h
206
+
207
+ @traces.concat(traces)
208
+ end
209
+
210
+ def grouped_box_plot(plot_data, group_names, color_names,
211
+ orient:, colors:, gray:, dodge:, width: 0.8r,
212
+ flier_size: 5, whisker: 1.5, notch: false)
213
+ colors = Array(colors).map(&:to_hex_string)
214
+ gray = gray.to_hex_string
215
+ width = Float(width)
216
+ flier_size = Float(width)
217
+ whisker = Float(whisker)
218
+
219
+ @layout[:boxmode] = :group
220
+
221
+ if orient == :h
222
+ @layout[:xaxis] ||= {}
223
+ @layout[:xaxis][:zeroline] = false
224
+
225
+ plot_data = plot_data.map {|d| d.reverse }
226
+ group_names = group_names.reverse
227
+ end
228
+
229
+ traces = color_names.map.with_index do |color_name, i|
230
+ group_keys = group_names.flat_map.with_index { |name, j|
231
+ Array.new(plot_data[i][j].length, name)
232
+ }.flatten
233
+
234
+ values = plot_data[i].flat_map {|d| Array(d) }
235
+
236
+ trace = {
237
+ type: :box,
238
+ orientation: orient,
239
+ name: color_name,
240
+ marker: {color: colors[i]}
241
+ }
242
+
243
+ if orient == :v
244
+ trace.update(y: values, x: group_keys)
245
+ else
246
+ trace.update(x: values, y: group_keys)
247
+ end
248
+
249
+ trace
250
+ end
251
+
252
+ @traces.concat(traces)
253
+ end
254
+
255
+ def scatter(x, y, variables, color:, color_mapper:,
256
+ style:, style_mapper:, size:, size_mapper:)
257
+ orig_x, orig_y = x, y
258
+
259
+ x = case x
260
+ when Charty::Vector
261
+ x.to_a
262
+ else
263
+ Array.try_convert(x)
264
+ end
265
+ if x.nil?
266
+ raise ArgumentError, "Invalid value for x: %p" % orig_x
267
+ end
268
+
269
+ y = case y
270
+ when Charty::Vector
271
+ y.to_a
272
+ else
273
+ Array.try_convert(y)
274
+ end
275
+ if y.nil?
276
+ raise ArgumentError, "Invalid value for y: %p" % orig_y
277
+ end
278
+
279
+ unless color.nil? && style.nil?
280
+ grouped_scatter(x, y, variables,
281
+ color: color, color_mapper: color_mapper,
282
+ style: style, style_mapper: style_mapper,
283
+ size: size, size_mapper: size_mapper)
284
+ return
285
+ end
286
+
287
+ trace = {
288
+ type: :scatter,
289
+ mode: :markers,
290
+ x: x,
291
+ y: y,
292
+ marker: {
293
+ line: {
294
+ width: 1,
295
+ color: "#fff"
296
+ },
297
+ size: 10
298
+ }
299
+ }
300
+
301
+ unless size.nil?
302
+ trace[:marker][:size] = size_mapper[size].map {|x| 6.0 + x * 6.0 }
303
+ end
304
+
305
+ @traces << trace
306
+ end
307
+
308
+ private def grouped_scatter(x, y, variables, color:, color_mapper:,
309
+ style:, style_mapper:, size:, size_mapper:)
310
+ @layout[:showlegend] = true
311
+
312
+ groups = (0 ... x.length).group_by do |i|
313
+ key = {}
314
+ key[:color] = color[i] unless color.nil?
315
+ key[:style] = style[i] unless style.nil?
316
+ key
317
+ end
318
+
319
+ groups.each do |group_key, indices|
320
+ trace = {
321
+ type: :scatter,
322
+ mode: :markers,
323
+ x: x.values_at(*indices),
324
+ y: y.values_at(*indices),
325
+ marker: {
326
+ line: {
327
+ width: 1,
328
+ color: "#fff"
329
+ },
330
+ size: 10
331
+ }
332
+ }
333
+
334
+ unless size.nil?
335
+ vals = size.values_at(*indices)
336
+ trace[:marker][:size] = size_mapper[vals].map do |x|
337
+ scale_scatter_point_size(x).to_f
338
+ end
339
+ end
340
+
341
+ name = []
342
+ legend_title = []
343
+
344
+ if group_key.key?(:color)
345
+ trace[:marker][:color] = color_mapper[group_key[:color]].to_hex_string
346
+ name << group_key[:color]
347
+ legend_title << variables[:color]
348
+ end
349
+
350
+ if group_key.key?(:style)
351
+ trace[:marker][:symbol] = style_mapper[group_key[:style], :marker]
352
+ name << group_key[:style]
353
+ legend_title << variables[:style]
354
+ end
355
+
356
+ trace[:name] = name.uniq.join(", ") unless name.empty?
357
+
358
+ @traces << trace
359
+
360
+ unless legend_title.empty?
361
+ @layout[:legend] ||= {}
362
+ @layout[:legend][:title] = {text: legend_title.uniq.join(", ")}
363
+ end
364
+ end
132
365
  end
133
366
 
134
- def box_plot(plot_data, positions, color:, gray:,
135
- width: 0.8r, flier_size: 5, whisker: 1.5, notch: false)
136
- color = Array(color).map(&:to_hex_string)
137
- plot_data.each_with_index do |group_data, i|
138
- data = if group_data.empty?
139
- {type: :box, y: [] }
140
- else
141
- {type: :box, y: group_data, marker: {color: color[i]}}
142
- end
143
- @traces << data
367
+ def add_scatter_plot_legend(variables, color_mapper, size_mapper, style_mapper, legend)
368
+ if legend == :full
369
+ warn("Plotly backend does not support full verbosity legend")
370
+ end
371
+ end
372
+
373
+ private def scale_scatter_point_size(x)
374
+ min = 6
375
+ max = 12
376
+
377
+ min + x * (max - min)
378
+ end
379
+
380
+ def line(x, y, variables, color:, color_mapper:, size:, size_mapper:, style:, style_mapper:, ci_params:)
381
+ x = case x
382
+ when Charty::Vector
383
+ x.to_a
384
+ else
385
+ orig_x, x = x, Array.try_convert(x)
386
+ if x.nil?
387
+ raise ArgumentError, "Invalid value for x: %p" % orig_x
388
+ end
389
+ end
390
+
391
+ y = case y
392
+ when Charty::Vector
393
+ y.to_a
394
+ else
395
+ orig_y, y = y, Array.try_convert(y)
396
+ if y.nil?
397
+ raise ArgumentError, "Invalid value for y: %p" % orig_y
398
+ end
399
+ end
400
+
401
+ name = []
402
+ legend_title = []
403
+
404
+ if color.nil?
405
+ # TODO: do not hard code this
406
+ line_color = Colors["#1f77b4"] # the first color of D3's category10 palette
407
+ else
408
+ line_color = color_mapper[color].to_rgb
409
+ name << color
410
+ legend_title << variables[:color]
411
+ end
412
+
413
+ unless style.nil?
414
+ marker, dashes = style_mapper[style].values_at(:marker, :dashes)
415
+ name << style
416
+ legend_title << variables[:style]
417
+ end
418
+
419
+ trace = {
420
+ type: :scatter,
421
+ mode: marker.nil? ? "lines" : "lines+markers",
422
+ x: x,
423
+ y: y,
424
+ line: {
425
+ shape: :linear,
426
+ color: line_color.to_hex_string
427
+ }
428
+ }
429
+
430
+ default_line_width = 2.0
431
+ unless size.nil?
432
+ line_width = default_line_width + 2.0 * size_mapper[size]
433
+ trace[:line][:width] = line_width
434
+ end
435
+
436
+ unless dashes.nil?
437
+ trace[:line][:dash] = convert_dash_pattern(dashes, line_width || default_line_width)
438
+ end
439
+
440
+ unless marker.nil?
441
+ trace[:marker] = {
442
+ line: {
443
+ width: 1,
444
+ color: "#fff"
445
+ },
446
+ symbol: marker,
447
+ size: 10
448
+ }
449
+ end
450
+
451
+ unless ci_params.nil?
452
+ case ci_params[:style]
453
+ when :band
454
+ y_min = ci_params[:y_min].to_a
455
+ y_max = ci_params[:y_max].to_a
456
+ @traces << {
457
+ type: :scatter,
458
+ x: x,
459
+ y: y_max,
460
+ mode: :lines,
461
+ line: { shape: :linear, width: 0 },
462
+ showlegend: false
463
+ }
464
+ @traces << {
465
+ type: :scatter,
466
+ x: x,
467
+ y: y_min,
468
+ mode: :lines,
469
+ line: { shape: :linear, width: 0 },
470
+ fill: :tonexty,
471
+ fillcolor: line_color.to_rgba(alpha: 0.2).to_hex_string,
472
+ showlegend: false
473
+ }
474
+ when :bars
475
+ y_min = ci_params[:y_min].map.with_index {|v, i| y[i] - v }
476
+ y_max = ci_params[:y_max].map.with_index {|v, i| v - y[i] }
477
+ trace[:error_y] = {
478
+ visible: true,
479
+ type: :data,
480
+ array: y_max,
481
+ arrayminus: y_min
482
+ }
483
+ unless line_color.nil?
484
+ trace[:error_y][:color] = line_color
485
+ end
486
+ unless line_width.nil?
487
+ trace[:error_y][:thickness] = line_width
488
+ end
489
+ end
490
+ end
491
+
492
+ trace[:name] = name.uniq.join(", ") unless name.empty?
493
+
494
+ @traces << trace
495
+
496
+ unless legend_title.empty?
497
+ @layout[:showlegend] = true
498
+ @layout[:legend] ||= {}
499
+ @layout[:legend][:title] = {text: legend_title.uniq.join(", ")}
500
+ end
501
+ end
502
+
503
+ def add_line_plot_legend(variables, color_mapper, size_mapper, style_mapper, legend)
504
+ if legend == :full
505
+ warn("Plotly backend does not support full verbosity legend")
506
+ end
507
+
508
+ legend_order = if variables.key?(:color)
509
+ if variables.key?(:style)
510
+ # both color and style
511
+ color_mapper.levels.product(style_mapper.levels)
512
+ else
513
+ # only color
514
+ color_mapper.levels
515
+ end
516
+ elsif variables.key?(:style)
517
+ # only style
518
+ style_mapper.levels
519
+ else
520
+ # no legend entries
521
+ nil
522
+ end
523
+
524
+ if legend_order
525
+ # sort traces
526
+ legend_index = legend_order.map.with_index { |name, i|
527
+ [Array(name).uniq.join(", "), i]
528
+ }.to_h
529
+ @traces = @traces.each_with_index.sort_by { |trace, trace_index|
530
+ index = legend_index.fetch(trace[:name], legend_order.length)
531
+ [index, trace_index]
532
+ }.map(&:first)
533
+
534
+ # remove duplicated legend entries
535
+ names = {}
536
+ @traces.each do |trace|
537
+ if trace[:showlegend] != false
538
+ name = trace[:name]
539
+ if name
540
+ if names.key?(name)
541
+ # Hide duplications
542
+ trace[:showlegend] = false
543
+ else
544
+ trace[:showlegend] = true
545
+ names[name] = true
546
+ end
547
+ else
548
+ # Hide no name trace in legend
549
+ trace[:showlegend] = false
550
+ end
551
+ end
552
+ end
553
+ end
554
+ end
555
+
556
+ private def convert_dash_pattern(pattern, line_width)
557
+ case pattern
558
+ when ""
559
+ :solid
560
+ else
561
+ pattern.map {|d| "#{line_width * d}px" }.join(",")
562
+ end
563
+ end
564
+
565
+ PLOTLY_HISTNORM = {
566
+ count: "".freeze,
567
+ frequency: "density".freeze,
568
+ density: "probability density".freeze,
569
+ probability: "probability".freeze
570
+ }.freeze
571
+
572
+ def univariate_histogram(data, name, variable_name, stat,
573
+ bin_start, bin_end, bin_size, alpha,
574
+ color, color_mapper)
575
+ orientation = case variable_name
576
+ when :x
577
+ :v
578
+ else
579
+ :h
580
+ end
581
+ trace = {
582
+ type: "histogram",
583
+ name: name.to_s,
584
+ variable_name => data.to_a,
585
+ orientation: orientation,
586
+ histnorm: PLOTLY_HISTNORM[stat],
587
+ "#{variable_name}bins": {
588
+ start: bin_start,
589
+ end: bin_end,
590
+ size: bin_size
591
+ },
592
+ opacity: alpha
593
+ }
594
+
595
+ if color
596
+ trace[:marker] = {
597
+ color: color_mapper[color].to_rgb.to_hex_string
598
+ }
599
+ end
600
+
601
+ @traces << trace
602
+
603
+ @layout[:bargap] = 0.05
604
+
605
+ if @traces.length > 1
606
+ @layout[:barmode] = "overlay"
607
+ @layout[:showlegend] = true
144
608
  end
145
- @layout[:showlegend] = false
146
609
  end
147
610
 
148
611
  def set_xlabel(label)
@@ -161,45 +624,226 @@ module Charty
161
624
  @layout[:xaxis][:tickvals] = values
162
625
  end
163
626
 
627
+ def set_yticks(values)
628
+ @layout[:yaxis] ||= {}
629
+ @layout[:yaxis][:tickmode] = "array"
630
+ @layout[:yaxis][:tickvals] = values
631
+ end
632
+
164
633
  def set_xtick_labels(labels)
165
634
  @layout[:xaxis] ||= {}
166
635
  @layout[:xaxis][:tickmode] = "array"
167
636
  @layout[:xaxis][:ticktext] = labels
168
637
  end
169
638
 
639
+ def set_ytick_labels(labels)
640
+ @layout[:yaxis] ||= {}
641
+ @layout[:yaxis][:tickmode] = "array"
642
+ @layout[:yaxis][:ticktext] = labels
643
+ end
644
+
170
645
  def set_xlim(min, max)
171
646
  @layout[:xaxis] ||= {}
172
647
  @layout[:xaxis][:range] = [min, max]
173
648
  end
174
649
 
650
+ def set_ylim(min, max)
651
+ @layout[:yaxis] ||= {}
652
+ @layout[:yaxis][:range] = [min, max]
653
+ end
654
+
175
655
  def disable_xaxis_grid
176
656
  # do nothing
177
657
  end
178
658
 
179
- def show
180
- unless defined?(IRuby)
181
- raise NotImplementedError,
182
- "Plotly backend outside of IRuby is not supported"
659
+ def disable_yaxis_grid
660
+ # do nothing
661
+ end
662
+
663
+ def invert_yaxis
664
+ @traces.each do |trace|
665
+ case trace[:type]
666
+ when :bar
667
+ trace[:y].reverse!
668
+ end
183
669
  end
184
670
 
185
- IRubyOutput.prepare
671
+ if @layout[:boxmode] == :group
672
+ @traces.reverse!
673
+ end
674
+
675
+ if @layout[:yaxis] && @layout[:yaxis][:ticktext]
676
+ @layout[:yaxis][:ticktext].reverse!
677
+ end
678
+ end
679
+
680
+ def legend(loc:, title:)
681
+ @layout[:showlegend] = true
682
+ @layout[:legend] = {
683
+ title: {
684
+ text: title
685
+ }
686
+ }
687
+ # TODO: Handle loc
688
+ end
689
+
690
+ def save(filename, format: nil, title: nil, width: 700, height: 500, **kwargs)
691
+ format = detect_format(filename) if format.nil?
692
+
693
+ case format
694
+ when nil, :html, "text/html"
695
+ save_html(filename, title: title, **kwargs)
696
+ when :png, "png", "image/png",
697
+ :jpeg, "jpeg", "image/jpeg"
698
+ render_image(format, filename: filename, notebook: false, title: title, width: width, height: height, **kwargs)
699
+ end
700
+ nil
701
+ end
186
702
 
703
+ private def detect_format(filename)
704
+ case File.extname(filename).downcase
705
+ when ".htm", ".html"
706
+ :html
707
+ when ".png"
708
+ :png
709
+ when ".jpg", ".jpeg"
710
+ :jpeg
711
+ else
712
+ raise ArgumentError,
713
+ "Unable to infer file type from filename: %p" % filename
714
+ end
715
+ end
716
+
717
+ private def save_html(filename, title:, element_id: nil)
187
718
  html = <<~HTML
719
+ <!DOCTYPE html>
720
+ <html>
721
+ <head>
722
+ <meta charset="utf-8">
723
+ <title>%{title}</title>
724
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
725
+ </head>
726
+ <body>
188
727
  <div id="%{id}" style="width: 100%%; height:100%%;"></div>
189
728
  <script type="text/javascript">
190
- requirejs(["plotly"], function (Plotly) {
191
- Plotly.newPlot("%{id}", %{data}, %{layout});
192
- });
729
+ Plotly.newPlot("%{id}", %{data}, %{layout});
193
730
  </script>
731
+ </body>
732
+ </html>
194
733
  HTML
195
734
 
735
+ element_id = SecureRandom.uuid if element_id.nil?
736
+
196
737
  html %= {
197
- id: SecureRandom.uuid,
738
+ title: title || default_html_title,
739
+ id: element_id,
198
740
  data: JSON.dump(@traces),
199
741
  layout: JSON.dump(@layout)
200
742
  }
201
- IRuby.display(html, mime: "text/html")
202
- nil
743
+ File.write(filename, html)
744
+ end
745
+
746
+ private def default_html_title
747
+ "Charty plot"
748
+ end
749
+
750
+ def render(element_id: nil, format: nil, notebook: false)
751
+ case format
752
+ when :html, "html", nil
753
+ format = "text/html"
754
+ when :png, "png"
755
+ format = "image/png"
756
+ when :jpeg, "jpeg"
757
+ format = "image/jpeg"
758
+ end
759
+
760
+ case format
761
+ when "text/html"
762
+ # render html after this case cause
763
+ when "image/png", "image/jpeg"
764
+ image_data = render_image(format, element_id: element_id, notebook: false)
765
+ if notebook
766
+ return [format, image_data]
767
+ else
768
+ return image_data
769
+ end
770
+ else
771
+ raise ArgumentError,
772
+ "Unsupported mime type to render: %p" % format
773
+ end
774
+
775
+ element_id = SecureRandom.uuid if element_id.nil?
776
+
777
+ renderer = PlotlyHelpers::HtmlRenderer.new(full_html: !notebook)
778
+ html = renderer.render({data: @traces, layout: @layout}, element_id: element_id)
779
+ if notebook
780
+ [format, html]
781
+ else
782
+ html
783
+ end
784
+ end
785
+
786
+ def render_mimebundle(include: [], exclude: [])
787
+ types = case
788
+ when IRubyHelper.vscode?,
789
+ IRubyHelper.nteract?
790
+ [:plotly_mimetype]
791
+ else
792
+ [:plotly_mimetype, :notebook]
793
+ end
794
+ bundle = Util.filter_map(types) { |type|
795
+ case type
796
+ when :plotly_mimetype
797
+ render_plotly_mimetype_bundle
798
+ when :notebook
799
+ render_notebook_bundle
800
+ end
801
+ }.to_h
802
+ bundle
803
+ end
804
+
805
+ private def render_plotly_mimetype_bundle
806
+ renderer = PlotlyHelpers::PlotlyRenderer.new
807
+ obj = renderer.render({data: @traces, layout: @layout})
808
+ [ "application/vnd.plotly.v1+json", obj ]
809
+ end
810
+
811
+ private def render_notebook_bundle
812
+ renderer = self.class.notebook_renderer
813
+ renderer.activate
814
+ html = renderer.render({data: @traces, layout: @layout})
815
+ [ "text/html", html ]
816
+ end
817
+
818
+ # for new APIs
819
+ def self.notebook_renderer
820
+ @notebook_renderer ||= PlotlyHelpers::NotebookRenderer.new
821
+ end
822
+
823
+ private def render_image(format=nil, filename: nil, element_id: nil, notebook: false,
824
+ title: nil, width: nil, height: nil)
825
+ format = "image/png" if format.nil?
826
+ case format
827
+ when :png, "png", :jpeg, "jpeg"
828
+ image_type = format.to_s
829
+ when "image/png", "image/jpeg"
830
+ image_type = format.split("/").last
831
+ else
832
+ raise ArgumentError,
833
+ "Unsupported mime type to render image: %p" % format
834
+ end
835
+
836
+ height = 525 if height.nil?
837
+ width = (height * Math.sqrt(2)).to_i if width.nil?
838
+ title = "Charty plot" if title.nil?
839
+
840
+ element_id = SecureRandom.uuid if element_id.nil?
841
+ element_id = "charty-plotly-#{element_id}"
842
+ Dir.mktmpdir do |tmpdir|
843
+ html_filename = File.join(tmpdir, "%s.html" % element_id)
844
+ save_html(html_filename, title: title, element_id: element_id)
845
+ return self.class.render_image(html_filename, filename, image_type, element_id, width, height)
846
+ end
203
847
  end
204
848
 
205
849
  module IRubyOutput
@@ -242,6 +886,61 @@ module Charty
242
886
  END
243
887
  end
244
888
  end
889
+
890
+ @playwright_fiber = nil
891
+
892
+ def self.ensure_playwright
893
+ if @playwright_fiber.nil?
894
+ begin
895
+ require "playwright"
896
+ rescue LoadError
897
+ $stderr.puts "ERROR: You need to install playwright and playwright-ruby-client before using Plotly renderer"
898
+ raise
899
+ end
900
+
901
+ @playwright_fiber = Fiber.new do
902
+ playwright_cli_executable_path = ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", "npx playwright")
903
+ Playwright.create(playwright_cli_executable_path: playwright_cli_executable_path) do |playwright|
904
+ playwright.chromium.launch(headless: true) do |browser|
905
+ request = Fiber.yield
906
+ loop do
907
+ result = nil
908
+ case request.shift
909
+ when :finish
910
+ break
911
+ when :render
912
+ input, output, format, element_id, width, height = request
913
+
914
+ page = browser.new_page
915
+ page.set_viewport_size(width: width, height: height)
916
+ page.goto("file://#{input}")
917
+ element = page.query_selector("\##{element_id}")
918
+
919
+ kwargs = {type: format}
920
+ kwargs[:path] = output unless output.nil?
921
+ result = element.screenshot(**kwargs)
922
+ end
923
+ request = Fiber.yield(result)
924
+ end
925
+ end
926
+ end
927
+ end
928
+ @playwright_fiber.resume
929
+ end
930
+ end
931
+
932
+ def self.terminate_playwright
933
+ return if @playwright_fiber.nil?
934
+
935
+ @playwright_fiber.resume([:finish])
936
+ end
937
+
938
+ at_exit { terminate_playwright }
939
+
940
+ def self.render_image(input, output, format, element_id, width, height)
941
+ ensure_playwright if @playwright_fiber.nil?
942
+ @playwright_fiber.resume([:render, input, output, format.to_s, element_id, width, height])
943
+ end
245
944
  end
246
945
  end
247
946
  end