geodetic 0.3.1 → 0.3.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13b2b50f8ff03c2093abf1a2454f3db38942f9c32918eb23aab30b6c45a4146b
4
- data.tar.gz: 2b3d5a7f325e2766196d2997e9c1781f0425abe016edeeb2fe4f155fc6a43f13
3
+ metadata.gz: f295df82e85611a9a7a7dd56593ae029d6ad096cc5c96021d5b59b4f4b001cc2
4
+ data.tar.gz: 6da1bc3461fd4f372b53f5458d322f8c14d1feb8a2afa93a2375cb5641674674
5
5
  SHA512:
6
- metadata.gz: 1d0378c9df0e04f9bb3de688303abae35857c4fbc594a26094a983417baa55004ef7f7f4cef7c8db7511b8fc769d0a368420e7601cb04e12eaf887b324e51e61
7
- data.tar.gz: c20dd6fa952579d8d035e1afd146d7337caa087efd71df4db17dcc3918b8cb4c561237f1cb5820167d9f84a64209da48a1dd38d0b46a5bccf0e3ce6959aa06d3
6
+ metadata.gz: 1a0b72935abf28bbcb41182f800d1bdb52466530565f13d75fec5f3eec14c0dac7680f8220f09c51cf4d60ed3b87fc53a3675ceb972f1cec17ca06cbd096d717
7
+ data.tar.gz: 754e5b64d8e1d333dafd270a6e21434d46102ea57e43fbba16b83bcecd9eb69eb4e834a51554c10eb59433f6d434930bf2664fca0d9c22b37cf7378e90cc106e
data/CHANGELOG.md CHANGED
@@ -11,6 +11,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
  ## [Unreleased]
12
12
 
13
13
 
14
+ ## [0.3.2] - 2026-03-09
15
+
16
+ ### Added
17
+
18
+ - **`Geodetic::Path` class** — directed, ordered sequence of unique coordinates for modeling routes, trails, and boundaries
19
+ - **Navigation**: `first`, `last`, `next`, `prev`, `segments`, `size`, `empty?`
20
+ - **Membership**: `include?`/`includes?` (waypoint check), `contains?`/`inside?` (on-segment check with configurable tolerance)
21
+ - **Spatial**: `nearest_waypoint`, `closest_coordinate_to`, `distance_to`, `bearing_to` using geometric projection onto segments
22
+ - **Closest points**: `closest_points_to` for Path-to-Path, Path-to-Polygon, Path-to-Rectangle, and Path-to-Circle
23
+ - **Computed**: `total_distance`, `segment_distances`, `segment_bearings`, `reverse`
24
+ - **Subpath/split**: `between(from, to)` extracts a subpath; `split_at(coord)` divides into two paths sharing the split point
25
+ - **Interpolation**: `at_distance(distance)` finds the coordinate at a given distance along the path
26
+ - **Bounding box**: `bounds` returns an `Areas::Rectangle`
27
+ - **Polygon conversion**: `to_polygon` closes the path (validates no self-intersection)
28
+ - **Intersection**: `intersects?(other_path)` detects crossing segments
29
+ - **Equality**: `==` compares coordinates in order
30
+ - **Enumerable**: includes `Enumerable` via `each` — supports `map`, `select`, `any?`, `to_a`, etc.
31
+ - **Non-mutating operators**: `+` and `-` accept both coordinates and paths
32
+ - **Mutating operators**: `<<`, `>>`, `prepend`, `insert(after:/before:)`, `delete`/`remove` — all accept paths as well as coordinates
33
+ - **Path operations example** (`examples/06_path_operations.rb`) — 19-section demo covering all Path capabilities with a Manhattan walking route
34
+ - Documentation: `docs/reference/path.md` (Path reference)
35
+
36
+ ### Changed
37
+
38
+ - Updated `Geodetic::Feature` to support Path as a geometry type — delegates `distance_to` and `bearing_to` using geometric projection
39
+ - Updated README, `docs/index.md`, `docs/reference/feature.md`, `examples/README.md`, and mkdocs nav to include Path class
40
+
14
41
  ## [0.3.1] - 2026-03-09
