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