require "net/http" require "json" module Maptool # Originally a dev-time-only tool (the "overpass:pois" Rake task) kept # off the running server so it never depended on Overpass's public # instance being up, or ate into its (fairly strict, shared) rate limits, # on every page load. plugins/overpass-pois now also calls this from the # running app — but only ever on an explicit, login-gated "Run for this # area" click (see lib/maptool/plugins.rb), never automatically, so that # concern doesn't apply there the way it would if this ran on page load. module OverpassPois class FetchError < StandardError; end OVERPASS_API = "https://overpass-api.de/api/interpreter".freeze # Each entry: an Overpass tag filter -> { label:, icon:, color: }, used # both to build the query (unioning every filter) and to label/style # results back (matched by scanning an element's own tags for the same # key/value, not by which branch of the query found it — Overpass's # `out body` doesn't say which union member matched). `icon` names one # of the marker glyphs public/js/icons.js's POI_ICONS already vendors # for the GPX/p4n upload path — reused here rather than drawn again — # so these render the same way a p4n2gpx waypoint of the same kind # does, instead of every point falling back to gpx.js's plain red # unlabeled marker (its TRACK_COLOR default for a point with no color # of its own). shop_supermarket/shop_convenience both point at the same # NPS "store" glyph (icons.js's STORE_ICON) — that set has no separate # grocery-specific pictogram, so this is the honest match for both. CATEGORIES = { { "amenity" => "drinking_water" } => { label: "Drinking water", icon: "amenity_drinking_water", color: "#2e86de" }, { "amenity" => "sanitary_dump_station" } => { label: "Dump station", icon: "amenity_sanitary_dump_station", color: "#8d6e63" }, { "amenity" => "fuel", "fuel:lpg" => "yes" } => { label: "LPG refill", icon: "shop_gas", color: "#e67e22" }, { "shop" => "gas" } => { label: "LPG refill", icon: "shop_gas", color: "#e67e22" }, { "amenity" => "toilets" } => { label: "Public toilet", icon: "amenity_toilets", color: "#8d6e63" }, { "shop" => "laundry" } => { label: "Laundry", icon: "shop_laundry", color: "#5b9bd5" }, { "shop" => "supermarket" } => { label: "Supermarket", icon: "shop_supermarket", color: "#27ae60" }, { "shop" => "convenience" } => { label: "Convenience store", icon: "shop_convenience", color: "#27ae60" }, { "natural" => "spring" } => { label: "Spring", icon: "amenity_drinking_water", color: "#2e86de" }, }.freeze # Tags worth surfacing in a popup, beyond the bare category label — # each rendered as its own "Label: value" line, which gpx.js's # formatDescription (written for p4n2gpx's own "Label: value" export # lines) already turns into a distinct row with no changes needed on # that end. website/contact:website are emitted as a bare URL line # instead, since formatDescription also already auto-links those. DESCRIPTION_TAGS = [ ["opening_hours", "Opening hours"], ["operator", "Operator"], ].freeze # bbox: [south, west, north, east] (Overpass's own axis order). def self.fetch(bbox, timeout: 60) query = build_query(bbox, timeout: timeout) uri = URI(OVERPASS_API) response = Net::HTTP.post_form(uri, "data" => query) raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body) end def self.build_query(bbox, timeout: 60) bbox_str = bbox.join(",") filters = CATEGORIES.keys.map do |tags| conditions = tags.map { |k, v| %(["#{k}"="#{v}"]) }.join "node#{conditions}(#{bbox_str});" end.join("\n ") "[out:json][timeout:#{timeout}];\n(\n #{filters}\n);\nout body;" end # Pure — no network — so it's cheaply testable against a canned # Overpass response. Only `type: "node"` elements are handled: ways/ # relations (a mapped-as-an-area supermarket, say) would need their own # centroid computation, and the categories above are overwhelmingly # mapped as plain nodes in practice. def self.to_geojson(overpass_response) features = (overpass_response["elements"] || []).filter_map do |element| next unless element["type"] == "node" tags = element["tags"] || {} category = category_for(tags) next unless category { "type" => "Feature", "properties" => { "name" => tags["name"] || category[:label], "category" => category[:label], "icon" => category[:icon], "color" => category[:color], "description" => description_for(tags), }.compact, "geometry" => { "type" => "Point", "coordinates" => [element["lon"], element["lat"]] }, } end { "type" => "FeatureCollection", "features" => features } end def self.category_for(tags) CATEGORIES.each do |filter, category| return category if filter.all? { |k, v| tags[k] == v } end nil end private_class_method :category_for def self.description_for(tags) lines = DESCRIPTION_TAGS.filter_map { |tag, label| "#{label}: #{clean_tag(tags[tag])}" if tags[tag] } website = tags["website"] || tags["contact:website"] lines << clean_tag(website) if website lines.empty? ? nil : lines.join("\n") end private_class_method :description_for # An OSM tag value is free text from a world-editable map — an embedded # newline could otherwise inject an extra "line" of its own into the # description, e.g. an operator tag ending in "\nhttps://evil.example" # would render as its own separate, legitimate-looking link line once # gpx.js's formatDescription (written for p4n2gpx's own well-formed # "Label: value" lines) splits on "\n". def self.clean_tag(value) value.to_s.tr("\n\r", " ").strip end private_class_method :clean_tag end end