diff --git a/app/src/App.css b/app/src/App.css
index d0f46346..2644e9fd 100644
--- a/app/src/App.css
+++ b/app/src/App.css
@@ -725,3 +725,35 @@ tr.drag-deselecting td {
display: block;
}
}
+
+/* Map popups (POI details and pins) follow the active theme's tokens. */
+/* Scoped under .maplibregl-popup to outrank maplibre-gl.css, which loads after this file. */
+.maplibregl-popup .maplibregl-popup-content {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+}
+.maplibregl-popup .maplibregl-popup-close-button {
+ color: var(--muted);
+}
+.maplibregl-popup .maplibregl-popup-close-button:hover {
+ background: var(--head);
+ color: var(--text);
+}
+.maplibregl-popup.maplibregl-popup-anchor-top .maplibregl-popup-tip,
+.maplibregl-popup.maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
+.maplibregl-popup.maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
+ border-bottom-color: var(--panel);
+}
+.maplibregl-popup.maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
+.maplibregl-popup.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
+.maplibregl-popup.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
+ border-top-color: var(--panel);
+}
+.maplibregl-popup.maplibregl-popup-anchor-left .maplibregl-popup-tip {
+ border-right-color: var(--panel);
+}
+.maplibregl-popup.maplibregl-popup-anchor-right .maplibregl-popup-tip {
+ border-left-color: var(--panel);
+}
diff --git a/app/src/components/MapPoiPopup.test.js b/app/src/components/MapPoiPopup.test.js
new file mode 100644
index 00000000..bb6d248a
--- /dev/null
+++ b/app/src/components/MapPoiPopup.test.js
@@ -0,0 +1,63 @@
+import {poiPopupHtml} from './mapPoiPopup';
+
+// Clicking a POI on the map opens a popup built from attributes already in the pmtiles
+// file: the name, its category (kind), sub-category (kind_detail), and elevation.
+
+const feature = (properties) => ({properties});
+
+test('name, kind, detail and elevation are all shown, humanized', () => {
+ const html = poiPopupHtml([feature({
+ name: 'Flattop Mountain', kind: 'peak', kind_detail: 'volcano', elevation: 1070,
+ })]);
+ expect(html).toContain('Flattop Mountain');
+ expect(html).toContain('Peak');
+ expect(html).toContain('Volcano');
+ expect(html).toContain('1070 m');
+ expect(html).toContain('3510 ft');
+});
+
+test('missing attributes are left out rather than shown as undefined', () => {
+ const html = poiPopupHtml([feature({name: 'Franz', kind: 'bakery'})]);
+ expect(html).toContain('Franz');
+ expect(html).toContain('Bakery');
+ expect(html).not.toContain('undefined');
+ expect(html).not.toContain('Elevation');
+});
+
+test('kind_detail of "none" is not shown', () => {
+ const html = poiPopupHtml([feature({name: 'West High', kind: 'school', kind_detail: 'none'})]);
+ expect(html).not.toContain('none');
+ expect(html).not.toContain('None');
+});
+
+test('the same feature at several min_zoom levels appears once', () => {
+ const html = poiPopupHtml([
+ feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 12}),
+ feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 14}),
+ ]);
+ expect(html.match(/Westchester Lagoon/g)).toHaveLength(1);
+});
+
+test('feature properties are HTML escaped', () => {
+ const html = poiPopupHtml([feature({name: '', kind: 'a&b'})]);
+ expect(html).not.toContain('
{
+ const html = poiPopupHtml([{
+ properties: {name: 'Peak Two', kind: 'peak'},
+ geometry: {type: 'Point', coordinates: [-149.67301, 61.08571]},
+ }], {lng: -149.5, lat: 61.0});
+ expect(html).toContain('61.08571, -149.67301');
+});
+
+test('a feature without a point falls back to the clicked location', () => {
+ const html = poiPopupHtml([feature({name: 'Lagoon', kind: 'water'})], {lng: -149.5, lat: 61.0});
+ expect(html).toContain('61.00000, -149.50000');
+});
+
+test('no features gives no popup', () => {
+ expect(poiPopupHtml([])).toBeNull();
+});
diff --git a/app/src/components/MapViewer.js b/app/src/components/MapViewer.js
index 888bb78a..08469f6c 100644
--- a/app/src/components/MapViewer.js
+++ b/app/src/components/MapViewer.js
@@ -10,6 +10,7 @@ import {MAP_VIEWER_URI} from "./Vars";
import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui";
import {SettingsContext, ThemeContext} from "../contexts/contexts";
import {mapFlavor, mapSprite} from "../themes/names";
+import {poiPopupHtml} from "./mapPoiPopup";
// Terrain DEM file prefix for hillshade and contours.
const TERRAIN_PREFIX = "terrain-";
@@ -675,6 +676,29 @@ export default function MapViewer() {
map.on("click", () => setContextMenu(null));
map.on("movestart", () => setContextMenu(null));
+ // Click a POI to see the details the pmtiles file already has (kind, detail, elevation).
+ const poiLayerIds = () => (map.getStyle()?.layers || [])
+ .map(l => l.id)
+ .filter(id => id === "pois" || id.endsWith(":pois"))
+ .filter(id => map.getLayer(id));
+ map.on("click", (e) => {
+ if (destroyed) return;
+ const layers = poiLayerIds();
+ if (!layers.length) return;
+ const html = poiPopupHtml(map.queryRenderedFeatures(e.point, {layers}), e.lngLat);
+ if (!html) return;
+ new maplibregl.Popup({offset: 8, maxWidth: "260px", className: "wrolpi-map-popup"})
+ .setLngLat(e.lngLat)
+ .setHTML(html)
+ .addTo(map);
+ });
+ map.on("mousemove", (e) => {
+ if (destroyed) return;
+ const layers = poiLayerIds();
+ const hit = layers.length && map.queryRenderedFeatures(e.point, {layers}).length;
+ map.getCanvas().style.cursor = hit ? "pointer" : "";
+ });
+
// Long-press context menu (mobile).
let longPressTimer = null;
let touchStartPos = null;
diff --git a/app/src/components/mapPoiPopup.js b/app/src/components/mapPoiPopup.js
new file mode 100644
index 00000000..b744b71c
--- /dev/null
+++ b/app/src/components/mapPoiPopup.js
@@ -0,0 +1,68 @@
+// Popup content for a clicked map POI, built only from attributes already in the pmtiles file.
+// The Protomaps basemap keeps name, kind, kind_detail and elevation; there is no address,
+// phone or opening hours to show.
+
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+// "american_football" -> "American football"
+function humanize(value) {
+ const s = String(value).replace(/_/g, " ").trim();
+ return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+function formatCoordinates(lon, lat) {
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
+ return `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
+}
+
+// Returns the popup HTML for the clicked POI features, or null when nothing useful was clicked.
+// The same POI is often present at several min_zoom levels in one tile, so features are deduped
+// by name and kind. Coordinates come from the feature's own point when it has one, otherwise
+// from where the user clicked (clickLngLat is {lng, lat}).
+export function poiPopupHtml(features, clickLngLat = null) {
+ const seen = new Set();
+ const items = [];
+ for (const feature of features || []) {
+ const p = feature?.properties || {};
+ const name = p.name || p["name:en"];
+ if (!name && !p.kind) continue;
+ const key = `${name}|${p.kind}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ const rows = [];
+ if (p.kind) {
+ const detail = p.kind_detail && p.kind_detail !== "none" ? ` (${humanize(p.kind_detail)})` : "";
+ rows.push(`${humanize(p.kind)}${detail}`);
+ }
+ if (p.elevation !== undefined && p.elevation !== null && p.elevation !== ""
+ && Number.isFinite(Number(p.elevation))) {
+ const meters = Math.round(Number(p.elevation));
+ const feet = Math.round(meters * 3.28084);
+ rows.push(`Elevation: ${meters} m / ${feet} ft`);
+ }
+ const geometry = feature?.geometry;
+ const point = geometry?.type === "Point" && Array.isArray(geometry.coordinates)
+ ? geometry.coordinates : null;
+ const coordinates = point
+ ? formatCoordinates(Number(point[0]), Number(point[1]))
+ : clickLngLat && formatCoordinates(Number(clickLngLat.lng), Number(clickLngLat.lat));
+ if (coordinates) rows.push(coordinates);
+
+ items.push(
+ `