├── Gemfile ├── migrations └── 2023-03-28-apps.sql ├── views └── index.erb ├── Gemfile.lock ├── schema.sql ├── LICENSE ├── README.md ├── .gitignore ├── config ├── types.json └── icons.json ├── import.rb └── app.rb /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | ruby "2.7.6" 3 | 4 | gem "sinatra" 5 | gem "sequel" 6 | gem "mysql2" 7 | gem "dotenv" 8 | gem "nokogiri" 9 | gem "json" 10 | -------------------------------------------------------------------------------- /migrations/2023-03-28-apps.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE places ADD COLUMN app_source VARCHAR(50) NOT NULL DEFAULT ''; 2 | ALTER TABLE places ADD COLUMN app_name VARCHAR(50) NOT NULL DEFAULT ''; -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Meridian 5 | <% if ENV['FONTAWESOME_URL'].to_s.length > 0 %> 6 | 7 | <% end %> 8 | 9 | 10 | 11 |

12 |

13 | 14 |
15 | 16 | <% for place in @places %> 17 |

<%= place[:name] %> 18 | <% end %> 19 | 20 | 21 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | dotenv (2.7.6) 5 | json (2.6.2) 6 | mini_portile2 (2.8.0) 7 | mustermann (1.1.1) 8 | ruby2_keywords (~> 0.0.1) 9 | mysql2 (0.5.4) 10 | nokogiri (1.13.7) 11 | mini_portile2 (~> 2.8.0) 12 | racc (~> 1.4) 13 | racc (1.6.0) 14 | rack (2.2.4) 15 | rack-protection (2.2.1) 16 | rack 17 | ruby2_keywords (0.0.5) 18 | sequel (5.58.0) 19 | sinatra (2.2.1) 20 | mustermann (~> 1.0) 21 | rack (~> 2.2) 22 | rack-protection (= 2.2.1) 23 | tilt (~> 2.0) 24 | tilt (2.0.10) 25 | 26 | PLATFORMS 27 | ruby 28 | 29 | DEPENDENCIES 30 | dotenv 31 | json 32 | mysql2 33 | nokogiri 34 | sequel 35 | sinatra 36 | 37 | RUBY VERSION 38 | ruby 2.7.6p219 39 | 40 | BUNDLED WITH 41 | 2.1.4 42 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE places ( 2 | id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 3 | osm_id BIGINT UNSIGNED NOT NULL, 4 | osm_type VARCHAR(10) NOT NULL, 5 | name VARCHAR(100) NOT NULL, 6 | latitude FLOAT NOT NULL, 7 | longitude FLOAT NOT NULL, 8 | pt POINT NOT NULL, 9 | type VARCHAR(50) NOT NULL DEFAULT '', 10 | icon_carto VARCHAR(50) NOT NULL DEFAULT '', 11 | icon_fontawesome VARCHAR(50) NOT NULL DEFAULT '', 12 | address_street VARCHAR(200) NOT NULL DEFAULT '', 13 | address_city VARCHAR(200) NOT NULL DEFAULT '', 14 | address_region VARCHAR(200) NOT NULL DEFAULT '', 15 | address_postalcode VARCHAR(200) NOT NULL DEFAULT '', 16 | address_country VARCHAR(200) NOT NULL DEFAULT '', 17 | visited_count INT NOT NULL DEFAULT 0, 18 | app_source VARCHAR(50) NOT NULL DEFAULT '', 19 | app_name VARCHAR(50) NOT NULL DEFAULT '', 20 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 22 | PRIMARY KEY (id) 23 | ) ENGINE=InnoDB COLLATE utf8_general_ci DEFAULT CHARSET=utf8; 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Meridian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meridian 2 | 3 | https://latl.ong/ 4 | 5 | Meridian works by importing the OpenStreetMap places dataset into MySQL. You can download global or region data from various OpenStreetMap mirrors. If downloading in .pbf format, use the tool `osmium` to extract data into .xml. 6 | 7 | For example, given a file `north-america-latest.osm.pbf`, to extract roughly the Austin, Texas area to a new file: 8 | 9 | `$ osmium extract -b -98.0956,30.0489,-97.2963,30.5244 north-america-latest.osm.pbf -o austin.xml` 10 | 11 | `import.rb` reads the .xml file and outputs SQL INSERT statements which can be fed into MySQL. 12 | 13 | Make sure to install Ruby version 2.7.6 and run `bundle install` to get things set up. 14 | 15 | Example usage: 16 | 17 | `$ bundle exec ruby import.rb austin.xml > places.sql` 18 | `$ mysqladmin -u root -p create meridian_dev` 19 | `$ mysql -u root -p meridian_dev < places.sql` 20 | 21 | To configure the web app, create a `.env` file with `DATABASE_URL=mysql2://...` using your database name and credentials. Run the web app with: 22 | 23 | `$ bundle exec dotenv ruby app.rb` 24 | 25 | Place icons use Font Awesome. You can add `FONTAWESOME_URL=` to your `.env` file to specify a Kit JS URL. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | .env 58 | .DS_Store 59 | -------------------------------------------------------------------------------- /config/types.json: -------------------------------------------------------------------------------- 1 | { 2 | "cuisine": { 3 | "*": "restaurant" 4 | }, 5 | "amenity": { 6 | "restaurant": "restaurant", 7 | "cafe": "restaurant", 8 | "fast_food": "restaurant", 9 | "bank": "bank", 10 | "pharmacy": "pharmacy", 11 | "school": "school", 12 | "cinema": "cinema", 13 | "fuel": "fuel", 14 | "bar": "bar", 15 | "pub": "pub", 16 | "theatre": "theatre", 17 | "conference_centre": "conference_centre", 18 | "bus_station": "bus_station", 19 | "place_of_worship": "place_of_worship", 20 | "clinic": "clinic", 21 | "fire_station": "fire_station", 22 | "fountain": "fountain", 23 | "dentist": "dentist", 24 | "police": "police", 25 | "post_office": "post_office", 26 | "arts_centre": "arts_centre", 27 | "courthouse": "courthouse", 28 | "grave_yard": "grave_yard", 29 | "boat_rental": "boat_rental" 30 | }, 31 | "leisure": { 32 | "park": "park", 33 | "fitness_center": "fitness_center", 34 | "marina": "marina", 35 | "golf_course": "golf_course", 36 | "playground": "playground" 37 | }, 38 | "natural": { 39 | "beach": "beach" 40 | }, 41 | "bridge": { 42 | "yes": "bridge" 43 | }, 44 | "train": { 45 | "yes": "train" 46 | }, 47 | "railway": { 48 | "station": "train" 49 | }, 50 | "tourism": { 51 | "hotel": "hotel", 52 | "museum": "museum", 53 | "caravan_site": "caravan_site" 54 | }, 55 | "shop": { 56 | "supermarket": "supermarket", 57 | "alcohol": "alcohol", 58 | "car_repair": "car_repair", 59 | "travel_agency": "travel_agency", 60 | "books": "books", 61 | "optician": "optician", 62 | "musical_instrument": "musical_instrument", 63 | "pet": "pet", 64 | "hairdresser": "hairdresser", 65 | "coffee": "coffee", 66 | "copyshop": "copyshop", 67 | "car": "car", 68 | "hardware": "hardware" 69 | }, 70 | "aerodrome": { 71 | "international": "airport" 72 | }, 73 | "aeroway": { 74 | "aerodrome": "airport" 75 | }, 76 | "highway": { 77 | "trailhead": "trailhead" 78 | }, 79 | "historic": { 80 | "memorial": "memorial" 81 | } 82 | } -------------------------------------------------------------------------------- /config/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "sport": { 3 | "baseball": "baseball", 4 | "basketball": "basketball", 5 | "tennis": "tennis-ball", 6 | "swimming": "person-swimming", 7 | "american_football": "football", 8 | "soccer": "futbol" 9 | }, 10 | "cuisine": { 11 | "coffee_shop": "mug", 12 | "fast_food": "burger", 13 | "mexican": "taco", 14 | "tex-mex": "taco", 15 | "sandwich": "sandwich", 16 | "pizza": "pizza-slice", 17 | "gyros": "flatbread-stuffed", 18 | "chicken": "drumstick", 19 | "chinese": "bowl-chopsticks", 20 | "barbecue": "meat", 21 | "sushi": "sushi-roll", 22 | "donut": "donut" 23 | }, 24 | "amenity": { 25 | "restaurant": "utensils", 26 | "cafe": "utensils", 27 | "fast_food": "burger", 28 | "bank": "building-columns", 29 | "pharmacy": "prescription-bottle-medical", 30 | "school": "school", 31 | "cinema": "film", 32 | "fuel": "gas-pump", 33 | "bar": "whiskey-glass", 34 | "pub": "beer-mug", 35 | "theatre": "masks-theater", 36 | "conference_centre": "building", 37 | "bus_station": "bus", 38 | "place_of_worship": "place-of-worship", 39 | "clinic": "house-medical", 40 | "fire_station": "fire", 41 | "fountain": "droplet", 42 | "dentist": "tooth", 43 | "police": "user-police", 44 | "post_office": "envelopes-bulk", 45 | "arts_centre": "palette", 46 | "courthouse": "building-columns", 47 | "grave_yard": "tombstone-blank", 48 | "boat_rental": "sailboat" 49 | }, 50 | "leisure": { 51 | "park": "bench-tree", 52 | "fitness_center": "dumbbell", 53 | "marina": "sailboat", 54 | "golf_course": "golf-ball-tee", 55 | "playground": "bench-tree" 56 | }, 57 | "natural": { 58 | "beach": "umbrella-beach" 59 | }, 60 | "bridge": { 61 | "yes": "bridge" 62 | }, 63 | "train": { 64 | "yes": "train" 65 | }, 66 | "railway": { 67 | "station": "train" 68 | }, 69 | "tourism": { 70 | "hotel": "hotel", 71 | "museum": "building-columns", 72 | "caravan_site": "caravan-simple" 73 | }, 74 | "shop": { 75 | "supermarket": "cart-shopping", 76 | "alcohol": "wine-glass", 77 | "car_repair": "car-wrench", 78 | "travel_agency": "map", 79 | "books": "book-blank", 80 | "optician": "glasses", 81 | "musical_instrument": "music", 82 | "pet": "paw", 83 | "hairdresser": "scissors", 84 | "coffee": "mug", 85 | "copyshop": "print", 86 | "car": "car", 87 | "hardware": "hammer" 88 | }, 89 | "aerodrome": { 90 | "international": "plane" 91 | }, 92 | "aeroway": { 93 | "aerodrome": "plane" 94 | }, 95 | "highway": { 96 | "trailhead": "person-hiking" 97 | }, 98 | "historic": { 99 | "memorial": "monument" 100 | } 101 | } -------------------------------------------------------------------------------- /import.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "nokogiri" 3 | require "json" 4 | 5 | FILES = [ ARGV[0] ] 6 | ICONS = JSON.parse(IO.read("config/icons.json")) 7 | TYPES = JSON.parse(IO.read("config/types.json")) 8 | 9 | # the key order matters 10 | # more specific categories are hit first, otherwise it can fall through to more generic icons 11 | TAG_KEYS = ICONS.keys 12 | 13 | class PlacesFilter < Nokogiri::XML::SAX::Document 14 | attr_accessor :osm_id 15 | attr_accessor :latitude 16 | attr_accessor :longitude 17 | attr_accessor :name 18 | attr_accessor :type 19 | attr_accessor :icon_carto 20 | attr_accessor :icon_fontawesome 21 | attr_accessor :tags 22 | 23 | def start_document 24 | end 25 | 26 | def start_element(element_name, element_attrs = []) 27 | attrs = element_attrs.to_h 28 | if element_name == "node" 29 | self.osm_id = attrs["id"] 30 | self.latitude = attrs["lat"] 31 | self.longitude = attrs["lon"] 32 | self.name = "" 33 | self.type = "" 34 | self.icon_carto = "" 35 | self.icon_fontawesome = "" 36 | self.tags = [] 37 | elsif element_name == "tag" 38 | # just gather tag attributes here 39 | self.tags << attrs 40 | end 41 | end 42 | 43 | def end_element(element_name) 44 | if element_name == "node" 45 | # check for our keys in priority order 46 | for tag_k in TAG_KEYS 47 | # loop through found tags 48 | for attrs in self.tags 49 | k = attrs["k"] 50 | v = attrs["v"] 51 | 52 | if k == "name" 53 | self.name = v 54 | elsif k == tag_k 55 | # try to find an icon 56 | if self.icon_carto.length == 0 57 | # cuisine sometimes has multiple ;-separated values 58 | v = v.split(";").first 59 | if !ICONS[k][v].nil? 60 | self.icon_carto = "#{k}/#{v}" 61 | self.icon_fontawesome = ICONS[k][v] 62 | end 63 | end 64 | 65 | # also check types 66 | if self.type.length == 0 67 | if !TYPES[k].nil? 68 | if !TYPES[k][v].nil? 69 | self.type = v 70 | elsif !TYPES[k]["*"].nil? 71 | self.type = TYPES[k]["*"] 72 | end 73 | end 74 | end 75 | end 76 | end 77 | end 78 | 79 | if self.name.length > 0 80 | if self.icon_carto.length > 0 81 | s = "INSERT INTO places (osm_id, osm_type, name, latitude, longitude, pt, type, icon_carto, icon_fontawesome) VALUES (" 82 | s += "#{self.osm_id}, 'node', \"#{self.name.gsub(/"/,'""')}\", #{self.latitude}, #{self.longitude}, ST_GeomFromText('POINT(#{self.longitude} #{self.latitude})'), '#{self.type}', '#{self.icon_carto}', '#{self.icon_fontawesome}'" 83 | s += ");" 84 | puts s 85 | else 86 | # puts "#{self.osm_id}: No icon: #{self.name}" 87 | end 88 | end 89 | end 90 | end 91 | 92 | def end_document 93 | end 94 | end 95 | 96 | for f in FILES 97 | parser = Nokogiri::XML::SAX::Parser.new(PlacesFilter.new) 98 | parser.parse(File.open(f)) 99 | end 100 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | Bundler.require 2 | 3 | db = Sequel.connect(ENV["DATABASE_URL"]) 4 | 5 | ICONS = JSON.parse(IO.read("config/icons.json")) 6 | TYPES = JSON.parse(IO.read("config/types.json")) 7 | 8 | get "/" do 9 | @q = params[:q].to_s 10 | 11 | if @q.length > 0 12 | pieces = @q.split(",") 13 | lat = pieces.first.strip.to_f 14 | long = pieces.last.strip.to_f 15 | sql = "SELECT id, name, type, icon_fontawesome, ST_AsText(pt), ST_Distance_Sphere(pt, ST_GeomFromText('POINT(#{long} #{lat})')) AS meters FROM places HAVING meters < 100000 ORDER BY meters LIMIT 50" 16 | @places = db.fetch(sql) 17 | else 18 | @places = [] 19 | end 20 | 21 | erb :index 22 | end 23 | 24 | get "/places/types" do 25 | content_type :json 26 | 27 | flat_list = [] 28 | 29 | TYPES.each do |key, val| 30 | val.each do |sub_key, sub_val| 31 | flat_list << sub_val 32 | end 33 | end 34 | 35 | flat_list.uniq! 36 | return flat_list.to_json 37 | end 38 | 39 | get "/places/icons" do 40 | content_type :json 41 | 42 | flat_list = [] 43 | 44 | ICONS.each do |key, val| 45 | val.each do |sub_key, sub_val| 46 | flat_list << sub_val 47 | end 48 | end 49 | 50 | flat_list.uniq! 51 | return flat_list.to_json 52 | end 53 | 54 | get "/places/nearby" do 55 | content_type :json 56 | 57 | lat = params[:latitude].to_f 58 | long = params[:longitude].to_f 59 | meters = params[:meters] || 10000 60 | count = params[:count] || 50 61 | app_source = params[:app_source] 62 | 63 | if app_source.nil? 64 | sql = "SELECT id, osm_id, name, type, icon_carto, icon_fontawesome, latitude, longitude, ST_Distance_Sphere(pt, ST_GeomFromText('POINT(#{long} #{lat})')) AS meters FROM places HAVING meters < #{meters.to_i} ORDER BY meters LIMIT #{count.to_i}" 65 | places = db.fetch(sql) 66 | else 67 | sql = "SELECT id, osm_id, name, type, icon_carto, icon_fontawesome, latitude, longitude, ST_Distance_Sphere(pt, ST_GeomFromText('POINT(#{long} #{lat})')) AS meters FROM places WHERE app_source = ? HAVING meters < #{meters.to_i} ORDER BY meters LIMIT #{count.to_i}" 68 | places = db.fetch(sql, app_source) 69 | end 70 | 71 | results = [] 72 | for place in places 73 | tags = [ 74 | place[:type], 75 | place[:icon_fontawesome], 76 | place[:icon_carto].split("/").first, 77 | place[:icon_carto].split("/").last 78 | ] 79 | results << { 80 | id: place[:id], 81 | osm_id: place[:osm_id], 82 | name: place[:name], 83 | type: place[:type], 84 | icon_carto: place[:icon_carto], 85 | icon_fontawesome: place[:icon_fontawesome], 86 | latitude: place[:latitude], 87 | longitude: place[:longitude], 88 | tags: tags.uniq 89 | } 90 | end 91 | 92 | return JSON.pretty_generate(results) 93 | end 94 | 95 | post "/places" do 96 | content_type :json 97 | 98 | name = params[:name].to_s 99 | type = params[:type].to_s 100 | lat = params[:latitude].to_f 101 | long = params[:longitude].to_f 102 | app_source = params[:app_source].to_s 103 | app_name = params[:app_name].to_s 104 | 105 | if name.length > 0 106 | insert_ds = db["INSERT INTO places (osm_id, osm_type, name, type, latitude, longitude, pt, app_source, app_name) VALUES (?, ?, ?, ?, ?, ?, ST_GeomFromText('POINT(#{long} #{lat})'), ?, ?)", 0, "", name, type, lat, long, app_source, app_name] 107 | insert_ds.insert 108 | end 109 | 110 | info = {} 111 | return info.to_json 112 | end --------------------------------------------------------------------------------