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.
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Geodetic
6
+ class GeoJSON
7
+ include Enumerable
8
+
9
+ def initialize(*objects)
10
+ @objects = []
11
+ list = objects.length == 1 && objects[0].is_a?(Array) ? objects[0] : objects
12
+ list.each { |obj| add(obj) }
13
+ end
14
+
15
+ # --- Accumulate ---
16
+
17
+ def <<(object)
18
+ if object.is_a?(Array)
19
+ object.each { |obj| add(obj) }
20
+ else
21
+ add(object)
22
+ end
23
+ self
24
+ end
25
+
26
+ # --- Query ---
27
+
28
+ def size
29
+ @objects.size
30
+ end
31
+
32
+ alias length size
33
+
34
+ def empty?
35
+ @objects.empty?
36
+ end
37
+
38
+ def each(&block)
39
+ @objects.each(&block)
40
+ end
41
+
42
+ # --- Remove ---
43
+
44
+ def delete(object)
45
+ @objects.delete(object)
46
+ self
47
+ end
48
+
49
+ def clear
50
+ @objects.clear
51
+ self
52
+ end
53
+
54
+ # --- Export ---
55
+
56
+ def to_h
57
+ {
58
+ "type" => "FeatureCollection",
59
+ "features" => @objects.map { |obj| wrap_as_feature(obj) }
60
+ }
61
+ end
62
+
63
+ def to_json(pretty: false)
64
+ if pretty
65
+ JSON.pretty_generate(to_h)
66
+ else
67
+ JSON.generate(to_h)
68
+ end
69
+ end
70
+
71
+ def save(path, pretty: false)
72
+ File.write(path, to_json(pretty: pretty))
73
+ end
74
+
75
+ # --- Display ---
76
+
77
+ def to_s
78
+ "GeoJSON::FeatureCollection(#{size} features)"
79
+ end
80
+
81
+ def inspect
82
+ "#<Geodetic::GeoJSON size=#{size}>"
83
+ end
84
+
85
+ private
86
+
87
+ def add(object)
88
+ @objects << object
89
+ end
90
+
91
+ def wrap_as_feature(obj)
92
+ if obj.is_a?(Feature)
93
+ obj.to_geojson
94
+ else
95
+ { "type" => "Feature", "geometry" => obj.to_geojson, "properties" => {} }
96
+ end
97
+ end
98
+
99
+ # ---------------------------------------------------------------
100
+ # Geometry helpers (module-level, used by to_geojson on each type)
101
+ # ---------------------------------------------------------------
102
+
103
+ class << self
104
+ # --- Export helpers ---
105
+
106
+ def position(lla)
107
+ if lla.alt != 0.0
108
+ [lla.lng, lla.lat, lla.alt]
109
+ else
110
+ [lla.lng, lla.lat]
111
+ end
112
+ end
113
+
114
+ def point_hash(lla)
115
+ { "type" => "Point", "coordinates" => position(lla) }
116
+ end
117
+
118
+ def line_string_hash(lla_array)
119
+ { "type" => "LineString", "coordinates" => lla_array.map { |p| position(p) } }
120
+ end
121
+
122
+ def polygon_hash(rings)
123
+ { "type" => "Polygon", "coordinates" => rings.map { |ring| ring.map { |p| position(p) } } }
124
+ end
125
+
126
+ # --- Import ---
127
+
128
+ def load(path)
129
+ data = JSON.parse(File.read(path))
130
+ parse(data)
131
+ end
132
+
133
+ def parse(data)
134
+ case data["type"]
135
+ when "FeatureCollection"
136
+ data["features"].flat_map { |f| parse(f) }
137
+ when "Feature"
138
+ parse_feature(data)
139
+ when "GeometryCollection"
140
+ data["geometries"].flat_map { |g| parse_geometry(g) }
141
+ else
142
+ [parse_geometry(data)]
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def parse_feature(data)
149
+ geometry = parse_geometry(data["geometry"])
150
+ properties = data["properties"] || {}
151
+
152
+ results = geometry.is_a?(Array) ? geometry : [geometry]
153
+
154
+ results.map do |geom|
155
+ name = properties["name"]
156
+ metadata = properties.reject { |k, _| k == "name" }
157
+
158
+ if name || !metadata.empty?
159
+ Feature.new(
160
+ label: name,
161
+ geometry: geom,
162
+ metadata: metadata.transform_keys(&:to_sym)
163
+ )
164
+ else
165
+ geom
166
+ end
167
+ end
168
+ end
169
+
170
+ def parse_geometry(data)
171
+ case data["type"]
172
+ when "Point"
173
+ parse_point(data["coordinates"])
174
+ when "LineString"
175
+ parse_line_string(data["coordinates"])
176
+ when "Polygon"
177
+ parse_polygon(data["coordinates"])
178
+ when "MultiPoint"
179
+ data["coordinates"].map { |pos| parse_point(pos) }
180
+ when "MultiLineString"
181
+ data["coordinates"].map { |coords| parse_line_string(coords) }
182
+ when "MultiPolygon"
183
+ data["coordinates"].map { |rings| parse_polygon(rings) }
184
+ when "GeometryCollection"
185
+ data["geometries"].flat_map { |g| parse_geometry(g) }
186
+ else
187
+ raise ArgumentError, "unknown GeoJSON geometry type: #{data["type"]}"
188
+ end
189
+ end
190
+
191
+ def parse_point(coords)
192
+ lng, lat = coords[0], coords[1]
193
+ alt = coords[2] || 0.0
194
+ Coordinate::LLA.new(lat: lat, lng: lng, alt: alt)
195
+ end
196
+
197
+ def parse_line_string(coords)
198
+ points = coords.map { |pos| parse_point(pos) }
199
+ if points.length == 2
200
+ Segment.new(points[0], points[1])
201
+ else
202
+ Path.new(coordinates: points)
203
+ end
204
+ end
205
+
206
+ def parse_polygon(rings)
207
+ outer = rings[0].map { |pos| parse_point(pos) }
208
+ # Remove closing point if it duplicates the first (Polygon#initialize adds it)
209
+ outer.pop if outer.length > 1 && outer.first == outer.last
210
+ Areas::Polygon.new(boundary: outer)
211
+ end
212
+ end
213
+
214
+ # ---------------------------------------------------------------
215
+ # Mixin for coordinate classes (applied at bottom of this file)
216
+ # ---------------------------------------------------------------
217
+
218
+ module CoordinateMethods
219
+ def to_geojson
220
+ if is_a?(Coordinate::ENU) || is_a?(Coordinate::NED)
221
+ raise ArgumentError,
222
+ "#{self.class.name.split('::').last} is a relative coordinate system " \
223
+ "and cannot be exported to GeoJSON without a reference point. " \
224
+ "Convert to an absolute system (e.g., LLA) first."
225
+ end
226
+
227
+ lla = is_a?(Coordinate::LLA) ? self : to_lla
228
+ GeoJSON.point_hash(lla)
229
+ end
230
+ end
231
+ end
232
+ end
233
+
234
+ # ---------------------------------------------------------------
235
+ # Add to_geojson to non-coordinate geometry types
236
+ # ---------------------------------------------------------------
237
+
238
+ module Geodetic
239
+ class Segment
240
+ def to_geojson
241
+ GeoJSON.line_string_hash([@start_point, @end_point])
242
+ end
243
+ end
244
+
245
+ class Path
246
+ def to_geojson(as: :line_string)
247
+ raise ArgumentError, "path is empty" if empty?
248
+
249
+ if as == :polygon
250
+ raise ArgumentError, "need at least 3 coordinates for a polygon" if size < 3
251
+ ring = @coordinates.dup
252
+ ring << ring.first unless ring.first == ring.last
253
+ GeoJSON.polygon_hash([ring])
254
+ else
255
+ raise ArgumentError, "need at least 2 coordinates for a line string" if size < 2
256
+ GeoJSON.line_string_hash(@coordinates)
257
+ end
258
+ end
259
+ end
260
+
261
+ class Feature
262
+ def to_geojson
263
+ properties = {}
264
+ properties["name"] = @label if @label
265
+ properties.merge!(@metadata.transform_keys(&:to_s)) if @metadata && !@metadata.empty?
266
+ { "type" => "Feature", "geometry" => @geometry.to_geojson, "properties" => properties }
267
+ end
268
+ end
269
+
270
+ module Areas
271
+ class Polygon
272
+ def to_geojson
273
+ GeoJSON.polygon_hash([@boundary])
274
+ end
275
+ end
276
+
277
+ class Circle
278
+ def to_geojson(segments: 32)
279
+ step = 360.0 / segments
280
+ ring = segments.times.map do |i|
281
+ Vector.new(distance: @radius, bearing: step * i).destination_from(@centroid)
282
+ end
283
+ ring << ring.first
284
+ GeoJSON.polygon_hash([ring])
285
+ end
286
+ end
287
+
288
+ class BoundingBox
289
+ def to_geojson
290
+ ring = [nw, ne, @se, sw, nw]
291
+ GeoJSON.polygon_hash([ring])
292
+ end
293
+ end
294
+ end
295
+ end
296
+
297
+ # ---------------------------------------------------------------
298
+ # Apply coordinate mixin to all registered coordinate classes
299
+ # ---------------------------------------------------------------
300
+
301
+ Geodetic::Coordinate.systems.each do |klass|
302
+ klass.include(Geodetic::GeoJSON::CoordinateMethods)
303
+ end
data/lib/geodetic/path.rb CHANGED
@@ -161,8 +161,9 @@ module Geodetic
161
161
  def bounds
