diff options
Diffstat (limited to 'assets/static/js/trail.js')
| -rw-r--r-- | assets/static/js/trail.js | 482 |
1 files changed, 482 insertions, 0 deletions
diff --git a/assets/static/js/trail.js b/assets/static/js/trail.js new file mode 100644 index 0000000..bca224d --- /dev/null +++ b/assets/static/js/trail.js @@ -0,0 +1,482 @@ +"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: + '<span class="trail-marker-symbol" aria-hidden="true"></span>' + + `<span class="visually-hidden">${label}</span>`, + 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); + } +}); |
