postrunner 0.0.2 → 0.0.3

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,18 +1,22 @@
1
1
  require 'fit4ruby'
2
- require 'postrunner/RuntimeConfig'
2
+
3
+ require 'postrunner/ActivityReport'
4
+ require 'postrunner/ActivityView'
3
5
 
4
6
  module PostRunner
5
7
 
6
8
  class Activity
7
9
 
8
- attr_reader :fit_file
9
- attr_accessor :name
10
+ attr_reader :fit_file, :name, :fit_activity
11
+ attr_accessor :db
10
12
 
11
13
  # This is a list of variables that provide data from the fit file. To
12
14
  # speed up access to it, we cache the data in the activity database.
13
- @@CachedVariables = %w( start_time distance duration avg_speed )
15
+ @@CachedVariables = %w( timestamp total_distance total_timer_time
16
+ avg_speed )
14
17
 
15
- def initialize(fit_file, fit_activity, name = nil)
18
+ def initialize(db, fit_file, fit_activity, name = nil)
19
+ @db = db
16
20
  @fit_file = fit_file
17
21
  @fit_activity = fit_activity
18
22
  @name = name || fit_file
@@ -24,6 +28,15 @@ module PostRunner
24
28
  end
25
29
  end
26
30
 
31
+ def check
32
+ @fit_activity = load_fit_file
33
+ Log.info "FIT file #{@fit_file} is OK"
34
+ end
35
+
36
+ def dump(filter)
37
+ @fit_activity = load_fit_file(filter)
38
+ end
39
+
27
40
  def yaml_initialize(tag, value)
28
41
  # Create attr_readers for cached variables.
29
42
  @@CachedVariables.each { |v| self.class.send(:attr_reader, v.to_sym) }
@@ -37,7 +50,7 @@ module PostRunner
37
50
  end
38
51
 
39
52
  def encode_with(coder)
40
- attr_ignore = %w( @fit_activity )
53
+ attr_ignore = %w( @db @fit_activity )
41
54
 
42
55
  instance_variables.each do |v|
43
56
  v = v.to_s
@@ -47,15 +60,45 @@ module PostRunner
47
60
  end
48
61
  end
49
62
 
50
- def method_missing(method_name, *args, &block)
51
- fit_file = File.join(Config['fit_dir'], @fit_file)
63
+ def show
64
+ @fit_activity = load_fit_file unless @fit_activity
65
+ view = ActivityView.new(self, File.join(@db.db_dir, 'html'))
66
+ #view = TrackView.new(self, '../../html')
67
+ #view.generate_html
68
+ #chart = ChartView.new(self, '../../html')
69
+ #chart.generate_html
70
+ end
71
+
72
+ def summary
73
+ @fit_activity = load_fit_file unless @fit_activity
74
+ puts ActivityReport.new(@fit_activity).to_s
75
+ end
76
+
77
+ def rename(name)
78
+ @name = name
79
+ end
80
+
81
+ def register_records(db)
82
+ @fit_activity.personal_records.each do |r|
83
+ if r.longest_distance == 1
84
+ # In case longest_distance is 1 the distance is stored in the
85
+ # duration field in 10-th of meters.
86
+ db.register_result(r.duration * 10.0 , 0, r.start_time, @fit_file)
87
+ else
88
+ db.register_result(r.distance, r.duration, r.start_time, @fit_file)
89
+ end
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def load_fit_file(filter = nil)
96
+ fit_file = File.join(@db.fit_dir, @fit_file)
52
97
  begin
53
- @fit_activity = Fit4Ruby.read(fit_file) unless @fit_activity
98
+ return Fit4Ruby.read(fit_file, filter)
54
99
  rescue Fit4Ruby::Error
55
- Log.error "Cannot read #{fit_file}: #{$!}"
56
- return false
100
+ Log.fatal $!
57
101
  end
58
- @fit_activity.send(method_name, *args, &block)
59
102
  end
60
103
 
61
104
  end
