From a16b701e5b64e4dcf876d22e8632ee922ae1f9d2 Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 26 Sep 2026 19:32:59 -0600 Subject: [PATCH] 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 --- app/src/App.css | 32 ++++++++++++ app/src/components/MapPoiPopup.test.js | 63 ++++++++++++++++++++++++ app/src/components/MapViewer.js | 24 +++++++++ app/src/components/mapPoiPopup.js | 68 ++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 app/src/components/MapPoiPopup.test.js create mode 100644 app/src/components/mapPoiPopup.js 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( + `
` + + `${escapeHtml(name || humanize(p.kind))}` + + rows.map(r => `
${escapeHtml(r)}`).join("") + + `
` + ); + } + if (!items.length) return null; + return `
${items.join("")}
`; +}