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