feat: expand connect markers to 4-mode select

Replaces the boolean toggle with a select field offering:
  on             — connect all consecutive entries (chronological line)
  manual         — force_connect entries only (user-controlled connections)
  intelligent_gpx — suppress connectors where GPX covers both endpoints;
                    force_connect overrides (original smart logic, restored)
  off            — no connectors at all; force_connect also ignored

buildJourneySegments gains an optional trackpointsPerFile param used
only by intelligent_gpx mode. renderGpxJourney extracts trackpoints
only when connectMode is intelligent_gpx. dailies.html.twig falls
back from intelligent_gpx → on (mini-map has no GPX tracks to
suppress against).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
This commit is contained in:
2026-06-21 11:42:03 +02:00
parent 9809950347
commit eafc431e0e
6 changed files with 100 additions and 30 deletions
+7 -8
View File
@@ -68,16 +68,15 @@ form:
type: bool type: bool
header.autoconnect: header.autoconnect:
type: toggle type: select
label: Connect markers label: Connect markers
help: 'Draw connector lines between all location markers in chronological order' help: 'Controls connector lines between location markers'
highlight: 1 default: 'on'
default: 1
options: options:
1: 'Yes' 'on': 'On — always connect all'
0: 'No' 'manual': 'Manual — force connect only'
validate: 'intelligent_gpx': 'Intelligent GPX — suppress where route is covered'
type: bool 'off': 'Off — no connections'
publishing: publishing:
type: tab type: tab
+84 -14
View File
@@ -152,21 +152,65 @@
return el; 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. * Build journey line segments from entries.
* *
* entries: [{lat, lng, force_connect?}, ...] in chronological order * entries: [{lat, lng, force_connect?}, ...] in chronological order
* opts.autoconnect (bool, default true): * opts.connectMode: 'on' | 'off' | 'manual' | 'intelligent_gpx' (default: 'on')
* true → connect every consecutive pair (simple chronological line) * 'on' → connect every consecutive pair (force_connect irrelevant)
* false → connect only entries where force_connect:true * '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. * Returns array of segments [[lng, lat], ...] in MapLibre coordinate order.
* Segments with < 2 points are omitted. * Segments with < 2 points are omitted.
*/ */
function buildJourneySegments(entries, opts) { function buildJourneySegments(entries, opts, trackpointsPerFile) {
var autoconnect = !opts || opts.autoconnect !== false; var mode = (opts && opts.connectMode) || 'on';
var segments = []; var segments = [];
var current = []; var current = [];
for (var i = 0; i < entries.length; i++) { for (var i = 0; i < entries.length; i++) {
var e = entries[i]; var e = entries[i];
@@ -174,7 +218,30 @@
if (i === 0) { current.push(lngLat); continue; } if (i === 0) { current.push(lngLat); continue; }
var connect = e.force_connect || autoconnect; 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) { if (connect) {
current.push(lngLat); current.push(lngLat);
@@ -202,15 +269,16 @@
/* /*
* Fetch GPX files and render their raw tracks, then draw journey connector * Fetch GPX files and render their raw tracks, then draw journey connector
* lines between entries per opts.autoconnect / force_connect. * lines between entries per opts.connectMode / force_connect.
* *
* gpxUrls: array of GPX file URLs to fetch (empty → no tracks, connectors only) * gpxUrls: array of GPX file URLs to fetch (empty → no tracks, connectors only)
* entries: [{lat, lng, force_connect?}, ...] in chronological order * entries: [{lat, lng, force_connect?}, ...] in chronological order
* gpxSourcePrefix: source/layer ID prefix for raw GPX tracks (e.g. 'gpx', 'home-gpx') * 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') * journeySourceId: base source ID for connector segments (e.g. 'journey', 'home-journey')
* opts: forwarded to buildJourneySegments (opts.autoconnect) * opts: forwarded to buildJourneySegments (opts.connectMode)
*/ */
function renderGpxJourney(map, gpxUrls, entries, gpxSourcePrefix, journeySourceId, opts) { function renderGpxJourney(map, gpxUrls, entries, gpxSourcePrefix, journeySourceId, opts) {
var needsTrackpoints = opts && opts.connectMode === 'intelligent_gpx';
Promise.all(gpxUrls.map(function (url, idx) { Promise.all(gpxUrls.map(function (url, idx) {
return fetch(url) return fetch(url)
.then(function (r) { return r.text(); }) .then(function (r) { return r.text(); })
@@ -224,10 +292,12 @@
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': ACCENT, 'line-width': 2, 'line-opacity': 0.7 } 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); }); .catch(function (err) { console.warn('GPX load failed:', url, err); return []; });
})).then(function () { })).then(function (allTrackpoints) {
var segments = buildJourneySegments(entries, opts); var valid = needsTrackpoints ? allTrackpoints.filter(function (tp) { return tp.length > 0; }) : [];
var segments = buildJourneySegments(entries, opts, valid);
addJourneySegments(map, segments, journeySourceId); addJourneySegments(map, segments, journeySourceId);
}); });
} }
@@ -44,7 +44,8 @@
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script> <script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
<script> <script>
var FEED_ENTRIES = {{ map_entries|json_encode|raw }}; var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
var AUTOCONNECT = {{ trip_page.header.autoconnect ?? true ? 'true' : 'false' }}; {% set _ac = trip_page.header.autoconnect ?? 'on' %}
var AUTOCONNECT = "{{ _ac == 'intelligent_gpx' ? 'on' : _ac }}";
var feedMap = new maplibregl.Map({ var feedMap = new maplibregl.Map({
container: 'feed-map', container: 'feed-map',
@@ -79,7 +80,7 @@ feedMap.on('load', function () {
feedMap.fitBounds(bounds, { padding: 60, maxZoom: 11 }); feedMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
} }
var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, { autoconnect: AUTOCONNECT }); var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, { connectMode: AUTOCONNECT });
MapUtils.addJourneySegments(feedMap, segments, 'feed-journey'); MapUtils.addJourneySegments(feedMap, segments, 'feed-journey');
}); });
</script> </script>
+2 -2
View File
@@ -152,7 +152,7 @@
var HOME_ENTRIES = {{ map_entries|json_encode|raw }}; var HOME_ENTRIES = {{ map_entries|json_encode|raw }};
var HOME_GPX_URLS = {{ home_gpx_urls|json_encode|raw }}; var HOME_GPX_URLS = {{ home_gpx_urls|json_encode|raw }};
var USE_GPX = {{ trip and trip.header.use_gpx is not null ? (trip.header.use_gpx ? 'true' : 'false') : 'true' }}; var USE_GPX = {{ trip and trip.header.use_gpx is not null ? (trip.header.use_gpx ? 'true' : 'false') : 'true' }};
var AUTOCONNECT = {{ trip and trip.header.autoconnect is not null ? (trip.header.autoconnect ? 'true' : 'false') : 'true' }}; var AUTOCONNECT = "{{ trip ? (trip.header.autoconnect ?? 'on') : 'on' }}";
var homeMap = new maplibregl.Map({ var homeMap = new maplibregl.Map({
container: 'home-map', container: 'home-map',
@@ -194,7 +194,7 @@ homeMap.on('load', function () {
setTimeout(function () { homeMap.resize(); }, 100); setTimeout(function () { homeMap.resize(); }, 100);
MapUtils.renderGpxJourney(homeMap, USE_GPX ? HOME_GPX_URLS : [], HOME_ENTRIES, 'home-gpx', 'home-journey', { autoconnect: AUTOCONNECT }); MapUtils.renderGpxJourney(homeMap, USE_GPX ? HOME_GPX_URLS : [], HOME_ENTRIES, 'home-gpx', 'home-journey', { connectMode: AUTOCONNECT });
}); });
</script> </script>
{% endif %} {% endif %}
+2 -2
View File
@@ -45,7 +45,7 @@
var ENTRIES = {{ map_entries|json_encode|raw }}; var ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }}; var GPX_URLS = {{ gpx_urls|json_encode|raw }};
var USE_GPX = {{ trip_page.header.use_gpx ?? true ? 'true' : 'false' }}; var USE_GPX = {{ trip_page.header.use_gpx ?? true ? 'true' : 'false' }};
var AUTOCONNECT = {{ trip_page.header.autoconnect ?? true ? 'true' : 'false' }}; var AUTOCONNECT = "{{ trip_page.header.autoconnect ?? 'on' }}";
var map = new maplibregl.Map({ var map = new maplibregl.Map({
container: 'trip-map', container: 'trip-map',
@@ -94,7 +94,7 @@ map.on('load', function () {
} }
/* ── GPX tracks + journey segments ─────────────────────────── */ /* ── GPX tracks + journey segments ─────────────────────────── */
MapUtils.renderGpxJourney(map, USE_GPX ? GPX_URLS : [], ENTRIES, 'gpx', 'journey', { autoconnect: AUTOCONNECT }); MapUtils.renderGpxJourney(map, USE_GPX ? GPX_URLS : [], ENTRIES, 'gpx', 'journey', { connectMode: AUTOCONNECT });
}); });
</script> </script>
{% endblock %} {% endblock %}
+2 -2
View File
@@ -300,7 +300,7 @@
var TRIP_ENTRIES = {{ map_entries|json_encode|raw }}; var TRIP_ENTRIES = {{ map_entries|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }}; var GPX_URLS = {{ gpx_urls|json_encode|raw }};
var USE_GPX = {{ page.header.use_gpx ?? true ? 'true' : 'false' }}; var USE_GPX = {{ page.header.use_gpx ?? true ? 'true' : 'false' }};
var AUTOCONNECT = {{ page.header.autoconnect ?? true ? 'true' : 'false' }}; var AUTOCONNECT = "{{ page.header.autoconnect ?? 'on' }}";
var tripMap = new maplibregl.Map({ var tripMap = new maplibregl.Map({
container: 'trip-map', container: 'trip-map',
@@ -351,7 +351,7 @@ tripMap.on('load', function () {
} }
/* ── GPX tracks + journey segments ─────────────────────────── */ /* ── GPX tracks + journey segments ─────────────────────────── */
MapUtils.renderGpxJourney(tripMap, USE_GPX ? GPX_URLS : [], TRIP_ENTRIES, 'gpx', 'trip-journey', { autoconnect: AUTOCONNECT }); MapUtils.renderGpxJourney(tripMap, USE_GPX ? GPX_URLS : [], TRIP_ENTRIES, 'gpx', 'trip-journey', { connectMode: AUTOCONNECT });
}); });
setTimeout(function () { tripMap.resize(); }, 100); setTimeout(function () { tripMap.resize(); }, 100);