has_geo_lookup 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +389 -0
- data/Rakefile +8 -0
- data/lib/generators/has_geo_lookup/install_generator.rb +66 -0
- data/lib/generators/has_geo_lookup/templates/INSTALL.md +60 -0
- data/lib/generators/has_geo_lookup/templates/create_feature_codes.rb.erb +17 -0
- data/lib/generators/has_geo_lookup/templates/create_geoboundaries.rb.erb +24 -0
- data/lib/generators/has_geo_lookup/templates/create_geoboundaries_metros.rb.erb +10 -0
- data/lib/generators/has_geo_lookup/templates/create_geonames.rb.erb +40 -0
- data/lib/generators/has_geo_lookup/templates/create_geonames_metros.rb.erb +10 -0
- data/lib/generators/has_geo_lookup/templates/create_metros.rb.erb +17 -0
- data/lib/has_geo_lookup/concern.rb +813 -0
- data/lib/has_geo_lookup/index_checker.rb +360 -0
- data/lib/has_geo_lookup/models/feature_code.rb +194 -0
- data/lib/has_geo_lookup/models/geoboundary.rb +220 -0
- data/lib/has_geo_lookup/models/geoname.rb +152 -0
- data/lib/has_geo_lookup/models/metro.rb +247 -0
- data/lib/has_geo_lookup/railtie.rb +11 -0
- data/lib/has_geo_lookup/version.rb +5 -0
- data/lib/has_geo_lookup.rb +28 -0
- data/lib/tasks/has_geo_lookup.rake +111 -0
- data/sig/has_geo_lookup.rbs +4 -0
- metadata +183 -0
@@ -0,0 +1,220 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Administrative boundary geometries from GeoBoundaries.org
|
4
|
+
#
|
5
|
+
# This model stores precise administrative boundary polygons from GeoBoundaries.org,
|
6
|
+
# providing accurate geometric shapes for countries, states, counties, and municipalities
|
7
|
+
# worldwide. Each boundary includes PostGIS geometry data for spatial queries and
|
8
|
+
# containment testing.
|
9
|
+
#
|
10
|
+
# GeoBoundaries provides multiple administrative levels:
|
11
|
+
# - ADM0: Country boundaries
|
12
|
+
# - ADM1: State/province boundaries (e.g., California, Ontario)
|
13
|
+
# - ADM2: County/district boundaries (e.g., Los Angeles County)
|
14
|
+
# - ADM3: Municipality boundaries (e.g., city limits)
|
15
|
+
# - ADM4: Neighborhood/ward boundaries
|
16
|
+
#
|
17
|
+
# The boundary geometries are stored using PostGIS and can be used for precise
|
18
|
+
# point-in-polygon queries to determine administrative containment.
|
19
|
+
#
|
20
|
+
# @attr [String] name Official boundary name
|
21
|
+
# @attr [String] level Administrative level (ADM0, ADM1, ADM2, ADM3, ADM4)
|
22
|
+
# @attr [String] shape_iso ISO3 country code for this boundary
|
23
|
+
# @attr [String] shape_group Grouping identifier for related boundaries
|
24
|
+
# @attr [RGeo::Geos::CAPIGeometryMethods] boundary PostGIS geometry (polygon/multipolygon)
|
25
|
+
#
|
26
|
+
# @example Find boundaries containing a point
|
27
|
+
# Geoboundary.where("ST_Contains(boundary, ST_GeomFromText('POINT(-74.0060 40.7128)', 4326))")
|
28
|
+
#
|
29
|
+
# @example Find state-level boundaries for the US
|
30
|
+
# Geoboundary.where(level: "ADM1").where("shape_iso LIKE '%USA%'")
|
31
|
+
#
|
32
|
+
# @see https://www.geoboundaries.org GeoBoundaries.org data source
|
33
|
+
class Geoboundary < ActiveRecord::Base
|
34
|
+
# Associations
|
35
|
+
has_and_belongs_to_many :metros
|
36
|
+
|
37
|
+
# Validations
|
38
|
+
validates :name, presence: true
|
39
|
+
validates :level, presence: true, inclusion: { in: %w[ADM0 ADM1 ADM2 ADM3 ADM4] }
|
40
|
+
validates :boundary, presence: true
|
41
|
+
|
42
|
+
# Scopes for different administrative levels
|
43
|
+
scope :countries, -> { where(level: "ADM0") }
|
44
|
+
scope :states_provinces, -> { where(level: "ADM1") }
|
45
|
+
scope :counties_districts, -> { where(level: "ADM2") }
|
46
|
+
scope :municipalities, -> { where(level: "ADM3") }
|
47
|
+
scope :neighborhoods, -> { where(level: "ADM4") }
|
48
|
+
|
49
|
+
scope :by_country, ->(country_code) {
|
50
|
+
iso3 = country_code_to_iso3(country_code)
|
51
|
+
where("shape_iso LIKE ? OR shape_group LIKE ?", "%#{iso3}%", "%#{iso3}%") if iso3
|
52
|
+
}
|
53
|
+
|
54
|
+
scope :containing_point, ->(latitude, longitude) {
|
55
|
+
where("ST_Contains(boundary, ST_GeomFromText(?, 4326))", "POINT(#{longitude} #{latitude})")
|
56
|
+
}
|
57
|
+
|
58
|
+
# Check if this boundary contains the given coordinates
|
59
|
+
#
|
60
|
+
# Uses PostGIS ST_Contains function to perform precise geometric containment
|
61
|
+
# testing against the boundary polygon.
|
62
|
+
#
|
63
|
+
# @param latitude [Float] Latitude in decimal degrees
|
64
|
+
# @param longitude [Float] Longitude in decimal degrees
|
65
|
+
# @return [Boolean] true if the point is within this boundary
|
66
|
+
#
|
67
|
+
# @example
|
68
|
+
# boundary.contains_point?(40.7128, -74.0060)
|
69
|
+
# # => true (if NYC coordinates are within this boundary)
|
70
|
+
def contains_point?(latitude, longitude)
|
71
|
+
return false unless latitude && longitude && boundary
|
72
|
+
|
73
|
+
point_wkt = "POINT(#{longitude} #{latitude})"
|
74
|
+
|
75
|
+
self.class.connection.select_value(
|
76
|
+
"SELECT ST_Contains(ST_GeomFromText(?), ST_GeomFromText(?, 4326)) AS contains",
|
77
|
+
boundary.to_s, point_wkt
|
78
|
+
) == 1
|
79
|
+
rescue => e
|
80
|
+
Rails.logger.warn "Error checking point containment: #{e.message}"
|
81
|
+
false
|
82
|
+
end
|
83
|
+
|
84
|
+
# Calculate the area of this boundary in square kilometers
|
85
|
+
#
|
86
|
+
# Uses PostGIS ST_Area function with spheroid calculations for accurate
|
87
|
+
# area computation on the Earth's surface.
|
88
|
+
#
|
89
|
+
# @return [Float] Area in square kilometers
|
90
|
+
#
|
91
|
+
# @example
|
92
|
+
# boundary.area_km2
|
93
|
+
# # => 10991.5 (area in square kilometers)
|
94
|
+
def area_km2
|
95
|
+
return nil unless boundary
|
96
|
+
|
97
|
+
area_m2 = self.class.connection.select_value(
|
98
|
+
"SELECT ST_Area(ST_Transform(ST_GeomFromText(?), 3857))",
|
99
|
+
boundary.to_s
|
100
|
+
)
|
101
|
+
|
102
|
+
area_m2 ? (area_m2 / 1_000_000).round(2) : nil
|
103
|
+
rescue => e
|
104
|
+
Rails.logger.warn "Error calculating boundary area: #{e.message}"
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
# Get the centroid (center point) of this boundary
|
109
|
+
#
|
110
|
+
# Uses PostGIS ST_Centroid function to find the geometric center
|
111
|
+
# of the boundary polygon.
|
112
|
+
#
|
113
|
+
# @return [Array<Float>, nil] [latitude, longitude] of centroid, or nil if error
|
114
|
+
#
|
115
|
+
# @example
|
116
|
+
# boundary.centroid
|
117
|
+
# # => [40.7589, -73.9851] (lat, lng of boundary center)
|
118
|
+
def centroid
|
119
|
+
return nil unless boundary
|
120
|
+
|
121
|
+
result = self.class.connection.select_one(
|
122
|
+
"SELECT ST_Y(ST_Centroid(ST_GeomFromText(?))) AS lat, ST_X(ST_Centroid(ST_GeomFromText(?))) AS lng",
|
123
|
+
boundary.to_s, boundary.to_s
|
124
|
+
)
|
125
|
+
|
126
|
+
result ? [result['lat'].to_f, result['lng'].to_f] : nil
|
127
|
+
rescue => e
|
128
|
+
Rails.logger.warn "Error calculating boundary centroid: #{e.message}"
|
129
|
+
nil
|
130
|
+
end
|
131
|
+
|
132
|
+
# Returns a human-readable description of this boundary
|
133
|
+
#
|
134
|
+
# Includes the boundary name, administrative level, and country context
|
135
|
+
# for clear identification.
|
136
|
+
#
|
137
|
+
# @return [String] Formatted description
|
138
|
+
#
|
139
|
+
# @example
|
140
|
+
# boundary.display_name
|
141
|
+
# # => "Los Angeles County (ADM2 - County/District, USA)"
|
142
|
+
def display_name
|
143
|
+
level_description = case level
|
144
|
+
when "ADM0" then "Country"
|
145
|
+
when "ADM1" then "State/Province"
|
146
|
+
when "ADM2" then "County/District"
|
147
|
+
when "ADM3" then "Municipality"
|
148
|
+
when "ADM4" then "Neighborhood/Ward"
|
149
|
+
else level
|
150
|
+
end
|
151
|
+
|
152
|
+
country_info = extract_country_from_shape_iso
|
153
|
+
country_suffix = country_info ? ", #{country_info}" : ""
|
154
|
+
|
155
|
+
"#{name} (#{level} - #{level_description}#{country_suffix})"
|
156
|
+
end
|
157
|
+
|
158
|
+
# Check if this is a country-level boundary
|
159
|
+
# @return [Boolean] true if level is "ADM0"
|
160
|
+
def country?
|
161
|
+
level == "ADM0"
|
162
|
+
end
|
163
|
+
|
164
|
+
# Check if this is a state/province-level boundary
|
165
|
+
# @return [Boolean] true if level is "ADM1"
|
166
|
+
def state_province?
|
167
|
+
level == "ADM1"
|
168
|
+
end
|
169
|
+
|
170
|
+
# Check if this is a county/district-level boundary
|
171
|
+
# @return [Boolean] true if level is "ADM2"
|
172
|
+
def county_district?
|
173
|
+
level == "ADM2"
|
174
|
+
end
|
175
|
+
|
176
|
+
# Check if this is a municipality-level boundary
|
177
|
+
# @return [Boolean] true if level is "ADM3"
|
178
|
+
def municipality?
|
179
|
+
level == "ADM3"
|
180
|
+
end
|
181
|
+
|
182
|
+
# Check if this is a neighborhood-level boundary
|
183
|
+
# @return [Boolean] true if level is "ADM4"
|
184
|
+
def neighborhood?
|
185
|
+
level == "ADM4"
|
186
|
+
end
|
187
|
+
|
188
|
+
private
|
189
|
+
|
190
|
+
# Convert 2-letter country code to 3-letter ISO3 code
|
191
|
+
def self.country_code_to_iso3(country_code)
|
192
|
+
return nil unless country_code&.length == 2
|
193
|
+
|
194
|
+
begin
|
195
|
+
country = Iso3166.for_code(country_code.upcase)
|
196
|
+
country&.code3
|
197
|
+
rescue
|
198
|
+
nil
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# Extract country information from shape_iso field
|
203
|
+
def extract_country_from_shape_iso
|
204
|
+
return nil unless shape_iso.present?
|
205
|
+
|
206
|
+
# shape_iso often contains patterns like "USA-ADM1" or similar
|
207
|
+
# Extract the country code portion
|
208
|
+
country_match = shape_iso.match(/([A-Z]{3})/)
|
209
|
+
return nil unless country_match
|
210
|
+
|
211
|
+
iso3_code = country_match[1]
|
212
|
+
|
213
|
+
begin
|
214
|
+
country = Iso3166.for_alpha3(iso3_code)
|
215
|
+
country&.name
|
216
|
+
rescue
|
217
|
+
iso3_code
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Geoname model for geographic place names from Geonames.org
|
4
|
+
#
|
5
|
+
# This model represents geographic features from the Geonames.org dataset, which provides
|
6
|
+
# comprehensive information about populated places, administrative divisions, and geographic
|
7
|
+
# features worldwide. Each record includes coordinates, names, administrative codes, and
|
8
|
+
# feature classifications.
|
9
|
+
#
|
10
|
+
# The Geonames dataset is organized using feature classes and codes that categorize
|
11
|
+
# different types of geographic entities (populated places, administrative areas,
|
12
|
+
# hydrographic features, etc.).
|
13
|
+
#
|
14
|
+
# @example Find populated places in the US
|
15
|
+
# Geoname.where(country_code: "US", feature_class: "P")
|
16
|
+
#
|
17
|
+
# @example Find administrative divisions
|
18
|
+
# Geoname.where(feature_class: "A", feature_code: "ADM2")
|
19
|
+
#
|
20
|
+
# @see https://www.geonames.org/ Official Geonames website
|
21
|
+
# @see FeatureCode For feature classification definitions
|
22
|
+
class Geoname < ActiveRecord::Base
|
23
|
+
include HasGeoLookup
|
24
|
+
|
25
|
+
# Associations
|
26
|
+
has_and_belongs_to_many :metros
|
27
|
+
|
28
|
+
# Validations
|
29
|
+
validates :name, presence: true
|
30
|
+
validates :latitude, :longitude, presence: true, numericality: true
|
31
|
+
validates :feature_class, presence: true, length: { is: 1 }
|
32
|
+
validates :feature_code, presence: true, length: { maximum: 10 }
|
33
|
+
validates :country_code, length: { is: 2 }, allow_blank: true
|
34
|
+
validates :population, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
35
|
+
validates :elevation, numericality: true, allow_nil: true
|
36
|
+
|
37
|
+
# Scopes for common queries
|
38
|
+
scope :populated_places, -> { where(feature_class: "P") }
|
39
|
+
scope :administrative, -> { where(feature_class: "A") }
|
40
|
+
scope :hydrographic, -> { where(feature_class: "H") }
|
41
|
+
scope :terrain, -> { where(feature_class: "T") }
|
42
|
+
scope :roads_railways, -> { where(feature_class: "R") }
|
43
|
+
scope :spots_buildings, -> { where(feature_class: "S") }
|
44
|
+
scope :undersea, -> { where(feature_class: "U") }
|
45
|
+
scope :vegetation, -> { where(feature_class: "V") }
|
46
|
+
|
47
|
+
scope :by_country, ->(country_code) { where(country_code: country_code.upcase) }
|
48
|
+
scope :with_population, -> { where.not(population: [nil, 0]) }
|
49
|
+
scope :major_places, -> { with_population.where("population > ?", 100000) }
|
50
|
+
|
51
|
+
# Geographic bounds scopes
|
52
|
+
scope :within_bounds, ->(north, south, east, west) {
|
53
|
+
where(latitude: south..north, longitude: west..east)
|
54
|
+
}
|
55
|
+
|
56
|
+
scope :near_coordinates, ->(lat, lng, radius_deg = 1.0) {
|
57
|
+
where(
|
58
|
+
latitude: (lat - radius_deg)..(lat + radius_deg),
|
59
|
+
longitude: (lng - radius_deg)..(lng + radius_deg)
|
60
|
+
)
|
61
|
+
}
|
62
|
+
|
63
|
+
# Calculate distance to coordinates using Haversine formula
|
64
|
+
#
|
65
|
+
# This method calculates the great-circle distance between this geoname's coordinates
|
66
|
+
# and the provided coordinates using the Haversine formula. Useful for finding nearby
|
67
|
+
# places or sorting by distance.
|
68
|
+
#
|
69
|
+
# @param target_lat [Float] Target latitude in degrees
|
70
|
+
# @param target_lng [Float] Target longitude in degrees
|
71
|
+
# @return [Float] Distance in kilometers
|
72
|
+
#
|
73
|
+
# @example
|
74
|
+
# geoname.distance_to(40.7128, -74.0060)
|
75
|
+
# # => 245.67 (distance in kilometers)
|
76
|
+
def distance_to(target_lat, target_lng)
|
77
|
+
return nil unless latitude && longitude && target_lat && target_lng
|
78
|
+
|
79
|
+
# Haversine formula
|
80
|
+
radius_km = 6371.0
|
81
|
+
lat1_rad = latitude * Math::PI / 180
|
82
|
+
lat2_rad = target_lat * Math::PI / 180
|
83
|
+
delta_lat_rad = (target_lat - latitude) * Math::PI / 180
|
84
|
+
delta_lng_rad = (target_lng - longitude) * Math::PI / 180
|
85
|
+
|
86
|
+
a = Math.sin(delta_lat_rad / 2) * Math.sin(delta_lat_rad / 2) +
|
87
|
+
Math.cos(lat1_rad) * Math.cos(lat2_rad) *
|
88
|
+
Math.sin(delta_lng_rad / 2) * Math.sin(delta_lng_rad / 2)
|
89
|
+
|
90
|
+
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
91
|
+
radius_km * c
|
92
|
+
end
|
93
|
+
|
94
|
+
# Returns a human-readable description of this place
|
95
|
+
#
|
96
|
+
# Combines the name with administrative context and country information to provide
|
97
|
+
# a comprehensive description suitable for display or logging.
|
98
|
+
#
|
99
|
+
# @return [String] Formatted description
|
100
|
+
#
|
101
|
+
# @example
|
102
|
+
# geoname.display_name
|
103
|
+
# # => "New York, New York, US (PPL - populated place)"
|
104
|
+
def display_name
|
105
|
+
parts = [name]
|
106
|
+
parts << admin1_name if admin1_name.present?
|
107
|
+
parts << admin2_name if admin2_name.present? && admin2_name != admin1_name
|
108
|
+
parts << country_code if country_code.present?
|
109
|
+
|
110
|
+
description = parts.join(", ")
|
111
|
+
|
112
|
+
if feature_code.present?
|
113
|
+
description += " (#{feature_code})"
|
114
|
+
end
|
115
|
+
|
116
|
+
description
|
117
|
+
end
|
118
|
+
|
119
|
+
# Check if this is a populated place
|
120
|
+
# @return [Boolean] true if feature_class is "P"
|
121
|
+
def populated_place?
|
122
|
+
feature_class == "P"
|
123
|
+
end
|
124
|
+
|
125
|
+
# Check if this is an administrative division
|
126
|
+
# @return [Boolean] true if feature_class is "A"
|
127
|
+
def administrative_division?
|
128
|
+
feature_class == "A"
|
129
|
+
end
|
130
|
+
|
131
|
+
# Get the administrative level for administrative divisions
|
132
|
+
# @return [String, nil] Administrative level (ADM1, ADM2, etc.) or nil
|
133
|
+
def administrative_level
|
134
|
+
return nil unless administrative_division?
|
135
|
+
feature_code if feature_code&.start_with?("ADM")
|
136
|
+
end
|
137
|
+
|
138
|
+
# Ensure country codes are stored in uppercase
|
139
|
+
def country_code=(value)
|
140
|
+
super(value&.upcase)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Ensure feature class is stored in uppercase
|
144
|
+
def feature_class=(value)
|
145
|
+
super(value&.upcase)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Ensure feature code is stored in uppercase
|
149
|
+
def feature_code=(value)
|
150
|
+
super(value&.upcase)
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,247 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Metropolitan areas defined by collections of administrative boundaries
|
4
|
+
#
|
5
|
+
# This model represents metropolitan areas (metro areas, urban agglomerations) as
|
6
|
+
# collections of administrative boundaries from GeoBoundaries.org. Rather than storing
|
7
|
+
# separate geometry, metros are defined by their constituent geoboundaries, allowing
|
8
|
+
# for flexible and maintainable metro definitions.
|
9
|
+
#
|
10
|
+
# Metro areas are useful for:
|
11
|
+
# - Real estate market analysis by metropolitan region
|
12
|
+
# - Economic data aggregation across municipal boundaries
|
13
|
+
# - Transportation and infrastructure planning
|
14
|
+
# - Population and demographic analysis
|
15
|
+
#
|
16
|
+
# Each metro can span multiple administrative levels and boundaries, reflecting
|
17
|
+
# the real-world nature of metropolitan areas that often cross county, city,
|
18
|
+
# and sometimes state boundaries.
|
19
|
+
#
|
20
|
+
# @attr [String] name Metro area name (e.g., "San Francisco Bay Area", "Greater London")
|
21
|
+
# @attr [String] details Additional descriptive information about the metro
|
22
|
+
# @attr [String] country_code Primary country code for this metro area
|
23
|
+
# @attr [Integer] population Estimated metro population (optional)
|
24
|
+
#
|
25
|
+
# @example Find metros containing a point
|
26
|
+
# Metro.joins(:geoboundaries)
|
27
|
+
# .where("ST_Contains(geoboundaries.boundary, ST_GeomFromText(?, 4326))", "POINT(-122.4194 37.7749)")
|
28
|
+
#
|
29
|
+
# @example Find metros in a specific country
|
30
|
+
# Metro.where(country_code: "US")
|
31
|
+
class Metro < ActiveRecord::Base
|
32
|
+
# Associations
|
33
|
+
has_and_belongs_to_many :geoboundaries
|
34
|
+
|
35
|
+
# Validations
|
36
|
+
validates :name, presence: true, uniqueness: true
|
37
|
+
validates :country_code, length: { is: 2 }, allow_blank: true
|
38
|
+
validates :population, numericality: { greater_than: 0 }, allow_nil: true
|
39
|
+
|
40
|
+
# Scopes
|
41
|
+
scope :by_country, ->(country_code) { where(country_code: country_code.upcase) }
|
42
|
+
scope :with_population, -> { where.not(population: nil) }
|
43
|
+
scope :major_metros, -> { with_population.where("population > ?", 1_000_000) }
|
44
|
+
|
45
|
+
scope :containing_point, ->(latitude, longitude) {
|
46
|
+
joins(:geoboundaries)
|
47
|
+
.where("ST_Contains(geoboundaries.boundary, ST_GeomFromText(?, 4326))",
|
48
|
+
"POINT(#{longitude} #{latitude})")
|
49
|
+
.distinct
|
50
|
+
}
|
51
|
+
|
52
|
+
# Check if this metro contains the given coordinates
|
53
|
+
#
|
54
|
+
# Uses PostGIS spatial queries against all associated geoboundaries to determine
|
55
|
+
# if the point falls within any boundary that defines this metropolitan area.
|
56
|
+
#
|
57
|
+
# @param latitude [Float] Latitude in decimal degrees
|
58
|
+
# @param longitude [Float] Longitude in decimal degrees
|
59
|
+
# @return [Boolean] true if the point is within this metro area
|
60
|
+
#
|
61
|
+
# @example
|
62
|
+
# metro.contains_point?(37.7749, -122.4194)
|
63
|
+
# # => true (if coordinates are within San Francisco Bay Area)
|
64
|
+
def contains_point?(latitude, longitude)
|
65
|
+
return false unless latitude && longitude
|
66
|
+
return false unless geoboundaries.any?
|
67
|
+
|
68
|
+
# Check if point is contained within any of the metro's boundaries
|
69
|
+
geoboundaries.joins("INNER JOIN geoboundaries gb ON gb.id = geoboundaries.id")
|
70
|
+
.where("ST_Contains(gb.boundary, ST_GeomFromText(?, 4326))",
|
71
|
+
"POINT(#{longitude} #{latitude})")
|
72
|
+
.exists?
|
73
|
+
rescue => e
|
74
|
+
Rails.logger.warn "Error checking metro point containment: #{e.message}"
|
75
|
+
false
|
76
|
+
end
|
77
|
+
|
78
|
+
# Calculate the total area of this metro in square kilometers
|
79
|
+
#
|
80
|
+
# Sums the areas of all constituent geoboundaries, with handling for
|
81
|
+
# overlapping boundaries to avoid double-counting.
|
82
|
+
#
|
83
|
+
# @return [Float, nil] Total area in square kilometers, or nil if no boundaries
|
84
|
+
#
|
85
|
+
# @example
|
86
|
+
# metro.total_area_km2
|
87
|
+
# # => 18040.5 (total metro area in square kilometers)
|
88
|
+
def total_area_km2
|
89
|
+
return nil unless geoboundaries.any?
|
90
|
+
|
91
|
+
# Calculate total area using ST_Union to handle overlapping boundaries
|
92
|
+
result = self.class.connection.select_value(<<~SQL.squish)
|
93
|
+
SELECT ST_Area(ST_Transform(ST_Union(boundary), 3857)) / 1000000 AS total_area_km2
|
94
|
+
FROM geoboundaries
|
95
|
+
WHERE id IN (#{geoboundary_ids.join(',')})
|
96
|
+
SQL
|
97
|
+
|
98
|
+
result&.round(2)
|
99
|
+
rescue => e
|
100
|
+
Rails.logger.warn "Error calculating metro area: #{e.message}"
|
101
|
+
nil
|
102
|
+
end
|
103
|
+
|
104
|
+
# Get the geographic center (centroid) of this metro
|
105
|
+
#
|
106
|
+
# Calculates the centroid of all constituent boundaries combined,
|
107
|
+
# providing a representative center point for the metropolitan area.
|
108
|
+
#
|
109
|
+
# @return [Array<Float>, nil] [latitude, longitude] of metro center, or nil if error
|
110
|
+
#
|
111
|
+
# @example
|
112
|
+
# metro.centroid
|
113
|
+
# # => [37.4419, -122.1430] (lat, lng of metro center)
|
114
|
+
def centroid
|
115
|
+
return nil unless geoboundaries.any?
|
116
|
+
|
117
|
+
result = self.class.connection.select_one(<<~SQL.squish)
|
118
|
+
SELECT
|
119
|
+
ST_Y(ST_Centroid(ST_Union(boundary))) AS lat,
|
120
|
+
ST_X(ST_Centroid(ST_Union(boundary))) AS lng
|
121
|
+
FROM geoboundaries
|
122
|
+
WHERE id IN (#{geoboundary_ids.join(',')})
|
123
|
+
SQL
|
124
|
+
|
125
|
+
result ? [result['lat'].to_f, result['lng'].to_f] : nil
|
126
|
+
rescue => e
|
127
|
+
Rails.logger.warn "Error calculating metro centroid: #{e.message}"
|
128
|
+
nil
|
129
|
+
end
|
130
|
+
|
131
|
+
# Get all boundary names that define this metro
|
132
|
+
#
|
133
|
+
# Returns a list of all constituent boundary names for understanding
|
134
|
+
# the geographic composition of this metropolitan area.
|
135
|
+
#
|
136
|
+
# @return [Array<String>] Array of boundary names
|
137
|
+
#
|
138
|
+
# @example
|
139
|
+
# metro.boundary_names
|
140
|
+
# # => ["San Francisco County", "Alameda County", "Santa Clara County", ...]
|
141
|
+
def boundary_names
|
142
|
+
geoboundaries.pluck(:name).compact.sort
|
143
|
+
end
|
144
|
+
|
145
|
+
# Get administrative levels represented in this metro
|
146
|
+
#
|
147
|
+
# Returns the different administrative levels (ADM1, ADM2, etc.) that
|
148
|
+
# make up this metropolitan area.
|
149
|
+
#
|
150
|
+
# @return [Array<String>] Array of administrative levels
|
151
|
+
#
|
152
|
+
# @example
|
153
|
+
# metro.admin_levels
|
154
|
+
# # => ["ADM1", "ADM2"] (includes state and county level boundaries)
|
155
|
+
def admin_levels
|
156
|
+
geoboundaries.distinct.pluck(:level).compact.sort
|
157
|
+
end
|
158
|
+
|
159
|
+
# Check if this metro spans multiple states/provinces
|
160
|
+
#
|
161
|
+
# Determines if the metro includes boundaries from multiple ADM1
|
162
|
+
# (state/province) level divisions.
|
163
|
+
#
|
164
|
+
# @return [Boolean] true if metro spans multiple states/provinces
|
165
|
+
#
|
166
|
+
# @example
|
167
|
+
# metro.multi_state?
|
168
|
+
# # => true (if metro crosses state boundaries)
|
169
|
+
def multi_state?
|
170
|
+
state_boundaries = geoboundaries.where(level: "ADM1")
|
171
|
+
state_boundaries.count > 1
|
172
|
+
end
|
173
|
+
|
174
|
+
# Get population density (people per km²)
|
175
|
+
#
|
176
|
+
# Calculates population density based on total population and area,
|
177
|
+
# if both values are available.
|
178
|
+
#
|
179
|
+
# @return [Float, nil] Population density in people per km², or nil if data unavailable
|
180
|
+
#
|
181
|
+
# @example
|
182
|
+
# metro.population_density
|
183
|
+
# # => 1547.3 (people per square kilometer)
|
184
|
+
def population_density
|
185
|
+
return nil unless population && total_area_km2
|
186
|
+
return nil if total_area_km2.zero?
|
187
|
+
|
188
|
+
(population.to_f / total_area_km2).round(1)
|
189
|
+
end
|
190
|
+
|
191
|
+
# Returns a human-readable description of this metro
|
192
|
+
#
|
193
|
+
# Combines name, details, country, and constituent boundary information
|
194
|
+
# for comprehensive metro identification.
|
195
|
+
#
|
196
|
+
# @return [String] Formatted description
|
197
|
+
#
|
198
|
+
# @example
|
199
|
+
# metro.display_name
|
200
|
+
# # => "San Francisco Bay Area (US) - 9 counties, 2 admin levels"
|
201
|
+
def display_name
|
202
|
+
parts = [name]
|
203
|
+
parts << "(#{country_code})" if country_code.present?
|
204
|
+
|
205
|
+
if geoboundaries.any?
|
206
|
+
boundary_count = geoboundaries.count
|
207
|
+
level_count = admin_levels.count
|
208
|
+
parts << "#{boundary_count} boundaries, #{level_count} admin level#{'s' if level_count != 1}"
|
209
|
+
end
|
210
|
+
|
211
|
+
description = parts.join(" - ")
|
212
|
+
description += "\n#{details}" if details.present?
|
213
|
+
description
|
214
|
+
end
|
215
|
+
|
216
|
+
# Ensure country code is stored in uppercase
|
217
|
+
def country_code=(value)
|
218
|
+
super(value&.upcase)
|
219
|
+
end
|
220
|
+
|
221
|
+
# Get a summary of this metro's geographic composition
|
222
|
+
#
|
223
|
+
# Returns detailed information about the boundaries and administrative
|
224
|
+
# levels that make up this metropolitan area.
|
225
|
+
#
|
226
|
+
# @return [Hash] Summary with boundary counts by level and names
|
227
|
+
#
|
228
|
+
# @example
|
229
|
+
# metro.geographic_summary
|
230
|
+
# # => {
|
231
|
+
# # total_boundaries: 9,
|
232
|
+
# # by_level: {"ADM1" => 1, "ADM2" => 8},
|
233
|
+
# # boundary_names: ["San Francisco County", "Alameda County", ...],
|
234
|
+
# # spans_multiple_states: false
|
235
|
+
# # }
|
236
|
+
def geographic_summary
|
237
|
+
{
|
238
|
+
total_boundaries: geoboundaries.count,
|
239
|
+
by_level: geoboundaries.group(:level).count,
|
240
|
+
boundary_names: boundary_names,
|
241
|
+
spans_multiple_states: multi_state?,
|
242
|
+
admin_levels: admin_levels,
|
243
|
+
total_area_km2: total_area_km2,
|
244
|
+
population_density: population_density
|
245
|
+
}
|
246
|
+
end
|
247
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support"
|
4
|
+
require "active_support/concern"
|
5
|
+
require "active_record"
|
6
|
+
|
7
|
+
require_relative "has_geo_lookup/version"
|
8
|
+
require_relative "has_geo_lookup/concern"
|
9
|
+
|
10
|
+
# Require models
|
11
|
+
require_relative "has_geo_lookup/models/geoname"
|
12
|
+
require_relative "has_geo_lookup/models/geoboundary"
|
13
|
+
require_relative "has_geo_lookup/models/feature_code"
|
14
|
+
require_relative "has_geo_lookup/models/metro"
|
15
|
+
|
16
|
+
# Require utilities
|
17
|
+
require_relative "has_geo_lookup/index_checker"
|
18
|
+
|
19
|
+
# Require generators and railtie if Rails is available
|
20
|
+
if defined?(Rails)
|
21
|
+
require_relative "generators/has_geo_lookup/install_generator"
|
22
|
+
require_relative "has_geo_lookup/railtie"
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
module HasGeoLookup
|
27
|
+
class Error < StandardError; end
|
28
|
+
end
|