nakarte

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

track-list.js (62562B)


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