diff --git a/docs/architecture.adoc b/docs/architecture.adoc
index f11c7d9..2f595f8 100644
--- a/docs/architecture.adoc
+++ b/docs/architecture.adoc
@@ -415,7 +415,8 @@ Like the radar overlay, GPX layers are removed by `map.setStyle()`
whichever files were checked.
Waypoint popups:: Clicking a waypoint badge opens a MapLibre popup
-with its name, category (if any), description and time. Click/hover handling is
+with its name, category (if any), photos (if any), description and
+time. Click/hover handling is
registered once per file (via `map.on(type, layerId, handler)`, a
delegated listener that survives `setStyle()`) rather than on every
`style.load` re-add, which would otherwise stack duplicate handlers
@@ -442,6 +443,16 @@ before, one paragraph with line breaks. So this only ever adds
structure on top of recognized lines; it never loses or hides text from
files that don't have any.
+
+A newer p4n2gpx export also emits one plain GPX `` per
+listing photo (not a vendor extension like `waypoint_style`'s
+`` — `` is base GPX 1.1, any number allowed per
+``/``/``). `Gpx.waypoint_photos` collects their `href`s
+into a `photos` property; `formatPhotos` renders them as a row of
+64px thumbnails above the description, each linking to the full-size
+image in a new tab. A plain GPS waypoint or an older export has none,
+so the row just doesn't render — same "only ever adds, never assumes"
+approach as the description formatting above.
++
`Services`/`Activities` get one further step (`formatMetaRow`,
`formatTags`): the value is park4night's own comma-separated codes
(`animaux, point_eau, wc_public`), which on their own told a reader
diff --git a/lib/maptool/gpx.rb b/lib/maptool/gpx.rb
index b203b90..d63942e 100644
--- a/lib/maptool/gpx.rb
+++ b/lib/maptool/gpx.rb
@@ -51,11 +51,21 @@ module Maptool
"category" => child_text(wpt, "type"),
"color" => ext[:color],
"icon" => ext[:icon],
+ "photos" => waypoint_photos(wpt),
},
}
end
private_class_method :waypoint_feature
+ # is plain GPX 1.1 (a wpt/trk/rte can carry any number of them),
+ # not a vendor extension like waypoint_style's — a newer
+ # p4n2gpx export started emitting one per park4night listing photo. A
+ # plain GPS waypoint has none, so this is almost always empty.
+ def self.waypoint_photos(wpt)
+ wpt.get_elements("link").filter_map { |link| link.attributes["href"] }
+ end
+ private_class_method :waypoint_photos
+
OSMAND_NAMESPACE = "https://osmand.net"
private_constant :OSMAND_NAMESPACE
diff --git a/public/css/style.css b/public/css/style.css
index 34fa26a..aae3d78 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -582,6 +582,21 @@ html, body {
line-height: 1.4;
}
+.gpx-popup__photos {
+ display: flex;
+ gap: 4px;
+ margin: 4px 0;
+ overflow-x: auto;
+}
+
+.gpx-popup__photo {
+ width: 64px;
+ height: 64px;
+ flex-shrink: 0;
+ object-fit: cover;
+ border-radius: 4px;
+}
+
.gpx-popup__meta {
margin-top: 8px;
padding-top: 6px;
diff --git a/public/js/gpx.js b/public/js/gpx.js
index 62ce5fd..40c5292 100644
--- a/public/js/gpx.js
+++ b/public/js/gpx.js
@@ -754,10 +754,11 @@ function gpxFileUrl(path) {
// properties come straight from an uploaded GPX file — untrusted input —
// so every value is escaped before landing in the popup's HTML.
-function popupHtml({ name, description, time, category }) {
+function popupHtml({ name, description, time, category, photos }) {
const parts = [];
if (name) parts.push(`${escapeHtml(name)}`);
if (category) parts.push(`
${escapeHtml(category)}
`);
+ if (photos?.length > 0) parts.push(formatPhotos(photos));
if (description) parts.push(formatDescription(description));
if (time) {
const parsed = new Date(time);
@@ -808,6 +809,22 @@ function escapeHtml(text) {
const DESCRIPTION_META_LINE = /^(Rating|Services|Activities):\s*(.+)$/i;
const DESCRIPTION_LINK_LINE = /^https?:\/\/\S+$/;
+// photo URLs (a newer p4n2gpx export adds one per park4night listing
+// photo — see lib/maptool/gpx.rb's waypoint_photos) as a row of thumbnails,
+// each opening the full-size image in a new tab. Untrusted URLs from an
+// uploaded file, same as everywhere else in this popup — escaped for use as
+// both an href and an img src.
+function formatPhotos(photos) {
+ const thumbs = photos
+ .map((url) => escapeHtml(url))
+ .map(
+ (url) =>
+ ``,
+ )
+ .join("");
+ return `