Color-code, cluster and add icons to POI waypoints
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:
1 parent
708c08259b
commit
0b18dc018a
8 files changed
+434
-35
No files matched your search
+58
-12
@@ -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
|
`Fit` reads Garmin's binary FIT format directly
|
||||||
(hand-rolled — see below) into a single `MultiLineString` `track`
|
(hand-rolled — see below) into a single `MultiLineString` `track`
|
||||||
feature; FIT activity files don't really have a GPX-waypoint
|
feature; FIT activity files don't really have a GPX-waypoint
|
||||||
equivalent, so it never produces `Point` features. MapLibre's
|
equivalent, so it never produces `Point` features. Checking a file
|
||||||
`geometry-type` expression treats `MultiLineString` the same as
|
fetches its GeoJSON itself (`public/js/gpx.js`'s `_addLayers`) and
|
||||||
`LineString`, so a single GeoJSON source per file with a `line` layer
|
hands the parsed result to `addSource` — rather than passing MapLibre a
|
||||||
and a `circle` layer (each filtered by geometry type) is enough to
|
URL and letting it fetch internally — specifically so a `422` (a file
|
||||||
render both. Checking a file fetches its GeoJSON itself
|
REXML or `Fit` can't parse) surfaces as an alert and un-checks the box
|
||||||
(`public/js/gpx.js`'s `_addLayers`) and hands the parsed result to
|
instead of the layer just silently never appearing, which is what a
|
||||||
`addSource` — rather than passing MapLibre a URL and letting it fetch
|
`data: url` source does on a failed fetch. The fetched features are
|
||||||
internally — specifically so a `422` (a file REXML or `Fit` can't
|
then split client-side into two GeoJSON sources per file, a
|
||||||
parse) surfaces as an alert and un-checks the box instead of the layer
|
lines-and-routes one and a points-only one, rather than one mixed
|
||||||
just silently never appearing, which is what a `data: url` source does
|
source with a `geometry-type` filter per layer (the previous design):
|
||||||
on a failed fetch.
|
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 parsing:: No gem was added for this — the one Ruby option
|
||||||
(`fit-parser`, tjwallace/fit) hasn't been touched since 2022 and pulls
|
(`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.
|
whichever files were checked.
|
||||||
|
|
||||||
Waypoint popups:: Clicking a waypoint circle opens a MapLibre popup
|
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
|
registered once per file (via `map.on(type, layerId, handler)`, a
|
||||||
delegated listener that survives `setStyle()`) rather than on every
|
delegated listener that survives `setStyle()`) rather than on every
|
||||||
`style.load` re-add, which would otherwise stack duplicate handlers
|
`style.load` re-add, which would otherwise stack duplicate handlers
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.waypoint_feature(wpt)
|
def self.waypoint_feature(wpt)
|
||||||
|
ext = waypoint_style(wpt)
|
||||||
{
|
{
|
||||||
"type" => "Feature",
|
"type" => "Feature",
|
||||||
"geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) },
|
"geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) },
|
||||||
@@ -47,11 +48,44 @@ module Maptool
|
|||||||
"name" => child_text(wpt, "name"),
|
"name" => child_text(wpt, "name"),
|
||||||
"description" => child_text(wpt, "desc"),
|
"description" => child_text(wpt, "desc"),
|
||||||
"time" => child_text(wpt, "time"),
|
"time" => child_text(wpt, "time"),
|
||||||
|
"category" => child_text(wpt, "type"),
|
||||||
|
"color" => ext[:color],
|
||||||
|
"icon" => ext[:icon],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
private_class_method :waypoint_feature
|
private_class_method :waypoint_feature
|
||||||
|
|
||||||
|
OSMAND_NAMESPACE = "https://osmand.net"
|
||||||
|
private_constant :OSMAND_NAMESPACE
|
||||||
|
|
||||||
|
# POI exports (park4night via p4n2gpx, OsmAnd) tag each waypoint with a
|
||||||
|
# category (<type>) and, in a vendor <extensions> block, a per-category
|
||||||
|
# color and an OsmAnd icon key (e.g. "amenity_parking") — used to
|
||||||
|
# color/icon-code the map instead of every waypoint rendering as the same
|
||||||
|
# plain dot. Matched by namespace *URI*, not just local element name
|
||||||
|
# (unlike point_extensions below, which only cares about local names):
|
||||||
|
# a p4n2gpx export carries both an <osmand:icon> (a category key we can
|
||||||
|
# actually render) and a <locus:icon> (a Locus-app-specific drawable
|
||||||
|
# path) side by side — both strip down to the same local name "icon",
|
||||||
|
# so matching on that alone picks whichever happens to come last.
|
||||||
|
def self.waypoint_style(wpt)
|
||||||
|
values = {}
|
||||||
|
extensions = wpt.elements["extensions"]
|
||||||
|
return values unless extensions
|
||||||
|
|
||||||
|
extensions.elements.each do |el|
|
||||||
|
next unless el.namespace == OSMAND_NAMESPACE
|
||||||
|
|
||||||
|
case el.name
|
||||||
|
when "color" then values[:color] = el.text&.strip
|
||||||
|
when "icon" then values[:icon] = el.text&.strip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
values
|
||||||
|
end
|
||||||
|
private_class_method :waypoint_style
|
||||||
|
|
||||||
def self.track_feature(trk)
|
def self.track_feature(trk)
|
||||||
lines = []
|
lines = []
|
||||||
all_points = []
|
all_points = []
|
||||||
|
|||||||
@@ -398,6 +398,32 @@ html, body {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpx-legend {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-legend[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-legend__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpx-legend__swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#map.gpx-drop-hover::after {
|
#map.gpx-drop-hover::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -417,6 +443,13 @@ html, body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gpx-popup__category {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.gnss-control__status {
|
.gnss-control__status {
|
||||||
color: #888;
|
color: #888;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
+205
-23
@@ -1,5 +1,5 @@
|
|||||||
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||||
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON, ROUTE_ICON } from "/js/icons.js";
|
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON, ROUTE_ICON, POI_ICONS } from "/js/icons.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||||
import { formatActivityType } from "/js/track_profile.js";
|
import { formatActivityType } from "/js/track_profile.js";
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ const UPLOAD_URL = "/api/gpx";
|
|||||||
const FOLDER_URL = "/api/gpx/folder";
|
const FOLDER_URL = "/api/gpx/folder";
|
||||||
const MOVE_URL = "/api/gpx/move";
|
const MOVE_URL = "/api/gpx/move";
|
||||||
const TRACK_COLOR = "#e63946";
|
const TRACK_COLOR = "#e63946";
|
||||||
|
const POI_ICON_KEYS = Object.keys(POI_ICONS);
|
||||||
|
const POI_ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below
|
||||||
|
const POI_ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, matching the unclustered circle's 14px diameter
|
||||||
|
|
||||||
// Custom drag payload MIME type for moving an already-uploaded file between
|
// Custom drag payload MIME type for moving an already-uploaded file between
|
||||||
// folders, as opposed to an OS file drag (type "Files") for uploading a new
|
// folders, as opposed to an OS file drag (type "Files") for uploading a new
|
||||||
@@ -24,14 +27,33 @@ export class GpxPanelControl {
|
|||||||
this._profilePanel = profilePanel;
|
this._profilePanel = profilePanel;
|
||||||
this._visible = new Set(); // file paths currently shown on the map
|
this._visible = new Set(); // file paths currently shown on the map
|
||||||
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
|
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
|
||||||
|
this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend
|
||||||
|
|
||||||
// setStyle() (switching the Online/Offline base map) tears down all
|
// setStyle() (switching the Online/Offline base map) tears down all
|
||||||
// custom sources and layers — put visible GPX layers back afterwards.
|
// custom sources/layers/images — re-register the POI icon sprites
|
||||||
|
// before putting visible GPX layers back, so a symbol layer added right
|
||||||
|
// away doesn't reference a not-yet-existing image.
|
||||||
|
this._iconsReady = this._registerPoiIcons();
|
||||||
map.on("style.load", () => {
|
map.on("style.load", () => {
|
||||||
|
this._iconsReady = this._registerPoiIcons();
|
||||||
this._visible.forEach((path) => this._addLayers(path));
|
this._visible.forEach((path) => this._addLayers(path));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SDF images (a single alpha shape, tinted at render time via icon-color)
|
||||||
|
// rather than plain color images: the source SVGs are silhouettes with no
|
||||||
|
// color information worth preserving, and SDF lets the same sprite be
|
||||||
|
// reused if a future category needs a different tint.
|
||||||
|
async _registerPoiIcons() {
|
||||||
|
await Promise.all(
|
||||||
|
POI_ICON_KEYS.map(async (key) => {
|
||||||
|
if (this._map.hasImage(key)) return;
|
||||||
|
const imageData = await svgToImageData(POI_ICONS[key], POI_ICON_IMAGE_SIZE);
|
||||||
|
if (!this._map.hasImage(key)) this._map.addImage(key, imageData, { sdf: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
mount(sidebar) {
|
mount(sidebar) {
|
||||||
this._container = document.createElement("div");
|
this._container = document.createElement("div");
|
||||||
this._container.className = "sidebar-item";
|
this._container.className = "sidebar-item";
|
||||||
@@ -41,6 +63,7 @@ export class GpxPanelControl {
|
|||||||
|
|
||||||
this._panel = document.getElementById("gpx-panel");
|
this._panel = document.getElementById("gpx-panel");
|
||||||
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
this._treeContainer = this._panel.querySelector(".gpx-tree");
|
||||||
|
this._legendContainer = this._panel.querySelector(".gpx-legend");
|
||||||
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
|
||||||
this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON;
|
this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON;
|
||||||
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
|
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
|
||||||
@@ -294,8 +317,9 @@ export class GpxPanelControl {
|
|||||||
// instead of the layer just silently never appearing. Returns whether it
|
// instead of the layer just silently never appearing. Returns whether it
|
||||||
// succeeded.
|
// succeeded.
|
||||||
async _addLayers(path) {
|
async _addLayers(path) {
|
||||||
const id = sourceId(path);
|
const linesId = linesSourceId(path);
|
||||||
if (this._map.getSource(id)) return true;
|
const pointsId = pointsSourceId(path);
|
||||||
|
if (this._map.getSource(linesId) || this._map.getSource(pointsId)) return true;
|
||||||
|
|
||||||
let geojson;
|
let geojson;
|
||||||
try {
|
try {
|
||||||
@@ -311,33 +335,120 @@ export class GpxPanelControl {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._map.getSource(id)) return true; // added concurrently (e.g. a style.load race) while this was fetching
|
if (this._map.getSource(linesId) || this._map.getSource(pointsId)) return true; // added concurrently (e.g. a style.load race) while this was fetching
|
||||||
|
|
||||||
this._map.addSource(id, { type: "geojson", data: geojson });
|
// Split into two sources: MapLibre's clustering only understands a
|
||||||
|
// source made entirely of Point/MultiPoint features, and a file can mix
|
||||||
|
// a track/route line with waypoints (or, like a park4night POI export,
|
||||||
|
// be thousands of waypoints and nothing else).
|
||||||
|
const lines = { type: "FeatureCollection", features: [] };
|
||||||
|
const points = { type: "FeatureCollection", features: [] };
|
||||||
|
for (const feature of geojson.features) {
|
||||||
|
(feature.geometry.type === "Point" ? points : lines).features.push(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._map.addSource(linesId, { type: "geojson", data: lines });
|
||||||
this._map.addLayer({
|
this._map.addLayer({
|
||||||
id: `${id}-line`,
|
id: `${linesId}-line`,
|
||||||
type: "line",
|
type: "line",
|
||||||
source: id,
|
source: linesId,
|
||||||
filter: ["==", ["geometry-type"], "LineString"],
|
|
||||||
layout: { "line-cap": "round", "line-join": "round" },
|
layout: { "line-cap": "round", "line-join": "round" },
|
||||||
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
|
paint: { "line-color": TRACK_COLOR, "line-width": 3 },
|
||||||
});
|
});
|
||||||
|
this._registerLineInteractivity(`${linesId}-line`);
|
||||||
|
|
||||||
|
// clusterRadius/clusterMaxZoom are harmless when there's nothing dense
|
||||||
|
// enough to cluster (a handful of waypoints just render individually).
|
||||||
|
this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 });
|
||||||
|
await this._iconsReady;
|
||||||
|
if (!this._map.getSource(pointsId)) return true; // a style.load (basemap switch) tore it down while icons were loading
|
||||||
|
|
||||||
|
this._addClusterLayers(pointsId);
|
||||||
|
|
||||||
|
if (points.features.length > 0) this._pointsByPath.set(path, points);
|
||||||
|
this._updateLegend();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single-hue, light->dark ramp keyed on point_count (magnitude, not
|
||||||
|
// category) for the clustered circles; individual points below that use
|
||||||
|
// each waypoint's own category color (see Maptool::Gpx#waypoint_color),
|
||||||
|
// falling back to the plain track color for waypoints with no category
|
||||||
|
// (e.g. a hand-placed GNSS pin, not from a POI export).
|
||||||
|
_addClusterLayers(pointsId) {
|
||||||
this._map.addLayer({
|
this._map.addLayer({
|
||||||
id: `${id}-points`,
|
id: `${pointsId}-clusters`,
|
||||||
type: "circle",
|
type: "circle",
|
||||||
source: id,
|
source: pointsId,
|
||||||
filter: ["==", ["geometry-type"], "Point"],
|
filter: ["has", "point_count"],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-radius": 5,
|
"circle-radius": ["step", ["get", "point_count"], 16, 25, 20, 100, 26],
|
||||||
"circle-color": TRACK_COLOR,
|
"circle-color": ["step", ["get", "point_count"], "#a8c8ea", 25, "#5b9bd5", 100, "#2e6da4"],
|
||||||
"circle-stroke-color": "#fff",
|
"circle-stroke-color": "#fff",
|
||||||
"circle-stroke-width": 1.5,
|
"circle-stroke-width": 1.5,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this._map.addLayer({
|
||||||
|
id: `${pointsId}-cluster-count`,
|
||||||
|
type: "symbol",
|
||||||
|
source: pointsId,
|
||||||
|
filter: ["has", "point_count"],
|
||||||
|
layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Regular"], "text-size": 11 },
|
||||||
|
paint: { "text-color": "#fff" },
|
||||||
|
});
|
||||||
|
this._map.addLayer({
|
||||||
|
id: `${pointsId}-unclustered`,
|
||||||
|
type: "circle",
|
||||||
|
source: pointsId,
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
paint: {
|
||||||
|
"circle-radius": 7,
|
||||||
|
"circle-color": ["coalesce", ["get", "color"], TRACK_COLOR],
|
||||||
|
"circle-stroke-color": "#fff",
|
||||||
|
"circle-stroke-width": 1.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Only for the curated POI_ICON_KEYS (see icons.js) — anything else
|
||||||
|
// (a plain hand-placed waypoint, or a category p4n2gpx/OsmAnd hasn't
|
||||||
|
// been taught here) just keeps the colored dot above with no icon.
|
||||||
|
this._map.addLayer({
|
||||||
|
id: `${pointsId}-unclustered-icon`,
|
||||||
|
type: "symbol",
|
||||||
|
source: pointsId,
|
||||||
|
filter: ["all", ["!", ["has", "point_count"]], ["in", ["get", "icon"], ["literal", POI_ICON_KEYS]]],
|
||||||
|
layout: {
|
||||||
|
"icon-image": ["get", "icon"],
|
||||||
|
"icon-size": POI_ICON_SIZE,
|
||||||
|
"icon-allow-overlap": true,
|
||||||
|
"icon-ignore-placement": true,
|
||||||
|
},
|
||||||
|
paint: { "icon-color": "#fff" },
|
||||||
|
});
|
||||||
|
|
||||||
this._registerPointInteractivity(`${id}-points`);
|
this._registerClusterInteractivity(pointsId);
|
||||||
this._registerLineInteractivity(`${id}-line`);
|
this._registerPointInteractivity(`${pointsId}-unclustered`);
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
// Clicking a cluster zooms in just far enough to break it apart, the
|
||||||
|
// standard MapLibre cluster-expansion pattern.
|
||||||
|
_registerClusterInteractivity(pointsId) {
|
||||||
|
const clusterId = `${pointsId}-clusters`;
|
||||||
|
if (this._interactiveLayers.has(clusterId)) return;
|
||||||
|
this._interactiveLayers.add(clusterId);
|
||||||
|
|
||||||
|
this._map.on("click", clusterId, async (event) => {
|
||||||
|
const feature = this._map.queryRenderedFeatures(event.point, { layers: [clusterId] })[0];
|
||||||
|
if (!feature) return;
|
||||||
|
const zoom = await this._map.getSource(pointsId).getClusterExpansionZoom(feature.properties.cluster_id);
|
||||||
|
this._map.easeTo({ center: feature.geometry.coordinates, zoom });
|
||||||
|
});
|
||||||
|
this._map.on("mouseenter", clusterId, () => {
|
||||||
|
this._map.getCanvas().style.cursor = "pointer";
|
||||||
|
});
|
||||||
|
this._map.on("mouseleave", clusterId, () => {
|
||||||
|
this._map.getCanvas().style.cursor = "";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registered once per layer id, ever — map.on(type, layerId, fn) is a
|
// Registered once per layer id, ever — map.on(type, layerId, fn) is a
|
||||||
@@ -396,11 +507,50 @@ export class GpxPanelControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_removeLayers(path) {
|
_removeLayers(path) {
|
||||||
const id = sourceId(path);
|
const linesId = linesSourceId(path);
|
||||||
[`${id}-line`, `${id}-points`].forEach((layerId) => {
|
const pointsId = pointsSourceId(path);
|
||||||
|
[
|
||||||
|
`${linesId}-line`,
|
||||||
|
`${pointsId}-clusters`,
|
||||||
|
`${pointsId}-cluster-count`,
|
||||||
|
`${pointsId}-unclustered`,
|
||||||
|
`${pointsId}-unclustered-icon`,
|
||||||
|
].forEach((layerId) => {
|
||||||
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
|
if (this._map.getLayer(layerId)) this._map.removeLayer(layerId);
|
||||||
});
|
});
|
||||||
if (this._map.getSource(id)) this._map.removeSource(id);
|
if (this._map.getSource(linesId)) this._map.removeSource(linesId);
|
||||||
|
if (this._map.getSource(pointsId)) this._map.removeSource(pointsId);
|
||||||
|
|
||||||
|
this._pointsByPath.delete(path);
|
||||||
|
this._updateLegend();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuilt from whichever visible files actually carry categorized
|
||||||
|
// waypoints (a park4night/OsmAnd POI export) — a plain GPS track has none
|
||||||
|
// and the legend just stays hidden.
|
||||||
|
_updateLegend() {
|
||||||
|
const categories = new Map(); // category name -> color
|
||||||
|
for (const points of this._pointsByPath.values()) {
|
||||||
|
for (const feature of points.features) {
|
||||||
|
const category = feature.properties.category;
|
||||||
|
if (category && !categories.has(category)) categories.set(category, feature.properties.color || TRACK_COLOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._legendContainer.innerHTML = "";
|
||||||
|
this._legendContainer.hidden = categories.size === 0;
|
||||||
|
[...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, color]) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "gpx-legend__item";
|
||||||
|
const swatch = document.createElement("span");
|
||||||
|
swatch.className = "gpx-legend__swatch";
|
||||||
|
swatch.style.background = color; // a malformed/malicious value from an uploaded file is simply ignored by the CSSOM, never parsed as markup
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = category;
|
||||||
|
item.appendChild(swatch);
|
||||||
|
item.appendChild(label);
|
||||||
|
this._legendContainer.appendChild(item);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _uploadFiles(fileList, folder) {
|
async _uploadFiles(fileList, folder) {
|
||||||
@@ -504,8 +654,39 @@ function redirectIfUnauthorized(response) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceId(path) {
|
// Rasterizes a hand-authored SVG icon to ImageData for map.addImage(), at a
|
||||||
return `gpx-${path}`;
|
// fixed square size (the source SVGs have no explicit width/height, just a
|
||||||
|
// viewBox, so one is injected — otherwise an <img> falls back to the CSS
|
||||||
|
// default replaced-element size instead of the viewBox's aspect).
|
||||||
|
async function svgToImageData(svgMarkup, size) {
|
||||||
|
// xmlns is required here even though the other hand-drawn icons in this
|
||||||
|
// app get away without it — those are inlined via innerHTML, where the
|
||||||
|
// HTML parser assigns the SVG namespace from context, but a standalone
|
||||||
|
// <img src="blob:..."> loads it as its own XML document, which needs an
|
||||||
|
// explicit namespace or every browser refuses to decode it.
|
||||||
|
const sized = svgMarkup.replace("<svg ", `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" `);
|
||||||
|
const url = URL.createObjectURL(new Blob([sized], { type: "image/svg+xml" }));
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
await img.decode();
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
return ctx.getImageData(0, 0, size, size);
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesSourceId(path) {
|
||||||
|
return `gpx-${path}-lines`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsSourceId(path) {
|
||||||
|
return `gpx-${path}-points`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gpxFileUrl(path) {
|
function gpxFileUrl(path) {
|
||||||
@@ -514,9 +695,10 @@ function gpxFileUrl(path) {
|
|||||||
|
|
||||||
// properties come straight from an uploaded GPX file — untrusted input —
|
// properties come straight from an uploaded GPX file — untrusted input —
|
||||||
// so every value is escaped before landing in the popup's HTML.
|
// so every value is escaped before landing in the popup's HTML.
|
||||||
function popupHtml({ name, description, time }) {
|
function popupHtml({ name, description, time, category }) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
|
||||||
|
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
|
||||||
if (description) parts.push(`<p>${linkify(description).replace(/\n/g, "<br>")}</p>`);
|
if (description) parts.push(`<p>${linkify(description).replace(/\n/g, "<br>")}</p>`);
|
||||||
if (time) {
|
if (time) {
|
||||||
const parsed = new Date(time);
|
const parsed = new Date(time);
|
||||||
|
|||||||
@@ -58,6 +58,53 @@ export const ROUTE_ICON = `
|
|||||||
<path d="M6.5 7.5C10 11 8 13 12 13s2 5 6.5 5" stroke-dasharray="2 2.2"/>
|
<path d="M6.5 7.5C10 11 8 13 12 13s2 5 6.5 5" stroke-dasharray="2 2.2"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
// POI category glyphs (park4night/OsmAnd exports), keyed by the OsmAnd icon
|
||||||
|
// name a file's <osmand:icon> extension carries — a small curated set for
|
||||||
|
// the categories park4night actually uses (see waypoint_style in
|
||||||
|
// lib/maptool/gpx.rb), not an exhaustive OsmAnd icon set. Filled shapes
|
||||||
|
// (not stroked outlines like the sidebar icons above) since these render at
|
||||||
|
// ~14px on the map, where a hairline stroke disappears; solid silhouettes
|
||||||
|
// stay legible that small. Rasterized and used as SDF map icons (see
|
||||||
|
// gpx.js), so only the alpha shape matters — the actual `currentColor`
|
||||||
|
// value is irrelevant.
|
||||||
|
export const POI_ICONS = {
|
||||||
|
amenity_parking: `
|
||||||
|
<svg viewBox="0 0 24 24"><text x="12" y="18" font-family="Arial, Helvetica, sans-serif" font-size="16" font-weight="700" text-anchor="middle" fill="currentColor">P</text></svg>`,
|
||||||
|
|
||||||
|
tourism_camp_site: `
|
||||||
|
<svg viewBox="0 0 24 24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 3 22 20 2 20 Z M12 12 15.5 20 8.5 20 Z" fill="currentColor"/></svg>`,
|
||||||
|
|
||||||
|
leisure_picnic_table: `
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="9" width="18" height="3" fill="currentColor"/>
|
||||||
|
<path d="M5 12 2 20 5 20 7.5 12 Z M19 12 22 20 19 20 16.5 12 Z" fill="currentColor"/>
|
||||||
|
</svg>`,
|
||||||
|
|
||||||
|
amenity_fuel: `
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2C12 2 5 11 5 15.5A7 7 0 0 0 19 15.5C19 11 12 2 12 2Z" fill="currentColor"/></svg>`,
|
||||||
|
|
||||||
|
tourism_hotel: `
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="11" width="18" height="7" rx="1.5" fill="currentColor"/>
|
||||||
|
<rect x="4" y="8" width="6" height="4" rx="1" fill="currentColor"/>
|
||||||
|
<rect x="3" y="18" width="2" height="3" fill="currentColor"/>
|
||||||
|
<rect x="19" y="18" width="2" height="3" fill="currentColor"/>
|
||||||
|
</svg>`,
|
||||||
|
|
||||||
|
forest: `
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2 18 12 15 12 19 19 5 19 9 12 6 12Z" fill="currentColor"/>
|
||||||
|
<rect x="10.5" y="19" width="3" height="3" fill="currentColor"/>
|
||||||
|
</svg>`,
|
||||||
|
|
||||||
|
sport_cycling: `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="6" cy="17" r="4"/>
|
||||||
|
<circle cx="18" cy="17" r="4"/>
|
||||||
|
<path d="M6 17 11 8H15M11 8 15 17M18 17 14 10"/>
|
||||||
|
</svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
export const LIVETRACK_ICON = `
|
export const LIVETRACK_ICON = `
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/>
|
<path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/>
|
||||||
|
|||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="test">
|
||||||
|
<wpt lat="48.1" lon="17.2">
|
||||||
|
<name>Free spot</name>
|
||||||
|
<type>Free motorhome area</type>
|
||||||
|
<extensions>
|
||||||
|
<osmand:color xmlns:osmand="https://osmand.net">#2E7D32</osmand:color>
|
||||||
|
<osmand:icon xmlns:osmand="https://osmand.net">tourism_camp_site</osmand:icon>
|
||||||
|
</extensions>
|
||||||
|
</wpt>
|
||||||
|
<wpt lat="48.101" lon="17.201">
|
||||||
|
<name>Plain waypoint</name>
|
||||||
|
</wpt>
|
||||||
|
<wpt lat="48.102" lon="17.202">
|
||||||
|
<name>p4n2gpx export</name>
|
||||||
|
<type>Daily parking only</type>
|
||||||
|
<extensions>
|
||||||
|
<osmand:color xmlns:osmand="https://osmand.net">#546E7A</osmand:color>
|
||||||
|
<osmand:icon xmlns:osmand="https://osmand.net">amenity_parking</osmand:icon>
|
||||||
|
<locus:icon xmlns:locus="http://www.locusmap.eu">res/drawable-hdpi/ic_parking_small.png</locus:icon>
|
||||||
|
</extensions>
|
||||||
|
</wpt>
|
||||||
|
</gpx>
|
||||||
@@ -41,6 +41,39 @@ class GpxTest < Minitest::Test
|
|||||||
assert_equal [17.2, 48.1], [profile[0]["lon"], profile[0]["lat"]]
|
assert_equal [17.2, 48.1], [profile[0]["lon"], profile[0]["lat"]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_waypoint_captures_category_and_color_from_poi_extensions
|
||||||
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
|
||||||
|
free_spot = geojson["features"].find { |f| f["properties"]["name"] == "Free spot" }
|
||||||
|
|
||||||
|
assert_equal "Free motorhome area", free_spot["properties"]["category"]
|
||||||
|
assert_equal "#2E7D32", free_spot["properties"]["color"]
|
||||||
|
assert_equal "tourism_camp_site", free_spot["properties"]["icon"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regression test for a real bug: a p4n2gpx export's <extensions> carries
|
||||||
|
# both an <osmand:icon> (a category key this app can render) and a
|
||||||
|
# <locus:icon> (a Locus-app drawable path) side by side. REXML strips
|
||||||
|
# namespace prefixes from Element#name, so both elements report the same
|
||||||
|
# local name "icon" — matching on that alone picked whichever happened to
|
||||||
|
# come last in the file (locus, since it followed osmand), leaving every
|
||||||
|
# waypoint's icon set to a meaningless drawable path that never matched
|
||||||
|
# any category. Must match by namespace URI instead.
|
||||||
|
def test_waypoint_icon_ignores_non_osmand_extensions_with_the_same_local_name
|
||||||
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
|
||||||
|
export = geojson["features"].find { |f| f["properties"]["name"] == "p4n2gpx export" }
|
||||||
|
|
||||||
|
assert_equal "amenity_parking", export["properties"]["icon"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_waypoint_without_category_or_color_leaves_them_nil
|
||||||
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
|
||||||
|
plain = geojson["features"].find { |f| f["properties"]["name"] == "Plain waypoint" }
|
||||||
|
|
||||||
|
assert_nil plain["properties"]["category"]
|
||||||
|
assert_nil plain["properties"]["color"]
|
||||||
|
assert_nil plain["properties"]["icon"]
|
||||||
|
end
|
||||||
|
|
||||||
def test_kinds_detects_routes
|
def test_kinds_detects_routes
|
||||||
assert_equal ["route"], Maptool::Gpx.kinds(fixture_path("sample_route.gpx"))
|
assert_equal ["route"], Maptool::Gpx.kinds(fixture_path("sample_route.gpx"))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<p class="gpx-upload__status" hidden></p>
|
<p class="gpx-upload__status" hidden></p>
|
||||||
|
|
||||||
<div class="gpx-tree"></div>
|
<div class="gpx-tree"></div>
|
||||||
|
<div class="gpx-legend" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="livetrack-panel" class="basemap-panel" hidden>
|
<div id="livetrack-panel" class="basemap-panel" hidden>
|
||||||
|
|||||||
Reference in new issue
Block a user