Replace offline basemap pipeline with Planetiler, unify online/offline styling
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 2m43s

Offline tiles were built from Protomaps' own basemap schema, needing a
completely separate hand-tuned theme (protomaps-themes-base + OFFLINE_THEME)
from the online Liberty style's ONLINE_STYLE_PATCH — the two could never be
made to match exactly no matter how much either side got tuned (today's
session found and fixed several mismatches: building opacity, park
boundaries, road casings, road widths).

Offline.pmtiles is now built with Planetiler's default OpenMapTiles profile
against Geofabrik per-country OSM extracts (osmium-merged when more than one
country is selected), the same vector schema Liberty already expects.
offlineStyle() now clones the same Liberty JSON (vendored as a local
snapshot for reliability with no internet) and applies the identical
ONLINE_STYLE_PATCH, just repointed at local PMTiles/glyphs/sprite — one
palette instead of two, correct on both sides by construction.

Also vendors Liberty's icon sprite for the first time (offline had none
before), adds Java/osmium-tool/planetiler.jar to the Docker image, and
removes the now-unused protomaps-themes-base dependency and the
theme-comparison debug page that found today's bugs.
This commit is contained in:
randy committed 2026-09-11 20:52:11 +02:00
1 parent 0874e8f8ed
commit d08ed41185
21 files changed
+4286 -512

No files matched your search

