Files
maptool/public/js/map.js
T
randy b6a9c1546e
CI / check (push) Successful in 46s
CI / deploy (push) Successful in 15s
Add a distance/area measuring tool
A ruler button in the top-left control stack, matching NPS's own
Area/Distance ruler. Click to add vertices; distance sums haversine
segment lengths, area uses the spherical-excess polygon formula
(same one turf.js's area() uses) rather than a flat shoelace, since a
flat approximation drifts at park-boundary scale. SI units only
(m/km, m²/km²) — no imperial toggle.
2026-09-07 22:15:08 +02:00

341 lines
13 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";
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.
//
// 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, below.
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");
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",
);
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;
}