diff --git a/config.template.js b/config.template.js index 09a97e6..28ea8d1 100644 --- a/config.template.js +++ b/config.template.js @@ -100,6 +100,18 @@ nodata: { color: 'darkred', }, + beeline: { + weight: 5, + dashArray: [1, 10], + color: 'magenta', + opacity: BR.conf.defaultOpacity, + }, + beelineTrailer: { + weight: 5, + dashArray: [1, 10], + opacity: 0.6, + color: 'magenta', + }, }; BR.conf.markerColors = { diff --git a/css/style.css b/css/style.css index 461e975..d90c841 100644 --- a/css/style.css +++ b/css/style.css @@ -443,6 +443,22 @@ button.btn { line-height: 30px; } +button.activate-beeline-active, +button.deactivate-beeline-active { + transition-duration: 0.3s; +} +button.activate-beeline-active.disabled, +button.deactivate-beeline-active.disabled { + height: 0; + border-bottom-width: 0px; +} +.mdi.active { + fill: #2074b6; +} +.mdi { + width: 18px; +} + /* smaller tab height */ .nav > li > a { padding: 2px 15px; diff --git a/index.html b/index.html index 0058bd9..a68da4f 100644 --- a/index.html +++ b/index.html @@ -1132,6 +1132,16 @@
+ +
+diff --git a/js/Map.js b/js/Map.js index 77e1ec4..972e561 100644 --- a/js/Map.js +++ b/js/Map.js @@ -12,13 +12,15 @@ BR.Map = { var maxZoom = 19; + if (BR.Browser.touch) { + L.Draggable.prototype.options.clickTolerance = 10; + } + map = new L.Map('map', { zoomControl: false, // add it manually so that we can translate it worldCopyJump: true, minZoom: 0, maxZoom: maxZoom, - // fix for route drag on mobile (#285), until next Leaflet version released (> 1.6.0) - tap: false, }); if (BR.Util.getResponsiveBreakpoint() >= '3md') { diff --git a/js/control/Export.js b/js/control/Export.js index 5ec4a5e..05a694d 100644 --- a/js/control/Export.js +++ b/js/control/Export.js @@ -74,7 +74,15 @@ BR.Export = L.Class.extend({ link.download = (name || 'brouter') + '.' + format; link.click(); } else { - var uri = this.router.getUrl(this.latLngs, this.pois.getMarkers(), null, format, nameUri, includeWaypoints); + var uri = this.router.getUrl( + this.latLngs, + null, + this.pois.getMarkers(), + null, + format, + nameUri, + includeWaypoints + ); var evt = document.createEvent('MouseEvents'); evt.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); var link = document.createElement('a'); @@ -284,8 +292,15 @@ BR.Export._concatTotalTrack = function (segments) { let featureCoordinates = feature.geometry.coordinates; if (segmentIndex > 0) { - // remove first segment coordinate, same as previous last - featureCoordinates = featureCoordinates.slice(1); + // remove duplicate coordinate: first segment coordinate same as previous last, + // remove the one without ele value (e.g. beeline) + const prevLast = coordinates[coordinates.length - 1]; + const first = featureCoordinates[0]; + if (prevLast.length < first.length) { + coordinates.pop(); + } else { + featureCoordinates = featureCoordinates.slice(1); + } } coordinates = coordinates.concat(featureCoordinates); } diff --git a/js/control/Profile.js b/js/control/Profile.js index 8de56bc..5663ef2 100644 --- a/js/control/Profile.js +++ b/js/control/Profile.js @@ -38,48 +38,41 @@ BR.Profile = L.Evented.extend({ button.blur(); }, - update: function (options) { + update: function (options, cb) { var profileName = options.profile, profileUrl, - empty = !this.editor.getValue(), - clean = this.editor.isClean(); + loading = false; if (profileName && BR.conf.profilesUrl) { - // only synchronize profile editor/parameters with selection if no manual changes in full editor, - // else keep custom profile pinned - to prevent changes in another profile overwriting previous ones - if (empty || clean) { - this.profileName = profileName; - if (!(profileName in this.cache)) { - profileUrl = BR.conf.profilesUrl + profileName + '.brf'; - BR.Util.get( - profileUrl, - L.bind(function (err, profileText) { - if (err) { - console.warn('Error getting profile from "' + profileUrl + '": ' + err); - return; - } + this.selectedProfileName = profileName; - this.cache[profileName] = profileText; + if (!(profileName in this.cache)) { + profileUrl = BR.conf.profilesUrl + profileName + '.brf'; + loading = true; + BR.Util.get( + profileUrl, + L.bind(function (err, profileText) { + if (err) { + console.warn('Error getting profile from "' + profileUrl + '": ' + err); + if (cb) cb(); + return; + } - // don't set when option has changed while loading - if (!this.profileName || this.profileName === profileName) { - this._setValue(profileText); - } - }, this) - ); - } else { - this._setValue(this.cache[profileName]); - } + this.cache[profileName] = profileText; - if (!this.pinned.hidden) { - this.pinned.hidden = true; - } + // don't set when option has changed while loading + if (!this.profileName || this.selectedProfileName === profileName) { + this._updateProfile(profileName, profileText); + } + if (cb) cb(); + }, this) + ); } else { - if (this.pinned.hidden) { - this.pinned.hidden = false; - } + this._updateProfile(profileName, this.cache[profileName]); } } + + if (cb && !loading) cb(); }, show: function () { @@ -101,7 +94,7 @@ BR.Profile = L.Evented.extend({ } } - const profileText = this._getProfileText(); + const profileText = this._getSelectedProfileText(); if (!profileText) return value; const regex = new RegExp(`assign\\s*${name}\\s*=?\\s*([\\w\\.]*)`); @@ -188,6 +181,26 @@ BR.Profile = L.Evented.extend({ }); }, + _updateProfile: function (profileName, profileText) { + const empty = !this.editor.getValue(); + const clean = this.editor.isClean(); + + // only synchronize profile editor/parameters with selection if no manual changes in full editor, + // else keep custom profile pinned - to prevent changes in another profile overwriting previous ones + if (empty || clean) { + this.profileName = profileName; + this._setValue(profileText); + + if (!this.pinned.hidden) { + this.pinned.hidden = true; + } + } else { + if (this.pinned.hidden) { + this.pinned.hidden = false; + } + } + }, + _setValue: function (profileText) { profileText = profileText || ''; @@ -363,4 +376,8 @@ BR.Profile = L.Evented.extend({ _getProfileText: function () { return this.editor.getValue(); }, + + _getSelectedProfileText: function () { + return this.cache[this.selectedProfileName] ?? this.editor.getValue(); + }, }); diff --git a/js/control/TrackAnalysis.js b/js/control/TrackAnalysis.js index f77aeb5..462e7e8 100644 --- a/js/control/TrackAnalysis.js +++ b/js/control/TrackAnalysis.js @@ -496,9 +496,11 @@ BR.TrackAnalysis = L.Class.extend({ } return typeof parsed.tracktype === 'string' && parsed.tracktype === trackType; + } else if (dataName === 'internal-unknown' && typeof parsed.highway !== 'string') { + return true; } - return parsed.highway === dataName; + return typeof parsed.highway === 'string' && parsed.highway === dataName; case 'surface': return this.singleWayTagMatchesData('surface', parsed, dataName); case 'smoothness': diff --git a/js/control/TrackStats.js b/js/control/TrackStats.js index de6f63f..4040798 100644 --- a/js/control/TrackStats.js +++ b/js/control/TrackStats.js @@ -9,6 +9,9 @@ BR.TrackStats = L.Class.extend({ $('#stats-container').show(); $('#stats-info').hide(); + const hasBeeline = segments.filter((line) => line?._routing?.beeline).length > 0; + document.getElementById('beeline-warning').hidden = !hasBeeline; + var stats = this.calcStats(polyline, segments), length1 = L.Util.formatNum(stats.trackLength / 1000, 1).toLocaleString(), length3 = L.Util.formatNum(stats.trackLength / 1000, 3).toLocaleString(undefined, { diff --git a/js/format/Xml.js b/js/format/Xml.js index 93efdcd..d52a147 100644 --- a/js/format/Xml.js +++ b/js/format/Xml.js @@ -25,7 +25,11 @@ BR.Xml = { } } else { if (singleLineTagList.includes(tag)) { - singleLineTag = tag; + const closeIndex = xml.indexOf('>', match.index + 1); + const selfClosing = xml.charAt(closeIndex - 1) === '/'; + if (!selfClosing) { + singleLineTag = tag; + } } let endIndex = match.index + 1; lines.push(xml.substring(startIndex, endIndex)); diff --git a/js/index.js b/js/index.js index 8ef2fd3..ae235c9 100644 --- a/js/index.js +++ b/js/index.js @@ -83,6 +83,40 @@ ], }); + // https://github.com/Templarian/MaterialDesign/blob/d0b28330af6648ca4c50c14d55043d71f813b3ae/svg/vector-line.svg + // Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0), https://github.com/Templarian/MaterialDesign/blob/master/LICENSE + const svg = ` + `; + const beelineClickHandler = function (control) { + const enabled = routing.toggleBeelineDrawing(); + control.state(enabled ? 'deactivate-beeline' : 'activate-beeline'); + }; + const title = i18next.t('keyboard.generic-shortcut', { + action: '$t(map.toggle-beeline)', + key: 'B', + }); + const beelineButton = L.easyButton({ + states: [ + { + stateName: 'activate-beeline', + icon: svg.replace(' active', ''), + onClick: beelineClickHandler, + title: title, + }, + { + stateName: 'deactivate-beeline', + icon: svg, + onClick: beelineClickHandler, + title: title, + }, + ], + }); + map.on('routing:beeline-start', () => beelineButton.state('deactivate-beeline')); + map.on('routing:beeline-end', () => beelineButton.state('activate-beeline')); + var reverseRouteButton = L.easyButton( 'fa-random', function () { @@ -192,9 +226,12 @@ } routingOptions = new BR.RoutingOptions(); - routingOptions.on('update', updateRoute); routingOptions.on('update', function (evt) { - profile.update(evt.options); + if (urlHash.movingMap) return; + + profile.update(evt.options, () => { + updateRoute(evt); + }); }); BR.NogoAreas.MSG_BUTTON = i18next.t('keyboard.generic-shortcut', { @@ -256,7 +293,7 @@ routingPathQuality = new BR.RoutingPathQuality(map, layersControl); - routing = new BR.Routing({ + routing = new BR.Routing(profile, { routing: { router: L.bind(router.getRouteSegment, router), }, @@ -267,15 +304,17 @@ exportRoute = new BR.Export(router, pois, profile); - routing.on('routing:routeWaypointEnd routing:setWaypointsEnd', function (evt) { + routing.on('routing:routeWaypointEnd routing:setWaypointsEnd routing:rerouteSegmentEnd', function (evt) { search.clear(); onUpdate(evt && evt.err); }); map.on('routing:draw-start', function () { drawButton.state('deactivate-draw'); + beelineButton.enable(); }); map.on('routing:draw-end', function () { drawButton.state('activate-draw'); + beelineButton.disable(); }); function onUpdate(err) { @@ -330,7 +369,7 @@ circlego.addTo(map); } - var buttons = [drawButton, reverseRouteButton, nogos.getButton()]; + var buttons = [drawButton, beelineButton, reverseRouteButton, nogos.getButton()]; if (circlego) buttons.push(circlego.getButton()); buttons.push(deletePointButton, deleteRouteButton); @@ -363,11 +402,12 @@ // initial option settings (after controls are added and initialized with onAdd) router.setOptions(nogos.getOptions()); router.setOptions(routingOptions.getOptions()); - profile.update(routingOptions.getOptions()); - // restore active layers from local storage when called without hash // (check before hash plugin init) if (!location.hash) { + profile.update(routingOptions.getOptions()); + + // restore active layers from local storage when called without hash layersControl.loadActiveLayers(); } @@ -391,13 +431,16 @@ router.setOptions(opts); routingOptions.setOptions(opts); nogos.setOptions(opts); - profile.update(opts); - if (opts.lonlats) { - routing.draw(false); - routing.clear(); - routing.setWaypoints(opts.lonlats); - } + const optsOrDefault = Object.assign({}, routingOptions.getOptions(), opts); + profile.update(optsOrDefault, () => { + if (opts.lonlats) { + routing.draw(false); + routing.clear(); + routing.setWaypoints(opts.lonlats, opts.beelineFlags); + } + }); + if (opts.pois) { pois.setMarkers(opts.pois); } @@ -419,7 +462,13 @@ // this callback is used to append anything in URL after L.Hash wrote #map=zoom/lat/lng/layer urlHash.additionalCb = function () { var url = router - .getUrl(routing.getWaypoints(), pois.getMarkers(), circlego ? circlego.getCircle() : null, null) + .getUrl( + routing.getWaypoints(), + routing.getBeelineFlags(), + pois.getMarkers(), + circlego ? circlego.getCircle() : null, + null + ) .substr('brouter?'.length + 1); // by default brouter use | as separator. To make URL more human-readable, we remplace them with ; for users diff --git a/js/plugin/RouteLoaderConverter.js b/js/plugin/RouteLoaderConverter.js index 53967c7..1be7cad 100644 --- a/js/plugin/RouteLoaderConverter.js +++ b/js/plugin/RouteLoaderConverter.js @@ -341,7 +341,7 @@ BR.routeLoader = function (map, layersControl, routing, pois) { } if (routingPoints.length > 0) { - routing.setWaypoints(routingPoints, function (event) { + routing.setWaypoints(routingPoints, null, function (event) { if (!event) return; var err = event.error; BR.message.showError( diff --git a/js/plugin/Routing.js b/js/plugin/Routing.js index 5f80e85..ed6bbb8 100644 --- a/js/plugin/Routing.js +++ b/js/plugin/Routing.js @@ -28,12 +28,23 @@ BR.Routing = L.Routing.extend({ draw: { enable: 68, // char code for 'd' disable: 27, // char code for 'ESC' + beelineMode: 66, // char code for 'b' + // char code for 'Shift', same key as `beelineModifierName` + beelineModifier: 16, + // modifier key to draw straight line on click [shiftKey|null] (others don't work everywhere) + beelineModifierName: 'shiftKey', }, reverse: 82, // char code for 'r' deleteLastPoint: 90, // char code for 'z' }, }, + initialize: function (profile, options) { + L.Routing.prototype.initialize.call(this, options); + + this.profile = profile; + }, + onAdd: function (map) { this.options.tooltips.waypoint = i18next.t('map.route-tooltip-waypoint'); this.options.tooltips.segment = i18next.t('map.route-tooltip-segment'); @@ -48,13 +59,36 @@ BR.Routing = L.Routing.extend({ this._waypoints.on('layeradd', this._setMarkerOpacity, this); - this.on('routing:routeWaypointStart routing:rerouteAllSegmentsStart', function (evt) { - this._removeDistanceMarkers(); + // flag if (re-)routing of all segments is ongoing + this._routingAll = false; + this.on('routing:rerouteAllSegmentsStart routing:setWaypointsStart', function (evt) { + this._routingAll = true; + }); + this.on('routing:rerouteAllSegmentsEnd routing:setWaypointsEnd', function (evt) { + this._routingAll = false; }); - this.on('routing:routeWaypointEnd routing:setWaypointsEnd routing:rerouteAllSegmentsEnd', function (evt) { - this._updateDistanceMarkers(evt); - }); + this.on( + 'routing:routeWaypointStart routing:rerouteAllSegmentsStart routing:rerouteSegmentStart', + function (evt) { + if (!this._routingAll || evt.type === 'routing:rerouteAllSegmentsStart') { + this._removeDistanceMarkers(); + } + } + ); + + this.on( + 'routing:routeWaypointEnd routing:setWaypointsEnd routing:rerouteAllSegmentsEnd routing:rerouteSegmentEnd', + function (evt) { + if ( + !this._routingAll || + evt.type === 'routing:rerouteAllSegmentsEnd' || + evt.type === 'routing:setWaypointsEnd' + ) { + this._updateDistanceMarkers(evt); + } + } + ); // turn line mouse marker off while over waypoint marker this.on( @@ -65,7 +99,7 @@ BR.Routing = L.Routing.extend({ return; } - this._mouseMarker.setOpacity(0.0); + this._hideMouseMarker(); this._map.off('mousemove', this._segmentOnMousemove, this); this._suspended = true; }, @@ -141,22 +175,27 @@ BR.Routing = L.Routing.extend({ this._show(); } } - function hide() { + var hide = function () { if (!this._hidden && this._parent._waypoints._first) { this._hide(); } + }.bind(this._draw); + function hideOverControl(e) { + hide(); + // prevent showing trailer when clicking state buttons (causes event that propagates to map) + L.DomEvent.stopPropagation(e); } this._draw.on('enabled', function () { this._map.on('mouseout', hide, this); this._map.on('mouseover', show, this); L.DomEvent.on(this._map._controlContainer, 'mouseout', show, this); - L.DomEvent.on(this._map._controlContainer, 'mouseover', hide, this); + L.DomEvent.on(this._map._controlContainer, 'mouseover', hideOverControl, this); }); this._draw.on('disabled', function () { this._map.off('mouseout', hide, this); this._map.off('mouseover', show, this); L.DomEvent.off(this._map._controlContainer, 'mouseout', show, this); - L.DomEvent.off(this._map._controlContainer, 'mouseover', hide, this); + L.DomEvent.off(this._map._controlContainer, 'mouseover', hideOverControl, this); }); // Call show after deleting last waypoint, but hide trailer. @@ -175,6 +214,21 @@ BR.Routing = L.Routing.extend({ this._draw ); + // avoid accidental shift-drag zooms while drawing beeline with shift-click + this._map.boxZoom.disable(); + this._map.addHandler('boxZoom', BR.ClickTolerantBoxZoom); + this._draw.on('enabled', function () { + this._map.boxZoom.tolerant = true; + }); + this._draw.on('disabled', function () { + this._map.boxZoom.tolerant = false; + }); + + // remove listeners registered in super.onAdd, keys not working when map container lost focus + // (by navbar/sidebar interaction), use document instead + L.DomEvent.removeListener(this._container, 'keydown', this._keydownListener, this); + L.DomEvent.removeListener(this._container, 'keyup', this._keyupListener, this); + L.DomEvent.addListener(document, 'keydown', this._keydownListener, this); L.DomEvent.addListener(document, 'keyup', this._keyupListener, this); @@ -185,7 +239,9 @@ BR.Routing = L.Routing.extend({ }, _addSegmentCasing: function (e) { - var casing = L.polyline(e.layer.getLatLngs(), this.options.styles.trackCasing); + // extend layer style to inherit beeline dashArray + const casingStyle = Object.assign({}, e.layer.options, this.options.styles.trackCasing); + const casing = L.polyline(e.layer.getLatLngs(), Object.assign({}, casingStyle, { interactive: false })); this._segmentsCasing.addLayer(casing); e.layer._casing = casing; this._segments.bringToFront(); @@ -262,7 +318,7 @@ BR.Routing = L.Routing.extend({ } }, - setWaypoints: function (latLngs, cb) { + setWaypoints: function (latLngs, beelineFlags, cb) { var i; var callbackCount = 0; var firstErr; @@ -291,7 +347,8 @@ BR.Routing = L.Routing.extend({ this._loadingTrailerGroup._map = null; for (i = 0; latLngs && i < latLngs.length; i++) { - this.addWaypoint(latLngs[i], this._waypoints._last, null, callback); + const beeline = beelineFlags && i < beelineFlags.length ? beelineFlags[i] : null; + this.addWaypoint(latLngs[i], beeline, this._waypoints._last, null, callback); } this._loadingTrailerGroup._map = this._map; @@ -374,14 +431,16 @@ BR.Routing = L.Routing.extend({ this.reverse(); } else if (e.keyCode === this.options.shortcut.deleteLastPoint) { this.deleteLastPoint(); + } else if (e.keyCode === this.options.shortcut.draw.beelineMode) { + this.toggleBeelineDrawing(); + } else if (e.keyCode === this.options.shortcut.draw.beelineModifier) { + this._draw._setTrailerStyle(true); } }, _keyupListener: function (e) { - // Prevent Leaflet from triggering drawing a second time on keyup, - // since this is already done in _keydownListener - if (e.keyCode === this.options.shortcut.draw.enable) { - return; + if (e.keyCode === this.options.shortcut.draw.beelineModifier) { + this._draw._setTrailerStyle(false); } }, @@ -390,10 +449,12 @@ BR.Routing = L.Routing.extend({ }, reverse: function () { - var waypoints = this.getWaypoints(); + const waypoints = this.getWaypoints(); + const beelineFlags = this.getBeelineFlags(); waypoints.reverse(); + beelineFlags.reverse(); this.clear(); - this.setWaypoints(waypoints); + this.setWaypoints(waypoints, beelineFlags); }, deleteLastPoint: function () { @@ -417,4 +478,166 @@ BR.Routing = L.Routing.extend({ this._map.addLayer(this._distanceMarkers); } }, + + _distance: function (latLng1, latLng2) { + //return Math.round(latLng1.distanceTo(latLng2)); + const [ilon1, ilat1] = btools.util.CheapRuler.toIntegerLngLat([latLng1.lng, latLng1.lat]); + const [ilon2, ilat2] = btools.util.CheapRuler.toIntegerLngLat([latLng2.lng, latLng2.lat]); + + return btools.util.CheapRuler.calcDistance(ilon1, ilat1, ilon2, ilat2); + }, + + _computeKinematic: function (distance, deltaHeight, costFactor) { + const rc = new BR.RoutingContext(this.profile); + rc.expctxWay = new BR.BExpressionContextWay(undefined, costFactor); + const stdPath = new BR.StdPath(); + + stdPath.computeKinematic(rc, distance, deltaHeight, true); + return stdPath; + }, + + _getCostFactor: function (line) { + let costFactor; + if (line) { + const props = line.feature.properties; + const length = props['track-length']; + const cost = props['cost']; + if (length) { + costFactor = cost / length; + } + } + return costFactor; + }, + + _interpolateBeelines: function (serialBeelines, before, after) { + let altStart = before?.getLatLngs()[before.getLatLngs().length - 1].alt; + const altEnd = after?.getLatLngs()[0].alt ?? altStart; + altStart ?? (altStart = altEnd); + + let serialDelta = 0; + if (altStart != null && altEnd != null) { + serialDelta = altEnd - altStart; + } + const serialDistance = serialBeelines.reduce( + (dist, line) => (dist += line.feature.properties['track-length']), + 0 + ); + + let beforeCostFactor = this._getCostFactor(before); + let afterCostFactor = this._getCostFactor(after); + let costFactor; + if (beforeCostFactor != null && afterCostFactor != null) { + costFactor = Math.max(beforeCostFactor, afterCostFactor); + } else { + costFactor = beforeCostFactor ?? afterCostFactor; + } + + for (const beeline of serialBeelines) { + const props = beeline.feature.properties; + const distance = props['track-length']; + const deltaHeight = (serialDelta * distance) / serialDistance; + + const stdPath = this._computeKinematic(distance, deltaHeight, costFactor); + props['total-energy'] = stdPath.getTotalEnergy(); + props['total-time'] = stdPath.getTotalTime(); + + // match BRouter/Java rounding where `(int)` cast truncates decimals + // https://github.com/abrensch/brouter/blob/14d5a2c4e6b101a2eab711e70151142881df95c6/brouter-core/src/main/java/btools/router/RoutingEngine.java#L1216-L1217 + if (deltaHeight > 0) { + // no filtering for simplicity for now + props['filtered ascend'] = Math.trunc(deltaHeight); + } + props['plain-ascend'] = Math.trunc(deltaHeight + 0.5); + // do not set interpolated alt value, to explicitly show missing data, e.g. in height graph + + props['cost'] = Math.round(distance * (costFactor ?? 0)); + } + }, + + _updateBeelines: function () { + L.Routing.prototype._updateBeelines.call(this); + + let serialBeelines = []; + let before = null; + + this._eachSegment(function (m1, m2, line) { + if (line?._routing?.beeline) { + serialBeelines.push(line); + } else { + if (serialBeelines.length > 0) { + this._interpolateBeelines(serialBeelines, before, line); + } + before = line; + serialBeelines = []; + } + }); + + if (serialBeelines.length > 0) { + this._interpolateBeelines(serialBeelines, before, null); + } + }, + + createBeeline: function (latLng1, latLng2) { + const layer = L.Routing.prototype.createBeeline.call(this, latLng1, latLng2); + // remove alt from cloned LatLngs to show gap in elevation graph to indicate no data inbetween + delete layer.getLatLngs()[0].alt; + delete layer.getLatLngs()[1].alt; + const distance = this._distance(latLng1, latLng2); + const props = { + cost: 0, + 'filtered ascend': 0, + 'plain-ascend': 0, + 'total-energy': 0, + 'total-time': 0, + 'track-length': distance, + messages: [ + [ + 'Longitude', + 'Latitude', + 'Elevation', + 'Distance', + 'CostPerKm', + 'ElevCost', + 'TurnCost', + 'NodeCost', + 'InitialCost', + 'WayTags', + 'NodeTags', + 'Time', + 'Energy', + ], + [ + latLng2.lng * 1000000, + latLng2.lat * 1000000, + null, + distance, + null, + null, + null, + null, + null, + '', + '', + null, + null, + ], + ], + }; + layer.feature = turf.lineString( + [ + [latLng1.lng, latLng1.lat], + [latLng2.lng, latLng2.lat], + ], + props + ); + + // corresponding to BRouter._assignFeatures + for (const latLng of layer.getLatLngs()) { + const featureMessage = props.messages[1]; + latLng.feature = BR.TrackEdges.getFeature(featureMessage); + latLng.message = featureMessage; + } + + return layer; + }, }); diff --git a/js/plugin/RoutingPathQuality.js b/js/plugin/RoutingPathQuality.js index 2e0e074..0ff67e0 100644 --- a/js/plugin/RoutingPathQuality.js +++ b/js/plugin/RoutingPathQuality.js @@ -227,6 +227,7 @@ var HotLineQualityProvider = L.Class.extend({ var flatLines = []; for (var i = 0; segments && i < segments.length; i++) { var segment = segments[i]; + if (segment._routing?.beeline) continue; var vals = this._computeLatLngVals(segment); segmentLatLngs.push(vals); Array.prototype.push.apply(flatLines, vals); diff --git a/js/plugin/TracksLoader.js b/js/plugin/TracksLoader.js index a68990a..bef99e0 100644 --- a/js/plugin/TracksLoader.js +++ b/js/plugin/TracksLoader.js @@ -70,6 +70,11 @@ BR.tracksLoader = function (map, layersControl, routing, pois) { } }, }); + + // make sure tracks are always shown below route by adding a custom pane below `leaflet-overlay-pane` + map.createPane('tracks'); + map.getPane('tracks').style.zIndex = 350; + var tracksLoaderControl = new TracksLoader(); tracksLoaderControl.addTo(map); diff --git a/js/router/BRouter.js b/js/router/BRouter.js index 728f716..6b272cd 100644 --- a/js/router/BRouter.js +++ b/js/router/BRouter.js @@ -42,10 +42,15 @@ L.BRouter = L.Class.extend({ L.setOptions(this, options); }, - getUrlParams: function (latLngs, pois, circlego, format) { + getUrlParams: function (latLngs, beelineFlags, pois, circlego, format) { params = {}; if (this._getLonLatsString(latLngs) != null) params.lonlats = this._getLonLatsString(latLngs); + if (beelineFlags && beelineFlags.length > 0) { + const beelineString = this._getBeelineString(beelineFlags); + if (beelineString.length > 0) params.straight = beelineString; + } + if (this.options.nogos && this._getNogosString(this.options.nogos).length > 0) params.nogos = this._getNogosString(this.options.nogos); @@ -84,6 +89,9 @@ L.BRouter = L.Class.extend({ if (params.lonlats) { opts.lonlats = this._parseLonLats(params.lonlats); } + if (params.straight) { + opts.beelineFlags = this._parseBeelines(params.straight, opts.lonlats); + } if (params.nogos) { opts.nogos = this._parseNogos(params.nogos); } @@ -117,11 +125,12 @@ L.BRouter = L.Class.extend({ return opts; }, - getUrl: function (latLngs, pois, circlego, format, trackname, exportWaypoints) { - var urlParams = this.getUrlParams(latLngs, pois, circlego, format); + getUrl: function (latLngs, beelineFlags, pois, circlego, format, trackname, exportWaypoints) { + var urlParams = this.getUrlParams(latLngs, beelineFlags, pois, circlego, format); var args = []; if (urlParams.lonlats != null && urlParams.lonlats.length > 0) args.push(L.Util.template('lonlats={lonlats}', urlParams)); + if (urlParams.straight != null) args.push(L.Util.template('straight={straight}', urlParams)); if (urlParams.pois != null && urlParams.pois.length > 0) args.push(L.Util.template('pois={pois}', urlParams)); if (urlParams.circlego != null) args.push(L.Util.template('ringgo={circlego}', urlParams)); if (urlParams.nogos != null) args.push(L.Util.template('nogos={nogos}', urlParams)); @@ -144,7 +153,7 @@ L.BRouter = L.Class.extend({ }, getRoute: function (latLngs, cb) { - var url = this.getUrl(latLngs, null, null, 'geojson'), + var url = this.getUrl(latLngs, null, null, null, 'geojson'), xhr = new XMLHttpRequest(); if (!url) { @@ -228,7 +237,7 @@ L.BRouter = L.Class.extend({ var segmentLatLng = segmentLatLngs[fi], featureMessage = featureMessages[mi]; - segmentLatLng.feature = this._getFeature(featureMessage); + segmentLatLng.feature = BR.TrackEdges.getFeature(featureMessage); segmentLatLng.message = featureMessage; if (featureLatLng.equals(segmentLatLngs[fi])) { @@ -241,22 +250,6 @@ L.BRouter = L.Class.extend({ return segment; }, - _getFeature: function (featureMessage) { - //["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags"] - return { - cost: { - perKm: parseInt(featureMessage[4]), - elev: parseInt(featureMessage[5]), - turn: parseInt(featureMessage[6]), - node: parseInt(featureMessage[7]), - initial: parseInt(featureMessage[8]), - }, - distance: parseInt(featureMessage[3]), - wayTags: featureMessage[9], - nodeTags: featureMessage[10], - }; - }, - _getFeatureLatLng: function (message) { var lon = message[0] / 1000000, lat = message[1] / 1000000; @@ -305,6 +298,27 @@ L.BRouter = L.Class.extend({ return lonlats; }, + _getBeelineString: function (beelineFlags) { + var indexes = []; + for (var i = 0; i < beelineFlags.length; i++) { + if (beelineFlags[i]) { + indexes.push(i); + } + } + return indexes.join(','); + }, + + _parseBeelines: function (s, lonlats) { + if (!lonlats || lonlats.length < 2) return []; + + const beelineFlags = new Array(lonlats.length - 1); + beelineFlags.fill(false); + for (const i of s.split(',')) { + beelineFlags[i] = true; + } + return beelineFlags; + }, + _getLonLatsNameString: function (latLngNames) { var s = ''; for (var i = 0; i < latLngNames.length; i++) { diff --git a/js/util/CheapRuler.js b/js/util/CheapRuler.js new file mode 100644 index 0000000..ad12fd1 --- /dev/null +++ b/js/util/CheapRuler.js @@ -0,0 +1,125 @@ +/* Generated from Java with JSweet 3.1.0 - http://www.jsweet.org */ +//var btools; +btools = {}; +(function (btools) { + var util; + (function (util) { + class CheapRuler { + static __static_initialize() { + if (!CheapRuler.__static_initialized) { + CheapRuler.__static_initialized = true; + CheapRuler.__static_initializer_0(); + } + } + static DEG_TO_RAD_$LI$() { + CheapRuler.__static_initialize(); + if (CheapRuler.DEG_TO_RAD == null) { + CheapRuler.DEG_TO_RAD = Math.PI / 180.0; + } + return CheapRuler.DEG_TO_RAD; + } + static SCALE_CACHE_$LI$() { + CheapRuler.__static_initialize(); + if (CheapRuler.SCALE_CACHE == null) { + CheapRuler.SCALE_CACHE = ((s) => { + let a = []; + while (s-- > 0) a.push(null); + return a; + })(CheapRuler.SCALE_CACHE_LENGTH); + } + return CheapRuler.SCALE_CACHE; + } + static __static_initializer_0() { + for (let i = 0; i < CheapRuler.SCALE_CACHE_LENGTH; i++) { + { + CheapRuler.SCALE_CACHE_$LI$()[i] = CheapRuler.calcKxKyFromILat( + i * CheapRuler.SCALE_CACHE_INCREMENT + ((CheapRuler.SCALE_CACHE_INCREMENT / 2) | 0) + ); + } + } + } + /*private*/ static calcKxKyFromILat(ilat) { + const lat = CheapRuler.DEG_TO_RAD_$LI$() * (ilat * CheapRuler.ILATLNG_TO_LATLNG - 90); + const cos = Math.cos(lat); + const cos2 = 2 * cos * cos - 1; + const cos3 = 2 * cos * cos2 - cos; + const cos4 = 2 * cos * cos3 - cos2; + const cos5 = 2 * cos * cos4 - cos3; + const kxky = [0, 0]; + kxky[0] = + (111.41513 * cos - 0.09455 * cos3 + 1.2e-4 * cos5) * + CheapRuler.ILATLNG_TO_LATLNG * + CheapRuler.KILOMETERS_TO_METERS; + kxky[1] = + (111.13209 - 0.56605 * cos2 + 0.0012 * cos4) * + CheapRuler.ILATLNG_TO_LATLNG * + CheapRuler.KILOMETERS_TO_METERS; + return kxky; + } + /** + * Calculate the degree->meter scale for given latitude + * + * @return {double[]} [lon->meter,lat->meter] + * @param {number} ilat + */ + static getLonLatToMeterScales(ilat) { + return CheapRuler.SCALE_CACHE_$LI$()[(ilat / CheapRuler.SCALE_CACHE_INCREMENT) | 0]; + } + /** + * Compute the distance (in meters) between two points represented by their + * (integer) latitude and longitude. + * + * @param {number} ilon1 Integer longitude for the start point. this is (longitude in degrees + 180) * 1e6. + * @param {number} ilat1 Integer latitude for the start point, this is (latitude + 90) * 1e6. + * @param {number} ilon2 Integer longitude for the end point, this is (longitude + 180) * 1e6. + * @param {number} ilat2 Integer latitude for the end point, this is (latitude + 90) * 1e6. + * @return {number} The distance between the two points, in meters. + * + * Note: + * Integer longitude is ((longitude in degrees) + 180) * 1e6. + * Integer latitude is ((latitude in degrees) + 90) * 1e6. + */ + static distance(ilon1, ilat1, ilon2, ilat2) { + const kxky = CheapRuler.getLonLatToMeterScales((ilat1 + ilat2) >> 1); + const dlon = (ilon1 - ilon2) * kxky[0]; + const dlat = (ilat1 - ilat2) * kxky[1]; + return Math.sqrt(dlat * dlat + dlon * dlon); + } + } + CheapRuler.__static_initialized = false; + /** + * Cheap-Ruler Java implementation + * See + * https://blog.mapbox.com/fast-geodesic-approximations-with-cheap-ruler-106f229ad016 + * for more details. + * + * Original code is at https://github.com/mapbox/cheap-ruler under ISC license. + * + * This is implemented as a Singleton to have a unique cache for the cosine + * values across all the code. + */ + CheapRuler.ILATLNG_TO_LATLNG = 1.0e-6; + CheapRuler.KILOMETERS_TO_METERS = 1000; + CheapRuler.SCALE_CACHE_LENGTH = 1800; + CheapRuler.SCALE_CACHE_INCREMENT = 100000; + util.CheapRuler = CheapRuler; + CheapRuler['__class'] = 'btools.util.CheapRuler'; + })((util = btools.util || (btools.util = {}))); +})(btools || (btools = {})); +btools.util.CheapRuler.__static_initialize(); + +btools.util.CheapRuler.toIntegerLngLat = (coordinate) => { + const ilon = Math.round((coordinate[0] + 180) * 1e6); + const ilat = Math.round((coordinate[1] + 90) * 1e6); + + return [ilon, ilat]; +}; + +btools.util.CheapRuler.calcDistance = (ilon1, ilat1, ilon2, ilat2) => { + const distanceFloat = btools.util.CheapRuler.distance(ilon1, ilat1, ilon2, ilat2); + + // Convert to integer (no decimals) values to match BRouter OsmPathElement.calcDistance: + // `(int)(CheapRuler.distance(ilon, ilat, p.getILon(), p.getILat()) + 1.0 );` + // https://github.com/abrensch/brouter/blob/1640bafa800f8bab7aebde797edc99fdbeea3b07/brouter-core/src/main/java/btools/router/OsmPathElement.java#L81 + return Math.trunc(distanceFloat + 1.0); +}; diff --git a/js/util/ClickTolerantBoxZoom.js b/js/util/ClickTolerantBoxZoom.js new file mode 100644 index 0000000..dc5ee17 --- /dev/null +++ b/js/util/ClickTolerantBoxZoom.js @@ -0,0 +1,55 @@ +/** + * Avoids conflict between shift-click and shift-drag. + * Extends BoxZoom to support a small click tolerance like in Draggable and + * a larger drag tolerance as a "neutral zone" before starting with box zoom dragging, + * to avoid accidental zooms. + */ +BR.ClickTolerantBoxZoom = L.Map.BoxZoom.extend({ + clickTolerance: L.Draggable.prototype.options.clickTolerance, + // use more than clickTolerance before starting box zoom to surely avoid accidental zooms + dragTolerance: 15, + // flag to enable or disable click/drag tolerance, classic BoxZoom behaviour when false + tolerant: true, + + // "neutral zone", state between clickTolerance and dragTolerance, + // already signals dragging to map and thus prevents click + _preMoved: false, + + moved: function () { + return this._preMoved || this._moved; + }, + + _resetState: function () { + L.Map.BoxZoom.prototype._resetState.call(this); + this._preMoved = false; + }, + + _onMouseMove: function (e) { + if (!this._moved) { + const point = this._map.mouseEventToContainerPoint(e); + + // derived from L.Draggable._onMove + var offsetPoint = point.clone()._subtract(this._startPoint); + var offset = Math.abs(offsetPoint.x || 0) + Math.abs(offsetPoint.y || 0); + + if (this.tolerant && offset < this.dragTolerance) { + if (!this._preMoved && offset >= this.clickTolerance) { + this._preMoved = true; + } + + return; + } + } + + L.Map.BoxZoom.prototype._onMouseMove.call(this, e); + }, + + _onMouseUp: function (e) { + L.Map.BoxZoom.prototype._onMouseUp.call(this, e); + + if (!this._moved && this._preMoved) { + this._clearDeferredResetState(); + this._resetStateTimeout = setTimeout(L.Util.bind(this._resetState, this), 0); + } + }, +}); diff --git a/js/util/LeafletPatches.js b/js/util/LeafletPatches.js new file mode 100644 index 0000000..bfdae9c --- /dev/null +++ b/js/util/LeafletPatches.js @@ -0,0 +1,16 @@ +// Fixes wrong added offset when dragging, which can leave mouse off the marker +// after dragging and cause a map click +// see https://github.com/Leaflet/Leaflet/pull/7446 +// see https://github.com/Leaflet/Leaflet/issues/4457 +L.Draggable.prototype._onMoveOrig = L.Draggable.prototype._onMove; +L.Draggable.prototype._onMove = function (e) { + var start = !this._moved; + + this._onMoveOrig.call(this, e); + + if (start && this._moved) { + var offset = this._newPos.subtract(this._startPos); + this._startPos = this._startPos.add(offset); + this._newPos = this._newPos.add(offset); + } +}; diff --git a/js/util/StdPath.js b/js/util/StdPath.js new file mode 100644 index 0000000..108c39a --- /dev/null +++ b/js/util/StdPath.js @@ -0,0 +1,164 @@ +(function () { + // Calculates time and energy stats + + class BExpressionContextWay { + constructor(maxspeed = 45.0, costfactor = 1.0) { + this.maxspeed = maxspeed; + this.costfactor = costfactor; + } + getMaxspeed() { + return this.maxspeed; + } + getCostfactor() { + return this.costfactor; + } + } + + class BExpressionContext { + constructor(profile) { + this.profile = profile; + } + + getVariableValue(name, defaultValue) { + let value = this.profile?.getProfileVar(name) ?? defaultValue; + if (value === 'true') { + value = 1; + } else if (value === 'false') { + value = 0; + } + return +value; + } + } + + // from BRouter btools.router.RoutingContext + class RoutingContext { + constructor(profile) { + this.expctxGlobal = new BExpressionContext(profile); + this.expctxWay = new BExpressionContextWay(); + + this.bikeMode = 0 !== this.expctxGlobal.getVariableValue('validForBikes', 0); + this.footMode = 0 !== this.expctxGlobal.getVariableValue('validForFoot', 0); + + this.totalMass = this.expctxGlobal.getVariableValue('totalMass', 90.0); + this.maxSpeed = this.expctxGlobal.getVariableValue('maxSpeed', this.footMode ? 6.0 : 45.0) / 3.6; + this.S_C_x = this.expctxGlobal.getVariableValue('S_C_x', 0.5 * 0.45); + this.defaultC_r = this.expctxGlobal.getVariableValue('C_r', 0.01); + this.bikerPower = this.expctxGlobal.getVariableValue('bikerPower', 100.0); + } + } + + // from BRouter btools.router.StdPath + class StdPath { + constructor() { + this.totalTime = 0; + this.totalEnergy = 0; + this.elevation_buffer = 0; + } + + /** + * Approximation to Math.exp for small negative arguments + * @param {number} e + * @return {number} + */ + static exp(e) { + var x = e; + var f = 1.0; + while (e < -1.0) { + { + e += 1.0; + f *= 0.367879; + } + } + return f * (1.0 + x * (1.0 + x * (0.5 + x * (0.166667 + 0.0416667 * x)))); + } + + static solveCubic(a, c, d) { + var v = 8.0; + var findingStartvalue = true; + for (var i = 0; i < 10; i++) { + { + var y = (a * v * v + c) * v - d; + if (y < 0.1) { + if (findingStartvalue) { + v *= 2.0; + continue; + } + break; + } + findingStartvalue = false; + var y_prime = 3 * a * v * v + c; + v -= y / y_prime; + } + } + return v; + } + + resetState() { + this.totalTime = 0.0; + this.totalEnergy = 0.0; + this.elevation_buffer = 0.0; + } + + calcIncline(dist) { + var min_delta = 3.0; + var shift; + if (this.elevation_buffer > min_delta) { + shift = -min_delta; + } else if (this.elevation_buffer < min_delta) { + shift = -min_delta; + } else { + return 0.0; + } + var decayFactor = StdPath.exp(-dist / 100.0); + var new_elevation_buffer = (this.elevation_buffer + shift) * decayFactor - shift; + var incline = (this.elevation_buffer - new_elevation_buffer) / dist; + this.elevation_buffer = new_elevation_buffer; + return incline; + } + + computeKinematic(rc, dist, delta_h, detailMode) { + if (!detailMode) { + return; + } + this.elevation_buffer += delta_h; + var incline = this.calcIncline(dist); + var wayMaxspeed; + wayMaxspeed = rc.expctxWay.getMaxspeed() / 3.6; + if (wayMaxspeed === 0) { + wayMaxspeed = rc.maxSpeed; + } + wayMaxspeed = Math.min(wayMaxspeed, rc.maxSpeed); + var speed; + var f_roll = rc.totalMass * StdPath.GRAVITY * (rc.defaultC_r + incline); + if (rc.footMode || rc.expctxWay.getCostfactor() > 4.9) { + speed = rc.maxSpeed * 3.6; + speed = (speed * StdPath.exp(-3.5 * Math.abs(incline + 0.05))) / 3.6; + } else if (rc.bikeMode) { + speed = StdPath.solveCubic(rc.S_C_x, f_roll, rc.bikerPower); + speed = Math.min(speed, wayMaxspeed); + } else { + speed = wayMaxspeed; + } + var dt = dist / speed; + this.totalTime += dt; + var energy = dist * (rc.S_C_x * speed * speed + f_roll); + if (energy > 0.0) { + this.totalEnergy += energy; + } + } + + getTotalTime() { + return this.totalTime; + } + + getTotalEnergy() { + return this.totalEnergy; + } + } + + StdPath.GRAVITY = 9.81; + + BR.StdPath = StdPath; + BR.RoutingContext = RoutingContext; + BR.BExpressionContextWay = BExpressionContextWay; +})(); diff --git a/js/util/Track.js b/js/util/Track.js index 19734ca..27cb7fa 100644 --- a/js/util/Track.js +++ b/js/util/Track.js @@ -32,6 +32,7 @@ BR.Track = { zIndexOffset: -1000, }); }, + pane: 'tracks', }; }, diff --git a/js/util/TrackEdges.js b/js/util/TrackEdges.js index 6602545..9102ec8 100644 --- a/js/util/TrackEdges.js +++ b/js/util/TrackEdges.js @@ -5,6 +5,24 @@ * @type {L.Class} */ BR.TrackEdges = L.Class.extend({ + statics: { + getFeature: function (featureMessage) { + //["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags"] + return { + cost: { + perKm: parseInt(featureMessage[4]), + elev: parseInt(featureMessage[5]), + turn: parseInt(featureMessage[6]), + node: parseInt(featureMessage[7]), + initial: parseInt(featureMessage[8]), + }, + distance: parseInt(featureMessage[3]), + wayTags: featureMessage[9], + nodeTags: featureMessage[10], + }; + }, + }, + /** * List of indexes for the track array where * a segment with different features ends diff --git a/locales/en.json b/locales/en.json index d9dc498..d06796a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -43,6 +43,7 @@ }, "footer": { "ascend": "Ascend", + "beeline-warning": "Warning: no data for straight lines, values interpolated", "cost": "Cost", "distance": "Distance", "elevation-chart": "Toggle elevation chart", @@ -159,11 +160,12 @@ "route-quality-cost": "Cost coding", "route-quality-incline": "Incline coding", "route-quality-shortcut": "{{action}} ({{key}} key to toggle)", - "route-tooltip-segment": "Drag to create a new waypoint", + "route-tooltip-segment": "Drag to create a new waypoint. Click to toggle straight line.", "route-tooltip-waypoint": "Waypoint. Drag to move; Click to remove.", "strava-biking": "Show Strava biking segments", "strava-running": "Show Strava running segments", "strava-shortcut": "{{action}}\n({{key}} key to toggle layer, click to reload for current area)", + "toggle-beeline": "Toggle straight line", "zoomInTitle": "Zoom in", "zoomOutTitle": "Zoom out" }, diff --git a/locales/keys.js b/locales/keys.js index ceba32f..8d1f07b 100644 --- a/locales/keys.js +++ b/locales/keys.js @@ -13,6 +13,7 @@ i18next.t('map.draw-poi-start'); i18next.t('map.draw-poi-stop'); i18next.t('map.draw-route-start'); i18next.t('map.draw-route-stop'); +i18next.t('map.toggle-beeline'); i18next.t('map.geocoder'); i18next.t('map.locate-me'); i18next.t('map.nogo.cancel'); diff --git a/package.json b/package.json index 5b86b70..31697e5 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "jquery": "3.5.1", "jquery-i18next": "^1.2.1", "jstree": "^3.3.8", - "leaflet": "~1.6.0", + "leaflet": "~1.7.1", "leaflet-control-geocoder": "^2.2.0", "leaflet-easybutton": "*", "leaflet-editable": "^1.1.0", @@ -69,7 +69,7 @@ "leaflet-osm-notes": "osmlab/leaflet-osm-notes#af2aa811", "leaflet-plugins": "~3.0.0", "leaflet-providers": "^1.10.2", - "leaflet-routing": "nrenner/leaflet-routing#e94e153", + "leaflet-routing": "nrenner/leaflet-routing#773314a", "leaflet-sidebar-v2": "nrenner/leaflet-sidebar-v2#dev", "leaflet-triangle-marker": "^1.0.2", "leaflet.heightgraph": "nrenner/Leaflet.Heightgraph#0757b2a", @@ -117,7 +117,7 @@ "gulp-util": "^3.0.7", "gulp-zip": "^5.0.2", "husky": "^4.3.4", - "i18next-scanner": "^3.0.0", + "i18next-scanner": "^3.1.0", "jest": "^26.6.3", "marked": "^4.0.10", "merge-stream": "^2.0.0", diff --git a/tests/format/Gpx.test.js b/tests/format/Gpx.test.js index df9d972..5888aa8 100644 --- a/tests/format/Gpx.test.js +++ b/tests/format/Gpx.test.js @@ -10,6 +10,7 @@ require('../../js/format/Gpx.js'); const fs = require('fs'); +// lonlats=8.467712,49.488117;8.470598,49.488849 + turnInstructionMode = 4 (comment-style) const geoJson = require('./data/track.json'); // lonlats=8.467712,49.488117;8.469354,49.488394;8.470556,49.488946;8.469982,49.489176 + turnInstructionMode = 5 // console log in Export._formatTrack @@ -64,6 +65,8 @@ describe('voice hints', () => { /:(rteTime|rteSpeed)>([\d.]*)<\//g, (match, p1, p2) => `:${p1}>${(+p2).toFixed(3)}` ); + // ignore off by one due to times passed with 3 decimals + brouterGpx = brouterGpx.replace('rteSpeed>9.361<', 'rteSpeed>9.360<'); const gpx = BR.Gpx.format(geoJson, 2); expect(gpx).toEqual(brouterGpx); diff --git a/tests/format/data/track.json b/tests/format/data/track.json index b2c989c..0054266 100644 --- a/tests/format/data/track.json +++ b/tests/format/data/track.json @@ -4,7 +4,7 @@ { "type": "Feature", "properties": { - "creator": "BRouter-1.1", + "creator": "BRouter-1.6.3", "name": "Track", "track-length": "319", "filtered ascend": "2", @@ -17,11 +17,11 @@ [5,2,0,90.0,-90," 6(-89)6 (0)6 (89)6"] ], "messages": [ - ["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags"], - ["8468340", "49488794", "101", "89", "1000", "0", "0", "0", "0", "highway=residential surface=asphalt cycleway=lane oneway=yes lcn=yes smoothness=good route_bicycle_icn=yes route_bicycle_ncn=yes route_bicycle_rcn=yes", ""], - ["8470671", "49488909", "99", "230", "1150", "0", "180", "0", "0", "highway=residential surface=asphalt oneway=yes smoothness=good", ""] + ["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags", "Time", "Energy"], + ["8468340", "49488794", "101", "89", "1000", "0", "0", "0", "0", "highway=residential surface=asphalt cycleway=lane oneway=yes lcn=yes smoothness=good route_bicycle_icn=yes route_bicycle_ncn=yes route_bicycle_rcn=yes", "", "9", "959"], + ["8470671", "49488909", "99", "230", "1150", "0", "180", "0", "0", "highway=residential surface=asphalt oneway=yes smoothness=good", "", "44", "4412"] ], - "times": [0.0,9.592433,12.270765,14.129882,19.406338,34.50238,44.117233] + "times": [0,9.592,12.271,14.13,19.406,34.502,44.117] }, "geometry": { "type": "LineString", diff --git a/tests/util/CheapRuler.test.js b/tests/util/CheapRuler.test.js new file mode 100644 index 0000000..28b9498 --- /dev/null +++ b/tests/util/CheapRuler.test.js @@ -0,0 +1,38 @@ +require('../../js/util/CheapRuler.js'); +const geoJson = require('../format/data/track.json'); + +test('distance', () => { + // https://github.com/abrensch/brouter/issues/3#issuecomment-440375918 + const lngLat1 = [2.3158, 48.8124]; + const lngLat2 = [2.321, 48.8204]; + + const [ilon1, ilat1] = btools.util.CheapRuler.toIntegerLngLat(lngLat1); + const [ilon2, ilat2] = btools.util.CheapRuler.toIntegerLngLat(lngLat2); + + const distance = btools.util.CheapRuler.distance(ilon1, ilat1, ilon2, ilat2); + + // 968.1670119067338 - issue #3 (App.java) + // 968.0593622374572 - CheapRuler.java + expect(distance).toBeCloseTo(968.0593622374572); +}); + +test('total distance', () => { + const coordinates = geoJson.features[0].geometry.coordinates; + const properties = geoJson.features[0].properties; + let totalDistance = 0; + + for (let i = 0; i < coordinates.length; i++) { + if (i === 0) continue; + + const coord1 = coordinates[i - 1]; + const coord2 = coordinates[i]; + + const [ilon1, ilat1] = btools.util.CheapRuler.toIntegerLngLat(coord1); + const [ilon2, ilat2] = btools.util.CheapRuler.toIntegerLngLat(coord2); + + const distance = btools.util.CheapRuler.calcDistance(ilon1, ilat1, ilon2, ilat2); + totalDistance += distance; + } + + expect(Math.round(totalDistance)).toEqual(+properties['track-length']); +}); diff --git a/tests/util/StdPath.test.js b/tests/util/StdPath.test.js new file mode 100644 index 0000000..5561fe3 --- /dev/null +++ b/tests/util/StdPath.test.js @@ -0,0 +1,36 @@ +BR = {}; +require('../../js/util/CheapRuler.js'); +require('../../js/util/StdPath.js'); + +const geoJson = require('../format/data/track.json'); + +test('simple track', () => { + const coordinates = geoJson.features[0].geometry.coordinates; + const properties = geoJson.features[0].properties; + const dummyProfileVars = { + getProfileVar(name) { + const vars = { validForBikes: 1 }; + return vars[name]; + }, + }; + const rc = new BR.RoutingContext(dummyProfileVars); + const stdPath = new BR.StdPath(); + + for (let i = 0; i < coordinates.length; i++) { + if (i === 0) continue; + + const coord1 = coordinates[i - 1]; + const coord2 = coordinates[i]; + + const [ilon1, ilat1] = btools.util.CheapRuler.toIntegerLngLat(coord1); + const [ilon2, ilat2] = btools.util.CheapRuler.toIntegerLngLat(coord2); + + const distance = btools.util.CheapRuler.calcDistance(ilon1, ilat1, ilon2, ilat2); + const deltaHeight = coord2[2] - coord1[2]; + + stdPath.computeKinematic(rc, distance, deltaHeight, true); + } + + expect(Math.round(stdPath.getTotalEnergy())).toEqual(+properties['total-energy']); + expect(Math.round(stdPath.getTotalTime())).toEqual(+properties['total-time']); +}); diff --git a/yarn.lock b/yarn.lock index 449687b..23928ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5106,6 +5106,11 @@ esprima-fb@3001.1.0-dev-harmony-fb: resolved "https://registry.yarnpkg.com/esprima-fb/-/esprima-fb-3001.0001.0000-dev-harmony-fb.tgz#b77d37abcd38ea0b77426bb8bc2922ce6b426411" integrity sha1-t303q8046gt3Qmu4vCkizmtCZBE= +esprima-next@^5.7.0: + version "5.8.1" + resolved "https://registry.yarnpkg.com/esprima-next/-/esprima-next-5.8.1.tgz#e670c9e807dce91075160d7cd7735c4b74581338" + integrity sha512-jPuleZ9j065A9xGKreFh9YSgPlbL9/miG/l4KslkwEb7Ilwl5Ct7BmDkSTHA0rW0qnqLx+hsZWIB66s1XaMAyA== + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -6487,10 +6492,10 @@ i18next-browser-languagedetector@^6.0.1: dependencies: "@babel/runtime" "^7.5.5" -i18next-scanner@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/i18next-scanner/-/i18next-scanner-3.0.0.tgz#16024fa7f6dc5fd73d91545bd01566f86a76529a" - integrity sha512-cm4Ch3VqicGZS8y+4xSvXoOsnE/iWhHZi6AZEyAgLLm3EDZ/eY21gDbLfbnwKVY6wCghzAEO9LfRNlxwTo8KMQ== +i18next-scanner@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/i18next-scanner/-/i18next-scanner-3.1.0.tgz#35d00d945637c1a2b90124b0fd327040ac197598" + integrity sha512-dHLXUJIiF1CYJNslCkJFDYJySk5fg+dzdg9O73XXqHcdZwJ2947SWusqq8HdNFB7LpkBi8oTG6TWLZPmqbAh8Q== dependencies: acorn "^8.0.4" acorn-dynamic-import "^4.0.0" @@ -6503,7 +6508,7 @@ i18next-scanner@^3.0.0: deepmerge "^4.0.0" ensure-array "^1.0.0" eol "^0.9.1" - esprima "^4.0.0" + esprima-next "^5.7.0" gulp-sort "^2.0.0" i18next "*" lodash "^4.0.0" @@ -7776,9 +7781,9 @@ leaflet-providers@^1.10.2: resolved "https://registry.yarnpkg.com/leaflet-providers/-/leaflet-providers-1.10.2.tgz#763c8e6655f26caf1afe3a1ef4add6c3e32de663" integrity sha512-1l867LObxwuFBeyPeBewip8PAXKOnvEoujq4/9y2TKTiZNHH76ksBD6dfktGjgUrOF+IdjsGHkpASPE+v2DQLw== -leaflet-routing@nrenner/leaflet-routing#e94e153: - version "0.1.3" - resolved "https://codeload.github.com/nrenner/leaflet-routing/tar.gz/e94e153b7574510313cb0bfefcd8776edebf627e" +leaflet-routing@nrenner/leaflet-routing#773314a: + version "0.1.4-beta" + resolved "https://codeload.github.com/nrenner/leaflet-routing/tar.gz/773314a37940b32b2fec84886611ecbd4d6f3df9" leaflet-sidebar-v2@nrenner/leaflet-sidebar-v2#dev: version "3.0.2" @@ -7831,12 +7836,12 @@ leaflet@^1.0.1, leaflet@^1.3.4: resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.4.0.tgz#d5f56eeb2aa32787c24011e8be4c77e362ae171b" integrity sha512-x9j9tGY1+PDLN9pcWTx9/y6C5nezoTMB8BLK5jTakx+H7bPlnbCHfi9Hjg+Qt36sgDz/cb9lrSpNQXmk45Tvhw== -leaflet@^1.5.0: +leaflet@^1.5.0, leaflet@~1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.7.1.tgz#10d684916edfe1bf41d688a3b97127c0322a2a19" integrity sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw== -leaflet@^1.6.0, leaflet@~1.6.0: +leaflet@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.6.0.tgz#aecbb044b949ec29469eeb31c77a88e2f448f308" integrity sha512-CPkhyqWUKZKFJ6K8umN5/D2wrJ2+/8UIpXppY7QDnUZW5bZL5+SEI2J7GBpwh4LIupOKqbNSQXgqmrEJopHVNQ==