nakarte

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

track-list.js (67879B)


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