@goshawk22/leaflet-elevation
Version:
A Leaflet plugin that allows to add elevation profiles using d3js
1,633 lines (1,418 loc) • 52.8 kB
JavaScript
(function (factory) {
typeof define === 'function' && define.amd ? define(factory) :
factory();
})((function () { 'use strict';
/**
* TODO: exget computed styles of theese values from actual "CSS vars"
**/
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;
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")
*/
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)
*/
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.
*/
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
*/
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")
*/
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
*/
const parseNumeric = (property, id) => parseInt((typeof property === 'object' ? property[id] : property));
/**
* Extract datetime property (id) from GeoJSON object
*/
const parseDate = (property, id) => new Date(Date.parse((typeof property === 'object' ? property[id] : property)));
/**
* A little bit shorter than L.DomUtil
*/
const addClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.addClass(n, s));
const removeClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.removeClass(n, s));
const toggleClass = (n, str, cond) => (cond ? addClass : removeClass)(n, str);
const replaceClass = (n, rem, add) => (rem && removeClass(n, rem)) || (add && addClass(n, add));
const style = (n, k, v) => (typeof v === "undefined" && L.DomUtil.getStyle(n, k)) || n.style.setProperty(k, v);
const toggleStyle = (n, k, v, cond) => style(n, k, cond ? v : '');
const setAttributes = (n, attrs) => { for (let k in attrs) { n.setAttribute(k, attrs[k]); } };
const toggleEvent = (el, e, fn, cond) => el[cond ? 'on' : 'off'](e, fn);
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; };
const append = (n, c) => n.appendChild(c);
const insert = (n, c, pos) => n.insertAdjacentElement(pos, c);
const select = (str, n) => (n || document).querySelector(str);
const each = (obj, fn) => { for (let i in obj) fn(obj[i], i); };
const randomId = () => Math.random().toString(36).substr(2, 9);
/**
* TODO: use generators instead? (ie. "yield")
*/
const iMax = (iVal, max = -Infinity) => (iVal > max ? iVal : max);
const iMin = (iVal, min = +Infinity) => (iVal < min ? iVal : min);
const iAvg = (iVal, avg = 0, idx = 1) => (iVal + avg * (idx - 1)) / idx;
const iSum = (iVal, sum = 0) => iVal + sum;
/**
* Alias for some leaflet core functions
*/
const { on, off } = L.DomEvent;
const { throttle, wrapNum } = L.Util;
const { hasClass } = L.DomUtil;
/**
* Limit floating point precision
*/
const round = L.Util.formatNum;
/**
* Limit a number between min / max values
*/
const clamp = (val, range) => range ? (val < range[0] ? range[0] : val > range[1] ? range[1] : val) : val;
/**
* Limit a delta difference between two values
*/
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
*/
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;
}
}
var _ = {
__proto__: null,
Colors: Colors,
addClass: addClass,
append: append,
clamp: clamp,
cloneDeep: cloneDeep,
coordPropsToMeta: coordPropsToMeta,
create: create,
drawCanvas: drawCanvas,
each: each,
formatDate: formatDate,
formatTime: formatTime,
hasClass: hasClass,
iAvg: iAvg,
iMax: iMax,
iMin: iMin,
iSum: iSum,
insert: insert,
off: off,
on: on,
parseDate: parseDate,
parseNumeric: parseNumeric,
randomId: randomId,
removeClass: removeClass,
replaceClass: replaceClass,
resolveURL: resolveURL,
round: round,
saveFile: saveFile,
select: select,
setAttributes: setAttributes,
style: style,
throttle: throttle,
toggleClass: toggleClass,
toggleEvent: toggleEvent,
toggleStyle: toggleStyle,
wrapDelta: wrapDelta,
wrapNum: wrapNum
};
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) || (({ url: (typeof document === 'undefined' && typeof location === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : typeof document === 'undefined' ? location.href : (document.currentScript && document.currentScript.src || new URL('leaflet-elevation.js', document.baseURI).href)) }) && (typeof document === 'undefined' && typeof location === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : typeof document === 'undefined' ? location.href : (document.currentScript && document.currentScript.src || new URL('leaflet-elevation.js', document.baseURI).href)))).split("/").slice(0,-1).join("/") + '/',
};
// "leaflet-i18n" fallback
if (!L._ || !L.i18n) {
L._ = L.i18n = (string, data) => string;
}
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: coordPropsToMeta$1,
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$1),
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._summ