youplot 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,97 +1,216 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'dsv_reader'
4
- require_relative 'command/parser'
3
+ require_relative 'dsv'
4
+ require_relative 'parser'
5
5
 
6
6
  # FIXME
7
- require_relative 'backends/unicode_plot_backend'
7
+ require_relative 'backends/unicode_plot'
8
8
 
9
9
  module YouPlot
10
10
  Data = Struct.new(:headers, :series)
11
11
 
12
12
  class Command
13
- attr_accessor :params
14
- attr_reader :data, :fmt, :parser
13
+ attr_accessor :command, :params, :options
14
+ attr_reader :data, :parser
15
15
 
16
16
  def initialize(argv = ARGV)
17
17
  @argv = argv
18
- @params = Params.new
19
18
  @parser = Parser.new
20
- @backend = YouPlot::Backends::UnicodePlotBackend
19
+ @command = nil
20
+ @params = nil
21
+ @options = nil
22
+ @backend = YouPlot::Backends::UnicodePlot
23
+ end
24
+
25
+ def run_as_executable
26
+ YouPlot.run_as_executable = true
27
+ run
21
28
  end
22
29
 
23
30
  def run
24
31
  parser.parse_options(@argv)
25
- command = parser.command
26
- params = parser.params
27
- delimiter = parser.delimiter
28
- transpose = parser.transpose
29
- headers = parser.headers
30
- pass = parser.pass
31
- output = parser.output
32
- fmt = parser.fmt
33
- @encoding = parser.encoding
34
- @debug = parser.debug
35
-
36
- if command == :colors
37
- @backend.colors(parser.color_names)
38
- exit
32
+ @command ||= parser.command
33
+ @options ||= parser.options
34
+ @params ||= parser.params
35
+
36
+ # color command
37
+ if %i[colors color colours colour].include? @command
38
+ plot = create_plot
39
+ output_plot(plot)
40
+ return
39
41
  end
40
42
 
41
- # Sometimes the input file does not end with a newline code.
42
- while (input = Kernel.gets(nil))
43
-
44
- # Pass the input to subsequent pipelines
45
- case pass
46
- when IO
47
- pass.print(input)
48
- else
49
- if pass
50
- File.open(pass, 'w') do |f|
51
- f.print(input)
52
- end
53
- end
43
+ # progressive mode
44
+ if options[:progressive]
45
+ stop = false
46
+ Signal.trap(:INT) { stop = true }
47
+
48
+ # make cursor invisible
49
+ options[:output].print "\e[?25l"
50
+
51
+ # mainloop
52
+ while (input = Kernel.gets)
53
+ n = main_progressive(input)
54
+ break if stop
55
+
56
+ options[:output].print "\e[#{n}F"
57
+ end
58
+
59
+ options[:output].print "\e[0J"
60
+ # make cursor visible
61
+ options[:output].print "\e[?25h"
62
+
63
+ # normal mode
64
+ else
65
+ # Sometimes the input file does not end with a newline code.
66
+ while (input = Kernel.gets(nil))
67
+ main(input)
68
+ end
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def main(input)
75
+ # Outputs input data to a file or stdout.
76
+ output_data(input)
77
+
78
+ @data = parse_dsv(input)
79
+
80
+ # Debug mode, show parsed results
81
+ pp @data if options[:debug]
82
+
83
+ # When run as a program instead of a library
84
+ if YouPlot.run_as_executable?
85
+ begin
86
+ plot = create_plot
87
+ rescue ArgumentError => e
88
+ # Show only one line of error.
89
+ warn e.backtrace[0]
90
+ # Show error message in purple
91
+ warn "\e[35m#{e}\e[0m"
92
+ # Explicitly terminated with exit code: 1
93
+ exit 1
94
+ end
95
+
96
+ # When running YouPlot as a library (e.g. for testing)
97
+ else
98
+ plot = create_plot
99
+ end
100
+
101
+ output_plot(plot)
102
+ end
103
+
104
+ def main_progressive(input)
105
+ output_data(input)
106
+
107
+ # FIXME
108
+ # Worked around the problem of not being able to draw
109
+ # plots when there is only one header line.
110
+ if @raw_data.nil?
111
+ @raw_data = String.new
112
+ if options[:headers]
113
+ @raw_data << input
114
+ return
54
115
  end
