Build terrain.pmtiles from Mapterhorn instead of fetching tiles ourselves
Mapterhorn (mapterhorn.com, Protomaps-affiliated) publishes pre-built Terrarium-encoded terrain as PMTiles: a global planet.pmtiles (z0-12) plus ~450 regional chunks (z13+). Same pmtiles extract --bbox=... mechanism offline.pmtiles already uses, just against their files — confirmed live against their real manifest and the documented Interlaken example. Replaces fetching every individual tile ourselves (even after this session's earlier concurrency fix, still tens of minutes for a real region) with 1-3 extract calls plus a merge when the bbox spans more than one chunk: seconds, not minutes. Removes CONCURRENT_TILE_FETCHES, write_terrarium_mbtiles, tile_range, lng/lat_to_tile_x/y, fetch_terrarium_tile, and the sqlite3 gem entirely — none of it is needed once the tiles arrive pre-packaged. dem.js's point-elevation lookup (shadow.js's terrain-shadow overlay) hardcoded a 256px PNG tile assumption that Mapterhorn's 512px WebP tiles would have silently broken — now reads the actually-decoded tile's own size and mime type instead of assuming either.
This commit is contained in:
1 parent
d041c21270
commit
f89dea5805
8 files changed
+224
-234
No files matched your search
@@ -6,7 +6,6 @@ gem "puma", "~> 8.0"
|
||||
gem "rexml", "~> 3.4"
|
||||
gem "oauth2", "~> 2.0"
|
||||
gem "rubyzip", "~> 2.4"
|
||||
gem "sqlite3", "~> 2.0"
|
||||
|
||||
group :development do
|
||||
gem "rerun"
|
||||
|
||||
@@ -95,16 +95,6 @@ GEM
|
||||
snaky_hash (2.0.7)
|
||||
hashie (>= 0.1.0, < 6)
|
||||
version_gem (~> 1.1, >= 1.1.14)
|
||||
sqlite3 (2.9.6-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.6-aarch64-linux-musl)
|
||||
sqlite3 (2.9.6-arm-linux-gnu)
|
||||
sqlite3 (2.9.6-arm-linux-musl)
|
||||
sqlite3 (2.9.6-arm64-darwin)
|
||||
sqlite3 (2.9.6-x86-linux-gnu)
|
||||
sqlite3 (2.9.6-x86-linux-musl)
|
||||
sqlite3 (2.9.6-x86_64-darwin)
|
||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.6-x86_64-linux-musl)
|
||||
tilt (2.9.0)
|
||||
uri (1.1.1)
|
||||
version_gem (1.1.15)
|
||||
@@ -132,7 +122,6 @@ DEPENDENCIES
|
||||
rubyzip (~> 2.4)
|
||||
sinatra (~> 4.2)
|
||||
sinatra-contrib (~> 4.2)
|
||||
sqlite3 (~> 2.0)
|
||||
|
||||
CHECKSUMS
|
||||
anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55
|
||||
@@ -182,16 +171,6 @@ CHECKSUMS
|
||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
||||
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
|
||||
snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d
|
||||
sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c
|
||||
sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9
|
||||
sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec
|
||||
sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33
|
||||
sqlite3 (2.9.6-arm64-darwin) sha256=849b5d7f795e60fe25076d62c72dd722beb45b3850b516ad978d60ee848ec15b
|
||||
sqlite3 (2.9.6-x86-linux-gnu) sha256=fbaa9f46f9708f57dd8a459b37fc269f9613e0cacf1547df01aa439cc45c20c0
|
||||
sqlite3 (2.9.6-x86-linux-musl) sha256=6715026fbb5530e810b28ef43b9c4f84cd3c991f67b9d808a31fcc32b847abbd
|
||||
sqlite3 (2.9.6-x86_64-darwin) sha256=b5842fea77781c14da03fa7bc0feb82db03a69e135affcb6f5399cbd2797a5f3
|
||||
sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634
|
||||
sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf
|
||||
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
|
||||
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
||||
version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0
|
||||
|
||||
+28
-12
@@ -58,13 +58,28 @@ no API key). For it to work offline too, place a PMTiles archive of
|
||||
Terrarium-encoded `raster-dem` tiles at `data/terrain.pmtiles`
|
||||
(`GET /api/terrain-tiles` reports `available: true` once it's there,
|
||||
same pattern as offline map data above). Also generatable in-app (see
|
||||
below) or by hand — build one locally from a DEM source (e.g.
|
||||
Copernicus GLO-30 or SRTM) with a tool like
|
||||
https://github.com/mapbox/rio-rgbify[rio-rgbify] (GeoTIFF ->
|
||||
Terrarium-encoded PNG tiles in an `.mbtiles`), then
|
||||
`pmtiles convert out.mbtiles data/terrain.pmtiles` with
|
||||
https://github.com/protomaps/go-pmtiles[go-pmtiles], same as
|
||||
`data/offline.pmtiles`.
|
||||
below) or by hand, the same `go-pmtiles extract` mechanism as
|
||||
`data/offline.pmtiles` above — but from
|
||||
https://mapterhorn.com[Mapterhorn], a Protomaps-affiliated project
|
||||
publishing pre-built Terrarium-encoded terrain as PMTiles (a global
|
||||
`planet.pmtiles`, z0-12, plus ~450 regional chunks covering z13 and up
|
||||
— see https://mapterhorn.com/data-access/[their own data-access page]
|
||||
for the full manifest and per-source license/attribution details, CC-BY-4.0
|
||||
and similar, varying by region):
|
||||
|
||||
[source,shell]
|
||||
----
|
||||
pmtiles extract https://download.mapterhorn.com/planet.pmtiles planet-extract.pmtiles --bbox=west,south,east,north --maxzoom=15
|
||||
# only if a regional chunk covers the bbox too (see download_urls.json) —
|
||||
# most bboxes hit exactly one, a bbox straddling a chunk boundary hits two:
|
||||
pmtiles extract https://download.mapterhorn.com/<chunk>.pmtiles chunk-extract.pmtiles --bbox=west,south,east,north --maxzoom=15
|
||||
pmtiles merge planet-extract.pmtiles chunk-extract.pmtiles data/terrain.pmtiles
|
||||
----
|
||||
|
||||
Tiles are 512px WebP, not the 256px PNG this app's *online* Terrarium
|
||||
source uses — `public/js/dem.js`'s own point-elevation lookups
|
||||
(`shadow.js`'s terrain-shadow overlay) read the actual decoded tile
|
||||
size rather than assuming one, so this isn't a problem either way.
|
||||
|
||||
== In-app regeneration
|
||||
|
||||
@@ -78,11 +93,12 @@ 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); regenerating terrain.pmtiles this way fetches
|
||||
individual Terrarium tiles itself and packs them into an MBTiles file
|
||||
(the `sqlite3` gem) before the same `pmtiles convert` step, since no
|
||||
public Terrarium-encoded PMTiles archive exists to `extract` from the
|
||||
way offline.pmtiles above can.
|
||||
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.
|
||||
|
||||
A build runs in the background (`POST /api/offline-tiles/build` /
|
||||
`/api/terrain-tiles/build`, polled via the matching `*/build-status`
|
||||
|
||||
@@ -82,6 +82,15 @@ The online map style (not vendored, fetched at runtime when the
|
||||
https://tiles.openfreemap.org/styles/liberty[OpenFreeMap "liberty"],
|
||||
built from https://www.openstreetmap.org[OpenStreetMap] data.
|
||||
|
||||
`terrain.pmtiles` (`lib/maptool/pmtiles_builder.rb`'s in-app
|
||||
regeneration, or the manual recipe in `docs/development.adoc`) is
|
||||
extracted at build time, not vendored, from
|
||||
https://mapterhorn.com[Mapterhorn] — a Protomaps-affiliated project
|
||||
publishing pre-built Terrarium-encoded terrain as PMTiles. Per-region
|
||||
source licenses vary (CC-BY-4.0 and similar); see
|
||||
https://download.mapterhorn.com/attribution.json[their own attribution
|
||||
list] for the full provenance.
|
||||
|
||||
Not a frontend asset like the above, but also baked into this app's own
|
||||
Docker image (not a separate container the way kiwix-serve/gpsd are —
|
||||
see `docs/deployment.adoc`/`docs/development.adoc`): the `pmtiles` CLI
|
||||
|
||||
+56
-126
@@ -2,8 +2,8 @@ require "open3"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
require "json"
|
||||
require "sqlite3"
|
||||
require "tmpdir"
|
||||
require "fileutils"
|
||||
|
||||
module Maptool
|
||||
# Regenerates data/offline.pmtiles and data/terrain.pmtiles for a given
|
||||
@@ -22,19 +22,20 @@ module Maptool
|
||||
PROTOMAPS_BUILDS_INDEX = "https://build-metadata.protomaps.dev/builds.json"
|
||||
PROTOMAPS_BUILD_BASE = "https://build.protomaps.com"
|
||||
|
||||
# Same public, no-API-key Terrarium tile source public/js/overlays.js's
|
||||
# ONLINE_TERRAIN_TILES already fetches client-side — no shared constant
|
||||
# between Ruby and JS, just the same literal URL template. Matches that
|
||||
# source's own online maxzoom.
|
||||
TERRARIUM_URL_TEMPLATE = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/%<z>d/%<x>d/%<y>d.png"
|
||||
# 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
|
||||
# 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.
|
||||
MAPTERHORN_MANIFEST_URL = "https://download.mapterhorn.com/download_urls.json"
|
||||
MAPTERHORN_PLANET_NAME = "planet.pmtiles"
|
||||
# Matches the online hillshade layer's own cap (public/js/overlays.js's
|
||||
# ONLINE_TERRAIN_TILES source, maxzoom: 15) — applied uniformly even
|
||||
# though some Mapterhorn chunks natively offer more, so the offline
|
||||
# experience doesn't vary in detail from one region to the next.
|
||||
TERRAIN_MAX_ZOOM = 15
|
||||
# A real bbox at z0..15 is tens of thousands of tiles — serial fetches
|
||||
# (the original ponytail'd version of this) measured ~0.65s/tile, almost
|
||||
# all TCP+TLS handshake, no connection reuse: hours for a modest region.
|
||||
# This many workers, each keeping its own persistent connection (see
|
||||
# fetch_terrarium_tile), is a big enough win to matter without looking
|
||||
# like abuse against a public bucket.
|
||||
CONCURRENT_TILE_FETCHES = 16
|
||||
|
||||
OFFLINE_STATUS = { state: "idle" }
|
||||
TERRAIN_STATUS = { state: "idle" }
|
||||
@@ -71,16 +72,12 @@ module Maptool
|
||||
Thread.new { run_job(OFFLINE_MUTEX, OFFLINE_STATUS) { extract_offline(bounds, data_dir, fetch_build_url) } }
|
||||
end
|
||||
|
||||
# fetch_tile: same injectable-seam reasoning as fetch_build_url above,
|
||||
# for the many individual Terrarium tile fetches this needs instead of
|
||||
# one index lookup. This build can run for minutes with nothing else to
|
||||
# show for it, so write_terrarium_mbtiles reports tiles_done/tiles_total
|
||||
# into the status Hash as it goes — otherwise a poller sees "running"
|
||||
# the whole time with no way to tell it apart from stuck.
|
||||
def self.build_terrain(bounds, data_dir:, fetch_tile: method(:fetch_terrarium_tile))
|
||||
# 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))
|
||||
begin_run!(TERRAIN_MUTEX, TERRAIN_STATUS)
|
||||
progress = ->(done, total) { TERRAIN_MUTEX.synchronize { TERRAIN_STATUS.merge!(tiles_done: done, tiles_total: total) } }
|
||||
Thread.new { run_job(TERRAIN_MUTEX, TERRAIN_STATUS) { build_terrain_pmtiles(bounds, data_dir, fetch_tile, progress) } }
|
||||
Thread.new { run_job(TERRAIN_MUTEX, TERRAIN_STATUS) { build_terrain_pmtiles(bounds, data_dir, fetch_manifest) } }
|
||||
end
|
||||
|
||||
def self.begin_run!(mutex, status)
|
||||
@@ -128,122 +125,55 @@ module Maptool
|
||||
|
||||
# --- terrain.pmtiles ---
|
||||
|
||||
def self.build_terrain_pmtiles(bounds, data_dir, fetch_tile, progress)
|
||||
# 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
|
||||
tmp_mbtiles = File.join(Dir.tmpdir, "maptool-terrain-#{Process.pid}-#{Time.now.to_i}.mbtiles")
|
||||
tmp_path = File.join(data_dir, "terrain.pmtiles.tmp")
|
||||
items = fetch_manifest.call
|
||||
planet = items.find { |item| item["name"] == MAPTERHORN_PLANET_NAME }
|
||||
raise BuildError, "mapterhorn manifest is missing #{MAPTERHORN_PLANET_NAME}" unless planet
|
||||
|
||||
write_terrarium_mbtiles(tmp_mbtiles, south, west, north, east, fetch_tile, progress)
|
||||
run_pmtiles!("convert", tmp_mbtiles, tmp_path)
|
||||
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}")
|
||||
extract_path
|
||||
end
|
||||
|
||||
if extracts.size == 1
|
||||
File.rename(extracts.first, tmp_path)
|
||||
else
|
||||
run_pmtiles!("merge", *extracts, tmp_path)
|
||||
end
|
||||
File.rename(tmp_path, File.join(data_dir, "terrain.pmtiles"))
|
||||
ensure
|
||||
File.delete(tmp_mbtiles) if tmp_mbtiles && File.exist?(tmp_mbtiles)
|
||||
FileUtils.remove_entry(scratch_dir) if scratch_dir && Dir.exist?(scratch_dir)
|
||||
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
||||
end
|
||||
private_class_method :build_terrain_pmtiles
|
||||
|
||||
# Terrarium PNG tiles for z0..TERRAIN_MAX_ZOOM over the bbox, packed
|
||||
# into a minimal MBTiles (SQLite) archive — go-pmtiles' own `convert`
|
||||
# only accepts MBTiles/PMTiles as input, no raw tile directory, and no
|
||||
# public Terrarium-encoded PMTiles archive is known to exist to
|
||||
# `extract` from the way offline.pmtiles does above.
|
||||
#
|
||||
# fetch_tile.call happens on CONCURRENT_TILE_FETCHES worker threads at
|
||||
# once (I/O-bound — Ruby's GVL is released while a thread blocks on
|
||||
# network I/O, so this genuinely overlaps); every SQLite write and
|
||||
# progress update stays on this thread, draining a results Queue the
|
||||
# workers feed, so nothing here needs its own locking.
|
||||
def self.write_terrarium_mbtiles(path, south, west, north, east, fetch_tile, progress)
|
||||
db = SQLite3::Database.new(path)
|
||||
db.execute_batch(<<~SQL)
|
||||
CREATE TABLE metadata (name TEXT, value TEXT);
|
||||
CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);
|
||||
CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row);
|
||||
SQL
|
||||
{ "format" => "png", "name" => "terrain", "type" => "baselayer", "minzoom" => "0", "maxzoom" => TERRAIN_MAX_ZOOM.to_s }.each do |name, value|
|
||||
db.execute("INSERT INTO metadata (name, value) VALUES (?, ?)", [name, value])
|
||||
end
|
||||
|
||||
# tile_range per zoom computed once upfront (cheap — no fetching, just
|
||||
# tile math), so progress can report a real total from the very first
|
||||
# call instead of only once the run finishes.
|
||||
work = (0..TERRAIN_MAX_ZOOM).flat_map { |z| tile_range(z, south, west, north, east).map { |x, y| [z, x, y] } }
|
||||
total = work.size
|
||||
done = 0
|
||||
progress.call(done, total)
|
||||
|
||||
queue = Queue.new
|
||||
work.each { |item| queue << item }
|
||||
CONCURRENT_TILE_FETCHES.times { queue << nil } # one stop signal per worker
|
||||
|
||||
results = Queue.new
|
||||
workers = CONCURRENT_TILE_FETCHES.times.map do
|
||||
Thread.new do
|
||||
while (item = queue.pop)
|
||||
z, x, y = item
|
||||
results << [z, x, y, fetch_tile.call(z, x, y)]
|
||||
end
|
||||
ensure
|
||||
# Only set when fetch_tile is fetch_terrarium_tile (a test fake
|
||||
# has no reason to touch this) — closes this worker's persistent
|
||||
# connection rather than leaving the socket for GC to eventually
|
||||
# notice.
|
||||
Thread.current[:maptool_terrarium_http]&.finish
|
||||
end
|
||||
end
|
||||
|
||||
total.times do
|
||||
z, x, y, bytes = results.pop
|
||||
done += 1
|
||||
progress.call(done, total)
|
||||
next unless bytes
|
||||
|
||||
# MBTiles uses TMS row numbering (0 at the south) — the opposite of
|
||||
# the XYZ scheme both the Terrarium source and this app's own online
|
||||
# raster-dem layer use (0 at the north) — flipped here so go-pmtiles
|
||||
# ends up serving the same tile at the same z/x/y this app already
|
||||
# requests online.
|
||||
tms_y = (2**z) - 1 - y
|
||||
db.execute("INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", [z, x, tms_y, bytes.b])
|
||||
end
|
||||
workers.each(&:join)
|
||||
ensure
|
||||
db&.close
|
||||
def self.bbox_intersects?(item, south, west, north, east)
|
||||
item["min_lon"] < east && item["max_lon"] > west && item["min_lat"] < north && item["max_lat"] > south
|
||||
end
|
||||
private_class_method :write_terrarium_mbtiles
|
||||
private_class_method :bbox_intersects?
|
||||
|
||||
def self.tile_range(z, south, west, north, east)
|
||||
n = 2**z
|
||||
x_min, x_max = [lng_to_tile_x(west, n), lng_to_tile_x(east, n)].minmax
|
||||
y_min, y_max = [lat_to_tile_y(north, n), lat_to_tile_y(south, n)].minmax
|
||||
x_min.upto(x_max).flat_map { |x| y_min.upto(y_max).map { |y| [x, y] } }
|
||||
end
|
||||
private_class_method :tile_range
|
||||
def self.fetch_mapterhorn_manifest
|
||||
response = Net::HTTP.get_response(URI(MAPTERHORN_MANIFEST_URL))
|
||||
raise BuildError, "could not reach #{MAPTERHORN_MANIFEST_URL}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
def self.lng_to_tile_x(lng, n)
|
||||
(((lng + 180) / 360) * n).floor.clamp(0, n - 1)
|
||||
JSON.parse(response.body).fetch("items")
|
||||
rescue JSON::ParserError
|
||||
raise BuildError, "mapterhorn manifest was not valid JSON"
|
||||
end
|
||||
private_class_method :lng_to_tile_x
|
||||
|
||||
def self.lat_to_tile_y(lat, n)
|
||||
lat_rad = lat * Math::PI / 180
|
||||
(((1 - (Math.log(Math.tan(lat_rad) + (1 / Math.cos(lat_rad))) / Math::PI)) / 2) * n).floor.clamp(0, n - 1)
|
||||
end
|
||||
private_class_method :lat_to_tile_y
|
||||
|
||||
# One persistent connection per calling Thread (each of
|
||||
# write_terrarium_mbtiles' worker threads calls this many times) instead
|
||||
# of Net::HTTP.get_response's usual "new TCP+TLS handshake per call" —
|
||||
# that handshake was most of the ~0.65s/tile this used to cost.
|
||||
def self.fetch_terrarium_tile(z, x, y)
|
||||
uri = URI(format(TERRARIUM_URL_TEMPLATE, z: z, x: x, y: y))
|
||||
http = (Thread.current[:maptool_terrarium_http] ||= Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https"))
|
||||
response = http.get(uri.request_uri)
|
||||
response.is_a?(Net::HTTPSuccess) ? response.body : nil
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
private_class_method :fetch_terrarium_tile
|
||||
private_class_method :fetch_mapterhorn_manifest
|
||||
|
||||
# --- shared ---
|
||||
|
||||
|
||||
+19
-17
@@ -7,11 +7,14 @@
|
||||
import { ONLINE_TERRAIN_TILES } from "/js/overlays.js";
|
||||
|
||||
const DEM_ZOOM = 10; // fixed lookup zoom, independent of the map's own display zoom
|
||||
const TILE_SIZE = 256;
|
||||
|
||||
const tileCache = new Map(); // "z/x/y" -> Promise<Int16Array|null>, so concurrent lookups on the same not-yet-fetched tile share one fetch
|
||||
const tileCache = new Map(); // "z/x/y" -> Promise<{elevations,size}|null>, so concurrent lookups on the same not-yet-fetched tile share one fetch
|
||||
let offlinePmtiles = null;
|
||||
|
||||
// fracX/fracY (not pixel coordinates): the tile hasn't been fetched yet at
|
||||
// this point, so its actual pixel size isn't known — online tiles are
|
||||
// 256px PNG, offline ones (Mapterhorn, see lib/maptool/pmtiles_builder.rb)
|
||||
// are 512px WebP. elevationAt scales these once the real size is known.
|
||||
function lngLatToTile(lng, lat, zoom) {
|
||||
const n = 2 ** zoom;
|
||||
const x = ((lng + 180) / 360) * n;
|
||||
@@ -19,16 +22,11 @@ function lngLatToTile(lng, lat, zoom) {
|
||||
const y = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n;
|
||||
const tileX = Math.floor(x);
|
||||
const tileY = Math.floor(y);
|
||||
return {
|
||||
tileX,
|
||||
tileY,
|
||||
px: Math.min(TILE_SIZE - 1, Math.floor((x - tileX) * TILE_SIZE)),
|
||||
py: Math.min(TILE_SIZE - 1, Math.floor((y - tileY) * TILE_SIZE)),
|
||||
};
|
||||
return { tileX, tileY, fracX: x - tileX, fracY: y - tileY };
|
||||
}
|
||||
|
||||
async function decodeTerrarium(pngBytes) {
|
||||
const bitmap = await createImageBitmap(new Blob([pngBytes], { type: "image/png" }));
|
||||
async function decodeTerrarium(bytes, mime) {
|
||||
const bitmap = await createImageBitmap(new Blob([bytes], { type: mime }));
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
@@ -37,18 +35,18 @@ async function decodeTerrarium(pngBytes) {
|
||||
for (let i = 0; i < elevations.length; i++) {
|
||||
elevations[i] = data[i * 4] * 256 + data[i * 4 + 1] + data[i * 4 + 2] / 256 - 32768;
|
||||
}
|
||||
return elevations;
|
||||
return { elevations, size: bitmap.width };
|
||||
}
|
||||
|
||||
async function fetchTileBytes(z, x, y, offlineAvailable) {
|
||||
if (offlineAvailable) {
|
||||
if (!offlinePmtiles) offlinePmtiles = new window.pmtiles.PMTiles(`${window.location.origin}/tiles/terrain.pmtiles`);
|
||||
const result = await offlinePmtiles.getZxy(z, x, y);
|
||||
return result ? new Uint8Array(result.data) : null;
|
||||
return result ? { bytes: new Uint8Array(result.data), mime: "image/webp" } : null;
|
||||
}
|
||||
const url = ONLINE_TERRAIN_TILES[0].replace("{z}", z).replace("{x}", x).replace("{y}", y);
|
||||
const response = await fetch(url);
|
||||
return response.ok ? new Uint8Array(await response.arrayBuffer()) : null;
|
||||
return response.ok ? { bytes: new Uint8Array(await response.arrayBuffer()), mime: "image/png" } : null;
|
||||
}
|
||||
|
||||
function fetchTile(z, x, y, offlineAvailable) {
|
||||
@@ -57,7 +55,7 @@ function fetchTile(z, x, y, offlineAvailable) {
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = fetchTileBytes(z, x, y, offlineAvailable)
|
||||
.then((bytes) => (bytes ? decodeTerrarium(bytes) : null))
|
||||
.then((result) => (result ? decodeTerrarium(result.bytes, result.mime) : null))
|
||||
.catch(() => null);
|
||||
tileCache.set(key, promise);
|
||||
return promise;
|
||||
@@ -66,7 +64,11 @@ function fetchTile(z, x, y, offlineAvailable) {
|
||||
// -> Promise<number|null> — null on a fetch/decode failure, or a point
|
||||
// falling outside a partial offline extract's zoom range.
|
||||
export async function elevationAt(lng, lat, offlineAvailable) {
|
||||
const { tileX, tileY, px, py } = lngLatToTile(lng, lat, DEM_ZOOM);
|
||||
const elevations = await fetchTile(DEM_ZOOM, tileX, tileY, offlineAvailable);
|
||||
return elevations ? elevations[py * TILE_SIZE + px] : null;
|
||||
const { tileX, tileY, fracX, fracY } = lngLatToTile(lng, lat, DEM_ZOOM);
|
||||
const tile = await fetchTile(DEM_ZOOM, tileX, tileY, offlineAvailable);
|
||||
if (!tile) return null;
|
||||
|
||||
const px = Math.min(tile.size - 1, Math.floor(fracX * tile.size));
|
||||
const py = Math.min(tile.size - 1, Math.floor(fracY * tile.size));
|
||||
return tile.elevations[py * tile.size + px];
|
||||
}
|
||||
+11
-1
@@ -80,8 +80,18 @@ class HillshadeControl {
|
||||
_addLayer() {
|
||||
if (this._map.getSource(HILLSHADE_ID)) return;
|
||||
|
||||
// terrain.pmtiles is built from Mapterhorn (mapterhorn.com — see
|
||||
// lib/maptool/pmtiles_builder.rb) whether generated in-app or by hand
|
||||
// (docs/development.adoc), so this credits them regardless of how it
|
||||
// got there — their own site lists the full per-region source
|
||||
// provenance (CC-BY-4.0 and similar), not practical to reproduce here.
|
||||
const source = this._offlineAvailable
|
||||
? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium" }
|
||||
? {
|
||||
type: "raster-dem",
|
||||
url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`,
|
||||
encoding: "terrarium",
|
||||
attribution: "Terrain: Mapterhorn",
|
||||
}
|
||||
: {
|
||||
type: "raster-dem",
|
||||
tiles: ONLINE_TERRAIN_TILES,
|
||||
|
||||
+101
-56
@@ -3,22 +3,37 @@ require "maptool/pmtiles_builder"
|
||||
require "tmpdir"
|
||||
require "fileutils"
|
||||
|
||||
# Network calls (the protomaps build index, individual Terrarium tile
|
||||
# fetches) are never exercised here — same "keep this offline/deterministic"
|
||||
# reasoning test_helper.rb already gives for /api/plugins staying off the
|
||||
# real Overpass API — build_offline/build_terrain take fetch_build_url/
|
||||
# fetch_tile precisely so tests can swap those out. A fake `pmtiles` CLI
|
||||
# (PMTILES_BIN) stands in for the real go-pmtiles binary.
|
||||
# Network calls (the protomaps build index, the mapterhorn manifest) are
|
||||
# never exercised here — same "keep this offline/deterministic" reasoning
|
||||
# test_helper.rb already gives for /api/plugins staying off the real
|
||||
# Overpass API — build_offline/build_terrain take fetch_build_url/
|
||||
# fetch_manifest precisely so tests can swap those out. A fake `pmtiles`
|
||||
# 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").
|
||||
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")
|
||||
|
||||
# $3 is the output path either way: `extract SOURCE OUTPUT --bbox=...
|
||||
# --maxzoom=...` and `convert INPUT OUTPUT` both put it 3rd, even though
|
||||
# it's not the *last* arg in the extract case (the --flags follow it).
|
||||
# Output path: $3 for `extract SOURCE OUTPUT --bbox=... --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")
|
||||
File.write(@pmtiles_ok, "#!/bin/sh\necho fake > \"$3\"\n")
|
||||
File.write(@pmtiles_ok, <<~SH)
|
||||
#!/bin/sh
|
||||
echo "$1 $2" >> "$PMTILES_LOG"
|
||||
case "$1" in
|
||||
merge)
|
||||
for last; do :; done
|
||||
echo fake > "$last"
|
||||
;;
|
||||
*)
|
||||
echo fake > "$3"
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
File.chmod(0o755, @pmtiles_ok)
|
||||
|
||||
@pmtiles_fail = File.join(@bin_dir, "pmtiles-fail")
|
||||
@@ -26,6 +41,7 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
File.chmod(0o755, @pmtiles_fail)
|
||||
|
||||
@original_pmtiles_bin = ENV["PMTILES_BIN"]
|
||||
ENV["PMTILES_LOG"] = @log
|
||||
Maptool::PmtilesBuilder.reset!
|
||||
end
|
||||
|
||||
@@ -33,9 +49,26 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
FileUtils.remove_entry(@data_dir)
|
||||
FileUtils.remove_entry(@bin_dir)
|
||||
ENV["PMTILES_BIN"] = @original_pmtiles_bin
|
||||
ENV.delete("PMTILES_LOG")
|
||||
Maptool::PmtilesBuilder.reset!
|
||||
end
|
||||
|
||||
def log_lines
|
||||
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 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 },
|
||||
]
|
||||
end
|
||||
|
||||
def test_offline_and_terrain_status_start_idle
|
||||
assert_equal({ state: "idle" }, Maptool::PmtilesBuilder.offline_status)
|
||||
assert_equal({ state: "idle" }, Maptool::PmtilesBuilder.terrain_status)
|
||||
@@ -100,65 +133,77 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
thread&.join
|
||||
end
|
||||
|
||||
# A tiny real-world bbox (a few blocks of central Bratislava) keeps the
|
||||
# z0..15 tile_range small at every zoom level, so this stays fast even
|
||||
# though it iterates all 16 levels.
|
||||
def test_build_terrain_packs_fetched_tiles_and_converts
|
||||
# A bbox nowhere near chunk-a/chunk-b — 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
|
||||
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||
thread = Maptool::PmtilesBuilder.build_terrain(
|
||||
[48.140, 17.100, 48.141, 17.101],
|
||||
data_dir: @data_dir,
|
||||
fetch_tile: ->(_z, _x, _y) { "not a real png, write_terrarium_mbtiles just stores opaque bytes" },
|
||||
)
|
||||
thread = Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
|
||||
thread.join
|
||||
|
||||
assert_path_exists File.join(@data_dir, "terrain.pmtiles")
|
||||
status = Maptool::PmtilesBuilder.terrain_status
|
||||
assert_equal "done", status[:state]
|
||||
assert_operator status[:tiles_total], :>, 0
|
||||
assert_equal status[:tiles_total], status[:tiles_done]
|
||||
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
|
||||
assert_equal ["extract https://example.com/planet.pmtiles"], log_lines
|
||||
end
|
||||
|
||||
# Every fetch_tile call blocks on a gate until explicitly told not to —
|
||||
# with CONCURRENT_TILE_FETCHES workers pulling from a shared queue, that
|
||||
# holds the whole pool at "in flight, none finished yet" deterministically
|
||||
# (a big enough bbox that z0..15 yields far more tiles than worker slots,
|
||||
# so most of the job is still queued up behind the blocked pool).
|
||||
def test_build_terrain_reports_progress_while_running
|
||||
# A bbox inside chunk-a's extent — planet.pmtiles (always) plus that one
|
||||
# chunk get extracted, then merged.
|
||||
def test_build_terrain_extracts_and_merges_a_single_intersecting_chunk
|
||||
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||
concurrency = Maptool::PmtilesBuilder::CONCURRENT_TILE_FETCHES
|
||||
gate = Queue.new
|
||||
unblocked = false
|
||||
fetch_tile = lambda do |_z, _x, _y|
|
||||
gate.pop unless unblocked
|
||||
"bytes"
|
||||
end
|
||||
thread = Maptool::PmtilesBuilder.build_terrain([47.5, 16.5, 48.5, 17.5], data_dir: @data_dir, fetch_tile: fetch_tile)
|
||||
thread = Maptool::PmtilesBuilder.build_terrain([48.1, 17.1, 48.2, 17.2], data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
|
||||
thread.join
|
||||
|
||||
# Let exactly one batch through (fills every worker's first call) then
|
||||
# check before releasing the rest — done should be capped at the pool
|
||||
# size, nowhere near the real total.
|
||||
concurrency.times { gate.push(nil) }
|
||||
sleep 0.05
|
||||
status = Maptool::PmtilesBuilder.terrain_status
|
||||
assert_equal "running", status[:state]
|
||||
assert_operator status[:tiles_total], :>, concurrency
|
||||
assert_operator status[:tiles_done], :<, status[:tiles_total]
|
||||
ensure
|
||||
unblocked = true
|
||||
concurrency.times { gate.push(nil) } # release whatever's still blocked in an in-progress gate.pop
|
||||
thread&.join
|
||||
assert_path_exists File.join(@data_dir, "terrain.pmtiles")
|
||||
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
|
||||
extracted = log_lines.select { |line| line.start_with?("extract ") }
|
||||
merged = log_lines.select { |line| line.start_with?("merge ") }
|
||||
assert_equal ["extract https://example.com/planet.pmtiles", "extract https://example.com/chunk-a.pmtiles"], extracted
|
||||
assert_equal 1, merged.size
|
||||
end
|
||||
|
||||
def test_build_terrain_still_succeeds_when_some_tiles_are_missing
|
||||
# A bbox spanning both chunk-a and chunk-b — planet + both chunks, one
|
||||
# 3-way merge.
|
||||
def test_build_terrain_merges_multiple_intersecting_chunks
|
||||
ENV["PMTILES_BIN"] = @pmtiles_ok
|
||||
thread = Maptool::PmtilesBuilder.build_terrain(
|
||||
[48.140, 17.100, 48.141, 17.101],
|
||||
data_dir: @data_dir,
|
||||
fetch_tile: ->(_z, _x, _y) { nil }, # every tile "missing" — should skip, not fail, the same way HillshadeControl's own source tolerates gaps
|
||||
)
|
||||
thread = Maptool::PmtilesBuilder.build_terrain([47.9, 16.9, 50.6, 20.6], data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
|
||||
thread.join
|
||||
|
||||
assert_equal "done", Maptool::PmtilesBuilder.terrain_status[:state]
|
||||
extracted = log_lines.count { |line| line.start_with?("extract ") }
|
||||
assert_equal 3, extracted # planet + chunk-a + chunk-b
|
||||
assert_equal 1, log_lines.count { |line| line.start_with?("merge ") }
|
||||
end
|
||||
|
||||
def test_build_terrain_reports_a_clear_error_when_the_manifest_is_missing_planet
|
||||
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.join
|
||||
|
||||
status = Maptool::PmtilesBuilder.terrain_status
|
||||
assert_equal "error", status[:state]
|
||||
assert_match(/missing planet\.pmtiles/, status[:message])
|
||||
end
|
||||
|
||||
def test_build_terrain_reports_an_error_status_on_non_zero_exit
|
||||
ENV["PMTILES_BIN"] = @pmtiles_fail
|
||||
thread = Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { fake_manifest })
|
||||
thread.join
|
||||
|
||||
refute_path_exists File.join(@data_dir, "terrain.pmtiles")
|
||||
status = Maptool::PmtilesBuilder.terrain_status
|
||||
assert_equal "error", status[:state]
|
||||
assert_match(/exited with status/, status[:message])
|
||||
end
|
||||
|
||||
def test_build_terrain_raises_when_one_is_already_running
|
||||
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 })
|
||||
|
||||
assert_raises(Maptool::PmtilesBuilder::BuildError) do
|
||||
Maptool::PmtilesBuilder.build_terrain([0, 0, 1, 1], data_dir: @data_dir, fetch_manifest: -> { "unused" })
|
||||
end
|
||||
ensure
|
||||
gate.push(fake_manifest)
|
||||
thread&.join
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user