geodetic 0.4.0 → 0.5.1

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: f85da37953a9e974422502d62fbf82279fcf4f0ff71594ada2bb150e07cc75d1
4
- data.tar.gz: a986282c09df583d9de453034f36e1a15c0d29cc016f586f09239f4f642d6fae
3
+ metadata.gz: 82e74456b38ec9c888ee70e68de3e626f0333b9f791c8de7779f5aa544cbbbc7
4
+ data.tar.gz: a76e9a726d4a503f0e2331f321de25a3b7c155e8712a27a18c53ba8e5ab27953
5
5
  SHA512:
6
- metadata.gz: 2d72a9b0e9e1a0688f25659dbff3d35e8b1c001c72468d2d2bd5c64ffe9fd2f16315dbbe4ec429c545115268e7486db81b26c4f6c9d625de3a8089b82cbcf8df
7
- data.tar.gz: 031eb9604c27d9683c74bf3483b913a5ddb065ed78bd5375f23297f39b923f5a6212498c236982a310afe25fa5ca300df0dfaaa60d8632ad02950c75c2662e15
6
+ metadata.gz: 4bd3b6a3c7664f9f0b20c7bd5c9cee52d80528fe91f97d5a9d2a8a48c9f8314d34027f50ba3b091581650965721e8275b268564ac6fb203fdc896c1f7fa25fa4
7
+ data.tar.gz: f7d83378049a016a388e2f8b4130179e7166704e20b10525a6f5c5a88a12a8812b703fe484dbfd8c40d7af7d8e6ddda7a903052575ea52b48a169c1ebff3fd8d
data/CHANGELOG.md CHANGED
@@ -1,8 +1,5 @@
1
1
  # Changelog
2
2
 
3
- > [!CAUTION]
4
- > This gem is under active development. APIs and features may change without notice.
5
-
6
3
  All notable changes to this project will be documented in this file.
7
4
 
8
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
@@ -11,6 +8,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
8
  ## [Unreleased]
12
9
 
13
10
 
11
+ ## [0.5.1] - 2026-03-10
12
+
13
+ ### Added
14
+
15
+ - **`Geodetic::GeoJSON` class** — build GeoJSON FeatureCollections from any mix of Geodetic objects
16
+ - **Constructor**: `GeoJSON.new`, `GeoJSON.new(obj, ...)`, `GeoJSON.new([array])`
17
+ - **Accumulate**: `<<` accepts single objects or arrays; returns `self` for chaining
18
+ - **Query**: `size`/`length`, `empty?`, `each`, and all `Enumerable` methods
19
+ - **Remove**: `delete(obj)`, `clear`
20
+ - **Export**: `to_h` (Ruby Hash), `to_json`/`to_json(pretty: true)` (JSON string), `save(path, pretty: false)` (file output)
21
+ - Non-Feature objects auto-wrapped as GeoJSON Features with empty properties
22
+ - Feature objects carry `label` → `"name"` and `metadata` → `properties`
23
+ - **`to_geojson` instance method** on all geometry types:
24
+ - All 18 coordinate classes → GeoJSON Point (via LLA; altitude included when non-zero)
25
+ - `Segment` → GeoJSON LineString (2 positions)
26
+ - `Path` → GeoJSON LineString (default) or Polygon (`as: :polygon`, auto-closes ring)
27
+ - `Areas::Polygon` and subclasses → GeoJSON Polygon
28
+ - `Areas::Circle` → GeoJSON Polygon (N-gon approximation, default 32 segments, configurable via `segments:`)
29
+ - `Areas::BoundingBox` → GeoJSON Polygon (4 corners, right-hand rule ring order)
30
+ - `Feature` → GeoJSON Feature with geometry and properties
31
+ - ENU/NED raise `ArgumentError` (relative systems require conversion first)
32
+ - GeoJSON export example (`examples/09_geojson_export.rb`) — 10-section demo covering `to_geojson` on all geometry types, FeatureCollection building, delete/clear, Enumerable, and file export
33
+ - Documentation: `docs/reference/geojson.md` (GeoJSON Export reference)
34
+
35
+ ### Fixed
36
+
37
+ - Corrected stale numeric values in documentation:
38
+ - Seattle→Portland distance: 235393.17 → 235385.71 m (README, `docs/reference/conversions.md`)
39
+ - Seattle→Portland bearing: 188.2° → 186.25° (README, `docs/reference/conversions.md`, `docs/reference/arithmetic.md`)
40
+ - Seattle→Portland miles: 146.28 → 146.26 (README)
41
+ - Liberty→Empire bearing: 36.99° → 36.95° (README, `docs/reference/feature.md`)
42
+ - Fixed `docs/index.md` Key Features list to include all 18 coordinate systems (was missing GEOREF, GARS, H3)
43
+
44
+ ### Changed
45
+
46
+ - Updated README with GeoJSON Export section, key features bullet, and example 09 in the examples table
47
+ - Updated `docs/index.md` with GeoJSON Export in key features and reference links
48
+ - Updated `examples/README.md` with example 09 description
49
+
50
+ ## [0.5.0] - 2026-03-10
51
+
52
+ ### Added
53
+
54
+ - **`Geodetic::Vector` class** — geodetic displacement pairing a Distance (magnitude) with a Bearing (direction)
55
+ - **Construction**: `Vector.new(distance:, bearing:)` with automatic coercion from numeric values
56
+ - **Components**: `north`, `east` — decomposed meters; `magnitude` — distance in meters
57
+ - **Factory methods**: `Vector.from_components(north:, east:)`, `Vector.from_segment(segment)`
58
+ - **Vincenty direct**: `destination_from(origin)` solves the direct geodetic problem on the WGS84 ellipsoid
59
+ - **Arithmetic**: `+`, `-` (component-wise), `*`, `/` (scalar), `-@` (unary minus); `Numeric * Vector` via coerce
60
+ - **Products**: `dot(other)`, `cross(other)`, `angle_between(other)`
61
+ - **Properties**: `zero?`, `normalize`, `reverse`/`inverse`
62
+ - **Comparable**: ordered by distance (magnitude)
63
+ - Near-zero results (< 1e-9 m) snap to clean zero vector
64
+ - **Geodetic arithmetic with `+` operator** — build geometry from coordinates, vectors, and distances:
65
+ - `Coordinate + Coordinate` → Segment
66
+ - `Coordinate + Coordinate + Coordinate` → Path (via Segment + Coordinate → Path)
67
+ - `Coordinate + Segment` → Path
68
+ - `Segment + Coordinate` → Path
69
+ - `Segment + Segment` → Path
70
+ - `Coordinate + Distance` → Circle
71
+ - `Distance + Coordinate` → Circle (commutative)
72
+ - `Coordinate + Vector` → Segment (Vincenty direct)
73
+ - `Vector + Coordinate` → Segment (reverse start to coordinate)
74
+ - `Segment + Vector` → Path (extend from endpoint)
75
+ - `Vector + Segment` → Path (prepend via reverse)
76
+ - `Path + Vector` → Path (extend from last point)
77
+ - **Translation with `*` operator and `translate` method** — uniform displacement across all geometric types:
78
+ - `Coordinate * Vector` → Coordinate (translated point)
79
+ - `Segment * Vector` → Segment (translated endpoints)
80
+ - `Path * Vector` → Path (translated waypoints)
81
+ - `Circle * Vector` → Circle (translated centroid, preserved radius)
82
+ - `Polygon * Vector` → Polygon (translated vertices)
83
+ - **`Segment#to_vector`** — extract a Vector from a Segment's length and bearing
84
+ - **`Path#to_corridor(width:)`** — convert a path into a Polygon corridor of a given width; uses mean bearing at interior waypoints to avoid self-intersection; accepts meters or a Distance object
85
+ - **Geodetic arithmetic example** (`examples/08_geodetic_arithmetic.rb`) — 11-section demo covering all arithmetic operators, Vector class, translation, corridors, and composed operations
86
+ - Documentation: `docs/reference/vector.md` (Vector reference), `docs/reference/arithmetic.md` (Geodetic Arithmetic reference)
87
+
88
+ ### Changed
89
+
90
+ - Updated README with Vector, Geodetic Arithmetic, and Corridors sections; added to key features list
91
+ - Updated `examples/README.md` with example 08 description
92
+
14
93
  ## [0.4.0] - 2026-03-10
