Upload GPX/KML tracks to a page, strip timing/telemetry, and render them as an interactive Leaflet map. Grav plugin.
  • PHP 70.7%
  • JavaScript 18.6%
  • CSS 8.6%
  • Twig 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-16 18:20:00 +00:00
assets Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
blueprints Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
classes Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
templates/partials Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
blueprints.yaml Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
CHANGELOG.md Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
languages.yaml Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
LICENSE Add LICENSE, git-clone install instructions 2026-08-16 18:20:00 +00:00
README.md Add LICENSE, git-clone install instructions 2026-08-16 18:20:00 +00:00
route-map.php Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00
route-map.yaml Initial commit (v1.0.0) 2026-08-16 17:27:24 +00:00

Route Map

A Grav 2.0 plugin for route blogs. Upload GPX and KML files to a post, and the plugin strips the timing and telemetry out of them and draws them as an interactive map, with a display name, colour, line thickness and visibility toggle for each route. Readers get a basemap switcher, a full-screen button and a download link per track.

Built for shared hosting. No Composer packages, no vendor folder, no exec(), no image libraries, no database. It uses ext-dom, ext-libxml and ext-json, all of which are compiled into stock cPanel PHP builds.

Requirements

Grav 2.0 or newer
PHP 8.3+ (tested against the 8.5 feature set Grav 2.0 targets)
Extensions dom, libxml, json — all standard
Browser side Leaflet 1.9.x, loaded from a CDN by default or served locally

Install

git clone forgejo@code.hall.me.uk:david/route-map.git user/plugins/route-map

Or drop the route-map folder into user/plugins/ so you end up with user/plugins/route-map/route-map.php, then clear the cache:

bin/grav clear-cache

If you have shell access, bin/gpm install is not involved — this is a manual install, which is the normal path for a plugin you maintain yourself.

Using it

  1. Edit a post. There is now a Routes tab.
  2. Upload one or more .gpx or .kml files. They land in the post's own folder, are cleaned on the way in, and each gets a route row named from its filename.
  3. Adjust the name, colour and line thickness per row, and switch off any you do not want drawn or offered for download.
  4. Save.

If the Routes list does not show a new row immediately, refresh the editor — the upload arrives in its own request, separate from the form you have open, so whether the list updates on its own depends on how the admin re-reads the page afterwards. The row is written either way. Set auto_add_routes: false to go back to rows appearing only on save.

Rows are drawn and listed in the order they appear, so drag them to reorder.

Removing a track

Delete the row with the list's remove button and save. The file is deleted from the page folder along with it. If you would rather keep the file on disk, turn off Delete the file when a route is removed in the plugin settings — either way it stops being listed and stops being drawn.

The plugin keeps a small .route-map.json index in each page folder recording which files it has already shown you, and which of those were added automatically at upload and have not been through a save yet. That is what makes the difference between "the editor removed this on purpose" and "someone dropped a new file in over FTP", so the second case is still picked up automatically. Deleting that index just means every file in the folder gets adopted again on the next save.

By default the map is appended after the post content. To place it somewhere specific, type [ROUTE_MAP] in the post body — that always wins, wherever the position setting is. Set Map position to Only where the placeholder appears if you never want the automatic placement.

What gets stripped

Everything below is on by default and configurable under Plugins → Route Map → What gets stripped.

Setting Removes
Timestamps <time>, <when>, <TimeStamp>, <TimeSpan> — when you were at each point
Speed and heading <speed>, <course>, <heading>, <magvar>, <gx:angles>
Extension blocks <extensions> and <ExtendedData> — where watches store heart rate, cadence, power and temperature
GPS quality <sat>, <hdop>, <vdop>, <pdop>, <dgpsid>, <fix>
Device and author the creator attribute plus <author> and <copyright> (off by default)

Elevation is kept. So are waypoint and track names.

To keep the timestamps in a particular post, set Timestamps in these files to Keep them on the Routes tab, or change the site-wide setting. Set it before you upload: stripping is a rewrite, not a flag, so once the timestamps are gone the only way back is to upload the file again. Going the other way is fine — turn a strip option on later and the plugin notices that already-processed files are missing that pass and cleans them on the next save.

