nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

index.js (34209B)


      1 import L from 'leaflet';
      2 import './elevation-profile.css';
      3 import {ElevationProvider} from '~/lib/elevations';
      4 import '~/lib/leaflet.control.commons';
      5 import {notify} from '~/lib/notifications';
      6 import * as logging from '~/lib/logging';
      7 import {DragEvents} from '~/lib/leaflet.events.drag';
      8 
      9 function calcSamplingInterval(length) {
     10     var targetPointsN = 2000;
     11     var maxPointsN = 9999;
     12     var samplingIntgerval = length / targetPointsN;
     13     if (samplingIntgerval < 10) {
     14         samplingIntgerval = 10;
     15     }
     16     if (samplingIntgerval > 50) {
     17         samplingIntgerval = 50;
     18     }
     19     if (length / samplingIntgerval > maxPointsN) {
     20         samplingIntgerval = length / maxPointsN;
     21     }
     22     return samplingIntgerval;
     23 }
     24 
     25 function createSvg(tagName, attributes, parent) {
     26     var element = document.createElementNS('http://www.w3.org/2000/svg', tagName);
     27     if (attributes) {
     28         var keys = Object.keys(attributes),
     29             key, value;
     30         for (var i = 0; i < keys.length; i++) {
     31             key = keys[i];
     32             value = attributes[key];
     33             element.setAttribute(key, value);
     34         }
     35     }
     36     if (parent) {
     37         parent.appendChild(element);
     38     }
     39     return element;
     40 }
     41 
     42 function pointOnSegmentAtDistance(p1, p2, dist) {
     43     // FIXME: we should place markers along projected line to avoid transformation distortions
     44     var q = dist / p1.distanceTo(p2),
     45         x = p1.lng + (p2.lng - p1.lng) * q,
     46         y = p1.lat + (p2.lat - p1.lat) * q;
     47     return L.latLng(y, x);
     48 }
     49 
     50 function gradientToAngle(g) {
     51     return Math.round(Math.atan(g) * 180 / Math.PI);
     52 }
     53 
     54 function pathRegularSamples(latlngs, step) {
     55     if (!latlngs.length) {
     56         return [];
     57     }
     58     var samples = [],
     59         lastSampleDist = 0,
     60         lastPointDistance = 0,
     61         nextPointDistance = 0,
     62         segmentLength, i;
     63 
     64     samples.push(latlngs[0]);
     65     for (i = 1; i < latlngs.length; i++) {
     66         segmentLength = latlngs[i].distanceTo(latlngs[i - 1]);
     67         nextPointDistance = lastPointDistance + segmentLength;
     68         if (nextPointDistance >= lastSampleDist + step) {
     69             while (lastSampleDist + step <= nextPointDistance) {
     70                 lastSampleDist += step;
     71                 samples.push(
     72                     pointOnSegmentAtDistance(latlngs[i - 1], latlngs[i], lastSampleDist - lastPointDistance)
     73                 );
     74             }
     75         }
     76         lastPointDistance = nextPointDistance;
     77     }
     78     if (samples.length < 2) {
     79         samples.push(latlngs[latlngs.length - 1]);
     80     }
     81     return samples;
     82 }
     83 
     84 const ElevationProfile = L.Class.extend({
     85         options: {
     86             samplingInterval: 50,
     87             sightLine: false
     88         },
     89 
     90         includes: L.Mixin.Events,
     91 
     92         initialize: function(map, latlngs, options) {
     93             L.setOptions(this, options);
     94             this.path = latlngs;
     95             var samples = this.samples = pathRegularSamples(this.path, this.options.samplingInterval);
     96             samples = samples.map((latlng) => latlng.wrap());
     97             if (!samples.length) {
     98                 notify('Track is empty');
     99                 return;
    100             }
    101             var that = this;
    102             this.horizZoom = 1;
    103             this.dragStart = null;
    104             new ElevationProvider().get(samples)
    105                 .then(function(values) {
    106                         that.values = values;
    107                         that._addTo(map);
    108                     }
    109                 )
    110                 .catch((e) => {
    111                     logging.captureException(e, 'error getting elevation');
    112                     notify(`Failed to get elevation data: ${e.message}`);
    113                 });
    114             this.values = null;
    115         },
    116 
    117         _onWindowResize: function() {
    118             this._resizeGraph();
    119         },
    120 
    121         _resizeGraph: function() {
    122             const newSvgWidth = this.drawingContainer.clientWidth * this.horizZoom;
    123             if (this.svgWidth < this.drawingContainer.clientWidth) {
    124                 this.svgWidth = newSvgWidth;
    125                 this.svg.setAttribute('width', this.svgWidth + 'px');
    126                 this.updateGraph();
    127                 this.updateGraphSelection();
    128             }
    129         },
    130 
    131         _addTo: function(map) {
    132             this._map = map;
    133             var container = this._container = L.DomUtil.create('div', 'elevation-profile-container');
    134             L.Control.prototype._stopContainerEvents.call(this);
    135             this._map._controlContainer.appendChild(container);
    136             this.setupContainerLayout();
    137             this.updateGraph();
    138             const icon = L.divIcon({
    139                     className: 'elevation-profile-marker',
    140                     html:
    141                         '<div class="elevation-profile-marker-icon"></div>' +
    142                         '<div class="elevation-profile-marker-label"></div>'
    143                 }
    144             );
    145             this.trackMarker = L.marker([1000, 0], {interactive: false, icon: icon});
    146             this.polyline = L.polyline(this.path, {weight: 30, opacity: 0}).addTo(map);
    147             this.polyline.on('mousemove', this.onLineMouseMove, this);
    148             this.polyline.on('mouseover', this.onLineMouseEnter, this);
    149             this.polyline.on('mouseout', this.onLineMouseLeave, this);
    150             this.polyLineSelection = L.polyline([], {weight: 20, opacity: 0.5, color: 'yellow', lineCap: 'butt'});
    151             return this;
    152         },
    153 
    154         setupContainerLayout: function() {
    155             var horizZoom = this.horizZoom = 1;
    156             var container = this._container;
    157             this.propsContainer = L.DomUtil.create('div', 'elevation-profile-properties', container);
    158             this.leftAxisLables = L.DomUtil.create('div', 'elevation-profile-left-axis', container);
    159             this.closeButton = L.DomUtil.create('div', 'elevation-profile-close', container);
    160             L.DomEvent.on(this.closeButton, 'click', this.onCloseButtonClick, this);
    161             this.drawingContainer = L.DomUtil.create('div', 'elevation-profile-drawingContainer', container);
    162             this.graphCursor = L.DomUtil.create('div', 'elevation-profile-cursor elevation-profile-cursor-hidden',
    163                 this.drawingContainer
    164             );
    165             this.graphCursorLabel =
    166                 L.DomUtil.create('div', 'elevation-profile-cursor-label elevation-profile-cursor-hidden',
    167                     this.drawingContainer
    168                 );
    169             this.graphSelection = L.DomUtil.create('div', 'elevation-profile-selection elevation-profile-cursor-hidden',
    170                 this.drawingContainer
    171             );
    172             var svgWidth = this.svgWidth = this.drawingContainer.clientWidth * horizZoom,
    173                 svgHeight = this.svgHeight = this.drawingContainer.clientHeight;
    174             var svg = this.svg = createSvg('svg', {width: svgWidth, height: svgHeight}, this.drawingContainer);
    175             L.DomEvent.on(svg, 'mousemove', this.onSvgMouseMove, this);
    176             // We should handle mouseenter event, but due to a
    177             // bug in Chrome (https://bugs.chromium.org/p/chromium/issues/detail?id=846738)
    178             // this event is emitted while resizing window by dragging right window frame
    179             // which causes cursor to appeat while resizing
    180             L.DomEvent.on(svg, 'mousemove', this.onSvgEnter, this);
    181             L.DomEvent.on(svg, 'mouseleave', this.onSvgLeave, this);
    182             L.DomEvent.on(svg, 'mousewheel', this.onSvgMouseWheel, this);
    183             this.svgDragEvents = new DragEvents(this.svg, {dragButtons: [0, 2], stopOnLeave: true});
    184             this.svgDragEvents.on('dragstart', this.onSvgDragStart, this);
    185             this.svgDragEvents.on('dragend', this.onSvgDragEnd, this);
    186             this.svgDragEvents.on('drag', this.onSvgDrag, this);
    187             this.svgDragEvents.on('click', this.onSvgClick, this);
    188             L.DomEvent.on(svg, 'dblclick', this.onSvgDblClick, this);
    189             L.DomEvent.on(window, 'resize', this._onWindowResize, this);
    190         },
    191 
    192         removeFrom: function(map) {
    193             if (this.abortLoading) {
    194                 this.abortLoading();
    195             }
    196             if (!this._map) {
    197                 return this;
    198             }
    199             this._map._controlContainer.removeChild(this._container);
    200             map.removeLayer(this.polyline);
    201             map.removeLayer(this.trackMarker);
    202             map.removeLayer(this.polyLineSelection);
    203             L.DomEvent.off(window, 'resize', this._onWindowResize, this);
    204             this._map = null;
    205             this.fire('remove');
    206             return this;
    207         },
    208 
    209         onSvgDragStart: function(e) {
    210             if (e.dragButton === 0) {
    211                 // FIXME: restore hiding when we make display of selection on map
    212                 // this.cursorHide();
    213                 this.polyLineSelection.addTo(this._map).bringToBack();
    214                 this.selStartIndex = this.roundedSampleIndexFromMouseEvent(e);
    215             }
    216         },
    217 
    218         graphContainerOffsetFromMouseEvent: function(e) {
    219             if (e.originalEvent) {
    220                 e = e.originalEvent;
    221             }
    222             let x = e.clientX - this.svg.getBoundingClientRect().left;
    223             if (x < 0) {
    224                 x = 0;
    225             }
    226             if (x > this.svgWidth - 1) {
    227                 x = this.svgWidth - 1;
    228             }
    229             return x;
    230         },
    231 
    232         sampleIndexFromMouseEvent: function(e) {
    233             const x = this.graphContainerOffsetFromMouseEvent(e);
    234             return x / (this.svgWidth - 1) * (this.values.length - 1);
    235         },
    236 
    237         roundedSampleIndexFromMouseEvent: function(e) {
    238             return Math.round(this.sampleIndexFromMouseEvent(e));
    239         },
    240 
    241         setTrackMarkerLabel: function(label) {
    242             const icon = this.trackMarker._icon;
    243             if (!icon) {
    244                 return;
    245             }
    246             icon.getElementsByClassName('elevation-profile-marker-label')[0].innerHTML = label;
    247         },
    248 
    249         setGraphSelection: function(cursorIndex) {
    250             this.selMinInd = Math.min(cursorIndex, this.selStartIndex);
    251             this.selMaxInd = Math.max(cursorIndex, this.selStartIndex);
    252 
    253             if (this.selMinInd < 0) {
    254                 this.selMinInd = 0;
    255             }
    256             if (this.selMaxInd > this.values.length - 1) {
    257                 this.selMaxInd = this.values.length - 1;
    258             }
    259             this.updateGraphSelection();
    260             L.DomUtil.removeClass(this.graphSelection, 'elevation-profile-cursor-hidden');
    261         },
    262 
    263         updateGraphSelection: function() {
    264             const selStart = this.selMinInd * (this.svgWidth - 1) / (this.values.length - 1);
    265             const selEnd = this.selMaxInd * (this.svgWidth - 1) / (this.values.length - 1);
    266             this.graphSelection.style.left = selStart + 'px';
    267             this.graphSelection.style.width = (selEnd - selStart) + 'px';
    268         },
    269 
    270         onSvgDragEnd: function(e) {
    271             if (e.dragButton === 0) {
    272                 this.cursorShow();
    273                 this.setGraphSelection(this.roundedSampleIndexFromMouseEvent(e));
    274                 var stats = this.calcProfileStats(this.values.slice(this.selMinInd, this.selMaxInd + 1));
    275                 this.updatePropsDisplay(stats);
    276                 L.DomUtil.addClass(this.propsContainer, 'elevation-profile-properties-selected');
    277             }
    278             if (e.dragButton === 2) {
    279                 this.drawingContainer.scrollLeft -= e.dragMovement.x;
    280             }
    281         },
    282 
    283         onSvgDrag: function(e) {
    284             if (e.dragButton === 0) {
    285                 this.setGraphSelection(this.roundedSampleIndexFromMouseEvent(e));
    286                 this.polyLineSelection.setLatLngs(this.samples.slice(this.selMinInd, this.selMaxInd + 1));
    287             }
    288             if (e.dragButton === 2) {
    289                 this.drawingContainer.scrollLeft -= e.dragMovement.x;
    290             }
    291         },
    292 
    293         onSvgClick: function(e) {
    294             const button = e.originalEvent.button;
    295             if (button === 0) {
    296                 this.dragStart = null;
    297                 L.DomUtil.addClass(this.graphSelection, 'elevation-profile-cursor-hidden');
    298                 L.DomUtil.removeClass(this.propsContainer, 'elevation-profile-properties-selected');
    299                 this._map.removeLayer(this.polyLineSelection);
    300                 if (this.stats) {
    301                     this.updatePropsDisplay(this.stats);
    302                 }
    303             }
    304             if (button === 2) {
    305                 this.setMapPositionAtIndex(this.roundedSampleIndexFromMouseEvent(e));
    306             }
    307         },
    308 
    309         onSvgDblClick: function(e) {
    310             this.setMapPositionAtIndex(this.roundedSampleIndexFromMouseEvent(e));
    311         },
    312 
    313         setMapPositionAtIndex: function(ind) {
    314             var latlng = this.samples[ind];
    315             if (latlng) {
    316                 this._map.panTo(latlng);
    317             }
    318         },
    319 
    320         onSvgMouseWheel: function(e) {
    321             var oldHorizZoom = this.horizZoom;
    322             this.horizZoom += L.DomEvent.getWheelDelta(e) > 0 ? 1 : -1;
    323             if (this.horizZoom < 1) {
    324                 this.horizZoom = 1;
    325             }
    326             if (this.horizZoom > 10) {
    327                 this.horizZoom = 10;
    328             }
    329 
    330             var ind = this.sampleIndexFromMouseEvent(e);
    331 
    332             var newScrollLeft = this.drawingContainer.scrollLeft +
    333                 this.graphContainerOffsetFromMouseEvent(e) * (this.horizZoom / oldHorizZoom - 1);
    334             if (newScrollLeft < 0) {
    335                 newScrollLeft = 0;
    336             }
    337 
    338             this.svgWidth = this.drawingContainer.clientWidth * this.horizZoom;
    339             this.svg.setAttribute('width', this.svgWidth + 'px');
    340             this.setupGraph();
    341             if (newScrollLeft > this.svgWidth - this.drawingContainer.clientWidth) {
    342                 newScrollLeft = this.svgWidth - this.drawingContainer.clientWidth;
    343             }
    344             this.drawingContainer.scrollLeft = newScrollLeft;
    345 
    346             this.cursorHide();
    347             this.setCursorPosition(ind);
    348             this.cursorShow();
    349             this.updateGraphSelection();
    350         },
    351 
    352         updateGraph: function() {
    353             if (!this._map || !this.values) {
    354                 return;
    355             }
    356 
    357             this.stats = this.calcProfileStats(this.values);
    358             this.updatePropsDisplay(this.stats);
    359             this.setupGraph();
    360         },
    361 
    362         updatePropsDisplay: function(stats) {
    363             if (!this._map) {
    364                 return;
    365             }
    366             let d;
    367             if (stats.noData) {
    368                 d = {
    369                     maxElev: '-',
    370                     minElev: '-',
    371                     startElev: '-',
    372                     endElev: '-',
    373                     change: '-',
    374                     ascentAngleStr: '-',
    375                     descentAngleStr: '-',
    376                     ascent: '-',
    377                     descent: '-',
    378                     startApprox: '',
    379                     endApprox: '',
    380                     approx: '',
    381                     incomplete: 'No elevation data',
    382                 };
    383             } else {
    384                 d = {
    385                     maxElev: Math.round(stats.max),
    386                     minElev: Math.round(stats.min),
    387                     startElev: Math.round(stats.start),
    388                     endElev: Math.round(stats.end),
    389                     change: Math.round(stats.finalAscent),
    390                     ascentAngleStr: isNaN(stats.angleAvgAscent) ? '-' : L.Util.template('{avg} / {max}&deg;',
    391                             {avg: stats.angleAvgAscent, max: stats.angleMaxAscent}
    392                         ),
    393                     descentAngleStr: isNaN(stats.angleAvgDescent) ? '-' : L.Util.template('{avg} / {max}&deg;',
    394                             {avg: stats.angleAvgDescent, max: stats.angleMaxDescent}
    395                         ),
    396                     ascent: Math.round(stats.ascent),
    397                     descent: Math.round(stats.descent),
    398                     dist: (stats.distance / 1000).toFixed(1),
    399                     startApprox: stats.dataLostAtStart > 0.02 ? '~ ' : '',
    400                     endApprox: stats.dataLostAtEnd > 0.02 ? '~ ' : '',
    401                     approx: stats.dataLost > 0.02 ? '~ ' : '',
    402                     incomplete: stats.dataLost > 0.02 ? 'Some elevation data missing' : '',
    403                 };
    404             }
    405             d.dist = (stats.distance / 1000).toFixed(1);
    406 
    407             this.propsContainer.innerHTML = `
    408                 <table>
    409                 <tr><td>Max elevation:</td><td>${d.maxElev}</td></tr>
    410                 <tr><td>Min elevation:</td><td>${d.minElev}</td></tr>
    411                 <tr class="start-group"><td>Start elevation:</td><td>${d.startApprox}${d.startElev}</td></tr>
    412                 <tr><td>Finish elevation:</td><td>${d.endApprox}${d.endElev}</td></tr>
    413                 <tr><td>Start to finish elevation change:</td><td>${d.startApprox || d.endApprox}${d.change}</td></tr>
    414                 <tr class="start-group"><td>Avg / Max ascent inclination:</td><td>${d.ascentAngleStr}</td></tr>
    415                 <tr><td>Avg / Max descent inclination:</td><td>${d.descentAngleStr}</td></tr>
    416                 <tr class="start-group"><td>Total ascent:</td><td>${d.approx}${d.ascent}</td></tr>
    417                 <tr><td>Total descent:</td><td>${d.approx}${d.descent}</td></tr>
    418                 <tr class="start-group"><td>Distance:</td><td>${d.dist} km</td></tr>
    419                 <tr><td colspan="2" style="text-align: center">${d.incomplete}</td></tr>
    420                 </table>
    421                 `;
    422         },
    423 
    424         calcGridValues: function(minValue, maxValue) {
    425             var ticksNs = [3, 4, 5],
    426                 tickSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000],
    427                 ticks = [],
    428                 i, j, k, ticksN, tickStep, tick1, tick2,
    429                 matchFound = false;
    430 
    431             for (i = 0; i < tickSteps.length; i++) {
    432                 tickStep = tickSteps[i];
    433                 for (j = 0; j < ticksNs.length; j++) {
    434                     ticksN = ticksNs[j];
    435                     tick1 = Math.floor(minValue / tickStep);
    436                     tick2 = Math.ceil(maxValue / tickStep);
    437                     if ((tick2 - tick1) < ticksN) {
    438                         matchFound = true;
    439                         break;
    440                     }
    441                 }
    442                 if (matchFound) {
    443                     break;
    444                 }
    445             }
    446             for (k = tick1; k < tick1 + ticksN; k++) {
    447                 ticks.push(k * tickStep);
    448             }
    449             return ticks;
    450         },
    451 
    452         filterElevations: function(values, tolerance) {
    453             var filtered = values.slice(0);
    454             if (filtered.length < 3) {
    455                 return filtered;
    456             }
    457             var scanStart, scanEnd, job, linearValue, linearDelta, maxError, maxErrorInd, i, error;
    458             var queue = [[0, filtered.length - 1]];
    459             while (queue.length) {
    460                 job = queue.pop();
    461                 scanStart = job[0];
    462                 scanEnd = job[1];
    463                 linearValue = filtered[scanStart];
    464                 linearDelta = (filtered[scanEnd] - filtered[scanStart]) / (scanEnd - scanStart);
    465                 maxError = null;
    466                 maxErrorInd = null;
    467                 for (i = scanStart + 1; i < scanEnd; i++) {
    468                     linearValue += linearDelta;
    469                     if (filtered[i] === null) {
    470                         continue;
    471                     }
    472                     error = Math.abs(filtered[i] - linearValue);
    473                     if (error === null || error > maxError) {
    474                         maxError = error;
    475                         maxErrorInd = i;
    476                     }
    477                 }
    478                 if (maxError > tolerance) {
    479                     if (scanEnd > scanStart + 2) {
    480                         queue.push([scanStart, maxErrorInd]);
    481                         queue.push([maxErrorInd, scanEnd]);
    482                     }
    483                 } else {
    484                     filtered.splice(scanStart + 1, scanEnd - scanStart - 1);
    485                 }
    486             }
    487             return filtered;
    488         },
    489 
    490         calcProfileStats: function(values) {
    491             const stats = {
    492                 distance: (values.length - 1) * this.options.samplingInterval
    493             };
    494             const notNullValues = values.filter((value) => value !== null);
    495             if (notNullValues.length === 0) {
    496                 stats.noData = true;
    497                 return stats;
    498             }
    499             stats.min = Math.min(...notNullValues);
    500             stats.max = Math.max(...notNullValues);
    501             let firstNotNullIndex = true,
    502                 lastNotNullIndex = true,
    503                 firstNotNullValue,
    504                 lastNotNullValue;
    505             for (let i = 0; i < values.length; i++) {
    506                 let value = values[i];
    507                 if (value !== null) {
    508                     firstNotNullValue = value;
    509                     firstNotNullIndex = i;
    510                     break;
    511                 }
    512             }
    513             for (let i = values.length - 1; i >= 0; i--) {
    514                 let value = values[i];
    515                 if (value !== null) {
    516                     lastNotNullValue = value;
    517                     lastNotNullIndex = i;
    518                     break;
    519                 }
    520             }
    521             stats.finalAscent = lastNotNullValue - firstNotNullValue;
    522 
    523             const ascents = [],
    524                 descents = [];
    525             let prevNotNullValue = values[firstNotNullIndex],
    526                 prevNotNullIndex = firstNotNullIndex;
    527             for (let i = firstNotNullIndex + 1; i <= lastNotNullIndex; i++) {
    528                 let value = values[i];
    529                 if (value === null) {
    530                     continue;
    531                 }
    532                 let length = i - prevNotNullIndex;
    533                 let gradient = (value - prevNotNullValue) / length;
    534                 if (gradient > 0) {
    535                     for (let j = 0; j < length; j++) {
    536                         ascents.push(gradient);
    537                     }
    538                 } else if (gradient < 0) {
    539                     for (let j = 0; j < length; j++) {
    540                         descents.push(-gradient);
    541                     }
    542                 }
    543                 prevNotNullIndex = i;
    544                 prevNotNullValue = value;
    545             }
    546             function sum(a, b) {
    547                 return a + b;
    548             }
    549             if (ascents.length !== 0) {
    550                 stats.gradientAvgAscent = ascents.reduce(sum, 0) / ascents.length / this.options.samplingInterval;
    551                 stats.gradientMinAscent = Math.min(...ascents) / this.options.samplingInterval;
    552                 stats.gradientMaxAscent = Math.max(...ascents) / this.options.samplingInterval;
    553                 stats.angleAvgAscent = gradientToAngle(stats.gradientAvgAscent);
    554                 stats.angleMinAscent = gradientToAngle(stats.gradientMinAscent);
    555                 stats.angleMaxAscent = gradientToAngle(stats.gradientMaxAscent);
    556             }
    557             if (descents.length !== 0) {
    558                 stats.gradientAvgDescent = descents.reduce(sum, 0) / descents.length / this.options.samplingInterval;
    559                 stats.gradientMinDescent = Math.min(...descents) / this.options.samplingInterval;
    560                 stats.gradientMaxDescent = Math.max(...descents) / this.options.samplingInterval;
    561                 stats.angleAvgDescent = gradientToAngle(stats.gradientAvgDescent);
    562                 stats.angleMinDescent = gradientToAngle(stats.gradientMinDescent);
    563                 stats.angleMaxDescent = gradientToAngle(stats.gradientMaxDescent);
    564             }
    565 
    566             stats.start = firstNotNullValue;
    567             stats.end = lastNotNullValue;
    568             stats.distance = (values.length - 1) * this.options.samplingInterval;
    569             stats.dataLostAtStart = firstNotNullIndex / values.length;
    570             stats.dataLostAtEnd = 1 - lastNotNullIndex / (values.length - 1);
    571             stats.dataLost = 1 - notNullValues.length / values.length;
    572 
    573             const filterTolerance = 5;
    574             const filtered = this.filterElevations(values.slice(firstNotNullIndex, lastNotNullIndex), filterTolerance);
    575             let ascent = 0,
    576                 descent = 0,
    577                 delta;
    578             for (let i = 1; i < filtered.length; i++) {
    579                 delta = filtered[i] - filtered[i - 1];
    580                 if (delta < 0) {
    581                     descent += -delta;
    582                 } else {
    583                     ascent += delta;
    584                 }
    585             }
    586             stats.ascent = ascent;
    587             stats.descent = descent;
    588 
    589             return stats;
    590         },
    591 
    592         setCursorPosition: function(ind) {
    593             if (!this._map || !this.values) {
    594                 return;
    595             }
    596             const samplingInterval = this.options.samplingInterval;
    597             const distanceKm = samplingInterval * ind / 1000;
    598             const distanceStr = `${distanceKm.toFixed(2)} km`;
    599             const sample1 = this.values[Math.ceil(ind)];
    600             const sample2 = this.values[Math.floor(ind)];
    601             let angleStr;
    602             if (sample1 !== null && sample2 !== null) {
    603                 const gradient = (sample2 - sample1) / samplingInterval;
    604                 angleStr = `${Math.round(Math.atan(gradient) * 180 / Math.PI)}&deg`;
    605             } else {
    606                 angleStr = '-';
    607             }
    608 
    609             const x = Math.round(ind / (this.values.length - 1) * (this.svgWidth - 1));
    610             const indInt = Math.round(ind);
    611             let elevation = this.values[indInt];
    612             if (elevation === null) {
    613                 elevation = sample1;
    614             }
    615             if (elevation === null) {
    616                 elevation = sample2;
    617             }
    618             const elevationStr = (elevation === null) ? '-' : `${elevation} m`;
    619 
    620             const cursorLabel = `${elevationStr}<br>${distanceStr}<br>${angleStr}`;
    621 
    622             this.graphCursorLabel.innerHTML = cursorLabel;
    623 
    624             this.graphCursor.style.left = x + 'px';
    625             this.graphCursorLabel.style.left = x + 'px';
    626             if (this.drawingContainer.getBoundingClientRect().left - this.drawingContainer.scrollLeft + x +
    627                 this.graphCursorLabel.offsetWidth >= this.drawingContainer.getBoundingClientRect().right) {
    628                 L.DomUtil.addClass(this.graphCursorLabel, 'elevation-profile-cursor-label-left');
    629             } else {
    630                 L.DomUtil.removeClass(this.graphCursorLabel, 'elevation-profile-cursor-label-left');
    631             }
    632 
    633             let markerPos;
    634             if (ind <= 0) {
    635                 markerPos = this.samples[0];
    636             } else if (ind >= this.samples.length - 1) {
    637                 markerPos = this.samples[this.samples.length - 1];
    638             } else {
    639                 const p1 = this.samples[Math.floor(ind)],
    640                     p2 = this.samples[Math.ceil(ind)],
    641                     indFrac = ind - Math.floor(ind);
    642                 markerPos = [p1.lat + (p2.lat - p1.lat) * indFrac, p1.lng + (p2.lng - p1.lng) * indFrac];
    643             }
    644             this.trackMarker.setLatLng(markerPos);
    645 
    646             this.setTrackMarkerLabel(cursorLabel);
    647         },
    648 
    649         onSvgMouseMove: function(e) {
    650             if (!this.values) {
    651                 return;
    652             }
    653             this.setCursorPosition(this.sampleIndexFromMouseEvent(e));
    654         },
    655 
    656         cursorShow: function() {
    657             L.DomUtil.removeClass(this.graphCursor, 'elevation-profile-cursor-hidden');
    658             L.DomUtil.removeClass(this.graphCursorLabel, 'elevation-profile-cursor-hidden');
    659             this._map.addLayer(this.trackMarker);
    660         },
    661 
    662         cursorHide: function() {
    663             L.DomUtil.addClass(this.graphCursor, 'elevation-profile-cursor-hidden');
    664             L.DomUtil.addClass(this.graphCursorLabel, 'elevation-profile-cursor-hidden');
    665             this._map.removeLayer(this.trackMarker);
    666         },
    667 
    668         onSvgEnter: function() {
    669             this.cursorShow();
    670         },
    671 
    672         onSvgLeave: function() {
    673             this.cursorHide();
    674         },
    675 
    676         onLineMouseEnter: function() {
    677             this.cursorShow();
    678         },
    679 
    680         onLineMouseLeave: function() {
    681             this.cursorHide();
    682         },
    683 
    684         onLineMouseMove: function(e) {
    685             function sqrDist(latlng1, latlng2) {
    686                 var dx = (latlng1.lng - latlng2.lng);
    687                 var dy = (latlng1.lat - latlng2.lat);
    688                 return dx * dx + dy * dy;
    689             }
    690 
    691             var nearestInd = null,
    692                 ind,
    693                 minDist = null,
    694                 mouseLatlng = e.latlng,
    695                 i, sampleLatlng, dist, di;
    696             let nextDist,
    697                 nextSampleDist,
    698                 prevDist,
    699                 prevSampleDist;
    700             for (i = 0; i < this.samples.length; i++) {
    701                 sampleLatlng = this.samples[i];
    702                 dist = sqrDist(sampleLatlng, mouseLatlng);
    703                 if (nearestInd === null || dist < minDist) {
    704                     nearestInd = i;
    705                     minDist = dist;
    706                 }
    707             }
    708 
    709             if (nearestInd !== null) {
    710                 ind = nearestInd;
    711                 if (nearestInd > 0) {
    712                     prevDist = sqrDist(mouseLatlng, this.samples[nearestInd - 1]);
    713                     prevSampleDist = sqrDist(this.samples[nearestInd], this.samples[nearestInd - 1]);
    714                 }
    715                 if (nearestInd < this.samples.length - 1) {
    716                     nextDist = sqrDist(mouseLatlng, this.samples[nearestInd + 1]);
    717                     nextSampleDist = sqrDist(this.samples[nearestInd], this.samples[nearestInd + 1]);
    718                 }
    719 
    720                 if (nearestInd === 0) {
    721                     if (nextDist < minDist + nextSampleDist) {
    722                         di = (minDist - nextDist) / 2 / nextSampleDist + 1 / 2;
    723                     } else {
    724                         di = 0.001;
    725                     }
    726                 } else if (nearestInd === this.samples.length - 1) {
    727                     if (prevDist < minDist + prevSampleDist) {
    728                         di = -((minDist - prevDist) / 2 / prevSampleDist + 1 / 2);
    729                     } else {
    730                         di = -0.001;
    731                     }
    732                 } else {
    733                     if (prevDist < nextDist) {
    734                         di = -((minDist - prevDist) / 2 / prevSampleDist + 1 / 2);
    735                     } else {
    736                         di = (minDist - nextDist) / 2 / nextSampleDist + 1 / 2;
    737                     }
    738                 }
    739                 if (di < -1) {
    740                     di = -1;
    741                 }
    742                 if (di > 1) {
    743                     di = 1;
    744                 }
    745                 this.setCursorPosition(ind + di);
    746             }
    747         },
    748 
    749         setupGraph: function() {
    750             if (!this._map || !this.values) {
    751                 return;
    752             }
    753 
    754             while (this.svg.hasChildNodes()) {
    755                 this.svg.removeChild(this.svg.lastChild);
    756             }
    757             while (this.leftAxisLables.hasChildNodes()) {
    758                 this.leftAxisLables.removeChild(this.leftAxisLables.lastChild);
    759             }
    760 
    761             var maxValue = Math.max.apply(null, this.values),
    762                 minValue = Math.min.apply(null, this.values),
    763                 svg = this.svg,
    764                 path, i, horizStep, verticalMultiplier, x, y, gridValues, label;
    765 
    766             var paddingBottom = 8 + 16,
    767                 paddingTop = 8;
    768 
    769             gridValues = this.calcGridValues(minValue, maxValue);
    770             var gridStep = (this.svgHeight - paddingBottom - paddingTop) / (gridValues.length - 1);
    771             for (i = 0; i < gridValues.length; i++) {
    772                 y = Math.round(i * gridStep - 0.5) + 0.5 + paddingTop;
    773                 path = L.Util.template('M{x1} {y} L{x2} {y}', {x1: 0, x2: this.svgWidth * this.horizZoom, y: y});
    774                 createSvg(
    775                     'path',
    776                     {'d': path, 'stroke-width': '1px', 'stroke': 'green', 'fill': 'none', 'stroke-opacity': '0.5'},
    777                     svg
    778                 );
    779 
    780                 label = L.DomUtil.create('div', 'elevation-profile-grid-label', this.leftAxisLables);
    781                 label.innerHTML = gridValues[gridValues.length - i - 1];
    782                 label.style.top = (gridStep * i + paddingTop) + 'px';
    783             }
    784 
    785             horizStep = this.svgWidth / (this.values.length - 1);
    786             verticalMultiplier =
    787                 (this.svgHeight - paddingTop - paddingBottom) / (gridValues[gridValues.length - 1] - gridValues[0]);
    788 
    789             path = [];
    790             const valueToSvgCoord = (el) => {
    791                 const y = (el - gridValues[0]) * verticalMultiplier;
    792                 return this.svgHeight - y - paddingBottom;
    793             };
    794 
    795             let startNewSegment = true;
    796             for (i = 0; i < this.values.length; i++) {
    797                 let value = this.values[i];
    798                 if (value === null) {
    799                     startNewSegment = true;
    800                     continue;
    801                 }
    802                 path.push(startNewSegment ? 'M' : 'L');
    803                 x = i * horizStep;
    804                 y = valueToSvgCoord(value);
    805                 path.push(x + ' ' + y + ' ');
    806                 startNewSegment = false;
    807             }
    808             path = path.join('');
    809             createSvg('path', {'d': path, 'stroke-width': '1px', 'stroke': 'brown', 'fill': 'none'}, svg);
    810             // sightline
    811             if (this.options.sightLine) {
    812                 path = L.Util.template('M{x1} {y1} L{x2} {y2}', {
    813                         x1: 0,
    814                         x2: this.svgWidth * this.horizZoom,
    815                         y1: valueToSvgCoord(this.values[0]),
    816                         y2: valueToSvgCoord(this.values[this.values.length - 1])
    817                     }
    818                 );
    819                 createSvg(
    820                     'path',
    821                     {'d': path, 'stroke-width': '3px', 'stroke': '#94b1ff', 'fill': 'none', 'stroke-opacity': '0.5'},
    822                     svg
    823                 );
    824             }
    825         },
    826 
    827         onCloseButtonClick: function() {
    828             this.removeFrom(this._map);
    829         }
    830     }
    831 );
    832 
    833 export {ElevationProfile, calcSamplingInterval};