WAYPOINT_ICON (coords) and PENDING_ICON (countdown) reused as-is; two new
hand-drawn SUNRISE_ICON/SUNSET_ICON since NPS's symbol set has no match —
same currentColor stroke style as the rest of icons.js, so they render
monochrome in the dark topbar rather than colored emoji.
sun.js's formatSunInfo now returns {sunrise, sunset, countdown} instead of
one joined string, so map.js can place its own icon in front of each
piece instead of the module owning any markup.
607 lines
27 KiB
JavaScript
607 lines
27 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 { 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 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;
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
// 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);
|
|
// 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");
|
|
|
|
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/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;
|
|
}
|