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 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