Files
intotheeast-com/docs/superpowers/plans/2026-06-19-maplibre-migration.md
T

20 KiB
Raw Blame History

MapLibre GL Migration 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.

Status: Complete (2026-06-20)

Goal: Replace Leaflet JS across all three maps (full map, mini-map on dailies, home page map) with MapLibre GL JS, add an animated journey line, and improve map CSS using our design tokens.

Architecture: A shared JS utility file (maplibre-utils.js) provides animateJourneyLine, addJourneyLine, and createDotMarker — reused by all three map templates. Each template loads MapLibre GL + the utility file, then calls these helpers. GPX rendering switches from leaflet-gpx to @mapbox/togeojson + MapLibre GeoJSON layers.

Tech Stack: MapLibre GL JS 4.x (CDN), @mapbox/togeojson 0.16.2 (CDN), CARTO dark-matter vector style (free, no key), vanilla JS (no framework).

Global Constraints

  • MapLibre GL CDN: https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js and .css
  • toGeoJSON CDN: https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js
  • Map tile style URL: https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json
  • Accent colour (journey line, markers): #2A8C73 — matches --color-accent in tokens.css
  • Latest-entry marker accent: #155244 (same as current Leaflet code)
  • Animation duration: 5000ms, ease-out cubic
  • Respect prefers-reduced-motion: reduce — skip animation, show full line immediately
  • cooperativeGestures on embedded maps (mini-map, home map); full-page map uses default (free) gestures
  • No new Grav plugins, no npm — CDN only
  • Run make content-push after changes to sync to production git repo

Task 1: CSS — Remove Leaflet override, add MapLibre design-token styles

Files:

  • Modify: user/themes/intotheeast/css/style.css (around line 371)

