bd7eb246d5
When suppressing duplicate cafes and place names, look back a certain distance along the route rather than just the previous point of interest. When searching for cafes, use SearchIntersect() to return all entries in the bounding rectangle, not just the nearest. Remove (most) hard-coded constants and allow these to be overriden by options to the NewGPXSummarizer() constructor.
338 lines
8.4 KiB
Go
338 lines
8.4 KiB
Go
package placenames
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dhconnelly/rtreego"
|
|
"github.com/fofanov/go-osgb"
|
|
"github.com/ray1729/gpx-utils/pkg/cafes"
|
|
"github.com/twpayne/go-gpx"
|
|
)
|
|
|
|
var populatedPlaceRank = map[string]int{
|
|
"City": 5,
|
|
"Town": 4,
|
|
"Village": 3,
|
|
"Hamlet": 3,
|
|
"Other Settlement": 1,
|
|
}
|
|
|
|
// GPXSummarizerConfig allows override of defaults used by the search algorithm.
|
|
type GPXSummarizerConfig struct {
|
|
CoffeeStopSearchRectangleSize float64
|
|
CoffeeStopDuplicateDistance float64
|
|
PointOfInterestDuplicateDistance float64
|
|
PointOfInterestMinimumDistance float64
|
|
MinimumSettlementRank int
|
|
}
|
|
|
|
var DefaultGPXSummarizerConfig = GPXSummarizerConfig{
|
|
CoffeeStopSearchRectangleSize: 500.0, // m
|
|
CoffeeStopDuplicateDistance: 2.0, // km
|
|
PointOfInterestDuplicateDistance: 1.0, // km
|
|
PointOfInterestMinimumDistance: 0.2, // km
|
|
MinimumSettlementRank: 1, // "Other Settlement"
|
|
}
|
|
|
|
type Option func(*GPXSummarizerConfig)
|
|
|
|
// WithCoffeeStopSearchRectangleSize overrides the size (in metres) of the rectangle searched
|
|
// for coffee stops near the route. Default 500m.
|
|
func WithCoffeeStopSearchRectangleSize(d float64) Option {
|
|
return func(c *GPXSummarizerConfig) {
|
|
c.CoffeeStopSearchRectangleSize = d
|
|
}
|
|
}
|
|
|
|
// WithCoffeeStopDuplicateDistance overrides the distance (in kilometers) we look back along the
|
|
// route when suppressing duplicate coffee stop entries. This should be at least twice the
|
|
// CoffeeStopSearchRectangleSize. Default 2km.
|
|
func WithCoffeeStopDuplicateDistance(d float64) Option {
|
|
return func(c *GPXSummarizerConfig) {
|
|
c.CoffeeStopDuplicateDistance = d
|
|
}
|
|
}
|
|
|
|
// WithPointOfInterestDuplicateDistance overrides the distance (in km) we look back along
|
|
// the route when suppressing duplicate points of interest.
|
|
func WithPointOfInterestDuplicateDistance(d float64) Option {
|
|
return func(c *GPXSummarizerConfig) {
|
|
c.PointOfInterestDuplicateDistance = d
|
|
}
|
|
}
|
|
|
|
// WithPointOfInterestMinimumDistance overrides the minimum distance (in km) between points
|
|
// of interest (if two POI appear within this distance, the second one is suppressed). Default
|
|
// 0km (no suppression).
|
|
func WithPointOfInterestMinimumDistance(d float64) Option {
|
|
return func(c *GPXSummarizerConfig) {
|
|
c.PointOfInterestMinimumDistance = d
|
|
}
|
|
}
|
|
|
|
func WithMinimumSettlement(s string) Option {
|
|
rank, ok := populatedPlaceRank[s]
|
|
if !ok {
|
|
panic(fmt.Sprintf("invalid settlement type: %s", s))
|
|
}
|
|
return func(c *GPXSummarizerConfig) {
|
|
c.MinimumSettlementRank = rank
|
|
}
|
|
}
|
|
|
|
type GPXSummarizer struct {
|
|
poi *rtreego.Rtree
|
|
trans osgb.CoordinateTransformer
|
|
conf GPXSummarizerConfig
|
|
}
|
|
|
|
func NewGPXSummarizer(opts ...Option) (*GPXSummarizer, error) {
|
|
conf := DefaultGPXSummarizerConfig
|
|
for _, f := range opts {
|
|
f(&conf)
|
|
}
|
|
trans, err := osgb.NewOSTN15Transformer()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rt, err := RestoreIndex()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &GPXSummarizer{poi: rt, trans: trans, conf: conf}, nil
|
|
}
|
|
|
|
func distance(p1, p2 rtreego.Point) float64 {
|
|
if len(p1) != len(p2) {
|
|
panic("Length mismatch")
|
|
}
|
|
var s float64
|
|
for i := range p1 {
|
|
d := p1[i] - p2[i]
|
|
s += d * d
|
|
}
|
|
return math.Sqrt(s) / 1000.0
|
|
}
|
|
|
|
type POI struct {
|
|
Name string
|
|
Type string
|
|
Distance float64
|
|
}
|
|
|
|
type RefreshmentStop struct {
|
|
Name string
|
|
Url string
|
|
Distance float64
|
|
}
|
|
|
|
type TrackSummary struct {
|
|
Name string
|
|
Direction string
|
|
Time time.Time
|
|
Link string
|
|
Start string
|
|
Finish string
|
|
Distance float64
|
|
Ascent float64
|
|
Descent float64
|
|
PointsOfInterest []POI
|
|
RefreshmentStops []RefreshmentStop `json:",omitempty"`
|
|
Counties map[string]int
|
|
}
|
|
|
|
func (gs *GPXSummarizer) SummarizeTrack(r io.Reader, stops *rtreego.Rtree) (*TrackSummary, error) {
|
|
g, err := gpx.Read(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var s TrackSummary
|
|
s.Name = g.Metadata.Name
|
|
s.Time = g.Metadata.Time
|
|
s.Counties = make(map[string]int)
|
|
for _, l := range g.Metadata.Link {
|
|
if strings.HasPrefix(l.HREF, "http") {
|
|
s.Link = l.HREF
|
|
break
|
|
}
|
|
}
|
|
|
|
var elevations []float64
|
|
var prevPlace string
|
|
var prevPlacePoint rtreego.Point
|
|
var prevPoint rtreego.Point
|
|
var start rtreego.Point
|
|
var dN, dE float64
|
|
|
|
init := true
|
|
for _, trk := range g.Trk {
|
|
for _, seg := range trk.TrkSeg {
|
|
for _, p := range seg.TrkPt {
|
|
gpsCoord := osgb.NewETRS89Coord(p.Lon, p.Lat, p.Ele)
|
|
ngCoord, err := gs.trans.ToNationalGrid(gpsCoord)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
elevations = append(elevations, p.Ele)
|
|
thisPoint := rtreego.Point{ngCoord.Easting, ngCoord.Northing}
|
|
nn, _ := gs.poi.NearestNeighbor(thisPoint).(*NamedBoundary)
|
|
if init {
|
|
if !nn.NearEnough(thisPoint, 500.0) {
|
|
return nil, fmt.Errorf("start point out of range")
|
|
}
|
|
start = thisPoint
|
|
s.Start = nn.Name
|
|
prevPlace = nn.Name
|
|
prevPlacePoint = thisPoint
|
|
prevPoint = thisPoint
|
|
s.PointsOfInterest = append(s.PointsOfInterest, POI{Name: nn.Name, Type: nn.Type, Distance: 0.0})
|
|
s.Counties[nn.County]++
|
|
init = false
|
|
continue
|
|
}
|
|
s.Distance += distance(thisPoint, prevPoint)
|
|
dE += thisPoint[0] - start[0]
|
|
dN += thisPoint[1] - start[1]
|
|
if nn.Contains(thisPoint) && populatedPlaceRank[nn.Type] >= gs.conf.MinimumSettlementRank {
|
|
s.Counties[nn.County]++
|
|
seenRecently := false
|
|
for i := len(s.PointsOfInterest) - 1; i >= 0; i-- {
|
|
if i < len(s.PointsOfInterest)-1 && s.Distance-s.PointsOfInterest[i].Distance > gs.conf.PointOfInterestDuplicateDistance {
|
|
break
|
|
}
|
|
if nn.Name == s.PointsOfInterest[i].Name {
|
|
seenRecently = true
|
|
break
|
|
}
|
|
}
|
|
if !seenRecently && distance(thisPoint, prevPlacePoint) > gs.conf.PointOfInterestMinimumDistance {
|
|
s.PointsOfInterest = append(s.PointsOfInterest, POI{Name: nn.Name, Type: nn.Type, Distance: s.Distance})
|
|
prevPlace = nn.Name
|
|
prevPlacePoint = thisPoint
|
|
}
|
|
}
|
|
if stops != nil {
|
|
for _, nearbyStop := range stops.SearchIntersect(thisPoint.ToRect(gs.conf.CoffeeStopSearchRectangleSize)) {
|
|
stop := nearbyStop.(*cafes.RefreshmentStop)
|
|
seenRecently := false
|
|
for i := len(s.RefreshmentStops) - 1; i >= 0; i-- {
|
|
if i < len(s.RefreshmentStops)-1 && s.Distance-s.RefreshmentStops[i].Distance > gs.conf.CoffeeStopDuplicateDistance {
|
|
break
|
|
}
|
|
if s.RefreshmentStops[i].Name == stop.Name {
|
|
seenRecently = true
|
|
break
|
|
}
|
|
}
|
|
if !seenRecently {
|
|
s.RefreshmentStops = append(s.RefreshmentStops, RefreshmentStop{
|
|
Name: stop.Name,
|
|
Url: stop.Url,
|
|
Distance: s.Distance,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
prevPoint = thisPoint
|
|
}
|
|
}
|
|
}
|
|
s.Finish = prevPlace
|
|
s.Direction = calcDirection(dE, dN)
|
|
s.Ascent, s.Descent = calcUphillDownhill(elevations)
|
|
s.Counties = toPercentages(s.Counties)
|
|
return &s, nil
|
|
}
|
|
|
|
func toPercentages(m map[string]int) map[string]int {
|
|
t := 0
|
|
for _, v := range m {
|
|
t += v
|
|
}
|
|
for k, v := range m {
|
|
m[k] = v * 100 / t
|
|
if m[k] == 0 {
|
|
delete(m, k)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func calcDirection(dE, dN float64) string {
|
|
if dN == 0 {
|
|
if dE >= 0 {
|
|
return "east"
|
|
}
|
|
return "west"
|
|
}
|
|
t := math.Abs(dE) / math.Abs(dN)
|
|
if dN > 0 {
|
|
if t < math.Tan(math.Pi/8) {
|
|
return "north"
|
|
}
|
|
if t < math.Tan(3*math.Pi/8) {
|
|
if dE > 0 {
|
|
return "north-east"
|
|
}
|
|
return "north-west"
|
|
}
|
|
if dE > 0 {
|
|
return "east"
|
|
}
|
|
return "west"
|
|
}
|
|
if t < math.Tan(math.Pi/8) {
|
|
return "south"
|
|
}
|
|
if t < math.Tan(3*math.Pi/8) {
|
|
if dE > 0 {
|
|
return "south-east"
|
|
}
|
|
return "south-west"
|
|
}
|
|
if dE > 0 {
|
|
return "east"
|
|
}
|
|
return "west"
|
|
}
|
|
|
|
// calcUphillDownhill calculates uphill/downhill data
|
|
// Implementation from https://github.com/ptrv/go-gpx
|
|
func calcUphillDownhill(elevations []float64) (float64, float64) {
|
|
elevsLen := len(elevations)
|
|
if elevsLen == 0 {
|
|
return 0.0, 0.0
|
|
}
|
|
|
|
smoothElevations := make([]float64, elevsLen)
|
|
|
|
for i, elev := range elevations {
|
|
var currEle float64
|
|
if 0 < i && i < elevsLen-1 {
|
|
prevEle := elevations[i-1]
|
|
nextEle := elevations[i+1]
|
|
currEle = prevEle*0.3 + elev*0.4 + nextEle*0.3
|
|
} else {
|
|
currEle = elev
|
|
}
|
|
smoothElevations[i] = currEle
|
|
}
|
|
|
|
var uphill float64
|
|
var downhill float64
|
|
|
|
for i := 1; i < len(smoothElevations); i++ {
|
|
d := smoothElevations[i] - smoothElevations[i-1]
|
|
if d > 0.0 {
|
|
uphill += d
|
|
} else {
|
|
downhill -= d
|
|
}
|
|
}
|
|
|
|
return uphill, downhill
|
|
}
|