charty 0.2.0 → 0.2.6
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/.github/workflows/ci.yml +71 -0
- data/.github/workflows/nmatrix.yml +67 -0
- data/.github/workflows/pycall.yml +86 -0
- data/Dockerfile.dev +9 -1
- data/Gemfile +18 -0
- data/README.md +177 -9
- data/Rakefile +4 -5
- data/charty.gemspec +10 -4
- data/examples/Gemfile +1 -0
- data/examples/active_record.ipynb +1 -1
- data/examples/daru.ipynb +1 -1
- data/examples/iris_dataset.ipynb +1 -1
- data/examples/nmatrix.ipynb +1 -1
- data/examples/{numo-narray.ipynb → numo_narray.ipynb} +1 -1
- data/examples/palette.rb +71 -0
- data/examples/sample.png +0 -0
- data/examples/sample_images/hist_gruff.png +0 -0
- data/examples/sample_pyplot.ipynb +40 -38
- data/images/penguins_body_mass_g_flipper_length_mm_scatter_plot.png +0 -0
- data/images/penguins_body_mass_g_flipper_length_mm_species_scatter_plot.png +0 -0
- data/images/penguins_body_mass_g_flipper_length_mm_species_sex_scatter_plot.png +0 -0
- data/images/penguins_species_body_mass_g_bar_plot_h.png +0 -0
- data/images/penguins_species_body_mass_g_bar_plot_v.png +0 -0
- data/images/penguins_species_body_mass_g_box_plot_h.png +0 -0
- data/images/penguins_species_body_mass_g_box_plot_v.png +0 -0
- data/images/penguins_species_body_mass_g_sex_bar_plot_v.png +0 -0
- data/images/penguins_species_body_mass_g_sex_box_plot_v.png +0 -0
- data/lib/charty.rb +13 -1
- data/lib/charty/backend_methods.rb +8 -0
- data/lib/charty/backends.rb +26 -1
- data/lib/charty/backends/bokeh.rb +31 -31
- data/lib/charty/backends/{google_chart.rb → google_charts.rb} +75 -33
- data/lib/charty/backends/gruff.rb +14 -3
- data/lib/charty/backends/plotly.rb +774 -9
- data/lib/charty/backends/pyplot.rb +611 -34
- data/lib/charty/backends/rubyplot.rb +2 -2
- data/lib/charty/backends/unicode_plot.rb +79 -0
- data/lib/charty/dash_pattern_generator.rb +57 -0
- data/lib/charty/index.rb +213 -0
- data/lib/charty/linspace.rb +1 -1
- data/lib/charty/plot_methods.rb +254 -0
- data/lib/charty/plotter.rb +10 -10
- data/lib/charty/plotters.rb +12 -0
- data/lib/charty/plotters/abstract_plotter.rb +243 -0
- data/lib/charty/plotters/bar_plotter.rb +201 -0
- data/lib/charty/plotters/box_plotter.rb +79 -0
- data/lib/charty/plotters/categorical_plotter.rb +380 -0
- data/lib/charty/plotters/count_plotter.rb +7 -0
- data/lib/charty/plotters/estimation_support.rb +84 -0
- data/lib/charty/plotters/line_plotter.rb +300 -0
- data/lib/charty/plotters/random_support.rb +25 -0
- data/lib/charty/plotters/relational_plotter.rb +635 -0
- data/lib/charty/plotters/scatter_plotter.rb +80 -0
- data/lib/charty/plotters/vector_plotter.rb +6 -0
- data/lib/charty/statistics.rb +114 -0
- data/lib/charty/table.rb +161 -15
- data/lib/charty/table_adapters.rb +2 -0
- data/lib/charty/table_adapters/active_record_adapter.rb +17 -9
- data/lib/charty/table_adapters/base_adapter.rb +166 -0
- data/lib/charty/table_adapters/daru_adapter.rb +41 -3
- data/lib/charty/table_adapters/datasets_adapter.rb +17 -2
- data/lib/charty/table_adapters/hash_adapter.rb +143 -16
- data/lib/charty/table_adapters/narray_adapter.rb +25 -6
- data/lib/charty/table_adapters/nmatrix_adapter.rb +15 -5
- data/lib/charty/table_adapters/pandas_adapter.rb +163 -0
- data/lib/charty/util.rb +28 -0
- data/lib/charty/vector.rb +69 -0
- data/lib/charty/vector_adapters.rb +187 -0
- data/lib/charty/vector_adapters/array_adapter.rb +101 -0
- data/lib/charty/vector_adapters/daru_adapter.rb +163 -0
- data/lib/charty/vector_adapters/narray_adapter.rb +182 -0
- data/lib/charty/vector_adapters/nmatrix_adapter.rb +37 -0
- data/lib/charty/vector_adapters/numpy_adapter.rb +168 -0
- data/lib/charty/vector_adapters/pandas_adapter.rb +199 -0
- data/lib/charty/version.rb +1 -1
- metadata +121 -22
- data/.travis.yml +0 -10
data/lib/charty/plotter.rb
CHANGED
@@ -237,22 +237,22 @@ module Charty
|
|
237
237
|
end
|
238
238
|
|
239
239
|
def render(filename=nil)
|
240
|
-
@backend.
|
240
|
+
@backend.old_style_render(self, filename)
|
241
241
|
end
|
242
242
|
|
243
|
-
def save(filename=nil)
|
244
|
-
@backend.
|
243
|
+
def save(filename=nil, **kw)
|
244
|
+
@backend.old_style_save(self, filename, **kw)
|
245
245
|
end
|
246
246
|
|
247
247
|
def apply(backend)
|
248
248
|
case
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
249
|
+
when !@series.empty?
|
250
|
+
backend.series = @series
|
251
|
+
when @function
|
252
|
+
linspace = Linspace.new(@range[:x], 100)
|
253
|
+
# TODO: set label with function
|
254
|
+
# TODO: set ys to xs when gruff curve with function
|
255
|
+
@series << Series.new(linspace.to_a, linspace.map{|x| @function.call(x) }, label: "function" )
|
256
256
|
end
|
257
257
|
|
258
258
|
@backend = backend
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require_relative "plotters/abstract_plotter"
|
2
|
+
require_relative "plotters/random_support"
|
3
|
+
require_relative "plotters/estimation_support"
|
4
|
+
require_relative "plotters/categorical_plotter"
|
5
|
+
require_relative "plotters/bar_plotter"
|
6
|
+
require_relative "plotters/box_plotter"
|
7
|
+
require_relative "plotters/count_plotter"
|
8
|
+
|
9
|
+
require_relative "plotters/vector_plotter"
|
10
|
+
require_relative "plotters/relational_plotter"
|
11
|
+
require_relative "plotters/scatter_plotter"
|
12
|
+
require_relative "plotters/line_plotter"
|
@@ -0,0 +1,243 @@
|
|
1
|
+
module Charty
|
2
|
+
module Plotters
|
3
|
+
class AbstractPlotter
|
4
|
+
def initialize(x, y, color, **options)
|
5
|
+
self.x = x
|
6
|
+
self.y = y
|
7
|
+
self.color = color
|
8
|
+
self.data = data
|
9
|
+
self.palette = palette
|
10
|
+
substitute_options(options)
|
11
|
+
|
12
|
+
@var_levels = {}
|
13
|
+
@var_ordered = {x: false, y: false}
|
14
|
+
|
15
|
+
yield self if block_given?
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :data, :x, :y, :color
|
19
|
+
attr_reader :color_order, :key_color, :palette
|
20
|
+
|
21
|
+
def var_levels
|
22
|
+
variables.each_key do |var|
|
23
|
+
# TODO: Move mappers from RelationalPlotter to here,
|
24
|
+
# and remove the use of instance_variable_get
|
25
|
+
if instance_variable_defined?(:"@#{var}_mapper")
|
26
|
+
mapper = instance_variable_get(:"@#{var}_mapper")
|
27
|
+
@var_levels[var] = mapper.levels
|
28
|
+
end
|
29
|
+
end
|
30
|
+
@var_levels
|
31
|
+
end
|
32
|
+
|
33
|
+
def inspect
|
34
|
+
"#<#{self.class}:0x%016x>" % self.object_id
|
35
|
+
end
|
36
|
+
|
37
|
+
def data=(data)
|
38
|
+
@data = case data
|
39
|
+
when nil, Charty::Table
|
40
|
+
data
|
41
|
+
when method(:array?)
|
42
|
+
Charty::Vector.new(data)
|
43
|
+
else
|
44
|
+
Charty::Table.new(data)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def x=(x)
|
49
|
+
@x = check_dimension(x, :x)
|
50
|
+
end
|
51
|
+
|
52
|
+
def y=(y)
|
53
|
+
@y = check_dimension(y, :y)
|
54
|
+
end
|
55
|
+
|
56
|
+
def color=(color)
|
57
|
+
@color = check_dimension(color, :color)
|
58
|
+
end
|
59
|
+
|
60
|
+
def color_order=(color_order)
|
61
|
+
@color_order = color_order
|
62
|
+
end
|
63
|
+
|
64
|
+
# TODO: move to categorical_plotter
|
65
|
+
def key_color=(key_color)
|
66
|
+
#@key_color = XXX
|
67
|
+
unless key_color.nil?
|
68
|
+
raise NotImplementedError,
|
69
|
+
"Specifying key_color is not supported yet"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def palette=(palette)
|
74
|
+
@palette = case palette
|
75
|
+
when nil, Palette, Symbol, String
|
76
|
+
palette
|
77
|
+
else
|
78
|
+
raise ArgumentError,
|
79
|
+
"invalid type for palette (given #{palette.class}, " +
|
80
|
+
"expected Palette, Symbol, or String)"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
private def substitute_options(options)
|
85
|
+
options.each do |key, val|
|
86
|
+
send("#{key}=", val)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
private def check_dimension(value, name)
|
91
|
+
case value
|
92
|
+
when nil, Symbol, String
|
93
|
+
value
|
94
|
+
when ->(x) { x.respond_to?(:to_str) }
|
95
|
+
value.to_str
|
96
|
+
when method(:array?)
|
97
|
+
Charty::Vector.new(value)
|
98
|
+
else
|
99
|
+
raise ArgumentError,
|
100
|
+
"invalid type of dimension for #{name} (given #{value.inspect})",
|
101
|
+
caller
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
private def check_number(value, name, allow_nil: false)
|
106
|
+
case value
|
107
|
+
when Numeric
|
108
|
+
value
|
109
|
+
else
|
110
|
+
if allow_nil && value.nil?
|
111
|
+
nil
|
112
|
+
else
|
113
|
+
expected = if allow_nil
|
114
|
+
"number or nil"
|
115
|
+
else
|
116
|
+
"number"
|
117
|
+
end
|
118
|
+
raise ArgumentError,
|
119
|
+
"invalid value for #{name} (%p for #{expected})" % value,
|
120
|
+
caller
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
private def check_boolean(value, name, allow_nil: false)
|
126
|
+
case value
|
127
|
+
when true, false
|
128
|
+
value
|
129
|
+
else
|
130
|
+
expected = if allow_nil
|
131
|
+
"true, false, or nil"
|
132
|
+
else
|
133
|
+
"true or false"
|
134
|
+
end
|
135
|
+
raise ArgumentError,
|
136
|
+
"invalid value for #{name} (%p for #{expected})" % value,
|
137
|
+
caller
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
private def variable_type(vector, boolean_type=:numeric)
|
142
|
+
if vector.numeric?
|
143
|
+
:numeric
|
144
|
+
elsif vector.categorical?
|
145
|
+
:categorical
|
146
|
+
else
|
147
|
+
case vector[0]
|
148
|
+
when true, false
|
149
|
+
boolean_type
|
150
|
+
else
|
151
|
+
:categorical
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
private def array?(value)
|
157
|
+
TableAdapters::HashAdapter.array?(value)
|
158
|
+
end
|
159
|
+
|
160
|
+
private def remove_na!(ary)
|
161
|
+
ary.reject! {|x| Util.missing?(x) }
|
162
|
+
ary
|
163
|
+
end
|
164
|
+
|
165
|
+
private def each_subset(grouping_vars, reverse: false, processed: false, by_facet: true, allow_empty: false, drop_na: true)
|
166
|
+
case grouping_vars
|
167
|
+
when nil
|
168
|
+
grouping_vars = []
|
169
|
+
when String, Symbol
|
170
|
+
grouping_vars = [grouping_vars.to_sym]
|
171
|
+
end
|
172
|
+
|
173
|
+
if by_facet
|
174
|
+
[:col, :row].each do |facet_var|
|
175
|
+
grouping_vars << facet_var if variables.key?(facet_var)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
grouping_vars = grouping_vars.select {|var| variables.key?(var) }
|
180
|
+
|
181
|
+
data = processed ? processed_data : plot_data
|
182
|
+
data = data.drop_na if drop_na
|
183
|
+
|
184
|
+
levels = var_levels.dup
|
185
|
+
|
186
|
+
[:x, :y].each do |axis|
|
187
|
+
levels[axis] = plot_data[axis].categorical_order()
|
188
|
+
if processed
|
189
|
+
# TODO: perform inverse conversion of axis scaling here
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
if not grouping_vars.empty?
|
194
|
+
grouped = data.group_by(grouping_vars, sort: false)
|
195
|
+
grouped.each_group do |group_key, group_data|
|
196
|
+
next if group_data.empty? && !allow_empty
|
197
|
+
|
198
|
+
yield(grouping_vars.zip(group_key).to_h, group_data)
|
199
|
+
end
|
200
|
+
else
|
201
|
+
yield({}, data.dup)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def processed_data
|
206
|
+
@processed_data ||= calculate_processed_data
|
207
|
+
end
|
208
|
+
|
209
|
+
private def calculate_processed_data
|
210
|
+
# TODO: axis scaling support
|
211
|
+
plot_data
|
212
|
+
end
|
213
|
+
|
214
|
+
def save(filename, **kwargs)
|
215
|
+
backend = Backends.current
|
216
|
+
backend.begin_figure
|
217
|
+
render_plot(backend, **kwargs)
|
218
|
+
backend.save(filename, **kwargs)
|
219
|
+
end
|
220
|
+
|
221
|
+
def render(notebook: false, **kwargs)
|
222
|
+
backend = Backends.current
|
223
|
+
backend.begin_figure
|
224
|
+
render_plot(backend, notebook: notebook, **kwargs)
|
225
|
+
backend.render(notebook: notebook, **kwargs)
|
226
|
+
end
|
227
|
+
|
228
|
+
private def render_plot(*, **)
|
229
|
+
raise NotImplementedError,
|
230
|
+
"subclass must implement #{__method__}"
|
231
|
+
end
|
232
|
+
|
233
|
+
def to_iruby
|
234
|
+
render(notebook: iruby_notebook?)
|
235
|
+
end
|
236
|
+
|
237
|
+
private def iruby_notebook?
|
238
|
+
return false unless defined?(IRuby)
|
239
|
+
true # TODO: Check the server is notebook or not
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
@@ -0,0 +1,201 @@
|
|
1
|
+
module Charty
|
2
|
+
module Plotters
|
3
|
+
class BarPlotter < CategoricalPlotter
|
4
|
+
self.default_palette = :light
|
5
|
+
self.require_numeric = true
|
6
|
+
|
7
|
+
def initialize(data: nil, variables: {}, **options, &block)
|
8
|
+
x, y, color = variables.values_at(:x, :y, :color)
|
9
|
+
super(x, y, color, data: data, **options, &block)
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :error_color
|
13
|
+
|
14
|
+
def error_color=(error_color)
|
15
|
+
@error_color = check_error_color(error_color)
|
16
|
+
end
|
17
|
+
|
18
|
+
private def check_error_color(value)
|
19
|
+
case value
|
20
|
+
when Colors::AbstractColor
|
21
|
+
value
|
22
|
+
when Array
|
23
|
+
Colors::RGB.new(*value)
|
24
|
+
when String
|
25
|
+
# TODO: Use Colors.parse when it'll be available
|
26
|
+
Colors::RGB.parse(value)
|
27
|
+
else
|
28
|
+
raise ArgumentError,
|
29
|
+
"invalid value for error_color (%p for a color, a RGB tripret, or a RGB hex string)" % value
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
attr_reader :error_width
|
34
|
+
|
35
|
+
def error_width=(error_width)
|
36
|
+
@error_width = check_number(error_width, :error_width, allow_nil: true)
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_reader :cap_size
|
40
|
+
|
41
|
+
def cap_size=(cap_size)
|
42
|
+
@cap_size = check_number(cap_size, :cap_size, allow_nil: true)
|
43
|
+
end
|
44
|
+
|
45
|
+
private def render_plot(backend, **)
|
46
|
+
draw_bars(backend)
|
47
|
+
annotate_axes(backend)
|
48
|
+
backend.invert_yaxis if orient == :h
|
49
|
+
end
|
50
|
+
|
51
|
+
private def draw_bars(backend)
|
52
|
+
setup_estimations
|
53
|
+
|
54
|
+
if @plot_colors.nil?
|
55
|
+
bar_pos = (0 ... @estimations.length).to_a
|
56
|
+
error_colors = bar_pos.map { error_color }
|
57
|
+
if @conf_int.empty?
|
58
|
+
ci_params = {}
|
59
|
+
else
|
60
|
+
ci_params = {conf_int: @conf_int, error_colors: error_colors,
|
61
|
+
error_width: error_width, cap_size: cap_size}
|
62
|
+
end
|
63
|
+
backend.bar(bar_pos, nil, @estimations, @colors, orient, **ci_params)
|
64
|
+
else
|
65
|
+
bar_pos = (0 ... @estimations[0].length).to_a
|
66
|
+
error_colors = bar_pos.map { error_color }
|
67
|
+
offsets = color_offsets
|
68
|
+
width = nested_width
|
69
|
+
@color_names.each_with_index do |color_name, i|
|
70
|
+
pos = bar_pos.map {|x| x + offsets[i] }
|
71
|
+
colors = Array.new(@estimations[i].length) { @colors[i] }
|
72
|
+
if @conf_int[i].empty?
|
73
|
+
ci_params = {}
|
74
|
+
else
|
75
|
+
ci_params = {conf_int: @conf_int[i], error_colors: error_colors,
|
76
|
+
error_width: error_width, cap_size: cap_size}
|
77
|
+
end
|
78
|
+
backend.bar(pos, @group_names, @estimations[i], colors, orient,
|
79
|
+
label: color_name, width: width, **ci_params)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
private def setup_estimations
|
85
|
+
if @color_names.nil?
|
86
|
+
setup_estimations_with_single_color_group
|
87
|
+
else
|
88
|
+
setup_estimations_with_multiple_color_groups
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
private def setup_estimations_with_single_color_group
|
93
|
+
estimations = []
|
94
|
+
conf_int = []
|
95
|
+
|
96
|
+
@plot_data.each do |group_data|
|
97
|
+
# Single color group
|
98
|
+
if @plot_units.nil?
|
99
|
+
stat_data = group_data.drop_na
|
100
|
+
unit_data = nil
|
101
|
+
else
|
102
|
+
# TODO: Support units
|
103
|
+
end
|
104
|
+
|
105
|
+
estimation = if stat_data.size == 0
|
106
|
+
Float::NAN
|
107
|
+
else
|
108
|
+
estimate(estimator, stat_data)
|
109
|
+
end
|
110
|
+
estimations << estimation
|
111
|
+
|
112
|
+
unless ci.nil?
|
113
|
+
if stat_data.size < 2
|
114
|
+
conf_int << [Float::NAN, Float::NAN]
|
115
|
+
next
|
116
|
+
end
|
117
|
+
|
118
|
+
if ci == :sd
|
119
|
+
sd = stat_data.stdev
|
120
|
+
conf_int << [estimation - sd, estimation + sd]
|
121
|
+
else
|
122
|
+
conf_int << Statistics.bootstrap_ci(stat_data, ci, func: estimator, n_boot: n_boot,
|
123
|
+
units: unit_data, random: random)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
@estimations = estimations
|
129
|
+
@conf_int = conf_int
|
130
|
+
end
|
131
|
+
|
132
|
+
private def setup_estimations_with_multiple_color_groups
|
133
|
+
estimations = Array.new(@color_names.length) { [] }
|
134
|
+
conf_int = Array.new(@color_names.length) { [] }
|
135
|
+
|
136
|
+
@plot_data.each_with_index do |group_data, i|
|
137
|
+
@color_names.each_with_index do |color_name, j|
|
138
|
+
if @plot_colors[i].length == 0
|
139
|
+
estimations[j] << Float::NAN
|
140
|
+
unless ci.nil?
|
141
|
+
conf_int[j] << [Float::NAN, Float::NAN]
|
142
|
+
end
|
143
|
+
next
|
144
|
+
end
|
145
|
+
|
146
|
+
color_mask = @plot_colors[i].eq(color_name)
|
147
|
+
if @plot_units.nil?
|
148
|
+
begin
|
149
|
+
stat_data = group_data[color_mask].drop_na
|
150
|
+
rescue
|
151
|
+
@plot_data.each_with_index {|pd, k| p k => pd }
|
152
|
+
@plot_colors.each_with_index {|pc, k| p k => pc }
|
153
|
+
raise
|
154
|
+
end
|
155
|
+
unit_data = nil
|
156
|
+
else
|
157
|
+
# TODO: Support units
|
158
|
+
end
|
159
|
+
|
160
|
+
estimation = if stat_data.size == 0
|
161
|
+
Float::NAN
|
162
|
+
else
|
163
|
+
estimate(estimator, stat_data)
|
164
|
+
end
|
165
|
+
estimations[j] << estimation
|
166
|
+
|
167
|
+
unless ci.nil?
|
168
|
+
if stat_data.size < 2
|
169
|
+
conf_int[j] << [Float::NAN, Float::NAN]
|
170
|
+
next
|
171
|
+
end
|
172
|
+
|
173
|
+
if ci == :sd
|
174
|
+
sd = stat_data.stdev
|
175
|
+
conf_int[j] << [estimation - sd, estimation + sd]
|
176
|
+
else
|
177
|
+
conf_int[j] << Statistics.bootstrap_ci(stat_data, ci, func: estimator, n_boot: n_boot,
|
178
|
+
units: unit_data, random: random)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
@estimations = estimations
|
185
|
+
@conf_int = conf_int
|
186
|
+
end
|
187
|
+
|
188
|
+
private def estimate(estimator, data)
|
189
|
+
case estimator
|
190
|
+
when :count
|
191
|
+
data.length
|
192
|
+
when :mean
|
193
|
+
data.mean
|
194
|
+
else
|
195
|
+
# TODO: Support other estimations
|
196
|
+
raise NotImplementedError, "#{estimator} estimator is not supported yet"
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|