cmd_plot 0.0.1 → 0.1.0
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 +5 -5
- data/CHANGELOG.md +26 -0
- data/LICENSE +340 -0
- data/README.md +179 -0
- data/lib/cmd_plot/bar_chart.rb +43 -0
- data/lib/cmd_plot/braille.rb +56 -0
- data/lib/cmd_plot/color_map.rb +61 -0
- data/lib/cmd_plot/data.rb +38 -0
- data/lib/cmd_plot/legacy.rb +41 -0
- data/lib/cmd_plot/line_plot.rb +156 -0
- data/lib/cmd_plot/tables.rb +88 -0
- data/lib/cmd_plot/version.rb +5 -0
- data/lib/cmd_plot.rb +53 -5
- metadata +26 -18
- data/lib/cmd_plot/bar.rb +0 -30
- data/lib/cmd_plot/basic_data.rb +0 -33
- data/lib/cmd_plot/pcolor.rb +0 -73
- data/lib/cmd_plot/plot.rb +0 -116
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CmdPlot
|
|
4
|
+
# 2D color maps of a matrix, e.g. to eyeball a function of two variables.
|
|
5
|
+
module ColorMap
|
|
6
|
+
MAX_SHADES = TEXT_COLORS.length * FILL_SYMBOLS.length
|
|
7
|
+
|
|
8
|
+
# data - array of rows, each an array of numbers
|
|
9
|
+
# shades - number of distinct shades (default: one per distinct value, at most 28)
|
|
10
|
+
#
|
|
11
|
+
# The matrix is averaged down (or stretched up) to the plot size. Each shade is a
|
|
12
|
+
# combination of a color and a fill symbol.
|
|
13
|
+
def pcolor(data, shades = nil)
|
|
14
|
+
raise ArgumentError, 'nothing to plot: no rows' if data.empty? || data.first.empty?
|
|
15
|
+
|
|
16
|
+
shades = data.flatten.uniq.length if shades.nil?
|
|
17
|
+
shades = MAX_SHADES if shades > MAX_SHADES || shades <= 0
|
|
18
|
+
x_scale = data.first.length / @width.to_f
|
|
19
|
+
y_scale = data.length / @height.to_f
|
|
20
|
+
z_min, z_max = data.flatten.minmax
|
|
21
|
+
buf = +"Pcolor with x_scale: #{x_scale.round(3)}, y_scale: #{y_scale.round(3)}, " \
|
|
22
|
+
"z_min: #{z_min.round(3)}, z_max: #{z_max.round(3)}\n"
|
|
23
|
+
|
|
24
|
+
@height.times { |row|
|
|
25
|
+
line = Array.new(@width) { |col|
|
|
26
|
+
val = cell_average(data, row, col, x_scale, y_scale)
|
|
27
|
+
next ' ' if val.nil?
|
|
28
|
+
|
|
29
|
+
shade = z_max == z_min ? 0 : ((val - z_min) / (z_max - z_min).to_f * (shades - 1)).round
|
|
30
|
+
colorize(FILL_SYMBOLS[shade / TEXT_COLORS.length], "3#{TEXT_COLORS[shade % TEXT_COLORS.length]}")
|
|
31
|
+
}
|
|
32
|
+
buf << line.join << "\n"
|
|
33
|
+
}
|
|
34
|
+
out.print(buf)
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Average of the data points that fall into one output cell, or nil if none do.
|
|
41
|
+
def cell_average(data, row, col, x_scale, y_scale)
|
|
42
|
+
first_row = (row * y_scale).floor
|
|
43
|
+
first_col = (col * x_scale).floor
|
|
44
|
+
sum = 0.0
|
|
45
|
+
n = 0
|
|
46
|
+
(first_row..[first_row, ((row + 1) * y_scale).floor - 1].max).each { |r|
|
|
47
|
+
next if r >= data.length
|
|
48
|
+
|
|
49
|
+
(first_col..[first_col, ((col + 1) * x_scale).floor - 1].max).each { |c|
|
|
50
|
+
next if c >= data[r].length
|
|
51
|
+
|
|
52
|
+
sum += data[r][c]
|
|
53
|
+
n += 1
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
n.zero? ? nil : sum / n
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
include ColorMap
|
|
61
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CmdPlot
|
|
4
|
+
# Numeric helpers used by the plots. Kept inside the CmdPlot namespace so the gem
|
|
5
|
+
# doesn't define top-level constants in the host application.
|
|
6
|
+
module Data
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# n evenly spaced floats from min to max, both ends included.
|
|
10
|
+
def linspace(min, max, n)
|
|
11
|
+
return [min.to_f] if n == 1
|
|
12
|
+
|
|
13
|
+
Array.new(n) { |i| min.to_f + (i.to_f * (max.to_f - min.to_f) / (n - 1).to_f) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Counts values into bins.
|
|
17
|
+
#
|
|
18
|
+
# bins - number of equal-width bins between min and max, or an array of bin limits
|
|
19
|
+
# maxcap - optional cap applied to every count
|
|
20
|
+
#
|
|
21
|
+
# Values below the first limit land in the first bin, values at or above the last
|
|
22
|
+
# limit in the last bin.
|
|
23
|
+
#
|
|
24
|
+
# return - [counts, bin_centers]
|
|
25
|
+
def histogram(values, bins, maxcap = nil)
|
|
26
|
+
limits = bins.is_a?(Array) ? bins : linspace(values.min, values.max, bins + 1)
|
|
27
|
+
counts = Array.new(limits.length - 1, 0)
|
|
28
|
+
values.each { |v|
|
|
29
|
+
bin = limits.index { |limit| v < limit } || counts.length
|
|
30
|
+
bin = 1 if bin.zero?
|
|
31
|
+
counts[bin - 1] += 1
|
|
32
|
+
}
|
|
33
|
+
counts = counts.map { |c| [c, maxcap].min } unless maxcap.nil?
|
|
34
|
+
centers = limits.each_cons(2).map { |a, b| (a + b) / 2.0 }
|
|
35
|
+
[counts, centers]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CmdPlot
|
|
4
|
+
# The 0.0.1 classes, kept so code written against them still runs. They delegate to
|
|
5
|
+
# CmdPlot; new code should use CmdPlot directly.
|
|
6
|
+
class Plot
|
|
7
|
+
def initialize(width = 140, height = 40, _symbol = nil)
|
|
8
|
+
@plotter = CmdPlot.new(width, height)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def plot(x, y = nil)
|
|
12
|
+
@plotter.plot(x, y)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Bar
|
|
17
|
+
def initialize(width = 140, height = 40, symbol = '°')
|
|
18
|
+
@plotter = CmdPlot.new(width, height)
|
|
19
|
+
@symbol = symbol
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def bar(values, labels)
|
|
23
|
+
@plotter.bar(values, labels, @symbol)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def histogram(values, bins = 20)
|
|
27
|
+
counts, centers = Data.histogram(values, bins)
|
|
28
|
+
bar(counts, centers.map { |c| c.round(1).to_s })
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Pcolor
|
|
33
|
+
def initialize(width = 140, height = 40)
|
|
34
|
+
@plotter = CmdPlot.new(width, height)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pcolor(data, shades = nil)
|
|
38
|
+
@plotter.pcolor(data, shades)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CmdPlot
|
|
4
|
+
# Line plots drawn with braille dots, one color per series.
|
|
5
|
+
module LinePlot
|
|
6
|
+
# Accepted input:
|
|
7
|
+
# plot([y0, y1, ...]) y values, x is the index
|
|
8
|
+
# plot([x0, x1, ...], [y0, y1, ...]) separate x and y arrays
|
|
9
|
+
# plot({ x0 => y0, x1 => y1 }) one series as a hash
|
|
10
|
+
# plot([{ x0 => y0 }, { x0 => y0 }]) several series, one color each
|
|
11
|
+
#
|
|
12
|
+
# x values can be Dates. nil y values leave a gap in the line.
|
|
13
|
+
#
|
|
14
|
+
# intense: - use the bright color variants
|
|
15
|
+
# bold: - draw the dots in bold
|
|
16
|
+
def plot(data, data2 = nil, intense: true, bold: true)
|
|
17
|
+
raise ArgumentError, 'a line plot needs at least 2x2 characters' if @width < 2 || @height < 2
|
|
18
|
+
|
|
19
|
+
series = plot_series(data, data2)
|
|
20
|
+
start_date = date_start(series)
|
|
21
|
+
series = series.map { |s| s.transform_keys { |d| (d - start_date).to_f } } unless start_date.nil?
|
|
22
|
+
|
|
23
|
+
ys = series.flat_map(&:values).compact
|
|
24
|
+
raise ArgumentError, 'nothing to plot: every y value is nil' if ys.empty?
|
|
25
|
+
|
|
26
|
+
x_min, x_max = spread(*series.flat_map(&:keys).compact.minmax)
|
|
27
|
+
y_min, y_max = spread(*ys.minmax)
|
|
28
|
+
|
|
29
|
+
cells = Array.new(@height) { Array.new(@width, ' ') }
|
|
30
|
+
series.each_with_index { |s, i|
|
|
31
|
+
code = "#{bold ? 1 : 0};#{intense ? 9 : 3}#{TEXT_COLORS[i % TEXT_COLORS.length]}"
|
|
32
|
+
draw_series(cells, dot_points(s, x_min, x_max, y_min, y_max), code)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
x_ticks = Data.linspace(x_min, x_max, [2, @width / 15].max).map { |v|
|
|
36
|
+
start_date.nil? ? format('%.2f', v) : (start_date + v.round).to_s
|
|
37
|
+
}
|
|
38
|
+
y_ticks = Data.linspace(y_max, y_min, [2, @height / 10].max).map { |v| format('%.2f', v) }
|
|
39
|
+
|
|
40
|
+
buf = +''
|
|
41
|
+
plot_area(buf, cells.map(&:join), y_ticks)
|
|
42
|
+
x_axis(buf, x_ticks, y_ticks.map(&:length).max)
|
|
43
|
+
out.print(buf)
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def plot_series(data, data2)
|
|
50
|
+
return [data.zip(data2).to_h] unless data2.nil?
|
|
51
|
+
return [data] if data.is_a?(Hash)
|
|
52
|
+
raise ArgumentError, 'expected an array or a hash to plot' unless data.is_a?(Array)
|
|
53
|
+
return [data.each_with_index.to_h { |y, i| [i, y] }] if data.all? { |d| d.nil? || d.is_a?(Numeric) }
|
|
54
|
+
return data if data.all?(Hash)
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, 'expected numbers, a hash, or an array of hashes to plot'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def date_start(series)
|
|
60
|
+
keys = series.flat_map(&:keys).compact
|
|
61
|
+
return nil unless keys.any?(Date)
|
|
62
|
+
raise ArgumentError, 'cannot mix Date and numeric x values' unless keys.all?(Date)
|
|
63
|
+
|
|
64
|
+
keys.min
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Widens a zero-width range so constant data still scales.
|
|
68
|
+
def spread(min, max)
|
|
69
|
+
return [min, max] unless min == max
|
|
70
|
+
|
|
71
|
+
delta = min.zero? ? 1.0 : min.abs * 0.05
|
|
72
|
+
[min - delta, max + delta]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Scales a series onto the braille dot grid and connects consecutive points.
|
|
76
|
+
def dot_points(series, x_min, x_max, y_min, y_max)
|
|
77
|
+
cols = @width * 2
|
|
78
|
+
rows = @height * 4
|
|
79
|
+
xs = series.keys.map { |v| v.nil? ? nil : ((v - x_min) / (x_max - x_min).to_f * (cols - 1)).round }
|
|
80
|
+
ys = series.values.map { |v| v.nil? ? nil : ((v - y_min) / (y_max - y_min).to_f * (rows - 1)).round }
|
|
81
|
+
pairs = xs.zip(ys)
|
|
82
|
+
points = if pairs.length == 1
|
|
83
|
+
pairs.reject { |pair| pair[1].nil? }
|
|
84
|
+
else
|
|
85
|
+
pairs.each_cons(2).flat_map { |(x0, y0), (x1, y1)| Braille.line_points(x0, y0, x1, y1) }
|
|
86
|
+
end
|
|
87
|
+
points.map { |x, y| [x.clamp(0, cols - 1), y.clamp(0, rows - 1)] }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Later series overwrite earlier ones where they share a cell.
|
|
91
|
+
def draw_series(cells, points, code)
|
|
92
|
+
masks = Array.new(@height) { Array.new(@width, 0) }
|
|
93
|
+
points.each { |x, y|
|
|
94
|
+
masks[@height - 1 - (y / 4)][x / 2] |= Braille::DOT_BITS[3 - (y % 4)][x % 2]
|
|
95
|
+
}
|
|
96
|
+
masks.each_with_index { |row, r|
|
|
97
|
+
row.each_with_index { |mask, c|
|
|
98
|
+
next if mask.zero?
|
|
99
|
+
|
|
100
|
+
cells[r][c] = colorize((Braille::BASE + mask).chr(Encoding::UTF_8), code)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Spreads the y ticks as evenly as the line count allows.
|
|
106
|
+
def plot_area(buf, lines, y_ticks)
|
|
107
|
+
tick_width = y_ticks.map(&:length).max
|
|
108
|
+
spacing = (@height - y_ticks.length) / (y_ticks.length - 1)
|
|
109
|
+
missing = @height - ((spacing * (y_ticks.length - 1)) + y_ticks.length)
|
|
110
|
+
missing_dist = missing.zero? ? @height : y_ticks.length / missing.to_f
|
|
111
|
+
next_tick = 0
|
|
112
|
+
tick_offset = 0
|
|
113
|
+
lines.each_with_index { |line, row|
|
|
114
|
+
if next_tick < y_ticks.length && row == tick_offset + (next_tick * (spacing + 1))
|
|
115
|
+
buf << y_ticks[next_tick].rjust(tick_width) << ' ├ '
|
|
116
|
+
next_tick += 1
|
|
117
|
+
tick_offset += 1 if ((tick_offset + 1) * missing_dist) < (next_tick + 2)
|
|
118
|
+
else
|
|
119
|
+
buf << (' ' * tick_width) << ' │ '
|
|
120
|
+
end
|
|
121
|
+
buf << line << "\n"
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def x_axis(buf, x_ticks, indent)
|
|
126
|
+
buf << (' ' * indent) << ' └─'
|
|
127
|
+
spread_between(buf, Array.new(x_ticks.length, '┴'), '─')
|
|
128
|
+
buf << (' ' * indent) << ' '
|
|
129
|
+
spread_between(buf, x_ticks, ' ')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Writes the items across the plot width, padding the gaps with fill. When the
|
|
133
|
+
# width doesn't divide evenly the leftover goes into the gaps one by one.
|
|
134
|
+
def spread_between(buf, items, fill)
|
|
135
|
+
total = items.sum(&:length)
|
|
136
|
+
gap = [(@width - total) / (items.length - 1), 1].max
|
|
137
|
+
missing = @width - ((gap * (items.length - 1)) + total)
|
|
138
|
+
missing_dist = missing.zero? ? @width : items.length / missing.to_f
|
|
139
|
+
added = 0
|
|
140
|
+
items.each_with_index { |item, i|
|
|
141
|
+
buf << item
|
|
142
|
+
if i == items.length - 1
|
|
143
|
+
buf << "\n"
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
buf << (fill * gap)
|
|
147
|
+
if ((added + 1) * missing_dist) < (i + 3)
|
|
148
|
+
added += 1
|
|
149
|
+
buf << fill
|
|
150
|
+
end
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
include LinePlot
|
|
156
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CmdPlot
|
|
4
|
+
# Tabular dumps of records.
|
|
5
|
+
#
|
|
6
|
+
# data is an array of hashes, or of objects that respond to #attributes (e.g. an
|
|
7
|
+
# ActiveRecord relation). Objects are turned into hashes with string keys and string
|
|
8
|
+
# values, so pass field names as strings for them.
|
|
9
|
+
module Tables
|
|
10
|
+
# Columns separated by │, each as wide as its longest value.
|
|
11
|
+
#
|
|
12
|
+
# fields - keys to show, in order (default: the keys of the first record)
|
|
13
|
+
# shorten - cut every column to at most this many characters
|
|
14
|
+
def table(data, fields = nil, shorten = nil)
|
|
15
|
+
rows = records(data)
|
|
16
|
+
fields ||= rows.first&.keys
|
|
17
|
+
return nil if fields.nil?
|
|
18
|
+
|
|
19
|
+
widths = fields.map { |f|
|
|
20
|
+
width = ([f.to_s.length] + rows.map { |r| r[f].to_s.length }).max
|
|
21
|
+
shorten.nil? ? width : [width, shorten].min
|
|
22
|
+
}
|
|
23
|
+
cut = ->(value) { shorten.nil? ? value.to_s : value.to_s[0, shorten] }
|
|
24
|
+
line = ->(values) { " #{values.zip(widths).map { |v, w| cut.call(v).rjust(w) }.join(' │ ')}" }
|
|
25
|
+
|
|
26
|
+
buf = +''
|
|
27
|
+
buf << line.call(fields) << "\n"
|
|
28
|
+
buf << widths.map { |w| '─' * (w + 2) }.join('┼') << "\n"
|
|
29
|
+
rows.each { |r| buf << line.call(fields.map { |f| r[f] }) << "\n" }
|
|
30
|
+
out.print(buf)
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# One block per record with a line per field, like psql's \x mode. Useful when
|
|
35
|
+
# records have too many fields for a table.
|
|
36
|
+
def expanded_table(data, fields = nil)
|
|
37
|
+
rows = records(data)
|
|
38
|
+
fields ||= rows.first&.keys
|
|
39
|
+
return nil if fields.nil?
|
|
40
|
+
|
|
41
|
+
num_width = rows.length.to_s.length
|
|
42
|
+
name_width = [13 + num_width, fields.map { |f| f.to_s.length }.max + 1].max
|
|
43
|
+
buf = +''
|
|
44
|
+
rows.each_with_index { |r, i|
|
|
45
|
+
buf << "─[ RECORD #{(i + 1).to_s.rjust(num_width)} ]─" << ('─' * (name_width - 13 - num_width))
|
|
46
|
+
buf << '┬' << ('─' * [70 - name_width, 0].max) << "\n"
|
|
47
|
+
fields.each { |f| buf << f.to_s.ljust(name_width) << '│ ' << r[f].to_s << "\n" }
|
|
48
|
+
}
|
|
49
|
+
out.print(buf)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Comma-separated values with a header line. Values that contain a comma, quote or
|
|
54
|
+
# line break are quoted.
|
|
55
|
+
def csv(data, fields = nil)
|
|
56
|
+
rows = records(data)
|
|
57
|
+
fields ||= rows.first&.keys
|
|
58
|
+
return nil if fields.nil?
|
|
59
|
+
|
|
60
|
+
buf = +''
|
|
61
|
+
buf << fields.map { |f| csv_value(f) }.join(', ') << "\n"
|
|
62
|
+
rows.each { |r| buf << fields.map { |f| csv_value(r[f]) }.join(', ') << "\n" }
|
|
63
|
+
out.print(buf)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def records(data)
|
|
70
|
+
data.map { |row|
|
|
71
|
+
if row.respond_to?(:attributes)
|
|
72
|
+
row.attributes.to_h { |k, v| [k.to_s, v.to_s] }
|
|
73
|
+
else
|
|
74
|
+
row
|
|
75
|
+
end
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def csv_value(value)
|
|
80
|
+
text = value.to_s
|
|
81
|
+
return text unless text.match?(/[",\r\n]/)
|
|
82
|
+
|
|
83
|
+
"\"#{text.gsub('"', '""')}\""
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
include Tables
|
|
88
|
+
end
|
data/lib/cmd_plot.rb
CHANGED
|
@@ -1,9 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
# Quick text-mode plots, charts and tables for the console.
|
|
6
|
+
#
|
|
7
|
+
# plotter = CmdPlot.new(100, 25)
|
|
8
|
+
# plotter.plot([1, 4, 9, 16, 25])
|
|
9
|
+
# plotter.bar([3, 5, 2], %w[a b c])
|
|
10
|
+
# plotter.table([{ 'id' => 1, 'name' => 'x' }])
|
|
11
|
+
#
|
|
12
|
+
# Every method writes to the output stream and returns nil, so it reads cleanly in an
|
|
13
|
+
# interactive console.
|
|
1
14
|
class CmdPlot
|
|
15
|
+
# ANSI color numbers: red, green, brown, blue, magenta, cyan, gray
|
|
16
|
+
TEXT_COLORS = [1, 2, 3, 4, 5, 6, 7].freeze
|
|
17
|
+
FILL_SYMBOLS = ['█', '▓', '░', '▒'].freeze
|
|
2
18
|
|
|
3
|
-
|
|
19
|
+
attr_reader :width, :height
|
|
20
|
+
|
|
21
|
+
# width - width of the drawing area in characters
|
|
22
|
+
# height - height of the drawing area in lines
|
|
23
|
+
# out: - IO to write to; defaults to whatever $stdout is at the time of the call
|
|
24
|
+
# color: - false drops the ANSI escape codes, e.g. when writing to a file
|
|
25
|
+
def initialize(width = 140, height = 40, out: nil, color: true)
|
|
26
|
+
raise ArgumentError, 'width must be at least 1' if width.to_i < 1
|
|
27
|
+
raise ArgumentError, 'height must be at least 1' if height.to_i < 1
|
|
28
|
+
|
|
29
|
+
@width = width.to_i
|
|
30
|
+
@height = height.to_i
|
|
31
|
+
@out = out
|
|
32
|
+
@color = color
|
|
33
|
+
end
|
|
4
34
|
|
|
5
|
-
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def out
|
|
38
|
+
@out || $stdout
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def color?
|
|
42
|
+
@color
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def colorize(text, code)
|
|
46
|
+
color? ? "\e[#{code}m#{text}\e[0m" : text
|
|
47
|
+
end
|
|
48
|
+
end
|
|
6
49
|
|
|
7
|
-
require 'cmd_plot/
|
|
8
|
-
require 'cmd_plot/
|
|
9
|
-
require 'cmd_plot/
|
|
50
|
+
require 'cmd_plot/version'
|
|
51
|
+
require 'cmd_plot/data'
|
|
52
|
+
require 'cmd_plot/braille'
|
|
53
|
+
require 'cmd_plot/line_plot'
|
|
54
|
+
require 'cmd_plot/bar_chart'
|
|
55
|
+
require 'cmd_plot/color_map'
|
|
56
|
+
require 'cmd_plot/tables'
|
|
57
|
+
require 'cmd_plot/legacy'
|
metadata
CHANGED
|
@@ -1,33 +1,43 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cmd_plot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andreas Buschermoehle
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies: []
|
|
13
|
-
description:
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
description: 'Quick visualization of data on the command line: braille line plots
|
|
13
|
+
with several series and date axes, bar charts, histograms, 2D color maps, and table/CSV
|
|
14
|
+
dumps of hashes or ActiveRecord-style records. No dependencies.'
|
|
16
15
|
email: andreas@buschermoehle.org
|
|
17
16
|
executables: []
|
|
18
17
|
extensions: []
|
|
19
18
|
extra_rdoc_files: []
|
|
20
19
|
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
21
23
|
- lib/cmd_plot.rb
|
|
22
|
-
- lib/cmd_plot/
|
|
23
|
-
- lib/cmd_plot/
|
|
24
|
-
- lib/cmd_plot/
|
|
25
|
-
- lib/cmd_plot/
|
|
26
|
-
|
|
24
|
+
- lib/cmd_plot/bar_chart.rb
|
|
25
|
+
- lib/cmd_plot/braille.rb
|
|
26
|
+
- lib/cmd_plot/color_map.rb
|
|
27
|
+
- lib/cmd_plot/data.rb
|
|
28
|
+
- lib/cmd_plot/legacy.rb
|
|
29
|
+
- lib/cmd_plot/line_plot.rb
|
|
30
|
+
- lib/cmd_plot/tables.rb
|
|
31
|
+
- lib/cmd_plot/version.rb
|
|
32
|
+
homepage: https://github.com/MrBusch/cmdplot
|
|
27
33
|
licenses:
|
|
28
34
|
- GPL-2.0
|
|
29
|
-
metadata:
|
|
30
|
-
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://github.com/MrBusch/cmdplot
|
|
37
|
+
source_code_uri: https://github.com/MrBusch/cmdplot
|
|
38
|
+
changelog_uri: https://github.com/MrBusch/cmdplot/blob/main/CHANGELOG.md
|
|
39
|
+
bug_tracker_uri: https://github.com/MrBusch/cmdplot/issues
|
|
40
|
+
rubygems_mfa_required: 'true'
|
|
31
41
|
rdoc_options: []
|
|
32
42
|
require_paths:
|
|
33
43
|
- lib
|
|
@@ -35,16 +45,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
35
45
|
requirements:
|
|
36
46
|
- - ">="
|
|
37
47
|
- !ruby/object:Gem::Version
|
|
38
|
-
version: '
|
|
48
|
+
version: '2.7'
|
|
39
49
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
50
|
requirements:
|
|
41
51
|
- - ">="
|
|
42
52
|
- !ruby/object:Gem::Version
|
|
43
53
|
version: '0'
|
|
44
54
|
requirements: []
|
|
45
|
-
|
|
46
|
-
rubygems_version: 2.6.12
|
|
47
|
-
signing_key:
|
|
55
|
+
rubygems_version: 3.6.9
|
|
48
56
|
specification_version: 4
|
|
49
|
-
summary:
|
|
57
|
+
summary: Line plots, bar charts, histograms, color maps and tables in the terminal.
|
|
50
58
|
test_files: []
|
data/lib/cmd_plot/bar.rb
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
class CmdPlot::Bar
|
|
2
|
-
def initialize(width = 140, height = 40, symbol = '°')
|
|
3
|
-
@width = width
|
|
4
|
-
@height = height
|
|
5
|
-
@symbol = symbol
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
def bar(height, labels)
|
|
9
|
-
# Calculate scaling of data to plot
|
|
10
|
-
max_height = height.max.to_f
|
|
11
|
-
max_label_length = labels.map { |l| l.length }.max
|
|
12
|
-
max_val_length = height.map { |l| l.to_s.length }.max
|
|
13
|
-
max_bar_length = (@width - max_label_length - max_val_length - 3)
|
|
14
|
-
# Plot each bar
|
|
15
|
-
for data in height.zip(labels)
|
|
16
|
-
label_fill_space = ' ' * (max_label_length - data[1].length)
|
|
17
|
-
value_fill_space = ' ' * (max_val_length - data[0].to_s.length)
|
|
18
|
-
bar_plot = @symbol * ((data[0] / max_height) * max_bar_length).round
|
|
19
|
-
puts "#{label_fill_space}#{data[1]}: #{value_fill_space}#{data[0]} #{bar_plot}"
|
|
20
|
-
end
|
|
21
|
-
return nil
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def histogram(data, bins = 20)
|
|
25
|
-
# Turn data into bins with counts
|
|
26
|
-
counts, bin_centers = BasicData.build_hist(data, bins)
|
|
27
|
-
# Plot as bar chart
|
|
28
|
-
bar(counts, bin_centers.map { |p| p.round(1).to_s })
|
|
29
|
-
end
|
|
30
|
-
end
|
data/lib/cmd_plot/basic_data.rb
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
class BasicData
|
|
2
|
-
|
|
3
|
-
def self.linspace(min, max, n)
|
|
4
|
-
return Array.new(n) { |i| min.to_f + i.to_f * (max.to_f - min.to_f) / (n - 1).to_f }
|
|
5
|
-
end
|
|
6
|
-
|
|
7
|
-
def self.build_hist(data, bin_spec, maxcap = nil)
|
|
8
|
-
min = data.min
|
|
9
|
-
max = data.max
|
|
10
|
-
if bin_spec.kind_of?(Array)
|
|
11
|
-
limits = bin_spec
|
|
12
|
-
nr_bins = limits.length - 1
|
|
13
|
-
else
|
|
14
|
-
limits = BasicData.linspace(min, max, bin_spec + 1)
|
|
15
|
-
nr_bins = bin_spec
|
|
16
|
-
end
|
|
17
|
-
counts = Array.new(nr_bins, 0)
|
|
18
|
-
for d in data
|
|
19
|
-
bin = limits.map { |x| d < x }.index(true)
|
|
20
|
-
if bin.nil?
|
|
21
|
-
bin = counts.length
|
|
22
|
-
end
|
|
23
|
-
if bin == 0
|
|
24
|
-
bin = 1
|
|
25
|
-
end
|
|
26
|
-
counts[bin - 1] += 1
|
|
27
|
-
end
|
|
28
|
-
counts = counts.map { |v| [v, maxcap].min } unless maxcap.nil?
|
|
29
|
-
bin_centers = limits.each_cons(2).to_a.map { |a| (a[0] + a[1]) / 2.0 }
|
|
30
|
-
return counts, bin_centers
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
end
|
data/lib/cmd_plot/pcolor.rb
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
class CmdPlot::Pcolor
|
|
2
|
-
|
|
3
|
-
#red, green, brown, blue, magenta, cyan, gray
|
|
4
|
-
TEXT_COLORS = [31, 32, 33, 34, 35, 36, 37]
|
|
5
|
-
FILL_SYMBOLS = ['░', '▒', '▓', '█']
|
|
6
|
-
MAX_SHADES = TEXT_COLORS.length * FILL_SYMBOLS.length
|
|
7
|
-
|
|
8
|
-
def initialize(width = 140, height = 40)
|
|
9
|
-
@width = width
|
|
10
|
-
@height = height
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def pcolor(data, shades = nil)
|
|
14
|
-
# Calculate scaling of data to picture
|
|
15
|
-
shades = data.flatten.uniq.length if shades.nil?
|
|
16
|
-
shades = MAX_SHADES if shades > MAX_SHADES or shades <= 0
|
|
17
|
-
x_scale = data[0].length / @width.to_f
|
|
18
|
-
y_scale = data.length / @height.to_f
|
|
19
|
-
z_max = data.map { |r| r.max }.max
|
|
20
|
-
z_min = data.map { |r| r.min }.min
|
|
21
|
-
puts "x_scale: #{x_scale}, y_scale: #{y_scale}, z_min: #{z_min}, z_max: #{z_max}"
|
|
22
|
-
|
|
23
|
-
# Calculcate scaled data
|
|
24
|
-
scaled_data = Array.new(@height) { Array.new(@width) { 0.0 } }
|
|
25
|
-
for row in 0..(scaled_data.length - 1)
|
|
26
|
-
for col in 0..(scaled_data[row].length - 1)
|
|
27
|
-
n = 0.0
|
|
28
|
-
lower_row_idx = (row * y_scale).floor
|
|
29
|
-
upper_row_idx = [lower_row_idx, ((row + 1) * y_scale).floor - 1].max
|
|
30
|
-
lower_col_idx = (col * x_scale).floor
|
|
31
|
-
upper_col_idx = [lower_col_idx, ((col + 1) * x_scale).floor - 1].max
|
|
32
|
-
for d_row in lower_row_idx..upper_row_idx
|
|
33
|
-
for d_col in lower_col_idx..upper_col_idx
|
|
34
|
-
scaled_data[row][col] += data[d_row][d_col]
|
|
35
|
-
n += 1.0
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
if(n > 0)
|
|
39
|
-
scaled_data[row][col] /= n
|
|
40
|
-
else
|
|
41
|
-
scaled_data[row][col] = nil
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Full picture starts out empty
|
|
47
|
-
pic = Array.new(@height) { Array.new(@width) { nil } }
|
|
48
|
-
# Run through data and fill in the plotting symbol/color
|
|
49
|
-
for row in 0..(scaled_data.length - 1)
|
|
50
|
-
for col in 0..(scaled_data[row].length - 1)
|
|
51
|
-
val = scaled_data[row][col]
|
|
52
|
-
next if val.nil?
|
|
53
|
-
fill = ((val - z_min) / (z_max - z_min).to_f * (shades - 1)).round
|
|
54
|
-
pic[row][col] = fill
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Plot the picture
|
|
59
|
-
for row in pic
|
|
60
|
-
for col in row
|
|
61
|
-
if col.nil?
|
|
62
|
-
print ' '
|
|
63
|
-
else
|
|
64
|
-
color = TEXT_COLORS[col % TEXT_COLORS.length]
|
|
65
|
-
sym = FILL_SYMBOLS[col / TEXT_COLORS.length]
|
|
66
|
-
print "\e[#{color}m#{sym}\e[0m"
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
print "\n"
|
|
70
|
-
end
|
|
71
|
-
return nil
|
|
72
|
-
end
|
|
73
|
-
end
|