Color-code, cluster and add icons to POI waypoints
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 16s

A park4night/OsmAnd POI export (e.g. a p4n2gpx area export) tags every
waypoint with a category and a per-category color/icon in its <extensions>
block, all of which was previously thrown away — every waypoint rendered as
the same plain red dot, unreadable once a file has a few thousand of them.

Maptool::Gpx now captures category/color/icon (waypoint_style), matched by
namespace URI rather than local element name: a p4n2gpx file carries both
<osmand:icon> and <locus:icon> side by side, and REXML strips prefixes from
Element#name, so both report as just "icon" — matching on that alone was
silently picking the (useless) Locus drawable path instead of the OsmAnd
category key. Regression test added for this.

On the map: waypoints are colored by category (falling back to the plain
track color for anything uncategorized), a small hand-drawn icon set
(public/js/icons.js's POI_ICONS, rasterized to SDF sprites at runtime) draws
a glyph on top for the categories it covers, the points source is clustered
so a dense export collapses into count bubbles instead of a solid blob, and
a legend in the side panel lists whichever categories are currently visible.

Splitting each file's GeoJSON into a lines source and a points source
(rather than one mixed source with a geometry-type filter per layer) was
needed for clustering, since MapLibre's cluster support only understands a
source made entirely of points.
This commit is contained in:
randy committed 2026-09-07 16:18:06 +02:00
1 parent 708c08259b
commit 0b18dc018a
8 files changed
+434 -35

No files matched your search

+58 -12
View File
@@ -193,17 +193,63 @@ route icon next to a route file and a solid one for a track
`Fit` reads Garmin's binary FIT format directly
(hand-rolled — see below) into a single `MultiLineString` `track`
feature; FIT activity files don't really have a GPX-waypoint
equivalent, so it never produces `Point` features. MapLibre's
`geometry-type` expression treats `MultiLineString` the same as
`LineString`, so a single GeoJSON source per file with a `line` layer
and a `circle` layer (each filtered by geometry type) is enough to
render both. Checking a file fetches its GeoJSON itself
(`public/js/gpx.js`'s `_addLayers`) and hands the parsed result to
`addSource` — rather than passing MapLibre a URL and letting it fetch
internally — specifically so a `422` (a file REXML or `Fit` can't
parse) surfaces as an alert and un-checks the box instead of the layer
just silently never appearing, which is what a `data: url` source does
on a failed fetch.
equivalent, so it never produces `Point` features. Checking a file
fetches its GeoJSON itself (`public/js/gpx.js`'s `_addLayers`) and
hands the parsed result to `addSource` — rather than passing MapLibre a
URL and letting it fetch internally — specifically so a `422` (a file
REXML or `Fit` can't parse) surfaces as an alert and un-checks the box
instead of the layer just silently never appearing, which is what a
`data: url` source does on a failed fetch. The fetched features are
then split client-side into two GeoJSON sources per file, a
lines-and-routes one and a points-only one, rather than one mixed
source with a `geometry-type` filter per layer (the previous design):
MapLibre's built-in point clustering (below) only understands a source
made entirely of `Point`/`MultiPoint` features, so a file mixing a
track with waypoints — or a several-thousand-waypoint POI export with
no line at all — needs the points on their own source regardless of
what else the file's GeoJSON also carries.
POI category styling & clustering:: A POI export (park4night via
p4n2gpx, OsmAnd) tags each waypoint with a category (`<type>`) and, in
a vendor `<extensions>` block, a per-category color and OsmAnd icon key
(e.g. `amenity_parking`) — `Maptool::Gpx.waypoint_style` reads both
into `category`/`color` properties (an OsmAnd export can carry a
`<locus:icon>` alongside `<osmand:icon>`, both of which strip down to
the same local element name "icon" once REXML drops namespace
prefixes, so this specifically matches on namespace *URI*
(`https://osmand.net`), not local name, to avoid picking whichever
happens to come last in the file). The unclustered-points map layer
colors each dot via `["coalesce", ["get", "color"], TRACK_COLOR]`
(falling back to the plain track color for a waypoint with no
category, e.g. a hand-placed GNSS pin), and a small curated icon set
(`public/js/icons.js`'s `POI_ICONS`, ~7 hand-drawn glyphs covering the
categories p4n2gpx actually uses, not an exhaustive OsmAnd icon set) is
rasterized once per style load (`_registerPoiIcons`, an SVG →
`<img>` → `<canvas>` → `ImageData` round trip, since these need to be
real raster images for `map.addImage`) and registered as SDF images —
a single alpha shape MapLibre tints at render time via `icon-color`
rather than a fixed-color image — so a symbol layer can draw a white
glyph on top of each dot for any waypoint whose `icon` matches a known
key (filtered out entirely for anything else, so an unrecognized icon
key just falls back to a plain colored dot rather than a broken/missing
image warning). The points source itself is created with
`cluster: true`; nearby points collapse into a stepped-radius,
sequential-blue circle sized by `point_count` below `clusterMaxZoom`,
clicking one zooms to its `getClusterExpansionZoom()`, and this is
harmless to enable unconditionally — a source with nothing dense enough
to cluster just renders every point individually as usual. A small
legend (`.gpx-legend` in the side panel) lists every distinct category
+ color currently visible across all checked files, rebuilt from the
same points GeoJSON kept around for exactly this
(`GpxPanelControl#_pointsByPath`) whenever a file is toggled on/off; it
stays hidden for files with no categorized waypoints (a plain GPS
track has none). The legend swatch color is set via
`swatch.style.background = color` (a DOM property assignment) rather
than interpolating the untrusted color string into an `innerHTML`
attribute — `escapeHtml` (used elsewhere on this page) only guards
element content, not attribute-quote escaping, so building
`style="background:${color}"` as a string would have been an
injection risk for a crafted GPX file.
FIT parsing:: No gem was added for this — the one Ruby option
(`fit-parser`, tjwallace/fit) hasn't been touched since 2022 and pulls
@@ -234,7 +280,7 @@ Like the radar overlay, GPX layers are removed by `map.setStyle()`
whichever files were checked.
Waypoint popups:: Clicking a waypoint circle opens a MapLibre popup
with its name, description and time. Click/hover handling is
with its name, category (if any), description and time. Click/hover handling is
registered once per file (via `map.on(type, layerId, handler)`, a
delegated listener that survives `setStyle()`) rather than on every
`style.load` re-add, which would otherwise stack duplicate handlers