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}°', 391 {avg: stats.angleAvgAscent, max: stats.angleMaxAscent} 392 ), 393 descentAngleStr: isNaN(stats.angleAvgDescent) ? '-' : L.Util.template('{avg} / {max}°', 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)}°`; 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};