postrunner 0.0.11 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +5 -2
- data/Rakefile +18 -2
- data/lib/postrunner/ActivitiesDB.rb +1 -22
- data/lib/postrunner/Activity.rb +21 -0
- data/lib/postrunner/ActivityLink.rb +1 -1
- data/lib/postrunner/ActivityListView.rb +10 -9
- data/lib/postrunner/ActivitySummary.rb +42 -15
- data/lib/postrunner/ActivityView.rb +10 -8
- data/lib/postrunner/ChartView.rb +238 -80
- data/lib/postrunner/DataSources.rb +1 -14
- data/lib/postrunner/DeviceList.rb +18 -10
- data/lib/postrunner/DirUtils.rb +33 -0
- data/lib/postrunner/EventList.rb +149 -0
- data/lib/postrunner/FFS_Activity.rb +297 -0
- data/lib/postrunner/FFS_Device.rb +129 -0
- data/lib/postrunner/FitFileStore.rb +372 -0
- data/lib/postrunner/HRV_Analyzer.rb +178 -0
- data/lib/postrunner/LinearPredictor.rb +46 -0
- data/lib/postrunner/Main.rb +135 -33
- data/lib/postrunner/Percentiles.rb +45 -0
- data/lib/postrunner/PersonalRecords.rb +203 -114
- data/lib/postrunner/RecordListPageView.rb +6 -6
- data/lib/postrunner/UserProfileView.rb +4 -0
- data/lib/postrunner/version.rb +1 -1
- data/misc/postrunner/trackview.js +99 -0
- data/postrunner.gemspec +5 -5
- data/spec/ActivitySummary_spec.rb +15 -4
- data/spec/FitFileStore_spec.rb +133 -0
- data/spec/FlexiTable_spec.rb +1 -1
- data/spec/PersonalRecords_spec.rb +206 -0
- data/spec/PostRunner_spec.rb +64 -60
- data/spec/View_spec.rb +1 -1
- data/spec/spec_helper.rb +76 -2
- metadata +42 -28
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 99ae2bd8b3b82d191caf48131b2859c4f4e29720
|
4
|
+
data.tar.gz: ca9b0777ec1fe947bd2aeefa969b7c6e45d52d19
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 89b8afc287b91f5f787e00da5b13642f2e2b86fd73296bd7d70580069698ce36c794773d16377d029eedb92f16293722c70605e79ab100c67442b51e4b7efd70
|
7
|
+
data.tar.gz: 2089af8448f6151f7d3dfd1a1e9ccfeaa4849fa59e1f9b3b30ea8df798ac8b673e639beb494d07aef40c234abe3999a39764523defacf3524e61ee0263cfc2d8
|
data/README.md
CHANGED
@@ -3,8 +3,11 @@
|
|
3
3
|
PostRunner is an application to manage FIT files such as those
|
4
4
|
produced by Garmin products like the Forerunner 620 (FR620) and Fenix
|
5
5
|
3. It allows you to import the files from the device and inspect them.
|
6
|
-
|
7
|
-
|
6
|
+
In addition to the common features like plotting pace, heart rates,
|
7
|
+
elevation and other captured values it also provides a heart rate
|
8
|
+
variability (HRV) analysis. It can also update satellite orbit prediction
|
9
|
+
(EPO) data on the device to speed-up fix times. It is an offline
|
10
|
+
alternative to Garmin Connect.
|
8
11
|
|
9
12
|
## Installation
|
10
13
|
|
data/Rakefile
CHANGED
@@ -1,8 +1,24 @@
|
|
1
|
+
$:.unshift File.join(File.dirname(__FILE__))
|
2
|
+
|
3
|
+
# Add the lib directory to the search path if it isn't included already
|
4
|
+
lib = File.expand_path('../lib', __FILE__)
|
5
|
+
$:.unshift lib unless $:.include?(lib)
|
6
|
+
|
1
7
|
require "bundler/gem_tasks"
|
2
8
|
require "rspec/core/rake_task"
|
9
|
+
require 'rake/clean'
|
10
|
+
require 'yard'
|
11
|
+
YARD::Rake::YardocTask.new
|
3
12
|
|
4
|
-
|
13
|
+
Dir.glob( 'tasks/*.rake').each do |fn|
|
14
|
+
begin
|
15
|
+
load fn;
|
16
|
+
rescue LoadError
|
17
|
+
puts "#{fn.split('/')[1]} tasks unavailable: #{$!}"
|
18
|
+
end
|
19
|
+
end
|
5
20
|
|
6
|
-
task :default
|
21
|
+
task :default => :spec
|
7
22
|
task :test => :spec
|
8
23
|
|
24
|
+
desc 'Run all unit and spec tests'
|
@@ -3,7 +3,7 @@
|
|
3
3
|
#
|
4
4
|
# = ActivitiesDB.rb -- PostRunner - Manage the data from your Garmin sport devices.
|
5
5
|
#
|
6
|
-
# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
|
6
|
+
# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org>
|
7
7
|
#
|
8
8
|
# This program is free software; you can redistribute it and/or modify
|
9
9
|
# it under the terms of version 2 of the GNU General Public License as
|
@@ -68,7 +68,6 @@ module PostRunner
|
|
68
68
|
NavButtonDef.new('record.png', "records-0.html")
|
69
69
|
])
|
70
70
|
|
71
|
-
@records = PersonalRecords.new(self)
|
72
71
|
sync if sync_needed
|
73
72
|
end
|
74
73
|
|
@@ -140,7 +139,6 @@ module PostRunner
|
|
140
139
|
succ = successor(activity)
|
141
140
|
|
142
141
|
@activities.delete(activity)
|
143
|
-
@records.delete_activity(activity)
|
144
142
|
|
145
143
|
# The HTML activity views contain links to their predecessors and
|
146
144
|
# successors. After deleting an activity, we need to re-generate these
|
@@ -167,16 +165,6 @@ module PostRunner
|
|
167
165
|
sync
|
168
166
|
end
|
169
167
|
|
170
|
-
def check
|
171
|
-
@records.delete_all_records
|
172
|
-
@activities.sort do |a1, a2|
|
173
|
-
a1.timestamp <=> a2.timestamp
|
174
|
-
end.each { |a| a.check }
|
175
|
-
@records.sync
|
176
|
-
# Ensure that HTML index is up-to-date.
|
177
|
-
ActivityListView.new(self).update_index_pages
|
178
|
-
end
|
179
|
-
|
180
168
|
def ref_by_fit_file(fit_file)
|
181
169
|
i = 1
|
182
170
|
@activities.each do |activity|
|
@@ -278,14 +266,6 @@ module PostRunner
|
|
278
266
|
puts ActivityListView.new(self).to_s
|
279
267
|
end
|
280
268
|
|
281
|
-
def show_records
|
282
|
-
puts @records.to_s
|
283
|
-
end
|
284
|
-
|
285
|
-
def activity_records(activity)
|
286
|
-
@records.activity_records(activity)
|
287
|
-
end
|
288
|
-
|
289
269
|
# Launch a web browser and show an HTML file.
|
290
270
|
# @param html_file [String] file name of the HTML file to show
|
291
271
|
def show_in_browser(html_file)
|
@@ -336,7 +316,6 @@ module PostRunner
|
|
336
316
|
Log.fatal "Cannot write archive file '#{@archive_file}': #{$!}"
|
337
317
|
end
|
338
318
|
|
339
|
-
@records.sync
|
340
319
|
ActivityListView.new(self).update_index_pages
|
341
320
|
end
|
342
321
|
|
data/lib/postrunner/Activity.rb
CHANGED
@@ -14,6 +14,7 @@ require 'fit4ruby'
|
|
14
14
|
|
15
15
|
require 'postrunner/ActivitySummary'
|
16
16
|
require 'postrunner/DataSources'
|
17
|
+
require 'postrunner/EventList'
|
17
18
|
require 'postrunner/ActivityView'
|
18
19
|
require 'postrunner/Schema'
|
19
20
|
require 'postrunner/QueryResult'
|
@@ -196,6 +197,11 @@ module PostRunner
|
|
196
197
|
QueryResult.new(value, schema)
|
197
198
|
end
|
198
199
|
|
200
|
+
def events
|
201
|
+
@fit_activity = load_fit_file unless @fit_activity
|
202
|
+
puts EventList.new(self, @db.cfg[:unit_system]).to_s
|
203
|
+
end
|
204
|
+
|
199
205
|
def show
|
200
206
|
generate_html_view #unless File.exists?(@html_file)
|
201
207
|
|
@@ -374,6 +380,21 @@ module PostRunner
|
|
374
380
|
ActivitySubTypes[@sub_sport] || 'Undefined'
|
375
381
|
end
|
376
382
|
|
383
|
+
def distance(timestamp, unit_system)
|
384
|
+
@fit_activity = load_fit_file unless @fit_activity
|
385
|
+
|
386
|
+
@fit_activity.records.each do |record|
|
387
|
+
if record.timestamp >= timestamp
|
388
|
+
unit = { :metric => 'km', :statute => 'mi'}[unit_system]
|
389
|
+
value = record.get_as('distance', unit)
|
390
|
+
return '-' unless value
|
391
|
+
return "#{'%.2f %s' % [value, unit]}"
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
'-'
|
396
|
+
end
|
397
|
+
|
377
398
|
private
|
378
399
|
|
379
400
|
def load_fit_file(filter = nil)
|
@@ -30,7 +30,7 @@ module PostRunner
|
|
30
30
|
doc.unique(:activitylink_style) { doc.style(style) }
|
31
31
|
|
32
32
|
doc.a(@activity.name, { :class => 'activity_link',
|
33
|
-
:href => @activity.
|
33
|
+
:href => @activity.html_file_name(false) })
|
34
34
|
if @show_record_icon && @activity.has_records?
|
35
35
|
doc.img(nil, { :src => 'icons/record-small.png',
|
36
36
|
:style => 'vertical-align:middle' })
|
@@ -24,12 +24,12 @@ module PostRunner
|
|
24
24
|
|
25
25
|
include Fit4Ruby::Converters
|
26
26
|
|
27
|
-
def initialize(
|
28
|
-
@
|
29
|
-
@unit_system = @
|
27
|
+
def initialize(ffs)
|
28
|
+
@ffs = ffs
|
29
|
+
@unit_system = @ffs.store['config']['unit_system']
|
30
30
|
@page_size = 20
|
31
31
|
@page_no = -1
|
32
|
-
@last_page = (@
|
32
|
+
@last_page = (@ffs.activities.length - 1) / @page_size
|
33
33
|
end
|
34
34
|
|
35
35
|
def update_index_pages
|
@@ -46,7 +46,7 @@ module PostRunner
|
|
46
46
|
private
|
47
47
|
|
48
48
|
def generate_html_index_page(page_index)
|
49
|
-
views = @
|
49
|
+
views = @ffs.views
|
50
50
|
views.current_page = 'index.html'
|
51
51
|
|
52
52
|
pages = PagingButtons.new((0..@last_page).map do |i|
|
@@ -59,7 +59,8 @@ module PostRunner
|
|
59
59
|
@view.doc.head { @view.doc.style(style) }
|
60
60
|
body(@view.doc)
|
61
61
|
|
62
|
-
output_file = File.join(@
|
62
|
+
output_file = File.join(@ffs.store['config']['html_dir'],
|
63
|
+
pages.current_page)
|
63
64
|
@view.write(output_file)
|
64
65
|
end
|
65
66
|
|
@@ -85,9 +86,9 @@ module PostRunner
|
|
85
86
|
{ :halign => :right }
|
86
87
|
])
|
87
88
|
t.body
|
88
|
-
activities = @page_no == -1 ? @
|
89
|
-
@
|
90
|
-
|
89
|
+
activities = @page_no == -1 ? @ffs.activities :
|
90
|
+
@ffs.activities[(@page_no * @page_size)..
|
91
|
+
((@page_no + 1) * @page_size - 1)]
|
91
92
|
activities.each do |a|
|
92
93
|
t.row([
|
93
94
|
i += 1,
|
@@ -14,6 +14,8 @@ require 'fit4ruby'
|
|
14
14
|
|
15
15
|
require 'postrunner/FlexiTable'
|
16
16
|
require 'postrunner/ViewFrame'
|
17
|
+
require 'postrunner/HRV_Analyzer'
|
18
|
+
require 'postrunner/Percentiles'
|
17
19
|
|
18
20
|
module PostRunner
|
19
21
|
|
@@ -79,29 +81,54 @@ module PostRunner
|
|
79
81
|
"#{session.avg_heart_rate} bpm" : '-' ])
|
80
82
|
t.row([ 'Max. HR:', session.max_heart_rate ?
|
81
83
|
"#{session.max_heart_rate} bpm" : '-' ])
|
84
|
+
if @activity.sport == 'running' || @activity.sport == 'multisport'
|
85
|
+
t.row([ 'Avg. Run Cadence:',
|
86
|
+
session.avg_running_cadence ?
|
87
|
+
"#{(2 * session.avg_running_cadence).round} spm" : '-' ])
|
88
|
+
t.row([ 'Avg. Stride Length:',
|
89
|
+
local_value(session, 'avg_stride_length', '%.2f %s',
|
90
|
+
{ :metric => 'm', :statute => 'ft' }) ])
|
91
|
+
t.row([ 'Avg. Vertical Oscillation:',
|
92
|
+
local_value(session, 'avg_vertical_oscillation', '%.1f %s',
|
93
|
+
{ :metric => 'cm', :statute => 'in' }) ])
|
94
|
+
t.row([ 'Vertical Ratio:',
|
95
|
+
session.vertical_ratio ?
|
96
|
+
"#{session.vertical_ratio}%" : '-' ])
|
97
|
+
t.row([ 'Avg. Ground Contact Time:',
|
98
|
+
session.avg_stance_time ?
|
99
|
+
"#{session.avg_stance_time.round} ms" : '-' ])
|
100
|
+
t.row([ 'Avg. Ground Contact Time Balance:',
|
101
|
+
session.avg_gct_balance ?
|
102
|
+
"#{session.avg_gct_balance}% L / " +
|
103
|
+
"#{100.0 - session.avg_gct_balance}% R" : ';' ])
|
104
|
+
end
|
105
|
+
if @activity.sport == 'cycling'
|
106
|
+
t.row([ 'Avg. Cadence:',
|
107
|
+
session.avg_candence ?
|
108
|
+
"#{(2 * session.avg_candence).round} rpm" : '-' ])
|
109
|
+
end
|
110
|
+
|
82
111
|
t.row([ 'Training Effect:', session.total_training_effect ?
|
83
112
|
session.total_training_effect : '-' ])
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
{ :metric => 'cm', :statute => 'in' }) ])
|
90
|
-
t.row([ 'Avg. Ground Contact Time:',
|
91
|
-
session.avg_stance_time ?
|
92
|
-
"#{session.avg_stance_time.round} ms" : '-' ])
|
93
|
-
t.row([ 'Avg. Stride Length:',
|
94
|
-
local_value(session, 'avg_stride_length', '%.2f %s',
|
95
|
-
{ :metric => 'm', :statute => 'ft' }) ])
|
113
|
+
|
114
|
+
rec_info = @fit_activity.recovery_info
|
115
|
+
t.row([ 'Ignored Recovery Time:',
|
116
|
+
rec_info ? secsToDHMS(rec_info * 60) : '-' ])
|
117
|
+
|
96
118
|
rec_hr = @fit_activity.recovery_hr
|
97
119
|
end_hr = @fit_activity.ending_hr
|
98
120
|
t.row([ 'Recovery HR:',
|
99
121
|
rec_hr && end_hr ?
|
100
122
|
"#{rec_hr} bpm [#{end_hr - rec_hr} bpm]" : '-' ])
|
123
|
+
|
101
124
|
rec_time = @fit_activity.recovery_time
|
102
|
-
t.row([ 'Recovery Time:',
|
103
|
-
|
104
|
-
|
125
|
+
t.row([ 'Suggested Recovery Time:',
|
126
|
+
rec_time ? secsToDHMS(rec_time * 60) : '-' ])
|
127
|
+
|
128
|
+
hrv = HRV_Analyzer.new(@fit_activity)
|
129
|
+
if hrv.has_hrv_data?
|
130
|
+
t.row([ 'HRV Score:', "%.1f" % hrv.lnrmssdx20_1sigma ])
|
131
|
+
end
|
105
132
|
|
106
133
|
t
|
107
134
|
end
|
@@ -14,7 +14,9 @@ require 'fit4ruby'
|
|
14
14
|
|
15
15
|
require 'postrunner/View'
|
16
16
|
require 'postrunner/ActivitySummary'
|
17
|
+
require 'postrunner/EventList'
|
17
18
|
require 'postrunner/DeviceList'
|
19
|
+
require 'postrunner/DataSources'
|
18
20
|
require 'postrunner/UserProfileView'
|
19
21
|
require 'postrunner/TrackView'
|
20
22
|
require 'postrunner/ChartView'
|
@@ -25,26 +27,25 @@ module PostRunner
|
|
25
27
|
|
26
28
|
def initialize(activity, unit_system)
|
27
29
|
@activity = activity
|
28
|
-
|
30
|
+
ffs = @activity.store['file_store']
|
29
31
|
@unit_system = unit_system
|
30
32
|
|
31
|
-
views =
|
33
|
+
views = ffs.views
|
32
34
|
views.current_page = nil
|
33
35
|
|
34
36
|
# Sort activities in reverse order so the newest one is considered the
|
35
37
|
# last report by the pagin buttons.
|
36
|
-
activities =
|
38
|
+
activities = ffs.activities.sort do |a1, a2|
|
37
39
|
a1.timestamp <=> a2.timestamp
|
38
40
|
end
|
39
41
|
|
40
|
-
pages = PagingButtons.new(
|
41
|
-
|
42
|
-
|
43
|
-
pages.current_page = "#{@activity.fit_file[0..-5]}.html"
|
42
|
+
pages = PagingButtons.new(
|
43
|
+
activities.map { |a| a.html_file_name(false) }, false)
|
44
|
+
pages.current_page = @activity.html_file_name(false)
|
44
45
|
|
45
46
|
super("PostRunner Activity: #{@activity.name}", views, pages)
|
46
47
|
generate_html(@doc)
|
47
|
-
write(
|
48
|
+
write(@activity.html_file_name)
|
48
49
|
end
|
49
50
|
|
50
51
|
private
|
@@ -73,6 +74,7 @@ module PostRunner
|
|
73
74
|
}
|
74
75
|
doc.div({ :class => 'right_col' }) {
|
75
76
|
ChartView.new(@activity, @unit_system).to_html(doc)
|
77
|
+
EventList.new(@activity, @unit_system).to_html(doc)
|
76
78
|
}
|
77
79
|
}
|
78
80
|
doc.div({ :class => 'two_col' }) {
|
data/lib/postrunner/ChartView.rb
CHANGED
@@ -10,6 +10,8 @@
|
|
10
10
|
# published by the Free Software Foundation.
|
11
11
|
#
|
12
12
|
|
13
|
+
require 'postrunner/HRV_Analyzer'
|
14
|
+
|
13
15
|
module PostRunner
|
14
16
|
|
15
17
|
class ChartView
|
@@ -19,6 +21,137 @@ module PostRunner
|
|
19
21
|
@sport = activity.sport
|
20
22
|
@unit_system = unit_system
|
21
23
|
@empty_charts = {}
|
24
|
+
@hrv_analyzer = HRV_Analyzer.new(@activity.fit_activity)
|
25
|
+
|
26
|
+
@charts = [
|
27
|
+
{
|
28
|
+
:id => 'pace',
|
29
|
+
:label => 'Pace',
|
30
|
+
:unit => select_unit('min/km'),
|
31
|
+
:graph => :line_graph,
|
32
|
+
:colors => '#0A7BEE',
|
33
|
+
:show => @sport == 'running' || @sport = 'multisport',
|
34
|
+
},
|
35
|
+
{
|
36
|
+
:id => 'speed',
|
37
|
+
:label => 'Speed',
|
38
|
+
:unit => select_unit('km/h'),
|
39
|
+
:graph => :line_graph,
|
40
|
+
:colors => '#0A7BEE',
|
41
|
+
:show => @sport != 'running'
|
42
|
+
},
|
43
|
+
{
|
44
|
+
:id => 'altitude',
|
45
|
+
:label => 'Elevation',
|
46
|
+
:unit => select_unit('m'),
|
47
|
+
:graph => :line_graph,
|
48
|
+
:colors => '#5AAA44',
|
49
|
+
:show => @activity.sub_sport != 'treadmill'
|
50
|
+
},
|
51
|
+
{
|
52
|
+
:id => 'heart_rate',
|
53
|
+
:label => 'Heart Rate',
|
54
|
+
:unit => 'bpm',
|
55
|
+
:graph => :line_graph,
|
56
|
+
:colors => '#900000',
|
57
|
+
:show => true
|
58
|
+
},
|
59
|
+
{
|
60
|
+
:id => 'hrv',
|
61
|
+
:label => 'Heart Rate Variability',
|
62
|
+
:short_label => 'HRV',
|
63
|
+
:unit => 'ms',
|
64
|
+
:graph => :line_graph,
|
65
|
+
:colors => '#900000',
|
66
|
+
:show => @hrv_analyzer.has_hrv_data?
|
67
|
+
},
|
68
|
+
{
|
69
|
+
:id => 'hrv_score',
|
70
|
+
:label => 'HRV Score (30s Window)',
|
71
|
+
:short_label => 'HRV Score',
|
72
|
+
:graph => :line_graph,
|
73
|
+
:colors => '#900000',
|
74
|
+
:show => false
|
75
|
+
},
|
76
|
+
{
|
77
|
+
:id => 'run_cadence',
|
78
|
+
:label => 'Run Cadence',
|
79
|
+
:unit => 'spm',
|
80
|
+
:graph => :point_graph,
|
81
|
+
:colors => [ [ '#EE3F2D', 151 ], [ '#F79666', 163 ],
|
82
|
+
[ '#A0D488', 174 ], [ '#96D7DE', 185 ],
|
83
|
+
[ '#A88BBB', nil ] ],
|
84
|
+
:show => @sport == 'running' || @sport == 'multisport'
|
85
|
+
},
|
86
|
+
{
|
87
|
+
:id => 'stride_length',
|
88
|
+
:label => 'Stride Length',
|
89
|
+
:unit => select_unit('m'),
|
90
|
+
:graph => :point_graph,
|
91
|
+
:colors => [ ['#506DE1', nil ] ],
|
92
|
+
:show => @sport == 'running' || @sport == 'multisport'
|
93
|
+
},
|
94
|
+
{
|
95
|
+
:id => 'vertical_oscillation',
|
96
|
+
:label => 'Vertical Oscillation',
|
97
|
+
:short_label => 'Vert. Osc.',
|
98
|
+
:unit => select_unit('cm'),
|
99
|
+
:graph => :point_graph,
|
100
|
+
:colors => [ [ '#A88BBB', 67 ], [ '#96D7DE', 84 ],
|
101
|
+
[ '#A0D488', 101 ], [ '#F79666', 118 ],
|
102
|
+
[ '#EE3F2D', nil ] ],
|
103
|
+
:show => @sport == 'running' || @sport == 'multisport'
|
104
|
+
},
|
105
|
+
{
|
106
|
+
:id => 'vertical_ratio',
|
107
|
+
:label => 'Vertical Ratio',
|
108
|
+
:unit => '%',
|
109
|
+
:graph => :point_graph,
|
110
|
+
:colors => [ [ '#CF45BD', 6.1 ], [ '#4FBEED', 7.4 ],
|
111
|
+
[ '#6AB03A', 8.6 ], [ '#EDA14F', 10.1 ],
|
112
|
+
[ '#FF5558', nil ] ],
|
113
|
+
:show => @sport == 'running' || @sport == 'multisport'
|
114
|
+
},
|
115
|
+
{
|
116
|
+
:id => 'stance_time',
|
117
|
+
:label => 'Ground Contact Time',
|
118
|
+
:short_label => 'GCT',
|
119
|
+
:unit => 'ms',
|
120
|
+
:graph => :point_graph,
|
121
|
+
:colors => [ [ '#A88BBB', 208 ], [ '#96D7DE', 241 ],
|
122
|
+
[ '#A0D488', 273 ], [ '#F79666', 305 ],
|
123
|
+
[ '#EE3F2D', nil ] ],
|
124
|
+
:show => @sport == 'running' || @sport == 'multisport'
|
125
|
+
},
|
126
|
+
{
|
127
|
+
:id => 'gct_balance',
|
128
|
+
:label => 'Ground Contact Time Balance',
|
129
|
+
:short_label => 'GCT Balance',
|
130
|
+
:unit => '%',
|
131
|
+
:graph => :point_graph,
|
132
|
+
:colors => [ [ '#FF5558', 47.8 ], [ '#EDA14F', 49.2 ],
|
133
|
+
[ '#6AB03A', 50.7 ], [ '#EDA14F', 52.2 ],
|
134
|
+
[ '#FF5558', nil ] ],
|
135
|
+
:show => @sport == 'running' || @sport == 'multisport'
|
136
|
+
},
|
137
|
+
{
|
138
|
+
:id => 'cadence',
|
139
|
+
:label => 'Cadence',
|
140
|
+
:unit => 'rpm',
|
141
|
+
:graph => :line_graph,
|
142
|
+
:colors => '#A88BBB',
|
143
|
+
:show => @sport == 'cycling'
|
144
|
+
},
|
145
|
+
{
|
146
|
+
:id => 'temperature',
|
147
|
+
:label => 'Temperature',
|
148
|
+
:short_label => 'Temp.',
|
149
|
+
:unit => 'C',
|
150
|
+
:graph => :line_graph,
|
151
|
+
:colors => '#444444',
|
152
|
+
:show => true
|
153
|
+
}
|
154
|
+
]
|
22
155
|
end
|
23
156
|
|
24
157
|
def to_html(doc)
|
@@ -34,19 +167,10 @@ module PostRunner
|
|
34
167
|
}
|
35
168
|
|
36
169
|
doc.script(java_script)
|
37
|
-
|
38
|
-
|
170
|
+
@charts.each do |chart|
|
171
|
+
label = chart[:label] + (chart[:unit] ? " (#{chart[:unit]})" : '')
|
172
|
+
chart_div(doc, chart[:id], label) if chart[:show]
|
39
173
|
end
|
40
|
-
if @sport != 'running'
|
41
|
-
chart_div(doc, 'speed', "Speed (#{select_unit('km/h')})")
|
42
|
-
end
|
43
|
-
chart_div(doc, 'altitude', "Elevation (#{select_unit('m')})")
|
44
|
-
chart_div(doc, 'heart_rate', 'Heart Rate (bpm)')
|
45
|
-
chart_div(doc, 'run_cadence', 'Run Cadence (spm)')
|
46
|
-
chart_div(doc, 'vertical_oscillation',
|
47
|
-
"Vertical Oscillation (#{select_unit('cm')})")
|
48
|
-
chart_div(doc, 'stance_time', 'Ground Contact Time (ms)')
|
49
|
-
chart_div(doc, 'temperature', 'Temperature (°C)')
|
50
174
|
end
|
51
175
|
|
52
176
|
private
|
@@ -56,8 +180,10 @@ module PostRunner
|
|
56
180
|
when :metric
|
57
181
|
metric_unit
|
58
182
|
when :statute
|
59
|
-
{ 'min/km' => 'min/mi', '
|
60
|
-
'
|
183
|
+
{ 'min/km' => 'min/mi', 'km/h' => 'mph',
|
184
|
+
'mm' => 'in', 'cm' => 'in', 'm' => 'ft',
|
185
|
+
'bpm' => 'bpm', 'rpm' => 'rpm', 'spm' => 'spm', '%' => '%',
|
186
|
+
'ms' => 'ms' }[metric_unit]
|
61
187
|
else
|
62
188
|
Log.fatal "Unknown unit system #{@unit_system}"
|
63
189
|
end
|
@@ -97,33 +223,9 @@ EOT
|
|
97
223
|
s = "$(function() {\n"
|
98
224
|
|
99
225
|
s << tooltip_div
|
100
|
-
|
101
|
-
s <<
|
102
|
-
end
|
103
|
-
if @sport != 'running'
|
104
|
-
s << line_graph('speed', 'Speed', 'km/h', '#0A7BEE' )
|
226
|
+
@charts.each do |chart|
|
227
|
+
s << send(chart[:graph], chart) if chart[:show]
|
105
228
|
end
|
106
|
-
s << line_graph('altitude', 'Elevation', 'm', '#5AAA44')
|
107
|
-
s << line_graph('heart_rate', 'Heart Rate', 'bpm', '#900000')
|
108
|
-
s << point_graph('run_cadence', 'Run Cadence', 'spm',
|
109
|
-
[ [ '#EE3F2D', 151 ],
|
110
|
-
[ '#F79666', 163 ],
|
111
|
-
[ '#A0D488', 174 ],
|
112
|
-
[ '#96D7DE', 185 ],
|
113
|
-
[ '#A88BBB', nil ] ])
|
114
|
-
s << point_graph('vertical_oscillation', 'Vertical Oscillation', 'cm',
|
115
|
-
[ [ '#A88BBB', 67 ],
|
116
|
-
[ '#96D7DE', 84 ],
|
117
|
-
[ '#A0D488', 101 ],
|
118
|
-
[ '#F79666', 118 ],
|
119
|
-
[ '#EE3F2D', nil ] ])
|
120
|
-
s << point_graph('stance_time', 'Ground Contact Time', 'ms',
|
121
|
-
[ [ '#A88BBB', 208 ],
|
122
|
-
[ '#96D7DE', 241 ],
|
123
|
-
[ '#A0D488', 273 ],
|
124
|
-
[ '#F79666', 305 ],
|
125
|
-
[ '#EE3F2D', nil ] ])
|
126
|
-
s << line_graph('temperature', 'Temperature', 'C', '#444444')
|
127
229
|
|
128
230
|
s << "\n});\n"
|
129
231
|
|
@@ -158,35 +260,54 @@ EOT
|
|
158
260
|
EOT
|
159
261
|
end
|
160
262
|
|
161
|
-
def line_graph(
|
162
|
-
s = "var #{
|
263
|
+
def line_graph(chart)
|
264
|
+
s = "var #{chart[:id]}_data = [\n"
|
163
265
|
|
164
266
|
data_set = []
|
165
267
|
start_time = @activity.fit_activity.sessions[0].start_time.to_i
|
166
268
|
min_value = nil
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
269
|
+
if chart[:id] == 'hrv_score'
|
270
|
+
0.upto(@hrv_analyzer.total_duration.to_i - 30) do |t|
|
271
|
+
next unless (hrv_score = @hrv_analyzer.lnrmssdx20(t, 30)) > 0.0
|
272
|
+
min_value = hrv_score if min_value.nil? || min_value > hrv_score
|
273
|
+
data_set << [ t * 1000, hrv_score ]
|
274
|
+
end
|
275
|
+
elsif chart[:id] == 'hrv'
|
276
|
+
1.upto(@hrv_analyzer.rr_intervals.length - 1) do |idx|
|
277
|
+
curr_intvl = @hrv_analyzer.rr_intervals[idx]
|
278
|
+
prev_intvl = @hrv_analyzer.rr_intervals[idx - 1]
|
279
|
+
next unless curr_intvl && prev_intvl
|
280
|
+
|
281
|
+
# Convert the R-R interval duration to ms.
|
282
|
+
dt = (curr_intvl - prev_intvl) * 1000.0
|
283
|
+
min_value = dt if min_value.nil? || min_value > dt
|
284
|
+
data_set << [ @hrv_analyzer.timestamps[idx] * 1000, dt ]
|
285
|
+
end
|
286
|
+
else
|
287
|
+
@activity.fit_activity.records.each do |r|
|
288
|
+
value = r.get_as(chart[:id], chart[:unit] || '')
|
289
|
+
|
290
|
+
next unless value
|
291
|
+
|
292
|
+
if chart[:id] == 'pace'
|
293
|
+
# Slow speeds lead to very large pace values that make the graph
|
294
|
+
# hard to read. We cap the pace at 20.0 min/km to keep it readable.
|
295
|
+
if value > (@unit_system == :metric ? 20.0 : 36.0 )
|
296
|
+
value = nil
|
297
|
+
else
|
298
|
+
value = (value * 3600.0 * 1000).to_i
|
299
|
+
end
|
300
|
+
min_value = 0.0
|
177
301
|
else
|
178
|
-
|
302
|
+
min_value = value if (min_value.nil? || min_value > value)
|
179
303
|
end
|
180
|
-
|
181
|
-
else
|
182
|
-
min_value = value if (min_value.nil? || min_value > value)
|
304
|
+
data_set << [ ((r.timestamp.to_i - start_time) * 1000).to_i, value ]
|
183
305
|
end
|
184
|
-
data_set << [ ((r.timestamp.to_i - start_time) * 1000).to_i, value ]
|
185
306
|
end
|
186
307
|
|
187
308
|
# We don't want to plot charts with all nil values.
|
188
309
|
unless data_set.find { |v| v[1] != nil }
|
189
|
-
@empty_charts[
|
310
|
+
@empty_charts[chart[:id]] = true
|
190
311
|
return ''
|
191
312
|
end
|
192
313
|
s << data_set.map do |set|
|
@@ -196,17 +317,17 @@ EOT
|
|
196
317
|
|
197
318
|
s << lap_marks(start_time)
|
198
319
|
|
199
|
-
chart_id = "#{
|
320
|
+
chart_id = "#{chart[:id]}_chart"
|
200
321
|
s << <<"EOT"
|
201
322
|
var plot = $.plot(\"##{chart_id}\",
|
202
|
-
[ { data: #{
|
203
|
-
#{
|
204
|
-
lines: { show: true#{
|
323
|
+
[ { data: #{chart[:id]}_data,
|
324
|
+
#{chart[:colors] ? "color: \"#{chart[:colors]}\"," : ''}
|
325
|
+
lines: { show: true#{chart[:id] == 'pace' ? '' :
|
205
326
|
', fill: true'} } } ],
|
206
327
|
{ xaxis: { mode: "time" },
|
207
328
|
grid: { markings: lap_marks, hoverable: true }
|
208
329
|
EOT
|
209
|
-
if
|
330
|
+
if chart[:id] == 'pace'
|
210
331
|
s << ", yaxis: { mode: \"time\",\n" +
|
211
332
|
" transform: function (v) { return -v; },\n" +
|
212
333
|
" inverseTransform: function (v) { return -v; } }"
|
@@ -216,34 +337,35 @@ EOT
|
|
216
337
|
end
|
217
338
|
s << "});\n"
|
218
339
|
s << lap_mark_labels(chart_id, start_time)
|
219
|
-
s << hover_function(chart_id,
|
340
|
+
s << hover_function(chart_id, chart[:short_label] || chart[:label],
|
341
|
+
select_unit(chart[:unit] || '')) + "\n"
|
220
342
|
end
|
221
343
|
|
222
|
-
def point_graph(
|
223
|
-
# We need to split the
|
344
|
+
def point_graph(chart)
|
345
|
+
# We need to split the y-values into separate data sets for each
|
224
346
|
# color. The max value for each color determines which set a data point
|
225
347
|
# ends up in.
|
226
348
|
# Initialize the data sets. The key for data_sets is the corresponding
|
227
349
|
# index in colors.
|
228
350
|
data_sets = {}
|
229
|
-
colors.each.with_index { |cp, i| data_sets[i] = [] }
|
351
|
+
chart[:colors].each.with_index { |cp, i| data_sets[i] = [] }
|
230
352
|
|
231
|
-
# Now we can split the
|
353
|
+
# Now we can split the y-values into the sets.
|
232
354
|
start_time = @activity.fit_activity.sessions[0].start_time.to_i
|
233
355
|
@activity.fit_activity.records.each do |r|
|
234
356
|
# Undefined values will be discarded.
|
235
|
-
next unless (value = r.send(
|
357
|
+
next unless (value = r.send(chart[:id]))
|
236
358
|
|
237
359
|
# Find the right set by looking at the maximum allowed values for each
|
238
360
|
# color.
|
239
|
-
colors.each.with_index do |col_max_value, i|
|
361
|
+
chart[:colors].each.with_index do |col_max_value, i|
|
240
362
|
col, range_max_value = col_max_value
|
241
363
|
if range_max_value.nil? || value < range_max_value
|
242
364
|
# A range_max_value of nil means all values allowed. The value is
|
243
365
|
# in the allowed range for this set, so add the value as x/y pair
|
244
366
|
# to the set.
|
245
367
|
x_val = (r.timestamp.to_i - start_time) * 1000
|
246
|
-
data_sets[i] << [ x_val, r.get_as(
|
368
|
+
data_sets[i] << [ x_val, r.get_as(chart[:id], chart[:unit] || '') ]
|
247
369
|
# Abort the color loop since we've found the right set already.
|
248
370
|
break
|
249
371
|
end
|
@@ -252,32 +374,35 @@ EOT
|
|
252
374
|
|
253
375
|
# We don't want to plot charts with all nil values.
|
254
376
|
if data_sets.values.flatten.empty?
|
255
|
-
@empty_charts[
|
377
|
+
@empty_charts[chart[:id]] = true
|
256
378
|
return ''
|
257
379
|
end
|
258
380
|
|
259
381
|
# Now generate the JS variable definitions for each set.
|
260
382
|
s = ''
|
261
383
|
data_sets.each do |index, ds|
|
262
|
-
s << "var #{
|
384
|
+
s << "var #{chart[:id]}_data_#{index} = [\n"
|
263
385
|
s << ds.map { |dp| "[ #{dp[0]}, #{dp[1]} ]" }.join(', ')
|
264
386
|
s << " ];\n"
|
265
387
|
end
|
266
388
|
|
267
389
|
s << lap_marks(start_time)
|
268
390
|
|
269
|
-
chart_id = "#{
|
391
|
+
chart_id = "#{chart[:id]}_chart"
|
270
392
|
s << "var plot = $.plot(\"##{chart_id}\", [\n"
|
271
393
|
s << data_sets.map do |index, ds|
|
272
|
-
"{ data: #{
|
273
|
-
" color: \"#{colors[index][0]}\",\n" +
|
274
|
-
" points: { show: true,
|
394
|
+
"{ data: #{chart[:id]}_data_#{index},\n" +
|
395
|
+
" color: \"#{chart[:colors][index][0]}\",\n" +
|
396
|
+
" points: { show: true, " +
|
397
|
+
" fillColor: \"#{chart[:colors][index][0]}\", " +
|
275
398
|
" fill: true, radius: 2 } }"
|
276
399
|
end.join(', ')
|
277
|
-
s << "], { xaxis: { mode: \"time\" },
|
278
|
-
|
400
|
+
s << "], { xaxis: { mode: \"time\" }, " +
|
401
|
+
(chart[:id] == 'gct_balance' ? gct_balance_yaxis(data_sets) : '') +
|
402
|
+
" grid: { markings: lap_marks, hoverable: true } });\n"
|
279
403
|
s << lap_mark_labels(chart_id, start_time)
|
280
|
-
s << hover_function(chart_id,
|
404
|
+
s << hover_function(chart_id, chart[:short_label] || chart[:label],
|
405
|
+
select_unit(chart[:unit] || ''))
|
281
406
|
|
282
407
|
s
|
283
408
|
end
|
@@ -337,6 +462,39 @@ EOT
|
|
337
462
|
s
|
338
463
|
end
|
339
464
|
|
465
|
+
def gct_balance_yaxis(data_set)
|
466
|
+
# Decompose hash of array with x/y touples into a flat array of just y
|
467
|
+
# values.
|
468
|
+
yvalues = data_set.values.flatten(1).map { |touple| touple[1] }
|
469
|
+
# Find the largest and smallest value and round it up and down to the
|
470
|
+
# next Fixnum.
|
471
|
+
max = yvalues.max.ceil
|
472
|
+
min = yvalues.min.floor
|
473
|
+
# Ensure that the range 49 - 51 is always included.
|
474
|
+
max = 51.0 if max < 51.0
|
475
|
+
min = 49.0 if min > 49.0
|
476
|
+
# The graph is large to fit 6 ticks quite nicely.
|
477
|
+
tick_step = ((max - min) / 6.0).ceil
|
478
|
+
# Generate an Array with the tick values
|
479
|
+
tick_values = (0..5).to_a.map { |i| min + i * tick_step }
|
480
|
+
# Remove values that are larger than max
|
481
|
+
tick_values.delete_if { |v| v > max }
|
482
|
+
# Generate an Array of tick/label touples
|
483
|
+
ticks = []
|
484
|
+
tick_labels = tick_values.each do |value|
|
485
|
+
label = if value < 50
|
486
|
+
"#{100 - value}%R"
|
487
|
+
elsif value > 50
|
488
|
+
"#{value}%L"
|
489
|
+
else
|
490
|
+
'50/50'
|
491
|
+
end
|
492
|
+
ticks << [ value, label ]
|
493
|
+
end
|
494
|
+
# Convert the tick/label Array into a Flot yaxis definition.
|
495
|
+
"yaxis: { ticks: #{ticks.inspect} }, "
|
496
|
+
end
|
497
|
+
|
340
498
|
end
|
341
499
|
|
342
500
|
end
|