geodetic 0.5.0 → 0.5.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.
@@ -250,7 +250,7 @@ portland = Geodetic::Coordinate::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
250
250
  sf = Geodetic::Coordinate::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
251
251
 
252
252
  # Radial distances from receiver
253
- seattle.distance_to(portland) # => Distance (235393.17 m)
253
+ seattle.distance_to(portland) # => Distance (235385.71 m)
254
254
  seattle.distance_to(portland, sf) # => [Distance, Distance] (Array)
255
255
 
256
256
  # Consecutive chain distances
@@ -274,7 +274,7 @@ Both `distance_to` and `straight_line_distance_to` accept any coordinate type. C
274
274
  ```ruby
275
275
  utm = seattle.to_utm
276
276
  mgrs = Geodetic::Coordinate::MGRS.from_lla(portland)
277
- utm.distance_to(mgrs) # => Distance (235393.17 m)
277
+ utm.distance_to(mgrs) # => Distance (235385.71 m)
278
278
  ```
279
279
 
280
280
  ### ENU and NED (Relative Systems)
@@ -308,7 +308,7 @@ sf = Geodetic::Coordinate::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
308
308
 
309
309
  # Forward azimuth
310
310
  b = seattle.bearing_to(portland) # => Bearing
311
- b.degrees # => 188.2...
311
+ b.degrees # => 186.25...
312
312
  b.to_compass(points: 8) # => "S"
313
313
  b.reverse # => Bearing (back azimuth)
314
314
 
@@ -65,7 +65,7 @@ liberty.distance_to(LLA.new(lat: 40.7484, lng: -73.9857, alt: 0))
65
65
  Returns a `Geodetic::Bearing` from this feature to another feature, coordinate, or area. Uses the same centroid resolution as `distance_to`.
66
66
 
67
67
  ```ruby
68
- liberty.bearing_to(empire).degrees # => 36.99
68
+ liberty.bearing_to(empire).degrees # => 36.95
69
69
  liberty.bearing_to(empire).to_compass # => "NE"
70
70
  ```
71
71
 
