has_geo_lookup 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +11 -11
- data/lib/has_geo_lookup/boundary_importer.rb +414 -0
- data/lib/has_geo_lookup/concern.rb +71 -12
- data/lib/has_geo_lookup/models/geoboundary.rb +14 -5
- data/lib/has_geo_lookup/models/metro.rb +3 -3
- data/lib/has_geo_lookup/version.rb +1 -1
- data/lib/has_geo_lookup.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6cd5875015bfee6d81f04d358796a47694b5aea8fad959bef3c86cced8db6896
|
4
|
+
data.tar.gz: 2dcbbe4f2fed3cc17b7875a29157c0f2688dc40139655b83677043afd8950528
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b7025ec64ed8e092cd88d3d8e7e4f99e5b64cdbe787f08f7d7c4096ae2e5155065d96d4dcdcb3850d89f68063d8a0c43b9b7957749d77897749fbb26d16e9d80
|
7
|
+
data.tar.gz: a9073f68cc449421fb2dc0bf2f4e3b710d68999418fb6d4b2d551fdf61e9ae592058566a359da352413653b0f3ce107870588d531a93e860d9a5e53aaeed1ddb
|
data/README.md
CHANGED
@@ -269,25 +269,25 @@ This gem integrates data from:
|
|
269
269
|
|
270
270
|
## Performance Considerations
|
271
271
|
|
272
|
-
###
|
272
|
+
### Index Analysis and Optimization
|
273
273
|
|
274
|
-
The gem includes built-in tools to analyze and
|
274
|
+
The gem includes built-in tools to analyze and optimize database indexes for HasGeoLookup functionality:
|
275
275
|
|
276
276
|
```bash
|
277
|
-
# Check
|
278
|
-
rake has_geo_lookup:
|
277
|
+
# Check index coverage for all models using HasGeoLookup
|
278
|
+
rake has_geo_lookup:check_indexes
|
279
279
|
|
280
|
-
# Preview what
|
281
|
-
rake has_geo_lookup:
|
280
|
+
# Preview what indexes would be created (dry run)
|
281
|
+
rake has_geo_lookup:preview_indexes
|
282
282
|
|
283
|
-
# Generate Rails migration for
|
284
|
-
rake has_geo_lookup:
|
283
|
+
# Generate Rails migration for missing columns and indexes
|
284
|
+
rake has_geo_lookup:create_indexes
|
285
285
|
|
286
286
|
# Analyze a specific model in detail
|
287
287
|
rake has_geo_lookup:analyze_model[Listing]
|
288
288
|
```
|
289
289
|
|
290
|
-
**Programmatic
|
290
|
+
**Programmatic Index Management:**
|
291
291
|
|
292
292
|
```ruby
|
293
293
|
# Get performance analysis for all models
|
@@ -307,9 +307,9 @@ migration_path = HasGeoLookup::IndexChecker.generate_index_migration(Listing)
|
|
307
307
|
puts HasGeoLookup::IndexChecker.performance_report
|
308
308
|
```
|
309
309
|
|
310
|
-
**
|
310
|
+
**Setup and Index Creation:**
|
311
311
|
|
312
|
-
The `
|
312
|
+
The `create_indexes` task generates proper Rails migration files that can be committed to version control and run as part of your deployment process. The migration includes:
|
313
313
|
|
314
314
|
- **Required columns**: Adds `latitude` and `longitude` decimal columns if missing
|
315
315
|
- **Recommended indexes**: Creates optimized database indexes for geographic queries
|
@@ -0,0 +1,414 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open-uri"
|
4
|
+
require "json"
|
5
|
+
require "rgeo"
|
6
|
+
require "fileutils"
|
7
|
+
require "digest/sha1"
|
8
|
+
require "iso_3166"
|
9
|
+
|
10
|
+
module HasGeoLookup
|
11
|
+
class BoundaryImporter
|
12
|
+
class << self
|
13
|
+
# Import all available ADM levels (1-4) for a country
|
14
|
+
#
|
15
|
+
# @param iso2_code [String] 2-letter ISO country code (e.g., "US", "FR")
|
16
|
+
# @param options [Hash] Import options
|
17
|
+
# @option options [String] :cache_dir Directory for caching downloaded files
|
18
|
+
# @option options [Boolean] :verbose Enable detailed progress output (default: true)
|
19
|
+
#
|
20
|
+
# @return [Hash] Import results with :success, :total_processed, :errors keys
|
21
|
+
def import_country(iso2_code, options = {})
|
22
|
+
iso2 = iso2_code&.upcase
|
23
|
+
raise ArgumentError, "Please provide a 2-letter country code" unless iso2
|
24
|
+
|
25
|
+
cache_dir = options[:cache_dir] || default_cache_dir
|
26
|
+
verbose = options.fetch(:verbose, true)
|
27
|
+
|
28
|
+
# Convert ISO2 to ISO3 using iso_3166 gem with fallbacks
|
29
|
+
iso3, country_name = resolve_country_codes(iso2, verbose)
|
30
|
+
|
31
|
+
errors = []
|
32
|
+
total_processed = 0
|
33
|
+
factory = create_geometry_factory
|
34
|
+
|
35
|
+
(1..5).each do |level|
|
36
|
+
adm = "ADM#{level}"
|
37
|
+
puts "📍 Importing #{adm} boundaries..." if verbose
|
38
|
+
|
39
|
+
result = import_adm_level(iso3, adm, factory, cache_dir, errors, verbose)
|
40
|
+
|
41
|
+
unless result[:success]
|
42
|
+
puts "⏭️ Skipping remaining ADM levels (#{adm} and higher not available)" if verbose
|
43
|
+
break
|
44
|
+
end
|
45
|
+
|
46
|
+
total_processed += result[:count]
|
47
|
+
end
|
48
|
+
|
49
|
+
{
|
50
|
+
success: true,
|
51
|
+
total_processed: total_processed,
|
52
|
+
errors: errors,
|
53
|
+
country: { iso2: iso2, iso3: iso3, name: country_name }
|
54
|
+
}
|
55
|
+
end
|
56
|
+
|
57
|
+
# Import boundaries for a specific ADM level
|
58
|
+
#
|
59
|
+
# @param iso2_code [String] 2-letter ISO country code
|
60
|
+
# @param adm_level [String] Administrative level ("ADM1", "ADM2", "ADM3", "ADM4", "ADM5")
|
61
|
+
# @param options [Hash] Import options (same as import_country)
|
62
|
+
#
|
63
|
+
# @return [Hash] Import results
|
64
|
+
def import_level(iso2_code, adm_level, options = {})
|
65
|
+
iso2 = iso2_code&.upcase
|
66
|
+
raise ArgumentError, "Please provide a 2-letter country code" unless iso2
|
67
|
+
raise ArgumentError, "Please provide ADM level (ADM1-ADM5)" unless adm_level&.match?(/\AADM[1-5]\z/)
|
68
|
+
|
69
|
+
cache_dir = options[:cache_dir] || default_cache_dir
|
70
|
+
verbose = options.fetch(:verbose, true)
|
71
|
+
|
72
|
+
iso3, country_name = resolve_country_codes(iso2, verbose)
|
73
|
+
|
74
|
+
errors = []
|
75
|
+
factory = create_geometry_factory
|
76
|
+
|
77
|
+
result = import_adm_level(iso3, adm_level, factory, cache_dir, errors, verbose)
|
78
|
+
|
79
|
+
{
|
80
|
+
success: result[:success],
|
81
|
+
total_processed: result[:count],
|
82
|
+
errors: errors,
|
83
|
+
country: { iso2: iso2, iso3: iso3, name: country_name }
|
84
|
+
}
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def default_cache_dir
|
90
|
+
if defined?(Rails)
|
91
|
+
Rails.root.join("db", "boundaries")
|
92
|
+
else
|
93
|
+
File.join(Dir.tmpdir, "has_geo_lookup_boundaries")
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def create_geometry_factory
|
98
|
+
RGeo::Cartesian.factory(
|
99
|
+
srid: 4326,
|
100
|
+
uses_lenient_assertions: true,
|
101
|
+
has_z_coordinate: false,
|
102
|
+
wkt_parser: { support_ewkt: true }
|
103
|
+
)
|
104
|
+
end
|
105
|
+
|
106
|
+
def resolve_country_codes(iso2, verbose = true)
|
107
|
+
country = Iso3166.for_code(iso2)
|
108
|
+
|
109
|
+
if country
|
110
|
+
iso3 = country.code3
|
111
|
+
country_name = country.name.downcase.split(' ').map(&:capitalize).join(' ')
|
112
|
+
puts "🌍 Converting #{iso2} → #{iso3} (#{country_name})" if verbose
|
113
|
+
[iso3, country_name]
|
114
|
+
else
|
115
|
+
# Fallback for codes not recognized by iso_3166 gem
|
116
|
+
puts "⚠️ Country code '#{iso2}' not found in iso_3166 gem, trying direct lookup..." if verbose
|
117
|
+
|
118
|
+
# Common manual mappings for missing territories
|
119
|
+
manual_mappings = {
|
120
|
+
'BL' => { iso3: 'BLM', name: 'Saint Barthélemy' },
|
121
|
+
'MF' => { iso3: 'MAF', name: 'Saint Martin' },
|
122
|
+
'SX' => { iso3: 'SXM', name: 'Sint Maarten' }
|
123
|
+
}
|
124
|
+
|
125
|
+
if manual_mappings[iso2]
|
126
|
+
mapping = manual_mappings[iso2]
|
127
|
+
puts "🌍 Using manual mapping: #{iso2} → #{mapping[:iso3]} (#{mapping[:name]})" if verbose
|
128
|
+
[mapping[:iso3], mapping[:name]]
|
129
|
+
else
|
130
|
+
# Last resort: use ISO2 as ISO3 and try the API call
|
131
|
+
puts "🌍 Using direct code: #{iso2} → #{iso2} (attempting API lookup)" if verbose
|
132
|
+
[iso2, iso2]
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def import_adm_level(iso3, adm_level, factory, cache_dir, errors = [], verbose = true)
|
138
|
+
api_url = "https://www.geoboundaries.org/api/current/gbOpen/#{iso3}/#{adm_level}/"
|
139
|
+
puts "🌍 Fetching metadata from #{api_url}..." if verbose
|
140
|
+
|
141
|
+
# Get metadata (not cached since it's small and may change)
|
142
|
+
begin
|
143
|
+
metadata_response = URI.open(api_url).read
|
144
|
+
metadata = JSON.parse(metadata_response)
|
145
|
+
rescue OpenURI::HTTPError => e
|
146
|
+
if e.message.include?("404")
|
147
|
+
puts "⚠️ #{adm_level} boundaries not available for #{iso3} (404 Not Found)" if verbose
|
148
|
+
return { success: false, count: 0 }
|
149
|
+
else
|
150
|
+
puts "❌ HTTP error fetching metadata: #{e.message}" if verbose
|
151
|
+
return { success: false, count: 0 }
|
152
|
+
end
|
153
|
+
rescue JSON::ParserError => e
|
154
|
+
puts "❌ Invalid JSON in API response: #{e.message}" if verbose
|
155
|
+
return { success: false, count: 0 }
|
156
|
+
rescue => e
|
157
|
+
puts "❌ Unexpected error fetching metadata: #{e.class}: #{e.message}" if verbose
|
158
|
+
return { success: false, count: 0 }
|
159
|
+
end
|
160
|
+
|
161
|
+
# Use simplified geometry if available, fall back to full resolution
|
162
|
+
geojson_url = metadata["simplifiedGeometryGeoJSON"] || metadata["gjDownloadURL"]
|
163
|
+
unless geojson_url
|
164
|
+
puts "⚠️ No download URL found in API response for #{adm_level}" if verbose
|
165
|
+
return { success: false, count: 0 }
|
166
|
+
end
|
167
|
+
|
168
|
+
simplified = metadata["simplifiedGeometryGeoJSON"] ? "(simplified)" : "(full resolution)"
|
169
|
+
puts "📐 Using #{simplified} geometry" if verbose
|
170
|
+
|
171
|
+
# Check if GeoJSON is already cached
|
172
|
+
cached_file = local_geojson_path(iso3, adm_level, geojson_url, cache_dir)
|
173
|
+
if File.exist?(cached_file)
|
174
|
+
puts "📂 Using cached GeoJSON: #{cached_file}" if verbose
|
175
|
+
begin
|
176
|
+
data = JSON.parse(File.read(cached_file, encoding: "UTF-8"))
|
177
|
+
rescue JSON::ParserError => e
|
178
|
+
puts "❌ Invalid JSON in cached file, re-downloading: #{e.message}" if verbose
|
179
|
+
File.delete(cached_file)
|
180
|
+
data = download_and_cache_geojson(geojson_url, cached_file, verbose)
|
181
|
+
return { success: false, count: 0 } unless data
|
182
|
+
rescue Encoding::UndefinedConversionError => e
|
183
|
+
puts "❌ Encoding error in cached file, re-downloading: #{e.message}" if verbose
|
184
|
+
File.delete(cached_file)
|
185
|
+
data = download_and_cache_geojson(geojson_url, cached_file, verbose)
|
186
|
+
return { success: false, count: 0 } unless data
|
187
|
+
end
|
188
|
+
else
|
189
|
+
data = download_and_cache_geojson(geojson_url, cached_file, verbose)
|
190
|
+
return { success: false, count: 0 } unless data
|
191
|
+
end
|
192
|
+
|
193
|
+
unless data["features"] && data["features"].any?
|
194
|
+
puts "⚠️ No boundary features found in GeoJSON for #{adm_level}" if verbose
|
195
|
+
return { success: false, count: 0 }
|
196
|
+
end
|
197
|
+
|
198
|
+
count = process_features(data["features"], adm_level, geojson_url, factory, errors, verbose)
|
199
|
+
|
200
|
+
puts "✅ Processed #{count} boundaries for #{adm_level}." if verbose
|
201
|
+
{ success: true, count: count }
|
202
|
+
end
|
203
|
+
|
204
|
+
def process_features(features, adm_level, geojson_url, factory, errors, verbose)
|
205
|
+
count = 0
|
206
|
+
|
207
|
+
features.each do |feature|
|
208
|
+
props = feature["properties"]
|
209
|
+
coords = feature["geometry"]["coordinates"]
|
210
|
+
next unless coords && props
|
211
|
+
|
212
|
+
name = props["shapeName"]
|
213
|
+
shape_id = props["shapeID"]
|
214
|
+
shape_iso = props["shapeISO"]
|
215
|
+
shape_group = props["shapeGroup"]
|
216
|
+
|
217
|
+
puts "🔍 Importing #{name.inspect}..." if verbose
|
218
|
+
|
219
|
+
# Process geometry
|
220
|
+
multi = create_multipolygon_from_feature(feature, factory, name, errors, verbose)
|
221
|
+
next unless multi
|
222
|
+
|
223
|
+
wkt = multi.as_text
|
224
|
+
|
225
|
+
# Insert into database using the gem's Geoboundary model
|
226
|
+
Geoboundary.connection.execute(<<~SQL)
|
227
|
+
INSERT INTO geoboundaries (name, level, shape_id, shape_iso, shape_group, source_url, boundary, created_at, updated_at)
|
228
|
+
VALUES (
|
229
|
+
#{ActiveRecord::Base.connection.quote(name)},
|
230
|
+
#{ActiveRecord::Base.connection.quote(adm_level)},
|
231
|
+
#{ActiveRecord::Base.connection.quote(shape_id)},
|
232
|
+
#{ActiveRecord::Base.connection.quote(shape_iso)},
|
233
|
+
#{ActiveRecord::Base.connection.quote(shape_group)},
|
234
|
+
#{ActiveRecord::Base.connection.quote(geojson_url)},
|
235
|
+
ST_GeomFromText('#{wkt}', 4326),
|
236
|
+
NOW(), NOW()
|
237
|
+
)
|
238
|
+
ON DUPLICATE KEY UPDATE
|
239
|
+
name = VALUES(name),
|
240
|
+
level = VALUES(level),
|
241
|
+
shape_iso = VALUES(shape_iso),
|
242
|
+
shape_group = VALUES(shape_group),
|
243
|
+
source_url = VALUES(source_url),
|
244
|
+
boundary = VALUES(boundary),
|
245
|
+
updated_at = NOW()
|
246
|
+
SQL
|
247
|
+
|
248
|
+
count += 1
|
249
|
+
rescue => e
|
250
|
+
# Enhanced error info with debug context
|
251
|
+
error_info = {
|
252
|
+
name: name || "Unknown",
|
253
|
+
adm_level: adm_level,
|
254
|
+
message: "#{e.class}: #{e.message}",
|
255
|
+
details: build_error_details(e, wkt, feature&.dig("geometry", "type"), coords&.class, coords&.size),
|
256
|
+
backtrace: e.is_a?(NoMethodError) ? e.backtrace.first(3) : nil
|
257
|
+
}
|
258
|
+
errors << error_info
|
259
|
+
puts "❌ Failed to process #{name.inspect}: #{e.class} (error details saved for summary)" if verbose
|
260
|
+
end
|
261
|
+
|
262
|
+
count
|
263
|
+
end
|
264
|
+
|
265
|
+
def create_multipolygon_from_feature(feature, factory, name, errors, verbose)
|
266
|
+
coords = feature["geometry"]["coordinates"]
|
267
|
+
geom_type = feature["geometry"]["type"]
|
268
|
+
|
269
|
+
# Collect debug geometry structure silently
|
270
|
+
coords_structure = "#{coords.class} -> #{coords.first&.class} -> #{coords.first&.first&.class}"
|
271
|
+
|
272
|
+
polygon_groups = geom_type == "MultiPolygon" ? coords : [coords]
|
273
|
+
debug_info = []
|
274
|
+
|
275
|
+
reversed_polygons = polygon_groups.map.with_index do |poly_coords, poly_index|
|
276
|
+
# Collect debug info silently for problematic geometries
|
277
|
+
unless poly_coords.is_a?(Array) && poly_coords.any?
|
278
|
+
debug_info << "poly_coords[#{poly_index}] is #{poly_coords.class}: #{poly_coords.inspect[0..200]}..."
|
279
|
+
next nil
|
280
|
+
end
|
281
|
+
|
282
|
+
raw_ring = poly_coords.first
|
283
|
+
unless raw_ring.is_a?(Array) && raw_ring.any?
|
284
|
+
debug_info << "raw_ring is #{raw_ring.class}: #{raw_ring.inspect[0..200]}..."
|
285
|
+
debug_info << "poly_coords structure: #{poly_coords.map(&:class)}"
|
286
|
+
next nil
|
287
|
+
end
|
288
|
+
|
289
|
+
outer =
|
290
|
+
if raw_ring.size == 2 && raw_ring.all? { |pt| pt.is_a?(Array) && pt.size == 2 }
|
291
|
+
puts "⚠️ Constructing rectangular fallback for #{name.inspect} (2 points)" if verbose
|
292
|
+
(lon1, lat1), (lon2, lat2) = raw_ring
|
293
|
+
|
294
|
+
rectangle = [
|
295
|
+
[lat1, lon1],
|
296
|
+
[lat2, lon1],
|
297
|
+
[lat2, lon2],
|
298
|
+
[lat1, lon2],
|
299
|
+
[lat1, lon1]
|
300
|
+
].map { |lat, lon| factory.point(lat, lon) }
|
301
|
+
|
302
|
+
factory.linear_ring(rectangle)
|
303
|
+
else
|
304
|
+
ring_points = raw_ring.map { |lon, lat| factory.point(lat, lon) }
|
305
|
+
factory.linear_ring(ring_points)
|
306
|
+
end
|
307
|
+
|
308
|
+
# Process holes with better validation
|
309
|
+
holes = []
|
310
|
+
if poly_coords.size > 1
|
311
|
+
debug_info << "Processing #{poly_coords.size - 1} holes"
|
312
|
+
holes = poly_coords[1..].filter_map.with_index do |ring, hole_index|
|
313
|
+
unless ring.is_a?(Array) && ring.any?
|
314
|
+
debug_info << "hole[#{hole_index}] is #{ring.class}: #{ring.inspect[0..100]}..."
|
315
|
+
next nil
|
316
|
+
end
|
317
|
+
|
318
|
+
begin
|
319
|
+
debug_info << "hole[#{hole_index}] has #{ring.size} points, first point: #{ring.first.inspect}"
|
320
|
+
hole_points = ring.map { |lon, lat| factory.point(lat, lon) }
|
321
|
+
factory.linear_ring(hole_points)
|
322
|
+
rescue => e
|
323
|
+
debug_info << "hole[#{hole_index}] failed: #{e.class}: #{e.message}"
|
324
|
+
debug_info << "hole[#{hole_index}] structure: #{ring.map(&:class).uniq}"
|
325
|
+
debug_info << "hole[#{hole_index}] sample: #{ring.first(3).inspect}"
|
326
|
+
nil
|
327
|
+
end
|
328
|
+
end
|
329
|
+
debug_info << "Successfully processed #{holes.size} holes"
|
330
|
+
end
|
331
|
+
|
332
|
+
begin
|
333
|
+
# RGeo factory.polygon expects: outer_ring, hole1, hole2, hole3...
|
334
|
+
debug_info << "Creating polygon with outer: #{outer.class}, holes: #{holes.size} (#{holes.map(&:class)})"
|
335
|
+
factory.polygon(outer, *holes)
|
336
|
+
rescue => e
|
337
|
+
debug_info << "polygon creation failed: #{e.class}: #{e.message}"
|
338
|
+
debug_info << "outer ring valid: #{outer.respond_to?(:exterior_ring)}"
|
339
|
+
debug_info << "holes valid: #{holes.all? { |h| h.respond_to?(:exterior_ring) }}"
|
340
|
+
# Try without holes as fallback
|
341
|
+
begin
|
342
|
+
debug_info << "Retrying without holes..."
|
343
|
+
factory.polygon(outer)
|
344
|
+
rescue => e2
|
345
|
+
debug_info << "outer ring also failed: #{e2.class}: #{e2.message}"
|
346
|
+
nil
|
347
|
+
end
|
348
|
+
end
|
349
|
+
end.compact
|
350
|
+
|
351
|
+
# Skip if no valid polygons were created
|
352
|
+
if reversed_polygons.empty?
|
353
|
+
debug_info << "No valid polygons found, skipping"
|
354
|
+
return nil
|
355
|
+
end
|
356
|
+
|
357
|
+
factory.multi_polygon(reversed_polygons)
|
358
|
+
end
|
359
|
+
|
360
|
+
def local_geojson_path(iso3, adm_level, geojson_url, cache_dir)
|
361
|
+
# Create a safe filename from the URL and parameters
|
362
|
+
url_hash = Digest::SHA1.hexdigest(geojson_url)[0..8]
|
363
|
+
filename = "#{iso3}-#{adm_level}-#{url_hash}.geojson"
|
364
|
+
File.join(cache_dir, filename)
|
365
|
+
end
|
366
|
+
|
367
|
+
def build_error_details(error, wkt, geom_type, coords_class, coords_size)
|
368
|
+
details = []
|
369
|
+
details << "WKT length: #{wkt&.length || 'n/a'}"
|
370
|
+
details << "Geometry type: #{geom_type || 'n/a'}"
|
371
|
+
details << "Coordinates: #{coords_class}"
|
372
|
+
details << "Coordinates size: #{coords_size || 'n/a'}"
|
373
|
+
|
374
|
+
if error.is_a?(NoMethodError)
|
375
|
+
details << "Method called on: #{error.receiver&.class || 'unknown'}"
|
376
|
+
end
|
377
|
+
|
378
|
+
details.join(", ")
|
379
|
+
end
|
380
|
+
|
381
|
+
def download_and_cache_geojson(geojson_url, cached_file, verbose = true)
|
382
|
+
puts "⬇️ Downloading GeoJSON from #{geojson_url}..." if verbose
|
383
|
+
|
384
|
+
begin
|
385
|
+
geojson_response = URI.open(geojson_url, "rb") do |file|
|
386
|
+
file.read.force_encoding("UTF-8")
|
387
|
+
end
|
388
|
+
data = JSON.parse(geojson_response)
|
389
|
+
|
390
|
+
# Ensure directory exists
|
391
|
+
FileUtils.mkdir_p(File.dirname(cached_file))
|
392
|
+
|
393
|
+
# Cache the file
|
394
|
+
File.write(cached_file, geojson_response, encoding: "UTF-8")
|
395
|
+
puts "💾 Cached GeoJSON to #{cached_file}" if verbose
|
396
|
+
|
397
|
+
return data
|
398
|
+
rescue OpenURI::HTTPError => e
|
399
|
+
puts "❌ HTTP error downloading GeoJSON: #{e.message}" if verbose
|
400
|
+
return nil
|
401
|
+
rescue JSON::ParserError => e
|
402
|
+
puts "❌ Invalid JSON in GeoJSON file: #{e.message}" if verbose
|
403
|
+
return nil
|
404
|
+
rescue Encoding::UndefinedConversionError => e
|
405
|
+
puts "❌ Encoding error in GeoJSON file: #{e.message}" if verbose
|
406
|
+
return nil
|
407
|
+
rescue => e
|
408
|
+
puts "❌ Unexpected error downloading GeoJSON: #{e.class}: #{e.message}" if verbose
|
409
|
+
return nil
|
410
|
+
end
|
411
|
+
end
|
412
|
+
end
|
413
|
+
end
|
414
|
+
end
|
@@ -154,7 +154,7 @@ module HasGeoLookup
|
|
154
154
|
end
|
155
155
|
|
156
156
|
# Looks up the closest township-level area using feature_code = ADM3.
|
157
|
-
# Falls back to ADM4 if no ADM3 match is found.
|
157
|
+
# Falls back to ADM4, then ADM5 if no ADM3 match is found.
|
158
158
|
#
|
159
159
|
# Options:
|
160
160
|
# :radius_km — limit search radius (default: 25)
|
@@ -162,7 +162,7 @@ module HasGeoLookup
|
|
162
162
|
#
|
163
163
|
# Returns a Result struct with distance and matched geoname.
|
164
164
|
def closest_township(radius_km: 25, country_code: nil)
|
165
|
-
%w[ADM3 ADM4].each do |code|
|
165
|
+
%w[ADM3 ADM4 ADM5].each do |code|
|
166
166
|
result = nearest_geonames(
|
167
167
|
feature_class: "A",
|
168
168
|
feature_code: code,
|
@@ -254,10 +254,10 @@ module HasGeoLookup
|
|
254
254
|
end
|
255
255
|
|
256
256
|
# GeoBoundary equivalent of closest_township
|
257
|
-
# Returns ADM3 or
|
257
|
+
# Returns ADM3, ADM4 or ADM5 boundary that contains this point, with fallback
|
258
258
|
def township_boundary
|
259
|
-
# Try ADM3 first, then ADM4
|
260
|
-
%w[
|
259
|
+
# Try ADM3 first, then ADM4, then ADM5
|
260
|
+
%w[ADM5 ADM4 ADM3].each do |level|
|
261
261
|
boundary = containing_boundary(level)
|
262
262
|
return GeoboundaryResult.new(boundary, 0.0, level, boundary.name) if boundary
|
263
263
|
end
|
@@ -286,7 +286,7 @@ module HasGeoLookup
|
|
286
286
|
return geoname_result unless geoname_result
|
287
287
|
|
288
288
|
# Add boundary context to help validate the subdivision
|
289
|
-
boundaries = containing_boundaries(levels: %w[ADM2 ADM3 ADM4])
|
289
|
+
boundaries = containing_boundaries(levels: %w[ADM2 ADM3 ADM4 ADM5])
|
290
290
|
geoname_result.record.define_singleton_method(:containing_boundaries) { boundaries }
|
291
291
|
|
292
292
|
geoname_result
|
@@ -770,8 +770,9 @@ module HasGeoLookup
|
|
770
770
|
# Check if boundary data exists for a country
|
771
771
|
#
|
772
772
|
# @param iso2 [String] 2-letter country code
|
773
|
+
# @param level [String, nil] Optional specific ADM level to check (e.g., "ADM2")
|
773
774
|
# @return [Boolean] true if boundary data exists
|
774
|
-
def has_boundary_data?(iso2)
|
775
|
+
def has_boundary_data?(iso2, level = nil)
|
775
776
|
# Special cases for territories that don't have separate boundary data
|
776
777
|
territories_without_boundaries = %w[PR VI GU AS MP TC] # US territories + others
|
777
778
|
return true if territories_without_boundaries.include?(iso2)
|
@@ -782,9 +783,54 @@ module HasGeoLookup
|
|
782
783
|
|
783
784
|
iso3 = country.code3
|
784
785
|
|
785
|
-
#
|
786
|
-
|
787
|
-
|
786
|
+
# Build query for boundaries with optional level filter
|
787
|
+
query = Geoboundary.where("shape_iso LIKE ? OR shape_group LIKE ?", "%#{iso3}%", "%#{iso3}%")
|
788
|
+
query = query.where(level: level) if level
|
789
|
+
|
790
|
+
query.exists?
|
791
|
+
end
|
792
|
+
|
793
|
+
# Get detailed boundary coverage by ADM level for a country
|
794
|
+
#
|
795
|
+
# @param iso2 [String] 2-letter country code
|
796
|
+
# @return [Hash] Hash with ADM levels as keys and counts as values
|
797
|
+
# @example
|
798
|
+
# boundary_coverage_by_level('US')
|
799
|
+
# # => { 'ADM1' => 51, 'ADM2' => 3142, 'ADM3' => 0, 'ADM4' => 0, 'ADM5' => 0 }
|
800
|
+
def boundary_coverage_by_level(iso2)
|
801
|
+
# Special cases for territories
|
802
|
+
territories_without_boundaries = %w[PR VI GU AS MP TC]
|
803
|
+
if territories_without_boundaries.include?(iso2)
|
804
|
+
return %w[ADM1 ADM2 ADM3 ADM4 ADM5].index_with { |_| 1 } # Assume coverage
|
805
|
+
end
|
806
|
+
|
807
|
+
# Convert ISO2 to ISO3
|
808
|
+
country = Iso3166.for_code(iso2)
|
809
|
+
return {} unless country
|
810
|
+
|
811
|
+
iso3 = country.code3
|
812
|
+
|
813
|
+
# Count boundaries by level for this country
|
814
|
+
boundaries_by_level = Geoboundary.where("shape_iso LIKE ? OR shape_group LIKE ?", "%#{iso3}%", "%#{iso3}%")
|
815
|
+
.group(:level)
|
816
|
+
.count
|
817
|
+
|
818
|
+
# Ensure all ADM levels are represented (with 0 counts for missing)
|
819
|
+
%w[ADM1 ADM2 ADM3 ADM4 ADM5].index_with do |level|
|
820
|
+
boundaries_by_level[level] || 0
|
821
|
+
end
|
822
|
+
end
|
823
|
+
|
824
|
+
# Get list of missing ADM levels for a country
|
825
|
+
#
|
826
|
+
# @param iso2 [String] 2-letter country code
|
827
|
+
# @return [Array<String>] Array of missing ADM level strings
|
828
|
+
# @example
|
829
|
+
# missing_adm_levels('US')
|
830
|
+
# # => ['ADM3', 'ADM4', 'ADM5']
|
831
|
+
def missing_adm_levels(iso2)
|
832
|
+
coverage = boundary_coverage_by_level(iso2)
|
833
|
+
coverage.select { |_level, count| count == 0 }.keys
|
788
834
|
end
|
789
835
|
|
790
836
|
# Check if geonames data exists for a country
|
@@ -803,15 +849,28 @@ module HasGeoLookup
|
|
803
849
|
# Get comprehensive coverage status for a country
|
804
850
|
#
|
805
851
|
# @param iso2 [String] 2-letter country code
|
806
|
-
# @return [Hash] Coverage status with
|
852
|
+
# @return [Hash] Coverage status with detailed boundary information
|
853
|
+
# @example
|
854
|
+
# coverage_status('US')
|
855
|
+
# # => {
|
856
|
+
# # boundaries: true,
|
857
|
+
# # geonames: true,
|
858
|
+
# # complete: false,
|
859
|
+
# # boundary_coverage: { 'ADM1' => 51, 'ADM2' => 3142, 'ADM3' => 0, 'ADM4' => 0, 'ADM5' => 0 },
|
860
|
+
# # missing_adm_levels: ['ADM3', 'ADM4', 'ADM5']
|
861
|
+
# # }
|
807
862
|
def coverage_status(iso2)
|
808
863
|
boundaries = has_boundary_data?(iso2)
|
809
864
|
geonames = has_geonames_data?(iso2)
|
865
|
+
boundary_coverage = boundary_coverage_by_level(iso2)
|
866
|
+
missing_levels = missing_adm_levels(iso2)
|
810
867
|
|
811
868
|
{
|
812
869
|
boundaries: boundaries,
|
813
870
|
geonames: geonames,
|
814
|
-
complete: boundaries && geonames
|
871
|
+
complete: boundaries && geonames && missing_levels.empty?,
|
872
|
+
boundary_coverage: boundary_coverage,
|
873
|
+
missing_adm_levels: missing_levels
|
815
874
|
}
|
816
875
|
end
|
817
876
|
|
@@ -13,12 +13,13 @@
|
|
13
13
|
# - ADM2: County/district boundaries (e.g., Los Angeles County)
|
14
14
|
# - ADM3: Municipality boundaries (e.g., city limits)
|
15
15
|
# - ADM4: Neighborhood/ward boundaries
|
16
|
+
# - ADM5: Sub-neighborhood boundaries (city blocks, micro-districts)
|
16
17
|
#
|
17
18
|
# The boundary geometries are stored using PostGIS and can be used for precise
|
18
19
|
# point-in-polygon queries to determine administrative containment.
|
19
20
|
#
|
20
21
|
# @attr [String] name Official boundary name
|
21
|
-
# @attr [String] level Administrative level (ADM0, ADM1, ADM2, ADM3, ADM4)
|
22
|
+
# @attr [String] level Administrative level (ADM0, ADM1, ADM2, ADM3, ADM4, ADM5)
|
22
23
|
# @attr [String] shape_iso ISO3 country code for this boundary
|
23
24
|
# @attr [String] shape_group Grouping identifier for related boundaries
|
24
25
|
# @attr [RGeo::Geos::CAPIGeometryMethods] boundary PostGIS geometry (polygon/multipolygon)
|
@@ -36,7 +37,7 @@ class Geoboundary < ActiveRecord::Base
|
|
36
37
|
|
37
38
|
# Validations
|
38
39
|
validates :name, presence: true
|
39
|
-
validates :level, presence: true, inclusion: { in: %w[ADM0 ADM1 ADM2 ADM3 ADM4] }
|
40
|
+
validates :level, presence: true, inclusion: { in: %w[ADM0 ADM1 ADM2 ADM3 ADM4 ADM5] }
|
40
41
|
validates :boundary, presence: true
|
41
42
|
|
42
43
|
# Scopes for different administrative levels
|
@@ -45,6 +46,7 @@ class Geoboundary < ActiveRecord::Base
|
|
45
46
|
scope :counties_districts, -> { where(level: "ADM2") }
|
46
47
|
scope :municipalities, -> { where(level: "ADM3") }
|
47
48
|
scope :neighborhoods, -> { where(level: "ADM4") }
|
49
|
+
scope :sub_neighborhoods, -> { where(level: "ADM5") }
|
48
50
|
|
49
51
|
scope :by_country, ->(country_code) {
|
50
52
|
iso3 = country_code_to_iso3(country_code)
|
@@ -52,12 +54,12 @@ class Geoboundary < ActiveRecord::Base
|
|
52
54
|
}
|
53
55
|
|
54
56
|
scope :containing_point, ->(latitude, longitude) {
|
55
|
-
where("ST_Contains(boundary, ST_GeomFromText(?, 4326))", "POINT(#{
|
57
|
+
where("ST_Contains(boundary, ST_GeomFromText(?, 4326))", "POINT(#{latitude} #{longitude})")
|
56
58
|
}
|
57
59
|
|
58
60
|
# Check if this boundary contains the given coordinates
|
59
61
|
#
|
60
|
-
# Uses
|
62
|
+
# Uses MySQL ST_Contains function to perform precise geometric containment
|
61
63
|
# testing against the boundary polygon.
|
62
64
|
#
|
63
65
|
# @param latitude [Float] Latitude in decimal degrees
|
@@ -70,7 +72,7 @@ class Geoboundary < ActiveRecord::Base
|
|
70
72
|
def contains_point?(latitude, longitude)
|
71
73
|
return false unless latitude && longitude && boundary
|
72
74
|
|
73
|
-
point_wkt = "POINT(#{
|
75
|
+
point_wkt = "POINT(#{latitude} #{longitude})"
|
74
76
|
|
75
77
|
self.class.connection.select_value(
|
76
78
|
"SELECT ST_Contains(ST_GeomFromText(?), ST_GeomFromText(?, 4326)) AS contains",
|
@@ -146,6 +148,7 @@ class Geoboundary < ActiveRecord::Base
|
|
146
148
|
when "ADM2" then "County/District"
|
147
149
|
when "ADM3" then "Municipality"
|
148
150
|
when "ADM4" then "Neighborhood/Ward"
|
151
|
+
when "ADM5" then "Sub-Neighborhood/Block"
|
149
152
|
else level
|
150
153
|
end
|
151
154
|
|
@@ -185,6 +188,12 @@ class Geoboundary < ActiveRecord::Base
|
|
185
188
|
level == "ADM4"
|
186
189
|
end
|
187
190
|
|
191
|
+
# Check if this is a sub-neighborhood-level boundary
|
192
|
+
# @return [Boolean] true if level is "ADM5"
|
193
|
+
def sub_neighborhood?
|
194
|
+
level == "ADM5"
|
195
|
+
end
|
196
|
+
|
188
197
|
private
|
189
198
|
|
190
199
|
# Convert 2-letter country code to 3-letter ISO3 code
|
@@ -45,13 +45,13 @@ class Metro < ActiveRecord::Base
|
|
45
45
|
scope :containing_point, ->(latitude, longitude) {
|
46
46
|
joins(:geoboundaries)
|
47
47
|
.where("ST_Contains(geoboundaries.boundary, ST_GeomFromText(?, 4326))",
|
48
|
-
"POINT(#{
|
48
|
+
"POINT(#{latitude} #{longitude})")
|
49
49
|
.distinct
|
50
50
|
}
|
51
51
|
|
52
52
|
# Check if this metro contains the given coordinates
|
53
53
|
#
|
54
|
-
# Uses
|
54
|
+
# Uses MySQL spatial queries against all associated geoboundaries to determine
|
55
55
|
# if the point falls within any boundary that defines this metropolitan area.
|
56
56
|
#
|
57
57
|
# @param latitude [Float] Latitude in decimal degrees
|
@@ -68,7 +68,7 @@ class Metro < ActiveRecord::Base
|
|
68
68
|
# Check if point is contained within any of the metro's boundaries
|
69
69
|
geoboundaries.joins("INNER JOIN geoboundaries gb ON gb.id = geoboundaries.id")
|
70
70
|
.where("ST_Contains(gb.boundary, ST_GeomFromText(?, 4326))",
|
71
|
-
"POINT(#{
|
71
|
+
"POINT(#{latitude} #{longitude})")
|
72
72
|
.exists?
|
73
73
|
rescue => e
|
74
74
|
Rails.logger.warn "Error checking metro point containment: #{e.message}"
|
data/lib/has_geo_lookup.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: has_geo_lookup
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Michael Edlund
|
@@ -145,6 +145,7 @@ files:
|
|
145
145
|
- lib/generators/has_geo_lookup/templates/create_geonames_metros.rb.erb
|
146
146
|
- lib/generators/has_geo_lookup/templates/create_metros.rb.erb
|
147
147
|
- lib/has_geo_lookup.rb
|
148
|
+
- lib/has_geo_lookup/boundary_importer.rb
|
148
149
|
- lib/has_geo_lookup/concern.rb
|
149
150
|
- lib/has_geo_lookup/index_checker.rb
|
150
151
|
- lib/has_geo_lookup/models/feature_code.rb
|