refactor(theme): consolidate map init into MapUtils.initEntryMap

Extract the duplicated MapLibre init orchestration (map construction,
marker/popup loop, bounds fit, GPX journey, fullscreen toggle) into one
config-driven MapUtils.initEntryMap(opts) in maplibre-utils.js, bundled
into map.js. Convert trip.html.twig and both home.html.twig branches to
call it; home active gains the flash-highlight + a fullscreen button to
match trip, and home highlights' marker click now navigates to the
article. Adds a markLatest opt (false for highlights) and exposes the
map as window.tripMap/window.homeMap (used by existing Playwright specs).
feed-map.html.twig and map.html.twig left on their inline init (deferred).

Plan: docs/working/plans/2026-06-27-map-init-consolidation.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
This commit is contained in:
2026-06-28 00:11:34 +02:00
co-authored by Claude Opus 4.8
parent 75da83d6e1
commit 29535e39b8
4 changed files with 186 additions and 190 deletions
File diff suppressed because one or more lines are too long
+130
View File
@@ -417,8 +417,138 @@
}
}
/*
* Config-driven entry-map initialisation — the single orchestration shared by
* the trip page and both home views. Mirrors the trip page's canonical handler:
* compact collapsed attribution bottom-left, an on('load') marker loop with a
* map-tip hover popup, bounds fit (jumpTo for one entry), GPX journey rendering,
* and the fullscreen toggle.
*
* opts:
* container string — map div id ('trip-map', 'home-map')
* entries array — parsed map_entries [{lat,lng,slug,title,url,type?,force_connect?}, ...]
* cardPrefix string? — e.g. 'entry-'; when set and a card #prefix+slug exists,
* click scrolls+flashes that card; otherwise click navigates to entry.url
* storyMarkers bool — render createStoryMarker() for entry.type === 'story' (default: dot markers)
* markLatest bool — enlarge the final non-story entry's dot (default true; pass false for
* unordered sets like home highlights so no marker is singled out)
* fullscreen object? — { btnId, colSelector }; wires the fullscreen toggle + fullscreen-aware click
* gpx object? — { urls, use, autoconnect, sourcePrefix, journeyId }; forwarded to renderGpxJourney
* fit object? — { padding=60, maxZoom=11, singleZoom=10 }
*
* Returns the maplibregl.Map instance.
*/
function initEntryMap(opts) {
opts = opts || {};
var entries = opts.entries || [];
var fit = opts.fit || {};
var padding = fit.padding != null ? fit.padding : 60;
var maxZoom = fit.maxZoom != null ? fit.maxZoom : 11;
var singleZoom = fit.singleZoom != null ? fit.singleZoom : 10;
var markLatest = opts.markLatest !== false;
var map = new maplibregl.Map({
container: opts.container,
style: MAP_STYLE,
center: [20, 20],
zoom: 2,
attributionControl: false
});
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
map.on('load', function () {
if (entries.length === 0) {
map.jumpTo({ center: [0, 20], zoom: 2 });
return;
}
var bounds = new maplibregl.LngLatBounds();
entries.forEach(function (entry, i) {
var isLatest = markLatest && (entry.type !== 'story') && (i === entries.length - 1);
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
bounds.extend(lngLat);
var el = (opts.storyMarkers && entry.type === 'story')
? createStoryMarker()
: createDotMarker(isLatest);
el.dataset.url = entry.url;
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
.setLngLat(lngLat)
.setHTML('<span class="map-tip">' + entry.title + '</span>');
el.addEventListener('mouseenter', function () { popup.addTo(map); });
el.addEventListener('mouseleave', function () { popup.remove(); });
el.addEventListener('click', function () {
var card = opts.cardPrefix ? document.getElementById(opts.cardPrefix + entry.slug) : null;
if (!card) { window.location.href = entry.url; return; }
var hashId = opts.cardPrefix + entry.slug;
function scrollAndHighlight() {
window.location.hash = hashId;
setTimeout(function () {
card.classList.add('is-highlighted');
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
}, 350);
}
if (opts.fullscreen) {
var col = document.querySelector(opts.fullscreen.colSelector);
var isFs = col && col.classList.contains('is-fullscreen');
if (isFs) {
var fsBtn = document.getElementById(opts.fullscreen.btnId);
if (fsBtn) fsBtn.click();
setTimeout(scrollAndHighlight, 450);
return;
}
}
scrollAndHighlight();
});
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(map);
});
if (entries.length === 1) {
map.jumpTo({ center: [parseFloat(entries[0].lng), parseFloat(entries[0].lat)], zoom: singleZoom });
} else {
map.fitBounds(bounds, { padding: padding, maxZoom: maxZoom });
}
if (opts.gpx) {
renderGpxJourney(
map,
opts.gpx.use ? (opts.gpx.urls || []) : [],
entries,
opts.gpx.sourcePrefix,
opts.gpx.journeyId,
{ connectMode: opts.gpx.autoconnect }
);
}
/* Collapse attribution <details> which MapLibre may open on load */
var attrib = map.getContainer().querySelector('.maplibregl-ctrl-attrib');
if (attrib) attrib.removeAttribute('open');
});
setTimeout(function () { map.resize(); }, 100);
if (opts.fullscreen) {
var fsBtn = document.getElementById(opts.fullscreen.btnId);
var col = document.querySelector(opts.fullscreen.colSelector);
if (fsBtn && col) {
fsBtn.addEventListener('click', function () {
var isFs = col.classList.toggle('is-fullscreen');
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
document.body.style.overflow = isFs ? 'hidden' : '';
setTimeout(function () { map.resize(); }, 50);
});
}
}
return map;
}
global.MapUtils = {
MAP_STYLE: MAP_STYLE,
initEntryMap: initEntryMap,
ACCENT: ACCENT,
haversineKm: haversineKm,
parseGpxFiles: parseGpxFiles,