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.
@@ -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