rbplotly 1.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 601630a8bda423d88e103244a295c36513e8d99b959d0e7db508c65a776b49b4
4
- data.tar.gz: e2474fcfc722b3ece12085f992b502c456761fcdee39d1359dfd9486b5fe8774
3
+ metadata.gz: 3ee3bc96889cfdc7042b4cb2d4f493003ede009bb78ac14f59b2ff78aa56345c
4
+ data.tar.gz: e395c9e2230df5996b6a91a1757b4d72cd9829149aad82e7ba0acc3dac066c60
5
5
  SHA512:
6
- metadata.gz: 244c2be24a75a8d9479ca8a6326c44aafa464fc41f70ada2e362e710021e9a825bb3bbd5f681b2cdd4f597dfd98128cb8853f54325f34234accd4b866801954b
7
- data.tar.gz: cbdeb3ed2a7ee529fc2d0f38340d7a1f508f268f3a0fc5a010f4d40589d12e23f5ddd87434a139fd0431bd5a1e6c4f189467cba6dbba18edd9b80dccd1c4fdbf
6
+ metadata.gz: c05da261022f1d36806155a0f1f004e17c020e5451b79daf09daeb8f28d2820fd0cd90aa73444109c0516fd6b126745a74b28fa721261e941572fb857e9f7288
7
+ data.tar.gz: 46fa94b5913d6dfc0b37a5dd5761c59f7c775823b6bc02c41630c94ee7995d418d4676268086a6652fdd1a680c256a0f1205ef044a19892ac2bc5c47d992a306
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.1] - 2026-10-07
10
+
11
+ ### Added
12
+
13
+ - Relative subplot sizes through `column_widths` and `row_heights`, and per-cell right y
14
+ axes through `specs: [[{secondary_y: true}]]`. Trace placement, trace updates and y-axis
15
+ updates accept `secondary_y`; existing primary axis numbering is preserved.
16
+ - Animation frames through `Figure.new(frames:)`, `add_frame` and `frames=`, with validation
17
+ of partial trace/layout updates and inclusion in JSON output. HTML and notebook output
18
+ register frames and start playback; `to_html` and `write_html` accept `auto_play` and
19
+ `animation_opts`.
20
+ - Runnable examples for secondary axes and animation.
21
+
22
+ ### Fixed
23
+
24
+ - Require `json >= 2.9` to avoid the older `script_safe` bug that corrupts characters such
25
+ as `瀨` and `倩` in chart labels and JSON output.
26
+ - Reject negative, non-finite and out-of-range subplot spacing instead of silently
27
+ overlapping or collapsing cells; reject invalid row/column counts before calculating defaults.
28
+
7
29
  ## [1.0.0] - 2026-10-07
8
30
 
9
31
  A rewrite. Figures are now plain hashes checked against the plot schema of a pinned
@@ -60,5 +82,7 @@ plotly.js release, instead of hand-written classes that covered a few attributes
60
82
 
