Recipes
Each recipe is a whole program that does what one of the TrailSplits tools does. The scripts need Node 18 or newer and nothing else. They run without a key on the daily allowance; set TRAILSPLITS_API_KEY to use your project's credits.
An elevation profile for a GPX file
Reads a GPX file, asks for its elevation profile, prints climb and descent, and writes distance, height and grade as CSV for any chart library.
// Elevation profile of a GPX file, as CSV.
import { readFileSync } from 'node:fs';
const API = 'https://api.trailsplits.com';
const headers = { 'Content-Type': 'application/json', ...(process.env.TRAILSPLITS_API_KEY ? { Authorization: `Bearer ${process.env.TRAILSPLITS_API_KEY}` } : {}) };
async function call(path, body) {
const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) });
const answer = await res.json();
if (!res.ok) throw new Error(`${res.status} ${answer.error?.code ?? answer.error ?? ''}: ${answer.error?.message ?? answer.message ?? ''}`);
return answer;
}
const gpx = readFileSync(process.argv[2], 'utf8');
const profile = await call('/v1/elevation-profile', { gpx, points: 500 });
const { ascent_m, descent_m, min_m, max_m, source } = profile.elevation;
console.error(`${(profile.input.length_m / 1000).toFixed(1)} km, +${ascent_m} m / -${descent_m} m, ${min_m}–${max_m} m (${source} heights)`);
// Rows under named columns; a file with gaps has several parts, and no line is drawn across a gap.
const col = Object.fromEntries(profile.series.columns.map((name, i) => [name, i]));
console.log('distance_km,elevation_m,grade_pct');
for (const row of profile.series.points) {
console.log([(row[col.distance_m] / 1000).toFixed(3), row[col.elevation_m], row[col.grade_pct]].join(','));
}
Run: node profile-csv.mjs my-hike.gpx > profile.csv
Clean and simplify a GPX file
Removes repeated and broken points, then keeps the shape within 10 m with fewer points. Names, waypoints and the climb stay. Writes the new file next to the old one.
// Clean a GPX file, then simplify it, keeping the shape within 10 m.
import { readFileSync, writeFileSync } from 'node:fs';
const API = 'https://api.trailsplits.com';
const headers = { 'Content-Type': 'application/json', ...(process.env.TRAILSPLITS_API_KEY ? { Authorization: `Bearer ${process.env.TRAILSPLITS_API_KEY}` } : {}) };
async function call(path, body) {
const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) });
const answer = await res.json();
if (!res.ok) throw new Error(`${res.status} ${answer.error?.code ?? answer.error ?? ''}: ${answer.error?.message ?? answer.message ?? ''}`);
return answer;
}
const path = process.argv[2];
const clean = await call('/v1/gpx/clean', { gpx: readFileSync(path, 'utf8') });
console.log(`clean: ${clean.report.points_before} → ${clean.report.points_after} points`);
const simple = await call('/v1/gpx/simplify', { gpx: clean.gpx, tolerance_m: 10 });
console.log(`simplify: kept ${simple.result.points}, removed ${simple.result.removed}, largest deviation ${simple.result.max_deviation_m} m`);
writeFileSync(path.replace(/\.gpx$/i, '') + '.tidy.gpx', simple.gpx);
Run: node tidy-gpx.mjs my-hike.gpx
A loop generator in 30 lines
Takes a start, a distance and a profile, asks for the best loop and three alternatives on signed trails, prints them as a table, and saves them as GeoJSON for any map.
// Hiking, running or cycling loops from a start point, saved as GeoJSON.
import { writeFileSync } from 'node:fs';
const API = 'https://api.trailsplits.com';
const headers = { 'Content-Type': 'application/json', ...(process.env.TRAILSPLITS_API_KEY ? { Authorization: `Bearer ${process.env.TRAILSPLITS_API_KEY}` } : {}) };
async function call(path, body) {
const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) });
const answer = await res.json();
if (!res.ok) throw new Error(`${res.status} ${answer.error?.code ?? answer.error ?? ''}: ${answer.error?.message ?? answer.message ?? ''}`);
return answer;
}
const [lon, lat, km = '10', profile = 'hike'] = process.argv.slice(2);
const answer = await call('/v1/roundtrips', { start: [Number(lon), Number(lat)], distance_km: Number(km), profile, alternatives: 3 });
function decodePolyline6(s) { // lat,lon pairs at 1e-6 → [lon, lat]
const out = []; let i = 0, la = 0, lo = 0;
while (i < s.length) {
for (const axis of [0, 1]) {
let shift = 0, r = 0, b;
do { b = s.charCodeAt(i++) - 63; r |= (b & 31) << shift; shift += 5; } while (b >= 32);
const d = r & 1 ? ~(r >> 1) : r >> 1;
if (axis === 0) la += d; else lo += d;
}
out.push([lo / 1e6, la / 1e6]);
}
return out;
}
console.table(answer.loops.map((l) => ({ rank: l.rank, status: l.status, km: (l.distance_m / 1000).toFixed(1), up_m: l.ascent_m, minutes: l.time?.minutes ?? null, twice: `${Math.round(l.repeated_share * 100)}%` })));
const features = answer.loops.map((l) => ({ type: 'Feature', properties: { rank: l.rank, distance_m: l.distance_m, ascent_m: l.ascent_m }, geometry: { type: 'LineString', coordinates: decodePolyline6(l.shape) } }));
writeFileSync('loops.geojson', JSON.stringify({ type: 'FeatureCollection', features }));
Run: node loops.mjs 7.7491 46.0207 12 hike
Race splits for a course
For a GPX course and a target time, prints a split for every kilometre, slower on the climbs and steep descents, with the terrain of each. Paste it on your wrist or into your watch.
// Splits every kilometre for a course and a target time.
import { readFileSync } from 'node:fs';
const API = 'https://api.trailsplits.com';
const headers = { 'Content-Type': 'application/json', ...(process.env.TRAILSPLITS_API_KEY ? { Authorization: `Bearer ${process.env.TRAILSPLITS_API_KEY}` } : {}) };
async function call(path, body) {
const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) });
const answer = await res.json();
if (!res.ok) throw new Error(`${res.status} ${answer.error?.code ?? answer.error ?? ''}: ${answer.error?.message ?? answer.message ?? ''}`);
return answer;
}
const [path, target_time] = process.argv.slice(2);
const plan = await call('/v1/pace-plan', { gpx: readFileSync(path, 'utf8'), target_time });
const clock = (t) => { const s = Math.round(t); return `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; };
const pace = (t) => { const s = Math.round(t); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}/km`; };
console.log(`${(plan.course.distance_m / 1000).toFixed(1)} km, +${plan.course.gain_m} m, in ${clock(plan.target.seconds)} (${plan.course.heights} heights)`);
for (const s of plan.splits) console.log(`${s.label.padStart(8)} ${pace(s.pace_s_per_km).padStart(9)} ${clock(s.cumulative_seconds)} ${s.character}`);
for (const u of plan.unknowns) console.log('note:', u.message);
Run: node splits.mjs my-race.gpx 2:30:00
Weather along a hike
For a GPX file and a departure time, prints the forecast at each point at the hour you reach it, at that point's own height. Missing hours stay missing.
// The weather at each part of a hike, at the hour you get there.
import { readFileSync } from 'node:fs';
const API = 'https://api.trailsplits.com';
const headers = { 'Content-Type': 'application/json', ...(process.env.TRAILSPLITS_API_KEY ? { Authorization: `Bearer ${process.env.TRAILSPLITS_API_KEY}` } : {}) };
async function call(path, body) {
const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) });
const answer = await res.json();
if (!res.ok) throw new Error(`${res.status} ${answer.error?.code ?? answer.error ?? ''}: ${answer.error?.message ?? answer.message ?? ''}`);
return answer;
}
const [path, departure] = process.argv.slice(2); // departure with its offset, up to 16 days ahead
const answer = await call('/v1/route-weather', { gpx: readFileSync(path, 'utf8'), departure, profile: 'hike' });
for (const s of answer.samples) {
const where = `${(s.at_m / 1000).toFixed(1).padStart(5)} km ${s.kind.padEnd(5)} ${String(s.arrival_local).slice(11, 16)}`;
const w = s.weather;
console.log(w ? `${where} ${w.temp_c} °C, rain ${w.precip_probability_pct ?? '?'}%, wind ${w.wind_kmh ?? '?'} km/h` : `${where} no forecast (${s.missing})`);
}
console.log(answer.forecast.provider.attribution); // show this next to the values
Run: node hike-weather.mjs my-hike.gpx 2026-10-10T07:30:00+02:00
A map with 3D terrain
One HTML file: the TrailSplits outdoor map with hillshading and 3D terrain from the TrailSplits elevation tiles. Drag with the right mouse button (or two fingers) to tilt.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3D terrain with TrailSplits</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/pmtiles@4/dist/pmtiles.js"></script>
<style>body { margin: 0; } #map { height: 100vh; }</style>
</head>
<body>
<div id="map"></div>
<script>
const API = 'https://api.trailsplits.com';
maplibregl.addProtocol('pmtiles', new pmtiles.Protocol().tile);
const map = new maplibregl.Map({
container: 'map', style: API + '/styles/current/trailsplits_openmaptiles_style.json',
center: [7.6586, 45.9764], zoom: 12.5, pitch: 65, bearing: -20, maxPitch: 80,
attributionControl: { customAttribution: '© TrailSplits' },
});
map.on('load', () => {
// Map tiles, terrain and styles need no key and are not counted.
map.addSource('dem', { type: 'raster-dem', tiles: [API + '/tiles/v1/terrainrgb/current/{z}/{x}/{y}.png'], tileSize: 256, encoding: 'mapbox', maxzoom: 12,
attribution: "Terrain and contours produced using Copernicus WorldDEM-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved" });
map.setTerrain({ source: 'dem', exaggeration: 1.3 });
map.addLayer({ id: 'hillshade', type: 'hillshade', source: 'dem', paint: { 'hillshade-exaggeration': 0.35 } }, map.getStyle().layers.find((l) => l.type === 'symbol')?.id);
});
</script>
</body>
</html>
Run: Open terrain.html in a browser.