termplot 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 +7 -0
- data/.gitignore +10 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/Rakefile +18 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/termplot +5 -0
- data/doc/cpu.png +0 -0
- data/doc/demo.cast +638 -0
- data/doc/demo.gif +0 -0
- data/doc/memory.png +0 -0
- data/doc/sin.png +0 -0
- data/doc/tcp.png +0 -0
- data/lib/termplot.rb +9 -0
- data/lib/termplot/character_map.rb +55 -0
- data/lib/termplot/cli.rb +59 -0
- data/lib/termplot/colors.rb +55 -0
- data/lib/termplot/consumer.rb +71 -0
- data/lib/termplot/cursors/buffered_console_cursor.rb +70 -0
- data/lib/termplot/cursors/console_cursor.rb +56 -0
- data/lib/termplot/cursors/control_chars.rb +11 -0
- data/lib/termplot/cursors/virtual_cursor.rb +76 -0
- data/lib/termplot/renderer.rb +279 -0
- data/lib/termplot/series.rb +37 -0
- data/lib/termplot/shell.rb +31 -0
- data/lib/termplot/version.rb +3 -0
- data/lib/termplot/window.rb +69 -0
- data/termplot.gemspec +32 -0
- metadata +91 -0
@@ -0,0 +1,279 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "termplot/window"
|
4
|
+
require "termplot/character_map"
|
5
|
+
require "termplot/colors"
|
6
|
+
|
7
|
+
module Termplot
|
8
|
+
class Renderer
|
9
|
+
attr_reader :cols, :rows
|
10
|
+
|
11
|
+
def initialize(cols: 80, rows: 20, debug: false)
|
12
|
+
# Default border size, right border allocation will change dynamically as
|
13
|
+
# data comes in to account for the length of the numbers to be printed in
|
14
|
+
# the axis ticks
|
15
|
+
@border_size = default_border_size
|
16
|
+
@cols = cols > min_cols ? cols : min_cols
|
17
|
+
@rows = rows > min_rows ? rows : min_rows
|
18
|
+
@window = Window.new(cols: @cols, rows: @rows)
|
19
|
+
@decimals = 2
|
20
|
+
@tick_spacing = 3
|
21
|
+
@debug = debug
|
22
|
+
@errors = []
|
23
|
+
end
|
24
|
+
|
25
|
+
def render(series)
|
26
|
+
window.clear
|
27
|
+
errors.clear
|
28
|
+
|
29
|
+
# Calculate width of right hand axis
|
30
|
+
calculate_axis_size(series)
|
31
|
+
|
32
|
+
# Points
|
33
|
+
points = build_points(series)
|
34
|
+
render_points(series, points)
|
35
|
+
window.cursor.reset_position
|
36
|
+
|
37
|
+
# Title bar
|
38
|
+
render_title(series)
|
39
|
+
window.cursor.reset_position
|
40
|
+
|
41
|
+
# Borders
|
42
|
+
render_borders
|
43
|
+
window.cursor.reset_position
|
44
|
+
|
45
|
+
# Draw axis
|
46
|
+
ticks = build_ticks(points)
|
47
|
+
render_axis(ticks)
|
48
|
+
|
49
|
+
# Flush window to screen
|
50
|
+
debug? ?
|
51
|
+
window.flush_debug :
|
52
|
+
window.flush
|
53
|
+
|
54
|
+
if errors.any?
|
55
|
+
window.print_errors(errors)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def inner_width
|
60
|
+
cols - border_size.left - border_size.right
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
attr_reader :window, :border_size, :errors, :decimals, :tick_spacing
|
65
|
+
|
66
|
+
def debug?
|
67
|
+
@debug
|
68
|
+
end
|
69
|
+
|
70
|
+
def border_char_map
|
71
|
+
CharacterMap::DEFAULT
|
72
|
+
end
|
73
|
+
|
74
|
+
def inner_height
|
75
|
+
rows - border_size.top - border_size.bottom
|
76
|
+
end
|
77
|
+
|
78
|
+
# At minimum, 2 cols of inner_width for values
|
79
|
+
def min_cols
|
80
|
+
border_size.left + border_size.right + 2
|
81
|
+
end
|
82
|
+
|
83
|
+
# At minimum, 2 rows of inner_height for values
|
84
|
+
def min_rows
|
85
|
+
border_size.top + border_size.bottom + 2
|
86
|
+
end
|
87
|
+
|
88
|
+
Border = Struct.new(:top, :right, :bottom, :left)
|
89
|
+
def default_border_size
|
90
|
+
Border.new(2, 4, 1, 1)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Axis size = length of the longest point value , formatted as a string to
|
94
|
+
# @decimals decimal places, + 2 for some extra buffer + 1 for the border
|
95
|
+
# itself.
|
96
|
+
def calculate_axis_size(series)
|
97
|
+
border_right = series.data.map { |n| n.round(decimals).to_s.length }.max
|
98
|
+
border_right += 3
|
99
|
+
|
100
|
+
# Clamp border_right at cols - 3 to prevent the renderer from crashing
|
101
|
+
# with very large numbers
|
102
|
+
if border_right > cols - 3
|
103
|
+
errors.push(Colors.yellow "Warning: Axis tick values have been clipped, consider using more columns with -c")
|
104
|
+
border_right = cols - 3
|
105
|
+
end
|
106
|
+
|
107
|
+
@border_size = Border.new(2, border_right, 1, 1)
|
108
|
+
end
|
109
|
+
|
110
|
+
Point = Struct.new(:x, :y, :value)
|
111
|
+
def build_points(series)
|
112
|
+
return [] if series.data.empty?
|
113
|
+
points =
|
114
|
+
series.data.last(inner_width).map.with_index do |p, x|
|
115
|
+
# Map from series Y range to inner height
|
116
|
+
y = map_value(p, [series.min, series.max], [0, inner_height - 1])
|
117
|
+
|
118
|
+
# Invert Y value since pixel Y is inverse of cartesian Y
|
119
|
+
y = border_size.top - 1 + inner_height - y.round
|
120
|
+
|
121
|
+
# Add padding for border width
|
122
|
+
Point.new(x + border_size.left, y, p.to_f)
|
123
|
+
end
|
124
|
+
|
125
|
+
points
|
126
|
+
end
|
127
|
+
|
128
|
+
Tick = Struct.new(:y, :label)
|
129
|
+
def build_ticks(points)
|
130
|
+
return [] if points.empty?
|
131
|
+
max_point = points.max_by(&:value)
|
132
|
+
min_point = points.min_by(&:value)
|
133
|
+
point_y_range = points.max_by(&:y).y - points.min_by(&:y).y
|
134
|
+
point_value_range = max_point.value - min_point.value
|
135
|
+
ticks = []
|
136
|
+
ticks.push Tick.new(max_point.y, format_label(max_point.value))
|
137
|
+
|
138
|
+
# Distribute ticks between min and max, maintaining spacinig as much as
|
139
|
+
# possible. tick_spacing is inclusive of the tick row itself.
|
140
|
+
unless max_point.value == min_point.value &&
|
141
|
+
(point_y_range - 2) > tick_spacing
|
142
|
+
num_ticks = (point_y_range - 2) / tick_spacing
|
143
|
+
num_ticks.times do |i|
|
144
|
+
tick_y = max_point.y + (i + 1) * tick_spacing
|
145
|
+
value = max_point.value - point_value_range * ((i + 1) * tick_spacing) / point_y_range
|
146
|
+
ticks.push Tick.new(tick_y, format_label(value))
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
ticks.push Tick.new(min_point.y, format_label(min_point.value))
|
151
|
+
ticks
|
152
|
+
end
|
153
|
+
|
154
|
+
# Map value from one range to another
|
155
|
+
def map_value(val, from_range, to_range)
|
156
|
+
orig_range = [1, (from_range[1] - from_range[0]).abs].max
|
157
|
+
new_range = [1, (to_range[1] - to_range[0]).abs].max
|
158
|
+
|
159
|
+
((val.to_f - from_range[0]) / orig_range) * new_range + to_range[0]
|
160
|
+
end
|
161
|
+
|
162
|
+
def render_points(series, points)
|
163
|
+
# Render points
|
164
|
+
points.each_with_index do |point, i|
|
165
|
+
window.cursor.position = point.y * cols + point.x
|
166
|
+
if series.line_style[:extended]
|
167
|
+
prev_point = ((i - 1) >= 0) ? points[i-1] : nil
|
168
|
+
render_connected_line(series, prev_point, point)
|
169
|
+
else
|
170
|
+
window.write(colored(series, series.line_style[:point]))
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def render_connected_line(series, prev_point, point)
|
176
|
+
if prev_point.nil? || (prev_point.y == point.y)
|
177
|
+
window.write(colored(series, series.line_style[:horz_top]))
|
178
|
+
elsif prev_point.y > point.y
|
179
|
+
diff = prev_point.y - point.y
|
180
|
+
|
181
|
+
window.write(colored(series, series.line_style[:top_left]))
|
182
|
+
window.cursor.down
|
183
|
+
window.cursor.back
|
184
|
+
|
185
|
+
(diff - 1).times do
|
186
|
+
window.write(colored(series, series.line_style[:vert_right]))
|
187
|
+
window.cursor.down
|
188
|
+
window.cursor.back
|
189
|
+
end
|
190
|
+
|
191
|
+
window.write(colored(series, series.line_style[:bot_right]))
|
192
|
+
elsif prev_point.y < point.y
|
193
|
+
diff = point.y - prev_point.y
|
194
|
+
|
195
|
+
window.write(colored(series, series.line_style[:bot_left]))
|
196
|
+
window.cursor.up
|
197
|
+
window.cursor.back
|
198
|
+
|
199
|
+
(diff - 1).times do
|
200
|
+
window.write(colored(series, series.line_style[:vert_left]))
|
201
|
+
window.cursor.up
|
202
|
+
window.cursor.back
|
203
|
+
end
|
204
|
+
|
205
|
+
window.write(colored(series, series.line_style[:top_right]))
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def render_title(series)
|
210
|
+
legend_marker = colored(series, series.line_style[:point])
|
211
|
+
title = series.title
|
212
|
+
|
213
|
+
legend_position = [1, (border_size.left + 1 + inner_width) / 2 - (title.length + 2) / 2].max
|
214
|
+
if (title.length + 2 + legend_position) > cols
|
215
|
+
errors.push(Colors.yellow "Warning: Title has been clipped, consider using more rows with -r")
|
216
|
+
title = title[0..(cols - legend_position - 2)]
|
217
|
+
end
|
218
|
+
|
219
|
+
window.cursor.forward(legend_position)
|
220
|
+
window.write(legend_marker)
|
221
|
+
window.write(" ")
|
222
|
+
title.chars.each do |char|
|
223
|
+
window.write(char)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def render_borders
|
228
|
+
window.cursor.down(border_size.top - 1)
|
229
|
+
window.cursor.forward(border_size.left - 1)
|
230
|
+
window.write(border_char_map[:top_left])
|
231
|
+
inner_width.times { window.write(border_char_map[:horz_top]) }
|
232
|
+
window.write(border_char_map[:top_right])
|
233
|
+
window.cursor.forward(border_size.right - 1)
|
234
|
+
|
235
|
+
inner_height.times do |y|
|
236
|
+
window.cursor.forward(border_size.left - 1)
|
237
|
+
window.write(border_char_map[:vert_right])
|
238
|
+
window.cursor.forward(inner_width)
|
239
|
+
window.write(border_char_map[:vert_left])
|
240
|
+
window.cursor.forward(border_size.right - 1)
|
241
|
+
end
|
242
|
+
|
243
|
+
# Bottom border
|
244
|
+
# Jump to bottom left corner
|
245
|
+
window.cursor.forward(border_size.left - 1)
|
246
|
+
window.write(border_char_map[:bot_left])
|
247
|
+
inner_width.times { window.write(border_char_map[:horz_top]) }
|
248
|
+
window.write(border_char_map[:bot_right])
|
249
|
+
end
|
250
|
+
|
251
|
+
def render_axis(ticks)
|
252
|
+
window.cursor.down(border_size.top - 1)
|
253
|
+
window.cursor.forward(border_size.left + inner_width + 1)
|
254
|
+
|
255
|
+
# Render ticks
|
256
|
+
ticks.each do |tick|
|
257
|
+
window.cursor.row = tick.y
|
258
|
+
window.cursor.back
|
259
|
+
window.write(border_char_map[:tick_right])
|
260
|
+
tick.label.chars.each do |c|
|
261
|
+
window.write(c)
|
262
|
+
end
|
263
|
+
window.cursor.back(label_chars)
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
def format_label(num)
|
268
|
+
("%.2f" % num.round(decimals))[0..label_chars - 1].ljust(label_chars, " ")
|
269
|
+
end
|
270
|
+
|
271
|
+
def label_chars
|
272
|
+
border_size.right - 2
|
273
|
+
end
|
274
|
+
|
275
|
+
def colored(series, text)
|
276
|
+
Colors.send(series.color, text)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Termplot
|
2
|
+
class Series
|
3
|
+
attr_reader :title, :data, :min, :max, :range, :color, :line_style
|
4
|
+
attr_accessor :max_data_points
|
5
|
+
|
6
|
+
DEFAULT_COLOR = "red"
|
7
|
+
DEFAULT_LINE_STYLE = "line"
|
8
|
+
|
9
|
+
def initialize(max_data_points:, title: "Series", color: DEFAULT_COLOR, line_style: DEFAULT_LINE_STYLE)
|
10
|
+
@data = []
|
11
|
+
@max_data_points = max_data_points
|
12
|
+
@min = 0
|
13
|
+
@max = 0
|
14
|
+
@range = 0
|
15
|
+
|
16
|
+
@title = title
|
17
|
+
@color = Termplot::Colors.fetch(color, DEFAULT_COLOR)
|
18
|
+
@line_style = Termplot::CharacterMap::LINE_STYLES.fetch(
|
19
|
+
line_style,
|
20
|
+
Termplot::CharacterMap::LINE_STYLES[DEFAULT_LINE_STYLE]
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
def add_point(point)
|
25
|
+
@data.push(point)
|
26
|
+
|
27
|
+
while @data.length > max_data_points do
|
28
|
+
@data.shift
|
29
|
+
end
|
30
|
+
|
31
|
+
@min = data.min
|
32
|
+
@max = data.max
|
33
|
+
@range = (max - min).abs
|
34
|
+
@range = 1 if range.zero?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require "termios"
|
2
|
+
|
3
|
+
module Termplot
|
4
|
+
class Shell
|
5
|
+
class << self
|
6
|
+
attr_reader :termios_settings
|
7
|
+
|
8
|
+
CURSOR_HIDE = "\e[?25l"
|
9
|
+
CURSOR_SHOW = "\e[?25h"
|
10
|
+
def init
|
11
|
+
# Disable echo on stdout tty, prevents printing chars if you type in
|
12
|
+
# between rendering
|
13
|
+
@termios_settings = Termios.tcgetattr($stdout)
|
14
|
+
new_termios_settings = termios_settings.dup
|
15
|
+
new_termios_settings.c_lflag &= ~(Termios::ECHO)
|
16
|
+
Termios.tcsetattr($stdout, Termios::TCSAFLUSH, new_termios_settings)
|
17
|
+
|
18
|
+
print CURSOR_HIDE
|
19
|
+
at_exit { reset }
|
20
|
+
Signal.trap("INT") { exit(0) }
|
21
|
+
end
|
22
|
+
|
23
|
+
def reset
|
24
|
+
# Reset stdout tty to original settings
|
25
|
+
Termios.tcsetattr($stdout, Termios::TCSAFLUSH, termios_settings)
|
26
|
+
|
27
|
+
print CURSOR_SHOW
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require "termplot/cursors/virtual_cursor"
|
2
|
+
require "termplot/cursors/buffered_console_cursor"
|
3
|
+
|
4
|
+
module Termplot
|
5
|
+
class Window
|
6
|
+
attr_reader :rows, :cols, :buffer
|
7
|
+
def initialize(cols:, rows:)
|
8
|
+
@rows = rows
|
9
|
+
@cols = cols
|
10
|
+
@buffer = Array.new(cols * rows) { CharacterMap::DEFAULT[:empty] }
|
11
|
+
end
|
12
|
+
|
13
|
+
def cursor
|
14
|
+
@cursor ||= VirtualCursor.new(self)
|
15
|
+
end
|
16
|
+
|
17
|
+
def console_cursor
|
18
|
+
# Console buffer has an extra rows - 1 to account for new line characters
|
19
|
+
# between rows
|
20
|
+
@console_cursor ||=
|
21
|
+
BufferedConsoleCursor.new(self, Array.new(cols * rows + rows - 1))
|
22
|
+
end
|
23
|
+
|
24
|
+
def size
|
25
|
+
rows * cols
|
26
|
+
end
|
27
|
+
|
28
|
+
def write(char)
|
29
|
+
buffer[cursor.position] = char
|
30
|
+
cursor.write(char)
|
31
|
+
end
|
32
|
+
|
33
|
+
def clear
|
34
|
+
cursor.reset_position
|
35
|
+
size.times { write CharacterMap::DEFAULT[:empty] }
|
36
|
+
end
|
37
|
+
|
38
|
+
def flush
|
39
|
+
console_cursor.clear_buffer
|
40
|
+
console_cursor.reset_position
|
41
|
+
buffer.each_slice(cols).with_index do |line, i|
|
42
|
+
line.each do |v|
|
43
|
+
console_cursor.write(v)
|
44
|
+
end
|
45
|
+
console_cursor.new_line
|
46
|
+
end
|
47
|
+
console_cursor.flush
|
48
|
+
end
|
49
|
+
|
50
|
+
def flush_debug(str = "Window")
|
51
|
+
padding = "-" * 10
|
52
|
+
puts "\n#{padding} #{str} #{padding}\n"
|
53
|
+
buffer.each_slice(cols).with_index do |line, y|
|
54
|
+
render_line = line.each_with_index.map do |c, x|
|
55
|
+
y * cols + x == cursor.position ? "𝥺" : c
|
56
|
+
end
|
57
|
+
print render_line
|
58
|
+
puts
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# TODO: Refine later and include errors properly in the window
|
63
|
+
def print_errors(errors)
|
64
|
+
print errors.join(Termplot::ControlChars::NEWLINE)
|
65
|
+
print Termplot::ControlChars::NEWLINE
|
66
|
+
errors.length.times { print Termplot::ControlChars::UP }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
data/termplot.gemspec
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require_relative 'lib/termplot/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "termplot"
|
5
|
+
spec.version = Termplot::VERSION
|
6
|
+
spec.authors = ["Martin Nyaga"]
|
7
|
+
spec.email = ["nyagamartin72@gmail.com"]
|
8
|
+
|
9
|
+
spec.summary = %q{Plot time series charts in your terminal}
|
10
|
+
spec.description = %q{Plot time series charts in your terminal}
|
11
|
+
spec.homepage = "https://github.com/Martin-Nyaga/termplot"
|
12
|
+
spec.license = "MIT"
|
13
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
|
14
|
+
|
15
|
+
# spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/Martin-Nyaga/termplot"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/Martin-Nyaga/termplot"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = "bin"
|
27
|
+
spec.executables = "termplot"
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
# Dependencies
|
31
|
+
spec.add_runtime_dependency "ruby-termios", "~> 1.0"
|
32
|
+
end
|