import { Map as MaplibreMap, NavigationControl, Popup, addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { RadarControl } from "/js/radar.js"; import { GpxPanelControl, setKiwixConfig } from "/js/gpx.js"; import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { MeasureControl } from "/js/measure.js"; import { NotesControl } from "/js/notes.js"; import { PluginsControl } from "/js/plugins.js"; import { LocateControl } from "/js/locate.js"; import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { isLoggedIn } from "/js/auth.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; import OverviewMap from "/vendor/npmap-overview/overview.esm.js"; import * as maplibregl from "/vendor/maplibre-gl/maplibre-gl.mjs"; 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. // // 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. const ONLINE_STYLE_PATCH = { background: { "background-color": "#f9f6ef" }, landcover_sand: { "fill-color": "#f0e4c4" }, water: { "fill-color": "#a3cedf" }, park: { "fill-color": "#b3e378" }, park_outline: { "line-color": "#546619" }, building: { "fill-color": "#ded7cc" }, boundary_2: { "line-color": "#546619" }, boundary_3: { "line-color": "#546619" }, // Highway (motorway/trunk/primary), major (secondary/tertiary/link), // 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" }, 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_minor_casing: { "line-color": "#c9a66b" }, road_service_track_casing: { "line-color": "#c9a66b" }, tunnel_motorway: { "line-color": "#fdefbe" }, tunnel_trunk_primary: { "line-color": "#fdefbe" }, tunnel_secondary_tertiary: { "line-color": "#fef7dd" }, tunnel_link: { "line-color": "#fef7dd" }, tunnel_motorway_link: { "line-color": "#fef7dd" }, tunnel_minor: { "line-color": "#fffdf8" }, tunnel_service_track: { "line-color": "#fffdf8" }, tunnel_path_pedestrian: { "line-color": "#e9e2d8" }, tunnel_motorway_casing: { "line-color": "#c9a66b" }, tunnel_trunk_primary_casing: { "line-color": "#c9a66b" }, tunnel_secondary_tertiary_casing: { "line-color": "#c9a66b" }, tunnel_link_casing: { "line-color": "#c9a66b" }, tunnel_motorway_link_casing: { "line-color": "#c9a66b" }, tunnel_street_casing: { "line-color": "#c9a66b" }, bridge_motorway: { "line-color": "#fdefbe" }, bridge_trunk_primary: { "line-color": "#fdefbe" }, bridge_secondary_tertiary: { "line-color": "#fef7dd" }, bridge_link: { "line-color": "#fef7dd" }, bridge_motorway_link: { "line-color": "#fef7dd" }, bridge_street: { "line-color": "#fffdf8" }, bridge_service_track: { "line-color": "#fffdf8" }, bridge_path_pedestrian: { "line-color": "#e9e2d8" }, bridge_motorway_casing: { "line-color": "#c9a66b" }, bridge_trunk_primary_casing: { "line-color": "#c9a66b" }, bridge_secondary_tertiary_casing: { "line-color": "#c9a66b" }, bridge_link_casing: { "line-color": "#c9a66b" }, bridge_motorway_link_casing: { "line-color": "#c9a66b" }, bridge_street_casing: { "line-color": "#c9a66b" }, bridge_path_pedestrian_casing: { "line-color": "#c9a66b" }, }; function patchOnlineStyle(style) { for (const layer of style.layers) { const patch = ONLINE_STYLE_PATCH[layer.id]; if (patch) Object.assign((layer.paint ??= {}), patch); } return insertTrackOnlyLayer(style); } // Liberty's own "road_service_track" layer paints service roads (paved // driveways/parking aisles) and actual unpaved tracks the same color — // fine for a general-purpose map, not for this app's "can the camper get // there" use case. The underlying OpenMapTiles data does carry `class: // "track"` separately from `"service"` (see the filter below), so rather // than recoloring the merged layer (which would mislabel paved driveways // as unpaved), this clones it into a second layer scoped to `class == // "track"` only, dashed and dirt-brown — matching how the offline theme // already renders protomaps' own separate "other"/"path" road kind. // Surface-level only (excludes tunnel_/bridge_ variants): an unpaved track // in a tunnel or on a bridge is rare enough not to be worth a third layer. function insertTrackOnlyLayer(style) { const index = style.layers.findIndex((layer) => layer.id === "road_service_track"); if (index === -1) return style; const base = style.layers[index]; const trackOnly = { ...base, id: "road_track_only", filter: ["all", base.filter, ["==", ["get", "class"], "track"]], paint: { ...base.paint, "line-color": "#c9a66b", "line-dasharray": [2, 2] }, }; style.layers.splice(index + 1, 0, trackOnly); return style; } // Fetched (not just referenced by URL) so its colors can be patched before // the map ever sees it — MapLibre accepts a style object anywhere it'd // accept a style URL, so every other use of ONLINE_STYLE (applyStyle's // map.setStyle) needs no change. This runs before the map — and the rest // of the page's setup — is constructed (see the top-level await below), so // it falls back to the plain URL, letting MapLibre fetch and render it // unpatched, both when the fetch fails outright and when it's just slow // (a 3s timeout): this app is meant to work offline in the field, and // blocking the entire page on a remote style server would undermine that // even though this particular fetch is only for the online map. async function loadOnlineStyle() { try { const response = await fetch(ONLINE_STYLE_URL, { signal: AbortSignal.timeout(3000) }); return patchOnlineStyle(await response.json()); } catch (err) { return ONLINE_STYLE_URL; } } const ONLINE_STYLE = await loadOnlineStyle(); 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. // // 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 }; 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" }), }; } // A sidebar icon that opens a full-height side panel (like the GPX one) // with the Online/Offline choice — modelled after OsmAnd's "Configure map" // panel, rather than a small dropdown flyout. class LayerToggleControl { constructor(onSelect) { this._onSelect = onSelect; } mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; this._container.innerHTML = ``; this._icon = this._container.querySelector(".sidebar-icon"); this._icon.addEventListener("click", () => this._togglePanel()); this._panel = document.getElementById("basemap-panel"); this._panel .querySelector(".basemap-panel__close") .addEventListener("click", () => this._togglePanel(false)); this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => { button.addEventListener("click", () => { this._onSelect(button.dataset.style); this._togglePanel(false); }); }); onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); sidebar.appendChild(this._container); } _togglePanel(force) { const open = force === undefined ? this._panel.hidden : force; this._panel.hidden = !open; this._icon.classList.toggle("active", open); if (open) notifySidePanelOpen(this._panel); } // Public wrapper so a second entry point (MapLayersButton, a map corner // control) can open/close the same panel without reaching into a // "private" method by convention only. toggle() { this._togglePanel(); } setActive(name) { this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => { button.classList.toggle("active", button.dataset.style === name); }); } // Called once it's known there's no offline map to switch to (see the // /api/offline-tiles check below) — an Online/Offline switch doesn't make // sense to show at all when Offline was never going to work. remove() { this._container.remove(); this._panel.hidden = true; } } const HILLSHADE_ID = "hillshade-dem"; const COLOR_RELIEF_ID = "elevation-tint"; // Hypsometric tint reusing this app's own palette (wood_a's green at // valley floor, the road casing brown through the mid-slopes) rather than // a generic rainbow ramp, so it reads as part of the same map rather than // a bolted-on overlay. Stops picked for Central European terrain — the // Tatras top out around 2600m — not a general-purpose world ramp. const ELEVATION_COLOR_RAMP = [ "interpolate", ["linear"], ["raster-value"], 0, "#c3e8ab", 400, "#dcd08f", 900, "#c9a66b", 1600, "#a89a86", 2600, "#f9f6ef", ]; // Public, unauthenticated, no API key — the same open (SRTM/ArcticDEM-derived) // elevation tiles OpenStreetMap's own hillshade layer and many other free // tools use. Only reachable online; while the offline basemap is active this // is skipped in favor of a local terrain.pmtiles if one's been generated // (see docs/development.adoc), same optional-local-file story as // offline.pmtiles itself. const ONLINE_TERRAIN_TILES = ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"]; // A simple on/off sidebar toggle, not a panel — there's nothing to choose, // just whether the shading shows. class HillshadeControl { constructor(map) { this._map = map; this._active = false; this._offlineAvailable = false; // setStyle() (switching Online/Offline) tears down all custom // sources/layers — put hillshade back if it was on. map.on("style.load", () => { if (this._active) this._addLayer(); }); } mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; this._container.innerHTML = ``; this._button = this._container.querySelector(".sidebar-icon"); this._button.addEventListener("click", () => this._toggle()); sidebar.appendChild(this._container); } setOfflineAvailable(available) { this._offlineAvailable = available; } _toggle() { this._active = !this._active; this._button.classList.toggle("active", this._active); this._button.setAttribute("aria-pressed", String(this._active)); if (this._active) this._addLayer(); else this._removeLayer(); } _addLayer() { if (this._map.getSource(HILLSHADE_ID)) return; const source = this._offlineAvailable ? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium" } : { type: "raster-dem", tiles: ONLINE_TERRAIN_TILES, tileSize: 256, encoding: "terrarium", maxzoom: 15, attribution: "Terrain tiles: Mapzen, SRTM, ArcticDEM", }; this._map.addSource(HILLSHADE_ID, source); // Elevation tint under, hillshade on top: the tint sets the base color // per elevation band, hillshade multiplies slope-shading over it — the // combined "characteristic relief look" the TODO asked for, not two // independent layers. this._map.addLayer({ id: COLOR_RELIEF_ID, type: "color-relief", source: HILLSHADE_ID, paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 }, }); this._map.addLayer({ id: HILLSHADE_ID, type: "hillshade", source: HILLSHADE_ID, paint: { "hillshade-exaggeration": 0.4 } }); } _removeLayer() { if (this._map.getLayer(HILLSHADE_ID)) this._map.removeLayer(HILLSHADE_ID); if (this._map.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_ID); if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID); } } // A small map-corner button (grouped with Home/zoom, see below) that's a // second entry point to the same panel LayerToggleControl already manages // in the page's own sidebar — matching the National Park Service's own // map control layout, which keeps zoom/home/base-map together on the map // rather than split between the map and a separate side rail. class MapLayersButton { constructor(layerToggle) { this._layerToggle = layerToggle; } onAdd() { this._container = document.createElement("div"); this._container.className = "maplibregl-ctrl maplibregl-ctrl-group"; const button = document.createElement("button"); button.type = "button"; button.title = "Base map"; button.setAttribute("aria-label", "Base map"); button.innerHTML = LAYERS_ICON; button.addEventListener("click", () => this._layerToggle.toggle()); this._container.appendChild(button); return this._container; } onRemove() { this._container.remove(); } getDefaultPosition() { return "top-left"; } } // A passive zoom-level readout, sitting in the same stack as the buttons // around it — the National Park Service's own control layout has one (the // small numbered square next to their zoom buttons). Not a button: no // click behavior, just a live display, so it's a plain div rather than a //