Turn POI markers into pins and use a real icon set
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 16s

Colored circles for waypoints looked too similar to a base map style's own
small circular POI icons, so uploaded POI data blended into the map instead
of standing out. Switched the marker shape to a pin/teardrop (reusing the
existing WAYPOINT_ICON path, filled instead of stroked) — no base map style
uses that shape for its own points, so it reads immediately as "your data".
Anchored at its tip, the standard map-pin convention, rather than centered
on the coordinate.

Also swapped the hand-drawn category glyphs for the National Park Service's
own map symbols (github.com/nationalparkservice/symbol-library, BSD
licensed, forked from Mapbox's Maki) for the six categories it covers —
professional, purpose-built pictograms beat ad-hoc "fuel pump"/"picnic
table" line art. `forest` has no NPS equivalent and stays hand-drawn.

The glyph now sits in the pin's head rather than centered on a flat dot,
which needed real geometry (both layers anchor to the same point, so the
offset is the gap between where the pin's head-center ends up and where the
glyph's own visual center already sits) rather than the old anchor="center"
zero-offset placement.
This commit is contained in:
randy committed 2026-09-07 16:37:31 +02:00
1 parent 0b18dc018a
commit 2a83bb10ca
3 files changed
+143 -50

No files matched your search

+34 -15
View File
@@ -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 →
`<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
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 → `<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. 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
+49 -11
View File
@@ -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,
},
+60 -24
View File
@@ -58,37 +58,67 @@ export const ROUTE_ICON = `
<path d="M6.5 7.5C10 11 8 13 12 13s2 5 6.5 5" stroke-dasharray="2 2.2"/>
</svg>`;
// 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 = `
<svg viewBox="0 0 24 24"><path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z" fill="currentColor"/></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.
// 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: `
<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>`,
<svg viewBox="0 0 22 22"><path fill="currentColor" d="M11.9,11c1.8,0,3.5-1.8,3.5-3.6S13.7,4,11.9,4h-5v7H11.9z M12.1,0C16.2,0,20,3.3,20,7.5c0,4.1-3.9,7.5-8.1,7.5h-5v7H3V0
H12.1z"/></svg>`,
// src/standalone/campground-black-22.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 viewBox="0 0 22 22" fill="currentColor">
<path d="M17,21L9.4,5.8L2,21H0v1h19v-1H17z M5,21l4.5-8.5L14,21H5z"/>
<polygon points="20,18 20,18 12.3,2.9 11.5,4.7 19,19 20.5,19 "/>
<polygon points="15.5,0 14.7,1.7 22,16 22,13 "/>
</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>`,
// src/standalone/picnic-area-black-22.svg
leisure_picnic_table: `
<svg viewBox="0 0 22 22"><path fill="currentColor" d="M22,11h-6l-2-5h4V4H4v2h4l-2,5H0v2h5l-2,5h2l2-5h8l2,5h2l-2-5h5V11z M8,11l2-5h2l2,5H8z"/></svg>`,
// src/standalone/gas-station-black-22.svg
amenity_fuel: `
<svg viewBox="0 0 22 22"><path fill="currentColor" d="M19,14.4L19,14.4v-1.5V6.2c0-0.7-0.4-1.3-0.9-1.8L15,1.5c-0.2,0-0.8,0.4-0.8,0.4c0,0.2,1.5,1.8,1.5,1.8v2.6
c0,0.9,0.8,1.8,1.7,1.8L18,7.9v5v1.7l1,5.1c0,0.8-0.9,1.3-1.5,1.3C17,21,16,20.5,16,20v-8.9C16,10,15.4,9,14.4,9H12V1h-1V0H3v1H2
v21h10V10.4h2.4c0.4,0,0.6,0.3,0.6,0.7V20c0,1.2,1.2,2,2.5,2c1.2,0,2.5-0.8,2.5-2C20,19.7,19,14.4,19,14.4z M18,7h-1V5h1V7z
M10.5,5.8L9.6,6.6H4.4L3.5,5.8V2.2l0.9-0.9h5.2l0.9,0.9V5.8z"/></svg>`,
// src/standalone/lodging-black-22.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 viewBox="0 0 22 22" fill="currentColor">
<polygon points="0,4 2,4 2,12 22,12 22,17 20,17 20,14 2,14 2,17 0,17 "/>
<polygon points="3,11 22,11 22,8 21,7 13,6 7,6 7,9 3,9 "/>
<circle cx="4.5" cy="6.5" r="1.5"/>
</svg>`,
forest: `
@@ -97,11 +127,17 @@ export const POI_ICONS = {
<rect x="10.5" y="19" width="3" height="3" fill="currentColor"/>
</svg>`,
// src/standalone/bicycle-trail-black-22.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 viewBox="0 0 22 22" fill="currentColor">
<circle cx="13.9" cy="3" r="2"/>
<path d="M4.5,20C6.4,20,8,18.4,8,16.5S6.4,13,4.5,13S1,14.6,1,16.5S2.6,20,4.5,20z M0,16.5C0,14,2,12,4.5,12S9,14,9,16.5
S7,21,4.5,21S0,19,0,16.5z"/>
<path d="M17.5,20c1.9,0,3.5-1.6,3.5-3.5S19.4,13,17.5,13S14,14.6,14,16.5S15.6,20,17.5,20z M13,16.5c0-2.5,2-4.5,4.5-4.5
s4.5,2,4.5,4.5S20,21,17.5,21S13,19,13,16.5z"/>
<path d="M13.1,9.8c-0.2,0-0.5-0.1-0.7-0.4l-1.1-1.7L9.1,9.9l2.1,1.1c0.9,0.4,0.9,1,0.9,1v5c0,0.5-0.6,0.9-1.2,0.9S10,17.6,10,17v-4
l-3.8-1.9c-0.4-0.3-0.7-0.7-0.7-1.2C5.6,9.5,5.7,9.2,6,9l3.5-3.5C9.9,4.9,10.6,5,10.6,5c0.7,0,1.1,0.6,1.1,0.6l1.8,2.9h2.8
c1,0,1,1.3,0,1.3H13.1L13.1,9.8z"/>
</svg>`,
};