15
94
 
16
95
  ### Added
data/README.md CHANGED
@@ -22,6 +22,9 @@
22
22
  - <strong>Segments</strong> - Directed two-point line segments with projection, intersection, and interpolation<br>
23
23
  - <strong>Paths</strong> - Directed coordinate sequences with navigation, interpolation, closest approach, intersection, and area conversion<br>
24
24
  - <strong>Features</strong> - Named geometry wrapper with metadata and delegated distance/bearing<br>
25
+ - <strong>Vectors</strong> - Geodetic displacement (distance + bearing) with full arithmetic and Vincenty direct<br>
26
+ - <strong>Geodetic Arithmetic</strong> - Compose geometry with operators: P1 + P2 → Segment, + P3 → Path, + Distance → Circle, * Vector → translate<br>
27
+ - <strong>GeoJSON Export</strong> - Build FeatureCollections from any mix of objects and save to file<br>
25
28
  - <strong>Validated Setters</strong> - Type coercion and range validation on all coordinate attributes<br>
26
29
  - <strong>Serialization</strong> - to_s(precision), to_a, from_string, from_array, DMS format<br>
27
30
  - <strong>Multiple Datums</strong> - WGS84, Clarke 1866, GRS 1980, Airy 1830, and more<br>
@@ -71,11 +74,15 @@ require "geodetic"
71
74
 
72
75
  include Geodetic
73
76
 
74
- lla = Coordinates::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
75
- ecef = Coordinates::ECEF.new(x: -2304643.57, y: -3638650.07, z: 4688674.43)
76
- utm = Coordinates::UTM.new(easting: 548894.0, northing: 5272748.0, altitude: 184.0, zone: 10, hemisphere: "N")
77
- enu = Coordinates::ENU.new(e: 100.0, n: 200.0, u: 50.0)
78
- ned = Coordinates::NED.new(n: 200.0, e: 100.0, d: -50.0)
77
+ # lng:, lon:, and long: are all accepted for longitude
78
+ lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
79
+ lla = Coordinate::LLA.new(lat: 47.6205, lon: -122.3493, alt: 184.0)
80
+ lla = Coordinate::LLA.new(lat: 47.6205, long: -122.3493, alt: 184.0)
81
+ # Readers: lla.lng, lla.lon, lla.long, lla.longitude all return the same value
82
+ ecef = Coordinate::ECEF.new(x: -2304643.57, y: -3638650.07, z: 4688674.43)
83
+ utm = Coordinate::UTM.new(easting: 548894.0, northing: 5272748.0, altitude: 184.0, zone: 10, hemisphere: "N")
84
+ enu = Coordinate::ENU.new(e: 100.0, n: 200.0, u: 50.0)
85
+ ned = Coordinate::NED.new(n: 200.0, e: 100.0, d: -50.0)
79
86
  ```
80
87
 
81
88
  ### GCS Shorthand
@@ -110,19 +117,19 @@ Geodetic::Coordinate.systems.map { |c| c.name.split('::').last }
110
117
  Every coordinate system can convert to and from every other system:
111
118
 
112
119
  ```ruby