116
+ end
117
+ @raw_data << input
118
+
119
+ # FIXME
120
+ @data = parse_dsv(@raw_data)
121
+
122
+ plot = create_plot
123
+ output_plot_progressive(plot)
124
+ end
125
+
126
+ def parse_dsv(input)
127
+ # If encoding is specified, convert to UTF-8
128
+ if options[:encoding]
129
+ input.force_encoding(options[:encoding])
130
+ .encode!('utf-8')
131
+ end
132
+
133
+ begin
134
+ data = DSV.parse(input, options[:delimiter], options[:headers], options[:transpose])
135
+ rescue CSV::MalformedCSVError => e
136
+ warn 'Failed to parse the text. '
137
+ warn 'Please try to set the correct character encoding with --encoding option.'
138
+ raise e
139
+ end
140
+
141
+ data
142
+ end
55
143
 
56
- @data = if @encoding
57
- input2 = input.dup.force_encoding(@encoding).encode('utf-8')
58
- DSVReader.input(input2, delimiter, headers, transpose)
59
- else
60
- DSVReader.input(input, delimiter, headers, transpose)
61
- end
62
-
63
- pp @data if @debug
64
-
65
- plot = case command
66
- when :bar, :barplot
67
- @backend.barplot(data, params, fmt)
68
- when :count, :c
69
- @backend.barplot(data, params, count: true)
70
- when :hist, :histogram
71
- @backend.histogram(data, params)
72
- when :line, :lineplot
73
- @backend.line(data, params, fmt)
74
- when :lines, :lineplots
75
- @backend.lines(data, params, fmt)
76
- when :scatter, :s
77
- @backend.scatter(data, params, fmt)
78
- when :density, :d
79
- @backend.density(data, params, fmt)
80
- when :box, :boxplot
81
- @backend.boxplot(data, params)
82
- else
83
- raise "unrecognized plot_type: #{command}"
84
- end
85
-
86
- case output
87
- when IO
88
- plot.render(output)
89
- else
90
- File.open(output, 'w') do |f|
91
- plot.render(f)
144
+ def create_plot
145
+ case command
146
+ when :bar, :barplot
147
+ @backend.barplot(data, params, options[:fmt])
148
+ when :count, :c
149
+ @backend.barplot(data, params, count: true)
150
+ when :hist, :histogram
151
+ @backend.histogram(data, params)
152
+ when :line, :lineplot
153
+ @backend.line(data, params, options[:fmt])
154
+ when :lines, :lineplots
155
+ @backend.lines(data, params, options[:fmt])
156
+ when :scatter, :s
157
+ @backend.scatter(data, params, options[:fmt])
158
+ when :density, :d
159
+ @backend.density(data, params, options[:fmt])
160
+ when :box, :boxplot
161
+ @backend.boxplot(data, params)
162
+ when :colors, :color, :colours, :colour
163
+ @backend.colors(options[:color_names])
164
+ else
165
+ raise "unrecognized plot_type: #{command}"
166
+ end
167
+ end
168
+
169
+ def output_data(input)
170
+ # Pass the input to subsequent pipelines
171
+ case options[:pass]
172
+ when IO
173
+ options[:pass].print(input)
174
+ else
175
+ if options[:pass]
176
+ File.open(options[:pass], 'w') do |f|
177
+ f.print(input)
92
178
  end
93
179
  end
180
+ end
181
+ end
94
182
 
