summaryrefslogtreecommitdiffhomepage
path: root/types/geo/point.go
blob: d7160ac593338ebf6d3606ec8ec6c81b390d06dc (plain)
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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
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))
}