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