From accda1f2370b5812b6c2aaeae89a7c11da8b6b7b Mon Sep 17 00:00:00 2001 From: David Weber Date: Sat, 4 Jul 2026 08:13:41 +0200 Subject: [PATCH 1/2] Add OSM elevation to point POIs Natural peak features are usually OSM point features, but the POI layer only copied the OSM ele tag while building the named-polygon point-on-surface output. That meant point peaks were emitted without the elevation attribute even when the source feature had ele set. Move the elevation assignment into the common POI attribute chain so all OSM POIs can carry ele through to the output feature. This preserves the existing named-polygon behavior and lets Planetiler omit the attribute naturally when ele is absent. Add a regression test covering natural=peak with ele to make sure peak POIs expose elevation. Verification: ran PoisTest, full mvn package, spotless:check, git diff --check, and before/after Saarland PMTiles scans. The Saarland z15 scan changed from 0/489 peak features with elevation before the fix to 425/489 after the fix; the remaining peaks lack OSM ele in the source. AI assistance: this change was prepared with help from OpenAI Codex. --- .../main/java/com/protomaps/basemap/layers/Pois.java | 7 +++---- .../java/com/protomaps/basemap/layers/PoisTest.java | 11 +++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java index bc9d13af..1c6bc4e0 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -501,9 +501,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // Assign outputFeature if (hasNamedPolygon) { - outputFeature = features.pointOnSurface(this.name()) - //.setAttr("area_debug", wayArea) // DEBUG - .setAttr("elevation", sf.getString("ele")); + outputFeature = features.pointOnSurface(this.name()); } else if (sf.isPoint()) { outputFeature = features.point(this.name()); } else { @@ -525,7 +523,8 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { .setZoomRange(Math.min(minZoom, 15), 15) // Core OSM tags for different kinds of places // Special airport only tag (to indicate if it's an airport with regular commercial flights) - .setAttr("iata", sf.getString("iata")); + .setAttr("iata", sf.getString("iata")) + .setAttr("elevation", sf.getString("ele")); // Core Tilezen schema properties if (!kindDetail.equals("pm:undefined")) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java index 7ea79337..79d14214 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -197,6 +197,17 @@ void zoom_14_peak() { ))); } + @Test + void peakElevation() { + assertFeatures(14, + List.of(Map.of("kind", "peak", "min_zoom", 14, "elevation", "569")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of("natural", "peak", "ele", "569")), + "osm", null, 0 + ))); + } + @Test void zoom_14_golfCourse() { assertFeatures(14, From e46f46978de9b8a85c6b9549a1572907e0cc276e Mon Sep 17 00:00:00 2001 From: David Weber Date: Wed, 8 Jul 2026 04:13:38 +0200 Subject: [PATCH 2/2] Parse POI elevation as numeric OSM ele represents elevation in metres, so expose POI elevation as a numeric tile value instead of passing through raw tag text. This applies both to polygon-derived POIs that already carried elevation and to point POIs added by the previous change. Add a conservative parser that accepts clean signed decimal values, including an optional trailing m suffix with or without whitespace, and emits rounded integer metres. Omit elevation when ele contains other units or free text. Update POI tests to assert integer elevation for peak and named polygon output and cover decimal, metre-suffixed, and invalid values. AI assistance: This change was prepared with help from OpenAI Codex. --- .../com/protomaps/basemap/layers/Pois.java | 26 +++++++++- .../protomaps/basemap/layers/PoisTest.java | 49 ++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java index 1c6bc4e0..21a420ec 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -25,10 +25,15 @@ import com.protomaps.basemap.names.OsmNames; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; @SuppressWarnings("java:S1192") public class Pois implements ForwardingProfile.LayerPostProcessor { + // Matches numeric metre values with an optional trailing "m" suffix, e.g. "569", "569.5", "569 m", or "569m". + private static final Pattern ELEVATION_PATTERN = + Pattern.compile("^([+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+))(?:\\s*m)?$"); + private Map qrankGrading = Map.of( "station", new int[][]{{10, 50000}, {12, 20000}, {13, 10000}}, "aerodrome", new int[][]{{10, 50000}, {12, 20000}, {13, 5000}, {14, 2500}}, @@ -46,6 +51,25 @@ public Pois(QrankDb qrankDb) { public static final String LAYER_NAME = "pois"; + static Integer parseElevation(String elevation) { + if (elevation == null) { + return null; + } + + var matcher = ELEVATION_PATTERN.matcher(elevation.trim()); + if (!matcher.matches()) { + return null; + } + + var parsed = parseDoubleOrNull(matcher.group(1)); + if (parsed == null) { + return null; + } + + var rounded = Math.round(parsed); + return rounded >= Integer.MIN_VALUE && rounded <= Integer.MAX_VALUE ? (int) rounded : null; + } + private static final Expression WITH_OPERATOR_USFS = with("operator", "United States Forest Service", "US Forest Service", "U.S. Forest Service", "USDA Forest Service", "United States Department of Agriculture", "US National Forest Service", "United State Forest Service", "U.S. National Forest Service"); @@ -524,7 +548,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // Core OSM tags for different kinds of places // Special airport only tag (to indicate if it's an airport with regular commercial flights) .setAttr("iata", sf.getString("iata")) - .setAttr("elevation", sf.getString("ele")); + .setAttr("elevation", parseElevation(sf.getString("ele"))); // Core Tilezen schema properties if (!kindDetail.equals("pm:undefined")) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java index 79d14214..26e41745 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -1,6 +1,7 @@ package com.protomaps.basemap.layers; import static com.onthegomap.planetiler.TestUtils.*; +import static org.junit.jupiter.api.Assertions.assertFalse; import com.onthegomap.planetiler.reader.SimpleFeature; import java.util.HashMap; @@ -200,7 +201,7 @@ void zoom_14_peak() { @Test void peakElevation() { assertFeatures(14, - List.of(Map.of("kind", "peak", "min_zoom", 14, "elevation", "569")), + List.of(Map.of("kind", "peak", "min_zoom", 14, "elevation", 569)), process(SimpleFeature.create( newPoint(1, 1), new HashMap<>(Map.of("natural", "peak", "ele", "569")), @@ -208,6 +209,50 @@ void peakElevation() { ))); } + @Test + void peakElevationDecimal() { + assertFeatures(14, + List.of(Map.of("kind", "peak", "min_zoom", 14, "elevation", 570)), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of("natural", "peak", "ele", "569.5")), + "osm", null, 0 + ))); + } + + @Test + void peakElevationMeterSuffix() { + assertFeatures(14, + List.of(Map.of("kind", "peak", "min_zoom", 14, "elevation", 569)), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of("natural", "peak", "ele", "569 m")), + "osm", null, 0 + ))); + } + + @Test + void peakElevationMeterSuffixWithoutWhitespace() { + assertFeatures(14, + List.of(Map.of("kind", "peak", "min_zoom", 14, "elevation", 569)), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of("natural", "peak", "ele", "569m")), + "osm", null, 0 + ))); + } + + @Test + void peakElevationNonMeterUnitOmitted() { + var features = process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of("natural", "peak", "ele", "569 ft")), + "osm", null, 0 + )); + + assertFalse(toMap(features.iterator().next(), 14).containsKey("elevation")); + } + @Test void zoom_14_golfCourse() { assertFeatures(14, @@ -640,7 +685,7 @@ void zoom_8_forest_largeArea() { @Test void zoom_12_tallBuilding_100mHeight() { assertFeatures(12, - List.of(Map.of("kind", "office", "min_zoom", 12, "name", "Skyscraper", "elevation", "100")), + List.of(Map.of("kind", "office", "min_zoom", 12, "name", "Skyscraper", "elevation", 100)), process(SimpleFeature.create( AREA_12K_SQ_M, new HashMap<>(Map.of(