From 6f7d332993e74c05e314845e157062ceb23c289e Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Mon, 7 Sep 2026 16:55:23 +0200 Subject: [PATCH] Stop faking SDF for POI icons, fix the resulting blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge and category glyphs were registered as SDF images (a single alpha shape MapLibre tints at render time via icon-color) purely so one shared sprite could be recolored per category. But SDF rendering assumes the source actually encodes a distance field — a smooth multi-pixel gradient at every edge — and these were just normally antialiased rasters (a sharp ~1px transition). MapLibre's SDF antialiasing pass softened every edge as a result, visibly blurrier than the base map's own icons, worst on the small category glyph. The glyph color never varies (always white), so it never needed runtime tinting at all: _registerCategoryGlyphs now bakes #fff directly into the raster and registers one plain image per category. The badge color does vary (arbitrary per-file hex), but a real POI export only has a handful of distinct category colors, so _registerBadgeColors rasterizes one sharp image per distinct color actually present in the points being shown, cached by color, instead of one shared image tinted at render time. --- docs/architecture.adoc | 60 ++++++++++++++++++++++------------ public/js/gpx.js | 74 +++++++++++++++++++++++++++++++----------- 2 files changed, 94 insertions(+), 40 deletions(-) diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 548633c..0dd2ca7 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -225,32 +225,50 @@ like a base map style's own small circular POI icons, blending in instead of reading as "your data". A rounded square rather than a pin/teardrop: it matches how the National Park Service itself frames these exact category symbols (below) on its own maps, so the two look -native together instead of like mismatched icon styles. 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 — unlike a pin, which has to anchor at its tip — centered -directly on the coordinate with plain default anchoring, which also -means the category-glyph layer on top needs no offset math: both -layers just share the same center anchor at `[0, 0]`. 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 badge 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 badge rather than a -broken/missing image warning). Six of the seven glyphs are the +native together instead of like mismatched icon styles. `_addLayers` +defaults a point's `color` to the plain track color right on the +feature (`feature.properties.color ||= TRACK_COLOR`) before it ever +reaches a layer, rather than a `coalesce` in a style expression — see +below for why. Unlike a pin, which has to anchor at its tip, the badge +is centered directly on the coordinate with plain default anchoring, +which also means the category-glyph layer on top needs no offset math: +both layers just share the same center anchor at `[0, 0]`. 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 badge 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 badge 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 badge 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 +facilities, not land cover) and stays hand-drawn. ++ +Both the badge and the glyphs are rasterized from their source SVGs +(an `` → `` → `ImageData` round trip, since `map.addImage` +needs a real raster image) as plain, non-SDF images with the actual +target color already baked into the raster — not SDF images (a single +alpha shape MapLibre tints at render time via `icon-color`) as this +first shipped. SDF assumes the source encodes a genuine distance field +(a smooth multi-pixel gradient at every edge); handed a normally +antialiased raster (a sharp ~1px transition) instead, MapLibre's SDF +antialiasing pass made every glyph and badge visibly softer than the +base map's own icons, worst at the glyph's small icon-size. The glyphs +are always white, so `_registerCategoryGlyphs` just bakes `#fff` into +each one and registers a single fixed image per category, once per +style load — no per-render tinting needed at all. The badge's color is +per-file data though (an arbitrary hex value from the GPX), so it can't +be one fixed image; `_registerBadgeColors` instead rasterizes one sharp +image per *distinct* color actually present in the points being shown +(`badge-` as the image id, `["concat", "badge-", ["get", +"color"]]` as the layer's `icon-image`) — cheap in practice, since a +real POI export only ever has a handful of distinct category colors, +and results are cached by color so a second file reusing a color +already seen registers nothing new. ++ +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 7a38fb8..f17b977 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -20,8 +20,8 @@ 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, sized to sit inside the marker badge below -const MAP_MARKER_ID = "map-marker"; const MAP_MARKER_SIZE = 0.55; +const BADGE_IMAGE_PREFIX = "badge-"; // one registered image per distinct badge color actually seen, see _registerBadgeColors // 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 @@ -41,27 +41,54 @@ export class GpxPanelControl { this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend // setStyle() (switching the Online/Offline base map) tears down all - // 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(); + // custom sources/layers/images — re-register the category glyphs before + // putting visible GPX layers back, so a symbol layer added right away + // doesn't reference a not-yet-existing image. Badge images are handled + // separately (_registerBadgeColors, called from _addLayers) since which + // colors are needed depends on which file/category is being shown. + this._iconsReady = this._registerCategoryGlyphs(); map.on("style.load", () => { - this._iconsReady = this._registerPoiIcons(); + this._iconsReady = this._registerCategoryGlyphs(); 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() { - const sources = { ...POI_ICONS, [MAP_MARKER_ID]: MAP_MARKER_ICON }; + // Registered once, always white, as plain (non-SDF) images: these used to + // be SDF images tinted white via icon-color, but MapLibre's SDF rendering + // assumes the source encodes an actual distance field (a smooth multi-pixel + // gradient at each edge) — feeding it a normally-antialiased raster (a + // sharp 1px edge transition) instead made every glyph noticeably softer + // than the base map's own icons, especially shrunk down to icon-size 0.3. + // Since the glyph color never varies (always white, unlike the badge + // below), there's no need for SDF's runtime-tintable alpha mask at all — + // baking the color into the raster and skipping SDF renders as sharp as + // any ordinary sprite. + async _registerCategoryGlyphs() { await Promise.all( - Object.keys(sources).map(async (key) => { + POI_ICON_KEYS.map(async (key) => { if (this._map.hasImage(key)) return; - const imageData = await svgToImageData(sources[key], POI_ICON_IMAGE_SIZE); - if (!this._map.hasImage(key)) this._map.addImage(key, imageData, { sdf: true }); + const whiteSvg = POI_ICONS[key].replace(/currentColor/g, "#fff"); + const imageData = await svgToImageData(whiteSvg, POI_ICON_IMAGE_SIZE); + if (!this._map.hasImage(key)) this._map.addImage(key, imageData); + }), + ); + } + + // The badge's color is per-category data (arbitrary hex from a GPX file), + // so unlike the glyphs it can't be a single fixed-color image — but a real + // POI export only ever has a handful of distinct category colors, so it's + // cheap to rasterize one sharp, non-SDF image per color actually seen + // (rather than one shared image SDF-tinted at render time, which is what + // made these blurry — see _registerCategoryGlyphs above) and cache them by + // color for reuse across files. + async _registerBadgeColors(colors) { + await Promise.all( + [...colors].map(async (color) => { + const id = badgeImageId(color); + if (this._map.hasImage(id)) return; + const coloredSvg = MAP_MARKER_ICON.replace(/currentColor/g, color); + const imageData = await svgToImageData(coloredSvg, POI_ICON_IMAGE_SIZE); + if (!this._map.hasImage(id)) this._map.addImage(id, imageData); }), ); } @@ -358,6 +385,12 @@ export class GpxPanelControl { for (const feature of geojson.features) { (feature.geometry.type === "Point" ? points : lines).features.push(feature); } + // Normalized here (rather than a ["coalesce", ...] in the layer's + // icon-image expression) so every point's badge color is known upfront, + // before registering the per-color badge images below. + for (const feature of points.features) { + feature.properties.color ||= TRACK_COLOR; + } this._map.addSource(linesId, { type: "geojson", data: lines }); this._map.addLayer({ @@ -372,7 +405,8 @@ export class GpxPanelControl { // 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; + const badgeColors = new Set(points.features.map((f) => f.properties.color)); + await Promise.all([this._iconsReady, this._registerBadgeColors(badgeColors)]); if (!this._map.getSource(pointsId)) return true; // a style.load (basemap switch) tore it down while icons were loading this._addClusterLayers(pointsId); @@ -424,12 +458,11 @@ export class GpxPanelControl { source: pointsId, filter: ["!", ["has", "point_count"]], layout: { - "icon-image": MAP_MARKER_ID, + "icon-image": ["concat", BADGE_IMAGE_PREFIX, ["get", "color"]], "icon-size": MAP_MARKER_SIZE, "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 @@ -445,7 +478,6 @@ export class GpxPanelControl { "icon-allow-overlap": true, "icon-ignore-placement": true, }, - paint: { "icon-color": "#fff" }, }); this._registerClusterInteractivity(pointsId); @@ -703,6 +735,10 @@ async function svgToImageData(svgMarkup, size) { } } +function badgeImageId(color) { + return `${BADGE_IMAGE_PREFIX}${color}`; +} + function linesSourceId(path) { return `gpx-${path}-lines`; }