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