merge trails function added (#627)
* merge trails function added * fix conflict resolve issues * avoid incorrect authorship when merging trail comments * require edit access for all selected trails before merge * fix likes checkbox binding in trail merge modal * improve error handling * require editable target and deletable sources for merge * avoid duplicate trail fetch during merge * prevent duplicate likes when merging trails * check photo download responses during trail merge * add merge completion toast notifications * cleanup * small fixes * move merge code to backend, option to merge single trail selection with similar one, automatic merge for integrations add as option, maintenance page to find and merge similar trails added * fix build error * fix * docu, beautifying, refactoring --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
18
db/util/distance.go
Normal file
18
db/util/distance.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package util
|
||||
|
||||
import "math"
|
||||
|
||||
func HaversineDistanceMeters(lat1 float64, lon1 float64, lat2 float64, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0
|
||||
|
||||
lat1Rad := lat1 * math.Pi / 180
|
||||
lat2Rad := lat2 * math.Pi / 180
|
||||
dLat := (lat2 - lat1) * math.Pi / 180
|
||||
dLon := (lon2 - lon1) * math.Pi / 180
|
||||
|
||||
sinLat := math.Sin(dLat / 2)
|
||||
sinLon := math.Sin(dLon / 2)
|
||||
|
||||
h := sinLat*sinLat + math.Cos(lat1Rad)*math.Cos(lat2Rad)*sinLon*sinLon
|
||||
return 2 * earthRadius * math.Atan2(math.Sqrt(h), math.Sqrt(1-h))
|
||||
}
|
||||
173
db/util/geometry.go
Normal file
173
db/util/geometry.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
)
|
||||
|
||||
type TrailGeometryMetrics struct {
|
||||
MeanDistanceMeters float64
|
||||
MaxDistanceMeters float64
|
||||
StartDistanceMeters float64
|
||||
EndDistanceMeters float64
|
||||
}
|
||||
|
||||
type trailPoint struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
}
|
||||
|
||||
func TrailCoordinates(app core.App, r *core.Record) ([][2]float64, error) {
|
||||
gpxPath := r.GetString("gpx")
|
||||
if gpxPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fsys, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
reader, err := fsys.GetReader(r.BaseFilesPath() + "/" + gpxPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
content := new(bytes.Buffer)
|
||||
if _, err := io.Copy(content, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gpxData, err := gpx.Parse(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
points := make([][2]float64, 0)
|
||||
for _, trk := range gpxData.Tracks {
|
||||
for _, seg := range trk.Segments {
|
||||
for _, pt := range seg.Points {
|
||||
points = append(points, [2]float64{pt.Latitude, pt.Longitude})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func TrailGeometrySimilarity(app core.App, a *core.Record, b *core.Record) (*TrailGeometryMetrics, error) {
|
||||
aCoords, err := TrailCoordinates(app, a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load source geometry: %w", err)
|
||||
}
|
||||
bCoords, err := TrailCoordinates(app, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load target geometry: %w", err)
|
||||
}
|
||||
|
||||
return CompareTrailCoordinates(aCoords, bCoords)
|
||||
}
|
||||
|
||||
func CompareTrailCoordinates(aCoords [][2]float64, bCoords [][2]float64) (*TrailGeometryMetrics, error) {
|
||||
if len(aCoords) < 2 || len(bCoords) < 2 {
|
||||
return nil, fmt.Errorf("missing geometry")
|
||||
}
|
||||
|
||||
a := resampleTrailCoordinates(aCoords, 64)
|
||||
b := resampleTrailCoordinates(bCoords, 64)
|
||||
if len(a) < 2 || len(b) < 2 {
|
||||
return nil, fmt.Errorf("missing geometry")
|
||||
}
|
||||
|
||||
return compareSampledTrails(a, b), nil
|
||||
}
|
||||
|
||||
func compareSampledTrails(a []trailPoint, b []trailPoint) *TrailGeometryMetrics {
|
||||
count := min(len(a), len(b))
|
||||
if count == 0 {
|
||||
return &TrailGeometryMetrics{}
|
||||
}
|
||||
|
||||
sum := 0.0
|
||||
maxDistance := 0.0
|
||||
for i := 0; i < count; i++ {
|
||||
distance := HaversineDistanceMeters(a[i].Lat, a[i].Lon, b[i].Lat, b[i].Lon)
|
||||
sum += distance
|
||||
if distance > maxDistance {
|
||||
maxDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return &TrailGeometryMetrics{
|
||||
MeanDistanceMeters: sum / float64(count),
|
||||
MaxDistanceMeters: maxDistance,
|
||||
StartDistanceMeters: HaversineDistanceMeters(a[0].Lat, a[0].Lon, b[0].Lat, b[0].Lon),
|
||||
EndDistanceMeters: HaversineDistanceMeters(a[count-1].Lat, a[count-1].Lon, b[count-1].Lat, b[count-1].Lon),
|
||||
}
|
||||
}
|
||||
|
||||
func resampleTrailCoordinates(coords [][2]float64, targetPoints int) []trailPoint {
|
||||
points := make([]trailPoint, 0, len(coords))
|
||||
for _, coord := range coords {
|
||||
points = append(points, trailPoint{Lat: coord[0], Lon: coord[1]})
|
||||
}
|
||||
|
||||
if len(points) <= 2 || targetPoints <= 2 {
|
||||
return points
|
||||
}
|
||||
|
||||
cumulative := make([]float64, len(points))
|
||||
total := 0.0
|
||||
for i := 1; i < len(points); i++ {
|
||||
total += HaversineDistanceMeters(points[i-1].Lat, points[i-1].Lon, points[i].Lat, points[i].Lon)
|
||||
cumulative[i] = total
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return []trailPoint{points[0], points[len(points)-1]}
|
||||
}
|
||||
|
||||
resampled := make([]trailPoint, 0, targetPoints)
|
||||
for i := 0; i < targetPoints; i++ {
|
||||
targetDistance := (float64(i) / float64(targetPoints-1)) * total
|
||||
resampled = append(resampled, interpolateTrailPoint(points, cumulative, targetDistance))
|
||||
}
|
||||
|
||||
return resampled
|
||||
}
|
||||
|
||||
func interpolateTrailPoint(points []trailPoint, cumulative []float64, targetDistance float64) trailPoint {
|
||||
if targetDistance <= 0 {
|
||||
return points[0]
|
||||
}
|
||||
lastIndex := len(points) - 1
|
||||
if targetDistance >= cumulative[lastIndex] {
|
||||
return points[lastIndex]
|
||||
}
|
||||
|
||||
for i := 1; i < len(points); i++ {
|
||||
if cumulative[i] < targetDistance {
|
||||
continue
|
||||
}
|
||||
|
||||
prevDistance := cumulative[i-1]
|
||||
nextDistance := cumulative[i]
|
||||
if nextDistance == prevDistance {
|
||||
return points[i]
|
||||
}
|
||||
|
||||
ratio := (targetDistance - prevDistance) / (nextDistance - prevDistance)
|
||||
return trailPoint{
|
||||
Lat: points[i-1].Lat + (points[i].Lat-points[i-1].Lat)*ratio,
|
||||
Lon: points[i-1].Lon + (points[i].Lon-points[i-1].Lon)*ratio,
|
||||
}
|
||||
}
|
||||
|
||||
return points[lastIndex]
|
||||
}
|
||||
130
db/util/trail_external_reference.go
Normal file
130
db/util/trail_external_reference.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func FindTrailByExternalReference(app core.App, provider string, externalID string) (*core.Record, error) {
|
||||
if provider == "" || externalID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
refs, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"provider={:provider} && external_id={:external_id}",
|
||||
"+created",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
},
|
||||
)
|
||||
if err != nil || len(refs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trailID := refs[0].GetString("trail")
|
||||
if trailID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return app.FindRecordById("trails", trailID)
|
||||
}
|
||||
|
||||
func EnsureTrailExternalReference(app core.App, trailID string, provider string, externalID string) error {
|
||||
if trailID == "" || provider == "" || externalID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
refs, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"provider={:provider} && external_id={:external_id}",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
if refs[0].GetString("trail") == trailID {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("trail external reference already exists for another trail")
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Load(map[string]any{
|
||||
"trail": trailID,
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
})
|
||||
|
||||
return app.Save(record)
|
||||
}
|
||||
|
||||
func ReassignTrailExternalReferences(app core.App, sourceTrailID string, targetTrailID string) error {
|
||||
if sourceTrailID == "" || targetTrailID == "" || sourceTrailID == targetTrailID {
|
||||
return nil
|
||||
}
|
||||
|
||||
refs, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"trail={:trail}",
|
||||
"",
|
||||
-1,
|
||||
0,
|
||||
dbx.Params{"trail": sourceTrailID},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
provider := ref.GetString("provider")
|
||||
externalID := ref.GetString("external_id")
|
||||
|
||||
existing, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"trail={:trail} && provider={:provider} && external_id={:external_id}",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{
|
||||
"trail": targetTrailID,
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(existing) > 0 {
|
||||
if err := app.Delete(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ref.Set("trail", targetTrailID)
|
||||
if err := app.Save(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user