charty 0.1.5.dev → 0.2.5

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 (87) 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 +176 -9
  8. data/Rakefile +4 -5
  9. data/charty.gemspec +10 -1
  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_bokeh.ipynb +156 -0
  19. data/examples/sample_google_chart.ipynb +229 -68
  20. data/examples/sample_images/bar_bokeh.html +85 -0
  21. data/examples/sample_images/barh_bokeh.html +85 -0
  22. data/examples/sample_images/box_plot_bokeh.html +85 -0
  23. data/examples/sample_images/curve_bokeh.html +85 -0
  24. data/examples/sample_images/curve_with_function_bokeh.html +85 -0
  25. data/examples/sample_images/hist_gruff.png +0 -0
  26. data/examples/sample_images/scatter_bokeh.html +85 -0
  27. data/examples/sample_pyplot.ipynb +40 -38
  28. data/images/penguins_body_mass_g_flipper_length_mm_scatter_plot.png +0 -0
  29. data/images/penguins_body_mass_g_flipper_length_mm_species_scatter_plot.png +0 -0
  30. data/images/penguins_body_mass_g_flipper_length_mm_species_sex_scatter_plot.png +0 -0
  31. data/images/penguins_species_body_mass_g_bar_plot_h.png +0 -0
  32. data/images/penguins_species_body_mass_g_bar_plot_v.png +0 -0
  33. data/images/penguins_species_body_mass_g_box_plot_h.png +0 -0
  34. data/images/penguins_species_body_mass_g_box_plot_v.png +0 -0
  35. data/images/penguins_species_body_mass_g_sex_bar_plot_v.png +0 -0
  36. data/images/penguins_species_body_mass_g_sex_box_plot_v.png +0 -0
  37. data/lib/charty.rb +14 -1
  38. data/lib/charty/backend_methods.rb +8 -0
  39. data/lib/charty/backends.rb +80 -0
  40. data/lib/charty/backends/bokeh.rb +32 -26
  41. data/lib/charty/backends/google_charts.rb +267 -0
  42. data/lib/charty/backends/gruff.rb +102 -83
  43. data/lib/charty/backends/plotly.rb +685 -0
  44. data/lib/charty/backends/pyplot.rb +586 -92
  45. data/lib/charty/backends/rubyplot.rb +82 -74
  46. data/lib/charty/backends/unicode_plot.rb +79 -0
  47. data/lib/charty/index.rb +213 -0
  48. data/lib/charty/linspace.rb +1 -1
  49. data/lib/charty/missing_value_support.rb +14 -0
  50. data/lib/charty/plot_methods.rb +184 -0
  51. data/lib/charty/plotter.rb +48 -40
  52. data/lib/charty/plotters.rb +11 -0
  53. data/lib/charty/plotters/abstract_plotter.rb +183 -0
  54. data/lib/charty/plotters/bar_plotter.rb +201 -0
  55. data/lib/charty/plotters/box_plotter.rb +79 -0
  56. data/lib/charty/plotters/categorical_plotter.rb +380 -0
  57. data/lib/charty/plotters/count_plotter.rb +7 -0
  58. data/lib/charty/plotters/estimation_support.rb +84 -0
  59. data/lib/charty/plotters/random_support.rb +25 -0
  60. data/lib/charty/plotters/relational_plotter.rb +518 -0
  61. data/lib/charty/plotters/scatter_plotter.rb +104 -0
  62. data/lib/charty/plotters/vector_plotter.rb +6 -0
  63. data/lib/charty/statistics.rb +114 -0
  64. data/lib/charty/table.rb +80 -3
  65. data/lib/charty/table_adapters.rb +25 -0
  66. data/lib/charty/table_adapters/active_record_adapter.rb +63 -0
  67. data/lib/charty/table_adapters/base_adapter.rb +69 -0
  68. data/lib/charty/table_adapters/daru_adapter.rb +70 -0
  69. data/lib/charty/table_adapters/datasets_adapter.rb +49 -0
  70. data/lib/charty/table_adapters/hash_adapter.rb +224 -0
  71. data/lib/charty/table_adapters/narray_adapter.rb +76 -0
  72. data/lib/charty/table_adapters/nmatrix_adapter.rb +67 -0
  73. data/lib/charty/table_adapters/pandas_adapter.rb +81 -0
  74. data/lib/charty/util.rb +20 -0
  75. data/lib/charty/vector.rb +69 -0
  76. data/lib/charty/vector_adapters.rb +183 -0
  77. data/lib/charty/vector_adapters/array_adapter.rb +109 -0
  78. data/lib/charty/vector_adapters/daru_adapter.rb +171 -0
  79. data/lib/charty/vector_adapters/narray_adapter.rb +187 -0
  80. data/lib/charty/vector_adapters/nmatrix_adapter.rb +37 -0
  81. data/lib/charty/vector_adapters/numpy_adapter.rb +168 -0
  82. data/lib/charty/vector_adapters/pandas_adapter.rb +200 -0
  83. data/lib/charty/version.rb +1 -1
  84. metadata +179 -10
  85. data/.travis.yml +0 -11
  86. data/lib/charty/backends/google_chart.rb +0 -167
  87. data/lib/charty/plotter_adapter.rb +0 -17
