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