/* 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 } };
}
/*
* Centripetal Catmull-Rom spline (α=0.5) through waypoints → dense coords.
* Parameterising by √chord-length prevents the large bow that uniform
* parameterisation produces when consecutive points are very close together
* but their neighbours are far away (e.g. two entries both in Berlin).
*/
function catmullRomSpline(coords, steps) {
if (coords.length < 2) return coords;
steps = steps || 16;
var alpha = 0.5;
var out = [];
/* Phantom endpoints via reflection so the spline reaches the first and last real points */
var ext = [
[2*coords[0][0] - coords[1][0], 2*coords[0][1] - coords[1][1]]
].concat(coords).concat([
[2*coords[coords.length-1][0] - coords[coords.length-2][0],
2*coords[coords.length-1][1] - coords[coords.length-2][1]]
]);
function segT(a, b) {
var dx = b[0]-a[0], dy = b[1]-a[1];
return Math.pow(Math.max(Math.sqrt(dx*dx + dy*dy), 1e-10), alpha);
}
for (var i = 1; i < ext.length - 2; i++) {
var p0 = ext[i-1], p1 = ext[i], p2 = ext[i+1], p3 = ext[i+2];
var t0 = 0;
var t1 = t0 + segT(p0, p1);
var t2 = t1 + segT(p1, p2);
var t3 = t2 + segT(p2, p3);
for (var s = 0; s < steps; s++) {
var t = t1 + (t2 - t1) * s / steps;
var a1x = (t1-t)/(t1-t0)*p0[0] + (t-t0)/(t1-t0)*p1[0];
var a1y = (t1-t)/(t1-t0)*p0[1] + (t-t0)/(t1-t0)*p1[1];
var a2x = (t2-t)/(t2-t1)*p1[0] + (t-t1)/(t2-t1)*p2[0];
var a2y = (t2-t)/(t2-t1)*p1[1] + (t-t1)/(t2-t1)*p2[1];
var a3x = (t3-t)/(t3-t2)*p2[0] + (t-t2)/(t3-t2)*p3[0];
var a3y = (t3-t)/(t3-t2)*p2[1] + (t-t2)/(t3-t2)*p3[1];
var b1x = (t2-t)/(t2-t0)*a1x + (t-t0)/(t2-t0)*a2x;
var b1y = (t2-t)/(t2-t0)*a1y + (t-t0)/(t2-t0)*a2y;
var b2x = (t3-t)/(t3-t1)*a2x + (t-t1)/(t3-t1)*a3x;
var b2y = (t3-t)/(t3-t1)*a2y + (t-t1)/(t3-t1)*a3y;
out.push([
(t2-t)/(t2-t1)*b1x + (t-t1)/(t2-t1)*b2x,
(t2-t)/(t2-t1)*b1y + (t-t1)/(t2-t1)*b2y
]);
}
}
out.push(coords[coords.length - 1]);
return out;
}
/*
* Progressively draw the journey line using a requestAnimationFrame loop.
* splineCoords: dense interpolated coords from catmullRomSpline().
*/
function animateJourneyLine(map, splineCoords, sourceId) {
if (splineCoords.length < 2) return;
var segDist = [0];
for (var i = 1; i < splineCoords.length; i++) {
var dx = splineCoords[i][0] - splineCoords[i - 1][0];
var dy = splineCoords[i][1] - splineCoords[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;
var t = Math.min((now - startTime) / DURATION, 1);
var eased = 1 - Math.pow(1 - t, 3);
var target = eased * totalDist;
var animCoords = [splineCoords[0]];
for (var j = 1; j < splineCoords.length; j++) {
if (segDist[j] <= target) {
animCoords.push(splineCoords[j]);
} else {
var frac = (target - segDist[j - 1]) / (segDist[j] - segDist[j - 1]);
animCoords.push([
splineCoords[j - 1][0] + (splineCoords[j][0] - splineCoords[j - 1][0]) * frac,
splineCoords[j - 1][1] + (splineCoords[j][1] - splineCoords[j - 1][1]) * frac
]);
break;
}
}
map.getSource(sourceId).setData(lineFeature(animCoords));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/*
* Add a journey line to a loaded map — dotted, subordinate style so GPX
* tracks read as the primary route where they exist.
* coords: [[lng, lat], ...] raw waypoints (daily entry positions).
*/
function addJourneyLine(map, coords, sourceId) {
if (coords.length < 2) return;
var splineCoords = catmullRomSpline(coords, 16);
map.addSource(sourceId, { type: 'geojson', data: lineFeature([splineCoords[0]]) });
map.addLayer({
id: sourceId + '-line', type: 'line', source: sourceId,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': ACCENT,
'line-width': 2,
'line-opacity': 0.45,
'line-dasharray': [0, 2.5]
}
});
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
map.getSource(sourceId).setData(lineFeature(splineCoords));
} else {
animateJourneyLine(map, splineCoords, sourceId);
}
}
/*
* Return a styled
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;
}
/*
* Return a styled
element for a story marker (white diamond, teal border).
*/
function createStoryMarker() {
var el = document.createElement('div');
el.style.cssText = [
'width:10px',
'height:10px',
'background:#fff',
'border:2px solid ' + ACCENT,
'transform:rotate(45deg)',
'box-shadow:0 1px 4px rgba(0,0,0,0.4)',
'cursor:pointer'
].join(';');
return el;
}
/* Haversine distance in km between two [lat, lng] points */
function haversineKm(lat1, lng1, lat2, lng2) {
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLng = (lng2 - lng1) * Math.PI / 180;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/* Extract [[lat, lng], ...] from a toGeoJSON output (flips GeoJSON [lng,lat] order). */
function extractTrackpoints(geojson) {
var points = [];
(geojson.features || []).forEach(function (feat) {
var coords = [];
if (feat.geometry.type === 'LineString') {
coords = feat.geometry.coordinates;
} else if (feat.geometry.type === 'MultiLineString') {
feat.geometry.coordinates.forEach(function (line) { coords = coords.concat(line); });
}
coords.forEach(function (c) { points.push([c[1], c[0]]); });
});
return points;
}
/* True if [markerLat, markerLng] is within thresholdKm of any point in trackpoints [[lat,lng],...]. */
function isNearTrack(markerLat, markerLng, trackpoints, thresholdKm) {
if (!trackpoints || trackpoints.length === 0) return false;
var degLat = thresholdKm / 111;
var degLng = thresholdKm / (111 * Math.cos(markerLat * Math.PI / 180));
for (var i = 0; i < trackpoints.length; i += 10) {
var pt = trackpoints[i];
if (Math.abs(pt[0] - markerLat) > degLat || Math.abs(pt[1] - markerLng) > degLng) continue;
if (haversineKm(markerLat, markerLng, pt[0], pt[1]) <= thresholdKm) return true;
}
var last = trackpoints[trackpoints.length - 1];
return haversineKm(markerLat, markerLng, last[0], last[1]) <= thresholdKm;
}
/*
* Build journey line segments from entries.
*
* entries: [{lat, lng, force_connect?}, ...] in chronological order
* opts.connectMode: 'on' | 'off' | 'manual' | 'intelligent_gpx' (default: 'on')
* 'on' → connect every consecutive pair (force_connect irrelevant)
* 'off' → no connectors, force_connect is also ignored
* 'manual' → only entries with force_connect:true draw a connector
* 'intelligent_gpx'→ suppress connector where GPX covers both endpoints;
* force_connect always overrides
* trackpointsPerFile: [ [[lat,lng],...], ... ] — required only for intelligent_gpx
*
* Returns array of segments [[lng, lat], ...] in MapLibre coordinate order.
* Segments with < 2 points are omitted.
*/
function buildJourneySegments(entries, opts, trackpointsPerFile) {
var mode = (opts && opts.connectMode) || 'on';
var segments = [];
var current = [];
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var lngLat = [parseFloat(e.lng), parseFloat(e.lat)];
if (i === 0) { current.push(lngLat); continue; }
var connect;
if (mode === 'off') {
connect = false;
} else if (mode === 'on') {
connect = true;
} else if (mode === 'manual') {
connect = !!e.force_connect;
} else { /* intelligent_gpx */
if (e.force_connect) {
connect = true;
} else if (!trackpointsPerFile || trackpointsPerFile.length === 0) {
connect = true; /* no GPX present → connect all */
} else {
var prev = entries[i - 1];
var covered = false;
for (var f = 0; f < trackpointsPerFile.length; f++) {
if (isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10) &&
isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) {
covered = true; break;
}
}
connect = !covered;
}
}
if (connect) {
current.push(lngLat);
} else {
if (current.length >= 2) segments.push(current);
current = [lngLat];
}
}
if (current.length >= 2) segments.push(current);
return segments;
}
/*
* Draw journey segments — calls addJourneyLine once per segment.
* baseSourceId: e.g. 'journey' → sources become 'journey-0', 'journey-1', ...
* (single segment gets plain 'journey' for backwards compatibility).
*/
function addJourneySegments(map, segments, baseSourceId) {
segments.forEach(function (coords, i) {
var sid = segments.length === 1 ? baseSourceId : baseSourceId + '-' + i;
addJourneyLine(map, coords, sid);
});
}
/*
* Fetch GPX files and render their raw tracks, then draw journey connector
* lines between entries per opts.connectMode / force_connect.
*
* gpxUrls: array of GPX file URLs to fetch (empty → no tracks, connectors only)
* entries: [{lat, lng, force_connect?}, ...] in chronological order
* gpxSourcePrefix: source/layer ID prefix for raw GPX tracks (e.g. 'gpx', 'home-gpx')
* journeySourceId: base source ID for connector segments (e.g. 'journey', 'home-journey')
* opts: forwarded to buildJourneySegments (opts.connectMode)
*/
function renderGpxJourney(map, gpxUrls, entries, gpxSourcePrefix, journeySourceId, opts) {
var needsTrackpoints = opts && opts.connectMode === 'intelligent_gpx';
Promise.all(gpxUrls.map(function (url, idx) {
return 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 = gpxSourcePrefix + '-' + 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': ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
});
return needsTrackpoints ? extractTrackpoints(geojson) : [];
})
.catch(function (err) { console.warn('GPX load failed:', url, err); return []; });
})).then(function (allTrackpoints) {
var valid = needsTrackpoints ? allTrackpoints.filter(function (tp) { return tp.length > 0; }) : [];
var segments = buildJourneySegments(entries, opts, valid);
addJourneySegments(map, segments, journeySourceId);
});
}
global.MapUtils = {
MAP_STYLE: MAP_STYLE,
ACCENT: ACCENT,
addJourneyLine: addJourneyLine,
addJourneySegments: addJourneySegments,
buildJourneySegments: buildJourneySegments,
renderGpxJourney: renderGpxJourney,
createDotMarker: createDotMarker,
createStoryMarker: createStoryMarker
};
})(window);