Files
intotheeast-com-content/themes/intotheeast/js/maplibre-utils.js
T

344 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 <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;
}
/*
* Return a styled <div> 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);