Give Overpass POI downloads icons/descriptions and unique filenames
Overpass categories now carry the same icon+color styling a p4n2gpx waypoint gets, instead of falling back to gpx.js's plain red marker, plus a popup description built from opening_hours/operator/website tags. Supermarket/convenience store reuse NPS's generic store glyph, the only honest match in that icon set. Each plugin run's saved filename now bakes in the area's center and a timestamp, so repeated downloads for different areas land as separate, identifiable files instead of overpass-pois.geojson/-1/-2/...
This commit is contained in:
1 parent
72ffd0ef18
commit
1733fc7c1b
5 files changed
+119
-20
No files matched your search
@@ -356,13 +356,24 @@ module Maptool
|
|||||||
bbox = %w[south west north east].map { |key| params[key].to_s }
|
bbox = %w[south west north east].map { |key| params[key].to_s }
|
||||||
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
|
halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
|
||||||
|
|
||||||
|
south, west, north, east = bbox.map(&:to_f)
|
||||||
begin
|
begin
|
||||||
geojson = Plugins.run(PLUGINS_DIR, params[:name], bbox.map(&:to_f))
|
geojson = Plugins.run(PLUGINS_DIR, params[:name], [south, west, north, east])
|
||||||
rescue Plugins::RunError => e
|
rescue Plugins::RunError => e
|
||||||
halt 502, json(error: e.message)
|
halt 502, json(error: e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
path = GPX_STORE.save(folder: "", filename: "#{params[:name]}.geojson", io: StringIO.new(JSON.generate(geojson)))
|
# Filename carries the area's center and the run's timestamp (same
|
||||||
|
# idea as a p4n2gpx export's own "p4n_area_<lat>_<lon>_<date>_<time>"
|
||||||
|
# naming) — without it, running this again for a different area just
|
||||||
|
# overwrote/renamed-with-a-counter the same "overpass-pois.geojson",
|
||||||
|
# with nothing in the file tree to tell one run's POIs from another's.
|
||||||
|
center_lat = format("%.2f", (south + north) / 2)
|
||||||
|
center_lon = format("%.2f", (west + east) / 2)
|
||||||
|
timestamp = Time.now.strftime("%y%m%d_%H%M")
|
||||||
|
filename = "#{params[:name]}_#{center_lat}_#{center_lon}_#{timestamp}.geojson"
|
||||||
|
|
||||||
|
path = GPX_STORE.save(folder: "", filename: filename, io: StringIO.new(JSON.generate(geojson)))
|
||||||
json path: path, tree: GPX_STORE.tree
|
json path: path, tree: GPX_STORE.tree
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,23 +14,42 @@ module Maptool
|
|||||||
|
|
||||||
OVERPASS_API = "https://overpass-api.de/api/interpreter".freeze
|
OVERPASS_API = "https://overpass-api.de/api/interpreter".freeze
|
||||||
|
|
||||||
# Each entry: an Overpass tag filter -> a human category label, used
|
# Each entry: an Overpass tag filter -> { label:, icon:, color: }, used
|
||||||
# both to build the query (unioning every filter) and to label results
|
# both to build the query (unioning every filter) and to label/style
|
||||||
# back (matched by scanning an element's own tags for the same
|
# 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
|
# key/value, not by which branch of the query found it — Overpass's
|
||||||
# `out body` doesn't say which union member matched).
|
# `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 = {
|
CATEGORIES = {
|
||||||
{ "amenity" => "drinking_water" } => "Drinking water",
|
{ "amenity" => "drinking_water" } => { label: "Drinking water", icon: "amenity_drinking_water", color: "#2e86de" },
|
||||||
{ "amenity" => "sanitary_dump_station" } => "Dump station",
|
{ "amenity" => "sanitary_dump_station" } => { label: "Dump station", icon: "amenity_sanitary_dump_station", color: "#8d6e63" },
|
||||||
{ "amenity" => "fuel", "fuel:lpg" => "yes" } => "LPG refill",
|
{ "amenity" => "fuel", "fuel:lpg" => "yes" } => { label: "LPG refill", icon: "shop_gas", color: "#e67e22" },
|
||||||
{ "shop" => "gas" } => "LPG refill",
|
{ "shop" => "gas" } => { label: "LPG refill", icon: "shop_gas", color: "#e67e22" },
|
||||||
{ "amenity" => "toilets" } => "Public toilet",
|
{ "amenity" => "toilets" } => { label: "Public toilet", icon: "amenity_toilets", color: "#8d6e63" },
|
||||||
{ "shop" => "laundry" } => "Laundry",
|
{ "shop" => "laundry" } => { label: "Laundry", icon: "shop_laundry", color: "#5b9bd5" },
|
||||||
{ "shop" => "supermarket" } => "Supermarket",
|
{ "shop" => "supermarket" } => { label: "Supermarket", icon: "shop_supermarket", color: "#27ae60" },
|
||||||
{ "shop" => "convenience" } => "Convenience store",
|
{ "shop" => "convenience" } => { label: "Convenience store", icon: "shop_convenience", color: "#27ae60" },
|
||||||
{ "natural" => "spring" } => "Spring",
|
{ "natural" => "spring" } => { label: "Spring", icon: "amenity_drinking_water", color: "#2e86de" },
|
||||||
}.freeze
|
}.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).
|
# bbox: [south, west, north, east] (Overpass's own axis order).
|
||||||
def self.fetch(bbox, timeout: 60)
|
def self.fetch(bbox, timeout: 60)
|
||||||
query = build_query(bbox, timeout: timeout)
|
query = build_query(bbox, timeout: timeout)
|
||||||
@@ -59,12 +78,19 @@ module Maptool
|
|||||||
features = (overpass_response["elements"] || []).filter_map do |element|
|
features = (overpass_response["elements"] || []).filter_map do |element|
|
||||||
next unless element["type"] == "node"
|
next unless element["type"] == "node"
|
||||||
|
|
||||||
category = category_for(element["tags"] || {})
|
tags = element["tags"] || {}
|
||||||
|
category = category_for(tags)
|
||||||
next unless category
|
next unless category
|
||||||
|
|
||||||
{
|
{
|
||||||
"type" => "Feature",
|
"type" => "Feature",
|
||||||
"properties" => { "name" => (element["tags"] || {})["name"] || category, "category" => category },
|
"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"]] },
|
"geometry" => { "type" => "Point", "coordinates" => [element["lon"], element["lat"]] },
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -72,11 +98,19 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.category_for(tags)
|
def self.category_for(tags)
|
||||||
CATEGORIES.each do |filter, label|
|
CATEGORIES.each do |filter, category|
|
||||||
return label if filter.all? { |k, v| tags[k] == v }
|
return category if filter.all? { |k, v| tags[k] == v }
|
||||||
end
|
end
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
private_class_method :category_for
|
private_class_method :category_for
|
||||||
|
|
||||||
|
def self.description_for(tags)
|
||||||
|
lines = DESCRIPTION_TAGS.filter_map { |tag, label| "#{label}: #{tags[tag]}" if tags[tag] }
|
||||||
|
website = tags["website"] || tags["contact:website"]
|
||||||
|
lines << website if website
|
||||||
|
lines.empty? ? nil : lines.join("\n")
|
||||||
|
end
|
||||||
|
private_class_method :description_for
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -155,6 +155,20 @@ const BICYCLE_TRAIL_ICON = `
|
|||||||
c1,0,1,1.3,0,1.3H13.1L13.1,9.8z"/>
|
c1,0,1,1.3,0,1.3H13.1L13.1,9.8z"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
// src/store-22.svg — NPS's generic shop pictogram, the closest match in
|
||||||
|
// their set for both a supermarket and a convenience store (no separate
|
||||||
|
// grocery-specific glyph exists there).
|
||||||
|
const STORE_ICON = `
|
||||||
|
<svg viewBox="0 0 22 22" fill="currentColor">
|
||||||
|
<path d="M20,9c0,0-0.8,3-4,3C16,12,15.8,9.9,20,9z"/>
|
||||||
|
<path d="M17,14.3c0.5-0.5,1.3-0.9,2.1-0.9c1.6,0,2.9,1.3,2.9,3v0.2l0,0c-0.1,0.6-0.3,1.5-0.7,2.4c0,0-0.8,1.6-1.5,2.3l-0.1,0.1
|
||||||
|
c-0.3,0.3-0.8,0.5-1.3,0.5c-0.6,0-1.1-0.5-1.5-1c-0.3,0.5-0.9,1-1.5,1c-0.5,0-0.9-0.2-1.3-0.5L14,21.3c-0.7-0.7-1.5-2.3-1.5-2.3
|
||||||
|
c-0.5-1-0.7-1.8-0.7-2.4l0,0v-0.2c0-1.7,1.3-3,2.9-3C15.7,13.5,16.5,13.8,17,14.3"/>
|
||||||
|
<polygon points="4,0 8,0 8,2 4,2 "/>
|
||||||
|
<path d="M7,3H3c0,0-0.7,0.2-1.2,0.7L0.2,5.8C0.1,6,0,5.9,0,6.1V21c0,0.4,0.6,1,1,1h8c0.4,0,1-0.6,1-1V6C10,6,9.6,3.3,7,3z M4,4v5H3
|
||||||
|
v1H2l-1,1v-1V6h1V5h1L4,4z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
export const POI_ICONS = {
|
export const POI_ICONS = {
|
||||||
// src/parking-22.svg
|
// src/parking-22.svg
|
||||||
amenity_parking: `
|
amenity_parking: `
|
||||||
@@ -335,6 +349,8 @@ Object.assign(POI_ICONS, {
|
|||||||
leisure_fishing: FISHING_ICON,
|
leisure_fishing: FISHING_ICON,
|
||||||
sport_climbing: CLIMBING_ICON,
|
sport_climbing: CLIMBING_ICON,
|
||||||
route_hiking: WALKING_ICON,
|
route_hiking: WALKING_ICON,
|
||||||
|
shop_supermarket: STORE_ICON,
|
||||||
|
shop_convenience: STORE_ICON,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LIVETRACK_ICON = `
|
export const LIVETRACK_ICON = `
|
||||||
|
|||||||
+1
-1
@@ -307,7 +307,7 @@ class AppTest < Minitest::Test
|
|||||||
)
|
)
|
||||||
assert_equal 200, response.status
|
assert_equal 200, response.status
|
||||||
body = json_body(response)
|
body = json_body(response)
|
||||||
assert_equal "test-plugin.geojson", body["path"]
|
assert_match(/\Atest-plugin_48\.05_17\.05_\d{6}_\d{4}\.geojson\z/, body["path"])
|
||||||
|
|
||||||
saved = json_body(request.get("/api/gpx/file/#{body["path"]}"))
|
saved = json_body(request.get("/api/gpx/file/#{body["path"]}"))
|
||||||
assert_equal "Test POI", saved["features"][0]["properties"]["name"]
|
assert_equal "Test POI", saved["features"][0]["properties"]["name"]
|
||||||
|
|||||||
@@ -22,6 +22,44 @@ class OverpassPoisTest < Minitest::Test
|
|||||||
water = geojson["features"].find { |f| f["properties"]["category"] == "Drinking water" }
|
water = geojson["features"].find { |f| f["properties"]["category"] == "Drinking water" }
|
||||||
assert_equal [17.2, 48.1], water["geometry"]["coordinates"]
|
assert_equal [17.2, 48.1], water["geometry"]["coordinates"]
|
||||||
assert_equal "Drinking water", water["properties"]["name"], "falls back to the category when the node has no name tag"
|
assert_equal "Drinking water", water["properties"]["name"], "falls back to the category when the node has no name tag"
|
||||||
|
assert_equal "amenity_drinking_water", water["properties"]["icon"]
|
||||||
|
assert_equal "#2e86de", water["properties"]["color"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_to_geojson_assigns_icons_to_shop_categories
|
||||||
|
response = {
|
||||||
|
"elements" => [
|
||||||
|
{ "type" => "node", "lat" => 1, "lon" => 1, "tags" => { "shop" => "supermarket" } },
|
||||||
|
{ "type" => "node", "lat" => 2, "lon" => 2, "tags" => { "shop" => "convenience" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
icons = Maptool::OverpassPois.to_geojson(response)["features"].map { |f| f["properties"]["icon"] }
|
||||||
|
|
||||||
|
assert_equal %w[shop_supermarket shop_convenience], icons
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_to_geojson_builds_a_description_from_recognized_tags
|
||||||
|
response = {
|
||||||
|
"elements" => [
|
||||||
|
{
|
||||||
|
"type" => "node",
|
||||||
|
"lat" => 48.1,
|
||||||
|
"lon" => 17.2,
|
||||||
|
"tags" => { "amenity" => "toilets", "opening_hours" => "24/7", "operator" => "Town Council", "website" => "https://example.com" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
description = Maptool::OverpassPois.to_geojson(response)["features"].first["properties"]["description"]
|
||||||
|
|
||||||
|
assert_equal "Opening hours: 24/7\nOperator: Town Council\nhttps://example.com", description
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_to_geojson_omits_description_when_no_recognized_tags_are_present
|
||||||
|
response = { "elements" => [{ "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "toilets" } }] }
|
||||||
|
|
||||||
|
refute Maptool::OverpassPois.to_geojson(response)["features"].first["properties"].key?("description")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_to_geojson_uses_the_node_name_when_present
|
def test_to_geojson_uses_the_node_name_when_present
|
||||||
|
|||||||
Reference in new issue
Block a user