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,234 @@
|
|
1
|
+
module UnicodePlot
|
2
|
+
module BorderMaps
|
3
|
+
BORDER_SOLID = {
|
4
|
+
tl: "┌",
|
5
|
+
tr: "┐",
|
6
|
+
bl: "└",
|
7
|
+
br: "┘",
|
8
|
+
t: "─",
|
9
|
+
l: "│",
|
10
|
+
b: "─",
|
11
|
+
r: "│"
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
BORDER_BARPLOT = {
|
15
|
+
tl: "┌",
|
16
|
+
tr: "┐",
|
17
|
+
bl: "└",
|
18
|
+
br: "┘",
|
19
|
+
t: " ",
|
20
|
+
l: "┤",
|
21
|
+
b: " ",
|
22
|
+
r: " ",
|
23
|
+
}.freeze
|
24
|
+
end
|
25
|
+
|
26
|
+
BORDER_MAP = {
|
27
|
+
solid: BorderMaps::BORDER_SOLID,
|
28
|
+
barplot: BorderMaps::BORDER_BARPLOT,
|
29
|
+
}.freeze
|
30
|
+
|
31
|
+
module BorderPrinter
|
32
|
+
include StyledPrinter
|
33
|
+
|
34
|
+
def print_border_top(out, padding, length, border=:solid, color: :light_black)
|
35
|
+
return if border == :none
|
36
|
+
b = BORDER_MAP[border]
|
37
|
+
print_styled(out, padding, b[:tl], b[:t] * length, b[:tr], color: color)
|
38
|
+
end
|
39
|
+
|
40
|
+
def print_border_bottom(out, padding, length, border=:solid, color: :light_black)
|
41
|
+
return if border == :none
|
42
|
+
b = BORDER_MAP[border]
|
43
|
+
print_styled(out, padding, b[:bl], b[:b] * length, b[:br], color: color)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class Renderer
|
48
|
+
include BorderPrinter
|
49
|
+
|
50
|
+
def self.render(out, plot)
|
51
|
+
new(plot).render(out)
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(plot)
|
55
|
+
@plot = plot
|
56
|
+
@out = nil
|
57
|
+
end
|
58
|
+
|
59
|
+
attr_reader :plot
|
60
|
+
attr_reader :out
|
61
|
+
|
62
|
+
def render(out)
|
63
|
+
@out = out
|
64
|
+
init_render
|
65
|
+
|
66
|
+
render_top
|
67
|
+
render_rows
|
68
|
+
render_bottom
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def render_top
|
74
|
+
# plot the title and the top border
|
75
|
+
print_title(@border_padding, plot.title, p_width: @border_length, color: :bold)
|
76
|
+
puts if plot.title_given?
|
77
|
+
|
78
|
+
if plot.show_labels?
|
79
|
+
topleft_str = plot.decorations.fetch(:tl, "")
|
80
|
+
topleft_col = plot.colors_deco.fetch(:tl, :light_black)
|
81
|
+
topmid_str = plot.decorations.fetch(:t, "")
|
82
|
+
topmid_col = plot.colors_deco.fetch(:t, :light_black)
|
83
|
+
topright_str = plot.decorations.fetch(:tr, "")
|
84
|
+
topright_col = plot.colors_deco.fetch(:tr, :light_black)
|
85
|
+
|
86
|
+
if topleft_str != "" || topright_str != "" || topmid_str != ""
|
87
|
+
topleft_len = topleft_str.length
|
88
|
+
topmid_len = topmid_str.length
|
89
|
+
topright_len = topright_str.length
|
90
|
+
print_styled(out, @border_padding, topleft_str, color: topleft_col)
|
91
|
+
cnt = (@border_length / 2.0 - topmid_len / 2.0 - topleft_len).round
|
92
|
+
pad = cnt > 0 ? " " * cnt : ""
|
93
|
+
print_styled(out, pad, topmid_str, color: topmid_col)
|
94
|
+
cnt = @border_length - topright_len - topleft_len - topmid_len + 2 - cnt
|
95
|
+
pad = cnt > 0 ? " " * cnt : ""
|
96
|
+
print_styled(out, pad, topright_str, "\n", color: topright_col)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
print_border_top(out, @border_padding, @border_length, plot.border)
|
101
|
+
print(" " * @max_len_r, @plot_padding, "\n")
|
102
|
+
end
|
103
|
+
|
104
|
+
# render all rows
|
105
|
+
def render_rows
|
106
|
+
(0 ... plot.n_rows).each {|row| render_row(row) }
|
107
|
+
end
|
108
|
+
|
109
|
+
def render_row(row)
|
110
|
+
# Current labels to left and right of the row and their length
|
111
|
+
left_str = plot.labels_left.fetch(row, "")
|
112
|
+
left_col = plot.colors_left.fetch(row, :light_black)
|
113
|
+
right_str = plot.labels_right.fetch(row, "")
|
114
|
+
right_col = plot.colors_right.fetch(row, :light_black)
|
115
|
+
left_len = left_str.length
|
116
|
+
right_len = right_str.length
|
117
|
+
|
118
|
+
unless color?(out)
|
119
|
+
left_str = nocolor_string(left_str)
|
120
|
+
right_str = nocolor_string(right_str)
|
121
|
+
end
|
122
|
+
|
123
|
+
# print left annotations
|
124
|
+
print(" " * plot.margin)
|
125
|
+
if plot.show_labels?
|
126
|
+
if row == @y_lab_row
|
127
|
+
# print ylabel
|
128
|
+
print_styled(out, plot.ylabel, color: :normal)
|
129
|
+
print(" " * (@max_len_l - plot.ylabel_length - left_len))
|
130
|
+
else
|
131
|
+
# print padding to fill ylabel length
|
132
|
+
print(" " * (@max_len_l - left_len))
|
133
|
+
end
|
134
|
+
# print the left annotation
|
135
|
+
print_styled(out, left_str, color: left_col)
|
136
|
+
end
|
137
|
+
|
138
|
+
# print left border
|
139
|
+
print_styled(out, @plot_padding, @b[:l], color: :light_black)
|
140
|
+
|
141
|
+
# print canvas row
|
142
|
+
plot.print_row(out, row)
|
143
|
+
|
144
|
+
#print right label and padding
|
145
|
+
print_styled(out, @b[:r], color: :light_black)
|
146
|
+
if plot.show_labels?
|
147
|
+
print(@plot_padding)
|
148
|
+
print_styled(out, right_str, color: right_col)
|
149
|
+
print(" " * (@max_len_r - right_len))
|
150
|
+
end
|
151
|
+
puts
|
152
|
+
end
|
153
|
+
|
154
|
+
def render_bottom
|
155
|
+
# draw bottom border and bottom labels
|
156
|
+
print_border_bottom(out, @border_padding, @border_length, plot.border)
|
157
|
+
print(" " * @max_len_r, @plot_padding)
|
158
|
+
if plot.show_labels?
|
159
|
+
botleft_str = plot.decorations.fetch(:bl, "")
|
160
|
+
botleft_col = plot.colors_deco.fetch(:bl, :light_black)
|
161
|
+
botmid_str = plot.decorations.fetch(:b, "")
|
162
|
+
botmid_col = plot.colors_deco.fetch(:b, :light_black)
|
163
|
+
botright_str = plot.decorations.fetch(:br, "")
|
164
|
+
botright_col = plot.colors_deco.fetch(:br, :light_black)
|
165
|
+
|
166
|
+
if botleft_str != "" || botright_str != "" || botmid_str != ""
|
167
|
+
puts
|
168
|
+
botleft_len = botleft_str.length
|
169
|
+
botmid_len = botmid_str.length
|
170
|
+
botright_len = botright_str.length
|
171
|
+
print_styled(out, @border_padding, botleft_str, color: botleft_col)
|
172
|
+
cnt = (@border_length / 2.0 - botmid_len / 2.0 - botleft_len).round
|
173
|
+
pad = cnt > 0 ? " " * cnt : ""
|
174
|
+
print_styled(out, pad, botmid_str, color: botmid_col)
|
175
|
+
cnt = @border_length - botright_len - botleft_len - botmid_len + 2 - cnt
|
176
|
+
pad = cnt > 0 ? " " * cnt : ""
|
177
|
+
print_styled(out, pad, botright_str, color: botright_col)
|
178
|
+
end
|
179
|
+
|
180
|
+
# abuse the print_title function to print the xlabel. maybe refactor this
|
181
|
+
puts if plot.xlabel_given?
|
182
|
+
print_title(@border_padding, plot.xlabel, p_width: @border_length)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def init_render
|
187
|
+
@b = BORDER_MAP[plot.border]
|
188
|
+
@border_length = plot.n_columns
|
189
|
+
|
190
|
+
# get length of largest strings to the left and right
|
191
|
+
@max_len_l = plot.show_labels? && !plot.labels_left.empty? ?
|
192
|
+
plot.labels_left.each_value.map {|l| l.to_s.length }.max :
|
193
|
+
0
|
194
|
+
@max_len_r = plot.show_labels? && !plot.labels_right.empty? ?
|
195
|
+
plot.labels_right.each_value.map {|l| l.to_s.length }.max :
|
196
|
+
0
|
197
|
+
if plot.show_labels? && plot.ylabel_given?
|
198
|
+
@max_len_l += plot.ylabel_length + 1
|
199
|
+
end
|
200
|
+
|
201
|
+
# offset where the plot (incl border) begins
|
202
|
+
@plot_offset = @max_len_l + plot.margin + plot.padding
|
203
|
+
|
204
|
+
# padding-string from left to border
|
205
|
+
@plot_padding = " " * plot.padding
|
206
|
+
|
207
|
+
# padding-string between labels and border
|
208
|
+
@border_padding = " " * @plot_offset
|
209
|
+
|
210
|
+
# compute position of ylabel
|
211
|
+
@y_lab_row = (plot.n_rows / 2.0).round
|
212
|
+
end
|
213
|
+
|
214
|
+
def print_title(padding, title, p_width: 0, color: :normal)
|
215
|
+
return unless title && title != ""
|
216
|
+
offset = (p_width / 2.0 - title.length / 2.0).round
|
217
|
+
offset = [offset, 0].max
|
218
|
+
tpad = " " * offset
|
219
|
+
print_styled(out, padding, tpad, title, color: color)
|
220
|
+
end
|
221
|
+
|
222
|
+
def print(*args)
|
223
|
+
out.print(*args)
|
224
|
+
end
|
225
|
+
|
226
|
+
def puts(*args)
|
227
|
+
out.puts(*args)
|
228
|
+
end
|
229
|
+
|
230
|
+
def nocolor_string(str)
|
231
|
+
str.to_s.gsub(/\e\[[0-9]+m/, "")
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module UnicodePlot
|
2
|
+
module StyledPrinter
|
3
|
+
TEXT_COLORS = {
|
4
|
+
black: "\033[30m",
|
5
|
+
red: "\033[31m",
|
6
|
+
green: "\033[32m",
|
7
|
+
yellow: "\033[33m",
|
8
|
+
blue: "\033[34m",
|
9
|
+
magenta: "\033[35m",
|
10
|
+
cyan: "\033[36m",
|
11
|
+
white: "\033[37m",
|
12
|
+
gray: "\033[90m",
|
13
|
+
light_black: "\033[90m",
|
14
|
+
light_red: "\033[91m",
|
15
|
+
light_green: "\033[92m",
|
16
|
+
light_yellow: "\033[93m",
|
17
|
+
light_blue: "\033[94m",
|
18
|
+
light_magenta: "\033[95m",
|
19
|
+
light_cyan: "\033[96m",
|
20
|
+
normal: "\033[0m",
|
21
|
+
default: "\033[39m",
|
22
|
+
bold: "\033[1m",
|
23
|
+
underline: "\033[4m",
|
24
|
+
blink: "\033[5m",
|
25
|
+
reverse: "\033[7m",
|
26
|
+
hidden: "\033[8m",
|
27
|
+
nothing: "",
|
28
|
+
}
|
29
|
+
|
30
|
+
0.upto(255) do |i|
|
31
|
+
TEXT_COLORS[i] = "\033[38;5;#{i}m"
|
32
|
+
end
|
33
|
+
|
34
|
+
TEXT_COLORS.freeze
|
35
|
+
|
36
|
+
DISABLE_TEXT_STYLE = {
|
37
|
+
bold: "\033[22m",
|
38
|
+
underline: "\033[24m",
|
39
|
+
blink: "\033[25m",
|
40
|
+
reverse: "\033[27m",
|
41
|
+
hidden: "\033[28m",
|
42
|
+
normal: "",
|
43
|
+
default: "",
|
44
|
+
nothing: "",
|
45
|
+
}.freeze
|
46
|
+
|
47
|
+
COLOR_ENCODE = {
|
48
|
+
normal: 0b000,
|
49
|
+
blue: 0b001,
|
50
|
+
red: 0b010,
|
51
|
+
magenta: 0b011,
|
52
|
+
green: 0b100,
|
53
|
+
cyan: 0b101,
|
54
|
+
yellow: 0b110,
|
55
|
+
white: 0b111
|
56
|
+
}.freeze
|
57
|
+
|
58
|
+
COLOR_DECODE = COLOR_ENCODE.map {|k, v| [v, k] }.to_h.freeze
|
59
|
+
|
60
|
+
def print_styled(out, *args, bold: false, color: :normal)
|
61
|
+
return out.print(*args) unless color?(out)
|
62
|
+
|
63
|
+
str = StringIO.open {|sio| sio.print(*args); sio.close; sio.string }
|
64
|
+
color = :nothing if bold && color == :bold
|
65
|
+
enable_ansi = TEXT_COLORS.fetch(color, TEXT_COLORS[:default]) +
|
66
|
+
(bold ? TEXT_COLORS[:bold] : "")
|
67
|
+
disable_ansi = (bold ? DISABLE_TEXT_STYLE[:bold] : "") +
|
68
|
+
DISABLE_TEXT_STYLE.fetch(color, TEXT_COLORS[:default])
|
69
|
+
first = true
|
70
|
+
StringIO.open do |sio|
|
71
|
+
str.each_line do |line|
|
72
|
+
sio.puts unless first
|
73
|
+
first = false
|
74
|
+
continue if line.empty?
|
75
|
+
sio.print(enable_ansi, line, disable_ansi)
|
76
|
+
end
|
77
|
+
sio.close
|
78
|
+
out.print(sio.string)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def print_color(out, color, *args)
|
83
|
+
color = COLOR_DECODE[color]
|
84
|
+
print_styled(out, *args, color: color)
|
85
|
+
end
|
86
|
+
|
87
|
+
def color?(out)
|
88
|
+
out&.tty? || false
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module UnicodePlot
|
2
|
+
module ValueTransformer
|
3
|
+
PREDEFINED_TRANSFORM_FUNCTIONS = {
|
4
|
+
log: Math.method(:log),
|
5
|
+
ln: Math.method(:log),
|
6
|
+
log10: Math.method(:log10),
|
7
|
+
lg: Math.method(:log10),
|
8
|
+
log2: Math.method(:log2),
|
9
|
+
lb: Math.method(:log2),
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
def transform_values(func, values)
|
13
|
+
return values unless func
|
14
|
+
|
15
|
+
unless func.respond_to?(:call)
|
16
|
+
func = PREDEFINED_TRANSFORM_FUNCTIONS[func]
|
17
|
+
unless func.respond_to?(:call)
|
18
|
+
raise ArgumentError, "func must be callable"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
case values
|
23
|
+
when Numeric
|
24
|
+
func.(values)
|
25
|
+
else
|
26
|
+
values.map(&func)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
module_function def transform_name(func, basename="")
|
31
|
+
return basename unless func
|
32
|
+
case func
|
33
|
+
when String, Symbol
|
34
|
+
name = func
|
35
|
+
when ->(f) { f.respond_to?(:name) }
|
36
|
+
name = func.name
|
37
|
+
else
|
38
|
+
name = "custom"
|
39
|
+
end
|
40
|
+
"#{basename} [#{name}]"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
|
3
|
+
module Helper
|
4
|
+
module Fixture
|
5
|
+
def fixture_dir
|
6
|
+
test_dir = Pathname(File.expand_path("../..", __FILE__))
|
7
|
+
test_dir.join("fixtures")
|
8
|
+
end
|
9
|
+
|
10
|
+
def fixture_path(*components)
|
11
|
+
fixture_dir.join(*components)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'stringio'
|
2
|
+
|
3
|
+
module Helper
|
4
|
+
module WithTerm
|
5
|
+
def with_term(tty=true)
|
6
|
+
sio = StringIO.new
|
7
|
+
def sio.tty?; true; end if tty
|
8
|
+
|
9
|
+
orig_stdout, $stdout = $stdout, sio
|
10
|
+
orig_env = ENV.to_h.dup
|
11
|
+
ENV['TERM'] = 'xterm-256color'
|
12
|
+
|
13
|
+
result = yield
|
14
|
+
sio.close
|
15
|
+
|
16
|
+
[result, sio.string]
|
17
|
+
ensure
|
18
|
+
$stdout.close
|
19
|
+
$stdout = orig_stdout
|
20
|
+
ENV.replace(orig_env) if orig_env
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/test/run-test.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
base_dir = File.expand_path("../..", __FILE__)
|
2
|
+
lib_dir = File.join(base_dir, "lib")
|
3
|
+
test_dir = File.join(base_dir, "test")
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift(lib_dir)
|
6
|
+
|
7
|
+
require "test/unit"
|
8
|
+
require "unicode_plot"
|
9
|
+
|
10
|
+
require_relative "helper"
|
11
|
+
|
12
|
+
exit Test::Unit::AutoRunner.run(true, test_dir)
|
@@ -0,0 +1,59 @@
|
|
1
|
+
class BarplotTest < Test::Unit::TestCase
|
2
|
+
include Helper::Fixture
|
3
|
+
include Helper::WithTerm
|
4
|
+
|
5
|
+
sub_test_case("UnicodePlot.barplot") do
|
6
|
+
sub_test_case("print to tty") do
|
7
|
+
test("the output is colored") do
|
8
|
+
data = { bar: 23, foo: 37 }
|
9
|
+
plot = UnicodePlot.barplot(data: data)
|
10
|
+
_, output = with_term do
|
11
|
+
plot.render($stdout)
|
12
|
+
end
|
13
|
+
assert_equal(fixture_path("barplot/default.txt").read,
|
14
|
+
output)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
sub_test_case("print to non-tty IO") do
|
19
|
+
test("the output is not colored") do
|
20
|
+
data = { bar: 23, foo: 37 }
|
21
|
+
plot = UnicodePlot.barplot(data: data)
|
22
|
+
output = StringIO.open do |sio|
|
23
|
+
sio.print(plot)
|
24
|
+
sio.close
|
25
|
+
sio.string
|
26
|
+
end
|
27
|
+
assert_equal(fixture_path("barplot/nocolor.txt").read,
|
28
|
+
output)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
sub_test_case("xscale: :log10") do
|
33
|
+
test("default") do
|
34
|
+
plot = UnicodePlot.barplot(
|
35
|
+
[:a, :b, :c, :d, :e],
|
36
|
+
[0, 1, 10, 100, 1000],
|
37
|
+
title: "Logscale Plot",
|
38
|
+
xscale: :log10
|
39
|
+
)
|
40
|
+
_, output = with_term { plot.render($stdout) }
|
41
|
+
assert_equal(fixture_path("barplot/log10.txt").read,
|
42
|
+
output)
|
43
|
+
end
|
44
|
+
|
45
|
+
test("with custom label") do
|
46
|
+
plot = UnicodePlot.barplot(
|
47
|
+
[:a, :b, :c, :d, :e],
|
48
|
+
[0, 1, 10, 100, 1000],
|
49
|
+
title: "Logscale Plot",
|
50
|
+
xlabel: "custom label",
|
51
|
+
xscale: :log10
|
52
|
+
)
|
53
|
+
_, output = with_term { plot.render($stdout) }
|
54
|
+
assert_equal(fixture_path("barplot/log10_label.txt").read,
|
55
|
+
output)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|