<?php declare(strict_types=1);  
    namespace GpxStat; # development version has suffix 'new'
    if (!defined('PmWiki')) exit();
/* GpxStat
+  
This recipe processes a .gpx (XML) file to calculate a number of measurements including
* start time, end time, duration, moving time
* min and max elevation
* metres ascended and descended
* distance, max speed, average speed, average moving speed
.gpx files can have multiple tracks.
Directive parameters are
    gpx="groupname.pagename/filename"
        this parameter is mandatory, the groupname and pagename are optional
    display="layout"
        this parameter is optional. Layout values are
            table (of all available values)
            tramp (i.e. walk)
            ski
            drive
            train
            bus, boat, fly; in case these are differentiated in the future
            analyse (to assist with understanding the .gpx file)
        if not supplied all available values are displayed in a column
    stoppedthreshold="speed"
        this parameter is optional. Set thespeed in km/h below which points count as stopped, overrides default and config value if specified
    timezone="timezone/name"
        this parameter is optional. Timezones recognised are those from the IANA / Olson timezone database. A conversion from timezone abbreviations is also attempted. You can also try the keyword 'detect' to calculate the timezone from the first point on the track, YMMV.
    startname="location name"
        this parameter is optional. Replaces the text "Start:"
    endname="location name"
        this parameter is optional. Replaces the text "End:"
    banner="on", "off", or "version"
        this parameter is optional. Enables (default), disables, or shows banner with version

Written with research from Micrososft CoPilot.

The contents of a GPX file are similar to
<gpx>
<trk>
	<name>Paekākāriki escarpment</name>
	<trkseg>
		<trkpt lat="-40.988689" lon="174.951949">
			<ele>9.20</ele>
			<time>2022-04-23T21:52:26Z</time>
            <speed>1.1</speed>
		</trkpt>
    </trkseg>
</trk>
</gpx>

Speed can also be recorded as an extension			
    <extensions>
		<gte:gps speed="2.5"/>
	</extensions>
Speed is calculated if it is not available from either of the two tags in the file. 
   
See https://www.pmwiki.org/wiki/Cookbook/GpxStat and https://kiwiwiki.nz/pmwiki/pmwiki.php/Cookbook/GpxStat
+
  Copyright 2024-present Simon Davis
  This software is written for PmWiki; you can redistribute it and/or modify it under the terms
  of the GNU General Public License as published by the Free Software Foundation; 
  either version 3 of the License, or (at your option) any later version. 
  See pmwiki.php for full details and lack of warranty.
+
  Revision History <reverse chronological order please
  # 2024-05-05 update for PHP 8 warnings
  # 2024-03-00 add train
  # 2024-02-06 add avg dsc speed
  # 2024-01-16 add parameters (startname, endname, stoppedthreshold, banner), update analyse, handle no timestamps, correct display of duration
  # 2024-01-01 initial version
  +
*/
//=================================================================================================
#
// Initialise constants
    const NL = "\n";
    const BR = '<br/>' . NL;
    const GPXSTATNAME = 'GpxStat';
    $GpxStatNew = ''; # initialise
    if (substr(__NAMESPACE__, -3) === 'new') {
        $GpxStatNew = 'new'; # empty string for production version, 'new' for development version
    }
    const EARTH_RADIUS = 6371000; # metres
    const AVGWINDOW = 8; # number of points averaged to omit outliers
    # constants
# The stdDev value for outlier detection. See https://en.wikipedia.org/wiki/Standard_deviation#Rules_for_normally_distributed_data
# 1 sd ~= 68%; 2 sd ~= 95%; 2.5 sd ~= 99%; 3 sd ~= 99.7%
    const NRSTDDEV = 2.5; # 2.5 std devs ~= 99% of data, empirically this works well
    const NRSTDDEVELEV = 2; # 2 std devs ~= 95% of data, empirically this works well for elevation
    const KM_TO_M = 1000;
    const HOUR_TO_S = 3600;
    const VAL = 'val';
    const TXT = 'txt';
    # values
    const GPXS = 'gpxstat';
    const TDAT = 'trkDate';
    const TMZN = 'timeZone';
    const TZDB = 'geoTimeZone'; # IANA/Olson
    const TRTZ = 'trkDateTimezone'; # track official timezones
    const TZCL = 'tzCalc';
    const TNAM = 'trkName';
    const TDES = 'trkDscr';
    const DURN = 'duration';
    const ELPS = 'elapsedDuration';
    const DIST = 'distance';
    const ASCT = 'ascent';
    const DSCT = 'descent';
    const ASCTRAW = 'ascentRaw';
    const DSCTRAW = 'descentRaw';
    const ASTM = 'ascTime';
    const DSTM = 'dscTime';
    const STRT = 'startTime';
    const ENDT = 'endTime';
    const MNEL = 'minElev';
    const MXEL = 'maxElev';
    const CHEL = 'chgElev';
    const STEL = 'startElev';
    const ENEL = 'endElev';
    const MXSP = 'maxSpeed';
    const THSP = 'thresholdSpeed';
    const DURM = 'durMov';
    const DURS = 'durStp';
    const AVSP = 'avgSpd';
    const ASSP = 'ascSpd';
    const DSSP = 'dscSpd';
    const SPCL = 'spCalc';
    const AVMS = 'avgMovSpd';
    const NRPT = 'nrPoints';
    const NRSG = 'nrSegments';
    const NRTK = 'nrTracks';
    const FLDS = 'fileDesc';
    const FNAM = 'fileName';
    const SNAM = 'startName';
    const ENAM = 'endName';
    # HTML
    const TBLS = '<table class="gpxstat">';
    const TBLE = '</table>';
    const TBLR = '<tr>';
    const TBLD = '<td>';
    const TBLDL = '<td class="tdl">';
    const TBLDR = '<td class="tdr">';
    const TBLDB = '<td class="tdb">';
    const TBLD2 = '<td class="tdb" colspan="2">';
    const SS = '<span style="display:inline-block; min-width:11em;">';
    const SE = '</span>';
    const GPXSTXT = '<i>GpxStat</i> ';
    # literals
    const MTR =  '&nbsp;m ';
    const KM =   '&nbsp;km ';
    const KMH =  '&nbsp;km/h ';
    const HOUR = '&nbsp;h ';
#
# set debug flag
    \SDV($GpxStatDebug, false); # set default debug setting if not defined in a configuration file
    $gpxstat_debugon = boolval ($GpxStatDebug); # if on writes input and output to web page
# Version date
    $RecipeInfo[GPXSTATNAME] ['Version'] = '2024-05-05' . $GpxStatNew; # PmWiki version numbering is done by date
# recipe version page variable
    $FmtPV['$GpxStatVersion']   = "'" . __NAMESPACE__ . " version {$RecipeInfo[GPXSTATNAME] ['Version']}'"; // return version as a custom page variable
# set default formatting for recipe classes
    \SDV($HTMLStylesFmt[__NAMESPACE__], NL .
        '.gpxstat {font-size: smaller; font-family: monospace;}' . NL . 
        '.gpxstat table,  .gpxstat tbody, .gpxstat tr, .gpxstat td, .gpxstat th {vertical-align: top; border-collapse: collapse;}' . NL .
        '.gpxstat .tdr {border: dotted lightgrey; border-width:1px 1px 1px 0px;}' . NL .
        '.gpxstat .tdl {border: dotted lightgrey; border-width:1px 0px 1px 1px;}' . NL .
        '.gpxstat .tdb {border: dotted lightgrey; border-width:1px 1px 1px 1px;}' . NL
        );
#
    if (!function_exists('dmsg')) {
        if (!isset ($MessagesFmt[GPXSTATNAME])) $MessagesFmt[GPXSTATNAME] = [];
        function dmsg (string $smsgprefix, $smsgdata) { # local instance
            global $MessagesFmt;
            $MessagesFmt[GPXSTATNAME] [] = $smsgprefix . ': ' . \PHSC (implode(NL . $smsgprefix . ': ', [$smsgdata]));
        }
    }
#
    if ($gpxstat_debugon) 
		dmsg('<hr>' . __FILE__, $RecipeInfo[GPXSTATNAME]['Version']);
# declare $gpxstat for (:if enabled gpxstat:) recipe installation check
    $gpxstat = true; # enabled
