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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +38 -0
- data/lib/plotly/figure.rb +81 -25
- data/lib/plotly/frames.rb +38 -0
- data/lib/plotly/html.rb +15 -4
- data/lib/plotly/schema/plot-schema.json +24 -0
- data/lib/plotly/schema.rb +3 -0
- data/lib/plotly/subplots.rb +81 -13
- data/lib/plotly/version.rb +1 -1
- data/lib/plotly.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ee3bc96889cfdc7042b4cb2d4f493003ede009bb78ac14f59b2ff78aa56345c
|
|
4
|
+
data.tar.gz: e395c9e2230df5996b6a91a1757b4d72cd9829149aad82e7ba0acc3dac066c60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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}]
|
|
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
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
data/lib/plotly/subplots.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Cell.new(row, col, (row - 1) * cols + col,
|
|
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
|
|
data/lib/plotly/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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
|