From 1fe1d13c600b7dfe421b3b99b833b7d5d1232724 Mon Sep 17 00:00:00 2001
From: Dennis Fink
Date: Thu, 23 Jul 2026 00:18:21 +0200
Subject: Add GPX trail maps to blog posts
This adds a trail flow block so posts can include attached GPX files as
interactive maps with route display, elevation charts, activity
summaries, and start/finish markers.
The trail assets are loaded only when needed, keeping the shared
templates clean while vendoring the Leaflet dependencies required for
offline, self-hosted map rendering.
---
assets/static/js/trail.js | 482 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 482 insertions(+)
create mode 100644 assets/static/js/trail.js
(limited to 'assets/static/js')
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:
+ '' +
+ `${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);
+ }
+});
--
cgit v1.3.1