package geo
import (
"runtime"
"sync"
)
// Analysis is the per-track summary produced by Analyze (no point list — this
// is the lightweight result you'd store or recompute for a whole catalog).
type Analysis struct {
Name string
Points int
SimplifiedPoints int
ToleranceM float64
LengthKm float64
ElevationGainM float64
Bounds BoundingBox
}
// Analyze runs the full pipeline for one track: simplify + length + gain + bounds.
func (t Track) Analyze(toleranceM float64) Analysis {
s := t.Simplify(toleranceM)
return Analysis{
Name: t.Name,
Points: len(t.Points),
SimplifiedPoints: len(s.Points),
ToleranceM: toleranceM,
LengthKm: t.Length() / 1000,
ElevationGainM: t.ElevationGain(),
Bounds: t.Bounds(),
}
}
// AnalyzeBatchSequential analyzes tracks one after another on a single core.
func AnalyzeBatchSequential(tracks []Track, toleranceM float64) []Analysis {
out := make([]Analysis, len(tracks))
for i, t := range tracks {
out[i] = t.Analyze(toleranceM)
}
return out
}
// AnalyzeBatch analyzes tracks concurrently using a pool of `workers` goroutines.
// workers <= 0 defaults to the number of CPU cores. Output keeps input order.
func AnalyzeBatch(tracks []Track, toleranceM float64, workers int) []Analysis {
if workers <= 0 {
workers = runtime.NumCPU()
}
out := make([]Analysis, len(tracks))
jobs := make(chan int)
var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
for i := range jobs {
// Each job owns a distinct index, so concurrent writes to out
// never overlap — no mutex needed.
out[i] = tracks[i].Analyze(toleranceM)
}
}()
}
for i := range tracks {
jobs <- i
}
close(jobs)
wg.Wait()
return out
}
package geo
import "math"
const earthRadiusM = 6371000.0
func rad(deg float64) float64 { return deg * math.Pi / 180 }
// haversine returns the great-circle distance between two points in meters.
func haversine(a, b Point) float64 {
lat1, lat2 := rad(a.Lat), rad(b.Lat)
dLat := rad(b.Lat - a.Lat)
dLon := rad(b.Lon - a.Lon)
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * earthRadiusM * math.Asin(math.Sqrt(h))
}
package geo
import "math"
// Simplify returns a copy of the track keeping only the points needed to stay
// within toleranceMeters of the original shape (Ramer–Douglas–Peucker).
// Endpoints are always preserved. A non-positive tolerance returns a full copy.
func (t Track) Simplify(toleranceMeters float64) Track {
return Track{Name: t.Name, Points: simplify(t.Points, toleranceMeters)}
}
func simplify(pts []Point, tol float64) []Point {
if len(pts) < 3 || tol <= 0 {
return append([]Point(nil), pts...) // defensive copy
}
proj := project(pts)
keep := make([]bool, len(pts))
keep[0], keep[len(pts)-1] = true, true
rdp(proj, 0, len(pts)-1, tol, keep)
out := make([]Point, 0, len(pts))
for i := range pts {
if keep[i] {
out = append(out, pts[i])
}
}
return out
}
// planar is a point projected to local meters (equirectangular about the start
// latitude) so perpendicular distances come out in meters.
type planar struct{ x, y float64 }
func project(pts []Point) []planar {
cosLat0 := math.Cos(rad(pts[0].Lat))
out := make([]planar, len(pts))
for i, p := range pts {
out[i] = planar{x: earthRadiusM * rad(p.Lon) * cosLat0, y: earthRadiusM * rad(p.Lat)}
}
return out
}
// rdp marks (in keep) the points between first and last that must be retained.
func rdp(proj []planar, first, last int, eps float64, keep []bool) {
if last <= first+1 {
return
}
a, b := proj[first], proj[last]
maxDist, idx := 0.0, first
for i := first + 1; i < last; i++ {
if d := perpDistance(proj[i], a, b); d > maxDist {
maxDist, idx = d, i
}
}
if maxDist > eps {
keep[idx] = true
rdp(proj, first, idx, eps, keep)
rdp(proj, idx, last, eps, keep)
}
}
// perpDistance is the distance from p to the line segment a–b, in meters.
func perpDistance(p, a, b planar) float64 {
dx, dy := b.x-a.x, b.y-a.y
if dx == 0 && dy == 0 {
return math.Hypot(p.x-a.x, p.y-a.y)
}
cross := math.Abs(dx*(a.y-p.y) - dy*(a.x-p.x))
return cross / math.Hypot(dx, dy)
}
package geo
import "math"
// Track is a named sequence of GPS points plus the metrics derived from them.
type Track struct {
Name string
Points []Point
}
// BoundingBox is the lat/lon extent of a track.
type BoundingBox struct {
MinLat float64 `json:"minLat"`
MinLon float64 `json:"minLon"`
MaxLat float64 `json:"maxLat"`
MaxLon float64 `json:"maxLon"`
}
// Length returns the track length in meters (sum of great-circle segments).
func (t Track) Length() float64 {
var total float64
for i := 1; i < len(t.Points); i++ {
total += haversine(t.Points[i-1], t.Points[i])
}
return total
}
// ElevationGain returns the total positive elevation change in meters.
// Raw sum of uphill deltas — real apps smooth GPS noise first; good enough here.
func (t Track) ElevationGain() float64 {
var gain float64
for i := 1; i < len(t.Points); i++ {
if d := t.Points[i].Ele - t.Points[i-1].Ele; d > 0 {
gain += d
}
}
return gain
}
// Bounds returns the bounding box of the track. Zero value for an empty track.
func (t Track) Bounds() BoundingBox {
if len(t.Points) == 0 {
return BoundingBox{}
}
bb := BoundingBox{
MinLat: t.Points[0].Lat, MaxLat: t.Points[0].Lat,
MinLon: t.Points[0].Lon, MaxLon: t.Points[0].Lon,
}
for _, p := range t.Points[1:] {
bb.MinLat = min(bb.MinLat, p.Lat)
bb.MaxLat = max(bb.MaxLat, p.Lat)
bb.MinLon = min(bb.MinLon, p.Lon)
bb.MaxLon = max(bb.MaxLon, p.Lon)
}
return bb
}
// ElevationProfile resamples elevation along cumulative distance into `samples`
// evenly-spaced points (meters, 0.1 m precision). The result is a distance-based
// elevation curve fit for a small sparkline. Returns nil for tracks too short
// or with zero length to profile.
func (t Track) ElevationProfile(samples int) []float64 {
if samples < 2 || len(t.Points) < 2 {
return nil
}
// Cumulative great-circle distance at each point.
cum := make([]float64, len(t.Points))
for i := 1; i < len(t.Points); i++ {
cum[i] = cum[i-1] + haversine(t.Points[i-1], t.Points[i])
}
total := cum[len(cum)-1]
if total == 0 {
return nil
}
// Targets increase monotonically, so the segment cursor only moves forward.
out := make([]float64, samples)
seg := 0
for j := range samples {
target := total * float64(j) / float64(samples-1)
for seg < len(cum)-2 && cum[seg+1] < target {
seg++
}
d0, d1 := cum[seg], cum[seg+1]
e0, e1 := t.Points[seg].Ele, t.Points[seg+1].Ele
ele := e0
if d1 > d0 {
ele = e0 + (target-d0)/(d1-d0)*(e1-e0)
}
out[j] = math.Round(ele*10) / 10
}
return out
}
// Package gpx parses GPX 1.1 documents into geo domain types.
package gpx
import (
"encoding/xml"
"fmt"
"io"
"github.com/ontrack-by/geo-service/internal/geo"
)
// XML mapping types. Unexported: they exist only to shape decoding and
// never leave this package. Field tags map struct fields to XML names;
// Go matches by local name regardless of namespace prefix.
type xmlGPX struct {
XMLName xml.Name `xml:"gpx"`
Tracks []xmlTrk `xml:"trk"`
}
type xmlTrk struct {
Name string `xml:"name"`
Segments []xmlTrkseg `xml:"trkseg"`
}
type xmlTrkseg struct {
Points []xmlTrkpt `xml:"trkpt"`
}
type xmlTrkpt struct {
Lat float64 `xml:"lat,attr"` // ,attr → read from an attribute, not a child element
Lon float64 `xml:"lon,attr"`
Ele float64 `xml:"ele"`
}
// Parse reads a GPX document from r and returns the first track's name with
// all points flattened. It streams via a decoder, so it never holds the whole
// file as a string.
func Parse(r io.Reader) (*geo.Track, error) {
var doc xmlGPX
if err := xml.NewDecoder(r).Decode(&doc); err != nil {
return nil, fmt.Errorf("decode gpx: %w", err)
}
if len(doc.Tracks) == 0 {
return nil, fmt.Errorf("gpx: no <trk> element")
}
out := &geo.Track{Name: doc.Tracks[0].Name}
for _, trk := range doc.Tracks {
for _, seg := range trk.Segments {
for _, p := range seg.Points {
out.Points = append(out.Points, geo.Point{Lat: p.Lat, Lon: p.Lon, Ele: p.Ele})
}
}
}
if len(out.Points) == 0 {
return nil, fmt.Errorf("gpx: track %q has no points", out.Name)
}
return out, nil
}
package main
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
"github.com/ontrack-by/geo-service/internal/geo"
"github.com/ontrack-by/geo-service/internal/gpx"
)
const (
defaultToleranceM = 10.0
maxGPXBytes = 32 << 20 // 32 MiB upload cap
elevationProfileSamps = 64 // points in the elevation sparkline
)
type healthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Time string `json:"time"`
}
// analyzeResponse is what POST /v1/geo/analyze returns for an uploaded GPX.
type analyzeResponse struct {
Name string `json:"name"`
Points int `json:"points"`
SimplifiedPoints int `json:"simplifiedPoints"`
ToleranceM float64 `json:"toleranceM"`
LengthKm float64 `json:"lengthKm"`
ElevationGainM float64 `json:"elevationGainM"`
Bounds geo.BoundingBox `json:"bounds"`
Simplified [][2]float64 `json:"simplified"` // [lon, lat] pairs (GeoJSON order)
Profile []float64 `json:"profile"` // resampled elevation (m) for a sparkline
}
func main() {
port := getenv("PORT", "3003")
serviceToken := os.Getenv("SERVICE_TOKEN")
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)
// /v1/geo/* is internal: only the gateway may call it, carrying the shared
// X-Service-Token. Empty token = dev mode (open) with a loud warning.
var geo http.Handler = http.HandlerFunc(handleAnalyze)
if serviceToken != "" {
geo = requireServiceToken(serviceToken, geo)
} else {
log.Printf("WARNING: SERVICE_TOKEN not set — /v1/geo endpoints are UNAUTHENTICATED")
}
mux.Handle("POST /v1/geo/analyze", geo)
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("geo-service listening on :%s", port)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("server stopped: %v", err)
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// requireServiceToken rejects requests whose X-Service-Token does not match.
// The compare is constant-time so timing can't leak the secret.
func requireServiceToken(expected string, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(expected))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := sha256.Sum256([]byte(r.Header.Get("X-Service-Token")))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid service token")
return
}
next.ServeHTTP(w, r)
})
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, healthResponse{
Status: "ok",
Service: "geo-service",
Time: time.Now().UTC().Format(time.RFC3339),
})
}
// handleAnalyze parses a GPX request body, computes metrics, and returns a
// simplified polyline. Tolerance (meters) comes from the ?tolerance= query.
func handleAnalyze(w http.ResponseWriter, r *http.Request) {
tolerance := defaultToleranceM
if q := r.URL.Query().Get("tolerance"); q != "" {
if v, err := strconv.ParseFloat(q, 64); err == nil && v >= 0 && !math.IsInf(v, 0) {
tolerance = v
}
}
track, err := gpx.Parse(http.MaxBytesReader(w, r.Body, maxGPXBytes))
if err != nil {
writeError(w, http.StatusBadRequest, "INVALID_GPX", err.Error())
return
}
simplified := track.Simplify(tolerance)
coords := make([][2]float64, len(simplified.Points))
for i, p := range simplified.Points {
coords[i] = [2]float64{p.Lon, p.Lat}
}
writeJSON(w, http.StatusOK, analyzeResponse{
Name: track.Name,
Points: len(track.Points),
SimplifiedPoints: len(simplified.Points),
ToleranceM: tolerance,
LengthKm: track.Length() / 1000,
ElevationGainM: track.ElevationGain(),
Bounds: track.Bounds(),
Simplified: coords,
Profile: track.ElevationProfile(elevationProfileSamps),
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("write json: %v", err)
}
}
// errorEnvelope matches the { error: { code, message } } shape the rest of the
// OnTrack stack emits (backend-common AppError), so the gateway normalizes it.
type errorEnvelope struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
}