183
+ def output_plot(plot)
184
+ case options[:output]
185
+ when IO, StringIO
186
+ plot.render(options[:output])
187
+ when String, Tempfile
188
+ File.open(options[:output], 'w') do |f|
189
+ plot.render(f)
190
+ end
191
+ end
192
+ end
193
+
194
+ def output_plot_progressive(plot)
195
+ case options[:output]
196
+ when IO, StringIO
197
+ # RefactorMe
198
+ out = StringIO.new(String.new)
199
+ def out.tty?
200
+ true
201
+ end
202
+ plot.render(out)
203
+ lines = out.string.lines
204
+ lines.each do |line|
205
+ options[:output].print line.chomp
206
+ options[:output].print "\e[0K"
207
+ options[:output].puts
208
+ end
209
+ options[:output].print "\e[0J"
210
+ options[:output].flush
211
+ out.string.lines.size
212
+ else
213
+ raise 'In progressive mode, output to a file is not possible.'
95
214
  end
96
215
  end
97
216
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+
5
+ module YouPlot
6
+ # Module to handle DSV (Delimiter-separated values) format.
7
+ # Extract header and series.
8
+ module DSV
9
+ module_function
10
+
11
+ def parse(input, delimiter, headers, transpose)
12
+ # Parse as CSV
13
+ arr = CSV.parse(input, col_sep: delimiter)
14
+
15
+ # Remove blank lines
16
+ arr.delete_if do |i|
17
+ i == [] or i.all? nil
18
+ end
19
+
20
+ # get header
21
+ headers = get_headers(arr, headers, transpose)
22
+
23
+ # get series
24
+ series = get_series(arr, headers, transpose)
25
+
26
+ # Return if No header
27
+ return Data.new(headers, series) if headers.nil?
28
+
29
+ # Warn if header contains nil
30
+ warn "\e[35mHeaders contains nil in it.\e[0m" if headers.include?(nil)
31
+
32
+ # Warn if header contains ''
33
+ warn "\e[35mHeaders contains \"\" in it.\e[0m" if headers.include? ''
34
+
35
+ # Make sure the number of elements in the header matches the number of series.
36
+ h_size = headers.size
37
+ s_size = series.size
38
+
39
+ if h_size > s_size
40
+ warn "\e[35mThe number of headers is greater than the number of series.\e[0m"
41
+ exit 1 if YouPlot.run_as_executable?
42
+
43
+ elsif h_size < s_size
44
+ warn "\e[35mThe number of headers is less than the number of series.\e[0m"
45
+ exit 1 if YouPlot.run_as_executable?
46
+ end
47
+
48
+ Data.new(headers, series) if h_size == s_size
49
+ end
50
+
51
+ # Transpose different sized ruby arrays
52
+ # https://stackoverflow.com/q/26016632
53
+ def transpose2(arr)
54
+ Array.new(arr.map(&:length).max) { |i| arr.map { |e| e[i] } }
55
+ end
56
+
57
+ def get_headers(arr, headers, transpose)
58
+ # header(-)
59
+ return nil unless headers
60
+
61
+ # header(+) trenspose(+)
62
+ return arr.map(&:first) if transpose
63
+
64
+ # header(+) transpose(-)
65
+ arr[0]
66
+ end
67
+
68
+ def get_series(arr, headers, transpose)
69
+ # header(-)
70
+ unless headers
71
+ return arr if transpose
72
+
73
+ return transpose2(arr)
74
+ end
75
+
76
+ # header(+) but no element in the series.
77
+ # TODO: should raise error?
78
+ return Array.new(arr[0].size, []) if arr.size == 1
79
+
80
+ # header(+) transpose(+)
81
+ return arr.map { |row| row[1..-1] } if transpose
82
+
83
+ # header(+) transpose(-)
84
+ transpose2(arr[1..-1])
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YouPlot
4
+ # Command line options that are not Plot parameters
5
+ Options = Struct.new(
6
+ :delimiter,
7
+ :transpose,
8
+ :headers,
9
+ :pass,
10
+ :output,
11
+ :fmt,
12
+ :progressive,
13
+ :encoding,
14
+ :color_names,
15
+ :debug,
16
+ keyword_init: true
17
+ )
18
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YouPlot
4
+ # UnicodePlot parameters.
5
+ # Why Struct, not Hash?
6
+ # * The keys are static in Struct.
7
+ # * Struct does not conflict with keyword arguments. Hash dose.
8
+ Parameters = Struct.new(
9
+ # Sort me!
10
+ :title,
11
+ :width,
12
+ :height,
13
+ :border,
14
+ :margin,
15
+ :padding,
16
+ :color,
17
+ :xlabel,
18
+ :ylabel,
19
+ :labels,
20
+ :symbol,
21
+ :xscale,
22
+ :nbins,
23
+ :closed,
24
+ :canvas,
25
+ :xlim,
26
+ :ylim,
27
+ :grid,
28
+ :name
29
+ ) do
30
+ def to_hc
31
+ to_h.compact
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'options'
5
+
6
+ module YouPlot
7
+ # Class for parsing command line options
8
+ class Parser
9
+ class Error < StandardError; end
10
+
11
+ attr_reader :command, :options, :params,
12
+ :main_parser, :sub_parser
13
+
14
+ def initialize
15
+ @command = nil
16
+
17
+ @options = Options.new(
18
+ delimiter: "\t",
19
+ transpose: false,
20
+ headers: nil,
21
+ pass: false,
22
+ output: $stderr,
23
+ fmt: 'xyy',
24
+ progressive: false,
25
+ encoding: nil,
26
+ color_names: false,
27
+ debug: false
28
+ )
29
+
30
+ @params = Parameters.new
31
+ end
32
+
33
+ def create_base_parser
34
+ OptionParser.new do |parser|
35
+ parser.program_name = 'YouPlot'
36
+ parser.version = YouPlot::VERSION
37
+ parser.summary_width = 23
38
+ parser.on_tail('') # Add a blank line at the end
39
+ parser.separator('')
40
+ parser.on('Common options:')
41
+ parser.on('-O', '--pass [FILE]', 'file to output input data to [stdout]',
42
+ 'for inserting YouPlot in the middle of Unix pipes') do |v|
43
+ options[:pass] = v || $stdout
44
+ end
45
+ parser.on('-o', '--output [FILE]', 'file to output plots to [stdout]',
46
+ 'If no option is specified, plot will print to stderr') do |v|
47
+ options[:output] = v || $stdout
48
+ end
49
+ parser.on('-d', '--delimiter DELIM', String, 'use DELIM instead of [TAB] for field delimiter') do |v|
50
+ options[:delimiter] = v
51
+ end
52
+ parser.on('-H', '--headers', TrueClass, 'specify that the input has header row') do |v|
53
+ options[:headers] = v
54
+ end
55
+ parser.on('-T', '--transpose', TrueClass, 'transpose the axes of the input data') do |v|
56
+ options[:transpose] = v
57
+ end
58
+ parser.on('-t', '--title STR', String, 'print string on the top of plot') do |v|
59
+ params.title = v
60
+ end
61
+ parser.on('-x', '--xlabel STR', String, 'print string on the bottom of the plot') do |v|
62
+ params.xlabel = v
63
+ end
64
+ parser.on('-y', '--ylabel STR', String, 'print string on the far left of the plot') do |v|
65
+ params.ylabel = v
66
+ end
67
+ parser.on('-w', '--width INT', Integer, 'number of characters per row') do |v|
68
+ params.width = v
69
+ end
70
+ parser.on('-h', '--height INT', Numeric, 'number of rows') do |v|
71
+ params.height = v
72
+ end
73
+ border_options = UnicodePlot::BORDER_MAP.keys.join(', ')
74
+ parser.on('-b', '--border STR', String, 'specify the style of the bounding box', "(#{border_options})") do |v|
75
+ params.border = v.to_sym
76
+ end
77
+ parser.on('-m', '--margin INT', Numeric, 'number of spaces to the left of the plot') do |v|
78
+ params.margin = v
79
+ end
80
+ parser.on('--padding INT', Numeric, 'space of the left and right of the plot') do |v|
81
+ params.padding = v
82
+ end
83
+ parser.on('-c', '--color VAL', String, 'color of the drawing') do |v|
84
+ params.color = v =~ /\A[0-9]+\z/ ? v.to_i : v.to_sym
85
+ end
86
+ parser.on('--[no-]labels', TrueClass, 'hide the labels') do |v|
87
+ params.labels = v
88
+ end
89
+ parser.on('-p', '--progress', TrueClass, 'progressive mode [experimental]') do |v|
90
+ options[:progressive] = v
91
+ end
92
+ parser.on('-C', '--color-output', TrueClass, 'colorize even if writing to a pipe') do |_v|
93
+ UnicodePlot::IOContext.define_method(:color?) { true } # FIXME
94
+ end
95
+ parser.on('-M', '--monochrome', TrueClass, 'no colouring even if writing to a tty') do |_v|
96
+ UnicodePlot::IOContext.define_method(:color?) { false } # FIXME
97
+ end
98
+ parser.on('--encoding STR', String, 'Specify the input encoding') do |v|
99
+ options[:encoding] = v
100
+ end
101
+ # Optparse adds the help option, but it doesn't show up in usage.
102
+ # This is why you need the code below.
103
+ parser.on('--help', 'print sub-command help menu') do
104
+ puts parser.help
105
+ exit if YouPlot.run_as_executable?
106
+ end
107
+ parser.on('--debug', TrueClass, 'print preprocessed data') do |v|
108
+ options[:debug] = v
109
+ end
110
+ # yield opt if block_given?
111
+ end
112
+ end
113
+
114
+ def create_main_parser
115
+ @main_parser = create_base_parser
116
+ main_parser.banner = \
117
+ <<~MSG
118
+
119
+ Program: YouPlot (Tools for plotting on the terminal)
120
+ Version: #{YouPlot::VERSION} (using UnicodePlot #{UnicodePlot::VERSION})
121
+ Source: https://github.com/kojix2/youplot
122
+
123
+ Usage: uplot <command> [options] <in.tsv>
124
+
125
+ Commands:
126
+ barplot bar draw a horizontal barplot
127
+ histogram hist draw a horizontal histogram
128
+ lineplot line draw a line chart
129
+ lineplots lines draw a line chart with multiple series
130
+ scatter s draw a scatter plot
131
+ density d draw a density plot
132
+ boxplot box draw a horizontal boxplot
133
+ colors color show the list of available colors
134
+
135
+ count c draw a baplot based on the number of
136
+ occurrences (slow)
137
+
138
+ General options:
139
+ --help print command specific help menu
140
+ --version print the version of YouPlot
141
+ MSG
142
+
143
+ # Help for the main parser is simple.
144
+ # Simply show the banner above.
145
+ main_parser.on('--help', 'print sub-command help menu') do
146
+ puts main_parser.banner
147
+ puts
148
+ exit if YouPlot.run_as_executable?
149
+ end
150
+ end
151
+
152
+ def sub_parser_add_symbol
153
+ sub_parser.on_head('--symbol STR', String, 'character to be used to plot the bars') do |v|
154
+ params.symbol = v
155
+ end
156
+ end
157
+
158
+ def sub_parser_add_xscale
159
+ xscale_options = UnicodePlot::ValueTransformer::PREDEFINED_TRANSFORM_FUNCTIONS.keys.join(', ')
160
+ sub_parser.on_head('--xscale STR', String, "axis scaling (#{xscale_options})") do |v|
161
+ params.xscale = v.to_sym
162
+ end
163
+ end
164
+
165
+ def sub_parser_add_canvas
166
+ sub_parser.on_head('--canvas STR', String, 'type of canvas') do |v|
167
+ params.canvas = v.to_sym
168
+ end
169
+ end
170
+
171
+ def sub_parser_add_xlim
172
+ sub_parser.on_head('--xlim FLOAT,FLOAT', Array, 'plotting range for the x coordinate') do |v|
173
+ params.xlim = v
174
+ end
175
+ end
176
+
177
+ def sub_parser_add_ylim
178
+ sub_parser.on_head('--ylim FLOAT,FLOAT', Array, 'plotting range for the y coordinate') do |v|
179
+ params.ylim = v
180
+ end
181
+ end
182
+
183
+ def sub_parser_add_grid
184
+ sub_parser.on_head('--[no-]grid', TrueClass, 'draws grid-lines at the origin') do |v|
185
+ params.grid = v
186
+ end
187
+ end
188
+
189
+ def sub_parser_add_fmt_xyxy
190
+ sub_parser.on_head('--fmt STR', String,
191
+ 'xyxy : header is like x1, y1, x2, y2, x3, y3...',
192
+ 'xyy : header is like x, y1, y2, y2, y3...') do |v|
193
+ options[:fmt] = v
194
+ end
195
+ end
196
+
197
+ def sub_parser_add_fmt_yx
198
+ sub_parser.on_head('--fmt STR', String,
199
+ 'xy : header is like x, y...',
200
+ 'yx : header is like y, x...') do |v|
201
+ options[:fmt] = v
202
+ end
203
+ end
204
+
205
+ def create_sub_parser
206
+ @sub_parser = create_base_parser
207
+ sub_parser.banner = \
208
+ <<~MSG
209
+
210
+ Usage: YouPlot #{command} [options] <in.tsv>
211
+
212
+ Options for #{command}:
213
+ MSG
214
+
215
+ case command
216
+
217
+ # If you type only `uplot` in the terminal.
218
+ when nil
219
+ warn main_parser.banner
220
+ warn "\n"
221
+ exit 1 if YouPlot.run_as_executable?
222
+
223
+ when :barplot, :bar
224
+ sub_parser_add_symbol
225
+ sub_parser_add_fmt_yx
226
+ sub_parser_add_xscale
227
+
228
+ when :count, :c
229
+ sub_parser_add_symbol
230
+ sub_parser_add_xscale
231
+
232
+ when :histogram, :hist
233
+ sub_parser_add_symbol
234
+ sub_parser.on_head('--closed STR', String, 'side of the intervals to be closed [left]') do |v|
235
+ params.closed = v
236
+ end
237
+ sub_parser.on_head('-n', '--nbins INT', Numeric, 'approximate number of bins') do |v|
238
+ params.nbins = v
239
+ end
240
+
241
+ when :lineplot, :line
242
+ sub_parser_add_canvas
243
+ sub_parser_add_grid
244
+ sub_parser_add_fmt_yx
245
+ sub_parser_add_ylim
246
+ sub_parser_add_xlim
247
+
248
+ when :lineplots, :lines
249
+ sub_parser_add_canvas
250
+ sub_parser_add_grid
251
+ sub_parser_add_fmt_xyxy
252
+ sub_parser_add_ylim
253
+ sub_parser_add_xlim
254
+
255
+ when :scatter, :s
256
+ sub_parser_add_canvas
257
+ sub_parser_add_grid
258
+ sub_parser_add_fmt_xyxy
259
+ sub_parser_add_ylim
260
+ sub_parser_add_xlim
261
+
262
+ when :density, :d
263
+ sub_parser_add_canvas
264
+ sub_parser_add_grid
265
+ sub_parser_add_fmt_xyxy
266
+ sub_parser_add_ylim
267
+ sub_parser_add_xlim
268
+
269
+ when :boxplot, :box
270
+ sub_parser_add_xlim
271
+
272
+ when :colors, :color, :colours, :colour
273
+ sub_parser.on_head('-n', '--names', 'show color names only', TrueClass) do |v|
274
+ options[:color_names] = v
275
+ end
276
+
277
+ else
278
+ error_message = "uplot: unrecognized command '#{command}'"
279
+ if YouPlot.run_as_executable?
280
+ warn error_message
281
+ exit 1
282
+ else
283
+ raise Error, error_message
284
+ end
285
+ end
286
+ end
287
+
288
+ def parse_options(argv = ARGV)
289
+ begin
290
+ create_main_parser.order!(argv)
291
+ rescue OptionParser::ParseError => e
292
+ warn "uplot: #{e.message}"
293
+ exit 1 if YouPlot.run_as_executable?
294
+ end
295
+
296
+ @command = argv.shift&.to_sym
297
+
298
+ begin
299
+ create_sub_parser&.parse!(argv)
300
+ rescue OptionParser::ParseError => e
301
+ warn "uplot: #{e.message}"
302
+ exit 1 if YouPlot.run_as_executable?
303
+ end
304
+ end
305
+ end
306
+ end