Show waypoint photos from newer p4n2gpx exports

A newer p4n2gpx run (Ruby rewrite, ~/test/mapping/p4n2gpx) started
emitting one plain GPX <link href> per park4night listing photo,
which this app parsed but never displayed. Gpx.waypoint_photos
collects them into a photos property; the waypoint popup renders them
as a row of thumbnails above the description, each opening full-size
in a new tab. Absent on older exports and plain GPS waypoints, so the
row just doesn't render for those.
This commit is contained in:
randy committed 2026-09-07 22:38:22 +02:00
1 parent dd272ffb8a
commit dd0ad6d845
6 files changed
+72 -2

No files matched your search

+12 -1
View File
@@ -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 `<link href>` per
listing photo (not a vendor extension like `waypoint_style`'s
`<osmand:*>` — `<link>` is base GPX 1.1, any number allowed per
`<wpt>`/`<trk>`/`<rte>`). `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
+10
View File
@@ -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
# <link> is plain GPX 1.1 (a wpt/trk/rte can carry any number of them),
# not a vendor extension like waypoint_style's <osmand:*> — 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
+15
View File
@@ -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;
+18 -1
View File
@@ -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(`<strong>${escapeHtml(name)}</strong>`);
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
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+$/;
// <link> 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) =>
`<a href="${url}" target="_blank" rel="noopener noreferrer"><img class="gpx-popup__photo" src="${url}" loading="lazy" alt=""></a>`,
)
.join("");
return `<div class="gpx-popup__photos">${thumbs}</div>`;
}
function formatDescription(description) {
const body = [];
const meta = [];
+5
View File
@@ -20,4 +20,9 @@
<locus:icon xmlns:locus="http://www.locusmap.eu">res/drawable-hdpi/ic_parking_small.png</locus:icon>
</extensions>
</wpt>
<wpt lat="48.103" lon="17.203">
<name>With photos</name>
<link href="https://cdn3.park4night.com/lieu/1_100/1_gd.jpg"><text>Photo</text></link>
<link href="https://cdn3.park4night.com/lieu/1_100/2_gd.jpg"><text>Photo</text></link>
</wpt>
</gpx>
+12
View File
@@ -74,6 +74,18 @@ class GpxTest < Minitest::Test
assert_nil plain["properties"]["icon"]
end
def test_waypoint_captures_link_hrefs_as_photos
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
with_photos = geojson["features"].find { |f| f["properties"]["name"] == "With photos" }
plain = geojson["features"].find { |f| f["properties"]["name"] == "Plain waypoint" }
assert_equal(
["https://cdn3.park4night.com/lieu/1_100/1_gd.jpg", "https://cdn3.park4night.com/lieu/1_100/2_gd.jpg"],
with_photos["properties"]["photos"]
)
assert_empty plain["properties"]["photos"]
end
def test_kinds_detects_routes
assert_equal ["route"], Maptool::Gpx.kinds(fixture_path("sample_route.gpx"))
end