@@ -0,0 +1,102 @@
1
+ require 'fit4ruby'
2
+
3
+ require 'postrunner/FlexiTable'
4
+ require 'postrunner/ViewWidgets'
5
+
6
+ module PostRunner
7
+
8
+ class ActivityReport
9
+
10
+ include Fit4Ruby::Converters
11
+ include ViewWidgets
12
+
13
+ def initialize(activity)
14
+ @activity = activity
15
+ end
16
+
17
+ def to_s
18
+ session = @activity.sessions[0]
19
+
20
+ summary(session).to_s + "\n" + laps.to_s
21
+ end
22
+
23
+ def to_html(doc)
24
+ session = @activity.sessions[0]
25
+
26
+ frame(doc, 'Summary') {
27
+ summary(session).to_html(doc)
28
+ }
29
+ frame(doc, 'Laps') {
30
+ laps.to_html(doc)
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def summary(session)
37
+ t = FlexiTable.new
38
+ t.enable_frame(false)
39
+ t.body
40
+ t.row([ 'Date:', session.timestamp])
41
+ t.row([ 'Distance:', "#{'%.2f' % (session.total_distance / 1000.0)} km" ])
42
+ t.row([ 'Time:', secsToHMS(session.total_timer_time) ])
43
+ t.row([ 'Avg. Pace:',
44
+ "#{speedToPace(session.avg_speed)} min/km" ])
45
+ t.row([ 'Total Ascend:', "#{session.total_ascend} m" ])
46
+ t.row([ 'Total Descend:', "#{session.total_descent} m" ])
47
+ t.row([ 'Calories:', "#{session.total_calories} kCal" ])
48
+ t.row([ 'Avg. HR:', session.avg_heart_rate ?
49
+ "#{session.avg_heart_rate} bpm" : '-' ])
50
+ t.row([ 'Max. HR:', session.max_heart_rate ?
51
+ "#{session.max_heart_rate} bpm" : '-' ])
52
+ t.row([ 'Training Effect:', session.total_training_effect ?
53
+ session.total_training_effect : '-' ])
54
+ t.row([ 'Avg. Run Cadence:',
55
+ session.avg_running_cadence ?
56
+ "#{session.avg_running_cadence.round} spm" : '-' ])
57
+ t.row([ 'Avg. Vertical Oscillation:',
58
+ session.avg_vertical_oscillation ?
59
+ "#{'%.1f' % (session.avg_vertical_oscillation / 10)} cm" : '-' ])
60
+ t.row([ 'Avg. Ground Contact Time:',
61
+ session.avg_stance_time ?
62
+ "#{session.avg_stance_time.round} ms" : '-' ])
63
+ t.row([ 'Avg. Stride Length:',
64
+ session.avg_stride_length ?
65
+ "#{'%.2f' % (session.avg_stride_length / 2)} m" : '-' ])
66
+ rec_time = @activity.recovery_time
67
+ t.row([ 'Recovery Time:', rec_time ? secsToHMS(rec_time * 60) : '-' ])
68
+ vo2max = @activity.vo2max
69
+ t.row([ 'VO2max:', vo2max ? vo2max : '-' ])
70
+
71
+ t
72
+ end
73
+
74
+ def laps
75
+ t = FlexiTable.new
76
+ t.head
77
+ t.row([ 'Lap', 'Duration', 'Distance', 'Avg. Pace', 'Stride', 'Cadence',
78
+ 'Avg. HR', 'Max. HR' ])
79
+ t.set_column_attributes(Array.new(8, { :halign => :right }))
80
+ t.body
81
+ @activity.sessions[0].laps.each.with_index do |lap, index|
82
+ t.cell(index + 1)
83
+ t.cell(secsToHMS(lap.total_timer_time))
84
+ t.cell('%.2f' % (lap.total_distance / 1000.0))
85
+ t.cell(speedToPace(lap.avg_speed))
86
+ t.cell(lap.total_strides ?
87
+ '%.2f' % (lap.total_distance / (2 * lap.total_strides)) : '')
88
+ t.cell(lap.avg_running_cadence && lap.avg_fractional_cadence ?
89
+ '%.1f' % (2 * lap.avg_running_cadence +
90
+ (2 * lap.avg_fractional_cadence) / 100.0) : '')
91
+ t.cell(lap.avg_heart_rate.to_s)
92
+ t.cell(lap.max_heart_rate.to_s)
93
+ t.new_row
94
+ end
95
+
96
+ t
97
+ end
98
+
99
+ end
100
+
101
+ end
102
+
@@ -0,0 +1,133 @@
1
+ require 'fit4ruby'
2
+
3
+ require 'postrunner/HTMLBuilder'
4
+ require 'postrunner/ActivityReport'
5
+ require 'postrunner/ViewWidgets'
6
+ require 'postrunner/TrackView'
7
+ require 'postrunner/ChartView'
8
+
9
+ module PostRunner
10
+
11
+ class ActivityView
12
+
13
+ include ViewWidgets
14
+
15
+ def initialize(activity, output_dir)
16
+ @activity = activity
17
+ @output_dir = output_dir
18
+ @output_file = nil
19
+
20
+ ensure_output_dir
21
+
22
+ @doc = HTMLBuilder.new
23
+ generate_html(@doc)
24
+ write_file
25
+ show_in_browser
26
+ end
27
+
28
+ private
29
+
30
+ def ensure_output_dir
31
+ unless Dir.exists?(@output_dir)
32
+ begin
33
+ Dir.mkdir(@output_dir)
34
+ rescue SystemCallError
35
+ Log.fatal "Cannot create output directory '#{@output_dir}': #{$!}"
36
+ end
37
+ end
38
+ end
39
+
40
+ def generate_html(doc)
41
+ @report = ActivityReport.new(@activity.fit_activity)
42
+ @track_view = TrackView.new(@activity)
43
+ @chart_view = ChartView.new(@activity)
44
+
45
+ doc.html {
46
+ head(doc)
47
+ body(doc)
48
+ }
49
+ end
50
+
51
+ def head(doc)
52
+ doc.head {
53
+ doc.meta({ 'http-equiv' => 'Content-Type',
54
+ 'content' => 'text/html; charset=utf-8' })
55
+ doc.meta({ 'name' => 'viewport',
56
+ 'content' => 'width=device-width, ' +
57
+ 'initial-scale=1.0, maximum-scale=1.0, ' +
58
+ 'user-scalable=0' })
59
+ doc.title("PostRunner Activity: #{@activity.name}")
60
+ style(doc)
61
+ view_widgets_style(doc)
62
+ @chart_view.head(doc)
63
+ @track_view.head(doc)
64
+ }
65
+ end
66
+
67
+ def style(doc)
68
+ doc.style(<<EOT
69
+ .main {
70
+ width: 1210px;
71
+ margin: 0 auto;
72
+ }
73
+ .left_col {
74
+ float: left;
75
+ width: 400px;
76
+ }
77
+ .right_col {
78
+ float: right;
79
+ width: 600px;
80
+ }
81
+ .widget_container {
82
+ box-sizing: border-box;
83
+ width: 600px;
84
+ padding: 10px 15px 15px 15px;
85
+ margin: 15px auto 15px auto;
86
+ border: 1px solid #ddd;
87
+ background: #fff;
88
+ background: linear-gradient(#f6f6f6 0, #fff 50px);
89
+ background: -o-linear-gradient(#f6f6f6 0, #fff 50px);
90
+ background: -ms-linear-gradient(#f6f6f6 0, #fff 50px);
91
+ background: -moz-linear-gradient(#f6f6f6 0, #fff 50px);
92
+ background: -webkit-linear-gradient(#f6f6f6 0, #fff 50px);
93
+ box-shadow: 0 3px 10px rgba(0,0,0,0.15);
94
+ -o-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
95
+ -ms-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
96
+ -moz-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
97
+ -webkit-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
98
+ }
99
+ EOT
100
+ )
101
+ end
102
+
103
+ def body(doc)
104
+ doc.body({ 'onload' => 'init()' }) {
105
+ doc.div({ 'class' => 'main' }) {
106
+ doc.div({ 'class' => 'left_col' }) {
107
+ @report.to_html(doc)
108
+ @track_view.div(doc)
109
+ }
110
+ doc.div({ 'class' => 'right_col' }) {
111
+ @chart_view.div(doc)
112
+ }
113
+ }
114
+ }
115
+ end
116
+
117
+ def write_file
118
+ @output_file = File.join(@output_dir, "#{@activity.fit_file[0..-5]}.html")
119
+ begin
120
+ File.write(@output_file, @doc.to_html)
121
+ rescue IOError
122
+ Log.fatal "Cannot write activity view file '#{@output_file}: #{$!}"
123
+ end
124
+ end
125
+
126
+ def show_in_browser
127
+ system("firefox \"#{@output_file}\" &")
128
+ end
129
+
130
+ end
131
+
132
+ end
133
+
@@ -0,0 +1,195 @@
1
+ require 'postrunner/ViewWidgets'
2
+
3
+ module PostRunner
4
+
5
+ class ChartView
6
+
7
+ include ViewWidgets
8
+
9
+ def initialize(activity)
10
+ @activity = activity
11
+ end
12
+
13
+ def head(doc)
14
+ [ 'jquery/jquery-2.1.1.min.js', 'flot/jquery.flot.js',
15
+ 'flot/jquery.flot.time.js' ].each do |js|
16
+ doc.script({ 'language' => 'javascript', 'type' => 'text/javascript',
17
+ 'src' => js })
18
+ end
19
+ doc.style(style)
20
+ doc.script(java_script)
21
+ end
22
+
23
+ def div(doc)
24
+ chart_div(doc, 'pace', 'Pace (min/km)')
25
+ chart_div(doc, 'altitude', 'Elevation (m)')
26
+ chart_div(doc, 'heart_rate', 'Heart Rate (bpm)')
27
+ chart_div(doc, 'cadence', 'Run Cadence (spm)')
28
+ chart_div(doc, 'vertical_oscillation', 'Vertical Oscillation (cm)')
29
+ chart_div(doc, 'stance_time', 'Ground Contact Time (ms)')
30
+ end
31
+
32
+ private
33
+
34
+ def style
35
+ <<EOT
36
+ .chart-container {
37
+ box-sizing: border-box;
38
+ width: 600px;
39
+ height: 200px;
40
+ padding: 10px 15px 15px 15px;
41
+ margin: 15px auto 15px auto;
42
+ border: 1px solid #ddd;
43
+ background: #fff;
44
+ background: linear-gradient(#f6f6f6 0, #fff 50px);
45
+ background: -o-linear-gradient(#f6f6f6 0, #fff 50px);
46
+ background: -ms-linear-gradient(#f6f6f6 0, #fff 50px);
47
+ background: -moz-linear-gradient(#f6f6f6 0, #fff 50px);
48
+ background: -webkit-linear-gradient(#f6f6f6 0, #fff 50px);
49
+ box-shadow: 0 3px 10px rgba(0,0,0,0.15);
50
+ -o-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
51
+ -ms-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
52
+ -moz-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
53
+ -webkit-box-shadow: 0 3px 10px rgba(0,0,0,0.1);
54
+ }
55
+ .chart-placeholder {
56
+ width: 570px;
57
+ height: 200px;
58
+ font-size: 14px;
59
+ line-height: 1.2em;
60
+ }
61
+ EOT
62
+ end
63
+
64
+ def java_script
65
+ s = "$(function() {\n"
66
+
67
+ s << line_graph('pace', '#0A7BEE' )
68
+ s << line_graph('altitude', '#5AAA44')
69
+ s << line_graph('heart_rate', '#900000')
70
+ s << point_graph('cadence',
71
+ [ [ '#EE3F2D', 151 ],
72
+ [ '#F79666', 163 ],
73
+ [ '#A0D488', 174 ],
74
+ [ '#96D7DE', 185 ],
75
+ [ '#A88BBB', nil ] ], 2)
76
+ s << point_graph('vertical_oscillation',
77
+ [ [ '#A88BBB', 6.7 ],
78
+ [ '#96D7DE', 8.4 ],
79
+ [ '#A0D488', 10.1 ],
80
+ [ '#F79666', 11.8 ],
81
+ [ '#EE3F2D', nil ] ], 0.1)
82
+ s << point_graph('stance_time',
83
+ [ [ '#A88BBB', 208 ],
84
+ [ '#96D7DE', 241 ],
85
+ [ '#A0D488', 273 ],
86
+ [ '#F79666', 305 ],
87
+ [ '#EE3F2D', nil ] ])
88
+
89
+ s << "\n});\n"
90
+
91
+ s
92
+ end
93
+
94
+ def line_graph(field, color = nil)
95
+ s = "var #{field}_data = [\n"
96
+
97
+ first = true
98
+ start_time = @activity.fit_activity.sessions[0].start_time.to_i
99
+ @activity.fit_activity.records.each do |r|
100
+ if first
101
+ first = false
102
+ else
103
+ s << ', '
104
+ end
105
+ value = r.send(field)
106
+ if field == 'pace'
107
+ if value > 20.0
108
+ value = nil
109
+ else
110
+ value = (value * 3600.0 * 1000).to_i
111
+ end
112
+ end
113
+ s << "[ #{((r.timestamp.to_i - start_time) * 1000).to_i}, " +
114
+ "#{value ? value : 'null'} ]"
115
+ end
116
+
117
+ s << <<"EOT"
118
+ ];
119
+
120
+ $.plot("##{field}_chart",
121
+ [ { data: #{field}_data,
122
+ #{color ? "color: \"#{color}\"," : ''}
123
+ lines: { show: true#{field == 'pace' ? '' : ', fill: true'} } } ],
124
+ { xaxis: { mode: "time" }
125
+ EOT
126
+ if field == 'pace'
127
+ s << ", yaxis: { mode: \"time\",\n" +
128
+ " transform: function (v) { return -v; },\n" +
129
+ " inverseTransform: function (v) { return -v; } }"
130
+ end
131
+ s << "});\n"
132
+ end
133
+
134
+ def point_graph(field, colors, multiplier = 1)
135
+ # We need to split the field values into separate data sets for each
136
+ # color. The max value for each color determines which set a data point
137
+ # ends up in.
138
+ # Initialize the data sets. The key for data_sets is the corresponding
139
+ # index in colors.
140
+ data_sets = {}
141
+ colors.each.with_index { |cp, i| data_sets[i] = [] }
142
+
143
+ # Now we can split the field values into the sets.
144
+ start_time = @activity.fit_activity.sessions[0].start_time.to_i
145
+ @activity.fit_activity.records.each do |r|
146
+ # Undefined values will be discarded.
147
+ next unless (value = r.instance_variable_get('@' + field))
148
+ value *= multiplier
149
+
150
+ # Find the right set by looking at the maximum allowed values for each
151
+ # color.
152
+ colors.each.with_index do |col_max_value, i|
153
+ col, max_value = col_max_value
154
+ if max_value.nil? || value < max_value
155
+ # A max_value of nil means all values allowed. The value is in the
156
+ # allowed range for this set, so add the value as x/y pair to the
157
+ # set.
158
+ x_val = (r.timestamp.to_i - start_time) * 1000
159
+ data_sets[i] << [ x_val, value ]
160
+ # Abort the color loop since we've found the right set already.
161
+ break
162
+ end
163
+ end
164
+ end
165
+
166
+ # Now generate the JS variable definitions for each set.
167
+ s = ''
168
+ data_sets.each do |index, ds|
169
+ s << "var #{field}_data_#{index} = [\n"
170
+ s << ds.map { |dp| "[ #{dp[0]}, #{dp[1]} ]" }.join(', ')
171
+ s << " ];\n"
172
+ end
173
+
174
+ s << "$.plot(\"##{field}_chart\", [\n"
175
+ s << data_sets.map do |index, ds|
176
+ "{ data: #{field}_data_#{index},\n" +
177
+ " color: \"#{colors[index][0]}\",\n" +
178
+ " points: { show: true, fillColor: \"#{colors[index][0]}\", " +
179
+ " fill: true, radius: 2 } }"
180
+ end.join(', ')
181
+ s << "], { xaxis: { mode: \"time\" } });\n"
182
+
183
+ s
184
+ end
185
+
186
+ def chart_div(doc, field, title)
187
+ frame(doc, title) {
188
+ doc.div({ 'id' => "#{field}_chart", 'class' => 'chart-placeholder'})
189
+ }
190
+ end
191
+
192
+ end
193
+
194
+ end
195
+