diff --git a/index.html b/index.html index 939bbb4..278cacb 100644 --- a/index.html +++ b/index.html @@ -1149,6 +1149,8 @@ ); + + diff --git a/js/control/Export.js b/js/control/Export.js index 3740c8b..b9645f2 100644 --- a/js/control/Export.js +++ b/js/control/Export.js @@ -31,8 +31,9 @@ BR.Export = L.Class.extend({ this.update([]); }, - update: function (latLngs) { + update: function (latLngs, segments) { this.latLngs = latLngs; + this.segments = segments; if (latLngs.length < 2) { this.exportButton.addClass('disabled'); @@ -47,15 +48,38 @@ BR.Export = L.Class.extend({ var name = encodeURIComponent(exportForm['trackname'].value); var includeWaypoints = exportForm['include-waypoints'].checked; - var uri = this.router.getUrl(this.latLngs, this.pois.getMarkers(), null, format, name, includeWaypoints); - e.preventDefault(); - 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'); - link.href = uri; - link.dispatchEvent(evt); + if (true) { + var uri = this.router.getUrl(this.latLngs, this.pois.getMarkers(), null, format, name, 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'); + // link.href = uri; + // link.dispatchEvent(evt); + //} else { + + const track = this._formatTrack(format, name, includeWaypoints); + BR.Export.diff(uri, track, format); + } + }, + + _formatTrack: function (format, name, includeWaypoints) { + const track = BR.Export._concatTotalTrack(this.segments); + //console.log('GeoJson: ', trackGeoJson); + //console.log('GeoJson: ', JSON.stringify(trackGeoJson, null, 4)); + switch (format) { + case 'gpx': + //console.log('gpx: ', gpx); + return BR.Gpx.format(track, 2, 'bike'); // TODO parse turnInstructionMode, transportMode + case 'geojson': + return JSON.stringify(track, null, 2); + case 'kml': + default: + break; + } + console.error('Export format not implemented: ' + format); }, _validationMessage: function () { @@ -72,6 +96,7 @@ BR.Export = L.Class.extend({ }, _generateTrackname: function () { + return; // TODO remove var trackname = this.trackname; this._getCityAtPosition( this.latLngs[0], @@ -143,3 +168,150 @@ BR.Export = L.Class.extend({ BR.export = function () { return new BR.Export(); }; + +BR.Export._concatTotalTrack = function (segments) { + const sumProperties = (p, fp, keys) => { + for (const key of keys) { + p[key] = (+p[key] + +fp[key]).toString(); + } + }; + let coordinates = []; + let properties; + + //console.time('_concatTotalTrack'); + for (const [segmentIndex, segment] of segments.entries()) { + const feature = segment.feature; + if (!feature) continue; + + const coordOffset = coordinates.length > 0 ? coordinates.length - 1 : 0; + if (properties) { + const p = properties; + const fp = feature.properties; + + sumProperties(p, fp, [ + 'cost', + 'filtered ascend', + 'plain-ascend', + 'total-energy', + 'total-time', + 'track-length', + ]); + + p.messages = p.messages.concat(fp.messages.slice(1)); + if (p.times && fp.times) { + const lastTime = p.times[p.times.length - 1]; + for (const [timeIndex, time] of fp.times.entries()) { + if (timeIndex > 0) { + p.times.push(+(lastTime + time).toFixed(3)); + } + } + } + if (fp.voicehints) { + if (!p.voicehints) p.voicehints = []; + for (const fpHint of fp.voicehints) { + const hint = fpHint.slice(); + hint[0] += coordOffset; + p.voicehints.push(hint); + } + } + } else { + // clone + properties = Object.assign({}, feature.properties); + if (properties.voicehints) { + properties.voicehints = properties.voicehints.slice(); + } + if (properties.times) { + properties.times = properties.times.slice(); + } + } + + let featureCoordinates = feature.geometry.coordinates; + if (segmentIndex > 0) { + // remove first segment coordinate, same as previous last + featureCoordinates = featureCoordinates.slice(1); + } + coordinates = coordinates.concat(featureCoordinates); + } + //console.timeEnd('_concatTotalTrack'); + + return turf.featureCollection([turf.lineString(coordinates, properties)]); +}; + +// +BR.Export.diff = function (uri, track, format) { + BR.Util.get( + uri, + ((err, text) => { + if (err) { + console.error('Error exporting "' + profileUrl + '": ' + err); + return; + } + + if (format === 'gpx') { + text = BR.Gpx.pretty(BR.Export.adoptGpx(text)); + } else if (format === 'geojson') { + text = JSON.stringify(JSON.parse(text), null, 2); + } + var dmp = new diff_match_patch(); + var diff = dmp.diff_main(text, track); + dmp.diff_cleanupSemantic(diff); + + if (dmp.diff_levenshtein(diff) > 0) { + let i = 0; + while (i < diff.length - 2) { + if ( + diff[i][0] === 0 && + diff[i + 1][0] === -1 && + diff[i + 2][0] === 1 && + (/(rteTime|rteSpeed)>\d+\.\d{0,2}$/.test(diff[i][1]) || /time=[0-9h ]*m \d$/.test(diff[i][1])) + ) { + const del = +diff[i + 1][1]; + const ins = +diff[i + 2][1]; + if (Number.isInteger(del) && Number.isInteger(ins) && Math.abs(del - ins) === 1) { + diff.splice(i + 1, 2); + } + } + i++; + } + } + + if (dmp.diff_levenshtein(diff) > 0) { + //console.log('server: ', text); + //console.log('client: ', track); + console.log(diff); + bootbox.alert(dmp.diff_prettyHtml(diff)); + } else { + console.log('diff equal'); + } + }).bind(this) + ); +}; + +// TODO remove +// copied from Gpx.test.js +BR.Export.adoptGpx = function (gpx, replaceCreator = true) { + const creator = 'togpx'; + const name = 'Track'; + const newline = '\n'; + + gpx = gpx.replace(/=\.(?=\d)/, '=0.'); + if (replaceCreator) { + gpx = gpx.replace(/creator="[^"]*"/, `creator="${creator}"`); + } + gpx = gpx.replace(/creator="([^"]*)" version="1.1"/, 'version="1.1" \n creator="$1"'); + //gpx = gpx.replace(/\n [^<]*<\/name>/, `\n ${name}`); + gpx = gpx + .split(newline) + .map((line) => line.replace(/lon="([^"]*)" lat="([^"]*)"/, 'lat="$2" lon="$1"')) + .join(newline); + gpx = gpx.replace(/(lon|lat)="([-0-9]+.[0-9]+?)0+"/g, '$1="$2"'); // remove trailing zeros + gpx = gpx.replace('\n', ''); + + // added + gpx = gpx.replace(/>([-.0-9]+?0+)<\//g, (match, p1) => `>${+p1}([^<]*)<\//g, (match, p1, p2) => `${p1}>${(+p2).toFixed(3)}\n?\s*/, ''); // ignore (invalid) double tag + + return gpx; +}; diff --git a/js/format/Gpx.js b/js/format/Gpx.js index a56d139..9b8593f 100644 --- a/js/format/Gpx.js +++ b/js/format/Gpx.js @@ -49,14 +49,25 @@ BR.Gpx = { comment += ' energy=' + (props['total-energy'] / 3600000).toFixed(1) + 'kwh'; } if (props['total-time']) { - // TODO format, e.g. total-time=14833 -> time=4h 7m 13s - // see brouter OsmTrack.getFormattedTime2 - comment += ' time=' + props['total-time'] + 's'; + comment += ' time=' + BR.Gpx.formatTime(props['total-time']); } comment += ' -->'; return comment; }, + // 14833 -> 4h 7m 13s + // see BRouter OsmTrack.getFormattedTime2 + formatTime(seconds) { + const hours = Math.trunc(seconds / 3600); + const minutes = Math.trunc((seconds - hours * 3600) / 60); + seconds = seconds - hours * 3600 - minutes * 60; + let time = ''; + if (hours != 0) time += hours + 'h '; + if (minutes != 0) time += minutes + 'm '; + if (seconds != 0) time += seconds + 's'; + return time; + }, + // modified version of // https://gist.github.com/sente/1083506#gistcomment-2254622 // MIT License, Copyright (c) 2016 Stuart Powers, ES6 version by Jonathan Gruber diff --git a/js/format/VoiceHints.js b/js/format/VoiceHints.js index 3be14c2..6dc3c64 100644 --- a/js/format/VoiceHints.js +++ b/js/format/VoiceHints.js @@ -11,21 +11,25 @@ class RoundaboutCommand extends Command { constructor(command, exitNumber) { - this.name = command.name + exitNumber; - this.locus = command.locus + exitNumber; - this.orux = command.orux + exitNumber; - this.symbol = command.symbol + exitNumber; - this.message = command.message + exitNumber; + super( + command.name + exitNumber, + command.locus + exitNumber, + command.orux + exitNumber, + command.symbol + exitNumber, + command.message + exitNumber + ); } } class RoundaboutLeftCommand extends RoundaboutCommand { constructor(command, exitNumber) { - this.name = command.name + -exitNumber; - this.locus = command.locus + -exitNumber; - this.orux = command.orux + exitNumber; - this.symbol = command.symbol + -exitNumber; - this.message = command.message + -exitNumber; + super( + command.name + -exitNumber, + command.locus + -exitNumber, + command.orux + exitNumber, + command.symbol + -exitNumber, + command.message + -exitNumber + ); } } @@ -82,36 +86,25 @@ }, _getDuration: function (voicehintsIndex) { - const timeList = this.track.properties.times; + const times = this.track.properties.times; + if (!times) return 0; + const indexInTrack = this.voicehints[voicehintsIndex][0]; - const currTime = timeList[indexInTrack]; + const currentTime = times[indexInTrack]; const len = this.voicehints.length; - const nextIndex = voicehintsIndex < len - 1 ? this.voicehints[voicehintsIndex + 1][0] : timeList.length - 1; - const nextTime = timeList[nextIndex]; + const nextIndex = voicehintsIndex < len - 1 ? this.voicehints[voicehintsIndex + 1][0] : times.length - 1; + const nextTime = times[nextIndex]; - const duration = nextTime - currTime; - - // TODO remove - const time = this.voicehints[voicehintsIndex][4]; - const p = 5; - if (!(time.toPrecision(p) === duration.toPrecision(p))) { - console.error( - `${voicehintsIndex}: ${time.toPrecision(p)} =? ${duration.toPrecision(p)}, ${time} =? ${duration}` - ); - } - - return duration; + return nextTime - currentTime; }, _loopHints: function (hintCallback) { if (!this.voicehints) return; for (const [i, values] of this.voicehints.entries()) { - const [indexInTrack, commandId, exitNumber, distance, time, angle, geometry] = values; - const hint = { indexInTrack, commandId, exitNumber, distance, time, angle, geometry }; + const [indexInTrack, commandId, exitNumber, distance, angle, geometry] = values; + const hint = { indexInTrack, commandId, exitNumber, distance, angle, geometry }; - // TODO remove server hint time - //hint.time = this._getDuration(i); - this._getDuration(i); + hint.time = this._getDuration(i); if (hint.time > 0) { hint.speed = distance / hint.time; } @@ -229,8 +222,8 @@ extensions['locus:rteDistance'] = hint.distance; if (hint.time > 0) { - extensions['locus:rteTime'] = hint.time; - extensions['locus:rteSpeed'] = hint.speed; + extensions['locus:rteTime'] = hint.time.toFixed(3); + extensions['locus:rteSpeed'] = hint.speed.toFixed(3); } extensions['locus:rtePointAction'] = cmd.locus; diff --git a/js/index.js b/js/index.js index aaa7037..264637e 100644 --- a/js/index.js +++ b/js/index.js @@ -300,7 +300,7 @@ trackMessages.update(track, segments); trackAnalysis.update(track, segments); - exportRoute.update(latLngs); + exportRoute.update(latLngs, segments); } routing.addTo(map); diff --git a/tests/control/Export.test.js b/tests/control/Export.test.js new file mode 100644 index 0000000..5d1f8a4 --- /dev/null +++ b/tests/control/Export.test.js @@ -0,0 +1,40 @@ +BR = {}; +require('leaflet'); +turf = require('@turf/turf'); +require('../../js/control/Export.js'); + +// &lonlats=8.467712,49.488117;8.469354,49.488394;8.470556,49.488946;8.469982,49.489176 + turnInstructionMode=2 +const segments = require('./data/segments.json'); +const brouterTotal = require('./data/brouterTotal.json'); + +// resolve intended/accepted differences before comparing +function adopt(total, brouterTotal) { + // BRouter total aggregates messages over segments, client total does not, + // but that's Ok, so just fix for the test comparison + const messages = total.features[0].properties.messages; + const message = messages[4].slice(); + messages[4] = message; + message[3] = (+message[3] + +messages[2][3] + +messages[3][3]).toString(); + message[6] = (+message[6] + +messages[2][6] + +messages[3][6]).toString(); + messages.splice(2, 2); + + // fix minor float rounding difference + total.features[0].properties.times[6] = 28.833; // 28.832 + + total.features[0].properties.name = brouterTotal.features[0].properties.name; +} + +test('total track', () => { + const segmentsString = JSON.stringify(segments, null, 2); + let total = BR.Export._concatTotalTrack(segments); + adopt(total, brouterTotal); + expect(total).toEqual(brouterTotal); + + // test original segments are not modified + expect(JSON.stringify(segments, null, 2)).toEqual(segmentsString); + + // should be repeatable + total = BR.Export._concatTotalTrack(segments); + adopt(total, brouterTotal); + expect(total).toEqual(brouterTotal); +}); diff --git a/tests/control/data/brouterTotal.json b/tests/control/data/brouterTotal.json new file mode 100644 index 0000000..8ebefc4 --- /dev/null +++ b/tests/control/data/brouterTotal.json @@ -0,0 +1,45 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "creator": "BRouter-1.1", + "name": "brouter_1615489279610_0", + "track-length": "388", + "filtered ascend": "1", + "plain-ascend": "0", + "total-time": "44", + "total-energy": "4420", + "cost": "703", + "voicehints": [ + [1,5,0,88.0,89], + [6,2,0,99.0,-90], + [7,2,0,10.0,-90] + ], + "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", ""], + ["8469852", "49489230", "100", "299", "1150", "0", "270", "0", "0", "highway=residential surface=asphalt oneway=yes smoothness=good", ""] + ], + "times": [0,9.592,12.271,14.13,19.406,22.134,28.833,37.817,38.938,44.217] + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [8.467714, 49.488115, 101.5], + [8.468340, 49.488794, 101.5], + [8.468586, 49.488698, 101.5], + [8.468743, 49.488636, 101.5], + [8.469161, 49.488473, 101.75], + [8.469355, 49.488395, 102.0], + [8.469971, 49.488151, 103.5], + [8.470671, 49.488909, 99.5], + [8.470561, 49.488951, 99.5], + [8.469984, 49.489178, 100.0] + ] + } + } + ] + } + \ No newline at end of file diff --git a/tests/control/data/segments.json b/tests/control/data/segments.json new file mode 100644 index 0000000..e8d59e2 --- /dev/null +++ b/tests/control/data/segments.json @@ -0,0 +1,97 @@ +[{ + "feature": { + "type": "Feature", + "properties": { + "creator": "BRouter-1.1", + "name": "brouter_1615393581719_0", + "track-length": "177", + "filtered ascend": "0", + "plain-ascend": "1", + "total-time": "22", + "total-energy": "2213", + "cost": "280", + "voicehints": [ + [1,5,0,88.0,89] + ], + "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", ""], + ["8469971", "49488151", "102", "88", "1150", "0", "90", "0", "0", "highway=residential surface=asphalt oneway=yes smoothness=good", ""] + ], + "times": [0,9.592,12.271,14.13,19.406,22.134] + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [8.467714, 49.488115, 101.5], + [8.468340, 49.488794, 101.5], + [8.468586, 49.488698, 101.5], + [8.468743, 49.488636, 101.5], + [8.469161, 49.488473, 101.75], + [8.469355, 49.488395, 102.0] + ] + } + } +}, +{ + "feature": { + "type": "Feature", + "properties": { + "creator": "BRouter-1.1", + "name": "brouter_1615393581719_0", + "track-length": "162", + "filtered ascend": "1", + "plain-ascend": "-2", + "total-time": "17", + "total-energy": "1680", + "cost": "367", + "voicehints": [ + [1,2,0,99.0,-90], + [2,2,0,10.0,-90] + ], + "messages": [ + ["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags"], + ["8469852", "49489230", "99", "162", "1150", "0", "180", "0", "0", "highway=residential surface=asphalt oneway=yes smoothness=good", ""] + ], + "times": [0,6.698,15.683,16.804] + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [8.469355, 49.488395, 102.0], + [8.469971, 49.488151, 103.5], + [8.470671, 49.488909, 99.5], + [8.470561, 49.488951, 99.5] + ] + } + } + +}, +{ + "feature": { + "type": "Feature", + "properties": { + "creator": "BRouter-1.1", + "name": "brouter_1615393581719_0", + "track-length": "49", + "filtered ascend": "0", + "plain-ascend": "1", + "total-time": "5", + "total-energy": "527", + "cost": "56", + "messages": [ + ["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags"], + ["8469852", "49489230", "100", "49", "1150", "0", "0", "0", "0", "highway=residential surface=asphalt oneway=yes smoothness=good", ""] + ], + "times": [0,5.279] + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [8.470561, 49.488951, 99.5], + [8.469984, 49.489178, 100.0] + ] + } + } +} +] \ No newline at end of file diff --git a/tests/format/Gpx.test.js b/tests/format/Gpx.test.js index d0959d1..7e83640 100644 --- a/tests/format/Gpx.test.js +++ b/tests/format/Gpx.test.js @@ -47,6 +47,11 @@ describe('voice hints', () => { let brouterGpx = read('2-locus.gpx'); brouterGpx = brouterGpx.replace(/.0<\/locus:rteDistance/g, '\n\s*/, ''); // ignore (invalid) double tag + // ignore float rounding differences + brouterGpx = brouterGpx.replace( + /:(rteTime|rteSpeed)>([\d.]*)<\//g, + (match, p1, p2) => `:${p1}>${(+p2).toFixed(3)}