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

35 KiB
Raw Blame History

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:

        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):

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: G1G4 — buildJourneySegments algorithm correctness
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
// then call the functions with synthetic data via page.evaluate.
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');

async function getMapUtils(page) {
    await page.goto('/trips/italy-2025/map');
    await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
}

// G1: No GPX → all pairs connected in one segment
test('G1: all markers connected when no GPX files present', async ({ page }) => {
    await getMapUtils(page);

    var count = await page.evaluate(function () {
        var entries = [
            { lat: '43.0', lng: '11.0', force_connect: false },
            { lat: '44.0', lng: '12.0', force_connect: false },
            { lat: '45.0', lng: '13.0', force_connect: false }
        ];
        return MapUtils.buildJourneySegments(entries, [], 10).length;
    });

    expect(count).toBe(1);
});

// G2: Same GPX file covers both markers → connector suppressed (0 segments)
test('G2: connector suppressed when same GPX file covers both markers', async ({ page }) => {
    await getMapUtils(page);

    var count = await page.evaluate(function () {
        var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
        var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
        // Trackpoints covering both (stored as [lat, lng])
        var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
        return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
    });

    expect(count).toBe(0);
});

// G3: force_connect overrides GPX suppression
test('G3: force_connect keeps connector even when GPX covers both markers', async ({ page }) => {
    await getMapUtils(page);

    var count = await page.evaluate(function () {
        var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
        var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
        var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
        return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
    });

    expect(count).toBe(1);
});

// G4: Markers near DIFFERENT GPX files → connector kept
test('G4: connector kept when markers are near different GPX files', async ({ page }) => {
    await getMapUtils(page);

    var count = await page.evaluate(function () {
        var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
        var e2 = { lat: '45.000', lng: '13.000', force_connect: false };
        // Two separate files — each only covers one marker
        var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
        var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
        return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
    });

    expect(count).toBe(1);
});

// G5: First pair suppressed, second pair kept → one segment [e2, e3]
test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page }) => {
    await getMapUtils(page);

    var count = await page.evaluate(function () {
        // e1→e2: covered by track → suppressed; e1 is orphaned (< 2 pts, not pushed)
        // e2→e3: not covered → connector kept → segment [e2, e3]
        var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
        var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
        var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
        var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
        var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
        return segs.length;
    });

    expect(count).toBe(1); // one segment: [e2 → e3]
});
  • Step 2: Run tests to confirm they fail
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 MapUtils export

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 G1G5 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_connect from Grav page frontmatter

  • Step 1: Add force_connect to the Twig entry serialisation

In map.html.twig, the map_entries loop (lines 2431) builds the entry JSON. Add force_connect to the merge array:

        {% set map_entries = map_entries|merge([{
            'lat': entry.header.lat|number_format(6, '.', ''),
            'lng': entry.header.lng|number_format(6, '.', ''),
            'title': entry.title,
            'date': entry.date|date('d M Y'),
            'url': entry.url,
            'hero': hero_url,
            'force_connect': entry.header.force_connect ? true : false
        }]) %}
  • Step 2: Restructure the JS section in map.html.twig

Replace the entire <script> block (lines 42115) with the following. Key changes: GPX loading now returns Promises with extracted trackpoints; markers and bounds are set up before GPX loads; journey segments are drawn only after Promise.all resolves.

<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_connect from Grav page frontmatter

  • Step 1: Add force_connect to the Twig entry serialisation

In trip.html.twig, the map_entries loop (around line 89100) currently builds:

        {% 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 1829), 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_connect to the Twig entry serialisation

In the existing map_entries loop (lines 2128), 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 togeojson script and GPX_URLS variable to the JS section

Inside the {% if map_entries|length > 0 %} block, the existing script tags are (lines 3739):

<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-load first

  • 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 (M1M4, F1F7, G1G5, N-series, etc.) pass plus M5 and M6.

  • Step 3: Commit
git add tests/ui/maps.spec.js
git commit -m "test: add M5M6 integration tests for GPX connector logic"