rbplotly 0.1.2 → 1.0.1
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 +88 -0
- data/LICENSE.txt +1 -1
- data/README.md +213 -50
- data/lib/plotly/assets/plotly.min.js +3620 -0
- data/lib/plotly/attributes.rb +221 -0
- data/lib/plotly/figure.rb +292 -0
- data/lib/plotly/frames.rb +38 -0
- data/lib/plotly/html.rb +161 -0
- data/lib/plotly/plot.rb +11 -28
- data/lib/plotly/schema/plot-schema.json +57258 -0
- data/lib/plotly/schema.rb +99 -0
- data/lib/plotly/serializer.rb +49 -0
- data/lib/plotly/subplots.rb +186 -0
- data/lib/plotly/version.rb +7 -1
- data/lib/plotly.rb +22 -0
- data/lib/rbplotly.rb +3 -4
- metadata +35 -179
- data/.gitignore +0 -11
- data/.rspec +0 -2
- data/.rubocop.yml +0 -22
- data/.travis.yml +0 -9
- 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/Bar Charts.ipynb +0 -254
- data/examples/Basic Usage.ipynb +0 -227
- data/examples/Histograms.ipynb +0 -79
- data/examples/Line charts.ipynb +0 -88
- data/examples/Pie Charts.ipynb +0 -151
- data/examples/Scatter Plots.ipynb +0 -224
- data/examples/heatmaps.ipynb +0 -150
- data/lib/plotly/axis.rb +0 -18
- data/lib/plotly/castable.rb +0 -20
- data/lib/plotly/client.rb +0 -45
- data/lib/plotly/data.rb +0 -33
- data/lib/plotly/exportable.rb +0 -23
- data/lib/plotly/layout.rb +0 -33
- data/lib/plotly/line.rb +0 -17
- data/lib/plotly/marker.rb +0 -23
- data/lib/plotly/offline/exportable.rb +0 -36
- 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 -35
|
@@ -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,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module Plotly
|
|
6
|
+
# A plotly.js figure: traces (`data`), `layout`, `config` and animation `frames`.
|
|
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
|
+
# @return [Array<Hash{String => Object}>] animation frames; direct mutation skips validation
|
|
24
|
+
attr_reader :frames
|
|
25
|
+
|
|
26
|
+
# @param data [Array<Hash>] traces; a trace without `type` is a scatter trace
|
|
27
|
+
# @param layout [Hash]
|
|
28
|
+
# @param config [Hash]
|
|
29
|
+
# @param validate [Boolean] check attributes against the plotly.js schema
|
|
30
|
+
# @param frames [Array<Hash>] partial trace/layout updates for animation
|
|
31
|
+
def initialize(data: [], layout: {}, config: {}, validate: true, frames: [])
|
|
32
|
+
@validate = validate
|
|
33
|
+
@grid = nil
|
|
34
|
+
@data = []
|
|
35
|
+
@layout = build(schema.layout, layout, "layout")
|
|
36
|
+
@config = build(schema.config, config, "config")
|
|
37
|
+
raise ArgumentError, "data must be an Array of trace Hashes, got #{data.inspect}" unless data.is_a?(Array)
|
|
38
|
+
|
|
39
|
+
data.each { |trace| add_trace(trace) }
|
|
40
|
+
self.frames = frames
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Replaces the animation frames after validating all of them.
|
|
44
|
+
# Define the base traces first so frames may omit their trace types.
|
|
45
|
+
# @param frames [Array<Hash>]
|
|
46
|
+
# @return [Array<Hash>]
|
|
47
|
+
def frames=(frames)
|
|
48
|
+
raise ValidationError, "frames: expected an Array of Hashes" unless frames.is_a?(Array)
|
|
49
|
+
|
|
50
|
+
@frames = frames.each_with_index.map { |frame, i| build_frame(frame, i) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Appends a frame. `traces` maps its data entries to zero-based base trace indices.
|
|
54
|
+
# @param frame [Hash] frame attributes, merged with keyword arguments
|
|
55
|
+
# @return [self]
|
|
56
|
+
def add_frame(frame = {}, **attrs)
|
|
57
|
+
unless frame.is_a?(Hash)
|
|
58
|
+
raise ValidationError, "frames[#{@frames.size}]: expected a Hash"
|
|
59
|
+
end
|
|
60
|
+
@frames << build_frame(frame.transform_keys(&:to_s).merge(attrs.transform_keys(&:to_s)), @frames.size)
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Appends a trace.
|
|
65
|
+
#
|
|
66
|
+
# @param trace [Hash] trace attributes; keyword arguments are merged into it
|
|
67
|
+
# @param row [Integer, nil] subplot row (1-based), for figures made by {Plotly.make_subplots}
|
|
68
|
+
# @param col [Integer, nil] subplot column (1-based)
|
|
69
|
+
# @param secondary_y [Boolean] use the cell's right y axis (requires row, col and a secondary_y spec)
|
|
70
|
+
# @return [self]
|
|
71
|
+
def add_trace(trace = {}, row: nil, col: nil, secondary_y: false, **attrs)
|
|
72
|
+
check_secondary_y(secondary_y)
|
|
73
|
+
trace = trace.to_h { |k, v| [k.to_s, v] }.merge(attrs.transform_keys(&:to_s))
|
|
74
|
+
type = (trace.delete("type") || "scatter").to_s
|
|
75
|
+
node = trace_node(type, "data[#{@data.size}]")
|
|
76
|
+
built = {"type" => type}.merge(build(node, trace, "data[#{@data.size}]"))
|
|
77
|
+
built.merge!(cell_reference(node, row, col, secondary_y)) if row || col || secondary_y
|
|
78
|
+
@data << built
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Schema.default.trace_types.each do |type|
|
|
83
|
+
# @!method add_scatter(row: nil, col: nil, secondary_y: false, **attrs)
|
|
84
|
+
# Appends a trace of this type; one such helper exists for every plotly.js trace type.
|
|
85
|
+
# @return [Figure]
|
|
86
|
+
define_method(:"add_#{type}") do |trace = {}, row: nil, col: nil, secondary_y: false, **attrs|
|
|
87
|
+
add_trace(trace.merge(attrs).merge(type: type), row: row, col: col, secondary_y: secondary_y)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Deep-merges attributes into the layout. Nothing changes if any attribute is invalid.
|
|
92
|
+
# @return [self]
|
|
93
|
+
def update_layout(attrs = {}, **kw)
|
|
94
|
+
@layout = Attributes.deep_merge(@layout, build(schema.layout, attrs.merge(kw), "layout"))
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Deep-merges attributes into the config.
|
|
99
|
+
# @return [self]
|
|
100
|
+
def update_config(attrs = {}, **kw)
|
|
101
|
+
@config = Attributes.deep_merge(@config, build(schema.config, attrs.merge(kw), "config"))
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Deep-merges attributes into every trace, or into the traces that match.
|
|
106
|
+
#
|
|
107
|
+
# @param selector [Hash, Proc, nil] attributes a trace must have (`{type: :bar}`), or a
|
|
108
|
+
# block receiving the normalized trace
|
|
109
|
+
# @param row [Integer, nil] only traces in this subplot row
|
|
110
|
+
# @param col [Integer, nil] only traces in this subplot column
|
|
111
|
+
# @param secondary_y [Boolean, nil] select right/left y-axis traces; nil selects both
|
|
112
|
+
# @return [self]
|
|
113
|
+
def update_traces(attrs = {}, selector: nil, row: nil, col: nil, secondary_y: nil, **kw)
|
|
114
|
+
check_secondary_y(secondary_y)
|
|
115
|
+
grid! unless secondary_y.nil?
|
|
116
|
+
attrs = attrs.merge(kw)
|
|
117
|
+
targets = @data.each_index.select { |i| selected?(@data[i], selector) && in_cell?(@data[i], row, col, secondary_y) }
|
|
118
|
+
updates = targets.to_h do |i|
|
|
119
|
+
[i, build(trace_node(@data[i]["type"], "data[#{i}]"), attrs, "data[#{i}]")]
|
|
120
|
+
end
|
|
121
|
+
updates.each { |i, update| @data[i] = Attributes.deep_merge(@data[i], update) }
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Deep-merges attributes into every x axis, or into the x axis of one subplot.
|
|
126
|
+
# @return [self]
|
|
127
|
+
def update_xaxes(attrs = {}, row: nil, col: nil, **kw) = update_axes("x", attrs.merge(kw), row, col)
|
|
128
|
+
|
|
129
|
+
# Deep-merges attributes into every y axis, or into the y axis of one subplot.
|
|
130
|
+
# @param secondary_y [Boolean, nil] select right/left y axes; nil selects both
|
|
131
|
+
# @return [self]
|
|
132
|
+
def update_yaxes(attrs = {}, row: nil, col: nil, secondary_y: nil, **kw)
|
|
133
|
+
check_secondary_y(secondary_y)
|
|
134
|
+
update_axes("y", attrs.merge(kw), row, col, secondary_y)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Renders the figure as HTML. See {HTML.render} for the options.
|
|
138
|
+
#
|
|
139
|
+
# @example In a Rails view
|
|
140
|
+
# <%= raw @figure.to_html(height: 400) %>
|
|
141
|
+
# @return [String] an HTML fragment (or document with `full_html: true`)
|
|
142
|
+
def to_html(include_plotlyjs: :cdn, full_html: false, div_id: nil, width: nil, height: nil,
|
|
143
|
+
auto_play: true, animation_opts: {})
|
|
144
|
+
HTML.render(self, include_plotlyjs: include_plotlyjs, full_html: full_html, div_id: div_id,
|
|
145
|
+
width: width, height: height, auto_play: auto_play, animation_opts: animation_opts)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Writes a standalone HTML page. By default plotly.js is embedded so the file works offline
|
|
149
|
+
# and can be shared as a single attachment.
|
|
150
|
+
#
|
|
151
|
+
# @param path [String]
|
|
152
|
+
# @param open [Boolean] also open the page in the default browser
|
|
153
|
+
# @param auto_play [Boolean] start animation after the figure is drawn
|
|
154
|
+
# @param animation_opts [Hash] options passed to Plotly.animate
|
|
155
|
+
# @return [String] the path written
|
|
156
|
+
def write_html(path, include_plotlyjs: :inline, open: false, width: nil, height: nil,
|
|
157
|
+
auto_play: true, animation_opts: {})
|
|
158
|
+
File.write(path, to_html(include_plotlyjs: include_plotlyjs, full_html: true, width: width, height: height,
|
|
159
|
+
auto_play: auto_play, animation_opts: animation_opts))
|
|
160
|
+
Browser.open(File.expand_path(path)) if open
|
|
161
|
+
path
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Shows the figure: inline in an IRuby notebook, otherwise as a temporary page in the browser.
|
|
165
|
+
# @return [String, nil] the temporary file outside notebooks
|
|
166
|
+
def show
|
|
167
|
+
if defined?(::IRuby) && ::IRuby.respond_to?(:display)
|
|
168
|
+
::IRuby.display(self)
|
|
169
|
+
return nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
write_html(File.join(Dir.tmpdir, "rbplotly-#{SecureRandom.hex(8)}.html"), open: true)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# IRuby's display hook. IRuby 0.8 prefers it to {#to_html}, whose output would run before
|
|
176
|
+
# plotly.js loads in a notebook.
|
|
177
|
+
# @return [Array(Hash{String => String}, Hash)] formats and metadata
|
|
178
|
+
def to_iruby_mimebundle(include: [])
|
|
179
|
+
[{"text/html" => HTML.notebook(self)}, {}]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# IRuby's display hook in versions before 0.8.
|
|
183
|
+
# @return [Array(String, String)]
|
|
184
|
+
def to_iruby = ["text/html", HTML.notebook(self)]
|
|
185
|
+
|
|
186
|
+
# @return [Hash{String => Object}] data, layout and nonempty frames, sharing the
|
|
187
|
+
# figure's own Hashes: changing them skips validation, as with {#data} and {#layout}
|
|
188
|
+
def to_h
|
|
189
|
+
result = {"data" => @data, "layout" => @layout}
|
|
190
|
+
result["frames"] = @frames unless @frames.empty?
|
|
191
|
+
result
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [String] the figure as plotly.js JSON (config is not included, as in plotly.py)
|
|
195
|
+
def to_json(*) = Serializer.dump(to_h)
|
|
196
|
+
|
|
197
|
+
# @return [String] trace types and layout keys, without the data
|
|
198
|
+
def inspect
|
|
199
|
+
"#<#{self.class.name} data=[#{@data.map { |t| t["type"] }.join(", ")}] layout=[#{@layout.keys.join(", ")}]>"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @api private
|
|
203
|
+
# @param grid [Subplots::Grid] set by {Plotly.make_subplots}
|
|
204
|
+
def subplot_grid=(grid)
|
|
205
|
+
@grid = grid
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def schema = Schema.default
|
|
211
|
+
|
|
212
|
+
def build_frame(frame, index)
|
|
213
|
+
Frames.build(frame, data: @data, path: "frames[#{index}]", validate: @validate)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build(node, attrs, path) = Attributes.build(node, attrs, path: path, validate: @validate)
|
|
217
|
+
|
|
218
|
+
def trace_node(type, path)
|
|
219
|
+
node = schema.trace(type)
|
|
220
|
+
return node if node
|
|
221
|
+
|
|
222
|
+
message = "#{path}.type: #{type.inspect} is not a plotly.js trace type"
|
|
223
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: schema.trace_types).correct(type).first
|
|
224
|
+
raise ValidationError, suggestion ? "#{message}. Did you mean #{suggestion.inspect}?" : message
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def selected?(trace, selector)
|
|
228
|
+
case selector
|
|
229
|
+
when nil then true
|
|
230
|
+
when Proc then selector.call(trace)
|
|
231
|
+
when Hash
|
|
232
|
+
selector.all? { |key, value| Serializer.plain(trace[key.to_s]) == Serializer.plain(value) }
|
|
233
|
+
else raise ArgumentError, "selector must be a Hash or a Proc, got #{selector.inspect}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def check_secondary_y(value)
|
|
238
|
+
raise ArgumentError, "secondary_y must be true, false or nil" unless [true, false, nil].include?(value)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def in_cell?(trace, row, col, secondary_y = nil)
|
|
242
|
+
return true unless row || col || !secondary_y.nil?
|
|
243
|
+
|
|
244
|
+
grid!.cells(row, col).any? do |cell|
|
|
245
|
+
domain = trace["domain"]
|
|
246
|
+
if domain.is_a?(Hash)
|
|
247
|
+
secondary_y.nil? && domain["x"] == cell.x_domain && domain["y"] == cell.y_domain
|
|
248
|
+
elsif schema.trace(trace["type"])&.child("xaxis")
|
|
249
|
+
axes = [(trace["xaxis"] || "x").to_s, (trace["yaxis"] || "y").to_s]
|
|
250
|
+
(secondary_y != true && cell.axis_ids == axes) ||
|
|
251
|
+
(secondary_y != false && cell.secondary_index && cell.axis_ids(true) == axes)
|
|
252
|
+
else
|
|
253
|
+
false # 3D, polar, map... traces are not in any cell
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def cell_reference(node, row, col, secondary_y)
|
|
259
|
+
cell = grid!.cell(row, col)
|
|
260
|
+
if secondary_y && (!node.child("xaxis") || !cell.secondary_index)
|
|
261
|
+
raise ArgumentError, "secondary_y needs a cartesian trace and a cell with specs: {secondary_y: true}"
|
|
262
|
+
end
|
|
263
|
+
if node.child("xaxis")
|
|
264
|
+
{"xaxis" => cell.axis_ids[0], "yaxis" => cell.axis_ids(secondary_y)[1]}
|
|
265
|
+
elsif node.child("domain")
|
|
266
|
+
{"domain" => {"x" => cell.x_domain, "y" => cell.y_domain}}
|
|
267
|
+
else
|
|
268
|
+
raise ArgumentError, "#{node.name} traces cannot be placed in a subplot cell"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def update_axes(letter, attrs, row, col, secondary_y = nil)
|
|
273
|
+
keys = if row || col || !secondary_y.nil?
|
|
274
|
+
grid!.cells(row, col).flat_map { |cell| (letter == "y") ? cell.y_keys(secondary_y) : [cell.layout_key(letter)] }
|
|
275
|
+
else
|
|
276
|
+
# Traces on cartesian axes refer to x/y unless they name another axis.
|
|
277
|
+
referenced = @data.filter_map do |t|
|
|
278
|
+
next unless schema.trace(t["type"])&.child("#{letter}axis")
|
|
279
|
+
|
|
280
|
+
(t["#{letter}axis"] || letter).to_s.sub(/\A#{letter}/, "#{letter}axis")
|
|
281
|
+
end
|
|
282
|
+
found = (@layout.keys.grep(/\A#{letter}axis\d*\z/) + referenced).uniq
|
|
283
|
+
found.empty? ? ["#{letter}axis"] : found
|
|
284
|
+
end
|
|
285
|
+
update_layout(keys.to_h { |key| [key, attrs] })
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def grid!
|
|
289
|
+
@grid or raise ArgumentError, "row and col need a figure made by Plotly.make_subplots"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plotly
|
|
4
|
+
# Normalizes animation frames and validates their partial trace and layout updates.
|
|
5
|
+
# @api private
|
|
6
|
+
module Frames
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def build(frame, data:, path:, validate:)
|
|
10
|
+
schema = Schema.default
|
|
11
|
+
result = Attributes.build(schema.frame, frame, path: path, validate: validate)
|
|
12
|
+
traces = result["traces"]
|
|
13
|
+
if !traces.nil? && !(traces.is_a?(Array) && traces.all? { |i| i.is_a?(Integer) && i >= 0 })
|
|
14
|
+
raise ValidationError, "#{path}.traces: expected an Array of non-negative trace indices"
|
|
15
|
+
end
|
|
16
|
+
unless result["data"].nil?
|
|
17
|
+
unless result["data"].is_a?(Array) && result["data"].all?(Hash)
|
|
18
|
+
raise ValidationError, "#{path}.data: expected an Array of Hashes"
|
|
19
|
+
end
|
|
20
|
+
result["data"] = result["data"].each_with_index.map do |trace, i|
|
|
21
|
+
trace = trace.transform_keys(&:to_s)
|
|
22
|
+
target = traces&.fetch(i, i) || i
|
|
23
|
+
explicit_type = trace.delete("type")
|
|
24
|
+
type = (explicit_type || data[target]&.fetch("type", nil) || "scatter").to_s
|
|
25
|
+
node = schema.trace(type)
|
|
26
|
+
raise ValidationError, "#{path}.data[#{i}].type: #{type.inspect} is not a plotly.js trace type" unless node
|
|
27
|
+
|
|
28
|
+
attrs = Attributes.build(node, trace, path: "#{path}.data[#{i}]", validate: validate)
|
|
29
|
+
explicit_type ? {"type" => type}.merge(attrs) : attrs
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
unless result["layout"].nil?
|
|
33
|
+
result["layout"] = Attributes.build(schema.layout, result["layout"], path: "#{path}.layout", validate: validate)
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|