charty 0.1.5.dev → 0.2.5

Sign up to get free protection for your applications and to get access to all the features.
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