What: Delete the one Leaflet-specific rule and add a MapLibre CSS block that styles navigation controls, attribution bar, popups, and cursor using design tokens.

  • Open style.css and find the Leaflet block

    Locate (around line 371):

    /* match CartoDB dark tile background so no grey flash on load/zoom */
    .leaflet-container { background: #282828 !important; }
    
  • Delete that rule and replace with the MapLibre block

    Delete the line above. Immediately after the .map-empty { ... } block (around line 381), add:

    /* ── MapLibre GL overrides ───────────────────────────────────────────────── */
    
    /* Navigation controls (zoom +/) */
    .maplibregl-ctrl-group {
        background: var(--color-canvas);
        border: 1px solid var(--color-border);
        border-radius: var(--radius-sm);
        box-shadow: var(--shadow-sm);
    }
    .maplibregl-ctrl-group button {
        color: var(--color-ink-2);
    }
    .maplibregl-ctrl-group button:hover {
        background: var(--color-surface-raised);
        color: var(--color-ink);
    }
    .maplibregl-ctrl-group button + button {
        border-top: 1px solid var(--color-border);
    }
    
    /* Attribution bar */
    .maplibregl-ctrl-attrib {
        background: rgba(26, 24, 20, 0.75) !important;
        color: var(--color-ink-muted) !important;
        font-family: var(--font-ui);
        font-size: 0.7rem;
        backdrop-filter: blur(4px);
        -webkit-backdrop-filter: blur(4px);
    }
    .maplibregl-ctrl-attrib a {
        color: var(--color-accent) !important;
    }
    
    /* Popup */
    .maplibregl-popup-content {
        background: var(--color-canvas);
        color: var(--color-ink);
        font-family: var(--font-ui);
        border: 1px solid var(--color-border);
        border-radius: var(--radius-md);
        box-shadow: var(--shadow-md);
        padding: var(--space-4);
    }
    .maplibregl-popup-tip {
        border-top-color: var(--color-canvas) !important;
    }
    .maplibregl-popup-close-button {
        color: var(--color-ink-muted);
        font-size: 1.1rem;
        padding: var(--space-1) var(--space-2);
    }
    .maplibregl-popup-close-button:hover {
        color: var(--color-ink);
        background: transparent;
    }
    
    /* Cursor */
    .maplibregl-canvas-container.maplibregl-interactive { cursor: grab; }
    .maplibregl-canvas-container.maplibregl-interactive:active { cursor: grabbing; }
    
  • Verify: open http://localhost:8081/map in browser

    If no entries exist, run make demo-load first. Check:

    • No JS errors in console
    • Page layout unchanged (map still fills viewport below nav)
  • Commit

    git -C user add themes/intotheeast/css/style.css
    git -C user commit -m "style: swap Leaflet CSS override for MapLibre design-token styles"
    

Task 2: Shared JS utilities file

Files:

  • Create: user/themes/intotheeast/js/maplibre-utils.js

Interfaces:

  • Produces: window.MapUtils.animateJourneyLine(map, coords, sourceId), window.MapUtils.addJourneyLine(map, coords, sourceId), window.MapUtils.createDotMarker(isLatest), window.MapUtils.MAP_STYLE, window.MapUtils.ACCENT
  • Loaded by: all three map templates via <script src="{{ url('theme://js/maplibre-utils.js') }}"></script>

What: Extract the animated journey line logic and marker factory into a single file so all three templates share one implementation.

  • Create user/themes/intotheeast/js/maplibre-utils.js

    /* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
    (function (global) {
      var ACCENT      = '#2A8C73';
      var ACCENT_DIM  = '#155244';
      var MAP_STYLE   = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
    
      /* Build a GeoJSON LineString feature */
      function lineFeature(coords) {
        return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } };
      }
    
      /*
       * Progressively draw the journey line using a requestAnimationFrame loop.
       * coords: [[lng, lat], ...] in chronological order.
       * sourceId: the MapLibre source id to update each frame.
       */
      function animateJourneyLine(map, coords, sourceId) {
        if (coords.length < 2) return;
    
        /* Cumulative Euclidean distance between waypoints */
        var segDist = [0];
        for (var i = 1; i < coords.length; i++) {
          var dx = coords[i][0] - coords[i - 1][0];
          var dy = coords[i][1] - coords[i - 1][1];
          segDist.push(segDist[i - 1] + Math.sqrt(dx * dx + dy * dy));
        }
        var totalDist = segDist[segDist.length - 1];
        var DURATION  = 5000;
        var startTime = performance.now();
    
        function frame(now) {
          if (!map.getSource(sourceId)) return; /* map was removed */
          var t      = Math.min((now - startTime) / DURATION, 1);
          var eased  = 1 - Math.pow(1 - t, 3); /* ease-out cubic */
          var target = eased * totalDist;
    
          var animCoords = [coords[0]];
          for (var j = 1; j < coords.length; j++) {
            if (segDist[j] <= target) {
              animCoords.push(coords[j]);
            } else {
              var frac = (target - segDist[j - 1]) / (segDist[j] - segDist[j - 1]);
              animCoords.push([
                coords[j - 1][0] + (coords[j][0] - coords[j - 1][0]) * frac,
                coords[j - 1][1] + (coords[j][1] - coords[j - 1][1]) * frac
              ]);
              break;
            }
          }
    
          map.getSource(sourceId).setData(lineFeature(animCoords));
          if (t < 1) requestAnimationFrame(frame);
        }
    
        requestAnimationFrame(frame);
      }
    
      /*
       * Add a journey line source + two layers (glow + main) to a loaded map,
       * then animate or draw instantly based on prefers-reduced-motion.
       */
      function addJourneyLine(map, coords, sourceId) {
        if (coords.length < 2) return;
    
        map.addSource(sourceId, { type: 'geojson', data: lineFeature([coords[0]]) });
    
        map.addLayer({
          id: sourceId + '-glow', type: 'line', source: sourceId,
          layout: { 'line-join': 'round', 'line-cap': 'round' },
          paint:  { 'line-color': ACCENT, 'line-width': 6, 'line-opacity': 0.18 }
        });
    
        map.addLayer({
          id: sourceId + '-line', type: 'line', source: sourceId,
          layout: { 'line-join': 'round', 'line-cap': 'round' },
          paint:  { 'line-color': ACCENT, 'line-width': 2.5, 'line-opacity': 0.85 }
        });
    
        var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        if (reducedMotion) {
          map.getSource(sourceId).setData(lineFeature(coords));
        } else {
          animateJourneyLine(map, coords, sourceId);
        }
      }
    
      /*
       * Return a styled <div> element for a map marker dot.
       * isLatest: make it larger with a teal ring.
       */
      function createDotMarker(isLatest) {
        var el   = document.createElement('div');
        var size = isLatest ? 18 : 12;
        var bg   = isLatest ? ACCENT_DIM : ACCENT;
        var ring = isLatest ? ',0 0 0 4px rgba(42,140,115,0.25)' : '';
        el.style.cssText = [
          'width:' + size + 'px',
          'height:' + size + 'px',
          'background:' + bg,
          'border:2px solid #fff',
          'border-radius:50%',
          'box-shadow:0 1px 4px rgba(0,0,0,0.4)' + ring,
          'cursor:pointer'
        ].join(';');
        return el;
      }
    
      global.MapUtils = { MAP_STYLE: MAP_STYLE, ACCENT: ACCENT, addJourneyLine: addJourneyLine, createDotMarker: createDotMarker };
    })(window);
    
  • Verify the file parses without syntax errors

    node --check /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/maplibre-utils.js
    

    Expected: no output (clean parse).

  • Commit

    git -C user add themes/intotheeast/js/maplibre-utils.js
    git -C user commit -m "feat: add shared MapLibre GL utilities (journey line, markers)"
    