61
83
  The last 0.x release. See the [git history](https://github.com/ash1day/rbplotly/commits/v0.1.2).
62
84
 
85
+ [Unreleased]: https://github.com/ash1day/rbplotly/compare/v1.0.1...HEAD
86
+ [1.0.1]: https://github.com/ash1day/rbplotly/compare/v1.0.0...v1.0.1
63
87
  [1.0.0]: https://github.com/ash1day/rbplotly/compare/v0.1.2...v1.0.0
64
88
  [0.1.2]: https://github.com/ash1day/rbplotly/releases/tag/v0.1.2
data/README.md CHANGED
@@ -124,6 +124,44 @@ fig.update_yaxes({title_text: "USD (M)"}, row: 1, col: 1)
124
124
  Traces on x/y axes go on the cell's axes and domain traces (pie, sunburst, ...) fill the cell;
125
125
  3D, polar, ternary and map traces cannot be placed by `row:`/`col:` yet.
126
126
 
127
+ Use positive relative `column_widths:` and `row_heights:` to size cells. Row heights are
128
+ ordered from top to bottom; spacing is subtracted before distributing the remaining space.
129
+ Enable a right y axis with `specs:`, then select it with `secondary_y: true`:
130
+
131
+ ```ruby
132
+ fig = Plotly.make_subplots(cols: 2, column_widths: [2, 1],
133
+ specs: [[{secondary_y: true}, {}]])
134
+ fig.add_bar(x: %w[Jan Feb Mar], y: [100, 150, 180], name: "Orders", row: 1, col: 1)
135
+ fig.add_scatter(x: %w[Jan Feb Mar], y: [2, 3, 2.5], name: "Conversion %",
136
+ row: 1, col: 1, secondary_y: true)
137
+ fig.update_yaxes(title_text: "Orders", row: 1, col: 1, secondary_y: false)
138
+ fig.update_yaxes(title_text: "Conversion", ticksuffix: "%", secondary_y: true)
139
+ ```
140
+
141
+ `specs` currently accepts only `secondary_y` in each cell hash. Existing primary axis
142
+ numbers stay unchanged; secondary axes are numbered after all primary axes.
143
+ `update_traces` and `update_yaxes` accept `secondary_y: true` (right), `false` (left),
144
+ or the default `nil` (both). Axis sharing links only primary axes.
145
+
146
+ ### Animation
147
+
148
+ Supply `frames:` to the constructor, append with `add_frame`, or replace with `frames=`.
149
+ Define base traces before frames: omitted trace types are inferred from the corresponding
150
+ base trace. A frame's `traces` array maps its data entries to zero-based trace indices.
151
+
152
+ ```ruby
153
+ fig = Plotly::Figure.new.add_scatter(x: [0, 1], y: [0, 1], mode: :"lines+markers")
154
+ fig.update_layout(xaxis_range: [0, 1], yaxis_range: [0, 3])
155
+ fig.add_frame(name: "first", data: [{y: [1, 2]}])
156
+ fig.add_frame(name: "second", data: [{y: [2, 3]}])
157
+ fig.write_html("animation.html", animation_opts: {frame: {duration: 700}, transition: {duration: 300}})
158
+ ```
159
+
160
+ HTML and notebook output register frames after drawing, then play them automatically.
161
+ Pass `auto_play: false` to `to_html` or `write_html` to register frames without starting;
162
+ use layout `updatemenus`/`sliders` or JavaScript to trigger playback. Nonempty frames are
163
+ included in `to_h` and `to_json`. Without base data, an omitted trace type defaults to scatter.
164
+
127
165
  ## Output
128
166
 
129
167
  | Method | Gives you |
data/lib/plotly/figure.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "did_you_mean"
4
4
 
5
5
  module Plotly
6
- # A plotly.js figure: traces (`data`), `layout` and `config`.
6
+ # A plotly.js figure: traces (`data`), `layout`, `config` and animation `frames`.
7
7
  #
8
8
  # Every attribute is checked against the schema of the bundled plotly.js release when it
9
9
  # is added, so a typo fails where it was written instead of silently drawing nothing.
@@ -20,12 +20,15 @@ module Plotly
20
20
  attr_reader :layout
21
21
  # @return [Hash{String => Object}] plotly.js config (modebar, responsiveness, ...)
22
22
  attr_reader :config
23
+ # @return [Array<Hash{String => Object}>] animation frames; direct mutation skips validation
24
+ attr_reader :frames
23
25
 
24
26
  # @param data [Array<Hash>] traces; a trace without `type` is a scatter trace
25
27
  # @param layout [Hash]
26
28
  # @param config [Hash]
27
29
  # @param validate [Boolean] check attributes against the plotly.js schema
28
- def initialize(data: [], layout: {}, config: {}, validate: true)
30
+ # @param frames [Array<Hash>] partial trace/layout updates for animation
31
+ def initialize(data: [], layout: {}, config: {}, validate: true, frames: [])
29
32
  @validate = validate
30
33
  @grid = nil
31
34
  @data = []
@@ -34,6 +37,28 @@ module Plotly
34
37
  raise ArgumentError, "data must be an Array of trace Hashes, got #{data.inspect}" unless data.is_a?(Array)
35
38
 
36
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
37
62
  end
38
63
 
39
64
  # Appends a trace.
@@ -41,23 +66,25 @@ module Plotly
41
66
  # @param trace [Hash] trace attributes; keyword arguments are merged into it
42
67
  # @param row [Integer, nil] subplot row (1-based), for figures made by {Plotly.make_subplots}
43
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)
44
70
  # @return [self]
