Back

runs.papp

All 389 lines. No libraries, nothing hidden. Read it before you run it.

runs.papp
<!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>
Download runs.papp