15
42
 
16
43
  ### Added
data/README.md CHANGED
@@ -19,6 +19,7 @@
19
19
  - <strong>Bearing Calculations</strong> - Forward azimuth, back azimuth, compass directions, elevation angles<br>
20
20
  - <strong>Geoid Height Support</strong> - EGM96, EGM2008, GEOID18, GEOID12B models<br>
21
21
  - <strong>Geographic Areas</strong> - Circle, Polygon, and Rectangle with point-in-area tests<br>
22
+ - <strong>Paths</strong> - Directed coordinate sequences with navigation, interpolation, closest approach, intersection, and area conversion<br>
22
23
  - <strong>Features</strong> - Named geometry wrapper with metadata and delegated distance/bearing<br>
23
24
  - <strong>Validated Setters</strong> - Type coercion and range validation on all coordinate attributes<br>
24
25
  - <strong>Serialization</strong> - to_s(precision), to_a, from_string, from_array, DMS format<br>
@@ -541,9 +542,49 @@ rect.sw # => computed SW corner
541
542
  rect.includes?(point) # => true/false
542
543
  ```
543
544
 
545
+ ### Paths
546
+
547
+ `Path` is a directed, ordered sequence of unique coordinates representing routes, trails, or boundaries.
548
+
549
+ ```ruby
550
+ route = Path.new(coordinates: [battery_park, wall_street, brooklyn_bridge, city_hall])
551
+
552
+ # Navigation
553
+ route.first # => starting waypoint
554
+ route.next(wall_street) # => brooklyn_bridge
555
+ route.total_distance.to_km # => "3.42 km"
556
+
557
+ # Build incrementally
558
+ trail = Path.new
559
+ trail << start << middle << finish
560
+ trail >> new_start # prepend
561
+
562
+ # Combine paths
563
+ combined = downtown + uptown # concatenate
564
+ trimmed = combined - detour # remove coordinates
565
+
566
+ # Closest approach (geometric projection, not just waypoints)
567
+ route.closest_coordinate_to(off_path_point)
568
+ route.distance_to(target)
569
+ route.closest_points_to(other_path) # path-to-path
570
+
571
+ # Spatial operations
572
+ sub = route.between(a, b) # extract subpath
573
+ left, right = route.split_at(c) # split at waypoint
574
+ route.at_distance(Distance.km(2)) # interpolate along path
575
+ route.bounds # => Areas::Rectangle
576
+ route.to_polygon # close into polygon
577
+ route.intersects?(other_path) # crossing detection
578
+ route.contains?(point) # on-segment check
579
+
580
+ # Enumerable
581
+ route.map { |c| c.lat }
582
+ route.select { |c| c.lat > 40.72 }
583
+ ```
584
+
544
585
  ### Features
545
586
 
546
- `Feature` wraps a geometry (any coordinate or area) with a label and a metadata hash. It delegates `distance_to` and `bearing_to` to its geometry, using the centroid for area geometries.
587
+ `Feature` wraps a geometry (any coordinate, area, or path) with a label and a metadata hash. It delegates `distance_to` and `bearing_to` to its geometry, using the centroid for area geometries.
547
588
 
548
589
  ```ruby
