nakarte

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

track-list.js (61181B)


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