45
- def add_trace(trace = {}, row: nil, col: nil, **attrs)
71
+ def add_trace(trace = {}, row: nil, col: nil, secondary_y: false, **attrs)
72
+ check_secondary_y(secondary_y)
46
73
  trace = trace.to_h { |k, v| [k.to_s, v] }.merge(attrs.transform_keys(&:to_s))
47
74
  type = (trace.delete("type") || "scatter").to_s
48
75
  node = trace_node(type, "data[#{@data.size}]")
49
76
  built = {"type" => type}.merge(build(node, trace, "data[#{@data.size}]"))
50
- built.merge!(cell_reference(node, row, col)) if row || col
77
+ built.merge!(cell_reference(node, row, col, secondary_y)) if row || col || secondary_y
51
78
  @data << built
52
79
  self
53
80
  end
54
81
 
55
82
  Schema.default.trace_types.each do |type|
56
- # @!method add_scatter(row: nil, col: nil, **attrs)
83
+ # @!method add_scatter(row: nil, col: nil, secondary_y: false, **attrs)
57
84
  # Appends a trace of this type; one such helper exists for every plotly.js trace type.
58
85
  # @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)
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)
61
88
  end
62
89
  end
63
90
 
@@ -81,10 +108,13 @@ module Plotly
81
108
  # block receiving the normalized trace
82
109
  # @param row [Integer, nil] only traces in this subplot row
83
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
84
112
  # @return [self]
85
- def update_traces(attrs = {}, selector: nil, row: nil, col: nil, **kw)
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?
86
116
  attrs = attrs.merge(kw)
87
- targets = @data.each_index.select { |i| selected?(@data[i], selector) && in_cell?(@data[i], row, col) }
117
+ targets = @data.each_index.select { |i| selected?(@data[i], selector) && in_cell?(@data[i], row, col, secondary_y) }
88
118
  updates = targets.to_h do |i|
89
119
  [i, build(trace_node(@data[i]["type"], "data[#{i}]"), attrs, "data[#{i}]")]
90
120
  end
@@ -97,17 +127,22 @@ module Plotly
97
127
  def update_xaxes(attrs = {}, row: nil, col: nil, **kw) = update_axes("x", attrs.merge(kw), row, col)
98
128
 
99
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
100
131
  # @return [self]
101
- def update_yaxes(attrs = {}, row: nil, col: nil, **kw) = update_axes("y", attrs.merge(kw), row, col)
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
102
136
 
103
137
  # Renders the figure as HTML. See {HTML.render} for the options.
104
138
  #
105
139
  # @example In a Rails view
106
140
  # <%= raw @figure.to_html(height: 400) %>
107
141
  # @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)
142
+ def to_html(include_plotlyjs: :cdn, full_html: false, div_id: nil, width: nil, height: nil,
143
+ auto_play: true, animation_opts: {})
109
144
  HTML.render(self, include_plotlyjs: include_plotlyjs, full_html: full_html, div_id: div_id,
110
- width: width, height: height)
145
+ width: width, height: height, auto_play: auto_play, animation_opts: animation_opts)
111
146
  end
112
147
 
113
148
  # Writes a standalone HTML page. By default plotly.js is embedded so the file works offline
@@ -115,9 +150,13 @@ module Plotly
115
150
  #
116
151
  # @param path [String]
117
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
118
155
  # @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))
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))
121
160
  Browser.open(File.expand_path(path)) if open
122
161
  path
123
162
  end
@@ -144,9 +183,13 @@ module Plotly
144
183
  # @return [Array(String, String)]
