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.
Files changed (51) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +1 -1
  4. data/README.md +213 -50
  5. data/lib/plotly/assets/plotly.min.js +3620 -0
  6. data/lib/plotly/attributes.rb +221 -0
  7. data/lib/plotly/figure.rb +292 -0
  8. data/lib/plotly/frames.rb +38 -0
  9. data/lib/plotly/html.rb +161 -0
  10. data/lib/plotly/plot.rb +11 -28
  11. data/lib/plotly/schema/plot-schema.json +57258 -0
  12. data/lib/plotly/schema.rb +99 -0
  13. data/lib/plotly/serializer.rb +49 -0
  14. data/lib/plotly/subplots.rb +186 -0
  15. data/lib/plotly/version.rb +7 -1
  16. data/lib/plotly.rb +22 -0
  17. data/lib/rbplotly.rb +3 -4
  18. metadata +35 -179
  19. data/.gitignore +0 -11
  20. data/.rspec +0 -2
  21. data/.rubocop.yml +0 -22
  22. data/.travis.yml +0 -9
  23. data/CODE_OF_CONDUCT.md +0 -49
  24. data/Gemfile +0 -4
  25. data/Guardfile +0 -12
  26. data/Rakefile +0 -9
  27. data/bin/console +0 -14
  28. data/bin/setup +0 -8
  29. data/docs/images/line_chart.png +0 -0
  30. data/examples/Bar Charts.ipynb +0 -254
  31. data/examples/Basic Usage.ipynb +0 -227
  32. data/examples/Histograms.ipynb +0 -79
  33. data/examples/Line charts.ipynb +0 -88
  34. data/examples/Pie Charts.ipynb +0 -151
  35. data/examples/Scatter Plots.ipynb +0 -224
  36. data/examples/heatmaps.ipynb +0 -150
  37. data/lib/plotly/axis.rb +0 -18
  38. data/lib/plotly/castable.rb +0 -20
  39. data/lib/plotly/client.rb +0 -45
  40. data/lib/plotly/data.rb +0 -33
  41. data/lib/plotly/exportable.rb +0 -23
  42. data/lib/plotly/layout.rb +0 -33
  43. data/lib/plotly/line.rb +0 -17
  44. data/lib/plotly/marker.rb +0 -23
  45. data/lib/plotly/offline/exportable.rb +0 -36
  46. data/lib/plotly/offline/html.rb +0 -46
  47. data/lib/plotly/offline/plotly.min.js +0 -59
  48. data/lib/plotly/offline/templates/body.erb +0 -26
  49. data/lib/plotly/offline/templates/plot.erb +0 -12
  50. data/lib/plotly/util.rb +0 -11
  51. 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