Files
maptool/public/js/map.js
T
randy c6e4a455b6
CI / check (push) Successful in 21s
CI / deploy (push) Successful in 11s
Split offline hiking trails per country, add a picker instead of one big file
data/hiking_trails.geojson is now data/hiking_trails/<CODE>.geojson —
one file per country instead of a merge, generated per-source
(PmtilesBuilder.extract_hiking_trails), cached the same way
geofabrik_cache is (skipped once a country's own extract is already
newer than its source .osm.pbf, so growing the selection by one
country only re-extracts that one). GET /api/hiking-trails now lists
available country codes instead of a single file's status.

The Overlays panel's "Hiking trails" item becomes an expandable
per-country checklist once any offline extract exists (same shape as
Radar's own expand), replacing the plain on/off checkbox — nobody
covering a dozen countries' worth of offline map data needs all of
their hiking trails loaded and parsed at once, so each ticked country
fetches its own GeoJSON independently instead.

HikingTrails.extract_merged (server-side multi-country merge, no
longer needed now that the client picks per country) removed again.
Verified the new extract_hiking_trails + caching skip live on brick
against a real cached country PBF.
2026-09-13 11:17:05 +02:00

656 lines
29 KiB
JavaScript

import {
Map as MaplibreMap,
NavigationControl,
Popup,
ScaleControl,
addProtocol,
} from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { GpxPanelControl } from "/js/gpx.js";
import { OverlaysControl } from "/js/overlays.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 { NotesControl } from "/js/notes.js";
import { PluginsControl } from "/js/plugins.js";
import { LocateControl } from "/js/locate.js";
import { SearchControl } from "/js/search.js";
import { OfflineDataControl } from "/js/offline_data.js";
import { LAYERS_ICON, WAYPOINT_ICON, SUNRISE_ICON, SUNSET_ICON, PENDING_ICON } from "/js/icons.js";
import { formatCoords, formatSunInfo } from "/js/sun.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { isLoggedIn } from "/js/auth.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 a warm, NPS-Park-Tiles-inspired palette —
// cream ground, muted roads/boundaries — mapped onto Liberty's OpenMapTiles
// layer ids. Applied identically regardless of which PMTiles data actually
// backs the "openmaptiles" source (see offlineStyle() below): offline tiles
// are built by Planetiler's own OpenMapTiles profile (docs/development.adoc),
// the same schema Liberty already expects, so one patch serves both —
// no separate offline theme/palette to keep in sync. 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.
//
// wood/grass are deliberately left unpatched — Liberty's own green (a
// translucent landcover_wood/landcover_grass wash) already reads well over
// the patched cream background.
const ONLINE_STYLE_PATCH = {
background: { "background-color": "#f9f6ef" },
// Liberty washes every built-up area in a translucent gray-tan below
// z12 (landuse_residential, maxzoom 12 — gone by this app's default
// z13) that the offline schema has no equivalent of; protomaps just
// leaves that ground as plain background/earth. Matched to background
// here rather than left alone, so a zoomed-out view doesn't read as
// visibly dingier online than offline.
landuse_residential: { "fill-color": "#f9f6ef" },
landcover_sand: { "fill-color": "#f0e4c4" },
water: { "fill-color": "#a3cedf" },
// fill-outline-color isn't touched by a plain fill-color patch — Liberty
// draws it separately, defaulting to a bright green that reads as a
// distinct border around named conservation areas (e.g. the Malé
// Karpaty CHKO). Matched to the fill itself, and park_outline's own
// dashed line hidden below, so a reserve blends in as ordinary forest —
// offline has no equivalent stroke at all (see park_a/park_b below),
// and the point was never to call reserves out as visibly different,
// on either side.
park: { "fill-color": "#b3e378", "fill-outline-color": "#b3e378" },
park_outline: { "line-opacity": 0 },
// fill-opacity: 0.5 matches the offline theme's own "buildings" layer —
// without it, Liberty's dense individual building footprints (each with
// its own outline) paint fully opaque and read much heavier/grayer than
// offline's softer, half-transparent fill over the same cream ground.
building: { "fill-color": "#ded7cc", "fill-opacity": 0.5 },
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.
//
// line-width below is a flat number, not Liberty's own zoom-interpolated
// expression — picked to match the offline theme's actual rendered width
// at this app's default zoom (13; verified live via queryRenderedFeatures
// on both sides), since without it Liberty's own curve draws surface
// roads roughly twice as thick as offline at street level, reading as a
// much bolder road network online.
// ponytail: tuned for z13 only, not a matching curve across all zooms —
// will drift back apart at very different zoom levels. Upgrade path:
// sample offline's width at a few more zooms and replace with a matching
// ["interpolate", ["linear"], ["zoom"], ...] expression.
road_motorway: { "line-color": "#fdefbe", "line-width": 2.26 },
road_trunk_primary: { "line-color": "#fdefbe", "line-width": 2.26 },
road_secondary_tertiary: { "line-color": "#fef7dd", "line-width": 1.87 },
road_link: { "line-color": "#fef7dd", "line-width": 1 },
road_motorway_link: { "line-color": "#fef7dd", "line-width": 1 },
road_minor: { "line-color": "#fffdf8" },
road_service_track: { "line-color": "#fffdf8" },
road_path_pedestrian: { "line-color": "#e9e2d8" },
road_motorway_casing: { "line-color": "#c9a66b", "line-width": 1.48 },
road_trunk_primary_casing: { "line-color": "#c9a66b", "line-width": 1.48 },
road_secondary_tertiary_casing: { "line-color": "#c9a66b", "line-width": 1 },
road_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 },
road_motorway_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 },
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();
// A vendored copy of the same Liberty style ONLINE_STYLE fetches live
// (public/vendor/liberty-style/liberty.json — a point-in-time snapshot, not
// auto-updated) that offlineStyle() below patches+repoints the exact same
// way. Offline mode can't depend on ONLINE_STYLE_URL actually being
// reachable — that fetch failing/timing out is the normal case in the
// field, not an edge case — so this is a local, same-origin fetch instead
// (fast and always available, since it's served by this app itself).
const OFFLINE_STYLE_TEMPLATE = await fetch("/vendor/liberty-style/liberty.json").then((response) => response.json());
const protocol = new pmtiles.Protocol();
addProtocol("pmtiles", protocol.tile);
// Builds the offline style from OFFLINE_STYLE_TEMPLATE — the identical
// Liberty JSON + ONLINE_STYLE_PATCH recolor as the online map, just
// repointed at a local PMTiles source instead of tiles.openfreemap.org.
// This works because data/offline.pmtiles is now built by Planetiler's own
// OpenMapTiles profile (see docs/development.adoc), the same vector schema
// Liberty expects — before that, offline tiles used a different schema
// (protomaps' own basemaps) needing a completely separate hand-tuned
// theme (protomaps-themes-base + OFFLINE_THEME) that could never be made
// to match ONLINE_STYLE_PATCH's look exactly, no matter how much either
// side got tuned.
//
// glyphs/sprite point at vendored local copies (public/vendor/fonts,
// public/vendor/liberty-sprite) rather than tiles.openfreemap.org's —
// glyphs are Latin-only (see docs/architecture.adoc: labels in other
// scripts won't render offline), sprite is a one-time snapshot like
// OFFLINE_STYLE_TEMPLATE itself. ne2_shaded (Liberty's low-zoom shaded-
// relief backdrop, z0-6 only) is left pointing at its remote URL
// unchanged — offline usage is regional, not whole-earth zoomed out, so
// this only ever silently fails to load at a zoom level offline users
// aren't in, the same graceful-degradation story as this app's other
// online-only extras (Wikipedia, live radar).
function offlineStyle() {
const style = patchOnlineStyle(structuredClone(OFFLINE_STYLE_TEMPLATE));
style.sources.openmaptiles.url = `pmtiles://${window.location.origin}/tiles/offline.pmtiles`;
style.glyphs = `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`;
style.sprite = `${window.location.origin}/vendor/liberty-sprite/ofm`;
return style;
}
// 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";
}
}
// Tilts the camera between a flat top-down view and a fixed 3D-perspective
// pitch — bearing stays untouched (this map never rotates, see the
// NavigationControl comment below), so this is purely "look at it from an
// angle", not a rotate/compass control. Independent of the "Terrain
// shading" overlay toggle (overlays.js) — that adds a raster-dem hillshade/
// color-relief layer, this just moves the camera; combining both gives a
// real sense of relief but neither depends on the other.
const PITCH_3D = 60;
class PitchToggleControl {
onAdd(map) {
this._map = map;
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group pitch-toggle-ctrl";
this._button = document.createElement("button");
this._button.type = "button";
this._button.className = "pitch-toggle-ctrl__button";
this._button.addEventListener("click", () => this._toggle());
this._container.appendChild(this._button);
this._update = () => this._render();
map.on("pitchend", this._update);
this._render();
return this._container;
}
onRemove() {
this._map.off("pitchend", this._update);
this._container.remove();
}
getDefaultPosition() {
return "top-left";
}
// Set once overlaysControl exists (it's built later in this file) —
// lets raised terrain (OverlaysControl.syncTerrain, see overlays.js)
// follow the pitch toggle without this control needing to know anything
// about hillshade/DEM sources itself.
setOverlaysControl(overlaysControl) {
this._overlaysControl = overlaysControl;
this._overlaysControl.syncTerrain(this._map.getPitch() > 0);
}
_toggle() {
this._map.easeTo({ pitch: this._map.getPitch() > 0 ? 0 : PITCH_3D, duration: 300 });
}
_render() {
const is3d = this._map.getPitch() > 0;
this._button.textContent = is3d ? "2D" : "3D";
this._button.title = is3d ? "Switch to 2D view" : "Switch to 3D view";
this._button.setAttribute("aria-label", this._button.title);
this._button.classList.toggle("active", is3d);
this._overlaysControl?.syncTerrain(is3d);
}
}
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 SearchControl(), "top-left");
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");
const pitchToggle = new PitchToggleControl();
map.addControl(pitchToggle, "top-left");
map.addControl(new MeasureControl(), "top-left");
// Writing a note needs a login (POST /api/gpx/notes) — no point adding a
// button that can only ever end in a 401 for a logged-out viewer, same
// "hide the write-only affordance" story as GpxPanelControl's own
// upload/new-folder buttons.
if (isLoggedIn()) map.addControl(new NotesControl(), "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");
map.addControl(new ScaleControl({ unit: "metric" }), "bottom-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.innerHTML = `<span class="topbar__icon">${WAYPOINT_ICON}</span><span>${formatCoords(lat, lng)}</span>`;
};
updateCoords();
map.on("move", updateCoords);
// Plain decimal degrees, not the DMS text actually shown — that's what
// pastes cleanly into another map/GPS app, which is the whole point of
// copying a coordinate in the first place.
coordsLabel.title = "Click to copy coordinates";
coordsLabel.addEventListener("click", async () => {
const { lng, lat } = map.getCenter();
try {
await navigator.clipboard.writeText(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
} catch {
return; // clipboard API unavailable (e.g. an insecure context) — nothing to fall back to
}
coordsLabel.textContent = "Copied!";
setTimeout(updateCoords, 1000);
});
// Sunrise/sunset for the same center point the coords above already show —
// on moveend (not every "move" frame, unlike updateCoords: the sun position
// barely moves between two nearby spots, not worth recomputing constantly)
// plus a periodic tick so the "Xh Ym to sunset" countdown keeps counting
// down even while the map sits still.
const sunLabel = document.querySelector(".topbar__sun");
const updateSun = () => {
const { lng, lat } = map.getCenter();
const info = formatSunInfo(lat, lng);
sunLabel.hidden = info === null;
if (!info) return;
const countdownGroup = info.countdown
? `<span class="topbar__group"><span class="topbar__icon">${PENDING_ICON}</span><span>${info.countdown}</span></span>`
: "";
sunLabel.innerHTML =
`<span class="topbar__group"><span class="topbar__icon">${SUNRISE_ICON}</span><span>${info.sunrise}</span></span>` +
`<span class="topbar__group"><span class="topbar__icon">${SUNSET_ICON}</span><span>${info.sunset}</span></span>` +
countdownGroup;
};
updateSun();
map.on("moveend", updateSun);
setInterval(updateSun, 60000);
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 overlaysControl = new OverlaysControl(map);
overlaysControl.mount(sidebar);
pitchToggle.setOverlaysControl(overlaysControl);
// Running a plugin needs a login (POST /api/plugins/:name/run) — same
// "hide the write-only affordance" story as NotesControl above.
if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
layerToggle.mount(sidebar);
layerToggle.setActive("online");
new OfflineDataControl().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 }) => {
overlaysControl.setHillshadeOfflineAvailable(available);
overlaysControl.setShadowOfflineAvailable(available);
})
.catch(() => {});
fetch("/api/hiking-trails")
.then((response) => response.json())
.then(({ countries }) => overlaysControl.setHikingTrailsOfflineCountries(countries))
.catch(() => {});
fetch("/api/kiwix")
.then((response) => response.json())
.then(({ available }) => overlaysControl.setWikiAvailable(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;
}