162
162
  raise ArgumentError, "path is empty" if empty?
163
163
 
164
- lats = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lat : c.to_lla.lat }
165
- lngs = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lng : c.to_lla.lng }
164
+ llas = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c : c.to_lla }
165
+ lats = llas.map(&:lat)
166
+ lngs = llas.map(&:lng)
166
167
 
167
168
  Areas::BoundingBox.new(
168
169
  nw: Coordinate::LLA.new(lat: lats.max, lng: lngs.min, alt: 0),
@@ -186,6 +187,8 @@ module Geodetic
186
187
  def intersects?(other_path)
187
188
  raise ArgumentError, "expected a Path" unless other_path.is_a?(Path)
188
189
 
190
+ return false unless bounds_overlap?(bounds, other_path.bounds)
191
+
189
192
  segments.each do |seg1|
190
193
  other_path.segments.each do |seg2|
191
194
  return true if seg1.intersects?(seg2)
@@ -494,19 +497,6 @@ module Geodetic
494
497
  end
495
498
  end
496
499
 
497
- def resolve_point_from(other)
498
- case other
499
- when Feature
500
- geo = other.geometry
501
- geo.respond_to?(:centroid) ? geo.centroid : geo
502
- when Path
503
- raise ArgumentError, "path is empty" if other.empty?
504
- other.first
505
- else
506
- other.respond_to?(:centroid) ? other.centroid : other
507
- end
508
- end
509
-
510
500
  def dup_path
511
501
  self.class.new(coordinates: @coordinates.dup)
512
502
  end
@@ -519,6 +509,13 @@ module Geodetic
519
509
  Vector.new(distance: distance_m, bearing: bearing_deg).destination_from(lla)
520
510
  end
521
511
 
512
+ def bounds_overlap?(a, b)
513
+ a.nw.lat >= b.se.lat &&
514
+ b.nw.lat >= a.se.lat &&
515
+ a.se.lng >= b.nw.lng &&
516
+ b.se.lng >= a.nw.lng
517
+ end
518
+
522
519
  def mean_bearing(b1, b2)
523
520
  r1 = b1 * RAD_PER_DEG
524
521
  r2 = b2 * RAD_PER_DEG
@@ -77,24 +77,8 @@ module Geodetic
77
77
 
78
78
  # Tests if a point lies on this segment within a tolerance (meters).
79
79
  def contains?(point, tolerance: 10.0)
80
- target = point.is_a?(Coordinate::LLA) ? point : point.to_lla
81
- seg_len = length_meters
82
-
83
- return @start_point == target || @end_point == target if seg_len < 1e-6
84
-
85
- dist_a_p = @start_point.distance_to(target).meters
86
- dist_p_b = target.distance_to(@end_point).meters
87
-
88
- # Exact endpoint match
89
- return true if dist_a_p < 1e-6 || dist_p_b < 1e-6
90
-
91
- return false if dist_a_p > seg_len || dist_p_b > seg_len
92
-
93
- delta = Math.atan(tolerance / seg_len) * Geodetic::DEG_PER_RAD
94
- bearing_ap = @start_point.bearing_to(target).degrees
95
- bearing_pb = target.bearing_to(@end_point).degrees
96
-
97
- (bearing_ap - bearing_pb).abs <= delta
80
+ _closest, distance = project(point)
81
+ distance <= tolerance
98
82
  end
99
83
 
100
84
  # True if the point is a vertex (start or end point).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Geodetic
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end
data/lib/geodetic.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ module Geodetic
4
+ class Error < StandardError; end
5
+ end
6
+
3
7
  require_relative "geodetic/version"
4
8
  require_relative "geodetic/datum"
5
9
  require_relative "geodetic/distance"
@@ -11,7 +15,4 @@ require_relative "geodetic/vector"
11
15
  require_relative "geodetic/segment"
12
16
  require_relative "geodetic/path"
13
17
  require_relative "geodetic/feature"
14
-
15
- module Geodetic
16
- class Error < StandardError; end
17
- end
18
+ require_relative "geodetic/geojson"
data/mkdocs.yml CHANGED
@@ -132,6 +132,13 @@ nav:
132
132
  - UPS: coordinate-systems/ups.md
133
133
  - State Plane: coordinate-systems/state-plane.md
134
134
  - BNG: coordinate-systems/bng.md
135
+ - GH36: coordinate-systems/gh36.md
136
+ - GH: coordinate-systems/gh.md
137
+ - HAM: coordinate-systems/ham.md
138
+ - OLC: coordinate-systems/olc.md
139
+ - GEOREF: coordinate-systems/georef.md
140
+ - GARS: coordinate-systems/gars.md
141
+ - H3: coordinate-systems/h3.md
135
142
  - Reference:
136
143
  - Datums: reference/datums.md
137
144
  - Geoid Height: reference/geoid-height.md
@@ -141,4 +148,7 @@ nav:
141
148
  - Feature: reference/feature.md
142
149
  - Serialization: reference/serialization.md
143
150
  - Conversions: reference/conversions.md
151
+ - Vector: reference/vector.md
152
+ - Arithmetic: reference/arithmetic.md
153
+ - GeoJSON Export: reference/geojson.md
144
154
  - Map Rendering: reference/map-rendering.md
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: geodetic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -56,6 +56,7 @@ files:
56
56
  - docs/reference/datums.md
57
57
  - docs/reference/feature.md
58
58
  - docs/reference/geoid-height.md
59
+ - docs/reference/geojson.md
59
60
  - docs/reference/map-rendering.md
60
61
  - docs/reference/path.md
61
62
  - docs/reference/segment.md
@@ -76,7 +77,9 @@ files:
76
77
  - examples/06_path_operations.rb
77
78
  - examples/07_segments_and_shapes.rb
78
79
  - examples/08_geodetic_arithmetic.rb
80
+ - examples/09_geojson_export.rb
79
81
  - examples/README.md
82
+ - examples/geodetic_demo.geojson
80
83
  - fiddle_pointer_buffer_pool.md
81
84
  - lib/geodetic.rb
82
85
  - lib/geodetic/areas.rb
@@ -114,6 +117,7 @@ files:
114
117
  - lib/geodetic/distance.rb
115
118
  - lib/geodetic/feature.rb
116
119
  - lib/geodetic/geoid_height.rb
120
+ - lib/geodetic/geojson.rb
117
121
  - lib/geodetic/path.rb
118
122
  - lib/geodetic/segment.rb
119
123
  - lib/geodetic/vector.rb