runs.papp
All 389 lines. No libraries, nothing hidden. Read it before you run it.
<!doctype html>
<meta charset="utf-8">
<meta name="papp-net" content="tile.openstreetmap.org:443">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-1 -1 64 64'><g fill='none' stroke='%23111114' stroke-width='6' stroke-linecap='round'><path d='M10 52c0-14 18-8 18-22S46 30 46 18'/></g><circle cx='10' cy='52' r='6' fill='%23111114'/><circle cx='48' cy='14' r='10' fill='%23ff4f00'/></svg>">
<!--
Runs: see what your watch recorded, without uploading it anywhere.
Put this page next to the .gpx files your watch or app exports: Garmin Connect, Strava, Apple Health and most others.
It reads each track point's position and time, and its elevation and heart rate if they are there:
<trkpt lat="52.5163" lon="13.3777"><ele>34.2</ele><time>2026-10-27T06:12:04Z</time>
<extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>148</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions></trkpt>
Its only network is the OpenStreetMap tile server, for the map under the route. The server sees which map squares
you look at; your runs never leave this folder.
Hover a chart or the route to follow the run, ↑ and ↓ to switch runs. A new .gpx in the folder shows up by itself.
-->
<title>Runs</title>
<style>
:root { color-scheme: light dark; --paper: light-dark(#fff, #0f0f11); --well: light-dark(#f4f4f5, #1c1c20); --ink: light-dark(#0f0f11, #f2f2f3); --soft: light-dark(#6b6b72, #9b9ba4); --rule: light-dark(#e4e4e7, #2b2b31); --accent: #ff4f00; }
body { display: grid; grid-template-columns: 17rem minmax(0, 1fr); height: 100vh; margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.45 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--rule); }
aside header { padding: 28px 20px 14px; }
h1, h2 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
aside header p, #started { margin: 2px 0 0; color: var(--soft); }
nav { overflow-y: auto; padding: 0 10px 20px; }
nav button { display: block; width: 100%; margin-bottom: 2px; padding: 9px 12px; border: 0; border-left: 3px solid transparent; border-radius: 8px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
nav button:hover { background: var(--well); }
nav button.on { border-left-color: var(--accent); background: var(--well); }
nav strong { display: block; font-weight: 600; }
nav span { display: flex; justify-content: space-between; color: var(--soft); font-size: 12.5px; }
main { overflow-y: auto; padding: 28px; }
#empty { place-self: center; max-width: 24rem; color: var(--soft); text-align: center; }
#empty strong { display: block; margin-bottom: 6px; color: var(--ink); font-size: 16px; }
code { color: var(--ink); font: 12px ui-monospace, monospace; overflow-wrap: anywhere; }
dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); gap: 1px; margin: 20px 0; overflow: hidden; border: 1px solid var(--rule); border-radius: 12px; background: var(--rule); }
dl div { padding: 10px 14px; background: var(--paper); }
dt { color: var(--soft); font-size: 12px; }
dd { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
dd small { margin-left: 3px; color: var(--soft); font-size: 12px; font-weight: 400; }
#map { position: relative; height: 340px; overflow: hidden; border: 1px solid var(--rule); border-radius: 12px; background: var(--well); }
#tiles img { position: absolute; width: 256px; height: 256px; filter: grayscale(1) contrast(0.9); opacity: 0.85; }
@media (prefers-color-scheme: dark) { #tiles img { filter: grayscale(1) invert(1) brightness(0.8) contrast(1.1); opacity: 0.7; } }
#route { position: absolute; inset: 0; width: 100%; height: 100%; }
#route path { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.casing { stroke: var(--paper); stroke-width: 7; }
.line { stroke: var(--accent); stroke-width: 3.5; }
.start { fill: var(--paper); stroke: var(--ink); stroke-width: 2.5; }
.finish { fill: var(--ink); stroke: var(--paper); stroke-width: 2.5; }
.dot { fill: var(--accent); stroke: var(--paper); stroke-width: 3; visibility: hidden; }
#where, #map small { position: absolute; padding: 2px 8px; background: color-mix(in srgb, var(--paper) 85%, transparent); }
#where { top: 10px; left: 10px; border-radius: 6px; font-weight: 600; }
#where:empty { display: none; }
#map small { right: 0; bottom: 0; border-top-left-radius: 6px; color: var(--soft); font-size: 11px; }
.lower { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; gap: 32px; margin-top: 24px; }
h3 { margin: 0 0 10px; color: var(--soft); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
.chart { margin-bottom: 14px; }
.chart p { display: flex; justify-content: space-between; margin: 0 0 4px; }
.chart p span { color: var(--soft); }
#charts svg { display: block; width: 100%; height: 72px; overflow: visible; }
#charts .axis { height: 18px; }
.chart .line { fill: none; stroke: var(--ink); stroke-width: 1.5; }
.area { fill: var(--well); }
.base { stroke: var(--rule); }
.hover { stroke: var(--accent); stroke-width: 1.5; visibility: hidden; }
#charts text { fill: var(--soft); font-size: 11px; paint-order: stroke; stroke: var(--paper); stroke-width: 3px; }
table { width: 100%; border-collapse: collapse; }
th { padding: 0 0 6px; color: var(--soft); font-size: 12px; font-weight: 500; text-align: right; }
td { padding: 5px 0; border-top: 1px solid var(--rule); text-align: right; }
th:first-child, td:first-child { text-align: left; }
td.bar { width: 40%; padding: 0 10px; }
td.bar span { display: block; height: 6px; border-radius: 99px; background: var(--ink); }
.fastest td.bar span { background: var(--accent); }
@media (max-width: 1000px) { .lower { grid-template-columns: minmax(0, 1fr); } }
</style>
<aside>
<header><h1>Runs</h1><p id="count"></p></header>
<nav id="list"></nav>
</aside>
<main id="empty" hidden></main>
<main id="view" hidden>
<h2 id="heading"></h2>
<p id="started"></p>
<dl id="stats"></dl>
<div id="map"><div id="tiles"></div><svg id="route"></svg><span id="where"></span><small>© OpenStreetMap contributors</small></div>
<div class="lower">
<section id="charts"></section>
<section><h3>Splits</h3><table id="table"></table></section>
</div>
</main>
<script type="module">
// The charts, top to bottom. Pace runs upside down, faster higher, and ignores the slowest and fastest 5% so a stop doesn't flatten it.
const kinds = [
{ name: 'Pace', value: point => point.pace, format: value => `${pace(value)} /km`, idle: run => `avg ${pace(run.pace)} /km`, invert: true, spread: 0.05 },
{ name: 'Heart rate', value: point => point.hr, format: value => `${Math.round(value)} bpm`, idle: run => `avg ${Math.round(run.hr.average)} bpm`, spread: 0 },
{ name: 'Elevation', value: point => point.ele, format: value => `${Math.round(value)} m`, idle: run => `+${Math.round(run.gain)} m`, area: true, spread: 0 },
];
const runs = [];
let run;
let reload;
await load();
papp.watch('.', file => {
if (!file.toLowerCase().endsWith('.gpx')) return;
clearTimeout(reload);
reload = setTimeout(load, 300);
});
addEventListener('resize', () => {
if (run) draw();
});
list.onclick = ({ target }) => {
const button = target.closest('button');
if (button) select(runs[button.dataset.run]);
};
addEventListener('keydown', event => {
const step = { ArrowUp: -1, ArrowDown: 1 }[event.key];
const next = runs[runs.indexOf(run) + step];
if (step && next) { event.preventDefault(); select(next); }
});
charts.onmousemove = event => {
const { left, width } = charts.querySelector('svg').getBoundingClientRect();
const distance = Math.min(Math.max((event.clientX - left) / width, 0), 1) * run.distance;
let low = 0;
let high = run.points.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (run.points[middle].distance < distance) low = middle + 1;
else high = middle;
}
show(low);
};
// The nearest point of the route within 24 px of the pointer.
map.onmousemove = event => {
const { left, top } = map.getBoundingClientRect();
let nearest = null;
let best = 24 ** 2;
run.screen.forEach(([x, y], index) => {
const away = (x - event.clientX + left) ** 2 + (y - event.clientY + top) ** 2;
if (away < best) [nearest, best] = [index, away];
});
show(nearest);
};
charts.onmouseleave = map.onmouseleave = () => show(null);
tiles.addEventListener('error', ({ target }) => target.remove(), true);
async function load() {
const found = [];
for (const file of await papp.readdir('.')) {
if (!file.toLowerCase().endsWith('.gpx')) continue;
const { title, points } = parse(await papp.readFile(file));
if (points.length > 1) found.push({ file, title: title || file.replace(/\.gpx$/i, ''), points, ...measure(points) });
}
runs.splice(0, runs.length, ...found.sort((a, b) => (b.start || 0) - (a.start || 0)));
const distance = runs.reduce((sum, run) => sum + run.distance, 0);
count.textContent = runs.length ? `${runs.length} ${runs.length === 1 ? 'run' : 'runs'} · ${km(distance)} km` : 'No runs yet';
empty.hidden = runs.length > 0;
empty.innerHTML = `<strong>No runs in this folder</strong>Export a run from your watch or app as a <code>.gpx</code> file, and put it next to this app, in <code>${escape(papp.dir)}</code>.`;
select(runs.find(each => each.file === run?.file) ?? runs[0]);
}
// Reads <trkpt> with a regex rather than an XML parser: exports differ in namespaces, and this needs only a few fields.
function parse(text) {
const known = value => (Number.isFinite(value) ? value : null);
const points = [];
for (const [, attributes, body = ''] of text.matchAll(/<trkpt\b([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/g)) {
const attribute = name => parseFloat(attributes.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)`))?.[1]);
const tag = name => body.match(new RegExp(`<(?:\\w+:)?${name}>\\s*([^<]*)<`))?.[1];
const [lat, lon] = [attribute('lat'), attribute('lon')];
if (Number.isFinite(lat) && Number.isFinite(lon)) points.push({ lat, lon, ele: known(parseFloat(tag('ele'))), time: known(Date.parse(tag('time'))), hr: parseFloat(tag('hr')) || null });
}
const title = text.match(/<trk\b[^>]*>\s*<name>(?:<!\[CDATA\[)?([^<\]]*)/)?.[1].trim();
return { title: title?.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[entity]), points };
}
// Adds distance, moving time and a trailing 200 m pace to each point, and totals the run.
// A stretch slower than 0.5 m/s counts as standing still. Elevation counts only once it moves 3 m from the last level it counted, up or down, so GPS jitter doesn't add up.
function measure(points) {
let [distance, moving, gain, level, back] = [0, 0, 0, points[0].ele, 0];
const hrs = points.map(point => point.hr).filter(Boolean);
points.forEach((point, index) => {
const last = points[index - 1] ?? point;
const step = haversine(last, point);
const seconds = (point.time - last.time) / 1000;
distance += step;
if (seconds > 0 && step / seconds > 0.5) moving += seconds;
Object.assign(point, { distance, moving });
while (back < index && point.distance - points[back + 1].distance >= 200) back++;
const span = point.distance - points[back].distance;
point.pace = span >= 200 && moving > points[back].moving ? ((moving - points[back].moving) / span) * 1000 : null;
if (point.ele === null) return;
level ??= point.ele;
if (Math.abs(point.ele - level) <= 3) return;
gain += Math.max(point.ele - level, 0);
level = point.ele;
});
return {
start: points[0].time,
distance,
moving,
gain,
pace: moving ? (moving / distance) * 1000 : null,
hr: hrs.length ? { average: hrs.reduce((sum, hr) => sum + hr, 0) / hrs.length, max: Math.max(...hrs) } : null,
splits: splits(points),
};
}
// One row per whole km, and the rest if it's over 50 m. Each boundary falls between two points, so it's interpolated.
function splits(points) {
const rows = [];
const row = (from, to, hrs) => ({ length: to.distance - from.distance, pace: to.moving > from.moving ? ((to.moving - from.moving) / (to.distance - from.distance)) * 1000 : null, climb: from.ele === null || to.ele === null ? null : to.ele - from.ele, hr: hrs.length ? hrs.reduce((sum, hr) => sum + hr, 0) / hrs.length : null });
let [from, next, hrs] = [points[0], 1000, []];
points.slice(1).forEach((point, index) => {
const last = points[index];
while (point.distance >= next) {
const share = (next - last.distance) / (point.distance - last.distance);
const to = { distance: next, moving: last.moving + (point.moving - last.moving) * share, ele: last.ele === null || point.ele === null ? null : last.ele + (point.ele - last.ele) * share };
rows.push(row(from, to, hrs));
[from, next, hrs] = [to, next + 1000, []];
}
if (point.hr) hrs.push(point.hr);
});
if (points.at(-1).distance - from.distance > 50) rows.push(row(from, points.at(-1), hrs));
return rows;
}
function haversine(a, b) {
const radians = Math.PI / 180;
const h = Math.sin(((b.lat - a.lat) * radians) / 2) ** 2 + Math.cos(a.lat * radians) * Math.cos(b.lat * radians) * Math.sin(((b.lon - a.lon) * radians) / 2) ** 2;
return 2 * 6371000 * Math.asin(Math.sqrt(h));
}
function select(chosen) {
run = chosen;
view.hidden = !run;
list.innerHTML = runs
.map((each, index) => `
<button data-run="${index}" class="${each === run ? 'on' : ''}">
<strong>${escape(each.title)}</strong>
<span>${each.start ? new Date(each.start).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) : escape(each.file)}</span>
<span><span>${km(each.distance)} km</span><span>${duration(each.moving)}</span><span>${pace(each.pace)} /km</span></span>
</button>`)
.join('');
if (!run) return;
list.querySelector('.on').scrollIntoView({ block: 'nearest' });
heading.textContent = run.title;
started.textContent = [run.start && new Date(run.start).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'short' }), run.file].filter(Boolean).join(' · ');
const totals = [['Distance', km(run.distance), 'km'], ['Moving time', duration(run.moving)], ['Avg pace', pace(run.pace), '/km'], ['Elevation gain', Math.round(run.gain), 'm']];
if (run.hr) totals.push(['Avg heart rate', Math.round(run.hr.average), 'bpm'], ['Max heart rate', run.hr.max, 'bpm']);
stats.innerHTML = totals.map(([label, value, unit = '']) => `<div><dt>${label}</dt><dd>${value}<small>${unit}</small></dd></div>`).join('');
const fastest = Math.min(...run.splits.map(split => split.pace ?? Infinity));
table.innerHTML = `<tr><th>Km</th><th></th><th>Pace</th><th>Elev</th>${run.hr ? '<th>HR</th>' : ''}</tr>` + run.splits
.map((split, index) => `
<tr class="${split.pace === fastest ? 'fastest' : ''}">
<td>${split.length > 999 ? index + 1 : (split.length / 1000).toFixed(2)}</td>
<td class="bar"><span style="width: ${split.pace ? (fastest / split.pace) * 100 : 0}%"></span></td>
<td>${pace(split.pace)}</td>
<td>${split.climb !== null ? `${split.climb < 0 ? '−' : '+'}${Math.abs(Math.round(split.climb))}` : '–'}</td>
${run.hr ? `<td>${split.hr ? Math.round(split.hr) : '–'}</td>` : ''}
</tr>`)
.join('');
draw();
}
function draw() {
drawMap();
drawCharts();
show(null);
}
// Web Mercator, in pixels at a zoom where the world is 256 × 2^zoom wide: the numbering OpenStreetMap's tiles use.
function project(lat, lon, zoom) {
const sin = Math.sin((lat * Math.PI) / 180);
const size = 256 * 2 ** zoom;
return [((lon + 180) / 360) * size, (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * size];
}
// Picks the closest zoom at which the route fits with a margin, then lays the tiles that cover the map and the route over them.
function drawMap() {
const { clientWidth: width, clientHeight: height } = map;
const lats = run.points.map(point => point.lat);
const lons = run.points.map(point => point.lon);
const [north, south, west, east] = [lats.reduce((a, b) => Math.max(a, b)), lats.reduce((a, b) => Math.min(a, b)), lons.reduce((a, b) => Math.min(a, b)), lons.reduce((a, b) => Math.max(a, b))];
let zoom = 17;
let corners;
for (; zoom > 0; zoom--) {
corners = [...project(north, west, zoom), ...project(south, east, zoom)];
if (corners[2] - corners[0] <= width - 64 && corners[3] - corners[1] <= height - 64) break;
}
const left = (corners[0] + corners[2] - width) / 2;
const top = (corners[1] + corners[3] - height) / 2;
const count = 2 ** zoom;
let images = '';
for (let x = Math.floor(left / 256); x * 256 < left + width; x++) {
for (let y = Math.max(0, Math.floor(top / 256)); y * 256 < top + height && y < count; y++) {
images += `<img alt="" src="https://tile.openstreetmap.org/${zoom}/${((x % count) + count) % count}/${y}.png" style="left: ${x * 256 - left}px; top: ${y * 256 - top}px">`;
}
}
tiles.innerHTML = images;
run.screen = run.points.map(point => project(point.lat, point.lon, zoom)).map(([x, y]) => [x - left, y - top]);
const path = 'M' + run.screen.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join('L');
const [[startX, startY], [endX, endY]] = [run.screen[0], run.screen.at(-1)];
route.setAttribute('viewBox', `0 0 ${width} ${height}`);
route.innerHTML = `<path class="casing" d="${path}"/><path class="line" d="${path}"/><circle class="finish" cx="${endX}" cy="${endY}" r="5"/><circle class="start" cx="${startX}" cy="${startY}" r="5"/><circle class="dot" r="7"/>`;
}
// Each chart is drawn at its own pixel width, so lines stay crisp. A point is drawn only once it moves a pixel along.
function drawCharts() {
const shown = kinds.filter(kind => run.points.some(point => kind.value(point) !== null));
charts.innerHTML = shown.map(kind => `<div class="chart"><p><span>${kind.name}</span><b></b></p><svg></svg></div>`).join('') + '<svg class="axis"></svg>';
const width = charts.querySelector('svg').clientWidth;
const height = 72;
const along = point => (point.distance / run.distance) * width;
shown.forEach((kind, index) => {
const values = run.points.map(kind.value).filter(value => value !== null).sort((a, b) => a - b);
let [low, high] = [values[Math.floor(values.length * kind.spread)], values[Math.ceil(values.length * (1 - kind.spread)) - 1]];
const pad = (high - low) * 0.1 || 1;
[low, high] = [low - pad, high + pad];
const y = value => Math.min(Math.max(kind.invert ? ((value - low) / (high - low)) * height : height - ((value - low) / (high - low)) * height, 0), height);
let line = '';
let lastX = -1;
let first;
for (const point of run.points) {
const value = kind.value(point);
if (value === null || (along(point) - lastX < 1 && point !== run.points.at(-1))) continue;
first ??= along(point);
line += `${line ? 'L' : 'M'}${along(point).toFixed(1)},${y(value).toFixed(1)}`;
lastX = along(point);
}
const [top, bottom] = kind.invert ? [low, high] : [high, low];
charts.querySelectorAll('.chart svg')[index].innerHTML = `
${kind.area ? `<path class="area" d="${line}L${lastX},${height}L${first},${height}Z"/>` : ''}
<line class="base" x2="${width}" y1="${height}" y2="${height}"/>
<path class="line" d="${line}"/>
<text x="${width}" y="10" text-anchor="end">${kind.format(top + pad * (kind.invert ? 1 : -1))}</text>
<text x="${width}" y="${height - 4}" text-anchor="end">${kind.format(bottom + pad * (kind.invert ? -1 : 1))}</text>
<line class="hover" y2="${height}"/>`;
});
const step = [1, 2, 5, 10, 20, 50].find(step => (width / run.distance) * step * 1000 >= 48) ?? 100;
let ticks = '';
for (let mark = 0; mark <= run.distance / 1000; mark += step) ticks += `<text x="${(mark * 1000 / run.distance) * width}" y="13" text-anchor="${mark ? (mark * 1000 > run.distance * 0.96 ? 'end' : 'middle') : 'start'}">${mark} km</text>`;
charts.querySelector('.axis').innerHTML = ticks;
run.shown = shown;
}
// Shows one point on the map and on every chart, or none.
function show(index) {
const point = run.points[index];
const dot = route.querySelector('.dot');
dot.style.visibility = point ? 'visible' : 'hidden';
if (point) {
dot.setAttribute('cx', run.screen[index][0]);
dot.setAttribute('cy', run.screen[index][1]);
}
where.textContent = point ? `${km(point.distance)} km · ${duration(point.moving)}` : '';
charts.querySelectorAll('.chart').forEach((chart, kind) => {
const value = point && run.shown[kind].value(point);
chart.querySelector('b').textContent = point ? (value === null ? '–' : run.shown[kind].format(value)) : run.shown[kind].idle(run);
const hover = chart.querySelector('.hover');
hover.style.visibility = point ? 'visible' : 'hidden';
if (point) {
const x = (point.distance / run.distance) * hover.ownerSVGElement.clientWidth;
hover.setAttribute('x1', x);
hover.setAttribute('x2', x);
}
});
}
function km(meters) {
return (meters / 1000).toFixed(2);
}
function duration(seconds) {
const s = Math.round(seconds);
const [h, m] = [Math.floor(s / 3600), Math.floor((s % 3600) / 60)];
return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(s % 60).padStart(2, '0')}`;
}
function pace(secondsPerKm) {
if (!Number.isFinite(secondsPerKm)) return '–';
const s = Math.round(secondsPerKm);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function escape(text) {
return String(text).replace(/[&<>"]/g, character => `&#${character.charCodeAt(0)};`);
}
</script>