package activity import ( "encoding/xml" "fmt" "math" "strconv" "strings" "time" ) // GPX XML structures type gpxFile struct { Metadata gpxMetadata `xml:"metadata"` Tracks []gpxTrack `xml:"trk"` } type gpxMetadata struct { Name string `xml:"name"` Time string `xml:"time"` } type gpxTrack struct { Name string `xml:"name"` Type string `xml:"type"` Segments []gpxTrackSeg `xml:"trkseg"` } type gpxTrackSeg struct { Points []gpxTrackPoint `xml:"trkpt"` } type gpxTrackPoint struct { Lat float64 `xml:"lat,attr"` Lon float64 `xml:"lon,attr"` Elevation float64 `xml:"ele"` Time string `xml:"time"` Extensions gpxTPExtensions `xml:"extensions"` } // gpxTPExtensions captures the raw inner XML of extensions for flexible parsing. type gpxTPExtensions struct { InnerXML string `xml:",innerxml"` } // ParseGPX parses a GPX activity file, computing metrics from trackpoints. func ParseGPX(data []byte) (*ParsedActivity, error) { var gpx gpxFile if err := xml.Unmarshal(data, &gpx); err != nil { return nil, fmt.Errorf("failed to parse GPX file: %w", err) } if len(gpx.Tracks) == 0 { return nil, fmt.Errorf("no tracks found in GPX file") } result := &ParsedActivity{} // Set title from track or metadata if gpx.Tracks[0].Name != "" { result.Title = gpx.Tracks[0].Name } else if gpx.Metadata.Name != "" { result.Title = gpx.Metadata.Name } // Collect all points across tracks and segments var allPoints []gpxTrackPoint for _, trk := range gpx.Tracks { for _, seg := range trk.Segments { allPoints = append(allPoints, seg.Points...) } } if len(allPoints) == 0 { return nil, fmt.Errorf("no trackpoints found in GPX file") } // Calculate distance using Haversine formula var totalDistance float64 for i := 1; i < len(allPoints); i++ { d := haversine(allPoints[i-1].Lat, allPoints[i-1].Lon, allPoints[i].Lat, allPoints[i].Lon) totalDistance += d } result.Distance = totalDistance / 1000.0 // meters to km // Calculate duration from timestamps var firstTime, lastTime time.Time for _, pt := range allPoints { if pt.Time == "" { continue } t, err := time.Parse(time.RFC3339, pt.Time) if err != nil { continue } if firstTime.IsZero() { firstTime = t } lastTime = t } if !firstTime.IsZero() && !lastTime.IsZero() { result.Duration = int(lastTime.Sub(firstTime).Seconds()) result.StartTime = firstTime } // Calculate elevation gain (sum of positive altitude deltas) var elevGain float64 var prevEle float64 firstEle := true for _, pt := range allPoints { if pt.Elevation == 0 { continue } if firstEle { prevEle = pt.Elevation firstEle = false continue } delta := pt.Elevation - prevEle if delta > 0 { elevGain += delta } prevEle = pt.Elevation } result.ElevGain = int(math.Round(elevGain)) // Extract HR, power, cadence from extensions var hrSum, powerSum, cadSum int var hrCount, powerCount, cadCount int var maxHR, maxPower int for _, pt := range allPoints { hr, power, cad := parseGPXExtensions(pt.Extensions.InnerXML) if hr > 0 { hrSum += hr hrCount++ if hr > maxHR { maxHR = hr } } if power > 0 { powerSum += power powerCount++ if power > maxPower { maxPower = power } } if cad > 0 { cadSum += cad cadCount++ } } if hrCount > 0 { result.AvgHR = hrSum / hrCount } result.MaxHR = maxHR if powerCount > 0 { result.AvgPower = powerSum / powerCount } result.MaxPower = maxPower if cadCount > 0 { result.AvgCadence = cadSum / cadCount } return result, nil } // parseGPXExtensions extracts HR, power, and cadence from extension XML. // Handles common namespaces: Garmin TrackPointExtension, ClueTrust, generic. func parseGPXExtensions(innerXML string) (hr, power, cadence int) { if innerXML == "" { return } // Parse the inner XML as a generic tree type anyElement struct { XMLName xml.Name Value string `xml:",chardata"` Children []anyElement `xml:",any"` } var elements []anyElement wrapped := "" + innerXML + "" var wrapper struct { Children []anyElement `xml:",any"` } if err := xml.Unmarshal([]byte(wrapped), &wrapper); err != nil { return } elements = wrapper.Children // Walk the element tree looking for known field names var walk func(elems []anyElement) walk = func(elems []anyElement) { for _, el := range elems { local := strings.ToLower(el.XMLName.Local) switch local { case "hr": if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 { hr = v } // HR might be nested:
145 for _, child := range el.Children { if strings.ToLower(child.XMLName.Local) == "value" { if v, err := strconv.Atoi(strings.TrimSpace(child.Value)); err == nil && v > 0 { hr = v } } } case "power", "watts": if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 { power = v } case "cad": if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 { cadence = v } } // Recurse into children (e.g., TrackPointExtension wrapper) if len(el.Children) > 0 { walk(el.Children) } } } walk(elements) return } // haversine calculates the distance in meters between two lat/lon points. func haversine(lat1, lon1, lat2, lon2 float64) float64 { const earthRadius = 6371000.0 // meters dLat := degToRad(lat2 - lat1) dLon := degToRad(lon2 - lon1) a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(degToRad(lat1))*math.Cos(degToRad(lat2))* math.Sin(dLon/2)*math.Sin(dLon/2) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return earthRadius * c } func degToRad(deg float64) float64 { return deg * math.Pi / 180.0 }