require "open3" require "net/http" require "uri" require "json" require "tmpdir" require "fileutils" require "etc" require_relative "hiking_trails" module Maptool # Regenerates data/offline.pmtiles and data/terrain.pmtiles for the # currently *selected* regions (data/offline_regions.json, an array of # Natural Earth iso_a2 country codes — see available_regions/ # selected_regions/set_selected_regions below). No job queue: each build # runs on its own background Thread and reports into a Mutex-guarded # status Hash a route can poll, the same shape app.rb's own GNSS poller # (GNSS_LATEST/GNSS_LATEST_MUTEX) already uses. module PmtilesBuilder class BuildError < StandardError; end # Natural Earth (public domain, no attribution required) 110m admin-0 # countries, filtered to Europe and trimmed to name/iso_a2/iso_a3 — # see the plan this was built from for the exact filter. 110m is # deliberately coarse (a km or so of border precision, not # survey-grade) — fine for "which country to grab," a same-schema swap # to Natural Earth's 50m/10m cut if that ever needs to be tighter. REGIONS_FILE = File.join(__dir__, "data", "europe_countries.geojson") # Every iso_a2 code in REGIONS_FILE mapped to its Geofabrik download # slug (https://download.geofabrik.de/-latest.osm.pbf) — Geofabrik # names regions, not ISO codes, and doesn't always split the way a # country border does, so this table exists rather than deriving the # slug from the country name. Two known quirks worth knowing about, # not worth working around: # - RU has no "europe/" prefix (Russia is its own top-level index, # and unlike every other entry here is a single ~4GB file covering # the whole country rather than a European-only slice). # - GB's "great-britain" slug is England/Scotland/Wales only — # Northern Ireland lives in "ireland-and-northern-ireland" instead # (bundled with IE, not GB). Selecting GB alone won't include NI. GEOFABRIK_SLUGS = { "AL" => "europe/albania", "AT" => "europe/austria", "BY" => "europe/belarus", "BE" => "europe/belgium", "BA" => "europe/bosnia-herzegovina", "BG" => "europe/bulgaria", "HR" => "europe/croatia", "CZ" => "europe/czech-republic", "DK" => "europe/denmark", "EE" => "europe/estonia", "FI" => "europe/finland", "FR" => "europe/france", "DE" => "europe/germany", "GR" => "europe/greece", "HU" => "europe/hungary", "IS" => "europe/iceland", "IE" => "europe/ireland-and-northern-ireland", "IT" => "europe/italy", "XK" => "europe/kosovo", "LV" => "europe/latvia", "LT" => "europe/lithuania", "LU" => "europe/luxembourg", "MD" => "europe/moldova", "ME" => "europe/montenegro", "NL" => "europe/netherlands", "MK" => "europe/macedonia", "NO" => "europe/norway", "PL" => "europe/poland", "PT" => "europe/portugal", "RO" => "europe/romania", "RU" => "russia", "RS" => "europe/serbia", "SK" => "europe/slovakia", "SI" => "europe/slovenia", "ES" => "europe/spain", "SE" => "europe/sweden", "CH" => "europe/switzerland", "UA" => "europe/ukraine", "GB" => "europe/great-britain", }.freeze # Mapterhorn (a Protomaps-affiliated project) publishes Terrarium- # encoded terrain as pre-built PMTiles archives: one global # planet.pmtiles (z0-12) plus ~450 regional chunks (z13 up to a # per-region max, 13-18) — extract_offline below no longer touches # Protomaps at all (see GEOFABRIK_SLUGS above), but terrain stays on # this same "extract only the region's byte ranges from a remote # archive" story via go-pmtiles, unrelated to the basemap swap. MAPTERHORN_MANIFEST_URL = "https://download.mapterhorn.com/download_urls.json" MAPTERHORN_PLANET_NAME = "planet.pmtiles" # Deliberately *below* the online hillshade layer's own cap # (public/js/overlays.js's ONLINE_TERRAIN_TILES source, maxzoom: 15) — # raster DEM tiles don't compress away like vector ones do, so every # extra zoom level roughly quadruples storage. z13 is each regional # chunk's own native minzoom (see the comment above), so this is the # cheapest useful cut: one raster level per chunk, not a 15-13=2-level # pyramid on top of it. Bumped into a 181GB terrain.pmtiles for a # 5-country selection before this was lowered — see docs/development.adoc. TERRAIN_MAX_ZOOM = 13 OFFLINE_STATUS = { state: "idle" } TERRAIN_STATUS = { state: "idle" } OFFLINE_MUTEX = Mutex.new TERRAIN_MUTEX = Mutex.new def self.offline_status OFFLINE_MUTEX.synchronize { OFFLINE_STATUS.dup } end def self.terrain_status TERRAIN_MUTEX.synchronize { TERRAIN_STATUS.dup } end # Test-only: the status hashes are process-wide state (same caveat as # GPX_STORE/LIVETRACK_STORE in app.rb), so the suite needs a way to # reset them between tests the way AppTestHelper#teardown already does # for those. def self.reset! OFFLINE_MUTEX.synchronize { OFFLINE_STATUS.replace(state: "idle") } TERRAIN_MUTEX.synchronize { TERRAIN_STATUS.replace(state: "idle") } end # --- region selection --- def self.available_regions region_features.map { |f| { code: f["properties"]["iso_a2"], name: f["properties"]["name"] } }.sort_by { |r| r[:name] } end def self.selected_regions(data_dir) path = selected_regions_path(data_dir) return [] unless File.exist?(path) JSON.parse(File.read(path)) rescue JSON::ParserError [] end def self.set_selected_regions(data_dir, codes) unknown = codes - available_regions.map { |r| r[:code] } raise BuildError, "unknown region code(s): #{unknown.join(", ")}" unless unknown.empty? File.write(selected_regions_path(data_dir), JSON.generate(codes)) end def self.selected_regions_path(data_dir) File.join(data_dir, "offline_regions.json") end private_class_method :selected_regions_path def self.region_features @region_features ||= JSON.parse(File.read(REGIONS_FILE))["features"] end private_class_method :region_features # Still used by terrain (below) to compute the aggregate bbox Mapterhorn # chunk intersection needs — offline.pmtiles no longer needs this at # all, since each Geofabrik country file is already clipped to that # country's real border (more accurate than this 110m polygon, not # less), so there's no separate "--region=" precision step for it # anymore. def self.region_bbox(codes) selected = region_features.select { |f| codes.include?(f["properties"]["iso_a2"]) } raise BuildError, "no matching regions for #{codes.join(", ")}" if selected.empty? coords = selected.flat_map { |f| ring_coordinates(f["geometry"]) } lons = coords.map { |lng, _lat| lng } lats = coords.map { |_lng, lat| lat } [lats.min, lons.min, lats.max, lons.max] end private_class_method :region_bbox def self.ring_coordinates(geometry) case geometry["type"] when "Polygon" then geometry["coordinates"].flatten(1) when "MultiPolygon" then geometry["coordinates"].flatten(2) else [] end end private_class_method :ring_coordinates # --- builds --- # Returns once the background Thread has started, not once the build # finishes — raises BuildError (the caller turns this into an error # response) if nothing's selected yet, or one's already running. # # download_extract/fetch_last_modified exist as injectable seams so # tests can exercise the rest of this (scratch dir, osmium/planetiler # invocation, atomic rename, the per-country cache below) without a # real network call — same reasoning test_helper.rb already gives for # keeping /api/plugins route tests off the real Overpass API. def self.build_offline(data_dir:, download_extract: method(:download_extract), fetch_last_modified: method(:fetch_geofabrik_last_modified)) codes = selected_regions(data_dir) raise BuildError, "select at least one region first" if codes.empty? begin_run!(OFFLINE_MUTEX, OFFLINE_STATUS) Thread.new do run_job(OFFLINE_MUTEX, OFFLINE_STATUS) do |report_step| extract_offline(codes, data_dir, download_extract, fetch_last_modified, report_step) end end end # fetch_manifest: same injectable-seam reasoning as download_extract # above, for the one Mapterhorn manifest lookup this needs. def self.build_terrain(data_dir:, fetch_manifest: method(:fetch_mapterhorn_manifest)) codes = selected_regions(data_dir) raise BuildError, "select at least one region first" if codes.empty? begin_run!(TERRAIN_MUTEX, TERRAIN_STATUS) Thread.new do run_job(TERRAIN_MUTEX, TERRAIN_STATUS) do |report_step| build_terrain_pmtiles(codes, data_dir, fetch_manifest, report_step) end end end def self.begin_run!(mutex, status) mutex.synchronize do raise BuildError, "a build is already running" if status[:state] == "running" status.replace(state: "running", started_at: Time.now.utc.iso8601) end end private_class_method :begin_run! # Yields a report_step proc the build can call with a short # human-readable description of what it's doing right now ("downloading # SK (1/2)", "tiling with planetiler", ...) — surfaced as status[:step] # so GET .../build-status can show progress beyond just "running since # X" (see docs/development.adoc). def self.run_job(mutex, status) report_step = ->(text) { mutex.synchronize { status[:step] = text } } yield report_step mutex.synchronize { status.merge!(state: "done", finished_at: Time.now.utc.iso8601).delete(:message) } rescue => e mutex.synchronize { status.merge!(state: "error", finished_at: Time.now.utc.iso8601, message: e.message) } ensure mutex.synchronize { status.delete(:step) } end private_class_method :run_job # --- offline.pmtiles --- # One Geofabrik .osm.pbf per selected country (already clipped to that # country's real border), osmium-merged into one file if more than one # was selected (skipped entirely for a single country — nothing to # merge), then tiled by Planetiler using its default OpenMapTiles # profile straight to PMTiles — the same vector schema # tiles.openfreemap.org's Liberty style already expects, so # public/js/map.js's offlineStyle() can reuse ONLINE_STYLE_PATCH # unchanged instead of a separate protomaps-themes-base palette. def self.extract_offline(codes, data_dir, download_extract, fetch_last_modified, report_step) scratch_dir = Dir.mktmpdir("maptool-offline") sources = codes.each_with_index.map do |code, i| slug = GEOFABRIK_SLUGS.fetch(code) { raise BuildError, "no Geofabrik source known for region #{code}" } report_step.call(progress_step("fetching #{code}", codes, i)) cached_extract(code, slug, data_dir, download_extract, fetch_last_modified) end osm_path = if sources.size == 1 sources.first["path"] else report_step.call("merging with osmium") merged_path = File.join(scratch_dir, "merged.osm.pbf") run_osmium!("merge", *sources.map { |s| s["path"] }, "-o", merged_path) merged_path end report_step.call("tiling with planetiler") # .tmp.pmtiles, not .pmtiles.tmp — Planetiler picks its output archive # format (pmtiles vs mbtiles) from the file extension, so a ".tmp" # suffix made it fail with "Unsupported format tmp" instead of # actually tiling anything. tmp_path = File.join(data_dir, "offline.tmp.pmtiles") # --download: the OpenMapTiles profile also needs a few small auxiliary # sources (water polygons, Natural Earth, lake centerlines) alongside # the OSM extract itself — without this flag Planetiler expects them # to already exist on disk and fails instead of fetching them. They # land under Planetiler's own default "data/sources/", relative to # this process's working directory (the app root, so in practice # that's data_dir's own "sources" subdirectory) — fetched once, # reused by every later build. # --threads: Planetiler's own core-count autodetection can come up # short in a container (cgroup/JVM detection quirks vary by runtime), # leaving most of a many-core host idle — Etc.nprocessors asks the # OS directly (sched_getaffinity on Linux), which correctly reflects # whatever's actually usable by this process even under a container. run_planetiler!("--osm-path=#{osm_path}", "--output=#{tmp_path}", "--download", "--threads=#{Etc.nprocessors}") File.rename(tmp_path, File.join(data_dir, "offline.pmtiles")) File.write(offline_meta_path(data_dir), JSON.generate(sources: sources.map { |s| s.slice("code", "slug", "last_modified") })) report_step.call("extracting hiking trails") extract_hiking_trails(sources, data_dir) ensure FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir) File.delete(tmp_path) if tmp_path && File.exist?(tmp_path) end private_class_method :extract_offline # One GeoJSON per country (data/hiking_trails/.geojson), not one # merged file — the frontend only ever needs to show a country at a # time the viewer actually picks (see HikingTrailsControl's country # checklist), and a combined multi-country file was needlessly making # every viewer fetch every selected country's trails regardless. # # Runs over the same per-country .osm.pbf files `sources` above already # downloaded/cached for offline.pmtiles itself — no separate download. # Cached the same way geofabrik_cache is: skipped for a country whose # extract is already newer than its source .osm.pbf, so growing the # region selection by one country only extracts that one, not every # already-covered country all over again. # # Best-effort per country: an extraction problem (a malformed PBF # region, an osmium hiccup) shouldn't fail the whole offline.pmtiles # build, or even the other countries in this same selection, over # what's a bonus overlay, not the core map. def self.extract_hiking_trails(sources, data_dir) FileUtils.mkdir_p(hiking_trails_dir(data_dir)) sources.each do |source| out_path = hiking_trails_country_path(data_dir, source["code"]) next if File.exist?(out_path) && File.mtime(out_path) >= File.mtime(source["path"]) tmp_path = "#{out_path}.tmp" begin File.write(tmp_path, JSON.generate(HikingTrails.extract(source["path"]))) File.rename(tmp_path, out_path) rescue HikingTrails::ExtractError => e File.delete(tmp_path) if File.exist?(tmp_path) warn "hiking trails extraction failed for #{source["code"]}, skipping: #{e.message}" end end end private_class_method :extract_hiking_trails def self.hiking_trails_dir(data_dir) File.join(data_dir, "hiking_trails") end def self.hiking_trails_country_path(data_dir, code) File.join(hiking_trails_dir(data_dir), "#{code}.geojson") end # Country codes with an already-extracted offline hiking-trails file, # sorted — not necessarily the same as the *current* region selection # (accumulates like geofabrik_cache does; a country deselected after # being built once is left alone rather than swept up here). def self.hiking_trails_countries(data_dir) Dir.glob(File.join(hiking_trails_dir(data_dir), "*.geojson")).map { |path| File.basename(path, ".geojson") }.sort end # Renders a report_step string like "fetching PL (3/4) — done: SK, CZ — # pending: HU" — so GET .../build-status shows not just "it's running" # but which of the current build's sources is in flight and which are # already behind/ahead of it. def self.progress_step(action, labels, index) done = labels[0...index] pending = labels[(index + 1)..] || [] parts = ["#{action} (#{index + 1}/#{labels.size})"] parts << "done: #{done.join(", ")}" unless done.empty? parts << "pending: #{pending.join(", ")}" unless pending.empty? parts.join(" — ") end private_class_method :progress_step def self.geofabrik_url(slug) "https://download.geofabrik.de/#{slug}-latest.osm.pbf" end private_class_method :geofabrik_url # Per-country .osm.pbf cache, kept across builds under data_dir (not # scratch_dir, which is wiped every run) — changing the region # selection re-downloads only the countries that are new to it, and # re-running the same selection re-downloads nothing unless Geofabrik's # own Last-Modified for that country moved on. A stamp file next to # each cached extract records the Last-Modified it was fetched with; # a HEAD check (cheap) decides whether the multi-GB GET (not cheap) is # skippable. def self.cached_extract(code, slug, data_dir, download_extract, fetch_last_modified) cache_dir = geofabrik_cache_dir(data_dir) FileUtils.mkdir_p(cache_dir) path = File.join(cache_dir, "#{code}.osm.pbf") stamp_path = "#{path}.last_modified" url = geofabrik_url(slug) if File.exist?(path) && File.exist?(stamp_path) current = begin fetch_last_modified.call(url) rescue BuildError nil end return { "code" => code, "slug" => slug, "last_modified" => current, "path" => path } if current && current == File.read(stamp_path) end last_modified = download_extract.call(url, path) File.write(stamp_path, last_modified.to_s) { "code" => code, "slug" => slug, "last_modified" => last_modified, "path" => path } end private_class_method :cached_extract def self.geofabrik_cache_dir(data_dir) File.join(data_dir, "geofabrik_cache") end private_class_method :geofabrik_cache_dir # Real network implementation of the download_extract seam above — # streams the extract to dest (some of these are multi-GB — Net::HTTP's # plain #get buffers the whole response body in memory first, which # read_body's block form avoids) and returns its Last-Modified header # (free off the same GET, no separate HEAD needed) for the sidecar # meta / update-check below. Geofabrik 302s (e.g. .../europe/austria # redirecting to wherever actually serves it) — followed manually # since request_get's block form doesn't do this itself. def self.download_extract(url, dest, limit: 5) raise BuildError, "too many redirects downloading #{url}" if limit.zero? uri = URI(url) last_modified = nil redirect_to = nil Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| http.request_get(uri) do |response| case response when Net::HTTPSuccess last_modified = response["last-modified"] File.open(dest, "wb") { |file| response.read_body { |chunk| file.write(chunk) } } when Net::HTTPRedirection redirect_to = URI.join(url, response["location"]).to_s else raise BuildError, "could not download #{url} (HTTP #{response.code})" end end end return download_extract(redirect_to, dest, limit: limit - 1) if redirect_to last_modified end # Sidecar recording which Geofabrik sources (country code, slug, and # that request's Last-Modified) the on-disk offline.pmtiles was # actually built from — the file itself carries no such record. nil if # never built (or built before this existed). def self.offline_meta(data_dir) read_json(offline_meta_path(data_dir)) end # Compares each currently-selected country's live Geofabrik # Last-Modified against what's recorded, without downloading anything # — nil (not true/false) if there's nothing to compare yet (no local # build, nothing selected) or a check couldn't be completed, so the # caller can tell "don't know" apart from "no". Also naturally reports # "yes" if the selection changed since the last build (a country now # relevant that wasn't recorded, or vice versa) — same reasoning # terrain_update_available? below already uses. def self.offline_update_available?(data_dir, fetch_last_modified: method(:fetch_geofabrik_last_modified)) meta = offline_meta(data_dir) return nil unless meta codes = selected_regions(data_dir) return nil if codes.empty? current = codes.map do |code| slug = GEOFABRIK_SLUGS[code] return nil unless slug { "code" => code, "last_modified" => fetch_last_modified.call(geofabrik_url(slug)) } end.sort_by { |s| s["code"] } recorded = (meta["sources"] || []).map { |s| s.slice("code", "last_modified") }.sort_by { |s| s["code"] } current != recorded rescue BuildError nil end def self.fetch_geofabrik_last_modified(url, limit: 5) raise BuildError, "too many redirects reaching #{url}" if limit.zero? uri = URI(url) response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.head(uri) } case response when Net::HTTPSuccess then response["last-modified"] when Net::HTTPRedirection then fetch_geofabrik_last_modified(URI.join(url, response["location"]).to_s, limit: limit - 1) else raise BuildError, "could not reach #{url}" end end def self.offline_meta_path(data_dir) File.join(data_dir, "offline.pmtiles.meta.json") end private_class_method :offline_meta_path # Deletes offline.pmtiles, its sidecar meta, and the whole per-country # download cache (data/geofabrik_cache/) — "start over" for the offline # basemap. The current selection (data/offline_regions.json) is left # alone, so a following build just re-downloads it fresh. def self.delete_offline_data(data_dir) OFFLINE_MUTEX.synchronize { raise BuildError, "a build is already running" if OFFLINE_STATUS[:state] == "running" } File.delete(File.join(data_dir, "offline.pmtiles")) if File.exist?(File.join(data_dir, "offline.pmtiles")) File.delete(offline_meta_path(data_dir)) if File.exist?(offline_meta_path(data_dir)) FileUtils.remove_entry(hiking_trails_dir(data_dir)) if Dir.exist?(hiking_trails_dir(data_dir)) cache_dir = geofabrik_cache_dir(data_dir) FileUtils.remove_entry(cache_dir) if Dir.exist?(cache_dir) end # Deletes just one country's cached .osm.pbf (and its Last-Modified # stamp) — e.g. to force a fresh download without touching the others, # or to reclaim space for a country no longer selected. def self.delete_cached_extract(data_dir, code) OFFLINE_MUTEX.synchronize { raise BuildError, "a build is already running" if OFFLINE_STATUS[:state] == "running" } cache_dir = geofabrik_cache_dir(data_dir) path = File.join(cache_dir, "#{code}.osm.pbf") File.delete(path) if File.exist?(path) File.delete("#{path}.last_modified") if File.exist?("#{path}.last_modified") end # What's actually sitting in the per-country download cache right now — # not the same thing as offline_meta's "sources", which only reflects # whatever the *last successful build* used. A country can be cached # without ever having been built (selected, then deselected again # before Apply) or built from without still being selected, and this is # what lets the region picker show/clear either case. def self.cached_extracts(data_dir) cache_dir = geofabrik_cache_dir(data_dir) return [] unless Dir.exist?(cache_dir) Dir.glob(File.join(cache_dir, "*.osm.pbf")).map do |path| stamp_path = "#{path}.last_modified" { "code" => File.basename(path, ".osm.pbf"), "size" => File.size(path), "last_modified" => File.exist?(stamp_path) ? File.read(stamp_path) : nil, } end.sort_by { |s| s["code"] } end # --- terrain.pmtiles --- # planet.pmtiles (z0-12, always) plus whichever regional chunks # intersect the selection's aggregate bbox (z13+, zero or more) are # each `extract`ed on their own using the *same* --region= file, then # combined with `pmtiles merge` if there was more than one. Always # safe to merge regardless of how many countries are selected or # whether they're adjacent: each Mapterhorn chunk is its own # pre-partitioned, non-overlapping source grid cell, so two different # chunks' extracts can never share a tile id — the only thing `merge` # refuses (see pmtiles/merge.go's own disjointness check). What would # NOT be safe is extracting the *same* source once per country and # merging those — adjacent countries' region-extracts would share # border tiles (extraction copies whole tiles, it doesn't clip vector # content to the polygon) — so this always does one extract per # *source archive*, never one per country. def self.build_terrain_pmtiles(codes, data_dir, fetch_manifest, report_step) report_step.call("fetching mapterhorn manifest") items = fetch_manifest.call planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME } raise BuildError, "mapterhorn manifest is missing #{MAPTERHORN_PLANET_NAME}" unless planet scratch_dir = Dir.mktmpdir("maptool-terrain") south, west, north, east = region_bbox(codes) region_path = File.join(scratch_dir, "region.geojson") selected = region_features.select { |f| codes.include?(f["properties"]["iso_a2"]) } File.write(region_path, JSON.generate(type: "FeatureCollection", features: selected)) chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) } # .tmp.pmtiles, not .pmtiles.tmp — same reasoning as offline.pmtiles' # own tmp_path above, in case go-pmtiles also derives format from # the extension for its "merge" output. tmp_path = File.join(data_dir, "terrain.tmp.pmtiles") sources = [planet, *chunks] labels = sources.map { |item| item["name"] } extracts = sources.each_with_index.map do |item, i| report_step.call(progress_step("extracting #{item["name"]}", labels, i)) extract_path = File.join(scratch_dir, "extract-#{i}.pmtiles") # --overfetch=0: go-pmtiles' default (0.05) merges nearby byte # ranges into fewer, bigger HTTP requests to save round-trips — # for planet.pmtiles (global z0-12, tens of GB) covering a # multi-country selection that collapsed the whole extract into # a *single* ~60GB request, which then had no way to resume after # an HTTP/2 stream reset 11 minutes in (see the "died again" # 2026-09-12 build). Disabling merging trades that one fragile, # unresumable mega-request for many small ones spread over # --download-threads (default 4) — slower on a clean run, but no # longer one dropped stream away from losing 20 minutes of transfer. run_pmtiles!("extract", item["url"], extract_path, "--region=#{region_path}", "--maxzoom=#{TERRAIN_MAX_ZOOM}", "--overfetch=0") extract_path end if extracts.size == 1 File.rename(extracts.first, tmp_path) else report_step.call("merging with pmtiles") run_pmtiles!("merge", *extracts, tmp_path) end File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles")) File.write(terrain_meta_path(data_dir), JSON.generate(sources: [planet, *chunks].map { |i| { name: i["name"], md5sum: i["md5sum"] } })) ensure FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir) File.delete(tmp_path) if tmp_path && File.exist?(tmp_path) end private_class_method :build_terrain_pmtiles def self.bbox_intersects?(item, south, west, north, east) item["min_lon"] < east && item["max_lon"] > west && item["min_lat"] < north && item["max_lat"] > south end private_class_method :bbox_intersects? def self.fetch_mapterhorn_manifest response = Net::HTTP.get_response(URI(MAPTERHORN_MANIFEST_URL)) raise BuildError, "could not reach #{MAPTERHORN_MANIFEST_URL}" unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body).fetch("items") rescue JSON::ParserError raise BuildError, "mapterhorn manifest was not valid JSON" end private_class_method :fetch_mapterhorn_manifest # Sidecar recording which Mapterhorn sources (name + md5sum) the on-disk # terrain.pmtiles was actually extracted+merged from. nil if never built. def self.terrain_meta(data_dir) read_json(terrain_meta_path(data_dir)) end # Same "don't know" vs. "no" distinction as offline_update_available? # above. Recomputes which sources *would* be selected for the current # selection (same manifest + bbox_intersects? this build itself uses) # and compares their md5sums against what's recorded — so this also # correctly says "yes" if the selection changed since the last build # (a chunk now relevant that wasn't recorded), not just if Mapterhorn's # own data moved on. def self.terrain_update_available?(data_dir, fetch_manifest: method(:fetch_mapterhorn_manifest)) meta = terrain_meta(data_dir) return nil unless meta codes = selected_regions(data_dir) return nil if codes.empty? items = fetch_manifest.call planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME } return nil unless planet south, west, north, east = region_bbox(codes) chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) } current = [planet, *chunks].map { |i| { "name" => i["name"], "md5sum" => i["md5sum"] } }.sort_by { |s| s["name"] } current != (meta["sources"] || []).sort_by { |s| s["name"] } rescue BuildError nil end def self.terrain_meta_path(data_dir) File.join(data_dir, "terrain.pmtiles.meta.json") end private_class_method :terrain_meta_path # Deletes terrain.pmtiles and its sidecar meta — same "start over" # reasoning as delete_offline_data above. No download cache to clean up # here: terrain.pmtiles' pmtiles-extract step reads its sources over # HTTP range requests rather than downloading a whole archive first. def self.delete_terrain_data(data_dir) TERRAIN_MUTEX.synchronize { raise BuildError, "a build is already running" if TERRAIN_STATUS[:state] == "running" } File.delete(File.join(data_dir, "terrain.pmtiles")) if File.exist?(File.join(data_dir, "terrain.pmtiles")) File.delete(terrain_meta_path(data_dir)) if File.exist?(terrain_meta_path(data_dir)) end # --- shared --- def self.read_json(path) File.exist?(path) ? JSON.parse(File.read(path)) : nil rescue JSON::ParserError nil end private_class_method :read_json # ENV read lazily (not load-time constants) so tests can point these at # fake binaries without a require-order dance. def self.pmtiles_bin ENV.fetch("PMTILES_BIN", "pmtiles") end def self.osmium_bin ENV.fetch("OSMIUM_BIN", "osmium") end def self.java_bin ENV.fetch("JAVA_BIN", "java") end def self.planetiler_jar ENV.fetch("PLANETILER_JAR", "/usr/local/lib/planetiler.jar") end # go-pmtiles' extract logs one progress line per chunk fetched # ("fetching N dirs, M chunks, K requests") — fine on a terminal where # it overwrites itself, but captured non-interactively that's one line # per chunk, easily hundreds of thousands of characters for a big # region. The actual failure is always at the end, so keep only the # tail — same story for osmium/planetiler's own chatty stdout. OUTPUT_TAIL_LINES = 40 # Some CLI tools (go-pmtiles included) write their actual error to # stdout rather than stderr — combine both rather than risk raising a # BuildError with nothing useful in it. def self.command_output(stdout, stderr) lines = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n").lines return lines.join if lines.size <= OUTPUT_TAIL_LINES omitted = lines.size - OUTPUT_TAIL_LINES "(#{omitted} earlier lines omitted)\n#{lines.last(OUTPUT_TAIL_LINES).join}" end private_class_method :command_output # extract/merge stream tens of GB over HTTP range requests; retry a # transient failure (e.g. a mid-transfer HTTP/2 stream reset from the # remote) a couple times before giving up, rather than forcing a full # re-download on every hiccup. def self.run_pmtiles!(*args, attempts: 3) attempts.times do |i| stdout, stderr, status = Open3.capture3(pmtiles_bin, *args) return if status.success? raise BuildError, "pmtiles exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" if i == attempts - 1 end rescue Errno::ENOENT, Errno::EACCES => e raise BuildError, "could not run '#{pmtiles_bin}' (#{e.message}) — is go-pmtiles installed and on PATH? See docs/development.adoc." end private_class_method :run_pmtiles! def self.run_osmium!(*args) stdout, stderr, status = Open3.capture3(osmium_bin, *args) raise BuildError, "osmium exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" unless status.success? rescue Errno::ENOENT, Errno::EACCES => e raise BuildError, "could not run '#{osmium_bin}' (#{e.message}) — is osmium-tool installed and on PATH? See docs/development.adoc." end private_class_method :run_osmium! def self.run_planetiler!(*args) stdout, stderr, status = Open3.capture3(java_bin, "-jar", planetiler_jar, *args) raise BuildError, "planetiler exited with status #{status.exitstatus}: #{command_output(stdout, stderr)}" unless status.success? rescue Errno::ENOENT, Errno::EACCES => e raise BuildError, "could not run '#{java_bin} -jar #{planetiler_jar}' (#{e.message}) — is Java and planetiler.jar installed? See docs/development.adoc." end private_class_method :run_planetiler! end end