nakarte

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

track-list.js (63432B)


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