tchart 1.0.2

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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/bin/tchart +5 -0
  3. data/lib/tchart.rb +27 -0
  4. data/lib/tchart/lang/tchart_error.rb +9 -0
  5. data/lib/tchart/model/bar.rb +24 -0
  6. data/lib/tchart/model/chart.rb +25 -0
  7. data/lib/tchart/model/command_line_args.rb +18 -0
  8. data/lib/tchart/model/coordinate.rb +29 -0
  9. data/lib/tchart/model/grid_line.rb +25 -0
  10. data/lib/tchart/model/label.rb +43 -0
  11. data/lib/tchart/model/layout.rb +85 -0
  12. data/lib/tchart/model/separator.rb +28 -0
  13. data/lib/tchart/model/settings.rb +68 -0
  14. data/lib/tchart/model/y_item.rb +44 -0
  15. data/lib/tchart/process/chart_builder.rb +63 -0
  16. data/lib/tchart/process/command_line_parser.rb +73 -0
  17. data/lib/tchart/process/data_parser.rb +76 -0
  18. data/lib/tchart/process/data_reader.rb +14 -0
  19. data/lib/tchart/process/items_parser.rb +156 -0
  20. data/lib/tchart/process/layout_builder.rb +106 -0
  21. data/lib/tchart/process/settings_parser.rb +63 -0
  22. data/lib/tchart/process/tex_builder.rb +85 -0
  23. data/lib/tchart/process/tex_writer.rb +13 -0
  24. data/lib/tchart/version.rb +3 -0
  25. data/test/integration_test.rb +82 -0
  26. data/test/tchart/model/bar_test.rb +17 -0
  27. data/test/tchart/model/coordinate_test.rb +10 -0
  28. data/test/tchart/model/grid_line_test.rb +17 -0
  29. data/test/tchart/model/label_test.rb +31 -0
  30. data/test/tchart/model/layout_test.rb +17 -0
  31. data/test/tchart/model/separator_test.rb +19 -0
  32. data/test/tchart/model/settings_test.rb +48 -0
  33. data/test/tchart/model/y_item_test.rb +24 -0
  34. data/test/tchart/process/chart_builder_test.rb +33 -0
  35. data/test/tchart/process/command_line_parser_test.rb +89 -0
  36. data/test/tchart/process/data_parser_test.rb +60 -0
  37. data/test/tchart/process/data_reader_test.rb +23 -0
  38. data/test/tchart/process/items_parser_test.rb +154 -0
  39. data/test/tchart/process/layout_builder_test.rb +189 -0
  40. data/test/tchart/process/settings_parser_test.rb +75 -0
  41. data/test/tchart/process/tex_builder_test.rb +120 -0
  42. data/test/tchart/process/tex_writer_test.rb +38 -0
  43. data/test/tchart_test.rb +47 -0
  44. metadata +154 -0
