NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Strava route planner: GPX import
// @version 0.1.1
// @grant none
// @include https://www.strava.com/routes/new*
// @require https://openuserjs.org/src/libs/xsanda/Run_code_as_client.js
// @updateURL https://openuserjs.org/meta/xsanda/Strava_route_planner_GPX_import.meta.js
// @downloadURL https://openuserjs.org/install/xsanda/Strava_route_planner_GPX_import.user.js
// @license MIT
// ==/UserScript==
/* jshint esversion: 8 */
/* globals runAsClient, polyline, Distance, gpxParse */
runAsClient(async () => {
const loadLibrary = async function (url) {
await new Promise((res) => {
var script = document.createElement('script');
script.setAttribute('src', url);
script.onload = () => res();
document.body.appendChild(script);
});
};
// Add some libraries. The polyline library is already bundled, but React makes it awkward to access.
await Promise.all([
loadLibrary('https://unpkg.com/@mapbox/polyline@^1.1.1'),
loadLibrary('https://unpkg.com/gpx-parse@^0.10/dist/gpx-parse-browser.js'),
loadLibrary('https://unpkg.com/geo-distance@0.2.0'),
]);
// For reference only, based on deminifying Strava’s implementation.
const decodeElevation = function* (elevationPolyline) {
const rawDecoded = polyline.decode(elevationPolyline);
let p = 0;
for (let i = 0; i < rawDecoded.length; ++i) {
const s = rawDecoded[i];
const distance = s[0] / 0.0001;
const spotHeight = s[1] / 0.001;
// if distance has increased by more than a kilometre, add a gap of a very large number
// idk why
if (i > 0 && rawDecoded[i - 1][0] > rawDecoded[i][0] + 0.1) ++p;
yield [distance + (90 * p) / 0.0001, spotHeight];
}
console.log(p);
};
// Inverse of decodeElevation, except for the weird jump of 90.
const encodeElevation = (elevation) => {
const raw = [];
for (const [d, h] of elevation) {
raw.push([d * 0.0001, h * 0.001]);
}
return polyline.encode(raw);
};
// Get the file from the transfer. At the moment nothing happens if multiple files are dropped.
async function getFile(e) {
if (e.dataTransfer.items && e.dataTransfer.items.length == 1) {
if (e.dataTransfer.items[0].kind === 'file') {
return await e.dataTransfer.items[0].getAsFile().text();
}
} else if (e.dataTransfer.files && e.dataTransfer.files.length == 1) {
return await e.dataTransfer.files[0].text();
}
return undefined;
}
const convertRoute = (track) => {
const points = [];
const elePoints = [];
let distance = 0;
for (const segment of track.segments) {
for (const { lat, lon, elevation } of segment) {
if (points.length) {
distance +=
Distance.between(points[points.length - 1], { lat, lon }) *
Distance.unit_conversion['m'];
}
points.push([lat, lon]);
elePoints.push([distance, elevation]);
}
}
return {
points: polyline.encode(points),
elevation: encodeElevation(elePoints),
};
};
const elevationGain = (track) => {
let total = 0;
for (const segment of track.segments) {
let last;
for (const point of segment) {
if (last !== undefined && point.elevation > last) {
total += point.elevation - last;
}
last = point.elevation;
}
}
return total;
};
const last = (arr) => arr[arr.length - 1];
const startLocation = (track) => {
const { lat, lon: lng } = track.segments[0][0];
return { lat, lng };
};
const endLocation = (track) => {
const { lat, lon: lng } = last(last(track.segments));
return { lat, lng };
};
// This is based on React’s internal structure, so very fragile.
// Defaults to a run if it can’t access the chosen option.
const routeType = () => {
try {
return [
...Object.entries(
document.querySelector('[class^="RouteBuilder--sidebar"]')
),
].find((a) => /^__reactInternalInstance/.test(a[0]))[1].memoizedProps
.children.props.routingPreferences.routeType;
} catch (e) {
return 2;
}
};
// Based on the data sent to the server normally.
const prepareRequest = (gpx) => {
const data = JSON.parse(document.getElementById('__NEXT_DATA__').text);
const athlete = data.props.pageProps.currentAthlete;
const track = gpx.tracks[0];
const { points, elevation } = convertRoute(track);
return {
metadata: {
overview: { encoding: 1, data: '' },
elevation_profile: { encoding: 2, data: '' },
popularity: 0.5,
elevation: 0,
manual: 0,
route_type: routeType(),
length: track.length() * 1000,
elevation_gain: elevationGain(track),
name: track.name,
description: '',
sub_type: 1, // TODO: get this to be more advanced, e.g. set to trail if importing a hike.
is_private: false,
created_at: Date.now(),
athlete_id: athlete.id,
},
starred: false,
route: {
preferences: { popularity: 0.5, elevation: 0, route_type: 1 },
elements: [
{ element_type: 1, waypoint: { point: startLocation(track) } },
{
element_type: 1,
waypoint: { point: endLocation(track) },
},
],
legs: [
{
leg_type: 2,
start_element: 0,
paths: [
{
path_type: 1,
origin: startLocation(track),
target: endLocation(track),
length: track.length() * 1000,
elevation_gain: elevationGain(track),
polyline: {
encoding: 2,
data: points,
},
elevation: { encoding: 1, data: elevation },
directions: [],
surface_type_offsets: [{ distance_offset: 0, surface_type: 3 }],
},
],
},
],
},
};
};
// Take a GPX file (as a string) and parse and convert it, then send it to Strava.
const handleFile = async (rawGPX) => {
try { document.querySelector('.mapboxgl-ctrl-geocoder--input').placeholder = "Importing GPX file…"; } catch (e) {};
try { document.querySelector("[class^='PaywallModal--body'] .text-caption1").innerText = "Importing GPX file…"; } catch (e) {};
const gpx = await new Promise((resolve, reject) =>
gpxParse.parseGpx(rawGPX, (err, val) =>
err ? reject(err) : resolve(val)
)
);
const req = prepareRequest(gpx);
const raw = await fetch('https://www.strava.com/frontend/routes', {
method: 'POST',
body: JSON.stringify(req),
headers: {
'x-csrf-token': document.querySelector("meta[name='csrf']").content,
Accept: 'application/json',
'Content-Type': 'application/json;charset=utf-8',
},
});
const res = await raw.json();
// Go to the new route
window.location = `https://www.strava.com/routes/${res.route_id}`;
};
// Try every second until something is non-undefined.
const waitFor = (f, freq = 1000) =>
new Promise((resolve) => {
const interval = setInterval(() => {
const result = f();
if (result !== undefined) {
clearInterval(interval);
resolve(result);
}
}, freq);
});
// When the header is loaded, add a button to it to import the GPX file.
waitFor(() => document.querySelector("header [class^='Header--actions']")).then(header => {
const label = document.createElement("label");
label.classList.add('text-footnote', 'mr-sm');
label.href = '#';
label.innerText = 'Import GPX route';
label.style.cursor = 'pointer';
const filePicker = document.createElement("input");
filePicker.type = 'file';
filePicker.style.display = 'none';
filePicker.addEventListener('change', async e => {
const file = e.target.files[0];
if (file) handleFile(await file.text());
});
label.append(filePicker);
header.prepend(label);
});
// preventDefault on both is needed to avoid opening the file in the browser as soon as it is dropped.
document.body.addEventListener('dragover', (e) => e.preventDefault());
document.body.addEventListener('drop', async (e) => {
e.preventDefault();
const rawGPX = await getFile(e);
if (rawGPX) handleFile(rawGPX);
});
});