mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 15:23:45 +00:00
types/geo: add geo.Point and its associated units (#16583)
Package geo provides functionality to represent and process geographical locations on a sphere. The main type, geo.Point, represents a pair of latitude and longitude coordinates. Updates tailscale/corp#29968 Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
parent
e7238efafa
commit
93511be044
6
types/geo/doc.go
Normal file
6
types/geo/doc.go
Normal file
@ -0,0 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package geo provides functionality to represent and process geographical
|
||||
// locations on a spherical Earth.
|
||||
package geo
|
279
types/geo/point.go
Normal file
279
types/geo/point.go
Normal file
@ -0,0 +1,279 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ErrBadPoint indicates that the point is malformed.
|
||||
var ErrBadPoint = errors.New("not a valid point")
|
||||
|
||||
// Point represents a pair of latitude and longitude coordinates.
|
||||
type Point struct {
|
||||
lat Degrees
|
||||
// lng180 is the longitude offset by +180° so the zero value is invalid
|
||||
// and +0+0/ is Point{lat: +0.0, lng180: +180.0}.
|
||||
lng180 Degrees
|
||||
}
|
||||
|
||||
// MakePoint returns a Point representing a given latitude and longitude on
|
||||
// a WGS 84 ellipsoid. The Coordinate Reference System is EPSG:4326.
|
||||
// Latitude is wrapped to [-90°, +90°] and longitude to (-180°, +180°].
|
||||
func MakePoint(latitude, longitude Degrees) Point {
|
||||
lat, lng := float64(latitude), float64(longitude)
|
||||
|
||||
switch {
|
||||
case math.IsNaN(lat) || math.IsInf(lat, 0):
|
||||
// don’t wrap
|
||||
case lat < -90 || lat > 90:
|
||||
// Latitude wraps by flipping the longitude
|
||||
lat = math.Mod(lat, 360.0)
|
||||
switch {
|
||||
case lat == 0.0:
|
||||
lat = 0.0 // -0.0 == 0.0, but -0° is not valid
|
||||
case lat < -270.0:
|
||||
lat = +360.0 + lat
|
||||
case lat < -90.0:
|
||||
lat = -180.0 - lat
|
||||
lng += 180.0
|
||||
case lat > +270.0:
|
||||
lat = -360.0 + lat
|
||||
case lat > +90.0:
|
||||
lat = +180.0 - lat
|
||||
lng += 180.0
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case lat == -90.0 || lat == +90.0:
|
||||
// By convention, the north and south poles have longitude 0°.
|
||||
lng = 0
|
||||
case math.IsNaN(lng) || math.IsInf(lng, 0):
|
||||
// don’t wrap
|
||||
case lng <= -180.0 || lng > 180.0:
|
||||
// Longitude wraps around normally
|
||||
lng = math.Mod(lng, 360.0)
|
||||
switch {
|
||||
case lng == 0.0:
|
||||
lng = 0.0 // -0.0 == 0.0, but -0° is not valid
|
||||
case lng <= -180.0:
|
||||
lng = +360.0 + lng
|
||||
case lng > +180.0:
|
||||
lng = -360.0 + lng
|
||||
}
|
||||
}
|
||||
|
||||
return Point{
|
||||
lat: Degrees(lat),
|
||||
lng180: Degrees(lng + 180.0),
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports if p is a valid point.
|
||||
func (p Point) Valid() bool {
|
||||
return !p.IsZero()
|
||||
}
|
||||
|
||||
// LatLng reports the latitude and longitude.
|
||||
func (p Point) LatLng() (lat, lng Degrees, err error) {
|
||||
if p.IsZero() {
|
||||
return 0 * Degree, 0 * Degree, ErrBadPoint
|
||||
}
|
||||
return p.lat, p.lng180 - 180.0*Degree, nil
|
||||
}
|
||||
|
||||
// LatLng reports the latitude and longitude in float64. If err is nil, then lat
|
||||
// and lng will never both be 0.0 to disambiguate between an empty struct and
|
||||
// Null Island (0° 0°).
|
||||
func (p Point) LatLngFloat64() (lat, lng float64, err error) {
|
||||
dlat, dlng, err := p.LatLng()
|
||||
if err != nil {
|
||||
return 0.0, 0.0, err
|
||||
}
|
||||
if dlat == 0.0 && dlng == 0.0 {
|
||||
// dlng must survive conversion to float32.
|
||||
dlng = math.SmallestNonzeroFloat32
|
||||
}
|
||||
return float64(dlat), float64(dlng), err
|
||||
}
|
||||
|
||||
// SphericalAngleTo returns the angular distance from p to q, calculated on a
|
||||
// spherical Earth.
|
||||
func (p Point) SphericalAngleTo(q Point) (Radians, error) {
|
||||
pLat, pLng, pErr := p.LatLng()
|
||||
qLat, qLng, qErr := q.LatLng()
|
||||
switch {
|
||||
case pErr != nil && qErr != nil:
|
||||
return 0.0, fmt.Errorf("spherical distance from %v to %v: %w", p, q, errors.Join(pErr, qErr))
|
||||
case pErr != nil:
|
||||
return 0.0, fmt.Errorf("spherical distance from %v: %w", p, pErr)
|
||||
case qErr != nil:
|
||||
return 0.0, fmt.Errorf("spherical distance to %v: %w", q, qErr)
|
||||
}
|
||||
// The spherical law of cosines is accurate enough for close points when
|
||||
// using float64.
|
||||
//
|
||||
// The haversine formula is an alternative, but it is poorly behaved
|
||||
// when points are on opposite sides of the sphere.
|
||||
rLat, rLng := float64(pLat.Radians()), float64(pLng.Radians())
|
||||
sLat, sLng := float64(qLat.Radians()), float64(qLng.Radians())
|
||||
cosA := math.Sin(rLat)*math.Sin(sLat) +
|
||||
math.Cos(rLat)*math.Cos(sLat)*math.Cos(rLng-sLng)
|
||||
return Radians(math.Acos(cosA)), nil
|
||||
}
|
||||
|
||||
// DistanceTo reports the great-circle distance between p and q, in meters.
|
||||
func (p Point) DistanceTo(q Point) (Distance, error) {
|
||||
r, err := p.SphericalAngleTo(q)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return DistanceOnEarth(r.Turns()), nil
|
||||
}
|
||||
|
||||
// String returns a space-separated pair of latitude and longitude, in decimal
|
||||
// degrees. Positive latitudes are in the northern hemisphere, and positive
|
||||
// longitudes are east of the prime meridian. If p was not initialized, this
|
||||
// will return "nowhere".
|
||||
func (p Point) String() string {
|
||||
lat, lng, err := p.LatLng()
|
||||
if err != nil {
|
||||
if err == ErrBadPoint {
|
||||
return "nowhere"
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return lat.String() + " " + lng.String()
|
||||
}
|
||||
|
||||
// AppendBinary implements [encoding.BinaryAppender]. The output consists of two
|
||||
// float32s in big-endian byte order: latitude and longitude offset by 180°.
|
||||
// If p is not a valid, the output will be an 8-byte zero value.
|
||||
func (p Point) AppendBinary(b []byte) ([]byte, error) {
|
||||
end := binary.BigEndian
|
||||
b = end.AppendUint32(b, math.Float32bits(float32(p.lat)))
|
||||
b = end.AppendUint32(b, math.Float32bits(float32(p.lng180)))
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// MarshalBinary implements [encoding.BinaryMarshaller]. The output matches that
|
||||
// of calling [Point.AppendBinary].
|
||||
func (p Point) MarshalBinary() ([]byte, error) {
|
||||
var b [8]byte
|
||||
return p.AppendBinary(b[:0])
|
||||
}
|
||||
|
||||
// UnmarshalBinary implements [encoding.BinaryUnmarshaler]. It expects input
|
||||
// that was formatted by [Point.AppendBinary]: in big-endian byte order, a
|
||||
// float32 representing latitude followed by a float32 representing longitude
|
||||
// offset by 180°. If latitude and longitude fall outside valid ranges, then
|
||||
// an error is returned.
|
||||
func (p *Point) UnmarshalBinary(data []byte) error {
|
||||
if len(data) < 8 { // Two uint32s are 8 bytes long
|
||||
return fmt.Errorf("%w: not enough data: %q", ErrBadPoint, data)
|
||||
}
|
||||
|
||||
end := binary.BigEndian
|
||||
lat := Degrees(math.Float32frombits(end.Uint32(data[0:])))
|
||||
if lat < -90*Degree || lat > 90*Degree {
|
||||
return fmt.Errorf("%w: latitude outside [-90°, +90°]: %s", ErrBadPoint, lat)
|
||||
}
|
||||
lng180 := Degrees(math.Float32frombits(end.Uint32(data[4:])))
|
||||
if lng180 != 0 && (lng180 < 0*Degree || lng180 > 360*Degree) {
|
||||
// lng180 == 0 is OK: the zero value represents invalid points.
|
||||
lng := lng180 - 180*Degree
|
||||
return fmt.Errorf("%w: longitude outside (-180°, +180°]: %s", ErrBadPoint, lng)
|
||||
}
|
||||
|
||||
p.lat = lat
|
||||
p.lng180 = lng180
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendText implements [encoding.TextAppender]. The output is a point
|
||||
// formatted as OGC Well-Known Text, as "POINT (longitude latitude)" where
|
||||
// longitude and latitude are in decimal degrees. If p is not valid, the output
|
||||
// will be "POINT EMPTY".
|
||||
func (p Point) AppendText(b []byte) ([]byte, error) {
|
||||
if p.IsZero() {
|
||||
b = append(b, []byte("POINT EMPTY")...)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
lat, lng, err := p.LatLng()
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
|
||||
b = append(b, []byte("POINT (")...)
|
||||
b = strconv.AppendFloat(b, float64(lng), 'f', -1, 64)
|
||||
b = append(b, ' ')
|
||||
b = strconv.AppendFloat(b, float64(lat), 'f', -1, 64)
|
||||
b = append(b, ')')
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// MarshalText implements [encoding.TextMarshaller]. The output matches that
|
||||
// of calling [Point.AppendText].
|
||||
func (p Point) MarshalText() ([]byte, error) {
|
||||
var b [8]byte
|
||||
return p.AppendText(b[:0])
|
||||
}
|
||||
|
||||
// MarshalUint64 produces the same output as MashalBinary, encoded in a uint64.
|
||||
func (p Point) MarshalUint64() (uint64, error) {
|
||||
b, err := p.MarshalBinary()
|
||||
return binary.NativeEndian.Uint64(b), err
|
||||
}
|
||||
|
||||
// UnmarshalUint64 expects input formatted by MarshalUint64.
|
||||
func (p *Point) UnmarshalUint64(v uint64) error {
|
||||
b := binary.NativeEndian.AppendUint64(nil, v)
|
||||
return p.UnmarshalBinary(b)
|
||||
}
|
||||
|
||||
// IsZero reports if p is the zero value.
|
||||
func (p Point) IsZero() bool {
|
||||
return p == Point{}
|
||||
}
|
||||
|
||||
// EqualApprox reports if p and q are approximately equal: that is the absolute
|
||||
// difference of both latitude and longitude are less than tol. If tol is
|
||||
// negative, then tol defaults to a reasonably small number (10⁻⁵). If tol is
|
||||
// zero, then p and q must be exactly equal.
|
||||
func (p Point) EqualApprox(q Point, tol float64) bool {
|
||||
if tol == 0 {
|
||||
return p == q
|
||||
}
|
||||
|
||||
if p.IsZero() && q.IsZero() {
|
||||
return true
|
||||
} else if p.IsZero() || q.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
plat, plng, err := p.LatLng()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
qlat, qlng, err := q.LatLng()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if tol < 0 {
|
||||
tol = 1e-5
|
||||
}
|
||||
|
||||
dlat := float64(plat) - float64(qlat)
|
||||
dlng := float64(plng) - float64(qlng)
|
||||
return ((dlat < 0 && -dlat < tol) || (dlat >= 0 && dlat < tol)) &&
|
||||
((dlng < 0 && -dlng < tol) || (dlng >= 0 && dlng < tol))
|
||||
}
|
541
types/geo/point_test.go
Normal file
541
types/geo/point_test.go
Normal file
@ -0,0 +1,541 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"testing/quick"
|
||||
|
||||
"tailscale.com/types/geo"
|
||||
)
|
||||
|
||||
func TestPointZero(t *testing.T) {
|
||||
var zero geo.Point
|
||||
|
||||
if got := zero.IsZero(); !got {
|
||||
t.Errorf("IsZero() got %t", got)
|
||||
}
|
||||
|
||||
if got := zero.Valid(); got {
|
||||
t.Errorf("Valid() got %t", got)
|
||||
}
|
||||
|
||||
wantErr := geo.ErrBadPoint.Error()
|
||||
if _, _, err := zero.LatLng(); err.Error() != wantErr {
|
||||
t.Errorf("LatLng() err %q, want %q", err, wantErr)
|
||||
}
|
||||
|
||||
wantStr := "nowhere"
|
||||
if got := zero.String(); got != wantStr {
|
||||
t.Errorf("String() got %q, want %q", got, wantStr)
|
||||
}
|
||||
|
||||
wantB := []byte{0, 0, 0, 0, 0, 0, 0, 0}
|
||||
if b, err := zero.MarshalBinary(); err != nil {
|
||||
t.Errorf("MarshalBinary() err %q, want nil", err)
|
||||
} else if string(b) != string(wantB) {
|
||||
t.Errorf("MarshalBinary got %q, want %q", b, wantB)
|
||||
}
|
||||
|
||||
wantI := uint64(0x00000000)
|
||||
if i, err := zero.MarshalUint64(); err != nil {
|
||||
t.Errorf("MarshalUint64() err %q, want nil", err)
|
||||
} else if i != wantI {
|
||||
t.Errorf("MarshalUint64 got %v, want %v", i, wantI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoint(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
lat geo.Degrees
|
||||
lng geo.Degrees
|
||||
wantLat geo.Degrees
|
||||
wantLng geo.Degrees
|
||||
wantString string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "null-island",
|
||||
lat: +0.0,
|
||||
lng: +0.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+0° +0°",
|
||||
wantText: "POINT (0 0)",
|
||||
},
|
||||
{
|
||||
name: "north-pole",
|
||||
lat: +90.0,
|
||||
lng: +0.0,
|
||||
wantLat: +90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+90° +0°",
|
||||
wantText: "POINT (0 90)",
|
||||
},
|
||||
{
|
||||
name: "south-pole",
|
||||
lat: -90.0,
|
||||
lng: +0.0,
|
||||
wantLat: -90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "-90° +0°",
|
||||
wantText: "POINT (0 -90)",
|
||||
},
|
||||
{
|
||||
name: "north-pole-weird-longitude",
|
||||
lat: +90.0,
|
||||
lng: +1.0,
|
||||
wantLat: +90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+90° +0°",
|
||||
wantText: "POINT (0 90)",
|
||||
},
|
||||
{
|
||||
name: "south-pole-weird-longitude",
|
||||
lat: -90.0,
|
||||
lng: +1.0,
|
||||
wantLat: -90.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "-90° +0°",
|
||||
wantText: "POINT (0 -90)",
|
||||
},
|
||||
{
|
||||
name: "almost-north",
|
||||
lat: +89.0,
|
||||
lng: +0.0,
|
||||
wantLat: +89.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "+89° +0°",
|
||||
wantText: "POINT (0 89)",
|
||||
},
|
||||
{
|
||||
name: "past-north",
|
||||
lat: +91.0,
|
||||
lng: +0.0,
|
||||
wantLat: +89.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+89° +180°",
|
||||
wantText: "POINT (180 89)",
|
||||
},
|
||||
{
|
||||
name: "almost-south",
|
||||
lat: -89.0,
|
||||
lng: +0.0,
|
||||
wantLat: -89.0,
|
||||
wantLng: +0.0,
|
||||
wantString: "-89° +0°",
|
||||
wantText: "POINT (0 -89)",
|
||||
},
|
||||
{
|
||||
name: "past-south",
|
||||
lat: -91.0,
|
||||
lng: +0.0,
|
||||
wantLat: -89.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "-89° +180°",
|
||||
wantText: "POINT (180 -89)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-north",
|
||||
lat: +180.0,
|
||||
lng: +0.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-south",
|
||||
lat: -180.0,
|
||||
lng: +0.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-north",
|
||||
lat: +179.0,
|
||||
lng: +0.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+1° +180°",
|
||||
wantText: "POINT (180 1)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-north",
|
||||
lat: +181.0,
|
||||
lng: +0.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "-1° +180°",
|
||||
wantText: "POINT (180 -1)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-south",
|
||||
lat: -179.0,
|
||||
lng: +0.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "-1° +180°",
|
||||
wantText: "POINT (180 -1)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-south",
|
||||
lat: -181.0,
|
||||
lng: +0.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+1° +180°",
|
||||
wantText: "POINT (180 1)",
|
||||
},
|
||||
{
|
||||
name: "circumnavigate-north",
|
||||
lat: +360.0,
|
||||
lng: +1.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+0° +1°",
|
||||
wantText: "POINT (1 0)",
|
||||
},
|
||||
{
|
||||
name: "circumnavigate-south",
|
||||
lat: -360.0,
|
||||
lng: +1.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+0° +1°",
|
||||
wantText: "POINT (1 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-circumnavigate-north",
|
||||
lat: +359.0,
|
||||
lng: +1.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "-1° +1°",
|
||||
wantText: "POINT (1 -1)",
|
||||
},
|
||||
{
|
||||
name: "past-circumnavigate-north",
|
||||
lat: +361.0,
|
||||
lng: +1.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+1° +1°",
|
||||
wantText: "POINT (1 1)",
|
||||
},
|
||||
{
|
||||
name: "almost-circumnavigate-south",
|
||||
lat: -359.0,
|
||||
lng: +1.0,
|
||||
wantLat: +1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "+1° +1°",
|
||||
wantText: "POINT (1 1)",
|
||||
},
|
||||
{
|
||||
name: "past-circumnavigate-south",
|
||||
lat: -361.0,
|
||||
lng: +1.0,
|
||||
wantLat: -1.0,
|
||||
wantLng: +1.0,
|
||||
wantString: "-1° +1°",
|
||||
wantText: "POINT (1 -1)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-east",
|
||||
lat: +0.0,
|
||||
lng: +180.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "antimeridian-west",
|
||||
lat: +0.0,
|
||||
lng: -180.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +180.0,
|
||||
wantString: "+0° +180°",
|
||||
wantText: "POINT (180 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-east",
|
||||
lat: +0.0,
|
||||
lng: +179.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +179.0,
|
||||
wantString: "+0° +179°",
|
||||
wantText: "POINT (179 0)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-east",
|
||||
lat: +0.0,
|
||||
lng: +181.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: -179.0,
|
||||
wantString: "+0° -179°",
|
||||
wantText: "POINT (-179 0)",
|
||||
},
|
||||
{
|
||||
name: "almost-antimeridian-west",
|
||||
lat: +0.0,
|
||||
lng: -179.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: -179.0,
|
||||
wantString: "+0° -179°",
|
||||
wantText: "POINT (-179 0)",
|
||||
},
|
||||
{
|
||||
name: "past-antimeridian-west",
|
||||
lat: +0.0,
|
||||
lng: -181.0,
|
||||
wantLat: +0.0,
|
||||
wantLng: +179.0,
|
||||
wantString: "+0° +179°",
|
||||
wantText: "POINT (179 0)",
|
||||
},
|
||||
{
|
||||
name: "montreal",
|
||||
lat: +45.508888,
|
||||
lng: -73.561668,
|
||||
wantLat: +45.508888,
|
||||
wantLng: -73.561668,
|
||||
wantString: "+45.508888° -73.561668°",
|
||||
wantText: "POINT (-73.561668 45.508888)",
|
||||
},
|
||||
{
|
||||
name: "canada",
|
||||
lat: 57.550480044655636,
|
||||
lng: -98.41680517868062,
|
||||
wantLat: 57.550480044655636,
|
||||
wantLng: -98.41680517868062,
|
||||
wantString: "+57.550480044655636° -98.41680517868062°",
|
||||
wantText: "POINT (-98.41680517868062 57.550480044655636)",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := geo.MakePoint(tt.lat, tt.lng)
|
||||
|
||||
lat, lng, err := p.LatLng()
|
||||
if !approx(lat, tt.wantLat) {
|
||||
t.Errorf("MakePoint: lat %v, want %v", lat, tt.wantLat)
|
||||
}
|
||||
if !approx(lng, tt.wantLng) {
|
||||
t.Errorf("MakePoint: lng %v, want %v", lng, tt.wantLng)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("LatLng: err %q, expected nil", err)
|
||||
}
|
||||
|
||||
if got := p.String(); got != tt.wantString {
|
||||
t.Errorf("String: got %q, wantString %q", got, tt.wantString)
|
||||
}
|
||||
|
||||
txt, err := p.MarshalText()
|
||||
if err != nil {
|
||||
t.Errorf("Text: err %q, expected nil", err)
|
||||
} else if string(txt) != tt.wantText {
|
||||
t.Errorf("Text: got %q, wantText %q", txt, tt.wantText)
|
||||
}
|
||||
|
||||
b, err := p.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalBinary: err %q, expected nil", err)
|
||||
}
|
||||
|
||||
var q geo.Point
|
||||
if err := q.UnmarshalBinary(b); err != nil {
|
||||
t.Fatalf("UnmarshalBinary: err %q, expected nil", err)
|
||||
}
|
||||
if !q.EqualApprox(p, -1) {
|
||||
t.Errorf("UnmarshalBinary: roundtrip failed: %#v != %#v", q, p)
|
||||
}
|
||||
|
||||
i, err := p.MarshalUint64()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalUint64: err %q, expected nil", err)
|
||||
}
|
||||
|
||||
var r geo.Point
|
||||
if err := r.UnmarshalUint64(i); err != nil {
|
||||
t.Fatalf("UnmarshalUint64: err %r, expected nil", err)
|
||||
}
|
||||
if !q.EqualApprox(r, -1) {
|
||||
t.Errorf("UnmarshalUint64: roundtrip failed: %#v != %#v", r, p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointMarshalBinary(t *testing.T) {
|
||||
roundtrip := func(p geo.Point) error {
|
||||
b, err := p.MarshalBinary()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %v", err)
|
||||
}
|
||||
var q geo.Point
|
||||
if err := q.UnmarshalBinary(b); err != nil {
|
||||
return fmt.Errorf("unmarshal: %v", err)
|
||||
}
|
||||
if q != p {
|
||||
return fmt.Errorf("%#v != %#v", q, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("nowhere", func(t *testing.T) {
|
||||
var nowhere geo.Point
|
||||
if err := roundtrip(nowhere); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quick-check", func(t *testing.T) {
|
||||
f := func(lat geo.Degrees, lng geo.Degrees) (ok bool) {
|
||||
pt := geo.MakePoint(lat, lng)
|
||||
if err := roundtrip(pt); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
return !t.Failed()
|
||||
}
|
||||
if err := quick.Check(f, nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPointMarshalUint64(t *testing.T) {
|
||||
t.Skip("skip")
|
||||
roundtrip := func(p geo.Point) error {
|
||||
i, err := p.MarshalUint64()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %v", err)
|
||||
}
|
||||
var q geo.Point
|
||||
if err := q.UnmarshalUint64(i); err != nil {
|
||||
return fmt.Errorf("unmarshal: %v", err)
|
||||
}
|
||||
if q != p {
|
||||
return fmt.Errorf("%#v != %#v", q, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("nowhere", func(t *testing.T) {
|
||||
var nowhere geo.Point
|
||||
if err := roundtrip(nowhere); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quick-check", func(t *testing.T) {
|
||||
f := func(lat geo.Degrees, lng geo.Degrees) (ok bool) {
|
||||
if err := roundtrip(geo.MakePoint(lat, lng)); err != nil {
|
||||
t.Errorf("roundtrip: %v", err)
|
||||
}
|
||||
return !t.Failed()
|
||||
}
|
||||
if err := quick.Check(f, nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPointSphericalAngleTo(t *testing.T) {
|
||||
const earthRadius = 6371.000 // volumetric mean radius (km)
|
||||
const kmToRad = 1 / earthRadius
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
x geo.Point
|
||||
y geo.Point
|
||||
want geo.Radians
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "same-point-null-island",
|
||||
x: geo.MakePoint(0, 0),
|
||||
y: geo.MakePoint(0, 0),
|
||||
want: 0.0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "same-point-north-pole",
|
||||
x: geo.MakePoint(+90, 0),
|
||||
y: geo.MakePoint(+90, +90),
|
||||
want: 0.0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "same-point-south-pole",
|
||||
x: geo.MakePoint(-90, 0),
|
||||
y: geo.MakePoint(-90, -90),
|
||||
want: 0.0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "north-pole-to-south-pole",
|
||||
x: geo.MakePoint(+90, 0),
|
||||
y: geo.MakePoint(-90, -90),
|
||||
want: math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "toronto-to-montreal",
|
||||
x: geo.MakePoint(+43.6532, -79.3832),
|
||||
y: geo.MakePoint(+45.5019, -73.5674),
|
||||
want: 504.26 * kmToRad * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "sydney-to-san-francisco",
|
||||
x: geo.MakePoint(-33.8727, +151.2057),
|
||||
y: geo.MakePoint(+37.7749, -122.4194),
|
||||
want: 11948.18 * kmToRad * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "new-york-to-paris",
|
||||
x: geo.MakePoint(+40.7128, -74.0060),
|
||||
y: geo.MakePoint(+48.8575, +2.3514),
|
||||
want: 5837.15 * kmToRad * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "seattle-to-tokyo",
|
||||
x: geo.MakePoint(+47.6061, -122.3328),
|
||||
y: geo.MakePoint(+35.6764, +139.6500),
|
||||
want: 7700.00 * kmToRad * geo.Radian,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.x.SphericalAngleTo(tt.y)
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Fatalf("err %q, expected nil", err)
|
||||
}
|
||||
if tt.wantErr != "" && (err == nil || err.Error() != tt.wantErr) {
|
||||
t.Fatalf("err %q, expected %q", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
return
|
||||
}
|
||||
|
||||
if !approx(got, tt.want) {
|
||||
t.Errorf("x to y: got %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
// Distance should be commutative
|
||||
got, err = tt.y.SphericalAngleTo(tt.x)
|
||||
if err != nil {
|
||||
t.Fatalf("err %q, expected nil", err)
|
||||
}
|
||||
if !approx(got, tt.want) {
|
||||
t.Errorf("y to x: got %v, want %v", got, tt.want)
|
||||
}
|
||||
t.Logf("x to y: %v km", got/kmToRad)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func approx[T ~float64](x, y T) bool {
|
||||
return math.Abs(float64(x)-float64(y)) <= 1e-5
|
||||
}
|
106
types/geo/quantize.go
Normal file
106
types/geo/quantize.go
Normal file
@ -0,0 +1,106 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MinSeparation is the minimum separation between two points after quantizing.
|
||||
// [Point.Quantize] guarantees that two points will either be snapped to exactly
|
||||
// the same point, which conflates multiple positions together, or that the two
|
||||
// points will be far enough apart that successfully performing most reverse
|
||||
// lookups would be highly improbable.
|
||||
const MinSeparation = 50_000 * Meter
|
||||
|
||||
// Latitude
|
||||
var (
|
||||
// numSepsEquatorToPole is the number of separations between a point on
|
||||
// the equator to a point on a pole, that satisfies [minPointSep]. In
|
||||
// other words, the number of separations between 0° and +90° degrees
|
||||
// latitude.
|
||||
numSepsEquatorToPole = int(math.Floor(float64(
|
||||
earthPolarCircumference / MinSeparation / 4)))
|
||||
|
||||
// latSep is the number of degrees between two adjacent latitudinal
|
||||
// points. In other words, the next point going straight north of
|
||||
// 0° would be latSep°.
|
||||
latSep = Degrees(90.0 / float64(numSepsEquatorToPole))
|
||||
)
|
||||
|
||||
// snapToLat returns the number of the nearest latitudinal separation to
|
||||
// lat. A positive result is north of the equator, a negative result is south,
|
||||
// and zero is the equator itself. For example, a result of -1 would mean a
|
||||
// point that is [latSep] south of the equator.
|
||||
func snapToLat(lat Degrees) int {
|
||||
return int(math.Round(float64(lat / latSep)))
|
||||
}
|
||||
|
||||
// lngSep is a lookup table for the number of degrees between two adjacent
|
||||
// longitudinal separations. where the index corresponds to the absolute value
|
||||
// of the latitude separation. The first value corresponds to the equator and
|
||||
// the last value corresponds to the separation before the pole. There is no
|
||||
// value for the pole itself, because longitude has no meaning there.
|
||||
//
|
||||
// [lngSep] is calculated on init, which is so quick and will be used so often
|
||||
// that the startup cost is negligible.
|
||||
var lngSep = sync.OnceValue(func() []Degrees {
|
||||
lut := make([]Degrees, numSepsEquatorToPole)
|
||||
|
||||
// i ranges from the equator to a pole
|
||||
for i := range len(lut) {
|
||||
// lat ranges from [0°, 90°], because the southern hemisphere is
|
||||
// a reflection of the northern one.
|
||||
lat := Degrees(i) * latSep
|
||||
ratio := math.Cos(float64(lat.Radians()))
|
||||
circ := Distance(ratio) * earthEquatorialCircumference
|
||||
num := int(math.Floor(float64(circ / MinSeparation)))
|
||||
// We define lut[0] as 0°, lut[len(lut)] to be the north pole,
|
||||
// which means -lut[len(lut)] is the south pole.
|
||||
lut[i] = Degrees(360.0 / float64(num))
|
||||
}
|
||||
return lut
|
||||
})
|
||||
|
||||
// snapToLatLng returns the number of the nearest latitudinal separation to lat,
|
||||
// and the nearest longitudinal separation to lng.
|
||||
func snapToLatLng(lat, lng Degrees) (Degrees, Degrees) {
|
||||
latN := snapToLat(lat)
|
||||
|
||||
// absolute index into lngSep
|
||||
n := latN
|
||||
if n < 0 {
|
||||
n = -latN
|
||||
}
|
||||
|
||||
lngSep := lngSep()
|
||||
if n < len(lngSep) {
|
||||
sep := lngSep[n]
|
||||
lngN := int(math.Round(float64(lng / sep)))
|
||||
return Degrees(latN) * latSep, Degrees(lngN) * sep
|
||||
}
|
||||
if latN < 0 { // south pole
|
||||
return -90 * Degree, 0 * Degree
|
||||
} else { // north pole
|
||||
return +90 * Degree, 0 * Degree
|
||||
}
|
||||
}
|
||||
|
||||
// Quantize returns a new [Point] after throwing away enough location data in p
|
||||
// so that it would be difficult to distinguish a node among all the other nodes
|
||||
// in its general vicinity. One caveat is that if there’s only one point in an
|
||||
// obscure location, someone could triangulate the node using additional data.
|
||||
//
|
||||
// This method is stable: given the same p, it will always return the same
|
||||
// result. It is equivalent to snapping to points on Earth that are at least
|
||||
// [MinSeparation] apart.
|
||||
func (p Point) Quantize() Point {
|
||||
if p.IsZero() {
|
||||
return p
|
||||
}
|
||||
|
||||
lat, lng := snapToLatLng(p.lat, p.lng180-180)
|
||||
return MakePoint(lat, lng)
|
||||
}
|
130
types/geo/quantize_test.go
Normal file
130
types/geo/quantize_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/quick"
|
||||
|
||||
"tailscale.com/types/geo"
|
||||
)
|
||||
|
||||
func TestPointAnonymize(t *testing.T) {
|
||||
t.Run("nowhere", func(t *testing.T) {
|
||||
var zero geo.Point
|
||||
p := zero.Quantize()
|
||||
want := zero.Valid()
|
||||
if got := p.Valid(); got != want {
|
||||
t.Fatalf("zero.Valid %t, want %t", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("separation", func(t *testing.T) {
|
||||
// Walk from the south pole to the north pole and check that each
|
||||
// point on the latitude is approximately MinSeparation apart.
|
||||
const southPole = -90 * geo.Degree
|
||||
const northPole = 90 * geo.Degree
|
||||
const dateLine = 180 * geo.Degree
|
||||
|
||||
llat := southPole
|
||||
for lat := llat; lat <= northPole; lat += 0x1p-4 {
|
||||
last := geo.MakePoint(llat, 0)
|
||||
cur := geo.MakePoint(lat, 0)
|
||||
anon := cur.Quantize()
|
||||
switch l, g, err := anon.LatLng(); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case lat == southPole:
|
||||
// initialize llng, to the first snapped longitude
|
||||
llat = l
|
||||
goto Lng
|
||||
case g != 0:
|
||||
t.Fatalf("%v is west or east of %v", anon, last)
|
||||
case l < llat:
|
||||
t.Fatalf("%v is south of %v", anon, last)
|
||||
case l == llat:
|
||||
continue
|
||||
case l > llat:
|
||||
switch dist, err := last.DistanceTo(anon); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case dist == 0.0:
|
||||
continue
|
||||
case dist < geo.MinSeparation:
|
||||
t.Logf("lat=%v last=%v cur=%v anon=%v", lat, last, cur, anon)
|
||||
t.Fatalf("%v is too close to %v", anon, last)
|
||||
default:
|
||||
llat = l
|
||||
}
|
||||
}
|
||||
|
||||
Lng:
|
||||
llng := dateLine
|
||||
for lng := llng; lng <= dateLine && lng >= -dateLine; lng -= 0x1p-3 {
|
||||
last := geo.MakePoint(llat, llng)
|
||||
cur := geo.MakePoint(lat, lng)
|
||||
anon := cur.Quantize()
|
||||
switch l, g, err := anon.LatLng(); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case lng == dateLine:
|
||||
// initialize llng, to the first snapped longitude
|
||||
llng = g
|
||||
continue
|
||||
case l != llat:
|
||||
t.Fatalf("%v is north or south of %v", anon, last)
|
||||
case g != llng:
|
||||
const tolerance = geo.MinSeparation * 0x1p-9
|
||||
switch dist, err := last.DistanceTo(anon); {
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case dist < tolerance:
|
||||
continue
|
||||
case dist < (geo.MinSeparation - tolerance):
|
||||
t.Logf("lat=%v lng=%v last=%v cur=%v anon=%v", lat, lng, last, cur, anon)
|
||||
t.Fatalf("%v is too close to %v: %v", anon, last, dist)
|
||||
default:
|
||||
llng = g
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
if llat == southPole {
|
||||
t.Fatal("llat never incremented")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quick-check", func(t *testing.T) {
|
||||
f := func(lat, lng geo.Degrees) bool {
|
||||
p := geo.MakePoint(lat, lng)
|
||||
q := p.Quantize()
|
||||
t.Logf("quantize %v = %v", p, q)
|
||||
|
||||
lat, lng, err := q.LatLng()
|
||||
if err != nil {
|
||||
t.Errorf("err %v, want nil", err)
|
||||
return !t.Failed()
|
||||
}
|
||||
|
||||
if lat < -90*geo.Degree || lat > 90*geo.Degree {
|
||||
t.Errorf("lat outside [-90°, +90°]: %v", lat)
|
||||
}
|
||||
if lng < -180*geo.Degree || lng > 180*geo.Degree {
|
||||
t.Errorf("lng outside [-180°, +180°], %v", lng)
|
||||
}
|
||||
|
||||
if dist, err := p.DistanceTo(q); err != nil {
|
||||
t.Error(err)
|
||||
} else if dist > (geo.MinSeparation * 2) {
|
||||
t.Errorf("moved too far: %v", dist)
|
||||
}
|
||||
|
||||
return !t.Failed()
|
||||
}
|
||||
if err := quick.Check(f, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
191
types/geo/units.go
Normal file
191
types/geo/units.go
Normal file
@ -0,0 +1,191 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
Degree Degrees = 1
|
||||
Radian Radians = 1
|
||||
Turn Turns = 1
|
||||
Meter Distance = 1
|
||||
)
|
||||
|
||||
// Degrees represents a latitude or longitude, in decimal degrees.
|
||||
type Degrees float64
|
||||
|
||||
// ParseDegrees parses s as decimal degrees.
|
||||
func ParseDegrees(s string) (Degrees, error) {
|
||||
s = strings.TrimSuffix(s, "°")
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return Degrees(f), err
|
||||
}
|
||||
|
||||
// MustParseDegrees parses s as decimal degrees, but panics on error.
|
||||
func MustParseDegrees(s string) Degrees {
|
||||
d, err := ParseDegrees(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// String implements the [Stringer] interface. The output is formatted in
|
||||
// decimal degrees, prefixed by either the appropriate + or - sign, and suffixed
|
||||
// by a ° degree symbol.
|
||||
func (d Degrees) String() string {
|
||||
b, _ := d.AppendText(nil)
|
||||
b = append(b, []byte("°")...)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// AppendText implements [encoding.TextAppender]. The output is formatted in
|
||||
// decimal degrees, prefixed by either the appropriate + or - sign.
|
||||
func (d Degrees) AppendText(b []byte) ([]byte, error) {
|
||||
b = d.AppendZeroPaddedText(b, 0)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// AppendZeroPaddedText appends d formatted as decimal degrees to b. The number of
|
||||
// integer digits will be zero-padded to nint.
|
||||
func (d Degrees) AppendZeroPaddedText(b []byte, nint int) []byte {
|
||||
n := float64(d)
|
||||
|
||||
if math.IsInf(n, 0) || math.IsNaN(n) {
|
||||
return strconv.AppendFloat(b, n, 'f', -1, 64)
|
||||
}
|
||||
|
||||
sign := byte('+')
|
||||
if math.Signbit(n) {
|
||||
sign = '-'
|
||||
n = -n
|
||||
}
|
||||
b = append(b, sign)
|
||||
|
||||
pad := nint - 1
|
||||
for nn := n / 10; nn >= 1 && pad > 0; nn /= 10 {
|
||||
pad--
|
||||
}
|
||||
for range pad {
|
||||
b = append(b, '0')
|
||||
}
|
||||
return strconv.AppendFloat(b, n, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Radians converts d into radians.
|
||||
func (d Degrees) Radians() Radians {
|
||||
return Radians(d * math.Pi / 180.0)
|
||||
}
|
||||
|
||||
// Turns converts d into a number of turns.
|
||||
func (d Degrees) Turns() Turns {
|
||||
return Turns(d / 360.0)
|
||||
}
|
||||
|
||||
// Radians represents a latitude or longitude, in radians.
|
||||
type Radians float64
|
||||
|
||||
// ParseRadians parses s as radians.
|
||||
func ParseRadians(s string) (Radians, error) {
|
||||
s = strings.TrimSuffix(s, "rad")
|
||||
s = strings.TrimRightFunc(s, unicode.IsSpace)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return Radians(f), err
|
||||
}
|
||||
|
||||
// MustParseRadians parses s as radians, but panics on error.
|
||||
func MustParseRadians(s string) Radians {
|
||||
r, err := ParseRadians(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// String implements the [Stringer] interface.
|
||||
func (r Radians) String() string {
|
||||
return strconv.FormatFloat(float64(r), 'f', -1, 64) + " rad"
|
||||
}
|
||||
|
||||
// Degrees converts r into decimal degrees.
|
||||
func (r Radians) Degrees() Degrees {
|
||||
return Degrees(r * 180.0 / math.Pi)
|
||||
}
|
||||
|
||||
// Turns converts r into a number of turns.
|
||||
func (r Radians) Turns() Turns {
|
||||
return Turns(r / 2 / math.Pi)
|
||||
}
|
||||
|
||||
// Turns represents a number of complete revolutions around a sphere.
|
||||
type Turns float64
|
||||
|
||||
// String implements the [Stringer] interface.
|
||||
func (o Turns) String() string {
|
||||
return strconv.FormatFloat(float64(o), 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Degrees converts t into decimal degrees.
|
||||
func (o Turns) Degrees() Degrees {
|
||||
return Degrees(o * 360.0)
|
||||
}
|
||||
|
||||
// Radians converts t into radians.
|
||||
func (o Turns) Radians() Radians {
|
||||
return Radians(o * 2 * math.Pi)
|
||||
}
|
||||
|
||||
// Distance represents a great-circle distance in meters.
|
||||
type Distance float64
|
||||
|
||||
// ParseDistance parses s as distance in meters.
|
||||
func ParseDistance(s string) (Distance, error) {
|
||||
s = strings.TrimSuffix(s, "m")
|
||||
s = strings.TrimRightFunc(s, unicode.IsSpace)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return Distance(f), err
|
||||
}
|
||||
|
||||
// MustParseDistance parses s as distance in meters, but panics on error.
|
||||
func MustParseDistance(s string) Distance {
|
||||
d, err := ParseDistance(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// String implements the [Stringer] interface.
|
||||
func (d Distance) String() string {
|
||||
return strconv.FormatFloat(float64(d), 'f', -1, 64) + "m"
|
||||
}
|
||||
|
||||
// DistanceOnEarth converts t turns into the great-circle distance, in meters.
|
||||
func DistanceOnEarth(t Turns) Distance {
|
||||
return Distance(t) * EarthMeanCircumference
|
||||
}
|
||||
|
||||
// Earth Fact Sheet
|
||||
// https://nssdc.gsfc.nasa.gov/planetary/factsheet/earthfact.html
|
||||
const (
|
||||
// EarthMeanRadius is the volumetric mean radius of the Earth.
|
||||
EarthMeanRadius = 6_371_000 * Meter
|
||||
// EarthMeanCircumference is the volumetric mean circumference of the Earth.
|
||||
EarthMeanCircumference = 2 * math.Pi * EarthMeanRadius
|
||||
|
||||
// earthEquatorialRadius is the equatorial radius of the Earth.
|
||||
earthEquatorialRadius = 6_378_137 * Meter
|
||||
// earthEquatorialCircumference is the equatorial circumference of the Earth.
|
||||
earthEquatorialCircumference = 2 * math.Pi * earthEquatorialRadius
|
||||
|
||||
// earthPolarRadius is the polar radius of the Earth.
|
||||
earthPolarRadius = 6_356_752 * Meter
|
||||
// earthPolarCircumference is the polar circumference of the Earth.
|
||||
earthPolarCircumference = 2 * math.Pi * earthPolarRadius
|
||||
)
|
395
types/geo/units_test.go
Normal file
395
types/geo/units_test.go
Normal file
@ -0,0 +1,395 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package geo_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/types/geo"
|
||||
)
|
||||
|
||||
func TestDegrees(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
degs geo.Degrees
|
||||
wantStr string
|
||||
wantText string
|
||||
wantPad string
|
||||
wantRads geo.Radians
|
||||
wantTurns geo.Turns
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
degs: 0.0 * geo.Degree,
|
||||
wantStr: "+0°",
|
||||
wantText: "+0",
|
||||
wantPad: "+000",
|
||||
wantRads: 0.0 * geo.Radian,
|
||||
wantTurns: 0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "quarter-turn",
|
||||
degs: 90.0 * geo.Degree,
|
||||
wantStr: "+90°",
|
||||
wantText: "+90",
|
||||
wantPad: "+090",
|
||||
wantRads: 0.5 * math.Pi * geo.Radian,
|
||||
wantTurns: 0.25 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "half-turn",
|
||||
degs: 180.0 * geo.Degree,
|
||||
wantStr: "+180°",
|
||||
wantText: "+180",
|
||||
wantPad: "+180",
|
||||
wantRads: 1.0 * math.Pi * geo.Radian,
|
||||
wantTurns: 0.5 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "full-turn",
|
||||
degs: 360.0 * geo.Degree,
|
||||
wantStr: "+360°",
|
||||
wantText: "+360",
|
||||
wantPad: "+360",
|
||||
wantRads: 2.0 * math.Pi * geo.Radian,
|
||||
wantTurns: 1.0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
degs: geo.MustParseDegrees("-0.0"),
|
||||
wantStr: "-0°",
|
||||
wantText: "-0",
|
||||
wantPad: "-000",
|
||||
wantRads: 0 * geo.Radian * -1,
|
||||
wantTurns: 0 * geo.Turn * -1,
|
||||
},
|
||||
{
|
||||
name: "small-degree",
|
||||
degs: -1.2003 * geo.Degree,
|
||||
wantStr: "-1.2003°",
|
||||
wantText: "-1.2003",
|
||||
wantPad: "-001.2003",
|
||||
wantRads: -0.020949187011687936 * geo.Radian,
|
||||
wantTurns: -0.0033341666666666663 * geo.Turn,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.degs.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
d, err := geo.ParseDegrees(tt.wantStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDegrees err %q, want nil", err.Error())
|
||||
}
|
||||
if d != tt.degs {
|
||||
t.Errorf("ParseDegrees got %q, want %q", d, tt.degs)
|
||||
}
|
||||
|
||||
b, err := tt.degs.AppendText(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendText err %q, want nil", err.Error())
|
||||
}
|
||||
if string(b) != tt.wantText {
|
||||
t.Errorf("AppendText got %q, want %q", b, tt.wantText)
|
||||
}
|
||||
|
||||
b = tt.degs.AppendZeroPaddedText(nil, 3)
|
||||
if string(b) != tt.wantPad {
|
||||
t.Errorf("AppendZeroPaddedText got %q, want %q", b, tt.wantPad)
|
||||
}
|
||||
|
||||
r := tt.degs.Radians()
|
||||
if r != tt.wantRads {
|
||||
t.Errorf("Radian got %v, want %v", r, tt.wantRads)
|
||||
}
|
||||
if d := r.Degrees(); d != tt.degs { // Roundtrip
|
||||
t.Errorf("Degrees got %v, want %v", d, tt.degs)
|
||||
}
|
||||
|
||||
o := tt.degs.Turns()
|
||||
if o != tt.wantTurns {
|
||||
t.Errorf("Turns got %v, want %v", o, tt.wantTurns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadians(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
rads geo.Radians
|
||||
wantStr string
|
||||
wantText string
|
||||
wantDegs geo.Degrees
|
||||
wantTurns geo.Turns
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
rads: 0.0 * geo.Radian,
|
||||
wantStr: "0 rad",
|
||||
wantDegs: 0.0 * geo.Degree,
|
||||
wantTurns: 0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "quarter-turn",
|
||||
rads: 0.5 * math.Pi * geo.Radian,
|
||||
wantStr: "1.5707963267948966 rad",
|
||||
wantDegs: 90.0 * geo.Degree,
|
||||
wantTurns: 0.25 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "half-turn",
|
||||
rads: 1.0 * math.Pi * geo.Radian,
|
||||
wantStr: "3.141592653589793 rad",
|
||||
wantDegs: 180.0 * geo.Degree,
|
||||
wantTurns: 0.5 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "full-turn",
|
||||
rads: 2.0 * math.Pi * geo.Radian,
|
||||
wantStr: "6.283185307179586 rad",
|
||||
wantDegs: 360.0 * geo.Degree,
|
||||
wantTurns: 1.0 * geo.Turn,
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
rads: geo.MustParseRadians("-0"),
|
||||
wantStr: "-0 rad",
|
||||
wantDegs: 0 * geo.Degree * -1,
|
||||
wantTurns: 0 * geo.Turn * -1,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.rads.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
r, err := geo.ParseRadians(tt.wantStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDegrees err %q, want nil", err.Error())
|
||||
}
|
||||
if r != tt.rads {
|
||||
t.Errorf("ParseDegrees got %q, want %q", r, tt.rads)
|
||||
}
|
||||
|
||||
d := tt.rads.Degrees()
|
||||
if d != tt.wantDegs {
|
||||
t.Errorf("Degrees got %v, want %v", d, tt.wantDegs)
|
||||
}
|
||||
if r := d.Radians(); r != tt.rads { // Roundtrip
|
||||
t.Errorf("Radians got %v, want %v", r, tt.rads)
|
||||
}
|
||||
|
||||
o := tt.rads.Turns()
|
||||
if o != tt.wantTurns {
|
||||
t.Errorf("Turns got %v, want %v", o, tt.wantTurns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurns(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
turns geo.Turns
|
||||
wantStr string
|
||||
wantText string
|
||||
wantDegs geo.Degrees
|
||||
wantRads geo.Radians
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
turns: 0.0,
|
||||
wantStr: "0",
|
||||
wantDegs: 0.0 * geo.Degree,
|
||||
wantRads: 0 * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "quarter-turn",
|
||||
turns: 0.25,
|
||||
wantStr: "0.25",
|
||||
wantDegs: 90.0 * geo.Degree,
|
||||
wantRads: 0.5 * math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "half-turn",
|
||||
turns: 0.5,
|
||||
wantStr: "0.5",
|
||||
wantDegs: 180.0 * geo.Degree,
|
||||
wantRads: 1.0 * math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "full-turn",
|
||||
turns: 1.0,
|
||||
wantStr: "1",
|
||||
wantDegs: 360.0 * geo.Degree,
|
||||
wantRads: 2.0 * math.Pi * geo.Radian,
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
turns: geo.Turns(math.Copysign(0, -1)),
|
||||
wantStr: "-0",
|
||||
wantDegs: 0 * geo.Degree * -1,
|
||||
wantRads: 0 * geo.Radian * -1,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.turns.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
d := tt.turns.Degrees()
|
||||
if d != tt.wantDegs {
|
||||
t.Errorf("Degrees got %v, want %v", d, tt.wantDegs)
|
||||
}
|
||||
if o := d.Turns(); o != tt.turns { // Roundtrip
|
||||
t.Errorf("Turns got %v, want %v", o, tt.turns)
|
||||
}
|
||||
|
||||
r := tt.turns.Radians()
|
||||
if r != tt.wantRads {
|
||||
t.Errorf("Turns got %v, want %v", r, tt.wantRads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistance(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
dist geo.Distance
|
||||
wantStr string
|
||||
}{
|
||||
{
|
||||
name: "zero",
|
||||
dist: 0.0 * geo.Meter,
|
||||
wantStr: "0m",
|
||||
},
|
||||
{
|
||||
name: "random",
|
||||
dist: 4 * geo.Meter,
|
||||
wantStr: "4m",
|
||||
},
|
||||
{
|
||||
name: "light-second",
|
||||
dist: 299_792_458 * geo.Meter,
|
||||
wantStr: "299792458m",
|
||||
},
|
||||
{
|
||||
name: "planck-length",
|
||||
dist: 1.61625518e-35 * geo.Meter,
|
||||
wantStr: "0.0000000000000000000000000000000000161625518m",
|
||||
},
|
||||
{
|
||||
name: "negative-zero",
|
||||
dist: geo.Distance(math.Copysign(0, -1)),
|
||||
wantStr: "-0m",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.dist.String(); got != tt.wantStr {
|
||||
t.Errorf("String got %q, want %q", got, tt.wantStr)
|
||||
}
|
||||
|
||||
r, err := geo.ParseDistance(tt.wantStr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDegrees err %q, want nil", err.Error())
|
||||
}
|
||||
if r != tt.dist {
|
||||
t.Errorf("ParseDegrees got %q, want %q", r, tt.dist)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistanceOnEarth(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
here geo.Point
|
||||
there geo.Point
|
||||
want geo.Distance
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no-points",
|
||||
here: geo.Point{},
|
||||
there: geo.Point{},
|
||||
wantErr: "not a valid point",
|
||||
},
|
||||
{
|
||||
name: "not-here",
|
||||
here: geo.Point{},
|
||||
there: geo.MakePoint(0, 0),
|
||||
wantErr: "not a valid point",
|
||||
},
|
||||
{
|
||||
name: "not-there",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.Point{},
|
||||
wantErr: "not a valid point",
|
||||
},
|
||||
{
|
||||
name: "null-island",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.MakePoint(0, 0),
|
||||
want: 0 * geo.Meter,
|
||||
},
|
||||
{
|
||||
name: "equator-to-south-pole",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.MakePoint(-90, 0),
|
||||
want: geo.EarthMeanCircumference / 4,
|
||||
},
|
||||
{
|
||||
name: "north-pole-to-south-pole",
|
||||
here: geo.MakePoint(+90, 0),
|
||||
there: geo.MakePoint(-90, 0),
|
||||
want: geo.EarthMeanCircumference / 2,
|
||||
},
|
||||
{
|
||||
name: "meridian-to-antimeridian",
|
||||
here: geo.MakePoint(0, 0),
|
||||
there: geo.MakePoint(0, -180),
|
||||
want: geo.EarthMeanCircumference / 2,
|
||||
},
|
||||
{
|
||||
name: "positive-to-negative-antimeridian",
|
||||
here: geo.MakePoint(0, 180),
|
||||
there: geo.MakePoint(0, -180),
|
||||
want: 0 * geo.Meter,
|
||||
},
|
||||
{
|
||||
name: "toronto-to-montreal",
|
||||
here: geo.MakePoint(+43.70011, -79.41630),
|
||||
there: geo.MakePoint(+45.50884, -73.58781),
|
||||
want: 503_200 * geo.Meter,
|
||||
},
|
||||
{
|
||||
name: "montreal-to-san-francisco",
|
||||
here: geo.MakePoint(+45.50884, -73.58781),
|
||||
there: geo.MakePoint(+37.77493, -122.41942),
|
||||
want: 4_082_600 * geo.Meter,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.here.DistanceTo(tt.there)
|
||||
if tt.wantErr == "" && err != nil {
|
||||
t.Fatalf("err %q, want nil", err)
|
||||
}
|
||||
if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err %q, want %q", err, tt.wantErr)
|
||||
}
|
||||
|
||||
approx := func(x, y geo.Distance) bool {
|
||||
return math.Abs(float64(x)-float64(y)) <= 10
|
||||
}
|
||||
if !approx(got, tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user