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/index.html b/index.html
index 07d6c88..f96860c 100644
--- a/index.html
+++ b/index.html
@@ -488,6 +488,18 @@
CSV
+
+
+
diff --git a/js/control/Export.js b/js/control/Export.js
index e221763..96469c6 100644
--- a/js/control/Export.js
+++ b/js/control/Export.js
@@ -59,10 +59,11 @@ BR.Export = L.Class.extend({
_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 +99,21 @@ 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
+ // Maybe it's better to show a Info to the user e.g.:
+ // BR.message.showWarning(i18next.t('warning.fit-not-possible-from-server'));
+ // but the warning stays invisible behind the dialog :)
+ format = 'gpx';
+ }
var serverUrl = this.router.getUrl(
this.latLngs,
null,
@@ -141,6 +148,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/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"