936 lines
35 KiB
Markdown
936 lines
35 KiB
Markdown
# 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: 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 `<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.
|
||
|
||
```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 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('<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 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
|
||
<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 (M1–M4, F1–F7, G1–G5, N-series, etc.) pass plus M5 and M6.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add tests/ui/maps.spec.js
|
||
git commit -m "test: add M5–M6 integration tests for GPX connector logic"
|
||
```
|