GPX Editor

Self-hosted, zero-dependency GPX track editor — view, edit, smooth, split, and export GPX 1.0 / 1.1 files in the browser.

[utility] [gps] [active]
Last updated: 2026-05-23

Abstract

GPX Editor is a single-page tool for inspecting and editing GPX track files. Parse, render, edit, and export all happen client-side via the browser's DOMParser, Canvas 2D, and Blob APIs — no external JavaScript libraries, no tile-map library, no build step. PHP is used only for the optional upload and save-to-server endpoints; the core viewer/editor flow works without ever contacting the server. The tool targets runners, cyclists, and photographers who want to clean up GPS recordings (drop bad points, smooth elevation noise, split sessions, re-export) without uploading their tracks to a third party.

Data Sources

SourceFormatRole
User-supplied GPX file GPX 1.0 / 1.1 XML Primary input — drag-drop or file picker
CARTO Dark Matter raster tiles 256×256 PNG (with @2x retina) Basemap (added after v1.0 once it became clear "no tile map" was a hard usability problem)
Bundled test/sample.gpx GPX 1.1 Synthetic 50-point circular loop around Luxembourg City for smoke-testing parse, render, smoothing, split, and export

Known limitations

Methodology

1. Module pattern over ES modules

Every file attaches to a global window.GPXEditor namespace and is loaded via plain <script> tags with a per-request CSP nonce. ES module import was rejected for two reasons: it forces strict MIME-type handling for .js from the PHP server (extra config), and it triggers CORS preflights when the same files are served from a simple file:// dev open. Module load order is fixed in index.php: app → parser → renderer → editor → export, then GPXEditor.boot().

// All modules follow this pattern
(function (GPXEditor) {
  'use strict';
  const renderer = {};
  // … implementation …
  GPXEditor.renderer = renderer;
})(window.GPXEditor);

2. Namespace-agnostic GPX parsing

GPX 1.0 uses http://www.topografix.com/GPX/1/0; GPX 1.1 uses http://www.topografix.com/GPX/1/1. getElementsByTagNameNS would force a choice; getElementsByTagName is case-sensitive on XML documents. The parser instead walks element.children and filters by localName, which works for either namespace and any extension namespace, while being lazily evaluated via generator functions:

function* childrenByLocalName(el, local) {
  if (!el) return;
  for (const c of el.children) if (c.localName === local) yield c;
}

// Metadata also lives in two different places between 1.0 and 1.1
function parseMetadata(root) {
  const md  = firstChild(root, 'metadata');  // 1.1
  const src = md || root;                    // 1.0 falls back to <gpx>
  …
}

Invalid points (missing or non-finite lat/lon) throw rather than silently dropping. Silent drop was tried first; it masked a real bug in a test fixture by emitting a track that looked right but was missing a leg.

3. Canvas Mercator renderer

The map view is a single <canvas>. Coordinates are projected to Mercator radians (range ±π); a separate continuous state.view.zoom stores pixels-per-radian. The same coordinate system is used for the tile layer added later, so the underlying maths did not need to change when the basemap was bolted on. World-to-screen and screen-to-world are pure functions:

function worldToScreen(mx, my) {
  const v = viewport(), s = GPXEditor.state.view;
  return {
    x: v.w / 2 + (mx - s.panX) * s.zoom,
    y: v.h / 2 - (my - s.panY) * s.zoom, // y inverted: north is up
  };
}

Auto-fit on first paint computes the bbox in Mercator space and chooses a zoom that fits both axes inside the canvas with 40 px padding. Subsequent paints respect the user's pan / wheel-zoom / pinch-zoom state.

4. Tile basemap (bolted on after v1.0)

The original spec called for "no tile map". First feedback from a real user was that an isolated red polyline on black was unreadable. The fix re-uses the existing Mercator code: a chosen integer tile zoom z = round(log₂(stateZoom · 2π / 256)) selects a z/x/y from CARTO Dark Matter, the tile's world bounds are converted through worldToScreen, and the tile image is drawn with rounded integer coordinates so adjacent tiles butt without seams.

An LRU tile cache (max 512 entries) keeps memory bounded. Loaded tiles trigger a coalesced redraw via requestAnimationFrame so a burst of arrivals does not paint 16 times in 16 ms. The CSP img-src was widened to https://*.basemaps.cartocdn.com for this; everything else remained self.

5. Pub/sub event bus

Modules do not call each other directly. app.js exposes GPXEditor.events.on / off / emit and the modules subscribe to the events they care about (data:loaded, data:edited, selection:changed, view:resize). This keeps the renderer ignorant of the editor and vice versa, and made the bolted-on basemap a strictly additive change.

6. Edit operations

Three categories of mutation, all in editor.js:

7. GPX serialisation

The exporter emits GPX 1.1 with the standard schemaLocation declared so validators can verify the output. Element order matches the schema (ele → time → name → desc) because some third-party parsers are strict about it. Track points without elevation and without time collapse to a self-closing one-liner, which shaves substantial bytes on big files. Numeric formatting trims trailing zeros (6 decimal places of latitude / longitude is sub-metre).

<trk>
  <name>Sample Loop</name>
  <trkseg>
    <trkpt lat="49.611" lon="6.131">
      <ele>300.0</ele>
      <time>2026-05-23T08:00:00Z</time>
    </trkpt>
    <trkpt lat="49.612" lon="6.132"/>
  </trkseg>
</trk>

8. PHP backend (optional)

Two endpoints, both with a layered validation chain:

  1. upload.php — method → file presence → size → extension → finfo MIME → magic bytes (<?xml) → simplexml_load_string<gpx> root. Each step short-circuits on failure with a distinct HTTP status code (4xx / 5xx).
  2. save.php — same validation against the JSON request body, plus an atomic-ish stage-and-rename write.

The persisted filename is always a server-generated UUID; the client-supplied name is logged-style only. The uploads/ directory ships with an .htaccess that disables directory listing, strips PHP / CGI handlers, forces Content-Disposition: attachment, and denies any file whose extension is not .gpx.

9. CSP & nonces

Every <script> tag carries a per-request nonce generated by random_bytes(16) in index.php. The CSP is default-src 'none'; script-src 'self' 'nonce-…'; img-src 'self' data: https://*.basemaps.cartocdn.com (basemap); everything else self. strict-dynamic is intentionally omitted because modules load statically.

Stack & Tooling

Key Observations & Limitations

What works

Known gaps

Possible future directions