docs: mark maplibre-migration and story-mode plans complete

This commit is contained in:
2026-06-20 20:02:39 +02:00
parent cf03eebb72
commit 6135a680fe
2 changed files with 1904 additions and 0 deletions
@@ -0,0 +1,538 @@
# 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
```