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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +64 -0
- data/LICENSE.txt +1 -1
- data/README.md +177 -39
- data/lib/plotly/assets/plotly.min.js +3620 -0
- data/lib/plotly/attributes.rb +221 -0
- data/lib/plotly/figure.rb +236 -0
- data/lib/plotly/html.rb +150 -0
- data/lib/plotly/plot.rb +11 -40
- data/lib/plotly/schema/plot-schema.json +57234 -0
- data/lib/plotly/schema.rb +96 -0
- data/lib/plotly/serializer.rb +49 -0
- data/lib/plotly/subplots.rb +118 -0
- data/lib/plotly/version.rb +7 -1
- data/lib/plotly.rb +21 -0
- data/lib/rbplotly.rb +3 -4
- metadata +34 -162
- data/.gitignore +0 -11
- data/.rspec +0 -2
- data/.rubocop.yml +0 -22
- data/.travis.yml +0 -6
- data/CODE_OF_CONDUCT.md +0 -49
- data/Gemfile +0 -4
- data/Guardfile +0 -12
- data/Rakefile +0 -9
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/docs/images/line_chart.png +0 -0
- data/examples/basic_bar_chart.ipynb +0 -82
- data/examples/basic_histogram.ipynb +0 -78
- data/examples/basic_pie_chart.ipynb +0 -84
- data/examples/grouped_bar_chart.ipynb +0 -89
- data/examples/heatmaps.ipynb +0 -89
- data/examples/line_and_scatter_plots.ipynb +0 -227
- data/examples/stacked_bar_chart.ipynb +0 -89
- data/lib/plotly/axis.rb +0 -15
- data/lib/plotly/castable.rb +0 -20
- data/lib/plotly/client.rb +0 -40
- data/lib/plotly/data.rb +0 -22
- data/lib/plotly/exportable.rb +0 -23
- data/lib/plotly/layout.rb +0 -33
- data/lib/plotly/offline/exportable.rb +0 -31
- data/lib/plotly/offline/html.rb +0 -46
- data/lib/plotly/offline/plotly.min.js +0 -59
- data/lib/plotly/offline/templates/body.erb +0 -26
- data/lib/plotly/offline/templates/plot.erb +0 -12
- data/lib/plotly/util.rb +0 -11
- 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
|
data/lib/plotly/html.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
# @
|
|
43
|
-
def
|
|
44
|
-
|
|
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
|