diff options
| author | Dennis Fink | 2026-07-23 00:18:21 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-07-23 00:18:21 +0200 |
| commit | 1fe1d13c600b7dfe421b3b99b833b7d5d1232724 (patch) | |
| tree | 2e8636160cc1719c907be56f5ef8cbc7e0f10a53 /assets/static/vendor/leaflet-elevation/src | |
| parent | ca77de06be474dfd712402c327dc436864d233a3 (diff) | |
| download | dennisfink.me-1fe1d13c600b7dfe421b3b99b833b7d5d1232724.tar.gz dennisfink.me-1fe1d13c600b7dfe421b3b99b833b7d5d1232724.zip | |
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.
Diffstat (limited to '')
23 files changed, 4564 insertions, 0 deletions
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 <mask> + 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 <mask> + 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 <clipPath> 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); + } +}); diff --git a/assets/static/vendor/leaflet-elevation/src/components/d3.js b/assets/static/vendor/leaflet-elevation/src/components/d3.js new file mode 100644 index 0000000..2c35d57 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/d3.js @@ -0,0 +1,649 @@ +export const Area = ({ + width, + height, + xAttr, + yAttr, + scaleX, + scaleY, + interpolation = "curveLinear" +}) => { + return d3.area() + .curve(typeof interpolation === 'string' ? d3[interpolation] : interpolation) + .x(d => (d.xDiagCoord = scaleX(d[xAttr]))) + .y0(height) + .y1(d => scaleY(d[yAttr])); +}; + +export const Path = ({ + name, + color, + strokeColor, + strokeOpacity, + fillOpacity, + className = '' +}) => { + let path = d3.create('svg:path') + + if (name) path.classed(name + ' ' + className, true); + + path.style("pointer-events", "none"); + + path + .attr("fill", color || '#3366CC') + .attr("stroke", strokeColor || '#000') + .attr("stroke-opacity", strokeOpacity || '1') + .attr("fill-opacity", fillOpacity || '0.8'); + + return path; +}; + +export const Axis = ({ + type = "axis", + tickSize = 6, + tickPadding = 3, + position, + height, + width, + axis, + scale, + ticks, + tickFormat, + label, + labelX, + labelY, + name = "", + onAxisMount, +}) => { + return g => { + let [w, h] = [0, 0]; + if (position == "bottom") h = height; + if (position == "right") w = width; + + if (axis == "x" && type == "grid") { + tickSize = -height; + } else if (axis == "y" && type == "grid") { + tickSize = -width; + } + + let axisScale = d3["axis" + position.replace(/\b\w/g, l => l.toUpperCase())]() + .scale(scale) + .ticks(typeof ticks === 'function' ? ticks() : ticks) + .tickPadding(tickPadding) + .tickSize(tickSize) + .tickFormat(tickFormat); + + let axisGroup = g.append("g") + .attr("class", [axis, type, position, name].join(" ")) + .attr("transform", "translate(" + w + "," + h + ")") + .call(axisScale); + + if (label) { + axisGroup.append("svg:text") + .attr("x", labelX) + .attr("y", labelY) + .text(label); + } + + if (onAxisMount) { + axisGroup.call(onAxisMount); + } + + return axisGroup; + }; +}; + +export const Grid = (props) => { + props.type = "grid"; + return Axis(props); +}; + +export const PositionMarker = ({ + theme, + xCoord = 0, + yCoord = 0, + labels = {}, + item = {}, + length = 0, +}) => { + return g => { + + g.attr("class", "height-focus-group"); + + let line = g.select('.height-focus.line'); + let circle = g.select('.height-focus.circle-lower'); + let text = g.select('.height-focus-label'); + + if (!line.node()) line = g.append('svg:line'); + if (!circle.node()) circle = g.append('svg:circle'); + if (!text.node()) text = g.append('svg:text'); + + if (isNaN(xCoord) || isNaN(yCoord)) return g; + + circle + .attr("class", theme + " height-focus circle-lower") + .attr("transform", "translate(" + xCoord + "," + yCoord + ")") + .attr("r", 6) + .attr("cx", 0) + .attr("cy", 0); + + line + .attr("class", theme + " height-focus line") + .attr("x1", xCoord) + .attr("x2", xCoord) + .attr("y1", yCoord) + .attr("y2", length); + + text + .attr("class", theme + " height-focus-label") + .style("pointer-events", "none") + .attr("x", xCoord + 5) + .attr("y", length); + + let label; + + Object + .keys(labels) + .sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues? + .forEach((i)=> { + label = text.select(".height-focus-" + labels[i].name); + + if (!label.size()) { + label = text.append("svg:tspan") + .attr("class", "height-focus-" + labels[i].name /*+ " " + "order-" + labels[i].order*/) + .attr("dy", "1.5em"); + } + + label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item)); + + }); + + text.select('tspan').attr("dy", text.selectAll('tspan').size() > 1 ? "-1.5em" : "0em" ); + text.selectAll('tspan').attr("x", xCoord + 5); + + return g; + } +}; + +export const LegendItem = ({ + name, + label, + width, + height, + margins = {}, + color, + path, + className = '' +}) => { + return g => { + g + .attr("class", "legend-item legend-" + name.toLowerCase()) + .attr("data-name", name); + + g.on('click.legend', () => d3.select(g.node().ownerSVGElement || g) + .dispatch("legend_clicked", { + detail: { + path: path.node(), + name: name, + legend: g.node(), + enabled: !path.classed('leaflet-hidden'), + } + }) + ); + + g.append("svg:rect") + .attr("x", (width / 2) - 50) + .attr("y", height + margins.bottom / 2) + .attr("width", 50) + .attr("height", 10) + .attr("fill", color) + .attr("stroke", "#000") + .attr("stroke-opacity", "0.5") + .attr("fill-opacity", "0.25") + .attr("class", className); + + g.append('svg:text') + .text(L._(label || name)) + .attr("x", (width / 2) + 5) + .attr("font-size", 10) + .style("font-weight", "700") + .attr('y', height + margins.bottom / 2) + .attr('dy', "0.75em"); + + return g; + } +}; + +export const LegendSmall = ({ + width, + height, + items, + onClick +}) => { + return g => { + let idx = 0; + + g.data([{ + x: width, + y: height + 40 + }]).attr("transform", d => "translate(" + d.x + "," + d.y + ")").classed('legend-switcher'); + + // let label = g.selectAll(".legend-switcher-label") .data([{ idx: 0 }]); + let label = g.append('svg:text') + .attr("class", "legend-switcher-label") + .attr("text-anchor", "end") + .attr("x", -25) + .attr("y", 5) + .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length]))); + + let symbol = g.selectAll(".legend-switcher-symbol").data([ + { type: d3.symbolTriangle, x: 0, y: 3, angle: 0, size: 50, id: "down" }, + { type: d3.symbolTriangle, x: -13, y: 0, angle: 180, size: 50, id: "up" } + ]); + + symbol.exit().remove(); + label.exit().remove(); + + symbol.enter() + .append("svg:path") + .attr("class", "legend-switcher-symbol") + .attr("cursor", 'pointer') + .attr("fill", "#000") + .merge(symbol) + .attr("d", + d3.symbol() + .type(d => d.type) + .size(d => d.size) + ) + .attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")") + .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((d.id === "up" ? idx + 1 : idx - 1), [0, items.length]))); + + const setIdx = (id) => { + idx = id; + // label.enter() + // .append('text') + // .attr("class", "legend-switcher-label") + // .attr("text-anchor", "end") + // .attr("x", -25) + // .attr("y", 5) + // .merge(label.data([{ idx: id }])) + // .text(d => items.length ? (items[idx][0].toUpperCase() + items[idx].slice(1)) : '') + // .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length]))); + label.text(items.length ? L._((items[idx][0].toUpperCase() + items[idx].slice(1))) : ''); + onClick(items[idx]); + }; + setIdx(0); + + return g; + }; +}; + +export const Tooltip = ({ + xCoord, + yCoord, + width, + height, + labels = {}, + item = {}, +}) => { + return g => { + + let line = g.select('.mouse-focus-line'); + let box = g.select('.mouse-focus-label'); + + if (!line.node()) line = g.append('svg:line'); + if (!box.node()) box = g.append("g"); + + let rect = box.select(".mouse-focus-label-rect"); + let text = box.select(".mouse-focus-label-text"); + + if (!rect.node()) rect = box.append("svg:rect"); + if (!text.node()) text = box.append("svg:text"); + + // Sets focus-label-text position to the left / right of the mouse-focus-line + let xAlign = 0; + let yAlign = 0; + let bbox = { width: 0, height: 0 }; + try { bbox = text.node().getBBox(); } catch (e) { return g; } + + if (xCoord) xAlign = xCoord + (xCoord < width / 2 ? 10 : -bbox.width - 10); + if (yCoord) yAlign = Math.max(yCoord - bbox.height, L.Browser.webkit ? 0 : -Infinity); + + line + .attr('class', 'mouse-focus-line') + .attr('x2', xCoord) + .attr('y2', 0) + .attr('x1', xCoord) + .attr('y1', height); + + box + .attr('class', 'mouse-focus-label'); + + rect + .attr("class", "mouse-focus-label-rect") + .attr("x", xAlign - 5) + .attr("y", yAlign - 5) + .attr("width", bbox.width + 10) + .attr("height", bbox.height + 10) + .attr("rx", 3) + .attr("ry", 3); + + text + .attr("class", "mouse-focus-label-text") + .style("font-weight", "700") + .attr("y", yAlign); + + let label; + + Object + .keys(labels) + .sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues? + .forEach((i)=> { + label = text.select(".mouse-focus-label-" + labels[i].name); + + if (!label.size()) { + label = text.append("svg:tspan", ".mouse-focus-label-x") + .attr("class", "mouse-focus-label-" + labels[i].name /*+ " " + "order-" + labels[i].order*/) + .attr("dy", "1.5em"); + } + + label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item)); + }); + + text.select('tspan').attr("dy", "1em"); + text.selectAll('tspan').attr("x", xAlign); + + return g; + }; +}; + +export const Ruler = ({ + height, + width, +}) => { + return g => { + + g.data([{ + x: 0, + y: height, + }]) + .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); + + let rect = g.selectAll('.horizontal-drag-rect').data([{ w: width }]); + let line = g.selectAll('.horizontal-drag-line').data([{ w: width }]); + let label = g.selectAll('.horizontal-drag-label').data([{ w: width - 8 }]); + let symbol = g.selectAll('.horizontal-drag-symbol') + .data([{ + type: d3.symbolTriangle, + x: width + 7, + y: 0, + angle: -90, + size: 50 + }]); + + rect.exit().remove(); + line.exit().remove(); + label.exit().remove(); + symbol.exit().remove(); + + rect.enter() + .append("svg:rect") + .attr("class", "horizontal-drag-rect") + .attr("x", 0) + .attr("y", -8) + .attr("height", 8) + .attr('fill', 'none') + .attr('pointer-events', 'all') + .merge(rect) + .attr("width", d => d.w); + + line.enter() + .append("svg:line") + .attr("class", "horizontal-drag-line") + .attr("x1", 0) + .merge(line) + .attr("x2", d => d.w); + + label.enter() + .append("svg:text") + .attr("class", "horizontal-drag-label") + .attr("text-anchor", "end") + .attr("y", -8) + .merge(label) + .attr("x", d => d.w) + + symbol + .enter() + .append("svg:path") + .attr("class", "horizontal-drag-symbol") + .merge(symbol) + .attr("d", + d3.symbol() + .type(d => d.type) + .size(d => d.size) + ) + .attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")"); + + return g; + } +}; + +export const CheckPoint = ({ + point, + height, + width, + x, + y +}) => { + return g => { + + if (isNaN(x) || isNaN(y)) return g; + + if (!point.item || !point.item.property('isConnected')) { + point.position = point.position || "bottom"; + + point.item = g.append('g'); + + point.item.append("svg:line") + .attr("y1", 0) + .attr("x1", 0) + .attr("style","stroke: rgb(51, 51, 51); stroke-width: 0.5; stroke-dasharray: 2, 2;"); + + point.item + .append("svg:circle") + .attr("class", " height-focus circle-lower") + .attr("r", 3); + + if (point.label) { + point.item.append("svg:text") + .attr("dx", "4px") + .attr("dy", "-4px"); + } + } + + point.item + .datum({ + pos: point.position, + x: x, + y: y + }) + .attr("class", d => "point " + d.pos) + .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); + + point.item.select('line') + .datum({ + y2: ({'top': -y, 'bottom': height - y})[point.position], + x2: ({'left': -x, 'right': width - x})[point.position] || 0 + }) + .attr("y2", d => d.y2) + .attr("x2", d => d.x2) + + if (point.label) { + point.item.select('text') + .text(point.label); + } + + return g; + } +}; + +export const Domain = ({ + min, + max, + attr, + name, + forceBounds, + scale +}) => function(data) { + attr = (scale && scale.attr) || attr || name; + let domain = data && data.length ? d3.extent(data, d => d[attr]) : [0, 1]; + if (typeof min !== "undefined" && (min < domain[0] || forceBounds)) { + domain[0] = min; + } + if (typeof max !== "undefined" && (max > domain[1] || forceBounds)) { + domain[1] = max; + } + return domain; +}; + +export const Range = ({ + axis +}) => function(width, height) { + if (axis == 'x') return [0, width]; + else if (axis == 'y') return [height, 0]; +}; + +export const Scale = ({ + data, + attr, + min, + max, + forceBounds, + range, +}) => { + return d3.scaleLinear() + .range(range) + .domain(Domain({min, max, attr, forceBounds})(data)); +}; + +export const Bisect = ({ + data = [0, 1], + scale, + x, + attr +}) => { + return d3 + .bisector(d => d[attr]) + .left(data, scale.invert(x)); +}; + +export const Chart = ({ + width, + height, + margins = {}, + ruler, +}) => { + + const w = width - margins.left - margins.right; + const h = height - margins.top - margins.bottom; + + // SVG Container + const svg = d3.create("svg:svg").attr("class", "background"); + + const defs = svg.append("svg:defs"); + + // SVG Groups + const g = svg.append("g"); + const panes = { + grid : g.append("g").attr("class", "grid"), + area : g.append('g').attr("class", "area"), + axis : g.append('g').attr("class", "axis"), + point : g.append('g').attr("class", "point"), + brush : g.append("g").attr("class", "brush"), + tooltip: g.append("g").attr("class", "tooltip").attr('display', 'none'), + ruler : g.append('g').attr('class', 'ruler'), + legend : g.append('g').attr("class", "legend"), + }; + + // SVG Paths + const mask = panes.area .append("svg:mask") .attr("id", 'elevation-clipper-' + Math.random().toString(36).substr(2, 9)).attr('fill-opacity', 1); + const maskRect = mask .append("svg:rect") .attr('class', 'zoom') .attr('fill', 'white'); // white = transparent + + // Canvas Paths + const foreignObject = panes.area .append('svg:foreignObject').attr('mask', 'url(#' + mask.attr('id') + ')'); + const canvas = foreignObject.append('xhtml:canvas') .attr('class', 'canvas-plot'); + const context = canvas.node().getContext('2d'); + + // Add tooltip + panes.tooltip.call(Tooltip({ xCoord: 0, yCoord: 0, height: h, width : w, labels: {} })); + + // Add the brushing + let brush = d3.brushX().on('start.cursor end.cursor brush.cursor', () => panes.brush.select(".overlay").attr('cursor', null)); + + // Scales + const scale = (opts) => ({ x: Scale(opts.x), y: Scale(opts.y)}); + + let utils = { + defs, + mask, + canvas, + context, + brush, + }; + + let chart = { + svg, + g, + panes, + utils, + scale, + }; + + // Resize + chart._resize = ({ + width, + height, + margins = {}, + ruler, + }) => { + + const w = width - margins.left - margins.right; + const h = height - margins.top - margins.bottom; + + svg .attr("width", width).attr("height", height).attr("viewBox", `0 0 ${width} ${height}`); + g .attr("transform", "translate(" + margins.left + "," + margins.top + ")"); + + // Fix: https://github.com/Raruto/leaflet-elevation/issues/123 + // Fix: https://github.com/Raruto/leaflet-elevation/issues/232 + if ( + /Mac|iPod|iPhone|iPad/.test(navigator.platform) && + /AppleWebkit/i.test(navigator.userAgent) && + !/Chrome/.test(navigator.userAgent) + ) { + canvas .style("transform", "translate(" + margins.left + "px," + margins.top + "px)"); + } + + maskRect .attr("width", w).attr("height", h).attr("x", 0).attr("y", 0); + foreignObject.attr('width', w).attr('height', h); + canvas .attr('width', w).attr('height', h); + + if (ruler) { + panes.ruler.call(Ruler({ height: h, width: w })); + } + + panes.brush.call(brush.extent( [ [0,0], [w, h] ] )); + panes.brush.select(".overlay").attr('cursor', null); + + chart._width = w; + chart._height = h; + + chart.svg.dispatch('resize', { detail: { width: w, height: h } } ); + + }; + + chart.pane = (name) => (panes[name] || (panes[name] = g.append('g').attr("class", name))); + chart.get = (name) => utils[name]; + + chart._resize({ width, height, margins}); + + return chart; +}; diff --git a/assets/static/vendor/leaflet-elevation/src/components/marker.js b/assets/static/vendor/leaflet-elevation/src/components/marker.js new file mode 100644 index 0000000..6f1f6af --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/marker.js @@ -0,0 +1,96 @@ +import * as D3 from './d3.js'; + +const _ = L.Control.Elevation.Utils; + +export var Marker = L.Class.extend({ + + initialize(options, control) { + this.options = options; + this.control = control + + switch(this.options.marker) { + case 'elevation-line': + // this._container = d3.create("g").attr("class", "height-focus-group"); + break; + case 'position-marker': + // this._marker = L.circleMarker([0, 0], { pane: 'overlayPane', radius: 6, fillColor: '#fff', fillOpacity:1, color: '#000', weight:1, interactive: false }); + this._marker = L.marker([0, 0], { icon: this.options.markerIcon, zIndexOffset: 1000000, interactive: false }); + break; + } + + this._labels = {}; + + return this; + }, + + addTo(map) { + this._map = map; + switch(this.options.marker) { + case 'elevation-line': this._container = d3.select(map.getPane('elevationPane')).select("svg > g").call(D3.PositionMarker({})); break; + case 'position-marker': this._marker.addTo(map, { pane: 'overlayPane' }); break; + } + return this; + }, + + /** + * Update position marker ("leaflet-marker"). + */ + update(props) { + if (props) this._props = props; + else props = this._props; + + if (!props) return; + + if (props.options) this.options = props.options; + if (!this._map) this.addTo(props.map); + + this._latlng = props.item.latlng; + + switch(this.options.marker) { + case 'elevation-line': + if (this._container) { + let point = this._map.latLngToLayerPoint(this._latlng); + point = L.extend({}, props.item, this._map._rotate ? this._map.rotatedPointToMapPanePoint(point) : point); + + let yMax = (this.control._height() / props.yCoordMax * point[this.options.yAttr]); + + if (!isFinite(yMax) || isNaN(yMax)) yMax = 0; + + this._container.classed("leaflet-hidden", false); + this._container.call(D3.PositionMarker({ + theme : this.options.theme, + xCoord: point.x, + yCoord: point.y, + length: point.y - yMax, // normalized Y + labels: this._labels, + item: point, + })); + } + break; + case 'position-marker': + _.removeClass(this._marker.getElement(), 'leaflet-hidden'); + this._marker.setLatLng(this._latlng); + break; + } + }, + + /* + * Hides the position/height indicator marker drawn onto the map + */ + remove() { + this._props = null; + switch(this.options.marker) { + case 'elevation-line': this._container && this._container.classed("leaflet-hidden", true); break; + case 'position-marker': _.addClass(this._marker.getElement(), 'leaflet-hidden'); break; + } + }, + + getLatLng() { + return this._latlng; + }, + + _registerTooltip(props) { + this._labels[props.name] = props; + } + +}); diff --git a/assets/static/vendor/leaflet-elevation/src/components/summary.js b/assets/static/vendor/leaflet-elevation/src/components/summary.js new file mode 100644 index 0000000..e8da57f --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/summary.js @@ -0,0 +1,43 @@ +const _ = L.Control.Elevation.Utils;
+
+export var Summary = L.Class.extend({
+ initialize(opts, control) {
+ this.options = opts;
+ this.control = control;
+ this.labels = {};
+
+ let summary = this._container = _.create("div", "elevation-summary " + (opts.summary ? opts.summary + "-summary" : ''));
+ _.style(summary, 'max-width', opts.width ? opts.width + 'px' : '');
+ },
+
+ render() {
+ return container => container.append(() => this._container);
+ },
+
+ reset() {
+ this._container.innerHTML = '';
+ },
+
+ append(className, label, value) {
+ this._container.innerHTML += `<span class="${className}"><span class="summarylabel">${label}</span><span class="summaryvalue">${value}</span></span>`;
+ return this;
+ },
+
+ update() {
+ Object
+ .keys(this.labels)
+ .sort((a, b) => this.labels[a].order - this.labels[b].order) // TODO: any performance issues?
+ .forEach((i)=> {
+ let value = typeof this.labels[i].value !== "function" ? this.labels[i].value : this.labels[i].value(this.control.track_info, this.labels[i].unit || '');
+ this.append(i /*+ " order-" + this.labels[i].order*/, L._(this.labels[i].label), value, this.labels[i].order);
+ });
+ },
+
+ _registerSummary(data) {
+ for (let i in data) {
+ data[i].order = data[i].order ?? 1000;
+ this.labels[i] = data[i];
+ }
+ }
+
+});
diff --git a/assets/static/vendor/leaflet-elevation/src/control.js b/assets/static/vendor/leaflet-elevation/src/control.js new file mode 100644 index 0000000..27ecf77 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/control.js @@ -0,0 +1,1335 @@ +import * as _ from './utils'; +import { Options } from './options'; + +// "leaflet-i18n" fallback +if (!L._ || !L.i18n) { + L._ = L.i18n = (string, data) => string; +} + +export const Elevation = L.Control.Elevation = L.Control.extend({ + + includes: L.Evented ? L.Evented.prototype : L.Mixin.Events, + + options: Options, + __mileFactor: 0.621371, // 1 km = (0.621371 mi) + __footFactor: 3.28084, // 1 m = (3.28084 ft) + __D3: 'https://unpkg.com/d3@7.8.4/dist/d3.min.js', + __TOGEOJSON: 'https://unpkg.com/@tmcw/togeojson@5.6.2/dist/togeojson.umd.js', + __LGEOMUTIL: 'https://unpkg.com/leaflet-geometryutil@0.10.1/src/leaflet.geometryutil.js', + __LALMOSTOVER: 'https://unpkg.com/leaflet-almostover@1.0.1/src/leaflet.almostover.js', + __LHOTLINE: '../libs/leaflet-hotline.min.js', + __LDISTANCEM: '../libs/leaflet-distance-marker.min.js', + __LEDGESCALE: '../libs/leaflet-edgescale.min.js', + __LCHART: '../src/components/chart.js', + __LMARKER: '../src/components/marker.js', + __LSUMMARY: '../src/components/summary.js', + __modulesFolder: '../src/handlers/', + __btnIcon: '../images/elevation.svg', + + /* + * Add data to the diagram either from GPX or GeoJSON and update the axis domain and data + */ + addData(d, layer) { + this.import(this.__D3) + .then(() => { + if (this._modulesLoaded) { + layer = layer ?? (d.on && d); + this._addData(d); + this._addLayer(layer); + this._fireEvt("eledata_added", { data: d, layer: layer, track_info: this.track_info }); + } else { + this.once('modules_loaded', () => this.addData(d,layer)); + } + }); + }, + + /** + * Adds the control to the given map. + */ + addTo(map) { + if (this.options.detached) { + let parent = _.select(this.options.elevationDiv); + let eleDiv = this.onAdd(map); + parent ? _.append(parent, eleDiv) : _.insert(map.getContainer(), eleDiv, 'afterend'); + } else { + L.Control.prototype.addTo.call(this, map); + } + return this; + }, + + /* + * Reset data and display + */ + clear() { + if (this._marker) this._marker.remove(); + if (this._chart) this._clearChart(); + if (this._layers) this._clearLayers(this._layers); + if (this._markers) this._clearLayers(this._markers); + if (this._circleMarkers) this._circleMarkers.remove(); + if (this._hotline) this._hotline.eachLayer(l => l.options.renderer.remove()); // hotfix for: https://github.com/Raruto/leaflet-elevation/issues/233 + if (this._hotline) this._clearLayers(this._hotline); + + this._data = []; + this.track_info = {}; + + this._fireEvt("eledata_clear"); + + this._updateChart(); + }, + + _clearChart() { + if (this._events && this._events.elechart_updated) { + this._events.elechart_updated.forEach(({fn, ctx}) => this.off('elechart_updated', fn, ctx)); + } + if (this._chart && this._chart._container) { + this._chart._container.selectAll('g.point .point').remove(); + this._chart.clear(); + } + }, + + _clearLayers(l) { + l = l || this._layers; + if (l && l.eachLayer) { + l.eachLayer(f => f.remove()) + l.clearLayers(); + } + }, + + /** + * TODO: Create a base class to handle custom data attributes (heart rate, cadence, temperature, ...) + * + * @link https://leafletjs.com/examples/extending/extending-3-controls.html#handlers + */ + // addHandler: function (name, HandlerClass) { + // if (HandlerClass) { + // let handler = this[name] = new HandlerClass(this); + // this.handlers.push(handler); + // if (this.options[name]) { + // handler.enable(); + // } + // } + // return this; + // }, + + /** + * Disable chart brushing. + */ + disableBrush() { + this._chart._brushEnabled = false; + this._resetDrag(); + }, + + /** + * Enable chart brushing. + */ + enableBrush() { + this._chart._brushEnabled = true; + }, + + /** + * Disable chart zooming. + */ + disableZoom() { + this._chart._zoomEnabled = false; + this._chart._resetZoom(); + }, + + /** + * Enable chart zooming. + */ + enableZoom() { + this._chart._zoomEnabled = true; + }, + + /** + * Sets a map view that contains the given geographical bounds. + */ + fitBounds(bounds) { + bounds = bounds || this.getBounds(); + if (this._map && bounds.isValid()) this._map.fitBounds(bounds); + }, + + getBounds(data) { + return L.latLngBounds((data || this._data).map((d) => d.latlng)); + }, + + /** + * Get default zoom level (followMarker: true). + */ + getZFollow() { + return this.options.zFollow; + }, + + /** + * Hide current elevation chart profile. + */ + hide() { + _.style(this._container, "display", "none"); + }, + + /** + * Initialize chart control "options" and "container". + */ + initialize(opts) { + + // opts = L.setOptions(this, opts); + + // Fixes: https://github.com/Raruto/leaflet-elevation/pull/240 + opts = L.setOptions(this, L.extend({}, _.cloneDeep(Options), opts)); // "deep copy" nested objects (multiple charts) + + this._data = []; + this._layers = L.featureGroup(); + this._markers = L.featureGroup(); + this._hotline = L.featureGroup(); + this._circleMarkers = L.featureGroup(); + this._markedSegments = L.polyline([]); + this._start = L.circleMarker([0,0], (opts.trkStart || Options.trkStart)); + this._end = L.circleMarker([0,0], (opts.trkEnd || Options.trkEnd)); + this._chartEnabled = true; + this._yCoordMax = -Infinity; + this.track_info = {}; + // this.handlers = []; + + if (opts.followMarker) this._setMapView = _.throttle(this._setMapView, 300, this); + if (opts.legend) opts.margins.bottom += 30; + if (opts.theme) opts.polylineSegments.className += ' ' + opts.theme; + if (opts.wptIcons === true) opts.wptIcons = Options.wptIcons; + if (opts.distanceMarkers === true) opts.distanceMarkers = Options.distanceMarkers; + if (opts.trkStart) this._start.addTo(this._circleMarkers); + if (opts.trkEnd) this._end.addTo(this._circleMarkers); + + + this._markedSegments.setStyle(opts.polylineSegments); + + // Leaflet canvas renderer colors + L.extend(_.Colors, opts.colors || {}); + + // Various stuff + this._fixCanvasPaths(); + this._fixTooltipSize(); + + }, + + /** + * Javascript scripts downloader (lazy loader) + */ + import(src, condition) { + if (Array.isArray(src)) { + return Promise.all(src.map(m => this.import(m))); + } + switch(src) { + case this.__D3: condition = typeof d3 !== 'object'; break; + case this.__TOGEOJSON: condition = typeof toGeoJSON !== 'object'; break; + case this.__LGEOMUTIL: condition = typeof L.GeometryUtil !== 'object'; break; + case this.__LALMOSTOVER: condition = typeof L.Handler.AlmostOver !== 'function'; break; + case this.__LDISTANCEM: condition = typeof L.DistanceMarkers !== 'function'; break; + case this.__LEDGESCALE: condition = typeof L.Control.EdgeScale !== 'function'; break; + case this.__LHOTLINE: condition = typeof L.Hotline !== 'function'; break; + } + return condition !== false ? import(_.resolveURL(src, this.options.srcFolder)) : Promise.resolve(); + }, + + /** + * Load elevation data (GPX, GeoJSON, KML or TCX). + */ + load(data) { + this._parseFromString(data).then( geojson => geojson ? this._loadLayer(geojson) : this._loadFile(data)); + }, + + /** + * Create container DOM element and related event listeners. + * Called on control.addTo(map). + */ + onAdd(map) { + this._map = map; + + let container = this._container = _.create("div", "elevation-control " + this.options.theme + " " + (this.options.detached ? 'elevation-detached' : 'leaflet-control'), this.options.detached ? { id: 'elevation-' + _.randomId() } : {}); + + if (!this.eleDiv) this.eleDiv = container; + + this._loadModules(this.options.handlers).then(() => { // Inject here required modules (data handlers) + this._initChart(container); + this._initButton(container); + this._initSummary(container); + this._initMarker(map); + this._initLayer(map); + this._modulesLoaded = true; + this.fire('modules_loaded'); + }); + + this.fire('add'); + + return container; + }, + + /** + * Clean up control code and related event listeners. + * Called on control.remove(). + */ + onRemove(map) { + this._container = null; + + map + .off('zoom viewreset zoomanim', this._hideMarker, this) + .off('resize', this._resetView, this) + .off('resize', this._resizeChart, this) + .off('mousedown', this._resetDrag, this); + + _.off(map.getContainer(), 'mousewheel', this._resetDrag, this); + _.off(map.getContainer(), 'touchstart', this._resetDrag, this); + _.off(document, 'keydown', this._onKeyDown, this); + + this + .off('eledata_added eledata_loaded', this._updateChart, this) + .off('eledata_added eledata_loaded', this._updateSummary, this); + + this.fire('remove'); + }, + + /** + * Redraws the chart control. Sometimes useful after screen resize. + */ + redraw() { + this._resizeChart(); + }, + + /** + * Set default zoom level (followMarker: true). + */ + setZFollow(zoom) { + this.options.zFollow = zoom; + }, + + /** + * Hide current elevation chart profile. + */ + show() { + _.style(this._container, "display", "block"); + }, + + /* + * Parsing data either from GPX or GeoJSON and update the diagram data + */ + _addData(d) { + if (!d) { + return; + } + + // Standard GeoJSON + if (d.type === "FeatureCollection" ) { + return _.each(d.features, feature => this._addData(feature)); + } else if (d.type === "Feature") { + let geom = d.geometry; + if (geom) { + switch (geom.type) { + case 'LineString': return this._addGeoJSONData(geom.coordinates, d.properties); + case 'MultiLineString': return _.each(geom.coordinates, (coords, i) => this._addGeoJSONData(coords, d.properties, i)); + case 'Point': + default: return console.warn('Unsopperted GeoJSON feature geometry type:' + geom.type); + } + } + } + + // Fallback for leaflet layers (eg. L.Gpx) + if (d._latlngs) { + return this._addGeoJSONData(d._latlngs, d.feature && d.feature.properties); + } + + }, + + /* + * Parsing of GeoJSON data lines and their elevation in z-coordinate + */ + _addGeoJSONData(coords, properties, nestingLevel) { + + // "coordinateProperties" property is generated inside "@tmcw/toGeoJSON" + let props = (properties && properties.coordinateProperties) || properties; + + coords.forEach((point, i) => { + + // GARMIN_EXTENSIONS = ["hr", "cad", "atemp", "wtemp", "depth", "course", "bearing"]; + point.meta = point.meta ?? { time: null, ele: null }; + + point.prev = (attr) => (attr ? this._data[i > 0 ? i - 1 : 0][attr] : this._data[i > 0 ? i - 1 : 0]); + + this.fire("elepoint_init", { point: point, props: props, id: i, isMulti: nestingLevel }); + + this._addPoint( + point.lat ?? point[1], + point.lng ?? point[0], + point.alt ?? point.meta.ele ?? point[2] + ); + + this.fire("elepoint_added", { point: point, index: this._data.length - 1 }); + + if (this._yCoordMax < this._data[this._data.length - 1][this.options.yAttr]) this._yCoordMax = this._data[this._data.length - 1][this.options.yAttr]; + }); + + this.fire("eletrack_added", { coords: coords, index: this._data.length - 1 }); + }, + + /* + * Parse and push a single (x, y, z) point to current elevation profile. + */ + _addPoint(x, y, z) { + if (this.options.reverseCoords) { + [x, y] = [y, x]; + } + + this._data.push({ + x: x, + y: y, + z: z, + latlng: L.latLng(x, y, z) + }); + + this.fire("eledata_updated", { index: this._data.length - 1 }); + }, + + _addLayer(layer) { + if (layer) this._layers.addLayer(layer) + // Postpone adding the distance markers (lazy: true) + if (layer && this.options.distanceMarkers && this.options.distanceMarkers.lazy) { + layer.on('add remove', ({target, type}) => L.DistanceMarkers && target instanceof L.Polyline && target[type + 'DistanceMarkers']()); + } + return layer; + }, + + _addMarker(marker) { + if (marker) this._markers.addLayer(marker) + return marker; + }, + + /** + * Initialize "L.AlmostOver" integration + */ + _initAlmostOverHandler(map, layer) { + return (map && this.options.almostOver && !L.Browser.mobile) ? this.import([this.__LGEOMUTIL, this.__LALMOSTOVER]) + .then(() => { + map.addHandler('almostOver', L.Handler.AlmostOver) + if (L.GeometryUtil && map.almostOver && map.almostOver.enabled()) { + map.almostOver.addLayer(layer); + map + .on('almost:move', this._onMouseMoveLayer, this) + .on('almost:out', this._onMouseOut, this); + this.once('eledata_clear', () => { + map.almostOver.removeLayer(layer); + map + .off('almost:move', this._onMouseMoveLayer, this) + .off('almost:out', this._onMouseOut, this); + }) + } + }) : Promise.resolve(); + }, + + /** + * Initialize "L.DistanceMarkers" integration + */ + _initDistanceMarkers() { + return this.options.distanceMarkers ? this.import([this.__LGEOMUTIL, this.__LDISTANCEM]) : Promise.resolve(); + }, + + /** + * Initialize "L.Control.EdgeScale" integration + */ + _initEdgeScale(map) { + return this.options.edgeScale ? this.import(this.__LEDGESCALE) + .then(() => { + map.edgeScaleControl = map.edgeScaleControl || L.control.edgeScale('boolean' !== typeof this.options.edgeScale ? this.options.edgeScale : {}).addTo(map); + }) : Promise.resolve(); + }, + + _initHotLine(layer) { + let prop = typeof this.options.hotline == 'string' ? this.options.hotline : 'elevation'; + return this.options.hotline ? this.import(this.__LHOTLINE) + .then(() => { + layer.eachLayer((trkseg) => { + if (trkseg.feature.geometry.type != "Point") { + let geo = L.geoJson(trkseg.toGeoJSON(), { coordsToLatLng: (coords) => L.latLng(coords[0], coords[1], coords[2] * (this.options.altitudeFactor || 1))}); + let line = L.hotline(geo.toGeoJSON().features[0].geometry.coordinates, { + renderer: L.Hotline.renderer(), + min: isFinite(this.track_info[prop + '_min']) ? this.track_info[prop + '_min'] : 0, + max: isFinite(this.track_info[prop + '_max']) ? this.track_info[prop + '_max'] : 1, + palette: { + 0.0: '#008800', + 0.5: '#ffff00', + 1.0: '#ff0000' + }, + weight: 5, + outlineColor: '#000000', + outlineWidth: 1 + }).addTo(this._hotline); + let alpha = trkseg.options.style && trkseg.options.style.opacity || 1; + trkseg.on('add remove', ({type}) => { + trkseg.setStyle({opacity: (type == 'add' ? 0 : alpha)}); + line[(type == 'add' ? 'addTo' : 'removeFrom')](trkseg._map); + if (line._renderer) line._renderer._container.parentElement.insertBefore(line._renderer._container, line._renderer._container.parentElement.firstChild); + }); + } + }); + }) : Promise.resolve(); + }, + + /** + * Initialize "L.AlmostOver" and "L.DistanceMarkers" + */ + _initMapIntegrations(layer) { + let map = this._map; + if (map) { + if (this._data.length) { + this._start.setLatLng(this._data[0].latlng); + this._end.setLatLng(this._data[this._data.length -1].latlng); + } + Promise.all([ + this._initHotLine(layer), + this._initAlmostOverHandler(map, layer), + this._initDistanceMarkers(), + this._initEdgeScale(map), + ]).then(() => { + if (this.options.polyline) { + this._layers.addLayer(layer.addTo(map)); // hotfix for: https://github.com/Raruto/leaflet-elevation/issues/233 + this._circleMarkers.addTo(map); + } + if (this.options.autofitBounds) { + this.fitBounds(layer.getBounds()); + } + map.invalidateSize(); + }); + } else { + this.once('add', () => this._initMapIntegrations(layer)); + } + }, + + /* + * Collapse current chart control. + */ + _collapse() { + _.replaceClass(this._container, 'elevation-expanded', 'elevation-collapsed'); + if (this._map) this._map.invalidateSize(); + }, + + /* + * Expand current chart control. + */ + _expand() { + _.replaceClass(this._container, 'elevation-collapsed', 'elevation-expanded'); + if (this._map) this._map.invalidateSize(); + }, + + /** + * Add some basic colors to leaflet canvas renderer (preferCanvas: true). + */ + _fixCanvasPaths() { + let oldProto = L.Canvas.prototype._fillStroke; + let control = this; + + let theme = this.options.theme.split(' ')[0].replace('-theme', ''); + let color = _.Colors[theme] || {}; + + L.Canvas.include({ + _fillStroke(ctx, layer) { + if (control._layers.hasLayer(layer)) { + + let options = layer.options; + + options.color = color.line || color.area || theme; + options.stroke = !!options.color; + + oldProto.call(this, ctx, layer); + + if (options.stroke && options.weight !== 0) { + let oldVal = ctx.globalCompositeOperation || 'source-over'; + ctx.globalCompositeOperation = 'destination-over' + ctx.strokeStyle = color.outline || '#FFF'; + ctx.lineWidth = options.weight * 1.75; + ctx.stroke(); + ctx.globalCompositeOperation = oldVal; + } + + } else { + oldProto.call(this, ctx, layer); + } + } + }); + }, + + /** + * Partial fix for initial tooltip size + * + * @link https://github.com/Raruto/leaflet-elevation/issues/81#issuecomment-713477050 + */ + _fixTooltipSize() { + this.on('elechart_init', () => + this.once('elechart_change elechart_hover', ({data, xCoord}) => { + if (this._chartEnabled) { + this._chart._showDiagramIndicator(data, xCoord); + this._chart._showDiagramIndicator(data, xCoord); + } + this._updateMarker(data); + }) + ); + }, + + /* + * Finds a data entry for the given LatLng + */ + _findItemForLatLng(latlng) { + return this._data[this._chart._findIndexForLatLng(latlng)]; + }, + + /* + * Finds a data entry for the given xDiagCoord + */ + _findItemForX(x) { + return this._data[this._chart._findIndexForXCoord(x)]; + }, + + /** + * Fires an event of the specified type. + */ + _fireEvt(type, data, propagate) { + if (this.fire) this.fire(type, data, propagate); + if (this._map) this._map.fire(type, data, propagate); + }, + + /* + * Hides the position/height indicator marker drawn onto the map + */ + _hideMarker() { + if (this.options.autohideMarker) { + this._marker.remove(); + } + }, + + /** + * Generate "svg" chart (DOM element). + */ + _initChart(container) { + let opts = this.options; + let map = this._map; + + if (opts.detached) { + let { offsetWidth, offsetHeight} = this.eleDiv; + if (offsetWidth > 0) opts.width = offsetWidth; + if (offsetHeight > 20) opts.height = offsetHeight - 20; // 20 = horizontal scrollbar size. + } else { + let { clientWidth } = map.getContainer(); + opts._maxWidth = opts._maxWidth > opts.width ? opts._maxWidth : opts.width; + this._container.style.maxWidth = opts._maxWidth + 'px'; + if (opts._maxWidth > clientWidth) opts.width = clientWidth - 30; + } + + this + .import([this.__D3, this.__LCHART]) + .then((m) => { + + let chart = this._chart = new (m[1] || Elevation).Chart(opts, this); + + this._x = this._chart._x; + this._y = this._chart._y; + + d3 + .select(container) + .call(chart.render()) + + chart + .on('reset_drag', this._hideMarker, this) + .on('mouse_enter', this._onMouseEnter, this) + .on('dragged', this._onDragEnd, this) + .on('mouse_move', this._onMouseMove, this) + .on('mouse_out', this._onMouseOut, this) + .on('ruler_filter', this._onRulerFilter, this) + .on('zoom', this._updateChart, this) + .on('elepath_toggle', this._onToggleChart, this) + .on('margins_updated', this._resizeChart, this); + + + this.fire("elechart_init"); + + map + .on('zoom viewreset zoomanim', this._hideMarker, this) + .on('resize', this._resetView, this) + .on('resize', this._resizeChart, this) + .on('rotate', this._rotateMarker, this) + .on('mousedown', this._resetDrag, this); + + _.on(map.getContainer(), 'mousewheel', this._resetDrag, this); + _.on(map.getContainer(), 'touchstart', this._resetDrag, this); + _.on(document, 'keydown', this._onKeyDown, this); + + this + .on('eledata_added eledata_loaded', this._updateChart, this) + .on('eledata_added eledata_loaded', this._updateSummary, this); + + this._updateChart(); + this._updateSummary(); + }); + + + }, + + _initLayer() { + this._layers + .on('layeradd layerremove', ({layer, type}) => { + let node = layer.getElement && layer.getElement(); + _.toggleClass(node, this.options.polyline.className + ' ' + this.options.theme, type == 'layeradd'); + _.toggleEvent(layer, "mousemove", this._onMouseMoveLayer.bind(this), type == 'layeradd') + _.toggleEvent(layer, "mouseout", this._onMouseOut.bind(this), type == 'layeradd'); + }); + }, + + _initMarker(map) { + let pane = map.getPane('elevationPane'); + if (!pane) { + pane = this._pane = map.createPane('elevationPane', map.getPane('norotatePane') || map.getPane('mapPane')); + pane.style.zIndex = 625; // This pane is above markers but below popups. + pane.style.pointerEvents = 'none'; + } + + if (this._renderer) this._renderer.remove() + this._renderer = L.svg({ pane: "elevationPane" }).addTo(this._map); // default leaflet svg renderer + + this.import([this.__D3, this.__LMARKER]) + .then((m) => { + this._marker = new (m[1] || Elevation).Marker(this.options, this); + this.fire("elechart_marker"); + }); + }, + + /** + * Inspired by L.Control.Layers + */ + _initButton(container) { + L.DomEvent + .disableClickPropagation(container) + .disableScrollPropagation(container); + + this.options.collapsed ? this._collapse() : this._expand(); + + if (this.options.autohide) { + _.on(container, 'mouseover', this._expand, this); + _.on(container, 'mouseout', this._collapse, this); + this._map.on('click', this._collapse, this); + } + + if (this.options.closeBtn) { + let link = this._button = _.create('a', "elevation-toggle-icon", { href: '#', title: L._('Elevation'), }, container); + _.on(link, 'click', L.DomEvent.stop); + _.on(link, 'click', this._toggle, this); + _.on(link, 'focus', this._toggle, this); + fetch(_.resolveURL(this.__btnIcon, this.options.srcFolder)).then(r => r.ok && r.text().then(img => link.innerHTML = img)); + } + }, + + _initSummary(container) { + this.import(this.__LSUMMARY).then((m)=>{ + this._summary = new (m || Elevation).Summary({ summary: this.options.summary }, this); + + this.on('elechart_init', () => { + d3.select(container).call(this._summary.render()); + }); + }); + }, + + /** + * Retrieve data from a remote url (HTTP). + */ + _loadFile(url) { + fetch(url) + .then((response) => response.text()) + .then((data) => { + this._downloadURL = url; // TODO: handle multiple urls? + this._parseFromString(data) + .then( geojson => geojson && this._loadLayer(geojson)); + }).catch((err) => console.warn(err)); + }, + + /** + * Dynamically import only required javascript modules (code splitting) + */ + _loadModules(handlers) { + // First map known classnames (eg. "Altitude" --> L.Control.Elevation.Altitude) + handlers = handlers.map((h) => typeof h === 'string' && typeof Elevation[h] !== "undefined" ? Elevation[h] : h); + // Then load optional classes and custom imports (eg. "Cadence" --> import('../src/handlers/cadence.js')) + let modules = handlers.map(file => (typeof file === 'string' && this.import(this.__modulesFolder + file.toLowerCase() + '.js')) || (file instanceof Promise && file) || Promise.resolve()); + return Promise.all(modules).then((m) => { + _.each(m, (exported, i) => { + let fn = exported && Object.keys(exported)[0]; + if (fn) { + handlers[i] = Elevation[fn] = (Elevation[fn] ?? exported[fn]); + } + }); + _.each(handlers, h => ["function", "object"].includes(typeof h) && this._registerHandler(h)); + }); + }, + + /** + * Simple GeoJSON data loader (L.GeoJSON). + */ + _loadLayer(geojson) { + let { polyline, theme, waypoints, wptIcons, wptLabels, distanceMarkers } = this.options; + let style = L.extend({}, polyline); + + if (theme) { + style.className += ' ' + theme; + } + + if (geojson.name) { + this.track_info.name = geojson.name; + } + + let layer = L.geoJson(geojson, { + distanceMarkers: distanceMarkers, + style: style, + pointToLayer: (feature, latlng) => { + if (waypoints) { + let { desc, name, sym } = feature.properties; + desc = desc || ''; + name = name || ''; + // Handle chart waypoints (dots) + if ([true, 'dots'].includes(waypoints)) { + this._registerCheckPoint({ + latlng: latlng, + label : ([true, 'dots'].includes(wptLabels) ? name : '') + }); + } + // Handle map waypoints (markers) + if ([true, 'markers'].includes(waypoints) && wptIcons != false) { + return this._registerMarker({ + latlng : latlng, + sym : (sym ?? name).replace(' ', '-').replace('"', '').replace("'", '').toLowerCase(), + content: [true, 'markers'].includes(wptLabels) && (name || desc) && decodeURI("<b>" + name + "</b>" + (desc.length > 0 ? '<br>' + desc : '')) + }); + } + } + }, + onEachFeature: (feature, layer) => feature.geometry && feature.geometry.type != 'Point' && this.addData(feature, layer), + }); + + this.import(this.__D3).then(() => { + this._initMapIntegrations(layer); + const event_data = { data: geojson, layer: layer, name: this.track_info.name, track_info: this.track_info }; + if (this._modulesLoaded) { + this._fireEvt("eledata_loaded", event_data); + } else { + this.once('modules_loaded', () => this._fireEvt("eledata_loaded", event_data)); + } + }); + + return layer; + }, + + _onDragEnd({ dragstart, dragend}) { + this._hideMarker(); + this.fitBounds(L.latLngBounds([dragstart.latlng, dragend.latlng])); + + this.fire("elechart_dragged"); + }, + + _onKeyDown({key}) { + if (!this.options.detached && key === "Escape"){ + this._collapse() + }; + }, + + /** + * Trigger mouseenter event. + */ + _onMouseEnter() { + this.fire('elechart_enter'); + }, + + /* + * Handles the moueseover the chart and displays distance and altitude level. + */ + _onMouseMove({xCoord}) { + if (this._chartEnabled && this._data.length) { + let item = this._findItemForX(xCoord); + if (item) { + if (this._chartEnabled) this._chart._showDiagramIndicator(item, xCoord); + + this._updateMarker(item); + this._setMapView(item); + + if (this._map) { + _.addClass(this._map.getContainer(), 'elechart-hover'); + } + + this.fire("elechart_change", { data: item, xCoord: xCoord }); + this.fire("elechart_hover", { data: item, xCoord: xCoord }); + } + } + }, + + /* + * Handles mouseover events of the data layers on the map. + */ + _onMouseMoveLayer({latlng}) { + if (this._data.length) { + let item = this._findItemForLatLng(latlng); + if (item) { + let xCoord = item.xDiagCoord; + + if (this._chartEnabled) this._chart._showDiagramIndicator(item, xCoord); + + this._updateMarker(item); + + this.fire("elechart_change", { data: item, xCoord: xCoord }); + } + } + }, + + /* + * Handles the moueseout over the chart. + */ + _onMouseOut() { + if (!this.options.detached) { + this._hideMarker(); + this._chart._hideDiagramIndicator(); + } + + if (this._map) { + _.removeClass(this._map.getContainer(), 'elechart-hover'); + } + + this.fire("elechart_leave"); + }, + + /** + * Handles the drag event over the ruler filter. + */ + _onRulerFilter({coords}) { + this._updateMapSegments(coords); + }, + + /** + * Toggle chart data on legend click + */ + _onToggleChart({ name, enabled }) { + + this._chartEnabled = this._chart._hasActiveLayers(); + + // toggle layer visibility on empty chart + this._layers.eachLayer(layer => _.toggleClass(layer.getElement && layer.getElement(), this.options.polyline.className + ' ' + this.options.theme, this._chartEnabled)); + + // toggle option value (eg. altitude = { 'disabled' || 'enabled' }) + this.options[name] = !enabled && this.options[name] == 'disabled' ? 'enabled' : 'disabled'; + + // remove marker on empty chart + if (!this._chartEnabled) { + this._chart._hideDiagramIndicator(); + this._marker.remove(); + } + }, + + /** + * Simple GeoJSON Parser + */ + _parseFromGeoJSONString(data) { + try { + return JSON.parse(data); + } catch (e) { } + }, + + /** + * Attempt to parse raw response data (GeoJSON or XML > GeoJSON) + */ + _parseFromString(data) { + return new Promise(resolve => + this.import(this.__TOGEOJSON).then(() => { + let geojson; + try { + geojson = this._parseFromXMLString(data.trim()); + } catch (e) { + geojson = this._parseFromGeoJSONString(data.toString()); + } + if (geojson) { + geojson.name = geojson.name || (this._downloadURL || '').split('/').pop().split('#')[0].split('?')[0]; + } + resolve(geojson); + }) + ); + }, + + /** + * Simple XML Parser (GPX, KML, TCX) + */ + _parseFromXMLString(data) { + if (data.indexOf("<") != 0) { + throw 'Invalid XML'; + } + let xml = (new DOMParser()).parseFromString(data, "text/xml"); + let type = xml.documentElement.tagName.toLowerCase(); // "kml" or "gpx" + let name = xml.getElementsByTagName('name'); + if (xml.getElementsByTagName('parsererror').length) { + throw 'Invalid XML'; + } + if (!(type in toGeoJSON)) { + type = xml.documentElement.tagName == "TrainingCenterDatabase" ? 'tcx' : 'gpx'; + } + let geojson = toGeoJSON[type](xml); + geojson.name = name.length > 0 ? (Array.from(name).find(tag => tag.parentElement.tagName == "trk") ?? name[0]).textContent : ''; + return geojson; + }, + + /** + * Add chart profile to diagram + */ + _registerAreaPath(props) { + this.on("elechart_init", () => this._chart._registerAreaPath(props)); + }, + + /** + * Add chart grid to diagram + */ + _registerAxisGrid(props) { + this.on("elechart_axis", () => this._chart._registerAxisGrid(props)); + }, + + /** + * Add chart axis to diagram + */ + _registerAxisScale(props) { + this.on("elechart_axis", () => this._chart._registerAxisScale(props)); + }, + + /** + * Add a point of interest over the diagram + */ + _registerCheckPoint(props) { + const cb = () => this._chart._registerCheckPoint(props); + this + .on("elechart_updated", cb) + .once("eledata_clear", () => this.off("elechart_updated", cb)); + }, + + /** + * Base handler for iterative track statistics (dist, time, z, slope, speed, acceleration, ...) + */ + _registerDataAttribute(props) { + + // parse of "coordinateProperties" for later usage + if (props.coordPropsToMeta) { + this.on("elepoint_init", (e) => props.coordPropsToMeta.call(this, e)); + } + + // prevent excessive variabile instanstations + let i, curr, prev, attr = props.attr || props.name; + + // save here a reference to last used point + let lastValid = {}; + + // iteration + this.on("elepoint_added", ({index, point}) => { + i = index; + + prev = curr ?? this._data[i]; // same as: this._data[i > 0 ? i - 1 : i] + curr = this._data[i]; + + // retrieve point value + curr[attr] = props.pointToAttr.call(this, point, i); + + // check and fix missing data on last added point + if (i > 0 && isNaN(prev[attr])) { + if (!isNaN(lastValid[attr]) && !isNaN(curr[attr])) { + prev[attr] = (lastValid[attr] + curr[attr]) / 2; + } else if (!isNaN(lastValid[attr])) { + prev[attr] = lastValid[attr]; + } else if (!isNaN(curr[attr])) { + prev[attr] = curr[attr]; + } + // update "yAttr" and "xAttr" + if (props.meta) { + prev[props.meta] = prev[attr]; + } + } + + // skip to next iteration for invalid or missing data (eg. i == 0) + if (isNaN(curr[attr])) { + return; + } + + // update reference to last used point + lastValid[attr] = curr[attr]; + + // Limit "crazy" delta values. + if (props.deltaMax) { + curr[attr] =_.wrapDelta(curr[attr], prev[attr], props.deltaMax); + } + + // Range of acceptable values. + if (props.clampRange) { + curr[attr] = _.clamp(curr[attr], props.clampRange); + } + + // Limit floating point precision. + if (!isNaN(props.decimals)) { + curr[attr] = _.round(curr[attr], props.decimals); + } + + // update "track_info" stats (min, max, avg, ...) + if (props.stats) { + for (const key in props.stats) { + let sname = (props.statsName || attr) + (key != '' ? '_' : ''); + this.track_info[sname + key] = props.stats[key].call(this, curr[attr], this.track_info[sname + key], this._data.length); + } + } + + // update here some mixins (eg. complex "track_info" stuff) + if (props.onPointAdded) props.onPointAdded.call(this, curr[attr], i, point); + }); + }, + + /** + * Parse a module definition and attach related function listeners + */ + _registerHandler(props) { + + // eg. L.Control.Altitude + if (typeof props === "function") { + return this._registerHandler(props.call(this)); + } + + let { + name, + attr, + required, + deltaMax, + clampRange, + decimals, + meta, + unit, + coordinateProperties, + coordPropsToMeta, + pointToAttr, + onPointAdded, + stats, + statsName, + grid, + scale, + path, + tooltip, + summary + } = props; + + // eg. "altitude" == true + if (this.options[name] || required) { + + this._registerDataAttribute({ + name, + attr, + meta, + deltaMax, + clampRange, + decimals, + coordPropsToMeta: _.coordPropsToMeta(coordinateProperties, meta || name, coordPropsToMeta), + pointToAttr, + onPointAdded, + stats, + statsName, + }); + + if (grid) { + this._registerAxisGrid(L.extend({ name }, grid)); + } + + if (this.options[name] !== "summary") { + if (scale) this._registerAxisScale(L.extend({ name, label: unit }, scale)); + if (path) this._registerAreaPath(L.extend({ name }, path)); + } + + if (tooltip || props.tooltips) { + _.each([tooltip, ...(props.tooltips || [])], t => t && this._registerTooltip(L.extend({ name }, t))); + } + + if (summary) { + _.each(summary, (s, k) => summary[k] = L.extend({ unit }, s)); + this._registerSummary(summary); + } + } + }, + + _registerMarker({latlng, sym, content}) { + let { wptIcons } = this.options; + // generate and cache appropriate icon symbol + if (!wptIcons.hasOwnProperty(sym)) { + wptIcons[sym] = L.divIcon(L.extend({}, wptIcons[""].options, { html: '<i class="elevation-waypoint-icon ' + sym + '"></i>' } )); + } + let marker = L.marker(latlng, { icon: wptIcons[sym] }); + if (content) { + marker.bindPopup(content, { className: 'elevation-popup', keepInView: true }).openPopup(); + marker.bindTooltip(content, { className: 'elevation-tooltip', direction: 'auto', sticky: true, opacity: 1 }).openTooltip(); + } + return this._addMarker(marker) + }, + + /** + * Add chart or marker tooltip info + */ + _registerTooltip(props) { + props.chart && this.on("elechart_init", () => this._chart._registerTooltip(L.extend({}, props, { value: props.chart }))); + props.marker && this.on("elechart_marker", () => this._marker._registerTooltip(L.extend({}, props, { value: props.marker }))); + }, + + /** + * Add summary info to diagram + */ + _registerSummary(props) { + this.on('elechart_summary', () => this._summary._registerSummary(props)); + }, + + /* + * Removes the drag rectangle and zoms back to the total extent of the data. + */ + _resetDrag() { + this._chart._resetDrag(); + this._hideMarker(); + }, + + /** + * Resets drag, marker and bounds. + */ + _resetView() { + if (this._map && this._map._isFullscreen) return; + this._resetDrag(); + this._hideMarker(); + if (this.options.autofitBounds) { + this.fitBounds(); + } + }, + + /** + * Hacky way for handling chart resize. Deletes it and redraw chart. + */ + _resizeChart() { + if (this._container && _.style(this._container, "display") != "none") { + let opts = this.options; + let newWidth = opts.detached ? (this.eleDiv || this._container).offsetWidth : _.clamp(opts._maxWidth, [0, this._map.getContainer().clientWidth - 30]); + if (newWidth) { + opts.width = newWidth; + if (this._chart && this._chart._chart) { + this._chart._chart._resize(opts); + this._updateChart(); + } + } + this._updateMapSegments(); + } + }, + + /** + * Collapse or Expand chart control. + */ + _toggle() { + _.hasClass(this._container, "elevation-expanded") ? this._collapse() : this._expand(); + }, + + /** + * Update map center and zoom (followMarker: true) + */ + _setMapView(item) { + if (this._map && this.options.followMarker) { + let zoom = this._map.getZoom(); + let z = this.options.zFollow; + if (typeof z === "number") { + this._map.setView(item.latlng, (zoom < z ? z : zoom), { animate: true, duration: 0.25 }); + } else if (!this._map.getBounds().contains(item.latlng)) { + this._map.setView(item.latlng, zoom, { animate: true, duration: 0.25 }); + } + } + }, + + /** + * Calculates [x, y] domain and then update chart. + */ + _updateChart() { + if (this._chart && this._container) { + this.fire("elechart_axis"); + + this._chart.update({ data: this._data, options: this.options }); + + this._x = this._chart._x; + this._y = this._chart._y; + + this.fire('elechart_updated'); + } + }, + + /* + * Update the position/height indicator marker drawn onto the map + */ + _updateMarker(item) { + if (this._marker) { + this._marker.update({ + map : this._map, + item : item, + yCoordMax : this._yCoordMax || 0, + options : this.options + }); + } + }, + + /** + * Fix marker rotation on rotated maps + */ + _rotateMarker() { + if (this._marker) { + this._marker.update(); + } + }, + + /** + * Highlight track segments on the map. + */ + _updateMapSegments(coords) { + this._markedSegments.setLatLngs(coords || []); + if (coords && this._map && !this._map.hasLayer(this._markedSegments)) { + this._markedSegments.addTo(this._map); + } + }, + + /** + * Update chart summary. + */ + _updateSummary() { + if (this._summary) { + this._summary.reset(); + if (this.options.summary) { + this.fire("elechart_summary"); + this._summary.update(); + } + if (this.options.downloadLink && this._downloadURL) { // TODO: generate dynamically file content instead of using static file urls. + this._summary._container.innerHTML += '<span class="download"><a href="#">' + L._('Download') + '</a></span>' + _.select('.download a', this._summary._container).onclick = (e) => { + e.preventDefault(); + let event = { downloadLink: this.options.downloadLink, confirm: _.saveFile.bind(this, this._downloadURL) }; + if (this.options.downloadLink == 'modal' && typeof CustomEvent === "function") { + document.dispatchEvent(new CustomEvent("eletrack_download", { detail: event })); + } else if (this.options.downloadLink == 'link' || this.options.downloadLink === true) { + event.confirm(); + } + this.fire('eletrack_download', event); + }; + } + } + }, + + + /** + * Calculates chart width. + */ + _width() { + if (this._chart) return 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; + }, + +}); diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/acceleration.js b/assets/static/vendor/leaflet-elevation/src/handlers/acceleration.js new file mode 100644 index 0000000..9b31b5c --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/acceleration.js @@ -0,0 +1,64 @@ +export function Acceleration() {
+
+ const _ = L.Control.Elevation.Utils;
+
+ let opts = this.options;
+ let acceleration = {};
+
+ acceleration.label = opts.accelerationLabel || L._(opts.imperial ? 'ft/s²' : 'm/s²');
+ opts.accelerationFactor = opts.accelerationFactor || 1;
+
+ return {
+ name: 'acceleration',
+ unit: acceleration.label,
+ deltaMax: this.options.accelerationDeltaMax,
+ clampRange: this.options.accelerationRange,
+ decimals: 2,
+ pointToAttr: (_, i) => {
+ let dv = (this._data[i].speed - this._data[i > 0 ? i - 1 : i].speed) * (1000 / opts.timeFactor);
+ let dt = (this._data[i].time - this._data[i > 0 ? i - 1 : i].time) / 1000;
+ return dt > 0 ? Math.abs((dv / dt)) * opts.accelerationFactor : NaN;
+ },
+ stats: { max: _.iMax, min: _.iMin, avg: _.iAvg },
+ scale: {
+ axis : "y",
+ position : "right",
+ scale : { min: 0, max: +1 },
+ tickPadding: 16,
+ labelX : 25,
+ labelY : -8,
+ },
+ path: {
+ label : 'Acceleration',
+ yAttr : 'acceleration',
+ scaleX : 'distance',
+ scaleY : 'acceleration',
+ color : '#050402',
+ strokeColor : '#000',
+ strokeOpacity: "0.5",
+ fillOpacity : "0.25",
+ },
+ tooltip: {
+ chart: (item) => L._("a: ") + item.acceleration + " " + acceleration.label,
+ marker: (item) => Math.round(item.acceleration) + " " + acceleration.label,
+ order: 60,
+ },
+ summary: {
+ "minacceleration" : {
+ label: "Min Acceleration: ",
+ value: (track, unit) => Math.round(track.acceleration_min || 0) + ' ' + unit,
+ order: 60
+ },
+ "maxacceleration" : {
+ label: "Max Acceleration: ",
+ value: (track, unit) => Math.round(track.acceleration_max || 0) + ' ' + unit,
+ order: 61
+ },
+ "avgacceleration": {
+ label: "Avg Acceleration: ",
+ value: (track, unit) => Math.round(track.acceleration_avg || 0) + ' ' + unit,
+ order: 62
+ },
+ }
+ };
+}
diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/altitude.js b/assets/static/vendor/leaflet-elevation/src/handlers/altitude.js new file mode 100644 index 0000000..b7fe6e3 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/altitude.js @@ -0,0 +1,78 @@ +export function Altitude() {
+
+ const _ = L.Control.Elevation.Utils;
+
+ let opts = this.options;
+ let altitude = {};
+
+ let theme = opts.theme.split(' ')[0].replace('-theme', '');
+ let color = _.Colors[theme] || {};
+
+ opts.altitudeFactor = opts.imperial ? this.__footFactor : (opts.altitudeFactor || 1); // 1 m = (1 m)
+ altitude.label = opts.imperial ? "ft" : opts.yLabel;
+
+ return {
+ name: 'altitude',
+ required: this.options.slope,
+ meta: 'z',
+ unit: altitude.label,
+ statsName: 'elevation',
+ deltaMax: this.options.altitudeDeltaMax,
+ clampRange: this.options.altitudeRange,
+ // init: ({point}) => {
+ // // "alt" property is generated inside "leaflet"
+ // if ("alt" in point) point.meta.ele = point.alt;
+ // },
+ pointToAttr: (point, i) => {
+ if ("alt" in point) point.meta.ele = point.alt; // "alt" property is generated inside "leaflet"
+ return this._data[i].z *= opts.altitudeFactor;
+ },
+ stats: { max: _.iMax, min: _.iMin, avg: _.iAvg },
+ grid: {
+ axis : "y",
+ position : "left",
+ scale : "y" // this._chart._y,
+ },
+ scale: {
+ axis : "y",
+ position: "left",
+ scale : "y", // this._chart._y,
+ labelX : -3,
+ labelY : -8,
+ },
+ path: {
+ label : 'Altitude',
+ scaleX : 'distance',
+ scaleY : 'altitude',
+ className : 'area',
+ color : color.area || theme,
+ strokeColor : opts.detached ? color.stroke : '#000',
+ strokeOpacity: "1",
+ fillOpacity : opts.detached ? (color.alpha || '0.8') : 1,
+ preferCanvas : opts.preferCanvas,
+ },
+ tooltip: {
+ name: 'y',
+ chart: (item) => L._("y: ") + _.round(item[opts.yAttr], opts.decimalsY) + " " + altitude.label,
+ marker: (item) => _.round(item[opts.yAttr], opts.decimalsY) + " " + altitude.label,
+ order: 10,
+ },
+ summary: {
+ "minele" : {
+ label: "Min Elevation: ",
+ value: (track, unit) => (track.elevation_min || 0).toFixed(2) + ' ' + unit,
+ order: 30,
+ },
+ "maxele" : {
+ label: "Max Elevation: ",
+ value: (track, unit) => (track.elevation_max || 0).toFixed(2) + ' ' + unit,
+ order: 31,
+ },
+ "avgele" : {
+ label: "Avg Elevation: ",
+ value: (track, unit) => (track.elevation_avg || 0).toFixed(2) + ' ' + unit,
+ order: 32,
+ },
+ }
+ };
+}
diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/cadence.js b/assets/static/vendor/leaflet-elevation/src/handlers/cadence.js new file mode 100644 index 0000000..0ad7704 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/cadence.js @@ -0,0 +1,54 @@ +export function Cadence() {
+
+ const _ = L.Control.Elevation.Utils;
+
+ return {
+ name: 'cadence', // <-- Your custom option name (eg. "cadence: true")
+ unit: 'rpm',
+ meta: 'cad', // <-- point.meta.cad
+ coordinateProperties: ["cads", "cadences", "cad", "cadence"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON"
+ pointToAttr: (point, i) => (point.cad ?? point.meta.cad ?? point.prev('cadence')) || 0,
+ stats: { max: _.iMax, min: _.iMin, avg: _.iAvg },
+ scale: {
+ axis : "y",
+ position : "right",
+ scale : { min: -1, max: +1 },
+ tickPadding: 16,
+ labelX : 25,
+ labelY : -8,
+ },
+ path: {
+ label : 'RPM',
+ yAttr : 'cadence',
+ scaleX : 'distance',
+ scaleY : 'cadence',
+ color : '#FFF',
+ strokeColor : 'blue',
+ strokeOpacity: "0.85",
+ fillOpacity : "0.1",
+ },
+ tooltip: {
+ name: 'cadence',
+ chart: (item) => L._("cad: ") + item.cadence + " " + 'rpm',
+ marker: (item) => Math.round(item.cadence) + " " + 'rpm',
+ order: 1
+ },
+ summary: {
+ "minrpm": {
+ label: "Min RPM: ",
+ value: (track, unit) => Math.round(track.cadence_min || 0) + ' ' + unit,
+ // order: 30
+ },
+ "maxrpm": {
+ label: "Max RPM: ",
+ value: (track, unit) => Math.round(track.cadence_max || 0) + ' ' + unit,
+ // order: 30
+ },
+ "avgrpm": {
+ label: "Avg RPM: ",
+ value: (track, unit) => Math.round(track.cadence_avg || 0) + ' ' + unit,
+ // order: 20
+ },
+ }
+ };
+}
\ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/distance.js b/assets/static/vendor/leaflet-elevation/src/handlers/distance.js new file mode 100644 index 0000000..fa6fa1d --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/distance.js @@ -0,0 +1,46 @@ +export function Distance() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let distance = {}; + + opts.distanceFactor = opts.imperial ? this.__mileFactor : (opts.distanceFactor || 1); // 1 km = (1000 m) + distance.label = opts.imperial ? "mi" : opts.xLabel; + + return { + name: 'distance', + required: true, + attr: 'dist', + unit: distance.label, + decimals: 5, + pointToAttr: (_, i) => (i > 0 ? this._data[i - 1].dist : 0) + (this._data[i].latlng.distanceTo(this._data[i > 0 ? i - 1 : i].latlng) * opts.distanceFactor) / 1000, // convert back km to meters + // stats: { total: _.iSum }, + onPointAdded: (distance, i) => this.track_info.distance = distance, + scale: opts.distance && { + axis : "x", + position: "bottom", + scale : "x", // this._chart._x, + labelY : 25, + labelX : () => this._width() + 6, + ticks : () => _.clamp(this._chart._xTicks() / 2, [4, +Infinity]), + }, + grid: opts.distance && { + axis : "x", + position : "bottom", + scale : "x" // this._chart._x, + }, + tooltip: opts.distance && { + name: 'x', + chart: (item) => L._("x: ") + _.round(item[opts.xAttr], opts.decimalsX) + " " + distance.label, + order: 20 + }, + summary: opts.distance && { + "totlen" : { + label: "Total Length: ", + value: (track) => (track.distance || 0).toFixed(2) + ' ' + distance.label, + order: 10 + } + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/heart.js b/assets/static/vendor/leaflet-elevation/src/handlers/heart.js new file mode 100644 index 0000000..1930f60 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/heart.js @@ -0,0 +1,53 @@ +export function Heart() {
+
+ const _ = L.Control.Elevation.Utils;
+
+ return {
+ name: 'heart', // <-- Your custom option name (eg. "heart: true")
+ unit: 'bpm',
+ meta: 'hr', // <-- point.meta.hr
+ coordinateProperties: ["heart", "heartRates", "heartRate"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON"
+ pointToAttr: (point, i) => (point.hr ?? point.meta.hr ?? point.prev('heart')) || 0,
+ stats: { max: _.iMax, min: _.iMin, avg: _.iAvg },
+ scale: {
+ axis : "y",
+ position : "left",
+ scale : { min: -1, max: +1 },
+ tickPadding: 25,
+ labelX : -30,
+ labelY : -8,
+ },
+ path: {
+ label : 'ECG',
+ yAttr : 'heart',
+ scaleX : 'distance',
+ scaleY : 'heart',
+ color : 'white',
+ strokeColor : 'red',
+ strokeOpacity: "0.85",
+ fillOpacity : "0.1",
+ },
+ tooltip: {
+ chart: (item) => L._("hr: ") + item.heart + " " + 'bpm',
+ marker: (item) => Math.round(item.heart) + " " + 'bpm',
+ order: 1
+ },
+ summary: {
+ "minbpm": {
+ label: "Min BPM: ",
+ value: (track, unit) => Math.round(track.heart_min || 0) + ' ' + unit,
+ // order: 30
+ },
+ "maxbpm": {
+ label: "Max BPM: ",
+ value: (track, unit) => Math.round(track.heart_max || 0) + ' ' + unit,
+ // order: 30
+ },
+ "avgbpm": {
+ label: "Avg BPM: ",
+ value: (track, unit) => Math.round(track.heart_avg || 0) + ' ' + unit,
+ // order: 20
+ },
+ }
+ };
+};
\ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/labels.js b/assets/static/vendor/leaflet-elevation/src/handlers/labels.js new file mode 100644 index 0000000..31b3cd6 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/labels.js @@ -0,0 +1,45 @@ +/** + * @see https://github.com/Raruto/leaflet-elevation/issues/211 + * + * @example + * ```js + * L.control.Elevation({ handlers: [ 'Labels' ], labelsRotation: 25, labelsAlign: 'start' }) + * ``` + */ +export function Labels() { + + this.on('elechart_updated', function(e) { + + const pointG = this._chart._chart.pane('point'); + + const textRotation = this.options.labelsRotation ?? -90; + const textAnchor = this.options.labelsAlign; + + if (90 == Math.abs(textRotation)) { + + pointG.selectAll('text') + .attr('dy', '4px') + .attr("dx", (d, i, el) => Math.sign(textRotation) * (this._height() - d3.select(el[i].parentElement).datum().y - 8) + 'px') + .attr('text-anchor', textRotation > 0 ? 'end' : 'start') + .attr('transform', 'rotate(' + textRotation + ')') + + pointG.selectAll('circle') + .attr('r', '2.5') + .attr('fill', '#fff') + .attr("stroke", '#000') + .attr("stroke-width", '1.1'); + + } else if (!isNaN(textRotation)) { + + pointG.selectAll('text') + .attr("dx", "4px") + .attr("dy", "-9px") + .attr('text-anchor', textAnchor ?? (0 == textRotation ? 'start' : 'middle')) + .attr('transform', 'rotate('+ -textRotation +')') + + } + + }); + + return { }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/lineargradient.js b/assets/static/vendor/leaflet-elevation/src/handlers/lineargradient.js new file mode 100644 index 0000000..90e33ce --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/lineargradient.js @@ -0,0 +1,122 @@ +/** + * @see https://github.com/Raruto/leaflet-elevation/issues/251 + * + * @example + * ```js + * L.control.Elevation({ + * altitude: true, + * distance: true, + * handlers: [ 'Altitude', 'Distance', 'LinearGradient', ], + * linearGradient: { + * attr: 'z', + * path: 'altitude', + * range: { 0.0: '#008800', 0.5: '#ffff00', 1.0: '#ff0000' }, + * min: 'elevation_min', + * max: 'elevation_max', + * }, + * }) + * ``` + */ +export function LinearGradient() { + + if (!this.options.linearGradient) { + return {}; + } + + const _ = L.Control.Elevation.Utils; + + /** + * Initialize gradient color palette. + */ + const get_palette = function ({range, min, max, depth = 256}) { + const canvas = document.createElement('canvas'), + ctx = canvas.getContext('2d'), + gradient = ctx.createLinearGradient(0, 0, 0, depth); + + canvas.width = 1; + canvas.height = depth; + + for (let i in range) { + gradient.addColorStop(i, range[i]); + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 1, depth); + + const { data } = ctx.getImageData(0, 0, 1, depth); + + return { + /** + * Gets the RGB values of a given z value of the current palette. + * + * @param {number} value - Value to get the color for, should be between min and max. + * @returns {string} The RGB values as `rgb(r, g, b)` string + */ + getRGBColor(value) { + const idx = Math.floor(Math.min(Math.max((value - min) / (max - min), 0), 0.999) * depth) * 4; + return 'rgb(' + [data[idx], data[idx + 1], data[idx + 2]].join(',') + ')'; + } + }; + }; + + const { preferCanvas } = this.options; + const { attr, path: path_name, range, min, max } = L.extend({ + attr: 'z', + path: 'altitude', + range: { 0.0: '#008800', 0.5: '#ffff00', 1.0: '#ff0000' }, + min: 'elevation_min', + max: 'elevation_max', + }, (true === this.options.linearGradient) ? {} : this.options.linearGradient); + + const gradient_id = path_name + '-gradient-' + _.randomId(); + const legend_id = 'legend-' + gradient_id; + + // Charte profile gradient + this.on('elechart_axis', () => { + if (!this._data.length) return; + + const chart = this._chart; + const path = chart._paths[path_name]; + const { defs } = chart._chart.utils; + + const palette = get_palette({ + min: isFinite(this.track_info[min]) ? this.track_info[min] : 0, + max: isFinite(this.track_info[max]) ? this.track_info[max] : 1, + range, + }); + + let gradient; + + if (preferCanvas) { + /** ref: `path.__fillStyle` within L.Control.Elevation.Utils::drawCanvas(ctx, path) */ + path.__fillStyle = gradient = chart._context.createLinearGradient(0, 0, chart._width(), 0); + } else { + defs.select('#' + gradient_id).remove(); + gradient = defs.append('svg:linearGradient').attr('id', gradient_id); + gradient.addColorStop = function(offset, color) { gradient.append('svg:stop').attr('offset', offset).attr('stop-color', color) }; + path.attr('fill', 'url(#' + gradient_id + ')').classed('area', false); + } + + // Generate gradient for each segment picking colors from palette + for (let i = 0, data = this._data; i < data.length; i++) { + gradient.addColorStop((i) / data.length, palette.getRGBColor(data[i][attr])); + } + + }); + + // Legend item gradient + this.on('elechart_updated', () => { + const chart = this._chart; + const { defs } = chart._chart.utils; + defs.select('#' + legend_id).remove(); + const legendGradient = defs.append('svg:linearGradient').attr('id', legend_id); + Object.keys(range).sort().forEach(i => legendGradient.append('svg:stop').attr('offset', i).attr('stop-color', range[i])); + + chart._container + .select('.legend-' + path_name + ' > rect') + .attr('fill', 'url(#' + legend_id + ')') + .classed('area', false); + }); + + return { }; +}
\ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/pace.js b/assets/static/vendor/leaflet-elevation/src/handlers/pace.js new file mode 100644 index 0000000..9017f39 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/pace.js @@ -0,0 +1,65 @@ +export function Pace() {
+
+ const _ = L.Control.Elevation.Utils;
+
+ let opts = this.options;
+ let pace = {};
+
+ pace.label = opts.paceLabel || L._(opts.imperial ? 'min/mi' : 'min/km');
+ opts.paceFactor = opts.paceFactor || 60; // 1 min = 60 sec
+
+ return {
+ name: 'pace',
+ unit: pace.label,
+ deltaMax: this.options.paceDeltaMax,
+ clampRange: this.options.paceRange,
+ decimals: 2,
+ pointToAttr: (_, i) => {
+ let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000;
+ let dt = this._data[i].time - this._data[ i > 0 ? i - 1 : i].time;
+ return dx > 0 ? Math.abs((dt / dx) / opts.paceFactor) : NaN;
+ },
+ stats: { max: _.iMax, min: _.iMin, avg: _.iAvg },
+ scale : (this.options.pace && this.options.pace != "summary") && {
+ axis : "y",
+ position : "right",
+ scale : { min : 0, max : +1 },
+ tickPadding: 16,
+ labelX : 25,
+ labelY : -8,
+ },
+ path: (this.options.pace && this.options.pace != "summary") && {
+ // name : 'pace',
+ label : 'Pace',
+ yAttr : "pace",
+ scaleX : 'distance',
+ scaleY : 'pace',
+ color : '#03ffff',
+ strokeColor : '#000',
+ strokeOpacity: "0.5",
+ fillOpacity : "0.25",
+ },
+ tooltip: (this.options.pace) && {
+ chart: (item) => L._('pace: ') + item.pace + " " + pace.label,
+ marker: (item) => Math.round(item.pace) + " " + pace.label,
+ order: 50,
+ },
+ summary: (this.options.pace) && {
+ "minpace" : {
+ label: "Min Pace: ",
+ value: (track, unit) => Math.round(track.pace_min || 0) + ' ' + unit,
+ order: 51
+ },
+ "maxpace" : {
+ label: "Max Pace: ",
+ value: (track, unit) => Math.round(track.pace_max || 0) + ' ' + unit,
+ order: 51
+ },
+ "avgpace": {
+ label: "Avg Pace: ",
+ value: (track, unit) => Math.round(track.pace_avg || 0) + ' ' + unit,
+ order: 52
+ },
+ }
+ };
+}
diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/runner.js b/assets/static/vendor/leaflet-elevation/src/handlers/runner.js new file mode 100644 index 0000000..30dd80a --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/runner.js @@ -0,0 +1,109 @@ +/** + * @see https://github.com/Igor-Vladyka/leaflet.motion + * + * @example + * ```js + * L.control.Elevation({ handlers: [ 'Runner' ], runnerOptions: { polyline: {..}, motion: {..}, marker {..} } }) + * ``` + */ +export async function Runner() { + + await this.import(this.__LMOTION || 'https://unpkg.com/leaflet.motion@0.3.2/dist/leaflet.motion.min.js', typeof L.Motion !== 'object') + + let { runnerOptions } = this.options; + + runnerOptions = L.extend( + { polyline: {}, motion: {}, marker: undefined }, + 'object' === typeof runnerOptions ? runnerOptions : {} + ); + + // Custom tooltips + this._registerTooltip({ + name: 'here', + marker: (item) => L._("You are here: "), + order: 1, + }); + + this._registerTooltip({ + name: 'distance', + marker: (item) => Math.round(item.dist) + " " + this.options.xLabel, + order: 2, + }); + + this.addCheckpoint = function (checkpoint) { + return this._registerCheckPoint({ // <-- NB these are private functions use them at your own risk! + latlng: this._findItemForX(this._x(checkpoint.dist)).latlng, + label: checkpoint.label || '' + }); + } + + this.addRunner = function (runner) { + let x = this._x(runner.dist); + let y = this._y(this._findItemForX(x).z) + let g = d3.select(this._container) + .select('svg > g') + .append("svg:circle") + .attr("class", "runner " + this.options.theme + " height-focus circle-lower") + .attr("r", 6) + .attr("cx", x) + .attr("cy", y); + return g; + } + + this.setPositionFromLatLng = function (latlng) { + this._onMouseMoveLayer({ latlng: latlng }); // Update map and chart "markers" from latlng + }; + + this.tick = function (runner, dist = 0, inc = 0.1) { + dist = (dist <= this.track_info.distance - inc) ? dist + inc : 0; + this.updateRunnerPos(runner, dist); + setTimeout(() => this.tick(runner, dist), 150); + }; + + this.updateRunnerPos = function (runner, pos) { + let curr, x; + + if (pos instanceof L.LatLng) { + curr = this._findItemForLatLng(pos); + x = this._x(curr.dist); + } else { + x = this._x(pos); + curr = this._findItemForX(x); + } + + runner + .attr("cx", x) + .attr("cy", this._y(curr.z)); + this.setPositionFromLatLng(curr.latlng); + }; + + this.animate = function (layer, speed = 1500) { + + if (this._runner) { + this._runner.remove(); + } + + layer.setStyle({ opacity: 0.5 }); + + const geo = L.geoJson(layer.toGeoJSON(), { coordsToLatLng: (coords) => L.latLng(coords[0], coords[1], coords[2] * (this.options.altitudeFactor || 1)) }); + this._runner = L.motion.polyline( + geo.toGeoJSON().features[0].geometry.coordinates, + L.extend({}, { color: 'red', pane: 'elevationPane', attribution: '' }, runnerOptions.polyline), + L.extend({}, { auto: true, speed: speed, }, runnerOptions.motion), + runnerOptions.marker || undefined + ); + + // Override default function behavior: `L.Motion.Polyline::_drawMarker()` + this._runner._drawMarker = new Proxy(this._runner._drawMarker, { + apply: (target, thisArg, argArray) => { + thisArg._runner = thisArg._runner || this.addRunner({ dist: 0 }); + this.updateRunnerPos(thisArg._runner, argArray[0]); + return target.apply(thisArg, argArray); + } + }); + + this._runner.addTo(this._map); + }; + + return {}; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/slope.js b/assets/static/vendor/leaflet-elevation/src/handlers/slope.js new file mode 100644 index 0000000..37291b3 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/slope.js @@ -0,0 +1,79 @@ +export function Slope() {
+
+ const _ = L.Control.Elevation.Utils;
+
+ let opts = this.options;
+ let slope = {};
+
+ slope.label = opts.slopeLabel || '%';
+
+ return {
+ name: 'slope',
+ meta: 'slope',
+ unit: slope.label,
+ deltaMax: this.options.slopedDeltaMax,
+ clampRange: this.options.slopeRange,
+ decimals: 2,
+ pointToAttr: (_, i) => { // slope in % = ( dy / dx ) * 100;
+ let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000;
+ let dy = this._data[i][this.options.yAttr] - this._data[i > 0 ? i - 1 : i][this.options.yAttr];
+ return dx !== 0 ? (dy / dx) * 100 : NaN;
+ },
+ onPointAdded: (_, i) => {
+ let dz = this._data[i][this.options.yAttr] - this._data[i > 0 ? i - 1 : i][this.options.yAttr];
+ if (dz > 0) this.track_info.ascent = (this.track_info.ascent || 0) + dz; // Total Ascent
+ else if (dz < 0) this.track_info.descent = (this.track_info.descent || 0) - dz; // Total Descent
+ },
+ stats: { max: _.iMax, min: _.iMin, avg: _.iAvg, },
+ scale: {
+ axis : "y",
+ position : "right",
+ scale : { min: -1, max: +1 },
+ tickPadding: 16,
+ labelX : 25,
+ labelY : -8,
+ },
+ path: {
+ label : 'Slope',
+ yAttr : 'slope',
+ scaleX : 'distance',
+ scaleY : 'slope',
+ color : '#F00',
+ strokeColor : '#000',
+ strokeOpacity: "0.5",
+ fillOpacity : "0.25",
+ },
+ tooltip: {
+ chart: (item) => L._("m: ") + item.slope + slope.label,
+ marker: (item) => Math.round(item.slope) + slope.label,
+ order: 40,
+ },
+ summary: {
+ "minslope": {
+ label: "Min Slope: ",
+ value: (track, unit) => Math.round(track.slope_min || 0) + ' ' + unit,
+ order: 40
+ },
+ "maxslope": {
+ label: "Max Slope: ",
+ value: (track, unit) => Math.round(track.slope_max || 0) + ' ' + unit,
+ order: 41
+ },
+ "avgslope": {
+ label: "Avg Slope: ",
+ value: (track, unit) => Math.round(track.slope_avg || 0) + ' ' + unit,
+ order: 42
+ },
+ "ascent" : {
+ label: "Total Ascent: ",
+ value: (track, unit) => Math.round(track.ascent || 0) + ' ' + (this.options.imperial ? 'ft' : 'm'),
+ order: 43
+ },
+ "descent" : {
+ label: "Total Descent: ",
+ value: (track, unit) => Math.round(track.descent || 0) + ' ' + (this.options.imperial ? 'ft' : 'm'),
+ order: 45
+ },
+ }
+ };
+}
diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/speed.js b/assets/static/vendor/leaflet-elevation/src/handlers/speed.js new file mode 100644 index 0000000..534b340 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/speed.js @@ -0,0 +1,66 @@ +export function Speed() {
+
+ const _ = L.Control.Elevation.Utils;
+
+ let opts = this.options;
+ let speed = {};
+
+ speed.label = opts.speedLabel || L._(opts.imperial ? 'mph' : 'km/h');
+ opts.speedFactor = opts.speedFactor || 1;
+
+ return {
+ name: 'speed',
+ required: (this.options.acceleration),
+ unit: speed.label,
+ deltaMax: this.options.speedDeltaMax,
+ clampRange: this.options.speedRange,
+ decimals: 2,
+ pointToAttr: (_, i) => {
+ let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000;
+ let dt = this._data[i].time - this._data[ i > 0 ? i - 1 : i].time;
+ return dt > 0 ? Math.abs((dx / dt) * opts.timeFactor) * opts.speedFactor : NaN;
+ },
+ stats: { max: _.iMax, min: _.iMin, avg: _.iAvg },
+ scale : (this.options.speed && this.options.speed != "summary") && {
+ axis : "y",
+ position : "right",
+ scale : { min : 0, max : +1 },
+ tickPadding: 16,
+ labelX : 25,
+ labelY : -8,
+ },
+ path: (this.options.speed && this.options.speed != "summary") && {
+ // name : 'speed',
+ label : 'Speed',
+ yAttr : "speed",
+ scaleX : 'distance',
+ scaleY : 'speed',
+ color : '#03ffff',
+ strokeColor : '#000',
+ strokeOpacity: "0.5",
+ fillOpacity : "0.25",
+ },
+ tooltip: (this.options.speed) && {
+ chart: (item) => L._('v: ') + item.speed + " " + speed.label,
+ marker: (item) => Math.round(item.speed) + " " + speed.label,
+ order: 50,
+ },
+ summary: (this.options.speed) && {
+ "minspeed" : {
+ label: "Min Speed: ",
+ value: (track, unit) => Math.round(track.speed_min || 0) + ' ' + unit,
+ order: 51
+ },
+ "maxspeed" : {
+ label: "Max Speed: ",
+ value: (track, unit) => Math.round(track.speed_max || 0) + ' ' + unit,
+ order: 51
+ },
+ "avgspeed": {
+ label: "Avg Speed: ",
+ value: (track, unit) => Math.round(track.speed_avg || 0) + ' ' + unit,
+ order: 52
+ },
+ }
+ };
+}
diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/temperature.js b/assets/static/vendor/leaflet-elevation/src/handlers/temperature.js new file mode 100644 index 0000000..cf05308 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/temperature.js @@ -0,0 +1,62 @@ +export function Temperature() { + + const _ = L.Control.Elevation.Utils; + + let temperature = {}; + let opts = this.options; + temperature.label = opts.label || L._(opts.imperial ? '°F' : '°C'); + + // Fahrenheit = (Celsius * 9/5) + 32 + opts.temperatureFactor1 = opts.temperatureFactor1 ?? (opts.imperial ? 1.8 : 1); + opts.temperatureFactor2 = opts.temperatureFactor2 ?? (opts.imperial ? 32 : 0); + + return { + name: 'temperature', // <-- Your custom option name (eg. "temperature: true") + unit: temperature.label, + meta: 'atemps', // <-- point.meta.atemps + coordinateProperties: ["atemps"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON" + deltaMax: this.options.temperatureDeltaMax, + clampRange: this.options.temperatureRange, + decimals: 2, + pointToAttr: (point, i) => (point.meta.atemps ?? point.meta.atemps ?? point.prev('temperature')) * opts.temperatureFactor1 + opts.temperatureFactor2, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + scale: { + axis : "y", + position : "right", + scale : { min: -1, max: +1 }, + tickPadding: 16, + labelX : +18, + labelY : -8, + }, + path: { + label : temperature.label, + yAttr : 'temperature', + scaleX : 'distance', + scaleY : 'temperature', + color : 'transparent', + strokeColor : '#000', + strokeOpacity: "0.85", + // fillOpacity : "0.1", + }, + tooltip: { + name: 'temperature', + chart: (item) => L._("Temp: ") + Math.round(item.temperature).toLocaleString() + " " + temperature.label, + marker: (item) => Math.round(item.temperature).toLocaleString() + " " + temperature.label, + order: 1 + }, + summary: { + "mintemp": { + label: "Min Temp: ", + value: (track, unit) => Math.round(track.temperature_min || 0) + ' ' + unit, + }, + "maxtemp": { + label: "Max Temp: ", + value: (track, unit) => Math.round(track.temperature_max || 0) + ' ' + unit, + }, + "avgtemp": { + label: "Avg Temp: ", + value: (track, unit) => Math.round(track.temperature_avg || 0) + ' ' + unit, + }, + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/time.js b/assets/static/vendor/leaflet-elevation/src/handlers/time.js new file mode 100644 index 0000000..d94b136 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/time.js @@ -0,0 +1,103 @@ +export function Time() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let time = {}; + + time.label = opts.timeLabel || 't'; + opts.timeFactor = opts.timeFactor || 3600; + + /** + * Common AVG speeds: + * ---------------------- + * slow walk = 1.8 km/h + * walking = 3.6 km/h <-- default: 3.6 + * running = 10.8 km/h + * cycling = 18 km/h + * driving = 72 km/h + * ---------------------- + */ + this._timeAVGSpeed = (opts.timeAVGSpeed || 3.6) * (opts.speedFactor || 1); + + if (!opts.timeFormat) { + opts.timeFormat = (time) => (new Date(time)).toLocaleString().replaceAll('/', '-').replaceAll(',', ' '); + } else if (opts.timeFormat == 'time') { + opts.timeFormat = (time) => (new Date(time)).toLocaleTimeString(); + } else if (opts.timeFormat == 'date') { + opts.timeFormat = (time) => (new Date(time)).toLocaleDateString(); + } + + opts.xTimeFormat = opts.xTimeFormat || ((t) => _.formatTime(t).split("'")[0]); + + return { + name: 'time', + required: (this.options.speed || this.options.acceleration || this.options.timestamps), + coordinateProperties: ["coordTimes", "times", "time"], + coordPropsToMeta: _.parseDate, + pointToAttr: function(point, i) { + // Add missing timestamps (see: options.timeAVGSpeed) + if (!point.meta || !point.meta.time) { + point.meta = point.meta || {}; + if (i > 0) { + let dx = (this._data[i].dist - this._data[i - 1].dist); + let t0 = this._data[i - 1].time.getTime(); + point.meta.time = new Date(t0 + ( dx / this._timeAVGSpeed) * this.options.timeFactor * 1000); + } else { + point.meta.time = new Date(Date.now()) + } + } + // Handle timezone offset + let time = (point.meta.time.getTime() - point.meta.time.getTimezoneOffset() * 60 * 1000 !== 0) ? point.meta.time : 0; + // Update duration + this._data[i].duration = i > 0 ? (this._data[i - 1].duration || 0) + Math.abs(time - this._data[i - 1].time) : 0; + return time; + }, + onPointAdded: (_, i) => this.track_info.time = this._data[i].duration, + scale: (opts.time && opts.time != "summary" && !L.Browser.mobile) && { + axis : "x", + position : "top", + scale : { + attr : "duration", + min : 0, + }, + label : time.label, + labelY : -10, + labelX : () => this._width(), + name : "time", + ticks : () => _.clamp(this._chart._xTicks() / 2, [4, +Infinity]), + tickFormat : (d) => (d == 0 ? '' : opts.xTimeFormat(d)), + onAxisMount: axis => { + axis.select(".domain") + .remove(); + axis.selectAll("text") + .attr('opacity', 0.65) + .style('font-family', 'Monospace') + .style('font-size', '110%'); + axis.selectAll(".tick line") + .attr('y2', this._height()) + .attr('stroke-dasharray', 2) + .attr('opacity', 0.75); + } + }, + tooltips: [ + (this.options.time) && { + name: 'time', + chart: (item) => L._("T: ") + _.formatTime(item.duration || 0), + order: 20 + }, + (this.options.timestamps) && { + name: 'date', + chart: (item) => L._("t: ") + this.options.timeFormat(item.time), + order: 21, + } + ], + summary: (this.options.time) && { + "tottime" : { + label: "Total Time: ", + value: (track) => _.formatTime(track.time || 0), + order: 20 + } + } + }; +}
\ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/index.css b/assets/static/vendor/leaflet-elevation/src/index.css new file mode 100644 index 0000000..9b29174 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/index.css @@ -0,0 +1,329 @@ +.leaflet-hidden {
+ visibility: hidden;
+}
+
+.legend {
+ cursor: pointer;
+}
+
+.leaflet-container {
+ z-index: 0;
+ /* prevent overlapping the .elevation-detached chart */
+}
+
+.elevation-control .background {
+ background-color: var(--ele-bg, rgba(70, 130, 180, 0.2));
+ border-radius: 5px;
+ overflow: visible;
+ display: block;
+ touch-action: none;
+ user-select: none;
+ max-width: 100%;
+}
+
+.elevation-control .grid,
+.elevation-control .area > foreignObject,
+.elevation-control .axis,
+.elevation-control .tooltip,
+.height-focus.line {
+ pointer-events: none;
+}
+
+.elevation-control .axis line,
+.elevation-control .axis path {
+ stroke: var(--ele-axis, #2D1130);
+ stroke-width: 1;
+ fill: none;
+}
+
+.elevation-control .grid .tick line {
+ stroke: var(--ele-grid, #EEE);
+ stroke-width: 1px;
+ shape-rendering: crispEdges;
+}
+
+.elevation-control .grid path {
+ stroke-width: 0;
+}
+
+.elevation-control .axis text,
+.elevation-control .legend text,
+.elevation-control .point text {
+ fill: #000;
+ font-weight: 700;
+ paint-order: stroke fill;
+ stroke: #fff;
+ stroke-width: 2px
+}
+
+.elevation-control .y.axis text {
+ text-anchor: end;
+}
+
+.elevation-control .area {
+ fill: var(--ele-area, #4682B4);
+ stroke: var(--ele-stroke, #000);
+ stroke-width: 1.2;
+ paint-order: stroke fill;
+}
+
+.elevation-control .horizontal-drag-line {
+ cursor: row-resize;
+ stroke: transparent;
+ stroke-dasharray: 5;
+ stroke-width: 1.1;
+}
+
+.elevation-control .active .horizontal-drag-line {
+ stroke: #000;
+}
+
+.elevation-control .horizontal-drag-label {
+ fill: #000;
+ font-weight: 700;
+ paint-order: stroke;
+ stroke: #FFF;
+ stroke-width: 2px;
+}
+
+.elevation-control .ruler {
+ color: #000;
+ cursor: row-resize;
+}
+
+.elevation-control .mouse-focus-line {
+ stroke: #000;
+ stroke-width: 1;
+}
+
+.elevation-control .mouse-focus-label-rect {
+ fill: #000;
+ fill-opacity: 0.75;
+ stroke-width: 1;
+ stroke: #444;
+}
+
+.elevation-control .mouse-focus-label-text {
+ fill: #FFF;
+ font-size: 10px;
+}
+
+.elevation-control .brush .overlay {
+ cursor: unset;
+}
+
+.elevation-control .brush .selection {
+ fill: var(--ele-brush, rgba(23, 74, 117, 0.4));
+ stroke: none;
+ fill-opacity: unset;
+}
+
+.elevation-summary {
+ font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif;
+ font-size: 12px;
+ margin: var(--ele-sum-margin, 0 auto);
+ text-shadow: var(--ele-sum-shadow, 1px 0 0 #FFF, -1px 0 0 #FFF, 0 1px 0 #FFF, 0 -1px 0 #FFF, 1px 1px #FFF, -1px -1px 0 #FFF, 1px -1px 0 #FFF, -1px 1px 0 #FFF);
+}
+
+.elevation-summary>span:not(:last-child):after {
+ content: var(--ele-sum-sep, '');
+}
+
+.multiline-summary>span {
+ display: block;
+}
+
+.multiline-summary .download {
+ float: right;
+ margin-top: -3em;
+ margin-right: 2em;
+ font-weight: bold;
+ font-size: 1.2em;
+}
+
+.elevation-summary .summaryvalue {
+ font-weight: bold;
+}
+
+.elevation-toggle-icon {
+ background-color: #fff;
+ right: 5px;
+ top: 5px;
+ height: var(--ele-toggle-size, 36px);
+ width: var(--ele-toggle-size, 36px);
+ cursor: pointer;
+ box-shadow: 0 1px 7px rgba(0, 0, 0, 0.4);
+ border-radius: 5px;
+ display: inline-block;
+ position: var(--ele-toggle-pos, relative);
+}
+
+.elevation-toggle-icon:before {
+ content: '\2716';
+ display: var(--ele-close-btn, none);
+ color: #000;
+ width: 100%;
+ line-height: 20px;
+ text-align: center;
+ font-weight: bold;
+ font-size: 15px;
+}
+
+.leaflet-elevation-pane .height-focus,
+.leaflet-overlay-pane .height-focus {
+ stroke: #000;
+ fill: var(--ele-circle, var(--ele-area, #FFF));
+}
+
+.leaflet-elevation-pane .height-focus.line,
+.leaflet-overlay-pane .height-focus.line {
+ stroke-width: 2;
+}
+
+.leaflet-elevation-pane .height-focus-label,
+.leaflet-overlay-pane .height-focus-label {
+ font-size: 12px;
+ font-weight: 600;
+ fill: #000;
+ paint-order: stroke;
+ stroke: #FFF;
+ stroke-width: 2px;
+}
+
+.elevation-waypoint-icon:before,
+.elevation-position-icon:before {
+ content: "";
+ width: 100%;
+ height: 100%;
+ display: inline-block;
+ background: var(--ele-marker) no-repeat center center / contain;
+}
+
+.elevation-polyline {
+ stroke: var(--ele-poly, var(--ele-area, #000));
+ filter: drop-shadow(1px 1px 0 #FFF) drop-shadow(-1px -1px 0 #FFF) drop-shadow(1px -1px 0 #FFF) drop-shadow(-1px 1px 0 #FFF);
+}
+
+/* CHART STATES /////////////////////////////////////////////////// */
+
+.elevation-detached {
+ font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
+ height: auto;
+ width: 100%;
+ position: relative;
+ z-index: 0;
+}
+
+.elevation-detached .area {
+ fill-opacity: var(--ele-alpha, 0.8);
+}
+
+.elevation-detached.elevation-collapsed .elevation-summary {
+ display: block;
+}
+
+.elevation-detached.elevation-collapsed .elevation-toggle-icon {
+ top: 5px;
+ right: 9px;
+ bottom: 5px;
+ margin: auto;
+}
+
+.elevation-control.elevation-collapsed > * {
+ display: none;
+}
+
+.elevation-control.elevation-collapsed > .elevation-toggle-icon {
+ display: inline-block;
+}
+
+.elevation-detached {
+ --ele-sum-margin: 12px 35px;
+ --ele-sum-shadow: none;
+ --ele-toggle-pos: absolute;
+}
+
+.elevation-expanded {
+ --ele-close-btn: inline-block;
+ --ele-toggle-bg: none;
+ --ele-toggle-pos: absolute;
+ --ele-toggle-size: 20px;
+}
+
+.inline-summary {
+ --ele-sum-sep: "\0020\2014\0020";
+}
+
+.elevation-waypoint-icon {
+ --ele-marker: url(../images/elevation-pushpin.svg);
+}
+
+.elevation-position-icon {
+ --ele-marker: url(../images/elevation-position.svg);
+}
+
+/* LIME THEME ///////////////////////////////////////////////////// */
+.lime-theme {
+ --ele-bg: rgba(156, 194, 34, 0.2);
+ --ele-axis: #566B13;
+ --ele-area: #9CC222;
+ --ele-grid: #CCC;
+ --ele-brush: rgba(99, 126, 11, 0.4);
+ --ele-poly: #566B13;
+ --ele-line: #70ab00;
+}
+
+/* STEELBLUE THEME //////////////////////////////////////////////// */
+.steelblue-theme {
+ --ele-axis: #0D1821;
+ --ele-area: #4682B4;
+ --ele-brush: rgba(23, 74, 117, 0.4);
+ --ele-line: #174A75;
+}
+
+/* PURPLE THEME /////////////////////////////////////////////////// */
+.purple-theme {
+ --ele-bg: rgba(115, 44, 123, 0.2);
+ --ele-area: #732C7B;
+ --ele-brush: rgba(74, 14, 80, 0.4);
+ --ele-line: #732c7b;
+}
+
+/* YELLOW THEME /////////////////////////////////////////////////// */
+.yellow-theme {
+ --ele-area: #FF0;
+}
+
+/* RED THEME ////////////////////////////////////////////////////// */
+.red-theme {
+ --ele-area: #F00;
+}
+
+/* MAGENTA THEME ////////////////////////////////////////////////// */
+.magenta-theme {
+ --ele-bg: rgba(255, 255, 255, 0.47);
+ --ele-area: #FF005E;
+}
+
+/* LIGHTBLUE THEME //////////////////////////////////////////////// */
+.lightblue-theme {
+ --ele-area: #3366CC;
+ --ele-alpha: 0.45;
+ --ele-stroke: #4682B4;
+ --ele-circle: #fff;
+ --ele-line: #000;
+}
+
+.elevation-detached.lightblue-theme .area {
+ stroke: #3366CC;
+}
+
+/* leaflet-distance-markers */
+.dist-marker {
+ font-size: 0.5rem;
+ border: 1px solid #777;
+ border-radius: 10px;
+ text-align: center;
+ color: #000;
+ background: #fff;
+}
diff --git a/assets/static/vendor/leaflet-elevation/src/index.js b/assets/static/vendor/leaflet-elevation/src/index.js new file mode 100644 index 0000000..4e094ff --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/index.js @@ -0,0 +1,42 @@ +/*
+ * Copyright (c) 2019, 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) 2013-2016, MIT License, Felix “MrMufflon” Bache
+ *
+ * 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.
+ */
+
+import * as _ from './utils';
+import { Elevation } from './control';
+
+Elevation.Utils = _;
+
+L.control.elevation = (options) => new Elevation(options);
diff --git a/assets/static/vendor/leaflet-elevation/src/options.js b/assets/static/vendor/leaflet-elevation/src/options.js new file mode 100644 index 0000000..5923163 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/options.js @@ -0,0 +1,88 @@ +import * as _ from './utils'; + +export var Options = { + autofitBounds: true, + autohide: false, + autohideMarker: true, + almostover: true, + altitude: true, + closeBtn: true, + collapsed: false, + detached: true, + distance: true, + distanceMarkers: { lazy: true, distance: true, direction: true }, + dragging: !L.Browser.mobile, + downloadLink: 'link', + elevationDiv: "#elevation-div", + edgeScale: { bar: true, icon: false, coords: false }, + followMarker: true, + imperial: false, + legend: true, + handlers: ["Distance", "Time", "Altitude", "Slope", "Speed", "Acceleration"], + hotline: 'elevation', + marker: 'elevation-line', + markerIcon: L.divIcon({ + className: 'elevation-position-marker', + html: '<i class="elevation-position-icon"></i>', + iconSize: [32, 32], + iconAnchor: [16, 16], + }), + position: "topright", + polyline: { + className: 'elevation-polyline', + color: '#000', + opacity: 0.75, + weight: 5, + lineCap: 'round' + }, + polylineSegments: { + className: 'elevation-polyline-segments', + color: '#F00', + interactive: false, + }, + preferCanvas: false, + reverseCoords: false, + ruler: true, + theme: "lightblue-theme", + summary: 'inline', + slope: false, + speed: false, + time: true, + timeFactor: 3600, + timestamps: false, + trkStart: { className: 'start-marker', radius: 6, weight: 2, color: '#fff', fillColor: '#00d800', fillOpacity: 1, interactive: false }, + trkEnd: { className: 'end-marker', radius: 6, weight: 2, color: '#fff', fillColor: '#ff0606', fillOpacity: 1, interactive: false }, + waypoints: true, + wptIcons: { + '': L.divIcon({ + className: 'elevation-waypoint-marker', + html: '<i class="elevation-waypoint-icon default"></i>', + iconSize: [30, 30], + iconAnchor: [8, 30], + }), + }, + wptLabels: true, + xAttr: "dist", + xLabel: "km", + yAttr: "z", + yLabel: "m", + zFollow: false, + zooming: !L.Browser.Mobile, + + // Quite uncommon and undocumented options + margins: { top: 30, right: 30, bottom: 30, left: 40 }, + height: (screen.height * 0.3) || 200, + width: (screen.width * 0.6) || 600, + xTicks: undefined, + yTicks: undefined, + + decimalsX: 2, + decimalsY: 0, + forceAxisBounds: false, + interpolation: "curveLinear", + yAxisMax: undefined, + yAxisMin: undefined, + + // Prevent CORS issues for relative locations (dynamic import) + srcFolder: ((document.currentScript && document.currentScript.src) || (import.meta && import.meta.url)).split("/").slice(0,-1).join("/") + '/', +}; diff --git a/assets/static/vendor/leaflet-elevation/src/utils.js b/assets/static/vendor/leaflet-elevation/src/utils.js new file mode 100644 index 0000000..90035f7 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/utils.js @@ -0,0 +1,203 @@ +/** + * TODO: exget computed styles of theese values from actual "CSS vars" + **/ +export const Colors = { + 'lightblue': { area: '#3366CC', alpha: 0.45, stroke: '#3366CC' }, + 'magenta' : { area: '#FF005E' }, + 'yellow' : { area: '#FF0' }, + 'purple' : { area: '#732C7B' }, + 'steelblue': { area: '#4682B4' }, + 'red' : { area: '#F00' }, + 'lime' : { area: '#9CC222', line: '#566B13' } +}; + +const SEC = 1000; +const MIN = SEC * 60; +const HOUR = MIN * 60; +const DAY = HOUR * 24; + +export function resolveURL(src, baseUrl) { + return (new URL(src, (src.startsWith('../') || src.startsWith('./')) ? baseUrl : undefined)).toString() +}; + +/** + * Convert a time (millis) to a human readable duration string (%Dd %H:%M'%S") + */ +export function formatTime(t) { + let d = Math.floor(t / DAY); + let h = Math.floor( (t - d * DAY) / HOUR); + let m = Math.floor( (t - d * DAY - h * HOUR) / MIN); + let s = Math.round( (t - d * DAY - h * HOUR - m * MIN) / SEC); + if ( s === 60 ) { m++; s = 0; } + if ( m === 60 ) { h++; m = 0; } + if ( h === 24 ) { d++; h = 0; } + return (d ? d + "d " : '') + h.toString().padStart(2, 0) + ':' + m.toString().padStart(2, 0) + "'" + s.toString().padStart(2, 0) + '"'; +} + +/** + * Convert a time (millis) to human readable date string (dd-mm-yyyy hh:mm:ss) + */ + export function formatDate(format) { + if (!format) { + return (time) => (new Date(time)).toLocaleString().replaceAll('/', '-').replaceAll(',', ' '); + } else if (format == 'time') { + return (time) => (new Date(time)).toLocaleTimeString(); + } else if (format == 'date') { + return (time) => (new Date(time)).toLocaleDateString(); + } + return (time) => format(time); +} + +/** + * Generate download data event. + */ + export function saveFile(dataURI, fileName) { + let a = create('a', '', { href: dataURI, target: '_new', download: fileName || "", style: "display:none;" }); + let b = document.body; + b.appendChild(a); + a.click(); + b.removeChild(a); +} + + +/** + * Convert SVG Path into Path2D and then update canvas + */ + export function drawCanvas(ctx, path) { + path.classed('canvas-path', true); + + ctx.beginPath(); + ctx.moveTo(0, 0); + let p = new Path2D(path.attr('d')); + + ctx.strokeStyle = path.__strokeStyle || path.attr('stroke'); + ctx.fillStyle = path.__fillStyle || path.attr('fill'); + ctx.lineWidth = 1.25; + ctx.globalCompositeOperation = 'source-over'; + + // stroke opacity + ctx.globalAlpha = path.attr('stroke-opacity') || 0.3; + ctx.stroke(p); + + // fill opacity + ctx.globalAlpha = path.attr('fill-opacity') || 0.45; + ctx.fill(p); + + ctx.globalAlpha = 1; + + ctx.closePath(); +} + +/** + * Loop and extract GPX Extensions handled by "@tmcw/toGeoJSON" (eg. "coordinateProperties" > "times") + */ +export function coordPropsToMeta(coordProps, name, parser) { + return coordProps && (({props, point, id, isMulti }) => { + if (props) { + for (const key of coordProps) { + if (key in props) { + point.meta[name] = (parser || parseNumeric).call(this, (isMulti ? props[key][isMulti] : props[key]), id); + break; + } + } + } + }); +} + +/** + * Extract numeric property (id) from GeoJSON object + */ +export const parseNumeric = (property, id) => parseInt((typeof property === 'object' ? property[id] : property)); + +/** + * Extract datetime property (id) from GeoJSON object + */ +export const parseDate = (property, id) => new Date(Date.parse((typeof property === 'object' ? property[id] : property))); + +/** + * A little bit shorter than L.DomUtil + */ +export const addClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.addClass(n, s)); +export const removeClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.removeClass(n, s)); +export const toggleClass = (n, str, cond) => (cond ? addClass : removeClass)(n, str); +export const replaceClass = (n, rem, add) => (rem && removeClass(n, rem)) || (add && addClass(n, add)); +export const style = (n, k, v) => (typeof v === "undefined" && L.DomUtil.getStyle(n, k)) || n.style.setProperty(k, v); +export const toggleStyle = (n, k, v, cond) => style(n, k, cond ? v : ''); +export const setAttributes = (n, attrs) => { for (let k in attrs) { n.setAttribute(k, attrs[k]); } }; +export const toggleEvent = (el, e, fn, cond) => el[cond ? 'on' : 'off'](e, fn); +export const create = (tag, str, attrs, n) => { let elem = L.DomUtil.create(tag, str || ""); if (attrs) setAttributes(elem, attrs); if (n) append(n, elem); return elem; }; +export const append = (n, c) => n.appendChild(c); +export const insert = (n, c, pos) => n.insertAdjacentElement(pos, c); +export const select = (str, n) => (n || document).querySelector(str); +export const each = (obj, fn) => { for (let i in obj) fn(obj[i], i); }; +export const randomId = () => Math.random().toString(36).substr(2, 9); + +/** + * TODO: use generators instead? (ie. "yield") + */ +export const iMax = (iVal, max = -Infinity) => (iVal > max ? iVal : max); +export const iMin = (iVal, min = +Infinity) => (iVal < min ? iVal : min); +export const iAvg = (iVal, avg = 0, idx = 1) => (iVal + avg * (idx - 1)) / idx; +export const iSum = (iVal, sum = 0) => iVal + sum; + +/** + * Alias for some leaflet core functions + */ +export const { on, off } = L.DomEvent; +export const { throttle, wrapNum } = L.Util; +export const { hasClass } = L.DomUtil; + +/** + * Limit floating point precision + */ +export const round = L.Util.formatNum; + +/** + * Limit a number between min / max values + */ +export const clamp = (val, range) => range ? (val < range[0] ? range[0] : val > range[1] ? range[1] : val) : val; + +/** + * Limit a delta difference between two values + */ +export const wrapDelta = (curr, prev, deltaMax) => Math.abs(curr - prev) > deltaMax ? prev + deltaMax * Math.sign(curr - prev) : curr; + +/** + * A deep copy implementation that takes care of correct prototype chain and cycles, references + * + * @see https://web.dev/structured-clone/#features-and-limitations + */ +export function cloneDeep(o, skipProps = [], cache = []) { + switch(!o || typeof o) { + case 'object': + const hit = cache.filter(c => o === c.original)[0]; + if (hit) return hit.copy; // handle circular structures + const copy = Array.isArray(o) ? [] : Object.create(Object.getPrototypeOf(o)); + cache.push({ original: o, copy }); + Object + .getOwnPropertyNames(o) + .forEach(function (prop) { + const propdesc = Object.getOwnPropertyDescriptor(o, prop); + Object.defineProperty( + copy, + prop, + propdesc.get || propdesc.set + ? propdesc // just copy accessor properties + : { // deep copy data properties + writable: propdesc.writable, + configurable: propdesc.configurable, + enumerable: propdesc.enumerable, + value: skipProps.includes(prop) ? propdesc.value : cloneDeep(propdesc.value, skipProps, cache), + } + ); + }); + return copy; + case 'function': + case 'symbol': + console.warn('cloneDeep: ' + typeof o + 's not fully supported:', o); + case true: + // null, undefined or falsy primitive + default: + return o; + } +}
\ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/utils.spec.js b/assets/static/vendor/leaflet-elevation/src/utils.spec.js new file mode 100644 index 0000000..7371a96 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/utils.spec.js @@ -0,0 +1,50 @@ +/**
+ * src/utils.js
+ */
+
+import { suite } from 'uvu';
+import * as assert from 'uvu/assert';
+import '../test/setup/jsdom.js'
+import { iAvg, iMin, iMax, iSum } from "../src/utils.js";
+
+const toFixed = (n) => +n.toFixed(2);
+
+const test = suite('src/utils.js');
+
+test('iAvg()', () => {
+ let avg;
+ avg = iAvg(100, undefined, 1); assert.is(toFixed(avg), 100); // average for [100] is 100
+ avg = iAvg(100, avg, 2); assert.is(toFixed(avg), 100); // average for [100, 100] is 100
+ avg = iAvg(200, avg, 3); assert.is(toFixed(avg), 133.33); // average for [100, 100, 200] is 133.33
+ avg = iAvg(200, avg, 4); assert.is(toFixed(avg), 150); // average for [100, 100, 200, 200] is 150
+ avg = iAvg(NaN, avg, 5); assert.ok(isNaN(avg)); // average for [100, 100, 200, 200, NaN] is NaN
+});
+
+test('iMin()', () => {
+ let min;
+ min = iMin(100, undefined); assert.is(toFixed(min), 100); // min for [100] is 100
+ min = iMin(NaN, min); assert.is(toFixed(min), 100); // min for [100, NaN] is 100
+ min = iMin(0, min); assert.is(toFixed(min), 0); // min for [100, NaN, 0] is 100
+ min = iMin(-200, min); assert.is(toFixed(min), -200); // min for [100, NaN, 0, -200] is -200
+ min = iMin(200, min); assert.is(toFixed(min), -200); // min for [100, NaN, -100, -200, 200] is -200
+});
+
+test('iMax()', () => {
+ let max;
+ max = iMax(100, undefined); assert.is(toFixed(max), 100); // max for [100] is 100
+ max = iMax(NaN, max); assert.is(toFixed(max), 100); // max for [100, NaN] is 100
+ max = iMax(0, max); assert.is(toFixed(max), 100); // max for [100, NaN, 0] is 100
+ max = iMax(-200, max); assert.is(toFixed(max), 100); // max for [100, NaN, 0, -200] is 100
+ max = iMax(200, max); assert.is(toFixed(max), 200); // max for [100, NaN, -100, -200, 200] is 200
+});
+
+test('iSum()', () => {
+ let sum;
+ sum = iSum(10.25, undefined); assert.is(toFixed(sum), 10.25); // sum for [10.25] is 10.25
+ sum = iSum(0, sum); assert.is(toFixed(sum), 10.25); // sum for [10.25, 0] is 10.25
+ sum = iSum(-0.25, sum); assert.is(toFixed(sum), 10); // sum for [10.25, 0, -0.25] is 10
+ sum = iSum(-10, sum); assert.is(toFixed(sum), 0); // sum for [10.25, 0, -0.25, -10] is 0
+ sum = iSum(NaN, sum); assert.ok(isNaN(sum)); // sum for [10.25, 0, -0.25, -10, NaN] is NaN
+});
+
+test.run();
\ No newline at end of file |
