diff --git a/docs/architecture.adoc b/docs/architecture.adoc
index ea35f45..066f808 100644
--- a/docs/architecture.adoc
+++ b/docs/architecture.adoc
@@ -218,21 +218,40 @@ into `category`/`color` properties (an OsmAnd export can carry a
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 →
-` ` → `` → `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
+happens to come last in the file). Individual waypoints render as a
+pin (`public/js/icons.js`'s `MAP_PIN_ICON`, the same path as the
+existing `WAYPOINT_ICON` outline, filled instead of stroked) rather
+than a plain circle — a flat colored dot turned out to look too much
+like a base map style's own small circular POI icons, blending in
+instead of reading as "your data"; a pin is a shape convention no base
+map uses for its own points. It's colored 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 anchored at its tip (`icon-anchor: "bottom"`, the
+standard map-pin convention — the tip, not the shape's center, lands on
+the actual coordinate). A small curated icon set
+(`public/js/icons.js`'s `POI_ICONS`, covering the categories p4n2gpx
+actually uses, not an exhaustive OsmAnd icon set) draws a white glyph
+on top of the pin's head, in a symbol layer offset up from the shared
+tip anchor by a size computed from the pin and glyph geometry
+(`POI_ICON_OFFSET_Y` — both layers anchor to the same raster canvas
+edge, so the offset is "how far above that shared anchor is the pin's
+head-center, minus how far above it the glyph's own visual center
+already sits at its own icon-size"), filtered to just the known keys
+(anything else keeps the plain pin with no glyph, rather than a
+broken/missing image warning). Six of the seven glyphs are the
+National Park Service's own map symbols
+(`github.com/nationalparkservice/symbol-library`, BSD-licensed,
+originally forked from Mapbox's Maki project) — a professional,
+purpose-built icon set beats hand-drawing "fuel pump"/"picnic table"
+glyphs from scratch; `forest` has no NPS equivalent (that set covers
+facilities, not land cover) and stays hand-drawn. Both the pin and the
+category glyphs are rasterized once per style load (`_registerPoiIcons`,
+an SVG → ` ` → `` → `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. 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
diff --git a/public/js/gpx.js b/public/js/gpx.js
index 56c8d58..f9738aa 100644
--- a/public/js/gpx.js
+++ b/public/js/gpx.js
@@ -1,5 +1,14 @@
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
-import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON, ROUTE_ICON, POI_ICONS } from "/js/icons.js";
+import {
+ GPX_ICON,
+ NEW_FOLDER_ICON,
+ UPLOAD_ICON,
+ WAYPOINT_ICON,
+ TRACK_ICON,
+ ROUTE_ICON,
+ MAP_PIN_ICON,
+ POI_ICONS,
+} from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import { formatActivityType } from "/js/track_profile.js";
@@ -10,7 +19,24 @@ const MOVE_URL = "/api/gpx/move";
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
+const POI_ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, matching the pin head's diameter below
+
+// The pin glyph (icons.js's MAP_PIN_ICON) is a 24x24 viewBox with its head
+// circle centered at y=9, rasterized onto the same POI_ICON_IMAGE_SIZE
+// canvas as every other icon. icon-anchor: "bottom" anchors both this layer
+// and the category-glyph layer below to the *raster canvas's* bottom edge
+// (not the pin's visual tip, which sits a little above that edge) — so both
+// layers share one screen anchor point, and placing the glyph in the pin's
+// head is just "how far above that shared anchor is each icon's own visual
+// center, and what's the gap between them":
+const MAP_PIN_ID = "map-pin";
+const MAP_PIN_SIZE = 0.55;
+const MAP_PIN_HEAD_CENTER_SVG_Y = 9;
+const MAP_PIN_VIEWBOX = 24;
+const pinHeadCenterAboveAnchorPx =
+ (MAP_PIN_VIEWBOX - MAP_PIN_HEAD_CENTER_SVG_Y) * (POI_ICON_IMAGE_SIZE / MAP_PIN_VIEWBOX) * MAP_PIN_SIZE;
+const glyphOwnCenterAboveAnchorPx = (POI_ICON_IMAGE_SIZE * POI_ICON_SIZE) / 2; // glyph content roughly fills its own canvas
+const POI_ICON_OFFSET_Y = -((pinHeadCenterAboveAnchorPx - glyphOwnCenterAboveAnchorPx) / POI_ICON_SIZE);
// 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
@@ -45,10 +71,11 @@ export class GpxPanelControl {
// color information worth preserving, and SDF lets the same sprite be
// reused if a future category needs a different tint.
async _registerPoiIcons() {
+ const sources = { ...POI_ICONS, [MAP_PIN_ID]: MAP_PIN_ICON };
await Promise.all(
- POI_ICON_KEYS.map(async (key) => {
+ Object.keys(sources).map(async (key) => {
if (this._map.hasImage(key)) return;
- const imageData = await svgToImageData(POI_ICONS[key], POI_ICON_IMAGE_SIZE);
+ const imageData = await svgToImageData(sources[key], POI_ICON_IMAGE_SIZE);
if (!this._map.hasImage(key)) this._map.addImage(key, imageData, { sdf: true });
}),
);
@@ -397,21 +424,30 @@ export class GpxPanelControl {
layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Regular"], "text-size": 11 },
paint: { "text-color": "#fff" },
});
+ // A pin (icons.js's MAP_PIN_ICON), not a plain circle: a flat colored
+ // dot looks too much like a base map style's own small circular POI
+ // icons, so individual waypoints blended in and were hard to spot as
+ // "your data" versus the map's own points. Anchored at its tip (the
+ // standard map-pin convention), not centered on the coordinate.
this._map.addLayer({
id: `${pointsId}-unclustered`,
- type: "circle",
+ type: "symbol",
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,
+ layout: {
+ "icon-image": MAP_PIN_ID,
+ "icon-size": MAP_PIN_SIZE,
+ "icon-anchor": "bottom",
+ "icon-allow-overlap": true,
+ "icon-ignore-placement": true,
},
+ paint: { "icon-color": ["coalesce", ["get", "color"], TRACK_COLOR] },
});
// 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.
+ // been taught here) just keeps the plain pin above with no glyph.
+ // icon-offset moves it up from the anchor (the pin's tip) into the
+ // pin's round head — see POI_ICON_OFFSET_Y's comment above.
this._map.addLayer({
id: `${pointsId}-unclustered-icon`,
type: "symbol",
@@ -420,6 +456,8 @@ export class GpxPanelControl {
layout: {
"icon-image": ["get", "icon"],
"icon-size": POI_ICON_SIZE,
+ "icon-anchor": "bottom",
+ "icon-offset": [0, POI_ICON_OFFSET_Y],
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
diff --git a/public/js/icons.js b/public/js/icons.js
index 50e40f8..e47cba3 100644
--- a/public/js/icons.js
+++ b/public/js/icons.js
@@ -58,37 +58,67 @@ export const ROUTE_ICON = `
`;
+// Filled version of WAYPOINT_ICON's outline (same path, fill instead of
+// stroke) — used as the map marker for individual waypoints, tinted by
+// category color via icon-color (see gpx.js). A pin/teardrop shape rather
+// than a plain dot specifically so it doesn't blend in with a base map
+// style's own small circular POI icons; the tip (not the head center) is
+// the anchor point, the standard map-pin convention.
+export const MAP_PIN_ICON = `
+ `;
+
// POI category glyphs (park4night/OsmAnd exports), keyed by the OsmAnd icon
// name a file's 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.
+// lib/maptool/gpx.rb), not an exhaustive OsmAnd icon set.
+//
+// Six of these are the National Park Service's own map pictograms
+// (github.com/nationalparkservice/symbol-library, src/standalone/*-black-22),
+// not hand-drawn — a professional, widely-recognized icon set built for
+// exactly this job (small monochrome map markers) beats reinventing "fuel
+// pump"/"picnic table" glyphs from scratch. Used under that repo's license:
+//
+// BSD License. Copyright (c) 2013, Mapbox, LLC. All rights reserved.
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the copyright notice and
+// this list of conditions are retained.
+//
+// `forest` has no NPS equivalent (that set covers facilities, not land
+// cover) and stays hand-drawn. All filled shapes — these render at ~14px on
+// the map, where a hairline stroke disappears — and rasterized/used as SDF
+// map icons (see gpx.js), so only the alpha shape matters, not the actual
+// `currentColor` value.
export const POI_ICONS = {
+ // src/standalone/parking-black-22.svg
amenity_parking: `
-P `,
+ `,
+ // src/standalone/campground-black-22.svg
tourism_camp_site: `
- `,
-
- leisure_picnic_table: `
-
-
-
+
+
+
+
`,
- amenity_fuel: `
- `,
+ // src/standalone/picnic-area-black-22.svg
+ leisure_picnic_table: `
+ `,
+ // src/standalone/gas-station-black-22.svg
+ amenity_fuel: `
+ `,
+
+ // src/standalone/lodging-black-22.svg
tourism_hotel: `
-
-
-
-
-
+
+
+
+
`,
forest: `
@@ -97,11 +127,17 @@ export const POI_ICONS = {
`,
+ // src/standalone/bicycle-trail-black-22.svg
sport_cycling: `
-
-
-
-
+
+
+
+
+
`,
};