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