Self-hosted, zero-dependency GPX track editor — view, edit, smooth, split, and export GPX 1.0 / 1.1 files in the browser.
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.
| Source | Format | Role |
|---|---|---|
| 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 |
upload.php / save.php).<trk>) are parsed and rendered, but the editor UI talks about "the track" in the singular. Per-track editing works; per-track navigation in the sidebar is a known gap.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);
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.
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.
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.
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.
Three categories of mutation, all in editor.js:
i is removed, the new selection becomes min(i, len-1) so the user can hold delete to trim a tail.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>
Two endpoints, both with a layered validation chain:
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).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.
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.
/fonts/fonts.css — substituted for the IBM Plex Mono originally specified, for consistency with the rest of the lab and zero external font requestssimplexml, fileinfo, json — all default in a stock 8.x