/* 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);