import { Map as MaplibreMap, NavigationControl, GeolocateControl, Popup, addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { RadarControl } from "/js/radar.js"; import { GpxPanelControl } from "/js/gpx.js"; import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { LAYERS_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; const CENTER = [17.1077, 48.1486]; const ZOOM = 13; const ONLINE_STYLE = "https://tiles.openfreemap.org/styles/liberty"; 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", // Brown/khaki, not green: in Park Tiles' own Imagery style, green is // specifically the protected-area/park color (below), not a general land // cover fill — actual forest there is just the aerial photo underneath, // which this offline style has no equivalent of. Using that same vivid // green for ordinary wood/scrub as well (an earlier version of this // theme did) made everything read as "green," not "brown with green // parks" — reversed here to match the real distinction. wood_a: "#c9bd9c", wood_b: "#bcae8a", scrub_a: "#d9cfb0", scrub_b: "#cfc29d", park_a: "#c8e2a0", park_b: "#b3e378", // real: Park Tiles' protected-area/park-point green 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: "#fffdf8", link: "#fef7dd", 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; } } // 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"; } } const map = new MaplibreMap({ container: "map", style: ONLINE_STYLE, center: CENTER, zoom: ZOOM, // MapLibre's built-in Hash control: keeps #map=zoom/lat/lon in the URL in // sync with the view (via history.replaceState, so panning doesn't spam // the back button) and, if the page loads with one already in the URL, // opens there instead of the default CENTER/ZOOM — the same "grab the URL // to share this exact spot" permalink pattern OSM/Google Maps use. hash: "map", }); map.addControl( new GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, showAccuracyCircle: true, }), "bottom-right", ); // A compact top-left stack — Home, zoom, base map — matching the National // Park Service's own map control layout. Added in this order since // MapLibre stacks same-position controls in add order. // // homePosition/zoom pinned to the app's own default rather than left to the // button's own fallback (current center/zoom at construction) — now that // the URL hash can restore any view on load (see the Hash option above), // "current view" and "the default view" are no longer the same thing, and // a home button that resets to wherever you happened to load the page // wouldn't be a home button. This is *not* "go to my location" — that's // GeolocateControl, above. map.addControl(new HomeButton({ homePosition: CENTER, zoom: ZOOM }), "top-left"); // showCompass: false — this map never rotates, so a bearing-reset button // (NavigationControl's default third button) would just be dead weight. map.addControl(new NavigationControl({ showCompass: false }), "top-left"); const sidebar = document.getElementById("sidebar"); const layerToggle = new LayerToggleControl(applyStyle); layerToggle.mount(sidebar); layerToggle.setActive("online"); // The sidebar already has a "Base map" icon (opens the same panel) — // this is a second entry point for it, grouped with Home/zoom on the map // itself rather than off in the page's own side rail, matching NPS's // layout. Added after mount() since it reuses that panel/toggle state. const mapLayersButton = new MapLayersButton(layerToggle); map.addControl(mapLayersButton, "top-left"); const profilePanel = new TrackProfilePanel(map); profilePanel.mount(); new GpxPanelControl(map, profilePanel).mount(sidebar); new RadarControl(map).mount(sidebar); new GnssControl(map).mount(sidebar); new LiveTrackPanelControl(map).mount(sidebar); function applyStyle(name) { layerToggle.setActive(name); map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE); } // If a local PMTiles file is present, prefer it as the default style — its // presence signals the app is meant to be used offline here. If one isn't, // the Online/Offline switch itself doesn't make sense (there's nothing to // switch to), so it's removed entirely rather than left sitting there // offering a choice that can't work. fetch("/api/offline-tiles") .then((response) => response.json()) .then(({ available }) => { if (available) { applyStyle("offline"); } else { layerToggle.remove(); map.removeControl(mapLayersButton); } }) .catch(() => { // Backend check failed; keep the online style and leave the switch as // is rather than guessing — this is the same-origin request itself // failing, not evidence offline tiles are actually unavailable. }); // Alt+click anywhere shows every rendered feature at that point — base map // (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it — // with its raw properties, inspired by NPMap4's infoboxControl (a generic // "show me everything here" debug view, kept separate from the curated // popups those same features already have via their own layer-specific // click handlers, e.g. gpx.js's waypoint popups — both can fire on the same // click, which is fine, this is a power-user/debugging affordance, not // something replacing the normal popups). No dedicated UI for this // deliberately — a keyboard-modified click needs no button, state, or // cursor change of its own. map.on("click", (event) => { if (!event.originalEvent.altKey) return; const features = map.queryRenderedFeatures(event.point); if (features.length === 0) return; const html = features .map((feature) => { const rows = Object.entries(feature.properties) .map(([key, value]) => `${escapeHtml(key)}${escapeHtml(String(value))}`) .join(""); return `
${escapeHtml(feature.layer.id)}${rows || ""}
(no properties)
`; }) .join(""); new Popup({ maxWidth: "320px", className: "debug-popup" }).setLngLat(event.lngLat).setHTML(html).addTo(map); }); // Feature properties can come straight from an uploaded GPX/FIT file (an // untrusted source) via this app's own layers, not just the base map, so // every value is escaped the same way gpx.js's popups already do. function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; }