track-list.js (60405B)
1 import L from 'leaflet'; 2 import ko from 'knockout'; 3 import Contextmenu from '~/lib/contextmenu'; 4 import '~/lib/knockout.component.progress/progress'; 5 import './track-list.css'; 6 import {selectFiles, readFiles} from '~/lib/file-read'; 7 import parseGeoFile from './lib/parseGeoFile'; 8 import loadFromUrl from './lib/loadFromUrl'; 9 import * as geoExporters from './lib/geo_file_exporters'; 10 import copyToClipboard from '~/lib/clipboardCopy'; 11 import {saveAs} from '~/vendored/github.com/eligrey/FileSaver'; 12 import '~/lib/leaflet.layer.canvasMarkers'; 13 import '~/lib/leaflet.lineutil.simplifyLatLngs'; 14 import iconFromBackgroundImage from '~/lib/iconFromBackgroundImage'; 15 import '~/lib/controls-styles/controls-styles.css'; 16 import {ElevationProfile, calcSamplingInterval} from '~/lib/leaflet.control.elevation-profile'; 17 import '~/lib/leaflet.control.commons'; 18 import {blobFromString} from '~/lib/binary-strings'; 19 import '~/lib/leaflet.polyline-edit'; 20 import '~/lib/leaflet.polyline-measure'; 21 import * as logging from '~/lib/logging'; 22 import {notify, query} from '~/lib/notifications'; 23 import {fetch} from '~/lib/xhr-promise'; 24 import config from '~/config'; 25 import md5 from 'blueimp-md5'; 26 import {wrapLatLngToTarget, wrapLatLngBoundsToTarget} from '~/lib/leaflet.fixes/fixWorldCopyJump'; 27 import {splitLinesAt180Meridian} from "./lib/meridian180"; 28 import {ElevationProvider} from '~/lib/elevations'; 29 30 const TRACKLIST_TRACK_COLORS = ['#77f', '#f95', '#0ff', '#f77', '#f7f', '#ee5']; 31 32 const TrackSegment = L.MeasuredLine.extend({ 33 includes: L.Polyline.EditMixin, 34 35 options: { 36 weight: 6, 37 lineCap: 'round', 38 opacity: 0.5, 39 40 } 41 }); 42 TrackSegment.mergeOptions(L.Polyline.EditMixinOptions); 43 44 function unwrapLatLngsCrossing180Meridian(latngs) { 45 if (latngs.length === 0) { 46 return []; 47 } 48 const unwrapped = [latngs[0]]; 49 let lastUnwrapped; 50 let prevUnwrapped = latngs[0]; 51 for (let i = 1; i < latngs.length; i++) { 52 lastUnwrapped = wrapLatLngToTarget(latngs[i], prevUnwrapped); 53 unwrapped.push(lastUnwrapped); 54 prevUnwrapped = lastUnwrapped; 55 } 56 return unwrapped; 57 } 58 59 function closestPointToLineSegment(latlngs, segmentIndex, point) { 60 const crs = L.CRS.EPSG3857; 61 point = crs.latLngToPoint(point); 62 const segStart = crs.latLngToPoint(latlngs[segmentIndex]); 63 const segEnd = crs.latLngToPoint(latlngs[segmentIndex + 1]); 64 return crs.pointToLatLng(L.LineUtil.closestPointOnSegment(point, segStart, segEnd)); 65 } 66 67 function isPointCloserToStart(latlngs, latlng) { 68 const distToStart = latlng.distanceTo(latlngs[0]); 69 const distToEnd = latlng.distanceTo(latlngs[latlngs.length - 1]); 70 return distToStart < distToEnd; 71 } 72 73 L.Control.TrackList = L.Control.extend({ 74 options: { 75 position: 'bottomright', 76 lineCursorStyle: {interactive: false, weight: 1.5, opacity: 1, dashArray: '7,7'}, 77 lineCursorValidStyle: {color: 'green'}, 78 lineCursorInvalidStyle: {color: 'red'}, 79 splitExtensions: ['gpx', 'kml', 'kmz', 'wpt', 'rte', 'plt', 'fit', 'tmp', 'jpg', 'crdownload'], 80 splitExtensionsFirstStage: ['xml', 'txt', 'html', 'php', 'tmp', 'gz'], 81 trackHighlightStyle: { 82 color: 'yellow', 83 weight: 15, 84 opacity: 0.5, 85 }, 86 trackMarkerHighlightStyle: { 87 color: 'yellow', 88 weight: 20, 89 opacity: 0.6, 90 }, 91 trackStartHighlightStyle: { 92 color: 'green', 93 weight: 13, 94 opacity: 0.6, 95 }, 96 trackEndHighlightStyle: { 97 color: 'red', 98 weight: 13, 99 opacity: 0.6, 100 }, 101 }, 102 includes: L.Mixin.Events, 103 104 colors: TRACKLIST_TRACK_COLORS, 105 106 initialize: function() { 107 L.Control.prototype.initialize.call(this); 108 this.tracks = ko.observableArray(); 109 this.url = ko.observable(''); 110 this.readingFiles = ko.observable(0); 111 this.readProgressRange = ko.observable(); 112 this.readProgressDone = ko.observable(); 113 this._lastTrackColor = 0; 114 this.trackListHeight = ko.observable(0); 115 this.isPlacingPoint = false; 116 this.trackAddingPoint = ko.observable(null); 117 this.trackAddingSegment = ko.observable(null); 118 }, 119 120 onAdd: function(map) { 121 this.map = map; 122 this.tracks.removeAll(); 123 var container = this._container = L.DomUtil.create('div', 'leaflet-control leaflet-control-tracklist'); 124 this._stopContainerEvents(); 125 126 /* eslint-disable max-len */ 127 container.innerHTML = ` 128 <div class="leaflet-control-button-toggle" data-bind="click: setExpanded" 129 title="Load, edit and save tracks"></div> 130 <div class="leaflet-control-content"> 131 <div class="header"> 132 <div class="hint" 133 title="gpx kml Ozi zip YandexMaps Strava GPSLib Etomesto GarminConnect SportsTracker OSM Tracedetrail OpenStreetMap.ru Wikiloc"> 134 gpx kml Ozi zip YandexMaps Strava 135 <span class="formats-hint-more">…</span> 136 </div> 137 <div class="button-minimize" data-bind="click: setMinimized"></div> 138 </div> 139 <div class="inputs-row" data-bind="visible: !readingFiles()"> 140 <a class="button add-track" title="New track" data-bind="click: onButtonNewTrackClicked"></a 141 ><a class="button open-file" title="Open file" data-bind="click: loadFilesFromDisk"></a 142 ><input type="text" class="input-url" placeholder="Track URL" 143 data-bind="textInput: url, event: {keypress: onEnterPressedInInput, contextmenu: defaultEventHandle, mousemove: defaultEventHandle}" 144 ><a class="button download-url" title="Download URL" data-bind="click: loadFilesFromUrl"></a 145 ><a class="button menu-icon" data-bind="click: function(_,e){this.showMenu(e)}" title="Menu"></a> 146 </div> 147 <div style="text-align: center"> 148 <div data-bind=" 149 component: { 150 name: 'progress-indicator', 151 params: {progressRange: readProgressRange, progressDone: readProgressDone} 152 }, 153 visible: readingFiles"></div> 154 </div> 155 <div class="tracks-rows-wrapper" data-bind="style: {maxHeight: trackListHeight}"> 156 <table class="tracks-rows"><tbody data-bind="foreach: {data: tracks, as: 'track'}"> 157 <tr data-bind="event: { 158 contextmenu: $parent.showTrackMenu.bind($parent), 159 mouseenter: $parent.onTrackRowMouseEnter.bind($parent, track), 160 mouseleave: $parent.onTrackRowMouseLeave.bind($parent, track) 161 }, 162 css: {hover: hover() && $parent.tracks().length > 1, edit: isEdited() && $parent.tracks().length > 1}"> 163 <td><input type="checkbox" class="visibility-switch" data-bind="checked: track.visible"></td> 164 <td><div class="color-sample" data-bind="style: {backgroundColor: $parent.colors[track.color()]}, click: $parent.onColorSelectorClicked.bind($parent)"></div></td> 165 <td><div class="track-name-wrapper"><div class="track-name" data-bind="text: track.name, attr: {title: track.name}, click: $parent.setViewToTrack.bind($parent)"></div></div></td> 166 <td> 167 <div class="button-length" title="Show distance marks" data-bind=" 168 text: $parent.formatLength(track.length()), 169 css: {'ticks-enabled': track.measureTicksShown}, 170 click: $parent.switchMeasureTicksVisibility.bind($parent)"></div> 171 </td> 172 <td><div class="button-add-track" title="Add track segment" data-bind="click: $parent.onButtonAddSegmentClicked.bind($parent, track), css: {active: $parent.trackAddingSegment() === track}"></div></td> 173 <td><div class="button-add-point" title="Add point" data-bind="click: $parent.onButtonAddPointClicked.bind($parent, track), css: {active: $parent.trackAddingPoint() === track}"></div></td> 174 <td><a class="track-text-button" title="Actions" data-bind="click: $parent.showTrackMenu.bind($parent)">…</a></td> 175 </tr> 176 </tbody></table> 177 </div> 178 </div> 179 `; 180 /* eslint-enable max-len */ 181 182 ko.applyBindings(this, container); 183 // FIXME: add onRemove method and unsubscribe 184 L.DomEvent.addListener(map.getContainer(), 'drop', this.onFileDragDrop, this); 185 L.DomEvent.addListener(map.getContainer(), 'dragover', this.onFileDraging, this); 186 this.menu = new Contextmenu([ 187 {text: 'Copy link for all tracks', callback: this.copyAllTracksToClipboard.bind(this)}, 188 {text: 'Copy link for visible tracks', callback: this.copyVisibleTracksToClipboard.bind(this)}, 189 { 190 text: 'Create new track from all visible tracks', 191 callback: this.createNewTrackFromVisibleTracks.bind(this) 192 }, 193 '-', 194 {text: 'Delete all tracks', callback: this.deleteAllTracks.bind(this)}, 195 {text: 'Delete hidden tracks', callback: this.deleteHiddenTracks.bind(this)} 196 ] 197 ); 198 this._markerLayer = new L.Layer.CanvasMarkers(null, { 199 print: true, 200 scaleDependent: true, 201 zIndex: 1000, 202 printTransparent: true 203 }).addTo(map); 204 this._markerLayer.on('markerclick markercontextmenu', this.onMarkerClick, this); 205 this._markerLayer.on('markerenter', this.onMarkerEnter, this); 206 this._markerLayer.on('markerleave', this.onMarkerLeave, this); 207 map.on('resize', this._setAdaptiveHeight, this); 208 setTimeout(() => this._setAdaptiveHeight(), 0); 209 return container; 210 }, 211 212 defaultEventHandle: function(_, e) { 213 L.DomEvent.stopPropagation(e); 214 return true; 215 }, 216 217 _setAdaptiveHeight: function() { 218 const mapHeight = this._map.getSize().y; 219 let maxHeight; 220 maxHeight = 221 mapHeight - 222 this._container.offsetTop - // controls above 223 // controls below 224 (this._container.parentNode.offsetHeight - this._container.offsetTop - this._container.offsetHeight) - 225 105; // margin 226 this.trackListHeight(maxHeight + 'px'); 227 }, 228 229 setExpanded: function() { 230 L.DomUtil.removeClass(this._container, 'minimized'); 231 }, 232 233 setMinimized: function() { 234 L.DomUtil.addClass(this._container, 'minimized'); 235 }, 236 237 onFileDraging: function(e) { 238 L.DomEvent.stopPropagation(e); 239 L.DomEvent.preventDefault(e); 240 e.dataTransfer.dropEffect = 'copy'; 241 }, 242 243 onFileDragDrop: function(e) { 244 L.DomEvent.stopPropagation(e); 245 L.DomEvent.preventDefault(e); 246 const files = e.dataTransfer.files; 247 if (files && files.length) { 248 this.loadFilesFromFilesObject(files); 249 } 250 }, 251 252 onEnterPressedInInput: function(this_, e) { 253 if (e.keyCode === 13) { 254 this_.loadFilesFromUrl(); 255 L.DomEvent.stop(e); 256 return false; 257 } 258 return true; 259 }, 260 261 getTrackPolylines: function(track) { 262 return track.feature.getLayers().filter(function(layer) { 263 return layer instanceof L.Polyline; 264 } 265 ); 266 }, 267 268 getTrackPoints: function(track) { 269 return track.markers; 270 }, 271 272 onButtonNewTrackClicked: function() { 273 let name = this.url().trim(); 274 if (name.length > 0) { 275 this.url(''); 276 } else { 277 name = 'New track'; 278 } 279 this.addTrackAndEdit(name); 280 }, 281 282 addSegmentAndEdit: function(track) { 283 this.stopPlacingPoint(); 284 const segment = this.addTrackSegment(track); 285 this.startEditTrackSegement(segment); 286 segment.startDrawingLine(); 287 this.trackAddingSegment(track); 288 }, 289 290 addTrackAndEdit: function(name) { 291 const track = this.addTrack({name: name}); 292 this.addSegmentAndEdit(track); 293 return track; 294 }, 295 296 loadFilesFromFilesObject: function(files) { 297 this.readingFiles(this.readingFiles() + 1); 298 299 readFiles(files).then(function(fileDataArray) { 300 const geodataArray = []; 301 for (let fileData of fileDataArray) { 302 geodataArray.push(...parseGeoFile(fileData.filename, fileData.data)); 303 } 304 this.readingFiles(this.readingFiles() - 1); 305 306 this.addTracksFromGeodataArray(geodataArray); 307 }.bind(this)); 308 }, 309 310 loadFilesFromDisk: function() { 311 logging.captureBreadcrumb('load track from disk'); 312 selectFiles(true).then(this.loadFilesFromFilesObject.bind(this)); 313 }, 314 315 loadFilesFromUrl: function() { 316 var url = this.url().trim(); 317 if (!url) { 318 return; 319 } 320 321 this.readingFiles(this.readingFiles() + 1); 322 323 logging.captureBreadcrumb('load track from url', {trackUrl: url}); 324 loadFromUrl(url) 325 .then((geodata) => { 326 this.addTracksFromGeodataArray(geodata); 327 this.readingFiles(this.readingFiles() - 1); 328 }); 329 this.url(''); 330 }, 331 332 whenLoadDone: function(cb) { 333 if (this.readingFiles() === 0) { 334 cb(); 335 return; 336 } 337 const subscription = this.readingFiles.subscribe((value) => { 338 if (value === 0) { 339 subscription.dispose(); 340 cb(); 341 } 342 }); 343 }, 344 345 addTracksFromGeodataArray: function(geodata_array) { 346 let hasData = false; 347 var messages = []; 348 if (geodata_array.length === 0) { 349 messages.push('No tracks loaded'); 350 } 351 geodata_array.forEach(function(geodata) { 352 var data_empty = !((geodata.tracks && geodata.tracks.length) || 353 (geodata.points && geodata.points.length)); 354 355 if (!data_empty) { 356 if (geodata.tracks) { 357 geodata.tracks = geodata.tracks.map(function(line) { 358 line = unwrapLatLngsCrossing180Meridian(line); 359 line = L.LineUtil.simplifyLatlngs(line, 360 / (1 << 24)); 360 if (line.length === 1) { 361 line.push(line[0]); 362 } 363 return line; 364 } 365 ); 366 } 367 hasData = true; 368 this.addTrack(geodata); 369 } 370 var error_messages = { 371 CORRUPT: 'File "{name}" is corrupt', 372 UNSUPPORTED: 'File "{name}" has unsupported format or is badly corrupt', 373 NETWORK: 'Could not download file from url "{name}"', 374 INVALID_URL: '"{name}" is not of supported URL type', 375 }; 376 var message; 377 if (geodata.error) { 378 message = error_messages[geodata.error] || geodata.error; 379 if (data_empty) { 380 message += ', no data could be loaded'; 381 } else { 382 message += ', loaded data can be invalid or incomplete'; 383 } 384 } 385 if (message) { 386 message = L.Util.template(message, {name: geodata.name}); 387 messages.push(message); 388 } 389 }.bind(this) 390 ); 391 if (messages.length) { 392 notify(messages.join('\n')); 393 } 394 return hasData; 395 }, 396 397 onTrackColorChanged: function(track) { 398 var color = this.colors[track.color()]; 399 this.getTrackPolylines(track).forEach( 400 function(polyline) { 401 polyline.setStyle({color: color}); 402 } 403 ); 404 var markers = this.getTrackPoints(track); 405 markers.forEach(this.setMarkerIcon.bind(this)); 406 if (track.visible()) { 407 this._markerLayer.updateMarkers(markers); 408 } 409 }, 410 411 onTrackVisibilityChanged: function(track) { 412 if (track.visible()) { 413 this.map.addLayer(track.feature); 414 this._markerLayer.addMarkers(track.markers); 415 } else { 416 if (this.trackAddingPoint() === track) { 417 this.stopPlacingPoint(); 418 } 419 this.map.removeLayer(track.feature); 420 this._markerLayer.removeMarkers(track.markers); 421 } 422 this.updateTrackHighlight(); 423 }, 424 425 onTrackSegmentNodesChanged: function(track, segment) { 426 if (segment.getFixedLatLngs().length > 0) { 427 this.trackAddingSegment(null); 428 } 429 this.recalculateTrackLength(track); 430 }, 431 432 recalculateTrackLength: function(track) { 433 const lines = this.getTrackPolylines(track); 434 let length = 0; 435 for (let line of lines) { 436 length += line.getLength(); 437 } 438 track.length(length); 439 }, 440 441 formatLength: function(x) { 442 var digits = 0; 443 if (x < 10000) { 444 digits = 2; 445 } else if (x < 100000) { 446 digits = 1; 447 } 448 return (x / 1000).toFixed(digits) + ' km'; 449 }, 450 451 setTrackMeasureTicksVisibility: function(track) { 452 var visible = track.measureTicksShown(), 453 lines = this.getTrackPolylines(track); 454 lines.forEach((line) => line.setMeasureTicksVisible(visible)); 455 }, 456 457 switchMeasureTicksVisibility: function(track) { 458 track.measureTicksShown(!(track.measureTicksShown())); 459 }, 460 461 onColorSelectorClicked: function(track, e) { 462 track._contextmenu.show(e); 463 }, 464 465 setViewToTrack: function(track) { 466 this.setViewToBounds(this.getTrackBounds(track)); 467 }, 468 469 setViewToAllTracks: function(immediate) { 470 const bounds = L.latLngBounds([]); 471 for (let track of this.tracks()) { 472 bounds.extend(this.getTrackBounds(track)); 473 } 474 this.setViewToBounds(bounds, immediate); 475 }, 476 477 setViewToBounds: function(bounds, immediate) { 478 if (bounds && bounds.isValid()) { 479 bounds = wrapLatLngBoundsToTarget(bounds, this.map.getCenter()); 480 if (L.Browser.mobile || immediate) { 481 this.map.fitBounds(bounds, {maxZoom: 16}); 482 } else { 483 this.map.flyToBounds(bounds, {maxZoom: 16}); 484 } 485 } 486 }, 487 488 getTrackBounds: function(track) { 489 const lines = this.getTrackPolylines(track); 490 const points = this.getTrackPoints(track); 491 const bounds = L.latLngBounds([]); 492 if (lines.length || points.length) { 493 lines.forEach((l) => { 494 if (l.getLatLngs().length > 1) { 495 bounds.extend(wrapLatLngBoundsToTarget(l.getBounds(), bounds)); 496 } 497 } 498 ); 499 points.forEach((p) => { 500 bounds.extend(wrapLatLngToTarget(p.latlng, bounds)); 501 } 502 ); 503 } 504 return bounds; 505 }, 506 507 attachColorSelector: function(track) { 508 var items = this.colors.map(function(color, index) { 509 return { 510 text: '<div style="display: inline-block; vertical-align: middle; width: 50px; height: 0; ' + 511 'border-top: 4px solid ' + color + '"></div>', 512 callback: track.color.bind(null, index) 513 }; 514 } 515 ); 516 track._contextmenu = new Contextmenu(items); 517 }, 518 519 attachActionsMenu: function(track) { 520 var items = [ 521 function() { 522 return {text: `${track.name()}`, header: true}; 523 }, 524 '-', 525 {text: 'Rename', callback: this.renameTrack.bind(this, track)}, 526 {text: 'Duplicate', callback: this.duplicateTrack.bind(this, track)}, 527 {text: 'Reverse', callback: this.reverseTrack.bind(this, track)}, 528 {text: 'Show elevation profile', callback: this.showElevationProfileForTrack.bind(this, track)}, 529 '-', 530 {text: 'Delete', callback: this.removeTrack.bind(this, track)}, 531 '-', 532 {text: 'Save as GPX', callback: () => this.saveTrackAsFile(track, geoExporters.saveGpx, '.gpx')}, 533 {text: 'Save as KML', callback: () => this.saveTrackAsFile(track, geoExporters.saveKml, '.kml')}, 534 {text: 'Copy link for track', callback: this.copyTrackLinkToClipboard.bind(this, track)}, 535 {text: 'Extra', separator: true}, 536 { 537 text: 'Save as GPX with added elevation (SRTM)', 538 callback: this.saveTrackAsFile.bind(this, track, geoExporters.saveGpxWithElevations, '.gpx', true), 539 }, 540 ]; 541 track._actionsMenu = new Contextmenu(items); 542 }, 543 544 onButtonAddSegmentClicked: function(track) { 545 if (!track.visible()) { 546 return; 547 } 548 if (this.trackAddingSegment() === track) { 549 this.trackAddingSegment(null); 550 this.stopEditLine(); 551 } else { 552 this.addSegmentAndEdit(track); 553 } 554 }, 555 556 duplicateTrack: function(track) { 557 const segments = this.getTrackPolylines(track).map((line) => 558 line.getLatLngs().map((latlng) => [latlng.lat, latlng.lng]) 559 ); 560 const points = this.getTrackPoints(track) 561 .map((point) => ({lat: point.latlng.lat, lng: point.latlng.lng, name: point.label})); 562 this.addTrack({name: track.name(), tracks: segments, points}); 563 }, 564 565 reverseTrackSegment: function(trackSegment) { 566 trackSegment.stopDrawingLine(); 567 var latlngs = trackSegment.getLatLngs(); 568 latlngs = latlngs.map(function(ll) { 569 return [ll.lat, ll.lng]; 570 } 571 ); 572 latlngs.reverse(); 573 var isEdited = (this._editedLine === trackSegment); 574 this.deleteTrackSegment(trackSegment); 575 var newTrackSegment = this.addTrackSegment(trackSegment._parentTrack, latlngs); 576 if (isEdited) { 577 this.startEditTrackSegement(newTrackSegment); 578 } 579 }, 580 581 reverseTrack: function(track) { 582 var that = this; 583 this.getTrackPolylines(track).forEach(function(trackSegment) { 584 that.reverseTrackSegment(trackSegment); 585 } 586 ); 587 }, 588 589 copyTracksLinkToClipboard: function(tracks, mouseEvent) { 590 if (!tracks.length) { 591 notify('No tracks to copy'); 592 return; 593 } 594 let serialized = tracks.map((track) => this.trackToString(track)).join('/'); 595 const hashDigest = md5(serialized, null, true); 596 const key = btoa(hashDigest).replace(/\//ug, '_').replace(/\+/ug, '-').replace(/=/ug, ''); 597 const url = window.location + '&nktl=' + key; 598 copyToClipboard(url, mouseEvent); 599 fetch(`${config.tracksStorageServer}/track/${key}`, { 600 method: 'POST', 601 data: serialized, 602 withCredentials: true 603 }).then( 604 null, (e) => { 605 let message = e.message || e; 606 if (e.xhr.status === 413) { 607 message = 'track is too big'; 608 } 609 logging.captureMessage('Failed to save track to server', 610 {status: e.xhr.status, response: e.xhr.responseText}); 611 notify('Error making link: ' + message); 612 } 613 ); 614 }, 615 616 copyTrackLinkToClipboard: function(track, mouseEvent) { 617 this.copyTracksLinkToClipboard([track], mouseEvent); 618 }, 619 620 saveTrackAsFile: async function(track, exporter, extension, addElevations = false) { 621 var lines = this.getTrackPolylines(track) 622 .map(function(line) { 623 return line.getFixedLatLngs(); 624 } 625 ); 626 lines = splitLinesAt180Meridian(lines); 627 var points = this.getTrackPoints(track); 628 let name = track.name(); 629 // Browser (Chrome) removes leading dots. 630 name = name.replace(/^\./u, '_'); 631 for (let extensions of [this.options.splitExtensionsFirstStage, this.options.splitExtensions]) { 632 let i = name.lastIndexOf('.'); 633 if (i > -1 && extensions.includes(name.slice(i + 1).toLowerCase())) { 634 name = name.slice(0, i); 635 } 636 } 637 if (lines.length === 0 && points.length === 0) { 638 notify('Track is empty, nothing to save'); 639 return; 640 } 641 642 if (addElevations) { 643 const request = [ 644 ...points.map((p) => p.latlng), 645 ...lines.reduce((acc, cur) => { 646 acc.push(...cur); 647 return acc; 648 }, []) 649 ]; 650 let elevations; 651 try { 652 elevations = await new ElevationProvider().get(request); 653 } catch (e) { 654 logging.captureException(e, 'error getting elevation for gpx'); 655 notify(`Failed to get elevation data: ${e.message}`); 656 } 657 let n = 0; 658 for (let p of points) { 659 // we make copy of latlng as we are changing it 660 p.latlng = L.latLng(p.latlng.lat, p.latlng.lng, elevations[n]); 661 n += 1; 662 } 663 for (let line of lines) { 664 for (let p of line) { 665 // we do not need to create new LatLng since splitLinesAt180Meridian() have already done it 666 p.alt = elevations[n]; 667 n += 1; 668 } 669 } 670 } 671 672 var fileText = exporter(lines, name, points); 673 var filename = name + extension; 674 saveAs(blobFromString(fileText), filename, true); 675 }, 676 677 renameTrack: function(track) { 678 var newName = query('Enter new name', track.name()); 679 if (newName && newName.length) { 680 track.name(newName); 681 } 682 }, 683 684 showTrackMenu: function(track, e) { 685 track._actionsMenu.show(e); 686 }, 687 688 showMenu: function(e) { 689 this.menu.show(e); 690 }, 691 692 stopEditLine: function() { 693 if (this._editedLine) { 694 this._editedLine.stopEdit(); 695 } 696 }, 697 698 onTrackSegmentClick: function(e) { 699 if (this.isPlacingPoint) { 700 return; 701 } 702 const trackSegment = e.target; 703 if (this._lineJoinActive) { 704 L.DomEvent.stopPropagation(e); 705 this.joinTrackSegments(trackSegment, isPointCloserToStart(e.target.getLatLngs(), e.latlng)); 706 } else { 707 this.startEditTrackSegement(trackSegment); 708 L.DomEvent.stopPropagation(e); 709 } 710 }, 711 712 startEditTrackSegement: function(polyline) { 713 if (this._editedLine && this._editedLine !== polyline) { 714 this.stopEditLine(); 715 } 716 polyline.startEdit(); 717 this._editedLine = polyline; 718 polyline.once('editend', this.onLineEditEnd, this); 719 this.fire('startedit'); 720 }, 721 722 onButtonAddPointClicked: function(track) { 723 if (!track.visible()) { 724 return; 725 } 726 if (this.trackAddingPoint() === track) { 727 this.stopPlacingPoint(); 728 } else { 729 this.beginPointCreate(track); 730 } 731 }, 732 733 _beginPointEdit: function() { 734 this.stopPlacingPoint(); 735 this.stopEditLine(); 736 L.DomUtil.addClass(this._map._container, 'leaflet-point-placing'); 737 this.isPlacingPoint = true; 738 L.DomEvent.on(document, 'keydown', this.stopPlacingPointOnEscPressed, this); 739 this.fire('startedit'); 740 }, 741 742 beginPointMove: function(marker) { 743 this._beginPointEdit(); 744 this._movingMarker = marker; 745 this.map.on('click', this.movePoint, this); 746 }, 747 748 beginPointCreate: function(track) { 749 this._beginPointEdit(); 750 this.map.on('click', this.createNewPoint, this); 751 this.trackAddingPoint(track); 752 }, 753 754 movePoint: function(e) { 755 const marker = this._movingMarker; 756 const newLatLng = e.latlng.wrap(); 757 this._markerLayer.setMarkerPosition(marker, newLatLng); 758 this.stopPlacingPoint(); 759 }, 760 761 getNewPointName: function(track) { 762 let maxNumber = 0; 763 for (let marker of track.markers) { 764 const label = marker.label; 765 if (label.match(/^\d{3}([^\d.]|$)/u)) { 766 maxNumber = parseInt(label, 10); 767 } 768 } 769 return maxNumber === 999 ? '' : String(maxNumber + 1).padStart(3, '0'); 770 }, 771 772 createNewPoint: function(e) { 773 if (!this.isPlacingPoint) { 774 return; 775 } 776 const parentTrack = this.trackAddingPoint(); 777 const name = e.suggested && this._map.suggestedPoint?.title || this.getNewPointName(parentTrack); 778 const newLatLng = e.latlng.wrap(); 779 const marker = this.addPoint(parentTrack, {name: name, lat: newLatLng.lat, lng: newLatLng.lng}); 780 this._markerLayer.addMarker(marker); 781 // we need to show prompt after marker is dispalyed; 782 // grid layer is updated in setTimout(..., 0)after adding marker 783 // it is better to do it on 'load' event but when it is fired marker is not yet displayed 784 setTimeout(() => { 785 this.renamePoint(marker); 786 this.beginPointCreate(parentTrack); 787 }, 10); 788 }, 789 790 stopPlacingPointOnEscPressed: function(e) { 791 if (e.keyCode === 27) { 792 this.stopPlacingPoint(); 793 } 794 }, 795 796 stopPlacingPoint: function() { 797 this.isPlacingPoint = false; 798 this.trackAddingPoint(null); 799 L.DomUtil.removeClass(this._map._container, 'leaflet-point-placing'); 800 L.DomEvent.off(document, 'keydown', this.stopPlacingPointOnEscPressed, this); 801 this.map.off('click', this.createNewPoint, this); 802 this.map.off('click', this.movePoint, this); 803 }, 804 805 joinTrackSegments: function(newSegment, joinToStart) { 806 this.hideLineCursor(); 807 var originalSegment = this._editedLine; 808 var latlngs = originalSegment.getLatLngs(), 809 latngs2 = newSegment.getLatLngs(); 810 if (joinToStart === this._lineJoinFromStart) { 811 latngs2.reverse(); 812 } 813 if (this._lineJoinFromStart) { 814 latlngs.unshift(...latngs2); 815 } else { 816 latlngs.push(...latngs2); 817 } 818 latlngs = latlngs.map(function(ll) { 819 return [ll.lat, ll.lng]; 820 } 821 ); 822 this.deleteTrackSegment(originalSegment); 823 if (originalSegment._parentTrack === newSegment._parentTrack) { 824 this.deleteTrackSegment(newSegment); 825 } 826 this.addTrackSegment(originalSegment._parentTrack, latlngs); 827 }, 828 829 onLineEditEnd: function(e) { 830 const polyline = e.target; 831 const track = polyline._parentTrack; 832 if (polyline.getLatLngs().length < 2) { 833 this.deleteTrackSegment(polyline); 834 } 835 if (this._editedLine === polyline) { 836 this._editedLine = null; 837 } 838 if (!this.getTrackPolylines(track).length && !this.getTrackPoints(track).length && e.userCancelled) { 839 this.removeTrack(track); 840 } 841 }, 842 843 addTrackSegment: function(track, sourcePoints) { 844 var polyline = new TrackSegment(sourcePoints || [], { 845 color: this.colors[track.color()], 846 print: true 847 } 848 ); 849 polyline._parentTrack = track; 850 polyline.setMeasureTicksVisible(track.measureTicksShown()); 851 polyline.on('click', this.onTrackSegmentClick, this); 852 polyline.on('nodeschanged', this.onTrackSegmentNodesChanged.bind(this, track, polyline)); 853 polyline.on('noderightclick', this.onNodeRightClickShowMenu, this); 854 polyline.on('segmentrightclick', this.onSegmentRightClickShowMenu, this); 855 polyline.on('mouseover', () => this.onTrackMouseEnter(track)); 856 polyline.on('mouseout', () => this.onTrackMouseLeave(track)); 857 polyline.on('editstart', () => this.onTrackEditStart(track)); 858 polyline.on('editend', () => this.onTrackEditEnd(track)); 859 polyline.on('drawend', this.onTrackSegmentDrawEnd, this); 860 861 // polyline.on('editingstart', polyline.setMeasureTicksVisible.bind(polyline, false)); 862 // polyline.on('editingend', this.setTrackMeasureTicksVisibility.bind(this, track)); 863 track.feature.addLayer(polyline); 864 this.recalculateTrackLength(track); 865 return polyline; 866 }, 867 868 onNodeRightClickShowMenu: function(e) { 869 var items = []; 870 if (e.nodeIndex > 0 && e.nodeIndex < e.line.getLatLngs().length - 1) { 871 items.push({ 872 text: 'Cut', 873 callback: this.splitTrackSegment.bind(this, e.line, e.nodeIndex, null) 874 } 875 ); 876 } 877 if (e.nodeIndex === 0 || e.nodeIndex === e.line.getLatLngs().length - 1) { 878 items.push({text: 'Join', callback: this.startLineJoinSelection.bind(this, e)}); 879 } 880 items.push({text: 'Reverse', callback: this.reverseTrackSegment.bind(this, e.line)}); 881 items.push({text: 'Shortcut', callback: this.startShortCutSelection.bind(this, e, true)}); 882 items.push({text: 'Delete segment', callback: this.deleteTrackSegment.bind(this, e.line)}); 883 items.push({text: 'New track from segment', callback: this.newTrackFromSegment.bind(this, e.line)}); 884 items.push({ 885 text: 'Show elevation profile for segment', 886 callback: this.showElevationProfileForSegment.bind(this, e.line) 887 } 888 ); 889 890 var menu = new Contextmenu(items); 891 menu.show(e.mouseEvent); 892 }, 893 894 onSegmentRightClickShowMenu: function(e) { 895 var menu = new Contextmenu([ 896 { 897 text: 'Cut', 898 callback: this.splitTrackSegment.bind(this, e.line, e.nodeIndex, e.mouseEvent.latlng) 899 }, 900 {text: 'Reverse', callback: this.reverseTrackSegment.bind(this, e.line)}, 901 {text: 'Shortcut', callback: this.startShortCutSelection.bind(this, e, false)}, 902 {text: 'Delete segment', callback: this.deleteTrackSegment.bind(this, e.line)}, 903 {text: 'New track from segment', callback: this.newTrackFromSegment.bind(this, e.line)}, 904 { 905 text: 'Show elevation profile for segment', 906 callback: this.showElevationProfileForSegment.bind(this, e.line) 907 } 908 ] 909 ); 910 menu.show(e.mouseEvent); 911 }, 912 913 showLineCursor: function(start, mousepos) { 914 this.hideLineCursor(); 915 this._editedLine.stopDrawingLine(); 916 this._lineCursor = L.polyline([start.clone(), mousepos], { 917 ...this.options.lineCursorStyle, 918 ...this.options.lineCursorInvalidStyle, 919 }).addTo(this._map); 920 this.map.on('mousemove', this.onMouseMoveOnMapForLineCursor, this); 921 this.map.on('click', this.hideLineCursor, this); 922 L.DomEvent.on(document, 'keyup', this.onKeyUpForLineCursor, this); 923 L.DomUtil.addClass(this.map.getContainer(), 'tracklist-line-cursor-shown'); 924 this._editedLine.preventStopEdit = true; 925 }, 926 927 hideLineCursor: function() { 928 if (this._lineCursor) { 929 this.map.off('mousemove', this.onMouseMoveOnMapForLineCursor, this); 930 this.map.off('click', this.hideLineCursor, this); 931 L.DomUtil.removeClass(this.map.getContainer(), 'tracklist-line-cursor-shown'); 932 L.DomEvent.off(document, 'keyup', this.onKeyUpForLineCursor, this); 933 this.map.removeLayer(this._lineCursor); 934 this._lineCursor = null; 935 this.fire('linecursorhide'); 936 this._editedLine.preventStopEdit = false; 937 } 938 }, 939 940 onMouseMoveOnMapForLineCursor: function(e) { 941 this.updateLineCursor(e.latlng, false); 942 }, 943 944 updateLineCursor: function(latlng, isValid) { 945 if (!this._lineCursor) { 946 return; 947 } 948 this._lineCursor.getLatLngs().splice(1, 1, latlng); 949 this._lineCursor.redraw(); 950 this._lineCursor.setStyle( 951 isValid ? this.options.lineCursorValidStyle : this.options.lineCursorInvalidStyle 952 ); 953 }, 954 955 onKeyUpForLineCursor: function(e) { 956 if (e.target.tagName.toLowerCase() === 'input') { 957 return; 958 } 959 switch (e.keyCode) { 960 case 27: 961 case 13: 962 this.hideLineCursor(); 963 L.DomEvent.stop(e); 964 break; 965 default: 966 } 967 }, 968 969 startLineJoinSelection: function(e) { 970 this._lineJoinFromStart = (e.nodeIndex === 0); 971 const cursorStart = this._editedLine.getLatLngs()[e.nodeIndex]; 972 this.showLineCursor(cursorStart, e.mouseEvent.latlng); 973 this.on('linecursorhide', this.onLineCursorHideForJoin, this); 974 for (let track of this.tracks()) { 975 track.feature.on('mousemove', this.onMouseMoveOnLineForJoin, this); 976 } 977 this._lineJoinActive = true; 978 this._editedLine.disableEditOnLeftClick(true); 979 }, 980 981 onMouseMoveOnLineForJoin: function(e) { 982 const latlngs = e.layer.getLatLngs(); 983 const lineJoinToStart = isPointCloserToStart(latlngs, e.latlng); 984 const cursorEnd = lineJoinToStart ? latlngs[0] : latlngs[latlngs.length - 1]; 985 L.DomEvent.stopPropagation(e); 986 this.updateLineCursor(cursorEnd, true); 987 }, 988 989 onLineCursorHideForJoin: function() { 990 for (let track of this.tracks()) { 991 track.feature.off('mousemove', this.onMouseMoveOnLineForJoin, this); 992 } 993 this.off('linecursorhide', this.onLineCursorHideForJoin, this); 994 this._editedLine.disableEditOnLeftClick(false); 995 this._lineJoinActive = false; 996 }, 997 998 startShortCutSelection: function(e, startFromNode) { 999 const line = this._editedLine; 1000 this._shortCut = {startNodeIndex: e.nodeIndex, startFromNode}; 1001 let cursorStart; 1002 if (startFromNode) { 1003 cursorStart = line.getLatLngs()[e.nodeIndex]; 1004 } else { 1005 cursorStart = closestPointToLineSegment(line.getLatLngs(), e.nodeIndex, e.mouseEvent.latlng); 1006 this._shortCut.startLatLng = cursorStart; 1007 } 1008 this.showLineCursor(cursorStart, e.mouseEvent.latlng); 1009 line.nodeMarkers.on('mousemove', this.onMouseMoveOnNodeMarkerForShortCut, this); 1010 line.segmentOverlays.on('mousemove', this.onMouseMoveOnLineSegmentForShortCut, this); 1011 this.map.on('mousemove', this.onMouseMoveOnMapForShortCut, this); 1012 line.nodeMarkers.on('click', this.onClickNodeMarkerForShortCut, this); 1013 line.segmentOverlays.on('click', this.onClickLineSegmentForShortCut, this); 1014 this.on('linecursorhide', this.onLineCursorHideForShortCut, this); 1015 line.disableEditOnLeftClick(true); 1016 }, 1017 1018 onMouseMoveOnLineSegmentForShortCut: function(e) { 1019 this.updateShortCutSelection(e, false); 1020 }, 1021 1022 onMouseMoveOnNodeMarkerForShortCut: function(e) { 1023 this.updateShortCutSelection(e, true); 1024 }, 1025 1026 onMouseMoveOnMapForShortCut: function() { 1027 this._editedLine.highlighNodesForDeletion(); 1028 }, 1029 1030 updateShortCutSelection: function(e, endAtNode) { 1031 L.DomEvent.stopPropagation(e); 1032 const line = this._editedLine; 1033 const {firstNodeToDelete, lastNodeToDelete, rangeValid} = this.getShortCutNodes(e, endAtNode); 1034 this.updateLineCursor(e.latlng, rangeValid); 1035 if (rangeValid) { 1036 line.highlighNodesForDeletion(firstNodeToDelete, lastNodeToDelete); 1037 } else { 1038 line.highlighNodesForDeletion(); 1039 } 1040 }, 1041 1042 onLineCursorHideForShortCut: function() { 1043 const line = this._editedLine; 1044 line.highlighNodesForDeletion(); 1045 line.nodeMarkers.off('mousemove', this.onMouseMoveOnNodeMarkerForShortCut, this); 1046 line.segmentOverlays.off('mousemove', this.onMouseMoveOnLineSegmentForShortCut, this); 1047 this.map.off('mousemove', this.onMouseMoveOnMapForShortCut, this); 1048 line.nodeMarkers.off('click', this.onClickNodeMarkerForShortCut, this); 1049 line.segmentOverlays.off('click', this.onClickLineSegmentForShortCut, this); 1050 this.off('linecursorhide', this.onLineCursorHideForShortCut, this); 1051 line.disableEditOnLeftClick(false); 1052 this._shortCut = null; 1053 }, 1054 1055 onClickLineSegmentForShortCut: function(e) { 1056 this.shortCutSegment(e, false); 1057 }, 1058 1059 onClickNodeMarkerForShortCut: function(e) { 1060 this.shortCutSegment(e, true); 1061 }, 1062 1063 getShortCutNodes: function(e, endAtNode) { 1064 const line = this._editedLine; 1065 let startFromNode = this._shortCut.startFromNode; 1066 let startNodeIndex = this._shortCut.startNodeIndex; 1067 let endNodeIndex = line[endAtNode ? 'getMarkerIndex' : 'getSegmentOverlayIndex'](e.layer); 1068 const newNodes = []; 1069 if (!startFromNode) { 1070 newNodes.push(this._shortCut.startLatLng); 1071 } 1072 if (!endAtNode) { 1073 newNodes.push(closestPointToLineSegment(line.getLatLngs(), endNodeIndex, e.latlng)); 1074 } 1075 let firstNodeToDelete, lastNodeToDelete; 1076 if (endNodeIndex > startNodeIndex) { 1077 firstNodeToDelete = startNodeIndex + 1; 1078 lastNodeToDelete = endNodeIndex - 1; 1079 if (!endAtNode) { 1080 lastNodeToDelete += 1; 1081 } 1082 } else { 1083 newNodes.reverse(); 1084 firstNodeToDelete = endNodeIndex + 1; 1085 lastNodeToDelete = startNodeIndex - 1; 1086 if (!startFromNode) { 1087 lastNodeToDelete += 1; 1088 } 1089 } 1090 return {firstNodeToDelete, lastNodeToDelete, newNodes, rangeValid: lastNodeToDelete >= firstNodeToDelete}; 1091 }, 1092 1093 shortCutSegment: function(e, endAtNode) { 1094 L.DomEvent.stopPropagation(e); 1095 const line = this._editedLine; 1096 const {firstNodeToDelete, lastNodeToDelete, newNodes, rangeValid} = this.getShortCutNodes(e, endAtNode); 1097 if (!rangeValid) { 1098 return; 1099 } 1100 this.stopEditLine(); 1101 line.spliceLatLngs(firstNodeToDelete, lastNodeToDelete - firstNodeToDelete + 1, ...newNodes); 1102 this.startEditTrackSegement(line); 1103 }, 1104 1105 onTrackMouseEnter: function(track) { 1106 track.hover(true); 1107 }, 1108 1109 onTrackMouseLeave: function(track) { 1110 track.hover(false); 1111 }, 1112 1113 onTrackEditStart: function(track) { 1114 track.isEdited(true); 1115 }, 1116 1117 onTrackEditEnd: function(track) { 1118 track.isEdited(false); 1119 this.hideLineCursor(); 1120 this._editedLine = null; 1121 }, 1122 1123 onTrackRowMouseEnter: function(track) { 1124 track.hover(true); 1125 }, 1126 1127 onTrackRowMouseLeave: function(track) { 1128 track.hover(false); 1129 }, 1130 1131 onTrackSegmentDrawEnd: function() { 1132 this.trackAddingSegment(null); 1133 }, 1134 1135 splitTrackSegment: function(trackSegment, nodeIndex, latlng) { 1136 var latlngs = trackSegment.getLatLngs(); 1137 latlngs = latlngs.map((latlng) => latlng.clone()); 1138 var latlngs1 = latlngs.slice(0, nodeIndex + 1), 1139 latlngs2 = latlngs.slice(nodeIndex + 1); 1140 if (latlng) { 1141 latlng = closestPointToLineSegment(latlngs, nodeIndex, latlng); 1142 latlngs1.push(latlng.clone()); 1143 } else { 1144 latlng = latlngs[nodeIndex]; 1145 } 1146 latlngs2.unshift(latlng.clone()); 1147 this.deleteTrackSegment(trackSegment); 1148 var segment1 = this.addTrackSegment(trackSegment._parentTrack, latlngs1); 1149 this.addTrackSegment(trackSegment._parentTrack, latlngs2); 1150 this.startEditTrackSegement(segment1); 1151 }, 1152 1153 deleteTrackSegment: function(trackSegment) { 1154 const track = trackSegment._parentTrack; 1155 track.feature.removeLayer(trackSegment); 1156 this.recalculateTrackLength(track); 1157 }, 1158 1159 newTrackFromSegment: function(trackSegment) { 1160 var srcNodes = trackSegment.getLatLngs(), 1161 newNodes = [], 1162 i; 1163 for (i = 0; i < srcNodes.length; i++) { 1164 newNodes.push([srcNodes[i].lat, srcNodes[i].lng]); 1165 } 1166 this.addTrack({name: "New track", tracks: [newNodes]}); 1167 }, 1168 1169 addTrack: function(geodata) { 1170 var color; 1171 color = geodata.color; 1172 if (!(color >= 0 && color < this.colors.length)) { 1173 color = this._lastTrackColor; 1174 this._lastTrackColor = (this._lastTrackColor + 1) % this.colors.length; 1175 } 1176 var track = { 1177 name: ko.observable(geodata.name), 1178 color: ko.observable(color), 1179 visible: ko.observable(!geodata.trackHidden), 1180 length: ko.observable(0), 1181 measureTicksShown: ko.observable(geodata.measureTicksShown || false), 1182 feature: L.featureGroup([]), 1183 markers: [], 1184 hover: ko.observable(false), 1185 isEdited: ko.observable(false) 1186 }; 1187 (geodata.tracks || []).forEach(this.addTrackSegment.bind(this, track)); 1188 (geodata.points || []).forEach(this.addPoint.bind(this, track)); 1189 1190 this.tracks.push(track); 1191 1192 track.visible.subscribe(this.onTrackVisibilityChanged.bind(this, track)); 1193 track.measureTicksShown.subscribe(this.setTrackMeasureTicksVisibility.bind(this, track)); 1194 track.color.subscribe(this.onTrackColorChanged.bind(this, track)); 1195 if (!L.Browser.touch) { 1196 track.feature.bindTooltip(() => track.name(), {sticky: true, delay: 500}); 1197 } 1198 track.hover.subscribe(this.onTrackHoverChanged.bind(this, track)); 1199 1200 // this.onTrackColorChanged(track); 1201 this.onTrackVisibilityChanged(track); 1202 this.attachColorSelector(track); 1203 this.attachActionsMenu(track); 1204 return track; 1205 }, 1206 1207 onTrackHoverChanged: function(track, hover) { 1208 if (hover) { 1209 this._highlightedTrack = track; 1210 } else if (this._highlightedTrack === track) { 1211 this._highlightedTrack = null; 1212 } 1213 this.updateTrackHighlight(); 1214 }, 1215 1216 updateTrackHighlight: function() { 1217 if (L.Browser.touch) { 1218 return; 1219 } 1220 if (this._trackHighlight) { 1221 this._trackHighlight.removeFrom(this._map); 1222 this._trackHighlight = null; 1223 } 1224 if (this._highlightedTrack && this._highlightedTrack.visible()) { 1225 const trackHighlight = L.featureGroup([]).addTo(this._map).bringToBack(); 1226 for (const line of this._highlightedTrack.feature.getLayers()) { 1227 let latlngs = line.getFixedLatLngs(); 1228 if (latlngs.length === 0) { 1229 continue; 1230 } 1231 L.polyline(latlngs, {...this.options.trackHighlightStyle, interactive: false}).addTo( 1232 trackHighlight 1233 ); 1234 const start = latlngs[0]; 1235 const end = latlngs[latlngs.length - 1]; 1236 L.polyline([start, start], {...this.options.trackStartHighlightStyle, interactive: false}).addTo( 1237 trackHighlight 1238 ); 1239 L.polyline([end, end], {...this.options.trackEndHighlightStyle, interactive: false}).addTo( 1240 trackHighlight 1241 ); 1242 } 1243 for (const marker of this._highlightedTrack.markers) { 1244 const latlng = marker.latlng.clone(); 1245 L.polyline([latlng, latlng], {...this.options.trackMarkerHighlightStyle, interactive: false}).addTo( 1246 trackHighlight 1247 ); 1248 } 1249 this._trackHighlight = trackHighlight; 1250 } 1251 }, 1252 1253 setMarkerIcon: function(marker) { 1254 var symbol = 'marker', 1255 colorInd = marker._parentTrack.color() + 1, 1256 className = 'symbol-' + symbol + '-' + colorInd; 1257 marker.icon = iconFromBackgroundImage('track-waypoint ' + className); 1258 }, 1259 1260 setMarkerLabel: function(marker, label) { 1261 marker.label = label; 1262 }, 1263 1264 addPoint: function(track, srcPoint) { 1265 var marker = { 1266 latlng: L.latLng([srcPoint.lat, srcPoint.lng]), 1267 _parentTrack: track, 1268 }; 1269 this.setMarkerIcon(marker); 1270 this.setMarkerLabel(marker, srcPoint.name); 1271 track.markers.push(marker); 1272 marker._parentTrack = track; 1273 return marker; 1274 }, 1275 1276 onMarkerClick: function(e) { 1277 new Contextmenu([ 1278 {text: e.marker.label, header: true}, 1279 '-', 1280 {text: 'Rename', callback: this.renamePoint.bind(this, e.marker)}, 1281 {text: 'Move', callback: this.beginPointMove.bind(this, e.marker)}, 1282 {text: 'Delete', callback: this.removePoint.bind(this, e.marker)}, 1283 ] 1284 ).show(e); 1285 }, 1286 1287 onMarkerEnter: function(e) { 1288 e.marker._parentTrack.hover(true); 1289 }, 1290 1291 onMarkerLeave: function(e) { 1292 e.marker._parentTrack.hover(false); 1293 }, 1294 1295 removePoint: function(marker) { 1296 this.stopPlacingPoint(); 1297 this._markerLayer.removeMarker(marker); 1298 const markers = marker._parentTrack.markers; 1299 const i = markers.indexOf(marker); 1300 markers.splice(i, 1); 1301 }, 1302 1303 renamePoint: function(marker) { 1304 this.stopPlacingPoint(); 1305 var newLabel = query('New point name', marker.label); 1306 if (newLabel !== null) { 1307 this.setMarkerLabel(marker, newLabel); 1308 this._markerLayer.updateMarker(marker); 1309 } 1310 }, 1311 1312 removeTrack: function(track) { 1313 track.visible(false); 1314 this.tracks.remove(track); 1315 }, 1316 1317 deleteAllTracks: function() { 1318 var tracks = this.tracks().slice(0), 1319 i; 1320 for (i = 0; i < tracks.length; i++) { 1321 this.removeTrack(tracks[i]); 1322 } 1323 }, 1324 1325 deleteHiddenTracks: function() { 1326 var tracks = this.tracks().slice(0), 1327 i, track; 1328 for (i = 0; i < tracks.length; i++) { 1329 track = tracks[i]; 1330 if (!track.visible()) { 1331 this.removeTrack(tracks[i]); 1332 } 1333 } 1334 }, 1335 1336 trackToString: function(track, forceVisible) { 1337 var lines = this.getTrackPolylines(track).map(function(line) { 1338 var points = line.getFixedLatLngs(); 1339 points = L.LineUtil.simplifyLatlngs(points, 360 / (1 << 24)); 1340 return points; 1341 } 1342 ); 1343 return geoExporters.saveToString(lines, track.name(), track.color(), track.measureTicksShown(), 1344 this.getTrackPoints(track), forceVisible ? false : !track.visible() 1345 ); 1346 }, 1347 1348 copyAllTracksToClipboard: function(mouseEvent) { 1349 this.copyTracksLinkToClipboard(this.tracks(), mouseEvent); 1350 }, 1351 1352 copyVisibleTracksToClipboard: function(mouseEvent) { 1353 const tracks = this.tracks().filter((track) => track.visible()); 1354 this.copyTracksLinkToClipboard(tracks, mouseEvent); 1355 }, 1356 1357 createNewTrackFromVisibleTracks: function() { 1358 const tracks = this.tracks().filter((track) => track.visible()); 1359 if (tracks.length === 0) { 1360 return; 1361 } 1362 let newTrackName = tracks[0].name(); 1363 newTrackName = query('New track name', newTrackName); 1364 if (newTrackName === null) { 1365 return; 1366 } 1367 1368 const newTrackSegments = []; 1369 const newTrackPoints = []; 1370 1371 for (const track of tracks) { 1372 for (let segment of this.getTrackPolylines(track)) { 1373 const points = segment.getFixedLatLngs().map(({lat, lng}) => ({lat, lng})); 1374 newTrackSegments.push(points); 1375 } 1376 const points = this.getTrackPoints(track).map((point) => ({ 1377 lat: point.latlng.lat, 1378 lng: point.latlng.lng, 1379 name: point.label 1380 })); 1381 newTrackPoints.push(...points); 1382 } 1383 1384 this.addTrack({name: newTrackName, tracks: newTrackSegments, points: newTrackPoints}); 1385 }, 1386 1387 exportTracks: function(minTicksIntervalMeters) { 1388 var that = this; 1389 /* eslint-disable max-nested-callbacks */ 1390 return this.tracks() 1391 .filter(function(track) { 1392 return that.getTrackPolylines(track).length; 1393 } 1394 ) 1395 .map(function(track) { 1396 var capturedTrack = track.feature.getLayers().map(function(pl) { 1397 return pl.getLatLngs().map(function(ll) { 1398 return [ll.lat, ll.lng]; 1399 } 1400 ); 1401 } 1402 ); 1403 var bounds = track.feature.getBounds(); 1404 var capturedBounds = [ 1405 [bounds.getSouth(), bounds.getWest()], 1406 [bounds.getNorth(), bounds.getEast()] 1407 ]; 1408 return { 1409 color: track.color(), 1410 visible: track.visible(), 1411 segments: capturedTrack, 1412 bounds: capturedBounds, 1413 measureTicksShown: track.measureTicksShown(), 1414 measureTicks: [].concat(...track.feature.getLayers().map(function(pl) { 1415 return pl.getTicksPositions(minTicksIntervalMeters); 1416 } 1417 ) 1418 ) 1419 }; 1420 } 1421 ); 1422 /* eslint-enable max-nested-callbacks */ 1423 }, 1424 1425 showElevationProfileForSegment: function(line) { 1426 this.hideElevationProfile(); 1427 this.stopEditLine(); 1428 this._elevationControl = new ElevationProfile(this._map, line.getLatLngs(), { 1429 samplingInterval: calcSamplingInterval(line.getLength()) 1430 } 1431 ); 1432 this.fire('elevation-shown'); 1433 }, 1434 1435 showElevationProfileForTrack: function(track) { 1436 var lines = this.getTrackPolylines(track), 1437 path = [], 1438 i; 1439 for (i = 0; i < lines.length; i++) { 1440 if (lines[i] === this._editedLine) { 1441 this.stopEditLine(); 1442 } 1443 path = path.concat(lines[i].getLatLngs()); 1444 } 1445 this.hideElevationProfile(); 1446 this._elevationControl = new ElevationProfile(this._map, path, { 1447 samplingInterval: calcSamplingInterval(new L.MeasuredLine(path).getLength()) 1448 } 1449 ); 1450 this.fire('elevation-shown'); 1451 }, 1452 1453 hideElevationProfile: function() { 1454 if (this._elevationControl) { 1455 this._elevationControl.removeFrom(this._map); 1456 } 1457 this._elevationControl = null; 1458 }, 1459 1460 hasTracks: function() { 1461 return this.tracks().length > 0; 1462 } 1463 } 1464 ); 1465 1466 export {TRACKLIST_TRACK_COLORS};