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.
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Demonstration of the Path class
4
+ # Shows construction, navigation, mutation, closest-approach
5
+ # calculations, containment testing, Enumerable, subpaths,
6
+ # interpolation, bounding boxes, intersection, path-to-path
7
+ # and path-to-area operations, and Feature integration.
8
+
9
+ require_relative "../lib/geodetic"
10
+
11
+ include Geodetic
12
+ LLA = Coordinate::LLA
13
+ Distance = Geodetic::Distance
14
+
15
+ # ── Define waypoints along a hiking route through Manhattan ────
16
+
17
+ battery_park = LLA.new(lat: 40.7033, lng: -74.0170, alt: 0)
18
+ wall_street = LLA.new(lat: 40.7074, lng: -74.0113, alt: 0)
19
+ brooklyn_bridge = LLA.new(lat: 40.7061, lng: -73.9969, alt: 0)
20
+ city_hall = LLA.new(lat: 40.7128, lng: -74.0060, alt: 0)
21
+ soho = LLA.new(lat: 40.7233, lng: -73.9985, alt: 0)
22
+ union_square = LLA.new(lat: 40.7359, lng: -73.9911, alt: 0)
23
+ empire_state = LLA.new(lat: 40.7484, lng: -73.9857, alt: 0)
24
+ times_square = LLA.new(lat: 40.7580, lng: -73.9855, alt: 0)
25
+ central_park = LLA.new(lat: 40.7829, lng: -73.9654, alt: 0)
26
+
27
+ # ── 1. Construction ────────────────────────────────────────────
28
+
29
+ puts "=== Path Operations Demo ==="
30
+ puts
31
+ puts "--- 1. Construction ---"
32
+
33
+ # From an array of coordinates
34
+ route = Path.new(coordinates: [
35
+ battery_park, wall_street, brooklyn_bridge,
36
+ city_hall, soho, union_square, empire_state
37
+ ])
38
+
39
+ puts <<~CONSTRUCTION
40
+ Route has #{route.size} waypoints
41
+ Start: #{route.first.to_s(4)}
42
+ End: #{route.last.to_s(4)}
43
+ CONSTRUCTION
44
+ puts
45
+
46
+ # ── 2. Navigation ─────────────────────────────────────────────
47
+
48
+ puts "--- 2. Navigation ---"
49
+
50
+ puts <<~NAV
51
+ After Wall Street: #{route.next(wall_street)&.to_s(4) || 'nil'}
52
+ Before SoHo: #{route.prev(soho)&.to_s(4) || 'nil'}
53
+ Start has no prev: #{route.prev(battery_park).inspect}
54
+ End has no next: #{route.next(empire_state).inspect}
55
+ NAV
56
+ puts
57
+
58
+ # ── 3. Segments, distances, and bearings ──────────────────────
59
+
60
+ puts "--- 3. Segment Analysis ---"
61
+
62
+ route.segments.each_with_index do |(a, b), i|
63
+ dist = a.distance_to(b)
64
+ bearing = a.bearing_to(b)
65
+ puts " Segment #{i + 1}: #{dist.to_km} #{bearing.to_compass(points: 8)} (#{bearing.to_s(1)})"
66
+ end
67
+
68
+ puts <<~TOTAL
69
+
70
+ Total route distance: #{route.total_distance.to_km}
71
+ TOTAL
72
+ puts
73
+
74
+ # ── 4. Mutation: building a path incrementally ────────────────
75
+
76
+ puts "--- 4. Mutation ---"
77
+
78
+ # Start empty, build with <<
79
+ trail = Path.new
80
+ trail << battery_park << wall_street << city_hall
81
+ puts "Built with <<: #{trail.size} waypoints"
82
+
83
+ # Prepend with >>
84
+ trail >> LLA.new(lat: 40.6892, lng: -74.0445, alt: 0) # Statue of Liberty ferry
85
+ puts "After >> prepend: #{trail.size} waypoints, starts at #{trail.first.to_s(4)}"
86
+
87
+ # Insert between waypoints
88
+ trail.insert(brooklyn_bridge, after: wall_street)
89
+ puts "After insert: #{trail.size} waypoints"
90
+
91
+ # Non-mutating + returns a new path
92
+ extended = trail + soho
93
+ puts "Original trail: #{trail.size} waypoints (unchanged)"
94
+ puts "Extended trail: #{extended.size} waypoints (new path)"
95
+
96
+ # Delete a waypoint
97
+ trail.delete(wall_street)
98
+ puts "After delete: #{trail.size} waypoints"
99
+ puts
100
+
101
+ # ── 5. Path + Path, Path - Path ───────────────────────────────
102
+
103
+ puts "--- 5. Path + Path, Path - Path ---"
104
+
105
+ downtown = Path.new(coordinates: [battery_park, wall_street, brooklyn_bridge])
106
+ uptown = Path.new(coordinates: [city_hall, soho, union_square])
107
+
108
+ # Concatenate two paths
109
+ combined = downtown + uptown
110
+ puts " Downtown (#{downtown.size}) + Uptown (#{uptown.size}) = Combined (#{combined.size})"
111
+
112
+ # Subtract a path's coordinates from another
113
+ trimmed = combined - uptown
114
+ puts " Combined - Uptown = #{trimmed.size} waypoints: #{trimmed.map { |c| c.to_s(4) }.join(', ')}"
115
+ puts
116
+
117
+ # ── 6. Closest approach ───────────────────────────────────────
118
+
119
+ puts "--- 6. Closest Approach ---"
120
+
121
+ # The Flatiron Building is not on our route — where do we pass closest?
122
+ flatiron = LLA.new(lat: 40.7411, lng: -73.9897, alt: 0)
123
+
124
+ closest = route.closest_coordinate_to(flatiron)
125
+ dist = route.distance_to(flatiron)
126
+ bearing = route.bearing_to(flatiron)
127
+
128
+ puts <<~APPROACH
129
+ Target: Flatiron Building (#{flatiron.to_s(4)})
130
+ Nearest waypoint: #{route.nearest_waypoint(flatiron).to_s(4)}
131
+ Closest approach: #{closest.to_s(4)}
132
+ Distance: #{dist.to_km}
133
+ Bearing: #{bearing.to_s(1)} (#{bearing.to_compass(points: 8)})
134
+ APPROACH
135
+
136
+ # Compare waypoint-only vs geometric projection
137
+ wp_dist = route.nearest_waypoint(flatiron).distance_to(flatiron)
138
+ puts <<~COMPARE
139
+ Waypoint-only distance: #{wp_dist.to_km}
140
+ Projected distance: #{dist.to_km}
141
+ Improvement: #{Distance.new(wp_dist.meters - dist.meters)}
142
+ COMPARE
143
+ puts
144
+
145
+ # ── 7. Containment testing ─────────────────────────────────────
146
+
147
+ puts "--- 7. Containment ---"
148
+
149
+ # Waypoint check
150
+ puts " Union Square is a waypoint? #{route.includes?(union_square)}"
151
+ puts " Flatiron is a waypoint? #{route.includes?(flatiron)}"
152
+
153
+ # On-segment check (is a point on the path within tolerance?)
154
+ midpoint_lat = (union_square.lat + empire_state.lat) / 2.0
155
+ midpoint_lng = (union_square.lng + empire_state.lng) / 2.0
156
+ on_path = LLA.new(lat: midpoint_lat, lng: midpoint_lng, alt: 0)
157
+ off_path = LLA.new(lat: midpoint_lat + 0.01, lng: midpoint_lng, alt: 0)
158
+
159
+ puts <<~CONTAINMENT
160
+ Midpoint on segment? #{route.contains?(on_path)}
161
+ Point 1km off path? #{route.contains?(off_path)}
162
+ Point 1km off (500m tol)? #{route.contains?(off_path, tolerance: 500)}
163
+ CONTAINMENT
164
+ puts
165
+
166
+ # ── 8. Enumerable ──────────────────────────────────────────────
167
+
168
+ puts "--- 8. Enumerable ---"
169
+
170
+ # Path includes Enumerable — use map, select, any?, etc.
171
+ latitudes = route.map { |c| c.lat.round(4) }
172
+ puts " Latitudes: #{latitudes.join(', ')}"
173
+
174
+ northernmost = route.max_by { |c| c.lat }
175
+ puts " Northernmost waypoint: #{northernmost.to_s(4)}"
176
+
177
+ above_40_72 = route.select { |c| c.lat > 40.72 }
178
+ puts " Waypoints above 40.72°N: #{above_40_72.size}"
179
+ puts
180
+
181
+ # ── 9. Equality ────────────────────────────────────────────────
182
+
183
+ puts "--- 9. Equality ---"
184
+
185
+ p1 = Path.new(coordinates: [battery_park, wall_street, city_hall])
186
+ p2 = Path.new(coordinates: [battery_park, wall_street, city_hall])
187
+ p3 = p1.reverse
188
+
189
+ puts <<~EQUALITY
190
+ Same coordinates, same order: #{p1 == p2}
191
+ Same coordinates, reversed order: #{p1 == p3}
192
+ Path vs reversed path: #{p1 == p1.reverse}
193
+ EQUALITY
194
+ puts
195
+
196
+ # ── 10. Subpath (between) ─────────────────────────────────────
197
+
198
+ puts "--- 10. Subpath (between) ---"
199
+
200
+ sub = route.between(wall_street, union_square)
201
+ puts <<~SUBPATH
202
+ Full route: #{route.size} waypoints, #{route.total_distance.to_km}
203
+ Subpath: #{sub.size} waypoints (Wall Street → Union Square)
204
+ Sub-distance: #{sub.total_distance.to_km}
205
+ SUBPATH
206
+ puts
207
+
208
+ # ── 11. Split ──────────────────────────────────────────────────
209
+
210
+ puts "--- 11. Split ---"
211
+
212
+ left, right = route.split_at(city_hall)
213
+ puts <<~SPLIT
214
+ Split at City Hall:
215
+ Left half: #{left.size} waypoints (#{left.first.to_s(4)} → #{left.last.to_s(4)})
216
+ Right half: #{right.size} waypoints (#{right.first.to_s(4)} → #{right.last.to_s(4)})
217
+ Shared point: #{left.last == right.first}
218
+ SPLIT
219
+ puts
220
+
221
+ # ── 12. Interpolation (at_distance) ───────────────────────────
222
+
223
+ puts "--- 12. Interpolation ---"
224
+
225
+ total = route.total_distance
226
+ quarter = route.at_distance(Distance.new(total.meters * 0.25))
227
+ halfway = route.at_distance(Distance.new(total.meters * 0.50))
228
+ three_qtr = route.at_distance(Distance.new(total.meters * 0.75))
229
+
230
+ puts <<~INTERP
231
+ Total route: #{total.to_km}
232
+ At 25%: #{quarter.to_s(4)}
233
+ At 50%: #{halfway.to_s(4)}
234
+ At 75%: #{three_qtr.to_s(4)}
235
+ INTERP
236
+ puts
237
+
238
+ # ── 13. Bounding Box ──────────────────────────────────────────
239
+
240
+ puts "--- 13. Bounding Box ---"
241
+
242
+ bbox = route.bounds
243
+ puts <<~BOUNDS
244
+ NW corner: #{bbox.nw.to_s(4)}
245
+ SE corner: #{bbox.se.to_s(4)}
246
+ Centroid: #{bbox.centroid.to_s(4)}
247
+ Flatiron inside bounds? #{bbox.includes?(flatiron)}
248
+ Central Park in bounds? #{bbox.includes?(central_park)}
249
+ BOUNDS
250
+ puts
251
+
252
+ # ── 14. To Polygon ────────────────────────────────────────────
253
+
254
+ puts "--- 14. To Polygon ---"
255
+
256
+ # A triangular path can be closed into a polygon
257
+ triangle = Path.new(coordinates: [battery_park, brooklyn_bridge, empire_state])
258
+ poly = triangle.to_polygon
259
+ puts <<~POLYGON
260
+ Triangle path: #{triangle.size} waypoints
261
+ Polygon boundary: #{poly.boundary.size} points (closed)
262
+ City Hall inside triangle? #{poly.includes?(city_hall)}
263
+ Central Park inside? #{poly.includes?(central_park)}
264
+ POLYGON
265
+ puts
266
+
267
+ # ── 15. Intersection ──────────────────────────────────────────
268
+
269
+ puts "--- 15. Path Intersection ---"
270
+
271
+ # A crosstown path that crosses our uptown route
272
+ crosstown_west = LLA.new(lat: 40.7350, lng: -74.0050, alt: 0)
273
+ crosstown_east = LLA.new(lat: 40.7350, lng: -73.9750, alt: 0)
274
+ crosstown = Path.new(coordinates: [crosstown_west, crosstown_east])
275
+
276
+ # A path that runs parallel, never crossing
277
+ parallel_west = LLA.new(lat: 40.7000, lng: -74.0200, alt: 0)
278
+ parallel_east = LLA.new(lat: 40.7000, lng: -73.9800, alt: 0)
279
+ parallel = Path.new(coordinates: [parallel_west, parallel_east])
280
+
281
+ puts <<~INTERSECT
282
+ Route intersects crosstown? #{route.intersects?(crosstown)}
283
+ Route intersects parallel? #{route.intersects?(parallel)}
284
+ INTERSECT
285
+ puts
286
+
287
+ # ── 16. Path-to-Path closest points ──────────────────────────
288
+
289
+ puts "--- 16. Path-to-Path Closest Points ---"
290
+
291
+ west_side = Path.new(coordinates: [
292
+ LLA.new(lat: 40.7100, lng: -74.0150, alt: 0),
293
+ LLA.new(lat: 40.7500, lng: -74.0050, alt: 0)
294
+ ])
295
+
296
+ result = route.closest_points_to(west_side)
297
+ puts <<~P2P
298
+ Route closest point: #{result[:path_point].to_s(4)}
299
+ West Side closest point: #{result[:area_point].to_s(4)}
300
+ Distance between: #{result[:distance].to_km}
301
+ P2P
302
+ puts
303
+
304
+ # ── 17. Path-to-Area closest points ──────────────────────────
305
+
306
+ puts "--- 17. Path-to-Area Closest Points ---"
307
+
308
+ # Distance from route to a circular area around Central Park
309
+ park_zone = Areas::Circle.new(centroid: central_park, radius: 500)
310
+ circle_result = route.closest_points_to(park_zone)
311
+
312
+ puts <<~AREA
313
+ Central Park zone (500m radius):
314
+ Path closest point: #{circle_result[:path_point].to_s(4)}
315
+ Area closest point: #{circle_result[:area_point].to_s(4)}
316
+ Distance to zone: #{circle_result[:distance].to_km}
317
+ AREA
318
+
319
+ # Distance from route to a polygon
320
+ park_poly = Areas::Polygon.new(boundary: [
321
+ LLA.new(lat: 40.800, lng: -73.958, alt: 0),
322
+ LLA.new(lat: 40.800, lng: -73.949, alt: 0),
323
+ LLA.new(lat: 40.764, lng: -73.973, alt: 0),
324
+ LLA.new(lat: 40.764, lng: -73.981, alt: 0)
325
+ ])
326
+
327
+ poly_result = route.closest_points_to(park_poly)
328
+ puts <<~POLY
329
+ Central Park polygon:
330
+ Path closest point: #{poly_result[:path_point].to_s(4)}
331
+ Area closest point: #{poly_result[:area_point].to_s(4)}
332
+ Distance to polygon: #{poly_result[:distance].to_km}
333
+ POLY
334
+ puts
335
+
336
+ # ── 18. Reverse ────────────────────────────────────────────────
337
+
338
+ puts "--- 18. Reverse ---"
339
+
340
+ return_route = route.reverse
341
+ puts <<~REVERSE
342
+ Original: #{route.first.to_s(4)} -> #{route.last.to_s(4)}
343
+ Reversed: #{return_route.first.to_s(4)} -> #{return_route.last.to_s(4)}
344
+ Same distance: #{route.total_distance.to_km} vs #{return_route.total_distance.to_km}
345
+ REVERSE
346
+ puts
347
+
348
+ # ── 19. Feature integration ───────────────────────────────────
349
+
350
+ puts "--- 19. Feature Integration ---"
351
+
352
+ hiking_route = Feature.new(
353
+ label: "Manhattan Walking Tour",
354
+ geometry: route,
355
+ metadata: { type: "walking", difficulty: "easy" }
356
+ )
357
+
358
+ statue = Feature.new(
359
+ label: "Statue of Liberty",
360
+ geometry: LLA.new(lat: 40.6892, lng: -74.0445, alt: 0),
361
+ metadata: { category: "monument" }
362
+ )
363
+
364
+ puts <<~FEATURE
365
+ Route: #{hiking_route.label} (#{hiking_route.metadata[:type]})
366
+ Closest approach to #{statue.label}: #{hiking_route.distance_to(statue).to_km}
367
+ Bearing to #{statue.label}: #{hiking_route.bearing_to(statue).to_s(1)}
368
+ FEATURE
data/examples/README.md CHANGED
@@ -60,3 +60,26 @@ CLI flags:
60
60
  ```
61
61
 
62
62
  Output: `examples/05_map_rendering/nyc_landmarks.png`
63
+
64
+ ## 06 - Path Operations
65
+
66
+ Demonstrates the `Geodetic::Path` class with a walking route through Manhattan. Covers:
67
+
68
+ - **Construction** from arrays and incremental building with `<<` and `>>`
69
+ - **Navigation** with `first`, `last`, `next`, `prev`
70
+ - **Segment analysis** with distances and bearings for each leg
71
+ - **Mutation** with `insert`, `delete`, `+`, `-`
72
+ - **Path arithmetic** combining paths with `+`, `<<`, `>>` and removing with `-`
73
+ - **Closest approach** using geometric projection to find the nearest point on the path to an off-path target
74
+ - **Containment testing** with `includes?` (waypoint check) and `contains?` (on-segment check)
75
+ - **Enumerable** iteration with `map`, `select`, `max_by`
76
+ - **Equality** comparing paths by coordinates and order
77
+ - **Subpath extraction** with `between` and **splitting** with `split_at`
78
+ - **Interpolation** finding coordinates at a given distance along the path with `at_distance`
79
+ - **Bounding box** with `bounds` returning an `Areas::Rectangle`
80
+ - **Polygon conversion** with `to_polygon` (validates no self-intersection)
81
+ - **Path intersection** detection with `intersects?`
82
+ - **Path-to-Path closest points** finding the nearest pair between two paths
83
+ - **Path-to-Area closest points** for Circle and Polygon areas
84
+ - **Reverse** to create the return route
85
+ - **Feature integration** wrapping a Path with label and metadata
@@ -11,11 +11,19 @@ module Geodetic
11
11
  end
12
12
 
13
13
  def distance_to(other)
14
- resolve_point.distance_to(resolve_point_from(other))
14
+ if @geometry.is_a?(Path)
15
+ @geometry.distance_to(other)
16
+ else
17
+ resolve_point.distance_to(resolve_point_from(other))
18
+ end
15
19
  end
16
20
 
17
21
  def bearing_to(other)
18
- resolve_point.bearing_to(resolve_point_from(other))
22
+ if @geometry.is_a?(Path)
23
+ @geometry.bearing_to(other)
24
+ else
25
+ resolve_point.bearing_to(resolve_point_from(other))
26
+ end
19
27
  end
20
28
 
21
29
  def to_s