diff --git a/docs/superpowers/plans/2026-06-20-gpx-connector-logic.md b/docs/superpowers/plans/2026-06-20-gpx-connector-logic.md new file mode 100644 index 0000000..17acd64 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-gpx-connector-logic.md @@ -0,0 +1,935 @@ +# GPX Connector Logic Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Suppress the straight-line connector between adjacent map markers when a single GPX file covers both endpoints; keep connectors for uncovered gaps; add `force_connect` and `transport_mode` fields to entry/story blueprints. + +**Architecture:** Pure client-side. GPX files are already fetched to display tracks; their parsed trackpoints are reused to run a same-file proximity check per adjacent marker pair. Journey segments are built after all GPX fetches settle (Promise.all). The algorithm lives in `maplibre-utils.js` as pure functions exposed on `MapUtils`. + +**Tech Stack:** Vanilla JS (ES5 IIFE pattern matching existing code), MapLibre GL 4, `@mapbox/togeojson` 0.16.2, Grav 2 blueprint YAML, Playwright for tests. + +## Global Constraints + +- ES5 syntax only in all JS — no arrow functions, const/let, template literals, or modules (matching existing `maplibre-utils.js` style) +- All JS functions inside the existing `maplibre-utils.js` IIFE +- Grav blueprint fields use `header.` prefix in the `form.fields` tree +- Proximity threshold: **10 km** (hardcoded, not configurable) +- Trackpoints stored internally as `[lat, lng]` (latitude first); MapLibre coords are `[lng, lat]` (longitude first) — never mix these up +- Demo data required for Playwright tests: run `make demo-load` before the test suite +- Dev server runs at `http://localhost:8081` + +--- + +### Task 1: Blueprint — add `force_connect` and `transport_mode` fields + +**Files:** +- Modify: `user/themes/intotheeast/blueprints/entry.yaml` +- Create: `user/themes/intotheeast/blueprints/story.yaml` + +**Interfaces:** +- Produces: `entry.header.force_connect` (bool, default false), `entry.header.transport_mode` (string, default null) available in Twig templates and Admin2 UI + +- [ ] **Step 1: Add a Journey tab to `entry.yaml`** + +In `user/themes/intotheeast/blueprints/entry.yaml`, append this tab section after the `publishing:` tab block (before the closing of the `tabs.fields` block). The final file should end with: + +```yaml + journey: + type: tab + title: Journey + fields: + header.transport_mode: + type: select + label: How I arrived here + default: '' + options: + '': '— not specified —' + 'walking': 'Walking' + 'bicycle': 'Bicycle' + 'bus': 'Bus' + 'train': 'Train' + 'car': 'Car' + + header.force_connect: + type: toggle + label: Force connector line + help: 'When GPX tracks are present, always draw a connector from the previous marker to this one' + highlight: 1 + default: 0 + options: + 1: 'Yes' + 0: 'No' + validate: + type: bool +``` + +- [ ] **Step 2: Create `story.yaml` blueprint** + +Create `user/themes/intotheeast/blueprints/story.yaml` with this full content (covers all existing story frontmatter fields plus the new Journey tab): + +```yaml +title: 'Story' + +form: + fields: + tabs: + type: tabs + active: 1 + fields: + + content: + type: tab + title: Content + fields: + header.title: + type: text + label: Title + validate: + required: true + + header.date: + type: datetime + label: Date + format: 'Y-m-d H:i' + validate: + required: true + + header.hero_image: + type: text + label: Hero Image + placeholder: 'hero.jpg' + help: 'Filename of the hero image (upload via Media tab)' + + header.hero_alt: + type: text + label: Hero Image Alt Text + placeholder: 'Description of the hero image' + + content: + type: markdown + label: Content + validate: + required: true + + location: + type: tab + title: Location + fields: + header.location_name: + type: text + label: Location Name + placeholder: 'e.g. Val d''Orcia' + + header.location_country: + type: text + label: Country + placeholder: 'e.g. Italy' + + header.lat: + type: text + label: Latitude + placeholder: '43.0780' + help: 'GPS latitude (decimal degrees)' + + header.lng: + type: text + label: Longitude + placeholder: '11.6760' + help: 'GPS longitude (decimal degrees)' + + publishing: + type: tab + title: Publishing + fields: + header.published: + type: toggle + label: Published + highlight: 1 + default: 1 + options: + 1: 'Yes' + 0: 'No' + validate: + type: bool + + journey: + type: tab + title: Journey + fields: + header.transport_mode: + type: select + label: How I arrived here + default: '' + options: + '': '— not specified —' + 'walking': 'Walking' + 'bicycle': 'Bicycle' + 'bus': 'Bus' + 'train': 'Train' + 'car': 'Car' + + header.force_connect: + type: toggle + label: Force connector line + help: 'When GPX tracks are present, always draw a connector from the previous marker to this one' + highlight: 1 + default: 0 + options: + 1: 'Yes' + 0: 'No' + validate: + type: bool +``` + +- [ ] **Step 3: Manual verification** + +Open Admin2 at `http://localhost:8081/admin` → edit any entry under a dailies folder → confirm a "Journey" tab appears with "How I arrived here" select and "Force connector line" toggle. Then open any story page → confirm the same "Journey" tab is present. + +- [ ] **Step 4: Commit** + +```bash +git add user/themes/intotheeast/blueprints/entry.yaml user/themes/intotheeast/blueprints/story.yaml +git commit -m "feat: add force_connect and transport_mode fields to entry and story blueprints" +``` + +--- + +### Task 2: Algorithm functions in `maplibre-utils.js` + +**Files:** +- Modify: `user/themes/intotheeast/js/maplibre-utils.js` +- Create: `tests/ui/gpx-journey.spec.js` + +**Interfaces:** +- Produces: + - `MapUtils.extractTrackpoints(geojson)` → `[[lat, lng], ...]` + - `MapUtils.buildJourneySegments(entries, allTrackpoints, thresholdKm)` → `[[lng, lat], ...][]` + - `MapUtils.addJourneySegments(map, segments, baseSourceId)` → void +- Consumes: `toGeoJSON.gpx()` output (GeoJSON FeatureCollection) + +- [ ] **Step 1: Write failing Playwright tests** + +Create `tests/ui/gpx-journey.spec.js`: + +```javascript +// @ts-check +// Tests: G1–G4 — buildJourneySegments algorithm correctness +// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope, +// then call the functions with synthetic data via page.evaluate. +// Requires demo data: run `make demo-load` before this suite. +const { test, expect } = require('@playwright/test'); + +async function getMapUtils(page) { + await page.goto('/trips/italy-2025/map'); + await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); +} + +// G1: No GPX → all pairs connected in one segment +test('G1: all markers connected when no GPX files present', async ({ page }) => { + await getMapUtils(page); + + var count = await page.evaluate(function () { + var entries = [ + { lat: '43.0', lng: '11.0', force_connect: false }, + { lat: '44.0', lng: '12.0', force_connect: false }, + { lat: '45.0', lng: '13.0', force_connect: false } + ]; + return MapUtils.buildJourneySegments(entries, [], 10).length; + }); + + expect(count).toBe(1); +}); + +// G2: Same GPX file covers both markers → connector suppressed (0 segments) +test('G2: connector suppressed when same GPX file covers both markers', async ({ page }) => { + await getMapUtils(page); + + var count = await page.evaluate(function () { + var e1 = { lat: '43.000', lng: '11.000', force_connect: false }; + var e2 = { lat: '43.010', lng: '11.010', force_connect: false }; + // Trackpoints covering both (stored as [lat, lng]) + var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; + return MapUtils.buildJourneySegments([e1, e2], [track], 10).length; + }); + + expect(count).toBe(0); +}); + +// G3: force_connect overrides GPX suppression +test('G3: force_connect keeps connector even when GPX covers both markers', async ({ page }) => { + await getMapUtils(page); + + var count = await page.evaluate(function () { + var e1 = { lat: '43.000', lng: '11.000', force_connect: false }; + var e2 = { lat: '43.010', lng: '11.010', force_connect: true }; + var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; + return MapUtils.buildJourneySegments([e1, e2], [track], 10).length; + }); + + expect(count).toBe(1); +}); + +// G4: Markers near DIFFERENT GPX files → connector kept +test('G4: connector kept when markers are near different GPX files', async ({ page }) => { + await getMapUtils(page); + + var count = await page.evaluate(function () { + var e1 = { lat: '43.000', lng: '11.000', force_connect: false }; + var e2 = { lat: '45.000', lng: '13.000', force_connect: false }; + // Two separate files — each only covers one marker + var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only + var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only + return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length; + }); + + expect(count).toBe(1); +}); + +// G5: First pair suppressed, second pair kept → one segment [e2, e3] +test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page }) => { + await getMapUtils(page); + + var count = await page.evaluate(function () { + // e1→e2: covered by track → suppressed; e1 is orphaned (< 2 pts, not pushed) + // e2→e3: not covered → connector kept → segment [e2, e3] + var e1 = { lat: '43.000', lng: '11.000', force_connect: false }; + var e2 = { lat: '43.010', lng: '11.010', force_connect: false }; + var e3 = { lat: '45.000', lng: '13.000', force_connect: false }; + var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only + var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10); + return segs.length; + }); + + expect(count).toBe(1); // one segment: [e2 → e3] +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +npx playwright test tests/ui/gpx-journey.spec.js +``` + +Expected: All 5 tests fail with `MapUtils.buildJourneySegments is not a function` (or similar). + +- [ ] **Step 3: Add algorithm functions to `maplibre-utils.js`** + +Inside the IIFE in `user/themes/intotheeast/js/maplibre-utils.js`, add the following functions **before** the `global.MapUtils = ...` line at the bottom: + +```javascript + /* ── GPX connector algorithm ────────────────────────────────────────── */ + + /* Haversine distance in km between two [lat, lng] points */ + function haversineKm(lat1, lng1, lat2, lng2) { + var R = 6371; + var dLat = (lat2 - lat1) * Math.PI / 180; + var dLng = (lng2 - lng1) * Math.PI / 180; + var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + /* + * Extract trackpoints from a toGeoJSON output. + * Returns [[lat, lng], ...] — latitude first (internal convention). + * GeoJSON coordinates are [lng, lat]; we flip them here. + */ + function extractTrackpoints(geojson) { + var points = []; + (geojson.features || []).forEach(function (feat) { + var coords = []; + if (feat.geometry.type === 'LineString') { + coords = feat.geometry.coordinates; + } else if (feat.geometry.type === 'MultiLineString') { + feat.geometry.coordinates.forEach(function (line) { + coords = coords.concat(line); + }); + } + coords.forEach(function (c) { points.push([c[1], c[0]]); }); // [lng,lat] → [lat,lng] + }); + return points; + } + + /* + * Check whether a marker is within thresholdKm of any trackpoint in the array. + * trackpoints: [[lat, lng], ...] (internal convention, latitude first). + * Samples every 10th point for performance; always checks the last point. + */ + function isNearTrack(markerLat, markerLng, trackpoints, thresholdKm) { + if (!trackpoints || trackpoints.length === 0) return false; + var degLat = thresholdKm / 111; + var degLng = thresholdKm / (111 * Math.cos(markerLat * Math.PI / 180)); + for (var i = 0; i < trackpoints.length; i += 10) { + var pt = trackpoints[i]; + if (Math.abs(pt[0] - markerLat) > degLat || Math.abs(pt[1] - markerLng) > degLng) continue; + if (haversineKm(markerLat, markerLng, pt[0], pt[1]) <= thresholdKm) return true; + } + var last = trackpoints[trackpoints.length - 1]; + return haversineKm(markerLat, markerLng, last[0], last[1]) <= thresholdKm; + } + + /* + * Build journey line segments from entries and GPX trackpoints. + * + * entries: [{lat, lng, force_connect}, ...] in chronological order + * allTrackpoints: [ [[lat,lng],...], ... ] — one sub-array per GPX file + * thresholdKm: proximity radius (default 10) + * + * Returns array of segments, each segment being [[lng, lat], ...] in MapLibre + * coordinate order. A segment with < 2 points is omitted. + * + * Rules: + * - No GPX files → all adjacent pairs connected (one segment) + * - GPX present, pair covered by same file → connector suppressed + * - GPX present, pair NOT covered by any single file → connector drawn + * - force_connect on arriving entry → always draw connector + */ + function buildJourneySegments(entries, allTrackpoints, thresholdKm) { + thresholdKm = thresholdKm || 10; + var hasGpx = allTrackpoints && allTrackpoints.length > 0; + var segments = []; + var current = []; + + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + var lngLat = [parseFloat(e.lng), parseFloat(e.lat)]; // MapLibre: [lng, lat] + + if (i === 0) { + current.push(lngLat); + continue; + } + + var prev = entries[i - 1]; + var connect; + + if (!hasGpx || e.force_connect) { + connect = true; + } else { + var pLat = parseFloat(prev.lat); + var pLng = parseFloat(prev.lng); + var cLat = parseFloat(e.lat); + var cLng = parseFloat(e.lng); + var covered = false; + for (var f = 0; f < allTrackpoints.length; f++) { + if (isNearTrack(pLat, pLng, allTrackpoints[f], thresholdKm) && + isNearTrack(cLat, cLng, allTrackpoints[f], thresholdKm)) { + covered = true; + break; + } + } + connect = !covered; + } + + if (connect) { + current.push(lngLat); + } else { + if (current.length >= 2) segments.push(current); + current = [lngLat]; // start new segment from this point + } + } + + if (current.length >= 2) segments.push(current); + return segments; + } + + /* + * Draw journey segments — calls addJourneyLine once per segment. + * baseSourceId: e.g. 'journey' → sources become 'journey-0', 'journey-1', ... + * (single segment gets plain 'journey' for backwards compatibility). + */ + function addJourneySegments(map, segments, baseSourceId) { + segments.forEach(function (coords, i) { + var sid = segments.length === 1 ? baseSourceId : baseSourceId + '-' + i; + addJourneyLine(map, coords, sid); + }); + } +``` + +- [ ] **Step 4: Update the `MapUtils` export** + +Replace the existing `global.MapUtils = ...` line at the bottom of the IIFE with: + +```javascript + global.MapUtils = { + MAP_STYLE: MAP_STYLE, + ACCENT: ACCENT, + addJourneyLine: addJourneyLine, + addJourneySegments: addJourneySegments, + buildJourneySegments: buildJourneySegments, + extractTrackpoints: extractTrackpoints, + createDotMarker: createDotMarker + }; +``` + +- [ ] **Step 5: Run tests to confirm G1–G5 pass** + +```bash +npx playwright test tests/ui/gpx-journey.spec.js +``` + +Expected: All 5 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add user/themes/intotheeast/js/maplibre-utils.js tests/ui/gpx-journey.spec.js +git commit -m "feat: add GPX proximity algorithm to MapUtils (buildJourneySegments, extractTrackpoints)" +``` + +--- + +### Task 3: Rewire `map.html.twig` to use the algorithm + +**Files:** +- Modify: `user/themes/intotheeast/templates/map.html.twig` + +**Interfaces:** +- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments` +- Consumes: `entry.header.force_connect` from Grav page frontmatter + +- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation** + +In `map.html.twig`, the `map_entries` loop (lines 24–31) builds the entry JSON. Add `force_connect` to the merge array: + +```twig + {% set map_entries = map_entries|merge([{ + 'lat': entry.header.lat|number_format(6, '.', ''), + 'lng': entry.header.lng|number_format(6, '.', ''), + 'title': entry.title, + 'date': entry.date|date('d M Y'), + 'url': entry.url, + 'hero': hero_url, + 'force_connect': entry.header.force_connect ? true : false + }]) %} +``` + +- [ ] **Step 2: Restructure the JS section in `map.html.twig`** + +Replace the entire ` +``` + +- [ ] **Step 3: Verify the page loads without JS errors** + +```bash +npx playwright test tests/ui/maps.spec.js --grep "M1|M2" +``` + +Expected: M1 and M2 pass (canvas renders, markers visible, no JS errors). + +- [ ] **Step 4: Commit** + +```bash +git add user/themes/intotheeast/templates/map.html.twig +git commit -m "feat: use buildJourneySegments in map.html.twig — suppress connectors covered by GPX" +``` + +--- + +### Task 4: Rewire `trip.html.twig` mini-map to use the algorithm + +**Files:** +- Modify: `user/themes/intotheeast/templates/trip.html.twig` + +**Interfaces:** +- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments` +- Consumes: `item.page.header.force_connect` from Grav page frontmatter + +- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation** + +In `trip.html.twig`, the `map_entries` loop (around line 89–100) currently builds: + +```twig + {% set map_entries = map_entries|merge([{ + 'lat': item.page.header.lat|number_format(6, '.', ''), + 'lng': item.page.header.lng|number_format(6, '.', ''), + 'slug': item.page.slug, + 'title': item.page.title, + 'url': item.page.url + }]) %} +``` + +Add `force_connect`: + +```twig + {% set map_entries = map_entries|merge([{ + 'lat': item.page.header.lat|number_format(6, '.', ''), + 'lng': item.page.header.lng|number_format(6, '.', ''), + 'slug': item.page.slug, + 'title': item.page.title, + 'url': item.page.url, + 'force_connect': item.page.header.force_connect ? true : false + }]) %} +``` + +- [ ] **Step 2: Restructure the tripMap JS section** + +The tripMap JS block starts around line 303 (`tripMap.on('load', function () {`). Replace the entire `tripMap.on('load', ...)` block with the new version below. Everything outside `tripMap.on('load', ...)` (the `var tripMap = ...` declaration, `setTimeout(function() { tripMap.resize(); }, 100);`, and the filter bar JS) stays unchanged. + +Replace from `tripMap.on('load', function () {` through the closing `});` of that callback with: + +```javascript +tripMap.on('load', function () { + if (TRIP_ENTRIES.length === 0) { + tripMap.jumpTo({ center: [0, 20], zoom: 2 }); + return; + } + + /* ── Markers + bounds ──────────────────────────────────────── */ + var bounds = new maplibregl.LngLatBounds(); + + TRIP_ENTRIES.forEach(function (entry, i) { + var isLatest = (i === TRIP_ENTRIES.length - 1); + var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)]; + bounds.extend(lngLat); + + var el = MapUtils.createDotMarker(isLatest); + el.dataset.url = entry.url; + var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' }) + .setLngLat(lngLat) + .setHTML('' + entry.title + ''); + el.addEventListener('mouseenter', function () { popup.addTo(tripMap); }); + el.addEventListener('mouseleave', function () { popup.remove(); }); + el.addEventListener('click', function () { + var card = document.getElementById('entry-' + entry.slug); + if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + + new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(tripMap); + }); + + /* ── Fit bounds ─────────────────────────────────────────────── */ + if (TRIP_ENTRIES.length === 1) { + tripMap.jumpTo({ center: [parseFloat(TRIP_ENTRIES[0].lng), parseFloat(TRIP_ENTRIES[0].lat)], zoom: 10 }); + } else { + tripMap.fitBounds(bounds, { padding: 60, maxZoom: 11 }); + } + + /* ── GPX tracks + journey segments ─────────────────────────── */ + Promise.all(GPX_URLS.map(function (url, idx) { + return fetch(url) + .then(function (r) { return r.text(); }) + .then(function (text) { + var xml = new DOMParser().parseFromString(text, 'text/xml'); + var geojson = toGeoJSON.gpx(xml); + var sid = 'gpx-' + idx; + tripMap.addSource(sid, { type: 'geojson', data: geojson }); + tripMap.addLayer({ + id: sid + '-line', type: 'line', source: sid, + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 } + }); + return MapUtils.extractTrackpoints(geojson); + }) + .catch(function (err) { + console.warn('GPX load failed:', url, err); + return []; + }); + })).then(function (allTrackpoints) { + var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; }); + var segments = MapUtils.buildJourneySegments(TRIP_ENTRIES, validTrackpoints, 10); + MapUtils.addJourneySegments(tripMap, segments, 'trip-journey'); + }); +}); +``` + +- [ ] **Step 3: Check the stats section — preserve any remaining JS below the map block** + +Scan `trip.html.twig` for `parseGpxFiles` (around line 494). This is a separate GPX parsing call for the stats section. **Do not modify it** — it is a different code path and uses its own GPX fetching logic. + +- [ ] **Step 4: Verify the trip page renders without JS errors** + +```bash +npx playwright test tests/ui/maps.spec.js --grep "M4" +``` + +Expected: M4 passes (home map canvas renders, no JS errors). + +Also manually visit `http://localhost:8081/trips/italy-2025` in a browser and confirm the mini-map renders, markers appear, and the browser console shows no errors. + +- [ ] **Step 5: Commit** + +```bash +git add user/themes/intotheeast/templates/trip.html.twig +git commit -m "feat: use buildJourneySegments in trip.html.twig mini-map" +``` + +--- + +### Task 5: Rewire `dailies.html.twig` mini-map to use the algorithm + +**Files:** +- Modify: `user/themes/intotheeast/templates/dailies.html.twig` + +**Interfaces:** +- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments` + +- [ ] **Step 1: Add GPX URL collection to the Twig section of `dailies.html.twig`** + +After the existing `{% set map_entries = [] %}` block (around line 18–29), add GPX URL collection from the parent trip page. Insert before the `{% if map_entries|length > 0 %}` line: + +```twig +{# Collect GPX URLs from parent trip page for connector algorithm #} +{% set trip_page = page.parent() %} +{% set gpx_urls = [] %} +{% for name, media in trip_page.media.all %} + {% if name|split('.')|last == 'gpx' %} + {% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %} + {% endif %} +{% endfor %} +``` + +- [ ] **Step 2: Add `force_connect` to the Twig entry serialisation** + +In the existing `map_entries` loop (lines 21–28), add `force_connect`: + +```twig + {% set map_entries = map_entries|merge([{ + 'lat': item.page.header.lat, + 'lng': item.page.header.lng, + 'title': item.page.title, + 'slug': item.page.slug, + 'url': item.page.url, + 'force_connect': item.page.header.force_connect ? true : false + }]) %} +``` + +- [ ] **Step 3: Add `togeojson` script and `GPX_URLS` variable to the JS section** + +Inside the `{% if map_entries|length > 0 %}` block, the existing script tags are (lines 37–39): + +```html + + + +``` + +Add the toGeoJSON script between maplibre-gl.js and maplibre-utils.js: + +```html + + + + +``` + +And add the `GPX_URLS` variable immediately after `FEED_ENTRIES`: + +```javascript +var FEED_ENTRIES = {{ map_entries|json_encode|raw }}; +var GPX_URLS = {{ gpx_urls|json_encode|raw }}; +``` + +- [ ] **Step 4: Restructure `feedMap.on('load', ...)` to use Promise.all** + +Replace the existing `feedMap.on('load', function () { ... });` block with: + +```javascript +feedMap.on('load', function () { + var bounds = new maplibregl.LngLatBounds(); + + FEED_ENTRIES.forEach(function (entry, i) { + var isLatest = (i === FEED_ENTRIES.length - 1); + var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)]; + bounds.extend(lngLat); + + var el = MapUtils.createDotMarker(isLatest); + el.dataset.url = entry.url; + var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' }) + .setLngLat(lngLat) + .setHTML('' + entry.title + ''); + el.addEventListener('mouseenter', function () { popup.addTo(feedMap); }); + el.addEventListener('mouseleave', function () { popup.remove(); }); + el.addEventListener('click', function () { window.location.href = entry.url; }); + + new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap); + }); + + if (FEED_ENTRIES.length === 1) { + feedMap.jumpTo({ center: [parseFloat(FEED_ENTRIES[0].lng), parseFloat(FEED_ENTRIES[0].lat)], zoom: 10 }); + } else { + feedMap.fitBounds(bounds, { padding: 60, maxZoom: 11 }); + } + + Promise.all(GPX_URLS.map(function (url, idx) { + return fetch(url) + .then(function (r) { return r.text(); }) + .then(function (text) { + var xml = new DOMParser().parseFromString(text, 'text/xml'); + var geojson = toGeoJSON.gpx(xml); + return MapUtils.extractTrackpoints(geojson); + }) + .catch(function (err) { + console.warn('GPX load failed (feed-map):', url, err); + return []; + }); + })).then(function (allTrackpoints) { + var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; }); + var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, validTrackpoints, 10); + MapUtils.addJourneySegments(feedMap, segments, 'feed-journey'); + }); +}); +``` + +Note: the feed-map does **not** display GPX tracks as lines (it's a compact mini-map). GPX files are fetched solely for the proximity algorithm. This is intentional. + +- [ ] **Step 5: Verify no JS errors on the dailies page** + +```bash +npx playwright test tests/ui/maps.spec.js --grep "M3" +``` + +Expected: M3 passes (dailies mini-map canvas renders, no JS errors). + +- [ ] **Step 6: Commit** + +```bash +git add user/themes/intotheeast/templates/dailies.html.twig +git commit -m "feat: apply GPX connector algorithm to dailies feed mini-map" +``` + +--- + +### Task 6: Integration tests — verify algorithm is wired end-to-end + +**Files:** +- Modify: `tests/ui/maps.spec.js` + +**Interfaces:** +- Consumes: italy-2025 demo data (has GPX files); run `make demo-load` first + +- [ ] **Step 1: Add end-to-end tests to `maps.spec.js`** + +Append these tests to `tests/ui/maps.spec.js`: + +```javascript +// ── M5: Italy map — no JS errors with GPX present ──────────────────────────── +test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + + await page.goto('/trips/italy-2025/map'); + await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); + // Wait for markers to confirm map.on('load') completed + await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 }); + // Give Promise.all time to resolve + await page.waitForTimeout(3000); + + expect(errors, 'No JS errors on Italy map page').toHaveLength(0); +}); + +// ── M6: Italy map — journey source exists after GPX loads ──────────────────── +test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => { + await page.goto('/trips/italy-2025/map'); + await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 }); + + // Wait until the journey source appears — addJourneySegments runs inside Promise.all.then() + // `var map = ...` in map.html.twig is a plain