From f53c74e06f8e05da955f6f9fa6abacfd1d241905 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Wed, 9 Sep 2026 10:21:41 +0200 Subject: [PATCH] Add rake overpass:pois to fetch camper POIs from Overpass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/maptool/overpass_pois.rb: queries Overpass for drinking water, dump stations, LPG refill, toilets, laundromats, supermarkets/ convenience stores, and springs within a bbox, writes GeoJSON. A dev-time tool (like the offline.pmtiles/terrain.pmtiles generation steps already documented) — not called by the running app, drop the output into data/gpx/ or upload it through the Tracks panel like any other .geojson layer. Verified against the real Overpass API (88 POIs for central Bratislava) in addition to the new unit tests (pure to_geojson/ build_query logic, no network in the suite itself). Full suite: 103 runs, 0 failures. --- Rakefile | 17 ++++++++ TODO.adoc | 2 +- docs/development.adoc | 13 ++++++ lib/maptool/overpass_pois.rb | 81 ++++++++++++++++++++++++++++++++++++ test/test_overpass_pois.rb | 68 ++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 lib/maptool/overpass_pois.rb create mode 100644 test/test_overpass_pois.rb diff --git a/Rakefile b/Rakefile index 4ffe980..f585fa4 100644 --- a/Rakefile +++ b/Rakefile @@ -6,3 +6,20 @@ Rake::TestTask.new(:test) do |t| end task default: :test + +namespace :overpass do + desc "Fetch camper-relevant POIs (water, dump stations, LPG, toilets, laundry, shops, springs) " \ + "for a bbox from Overpass and write GeoJSON. Usage: rake overpass:pois[south,west,north,east,out.geojson]" + task :pois, [:south, :west, :north, :east, :out] do |_t, args| + %i[south west north east out].each { |key| abort("usage: rake overpass:pois[south,west,north,east,out.geojson]") unless args[key] } + + require_relative "lib/maptool/overpass_pois" + bbox = [args[:south], args[:west], args[:north], args[:east]].map(&:to_f) + + puts "Querying Overpass for #{bbox.join(",")}..." + response = Maptool::OverpassPois.fetch(bbox) + geojson = Maptool::OverpassPois.to_geojson(response) + File.write(args[:out], JSON.generate(geojson)) + puts "Wrote #{geojson["features"].size} POIs to #{args[:out]}" + end +end diff --git a/TODO.adoc b/TODO.adoc index 7c9a89e..3c98ea3 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -12,7 +12,7 @@ independence from connectivity. * [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now ** [ ] Terrain slope readout for parking (numeric, not just visual shading) * [ ] Contour lines from OSM for hikes and mountain passes -* [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) +* [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload, not called by the running app itself * [ ] Import Park4Night / iOverlander / Freecampsites data * [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates * [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline diff --git a/docs/development.adoc b/docs/development.adoc index fe40131..3e88afe 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -59,6 +59,19 @@ Terrarium-encoded PNG tiles in an `.mbtiles`), then https://github.com/protomaps/go-pmtiles[go-pmtiles], same as `data/offline.pmtiles`. +== Camper-relevant POIs (Overpass) + +`rake overpass:pois[south,west,north,east,out.geojson]` queries +https://overpass-api.de[Overpass] for drinking water, dump stations, +LPG refill, public toilets, laundromats, supermarkets/convenience +stores, and springs within a bbox, and writes the result as a +`.geojson` file (`lib/maptool/overpass_pois.rb`). A dev-time tool, not +something the running app calls itself — drop the output into +`data/gpx/` (or upload it through the Tracks panel) like any other +`.geojson` layer once it's generated. Nodes only (nothing that's +mapped as a way/relation, e.g. a supermarket traced as a building +outline rather than a single point). + == GNSS position (gpsd) The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at diff --git a/lib/maptool/overpass_pois.rb b/lib/maptool/overpass_pois.rb new file mode 100644 index 0000000..636d973 --- /dev/null +++ b/lib/maptool/overpass_pois.rb @@ -0,0 +1,81 @@ +require "net/http" +require "json" + +module Maptool + # A dev-time tool (see the "overpass:pois" Rake task), not something the + # deployed app calls itself — same "generate locally, drop the result in + # data/gpx/" story as offline.pmtiles/terrain.pmtiles (docs/development.adoc), + # kept off the running server so it never depends on Overpass's public + # instance being up, or eats into its (fairly strict, shared) rate limits + # on every page load. + module OverpassPois + class FetchError < StandardError; end + + OVERPASS_API = "https://overpass-api.de/api/interpreter".freeze + + # Each entry: an Overpass tag filter -> a human category label, used + # both to build the query (unioning every filter) and to label 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). + CATEGORIES = { + { "amenity" => "drinking_water" } => "Drinking water", + { "amenity" => "sanitary_dump_station" } => "Dump station", + { "amenity" => "fuel", "fuel:lpg" => "yes" } => "LPG refill", + { "shop" => "gas" } => "LPG refill", + { "amenity" => "toilets" } => "Public toilet", + { "shop" => "laundry" } => "Laundry", + { "shop" => "supermarket" } => "Supermarket", + { "shop" => "convenience" } => "Convenience store", + { "natural" => "spring" } => "Spring", + }.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" + + category = category_for(element["tags"] || {}) + next unless category + + { + "type" => "Feature", + "properties" => { "name" => (element["tags"] || {})["name"] || category, "category" => category }, + "geometry" => { "type" => "Point", "coordinates" => [element["lon"], element["lat"]] }, + } + end + { "type" => "FeatureCollection", "features" => features } + end + + def self.category_for(tags) + CATEGORIES.each do |filter, label| + return label if filter.all? { |k, v| tags[k] == v } + end + nil + end + private_class_method :category_for + end +end diff --git a/test/test_overpass_pois.rb b/test/test_overpass_pois.rb new file mode 100644 index 0000000..8959aae --- /dev/null +++ b/test/test_overpass_pois.rb @@ -0,0 +1,68 @@ +require_relative "test_helper" +require "maptool/overpass_pois" + +# No real network calls — to_geojson is pure (canned Overpass response -> +# GeoJSON), and that's the part worth protecting from regressions. fetch() +# itself is a thin Net::HTTP.post_form + JSON.parse wrapper, same as +# LiveTrackClient's own fetch_html, not independently tested for the same +# reason that one isn't. +class OverpassPoisTest < Minitest::Test + def test_to_geojson_converts_matching_node_elements + response = { + "elements" => [ + { "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "drinking_water" } }, + { "type" => "node", "lat" => 48.3, "lon" => 17.4, "tags" => { "amenity" => "toilets", "name" => "Rest area" } }, + ], + } + + geojson = Maptool::OverpassPois.to_geojson(response) + + assert_equal "FeatureCollection", geojson["type"] + assert_equal 2, geojson["features"].size + water = geojson["features"].find { |f| f["properties"]["category"] == "Drinking water" } + 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" + end + + def test_to_geojson_uses_the_node_name_when_present + response = { "elements" => [{ "type" => "node", "lat" => 48.3, "lon" => 17.4, "tags" => { "amenity" => "toilets", "name" => "Rest area" } }] } + + feature = Maptool::OverpassPois.to_geojson(response)["features"].first + + assert_equal "Rest area", feature["properties"]["name"] + end + + def test_to_geojson_skips_ways_and_relations + response = { "elements" => [{ "type" => "way", "tags" => { "amenity" => "drinking_water" } }] } + + assert_empty Maptool::OverpassPois.to_geojson(response)["features"] + end + + def test_to_geojson_skips_nodes_matching_no_known_category + response = { "elements" => [{ "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "restaurant" } }] } + + assert_empty Maptool::OverpassPois.to_geojson(response)["features"] + end + + def test_lpg_matches_either_fuel_stations_with_the_lpg_tag_or_gas_bottle_shops + response = { + "elements" => [ + { "type" => "node", "lat" => 1, "lon" => 1, "tags" => { "amenity" => "fuel", "fuel:lpg" => "yes" } }, + { "type" => "node", "lat" => 2, "lon" => 2, "tags" => { "shop" => "gas" } }, + { "type" => "node", "lat" => 3, "lon" => 3, "tags" => { "amenity" => "fuel" } }, # no lpg tag - not a match + ], + } + + categories = Maptool::OverpassPois.to_geojson(response)["features"].map { |f| f["properties"]["category"] } + + assert_equal ["LPG refill", "LPG refill"], categories + end + + def test_build_query_includes_the_bbox_and_every_category_filter + query = Maptool::OverpassPois.build_query([48.0, 17.0, 49.0, 18.0]) + + assert_includes query, "48.0,17.0,49.0,18.0" + assert_includes query, %(["amenity"="drinking_water"]) + assert_includes query, %(["natural"="spring"]) + end +end