"use strict"; let trail_chart_number = 0; const create_map = (map_element) => { const open_street_map_mapnik = L.tileLayer.provider("OpenStreetMap.Mapnik", { referrerPolicy: "strict-origin-when-cross-origin", }); const open_street_map_de = L.tileLayer.provider("OpenStreetMap.DE", { referrerPolicy: "strict-origin-when-cross-origin", }); const open_street_map_france = L.tileLayer.provider("OpenStreetMap.France", { referrerPolicy: "strict-origin-when-cross-origin", }); const opnv_karte = L.tileLayer.provider("OPNVKarte", { referrerPolicy: "strict-origin-when-cross-origin", }); const open_topo_map = L.tileLayer.provider("OpenTopoMap", { referrerPolicy: "strict-origin-when-cross-origin", }); const cycl_osm = L.tileLayer.provider("CyclOSM", { referrerPolicy: "strict-origin-when-cross-origin", }); const mtb_map = L.tileLayer.provider("MtbMap", { referrerPolicy: "strict-origin-when-cross-origin", }); const map = L.map(map_element, { center: [49.75, 6.1], zoom: 8, zoomControl: true, scrollWheelZoom: true, keyboard: true, layers: [open_street_map_mapnik], }); L.control .layers( { OpenStreetMap: open_street_map_mapnik, OpenStreetMapDE: open_street_map_de, OpenStreetMapFrance: open_street_map_france, OPNVKarte: opnv_karte, OpenTopoMap: open_topo_map, CycleOSM: cycl_osm, MtbMap: mtb_map, }, null, { collapsed: true, position: "topright", }, ) .addTo(map); L.control .scale({ imperial: false, metric: true, position: "bottomleft", }) .addTo(map); new L.Control.FullScreen({ position: "topleft", title: "View map fullscreen", titleCancel: "Exit fullscreen", }).addTo(map); return map; }; const create_parking_icon = () => L.icon({ iconUrl: "/static/images/trail/parking.svg", iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -32], tooltipAnchor: [0, -28], className: "trail-parking-marker", }); const create_display_control = (map, chart_element) => { if (!chart_element.id) { trail_chart_number += 1; chart_element.id = `trail-chart-${trail_chart_number}`; } const parking_icon = create_parking_icon(); const display_control = L.control.elevation({ theme: "trail-theme", detached: true, elevationDiv: `#${chart_element.id}`, altitude: true, distance: true, time: "summary", speed: "summary", pace: "summary", slope: "summary", acceleration: false, summary: false, downloadLink: false, legend: true, ruler: false, autofitBounds: true, followMarker: false, marker: "position-marker", distanceMarkers: false, hotline: false, waypoints: "markers", wptLabels: "markers", wptIcons: { Parking: parking_icon, parking: parking_icon, "Parking Area": parking_icon, "parking area": parking_icon, "parking-area": parking_icon, Car: parking_icon, car: parking_icon, }, trkStart: false, trkEnd: false, closeBtn: false, }); display_control.addTo(map); return display_control; }; const create_summary_control = () => { const map_element = document.createElement("div"); map_element.hidden = true; document.body.append(map_element); const map = L.map(map_element, { center: [0, 0], zoom: 1, zoomControl: false, attributionControl: false, keyboard: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, touchZoom: false, }); const summary_control = L.control.elevation({ detached: false, collapsed: true, altitude: "summary", distance: "summary", time: "summary", speed: "summary", pace: "summary", slope: "summary", acceleration: false, summary: false, downloadLink: false, legend: false, ruler: false, autofitBounds: false, followMarker: false, marker: false, distanceMarkers: false, hotline: false, waypoints: false, wptLabels: false, trkStart: false, trkEnd: false, closeBtn: false, }); summary_control.addTo(map); return { control: summary_control, destroy: () => { map.remove(); map_element.remove(); }, }; }; function visit_leaflet_layer(layer, callback) { if (!layer) { return; } if (typeof layer.eachLayer === "function") { layer.eachLayer((child_layer) => { visit_leaflet_layer(child_layer, callback); }); return; } callback(layer); } const create_trail_icon = (type, label) => L.divIcon({ className: `trail-marker trail-marker-${type}`, html: '' + `${label}`, iconSize: [30, 30], iconAnchor: [15, 15], popupAnchor: [0, -16], tooltipAnchor: [0, -16], }); function add_trail_marker(map, position, type, label) { const marker = L.marker(position, { icon: create_trail_icon(type, label), keyboard: true, title: label, alt: label, }); marker.bindTooltip(label, { direction: "top", }); marker.addTo(map); } const add_endpoint_markers = (map, loaded_layer) => { const lat_lngs = []; visit_leaflet_layer(loaded_layer, (layer) => { if (typeof layer.getLatLngs !== "function") { return; } lat_lngs.push( ...layer .getLatLngs() .flat(Infinity) .filter( (item) => item && typeof item.lat === "number" && typeof item.lng === "number", ), ); }); if (lat_lngs.length < 2) { console.warn( "The loaded GPX did not contain enough track points for endpoint markers.", ); return; } const start = lat_lngs[0]; const finish = lat_lngs[lat_lngs.length - 1]; if (start.distanceTo(finish) <= 50) { add_trail_marker(map, start, "loop", "Start and finish"); return; } add_trail_marker(map, start, "start", "Start"); add_trail_marker(map, finish, "finish", "Finish"); }; const format_duration = (milliseconds) => { if (!Number.isFinite(milliseconds) || milliseconds < 0) { return "–"; } const total_seconds = Math.floor(milliseconds / 1000); const days = Math.floor(total_seconds / 86400); const hours = Math.floor((total_seconds % 86400) / 3600); const minutes = Math.floor((total_seconds % 3600) / 60); const seconds = total_seconds % 60; const time = [hours, minutes, seconds] .map((value) => String(value).padStart(2, "0")) .join(":"); if (days === 0) { return time; } const day_label = days === 1 ? "day" : "days"; return `${days} ${day_label} ${time}`; }; const format_pace = (time_milliseconds, distance_kilometres) => { if ( !Number.isFinite(time_milliseconds) || !Number.isFinite(distance_kilometres) || time_milliseconds < 0 || distance_kilometres <= 0 ) { return "–"; } const seconds_per_kilometre = Math.round( time_milliseconds / 1000 / distance_kilometres, ); const minutes = Math.floor(seconds_per_kilometre / 60); const seconds = seconds_per_kilometre % 60; return `${minutes}:${String(seconds).padStart(2, "0")} min/km`; }; const initialize_trail_map = (trail) => { const map_element = trail.querySelector("[data-trail-map]"); const chart_element = trail.querySelector("[data-trail-chart]"); const message_element = trail.querySelector("[data-trail-message]"); const trail_gpx_url = trail.dataset.trailGpxUrl; const activity_gpx_url = trail.dataset.activityGpxUrl; if (!map_element || !chart_element || (!trail_gpx_url && !activity_gpx_url)) { console.error( "Could not initialize trail map.", new Error( "The trail block requires a map, a chart and at least one GPX file.", ), ); if (map_element) { map_element.hidden = true; } if (chart_element) { chart_element.hidden = true; } if (message_element) { message_element.hidden = false; } return; } const display_gpx_url = trail_gpx_url || activity_gpx_url; const summary_gpx_url = activity_gpx_url || trail_gpx_url; const summary_uses_separate_gpx = summary_gpx_url !== display_gpx_url; try { const map = create_map(map_element); const display_control = create_display_control(map, chart_element); const set_summary_values = (track_info) => { const set_value = (selector, value) => { const element = trail.querySelector(selector); if (element) { element.textContent = value; } }; set_value( "[data-trail-distance]", `${track_info.distance.toFixed(2)} km`, ); set_value("[data-trail-time]", format_duration(track_info.time)); set_value( "[data-trail-pace]", format_pace(track_info.time, track_info.distance), ); set_value( "[data-trail-maximum-elevation]", `${track_info.elevation_max.toFixed(0)} m`, ); set_value( "[data-trail-minimum-elevation]", `${track_info.elevation_min.toFixed(0)} m`, ); set_value( "[data-trail-average-elevation]", `${track_info.elevation_avg.toFixed(0)} m`, ); set_value("[data-trail-ascent]", `${track_info.ascent.toFixed(0)} m`); set_value("[data-trail-descent]", `${track_info.descent.toFixed(0)} m`); set_value( "[data-trail-average-speed]", `${track_info.speed_avg.toFixed(2)} km/h`, ); set_value( "[data-trail-maximum-speed]", `${track_info.speed_max.toFixed(2)} km/h`, ); }; display_control.once("eledata_loaded", ({ layer, track_info }) => { add_endpoint_markers(map, layer); if (!summary_uses_separate_gpx) { set_summary_values(track_info); } map.invalidateSize(false); display_control.redraw(); }); display_control.load(display_gpx_url); if (summary_uses_separate_gpx) { const { control: summary_control, destroy: destroy_summary_control } = create_summary_control(); summary_control.once("eledata_loaded", ({ track_info }) => { set_summary_values(track_info); destroy_summary_control(); }); summary_control.load(summary_gpx_url); } } catch (error) { console.error("Could not initialize trail map.", error); map_element.hidden = true; chart_element.hidden = true; if (message_element) { message_element.hidden = false; } } }; document.addEventListener("DOMContentLoaded", () => { L.registerLocale("en:trail", { "x: ": "Distance: ", "y: ": "Elevation: ", Altitude: "Elevation", }); L.setLocale("en:trail"); const trails = document.querySelectorAll(".trail"); for (const trail of trails) { initialize_trail_map(trail); } });