// Package geo provides the geographic distance calculations used to decide // whether two pictures were taken at the same place. package geo import ( "errors" "math" "go-places-api/internal/domain" ) // EarthRadiusMeters is the mean radius of the WGS-84 ellipsoid. const EarthRadiusMeters = 6371008.8 var ErrInvalidCoordinate = errors.New("invalid coordinate") // DistanceMeters returns the great-circle distance between two points using the // haversine formula. It is accurate to roughly 0.5% which is far finer than the // radii this application compares against. func DistanceMeters(a, b domain.Coordinate) float64 { lat1 := degreesToRadians(a.Latitude) lat2 := degreesToRadians(b.Latitude) dLat := lat2 - lat1 dLon := degreesToRadians(b.Longitude - a.Longitude) sinLat := math.Sin(dLat / 2) sinLon := math.Sin(dLon / 2) h := sinLat*sinLat + math.Cos(lat1)*math.Cos(lat2)*sinLon*sinLon return 2 * EarthRadiusMeters * math.Asin(math.Sqrt(math.Min(1, h))) } // WithinRadius reports whether two points are no further apart than radiusMeters. func WithinRadius(a, b domain.Coordinate, radiusMeters float64) bool { return DistanceMeters(a, b) <= radiusMeters } // BoundingBox is a coarse lat/lon window that fully contains a circle of the // given radius around a centre. It exists to let the store pre-filter rows in // SQL before the exact haversine test is applied. type BoundingBox struct { MinLat float64 MaxLat float64 MinLon float64 MaxLon float64 } // Around returns the bounding box of a circle of radiusMeters around center. func Around(center domain.Coordinate, radiusMeters float64) BoundingBox { // Latitude degrees are a constant distance apart, so the latitude delta is // a simple division. Longitude degrees shrink towards the poles, so the // delta is scaled by 1/cos(latitude) and clamped near the poles. latDelta := radiusMeters / EarthRadiusMeters * 180 / math.Pi cos := math.Cos(degreesToRadians(center.Latitude)) var lonDelta float64 if math.Abs(cos) > 1e-9 { lonDelta = latDelta / math.Abs(cos) } else { // At the poles every longitude is the same meridian; use everything. lonDelta = 180 } // Clamp so the box stays within valid longitude range. if lonDelta > 180 { lonDelta = 180 } return BoundingBox{ MinLat: clamp(center.Latitude-latDelta, -90, 90), MaxLat: clamp(center.Latitude+latDelta, -90, 90), MinLon: clamp(center.Longitude-lonDelta, -180, 180), MaxLon: clamp(center.Longitude+lonDelta, -180, 180), } } // Contains reports whether the point falls inside the box. func (b BoundingBox) Contains(c domain.Coordinate) bool { return c.Latitude >= b.MinLat && c.Latitude <= b.MaxLat && c.Longitude >= b.MinLon && c.Longitude <= b.MaxLon } // Round rounds a distance to the nearest metre so that JSON output and equality // assertions are stable. func Round(meters float64) float64 { return math.Round(meters) } func degreesToRadians(deg float64) float64 { return deg * math.Pi / 180 } func clamp(v, lo, hi float64) float64 { return math.Max(lo, math.Min(hi, v)) }