youplot 0.3.2 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,10 @@
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)
@@ -19,7 +19,12 @@ module YouPlot
19
19
  @command = nil
20
20
  @params = nil
21
21
  @options = nil
22
- @backend = YouPlot::Backends::UnicodePlotBackend
22
+ @backend = YouPlot::Backends::UnicodePlot
23
+ end
24
+
25
+ def run_as_executable
26
+ YouPlot.run_as_executable = true
27
+ run
23
28
  end
24
29
 
25
30
  def run
@@ -28,9 +33,34 @@ module YouPlot
28
33
  @options ||= parser.options
29
34
  @params ||= parser.params
30
35
 
36
+ # color command
31
37
  if %i[colors color colours colour].include? @command
32
38
  plot = create_plot
33
39
  output_plot(plot)
40
+ return
41
+ end
42
+
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
34
64
  else
35
65
  # Sometimes the input file does not end with a newline code.
36
66
  while (input = Kernel.gets(nil))
@@ -42,19 +72,73 @@ module YouPlot
42
72
  private
43
73
 
44
74
  def main(input)
75
+ # Outputs input data to a file or stdout.
45
76
  output_data(input)
46
77
 
47
- @data = read_dsv(input)
78
+ @data = parse_dsv(input)
48
79
 
80
+ # Debug mode, show parsed results
49
81
  pp @data if options[:debug]
50
82
 
51
- plot = create_plot
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
+
52
101
  output_plot(plot)
53
102
  end
54
103
 
55
- def read_dsv(input)
56
- input = input.dup.force_encoding(options[:encoding]).encode('utf-8') if options[:encoding]
57
- DSVReader.input(input, options[:delimiter], options[:headers], options[:transpose])
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
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
58
142
  end
59
143
 
60
144
  def create_plot
@@ -62,7 +146,7 @@ module YouPlot
62
146
  when :bar, :barplot
63
147
  @backend.barplot(data, params, options[:fmt])
64
148
  when :count, :c
65
- @backend.barplot(data, params, count: true)
149
+ @backend.barplot(data, params, count: true, reverse: options[:reverse])
66
150
  when :hist, :histogram
67
151
  @backend.histogram(data, params)
68
152
  when :line, :lineplot
@@ -98,13 +182,36 @@ module YouPlot
98
182
 
99
183
  def output_plot(plot)
100
184
  case options[:output]
101
- when IO
185
+ when IO, StringIO
102
186
  plot.render(options[:output])
103
- else
187
+ when String, Tempfile
104
188
  File.open(options[:output], 'w') do |f|
105
189
  plot.render(f)
106
190
  end
107
191
  end
108
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.'
214
+ end
215
+ end
109
216
  end
110
217
  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
