aboutsummaryrefslogtreecommitdiff
path: root/climb/profile.go
blob: e88b2fa693fff80b464983d48d6f7afb490f4b5e (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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192

package climb

import "math"

// ProfileSection is one step of the displayed elevation profile: the straight stretch between two
// bends of the trace.
type ProfileSection struct {
	StartDistanceM  float64
	EndDistanceM    float64
	StartElevationM float64
	EndElevationM   float64
	SlopePercent    float64
}

// ProfilePolicy tunes how a trace is cut into display sections.
type ProfilePolicy struct {
	// MaxSections bounds the drawn steps: the widest bends are kept, the rest is averaged away.
	MaxSections int
	// MinBendM is the smallest elevation deviation worth its own step.
	MinBendM float64
	// BendPerBaseStep raises MinBendM with the sampling step, so a long climb tolerates
	// proportionally more smoothing than a short one.
	BendPerBaseStep float64
}

// DefaultProfilePolicy is the chart policy the web pages draw with.
func DefaultProfilePolicy() ProfilePolicy {
	return ProfilePolicy{
		MaxSections:     24,
		MinBendM:        1,
		BendPerBaseStep: 0.01,
	}
}

// profileBaseStepsM is the sampling ladder: the finest step that keeps a trace under
// profileMaxBaseCells samples, so GPS noise averages out and a ramp still localizes.
var profileBaseStepsM = []float64{25, 50, 100, 200, 500, 1000}

// profileMaxBaseCells bounds the sampling grid, whatever the trace length.
const profileMaxBaseCells = 120

type profileNode struct {
	distanceM  float64
	elevationM float64
}

type profileSplit struct {
	firstIndex int
	lastIndex  int
	splitIndex int
	deviationM float64
}

// ProfileSections cuts the trace into sections whose width follows its bends: a constant stretch
// collapses into one long section, a short steep ramp keeps its own, instead of both being averaged
// into a fixed distance bucket.
func (route Route) ProfileSections(policy ProfilePolicy) []ProfileSection {
	if len(route.Distances) != len(route.Elevations) || len(route.Distances) < 2 {
		return nil
	}
	lengthM := route.Distances[len(route.Distances)-1] - route.Distances[0]
	if lengthM <= 0 {
		return nil
	}
	baseStepM := profileBaseStepM(lengthM)
	nodes := profileNodes(route, baseStepM)
	bendM := math.Max(policy.MinBendM, policy.BendPerBaseStep*baseStepM)
	return sectionsFromNodes(simplifyNodes(nodes, bendM, policy.MaxSections))
}

// profileBaseStepM picks the finest sampling step that keeps the grid under profileMaxBaseCells.
func profileBaseStepM(lengthM float64) float64 {
	for _, stepM := range profileBaseStepsM {
		if math.Ceil(lengthM/stepM) <= profileMaxBaseCells {
			return stepM
		}
	}
	return math.Ceil(lengthM / profileMaxBaseCells)
}

// profileNodes resamples the trace on a uniform grid, interpolating between recorded points.
func profileNodes(route Route, stepM float64) []profileNode {
	startM := route.Distances[0]
	endM := route.Distances[len(route.Distances)-1]
	elevationAt := elevationInterpolator(route)
	nodes := make([]profileNode, 0, int((endM-startM)/stepM)+2)
	for distanceM := startM; distanceM < endM; distanceM += stepM {
		nodes = append(nodes, profileNode{distanceM: distanceM, elevationM: elevationAt(distanceM)})
	}
	return append(nodes, profileNode{distanceM: endM, elevationM: elevationAt(endM)})
}

// elevationInterpolator returns the trace elevation at a distance. Callers walk it forward, so the
// point cursor only moves on.
func elevationInterpolator(route Route) func(distanceM float64) float64 {
	index := 0
	lastIndex := len(route.Distances) - 1
	return func(distanceM float64) float64 {
		for index < lastIndex-1 && route.Distances[index+1] < distanceM {
			index++
		}
		nextIndex := min(index+1, lastIndex)
		spanM := route.Distances[nextIndex] - route.Distances[index]
		if spanM <= 0 {
			return route.Elevations[index]
		}
		fraction := (distanceM - route.Distances[index]) / spanM
		return route.Elevations[index] + min(max(fraction, 0), 1)*(route.Elevations[nextIndex]-route.Elevations[index])
	}
}

// chordDeviationM is how far the node sits above the chord joining the two ends of its segment.
func chordDeviationM(first, last, node profileNode) float64 {
	spanM := last.distanceM - first.distanceM
	if spanM <= 0 {
		return node.elevationM - first.elevationM
	}
	chordElevationM := first.elevationM + ((node.distanceM-first.distanceM)/spanM)*(last.elevationM-first.elevationM)
	return node.elevationM - chordElevationM
}

func bestSplit(nodes []profileNode, firstIndex, lastIndex int) profileSplit {
	split := profileSplit{firstIndex: firstIndex, lastIndex: lastIndex, splitIndex: -1}
	for index := firstIndex + 1; index < lastIndex; index++ {
		deviationM := math.Abs(chordDeviationM(nodes[firstIndex], nodes[lastIndex], nodes[index]))
		if deviationM > split.deviationM {
			split.deviationM = deviationM
			split.splitIndex = index
		}
	}
	return split
}

// simplifyNodes keeps splitting the segment holding the widest bend until the trace fits the section
// budget or has no bend left above bendM, so the cuts land on the most prominent bends first.
func simplifyNodes(nodes []profileNode, bendM float64, maxSections int) []profileNode {
	if len(nodes) <= 2 {
		return nodes
	}
	lastIndex := len(nodes) - 1
	kept := make([]bool, len(nodes))
	kept[0] = true
	kept[lastIndex] = true
	pending := []profileSplit{bestSplit(nodes, 0, lastIndex)}
	sectionCount := 1
	for sectionCount < maxSections {
		widest := -1
		for index, split := range pending {
			if split.splitIndex == -1 {
				continue
			}
			if widest == -1 || split.deviationM > pending[widest].deviationM {
				widest = index
			}
		}
		if widest == -1 || pending[widest].deviationM <= bendM {
			break
		}
		split := pending[widest]
		pending = append(pending[:widest], pending[widest+1:]...)
		kept[split.splitIndex] = true
		sectionCount++
		pending = append(pending, bestSplit(nodes, split.firstIndex, split.splitIndex), bestSplit(nodes, split.splitIndex, split.lastIndex))
	}
	simplified := make([]profileNode, 0, sectionCount+1)
	for index, node := range nodes {
		if kept[index] {
			simplified = append(simplified, node)
		}
	}
	return simplified
}

func sectionsFromNodes(nodes []profileNode) []ProfileSection {
	sections := make([]ProfileSection, 0, len(nodes)-1)
	for index := 1; index < len(nodes); index++ {
		start := nodes[index-1]
		end := nodes[index]
		spanM := end.distanceM - start.distanceM
		if spanM <= 0 {
			continue
		}
		sections = append(sections, ProfileSection{
			StartDistanceM:  start.distanceM,
			EndDistanceM:    end.distanceM,
			StartElevationM: start.elevationM,
			EndElevationM:   end.elevationM,
			SlopePercent:    (end.elevationM - start.elevationM) / spanM * 100,
		})
	}
	return sections
}