aboutsummaryrefslogtreecommitdiff
path: root/official_climb/matcher.go
blob: 00f7e9b03e5da50a91f616dad41f08e5115a52e2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103

package official_climb

import (
	"math"

	"github.com/jftuga/geodist"
	"github.com/martinlehoux/biking_home/geo"
)

// MatchedClimb is an official climb that lies on a route, together with the
// route point indexes of its start and end. It lets the climb detector know
// which segment of the route is already claimed by an official climb.
type MatchedClimb struct {
	OfficialClimb
	StartIndex int
	EndIndex   int
}

// MatchClimb matches an official climb against a route purely by
// coordinates: both the start and end coordinates must be within radiusM of a
// route point, and the matched start point must precede the matched end point.
func MatchClimb(route []geodist.Coord, official OfficialClimb, radiusM float64) (MatchedClimb, bool) {
	matches := MatchClimbs(route, official, radiusM)
	if len(matches) == 0 {
		return MatchedClimb{}, false
	}
	return matches[0], true
}

// MatchClimbs returns all non-overlapping occurrences of an official climb on
// a route. Each occurrence is defined by a start and end point both within
// radiusM, with start strictly before end and direction preserved. Loops are
// handled by clustering contiguous approaches into single candidates and pairing
// each start cluster with the first end cluster inside its lap window.
func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) []MatchedClimb {
	if math.IsNaN(radiusM) || math.IsInf(radiusM, 0) || radiusM <= 0 {
		return nil
	}
	if len(route) == 0 {
		return nil
	}
	startClusters := clustersInRadius(route, official.StartCoord, radiusM)
	endClusters := clustersInRadius(route, official.EndCoord, radiusM)
	if len(startClusters) == 0 || len(endClusters) == 0 {
		return nil
	}
	matches := make([]MatchedClimb, 0)
	for i, start := range startClusters {
		nextStartIdx := len(route)
		if i+1 < len(startClusters) {
			nextStartIdx = startClusters[i+1].Index
		}
		var matchedEnd *clusterCandidate
		for j := range endClusters {
			end := &endClusters[j]
			if end.Index > start.Index && end.Index < nextStartIdx {
				matchedEnd = end
				break
			}
		}
		if matchedEnd == nil {
			continue
		}
		matches = append(matches, MatchedClimb{
			OfficialClimb: official,
			StartIndex:    start.Index,
			EndIndex:      matchedEnd.Index,
		})
	}
	return matches
}

type clusterCandidate struct {
	Index    int
	Distance float64
}

func clustersInRadius(route []geodist.Coord, target geodist.Coord, radiusM float64) []clusterCandidate {
	candidates := make([]clusterCandidate, 0)
	inCluster := false
	bestIndex := 0
	bestDistance := 0.0
	for idx, coord := range route {
		dist := geo.DistanceM(coord, target)
		if dist <= radiusM {
			if !inCluster {
				inCluster = true
				bestIndex = idx
				bestDistance = dist
			} else if dist < bestDistance {
				bestIndex = idx
				bestDistance = dist
			}
		} else if inCluster {
			candidates = append(candidates, clusterCandidate{Index: bestIndex, Distance: bestDistance})
			inCluster = false
		}
	}
	if inCluster {
		candidates = append(candidates, clusterCandidate{Index: bestIndex, Distance: bestDistance})
	}
	return candidates
}