geodetic 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ # Path Reference
2
+
3
+ `Geodetic::Path` represents a directed, ordered sequence of unique coordinates. It models routes, trails, boundaries, and any linear geographic feature where the order of waypoints matters.
4
+
5
+ A Path has a start (first coordinate) and an end (last coordinate). No duplicate coordinates are allowed — each waypoint appears exactly once, enabling unambiguous navigation with `next` and `prev`.
6
+
7
+ Path includes Ruby's `Enumerable` module, so all standard iteration methods (`map`, `select`, `any?`, `to_a`, etc.) are available.
8
+
9
+ ---
10
+
11
+ ## Constructor
12
+
13
+ ```ruby
14
+ # Empty path
15
+ path = Path.new
16
+
17
+ # From an array of coordinates
18
+ path = Path.new(coordinates: [a, b, c, d])
19
+ ```
20
+
21
+ Raises `ArgumentError` if any coordinate appears more than once.
22
+
23
+ ---
24
+
25
+ ## Attributes
26
+
27
+ | Attribute | Type | Description |
28
+ |---------------|-------|-------------|
29
+ | `coordinates` | Array | The ordered list of waypoints (read-only) |
30
+
31
+ ---
32
+
33
+ ## Navigation
34
+
35
+ | Method | Returns | Description |
36
+ |---------------------|-------------|-------------|
37
+ | `first` | Coordinate | Starting waypoint |
38
+ | `last` | Coordinate | Ending waypoint |
39
+ | `next(coordinate)` | Coordinate | Waypoint after the given one, or `nil` at end |
40
+ | `prev(coordinate)` | Waypoint before the given one, or `nil` at start |
41
+ | `size` | Integer | Number of waypoints |
42
+ | `empty?` | Boolean | True if the path has no waypoints |
43
+ | `segments` | Array | Array of `Segment` objects for each consecutive pair |
44
+
45
+ ---
46
+
47
+ ## Membership
48
+
49
+ | Method | Description |
50
+ |--------|-------------|
51
+ | `include?(coord)` | True if the coordinate is a waypoint in the path |
52
+ | `includes?(coord)` | Alias for `include?` |
53
+ | `contains?(coord, tolerance: 10.0)` | True if the coordinate lies on any segment within tolerance (meters) |
54
+ | `inside?(coord, tolerance: 10.0)` | Alias for `contains?` |
55
+ | `excludes?(coord, tolerance: 10.0)` | Opposite of `contains?` |
56
+ | `exclude?(coord)` | Alias for `excludes?` |
57
+ | `outside?(coord)` | Alias for `excludes?` |
58
+
59
+ `includes?` checks waypoints only. `contains?` checks whether a coordinate lies on the line between any two consecutive waypoints, using a bearing comparison with a tolerance derived from the segment length.
60
+
61
+ ---
62
+
63
+ ## Equality
64
+
65
+ ```ruby
66
+ path1 == path2
67
+ ```
68
+
69
+ Two paths are equal if they have the same coordinates in the same order.
70
+
71
+ ---
72
+
73
+ ## Spatial Methods
74
+
75
+ | Method | Returns | Description |
76
+ |--------|---------|-------------|
77
+ | `nearest_waypoint(target)` | Coordinate | The waypoint closest to the target |
78
+ | `closest_coordinate_to(target)` | Coordinate | The closest point on the path (projected onto segments) |
79
+ | `distance_to(other)` | Distance | Distance from the closest point on the path to the target |
80
+ | `bearing_to(other)` | Bearing | Bearing from the closest point on the path to the target |
81
+ | `closest_points_to(other)` | Hash | Closest pair between path and an Area or another Path |
82
+
83
+ The `other` parameter for `distance_to`, `bearing_to`, and `closest_coordinate_to` can be a coordinate, a Feature, an Area, or another Path.
84
+
85
+ ### Closest Points
86
+
87
+ `closest_points_to` returns a hash with:
88
+
89
+ ```ruby
90
+ {
91
+ path_point: Coordinate, # closest point on this path
92
+ area_point: Coordinate, # closest point on the other geometry
93
+ distance: Distance # distance between the two points
94
+ }
95
+ ```
96
+
97
+ Accepts `Areas::Circle`, `Areas::Polygon`, `Areas::BoundingBox`, or another `Path`.
98
+
99
+ ---
100
+
101
+ ## Computed Properties
102
+
103
+ | Method | Returns | Description |
104
+ |--------|---------|-------------|
105
+ | `total_distance` | Distance | Sum of all segment distances |
106
+ | `segment_distances` | Array | Distance for each segment |
107
+ | `segment_bearings` | Array | Bearing for each segment |
108
+ | `reverse` | Path | New path with coordinates in reverse order |
109
+
110
+ ---
111
+
112
+ ## Subpath and Split
113
+
114
+ ### `between(from, to)`
115
+
116
+ Extracts a subpath between two waypoints (inclusive). Both must exist in the path, and `from` must precede `to`.
117
+
118
+ ```ruby
119
+ sub = route.between(wall_street, union_square)
120
+ ```
121
+
122
+ ### `split_at(coordinate)`
123
+
124
+ Splits the path at a waypoint, returning two paths that share the split point.
125
+
126
+ ```ruby
127
+ left, right = route.split_at(city_hall)
128
+ # left ends with city_hall, right starts with city_hall
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Interpolation
134
+
135
+ ### `at_distance(distance)`
136
+
137
+ Returns the coordinate at a given distance along the path from the start. Accepts a `Distance` object or a numeric value in meters.
138
+
139
+ ```ruby
140
+ halfway = route.at_distance(route.total_distance.meters / 2.0)
141
+ quarter = route.at_distance(Distance.new(route.total_distance.meters * 0.25))
142
+ ```
143
+
144
+ Returns the last coordinate if the distance exceeds the total path length.
145
+
146
+ ---
147
+
148
+ ## Bounding Box
149
+
150
+ ### `bounds`
151
+
152
+ Returns an `Areas::BoundingBox` representing the axis-aligned bounding box of all waypoints.
153
+
154
+ ```ruby
155
+ bbox = route.bounds
156
+ bbox.nw # => northwest corner
157
+ bbox.se # => southeast corner
158
+ bbox.includes?(some_point) # => true/false
159
+ ```
160
+
161
+ ---
162
+
163
+ ## To Polygon
164
+
165
+ ### `to_polygon`
166
+
167
+ Closes the path into an `Areas::Polygon` by connecting the last coordinate to the first. Requires at least 3 coordinates. Raises `ArgumentError` if the closing segment would intersect any interior segment of the path.
168
+
169
+ ```ruby
170
+ triangle = Path.new(coordinates: [a, b, c])
171
+ poly = triangle.to_polygon
172
+ poly.includes?(some_point)
173
+ ```
174
+
175
+ ---
176
+
177
+ ## Intersection
178
+
179
+ ### `intersects?(other_path)`
180
+
181
+ Returns true if any segment of this path crosses any segment of the other path. Uses orientation-based intersection testing.
182
+
183
+ ```ruby
184
+ route.intersects?(crosstown) # => true/false
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Non-Mutating Operators
190
+
191
+ These return new Path objects; the original is unchanged.
192
+
193
+ | Operator | Accepts | Description |
194
+ |----------|---------|-------------|
195
+ | `+ coordinate` | Coordinate | New path with coordinate appended |
196
+ | `+ path` | Path | New path with all coordinates of the other path appended |
197
+ | `- coordinate` | Coordinate | New path with coordinate removed |
198
+ | `- path` | Path | New path with all of the other path's coordinates removed |
199
+
200
+ ```ruby
201
+ combined = downtown_path + uptown_path
202
+ trimmed = full_route - detour_path
203
+ ```
204
+
205
+ Raises `ArgumentError` if `+` would create duplicates, or if `-` references coordinates not in the path.
206
+
207
+ ---
208
+
209
+ ## Mutating Operators
210
+
211
+ These modify the path in place and return `self` for chaining.
212
+
213
+ | Method | Accepts | Description |
214
+ |--------|---------|-------------|
215
+ | `<< other` | Coordinate or Path | Append to end |
216
+ | `>> other` | Coordinate or Path | Prepend to start |
217
+ | `prepend(other)` | Coordinate or Path | Same as `>>` |
218
+ | `insert(coord, after: ref)` | Coordinate | Insert after a reference waypoint |
219
+ | `insert(coord, before: ref)` | Coordinate | Insert before a reference waypoint |
220
+ | `delete(coord)` | Coordinate | Remove a waypoint |
221
+ | `remove(coord)` | Coordinate | Alias for `delete` |
222
+
223
+ ```ruby
224
+ path = Path.new
225
+ path << a << b << c # build incrementally
226
+ path >> start_point # prepend
227
+ path << other_path # append an entire path
228
+ path.insert(detour, after: b) # insert between waypoints
229
+ path.delete(b) # remove a waypoint
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Enumerable
235
+
236
+ Path includes `Enumerable`. The `each` method iterates over coordinates in order.
237
+
238
+ ```ruby
239
+ route.map { |c| c.lat }
240
+ route.select { |c| c.lat > 40.72 }
241
+ route.max_by { |c| c.lat }
242
+ route.to_a
243
+ ```
244
+
245
+ ---
246
+
247
+ ## Display
248
+
249
+ | Method | Returns |
250
+ |--------|---------|
251
+ | `to_s` | `"Path(7): 40.70... -> 40.71... -> ..."` |
252
+ | `inspect` | `"#<Geodetic::Path size=7 first=... last=...>"` |
253
+
254
+ ---
255
+
256
+ ## Feature Integration
257
+
258
+ A Path can be used as the geometry of a `Feature`. When a Feature wraps a Path, `distance_to` and `bearing_to` use the Path's geometric projection to find the closest approach point.
259
+
260
+ ```ruby
261
+ hiking_route = Feature.new(
262
+ label: "Manhattan Walking Tour",
263
+ geometry: route,
264
+ metadata: { type: "walking" }
265
+ )
266
+
267
+ hiking_route.distance_to(statue_of_liberty).to_km
268
+ hiking_route.bearing_to(statue_of_liberty).to_compass
269
+ ```
@@ -0,0 +1,181 @@
1
+ # Segment Reference
2
+
3
+ `Geodetic::Segment` represents a directed line segment between two points on the Earth's surface. It is the fundamental geometric primitive underlying `Path` segments, `Polygon` edges, and closest-approach calculations.
4
+
5
+ A Segment has a `start_point` and an `end_point`, both stored as `Coordinate::LLA`. Properties like `length`, `bearing`, and `midpoint` are computed lazily and cached.
6
+
7
+ ---
8
+
9
+ ## Great Circle Arcs
10
+
11
+ On a sphere, any two points that are not antipodal (diametrically opposite) define a great circle, and that great circle produces two arcs between them: the **minor arc** (the shorter path) and the **major arc** (the longer way around the globe).
12
+
13
+ Segment always uses the **minor arc**. All operations — `length`, `bearing`, `interpolate`, `project`, `contains?` — follow the shortest path between the two endpoints via Vincenty geodesic calculations.
14
+
15
+ For **antipodal points** (exactly opposite sides of the Earth, roughly 20,000 km apart), the great circle is degenerate: there are infinitely many paths of equal length and the bearing is undefined.
16
+
17
+ In practice, real-world segments rarely approach even a quarter of the Earth's circumference (~10,000 km), so the minor arc assumption holds for virtually all use cases.
18
+
19
+ ---
20
+
21
+ ## Constructor
22
+
23
+ ```ruby
24
+ a = Geodetic::Coordinate::LLA.new(lat: 40.7484, lng: -73.9857, alt: 0)
25
+ b = Geodetic::Coordinate::LLA.new(lat: 40.7580, lng: -73.9855, alt: 0)
26
+
27
+ seg = Geodetic::Segment.new(a, b)
28
+ ```
29
+
30
+ Accepts any coordinate type that responds to `to_lla`. Endpoints are converted to LLA on construction.
31
+
32
+ ---
33
+
34
+ ## Attributes
35
+
36
+ | Attribute | Type | Description |
37
+ |---------------|------|-------------|
38
+ | `start_point` | LLA | The starting point of the segment |
39
+ | `end_point` | LLA | The ending point of the segment |
40
+
41
+ ---
42
+
43
+ ## Properties
44
+
45
+ All properties are lazily computed and cached after first access.
46
+
47
+ | Method | Returns | Description |
48
+ |----------------|----------|-------------|
49
+ | `length` | Distance | Great-circle distance between endpoints |
50
+ | `distance` | Distance | Alias for `length` |
51
+ | `length_meters`| Float | Length in meters (convenience accessor) |
52
+ | `bearing` | Bearing | Forward azimuth from start to end |
53
+ | `midpoint` | LLA | Point at the halfway mark |
54
+
55
+ ```ruby
56
+ seg.length # => #<Geodetic::Distance 1067.45 m>
57
+ seg.distance # => #<Geodetic::Distance 1067.45 m> (alias)
58
+ seg.length_meters # => 1067.45
59
+ seg.bearing # => #<Geodetic::Bearing 1.0°>
60
+ seg.midpoint # => LLA at the midpoint
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Geometry
66
+
67
+ ### `reverse`
68
+
69
+ Returns a new Segment with start and end points swapped.
70
+
71
+ ```ruby
72
+ rev = seg.reverse
73
+ rev.start_point == seg.end_point # => true
74
+ ```
75
+
76
+ ### `interpolate(fraction)`
77
+
78
+ Returns the LLA coordinate at a given fraction (0.0 to 1.0) along the segment.
79
+
80
+ ```ruby
81
+ seg.interpolate(0.0) # => start_point
82
+ seg.interpolate(0.5) # => midpoint
83
+ seg.interpolate(1.0) # => end_point
84
+ seg.interpolate(0.25) # => quarter-way along
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Projection
90
+
91
+ ### `project(point)`
92
+
93
+ Projects a point onto the segment, returning the closest point on the segment and the distance in meters.
94
+
95
+ ```ruby
96
+ foot, distance_m = seg.project(target_point)
97
+ ```
98
+
99
+ - If the perpendicular foot falls within the segment, returns the foot and the perpendicular distance.
100
+ - If the foot falls before the start, returns `start_point`.
101
+ - If the foot falls past the end, returns `end_point`.
102
+ - Handles zero-length segments and target-at-endpoint edge cases.
103
+
104
+ This is the core geometric operation used by `Path#closest_coordinate_to`, `Path#closest_points_to`, and `Path#at_distance`.
105
+
106
+ ---
107
+
108
+ ## Membership
109
+
110
+ | Method | Description |
111
+ |--------|-------------|
112
+ | `includes?(point)` | True if the point is a vertex (start or end point) |
113
+ | `contains?(point, tolerance: 10.0)` | True if the point lies on the segment within tolerance (meters) |
114
+ | `excludes?(point, tolerance: 10.0)` | Opposite of `contains?` |
115
+
116
+ `includes?` checks vertices only. `contains?` checks whether a point lies anywhere along the line between the two endpoints using a bearing comparison with a tolerance derived from the segment length.
117
+
118
+ ```ruby
119
+ seg.includes?(seg.start_point) # => true
120
+ seg.includes?(seg.midpoint) # => false
121
+
122
+ seg.contains?(seg.midpoint) # => true
123
+ seg.contains?(far_away_point) # => false
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Intersection
129
+
130
+ ### `intersects?(other_segment)`
131
+
132
+ Tests whether two segments cross each other using cross-product orientation tests on a flat lat/lng approximation. Handles both proper intersections and collinear overlap.
133
+
134
+ ```ruby
135
+ seg1 = Geodetic::Segment.new(a, b)
136
+ seg2 = Geodetic::Segment.new(c, d)
137
+
138
+ seg1.intersects?(seg2) # => true/false
139
+ ```
140
+
141
+ Used internally by `Path#intersects?` and `Path#to_polygon` for self-intersection validation.
142
+
143
+ ---
144
+
145
+ ## Conversion
146
+
147
+ | Method | Returns | Description |
148
+ |----------|---------|-------------|
149
+ | `to_path`| Path | A two-point Path from start to end |
150
+ | `to_a` | Array | `[start_point, end_point]` |
151
+
152
+ ```ruby
153
+ seg.to_path # => #<Geodetic::Path size=2 ...>
154
+ seg.to_a # => [start_point, end_point]
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Equality and Display
160
+
161
+ ```ruby
162
+ seg1 == seg2 # true if same start and end points
163
+
164
+ seg.to_s # => "Segment(40.748400, ... -> 40.758000, ...)"
165
+ seg.inspect # => "#<Geodetic::Segment start=... end=... length=1067.45 m>"
166
+ ```
167
+
168
+ Two segments are equal if they have the same start and end points. Direction matters: `Segment.new(a, b) != Segment.new(b, a)`.
169
+
170
+ ---
171
+
172
+ ## Relationship to Path and Polygon
173
+
174
+ `Path#segments` returns an array of Segment objects:
175
+
176
+ ```ruby
177
+ route = Path.new(coordinates: [a, b, c, d])
178
+ route.segments # => [Segment(a→b), Segment(b→c), Segment(c→d)]
179
+ ```
180
+
181
+ Polygon edges are implicit segments formed by consecutive boundary points. Segment's `project`, `intersects?`, and `contains?` methods power the geometric operations in both Path and Polygon.
@@ -253,13 +253,13 @@ def demo_coordinate_systems
253
253
  puts " Lat/Lng error is from MGRS 1-meter grid precision truncation."
