- notes.js's ICON_LABELS wasn't extended for the shop_supermarket/ shop_convenience keys icons.js added — the note icon picker showed the raw internal key as the title instead of a real label. - Plugins.run mislabeled a malformed plugin.json as "plugin did not print valid JSON" — a message written for bad script stdout, not a bad manifest, since both paths shared one rescue. - overpass_pois.rb's description_for joined raw OSM tag values with "\n" — an operator/opening_hours tag with an embedded newline could inject an extra line into the popup that gpx.js's formatDescription (built for p4n2gpx's own well-formed lines) would render as its own independent, legitimate-looking link. Tag values are stripped of embedded newlines before joining.
128 lines
6.0 KiB
Ruby
128 lines
6.0 KiB
Ruby
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
|