diff --git a/bin/findit-nearby b/bin/findit-nearby deleted file mode 100644 index 0f09a31..0000000 --- a/bin/findit-nearby +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env - ruby - -require "#{File.dirname($0)}/../lib/findit" - -# Fix to lat/long of my house. -current_latitude = 30.367534 -current_longitude = -97.734225 - -result = FindIt.nearby(current_latitude, current_longitude) -require "pp" -pp result diff --git a/bin/findit-svc b/bin/findit-svc index 6d0cab8..c6fc4bd 100644 --- a/bin/findit-svc +++ b/bin/findit-svc @@ -1,33 +1,34 @@ #!/usr/bin/env -- ruby -BASEDIR = "#{File.dirname($0)}/.." require 'rubygems' -require "#{BASEDIR}/lib/findit" require 'sinatra' require 'uri' require 'json' +require './lib/findit.rb' -get '/nearby' do - send_file "pub/nearby.html" +get '/' do + send_file "public/nearby.html" end get '/findit.js' do - send_file "pub/findit.js" + send_file "public/findit.js" end get '/about.html' do - send_file "pub/about.html" + send_file "public/about.html" end -get '/nearby/:lat,:lng' do |lat, lng| - result = FindIt.nearby(lat, lng) +get '/svc/nearby/:lat,:lng' do |lat, lng| + result = FindIt.nearby(lat.to_f, lng.to_f) content_type :json result.to_json end -post '/nearby' do - a = URI.decode_www_form(request.body.string) - result = FindIt.nearby(a.assoc('latitude').last, a.assoc('longitude').last) +post '/svc/nearby' do + a = URI.decode_www_form(request.body.read) + lat = a.assoc('latitude').last.to_f + lng = a.assoc('longitude').last.to_f + result = FindIt.nearby(lat, lng) content_type :json result.to_json end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..c249894 --- /dev/null +++ b/config.ru @@ -0,0 +1,5 @@ +require 'rubygems' +require 'sinatra' + +load "./bin/findit-svc" +run Sinatra::Application diff --git a/facilities.db b/facilities.db new file mode 100644 index 0000000..5725a18 Binary files /dev/null and b/facilities.db differ diff --git a/lib/findit.rb b/lib/findit.rb index 8e4980e..1cf3ca1 100644 --- a/lib/findit.rb +++ b/lib/findit.rb @@ -1,163 +1,126 @@ -#!/usr/bin/env - ruby -# -# findit-nearby - Find nearby points of interest, proof of concept. -# -# This is very slow because it is querying against Google Fusion Tables. The data -# should be moved local. -# -# Next step would be to convert this to a web service that accepts current lat/long, -# and returns results in a JSON structure. -# - require 'rubygems' -require 'uri' -require 'rest-client' -require 'csv' +require 'dbi' -class Float - # convert a value in degrees to radians - def to_radians - self * (Math::PI/180.0) - end -end +#class Float +# # convert a value in degrees to radians +# def to_radians +# self * (Math::PI/180.0) +# end +#end +# +#class Array +# def to_radians +# self.map {|x| x.to_radians} +# end +#end -class FindIt +class Coordinate - attr_reader :current_loc + attr_accessor :latitude_deg, :longitude_deg, :latitude_rad, :longitude_rad - def initialize(latitude, longitude) - @current_loc = [latitude.to_f, longitude.to_f] - end + DEG_TO_RAD = Math::PI / 180.0 + def initialize(lat, lng, type) + case type + when :DEG + @latitude_deg = lat + @longitude_deg = lng + @latitude_rad = lat * DEG_TO_RAD + @longitude_rad = lng * DEG_TO_RAD + when :RAD + @latitude_rad = lat + @longitude_rad = lng + @latitude_deg = lat / DEG_TO_RAD + @longitude_deg = lng / DEG_TO_RAD + else + raise "unknown coordinate type \"#{type}\"" + end + end + EARTH_R = 3963.0 # Earth mean radius, in miles - # Calculate distance (in miles) between two locations (lat/long in degrees) + # Calculate distance (in miles) between two locations (lat/long in radians) # Based on equitorial approximation formula at: - # http://www.movable-type.co.uk/scripts/latlong.html - def distance(p1, p2 = current_loc) - lat1 = p1[0].to_radians - lon1 = p1[1].to_radians - lat2 = p2[0].to_radians - lon2 = p2[1].to_radians - x = (lon2-lon1) * Math.cos((lat1+lat2)/2); - y = (lat2-lat1); - Math.sqrt(x*x + y*y) * EARTH_R; - end - - # Submit a query to a Google Fusion Table which returns CSV text. - def query_google_fusion_table(table_id, where_clause = '', cols = '*') - sql = "SELECT #{cols} FROM #{table_id}" - sql += " WHERE #{where_clause}" unless where_clause.empty? - url = "https://www.google.com/fusiontables/api/query?sql=" + URI.escape(sql) - RestClient.get url - end - - # Some of the CoA datasets have lat/long encoded in an XML blob (ewwww...). - def parse_geometry(geo) - if geo.match(%r{(.*),(.*)}) - [$2.to_f, $1.to_f] + # http://www.movable-type.co.uk/scripts/latlong.html + def distance(*args) + case args.length + when 1 + p = args[0] + when 3 + p = Coordinate.new(*args) else - nil - end - end - - # Find the closest entry in a Google Fusion Table dataset. - # - # Usage: find_closest(response) {|row| extract_latitude_longitude_from_row(row)} - # - # Returns: {:location => [LAT,LNG], :distance => MILES, :row => ROW} - # - # The "row" is a CSV::Row datatype. - # - def find_closest(resp) - closest = nil - CSV.parse(resp, :headers => true) do |row| - p = yield(row) - if p - d = distance(p) - if closest.nil? || d < closest[:distance] - closest = { - :location => p, - :distance => d, - :row => row - } - end - end + raise "arguments should either be a Coordinate object or (lat,lng,type) values" end - raise "failed to find a location" if closest.nil? - closest - end + x = (p.longitude_rad-self.longitude_rad) * Math.cos((self.latitude_rad+p.latitude_rad)/2); + y = (p.latitude_rad-self.latitude_rad); + Math.sqrt(x*x + y*y) * EARTH_R; + end - # Remove excess whitespace, make naive attempt at capitalization. - def fixup_dirty_string(s) - s.split.map {|w| w.capitalize}.join(' ') - end +end - def closest_facility(factype) - resp = query_google_fusion_table('3046433', "FACILITY='#{factype}'") - closest = find_closest(resp) do |row| - parse_geometry(row['geometry']) - end - { - :object_type => factype.downcase.gsub(/\s+/, '_'), - :name => fixup_dirty_string(closest[:row]['Name']), - :address => fixup_dirty_string(closest[:row]['ADDRESS']), - :latitude => closest[:location][0], - :longitude => closest[:location][1], - :distance => closest[:distance], - #:raw_data => closest[:row], - } - end + +class FindIt - def closest_post_office - closest_facility("POST OFFICE") - end + DEFAULT_DATABASE = "facilities.db" + DEFAULT_TABLE = "facilities" + + attr_reader :database, :table, :loc, :dbh - def closest_library - closest_facility("LIBRARY") + def initialize(lat, lng, opts = {}) + @loc = Coordinate.new(lat, lng, opts[:type] || :DEG) + @database = opts[:database] || DEFAULT_DATABASE + @table = opts[:table] || DEFAULT_TABLE + raise "database file \"#{@database}\" not found" unless File.exist?(@database) + @dbh = DBI.connect("DBI:SQLite3:#{@database}") end - def closest_fire_station - resp = query_google_fusion_table('2987477') - closest = find_closest(resp) do |row| - [row['Latitude'].to_f, row['Longitude'].to_f] + + def closest_facility(factype, name = nil) + + args = [] + + sql = "SELECT * FROM #{@table} WHERE type LIKE ?" + args << factype + + if name + sql += " AND name LIKE ?" + args << name end - { - :object_type => "fire_station", - :name => nil, - :address => fixup_dirty_string(closest[:row]['Address']), - :latitude => closest[:location][0], - :longitude => closest[:location][1], - :distance => closest[:distance], - #:raw_data => closest[:row], - } - end - - def closest_moon_tower - resp = query_google_fusion_table('3046440', "BUILDING_N='MOONLIGHT TOWERS'") - closest = find_closest(resp) do |row| - parse_geometry(row['geometry']) + + closest = nil + @dbh.select_all(sql, *args) do |row| + d = @loc.distance(row['latitude_rad'], row['longitude_rad'], :RAD) + if closest.nil? || d < closest["distance"] + closest = row.to_h + closest["distance"] = d + end end - { - :object_type => "moon_tower", - :name => nil, - :address => fixup_dirty_string(closest[:row]['ADDRESS']), - :latitude => closest[:location][0], - :longitude => closest[:location][1], - :distance => closest[:distance], - #:raw_data => closest[:row], - } + + closest.delete_if {|k, v| v.nil?} if closest + return closest end + # Find collection of nearby objects. def nearby - result = {} - a = closest_post_office ; result[a[:object_type]] = a - a = closest_post_office ; result[a[:object_type]] = a - a = closest_library ; result[a[:object_type]] = a - a = closest_fire_station ; result[a[:object_type]] = a - a = closest_moon_tower ; result[a[:object_type]] = a - result + facilities = {} + + a = closest_facility("POST_OFFICE") + facilities[a["type"]] = a if a + + a = closest_facility("LIBRARY") + facilities[a["type"]] = a if a + + a = closest_facility("FIRE_STATION") + facilities[a["type"]] = a if a + + a = closest_facility("HISTORICAL_LANDMARK", "Moonlight Towers") + if a + a.delete("name") + facilities["MOON_TOWER"] = a + end + + facilities end # Find collection of nearby objects for a given latitude/longitude. diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..60072d1 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,3 @@ +Options -Indexes +DirectoryIndex nearby.html +PassengerEnabled on diff --git a/pub/about.html b/public/about.html similarity index 100% rename from pub/about.html rename to public/about.html diff --git a/pub/findit.js b/public/findit.js similarity index 86% rename from pub/findit.js rename to public/findit.js index a1dae4b..ef0e905 100644 --- a/pub/findit.js +++ b/public/findit.js @@ -13,7 +13,7 @@ function FindIt(opts) { r.dom_map_elem = document.getElementById(opts.map_id); if (! r.dom_map_elem) throw "cannot locate element id \"" + opts.map_id + "\" in page"; r.event_handler = opts.event_handler; - r.svc_endpoint = opts.svc_endpoint || document.URL; + r.svc_endpoint = opts.svc_endpoint || (document.URL + "svc/nearby"); r.map = null; r.marker_me = null; @@ -22,6 +22,13 @@ function FindIt(opts) { r.position = null; r.address = null; + r.recognized_features = [ + {type: "LIBRARY", title: "library"}, + {type: "POST_OFFICE", title: "post office"}, + {type: "FIRE_STATION", title: "fire station"}, + {type: "MOON_TOWER", title: "moon tower"}, + ]; + r.marker_images = { 'blue_dot' : new google.maps.MarkerImage( @@ -113,9 +120,9 @@ FindIt.methods = { } if (address) { - this.send_event("ADDRESS_GOOD", {'address' : address}); + this.send_event("ADDRESS_GOOD", {'address' : address}); } else { - this.findAddress(loc); + this.findAddress(loc); } this.searchNearby(loc); @@ -141,7 +148,7 @@ FindIt.methods = { google.maps.event.addListener(marker, 'dragend', dragCallBack); - this.makeInfoWindow(marker, "