113
- lla = Coordinates::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
120
+ lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
114
121
 
115
122
  # LLA to other systems
116
123
  ecef = lla.to_ecef
117
124
  utm = lla.to_utm
118
- wm = Coordinates::WebMercator.from_lla(lla)
119
- mgrs = Coordinates::MGRS.from_lla(lla)
125
+ wm = Coordinate::WebMercator.from_lla(lla)
126
+ mgrs = Coordinate::MGRS.from_lla(lla)
120
127
 
121
128
  # Convert back
122
129
  lla_roundtrip = ecef.to_lla
123
130
 
124
131
  # Local coordinate systems require a reference point
125
- reference = Coordinates::LLA.new(lat: 47.62, lng: -122.35, alt: 0.0)
132
+ reference = Coordinate::LLA.new(lat: 47.62, lng: -122.35, alt: 0.0)
126
133
  enu = lla.to_enu(reference)
127
134
  ned = lla.to_ned(reference)
128
135
  ```
@@ -132,15 +139,15 @@ ned = lla.to_ned(reference)
132
139
  All coordinate classes support `to_s`, `to_a`, `from_string`, and `from_array`. The `to_s` method accepts an optional precision parameter controlling the number of decimal places:
133
140
 
134
141
  ```ruby
135
- lla = Coordinates::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
142
+ lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
136
143
 
137
144
  lla.to_s # => "47.620500, -122.349300, 184.00"
138
145
  lla.to_s(3) # => "47.620, -122.349, 184.00"
139
146
  lla.to_s(0) # => "48, -122, 184"
140
147
  lla.to_a # => [47.6205, -122.3493, 184.0]
141
148
 
142
- Coordinates::LLA.from_string("47.6205, -122.3493, 184.0")
143
- Coordinates::LLA.from_array([47.6205, -122.3493, 184.0])
149
+ Coordinate::LLA.from_string("47.6205, -122.3493, 184.0")
150
+ Coordinate::LLA.from_array([47.6205, -122.3493, 184.0])
144
151
  ```
145
152
 
146
153
  Default precisions by class: LLA=6, Bearing=4, all others=2. Passing `0` returns integers.
@@ -150,24 +157,24 @@ Default precisions by class: LLA=6, Bearing=4, all others=2. Passing `0` returns
150
157
  All coordinate classes provide setter methods with type coercion and validation:
151
158
 
152
159
  ```ruby
153
- lla = Coordinates::LLA.new(lat: 47.0, lng: -122.0, alt: 100.0)
160
+ lla = Coordinate::LLA.new(lat: 47.0, lng: -122.0, alt: 100.0)
154
161
  lla.lat = 48.0 # validates -90..90
155
162
  lla.lng = -121.0 # validates -180..180
156
163
  lla.alt = 200.0 # no range constraint
157
164
  lla.lat = 91.0 # => ArgumentError
158
165
 
159
- utm = Coordinates::UTM.new(easting: 500000.0, northing: 5000000.0, zone: 10, hemisphere: 'N')
166
+ utm = Coordinate::UTM.new(easting: 500000.0, northing: 5000000.0, zone: 10, hemisphere: 'N')
160
167
  utm.zone = 15 # validates 1..60
161
168
  utm.hemisphere = 'S' # validates 'N' or 'S'
162
169
  utm.easting = -1.0 # => ArgumentError
163
170
 
164
171
  # UPS cross-validates hemisphere/zone combinations
165
- ups = Coordinates::UPS.new(hemisphere: 'N', zone: 'Y')
172
+ ups = Coordinate::UPS.new(hemisphere: 'N', zone: 'Y')
166
173
  ups.zone = 'Z' # valid for hemisphere 'N'
167
174
  ups.zone = 'A' # => ArgumentError (rolls back)
168
175
 
169
176
  # BNG auto-updates grid_ref when easting/northing change
170
- bng = Coordinates::BNG.new(easting: 530000, northing: 180000)
177
+ bng = Coordinate::BNG.new(easting: 530000, northing: 180000)
171
178
  bng.easting = 430000 # grid_ref automatically recalculated
172
179
  ```
173
180
 
@@ -176,10 +183,10 @@ ECEF, ENU, NED, and WebMercator setters coerce to float with no range constraint
176
183
  ### DMS (Degrees, Minutes, Seconds)
177
184
 
178
185
  ```ruby
179
- lla = Coordinates::LLA.new(lat: 37.7749, lng: -122.4192, alt: 15.0)
186
+ lla = Coordinate::LLA.new(lat: 37.7749, lng: -122.4192, alt: 15.0)
180
187
  lla.to_dms # => "37° 46' 29.64\" N, 122° 25' 9.12\" W, 15.00 m"
181
188
 
182
- Coordinates::LLA.from_dms("37° 46' 29.64\" N, 122° 25' 9.12\" W, 15.00 m")
189
+ Coordinate::LLA.from_dms("37° 46' 29.64\" N, 122° 25' 9.12\" W, 15.00 m")
183
190
  ```
184
191
 
185
192
  ### String-Based Coordinate Systems
@@ -187,12 +194,12 @@ Coordinates::LLA.from_dms("37° 46' 29.64\" N, 122° 25' 9.12\" W, 15.00 m")
187
194
  MGRS and USNG use string representations:
188
195
 
189
196
  ```ruby
190
- mgrs = Coordinates::MGRS.new(mgrs_string: "18SUJ2337006519")
191
- mgrs = Coordinates::MGRS.from_string("18SUJ2337006519")
197
+ mgrs = Coordinate::MGRS.new(mgrs_string: "18SUJ2337006519")
198
+ mgrs = Coordinate::MGRS.from_string("18SUJ2337006519")
192
199
  mgrs.to_s # => "18SUJ2337006519"
