docs: add GPX connector logic implementation plan
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
# GPX Connector Logic Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Suppress the straight-line connector between adjacent map markers when a single GPX file covers both endpoints; keep connectors for uncovered gaps; add `force_connect` and `transport_mode` fields to entry/story blueprints.
|
||||
|
||||
**Architecture:** Pure client-side. GPX files are already fetched to display tracks; their parsed trackpoints are reused to run a same-file proximity check per adjacent marker pair. Journey segments are built after all GPX fetches settle (Promise.all). The algorithm lives in `maplibre-utils.js` as pure functions exposed on `MapUtils`.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES5 IIFE pattern matching existing code), MapLibre GL 4, `@mapbox/togeojson` 0.16.2, Grav 2 blueprint YAML, Playwright for tests.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- ES5 syntax only in all JS — no arrow functions, const/let, template literals, or modules (matching existing `maplibre-utils.js` style)
|
||||
- All JS functions inside the existing `maplibre-utils.js` IIFE
|
||||
- Grav blueprint fields use `header.<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"
|
||||
```
|
||||
Reference in New Issue
Block a user