# acquire other configuration variables from config.php if they exist
    \SDV($GpxStatTimeFmt, 'H:i'); # e.g. 'Y-m-d T H:i:s'
    $timeFmt =  strval($GpxStatTimeFmt);
    \SDV($GpxStatDateFmt, 'Y-m-d'); # e.g. 'Y-m-d T H:i:s'
    $dateFmt = strval ($GpxStatDateFmt);
    \SDV($GpxStatThresholdSpeed, 0.8); # set threshold moving speed in km/hour
    $thresholdSpeedKMH = floatval ($GpxStatThresholdSpeed);
    \SDV ($GpxStatBanner, 'on'); # change display of banner
    $bannerFlag = mb_strtolower(strval ($GpxStatBanner));
    \SDV ($GpxStatBrief, 'true'); # make descriptors brief
    $briefFlag = boolval ($GpxStatBrief);
    \SDV ($GpxStatTimezone, null); # parameter is checked below
#
## Add a PmWiki custom markup 
    $vnamepagefile = "(?:[$PageNameChars]+(?:\.|\/)){0,2}" # optional group/page name and separator (/ or .) repeated
                   . "[$UploadNameChars]+" . '.gpx'; # file name and .gpx
    # (:GpxStat Optional parameters:)
    $gpxstat_markup_pattern = '/\\(:'
      . mb_strtolower(GPXSTATNAME) . '(.*)?:\\)/i';
    # when has to be at least fulltext
    \Markup(GPXSTATNAME . $GpxStatNew, #name
      'fulltext', # when, e.g. fulltext, directives
      $gpxstat_markup_pattern, # pattern
      __NAMESPACE__ . '\GpxStat_Directive' );
#    if ($gpxstat_debugon) dmsg('GpxStat markup', $gpxstat_markup_pattern);
//
    return; # completed PmWiki Info recipe setup
