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. --- .../libs/leaflet-distance-marker.js | 314 +++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js (limited to 'assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js') diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js new file mode 100644 index 0000000..43beb77 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2022, GPL-3.0+ Project, Raruto + * + * This file is free software: you may copy, redistribute and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 2 of the License, or (at your + * option) any later version. + * + * This file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright (c) 2014- Doroszlai Attila, 2016- Phil Whitehurst + * + * Permission to use, copy, modify, and/or distribute this software + * for any purpose with or without fee is hereby granted, provided + * that the above copyright notice and this permission notice appear + * in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL + * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR + * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, + * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +// TODO: the "L.DistanceMarker" (canvas marker) class could be alternatively provided by "leaflet-rotate"? + +L.DistanceMarker = L.CircleMarker.extend({ + _updatePath: function () { + let ctx = this._renderer._ctx; + let p = this._point; + + // Calculate image direction (rotation) + this.options.rotation = this.options.rotation || 0; + + // Draw circle marker (canvas point) + if (this.options.radius && this._renderer._updateCircle) { + this._renderer._updateCircle(this); + } + + // Draw image over circle (distance marker) + if (this.options.icon && this.options.icon.url) { + if (!this.options.icon.element) { + const icon = document.createElement('img'); + this.options.icon = L.extend({ rotate: 0, size: [40, 40], offset: { x: 0, y: 0 } }, this.options.icon); + this.options.icon.rotate += this.options.rotation; + this.options.icon.element = icon; + icon.src = this.options.icon.url; + icon.onload = () => this.redraw(); + icon.onerror = () => this.options.icon = null; + } else { + const icon = this.options.icon; + let cx = p.x + icon.offset.x; + let cy = p.y + icon.offset.y; + ctx.save(); + if (icon.rotate) { + ctx.translate(p.x, p.y); + ctx.rotate(icon.rotate); + cx = 0; + cy = 0; + } + ctx.drawImage(icon.element, cx - icon.size[0] / 2, cy - icon.size[1] / 2, icon.size[0], icon.size[1]); + ctx.restore(); + } + } + + // Add a label inside the circle (distance marker) + if (this.options.label) { + let cx = p.x, cy = p.y; + ctx.save(); + + ctx.font = this.options.font || 'normal 7pt "Helvetica Neue", Arial, Helvetica, sans-serif'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = this.options.fillStyle || 'black'; + + // TODO rescale circle to fit text + // let fontSize = Number(/[0-9\.]+/.exec(ctx.font)[0]); + // let fontWidth = ctx.measureText(this.options.html).width; + + if (this.options.rotation) { + ctx.translate(p.x, p.y); + ctx.rotate(this.options.rotation); + cx = 0; + cy = 0; + } + + // Temporary fix to prevent stroke blurs at higher zoom levels + if (this._map.getZoom() > 17) { + ctx.fillStyle = this.options.strokeStyle || 'black'; + } + + ctx.fillText(this.options.label, cx, cy); + + if (this.options.strokeStyle && this._map.getZoom() <= 17) { + ctx.strokeStyle = this.options.strokeStyle; + ctx.strokeText(this.options.label, cx, cy); + } + + ctx.restore(); + } + } +}); + +L.DistanceMarkers = L.LayerGroup.extend({ + options: { + cssClass: 'dist-marker', + iconSize: [12, 12], + arrowSize: [10, 10], + arrowUrl: "data:image/svg+xml,%3Csvg transform='rotate(90)' xmlns='http://www.w3.org/2000/svg' width='560px' height='560px' viewBox='0 0 560 560'%3E%3Cpath stroke-width='35' fill='%23000' stroke='%23FFF' d='M280,40L522,525L280,420L38,525z'/%3E%3C/svg%3E", + offset: 1000, + showAll: 12, + textFunction: (distance, i, offset) => i, + distance: true, + direction: true, + }, + initialize: function (line, map, options) { + + this._layers = {}; + this._zoomLayers = {}; + + options = L.setOptions(this, options); + + let preferCanvas = map.options.preferCanvas; + let showAll = Math.min(map.getMaxZoom(), options.showAll); + + // You should use "leaflet-rotate" to show rotated arrow markers (preferCanvas: false) + if (!preferCanvas && !map.options.rotate) { + console.warn('Missing dependency: "leaflet-rotate"'); + } + + // Get line coords as an array + let coords = typeof line.getLatLngs == 'function' ? line.getLatLngs() : line; + + // Handle "MultiLineString" features + coords = L.LineUtil.isFlat(coords) ? [coords] : coords; + + coords.forEach(latlngs => { + // Get accumulated line lengths as well as overall length + let accumulated = L.GeometryUtil.accumulatedLengths(latlngs); + let length = accumulated.length > 0 ? accumulated[accumulated.length - 1] : 0; + + // count = Number of distance markers to be added + // j = Position in accumulated line length array + for (let i = 1, count = Math.floor(length / options.offset), j = 0; i <= count; ++i) { + + let distance = options.offset * i; + + // Find the first accumulated distance that is greater than the distance of this marker + while (j < accumulated.length - 1 && accumulated[j] < distance) ++j; + + // Grab two nearest points either side marker position + let p1 = latlngs[j - 1]; + let p2 = latlngs[j]; + let m_line = L.polyline([p1, p2]); + + // and create a simple line to interpolate on + let ratio = (distance - accumulated[j - 1]) / (accumulated[j] - accumulated[j - 1]); + let position = L.GeometryUtil.interpolateOnLine(map, m_line, ratio); + let delta = map.project(p2).subtract(map.project(p1)); + let angle = Math.atan2(delta.y, delta.x); + + // Generate distance marker label + let text = options.textFunction.call(this, distance, i, options.offset); + + // Grouping layer of visible layers at zoom level (arrow + distance) + let zoom = this._minimumZoomLevelForItem(i, showAll); + let markers = this._zoomLayers[zoom] = this._zoomLayers[zoom] || L.layerGroup() + + // create arrow markers + if (options.direction && ((options.distance && i % 2 == 1) || !options.distance)) { + if (preferCanvas) { + markers.addLayer( + new L.DistanceMarker(p1, { + radius: 0, + icon: { + url: options.arrowUrl, //image link + size: options.arrowSize, //image size ( default [40, 40] ) + rotate: 0, //image base rotate ( default 0 ) + offset: { x: 0, y: 0 }, //image offset ( default { x: 0, y: 0 } ) + }, + rotation: angle, + interactive: false, + // label: '⮞', //'➜', + // font: 'normal 20pt "Helvetica Neue", Arial, Helvetica, sans-serif', + // fillStyle: 'white',//'#3366CC', + // strokeStyle: 'black', + }) + ); + } else { + markers.addLayer( + L.marker(position.latLng, { + icon: L.icon({ + iconUrl: options.arrowUrl, + iconSize: options.arrowSize, + }), + // NB the following option is added by "leaflet-rotate" + rotation: angle, + interactive: false, + }) + ); + } + } + + // create distance markers + if (options.distance && i % 2 == 0) { + if (preferCanvas) { + markers.addLayer( + new L.DistanceMarker(position.latLng, { + label: text, // TODO: handle text rotation (leaflet-rotate) + radius: 7, + fillColor: '#fff', + fillOpacity: 1, + fillStyle: 'black', + color: '#777', + weight: 1, + interactive: false, + }) + ); + } else { + markers.addLayer( + L.marker(position.latLng, { + title: text, + icon: L.divIcon({ + className: options.cssClass, + html: text, + iconSize: options.iconSize + }), + interactive: false, + }) + ); + } + } + } + }); + + const updateMarkerVisibility = () => { + let oldZoom = this._lastZoomLevel || 0; + let newZoom = map.getZoom(); + if (newZoom > oldZoom) { + for (let i = oldZoom + 1; i <= newZoom; ++i) { + if (this._zoomLayers[i] !== undefined) { + this.addLayer(this._zoomLayers[i]); + } + } + } else if (newZoom < oldZoom) { + for (let i = oldZoom; i > newZoom; --i) { + if (this._zoomLayers[i] !== undefined) { + this.removeLayer(this._zoomLayers[i]); + } + } + } + this._lastZoomLevel = newZoom; + }; + map.on('zoomend', updateMarkerVisibility); + updateMarkerVisibility(); + }, + + _minimumZoomLevelForItem: function (i, zoom) { + while (i > 0 && i % 2 === 0) { + --zoom; + i = Math.floor(i / 2); + } + return zoom; + }, + +}); + +L.Polyline.include({ + + _originalOnAdd: L.Polyline.prototype.onAdd, + _originalOnRemove: L.Polyline.prototype.onRemove, + + addDistanceMarkers: function () { + if (this._map && this._distanceMarkers) { + this._map.addLayer(this._distanceMarkers); + } + }, + + removeDistanceMarkers: function () { + if (this._map && this._distanceMarkers) { + this._map.removeLayer(this._distanceMarkers); + } + }, + + onAdd: function (map) { + this._originalOnAdd(map); + + let opts = this.options.distanceMarkers || {}; + if (this.options.distanceMarkers) { + this._distanceMarkers = this._distanceMarkers || new L.DistanceMarkers(this, map, opts); + } + if (opts.lazy === undefined || opts.lazy === false) { + this.addDistanceMarkers(); + } + }, + + onRemove: function (map) { + this.removeDistanceMarkers(); + this._originalOnRemove(map); + } + +}); -- cgit v1.3.1