The previous pass applied Park Tiles' real park/protected-area green (#b3e378) to wood_a/wood_b/scrub_a/scrub_b as well as park_a/park_b — but in Park Tiles' own Imagery style, that green is specifically the protected-area/administrative color; actual vegetation there is just the aerial photo underneath, which this offline style has no fill equivalent of. Applying it everywhere made the whole map read as green instead of "brown with green parks popping out", which is the actual character of their palette. Forests/scrub now render in muted brown/khaki instead, leaving the vivid green for park_a/park_b alone.
212 lines
8.2 KiB
JavaScript
212 lines
8.2 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);
|
|
|
|
// 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);
|
|
}
|
|
|
|
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.
|
|
});
|