20 KiB
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.jsand.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-accentintokens.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 cooperativeGestureson embedded maps (mini-map, home map); full-page map uses default (free) gestures- No new Grav plugins, no npm — CDN only
- Run
make content-pushafter 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/mapin browserIf no entries exist, run
make demo-loadfirst. 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.jsExpected: 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.MapUtilsfrom Task 2 (MAP_STYLE,addJourneyLine,createDotMarker) - Twig data shape consumed unchanged:
map_entriesarray withlat,lng,title,date,url,herokeys;gpx_urlsarray 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 1–33) 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/mapWith 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 37–78) - Modify:
user/themes/intotheeast/templates/home.html.twig(map section, around lines 126–168)
Interfaces:
- Consumes:
window.MapUtilsfrom Task 2 - Twig data shapes unchanged:
map_entries(both files) withlat,lng,title,slug,urlkeys
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.twigFind 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.twigFind 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