From 2a83bb10ca666d3f7d68b8c3de7affa2ec9adf79 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Mon, 7 Sep 2026 16:37:31 +0200 Subject: [PATCH] Turn POI markers into pins and use a real icon set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/architecture.adoc | 49 ++++++++++++++++-------- public/js/gpx.js | 60 ++++++++++++++++++++++++------ public/js/icons.js | 84 ++++++++++++++++++++++++++++++------------ 3 files changed, 143 insertions(+), 50 deletions(-) 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: ` - - - - + + + + + `, };