Task 3: Full map page — migrate map.html.twig

Files:

  • Modify: user/themes/intotheeast/templates/map.html.twig

Interfaces:

  • Consumes: window.MapUtils from Task 2 (MAP_STYLE, addJourneyLine, createDotMarker)
  • Twig data shape consumed unchanged: map_entries array with lat, lng, title, date, url, hero keys; gpx_urls array of strings

What: Replace the Leaflet map + GPX rendering with MapLibre GL. Keep all Twig data-gathering logic at the top unchanged. Only the HTML/CSS/JS at the bottom changes.

  • Replace everything from <div class="map-container"...> to end of {% endblock %}

    The Twig data-gathering at the top (lines 133) is unchanged. Replace from line 35 onwards with:

    <div class="map-container" id="trip-map"></div>
    
    <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>
    
    <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 () {
        /* ── GPX tracks ──────────────────────────────────────────── */
        GPX_URLS.forEach(function (url, idx) {
            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 }
                    });
                })
                .catch(function (err) { console.warn('GPX load failed:', url, err); });
        });
    
        if (ENTRIES.length === 0) return;
    
        /* ── Markers ─────────────────────────────────────────────── */
        var bounds = new maplibregl.LngLatBounds();
        var coords = [];
    
        ENTRIES.forEach(function (entry, i) {
            var isLatest = (i === ENTRIES.length - 1);
            var lngLat   = [parseFloat(entry.lng), parseFloat(entry.lat)];
            coords.push(lngLat);
            bounds.extend(lngLat);
    
            var el = MapUtils.createDotMarker(isLatest);
    
            var popupHtml = '<div style="min-width:160px;max-width:200px;">';
            if (entry.hero) {
                popupHtml += '<img src="' + entry.hero + '" alt="" style="width:100%;height:80px;object-fit:cover;border-radius:4px;display:block;margin-bottom:8px;">';
            }
            popupHtml += '<div style="font-size:0.75rem;color:var(--color-ink-muted);margin-bottom:2px;">📅 ' + entry.date + '</div>';
            popupHtml += '<div style="font-weight:600;font-size:0.9rem;margin-bottom:8px;color:var(--color-ink);">' + entry.title + '</div>';
            popupHtml += '<a href="' + entry.url + '" style="color:var(--color-accent);font-size:0.85rem;text-decoration:none;">Read entry →</a>';
            popupHtml += '</div>';
    
            new maplibregl.Marker({ element: el })
                .setLngLat(lngLat)
                .setPopup(new maplibregl.Popup({ offset: 10, maxWidth: '220px' }).setHTML(popupHtml))
                .addTo(map);
        });
    
        /* ── Journey line ────────────────────────────────────────── */
        MapUtils.addJourneyLine(map, coords, 'journey');
    
        /* ── Fit bounds ──────────────────────────────────────────── */
        if (ENTRIES.length === 1) {
            map.jumpTo({ center: coords[0], zoom: 10 });
        } else {
            map.fitBounds(bounds, { padding: 60, maxZoom: 11 });
        }
    });
    </script>
    {% endblock %}
    
  • Verify in browser at http://localhost:8081/trips/japan-korea-2026/map

    With demo data loaded (make demo-load):

    • Dark vector map fills the viewport
    • 7 teal dot markers visible on Japan→Korea route
    • Journey line animates in over ~5 seconds on load
    • Click a marker → popup appears with date, title, "Read entry →" link
    • Navigate controls (zoom +/) are styled with dark background (design tokens)
    • Attribution bar is dark/muted (not white)
    • No console errors
  • Commit

    git -C user add themes/intotheeast/templates/map.html.twig
    git -C user commit -m "feat: migrate full map page to MapLibre GL with animated journey line"
    

Task 4: Embedded maps — migrate dailies mini-map and home map

