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. --- .../leaflet-elevation/libs/leaflet-edgescale.js | 636 +++++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js (limited to 'assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js') diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js new file mode 100644 index 0000000..74c672d --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js @@ -0,0 +1,636 @@ +/* + * Copyright (c) 2023, 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) GPL-3.0+ Project - 2018- Dražen Tutić - https://github.com/dtutic/Leaflet.EdgeScaleBar + * Copyright (c) MIT License (MIT) - 2015- Xisco Guaita - https://github.com/xguaita/Leaflet.MapCenterCoord + */ + +/** + * Original source: https://github.com/xguaita/Leaflet.MapCenterCoord + */ +L.Control.EdgeScale = L.Control.extend({ + + // Defaults + options: { + position: 'bottomleft', + icon: true, + coords: true, + bar: true, + onMove: true, + template: '{y} | {x}', // https://en.wikipedia.org/wiki/ISO_6709 + projected: false, + formatProjected: '#.##0,000', + latlngFormat: 'DD', // DD, DM, DMS + latlngDesignators: true, + latLngFormatter: undefined, + iconStyle: { + background: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xml:space='preserve' viewBox='0 0 100 100'%3E%3Cg stroke='%23fff'%3E%3Ccircle cx='50' cy='50.2' r='3.9' stroke-width='2' /%3E%3Cpath stroke-width='3' d='M5 54h32a4 4 0 1 0 0-8H5a4 4 0 1 0 0 8z M54 5a4 4 0 1 0-8 0v32a4 4 0 1 0 8 0V5z M99 50c0-2-2-4-4-4H63a4 4 0 1 0 0 8h32c2 0 4-1 4-4zM46 95a4 4 0 1 0 8 0V64a4 4 0 1 0-8 0v31z'/%3E%3C/g%3E%3C/svg%3E%0A")`, + width: '24px', + height: '24px', + left: 'calc(50% - 12px)', + top: 'calc(50% - 12px)', + content: '', + display: 'block', + position: 'absolute', + zIndex: 999, + pointerEvents: 'none', + }, + containerStyle: { + backgroundColor: 'rgba(255, 255, 255, 0.7)', + boxShadow: '0 0 5px #bbb', + borderRadius: '3px', + padding: '3px 2px', + color: '#333', + font: '11px/1.5 Consolas, monaco, monospace', + writingMode: 'vertical-lr', + }, + }, + + initialize: function(options) { + L.setOptions(this, options); + }, + + onAdd: function (map) { + + if (this.options.bar) { + this._scaleBar = (new L.Control.EdgeScale.Layer(true === this.options.bar ? {} : this.options.bar)).addTo(map); + } + + // create a DOM element and put it into overlayPane + if (this.options.icon) { + this._icon = L.DomUtil.create('div', 'leaflet-crosshair'); + Object.assign(this._icon.style, this.options.iconStyle); + map.getContainer().insertBefore(this._icon, map.getContainer().firstChild); + } + + // Control container + this._container = L.DomUtil.create('div', 'leaflet-control-mapcentercoord'); + + Object.assign(this._container.style, this.options.containerStyle); + + if (!this.options.coords) { + this._container.style.display = 'none'; + } + + L.DomEvent.disableClickPropagation(this._container); + + this._container.innerHTML = this._getMapCenterCoord(); + + // Add events listeners for updating coordinates & icon's position + map.on('move', this._onMapMove, this); + map.on('moveend', this._onMapMove, this); + + return this._container; + }, + + onRemove: function (map) { + if (this.options.bar) { + this._scaleBar.remove(); + } + + // remove icon's DOM elements and listeners + if (this.options.icon) { + map.getContainer().removeChild(this._icon); + } + map.off('move', this._onMapMove, this); + map.off('moveend', this._onMapMove, this); + }, + + // update coordinates + _onMapMove: function (e) { + if (this.options.onMove || 'moveend' === e.type) { + this._container.innerHTML = this._getMapCenterCoord(); + } + }, + + _getMapCenterCoord: function () { + const center = this._map.getCenter(); + return this.options.projected + ? this._getProjectedCoord(this._map.options.crs.project(center)) + : this._getLatLngCoord(center); + }, + + _getProjectedCoord: function (center) { + return L.Util.template( + this.options.template, + { + x: this._format(this.options.formatProjected, center.x), + y: this._format(this.options.formatProjected, center.y) + } + ); + }, + + _getLatLngCoord: function (latLng) { + + const { latLngFormatter, latlngFormat, latlngDesignators: designators } = this.options; + + if (undefined !== latLngFormatter ) { + return latLngFormatter(latLng.lat, latLng.lng); + } + + let lat, lng, deg, min; + + // make a copy of center so we aren't affecting leaflet's internal state + let center = { + lat: latLng.lat, + lng: latLng.lng, + lng_neg: latLng.lng < 0, + lat_neg: latLng.lat < 0, + }; + + // 180 degrees & negative + if (center.lng < 0) { + center.lng = Math.abs(center.lng); + } + if (center.lng > 180) { + center.lng = 360 - center.lng; + center.lng_neg = !center.lng_neg; + } + if (center.lat < 0) { + center.lat = Math.abs(center.lat); + } + + // format + if ('DM' === latlngFormat) { + deg = parseInt(center.lng); + lng = deg + 'º ' + this._format('00.000', (center.lng - deg) * 60) + "'"; + deg = parseInt(center.lat); + lat = deg + 'º ' + this._format('00.000', (center.lat - deg) * 60) + "'"; + } else if ('DMS' === latlngFormat) { + deg = parseInt(center.lng); + min = (center.lng - deg) * 60; + lng = deg + 'º ' + this._format('00', parseInt(min)) + "' " + this._format('00.0', (min - parseInt(min)) * 60) + "''"; + deg = parseInt(center.lat); + min = (center.lat - deg) * 60; + lat = deg + 'º ' + this._format('00', parseInt(min)) + "' " + this._format('00.0', (min - parseInt(min)) * 60) + "''"; + } else { // 'DD' + lng = this._format('#0.00000', center.lng) + 'º'; + lat = this._format('##0.00000', center.lat) + 'º'; + } + + return L.Util.template(this.options.template, { + x: (!designators && center.lng_neg ? '-' : '') + lng + (designators ? (center.lng_neg ? ' W' : ' E') : ''), + y: (!designators && center.lat_neg ? '-' : '') + lat + (designators ? (center.lat_neg ? ' S' : ' N') : '') + }); + }, + + /** + * IntegraXor Web SCADA - JavaScript Number Formatter + * + * @see https://code.google.com/p/javascript-number-formatter + * @authors KPL, KHL + */ + _format: function (m, v) { + if (!m || isNaN(+v)) { + return v; // return as it is. + } + + v = m.charAt(0) == '-' ? -v : +v; // convert any string to number according to formation sign. + let isNegative = v < 0 ? v = -v : 0; // process only abs(), and turn on flag. + + let result = m.match(/[^\d\-\+#]/g); // search for separator for grp & decimal, anything not digit, not +/- sign, not #. + let Decimal = (result && result[result.length - 1]) || '.'; // treat the right most symbol as decimal + let Group = (result && result[1] && result[0]) || ','; // treat the left most symbol as group separator + + m = m.split(Decimal); // split the decimal for the format string if any. + v = v.toFixed(m[1] && m[1].length); // Fix the decimal first, toFixed will auto fill trailing zero. + v = +(v) + ''; // convert number to string to trim off *all* trailing decimal zero(es) + + let pos_trail_zero = m[1] && m[1].lastIndexOf('0'); // fill back any trailing zero according to format (look for last zero in format) + let part = v.split('.'); + + if (!part[1] || part[1] && part[1].length <= pos_trail_zero) { // integer will get !part[1] + v = (+v).toFixed(pos_trail_zero + 1); + } + let szSep = m[0].split(Group); // look for separator + m[0] = szSep.join(''); // join back without separator for counting the pos of any leading 0. + + let pos_lead_zero = m[0] && m[0].indexOf('0'); + if (pos_lead_zero > -1) { + while (part[0].length < (m[0].length - pos_lead_zero)) { + part[0] = '0' + part[0]; + } + } else if (+part[0] == 0) { + part[0] = ''; + } + v = v.split('.'); + v[0] = part[0]; + + var pos_separator = (szSep[1] && szSep[szSep.length - 1].length); // process the first group separator from decimal (.) only, the rest ignore. Get the length of the last slice of split result. + if (pos_separator) { + let integer = v[0]; + let str = ''; + let offset = integer.length % pos_separator; + for (let i = 0, l = integer.length; i < l; i++) { + str += integer.charAt(i); // ie6 only support charAt for sz. + if ( + !((i - offset + 1) % pos_separator) && + i < l - pos_separator // -pos_separator so that won't trail separator on full length + ) { + str += Group; + } + } + v[0] = str; + } + + v[1] = (m[1] && v[1]) ? Decimal + v[1] : ""; + return (isNegative ? '-' : '') + v[0] + v[1]; // put back any negation and combine integer and fraction. + } + +}); + +/** + * Original Source: https://github.com/dtutic/Leaflet.EdgeScaleBar + * + * Draws the metric scale bars in Web Mercator map along top and right edges. + * Authors: Dražen Tutić (dtutic@geof.hr), Ana Kuveždić Divjak (akuvezdic@geof.hr) + * University of Zagreb, Faculty of Geodesy, GEOF-OSGL Lab + * Inspired by LatLonGraticule Leaflet plugin by: lanwei@cloudybay.com.tw + */ +L.Control.EdgeScale.Layer = L.Layer.extend({ + + includes: L.Evented ? L.Evented.prototype : L.Mixin.Events, + + options: { + opacity: 1, + weight: 0.8, + gradient: { + size: 10, + opacity: 0.5, + }, + color: '#000', + font: '11px Arial', + zoomInterval: [ + {start: 0, end: 2, interval: 5000000}, + {start: 3, end: 3, interval: 2000000}, + {start: 4, end: 4, interval: 1000000}, + {start: 5, end: 5, interval: 500000}, + {start: 6, end: 7, interval: 200000}, + {start: 8, end: 8, interval: 100000}, + {start: 9, end: 9, interval: 50000}, + {start: 10, end: 10, interval: 20000}, + {start: 11, end: 11, interval: 10000}, + {start: 12, end: 12, interval: 5000}, + {start: 13, end: 13, interval: 2000}, + {start: 14, end: 14, interval: 1000}, + {start: 15, end: 15, interval: 500}, + {start: 16, end: 16, interval: 200}, + {start: 17, end: 17, interval: 100}, + {start: 18, end: 18, interval: 50}, + {start: 19, end: 19, interval: 20}, + {start: 20, end: 20, interval: 10} + ], + pane: 'edgescalePane' + }, + + initialize: function (options) { + + L.setOptions(this, options); + + // Constants of the WGS84 ellipsoid needed to calculate meridian length or latitute + const a = this._a = 6378137.0; + const b = this._b = 6356752.3142; + const n = this._n = (a - b)/(a + b); + const a2 = a * a; + const b2 = b * b; + const n2 = n * n; + const n3 = n2 * n; + const n4 = n3 * n; + const n5 = n4 * n; + this._A = a * (1.0 - n) * (1.0 - n2) * (1.0 + 9.0/4.0 * n2 + 225.0/64.0 * n4); + this._e2 = (a2 - b2) / a2; + this._ic1 = 1.5 * n - 29.0/12.0 * n3 + 553.0/80.0 * n5; + this._ic2 = 21.0/8.0 * n2 - 1537.0/128.0 * n4; + this._ic3 = 151.0/24.0 * n3 - 32373.0/640.0 * n5; + this._ic4 = 1097.0/64.0 * n4; + this._ic5 = 8011.0/150.0 * n5; + this._c1 = -1.5 * n + 31.0/24.0 * n3 - 669.0/640.0 * n5; + this._c2 = 15.0/18.0 * n2 - 435.0/128.0 * n4; + this._c3 = -35.0/12.0 * n3 + 651.0/80.0 * n5; + this._c4 = 315.0/64.0 * n4; + this._c5 = -693.0/80.0 * n5; + + // Latitude limit of the Web Mercator projection + this._LIMIT_PHI = 1.484419982; + }, + + onAdd: function (map) { + this._map = map; + + let pane = map.getPane(this.options.pane); + if (!pane) { + pane = this._pane = map.createPane('edgescalePane', map.getPane('norotatePane') || map.getPane('mapPane')); + pane.style.zIndex = 625; // This pane is above markers but below popups. + pane.style.pointerEvents = 'none'; + } + + this._pane = pane; + + // if (this._renderer) this._renderer.remove() + // this._renderer = L.canvas({ pane: "edgescalePane" }).addTo(this._map); // default leaflet svg renderer + + if (!this._canvas) { + this._initCanvas(); + } + + this._pane.appendChild(this._canvas); + + map.on('viewreset', this._reset, this); + map.on('move', this._reset, this); + map.on('moveend', this._reset, this); + map.on('rotate', this._reset, this); + + this._reset(); + }, + + onRemove: function (map) { + this._pane.removeChild(this._canvas); + + map.off('viewreset', this._reset, this); + map.off('move', this._reset, this); + map.off('moveend', this._reset, this); + }, + + addTo: function (map) { + map.addLayer(this); + return this; + }, + + setOpacity: function (opacity) { + this.options.opacity = opacity; + L.DomUtil.setOpacity(this._canvas, this.options.opacity); + return this; + }, + + bringToFront: function () { + if (this._canvas) { + this._pane.appendChild(this._canvas); + } + return this; + }, + + bringToBack: function () { + if (this._canvas) { + this._pane.insertBefore(this._canvas, pane.firstChild); + } + return this; + }, + + _initCanvas: function () { + this._canvas = L.DomUtil.create('canvas', ''); + this._ctx = this._canvas.getContext('2d'); + + this.setOpacity(); + + L.extend(this._canvas, { + onselectstart: L.Util.falseFn, + onmousemove: L.Util.falseFn, + onload: L.bind(this._onCanvasLoad, this) + }); + }, + + _reset: function () { + var canvas = this._canvas, + size = this._map.getSize(); + + this._setCanvasPosition(); + + canvas.width = size.x; + canvas.height = size.y; + canvas.style.width = size.x + 'px'; + canvas.style.height = size.y + 'px'; + + /** + * @TODO add support for "leaflet-rotate" + */ + if (this._map._bearing) { + return; + } + + const { gradient } = this.options; + + // horizontal gradient + if (!this._hor_gradient) { + this._hor_gradient = this._ctx.createLinearGradient(0, 0, 0, gradient.size); + this._hor_gradient.addColorStop(0,"rgba(255, 255, 255, " + gradient.opacity + ")"); + this._hor_gradient.addColorStop(1,"rgba(255, 255, 255, 0)"); + } + this._ctx.fillStyle = this._hor_gradient; + this._ctx.fillRect(0, 0, size.x, gradient.size); + + // vertical gradient + if (!this._vert_gradient) { + this._vert_gradient = this._ctx.createLinearGradient(0, 0, gradient.size, 0); + this._vert_gradient.addColorStop(0,"rgba(255, 255, 255, " + gradient.opacity + ")"); + this._vert_gradient.addColorStop(1,"rgba(255, 255, 255, 0)"); + } + this._ctx.fillStyle = this._vert_gradient; + this._ctx.fillRect(0, 0, gradient.size, size.y); + + this._ctx.beginPath(); + this._ctx.moveTo(0,0); + this._ctx.lineTo(size.x,0); + this._ctx.lineTo(size.x,size.y); + this._ctx.stroke(); + + this._calcInterval(); + this._draw(); + }, + + _onCanvasLoad: function () { + this.fire('load'); + }, + + _calcInterval: function() { + const { zoomInterval } = this.options; + const zoom = this._map.getZoom(); + + if (undefined !== zoomInterval) { + // Manually set scale using a custom this.options.zoomInterval object + for (const idx in zoomInterval) { + const dict = zoomInterval[idx]; + if (dict.start <= zoom && dict.end && dict.end >= zoom) { + this._interval = dict.interval; + break; + } + } + } else { + // Autamatically get current scale using L.Control.Scale + // Source: https://gis.stackexchange.com/a/198444 + this._interval = L.Control.Scale.prototype._getRoundNum( + this._map + .containerPointToLatLng([0, this._map.getSize().y / 2 ]) + .distanceTo( + this._map.containerPointToLatLng([L.Control.Scale.prototype.options.maxWidth, this._map.getSize().y / 2 ] + ) + ) + ); + } + + this._currZoom = zoom; + + }, + + _draw: function() { + this._ctx.strokeStyle = this.options.color; + this._create_lat_ticks(); + this._create_lon_ticks(); + + this._ctx.fillStyle = this.options.color; + this._ctx.font = this.options.font; + + const size = this._map.getSize(); + const text = this._interval >= 1000 ? (this._interval / 1000 + ' km') : this._interval + ' m'; + + this._ctx.textAlign = 'left'; + this._ctx.textBaseline = 'middle'; + this._ctx.fillText(text, +12, size.y / 2); + + this._ctx.textAlign = 'center'; + this._ctx.textBaseline = 'top'; + this._ctx.fillText(text, size.x / 2, 12); + }, + + _create_lat_ticks: function() { + const { weight } = this.options; + const size = this._map.getSize(); + const to_rad = Math.PI/180.0; + const center = this._merLength(this._map.containerPointToLatLng(L.point(0, size.y / 2)).lat * to_rad); + const top = this._merLength(this._map.containerPointToLatLng(L.point(0,0)).lat * to_rad); + const bottom = this._merLength(this._map.containerPointToLatLng(L.point(0, size.y)).lat * to_rad); + + // draw major ticks + for (let i = center + this._interval / 2; i < top; i = i + this._interval) { + const phi = this._invmerLength(i); + if ((phi < this._LIMIT_PHI) && (phi > -this._LIMIT_PHI)) { + this._draw_lat_tick(phi, 10, weight * 1.5); + } + } + for (let i = center - this._interval / 2; i > bottom; i = i - this._interval) { + const phi = this._invmerLength(i); + if ((phi > -this._LIMIT_PHI) && (phi < this._LIMIT_PHI)) { + this._draw_lat_tick(phi, 10, weight * 1.5); + } + } + + // draw minor ticks + for (let i = center; i < top; i = i + this._interval / 10.0) { + const phi = this._invmerLength(i); + if ((phi < this._LIMIT_PHI) && (phi > -this._LIMIT_PHI)) { + this._draw_lat_tick(phi, 4, weight); + } + } + for (let i = center - this._interval / 10; i > bottom; i = i - this._interval / 10.0) { + const phi = this._invmerLength(i); + if ((phi > -this._LIMIT_PHI) && (phi < this._LIMIT_PHI)) { + this._draw_lat_tick(phi, 4, weight); + } + } + }, + + _create_lon_ticks: function() { + const { weight } = this.options; + const size = this._map.getSize(); + const to_rad = Math.PI/180.0; + const to_deg = 180.0/Math.PI; + const center = this._map.containerPointToLatLng(L.point(size.x / 2, 0)); + const left = this._map.containerPointToLatLng(L.point(0, 0)); + const right = this._map.containerPointToLatLng(L.point(size.x, 0)); + const sinPhi2 = Math.pow(Math.sin(center.lat * to_rad), 2); + const N = this._a / Math.sqrt(1.0 - this._e2 * sinPhi2); + const dl = this._interval / (N * Math.cos(center.lat * to_rad)) * to_deg; + + // draw major ticks + for (let i = center.lng + dl / 2; i < right.lng; i = i + dl) this._draw_lon_tick(i, 10, weight * 1.5); + for (let i = center.lng - dl / 2; i > left.lng; i = i - dl) this._draw_lon_tick(i, 10, weight * 1.5); + + // draw minor ticks + for (let i = center.lng; i < right.lng; i = i + dl / 10) this._draw_lon_tick(i, 4, weight); + for (let i = center.lng - dl / 10; i > left.lng; i = i - dl / 10) this._draw_lon_tick(i, 4, weight); + }, + + _setCanvasPosition: function() { + let lt = this._map.containerPointToLayerPoint([0, 0]); + /** + * @TODO add support for "leaflet-rotate" + */ + if (this._map._bearing) { + lt = this._map.rotatedPointToMapPanePoint( + this._map.containerPointToLayerPoint(L.point(this._map._container.getBoundingClientRect())) + ); + } + L.DomUtil.setPosition(this._canvas, lt); + }, + + _latLngToCanvasPoint: function (latlng) { + return L.point( + this._map + .project(L.latLng(latlng)) + ._subtract(this._map.getPixelOrigin()) + ).add(this._map._getMapPanePos()); + }, + + _draw_lat_tick: function (phi, lenght, weight) { + const to_deg = 180.0/Math.PI; + const size = this._map.getSize(); + const y = this._latLngToCanvasPoint(L.latLng(phi * to_deg, 0.0)).y; + this._ctx.lineWidth = weight; + this._ctx.beginPath(); + this._ctx.moveTo(0, y); + this._ctx.lineTo(+ lenght, y); + this._ctx.stroke(); + }, + + _draw_lon_tick: function(lam, lenght, weight) { + const x = this._latLngToCanvasPoint(L.latLng(0.0, lam)).x; + this._ctx.lineWidth = weight; + this._ctx.beginPath(); + this._ctx.moveTo(x, 0); + this._ctx.lineTo(x, lenght); + this._ctx.stroke(); + }, + + _merLength: function(phi) { + const cos2 = Math.cos(2.0 * phi); + const sin2 = Math.sin(2.0 * phi); + return this._A * (phi + sin2 * (this._c1 + (this._c2 + (this._c3 + (this._c4 + this._c5 * cos2) * cos2) *cos2) * cos2)); + }, + + _invmerLength: function(s) { + const psi = s/this._A; + const cos2 = Math.cos(2.0 * psi); + const sin2 = Math.sin(2.0 * psi); + return psi + sin2 * (this._ic1 + (this._ic2 + (this._ic3 + (this._ic4 + this._ic5 * cos2) * cos2) * cos2) * cos2); + }, +}); + +L.control.edgeScale = function (options) { + return new L.Control.EdgeScale(options); +}; + +L.Map.mergeOptions({ + edgeScaleControl: false +}); + +L.Map.addInitHook(function () { + if (this.options.edgeScaleControl) { + this.edgeScaleControl = new L.Control.EdgeScale(); + this.addControl(this.edgeScaleControl); + } +}); \ No newline at end of file -- cgit v1.3.1