Home now fitBounds()s a country-wide bbox instead of resetting to CENTER/ZOOM's Bratislava point, since Home resetting into the same city you'd already be looking at wasn't useful. The overview map is NPS's own overview plugin (npmap5-plugins, still on an unmerged branch), vendored and converted from its UMD dist build to a plain ES module. Patched one MapLibre API break found while verifying it: the plugin reads map.transform (public in the MapLibre ~2.4 it targets) to relax the inset's polar pan limit; MapLibre 6 (this app's vendored version) no longer exposes .transform at all, so it threw on load. Guarded to skip that cosmetic adjustment when absent instead.
362 lines
14 KiB
JavaScript
362 lines
14 KiB
JavaScript
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 { MeasureControl } from "/js/measure.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";
|
|
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 = "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 = `<button type="button" class="sidebar-icon" title="Base map" aria-label="Base map">${LAYERS_ICON}</button>`;
|
|
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";
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// <button> (no hover/focus affordance implying it does something).
|
|
class ZoomLevelControl {
|
|
onAdd(map) {
|
|
this._map = map;
|
|
this._container = document.createElement("div");
|
|
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group zoom-level-ctrl";
|
|
this._label = document.createElement("div");
|
|
this._label.className = "zoom-level-ctrl__value";
|
|
this._container.appendChild(this._label);
|
|
|
|
this._update = () => {
|
|
this._label.textContent = Math.round(map.getZoom());
|
|
};
|
|
this._update();
|
|
map.on("zoom", this._update);
|
|
|
|
return this._container;
|
|
}
|
|
|
|
onRemove() {
|
|
this._map.off("zoom", this._update);
|
|
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",
|
|
});
|
|
|
|
// A compact top-left stack — Home, zoom, zoom-level readout, measure,
|
|
// locate-me, base map — matching the National Park Service's own map
|
|
// control layout,
|
|
// all grouped together rather than split across corners. Added in this
|
|
// order since MapLibre stacks same-position controls in add order.
|
|
//
|
|
// bounds (rather than homePosition/zoom) so Home resets to an overview of
|
|
// the whole country, not back to CENTER/ZOOM's initial Bratislava view —
|
|
// 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, below.
|
|
const SLOVAKIA_BOUNDS = [
|
|
[16.83, 47.73],
|
|
[22.57, 49.61],
|
|
];
|
|
map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "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");
|
|
map.addControl(new ZoomLevelControl(), "top-left");
|
|
map.addControl(new MeasureControl(), "top-left");
|
|
map.addControl(
|
|
new GeolocateControl({
|
|
positionOptions: { enableHighAccuracy: true },
|
|
trackUserLocation: true,
|
|
showAccuracyCircle: true,
|
|
}),
|
|
"top-left",
|
|
);
|
|
|
|
// A small inset map showing where the current view sits in context — NPS's
|
|
// own overview plugin (docs/nps-resources.adoc's "overview" entry, still on
|
|
// an unmerged branch upstream). It draws its own embedded MapLibre map, so
|
|
// it needs the maplibregl module itself (not just the Map class) — style
|
|
// '' (the default) means it clones whatever the main map's current style
|
|
// is (`this._mainMap.style.serialize()`), so it stays in sync with the
|
|
// Online/Offline toggle without a second style to maintain here. Built only
|
|
// once the main map's own style has loaded, matching the plugin's own
|
|
// example — its internal map is constructed eagerly in the constructor,
|
|
// before that clone happens in onAdd.
|
|
map.once("style.load", () => {
|
|
const OverviewMapControl = OverviewMap(maplibregl);
|
|
map.addControl(new OverviewMapControl({}), "top-right");
|
|
});
|
|
|
|
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]) => `<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(String(value))}</td></tr>`)
|
|
.join("");
|
|
return `<div class="debug-popup__feature"><strong>${escapeHtml(feature.layer.id)}</strong><table>${rows || "<tr><td>(no properties)</td></tr>"}</table></div>`;
|
|
})
|
|
.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;
|
|
}
|