Add parsing coordinates in lon, lat format
[rails.git] / app / controllers / geocoder_controller.rb
1 class GeocoderController < ApplicationController
2   require "cgi"
3   require "uri"
4   require "rexml/document"
5
6   before_action :authorize_web
7   before_action :set_locale
8   before_action :require_oauth, :only => [:search]
9
10   def search
11     @params = normalize_params
12     @sources = []
13
14     if @params[:lat] && @params[:lon]
15       @sources.push "latlon"
16       @sources.push "osm_nominatim_reverse"
17       @sources.push "geonames_reverse" if defined?(GEONAMES_USERNAME)
18     elsif @params[:query]
19       if @params[:query] =~ /^\d{5}(-\d{4})?$/
20         @sources.push "osm_nominatim"
21       elsif @params[:query] =~ /^(GIR 0AA|[A-PR-UWYZ]([0-9]{1,2}|([A-HK-Y][0-9]|[A-HK-Y][0-9]([0-9]|[ABEHMNPRV-Y]))|[0-9][A-HJKS-UW])\s*[0-9][ABD-HJLNP-UW-Z]{2})$/i
22         @sources.push "osm_nominatim"
23       elsif @params[:query] =~ /^[A-Z]\d[A-Z]\s*\d[A-Z]\d$/i
24         @sources.push "ca_postcode"
25         @sources.push "osm_nominatim"
26       else
27         @sources.push "osm_nominatim"
28         @sources.push "geonames" if defined?(GEONAMES_USERNAME)
29       end
30     end
31
32     if @sources.empty?
33       head :bad_request
34     else
35       render :layout => map_layout
36     end
37   end
38
39   def search_latlon
40     lat = params[:lat].to_f
41     lon = params[:lon].to_f
42     @results = []
43
44     if lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
45       @results.push(:lat => lat, :lon => lon,
46                     :zoom => params[:zoom],
47                     :name => "#{lat}, #{lon}")
48     end
49     if lon >= -90 && lon <= 90 && lat >= -180 && lat <= 180
50       @results.push(:lat => lon, :lon => lat,
51                     :zoom => params[:zoom],
52                     :name => "#{lon}, #{lat}")
53     end
54
55     if @results.empty?
56       @error = "Latitude or longitude are out of range"
57       render :action => "error"
58     else
59       render :action => "results"
60     end
61   end
62
63   def search_ca_postcode
64     # get query parameters
65     query = params[:query]
66     @results = []
67
68     # ask geocoder.ca (note - they have a per-day limit)
69     response = fetch_xml("https://geocoder.ca/?geoit=XML&postal=#{escape_query(query)}")
70
71     # parse the response
72     if response.get_elements("geodata/error").empty?
73       @results.push(:lat => response.text("geodata/latt"),
74                     :lon => response.text("geodata/longt"),
75                     :zoom => POSTCODE_ZOOM,
76                     :name => query.upcase)
77     end
78
79     render :action => "results"
80   rescue StandardError => ex
81     @error = "Error contacting geocoder.ca: #{ex}"
82     render :action => "error"
83   end
84
85   def search_osm_nominatim
86     # get query parameters
87     query = params[:query]
88     minlon = params[:minlon]
89     minlat = params[:minlat]
90     maxlon = params[:maxlon]
91     maxlat = params[:maxlat]
92
93     # get view box
94     viewbox = "&viewbox=#{minlon},#{maxlat},#{maxlon},#{minlat}" if minlon && minlat && maxlon && maxlat
95
96     # get objects to excude
97     exclude = "&exclude_place_ids=#{params[:exclude]}" if params[:exclude]
98
99     # ask nominatim
100     response = fetch_xml("#{NOMINATIM_URL}search?format=xml&extratags=1&q=#{escape_query(query)}#{viewbox}#{exclude}&accept-language=#{http_accept_language.user_preferred_languages.join(',')}")
101
102     # extract the results from the response
103     results =  response.elements["searchresults"]
104
105     # extract parameters from more_url
106     more_url_params = CGI.parse(URI.parse(results.attributes["more_url"]).query)
107
108     # create result array
109     @results = []
110
111     # create parameter hash for "more results" link
112     @more_params = params
113                    .permit(:query, :minlon, :minlat, :maxlon, :maxlat, :exclude)
114                    .merge(:exclude => more_url_params["exclude_place_ids"].first)
115
116     # parse the response
117     results.elements.each("place") do |place|
118       lat = place.attributes["lat"]
119       lon = place.attributes["lon"]
120       klass = place.attributes["class"]
121       type = place.attributes["type"]
122       name = place.attributes["display_name"]
123       min_lat, max_lat, min_lon, max_lon = place.attributes["boundingbox"].split(",")
124       prefix_name = if type.empty?
125                       ""
126                     else
127                       t "geocoder.search_osm_nominatim.prefix.#{klass}.#{type}", :default => type.tr("_", " ").capitalize
128                     end
129       if klass == "boundary" && type == "administrative"
130         rank = (place.attributes["place_rank"].to_i + 1) / 2
131         prefix_name = t "geocoder.search_osm_nominatim.admin_levels.level#{rank}", :default => prefix_name
132         place.elements["extratags"].elements.each("tag") do |extratag|
133           prefix_name = t "geocoder.search_osm_nominatim.prefix.place.#{extratag.attributes['value']}", :default => prefix_name if extratag.attributes["key"] == "place"
134         end
135       end
136       prefix = t "geocoder.search_osm_nominatim.prefix_format", :name => prefix_name
137       object_type = place.attributes["osm_type"]
138       object_id = place.attributes["osm_id"]
139
140       @results.push(:lat => lat, :lon => lon,
141                     :min_lat => min_lat, :max_lat => max_lat,
142                     :min_lon => min_lon, :max_lon => max_lon,
143                     :prefix => prefix, :name => name,
144                     :type => object_type, :id => object_id)
145     end
146
147     render :action => "results"
148   rescue StandardError => ex
149     @error = "Error contacting nominatim.openstreetmap.org: #{ex}"
150     render :action => "error"
151   end
152
153   def search_geonames
154     # get query parameters
155     query = params[:query]
156
157     # get preferred language
158     lang = I18n.locale.to_s.split("-").first
159
160     # create result array
161     @results = []
162
163     # ask geonames.org
164     response = fetch_xml("http://api.geonames.org/search?q=#{escape_query(query)}&lang=#{lang}&maxRows=20&username=#{GEONAMES_USERNAME}")
165
166     # parse the response
167     response.elements.each("geonames/geoname") do |geoname|
168       lat = geoname.text("lat")
169       lon = geoname.text("lng")
170       name = geoname.text("name")
171       country = geoname.text("countryName")
172
173       @results.push(:lat => lat, :lon => lon,
174                     :zoom => GEONAMES_ZOOM,
175                     :name => name,
176                     :suffix => ", #{country}")
177     end
178
179     render :action => "results"
180   rescue StandardError => ex
181     @error = "Error contacting api.geonames.org: #{ex}"
182     render :action => "error"
183   end
184
185   def search_osm_nominatim_reverse
186     # get query parameters
187     lat = params[:lat]
188     lon = params[:lon]
189     zoom = params[:zoom]
190
191     # create result array
192     @results = []
193
194     # ask nominatim
195     response = fetch_xml("#{NOMINATIM_URL}reverse?lat=#{lat}&lon=#{lon}&zoom=#{zoom}&accept-language=#{http_accept_language.user_preferred_languages.join(',')}")
196
197     # parse the response
198     response.elements.each("reversegeocode/result") do |result|
199       lat = result.attributes["lat"]
200       lon = result.attributes["lon"]
201       object_type = result.attributes["osm_type"]
202       object_id = result.attributes["osm_id"]
203       description = result.text
204
205       @results.push(:lat => lat, :lon => lon,
206                     :zoom => zoom,
207                     :name => description,
208                     :type => object_type, :id => object_id)
209     end
210
211     render :action => "results"
212   rescue StandardError => ex
213     @error = "Error contacting nominatim.openstreetmap.org: #{ex}"
214     render :action => "error"
215   end
216
217   def search_geonames_reverse
218     # get query parameters
219     lat = params[:lat]
220     lon = params[:lon]
221
222     # get preferred language
223     lang = I18n.locale.to_s.split("-").first
224
225     # create result array
226     @results = []
227
228     # ask geonames.org
229     response = fetch_xml("http://api.geonames.org/countrySubdivision?lat=#{lat}&lng=#{lon}&lang=#{lang}&username=#{GEONAMES_USERNAME}")
230
231     # parse the response
232     response.elements.each("geonames/countrySubdivision") do |geoname|
233       name = geoname.text("adminName1")
234       country = geoname.text("countryName")
235
236       @results.push(:lat => lat, :lon => lon,
237                     :zoom => GEONAMES_ZOOM,
238                     :name => name,
239                     :suffix => ", #{country}")
240     end
241
242     render :action => "results"
243   rescue StandardError => ex
244     @error = "Error contacting api.geonames.org: #{ex}"
245     render :action => "error"
246   end
247
248   private
249
250   def fetch_text(url)
251     response = OSM.http_client.get(URI.parse(url))
252
253     if response.success?
254       response.body
255     else
256       raise response.status.to_s
257     end
258   end
259
260   def fetch_xml(url)
261     REXML::Document.new(fetch_text(url))
262   end
263
264   def escape_query(query)
265     CGI.escape(query)
266   end
267
268   def normalize_params
269     if query = params[:query]
270       query.strip!
271
272       if latlon = query.match(/^([NS])\s*(\d{1,3}(\.\d*)?)\W*([EW])\s*(\d{1,3}(\.\d*)?)$/).try(:captures) # [NSEW] decimal degrees
273         params.merge!(nsew_to_decdeg(latlon)).delete(:query)
274       elsif latlon = query.match(/^(\d{1,3}(\.\d*)?)\s*([NS])\W*(\d{1,3}(\.\d*)?)\s*([EW])$/).try(:captures) # decimal degrees [NSEW]
275         params.merge!(nsew_to_decdeg(latlon)).delete(:query)
276
277       elsif latlon = query.match(/^([NS])\s*(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?\W*([EW])\s*(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?$/).try(:captures) # [NSEW] degrees, decimal minutes
278         params.merge!(ddm_to_decdeg(latlon)).delete(:query)
279       elsif latlon = query.match(/^(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?\s*([NS])\W*(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?\s*([EW])$/).try(:captures) # degrees, decimal minutes [NSEW]
280         params.merge!(ddm_to_decdeg(latlon)).delete(:query)
281
282       elsif latlon = query.match(/^([NS])\s*(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]?\W*([EW])\s*(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]?$/).try(:captures) # [NSEW] degrees, minutes, decimal seconds
283         params.merge!(dms_to_decdeg(latlon)).delete(:query)
284       elsif latlon = query.match(/^(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]\s*([NS])\W*(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]?\s*([EW])$/).try(:captures) # degrees, minutes, decimal seconds [NSEW]
285         params.merge!(dms_to_decdeg(latlon)).delete(:query)
286
287       elsif latlon = query.match(/^\s*([+-]?\d+(\.\d*)?)\s*[\s,]\s*([+-]?\d+(\.\d*)?)\s*$/)
288         params.merge!(:lat => latlon[1].to_f, :lon => latlon[3].to_f).delete(:query)
289       end
290     end
291
292     params.permit(:query, :lat, :lon, :zoom, :minlat, :minlon, :maxlat, :maxlon)
293   end
294
295   def nsew_to_decdeg(captures)
296     begin
297       Float(captures[0])
298       lat = !captures[2].casecmp("s").zero? ? captures[0].to_f : -captures[0].to_f
299       lon = !captures[5].casecmp("w").zero? ? captures[3].to_f : -captures[3].to_f
300     rescue StandardError
301       lat = !captures[0].casecmp("s").zero? ? captures[1].to_f : -captures[1].to_f
302       lon = !captures[3].casecmp("w").zero? ? captures[4].to_f : -captures[4].to_f
303     end
304     { :lat => lat, :lon => lon }
305   end
306
307   def ddm_to_decdeg(captures)
308     begin
309       Float(captures[0])
310       lat = !captures[3].casecmp("s").zero? ? captures[0].to_f + captures[1].to_f / 60 : -(captures[0].to_f + captures[1].to_f / 60)
311       lon = !captures[7].casecmp("w").zero? ? captures[4].to_f + captures[5].to_f / 60 : -(captures[4].to_f + captures[5].to_f / 60)
312     rescue StandardError
313       lat = !captures[0].casecmp("s").zero? ? captures[1].to_f + captures[2].to_f / 60 : -(captures[1].to_f + captures[2].to_f / 60)
314       lon = !captures[4].casecmp("w").zero? ? captures[5].to_f + captures[6].to_f / 60 : -(captures[5].to_f + captures[6].to_f / 60)
315     end
316     { :lat => lat, :lon => lon }
317   end
318
319   def dms_to_decdeg(captures)
320     begin
321       Float(captures[0])
322       lat = !captures[4].casecmp("s").zero? ? captures[0].to_f + (captures[1].to_f + captures[2].to_f / 60) / 60 : -(captures[0].to_f + (captures[1].to_f + captures[2].to_f / 60) / 60)
323       lon = !captures[9].casecmp("w").zero? ? captures[5].to_f + (captures[6].to_f + captures[7].to_f / 60) / 60 : -(captures[5].to_f + (captures[6].to_f + captures[7].to_f / 60) / 60)
324     rescue StandardError
325       lat = !captures[0].casecmp("s").zero? ? captures[1].to_f + (captures[2].to_f + captures[3].to_f / 60) / 60 : -(captures[1].to_f + (captures[2].to_f + captures[3].to_f / 60) / 60)
326       lon = !captures[5].casecmp("w").zero? ? captures[6].to_f + (captures[7].to_f + captures[8].to_f / 60) / 60 : -(captures[6].to_f + (captures[7].to_f + captures[8].to_f / 60) / 60)
327     end
328     { :lat => lat, :lon => lon }
329   end
330 end