193
200
 
194
- usng = Coordinates::USNG.new(usng_string: "18T WL 12345 67890")
195
- usng = Coordinates::USNG.from_string("18T WL 12345 67890")
201
+ usng = Coordinate::USNG.new(usng_string: "18T WL 12345 67890")
202
+ usng = Coordinate::USNG.from_string("18T WL 12345 67890")
196
203
  usng.to_s # => "18T WL 12345 67890"
197
204
  ```
198
205
 
@@ -208,9 +215,9 @@ portland = Geodetic::Coordinate::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
208
215
  sf = Geodetic::Coordinate::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
209
216
 
210
217
  d = seattle.distance_to(portland) # => Distance (meters)
211
- d.meters # => 235393.17
218
+ d.meters # => 235385.71
212
219
  d.to_km.to_f # => 235.39
213
- d.to_mi.to_f # => 146.28
220
+ d.to_mi.to_f # => 146.26
214
221
 
215
222
  seattle.distance_to(portland, sf) # => [Distance, Distance] (radial)
216
223
  seattle.distance_to([portland, sf]) # => [Distance, Distance] (radial)
@@ -252,12 +259,12 @@ seattle = Geodetic::Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
252
259
  portland = Geodetic::Coordinate::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
253
260
 
254
261
  b = seattle.bearing_to(portland) # => Bearing
255
- b.degrees # => 188.2
256
- b.to_radians # => 3.28...
262
+ b.degrees # => 186.25
263
+ b.to_radians # => 3.25...
257
264
  b.to_compass # => "S"
258
265
  b.to_compass(points: 8) # => "S"
259
266
  b.reverse # => Bearing (back azimuth)
260
- b.to_s # => "188.2036°"
267
+ b.to_s # => "186.2539°"
261
268
  ```
262
269
 
263
270
  **Instance method `elevation_to`** — vertical look angle:
@@ -403,7 +410,7 @@ geoid.convert_vertical_datum(47.6205, -122.3493, 184.0, "HAE", "NAVD88")
403
410
  The `GeoidHeightSupport` module is mixed into LLA for convenience:
404
411
 
405
412
  ```ruby
406
- lla = Coordinates::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
413
+ lla = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
407
414
  lla.geoid_height # => geoid undulation in meters
408
415
  lla.orthometric_height # => height above mean sea level
409
416
  ```
@@ -414,10 +421,10 @@ A spatial hashing coordinate that encodes lat/lng into a compact, URL-friendly s
414
421
 
415
422
  ```ruby
416
423
  # From a geohash string
417
- gh36 = Coordinates::GH36.new("bdrdC26BqH")
424
+ gh36 = Coordinate::GH36.new("bdrdC26BqH")
418
425
 
419
426
  # From any coordinate
420
- gh36 = Coordinates::GH36.new(lla)
427
+ gh36 = Coordinate::GH36.new(lla)
421
428
  gh36 = lla.to_gh36(precision: 8)
422
429
 
423
430
  # Decode back to LLA
@@ -441,10 +448,10 @@ The standard Geohash (base-32) algorithm by Gustavo Niemeyer, widely supported b
441
448
 
442
449
  ```ruby
443
450
  # From a geohash string
444
- gh = Coordinates::GH.new("dr5ru7")
451
+ gh = Coordinate::GH.new("dr5ru7")
445
452
 
446
453
  # From any coordinate
447
- gh = Coordinates::GH.new(lla)
454
+ gh = Coordinate::GH.new(lla)
448
455
  gh = lla.to_gh(precision: 8)
449
456
 
450
457
  # Decode back to LLA
@@ -468,10 +475,10 @@ The Maidenhead Locator System used worldwide in amateur radio for grid square id
468
475
 
469
476
  ```ruby
470
477
  # From a Maidenhead locator string
471
- ham = Coordinates::HAM.new("FN31pr")
478
+ ham = Coordinate::HAM.new("FN31pr")
472
479
 
473
480
  # From any coordinate
474
- ham = Coordinates::HAM.new(lla)
481
+ ham = Coordinate::HAM.new(lla)
475
482
  ham = lla.to_ham(precision: 8)
476
483
 
477
484
  # Decode back to LLA
@@ -495,10 +502,10 @@ Google's open system for encoding locations into short, URL-friendly codes:
495
502
 
496
503
  ```ruby
497
504
  # From a plus code string
498
- olc = Coordinates::OLC.new("849VCWC8+R9")
505
+ olc = Coordinate::OLC.new("849VCWC8+R9")
499
506
 
500
507
  # From any coordinate
501
- olc = Coordinates::OLC.new(lla)
508
+ olc = Coordinate::OLC.new(lla)
502
509
  olc = lla.to_olc(precision: 11)
503
510
 
504
511
  # Decode back to LLA
@@ -520,22 +527,22 @@ olc.precision_in_meters # => { lat: 13.9, lng: 13.9 }
520
527
 
