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.
@@ -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,3 @@
1
+ module Termplot
2
+ VERSION = "0.1.0"
3
+ 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
@@ -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