/*-----------------------------------------------------------------------------------------------------------*/
#
/** GPX file statistics
*   /param   
*       gpx=groupname.pagename/filename.gpx
*       display=layout
*       timezone=tzname
*   /return  The PmWiki-formatted information as HTML.
*/
#
function GpxStat_Directive (array $m):string { 
#
    global $gpxstat_debugon, $RecipeInfo;# import variables
    global $timeFmt, $dateFmt, $thresholdSpeedKMH, $vnamepagefile, $bannerFlag, $briefFlag; 
    global $GpxStatTimezone;
    global $retVal, $showVals; # for debugging
    $args = \ParseArgs($m[1]); # contains all text within directive
    $retVal = '<:block>' . NL; # break out of paragraph;
    $diffVals = [];
    $dispArray = ['default']; # defaults to default
    $calcVals = []; # initialise
    $calcVals [SNAM] = '';
    $calcVals [ENAM] = '';
// 
    if (array_key_exists ('debug', $args)) {
        $debugopt = $args['debug'] === 'true';
        if ($debugopt) $gpxstat_debugon = true; # set on inside directive
    }
    if ($gpxstat_debugon) { # display inputs and outputs to wiki page
        #dmsg ('<hr>', GPXSTATNAME);
        #dmsg ('m[]', $m);
        #dmsg ('args[]', $args);
    } # end gpxstat_debugon
    if (array_key_exists ('display', $args)) {
        $dispArray = explode(',', $args ['display']);
        $dispArray = array_map('strtolower', $dispArray); // Convert all values to lowercase
    }
    if (array_key_exists ('startname', $args)) {
        $calcVals [SNAM] = strval ($args['startname']);
    }
    if (array_key_exists ('endname', $args)) {
        $calcVals [ENAM] = strval ($args['endname']);
    }
      if (array_key_exists ('banner', $args)) {
          $bannerFlag = mb_strtolower(strval ($args['banner']));
      }
      if (array_key_exists ('brief', $args)) {
          $briefFlag = boolval ($args['brief']);
      }
    if (array_key_exists ('stoppedthreshold', $args)){
        $thresholdSpeedKMH = floatval ($args['stoppedthreshold']); # override config or default values
    }
    #
    switch (true) {
        case (array_key_exists ('gpx', $args)):
		    $fileName = urldecode (strval($args['gpx']));
            if (empty ($fileName)) {
                return GPXSTATNAME . ': gpx filename is missing ' . strval($fileName);
            }
            $pmatch = preg_match ('/^' . $vnamepagefile . '/i', $fileName);
            if ($pmatch === false) return GPXSTATNAME . ': gpx does not match a filename pattern "' . $fileName . '" failed (' . $vnamepagefile . ')';
            if (1 == $pmatch) { # pattern matches subject
                list ($fileexists, $wikifilefqdn) = CheckWikiFile ($fileName);
                //if ($gpxstat_debugon) dmsg ('gpxfile', '"' . $args['gpx'] . '", "' . $wikifilefqdn . '"');
                if ($fileexists) break; # exit switch, we have a file to process
                return $wikifilefqdn; # return file upload code to PmWiki (don't use Keep)
            }
            # pattern does not match subject
            return GPXSTATNAME . ': "' . $fileName . '" not a valid filename';
        default:
            return GPXSTATNAME . ': parameter "gpx=filename.gpx"  is required';
    } # end switch
    $thresholdSpeedMS = $thresholdSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to m/s
    // Load the XML file
    libxml_use_internal_errors(true);
    $gpxXml = simplexml_load_file($wikifilefqdn); # returns class object or null
#
    if ($gpxXml === false) {
        // Retrieve an array of errors
        $errors = libxml_get_errors();
        // Iterate over each error
        foreach ($errors as $error) {
            $retVal .= display_xml_error($error, $gpxXml);
        }
        // Clear the libxml error buffer
        libxml_clear_errors();
        // Handle the errors
        return 'File "' . $wikifilefqdn . '" ' . BR . $retVal;
    }
    if (! ($gpxXml->getName() === 'gpx')) return 'File is not a gpx file' . BR;
// Get the namespace of the gpx data 
    $trknamespaces = $gpxXml->getNamespaces(true);            
#
    $calcVals [TZCL] = ''; # global message
    list ($calcVals [TMZN], $calcVals [TZCL]) = checkTimezone ($GpxStatTimezone, $gpxXml); # requires gpx file
#
    if (array_key_exists ('timezone', $args)) {
        list ($calcVals [TMZN], $calcVals [TZCL]) = checkTimezone (strval($args['timezone']), $gpxXml); # requires gpx file
    }
    // Initialise variables
    $calcVals [TNAM] = []; # multiple values are possible
    $calcVals [TDES] = []; # multiple values are possible
    $calcVals [NRSG] = 0; # count track segments
    $calcVals [NRTK] = 0; # count number of tracks
    $calcVals [ASCTRAW] = 0;
    $calcVals [DSCTRAW] = 0;
    $calcVals [STRT] = null;
    $calcVals [ENDT] = null;
    $calcVals [ASTM] = 0;
    $calcVals [DSTM] = 0;
    $calcVals [ASSP] = 0;
    $calcVals [DSSP] = 0;
    $calcVals [STEL] = null;
    $calcVals [DURM] = 0;
    $calcVals [DURN] = 0;
    $calcVals [DURS] = 0;
    $calcVals [TDAT] = null;
    $calcVals [SPCL] = '';
    $timeVals = []; # time values in seconds
    $distVals = []; # distance values in metres
    $speedVals = []; # speed values in metres per second
    $elevVals = []; # elevation values in metres
    $pointsNumber = -1; # point number starts from zero
    $showVals = true or $gpxstat_debugon or boolval(in_array('showvals', $dispArray));

// Extract values from the gpx root
    if (isset($gpxXml->name)) { # some gpx files have a name here
        $calcVals [TNAM][] = strval($gpxXml->name);
    }
    if (isset($gpxXml->desc)) { # some gpx files have a desc here
        $calcVals [TDES][] = strval($gpxXml->desc);
    }
// Extract values from the metadata
    if (isset($gpxXml->metadata->time)) {
        $calcVals [TDAT] = date_create(strval($gpxXml->metadata->time));
    }
    if (isset($gpxXml->metadata->name)) { # some gpx file have a name here
        $calcVals [TNAM][] = strval($gpxXml->metadata->name);
    }
    if (isset($gpxXml->metadata->desc)) { # some gpx file have a desc here
        $calcVals [TDES][] = strval($gpxXml->metadata->desc);
    }
// Loop through each "trk" element in the XML
foreach ($gpxXml->trk as $trk) {
    // treat each track as separate (to avoid values from jumps from one track to the next)
    $previousTrkpt = null;
    $previousTime = null;
    $previousElev = null;
    $calcVals [NRTK]++;
    if (isset($trk->name)) $calcVals [TNAM][] = strval($trk->name);
    if (isset($trk->desc)) $calcVals [TDES][] = strval($trk->desc);
    // Loop through each "trkseg" element
    foreach ($trk->trkseg as $trkSeg) {
        $calcVals [NRSG]++;
        // Loop through each "trkpt" element
        foreach ($trkSeg->trkpt as $trkPnt) {
            // Increment the count of points for each "trkpt" element
            $pointsNumber++; # note these arrays start from 0
            $timeVals [$pointsNumber] = $pointTime = (isset($trkPnt->time)) ? strtotime(strval($trkPnt->time)) : 0; # UNIX timestamp
            $elevVals [$pointsNumber] = $pointElev = (isset($trkPnt->ele)) ? floatval($trkPnt->ele) : 0;
            if (empty($calcVals [STEL])) {
                $calcVals [STEL] = $pointElev; # save first elevation in track
                }
            // Calculate distance using the Haversine formula
            if (isset($previousTrkpt)) {
                $distPoint = haversineGreatCircleDistance(floatval($previousTrkpt['lat']), floatval($previousTrkpt['lon']), floatval($trkPnt['lat']), floatval($trkPnt['lon'])); # metres
                $distVals [$pointsNumber] = $distPoint; # calculated
                
                // Calculate speed if not available
                switch (true) {
                    case (isset($trkPnt->speed)):
                        break; # speed tag exists and is not empty
                    case (isset($trknamespaces['gte'])): 
                        // Register the namespace with the SimpleXMLElement
                        $trkPnt->registerXPathNamespace('gte', $trknamespaces['gte']);
                        // Check if the extensions and gps nodes exist
                        if (isset($trkPnt->extensions) && isset($trkPnt->extensions->children($trknamespaces['gte'])->gps)) {
                            // Extract the speed
                            $speed = $trkPnt->extensions->children($trknamespaces['gte'])->gps->attributes()->speed;
                        } else {$speed = null;}
                        if (isset($speed) && !empty($speed)) {
                            $trkPnt->speed  = strval($speed);
                            $calcVals [SPCL] = 'speed from extension';
                            break; # speed exists in extension
                        }
                        # fall through if speed not set in extension
                    default: // Calculate speed if not available
                        $timeDelta = abs($previousTime - $pointTime);
                        $speed = ($timeDelta > 0) ? $distPoint / $timeDelta : 0;
                        $trkPnt->speed = strval($speed);
                        $calcVals [SPCL] = 'speeds calculated';
                } # end switch
            } else {
                $distVals [$pointsNumber] = 0; # distance of zero if no previous point
            } # end if
            $speedVals [$pointsNumber] = $pointSpeed = (isset($trkPnt->speed)) ? floatval($trkPnt->speed) : 0; # defaults to zero
                             
            // Calculate metres climbed and descended
            if (isset($previousTrkpt)) {
                $pointElevChange = $pointElev - $previousElev;
                if ($pointElevChange > 0) {
                    $calcVals [ASCTRAW] += $pointElevChange;
                } else {
                    $calcVals [DSCTRAW] += abs($pointElevChange);
                }
            }
        
            // Set start time 
            if (empty($calcVals [STRT])) {
                $calcVals [STRT] = $pointTime;
            }
            if (empty($calcVals [TDAT])) {
                $calcVals [TDAT] = $pointTime;
            }
            $calcVals [ENDT] = $pointTime;
        
            // Calculate moving times
            if (isset($previousTrkpt)) {
                if ($previousTime !== null) {
                    $intervalTime = abs($pointTime - $previousTime);
                    $calcVals [DURN] += $intervalTime; # add to track duration
                    if ($pointSpeed > $thresholdSpeedMS) { # moving
                        $calcVals [DURM] += $intervalTime;
                         if ($previousElev !== null) {
                             if ($pointElev > $previousElev) {
                                 $calcVals [ASTM] += $intervalTime; // Add to ascending time
                            } elseif ($pointElev < $previousElev) {
                                 $calcVals [DSTM] += $intervalTime; // Add to descending time
                            }
                        }
                    } else { # not moving
                        $calcVals [DURS] += $intervalTime; # stopped
                    }
                }
            }
            $previousTime = $pointTime;
            $previousElev = $pointElev;
            $previousTrkpt = $trkPnt;
        }  # end foreach trkseg
    }  # end foreach trk
} # end foreach gpxxml
$calcVals [ENEL] = $pointElev;
$calcVals [NRPT] = $pointsNumber + 1;

list ($speedSmooth, $speedOutliers, $diffVals[MXSP]) = removeOutliers('Speed', $speedVals, NRSTDDEV, AVGWINDOW);
list ($elevSmooth, $elevOutliers, $diffVals[MXEL])   = removeOutliers('Elevation', $elevVals, NRSTDDEVELEV, AVGWINDOW);
list ($distSmooth, $distOutliers, $diffVals[DIST])   = removeOutliers('Distance', $distVals, NRSTDDEV, AVGWINDOW);
list ($calcVals [ASCT], $calcVals [DSCT]) = calcChange   ($elevSmooth, $speedSmooth, $thresholdSpeedKMH); # calculate ascend and descent from smoothed values
list ($calcVals [ASSP], $calcVals [DSSP]) = calcAvgSpeed ($elevSmooth, $speedSmooth, $distSmooth, $timeVals, $thresholdSpeedKMH); # calculate average speed from smoothed values
list ($minV, $maxV) = calcMinMax ($speedSmooth);
$calcVals [MXSP] = $maxV; # calculate from smoothed values
$calcVals [DIST] = array_sum ($distSmooth);
#
$outVals[TZDB][TXT] = $outVals[TZDB][VAL] = '';
if ((!empty($calcVals [TZCL])) or (! empty ($calcVals [TMZN]))) {
    $outVals[TZDB][TXT] = 'Time&nbsp;zone: ';
    $outVals[TZDB][VAL] = strval($calcVals [TMZN]);
}
#
switch ($bannerFlag) {
    case 'off' : 
        $outVals[GPXS][TXT] = '';
        $outVals[GPXS][VAL] = '';
        break;
    case 'on' :
        $outVals[GPXS][TXT] = makeabbr (GPXSTXT, 'Version ' . $RecipeInfo[GPXSTATNAME] ['Version']);
        $outVals[GPXS][VAL] = '';
        break;
    default:
        $retVal = 'GpxStat: Banner parameter must have values "on", "off", or "version"';
        # falls through
    case 'version' :
        $outVals[GPXS][TXT] = GPXSTXT;
        $outVals[GPXS][VAL] = 'Version ' . $RecipeInfo[GPXSTATNAME] ['Version'];
} # end switch
$outVals[FLDS][TXT] = 'File: ';
$outVals[FLDS][VAL] = '"' . urldecode ($wikifilefqdn) . '"';
$outVals[FNAM][TXT] = 'Filename: ';
$outVals[FNAM][VAL] = urldecode(basename ($wikifilefqdn, '.gpx'));
$outVals[TRTZ][TXT] = 'Track date time&nbsp;zone: ';
$outVals[TRTZ][VAL] = date('T', $calcVals [TDAT]);
$outVals[TNAM][TXT] = 'Track&nbsp;name: ';
$outVals[TNAM][VAL] = implode (BR, $calcVals [TNAM]);
$outVals[TDES][TXT] = 'Track&nbsp;desc: ';
$outVals[TDES][VAL] = implode (BR, $calcVals [TDES]);
$outVals[TDAT][TXT] = 'Track&nbsp;date ('. $outVals[TRTZ][VAL] . '): ';
$outVals[TDAT][VAL] = date($dateFmt, $calcVals [TDAT]);
$outVals[DURN][TXT] = makeabbr('Duration: ', $outVals[TDAT][TXT] . date($dateFmt, $calcVals [TDAT]));
$outVals[DURN][VAL] = displaySecsAsHoursMins($calcVals [DURN]) . HOUR;
$calcVals [ELPS] = $calcVals [ENDT] - $calcVals [STRT];
$outVals[ELPS][TXT] = 'Elapsed: ';
$outVals[ELPS][VAL] = displaySecsAsHoursMins($calcVals [ELPS]) . HOUR;
$outVals[DIST][TXT] = makeabbr('Distance: ', $outVals[FNAM][VAL]);
$outVals[MXSP][TXT] = 'Max speed: ';
$outVals[ASCT][TXT] = 'Ascent: ';
$outVals[ASCT][VAL] = number_format($calcVals [ASCT]) . MTR;
$outVals[DSCT][TXT] = 'Descent: ';
$outVals[DSCT][VAL] = number_format($calcVals [DSCT]) . MTR;
$outVals[STRT][TXT] = (empty($calcVals [SNAM])) ? 'Start: ' : makeabbr ($calcVals [SNAM], 'Start');
$outVals[STRT][VAL] = makeabbr((date('Y-m-d', $calcVals [STRT]) == date('Y-m-d', $calcVals [ENDT])) ? date($timeFmt, $calcVals [STRT]) : date($dateFmt, $calcVals [STRT]) . '&nbsp;' . date($timeFmt, $calcVals [STRT]), $calcVals [TMZN]);
$tzid = (empty ($calcVals [TMZN])) ? '' : get_timezone_abbreviation($calcVals [TMZN]);
$outVals[ENDT][TXT] = (empty($calcVals [ENAM])) ? 'End: ' : makeabbr ($calcVals [ENAM], 'End');
$outVals[ENDT][VAL] = makeabbr((date('Y-m-d', $calcVals [STRT]) == date('Y-m-d', $calcVals [ENDT])) ? date($timeFmt, $calcVals [ENDT])   : date($dateFmt, $calcVals [ENDT])   . '&nbsp;' . date($timeFmt, $calcVals [ENDT]), $tzid);
$outVals[ASTM][TXT] = 'Duration ascent: ';
$outVals[ASTM][VAL] = displaySecsAsHoursMins($calcVals [ASTM]) . HOUR;
$outVals[DSTM][TXT] = 'Duration descent: ';
$outVals[DSTM][VAL] = displaySecsAsHoursMins($calcVals [DSTM]) . HOUR;
$outVals[STEL][TXT] = ($briefFlag) ? 'Start elev: ' : 'Start elevation: ';
$outVals[STEL][VAL] = number_format($calcVals [STEL]) . MTR;
$outVals[ENEL][TXT] = ($briefFlag) ? 'End elev: ' : 'End elevation: ';
$outVals[ENEL][VAL] = number_format($calcVals [ENEL]) . MTR;
list ($minV, $maxV) = calcMinMax ($elevSmooth);
$outVals[MNEL][TXT] = ($briefFlag) ? 'Min elev: ' : 'Min elevation: ';
$outVals[MNEL][VAL] = number_format($minV) . MTR;
$outVals[MXEL][TXT] = ($briefFlag) ? 'Max elev: ' : 'Max elevation: ';
$outVals[MXEL][VAL] = number_format($maxV) . MTR;
$outVals[CHEL][TXT] = (($briefFlag) ? 'Elev ' : 'Elevation ') . (($calcVals [ENEL] >= $calcVals [STEL]) ? 'gain' : 'loss') . ': ';
$outVals[CHEL][VAL] = number_format($calcVals [ENEL] - $calcVals [STEL]) . MTR;
$outVals[DURM][TXT] = 'Duration moving: ';
$outVals[DURM][VAL] = displaySecsAsHoursMins($calcVals [DURM]) . HOUR;
$outVals[DURS][TXT] = 'Duration stopped: ';
$outVals[DURS][VAL] = displaySecsAsHoursMins($calcVals [DURS]) . HOUR;
$calcVals [AVSP] = ($calcVals [DURN] > 0) ? $calcVals [DIST] / $calcVals [DURN] : 0;
$outVals[AVSP][TXT] = 'Avg speed: ';
$decPlaces = ($calcVals [AVSP] < 10) ? 1 : 0;
$outVals[AVSP][VAL] = number_format($calcVals [AVSP] * 3.6, $decPlaces) . KMH;
$outVals[AVMS][TXT] = 'Avg moving speed: ';
$calcVals [AVMS] = ($calcVals [DURM] > 0) ? $calcVals [DIST] / $calcVals [DURM] : 0;
$decPlaces = ($calcVals [AVMS] < 10) ? 1 : 0;
$outVals[AVMS][VAL] = number_format($calcVals [AVMS] * 3.6, $decPlaces) . KMH;
$outVals[ASSP][TXT] = 'Avg asc speed: ';
$decPlaces = ($calcVals [ASSP] < 10) ? 1 : 0;
$outVals[ASSP][VAL] = number_format($calcVals [ASSP], $decPlaces) . KMH;
$outVals[DSSP][TXT] = 'Avg dsc speed: ';
$decPlaces = ($calcVals [DSSP] < 10) ? 1 : 0;
$outVals[DSSP][VAL] = number_format($calcVals [DSSP], $decPlaces) . KMH;
$outVals[THSP][TXT] = 'Threshold&nbsp;speed: ';
$outVals[THSP][VAL] = number_format($thresholdSpeedKMH, 1) . KMH;
$outVals[NRPT][TXT] = '# Points: ';
$outVals[NRPT][VAL] = number_format($calcVals [NRPT]);
$outVals[NRSG][TXT] = '# Segments: ';
$outVals[NRSG][VAL] = number_format($calcVals [NRSG]);
$outVals[NRTK][TXT] = '# Tracks: ';
$outVals[NRTK][VAL] = number_format($calcVals [NRTK]);

// Select number of decimal places
$decPlaces = ($calcVals [DIST] < 100000) ? 1 : 0; # metres
$outVals[DIST][VAL] = number_format($calcVals [DIST] / 1000, $decPlaces) . KM;

// Format to 1 decimal place
$decPlaces = ($calcVals [MXSP] < 10) ? 1 : 0;
$outVals[MXSP][VAL] = number_format($calcVals [MXSP], $decPlaces) . KMH; # km/h

// Display the information
if (empty (array_Filter($timeVals))) {
    // GPX file contained no time values, e.g. if it was converted from a KML file
     $retVal .= TBLS . NL;
     $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL;
     $retVal .= TBLDL . $outVals[GPXS][TXT] . TBLDR . $outVals[GPXS][VAL] . NL;
     $retVal .= TBLE . NL;
} else {
    foreach ($dispArray as $layout) {
      switch ($layout) {
        case 'table':
            $retVal .= TBLS . NL;
            $retVal .= TBLR . TBLDL . $outVals[TNAM][TXT] . '<td class="tdr" colspan="7">' . $outVals[TNAM][VAL]. '</td></tr>' . NL;
            $retVal .= TBLR . TBLDL . $outVals[TDES][TXT] . '<td class="tdr" colspan="7">' . $outVals[TDES][VAL] . '</td></tr>' . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL;
            $retVal .= TBLDL . $outVals[AVSP][TXT] . TBLDR . $outVals[AVSP][VAL] . NL;
            $retVal .= TBLD2 . $calcVals [SPCL] . NL;
            $retVal .= TBLDL . $outVals[STEL][TXT] . TBLDR . $outVals[STEL][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL;
            $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL;
            $retVal .= TBLDL . $outVals[MNEL][TXT] . TBLDR . $outVals[MNEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[ENEL][TXT] . TBLDR . $outVals[ENEL][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL;
            $retVal .= TBLDL . $outVals[MXSP][TXT] . TBLDR . $outVals[MXSP][VAL] . NL;
            $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[CHEL][TXT] . TBLDR . $outVals[CHEL][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURS][TXT] . TBLDR . $outVals[DURS][VAL] . NL;
            $retVal .= TBLDL . $outVals[GPXS][TXT] . TBLDR . $outVals[GPXS][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL;
            $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL;
            $retVal .= TBLDL . $outVals[TDAT][TXT] . TBLDR . $outVals[TDAT][VAL] . NL;
            $retVal .= TBLDL . $outVals[TZDB][TXT] . TBLDR . $outVals[TZDB][VAL] . ' ' . $calcVals [TZCL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[NRPT][TXT] . TBLDR . $outVals[NRPT][VAL] . NL;
            if ($calcVals [NRSG] > 1) {
                $retVal .= TBLDL . $outVals[NRSG][TXT] . TBLDR . $outVals[NRSG][VAL] . NL;
            } else {$retVal .= TBLD2 . NL;
            }
            if ($calcVals [NRTK] > 1) {
                $retVal .= TBLDL . $outVals[NRTK][TXT] . TBLDR . $outVals[NRTK][VAL] . NL;
            } else {$retVal .= TBLD2 . NL;
            }
            $retVal .= TBLDL . $outVals[THSP][TXT] . TBLDR . $outVals[THSP][VAL] . NL;
            $retVal .= TBLR . TBLDL . $outVals[FLDS][TXT] . '<td class="tdr" colspan="7">' . $outVals[FLDS][VAL] . '</td></tr>' . NL;
            $retVal .= TBLE . NL;
            break;
        case 'ski':
            $retVal .= TBLS . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL;
            $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL;
            $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL;
            $retVal .= TBLDL . $outVals[MXSP][TXT] . TBLDR . $outVals[MXSP][VAL] . NL;
            $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL;
            $retVal .= TBLDL . $outVals[ASTM][TXT] . TBLDR . $outVals[ASTM][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL;
            $retVal .= TBLDL . $outVals[DSSP][TXT] . TBLDR . $outVals[DSSP][VAL] . NL;
            $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL;
            $retVal .= TBLDL . $outVals[DSTM][TXT] . TBLDR . $outVals[DSTM][VAL] . NL;
            $retVal .= TBLE . NL;
            break;
        case 'walk':
            $retVal .= TBLS . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL;
            $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL;
            $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL;
            $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL;
            $retVal .= TBLE . NL;
            break;
        case 'tramp':
            $retVal .= TBLS . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL;
            $retVal .= TBLDL . $outVals[STEL][TXT] . TBLDR . $outVals[STEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL;
            $retVal .= TBLDL . $outVals[ENEL][TXT] . TBLDR . $outVals[ENEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL;
            $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL;
            $retVal .= TBLDL . $outVals[CHEL][TXT] . TBLDR . $outVals[CHEL][VAL] . NL;
            $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL;
            $retVal .= TBLE . NL;
            break;
        case 'train':
        case 'fly': # add in case we want to differentiate these later
        case 'boat':
        case 'bus':
        case 'drive':
            $retVal .= TBLS . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL;
            $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL;
            if (in_array('train', $dispArray)) {
                $retVal .= TBLDL . $outVals[MXSP][TXT] . TBLDR . $outVals[MXSP][VAL] . NL;
            } else {
                $retVal .= TBLDL . $outVals[GPXS][TXT] . TBLDR . $outVals[GPXS][VAL] . NL;
            }
            $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL;
            $retVal .= TBLR;
            $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL;
            $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL;
            $retVal .= TBLDL . $outVals[MNEL][TXT] . TBLDR . $outVals[MNEL][VAL] . NL;
            $retVal .= TBLE . NL;
            break;
        case 'default': # show everything by default in a list
            $retVal .= '<div class="gpxstat">' . NL;
            $retVal .= SS . GPXSTXT             . SE . $RecipeInfo[GPXSTATNAME] ['Version'] . BR;
            $retVal .= SS . $outVals[DIST][TXT] . SE . $outVals[DIST][VAL] . BR;
            $retVal .= SS . $outVals[MXSP][TXT] . SE . $outVals[MXSP][VAL] . ' ' . $calcVals [SPCL] . BR;
            $retVal .= SS . $outVals[DSSP][TXT] . SE . $outVals[DSSP][VAL] . BR;
            $retVal .= SS . $outVals[ASSP][TXT] . SE . $outVals[ASSP][VAL] . BR;
            $retVal .= SS . $outVals[AVMS][TXT] . SE . $outVals[AVMS][VAL] . BR;
            $retVal .= SS . $outVals[AVSP][TXT] . SE . $outVals[AVSP][VAL] . BR;
            $retVal .= SS . $outVals[MNEL][TXT] . SE . $outVals[MNEL][VAL] . BR;
            $retVal .= SS . $outVals[MXEL][TXT] . SE . $outVals[MXEL][VAL] . BR;
            $retVal .= SS . $outVals[STEL][TXT] . SE . $outVals[STEL][VAL] . BR;
            $retVal .= SS . $outVals[ENEL][TXT] . SE . $outVals[ENEL][VAL] . BR;
            $retVal .= SS . $outVals[CHEL][TXT] . SE . $outVals[CHEL][VAL] . BR;
            $retVal .= SS . $outVals[ASCT][TXT] . SE . $outVals[ASCT][VAL] . BR;
            $retVal .= SS . $outVals[DSCT][TXT] . SE . $outVals[DSCT][VAL] . BR;
            $retVal .= SS . $outVals[STRT][TXT] . SE . $outVals[STRT][VAL] . BR;
            $retVal .= SS . $outVals[ENDT][TXT] . SE . $outVals[ENDT][VAL] . BR;
            $retVal .= SS . $outVals[ELPS][TXT] . SE . $outVals[ELPS][VAL] . BR;
            $retVal .= SS . $outVals[DURN][TXT] . SE . $outVals[DURN][VAL] . BR;
            $retVal .= SS . $outVals[DURM][TXT] . SE . $outVals[DURM][VAL] . BR;
            $retVal .= SS . $outVals[ASTM][TXT] . SE . $outVals[ASTM][VAL] . BR;
            $retVal .= SS . $outVals[DSTM][TXT] . SE . $outVals[DSTM][VAL] . BR;
            $retVal .= SS . $outVals[DURS][TXT] . SE . $outVals[DURS][VAL] . BR;
            $retVal .= SS . $outVals[NRPT][TXT] . SE . $outVals[NRPT][VAL] . BR;
            if ($calcVals [NRSG] > 1) {
                $retVal .= SS . $outVals[NRSG][TXT] . SE . $outVals[NRSG][VAL] . BR;
            }
            if ($calcVals [NRTK] > 1) {
                $retVal .= SS . $outVals[NRTK][TXT] . SE . $outVals[NRTK][VAL] . BR;
            }
            $retVal .= SS . $outVals[TZDB][TXT] . SE . $outVals[TZDB][VAL] . ' ' . $calcVals [TZCL] . BR;
            $retVal .= SS . $outVals[TDAT][TXT] . SE . $outVals[TDAT][VAL] . BR;
            $retVal .= SS . $outVals[TNAM][TXT] . SE . $outVals[TNAM][VAL] . BR;
            $retVal .= SS . $outVals[TDES][TXT] . SE . $outVals[TDES][VAL] . BR;
            $retVal .= SS . $outVals[FLDS][TXT] . SE . $outVals[FLDS][VAL] . SE . NL;
            $retVal .= '</div>' . NL;
            break;
        case 'analyse': # displayed after all other display options
            break;
        case 'showvals' : # changes analyse output
            break;
        default:
            $retVal .= GPXSTATNAME . ': Unknown display option: "' . implode (', ', $dispArray) . '"' . BR;
        } # end switch
    } # end foreach
} # end if
// Provide analysis of GPX file
    if (in_array('analyse', $dispArray)) {
        $retVal .= '<hr>' . TBLS . NL;
        $retVal .= TBLR . TBLDL . '<i>Analyse</i>' . TBLDR . $calcVals [SPCL] . NL;
        $retVal .= TBLDL . $outVals[TZDB][TXT] . TBLDR . $outVals[TZDB][VAL] . ' ' . $calcVals [TZCL] . NL;
        $retVal .= TBLDL . $outVals[THSP][TXT] . TBLDR . $outVals[THSP][VAL] . NL;
        $retVal .= TBLR . TBLD . 'Stopped durations: ';
        // analyse the effect of a variance in stopped threshold speed
        $colCount = 1;
        $analyseTHSpeed = 0.2; # km/h
        $thSpeed = 0.1; # km/h
        for ($indx = 1; $indx <= 17; $indx++) {
            if ($colCount++ > 5) {
                $colCount = 1;
                $retVal .= NL . TBLR . NL; 
            }
            list ($stoppedDuration, $stoppedCount) = calcStoppedDuration ($timeVals, $speedVals, $thSpeed);
            $retVal .= TBLDB . displaySecsAsHoursMins($stoppedDuration) . HOUR . number_format ($thSpeed, 1) . KMH . ' (#' . number_format ($stoppedCount) . ')' . NL;
            $thSpeed += $analyseTHSpeed;
        } #end for
        // analyse the distribution of the data
        foreach ($diffVals as $diffKey => $diffVal) {
            $retVal .= TBLR . TBLDL . 'Variances: ' . TBLDR . $outVals[$diffKey][TXT] . ' ' . $outVals[$diffKey][VAL] . NL . $diffVal . NL;
        }
        $retVal .= TBLR;
        $retVal .= TBLDL . $outVals[NRPT][TXT] . TBLDR . $outVals[NRPT][VAL] . NL;
        if ($calcVals [NRSG] > 1) {
            $retVal .= TBLDL . $outVals[NRSG][TXT] . TBLDR . $outVals[NRSG][VAL] . NL;
        } else {$retVal .= TBLD2 . NL;
        }
        if ($calcVals [NRTK] > 1) {
            $retVal .= TBLDL . $outVals[NRTK][TXT] . TBLDR . $outVals[NRTK][VAL] . NL;
        } else {$retVal .= TBLD2 . NL;
        }
        $retVal .= TBLR . TBLDL . $outVals[TNAM][TXT] . '<td class="tdr" colspan="5">' . $outVals[TNAM][VAL] . '</td></tr>' . NL;
        $retVal .= TBLR . TBLDL . $outVals[TDES][TXT] . '<td class="tdr" colspan="5">' . $outVals[TDES][VAL] . '</td></tr>' . NL;
        $retVal .= TBLR . TBLDL . $outVals[TDAT][TXT] . '<td class="tdr" colspan="5">' . $outVals[TDAT][VAL] . NL;
        $retVal .= TBLE . NL;
        $retVal .= '<span class="gpxstat">' . $outVals[FLDS][TXT] . $outVals[FLDS][VAL] . '</span>' . BR;

        $retVal .= '<details><summary class="gpxstat">Max, Min, Outliers</summary>' . NL . TBLS . TBLR . NL;
        
        // Get the maximum speeds for analysis
        $retVal .= TBLDL;
        arsort($speedVals, SORT_NUMERIC); // Sort speeds in descending order while maintaining index association
        $maxSpeeds = array_slice($speedVals, 0, AVGWINDOW, true);
        $retVal .= 'Max&nbsp;Speeds' . BR . 'Record#: Speed' . BR;
        foreach ($maxSpeeds as $recordNumber => $maxspeed) {
            $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($maxspeed, 1) . KMH . BR;
        } # end foreach
        $retVal .= 'Avg Max speed: ' . number_format (array_sum($maxSpeeds) / count ($maxSpeeds), 1) . KMH . BR;
        
        // Get the maximum elevations for analysis
        $retVal .= TBLDL;
        arsort($elevVals, SORT_NUMERIC); // Sort elevation in descending order while maintaining index association
        $maxElevs = array_slice($elevVals, 0, AVGWINDOW);
        $retVal .= 'Max&nbsp;Elevations' . BR . 'Record#: Elevation' . BR;
        foreach ($maxElevs as $recordNumber => $maxelev) {
            $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($maxelev, 1) . MTR . BR;
        } # end foreach
        $retVal .= 'Avg Max Elev: ' . number_format (array_sum($maxElevs) / count ($maxElevs), 1) . MTR . BR;
        
        // Get the minimum elevations for analysis
        $retVal .= TBLDL;
        $minElevs = array_slice($elevVals, -intval(AVGWINDOW));
        $retVal .= 'Min&nbsp;Elevations' . BR . 'Record#: Elevation' . BR;
        foreach ($minElevs as $recordNumber => $minelev) {
            $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($minelev, 1) . MTR . BR;
        } # end foreach
        $retVal .= 'Avg Min Elev: ' . number_format (array_sum($minElevs) / count ($minElevs), 1) . MTR . BR;
        
        // Get the maximum distances for analysis
        $retVal .= TBLDL;
        arsort($distVals, SORT_NUMERIC);
        $maxDists = array_slice($distVals, 0, AVGWINDOW, true);
        $retVal .= 'Max&nbsp;Distances' . BR . 'Record#: Distance' . BR;
        foreach ($maxDists as $recordNumber => $maxdist) {
            $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($maxdist, 1) . MTR . BR;
        } # end foreach
        $retVal .= 'Avg Max dist: ' . number_format (array_sum($maxDists) / count ($maxDists), 1) . KMH . BR;
        
        // Display speed outliers
        $retVal .= TBLDL;
        $retVal .= 'Speed&nbsp;outliers (' . number_format(count($speedOutliers)) . ')' . BR . 'Record#: Speed' . BR;
        foreach ($speedOutliers as $speedOutlier) {
            $retVal .= '#' . number_format($speedOutlier['position']) . ': ' . number_format($speedOutlier['value'], 1) . KMH . BR;
        } # end foreach
        // Display elevation outliers
        $retVal .= TBLDL;
        $retVal .= 'Elevation&nbsp;outliers (' . number_format(count($elevOutliers)) . ')' . BR . 'Record#: Elevation' . BR;
        foreach ($elevOutliers as $elevOutlier) {
            $retVal .= (isset($elevOutlier)) ? '#' . number_format($elevOutlier['position']) . ': ' . number_format($elevOutlier['value'], 1) . MTR . BR : 'Elev outlier missing ' . BR;
        } # end foreach
        // Display distance outliers
        $retVal .= TBLDL;
        $retVal .= 'Distance&nbsp;outliers (' . number_format(count($distOutliers)) . ')' . BR . 'Record#: Distance' . BR;
        foreach ($distOutliers as $distOutlier) {
            $retVal .= '#' . number_format($distOutlier['position']) . ': ' . number_format($distOutlier['value'], 1) . MTR . BR;
        } # end foreach
        $retVal .= TBLE . NL . '</details>' . NL;
    } # end if
return Keep ($retVal);
} # 
#
function removeOutliers(string $dataName, array $dataIn, $stdDev, int $windowSize = AVGWINDOW) {
/*
* This function removes outliers from a dataset using a simple outlier detection algorithm.
*
* @param array $dataIn The input data array.
* @param float $stdDev The stdDev value for outlier detection. See https://en.wikipedia.org/wiki/Standard_deviation#Rules_for_normally_distributed_data
                       1 sd ~= 68%; 2 sd ~= 95%; 2.5 sd ~= 99%; 3 sd ~= 99.7%
* @param int $windowSize The number of points to consider for the average delta vector.
* @local float $blendingFactor The blending factor for combining the extrapolated point and the actual data point.
*
* @return associative array with  
*   data array with outliers removed
*   array of outliers along with their original positions in the input data array. 
* Each outlier is represented as an associative array with `value` and `position` keys, where `value` is the outlier value and `position` is its original position in the input data array.
*
* The blending factor determines how much weight is given to the actual data point versus the extrapolated point. 
* A common starting point is 0.5, which gives equal weight to both. To give more weight to the actual data points, increase $P (e.g., to 0.6 or 0.7). 
* To give more weight to the extrapolated points (i.e., smooth the data more), decrease $P (e.g., to 0.4 or 0.3).
*/
    global $showVals; # for use with analyse
    $outlierVals = ''; # log outlier values
    $blendingFactor = 1.0; # set to ignore extrapolated point if within stdDev (don't change this for this gpx application)
    $smoothData = []; // Initialise an empty array to store the smoothed result.
    $outliers = []; // Initialise an empty array to store the outliers.
    $sizeData = count($dataIn); // Get the size of the input data array.
    list ($diffAvg, $diffMax, $diffStdDev) = calcDifference($dataIn); # find average, maximum, and std deviation of the differences between points
#
    $threshold = $diffStdDev * $stdDev;; # default threshold set to value
    $outlierVals .= TBLDR . ' Diff avg: ' . number_format($diffAvg, 2) . 
                    TBLDR . ' Diff std dev: ' . number_format($diffStdDev, 2) . 
                    TBLDR . ' Diff max: ' . number_format($diffMax, 2) . 
                    TBLDR . ' Calc Thrh: ' . number_format($threshold, 2);
    if ($showVals) {
        $outlierVals .= TBLR . TBLDL . $dataName . ' diff vals: ' . '<td class="tdr" colspan="3">' . TBLDR . '#points: ' . number_format($sizeData) . TBLDR . '#std dev: ' . $stdDev . NL ;
        $outlierVals .= TBLR . '<td colspan=6>' . '<details><summary class="gpxstat">' . $dataName . ' details</summary>' . NL .TBLS . NL;
    }
    // Loop over the data array starting from the x-th element.
    for ($indx = 1; $indx < $sizeData; $indx++) {
        // Compute the average delta vector over the last windowSize points
		# start from 1 as we are calclating the difference from the previous point
        $slidingWindow = array_slice($dataIn, $indx - $windowSize, $windowSize);
        list ($wndwDelta, , ) = calcDifference($slidingWindow);

        // Extrapolate a new point by adding the average delta vector to the last point.
        $extrapolatedPoint = floatval ($dataIn[$indx - 1] + $wndwDelta);
        $pointVariance = floatval ($dataIn[$indx] - $dataIn[$indx - 1]);
        // If the variance between the actual data point and the previous point is less than the threshold...
        switch (true) {
            case (abs($pointVariance) <= $threshold):
            // ...consider it a good data point and blend it with the extrapolated point.
                $smoothData[] = ($blendingFactor * $dataIn[$indx]) + ((1 - $blendingFactor) * $extrapolatedPoint);
                break;
            default:  
            // If the variance exceeds the threshold, consider it an outlier and use just the extrapolated point.
                $smoothData[] = $extrapolatedPoint;
                // Add the outlier and its original position to the outliers array.
                $outliers[] = ['value' => $dataIn[$indx], 'position' => intval($indx)];
                if ($showVals) {
                    $outlierVals .= TBLR . TBLDL . '#: ' . number_format($indx - 1) . 
                                    TBLDL . ' Wndw delta: ' . number_format($wndwDelta, 2) . 
                                    TBLDL . ' pt:' . ((isset ($dataIn[$indx-1])) ? number_format($dataIn[$indx-1], 1) : 'null') . 
                                    TBLDL . '>pt+: ' . number_format($dataIn[$indx], 1) . 
                                    TBLDL . ' (expt:' . number_format($extrapolatedPoint, 1) . ')' .
                                    TBLDB . ' var:' . number_format($pointVariance, 1) .  NL;
            }
        } # end switch
    } # end for
    if ($showVals) {
        $outlierVals .= TBLE . '</details>';
    }
    // Return the resulting data array and the outliers.
    return [$smoothData, $outliers, $outlierVals];
} # end removeOutliers
#
function calcDifference (array $dataIn) {
/* Calculate the average difference between all consecutive values in an array.
 * $dataIn array The input array of numeric values.
 * @return array The average difference, max diff, and standard deviation if the array has at least two elements, otherwise zero.
 */
    $pointDifferences = []; # initialise
    $squares = [];
    $nrItems = count($dataIn);
    if($nrItems <= 1) {
        return [0, 0, 0];
    }
    // calculate the difference between every consecutive point
    for($indx = 0; $indx < $nrItems - 1; $indx++) {
        $pointDifferences [] = $dataIn[$indx + 1] - $dataIn[$indx];
    }
    $diffMax = max(array_map('abs', $pointDifferences)); # absolute value
    $totalDiff = array_sum ($pointDifferences);
    $diffAvg = floatval ($totalDiff / ($nrItems - 1));
    
    $squares = [];
    foreach ($pointDifferences as $pointVal) {
        $squares[] = pow($pointVal - $diffAvg, 2);
    }
    $stdDev = floatval (sqrt(array_sum($squares) / count($squares)));
    return [$diffAvg, $diffMax, $stdDev];
} # end calcDifference
#
function calcStoppedDuration (array $timeVals, array $speedVals, float $thSpeedKMH) {
// Calculate stopped times; note parameter arrays start from 1
    $nrSpeedVals = count ($speedVals);
    if (count ($speedVals) !== count ($timeVals)) return [$nrSpeedVals, count ($timeVals)]; # should never happen
    $thresholdSpeedMS = $thSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to metres per second
    $stoppedDuration = 0;
    $stoppedCount = 0;
    for ($indx = 1; $indx < $nrSpeedVals; $indx++) {
        if ($speedVals [$indx] <= $thresholdSpeedMS) { # stopped
            $intervalDuration = abs($timeVals [$indx] - $timeVals [$indx - 1]);
            # this is a proxy for detecting separate tracks
            # if the interval is greater than 4 hours ignore it for analysis purposes
            if ($intervalDuration < 4 * HOUR_TO_S) { # four hours in seconds
                $stoppedDuration += $intervalDuration;
                $stoppedCount++;
            }
        }
    }
    return [$stoppedDuration, $stoppedCount]; # unix timestamp
} # end CalcStoppedTime
#
function calcChange (array $dataIn, array $speedVals, float $thSpeedKMH) {
// Calculate values increasing and descreasing when moving
    $nrItems = count($dataIn);
    if ($nrItems !== count ($speedVals)) return [$nrItems, count ($speedVals)]; # should never happen
    $thresholdSpeedMS = $thSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to metres per second
    $Increase = 0;
    $Decrease = 0;
    for ($indx = 2; $indx < $nrItems; $indx++) {
        if ($speedVals [$indx] > $thresholdSpeedMS) { # moving
            $pointChange = $dataIn [$indx] - $dataIn [$indx - 1];
            if ($pointChange < 0) { # decreasing
                $Decrease += abs($pointChange);
            } else {
                $Increase += $pointChange;
            } # if
        }
    } # for
    return [$Increase, $Decrease]; # unix timestamp
} # end calcChange
#
function calcAvgSpeed (array $elevVals, array $speedVals, array $distVals, array $timeVals, float $thSpeedKMH) {
// Calculate speeds ascending and descending when moving
    $nrElevVals = count ($elevVals);
    if ($nrElevVals !== count ($speedVals)) return [$nrElevVals, count ($speedVals)]; # should never happen
    $thresholdSpeedMS = $thSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to metres per second
    $ascDist = 0; # sum of ascending distance in metres
    $ascTime = 0; # sum of time ascending in seconds
    $dscDist = 0; # sum or descending distance in metres
    $dscTime = 0; # sum of time descending in seconds
    for ($indx = 2; $indx < $nrElevVals; $indx++) {
        if ($speedVals [$indx] > $thresholdSpeedMS) { # moving
            $elevChange = $elevVals [$indx] - $elevVals [$indx - 1];
            if ($elevChange == 0) continue; # next
            #
            $distChange = $distVals [$indx]; # metres
            $timeChange = $timeVals [$indx] - $timeVals [$indx - 1]; # seconds
            if ($elevChange < 0) { # descending
                $dscDist += $distChange;
                $dscTime += $timeChange;
            } else { # ascending
                $ascDist += $distChange;
                $ascTime += $timeChange;
            } # if
        }
    } # for
    $ascSpeed = calculateSpeedKMH ($ascDist, $ascTime);
    $dscSpeed = calculateSpeedKMH ($dscDist, $dscTime);
    return [$ascSpeed, $dscSpeed]; # speed KMH
} # end calcAvgSpeed
#
function calcMinMax (array $calcVals) : array {
     arsort($calcVals, SORT_NUMERIC); // Sort vals in descending order while maintaining index association
     $minVals = array_slice($calcVals, -intval(AVGWINDOW));
     $minVal  = array_sum($minVals) / count ($minVals);
     $maxVals = array_slice($calcVals, 0, intval(AVGWINDOW), true);
     $maxVal  = array_sum($maxVals) / count ($maxVals);
     return [$minVal, $maxVal];
}
#
function calculateSpeedKMH (float $distanceInMeters, float $durationInSeconds): float {
    $speedInKMH = ($durationInSeconds == 0) ? 0 : $distanceInMeters / 1000 / ($durationInSeconds / HOUR_TO_S);
    return $speedInKMH;
}
#
// check wiki file exists when passed groupname.pagename/filename.ext
function CheckWikiFile (string $wikifilename):array {
    global $UploadDir, $FmtV, $gpxstat_debugon, $pagename;
    # check if wiki file exists
    # if it does exist return FQDN
    # if it doesn't exist return markup to allow file to be uploaded
    $wikifilefqdn = \DownloadUrl ($pagename, $wikifilename); #PmWiki function, returns the public URL of an attached file or false if it doesn't exist
    //if ($gpxstat_debugon) tpmsg ('wikifile', '"' . $wikifilename . '", "' . strval ($wikifilefqdn) . '", "' . $FmtV['$LinkUpload'] . '"');
    if (! $wikifilefqdn === false) { # file exists
        return [true, $wikifilefqdn];
    }
    $wikimarkup = 'Upload: [[Attach:' . $wikifilename . ' | ' . $wikifilename . ']]' . BR; # 
    # note that the markup [[>>]] already seems to have been processed by the time we return this
    return [false, $wikimarkup];
} # end CheckWikiFile
#
function checkTimezone ($gpxTimezone, $gpxXml) {
# seach IANA / Olson and official abbreviations to validate input
    global $retVal;
    $tzCalc = '';
    // Check if the parameter is a SimpleXMLElement object
    $gpxXml->registerXPathNamespace('gpx', 'https://www.topografix.com/GPX/1/1');

    if (empty($gpxTimezone)) return [null, $tzCalc]; # no timezone to check
    $gpxstatTimezone = strval ($gpxTimezone);
    if (strtolower($gpxstatTimezone) == 'detect') { # try to calculate timezone for position
        list ($cur_lat, $cur_long) = get_first_lat_long_from_gpx($gpxXml);
        $timezone = get_nearest_timezone($cur_lat, $cur_long, $country_code = null);
        if (empty($timezone)) return [null, 'TZ not detected'];
        return [$timezone, 'TZ detected'];
    }
    // search IANA / Olson timezone database
    if (in_array($gpxstatTimezone, timezone_identifiers_list())) {
        $timezone = $gpxstatTimezone;
        date_default_timezone_set($timezone); # side effect
        return [$timezone, $tzCalc];
    } 
    // search official timezone abbreviations
    $timezone = timezone_name_from_abbr($gpxstatTimezone);
    if (! empty($timezone)) { 
        if (in_array(strval($timezone), timezone_identifiers_list())) {
            date_default_timezone_set($timezone); # side effect
            return [$timezone, $tzCalc];
        }
    } 
    $tzCalc = 'Unrecognised: "' . $gpxstatTimezone . '" timezone';
    $retVal .= GPXSTATNAME . ': ' . $tzCalc;
    return [null, $tzCalc];
} # end checkTimezone
#
function get_nearest_timezone($cur_lat, $cur_long, $country_code = null) {
// Function to calculate the nearest timezone based on the given latitude and longitude
// This method might not be 100% accurate for countries with multiple timezones. 
// see https://stackoverflow.com/questions/3126878/get-php-timezone-name-from-latitude-and-longitude
    // Get all timezone identifiers, if country code is provided then get timezone identifiers of that country
    $timezone_ids = (empty($country_code)) # country_code not supplied
        ? \DateTimeZone::listIdentifiers()
        : \DateTimeZone::listIdentifiers(\DateTimeZone::PER_COUNTRY, $country_code);

    // Check if timezone identifiers exist
    if($timezone_ids && is_array($timezone_ids) && isset($timezone_ids[0])) {
        // If only one identifier exists, set it as the timezone
        if(count($timezone_ids) == 1) {
            return $timezone_ids[0];
        }
        // Loop through all timezone identifiers
        $tz_distance = PHP_INT_MAX;
        $time_zone = null;
        foreach($timezone_ids as $timezone_id) {
            // Create a new DateTimeZone object
            $timezone = new \DateTimeZone($timezone_id);
            // Get the location of the timezone
            $location = $timezone->getLocation();
            $tz_lat   = $location['latitude'];
            $tz_long  = $location['longitude'];
            // Calculate the distance between the given location and the timezone location
            $theta = floatval($cur_long) - $tz_long;
            $distance = (sin(deg2rad(floatval($cur_lat))) * sin(deg2rad($tz_lat)))
              + (cos(deg2rad(floatval($cur_lat))) * cos(deg2rad($tz_lat)) * cos(deg2rad($theta)));
            $distance = acos($distance);
            $distance = abs(rad2deg($distance));
            // If no timezone has been set or the calculated distance is less than the previous distance
            // then set the current timezone as the nearest timezone
            if($tz_distance > $distance) {
                $time_zone   = $timezone_id;
                $tz_distance = $distance;
            }
        }
        // Return the nearest timezone
        return $time_zone;
    }
    // If no timezone identifiers exist, return message
    return 'get_nearest_timezone: no timezone ids' . BR;
}
#
function get_first_lat_long_from_gpx($xml) {
/**
 * Function to get the first latitude and longitude from GPX data.
 *
 * @param string $gpx_contents The GPX data as a string.
 * @return array withth 'lat' and 'lon' , or null.
 */
    global $retVal;
    $xml->registerXPathNamespace('gpx', 'https://www.topografix.com/GPX/1/1');

    // Find the first track point
    $trackpoints = $xml->xpath('//gpx:trkpt');
    if ($trackpoints === false) return null;
    if (empty($trackpoints)) return null;

    // Track points are found and the first one exists
    // Get the first track point
    $first_point = $trackpoints[0];
    // Get the latitude and longitude attributes as strings
    $lat = strval($first_point['lat']);
    $lon = strval($first_point['lon']);
    // Return the latitude and longitude as an associative array
    return [$lat, $lon];
}
#
function haversineGreatCircleDistance (float $latitudeFrom, float $longitudeFrom, float $latitudeTo, float $longitudeTo): float {
// calculate distance in metres between two points on the earth's surface
  // convert from degrees to radians
  ## see https://community.esri.com/t5/coordinate-reference-systems-blog/distance-on-a-sphere-the-haversine-formula/ba-p/902128
  $latFrom = deg2rad($latitudeFrom);
  $lonFrom = deg2rad($longitudeFrom);
  $latTo = deg2rad($latitudeTo);
  $lonTo = deg2rad($longitudeTo);

  $latDelta = $latTo - $latFrom;
  $lonDelta = $lonTo - $lonFrom;

  $angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
    cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
  return $angle * EARTH_RADIUS; # return in units of metres
} # end haversineGreatCircleDistance
#
function get_timezone_abbreviation($timezone_id) {
    $abb_list = timezone_abbreviations_list();
    foreach ($abb_list as $abb_key => $abb_val) {
        $key = array_search($timezone_id, array_column($abb_val, 'timezone_id'));
        if ($key !== false) {
            return strtoupper($abb_key);
        }
    }
    return false;
}
#
function displaySecsAsHoursMins(int $duration, string $format = '%02d:%02d'): string {
# convert a duration in seconds to a display as hours and minutes
    $hours = intval (floor($duration / HOUR_TO_S));
    $minutes = intval (($duration / 60)) % 60;
    return sprintf($format, $hours, $minutes);
}
#
function makeabbr (string $literal, $title): string {
# https://stackoverflow.com/questions/5362628/how-to-get-the-names-and-abbreviations-of-a-time-zone-in-php
    if (empty($title)) return $literal;
    return '<abbr title="' . $title . '">' . $literal . '</abbr>';
}
function display_xml_error($error, $gpxXml) {
# This function takes a LibXMLError object and the XML data as input,
# and returns a string that represents the error.
    $return = GPXSTATNAME . ': "' . $gpxXml[$error->line - 1] . '"' . BR;
    $return .= str_repeat('-', $error->column) . "^\n";
    // Determine the error level and add the appropriate message to the return string
    switch ($error->level) {
        case LIBXML_ERR_WARNING:
            $return .= "Warning $error->code: ";
            break;
        case LIBXML_ERR_ERROR:
            $return .= "Error $error->code: ";
            break;
        case LIBXML_ERR_FATAL:
            $return .= "Fatal Error $error->code: ";
            break;
    }
    // Add the error message, line, and column to the return string
    $return .= trim($error->message) . BR .
               "Line: $error->line" . BR .
               "Column: $error->column";
    // If a file is associated with the error, add it to the return string
    if ($error->file) {
        $return .= BR . "File: $error->file";
    }
    return $return;
} # end display_xml_error