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.

Same as the Elevation Profile tool Uses Elevation profile of a line Download profile-csv.mjs

profile-csv.mjs
// 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.

Same as the GPX Cleaner and GPX Simplifier Uses Clean a GPX fileUses Simplify a GPX file or line Download tidy-gpx.mjs

tidy-gpx.mjs
// 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.

Same as the Loop button in the Planner Uses Round trips from a start point Download loops.mjs

loops.mjs
// 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.

Same as the Pace & Split Calculator Uses Pace plan for a course Download splits.mjs

splits.mjs
// 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.

Same as the Route Forecast tool Uses Weather along a route at arrival time Download hike-weather.mjs

hike-weather.mjs
// 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.

Same as the Flyover tool Download terrain.html

terrain.html
<!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.