21b572677e
Adds two configurable toggles to the trip blueprint (Admin2 Trip tab): - use_gpx: show/hide GPX tracks on all maps (default: enabled) - autoconnect: draw connector lines between markers (default: enabled) When use_gpx is off, GPX files are not fetched or rendered on any map (home, map, trip, dailies). The stats panel in trip.html.twig still reads GPX_URLS directly and is unaffected. When autoconnect is off, buildJourneySegments suppresses all auto-connectors; only entries with force_connect:true still draw a line — making force_connect behaviour independent of both settings. Also refactors the inline Promise.all in trip.html.twig to use the shared renderGpxJourney utility (reducing duplication). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
/* 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 } };
|
|
}
|
|
|
|
/*
|
|
* Catmull-Rom spline through waypoints → dense interpolated coords.
|
|
* Produces a smooth curve that passes through every entry dot.
|
|
* steps: interpolated points per segment (16 is plenty for daily entries).
|
|
*/
|
|
function catmullRomSpline(coords, steps) {
|
|
if (coords.length < 2) return coords;
|
|
steps = steps || 16;
|
|
var out = [];
|
|
|
|
for (var i = 0; i < coords.length - 1; i++) {
|
|
var p0 = coords[Math.max(i - 1, 0)];
|
|
var p1 = coords[i];
|
|
var p2 = coords[i + 1];
|
|
var p3 = coords[Math.min(i + 2, coords.length - 1)];
|
|
|
|
for (var s = 0; s < steps; s++) {
|
|
var t = s / steps;
|
|
var t2 = t * t;
|
|
var t3 = t2 * t;
|
|
out.push([
|
|
0.5 * ((2*p1[0]) + (-p0[0]+p2[0])*t + (2*p0[0]-5*p1[0]+4*p2[0]-p3[0])*t2 + (-p0[0]+3*p1[0]-3*p2[0]+p3[0])*t3),
|
|
0.5 * ((2*p1[1]) + (-p0[1]+p2[1])*t + (2*p0[1]-5*p1[1]+4*p2[1]-p3[1])*t2 + (-p0[1]+3*p1[1]-3*p2[1]+p3[1])*t3)
|
|
]);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
/* ── GPX connector algorithm ────────────────────────────────────────── */
|
|
|
|
/* 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 trackpoints from a toGeoJSON output.
|
|
* Returns [[lat, lng], ...] — latitude first (internal convention).
|
|
* GeoJSON coordinates are [lng, lat]; we flip them here.
|
|
*/
|
|
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]]); }); // [lng,lat] → [lat,lng]
|
|
});
|
|
return points;
|
|
}
|
|
|
|
/*
|
|
* Check whether a marker is within thresholdKm of any trackpoint in the array.
|
|
* trackpoints: [[lat, lng], ...] (internal convention, latitude first).
|
|
* Samples every 10th point for performance; always checks the last point.
|
|
*/
|
|
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;
|
|
}
|
|
// Always check the last point (may be skipped by stride=10).
|
|
// Note: per-point degree pre-filter in the loop is functionally equivalent
|
|
// to a per-file bounding-box skip at this data scale.
|
|
var last = trackpoints[trackpoints.length - 1];
|
|
return haversineKm(markerLat, markerLng, last[0], last[1]) <= thresholdKm;
|
|
}
|
|
|
|
/*
|
|
* Build journey line segments from entries and GPX trackpoints.
|
|
*
|
|
* entries: [{lat, lng, force_connect}, ...] in chronological order
|
|
* allTrackpoints: [ [[lat,lng],...], ... ] — one sub-array per GPX file
|
|
* thresholdKm: proximity radius (default 10)
|
|
*
|
|
* Returns array of segments, each segment being [[lng, lat], ...] in MapLibre
|
|
* coordinate order. A segment with < 2 points is omitted.
|
|
*
|
|
* Rules:
|
|
* - No GPX files → all adjacent pairs connected (one segment)
|
|
* - GPX present, pair covered by same file → connector suppressed
|
|
* - GPX present, pair NOT covered by any single file → connector drawn
|
|
* - force_connect on arriving entry → always draw connector
|
|
*/
|
|
/*
|
|
* opts.autoconnect (bool, default true): when false, only entries with
|
|
* force_connect:true are connected — all other pairs are left unconnected.
|
|
*/
|
|
function buildJourneySegments(entries, allTrackpoints, thresholdKm, opts) {
|
|
thresholdKm = thresholdKm || 10;
|
|
var autoconnect = !opts || opts.autoconnect !== false;
|
|
var hasGpx = allTrackpoints && allTrackpoints.length > 0;
|
|
var segments = [];
|
|
var current = [];
|
|
|
|
for (var i = 0; i < entries.length; i++) {
|
|
var e = entries[i];
|
|
var lngLat = [parseFloat(e.lng), parseFloat(e.lat)]; // MapLibre: [lng, lat]
|
|
|
|
if (i === 0) {
|
|
current.push(lngLat);
|
|
continue;
|
|
}
|
|
|
|
var prev = entries[i - 1];
|
|
var connect;
|
|
|
|
if (e.force_connect) {
|
|
connect = true;
|
|
} else if (!autoconnect) {
|
|
connect = false;
|
|
} else if (!hasGpx) {
|
|
connect = true;
|
|
} else {
|
|
var pLat = parseFloat(prev.lat);
|
|
var pLng = parseFloat(prev.lng);
|
|
var cLat = parseFloat(e.lat);
|
|
var cLng = parseFloat(e.lng);
|
|
var covered = false;
|
|
for (var f = 0; f < allTrackpoints.length; f++) {
|
|
if (isNearTrack(pLat, pLng, allTrackpoints[f], thresholdKm) &&
|
|
isNearTrack(cLat, cLng, allTrackpoints[f], thresholdKm)) {
|
|
covered = true;
|
|
break;
|
|
}
|
|
}
|
|
connect = !covered;
|
|
}
|
|
|
|
if (connect) {
|
|
current.push(lngLat);
|
|
} else {
|
|
if (current.length >= 2) segments.push(current);
|
|
current = [lngLat]; // start new segment from this point
|
|
}
|
|
}
|
|
|
|
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, render their raw tracks, then draw journey connector lines
|
|
* only between entries not covered by any GPX file (connector suppression).
|
|
*
|
|
* gpxUrls: array of GPX file URLs to fetch
|
|
* 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')
|
|
*
|
|
* When gpxUrls is empty, Promise.all resolves immediately → no GPX layers,
|
|
* buildJourneySegments draws a full connector line between all entries.
|
|
*/
|
|
function renderGpxJourney(map, gpxUrls, entries, gpxSourcePrefix, journeySourceId, opts) {
|
|
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 extractTrackpoints(geojson);
|
|
})
|
|
.catch(function (err) { console.warn('GPX load failed:', url, err); return []; });
|
|
})).then(function (allTrackpoints) {
|
|
var valid = allTrackpoints.filter(function (tp) { return tp.length > 0; });
|
|
var segments = buildJourneySegments(entries, valid, 10, opts);
|
|
addJourneySegments(map, segments, journeySourceId);
|
|
});
|
|
}
|
|
|
|
global.MapUtils = {
|
|
MAP_STYLE: MAP_STYLE,
|
|
ACCENT: ACCENT,
|
|
addJourneyLine: addJourneyLine,
|
|
addJourneySegments: addJourneySegments,
|
|
buildJourneySegments: buildJourneySegments,
|
|
extractTrackpoints: extractTrackpoints,
|
|
renderGpxJourney: renderGpxJourney,
|
|
createDotMarker: createDotMarker,
|
|
createStoryMarker: createStoryMarker
|
|
};
|
|
})(window);
|