track-list.js (68263B)
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 Etomesto 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 loadFromUrl(url) 380 .then((geodata) => { 381 this.addTracksFromGeodataArray(geodata); 382 this.readingFiles(this.readingFiles() - 1); 383 }); 384 this.url(''); 385 }, 386 387 whenLoadDone: function(cb) { 388 if (this.readingFiles() === 0) { 389 cb(); 390 return; 391 } 392 const subscription = this.readingFiles.subscribe((value) => { 393 if (value === 0) { 394 subscription.dispose(); 395 cb(); 396 } 397 }); 398 }, 399 400 addTracksFromGeodataArray: function(geodata_array, allowEmpty = false) { 401 let hasData = false; 402 var messages = []; 403 if (geodata_array.length === 0) { 404 messages.push('No tracks loaded'); 405 } 406 geodata_array.forEach(function(geodata) { 407 var data_empty = !((geodata.tracks && geodata.tracks.length) || 408 (geodata.points && geodata.points.length)); 409 410 if (!data_empty || allowEmpty) { 411 if (geodata.tracks) { 412 geodata.tracks = geodata.tracks.map(function(line) { 413 line = unwrapLatLngsCrossing180Meridian(line); 414 line = L.LineUtil.simplifyLatlngs(line, 360 / (1 << 24)); 415 if (line.length === 1) { 416 line.push(line[0]); 417 } 418 return line; 419 } 420 ); 421 } 422 hasData = true; 423 this.addTrack(geodata); 424 } 425 var error_messages = { 426 CORRUPT: 'File "{name}" is corrupt', 427 UNSUPPORTED: 'File "{name}" has unsupported format or is badly corrupt', 428 NETWORK: 'Could not download file from url "{name}"', 429 INVALID_URL: '"{name}" is not of supported URL type', 430 }; 431 var message; 432 if (geodata.error) { 433 message = error_messages[geodata.error] || geodata.error; 434 if (data_empty) { 435 message += ', no data could be loaded'; 436 } else { 437 message += ', loaded data can be invalid or incomplete'; 438 } 439 } else if (data_empty && !allowEmpty) { 440 message = 441 'No data could be loaded from file "{name}". ' + 442 'File is empty or contains only unsupported data.'; 443 } 444 if (message) { 445 message = L.Util.template(message, {name: geodata.name}); 446 messages.push(message); 447 } 448 }.bind(this) 449 ); 450 if (messages.length) { 451 notify(messages.join('\n')); 452 } 453 return hasData; 454 }, 455 456 onTrackColorChanged: function(track) { 457 var color = this.colors[track.color()]; 458 this.getTrackPolylines(track).forEach( 459 function(polyline) { 460 polyline.setStyle({color: color}); 461 } 462 ); 463 var markers = this.getTrackPoints(track); 464 markers.forEach(this.setMarkerIcon.bind(this)); 465 if (track.visible()) { 466 this._markerLayer.updateMarkers(markers); 467 } 468 this.notifyTracksChanged(); 469 }, 470 471 onTrackVisibilityChanged: function(track) { 472 if (track.visible()) { 473 this.map.addLayer(track.feature); 474 this._markerLayer.addMarkers(track.markers); 475 } else { 476 if (this.trackAddingPoint() === track) { 477 this.stopPlacingPoint(); 478 } 479 this.map.removeLayer(track.feature); 480 this._markerLayer.removeMarkers(track.markers); 481 } 482 this.updateTrackHighlight(); 483 this.notifyTracksChanged(); 484 }, 485 486 onTrackSegmentNodesChanged: function(track, segment) { 487 if (segment.getFixedLatLngs().length > 0) { 488 this.trackAddingSegment(null); 489 } 490 this.recalculateTrackLength(track); 491 this.notifyTracksChanged(); 492 }, 493 494 recalculateTrackLength: function(track) { 495 const lines = this.getTrackPolylines(track); 496 let length = 0; 497 for (let line of lines) { 498 length += line.getLength(); 499 } 500 track.length(length); 501 }, 502 503 formatLength: function(x) { 504 var digits = 0; 505 if (x < 10000) { 506 digits = 2; 507 } else if (x < 100000) { 508 digits = 1; 509 } 510 return (x / 1000).toFixed(digits) + ' km'; 511 }, 512 513 formatArea: function(sqMeters) { 514 let value, units; 515 if (sqMeters < 100_000) { 516 value = sqMeters; 517 units = 'm²'; 518 } else { 519 value = sqMeters / 1_000_000; 520 units = 'km²'; 521 } 522 let options; 523 if (value < 10) { 524 options = {maximumFractionDigits: 2, minimumFractionDigits: 2}; 525 } else if (value < 100) { 526 options = {maximumFractionDigits: 1, minimumFractionDigits: 1}; 527 } else { 528 options = {maximumSignificantDigits: 3}; 529 } 530 const formattedValue = value.toLocaleString('ru-RU', options); 531 return `${formattedValue} ${units}`; 532 }, 533 534 setTrackMeasureTicksVisibility: function(track) { 535 var visible = track.measureTicksShown(), 536 lines = this.getTrackPolylines(track); 537 lines.forEach((line) => line.setMeasureTicksVisible(visible)); 538 this.notifyTracksChanged(); 539 }, 540 541 switchMeasureTicksVisibility: function(track) { 542 track.measureTicksShown(!(track.measureTicksShown())); 543 }, 544 545 onColorSelectorClicked: function(track, e) { 546 track._contextmenu.show(e); 547 }, 548 549 setViewToTrack: function(track) { 550 this.setViewToBounds(this.getTrackBounds(track)); 551 }, 552 553 setViewToAllTracks: function(immediate) { 554 const bounds = L.latLngBounds([]); 555 for (let track of this.tracks()) { 556 bounds.extend(this.getTrackBounds(track)); 557 } 558 this.setViewToBounds(bounds, immediate); 559 }, 560 561 setViewToBounds: function(bounds, immediate) { 562 if (bounds && bounds.isValid()) { 563 bounds = wrapLatLngBoundsToTarget(bounds, this.map.getCenter()); 564 if (L.Browser.mobile || immediate) { 565 this.map.fitBounds(bounds, {maxZoom: 16}); 566 } else { 567 this.map.flyToBounds(bounds, {maxZoom: 16}); 568 } 569 } 570 }, 571 572 getTrackBounds: function(track) { 573 const lines = this.getTrackPolylines(track); 574 const points = this.getTrackPoints(track); 575 const bounds = L.latLngBounds([]); 576 if (lines.length || points.length) { 577 lines.forEach((l) => { 578 if (l.getLatLngs().length > 1) { 579 bounds.extend(wrapLatLngBoundsToTarget(l.getBounds(), bounds)); 580 } 581 } 582 ); 583 points.forEach((p) => { 584 bounds.extend(wrapLatLngToTarget(p.latlng, bounds)); 585 } 586 ); 587 } 588 return bounds; 589 }, 590 591 attachColorSelector: function(track) { 592 var items = this.colors.map(function(color, index) { 593 return { 594 text: '<div style="display: inline-block; vertical-align: middle; width: 50px; height: 0; ' + 595 'border-top: 4px solid ' + color + '"></div>', 596 callback: track.color.bind(null, index) 597 }; 598 } 599 ); 600 track._contextmenu = new Contextmenu(items); 601 }, 602 603 attachActionsMenu: function(track) { 604 var items = [ 605 function() { 606 return {text: `${track.name()}`, header: true}; 607 }, 608 '-', 609 {text: 'Rename', callback: this.renameTrack.bind(this, track)}, 610 {text: 'Duplicate', callback: this.duplicateTrack.bind(this, track)}, 611 {text: 'Reverse', callback: this.reverseTrack.bind(this, track)}, 612 {text: 'Show elevation profile', callback: this.showElevationProfileForTrack.bind(this, track)}, 613 '-', 614 {text: 'Delete', callback: this.removeTrack.bind(this, track)}, 615 '-', 616 {text: 'Save as GPX', callback: () => this.saveTrackAsFile(track, geoExporters.saveGpx, '.gpx')}, 617 {text: 'Save as KML', callback: () => this.saveTrackAsFile(track, geoExporters.saveKml, '.kml')}, 618 {text: 'Copy link for track', callback: this.copyTrackLinkToClipboard.bind(this, track)}, 619 {text: 'Extra', separator: true}, 620 { 621 text: 'Save as GPX with added elevation (SRTM)', 622 callback: this.saveTrackAsFile.bind(this, track, geoExporters.saveGpxWithElevations, '.gpx', true), 623 }, 624 ]; 625 track._actionsMenu = new Contextmenu(items); 626 }, 627 628 onButtonAddSegmentClicked: function(track) { 629 if (!track.visible()) { 630 return; 631 } 632 if (this.trackAddingSegment() === track) { 633 this.trackAddingSegment(null); 634 this.stopEditLine(); 635 } else { 636 this.addSegmentAndEdit(track); 637 } 638 }, 639 640 duplicateTrack: function(track) { 641 const segments = this.getTrackPolylines(track).map((line) => 642 line.getLatLngs().map((latlng) => [latlng.lat, latlng.lng]) 643 ); 644 const points = this.getTrackPoints(track) 645 .map((point) => ({lat: point.latlng.lat, lng: point.latlng.lng, name: point.label})); 646 this.addTrack({name: track.name(), tracks: segments, points}); 647 }, 648 649 reverseTrackSegment: function(trackSegment) { 650 trackSegment.stopDrawingLine(); 651 var latlngs = trackSegment.getLatLngs(); 652 latlngs = latlngs.map(function(ll) { 653 return [ll.lat, ll.lng]; 654 } 655 ); 656 latlngs.reverse(); 657 var isEdited = (this._editedLine === trackSegment); 658 this.deleteTrackSegment(trackSegment); 659 var newTrackSegment = this.addTrackSegment(trackSegment._parentTrack, latlngs); 660 if (isEdited) { 661 this.startEditTrackSegement(newTrackSegment); 662 } 663 }, 664 665 reverseTrack: function(track) { 666 var that = this; 667 this.getTrackPolylines(track).forEach(function(trackSegment) { 668 that.reverseTrackSegment(trackSegment); 669 } 670 ); 671 }, 672 673 serializeTracks: function(tracks) { 674 return tracks.map((track) => this.trackToString(track)).join('/'); 675 }, 676 677 copyTracksLinkToClipboard: function(tracks, mouseEvent, allowWithoutTracks = false) { 678 if (!tracks.length) { 679 if (allowWithoutTracks) { 680 const url = getLinkToShare(this.options.keysToExcludeOnCopyLink); 681 copyToClipboard(url, mouseEvent); 682 return; 683 } 684 notify('No tracks to copy'); 685 return; 686 } 687 let serialized = this.serializeTracks(tracks); 688 const hashDigest = md5(serialized, null, true); 689 const key = btoa(hashDigest).replace(/\//ug, '_').replace(/\+/ug, '-').replace(/=/ug, ''); 690 const url = getLinkToShare(this.options.keysToExcludeOnCopyLink, {nktl: key}); 691 copyToClipboard(url, mouseEvent); 692 fetch(`${config.tracksStorageServer}/track/${key}`, { 693 method: 'POST', 694 data: serialized, 695 withCredentials: true 696 }).then( 697 null, (e) => { 698 let message = e.message || e; 699 if (e.xhr.status === 413) { 700 message = 'track is too big'; 701 } 702 logging.captureMessage('Failed to save track to server', 703 {status: e.xhr.status, response: e.xhr.responseText}); 704 notify('Error making link: ' + message); 705 } 706 ); 707 }, 708 709 copyTrackLinkToClipboard: function(track, mouseEvent) { 710 this.copyTracksLinkToClipboard([track], mouseEvent); 711 }, 712 713 exportTrackAsFile: async function(track, exporter, extension, addElevations, allowEmpty) { 714 var lines = this.getTrackPolylines(track) 715 .map(function(line) { 716 return line.getFixedLatLngs(); 717 } 718 ); 719 lines = splitLinesAt180Meridian(lines); 720 var points = this.getTrackPoints(track); 721 let name = track.name(); 722 // Browser (Chrome) removes leading dots. Also we do not want to create hidden files on Linux 723 name = name.replace(/^\./u, '_'); 724 for (let extensions of [this.options.splitExtensionsFirstStage, this.options.splitExtensions]) { 725 let i = name.lastIndexOf('.'); 726 if (i > -1 && extensions.includes(name.slice(i + 1).toLowerCase())) { 727 name = name.slice(0, i); 728 } 729 } 730 if (!allowEmpty && lines.length === 0 && points.length === 0) { 731 return {error: 'Track is empty, nothing to save'}; 732 } 733 734 if (addElevations) { 735 const request = [ 736 ...points.map((p) => p.latlng), 737 ...lines.reduce((acc, cur) => { 738 acc.push(...cur); 739 return acc; 740 }, []) 741 ]; 742 let elevations; 743 try { 744 elevations = await new ElevationProvider().get(request); 745 } catch (e) { 746 logging.captureException(e, 'error getting elevation for gpx'); 747 notify(`Failed to get elevation data: ${e.message}`); 748 } 749 let n = 0; 750 for (let p of points) { 751 // we make copy of latlng as we are changing it 752 p.latlng = L.latLng(p.latlng.lat, p.latlng.lng, elevations[n]); 753 n += 1; 754 } 755 for (let line of lines) { 756 for (let p of line) { 757 // we do not need to create new LatLng since splitLinesAt180Meridian() have already done it 758 p.alt = elevations[n]; 759 n += 1; 760 } 761 } 762 } 763 764 return { 765 content: exporter(lines, name, points), 766 filename: name + extension, 767 }; 768 }, 769 770 saveTrackAsFile: async function(track, exporter, extension, addElevations = false) { 771 const {error, content, filename} = await this.exportTrackAsFile( 772 track, exporter, extension, addElevations, false 773 ); 774 if (error) { 775 notify(error); 776 return; 777 } 778 saveAs(blobFromString(content), filename, true); 779 }, 780 781 renameTrack: function(track) { 782 var newName = query('Enter new name', track.name()); 783 if (newName && newName.length) { 784 track.name(newName); 785 this.notifyTracksChanged(); 786 } 787 }, 788 789 showTrackMenu: function(track, e) { 790 track._actionsMenu.show(e); 791 }, 792 793 showMenu: function(e) { 794 this.menu.show(e); 795 }, 796 797 stopEditLine: function() { 798 if (this._editedLine) { 799 this._editedLine.stopEdit(); 800 } 801 }, 802 803 onTrackSegmentClick: function(e) { 804 if (this.isPlacingPoint) { 805 return; 806 } 807 const trackSegment = e.target; 808 if (this._lineJoinActive) { 809 L.DomEvent.stopPropagation(e); 810 this.joinTrackSegments(trackSegment, isPointCloserToStart(e.target.getLatLngs(), e.latlng)); 811 } else { 812 this.startEditTrackSegement(trackSegment); 813 L.DomEvent.stopPropagation(e); 814 } 815 }, 816 817 startEditTrackSegement: function(polyline) { 818 if (this._editedLine && this._editedLine !== polyline) { 819 this.stopEditLine(); 820 } 821 polyline.startEdit(); 822 this._editedLine = polyline; 823 polyline.once('editend', this.onLineEditEnd, this); 824 this.fire('startedit'); 825 }, 826 827 onButtonAddPointClicked: function(track) { 828 if (!track.visible()) { 829 return; 830 } 831 if (this.trackAddingPoint() === track) { 832 this.stopPlacingPoint(); 833 } else { 834 this.beginPointCreate(track); 835 } 836 }, 837 838 _beginPointEdit: function() { 839 this.stopPlacingPoint(); 840 this.stopEditLine(); 841 L.DomUtil.addClass(this._map._container, 'leaflet-point-placing'); 842 this.isPlacingPoint = true; 843 L.DomEvent.on(document, 'keydown', this.stopPlacingPointOnEscPressed, this); 844 this.fire('startedit'); 845 }, 846 847 beginPointMove: function(marker) { 848 this._beginPointEdit(); 849 this._movingMarker = marker; 850 this.map.on('click', this.movePoint, this); 851 }, 852 853 copyPointCoordinatesToClipboard: function(marker, e) { 854 const {lat, lng} = coordFormats.formatLatLng(marker.latlng.wrap(), coordFormats.SIGNED_DEGREES); 855 copyToClipboard(`${lat} ${lng}`, e.originalEvent); 856 }, 857 858 beginPointCreate: function(track) { 859 this._beginPointEdit(); 860 this.map.on('click', this.createNewPoint, this); 861 this.trackAddingPoint(track); 862 }, 863 864 movePoint: function(e) { 865 const marker = this._movingMarker; 866 const newLatLng = e.latlng.wrap(); 867 this._markerLayer.setMarkerPosition(marker, newLatLng); 868 this.stopPlacingPoint(); 869 this.notifyTracksChanged(); 870 }, 871 872 getNewPointName: function(track) { 873 let maxNumber = 0; 874 for (let marker of track.markers) { 875 const label = marker.label; 876 if (label.match(/^\d{3}([^\d.]|$)/u)) { 877 maxNumber = parseInt(label, 10); 878 } 879 } 880 return maxNumber === 999 ? '' : String(maxNumber + 1).padStart(3, '0'); 881 }, 882 883 createNewPoint: function(e) { 884 if (!this.isPlacingPoint) { 885 return; 886 } 887 const parentTrack = this.trackAddingPoint(); 888 const name = e.suggested && this._map.suggestedPoint?.title || this.getNewPointName(parentTrack); 889 const newLatLng = e.latlng.wrap(); 890 const marker = this.addPoint(parentTrack, {name: name, lat: newLatLng.lat, lng: newLatLng.lng}); 891 this._markerLayer.addMarker(marker); 892 this.notifyTracksChanged(); 893 // we need to show prompt after marker is dispalyed; 894 // grid layer is updated in setTimout(..., 0)after adding marker 895 // it is better to do it on 'load' event but when it is fired marker is not yet displayed 896 setTimeout(() => { 897 this.renamePoint(marker); 898 this.beginPointCreate(parentTrack); 899 }, 10); 900 }, 901 902 stopPlacingPointOnEscPressed: function(e) { 903 if (e.keyCode === 27) { 904 this.stopPlacingPoint(); 905 } 906 }, 907 908 stopPlacingPoint: function() { 909 this.isPlacingPoint = false; 910 this.trackAddingPoint(null); 911 L.DomUtil.removeClass(this._map._container, 'leaflet-point-placing'); 912 L.DomEvent.off(document, 'keydown', this.stopPlacingPointOnEscPressed, this); 913 this.map.off('click', this.createNewPoint, this); 914 this.map.off('click', this.movePoint, this); 915 }, 916 917 joinTrackSegments: function(newSegment, joinToStart) { 918 this.hideLineCursor(); 919 var originalSegment = this._editedLine; 920 var latlngs = originalSegment.getLatLngs(), 921 latngs2 = newSegment.getLatLngs(); 922 if (joinToStart === this._lineJoinFromStart) { 923 latngs2.reverse(); 924 } 925 if (this._lineJoinFromStart) { 926 latlngs.unshift(...latngs2); 927 } else { 928 latlngs.push(...latngs2); 929 } 930 latlngs = latlngs.map(function(ll) { 931 return [ll.lat, ll.lng]; 932 } 933 ); 934 this.deleteTrackSegment(originalSegment); 935 if (originalSegment._parentTrack === newSegment._parentTrack) { 936 this.deleteTrackSegment(newSegment); 937 } 938 this.addTrackSegment(originalSegment._parentTrack, latlngs); 939 }, 940 941 onLineEditEnd: function(e) { 942 const polyline = e.target; 943 const track = polyline._parentTrack; 944 if (polyline.getLatLngs().length < 2) { 945 this.deleteTrackSegment(polyline); 946 } 947 if (this._editedLine === polyline) { 948 this._editedLine = null; 949 } 950 if (!this.getTrackPolylines(track).length && !this.getTrackPoints(track).length && e.userCancelled) { 951 this.removeTrack(track); 952 } 953 }, 954 955 formatSegmentTooltip: function(segment) { 956 const track = segment._parentTrack; 957 const trackSegments = this.getTrackPolylines(track); 958 const trackSegmentsCount = trackSegments.length; 959 const segmentOrdinalNumber = trackSegments.indexOf(segment) + 1; 960 961 // avoid slow calculation of self-intersections due to brute-force algorithm 962 const MAX_POINTS_FOR_INTERSECTIONS_CALCULATION = 1000; 963 // avoid noticeable errors in area calculations due to usage of approximate algorithm 964 const MAX_EXTENT_WIDTH = 10; 965 const MAX_EXTENT_HEIGHT = 5; 966 967 let segmentArea; 968 let points = segment.getLatLngs(); 969 if (points.length > 1 && points[0].equals(points.at(-1))) { 970 points = points.slice(0, -1); 971 } 972 if (points.length > MAX_POINTS_FOR_INTERSECTIONS_CALCULATION) { 973 segmentArea = '-- <span class="track-tooltip-area-calc-error">(too many points)</span>'; 974 } 975 if (!segmentArea) { 976 const bounds = L.latLngBounds(points); 977 if ( 978 bounds.getEast() - bounds.getWest() > MAX_EXTENT_WIDTH || 979 bounds.getNorth() - bounds.getSouth() > MAX_EXTENT_HEIGHT 980 ) { 981 segmentArea = '-- <span class="track-tooltip-area-calc-error">(too big extent)</span>'; 982 } 983 } 984 if (!segmentArea && polylineHasSelfIntersections(points)) { 985 segmentArea = '-- <span class="track-tooltip-area-calc-error">(self-intersection)</span>'; 986 } 987 if (!segmentArea) { 988 segmentArea = this.formatArea(polygonArea(points)); 989 } 990 return ` 991 <b>${track.name()}</b><br> 992 <br> 993 Segment number: ${segmentOrdinalNumber} / ${trackSegmentsCount}<br> 994 Segment length: ${this.formatLength(segment.getLength())}<br> 995 Segment area: ${segmentArea} 996 `; 997 }, 998 999 addTrackSegment: function(track, sourcePoints) { 1000 var polyline = new TrackSegment(sourcePoints || [], { 1001 color: this.colors[track.color()], 1002 print: true 1003 } 1004 ); 1005 polyline._parentTrack = track; 1006 polyline.setMeasureTicksVisible(track.measureTicksShown()); 1007 polyline.on('click', this.onTrackSegmentClick, this); 1008 polyline.on('nodeschanged', this.onTrackSegmentNodesChanged.bind(this, track, polyline)); 1009 polyline.on('noderightclick', this.onNodeRightClickShowMenu, this); 1010 polyline.on('segmentrightclick', this.onSegmentRightClickShowMenu, this); 1011 polyline.on('mouseover', () => this.onTrackMouseEnter(track)); 1012 polyline.on('mouseout', () => this.onTrackMouseLeave(track)); 1013 polyline.on('editstart', () => this.onTrackEditStart(track)); 1014 polyline.on('editend', () => this.onTrackEditEnd(track)); 1015 polyline.on('drawend', this.onTrackSegmentDrawEnd, this); 1016 1017 if (!L.Browser.touch) { 1018 polyline.bindTooltip(() => this.formatSegmentTooltip(polyline), {sticky: true, delay: 500}); 1019 } 1020 1021 // polyline.on('editingstart', polyline.setMeasureTicksVisible.bind(polyline, false)); 1022 // polyline.on('editingend', this.setTrackMeasureTicksVisibility.bind(this, track)); 1023 track.feature.addLayer(polyline); 1024 this.recalculateTrackLength(track); 1025 this.notifyTracksChanged(); 1026 return polyline; 1027 }, 1028 1029 onNodeRightClickShowMenu: function(e) { 1030 var items = []; 1031 if (e.nodeIndex > 0 && e.nodeIndex < e.line.getLatLngs().length - 1) { 1032 items.push({ 1033 text: 'Cut', 1034 callback: this.splitTrackSegment.bind(this, e.line, e.nodeIndex, null) 1035 } 1036 ); 1037 } 1038 if (e.nodeIndex === 0 || e.nodeIndex === e.line.getLatLngs().length - 1) { 1039 items.push({text: 'Join', callback: this.startLineJoinSelection.bind(this, e)}); 1040 } 1041 items.push({text: 'Reverse', callback: this.reverseTrackSegment.bind(this, e.line)}); 1042 items.push({text: 'Shortcut', callback: this.startShortCutSelection.bind(this, e, true)}); 1043 items.push({text: 'Delete segment', callback: this.deleteTrackSegment.bind(this, e.line)}); 1044 items.push({text: 'New track from segment', callback: this.newTrackFromSegment.bind(this, e.line)}); 1045 items.push({ 1046 text: 'Show elevation profile for segment', 1047 callback: this.showElevationProfileForSegment.bind(this, e.line) 1048 } 1049 ); 1050 1051 var menu = new Contextmenu(items); 1052 menu.show(e.mouseEvent); 1053 }, 1054 1055 onSegmentRightClickShowMenu: function(e) { 1056 var menu = new Contextmenu([ 1057 { 1058 text: 'Cut', 1059 callback: this.splitTrackSegment.bind(this, e.line, e.nodeIndex, e.mouseEvent.latlng) 1060 }, 1061 {text: 'Reverse', callback: this.reverseTrackSegment.bind(this, e.line)}, 1062 {text: 'Shortcut', callback: this.startShortCutSelection.bind(this, e, false)}, 1063 {text: 'Delete segment', callback: this.deleteTrackSegment.bind(this, e.line)}, 1064 {text: 'New track from segment', callback: this.newTrackFromSegment.bind(this, e.line)}, 1065 { 1066 text: 'Show elevation profile for segment', 1067 callback: this.showElevationProfileForSegment.bind(this, e.line) 1068 } 1069 ] 1070 ); 1071 menu.show(e.mouseEvent); 1072 }, 1073 1074 showLineCursor: function(start, mousepos) { 1075 this.hideLineCursor(); 1076 this._editedLine.stopDrawingLine(); 1077 this._lineCursor = L.polyline([start.clone(), mousepos], { 1078 ...this.options.lineCursorStyle, 1079 ...this.options.lineCursorInvalidStyle, 1080 }).addTo(this._map); 1081 this.map.on('mousemove', this.onMouseMoveOnMapForLineCursor, this); 1082 this.map.on('click', this.hideLineCursor, this); 1083 L.DomEvent.on(document, 'keyup', this.onKeyUpForLineCursor, this); 1084 L.DomUtil.addClass(this.map.getContainer(), 'tracklist-line-cursor-shown'); 1085 this._editedLine.preventStopEdit = true; 1086 }, 1087 1088 hideLineCursor: function() { 1089 if (this._lineCursor) { 1090 this.map.off('mousemove', this.onMouseMoveOnMapForLineCursor, this); 1091 this.map.off('click', this.hideLineCursor, this); 1092 L.DomUtil.removeClass(this.map.getContainer(), 'tracklist-line-cursor-shown'); 1093 L.DomEvent.off(document, 'keyup', this.onKeyUpForLineCursor, this); 1094 this.map.removeLayer(this._lineCursor); 1095 this._lineCursor = null; 1096 this.fire('linecursorhide'); 1097 this._editedLine.preventStopEdit = false; 1098 } 1099 }, 1100 1101 onMouseMoveOnMapForLineCursor: function(e) { 1102 this.updateLineCursor(e.latlng, false); 1103 }, 1104 1105 updateLineCursor: function(latlng, isValid) { 1106 if (!this._lineCursor) { 1107 return; 1108 } 1109 this._lineCursor.getLatLngs().splice(1, 1, latlng); 1110 this._lineCursor.redraw(); 1111 this._lineCursor.setStyle( 1112 isValid ? this.options.lineCursorValidStyle : this.options.lineCursorInvalidStyle 1113 ); 1114 }, 1115 1116 onKeyUpForLineCursor: function(e) { 1117 if (e.target.tagName.toLowerCase() === 'input') { 1118 return; 1119 } 1120 switch (e.keyCode) { 1121 case 27: 1122 case 13: 1123 this.hideLineCursor(); 1124 L.DomEvent.stop(e); 1125 break; 1126 default: 1127 } 1128 }, 1129 1130 startLineJoinSelection: function(e) { 1131 this._lineJoinFromStart = (e.nodeIndex === 0); 1132 const cursorStart = this._editedLine.getLatLngs()[e.nodeIndex]; 1133 this.showLineCursor(cursorStart, e.mouseEvent.latlng); 1134 this.on('linecursorhide', this.onLineCursorHideForJoin, this); 1135 for (let track of this.tracks()) { 1136 track.feature.on('mousemove', this.onMouseMoveOnLineForJoin, this); 1137 } 1138 this._lineJoinActive = true; 1139 this._editedLine.disableEditOnLeftClick(true); 1140 }, 1141 1142 onMouseMoveOnLineForJoin: function(e) { 1143 const latlngs = e.layer.getLatLngs(); 1144 const lineJoinToStart = isPointCloserToStart(latlngs, e.latlng); 1145 const cursorEnd = lineJoinToStart ? latlngs[0] : latlngs[latlngs.length - 1]; 1146 L.DomEvent.stopPropagation(e); 1147 this.updateLineCursor(cursorEnd, true); 1148 }, 1149 1150 onLineCursorHideForJoin: function() { 1151 for (let track of this.tracks()) { 1152 track.feature.off('mousemove', this.onMouseMoveOnLineForJoin, this); 1153 } 1154 this.off('linecursorhide', this.onLineCursorHideForJoin, this); 1155 this._editedLine.disableEditOnLeftClick(false); 1156 this._lineJoinActive = false; 1157 }, 1158 1159 startShortCutSelection: function(e, startFromNode) { 1160 const line = this._editedLine; 1161 this._shortCut = {startNodeIndex: e.nodeIndex, startFromNode}; 1162 let cursorStart; 1163 if (startFromNode) { 1164 cursorStart = line.getLatLngs()[e.nodeIndex]; 1165 } else { 1166 cursorStart = closestPointToLineSegment(line.getLatLngs(), e.nodeIndex, e.mouseEvent.latlng); 1167 this._shortCut.startLatLng = cursorStart; 1168 } 1169 this.showLineCursor(cursorStart, e.mouseEvent.latlng); 1170 line.nodeMarkers.on('mousemove', this.onMouseMoveOnNodeMarkerForShortCut, this); 1171 line.segmentOverlays.on('mousemove', this.onMouseMoveOnLineSegmentForShortCut, this); 1172 this.map.on('mousemove', this.onMouseMoveOnMapForShortCut, this); 1173 line.nodeMarkers.on('click', this.onClickNodeMarkerForShortCut, this); 1174 line.segmentOverlays.on('click', this.onClickLineSegmentForShortCut, this); 1175 this.on('linecursorhide', this.onLineCursorHideForShortCut, this); 1176 line.disableEditOnLeftClick(true); 1177 }, 1178 1179 onMouseMoveOnLineSegmentForShortCut: function(e) { 1180 this.updateShortCutSelection(e, false); 1181 }, 1182 1183 onMouseMoveOnNodeMarkerForShortCut: function(e) { 1184 this.updateShortCutSelection(e, true); 1185 }, 1186 1187 onMouseMoveOnMapForShortCut: function() { 1188 this._editedLine.highlighNodesForDeletion(); 1189 }, 1190 1191 updateShortCutSelection: function(e, endAtNode) { 1192 L.DomEvent.stopPropagation(e); 1193 const line = this._editedLine; 1194 const {firstNodeToDelete, lastNodeToDelete, rangeValid} = this.getShortCutNodes(e, endAtNode); 1195 this.updateLineCursor(e.latlng, rangeValid); 1196 if (rangeValid) { 1197 line.highlighNodesForDeletion(firstNodeToDelete, lastNodeToDelete); 1198 } else { 1199 line.highlighNodesForDeletion(); 1200 } 1201 }, 1202 1203 onLineCursorHideForShortCut: function() { 1204 const line = this._editedLine; 1205 line.highlighNodesForDeletion(); 1206 line.nodeMarkers.off('mousemove', this.onMouseMoveOnNodeMarkerForShortCut, this); 1207 line.segmentOverlays.off('mousemove', this.onMouseMoveOnLineSegmentForShortCut, this); 1208 this.map.off('mousemove', this.onMouseMoveOnMapForShortCut, this); 1209 line.nodeMarkers.off('click', this.onClickNodeMarkerForShortCut, this); 1210 line.segmentOverlays.off('click', this.onClickLineSegmentForShortCut, this); 1211 this.off('linecursorhide', this.onLineCursorHideForShortCut, this); 1212 line.disableEditOnLeftClick(false); 1213 this._shortCut = null; 1214 }, 1215 1216 onClickLineSegmentForShortCut: function(e) { 1217 this.shortCutSegment(e, false); 1218 }, 1219 1220 onClickNodeMarkerForShortCut: function(e) { 1221 this.shortCutSegment(e, true); 1222 }, 1223 1224 getShortCutNodes: function(e, endAtNode) { 1225 const line = this._editedLine; 1226 let startFromNode = this._shortCut.startFromNode; 1227 let startNodeIndex = this._shortCut.startNodeIndex; 1228 let endNodeIndex = line[endAtNode ? 'getMarkerIndex' : 'getSegmentOverlayIndex'](e.layer); 1229 const newNodes = []; 1230 if (!startFromNode) { 1231 newNodes.push(this._shortCut.startLatLng); 1232 } 1233 if (!endAtNode) { 1234 newNodes.push(closestPointToLineSegment(line.getLatLngs(), endNodeIndex, e.latlng)); 1235 } 1236 let firstNodeToDelete, lastNodeToDelete; 1237 if (endNodeIndex > startNodeIndex) { 1238 firstNodeToDelete = startNodeIndex + 1; 1239 lastNodeToDelete = endNodeIndex - 1; 1240 if (!endAtNode) { 1241 lastNodeToDelete += 1; 1242 } 1243 } else { 1244 newNodes.reverse(); 1245 firstNodeToDelete = endNodeIndex + 1; 1246 lastNodeToDelete = startNodeIndex - 1; 1247 if (!startFromNode) { 1248 lastNodeToDelete += 1; 1249 } 1250 } 1251 return {firstNodeToDelete, lastNodeToDelete, newNodes, rangeValid: lastNodeToDelete >= firstNodeToDelete}; 1252 }, 1253 1254 shortCutSegment: function(e, endAtNode) { 1255 L.DomEvent.stopPropagation(e); 1256 const line = this._editedLine; 1257 const {firstNodeToDelete, lastNodeToDelete, newNodes, rangeValid} = this.getShortCutNodes(e, endAtNode); 1258 if (!rangeValid) { 1259 return; 1260 } 1261 this.stopEditLine(); 1262 line.spliceLatLngs(firstNodeToDelete, lastNodeToDelete - firstNodeToDelete + 1, ...newNodes); 1263 this.startEditTrackSegement(line); 1264 }, 1265 1266 onTrackMouseEnter: function(track) { 1267 track.hover(true); 1268 }, 1269 1270 onTrackMouseLeave: function(track) { 1271 track.hover(false); 1272 }, 1273 1274 onTrackEditStart: function(track) { 1275 track.isEdited(true); 1276 this.scrollListToTrack(track); 1277 }, 1278 1279 onTrackEditEnd: function(track) { 1280 track.isEdited(false); 1281 this.hideLineCursor(); 1282 this._editedLine = null; 1283 }, 1284 1285 onTrackRowMouseEnter: function(track) { 1286 track.hover(true); 1287 }, 1288 1289 onTrackRowMouseLeave: function(track) { 1290 track.hover(false); 1291 }, 1292 1293 onTrackSegmentDrawEnd: function() { 1294 this.trackAddingSegment(null); 1295 }, 1296 1297 splitTrackSegment: function(trackSegment, nodeIndex, latlng) { 1298 var latlngs = trackSegment.getLatLngs(); 1299 latlngs = latlngs.map((latlng) => latlng.clone()); 1300 var latlngs1 = latlngs.slice(0, nodeIndex + 1), 1301 latlngs2 = latlngs.slice(nodeIndex + 1); 1302 if (latlng) { 1303 latlng = closestPointToLineSegment(latlngs, nodeIndex, latlng); 1304 latlngs1.push(latlng.clone()); 1305 } else { 1306 latlng = latlngs[nodeIndex]; 1307 } 1308 latlngs2.unshift(latlng.clone()); 1309 this.deleteTrackSegment(trackSegment); 1310 var segment1 = this.addTrackSegment(trackSegment._parentTrack, latlngs1); 1311 this.addTrackSegment(trackSegment._parentTrack, latlngs2); 1312 this.startEditTrackSegement(segment1); 1313 }, 1314 1315 deleteTrackSegment: function(trackSegment) { 1316 const track = trackSegment._parentTrack; 1317 track.feature.removeLayer(trackSegment); 1318 this.recalculateTrackLength(track); 1319 this.notifyTracksChanged(); 1320 }, 1321 1322 newTrackFromSegment: function(trackSegment) { 1323 var srcNodes = trackSegment.getLatLngs(), 1324 newNodes = [], 1325 i; 1326 for (i = 0; i < srcNodes.length; i++) { 1327 newNodes.push([srcNodes[i].lat, srcNodes[i].lng]); 1328 } 1329 this.addTrack({name: "New track", tracks: [newNodes]}); 1330 }, 1331 1332 addTrack: function(geodata) { 1333 var color; 1334 color = geodata.color; 1335 if (!(color >= 0 && color < this.colors.length)) { 1336 color = this._lastTrackColor; 1337 this._lastTrackColor = (this._lastTrackColor + 1) % this.colors.length; 1338 } 1339 var track = { 1340 name: ko.observable(geodata.name), 1341 color: ko.observable(color), 1342 visible: ko.observable(!geodata.trackHidden), 1343 length: ko.observable(0), 1344 measureTicksShown: ko.observable(geodata.measureTicksShown || false), 1345 feature: L.featureGroup([]), 1346 markers: [], 1347 hover: ko.observable(false), 1348 isEdited: ko.observable(false), 1349 row: ko.observable(null), 1350 }; 1351 (geodata.tracks || []).forEach(this.addTrackSegment.bind(this, track)); 1352 (geodata.points || []).forEach(this.addPoint.bind(this, track)); 1353 1354 this.tracks.push(track); 1355 1356 track.visible.subscribe(this.onTrackVisibilityChanged.bind(this, track)); 1357 track.measureTicksShown.subscribe(this.setTrackMeasureTicksVisibility.bind(this, track)); 1358 track.color.subscribe(this.onTrackColorChanged.bind(this, track)); 1359 track.hover.subscribe(this.onTrackHoverChanged.bind(this, track)); 1360 1361 // this.onTrackColorChanged(track); 1362 this.onTrackVisibilityChanged(track); 1363 this.attachColorSelector(track); 1364 this.attachActionsMenu(track); 1365 this.notifyTracksChanged(); 1366 this.scrollListToTrack(track); 1367 return track; 1368 }, 1369 1370 onTrackHoverChanged: function(track, hover) { 1371 if (hover) { 1372 this._highlightedTrack = track; 1373 } else if (this._highlightedTrack === track) { 1374 this._highlightedTrack = null; 1375 } 1376 this.updateTrackHighlight(); 1377 }, 1378 1379 updateTrackHighlight: function() { 1380 if (L.Browser.touch) { 1381 return; 1382 } 1383 if (this._trackHighlight) { 1384 this._trackHighlight.removeFrom(this._map); 1385 this._trackHighlight = null; 1386 } 1387 if (this._highlightedTrack && this._highlightedTrack.visible()) { 1388 const trackHighlight = L.featureGroup([]).addTo(this._map).bringToBack(); 1389 for (const line of this._highlightedTrack.feature.getLayers()) { 1390 let latlngs = line.getFixedLatLngs(); 1391 if (latlngs.length === 0) { 1392 continue; 1393 } 1394 L.polyline(latlngs, {...this.options.trackHighlightStyle, interactive: false}).addTo( 1395 trackHighlight 1396 ); 1397 const start = latlngs[0]; 1398 const end = latlngs[latlngs.length - 1]; 1399 L.polyline([start, start], {...this.options.trackStartHighlightStyle, interactive: false}).addTo( 1400 trackHighlight 1401 ); 1402 L.polyline([end, end], {...this.options.trackEndHighlightStyle, interactive: false}).addTo( 1403 trackHighlight 1404 ); 1405 } 1406 for (const marker of this._highlightedTrack.markers) { 1407 const latlng = marker.latlng.clone(); 1408 L.polyline([latlng, latlng], {...this.options.trackMarkerHighlightStyle, interactive: false}).addTo( 1409 trackHighlight 1410 ); 1411 } 1412 this._trackHighlight = trackHighlight; 1413 } 1414 }, 1415 1416 scrollListToTrack: function(track) { 1417 track.row().scrollIntoView({behavior: 'smooth', block: 'nearest', container: 'nearest'}); 1418 }, 1419 1420 setMarkerIcon: function(marker) { 1421 var symbol = 'marker', 1422 colorInd = marker._parentTrack.color() + 1, 1423 className = 'symbol-' + symbol + '-' + colorInd; 1424 marker.icon = iconFromBackgroundImage('track-waypoint ' + className); 1425 }, 1426 1427 setMarkerLabel: function(marker, label) { 1428 marker.label = label; 1429 }, 1430 1431 addPoint: function(track, srcPoint) { 1432 var marker = { 1433 latlng: L.latLng([srcPoint.lat, srcPoint.lng]), 1434 _parentTrack: track, 1435 }; 1436 this.setMarkerIcon(marker); 1437 this.setMarkerLabel(marker, srcPoint.name); 1438 track.markers.push(marker); 1439 marker._parentTrack = track; 1440 return marker; 1441 }, 1442 1443 onMarkerClick: function(e) { 1444 new Contextmenu([ 1445 {text: e.marker.label, header: true}, 1446 '-', 1447 {text: 'Rename', callback: this.renamePoint.bind(this, e.marker)}, 1448 {text: 'Move', callback: this.beginPointMove.bind(this, e.marker)}, 1449 {text: 'Copy coordinates', callback: this.copyPointCoordinatesToClipboard.bind(this, e.marker, e)}, 1450 {text: 'Delete', callback: this.removePoint.bind(this, e.marker)}, 1451 ] 1452 ).show(e); 1453 }, 1454 1455 onMarkerEnter: function(e) { 1456 e.marker._parentTrack.hover(true); 1457 }, 1458 1459 onMarkerLeave: function(e) { 1460 e.marker._parentTrack.hover(false); 1461 }, 1462 1463 removePoint: function(marker) { 1464 this.stopPlacingPoint(); 1465 this._markerLayer.removeMarker(marker); 1466 const markers = marker._parentTrack.markers; 1467 const i = markers.indexOf(marker); 1468 markers.splice(i, 1); 1469 this.notifyTracksChanged(); 1470 }, 1471 1472 renamePoint: function(marker) { 1473 this.stopPlacingPoint(); 1474 var newLabel = query('New point name', marker.label); 1475 if (newLabel !== null) { 1476 this.setMarkerLabel(marker, newLabel); 1477 this._markerLayer.updateMarker(marker); 1478 this.notifyTracksChanged(); 1479 } 1480 }, 1481 1482 removeTrack: function(track) { 1483 track.visible(false); 1484 this.tracks.remove(track); 1485 this.notifyTracksChanged(); 1486 }, 1487 1488 deleteAllTracks: function() { 1489 var tracks = this.tracks().slice(0), 1490 i; 1491 for (i = 0; i < tracks.length; i++) { 1492 this.removeTrack(tracks[i]); 1493 } 1494 }, 1495 1496 deleteHiddenTracks: function() { 1497 var tracks = this.tracks().slice(0), 1498 i, track; 1499 for (i = 0; i < tracks.length; i++) { 1500 track = tracks[i]; 1501 if (!track.visible()) { 1502 this.removeTrack(tracks[i]); 1503 } 1504 } 1505 }, 1506 1507 trackToString: function(track, forceVisible) { 1508 var lines = this.getTrackPolylines(track).map(function(line) { 1509 var points = line.getFixedLatLngs(); 1510 points = L.LineUtil.simplifyLatlngs(points, 360 / (1 << 24)); 1511 return points; 1512 } 1513 ); 1514 return geoExporters.saveToString(lines, track.name(), track.color(), track.measureTicksShown(), 1515 this.getTrackPoints(track), forceVisible ? false : !track.visible() 1516 ); 1517 }, 1518 1519 loadTracksFromString(s, allowEmpty = false) { 1520 const geodata = parseNktkSequence(s); 1521 this.addTracksFromGeodataArray(geodata, allowEmpty); 1522 }, 1523 1524 copyAllTracksToClipboard: function(mouseEvent, allowWithoutTracks = false) { 1525 this.copyTracksLinkToClipboard(this.tracks(), mouseEvent, allowWithoutTracks); 1526 }, 1527 1528 copyVisibleTracksToClipboard: function(mouseEvent) { 1529 const tracks = this.tracks().filter((track) => track.visible()); 1530 this.copyTracksLinkToClipboard(tracks, mouseEvent); 1531 }, 1532 1533 createNewTrackFromVisibleTracks: function() { 1534 const tracks = this.tracks().filter((track) => track.visible()); 1535 if (tracks.length === 0) { 1536 return; 1537 } 1538 let newTrackName = tracks[0].name(); 1539 newTrackName = query('New track name', newTrackName); 1540 if (newTrackName === null) { 1541 return; 1542 } 1543 1544 const newTrackSegments = []; 1545 const newTrackPoints = []; 1546 1547 for (const track of tracks) { 1548 for (let segment of this.getTrackPolylines(track)) { 1549 const points = segment.getFixedLatLngs().map(({lat, lng}) => ({lat, lng})); 1550 newTrackSegments.push(points); 1551 } 1552 const points = this.getTrackPoints(track).map((point) => ({ 1553 lat: point.latlng.lat, 1554 lng: point.latlng.lng, 1555 name: point.label 1556 })); 1557 newTrackPoints.push(...points); 1558 } 1559 1560 this.addTrack({name: newTrackName, tracks: newTrackSegments, points: newTrackPoints}); 1561 }, 1562 1563 saveAllTracksToZipFile: async function() { 1564 const tracks = this.tracks(); 1565 const trackFilesData = []; 1566 const seenNames = new Set(); 1567 for (const track of tracks) { 1568 const {error, content, filename} = await this.exportTrackAsFile( 1569 track, geoExporters.saveGpx, '', false, true 1570 ); 1571 if (error) { 1572 notify(error); 1573 return; 1574 } 1575 const safeFilename = filename.replaceAll(/[<>:"/\\|?*]/ug, '_'); 1576 const uniqueFilename = makeNameUnique(safeFilename, seenNames); 1577 seenNames.add(uniqueFilename); 1578 trackFilesData.push({content, filename: uniqueFilename + '.gpx'}); 1579 } 1580 const zipFile = createZipFile(trackFilesData); 1581 const now = new Date(); 1582 const dateString = [ 1583 String(now.getDate()).padStart(2, '0'), 1584 '.', 1585 String(now.getMonth()).padStart(2, '0'), 1586 '.', 1587 now.getFullYear(), 1588 '_', 1589 String(now.getHours()).padStart(2, '0'), 1590 '.', 1591 String(now.getMinutes()).padStart(2, '0'), 1592 ].join(''); 1593 const zipFilename = `nakarte_tracks_${dateString}.zip`; 1594 saveAs(new Blob([zipFile], {type: 'application/download'}), zipFilename, true); 1595 }, 1596 1597 showElevationProfileForSegment: function(line) { 1598 this.hideElevationProfile(); 1599 this.stopEditLine(); 1600 this._elevationControl = new ElevationProfile(this._map, line.getLatLngs(), { 1601 samplingInterval: calcSamplingInterval(line.getLength()) 1602 } 1603 ); 1604 this.fire('elevation-shown'); 1605 }, 1606 1607 showElevationProfileForTrack: function(track) { 1608 var lines = this.getTrackPolylines(track), 1609 path = [], 1610 i; 1611 for (i = 0; i < lines.length; i++) { 1612 if (lines[i] === this._editedLine) { 1613 this.stopEditLine(); 1614 } 1615 path = path.concat(lines[i].getLatLngs()); 1616 } 1617 this.hideElevationProfile(); 1618 this._elevationControl = new ElevationProfile(this._map, path, { 1619 samplingInterval: calcSamplingInterval(new L.MeasuredLine(path).getLength()) 1620 } 1621 ); 1622 this.fire('elevation-shown'); 1623 }, 1624 1625 hideElevationProfile: function() { 1626 if (this._elevationControl) { 1627 this._elevationControl.removeFrom(this._map); 1628 } 1629 this._elevationControl = null; 1630 }, 1631 1632 hasTracks: function() { 1633 return this.tracks().length > 0; 1634 }, 1635 1636 notifyTracksChanged() { 1637 this.fire('trackschanged'); 1638 }, 1639 1640 showAllTracks: function() { 1641 this.tracks().forEach((track) => track.visible(true)); 1642 }, 1643 1644 hideAllTracks: function() { 1645 this.tracks().forEach((track) => track.visible(false)); 1646 }, 1647 1648 onTrackCheckboxClicked: function(clickedTrack, event) { 1649 if (event.shiftKey) { 1650 this.tracks().forEach((track) => { 1651 track.visible(track === clickedTrack); 1652 }); 1653 } 1654 return true; 1655 } 1656 } 1657 ); 1658 1659 export {TRACKLIST_TRACK_COLORS};
