#!/usr/bin/env ruby -w # encoding: UTF-8 # # = ChartView.rb -- PostRunner - Manage the data from your Garmin sport devices. # # Copyright (c) 2014 by Chris Schlaeger # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. # module PostRunner class ChartView def initialize(activity, unit_system) @activity = activity @sport = activity.fit_activity.sessions[0].sport @unit_system = unit_system @empty_charts = {} end def to_html(doc) doc.unique(:chartview_style) { doc.head { [ 'jquery/jquery-2.1.1.min.js', 'flot/jquery.flot.js', 'flot/jquery.flot.time.js' ].each do |js| doc.script({ 'language' => 'javascript', 'type' => 'text/javascript', 'src' => js }) end doc.style(style) } } doc.script(java_script) if @sport == 'running' chart_div(doc, 'pace', "Pace (#{select_unit('min/km')})") else chart_div(doc, 'speed', "Speed (#{select_unit('km/h')})") end chart_div(doc, 'altitude', "Elevation (#{select_unit('m')})") chart_div(doc, 'heart_rate', 'Heart Rate (bpm)') chart_div(doc, 'run_cadence', 'Run Cadence (spm)') chart_div(doc, 'vertical_oscillation', "Vertical Oscillation (#{select_unit('cm')})") chart_div(doc, 'stance_time', 'Ground Contact Time (ms)') end private def select_unit(metric_unit) case @unit_system when :metric metric_unit when :statute { 'min/km' => 'min/mi', 'm' => 'ft', 'cm' => 'in', 'km/h' => 'mph', 'bpm' => 'bpm', 'spm' => 'spm', 'ms' => 'ms' }[metric_unit] else Log.fatal "Unknown unit system #{@unit_system}" end end def style <").css({ position: "absolute", display: "none", border: "1px solid #888", padding: "2px", "background-color": "#EEE", opacity: 0.90, "font-size": "8pt" }).appendTo("body"); EOT end def line_graph(field, y_label, unit, color = nil) s = "var #{field}_data = [\n" data_set = [] start_time = @activity.fit_activity.sessions[0].start_time.to_i min_value = nil @activity.fit_activity.records.each do |r| value = r.get_as(field, select_unit(unit)) if field == 'pace' # Slow speeds lead to very large pace values that make the graph # hard to read. We cap the pace at 20.0 min/km to keep it readable. if value > (@unit_system == :metric ? 20.0 : 36.0 ) value = nil else value = (value * 3600.0 * 1000).to_i end else min_value = value if value && (min_value.nil? || min_value > value) end data_set << [ ((r.timestamp.to_i - start_time) * 1000).to_i, value ] end # We don't want to plot charts with all nil values. unless data_set.find { |v| v[1] != nil } @empty_charts[field] = true return '' end s << data_set.map do |set| "[ #{set[0]}, #{set[1] ? set[1] : 'null'} ]" end.join(', ') chart_id = "#{field}_chart" s << <<"EOT" ]; $.plot(\"##{chart_id}\", [ { data: #{field}_data, #{color ? "color: \"#{color}\"," : ''} lines: { show: true#{field == 'pace' ? '' : ', fill: true'} } } ], { xaxis: { mode: "time" }, grid: { hoverable: true } EOT if field == 'pace' s << ", yaxis: { mode: \"time\",\n" + " transform: function (v) { return -v; },\n" + " inverseTransform: function (v) { return -v; } }" else # Set the minimum slightly below the lowest found value. s << ", yaxis: { min: #{0.9 * min_value} }" end s << "});\n" s << hover_function(chart_id, y_label, select_unit(unit)) + "\n" end def point_graph(field, y_label, unit, colors) # 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.send(field)) # 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, r.get_as(field, select_unit(unit)) ] # Abort the color loop since we've found the right set already. break end end end # We don't want to plot charts with all nil values. if data_sets.values.flatten.empty? @empty_charts[field] = true return '' 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 chart_id = "#{field}_chart" s << "$.plot(\"##{chart_id}\", [\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\" }, grid: { hoverable: true } });\n" s << hover_function(chart_id, y_label, select_unit(unit)) s end def chart_div(doc, field, title) # Don't plot frame for graph without data. return if @empty_charts[field] ViewFrame.new(title) { doc.div({ 'id' => "#{field}_chart", 'class' => 'chart-placeholder'}) }.to_html(doc) end def hover_function(chart_id, y_label, y_unit) <<"EOT" $("##{chart_id}").bind("plothover", function (event, pos, item) { if (item) { var x = timeToHMS(item.datapoint[0]); var y = #{y_label == 'Pace' ? 'timeToHMS(item.datapoint[1] / 60)' : 'item.datapoint[1].toFixed(0)'}; $("#tooltip").html("#{y_label}: " + y + " #{y_unit}
" + "Time: " + x + " h:m:s") .css({top: item.pageY-20, left: item.pageX+15}) .fadeIn(200); } else { $("#tooltip").hide(); } }); EOT end end end