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/src/components/chart.js | 783 +++++++++++++++++++++ 1 file changed, 783 insertions(+) create mode 100644 assets/static/vendor/leaflet-elevation/src/components/chart.js (limited to 'assets/static/vendor/leaflet-elevation/src/components/chart.js') diff --git a/assets/static/vendor/leaflet-elevation/src/components/chart.js b/assets/static/vendor/leaflet-elevation/src/components/chart.js new file mode 100644 index 0000000..b0b5458 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/chart.js @@ -0,0 +1,783 @@ +import * as D3 from './d3.js'; + +const _ = L.Control.Elevation.Utils; + +export var Chart = L.Control.Elevation.Chart = L.Class.extend({ + + includes: L.Evented ? L.Evented.prototype : L.Mixin.Events, + + initialize(opts, control) { + this.options = opts; + this.control = control; + + this._data = control._data || []; + + // cache registered components + this._props = { + scales : {}, + paths : {}, + areas : {}, + grids : {}, + axes : {}, + legendItems : {}, + tooltipItems: {}, + }; + + this._scales = {}; + this._domains = {}; + this._ranges = {}; + this._paths = {}; + + this._brushEnabled = opts.dragging; + this._zoomEnabled = opts.zooming; + + opts.xTicks = this._xTicks(); + opts.yTicks = this._yTicks(); + + let chart = this._chart = D3.Chart(opts); + + // SVG Container + this._container = chart.svg; + + // Panes + this._grid = chart.pane('grid'); + this._area = chart.pane('area'); + this._point = chart.pane('point'); + this._axis = chart.pane('axis'); + this._legend = chart.pane('legend'); + this._tooltip = chart.pane('tooltip'); + this._ruler = chart.pane('ruler'); + + // Scales + this._initScale(); + + // Helpers + this._mask = chart.get('mask'); + this._context = chart.get('context'); + this._brush = chart.get('brush'); + + this._zoom = d3.zoom(); + this._drag = d3.drag(); + + // Interactions + this._initInteractions(); + + // svg.on('resize', (e)=>console.log(e.detail)); + + // Handle multi-track segments + this._maskGaps = []; + control.on('eletrack_added', ({index}) => { + this._maskGaps.push(index); + control.once('elepoint_added', ({index}) => this._maskGaps.push(index)); + }); + + }, + + update(props) { + if (props) { + if (props.data) this._data = props.data; + if (props.options) this.options = props.options; + } + + this.options.xTicks = this._xTicks(); + this.options.yTicks = this._yTicks(); + + this._updateScale(); + this._updateAxis(); + this._updateMargins(); + this._updateLegend(); + this._updateClipper(); + this._updateArea(); + + return this; + }, + + render() { + return container => container.append(() => this._container.node()); + }, + + clear() { + this._resetDrag(); + this._hideDiagramIndicator() + this._area.selectAll('path').attr("d", "M0 0"); + this._context.clearRect(0, 0, this._width(), this._height()); + this._mask.selectAll(".gap").remove(); + this._maskGaps = []; + + // if (this._path) { + // this._x.domain([0, 1]); + // this._y.domain([0, 1]); + // } + }, + + _drawPath(name) { + let path = this._paths[name]; + let area = this._props.areas[name]; + + path.datum(this._data).attr("d", + D3.Area( + L.extend({}, area, { + width : this._width(), + height : this._height(), + scaleX : this._scales[area.scaleX], + scaleY : this._scales[area.scaleY] + }) + ) + ); + + if (!path.classed('leaflet-hidden')) { + this.options.preferCanvas + ? _.drawCanvas(this._context, path) + : _.append(this._area.node(), path.node()); + } + }, + + _hasActiveLayers() { + const paths = this._paths; + for (var i in paths) { + if (!paths[i].classed('leaflet-hidden')) { + return true; + } + } + return false; + }, + + /** + * Initialize "d3-brush". + */ + _initBrush(e) { + const brush = ({selection}) => { + if (selection && this._data.length) { + let start = this._findIndexForXCoord(selection[0]); + let end = this._findIndexForXCoord(selection[1]); + this.fire('dragged', { dragstart: this._data[start], dragend: this._data[end] }); + } + } + + const focus = (e) => { + if (this._data.length && (e.type != 'brush' /*|| e.sourceEvent*/)) { + let rect = this._chart.panes.brush.select('.overlay').node(); + let coords = d3.pointers(e, rect)[0]; + let xCoord = _.clamp(coords[0], [0, this._width()]); // Clamps cursor position to chart's bounds (see issues: #270, #301) + let item = this._data[this._findIndexForXCoord(xCoord)]; + + this.fire("mouse_move", { item: item, xCoord: xCoord }); + } + }; + + this._brush + .filter(({shiftKey, button}) => !shiftKey && !button && this._brushEnabled) + .on("end.update", brush) + .on("brush.update", focus); + + this._chart.panes.brush + .on("mouseenter.focus touchstart.focus", this.fire.bind(this, "mouse_enter")) + .on("mouseout.focus touchend.focus", this.fire.bind(this, "mouse_out") ) + .on("mousemove.focus touchmove.focus", focus ); + + }, + + /** + * Initialize "d3-zoom" + */ + _initClipper() { + let svg = this._container; + let margin = this.options.margins; + + const zoom = this._zoom; + + const onStart = ({transform, sourceEvent}) => { + if (sourceEvent && sourceEvent.type == "mousedown") svg.style('cursor', 'grabbing'); + if (transform.k == 1 && transform.x == 0) { + this._container.classed('zoomed', true); + // Apply d3-zoom and bind + if (this._mask) { + this._point.attr('mask', 'url(#' + this._mask.attr('id') + ')'); + } + } + this.zooming = true; + }; + + const onEnd = ({transform}) => { + if (transform.k ==1 && transform.x == 0) { + this._container.classed('zoomed', false); + // Reset d3-zoom and remove + if (this._mask) { + this._point.attr('mask', null); + } + } + this.zooming = false; + svg.style('cursor', ''); + }; + + const onZoom = ({transform, sourceEvent}) => { + // TODO: find a faster way to redraw the chart. + this.zooming = false; + this._updateScale(); // hacky way for restoring x scale when zooming out + this.zooming = true; + this._scales.distance = this._x = transform.rescaleX(this._x); // calculate x scale at zoom level + if (this._scales.time) this._scales.time = transform.rescaleX(this._scales.time); // calculate x scale at zoom level + this._resetDrag(); + if (sourceEvent && sourceEvent.type == "mousemove") { + this._hideDiagramIndicator(); + } + this.fire('zoom'); + }; + + zoom + .scaleExtent([1, 10]) + .extent([ + [margin.left, 0], + [this._width() - margin.right, this._height()] + ]) + .translateExtent([ + [margin.left, -Infinity], + [this._width() - margin.right, Infinity] + ]) + .filter(({shiftKey, buttons}) => (shiftKey || buttons == 4) && this._zoomEnabled) + .on("start", onStart) + .on("end", onEnd) + .on("zoom", onZoom); + + svg.call(zoom); // add zoom functionality to "svg" group + + // d3.select("body").on("keydown.grabzoom keyup.grabzoom", (e) => svg.style('cursor', e.shiftKey ? 'move' : '')); + + }, + + _initInteractions() { + this._initBrush(); + this._initRuler(); + this._initClipper(); + this._initLegend(); + }, + + /** + * Toggle chart data on legend click + */ + _initLegend() { + this._container.on('legend_clicked', ({detail}) => { + let { path, legend, name, enabled } = detail; + if (path) { + let label = _.select('text', legend); + let rect = _.select('rect', legend); + _.toggleStyle(label, 'text-decoration-line', 'line-through', enabled); + _.toggleStyle(rect, 'fill-opacity', '0', enabled); + _.toggleClass(path, 'leaflet-hidden', enabled); + this._updateArea(); + this.fire("elepath_toggle", { path, name, legend, enabled }) + } + }); + }, + + /** + * Initialize "ruler". + */ + _initRuler() { + if (!this.options.ruler) return; + + // const yMax = this._height(); + const formatNum = d3.format(".0f"); + const drag = this._drag; + + const label = (e, d) => { + let yMax = this._height(); + let y = this._ruler.data()[0].y; + if (y >= yMax || y <= 0) this._ruler.select(".horizontal-drag-label").text(''); + this._hideDiagramIndicator(); + }; + + const position = (e, d) => { + let yCoord = d3.pointers(e, this._area.node())[0][1]; + let yMax = this._height(); + let y = _.clamp(yCoord, [0, yMax]); + + this._ruler + .data([L.extend(this._ruler.data()[0], { y: y })]) + .attr("transform", d => "translate(" + d.x + "," + d.y + ")") + .classed('active', y < yMax); + + this._container + .select(".horizontal-drag-label") + .text(formatNum(this._y.invert(y)) + " " + (this.options.imperial ? 'ft' : 'm')); + + this.fire('ruler_filter', { coords: yCoord < yMax && yCoord > 0 ? this._findCoordsForY(yCoord) : [] }); + } + + drag + .on("start end", label) + .on("drag", position); + + this._ruler.call(drag); + + }, + + /** + * Initialize x and y scales + */ + _initScale() { + let opts = this.options; + + this._registerAxisScale({ + axis : 'x', + position: 'bottom', + attr : opts.xAttr, + min : opts.xAxisMin, + max : opts.xAxisMax, + name : 'distance' + }); + + this._registerAxisScale({ + axis : 'y', + position : 'left', + attr : opts.yAttr, + min : opts.yAxisMin, + max : opts.yAxisMax, + name : 'altitude' + }); + + this._x = this._scales.distance; + this._y = this._scales.altitude; + }, + + _registerAreaPath(props) { + if (props.scale == 'y') props.scale = this._y; + else if (props.scale == 'x') props.scale = this._x; + + let opts = this.options; + + if (!props.xAttr) props.xAttr = opts.xAttr; + if (!props.yAttr) props.yAttr = opts.yAttr; + if (typeof props.preferCanvas === "undefined") props.preferCanvas = opts.preferCanvas; + + // Save paths in memory for latter usage + this._paths[props.name] = D3.Path(props); + this._props.areas[props.name] = props; + + if (opts.legend) { + this._props.legendItems[props.name] = { + name : props.name, + label : props.label, + color : props.color, + className: props.className, + path : this._paths[props.name] + }; + } + + }, + + _registerAxisGrid(props) { + if (props.scale == 'y') props.scale = this._y; + else if (props.scale == 'x') props.scale = this._x; + + this._props.grids[props.name || props.axis] = props; + }, + + _registerAxisScale(props) { + if (props.scale == 'y') props.scale = this._y; + else if (props.scale == 'x') props.scale = this._x; + + let opts = this.options; + let scale = props.scale; + + if (typeof this._scales[props.name] === 'function') { + props.scale = this._scales[props.name]; // retrieve cached scale + } else if (typeof scale !== 'function') { + scale = L.extend({ + data : this._data, + forceBounds: opts.forceAxisBounds + }, scale); + + scale.attr = scale.attr || props.name; + + let domain = this._domains[props.name] = D3.Domain(props); + let range = this._ranges[props.name] = D3.Range(props); + scale.range = scale.range || range(this._width(),this._height()); + scale.domain = scale.domain || domain(this._data); + + this._props.scales[props.name] = scale; + + props.scale = this._scales[props.name] = D3.Scale(scale); + } + + if (!props.ticks) { + if (props.axis == 'x') props.ticks = this._xTicks.bind(this); + else if (props.axis == 'y') props.ticks = this._yTicks.bind(this); + } + + this._props.axes[props.name] = props; + + if (props.name == this.options.yScale) this._y = scale; + if (props.name == this.options.xScale) this._x = scale; + + return scale; + }, + + /** + * Add a point of interest over the chart + */ + _registerCheckPoint(point) { + if (!this._data.length) return; + + const {xAttr, yAttr} = this.options; + let item, x, y; + + if (point.latlng) { + item = this._data[this._findIndexForLatLng(point.latlng)]; + x = this._x(item[xAttr]); + y = this._y(item[yAttr]); + } else if (!isNaN(point[xAttr])) { + x = this._x(point[xAttr]); + item = this._data[this._findIndexForXCoord(x)] + y = this._y(item[yAttr]); + } + + this._point.call(D3.CheckPoint({ + point: point, + width: this._width(), + height: this._height(), + x: x, + y: y, + })); + }, + + _registerTooltip(props) { + props.order = props.order ?? 1000; + this._props.tooltipItems[props.name] = props; + }, + + _updateArea() { + // Reset and update chart profiles + this._context.clearRect(0, 0, this._width(), this._height()); + _.each(this._paths, (path, i) => !path.classed('leaflet-hidden') && this._drawPath(i)); + }, + + _updateAxis() { + + let opts = this.options; + + const gridOpts = { + width : this._width(), + height : this._height(), + tickFormat: "", + }; + + const axesOpts = { + width : this._width(), + height : this._height(), + }; + + // Reset grids + this._grid.selectAll('g').remove(); + _.each(this._props.grids, (grid, i) => { + if (opts[i] !== false && opts[i] !== 'summary') { + this._grid.call(D3.Grid(L.extend({}, gridOpts, grid))) + } + }); + + // Rest axis + this._axis.selectAll('g').remove(); + _.each(this._props.axes, (axis, i) => { + if (opts[i] !== false && opts[i] !== 'summary') { + this._axis.call(D3.Axis(L.extend({}, axesOpts, axis))); + } + }); + + // Adjust axis scale positions + this._axis + .selectAll('.y.axis.right') + .each((d, i, n) => { + + let axis = d3.select(n[i]); + let transform = axis.attr('transform'); + let translate = transform.substring(transform.indexOf("(") + 1, transform.indexOf(")")).split(","); + + axis.attr('transform', 'translate(' + (+translate[0] + (i * 40)) + ',' + translate[1] + ')') + + if (i > 0) { + axis.select(':scope > path') .attr('opacity', 0.25); + axis.selectAll(':scope > .tick line').attr('opacity', 0.75); + } + + }); + + }, + + _updateClipper() { + const { xAttr, margins } = this.options; + const data = this._data; + + this._zoom + .scaleExtent([1, 10]) + .extent([ + [margins.left, 0], + [this._width() - margins.right, this._height()] + ]) + .translateExtent([ + [margins.left, -Infinity], + [this._width() - margins.right, Infinity] + ]); + + // Apply svg mask on multi-track segments + this._mask.selectAll(".gap").remove() + this._maskGaps.forEach((d, i) => { + if (i >= this._maskGaps.length - 2) return; + let x1 = this._x(data[this._findIndexForLatLng(data[this._maskGaps[i]].latlng)][xAttr]); + let x2 = this._x(data[this._findIndexForLatLng(data[this._maskGaps[i + 1]].latlng)][xAttr]); + this._mask + .append("rect") + .attr("x", x1) + .attr("y", 0) + .attr("width", x2 - x1 ) + .attr("height", this._height()) + .attr('class', 'gap') + .attr('fill-opacity', '0.8') + .attr("fill", 'black'); // hide = black (mask) + }); + }, + + _updateLegend: function () { + let xAxesB = this._axis.selectAll('.x.axis.bottom').nodes().length; + + // Get legend items + let items = Object.keys(this._paths); + + // Calculate legend item positions + let n = items.length; + let v = Array(Math.floor(n / 2)).fill(null).map((d, i) => (i + 1) * 2 - (1 - Math.sign(n % 2))); + let rev = v.slice().reverse().map((d) => -(d)); + + // push a fake element to handle center alignment (odd numbers) + if (n % 2 !== 0) { + rev.push(0); + } + v = rev.concat(v); + + // Reset legend items + this._legend.selectAll('g').remove(); + + // Render legend items + _.each(this._props.legendItems, (legend) => { + this._legend.append("g").call( + D3.LegendItem(L.extend({ + width : this._width(), + height : this._height(), + margins: this.options.margins, + }, legend)) + ); + }); + + // Render legend item switcher + if (n > 1) { + this._legend.append("g").call( + D3.LegendSmall({ + width : this._width(), + height : this._height(), + items : items, + onClick: (selected) => { + _.each(items, name => this._togglePath(name, selected == name, true)); + this._updateArea(); + } + }) + ); + } + + _.each(items, (name, i) => { + // Adjust legend item positions + this._legend.select('[data-name=' + name + ']').attr("transform", "translate(" + (v[i] * 55) + ", " + (xAxesB * 2) + ")"); + // Set initial state (disabled controls) + this._togglePath(name, !(name in this.options && this.options[name] == 'disabled'), true); + }); + + }, + + _updateMargins() { + // Get chart margins + let xAxesB = this._axis.selectAll('.x.axis.bottom').nodes().length; + let xAxesL = this._axis.selectAll('.y.axis.left').nodes().length; + let xAxesR = this._axis.selectAll('.y.axis.right').nodes().length; + let marginB = 60 + (xAxesB * 2); + let marginL = 10 + (xAxesL * 30); + let marginR = 40 + (xAxesR * 30); + + let marginsUpdated = false + + // Adjust right margin + if (xAxesR && this.options.margins.right < marginR) { + this.options.margins.right = marginR; + marginsUpdated = true; + } + + // Adjust left margin + if (xAxesL && this.options.margins.left < marginL) { + this.options.margins.left = marginL; + marginsUpdated = true; + } + + // Adjust bottom margin + if (xAxesB && this.options.margins.bottom < marginB) { + this.options.margins.bottom = marginB; + marginsUpdated = true; + } + + if (marginsUpdated) { + this.fire('margins_updated'); + } + }, + + _updateScale() { + if (this.zooming) return { x: this._x, y: this._y }; + + for (let i in this._scales) { + this._scales[i] + .domain(this._domains[i](this._data)) + .range(this._ranges[i](this._width(), this._height())) + } + + return { x: this._x, y: this._y }; + }, + + /** + * Calculates chart width. + */ + _width() { + if (this._chart) this._chart._width; + const { width, margins } = this.options; + return width - margins.left - margins.right; + }, + + /** + * Calculates chart height. + */ + _height() { + if (this._chart) return this._chart._height; + const { height, margins } = this.options; + return height - margins.top - margins.bottom; + }, + + /* + * Finds data entries above a given y-elevation value and returns geo-coordinates + */ + _findCoordsForY(y) { + let data = this._data; + let z = this._y.invert(y); + + // save indexes of elevation values above the horizontal line + const list = data.reduce((array, item, index) => { + if (item[this.options.yAttr] >= z) array.push(index); + return array; + }, []); + + let start = 0; + let next; + + // split index list into blocks of coordinates + const coords = list.reduce((array, _, curr) => { + next = curr + 1; + if (list[next] !== list[curr] + 1 || next === list.length) { + array.push( + list + .slice(start, next) + .map(i => data[i].latlng) + ); + start = next; + } + return array; + }, []); + + return coords; + }, + + /* + * Finds a data entry for a given x-coordinate of the diagram + */ + _findIndexForXCoord(x) { + return d3 + .bisector(d => d[this.options.xAttr]) + .left(this._data || [0, 1], this._x.invert(x)); + }, + + /* + * Finds a data entry for a given latlng of the map + */ + _findIndexForLatLng(latlng) { + let result = null; + let d = Infinity; + this._data.forEach((item, index) => { + let dist = latlng.distanceTo(item.latlng); + if (dist < d) { + d = dist; + result = index; + } + }); + return result; + }, + + /* + * Removes the drag rectangle and zoms back to the total extent of the data. + */ + _resetDrag() { + if (this._chart.panes.brush.select(".selection").attr('width')) { + this._chart.panes.brush.call(this._brush.clear); + this._hideDiagramIndicator(); + this.fire('reset_drag'); + } + }, + + _resetZoom() { + if (this._zoom) { + this._zoom.transform(this._chart.svg, d3.zoomIdentity); + } + }, + + /** + * Display distance and altitude level ("focus-rect"). + */ + _showDiagramIndicator(item, xCoordinate) { + this._tooltip + .attr("display", null) + .call(D3.Tooltip({ + xCoord: xCoordinate, + yCoord: this._y(item[this.options.yAttr]), + height: this._height(), + width : this._width(), + labels: this._props.tooltipItems, + item: item + })); + }, + + _togglePath(name, enabled = true, lazy = false) { + let path = this._paths[name]; + let legend = this._container.select('.legend [data-name=' + name + ']'); + path.classed('leaflet-hidden', !enabled); + legend.select('text').style('text-decoration-line', enabled ? '' : 'line-through'); + legend.select('rect').style('fill-opacity', enabled ? '': '0'); + // Apply d3-zoom (bind mask) + if (this._mask) { + path.attr('mask', 'url(#' + this._mask.attr('id') + ')'); + } + if (!lazy) { + this._updateArea(); + } + }, + + _hideDiagramIndicator() { + this._tooltip.attr("display", 'none'); + }, + + /** + * Calculate chart xTicks + */ + _xTicks() { + if (this.__xTicks) this.__xTicks = this.options.xTicks; + return this.__xTicks || Math.round(this._width() / 75); +}, + + /** + * Calculate chart yTicks + */ + _yTicks() { + if (this.__yTicks) this.__yTicks = this.options.yTicks; + return this.__yTicks || Math.round(this._height() / 30); + } +}); -- cgit v1.3.1