521
528
  ```ruby
522
529
  # Circle area
523
- center = Coordinates::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
530
+ center = Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
524
531
  circle = Areas::Circle.new(centroid: center, radius: 1000.0) # 1km radius
525
532
 
526
533
  # Polygon area
527
534
  points = [
528
- Coordinates::LLA.new(lat: 47.60, lng: -122.35, alt: 0.0),
529
- Coordinates::LLA.new(lat: 47.63, lng: -122.35, alt: 0.0),
530
- Coordinates::LLA.new(lat: 47.63, lng: -122.33, alt: 0.0),
531
- Coordinates::LLA.new(lat: 47.60, lng: -122.33, alt: 0.0),
535
+ Coordinate::LLA.new(lat: 47.60, lng: -122.35, alt: 0.0),
536
+ Coordinate::LLA.new(lat: 47.63, lng: -122.35, alt: 0.0),
537
+ Coordinate::LLA.new(lat: 47.63, lng: -122.33, alt: 0.0),
538
+ Coordinate::LLA.new(lat: 47.60, lng: -122.33, alt: 0.0),
532
539
  ]
533
540
  polygon = Areas::Polygon.new(boundary: points)
534
541
  polygon.centroid # => computed centroid as LLA
535
542
 
536
543
  # BoundingBox area (accepts any coordinate type)
537
- nw = Coordinates::LLA.new(lat: 41.0, lng: -75.0)
538
- se = Coordinates::LLA.new(lat: 40.0, lng: -74.0)
544
+ nw = Coordinate::LLA.new(lat: 41.0, lng: -75.0)
545
+ se = Coordinate::LLA.new(lat: 40.0, lng: -74.0)
539
546
  rect = Areas::BoundingBox.new(nw: nw, se: se)
540
547
  rect.centroid # => LLA at center
541
548
  rect.ne # => computed NE corner
@@ -626,18 +633,18 @@ route.select { |c| c.lat > 40.72 }
626
633
  ```ruby
627
634
  liberty = Feature.new(
628
635
  label: "Statue of Liberty",
629
- geometry: Coordinates::LLA.new(lat: 40.6892, lng: -74.0445, alt: 0),
636
+ geometry: Coordinate::LLA.new(lat: 40.6892, lng: -74.0445, alt: 0),
630
637
  metadata: { category: "monument", year: 1886 }
631
638
  )
632
639
 
633
640
  empire = Feature.new(
634
641
  label: "Empire State Building",
635
- geometry: Coordinates::LLA.new(lat: 40.7484, lng: -73.9857, alt: 0),
642
+ geometry: Coordinate::LLA.new(lat: 40.7484, lng: -73.9857, alt: 0),
636
643
  metadata: { category: "building", floors: 102 }
637
644
  )
638
645
 
639
646
  liberty.distance_to(empire).to_km # => "8.24 km"
640
- liberty.bearing_to(empire).degrees # => 36.99
647
+ liberty.bearing_to(empire).degrees # => 36.95
641
648
 
642
649
  # Area geometries use the centroid for distance/bearing