145
184
  def to_iruby = ["text/html", HTML.notebook(self)]
146
185
 
147
- # @return [Hash{String => Object}] `{"data" => [...], "layout" => {...}}`, sharing the
186
+ # @return [Hash{String => Object}] data, layout and nonempty frames, sharing the
148
187
  # figure's own Hashes: changing them skips validation, as with {#data} and {#layout}
149
- def to_h = {"data" => @data, "layout" => @layout}
188
+ def to_h
189
+ result = {"data" => @data, "layout" => @layout}
190
+ result["frames"] = @frames unless @frames.empty?
191
+ result
192
+ end
150
193
 
151
194
  # @return [String] the figure as plotly.js JSON (config is not included, as in plotly.py)
152
195
  def to_json(*) = Serializer.dump(to_h)
@@ -166,6 +209,10 @@ module Plotly
166
209
 
167
210
  def schema = Schema.default
168
211
 
212
+ def build_frame(frame, index)
213
+ Frames.build(frame, data: @data, path: "frames[#{index}]", validate: @validate)
214
+ end
215
+
169
216
  def build(node, attrs, path) = Attributes.build(node, attrs, path: path, validate: @validate)
170
217
 
171
218
  def trace_node(type, path)
@@ -187,25 +234,34 @@ module Plotly
187
234
  end
188
235
  end
189
236
 
190
- def in_cell?(trace, row, col)
191
- return true unless row || col
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?
192
243
 
193
244
  grid!.cells(row, col).any? do |cell|
194
245
  domain = trace["domain"]
195
246
  if domain.is_a?(Hash)
196
- domain["x"] == cell.x_domain && domain["y"] == cell.y_domain
247
+ secondary_y.nil? && domain["x"] == cell.x_domain && domain["y"] == cell.y_domain
197
248
  elsif schema.trace(trace["type"])&.child("xaxis")
198
- cell.axis_ids == [(trace["xaxis"] || "x").to_s, (trace["yaxis"] || "y").to_s]
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)
199
252
  else
200
253
  false # 3D, polar, map... traces are not in any cell
201
254
  end
202
255
  end
203
256
  end
204
257
 
205
- def cell_reference(node, row, col)
258
+ def cell_reference(node, row, col, secondary_y)
206
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
207
263
  if node.child("xaxis")
208
- {"xaxis" => cell.axis_ids[0], "yaxis" => cell.axis_ids[1]}
264
+ {"xaxis" => cell.axis_ids[0], "yaxis" => cell.axis_ids(secondary_y)[1]}
209
265
  elsif node.child("domain")
210
266
  {"domain" => {"x" => cell.x_domain, "y" => cell.y_domain}}
211
267
  else
@@ -213,9 +269,9 @@ module Plotly
213
269
  end
214
270
  end
215
271
 
216
- def update_axes(letter, attrs, row, col)
217
- keys = if row || col
218
- grid!.cells(row, col).map { |cell| cell.layout_key(letter) }
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)] }
219
275
  else
220
276
  # Traces on cartesian axes refer to x/y unless they name another axis.
221
277
  referenced = @data.filter_map do |t|
@@ -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
data/lib/plotly/html.rb CHANGED
@@ -25,13 +25,16 @@ module Plotly
25
25
  # @param height [Integer, String, nil] element height; by default the layout height, else
26
26
  # 450px for a fragment (a percentage would collapse in a container of automatic height)
27
27
  # and the whole window for a full document
28
+ # @param auto_play [Boolean] start animation after registering frames
29
+ # @param animation_opts [Hash] options passed to Plotly.animate (frame and transition durations, etc.)
28
30
  # @return [String]
29
- def render(figure, include_plotlyjs: :cdn, full_html: false, div_id: nil, width: nil, height: nil)
31
+ def render(figure, include_plotlyjs: :cdn, full_html: false, div_id: nil, width: nil, height: nil,
32
+ auto_play: true, animation_opts: {})
30
33
  id = div_id || "rbplotly-#{SecureRandom.uuid}"
