summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorChris Schlaeger <chris@linux.com>2014-08-09 21:46:25 +0200
committerChris Schlaeger <chris@linux.com>2014-08-09 21:46:25 +0200
commitdb9c514a07e962fc3106fd1b5d11a321e4ced579 (patch)
tree9ae0878212c0b52684ba304366cfac8b1646ab11 /lib
parent46a2f9644735b27bfe90e5599de4f6ff32b4bdf9 (diff)
downloadpostrunner-db9c514a07e962fc3106fd1b5d11a321e4ced579.zip
First version of HTML views.
Diffstat (limited to 'lib')
-rw-r--r--lib/postrunner/Activity.rb10
-rw-r--r--lib/postrunner/ChartView.rb211
-rw-r--r--lib/postrunner/Main.rb7
-rw-r--r--lib/postrunner/PersonalRecords.rb145
-rw-r--r--lib/postrunner/TrackView.rb150
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
+