549
590
  liberty = Feature.new(
@@ -600,6 +641,7 @@ The [`examples/`](examples/) directory contains runnable demo scripts showing pr
600
641
  | [`03_distance_calculations.rb`](examples/03_distance_calculations.rb) | Distance class features, unit conversions, and arithmetic |
601
642
  | [`04_bearing_calculations.rb`](examples/04_bearing_calculations.rb) | Bearing class, compass directions, elevation angles, and chain bearings |
602
643
  | [`05_map_rendering/`](examples/05_map_rendering/) | Render landmarks on a raster map with Feature objects, polygon areas, bearing arrows, and icons using [libgd-gis](https://rubygems.org/gems/libgd-gis) |
644
+ | [`06_path_operations.rb`](examples/06_path_operations.rb) | Path class: construction, navigation, mutation, path arithmetic, closest approach, containment, Enumerable, equality, subpaths, split, interpolation, bounding boxes, polygon conversion, intersection, path-to-path/area closest points, and Feature integration |
603
645
 
604
646
  Run any example with:
605
647
 
data/docs/index.md CHANGED
@@ -15,6 +15,7 @@
15
15
  <li><strong>Bearing Calculations</strong> - Forward azimuth, back azimuth, compass directions, elevation angles<br>
16
16
  <li><strong>Geoid Height Support</strong> - EGM96, EGM2008, GEOID18, GEOID12B models<br>
17
17
  <li><strong>Geographic Areas</strong> - Circle, Polygon, and Rectangle with point-in-area tests<br>
18
+ <li><strong>Paths</strong> - Directed coordinate sequences with navigation, interpolation, closest approach, intersection, and area conversion<br>
18
19
  <li><strong>Features</strong> - Named geometry wrapper with metadata and delegated distance/bearing<br>
19
20
  <li><strong>Validated Setters</strong> - Type coercion and range validation on all coordinate attributes<br>
20
21
  <li><strong>Serialization</strong> - to_s(precision), to_a, from_string, from_array, DMS format<br>
@@ -57,7 +58,8 @@ Geodetic supports full bidirectional conversion between all 18 coordinate system
57
58
  - **16 geodetic datums** -- WGS84, GRS 1980, Clarke 1866, Airy 1830, Bessel 1841, and more. All conversion methods accept an optional datum parameter, defaulting to WGS84.
58
59
  - **Geoid height calculations** -- Convert between ellipsoidal and orthometric heights using models such as EGM96, EGM2008, GEOID18, and GEOID12B.
59
60
  - **Geographic areas** -- `Geodetic::Areas::Circle`, `Geodetic::Areas::Polygon`, and `Geodetic::Areas::Rectangle` for point-in-area testing.
60
- - **Features** -- `Geodetic::Feature` wraps any coordinate or area with a label and metadata hash, delegating `distance_to` and `bearing_to` to the underlying geometry.
61
+ - **Paths** -- `Geodetic::Path` is a directed, ordered sequence of unique coordinates supporting navigation, segment analysis, interpolation, closest approach (geometric projection), containment testing, bounding boxes, polygon conversion, and path intersection detection.
62
+ - **Features** -- `Geodetic::Feature` wraps any coordinate, area, or path with a label and metadata hash, delegating `distance_to` and `bearing_to` to the underlying geometry.
61
63
 
62
64
  ## Design Principles
63
65
 
@@ -14,7 +14,7 @@ Feature.new(
14
14
  )
15
15
  ```
16
16
 
17
- The `geometry` parameter accepts any coordinate class or any area class (`Circle`, `Polygon`, `Rectangle`). The `metadata` hash is optional and defaults to `{}`.
17
+ The `geometry` parameter accepts any coordinate class, any area class (`Circle`, `Polygon`, `Rectangle`), or a `Path`. The `metadata` hash is optional and defaults to `{}`.
18
18
 
19
19
  ---
20
20
 
@@ -36,8 +36,9 @@ A Feature's geometry can be any of:
36
36
 
37
37
  - **Coordinate** — any of the 18 coordinate classes (`LLA`, `ECEF`, `UTM`, etc.)
38
38
  - **Area** — `Areas::Circle`, `Areas::Polygon`, or `Areas::Rectangle`
39
+ - **Path** — a `Geodetic::Path` representing a route or trail
39
40
 
40
- When the geometry is an area, `distance_to` and `bearing_to` use the area's `centroid` as the reference point.
41
+ When the geometry is an area, `distance_to` and `bearing_to` use the area's `centroid` as the reference point. When the geometry is a Path, `distance_to` and `bearing_to` use geometric projection to find the closest approach point on the path.
41
42
 
42
43
  ---
43
44
 
@@ -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 | Pairs of consecutive coordinates `[[a,b], [b,c], ...]` |
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::Rectangle`, 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::Rectangle` 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
+ ```