254
254
  puts
255
255
 
256
- # ========== Rectangle Area ==========
257
- puts "RECTANGLE AREA"
256
+ # ========== BoundingBox Area ==========
257
+ puts "BOUNDING BOX AREA"
258
258
  puts "-" * 50
259
259
 
260
260
  nw = LLA.new(lat: 47.65, lng: -122.40)
261
261
  se = LLA.new(lat: 47.60, lng: -122.30)
262
- rect = Areas::Rectangle.new(nw: nw, se: se)
262
+ rect = Areas::BoundingBox.new(nw: nw, se: se)
263
263
  puts " NW: (#{rect.nw.lat}, #{rect.nw.lng})"
264
264
  puts " SE: (#{rect.se.lat}, #{rect.se.lng})"
265
265
  puts " NE: (#{rect.ne.lat}, #{rect.ne.lng})"
@@ -268,10 +268,10 @@ def demo_coordinate_systems
268
268
  puts " Space Needle inside? #{rect.includes?(lla_coord)}"
269
269
  puts " London inside? #{rect.includes?(london_lla)}"
270
270
 
271
- # Rectangle from non-LLA coordinates
271
+ # BoundingBox from non-LLA coordinates
272
272
  nw_wm = WebMerc.from_lla(nw)
273
273
  se_wm = WebMerc.from_lla(se)
274
- rect_wm = Areas::Rectangle.new(nw: nw_wm, se: se_wm)
274
+ rect_wm = Areas::BoundingBox.new(nw: nw_wm, se: se_wm)
275
275
  puts " From WebMercator: NW=(#{rect_wm.nw.lat.round(4)}, #{rect_wm.nw.lng.round(4)})"
276
276
  puts
277
277
 
@@ -292,7 +292,7 @@ def demo_coordinate_systems
292
292
  puts "Geohash-36 (GH36)"
293
293
  puts "Geoid Height Support"
294
294
  puts
295
- puts "Areas: Circle, Polygon, Rectangle"
295
+ puts "Areas: Circle, Polygon, BoundingBox"
296
296
  puts
297
297
  puts "All coordinate systems support complete bidirectional conversions!"
298
298
  puts "Total coordinate systems implemented: 13"