nakarte

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

track-list.js (68324B)


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