From dfca8ef6e203474faa3525c62372f5b783338367 Mon Sep 17 00:00:00 2001 From: Mischa Date: Sat, 20 Jun 2026 00:39:39 +0200 Subject: [PATCH] feat: add GPX proximity algorithm to MapUtils (buildJourneySegments, extractTrackpoints) Adds haversineKm, extractTrackpoints, isNearTrack, buildJourneySegments, and addJourneySegments to the shared MapLibre GL IIFE. Updates MapUtils export to expose the new functions. ES5-only; no arrow functions, const/let, or modules. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM --- themes/intotheeast/js/maplibre-utils.js | 138 +++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/themes/intotheeast/js/maplibre-utils.js b/themes/intotheeast/js/maplibre-utils.js index eee2b27..1a996e0 100644 --- a/themes/intotheeast/js/maplibre-utils.js +++ b/themes/intotheeast/js/maplibre-utils.js @@ -135,5 +135,141 @@ return el; } - global.MapUtils = { MAP_STYLE: MAP_STYLE, ACCENT: ACCENT, addJourneyLine: addJourneyLine, createDotMarker: createDotMarker }; + /* ── 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; + } + 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 + */ + function buildJourneySegments(entries, allTrackpoints, thresholdKm) { + thresholdKm = thresholdKm || 10; + 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 (!hasGpx || e.force_connect) { + 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); + }); + } + + global.MapUtils = { + MAP_STYLE: MAP_STYLE, + ACCENT: ACCENT, + addJourneyLine: addJourneyLine, + addJourneySegments: addJourneySegments, + buildJourneySegments: buildJourneySegments, + extractTrackpoints: extractTrackpoints, + createDotMarker: createDotMarker + }; })(window);