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