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

539 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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.
- [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 <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);
```
- [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 `<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:
```twig
<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 %}
```
- [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 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`.
- [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 %}
<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 %}
```
- [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 %}
<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 %}
```
- [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
```