Files:

  • Modify: user/themes/intotheeast/templates/dailies.html.twig (mini-map section, around lines 3778)
  • Modify: user/themes/intotheeast/templates/home.html.twig (map section, around lines 126168)

Interfaces:

  • Consumes: window.MapUtils from Task 2
  • Twig data shapes unchanged: map_entries (both files) with lat, lng, title, slug, url keys

What: Both embedded maps follow the same pattern — no GPX, no popup (markers navigate on click), cooperativeGestures: true to prevent mobile scroll-trap, animated line via MapUtils.addJourneyLine.

  • Replace the map block in dailies.html.twig

    Find the {% if map_entries|length > 0 %} block (around line 31) and replace from there to the closing {% endif %} and the script block:

    {% if map_entries|length > 0 %}
    <div class="feed-map-wrap">
        <div class="feed-map" id="feed-map"></div>
        <a class="feed-map-link" href="{{ page.parent().url }}/map">View full map →</a>
    </div>
    
    <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>
    <script>
    var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
    
    var feedMap = new maplibregl.Map({
        container: 'feed-map',
        style: MapUtils.MAP_STYLE,
        center: [20, 20],
        zoom: 2,
        cooperativeGestures: true
    });
    
    feedMap.on('load', function () {
        var bounds = new maplibregl.LngLatBounds();
        var coords = [];
    
        FEED_ENTRIES.forEach(function (entry, i) {
            var isLatest = (i === FEED_ENTRIES.length - 1);
            var lngLat   = [parseFloat(entry.lng), parseFloat(entry.lat)];
            coords.push(lngLat);
            bounds.extend(lngLat);
    
            var el = MapUtils.createDotMarker(isLatest);
            el.addEventListener('click', function () {
                window.location.href = entry.url;
            });
    
            new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap);
        });
    
        MapUtils.addJourneyLine(feedMap, coords, 'feed-journey');
    
        if (FEED_ENTRIES.length === 1) {
            feedMap.jumpTo({ center: coords[0], zoom: 10 });
        } else {
            feedMap.fitBounds(bounds, { padding: 20, maxZoom: 11 });
        }
    });
    </script>
    {% endif %}
    
  • Replace the map block in home.html.twig

    Find the {% if map_entries|length > 0 %} block (around line 125) and replace from there to end of {% endblock %}:

    {% if map_entries|length > 0 %}
    <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>
    <script>
    var HOME_ENTRIES = {{ map_entries|json_encode|raw }};
    
    var homeMap = new maplibregl.Map({
        container: 'home-map',
        style: MapUtils.MAP_STYLE,
        center: [20, 20],
        zoom: 2,
        cooperativeGestures: true
    });
    
    homeMap.on('load', function () {
        var bounds = new maplibregl.LngLatBounds();
        var coords = [];
    
        HOME_ENTRIES.forEach(function (entry, i) {
            var isLatest = (i === HOME_ENTRIES.length - 1);
            var lngLat   = [parseFloat(entry.lng), parseFloat(entry.lat)];
            coords.push(lngLat);
            bounds.extend(lngLat);
    
            var el = MapUtils.createDotMarker(isLatest);
            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(homeMap);
        });
    
        MapUtils.addJourneyLine(homeMap, coords, 'home-journey');
    
        if (HOME_ENTRIES.length === 1) {
            homeMap.jumpTo({ center: coords[0], zoom: 10 });
        } else {
            homeMap.fitBounds(bounds, { padding: 20, maxZoom: 11 });
        }
    
        setTimeout(function () { homeMap.resize(); }, 100);
    });
    </script>
    {% endif %}
    {% endblock %}
    
  • Verify mini-map at http://localhost:8081/trips/japan-korea-2026/dailies

    • Mini-map appears above journal feed with dark vector tiles
    • Journey line animates in
    • Click a marker → navigates to that entry's page (not a popup)
    • On mobile: pinch-zoom within the mini-map requires two fingers; one finger scrolls the page past it
    • "View full map →" link works
  • Verify home map at http://localhost:8081

    • Left column sticky map shows dark vector tiles
    • Journey line animates in
    • Click a marker → page scrolls to the matching entry card in the right column
    • On mobile (< 768px): map collapses to 40vh above the feed, touch-scroll works on page
  • Commit

    git -C user add themes/intotheeast/templates/dailies.html.twig themes/intotheeast/templates/home.html.twig
    git -C user commit -m "feat: migrate mini-map and home map to MapLibre GL"
    
  • Final sync

    make content-push