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); }); });