Files
intotheeast-com/docs/superpowers/plans/2026-06-20-gpx-connector-logic.md
T

936 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.<fieldname>` 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: G1G4 — 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 G1G5 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 2431) 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 `<script>` block (lines 42115) with the following. Key changes: GPX loading now returns Promises with extracted trackpoints; markers and bounds are set up before GPX loads; journey segments are drawn only after Promise.all resolves.
```javascript
<script>
var ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
var map = new maplibregl.Map({
container: 'trip-map',
style: MapUtils.MAP_STYLE,
center: [20, 20],
zoom: 2
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
if (ENTRIES.length === 0) {
var empty = document.createElement('div');
empty.className = 'map-empty';
empty.textContent = 'No locations yet — entries with GPS will appear here.';
document.getElementById('trip-map').appendChild(empty);
}
map.on('load', function () {
if (ENTRIES.length === 0) return;
/* ── Markers + bounds ──────────────────────────────────────── */
var bounds = new maplibregl.LngLatBounds();
ENTRIES.forEach(function (entry, i) {
var isLatest = (i === 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('<span class="map-tip">' + entry.title + '</span>');
el.addEventListener('mouseenter', function () { popup.addTo(map); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () { window.location.href = entry.url; });
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(map);
});
/* ── Fit bounds ─────────────────────────────────────────────── */
if (ENTRIES.length === 1) {
map.jumpTo({ center: [parseFloat(ENTRIES[0].lng), parseFloat(ENTRIES[0].lat)], zoom: 10 });
} else {
map.fitBounds(bounds, { padding: 100, 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;
map.addSource(sid, { type: 'geojson', data: geojson });
map.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(ENTRIES, validTrackpoints, 10);
MapUtils.addJourneySegments(map, segments, 'journey');
});
});
</script>
```
- [ ] **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 89100) 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('<span class="map-tip">' + entry.title + '</span>');
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 1829), 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 2128), 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 3739):
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
```
Add the toGeoJSON script between maplibre-gl.js and maplibre-utils.js:
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
```
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('<span class="map-tip">' + entry.title + '</span>');
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 <script> var → available as window.map.
await page.waitForFunction(function () {
return window.map &&
(window.map.getSource('journey') !== undefined ||
window.map.getSource('journey-0') !== undefined);
}, { timeout: 15000 });
const hasSource = await page.evaluate(function () {
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
});
expect(hasSource).toBe(true);
});
```
- [ ] **Step 2: Run the full test suite**
```bash
npx playwright test
```
Expected: All existing tests (M1M4, F1F7, G1G5, N-series, etc.) pass plus M5 and M6.
- [ ] **Step 3: Commit**
```bash
git add tests/ui/maps.spec.js
git commit -m "test: add M5M6 integration tests for GPX connector logic"
```