@@ -0,0 +1,685 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "tmpdir"
4
+
5
+ module Charty
6
+ module Backends
7
+ class Plotly
8
+ Backends.register(:plotly, self)
9
+
10
+ attr_reader :context
11
+
12
+ class << self
13
+ attr_writer :chart_id, :with_api_load_tag, :plotly_src
14
+
15
+ def chart_id
16
+ @chart_id ||= 0
17
+ end
18
+
19
+ def with_api_load_tag
20
+ return @with_api_load_tag unless @with_api_load_tag.nil?
21
+
22
+ @with_api_load_tag = true
23
+ end
24
+
25
+ def plotly_src
26
+ @plotly_src ||= 'https://cdn.plot.ly/plotly-latest.min.js'
27
+ end
28
+ end
29
+
30
+ def initilize
31
+ end
32
+
33
+ def label(x, y)
34
+ end
35
+
36
+ def series=(series)
37
+ @series = series
38
+ end
39
+
40
+ def old_style_render(context, filename)
41
+ plot(nil, context)
42
+ end
43
+
44
+ def plot(plot, context)
45
+ context = context
46
+ self.class.chart_id += 1
47
+
48
+ case context.method
49
+ when :bar
50
+ render_graph(context, :bar)
51
+ when :curve
52
+ render_graph(context, :scatter)
53
+ when :scatter
54
+ render_graph(context, nil, options: {data: {mode: "markers"}})
55
+ else
56
+ raise NotImplementedError
57
+ end
58
+ end
59
+
60
+ private def plotly_load_tag
61
+ if self.class.with_api_load_tag
62
+ "<script type='text/javascript' src='#{self.class.plotly_src}'></script>"
63
+ else
64
+ end
65
+ end
66
+
67
+ private def div_id
68
+ "charty-plotly-#{self.class.chart_id}"
69
+ end
70
+
71
+ private def div_style
72
+ "width: 100%;height: 100%;"
73
+ end
74
+
75
+ private def render_graph(context, type, options: {})
76
+ data = context.series.map do |series|
77
+ {
78
+ type: type,
79
+ x: series.xs.to_a,
80
+ y: series.ys.to_a,
81
+ name: series.label
82
+ }.merge(options[:data] || {})
83
+ end
84
+ layout = {
85
+ title: { text: context.title },
86
+ xaxis: {
87
+ title: context.xlabel,
88
+ range: [context.range[:x].first, context.range[:x].last]
89
+ },
90
+ yaxis: {
91
+ title: context.ylabel,
92
+ range: [context.range[:y].first, context.range[:y].last]
93
+ }
94
+ }
95
+ render_html(data, layout)
96
+ end
97
+
98
+ private def render_html(data, layout)
99
+ <<~FRAGMENT
100
+ #{plotly_load_tag unless self.class.chart_id > 1}
101
+ <div id="#{div_id}" style="#{div_style}"></div>
102
+ <script>
103
+ Plotly.plot('#{div_id}', #{JSON.dump(data)}, #{JSON.dump(layout)} );
104
+ </script>
105
+ FRAGMENT
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, legend:, color:, color_mapper:,
252
+ style:, style_mapper:, size:, size_mapper:)
253
+ if legend == :full
254
+ warn("Plotly backend does not support full verbosity legend")
255
+ end
256
+
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, legend: legend,
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, legend:, 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(&method(:scale_scatter_point_size))
337
+ end
338
+
339
+ name = []
340
+ legend_title = []
341
+
342
+ if group_key.key?(:color)
343
+ trace[:marker][:color] = color_mapper[group_key[:color]].to_hex_string
344
+ name << group_key[:color]
345
+ legend_title << variables[:color]
346
+ end
347
+
348
+ if group_key.key?(:style)
349
+ trace[:marker][:symbol] = style_mapper[group_key[:style], :marker]
350
+ name << group_key[:style]
351
+ legend_title << variables[:style]
352
+ end
353
+
354
+ trace[:name] = name.uniq.join(", ") unless name.empty?
355
+
356
+ @traces << trace
357
+
358
+ unless legend_title.empty?
359
+ @layout[:legend] ||= {}
360
+ @layout[:legend][:title] = {text: legend_title.uniq.join(", ")}
361
+ end
362
+ end
363
+ end
364
+
365
+ private def scale_scatter_point_size(x)
366
+ min = 6
367
+ max = 12
368
+
369
+ min + x * (max - min)
370
+ end
371
+
372
+ def set_xlabel(label)
373
+ @layout[:xaxis] ||= {}
374
+ @layout[:xaxis][:title] = label
375
+ end
376
+
377
+ def set_ylabel(label)
378
+ @layout[:yaxis] ||= {}
379
+ @layout[:yaxis][:title] = label
380
+ end
381
+
382
+ def set_xticks(values)
383
+ @layout[:xaxis] ||= {}
384
+ @layout[:xaxis][:tickmode] = "array"
385
+ @layout[:xaxis][:tickvals] = values
386
+ end
387
+
388
+ def set_yticks(values)
389
+ @layout[:yaxis] ||= {}
390
+ @layout[:yaxis][:tickmode] = "array"
391
+ @layout[:yaxis][:tickvals] = values
392
+ end
393
+
394
+ def set_xtick_labels(labels)
395
+ @layout[:xaxis] ||= {}
396
+ @layout[:xaxis][:tickmode] = "array"
397
+ @layout[:xaxis][:ticktext] = labels
398
+ end
399
+
400
+ def set_ytick_labels(labels)
401
+ @layout[:yaxis] ||= {}
402
+ @layout[:yaxis][:tickmode] = "array"
403
+ @layout[:yaxis][:ticktext] = labels
404
+ end
405
+
406
+ def set_xlim(min, max)
407
+ @layout[:xaxis] ||= {}
408
+ @layout[:xaxis][:range] = [min, max]
409
+ end
410
+
411
+ def set_ylim(min, max)
412
+ @layout[:yaxis] ||= {}
413
+ @layout[:yaxis][:range] = [min, max]
414
+ end
415
+
416
+ def disable_xaxis_grid
417
+ # do nothing
418
+ end
419
+
420
+ def disable_yaxis_grid
421
+ # do nothing
422
+ end
423
+
424
+ def invert_yaxis
425
+ @traces.each do |trace|
426
+ case trace[:type]
427
+ when :bar
428
+ trace[:y].reverse!
429
+ end
430
+ end
431
+
432
+ if @layout[:boxmode] == :group
433
+ @traces.reverse!
434
+ end
435
+
436
+ if @layout[:yaxis] && @layout[:yaxis][:ticktext]
437
+ @layout[:yaxis][:ticktext].reverse!
438
+ end
439
+ end
440
+
441
+ def legend(loc:, title:)
442
+ @layout[:showlegend] = true
443
+ @layout[:legend] = {
444
+ title: {
445
+ text: title
446
+ }
447
+ }
448
+ # TODO: Handle loc
449
+ end
450
+
451
+ def save(filename, format: nil, title: nil, width: 700, height: 500, **kwargs)
452
+ format = detect_format(filename) if format.nil?
453
+
454
+ case format
455
+ when nil, :html, "text/html"
456
+ save_html(filename, title: title, **kwargs)
457
+ when :png, "png", "image/png",
458
+ :jpeg, "jpeg", "image/jpeg"
459
+ render_image(format, filename: filename, notebook: false, title: title, width: width, height: height, **kwargs)
460
+ end
461
+ nil
462
+ end
463
+
464
+ private def detect_format(filename)
465
+ case File.extname(filename).downcase
466
+ when ".htm", ".html"
467
+ :html
468
+ when ".png"
469
+ :png
470
+ when ".jpg", ".jpeg"
471
+ :jpeg
472
+ else
473
+ raise ArgumentError,
474
+ "Unable to infer file type from filename: %p" % filename
475
+ end
476
+ end
477
+
478
+ private def save_html(filename, title:, element_id: nil)
479
+ html = <<~HTML
480
+ <!DOCTYPE html>
481
+ <html>
482
+ <head>
483
+ <meta charset="utf-8">
484
+ <title>%{title}</title>
485
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
486
+ </head>
487
+ <body>
488
+ <div id="%{id}" style="width: 100%%; height:100%%;"></div>
489
+ <script type="text/javascript">
490
+ Plotly.newPlot("%{id}", %{data}, %{layout});
491
+ </script>
492
+ </body>
493
+ </html>
494
+ HTML
495
+
496
+ element_id = SecureRandom.uuid if element_id.nil?
497
+
498
+ html %= {
499
+ title: title || default_html_title,
500
+ id: element_id,
501
+ data: JSON.dump(@traces),
502
+ layout: JSON.dump(@layout)
503
+ }
504
+ File.write(filename, html)
505
+ end
506
+
507
+ private def default_html_title
508
+ "Charty plot"
509
+ end
510
+
511
+ def render(element_id: nil, format: nil, notebook: false)
512
+ case format
513
+ when :html, "html"
514
+ format = "text/html"
515
+ when :png, "png"
516
+ format = "image/png"
517
+ when :jpeg, "jpeg"
518
+ format = "image/jpeg"
519
+ end
520
+
521
+ case format
522
+ when "text/html", nil
523
+ # render html after this case cause
524
+ when "image/png", "image/jpeg"
525
+ image_data = render_image(format, element_id: element_id, notebook: false)
526
+ if notebook
527
+ return [format, image_data]
528
+ else
529
+ return image_data
530
+ end
531
+ else
532
+ raise ArgumentError,
533
+ "Unsupported mime type to render: %p" % format
534
+ end
535
+
536
+ # TODO: size should be customizable
537
+ html = <<~HTML
538
+ <div id="%{id}" style="width: 100%%; height:525px;"></div>
539
+ <script type="text/javascript">
540
+ requirejs(["plotly"], function (Plotly) {
541
+ Plotly.newPlot("%{id}", %{data}, %{layout});
542
+ });
543
+ </script>
544
+ HTML
545
+
546
+ element_id = SecureRandom.uuid if element_id.nil?
547
+
548
+ html %= {
549
+ id: element_id,
550
+ data: JSON.dump(@traces),
551
+ layout: JSON.dump(@layout)
552
+ }
553
+
554
+ if notebook
555
+ IRubyOutput.prepare
556
+ ["text/html", html]
557
+ else
558
+ html
559
+ end
560
+ end
561
+
562
+ private def render_image(format=nil, filename: nil, element_id: nil, notebook: false,
563
+ title: nil, width: nil, height: nil)
564
+ format = "image/png" if format.nil?
565
+ case format
566
+ when :png, "png", :jpeg, "jpeg"
567
+ image_type = format.to_s
568
+ when "image/png", "image/jpeg"
569
+ image_type = format.split("/").last
570
+ else
571
+ raise ArgumentError,
572
+ "Unsupported mime type to render image: %p" % format
573
+ end
574
+
575
+ height = 525 if height.nil?
576
+ width = (height * Math.sqrt(2)).to_i if width.nil?
577
+ title = "Charty plot" if title.nil?
578
+
579
+ element_id = SecureRandom.uuid if element_id.nil?
580
+ element_id = "charty-plotly-#{element_id}"
581
+ Dir.mktmpdir do |tmpdir|
582
+ html_filename = File.join(tmpdir, "%s.html" % element_id)
583
+ save_html(html_filename, title: title, element_id: element_id)
584
+ return self.class.render_image(html_filename, filename, image_type, element_id, width, height)
585
+ end
586
+ end
587
+
588
+ module IRubyOutput
589
+ @prepared = false
590
+
591
+ def self.prepare
592
+ return if @prepared
593
+
594
+ html = <<~HTML
595
+ <script type="text/javascript">
596
+ %{win_config}
597
+ %{mathjax_config}
598
+ require.config({
599
+ paths: {
600
+ plotly: "https://cdn.plot.ly/plotly-latest.min"
601
+ }
602
+ });
603
+ </script>
604
+ HTML
605
+
606
+ html %= {
607
+ win_config: window_plotly_config,
608
+ mathjax_config: mathjax_config
609
+ }
610
+
611
+ IRuby.display(html, mime: "text/html")
612
+ @prepared = true
613
+ end
614
+
615
+ def self.window_plotly_config
616
+ <<~END
617
+ window.PlotlyConfig = {MathJaxConfig: 'local'};
618
+ END
619
+ end
620
+
621
+
622
+ def self.mathjax_config
623
+ <<~END
624
+ if (window.MathJax) {MathJax.Hub.Config({SVG: {font: "STIX-Web"}});}
625
+ END
626
+ end
627
+ end
628
+
629
+ @playwright_fiber = nil
630
+
631
+ def self.ensure_playwright
632
+ if @playwright_fiber.nil?
633
+ begin
634
+ require "playwright"
635
+ rescue LoadError
636
+ $stderr.puts "ERROR: You need to install playwright and playwright-ruby-client before using Plotly renderer"
637
+ raise
638
+ end
639
+
640
+ @playwright_fiber = Fiber.new do
641
+ playwright_cli_executable_path = ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", "npx playwright")
642
+ Playwright.create(playwright_cli_executable_path: playwright_cli_executable_path) do |playwright|
643
+ playwright.chromium.launch(headless: true) do |browser|
644
+ request = Fiber.yield
645
+ loop do
646
+ result = nil
647
+ case request.shift
648
+ when :finish
649
+ break
650
+ when :render
651
+ input, output, format, element_id, width, height = request
652
+
653
+ page = browser.new_page
654
+ page.set_viewport_size(width: width, height: height)
655
+ page.goto("file://#{input}")
656
+ element = page.query_selector("\##{element_id}")
657
+
658
+ kwargs = {type: format}
659
+ kwargs[:path] = output unless output.nil?
660
+ result = element.screenshot(**kwargs)
661
+ end
662
+ request = Fiber.yield(result)
663
+ end
664
+ end
665
+ end
666
+ end
667
+ @playwright_fiber.resume
668
+ end
669
+ end
670
+
671
+ def self.terminate_playwright
672
+ return if @playwright_fiber.nil?
673
+
674
+ @playwright_fiber.resume([:finish])
675
+ end
676
+
677
+ at_exit { terminate_playwright }
678
+
679
+ def self.render_image(input, output, format, element_id, width, height)
680
+ ensure_playwright if @playwright_fiber.nil?
681
+ @playwright_fiber.resume([:render, input, output, format.to_s, element_id, width, height])
682
+ end
683
+ end
684
+ end
685
+ end