unicode_plot 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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