# 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