charty 0.2.3 → 0.2.8

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