diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 22cd22a..f06f4a2 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -348,12 +348,26 @@ single paragraph, it read as a wall of run-together text. `formatDescription` pulls out whichever lines match a recognizable `Label: value` shape (`Rating`/`Services`/`Activities`) or are a bare URL, and renders each kind with its own visual treatment (a bordered -"meta" block with bold labels for the former, an isolated link line for -the latter); everything else — the actual narrative, or a plain GPX +"meta" block with a muted label for the former, an isolated link line +for the latter); everything else — the actual narrative, or a plain GPX file's `` with none of this structure at all — renders exactly as 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. ++ +`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 +nothing — split on commas and looked up in `PARK4NIGHT_TAGS` +(`public/js/icons.js`), each renders as a small icon + human label tag +instead. That table covers the codes p4n2gpx's exports actually use +(not park4night's whole API), with a human label always and an icon +only where the icon set below has a reasonable match — most of the +icons are National Park Service symbols again (same source/license as +`POI_ICONS`), a few (bakery, wifi, vehicle wash, ...) have no NPS +equivalent and get a label with no icon. A code this table doesn't +cover at all just renders as its own raw text, same as before this +existed — nothing p4n2gpx might add later is silently dropped. Track properties:: Every track feature (both formats) also carries `distance_meters`, `elevation_gain_meters`, `duration_secs`, diff --git a/public/css/style.css b/public/css/style.css index b915479..3d3aa01 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -458,7 +458,6 @@ html, body { margin-top: 8px; padding-top: 6px; border-top: 1px solid #eee; - color: #888; font-size: 12px; line-height: 1.5; } @@ -468,9 +467,36 @@ html, body { } .gpx-popup__meta-label { + color: #888; margin-right: 4px; } +.gpx-popup__tags { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 2px; +} + +.gpx-popup__tag { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.gpx-popup__tag-icon { + display: flex; + width: 13px; + height: 13px; + color: #555; + flex-shrink: 0; +} + +.gpx-popup__tag-icon svg { + width: 100%; + height: 100%; +} + .gpx-popup__link { margin-top: 6px; font-size: 12px; diff --git a/public/js/gpx.js b/public/js/gpx.js index 915af09..62ce5fd 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -8,6 +8,7 @@ import { ROUTE_ICON, MAP_MARKER_ICON, POI_ICONS, + PARK4NIGHT_TAGS, } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { formatActivityType } from "/js/track_profile.js"; @@ -828,12 +829,7 @@ function formatDescription(description) { const bodyText = body.join("\n").trim(); if (bodyText) parts.push(`

${linkify(bodyText).replace(/\n/g, "
")}

`); if (meta.length > 0) { - const rows = meta - .map( - ([, label, value]) => - `
${escapeHtml(label)} ${escapeHtml(value)}
`, - ) - .join(""); + const rows = meta.map(([, label, value]) => formatMetaRow(label, value)).join(""); parts.push(`
${rows}
`); } links.forEach((url) => parts.push(``)); @@ -841,6 +837,33 @@ function formatDescription(description) { return parts.join(""); } +// Services/Activities get rendered as icon+label tags (PARK4NIGHT_TAGS, +// icons.js) rather than their raw comma-separated park4night codes — "vtt, +// eaux_vives, peche" told a reader nothing on its own. Rating (or anything +// else matching DESCRIPTION_META_LINE) stays plain text; a park4night code +// this app hasn't catalogued yet just falls back to showing its raw text, +// same as before PARK4NIGHT_TAGS existed, so nothing is ever hidden. +function formatMetaRow(label, value) { + const isTagList = /^(services|activities)$/i.test(label); + const valueHtml = isTagList + ? `
${formatTags(value)}
` + : ` ${escapeHtml(value)}`; + return `
${escapeHtml(label)}${valueHtml}
`; +} + +function formatTags(value) { + return value + .split(",") + .map((code) => code.trim()) + .filter(Boolean) + .map((code) => { + const tag = PARK4NIGHT_TAGS[code]; + const icon = tag?.icon ? `${tag.icon}` : ""; + return `${icon}${escapeHtml(tag?.label ?? code)}`; + }) + .join(""); +} + // Turns bare URLs in untrusted text into safe links: the string is // split on the URL pattern and every piece is escaped — including the URL // itself, both as href and as link text — before any HTML is assembled, so diff --git a/public/js/icons.js b/public/js/icons.js index 2c2f5e5..d64ad41 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -91,6 +91,21 @@ export const MAP_MARKER_ICON = ` // the map, where a hairline stroke disappears — and rasterized/used as SDF // map icons (see gpx.js), so only the alpha shape matters, not the actual // `currentColor` value. + +// src/standalone/bicycle-trail-black-22.svg — pulled out to its own constant +// since it's also used unrasterized, inline, as PARK4NIGHT_TAGS.vtt below. +const BICYCLE_TRAIL_ICON = ` + + + + + +`; + export const POI_ICONS = { // src/standalone/parking-black-22.svg amenity_parking: ` @@ -131,17 +146,112 @@ export const POI_ICONS = { `, // src/standalone/bicycle-trail-black-22.svg - sport_cycling: ` - - - - - -`, + sport_cycling: BICYCLE_TRAIL_ICON, +}; + +// National Park Service map symbols (same source/license as POI_ICONS above) +// used as small inline glyphs next to a park4night waypoint's Services/ +// Activities tags (see PARK4NIGHT_TAGS and formatDescription in +// public/js/gpx.js) — pulled into named constants first since a few codes +// share the same real-world facility (eau_noire/eau_usee are both a dump +// station, gaz/gpl are both a propane refill, peche/peche_pied are both +// fishing) and BICYCLE_TRAIL_ICON is also POI_ICONS.sport_cycling above. +const PETS_ON_LEASH_ICON = ` +`; + +const SHOWERS_ICON = ` +`; + +const SANITARY_DISPOSAL_ICON = ` +`; + +const ELECTRICAL_HOOKUP_ICON = ` +`; + +const PROPANE_ICON = ` +`; + +const LAUNDRY_ICON = ` +`; + +const SWIMMING_ICON = ` +`; + +const DRINKING_WATER_ICON = ` +`; + +const TRASH_DUMPSTER_ICON = ` +`; + +const RESTROOMS_ICON = ` +`; + +const RIVER_RAFTING_ICON = ` +`; + +const CLIMBING_ICON = ` +`; + +const PLAYGROUND_ICON = ` +`; + +const MOTOR_BIKE_TRAIL_ICON = ` +`; + +const FISHING_ICON = ` +`; + +const SCENIC_VIEWPOINT_ICON = ` +`; + +const WALKING_ICON = ` +`; + +const GUIDED_TOURS_ICON = ` +`; + +const WIND_SURFING_ICON = ` +`; + +// park4night's fixed vocabulary of Services/Activities codes (the ones +// p4n2gpx exports actually use — not necessarily park4night's entire API), +// each with a human label (always shown) and an icon where the NPS set has +// a reasonable match. formatDescription (public/js/gpx.js) reads this; +// an unrecognized code just renders as its raw text, same as before this +// existed, so nothing p4n2gpx might add later is ever hidden. +export const PARK4NIGHT_TAGS = { + // Services + animaux: { icon: PETS_ON_LEASH_ICON, label: "Pets allowed" }, + boulangerie: { label: "Bakery nearby" }, + caravaneige: { label: "Winter/snow camping" }, + donnees_mobile: { label: "Mobile data coverage" }, + douche: { icon: SHOWERS_ICON, label: "Showers" }, + eau_noire: { icon: SANITARY_DISPOSAL_ICON, label: "Black water disposal" }, + eau_usee: { icon: SANITARY_DISPOSAL_ICON, label: "Grey water disposal" }, + electricite: { icon: ELECTRICAL_HOOKUP_ICON, label: "Electricity" }, + gaz: { icon: PROPANE_ICON, label: "Gas/propane refill" }, + gpl: { icon: PROPANE_ICON, label: "LPG refill" }, + lavage: { label: "Vehicle wash" }, + laverie: { icon: LAUNDRY_ICON, label: "Laundry" }, + piscine: { icon: SWIMMING_ICON, label: "Swimming pool" }, + point_eau: { icon: DRINKING_WATER_ICON, label: "Drinking water" }, + poubelle: { icon: TRASH_DUMPSTER_ICON, label: "Trash disposal" }, + wc_public: { icon: RESTROOMS_ICON, label: "Public toilet" }, + wifi: { label: "Wi-Fi" }, + + // Activities + baignade: { icon: SWIMMING_ICON, label: "Swimming" }, + eaux_vives: { icon: RIVER_RAFTING_ICON, label: "Whitewater sports" }, + escalade: { icon: CLIMBING_ICON, label: "Climbing" }, + jeux_enfants: { icon: PLAYGROUND_ICON, label: "Playground" }, + moto: { icon: MOTOR_BIKE_TRAIL_ICON, label: "Motorcycling" }, + peche: { icon: FISHING_ICON, label: "Fishing" }, + peche_pied: { icon: FISHING_ICON, label: "Shore fishing" }, + point_de_vue: { icon: SCENIC_VIEWPOINT_ICON, label: "Scenic viewpoint" }, + rando: { icon: WALKING_ICON, label: "Hiking" }, + visites: { icon: GUIDED_TOURS_ICON, label: "Sightseeing" }, + vtt: { icon: BICYCLE_TRAIL_ICON, label: "Mountain biking" }, + windsurf: { icon: WIND_SURFING_ICON, label: "Windsurfing" }, }; export const LIVETRACK_ICON = `