map: clicking a POI shows its kind, detail, elevation and coordinates

The Protomaps basemap keeps a few attributes per POI beyond the name the
map already labels: kind, kind_detail and elevation.  Clicking a POI now
opens a popup with those, plus the feature's coordinates (or the click
location when the feature has no point).  The same POI appears at several
min_zoom levels within one tile, so features are deduped by name and kind.

Map popups (this one and the existing pin popup) now follow the active
theme's tokens instead of MapLibre's fixed white, covering light, dark,
night and amber.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-09-26 19:32:59 -06:00
parent 15d3792b0e
commit a16b701e5b
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 187 additions and 0 deletions

View file

@ -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);
}

View file

@ -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: '<img src=x onerror=alert(1)>', kind: 'a&b'})]);
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
expect(html).toContain('A&amp;b');
});
test('a point feature shows its own coordinates, lat then lon', () => {
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();
});

View file

@ -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;

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// "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(
`<div style="margin-bottom:6px">` +
`<strong>${escapeHtml(name || humanize(p.kind))}</strong>` +
rows.map(r => `<br/><small>${escapeHtml(r)}</small>`).join("") +
`</div>`
);
}
if (!items.length) return null;
return `<div style="font-size:13px;padding-right:12px">${items.join("")}</div>`;
}