Reuses the same raster-dem source the hillshade toggle already added — a color-relief layer tints by elevation band (green valleys through brown mid-slopes to pale grey peaks, in this app's own palette) under the hillshade layer, so one toggle gives the combined shaded-relief look instead of two separate layers.
641 lines
28 KiB
JavaScript
641 lines
28 KiB
JavaScript
import {
|
|
Map as MaplibreMap,
|
|
NavigationControl,
|
|
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 { LocateControl } from "/js/locate.js";
|
|
import { LAYERS_ICON, HILLSHADE_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_URL = "https://tiles.openfreemap.org/styles/liberty";
|
|
|
|
// Recolors OpenFreeMap Liberty to the same NPS-inspired palette as
|
|
// OFFLINE_THEME below — warm cream ground, muted roads/boundaries — mapped
|
|
// onto Liberty's OpenMapTiles layer ids instead of protomaps' theme roles
|
|
// (the two schemas don't share layer names, so this is a separate
|
|
// hand-built table, not a shared object). Only the layers that dominate the
|
|
// map's overall color read are touched; labels, halos, rail, and minor
|
|
// overlays (cemetery/hospital/school/pitch/track shading, building
|
|
// outlines) are left at Liberty's own defaults — same "not a full
|
|
// recolor" scope as OFFLINE_THEME. Road casings have no protomaps-role
|
|
// equivalent to reuse (offline roads don't get a separate outline), so
|
|
// #c9a66b there is reasoned to fit the family rather than sourced from
|
|
// anywhere real.
|
|
//
|
|
// wood/grass are deliberately left unpatched — Liberty's own green reads
|
|
// better than this app's earlier brown/khaki recolor, and OFFLINE_THEME's
|
|
// wood_a/scrub_a below were changed to match Liberty's green instead.
|
|
const ONLINE_STYLE_PATCH = {
|
|
background: { "background-color": "#f9f6ef" },
|
|
landcover_sand: { "fill-color": "#f0e4c4" },
|
|
water: { "fill-color": "#a3cedf" },
|
|
park: { "fill-color": "#b3e378" },
|
|
park_outline: { "line-color": "#546619" },
|
|
building: { "fill-color": "#ded7cc" },
|
|
boundary_2: { "line-color": "#546619" },
|
|
boundary_3: { "line-color": "#546619" },
|
|
|
|
// Highway (motorway/trunk/primary), major (secondary/tertiary/link),
|
|
// minor (residential/service/track), pedestrian — each repeated for its
|
|
// tunnel_/bridge_ counterpart so a road doesn't change color when it
|
|
// dips into a tunnel or crosses a bridge. Rail is left alone.
|
|
road_motorway: { "line-color": "#fdefbe" },
|
|
road_trunk_primary: { "line-color": "#fdefbe" },
|
|
road_secondary_tertiary: { "line-color": "#fef7dd" },
|
|
road_link: { "line-color": "#fef7dd" },
|
|
road_motorway_link: { "line-color": "#fef7dd" },
|
|
road_minor: { "line-color": "#fffdf8" },
|
|
road_service_track: { "line-color": "#fffdf8" },
|
|
road_path_pedestrian: { "line-color": "#e9e2d8" },
|
|
road_motorway_casing: { "line-color": "#c9a66b" },
|
|
road_trunk_primary_casing: { "line-color": "#c9a66b" },
|
|
road_secondary_tertiary_casing: { "line-color": "#c9a66b" },
|
|
road_link_casing: { "line-color": "#c9a66b" },
|
|
road_motorway_link_casing: { "line-color": "#c9a66b" },
|
|
road_minor_casing: { "line-color": "#c9a66b" },
|
|
road_service_track_casing: { "line-color": "#c9a66b" },
|
|
|
|
tunnel_motorway: { "line-color": "#fdefbe" },
|
|
tunnel_trunk_primary: { "line-color": "#fdefbe" },
|
|
tunnel_secondary_tertiary: { "line-color": "#fef7dd" },
|
|
tunnel_link: { "line-color": "#fef7dd" },
|
|
tunnel_motorway_link: { "line-color": "#fef7dd" },
|
|
tunnel_minor: { "line-color": "#fffdf8" },
|
|
tunnel_service_track: { "line-color": "#fffdf8" },
|
|
tunnel_path_pedestrian: { "line-color": "#e9e2d8" },
|
|
tunnel_motorway_casing: { "line-color": "#c9a66b" },
|
|
tunnel_trunk_primary_casing: { "line-color": "#c9a66b" },
|
|
tunnel_secondary_tertiary_casing: { "line-color": "#c9a66b" },
|
|
tunnel_link_casing: { "line-color": "#c9a66b" },
|
|
tunnel_motorway_link_casing: { "line-color": "#c9a66b" },
|
|
tunnel_street_casing: { "line-color": "#c9a66b" },
|
|
|
|
bridge_motorway: { "line-color": "#fdefbe" },
|
|
bridge_trunk_primary: { "line-color": "#fdefbe" },
|
|
bridge_secondary_tertiary: { "line-color": "#fef7dd" },
|
|
bridge_link: { "line-color": "#fef7dd" },
|
|
bridge_motorway_link: { "line-color": "#fef7dd" },
|
|
bridge_street: { "line-color": "#fffdf8" },
|
|
bridge_service_track: { "line-color": "#fffdf8" },
|
|
bridge_path_pedestrian: { "line-color": "#e9e2d8" },
|
|
bridge_motorway_casing: { "line-color": "#c9a66b" },
|
|
bridge_trunk_primary_casing: { "line-color": "#c9a66b" },
|
|
bridge_secondary_tertiary_casing: { "line-color": "#c9a66b" },
|
|
bridge_link_casing: { "line-color": "#c9a66b" },
|
|
bridge_motorway_link_casing: { "line-color": "#c9a66b" },
|
|
bridge_street_casing: { "line-color": "#c9a66b" },
|
|
bridge_path_pedestrian_casing: { "line-color": "#c9a66b" },
|
|
};
|
|
|
|
function patchOnlineStyle(style) {
|
|
for (const layer of style.layers) {
|
|
const patch = ONLINE_STYLE_PATCH[layer.id];
|
|
if (patch) Object.assign((layer.paint ??= {}), patch);
|
|
}
|
|
return insertTrackOnlyLayer(style);
|
|
}
|
|
|
|
// Liberty's own "road_service_track" layer paints service roads (paved
|
|
// driveways/parking aisles) and actual unpaved tracks the same color —
|
|
// fine for a general-purpose map, not for this app's "can the camper get
|
|
// there" use case. The underlying OpenMapTiles data does carry `class:
|
|
// "track"` separately from `"service"` (see the filter below), so rather
|
|
// than recoloring the merged layer (which would mislabel paved driveways
|
|
// as unpaved), this clones it into a second layer scoped to `class ==
|
|
// "track"` only, dashed and dirt-brown — matching how the offline theme
|
|
// already renders protomaps' own separate "other"/"path" road kind.
|
|
// Surface-level only (excludes tunnel_/bridge_ variants): an unpaved track
|
|
// in a tunnel or on a bridge is rare enough not to be worth a third layer.
|
|
function insertTrackOnlyLayer(style) {
|
|
const index = style.layers.findIndex((layer) => layer.id === "road_service_track");
|
|
if (index === -1) return style;
|
|
|
|
const base = style.layers[index];
|
|
const trackOnly = {
|
|
...base,
|
|
id: "road_track_only",
|
|
filter: ["all", base.filter, ["==", ["get", "class"], "track"]],
|
|
paint: { ...base.paint, "line-color": "#c9a66b", "line-dasharray": [2, 2] },
|
|
};
|
|
style.layers.splice(index + 1, 0, trackOnly);
|
|
return style;
|
|
}
|
|
|
|
// Fetched (not just referenced by URL) so its colors can be patched before
|
|
// the map ever sees it — MapLibre accepts a style object anywhere it'd
|
|
// accept a style URL, so every other use of ONLINE_STYLE (applyStyle's
|
|
// map.setStyle) needs no change. This runs before the map — and the rest
|
|
// of the page's setup — is constructed (see the top-level await below), so
|
|
// it falls back to the plain URL, letting MapLibre fetch and render it
|
|
// unpatched, both when the fetch fails outright and when it's just slow
|
|
// (a 3s timeout): this app is meant to work offline in the field, and
|
|
// blocking the entire page on a remote style server would undermine that
|
|
// even though this particular fetch is only for the online map.
|
|
async function loadOnlineStyle() {
|
|
try {
|
|
const response = await fetch(ONLINE_STYLE_URL, { signal: AbortSignal.timeout(3000) });
|
|
return patchOnlineStyle(await response.json());
|
|
} catch (err) {
|
|
return ONLINE_STYLE_URL;
|
|
}
|
|
}
|
|
|
|
const ONLINE_STYLE = await loadOnlineStyle();
|
|
|
|
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",
|
|
// Matches OpenFreeMap Liberty's own wood/grass green (its landcover_wood
|
|
// hsla(98,61%,72%,0.7) and landcover_grass rgba(176,213,154,1) at 0.3
|
|
// opacity, blended over the cream background) — the online map's actual
|
|
// look whenever ONLINE_STYLE_PATCH's brown/khaki recolor fails to apply
|
|
// (see loadOnlineStyle's fetch fallback) turned out to be the look this
|
|
// app's users actually want, so offline was changed to match it instead
|
|
// of chasing the patch's own reliability.
|
|
wood_a: "#c3e8ab",
|
|
wood_b: "#a8d888",
|
|
scrub_a: "#dcecc9",
|
|
scrub_b: "#cfe3b8",
|
|
// Close to wood_b, not the more saturated Park Tiles green: protomaps'
|
|
// theme routes "forest"/"nature_reserve"/"protected_area" (e.g. the Malé
|
|
// Karpaty CHKO boundary) through this same park_a/park_b pair as actual
|
|
// named parks — Liberty's online style doesn't make that reserve-boundary
|
|
// distinction at all, so a visibly different green here reads as an
|
|
// offline-only blotch rather than a deliberate distinction.
|
|
park_a: "#c3e8ab",
|
|
park_b: "#a8d888",
|
|
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" (singular) is never actually read by protomaps' own paint
|
|
// expressions — only the zoom-interpolated minor_a/minor_b pair is, so a
|
|
// plain "minor" key here was dead: ordinary streets were silently
|
|
// falling back to the base theme's default pale yellow instead of this
|
|
// app's cream.
|
|
minor_a: "#fffdf8",
|
|
minor_b: "#fffdf8",
|
|
link: "#fef7dd",
|
|
// Distinct brown, not cream: protomaps' "other"/"path" kind is what a
|
|
// camper actually needs called out — unpaved tracks and footpaths,
|
|
// separate from paved minor_road/service (minor_service, left at the
|
|
// base theme's default since it's real driveways/parking aisles, not
|
|
// unpaved). Already rendered dashed by the theme itself; this just picks
|
|
// a real "dirt" tone instead of unstyled white.
|
|
other: "#c9a66b",
|
|
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;
|
|
}
|
|
}
|
|
|
|
const HILLSHADE_ID = "hillshade-dem";
|
|
const COLOR_RELIEF_ID = "elevation-tint";
|
|
// Hypsometric tint reusing this app's own palette (wood_a's green at
|
|
// valley floor, the road casing brown through the mid-slopes) rather than
|
|
// a generic rainbow ramp, so it reads as part of the same map rather than
|
|
// a bolted-on overlay. Stops picked for Central European terrain — the
|
|
// Tatras top out around 2600m — not a general-purpose world ramp.
|
|
const ELEVATION_COLOR_RAMP = [
|
|
"interpolate",
|
|
["linear"],
|
|
["raster-value"],
|
|
0, "#c3e8ab",
|
|
400, "#dcd08f",
|
|
900, "#c9a66b",
|
|
1600, "#a89a86",
|
|
2600, "#f9f6ef",
|
|
];
|
|
// Public, unauthenticated, no API key — the same open (SRTM/ArcticDEM-derived)
|
|
// elevation tiles OpenStreetMap's own hillshade layer and many other free
|
|
// tools use. Only reachable online; while the offline basemap is active this
|
|
// is skipped in favor of a local terrain.pmtiles if one's been generated
|
|
// (see docs/development.adoc), same optional-local-file story as
|
|
// offline.pmtiles itself.
|
|
const ONLINE_TERRAIN_TILES = ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"];
|
|
|
|
// A simple on/off sidebar toggle, not a panel — there's nothing to choose,
|
|
// just whether the shading shows.
|
|
class HillshadeControl {
|
|
constructor(map) {
|
|
this._map = map;
|
|
this._active = false;
|
|
this._offlineAvailable = false;
|
|
// setStyle() (switching Online/Offline) tears down all custom
|
|
// sources/layers — put hillshade back if it was on.
|
|
map.on("style.load", () => {
|
|
if (this._active) this._addLayer();
|
|
});
|
|
}
|
|
|
|
mount(sidebar) {
|
|
this._container = document.createElement("div");
|
|
this._container.className = "sidebar-item";
|
|
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Terrain shading" aria-label="Terrain shading" aria-pressed="false">${HILLSHADE_ICON}</button>`;
|
|
this._button = this._container.querySelector(".sidebar-icon");
|
|
this._button.addEventListener("click", () => this._toggle());
|
|
sidebar.appendChild(this._container);
|
|
}
|
|
|
|
setOfflineAvailable(available) {
|
|
this._offlineAvailable = available;
|
|
}
|
|
|
|
_toggle() {
|
|
this._active = !this._active;
|
|
this._button.classList.toggle("active", this._active);
|
|
this._button.setAttribute("aria-pressed", String(this._active));
|
|
if (this._active) this._addLayer();
|
|
else this._removeLayer();
|
|
}
|
|
|
|
_addLayer() {
|
|
if (this._map.getSource(HILLSHADE_ID)) return;
|
|
|
|
const source = this._offlineAvailable
|
|
? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium" }
|
|
: {
|
|
type: "raster-dem",
|
|
tiles: ONLINE_TERRAIN_TILES,
|
|
tileSize: 256,
|
|
encoding: "terrarium",
|
|
maxzoom: 15,
|
|
attribution: "Terrain tiles: Mapzen, SRTM, ArcticDEM",
|
|
};
|
|
this._map.addSource(HILLSHADE_ID, source);
|
|
// Elevation tint under, hillshade on top: the tint sets the base color
|
|
// per elevation band, hillshade multiplies slope-shading over it — the
|
|
// combined "characteristic relief look" the TODO asked for, not two
|
|
// independent layers.
|
|
this._map.addLayer({
|
|
id: COLOR_RELIEF_ID,
|
|
type: "color-relief",
|
|
source: HILLSHADE_ID,
|
|
paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 },
|
|
});
|
|
this._map.addLayer({ id: HILLSHADE_ID, type: "hillshade", source: HILLSHADE_ID, paint: { "hillshade-exaggeration": 0.4 } });
|
|
}
|
|
|
|
_removeLayer() {
|
|
if (this._map.getLayer(HILLSHADE_ID)) this._map.removeLayer(HILLSHADE_ID);
|
|
if (this._map.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_ID);
|
|
if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID);
|
|
}
|
|
}
|
|
|
|
// 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 LocateControl, 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");
|
|
// Constructed here (rather than down where it's mounted, alongside the
|
|
// other sidebar controls) so LocateControl can hold the same instance —
|
|
// its constructor only touches `map`, mount() (which needs the sidebar
|
|
// element) can still happen later.
|
|
const gnssControl = new GnssControl(map);
|
|
// Not MapLibre's own GeolocateControl (browser Geolocation API only) —
|
|
// this app has its own GNSS receiver (gpsd, possibly a simulated one, see
|
|
// bin/gnss_simulator.rb), and "find my location" should prefer that over a
|
|
// device's separate, often less accurate location service, but only once
|
|
// the "GNSS" sidebar icon actually turns it on — not just whenever a
|
|
// receiver happens to have a fix. See locate.js.
|
|
map.addControl(new LocateControl(gnssControl), "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 coordsLabel = document.querySelector(".topbar__coords");
|
|
const updateCoords = () => {
|
|
const { lng, lat } = map.getCenter();
|
|
coordsLabel.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
|
};
|
|
updateCoords();
|
|
map.on("move", updateCoords);
|
|
|
|
const sidebar = document.getElementById("sidebar");
|
|
|
|
// Grouped into the TODO's planned 4-module menu (Explorer / Offline hub /
|
|
// Navigation / Camper & HW) ahead of that split actually happening —
|
|
// Camper & HW has no sidebar item yet (leveling/solar/telemetry are all
|
|
// still unbuilt), so it's left out rather than shown empty.
|
|
const layerToggle = new LayerToggleControl(applyStyle);
|
|
const profilePanel = new TrackProfilePanel(map);
|
|
profilePanel.mount();
|
|
|
|
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`);
|
|
new GpxPanelControl(map, profilePanel).mount(sidebar);
|
|
const hillshadeControl = new HillshadeControl(map);
|
|
hillshadeControl.mount(sidebar);
|
|
|
|
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
|
|
layerToggle.mount(sidebar);
|
|
layerToggle.setActive("online");
|
|
new RadarControl(map).mount(sidebar);
|
|
|
|
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Navigation"></div>`);
|
|
gnssControl.mount(sidebar);
|
|
new LiveTrackPanelControl(map).mount(sidebar);
|
|
|
|
// 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. layerToggle only needs to exist (not be mounted yet) for this.
|
|
const mapLayersButton = new MapLayersButton(layerToggle);
|
|
map.addControl(mapLayersButton, "top-left");
|
|
|
|
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.
|
|
const offlineStatus = document.querySelector(".topbar__offline-status");
|
|
fetch("/api/offline-tiles")
|
|
.then((response) => response.json())
|
|
.then(({ available }) => {
|
|
if (available) {
|
|
applyStyle("offline");
|
|
} else {
|
|
layerToggle.remove();
|
|
map.removeControl(mapLayersButton);
|
|
}
|
|
offlineStatus.hidden = false;
|
|
offlineStatus.classList.toggle("topbar__offline-status--ready", available);
|
|
offlineStatus.title = available ? "Offline map data ready" : "No offline map data on this server";
|
|
})
|
|
.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.
|
|
});
|
|
|
|
fetch("/api/terrain-tiles")
|
|
.then((response) => response.json())
|
|
.then(({ available }) => hillshadeControl.setOfflineAvailable(available))
|
|
.catch(() => {});
|
|
|
|
// 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;
|
|
}
|