You are here!

Drag this marker to explore the city.

"); + this.makeInfoWindow(marker, "You are here
Drag this marker to explore the city."); return marker; }, @@ -197,11 +204,14 @@ FindIt.methods = { var nearby = eval('(' + req.responseText + ')'); - this.placeFeatureOnMap(nearby.library, "library"); - this.placeFeatureOnMap(nearby.post_office, "post office"); - this.placeFeatureOnMap(nearby.fire_station, "fire station"); - this.placeFeatureOnMap(nearby.moon_tower, "moon tower"); - + for (var i = 0 ; i < this.recognized_features.length ; ++i ) { + var type = this.recognized_features[i].type; + var title = this.recognized_features[i].title; + if (nearby[type]) { + this.placeFeatureOnMap(nearby[type], title); + } + } + this.send_event("COMPLETE", {}); }, @@ -269,14 +279,19 @@ FindIt.methods = { // Build info window content for a feature. infoWindowContentForFeature : function(feature, descr) { - var content = "

Nearest " + descr + "

\n

"; + var result = ["Nearest " + descr.escapeHTML() + ""]; if (! isEmpty(feature.name)) { - content = content + feature.name + "
\n"; + result.push(feature.name.escapeHTML()); + } + result.push(feature.address.escapeHTML()); + if (! isEmpty(feature.info)) { + result.push(feature.info.escapeHTML()); + } + result.push(feature.distance.toFixed(1) + " mi away"); + if (! isEmpty(feature.link)) { + result.push("more info ..."); } - content = content + feature.address + "
\n" + - feature.distance.toFixed(1) + " mi away

