# 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. - [x] **Open style.css and find the Leaflet block** Locate (around line 371): ```css /* match CartoDB dark tile background so no grey flash on load/zoom */ .leaflet-container { background: #282828 !important; } ``` - [x] **Delete that rule and replace with the MapLibre block** Delete the line above. Immediately after the `.map-empty { ... }` block (around line 381), add: ```css /* ── 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; } ``` - [x] **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) - [x] **Commit** ```bash 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 `` **What:** Extract the animated journey line logic and marker factory into a single file so all three templates share one implementation. - [x] **Create `user/themes/intotheeast/js/maplibre-utils.js`** ```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
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); ``` - [x] **Verify the file parses without syntax errors** ```bash node --check /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/maplibre-utils.js ``` Expected: no output (clean parse). - [x] **Commit** ```bash 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. - [x] **Replace everything from `
` to end of `{% endblock %}`** The Twig data-gathering at the top (lines 1–33) is unchanged. Replace from line 35 onwards with: ```twig
{% endblock %} ``` - [x] **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 - [x] **Commit** ```bash 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.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`. - [x] **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: ```twig {% if map_entries|length > 0 %} {% endif %} ``` - [x] **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 %}`: ```twig {% if map_entries|length > 0 %} {% endif %} {% endblock %} ``` - [x] **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 - [x] **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 - [x] **Commit** ```bash 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" ``` - [x] **Final sync** ```bash make content-push ```