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,264 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Demonstration of rendering geodetic coordinates on a map
|
|
4
|
+
# using the libgd-gis gem for native Ruby map generation.
|
|
5
|
+
# Shows how Geodetic::Feature wraps named coordinates and areas.
|
|
6
|
+
#
|
|
7
|
+
# Prerequisites:
|
|
8
|
+
# gem install libgd-gis
|
|
9
|
+
# brew install gd (macOS) or apt install libgd-dev (Linux)
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# ruby -Ilib examples/05_map_rendering.rb
|
|
13
|
+
|
|
14
|
+
if ARGV.include?("-h") || ARGV.include?("--help")
|
|
15
|
+
puts <<~HELP
|
|
16
|
+
Usage: ./demo.rb [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--light Use light basemap with dark labels
|
|
20
|
+
--dark Use dark basemap with light labels
|
|
21
|
+
Default: follows macOS appearance setting
|
|
22
|
+
|
|
23
|
+
--central-park Show Central Park polygon boundary (default)
|
|
24
|
+
--no-central-park Hide Central Park polygon boundary
|
|
25
|
+
|
|
26
|
+
--icon-scale=N Scale icons by factor N (default: 0.5)
|
|
27
|
+
Examples: --icon-scale=0.25, --icon-scale=1.0
|
|
28
|
+
|
|
29
|
+
-h, --help Show this help message
|
|
30
|
+
HELP
|
|
31
|
+
exit
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require_relative "../../lib/geodetic"
|
|
35
|
+
require "gd/gis"
|
|
36
|
+
|
|
37
|
+
include Geodetic
|
|
38
|
+
LLA = Coordinate::LLA
|
|
39
|
+
|
|
40
|
+
# --- Theme detection: --light, --dark, or macOS system default ---
|
|
41
|
+
|
|
42
|
+
def detect_macos_dark_mode
|
|
43
|
+
result = `defaults read -g AppleInterfaceStyle 2>/dev/null`.strip
|
|
44
|
+
result == "Dark"
|
|
45
|
+
rescue
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
DARK_MODE = if ARGV.include?("--dark")
|
|
50
|
+
true
|
|
51
|
+
elsif ARGV.include?("--light")
|
|
52
|
+
false
|
|
53
|
+
else
|
|
54
|
+
detect_macos_dark_mode
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
BASEMAP = DARK_MODE ? :carto_dark : :carto_light
|
|
58
|
+
LABEL_COLOR = DARK_MODE ? [255, 255, 255] : [0, 0, 0]
|
|
59
|
+
ICON_BG = DARK_MODE # draw a white disc behind icons on dark basemaps
|
|
60
|
+
CENTRAL_PARK = !ARGV.include?("--no-central-park") # --central-park is the default
|
|
61
|
+
ICON_SCALE = (ARGV.find { |a| a.start_with?("--icon-scale=") }&.split("=", 2)&.last&.to_f) || 0.5
|
|
62
|
+
|
|
63
|
+
# --- 1. Define landmarks as Features with icon paths ---
|
|
64
|
+
|
|
65
|
+
ICONS_DIR = File.join(__dir__, "icons")
|
|
66
|
+
|
|
67
|
+
landmarks = [
|
|
68
|
+
Feature.new(label: "Statue of Liberty", geometry: LLA.new(lat: 40.6892, lng: -74.0445, alt: 0),
|
|
69
|
+
metadata: { category: "monument", year: 1886, icon: File.join(ICONS_DIR, "monument.png") }),
|
|
70
|
+
Feature.new(label: "Empire State Bldg", geometry: LLA.new(lat: 40.7484, lng: -73.9857, alt: 0),
|
|
71
|
+
metadata: { category: "building", floors: 102, icon: File.join(ICONS_DIR, "building.png") }),
|
|
72
|
+
Feature.new(label: "Central Park", geometry: LLA.new(lat: 40.7829, lng: -73.9654, alt: 0),
|
|
73
|
+
metadata: { category: "park", acres: 843, icon: File.join(ICONS_DIR, "park.png") }),
|
|
74
|
+
Feature.new(label: "Brooklyn Bridge", geometry: LLA.new(lat: 40.7061, lng: -73.9969, alt: 0),
|
|
75
|
+
metadata: { category: "bridge", year: 1883, icon: File.join(ICONS_DIR, "bridge.png") }),
|
|
76
|
+
Feature.new(label: "Times Square", geometry: LLA.new(lat: 40.7580, lng: -73.9855, alt: 0),
|
|
77
|
+
metadata: { category: "landmark", icon: File.join(ICONS_DIR, "landmark.png") }),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# An area-based Feature: Central Park as a polygon boundary
|
|
81
|
+
# Vertices trace the park perimeter (59th St to 110th St)
|
|
82
|
+
central_park_area = Feature.new(
|
|
83
|
+
label: "Central Park",
|
|
84
|
+
geometry: Areas::Polygon.new(boundary: [
|
|
85
|
+
# Vertices from OpenStreetMap data (way 427818536), simplified.
|
|
86
|
+
# Central Park is a parallelogram tilted ~29° with Manhattan's grid.
|
|
87
|
+
LLA.new(lat: 40.7679, lng: -73.9818, alt: 0), # SW corner (Columbus Circle, 59th & CPW)
|
|
88
|
+
LLA.new(lat: 40.7649, lng: -73.9727, alt: 0), # SE corner (Grand Army Plaza, 59th & 5th Ave)
|
|
89
|
+
LLA.new(lat: 40.7691, lng: -73.9697, alt: 0), # E edge — 65th St
|
|
90
|
+
LLA.new(lat: 40.7728, lng: -73.9672, alt: 0), # E edge — 70th St
|
|
91
|
+
LLA.new(lat: 40.7766, lng: -73.9648, alt: 0), # E edge — 75th St
|
|
92
|
+
LLA.new(lat: 40.7812, lng: -73.9618, alt: 0), # E edge — 81st St (Met Museum)
|
|
93
|
+
LLA.new(lat: 40.7855, lng: -73.9592, alt: 0), # E edge — 86th St
|
|
94
|
+
LLA.new(lat: 40.7903, lng: -73.9561, alt: 0), # E edge — 92nd St
|
|
95
|
+
LLA.new(lat: 40.7950, lng: -73.9531, alt: 0), # E edge — 97th St
|
|
96
|
+
LLA.new(lat: 40.7994, lng: -73.9500, alt: 0), # E edge — 108th St
|
|
97
|
+
LLA.new(lat: 40.8003, lng: -73.9494, alt: 0), # NE corner (110th & 5th Ave)
|
|
98
|
+
LLA.new(lat: 40.8013, lng: -73.9580, alt: 0), # N edge
|
|
99
|
+
LLA.new(lat: 40.8008, lng: -73.9585, alt: 0), # NW corner (110th & CPW)
|
|
100
|
+
LLA.new(lat: 40.7962, lng: -73.9614, alt: 0), # W edge — 103rd St
|
|
101
|
+
LLA.new(lat: 40.7915, lng: -73.9648, alt: 0), # W edge — 93rd St
|
|
102
|
+
LLA.new(lat: 40.7863, lng: -73.9679, alt: 0), # W edge — 86th St
|
|
103
|
+
LLA.new(lat: 40.7815, lng: -73.9706, alt: 0), # W edge — 79th St
|
|
104
|
+
LLA.new(lat: 40.7769, lng: -73.9731, alt: 0), # W edge — 73rd St
|
|
105
|
+
LLA.new(lat: 40.7726, lng: -73.9755, alt: 0), # W edge — 68th St
|
|
106
|
+
LLA.new(lat: 40.7698, lng: -73.9772, alt: 0), # W edge — 64th St
|
|
107
|
+
]),
|
|
108
|
+
metadata: { note: "simplified boundary from OpenStreetMap way 427818536" }
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# --- 2. Compute a bounding box from Feature coordinates ---
|
|
112
|
+
|
|
113
|
+
all_points = landmarks.map { |f| f.geometry }
|
|
114
|
+
all_points += central_park_area.geometry.boundary if CENTRAL_PARK
|
|
115
|
+
lats = all_points.map(&:lat)
|
|
116
|
+
lngs = all_points.map(&:lng)
|
|
117
|
+
padding = 0.03
|
|
118
|
+
|
|
119
|
+
bbox = [
|
|
120
|
+
lngs.min - padding, # west
|
|
121
|
+
lats.min - padding, # south
|
|
122
|
+
lngs.max + padding, # east
|
|
123
|
+
lats.max + padding # north
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# --- 3. Create the map ---
|
|
127
|
+
|
|
128
|
+
ZOOM = 12
|
|
129
|
+
|
|
130
|
+
map = GD::GIS::Map.new(
|
|
131
|
+
bbox: bbox,
|
|
132
|
+
zoom: ZOOM,
|
|
133
|
+
basemap: BASEMAP,
|
|
134
|
+
width: 1024,
|
|
135
|
+
height: 768
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# --- 4. Optionally draw Central Park polygon on the map ---
|
|
139
|
+
|
|
140
|
+
if CENTRAL_PARK
|
|
141
|
+
park_coords = central_park_area.geometry.boundary.map { |pt| [pt.lng, pt.lat] }
|
|
142
|
+
|
|
143
|
+
map.add_polygons(
|
|
144
|
+
[[park_coords]], # polygons > rings > [lng, lat] points
|
|
145
|
+
fill: [80, 255, 120, 60],
|
|
146
|
+
stroke: [50, 200, 80, 255],
|
|
147
|
+
width: 2
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Style must be set (add_polygons sets it as a side effect, but just in case)
|
|
152
|
+
map.style ||= GD::GIS::Style.default
|
|
153
|
+
|
|
154
|
+
# --- 6. Show Feature info using delegation ---
|
|
155
|
+
|
|
156
|
+
liberty = landmarks.first
|
|
157
|
+
|
|
158
|
+
puts "Landmarks relative to #{liberty.label}:"
|
|
159
|
+
puts "-" * 60
|
|
160
|
+
|
|
161
|
+
landmarks.each do |feature|
|
|
162
|
+
utm = feature.geometry.to_utm
|
|
163
|
+
|
|
164
|
+
puts <<~INFO
|
|
165
|
+
#{feature.label} [#{feature.metadata[:category]}]
|
|
166
|
+
LLA: #{feature.geometry.to_s(4)}
|
|
167
|
+
UTM: #{utm.to_s(2)}
|
|
168
|
+
Distance to #{liberty.label}: #{feature.distance_to(liberty).to_km}
|
|
169
|
+
Bearing to #{liberty.label}: #{feature.bearing_to(liberty)}
|
|
170
|
+
|
|
171
|
+
INFO
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Distance from an area Feature to a point Feature
|
|
175
|
+
puts "#{central_park_area.label} -> #{liberty.label}: #{central_park_area.distance_to(liberty).to_km}"
|
|
176
|
+
puts
|
|
177
|
+
|
|
178
|
+
# --- 7. Render, draw markers/areas/labels, and save ---
|
|
179
|
+
|
|
180
|
+
output_path = File.join(__dir__, "nyc_landmarks.png")
|
|
181
|
+
map.render
|
|
182
|
+
|
|
183
|
+
img = map.image
|
|
184
|
+
map_bbox = map.instance_variable_get(:@bbox)
|
|
185
|
+
zoom = ZOOM
|
|
186
|
+
FONT = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
|
187
|
+
|
|
188
|
+
# Draw landmark icons and labels
|
|
189
|
+
landmarks.each do |feature|
|
|
190
|
+
pt = feature.geometry
|
|
191
|
+
px, py = GD::GIS::Geometry.project(pt.lng, pt.lat, map_bbox, zoom)
|
|
192
|
+
x, y = px.round, py.round
|
|
193
|
+
|
|
194
|
+
# Load and scale the icon
|
|
195
|
+
icon = GD::Image.open(feature.metadata[:icon])
|
|
196
|
+
icon.alpha_blending = true
|
|
197
|
+
icon.save_alpha = true
|
|
198
|
+
iw = (icon.width * ICON_SCALE).round
|
|
199
|
+
ih = (icon.height * ICON_SCALE).round
|
|
200
|
+
scaled = icon.scale(iw, ih)
|
|
201
|
+
scaled.alpha_blending = true
|
|
202
|
+
scaled.save_alpha = true
|
|
203
|
+
|
|
204
|
+
# On dark basemaps, add a white disc so black icons are visible
|
|
205
|
+
if ICON_BG
|
|
206
|
+
radius = (iw / 2 * 1.3).round
|
|
207
|
+
img.filled_circle(x, y, radius, [255, 255, 255])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
img.copy(scaled, x - iw / 2, y - ih / 2, 0, 0, iw, ih)
|
|
211
|
+
|
|
212
|
+
# Label to the right of the icon
|
|
213
|
+
img.text(feature.label, x: x + iw / 2 + 6, y: y + 5,
|
|
214
|
+
font: FONT, size: 16, color: LABEL_COLOR)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- 8. Draw bearing arrows ---
|
|
218
|
+
|
|
219
|
+
def draw_bearing_arrow(img, from_feature, to_feature, map_bbox, zoom, color, font)
|
|
220
|
+
bearing = from_feature.bearing_to(to_feature)
|
|
221
|
+
|
|
222
|
+
x1, y1 = GD::GIS::Geometry.project(from_feature.geometry.lng, from_feature.geometry.lat, map_bbox, zoom).map(&:round)
|
|
223
|
+
x2, y2 = GD::GIS::Geometry.project(to_feature.geometry.lng, to_feature.geometry.lat, map_bbox, zoom).map(&:round)
|
|
224
|
+
|
|
225
|
+
# Line (doubled for thickness)
|
|
226
|
+
img.line(x1, y1, x2, y2, color)
|
|
227
|
+
img.line(x1 + 1, y1, x2 + 1, y2, color)
|
|
228
|
+
|
|
229
|
+
# Arrowhead at destination
|
|
230
|
+
dx, dy = (x2 - x1).to_f, (y2 - y1).to_f
|
|
231
|
+
len = Math.sqrt(dx * dx + dy * dy)
|
|
232
|
+
ux, uy = dx / len, dy / len
|
|
233
|
+
|
|
234
|
+
tip_x = x2 - (ux * 20).round
|
|
235
|
+
tip_y = y2 - (uy * 20).round
|
|
236
|
+
base_x = tip_x - (ux * 14).round
|
|
237
|
+
base_y = tip_y - (uy * 14).round
|
|
238
|
+
px, py = -uy, ux
|
|
239
|
+
p1x = base_x + (px * 7).round
|
|
240
|
+
p1y = base_y + (py * 7).round
|
|
241
|
+
p2x = base_x - (px * 7).round
|
|
242
|
+
p2y = base_y - (py * 7).round
|
|
243
|
+
img.filled_polygon([[tip_x, tip_y], [p1x, p1y], [p2x, p2y]], color)
|
|
244
|
+
|
|
245
|
+
# Label at midpoint
|
|
246
|
+
mid_x = (x1 + x2) / 2
|
|
247
|
+
mid_y = (y1 + y2) / 2
|
|
248
|
+
img.text("#{bearing.degrees.round(1)}\u00B0", x: mid_x + 8, y: mid_y + 5,
|
|
249
|
+
font: font, size: 14, color: color)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
img.antialias = true
|
|
253
|
+
brooklyn = landmarks.find { |f| f.label == "Brooklyn Bridge" }
|
|
254
|
+
liberty = landmarks.find { |f| f.label == "Statue of Liberty" }
|
|
255
|
+
empire = landmarks.find { |f| f.label == "Empire State Bldg" }
|
|
256
|
+
|
|
257
|
+
arrow_color = DARK_MODE ? [255, 100, 100] : [200, 30, 30]
|
|
258
|
+
|
|
259
|
+
draw_bearing_arrow(img, brooklyn, liberty, map_bbox, zoom, arrow_color, FONT)
|
|
260
|
+
draw_bearing_arrow(img, brooklyn, empire, map_bbox, zoom, arrow_color, FONT)
|
|
261
|
+
|
|
262
|
+
map.save(output_path)
|
|
263
|
+
|
|
264
|
+
puts "Map saved to #{output_path}"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/examples/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Geodetic Examples
|
|
2
|
+
|
|
3
|
+
Runnable demo scripts showing progressive usage of the Geodetic library. Run any single-file example from the project root:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
ruby -Ilib examples/01_basic_conversions.rb
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 01 - Basic Conversions
|
|
10
|
+
|
|
11
|
+
Converts a single point between LLA, ECEF, UTM, ENU, and NED coordinate systems. Demonstrates roundtrip accuracy and local coordinate frames with a reference point.
|
|
12
|
+
|
|
13
|
+
## 02 - All Coordinate Systems
|
|
14
|
+
|
|
15
|
+
Walks through all 18 coordinate systems with cross-system conversion chains. Covers spatial hashes (GH, GH36, HAM, OLC, GEOREF, GARS, H3), areas, and neighbor lookups.
|
|
16
|
+
|
|
17
|
+
## 03 - Distance Calculations
|
|
18
|
+
|
|
19
|
+
Demonstrates the `Distance` class: great-circle and straight-line distances, unit conversions (km, mi, ft, nmi), arithmetic, comparison, and cross-system distance calculations.
|
|
20
|
+
|
|
21
|
+
## 04 - Bearing Calculations
|
|
22
|
+
|
|
23
|
+
Demonstrates the `Bearing` class: forward azimuth, back azimuth, compass directions (4/8/16-point), elevation angles, chain bearings, and cross-system bearing calculations.
|
|
24
|
+
|
|
25
|
+
## 05 - Map Rendering
|
|
26
|
+
|
|
27
|
+
Renders geodetic data on a raster map using the [libgd-gis](https://rubygems.org/gems/libgd-gis) gem. Showcases `Geodetic::Feature` for wrapping coordinates and areas with labels and metadata.
|
|
28
|
+
|
|
29
|
+
Features demonstrated:
|
|
30
|
+
|
|
31
|
+
- **Feature objects** with point and polygon geometries
|
|
32
|
+
- **Landmark icons** scaled and composited onto the map
|
|
33
|
+
- **Polygon rendering** of Central Park's boundary
|
|
34
|
+
- **Bearing arrows** with degree labels between landmarks
|
|
35
|
+
- **Distance and bearing delegation** through Feature objects
|
|
36
|
+
- **UTM conversion** output for each landmark
|
|
37
|
+
- **Light/dark theme** switching with macOS system detection
|
|
38
|
+
|
|
39
|
+
Prerequisites:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gem install libgd-gis
|
|
43
|
+
brew install gd # macOS
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run from the project root:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ruby -Ilib examples/05_map_rendering/demo.rb
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
CLI flags:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
--light / --dark Select basemap theme (default: macOS system setting)
|
|
56
|
+
--central-park Show Central Park polygon (default)
|
|
57
|
+
--no-central-park Hide Central Park polygon
|
|
58
|
+
--icon-scale=N Scale landmark icons (default: 0.5)
|
|
59
|
+
-h, --help Show help
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Output: `examples/05_map_rendering/nyc_landmarks.png`
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# H3 Fiddle Pointer Buffer Pool Optimization
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Every H3 C function call in `lib/geodetic/coordinate/h3.rb` allocates fresh `Fiddle::Pointer` objects that are immediately discarded after use. At high call volumes (1M+ conversions), this creates hundreds of thousands of short-lived heap objects and GC pressure.
|
|
6
|
+
|
|
7
|
+
## Affected Methods
|
|
8
|
+
|
|
9
|
+
| Method | Pointers Allocated | Sizes |
|
|
10
|
+
|--------|-------------------|-------|
|
|
11
|
+
| `lat_lng_to_cell` | 2 | `SIZEOF_LATLNG` (16B) + `SIZEOF_H3INDEX` (8B) |
|
|
12
|
+
| `cell_to_lat_lng` | 1 | `SIZEOF_LATLNG` (16B) |
|
|
13
|
+
| `cell_to_boundary` | 1 | `SIZEOF_CELL_BOUNDARY` (168B) |
|
|
14
|
+
| `grid_disk` | 2 | `SIZEOF_INT64` (8B) + variable |
|
|
15
|
+
| `cell_area_m2` | 1 | `SIZEOF_DOUBLE` (8B) |
|
|
16
|
+
| `cell_to_parent` | 1 | `SIZEOF_H3INDEX` (8B) |
|
|
17
|
+
| `cell_to_children` | 2 | `SIZEOF_INT64` (8B) + variable |
|
|
18
|
+
|
|
19
|
+
## Proposed Solution
|
|
20
|
+
|
|
21
|
+
Pre-allocate thread-local reusable buffers for fixed-size structs. Variable-size buffers (`grid_disk` output, `cell_to_children` output) cannot be pre-allocated since their size depends on input.
|
|
22
|
+
|
|
23
|
+
### Thread-Local Buffer Helper
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
module LibH3
|
|
27
|
+
def self.thread_buffer(name, size)
|
|
28
|
+
key = :"geodetic_h3_#{name}"
|
|
29
|
+
Thread.current[key] ||= Fiddle::Pointer.malloc(size)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Before (current code)
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
def self.lat_lng_to_cell(lat_rad, lng_rad, resolution)
|
|
38
|
+
require_library!
|
|
39
|
+
latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
|
|
40
|
+
latlng[0, SIZEOF_LATLNG] = [lat_rad, lng_rad].pack('d2')
|
|
41
|
+
out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
|
|
42
|
+
err = F_LAT_LNG_TO_CELL.call(latlng, resolution, out)
|
|
43
|
+
raise ArgumentError, "H3 latLngToCell error (code #{err})" unless err == 0
|
|
44
|
+
out[0, SIZEOF_H3INDEX].unpack1('Q')
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### After (with buffer reuse)
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
def self.lat_lng_to_cell(lat_rad, lng_rad, resolution)
|
|
52
|
+
require_library!
|
|
53
|
+
latlng = thread_buffer(:latlng, SIZEOF_LATLNG)
|
|
54
|
+
latlng[0, SIZEOF_LATLNG] = [lat_rad, lng_rad].pack('d2')
|
|
55
|
+
out = thread_buffer(:h3index, SIZEOF_H3INDEX)
|
|
56
|
+
err = F_LAT_LNG_TO_CELL.call(latlng, resolution, out)
|
|
57
|
+
raise ArgumentError, "H3 latLngToCell error (code #{err})" unless err == 0
|
|
58
|
+
out[0, SIZEOF_H3INDEX].unpack1('Q')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.cell_to_lat_lng(h3_index)
|
|
62
|
+
require_library!
|
|
63
|
+
latlng = thread_buffer(:latlng, SIZEOF_LATLNG)
|
|
64
|
+
err = F_CELL_TO_LAT_LNG.call(h3_index, latlng)
|
|
65
|
+
raise ArgumentError, "H3 cellToLatLng error (code #{err})" unless err == 0
|
|
66
|
+
lat_rad, lng_rad = latlng[0, SIZEOF_LATLNG].unpack('d2')
|
|
67
|
+
{ lat: lat_rad * DEG_PER_RAD, lng: lng_rad * DEG_PER_RAD }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.cell_to_boundary(h3_index)
|
|
71
|
+
require_library!
|
|
72
|
+
cb = thread_buffer(:cell_boundary, SIZEOF_CELL_BOUNDARY)
|
|
73
|
+
err = F_CELL_TO_BOUNDARY.call(h3_index, cb)
|
|
74
|
+
raise ArgumentError, "H3 cellToBoundary error (code #{err})" unless err == 0
|
|
75
|
+
num_verts = cb[0, 4].unpack1('i')
|
|
76
|
+
verts = cb[8, num_verts * SIZEOF_LATLNG].unpack("d#{num_verts * 2}")
|
|
77
|
+
(0...num_verts).map do |i|
|
|
78
|
+
{ lat: verts[i * 2] * DEG_PER_RAD, lng: verts[i * 2 + 1] * DEG_PER_RAD }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.cell_area_m2(h3_index)
|
|
83
|
+
require_library!
|
|
84
|
+
out = thread_buffer(:double, Fiddle::SIZEOF_DOUBLE)
|
|
85
|
+
err = F_CELL_AREA_M2.call(h3_index, out)
|
|
86
|
+
raise ArgumentError, "H3 cellAreaM2 error (code #{err})" unless err == 0
|
|
87
|
+
out[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.cell_to_parent(h3_index, parent_res)
|
|
91
|
+
require_library!
|
|
92
|
+
out = thread_buffer(:h3index, SIZEOF_H3INDEX)
|
|
93
|
+
err = F_CELL_TO_PARENT.call(h3_index, parent_res, out)
|
|
94
|
+
raise ArgumentError, "H3 cellToParent error (code #{err})" unless err == 0
|
|
95
|
+
out[0, SIZEOF_H3INDEX].unpack1('Q')
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Reusable Fixed-Size Buffers
|
|
100
|
+
|
|
101
|
+
| Buffer Name | Size | Used By |
|
|
102
|
+
|-------------|------|---------|
|
|
103
|
+
| `:latlng` | 16 bytes | `lat_lng_to_cell`, `cell_to_lat_lng` |
|
|
104
|
+
| `:h3index` | 8 bytes | `lat_lng_to_cell`, `cell_to_parent` |
|
|
105
|
+
| `:cell_boundary` | 168 bytes | `cell_to_boundary` |
|
|
106
|
+
| `:int64` | 8 bytes | `grid_disk`, `cell_to_children` |
|
|
107
|
+
| `:double` | 8 bytes | `cell_area_m2` |
|
|
108
|
+
|
|
109
|
+
## Thread Safety
|
|
110
|
+
|
|
111
|
+
Using `Thread.current` ensures each thread gets its own buffer. No mutex needed. Buffers are overwritten on each call, so stale data is not a concern — the C function fills the buffer before Ruby reads it.
|
|
112
|
+
|
|
113
|
+
## When to Implement
|
|
114
|
+
|
|
115
|
+
This optimization is only worthwhile for high-volume batch processing (1M+ H3 conversions). For typical usage the current per-call allocation is fine and simpler to reason about.
|
|
116
|
+
|
|
117
|
+
## Not Optimizable
|
|
118
|
+
|
|
119
|
+
The `grid_disk` and `cell_to_children` output buffers are variable-sized (depends on `k` ring size or child resolution) and must be allocated per call.
|
|
@@ -35,6 +35,13 @@ module Geodetic
|
|
|
35
35
|
['HL', 'HM', 'HN', 'HO', 'HP', 'JL', 'JM']
|
|
36
36
|
]
|
|
37
37
|
|
|
38
|
+
# Reverse lookup: grid square letters → [x, y] coordinates
|
|
39
|
+
GRID_SQUARE_LOOKUP = GRID_SQUARES.each_with_index.each_with_object({}) do |(row, y), hash|
|
|
40
|
+
row.each_with_index do |square, x|
|
|
41
|
+
hash[square] = [x, 12 - y]
|
|
42
|
+
end
|
|
43
|
+
end.freeze
|
|
44
|
+
|
|
38
45
|
# OSGB36 datum (approximation - use Airy 1830 ellipsoid)
|
|
39
46
|
OSGB36 = Struct.new(:name, :a, :b, :f, :e, :e2).new(
|
|
40
47
|
'OSGB36',
|
|
@@ -252,22 +259,9 @@ module Geodetic
|
|
|
252
259
|
east_digits = $2
|
|
253
260
|
north_digits = $3
|
|
254
261
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
grid_y =
|
|
258
|
-
|
|
259
|
-
GRID_SQUARES.each_with_index do |row, y|
|
|
260
|
-
row.each_with_index do |square, x|
|
|
261
|
-
if square == letters
|
|
262
|
-
grid_x = x
|
|
263
|
-
grid_y = 12 - y
|
|
264
|
-
break
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
break if grid_x
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
raise ArgumentError, "Invalid grid square: #{letters}" unless grid_x
|
|
262
|
+
grid_coords = GRID_SQUARE_LOOKUP[letters]
|
|
263
|
+
raise ArgumentError, "Invalid grid square: #{letters}" unless grid_coords
|
|
264
|
+
grid_x, grid_y = grid_coords
|
|
271
265
|
|
|
272
266
|
# Calculate coordinates
|
|
273
267
|
precision = east_digits.length
|
|
@@ -279,22 +273,9 @@ module Geodetic
|
|
|
279
273
|
elsif grid_ref.match(/^([A-Z]{2})$/)
|
|
280
274
|
letters = $1
|
|
281
275
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
grid_y =
|
|
285
|
-
|
|
286
|
-
GRID_SQUARES.each_with_index do |row, y|
|
|
287
|
-
row.each_with_index do |square, x|
|
|
288
|
-
if square == letters
|
|
289
|
-
grid_x = x
|
|
290
|
-
grid_y = 12 - y
|
|
291
|
-
break
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
break if grid_x
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
raise ArgumentError, "Invalid grid square: #{letters}" unless grid_x
|
|
276
|
+
grid_coords = GRID_SQUARE_LOOKUP[letters]
|
|
277
|
+
raise ArgumentError, "Invalid grid square: #{letters}" unless grid_coords
|
|
278
|
+
grid_x, grid_y = grid_coords
|
|
298
279
|
|
|
299
280
|
@easting = grid_x * 100000 + 50000 # Center of square
|
|
300
281
|
@northing = grid_y * 100000 + 50000 # Center of square
|
|
@@ -353,7 +334,7 @@ module Geodetic
|
|
|
353
334
|
new(easting: easting, northing: northing)
|
|
354
335
|
end
|
|
355
336
|
|
|
356
|
-
Coordinate.register_class(self)
|
|
337
|
+
Coordinate.register_class(self, hash_conversion_style: :with_datum)
|
|
357
338
|
end
|
|
358
339
|
end
|
|
359
340
|
end
|
|
@@ -206,6 +206,10 @@ module Geodetic
|
|
|
206
206
|
new(x: parts[0].to_f, y: parts[1].to_f, z: parts[2].to_f)
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
+
def valid?
|
|
210
|
+
@x.finite? && @y.finite? && @z.finite?
|
|
211
|
+
end
|
|
212
|
+
|
|
209
213
|
def ==(other)
|
|
210
214
|
return false unless other.is_a?(ECEF)
|
|
211
215
|
|
|
@@ -217,7 +221,7 @@ module Geodetic
|
|
|
217
221
|
end
|
|
218
222
|
|
|
219
223
|
|
|
220
|
-
Coordinate.register_class(self)
|
|
224
|
+
Coordinate.register_class(self, hash_conversion_style: :no_datum)
|
|
221
225
|
end
|
|
222
226
|
end
|
|
223
227
|
end
|
|
@@ -212,6 +212,15 @@ module Geodetic
|
|
|
212
212
|
from_lla(lla, reference_lla)
|
|
213
213
|
end
|
|
214
214
|
|
|
215
|
+
def to_h3(reference_lla, precision: 7)
|
|
216
|
+
H3.new(to_lla(reference_lla), precision: precision)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.from_h3(h3_coord, reference_lla)
|
|
220
|
+
lla = h3_coord.to_lla
|
|
221
|
+
from_lla(lla, reference_lla)
|
|
222
|
+
end
|
|
223
|
+
|
|
215
224
|
def to_s(precision = 2)
|
|
216
225
|
precision = precision.to_i
|
|
217
226
|
if precision == 0
|
|
@@ -234,6 +243,10 @@ module Geodetic
|
|
|
234
243
|
new(e: parts[0].to_f, n: parts[1].to_f, u: parts[2].to_f)
|
|
235
244
|
end
|
|
236
245
|
|
|
246
|
+
def valid?
|
|
247
|
+
@e.finite? && @n.finite? && @u.finite?
|
|
248
|
+
end
|
|
249
|
+
|
|
237
250
|
def ==(other)
|
|
238
251
|
return false unless other.is_a?(ENU)
|
|
239
252
|
|
|
@@ -76,12 +76,12 @@ module Geodetic
|
|
|
76
76
|
VALID_LENGTHS.include?(@code.length) && valid_characters?(@code)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
protected
|
|
80
|
+
|
|
79
81
|
def code_value
|
|
80
82
|
@code
|
|
81
83
|
end
|
|
82
84
|
|
|
83
|
-
protected
|
|
84
|
-
|
|
85
85
|
def normalize(string)
|
|
86
86
|
result = String.new(capacity: string.length)
|
|
87
87
|
string.each_char.with_index do |ch, i|
|
|
@@ -227,7 +227,6 @@ module Geodetic
|
|
|
227
227
|
end
|
|
228
228
|
|
|
229
229
|
register_hash_system(:gars, self, default_precision: 7)
|
|
230
|
-
Coordinate.register_class(self)
|
|
231
230
|
end
|
|
232
231
|
end
|
|
233
232
|
end
|
|
@@ -55,12 +55,12 @@ module Geodetic
|
|
|
55
55
|
VALID_LENGTHS.include?(@code.length) && valid_characters?(@code)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
protected
|
|
59
|
+
|
|
58
60
|
def code_value
|
|
59
61
|
@code
|
|
60
62
|
end
|
|
61
63
|
|
|
62
|
-
protected
|
|
63
|
-
|
|
64
64
|
def normalize(string)
|
|
65
65
|
string.upcase
|
|
66
66
|
end
|
|
@@ -198,7 +198,6 @@ module Geodetic
|
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
register_hash_system(:georef, self, default_precision: 8)
|
|
201
|
-
Coordinate.register_class(self)
|
|
202
201
|
end
|
|
203
202
|
end
|
|
204
203
|
end
|
|
@@ -39,13 +39,12 @@ module Geodetic
|
|
|
39
39
|
@geohash.length > 0 && @geohash.each_char.all? { |c| CHAR_INDEX.key?(c) }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
protected
|
|
43
|
+
|
|
43
44
|
def code_value
|
|
44
45
|
@geohash
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
protected
|
|
48
|
-
|
|
49
48
|
def normalize(string)
|
|
50
49
|
string.downcase
|
|
51
50
|
end
|
|
@@ -155,7 +154,6 @@ module Geodetic
|
|
|
155
154
|
alias_method :validate_code!, :validate_geohash!
|
|
156
155
|
|
|
157
156
|
register_hash_system(:gh, self, default_precision: 12)
|
|
158
|
-
Coordinate.register_class(self)
|
|
159
157
|
end
|
|
160
158
|
end
|
|
161
159
|
end
|
|
@@ -74,10 +74,6 @@ module Geodetic
|
|
|
74
74
|
@geohash.length > 0 && @geohash.each_char.all? { |c| VALID_CHARS_SET.include?(c) }
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
def code_value
|
|
78
|
-
@geohash
|
|
79
|
-
end
|
|
80
|
-
|
|
81
77
|
# --- GH36-specific overrides (matrix-based algorithms) ---
|
|
82
78
|
|
|
83
79
|
# Uses recursive matrix-based neighbor calculation instead of bounds-based
|
|
@@ -105,6 +101,10 @@ module Geodetic
|
|
|
105
101
|
|
|
106
102
|
protected
|
|
107
103
|
|
|
104
|
+
def code_value
|
|
105
|
+
@geohash
|
|
106
|
+
end
|
|
107
|
+
|
|
108
108
|
def set_code(value)
|
|
109
109
|
@geohash = value
|
|
110
110
|
end
|
|
@@ -243,7 +243,6 @@ module Geodetic
|
|
|
243
243
|
alias_method :validate_code!, :validate_geohash!
|
|
244
244
|
|
|
245
245
|
register_hash_system(:gh36, self, default_precision: 10)
|
|
246
|
-
Coordinate.register_class(self)
|
|
247
246
|
end
|
|
248
247
|
end
|
|
249
248
|
end
|