rbplotly 0.1.1 → 1.0.0

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 (48) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +64 -0
  3. data/LICENSE.txt +1 -1
  4. data/README.md +177 -39
  5. data/lib/plotly/assets/plotly.min.js +3620 -0
  6. data/lib/plotly/attributes.rb +221 -0
  7. data/lib/plotly/figure.rb +236 -0
  8. data/lib/plotly/html.rb +150 -0
  9. data/lib/plotly/plot.rb +11 -40
  10. data/lib/plotly/schema/plot-schema.json +57234 -0
  11. data/lib/plotly/schema.rb +96 -0
  12. data/lib/plotly/serializer.rb +49 -0
  13. data/lib/plotly/subplots.rb +118 -0
  14. data/lib/plotly/version.rb +7 -1
  15. data/lib/plotly.rb +21 -0
  16. data/lib/rbplotly.rb +3 -4
  17. metadata +34 -162
  18. data/.gitignore +0 -11
  19. data/.rspec +0 -2
  20. data/.rubocop.yml +0 -22
  21. data/.travis.yml +0 -6
  22. data/CODE_OF_CONDUCT.md +0 -49
  23. data/Gemfile +0 -4
  24. data/Guardfile +0 -12
  25. data/Rakefile +0 -9
  26. data/bin/console +0 -14
  27. data/bin/setup +0 -8
  28. data/docs/images/line_chart.png +0 -0
  29. data/examples/basic_bar_chart.ipynb +0 -82
  30. data/examples/basic_histogram.ipynb +0 -78
  31. data/examples/basic_pie_chart.ipynb +0 -84
  32. data/examples/grouped_bar_chart.ipynb +0 -89
  33. data/examples/heatmaps.ipynb +0 -89
  34. data/examples/line_and_scatter_plots.ipynb +0 -227
  35. data/examples/stacked_bar_chart.ipynb +0 -89
  36. data/lib/plotly/axis.rb +0 -15
  37. data/lib/plotly/castable.rb +0 -20
  38. data/lib/plotly/client.rb +0 -40
  39. data/lib/plotly/data.rb +0 -22
  40. data/lib/plotly/exportable.rb +0 -23
  41. data/lib/plotly/layout.rb +0 -33
  42. data/lib/plotly/offline/exportable.rb +0 -31
  43. data/lib/plotly/offline/html.rb +0 -46
  44. data/lib/plotly/offline/plotly.min.js +0 -59
  45. data/lib/plotly/offline/templates/body.erb +0 -26
  46. data/lib/plotly/offline/templates/plot.erb +0 -12
  47. data/lib/plotly/util.rb +0 -11
  48. data/rbplotly.gemspec +0 -31
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "did_you_mean" # explicit, so suggestions work under --disable-did_you_mean too
5
+
6
+ module Plotly
7
+ # Turns user-supplied attribute hashes into plotly.js attribute trees.
8
+ #
9
+ # Keys become strings, underscore paths expand (`marker_line_width: 2` becomes
10
+ # `{"marker" => {"line" => {"width" => 2}}}`) and, unless validation is off, every key and
11
+ # value is checked against the schema. Values themselves are kept as given; converting them
12
+ # to JSON is {Serializer}'s job.
13
+ module Attributes
14
+ module_function
15
+
16
+ # @param node [Schema::Node] schema of the object being built
17
+ # @param attrs [Hash]
18
+ # @param path [String] location used in error messages, e.g. "data[0]" or "layout"
19
+ # @param validate [Boolean]
20
+ # @return [Hash{String => Object}]
21
+ def build(node, attrs, path:, validate: true)
22
+ Builder.new(validate).object(node, attrs, path)
23
+ end
24
+
25
+ # Merges two attribute trees built by {build}; nested hashes merge, everything else is replaced.
26
+ def deep_merge(base, other)
27
+ base.merge(other) do |_key, old, new|
28
+ (old.is_a?(Hash) && new.is_a?(Hash)) ? deep_merge(old, new) : new
29
+ end
30
+ end
31
+
32
+ # Walks one attribute hash against its schema node.
33
+ # @api private
34
+ class Builder
35
+ def initialize(validate)
36
+ @validate = validate
37
+ end
38
+
39
+ def object(node, attrs, path)
40
+ unless attrs.is_a?(Hash)
41
+ raise ValidationError, "#{path}: expected a Hash, got #{attrs.inspect}"
42
+ end
43
+
44
+ attrs.each_with_object({}) do |(key, value), result|
45
+ key = key.to_s
46
+ child = node&.child(key)
47
+ if child.nil? && (expanded = expand(node, key, value))
48
+ assign(result, expanded.first, object(node.child(expanded.first), expanded.last, join(path, expanded.first)))
49
+ else
50
+ unknown!(node, key, path) if child.nil? && node && @validate
51
+ assign(result, key, value(child, value, join(path, key)))
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def join(path, key) = path.empty? ? key : "#{path}.#{key}"
59
+
60
+ def assign(result, key, value)
61
+ result[key] = (result[key].is_a?(Hash) && value.is_a?(Hash)) ? Attributes.deep_merge(result[key], value) : value
62
+ end
63
+
64
+ # "marker_line_width" => ["marker", {"line_width" => value}] when marker is an object.
65
+ def expand(node, key, value)
66
+ return nil unless node
67
+
68
+ positions = (0...key.length).select { |i| key[i] == "_" }.reverse
69
+ positions.each do |i|
70
+ head = key[0...i]
71
+ child = node.child(head)
72
+ return [head, {key[(i + 1)..] => value}] if child&.object?
73
+ end
74
+ nil
75
+ end
76
+
77
+ def value(node, value, path)
78
+ return value if node.nil? || value.nil? # nil unsets an attribute, nested ones included
79
+ return object(node, value, path) if node.object? && value.is_a?(Hash)
80
+
81
+ if node.object?
82
+ invalid_object!(node, value, path) if @validate
83
+ value
84
+ elsif node.array?
85
+ array(node, value, path)
86
+ else
87
+ leaf(node, value, path) if @validate
88
+ value
89
+ end
90
+ end
91
+
92
+ def array(node, value, path)
93
+ unless value.is_a?(Array) && value.all?(Hash)
94
+ return value unless @validate
95
+
96
+ raise ValidationError, "#{path}: expected an Array of Hashes, got #{value.inspect}"
97
+ end
98
+ value.each_with_index.map { |item, i| object(node.item, item, "#{path}[#{i}]") }
99
+ end
100
+
101
+ def leaf(node, value, path, attr_path = path)
102
+ if node.array_ok? && array_like?(value)
103
+ # Nested arrays too: table cells take one value per cell ([[12, 14], ...]).
104
+ value.to_a.each_with_index { |v, i| leaf(node, v, "#{path}[#{i}]", attr_path) }
105
+ else
106
+ scalar(node, value, path, attr_path)
107
+ end
108
+ end
109
+
110
+ def scalar(node, value, path, attr_path)
111
+ return if value.nil?
112
+
113
+ case node.type
114
+ when "enumerated" then enumerated(node, value, path)
115
+ when "flaglist" then flaglist(node, value, path, attr_path)
116
+ when "boolean"
117
+ invalid!(path, "expected true or false, got #{value.inspect}") unless [true, false].include?(value)
118
+ when "number", "integer" then number(node, value, path)
119
+ when "angle" then number(node, value, path) unless value.to_s == "auto"
120
+ end
121
+ end
122
+
123
+ # Arrays, Ranges, Sets and data frame columns; Serializer writes all of them as arrays.
124
+ def array_like?(value)
125
+ case value
126
+ when Array then true
127
+ when String, Symbol, Hash, Numeric, Time, Date, true, false, nil then false
128
+ else value.respond_to?(:to_a)
129
+ end
130
+ end
131
+
132
+ def enumerated(node, value, path)
133
+ given = value.is_a?(Symbol) ? value.to_s : value
134
+ return if node.values.any? { |allowed| allowed_value?(allowed, given) }
135
+
136
+ listed = node.values.reject { |v| pattern?(v) }
137
+ message = "#{given.inspect} is not one of #{listed.map(&:inspect).join(", ")}"
138
+ if given.is_a?(String)
139
+ words = listed.grep(String)
140
+ suggestion = DidYouMean::SpellChecker.new(dictionary: words).correct(given).first
141
+ message += ". Did you mean #{suggestion.inspect}?" if suggestion
142
+ end
143
+ invalid!(path, message)
144
+ end
145
+
146
+ def allowed_value?(allowed, given)
147
+ return Regexp.new(allowed[1..-2]).match?(given) if pattern?(allowed) && given.is_a?(String)
148
+
149
+ allowed == given
150
+ end
151
+
152
+ def pattern?(value) = value.is_a?(String) && value.length > 1 && value.start_with?("/") && value.end_with?("/")
153
+
154
+ def flaglist(node, value, path, attr_path)
155
+ # Extras may be booleans (config.scrollZoom, axis automargin) as well as strings.
156
+ return if node.extras.include?(value) || node.extras.include?(value.to_s)
157
+ if [true, false].include?(value)
158
+ invalid!(path, "#{value} is not allowed for #{attr_path.split(".").last}")
159
+ end
160
+
161
+ given = value.to_s
162
+
163
+ name = attr_path.split(".").last
164
+ given.split("+").each do |flag|
165
+ next if node.flags.include?(flag)
166
+
167
+ suggestion = DidYouMean::SpellChecker.new(dictionary: node.flags).correct(flag).first
168
+ message = "#{flag.inspect} is not a flag of #{name}."
169
+ message += " Did you mean #{suggestion.inspect}?" if suggestion
170
+ message += " Join #{node.flags.map(&:inspect).join(", ")} with \"+\""
171
+ message += case node.extras.size
172
+ when 0 then ""
173
+ when 1 then ", or use #{node.extras.first.inspect}"
174
+ else ", or use one of #{node.extras.map(&:inspect).join(", ")}"
175
+ end
176
+ invalid!(path, message)
177
+ end
178
+ end
179
+
180
+ # Follows plotly.js' coercion: named extras ("bold" for font.weight), numeric strings,
181
+ # and whole floats for integers are accepted.
182
+ def number(node, value, path)
183
+ return if !value.is_a?(Numeric) && node.extras.include?(value.to_s)
184
+
185
+ integer = node.type == "integer"
186
+ number = case value
187
+ when Complex then nil
188
+ when Numeric then value
189
+ when String then Float(value, exception: false)
190
+ end
191
+ ok = !number.nil? && (!integer || (number.finite? && number == number.round))
192
+ invalid!(path, "expected #{integer ? "an integer" : "a number"}, got #{value.inspect}") unless ok
193
+
194
+ invalid!(path, "#{value} is less than the minimum #{node.min}") if node.min && number < node.min
195
+ invalid!(path, "#{value} is greater than the maximum #{node.max}") if node.max && number > node.max
196
+ end
197
+
198
+ def invalid_object!(node, value, path)
199
+ hint = ""
200
+ if value.is_a?(String) && node.attribute_names.include?("text")
201
+ key = path.split(".").last
202
+ underscored = path.split(".").drop(1).join("_")
203
+ hint = ". Plotly.js no longer accepts a plain string here: use #{key}: {text: #{value.inspect}} " \
204
+ "or #{underscored}_text: #{value.inspect}"
205
+ end
206
+ raise ValidationError, "#{path}: expected a Hash of #{node.name} attributes, got #{value.inspect}#{hint}"
207
+ end
208
+
209
+ def invalid!(path, message)
210
+ raise ValidationError, "#{path}: #{message}"
211
+ end
212
+
213
+ def unknown!(node, key, path)
214
+ message = "#{join(path, key)}: #{node.name} has no attribute #{key.inspect}"
215
+ suggestion = DidYouMean::SpellChecker.new(dictionary: node.attribute_names).correct(key).first
216
+ message += ". Did you mean #{suggestion.inspect}?" if suggestion
217
+ raise ValidationError, message
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+
5
+ module Plotly
6
+ # A plotly.js figure: traces (`data`), `layout` and `config`.
7
+ #
8
+ # Every attribute is checked against the schema of the bundled plotly.js release when it
9
+ # is added, so a typo fails where it was written instead of silently drawing nothing.
10
+ #
11
+ # @example
12
+ # fig = Plotly::Figure.new
13
+ # .add_scatter(x: [1, 2, 3], y: [2, 1, 3], mode: :lines, name: "Model")
14
+ # .update_layout(title_text: "Forecast", xaxis_title_text: "Day")
15
+ # fig.write_html("forecast.html")
16
+ class Figure
17
+ # @return [Array<Hash{String => Object}>] traces, normalized. Changing them directly skips validation.
18
+ attr_reader :data
19
+ # @return [Hash{String => Object}] the layout, normalized. Changing it directly skips validation.
20
+ attr_reader :layout
21
+ # @return [Hash{String => Object}] plotly.js config (modebar, responsiveness, ...)
22
+ attr_reader :config
23
+
24
+ # @param data [Array<Hash>] traces; a trace without `type` is a scatter trace
25
+ # @param layout [Hash]
26
+ # @param config [Hash]
27
+ # @param validate [Boolean] check attributes against the plotly.js schema
28
+ def initialize(data: [], layout: {}, config: {}, validate: true)
29
+ @validate = validate
30
+ @grid = nil
31
+ @data = []
32
+ @layout = build(schema.layout, layout, "layout")
33
+ @config = build(schema.config, config, "config")
34
+ raise ArgumentError, "data must be an Array of trace Hashes, got #{data.inspect}" unless data.is_a?(Array)
35
+
36
+ data.each { |trace| add_trace(trace) }
37
+ end
38
+
39
+ # Appends a trace.
40
+ #
41
+ # @param trace [Hash] trace attributes; keyword arguments are merged into it
42
+ # @param row [Integer, nil] subplot row (1-based), for figures made by {Plotly.make_subplots}
43
+ # @param col [Integer, nil] subplot column (1-based)
44
+ # @return [self]
45
+ def add_trace(trace = {}, row: nil, col: nil, **attrs)
46
+ trace = trace.to_h { |k, v| [k.to_s, v] }.merge(attrs.transform_keys(&:to_s))
47
+ type = (trace.delete("type") || "scatter").to_s
48
+ node = trace_node(type, "data[#{@data.size}]")
49
+ built = {"type" => type}.merge(build(node, trace, "data[#{@data.size}]"))
50
+ built.merge!(cell_reference(node, row, col)) if row || col
51
+ @data << built
52
+ self
53
+ end
54
+
55
+ Schema.default.trace_types.each do |type|
56
+ # @!method add_scatter(row: nil, col: nil, **attrs)
57
+ # Appends a trace of this type; one such helper exists for every plotly.js trace type.
58
+ # @return [Figure]
59
+ define_method(:"add_#{type}") do |trace = {}, row: nil, col: nil, **attrs|
60
+ add_trace(trace.merge(attrs).merge(type: type), row: row, col: col)
61
+ end
62
+ end
63
+
64
+ # Deep-merges attributes into the layout. Nothing changes if any attribute is invalid.
65
+ # @return [self]
66
+ def update_layout(attrs = {}, **kw)
67
+ @layout = Attributes.deep_merge(@layout, build(schema.layout, attrs.merge(kw), "layout"))
68
+ self
69
+ end
70
+
71
+ # Deep-merges attributes into the config.
72
+ # @return [self]
73
+ def update_config(attrs = {}, **kw)
74
+ @config = Attributes.deep_merge(@config, build(schema.config, attrs.merge(kw), "config"))
75
+ self
76
+ end
77
+
78
+ # Deep-merges attributes into every trace, or into the traces that match.
79
+ #
80
+ # @param selector [Hash, Proc, nil] attributes a trace must have (`{type: :bar}`), or a
81
+ # block receiving the normalized trace
82
+ # @param row [Integer, nil] only traces in this subplot row
83
+ # @param col [Integer, nil] only traces in this subplot column
84
+ # @return [self]
85
+ def update_traces(attrs = {}, selector: nil, row: nil, col: nil, **kw)
86
+ attrs = attrs.merge(kw)
87
+ targets = @data.each_index.select { |i| selected?(@data[i], selector) && in_cell?(@data[i], row, col) }
88
+ updates = targets.to_h do |i|
89
+ [i, build(trace_node(@data[i]["type"], "data[#{i}]"), attrs, "data[#{i}]")]
90
+ end
91
+ updates.each { |i, update| @data[i] = Attributes.deep_merge(@data[i], update) }
92
+ self
93
+ end
94
+
95
+ # Deep-merges attributes into every x axis, or into the x axis of one subplot.
96
+ # @return [self]
97
+ def update_xaxes(attrs = {}, row: nil, col: nil, **kw) = update_axes("x", attrs.merge(kw), row, col)
98
+
99
+ # Deep-merges attributes into every y axis, or into the y axis of one subplot.
100
+ # @return [self]
101
+ def update_yaxes(attrs = {}, row: nil, col: nil, **kw) = update_axes("y", attrs.merge(kw), row, col)
102
+
103
+ # Renders the figure as HTML. See {HTML.render} for the options.
104
+ #
105
+ # @example In a Rails view
106
+ # <%= raw @figure.to_html(height: 400) %>
107
+ # @return [String] an HTML fragment (or document with `full_html: true`)
108
+ def to_html(include_plotlyjs: :cdn, full_html: false, div_id: nil, width: nil, height: nil)
109
+ HTML.render(self, include_plotlyjs: include_plotlyjs, full_html: full_html, div_id: div_id,
110
+ width: width, height: height)
111
+ end
112
+
113
+ # Writes a standalone HTML page. By default plotly.js is embedded so the file works offline
114
+ # and can be shared as a single attachment.
115
+ #
116
+ # @param path [String]
117
+ # @param open [Boolean] also open the page in the default browser
118
+ # @return [String] the path written
119
+ def write_html(path, include_plotlyjs: :inline, open: false, width: nil, height: nil)
120
+ File.write(path, to_html(include_plotlyjs: include_plotlyjs, full_html: true, width: width, height: height))
121
+ Browser.open(File.expand_path(path)) if open
122
+ path
123
+ end
124
+
125
+ # Shows the figure: inline in an IRuby notebook, otherwise as a temporary page in the browser.
126
+ # @return [String, nil] the temporary file outside notebooks
127
+ def show
128
+ if defined?(::IRuby) && ::IRuby.respond_to?(:display)
129
+ ::IRuby.display(self)
130
+ return nil
131
+ end
132
+
133
+ write_html(File.join(Dir.tmpdir, "rbplotly-#{SecureRandom.hex(8)}.html"), open: true)
134
+ end
135
+
136
+ # IRuby's display hook. IRuby 0.8 prefers it to {#to_html}, whose output would run before
137
+ # plotly.js loads in a notebook.
138
+ # @return [Array(Hash{String => String}, Hash)] formats and metadata
139
+ def to_iruby_mimebundle(include: [])
140
+ [{"text/html" => HTML.notebook(self)}, {}]
141
+ end
142
+
143
+ # IRuby's display hook in versions before 0.8.
144
+ # @return [Array(String, String)]
145
+ def to_iruby = ["text/html", HTML.notebook(self)]
146
+
147
+ # @return [Hash{String => Object}] `{"data" => [...], "layout" => {...}}`, sharing the
148
+ # figure's own Hashes: changing them skips validation, as with {#data} and {#layout}
149
+ def to_h = {"data" => @data, "layout" => @layout}
150
+
151
+ # @return [String] the figure as plotly.js JSON (config is not included, as in plotly.py)
152
+ def to_json(*) = Serializer.dump(to_h)
153
+
154
+ # @return [String] trace types and layout keys, without the data
155
+ def inspect
156
+ "#<#{self.class.name} data=[#{@data.map { |t| t["type"] }.join(", ")}] layout=[#{@layout.keys.join(", ")}]>"
157
+ end
158
+
159
+ # @api private
160
+ # @param grid [Subplots::Grid] set by {Plotly.make_subplots}
161
+ def subplot_grid=(grid)
162
+ @grid = grid
163
+ end
164
+
165
+ private
166
+
167
+ def schema = Schema.default
168
+
169
+ def build(node, attrs, path) = Attributes.build(node, attrs, path: path, validate: @validate)
170
+
171
+ def trace_node(type, path)
172
+ node = schema.trace(type)
173
+ return node if node
174
+
175
+ message = "#{path}.type: #{type.inspect} is not a plotly.js trace type"
176
+ suggestion = DidYouMean::SpellChecker.new(dictionary: schema.trace_types).correct(type).first
177
+ raise ValidationError, suggestion ? "#{message}. Did you mean #{suggestion.inspect}?" : message
178
+ end
179
+
180
+ def selected?(trace, selector)
181
+ case selector
182
+ when nil then true
183
+ when Proc then selector.call(trace)
184
+ when Hash
185
+ selector.all? { |key, value| Serializer.plain(trace[key.to_s]) == Serializer.plain(value) }
186
+ else raise ArgumentError, "selector must be a Hash or a Proc, got #{selector.inspect}"
187
+ end
188
+ end
189
+
190
+ def in_cell?(trace, row, col)
191
+ return true unless row || col
192
+
193
+ grid!.cells(row, col).any? do |cell|
194
+ domain = trace["domain"]
195
+ if domain.is_a?(Hash)
196
+ domain["x"] == cell.x_domain && domain["y"] == cell.y_domain
197
+ elsif schema.trace(trace["type"])&.child("xaxis")
198
+ cell.axis_ids == [(trace["xaxis"] || "x").to_s, (trace["yaxis"] || "y").to_s]
199
+ else
200
+ false # 3D, polar, map... traces are not in any cell
201
+ end
202
+ end
203
+ end
204
+
205
+ def cell_reference(node, row, col)
206
+ cell = grid!.cell(row, col)
207
+ if node.child("xaxis")
208
+ {"xaxis" => cell.axis_ids[0], "yaxis" => cell.axis_ids[1]}
209
+ elsif node.child("domain")
210
+ {"domain" => {"x" => cell.x_domain, "y" => cell.y_domain}}
211
+ else
212
+ raise ArgumentError, "#{node.name} traces cannot be placed in a subplot cell"
213
+ end
214
+ end
215
+
216
+ def update_axes(letter, attrs, row, col)
217
+ keys = if row || col
218
+ grid!.cells(row, col).map { |cell| cell.layout_key(letter) }
219
+ else
220
+ # Traces on cartesian axes refer to x/y unless they name another axis.
221
+ referenced = @data.filter_map do |t|
222
+ next unless schema.trace(t["type"])&.child("#{letter}axis")
223
+
224
+ (t["#{letter}axis"] || letter).to_s.sub(/\A#{letter}/, "#{letter}axis")
225
+ end
226
+ found = (@layout.keys.grep(/\A#{letter}axis\d*\z/) + referenced).uniq
227
+ found.empty? ? ["#{letter}axis"] : found
228
+ end
229
+ update_layout(keys.to_h { |key| [key, attrs] })
230
+ end
231
+
232
+ def grid!
233
+ @grid or raise ArgumentError, "row and col need a figure made by Plotly.make_subplots"
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "securerandom"
5
+
6
+ module Plotly
7
+ # Renders figures as HTML.
8
+ module HTML
9
+ # The plotly.js build that `include_plotlyjs: :cdn` and notebook output load.
10
+ CDN_URL = "https://cdn.plot.ly/plotly-#{PLOTLY_JS_VERSION}.min.js"
11
+ # The same build, bundled in the gem for `include_plotlyjs: :inline`.
12
+ BUNDLE_PATH = File.expand_path("assets/plotly.min.js", __dir__)
13
+ # Config applied under the figure's own config.
14
+ DEFAULT_CONFIG = {"responsive" => true}.freeze
15
+
16
+ module_function
17
+
18
+ # @param figure [Figure]
19
+ # @param include_plotlyjs [:cdn, :inline, false, String] how the page gets plotly.js:
20
+ # the pinned CDN build, the bundled copy embedded in the page (works offline), not at
21
+ # all (the page already loads it), or from the given URL
22
+ # @param full_html [Boolean] a whole document instead of a fragment
23
+ # @param div_id [String, nil] id of the chart element; random by default
24
+ # @param width [Integer, String, nil] element width (Integer means pixels); 100% by default
25
+ # @param height [Integer, String, nil] element height; by default the layout height, else
26
+ # 450px for a fragment (a percentage would collapse in a container of automatic height)
27
+ # and the whole window for a full document
28
+ # @return [String]
29
+ def render(figure, include_plotlyjs: :cdn, full_html: false, div_id: nil, width: nil, height: nil)
30
+ id = div_id || "rbplotly-#{SecureRandom.uuid}"
31
+ body = [
32
+ plotlyjs_tag(include_plotlyjs),
33
+ %(<div id="#{CGI.escapeHTML(id)}" class="plotly-graph-div" style="#{style(width, height || default_height(figure, full_html))}"></div>),
34
+ "<script>\n#{draw_call(figure, id)}\n</script>"
35
+ ].compact.join("\n")
36
+ full_html ? document(figure, body) : body
37
+ end
38
+
39
+ # HTML for Jupyter frontends (classic Notebook, JupyterLab, VS Code): loads plotly.js once
40
+ # per page and draws. plotly.js 3+ always registers itself as `window.Plotly`, never as an
41
+ # AMD module, so a plain script element works even where the page uses RequireJS.
42
+ def notebook(figure)
43
+ id = "rbplotly-#{SecureRandom.uuid}"
44
+ <<~HTML
45
+ <div id="#{id}" class="plotly-graph-div" style="#{style(nil, default_height(figure, false))}"></div>
46
+ <script>
47
+ (function () {
48
+ var src = "#{CDN_URL}";
49
+ function draw(Plotly) {
50
+ #{draw_call(figure, id).gsub("\n", "\n ")}
51
+ }
52
+ if (window.Plotly) { draw(window.Plotly); return; }
53
+ var script = document.querySelector('script[data-rbplotly="' + src + '"]');
54
+ if (!script) {
55
+ script = document.createElement("script");
56
+ script.src = src;
57
+ script.setAttribute("data-rbplotly", src);
58
+ document.head.appendChild(script);
59
+ }
60
+ script.addEventListener("load", function () { draw(window.Plotly); });
61
+ script.addEventListener("error", function () {
62
+ script.remove(); // so the next output tries again
63
+ var el = document.getElementById("#{id}");
64
+ if (el) el.textContent = "rbplotly: could not load plotly.js from " + src;
65
+ });
66
+ })();
67
+ </script>
68
+ HTML
69
+ end
70
+
71
+ # @api private
72
+ def draw_call(figure, id)
73
+ config = DEFAULT_CONFIG.merge(figure.config)
74
+ args = [id, figure.data, figure.layout, config].map { |arg| Serializer.dump(arg) }
75
+ "Plotly.newPlot(#{args.join(", ")});"
76
+ end
77
+
78
+ def plotlyjs_tag(mode)
79
+ case mode
80
+ when :cdn then script_src(CDN_URL)
81
+ when :inline then "<script>#{bundle.gsub("</script", "<\\/script")}</script>"
82
+ when false, nil then nil
83
+ when String then script_src(mode)
84
+ else
85
+ raise ArgumentError, "include_plotlyjs must be :cdn, :inline, false or a URL, got #{mode.inspect}"
86
+ end
87
+ end
88
+
89
+ def script_src(url) = %(<script src="#{CGI.escapeHTML(url)}" charset="utf-8"></script>)
90
+
91
+ def bundle
92
+ @bundle ||= File.read(BUNDLE_PATH, encoding: "UTF-8")
93
+ rescue Errno::ENOENT
94
+ raise Error, "the bundled plotly.js is missing (#{BUNDLE_PATH}). In a git checkout, run " \
95
+ "`bundle exec rake plotlyjs:fetch`, or use include_plotlyjs: :cdn"
96
+ end
97
+
98
+ def style(width, height)
99
+ "height:#{length(height)};width:#{length(width || "100%")};"
100
+ end
101
+
102
+ def default_height(figure, full_html)
103
+ layout_height = figure.layout["height"]
104
+ return layout_height.round if layout_height.is_a?(Numeric)
105
+
106
+ full_html ? "100%" : 450
107
+ end
108
+
109
+ def length(value) = value.is_a?(Integer) ? "#{value}px" : CGI.escapeHTML(value.to_s)
110
+
111
+ def document(figure, body)
112
+ title = figure.layout.dig("title", "text")
113
+ title = (title.is_a?(String) || title.is_a?(Symbol)) ? title.to_s : "Plotly figure"
114
+ <<~HTML
115
+ <!DOCTYPE html>
116
+ <html>
117
+ <head>
118
+ <meta charset="utf-8">
119
+ <meta name="viewport" content="width=device-width, initial-scale=1">
120
+ <title>#{CGI.escapeHTML(title)}</title>
121
+ <style>html, body { height: 100%; margin: 0; }</style>
122
+ </head>
123
+ <body>
124
+ #{body}
125
+ </body>
126
+ </html>
127
+ HTML
128
+ end
129
+
130
+ private_class_method :plotlyjs_tag, :script_src, :bundle, :style, :default_height, :length, :document
131
+ end
132
+
133
+ # Opens files in the desktop's default browser.
134
+ module Browser
135
+ module_function
136
+
137
+ # @param path [String] absolute path of an HTML file
138
+ def open(path)
139
+ command = case RbConfig::CONFIG["host_os"]
140
+ when /darwin/ then ["open", path]
141
+ # Not `cmd /c start`: cmd.exe would interpret &, | and ^ in the file name.
142
+ when /mswin|mingw|cygwin/ then ["explorer.exe", path]
143
+ else ["xdg-open", path]
144
+ end
145
+ Process.detach(Process.spawn(*command, out: File::NULL, err: File::NULL))
146
+ rescue SystemCallError
147
+ warn "rbplotly: could not open a browser; the chart is at #{path}"
148
+ end
149
+ end
150
+ end
data/lib/plotly/plot.rb CHANGED
@@ -1,47 +1,18 @@
1
- require 'plotly/data'
2
- require 'plotly/layout'
3
- require 'plotly/exportable'
4
- require 'plotly/offline/exportable'
1
+ # frozen_string_literal: true
5
2
 
6
3
  module Plotly
7
- class Plot
8
- include Exportable
9
- include Offline::Exportable
10
-
11
- # @!attribute [r] data
12
- # @return [Array] list of Plotly::Data objects
13
- # @!attribute [r] layout
14
- # @return [Plotly::Layout]
15
- attr_reader :data, :layout
16
-
17
- # @option data [Array] list of Hash or Plotly::Data objects
18
- # @option layout [Hash or Plotly::Layout]
19
- def initialize(data: [], layout: {})
20
- @data = data.map { |d| d.is_a?(Hash) ? Data.new(d) : d }
21
- @layout = layout.convert_to(Plotly::Layout)
22
- end
23
-
24
- # @param data [Plotly::Data or Hash]
25
- def add_data(data = Data.new)
26
- @data.push begin
27
- case data
28
- when Data then data
29
- when Hash then Data.new(data)
30
- end
31
- end
32
- end
33
-
34
- # @todo add remove_data method
35
-
36
- # @param data [Array] list of Hash or Plotly::Data objects
37
- def data=(data)
38
- raise unless data.is_a?(Array)
39
- @data = data.map { |d| d.convert_to(Plotly::Data) }
4
+ # The 0.x entry point, kept so existing code runs while it moves to {Figure}.
5
+ # @deprecated Use {Figure}; `generate_html(path:, open:)` becomes `write_html(path, open:)`.
6
+ class Plot < Figure
7
+ def initialize(data: [], layout: {}, **options)
8
+ warn "Plotly::Plot is deprecated and will be removed in rbplotly 2.0; use Plotly::Figure " \
9
+ "(generate_html(path:) is now write_html(path))", uplevel: 1
10
+ super
40
11
  end
41
12
 
42
- # @param layout [Hash or Plotly::Layout]
43
- def layout=(layout)
44
- @layout = layout.convert_to(Plotly::Layout)
13
+ # @deprecated Use {Figure#write_html}.
14
+ def generate_html(path: "plot.html", open: true)
15
+ write_html(path, open: open)
45
16
  end
46
17
  end
47
18
  end