youplot 0.3.1 → 0.4.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.
@@ -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