geodetic 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +83 -28
- data/docs/coordinate-systems/gars.md +0 -4
- data/docs/coordinate-systems/georef.md +0 -4
- data/docs/coordinate-systems/gh.md +0 -4
- data/docs/coordinate-systems/gh36.md +0 -4
- data/docs/coordinate-systems/h3.md +308 -0
- data/docs/coordinate-systems/ham.md +0 -4
- data/docs/coordinate-systems/index.md +25 -23
- data/docs/coordinate-systems/olc.md +0 -4
- data/docs/index.md +7 -3
- data/docs/reference/conversions.md +15 -15
- data/docs/reference/feature.md +116 -0
- data/docs/reference/map-rendering.md +32 -0
- data/docs/reference/serialization.md +4 -4
- data/examples/02_all_coordinate_systems.rb +0 -3
- data/examples/03_distance_calculations.rb +1 -0
- data/examples/04_bearing_calculations.rb +1 -0
- data/examples/05_map_rendering/.gitignore +2 -0
- data/examples/05_map_rendering/demo.rb +264 -0
- data/examples/05_map_rendering/icons/bridge.png +0 -0
- data/examples/05_map_rendering/icons/building.png +0 -0
- data/examples/05_map_rendering/icons/landmark.png +0 -0
- data/examples/05_map_rendering/icons/monument.png +0 -0
- data/examples/05_map_rendering/icons/park.png +0 -0
- data/examples/05_map_rendering/nyc_landmarks.png +0 -0
- data/examples/README.md +62 -0
- data/fiddle_pointer_buffer_pool.md +119 -0
- data/lib/geodetic/coordinate/bng.rb +14 -33
- data/lib/geodetic/coordinate/ecef.rb +5 -1
- data/lib/geodetic/coordinate/enu.rb +13 -0
- data/lib/geodetic/coordinate/gars.rb +2 -3
- data/lib/geodetic/coordinate/georef.rb +2 -3
- data/lib/geodetic/coordinate/gh.rb +2 -4
- data/lib/geodetic/coordinate/gh36.rb +4 -5
- data/lib/geodetic/coordinate/h3.rb +412 -0
- data/lib/geodetic/coordinate/ham.rb +2 -3
- data/lib/geodetic/coordinate/lla.rb +15 -1
- data/lib/geodetic/coordinate/mgrs.rb +1 -1
- data/lib/geodetic/coordinate/ned.rb +13 -0
- data/lib/geodetic/coordinate/olc.rb +0 -1
- data/lib/geodetic/coordinate/spatial_hash.rb +2 -2
- data/lib/geodetic/coordinate/state_plane.rb +9 -0
- data/lib/geodetic/coordinate/ups.rb +1 -1
- data/lib/geodetic/coordinate/usng.rb +1 -1
- data/lib/geodetic/coordinate/utm.rb +1 -1
- data/lib/geodetic/coordinate/web_mercator.rb +1 -1
- data/lib/geodetic/coordinate.rb +31 -26
- data/lib/geodetic/feature.rb +44 -0
- data/lib/geodetic/geoid_height.rb +11 -6
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +1 -0
- data/mkdocs.yml +2 -0
- metadata +20 -5
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# H3 (Uber's Hexagonal Hierarchical Spatial Index) Coordinate
|
|
4
|
+
#
|
|
5
|
+
# A hierarchical geospatial index that divides the globe into hexagonal cells
|
|
6
|
+
# (and 12 pentagons) at 16 resolution levels (0-15). Each cell is identified
|
|
7
|
+
# by a 64-bit integer (H3Index), typically displayed as a 15-character hex string.
|
|
8
|
+
#
|
|
9
|
+
# REQUIRES: libh3 shared library (brew install h3)
|
|
10
|
+
# Uses Ruby's fiddle (stdlib) to call H3 v4 C API — no gem dependency.
|
|
11
|
+
#
|
|
12
|
+
# Key differences from other spatial hashes:
|
|
13
|
+
# - Cells are hexagons (6 vertices), not rectangles
|
|
14
|
+
# - to_area returns Areas::Polygon, not Areas::Rectangle
|
|
15
|
+
# - neighbors returns an Array (6 cells), not a directional Hash
|
|
16
|
+
# - "precision" maps to H3 resolution (0-15), not string length
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# H3.new("872a1072bffffff") # from hex string
|
|
20
|
+
# H3.new(0x872a1072bffffff) # from integer
|
|
21
|
+
# H3.new(lla_coord) # from any coordinate
|
|
22
|
+
# H3.new(lla_coord, precision: 9) # resolution 9
|
|
23
|
+
|
|
24
|
+
require_relative 'spatial_hash'
|
|
25
|
+
|
|
26
|
+
module Geodetic
|
|
27
|
+
module Coordinate
|
|
28
|
+
class H3 < SpatialHash
|
|
29
|
+
# --- FFI bindings to libh3 via fiddle ---
|
|
30
|
+
|
|
31
|
+
module LibH3
|
|
32
|
+
require 'fiddle'
|
|
33
|
+
|
|
34
|
+
SEARCH_PATHS = [
|
|
35
|
+
ENV['LIBH3_PATH'],
|
|
36
|
+
'/opt/homebrew/lib/libh3.dylib',
|
|
37
|
+
'/opt/homebrew/lib/libh3.1.dylib',
|
|
38
|
+
'/usr/local/lib/libh3.dylib',
|
|
39
|
+
'/usr/local/lib/libh3.1.dylib',
|
|
40
|
+
'/usr/lib/libh3.so',
|
|
41
|
+
'/usr/lib/libh3.so.1',
|
|
42
|
+
'/usr/local/lib/libh3.so',
|
|
43
|
+
'/usr/local/lib/libh3.so.1',
|
|
44
|
+
'/usr/lib/x86_64-linux-gnu/libh3.so',
|
|
45
|
+
'/usr/lib/aarch64-linux-gnu/libh3.so',
|
|
46
|
+
].compact.freeze
|
|
47
|
+
|
|
48
|
+
@handle = nil
|
|
49
|
+
@available = false
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
attr_reader :handle
|
|
53
|
+
|
|
54
|
+
def available?
|
|
55
|
+
@available
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def require_library!
|
|
59
|
+
return if @available
|
|
60
|
+
raise Geodetic::Error,
|
|
61
|
+
"libh3 not found. Install H3: brew install h3 (macOS) " \
|
|
62
|
+
"or see https://h3geo.org/docs/installation. " \
|
|
63
|
+
"Set LIBH3_PATH env var to specify a custom library path."
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
path = SEARCH_PATHS.find { |p| File.exist?(p) }
|
|
69
|
+
if path
|
|
70
|
+
@handle = Fiddle.dlopen(path)
|
|
71
|
+
@available = true
|
|
72
|
+
end
|
|
73
|
+
rescue Fiddle::DLError
|
|
74
|
+
@available = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Struct sizes
|
|
78
|
+
SIZEOF_LATLNG = 2 * Fiddle::SIZEOF_DOUBLE # 16 bytes
|
|
79
|
+
SIZEOF_CELL_BOUNDARY = 8 + 10 * SIZEOF_LATLNG # 168 bytes (int + pad + 10 LatLngs)
|
|
80
|
+
SIZEOF_H3INDEX = Fiddle::SIZEOF_LONG_LONG # 8 bytes
|
|
81
|
+
SIZEOF_INT64 = Fiddle::SIZEOF_LONG_LONG # 8 bytes
|
|
82
|
+
|
|
83
|
+
# Function bindings (lazy-loaded)
|
|
84
|
+
def self.bind(name, args, ret)
|
|
85
|
+
return nil unless @available
|
|
86
|
+
ptr = @handle[name]
|
|
87
|
+
Fiddle::Function.new(ptr, args, ret)
|
|
88
|
+
rescue Fiddle::DLError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# H3Error latLngToCell(const LatLng *g, int res, H3Index *out)
|
|
93
|
+
F_LAT_LNG_TO_CELL = bind('latLngToCell',
|
|
94
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
95
|
+
Fiddle::TYPE_INT)
|
|
96
|
+
|
|
97
|
+
# H3Error cellToLatLng(H3Index h3, LatLng *g)
|
|
98
|
+
F_CELL_TO_LAT_LNG = bind('cellToLatLng',
|
|
99
|
+
[Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
|
|
100
|
+
Fiddle::TYPE_INT)
|
|
101
|
+
|
|
102
|
+
# H3Error cellToBoundary(H3Index h3, CellBoundary *gp)
|
|
103
|
+
F_CELL_TO_BOUNDARY = bind('cellToBoundary',
|
|
104
|
+
[Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
|
|
105
|
+
Fiddle::TYPE_INT)
|
|
106
|
+
|
|
107
|
+
# int getResolution(H3Index h)
|
|
108
|
+
F_GET_RESOLUTION = bind('getResolution',
|
|
109
|
+
[Fiddle::TYPE_LONG_LONG],
|
|
110
|
+
Fiddle::TYPE_INT)
|
|
111
|
+
|
|
112
|
+
# int isValidCell(H3Index h)
|
|
113
|
+
F_IS_VALID_CELL = bind('isValidCell',
|
|
114
|
+
[Fiddle::TYPE_LONG_LONG],
|
|
115
|
+
Fiddle::TYPE_INT)
|
|
116
|
+
|
|
117
|
+
# int isPentagon(H3Index h)
|
|
118
|
+
F_IS_PENTAGON = bind('isPentagon',
|
|
119
|
+
[Fiddle::TYPE_LONG_LONG],
|
|
120
|
+
Fiddle::TYPE_INT)
|
|
121
|
+
|
|
122
|
+
# H3Error gridDisk(H3Index origin, int k, H3Index *out)
|
|
123
|
+
F_GRID_DISK = bind('gridDisk',
|
|
124
|
+
[Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
125
|
+
Fiddle::TYPE_INT)
|
|
126
|
+
|
|
127
|
+
# H3Error maxGridDiskSize(int k, int64_t *out)
|
|
128
|
+
F_MAX_GRID_DISK_SIZE = bind('maxGridDiskSize',
|
|
129
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
130
|
+
Fiddle::TYPE_INT)
|
|
131
|
+
|
|
132
|
+
# H3Error cellAreaM2(H3Index h, double *out)
|
|
133
|
+
F_CELL_AREA_M2 = bind('cellAreaM2',
|
|
134
|
+
[Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
|
|
135
|
+
Fiddle::TYPE_INT)
|
|
136
|
+
|
|
137
|
+
# H3Error cellToParent(H3Index h, int parentRes, H3Index *out)
|
|
138
|
+
F_CELL_TO_PARENT = bind('cellToParent',
|
|
139
|
+
[Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
140
|
+
Fiddle::TYPE_INT)
|
|
141
|
+
|
|
142
|
+
# H3Error cellToChildrenSize(H3Index h, int childRes, int64_t *out)
|
|
143
|
+
F_CELL_TO_CHILDREN_SIZE = bind('cellToChildrenSize',
|
|
144
|
+
[Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
145
|
+
Fiddle::TYPE_INT)
|
|
146
|
+
|
|
147
|
+
# H3Error cellToChildren(H3Index h, int childRes, H3Index *out)
|
|
148
|
+
F_CELL_TO_CHILDREN = bind('cellToChildren',
|
|
149
|
+
[Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
150
|
+
Fiddle::TYPE_INT)
|
|
151
|
+
|
|
152
|
+
# --- High-level wrappers ---
|
|
153
|
+
|
|
154
|
+
def self.lat_lng_to_cell(lat_rad, lng_rad, resolution)
|
|
155
|
+
require_library!
|
|
156
|
+
latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
|
|
157
|
+
latlng[0, SIZEOF_LATLNG] = [lat_rad, lng_rad].pack('d2')
|
|
158
|
+
out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
|
|
159
|
+
err = F_LAT_LNG_TO_CELL.call(latlng, resolution, out)
|
|
160
|
+
raise ArgumentError, "H3 latLngToCell error (code #{err})" unless err == 0
|
|
161
|
+
out[0, SIZEOF_H3INDEX].unpack1('Q')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.cell_to_lat_lng(h3_index)
|
|
165
|
+
require_library!
|
|
166
|
+
latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
|
|
167
|
+
err = F_CELL_TO_LAT_LNG.call(h3_index, latlng)
|
|
168
|
+
raise ArgumentError, "H3 cellToLatLng error (code #{err})" unless err == 0
|
|
169
|
+
lat_rad, lng_rad = latlng[0, SIZEOF_LATLNG].unpack('d2')
|
|
170
|
+
{ lat: lat_rad * DEG_PER_RAD, lng: lng_rad * DEG_PER_RAD }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.cell_to_boundary(h3_index)
|
|
174
|
+
require_library!
|
|
175
|
+
cb = Fiddle::Pointer.malloc(SIZEOF_CELL_BOUNDARY, Fiddle::RUBY_FREE)
|
|
176
|
+
err = F_CELL_TO_BOUNDARY.call(h3_index, cb)
|
|
177
|
+
raise ArgumentError, "H3 cellToBoundary error (code #{err})" unless err == 0
|
|
178
|
+
num_verts = cb[0, 4].unpack1('i')
|
|
179
|
+
# Vertices start at offset 8 (4 byte int + 4 byte padding)
|
|
180
|
+
verts = cb[8, num_verts * SIZEOF_LATLNG].unpack("d#{num_verts * 2}")
|
|
181
|
+
(0...num_verts).map do |i|
|
|
182
|
+
{ lat: verts[i * 2] * DEG_PER_RAD, lng: verts[i * 2 + 1] * DEG_PER_RAD }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.get_resolution(h3_index)
|
|
187
|
+
require_library!
|
|
188
|
+
F_GET_RESOLUTION.call(h3_index)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.is_valid_cell(h3_index)
|
|
192
|
+
require_library!
|
|
193
|
+
F_IS_VALID_CELL.call(h3_index) == 1
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.is_pentagon(h3_index)
|
|
197
|
+
require_library!
|
|
198
|
+
F_IS_PENTAGON.call(h3_index) == 1
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.grid_disk(h3_index, k)
|
|
202
|
+
require_library!
|
|
203
|
+
size_ptr = Fiddle::Pointer.malloc(SIZEOF_INT64, Fiddle::RUBY_FREE)
|
|
204
|
+
err = F_MAX_GRID_DISK_SIZE.call(k, size_ptr)
|
|
205
|
+
raise ArgumentError, "H3 maxGridDiskSize error (code #{err})" unless err == 0
|
|
206
|
+
max_size = size_ptr[0, SIZEOF_INT64].unpack1('q')
|
|
207
|
+
|
|
208
|
+
out = Fiddle::Pointer.malloc(max_size * SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
|
|
209
|
+
err = F_GRID_DISK.call(h3_index, k, out)
|
|
210
|
+
raise ArgumentError, "H3 gridDisk error (code #{err})" unless err == 0
|
|
211
|
+
|
|
212
|
+
out[0, max_size * SIZEOF_H3INDEX].unpack("Q#{max_size}").reject(&:zero?)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.cell_area_m2(h3_index)
|
|
216
|
+
require_library!
|
|
217
|
+
out = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
|
|
218
|
+
err = F_CELL_AREA_M2.call(h3_index, out)
|
|
219
|
+
raise ArgumentError, "H3 cellAreaM2 error (code #{err})" unless err == 0
|
|
220
|
+
out[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.cell_to_parent(h3_index, parent_res)
|
|
224
|
+
require_library!
|
|
225
|
+
out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
|
|
226
|
+
err = F_CELL_TO_PARENT.call(h3_index, parent_res, out)
|
|
227
|
+
raise ArgumentError, "H3 cellToParent error (code #{err})" unless err == 0
|
|
228
|
+
out[0, SIZEOF_H3INDEX].unpack1('Q')
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.cell_to_children(h3_index, child_res)
|
|
232
|
+
require_library!
|
|
233
|
+
size_ptr = Fiddle::Pointer.malloc(SIZEOF_INT64, Fiddle::RUBY_FREE)
|
|
234
|
+
err = F_CELL_TO_CHILDREN_SIZE.call(h3_index, child_res, size_ptr)
|
|
235
|
+
raise ArgumentError, "H3 cellToChildrenSize error (code #{err})" unless err == 0
|
|
236
|
+
count = size_ptr[0, SIZEOF_INT64].unpack1('q')
|
|
237
|
+
|
|
238
|
+
out = Fiddle::Pointer.malloc(count * SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
|
|
239
|
+
err = F_CELL_TO_CHILDREN.call(h3_index, child_res, out)
|
|
240
|
+
raise ArgumentError, "H3 cellToChildren error (code #{err})" unless err == 0
|
|
241
|
+
|
|
242
|
+
out[0, count * SIZEOF_H3INDEX].unpack("Q#{count}").reject(&:zero?)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# --- Class configuration ---
|
|
247
|
+
|
|
248
|
+
attr_reader :code
|
|
249
|
+
|
|
250
|
+
def self.default_precision = 7
|
|
251
|
+
def self.hash_system_name = :h3
|
|
252
|
+
|
|
253
|
+
def self.available?
|
|
254
|
+
LibH3.available?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# --- Constructor ---
|
|
258
|
+
|
|
259
|
+
def initialize(source, precision: self.class.default_precision)
|
|
260
|
+
case source
|
|
261
|
+
when Integer
|
|
262
|
+
hex = format('%x', source)
|
|
263
|
+
validate_code!(hex)
|
|
264
|
+
set_code(hex)
|
|
265
|
+
when String
|
|
266
|
+
normalized = normalize(source.strip)
|
|
267
|
+
validate_code!(normalized)
|
|
268
|
+
set_code(normalized)
|
|
269
|
+
when LLA
|
|
270
|
+
set_code(encode(source.lat, source.lng, precision))
|
|
271
|
+
else
|
|
272
|
+
if source.respond_to?(:to_lla)
|
|
273
|
+
lla = source.to_lla
|
|
274
|
+
set_code(encode(lla.lat, lla.lng, precision))
|
|
275
|
+
else
|
|
276
|
+
raise ArgumentError,
|
|
277
|
+
"Expected an H3 hex String, Integer, or coordinate object, got #{source.class}"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# --- H3-specific methods ---
|
|
283
|
+
|
|
284
|
+
# The 64-bit H3 cell index
|
|
285
|
+
def h3_index
|
|
286
|
+
@h3_index ||= code.to_i(16)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# H3 resolution (0-15)
|
|
290
|
+
def precision
|
|
291
|
+
@resolution ||= LibH3.get_resolution(h3_index)
|
|
292
|
+
end
|
|
293
|
+
alias_method :resolution, :precision
|
|
294
|
+
|
|
295
|
+
# Is this cell a pentagon? (12 per resolution level)
|
|
296
|
+
def pentagon?
|
|
297
|
+
LibH3.is_pentagon(h3_index)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Parent cell at a coarser resolution
|
|
301
|
+
def parent(parent_resolution)
|
|
302
|
+
raise ArgumentError, "Parent resolution must be < #{resolution}" unless parent_resolution < resolution
|
|
303
|
+
parent_idx = LibH3.cell_to_parent(h3_index, parent_resolution)
|
|
304
|
+
self.class.new(parent_idx)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Child cells at a finer resolution
|
|
308
|
+
def children(child_resolution)
|
|
309
|
+
raise ArgumentError, "Child resolution must be > #{resolution}" unless child_resolution > resolution
|
|
310
|
+
child_indices = LibH3.cell_to_children(h3_index, child_resolution)
|
|
311
|
+
child_indices.map { |idx| self.class.new(idx) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# All cells within k steps (k=0 returns self, k=1 returns 7 cells, etc.)
|
|
315
|
+
def grid_disk(k = 1)
|
|
316
|
+
indices = LibH3.grid_disk(h3_index, k)
|
|
317
|
+
indices.map { |idx| self.class.new(idx) }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Cell area in square meters
|
|
321
|
+
def cell_area
|
|
322
|
+
LibH3.cell_area_m2(h3_index)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# --- Override SpatialHash methods ---
|
|
326
|
+
|
|
327
|
+
def valid?
|
|
328
|
+
LibH3.is_valid_cell(h3_index)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Returns all adjacent cells as an Array of H3 instances.
|
|
332
|
+
# Hexagons have 6 neighbors; pentagons have 5.
|
|
333
|
+
# Note: returns Array, not directional Hash (hexagons have no cardinal directions).
|
|
334
|
+
def neighbors
|
|
335
|
+
indices = LibH3.grid_disk(h3_index, 1)
|
|
336
|
+
indices.reject { |idx| idx == h3_index }.map { |idx| self.class.new(idx) }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Returns the hexagonal cell boundary as an Areas::Polygon.
|
|
340
|
+
def to_area
|
|
341
|
+
verts = LibH3.cell_to_boundary(h3_index)
|
|
342
|
+
boundary = verts.map { |v| LLA.new(lat: v[:lat], lng: v[:lng], alt: 0.0) }
|
|
343
|
+
Areas::Polygon.new(boundary: boundary)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Returns approximate precision in meters as { lat:, lng:, area_m2: }
|
|
347
|
+
def precision_in_meters
|
|
348
|
+
area = cell_area
|
|
349
|
+
edge = Math.sqrt(area)
|
|
350
|
+
{ lat: edge, lng: edge, area_m2: area }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def to_s(format = nil)
|
|
354
|
+
if format == :integer
|
|
355
|
+
h3_index
|
|
356
|
+
else
|
|
357
|
+
code
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
protected
|
|
362
|
+
|
|
363
|
+
def code_value
|
|
364
|
+
@code
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def normalize(string)
|
|
368
|
+
string.downcase.delete_prefix('0x')
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def set_code(value)
|
|
372
|
+
@code = value
|
|
373
|
+
@h3_index = nil
|
|
374
|
+
@resolution = nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
def encode(lat, lng, resolution = self.class.default_precision)
|
|
380
|
+
resolution = resolution.clamp(0, 15)
|
|
381
|
+
lat_rad = lat * RAD_PER_DEG
|
|
382
|
+
lng_rad = lng * RAD_PER_DEG
|
|
383
|
+
idx = LibH3.lat_lng_to_cell(lat_rad, lng_rad, resolution)
|
|
384
|
+
format('%x', idx)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def decode(code_string)
|
|
388
|
+
idx = code_string.to_i(16)
|
|
389
|
+
LibH3.cell_to_lat_lng(idx)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def decode_bounds(code_string)
|
|
393
|
+
idx = code_string.to_i(16)
|
|
394
|
+
verts = LibH3.cell_to_boundary(idx)
|
|
395
|
+
lats = verts.map { |v| v[:lat] }
|
|
396
|
+
lngs = verts.map { |v| v[:lng] }
|
|
397
|
+
{ min_lat: lats.min, max_lat: lats.max, min_lng: lngs.min, max_lng: lngs.max }
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def validate_h3!(code_string)
|
|
401
|
+
raise ArgumentError, "H3 code cannot be empty" if code_string.empty?
|
|
402
|
+
raise ArgumentError, "Invalid H3 hex string: #{code_string}" unless code_string.match?(/\A[0-9a-f]+\z/)
|
|
403
|
+
idx = code_string.to_i(16)
|
|
404
|
+
raise ArgumentError, "Invalid H3 cell index: #{code_string}" unless LibH3.is_valid_cell(idx)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
alias_method :validate_code!, :validate_h3!
|
|
408
|
+
|
|
409
|
+
register_hash_system(:h3, self, default_precision: 7)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -74,12 +74,12 @@ module Geodetic
|
|
|
74
74
|
valid_characters?(@locator)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
protected
|
|
78
|
+
|
|
77
79
|
def code_value
|
|
78
80
|
@locator
|
|
79
81
|
end
|
|
80
82
|
|
|
81
|
-
protected
|
|
82
|
-
|
|
83
83
|
def normalize(string)
|
|
84
84
|
result = String.new(capacity: string.length)
|
|
85
85
|
string.each_char.with_index do |ch, i|
|
|
@@ -220,7 +220,6 @@ module Geodetic
|
|
|
220
220
|
end
|
|
221
221
|
|
|
222
222
|
register_hash_system(:ham, self, default_precision: 6)
|
|
223
|
-
Coordinate.register_class(self)
|
|
224
223
|
end
|
|
225
224
|
end
|
|
226
225
|
end
|
|
@@ -270,6 +270,14 @@ module Geodetic
|
|
|
270
270
|
gars_coord.to_lla(datum)
|
|
271
271
|
end
|
|
272
272
|
|
|
273
|
+
def to_h3(precision: 7)
|
|
274
|
+
H3.new(self, precision: precision)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.from_h3(h3_coord, datum = WGS84)
|
|
278
|
+
h3_coord.to_lla(datum)
|
|
279
|
+
end
|
|
280
|
+
|
|
273
281
|
def self.from_lla(lla_coord, datum = WGS84)
|
|
274
282
|
lla_coord
|
|
275
283
|
end
|
|
@@ -297,6 +305,12 @@ module Geodetic
|
|
|
297
305
|
new(lat: parts[0].to_f, lng: parts[1].to_f, alt: parts[2].to_f)
|
|
298
306
|
end
|
|
299
307
|
|
|
308
|
+
def valid?
|
|
309
|
+
@lat.finite? && @lng.finite? && @alt.finite? &&
|
|
310
|
+
@lat >= -90 && @lat <= 90 &&
|
|
311
|
+
@lng >= -180 && @lng <= 180
|
|
312
|
+
end
|
|
313
|
+
|
|
300
314
|
def ==(other)
|
|
301
315
|
return false unless other.is_a?(LLA)
|
|
302
316
|
|
|
@@ -317,7 +331,7 @@ module Geodetic
|
|
|
317
331
|
raise ArgumentError, "Longitude must be between -180 and 180 degrees" if @lng < -180 || @lng > 180
|
|
318
332
|
end
|
|
319
333
|
|
|
320
|
-
Coordinate.register_class(self)
|
|
334
|
+
Coordinate.register_class(self, hash_conversion_style: :no_datum)
|
|
321
335
|
end
|
|
322
336
|
end
|
|
323
337
|
end
|
|
@@ -193,6 +193,15 @@ module Geodetic
|
|
|
193
193
|
from_lla(lla, reference_lla)
|
|
194
194
|
end
|
|
195
195
|
|
|
196
|
+
def to_h3(reference_lla, precision: 7)
|
|
197
|
+
H3.new(to_lla(reference_lla), precision: precision)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.from_h3(h3_coord, reference_lla)
|
|
201
|
+
lla = h3_coord.to_lla
|
|
202
|
+
from_lla(lla, reference_lla)
|
|
203
|
+
end
|
|
204
|
+
|
|
196
205
|
def to_s(precision = 2)
|
|
197
206
|
precision = precision.to_i
|
|
198
207
|
if precision == 0
|
|
@@ -215,6 +224,10 @@ module Geodetic
|
|
|
215
224
|
new(n: parts[0].to_f, e: parts[1].to_f, d: parts[2].to_f)
|
|
216
225
|
end
|
|
217
226
|
|
|
227
|
+
def valid?
|
|
228
|
+
@n.finite? && @e.finite? && @d.finite?
|
|
229
|
+
end
|
|
230
|
+
|
|
218
231
|
def ==(other)
|
|
219
232
|
return false unless other.is_a?(NED)
|
|
220
233
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# decode_bounds(code_string) → { min_lat:, max_lat:, min_lng:, max_lng: }
|
|
13
13
|
# validate_code!(string) → raises ArgumentError or nil
|
|
14
14
|
# set_code(normalized_string) → sets the internal ivar (@geohash, @code, etc.)
|
|
15
|
-
# code_value → returns the internal ivar
|
|
15
|
+
# code_value → returns the internal ivar (protected)
|
|
16
16
|
# self.default_precision → Integer
|
|
17
17
|
# self.hash_system_name → Symbol (:gh, :gh36, :ham, :olc)
|
|
18
18
|
#
|
|
@@ -52,6 +52,7 @@ module Geodetic
|
|
|
52
52
|
class_name: klass.name.split('::').last,
|
|
53
53
|
default_precision: default_precision
|
|
54
54
|
}
|
|
55
|
+
Coordinate.register_class(klass)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
# Called once after all spatial hash subclasses are loaded.
|
|
@@ -285,7 +286,6 @@ module Geodetic
|
|
|
285
286
|
code_value == other.code_value
|
|
286
287
|
end
|
|
287
288
|
|
|
288
|
-
alias_method :to_slug, :to_s
|
|
289
289
|
|
|
290
290
|
# Returns all 8 neighboring cells
|
|
291
291
|
# Keys: :N, :S, :E, :W, :NE, :NW, :SE, :SW
|
|
@@ -305,6 +305,15 @@ module Geodetic
|
|
|
305
305
|
from_lla(lla_coord, zone_code, datum)
|
|
306
306
|
end
|
|
307
307
|
|
|
308
|
+
def to_h3(datum = nil, precision: 7)
|
|
309
|
+
H3.new(to_lla(datum), precision: precision)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def self.from_h3(h3_coord, zone_code, datum = WGS84)
|
|
313
|
+
lla_coord = h3_coord.to_lla(datum)
|
|
314
|
+
from_lla(lla_coord, zone_code, datum)
|
|
315
|
+
end
|
|
316
|
+
|
|
308
317
|
# Unit conversion methods
|
|
309
318
|
def to_meters
|
|
310
319
|
zone_info = ZONES[@zone_code]
|
data/lib/geodetic/coordinate.rb
CHANGED
|
@@ -3,13 +3,39 @@
|
|
|
3
3
|
module Geodetic
|
|
4
4
|
module Coordinate
|
|
5
5
|
# Registry for coordinate classes — each class registers itself at load time
|
|
6
|
+
# Each entry is [klass, options_hash] where options may include :hash_conversion_style
|
|
6
7
|
@registered_classes = []
|
|
8
|
+
@finalized = false
|
|
7
9
|
|
|
8
10
|
class << self
|
|
9
11
|
attr_reader :registered_classes
|
|
10
12
|
|
|
11
|
-
def
|
|
12
|
-
@registered_classes
|
|
13
|
+
def systems
|
|
14
|
+
@registered_classes.map(&:first).freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register_class(klass, hash_conversion_style: nil)
|
|
18
|
+
@registered_classes << [klass, { hash_conversion_style: hash_conversion_style }]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def finalize!
|
|
22
|
+
raise "Geodetic::Coordinate already finalized!" if @finalized
|
|
23
|
+
@finalized = true
|
|
24
|
+
|
|
25
|
+
# Phase 1: Generate cross-hash conversion methods between spatial hash subclasses
|
|
26
|
+
SpatialHash.finalize_cross_hash_conversions!
|
|
27
|
+
|
|
28
|
+
# Phase 2: Generate hash conversion methods on non-hash coordinate classes
|
|
29
|
+
@registered_classes.each do |klass, opts|
|
|
30
|
+
next unless opts[:hash_conversion_style]
|
|
31
|
+
SpatialHash.generate_hash_conversions_for(klass, style: opts[:hash_conversion_style])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Phase 3: Include distance/bearing mixins in all registered coordinate classes
|
|
35
|
+
@registered_classes.each do |klass, _opts|
|
|
36
|
+
klass.include(DistanceMethods)
|
|
37
|
+
klass.include(BearingMethods)
|
|
38
|
+
end
|
|
13
39
|
end
|
|
14
40
|
end
|
|
15
41
|
end
|
|
@@ -33,6 +59,7 @@ require_relative "coordinate/ham"
|
|
|
33
59
|
require_relative "coordinate/olc"
|
|
34
60
|
require_relative "coordinate/georef"
|
|
35
61
|
require_relative "coordinate/gars"
|
|
62
|
+
require_relative "coordinate/h3"
|
|
36
63
|
|
|
37
64
|
module Geodetic
|
|
38
65
|
module Coordinate
|
|
@@ -256,27 +283,5 @@ module Geodetic
|
|
|
256
283
|
end
|
|
257
284
|
end
|
|
258
285
|
|
|
259
|
-
#
|
|
260
|
-
Geodetic::Coordinate
|
|
261
|
-
|
|
262
|
-
# Generate hash conversion methods (to_gh, from_gh, etc.) on non-hash coordinate classes
|
|
263
|
-
sh = Geodetic::Coordinate::SpatialHash
|
|
264
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::LLA, style: :no_datum)
|
|
265
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::ECEF, style: :no_datum)
|
|
266
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::UTM, style: :no_datum)
|
|
267
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::WebMercator, style: :no_datum)
|
|
268
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::BNG, style: :with_datum)
|
|
269
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::UPS, style: :with_datum)
|
|
270
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::MGRS, style: :with_datum_and_precision)
|
|
271
|
-
sh.generate_hash_conversions_for(Geodetic::Coordinate::USNG, style: :with_datum_and_precision)
|
|
272
|
-
|
|
273
|
-
# Include distance/bearing mixins in all registered coordinate classes
|
|
274
|
-
ALL_COORD_CLASSES = Geodetic::Coordinate.registered_classes.freeze
|
|
275
|
-
|
|
276
|
-
ALL_COORD_CLASSES.each do |klass|
|
|
277
|
-
klass.include(Geodetic::Coordinate::DistanceMethods)
|
|
278
|
-
klass.include(Geodetic::Coordinate::BearingMethods)
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
# GCS is a convenience alias for Geodetic::Coordinate, available after require "geodetic"
|
|
282
|
-
GCS = Geodetic::Coordinate
|
|
286
|
+
# All classes loaded and registered — finalize conversions and mixins
|
|
287
|
+
Geodetic::Coordinate.finalize!
|