@@ -0,0 +1,388 @@
1
+ # GeoJSON Export Reference
2
+
3
+ `Geodetic::GeoJSON` builds a [GeoJSON](https://datatracker.ietf.org/doc/html/rfc7946) FeatureCollection from any combination of Geodetic objects. It provides an accumulator pattern for collecting geometry, then exporting as a Hash, JSON string, or file.
4
+
5
+ Every geometry type also gains a `to_geojson` instance method that returns a GeoJSON-compatible Ruby Hash.
6
+
7
+ ---
8
+
9
+ ## GeoJSON Class
10
+
11
+ ### Constructor
12
+
13
+ ```ruby
14
+ gj = Geodetic::GeoJSON.new # empty collection
15
+ gj = Geodetic::GeoJSON.new(seattle, portland) # initialize with objects
16
+ gj = Geodetic::GeoJSON.new([seattle, portland]) # also accepts an array
17
+ ```
18
+
19
+ ### Accumulating Objects
20
+
21
+ Use `<<` to add a single object or an array of objects. Returns `self` for chaining.
22
+
23
+ ```ruby
24
+ gj = Geodetic::GeoJSON.new
25
+
26
+ gj << seattle # single coordinate
27
+ gj << [portland, sf, la] # array of coordinates
28
+ gj << Geodetic::Segment.new(a, b) # segment
29
+ gj << route # path
30
+ gj << polygon # area
31
+ gj << circle # circle (approximated)
32
+ gj << bbox # bounding box
33
+ gj << feature # feature (preserves properties)
34
+ ```
35
+
36
+ Any Geodetic object with a `to_geojson` method can be added. Non-Feature objects are auto-wrapped as GeoJSON Features with empty `properties`. Feature objects carry their `label` and `metadata` into the GeoJSON output.
37
+
38
+ ### Query
39
+
40
+ | Method | Returns | Description |
41
+ |----------|---------|-------------|
42
+ | `size` | Integer | Number of collected objects |
43
+ | `length` | Integer | Alias for `size` |
44
+ | `empty?` | Boolean | True if no objects collected |
45
+ | `each` | Enumerator | Iterate over collected objects |
46
+
47
+ `GeoJSON` includes `Enumerable`, so `map`, `select`, `reject`, `count`, `to_a`, and all other Enumerable methods are available.
48
+
49
+ ```ruby
50
+ gj.size # => 5
51
+ gj.empty? # => false
52
+ gj.map { |obj| obj.class } # => [LLA, LLA, Path, ...]
53
+ ```
54
+
55
+ ### Removing Objects
56
+
57
+ | Method | Description |
58
+ |---------------|-------------|
59
+ | `delete(obj)` | Remove a specific object from the collection |
60
+ | `clear` | Remove all objects |
61
+
62
+ Both return `self` for chaining.
63
+
64
+ ```ruby
65
+ gj.delete(portland)
66
+ gj.clear
67
+ ```
68
+
69
+ ### Export
70
+
71
+ | Method | Returns | Description |
72
+ |----------------------------|---------|-------------|
73
+ | `to_h` | Hash | GeoJSON FeatureCollection as a Ruby Hash |
74
+ | `to_json` | String | Compact JSON string |
75
+ | `to_json(pretty: true)` | String | Pretty-printed JSON string |
76
+ | `save(path)` | nil | Write compact JSON to file |
77
+ | `save(path, pretty: true)` | nil | Write pretty-printed JSON to file |
78
+
79
+ ```ruby
80
+ gj.to_h
81
+ # => {"type" => "FeatureCollection", "features" => [...]}
82
+
83
+ gj.to_json
84
+ # => '{"type":"FeatureCollection","features":[...]}'
85
+
86
+ gj.to_json(pretty: true)
87
+ # => formatted JSON with indentation
88
+
89
+ gj.save("my_map.geojson")
90
+ gj.save("my_map.geojson", pretty: true)
91
+ ```
92
+
93
+ The `to_json` and `save` methods use Ruby's built-in `json` library (a default gem, always available). No external dependencies are required.
94
+
95
+ ### Display
96
+
97
+ ```ruby
98
+ gj.to_s # => "GeoJSON::FeatureCollection(5 features)"
99
+ gj.inspect # => "#<Geodetic::GeoJSON size=5>"
100
+ ```
101
+
102
+ ### Import
103
+
104
+ | Method | Returns | Description |
105
+ |--------|---------|-------------|
106
+ | `GeoJSON.load(path)` | Array | Read a GeoJSON file and return an Array of Geodetic objects |
107
+ | `GeoJSON.parse(hash)` | Array | Parse a GeoJSON Hash and return an Array of Geodetic objects |
108
+
109
+ ```ruby
110
+ objects = Geodetic::GeoJSON.load("west_coast.geojson")
111
+ # => [Feature("Seattle", LLA), Feature("Portland", LLA), Segment, Polygon, ...]
112
+ ```
113
+
114
+ `parse` accepts a Ruby Hash (useful when you already have parsed JSON):
115
+
116
+ ```ruby
117
+ data = JSON.parse(File.read("west_coast.geojson"))
118
+ objects = Geodetic::GeoJSON.parse(data)
119
+ ```
120
+
121
+ **GeoJSON → Geodetic type mapping:**
122
+
123
+ | GeoJSON type | Geodetic type |
124
+ |--------------|---------------|
125
+ | Point | `Coordinate::LLA` |
126
+ | LineString (2 points) | `Segment` |
127
+ | LineString (3+ points) | `Path` |
128
+ | Polygon | `Areas::Polygon` (outer ring only; holes are dropped) |
129
+ | MultiPoint | Multiple `Coordinate::LLA` |
130
+ | MultiLineString | Multiple `Segment` or `Path` |
131
+ | MultiPolygon | Multiple `Areas::Polygon` |
132
+ | GeometryCollection | Flattened into individual geometries |
133
+
134
+ **Feature handling:**
135
+
136
+ - A GeoJSON Feature with a `"name"` property or any non-empty properties becomes a `Geodetic::Feature`. The `"name"` property maps to `label`, and remaining properties become `metadata` with symbol keys.
137
+ - A GeoJSON Feature with empty properties (`{}`) returns the raw geometry with no Feature wrapper.
138
+
139
+ This means a save/load roundtrip preserves Feature labels, metadata, and geometry types:
140
+
141
+ ```ruby
142
+ # Save
143
+ gj = Geodetic::GeoJSON.new
144
+ gj << Geodetic::Feature.new(label: "Seattle", geometry: seattle, metadata: { state: "WA" })
145
+ gj << portland # raw coordinate, no Feature
146
+ gj.save("cities.geojson")
147
+
148
+ # Load
149
+ objects = Geodetic::GeoJSON.load("cities.geojson")
150
+ objects[0] # => Feature (label: "Seattle", metadata: {state: "WA"})
151
+ objects[0].label # => "Seattle"
152
+ objects[0].metadata # => {state: "WA"}
153
+ objects[1] # => LLA (raw coordinate, no Feature wrapper)
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Geometry Mapping
159
+
160
+ Each Geodetic geometry type maps to a specific GeoJSON geometry type:
161
+
162
+ | Geodetic Type | GeoJSON Type | Notes |
163
+ |---------------|-------------|-------|
164
+ | Any coordinate (LLA, UTM, ECEF, ...) | Point | Converts through LLA |
165
+ | `Segment` | LineString | 2 positions |
166
+ | `Path` | LineString | N positions (default) |
167
+ | `Path` (with `as: :polygon`) | Polygon | Auto-closes the ring |
168
+ | `Areas::Polygon` (and subclasses) | Polygon | Boundary ring already closed |
169
+ | `Areas::Circle` | Polygon | Approximated as N-gon (default 32) |
170
+ | `Areas::BoundingBox` | Polygon | 4 corners, closed |
171
+ | `Feature` | Feature | Geometry + properties |
172
+
173
+ ---
174
+
175
+ ## Individual `to_geojson` Methods
176
+
177
+ ### Coordinates
178
+
179
+ All 18 coordinate classes gain a `to_geojson` method. It converts the coordinate to LLA and returns a GeoJSON Point.
180
+
181
+ ```ruby
182
+ seattle = Geodetic::Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
183
+ seattle.to_geojson
184
+ # => {"type" => "Point", "coordinates" => [-122.3493, 47.6205]}
185
+ ```
186
+
187
+ **Altitude handling:** The GeoJSON position array is `[lng, lat]` when altitude is zero, and `[lng, lat, alt]` when altitude is non-zero.
188
+
189
+ ```ruby
190
+ Geodetic::Coordinate::LLA.new(lat: 47.62, lng: -122.35, alt: 184.0).to_geojson
191
+ # => {"type" => "Point", "coordinates" => [-122.35, 47.62, 184.0]}
192
+ ```
193
+
194
+ **Cross-system:** Any coordinate type works — UTM, ECEF, MGRS, GH, etc. are converted to LLA internally.
195
+
196
+ ```ruby
197
+ seattle.to_utm.to_geojson # => {"type" => "Point", ...}
198
+ seattle.to_mgrs.to_geojson # => {"type" => "Point", ...}
199
+ ```
200
+
201
+ **ENU and NED:** These are relative coordinate systems with no absolute position. Calling `to_geojson` raises `ArgumentError` with a message explaining that conversion to an absolute system is required first.
202
+
203
+ ```ruby
204
+ enu = Geodetic::Coordinate::ENU.new(e: 100, n: 200, u: 10)
205
+ enu.to_geojson # => ArgumentError
206
+ ```
207
+
208
+ ---
209
+
210
+ ### Segment
211
+
212
+ Returns a GeoJSON LineString with two positions.
213
+
214
+ ```ruby
215
+ seg = Geodetic::Segment.new(seattle, portland)
216
+ seg.to_geojson
217
+ # => {"type" => "LineString", "coordinates" => [[-122.3493, 47.6205], [-122.6784, 45.5152]]}
218
+ ```
219
+
220
+ ---
221
+
222
+ ### Path
223
+
224
+ Returns a GeoJSON LineString by default. Requires at least 2 coordinates.
225
+
226
+ ```ruby
227
+ route = Geodetic::Path.new(coordinates: [seattle, portland, sf])
228
+ route.to_geojson
229
+ # => {"type" => "LineString", "coordinates" => [[...], [...], [...]]}
230
+ ```
231
+
232
+ **As polygon:** Pass `as: :polygon` to export as a closed GeoJSON Polygon. Requires at least 3 coordinates. The ring is auto-closed if the first and last coordinates differ.
233
+
234
+ ```ruby
235
+ route.to_geojson(as: :polygon)
236
+ # => {"type" => "Polygon", "coordinates" => [[[...], [...], [...], [...]]]}
237
+ ```
238
+
239
+ **Edge cases:**
240
+
241
+ | Condition | Behavior |
242
+ |-----------|----------|
243
+ | Empty path | Raises `ArgumentError` |
244
+ | 1 coordinate (line_string) | Raises `ArgumentError` |
245
+ | 2 coordinates (polygon) | Raises `ArgumentError` |
246
+
247
+ ---
248
+
249
+ ### Areas::Polygon
250
+
251
+ Returns a GeoJSON Polygon. The boundary ring is already closed by `Polygon#initialize`.
252
+
253
+ ```ruby
254
+ poly = Geodetic::Areas::Polygon.new(boundary: [a, b, c])
255
+ poly.to_geojson
256
+ # => {"type" => "Polygon", "coordinates" => [[[...], [...], [...], [...]]]}
257
+ ```
258
+
259
+ All Polygon subclasses (`Triangle`, `Rectangle`, `Pentagon`, `Hexagon`, `Octagon`) inherit this method.
260
+
261
+ ---
262
+
263
+ ### Areas::Circle
264
+
265
+ Returns a GeoJSON Polygon approximating the circle as a regular N-gon. Default is 32 segments.
266
+
267
+ ```ruby
268
+ circle = Geodetic::Areas::Circle.new(centroid: seattle, radius: 10_000)
269
+
270
+ circle.to_geojson # => 32-gon (33 positions including closing)
271
+ circle.to_geojson(segments: 64) # => 64-gon (65 positions including closing)
272
+ circle.to_geojson(segments: 8) # => 8-gon (9 positions including closing)
273
+ ```
274
+
275
+ The vertices are computed using `Geodetic::Vector#destination_from` (Vincenty direct), so the approximation is geodetically accurate.
276
+
277
+ ---
278
+
279
+ ### Areas::BoundingBox
280
+
281
+ Returns a GeoJSON Polygon with 4 corners plus the closing point (5 positions total). Ring order follows the GeoJSON right-hand rule: NW → NE → SE → SW → NW.
282
+
283
+ ```ruby
284
+ bbox = Geodetic::Areas::BoundingBox.new(
285
+ nw: Geodetic::Coordinate::LLA.new(lat: 48.0, lng: -123.0, alt: 0),
286
+ se: Geodetic::Coordinate::LLA.new(lat: 46.0, lng: -121.0, alt: 0)
287
+ )
288
+ bbox.to_geojson
289
+ # => {"type" => "Polygon", "coordinates" => [[[-123.0, 48.0], [-121.0, 48.0], [-121.0, 46.0], [-123.0, 46.0], [-123.0, 48.0]]]}
290
+ ```
291
+
292
+ ---
293
+
294
+ ### Feature
295
+
296
+ Returns a GeoJSON Feature. The `label` is mapped to the `"name"` property. The `metadata` hash is merged into `properties` with keys converted to strings.
297
+
298
+ ```ruby
299
+ f = Geodetic::Feature.new(
300
+ label: "Seattle",
301
+ geometry: seattle,
302
+ metadata: { state: "WA", population: 750_000 }
303
+ )
304
+ f.to_geojson
305
+ # => {
306
+ # "type" => "Feature",
307
+ # "geometry" => {"type" => "Point", "coordinates" => [-122.3493, 47.6205]},
308
+ # "properties" => {"name" => "Seattle", "state" => "WA", "population" => 750000}
309
+ # }
310
+ ```
311
+
312
+ | Feature field | GeoJSON mapping |
313
+ |---------------|-----------------|
314
+ | `label` | `properties["name"]` (omitted if `nil`) |
315
+ | `metadata` | Merged into `properties` (symbol keys stringified) |
316
+ | `geometry` | Delegates to the geometry's `to_geojson` |
317
+
318
+ The geometry can be any type: coordinate, segment, path, polygon, circle, or bounding box.
319
+
320
+ ```ruby
321
+ # Feature wrapping a Path
322
+ route_feature = Geodetic::Feature.new(
323
+ label: "West Coast Route",
324
+ geometry: route,
325
+ metadata: { mode: "driving" }
326
+ )
327
+ route_feature.to_geojson
328
+ # => {"type" => "Feature", "geometry" => {"type" => "LineString", ...}, "properties" => {...}}
329
+ ```
330
+
331
+ ---
332
+
333
+ ## Complete Example
334
+
335
+ ```ruby
336
+ require "geodetic"
337
+
338
+ seattle = Geodetic::Coordinate::LLA.new(lat: 47.6205, lng: -122.3493, alt: 0)
339
+ portland = Geodetic::Coordinate::LLA.new(lat: 45.5152, lng: -122.6784, alt: 0)
340
+ sf = Geodetic::Coordinate::LLA.new(lat: 37.7749, lng: -122.4194, alt: 0)
341
+
342
+ # Build a collection
343
+ gj = Geodetic::GeoJSON.new
344
+
345
+ # Add cities as features with metadata
346
+ gj << Geodetic::Feature.new(label: "Seattle", geometry: seattle, metadata: { pop: 750_000 })
347
+ gj << Geodetic::Feature.new(label: "Portland", geometry: portland, metadata: { pop: 650_000 })
348
+ gj << Geodetic::Feature.new(label: "San Francisco", geometry: sf, metadata: { pop: 870_000 })
349
+
350
+ # Add the route connecting them
351
+ route = Geodetic::Path.new(coordinates: [seattle, portland, sf])
352
+ gj << Geodetic::Feature.new(label: "West Coast Route", geometry: route)
353
+
354
+ # Add a 50km radius around Seattle
355
+ gj << Geodetic::Feature.new(
356
+ label: "Seattle Metro",
357
+ geometry: Geodetic::Areas::Circle.new(centroid: seattle, radius: 50_000)
358
+ )
359
+
360
+ # Export
361
+ gj.save("west_coast.geojson", pretty: true)
362
+ ```
363
+
364
+ The output file can be opened directly in [geojson.io](https://geojson.io), QGIS, Mapbox, Leaflet, or any other GeoJSON-compatible tool.
365
+
366
+ ---
367
+
368
+ ## GeoJSON Specification Notes
369
+
370
+ - **Coordinate order** is `[longitude, latitude]` (not `[lat, lng]`), per [RFC 7946 Section 3.1.1](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1).
371
+ - **Altitude** is optional. Included as the third element when non-zero.
372
+ - **Polygon rings** follow the right-hand rule: exterior rings are counterclockwise. BoundingBox uses NW → NE → SE → SW → NW.
373
+ - **String keys** are used throughout (`"type"`, `"coordinates"`, `"properties"`, etc.) per JSON convention.
374
+
375
+ ---
376
+
377
+ ## Visualizing GeoJSON
378
+
379
+ The easiest way to verify your exported GeoJSON is [geojson.io](https://geojson.io). It renders points, lines, and polygons on an interactive map with property inspection.
380
+
381
+ To use it:
382
+
383
+ 1. Export your collection: `gj.save("my_map.geojson", pretty: true)`
384
+ 2. Open [geojson.io](https://geojson.io) in a browser
385
+ 3. Drag and drop the `.geojson` file onto the map, or paste the JSON into the editor panel
386
+
387
+ Feature properties (name, metadata) appear in popups when you click on a rendered geometry. This makes it a quick way to confirm that coordinates, shapes, and metadata are correct before integrating with QGIS, Mapbox, Leaflet, or other GIS tools.
388
+ - Output is a Ruby Hash. Call `.to_json` or `JSON.generate(hash)` to produce a JSON string.
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Demonstration of GeoJSON Export
4
+ # Shows how to build a GeoJSON FeatureCollection from mixed Geodetic objects
5
+ # and save it to a file that can be opened in any GeoJSON viewer.
6
+
7
+ require_relative "../lib/geodetic"
8
+
9
+ include Geodetic
10
+
11
+ LLA = Coordinate::LLA
12
+ Distance = Geodetic::Distance
13
+ Vector = Geodetic::Vector
14
+
15
+ puts "=== GeoJSON Export Demo ==="
16
+ puts
17
+
18
+ # ── 1. Individual to_geojson on each geometry type ─────────────────
19
+
20
+ puts "--- 1. Coordinate → GeoJSON Point ---"
21
+ puts
22
+
23
+ seattle = LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
24
+ puts " seattle.to_geojson"
25
+ puts " => #{seattle.to_geojson}"
26
+ puts
27
+
28
+ # Works from any coordinate system — converts through LLA
29
+ utm = seattle.to_utm
30
+ puts " seattle.to_utm.to_geojson"
31
+ puts " => #{utm.to_geojson}"
32
+ puts
33
+
34
+ # Altitude included when non-zero
35
+ space_needle = LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
36
+ puts " With altitude: #{space_needle.to_geojson}"
37
+ puts
38
+
39
+ # ── 2. Segment → LineString ───────────────────────────────────────
40
+
41
+ puts "--- 2. Segment → GeoJSON LineString ---"
42
+ puts
43
+
44
+ portland = LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
45
+ seg = Segment.new(seattle, portland)
46
+ puts " Segment(seattle → portland).to_geojson"
47
+ puts " => #{seg.to_geojson}"
48
+ puts
49
+
50
+ # ── 3. Path → LineString or Polygon ───────────────────────────────
51
+
52
+ puts "--- 3. Path → GeoJSON LineString ---"
53
+ puts
54
+
55
+ sf = LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
56
+ la = LLA.new(lat: 34.0522, lng: -118.2437, alt: 0.0)
57
+ route = Path.new(coordinates: [seattle, portland, sf, la])
58
+
59
+ geojson = route.to_geojson
60
+ puts " Path(4 waypoints).to_geojson"
61
+ puts " type: #{geojson["type"]}, points: #{geojson["coordinates"].length}"
62
+ puts
63
+
64
+ # Path can also export as a polygon
65
+ triangle_path = Path.new(coordinates: [
66
+ LLA.new(lat: 47.0, lng: -122.5, alt: 0),
67
+ LLA.new(lat: 46.0, lng: -121.0, alt: 0),
68
+ LLA.new(lat: 46.0, lng: -123.0, alt: 0)
69
+ ])
70
+ poly_geojson = triangle_path.to_geojson(as: :polygon)
71
+ puts " Path(3 points).to_geojson(as: :polygon)"
72
+ puts " type: #{poly_geojson["type"]}, ring closed: #{poly_geojson["coordinates"][0].first == poly_geojson["coordinates"][0].last}"
73
+ puts
74
+
75
+ # ── 4. Areas → Polygon ────────────────────────────────────────────
76
+
77
+ puts "--- 4. Areas → GeoJSON Polygon ---"
78
+ puts
79
+
80
+ # Polygon
81
+ a = LLA.new(lat: 47.7, lng: -122.5, alt: 0)
82
+ b = LLA.new(lat: 47.5, lng: -122.1, alt: 0)
83
+ c = LLA.new(lat: 47.3, lng: -122.4, alt: 0)
84
+ poly = Areas::Polygon.new(boundary: [a, b, c])
85
+ puts " Polygon(3 vertices).to_geojson"
86
+ puts " type: #{poly.to_geojson["type"]}"
87
+
88
+ # Circle (approximated as 32-gon)
89
+ circle = Areas::Circle.new(centroid: seattle, radius: 10_000)
90
+ circle_gj = circle.to_geojson
91
+ ring_size = circle_gj["coordinates"][0].length
92
+ puts " Circle(10km).to_geojson → #{ring_size - 1}-gon (#{ring_size} points including closing)"
93
+
94
+ # Circle with custom resolution
95
+ circle_gj_64 = circle.to_geojson(segments: 64)
96
+ puts " Circle(10km).to_geojson(segments: 64) → #{circle_gj_64["coordinates"][0].length - 1}-gon"
97
+
98
+ # BoundingBox
99
+ bbox = Areas::BoundingBox.new(
100
+ nw: LLA.new(lat: 48.0, lng: -123.0, alt: 0),
101
+ se: LLA.new(lat: 46.0, lng: -121.0, alt: 0)
102
+ )
103
+ puts " BoundingBox.to_geojson → #{bbox.to_geojson["coordinates"][0].length} points (4 corners + closing)"
104
+ puts
105
+
106
+ # ── 5. Feature → GeoJSON Feature with properties ──────────────────
107
+
108
+ puts "--- 5. Feature → GeoJSON Feature ---"
109
+ puts
110
+
111
+ city = Feature.new(
112
+ label: "Seattle",
113
+ geometry: seattle,
114
+ metadata: { state: "WA", population: 750_000, timezone: "PST" }
115
+ )
116
+ feature_gj = city.to_geojson
117
+ puts " Feature('Seattle').to_geojson"
118
+ puts " type: #{feature_gj["type"]}"
119
+ puts " geometry: #{feature_gj["geometry"]["type"]}"
120
+ puts " properties: #{feature_gj["properties"]}"
121
+ puts
122
+
123
+ # Feature wrapping a polygon
124
+ park = Feature.new(
125
+ label: "Triangle Park",
126
+ geometry: poly,
127
+ metadata: { type: "park", area_sqkm: 12.5 }
128
+ )
129
+ park_gj = park.to_geojson
130
+ puts " Feature('Triangle Park', polygon).to_geojson"
131
+ puts " geometry: #{park_gj["geometry"]["type"]}"
132
+ puts " properties: #{park_gj["properties"]}"
133
+ puts
134
+
135
+ # ── 6. Building a FeatureCollection ───────────────────────────────
136
+
137
+ puts "--- 6. GeoJSON FeatureCollection ---"
138
+ puts
139
+
140
+ gj = GeoJSON.new
141
+ puts " gj = GeoJSON.new"
142
+ puts " gj.size: #{gj.size}, empty? #{gj.empty?}"
143
+ puts
144
+
145
+ # Add cities as features
146
+ cities = {
147
+ "Seattle" => LLA.new(lat: 47.6205, lng: -122.3493, alt: 0),
148
+ "Portland" => LLA.new(lat: 45.5152, lng: -122.6784, alt: 0),
149
+ "San Francisco" => LLA.new(lat: 37.7749, lng: -122.4194, alt: 0),
150
+ "Los Angeles" => LLA.new(lat: 34.0522, lng: -118.2437, alt: 0),
151
+ "New York" => LLA.new(lat: 40.7128, lng: -74.0060, alt: 0)
152
+ }
153
+
154
+ cities.each do |name, coord|
155
+ gj << Feature.new(label: name, geometry: coord, metadata: { type: "city" })
156
+ end
157
+ puts " Added #{cities.size} cities as Features"
158
+
159
+ # Add a route as a path
160
+ west_coast = Path.new(coordinates: cities.values_at("Seattle", "Portland", "San Francisco", "Los Angeles"))
161
+ gj << Feature.new(label: "West Coast Route", geometry: west_coast, metadata: { mode: "driving" })
162
+ puts " Added West Coast route (Path with 4 waypoints)"
163
+
164
+ # Add an array of raw coordinates (auto-wrapped as Features)
165
+ landmarks = [
166
+ LLA.new(lat: 48.8566, lng: 2.3522, alt: 0), # Paris
167
+ LLA.new(lat: 51.5074, lng: -0.1278, alt: 0) # London
168
+ ]
169
+ gj << landmarks
170
+ puts " Added 2 landmarks via << [array]"
171
+
172
+ # Add a bounding box and a circle
173
+ gj << Feature.new(label: "Pacific NW", geometry: bbox, metadata: { region: true })
174
+ gj << Feature.new(label: "Seattle Metro", geometry: circle, metadata: { radius_km: 10 })
175
+ puts " Added bounding box and circle"
176
+
177
+ puts
178
+ puts " Total items: #{gj.size}"
179
+ puts " to_s: #{gj}"
180
+ puts
181
+
182
+ # ── 7. Initialize with objects ────────────────────────────────────
183
+
184
+ puts "--- 7. Initialize with objects ---"
185
+ puts
186
+
187
+ gj2 = GeoJSON.new(seattle, portland, sf)
188
+ puts " GeoJSON.new(seattle, portland, sf) → size: #{gj2.size}"
189
+
190
+ gj3 = GeoJSON.new([seattle, portland])
191
+ puts " GeoJSON.new([seattle, portland]) → size: #{gj3.size}"
192
+ puts
193
+
194
+ # ── 8. Delete and Clear ───────────────────────────────────────────
195
+
196
+ puts "--- 8. Delete and Clear ---"
197
+ puts
198
+
199
+ gj4 = GeoJSON.new(seattle, portland, sf)
200
+ puts " Before delete: #{gj4.size}"
201
+ gj4.delete(portland)
202
+ puts " After delete(portland): #{gj4.size}"
203
+ gj4.clear
204
+ puts " After clear: #{gj4.size}, empty? #{gj4.empty?}"
205
+ puts
206
+
207
+ # ── 9. Enumerable ─────────────────────────────────────────────────
208
+
209
+ puts "--- 9. Enumerable ---"
210
+ puts
211
+
212
+ gj5 = GeoJSON.new(seattle, portland, sf)
213
+ puts " Iterating:"
214
+ gj5.each { |obj| puts " #{obj.class}: #{obj.to_s(4)}" }
215
+ puts " map to classes: #{gj5.map(&:class).map(&:name)}"
216
+ puts
217
+
218
+ # ── 10. Export to Hash, JSON, and File ─────────────────────────────
219
+
220
+ puts "--- 10. Export ---"
221
+ puts
222
+
223
+ # to_h
224
+ h = gj.to_h
225
+ puts " to_h:"
226
+ puts " type: #{h["type"]}"
227
+ puts " features: #{h["features"].length}"
228
+ puts " geometry types: #{h["features"].map { |f| f["geometry"]["type"] }.tally}"
229
+ puts
230
+
231
+ # to_json (compact)
232
+ json = gj.to_json
233
+ puts " to_json (compact): #{json.length} bytes"
234
+
235
+ # to_json (pretty)
236
+ pretty = gj.to_json(pretty: true)
237
+ puts " to_json(pretty: true): #{pretty.length} bytes, #{pretty.lines.count} lines"
238
+ puts
239
+
240
+ # save to file
241
+ output_path = File.join(__dir__, "geodetic_demo.geojson")
242
+ gj.save(output_path, pretty: true)
243
+ puts " Saved to: #{output_path}"
244
+ puts " File size: #{File.size(output_path)} bytes"
245
+ puts
246
+
247
+ # Show first few lines of the pretty-printed output
248
+ puts " Preview (first 15 lines):"
249
+ pretty.lines.first(15).each { |line| puts " #{line}" }
250
+ puts " ..."
251
+ puts
252
+
253
+ puts "=== Done ==="
254
+ puts
255
+ puts "Open #{output_path} in https://geojson.io to visualize the data."
data/examples/README.md CHANGED
@@ -117,3 +117,17 @@ Demonstrates the operator-based geometry system and the `Geodetic::Vector` class
117
117
  - **Key distinction: + vs \*** where `P + V` returns a Segment (the journey) and `P * V` returns a Coordinate (the destination)
118
118
  - **Path corridors** with `to_corridor(width:)` converting a path into a polygon, and translating the corridor
119
119
  - **Composing operations** chaining arithmetic, vector math, and corridors in single expressions
120
+
121
+ ## 09 - GeoJSON Export
122
+
123
+ Demonstrates the `Geodetic::GeoJSON` class for building and exporting GeoJSON FeatureCollections. Covers:
124
+
125
+ - **Coordinate → Point** with `to_geojson` on any coordinate system, including altitude handling
126
+ - **Segment → LineString** exporting two-point directed segments
127
+ - **Path → LineString** and optional `to_geojson(as: :polygon)` for closed paths
128
+ - **Areas → Polygon** for `Polygon`, `Circle` (32-gon approximation with configurable `segments:`), and `BoundingBox`
129
+ - **Feature → GeoJSON Feature** with `label` mapped to `"name"` and `metadata` merged into `properties`
130
+ - **FeatureCollection building** with `GeoJSON.new`, `<<` for single objects and arrays, and `GeoJSON.new(obj, ...)` initialization
131
+ - **Delete and clear** removing individual objects or emptying the collection
132
+ - **Enumerable** iteration over collected objects
133
+ - **Export** via `to_h` (Ruby Hash), `to_json`/`to_json(pretty: true)` (JSON string), and `save(path, pretty:)` (file output)