diff options
-rw-r--r-- | lib/postrunner/Activity.rb | 10 | ||||
-rw-r--r-- | lib/postrunner/ChartView.rb | 211 | ||||
-rw-r--r-- | lib/postrunner/Main.rb | 7 | ||||
-rw-r--r-- | lib/postrunner/PersonalRecords.rb | 145 | ||||
-rw-r--r-- | lib/postrunner/TrackView.rb | 150 |
5 files changed, 523 insertions, 0 deletions
diff --git a/lib/postrunner/Activity.rb b/lib/postrunner/Activity.rb index 0ded82b..c60a557 100644 --- a/lib/postrunner/Activity.rb +++ b/lib/postrunner/Activity.rb @@ -1,6 +1,8 @@ require 'fit4ruby' require 'postrunner/ActivityReport' +require 'postrunner/TrackView' +require 'postrunner/ChartView' module PostRunner @@ -59,6 +61,14 @@ module PostRunner end end + def show + @fit_activity = load_fit_file unless @fit_activity + view = TrackView.new(self, '../../html') + view.generate_html + chart = ChartView.new(self, '../../html') + chart.generate_html + end + def summary @fit_activity = load_fit_file unless @fit_activity puts ActivityReport.new(@fit_activity).to_s diff --git a/lib/postrunner/ChartView.rb b/lib/postrunner/ChartView.rb new file mode 100644 index 0000000..f25a731 --- /dev/null +++ b/lib/postrunner/ChartView.rb @@ -0,0 +1,211 @@ +module PostRunner + + class ChartView + + def initialize(activity, output_dir) + @activity = activity + @output_dir = output_dir + end + + def generate_html + s = <<EOT +<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <title>Flot Examples: Basic Usage</title> + <style> +.chart-container { + box-sizing: border-box; + width: 600px; + height: 200px; + padding: 10px 15px 15px 15px; + margin: 15px auto 15px auto; + border: 1px solid #ddd; + background: #fff; + background: linear-gradient(#f6f6f6 0, #fff 50px); + background: -o-linear-gradient(#f6f6f6 0, #fff 50px); + background: -ms-linear-gradient(#f6f6f6 0, #fff 50px); + background: -moz-linear-gradient(#f6f6f6 0, #fff 50px); + background: -webkit-linear-gradient(#f6f6f6 0, #fff 50px); + box-shadow: 0 3px 10px rgba(0,0,0,0.15); + -o-box-shadow: 0 3px 10px rgba(0,0,0,0.1); + -ms-box-shadow: 0 3px 10px rgba(0,0,0,0.1); + -moz-box-shadow: 0 3px 10px rgba(0,0,0,0.1); + -webkit-box-shadow: 0 3px 10px rgba(0,0,0,0.1); +} +.chart-placeholder { + width: 100%; + height: 100%; + font-size: 14px; + line-height: 1.2em; +} + </style> + <script language="javascript" type="text/javascript" +src="js/jquery-2.1.1.js"></script> + <script language="javascript" type="text/javascript" +src="js/flot/jquery.flot.js"></script> + <script language="javascript" type="text/javascript" + src="js/flot/jquery.flot.time.js"></script> + <script type="text/javascript"> + + $(function() { +EOT + + s << line_graph('pace', '#0A7BEE' ) + s << line_graph('altitude', '#5AAA44') + s << line_graph('heart_rate', '#900000') + s << point_graph('cadence', + [ [ '#EE3F2D', 151 ], + [ '#F79666', 163 ], + [ '#A0D488', 174 ], + [ '#96D7DE', 185 ], + [ '#A88BBB', nil ] ], 2) + s << point_graph('vertical_oscillation', + [ [ '#A88BBB', 6.7 ], + [ '#96D7DE', 8.4 ], + [ '#A0D488', 10.1 ], + [ '#F79666', 11.8 ], + [ '#EE3F2D', nil ] ], 0.1) + s << point_graph('stance_time', + [ [ '#A88BBB', 208 ], + [ '#96D7DE', 241 ], + [ '#A0D488', 273 ], + [ '#F79666', 305 ], + [ '#EE3F2D', nil ] ]) + + s << <<EOT + }); + </script> +</head> +<body> + <div id="header"> + <h2>HR Chart</h2> + </div> +EOT + + s << chart_div('pace', 'Pace (min/km)') + s << chart_div('altitude', 'Elevation (m)') + s << chart_div('heart_rate', 'Heart Rate (bpm)') + s << chart_div('cadence', 'Run Cadence (spm)') + s << chart_div('vertical_oscillation', 'Vertical Oscillation (cm)') + s << chart_div('stance_time', 'Ground Contact Time (ms)') + + s << "</body>\n</html>\n" + + file_name = File.join(@output_dir, "#{@activity_id}_hr.html") + begin + File.write(file_name, s) + rescue IOError + Log.fatal "Cannot write chart view '#{file_name}': #{$!}" + end + end + + private + + def line_graph(field, color = nil) + s = "var #{field}_data = [\n" + + first = true + start_time = @activity.fit_activity.sessions[0].start_time.to_i + @activity.fit_activity.records.each do |r| + if first + first = false + else + s << ', ' + end + value = r.send(field) + if field == 'pace' + if value > 20.0 + value = nil + else + value = (value * 3600.0 * 1000).to_i + end + end + s << "[ #{((r.timestamp.to_i - start_time) * 1000).to_i}, " + + "#{value ? value : 'null'} ]" + end + + s << <<"EOT" + ]; + + $.plot("##{field}_chart", + [ { data: #{field}_data, + #{color ? "color: \"#{color}\"," : ''} + lines: { show: true#{field == 'pace' ? '' : ', fill: true'} } } ], + { xaxis: { mode: "time" } +EOT + if field == 'pace' + s << ", yaxis: { mode: \"time\",\n" + + " transform: function (v) { return -v; },\n" + + " inverseTransform: function (v) { return -v; } }" + end + s << "});\n" + end + + def point_graph(field, colors, multiplier = 1) + # We need to split the field values into separate data sets for each + # color. The max value for each color determines which set a data point + # ends up in. + # Initialize the data sets. The key for data_sets is the corresponding + # index in colors. + data_sets = {} + colors.each.with_index { |cp, i| data_sets[i] = [] } + + # Now we can split the field values into the sets. + start_time = @activity.fit_activity.sessions[0].start_time.to_i + @activity.fit_activity.records.each do |r| + # Undefined values will be discarded. + next unless (value = r.instance_variable_get('@' + field)) + value *= multiplier + + # Find the right set by looking at the maximum allowed values for each + # color. + colors.each.with_index do |col_max_value, i| + col, max_value = col_max_value + if max_value.nil? || value < max_value + # A max_value of nil means all values allowed. The value is in the + # allowed range for this set, so add the value as x/y pair to the + # set. + x_val = (r.timestamp.to_i - start_time) * 1000 + data_sets[i] << [ x_val, value ] + # Abort the color loop since we've found the right set already. + break + end + end + end + + # Now generate the JS variable definitions for each set. + s = '' + data_sets.each do |index, ds| + s << "var #{field}_data_#{index} = [\n" + s << ds.map { |dp| "[ #{dp[0]}, #{dp[1]} ]" }.join(', ') + s << " ];\n" + end + + s << "$.plot(\"##{field}_chart\", [\n" + s << data_sets.map do |index, ds| + "{ data: #{field}_data_#{index},\n" + + " color: \"#{colors[index][0]}\",\n" + + " points: { show: true, fillColor: \"#{colors[index][0]}\", " + + " fill: true, radius: 2 } }" + end.join(', ') + s << "], { xaxis: { mode: \"time\" } });\n" + + s + end + + def chart_div(field, title) + " <div id=\"#{field}_content\">\n" + + " <div class=\"chart-container\">\n" + + " <b>#{title}</b>\n" + + " <div id=\"#{field}_chart\" class=\"chart-placeholder\">" + + "</div>\n" + + " </div>\n" + + " </div>\n" + end + + end + +end + diff --git a/lib/postrunner/Main.rb b/lib/postrunner/Main.rb index fadb89a..d35846b 100644 --- a/lib/postrunner/Main.rb +++ b/lib/postrunner/Main.rb @@ -121,6 +121,9 @@ rename <ref> Replace the FIT file name with a more meaningful name that describes the activity. +show <ref> + Show the FIT activity in a web browser. + summary <ref> Display the summary information for the FIT file. EOT @@ -153,6 +156,8 @@ EOT @activities.show_records when 'rename' process_activities(args, :rename) + when 'show' + process_activities(args, :show) when 'summary' process_activities(args, :summary) when nil @@ -227,6 +232,8 @@ EOT activity.dump(@filter) when :rename @activities.rename(activity, @name) + when :show + activity.show when :summary activity.summary else diff --git a/lib/postrunner/PersonalRecords.rb b/lib/postrunner/PersonalRecords.rb new file mode 100644 index 0000000..fab0058 --- /dev/null +++ b/lib/postrunner/PersonalRecords.rb @@ -0,0 +1,145 @@ +require 'fileutils' +require 'yaml' + +require 'fit4ruby' + +module PostRunner + + class PersonalRecords + + class Record + + attr_accessor :distance, :duration, :start_time, :fit_file + + def initialize(distance, duration, start_time, fit_file) + @distance = distance + @duration = duration + @start_time = start_time + @fit_file = fit_file + end + + end + + include Fit4Ruby::Converters + + def initialize(activities) + @activities = activities + @db_dir = activities.db_dir + @records_file = File.join(@db_dir, 'records.yml') + @records = [] + + load_records + end + + def register_result(distance, duration, start_time, fit_file) + @records.each do |record| + if record.duration > 0 + if duration > 0 + # This is a speed record for a popular distance. + if distance == record.distance + if duration < record.duration + record.duration = duration + record.start_time = start_time + record.fit_file = fit_file + Log.info "New record for #{distance} m in " + + "#{secsToHMS(duration)}" + return true + else + # No new record for this distance. + return false + end + end + end + else + if distance > record.distance + # This is a new distance record. + record.distance = distance + record.duration = 0 + record.start_time = start_time + record.fit_file = fit_file + Log.info "New distance record #{distance} m" + return true + else + # No new distance record. + return false + end + end + end + + # We have not found a record. + @records << Record.new(distance, duration, start_time, fit_file) + if duration == 0 + Log.info "New distance record #{distance} m" + else + Log.info "New record for #{distance}m in #{secsToHMS(duration)}" + end + + true + end + + def delete_activity(fit_file) + @records.delete_if { |r| r.fit_file == fit_file } + end + + def sync + save_records + end + + def to_s + record_names = { 1000.0 => '1 km', 1609.0 => '1 mi', 5000.0 => '5 km', + 21097.5 => '1/2 Marathon', 42195.0 => 'Marathon' } + t = FlexiTable.new + t.head + t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity', 'Date' ], + { :halign => :center }) + t.set_column_attributes([ + {}, + { :halign => :right }, + { :halign => :right }, + { :halign => :right }, + { :halign => :right }, + { :halign => :left } + ]) + t.body + @records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r| + activity = @activities.activity_by_fit_file(r.fit_file) + t.row((r.duration == 0 ? + [ 'Longest Run', '%.1f m' % r.distance, '-' ] : + [ record_names[r.distance], secsToHMS(r.duration), + speedToPace(r.distance / r.duration) ]) + + [ @activities.ref_by_fit_file(r.fit_file), + activity.name, r.start_time.strftime("%Y-%m-%d") ]) + end + t.to_s + end + + private + + def load_records + begin + if File.exists?(@records_file) + @records = YAML.load_file(@records_file) + else + Log.info "No records file found at '#{@records_file}'" + end + rescue StandardError + Log.fatal "Cannot load records file '#{@records_file}': #{$!}" + end + + unless @records.is_a?(Array) + Log.fatal "The personal records file '#{@records_file}' is corrupted" + end + end + + def save_records + begin + File.open(@records_file, 'w') { |f| f.write(@records.to_yaml) } + rescue StandardError + Log.fatal "Cannot write records file '#{@records_file}': #{$!}" + end + end + + end + +end + diff --git a/lib/postrunner/TrackView.rb b/lib/postrunner/TrackView.rb new file mode 100644 index 0000000..1110a29 --- /dev/null +++ b/lib/postrunner/TrackView.rb @@ -0,0 +1,150 @@ +require 'fit4ruby' + +module PostRunner + + class TrackView + + def initialize(activity, output_dir) + @activity = activity + @activity_id = activity.fit_file[0..-4] + @output_dir = output_dir + end + + def generate_html + s = <<EOT +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta name="viewport" content="width=device-width, + initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> + <meta name="apple-mobile-web-app-capable" content="yes"> +EOT + s << "<title>PostRunner: #{@activity.name}</title>\n" + s << <<EOT + <link rel="stylesheet" href="js/theme/default/style.css" type="text/css"> + <link rel="stylesheet" href="js/theme/default/google.css" type="text/css"> + <style> +.olControlAttribution { + bottom: 5px; +} + +.trackmap { + width: 600px; + height: 400px; + border: 1px solid #ccc; +} + </style> + <script src="js/OpenLayers.js"></script> + <script> +EOT + s << js_file + + s << <<EOT + </script> + </head> + <body onload="init()"> +EOT + s << "<h1 id=\"title\">PostRunner: #{@activity.name}</h1>\n" + s << <<EOT + <p id="shortdesc"> + Map view of a captured track. + </p> + <div id="map" class="trackmap"></div> + </body> +</html> +EOT + file_name = File.join(@output_dir, "#{@activity_id}.html") + begin + File.write(file_name, s) + rescue IOError + Log.fatal "Cannot write TrackViewer file '#{file_name}': #{$!}" + end + end + + private + + def js_file + script = <<EOT +var map; + +function init() { + var mercator = new OpenLayers.Projection("EPSG:900913"); + var geographic = new OpenLayers.Projection("EPSG:4326"); +EOT + + session = @activity.fit_activity.sessions[0] + center_long = session.swc_long + + (session.nec_long - session.swc_long) / 2.0 + center_lat = session.swc_lat + + (session.nec_lat - session.swc_lat) / 2.0 + last_lap = @activity.fit_activity.laps[-1] + + script << <<EOT + map = new OpenLayers.Map({ + div: "map", + projection: mercator, + layers: [ new OpenLayers.Layer.OSM() ], + center: new OpenLayers.LonLat(#{center_long}, #{center_lat}).transform(geographic, mercator), + zoom: 13 + }); +EOT + script << <<"EOT" + track_layer = new OpenLayers.Layer.PointTrack("Track", + {style: {strokeColor: '#FF0000', strokeWidth: 5}}); + map.addLayer(track_layer); + track_layer.addNodes([ +EOT + track_points(script) + + script << <<"EOT" + ]); + var markers = new OpenLayers.Layer.Markers( "Markers" ); + map.addLayer(markers); + + var size = new OpenLayers.Size(21,25); + var offset = new OpenLayers.Pixel(-(size.w/2), -size.h); +EOT + set_marker(script, 'marker-green', session.start_position_long, + session.start_position_lat) + @activity.fit_activity.laps[0..-2].each do |lap| + set_marker(script, 'marker-blue', + lap.end_position_long, lap.end_position_lat) + end + set_marker(script, 'marker', + last_lap.end_position_long, last_lap.end_position_lat) + script << "\n};" + + script + end + + def track_points(script) + first = true + @activity.fit_activity.sessions.each do |session| + session.laps.each do |lap| + lap.records.each do |record| + long = record.position_long + lat = record.position_lat + if first + first = false + else + script << "," + end + script << <<"EOT" +new OpenLayers.Feature.Vector(new OpenLayers.Geometry.Point(#{long}, #{lat}).transform(geographic, mercator)) +EOT + end + end + end + end + + def set_marker(script, type, long, lat) + script << <<"EOT" + markers.addMarker(new OpenLayers.Marker(new OpenLayers.LonLat(#{long},#{lat}).transform(geographic, mercator),new OpenLayers.Icon('js/img/#{type}.png',size,offset))); +EOT + end + + end + +end + |