31
34
  body = [
32
35
  plotlyjs_tag(include_plotlyjs),
33
36
  %(<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>"
37
+ "<script>\n#{draw_call(figure, id, auto_play: auto_play, animation_opts: animation_opts)}\n</script>"
35
38
  ].compact.join("\n")
36
39
  full_html ? document(figure, body) : body
37
40
  end
@@ -69,10 +72,18 @@ module Plotly
69
72
  end
70
73
 
71
74
  # @api private
72
- def draw_call(figure, id)
75
+ def draw_call(figure, id, auto_play: true, animation_opts: {})
73
76
  config = DEFAULT_CONFIG.merge(figure.config)
74
77
  args = [id, figure.data, figure.layout, config].map { |arg| Serializer.dump(arg) }
75
- "Plotly.newPlot(#{args.join(", ")});"
78
+ script = "Plotly.newPlot(#{args.join(", ")})"
79
+ unless figure.frames.empty?
80
+ plot_id = Serializer.dump(id)
81
+ script += ".then(function () { return Plotly.addFrames(#{plot_id}, #{Serializer.dump(figure.frames)}); })"
82
+ if auto_play
83
+ script += ".then(function () { return Plotly.animate(#{plot_id}, null, #{Serializer.dump(animation_opts)}); })"
84
+ end
85
+ end
86
+ script + ";"
76
87
  end
77
88
 
78
89
  def plotlyjs_tag(mode)
@@ -57230,5 +57230,29 @@
57230
57230
  "type": "boolean"
57231
57231
  }
57232
57232
  }
57233
+ },
57234
+ "frames": {
57235
+ "array": {
57236
+ "attrs": {
57237
+ "baseframe": {
57238
+ "type": "string"
57239
+ },
57240
+ "data": {
57241
+ "type": "any"
57242
+ },
57243
+ "group": {
57244
+ "type": "string"
57245
+ },
57246
+ "layout": {
57247
+ "type": "any"
57248
+ },
57249
+ "name": {
57250
+ "type": "string"
57251
+ },
57252
+ "traces": {
57253
+ "type": "any"
57254
+ }
57255
+ }
57256
+ }
57233
57257
  }
57234
57258
  }
data/lib/plotly/schema.rb CHANGED
@@ -92,5 +92,8 @@ module Plotly
92
92
 
93
93
  # @return [Node] plotly.js config options
94
94
  def config = Node.new("config", @raw.fetch("config"))
95
+
96
+ # @return [Node] attributes of an animation frame (data and layout are validated separately)
97
+ def frame = Node.new("frames", @raw.fetch("frames")).item
95
98
  end
96
99
  end
@@ -18,11 +18,15 @@ module Plotly
18
18
  # @param subplot_titles [Array<String>, nil] one title per cell, in row-major order
19
19
  # @param horizontal_spacing [Float] gap between columns, as a fraction of the figure width
20
20
  # @param vertical_spacing [Float] gap between rows, as a fraction of the figure height
21
+ # @param column_widths [Array<Numeric>, nil] positive relative column widths, left to right
22
+ # @param row_heights [Array<Numeric>, nil] positive relative row heights, top to bottom
23
+ # @param specs [Array<Array<Hash>>, nil] one hash per cell; `secondary_y: true` adds a right y axis
21
24
  # @param figure_options [Hash] passed to {Figure#initialize} (`layout:`, `config:`, `validate:`)
22
25
  # @return [Figure]
23
26
  def self.make_subplots(rows: 1, cols: 1, shared_xaxes: false, shared_yaxes: false, subplot_titles: nil,
24
- horizontal_spacing: 0.2 / cols, vertical_spacing: 0.3 / rows, **figure_options)
25
- grid = Subplots::Grid.new(rows, cols, horizontal_spacing, vertical_spacing)
27
+ horizontal_spacing: nil, vertical_spacing: nil, column_widths: nil, row_heights: nil, specs: nil, **figure_options)
28
+ grid = Subplots::Grid.new(rows, cols, horizontal_spacing, vertical_spacing,
29
+ column_widths: column_widths, row_heights: row_heights, specs: specs)
26
30
  titles = Array(subplot_titles)
