geodetic 0.1.0 → 0.3.0
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 +28 -4
- data/README.md +19 -5
- data/docs/coordinate-systems/bng.md +5 -5
- data/docs/coordinate-systems/ecef.md +23 -23
- data/docs/coordinate-systems/enu.md +3 -3
- data/docs/coordinate-systems/gars.md +246 -0
- data/docs/coordinate-systems/georef.md +221 -0
- data/docs/coordinate-systems/gh.md +7 -7
- data/docs/coordinate-systems/gh36.md +6 -6
- data/docs/coordinate-systems/h3.md +312 -0
- data/docs/coordinate-systems/ham.md +6 -6
- data/docs/coordinate-systems/index.md +40 -34
- data/docs/coordinate-systems/lla.md +26 -26
- data/docs/coordinate-systems/mgrs.md +3 -3
- data/docs/coordinate-systems/ned.md +3 -3
- data/docs/coordinate-systems/olc.md +6 -6
- data/docs/coordinate-systems/state-plane.md +2 -2
- data/docs/coordinate-systems/ups.md +4 -4
- data/docs/coordinate-systems/usng.md +2 -2
- data/docs/coordinate-systems/utm.md +23 -23
- data/docs/coordinate-systems/web-mercator.md +7 -7
- data/docs/getting-started/installation.md +17 -17
- data/docs/getting-started/quick-start.md +8 -8
- data/docs/index.md +22 -19
- data/docs/reference/areas.md +15 -15
- data/docs/reference/conversions.md +31 -31
- data/docs/reference/geoid-height.md +5 -5
- data/docs/reference/serialization.md +44 -44
- data/examples/01_basic_conversions.rb +10 -10
- data/examples/02_all_coordinate_systems.rb +24 -24
- data/lib/geodetic/areas/circle.rb +1 -1
- data/lib/geodetic/areas/polygon.rb +2 -2
- data/lib/geodetic/areas/rectangle.rb +6 -6
- data/lib/geodetic/{coordinates → coordinate}/bng.rb +3 -37
- data/lib/geodetic/{coordinates → coordinate}/ecef.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/enu.rb +30 -1
- data/lib/geodetic/coordinate/gars.rb +233 -0
- data/lib/geodetic/coordinate/georef.rb +204 -0
- data/lib/geodetic/coordinate/gh.rb +161 -0
- data/lib/geodetic/{coordinates → coordinate}/gh36.rb +28 -187
- data/lib/geodetic/coordinate/h3.rb +413 -0
- data/lib/geodetic/coordinate/ham.rb +226 -0
- data/lib/geodetic/{coordinates → coordinate}/lla.rb +31 -1
- data/lib/geodetic/{coordinates → coordinate}/mgrs.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/ned.rb +30 -1
- data/lib/geodetic/{coordinates → coordinate}/olc.rb +19 -225
- data/lib/geodetic/coordinate/spatial_hash.rb +342 -0
- data/lib/geodetic/{coordinates → coordinate}/state_plane.rb +30 -1
- data/lib/geodetic/{coordinates → coordinate}/ups.rb +3 -37
- data/lib/geodetic/{coordinates → coordinate}/usng.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/utm.rb +3 -33
- data/lib/geodetic/{coordinates → coordinate}/web_mercator.rb +3 -33
- data/lib/geodetic/{coordinates.rb → coordinate.rb} +62 -45
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +1 -1
- data/spatial_hash_idea.md +241 -0
- metadata +29 -20
- data/lib/geodetic/coordinates/gh.rb +0 -372
- data/lib/geodetic/coordinates/ham.rb +0 -435
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
# GH36.new(lla_coord) # from any coordinate (converts via LLA)
|
|
18
18
|
# GH36.new(utm_coord, precision: 8) # with custom precision
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
module Coordinates
|
|
22
|
-
class GH36
|
|
23
|
-
require_relative '../datum'
|
|
20
|
+
require_relative 'spatial_hash'
|
|
24
21
|
|
|
22
|
+
module Geodetic
|
|
23
|
+
module Coordinate
|
|
24
|
+
class GH36 < SpatialHash
|
|
25
25
|
# 6x6 encoding matrix mapping (row, col) to character
|
|
26
26
|
# Row 0 is the northernmost latitude slice; row 5 is southernmost
|
|
27
27
|
# Col 0 is the westernmost longitude slice; col 5 is easternmost
|
|
@@ -41,9 +41,6 @@ module Geodetic
|
|
|
41
41
|
VALID_CHARS = '23456789bBCdDFgGhHjJKlLMnNPqQrRtTVWX'
|
|
42
42
|
VALID_CHARS_SET = VALID_CHARS.chars.to_set.freeze
|
|
43
43
|
|
|
44
|
-
# Default hash length (10 chars gives sub-meter precision)
|
|
45
|
-
DEFAULT_LENGTH = 10
|
|
46
|
-
|
|
47
44
|
# Reverse lookup: character -> [row, col] in the matrix
|
|
48
45
|
CHAR_INDEX = {}.tap do |h|
|
|
49
46
|
MATRIX.each_with_index do |row, r|
|
|
@@ -68,183 +65,22 @@ module Geodetic
|
|
|
68
65
|
|
|
69
66
|
attr_reader :geohash
|
|
70
67
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
# GH36.new("bdrdC26BqH") # from geohash string
|
|
74
|
-
# GH36.new(lla) # from LLA coordinate
|
|
75
|
-
# GH36.new(utm, precision: 8) # from any coordinate with custom precision
|
|
76
|
-
def initialize(source, precision: DEFAULT_LENGTH)
|
|
77
|
-
case source
|
|
78
|
-
when String
|
|
79
|
-
validate_geohash!(source)
|
|
80
|
-
@geohash = source
|
|
81
|
-
when LLA
|
|
82
|
-
@geohash = encode(source.lat, source.lng, precision)
|
|
83
|
-
else
|
|
84
|
-
if source.respond_to?(:to_lla)
|
|
85
|
-
lla = source.to_lla
|
|
86
|
-
@geohash = encode(lla.lat, lla.lng, precision)
|
|
87
|
-
else
|
|
88
|
-
raise ArgumentError,
|
|
89
|
-
"Expected a geohash String or a coordinate object, got #{source.class}"
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def precision
|
|
95
|
-
@geohash.length
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def to_s(truncate_to = nil)
|
|
99
|
-
if truncate_to
|
|
100
|
-
@geohash[0, truncate_to.to_i]
|
|
101
|
-
else
|
|
102
|
-
@geohash
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def to_a
|
|
107
|
-
coords = decode(@geohash)
|
|
108
|
-
[coords[:lat], coords[:lng]]
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def self.from_array(array)
|
|
112
|
-
new(LLA.new(lat: array[0].to_f, lng: array[1].to_f))
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def self.from_string(string)
|
|
116
|
-
new(string.strip)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Decode to LLA (altitude is always 0.0 since GH36 is 2D)
|
|
120
|
-
def to_lla(datum = WGS84)
|
|
121
|
-
coords = decode(@geohash)
|
|
122
|
-
LLA.new(lat: coords[:lat], lng: coords[:lng], alt: 0.0)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def self.from_lla(lla_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
126
|
-
new(lla_coord, precision: precision)
|
|
127
|
-
end
|
|
68
|
+
def self.default_precision = 10
|
|
69
|
+
def self.hash_system_name = :gh36
|
|
128
70
|
|
|
129
|
-
#
|
|
130
|
-
|
|
131
|
-
def to_ecef(datum = WGS84)
|
|
132
|
-
to_lla(datum).to_ecef(datum)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def self.from_ecef(ecef_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
136
|
-
new(ecef_coord, precision: precision)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def to_utm(datum = WGS84)
|
|
140
|
-
to_lla(datum).to_utm(datum)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def self.from_utm(utm_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
144
|
-
new(utm_coord, precision: precision)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def to_enu(reference_lla, datum = WGS84)
|
|
148
|
-
to_lla(datum).to_enu(reference_lla)
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def self.from_enu(enu_coord, reference_lla, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
152
|
-
lla_coord = enu_coord.to_lla(reference_lla)
|
|
153
|
-
new(lla_coord, precision: precision)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def to_ned(reference_lla, datum = WGS84)
|
|
157
|
-
to_lla(datum).to_ned(reference_lla)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def self.from_ned(ned_coord, reference_lla, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
161
|
-
lla_coord = ned_coord.to_lla(reference_lla)
|
|
162
|
-
new(lla_coord, precision: precision)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def to_mgrs(datum = WGS84, mgrs_precision = 5)
|
|
166
|
-
MGRS.from_lla(to_lla(datum), datum, mgrs_precision)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def self.from_mgrs(mgrs_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
170
|
-
new(mgrs_coord, precision: precision)
|
|
171
|
-
end
|
|
71
|
+
# --- Subclass contract implementations ---
|
|
172
72
|
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def self.from_usng(usng_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
178
|
-
new(usng_coord, precision: precision)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def to_web_mercator(datum = WGS84)
|
|
182
|
-
WebMercator.from_lla(to_lla(datum), datum)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def self.from_web_mercator(wm_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
186
|
-
new(wm_coord, precision: precision)
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def to_ups(datum = WGS84)
|
|
190
|
-
UPS.from_lla(to_lla(datum), datum)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def self.from_ups(ups_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
194
|
-
new(ups_coord, precision: precision)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def to_state_plane(zone_code, datum = WGS84)
|
|
198
|
-
StatePlane.from_lla(to_lla(datum), zone_code, datum)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def self.from_state_plane(sp_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
202
|
-
new(sp_coord, precision: precision)
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def to_bng(datum = WGS84)
|
|
206
|
-
BNG.from_lla(to_lla(datum), datum)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def self.from_bng(bng_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
210
|
-
new(bng_coord, precision: precision)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def to_gh(datum = WGS84, gh_precision: 12)
|
|
214
|
-
GH.new(to_lla(datum), precision: gh_precision)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def self.from_gh(gh_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
218
|
-
new(gh_coord, precision: precision)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def to_ham(datum = WGS84, ham_precision: 6)
|
|
222
|
-
HAM.new(to_lla(datum), precision: ham_precision)
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def self.from_ham(ham_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
226
|
-
new(ham_coord, precision: precision)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def to_olc(datum = WGS84, olc_precision: 10)
|
|
230
|
-
OLC.new(to_lla(datum), precision: olc_precision)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def self.from_olc(olc_coord, datum = WGS84, precision = DEFAULT_LENGTH)
|
|
234
|
-
new(olc_coord, precision: precision)
|
|
73
|
+
def valid?
|
|
74
|
+
@geohash.length > 0 && @geohash.each_char.all? { |c| VALID_CHARS_SET.include?(c) }
|
|
235
75
|
end
|
|
236
76
|
|
|
237
|
-
def
|
|
238
|
-
|
|
239
|
-
@geohash == other.geohash
|
|
77
|
+
def code_value
|
|
78
|
+
@geohash
|
|
240
79
|
end
|
|
241
80
|
|
|
242
|
-
|
|
243
|
-
@geohash.length > 0 && @geohash.each_char.all? { |c| VALID_CHARS_SET.include?(c) }
|
|
244
|
-
end
|
|
81
|
+
# --- GH36-specific overrides (matrix-based algorithms) ---
|
|
245
82
|
|
|
246
|
-
#
|
|
247
|
-
# Keys: :N, :S, :E, :W, :NE, :NW, :SE, :SW
|
|
83
|
+
# Uses recursive matrix-based neighbor calculation instead of bounds-based
|
|
248
84
|
def neighbors
|
|
249
85
|
DIRECTIONS.each_with_object({}) do |(dir, delta), result|
|
|
250
86
|
hash = self.class.send(:neighbor_hash, @geohash, delta[0], delta[1])
|
|
@@ -252,7 +88,7 @@ module Geodetic
|
|
|
252
88
|
end
|
|
253
89
|
end
|
|
254
90
|
|
|
255
|
-
#
|
|
91
|
+
# Uses decode_bounds via class method
|
|
256
92
|
def to_area
|
|
257
93
|
bb = self.class.send(:decode_bounds, @geohash)
|
|
258
94
|
nw = LLA.new(lat: bb[:max_lat], lng: bb[:min_lng], alt: 0.0)
|
|
@@ -260,20 +96,23 @@ module Geodetic
|
|
|
260
96
|
Areas::Rectangle.new(nw: nw, se: se)
|
|
261
97
|
end
|
|
262
98
|
|
|
263
|
-
#
|
|
99
|
+
# Uses formula-based precision instead of bounds-based
|
|
264
100
|
def precision_in_meters
|
|
265
101
|
one_degree_meters = (2 * Math::PI * 6_370_000) / 360.0
|
|
266
102
|
lat_prec = (90.0 / (MATRIX_SIDE ** precision)) * one_degree_meters
|
|
267
103
|
{ lat: lat_prec, lng: lat_prec * 2 }
|
|
268
104
|
end
|
|
269
105
|
|
|
270
|
-
|
|
271
|
-
|
|
106
|
+
protected
|
|
107
|
+
|
|
108
|
+
def set_code(value)
|
|
109
|
+
@geohash = value
|
|
110
|
+
end
|
|
272
111
|
|
|
273
112
|
private
|
|
274
113
|
|
|
275
114
|
# Encode lat/lng to a geohash string of given length
|
|
276
|
-
def encode(lat, lng, length =
|
|
115
|
+
def encode(lat, lng, length = self.class.default_precision)
|
|
277
116
|
lat_min, lat_max = -90.0, 90.0
|
|
278
117
|
lng_min, lng_max = -180.0, 180.0
|
|
279
118
|
|
|
@@ -323,7 +162,7 @@ module Geodetic
|
|
|
323
162
|
}
|
|
324
163
|
end
|
|
325
164
|
|
|
326
|
-
# Decode a geohash string to its bounding box
|
|
165
|
+
# Decode a geohash string to its bounding box (class method for neighbor_hash access)
|
|
327
166
|
def self.decode_bounds(geohash)
|
|
328
167
|
lat_min, lat_max = -90.0, 90.0
|
|
329
168
|
lng_min, lng_max = -180.0, 180.0
|
|
@@ -353,9 +192,6 @@ module Geodetic
|
|
|
353
192
|
# Compute a neighbor hash by adjusting the last character's position
|
|
354
193
|
# in the matrix. When the adjustment wraps beyond the matrix edge,
|
|
355
194
|
# we recurse on the parent prefix and carry the wrap.
|
|
356
|
-
#
|
|
357
|
-
# Matrix layout: row 0 = north (high lat), row 5 = south (low lat)
|
|
358
|
-
# col 0 = west (low lng), col 5 = east (high lng)
|
|
359
195
|
def self.neighbor_hash(hash, row_delta, col_delta)
|
|
360
196
|
return hash if hash.empty?
|
|
361
197
|
|
|
@@ -373,7 +209,7 @@ module Geodetic
|
|
|
373
209
|
carry_col = 0
|
|
374
210
|
|
|
375
211
|
if new_row < 0
|
|
376
|
-
carry_row = row_delta
|
|
212
|
+
carry_row = row_delta
|
|
377
213
|
new_row += MATRIX_SIDE
|
|
378
214
|
elsif new_row >= MATRIX_SIDE
|
|
379
215
|
carry_row = row_delta
|
|
@@ -403,6 +239,11 @@ module Geodetic
|
|
|
403
239
|
raise ArgumentError, "Invalid Geohash-36 characters: #{invalid.join(', ')}"
|
|
404
240
|
end
|
|
405
241
|
end
|
|
242
|
+
|
|
243
|
+
alias_method :validate_code!, :validate_geohash!
|
|
244
|
+
|
|
245
|
+
register_hash_system(:gh36, self, default_precision: 10)
|
|
246
|
+
Coordinate.register_class(self)
|
|
406
247
|
end
|
|
407
248
|
end
|
|
408
249
|
end
|
|
@@ -0,0 +1,413 @@
|
|
|
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
|
+
def code_value
|
|
362
|
+
@code
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
protected
|
|
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
|
+
Coordinate.register_class(self)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|