postrunner 0.0.11 → 0.1.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.
- 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
|