27
31
  if titles.size > grid.cells.size
28
32
  raise ArgumentError, "#{titles.size} subplot titles for #{grid.cells.size} cells"
@@ -39,34 +43,52 @@ module Plotly
39
43
  # Grid layout behind {Plotly.make_subplots}.
40
44
  # @api private
41
45
  module Subplots
42
- Cell = Struct.new(:row, :col, :index, :x_domain, :y_domain) do
46
+ Cell = Struct.new(:row, :col, :index, :x_domain, :y_domain, :secondary_index) do
43
47
  def suffix = (index == 1) ? "" : index.to_s
44
48
 
45
- def axis_ids = ["x#{suffix}", "y#{suffix}"]
49
+ def axis_ids(secondary_y = false) = ["x#{suffix}", secondary_y ? "y#{secondary_index}" : "y#{suffix}"]
46
50
 
47
51
  def layout_key(letter) = "#{letter}axis#{suffix}"
52
+
53
+ def y_keys(secondary_y = nil)
54
+ keys = []
55
+ keys << layout_key("y") unless secondary_y == true
56
+ keys << "yaxis#{secondary_index}" if secondary_index && secondary_y != false
57
+ keys
58
+ end
48
59
  end
49
60
 
50
61
  class Grid
51
62
  attr_reader :rows, :cols
52
63
 
53
- def initialize(rows, cols, horizontal_spacing, vertical_spacing)
64
+ def initialize(rows, cols, horizontal_spacing, vertical_spacing, column_widths: nil, row_heights: nil, specs: nil)
54
65
  unless rows.is_a?(Integer) && cols.is_a?(Integer) && rows.positive? && cols.positive?
55
66
  raise ArgumentError, "rows and cols must be positive integers"
56
67
  end
57
68
 
58
69
  @rows = rows
59
70
  @cols = cols
60
- width = (1.0 - horizontal_spacing * (cols - 1)) / cols
61
- height = (1.0 - vertical_spacing * (rows - 1)) / rows
62
- raise ArgumentError, "spacing leaves no room for the subplots" unless width.positive? && height.positive?
63
-
71
+ horizontal_spacing = 0.2 / cols if horizontal_spacing.nil?
72
+ vertical_spacing = 0.3 / rows if vertical_spacing.nil?
73
+ horizontal_spacing = spacing(horizontal_spacing, cols, "horizontal_spacing")
74
+ vertical_spacing = spacing(vertical_spacing, rows, "vertical_spacing")
75
+ widths = sizes(column_widths, cols, 1.0 - horizontal_spacing * (cols - 1), "column_widths")
76
+ heights = sizes(row_heights, rows, 1.0 - vertical_spacing * (rows - 1), "row_heights")
77
+ secondary = secondary_cells(specs)
78
+ next_axis = rows * cols
79
+
80
+ y1 = 1.0
64
81
  @cells = (1..rows).flat_map do |row|
65
- (1..cols).map do |col|
66
- x0 = (col - 1) * (width + horizontal_spacing)
67
- y1 = 1.0 - (row - 1) * (height + vertical_spacing)
68
- Cell.new(row, col, (row - 1) * cols + col, domain(x0, x0 + width), domain(y1 - height, y1))
82
+ x0 = 0.0
83
+ cells = (1..cols).map do |col|
84
+ second_axis = secondary[row - 1][col - 1] ? (next_axis += 1) : nil
85
+ cell = Cell.new(row, col, (row - 1) * cols + col,
86
+ domain(x0, x0 + widths[col - 1]), domain(y1 - heights[row - 1], y1), second_axis)
87
+ x0 += widths[col - 1] + horizontal_spacing
88
+ cell
69
89
  end
90
+ y1 -= heights[row - 1] + vertical_spacing
91
+ cells
70
92
  end
71
93
  end
72
94
 
