nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

track-list.js (60405B)


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