643
650
  park = Feature.new(
@@ -649,14 +656,118 @@ park.distance_to(liberty).to_km # => "12.47 km"
649
656
 
650
657
  All three attributes (`label`, `geometry`, `metadata`) are mutable.
651
658
 
659
+ ### Vectors
660
+
661
+ `Vector` pairs a `Distance` (magnitude) with a `Bearing` (direction) to represent a geodetic displacement. It solves the Vincenty direct problem to compute destination points.
662
+
663
+ ```ruby
664
+ v = Geodetic::Vector.new(distance: 10_000, bearing: 90.0)
665
+ v = Geodetic::Vector.new(distance: Distance.km(10), bearing: Bearing.new(90))
666
+
667
+ v.north # => north component in meters
668
+ v.east # => east component in meters
669
+ v.magnitude # => distance in meters
670
+ v.reverse # => same distance, opposite bearing
671
+ v.normalize # => unit vector (1 meter)
672
+ ```
673
+
674
+ **Vector arithmetic:**
675
+
676
+ ```ruby
677
+ v1 + v2 # => Vector (component-wise addition)
678
+ v1 - v2 # => Vector (component-wise subtraction)
679
+ v * 3 # => Vector (scale distance)
680
+ v / 2 # => Vector (scale distance)
681
+ -v # => Vector (reverse bearing)
682
+ v.dot(v2) # => Float (dot product)
683
+ v.cross(v2) # => Float (2D cross product)
684
+ ```
685
+
686
+ **Factory methods:**
687
+
688
+ ```ruby
689
+ Vector.from_components(north: 1000, east: 500)
690
+ Vector.from_segment(segment)
691
+ segment.to_vector
692
+ ```
693
+
694
+ ### Geodetic Arithmetic
695
+
696
+ Operators build geometry from coordinates, vectors, and distances:
697
+
698
+ ```ruby
699
+ # Building geometry with +
700
+ p1 + p2 # => Segment
701
+ p1 + p2 + p3 # => Path
702
+ p1 + segment # => Path
703
+ segment + p3 # => Path
704
+ segment + segment # => Path
705
+ p1 + distance # => Circle
706
+ p1 + vector # => Segment (to destination)
707
+ segment + vector # => Path (extend from endpoint)
708
+ vector + segment # => Path (prepend via reverse)
709
+ path + vector # => Path (extend from last point)
710
+ vector + coordinate # => Segment
711
+ distance + coordinate # => Circle
712
+
713
+ # Translation with * or .translate
714
+ p1 * vector # => Coordinate (translated point)
715
+ segment * vector # => Segment (translated endpoints)
716
+ path * vector # => Path (translated waypoints)
717
+ circle * vector # => Circle (translated centroid)
718
+ polygon * vector # => Polygon (translated vertices)
719
+ ```
720
+
721
+ ### Corridors
722
+
723
+ Convert a path into a polygon corridor of a given width:
724
+
725
+ ```ruby
726
+ route = seattle + portland + sf
727
+ corridor = route.to_corridor(width: 1000) # 1km wide polygon
728
+ corridor = route.to_corridor(width: Distance.km(1))
729
+ ```
730
+
731
+ ### GeoJSON Export
732
+
733
+ `GeoJSON` builds a GeoJSON FeatureCollection from any mix of Geodetic objects and writes it to a file.
734
+
735
+ ```ruby
736
+ gj = Geodetic::GeoJSON.new
737
+ gj << seattle
738
+ gj << [portland, sf, la]
739
+ gj << Feature.new(label: "Route", geometry: route, metadata: { mode: "driving" })
740
+ gj << Areas::Circle.new(centroid: seattle, radius: 10_000)
741
+
742
+ gj.size # => 6
743
+ gj.to_h # => {"type" => "FeatureCollection", "features" => [...]}
744
+ gj.to_json # => compact JSON string
745
+ gj.save("map.geojson", pretty: true)
746
+ ```
747
+
748
+ Every geometry type has a `to_geojson` method returning a GeoJSON-compatible Hash:
749
+
750
+ ```ruby
751
+ seattle.to_geojson # => {"type" => "Point", ...}
752
+ Segment.new(seattle, portland).to_geojson # => {"type" => "LineString", ...}
753
+ route.to_geojson # => {"type" => "LineString", ...}
754
+ route.to_geojson(as: :polygon) # => {"type" => "Polygon", ...}
755
+ polygon.to_geojson # => {"type" => "Polygon", ...}
756
+ circle.to_geojson(segments: 64) # => {"type" => "Polygon", ...} (64-gon)
757
+ bbox.to_geojson # => {"type" => "Polygon", ...}
758
+ feature.to_geojson # => {"type" => "Feature", ...}
759
+ ```
760
+
761
+ Features carry their `label` as `"name"` and `metadata` as `properties` in the GeoJSON output. Non-Feature objects added to the collection are auto-wrapped as Features with empty properties.
762
+
652
763
  ### Web Mercator Tile Coordinates
653
764
 
654
765
  ```ruby
655
- wm = Coordinates::WebMercator.from_lla(lla)
766
+ wm = Coordinate::WebMercator.from_lla(lla)
656
767
  wm.to_tile_coordinates(15) # => [x_tile, y_tile, zoom]
657
768
  wm.to_pixel_coordinates(15) # => [x_pixel, y_pixel, zoom]
658
769
 
659
- Coordinates::WebMercator.from_tile_coordinates(5241, 11438, 15)
770
+ Coordinate::WebMercator.from_tile_coordinates(5241, 11438, 15)
660
771
  ```
661
772
 
662
773
  ## Available Datums
@@ -679,6 +790,9 @@ The [`examples/`](examples/) directory contains runnable demo scripts showing pr
679
790
  | [`04_bearing_calculations.rb`](examples/04_bearing_calculations.rb) | Bearing class, compass directions, elevation angles, and chain bearings |
680
791
  | [`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) |
681
792
  | [`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 |
793
+ | [`07_segments_and_shapes.rb`](examples/07_segments_and_shapes.rb) | Segment and polygon subclasses: Triangle, Rectangle, Pentagon, Hexagon, Octagon with containment, edges, and bounding boxes |
794
+ | [`08_geodetic_arithmetic.rb`](examples/08_geodetic_arithmetic.rb) | Geodetic arithmetic: building geometry with + (Segments, Paths, Circles), Vector class (Vincenty direct, components, arithmetic, dot/cross products), translation with * (Coordinates, Segments, Paths, Circles, Polygons), and corridors |
795
+ | [`09_geojson_export.rb`](examples/09_geojson_export.rb) | GeoJSON export: `to_geojson` on all geometry types, `GeoJSON` class for building FeatureCollections with `<<`, delete/clear, Enumerable, and `save` to file |
682
796
 
683
797
  Run any example with:
684
798
 
@@ -6,54 +6,54 @@ The Geodetic gem supports 18 coordinate systems organized into six categories. A
6
6
 
7
7
  | System | Class | Description |
8
8
  |--------|-------|-------------|
9
- | **LLA** | `Geodetic::Coordinate::LLA` | Latitude, Longitude, Altitude. The most common geographic coordinate system, expressing positions in decimal degrees with altitude in meters. Negative longitude is the Western hemisphere; negative latitude is the Southern hemisphere. |
10
- | **ECEF** | `Geodetic::Coordinate::ECEF` | Earth-Centered, Earth-Fixed. A Cartesian coordinate system with the origin at the Earth's center of mass. Positions are expressed as X, Y, Z in meters. Commonly used in satellite navigation and aerospace applications. |
11
- | **UTM** | `Geodetic::Coordinate::UTM` | Universal Transverse Mercator. Divides the Earth into 60 zones (each 6 degrees of longitude), projecting positions as easting/northing in meters within a zone and hemisphere. Covers latitudes 80S to 84N. |
9
+ | [**LLA**](lla.md) | `Geodetic::Coordinate::LLA` | Latitude, Longitude, Altitude. The most common geographic coordinate system, expressing positions in decimal degrees with altitude in meters. Negative longitude is the Western hemisphere; negative latitude is the Southern hemisphere. |
10
+ | [**ECEF**](ecef.md) | `Geodetic::Coordinate::ECEF` | Earth-Centered, Earth-Fixed. A Cartesian coordinate system with the origin at the Earth's center of mass. Positions are expressed as X, Y, Z in meters. Commonly used in satellite navigation and aerospace applications. |
11
+ | [**UTM**](utm.md) | `Geodetic::Coordinate::UTM` | Universal Transverse Mercator. Divides the Earth into 60 zones (each 6 degrees of longitude), projecting positions as easting/northing in meters within a zone and hemisphere. Covers latitudes 80S to 84N. |
12
12
 
13
13
  ## Local Tangent Plane Systems
14
14
 
15
15
  | System | Class | Description |
16
16
  |--------|-------|-------------|
17
- | **ENU** | `Geodetic::Coordinate::ENU` | East, North, Up. A local tangent plane coordinate system centered on a reference point. Axes point East, North, and Up relative to the reference. Distances are in meters. Used in robotics, surveying, and local navigation. |
18
- | **NED** | `Geodetic::Coordinate::NED` | North, East, Down. A local tangent plane coordinate system centered on a reference point. Axes point North, East, and Down. Used extensively in aerospace and aviation applications. Mathematically related to ENU by axis reordering and sign inversion. |
17
+ | [**ENU**](enu.md) | `Geodetic::Coordinate::ENU` | East, North, Up. A local tangent plane coordinate system centered on a reference point. Axes point East, North, and Up relative to the reference. Distances are in meters. Used in robotics, surveying, and local navigation. |
18
+ | [**NED**](ned.md) | `Geodetic::Coordinate::NED` | North, East, Down. A local tangent plane coordinate system centered on a reference point. Axes point North, East, and Down. Used extensively in aerospace and aviation applications. Mathematically related to ENU by axis reordering and sign inversion. |
19
19
 
20
20
  ## Military and Grid Systems
21
21
 
22
22
  | System | Class | Description |
23
23
  |--------|-------|-------------|
24
- | **MGRS** | `Geodetic::Coordinate::MGRS` | Military Grid Reference System. An alphanumeric system based on UTM that identifies positions using grid zone designator, 100km square identifier, and numeric easting/northing. Variable precision from 10km down to 1m. |
25
- | **USNG** | `Geodetic::Coordinate::USNG` | United States National Grid. Based on MGRS but formatted with spaces for readability. Used primarily within the United States for emergency services and land management. |
24
+ | [**MGRS**](mgrs.md) | `Geodetic::Coordinate::MGRS` | Military Grid Reference System. An alphanumeric system based on UTM that identifies positions using grid zone designator, 100km square identifier, and numeric easting/northing. Variable precision from 10km down to 1m. |
25
+ | [**USNG**](usng.md) | `Geodetic::Coordinate::USNG` | United States National Grid. Based on MGRS but formatted with spaces for readability. Used primarily within the United States for emergency services and land management. |
26
26
 
27
27
  ## Web Mapping
28
28
 
29
29
  | System | Class | Description |
30
30
  |--------|-------|-------------|
31
- | **WebMercator** | `Geodetic::Coordinate::WebMercator` | Web Mercator (EPSG:3857). Also known as Pseudo-Mercator or Spherical Mercator. The projection used by Google Maps, OpenStreetMap, and Bing Maps. Positions are X/Y in meters. Latitude is clamped to approximately +/-85.05 degrees. Includes tile and pixel coordinate methods for web mapping applications. |
31
+ | [**WebMercator**](web-mercator.md) | `Geodetic::Coordinate::WebMercator` | Web Mercator (EPSG:3857). Also known as Pseudo-Mercator or Spherical Mercator. The projection used by Google Maps, OpenStreetMap, and Bing Maps. Positions are X/Y in meters. Latitude is clamped to approximately +/-85.05 degrees. Includes tile and pixel coordinate methods for web mapping applications. |
32
32
 
33
33
  ## Polar
34
34
 
35
35
  | System | Class | Description |
36
36
  |--------|-------|-------------|
37
- | **UPS** | `Geodetic::Coordinate::UPS` | Universal Polar Stereographic. Covers the polar regions not handled by UTM (north of 84N and south of 80S). Uses a stereographic projection centered on each pole with zones Y/Z (north) and A/B (south). |
37
+ | [**UPS**](ups.md) | `Geodetic::Coordinate::UPS` | Universal Polar Stereographic. Covers the polar regions not handled by UTM (north of 84N and south of 80S). Uses a stereographic projection centered on each pole with zones Y/Z (north) and A/B (south). |
38
38
 
39
39
  ## Spatial Hashing
40
40
 
41
41
  | System | Class | Description |
42
42
  |--------|-------|-------------|
43
- | **GH36** | `Geodetic::Coordinate::GH36` | Geohash-36. A hierarchical spatial hashing algorithm that encodes latitude/longitude into a compact, URL-friendly string using a case-sensitive 36-character alphabet (radix-36). Each hash represents a rectangular cell; the coordinate value is the cell midpoint. Supports neighbor lookup, area extraction via `to_area`, and configurable precision (default 10 characters for sub-meter resolution). |
44
- | **GH** | `Geodetic::Coordinate::GH` | Geohash (base-32). The standard geohash algorithm by Gustavo Niemeyer using a 32-character alphabet (`0-9, b-z` excluding `a, i, l, o`). The de facto standard for spatial hashing, natively supported by Elasticsearch, Redis, PostGIS, and many geocoding services. Supports neighbor lookup, area extraction, and configurable precision (default 12 characters for sub-centimeter resolution). |
45
- | **HAM** | `Geodetic::Coordinate::HAM` | Maidenhead Locator System. A hierarchical grid system used worldwide in amateur radio that encodes positions using alternating letter/digit pairs (e.g., `FN31pr`). Four levels of precision: Field (18x18), Square (10x10), Subsquare (24x24), Extended (10x10). Supports neighbor lookup, area extraction, and configurable precision (default 6 characters for ~5 km resolution). |
46
- | **OLC** | `Geodetic::Coordinate::OLC` | Open Location Code (Plus Codes). Google's open system for encoding locations into short codes like `849VCWC8+R9`. Uses a 20-character alphabet with 5 paired levels of base-20 encoding plus optional grid refinement. Includes a `+` separator at position 8. Supports neighbor lookup, area extraction, and configurable precision (default 10 characters for ~14 m resolution). |
47
- | **GEOREF** | `Geodetic::Coordinate::GEOREF` | World Geographic Reference System. A geocode system used in aviation and military applications that encodes positions using letter tiles (15° grid), letter degree subdivisions, and numeric minute pairs. Uses a 24-letter alphabet (A-Z excluding I and O). Supports variable precision from 15° tiles (2 chars) down to 0.01-minute resolution (12 chars). Default precision is 8 characters (1-minute resolution). |
48
- | **GARS** | `Geodetic::Coordinate::GARS` | Global Area Reference System. An NGA standard that divides the world into 30-minute cells identified by a 3-digit longitude band (001-720) and 2-letter latitude band. Cells are subdivided into 15-minute quadrants (1-4) and 5-minute keypads (1-9, telephone layout). Variable precision: 5 chars (30'), 6 chars (15'), 7 chars (5'). Default precision is 7 characters. |
49
- | **H3** | `Geodetic::Coordinate::H3` | H3 Hexagonal Hierarchical Index. Uber's spatial indexing system that divides the globe into hexagonal cells (and 12 pentagons) at 16 resolution levels (0-15). Each cell is a 64-bit integer displayed as a hex string. Unlike the rectangular spatial hashes, `to_area` returns an `Areas::Polygon` with 6 vertices (5 for pentagons) and `neighbors` returns an Array of 6 cells. **Requires `libh3` installed** (`brew install h3` on macOS). |
43
+ | [**GH36**](gh36.md) | `Geodetic::Coordinate::GH36` | Geohash-36. A hierarchical spatial hashing algorithm that encodes latitude/longitude into a compact, URL-friendly string using a case-sensitive 36-character alphabet (radix-36). Each hash represents a rectangular cell; the coordinate value is the cell midpoint. Supports neighbor lookup, area extraction via `to_area`, and configurable precision (default 10 characters for sub-meter resolution). |
44
+ | [**GH**](gh.md) | `Geodetic::Coordinate::GH` | Geohash (base-32). The standard geohash algorithm by Gustavo Niemeyer using a 32-character alphabet (`0-9, b-z` excluding `a, i, l, o`). The de facto standard for spatial hashing, natively supported by Elasticsearch, Redis, PostGIS, and many geocoding services. Supports neighbor lookup, area extraction, and configurable precision (default 12 characters for sub-centimeter resolution). |
45
+ | [**HAM**](ham.md) | `Geodetic::Coordinate::HAM` | Maidenhead Locator System. A hierarchical grid system used worldwide in amateur radio that encodes positions using alternating letter/digit pairs (e.g., `FN31pr`). Four levels of precision: Field (18x18), Square (10x10), Subsquare (24x24), Extended (10x10). Supports neighbor lookup, area extraction, and configurable precision (default 6 characters for ~5 km resolution). |
46
+ | [**OLC**](olc.md) | `Geodetic::Coordinate::OLC` | Open Location Code (Plus Codes). Google's open system for encoding locations into short codes like `849VCWC8+R9`. Uses a 20-character alphabet with 5 paired levels of base-20 encoding plus optional grid refinement. Includes a `+` separator at position 8. Supports neighbor lookup, area extraction, and configurable precision (default 10 characters for ~14 m resolution). |
47
+ | [**GEOREF**](georef.md) | `Geodetic::Coordinate::GEOREF` | World Geographic Reference System. A geocode system used in aviation and military applications that encodes positions using letter tiles (15° grid), letter degree subdivisions, and numeric minute pairs. Uses a 24-letter alphabet (A-Z excluding I and O). Supports variable precision from 15° tiles (2 chars) down to 0.01-minute resolution (12 chars). Default precision is 8 characters (1-minute resolution). |
48
+ | [**GARS**](gars.md) | `Geodetic::Coordinate::GARS` | Global Area Reference System. An NGA standard that divides the world into 30-minute cells identified by a 3-digit longitude band (001-720) and 2-letter latitude band. Cells are subdivided into 15-minute quadrants (1-4) and 5-minute keypads (1-9, telephone layout). Variable precision: 5 chars (30'), 6 chars (15'), 7 chars (5'). Default precision is 7 characters. |
49
+ | [**H3**](h3.md) | `Geodetic::Coordinate::H3` | H3 Hexagonal Hierarchical Index. Uber's spatial indexing system that divides the globe into hexagonal cells (and 12 pentagons) at 16 resolution levels (0-15). Each cell is a 64-bit integer displayed as a hex string. Unlike the rectangular spatial hashes, `to_area` returns an `Areas::Polygon` with 6 vertices (5 for pentagons) and `neighbors` returns an Array of 6 cells. **Requires `libh3` installed** (`brew install h3` on macOS). |
50
50
 
51
51
  ## Regional Systems
52
52
 
53
53
  | System | Class | Description |
54
54
  |--------|-------|-------------|
55
- | **StatePlane** | `Geodetic::Coordinate::StatePlane` | State Plane Coordinate System. US state-based coordinate systems using Lambert Conformal Conic or Transverse Mercator projections. Each state has one or more zones with specific parameters. Coordinates are typically in US Survey Feet. |
56
- | **BNG** | `Geodetic::Coordinate::BNG` | British National Grid. The official coordinate system for Great Britain, based on the OSGB36 datum with the Airy 1830 ellipsoid. Uses a Transverse Mercator projection and an alphanumeric grid reference system (e.g., "TQ 30 80"). |
55
+ | [**StatePlane**](state-plane.md) | `Geodetic::Coordinate::StatePlane` | State Plane Coordinate System. US state-based coordinate systems using Lambert Conformal Conic or Transverse Mercator projections. Each state has one or more zones with specific parameters. Coordinates are typically in US Survey Feet. |
56
+ | [**BNG**](bng.md) | `Geodetic::Coordinate::BNG` | British National Grid. The official coordinate system for Great Britain, based on the OSGB36 datum with the Airy 1830 ellipsoid. Uses a Transverse Mercator projection and an alphanumeric grid reference system (e.g., "TQ 30 80"). |
57
57
 
58
58
  ---
59
59