diff --git a/babel.config.js b/babel.config.js index 5819293..bb81b2f 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,5 +1,5 @@ module.exports = { presets: [['@babel/preset-env', {}]], sourceType: 'script', - exclude: [/node_modules\/(?!overpass-layer|leaflet.locatecontrol\/).*/], + exclude: [/node_modules\/(?!overpass-layer|leaflet.locatecontrol|fit-file-writer\/).*/], }; diff --git a/css/style.css b/css/style.css index 367b101..669f12b 100644 --- a/css/style.css +++ b/css/style.css @@ -590,6 +590,10 @@ table.dataTable.display tbody tr:hover.selected { line-height: 24px; } +.format-turns-enabled { + font-size: 0.8em; +} + /* tooltip */ .editing-tooltip, diff --git a/index.html b/index.html index 07d6c88..b1411b1 100644 --- a/index.html +++ b/index.html @@ -450,6 +450,7 @@ checked /> GPX +
@@ -488,6 +489,19 @@ CSV
+
+ +
@@ -520,7 +534,7 @@ diff --git a/js/control/Export.js b/js/control/Export.js index e221763..abf4f60 100644 --- a/js/control/Export.js +++ b/js/control/Export.js @@ -31,7 +31,9 @@ BR.Export = L.Class.extend({ L.DomEvent.addListener(document, 'keydown', this._keydownListener, this); - $('#export').on('show.bs.modal', this._warnStraightLine.bind(this)); + $('#export').on('show.bs.modal', this._warnDownload.bind(this)); + $('#export input[name=format]').on('change', this._warnDownload.bind(this)); + $('#export').on('show.bs.modal', this._turnInstructionInfo.bind(this)); this.update([]); }, @@ -47,22 +49,34 @@ BR.Export = L.Class.extend({ } }, - _warnStraightLine: function () { + _warnDownload: function () { const hasBeeline = BR.Routing.hasBeeline(this.segments); - document.getElementById('export-beeline-warning').hidden = !hasBeeline; + const isFit = $('#format-fit').prop('checked'); + $('#export-download-warning').prop('hidden', !hasBeeline && !isFit); let title = 'Download from server (deprecated)'; if (hasBeeline) { title = '[Warning: straight lines not supported] ' + title; } + if (isFit) { + title = '[Warning: FIT not supported] ' + title; + } document.getElementById('serverExport').title = title; }, + _turnInstructionInfo: function () { + const turnInstructionMode = +this.profile.getProfileVar('turnInstructionMode'); + $('.format-turns-enabled') + .prop('hidden', turnInstructionMode <= 1) + .attr('title', i18next.t('export.turns_enabled')); + }, + _getMimeType: function (format) { const mimeTypeMap = { - gpx: 'application/gpx+xml', - kml: 'application/vnd.google-earth.kml+xml', - geojson: 'application/vnd.geo+json', - csv: 'text/tab-separated-values', + gpx: 'application/gpx+xml;charset=utf-8', + kml: 'application/vnd.google-earth.kml+xml;charset=utf-8', + geojson: 'application/vnd.geo+json;charset=utf-8', + csv: 'text/tab-separated-values;charset=utf-8', + fit: 'application/vnd.ant.fit', }; return mimeTypeMap[format]; @@ -98,15 +112,18 @@ BR.Export = L.Class.extend({ const track = this._formatTrack(format, name, includeWaypoints); const fileName = (name || 'brouter') + '.' + format; - const mimeType = this._getMimeType(format); const blob = new Blob([track], { - type: mimeType + ';charset=utf-8', + type: this._getMimeType(format), }); const reader = new FileReader(); reader.onload = (e) => this._triggerDownload(reader.result, fileName); reader.readAsDataURL(blob); } else { + if (format === 'fit') { + // Server can't handle fit - downgrade to gpx + format = 'gpx'; + } var serverUrl = this.router.getUrl( this.latLngs, null, @@ -141,6 +158,8 @@ BR.Export = L.Class.extend({ return JSON.stringify(track, null, 2); case 'csv': return BR.Csv.format(track); + case 'fit': + return BR.Fit.format(track); default: break; } diff --git a/js/format/Fit.js b/js/format/Fit.js new file mode 100644 index 0000000..3d577b2 --- /dev/null +++ b/js/format/Fit.js @@ -0,0 +1,63 @@ +BR.Fit = { + format: function (geoJson, turnInstructionMode = 0) { + if (!geoJson?.features) return ''; + + function calcDistance(p1, p2) { + const [ilon1, ilat1] = btools.util.CheapRuler.toIntegerLngLat(p1); + const [ilon2, ilat2] = btools.util.CheapRuler.toIntegerLngLat(p2); + return btools.util.CheapRuler.distance(ilon1, ilat1, ilon2, ilat2); + } + + const course = geoJson.features[0]; + const track = course.geometry.coordinates; + const startPoint = track[0]; + const endPoint = track[track.length - 1]; + let trackStamps = course.properties.times || course.properties.coordTimes || []; + let last = 0; + trackStamps = trackStamps.map((stamp) => { + stamp = Math.round(stamp); // FIT only support seconds + // avoid strange & identical timestamps. This is required(?) for matching + // of turn instructions with the route. + if (stamp <= last) { + stamp = last + 1; + } + last = stamp; + // FIT epoch starts at 1989-12-31T00:00 - avoid wrapping + return (631065600 + stamp) * 1000; + }); + + const encoder = new FITCourseFile( + course.properties.name, + trackStamps[0], + course.properties['total-time'], + startPoint, + endPoint, + course.properties['filtered-ascend'] + ); + let distanceTotal = 0; + let distanceTillPoint = [0]; + encoder.point(trackStamps[0], track[0], track[0][2], 0); + for (let i = 1; i < track.length; i++) { + distanceTotal += calcDistance(track[i - 1], track[i]); + distanceTillPoint[i] = distanceTotal; + encoder.point(trackStamps[i], track[i], track[i][2], distanceTotal); + } + // Note 1: Mixing points and hints is also legal should some devices + // require it - until then keep it simple. + // Note 2: The distance inside the FIT files seems somewhat redundant. + // Not sure if it is really required. May depend on the device? + // Note 3: Can't use hint.distance that is the value to the next turn. + const voiceHints = BR.voiceHints(geoJson, 2); + voiceHints._loopHints((hint, cmd, coord) => { + encoder.turn( + trackStamps[hint.indexInTrack], + coord, + cmd.fit, + cmd.fit === 'generic' ? cmd.message : undefined, + distanceTillPoint[hint.indexInTrack] + ); + }); + + return encoder.finalize(trackStamps[trackStamps.length - 1]); + }, +}; diff --git a/js/format/VoiceHints.js b/js/format/VoiceHints.js index 1d91c4b..e842122 100644 --- a/js/format/VoiceHints.js +++ b/js/format/VoiceHints.js @@ -1,11 +1,11 @@ (function () { class Command { - constructor(name, locus, orux, symbol, message) { + constructor(name, locus, orux, symbol, fit, message) { this.name = name; this.locus = locus; this.orux = orux; this.symbol = symbol; - this.message = message; + (this.fit = fit), (this.message = message); } } @@ -16,6 +16,7 @@ command.locus + exitNumber, command.orux + exitNumber, command.symbol + exitNumber, + command.fit, command.message + exitNumber ); } @@ -28,6 +29,7 @@ command.locus + -exitNumber, command.orux + exitNumber, command.symbol + -exitNumber, + command.fit, command.message + -exitNumber ); } @@ -114,20 +116,20 @@ // from BRouter btools.router.VoiceHint VoiceHints.commands = (function () { return { - 1: new Command('C', 1, 1002, 'Straight', 'straight'), - 2: new Command('TL', 4, 1000, 'Left', 'left'), - 3: new Command('TSLL', 3, 1017, 'TSLL', 'slight left'), - 4: new Command('TSHL', 5, 1019, 'TSHL', 'sharp left'), - 5: new Command('TR', 7, 1001, 'Right', 'right'), - 6: new Command('TSLR', 6, 1016, 'TSLR', 'slight right'), - 7: new Command('TSHR', 8, 1018, 'TSHR', 'sharp right'), - 8: new Command('KL', 9, 1015, 'TSLL', 'keep left'), - 9: new Command('KR', 10, 1014, 'TSLR', 'keep right'), - 10: new Command('TU', 13, 1003, 'TU', 'u-turn'), - 11: new Command('TRU', 14, 1003, 'TU', 'u-turn'), // Right U-turn - 12: new Command('OFFR'), // Off route - 13: new Command('RNDB', 26, 1008, 'RNDB', 'Take exit '), // Roundabout - 14: new Command('RNLB', 26, 1008, 'RNLB', 'Take exit '), // Roundabout left + 1: new Command('C', 1, 1002, 'Straight', 'straight', 'straight'), + 2: new Command('TL', 4, 1000, 'Left', 'left', 'left'), + 3: new Command('TSLL', 3, 1017, 'TSLL', 'slight_left', 'slight left'), + 4: new Command('TSHL', 5, 1019, 'TSHL', 'sharp_left', 'sharp left'), + 5: new Command('TR', 7, 1001, 'Right', 'right', 'right'), + 6: new Command('TSLR', 6, 1016, 'TSLR', 'slight_right', 'slight right'), + 7: new Command('TSHR', 8, 1018, 'TSHR', 'sharp_right', 'sharp right'), + 8: new Command('KL', 9, 1015, 'TSLL', 'left_fork', 'keep left'), + 9: new Command('KR', 10, 1014, 'TSLR', 'right_fork', 'keep right'), + 10: new Command('TU', 13, 1003, 'TU', 'u_turn', 'u-turn'), + 11: new Command('TRU', 14, 1003, 'TU', 'u_turn', 'u-turn'), // Right U-turn + 12: new Command('OFFR', undefined, undefined, undefined, 'danger', undefined), // Off route + 13: new Command('RNDB', 26, 1008, 'RNDB', 'generic', 'Take exit '), // Roundabout + 14: new Command('RNLB', 26, 1008, 'RNLB', 'generic', 'Take exit '), // Roundabout left }; })(); diff --git a/locales/en.json b/locales/en.json index 6609c48..3985a38 100644 --- a/locales/en.json +++ b/locales/en.json @@ -39,7 +39,8 @@ "route-from-to": "{{from}} - {{to}} ({{distance}}km)", "route-loop": "{{from}} ({{distance}}km)", "title": "Export route", - "trackname": "Name" + "trackname": "Name", + "turns_enabled": "Includes turn instructions" }, "footer": { "ascend": "Ascend", diff --git a/package.json b/package.json index cea1d82..5665aea 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "codemirror": "5.65.8", "core-js-bundle": "3.25.1", "datatables": "1.10.18", + "fit-file-writer": "tbsmark86/fit-file-writer#3eebe13", "font-awesome": "4.7.0", "geo-data-exchange": "alexcojocaru/geo-data-exchange#v1.1.0", "i18next": "19.9.2", diff --git a/yarn.lock b/yarn.lock index 7d68ca2..90de338 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6111,6 +6111,10 @@ first-chunk-stream@^2.0.0: dependencies: readable-stream "^2.0.2" +fit-file-writer@tbsmark86/fit-file-writer#3eebe13: + version "0.2.0" + resolved "https://codeload.github.com/tbsmark86/fit-file-writer/tar.gz/3eebe137c3516c96fcfb437896676e805957ba7a" + flagged-respawn@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-1.0.1.tgz#e7de6f1279ddd9ca9aac8a5971d618606b3aab41"