@@ -91,6 +113,10 @@ module Plotly
91
113
  layout[cell.layout_key("y")] = {"domain" => cell.y_domain, "anchor" => x_id}.merge(
92
114
  (shared_y && cell.col != 1) ? linked(cell(cell.row, 1).axis_ids[1]) : {}
93
115
  )
116
+ if cell.secondary_index
117
+ layout["yaxis#{cell.secondary_index}"] = {"anchor" => x_id, "overlaying" => y_id,
118
+ "side" => "right", "automargin" => true}
119
+ end
94
120
  end
95
121
  end
96
122
 
@@ -109,6 +135,48 @@ module Plotly
109
135
 
110
136
  private
111
137
 
138
+ def positive_number?(value)
139
+ value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value.positive?
140
+ end
141
+
142
+ def spacing(value, count, name)
143
+ unless value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value.between?(0, 1)
144
+ raise ArgumentError, "#{name} must be a finite number between 0 and 1"
145
+ end
146
+ raise ArgumentError, "spacing leaves no room for the subplots" unless value * (count - 1) < 1
147
+
148
+ value.to_f
149
+ end
150
+
151
+ def sizes(values, count, available, name)
152
+ values = Array.new(count, 1) if values.nil?
153
+ unless values.is_a?(Array) && values.size == count && values.all? { |v| positive_number?(v) }
154
+ raise ArgumentError, "#{name} must contain #{count} positive finite numbers"
155
+ end
156
+ largest = values.max
157
+ weights = values.map { |v| v.fdiv(largest) }
158
+ total = weights.sum
159
+ weights.map { |v| available * v / total }
160
+ end
161
+
162
+ def secondary_cells(specs)
163
+ return Array.new(rows) { Array.new(cols, false) } if specs.nil?
164
+ unless specs.is_a?(Array) && specs.size == rows && specs.all? { |row| row.is_a?(Array) && row.size == cols }
165
+ raise ArgumentError, "specs must be a #{rows}x#{cols} array of cell hashes"
166
+ end
167
+ specs.map do |row|
168
+ row.map do |spec|
169
+ unless spec.is_a?(Hash) && spec.keys.all? { |k| k.to_s == "secondary_y" }
170
+ raise ArgumentError, "each spec must be a Hash with only the secondary_y option"
171
+ end
172
+ value = spec.transform_keys(&:to_s).fetch("secondary_y", false)
173
+ raise ArgumentError, "secondary_y must be true or false" unless [true, false].include?(value)
174
+
175
+ value
176
+ end
177
+ end
178
+ end
179
+
112
180
  # Follows another axis' range and leaves the tick labels to it.
113
181
  def linked(axis_id) = {"matches" => axis_id, "showticklabels" => false}
114
182
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Plotly
4
4
  # The gem version.
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.1"
6
6
 
7
7
  # The plotly.js release this gem's schema, CDN URL and bundled script are built from.
8
8
  PLOTLY_JS_VERSION = "4.1.2"
data/lib/plotly.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "plotly/schema"
16
16
  require_relative "plotly/attributes"
17
17
  require_relative "plotly/serializer"
18
18
  require_relative "plotly/html"
19
+ require_relative "plotly/frames"
19
20
  require_relative "plotly/figure"
20
21
  require_relative "plotly/subplots"
21
22
  require_relative "plotly/plot"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rbplotly
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoshihiro Ashida
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '2.7'
18
+ version: '2.9'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '2.7'
25
+ version: '2.9'
26
26
  description: Build Plotly.js figures with plain Ruby hashes, check every attribute
27
27
  against the plot schema of the bundled plotly.js release, and write self-contained
28
28
  HTML that works offline, in Rails views and in Jupyter (IRuby). No account or API
@@ -40,6 +40,7 @@ files:
40
40
  - lib/plotly/assets/plotly.min.js
41
41
  - lib/plotly/attributes.rb
42
42
  - lib/plotly/figure.rb
43
+ - lib/plotly/frames.rb
43
44
  - lib/plotly/html.rb
44
45
  - lib/plotly/plot.rb
45
46
  - lib/plotly/schema.rb