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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: e7079467bfe7e66fd8fd3f18f607a94aeabe9924
4
- data.tar.gz: 501da89a9f177cde254dd0fe9170df23089ec387
3
+ metadata.gz: 99ae2bd8b3b82d191caf48131b2859c4f4e29720
4
+ data.tar.gz: ca9b0777ec1fe947bd2aeefa969b7c6e45d52d19
5
5
  SHA512:
6
- metadata.gz: e12ab80e6e5ac8761cc5766d967a80f6e38d5fbd50a1a782ec3bbf40099a0fc0912a7b1878b2bba8a7b9d3f96b73c2fa47672e5987f1cd7d45db83b5173e00ac
7
- data.tar.gz: 527efab9134703a38207197f2ad384cdbb6ba7ce242ce8f6ee589868f0b08c83fcd0229afcc6cebf928f88e27cad46abaa4b9e24ea1719009e99e1ac6b20f66f
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
- It can also update satellite orbit prediction (EPO) data on the device
7
- to speed-up fix times.
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
- RSpec::Core::RakeTask.new
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 => :spec
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
 
@@ -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.fit_file[0..-5] + '.html' })
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(db)
28
- @db = db
29
- @unit_system = @db.cfg[: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 = (@db.activities.length - 1) / @page_size
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 = @db.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(@db.cfg[:html_dir], pages.current_page)
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 ? @db.activities :
89
- @db.activities[(@page_no * @page_size)..
90
- ((@page_no + 1) * @page_size - 1)]
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
- t.row([ 'Avg. Run Cadence:',
85
- session.avg_running_cadence ?
86
- "#{(2 * session.avg_running_cadence).round} spm" : '-' ])
87
- t.row([ 'Avg. Vertical Oscillation:',
88
- local_value(session, 'avg_vertical_oscillation', '%.1f %s',
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:', rec_time ? secsToHMS(rec_time * 60) : '-' ])
103
- vo2max = @fit_activity.vo2max
104
- t.row([ 'VO2max:', vo2max ? vo2max : '-' ])
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
- db = @activity.db
30
+ ffs = @activity.store['file_store']
29
31
  @unit_system = unit_system
30
32
 
31
- views = db.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 = db.activities.sort do |a1, a2|
38
+ activities = ffs.activities.sort do |a1, a2|
37
39
  a1.timestamp <=> a2.timestamp
38
40
  end
39
41
 
40
- pages = PagingButtons.new(activities.map do |a|
41
- "#{a.fit_file[0..-5]}.html"
42
- end, false)
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(File.join(db.cfg[:html_dir], pages.current_page))
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' }) {
@@ -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
- if @sport == 'running' || @sport == 'multisport'
38
- chart_div(doc, 'pace', "Pace (#{select_unit('min/km')})")
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', 'm' => 'ft', 'cm' => 'in', 'km/h' => 'mph',
60
- 'bpm' => 'bpm', 'spm' => 'spm', 'ms' => 'ms' }[metric_unit]
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
- if @sport == 'running' || @sport == 'multisport'
101
- s << line_graph('pace', 'Pace', 'min/km', '#0A7BEE' )
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(field, y_label, unit, color = nil)
162
- s = "var #{field}_data = [\n"
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
- @activity.fit_activity.records.each do |r|
168
- value = r.get_as(field, select_unit(unit))
169
-
170
- next unless value
171
-
172
- if field == 'pace'
173
- # Slow speeds lead to very large pace values that make the graph
174
- # hard to read. We cap the pace at 20.0 min/km to keep it readable.
175
- if value > (@unit_system == :metric ? 20.0 : 36.0 )
176
- value = nil
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
- value = (value * 3600.0 * 1000).to_i
302
+ min_value = value if (min_value.nil? || min_value > value)
179
303
  end
180
- min_value = 0.0
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[field] = true
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 = "#{field}_chart"
320
+ chart_id = "#{chart[:id]}_chart"
200
321
  s << <<"EOT"
201
322
  var plot = $.plot(\"##{chart_id}\",
202
- [ { data: #{field}_data,
203
- #{color ? "color: \"#{color}\"," : ''}
204
- lines: { show: true#{field == 'pace' ? '' :
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 field == 'pace'
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, y_label, select_unit(unit)) + "\n"
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(field, y_label, unit, colors)
223
- # We need to split the field values into separate data sets for each
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 field values into the sets.
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(field))
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(field, select_unit(unit)) ]
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[field] = true
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 #{field}_data_#{index} = [\n"
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 = "#{field}_chart"
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: #{field}_data_#{index},\n" +
273
- " color: \"#{colors[index][0]}\",\n" +
274
- " points: { show: true, fillColor: \"#{colors[index][0]}\", " +
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
- grid: { markings: lap_marks, hoverable: true } });\n"
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, y_label, select_unit(unit))
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