intelligent_gpx mode required BOTH endpoints of a leg to be near the SAME GPX file before treating it as covered. This trip's routes are one contiguous chain recorded a day per file (…-to-X.gpx, X-to-….gpx), so any leg spanning a file boundary — an unblogged intermediate stop — read as uncovered and got a straight connector drawn over an already-GPX-covered path. Each endpoint now only has to be near some file. The trade is that a real gap (flight, train transfer) whose endpoints each sit on their own track also reads as covered and gets no connector; that case is not detectable from proximity alone, and `force_connect: true` on the entry after the gap already handles it. js/map.js is the rebuilt bundle (make build-assets), not a hand-edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
574 lines
23 KiB
JavaScript
574 lines
23 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 } };
|
||
}
|
||
|
||
/*
|
||
* 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];
|
||
// Each endpoint just needs to be near SOME GPX file, not necessarily
|
||
// the same one — a leg can legitimately span two consecutive files
|
||
// (e.g. an unblogged intermediate stop splits one route in two).
|
||
// Requiring one shared file used to draw a straight connector straight
|
||
// over an already-GPX-covered path, since this trip's routes are one
|
||
// contiguous chain recorded a day per file (…-to-X.gpx, X-to-….gpx).
|
||
//
|
||
// The trade: two endpoints each near a DIFFERENT track now read as
|
||
// covered even when there is a real gap between them (a flight or a
|
||
// train transfer), so no connector is drawn across it. That case is
|
||
// not detectable from proximity alone — mark the entry after the gap
|
||
// `force_connect: true` and the branch above handles it.
|
||
var prevNear = false, currNear = false;
|
||
for (var f = 0; f < trackpointsPerFile.length; f++) {
|
||
if (!prevNear && isNearTrack(parseFloat(prev.lat), parseFloat(prev.lng), trackpointsPerFile[f], 10)) prevNear = true;
|
||
if (!currNear && isNearTrack(parseFloat(e.lat), parseFloat(e.lng), trackpointsPerFile[f], 10)) currNear = true;
|
||
if (prevNear && currNear) break;
|
||
}
|
||
connect = !(prevNear && currNear);
|
||
}
|
||
}
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
/*
|
||
* Parse one or more GPX files and compute aggregate cycling statistics.
|
||
* urls: array of GPX file URL strings
|
||
* callback: called once with { distance, eleGain, eleLoss, highest, lowest, movingTime, avgSpeed }
|
||
* or { error: 'no files' } if urls is empty.
|
||
*
|
||
* distance/eleGain/eleLoss in raw units (km / metres).
|
||
* movingTime: "H:MM" string. avgSpeed: km/h number.
|
||
*/
|
||
function parseGpxFiles(urls, callback) {
|
||
var pending = urls.length;
|
||
var fileResults = new Array(urls.length);
|
||
if (pending === 0) { callback({ error: 'no files' }); return; }
|
||
|
||
urls.forEach(function (url, idx) {
|
||
fetch(url)
|
||
.then(function (r) { return r.text(); })
|
||
.then(function (text) {
|
||
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||
var pts = [];
|
||
xml.querySelectorAll('trkpt').forEach(function (pt) {
|
||
var eleEl = pt.querySelector('ele');
|
||
var timeEl = pt.querySelector('time');
|
||
pts.push({
|
||
lat: parseFloat(pt.getAttribute('lat')),
|
||
lon: parseFloat(pt.getAttribute('lon')),
|
||
ele: eleEl ? parseFloat(eleEl.textContent) : NaN,
|
||
time: timeEl ? timeEl.textContent : null
|
||
});
|
||
});
|
||
fileResults[idx] = pts;
|
||
if (--pending === 0) computeAndCallback();
|
||
})
|
||
.catch(function (err) {
|
||
console.warn('GPX load failed:', url, err);
|
||
fileResults[idx] = [];
|
||
if (--pending === 0) computeAndCallback();
|
||
});
|
||
});
|
||
|
||
function computeAndCallback() {
|
||
var totalDistance = 0, totalEleGain = 0, totalEleLoss = 0;
|
||
var globalHighest = NaN, globalLowest = NaN, totalMovingTime = 0;
|
||
|
||
fileResults.forEach(function (pts) {
|
||
if (!pts || pts.length < 2) return;
|
||
/* Include first point of each file in elevation range */
|
||
if (!isNaN(pts[0].ele)) {
|
||
if (isNaN(globalHighest) || pts[0].ele > globalHighest) globalHighest = pts[0].ele;
|
||
if (isNaN(globalLowest) || pts[0].ele < globalLowest) globalLowest = pts[0].ele;
|
||
}
|
||
for (var i = 1; i < pts.length; i++) {
|
||
var p0 = pts[i - 1], p1 = pts[i];
|
||
totalDistance += haversineKm(p0.lat, p0.lon, p1.lat, p1.lon);
|
||
if (!isNaN(p0.ele) && !isNaN(p1.ele)) {
|
||
var dEle = p1.ele - p0.ele;
|
||
if (dEle > 0) totalEleGain += dEle;
|
||
if (dEle < 0) totalEleLoss += (-dEle);
|
||
if (isNaN(globalHighest) || p1.ele > globalHighest) globalHighest = p1.ele;
|
||
if (isNaN(globalLowest) || p1.ele < globalLowest) globalLowest = p1.ele;
|
||
}
|
||
if (p0.time && p1.time) {
|
||
var dtHrs = (Date.parse(p1.time) - Date.parse(p0.time)) / 3600000;
|
||
if (dtHrs > 0 && (haversineKm(p0.lat, p0.lon, p1.lat, p1.lon) / dtHrs) >= 1) {
|
||
totalMovingTime += dtHrs;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
var avgSpeed = totalMovingTime > 0 ? totalDistance / totalMovingTime : 0;
|
||
var movHours = Math.floor(totalMovingTime);
|
||
var movMins = Math.round((totalMovingTime - movHours) * 60);
|
||
if (movMins === 60) { movHours++; movMins = 0; }
|
||
|
||
callback({
|
||
distance: totalDistance,
|
||
eleGain: totalEleGain,
|
||
eleLoss: totalEleLoss,
|
||
highest: globalHighest,
|
||
lowest: globalLowest,
|
||
movingTime: movHours + ':' + (movMins < 10 ? '0' : '') + movMins,
|
||
avgSpeed: avgSpeed
|
||
});
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Config-driven entry-map initialisation — the single orchestration shared by
|
||
* the trip page and both home views. Mirrors the trip page's canonical handler:
|
||
* compact collapsed attribution bottom-left, an on('load') marker loop with a
|
||
* map-tip hover popup, bounds fit (jumpTo for one entry), GPX journey rendering,
|
||
* and the fullscreen toggle.
|
||
*
|
||
* opts:
|
||
* container string — map div id ('trip-map', 'home-map')
|
||
* entries array — parsed map_entries [{lat,lng,slug,title,url,type?,force_connect?}, ...]
|
||
* cardPrefix string? — e.g. 'entry-'; when set and a card #prefix+slug exists,
|
||
* click scrolls+flashes that card; otherwise click navigates to entry.url
|
||
* storyMarkers bool — render createStoryMarker() for entry.type === 'story' (default: dot markers)
|
||
* markLatest bool — enlarge the final non-story entry's dot (default true; pass false for
|
||
* unordered sets like home highlights so no marker is singled out)
|
||
* fullscreen object? — { btnId, colSelector }; wires the fullscreen toggle + fullscreen-aware click
|
||
* gpx object? — { urls, use, autoconnect, sourcePrefix, journeyId }; forwarded to renderGpxJourney
|
||
* fit object? — { padding=60, maxZoom=11, singleZoom=10 }
|
||
*
|
||
* Returns the maplibregl.Map instance.
|
||
*/
|
||
function initEntryMap(opts) {
|
||
opts = opts || {};
|
||
var entries = opts.entries || [];
|
||
var fit = opts.fit || {};
|
||
var padding = fit.padding != null ? fit.padding : 60;
|
||
var maxZoom = fit.maxZoom != null ? fit.maxZoom : 11;
|
||
var singleZoom = fit.singleZoom != null ? fit.singleZoom : 10;
|
||
var markLatest = opts.markLatest !== false;
|
||
|
||
var map = new maplibregl.Map({
|
||
container: opts.container,
|
||
style: MAP_STYLE,
|
||
center: [20, 20],
|
||
zoom: 2,
|
||
attributionControl: false
|
||
});
|
||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||
|
||
map.on('load', function () {
|
||
if (entries.length === 0) {
|
||
map.jumpTo({ center: [0, 20], zoom: 2 });
|
||
return;
|
||
}
|
||
|
||
var bounds = new maplibregl.LngLatBounds();
|
||
|
||
entries.forEach(function (entry, i) {
|
||
var isLatest = markLatest && (entry.type !== 'story') && (i === entries.length - 1);
|
||
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||
bounds.extend(lngLat);
|
||
|
||
var el = (opts.storyMarkers && entry.type === 'story')
|
||
? createStoryMarker()
|
||
: createDotMarker(isLatest);
|
||
el.dataset.url = entry.url;
|
||
|
||
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||
.setLngLat(lngLat)
|
||
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||
el.addEventListener('mouseenter', function () { popup.addTo(map); });
|
||
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||
el.addEventListener('click', function () {
|
||
var card = opts.cardPrefix ? document.getElementById(opts.cardPrefix + entry.slug) : null;
|
||
if (!card) { window.location.href = entry.url; return; }
|
||
var hashId = opts.cardPrefix + entry.slug;
|
||
function scrollAndHighlight() {
|
||
window.location.hash = hashId;
|
||
setTimeout(function () {
|
||
card.classList.add('is-highlighted');
|
||
setTimeout(function () { card.classList.remove('is-highlighted'); }, 700);
|
||
}, 350);
|
||
}
|
||
if (opts.fullscreen) {
|
||
var col = document.querySelector(opts.fullscreen.colSelector);
|
||
var isFs = col && col.classList.contains('is-fullscreen');
|
||
if (isFs) {
|
||
var fsBtn = document.getElementById(opts.fullscreen.btnId);
|
||
if (fsBtn) fsBtn.click();
|
||
setTimeout(scrollAndHighlight, 450);
|
||
return;
|
||
}
|
||
}
|
||
scrollAndHighlight();
|
||
});
|
||
|
||
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(map);
|
||
});
|
||
|
||
if (entries.length === 1) {
|
||
map.jumpTo({ center: [parseFloat(entries[0].lng), parseFloat(entries[0].lat)], zoom: singleZoom });
|
||
} else {
|
||
map.fitBounds(bounds, { padding: padding, maxZoom: maxZoom });
|
||
}
|
||
|
||
if (opts.gpx) {
|
||
renderGpxJourney(
|
||
map,
|
||
opts.gpx.use ? (opts.gpx.urls || []) : [],
|
||
entries,
|
||
opts.gpx.sourcePrefix,
|
||
opts.gpx.journeyId,
|
||
{ connectMode: opts.gpx.autoconnect }
|
||
);
|
||
}
|
||
|
||
/* Collapse attribution <details> which MapLibre may open on load */
|
||
var attrib = map.getContainer().querySelector('.maplibregl-ctrl-attrib');
|
||
if (attrib) attrib.removeAttribute('open');
|
||
});
|
||
|
||
setTimeout(function () { map.resize(); }, 100);
|
||
|
||
if (opts.fullscreen) {
|
||
var fsBtn = document.getElementById(opts.fullscreen.btnId);
|
||
var col = document.querySelector(opts.fullscreen.colSelector);
|
||
if (fsBtn && col) {
|
||
fsBtn.addEventListener('click', function () {
|
||
var isFs = col.classList.toggle('is-fullscreen');
|
||
fsBtn.setAttribute('aria-label', isFs ? 'Close map' : 'Expand map');
|
||
document.body.style.overflow = isFs ? 'hidden' : '';
|
||
setTimeout(function () { map.resize(); }, 50);
|
||
});
|
||
}
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
global.MapUtils = {
|
||
MAP_STYLE: MAP_STYLE,
|
||
initEntryMap: initEntryMap,
|
||
ACCENT: ACCENT,
|
||
haversineKm: haversineKm,
|
||
parseGpxFiles: parseGpxFiles,
|
||
addJourneyLine: addJourneyLine,
|
||
addJourneySegments: addJourneySegments,
|
||
buildJourneySegments: buildJourneySegments,
|
||
renderGpxJourney: renderGpxJourney,
|
||
createDotMarker: createDotMarker,
|
||
createStoryMarker: createStoryMarker
|
||
};
|
||
})(window);
|