+ :reverse, # count
15
+ :color_names, # color
16
+ :debug
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,310 @@
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
+ "\t", # elimiter:
19
+ false, # transpose:
20
+ nil, # headers:
21
+ false, # pass:
22
+ $stderr, # output:
23
+ 'xyy', # fmt:
24
+ false, # progressive:
25
+ nil, # encoding:
26
+ false, # color_names:
27
+ false # debug:
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
+ canvas_types = UnicodePlot::Canvas::CANVAS_CLASS_MAP.keys.join(', ')
167
+ sub_parser.on_head('--canvas STR', String, 'type of canvas', "(#{canvas_types})") do |v|
168
+ params.canvas = v.to_sym
169
+ end
170
+ end
171
+
172
+ def sub_parser_add_xlim
173
+ sub_parser.on_head('--xlim FLOAT,FLOAT', Array, 'plotting range for the x coordinate') do |v|
174
+ params.xlim = v
175
+ end
176
+ end
177
+
178
+ def sub_parser_add_ylim
179
+ sub_parser.on_head('--ylim FLOAT,FLOAT', Array, 'plotting range for the y coordinate') do |v|
180
+ params.ylim = v
181
+ end
182
+ end
183
+
184
+ def sub_parser_add_grid
185
+ sub_parser.on_head('--[no-]grid', TrueClass, 'draws grid-lines at the origin') do |v|
186
+ params.grid = v
187
+ end
188
+ end
189
+
190
+ def sub_parser_add_fmt_xyxy
191
+ sub_parser.on_head('--fmt STR', String,
192
+ 'xyxy : header is like x1, y1, x2, y2, x3, y3...',
193
+ 'xyy : header is like x, y1, y2, y2, y3...') do |v|
194
+ options[:fmt] = v
195
+ end
196
+ end
197
+
198
+ def sub_parser_add_fmt_yx
199
+ sub_parser.on_head('--fmt STR', String,
200
+ 'xy : header is like x, y...',
201
+ 'yx : header is like y, x...') do |v|
202
+ options[:fmt] = v
203
+ end
204
+ end
205
+
206
+ def create_sub_parser
207
+ @sub_parser = create_base_parser
208
+ sub_parser.banner = \
209
+ <<~MSG
210
+
211
+ Usage: YouPlot #{command} [options] <in.tsv>
212
+
213
+ Options for #{command}:
214
+ MSG
215
+
216
+ case command
217
+
218
+ # If you type only `uplot` in the terminal.
219
+ when nil
220
+ warn main_parser.banner
221
+ warn "\n"
222
+ exit 1 if YouPlot.run_as_executable?
223
+
224
+ when :barplot, :bar
225
+ sub_parser_add_symbol
226
+ sub_parser_add_fmt_yx
227
+ sub_parser_add_xscale
228
+
229
+ when :count, :c
230
+ sub_parser.on_head('-r', '--reverse', TrueClass, 'reverse the result of comparisons') do |v|
231
+ options.reverse = v
232
+ end
233
+ sub_parser_add_symbol
234
+ sub_parser_add_xscale
235
+
236
+ when :histogram, :hist
237
+ sub_parser_add_symbol
238
+ sub_parser.on_head('--closed STR', String, 'side of the intervals to be closed [left]') do |v|
239
+ params.closed = v
240
+ end
241
+ sub_parser.on_head('-n', '--nbins INT', Numeric, 'approximate number of bins') do |v|
242
+ params.nbins = v
243
+ end
244
+
245
+ when :lineplot, :line
246
+ sub_parser_add_canvas
247
+ sub_parser_add_grid
248
+ sub_parser_add_fmt_yx
249
+ sub_parser_add_ylim
250
+ sub_parser_add_xlim
251
+
252
+ when :lineplots, :lines
253
+ sub_parser_add_canvas
254
+ sub_parser_add_grid
255
+ sub_parser_add_fmt_xyxy
256
+ sub_parser_add_ylim
257
+ sub_parser_add_xlim
258
+
259
+ when :scatter, :s
260
+ sub_parser_add_canvas
261
+ sub_parser_add_grid
262
+ sub_parser_add_fmt_xyxy
263
+ sub_parser_add_ylim
264
+ sub_parser_add_xlim
265
+
266
+ when :density, :d
267
+ sub_parser_add_canvas
268
+ sub_parser_add_grid
269
+ sub_parser_add_fmt_xyxy
270
+ sub_parser_add_ylim
271
+ sub_parser_add_xlim
272
+
273
+ when :boxplot, :box
274
+ sub_parser_add_xlim
275
+
276
+ when :colors, :color, :colours, :colour
277
+ sub_parser.on_head('-n', '--names', TrueClass, 'show color names only') do |v|
278
+ options[:color_names] = v
279
+ end
280
+
281
+ else
282
+ error_message = "uplot: unrecognized command '#{command}'"
283
+ if YouPlot.run_as_executable?
284
+ warn error_message
285
+ exit 1
286
+ else
287
+ raise Error, error_message
288
+ end
289
+ end
290
+ end
291
+
292
+ def parse_options(argv = ARGV)
293
+ begin
294
+ create_main_parser.order!(argv)
295
+ rescue OptionParser::ParseError => e
296
+ warn "uplot: #{e.message}"
297
+ exit 1 if YouPlot.run_as_executable?
298
+ end
299
+
300
+ @command = argv.shift&.to_sym
301
+
302
+ begin
303
+ create_sub_parser&.parse!(argv)
304
+ rescue OptionParser::ParseError => e
305
+ warn "uplot: #{e.message}"
306
+ exit 1 if YouPlot.run_as_executable?
307
+ end
308
+ end
309
+ end
310
+ end