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 `
${thumbs}
`; +} + function formatDescription(description) { const body = []; const meta = []; diff --git a/test/fixtures/sample_poi.gpx b/test/fixtures/sample_poi.gpx index 2c93f3b..65ac175 100644 --- a/test/fixtures/sample_poi.gpx +++ b/test/fixtures/sample_poi.gpx @@ -20,4 +20,9 @@ res/drawable-hdpi/ic_parking_small.png
+ + With photos + Photo + Photo + diff --git a/test/test_gpx.rb b/test/test_gpx.rb index f2afd66..6293c00 100644 --- a/test/test_gpx.rb +++ b/test/test_gpx.rb @@ -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