35 KiB
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.jsstyle) - All JS functions inside the existing
maplibre-utils.jsIIFE - Grav blueprint fields use
header.<fieldname>prefix in theform.fieldstree - 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-loadbefore 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:
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.yamlblueprint
Create user/themes/intotheeast/blueprints/story.yaml with this full content (covers all existing story frontmatter fields plus the new Journey tab):
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
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:
// @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
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:
/* ── 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
MapUtilsexport
Replace the existing global.MapUtils = ... line at the bottom of the IIFE with:
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
npx playwright test tests/ui/gpx-journey.spec.js
Expected: All 5 tests pass.
- Step 6: Commit
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_connectfrom Grav page frontmatter -
Step 1: Add
force_connectto 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:
{% 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 42–115) 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.
<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
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
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_connectfrom Grav page frontmatter -
Step 1: Add
force_connectto the Twig entry serialisation
In trip.html.twig, the map_entries loop (around line 89–100) currently builds:
{% 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:
{% 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:
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
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
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:
{# 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_connectto the Twig entry serialisation
In the existing map_entries loop (lines 21–28), add force_connect:
{% 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
togeojsonscript andGPX_URLSvariable to the JS section
Inside the {% if map_entries|length > 0 %} block, the existing script tags are (lines 37–39):
<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:
<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:
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:
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
npx playwright test tests/ui/maps.spec.js --grep "M3"
Expected: M3 passes (dailies mini-map canvas renders, no JS errors).
- Step 6: Commit
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-loadfirst -
Step 1: Add end-to-end tests to
maps.spec.js
Append these tests to tests/ui/maps.spec.js:
// ── 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
npx playwright test
Expected: All existing tests (M1–M4, F1–F7, G1–G5, N-series, etc.) pass plus M5 and M6.
- Step 3: Commit
git add tests/ui/maps.spec.js
git commit -m "test: add M5–M6 integration tests for GPX connector logic"