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("")}
`; +}