Files
maptool/public/js/map.js
T
randy 170af80cac
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 18s
Add a Home button and fill icon gaps from NPS's newer symbol library
Two things pulled in from github.com/nationalparkservice/npmap5-plugins:

- A "Home" button (public/vendor/npmap-home-button/, NPS's own MapLibre
  control) pinned to the app's CENTER/ZOOM, sitting top-left on the map. Now
  that the URL hash permalink can open the map at any view, "current view"
  and "the default view" aren't the same thing anymore, so a quick way back
  to the default is worth having.

- npmap5-plugins bundles a newer, actively-maintained version of the same
  NPS symbol-library already used for POI_ICONS (confirmed by its
  maintainer to be the designated replacement for the older standalone
  repo: nationalparkservice/npmap5-plugins#4), with a much larger icon
  vocabulary and a simpler CC0 (public domain) license. All 25 icons
  already in use came back byte-identical from the new source, so this
  just repoints their attribution — but it also has icons the old repo
  didn't: `forest` (POI_ICONS) is now the official "wilderness" pictogram
  instead of hand-drawn, and three of the five previously icon-less
  Services/Activities tags (wifi, mobile data coverage, winter/snow
  camping) now have real icons too. Only bakery and vehicle wash still
  have no NPS equivalent.
2026-09-07 19:45:19 +02:00

182 lines
6.4 KiB
JavaScript

import {
Map as MaplibreMap,
NavigationControl,
GeolocateControl,
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);
// A few color roles nudged from protomaps-themes-base's stock "light" theme
// towards the online OpenFreeMap "liberty" style's actual palette (sampled
// from its style JSON — see docs/architecture.adoc), so switching between
// Online/Offline doesn't look like two different apps. Not a full recolor:
// most roles (labels, casings, minor terrain fills, ...) are left at their
// theme defaults.
const OFFLINE_THEME = {
...protomaps_themes_base.namedTheme("light"),
background: "#f8f4f0",
earth: "#f8f4f0",
water: "#9ebdff",
wood_a: "#ace38c",
wood_b: "#ace38c",
buildings: "#dcd9d6",
highway: "#f2934a",
major: "#ffeeaa",
link: "#ffeeaa",
};
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);
}
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 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 NavigationControl(), "bottom-right");
map.addControl(
new GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showAccuracyCircle: true,
}),
"bottom-right",
);
// 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.
map.addControl(new HomeButton({ homePosition: CENTER, zoom: ZOOM }), "top-left");
const sidebar = document.getElementById("sidebar");
const layerToggle = new LayerToggleControl(applyStyle);
layerToggle.mount(sidebar);
layerToggle.setActive("online");
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();
}
})
.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.
});