- PHP 70.7%
- JavaScript 18.6%
- CSS 8.6%
- Twig 2.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| assets | ||
| blueprints | ||
| classes | ||
| templates/partials | ||
| blueprints.yaml | ||
| CHANGELOG.md | ||
| languages.yaml | ||
| LICENSE | ||
| README.md | ||
| route-map.php | ||
| route-map.yaml | ||
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
- Edit a post. There is now a Routes tab.
- Upload one or more
.gpxor.kmlfiles. They land in the post's own folder, are cleaned on the way in, and each gets a route row named from its filename. - Adjust the name, colour and line thickness per row, and switch off any you do not want drawn or offered for download.
- 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 Ramer–Douglas–Peucker 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
.kmlinside. - 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 |
Ramer–Douglas–Peucker 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.