Matching is done on the element's local name, so it does not matter whether your recorder writes <time>, <gpx:time> or <gpxtpx:speed> — they all go.

Files that declare a DTD are refused rather than processed. GPX and KML never need one, and accepting one would mean storing it and handing it to whoever downloads the track next. Entity substitution and network access are off at the parser, so external entities cannot be fetched or expanded. Track and waypoint names are stripped of markup on the way in and rendered as text on the way out.

Files are cleaned at three points: on upload, on page save, and on render. The last one is the safety net for files you copied in over FTP. A cleaned file gets a marker comment at the top, so repeat saves cost nothing.

This rewrites your files in place and there is no undo. Keep master copies of your recordings somewhere outside the site. If you would rather see for yourself before trusting it, upload one file, save, and open the file in the page folder — the timestamps are gone and the coordinates are untouched.

Page weight

A one-hour recording is typically 3,600 points and looks identical to 500 on a map the width of a blog column. Lines are simplified with RamerDouglasPeucker at a 3 m tolerance and capped at 4,000 points, and coordinates are rounded to five decimal places (about a metre). On a jittery 4,000-point track that is roughly a two-thirds reduction with no visible change. Both numbers are configurable under Geometry.

The GeoJSON is embedded in the page and generated inside Grav's content cache, so it is computed once per edit, not once per visitor.

Basemaps

Readers switch between basemaps with the control in the corner. Four are configured out of the box under Plugins → Route Map → Basemaps:

OpenStreetMap the standard style, zoom 19
OpenTopoMap contours and paths, zoom 17
CyclOSM cycle infrastructure, zoom 20
Tracestrack Topo needs a free key from tracestrack.com — the entry is skipped until you add one

Add your own with an XYZ template. Put {key} in the URL where a provider wants an API key and fill in the API key field. Set Tile size to 512 for providers that serve retina tiles; otherwise they overlap, because Leaflet assumes 256.

Leave one entry enabled and the switcher disappears.

Check each provider's usage policy before you go live. The OpenStreetMap Foundation's tiles are donated infrastructure with a fair-use policy, not a free CDN, and OpenTopoMap and CyclOSM are volunteer-run too.

Making tracks stand out

Each route is drawn twice: a wider contrasting casing underneath, then the route colour on top. Without it a line disappears into a detailed basemap — OpenTopoMap in particular gives contours roughly the same visual weight as a 4px track.

The casing lives in its own Leaflet pane below the route pane, so every casing sits under every route rather than only under its own. With overlapping tracks that is the difference between clean crossings and one route's outline cutting across another's colour.

Colour is picked per route from the route's own luminance — white under most colours, near-black under pale ones. Width, opacity and an explicit colour are under Plugins → Route Map → Map, and setting the width to 0 turns it off. If your basemaps are all dark, set the colour explicitly rather than relying on the automatic choice, which assumes a light background.

Full screen

The button below the zoom control expands the map. It uses the browser's Fullscreen API where that works and pins the map over the viewport where it does not — iOS Safari only grants real full screen to video, so the fallback is what iPhone readers get. Escape closes both. Scroll-wheel zoom switches on while full screen, since there is no page behind it to scroll.

Turn the button off under Plugins → Route Map → Map.

Downloads

Each route gets a download link in the legend, served from {post URL}/route-map-download/{filename}. A file is only handed out if its row exists, is visible, and has downloads enabled, and it is cleaned before it is sent — so a download can never contain data the map does not show. Turn it off site-wide, change the default for new routes, or switch it off for one route on the Routes tab.

Downloads honour the post's access: rules. The handler has to check them itself, because it answers before the point in the request where Grav authorises a page, so a restricted post's tracks would otherwise be public to anyone with the URL. Rules are inherited from the nearest ancestor that declares any, and the check fails closed: if it cannot evaluate the rules, nobody gets the file. Restricted downloads are also sent Cache-Control: private, no-store so a proxy cannot hold on to them.

Files that resolve outside the page folder are refused, and symlinks are ignored everywhere in the plugin.

