#!/usr/bin/perl use strict; use lib '/usr/built/lib/perl5/site_perl'; use Getopt::Long; use File::Basename; use File::Find; use File::Copy; use FileHandle; use POSIX qw(strftime ceil floor pow); use Date::Manip; use CGI ':standard'; use Data::Dumper; use S710; use GD; use Statistics::LineFit; use Math::Trig; use constant Y_AXIS_POWER => 4; # Parse command line options our $opt_srddir = "$ENV{HOME}/Polar"; our $opt_name = "Tux"; our $opt_notes; our $opt_summary; our $opt_supplement; our $opt_force; our $opt_help; if (not GetOptions("srddir=s", "name=s", "notes=s", "summary=s", "supplement=s", "force", "help") or $opt_help) { die "USAGE: $0 [-srddir directory] [-name name] [-notes file] [-summary file] [-force] [-help]\n"; } # Find all raw workout data files my @rawfiles = ( ); find(sub { if($File::Find::name =~ /\.srd$/) { push(@rawfiles, $File::Find::name); } }, $opt_srddir); # If there was a notes file, read it in my $notes = { }; if (defined($opt_notes)) { my $fh = FileHandle->new($opt_notes); while (<$fh>) { if ($_ =~ /^\s*(.*?):\s+(.*)$/) { my $unixtime = UnixDate(ParseDate($1), "%s"); if (defined($unixtime)) { $notes->{$unixtime} = $2; } } } } # If there was an other workouts file, read it in my $supplements = [ ]; if (defined($opt_supplement)) { my $fh = FileHandle->new($opt_supplement); while (<$fh>) { if ($_ =~ /^\s*(\S+)\s+(\S+)\s+(.+?)\s+(\S+)\s*$/) { my $unixtime = UnixDate(ParseDate("01:00 AM $1"), "%s"); if (defined($unixtime)) { push(@{$supplements}, { unixtime => $unixtime, date => strftime("%m/%d/%y", localtime($unixtime)), type => $2, distance => $3, duration => $4 }); } } } } # Style sheet used by the generated pages (see below BEGIN block) my $style; # Associative array to hold workout summaries for generating the index page my $workouts = { }; # For each raw workout data file that was found... foreach my $rawfile ( @rawfiles ) { my $basefile = basename($rawfile); my $rawtime = $opt_force ? time() : (stat($rawfile))[9]; # Get the S710 package to parse the raw workout data my $workout = S710::full_workout(dirname($rawfile), $basefile); if (not defined($workout)) { print STDERR "Error: Could not read raw workout data from file \"$rawfile\".\n"; next; } # Filter out unreasonably low or high heart rate readings from the data for (my $i=1; $i<$workout->{samples}; $i++) { if ($workout->{hr_data}->[$i] < 50 or $workout->{hr_data}->[$i] > 190) { $workout->{hr_data}->[$i] = $workout->{hr_data}->[$i-1]; } } if (not $workout->{no_speed}) { if ($workout->{avg_speed} < 1) { $workout->{no_speed} = 1; $workout->{no_power} = 1; } } if (not $workout->{no_speed}) { $workout->{type} = "Bike"; } # Filter out unreasonably high speed readings from the data if (not $workout->{no_speed}) { for (my $i=1; $i<$workout->{samples}; $i++) { if ($workout->{speed_data}->[$i] > 45) { $workout->{speed_data}->[$i] = $workout->{speed_data}->[$i-1]; } } } # Compute the sample numbers of the lap boundaries for (my $i=0; $i<$workout->{laps}; $i++) { if ($workout->{lap_data}->[$i]->{cumulative} =~ /^(\d+):(\d{2}):(\d{2})/) { $workout->{lap_data}->[$i]->{end_sample} = int(($1*3600 + $2*60 + $3) / $workout->{recording_interval}); } } # Compute the minimum and maximum heart rates here (filter out # unreasonably low or high heart rate readings from the raw data) my ($minhr, $maxhr, $avghr, $counthr) = (300, 0, 0, 0); foreach my $hr ( @{$workout->{hr_data}} ) { if ($hr > $maxhr) { $maxhr = $hr; } if ($hr < $minhr) { $minhr = $hr; } $avghr += $hr; $counthr++; } $avghr = int($avghr/$counthr); if ($avghr == 0 and $maxhr == 0) { $avghr = "N"; $maxhr = "A"; } # Compute the maximum speed my $maxspd = 0; if (not $workout->{no_speed}) { $maxspd = (sort { $b <=> $a } @{$workout->{speed_data}})[0]; } if (not $workout->{no_alt}) { for (my $i=0; $i<$workout->{samples}; $i++) { if ($workout->{alt_data}->[$i] != 0) { for (; $i>=1; $i--) { $workout->{alt_data}->[$i-1] = $workout->{alt_data}->[$i]; } last; } } } # Flatten the power data into a simple array (removing L/R balance and pedaling index) my ($maxpow, $avgpow); if (not $workout->{no_power} and ref($workout->{power_data}->[0]) eq "ARRAY") { $workout->{power_data} = [ map { $_->[0] } @{$workout->{power_data}} ]; $maxpow = (sort { $a <=> $b } @{$workout->{power_data}})[-1]; $avgpow = 0; my $count = 0; for (my $i=0; $i<$workout->{samples}; $i++) { if ($workout->{power_data}->[$i] > 0) { $avgpow += $workout->{power_data}->[$i]; $count++; } } if ($count > 0) { $avgpow = ceil($avgpow / $count * 10) / 10; } my $period = 30 / $workout->{recording_interval}; if ($period > 1) { for (my $i=$workout->{samples}-1; $i>=($period-1); $i--) { my $sum = 0; for (my $j=$i-($period-1); $j<=$i; $j++) { $sum += $workout->{power_data}->[$j]; } $sum /= $period; $workout->{power_data}->[$i] = $sum; } } } $maxpow = ceil($maxpow * 10) / 10; # If elevation data is available, but the variation # is very small, treat it as if it doesn't exist if (not $workout->{no_alt}) { my ($minalt, $maxalt) = (sort { $a <=> $b } @{$workout->{alt_data}})[0,-1]; if (($maxalt - $minalt) < 50) { $workout->{no_alt} = 1; # If elevation data was available and the range was too small, but speed # data is available, assume this was a bike workout on the indoor trainer # and compute power data from speed data if (not $workout->{no_speed}) { if (not $workout->{no_power}) { $workout->{power_data2} = $workout->{power_data}; } $workout->{power_data} = [ ]; for (my $i=0; $i<$workout->{samples}; $i++) { my $sum = 0; for (my $j=($i<5)?0:$i-5; $j<=$i; $j++) { $sum += $workout->{speed_data}->[$j]; } $sum /= ($i<5) ? $i+1 : 6; $workout->{power_data}->[$i] = 5.244820 * $sum + 0.019168 * pow($sum, 3); } delete($workout->{no_power}); } } } my ($IF, $TSS); if (not $workout->{no_power}) { my $avg = 0; for (my $i=5; $i<@{$workout->{power_data}}; $i++) { $avg += $workout->{power_data}->[$i] ** 4; } $avg = ($avg / ($workout->{samples} - 5)) ** 0.25; $IF = $avg / 200; $TSS = $avg * $workout->{samples} * $workout->{recording_interval} * $IF; $TSS /= 36 * 200; $IF = ceil($IF * 100) / 100; $TSS = ceil($TSS); } # Compute total climb amount my $climb = 0; if (not $workout->{no_alt}) { for (my $i=6; $i<@{$workout->{alt_data}}; $i++) { if ($workout->{alt_data}->[$i] > $workout->{alt_data}->[$i-1]) { $climb += $workout->{alt_data}->[$i] - $workout->{alt_data}->[$i-1]; } } } # If elevation and speed (and therefore distance) data is available... if (not $workout->{no_alt} and not $workout->{no_speed} and ($rawtime > (stat("images/$basefile.elt.png"))[9] or $rawtime > (stat("images/$basefile.eld.png"))[9])) { my @x; my @xi; my @y; $x[0] = 0; $xi[0] = 0; $y[0] = $workout->{alt_data}->[0]; for (my $i=1; $i<$workout->{samples}; $i++) { if (($workout->{dist_data}->[$i] - $x[-1]) >= 0.1) { push(@x, $x[-1]+0.1); push(@xi, $i); push(@y, $workout->{alt_data}->[$i]); } } # my $fh = FileHandle->new(">elev"); # for (my $i=0; $i<@x; $i++) { # $fh->print("$x[$i] $y[$i]\n"); # } # $fh->close(); my @results = piecewise(\@x, \@y); # my $fh = FileHandle->new(">fit"); # for (my $i=0; $i<@results; $i++) { # $fh->print("$x[$results[$i]] $y[$results[$i]]\n"); # } # $fh->close(); for (my $i=0; $i<@results-2; $i++) { my ($x0, $x1, $x2) = @x[@results[$i..$i+2]]; my ($y0, $y1, $y2) = @y[@results[$i..$i+2]]; my $m1 = ($y1 - $y0) / (5280 * ($x1 - $x0)); my $m2 = ($y2 - $y1) / (5280 * ($x2 - $x1)); my $theta = atan(($m2 - $m1) / (1 + $m1*$m2)); if (abs(rad2deg($theta)) < 1.15) { splice(@results, $i+1, 1); $i--; } elsif (($m1 * $m2) >= 0 and abs(rad2deg($theta)) < 2 and (($x1 - $x0) < 0.25 or ($x2 - $x1) < 0.25)) { splice(@results, $i+1, 1); $i--; } } # my $fh = FileHandle->new(">fit2"); # for (my $i=0; $i<@results; $i++) { # $fh->print("$x[$results[$i]] $y[$results[$i]]\n"); # } # $fh->close(); for (my $i=0; $i<$#results; $i++) { my ($x0, $x1) = @x[@results[$i..$i+1]]; my ($y0, $y1) = @y[@results[$i..$i+1]]; my $m = ($y1 - $y0) / (5280 * ($x1 - $x0)); if (($x1 - $x0) >= 0.2 and $m >= 0.025) { my $begin = $xi[$results[$i]]; my $end = $xi[$results[$i+1]]; while ($workout->{alt_data}->[$begin+1] <= $workout->{alt_data}->[$begin]) { $begin++; } while ($workout->{alt_data}->[$end-1] >= $workout->{alt_data}->[$end]) { $end--; } $m = ($workout->{alt_data}->[$end] - $workout->{alt_data}->[$begin]) / (5280 * ($workout->{dist_data}->[$end] - $workout->{dist_data}->[$begin])); if ($m >= 0.03) { push(@{$workout->{big_climbs}}, { begin => $begin, end => $end, sample => ($begin+$end)/2, slope => $m }); } } } } for (my $i=0; $i<$workout->{laps}; $i++) { my $sum = 0; my $count = 0; for (my $sample = $i ? $workout->{lap_data}->[$i-1]->{end_sample}+1 : 0; $sample < $workout->{lap_data}->[$i]->{end_sample}; $sample++) { if ($workout->{speed_data}->[$sample] >= 3) { $sum += $workout->{speed_data}->[$sample]; $count++; } } $workout->{lap_data}->[$i]->{avg_speed} = $count ? $sum/$count : 0; } # Generate the heart rate histogram plot if ($rawtime > (stat("images/$basefile.hrh.png"))[9]) { S710::plot_byte_histogram($rawfile, S710::Y_AXIS_HEART_RATE, '#ff0000', 80, 200, 320, 180, 1, "images/$basefile.hrh.png"); } # Generate the heart rate zones plot if ($rawtime > (stat("images/$basefile.hrz.png"))[9]) { S710::plot_byte_zones($rawfile, S710::Y_AXIS_HEART_RATE, 320, 180, "images/$basefile.hrz.png"); } # Generate the heart rate throughout workout plot if ($rawtime > (stat("images/$basefile.hrt.png"))[9]) { plot_workout_xy($workout, S710::Y_AXIS_HEART_RATE, S710::X_AXIS_TIME, '#ff0000', S710::TIC_LINES, 800, 240, "images/$basefile.hrt.png"); } # If distance (speed) data is also available, generate a plot against distance if (not $workout->{no_speed}) { if ($rawtime > (stat("images/$basefile.hrd.png"))[9]) { plot_workout_xy($workout, S710::Y_AXIS_HEART_RATE, S710::X_AXIS_DISTANCE, '#ff0000', S710::TIC_LINES, 800, 240, "images/$basefile.hrd.png"); } } # Generate elevation throughout workout plot if (not $workout->{no_alt}) { if ($rawtime > (stat("images/$basefile.elt.png"))[9]) { plot_workout_xy($workout, S710::Y_AXIS_ALTITUDE, S710::X_AXIS_TIME, '#00ff00', S710::TIC_LINES|S710::TIC_SHADE_GREEN, 800, 240, "images/$basefile.elt.png"); } # If distance (speed) data is also available, generate a plot against distance if (not $workout->{no_speed}) { if ($rawtime > (stat("images/$basefile.eld.png"))[9]) { plot_workout_xy($workout, S710::Y_AXIS_ALTITUDE, S710::X_AXIS_DISTANCE, '#00ff00', S710::TIC_LINES|S710::TIC_SHADE_GREEN, 800, 240, "images/$basefile.eld.png"); } } } # Generate power throughout workout plot if (not $workout->{no_power}) { if ($rawtime > (stat("images/$basefile.pot.png"))[9]) { plot_workout_xy($workout, Y_AXIS_POWER, S710::X_AXIS_TIME, '#0000ff', S710::TIC_LINES, 800, 240, "images/$basefile.pot.png"); } if ($rawtime > (stat("images/$basefile.pod.png"))[9]) { plot_workout_xy($workout, Y_AXIS_POWER, S710::X_AXIS_DISTANCE, '#0000ff', S710::TIC_LINES, 800, 240, "images/$basefile.pod.png"); } } # Generate speed throughout workout plot if (not $workout->{no_speed}) { if ($rawtime > (stat("images/$basefile.spt.png"))[9]) { plot_workout_xy($workout, S710::Y_AXIS_SPEED, S710::X_AXIS_TIME, '#0000ff', S710::TIC_LINES, 800, 240, "images/$basefile.spt.png"); } if ($rawtime > (stat("images/$basefile.spd.png"))[9]) { plot_workout_xy($workout, S710::Y_AXIS_SPEED, S710::X_AXIS_DISTANCE, '#0000ff', S710::TIC_LINES, 800, 240, "images/$basefile.spd.png"); } } # Put the workout summary aside for later use (during index generation) $workouts->{$workout->{unixtime}} = { workout => $workout, type => undef, unixtime => $workout->{unixtime}, date => strftime("%m/%d/%y", localtime($workout->{unixtime}-2*60*60)), duration => $workout->{duration}, distance => $workout->{exercise_distance}, avg_hr => $avghr, max_hr => $maxhr, max_spd => $maxspd, climb => $climb, avg_pow => $avgpow, max_pow => $maxpow, if => $IF, tss => $TSS, basefile => $basefile, url => "$basefile.html", rawtime => $rawtime }; } my @workouts = sort { $a->{unixtime} <=> $b->{unixtime} } values(%{$workouts}); map { $_->{rawtime} = time() } @workouts[-13..-1]; for (my $i=0; $i<@workouts; $i++) { my $workout = $workouts[$i]; if ($workout->{rawtime} < (stat($workout->{url}))[9]) { next; } # print Dumper($workout); # Create the file that will hold the HTML for this workout my $fh = FileHandle->new(">$workout->{url}"); # Create the CGI object used for HTML generation my $cgi = CGI::new(); # Start the HTML print $fh $cgi->start_html(-title => "${opt_name}'s Training Log: " . strftime("%A, %B %d %G %I:%M %p", localtime($workout->{unixtime})), -style => { -code => $style }, -bgcolor => "#ffffff", -script => { -language => 'JAVASCRIPT', -code => generateJavaScript($workout->{basefile}, $workout->{workout}) }); # Big table holds everything for layout purposes print $fh "
\n"; # Heading with the workout date and time print $fh br(), "\n"; print $fh "\n"; print $fh "\n"; print $fh "\n"; print $fh "
", ($i > 0) ? "{url}\">" : " ", "\n"; print $fh "", h1(strftime("%A, %B %d %G %I:%M %p", localtime($workout->{unixtime}))), "", ($i+1 < @workouts) ? "{url}\">" : " ", "
\n"; # First section contains workout summary information print $fh h3("Summary"), "\n"; my @rows; push(@rows, Tr(td(["Workout length:", "$workout->{workout}->{duration}    ", "Heart rate (avg / max):", "$workout->{avg_hr} / $workout->{max_hr} bpm"]))); if (not $workout->{workout}->{no_speed}) { push(@rows, Tr(td(["Average speed:", "$workout->{workout}->{avg_speed} mph", "Maximum speed:", sprintf("%.1f mph", $workout->{max_spd})]))); } if (not $workout->{workout}->{no_alt}) { push(@rows, Tr(td(["Average elevation:", "$workout->{workout}->{avg_alt} ft", "Maximum elevation:", "$workout->{workout}->{max_alt} ft"]))); } if (not $workout->{workout}->{no_speed} or not $workout->{workout}->{no_alt}) { push(@rows, Tr(td([$workout->{workout}->{no_speed} ? () : ("Distance ridden:", "$workout->{workout}->{exercise_distance} mi"), $workout->{workout}->{no_alt} ? () : ("Total climb:", "$workout->{climb} ft")]))); # push(@rows, Tr(td(["Distance ridden:", "$workout->{workout}->{exercise_distance} mi", # "Total climb:", "$workout->{climb} ft"]))); } if ($workout->{workout}->{energy} > 0) { push(@rows, Tr(td(["Calories burned:", "$workout->{workout}->{energy} kCal"]))); } if (not $workout->{workout}->{no_power}) { push(@rows, Tr(td(["Power (avg / max):", "$workout->{avg_pow} / $workout->{max_pow} W"]))); push(@rows, Tr(td(["IF / TSS:", "$workout->{if} / $workout->{tss}"]))); } print $fh table(@rows), "\n"; # If there is a user-provided note for this workout, include it here if (defined(my $note = $notes->{int($workout->{unixtime}/60)*60})) { print $fh h3("Notes"), "\n"; print $fh table(Tr(td(font({face => "courier", size => "-1"}, $note)))), "\n"; } # Next is the heart rate histogram and zone chart print $fh h3("Heart rate histogram and zone chart"), "\n"; print $fh table({-width => 484}, Tr(td({-align => "right"}, [img({-src => "images/$workout->{basefile}.hrh.png"}), img({-src => "images/$workout->{basefile}.hrz.png"})]))), "\n"; # Followed by the heart rate vs time chart if (not $workout->{workout}->{no_speed}) { print $fh h3(table({-width => "100%", -border => 0, -cellpadding => 0, -cellspacing => 0}, Tr(td({-align => "left", -id => "hrheading"}, "Heart rate vs. time"), td({-align => "right"}, "[ vs. distance ]")))), "\n"; } else { print $fh h3("Heart rate vs. time"), "\n"; } print $fh img({-id => "hrimage", -src => "images/$workout->{basefile}.hrt.png"}), "\n"; # If elevation data is available, the chart is included if (not $workout->{workout}->{no_alt}) { print $fh h3({-id => "elheading"}, "Elevation vs. time"), "\n"; print $fh img({-id => "elimage", -src => "images/$workout->{basefile}.elt.png"}), "\n"; } # If power data is available, the chart is included if (not $workout->{workout}->{no_power}) { print $fh h3({-id => "poheading"}, "Power vs. time"), "\n"; print $fh img({-id => "poimage", -src => "images/$workout->{basefile}.pot.png"}), "\n"; } # If speed data is available, the chart is included if (not $workout->{workout}->{no_speed}) { print $fh h3({-id => "spheading"}, "Speed vs. time"), "\n"; print $fh img({-id => "spimage", -src => "images/$workout->{basefile}.spt.png"}), "\n"; } # Close the layout table print $fh "
\n"; # End the HTML #print $fh $cgi->end_html(); print $fh "\n"; print $fh "\n"; # Close the HTML file $fh->close(); } # Create the file that will hold the HTML for the index my $fh = FileHandle->new(">index.html"); # Create the CGI object used for HTML generation my $cgi = CGI::new(); # Start the HTML print $fh $cgi->start_html(-title => "${opt_name}'s Training Log Contents", -style => { -code => $style }, -bgcolor => "#ffffff"); # Big table holds everything for layout purposes print $fh "
\n"; # If there is a user-provided summary section for the index, include it here if (defined($opt_summary)) { print $fh h3("Summary"), "\n"; $fh->flush(); copy($opt_summary, $fh); } # Table of workout entries #print $fh h3("Workouts [Click on blue dates for details]"), "\n"; print $fh h3(table({-width => "100%", -border => 0, -cellpadding => 0, -cellspacing => 0}, Tr(td({-align => "left"}, "Workouts"), td({-align => "right"}, "[ click on blue dates for details ]")))), "\n"; print $fh "\n"; # List of alternating light and dark grey backgrounds for consecutive entries my @colors = ("#eeeeee", "#cccccc"); # Table headings print $fh Tr(td([b("Date"), b("Type"), b("Distance"), b("Duration")]) . td({align => "right"}, [b("HR (avg / max)")])), "\n"; foreach my $supplement (@{$supplements}) { my $workout; if (defined($workout = findMatchingWorkout($workouts, $supplement->{date}, $supplement->{duration})) and (not defined($workout->{type}) or $workout->{type} eq $supplement->{type})) { $workout->{type} = $supplement->{type}; if ($workout->{distance} == 0) { $workout->{distance} = $supplement->{distance}; } } else { if (exists($workouts->{$supplement->{unixtime}})) { $supplement->{unixtime}++; } $workouts->{$supplement->{unixtime}} = { unixtime => $supplement->{unixtime}, type => $supplement->{type}, distance => $supplement->{distance}, duration => $supplement->{duration}, avg_hr => "N", max_hr => "A" }; } } # For each workout summary, from a list sorted by date and time... foreach my $workout ( map { $workouts->{$_} } sort { $b <=> $a } keys(%{$workouts}) ) { if ($workout->{distance} =~ /\d$/) { $workout->{distance} .= " mi"; } # Generate a row of workout info print $fh Tr({bgcolor => $colors[0]}, defined($workout->{url}) ? td(a({href => "$workout->{url}"}, font({face => "courier", size => "-1"}, strftime("%a, %d %b %I:%M %p", localtime($workout->{unixtime}))))) : td(font({face => "courier", size => "-1"}, strftime("%a, %d %b", localtime($workout->{unixtime})))), td($workout->{type}), td($workout->{distance}), td($workout->{duration}), td({align => "right"}, "$workout->{avg_hr} / $workout->{max_hr}")), "\n"; # Flip colors ($colors[0], $colors[1]) = ($colors[1], $colors[0]); } # Close the workout entries table print $fh "
\n"; # Credit where credit's due... print $fh h3("Credits"), "\n"; print $fh "This online training log is made possible by Dave Bailey's S710 software for downloading and charting data from Polar heart rate monitors under Linux (and other operating systems). I've also borrowed the look and feel of his training log, but the Perl script that generates my pages is home-grown. Thanks Dave!\n"; # Close the layout table print $fh "
\n"; # End the HTML print $fh $cgi->end_html(); # Close the HTML file $fh->close(); sub compareDurations { my ($a, $b) = @_; $a =~ /^\s*(?:(\d\d?):)?(\d\d):(\d\d)/; $a = $1*3600+$2*60+$3; $b =~ /^\s*(?:(\d\d?):)?(\d\d):(\d\d)/; $b = $1*3600+$2*60+$3; return ($a > $b) ? ($b/$a) : ($a/$b); } sub findMatchingWorkout { my ($workouts, $date, $duration) = @_; my @workouts; foreach my $workout (values(%{$workouts})) { if ($workout->{date} eq $date) { push(@workouts, $workout); } } if (@workouts == 0) { return undef; } elsif (@workouts == 1) { if (compareDurations($workouts[0]->{duration}, $duration) >= 0.50) { return $workouts[0]; } else { return undef; } } @workouts = sort { compareDurations($b->{duration}, $duration) <=> compareDurations($a->{duration}, $duration) } @workouts; if (compareDurations($workouts[0]->{duration}, $duration) >= 0.85) { return $workouts[0]; } # foreach my $workout ( @workouts ) { # if (compareDurations($workout->{duration}, $duration) >= 0.85) { # return $workout; # } # } return undef; } sub generateJavaScript { my ($basefile, $workout) = @_; my @code; push(@code, "var hrmSamples = $workout->{samples};"); $workout->{duration} =~ /(?:(?:(\d+):)?(\d{2}):)?(\d{2}).\d$/; push(@code, "var hrmTime = @{[$1*3600+$2*60+$3]};"); push(@code, "var hrmDistance = @{[!$workout->{no_speed} ? $workout->{exercise_distance} : 0]};"); push(@code, "var hrmHRData = ["); for (my $i=0; $i<$workout->{samples}; $i++) { $code[$#code] .= "$workout->{hr_data}->[$i], "; } $code[$#code] .= "-1];"; if (not $workout->{no_alt}) { push(@code, "var hrmAltData = ["); for (my $i=0; $i<$workout->{samples}; $i++) { $code[$#code] .= "$workout->{alt_data}->[$i], "; } $code[$#code] .= "-1];"; } if (not $workout->{no_speed}) { push(@code, "var hrmDistData = ["); for (my $i=0; $i<$workout->{samples}; $i++) { $code[$#code] .= sprintf("%.1f, ", $workout->{dist_data}->[$i]); } $code[$#code] .= "-1];"; push(@code, "var hrmSpdData = ["); for (my $i=0; $i<$workout->{samples}; $i++) { $code[$#code] .= sprintf("%.1f, ", $workout->{speed_data}->[$i]); } $code[$#code] .= "-1];"; push(@code, "var distPixelToIndex = ["); my $j = 0; for (my $i=0; $i<=728; $i++) { while ($workout->{dist_data}->[$j+1] <= ($workout->{exercise_distance} / 728 * $i) and ($j+1) < $workout->{samples}) { $j++; } $code[$#code] .= "$j, "; } $code[$#code] .= "-1];"; } push(@code, "// Load the necessary images"); push(@code, "var vstime = 1;"); # Basic heart rate against time chart is always needed push(@code, "(new Image()).src='images/$basefile.hrt.png';"); # Heart rate agains distance chart is present only if speed # (and therefore distance) data for the workout is available if (not $workout->{no_speed}) { push(@code, "(new Image()).src='images/$basefile.hrd.png';"); } # Elevation against time chart is present if # elevation data for the workout is available if (not $workout->{no_alt}) { push(@code, "(new Image()).src='images/$basefile.elt.png';"); # Elevation against distance chart is present if speed (and therefore # distance) data for the workout is available in addition to elevation if (not $workout->{no_speed}) { push(@code, "(new Image()).src='images/$basefile.eld.png';"); } } # Power against time chart is present if # power data for the workout is available if (not $workout->{no_power}) { push(@code, "(new Image()).src='images/$basefile.pot.png';"); # Power against distance chart is present if speed (and therefore # distance) data for the workout is available in addition to power if (not $workout->{no_speed}) { push(@code, "(new Image()).src='images/$basefile.pod.png';"); } } # Both speed charts are present if speed (and therefore # distance) data for the workout is available if (not $workout->{no_speed}) { push(@code, "(new Image()).src='images/$basefile.spt.png';"); push(@code, "(new Image()).src='images/$basefile.spd.png';"); } # Image toggling is needed only if speed (and therefore # distance) data for the workout is available if (not $workout->{no_speed}) { push(@code, "// Function to toggle between the different sets of chart images"); push(@code, "// with the data plotted alternately against time and distance"); push(@code, "function toggleXAxes() {"); push(@code, " if (vstime) {"); push(@code, " document.getElementById('hrheading').innerHTML='Heart rate vs. distance';"); push(@code, " document.getElementById('hrimage').src='images/$basefile.hrd.png';"); if (not $workout->{no_alt}) { push(@code, " document.getElementById('elheading').innerHTML='Elevation vs. distance';"); push(@code, " document.getElementById('elimage').src='images/$basefile.eld.png';"); } if (not $workout->{no_power}) { push(@code, " document.getElementById('poheading').innerHTML='Power vs. distance';"); push(@code, " document.getElementById('poimage').src='images/$basefile.pod.png';"); } push(@code, " document.getElementById('spheading').innerHTML='Speed vs. distance';"); push(@code, " document.getElementById('spimage').src='images/$basefile.spd.png';"); push(@code, " document.getElementById('vsanchor').innerHTML='vs. time';"); push(@code, " vstime = 0;"); push(@code, " }"); push(@code, " else {"); push(@code, " document.getElementById('hrheading').innerHTML='Heart rate vs. time';"); push(@code, " document.getElementById('hrimage').src='images/$basefile.hrt.png';"); if (not $workout->{no_alt}) { push(@code, " document.getElementById('elheading').innerHTML='Elevation vs. time';"); push(@code, " document.getElementById('elimage').src='images/$basefile.elt.png';"); } if (not $workout->{no_power}) { push(@code, " document.getElementById('poheading').innerHTML='Power vs. time';"); push(@code, " document.getElementById('poimage').src='images/$basefile.pot.png';"); } push(@code, " document.getElementById('spheading').innerHTML='Speed vs. time';"); push(@code, " document.getElementById('spimage').src='images/$basefile.spt.png';"); push(@code, " document.getElementById('vsanchor').innerHTML='vs. distance';"); push(@code, " vstime = 1;"); push(@code, " }"); push(@code, "}"); push(@code, ""); } push(@code, split("\n", < 728) mouseX = 728; if (vstime) sample = parseInt((hrmSamples - 1) * mouseX / 728); else sample = distPixelToIndex[mouseX]; seconds = hrmTime * mouseX / 728; hours = parseInt(seconds / 3600); minutes = parseInt((seconds - (hours * 3600)) / 60); seconds = parseInt(seconds % 60); text = '' + hours + ':'; if (minutes < 10) text += '0'; text += minutes + ':'; if (seconds < 10) text += '0'; text += seconds; if (hrmDistance > 0) { distance = '' + hrmDistData[sample]; text += ' / '; if (distance.indexOf('.') > -1) { text += distance.substr(0, distance.indexOf('.')+2); } else { text += distance + '.0'; } text += ' mi'; } text += ' ('; text += hrmHRData[sample]; text += ' bpm'; if (window.hrmAltData) { text += ' / '; text += hrmAltData[sample]; text += ' ft'; } if (window.hrmSpdData) { text += ' / '; text += hrmSpdData[sample]; text += ' mph'; } text += ')'; window.status = text; } EOF )); return join("\n", @code); } # Style sheet used by the generated pages BEGIN { $style = ' A:link { text-decoration: none; } A:visited { text-decoration: none; } .menu { font-size : 9pt; font-family : sans-serif; font-weight : bold; font-style : normal; color : #003399; } .menu a { color : #003399; text-decoration : none; } .indent_both { padding-left: 1em; padding-right: 1em; padding-bottom: 1em; } .tt { font-size : 10pt; font-family : courier; } p { text-align: justify; } H1 { font-size : 14pt; font-family : Arial; font-weight : bold; font-style : normal; color : #003399; } H3 { font-size : 12pt; font-family : Arial; font-weight : normal; font-style : normal; color : #000000; border-bottom-width : 3px; border-color : #003399; border-style : solid; border-left-width : 0px; border-right-width : 0px; border-top-width : 0px; }'; } BEGIN { my @plotInfoMetric = ( { divisor => 1, units => "bpm" }, { divisor => 1, units => "m" }, { divisor => 1, units => "kph" }, { divisor => 1, units => "rpm" }, { divisor => 1, units => "W" } ); my @plotInfoEnglish = ( { divisor => 1, units => "bpm" }, { divisor => 1, units => "ft" }, { divisor => 1, units => "mph" }, { divisor => 1, units => "rpm" }, { divisor => 1, units => "W" } ); sub plot_workout_xy { my ($workout, $y_axis, $x_axis, $color, $tic, $width, $height, $filename) = @_; if (not ($y_axis == S710::Y_AXIS_HEART_RATE or ($y_axis == S710::Y_AXIS_ALTITUDE and not $workout->{no_alt}) or ($y_axis == S710::Y_AXIS_SPEED and not $workout->{no_speed}) or ($y_axis == S710::Y_AXIS_CADENCE and not $workout->{no_cadence}) or ($y_axis == Y_AXIS_POWER and not $workout->{no_power}))) { return undef; } my $p; if ($workout->{'units.system'} == 0) { $p = $plotInfoMetric[$y_axis]; } else { $p = @plotInfoEnglish[$y_axis]; } my $x0 = 15 + 6 * (6 + 1); my $x1 = $width - 15; my $y0 = 15; my $y1 = $height - 15 - 10; my $xmax; my $ppx = 0; my @xdata; if ($x_axis == S710::X_AXIS_TIME) { $xmax = $workout->{samples} * $workout->{recording_interval}; for (my $i=0; $i<$workout->{samples}; $i++) { $xdata[$i] = $i * $workout->{recording_interval}; } } elsif ($x_axis == S710::X_AXIS_DISTANCE and not $workout->{no_speed}) { $xmax = ceil($workout->{dist_data}->[-1] * 1000); @xdata = @{$workout->{dist_data}}; } else { return undef; } if ($xmax > 0) { $ppx = ($x1 - $x0) / $xmax; } my $ymin; my $ymax; my $ppy; my @ydata; if ($y_axis == S710::Y_AXIS_HEART_RATE) { ($ymin, $ymax) = (sort {$a <=> $b} (@ydata = @{$workout->{hr_data}}))[0,-1]; } elsif ($y_axis == S710::Y_AXIS_ALTITUDE) { ($ymin, $ymax) = (sort {$a <=> $b} (@ydata = @{$workout->{alt_data}}))[0,-1]; } elsif ($y_axis == S710::Y_AXIS_SPEED) { ($ymin, $ymax) = (sort {$a <=> $b} (@ydata = @{$workout->{speed_data}}))[0,-1]; } elsif ($y_axis == S710::Y_AXIS_CADENCE) { ($ymin, $ymax) = (sort {$a <=> $b} (@ydata = @{$workout->{cad_data}}))[0,-1]; } elsif ($y_axis == Y_AXIS_POWER) { ($ymin, $ymax) = (sort {$a <=> $b} (@ydata = @{$workout->{power_data}}))[0,-1]; } $ymax += 0.025 * ($ymax - $ymin); if ($ymin > 0) { $ymin -= 0.025 * ($ymax - $ymin); } if (($ymax - $ymin) < 0.01) { $ymax = 1; } $ppy = ($y1 - $y0) / ($ymax - $ymin); if ($x_axis == S710::X_AXIS_TIME) { for (my $i=0; $i<$workout->{samples}; $i++) { $xdata[$i] = $x0 + $ppx * $i * $workout->{recording_interval}; } } elsif ($x_axis == S710::X_AXIS_DISTANCE and not $workout->{no_speed}) { for (my $i=0; $i<$workout->{samples}; $i++) { $xdata[$i] = $x0 + $ppx * $workout->{dist_data}->[$i] * 1000; } } map { $_ = $y1 - $ppy * ($_ / $p->{divisor} - $ymin) } @ydata; my $image = GD::Image->new($width, $height); $color =~ /\#([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/; my ($white, $black, $red, $pixel) = ( $image->colorAllocate(255, 255, 255), $image->colorAllocate(0, 0, 0), $image->colorAllocate(255, 0, 0), $image->colorAllocate(hex($1), hex($2), hex($3)) ); $image->filledRectangle(0, 0, $width, $height, $white); unshift(@xdata, $x0); unshift(@ydata, $y1); push(@xdata, $x1); push(@ydata, $y1); draw_x_axis($image, $x_axis, undef, $xmax, $x0, $y1, $x1, $y1, $black); draw_y_axis($image, undef, $p->{units}, $ymin, $ymax, $tic, $x0, $y1, $x1, $y0, $black); if ($tic & S710::TIC_SHADE) { my $poly = GD::Polygon->new(); for (my $i=0; $i<$workout->{samples}+2; $i++) { $poly->addPt($xdata[$i], $ydata[$i]); } $image->filledPolygon($poly, $pixel); $image->polygon($poly, $black); } else { for (my $i=0; $i<$workout->{samples}; $i++) { $image->line($xdata[$i], $ydata[$i], $xdata[$i+1], $ydata[$i+1], $pixel); } } if ($y_axis == Y_AXIS_POWER and exists($workout->{power_data2})) { @ydata = @{$workout->{power_data2}}; map { $_ = $y1 - $ppy * ($_ / $p->{divisor} - $ymin) } @ydata; $pixel = $image->colorAllocate(192, 192, 255); for (my $i=0; $i<$workout->{samples}; $i++) { $image->line($xdata[$i], $ydata[$i], $xdata[$i+1], $ydata[$i+1], $pixel); } } if ($y_axis == S710::Y_AXIS_ALTITUDE) { foreach my $climb (@{$workout->{big_climbs}}) { my $x; if ($x_axis == S710::X_AXIS_TIME) { $x = $x0 + $ppx * $climb->{sample} * $workout->{recording_interval}; } elsif ($x_axis == S710::X_AXIS_DISTANCE) { $x = $x0 + $ppx * $workout->{dist_data}->[$climb->{sample}] * 1000; } my $y = $y1 - $ppy * ($workout->{alt_data}->[$climb->{sample}] / $p->{divisor} - $ymin); my $buf = sprintf("%.1f%%", $climb->{slope}*100); for (my $i=$climb->{begin}; $i<=$climb->{end}; $i++) { $image->line($xdata[$i], $ydata[$i], $xdata[$i+1], $ydata[$i+1], $red); $image->line($xdata[$i]+1, $ydata[$i], $xdata[$i+1]+1, $ydata[$i+1], $red); } foreach my $i (-1, 1, 0) { foreach my $j (-1, 1, 0) { $image->string(gdSmallFont, $x+$i, $y+$j, $buf, ($i == 0 and $j == 0) ? $black : $white); } } } } for (my $i=0, my $last_x=0; $i<$workout->{laps}-1; $i++) { my $x; if ($x_axis == S710::X_AXIS_TIME) { $x = ceil($x0 + $ppx * $workout->{lap_data}->[$i]->{end_sample} * $workout->{recording_interval}); } elsif ($x_axis == S710::X_AXIS_DISTANCE and not $workout->{no_speed}) { $x = ceil($x0 + $ppx * $workout->{dist_data}->[$workout->{lap_data}->[$i]->{end_sample}] * 1000); } $image->line($x, $y0, $x, $y1, $red); if (($x - $last_x) > 40) { $image->string(gdTinyFont, $x-36, $y1-10, substr($workout->{lap_data}->[$i]->{split}, 0, 7), $red); if (($y_axis == S710::Y_AXIS_ALTITUDE or $y_axis == S710::Y_AXIS_SPEED) and not $workout->{no_speed}) { my $s0 = $i ? $workout->{lap_data}->[$i-1]->{end_sample} : 0; my $s1 = $workout->{lap_data}->[$i]->{end_sample}; my $t0 = $s0 * $workout->{recording_interval}; my $t1 = $s1 * $workout->{recording_interval}; my $d0 = $workout->{dist_data}->[$s0]; my $d1 = $workout->{dist_data}->[$s1]; my $mph = sprintf("%.1f mph", $workout->{lap_data}->[$i]->{avg_speed}); $image->string(gdTinyFont, $x-41, $y1-20, $mph, $red); } if ($y_axis == S710::Y_AXIS_HEART_RATE) { my $sum = 0; my $count = 0; my $s0 = $i ? $workout->{lap_data}->[$i-1]->{end_sample} : 0; my $s1 = $workout->{lap_data}->[$i]->{end_sample}; for (my $i=$s0; $i<=$s1; $i++) { if ($workout->{hr_data}->[$i] > 0) { $sum += $workout->{hr_data}->[$i]; $count++; } } $sum = ceil($sum / $count); $image->string(gdTinyFont, $x-41, $y1-20, sprintf("%4d bpm", $sum), $red); } if ($y_axis == Y_AXIS_POWER and not $workout->{no_power}) { my $sum = 0; my $count = 0; my $s0 = $i ? $workout->{lap_data}->[$i-1]->{end_sample} : 0; my $s1 = $workout->{lap_data}->[$i]->{end_sample}; for (my $i=$s0; $i<=$s1; $i++) { if ($workout->{power_data}->[$i] > 0) { $sum += $workout->{power_data}->[$i]; $count++; } } if ($count > 0) { $sum = ceil($sum / $count); } $image->string(gdTinyFont, $x-41, $y1-20, sprintf("%6d W", $sum), $red); } $last_x = $x; } } $image->rectangle($x0, $y0, $x1, $y1, $black); my $fh = FileHandle->new(">$filename"); $fh->print($image->png()); $fh->close(); } } sub draw_x_axis { my ($image, $x_axis, $units, $max_x, $xi, $yi, $xf, $yf, $pixel) = @_; my $x_units; my $ts; my $tic; if ($x_axis == S710::X_AXIS_DISTANCE) { $ts = $max_x / 1000; if ($ts < 2) { $tic = 200; } # meters or ft elsif ($ts < 5) { $tic = 500; } # meters or ft elsif ($ts < 10) { $tic = 1; } # km or mi elsif ($ts < 20) { $tic = 2; } elsif ($ts < 30) { $tic = 3; } elsif ($ts < 40) { $tic = 4; } elsif ($ts < 50) { $tic = 5; } elsif ($ts < 80) { $tic = 8; } elsif ($ts < 120) { $tic = 10; } elsif ($ts < 150) { $tic = 12; } elsif ($ts < 180) { $tic = 15; } elsif ($ts < 240) { $tic = 20; } else { $tic = 30; } if ($tic > 100) { $x_units = "ft"; $ts *= 1000.0; } else { $x_units = "mi"; } } else { $ts = $max_x / 60; if ($ts < 10) { $tic = 2; } elsif ($ts < 30) { $tic = 5; } elsif ($ts < 60) { $tic = 10; } elsif ($ts < 120) { $tic = 20; } elsif ($ts < 180) { $tic = 30; } elsif ($ts < 270) { $tic = 45; } else { $tic = 1; } # hour if ($tic == 1) { $x_units = "hr"; $ts /= 60; } else { $x_units = "min"; } } my $ppx = ($xf - $xi) / $ts; my $tdx = gdSmallFont->width/2; my $tdy = gdSmallFont->height/2; $image->line($xi, $yi, $xi, $yi+4, $pixel); $image->string(gdSmallFont, $xi-$tdx, $yi+$tdy, "0 $x_units", $pixel); for (my $i=$tic; $i<$ts; $i+=$tic) { my $dx = $ppx*$i; $image->line($xi+$dx, $yi, $xi+$dx, $yi+4, $pixel); my $buf = sprintf("%d", $i); $image->string(gdSmallFont, $xi+$dx-$tdx*length($buf), $yi+$tdy, $buf, $pixel); } } sub draw_y_axis { my ($image, $side, $units, $ymin, $ymax, $tic_opts, $xi, $yi, $xf, $yf, $pixel) = @_; my $ts = $ymax - $ymin; my $tic; if ($ts < 6) { $tic = 1; } elsif ($ts < 12) { $tic = 2; } elsif ($ts < 18) { $tic = 3; } elsif ($ts < 24) { $tic = 4; } elsif ($ts < 30) { $tic = 5; } elsif ($ts < 40) { $tic = 6; } elsif ($ts < 50) { $tic = 8; } elsif ($ts < 80) { $tic = 10; } elsif ($ts < 100) { $tic = 15; } elsif ($ts < 150) { $tic = 20; } elsif ($ts < 200) { $tic = 25; } elsif ($ts < 240) { $tic = 30; } elsif ($ts < 400) { $tic = 50; } elsif ($ts < 600) { $tic = 75; } elsif ($ts < 800) { $tic = 100; } elsif ($ts < 1000) { $tic = 150; } elsif ($ts < 1500) { $tic = 200; } elsif ($ts < 2000) { $tic = 250; } elsif ($ts < 2400) { $tic = 300; } elsif ($ts < 3000) { $tic = 400; } else { $tic = 500; } my $st = floor($ymin-floor($ymin)%$tic); my $ppy = ($yi-$yf)/$ts; my $tdx = gdSmallFont->width; my $tdy = gdSmallFont->height/2; $side = -1; my $s = $tdx; my $xxi = $xi; my $xxf = ($tic_opts & S710::TIC_LINES) ? $xf : $xi; while ($st < $ymin) { $st += $tic; } my $dy = $yi - $ppy * ($st-$ymin); my $buf = sprintf("%d %s", $st, $units); $image->string(gdSmallFont, $xxi+$side*(6+$s*length($buf)), $dy-$tdy, $buf, $pixel); my $n = 0; for (my $i=$st+$tic; $i<$ymax; $i+=$tic) { my $dy = $yi - $ppy * ($i-$ymin); $buf = sprintf("%d", $i); $image->string(gdSmallFont, $xxi+$side*(6+$s*length($buf)), $dy-$tdy, $buf, $pixel); $n++; } $n++; if ($tic_opts & S710::TIC_SHADE) { for (my $i=$st; $i<$ymax+$tic; $i+=$tic) { my $p = get_pixel($image, $tic_opts, $n--); my $yyi = $yi - $ppy * ($i-$ymin); my $yyf = $yyi + $ppy * $tic; clamp(\$yyf, $yf, $yi); clamp(\$yyi, $yf, $yyf); $image->filledRectangle($xi, $yyi, $xf, $yyf, $p); } } for (my $i=$st; $i<$ymax; $i+=$tic) { my $dy = $yi - $ppy * ($i-$ymin); $image->line($xxi+$side*4, $dy, $xxf, $dy, $pixel); } } sub get_pixel { my ($image, $shade, $n) = @_; my $r = 0xee - 10*$n; my $g = $r; my $b = $r; if (($shade & S710::TIC_SHADE_RED) == S710::TIC_SHADE_RED) { $r += 5*$n; } elsif (($shade & S710::TIC_SHADE_GREEN) == S710::TIC_SHADE_GREEN) { $g += 5*$n; } elsif (($shade & S710::TIC_SHADE_BLUE) == S710::TIC_SHADE_BLUE) { $b += 5*$n; } clamp(\$r, 0, 0xee); clamp(\$g, 0, 0xee); clamp(\$b, 0, 0xee); my $p = $image->colorExact($r, $g, $b); if ($p == -1) { $p = $image->colorAllocate($r, $g, $b); } return $p; } sub clamp { my ($x, $min, $max) = @_; if (${$x} < $min) { ${$x} = $min; } if (${$x} > $max) { ${$x} = $max; } } BEGIN { my $fit = Statistics::LineFit->new(); sub piecewise { my ($x, $y) = @_; my (@x, @y); # print "piecewise(x[0]=$x->[0] -> x[$#{$x}]=$x->[-1], y[0]=$y->[0] -> y[$#{$y}]=$y->[-1])\n"; if (($x->[-1] - $x->[0]) <= 1) { return (0, $#{$x}); } my $maxrsquared = 0; my $index; my $rsquared; for (my $i=1; $i<$#{$x}; $i++) { # for (my $i=5; $i<$#{$x}-4; $i++) { @x = @{$x}[0..$i]; @y = @{$y}[0..$i]; $fit->setData(\@x, \@y); $rsquared = $fit->rSquared; @x = @{$x}[$i..$#{$x}]; @y = @{$y}[$i..$#{$x}]; $fit->setData(\@x, \@y); $rsquared *= $fit->rSquared; if ($rsquared > $maxrsquared) { $maxrsquared = $rsquared; $index = $i; } } @x = @{$x}[$index..$#{$x}]; @y = @{$y}[$index..$#{$x}]; my @results = piecewise(\@x, \@y); for (my $i=0; $i<@results; $i++) { $results[$i] += $index; } shift(@results); @x = @{$x}[0..$index]; @y = @{$y}[0..$index]; unshift(@results, piecewise(\@x, \@y)); return (@results); } }