+90 -112
View File
@@ -28,29 +28,46 @@ const CENTER = [17.1077, 48.1486];
const ZOOM = 13;
const ONLINE_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
// Recolors OpenFreeMap Liberty to the same NPS-inspired palette as
// OFFLINE_THEME below — warm cream ground, muted roads/boundaries — mapped
// onto Liberty's OpenMapTiles layer ids instead of protomaps' theme roles
// (the two schemas don't share layer names, so this is a separate
// hand-built table, not a shared object). Only the layers that dominate the
// map's overall color read are touched; labels, halos, rail, and minor
// overlays (cemetery/hospital/school/pitch/track shading, building
// outlines) are left at Liberty's own defaults — same "not a full
// recolor" scope as OFFLINE_THEME. Road casings have no protomaps-role
// equivalent to reuse (offline roads don't get a separate outline), so
// #c9a66b there is reasoned to fit the family rather than sourced from
// anywhere real.
// Recolors OpenFreeMap Liberty to a warm, NPS-Park-Tiles-inspired palette —
// cream ground, muted roads/boundaries — mapped onto Liberty's OpenMapTiles
// layer ids. Applied identically regardless of which PMTiles data actually
// backs the "openmaptiles" source (see offlineStyle() below): offline tiles
// are built by Planetiler's own OpenMapTiles profile (docs/development.adoc),
// the same schema Liberty already expects, so one patch serves both —
// no separate offline theme/palette to keep in sync. Only the layers that
// dominate the map's overall color read are touched; labels, halos, rail,
// and minor overlays (cemetery/hospital/school/pitch/track shading,
// building outlines) are left at Liberty's own defaults.
//
// wood/grass are deliberately left unpatched — Liberty's own green reads
// better than this app's earlier brown/khaki recolor, and OFFLINE_THEME's
// wood_a/scrub_a below were changed to match Liberty's green instead.
// wood/grass are deliberately left unpatched — Liberty's own green (a
// translucent landcover_wood/landcover_grass wash) already reads well over
// the patched cream background.
const ONLINE_STYLE_PATCH = {
background: { "background-color": "#f9f6ef" },
// Liberty washes every built-up area in a translucent gray-tan below
// z12 (landuse_residential, maxzoom 12 — gone by this app's default
// z13) that the offline schema has no equivalent of; protomaps just
// leaves that ground as plain background/earth. Matched to background
// here rather than left alone, so a zoomed-out view doesn't read as
// visibly dingier online than offline.
landuse_residential: { "fill-color": "#f9f6ef" },
landcover_sand: { "fill-color": "#f0e4c4" },
water: { "fill-color": "#a3cedf" },
park: { "fill-color": "#b3e378" },
park_outline: { "line-color": "#546619" },
building: { "fill-color": "#ded7cc" },
// fill-outline-color isn't touched by a plain fill-color patch — Liberty
// draws it separately, defaulting to a bright green that reads as a
// distinct border around named conservation areas (e.g. the Malé
// Karpaty CHKO). Matched to the fill itself, and park_outline's own
// dashed line hidden below, so a reserve blends in as ordinary forest —
// offline has no equivalent stroke at all (see park_a/park_b below),
// and the point was never to call reserves out as visibly different,
// on either side.
park: { "fill-color": "#b3e378", "fill-outline-color": "#b3e378" },
park_outline: { "line-opacity": 0 },
// fill-opacity: 0.5 matches the offline theme's own "buildings" layer —
// without it, Liberty's dense individual building footprints (each with
// its own outline) paint fully opaque and read much heavier/grayer than
// offline's softer, half-transparent fill over the same cream ground.
building: { "fill-color": "#ded7cc", "fill-opacity": 0.5 },
boundary_2: { "line-color": "#546619" },
boundary_3: { "line-color": "#546619" },
@@ -58,19 +75,30 @@ const ONLINE_STYLE_PATCH = {
// minor (residential/service/track), pedestrian — each repeated for its
// tunnel_/bridge_ counterpart so a road doesn't change color when it
// dips into a tunnel or crosses a bridge. Rail is left alone.
road_motorway: { "line-color": "#fdefbe" },
road_trunk_primary: { "line-color": "#fdefbe" },
road_secondary_tertiary: { "line-color": "#fef7dd" },
road_link: { "line-color": "#fef7dd" },
road_motorway_link: { "line-color": "#fef7dd" },
//
// line-width below is a flat number, not Liberty's own zoom-interpolated
// expression — picked to match the offline theme's actual rendered width
// at this app's default zoom (13; verified live via queryRenderedFeatures
// on both sides), since without it Liberty's own curve draws surface
// roads roughly twice as thick as offline at street level, reading as a
// much bolder road network online.
// ponytail: tuned for z13 only, not a matching curve across all zooms —
// will drift back apart at very different zoom levels. Upgrade path:
// sample offline's width at a few more zooms and replace with a matching
// ["interpolate", ["linear"], ["zoom"], ...] expression.
road_motorway: { "line-color": "#fdefbe", "line-width": 2.26 },
road_trunk_primary: { "line-color": "#fdefbe", "line-width": 2.26 },
road_secondary_tertiary: { "line-color": "#fef7dd", "line-width": 1.87 },
road_link: { "line-color": "#fef7dd", "line-width": 1 },
road_motorway_link: { "line-color": "#fef7dd", "line-width": 1 },
road_minor: { "line-color": "#fffdf8" },
road_service_track: { "line-color": "#fffdf8" },
road_path_pedestrian: { "line-color": "#e9e2d8" },
road_motorway_casing: { "line-color": "#c9a66b" },
road_trunk_primary_casing: { "line-color": "#c9a66b" },
road_secondary_tertiary_casing: { "line-color": "#c9a66b" },
road_link_casing: { "line-color": "#c9a66b" },
road_motorway_link_casing: { "line-color": "#c9a66b" },
road_motorway_casing: { "line-color": "#c9a66b", "line-width": 1.48 },
road_trunk_primary_casing: { "line-color": "#c9a66b", "line-width": 1.48 },
road_secondary_tertiary_casing: { "line-color": "#c9a66b", "line-width": 1 },
road_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 },
road_motorway_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 },
road_minor_casing: { "line-color": "#c9a66b" },
road_service_track_casing: { "line-color": "#c9a66b" },
@@ -161,95 +189,45 @@ async function loadOnlineStyle() {
const ONLINE_STYLE = await loadOnlineStyle();
// A vendored copy of the same Liberty style ONLINE_STYLE fetches live
// (public/vendor/liberty-style/liberty.json — a point-in-time snapshot, not
// auto-updated) that offlineStyle() below patches+repoints the exact same
// way. Offline mode can't depend on ONLINE_STYLE_URL actually being
// reachable — that fetch failing/timing out is the normal case in the
// field, not an edge case — so this is a local, same-origin fetch instead
// (fast and always available, since it's served by this app itself).
const OFFLINE_STYLE_TEMPLATE = await fetch("/vendor/liberty-style/liberty.json").then((response) => response.json());
const protocol = new pmtiles.Protocol();
addProtocol("pmtiles", protocol.tile);
// Nudged from protomaps-themes-base's stock "light" theme towards the
// National Park Service's own "Park Tiles" cartography — a warm,
// Harpers-Ferry-Center-derived look. Not their actual map: a handful of
// values below (marked) are the genuine paint colors pulled from a public
// Park Tiles style JSON
// (nps.gov/lib/npmap.js/style/ck72fwp2642dv07o7tbqinvz4.json, the "Imagery"
// variant — the only one of their four styles served from a plain,
// unauthenticated URL; "Standard"/"Slate"/"Light" resolve through Mapbox
// Studio's private API with an access token, which isn't something to
// piggyback on even though it's technically visible in their own JS bundle).
// The rest are reasoned to match that same family (warm cream ground, olive
// greens, muted blue) rather than sourced. Not a full recolor: labels,
// casings, and roles with no clean real-color anchor are left at theme
// defaults.
// Builds the offline style from OFFLINE_STYLE_TEMPLATE — the identical
// Liberty JSON + ONLINE_STYLE_PATCH recolor as the online map, just
// repointed at a local PMTiles source instead of tiles.openfreemap.org.
// This works because data/offline.pmtiles is now built by Planetiler's own
// OpenMapTiles profile (see docs/development.adoc), the same vector schema
// Liberty expects — before that, offline tiles used a different schema
// (protomaps' own basemaps) needing a completely separate hand-tuned
// theme (protomaps-themes-base + OFFLINE_THEME) that could never be made
// to match ONLINE_STYLE_PATCH's look exactly, no matter how much either
// side got tuned.
//
// Recreating the color choices is legitimate — that's not the kind of thing
// a data license restricts (see docs/architecture.adoc's "Offline maps"
// section) — what stays off-limits is their actual tile *service*: its
// OSM/terrain layers are licensed through Mapbox Atlas, a commercial,
// non-sublicensable data service scoped to NPS.
const OFFLINE_THEME = {
...protomaps_themes_base.namedTheme("light"),
background: "#f9f6ef", // real: Park Tiles' own background, hsl(40, 37%, 97%)
earth: "#f9f6ef",
water: "#a3cedf",
// Matches OpenFreeMap Liberty's own wood/grass green (its landcover_wood
// hsla(98,61%,72%,0.7) and landcover_grass rgba(176,213,154,1) at 0.3
// opacity, blended over the cream background) — the online map's actual
// look whenever ONLINE_STYLE_PATCH's brown/khaki recolor fails to apply
// (see loadOnlineStyle's fetch fallback) turned out to be the look this
// app's users actually want, so offline was changed to match it instead
// of chasing the patch's own reliability.
wood_a: "#c3e8ab",
wood_b: "#a8d888",
scrub_a: "#dcecc9",
scrub_b: "#cfe3b8",
// Close to wood_b, not the more saturated Park Tiles green: protomaps'
// theme routes "forest"/"nature_reserve"/"protected_area" (e.g. the Malé
// Karpaty CHKO boundary) through this same park_a/park_b pair as actual
// named parks — Liberty's online style doesn't make that reserve-boundary
// distinction at all, so a visibly different green here reads as an
// offline-only blotch rather than a deliberate distinction.
park_a: "#c3e8ab",
park_b: "#a8d888",
sand: "#f0e4c4",
beach: "#f0e4c4",
buildings: "#ded7cc",
pedestrian: "#e9e2d8",
highway: "#fdefbe", // real: Park Tiles' primary/highway road color
major: "#fef7dd", // real: Park Tiles' secondary road color
// "minor" (singular) is never actually read by protomaps' own paint
// expressions — only the zoom-interpolated minor_a/minor_b pair is, so a
// plain "minor" key here was dead: ordinary streets were silently
// falling back to the base theme's default pale yellow instead of this
// app's cream.
minor_a: "#fffdf8",
minor_b: "#fffdf8",
link: "#fef7dd",
// Distinct brown, not cream: protomaps' "other"/"path" kind is what a
// camper actually needs called out — unpaved tracks and footpaths,
// separate from paved minor_road/service (minor_service, left at the
// base theme's default since it's real driveways/parking aisles, not
// unpaved). Already rendered dashed by the theme itself; this just picks
// a real "dirt" tone instead of unstyled white.
other: "#c9a66b",
boundaries: "#546619", // real: Park Tiles' park-boundary line color
};
// glyphs/sprite point at vendored local copies (public/vendor/fonts,
// public/vendor/liberty-sprite) rather than tiles.openfreemap.org's —
// glyphs are Latin-only (see docs/architecture.adoc: labels in other
// scripts won't render offline), sprite is a one-time snapshot like
// OFFLINE_STYLE_TEMPLATE itself. ne2_shaded (Liberty's low-zoom shaded-
// relief backdrop, z0-6 only) is left pointing at its remote URL
// unchanged — offline usage is regional, not whole-earth zoomed out, so
// this only ever silently fails to load at a zoom level offline users
// aren't in, the same graceful-degradation story as this app's other
// online-only extras (Wikipedia, live radar).
function offlineStyle() {
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
return {
version: 8,
glyphs: `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`,
sources: {
offline: {
type: "vector",
url: `pmtiles://${pmtilesUrl}`,
attribution: "© OpenStreetMap contributors",
},
},
// lang: "en" pulls in labels_layers (see docs/architecture.adoc) — only
// the Latin-script glyph ranges are vendored, so labels using other
// scripts won't render offline.
layers: protomaps_themes_base.layers("offline", OFFLINE_THEME, { lang: "en" }),
};
const style = patchOnlineStyle(structuredClone(OFFLINE_STYLE_TEMPLATE));
style.sources.openmaptiles.url = `pmtiles://${window.location.origin}/tiles/offline.pmtiles`;
style.glyphs = `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`;
style.sprite = `${window.location.origin}/vendor/liberty-sprite/ofm`;
return style;
}
// A sidebar icon that opens a full-height side panel (like the GPX one)
+8 -8
View File
@@ -37,13 +37,13 @@ function flagIconUrl(code) {
return `/vendor/flag-icons/1x1/${code.toLowerCase()}.svg`;
}
// meta.source is a Protomaps build filename like "20260910.pmtiles" — the
// leading 8 digits are its build date.
// meta.sources is one entry per country the current build covers (see
// lib/maptool/pmtiles_builder.rb) — listing the codes is more useful here
// than a count, since they're exactly what RegionPicker's checklist above
// already shows.
function formatOfflineMeta(meta) {
if (!meta) return "";
const match = meta.source.match(/^(\d{4})(\d{2})(\d{2})/);
const date = match ? `${match[1]}-${match[2]}-${match[3]}` : meta.source;
return ` · Protomaps ${date}`;
return ` · ${meta.sources.map((s) => s.code).join(", ")}`;
}
function formatTerrainMeta(meta) {
@@ -172,9 +172,9 @@ class DataFileRow {
}
// Separate from _refreshStatus's own fetch (and only called once, right
// after — not polled): this one reaches an external service (Protomaps'
// build index or Mapterhorn's manifest, see
// lib/maptool/pmtiles_builder.rb), so it's kept off the hot status path
// after — not polled): this one reaches an external service (Geofabrik
// or Mapterhorn's manifest, see lib/maptool/pmtiles_builder.rb), so it's
// kept off the hot status path
// and just silently skipped if it fails, same as everywhere else this
// app degrades gracefully when an optional network check doesn't answer.
async _checkUpdate() {