Change the URL segment if route-map-download would clash with a child page name.

Leaflet

The default loads Leaflet 1.9.4 from unpkg. To serve it yourself instead — worth doing if you would rather not depend on a third-party CDN — download leaflet.css, leaflet.js and the images/ folder from leafletjs.com into:

user/plugins/route-map/assets/vendor/

then set Leaflet source to Serve from this plugin. If your theme already loads Leaflet, set it to My theme already loads Leaflet.

Stay on the 1.9 line. Leaflet 2.x ships as an ES module and does not expose the L global this plugin uses.

Customising

The template. Copy user/plugins/route-map/templates/partials/route-map.html.twig to user/themes/<your-theme>/templates/partials/route-map.html.twig and edit freely. The theme copy wins.

The styles. Every colour and dimension in route-map.css is a custom property on .route-map, so you can restyle it from your own stylesheet without touching the plugin:

.route-map {
    --route-map-height: 600px;
    --route-map-radius: 0;
    --route-map-border: #222;
    --route-map-control-bg: #ffffff;    /* Leaflet's zoom, layers and scale controls */
    --route-map-control-text: #333333;
}

The controls stay light in dark mode on purpose: Leaflet's layers toggle is a dark background image, so it disappears against a dark panel. Override the two tokens above if you would rather have dark controls and supply your own icon.

Full screen sets --route-map-backdrop, --route-map-text, --route-map-border and --route-map-surface inline while it is open, measured from the page, and removes them on exit. Anything you set on .route-map applies normally the rest of the time.

Placing it from a template. {{ route_map() }} renders the map for the current page, or {{ route_map(page) }} for a specific one. Set the map position to manual first, or you will get two maps. It is registered with the Twig content sandbox, so it also works inside a post body when you have Twig-in-content enabled.

Your own blueprint. If you would rather not have the tab injected everywhere, turn off Add the Routes tab to pages and import the fields yourself:

form:
  fields:
    tabs:
      fields:
        route_map:
          type: tab
          title: Routes
          import@:
            type: plugin
            name: route-map
            file: blueprints/route-map.yaml

Or leave injection on and list the templates it applies to under Limit to page templates.

Known limits

  • KMZ is not supported. It is a zipped KML. Unzip it and upload the .kml inside.
  • Modular pages. Assets are queued based on the page being served, so if the route files live on a modular child rather than the page itself, add the map from the parent template with {{ route_map(child) }}.
  • No elevation profile. Elevation is kept in the geometry, so a chart is a reasonable thing to add on top, but it is not in here.
  • Full screen on iOS is the pinned fallback rather than true full screen. Browser chrome stays visible. That is a Safari restriction, not something the plugin can work around.
  • Large files above 10 MB are skipped and logged rather than partly processed. Raise the limit under What gets stripped if you need to, but simplifying the file before upload is usually the better answer.

How it hangs together

File Job
route-map.php Event wiring, blueprint injection, content injection, assets
classes/Sanitizer.php Finds track files, strips timing and telemetry, rewrites atomically
classes/Converter.php GPX and KML to GeoJSON, namespace-agnostic
classes/Simplify.php RamerDouglasPeucker with a hard point cap
classes/Builder.php Reconciles the header's route list with the page folder, tracks removals, assembles basemaps
templates/partials/route-map.html.twig Markup for the map and legend
assets/route-map.js Leaflet setup, legend toggles, zoom-to-route

A note on Grav 2.0: the admin-facing handlers are registered statically and are deliberately not wrapped in an isAdmin() check. Every save in Grav 2.0 arrives through the API plugin, which does not establish the admin context until route dispatch — long after onPluginsInitialized(). Gating the subscription would silently unsubscribe them and the save logic would never run.

Security

Reviewed against XXE, entity expansion, XSS, CSRF, traversal, unsafe filenames, MIME spoofing, arbitrary overwrite, malformed XML and injection. Fixes for everything found are in 1.1.1 — see the changelog. If you find something, the interesting surfaces are the download handler in route-map.php and the two load() methods in classes/.

Licence

MIT.