Stop faking SDF for POI icons, fix the resulting blur
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 14s

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.
This commit is contained in:
randy committed 2026-09-07 16:55:23 +02:00
1 parent 7de8428b05
commit 6f7d332993
2 files changed
+94 -40

No files matched your search

+39 -21
View File
@@ -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 instead of reading as "your data". A rounded square rather than a
pin/teardrop: it matches how the National Park Service itself frames pin/teardrop: it matches how the National Park Service itself frames
these exact category symbols (below) on its own maps, so the two look these exact category symbols (below) on its own maps, so the two look
native together instead of like mismatched icon styles. It's colored native together instead of like mismatched icon styles. `_addLayers`
via `["coalesce", ["get", "color"], TRACK_COLOR]` (falling back to the defaults a point's `color` to the plain track color right on the
plain track color for a waypoint with no category, e.g. a hand-placed feature (`feature.properties.color ||= TRACK_COLOR`) before it ever
GNSS pin) and — unlike a pin, which has to anchor at its tip — centered reaches a layer, rather than a `coalesce` in a style expression — see
directly on the coordinate with plain default anchoring, which also below for why. Unlike a pin, which has to anchor at its tip, the badge
means the category-glyph layer on top needs no offset math: both is centered directly on the coordinate with plain default anchoring,
layers just share the same center anchor at `[0, 0]`. A small curated which also means the category-glyph layer on top needs no offset math:
icon set (`public/js/icons.js`'s `POI_ICONS`, covering the categories both layers just share the same center anchor at `[0, 0]`. A small
p4n2gpx actually uses, not an exhaustive OsmAnd icon set) draws a white curated icon set (`public/js/icons.js`'s `POI_ICONS`, covering the
glyph on top of the badge for any waypoint whose `icon` matches a known categories p4n2gpx actually uses, not an exhaustive OsmAnd icon set)
key (filtered out entirely for anything else, so an unrecognized icon draws a white glyph on top of the badge for any waypoint whose `icon`
key just falls back to a plain colored badge rather than a matches a known key (filtered out entirely for anything else, so an
broken/missing image warning). Six of the seven glyphs are the 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 National Park Service's own map symbols
(`github.com/nationalparkservice/symbol-library`, BSD-licensed, (`github.com/nationalparkservice/symbol-library`, BSD-licensed,
originally forked from Mapbox's Maki project) — a professional, originally forked from Mapbox's Maki project) — a professional,
purpose-built icon set beats hand-drawing "fuel pump"/"picnic table" purpose-built icon set beats hand-drawing "fuel pump"/"picnic table"
glyphs from scratch; `forest` has no NPS equivalent (that set covers glyphs from scratch; `forest` has no NPS equivalent (that set covers
facilities, not land cover) and stays hand-drawn. Both the badge and facilities, not land cover) and stays hand-drawn.
the category glyphs are rasterized once per style load +
(`_registerPoiIcons`, an SVG → `<img>` → `<canvas>` → `ImageData` round Both the badge and the glyphs are rasterized from their source SVGs
trip, since these need to be real raster images for `map.addImage`) and (an `<img>` → `<canvas>` → `ImageData` round trip, since `map.addImage`
registered as SDF images — a single alpha shape MapLibre tints at needs a real raster image) as plain, non-SDF images with the actual
render time via `icon-color` rather than a fixed-color image. The target color already baked into the raster — not SDF images (a single
points source itself alpha shape MapLibre tints at render time via `icon-color`) as this
is created with 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-<color>` 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, `cluster: true`; nearby points collapse into a stepped-radius,
sequential-blue circle sized by `point_count` below `clusterMaxZoom`, sequential-blue circle sized by `point_count` below `clusterMaxZoom`,
clicking one zooms to its `getClusterExpansionZoom()`, and this is clicking one zooms to its `getClusterExpansionZoom()`, and this is
+55 -19
View File
@@ -20,8 +20,8 @@ const TRACK_COLOR = "#e63946";
const POI_ICON_KEYS = Object.keys(POI_ICONS); 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_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 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 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 // 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
@@ -41,27 +41,54 @@ export class GpxPanelControl {
this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend 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/layers/images — re-register the POI icon sprites // custom sources/layers/images — re-register the category glyphs before
// before putting visible GPX layers back, so a symbol layer added right // putting visible GPX layers back, so a symbol layer added right away
// away doesn't reference a not-yet-existing image. // doesn't reference a not-yet-existing image. Badge images are handled
this._iconsReady = this._registerPoiIcons(); // 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", () => { map.on("style.load", () => {
this._iconsReady = this._registerPoiIcons(); this._iconsReady = this._registerCategoryGlyphs();
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) // Registered once, always white, as plain (non-SDF) images: these used to
// rather than plain color images: the source SVGs are silhouettes with no // be SDF images tinted white via icon-color, but MapLibre's SDF rendering
// color information worth preserving, and SDF lets the same sprite be // assumes the source encodes an actual distance field (a smooth multi-pixel
// reused if a future category needs a different tint. // gradient at each edge) — feeding it a normally-antialiased raster (a
async _registerPoiIcons() { // sharp 1px edge transition) instead made every glyph noticeably softer
const sources = { ...POI_ICONS, [MAP_MARKER_ID]: MAP_MARKER_ICON }; // 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( await Promise.all(
Object.keys(sources).map(async (key) => { POI_ICON_KEYS.map(async (key) => {
if (this._map.hasImage(key)) return; if (this._map.hasImage(key)) return;
const imageData = await svgToImageData(sources[key], POI_ICON_IMAGE_SIZE); const whiteSvg = POI_ICONS[key].replace(/currentColor/g, "#fff");
if (!this._map.hasImage(key)) this._map.addImage(key, imageData, { sdf: true }); 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) { for (const feature of geojson.features) {
(feature.geometry.type === "Point" ? points : lines).features.push(feature); (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.addSource(linesId, { type: "geojson", data: lines });
this._map.addLayer({ this._map.addLayer({
@@ -372,7 +405,8 @@ export class GpxPanelControl {
// clusterRadius/clusterMaxZoom are harmless when there's nothing dense // clusterRadius/clusterMaxZoom are harmless when there's nothing dense
// enough to cluster (a handful of waypoints just render individually). // enough to cluster (a handful of waypoints just render individually).
this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 }); 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 if (!this._map.getSource(pointsId)) return true; // a style.load (basemap switch) tore it down while icons were loading
this._addClusterLayers(pointsId); this._addClusterLayers(pointsId);
@@ -424,12 +458,11 @@ export class GpxPanelControl {
source: pointsId, source: pointsId,
filter: ["!", ["has", "point_count"]], filter: ["!", ["has", "point_count"]],
layout: { layout: {
"icon-image": MAP_MARKER_ID, "icon-image": ["concat", BADGE_IMAGE_PREFIX, ["get", "color"]],
"icon-size": MAP_MARKER_SIZE, "icon-size": MAP_MARKER_SIZE,
"icon-allow-overlap": true, "icon-allow-overlap": true,
"icon-ignore-placement": 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 // Only for the curated POI_ICON_KEYS (see icons.js) — anything else
// (a plain hand-placed waypoint, or a category p4n2gpx/OsmAnd hasn't // (a plain hand-placed waypoint, or a category p4n2gpx/OsmAnd hasn't
@@ -445,7 +478,6 @@ export class GpxPanelControl {
"icon-allow-overlap": true, "icon-allow-overlap": true,
"icon-ignore-placement": true, "icon-ignore-placement": true,
}, },
paint: { "icon-color": "#fff" },
}); });
this._registerClusterInteractivity(pointsId); this._registerClusterInteractivity(pointsId);
@@ -703,6 +735,10 @@ async function svgToImageData(svgMarkup, size) {
} }
} }
function badgeImageId(color) {
return `${BADGE_IMAGE_PREFIX}${color}`;
}
function linesSourceId(path) { function linesSourceId(path) {
return `gpx-${path}-lines`; return `gpx-${path}-lines`;
} }