\n" + - "

clickable link goes here

"; - return content; + return result.join("
\n"); } }; diff --git a/pub/nearby.html b/public/nearby.html similarity index 100% rename from pub/nearby.html rename to public/nearby.html diff --git a/test/data/empty.db b/test/data/empty.db new file mode 100644 index 0000000..e69de29 diff --git a/test/src/test-findit.rb b/test/src/test-findit.rb new file mode 100644 index 0000000..eb2166c --- /dev/null +++ b/test/src/test-findit.rb @@ -0,0 +1,111 @@ +BASEDIR = "#{File.dirname($0)}/../.." +$:.insert(0, "#{BASEDIR}/lib") + +require 'minitest/autorun' +require 'findit' + +P1_LAT_DEG = 30.286 +P1_LNG_DEG = -97.739 +P1_LAT_RAD = 0.52859 +P1_LNG_RAD = -1.7058 + +P2_LAT_DEG = 30.264 +P2_LNG_DEG = -97.747 + +# 1 radian ~ 50 - 70 miles => 1 mile ~ 0.14 - 0.20 radians => 0.01 miles ~ 0.0014 - 0.0020 radians +DELTA_RADIANS = 0.0007 # to round accurately to nearest 1/100th mile +DELTA_DEGREES = DELTA_RADIANS*(360/Math::PI) +DELTA_MILES = 0.005 + +class TestCoordinate < MiniTest::Unit::TestCase + + def test_constructor_degrees + p = Coordinate.new(P1_LAT_DEG, P1_LNG_DEG, :DEG) + refute_nil p + assert_in_delta P1_LAT_DEG, p.latitude_deg, DELTA_DEGREES + assert_in_delta P1_LNG_DEG, p.longitude_deg, DELTA_DEGREES + assert_in_delta P1_LAT_RAD, p.latitude_rad, DELTA_RADIANS + assert_in_delta P1_LNG_RAD, p.longitude_rad, DELTA_RADIANS + end + + def test_constructor_radians + p = Coordinate.new(P1_LAT_RAD, P1_LNG_RAD, :RAD) + refute_nil p + assert_in_delta P1_LAT_RAD, p.latitude_rad, DELTA_RADIANS + assert_in_delta P1_LNG_RAD, p.longitude_rad, DELTA_RADIANS + assert_in_delta P1_LAT_DEG, p.latitude_deg, DELTA_DEGREES + assert_in_delta P1_LNG_DEG, p.longitude_deg, DELTA_DEGREES + end + + def test_distance_to_coord + p1 = Coordinate.new(P1_LAT_DEG, P1_LNG_DEG, :DEG) + p2 = Coordinate.new(P2_LAT_DEG, P2_LNG_DEG, :DEG) + assert_in_delta 1.594, p1.distance(p2), DELTA_MILES + end + + def test_distance_to_location + p1 = Coordinate.new(P1_LAT_DEG, P1_LNG_DEG, :DEG) + assert_in_delta 1.594, p1.distance(P2_LAT_DEG, P2_LNG_DEG, :DEG), DELTA_MILES + end + +end + +class TestFindIt < MiniTest::Unit::TestCase + + def setup + @f = FindIt.new(P1_LAT_DEG, P1_LNG_DEG) + end + + def test_constructor + refute_nil @f + refute_nil @f.loc + assert_in_delta P1_LAT_DEG, @f.loc.latitude_deg, DELTA_DEGREES + assert_in_delta P1_LNG_DEG, @f.loc.longitude_deg, DELTA_DEGREES + assert_equal "facilities.db", @f.database + assert_equal "facilities", @f.table + refute_nil @f.dbh + end + + def test_constructor_options + @f1 = FindIt.new(P1_LAT_RAD, P1_LNG_RAD, :type => :RAD, :database => "./test/data/empty.db", :table => "does_not_exist") + refute_nil @f + refute_nil @f.loc + assert_in_delta P1_LAT_RAD, @f.loc.latitude_rad, DELTA_RADIANS + assert_in_delta P1_LNG_RAD, @f.loc.longitude_rad, DELTA_RADIANS + end + + def test_constructor_database_missing + assert_raises(RuntimeError) { + FindIt.new(P1_LAT_DEG, P1_LNG_DEG, :database => "does_not_exist.db") + } + end + + def test_closest + a = @f.closest_facility("POST_OFFICE") + refute_nil a + assert_equal "POST_OFFICE", a["type"] + assert_equal "West Mall University Of Texas Station", a["name"] + assert_equal "2201 Guadalupe", a["address"] + assert_equal "Austin", a["city"] + assert_equal "TX", a["state"] + assert_in_delta 30.2810, a["latitude"], DELTA_DEGREES + assert_in_delta -97.7335, a["longitude"], DELTA_DEGREES + assert_in_delta 0.52850, a["latitude_rad"], DELTA_RADIANS + assert_in_delta -1.7058, a["longitude_rad"], DELTA_RADIANS + assert_in_delta 0.410, a["distance"], DELTA_MILES + end + + def test_nearby + a = @f.nearby + refute_nil a + assert_equal 4, a.length + assert_equal %w(FIRE_STATION LIBRARY MOON_TOWER POST_OFFICE), a.keys.sort + assert_equal "506 W Martin Luther King Blvd", a["FIRE_STATION"]["address"] + assert_equal "810 Guadalupe St", a["LIBRARY"]["address"] + assert_equal "2110 Nueces Street", a["MOON_TOWER"]["address"] + assert_equal "2201 Guadalupe", a["POST_OFFICE"]["address"] + end + +end + + diff --git a/tmp/.KEEPME b/tmp/.KEEPME new file mode 100644 index 0000000..2a6d876 --- /dev/null +++ b/tmp/.KEEPME @@ -0,0 +1 @@ +This directory is required when deploying under Phusion Passenger.