unicode_plot 0.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 +7 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +20 -0
- data/README.md +44 -0
- data/Rakefile +13 -0
- data/lib/unicode_plot.rb +14 -0
- data/lib/unicode_plot/ascii_canvas.rb +157 -0
- data/lib/unicode_plot/barplot.rb +104 -0
- data/lib/unicode_plot/boxplot.rb +28 -0
- data/lib/unicode_plot/braille_canvas.rb +65 -0
- data/lib/unicode_plot/canvas.rb +160 -0
- data/lib/unicode_plot/lineplot.rb +249 -0
- data/lib/unicode_plot/plot.rb +139 -0
- data/lib/unicode_plot/renderer.rb +234 -0
- data/lib/unicode_plot/styled_printer.rb +91 -0
- data/lib/unicode_plot/value_transformer.rb +43 -0
- data/lib/unicode_plot/version.rb +9 -0
- data/test/helper.rb +2 -0
- data/test/helper/fixture.rb +14 -0
- data/test/helper/with_term.rb +23 -0
- data/test/run-test.rb +12 -0
- data/test/test-barplot.rb +59 -0
- data/test/test-boxplot.rb +48 -0
- data/test/test-canvas.rb +124 -0
- data/test/test-lineplot.rb +132 -0
- data/unicode_plot.gemspec +35 -0
- metadata +118 -0
@@ -0,0 +1,160 @@
|
|
1
|
+
module UnicodePlot
|
2
|
+
class Canvas
|
3
|
+
include BorderPrinter
|
4
|
+
|
5
|
+
def self.create(canvas_type, width, height, **kw)
|
6
|
+
case canvas_type
|
7
|
+
when :ascii
|
8
|
+
AsciiCanvas.new(width, height, **kw)
|
9
|
+
when :braille
|
10
|
+
BrailleCanvas.new(width, height, **kw)
|
11
|
+
else
|
12
|
+
raise ArgumentError, "unknown canvas type: #{canvas_type}"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(width, height, pixel_width, pixel_height, fill_char,
|
17
|
+
origin_x: 0,
|
18
|
+
origin_y: 0,
|
19
|
+
plot_width: 1,
|
20
|
+
plot_height: 1,
|
21
|
+
x_pixel_per_char: 1,
|
22
|
+
y_pixel_per_char: 1)
|
23
|
+
@width = width
|
24
|
+
@height = height
|
25
|
+
@pixel_width = pixel_width
|
26
|
+
@pixel_height = pixel_height
|
27
|
+
@origin_x = origin_x
|
28
|
+
@origin_y = origin_y
|
29
|
+
@plot_width = plot_width
|
30
|
+
@plot_height = plot_height
|
31
|
+
@x_pixel_per_char = x_pixel_per_char
|
32
|
+
@y_pixel_per_char = y_pixel_per_char
|
33
|
+
@grid = Array.new(@width * @height, fill_char)
|
34
|
+
@colors = Array.new(@width * @height, COLOR_ENCODE[:normal])
|
35
|
+
end
|
36
|
+
|
37
|
+
attr_reader :width
|
38
|
+
attr_reader :height
|
39
|
+
attr_reader :pixel_width
|
40
|
+
attr_reader :pixel_height
|
41
|
+
attr_reader :origin_x
|
42
|
+
attr_reader :origin_y
|
43
|
+
attr_reader :plot_width
|
44
|
+
attr_reader :plot_height
|
45
|
+
attr_reader :x_pixel_per_char
|
46
|
+
attr_reader :y_pixel_per_char
|
47
|
+
|
48
|
+
def show(out)
|
49
|
+
b = BorderMaps::BORDER_SOLID
|
50
|
+
border_length = width
|
51
|
+
|
52
|
+
print_border_top(out, "", border_length, :solid, color: :light_black)
|
53
|
+
out.puts
|
54
|
+
(0 ... height).each do |row_index|
|
55
|
+
print_styled(out, b[:l], color: :light_black)
|
56
|
+
print_row(out, row_index)
|
57
|
+
print_styled(out, b[:r], color: :light_black)
|
58
|
+
out.puts
|
59
|
+
end
|
60
|
+
print_border_bottom(out, "", border_length, :solid, color: :light_black)
|
61
|
+
end
|
62
|
+
|
63
|
+
def print(out)
|
64
|
+
(0 ... height).each do |row_index|
|
65
|
+
print_row(out, row_index)
|
66
|
+
out.puts if row_index < height - 1
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def char_at(x, y)
|
71
|
+
@grid[index_at(x, y)]
|
72
|
+
end
|
73
|
+
|
74
|
+
def color_at(x, y)
|
75
|
+
@colors[index_at(x, y)]
|
76
|
+
end
|
77
|
+
|
78
|
+
def index_at(x, y)
|
79
|
+
return nil unless 0 <= x && x < width && 0 <= y && y < height
|
80
|
+
y * width + x
|
81
|
+
end
|
82
|
+
|
83
|
+
def point!(x, y, color)
|
84
|
+
unless origin_x <= x && x <= origin_x + plot_width &&
|
85
|
+
origin_y <= y && y <= origin_y + plot_height
|
86
|
+
return c
|
87
|
+
end
|
88
|
+
|
89
|
+
plot_offset_x = x - origin_x
|
90
|
+
pixel_x = plot_offset_x.fdiv(plot_width) * pixel_width
|
91
|
+
|
92
|
+
plot_offset_y = y - origin_y
|
93
|
+
pixel_y = pixel_height - plot_offset_y.fdiv(plot_height) * pixel_height
|
94
|
+
|
95
|
+
pixel!(pixel_x.floor, pixel_y.floor, color)
|
96
|
+
end
|
97
|
+
|
98
|
+
def points!(x, y, color = :normal)
|
99
|
+
if x.length != y.length
|
100
|
+
raise ArgumentError, "x and y must be the same length"
|
101
|
+
end
|
102
|
+
unless x.length > 0
|
103
|
+
raise ArgumentError, "x and y must not be empty"
|
104
|
+
end
|
105
|
+
(0 ... x.length).each do |i|
|
106
|
+
point!(x[i], y[i], color)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# digital differential analyzer algorithm
|
111
|
+
def line!(x1, y1, x2, y2, color)
|
112
|
+
if (x1 < origin_x && x2 < origin_x) ||
|
113
|
+
(x1 > origin_x + plot_width && x2 > origin_x + plot_width)
|
114
|
+
return color
|
115
|
+
end
|
116
|
+
if (y1 < origin_y && y2 < origin_y) ||
|
117
|
+
(y1 > origin_y + plot_height && y2 > origin_y + plot_height)
|
118
|
+
return color
|
119
|
+
end
|
120
|
+
|
121
|
+
toff = x1 - origin_x
|
122
|
+
px1 = toff.fdiv(plot_width) * pixel_width
|
123
|
+
toff = x2 - origin_x
|
124
|
+
px2 = toff.fdiv(plot_width) * pixel_width
|
125
|
+
|
126
|
+
toff = y1 - origin_y
|
127
|
+
py1 = pixel_height - toff.fdiv(plot_height) * pixel_height
|
128
|
+
toff = y2 - origin_y
|
129
|
+
py2 = pixel_height - toff.fdiv(plot_height) * pixel_height
|
130
|
+
|
131
|
+
dx = px2 - px1
|
132
|
+
dy = py2 - py1
|
133
|
+
nsteps = dx.abs > dy.abs ? dx.abs : dy.abs
|
134
|
+
inc_x = dx.fdiv(nsteps)
|
135
|
+
inc_y = dy.fdiv(nsteps)
|
136
|
+
|
137
|
+
cur_x = px1
|
138
|
+
cur_y = py1
|
139
|
+
pixel!(cur_x.floor, cur_y.floor, color)
|
140
|
+
1.upto(nsteps) do |i|
|
141
|
+
cur_x += inc_x
|
142
|
+
cur_y += inc_y
|
143
|
+
pixel!(cur_x.floor, cur_y.floor, color)
|
144
|
+
end
|
145
|
+
color
|
146
|
+
end
|
147
|
+
|
148
|
+
def lines!(x, y, color = :normal)
|
149
|
+
if x.length != y.length
|
150
|
+
raise ArgumentError, "x and y must be the same length"
|
151
|
+
end
|
152
|
+
unless x.length > 0
|
153
|
+
raise ArgumentError, "x and y must not be empty"
|
154
|
+
end
|
155
|
+
(0 ... (x.length - 1)).each do |i|
|
156
|
+
line!(x[i], y[i], x[i+1], y[i+1], color)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,249 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module UnicodePlot
|
4
|
+
class GridCanvas < Plot
|
5
|
+
MIN_WIDTH = 5
|
6
|
+
DEFAULT_WIDTH = 40
|
7
|
+
MIN_HEIGHT = 2
|
8
|
+
DEFAULT_HEIGHT = 15
|
9
|
+
|
10
|
+
def initialize(x, y, canvas,
|
11
|
+
width: DEFAULT_WIDTH,
|
12
|
+
height: DEFAULT_HEIGHT,
|
13
|
+
xlim: [0, 0],
|
14
|
+
ylim: [0, 0],
|
15
|
+
grid: true,
|
16
|
+
**kw)
|
17
|
+
unless xlim.length == 2 && ylim.length == 2
|
18
|
+
raise ArgumentError, "xlim and ylim must be 2-length arrays"
|
19
|
+
end
|
20
|
+
width = [width, MIN_WIDTH].max
|
21
|
+
height = [height, MIN_HEIGHT].max
|
22
|
+
min_x, max_x = extend_limits(x, xlim)
|
23
|
+
min_y, max_y = extend_limits(y, ylim)
|
24
|
+
origin_x = min_x
|
25
|
+
origin_y = min_y
|
26
|
+
plot_width = max_x - origin_x
|
27
|
+
plot_height = max_y - origin_y
|
28
|
+
@canvas = Canvas.create(canvas, width, height,
|
29
|
+
origin_x: origin_x,
|
30
|
+
origin_y: origin_y,
|
31
|
+
plot_width: plot_width,
|
32
|
+
plot_height: plot_height)
|
33
|
+
super(**kw)
|
34
|
+
|
35
|
+
min_x_str = (roundable?(min_x) ? min_x.round : min_x).to_s
|
36
|
+
max_x_str = (roundable?(max_x) ? max_x.round : max_x).to_s
|
37
|
+
min_y_str = (roundable?(min_y) ? min_y.round : min_y).to_s
|
38
|
+
max_y_str = (roundable?(max_y) ? max_y.round : max_y).to_s
|
39
|
+
|
40
|
+
annotate_row!(:l, 0, max_y_str, color: :light_black)
|
41
|
+
annotate_row!(:l, height-1, min_y_str, color: :light_black)
|
42
|
+
annotate!(:bl, min_x_str, color: :light_black)
|
43
|
+
annotate!(:br, max_x_str, color: :light_black)
|
44
|
+
|
45
|
+
if grid
|
46
|
+
if min_y < 0 && 0 < max_y
|
47
|
+
step = plot_width.fdiv(width * @canvas.x_pixel_per_char - 1)
|
48
|
+
min_x.step(max_x, by: step) do |i|
|
49
|
+
@canvas.point!(i, 0, :normal)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
if min_x < 0 && 0 < max_x
|
53
|
+
step = plot_height.fdiv(height * @canvas.y_pixel_per_char - 1)
|
54
|
+
min_y.step(max_y, by: step) do |i|
|
55
|
+
@canvas.point!(0, i, :normal)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def origin_x
|
62
|
+
@canvas.origin_x
|
63
|
+
end
|
64
|
+
|
65
|
+
def origin_y
|
66
|
+
@canvas.origin_y
|
67
|
+
end
|
68
|
+
|
69
|
+
def plot_width
|
70
|
+
@canvas.plot_width
|
71
|
+
end
|
72
|
+
|
73
|
+
def plot_height
|
74
|
+
@canvas.plot_height
|
75
|
+
end
|
76
|
+
|
77
|
+
def n_rows
|
78
|
+
@canvas.height
|
79
|
+
end
|
80
|
+
|
81
|
+
def n_columns
|
82
|
+
@canvas.width
|
83
|
+
end
|
84
|
+
|
85
|
+
def points!(x, y, color)
|
86
|
+
@canvas.points!(x, y, color)
|
87
|
+
end
|
88
|
+
|
89
|
+
def lines!(x, y, color)
|
90
|
+
@canvas.lines!(x, y, color)
|
91
|
+
end
|
92
|
+
|
93
|
+
def print_row(out, row_index)
|
94
|
+
@canvas.print_row(out, row_index)
|
95
|
+
end
|
96
|
+
|
97
|
+
def extend_limits(values, limits)
|
98
|
+
mi, ma = limits.minmax.map(&:to_f)
|
99
|
+
if mi == 0 && ma == 0
|
100
|
+
mi, ma = values.minmax.map(&:to_f)
|
101
|
+
end
|
102
|
+
diff = ma - mi
|
103
|
+
if diff == 0
|
104
|
+
ma = mi + 1
|
105
|
+
mi = mi - 1
|
106
|
+
end
|
107
|
+
if limits == [0, 0]
|
108
|
+
plotting_range_narrow(mi, ma)
|
109
|
+
else
|
110
|
+
[mi, ma]
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def plotting_range_narrow(xmin, xmax)
|
115
|
+
diff = xmax - xmin
|
116
|
+
xmax = round_up_subtick(xmax, diff)
|
117
|
+
xmin = round_down_subtick(xmin, diff)
|
118
|
+
[xmin.to_f, xmax.to_f]
|
119
|
+
end
|
120
|
+
|
121
|
+
def round_up_subtick(x, m)
|
122
|
+
if x == 0
|
123
|
+
0.0
|
124
|
+
elsif x > 0
|
125
|
+
x.ceil(ceil_neg_log10(m) + 1)
|
126
|
+
else
|
127
|
+
-(-x).floor(ceil_neg_log10(m) + 1)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def round_down_subtick(x, m)
|
132
|
+
if x == 0
|
133
|
+
0.0
|
134
|
+
elsif x > 0
|
135
|
+
x.floor(ceil_neg_log10(m) + 1)
|
136
|
+
else
|
137
|
+
-(-x).ceil(ceil_neg_log10(m) + 1)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def ceil_neg_log10(x)
|
142
|
+
if roundable?(-Math.log10(x))
|
143
|
+
(-Math.log10(x)).ceil
|
144
|
+
else
|
145
|
+
(-Math.log10(x)).floor
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def roundable?(x)
|
150
|
+
x.to_i == x
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
class Lineplot < GridCanvas
|
155
|
+
def initialize(x, y, canvas,
|
156
|
+
**kw)
|
157
|
+
if x.length != y.length
|
158
|
+
raise ArgumentError, "x and y must be the same length"
|
159
|
+
end
|
160
|
+
unless x.length > 0
|
161
|
+
raise ArgumentError, "x and y must not be empty"
|
162
|
+
end
|
163
|
+
super(x, y, canvas, **kw)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
module_function def lineplot(*args,
|
168
|
+
canvas: :braille,
|
169
|
+
color: :auto,
|
170
|
+
name: "",
|
171
|
+
**kw)
|
172
|
+
case args.length
|
173
|
+
when 1
|
174
|
+
# y only
|
175
|
+
y = Array(args[0])
|
176
|
+
x = Array(1 .. y.length)
|
177
|
+
when 2
|
178
|
+
# x and y
|
179
|
+
x = Array(args[0])
|
180
|
+
y = Array(args[1])
|
181
|
+
else
|
182
|
+
raise ArgumentError, "wrong number of arguments"
|
183
|
+
end
|
184
|
+
|
185
|
+
case x[0]
|
186
|
+
when Time, Date
|
187
|
+
if x[0].is_a? Time
|
188
|
+
d = x.map(&:to_f)
|
189
|
+
else
|
190
|
+
origin = Date.new(1, 1, 1)
|
191
|
+
d = x.map {|xi| xi - origin }
|
192
|
+
end
|
193
|
+
plot = lineplot(d, y, canvas: canvas, color: color, name: name, **kw)
|
194
|
+
xmin, xmax = x.minmax
|
195
|
+
plot.annotate!(:bl, xmin.to_s, color: :light_black)
|
196
|
+
plot.annotate!(:br, xmax.to_s, color: :light_black)
|
197
|
+
plot
|
198
|
+
else
|
199
|
+
plot = Lineplot.new(x, y, canvas, **kw)
|
200
|
+
lineplot!(plot, x, y, color: color, name: name)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
module_function def lineplot!(plot,
|
205
|
+
*args,
|
206
|
+
color: :auto,
|
207
|
+
name: "")
|
208
|
+
case args.length
|
209
|
+
when 1
|
210
|
+
# y only
|
211
|
+
y = Array(args[0])
|
212
|
+
x = Array(1 .. y.length)
|
213
|
+
when 2
|
214
|
+
# x and y
|
215
|
+
x = Array(args[0])
|
216
|
+
y = Array(args[1])
|
217
|
+
|
218
|
+
if x.length == 1 && y.length == 1
|
219
|
+
# intercept and slope
|
220
|
+
intercept = x[0]
|
221
|
+
slope = y[0]
|
222
|
+
xmin = plot.origin_x
|
223
|
+
xmax = plot.origin_x + plot.plot_width
|
224
|
+
ymin = plot.origin_y
|
225
|
+
ymax = plot.origin_y + plot.plot_height
|
226
|
+
x = [xmin, xmax]
|
227
|
+
y = [intercept + xmin*slope, intercept + xmax*slope]
|
228
|
+
end
|
229
|
+
else
|
230
|
+
raise ArgumentError, "wrong number of arguments"
|
231
|
+
end
|
232
|
+
|
233
|
+
case x[0]
|
234
|
+
when Time, Date
|
235
|
+
if x[0].is_a? Time
|
236
|
+
d = x.map(&:to_f)
|
237
|
+
else
|
238
|
+
origin = Date.new(1, 1, 1)
|
239
|
+
d = x.map {|xi| xi - origin }
|
240
|
+
end
|
241
|
+
lineplot!(plot, d, y, color: color, name: name)
|
242
|
+
else
|
243
|
+
color = color == :auto ? plot.next_color : color
|
244
|
+
plot.annotate!(:r, name.to_s, color: color) unless name.nil? || name == ""
|
245
|
+
plot.lines!(x, y, color)
|
246
|
+
end
|
247
|
+
plot
|
248
|
+
end
|
249
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
module UnicodePlot
|
2
|
+
class Plot
|
3
|
+
include StyledPrinter
|
4
|
+
|
5
|
+
DEFAULT_BORDER = :solid
|
6
|
+
DEFAULT_MARGIN = 3
|
7
|
+
DEFAULT_PADDING = 1
|
8
|
+
|
9
|
+
def initialize(title: nil,
|
10
|
+
xlabel: nil,
|
11
|
+
ylabel: nil,
|
12
|
+
border: DEFAULT_BORDER,
|
13
|
+
margin: DEFAULT_MARGIN,
|
14
|
+
padding: DEFAULT_PADDING,
|
15
|
+
labels: true)
|
16
|
+
@title = title
|
17
|
+
@xlabel = xlabel
|
18
|
+
@ylabel = ylabel
|
19
|
+
@border = border
|
20
|
+
@margin = check_margin(margin)
|
21
|
+
@padding = padding
|
22
|
+
@labels_left = {}
|
23
|
+
@colors_left = {}
|
24
|
+
@labels_right = {}
|
25
|
+
@colors_right = {}
|
26
|
+
@decorations = {}
|
27
|
+
@colors_deco = {}
|
28
|
+
@show_labels = labels
|
29
|
+
@auto_color = 0
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_reader :title,
|
33
|
+
:xlabel,
|
34
|
+
:ylabel,
|
35
|
+
:border,
|
36
|
+
:margin,
|
37
|
+
:padding,
|
38
|
+
:labels_left,
|
39
|
+
:colors_left,
|
40
|
+
:labels_right,
|
41
|
+
:colors_right,
|
42
|
+
:decorations,
|
43
|
+
:colors_deco
|
44
|
+
|
45
|
+
def title_given?
|
46
|
+
title && title != ""
|
47
|
+
end
|
48
|
+
|
49
|
+
def xlabel_given?
|
50
|
+
xlabel && xlabel != ""
|
51
|
+
end
|
52
|
+
|
53
|
+
def ylabel_given?
|
54
|
+
ylabel && ylabel != ""
|
55
|
+
end
|
56
|
+
|
57
|
+
def ylabel_length
|
58
|
+
ylabel&.length || 0
|
59
|
+
end
|
60
|
+
|
61
|
+
def show_labels?
|
62
|
+
@show_labels
|
63
|
+
end
|
64
|
+
|
65
|
+
def annotate!(loc, value, color: :normal)
|
66
|
+
case loc
|
67
|
+
when :l
|
68
|
+
(0 ... n_rows).each do |row|
|
69
|
+
if @labels_left.fetch(row, "") == ""
|
70
|
+
@labels_left[row] = value
|
71
|
+
@colors_left[row] = color
|
72
|
+
break
|
73
|
+
end
|
74
|
+
end
|
75
|
+
when :r
|
76
|
+
(0 ... n_rows).each do |row|
|
77
|
+
if @labels_right.fetch(row, "") == ""
|
78
|
+
@labels_right[row] = value
|
79
|
+
@colors_right[row] = color
|
80
|
+
break
|
81
|
+
end
|
82
|
+
end
|
83
|
+
when :t, :b, :tl, :tr, :bl, :br
|
84
|
+
@decorations[loc] = value
|
85
|
+
@colors_deco[loc] = color
|
86
|
+
else
|
87
|
+
raise ArgumentError,
|
88
|
+
"unknown location to annotate (#{loc.inspect} for :t, :b, :l, :r, :tl, :tr, :bl, or :br)"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def annotate_row!(loc, row_index, value, color: :normal)
|
93
|
+
case loc
|
94
|
+
when :l
|
95
|
+
@labels_left[row_index] = value
|
96
|
+
@colors_left[row_index] = color
|
97
|
+
when :r
|
98
|
+
@labels_right[row_index] = value
|
99
|
+
@colors_right[row_index] = color
|
100
|
+
else
|
101
|
+
raise ArgumentError, "unknown location `#{loc}`, try :l or :r instead"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def render(out)
|
106
|
+
Renderer.render(out, self)
|
107
|
+
end
|
108
|
+
|
109
|
+
COLOR_CYCLE = [
|
110
|
+
:green,
|
111
|
+
:blue,
|
112
|
+
:red,
|
113
|
+
:magenta,
|
114
|
+
:yellow,
|
115
|
+
:cyan
|
116
|
+
].freeze
|
117
|
+
|
118
|
+
def next_color
|
119
|
+
COLOR_CYCLE[@auto_color]
|
120
|
+
ensure
|
121
|
+
@auto_color = (@auto_color + 1) % COLOR_CYCLE.length
|
122
|
+
end
|
123
|
+
|
124
|
+
def to_s
|
125
|
+
StringIO.open do |sio|
|
126
|
+
render(sio)
|
127
|
+
sio.close
|
128
|
+
sio.string
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
private def check_margin(margin)
|
133
|
+
if margin < 0
|
134
|
+
raise ArgumentError, "margin must be >= 0"
|
135
|
+
end
|
136
|
+
margin
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|