Two fixes based on feedback after the last commit: 1. The map wasn't following the position at all -- only the first fix recentered, later updates just moved the marker. Now every fix recenters (easeTo, not flyTo, after the first one, so it doesn't repeat that zoom swoop on every update) -- matches how GeolocateControl's own trackUserLocation always behaved. 2. GNSS should only be used when explicitly turned on via the sidebar "GNSS" icon, not whenever a receiver happens to have a fix -- simpler to test against the simulator that way, and puts the visitor in control of whether GNSS is engaged at all. GnssControl now owns following the map on its own (same flyTo-once/easeTo-after pattern) whenever it's enabled; LocateControl checks GnssControl#isEnabled() and, when true, just jumps to #getPosition()'s current fix and reuses its existing .gnss-marker instead of polling/marking on its own. Only falls back to the browser's own geolocation when GNSS is off. Verified end to end with the GNSS simulator: enabling sidebar GNSS recenters immediately and keeps following over several polls; clicking Locate while GNSS is on jumps to the same position without creating a second marker or touching its own active state; turning GNSS back off and clicking Locate correctly falls through to the browser geolocation path instead.
472 lines
20 KiB
JavaScript
472 lines
20 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 } 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, brown/khaki land cover, green
|
|
// reserved for parks, 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.
|
|
const ONLINE_STYLE_PATCH = {
|
|
background: { "background-color": "#f9f6ef" },
|
|
landcover_wood: { "fill-color": "#c9bd9c" },
|
|
landcover_grass: { "fill-color": "#d9cfb0" },
|
|
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 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",
|
|
// Brown/khaki, not green: in Park Tiles' own Imagery style, green is
|
|
// specifically the protected-area/park color (below), not a general land
|
|
// cover fill — actual forest there is just the aerial photo underneath,
|
|
// which this offline style has no equivalent of. Using that same vivid
|
|
// green for ordinary wood/scrub as well (an earlier version of this
|
|
// theme did) made everything read as "green," not "brown with green
|
|
// parks" — reversed here to match the real distinction.
|
|
wood_a: "#c9bd9c",
|
|
wood_b: "#bcae8a",
|
|
scrub_a: "#d9cfb0",
|
|
scrub_b: "#cfc29d",
|
|
park_a: "#c8e2a0",
|
|
park_b: "#b3e378", // real: Park Tiles' protected-area/park-point green
|
|
sand: "#f0e4c4",
|
|
beach: "#f0e4c4",
|
|
buildings: "#ded7cc",
|
|
pedestrian: "#e9e2d8",
|
|
highway: "#fdefbe", // real: Park Tiles' primary/highway road color
|
|
major: "#fef7dd", // real: Park Tiles' secondary road color
|
|
minor: "#fffdf8",
|
|
link: "#fef7dd",
|
|
boundaries: "#546619", // real: Park Tiles' park-boundary line color
|
|
};
|
|
|
|
function offlineStyle() {
|
|
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
|
|
|
|
return {
|
|
version: 8,
|
|
glyphs: `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`,
|
|
sources: {
|
|
offline: {
|
|
type: "vector",
|
|
url: `pmtiles://${pmtilesUrl}`,
|
|
attribution: "© OpenStreetMap contributors",
|
|
},
|
|
},
|
|
// lang: "en" pulls in labels_layers (see docs/architecture.adoc) — only
|
|
// the Latin-script glyph ranges are vendored, so labels using other
|
|
// scripts won't render offline.
|
|
layers: protomaps_themes_base.layers("offline", OFFLINE_THEME, { lang: "en" }),
|
|
};
|
|
}
|
|
|
|
// A sidebar icon that opens a full-height side panel (like the GPX one)
|
|
// with the Online/Offline choice — modelled after OsmAnd's "Configure map"
|
|
// panel, rather than a small dropdown flyout.
|
|
class LayerToggleControl {
|
|
constructor(onSelect) {
|
|
this._onSelect = onSelect;
|
|
}
|
|
|
|
mount(sidebar) {
|
|
this._container = document.createElement("div");
|
|
this._container.className = "sidebar-item";
|
|
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Base map" aria-label="Base map">${LAYERS_ICON}</button>`;
|
|
this._icon = this._container.querySelector(".sidebar-icon");
|
|
this._icon.addEventListener("click", () => this._togglePanel());
|
|
|
|
this._panel = document.getElementById("basemap-panel");
|
|
this._panel
|
|
.querySelector(".basemap-panel__close")
|
|
.addEventListener("click", () => this._togglePanel(false));
|
|
this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
this._onSelect(button.dataset.style);
|
|
this._togglePanel(false);
|
|
});
|
|
});
|
|
|
|
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
|
|
|
|
sidebar.appendChild(this._container);
|
|
}
|
|
|
|
_togglePanel(force) {
|
|
const open = force === undefined ? this._panel.hidden : force;
|
|
this._panel.hidden = !open;
|
|
this._icon.classList.toggle("active", open);
|
|
if (open) notifySidePanelOpen(this._panel);
|
|
}
|
|
|
|
// Public wrapper so a second entry point (MapLayersButton, a map corner
|
|
// control) can open/close the same panel without reaching into a
|
|
// "private" method by convention only.
|
|
toggle() {
|
|
this._togglePanel();
|
|
}
|
|
|
|
setActive(name) {
|
|
this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => {
|
|
button.classList.toggle("active", button.dataset.style === name);
|
|
});
|
|
}
|
|
|
|
// Called once it's known there's no offline map to switch to (see the
|
|
// /api/offline-tiles check below) — an Online/Offline switch doesn't make
|
|
// sense to show at all when Offline was never going to work.
|
|
remove() {
|
|
this._container.remove();
|
|
this._panel.hidden = true;
|
|
}
|
|
}
|
|
|
|
// A small map-corner button (grouped with Home/zoom, see below) that's a
|
|
// second entry point to the same panel LayerToggleControl already manages
|
|
// in the page's own sidebar — matching the National Park Service's own
|
|
// map control layout, which keeps zoom/home/base-map together on the map
|
|
// rather than split between the map and a separate side rail.
|
|
class MapLayersButton {
|
|
constructor(layerToggle) {
|
|
this._layerToggle = layerToggle;
|
|
}
|
|
|
|
onAdd() {
|
|
this._container = document.createElement("div");
|
|
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group";
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.title = "Base map";
|
|
button.setAttribute("aria-label", "Base map");
|
|
button.innerHTML = LAYERS_ICON;
|
|
button.addEventListener("click", () => this._layerToggle.toggle());
|
|
this._container.appendChild(button);
|
|
return this._container;
|
|
}
|
|
|
|
onRemove() {
|
|
this._container.remove();
|
|
}
|
|
|
|
getDefaultPosition() {
|
|
return "top-left";
|
|
}
|
|
}
|
|
|
|
// A passive zoom-level readout, sitting in the same stack as the buttons
|
|
// around it — the National Park Service's own control layout has one (the
|
|
// small numbered square next to their zoom buttons). Not a button: no
|
|
// click behavior, just a live display, so it's a plain div rather than a
|
|
// <button> (no hover/focus affordance implying it does something).
|
|
class ZoomLevelControl {
|
|
onAdd(map) {
|
|
this._map = map;
|
|
this._container = document.createElement("div");
|
|
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group zoom-level-ctrl";
|
|
this._label = document.createElement("div");
|
|
this._label.className = "zoom-level-ctrl__value";
|
|
this._container.appendChild(this._label);
|
|
|
|
this._update = () => {
|
|
this._label.textContent = Math.round(map.getZoom());
|
|
};
|
|
this._update();
|
|
map.on("zoom", this._update);
|
|
|
|
return this._container;
|
|
}
|
|
|
|
onRemove() {
|
|
this._map.off("zoom", this._update);
|
|
this._container.remove();
|
|
}
|
|
|
|
getDefaultPosition() {
|
|
return "top-left";
|
|
}
|
|
}
|
|
|
|
const map = new MaplibreMap({
|
|
container: "map",
|
|
style: ONLINE_STYLE,
|
|
center: CENTER,
|
|
zoom: ZOOM,
|
|
// MapLibre's built-in Hash control: keeps #map=zoom/lat/lon in the URL in
|
|
// sync with the view (via history.replaceState, so panning doesn't spam
|
|
// the back button) and, if the page loads with one already in the URL,
|
|
// opens there instead of the default CENTER/ZOOM — the same "grab the URL
|
|
// to share this exact spot" permalink pattern OSM/Google Maps use.
|
|
hash: "map",
|
|
});
|
|
|
|
// A compact top-left stack — Home, zoom, zoom-level readout, measure,
|
|
// locate-me, base map — matching the National Park Service's own map
|
|
// control layout,
|
|
// all grouped together rather than split across corners. Added in this
|
|
// order since MapLibre stacks same-position controls in add order.
|
|
//
|
|
// 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 sidebar = document.getElementById("sidebar");
|
|
|
|
const layerToggle = new LayerToggleControl(applyStyle);
|
|
layerToggle.mount(sidebar);
|
|
layerToggle.setActive("online");
|
|
// The sidebar already has a "Base map" icon (opens the same panel) —
|
|
// this is a second entry point for it, grouped with Home/zoom on the map
|
|
// itself rather than off in the page's own side rail, matching NPS's
|
|
// layout. Added after mount() since it reuses that panel/toggle state.
|
|
const mapLayersButton = new MapLayersButton(layerToggle);
|
|
map.addControl(mapLayersButton, "top-left");
|
|
|
|
const profilePanel = new TrackProfilePanel(map);
|
|
profilePanel.mount();
|
|
new GpxPanelControl(map, profilePanel).mount(sidebar);
|
|
new RadarControl(map).mount(sidebar);
|
|
gnssControl.mount(sidebar);
|
|
new LiveTrackPanelControl(map).mount(sidebar);
|
|
|
|
function applyStyle(name) {
|
|
layerToggle.setActive(name);
|
|
map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE);
|
|
}
|
|
|
|
// If a local PMTiles file is present, prefer it as the default style — its
|
|
// presence signals the app is meant to be used offline here. If one isn't,
|
|
// the Online/Offline switch itself doesn't make sense (there's nothing to
|
|
// switch to), so it's removed entirely rather than left sitting there
|
|
// offering a choice that can't work.
|
|
fetch("/api/offline-tiles")
|
|
.then((response) => response.json())
|
|
.then(({ available }) => {
|
|
if (available) {
|
|
applyStyle("offline");
|
|
} else {
|
|
layerToggle.remove();
|
|
map.removeControl(mapLayersButton);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Backend check failed; keep the online style and leave the switch as
|
|
// is rather than guessing — this is the same-origin request itself
|
|
// failing, not evidence offline tiles are actually unavailable.
|
|
});
|
|
|
|
// Alt+click anywhere shows every rendered feature at that point — base map
|
|
// (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it —
|
|
// with its raw properties, inspired by NPMap4's infoboxControl (a generic
|
|
// "show me everything here" debug view, kept separate from the curated
|
|
// popups those same features already have via their own layer-specific
|
|
// click handlers, e.g. gpx.js's waypoint popups — both can fire on the same
|
|
// click, which is fine, this is a power-user/debugging affordance, not
|
|
// something replacing the normal popups). No dedicated UI for this
|
|
// deliberately — a keyboard-modified click needs no button, state, or
|
|
// cursor change of its own.
|
|
map.on("click", (event) => {
|
|
if (!event.originalEvent.altKey) return;
|
|
|
|
const features = map.queryRenderedFeatures(event.point);
|
|
if (features.length === 0) return;
|
|
|
|
const html = features
|
|
.map((feature) => {
|
|
const rows = Object.entries(feature.properties)
|
|
.map(([key, value]) => `<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(String(value))}</td></tr>`)
|
|
.join("");
|
|
return `<div class="debug-popup__feature"><strong>${escapeHtml(feature.layer.id)}</strong><table>${rows || "<tr><td>(no properties)</td></tr>"}</table></div>`;
|
|
})
|
|
.join("");
|
|
|
|
new Popup({ maxWidth: "320px", className: "debug-popup" }).setLngLat(event.lngLat).setHTML(html).addTo(map);
|
|
});
|
|
|
|
// Feature properties can come straight from an uploaded GPX/FIT file (an
|
|
// untrusted source) via this app's own layers, not just the base map, so
|
|
// every value is escaped the same way gpx.js's popups already do.
|
|
function escapeHtml(text) {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|