# 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