From 53c9a82af279453a33751566fb0b1b102e14846c Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Fri, 11 Sep 2026 12:55:56 +0200 Subject: [PATCH] Add a country-based region picker for offline data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces "regenerate for the current viewport" with what Locus/OsmAnd- style apps give you: tick which countries you want offline (a Natural Earth-derived checklist, public domain, vendored), Apply rebuilds offline.pmtiles/terrain.pmtiles to cover the whole selection at once. Multiple regions can be offline simultaneously (e.g. Slovakia + Austria for a trip that crosses the border) — the selection itself is the source of truth (data/offline_regions.json), not a set of per-country files, so "remove a country" and "refresh a country" are both just "change the selection, then Apply." Confirmed against go-pmtiles' own source (not just its docs) before committing to this shape: `extract --region=` treats a FeatureCollection's features as their union already (no dissolve/union math needed on our side — pmtiles/region.go's UnmarshalRegion just concatenates every feature's rings), and `merge` hard-errors on any overlapping tile id between inputs (merge.go's prepareInputs). That second fact rules out "extract each country separately, merge them all" — adjacent countries' region-extracts would share border tiles, since extraction copies whole tiles rather than clipping vector content to the polygon. The actual fix: extract per *source archive* (the Protomaps build, each intersecting Mapterhorn chunk), each call covering the whole current selection via one --region= file — the terrain pipeline already extracted-then-merged per source this way, it just needed a multi-country region instead of a single bbox. --- app.rb | 43 ++++-- docs/development.adoc | 52 +++++--- lib/maptool/data/europe_countries.geojson | 1 + lib/maptool/pmtiles_builder.rb | 150 +++++++++++++++++---- public/css/style.css | 50 +++++-- public/js/map.js | 2 +- public/js/offline_data.js | 151 ++++++++++++++-------- test/test_app.rb | 46 +++++-- test/test_helper.rb | 1 + test/test_pmtiles_builder.rb | 116 +++++++++++------ 10 files changed, 442 insertions(+), 170 deletions(-) create mode 100644 lib/maptool/data/europe_countries.geojson diff --git a/app.rb b/app.rb index 6cd5f42..6a77111 100644 --- a/app.rb +++ b/app.rb @@ -223,18 +223,38 @@ module Maptool send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream" end - # Regenerates offline.pmtiles for the given bbox (the current map - # viewport, same "visible area is the area" convention as the Overpass - # POI plugin) by extracting it from Protomaps' own daily full-planet - # basemap build — see lib/maptool/pmtiles_builder.rb. Runs in the - # background; poll build-status below for progress. - post "/api/offline-tiles/build" do + # Lists the selectable countries and which of them are currently + # selected (data/offline_regions.json) — read-only, no login needed, + # same tier as the plain availability checks above. + get "/api/offline-regions" do + json available: PmtilesBuilder.available_regions, selected: PmtilesBuilder.selected_regions(DATA_DIR) + end + + # Just updates the selection — does not itself start a build, so + # ticking several countries doesn't fire off several rebuilds. The + # frontend follows this with the two build routes below once the user + # is done choosing. + post "/api/offline-regions" do require_login! - 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?) + codes = Array(params[:codes]) begin - PmtilesBuilder.build_offline(bbox.map(&:to_f), data_dir: DATA_DIR) + PmtilesBuilder.set_selected_regions(DATA_DIR, codes) + rescue PmtilesBuilder::BuildError => e + halt 400, json(error: e.message) + end + json ok: true + end + + # Regenerates offline.pmtiles for the currently selected regions (see + # POST /api/offline-regions above) by extracting them from Protomaps' + # own daily full-planet basemap build — see + # lib/maptool/pmtiles_builder.rb. Runs in the background; poll + # build-status below for progress. + post "/api/offline-tiles/build" do + require_login! + begin + PmtilesBuilder.build_offline(data_dir: DATA_DIR) rescue PmtilesBuilder::BuildError => e halt 409, json(error: e.message) end @@ -260,11 +280,8 @@ module Maptool post "/api/terrain-tiles/build" do require_login! - 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?) - begin - PmtilesBuilder.build_terrain(bbox.map(&:to_f), data_dir: DATA_DIR) + PmtilesBuilder.build_terrain(data_dir: DATA_DIR) rescue PmtilesBuilder::BuildError => e halt 409, json(error: e.message) end diff --git a/docs/development.adoc b/docs/development.adoc index bb0a85a..730d9ae 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -85,25 +85,47 @@ size rather than assuming one, so this isn't a problem either way. A logged-in user can also generate both files above from the app itself, no local tooling needed — the sidebar's "Offline data" icon -(Offline hub group) shows each file's status (size, last updated) and -a "Regenerate for current view" button. The region is always the -current map viewport, same "no area-picker, the visible area is the -area" convention the Overpass POI plugin already uses below. This -wraps exactly the manual `go-pmtiles` workflows documented above +(Offline hub group) shows a checklist of European countries +(`lib/maptool/data/europe_countries.geojson`, Natural Earth 110m +admin-0, public domain) plus each file's status (size, last updated). +Ticking countries and clicking "Apply changes" saves the selection +(`data/offline_regions.json`, an array of ISO A2 codes) and rebuilds +both files to cover it. + +There's no standalone per-country file to individually delete — +`offline.pmtiles`/`terrain.pmtiles` are always one combined extract for +the *whole current selection*. "Remove a country" and "refresh a +country" are both just "change the selection, then Apply" (unticking a +country and applying rebuilds without it; nothing to separately +delete). Every Apply re-extracts the entire selection from scratch, not +just what changed — fine given extraction is fast (byte-range requests +against remote archives, not per-tile fetches), but worth revisiting if +someone selects half of Europe at once and finds it slow. + +This wraps exactly the manual `go-pmtiles` workflows documented above (`lib/maptool/pmtiles_builder.rb`) — the server needs the https://github.com/protomaps/go-pmtiles[go-pmtiles] CLI installed and on `PATH` (override with the `PMTILES_BIN` env var if it lives -somewhere else). Fetches Mapterhorn's own small JSON manifest to work -out which regional chunk(s), if any, intersect the bbox, `extract`s -each (plus `planet.pmtiles`, always), then `merge`s them if there was -more than one — typically seconds, not the tens of minutes an earlier -version of this (fetching every individual Terrarium tile itself) used -to take for a real-sized region. +somewhere else), using `--region=` (a `FeatureCollection` of +the selected countries — go-pmtiles treats multiple features as their +union already, no union/dissolve math needed on this side) rather than +`--bbox=` for precise, per-country extraction. terrain.pmtiles extracts +`planet.pmtiles` (always) plus whichever Mapterhorn regional chunks +intersect the selection, `merge`s them if there was more than one — +always safe regardless of how many/which countries are selected, since +each chunk is Mapterhorn's own pre-partitioned, non-overlapping source +file (extracting the *same* source once per country and merging those, +by contrast, would fail — adjacent countries' region-extracts share +border tiles, since extraction copies whole tiles rather than clipping +vector content to the polygon, and `pmtiles merge` refuses any +overlapping tile id between inputs). -A build runs in the background (`POST /api/offline-tiles/build` / -`/api/terrain-tiles/build`, polled via the matching `*/build-status` -route) — only one of each kind at a time; starting a second while one's -running gets a 409. +A build runs in the background (`GET /api/offline-regions` to list/see +the current selection, `POST` to change it, then `POST +/api/offline-tiles/build` / `/api/terrain-tiles/build`, polled via the +matching `*/build-status` route) — only one of each kind at a time; +starting a second while one's running gets a 409, as does starting +either with nothing selected yet. == Camper-relevant POIs (Overpass) diff --git a/lib/maptool/data/europe_countries.geojson b/lib/maptool/data/europe_countries.geojson new file mode 100644 index 0000000..90e8c8d --- /dev/null +++ b/lib/maptool/data/europe_countries.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"Albania","iso_a2":"AL","iso_a3":"ALB"},"geometry":{"type":"Polygon","coordinates":[[[21.02004,40.842727],[20.99999,40.580004],[20.674997,40.435],[20.615,40.110007],[20.150016,39.624998],[19.98,39.694993],[19.960002,39.915006],[19.406082,40.250773],[19.319059,40.72723],[19.40355,41.409566],[19.540027,41.719986],[19.371769,41.877548],[19.371768,41.877551],[19.304486,42.195745],[19.738051,42.688247],[19.801613,42.500093],[20.0707,42.58863],[20.283755,42.32026],[20.52295,42.21787],[20.590247,41.855409],[20.590247,41.855404],[20.463175,41.515089],[20.605182,41.086226],[21.02004,40.842727]]]}},{"type":"Feature","properties":{"name":"Austria","iso_a2":"AT","iso_a3":"AUT"},"geometry":{"type":"Polygon","coordinates":[[[16.979667,48.123497],[16.903754,47.714866],[16.340584,47.712902],[16.534268,47.496171],[16.202298,46.852386],[16.011664,46.683611],[15.137092,46.658703],[14.632472,46.431817],[13.806475,46.509306],[12.376485,46.767559],[12.153088,47.115393],[11.164828,46.941579],[11.048556,46.751359],[10.442701,46.893546],[9.932448,46.920728],[9.47997,47.10281],[9.632932,47.347601],[9.594226,47.525058],[9.896068,47.580197],[10.402084,47.302488],[10.544504,47.566399],[11.426414,47.523766],[12.141357,47.703083],[12.62076,47.672388],[12.932627,47.467646],[13.025851,47.637584],[12.884103,48.289146],[13.243357,48.416115],[13.595946,48.877172],[14.338898,48.555305],[14.901447,48.964402],[15.253416,49.039074],[16.029647,48.733899],[16.499283,48.785808],[16.960288,48.596982],[16.879983,48.470013],[16.979667,48.123497]]]}},{"type":"Feature","properties":{"name":"Belarus","iso_a2":"BY","iso_a3":"BLR"},"geometry":{"type":"Polygon","coordinates":[[[28.176709,56.16913],[29.229513,55.918344],[29.371572,55.670091],[29.896294,55.789463],[30.873909,55.550976],[30.971836,55.081548],[30.757534,54.811771],[31.384472,54.157056],[31.791424,53.974639],[31.731273,53.794029],[32.405599,53.618045],[32.693643,53.351421],[32.304519,53.132726],[31.49764,53.16743],[31.305201,53.073996],[31.540018,52.742052],[31.78597,52.10168],[31.785992,52.101678],[30.927549,52.042353],[30.619454,51.822806],[30.555117,51.319503],[30.157364,51.416138],[29.254938,51.368234],[28.992835,51.602044],[28.617613,51.427714],[28.241615,51.572227],[27.454066,51.592303],[26.337959,51.832289],[25.327788,51.910656],[24.553106,51.888461],[24.005078,51.617444],[23.527071,51.578454],[23.508002,52.023647],[23.199494,52.486977],[23.799199,52.691099],[23.804935,53.089731],[23.527536,53.470122],[23.484128,53.912498],[24.450684,53.905702],[25.536354,54.282423],[25.768433,54.846963],[26.588279,55.167176],[26.494331,55.615107],[27.10246,55.783314],[28.176709,56.16913]]]}},{"type":"Feature","properties":{"name":"Belgium","iso_a2":"BE","iso_a3":"BEL"},"geometry":{"type":"Polygon","coordinates":[[[6.156658,50.803721],[6.043073,50.128052],[5.782417,50.090328],[5.674052,49.529484],[4.799222,49.985373],[4.286023,49.907497],[3.588184,50.378992],[3.123252,50.780363],[2.658422,50.796848],[2.513573,51.148506],[3.314971,51.345781],[3.315011,51.345777],[3.314971,51.345755],[4.047071,51.267259],[4.973991,51.475024],[5.606976,51.037298],[6.156658,50.803721]]]}},{"type":"Feature","properties":{"name":"Bosnia and Herz.","iso_a2":"BA","iso_a3":"BIH"},"geometry":{"type":"Polygon","coordinates":[[[18.56,42.65],[17.674922,43.028563],[17.297373,43.446341],[16.916156,43.667722],[16.456443,44.04124],[16.23966,44.351143],[15.750026,44.818712],[15.959367,45.233777],[16.318157,45.004127],[16.534939,45.211608],[17.002146,45.233777],[17.861783,45.06774],[18.553214,45.08159],[19.005485,44.860234],[19.00548,44.86023],[19.36803,44.863],[19.11761,44.42307],[19.59976,44.03847],[19.454,43.5681],[19.21852,43.52384],[19.03165,43.43253],[18.70648,43.20011],[18.56,42.65]]]}},{"type":"Feature","properties":{"name":"Bulgaria","iso_a2":"BG","iso_a3":"BGR"},"geometry":{"type":"Polygon","coordinates":[[[22.65715,44.234923],[22.944832,43.823785],[23.332302,43.897011],[24.100679,43.741051],[25.569272,43.688445],[26.065159,43.943494],[27.2424,44.175986],[27.970107,43.812468],[28.558081,43.707462],[28.039095,43.293172],[27.673898,42.577892],[27.99672,42.007359],[27.135739,42.141485],[26.117042,41.826905],[26.106138,41.328899],[25.197201,41.234486],[24.492645,41.583896],[23.692074,41.309081],[22.952377,41.337994],[22.881374,41.999297],[22.380526,42.32026],[22.545012,42.461362],[22.436595,42.580321],[22.604801,42.898519],[22.986019,43.211161],[22.500157,43.642814],[22.410446,44.008063],[22.65715,44.234923]]]}},{"type":"Feature","properties":{"name":"Croatia","iso_a2":"HR","iso_a3":"HRV"},"geometry":{"type":"Polygon","coordinates":[[[16.564808,46.503751],[16.882515,46.380632],[17.630066,45.951769],[18.456062,45.759481],[18.829825,45.908872],[19.072769,45.521511],[19.390476,45.236516],[19.005485,44.860234],[18.553214,45.08159],[17.861783,45.06774],[17.002146,45.233777],[16.534939,45.211608],[16.318157,45.004127],[15.959367,45.233777],[15.750026,44.818712],[16.23966,44.351143],[16.456443,44.04124],[16.916156,43.667722],[17.297373,43.446341],[17.674922,43.028563],[18.56,42.65],[18.450017,42.479992],[18.450016,42.479991],[17.50997,42.849995],[16.930006,43.209998],[16.015385,43.507215],[15.174454,44.243191],[15.37625,44.317915],[14.920309,44.738484],[14.901602,45.07606],[14.258748,45.233777],[13.952255,44.802124],[13.656976,45.136935],[13.679403,45.484149],[13.71506,45.500324],[14.411968,45.466166],[14.595109,45.634941],[14.935244,45.471695],[15.327675,45.452316],[15.323954,45.731783],[15.67153,45.834154],[15.768733,46.238108],[16.564808,46.503751]]]}},{"type":"Feature","properties":{"name":"Czechia","iso_a2":"CZ","iso_a3":"CZE"},"geometry":{"type":"Polygon","coordinates":[[[15.016996,51.106674],[15.490972,50.78473],[16.238627,50.697733],[16.176253,50.422607],[16.719476,50.215747],[16.868769,50.473974],[17.554567,50.362146],[17.649445,50.049038],[18.392914,49.988629],[18.853144,49.49623],[18.554971,49.495015],[18.399994,49.315001],[18.170498,49.271515],[18.104973,49.043983],[17.913512,48.996493],[17.886485,48.903475],[17.545007,48.800019],[17.101985,48.816969],[16.960288,48.596982],[16.499283,48.785808],[16.029647,48.733899],[15.253416,49.039074],[14.901447,48.964402],[14.338898,48.555305],[13.595946,48.877172],[13.031329,49.307068],[12.521024,49.547415],[12.415191,49.969121],[12.240111,50.266338],[12.966837,50.484076],[13.338132,50.733234],[14.056228,50.926918],[14.307013,51.117268],[14.570718,51.002339],[15.016996,51.106674]]]}},{"type":"Feature","properties":{"name":"Denmark","iso_a2":"DK","iso_a3":"DNK"},"geometry":{"type":"MultiPolygon","coordinates":[[[[9.921906,54.983104],[9.282049,54.830865],[8.526229,54.962744],[8.120311,55.517723],[8.089977,56.540012],[8.256582,56.809969],[8.543438,57.110003],[9.424469,57.172066],[9.775559,57.447941],[10.580006,57.730017],[10.546106,57.215733],[10.25,56.890016],[10.369993,56.609982],[10.912182,56.458621],[10.667804,56.081383],[10.369993,56.190007],[9.649985,55.469999],[9.921906,54.983104]]],[[[12.370904,56.111407],[12.690006,55.609991],[12.089991,54.800015],[11.043543,55.364864],[10.903914,55.779955],[12.370904,56.111407]]]]}},{"type":"Feature","properties":{"name":"Estonia","iso_a2":"EE","iso_a3":"EST"},"geometry":{"type":"Polygon","coordinates":[[[27.981127,59.475373],[27.98112,59.47537],[28.131699,59.300825],[27.42015,58.72457],[27.716686,57.791899],[27.288185,57.474528],[26.463532,57.476389],[25.60281,57.847529],[25.164594,57.970157],[24.312863,57.793424],[24.428928,58.383413],[24.061198,58.257375],[23.42656,58.612753],[23.339795,59.18724],[24.604214,59.465854],[25.864189,59.61109],[26.949136,59.445803],[27.981114,59.475388],[27.981127,59.475373]]]}},{"type":"Feature","properties":{"name":"Finland","iso_a2":"FI","iso_a3":"FIN"},"geometry":{"type":"Polygon","coordinates":[[[28.59193,69.064777],[28.445944,68.364613],[29.977426,67.698297],[29.054589,66.944286],[30.21765,65.80598],[29.54443,64.948672],[30.444685,64.204453],[30.035872,63.552814],[31.516092,62.867687],[31.139991,62.357693],[30.211107,61.780028],[28.07,60.50352],[28.070002,60.503519],[28.069998,60.503517],[26.255173,60.423961],[24.496624,60.057316],[22.869695,59.846373],[22.290764,60.391921],[21.322244,60.72017],[21.544866,61.705329],[21.059211,62.607393],[21.536029,63.189735],[22.442744,63.81781],[24.730512,64.902344],[25.398068,65.111427],[25.294043,65.534346],[23.903379,66.006927],[23.56588,66.396051],[23.539473,67.936009],[21.978535,68.616846],[20.645593,69.106247],[21.244936,69.370443],[22.356238,68.841741],[23.66205,68.891247],[24.735679,68.649557],[25.689213,69.092114],[26.179622,69.825299],[27.732292,70.164193],[29.015573,69.766491],[28.59193,69.064777]]]}},{"type":"Feature","properties":{"name":"France","iso_a2":"FR","iso_a3":"-99"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-51.657797,4.156232],[-52.249338,3.241094],[-52.556425,2.504705],[-52.939657,2.124858],[-53.418465,2.053389],[-53.554839,2.334897],[-53.778521,2.376703],[-54.088063,2.105557],[-54.524754,2.311849],[-54.269705,2.732392],[-54.181726,3.18978],[-54.006931,3.620038],[-54.399542,4.212611],[-54.478633,4.896756],[-53.958045,5.756548],[-53.618453,5.646529],[-52.882141,5.409851],[-51.823343,4.565768],[-51.657797,4.156232]]],[[[6.18632,49.463803],[6.65823,49.201958],[8.099279,49.017784],[7.593676,48.333019],[7.466759,47.620582],[7.192202,47.449766],[6.736571,47.541801],[6.768714,47.287708],[6.037389,46.725779],[6.022609,46.27299],[6.5001,46.429673],[6.843593,45.991147],[6.802355,45.70858],[7.096652,45.333099],[6.749955,45.028518],[7.007562,44.254767],[7.549596,44.127901],[7.435185,43.693845],[6.529245,43.128892],[4.556963,43.399651],[3.100411,43.075201],[2.985999,42.473015],[1.826793,42.343385],[0.701591,42.795734],[0.338047,42.579546],[-1.502771,43.034014],[-1.901351,43.422802],[-1.384225,44.02261],[-1.193798,46.014918],[-2.225724,47.064363],[-2.963276,47.570327],[-4.491555,47.954954],[-4.59235,48.68416],[-3.295814,48.901692],[-1.616511,48.644421],[-1.933494,49.776342],[-0.989469,49.347376],[1.338761,50.127173],[1.639001,50.946606],[2.513573,51.148506],[2.658422,50.796848],[3.123252,50.780363],[3.588184,50.378992],[4.286023,49.907497],[4.799222,49.985373],[5.674052,49.529484],[5.897759,49.442667],[6.18632,49.463803]]],[[[8.746009,42.628122],[9.390001,43.009985],[9.560016,42.152492],[9.229752,41.380007],[8.775723,41.583612],[8.544213,42.256517],[8.746009,42.628122]]]]}},{"type":"Feature","properties":{"name":"Germany","iso_a2":"DE","iso_a3":"DEU"},"geometry":{"type":"Polygon","coordinates":[[[14.119686,53.757029],[14.353315,53.248171],[14.074521,52.981263],[14.4376,52.62485],[14.685026,52.089947],[14.607098,51.745188],[15.016996,51.106674],[14.570718,51.002339],[14.307013,51.117268],[14.056228,50.926918],[13.338132,50.733234],[12.966837,50.484076],[12.240111,50.266338],[12.415191,49.969121],[12.521024,49.547415],[13.031329,49.307068],[13.595946,48.877172],[13.243357,48.416115],[12.884103,48.289146],[13.025851,47.637584],[12.932627,47.467646],[12.62076,47.672388],[12.141357,47.703083],[11.426414,47.523766],[10.544504,47.566399],[10.402084,47.302488],[9.896068,47.580197],[9.594226,47.525058],[8.522612,47.830828],[8.317301,47.61358],[7.466759,47.620582],[7.593676,48.333019],[8.099279,49.017784],[6.65823,49.201958],[6.18632,49.463803],[6.242751,49.902226],[6.043073,50.128052],[6.156658,50.803721],[5.988658,51.851616],[6.589397,51.852029],[6.84287,52.22844],[7.092053,53.144043],[6.90514,53.482162],[7.100425,53.693932],[7.936239,53.748296],[8.121706,53.527792],[8.800734,54.020786],[8.572118,54.395646],[8.526229,54.962744],[9.282049,54.830865],[9.921906,54.983104],[9.93958,54.596642],[10.950112,54.363607],[10.939467,54.008693],[11.956252,54.196486],[12.51844,54.470371],[13.647467,54.075511],[14.119686,53.757029]]]}},{"type":"Feature","properties":{"name":"Greece","iso_a2":"GR","iso_a3":"GRC"},"geometry":{"type":"MultiPolygon","coordinates":[[[[26.290003,35.29999],[26.164998,35.004995],[24.724982,34.919988],[24.735007,35.084991],[23.514978,35.279992],[23.69998,35.705004],[24.246665,35.368022],[25.025015,35.424996],[25.769208,35.354018],[25.745023,35.179998],[26.290003,35.29999]]],[[[22.952377,41.337994],[23.692074,41.309081],[24.492645,41.583896],[25.197201,41.234486],[26.106138,41.328899],[26.117042,41.826905],[26.604196,41.562115],[26.294602,40.936261],[26.056942,40.824123],[25.447677,40.852545],[24.925848,40.947062],[23.714811,40.687129],[24.407999,40.124993],[23.899968,39.962006],[23.342999,39.960998],[22.813988,40.476005],[22.626299,40.256561],[22.849748,39.659311],[23.350027,39.190011],[22.973099,38.970903],[23.530016,38.510001],[24.025025,38.219993],[24.040011,37.655015],[23.115003,37.920011],[23.409972,37.409991],[22.774972,37.30501],[23.154225,36.422506],[22.490028,36.41],[21.670026,36.844986],[21.295011,37.644989],[21.120034,38.310323],[20.730032,38.769985],[20.217712,39.340235],[20.150016,39.624998],[20.615,40.110007],[20.674997,40.435],[20.99999,40.580004],[21.02004,40.842727],[21.674161,40.931275],[22.055378,41.149866],[22.597308,41.130487],[22.76177,41.3048],[22.952377,41.337994]]]]}},{"type":"Feature","properties":{"name":"Hungary","iso_a2":"HU","iso_a3":"HUN"},"geometry":{"type":"Polygon","coordinates":[[[22.085608,48.422264],[22.64082,48.15024],[22.710531,47.882194],[22.099768,47.672439],[21.626515,46.994238],[21.021952,46.316088],[20.220192,46.127469],[19.596045,46.17173],[18.829838,45.908878],[18.829825,45.908872],[18.456062,45.759481],[17.630066,45.951769],[16.882515,46.380632],[16.564808,46.503751],[16.370505,46.841327],[16.202298,46.852386],[16.534268,47.496171],[16.340584,47.712902],[16.903754,47.714866],[16.979667,48.123497],[17.488473,47.867466],[17.857133,47.758429],[18.696513,47.880954],[18.777025,48.081768],[19.174365,48.111379],[19.661364,48.266615],[19.769471,48.202691],[20.239054,48.327567],[20.473562,48.56285],[20.801294,48.623854],[21.872236,48.319971],[22.085608,48.422264]]]}},{"type":"Feature","properties":{"name":"Iceland","iso_a2":"IS","iso_a3":"ISL"},"geometry":{"type":"Polygon","coordinates":[[[-14.508695,66.455892],[-14.739637,65.808748],[-13.609732,65.126671],[-14.909834,64.364082],[-17.794438,63.678749],[-18.656246,63.496383],[-19.972755,63.643635],[-22.762972,63.960179],[-21.778484,64.402116],[-23.955044,64.89113],[-22.184403,65.084968],[-22.227423,65.378594],[-24.326184,65.611189],[-23.650515,66.262519],[-22.134922,66.410469],[-20.576284,65.732112],[-19.056842,66.276601],[-17.798624,65.993853],[-16.167819,66.526792],[-14.508695,66.455892]]]}},{"type":"Feature","properties":{"name":"Ireland","iso_a2":"IE","iso_a3":"IRL"},"geometry":{"type":"Polygon","coordinates":[[[-6.197885,53.867565],[-6.032985,53.153164],[-6.788857,52.260118],[-8.561617,51.669301],[-9.977086,51.820455],[-9.166283,52.864629],[-9.688525,53.881363],[-8.327987,54.664519],[-7.572168,55.131622],[-7.366031,54.595841],[-7.572168,54.059956],[-6.95373,54.073702],[-6.197885,53.867565]]]}},{"type":"Feature","properties":{"name":"Italy","iso_a2":"IT","iso_a3":"ITA"},"geometry":{"type":"MultiPolygon","coordinates":[[[[10.442701,46.893546],[11.048556,46.751359],[11.164828,46.941579],[12.153088,47.115393],[12.376485,46.767559],[13.806475,46.509306],[13.69811,46.016778],[13.93763,45.591016],[13.141606,45.736692],[12.328581,45.381778],[12.383875,44.885374],[12.261453,44.600482],[12.589237,44.091366],[13.526906,43.587727],[14.029821,42.761008],[15.14257,41.95514],[15.926191,41.961315],[16.169897,41.740295],[15.889346,41.541082],[16.785002,41.179606],[17.519169,40.877143],[18.376687,40.355625],[18.480247,40.168866],[18.293385,39.810774],[17.73838,40.277671],[16.869596,40.442235],[16.448743,39.795401],[17.17149,39.4247],[17.052841,38.902871],[16.635088,38.843572],[16.100961,37.985899],[15.684087,37.908849],[15.687963,38.214593],[15.891981,38.750942],[16.109332,38.964547],[15.718814,39.544072],[15.413613,40.048357],[14.998496,40.172949],[14.703268,40.60455],[14.060672,40.786348],[13.627985,41.188287],[12.888082,41.25309],[12.106683,41.704535],[11.191906,42.355425],[10.511948,42.931463],[10.200029,43.920007],[9.702488,44.036279],[8.888946,44.366336],[8.428561,44.231228],[7.850767,43.767148],[7.435185,43.693845],[7.549596,44.127901],[7.007562,44.254767],[6.749955,45.028518],[7.096652,45.333099],[6.802355,45.70858],[6.843593,45.991147],[7.273851,45.776948],[7.755992,45.82449],[8.31663,46.163642],[8.489952,46.005151],[8.966306,46.036932],[9.182882,46.440215],[9.922837,46.314899],[10.363378,46.483571],[10.442701,46.893546]]],[[[14.761249,38.143874],[15.520376,38.231155],[15.160243,37.444046],[15.309898,37.134219],[15.099988,36.619987],[14.335229,36.996631],[13.826733,37.104531],[12.431004,37.61295],[12.570944,38.126381],[13.741156,38.034966],[14.761249,38.143874]]],[[[8.709991,40.899984],[9.210012,41.209991],[9.809975,40.500009],[9.669519,39.177376],[9.214818,39.240473],[8.806936,38.906618],[8.428302,39.171847],[8.388253,40.378311],[8.159998,40.950007],[8.709991,40.899984]]]]}},{"type":"Feature","properties":{"name":"Kosovo","iso_a2":"XK","iso_a3":"-99"},"geometry":{"type":"Polygon","coordinates":[[[20.590247,41.855409],[20.52295,42.21787],[20.283755,42.32026],[20.0707,42.58863],[20.25758,42.81275],[20.49679,42.88469],[20.63508,43.21671],[20.81448,43.27205],[20.95651,43.13094],[21.143395,43.068685],[21.27421,42.90959],[21.43866,42.86255],[21.63302,42.67717],[21.77505,42.6827],[21.66292,42.43922],[21.54332,42.32025],[21.576636,42.245224],[21.3527,42.2068],[20.76216,42.05186],[20.71731,41.84711],[20.590247,41.855409]]]}},{"type":"Feature","properties":{"name":"Latvia","iso_a2":"LV","iso_a3":"LVA"},"geometry":{"type":"Polygon","coordinates":[[[27.288185,57.474528],[27.770016,57.244258],[27.855282,56.759326],[28.176709,56.16913],[27.10246,55.783314],[26.494331,55.615107],[25.533047,56.100297],[25.000934,56.164531],[24.860684,56.372528],[23.878264,56.273671],[22.201157,56.337802],[21.0558,56.031076],[21.090424,56.783873],[21.581866,57.411871],[22.524341,57.753374],[23.318453,57.006236],[24.12073,57.025693],[24.312863,57.793424],[25.164594,57.970157],[25.60281,57.847529],[26.463532,57.476389],[27.288185,57.474528]]]}},{"type":"Feature","properties":{"name":"Lithuania","iso_a2":"LT","iso_a3":"LTU"},"geometry":{"type":"Polygon","coordinates":[[[26.494331,55.615107],[26.588279,55.167176],[25.768433,54.846963],[25.536354,54.282423],[24.450684,53.905702],[23.484128,53.912498],[23.243987,54.220567],[22.731099,54.327537],[22.651052,54.582741],[22.757764,54.856574],[22.315724,55.015299],[21.268449,55.190482],[21.0558,56.031076],[22.201157,56.337802],[23.878264,56.273671],[24.860684,56.372528],[25.000934,56.164531],[25.533047,56.100297],[26.494331,55.615107]]]}},{"type":"Feature","properties":{"name":"Luxembourg","iso_a2":"LU","iso_a3":"LUX"},"geometry":{"type":"Polygon","coordinates":[[[6.043073,50.128052],[6.242751,49.902226],[6.18632,49.463803],[5.897759,49.442667],[5.674052,49.529484],[5.782417,50.090328],[6.043073,50.128052]]]}},{"type":"Feature","properties":{"name":"Moldova","iso_a2":"MD","iso_a3":"MDA"},"geometry":{"type":"Polygon","coordinates":[[[26.619337,48.220726],[26.857824,48.368211],[27.522537,48.467119],[28.259547,48.155562],[28.670891,48.118149],[29.122698,47.849095],[29.050868,47.510227],[29.415135,47.346645],[29.559674,46.928583],[29.908852,46.674361],[29.83821,46.525326],[30.024659,46.423937],[29.759972,46.349988],[29.170654,46.379262],[29.072107,46.517678],[28.862972,46.437889],[28.933717,46.25883],[28.659987,45.939987],[28.485269,45.596907],[28.233554,45.488283],[28.054443,45.944586],[28.160018,46.371563],[28.12803,46.810476],[27.551166,47.405117],[27.233873,47.826771],[26.924176,48.123264],[26.619337,48.220726]]]}},{"type":"Feature","properties":{"name":"Montenegro","iso_a2":"ME","iso_a3":"MNE"},"geometry":{"type":"Polygon","coordinates":[[[20.0707,42.58863],[19.801613,42.500093],[19.738051,42.688247],[19.304486,42.195745],[19.371768,41.877551],[19.16246,41.95502],[18.88214,42.28151],[18.450017,42.479992],[18.56,42.65],[18.70648,43.20011],[19.03165,43.43253],[19.21852,43.52384],[19.48389,43.35229],[19.63,43.21378],[19.95857,43.10604],[20.3398,42.89852],[20.25758,42.81275],[20.0707,42.58863]]]}},{"type":"Feature","properties":{"name":"Netherlands","iso_a2":"NL","iso_a3":"NLD"},"geometry":{"type":"Polygon","coordinates":[[[6.90514,53.482162],[7.092053,53.144043],[6.84287,52.22844],[6.589397,51.852029],[5.988658,51.851616],[6.156658,50.803721],[5.606976,51.037298],[4.973991,51.475024],[4.047071,51.267259],[3.314971,51.345755],[3.315011,51.345777],[3.830289,51.620545],[4.705997,53.091798],[6.074183,53.510403],[6.90514,53.482162]]]}},{"type":"Feature","properties":{"name":"North Macedonia","iso_a2":"MK","iso_a3":"MKD"},"geometry":{"type":"Polygon","coordinates":[[[22.380526,42.32026],[22.881374,41.999297],[22.952377,41.337994],[22.76177,41.3048],[22.597308,41.130487],[22.055378,41.149866],[21.674161,40.931275],[21.02004,40.842727],[20.605182,41.086226],[20.463175,41.515089],[20.590247,41.855404],[20.590247,41.855409],[20.71731,41.84711],[20.76216,42.05186],[21.3527,42.2068],[21.576636,42.245224],[21.91708,42.30364],[22.380526,42.32026]]]}},{"type":"Feature","properties":{"name":"Norway","iso_a2":"NO","iso_a3":"-99"},"geometry":{"type":"MultiPolygon","coordinates":[[[[15.14282,79.67431],[15.52255,80.01608],[16.99085,80.05086],[18.25183,79.70175],[21.54383,78.95611],[19.02737,78.5626],[18.47172,77.82669],[17.59441,77.63796],[17.1182,76.80941],[15.91315,76.77045],[13.76259,77.38035],[14.66956,77.73565],[13.1706,78.02493],[11.22231,78.8693],[10.44453,79.65239],[13.17077,80.01046],[13.71852,79.66039],[15.14282,79.67431]]],[[[31.101042,69.558101],[29.39955,69.15692],[28.59193,69.064777],[29.015573,69.766491],[27.732292,70.164193],[26.179622,69.825299],[25.689213,69.092114],[24.735679,68.649557],[23.66205,68.891247],[22.356238,68.841741],[21.244936,69.370443],[20.645593,69.106247],[20.025269,69.065139],[19.87856,68.407194],[17.993868,68.567391],[17.729182,68.010552],[16.768879,68.013937],[16.108712,67.302456],[15.108411,66.193867],[13.55569,64.787028],[13.919905,64.445421],[13.571916,64.049114],[12.579935,64.066219],[11.930569,63.128318],[11.992064,61.800362],[12.631147,61.293572],[12.300366,60.117933],[11.468272,59.432393],[11.027369,58.856149],[10.356557,59.469807],[8.382,58.313288],[7.048748,58.078884],[5.665835,58.588155],[5.308234,59.663232],[4.992078,61.970998],[5.9129,62.614473],[8.553411,63.454008],[10.527709,64.486038],[12.358347,65.879726],[14.761146,67.810642],[16.435927,68.563205],[19.184028,69.817444],[21.378416,70.255169],[23.023742,70.202072],[24.546543,71.030497],[26.37005,70.986262],[28.165547,71.185474],[31.293418,70.453788],[30.005435,70.186259],[31.101042,69.558101]]],[[[27.407506,80.056406],[25.924651,79.517834],[23.024466,79.400012],[20.075188,79.566823],[19.897266,79.842362],[18.462264,79.85988],[17.368015,80.318896],[20.455992,80.598156],[21.907945,80.357679],[22.919253,80.657144],[25.447625,80.40734],[27.407506,80.056406]]],[[[24.72412,77.85385],[22.49032,77.44493],[20.72601,77.67704],[21.41611,77.93504],[20.8119,78.25463],[22.88426,78.45494],[23.28134,78.07954],[24.72412,77.85385]]]]}},{"type":"Feature","properties":{"name":"Poland","iso_a2":"PL","iso_a3":"POL"},"geometry":{"type":"Polygon","coordinates":[[[23.484128,53.912498],[23.527536,53.470122],[23.804935,53.089731],[23.799199,52.691099],[23.199494,52.486977],[23.508002,52.023647],[23.527071,51.578454],[24.029986,50.705407],[23.922757,50.424881],[23.426508,50.308506],[22.51845,49.476774],[22.776419,49.027395],[22.558138,49.085738],[21.607808,49.470107],[20.887955,49.328772],[20.415839,49.431453],[19.825023,49.217125],[19.320713,49.571574],[18.909575,49.435846],[18.853144,49.49623],[18.392914,49.988629],[17.649445,50.049038],[17.554567,50.362146],[16.868769,50.473974],[16.719476,50.215747],[16.176253,50.422607],[16.238627,50.697733],[15.490972,50.78473],[15.016996,51.106674],[14.607098,51.745188],[14.685026,52.089947],[14.4376,52.62485],[14.074521,52.981263],[14.353315,53.248171],[14.119686,53.757029],[14.8029,54.050706],[16.363477,54.513159],[17.622832,54.851536],[18.620859,54.682606],[18.696255,54.438719],[19.66064,54.426084],[20.892245,54.312525],[22.731099,54.327537],[23.243987,54.220567],[23.484128,53.912498]]]}},{"type":"Feature","properties":{"name":"Portugal","iso_a2":"PT","iso_a3":"PRT"},"geometry":{"type":"Polygon","coordinates":[[[-9.034818,41.880571],[-8.671946,42.134689],[-8.263857,42.280469],[-8.013175,41.790886],[-7.422513,41.792075],[-7.251309,41.918346],[-6.668606,41.883387],[-6.389088,41.381815],[-6.851127,41.111083],[-6.86402,40.330872],[-7.026413,40.184524],[-7.066592,39.711892],[-7.498632,39.629571],[-7.098037,39.030073],[-7.374092,38.373059],[-7.029281,38.075764],[-7.166508,37.803894],[-7.537105,37.428904],[-7.453726,37.097788],[-7.855613,36.838269],[-8.382816,36.97888],[-8.898857,36.868809],[-8.746101,37.651346],[-8.839998,38.266243],[-9.287464,38.358486],[-9.526571,38.737429],[-9.446989,39.392066],[-9.048305,39.755093],[-8.977353,40.159306],[-8.768684,40.760639],[-8.790853,41.184334],[-8.990789,41.543459],[-9.034818,41.880571]]]}},{"type":"Feature","properties":{"name":"Romania","iso_a2":"RO","iso_a3":"ROU"},"geometry":{"type":"Polygon","coordinates":[[[28.233554,45.488283],[28.679779,45.304031],[29.149725,45.464925],[29.603289,45.293308],[29.626543,45.035391],[29.141612,44.82021],[28.837858,44.913874],[28.558081,43.707462],[27.970107,43.812468],[27.2424,44.175986],[26.065159,43.943494],[25.569272,43.688445],[24.100679,43.741051],[23.332302,43.897011],[22.944832,43.823785],[22.65715,44.234923],[22.474008,44.409228],[22.705726,44.578003],[22.459022,44.702517],[22.145088,44.478422],[21.562023,44.768947],[21.483526,45.18117],[20.874313,45.416375],[20.762175,45.734573],[20.220192,46.127469],[21.021952,46.316088],[21.626515,46.994238],[22.099768,47.672439],[22.710531,47.882194],[23.142236,48.096341],[23.760958,47.985598],[24.402056,47.981878],[24.866317,47.737526],[25.207743,47.891056],[25.945941,47.987149],[26.19745,48.220881],[26.619337,48.220726],[26.924176,48.123264],[27.233873,47.826771],[27.551166,47.405117],[28.12803,46.810476],[28.160018,46.371563],[28.054443,45.944586],[28.233554,45.488283]]]}},{"type":"Feature","properties":{"name":"Russia","iso_a2":"RU","iso_a3":"RUS"},"geometry":{"type":"MultiPolygon","coordinates":[[[[178.7253,71.0988],[180,71.515714],[180,70.832199],[178.903425,70.78114],[178.7253,71.0988]]],[[[49.10116,46.39933],[48.64541,45.80629],[47.67591,45.64149],[46.68201,44.6092],[47.59094,43.66016],[47.49252,42.98658],[48.58437,41.80888],[48.584353,41.808869],[47.987283,41.405819],[47.815666,41.151416],[47.373315,41.219732],[46.686071,41.827137],[46.404951,41.860675],[45.7764,42.09244],[45.470279,42.502781],[44.537623,42.711993],[43.93121,42.55496],[43.75599,42.74083],[42.3944,43.2203],[40.92219,43.38215],[40.076965,43.553104],[39.955009,43.434998],[38.68,44.28],[37.53912,44.65721],[36.67546,45.24469],[37.40317,45.40451],[38.23295,46.24087],[37.67372,46.63657],[39.14767,47.04475],[39.1212,47.26336],[38.223538,47.10219],[38.255112,47.5464],[38.77057,47.82562],[39.738278,47.898937],[39.89562,48.23241],[39.67465,48.78382],[40.080789,49.30743],[40.06904,49.60105],[38.594988,49.926462],[38.010631,49.915662],[37.39346,50.383953],[36.626168,50.225591],[35.356116,50.577197],[35.37791,50.77394],[35.022183,51.207572],[34.224816,51.255993],[34.141978,51.566413],[34.391731,51.768882],[33.7527,52.335075],[32.715761,52.238465],[32.412058,52.288695],[32.15944,52.06125],[31.785992,52.101678],[31.78597,52.10168],[31.540018,52.742052],[31.305201,53.073996],[31.49764,53.16743],[32.304519,53.132726],[32.693643,53.351421],[32.405599,53.618045],[31.731273,53.794029],[31.791424,53.974639],[31.384472,54.157056],[30.757534,54.811771],[30.971836,55.081548],[30.873909,55.550976],[29.896294,55.789463],[29.371572,55.670091],[29.229513,55.918344],[28.176709,56.16913],[27.855282,56.759326],[27.770016,57.244258],[27.288185,57.474528],[27.716686,57.791899],[27.42015,58.72457],[28.131699,59.300825],[27.98112,59.47537],[27.981127,59.475373],[29.1177,60.02805],[28.070002,60.503519],[28.07,60.50352],[30.211107,61.780028],[31.139991,62.357693],[31.516092,62.867687],[30.035872,63.552814],[30.444685,64.204453],[29.54443,64.948672],[30.21765,65.80598],[29.054589,66.944286],[29.977426,67.698297],[28.445944,68.364613],[28.59193,69.064777],[29.39955,69.15692],[31.101042,69.558101],[31.10108,69.55811],[32.13272,69.90595],[33.77547,69.30142],[36.51396,69.06342],[40.29234,67.9324],[41.05987,67.45713],[41.12595,66.79158],[40.01583,66.26618],[38.38295,65.99953],[33.91871,66.75961],[33.18444,66.63253],[34.81477,65.90015],[34.878574,65.436213],[34.94391,64.41437],[36.23129,64.10945],[37.01273,63.84983],[37.14197,64.33471],[36.539579,64.76446],[37.17604,65.14322],[39.59345,64.52079],[40.4356,64.76446],[39.7626,65.49682],[42.09309,66.47623],[43.01604,66.41858],[43.94975,66.06908],[44.53226,66.75634],[43.69839,67.35245],[44.18795,67.95051],[43.45282,68.57079],[46.25,68.25],[46.82134,67.68997],[45.55517,67.56652],[45.56202,67.01005],[46.34915,66.66767],[47.89416,66.88455],[48.13876,67.52238],[50.22766,67.99867],[53.71743,68.85738],[54.47171,68.80815],[53.48582,68.20131],[54.72628,68.09702],[55.44268,68.43866],[57.31702,68.46628],[58.802,68.88082],[59.94142,68.27844],[61.07784,68.94069],[60.03,69.52],[60.55,69.85],[63.504,69.54739],[64.888115,69.234835],[68.51216,68.09233],[69.18068,68.61563],[68.16444,69.14436],[68.13522,69.35649],[66.93008,69.45461],[67.25976,69.92873],[66.72492,70.70889],[66.69466,71.02897],[68.54006,71.9345],[69.19636,72.84336],[69.94,73.04],[72.58754,72.77629],[72.79603,72.22006],[71.84811,71.40898],[72.47011,71.09019],[72.79188,70.39114],[72.5647,69.02085],[73.66787,68.4079],[73.2387,67.7404],[71.28,66.32],[72.42301,66.17267],[72.82077,66.53267],[73.92099,66.78946],[74.18651,67.28429],[75.052,67.76047],[74.46926,68.32899],[74.93584,68.98918],[73.84236,69.07146],[73.60187,69.62763],[74.3998,70.63175],[73.1011,71.44717],[74.89082,72.12119],[74.65926,72.83227],[75.15801,72.85497],[75.68351,72.30056],[75.28898,71.33556],[76.35911,71.15287],[75.90313,71.87401],[77.57665,72.26717],[79.65202,72.32011],[81.5,71.75],[80.61071,72.58285],[80.51109,73.6482],[82.25,73.85],[84.65526,73.80591],[86.8223,73.93688],[86.00956,74.45967],[87.16682,75.11643],[88.31571,75.14393],[90.26,75.64],[92.90058,75.77333],[93.23421,76.0472],[95.86,76.14],[96.67821,75.91548],[98.92254,76.44689],[100.75967,76.43028],[101.03532,76.86189],[101.99084,77.28754],[104.3516,77.69792],[106.06664,77.37389],[104.705,77.1274],[106.97013,76.97419],[107.24,76.48],[108.1538,76.72335],[111.07726,76.71],[113.33151,76.22224],[114.13417,75.84764],[113.88539,75.32779],[112.77918,75.03186],[110.15125,74.47673],[109.4,74.18],[110.64,74.04],[112.11919,73.78774],[113.01954,73.97693],[113.52958,73.33505],[113.96881,73.59488],[115.56782,73.75285],[118.77633,73.58772],[119.02,73.12],[123.20066,72.97122],[123.25777,73.73503],[125.38,73.56],[126.97644,73.56549],[128.59126,73.03871],[129.05157,72.39872],[128.46,71.98],[129.71599,71.19304],[131.28858,70.78699],[132.2535,71.8363],[133.85766,71.38642],[135.56193,71.65525],[137.49755,71.34763],[138.23409,71.62803],[139.86983,71.48783],[139.14791,72.41619],[140.46817,72.84941],[149.5,72.2],[150.35118,71.60643],[152.9689,70.84222],[157.00688,71.03141],[158.99779,70.86672],[159.83031,70.45324],[159.70866,69.72198],[160.94053,69.43728],[162.27907,69.64204],[164.05248,69.66823],[165.94037,69.47199],[167.83567,69.58269],[169.57763,68.6938],[170.81688,69.01363],[170.0082,69.65276],[170.45345,70.09703],[173.64391,69.81743],[175.72403,69.87725],[178.6,69.4],[180,68.963636],[180,64.979709],[179.99281,64.97433],[178.7072,64.53493],[177.41128,64.60821],[178.313,64.07593],[178.90825,63.25197],[179.37034,62.98262],[179.48636,62.56894],[179.22825,62.3041],[177.3643,62.5219],[174.56929,61.76915],[173.68013,61.65261],[172.15,60.95],[170.6985,60.33618],[170.33085,59.88177],[168.90046,60.57355],[166.29498,59.78855],[165.84,60.16],[164.87674,59.7316],[163.53929,59.86871],[163.21711,59.21101],[162.01733,58.24328],[162.05297,57.83912],[163.19191,57.61503],[163.05794,56.15924],[162.12958,56.12219],[161.70146,55.28568],[162.11749,54.85514],[160.36877,54.34433],[160.02173,53.20257],[158.53094,52.95868],[158.23118,51.94269],[156.78979,51.01105],[156.42,51.7],[155.99182,53.15895],[155.43366,55.38103],[155.91442,56.76792],[156.75815,57.3647],[156.81035,57.83204],[158.36433,58.05575],[160.15064,59.31477],[161.87204,60.343],[163.66969,61.1409],[164.47355,62.55061],[163.25842,62.46627],[162.65791,61.6425],[160.12148,60.54423],[159.30232,61.77396],[156.72068,61.43442],[154.21806,59.75818],[155.04375,59.14495],[152.81185,58.88385],[151.26573,58.78089],[151.33815,59.50396],[149.78371,59.65573],[148.54481,59.16448],[145.48722,59.33637],[142.19782,59.03998],[138.95848,57.08805],[135.12619,54.72959],[136.70171,54.60355],[137.19342,53.97732],[138.1647,53.75501],[138.80463,54.25455],[139.90151,54.18968],[141.34531,53.08957],[141.37923,52.23877],[140.59742,51.23967],[140.51308,50.04553],[140.06193,48.44671],[138.55472,46.99965],[138.21971,46.30795],[136.86232,45.1435],[135.51535,43.989],[134.86939,43.39821],[133.53687,42.81147],[132.90627,42.79849],[132.27807,43.28456],[130.93587,42.55274],[130.780005,42.22001],[130.780004,42.220008],[130.78,42.22],[130.779992,42.22001],[130.64,42.395],[130.64,42.395024],[130.633866,42.903015],[131.144688,42.92999],[131.288555,44.11152],[131.02519,44.96796],[131.883454,45.321162],[133.09712,45.14409],[133.769644,46.116927],[134.11235,47.21248],[134.50081,47.57845],[135.026311,48.47823],[133.373596,48.183442],[132.50669,47.78896],[130.98726,47.79013],[130.582293,48.729687],[129.397818,49.4406],[127.6574,49.76027],[127.287456,50.739797],[126.939157,51.353894],[126.564399,51.784255],[125.946349,52.792799],[125.068211,53.161045],[123.57147,53.4588],[122.245748,53.431726],[121.003085,53.251401],[120.177089,52.753886],[120.725789,52.516226],[120.7382,51.96411],[120.18208,51.64355],[119.27939,50.58292],[119.288461,50.142883],[117.879244,49.510983],[116.678801,49.888531],[115.485695,49.805177],[114.96211,50.140247],[114.362456,50.248303],[112.89774,49.543565],[111.581231,49.377968],[110.662011,49.130128],[109.402449,49.292961],[108.475167,49.282548],[107.868176,49.793705],[106.888804,50.274296],[105.886591,50.406019],[104.62158,50.27532],[103.676545,50.089966],[102.25589,50.51056],[102.06521,51.25991],[100.88948,51.516856],[99.981732,51.634006],[98.861491,52.047366],[97.82574,51.010995],[98.231762,50.422401],[97.25976,49.72605],[95.81402,49.97746],[94.815949,50.013433],[94.147566,50.480537],[93.10421,50.49529],[92.234712,50.802171],[90.713667,50.331812],[88.805567,49.470521],[87.751264,49.297198],[87.35997,49.214981],[86.829357,49.826675],[85.54127,49.692859],[85.11556,50.117303],[84.416377,50.3114],[83.935115,50.889246],[83.383004,51.069183],[81.945986,50.812196],[80.568447,51.388336],[80.03556,50.864751],[77.800916,53.404415],[76.525179,54.177003],[76.8911,54.490524],[74.38482,53.54685],[73.425679,53.48981],[73.508516,54.035617],[72.22415,54.376655],[71.180131,54.133285],[70.865267,55.169734],[69.068167,55.38525],[68.1691,54.970392],[65.66687,54.60125],[65.178534,54.354228],[61.4366,54.00625],[60.978066,53.664993],[61.699986,52.979996],[60.739993,52.719986],[60.927269,52.447548],[59.967534,51.96042],[61.588003,51.272659],[61.337424,50.79907],[59.932807,50.842194],[59.642282,50.545442],[58.36332,51.06364],[56.77798,51.04355],[55.71694,50.62171],[54.532878,51.02624],[52.328724,51.718652],[50.766648,51.692762],[48.702382,50.605128],[48.577841,49.87476],[47.54948,50.454698],[46.751596,49.356006],[47.043672,49.152039],[46.466446,48.394152],[47.31524,47.71585],[48.05725,47.74377],[48.694734,47.075628],[48.59325,46.56104],[49.10116,46.39933]]],[[[93.77766,81.0246],[95.940895,81.2504],[97.88385,80.746975],[100.186655,79.780135],[99.93976,78.88094],[97.75794,78.7562],[94.97259,79.044745],[93.31288,79.4265],[92.5454,80.14379],[91.18107,80.34146],[93.77766,81.0246]]],[[[102.837815,79.28129],[105.37243,78.71334],[105.07547,78.30689],[99.43814,77.921],[101.2649,79.23399],[102.08635,79.34641],[102.837815,79.28129]]],[[[138.831075,76.13676],[141.471615,76.09289],[145.086285,75.562625],[144.3,74.82],[140.61381,74.84768],[138.95544,74.61148],[136.97439,75.26167],[137.51176,75.94917],[138.831075,76.13676]]],[[[148.22223,75.345845],[150.73167,75.08406],[149.575925,74.68892],[147.977465,74.778355],[146.11919,75.17298],[146.358485,75.49682],[148.22223,75.345845]]],[[[139.86312,73.36983],[140.81171,73.76506],[142.06207,73.85758],[143.48283,73.47525],[143.60385,73.21244],[142.08763,73.20544],[140.038155,73.31692],[139.86312,73.36983]]],[[[44.846958,80.58981],[46.799139,80.771918],[48.318477,80.78401],[48.522806,80.514569],[49.09719,80.753986],[50.039768,80.918885],[51.522933,80.699726],[51.136187,80.54728],[49.793685,80.415428],[48.894411,80.339567],[48.754937,80.175468],[47.586119,80.010181],[46.502826,80.247247],[47.072455,80.559424],[44.846958,80.58981]]],[[[22.731099,54.327537],[20.892245,54.312525],[19.66064,54.426084],[19.888481,54.86616],[21.268449,55.190482],[22.315724,55.015299],[22.757764,54.856574],[22.651052,54.582741],[22.731099,54.327537]]],[[[53.50829,73.749814],[55.902459,74.627486],[55.631933,75.081412],[57.868644,75.60939],[61.170044,76.251883],[64.498368,76.439055],[66.210977,76.809782],[68.15706,76.939697],[68.852211,76.544811],[68.180573,76.233642],[64.637326,75.737755],[61.583508,75.260885],[58.477082,74.309056],[56.986786,73.333044],[55.419336,72.371268],[55.622838,71.540595],[57.535693,70.720464],[56.944979,70.632743],[53.677375,70.762658],[53.412017,71.206662],[51.601895,71.474759],[51.455754,72.014881],[52.478275,72.229442],[52.444169,72.774731],[54.427614,73.627548],[53.50829,73.749814]]],[[[142.914616,53.704578],[143.260848,52.74076],[143.235268,51.75666],[143.648007,50.7476],[144.654148,48.976391],[143.173928,49.306551],[142.558668,47.861575],[143.533492,46.836728],[143.505277,46.137908],[142.747701,46.740765],[142.09203,45.966755],[141.906925,46.805929],[142.018443,47.780133],[141.904445,48.859189],[142.1358,49.615163],[142.179983,50.952342],[141.594076,51.935435],[141.682546,53.301966],[142.606934,53.762145],[142.209749,54.225476],[142.654786,54.365881],[142.914616,53.704578]]],[[[-174.92825,67.20589],[-175.01425,66.58435],[-174.33983,66.33556],[-174.57182,67.06219],[-171.85731,66.91308],[-169.89958,65.97724],[-170.89107,65.54139],[-172.53025,65.43791],[-172.555,64.46079],[-172.95533,64.25269],[-173.89184,64.2826],[-174.65392,64.63125],[-175.98353,64.92288],[-176.20716,65.35667],[-177.22266,65.52024],[-178.35993,65.39052],[-178.90332,65.74044],[-178.68611,66.11211],[-179.88377,65.87456],[-179.43268,65.40411],[-180,64.979709],[-180,68.963636],[-177.55,68.2],[-174.92825,67.20589]]],[[[-178.69378,70.89302],[-180,70.832199],[-180,71.515714],[-179.871875,71.55762],[-179.02433,71.55553],[-177.577945,71.26948],[-177.663575,71.13277],[-178.69378,70.89302]]],[[[33.435988,45.971917],[33.699462,46.219573],[34.410402,46.005162],[34.732017,45.965666],[34.861792,45.768182],[35.012659,45.737725],[35.020788,45.651219],[35.510009,45.409993],[36.529998,45.46999],[36.334713,45.113216],[35.239999,44.939996],[33.882511,44.361479],[33.326421,44.564877],[33.546924,45.034771],[32.454174,45.327466],[32.630804,45.519186],[33.588162,45.851569],[33.435988,45.971917]]]]}},{"type":"Feature","properties":{"name":"Serbia","iso_a2":"RS","iso_a3":"SRB"},"geometry":{"type":"Polygon","coordinates":[[[18.829825,45.908872],[18.829838,45.908878],[19.596045,46.17173],[20.220192,46.127469],[20.762175,45.734573],[20.874313,45.416375],[21.483526,45.18117],[21.562023,44.768947],[22.145088,44.478422],[22.459022,44.702517],[22.705726,44.578003],[22.474008,44.409228],[22.65715,44.234923],[22.410446,44.008063],[22.500157,43.642814],[22.986019,43.211161],[22.604801,42.898519],[22.436595,42.580321],[22.545012,42.461362],[22.380526,42.32026],[21.91708,42.30364],[21.576636,42.245224],[21.54332,42.32025],[21.66292,42.43922],[21.77505,42.6827],[21.63302,42.67717],[21.43866,42.86255],[21.27421,42.90959],[21.143395,43.068685],[20.95651,43.13094],[20.81448,43.27205],[20.63508,43.21671],[20.49679,42.88469],[20.25758,42.81275],[20.3398,42.89852],[19.95857,43.10604],[19.63,43.21378],[19.48389,43.35229],[19.21852,43.52384],[19.454,43.5681],[19.59976,44.03847],[19.11761,44.42307],[19.36803,44.863],[19.00548,44.86023],[19.005485,44.860234],[19.390476,45.236516],[19.072769,45.521511],[18.829825,45.908872]]]}},{"type":"Feature","properties":{"name":"Slovakia","iso_a2":"SK","iso_a3":"SVK"},"geometry":{"type":"Polygon","coordinates":[[[22.558138,49.085738],[22.280842,48.825392],[22.085608,48.422264],[21.872236,48.319971],[20.801294,48.623854],[20.473562,48.56285],[20.239054,48.327567],[19.769471,48.202691],[19.661364,48.266615],[19.174365,48.111379],[18.777025,48.081768],[18.696513,47.880954],[17.857133,47.758429],[17.488473,47.867466],[16.979667,48.123497],[16.879983,48.470013],[16.960288,48.596982],[17.101985,48.816969],[17.545007,48.800019],[17.886485,48.903475],[17.913512,48.996493],[18.104973,49.043983],[18.170498,49.271515],[18.399994,49.315001],[18.554971,49.495015],[18.853144,49.49623],[18.909575,49.435846],[19.320713,49.571574],[19.825023,49.217125],[20.415839,49.431453],[20.887955,49.328772],[21.607808,49.470107],[22.558138,49.085738]]]}},{"type":"Feature","properties":{"name":"Slovenia","iso_a2":"SI","iso_a3":"SVN"},"geometry":{"type":"Polygon","coordinates":[[[13.806475,46.509306],[14.632472,46.431817],[15.137092,46.658703],[16.011664,46.683611],[16.202298,46.852386],[16.370505,46.841327],[16.564808,46.503751],[15.768733,46.238108],[15.67153,45.834154],[15.323954,45.731783],[15.327675,45.452316],[14.935244,45.471695],[14.595109,45.634941],[14.411968,45.466166],[13.71506,45.500324],[13.93763,45.591016],[13.69811,46.016778],[13.806475,46.509306]]]}},{"type":"Feature","properties":{"name":"Spain","iso_a2":"ES","iso_a3":"ESP"},"geometry":{"type":"Polygon","coordinates":[[[-7.453726,37.097788],[-7.537105,37.428904],[-7.166508,37.803894],[-7.029281,38.075764],[-7.374092,38.373059],[-7.098037,39.030073],[-7.498632,39.629571],[-7.066592,39.711892],[-7.026413,40.184524],[-6.86402,40.330872],[-6.851127,41.111083],[-6.389088,41.381815],[-6.668606,41.883387],[-7.251309,41.918346],[-7.422513,41.792075],[-8.013175,41.790886],[-8.263857,42.280469],[-8.671946,42.134689],[-9.034818,41.880571],[-8.984433,42.592775],[-9.392884,43.026625],[-7.97819,43.748338],[-6.754492,43.567909],[-5.411886,43.57424],[-4.347843,43.403449],[-3.517532,43.455901],[-1.901351,43.422802],[-1.502771,43.034014],[0.338047,42.579546],[0.701591,42.795734],[1.826793,42.343385],[2.985999,42.473015],[3.039484,41.89212],[2.091842,41.226089],[0.810525,41.014732],[0.721331,40.678318],[0.106692,40.123934],[-0.278711,39.309978],[0.111291,38.738514],[-0.467124,38.292366],[-0.683389,37.642354],[-1.438382,37.443064],[-2.146453,36.674144],[-3.415781,36.6589],[-4.368901,36.677839],[-4.995219,36.324708],[-5.37716,35.94685],[-5.866432,36.029817],[-6.236694,36.367677],[-6.520191,36.942913],[-7.453726,37.097788]]]}},{"type":"Feature","properties":{"name":"Sweden","iso_a2":"SE","iso_a3":"SWE"},"geometry":{"type":"Polygon","coordinates":[[[11.027369,58.856149],[11.468272,59.432393],[12.300366,60.117933],[12.631147,61.293572],[11.992064,61.800362],[11.930569,63.128318],[12.579935,64.066219],[13.571916,64.049114],[13.919905,64.445421],[13.55569,64.787028],[15.108411,66.193867],[16.108712,67.302456],[16.768879,68.013937],[17.729182,68.010552],[17.993868,68.567391],[19.87856,68.407194],[20.025269,69.065139],[20.645593,69.106247],[21.978535,68.616846],[23.539473,67.936009],[23.56588,66.396051],[23.903379,66.006927],[22.183173,65.723741],[21.213517,65.026005],[21.369631,64.413588],[19.778876,63.609554],[17.847779,62.7494],[17.119555,61.341166],[17.831346,60.636583],[18.787722,60.081914],[17.869225,58.953766],[16.829185,58.719827],[16.44771,57.041118],[15.879786,56.104302],[14.666681,56.200885],[14.100721,55.407781],[12.942911,55.361737],[12.625101,56.30708],[11.787942,57.441817],[11.027369,58.856149]]]}},{"type":"Feature","properties":{"name":"Switzerland","iso_a2":"CH","iso_a3":"CHE"},"geometry":{"type":"Polygon","coordinates":[[[9.594226,47.525058],[9.632932,47.347601],[9.47997,47.10281],[9.932448,46.920728],[10.442701,46.893546],[10.363378,46.483571],[9.922837,46.314899],[9.182882,46.440215],[8.966306,46.036932],[8.489952,46.005151],[8.31663,46.163642],[7.755992,45.82449],[7.273851,45.776948],[6.843593,45.991147],[6.5001,46.429673],[6.022609,46.27299],[6.037389,46.725779],[6.768714,47.287708],[6.736571,47.541801],[7.192202,47.449766],[7.466759,47.620582],[8.317301,47.61358],[8.522612,47.830828],[9.594226,47.525058]]]}},{"type":"Feature","properties":{"name":"Ukraine","iso_a2":"UA","iso_a3":"UKR"},"geometry":{"type":"Polygon","coordinates":[[[31.785992,52.101678],[32.15944,52.06125],[32.412058,52.288695],[32.715761,52.238465],[33.7527,52.335075],[34.391731,51.768882],[34.141978,51.566413],[34.224816,51.255993],[35.022183,51.207572],[35.37791,50.77394],[35.356116,50.577197],[36.626168,50.225591],[37.39346,50.383953],[38.010631,49.915662],[38.594988,49.926462],[40.06904,49.60105],[40.080789,49.30743],[39.67465,48.78382],[39.89562,48.23241],[39.738278,47.898937],[38.77057,47.82562],[38.255112,47.5464],[38.223538,47.10219],[37.425137,47.022221],[36.759855,46.6987],[35.823685,46.645964],[34.962342,46.273197],[35.012659,45.737725],[34.861792,45.768182],[34.732017,45.965666],[34.410402,46.005162],[33.699462,46.219573],[33.435988,45.971917],[33.298567,46.080598],[31.74414,46.333348],[31.675307,46.706245],[30.748749,46.5831],[30.377609,46.03241],[29.603289,45.293308],[29.149725,45.464925],[28.679779,45.304031],[28.233554,45.488283],[28.485269,45.596907],[28.659987,45.939987],[28.933717,46.25883],[28.862972,46.437889],[29.072107,46.517678],[29.170654,46.379262],[29.759972,46.349988],[30.024659,46.423937],[29.83821,46.525326],[29.908852,46.674361],[29.559674,46.928583],[29.415135,47.346645],[29.050868,47.510227],[29.122698,47.849095],[28.670891,48.118149],[28.259547,48.155562],[27.522537,48.467119],[26.857824,48.368211],[26.619337,48.220726],[26.19745,48.220881],[25.945941,47.987149],[25.207743,47.891056],[24.866317,47.737526],[24.402056,47.981878],[23.760958,47.985598],[23.142236,48.096341],[22.710531,47.882194],[22.64082,48.15024],[22.085608,48.422264],[22.280842,48.825392],[22.558138,49.085738],[22.776419,49.027395],[22.51845,49.476774],[23.426508,50.308506],[23.922757,50.424881],[24.029986,50.705407],[23.527071,51.578454],[24.005078,51.617444],[24.553106,51.888461],[25.327788,51.910656],[26.337959,51.832289],[27.454066,51.592303],[28.241615,51.572227],[28.617613,51.427714],[28.992835,51.602044],[29.254938,51.368234],[30.157364,51.416138],[30.555117,51.319503],[30.619454,51.822806],[30.927549,52.042353],[31.785992,52.101678]]]}},{"type":"Feature","properties":{"name":"United Kingdom","iso_a2":"GB","iso_a3":"GBR"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-6.197885,53.867565],[-6.95373,54.073702],[-7.572168,54.059956],[-7.366031,54.595841],[-7.572168,55.131622],[-6.733847,55.17286],[-5.661949,54.554603],[-6.197885,53.867565]]],[[[-3.093831,53.404547],[-3.09208,53.404441],[-2.945009,53.985],[-3.614701,54.600937],[-3.630005,54.615013],[-4.844169,54.790971],[-5.082527,55.061601],[-4.719112,55.508473],[-5.047981,55.783986],[-5.586398,55.311146],[-5.644999,56.275015],[-6.149981,56.78501],[-5.786825,57.818848],[-5.009999,58.630013],[-4.211495,58.550845],[-3.005005,58.635],[-4.073828,57.553025],[-3.055002,57.690019],[-1.959281,57.6848],[-2.219988,56.870017],[-3.119003,55.973793],[-2.085009,55.909998],[-2.005676,55.804903],[-1.114991,54.624986],[-0.430485,54.464376],[0.184981,53.325014],[0.469977,52.929999],[1.681531,52.73952],[1.559988,52.099998],[1.050562,51.806761],[1.449865,51.289428],[0.550334,50.765739],[-0.787517,50.774989],[-2.489998,50.500019],[-2.956274,50.69688],[-3.617448,50.228356],[-4.542508,50.341837],[-5.245023,49.96],[-5.776567,50.159678],[-4.30999,51.210001],[-3.414851,51.426009],[-3.422719,51.426848],[-4.984367,51.593466],[-5.267296,51.9914],[-4.222347,52.301356],[-4.770013,52.840005],[-4.579999,53.495004],[-3.093831,53.404547]]]]}}]} \ No newline at end of file diff --git a/lib/maptool/pmtiles_builder.rb b/lib/maptool/pmtiles_builder.rb index eab9553..1ce87b5 100644 --- a/lib/maptool/pmtiles_builder.rb +++ b/lib/maptool/pmtiles_builder.rb @@ -6,17 +6,28 @@ require "tmpdir" require "fileutils" module Maptool - # Regenerates data/offline.pmtiles and data/terrain.pmtiles for a given - # bbox by shelling out to go-pmtiles — the same tool docs/development.adoc - # already documents running by hand. 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. + # 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) by shelling out to + # go-pmtiles — the same tool docs/development.adoc already documents + # running by hand. 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 tiles 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") + # Protomaps' own daily full-planet basemap build index — extract_offline - # below only pulls the HTTP byte ranges covering the requested bbox + # below only pulls the HTTP byte ranges covering the requested region # (go-pmtiles's `extract` supports a remote source over range requests), # never the whole ~120GB file. PROTOMAPS_BUILDS_INDEX = "https://build-metadata.protomaps.dev/builds.json" @@ -25,7 +36,7 @@ module Maptool # 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) — same "extract only the bbox's byte ranges + # per-region max, 13-18) — same "extract only the region's byte ranges # from a remote archive" story as offline.pmtiles above, so no more # fetching individual tiles or building an MBTiles file ourselves the # way an earlier version of this file did. @@ -59,25 +70,97 @@ module Maptool TERRAIN_MUTEX.synchronize { TERRAIN_STATUS.replace(state: "idle") } end - # bounds: [south, west, north, east]. Returns once the background Thread - # has started, not once the build finishes — raises BuildError (the - # caller turns this into a 409) if one's already running. + # --- 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 + + # Writes the FeatureCollection of just the selected countries into + # `dir` — this *is* the file go-pmtiles' `--region=` reads. No + # union/dissolve needed: go-pmtiles treats a FeatureCollection's + # features as one combined MultiPolygon on its own (confirmed against + # its own source, pmtiles/region.go's UnmarshalRegion). Also returns a + # plain aggregate [south,west,north,east] (min/max over every ring + # coordinate, no geometry library) used only for the Mapterhorn + # chunk-intersection check below — approximate is fine there, the + # `--region=` file is what does the real precise selection. + def self.region_geojson_and_bbox(codes, dir) + selected = region_features.select { |f| codes.include?(f["properties"]["iso_a2"]) } + raise BuildError, "no matching regions for #{codes.join(", ")}" if selected.empty? + + path = File.join(dir, "region.geojson") + File.write(path, JSON.generate(type: "FeatureCollection", features: selected)) + + coords = selected.flat_map { |f| ring_coordinates(f["geometry"]) } + lons = coords.map { |lng, _lat| lng } + lats = coords.map { |_lng, lat| lat } + [path, [lats.min, lons.min, lats.max, lons.max]] + end + private_class_method :region_geojson_and_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. # # fetch_build_url exists as an injectable seam so tests can exercise the # rest of this (temp path, go-pmtiles invocation, atomic rename) 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(bounds, data_dir:, fetch_build_url: method(:latest_protomaps_build_url)) + def self.build_offline(data_dir:, fetch_build_url: method(:latest_protomaps_build_url)) + codes = selected_regions(data_dir) + raise BuildError, "select at least one region first" if codes.empty? + begin_run!(OFFLINE_MUTEX, OFFLINE_STATUS) - Thread.new { run_job(OFFLINE_MUTEX, OFFLINE_STATUS) { extract_offline(bounds, data_dir, fetch_build_url) } } + Thread.new { run_job(OFFLINE_MUTEX, OFFLINE_STATUS) { extract_offline(codes, data_dir, fetch_build_url) } } end # fetch_manifest: same injectable-seam reasoning as fetch_build_url # above, for the one Mapterhorn manifest lookup this needs instead of # the Protomaps build index. - def self.build_terrain(bounds, data_dir:, fetch_manifest: method(:fetch_mapterhorn_manifest)) + 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 { run_job(TERRAIN_MUTEX, TERRAIN_STATUS) { build_terrain_pmtiles(bounds, data_dir, fetch_manifest) } } + Thread.new { run_job(TERRAIN_MUTEX, TERRAIN_STATUS) { build_terrain_pmtiles(codes, data_dir, fetch_manifest) } } end def self.begin_run!(mutex, status) @@ -98,14 +181,16 @@ module Maptool # --- offline.pmtiles --- - def self.extract_offline(bounds, data_dir, fetch_build_url) + def self.extract_offline(codes, data_dir, fetch_build_url) source = fetch_build_url.call - south, west, north, east = bounds + scratch_dir = Dir.mktmpdir("maptool-offline") + region_path, = region_geojson_and_bbox(codes, scratch_dir) tmp_path = File.join(data_dir, "offline.pmtiles.tmp") - run_pmtiles!("extract", source, tmp_path, "--bbox=#{west},#{south},#{east},#{north}", "--maxzoom=15") + run_pmtiles!("extract", source, tmp_path, "--region=#{region_path}", "--maxzoom=15") File.rename(tmp_path, File.join(data_dir, "offline.pmtiles")) 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 @@ -126,25 +211,34 @@ module Maptool # --- terrain.pmtiles --- # planet.pmtiles (z0-12, always) plus whichever regional chunks - # intersect the bbox (z13+, zero or more — most bboxes hit exactly one, - # a bbox straddling a chunk boundary hits two) are each `extract`ed on - # their own, then combined with `pmtiles merge` if there was more than - # one (planet + a chunk are disjoint by zoom level, so this is always a - # valid merge; go-pmtiles requires clustered, matching-tile-type inputs, - # which every Mapterhorn PMTiles archive already satisfies). - def self.build_terrain_pmtiles(bounds, data_dir, fetch_manifest) - south, west, north, east = bounds + # 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) 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") + region_path, bbox = region_geojson_and_bbox(codes, scratch_dir) + south, west, north, east = bbox + chunks = items.select { |item| item["name"] != MAPTERHORN_PLANET_NAME && bbox_intersects?(item, south, west, north, east) } - scratch_dir = Dir.mktmpdir("maptool-terrain") tmp_path = File.join(data_dir, "terrain.pmtiles.tmp") extracts = [planet, *chunks].each_with_index.map do |item, i| extract_path = File.join(scratch_dir, "extract-#{i}.pmtiles") - run_pmtiles!("extract", item["url"], extract_path, "--bbox=#{west},#{south},#{east},#{north}", "--maxzoom=#{TERRAIN_MAX_ZOOM}") + run_pmtiles!("extract", item["url"], extract_path, "--region=#{region_path}", "--maxzoom=#{TERRAIN_MAX_ZOOM}") extract_path end diff --git a/public/css/style.css b/public/css/style.css index 12979ff..65c4e7d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -401,39 +401,65 @@ html, body { padding: 8px 12px; } -.offline-data-panel__row + .offline-data-panel__row { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid #eee; +.offline-data-panel__regions-label { + margin: 0 0 4px; + font-weight: 600; } -.offline-data-panel__row-header { +.offline-data-panel__region-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 220px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 4px; +} + +.offline-data-panel__region-list label { display: flex; align-items: center; - justify-content: space-between; - gap: 8px; + gap: 6px; + padding: 4px 8px; + cursor: pointer; } -.offline-data-panel__build { - flex-shrink: 0; +.offline-data-panel__region-list label:hover { + background: #f0f0f0; +} + +.offline-data-panel__region-status { + padding: 6px 8px; + color: #888; +} + +.offline-data-panel__apply { + margin-top: 8px; + width: 100%; border: 1px solid #ccc; border-radius: 4px; background: #fff; - padding: 4px 8px; + padding: 6px 8px; font: inherit; font-size: 12px; cursor: pointer; } -.offline-data-panel__build:hover { +.offline-data-panel__apply:hover { background: #f0f0f0; } -.offline-data-panel__build:disabled { +.offline-data-panel__apply:disabled { opacity: 0.5; cursor: default; } +.offline-data-panel__row { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid #eee; +} + .offline-data-panel__status { margin: 6px 0 0; color: #666; diff --git a/public/js/map.js b/public/js/map.js index 40ad1d4..7a189d7 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -516,7 +516,7 @@ if (isLoggedIn()) new PluginsControl(map).mount(sidebar); sidebar.insertAdjacentHTML("beforeend", ``); layerToggle.mount(sidebar); layerToggle.setActive("online"); -new OfflineDataControl(map).mount(sidebar); +new OfflineDataControl().mount(sidebar); sidebar.insertAdjacentHTML("beforeend", ``); gnssControl.mount(sidebar); diff --git a/public/js/offline_data.js b/public/js/offline_data.js index 2fe4e8a..e9928ac 100644 --- a/public/js/offline_data.js +++ b/public/js/offline_data.js @@ -1,6 +1,7 @@ import { OFFLINE_DATA_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { isLoggedIn } from "/js/auth.js"; +import { escapeHtml } from "/js/gpx.js"; const POLL_INTERVAL_MS = 3000; @@ -16,51 +17,106 @@ function formatElapsed(ms) { return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; } -// "Building…" alone gave no way to tell a slow build (terrain.pmtiles can -// take minutes — lib/maptool/pmtiles_builder.rb fetches tiles one at a -// time) from a stuck one. Elapsed time alone answers "is it alive" for -// both files; tile counts (only terrain.pmtiles reports these) give an -// actual sense of how much is left. +// "Building…" alone gave no way to tell a slow build from a stuck one — +// elapsed time answers "is it alive" regardless of how long a given +// selection takes. function formatRunning(status) { const elapsed = status.started_at ? ` (${formatElapsed(Date.now() - new Date(status.started_at).getTime())})` : ""; - const tiles = status.tiles_total ? ` · ${status.tiles_done}/${status.tiles_total} tiles` : ""; - return `Building…${elapsed}${tiles}`; + return `Building…${elapsed}`; } -// One row of the Offline data panel — status text plus a "Regenerate for -// current view" button, for either offline.pmtiles or terrain.pmtiles. -// Both files behave identically from here (same status/build/build-status -// endpoint shape, see lib/maptool/pmtiles_builder.rb), just against -// different URLs/labels, so one class covers both rather than writing the -// same fetch/poll logic twice. +// The checklist of selectable countries (GET /api/offline-regions) plus +// the "Apply changes" button that saves the ticked set (POST, same URL) +// and hands it off — doesn't trigger a build itself, OfflineDataControl +// does that across both files once the selection is saved. +class RegionPicker { + constructor(onApply) { + this._onApply = onApply; + } + + async mount(container) { + const section = document.createElement("div"); + section.className = "offline-data-panel__regions"; + section.innerHTML = ` +