@@ -0,0 +1,63 @@
1
+ module TChart
2
+
3
+ #
4
+ # Responsible for constructing a Chart from a Layout and a collection
5
+ # of items (Separators and YItems).
6
+ #
7
+ class ChartBuilder
8
+
9
+ def self.build(layout, items) # => Chart
10
+ ChartBuilder.new(layout, items).build
11
+ end
12
+
13
+ def initialize(layout, items)
14
+ @layout = layout
15
+ @items = items
16
+ @elements = []
17
+ end
18
+
19
+ def build # => Chart
20
+ build_frame
21
+ build_x_items
22
+ build_y_items
23
+ Chart.new(@elements)
24
+ end
25
+
26
+ private
27
+
28
+ def build_frame
29
+ @elements << new_frame_top
30
+ @elements << new_frame_bottom
31
+ end
32
+
33
+ def new_frame_top # => GridLine
34
+ GridLine.new(xy(0, @layout.y_axis_length), xy(@layout.x_axis_length, @layout.y_axis_length))
35
+ end
36
+
37
+ def new_frame_bottom # => GridLine
38
+ GridLine.new(xy(0, 0), xy(@layout.x_axis_length, 0))
39
+ end
40
+
41
+ def build_x_items
42
+ @layout.x_axis_tick_dates.zip(@layout.x_axis_tick_x_coordinates).each do |date, x|
43
+ @elements << new_x_label(date.year, x)
44
+ @elements << new_x_gridline(x)
45
+ end
46
+ end
47
+
48
+ def new_x_label(year, x) # => Label
49
+ Label.build_xlabel(xy(x, @layout.x_axis_label_y_coordinate), @layout.x_axis_label_width, year.to_s)
50
+ end
51
+
52
+ def new_x_gridline(x) # => GridLine
53
+ GridLine.new(xy(x, 0), xy(x, @layout.y_axis_length))
54
+ end
55
+
56
+ def build_y_items
57
+ @items.zip(@layout.y_axis_tick_y_coordinates).each do |item, y|
58
+ @elements.concat item.build(@layout, y)
59
+ end
60
+ end
61
+
62
+ end
63
+ end
@@ -0,0 +1,73 @@
1
+ module TChart
2
+
3
+ #
4
+ # Responsible for parsing command line options and arguments.
5
+ #
6
+ class CommandLineParser
7
+
8
+ def self.parse(argv) # => [ CommandLineArgs, [] ] or [ nil, [ String, ... ] ]
9
+ CommandLineParser.new.parse(argv)
10
+ end
11
+
12
+ def parse(argv) # => [ CommandLineArgs, [] ] or [ nil, [ String, ... ] ]
13
+ parse_options(argv)
14
+ [ parse_args(argv), [] ]
15
+ rescue TChartError => e
16
+ [ nil, [ e.message ] ]
17
+ end
18
+
19
+ private
20
+
21
+ def parse_options(argv)
22
+ argv.map { |arg| arg.strip.downcase }.each do |arg|
23
+ case
24
+ when arg == "-h" || arg == "--help"
25
+ raise_usage
26
+ when arg == "-v" || arg == "--version"
27
+ raise_version
28
+ when arg.start_with?("-")
29
+ raise_usage
30
+ end
31
+ end
32
+ end
33
+
34
+ def parse_args(argv) # => CommandLineArgs
35
+ raise_usage unless argv.length == 2
36
+ data_filename, tex_filename = argv
37
+ raise_data_filename_not_found(data_filename) unless File.exists?(data_filename)
38
+ raise_data_filename_not_a_file(data_filename) unless File.file?(data_filename)
39
+ raise_tex_filename_not_a_file(tex_filename) if File.exists?(tex_filename) && ! File.file?(tex_filename)
40
+ raise_same_filename(data_filename, tex_filename) if same_file?(data_filename, tex_filename)
41
+ CommandLineArgs.new(data_filename, tex_filename)
42
+ end
43
+
44
+ def same_file?(filename1, filename2)
45
+ File.expand_path(filename1) == File.expand_path(filename2)
46
+ end
47
+
48
+ def raise_version
49
+ raise TChartError, TChart::Version
50
+ end
51
+
52
+ def raise_usage
53
+ raise TChartError, "Usage: tchart [ --version | --help | input-data-filename output-tikz-filename ]"
54
+ end
55
+
56
+ def raise_data_filename_not_found(data_filename)
57
+ raise TChartError, "Error: input data file \"#{data_filename}\" not found."
58
+ end
59
+
60
+ def raise_data_filename_not_a_file(data_filename)
61
+ raise TChartError, "Error: input data file \"#{data_filename}\" is not a file."
62
+ end
63
+
64
+ def raise_tex_filename_not_a_file(tex_filename)
65
+ raise TChartError, "Error: existing output data file \"#{tex_filename}\" is not a file."
66
+ end
67
+
68
+ def raise_same_filename(data_filename, tex_filename)
69
+ raise TChartError, "Error: input \"#{data_filename}\" and output \"#{tex_filename}\" refer to the same file."
70
+ end
71
+
72
+ end
73
+ end
@@ -0,0 +1,76 @@
1
+ module TChart
2
+
3
+ #
4
+ # Responsible for parsing source data. Not responsible for
5
+ # aquiring the source data, e.g. not responsible for reading
6
+ # the source data from an input file.
7
+ #
8
+ class DataParser
9
+
10
+ #
11
+ # source_name is used in error messages; for example, if the
12
+ # source data was read from a file, then source_name would be
13
+ # the name of that file.
14
+ #
15
+ def self.parse(source_name, source_data) # => [ settings, items, errors ]
16
+ DataParser.new(source_name, source_data).parse
17
+ end
18
+
19
+ def initialize(source_name, source_data)
20
+ @source_name = source_name
21
+ @source_data = source_data
22
+ @line_number = nil
23
+ @errors = []
24
+ @settings_parser = SettingsParser.new
25
+ @items_parser = ItemsParser.new
26
+ end
27
+
28
+ def parse # => [ settings, items, errors ]
29
+ non_blank_source_lines.each { |line| parse_line(line) }
30
+ check_for_items if @errors.empty?
31
+ [ @settings_parser.settings, @items_parser.items, @errors ]
32
+ end
33
+
34
+ private
35
+
36
+ #
37
+ # Return source data lines that are not empty after
38
+ # comments have been removed.
39
+ #
40
+ def non_blank_source_lines # => Enumerator
41
+ Enumerator.new do |yielder|
42
+ @source_data.each_with_index do |line, index|
43
+ @line_number = index + 1
44
+ line = remove_comments(line).strip
45
+ yielder.yield line if line.length > 0
46
+ end
47
+ end
48
+ end
49
+
50
+ #
51
+ # "item # A comment." => "item "
52
+ # "# A comment." => ""
53
+ # "C\# # A coment." => "C\# "
54
+ #
55
+ def remove_comments(line) # => line
56
+ line.sub(/(?<!\\)#.*$/, '')
57
+ end
58
+
59
+ def parse_line(line)
60
+ unless @settings_parser.parse?(line)
61
+ @items_parser.parse(line)
62
+ end
63
+ rescue TChartError => e
64
+ save_error "#{@source_name}, #{@line_number}: #{e.message}"
65
+ end
66
+
67
+ def check_for_items
68
+ save_error "#{@source_name}: no items found" if @items_parser.items.empty?
69
+ end
70
+
71
+ def save_error(message)
72
+ @errors << message
73
+ end
74
+
75
+ end
76
+ end
@@ -0,0 +1,14 @@
1
+ module TChart
2
+
3
+ #
4
+ # Responsible for reading source data from an input file
5
+ # and parsing it.
6
+ #
7
+ module DataReader
8
+
9
+ def self.read(filename) # => [ settings, items, errors ]
10
+ File.open(filename) { |f| DataParser.parse(filename, f) }
11
+ end
12
+
13
+ end
14
+ end
@@ -0,0 +1,156 @@
1
+ require 'date'
2
+
3
+ module TChart
4
+
5
+ #
6
+ # Responsible for parsing a line of source data that contains either
7
+ # a separator or a data item. Also responsible for accumulating
8
+ # parsed items.
9
+ #
10
+ class ItemsParser
11
+
12
+ #
13
+ # The collection of parsed items, where an item is either an instance
14
+ # of YItem or of Separator. Starts empty and fills up with each
15
+ # successive call to #parse.
16
+ #
17
+ attr_reader :items
18
+
19
+ def initialize
20
+ @items = []
21
+ end
22
+
23
+ def parse(line)
24
+ description, style, *date_range_strings = extract_fields(line)
25
+ raise_description_missing if description.nil?
26
+ raise_style_missing if style.nil? && date_range_strings.length > 0
27
+ if description.start_with?("---")
28
+ parse_separator
29
+ else
30
+ parse_y_item(description, style, date_range_strings)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def parse_y_item(description, style, date_range_strings)
37
+ date_ranges = parse_date_ranges(date_range_strings)
38
+ check_for_overlaps(date_ranges)
39
+ save_item YItem.new(description, style, date_ranges)
40
+ end
41
+
42
+ def parse_separator
43
+ save_item Separator.new
44
+ end
45
+
46
+ def save_item(item)
47
+ @items << item
48
+ end
49
+
50
+ #
51
+ # 'a|b\|c|d | | e | \n' => [ 'a', 'b|c', 'd', nil, 'e' ]
52
+ #
53
+ def extract_fields(line) # => [ String, ... ]
54
+ line # => 'a|b\|c|d | | e | ' + "\n"
55
+ .sub( /(\s|\|)+$/, '' ) # => 'a|b\|c|d | | e'
56
+ .split( /(?<!\\)\|/ ) # => [ 'a', 'b\|c', 'd ', ' ', ' e' ]
57
+ .map { |field| remove_escapes(field) } # => [ 'a', 'b|c', 'd', ' ', 'e' ]
58
+ .map { |field| field.strip } # => [ 'a', 'b|c', 'd', '', 'e' ]
59
+ .map { |field| field.empty? ? nil : field } # => [ 'a', 'b|c', 'd', nil, 'e' ]
60
+ end
61
+
62
+ #
63
+ # '\a\\b' => 'a\b'
64
+ #
65
+ def remove_escapes(line) # => String
66
+ line.gsub(/\\(.)/, '\1')
67
+ end
68
+
69
+ def parse_date_ranges(date_range_strings) # => [ Date..Date, ... ]
70
+ date_range_strings.map { |date_range_string| parse_date_range(date_range_string) }
71
+ end
72
+
73
+ def parse_date_range(date_range_string) # => Date..Date
74
+ y1, m1, d1, y2, m2, d2 = fill_in_missing_date_elements( * match_date_range(date_range_string) )
75
+ date_begin = build_date(y1.to_i, m1.to_i, d1.to_i)
76
+ date_end = build_date(y2.to_i, m2.to_i, d2.to_i)
77
+ raise_date_range_reversed(date_begin, date_end) if date_end < date_begin
78
+ date_begin..date_end
79
+ end
80
+
81
+ def build_date(year, month, day) # => Date
82
+ Date.new(year, month, day)
83
+ rescue ArgumentError => e
84
+ raise_invalid_date(year, month, day, e.message)
85
+ end
86
+
87
+ #
88
+ # Matches d, d-d, or d - d, where d can be: y, y.m, or y.m.d
89
+ # Examples: 2000, 2000-2001, 2000-2001.8, 2000.4.17 - 2001
90
+ #
91
+ def match_date_range(date_range_string) # => y1, m1, d1, y2, m2, d2
92
+ m = /^(\d+)(\.(\d+)(\.(\d+))?)?(\s*-\s*(\d+)(\.(\d+)(\.(\d+))?)?)?$/.match(date_range_string)
93
+ raise_invalid_date_range(date_range_string) if not m
94
+ [ m[1], m[3], m[5], m[7], m[9], m[11] ]
95
+ end
96
+
97
+ def fill_in_missing_date_elements(y1, m1, d1, y2, m2, d2) # => y1, m1, d1, y2, m2, d2
98
+ [ y1, m1 || 1, d1 || 1,
99
+ y2 || y1, m2 || 12, d2 || -1 ]
100
+ end
101
+
102
+ def check_for_overlaps(date_ranges)
103
+ first, *rest = date_ranges
104
+ return if rest.empty?
105
+ rest.each { |range| raise_date_ranges_overlap(first, range) if overlap?(first, range) }
106
+ check_for_overlaps(rest)
107
+ end
108
+
109
+ def overlap?(range1, range2)
110
+ range1.include?(range2.first) or range2.include?(range1.first)
111
+ end
112
+
113
+ def raise_description_missing
114
+ raise TChartError, "description is missing"
115
+ end
116
+
117
+ def raise_style_missing
118
+ raise TChartError, "style is missing"
119
+ end
120
+
121
+ def raise_invalid_date(year, month, day, message)
122
+ raise TChartError, "#{year}.#{month}.#{day}: #{message}"
123
+ end
124
+
125
+ def raise_invalid_date_range(date_range_as_string)
126
+ raise TChartError, "bad date range \"#{date_range_as_string}\"; expecting 2000.4.17-2001.7.21 | 2002.4-2003, etc."
127
+ end
128
+
129
+ def raise_date_range_reversed(date_begin, date_end)
130
+ raise TChartError, "date range end #{d2s(date_end)} before start #{d2s(date_begin)}"
131
+ end
132
+
133
+ def raise_date_ranges_overlap(range1, range2)
134
+ raise TChartError, "date range #{dr2s(range1)} overlaps #{dr2s(range2)}"
135
+ end
136
+
137
+ #
138
+ # 'd2s' means 'date to string'
139
+ #
140
+ # 2001.3.17 => "2001.3.17"
141
+ #
142
+ def d2s(date) # => String
143
+ date.strftime('%Y.%-m.%-d')
144
+ end
145
+
146
+ #
147
+ # 'dr2s' means 'date range to string'
148
+ #
149
+ # 2003.3.17..2007.11.2 => "2003.3.17-2007.11.2"
150
+ #
151
+ def dr2s(date_range) # => String
152
+ "#{d2s(date_range.begin)}-#{d2s(date_range.end)}"
153
+ end
154
+
155
+ end
156
+ end
@@ -0,0 +1,106 @@
1
+ module TChart
2
+
3
+ #
4
+ # Responsible for calculating chart metrics and storing them
5
+ # in an instance of Layout. Metrics depend on chart settings
6
+ # and the number and dates ranges of the items being plotted.
7
+ #
8
+ class LayoutBuilder
9
+
10
+ def self.build(settings, items) # => [ layout, errors ]
11
+ layout = build_layout(settings, items)
12
+ errors = check_layout(layout)
13
+ [ layout, errors ]
14
+ end
15
+
16
+ private
17
+
18
+ def self.build_layout(settings, items) # => Layout
19
+ layout = Layout.new
20
+ layout.x_axis_tick_dates = calc_x_axis_tick_dates( *calc_items_date_range(items) )
21
+ layout.x_axis_length = calc_x_axis_length(settings)
22
+ layout.x_axis_tick_x_coordinates = calc_x_axis_tick_x_coordinates(layout.x_axis_tick_dates, layout.x_axis_length)
23
+ layout.y_axis_length = calc_y_axis_length(settings, items)
24
+ layout.y_axis_label_x_coordinate = calc_y_axis_label_x_coordinate(settings)
25
+ layout.y_axis_tick_y_coordinates = calc_y_axis_tick_y_coordinates(settings, items)
26
+ layout.x_axis_label_y_coordinate = settings.x_axis_label_y_coordinate
27
+ layout.x_axis_label_width = settings.x_axis_label_width
28
+ layout.y_axis_label_width = settings.y_axis_label_width
29
+ layout
30
+ end
31
+
32
+ def self.check_layout(layout) # => [ String, String, ... ]
33
+ errors = []
34
+ errors << plot_area_too_narrow_error(layout.x_axis_length) if layout.x_axis_length < 1
35
+ errors
36
+ end
37
+
38
+ def self.plot_area_too_narrow_error(x_axis_length)
39
+ "plot area is too narrow (#{x_axis_length}, min is 1); is chart_width too small, or x_axis_label_width or y_axis_label_width too large?"
40
+ end
41
+
42
+ def self.calc_items_date_range(items) # [ Date, Date ]
43
+ earliest, latest = nil, nil
44
+ items.each do |item|
45
+ item.date_ranges.each do |date_range|
46
+ earliest = date_range.begin if earliest.nil? or date_range.begin < earliest
47
+ latest = date_range.end if latest.nil? or latest < date_range.end
48
+ end
49
+ end
50
+ current_year = Date.today.year
51
+ earliest ||= Date.new(current_year, 1, 1)
52
+ latest ||= Date.new(current_year, 12, 31)
53
+ [earliest, latest]
54
+ end
55
+
56
+ def self.calc_x_axis_tick_dates(earliest, latest) # => [ Date, Date, ... ]
57
+ # if ten or fewer years to cover, create a tick every year
58
+ from_year = earliest.year # round down to Jan 1st of year
59
+ to_year = latest.year + 1 # +1 to round up to Jan 1st of the following year
60
+ return make_tick_dates(from_year, to_year, 1) if to_year - from_year <= 10
61
+
62
+ # if fifty or fewer years to cover, create a tick every five years
63
+ from_year = (from_year / 5.0).floor * 5 # round down to nearest 1/2 decade
64
+ to_year = (to_year / 5.0).ceil * 5 # round up to nearest 1/2 decade
65
+ return make_tick_dates(from_year, to_year, 5) if to_year - from_year <= 50
66
+
67
+ # create a tick every ten years
68
+ from_year = (from_year / 10.0).floor * 10 # round down to nearest decade
69
+ to_year = (to_year / 10.0).ceil * 10 # round up to nearest decade
70
+ return make_tick_dates(from_year, to_year, 10)
71
+ end
72
+
73
+ def self.make_tick_dates(from_year, to_year, interval) # => [ Date, Date, ... ]
74
+ (from_year..to_year).step(interval).map { |year| Date.new(year,1,1) }
75
+ end
76
+
77
+ def self.calc_x_axis_tick_x_coordinates(x_axis_tick_dates, x_axis_length) # => [ Numeric, Numeric, ... ]
78
+ num_coords = x_axis_tick_dates.size
79
+ x_interval = x_axis_length / (num_coords - 1.0)
80
+ (0..x_axis_length).step(x_interval).to_a
81
+ end
82
+
83
+ #
84
+ # 1/2 x_axis_label_width for the left margin, and 1/2 x_axis_label_width for the right margin
85
+ #
86
+ def self.calc_x_axis_length(settings) # => Numeric
87
+ settings.chart_width - settings.y_axis_label_width - settings.x_axis_label_width
88
+ end
89
+
90
+ #
91
+ # +1 for top and bottom margins, each of which is half the line height
92
+ #
93
+ def self.calc_y_axis_length(settings, items) # => Numeric
94
+ (items.length + 1) * settings.line_height
95
+ end
96
+
97
+ def self.calc_y_axis_label_x_coordinate(settings) # => Numeric
98
+ 0 - ((settings.y_axis_label_width / 2.0) + (settings.x_axis_label_width / 2.0))
99
+ end
100
+
101
+ def self.calc_y_axis_tick_y_coordinates(settings, items) # => [ Numeric, Numeric, ... ]
102
+ (settings.line_height * items.length).step(settings.line_height, -settings.line_height).to_a
103
+ end
104
+
105
+ end
106
+ end