539 lines
20 KiB
Markdown
539 lines
20 KiB
Markdown
# 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 1–33) 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 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 %}
|
||
<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
|
||
```
|