Regions

+
  • Loading…
+ + `; + this._list = section.querySelector(".offline-data-panel__region-list"); + this._applyButton = section.querySelector(".offline-data-panel__apply"); + // Viewing/ticking works for anyone; only saving needs a login — same + // finer-grained gating measure.js/gpx.js use for their own + // write-only affordances, rather than hiding this whole control. + this._applyButton.hidden = !isLoggedIn(); + this._applyButton.addEventListener("click", () => this._apply()); + container.appendChild(section); + + let data; + try { + data = await (await fetch("/api/offline-regions")).json(); + } catch { + this._list.innerHTML = `
  • Could not load the region list.
  • `; + return; + } + + const selected = new Set(data.selected); + this._list.innerHTML = data.available + .map( + (region) => ` +
  • + +
  • `, + ) + .join(""); + } + + async _apply() { + const codes = [...this._list.querySelectorAll("input:checked")].map((el) => el.value); + const body = new URLSearchParams(); + codes.forEach((code) => body.append("codes[]", code)); + + this._applyButton.disabled = true; + try { + const response = await fetch("/api/offline-regions", { method: "POST", body }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `could not save the selection: ${response.status}`); + } catch (err) { + window.alert(err.message); + return; + } finally { + this._applyButton.disabled = false; + } + + this._onApply(); + } +} + +// One row of the Offline data panel — just a status readout for either +// offline.pmtiles or terrain.pmtiles, both driven by the same selection +// (RegionPicker above). Both files behave identically from here (same +// status/build/build-status endpoint shape, see +// lib/maptool/pmtiles_builder.rb), just against different URLs/labels, so +// one class covers both rather than writing the same fetch/poll logic +// twice. class DataFileRow { - constructor(map, { label, statusUrl, buildUrl, buildStatusUrl }) { - this._map = map; + constructor({ label, statusUrl, buildUrl, buildStatusUrl }) { this._label = label; this._statusUrl = statusUrl; this._buildUrl = buildUrl; this._buildStatusUrl = buildStatusUrl; - this._exists = false; } mount(container) { const row = document.createElement("div"); row.className = "offline-data-panel__row"; row.innerHTML = ` -
    - ${this._label} - -
    + ${this._label}

    Checking…

    `; - this._buildButton = row.querySelector(".offline-data-panel__build"); this._statusEl = row.querySelector(".offline-data-panel__status"); - // Viewing status works for anyone; only triggering a build needs a - // login — same finer-grained gating measure.js/gpx.js use for their - // own write-only affordances, rather than hiding this whole control. - this._buildButton.hidden = !isLoggedIn(); - this._buildButton.addEventListener("click", () => this._build()); - container.appendChild(row); this._refreshStatus(); } @@ -73,34 +129,21 @@ class DataFileRow { this._statusEl.textContent = "Could not check status."; return; } - this._exists = data.available; this._statusEl.textContent = data.available ? `${formatBytes(data.size)} · updated ${new Date(data.mtime).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}` : "Not generated yet."; } - async _build() { - if (this._exists && !window.confirm(`Replace the existing ${this._label.toLowerCase()} with a fresh build for the current view?`)) { - return; - } - - const bounds = this._map.getBounds(); - const body = new URLSearchParams({ - south: bounds.getSouth(), - west: bounds.getWest(), - north: bounds.getNorth(), - east: bounds.getEast(), - }); - - this._buildButton.disabled = true; + // Called by OfflineDataControl once RegionPicker's Apply has saved a + // new selection — not tied to a button of this row's own. + async triggerBuild() { this._statusEl.textContent = "Starting…"; try { - const response = await fetch(this._buildUrl, { method: "POST", body }); + const response = await fetch(this._buildUrl, { method: "POST" }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || `build failed: ${response.status}`); } catch (err) { this._statusEl.textContent = err.message; - this._buildButton.disabled = false; return; } @@ -126,7 +169,6 @@ class DataFileRow { return; } - this._buildButton.disabled = false; if (status.state === "error") { this._statusEl.textContent = `Build failed: ${status.message}`; } else { @@ -137,27 +179,29 @@ class DataFileRow { } // A sidebar panel wrapping the manual go-pmtiles workflow -// docs/development.adoc documents — "Regenerate for current view" on -// either file shells out to it server-side instead of an admin running it -// by hand (see lib/maptool/pmtiles_builder.rb). No area-picker: the -// current map viewport is the region, same convention the Overpass POI -// plugin already uses for "give me a bbox." +// docs/development.adoc documents — a region picker (which countries +// should be available offline) plus, on "Apply changes", a rebuild of +// offline.pmtiles/terrain.pmtiles covering the whole current selection +// (see lib/maptool/pmtiles_builder.rb — there's no standalone per-country +// file to individually delete; "remove a country" and "refresh a +// country" are both just "change the selection, then Apply"). export class OfflineDataControl { - constructor(map) { + constructor() { this._rows = [ - new DataFileRow(map, { + new DataFileRow({ label: "Offline map data", statusUrl: "/api/offline-tiles", buildUrl: "/api/offline-tiles/build", buildStatusUrl: "/api/offline-tiles/build-status", }), - new DataFileRow(map, { + new DataFileRow({ label: "Offline terrain data", statusUrl: "/api/terrain-tiles", buildUrl: "/api/terrain-tiles/build", buildStatusUrl: "/api/terrain-tiles/build-status", }), ]; + this._regionPicker = new RegionPicker(() => this._rows.forEach((row) => row.triggerBuild())); } mount(sidebar) { @@ -171,6 +215,7 @@ export class OfflineDataControl { this._panel.querySelector(".basemap-panel__close").addEventListener("click", () => this._togglePanel(false)); const body = this._panel.querySelector(".offline-data-panel__body"); + this._regionPicker.mount(body); this._rows.forEach((row) => row.mount(body)); onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); diff --git a/test/test_app.rb b/test/test_app.rb index d26324e..6c73f3f 100644 --- a/test/test_app.rb +++ b/test/test_app.rb @@ -349,41 +349,63 @@ class AppTest < Minitest::Test assert_equal 502, response.status end + # --- offline regions --- + + def test_offline_regions_lists_available_and_selected + body = json_body(request.get("/api/offline-regions")) + assert_operator body["available"].size, :>, 0 + assert_includes body["available"], { "code" => "SK", "name" => "Slovakia" } + assert_equal [], body["selected"] + end + + def test_offline_regions_post_requires_login + assert_equal 401, request.post("/api/offline-regions").status + end + + def test_offline_regions_post_saves_the_selection + response = request.post("/api/offline-regions", params: { "codes" => %w[SK AT] }, **logged_in_env) + assert_equal 200, response.status + assert_equal %w[SK AT], json_body(request.get("/api/offline-regions"))["selected"] + end + + def test_offline_regions_post_rejects_an_unknown_code + response = request.post("/api/offline-regions", params: { "codes" => ["XX"] }, **logged_in_env) + assert_equal 400, response.status + end + # --- pmtiles regeneration --- # # The full build (network + go-pmtiles) is covered in isolation, with # fakes injected, by test_pmtiles_builder.rb — same "keep this offline" # reasoning as the rest of this file. What's route-specific and worth - # covering here is the bbox validation and the 409-while-running guard, - # neither of which needs a real build to actually run. + # covering here is the empty-selection validation and the + # 409-while-running guard, neither of which needs a real build to + # actually run. def test_offline_tiles_build_status_defaults_to_idle assert_equal({ "state" => "idle" }, json_body(request.get("/api/offline-tiles/build-status"))) assert_equal({ "state" => "idle" }, json_body(request.get("/api/terrain-tiles/build-status"))) end - def test_offline_tiles_build_requires_a_bbox + def test_offline_tiles_build_is_409_with_no_selection response = request.post("/api/offline-tiles/build", **logged_in_env) - assert_equal 400, response.status + assert_equal 409, response.status end - def test_terrain_tiles_build_requires_a_bbox + def test_terrain_tiles_build_is_409_with_no_selection response = request.post("/api/terrain-tiles/build", **logged_in_env) - assert_equal 400, response.status + assert_equal 409, response.status end # fetch_build_url blocks on a Queue, called directly (not through the # route, which has no way to inject a fake) so the build is # deterministically still "running" when the route call below happens. def test_offline_tiles_build_is_409_when_one_is_already_running + Maptool::PmtilesBuilder.set_selected_regions(Maptool::App::DATA_DIR, ["SK"]) gate = Queue.new - thread = Maptool::PmtilesBuilder.build_offline([1, 2, 3, 4], data_dir: Maptool::App::DATA_DIR, fetch_build_url: -> { gate.pop }) + thread = Maptool::PmtilesBuilder.build_offline(data_dir: Maptool::App::DATA_DIR, fetch_build_url: -> { gate.pop }) - response = request.post( - "/api/offline-tiles/build", - params: { south: "1", west: "2", north: "3", east: "4" }, - **logged_in_env, - ) + response = request.post("/api/offline-tiles/build", **logged_in_env) assert_equal 409, response.status ensure gate.push("unused") diff --git a/test/test_helper.rb b/test/test_helper.rb index b266ff8..da183d6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -66,6 +66,7 @@ module AppTestHelper def teardown FileUtils.rm_rf(Dir.glob(File.join(Maptool::App::DATA_DIR, "gpx", "*"))) File.write(File.join(Maptool::App::DATA_DIR, "livetrack.json"), "[]") + FileUtils.rm_f(File.join(Maptool::App::DATA_DIR, "offline_regions.json")) Maptool::PmtilesBuilder.reset! super end diff --git a/test/test_pmtiles_builder.rb b/test/test_pmtiles_builder.rb index afb5cc3..81cc5de 100644 --- a/test/test_pmtiles_builder.rb +++ b/test/test_pmtiles_builder.rb @@ -11,13 +11,19 @@ require "fileutils" # CLI (PMTILES_BIN) stands in for the real go-pmtiles binary, logging each # invocation's subcommand + first arg to PMTILES_LOG so tests can assert on # what actually got called (e.g. "was merge invoked, and with what"). +# +# Country codes below are real ones from the vendored +# lib/maptool/data/europe_countries.geojson (Slovakia, Iceland, Spain) — +# chosen so their real bboxes fall cleanly inside/outside the fake +# manifest's chunk extents below, without needing a second fake geometry +# source. class PmtilesBuilderTest < Minitest::Test def setup @data_dir = Dir.mktmpdir("maptool-pmtiles-test-data") @bin_dir = Dir.mktmpdir("maptool-pmtiles-test-bin") @log = File.join(@bin_dir, "pmtiles.log") - # Output path: $3 for `extract SOURCE OUTPUT --bbox=... --maxzoom=...` + # Output path: $3 for `extract SOURCE OUTPUT --region=... --maxzoom=...` # and `convert INPUT OUTPUT` alike; the *last* arg for `merge INPUT_1 # [INPUT_2 ...] OUTPUT`, since its input count varies. @pmtiles_ok = File.join(@bin_dir, "pmtiles-ok") @@ -57,15 +63,17 @@ class PmtilesBuilderTest < Minitest::Test File.exist?(@log) ? File.readlines(@log, chomp: true) : [] end - # planet.pmtiles always covers the whole world (z0-12); chunk-a/chunk-b - # are disjoint small regions (z13+), matching Mapterhorn's real shape - # (see download_urls.json) closely enough to exercise bbox_intersects? - # and the extract-then-maybe-merge logic without a real manifest fetch. + def select_regions(*codes) + Maptool::PmtilesBuilder.set_selected_regions(@data_dir, codes) + end + + # chunk-a covers Slovakia's real extent, chunk-b covers Iceland's — + # Spain falls in neither, for the "no chunk intersects" case. def fake_manifest [ { "name" => "planet.pmtiles", "url" => "https://example.com/planet.pmtiles", "min_lon" => -180.0, "min_lat" => -85.0, "max_lon" => 180.0, "max_lat" => 85.0, "min_zoom" => 0, "max_zoom" => 12 }, - { "name" => "chunk-a.pmtiles", "url" => "https://example.com/chunk-a.pmtiles", "min_lon" => 17.0, "min_lat" => 48.0, "max_lon" => 17.5, "max_lat" => 48.5, "min_zoom" => 13, "max_zoom" => 15 }, - { "name" => "chunk-b.pmtiles", "url" => "https://example.com/chunk-b.pmtiles", "min_lon" => 20.0, "min_lat" => 50.0, "max_lon" => 20.5, "max_lat" => 50.5, "min_zoom" => 13, "max_zoom" => 15 }, + { "name" => "chunk-a.pmtiles", "url" => "https://example.com/chunk-a.pmtiles", "min_lon" => 16.0, "min_lat" => 47.0, "max_lon" => 23.0, "max_lat" => 50.0, "min_zoom" => 13, "max_zoom" => 15 }, + { "name" => "chunk-b.pmtiles", "url" => "https://example.com/chunk-b.pmtiles", "min_lon" => -25.0, "min_lat" => 63.0, "max_lon" => -13.0, "max_lat" => 67.0, "min_zoom" => 13, "max_zoom" => 15 }, ] end @@ -74,27 +82,51 @@ class PmtilesBuilderTest < Minitest::Test assert_equal({ state: "idle" }, Maptool::PmtilesBuilder.terrain_status) end + # --- region selection --- + + def test_available_regions_includes_known_countries + codes = Maptool::PmtilesBuilder.available_regions.map { |r| r[:code] } + assert_includes codes, "SK" + assert_includes codes, "IS" + end + + def test_selected_regions_defaults_to_empty + assert_empty Maptool::PmtilesBuilder.selected_regions(@data_dir) + end + + def test_set_selected_regions_round_trips + select_regions("SK", "AT") + assert_equal ["SK", "AT"], Maptool::PmtilesBuilder.selected_regions(@data_dir) + end + + def test_set_selected_regions_rejects_an_unknown_code + assert_raises(Maptool::PmtilesBuilder::BuildError) { select_regions("SK", "XX") } + end + + # --- offline.pmtiles --- + + def test_build_offline_raises_with_no_selection + assert_raises(Maptool::PmtilesBuilder::BuildError) do + Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "unused" }) + end + end + def test_build_offline_writes_the_file_and_reports_done + select_regions("SK") ENV["PMTILES_BIN"] = @pmtiles_ok - thread = Maptool::PmtilesBuilder.build_offline( - [1, 2, 3, 4], - data_dir: @data_dir, - fetch_build_url: -> { "https://example.com/fake.pmtiles" }, - ) + thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }) thread.join assert_path_exists File.join(@data_dir, "offline.pmtiles") refute_path_exists File.join(@data_dir, "offline.pmtiles.tmp") assert_equal "done", Maptool::PmtilesBuilder.offline_status[:state] + assert_equal ["extract https://example.com/fake.pmtiles"], log_lines end def test_build_offline_reports_an_error_status_on_non_zero_exit + select_regions("SK") ENV["PMTILES_BIN"] = @pmtiles_fail - thread = Maptool::PmtilesBuilder.build_offline( - [1, 2, 3, 4], - data_dir: @data_dir, - fetch_build_url: -> { "https://example.com/fake.pmtiles" }, - ) + thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }) thread.join refute_path_exists File.join(@data_dir, "offline.pmtiles") @@ -104,12 +136,9 @@ class PmtilesBuilderTest < Minitest::Test end def test_build_offline_reports_a_clear_error_when_the_binary_is_missing + select_regions("SK") ENV["PMTILES_BIN"] = File.join(@bin_dir, "does-not-exist") - thread = Maptool::PmtilesBuilder.build_offline( - [1, 2, 3, 4], - data_dir: @data_dir, - fetch_build_url: -> { "https://example.com/fake.pmtiles" }, - ) + thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "https://example.com/fake.pmtiles" }) thread.join status = Maptool::PmtilesBuilder.offline_status @@ -121,23 +150,33 @@ class PmtilesBuilderTest < Minitest::Test # still "running" when the second call happens, rather than racing a real # one to see which state wins. def test_build_offline_raises_when_one_is_already_running + select_regions("SK") ENV["PMTILES_BIN"] = @pmtiles_ok gate = Queue.new - thread = Maptool::PmtilesBuilder.build_offline([1, 2, 3, 4], data_dir: @data_dir, fetch_build_url: -> { gate.pop }) + thread = Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { gate.pop }) assert_raises(Maptool::PmtilesBuilder::BuildError) do - Maptool::PmtilesBuilder.build_offline([1, 2, 3, 4], data_dir: @data_dir, fetch_build_url: -> { "unused" }) + Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, fetch_build_url: -> { "unused" }) end ensure gate.push("https://example.com/fake.pmtiles") thread&.join end - # A bbox nowhere near chunk-a/chunk-b — only planet.pmtiles matches, so + # --- terrain.pmtiles --- + + def test_build_terrain_raises_with_no_selection + assert_raises(Maptool::PmtilesBuilder::BuildError) do + Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) + end + end + + # Spain intersects neither fake chunk — only planet.pmtiles matches, so # this should be a single extract straight to the final path, no merge. def test_build_terrain_extracts_only_planet_when_no_chunk_intersects + select_regions("ES") ENV["PMTILES_BIN"] = @pmtiles_ok - thread = Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) + thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) thread.join assert_path_exists File.join(@data_dir, "terrain.pmtiles") @@ -145,11 +184,12 @@ class PmtilesBuilderTest < Minitest::Test assert_equal ["extract https://example.com/planet.pmtiles"], log_lines end - # A bbox inside chunk-a's extent — planet.pmtiles (always) plus that one - # chunk get extracted, then merged. + # Slovakia intersects chunk-a only — planet.pmtiles (always) plus that + # one chunk get extracted, then merged. def test_build_terrain_extracts_and_merges_a_single_intersecting_chunk + select_regions("SK") ENV["PMTILES_BIN"] = @pmtiles_ok - thread = Maptool::PmtilesBuilder.build_terrain([48.1, 17.1, 48.2, 17.2], data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) + thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) thread.join assert_path_exists File.join(@data_dir, "terrain.pmtiles") @@ -160,11 +200,12 @@ class PmtilesBuilderTest < Minitest::Test assert_equal 1, merged.size end - # A bbox spanning both chunk-a and chunk-b — planet + both chunks, one - # 3-way merge. + # Slovakia + Iceland together intersect both fake chunks — planet + both + # chunks, one 3-way merge. def test_build_terrain_merges_multiple_intersecting_chunks + select_regions("SK", "IS") ENV["PMTILES_BIN"] = @pmtiles_ok - thread = Maptool::PmtilesBuilder.build_terrain([47.9, 16.9, 50.6, 20.6], data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) + thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) thread.join assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state] @@ -174,8 +215,9 @@ class PmtilesBuilderTest < Minitest::Test end def test_build_terrain_reports_a_clear_error_when_the_manifest_is_missing_planet + select_regions("ES") ENV["PMTILES_BIN"] = @pmtiles_ok - thread = Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { fake_manifest.reject { |i| i["name"] == "planet.pmtiles" } }) + thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest.reject { |i| i["name"] == "planet.pmtiles" } }) thread.join status = Maptool::PmtilesBuilder.terrain_status @@ -184,8 +226,9 @@ class PmtilesBuilderTest < Minitest::Test end def test_build_terrain_reports_an_error_status_on_non_zero_exit + select_regions("ES") ENV["PMTILES_BIN"] = @pmtiles_fail - thread = Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) + thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { fake_manifest }) thread.join refute_path_exists File.join(@data_dir, "terrain.pmtiles") @@ -195,12 +238,13 @@ class PmtilesBuilderTest < Minitest::Test end def test_build_terrain_raises_when_one_is_already_running + select_regions("ES") ENV["PMTILES_BIN"] = @pmtiles_ok gate = Queue.new - thread = Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { gate.pop }) + thread = Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { gate.pop }) assert_raises(Maptool::PmtilesBuilder::BuildError) do - Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { "unused" }) + Maptool::PmtilesBuilder.build_terrain(data_dir: @data_dir, fetch_manifest: -> { "unused" }) end ensure gate.push(fake_manifest)