From 7e70005cc3258d834bcbb9cf325662507d4659db Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 11:48:52 -0800 Subject: [PATCH 01/76] Added base case POI zoom in new MultiExpression --- .../com/protomaps/basemap/layers/Pois.java | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 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 40f981ae..f6f7c565 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -2,6 +2,7 @@ import static com.onthegomap.planetiler.util.Parse.parseDoubleOrNull; import static com.protomaps.basemap.feature.Matcher.fromTag; +import static com.protomaps.basemap.feature.Matcher.getInteger; import static com.protomaps.basemap.feature.Matcher.getString; import static com.protomaps.basemap.feature.Matcher.rule; import static com.protomaps.basemap.feature.Matcher.use; @@ -47,7 +48,7 @@ public Pois(QrankDb qrankDb) { "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"); - private static final MultiExpression.Index> index = MultiExpression.of(List.of( + private static final MultiExpression.Index> kindsIndex = MultiExpression.of(List.of( // Everything is "other"/"" at first rule(use("kind", "other"), use("kindDetail", "")), @@ -144,6 +145,13 @@ public Pois(QrankDb qrankDb) { )).index(); + private static final MultiExpression.Index> zoomsIndex = MultiExpression.of(List.of( + + // Everything is zoom=15 at first + rule(use("minZoom", 15)) + + )).index(); + @Override public String name() { return LAYER_NAME; @@ -154,13 +162,20 @@ public String name() { Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); public void processOsm(SourceFeature sf, FeatureCollector features) { - var matches = index.getMatches(sf); - if (matches.isEmpty()) { + var kindMatches = kindsIndex.getMatches(sf); + if (kindMatches.isEmpty()) { + return; + } + + String kind = getString(sf, kindMatches, "kind", "undefined"); + String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); + + var zoomMatches = zoomsIndex.getMatches(sf); + if (zoomMatches.isEmpty()) { return; } - String kind = getString(sf, matches, "kind", "undefined"); - String kindDetail = getString(sf, matches, "kindDetail", "undefined"); + Integer minZoom = getInteger(sf, zoomMatches, "minZoom", 99); if ((sf.isPoint() || sf.canBePolygon()) && (sf.hasTag("aeroway", "aerodrome") || sf.hasTag("amenity") || @@ -177,7 +192,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { sf.hasTag("shop") || sf.hasTag("tourism") && (!sf.hasTag("historic", "district")))) { - Integer minZoom = 15; long qrank = 0; String wikidata = sf.getString("wikidata"); From 3a62861dd7819cfa8f03e8d5f593b27dc6cf9889 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 12:40:56 -0800 Subject: [PATCH 02/76] Moved initial zoom assignments to MultiExpression, seeing one unexplained failure with aerodrome/iata tags --- .../com/protomaps/basemap/layers/Pois.java | 112 ++++++++---------- 1 file changed, 51 insertions(+), 61 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 f6f7c565..dd8b05a6 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -148,7 +148,30 @@ public Pois(QrankDb qrankDb) { private static final MultiExpression.Index> zoomsIndex = MultiExpression.of(List.of( // Everything is zoom=15 at first - rule(use("minZoom", 15)) + rule(use("minZoom", 15)), + + rule(with("protomaps-basemaps:kind", "national_park"), use("minZoom", 11)), + rule(with("natural", "peak"), use("minZoom", 13)), + rule(with("highway", "bus_stop"), use("minZoom", 17)), + rule(with("tourism", "attraction", "camp_site", "hotel"), use("minZoom", 15)), + rule(with("shop", "grocery", "supermarket"), use("minZoom", 14)), + rule(with("leisure", "golf_course", "marina", "stadium"), use("minZoom", 13)), + rule(with("leisure", "park"), use("minZoom", 14)), // Lots of pocket parks and NODE parks, show those later than rest of leisure + rule(with("landuse", "cemetery"), use("minZoom", 14)), + rule(with("amenity", "cafe"), use("minZoom", 15)), + rule(with("amenity", "school"), use("minZoom", 15)), + rule(with("amenity", "library", "post_office", "townhall"), use("minZoom", 13)), + rule(with("amenity", "hospital"), use("minZoom", 12)), + rule(with("amenity", "university", "college"), use("minZoom", 14)), // One would think University should be earlier, but there are lots of dinky node only places, so if the university has a large area, it'll naturally improve its zoom in another section... + rule(with("aeroway", "aerodrome"), use("minZoom", 13)), + + // Emphasize large international airports earlier + rule( + with("aeroway", "aerodrome"), + with("protomaps-basemaps:kind", "aerodrome"), + with("iata"), + use("minZoom", 11) + ) )).index(); @@ -157,24 +180,47 @@ public String name() { return LAYER_NAME; } - // ~= pow((sqrt(70k) / (40m / 256)) / 256, 2) ~= 4.4e-11 + // ~= pow((sqrt(7e4) / (4e7 / 256)) / 256, 2) ~= 4.4e-11 private static final double WORLD_AREA_FOR_70K_SQUARE_METERS = Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); + public void calculateDimensions(SourceFeature sf) { + Double wayArea = 0.0; + Double height = 0.0; + + if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { + try { + wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; + } catch (GeometryException e) { + e.log("Exception in POI way calculation"); + } + if (sf.hasTag("height")) { + Double parsed = parseDoubleOrNull(sf.getString("height")); + if (parsed != null) { + height = parsed; + } + } + } + + sf.setTag("protomaps-basemaps:wayArea", wayArea); + sf.setTag("protomaps-basemaps:height", height); + } + public void processOsm(SourceFeature sf, FeatureCollector features) { var kindMatches = kindsIndex.getMatches(sf); if (kindMatches.isEmpty()) { return; } - String kind = getString(sf, kindMatches, "kind", "undefined"); - String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); - + calculateDimensions(sf); + sf.setTag("protomaps-basemaps:kind", getString(sf, kindMatches, "kind", "undefined")); var zoomMatches = zoomsIndex.getMatches(sf); if (zoomMatches.isEmpty()) { return; } + String kind = getString(sf, kindMatches, "kind", "undefined"); + String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); Integer minZoom = getInteger(sf, zoomMatches, "minZoom", 99); if ((sf.isPoint() || sf.canBePolygon()) && (sf.hasTag("aeroway", "aerodrome") || @@ -199,62 +245,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { qrank = qrankDb.get(wikidata); } - if (sf.hasTag("aeroway", "aerodrome")) { - minZoom = 13; - - // Emphasize large international airports earlier - if (kind.equals("aerodrome") && sf.hasTag("iata")) { - minZoom -= 2; - } - } else if (sf.hasTag("amenity", "university", "college")) { - // One would think University should be earlier, but there are lots of dinky node only places - // So if the university has a large area, it'll naturally improve it's zoom in the next section... - minZoom = 14; - } else if (sf.hasTag("amenity", "hospital")) { - minZoom = 12; - } else if (sf.hasTag("amenity", "library", "post_office", "townhall")) { - minZoom = 13; - } else if (sf.hasTag("amenity", "school")) { - minZoom = 15; - } else if (sf.hasTag("amenity", "cafe")) { - minZoom = 15; - } else if (sf.hasTag("landuse", "cemetery")) { - minZoom = 14; - } else if (sf.hasTag("leisure", "park")) { - // Lots of pocket parks and NODE parks, show those later than rest of leisure - minZoom = 14; - } else if (sf.hasTag("leisure", "golf_course", "marina", "stadium")) { - minZoom = 13; - } else if (sf.hasTag("shop", "grocery", "supermarket")) { - minZoom = 14; - } else if (sf.hasTag("tourism", "attraction", "camp_site", "hotel")) { - minZoom = 15; - } else if (sf.hasTag("highway", "bus_stop")) { - minZoom = 17; - } else if (sf.hasTag("natural", "peak")) { - minZoom = 13; - } - - // National parks - if (sf.hasTag("boundary", "national_park")) { - if (!(sf.hasTag("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") || - sf.hasTag("protection_title", "Conservation Area", "Conservation Park", "Environmental use", "Forest Reserve", - "National Forest", "National Wildlife Refuge", "Nature Refuge", "Nature Reserve", "Protected Site", - "Provincial Park", "Public Access Land", "Regional Reserve", "Resources Reserve", "State Forest", - "State Game Land", "State Park", "Watershed Recreation Unit", "Wild Forest", "Wilderness Area", - "Wilderness Study Area", "Wildlife Management", "Wildlife Management Area", "Wildlife Sanctuary")) && - (sf.hasTag("protect_class", "2", "3") || - sf.hasTag("operator", "United States National Park Service", "National Park Service", - "US National Park Service", "U.S. National Park Service", "US National Park service") || - sf.hasTag("operator:en", "Parks Canada") || - sf.hasTag("designation", "national_park") || - sf.hasTag("protection_title", "National Park"))) { - minZoom = 11; - } - } - // try first for polygon -> point representations if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { Double wayArea = 0.0; From 03316e2a08f3a3e792a804f3c023e9778cb697c1 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 12:55:05 -0800 Subject: [PATCH 03/76] Moved a bunch of high-zoom point logic to MultiExpression --- .../com/protomaps/basemap/layers/Pois.java | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 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 dd8b05a6..955f65c2 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -7,6 +7,7 @@ import static com.protomaps.basemap.feature.Matcher.rule; import static com.protomaps.basemap.feature.Matcher.use; import static com.protomaps.basemap.feature.Matcher.with; +import static com.protomaps.basemap.feature.Matcher.withPoint; import static com.protomaps.basemap.feature.Matcher.without; import com.onthegomap.planetiler.FeatureCollector; @@ -171,6 +172,46 @@ public Pois(QrankDb qrankDb) { with("protomaps-basemaps:kind", "aerodrome"), with("iata"), use("minZoom", 11) + ), + + rule( + withPoint(), + Expression.or( + with("amenity", "clinic", "dentist", "doctors", "social_facility", "baby_hatch", "childcare", + "car_sharing", "bureau_de_change", "emergency_phone", "karaoke", "karaoke_box", "money_transfer", "car_wash", + "hunting_stand", "studio", "boat_storage", "gambling", "adult_gaming_centre", "sanitary_dump_station", + "attraction", "animal", "water_slide", "roller_coaster", "summer_toboggan", "carousel", "amusement_ride", + "maze"), + with("historic", "memorial", "district"), + with("leisure", "pitch", "playground", "slipway"), + with("shop", "scuba_diving", "atv", "motorcycle", "snowmobile", "art", "bakery", "beauty", "bookmaker", + "books", "butcher", "car", "car_parts", "car_repair", "clothes", "computer", "convenience", "fashion", + "florist", "garden_centre", "gift", "golf", "greengrocer", "grocery", "hairdresser", "hifi", "jewelry", + "lottery", "mobile_phone", "newsagent", "optician", "perfumery", "ship_chandler", "stationery", "tobacco", + "travel_agency"), + with("tourism", "artwork", "hanami", "trail_riding_station", "bed_and_breakfast", "chalet", + "guest_house", "hostel") + ), + use("minZoom", 16) + ), + + // Some features should only be visible at very late zooms when they don't have a name + rule( + withPoint(), + without("name"), + Expression.or( + with("amenity", "atm", "bbq", "bench", "bicycle_parking", + "bicycle_rental", "bicycle_repair_station", "boat_storage", "bureau_de_change", "car_rental", "car_sharing", + "car_wash", "charging_station", "customs", "drinking_water", "fuel", "harbourmaster", "hunting_stand", + "karaoke_box", "life_ring", "money_transfer", "motorcycle_parking", "parking", "picnic_table", "post_box", + "ranger_station", "recycling", "sanitary_dump_station", "shelter", "shower", "taxi", "telephone", "toilets", + "waste_basket", "waste_disposal", "water_point", "watering_place", "bicycle_rental", "motorcycle_parking", + "charging_station"), + with("historic", "landmark", "wayside_cross"), + with("leisure", "dog_park", "firepit", "fishing", "pitch", "playground", "slipway", "swimming_area"), + with("tourism", "alpine_hut", "information", "picnic_site", "viewpoint", "wilderness_hut") + ), + use("minZoom", 16) ) )).index(); @@ -187,8 +228,10 @@ public String name() { public void calculateDimensions(SourceFeature sf) { Double wayArea = 0.0; Double height = 0.0; + String namedPolygon = "no"; if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { + namedPolygon = "yes"; try { wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; } catch (GeometryException e) { @@ -204,6 +247,7 @@ public void calculateDimensions(SourceFeature sf) { sf.setTag("protomaps-basemaps:wayArea", wayArea); sf.setTag("protomaps-basemaps:height", height); + sf.setTag("protomaps-basemaps:namedPolygon", namedPolygon); } public void processOsm(SourceFeature sf, FeatureCollector features) { @@ -538,37 +582,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { OsmNames.setOsmNames(pointFeature, sf, 0); - // Some features should only be visible at very late zooms when they don't have a name - if (!sf.hasTag("name") && (sf.hasTag("amenity", "atm", "bbq", "bench", "bicycle_parking", - "bicycle_rental", "bicycle_repair_station", "boat_storage", "bureau_de_change", "car_rental", "car_sharing", - "car_wash", "charging_station", "customs", "drinking_water", "fuel", "harbourmaster", "hunting_stand", - "karaoke_box", "life_ring", "money_transfer", "motorcycle_parking", "parking", "picnic_table", "post_box", - "ranger_station", "recycling", "sanitary_dump_station", "shelter", "shower", "taxi", "telephone", "toilets", - "waste_basket", "waste_disposal", "water_point", "watering_place", "bicycle_rental", "motorcycle_parking", - "charging_station") || - sf.hasTag("historic", "landmark", "wayside_cross") || - sf.hasTag("leisure", "dog_park", "firepit", "fishing", "pitch", "playground", "slipway", "swimming_area") || - sf.hasTag("tourism", "alpine_hut", "information", "picnic_site", "viewpoint", "wilderness_hut"))) { - pointFeature.setAttr("min_zoom", 17); - } - - if (sf.hasTag("amenity", "clinic", "dentist", "doctors", "social_facility", "baby_hatch", "childcare", - "car_sharing", "bureau_de_change", "emergency_phone", "karaoke", "karaoke_box", "money_transfer", "car_wash", - "hunting_stand", "studio", "boat_storage", "gambling", "adult_gaming_centre", "sanitary_dump_station", - "attraction", "animal", "water_slide", "roller_coaster", "summer_toboggan", "carousel", "amusement_ride", - "maze") || - sf.hasTag("historic", "memorial", "district") || - sf.hasTag("leisure", "pitch", "playground", "slipway") || - sf.hasTag("shop", "scuba_diving", "atv", "motorcycle", "snowmobile", "art", "bakery", "beauty", "bookmaker", - "books", "butcher", "car", "car_parts", "car_repair", "clothes", "computer", "convenience", "fashion", - "florist", "garden_centre", "gift", "golf", "greengrocer", "grocery", "hairdresser", "hifi", "jewelry", - "lottery", "mobile_phone", "newsagent", "optician", "perfumery", "ship_chandler", "stationery", "tobacco", - "travel_agency") || - sf.hasTag("tourism", "artwork", "hanami", "trail_riding_station", "bed_and_breakfast", "chalet", - "guest_house", "hostel")) { - pointFeature.setAttr("min_zoom", 17); - } - // Server sort features so client label collisions are pre-sorted // NOTE: (nvkelso 20230627) This could also include other params like the name pointFeature.setSortKey(minZoom * 1000); From 88218734a351b92ce95c7e4668ea71b7aff92b05 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 13:24:11 -0800 Subject: [PATCH 04/76] Created Matcher.SourceFeatureWithComputedTags() to allow mutation of tags for zoom checks --- .../protomaps/basemap/feature/Matcher.java | 81 +++++++++++++++++-- .../com/protomaps/basemap/layers/Pois.java | 32 +++++--- 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 24291d41..2c328037 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -2,13 +2,17 @@ import com.onthegomap.planetiler.expression.Expression; import com.onthegomap.planetiler.expression.MultiExpression; +import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeometryType; +import com.onthegomap.planetiler.geo.WithGeometry; import com.onthegomap.planetiler.reader.SourceFeature; +import com.onthegomap.planetiler.reader.WithTags; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.locationtech.jts.geom.Geometry; /** * A utility class for matching source feature properties to values. @@ -203,14 +207,14 @@ public static FromTag fromTag(String key) { return new FromTag(key); } - public static String getString(SourceFeature sf, List> matches, String key, String defaultValue) { + public static String getString(WithTags wt, List> matches, String key, String defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { Object value = match.get(key); if (value instanceof String stringValue) { return stringValue; } else if (value instanceof FromTag fromTag) { - return sf.getString(fromTag.key, defaultValue); + return wt.getString(fromTag.key, defaultValue); } else { return defaultValue; } @@ -219,7 +223,7 @@ public static String getString(SourceFeature sf, List> match return defaultValue; } - public static Integer getInteger(SourceFeature sf, List> matches, String key, + public static Integer getInteger(WithTags wt, List> matches, String key, Integer defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { @@ -228,7 +232,7 @@ public static Integer getInteger(SourceFeature sf, List> mat return integerValue; } else if (value instanceof FromTag fromTag) { try { - return sf.hasTag(fromTag.key) ? Integer.valueOf(sf.getString(fromTag.key)) : defaultValue; + return wt.hasTag(fromTag.key) ? Integer.valueOf(wt.getString(fromTag.key)) : defaultValue; } catch (NumberFormatException e) { return defaultValue; } @@ -240,7 +244,7 @@ public static Integer getInteger(SourceFeature sf, List> mat return defaultValue; } - public static Double getDouble(SourceFeature sf, List> matches, String key, Double defaultValue) { + public static Double getDouble(WithTags wt, List> matches, String key, Double defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { Object value = match.get(key); @@ -248,7 +252,7 @@ public static Double getDouble(SourceFeature sf, List> match return doubleValue; } else if (value instanceof FromTag fromTag) { try { - return sf.hasTag(fromTag.key) ? Double.valueOf(sf.getString(fromTag.key)) : defaultValue; + return wt.hasTag(fromTag.key) ? Double.valueOf(wt.getString(fromTag.key)) : defaultValue; } catch (NumberFormatException e) { return defaultValue; } @@ -260,7 +264,7 @@ public static Double getDouble(SourceFeature sf, List> match return defaultValue; } - public static Boolean getBoolean(SourceFeature sf, List> matches, String key, + public static Boolean getBoolean(WithTags wt, List> matches, String key, Boolean defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { @@ -268,7 +272,7 @@ public static Boolean getBoolean(SourceFeature sf, List> mat if (value instanceof Boolean booleanValue) { return booleanValue; } else if (value instanceof FromTag fromTag) { - return sf.hasTag(fromTag.key) ? sf.getBoolean(fromTag.key) : defaultValue; + return wt.hasTag(fromTag.key) ? wt.getBoolean(fromTag.key) : defaultValue; } else { return defaultValue; } @@ -277,4 +281,65 @@ public static Boolean getBoolean(SourceFeature sf, List> mat return defaultValue; } + /** + * Wrapper that combines a SourceFeature with computed tags without mutating the original. This allows MultiExpression + * matching to access both original and computed tags. + * + *

+ * This is useful when you need to add computed tags (like area calculations or derived properties) that should be + * accessible to MultiExpression rules, but the original SourceFeature has immutable tags. + *

+ */ + public static class SourceFeatureWithComputedTags extends WithGeometry implements WithTags { + private final SourceFeature delegate; + private final Map combinedTags; + + /** + * Creates a wrapper around a SourceFeature with additional computed tags. + * + * @param delegate The original SourceFeature to wrap + * @param computedTags Additional computed tags to merge with the original tags + */ + public SourceFeatureWithComputedTags(SourceFeature delegate, Map computedTags) { + this.delegate = delegate; + this.combinedTags = new HashMap<>(delegate.tags()); + this.combinedTags.putAll(computedTags); + } + + @Override + public Map tags() { + return combinedTags; + } + + @Override + public Geometry worldGeometry() throws GeometryException { + return delegate.worldGeometry(); + } + + @Override + public Geometry latLonGeometry() throws GeometryException { + return delegate.latLonGeometry(); + } + + @Override + public boolean isPoint() { + return delegate.isPoint(); + } + + @Override + public boolean canBePolygon() { + return delegate.canBePolygon(); + } + + @Override + public boolean canBeLine() { + return delegate.canBeLine(); + } + + /** Returns the original SourceFeature being wrapped */ + public SourceFeature getDelegate() { + return delegate; + } + } + } 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 955f65c2..74b437c4 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -19,8 +19,10 @@ import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.reader.SourceFeature; import com.protomaps.basemap.feature.FeatureId; +import com.protomaps.basemap.feature.Matcher; import com.protomaps.basemap.feature.QrankDb; import com.protomaps.basemap.names.OsmNames; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -225,13 +227,13 @@ public String name() { private static final double WORLD_AREA_FOR_70K_SQUARE_METERS = Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); - public void calculateDimensions(SourceFeature sf) { + public Map calculateDimensions(SourceFeature sf) { Double wayArea = 0.0; Double height = 0.0; - String namedPolygon = "no"; + Boolean hasNamedPolygon = false; if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - namedPolygon = "yes"; + hasNamedPolygon = true; try { wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; } catch (GeometryException e) { @@ -245,9 +247,11 @@ public void calculateDimensions(SourceFeature sf) { } } - sf.setTag("protomaps-basemaps:wayArea", wayArea); - sf.setTag("protomaps-basemaps:height", height); - sf.setTag("protomaps-basemaps:namedPolygon", namedPolygon); + return Map.of( + "protomaps-basemaps:wayArea", wayArea, + "protomaps-basemaps:height", height, + "protomaps-basemaps:hasNamedPolygon", hasNamedPolygon + ); } public void processOsm(SourceFeature sf, FeatureCollector features) { @@ -256,16 +260,20 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { return; } - calculateDimensions(sf); - sf.setTag("protomaps-basemaps:kind", getString(sf, kindMatches, "kind", "undefined")); - var zoomMatches = zoomsIndex.getMatches(sf); + String kind = getString(sf, kindMatches, "kind", "undefined"); + String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); + + // Calculate dimensions and create a wrapper with computed tags + Map computedTags = new HashMap<>(calculateDimensions(sf)); + computedTags.put("protomaps-basemaps:kind", kind); + + var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, computedTags); + var zoomMatches = zoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) { return; } - String kind = getString(sf, kindMatches, "kind", "undefined"); - String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); - Integer minZoom = getInteger(sf, zoomMatches, "minZoom", 99); + Integer minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); if ((sf.isPoint() || sf.canBePolygon()) && (sf.hasTag("aeroway", "aerodrome") || sf.hasTag("amenity") || From f7a49281325128093ff2445800e61048ba943773 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 13:36:52 -0800 Subject: [PATCH 05/76] Fixed Matcher.SourceFeatureWithComputedTags() to require fewer signature changes --- .../protomaps/basemap/feature/Matcher.java | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 2c328037..748ed8ac 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -7,6 +7,8 @@ import com.onthegomap.planetiler.geo.WithGeometry; import com.onthegomap.planetiler.reader.SourceFeature; import com.onthegomap.planetiler.reader.WithTags; +import com.onthegomap.planetiler.reader.osm.OsmReader; +import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -207,14 +209,14 @@ public static FromTag fromTag(String key) { return new FromTag(key); } - public static String getString(WithTags wt, List> matches, String key, String defaultValue) { + public static String getString(SourceFeature sf, List> matches, String key, String defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { Object value = match.get(key); if (value instanceof String stringValue) { return stringValue; } else if (value instanceof FromTag fromTag) { - return wt.getString(fromTag.key, defaultValue); + return sf.getString(fromTag.key, defaultValue); } else { return defaultValue; } @@ -223,7 +225,7 @@ public static String getString(WithTags wt, List> matches, S return defaultValue; } - public static Integer getInteger(WithTags wt, List> matches, String key, + public static Integer getInteger(SourceFeature sf, List> matches, String key, Integer defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { @@ -232,7 +234,7 @@ public static Integer getInteger(WithTags wt, List> matches, return integerValue; } else if (value instanceof FromTag fromTag) { try { - return wt.hasTag(fromTag.key) ? Integer.valueOf(wt.getString(fromTag.key)) : defaultValue; + return sf.hasTag(fromTag.key) ? Integer.valueOf(sf.getString(fromTag.key)) : defaultValue; } catch (NumberFormatException e) { return defaultValue; } @@ -244,7 +246,7 @@ public static Integer getInteger(WithTags wt, List> matches, return defaultValue; } - public static Double getDouble(WithTags wt, List> matches, String key, Double defaultValue) { + public static Double getDouble(SourceFeature sf, List> matches, String key, Double defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { Object value = match.get(key); @@ -252,7 +254,7 @@ public static Double getDouble(WithTags wt, List> matches, S return doubleValue; } else if (value instanceof FromTag fromTag) { try { - return wt.hasTag(fromTag.key) ? Double.valueOf(wt.getString(fromTag.key)) : defaultValue; + return sf.hasTag(fromTag.key) ? Double.valueOf(sf.getString(fromTag.key)) : defaultValue; } catch (NumberFormatException e) { return defaultValue; } @@ -264,7 +266,7 @@ public static Double getDouble(WithTags wt, List> matches, S return defaultValue; } - public static Boolean getBoolean(WithTags wt, List> matches, String key, + public static Boolean getBoolean(SourceFeature sf, List> matches, String key, Boolean defaultValue) { for (var match : matches.reversed()) { if (match.containsKey(key)) { @@ -272,7 +274,7 @@ public static Boolean getBoolean(WithTags wt, List> matches, if (value instanceof Boolean booleanValue) { return booleanValue; } else if (value instanceof FromTag fromTag) { - return wt.hasTag(fromTag.key) ? wt.getBoolean(fromTag.key) : defaultValue; + return sf.hasTag(fromTag.key) ? sf.getBoolean(fromTag.key) : defaultValue; } else { return defaultValue; } @@ -290,7 +292,7 @@ public static Boolean getBoolean(WithTags wt, List> matches, * accessible to MultiExpression rules, but the original SourceFeature has immutable tags. *

*/ - public static class SourceFeatureWithComputedTags extends WithGeometry implements WithTags { + public static class SourceFeatureWithComputedTags extends SourceFeature { private final SourceFeature delegate; private final Map combinedTags; @@ -301,6 +303,7 @@ public static class SourceFeatureWithComputedTags extends WithGeometry implement * @param computedTags Additional computed tags to merge with the original tags */ public SourceFeatureWithComputedTags(SourceFeature delegate, Map computedTags) { + super(new HashMap<>(delegate.tags()), delegate.getSource(), delegate.getSourceLayer(), null, delegate.id()); this.delegate = delegate; this.combinedTags = new HashMap<>(delegate.tags()); this.combinedTags.putAll(computedTags); @@ -336,9 +339,9 @@ public boolean canBeLine() { return delegate.canBeLine(); } - /** Returns the original SourceFeature being wrapped */ - public SourceFeature getDelegate() { - return delegate; + @Override + public boolean hasRelationInfo() { + return delegate.hasRelationInfo(); } } From 5c529a5383519f9af3ef821fca62b1c53885b4b3 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 13:46:03 -0800 Subject: [PATCH 06/76] Moved SourceFeatureWithComputedTags construction into computeExtraTags --- .../protomaps/basemap/feature/Matcher.java | 4 --- .../com/protomaps/basemap/layers/Pois.java | 25 +++++++++---------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 748ed8ac..446250ba 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -4,11 +4,7 @@ import com.onthegomap.planetiler.expression.MultiExpression; import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeometryType; -import com.onthegomap.planetiler.geo.WithGeometry; import com.onthegomap.planetiler.reader.SourceFeature; -import com.onthegomap.planetiler.reader.WithTags; -import com.onthegomap.planetiler.reader.osm.OsmReader; -import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; 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 74b437c4..69f1008c 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -22,7 +22,6 @@ import com.protomaps.basemap.feature.Matcher; import com.protomaps.basemap.feature.QrankDb; import com.protomaps.basemap.names.OsmNames; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -227,7 +226,7 @@ public String name() { private static final double WORLD_AREA_FOR_70K_SQUARE_METERS = Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); - public Map calculateDimensions(SourceFeature sf) { + public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { Double wayArea = 0.0; Double height = 0.0; Boolean hasNamedPolygon = false; @@ -247,10 +246,14 @@ public Map calculateDimensions(SourceFeature sf) { } } - return Map.of( - "protomaps-basemaps:wayArea", wayArea, - "protomaps-basemaps:height", height, - "protomaps-basemaps:hasNamedPolygon", hasNamedPolygon + return new Matcher.SourceFeatureWithComputedTags( + sf, + Map.of( + "protomaps-basemaps:kind", kind, + "protomaps-basemaps:wayArea", wayArea, + "protomaps-basemaps:height", height, + "protomaps-basemaps:hasNamedPolygon", hasNamedPolygon + ) ); } @@ -260,19 +263,15 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { return; } - String kind = getString(sf, kindMatches, "kind", "undefined"); - String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); - // Calculate dimensions and create a wrapper with computed tags - Map computedTags = new HashMap<>(calculateDimensions(sf)); - computedTags.put("protomaps-basemaps:kind", kind); - - var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, computedTags); + var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", "undefined")); var zoomMatches = zoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) { return; } + String kind = getString(sf2, kindMatches, "kind", "undefined"); + String kindDetail = getString(sf2, kindMatches, "kindDetail", "undefined"); Integer minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); if ((sf.isPoint() || sf.canBePolygon()) && (sf.hasTag("aeroway", "aerodrome") || From 5711a0e6601fda62af80f58a798f0f7cd426e834 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 14:07:32 -0800 Subject: [PATCH 07/76] Moved selected small-area polygons into rules --- .../com/protomaps/basemap/layers/Pois.java | 85 ++++++++++++++----- 1 file changed, 63 insertions(+), 22 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 69f1008c..95b63fa4 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -22,6 +22,7 @@ import com.protomaps.basemap.feature.Matcher; import com.protomaps.basemap.feature.QrankDb; import com.protomaps.basemap.names.OsmNames; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -213,6 +214,51 @@ public Pois(QrankDb qrankDb) { with("tourism", "alpine_hut", "information", "picnic_site", "viewpoint", "wilderness_hut") ), use("minZoom", 16) + ), + + rule( + with("protomaps-basemaps:hasNamedPolygon"), + with("protomaps-basemaps:kind", "playground"), + use("minZoom", 17) + ), + rule( + with("protomaps-basemaps:hasNamedPolygon"), + with("protomaps-basemaps:kind", "allotments"), + use("minZoom", 16) + ), + rule( + with("protomaps-basemaps:hasNamedPolygon"), + Expression.or(with("protomaps-basemaps:kind", "cemetery"), with("protomaps-basemaps:kind", "school")), + use("minZoom", 16) + ), + rule( + with("protomaps-basemaps:hasNamedPolygon"), + Expression.or( + with("protomaps-basemaps:kind", "forest"), + with("protomaps-basemaps:kind", "park"), + with("protomaps-basemaps:kind", "protected_area"), + with("protomaps-basemaps:kind", "nature_reserve"), + with("protomaps-basemaps:kind", "village_green") + ), + use("minZoom", 17) + ), + rule( + with("protomaps-basemaps:hasNamedPolygon"), + Expression.or(with("protomaps-basemaps:kind", "college"), with("protomaps-basemaps:kind", "university")), + use("minZoom", 15) + ), + rule( + with("protomaps-basemaps:hasNamedPolygon"), + Expression.or( + with("protomaps-basemaps:kind", "national_park"), + with("protomaps-basemaps:kind", "aerodrome"), + with("protomaps-basemaps:kind", "golf_course"), + with("protomaps-basemaps:kind", "military"), + with("protomaps-basemaps:kind", "naval_base"), + with("protomaps-basemaps:kind", "stadium"), + with("protomaps-basemaps:kind", "zoo") + ), + use("minZoom", 14) ) )).index(); @@ -227,34 +273,29 @@ public String name() { Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { - Double wayArea = 0.0; - Double height = 0.0; - Boolean hasNamedPolygon = false; + Map computedTags = new HashMap<>(Map.of( + "protomaps-basemaps:kind", kind, + "protomaps-basemaps:wayArea", 0.0, + "protomaps-basemaps:height", 0.0 + )); if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - hasNamedPolygon = true; + computedTags.put("protomaps-basemaps:hasNamedPolygon", true); try { - wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; + Double area = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; + computedTags.put("protomaps-basemaps:wayArea", area); } catch (GeometryException e) { e.log("Exception in POI way calculation"); } if (sf.hasTag("height")) { Double parsed = parseDoubleOrNull(sf.getString("height")); if (parsed != null) { - height = parsed; + computedTags.put("protomaps-basemaps:height", parsed); } } } - return new Matcher.SourceFeatureWithComputedTags( - sf, - Map.of( - "protomaps-basemaps:kind", kind, - "protomaps-basemaps:wayArea", wayArea, - "protomaps-basemaps:height", height, - "protomaps-basemaps:hasNamedPolygon", hasNamedPolygon - ) - ); + return new Matcher.SourceFeatureWithComputedTags(sf, computedTags); } public void processOsm(SourceFeature sf, FeatureCollector features) { @@ -337,7 +378,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else if (wayArea > 1) { minZoom = 13; } else if (wayArea > 0.25) { - minZoom = 14; + //minZoom = 14; } } else if (kind.equals("aerodrome") || kind.equals("golf_course") || @@ -360,7 +401,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else if (wayArea > 1) { minZoom = 13; } else if (wayArea > 0.25) { - minZoom = 14; + //minZoom = 14; } // Emphasize large international airports earlier @@ -400,7 +441,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else if (wayArea > 5) { minZoom = 14; } else { - minZoom = 15; + //minZoom = 15; } // Hack for weird San Francisco university @@ -433,7 +474,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else if (wayArea > 0.001) { minZoom = 16; } else { - minZoom = 17; + //minZoom = 17; } // Discount wilderness areas within US national forests and parks @@ -451,17 +492,17 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else if (wayArea > 0.01) { minZoom = 15; } else { - minZoom = 16; + //minZoom = 16; } // Typically for "building" derived label placements for shops and other businesses } else if (kind.equals("allotments")) { if (wayArea > 0.01) { minZoom = 15; } else { - minZoom = 16; + //minZoom = 16; } } else if (kind.equals("playground")) { - minZoom = 17; + // minZoom = 17; } else { if (wayArea > 10) { minZoom = 11; From 8fa32b325d093ff5fb42ce5a562d9778cb01e498 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 14:27:26 -0800 Subject: [PATCH 08/76] Moved some protomaps-basemaps: tags to private static strings --- .../com/protomaps/basemap/layers/Pois.java | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 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 95b63fa4..e5dbcbfa 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -47,6 +47,10 @@ public Pois(QrankDb qrankDb) { public static final String LAYER_NAME = "pois"; + // Internal tags used to reference calculated values between matchers + private static final String KIND_ATTR = "protomaps-basemaps:kind"; + private static final String HAS_NAMED_POLYGON = "protomaps-basemaps:hasNamedPolygon"; + 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"); @@ -153,7 +157,7 @@ public Pois(QrankDb qrankDb) { // Everything is zoom=15 at first rule(use("minZoom", 15)), - rule(with("protomaps-basemaps:kind", "national_park"), use("minZoom", 11)), + rule(with(KIND_ATTR, "national_park"), use("minZoom", 11)), rule(with("natural", "peak"), use("minZoom", 13)), rule(with("highway", "bus_stop"), use("minZoom", 17)), rule(with("tourism", "attraction", "camp_site", "hotel"), use("minZoom", 15)), @@ -171,7 +175,7 @@ public Pois(QrankDb qrankDb) { // Emphasize large international airports earlier rule( with("aeroway", "aerodrome"), - with("protomaps-basemaps:kind", "aerodrome"), + with(KIND_ATTR, "aerodrome"), with("iata"), use("minZoom", 11) ), @@ -217,47 +221,33 @@ public Pois(QrankDb qrankDb) { ), rule( - with("protomaps-basemaps:hasNamedPolygon"), - with("protomaps-basemaps:kind", "playground"), + with(HAS_NAMED_POLYGON), + with(KIND_ATTR, "playground"), use("minZoom", 17) ), rule( - with("protomaps-basemaps:hasNamedPolygon"), - with("protomaps-basemaps:kind", "allotments"), + with(HAS_NAMED_POLYGON), + with(KIND_ATTR, "allotments"), use("minZoom", 16) ), rule( - with("protomaps-basemaps:hasNamedPolygon"), - Expression.or(with("protomaps-basemaps:kind", "cemetery"), with("protomaps-basemaps:kind", "school")), + with(HAS_NAMED_POLYGON), + with(KIND_ATTR, "cemetery", "school"), use("minZoom", 16) ), rule( - with("protomaps-basemaps:hasNamedPolygon"), - Expression.or( - with("protomaps-basemaps:kind", "forest"), - with("protomaps-basemaps:kind", "park"), - with("protomaps-basemaps:kind", "protected_area"), - with("protomaps-basemaps:kind", "nature_reserve"), - with("protomaps-basemaps:kind", "village_green") - ), + with(HAS_NAMED_POLYGON), + with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), use("minZoom", 17) ), rule( - with("protomaps-basemaps:hasNamedPolygon"), - Expression.or(with("protomaps-basemaps:kind", "college"), with("protomaps-basemaps:kind", "university")), + with(HAS_NAMED_POLYGON), + with(KIND_ATTR, "college", "university"), use("minZoom", 15) ), rule( - with("protomaps-basemaps:hasNamedPolygon"), - Expression.or( - with("protomaps-basemaps:kind", "national_park"), - with("protomaps-basemaps:kind", "aerodrome"), - with("protomaps-basemaps:kind", "golf_course"), - with("protomaps-basemaps:kind", "military"), - with("protomaps-basemaps:kind", "naval_base"), - with("protomaps-basemaps:kind", "stadium"), - with("protomaps-basemaps:kind", "zoo") - ), + with(HAS_NAMED_POLYGON), + with(KIND_ATTR, "national_park", "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), use("minZoom", 14) ) @@ -274,13 +264,13 @@ public String name() { public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { Map computedTags = new HashMap<>(Map.of( - "protomaps-basemaps:kind", kind, + KIND_ATTR, kind, "protomaps-basemaps:wayArea", 0.0, "protomaps-basemaps:height", 0.0 )); if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - computedTags.put("protomaps-basemaps:hasNamedPolygon", true); + computedTags.put(HAS_NAMED_POLYGON, true); try { Double area = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; computedTags.put("protomaps-basemaps:wayArea", area); From 8200a7c40dfcddf71f5e6d9cd78e92bc7544eca7 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 20:50:52 -0800 Subject: [PATCH 09/76] Removed HashMap --- .../com/protomaps/basemap/layers/Pois.java | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 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 e5dbcbfa..254caddc 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -22,7 +22,6 @@ import com.protomaps.basemap.feature.Matcher; import com.protomaps.basemap.feature.QrankDb; import com.protomaps.basemap.names.OsmNames; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -263,28 +262,42 @@ public String name() { Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { - Map computedTags = new HashMap<>(Map.of( - KIND_ATTR, kind, - "protomaps-basemaps:wayArea", 0.0, - "protomaps-basemaps:height", 0.0 - )); + Double wayArea = 0.0; + Double height = 0.0; + Boolean hasNamedPolygon = false; if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - computedTags.put(HAS_NAMED_POLYGON, true); + hasNamedPolygon = true; try { - Double area = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; - computedTags.put("protomaps-basemaps:wayArea", area); + wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; } catch (GeometryException e) { e.log("Exception in POI way calculation"); } if (sf.hasTag("height")) { Double parsed = parseDoubleOrNull(sf.getString("height")); if (parsed != null) { - computedTags.put("protomaps-basemaps:height", parsed); + height = parsed; } } } + Map computedTags; + + if (hasNamedPolygon) { + computedTags = Map.of( + KIND_ATTR, kind, + HAS_NAMED_POLYGON, true, + "protomaps-basemaps:wayArea", wayArea, + "protomaps-basemaps:height", height + ); + } else { + computedTags = Map.of( + KIND_ATTR, kind, + "protomaps-basemaps:wayArea", wayArea, + "protomaps-basemaps:height", height + ); + } + return new Matcher.SourceFeatureWithComputedTags(sf, computedTags); } From 379cb9164371e8b27697b63c180b51e2aa229ec9 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 21:26:32 -0800 Subject: [PATCH 10/76] Moved all college/university zooms to rules with new withinRange expression --- .../protomaps/basemap/feature/Matcher.java | 56 +++++ .../com/protomaps/basemap/layers/Pois.java | 56 ++--- .../basemap/feature/MatcherTest.java | 206 ++++++++++++++++++ 3 files changed, 283 insertions(+), 35 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 446250ba..b489681d 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -151,6 +151,62 @@ public static Expression without(String... arguments) { return Expression.not(with(arguments)); } + /** + * Creates an {@link Expression} that matches when a numeric tag value is within a specified range. + * + *

+ * The lower bound is exclusive (value must be greater than the lower bound). The upper bound, if provided, is + * inclusive (value must be less than or equal to the upper bound). + *

+ * + *

+ * If the upper bound is null, only the lower bound is checked (value > lowerBound). + *

+ * + *

+ * Tag values that cannot be parsed as numbers or missing tags will not match. + *

+ * + * @param tagName The name of the tag to check. + * @param lowerBound The exclusive lower bound (value must be greater than this). + * @param upperBound The inclusive upper bound (value must be less than or equal to this), or null to check only the + * lower bound. + * @return An {@link Expression} for the numeric range check. + */ + public static Expression withinRange(String tagName, Integer lowerBound, Integer upperBound) { + return new WithinRangeExpression( + tagName, + new Long(lowerBound), + (upperBound == null ? null : new Long(upperBound)) + ); + } + + /** + * Expression implementation for numeric range matching. + */ + private record WithinRangeExpression(String tagName, long lowerBound, Long upperBound) implements Expression { + + @Override + public boolean evaluate(com.onthegomap.planetiler.reader.WithTags input, List matchKeys) { + if (!input.hasTag(tagName)) { + return false; + } + long value = input.getLong(tagName); + // getLong returns 0 for invalid values, so we need to check if 0 is actually the tag value + if (value == 0 && !"0".equals(input.getString(tagName))) { + // getLong returned 0 because parsing failed + return false; + } + return value > lowerBound && (upperBound == null || value <= upperBound); + } + + @Override + public String generateJavaCode() { + return "withinRange(" + com.onthegomap.planetiler.util.Format.quote(tagName) + ", " + lowerBound + "L, " + + (upperBound == null ? "null" : upperBound + "L") + ")"; + } + } + public static Expression withPoint() { return Expression.matchGeometryType(GeometryType.POINT); } 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 254caddc..df404bf0 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -8,6 +8,7 @@ import static com.protomaps.basemap.feature.Matcher.use; import static com.protomaps.basemap.feature.Matcher.with; import static com.protomaps.basemap.feature.Matcher.withPoint; +import static com.protomaps.basemap.feature.Matcher.withinRange; import static com.protomaps.basemap.feature.Matcher.without; import com.onthegomap.planetiler.FeatureCollector; @@ -48,6 +49,8 @@ public Pois(QrankDb qrankDb) { // Internal tags used to reference calculated values between matchers private static final String KIND_ATTR = "protomaps-basemaps:kind"; + private static final String WAYAREA_ATTR = "protomaps-basemaps:wayArea"; + private static final String HEIGHT_ATTR = "protomaps-basemaps:height"; private static final String HAS_NAMED_POLYGON = "protomaps-basemaps:hasNamedPolygon"; private static final Expression WITH_OPERATOR_USFS = with("operator", "United States Forest Service", @@ -239,11 +242,20 @@ public Pois(QrankDb qrankDb) { with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), use("minZoom", 17) ), - rule( - with(HAS_NAMED_POLYGON), - with(KIND_ATTR, "college", "university"), - use("minZoom", 15) - ), + + // College and university polygons + + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 0, 5), use("minZoom", 15)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5, 20), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20, 50), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 50, 100), use("minZoom", 12)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 100, 150), use("minZoom", 11)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 150, 250), use("minZoom", 10)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 250, 5000), use("minZoom", 9)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 8)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20000, null), use("minZoom", 7)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university + rule( with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park", "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), @@ -287,14 +299,14 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, computedTags = Map.of( KIND_ATTR, kind, HAS_NAMED_POLYGON, true, - "protomaps-basemaps:wayArea", wayArea, - "protomaps-basemaps:height", height + WAYAREA_ATTR, wayArea, + HEIGHT_ATTR, height ); } else { computedTags = Map.of( KIND_ATTR, kind, - "protomaps-basemaps:wayArea", wayArea, - "protomaps-basemaps:height", height + WAYAREA_ATTR, wayArea, + HEIGHT_ATTR, height ); } @@ -425,32 +437,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } } - } else if (kind.equals("college") || - kind.equals("university")) { - if (wayArea > 20000) { - minZoom = 7; - } else if (wayArea > 5000) { - minZoom = 8; - } else if (wayArea > 250) { - minZoom = 9; - } else if (wayArea > 150) { - minZoom = 10; - } else if (wayArea > 100) { - minZoom = 11; - } else if (wayArea > 50) { - minZoom = 12; - } else if (wayArea > 20) { - minZoom = 13; - } else if (wayArea > 5) { - minZoom = 14; - } else { - //minZoom = 15; - } - - // Hack for weird San Francisco university - if (sf.getString("name").equals("Academy of Art University")) { - minZoom = 14; - } } else if (kind.equals("forest") || kind.equals("park") || kind.equals("protected_area") || diff --git a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java index 79976954..b5a31b44 100644 --- a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java @@ -14,11 +14,14 @@ import static com.protomaps.basemap.feature.Matcher.withLine; import static com.protomaps.basemap.feature.Matcher.withPoint; import static com.protomaps.basemap.feature.Matcher.withPolygon; +import static com.protomaps.basemap.feature.Matcher.withinRange; import static com.protomaps.basemap.feature.Matcher.without; import static com.protomaps.basemap.feature.Matcher.withoutLine; import static com.protomaps.basemap.feature.Matcher.withoutPoint; import static com.protomaps.basemap.feature.Matcher.withoutPolygon; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.onthegomap.planetiler.expression.Expression; import com.onthegomap.planetiler.expression.MultiExpression; @@ -700,4 +703,207 @@ void testGetBooleanFromTag() { assertEquals(true, getBoolean(sf, matches, "a", false)); } + @Test + void testWithinRangeWithUpperBound() { + var expression = withinRange("population", 5, 10); + + // Value within range (5 < 7 <= 10) + var sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "7"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + + // Value at lower bound (not > 5) + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "5"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + + // Value at upper bound (10 <= 10) + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "10"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + + // Value below range + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "3"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + + // Value above range + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "15"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + } + + @Test + void testWithinRangeWithoutUpperBound() { + var expression = withinRange("population", 5, null); + + // Value above lower bound + var sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "10"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + + // Value at lower bound + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "5"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + + // Value below lower bound + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "3"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + + // Very large value + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "1000000"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + } + + @Test + void testWithinRangeMissingTag() { + var expression = withinRange("population", 5, 10); + + var sf = SimpleFeature.create( + newPoint(0, 0), + Map.of(), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + } + + @Test + void testWithinRangeNonNumericValue() { + var expression = withinRange("population", 5, 10); + + var sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "hello"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + } + + @Test + void testWithinRangeNegativeNumbers() { + var expression = withinRange("temperature", -10, 5); + + // Value within range (-10 < -5 <= 5) + var sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("temperature", "-5"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + + // Value at lower bound + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("temperature", "-10"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + + // Value at upper bound + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("temperature", "5"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + } + + @Test + void testWithinRangeZeroValue() { + var expression = withinRange("value", -5, 5); + + // Zero within range (-5 < 0 <= 5) + var sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("value", "0"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + } + + @Test + void testWithinRangeZeroAsBound() { + var expression = withinRange("value", 0, 10); + + // Value above zero bound + var sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("value", "5"), + "osm", + null, + 0 + ); + assertTrue(expression.evaluate(sf, List.of())); + + // Value at zero bound + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("value", "0"), + "osm", + null, + 0 + ); + assertFalse(expression.evaluate(sf, List.of())); + } + } From d03004f7f733e79ec4f2f0ef8371a1a7ab862052 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 26 Dec 2025 23:45:25 -0800 Subject: [PATCH 11/76] Fixed zoom rule ordering failure --- tiles/src/main/java/com/protomaps/basemap/layers/Pois.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 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 df404bf0..d67cda2c 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -57,7 +57,7 @@ public Pois(QrankDb qrankDb) { "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"); - private static final MultiExpression.Index> kindsIndex = MultiExpression.of(List.of( + private static final MultiExpression.Index> kindsIndex = MultiExpression.ofOrdered(List.of( // Everything is "other"/"" at first rule(use("kind", "other"), use("kindDetail", "")), @@ -154,7 +154,7 @@ public Pois(QrankDb qrankDb) { )).index(); - private static final MultiExpression.Index> zoomsIndex = MultiExpression.of(List.of( + private static final MultiExpression.Index> zoomsIndex = MultiExpression.ofOrdered(List.of( // Everything is zoom=15 at first rule(use("minZoom", 15)), @@ -437,6 +437,9 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } } + } else if (kind.equals("college") || + kind.equals("university")) { + // do nothing } else if (kind.equals("forest") || kind.equals("park") || kind.equals("protected_area") || From 1ff331639851db77656e7b212b11d3640eae9772 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 09:54:08 -0800 Subject: [PATCH 12/76] Moved assorted green and other zoom-based polygons to rules --- .../com/protomaps/basemap/layers/Pois.java | 102 +++++++----------- 1 file changed, 41 insertions(+), 61 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 d67cda2c..f2aadc50 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -237,30 +237,47 @@ public Pois(QrankDb qrankDb) { with(KIND_ATTR, "cemetery", "school"), use("minZoom", 16) ), - rule( - with(HAS_NAMED_POLYGON), - with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), - use("minZoom", 17) - ), + + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), use("minZoom", 17)), // College and university polygons - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 0, 5), use("minZoom", 15)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5, 20), use("minZoom", 14)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20, 50), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 50, 100), use("minZoom", 12)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 100, 150), use("minZoom", 11)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 150, 250), use("minZoom", 10)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 250, 5000), use("minZoom", 9)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 8)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20000, null), use("minZoom", 7)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 0, 5000), use("minZoom", 15)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20000, 50000), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 50000, 100000), use("minZoom", 12)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 100000, 150000), use("minZoom", 11)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 150000, 250000), use("minZoom", 10)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)), rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university - rule( - with(HAS_NAMED_POLYGON), - with(KIND_ATTR, "national_park", "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), - use("minZoom", 14) - ) + // Big green polygons + + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 0, 1), use("minZoom", 17)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 1, 10), use("minZoom", 16)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 10, 250), use("minZoom", 15)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 5000, 15000), use("minZoom", 12)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 15000, 250000), use("minZoom", 11)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 250000, 1000000), use("minZoom", 10)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 1000000, 4000000), use("minZoom", 9)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 4000000, 10000000), use("minZoom", 8)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 10000000, null), use("minZoom", 7)), + + // How are these similar? + + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)) )).index(); @@ -269,6 +286,10 @@ public String name() { return LAYER_NAME; } + // ~= pow((sqrt(70) / (4e7 / 256)) / 256, 2) ~= 4.4e-14 + private static final double WORLD_AREA_FOR_70_SQUARE_METERS = + Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70)) / 256d, 2); + // ~= pow((sqrt(7e4) / (4e7 / 256)) / 256, 2) ~= 4.4e-11 private static final double WORLD_AREA_FOR_70K_SQUARE_METERS = Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); @@ -281,7 +302,7 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { hasNamedPolygon = true; try { - wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; + wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70_SQUARE_METERS; } catch (GeometryException e) { e.log("Exception in POI way calculation"); } @@ -401,23 +422,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { kind.equals("naval_base") || kind.equals("stadium") || kind.equals("zoo")) { - if (wayArea > 20000) { // 500000000 - minZoom = 7; - } else if (wayArea > 5000) { // 200000000 - minZoom = 8; - } else if (wayArea > 250) { // 40000000 - minZoom = 9; - } else if (wayArea > 100) { // 8000000 - minZoom = 10; - } else if (wayArea > 20) { // 500000 - minZoom = 11; - } else if (wayArea > 5) { - minZoom = 12; - } else if (wayArea > 1) { - minZoom = 13; - } else if (wayArea > 0.25) { - //minZoom = 14; - } // Emphasize large international airports earlier // Because the area grading resets the earlier dispensation @@ -445,30 +449,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { kind.equals("protected_area") || kind.equals("nature_reserve") || kind.equals("village_green")) { - if (wayArea > 10000) { - minZoom = 7; - } else if (wayArea > 4000) { - minZoom = 8; - } else if (wayArea > 1000) { - minZoom = 9; - } else if (wayArea > 250) { - minZoom = 10; - } else if (wayArea > 15) { - minZoom = 11; - } else if (wayArea > 5) { - minZoom = 12; - } else if (wayArea > 1) { - minZoom = 13; - } else if (wayArea > 0.25) { - minZoom = 14; - } else if (wayArea > 0.01) { - minZoom = 15; - } else if (wayArea > 0.001) { - minZoom = 16; - } else { - //minZoom = 17; - } - // Discount wilderness areas within US national forests and parks if (kind.equals("nature_reserve") && sf.getString("name").contains("Wilderness")) { minZoom = minZoom + 1; From f2dbdf48497ecc636365857967cbd5932ff84770 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 10:02:49 -0800 Subject: [PATCH 13/76] Moved schools, cemeteries, and national parks to zoom-graded rules --- .../com/protomaps/basemap/layers/Pois.java | 61 +++++++------------ 1 file changed, 22 insertions(+), 39 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 f2aadc50..89ad9392 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -232,13 +232,28 @@ public Pois(QrankDb qrankDb) { with(KIND_ATTR, "allotments"), use("minZoom", 16) ), - rule( - with(HAS_NAMED_POLYGON), - with(KIND_ATTR, "cemetery", "school"), - use("minZoom", 16) - ), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), use("minZoom", 17)), + // Schools & Cemeteries + + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 10, 100), use("minZoom", 15)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 100, 1000), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 5000, null), use("minZoom", 12)), + + // National parks + + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 0, 250), use("minZoom", 17)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 250000, 2000000), use("minZoom", 9)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 2000000, 10000000), use("minZoom", 8)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 10000000, 25000000), use("minZoom", 7)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 25000000, 300000000), use("minZoom", 6)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 300000000, null), use("minZoom", 5)), // College and university polygons @@ -269,7 +284,6 @@ public Pois(QrankDb qrankDb) { // How are these similar? - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), use("minZoom", 14)), rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), @@ -395,27 +409,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // // Allowlist of kind values eligible for early zoom point labels if (kind.equals("national_park")) { - if (wayArea > 300000) { // 500000000 sq meters (web mercator proj) - minZoom = 5; - } else if (wayArea > 25000) { // 500000000 sq meters (web mercator proj) - minZoom = 6; - } else if (wayArea > 10000) { // 500000000 - minZoom = 7; - } else if (wayArea > 2000) { // 200000000 - minZoom = 8; - } else if (wayArea > 250) { // 40000000 - minZoom = 9; - } else if (wayArea > 100) { // 8000000 - minZoom = 10; - } else if (wayArea > 20) { // 500000 - minZoom = 11; - } else if (wayArea > 5) { - minZoom = 12; - } else if (wayArea > 1) { - minZoom = 13; - } else if (wayArea > 0.25) { - //minZoom = 14; - } + } else if (kind.equals("aerodrome") || kind.equals("golf_course") || kind.equals("military") || @@ -455,17 +449,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } else if (kind.equals("cemetery") || kind.equals("school")) { - if (wayArea > 5) { - minZoom = 12; - } else if (wayArea > 1) { - minZoom = 13; - } else if (wayArea > 0.1) { - minZoom = 14; - } else if (wayArea > 0.01) { - minZoom = 15; - } else { - //minZoom = 16; - } // Typically for "building" derived label placements for shops and other businesses } else if (kind.equals("allotments")) { if (wayArea > 0.01) { From cca5a645e13c99e9ca187a5f46dc091b04ab75a1 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 10:38:33 -0800 Subject: [PATCH 14/76] Corrected withinRange() bounds logic and moved height adjustments to rules --- .../protomaps/basemap/feature/Matcher.java | 2 +- .../com/protomaps/basemap/layers/Pois.java | 87 +++++++------------ .../basemap/feature/MatcherTest.java | 12 +-- 3 files changed, 39 insertions(+), 62 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index b489681d..a47cc205 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -197,7 +197,7 @@ public boolean evaluate(com.onthegomap.planetiler.reader.WithTags input, List lowerBound && (upperBound == null || value <= upperBound); + return value >= lowerBound && (upperBound == null || value < upperBound); } @Override 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 89ad9392..efe9c1b9 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -222,16 +222,39 @@ public Pois(QrankDb qrankDb) { use("minZoom", 16) ), + // Size-graded polygons, generic at first then per-kind adjustments + + rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 500), use("minZoom", 14)), + rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 500, 2000), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 2000, 10000), use("minZoom", 12)), + rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10000, null), use("minZoom", 11)), + + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "playground"), use("minZoom", 17)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 10, null), use("minZoom", 15)), + + // Height-graded polygons, generic at first then per-kind adjustments + // Small but tall features should show up early as they have regional prominance. + // Height measured in meters + + rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 10, 20), use("minZoom", 13)), + rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 20, 100), use("minZoom", 12)), + rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 100, null), use("minZoom", 11)), + + // Clamp certain kind values so medium tall buildings don't crowd downtown areas + // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer + // NOTE: (nvkelso 20230624) Turn this into an allowlist instead of a blocklist rule( with(HAS_NAMED_POLYGON), - with(KIND_ATTR, "playground"), - use("minZoom", 17) - ), - rule( - with(HAS_NAMED_POLYGON), - with(KIND_ATTR, "allotments"), - use("minZoom", 16) + with(KIND_ATTR, "hotel", "hostel", "parking", "bank", "place_of_worship", "jewelry", "yes", "restaurant", "coworking_space", "clothes", "art", "school"), + withinRange(WAYAREA_ATTR, 10, 2000), + withinRange(HEIGHT_ATTR, 20, 100), + use("minZoom", 13) ), + // Discount tall self storage buildings + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "storage_rental"), withinRange(WAYAREA_ATTR, 10, 2000), use("minZoom", 14)), + // Discount tall university buildings, require a related university landuse AOI + rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "university"), withinRange(WAYAREA_ATTR, 10, 2000), use("minZoom", 13)), // Schools & Cemeteries @@ -451,57 +474,11 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { kind.equals("school")) { // Typically for "building" derived label placements for shops and other businesses } else if (kind.equals("allotments")) { - if (wayArea > 0.01) { - minZoom = 15; - } else { - //minZoom = 16; - } + // } else if (kind.equals("playground")) { // minZoom = 17; } else { - if (wayArea > 10) { - minZoom = 11; - } else if (wayArea > 2) { - minZoom = 12; - } else if (wayArea > 0.5) { - minZoom = 13; - } else if (wayArea > 0.01) { - minZoom = 14; - } - - // Small but tall features should show up early as they have regional prominance. - // Height measured in meters - if (minZoom >= 13 && height > 0.0) { - if (height >= 100) { - minZoom = 11; - } else if (height >= 20) { - minZoom = 12; - } else if (height >= 10) { - minZoom = 13; - } - - // Clamp certain kind values so medium tall buildings don't crowd downtown areas - // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer - // NOTE: (nvkelso 20230624) Turn this into an allowlist instead of a blocklist - if (kind.equals("hotel") || kind.equals("hostel") || kind.equals("parking") || kind.equals("bank") || - kind.equals("place_of_worship") || kind.equals("jewelry") || kind.equals("yes") || - kind.equals("restaurant") || kind.equals("coworking_space") || kind.equals("clothes") || - kind.equals("art") || kind.equals("school")) { - if (minZoom == 12) { - minZoom = 13; - } - } - - // Discount tall self storage buildings - if (kind.equals("storage_rental")) { - minZoom = 14; - } - - // Discount tall university buildings, require a related university landuse AOI - if (kind.equals("university")) { - minZoom = 13; - } - } + // } // very long text names should only be shown at later zooms diff --git a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java index b5a31b44..49eda5ac 100644 --- a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java @@ -725,7 +725,7 @@ void testWithinRangeWithUpperBound() { null, 0 ); - assertFalse(expression.evaluate(sf, List.of())); + assertTrue(expression.evaluate(sf, List.of())); // Value at upper bound (10 <= 10) sf = SimpleFeature.create( @@ -735,7 +735,7 @@ void testWithinRangeWithUpperBound() { null, 0 ); - assertTrue(expression.evaluate(sf, List.of())); + assertFalse(expression.evaluate(sf, List.of())); // Value below range sf = SimpleFeature.create( @@ -780,7 +780,7 @@ void testWithinRangeWithoutUpperBound() { null, 0 ); - assertFalse(expression.evaluate(sf, List.of())); + assertTrue(expression.evaluate(sf, List.of())); // Value below lower bound sf = SimpleFeature.create( @@ -853,7 +853,7 @@ void testWithinRangeNegativeNumbers() { null, 0 ); - assertFalse(expression.evaluate(sf, List.of())); + assertTrue(expression.evaluate(sf, List.of())); // Value at upper bound sf = SimpleFeature.create( @@ -863,7 +863,7 @@ void testWithinRangeNegativeNumbers() { null, 0 ); - assertTrue(expression.evaluate(sf, List.of())); + assertFalse(expression.evaluate(sf, List.of())); } @Test @@ -903,7 +903,7 @@ void testWithinRangeZeroAsBound() { null, 0 ); - assertFalse(expression.evaluate(sf, List.of())); + assertTrue(expression.evaluate(sf, List.of())); } } From 4431fb3d0a03c651132878c3643f15c5eb275a7b Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 10:45:42 -0800 Subject: [PATCH 15/76] Reorganized final adjustments for clarity --- .../com/protomaps/basemap/layers/Pois.java | 92 +++++-------------- 1 file changed, 23 insertions(+), 69 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 efe9c1b9..9408d123 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -412,85 +412,39 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // try first for polygon -> point representations if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - Double wayArea = 0.0; - try { - wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70K_SQUARE_METERS; - } catch (GeometryException e) { - e.log("Exception in POI way calculation"); - } - double height = 0.0; - if (sf.hasTag("height")) { - Double parsed = parseDoubleOrNull(sf.getString("height")); - if (parsed != null) { - height = parsed; - } - } + // Emphasize large international airports earlier + // Because the area grading resets the earlier dispensation + if (kind.equals("aerodrome")) { + if (sf.hasTag("iata")) { + // prioritize international airports over regional airports + minZoom -= 2; - // Area zoom grading overrides the kind zoom grading in the section above. - // Roughly shared with the water label area zoom grading in physical points layer - // - // Allowlist of kind values eligible for early zoom point labels - if (kind.equals("national_park")) { - - } else if (kind.equals("aerodrome") || - kind.equals("golf_course") || - kind.equals("military") || - kind.equals("naval_base") || - kind.equals("stadium") || - kind.equals("zoo")) { - - // Emphasize large international airports earlier - // Because the area grading resets the earlier dispensation - if (kind.equals("aerodrome")) { - if (sf.hasTag("iata")) { - // prioritize international airports over regional airports - minZoom -= 2; - - // but don't show international airports tooooo early - if (minZoom < 10) { - minZoom = 10; - } - } else { - // and show other airports only once their polygon begins to be visible - if (minZoom < 12) { - minZoom = 12; - } + // but don't show international airports tooooo early + if (minZoom < 10) { + minZoom = 10; + } + } else { + // and show other airports only once their polygon begins to be visible + if (minZoom < 12) { + minZoom = 12; } } - } else if (kind.equals("college") || - kind.equals("university")) { - // do nothing - } else if (kind.equals("forest") || - kind.equals("park") || - kind.equals("protected_area") || - kind.equals("nature_reserve") || - kind.equals("village_green")) { - // Discount wilderness areas within US national forests and parks - if (kind.equals("nature_reserve") && sf.getString("name").contains("Wilderness")) { - minZoom = minZoom + 1; - } - } else if (kind.equals("cemetery") || - kind.equals("school")) { - // Typically for "building" derived label placements for shops and other businesses - } else if (kind.equals("allotments")) { - // - } else if (kind.equals("playground")) { - // minZoom = 17; - } else { - // + } + + // Discount wilderness areas within US national forests and parks + if (kind.equals("nature_reserve") && sf.getString("name").contains("Wilderness")) { + minZoom += 1; } // very long text names should only be shown at later zooms if (minZoom < 14) { var nameLength = sf.getString("name").length(); - if (nameLength > 30) { - if (nameLength > 45) { - minZoom += 2; - } else { - minZoom += 1; - } + if (nameLength > 45) { + minZoom += 2; + } else if (nameLength > 30) { + minZoom += 1; } } From 1febd2e8b06f83a6854752a3711867a8d5c8e773 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 10:52:14 -0800 Subject: [PATCH 16/76] Shorthanded a bunch of zoom rules --- .../com/protomaps/basemap/layers/Pois.java | 134 ++++++++++-------- 1 file changed, 75 insertions(+), 59 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 9408d123..64b399e2 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -154,6 +154,18 @@ public Pois(QrankDb qrankDb) { )).index(); + private static final Expression with_named_polygon = with(HAS_NAMED_POLYGON); + private static final Expression with_s_c_named_poly = + Expression.and(with_named_polygon, with(KIND_ATTR, "cemetery", "school")); + private static final Expression with_n_p_named_poly = + Expression.and(with_named_polygon, with(KIND_ATTR, "national_park")); + private static final Expression with_c_u_named_poly = + Expression.and(with_named_polygon, with(KIND_ATTR, "college", "university")); + private static final Expression with_b_g_named_poly = Expression.and(with_named_polygon, + with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green")); + private static final Expression with_etc_named_poly = Expression.and(with_named_polygon, + with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo")); + private static final MultiExpression.Index> zoomsIndex = MultiExpression.ofOrdered(List.of( // Everything is zoom=15 at first @@ -224,97 +236,101 @@ public Pois(QrankDb qrankDb) { // Size-graded polygons, generic at first then per-kind adjustments - rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 500), use("minZoom", 14)), - rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 500, 2000), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 2000, 10000), use("minZoom", 12)), - rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10000, null), use("minZoom", 11)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 500), use("minZoom", 14)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 500, 2000), use("minZoom", 13)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 2000, 10000), use("minZoom", 12)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10000, null), use("minZoom", 11)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "playground"), use("minZoom", 17)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 10, null), use("minZoom", 15)), + rule(with_named_polygon, with(KIND_ATTR, "playground"), use("minZoom", 17)), + rule(with_named_polygon, with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), + rule(with_named_polygon, with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 10, null), use("minZoom", 15)), // Height-graded polygons, generic at first then per-kind adjustments // Small but tall features should show up early as they have regional prominance. // Height measured in meters - rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 10, 20), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 20, 100), use("minZoom", 12)), - rule(with(HAS_NAMED_POLYGON), withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 100, null), use("minZoom", 11)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 10, 20), use("minZoom", 13)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 20, 100), + use("minZoom", 12)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 100, null), + use("minZoom", 11)), // Clamp certain kind values so medium tall buildings don't crowd downtown areas // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer // NOTE: (nvkelso 20230624) Turn this into an allowlist instead of a blocklist rule( - with(HAS_NAMED_POLYGON), - with(KIND_ATTR, "hotel", "hostel", "parking", "bank", "place_of_worship", "jewelry", "yes", "restaurant", "coworking_space", "clothes", "art", "school"), + with_named_polygon, + with(KIND_ATTR, "hotel", "hostel", "parking", "bank", "place_of_worship", "jewelry", "yes", "restaurant", + "coworking_space", "clothes", "art", "school"), withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 20, 100), use("minZoom", 13) ), // Discount tall self storage buildings - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "storage_rental"), withinRange(WAYAREA_ATTR, 10, 2000), use("minZoom", 14)), + rule(with_named_polygon, with(KIND_ATTR, "storage_rental"), withinRange(WAYAREA_ATTR, 10, 2000), + use("minZoom", 14)), // Discount tall university buildings, require a related university landuse AOI - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "university"), withinRange(WAYAREA_ATTR, 10, 2000), use("minZoom", 13)), + rule(with_named_polygon, with(KIND_ATTR, "university"), withinRange(WAYAREA_ATTR, 10, 2000), use("minZoom", 13)), // Schools & Cemeteries - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 10, 100), use("minZoom", 15)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 100, 1000), use("minZoom", 14)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "cemetery", "school"), withinRange(WAYAREA_ATTR, 5000, null), use("minZoom", 12)), + rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), + rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 10, 100), use("minZoom", 15)), + rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 100, 1000), use("minZoom", 14)), + rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 5000, null), use("minZoom", 12)), // National parks - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 0, 250), use("minZoom", 17)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 250000, 2000000), use("minZoom", 9)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 2000000, 10000000), use("minZoom", 8)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 10000000, 25000000), use("minZoom", 7)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 25000000, 300000000), use("minZoom", 6)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "national_park"), withinRange(WAYAREA_ATTR, 300000000, null), use("minZoom", 5)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 0, 250), use("minZoom", 17)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 250000, 2000000), use("minZoom", 9)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2000000, 10000000), use("minZoom", 8)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 10000000, 25000000), use("minZoom", 7)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 25000000, 300000000), use("minZoom", 6)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 300000000, null), use("minZoom", 5)), // College and university polygons - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 0, 5000), use("minZoom", 15)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 14)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20000, 50000), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 50000, 100000), use("minZoom", 12)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 100000, 150000), use("minZoom", 11)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 150000, 250000), use("minZoom", 10)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "college", "university"), with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 0, 5000), use("minZoom", 15)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 14)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 20000, 50000), use("minZoom", 13)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 50000, 100000), use("minZoom", 12)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 100000, 150000), use("minZoom", 11)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 150000, 250000), use("minZoom", 10)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)), + rule(with_c_u_named_poly, with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university // Big green polygons - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 0, 1), use("minZoom", 17)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 1, 10), use("minZoom", 16)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 10, 250), use("minZoom", 15)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 5000, 15000), use("minZoom", 12)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 15000, 250000), use("minZoom", 11)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 250000, 1000000), use("minZoom", 10)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 1000000, 4000000), use("minZoom", 9)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 4000000, 10000000), use("minZoom", 8)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green"), withinRange(WAYAREA_ATTR, 10000000, null), use("minZoom", 7)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 0, 1), use("minZoom", 17)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1, 10), use("minZoom", 16)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 10, 250), use("minZoom", 15)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 5000, 15000), use("minZoom", 12)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 15000, 250000), use("minZoom", 11)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 250000, 1000000), use("minZoom", 10)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1000000, 4000000), use("minZoom", 9)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 4000000, 10000000), use("minZoom", 8)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 10000000, null), use("minZoom", 7)), // How are these similar? - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), - rule(with(HAS_NAMED_POLYGON), with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"), withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)) + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)) )).index(); From 1a59b25ab7834df00a0fb7f0c5b4fdcf38d99351 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 11:09:50 -0800 Subject: [PATCH 17/76] Switched to overloaded withinRange() to allow for scientific notation in code --- .../protomaps/basemap/feature/Matcher.java | 45 +++++++++--- .../com/protomaps/basemap/layers/Pois.java | 70 +++++++++---------- .../basemap/feature/MatcherTest.java | 2 +- 3 files changed, 68 insertions(+), 49 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index a47cc205..c81fd7d8 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -155,12 +155,11 @@ public static Expression without(String... arguments) { * Creates an {@link Expression} that matches when a numeric tag value is within a specified range. * *

- * The lower bound is exclusive (value must be greater than the lower bound). The upper bound, if provided, is - * inclusive (value must be less than or equal to the upper bound). + * The lower bound is inclusive. The upper bound, if provided, is exclusive. *

* *

- * If the upper bound is null, only the lower bound is checked (value > lowerBound). + * If the upper bound is null, only the lower bound is checked (value >= lowerBound). *

* *

@@ -168,17 +167,41 @@ public static Expression without(String... arguments) { *

* * @param tagName The name of the tag to check. - * @param lowerBound The exclusive lower bound (value must be greater than this). - * @param upperBound The inclusive upper bound (value must be less than or equal to this), or null to check only the - * lower bound. + * @param lowerBound The inclusive lower bound. + * @param upperBound The exclusive upper bound, or null to check only the lower bound. * @return An {@link Expression} for the numeric range check. */ public static Expression withinRange(String tagName, Integer lowerBound, Integer upperBound) { - return new WithinRangeExpression( - tagName, - new Long(lowerBound), - (upperBound == null ? null : new Long(upperBound)) - ); + return new WithinRangeExpression(tagName, new Long(lowerBound), new Long(upperBound)); + } + + /** + * Overload withinRange to accept just lower bound integer + */ + public static Expression withinRange(String tagName, Integer lowerBound) { + return new WithinRangeExpression(tagName, new Long(lowerBound), null); + } + + /** + * Overload withinRange to accept lower bound integer and upper bound double + */ + public static Expression withinRange(String tagName, Integer lowerBound, Double upperBound) { + return new WithinRangeExpression(tagName, new Long(lowerBound), Double.valueOf(upperBound).longValue()); + } + + /** + * Overload withinRange to accept bounds as doubles + */ + public static Expression withinRange(String tagName, Double lowerBound, Double upperBound) { + return new WithinRangeExpression(tagName, Double.valueOf(lowerBound).longValue(), + Double.valueOf(upperBound).longValue()); + } + + /** + * Overload withinRange to accept just lower bound double + */ + public static Expression withinRange(String tagName, Double lowerBound) { + return new WithinRangeExpression(tagName, Double.valueOf(lowerBound).longValue(), null); } /** 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 64b399e2..e4ae3b01 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -238,12 +238,12 @@ public Pois(QrankDb qrankDb) { rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 500), use("minZoom", 14)), rule(with_named_polygon, withinRange(WAYAREA_ATTR, 500, 2000), use("minZoom", 13)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 2000, 10000), use("minZoom", 12)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10000, null), use("minZoom", 11)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 2000, 1e4), use("minZoom", 12)), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 1e4), use("minZoom", 11)), rule(with_named_polygon, with(KIND_ATTR, "playground"), use("minZoom", 17)), rule(with_named_polygon, with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), - rule(with_named_polygon, with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 10, null), use("minZoom", 15)), + rule(with_named_polygon, with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 10), use("minZoom", 15)), // Height-graded polygons, generic at first then per-kind adjustments // Small but tall features should show up early as they have regional prominance. @@ -252,7 +252,7 @@ public Pois(QrankDb qrankDb) { rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 10, 20), use("minZoom", 13)), rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 20, 100), use("minZoom", 12)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 100, null), + rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 100), use("minZoom", 11)), // Clamp certain kind values so medium tall buildings don't crowd downtown areas @@ -278,33 +278,33 @@ public Pois(QrankDb qrankDb) { rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 10, 100), use("minZoom", 15)), rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 100, 1000), use("minZoom", 14)), rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 5000, null), use("minZoom", 12)), + rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 5000), use("minZoom", 12)), // National parks rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 0, 250), use("minZoom", 17)), rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 250000, 2000000), use("minZoom", 9)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2000000, 10000000), use("minZoom", 8)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 10000000, 25000000), use("minZoom", 7)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 25000000, 300000000), use("minZoom", 6)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 300000000, null), use("minZoom", 5)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 5000, 2e4), use("minZoom", 12)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2e4, 1e5), use("minZoom", 11)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 1e5, 2.5e5), use("minZoom", 10)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 2e6), use("minZoom", 9)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2e6, 1e7), use("minZoom", 8)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 1e7, 2.5e7), use("minZoom", 7)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2.5e7, 3e8), use("minZoom", 6)), + rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 3e8), use("minZoom", 5)), // College and university polygons rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 0, 5000), use("minZoom", 15)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 14)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 20000, 50000), use("minZoom", 13)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 50000, 100000), use("minZoom", 12)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 100000, 150000), use("minZoom", 11)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 150000, 250000), use("minZoom", 10)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5000, 2e4), use("minZoom", 14)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 2e4, 5e4), use("minZoom", 13)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5e4, 1e5), use("minZoom", 12)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 1e5, 1.5e5), use("minZoom", 11)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 1.5e5, 2.5e5), use("minZoom", 10)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 5e6), use("minZoom", 9)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5e6, 2e7), use("minZoom", 8)), + rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 2e7), use("minZoom", 7)), rule(with_c_u_named_poly, with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university // Big green polygons @@ -314,23 +314,23 @@ public Pois(QrankDb qrankDb) { rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 10, 250), use("minZoom", 15)), rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 5000, 15000), use("minZoom", 12)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 15000, 250000), use("minZoom", 11)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 250000, 1000000), use("minZoom", 10)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1000000, 4000000), use("minZoom", 9)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 4000000, 10000000), use("minZoom", 8)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 10000000, null), use("minZoom", 7)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 5000, 1.5e4), use("minZoom", 12)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1.5e4, 2.5e5), use("minZoom", 11)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 1e6), use("minZoom", 10)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1e6, 4e6), use("minZoom", 9)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 4e6, 1e7), use("minZoom", 8)), + rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1e7), use("minZoom", 7)), // How are these similar? rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5000, 20000), use("minZoom", 12)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 20000, 100000), use("minZoom", 11)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 100000, 250000), use("minZoom", 10)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 250000, 5000000), use("minZoom", 9)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5000000, 20000000), use("minZoom", 8)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 20000000, null), use("minZoom", 7)) + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5000, 2e4), use("minZoom", 12)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 2e4, 1e5), use("minZoom", 11)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 1e5, 2.5e5), use("minZoom", 10)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 5e6), use("minZoom", 9)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5e6, 2e7), use("minZoom", 8)), + rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 2e7), use("minZoom", 7)) )).index(); @@ -343,10 +343,6 @@ public String name() { private static final double WORLD_AREA_FOR_70_SQUARE_METERS = Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70)) / 256d, 2); - // ~= pow((sqrt(7e4) / (4e7 / 256)) / 256, 2) ~= 4.4e-11 - private static final double WORLD_AREA_FOR_70K_SQUARE_METERS = - Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); - public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { Double wayArea = 0.0; Double height = 0.0; diff --git a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java index 49eda5ac..4fd641ba 100644 --- a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java @@ -760,7 +760,7 @@ void testWithinRangeWithUpperBound() { @Test void testWithinRangeWithoutUpperBound() { - var expression = withinRange("population", 5, null); + var expression = withinRange("population", 5); // Value above lower bound var sf = SimpleFeature.create( From 665645a7322a66c4c34745c367a571423b379542 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 11:58:12 -0800 Subject: [PATCH 18/76] Moved last top-level tag checks in processOsm() to rules --- .../com/protomaps/basemap/layers/Pois.java | 285 +++++++++--------- 1 file changed, 138 insertions(+), 147 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 e4ae3b01..bfac1287 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -168,8 +168,29 @@ public Pois(QrankDb qrankDb) { private static final MultiExpression.Index> zoomsIndex = MultiExpression.ofOrdered(List.of( - // Everything is zoom=15 at first - rule(use("minZoom", 15)), + // Everything with a point or a valid tag is zoom=15 at first + rule( + Expression.or( + withPoint(), + with("aeroway", "aerodrome"), + with("amenity"), + with("attraction"), + with("boundary", "national_park", "protected_area"), + with("craft"), + with("highway", "bus_stop"), + with("historic"), + with("landuse", "cemetery", "recreation_ground", "winter_sports", "quarry", "park", "forest", "military", + "village_green", "allotments"), + with("leisure"), + with("natural", "beach", "peak"), + with("railway", "station"), + with("shop"), + Expression.and(with("tourism"), without("historic", "district")) + ), + use("minZoom", 15) + ), + + // Fine-tune lots of specific categories rule(with(KIND_ATTR, "national_park"), use("minZoom", 11)), rule(with("natural", "peak"), use("minZoom", 13)), @@ -185,14 +206,7 @@ public Pois(QrankDb qrankDb) { rule(with("amenity", "hospital"), use("minZoom", 12)), rule(with("amenity", "university", "college"), use("minZoom", 14)), // One would think University should be earlier, but there are lots of dinky node only places, so if the university has a large area, it'll naturally improve its zoom in another section... rule(with("aeroway", "aerodrome"), use("minZoom", 13)), - - // Emphasize large international airports earlier - rule( - with("aeroway", "aerodrome"), - with(KIND_ATTR, "aerodrome"), - with("iata"), - use("minZoom", 11) - ), + rule(with("aeroway", "aerodrome"), with(KIND_ATTR, "aerodrome"), with("iata"), use("minZoom", 11)), // Emphasize large international airports earlier rule( withPoint(), @@ -216,6 +230,7 @@ public Pois(QrankDb qrankDb) { ), // Some features should only be visible at very late zooms when they don't have a name + rule( withPoint(), without("name"), @@ -321,7 +336,7 @@ public Pois(QrankDb qrankDb) { rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 4e6, 1e7), use("minZoom", 8)), rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1e7), use("minZoom", 7)), - // How are these similar? + // Remaining grab-bag of scaled kinds rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), @@ -366,176 +381,152 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, Map computedTags; if (hasNamedPolygon) { - computedTags = Map.of( - KIND_ATTR, kind, - HAS_NAMED_POLYGON, true, - WAYAREA_ATTR, wayArea, - HEIGHT_ATTR, height - ); + computedTags = Map.of(KIND_ATTR, kind, WAYAREA_ATTR, wayArea, HEIGHT_ATTR, height, HAS_NAMED_POLYGON, true); } else { - computedTags = Map.of( - KIND_ATTR, kind, - WAYAREA_ATTR, wayArea, - HEIGHT_ATTR, height - ); + computedTags = Map.of(KIND_ATTR, kind, WAYAREA_ATTR, wayArea, HEIGHT_ATTR, height); } return new Matcher.SourceFeatureWithComputedTags(sf, computedTags); } public void processOsm(SourceFeature sf, FeatureCollector features) { + // We only do points and polygons for POI labels + if (!sf.isPoint() && !sf.canBePolygon()) + return; + + // Map the Protomaps "kind" classification to incoming tags var kindMatches = kindsIndex.getMatches(sf); - if (kindMatches.isEmpty()) { + if (kindMatches.isEmpty()) return; - } // Calculate dimensions and create a wrapper with computed tags var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", "undefined")); var zoomMatches = zoomsIndex.getMatches(sf2); - if (zoomMatches.isEmpty()) { + if (zoomMatches.isEmpty()) return; - } String kind = getString(sf2, kindMatches, "kind", "undefined"); String kindDetail = getString(sf2, kindMatches, "kindDetail", "undefined"); Integer minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); - if ((sf.isPoint() || sf.canBePolygon()) && (sf.hasTag("aeroway", "aerodrome") || - sf.hasTag("amenity") || - sf.hasTag("attraction") || - sf.hasTag("boundary", "national_park", "protected_area") || - sf.hasTag("craft") || - sf.hasTag("historic") || - sf.hasTag("landuse", "cemetery", "recreation_ground", "winter_sports", "quarry", "park", "forest", "military", - "village_green", "allotments") || - sf.hasTag("leisure") || - sf.hasTag("natural", "beach", "peak") || - sf.hasTag("railway", "station") || - sf.hasTag("highway", "bus_stop") || - sf.hasTag("shop") || - sf.hasTag("tourism") && - (!sf.hasTag("historic", "district")))) { - long qrank = 0; - - String wikidata = sf.getString("wikidata"); - if (wikidata != null) { - qrank = qrankDb.get(wikidata); - } + long qrank = 0; + + String wikidata = sf.getString("wikidata"); + if (wikidata != null) { + qrank = qrankDb.get(wikidata); + } - // try first for polygon -> point representations - if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - - // Emphasize large international airports earlier - // Because the area grading resets the earlier dispensation - if (kind.equals("aerodrome")) { - if (sf.hasTag("iata")) { - // prioritize international airports over regional airports - minZoom -= 2; - - // but don't show international airports tooooo early - if (minZoom < 10) { - minZoom = 10; - } - } else { - // and show other airports only once their polygon begins to be visible - if (minZoom < 12) { - minZoom = 12; - } + // try first for polygon -> point representations + if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { + // Emphasize large international airports earlier + // Because the area grading resets the earlier dispensation + if (kind.equals("aerodrome")) { + if (sf.hasTag("iata")) { + // prioritize international airports over regional airports + minZoom -= 2; + + // but don't show international airports tooooo early + if (minZoom < 10) { + minZoom = 10; + } + } else { + // and show other airports only once their polygon begins to be visible + if (minZoom < 12) { + minZoom = 12; } } + } - // Discount wilderness areas within US national forests and parks - if (kind.equals("nature_reserve") && sf.getString("name").contains("Wilderness")) { - minZoom += 1; - } + // Discount wilderness areas within US national forests and parks + if (kind.equals("nature_reserve") && sf.getString("name").contains("Wilderness")) { + minZoom += 1; + } - // very long text names should only be shown at later zooms - if (minZoom < 14) { - var nameLength = sf.getString("name").length(); + // very long text names should only be shown at later zooms + if (minZoom < 14) { + var nameLength = sf.getString("name").length(); - if (nameLength > 45) { - minZoom += 2; - } else if (nameLength > 30) { - minZoom += 1; - } + if (nameLength > 45) { + minZoom += 2; + } else if (nameLength > 30) { + minZoom += 1; } + } - var rankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); - if (rankedZoom.isPresent()) - minZoom = rankedZoom.get(); - - var polyLabelPosition = features.pointOnSurface(this.name()) - // all POIs should receive their IDs at all zooms - // (there is no merging of POIs like with lines and polygons in other layers) - .setId(FeatureId.create(sf)) - // Core Tilezen schema properties - .setAttr("kind", kind) - // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions - // 512 px zooms versus 256 px logical zooms - .setAttr("min_zoom", minZoom + 1) - // - // DEBUG - //.setAttr("area_debug", wayArea) - // - // 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")) - // Extra OSM tags for certain kinds of places - // These are duplicate of what's in the kind_detail tag - .setBufferPixels(8) - .setZoomRange(Math.min(15, minZoom), 15); + var rankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); + if (rankedZoom.isPresent()) + minZoom = rankedZoom.get(); + var polyLabelPosition = features.pointOnSurface(this.name()) + // all POIs should receive their IDs at all zooms + // (there is no merging of POIs like with lines and polygons in other layers) + .setId(FeatureId.create(sf)) // Core Tilezen schema properties - if (!kindDetail.isEmpty()) { - polyLabelPosition.setAttr("kind_detail", kindDetail); - } + .setAttr("kind", kind) + // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions + // 512 px zooms versus 256 px logical zooms + .setAttr("min_zoom", minZoom + 1) + // + // DEBUG + //.setAttr("area_debug", wayArea) + // + // 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")) + // Extra OSM tags for certain kinds of places + // These are duplicate of what's in the kind_detail tag + .setBufferPixels(8) + .setZoomRange(Math.min(15, minZoom), 15); + + // Core Tilezen schema properties + if (!kindDetail.isEmpty()) { + polyLabelPosition.setAttr("kind_detail", kindDetail); + } - OsmNames.setOsmNames(polyLabelPosition, sf, 0); - - // Server sort features so client label collisions are pre-sorted - // NOTE: (nvkelso 20230627) This could also include other params like the name - polyLabelPosition.setSortKey(minZoom * 1000); - - // Even with the categorical zoom bucketing above, we end up with too dense a point feature spread in downtown - // areas, so cull the labels which wouldn't label at earlier zooms than the max_zoom of 15 - polyLabelPosition.setPointLabelGridSizeAndLimit(14, 8, 1); - - } else if (sf.isPoint()) { - var rankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); - if (rankedZoom.isPresent()) - minZoom = rankedZoom.get(); - - var pointFeature = features.point(this.name()) - // all POIs should receive their IDs at all zooms - // (there is no merging of POIs like with lines and polygons in other layers) - .setId(FeatureId.create(sf)) - // Core Tilezen schema properties - .setAttr("kind", kind) - // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions - // 512 px zooms versus 256 px logical zooms - .setAttr("min_zoom", minZoom + 1) - // 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")) - .setBufferPixels(8) - .setZoomRange(Math.min(minZoom, 15), 15); + OsmNames.setOsmNames(polyLabelPosition, sf, 0); - // Core Tilezen schema properties - if (!kindDetail.isEmpty()) { - pointFeature.setAttr("kind_detail", kindDetail); - } + // Server sort features so client label collisions are pre-sorted + // NOTE: (nvkelso 20230627) This could also include other params like the name + polyLabelPosition.setSortKey(minZoom * 1000); - OsmNames.setOsmNames(pointFeature, sf, 0); + // Even with the categorical zoom bucketing above, we end up with too dense a point feature spread in downtown + // areas, so cull the labels which wouldn't label at earlier zooms than the max_zoom of 15 + polyLabelPosition.setPointLabelGridSizeAndLimit(14, 8, 1); - // Server sort features so client label collisions are pre-sorted - // NOTE: (nvkelso 20230627) This could also include other params like the name - pointFeature.setSortKey(minZoom * 1000); + } else if (sf.isPoint()) { + var rankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); + if (rankedZoom.isPresent()) + minZoom = rankedZoom.get(); - // Even with the categorical zoom bucketing above, we end up with too dense a point feature spread in downtown - // areas, so cull the labels which wouldn't label at earlier zooms than the max_zoom of 15 - pointFeature.setPointLabelGridSizeAndLimit(14, 8, 1); - } + var pointFeature = features.point(this.name()) + // all POIs should receive their IDs at all zooms + // (there is no merging of POIs like with lines and polygons in other layers) + .setId(FeatureId.create(sf)) + // Core Tilezen schema properties + .setAttr("kind", kind) + // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions + // 512 px zooms versus 256 px logical zooms + .setAttr("min_zoom", minZoom + 1) + // 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")) + .setBufferPixels(8) + .setZoomRange(Math.min(minZoom, 15), 15); + + // Core Tilezen schema properties + if (!kindDetail.isEmpty()) + pointFeature.setAttr("kind_detail", kindDetail); + + OsmNames.setOsmNames(pointFeature, sf, 0); + + // Server sort features so client label collisions are pre-sorted + // NOTE: (nvkelso 20230627) This could also include other params like the name + pointFeature.setSortKey(minZoom * 1000); + + // Even with the categorical zoom bucketing above, we end up with too dense a point feature spread in downtown + // areas, so cull the labels which wouldn't label at earlier zooms than the max_zoom of 15 + pointFeature.setPointLabelGridSizeAndLimit(14, 8, 1); } } From 5dcb54886084d5e296a1a3facaaacd591f61790c Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 12:26:59 -0800 Subject: [PATCH 19/76] De-duped some final logic to clarify identical point/polygon POI behavior --- .../com/protomaps/basemap/layers/Pois.java | 70 ++++++------------- 1 file changed, 20 insertions(+), 50 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 bfac1287..8bcb5a79 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -405,19 +405,20 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { if (zoomMatches.isEmpty()) return; + // Output feature and its basic values to assign + FeatureCollector.Feature pointFeature = null; String kind = getString(sf2, kindMatches, "kind", "undefined"); String kindDetail = getString(sf2, kindMatches, "kindDetail", "undefined"); Integer minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); - long qrank = 0; - + // QRank may override minZoom entirely String wikidata = sf.getString("wikidata"); - if (wikidata != null) { - qrank = qrankDb.get(wikidata); - } + long qrank = (wikidata != null) ? qrankDb.get(wikidata) : 0; + var qrankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); // try first for polygon -> point representations if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { + // Emphasize large international airports earlier // Because the area grading resets the earlier dispensation if (kind.equals("aerodrome")) { @@ -453,53 +454,21 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } - var rankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); - if (rankedZoom.isPresent()) - minZoom = rankedZoom.get(); - - var polyLabelPosition = features.pointOnSurface(this.name()) - // all POIs should receive their IDs at all zooms - // (there is no merging of POIs like with lines and polygons in other layers) - .setId(FeatureId.create(sf)) - // Core Tilezen schema properties - .setAttr("kind", kind) - // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions - // 512 px zooms versus 256 px logical zooms - .setAttr("min_zoom", minZoom + 1) - // + pointFeature = features.pointOnSurface(this.name()) // DEBUG //.setAttr("area_debug", wayArea) - // - // 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")) - // Extra OSM tags for certain kinds of places - // These are duplicate of what's in the kind_detail tag - .setBufferPixels(8) - .setZoomRange(Math.min(15, minZoom), 15); - - // Core Tilezen schema properties - if (!kindDetail.isEmpty()) { - polyLabelPosition.setAttr("kind_detail", kindDetail); - } - - OsmNames.setOsmNames(polyLabelPosition, sf, 0); - - // Server sort features so client label collisions are pre-sorted - // NOTE: (nvkelso 20230627) This could also include other params like the name - polyLabelPosition.setSortKey(minZoom * 1000); - - // Even with the categorical zoom bucketing above, we end up with too dense a point feature spread in downtown - // areas, so cull the labels which wouldn't label at earlier zooms than the max_zoom of 15 - polyLabelPosition.setPointLabelGridSizeAndLimit(14, 8, 1); + .setAttr("elevation", sf.getString("ele")); } else if (sf.isPoint()) { - var rankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); - if (rankedZoom.isPresent()) - minZoom = rankedZoom.get(); + pointFeature = features.point(this.name()); + } - var pointFeature = features.point(this.name()) + if (pointFeature != null) { + // Override minZoom with QRank entirely + if (qrankedZoom.isPresent()) + minZoom = qrankedZoom.get(); + + pointFeature // all POIs should receive their IDs at all zooms // (there is no merging of POIs like with lines and polygons in other layers) .setId(FeatureId.create(sf)) @@ -508,11 +477,12 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions // 512 px zooms versus 256 px logical zooms .setAttr("min_zoom", minZoom + 1) + // + .setBufferPixels(8) + .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")) - .setBufferPixels(8) - .setZoomRange(Math.min(minZoom, 15), 15); + .setAttr("iata", sf.getString("iata")); // Core Tilezen schema properties if (!kindDetail.isEmpty()) From 3a569b64bcbfdffba8819f1f211583335645c939 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 12:42:02 -0800 Subject: [PATCH 20/76] Renamed and organized zoom rules for legibility --- .../com/protomaps/basemap/layers/Pois.java | 190 +++++++++--------- 1 file changed, 100 insertions(+), 90 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 8bcb5a79..3533fc44 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -48,9 +48,9 @@ public Pois(QrankDb qrankDb) { public static final String LAYER_NAME = "pois"; // Internal tags used to reference calculated values between matchers - private static final String KIND_ATTR = "protomaps-basemaps:kind"; - private static final String WAYAREA_ATTR = "protomaps-basemaps:wayArea"; - private static final String HEIGHT_ATTR = "protomaps-basemaps:height"; + private static final String KIND = "protomaps-basemaps:kind"; + private static final String WAYAREA = "protomaps-basemaps:wayArea"; + private static final String HEIGHT = "protomaps-basemaps:height"; private static final String HAS_NAMED_POLYGON = "protomaps-basemaps:hasNamedPolygon"; private static final Expression WITH_OPERATOR_USFS = with("operator", "United States Forest Service", @@ -154,17 +154,19 @@ public Pois(QrankDb qrankDb) { )).index(); + // Shorthand expressions to save space below + private static final Expression with_named_polygon = with(HAS_NAMED_POLYGON); private static final Expression with_s_c_named_poly = - Expression.and(with_named_polygon, with(KIND_ATTR, "cemetery", "school")); + Expression.and(with_named_polygon, with(KIND, "cemetery", "school")); private static final Expression with_n_p_named_poly = - Expression.and(with_named_polygon, with(KIND_ATTR, "national_park")); + Expression.and(with_named_polygon, with(KIND, "national_park")); private static final Expression with_c_u_named_poly = - Expression.and(with_named_polygon, with(KIND_ATTR, "college", "university")); + Expression.and(with_named_polygon, with(KIND, "college", "university")); private static final Expression with_b_g_named_poly = Expression.and(with_named_polygon, - with(KIND_ATTR, "forest", "park", "protected_area", "nature_reserve", "village_green")); + with(KIND, "forest", "park", "protected_area", "nature_reserve", "village_green")); private static final Expression with_etc_named_poly = Expression.and(with_named_polygon, - with(KIND_ATTR, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo")); + with(KIND, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo")); private static final MultiExpression.Index> zoomsIndex = MultiExpression.ofOrdered(List.of( @@ -190,24 +192,35 @@ public Pois(QrankDb qrankDb) { use("minZoom", 15) ), - // Fine-tune lots of specific categories + // Promote important point categories to earlier zooms - rule(with(KIND_ATTR, "national_park"), use("minZoom", 11)), - rule(with("natural", "peak"), use("minZoom", 13)), - rule(with("highway", "bus_stop"), use("minZoom", 17)), - rule(with("tourism", "attraction", "camp_site", "hotel"), use("minZoom", 15)), - rule(with("shop", "grocery", "supermarket"), use("minZoom", 14)), - rule(with("leisure", "golf_course", "marina", "stadium"), use("minZoom", 13)), - rule(with("leisure", "park"), use("minZoom", 14)), // Lots of pocket parks and NODE parks, show those later than rest of leisure - rule(with("landuse", "cemetery"), use("minZoom", 14)), - rule(with("amenity", "cafe"), use("minZoom", 15)), - rule(with("amenity", "school"), use("minZoom", 15)), - rule(with("amenity", "library", "post_office", "townhall"), use("minZoom", 13)), - rule(with("amenity", "hospital"), use("minZoom", 12)), - rule(with("amenity", "university", "college"), use("minZoom", 14)), // One would think University should be earlier, but there are lots of dinky node only places, so if the university has a large area, it'll naturally improve its zoom in another section... - rule(with("aeroway", "aerodrome"), use("minZoom", 13)), - rule(with("aeroway", "aerodrome"), with(KIND_ATTR, "aerodrome"), with("iata"), use("minZoom", 11)), // Emphasize large international airports earlier + rule( + withPoint(), + Expression.or( + with("amenity", "university", "college"), // One would think University should be earlier, but there are lots of dinky node only places, so if the university has a large area, it'll naturally improve its zoom in another section... + with("landuse", "cemetery"), + with("leisure", "park"), // Lots of pocket parks and NODE parks, show those later than rest of leisure + with("shop", "grocery", "supermarket") + ), + use("minZoom", 14) + ), + rule( + withPoint(), + Expression.or( + with("aeroway", "aerodrome"), + with("amenity", "library", "post_office", "townhall"), + with("leisure", "golf_course", "marina", "stadium"), + with("natural", "peak") + ), + use("minZoom", 13) + ), + rule(withPoint(), with("amenity", "hospital"), use("minZoom", 12)), + rule(withPoint(), with(KIND, "national_park"), use("minZoom", 11)), + rule(withPoint(), with("aeroway", "aerodrome"), with(KIND, "aerodrome"), with("iata"), use("minZoom", 11)), // Emphasize large international airports earlier + // Demote some unimportant point categories to very late zooms + + rule(with("highway", "bus_stop"), use("minZoom", 17)), rule( withPoint(), Expression.or( @@ -229,7 +242,7 @@ public Pois(QrankDb qrankDb) { use("minZoom", 16) ), - // Some features should only be visible at very late zooms when they don't have a name + // Demote some unnamed point categories to very late zooms rule( withPoint(), @@ -251,101 +264,98 @@ public Pois(QrankDb qrankDb) { // Size-graded polygons, generic at first then per-kind adjustments - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 500), use("minZoom", 14)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 500, 2000), use("minZoom", 13)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 2000, 1e4), use("minZoom", 12)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 1e4), use("minZoom", 11)), + rule(with_named_polygon, withinRange(WAYAREA, 10, 500), use("minZoom", 14)), + rule(with_named_polygon, withinRange(WAYAREA, 500, 2000), use("minZoom", 13)), + rule(with_named_polygon, withinRange(WAYAREA, 2000, 1e4), use("minZoom", 12)), + rule(with_named_polygon, withinRange(WAYAREA, 1e4), use("minZoom", 11)), - rule(with_named_polygon, with(KIND_ATTR, "playground"), use("minZoom", 17)), - rule(with_named_polygon, with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), - rule(with_named_polygon, with(KIND_ATTR, "allotments"), withinRange(WAYAREA_ATTR, 10), use("minZoom", 15)), + rule(with_named_polygon, with(KIND, "playground"), use("minZoom", 17)), + rule(with_named_polygon, with(KIND, "allotments"), withinRange(WAYAREA, 0, 10), use("minZoom", 16)), + rule(with_named_polygon, with(KIND, "allotments"), withinRange(WAYAREA, 10), use("minZoom", 15)), // Height-graded polygons, generic at first then per-kind adjustments - // Small but tall features should show up early as they have regional prominance. + // Small but tall features should show up early as they have regional prominence. // Height measured in meters - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 10, 20), use("minZoom", 13)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 20, 100), - use("minZoom", 12)), - rule(with_named_polygon, withinRange(WAYAREA_ATTR, 10, 2000), withinRange(HEIGHT_ATTR, 100), - use("minZoom", 11)), + rule(with_named_polygon, withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 10, 20), use("minZoom", 13)), + rule(with_named_polygon, withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 20, 100), use("minZoom", 12)), + rule(with_named_polygon, withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 100), use("minZoom", 11)), // Clamp certain kind values so medium tall buildings don't crowd downtown areas // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer // NOTE: (nvkelso 20230624) Turn this into an allowlist instead of a blocklist rule( with_named_polygon, - with(KIND_ATTR, "hotel", "hostel", "parking", "bank", "place_of_worship", "jewelry", "yes", "restaurant", + with(KIND, "hotel", "hostel", "parking", "bank", "place_of_worship", "jewelry", "yes", "restaurant", "coworking_space", "clothes", "art", "school"), - withinRange(WAYAREA_ATTR, 10, 2000), - withinRange(HEIGHT_ATTR, 20, 100), + withinRange(WAYAREA, 10, 2000), + withinRange(HEIGHT, 20, 100), use("minZoom", 13) ), // Discount tall self storage buildings - rule(with_named_polygon, with(KIND_ATTR, "storage_rental"), withinRange(WAYAREA_ATTR, 10, 2000), - use("minZoom", 14)), + rule(with_named_polygon, with(KIND, "storage_rental"), withinRange(WAYAREA, 10, 2000), use("minZoom", 14)), // Discount tall university buildings, require a related university landuse AOI - rule(with_named_polygon, with(KIND_ATTR, "university"), withinRange(WAYAREA_ATTR, 10, 2000), use("minZoom", 13)), + rule(with_named_polygon, with(KIND, "university"), withinRange(WAYAREA, 10, 2000), use("minZoom", 13)), // Schools & Cemeteries - rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 0, 10), use("minZoom", 16)), - rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 10, 100), use("minZoom", 15)), - rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 100, 1000), use("minZoom", 14)), - rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_s_c_named_poly, withinRange(WAYAREA_ATTR, 5000), use("minZoom", 12)), + rule(with_s_c_named_poly, withinRange(WAYAREA, 0, 10), use("minZoom", 16)), + rule(with_s_c_named_poly, withinRange(WAYAREA, 10, 100), use("minZoom", 15)), + rule(with_s_c_named_poly, withinRange(WAYAREA, 100, 1000), use("minZoom", 14)), + rule(with_s_c_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_s_c_named_poly, withinRange(WAYAREA, 5000), use("minZoom", 12)), // National parks - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 0, 250), use("minZoom", 17)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 5000, 2e4), use("minZoom", 12)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2e4, 1e5), use("minZoom", 11)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 1e5, 2.5e5), use("minZoom", 10)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 2e6), use("minZoom", 9)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2e6, 1e7), use("minZoom", 8)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 1e7, 2.5e7), use("minZoom", 7)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 2.5e7, 3e8), use("minZoom", 6)), - rule(with_n_p_named_poly, withinRange(WAYAREA_ATTR, 3e8), use("minZoom", 5)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 0, 250), use("minZoom", 17)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 2.5e5, 2e6), use("minZoom", 9)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 2e6, 1e7), use("minZoom", 8)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 1e7, 2.5e7), use("minZoom", 7)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 2.5e7, 3e8), use("minZoom", 6)), + rule(with_n_p_named_poly, withinRange(WAYAREA, 3e8), use("minZoom", 5)), // College and university polygons - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 0, 5000), use("minZoom", 15)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5000, 2e4), use("minZoom", 14)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 2e4, 5e4), use("minZoom", 13)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5e4, 1e5), use("minZoom", 12)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 1e5, 1.5e5), use("minZoom", 11)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 1.5e5, 2.5e5), use("minZoom", 10)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 5e6), use("minZoom", 9)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 5e6, 2e7), use("minZoom", 8)), - rule(with_c_u_named_poly, withinRange(WAYAREA_ATTR, 2e7), use("minZoom", 7)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 0, 5000), use("minZoom", 15)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 14)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 2e4, 5e4), use("minZoom", 13)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 5e4, 1e5), use("minZoom", 12)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 1e5, 1.5e5), use("minZoom", 11)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 1.5e5, 2.5e5), use("minZoom", 10)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), + rule(with_c_u_named_poly, withinRange(WAYAREA, 2e7), use("minZoom", 7)), rule(with_c_u_named_poly, with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university // Big green polygons - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 0, 1), use("minZoom", 17)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1, 10), use("minZoom", 16)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 10, 250), use("minZoom", 15)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 5000, 1.5e4), use("minZoom", 12)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1.5e4, 2.5e5), use("minZoom", 11)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 1e6), use("minZoom", 10)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1e6, 4e6), use("minZoom", 9)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 4e6, 1e7), use("minZoom", 8)), - rule(with_b_g_named_poly, withinRange(WAYAREA_ATTR, 1e7), use("minZoom", 7)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 0, 1), use("minZoom", 17)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 1, 10), use("minZoom", 16)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 10, 250), use("minZoom", 15)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 5000, 1.5e4), use("minZoom", 12)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 1.5e4, 2.5e5), use("minZoom", 11)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 2.5e5, 1e6), use("minZoom", 10)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 1e6, 4e6), use("minZoom", 9)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 4e6, 1e7), use("minZoom", 8)), + rule(with_b_g_named_poly, withinRange(WAYAREA, 1e7), use("minZoom", 7)), // Remaining grab-bag of scaled kinds - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 250, 1000), use("minZoom", 14)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 1000, 5000), use("minZoom", 13)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5000, 2e4), use("minZoom", 12)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 2e4, 1e5), use("minZoom", 11)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 1e5, 2.5e5), use("minZoom", 10)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 2.5e5, 5e6), use("minZoom", 9)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 5e6, 2e7), use("minZoom", 8)), - rule(with_etc_named_poly, withinRange(WAYAREA_ATTR, 2e7), use("minZoom", 7)) + rule(with_etc_named_poly, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), + rule(with_etc_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_etc_named_poly, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), + rule(with_etc_named_poly, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), + rule(with_etc_named_poly, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), + rule(with_etc_named_poly, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), + rule(with_etc_named_poly, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), + rule(with_etc_named_poly, withinRange(WAYAREA, 2e7), use("minZoom", 7)) )).index(); @@ -381,9 +391,9 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, Map computedTags; if (hasNamedPolygon) { - computedTags = Map.of(KIND_ATTR, kind, WAYAREA_ATTR, wayArea, HEIGHT_ATTR, height, HAS_NAMED_POLYGON, true); + computedTags = Map.of(KIND, kind, WAYAREA, wayArea, HEIGHT, height, HAS_NAMED_POLYGON, true); } else { - computedTags = Map.of(KIND_ATTR, kind, WAYAREA_ATTR, wayArea, HEIGHT_ATTR, height); + computedTags = Map.of(KIND, kind, WAYAREA, wayArea, HEIGHT, height); } return new Matcher.SourceFeatureWithComputedTags(sf, computedTags); From 2525a5c9a2c32dc69be1034cc3ffe4c65a846a59 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 12:54:16 -0800 Subject: [PATCH 21/76] Tweak, tweak, tweak --- .../com/protomaps/basemap/layers/Pois.java | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 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 3533fc44..e713dd89 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -400,7 +400,7 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, } public void processOsm(SourceFeature sf, FeatureCollector features) { - // We only do points and polygons for POI labels + // We only do POI display for points and polygons if (!sf.isPoint() && !sf.canBePolygon()) return; @@ -416,7 +416,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { return; // Output feature and its basic values to assign - FeatureCollector.Feature pointFeature = null; + FeatureCollector.Feature outputFeature = null; String kind = getString(sf2, kindMatches, "kind", "undefined"); String kindDetail = getString(sf2, kindMatches, "kindDetail", "undefined"); Integer minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); @@ -426,7 +426,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { long qrank = (wikidata != null) ? qrankDb.get(wikidata) : 0; var qrankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); - // try first for polygon -> point representations if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { // Emphasize large international airports earlier @@ -464,50 +463,50 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } - pointFeature = features.pointOnSurface(this.name()) - // DEBUG - //.setAttr("area_debug", wayArea) + outputFeature = features.pointOnSurface(this.name()) + //.setAttr("area_debug", wayArea) // DEBUG .setAttr("elevation", sf.getString("ele")); } else if (sf.isPoint()) { - pointFeature = features.point(this.name()); + outputFeature = features.point(this.name()); + } else { + return; } - if (pointFeature != null) { - // Override minZoom with QRank entirely - if (qrankedZoom.isPresent()) - minZoom = qrankedZoom.get(); - - pointFeature - // all POIs should receive their IDs at all zooms - // (there is no merging of POIs like with lines and polygons in other layers) - .setId(FeatureId.create(sf)) - // Core Tilezen schema properties - .setAttr("kind", kind) - // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions - // 512 px zooms versus 256 px logical zooms - .setAttr("min_zoom", minZoom + 1) - // - .setBufferPixels(8) - .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")); + // Override minZoom with QRank entirely + if (qrankedZoom.isPresent()) + minZoom = qrankedZoom.get(); + // Populate final outputFeature attributes + outputFeature + // all POIs should receive their IDs at all zooms + // (there is no merging of POIs like with lines and polygons in other layers) + .setId(FeatureId.create(sf)) // Core Tilezen schema properties - if (!kindDetail.isEmpty()) - pointFeature.setAttr("kind_detail", kindDetail); - - OsmNames.setOsmNames(pointFeature, sf, 0); - - // Server sort features so client label collisions are pre-sorted - // NOTE: (nvkelso 20230627) This could also include other params like the name - pointFeature.setSortKey(minZoom * 1000); - - // Even with the categorical zoom bucketing above, we end up with too dense a point feature spread in downtown - // areas, so cull the labels which wouldn't label at earlier zooms than the max_zoom of 15 - pointFeature.setPointLabelGridSizeAndLimit(14, 8, 1); - } + .setAttr("kind", kind) + // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions + // 512 px zooms versus 256 px logical zooms + .setAttr("min_zoom", minZoom + 1) + // + .setBufferPixels(8) + .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")); + + // Core Tilezen schema properties + if (!kindDetail.isEmpty()) + outputFeature.setAttr("kind_detail", kindDetail); + + OsmNames.setOsmNames(outputFeature, sf, 0); + + // Server sort features so client label collisions are pre-sorted + // NOTE: (nvkelso 20230627) This could also include other params like the name + outputFeature.setSortKey(minZoom * 1000); + + // Even with the categorical zoom bucketing above, we end up with too dense a point feature spread in downtown + // areas, so cull the labels which wouldn't label at earlier zooms than the max_zoom of 15 + outputFeature.setPointLabelGridSizeAndLimit(14, 8, 1); } @Override From 1982412b6b917b12565c1a685b3eb0a7edfbab63 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 13:06:18 -0800 Subject: [PATCH 22/76] More tweak, tweak, tweak --- .../com/protomaps/basemap/layers/Pois.java | 98 ++++++++++--------- 1 file changed, 52 insertions(+), 46 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 e713dd89..2761e0d9 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -400,8 +400,8 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, } public void processOsm(SourceFeature sf, FeatureCollector features) { - // We only do POI display for points and polygons - if (!sf.isPoint() && !sf.canBePolygon()) + // We only do POI display for points and named polygons + if (!(sf.isPoint() || sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null)) return; // Map the Protomaps "kind" classification to incoming tags @@ -409,74 +409,80 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { if (kindMatches.isEmpty()) return; - // Calculate dimensions and create a wrapper with computed tags - var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", "undefined")); - var zoomMatches = zoomsIndex.getMatches(sf2); - if (zoomMatches.isEmpty()) - return; - // Output feature and its basic values to assign - FeatureCollector.Feature outputFeature = null; - String kind = getString(sf2, kindMatches, "kind", "undefined"); - String kindDetail = getString(sf2, kindMatches, "kindDetail", "undefined"); - Integer minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); + FeatureCollector.Feature outputFeature; + String kind = getString(sf, kindMatches, "kind", "undefined"); + String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); + Integer minZoom; // QRank may override minZoom entirely String wikidata = sf.getString("wikidata"); long qrank = (wikidata != null) ? qrankDb.get(wikidata) : 0; var qrankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); - if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - - // Emphasize large international airports earlier - // Because the area grading resets the earlier dispensation - if (kind.equals("aerodrome")) { - if (sf.hasTag("iata")) { - // prioritize international airports over regional airports - minZoom -= 2; - - // but don't show international airports tooooo early - if (minZoom < 10) { - minZoom = 10; - } - } else { - // and show other airports only once their polygon begins to be visible - if (minZoom < 12) { - minZoom = 12; + if (qrankedZoom.isPresent()) { + // Set minZoom from QRank + minZoom = qrankedZoom.get(); + } else { + // Calculate minZoom using zoomsIndex + var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", "undefined")); + var zoomMatches = zoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + // Initial minZoom + minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); + + // Adjusted minZoom + if (sf.canBePolygon()) { + // Emphasize large international airports earlier + // Because the area grading resets the earlier dispensation + if (kind.equals("aerodrome")) { + if (sf.hasTag("iata")) { + // prioritize international airports over regional airports + minZoom -= 2; + + // but don't show international airports tooooo early + if (minZoom < 10) { + minZoom = 10; + } + } else { + // and show other airports only once their polygon begins to be visible + if (minZoom < 12) { + minZoom = 12; + } } } - } - // Discount wilderness areas within US national forests and parks - if (kind.equals("nature_reserve") && sf.getString("name").contains("Wilderness")) { - minZoom += 1; - } + // Discount wilderness areas within US national forests and parks + if (kind.equals("nature_reserve") && sf.getString("name").contains("Wilderness")) { + minZoom += 1; + } - // very long text names should only be shown at later zooms - if (minZoom < 14) { - var nameLength = sf.getString("name").length(); + // very long text names should only be shown at later zooms + if (minZoom < 14) { + var nameLength = sf.getString("name").length(); - if (nameLength > 45) { - minZoom += 2; - } else if (nameLength > 30) { - minZoom += 1; + if (nameLength > 45) { + minZoom += 2; + } else if (nameLength > 30) { + minZoom += 1; + } } } + } + // Assign outputFeature + if (sf.canBePolygon()) { outputFeature = features.pointOnSurface(this.name()) //.setAttr("area_debug", wayArea) // DEBUG .setAttr("elevation", sf.getString("ele")); - } else if (sf.isPoint()) { outputFeature = features.point(this.name()); } else { return; } - // Override minZoom with QRank entirely - if (qrankedZoom.isPresent()) - minZoom = qrankedZoom.get(); - // Populate final outputFeature attributes outputFeature // all POIs should receive their IDs at all zooms From f0ea92a16b37044935ac0da01d4ea2708bcffff0 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 13:17:10 -0800 Subject: [PATCH 23/76] Carved zoomsIndex into point-and-polygon-specific MultiExpressions --- .../com/protomaps/basemap/layers/Pois.java | 278 +++++++++--------- 1 file changed, 138 insertions(+), 140 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 2761e0d9..52e52ca4 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -7,7 +7,6 @@ import static com.protomaps.basemap.feature.Matcher.rule; import static com.protomaps.basemap.feature.Matcher.use; import static com.protomaps.basemap.feature.Matcher.with; -import static com.protomaps.basemap.feature.Matcher.withPoint; import static com.protomaps.basemap.feature.Matcher.withinRange; import static com.protomaps.basemap.feature.Matcher.without; @@ -156,46 +155,23 @@ public Pois(QrankDb qrankDb) { // Shorthand expressions to save space below - private static final Expression with_named_polygon = with(HAS_NAMED_POLYGON); - private static final Expression with_s_c_named_poly = - Expression.and(with_named_polygon, with(KIND, "cemetery", "school")); - private static final Expression with_n_p_named_poly = - Expression.and(with_named_polygon, with(KIND, "national_park")); - private static final Expression with_c_u_named_poly = - Expression.and(with_named_polygon, with(KIND, "college", "university")); - private static final Expression with_b_g_named_poly = Expression.and(with_named_polygon, - with(KIND, "forest", "park", "protected_area", "nature_reserve", "village_green")); - private static final Expression with_etc_named_poly = Expression.and(with_named_polygon, - with(KIND, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo")); - - private static final MultiExpression.Index> zoomsIndex = MultiExpression.ofOrdered(List.of( - - // Everything with a point or a valid tag is zoom=15 at first - rule( - Expression.or( - withPoint(), - with("aeroway", "aerodrome"), - with("amenity"), - with("attraction"), - with("boundary", "national_park", "protected_area"), - with("craft"), - with("highway", "bus_stop"), - with("historic"), - with("landuse", "cemetery", "recreation_ground", "winter_sports", "quarry", "park", "forest", "military", - "village_green", "allotments"), - with("leisure"), - with("natural", "beach", "peak"), - with("railway", "station"), - with("shop"), - Expression.and(with("tourism"), without("historic", "district")) - ), - use("minZoom", 15) - ), + private static final Expression with_s_c = with(KIND, "cemetery", "school"); + private static final Expression with_n_p = with(KIND, "national_park"); + private static final Expression with_c_u = with(KIND, "college", "university"); + private static final Expression with_b_g = + with(KIND, "forest", "park", "protected_area", "nature_reserve", "village_green"); + private static final Expression with_etc = + with(KIND, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"); + + private static final MultiExpression.Index> pointZoomsIndex = MultiExpression.ofOrdered(List.of( + + // Every point is zoom=15 at first + + rule(use("minZoom", 15)), // Promote important point categories to earlier zooms rule( - withPoint(), Expression.or( with("amenity", "university", "college"), // One would think University should be earlier, but there are lots of dinky node only places, so if the university has a large area, it'll naturally improve its zoom in another section... with("landuse", "cemetery"), @@ -205,7 +181,6 @@ public Pois(QrankDb qrankDb) { use("minZoom", 14) ), rule( - withPoint(), Expression.or( with("aeroway", "aerodrome"), with("amenity", "library", "post_office", "townhall"), @@ -214,15 +189,14 @@ public Pois(QrankDb qrankDb) { ), use("minZoom", 13) ), - rule(withPoint(), with("amenity", "hospital"), use("minZoom", 12)), - rule(withPoint(), with(KIND, "national_park"), use("minZoom", 11)), - rule(withPoint(), with("aeroway", "aerodrome"), with(KIND, "aerodrome"), with("iata"), use("minZoom", 11)), // Emphasize large international airports earlier + rule(with("amenity", "hospital"), use("minZoom", 12)), + rule(with(KIND, "national_park"), use("minZoom", 11)), + rule(with("aeroway", "aerodrome"), with(KIND, "aerodrome"), with("iata"), use("minZoom", 11)), // Emphasize large international airports earlier // Demote some unimportant point categories to very late zooms rule(with("highway", "bus_stop"), use("minZoom", 17)), rule( - withPoint(), Expression.or( with("amenity", "clinic", "dentist", "doctors", "social_facility", "baby_hatch", "childcare", "car_sharing", "bureau_de_change", "emergency_phone", "karaoke", "karaoke_box", "money_transfer", "car_wash", @@ -245,7 +219,6 @@ public Pois(QrankDb qrankDb) { // Demote some unnamed point categories to very late zooms rule( - withPoint(), without("name"), Expression.or( with("amenity", "atm", "bbq", "bench", "bicycle_parking", @@ -260,104 +233,129 @@ public Pois(QrankDb qrankDb) { with("tourism", "alpine_hut", "information", "picnic_site", "viewpoint", "wilderness_hut") ), use("minZoom", 16) - ), - - // Size-graded polygons, generic at first then per-kind adjustments - - rule(with_named_polygon, withinRange(WAYAREA, 10, 500), use("minZoom", 14)), - rule(with_named_polygon, withinRange(WAYAREA, 500, 2000), use("minZoom", 13)), - rule(with_named_polygon, withinRange(WAYAREA, 2000, 1e4), use("minZoom", 12)), - rule(with_named_polygon, withinRange(WAYAREA, 1e4), use("minZoom", 11)), - - rule(with_named_polygon, with(KIND, "playground"), use("minZoom", 17)), - rule(with_named_polygon, with(KIND, "allotments"), withinRange(WAYAREA, 0, 10), use("minZoom", 16)), - rule(with_named_polygon, with(KIND, "allotments"), withinRange(WAYAREA, 10), use("minZoom", 15)), - - // Height-graded polygons, generic at first then per-kind adjustments - // Small but tall features should show up early as they have regional prominence. - // Height measured in meters + ) - rule(with_named_polygon, withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 10, 20), use("minZoom", 13)), - rule(with_named_polygon, withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 20, 100), use("minZoom", 12)), - rule(with_named_polygon, withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 100), use("minZoom", 11)), - - // Clamp certain kind values so medium tall buildings don't crowd downtown areas - // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer - // NOTE: (nvkelso 20230624) Turn this into an allowlist instead of a blocklist - rule( - with_named_polygon, - with(KIND, "hotel", "hostel", "parking", "bank", "place_of_worship", "jewelry", "yes", "restaurant", - "coworking_space", "clothes", "art", "school"), - withinRange(WAYAREA, 10, 2000), - withinRange(HEIGHT, 20, 100), - use("minZoom", 13) - ), - // Discount tall self storage buildings - rule(with_named_polygon, with(KIND, "storage_rental"), withinRange(WAYAREA, 10, 2000), use("minZoom", 14)), - // Discount tall university buildings, require a related university landuse AOI - rule(with_named_polygon, with(KIND, "university"), withinRange(WAYAREA, 10, 2000), use("minZoom", 13)), - - // Schools & Cemeteries - - rule(with_s_c_named_poly, withinRange(WAYAREA, 0, 10), use("minZoom", 16)), - rule(with_s_c_named_poly, withinRange(WAYAREA, 10, 100), use("minZoom", 15)), - rule(with_s_c_named_poly, withinRange(WAYAREA, 100, 1000), use("minZoom", 14)), - rule(with_s_c_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_s_c_named_poly, withinRange(WAYAREA, 5000), use("minZoom", 12)), - - // National parks + )).index(); - rule(with_n_p_named_poly, withinRange(WAYAREA, 0, 250), use("minZoom", 17)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 2.5e5, 2e6), use("minZoom", 9)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 2e6, 1e7), use("minZoom", 8)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 1e7, 2.5e7), use("minZoom", 7)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 2.5e7, 3e8), use("minZoom", 6)), - rule(with_n_p_named_poly, withinRange(WAYAREA, 3e8), use("minZoom", 5)), - - // College and university polygons - - rule(with_c_u_named_poly, withinRange(WAYAREA, 0, 5000), use("minZoom", 15)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 14)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 2e4, 5e4), use("minZoom", 13)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 5e4, 1e5), use("minZoom", 12)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 1e5, 1.5e5), use("minZoom", 11)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 1.5e5, 2.5e5), use("minZoom", 10)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), - rule(with_c_u_named_poly, withinRange(WAYAREA, 2e7), use("minZoom", 7)), - rule(with_c_u_named_poly, with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university - - // Big green polygons - - rule(with_b_g_named_poly, withinRange(WAYAREA, 0, 1), use("minZoom", 17)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 1, 10), use("minZoom", 16)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 10, 250), use("minZoom", 15)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 5000, 1.5e4), use("minZoom", 12)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 1.5e4, 2.5e5), use("minZoom", 11)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 2.5e5, 1e6), use("minZoom", 10)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 1e6, 4e6), use("minZoom", 9)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 4e6, 1e7), use("minZoom", 8)), - rule(with_b_g_named_poly, withinRange(WAYAREA, 1e7), use("minZoom", 7)), - - // Remaining grab-bag of scaled kinds - - rule(with_etc_named_poly, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), - rule(with_etc_named_poly, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_etc_named_poly, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), - rule(with_etc_named_poly, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), - rule(with_etc_named_poly, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), - rule(with_etc_named_poly, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), - rule(with_etc_named_poly, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), - rule(with_etc_named_poly, withinRange(WAYAREA, 2e7), use("minZoom", 7)) + private static final MultiExpression.Index> namedPolygonZoomsIndex = + MultiExpression.ofOrdered(List.of( + + // Every named polygon with a valid tag is zoom=15 at first + rule( + Expression.or( + with("aeroway", "aerodrome"), + with("amenity"), + with("attraction"), + with("boundary", "national_park", "protected_area"), + with("craft"), + with("highway", "bus_stop"), + with("historic"), + with("landuse", "cemetery", "recreation_ground", "winter_sports", "quarry", "park", "forest", "military", + "village_green", "allotments"), + with("leisure"), + with("natural", "beach", "peak"), + with("railway", "station"), + with("shop"), + Expression.and(with("tourism"), without("historic", "district")) + ), + use("minZoom", 15) + ), - )).index(); + // Size-graded polygons, generic at first then per-kind adjustments + + rule(withinRange(WAYAREA, 10, 500), use("minZoom", 14)), + rule(withinRange(WAYAREA, 500, 2000), use("minZoom", 13)), + rule(withinRange(WAYAREA, 2000, 1e4), use("minZoom", 12)), + rule(withinRange(WAYAREA, 1e4), use("minZoom", 11)), + + rule(with(KIND, "playground"), use("minZoom", 17)), + rule(with(KIND, "allotments"), withinRange(WAYAREA, 0, 10), use("minZoom", 16)), + rule(with(KIND, "allotments"), withinRange(WAYAREA, 10), use("minZoom", 15)), + + // Height-graded polygons, generic at first then per-kind adjustments + // Small but tall features should show up early as they have regional prominence. + // Height measured in meters + + rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 10, 20), use("minZoom", 13)), + rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 20, 100), use("minZoom", 12)), + rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 100), use("minZoom", 11)), + + // Clamp certain kind values so medium tall buildings don't crowd downtown areas + // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer + // NOTE: (nvkelso 20230624) Turn this into an allowlist instead of a blocklist + rule( + with(KIND, "hotel", "hostel", "parking", "bank", "place_of_worship", "jewelry", "yes", "restaurant", + "coworking_space", "clothes", "art", "school"), + withinRange(WAYAREA, 10, 2000), + withinRange(HEIGHT, 20, 100), + use("minZoom", 13) + ), + // Discount tall self storage buildings + rule(with(KIND, "storage_rental"), withinRange(WAYAREA, 10, 2000), use("minZoom", 14)), + // Discount tall university buildings, require a related university landuse AOI + rule(with(KIND, "university"), withinRange(WAYAREA, 10, 2000), use("minZoom", 13)), + + // Schools & Cemeteries + + rule(with_s_c, withinRange(WAYAREA, 0, 10), use("minZoom", 16)), + rule(with_s_c, withinRange(WAYAREA, 10, 100), use("minZoom", 15)), + rule(with_s_c, withinRange(WAYAREA, 100, 1000), use("minZoom", 14)), + rule(with_s_c, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_s_c, withinRange(WAYAREA, 5000), use("minZoom", 12)), + + // National parks + + rule(with_n_p, withinRange(WAYAREA, 0, 250), use("minZoom", 17)), + rule(with_n_p, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), + rule(with_n_p, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_n_p, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), + rule(with_n_p, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), + rule(with_n_p, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), + rule(with_n_p, withinRange(WAYAREA, 2.5e5, 2e6), use("minZoom", 9)), + rule(with_n_p, withinRange(WAYAREA, 2e6, 1e7), use("minZoom", 8)), + rule(with_n_p, withinRange(WAYAREA, 1e7, 2.5e7), use("minZoom", 7)), + rule(with_n_p, withinRange(WAYAREA, 2.5e7, 3e8), use("minZoom", 6)), + rule(with_n_p, withinRange(WAYAREA, 3e8), use("minZoom", 5)), + + // College and university polygons + + rule(with_c_u, withinRange(WAYAREA, 0, 5000), use("minZoom", 15)), + rule(with_c_u, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 14)), + rule(with_c_u, withinRange(WAYAREA, 2e4, 5e4), use("minZoom", 13)), + rule(with_c_u, withinRange(WAYAREA, 5e4, 1e5), use("minZoom", 12)), + rule(with_c_u, withinRange(WAYAREA, 1e5, 1.5e5), use("minZoom", 11)), + rule(with_c_u, withinRange(WAYAREA, 1.5e5, 2.5e5), use("minZoom", 10)), + rule(with_c_u, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), + rule(with_c_u, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), + rule(with_c_u, withinRange(WAYAREA, 2e7), use("minZoom", 7)), + rule(with_c_u, with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university + + // Big green polygons + + rule(with_b_g, withinRange(WAYAREA, 0, 1), use("minZoom", 17)), + rule(with_b_g, withinRange(WAYAREA, 1, 10), use("minZoom", 16)), + rule(with_b_g, withinRange(WAYAREA, 10, 250), use("minZoom", 15)), + rule(with_b_g, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), + rule(with_b_g, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_b_g, withinRange(WAYAREA, 5000, 1.5e4), use("minZoom", 12)), + rule(with_b_g, withinRange(WAYAREA, 1.5e4, 2.5e5), use("minZoom", 11)), + rule(with_b_g, withinRange(WAYAREA, 2.5e5, 1e6), use("minZoom", 10)), + rule(with_b_g, withinRange(WAYAREA, 1e6, 4e6), use("minZoom", 9)), + rule(with_b_g, withinRange(WAYAREA, 4e6, 1e7), use("minZoom", 8)), + rule(with_b_g, withinRange(WAYAREA, 1e7), use("minZoom", 7)), + + // Remaining grab-bag of scaled kinds + + rule(with_etc, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), + rule(with_etc, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), + rule(with_etc, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), + rule(with_etc, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), + rule(with_etc, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), + rule(with_etc, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), + rule(with_etc, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), + rule(with_etc, withinRange(WAYAREA, 2e7), use("minZoom", 7)) + + )).index(); @Override public String name() { @@ -424,9 +422,9 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // Set minZoom from QRank minZoom = qrankedZoom.get(); } else { - // Calculate minZoom using zoomsIndex + // Calculate minZoom using zooms indexes var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", "undefined")); - var zoomMatches = zoomsIndex.getMatches(sf2); + var zoomMatches = sf.canBePolygon() ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) return; From 479a5e42d237cc8d5c19ef849b44238624a6f93a Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 13:37:50 -0800 Subject: [PATCH 24/76] Fixed overly-broad point matches on irrelevant tags --- .../com/protomaps/basemap/layers/Pois.java | 83 ++++++++++--------- 1 file changed, 44 insertions(+), 39 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 52e52ca4..063cb225 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -51,6 +51,7 @@ public Pois(QrankDb qrankDb) { private static final String WAYAREA = "protomaps-basemaps:wayArea"; private static final String HEIGHT = "protomaps-basemaps:height"; private static final String HAS_NAMED_POLYGON = "protomaps-basemaps:hasNamedPolygon"; + private static final String UNDEFINED = "protomaps-basemaps:undefined"; 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", @@ -58,8 +59,29 @@ public Pois(QrankDb qrankDb) { private static final MultiExpression.Index> kindsIndex = MultiExpression.ofOrdered(List.of( - // Everything is "other"/"" at first - rule(use("kind", "other"), use("kindDetail", "")), + // Everything is undefined at first + rule(use("kind", UNDEFINED), use("kindDetail", UNDEFINED)), + + // An initial set of tags we like + rule( + Expression.or( + with("aeroway", "aerodrome"), + with("amenity"), + with("attraction"), + with("boundary", "national_park", "protected_area"), + with("craft"), + with("highway", "bus_stop"), + with("historic"), + with("landuse", "cemetery", "recreation_ground", "winter_sports", "quarry", "park", "forest", "military", + "village_green", "allotments"), + with("leisure"), + with("natural", "beach", "peak"), + with("railway", "station"), + with("shop"), + Expression.and(with("tourism"), without("historic", "district")) + ), + use("kind", "other") + ), // Boundary is most generic, so place early else we lose out // on nature_reserve detail versus all the protected_area @@ -153,20 +175,9 @@ public Pois(QrankDb qrankDb) { )).index(); - // Shorthand expressions to save space below - - private static final Expression with_s_c = with(KIND, "cemetery", "school"); - private static final Expression with_n_p = with(KIND, "national_park"); - private static final Expression with_c_u = with(KIND, "college", "university"); - private static final Expression with_b_g = - with(KIND, "forest", "park", "protected_area", "nature_reserve", "village_green"); - private static final Expression with_etc = - with(KIND, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"); - private static final MultiExpression.Index> pointZoomsIndex = MultiExpression.ofOrdered(List.of( // Every point is zoom=15 at first - rule(use("minZoom", 15)), // Promote important point categories to earlier zooms @@ -237,29 +248,21 @@ public Pois(QrankDb qrankDb) { )).index(); + // Shorthand expressions to save space below + + private static final Expression with_s_c = with(KIND, "cemetery", "school"); + private static final Expression with_n_p = with(KIND, "national_park"); + private static final Expression with_c_u = with(KIND, "college", "university"); + private static final Expression with_b_g = + with(KIND, "forest", "park", "protected_area", "nature_reserve", "village_green"); + private static final Expression with_etc = + with(KIND, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"); + private static final MultiExpression.Index> namedPolygonZoomsIndex = MultiExpression.ofOrdered(List.of( - // Every named polygon with a valid tag is zoom=15 at first - rule( - Expression.or( - with("aeroway", "aerodrome"), - with("amenity"), - with("attraction"), - with("boundary", "national_park", "protected_area"), - with("craft"), - with("highway", "bus_stop"), - with("historic"), - with("landuse", "cemetery", "recreation_ground", "winter_sports", "quarry", "park", "forest", "military", - "village_green", "allotments"), - with("leisure"), - with("natural", "beach", "peak"), - with("railway", "station"), - with("shop"), - Expression.and(with("tourism"), without("historic", "district")) - ), - use("minZoom", 15) - ), + // Every named polygon is zoom=15 at first + rule(use("minZoom", 15)), // Size-graded polygons, generic at first then per-kind adjustments @@ -404,15 +407,17 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // Map the Protomaps "kind" classification to incoming tags var kindMatches = kindsIndex.getMatches(sf); - if (kindMatches.isEmpty()) - return; // Output feature and its basic values to assign FeatureCollector.Feature outputFeature; - String kind = getString(sf, kindMatches, "kind", "undefined"); - String kindDetail = getString(sf, kindMatches, "kindDetail", "undefined"); + String kind = getString(sf, kindMatches, "kind", UNDEFINED); + String kindDetail = getString(sf, kindMatches, "kindDetail", UNDEFINED); Integer minZoom; + // Quickly eliminate any features with non-matching tags + if (kind == UNDEFINED) + return; + // QRank may override minZoom entirely String wikidata = sf.getString("wikidata"); long qrank = (wikidata != null) ? qrankDb.get(wikidata) : 0; @@ -423,7 +428,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { minZoom = qrankedZoom.get(); } else { // Calculate minZoom using zooms indexes - var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", "undefined")); + var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", UNDEFINED)); var zoomMatches = sf.canBePolygon() ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) return; @@ -499,7 +504,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { .setAttr("iata", sf.getString("iata")); // Core Tilezen schema properties - if (!kindDetail.isEmpty()) + if (kindDetail != UNDEFINED) outputFeature.setAttr("kind_detail", kindDetail); OsmNames.setOsmNames(outputFeature, sf, 0); From 415e587690894d34a2732dcbc2b1ddceb69f069d Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 13:56:25 -0800 Subject: [PATCH 25/76] Switched to constants for KIND and MINZOOM strings --- .../com/protomaps/basemap/layers/Pois.java | 210 +++++++++--------- 1 file changed, 106 insertions(+), 104 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 063cb225..73a52e3a 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -48,6 +48,8 @@ public Pois(QrankDb qrankDb) { // Internal tags used to reference calculated values between matchers private static final String KIND = "protomaps-basemaps:kind"; + private static final String KIND_DETAIL = "protomaps-basemaps:kindDetail"; + private static final String MINZOOM = "protomaps-basemaps:minZoom"; private static final String WAYAREA = "protomaps-basemaps:wayArea"; private static final String HEIGHT = "protomaps-basemaps:height"; private static final String HAS_NAMED_POLYGON = "protomaps-basemaps:hasNamedPolygon"; @@ -60,7 +62,7 @@ public Pois(QrankDb qrankDb) { private static final MultiExpression.Index> kindsIndex = MultiExpression.ofOrdered(List.of( // Everything is undefined at first - rule(use("kind", UNDEFINED), use("kindDetail", UNDEFINED)), + rule(use(KIND, UNDEFINED), use(KIND_DETAIL, UNDEFINED)), // An initial set of tags we like rule( @@ -80,27 +82,27 @@ public Pois(QrankDb qrankDb) { with("shop"), Expression.and(with("tourism"), without("historic", "district")) ), - use("kind", "other") + use(KIND, "other") ), // Boundary is most generic, so place early else we lose out // on nature_reserve detail versus all the protected_area - rule(with("boundary"), use("kind", fromTag("boundary"))), + rule(with("boundary"), use(KIND, fromTag("boundary"))), // More specific kinds - rule(with("historic"), without("historic", "yes"), use("kind", fromTag("historic"))), - rule(with("tourism"), use("kind", fromTag("tourism"))), - rule(with("shop"), use("kind", fromTag("shop"))), - rule(with("highway"), use("kind", fromTag("highway"))), - rule(with("railway"), use("kind", fromTag("railway"))), - rule(with("natural"), use("kind", fromTag("natural"))), - rule(with("leisure"), use("kind", fromTag("leisure"))), - rule(with("landuse"), use("kind", fromTag("landuse"))), - rule(with("aeroway"), use("kind", fromTag("aeroway"))), - rule(with("craft"), use("kind", fromTag("craft"))), - rule(with("attraction"), use("kind", fromTag("attraction"))), - rule(with("amenity"), use("kind", fromTag("amenity"))), + rule(with("historic"), without("historic", "yes"), use(KIND, fromTag("historic"))), + rule(with("tourism"), use(KIND, fromTag("tourism"))), + rule(with("shop"), use(KIND, fromTag("shop"))), + rule(with("highway"), use(KIND, fromTag("highway"))), + rule(with("railway"), use(KIND, fromTag("railway"))), + rule(with("natural"), use(KIND, fromTag("natural"))), + rule(with("leisure"), use(KIND, fromTag("leisure"))), + rule(with("landuse"), use(KIND, fromTag("landuse"))), + rule(with("aeroway"), use(KIND, fromTag("aeroway"))), + rule(with("craft"), use(KIND, fromTag("craft"))), + rule(with("attraction"), use(KIND, fromTag("attraction"))), + rule(with("amenity"), use(KIND, fromTag("amenity"))), // National forests @@ -119,12 +121,12 @@ public Pois(QrankDb qrankDb) { WITH_OPERATOR_USFS ) ), - use("kind", "forest") + use(KIND, "forest") ), // National parks - rule(with("boundary", "national_park"), use("kind", "park")), + rule(with("boundary", "national_park"), use(KIND, "park")), rule( with("boundary", "national_park"), Expression.not(WITH_OPERATOR_USFS), @@ -141,44 +143,44 @@ public Pois(QrankDb qrankDb) { with("designation", "national_park"), with("protection_title", "National Park") ), - use("kind", "national_park") + use(KIND, "national_park") ), // Remaining things - rule(with("natural", "peak"), use("kind", fromTag("natural"))), - rule(with("highway", "bus_stop"), use("kind", fromTag("highway"))), - rule(with("tourism", "attraction", "camp_site", "hotel"), use("kind", fromTag("tourism"))), - rule(with("shop", "grocery", "supermarket"), use("kind", fromTag("shop"))), - rule(with("leisure", "golf_course", "marina", "stadium", "park"), use("kind", fromTag("leisure"))), + rule(with("natural", "peak"), use(KIND, fromTag("natural"))), + rule(with("highway", "bus_stop"), use(KIND, fromTag("highway"))), + rule(with("tourism", "attraction", "camp_site", "hotel"), use(KIND, fromTag("tourism"))), + rule(with("shop", "grocery", "supermarket"), use(KIND, fromTag("shop"))), + rule(with("leisure", "golf_course", "marina", "stadium", "park"), use(KIND, fromTag("leisure"))), - rule(with("landuse", "military"), use("kind", "military")), + rule(with("landuse", "military"), use(KIND, "military")), rule( with("landuse", "military"), with("military", "naval_base", "airfield"), - use("kind", fromTag("military")) + use(KIND, fromTag("military")) ), - rule(with("landuse", "cemetery"), use("kind", fromTag("landuse"))), + rule(with("landuse", "cemetery"), use(KIND, fromTag("landuse"))), rule( with("aeroway", "aerodrome"), - use("kind", "aerodrome"), - use("kindDetail", fromTag("aerodrome")) + use(KIND, "aerodrome"), + use(KIND_DETAIL, fromTag("aerodrome")) ), // Additional details for certain classes of POI - rule(with("sport"), use("kindDetail", fromTag("sport"))), - rule(with("religion"), use("kindDetail", fromTag("religion"))), - rule(with("cuisine"), use("kindDetail", fromTag("cuisine"))) + rule(with("sport"), use(KIND_DETAIL, fromTag("sport"))), + rule(with("religion"), use(KIND_DETAIL, fromTag("religion"))), + rule(with("cuisine"), use(KIND_DETAIL, fromTag("cuisine"))) )).index(); private static final MultiExpression.Index> pointZoomsIndex = MultiExpression.ofOrdered(List.of( // Every point is zoom=15 at first - rule(use("minZoom", 15)), + rule(use(MINZOOM, 15)), // Promote important point categories to earlier zooms @@ -189,7 +191,7 @@ public Pois(QrankDb qrankDb) { with("leisure", "park"), // Lots of pocket parks and NODE parks, show those later than rest of leisure with("shop", "grocery", "supermarket") ), - use("minZoom", 14) + use(MINZOOM, 14) ), rule( Expression.or( @@ -198,15 +200,15 @@ public Pois(QrankDb qrankDb) { with("leisure", "golf_course", "marina", "stadium"), with("natural", "peak") ), - use("minZoom", 13) + use(MINZOOM, 13) ), - rule(with("amenity", "hospital"), use("minZoom", 12)), - rule(with(KIND, "national_park"), use("minZoom", 11)), - rule(with("aeroway", "aerodrome"), with(KIND, "aerodrome"), with("iata"), use("minZoom", 11)), // Emphasize large international airports earlier + rule(with("amenity", "hospital"), use(MINZOOM, 12)), + rule(with(KIND, "national_park"), use(MINZOOM, 11)), + rule(with("aeroway", "aerodrome"), with(KIND, "aerodrome"), with("iata"), use(MINZOOM, 11)), // Emphasize large international airports earlier // Demote some unimportant point categories to very late zooms - rule(with("highway", "bus_stop"), use("minZoom", 17)), + rule(with("highway", "bus_stop"), use(MINZOOM, 17)), rule( Expression.or( with("amenity", "clinic", "dentist", "doctors", "social_facility", "baby_hatch", "childcare", @@ -224,7 +226,7 @@ public Pois(QrankDb qrankDb) { with("tourism", "artwork", "hanami", "trail_riding_station", "bed_and_breakfast", "chalet", "guest_house", "hostel") ), - use("minZoom", 16) + use(MINZOOM, 16) ), // Demote some unnamed point categories to very late zooms @@ -243,7 +245,7 @@ public Pois(QrankDb qrankDb) { with("leisure", "dog_park", "firepit", "fishing", "pitch", "playground", "slipway", "swimming_area"), with("tourism", "alpine_hut", "information", "picnic_site", "viewpoint", "wilderness_hut") ), - use("minZoom", 16) + use(MINZOOM, 16) ) )).index(); @@ -262,26 +264,26 @@ public Pois(QrankDb qrankDb) { MultiExpression.ofOrdered(List.of( // Every named polygon is zoom=15 at first - rule(use("minZoom", 15)), + rule(use(MINZOOM, 15)), // Size-graded polygons, generic at first then per-kind adjustments - rule(withinRange(WAYAREA, 10, 500), use("minZoom", 14)), - rule(withinRange(WAYAREA, 500, 2000), use("minZoom", 13)), - rule(withinRange(WAYAREA, 2000, 1e4), use("minZoom", 12)), - rule(withinRange(WAYAREA, 1e4), use("minZoom", 11)), + rule(withinRange(WAYAREA, 10, 500), use(MINZOOM, 14)), + rule(withinRange(WAYAREA, 500, 2000), use(MINZOOM, 13)), + rule(withinRange(WAYAREA, 2000, 1e4), use(MINZOOM, 12)), + rule(withinRange(WAYAREA, 1e4), use(MINZOOM, 11)), - rule(with(KIND, "playground"), use("minZoom", 17)), - rule(with(KIND, "allotments"), withinRange(WAYAREA, 0, 10), use("minZoom", 16)), - rule(with(KIND, "allotments"), withinRange(WAYAREA, 10), use("minZoom", 15)), + rule(with(KIND, "playground"), use(MINZOOM, 17)), + rule(with(KIND, "allotments"), withinRange(WAYAREA, 0, 10), use(MINZOOM, 16)), + rule(with(KIND, "allotments"), withinRange(WAYAREA, 10), use(MINZOOM, 15)), // Height-graded polygons, generic at first then per-kind adjustments // Small but tall features should show up early as they have regional prominence. // Height measured in meters - rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 10, 20), use("minZoom", 13)), - rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 20, 100), use("minZoom", 12)), - rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 100), use("minZoom", 11)), + rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 10, 20), use(MINZOOM, 13)), + rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 20, 100), use(MINZOOM, 12)), + rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 100), use(MINZOOM, 11)), // Clamp certain kind values so medium tall buildings don't crowd downtown areas // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer @@ -291,72 +293,72 @@ public Pois(QrankDb qrankDb) { "coworking_space", "clothes", "art", "school"), withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 20, 100), - use("minZoom", 13) + use(MINZOOM, 13) ), // Discount tall self storage buildings - rule(with(KIND, "storage_rental"), withinRange(WAYAREA, 10, 2000), use("minZoom", 14)), + rule(with(KIND, "storage_rental"), withinRange(WAYAREA, 10, 2000), use(MINZOOM, 14)), // Discount tall university buildings, require a related university landuse AOI - rule(with(KIND, "university"), withinRange(WAYAREA, 10, 2000), use("minZoom", 13)), + rule(with(KIND, "university"), withinRange(WAYAREA, 10, 2000), use(MINZOOM, 13)), // Schools & Cemeteries - rule(with_s_c, withinRange(WAYAREA, 0, 10), use("minZoom", 16)), - rule(with_s_c, withinRange(WAYAREA, 10, 100), use("minZoom", 15)), - rule(with_s_c, withinRange(WAYAREA, 100, 1000), use("minZoom", 14)), - rule(with_s_c, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_s_c, withinRange(WAYAREA, 5000), use("minZoom", 12)), + rule(with_s_c, withinRange(WAYAREA, 0, 10), use(MINZOOM, 16)), + rule(with_s_c, withinRange(WAYAREA, 10, 100), use(MINZOOM, 15)), + rule(with_s_c, withinRange(WAYAREA, 100, 1000), use(MINZOOM, 14)), + rule(with_s_c, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(with_s_c, withinRange(WAYAREA, 5000), use(MINZOOM, 12)), // National parks - rule(with_n_p, withinRange(WAYAREA, 0, 250), use("minZoom", 17)), - rule(with_n_p, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), - rule(with_n_p, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_n_p, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), - rule(with_n_p, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), - rule(with_n_p, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), - rule(with_n_p, withinRange(WAYAREA, 2.5e5, 2e6), use("minZoom", 9)), - rule(with_n_p, withinRange(WAYAREA, 2e6, 1e7), use("minZoom", 8)), - rule(with_n_p, withinRange(WAYAREA, 1e7, 2.5e7), use("minZoom", 7)), - rule(with_n_p, withinRange(WAYAREA, 2.5e7, 3e8), use("minZoom", 6)), - rule(with_n_p, withinRange(WAYAREA, 3e8), use("minZoom", 5)), + rule(with_n_p, withinRange(WAYAREA, 0, 250), use(MINZOOM, 17)), + rule(with_n_p, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), + rule(with_n_p, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(with_n_p, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 12)), + rule(with_n_p, withinRange(WAYAREA, 2e4, 1e5), use(MINZOOM, 11)), + rule(with_n_p, withinRange(WAYAREA, 1e5, 2.5e5), use(MINZOOM, 10)), + rule(with_n_p, withinRange(WAYAREA, 2.5e5, 2e6), use(MINZOOM, 9)), + rule(with_n_p, withinRange(WAYAREA, 2e6, 1e7), use(MINZOOM, 8)), + rule(with_n_p, withinRange(WAYAREA, 1e7, 2.5e7), use(MINZOOM, 7)), + rule(with_n_p, withinRange(WAYAREA, 2.5e7, 3e8), use(MINZOOM, 6)), + rule(with_n_p, withinRange(WAYAREA, 3e8), use(MINZOOM, 5)), // College and university polygons - rule(with_c_u, withinRange(WAYAREA, 0, 5000), use("minZoom", 15)), - rule(with_c_u, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 14)), - rule(with_c_u, withinRange(WAYAREA, 2e4, 5e4), use("minZoom", 13)), - rule(with_c_u, withinRange(WAYAREA, 5e4, 1e5), use("minZoom", 12)), - rule(with_c_u, withinRange(WAYAREA, 1e5, 1.5e5), use("minZoom", 11)), - rule(with_c_u, withinRange(WAYAREA, 1.5e5, 2.5e5), use("minZoom", 10)), - rule(with_c_u, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), - rule(with_c_u, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), - rule(with_c_u, withinRange(WAYAREA, 2e7), use("minZoom", 7)), - rule(with_c_u, with("name", "Academy of Art University"), use("minZoom", 14)), // Hack for weird San Francisco university + rule(with_c_u, withinRange(WAYAREA, 0, 5000), use(MINZOOM, 15)), + rule(with_c_u, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 14)), + rule(with_c_u, withinRange(WAYAREA, 2e4, 5e4), use(MINZOOM, 13)), + rule(with_c_u, withinRange(WAYAREA, 5e4, 1e5), use(MINZOOM, 12)), + rule(with_c_u, withinRange(WAYAREA, 1e5, 1.5e5), use(MINZOOM, 11)), + rule(with_c_u, withinRange(WAYAREA, 1.5e5, 2.5e5), use(MINZOOM, 10)), + rule(with_c_u, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), + rule(with_c_u, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), + rule(with_c_u, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)), + rule(with_c_u, with("name", "Academy of Art University"), use(MINZOOM, 14)), // Hack for weird San Francisco university // Big green polygons - rule(with_b_g, withinRange(WAYAREA, 0, 1), use("minZoom", 17)), - rule(with_b_g, withinRange(WAYAREA, 1, 10), use("minZoom", 16)), - rule(with_b_g, withinRange(WAYAREA, 10, 250), use("minZoom", 15)), - rule(with_b_g, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), - rule(with_b_g, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_b_g, withinRange(WAYAREA, 5000, 1.5e4), use("minZoom", 12)), - rule(with_b_g, withinRange(WAYAREA, 1.5e4, 2.5e5), use("minZoom", 11)), - rule(with_b_g, withinRange(WAYAREA, 2.5e5, 1e6), use("minZoom", 10)), - rule(with_b_g, withinRange(WAYAREA, 1e6, 4e6), use("minZoom", 9)), - rule(with_b_g, withinRange(WAYAREA, 4e6, 1e7), use("minZoom", 8)), - rule(with_b_g, withinRange(WAYAREA, 1e7), use("minZoom", 7)), + rule(with_b_g, withinRange(WAYAREA, 0, 1), use(MINZOOM, 17)), + rule(with_b_g, withinRange(WAYAREA, 1, 10), use(MINZOOM, 16)), + rule(with_b_g, withinRange(WAYAREA, 10, 250), use(MINZOOM, 15)), + rule(with_b_g, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), + rule(with_b_g, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(with_b_g, withinRange(WAYAREA, 5000, 1.5e4), use(MINZOOM, 12)), + rule(with_b_g, withinRange(WAYAREA, 1.5e4, 2.5e5), use(MINZOOM, 11)), + rule(with_b_g, withinRange(WAYAREA, 2.5e5, 1e6), use(MINZOOM, 10)), + rule(with_b_g, withinRange(WAYAREA, 1e6, 4e6), use(MINZOOM, 9)), + rule(with_b_g, withinRange(WAYAREA, 4e6, 1e7), use(MINZOOM, 8)), + rule(with_b_g, withinRange(WAYAREA, 1e7), use(MINZOOM, 7)), // Remaining grab-bag of scaled kinds - rule(with_etc, withinRange(WAYAREA, 250, 1000), use("minZoom", 14)), - rule(with_etc, withinRange(WAYAREA, 1000, 5000), use("minZoom", 13)), - rule(with_etc, withinRange(WAYAREA, 5000, 2e4), use("minZoom", 12)), - rule(with_etc, withinRange(WAYAREA, 2e4, 1e5), use("minZoom", 11)), - rule(with_etc, withinRange(WAYAREA, 1e5, 2.5e5), use("minZoom", 10)), - rule(with_etc, withinRange(WAYAREA, 2.5e5, 5e6), use("minZoom", 9)), - rule(with_etc, withinRange(WAYAREA, 5e6, 2e7), use("minZoom", 8)), - rule(with_etc, withinRange(WAYAREA, 2e7), use("minZoom", 7)) + rule(with_etc, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), + rule(with_etc, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(with_etc, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 12)), + rule(with_etc, withinRange(WAYAREA, 2e4, 1e5), use(MINZOOM, 11)), + rule(with_etc, withinRange(WAYAREA, 1e5, 2.5e5), use(MINZOOM, 10)), + rule(with_etc, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), + rule(with_etc, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), + rule(with_etc, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)) )).index(); @@ -405,13 +407,13 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { if (!(sf.isPoint() || sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null)) return; - // Map the Protomaps "kind" classification to incoming tags + // Map the Protomaps KIND classification to incoming tags var kindMatches = kindsIndex.getMatches(sf); // Output feature and its basic values to assign FeatureCollector.Feature outputFeature; - String kind = getString(sf, kindMatches, "kind", UNDEFINED); - String kindDetail = getString(sf, kindMatches, "kindDetail", UNDEFINED); + String kind = getString(sf, kindMatches, KIND, UNDEFINED); + String kindDetail = getString(sf, kindMatches, KIND_DETAIL, UNDEFINED); Integer minZoom; // Quickly eliminate any features with non-matching tags @@ -428,13 +430,13 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { minZoom = qrankedZoom.get(); } else { // Calculate minZoom using zooms indexes - var sf2 = computeExtraTags(sf, getString(sf, kindMatches, "kind", UNDEFINED)); + var sf2 = computeExtraTags(sf, getString(sf, kindMatches, KIND, UNDEFINED)); var zoomMatches = sf.canBePolygon() ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) return; // Initial minZoom - minZoom = getInteger(sf2, zoomMatches, "minZoom", 99); + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); // Adjusted minZoom if (sf.canBePolygon()) { From d619032a6202cd0c0b650fff33548f5d2c7fe5d1 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 14:07:32 -0800 Subject: [PATCH 26/76] Cleanup --- .../main/java/com/protomaps/basemap/feature/Matcher.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index c81fd7d8..36646ef5 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -222,12 +222,6 @@ public boolean evaluate(com.onthegomap.planetiler.reader.WithTags input, List= lowerBound && (upperBound == null || value < upperBound); } - - @Override - public String generateJavaCode() { - return "withinRange(" + com.onthegomap.planetiler.util.Format.quote(tagName) + ", " + lowerBound + "L, " + - (upperBound == null ? "null" : upperBound + "L") + ")"; - } } public static Expression withPoint() { From 2dfde30b72eca0d6d609b1218a97b6140770e894 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 17:48:17 -0800 Subject: [PATCH 27/76] Condensed some long booleans --- .../com/protomaps/basemap/layers/Pois.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 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 73a52e3a..64252399 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -371,13 +371,16 @@ public String name() { private static final double WORLD_AREA_FOR_70_SQUARE_METERS = Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70)) / 256d, 2); + private Boolean isNamedPolygon(SourceFeature sf) { + return sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null; + } + public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { Double wayArea = 0.0; Double height = 0.0; - Boolean hasNamedPolygon = false; + Boolean hasNamedPolygon = isNamedPolygon(sf); - if (sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null) { - hasNamedPolygon = true; + if (hasNamedPolygon) { try { wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70_SQUARE_METERS; } catch (GeometryException e) { @@ -403,8 +406,10 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, } public void processOsm(SourceFeature sf, FeatureCollector features) { + Boolean hasNamedPolygon = isNamedPolygon(sf); + // We only do POI display for points and named polygons - if (!(sf.isPoint() || sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null)) + if (!sf.isPoint() && !hasNamedPolygon) return; // Map the Protomaps KIND classification to incoming tags @@ -431,7 +436,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else { // Calculate minZoom using zooms indexes var sf2 = computeExtraTags(sf, getString(sf, kindMatches, KIND, UNDEFINED)); - var zoomMatches = sf.canBePolygon() ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); + var zoomMatches = hasNamedPolygon ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) return; @@ -439,7 +444,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); // Adjusted minZoom - if (sf.canBePolygon()) { + if (hasNamedPolygon) { // Emphasize large international airports earlier // Because the area grading resets the earlier dispensation if (kind.equals("aerodrome")) { @@ -478,7 +483,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } // Assign outputFeature - if (sf.canBePolygon()) { + if (hasNamedPolygon) { outputFeature = features.pointOnSurface(this.name()) //.setAttr("area_debug", wayArea) // DEBUG .setAttr("elevation", sf.getString("ele")); From ed0399e6489c1d4445f40506a1d76ce542c21f1e Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sun, 28 Dec 2025 10:00:34 -0800 Subject: [PATCH 28/76] Uppercased more constants --- .../com/protomaps/basemap/layers/Pois.java | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 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 64252399..47a8a8e8 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -252,12 +252,12 @@ public Pois(QrankDb qrankDb) { // Shorthand expressions to save space below - private static final Expression with_s_c = with(KIND, "cemetery", "school"); - private static final Expression with_n_p = with(KIND, "national_park"); - private static final Expression with_c_u = with(KIND, "college", "university"); - private static final Expression with_b_g = + private static final Expression WITH_S_C = with(KIND, "cemetery", "school"); + private static final Expression WITH_N_P = with(KIND, "national_park"); + private static final Expression WITH_C_U = with(KIND, "college", "university"); + private static final Expression WITH_B_G = with(KIND, "forest", "park", "protected_area", "nature_reserve", "village_green"); - private static final Expression with_etc = + private static final Expression WITH_ETC = with(KIND, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"); private static final MultiExpression.Index> namedPolygonZoomsIndex = @@ -302,63 +302,63 @@ public Pois(QrankDb qrankDb) { // Schools & Cemeteries - rule(with_s_c, withinRange(WAYAREA, 0, 10), use(MINZOOM, 16)), - rule(with_s_c, withinRange(WAYAREA, 10, 100), use(MINZOOM, 15)), - rule(with_s_c, withinRange(WAYAREA, 100, 1000), use(MINZOOM, 14)), - rule(with_s_c, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), - rule(with_s_c, withinRange(WAYAREA, 5000), use(MINZOOM, 12)), + rule(WITH_S_C, withinRange(WAYAREA, 0, 10), use(MINZOOM, 16)), + rule(WITH_S_C, withinRange(WAYAREA, 10, 100), use(MINZOOM, 15)), + rule(WITH_S_C, withinRange(WAYAREA, 100, 1000), use(MINZOOM, 14)), + rule(WITH_S_C, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(WITH_S_C, withinRange(WAYAREA, 5000), use(MINZOOM, 12)), // National parks - rule(with_n_p, withinRange(WAYAREA, 0, 250), use(MINZOOM, 17)), - rule(with_n_p, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), - rule(with_n_p, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), - rule(with_n_p, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 12)), - rule(with_n_p, withinRange(WAYAREA, 2e4, 1e5), use(MINZOOM, 11)), - rule(with_n_p, withinRange(WAYAREA, 1e5, 2.5e5), use(MINZOOM, 10)), - rule(with_n_p, withinRange(WAYAREA, 2.5e5, 2e6), use(MINZOOM, 9)), - rule(with_n_p, withinRange(WAYAREA, 2e6, 1e7), use(MINZOOM, 8)), - rule(with_n_p, withinRange(WAYAREA, 1e7, 2.5e7), use(MINZOOM, 7)), - rule(with_n_p, withinRange(WAYAREA, 2.5e7, 3e8), use(MINZOOM, 6)), - rule(with_n_p, withinRange(WAYAREA, 3e8), use(MINZOOM, 5)), + rule(WITH_N_P, withinRange(WAYAREA, 0, 250), use(MINZOOM, 17)), + rule(WITH_N_P, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), + rule(WITH_N_P, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(WITH_N_P, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 12)), + rule(WITH_N_P, withinRange(WAYAREA, 2e4, 1e5), use(MINZOOM, 11)), + rule(WITH_N_P, withinRange(WAYAREA, 1e5, 2.5e5), use(MINZOOM, 10)), + rule(WITH_N_P, withinRange(WAYAREA, 2.5e5, 2e6), use(MINZOOM, 9)), + rule(WITH_N_P, withinRange(WAYAREA, 2e6, 1e7), use(MINZOOM, 8)), + rule(WITH_N_P, withinRange(WAYAREA, 1e7, 2.5e7), use(MINZOOM, 7)), + rule(WITH_N_P, withinRange(WAYAREA, 2.5e7, 3e8), use(MINZOOM, 6)), + rule(WITH_N_P, withinRange(WAYAREA, 3e8), use(MINZOOM, 5)), // College and university polygons - rule(with_c_u, withinRange(WAYAREA, 0, 5000), use(MINZOOM, 15)), - rule(with_c_u, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 14)), - rule(with_c_u, withinRange(WAYAREA, 2e4, 5e4), use(MINZOOM, 13)), - rule(with_c_u, withinRange(WAYAREA, 5e4, 1e5), use(MINZOOM, 12)), - rule(with_c_u, withinRange(WAYAREA, 1e5, 1.5e5), use(MINZOOM, 11)), - rule(with_c_u, withinRange(WAYAREA, 1.5e5, 2.5e5), use(MINZOOM, 10)), - rule(with_c_u, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), - rule(with_c_u, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), - rule(with_c_u, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)), - rule(with_c_u, with("name", "Academy of Art University"), use(MINZOOM, 14)), // Hack for weird San Francisco university + rule(WITH_C_U, withinRange(WAYAREA, 0, 5000), use(MINZOOM, 15)), + rule(WITH_C_U, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 14)), + rule(WITH_C_U, withinRange(WAYAREA, 2e4, 5e4), use(MINZOOM, 13)), + rule(WITH_C_U, withinRange(WAYAREA, 5e4, 1e5), use(MINZOOM, 12)), + rule(WITH_C_U, withinRange(WAYAREA, 1e5, 1.5e5), use(MINZOOM, 11)), + rule(WITH_C_U, withinRange(WAYAREA, 1.5e5, 2.5e5), use(MINZOOM, 10)), + rule(WITH_C_U, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), + rule(WITH_C_U, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), + rule(WITH_C_U, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)), + rule(WITH_C_U, with("name", "Academy of Art University"), use(MINZOOM, 14)), // Hack for weird San Francisco university // Big green polygons - rule(with_b_g, withinRange(WAYAREA, 0, 1), use(MINZOOM, 17)), - rule(with_b_g, withinRange(WAYAREA, 1, 10), use(MINZOOM, 16)), - rule(with_b_g, withinRange(WAYAREA, 10, 250), use(MINZOOM, 15)), - rule(with_b_g, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), - rule(with_b_g, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), - rule(with_b_g, withinRange(WAYAREA, 5000, 1.5e4), use(MINZOOM, 12)), - rule(with_b_g, withinRange(WAYAREA, 1.5e4, 2.5e5), use(MINZOOM, 11)), - rule(with_b_g, withinRange(WAYAREA, 2.5e5, 1e6), use(MINZOOM, 10)), - rule(with_b_g, withinRange(WAYAREA, 1e6, 4e6), use(MINZOOM, 9)), - rule(with_b_g, withinRange(WAYAREA, 4e6, 1e7), use(MINZOOM, 8)), - rule(with_b_g, withinRange(WAYAREA, 1e7), use(MINZOOM, 7)), + rule(WITH_B_G, withinRange(WAYAREA, 0, 1), use(MINZOOM, 17)), + rule(WITH_B_G, withinRange(WAYAREA, 1, 10), use(MINZOOM, 16)), + rule(WITH_B_G, withinRange(WAYAREA, 10, 250), use(MINZOOM, 15)), + rule(WITH_B_G, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), + rule(WITH_B_G, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(WITH_B_G, withinRange(WAYAREA, 5000, 1.5e4), use(MINZOOM, 12)), + rule(WITH_B_G, withinRange(WAYAREA, 1.5e4, 2.5e5), use(MINZOOM, 11)), + rule(WITH_B_G, withinRange(WAYAREA, 2.5e5, 1e6), use(MINZOOM, 10)), + rule(WITH_B_G, withinRange(WAYAREA, 1e6, 4e6), use(MINZOOM, 9)), + rule(WITH_B_G, withinRange(WAYAREA, 4e6, 1e7), use(MINZOOM, 8)), + rule(WITH_B_G, withinRange(WAYAREA, 1e7), use(MINZOOM, 7)), // Remaining grab-bag of scaled kinds - rule(with_etc, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), - rule(with_etc, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), - rule(with_etc, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 12)), - rule(with_etc, withinRange(WAYAREA, 2e4, 1e5), use(MINZOOM, 11)), - rule(with_etc, withinRange(WAYAREA, 1e5, 2.5e5), use(MINZOOM, 10)), - rule(with_etc, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), - rule(with_etc, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), - rule(with_etc, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)) + rule(WITH_ETC, withinRange(WAYAREA, 250, 1000), use(MINZOOM, 14)), + rule(WITH_ETC, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), + rule(WITH_ETC, withinRange(WAYAREA, 5000, 2e4), use(MINZOOM, 12)), + rule(WITH_ETC, withinRange(WAYAREA, 2e4, 1e5), use(MINZOOM, 11)), + rule(WITH_ETC, withinRange(WAYAREA, 1e5, 2.5e5), use(MINZOOM, 10)), + rule(WITH_ETC, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), + rule(WITH_ETC, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), + rule(WITH_ETC, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)) )).index(); From da5e34fce77dcb76b5225c856a8a761be342785b Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 09:48:03 -0800 Subject: [PATCH 29/76] Bumped version number --- CHANGELOG.md | 4 ++++ tiles/src/main/java/com/protomaps/basemap/Basemap.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7be090..94b42d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +Tiles 4.13.6 +------ +- Translate POI min_zoom= assignments to MultiExpression rules [#539] + Tiles 4.13.5 ------ - Translate POI kind= assignments to MultiExpression rules [#537] diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index 6a4bc340..74abfc68 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -119,7 +119,7 @@ public String description() { @Override public String version() { - return "4.13.5"; + return "4.13.6"; } @Override From a349c4b6f951eb86c1eb77073bb7580f38a9a038 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 10:19:10 -0800 Subject: [PATCH 30/76] Applied automated code quality suggestions --- .../protomaps/basemap/feature/Matcher.java | 11 +++++------ .../com/protomaps/basemap/layers/Pois.java | 19 ++++++++----------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 36646ef5..5fb6b417 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -172,36 +172,35 @@ public static Expression without(String... arguments) { * @return An {@link Expression} for the numeric range check. */ public static Expression withinRange(String tagName, Integer lowerBound, Integer upperBound) { - return new WithinRangeExpression(tagName, new Long(lowerBound), new Long(upperBound)); + return new WithinRangeExpression(tagName, Long.valueOf(lowerBound), Long.valueOf(upperBound)); } /** * Overload withinRange to accept just lower bound integer */ public static Expression withinRange(String tagName, Integer lowerBound) { - return new WithinRangeExpression(tagName, new Long(lowerBound), null); + return new WithinRangeExpression(tagName, Long.valueOf(lowerBound), null); } /** * Overload withinRange to accept lower bound integer and upper bound double */ public static Expression withinRange(String tagName, Integer lowerBound, Double upperBound) { - return new WithinRangeExpression(tagName, new Long(lowerBound), Double.valueOf(upperBound).longValue()); + return new WithinRangeExpression(tagName, Long.valueOf(lowerBound), upperBound.longValue()); } /** * Overload withinRange to accept bounds as doubles */ public static Expression withinRange(String tagName, Double lowerBound, Double upperBound) { - return new WithinRangeExpression(tagName, Double.valueOf(lowerBound).longValue(), - Double.valueOf(upperBound).longValue()); + return new WithinRangeExpression(tagName, lowerBound.longValue(), upperBound.longValue()); } /** * Overload withinRange to accept just lower bound double */ public static Expression withinRange(String tagName, Double lowerBound) { - return new WithinRangeExpression(tagName, Double.valueOf(lowerBound).longValue(), null); + return new WithinRangeExpression(tagName, lowerBound.longValue(), null); } /** 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 47a8a8e8..2405924c 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -378,9 +378,8 @@ private Boolean isNamedPolygon(SourceFeature sf) { public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { Double wayArea = 0.0; Double height = 0.0; - Boolean hasNamedPolygon = isNamedPolygon(sf); - if (hasNamedPolygon) { + if (isNamedPolygon(sf)) { try { wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70_SQUARE_METERS; } catch (GeometryException e) { @@ -396,7 +395,7 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, Map computedTags; - if (hasNamedPolygon) { + if (isNamedPolygon(sf)) { computedTags = Map.of(KIND, kind, WAYAREA, wayArea, HEIGHT, height, HAS_NAMED_POLYGON, true); } else { computedTags = Map.of(KIND, kind, WAYAREA, wayArea, HEIGHT, height); @@ -406,10 +405,8 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, } public void processOsm(SourceFeature sf, FeatureCollector features) { - Boolean hasNamedPolygon = isNamedPolygon(sf); - // We only do POI display for points and named polygons - if (!sf.isPoint() && !hasNamedPolygon) + if (!sf.isPoint() && !isNamedPolygon(sf)) return; // Map the Protomaps KIND classification to incoming tags @@ -422,7 +419,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { Integer minZoom; // Quickly eliminate any features with non-matching tags - if (kind == UNDEFINED) + if (kind.equals(UNDEFINED)) return; // QRank may override minZoom entirely @@ -436,7 +433,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else { // Calculate minZoom using zooms indexes var sf2 = computeExtraTags(sf, getString(sf, kindMatches, KIND, UNDEFINED)); - var zoomMatches = hasNamedPolygon ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); + var zoomMatches = isNamedPolygon(sf) ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) return; @@ -444,7 +441,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); // Adjusted minZoom - if (hasNamedPolygon) { + if (isNamedPolygon(sf)) { // Emphasize large international airports earlier // Because the area grading resets the earlier dispensation if (kind.equals("aerodrome")) { @@ -483,7 +480,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } // Assign outputFeature - if (hasNamedPolygon) { + if (isNamedPolygon(sf)) { outputFeature = features.pointOnSurface(this.name()) //.setAttr("area_debug", wayArea) // DEBUG .setAttr("elevation", sf.getString("ele")); @@ -511,7 +508,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { .setAttr("iata", sf.getString("iata")); // Core Tilezen schema properties - if (kindDetail != UNDEFINED) + if (!kindDetail.equals(UNDEFINED)) outputFeature.setAttr("kind_detail", kindDetail); OsmNames.setOsmNames(outputFeature, sf, 0); From 5f9a59f1b3b858a3e5265018cb33e0c22f975a69 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 10:36:44 -0800 Subject: [PATCH 31/76] Applied additional code quality suggestions --- .../com/protomaps/basemap/layers/Pois.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 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 2405924c..81d97c1f 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -25,7 +25,6 @@ import java.util.List; import java.util.Map; - @SuppressWarnings("java:S1192") public class Pois implements ForwardingProfile.LayerPostProcessor { @@ -371,15 +370,16 @@ public String name() { private static final double WORLD_AREA_FOR_70_SQUARE_METERS = Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70)) / 256d, 2); - private Boolean isNamedPolygon(SourceFeature sf) { + private boolean isNamedPolygon(SourceFeature sf) { return sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null; } public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { Double wayArea = 0.0; Double height = 0.0; + boolean hasNamedPolygon = isNamedPolygon(sf); - if (isNamedPolygon(sf)) { + if (hasNamedPolygon) { try { wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70_SQUARE_METERS; } catch (GeometryException e) { @@ -395,7 +395,7 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, Map computedTags; - if (isNamedPolygon(sf)) { + if (hasNamedPolygon) { computedTags = Map.of(KIND, kind, WAYAREA, wayArea, HEIGHT, height, HAS_NAMED_POLYGON, true); } else { computedTags = Map.of(KIND, kind, WAYAREA, wayArea, HEIGHT, height); @@ -405,8 +405,10 @@ public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, } public void processOsm(SourceFeature sf, FeatureCollector features) { + boolean hasNamedPolygon = isNamedPolygon(sf); + // We only do POI display for points and named polygons - if (!sf.isPoint() && !isNamedPolygon(sf)) + if (!sf.isPoint() && !hasNamedPolygon) return; // Map the Protomaps KIND classification to incoming tags @@ -433,7 +435,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } else { // Calculate minZoom using zooms indexes var sf2 = computeExtraTags(sf, getString(sf, kindMatches, KIND, UNDEFINED)); - var zoomMatches = isNamedPolygon(sf) ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); + var zoomMatches = hasNamedPolygon ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); if (zoomMatches.isEmpty()) return; @@ -441,7 +443,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); // Adjusted minZoom - if (isNamedPolygon(sf)) { + if (hasNamedPolygon) { // Emphasize large international airports earlier // Because the area grading resets the earlier dispensation if (kind.equals("aerodrome")) { @@ -480,7 +482,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } // Assign outputFeature - if (isNamedPolygon(sf)) { + if (hasNamedPolygon) { outputFeature = features.pointOnSurface(this.name()) //.setAttr("area_debug", wayArea) // DEBUG .setAttr("elevation", sf.getString("ele")); From 85d697773be2ca02c0359f898b2c8468c59998d5 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 10:37:20 -0800 Subject: [PATCH 32/76] Committed trailing space removal in comment blocks due to .editorconfig settings --- .../protomaps/basemap/feature/Matcher.java | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 5fb6b417..3de89f50 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -14,23 +14,23 @@ /** * A utility class for matching source feature properties to values. - * + * *

* Use the {@link #rule} function to create entries for a Planetiler {@link MultiExpression}. A rule consists of * multiple contitions that get joined by a logical AND, and key-value pairs that should be used if all conditions of * the rule are true. The key-value pairs of rules that get added later override the key-value pairs of rules that were * added earlier. *

- * + * *

* The MultiExpression can be used on a source feature and the resulting list of matches can be used in * {@link #getString} and similar functions to retrieve a value. *

- * + * *

* Example usage: *

- * + * *
  * 
  *var index = MultiExpression.ofOrdered(List.of(rule(with("highway", "primary"), use("kind", "major_road")))).index();
@@ -44,16 +44,16 @@ public record Use(String key, Object value) {}
 
   /**
    * Creates a matching rule with conditions and values.
-   * 
+   *
    * 

* Create conditions by calling the {@link #with} or {@link #without} functions. All conditions are joined by a * logical AND. *

- * + * *

* Create key-value pairs with the {@link #use} function. *

- * + * * @param arguments A mix of {@link Use} instances for key-value pairs and {@link Expression} instances for * conditions. * @return A {@link MultiExpression.Entry} containing the rule definition. @@ -73,13 +73,13 @@ public static MultiExpression.Entry> rule(Object... argument /** * Creates a {@link Use} instance representing a key-value pair to be supplied to the {@link #rule} function. - * + * *

* While in principle any Object can be supplied as value, retrievalbe later on are only Strings with * {@link #getString}, Integers with {@link #getInteger}, Doubles with {@link #getDouble}, Booleans with * {@link #getBoolean}. *

- * + * * @param key The key. * @param value The value associated with the key. * @return A new {@link Use} instance. @@ -90,30 +90,30 @@ public static Use use(String key, Object value) { /** * Creates an {@link Expression} that matches any of the specified arguments. - * + * *

* If no argument is supplied, matches everything. *

- * + * *

* If one argument is supplied, matches all source features that have this tag, e.g., {@code with("highway")} matches * to all source features with a highway tag. *

- * + * *

* If two arguments are supplied, matches to all source features that have this tag-value pair, e.g., * {@code with("highway", "primary")} matches to all source features with highway=primary. *

- * + * *

* If more than two arguments are supplied, matches to all source features that have the first argument as tag and the * later arguments as possible values, e.g., {@code with("highway", "primary", "secondary")} matches to all source * features that have highway=primary or highway=secondary. *

- * + * *

* If an argument consists of multiple lines, it will be broken up into one argument per line. Example: - * + * *

    * 
    * with("""
@@ -124,7 +124,7 @@ public static Use use(String key, Object value) {
    * 
    * 
*

- * + * * @param arguments Field names to match. * @return An {@link Expression} for the given field names. */ @@ -251,15 +251,15 @@ public record FromTag(String key) {} /** * Creates a {@link FromTag} instance representing a tag reference. - * + * *

* Use this function if to retrieve a value from a source feature when calling {@link #getString} and similar. *

- * + * *

* Example usage: *

- * + * *
    * 
    *var index = MultiExpression.ofOrdered(List.of(rule(with("highway", "primary", "secondary"), use("kind", fromTag("highway"))))).index();
@@ -269,7 +269,7 @@ public record FromTag(String key) {}
    * 
*

* On a source feature with highway=primary the above will result in kind=primary. - * + * * @param key The key of the tag. * @return A new {@link FromTag} instance. */ From 792ec922742f465453c25964d6b8e54767506caf Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 5 Jan 2026 11:06:31 -0800 Subject: [PATCH 33/76] Replaced single-bound version of withinRange() by atLeast() --- .../protomaps/basemap/feature/Matcher.java | 32 +++++++++++-------- .../com/protomaps/basemap/layers/Pois.java | 17 +++++----- .../basemap/feature/MatcherTest.java | 7 ++-- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 3de89f50..68f4d456 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -159,29 +159,18 @@ public static Expression without(String... arguments) { *

* *

- * If the upper bound is null, only the lower bound is checked (value >= lowerBound). - *

- * - *

* Tag values that cannot be parsed as numbers or missing tags will not match. *

* * @param tagName The name of the tag to check. * @param lowerBound The inclusive lower bound. - * @param upperBound The exclusive upper bound, or null to check only the lower bound. + * @param upperBound The exclusive upper bound. * @return An {@link Expression} for the numeric range check. */ public static Expression withinRange(String tagName, Integer lowerBound, Integer upperBound) { return new WithinRangeExpression(tagName, Long.valueOf(lowerBound), Long.valueOf(upperBound)); } - /** - * Overload withinRange to accept just lower bound integer - */ - public static Expression withinRange(String tagName, Integer lowerBound) { - return new WithinRangeExpression(tagName, Long.valueOf(lowerBound), null); - } - /** * Overload withinRange to accept lower bound integer and upper bound double */ @@ -197,9 +186,24 @@ public static Expression withinRange(String tagName, Double lowerBound, Double u } /** - * Overload withinRange to accept just lower bound double + * Creates an {@link Expression} that matches when a numeric tag value is greater or equal to a value. + * + *

+ * Tag values that cannot be parsed as numbers or missing tags will not match. + *

+ * + * @param tagName The name of the tag to check. + * @param lowerBound The inclusive lower bound. + * @return An {@link Expression} for the numeric range check. + */ + public static Expression atLeast(String tagName, Integer lowerBound) { + return new WithinRangeExpression(tagName, Long.valueOf(lowerBound), null); + } + + /** + * Overload atLeast to accept just lower bound double */ - public static Expression withinRange(String tagName, Double lowerBound) { + public static Expression atLeast(String tagName, Double lowerBound) { return new WithinRangeExpression(tagName, lowerBound.longValue(), null); } 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 81d97c1f..1a07dc89 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -1,6 +1,7 @@ package com.protomaps.basemap.layers; import static com.onthegomap.planetiler.util.Parse.parseDoubleOrNull; +import static com.protomaps.basemap.feature.Matcher.atLeast; import static com.protomaps.basemap.feature.Matcher.fromTag; import static com.protomaps.basemap.feature.Matcher.getInteger; import static com.protomaps.basemap.feature.Matcher.getString; @@ -270,11 +271,11 @@ public Pois(QrankDb qrankDb) { rule(withinRange(WAYAREA, 10, 500), use(MINZOOM, 14)), rule(withinRange(WAYAREA, 500, 2000), use(MINZOOM, 13)), rule(withinRange(WAYAREA, 2000, 1e4), use(MINZOOM, 12)), - rule(withinRange(WAYAREA, 1e4), use(MINZOOM, 11)), + rule(atLeast(WAYAREA, 1e4), use(MINZOOM, 11)), rule(with(KIND, "playground"), use(MINZOOM, 17)), rule(with(KIND, "allotments"), withinRange(WAYAREA, 0, 10), use(MINZOOM, 16)), - rule(with(KIND, "allotments"), withinRange(WAYAREA, 10), use(MINZOOM, 15)), + rule(with(KIND, "allotments"), atLeast(WAYAREA, 10), use(MINZOOM, 15)), // Height-graded polygons, generic at first then per-kind adjustments // Small but tall features should show up early as they have regional prominence. @@ -282,7 +283,7 @@ public Pois(QrankDb qrankDb) { rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 10, 20), use(MINZOOM, 13)), rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 20, 100), use(MINZOOM, 12)), - rule(withinRange(WAYAREA, 10, 2000), withinRange(HEIGHT, 100), use(MINZOOM, 11)), + rule(withinRange(WAYAREA, 10, 2000), atLeast(HEIGHT, 100), use(MINZOOM, 11)), // Clamp certain kind values so medium tall buildings don't crowd downtown areas // NOTE: (nvkelso 20230623) Apply label grid to early zooms of POIs layer @@ -305,7 +306,7 @@ public Pois(QrankDb qrankDb) { rule(WITH_S_C, withinRange(WAYAREA, 10, 100), use(MINZOOM, 15)), rule(WITH_S_C, withinRange(WAYAREA, 100, 1000), use(MINZOOM, 14)), rule(WITH_S_C, withinRange(WAYAREA, 1000, 5000), use(MINZOOM, 13)), - rule(WITH_S_C, withinRange(WAYAREA, 5000), use(MINZOOM, 12)), + rule(WITH_S_C, atLeast(WAYAREA, 5000), use(MINZOOM, 12)), // National parks @@ -319,7 +320,7 @@ public Pois(QrankDb qrankDb) { rule(WITH_N_P, withinRange(WAYAREA, 2e6, 1e7), use(MINZOOM, 8)), rule(WITH_N_P, withinRange(WAYAREA, 1e7, 2.5e7), use(MINZOOM, 7)), rule(WITH_N_P, withinRange(WAYAREA, 2.5e7, 3e8), use(MINZOOM, 6)), - rule(WITH_N_P, withinRange(WAYAREA, 3e8), use(MINZOOM, 5)), + rule(WITH_N_P, atLeast(WAYAREA, 3e8), use(MINZOOM, 5)), // College and university polygons @@ -331,7 +332,7 @@ public Pois(QrankDb qrankDb) { rule(WITH_C_U, withinRange(WAYAREA, 1.5e5, 2.5e5), use(MINZOOM, 10)), rule(WITH_C_U, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), rule(WITH_C_U, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), - rule(WITH_C_U, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)), + rule(WITH_C_U, atLeast(WAYAREA, 2e7), use(MINZOOM, 7)), rule(WITH_C_U, with("name", "Academy of Art University"), use(MINZOOM, 14)), // Hack for weird San Francisco university // Big green polygons @@ -346,7 +347,7 @@ public Pois(QrankDb qrankDb) { rule(WITH_B_G, withinRange(WAYAREA, 2.5e5, 1e6), use(MINZOOM, 10)), rule(WITH_B_G, withinRange(WAYAREA, 1e6, 4e6), use(MINZOOM, 9)), rule(WITH_B_G, withinRange(WAYAREA, 4e6, 1e7), use(MINZOOM, 8)), - rule(WITH_B_G, withinRange(WAYAREA, 1e7), use(MINZOOM, 7)), + rule(WITH_B_G, atLeast(WAYAREA, 1e7), use(MINZOOM, 7)), // Remaining grab-bag of scaled kinds @@ -357,7 +358,7 @@ public Pois(QrankDb qrankDb) { rule(WITH_ETC, withinRange(WAYAREA, 1e5, 2.5e5), use(MINZOOM, 10)), rule(WITH_ETC, withinRange(WAYAREA, 2.5e5, 5e6), use(MINZOOM, 9)), rule(WITH_ETC, withinRange(WAYAREA, 5e6, 2e7), use(MINZOOM, 8)), - rule(WITH_ETC, withinRange(WAYAREA, 2e7), use(MINZOOM, 7)) + rule(WITH_ETC, atLeast(WAYAREA, 2e7), use(MINZOOM, 7)) )).index(); diff --git a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java index 4fd641ba..81e17ffe 100644 --- a/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/feature/MatcherTest.java @@ -3,6 +3,7 @@ import static com.onthegomap.planetiler.TestUtils.newLineString; import static com.onthegomap.planetiler.TestUtils.newPoint; import static com.onthegomap.planetiler.TestUtils.newPolygon; +import static com.protomaps.basemap.feature.Matcher.atLeast; import static com.protomaps.basemap.feature.Matcher.fromTag; import static com.protomaps.basemap.feature.Matcher.getBoolean; import static com.protomaps.basemap.feature.Matcher.getDouble; @@ -704,7 +705,7 @@ void testGetBooleanFromTag() { } @Test - void testWithinRangeWithUpperBound() { + void testWithinRange() { var expression = withinRange("population", 5, 10); // Value within range (5 < 7 <= 10) @@ -759,8 +760,8 @@ void testWithinRangeWithUpperBound() { } @Test - void testWithinRangeWithoutUpperBound() { - var expression = withinRange("population", 5); + void testAtLeast() { + var expression = atLeast("population", 5); // Value above lower bound var sf = SimpleFeature.create( From 0f0a42795f3e773c4d4358e2a442030ac18b2684 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 18:43:33 -0800 Subject: [PATCH 34/76] Recoded Claude-provided plan for Overture addition --- OVERTURE.md | 441 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 OVERTURE.md diff --git a/OVERTURE.md b/OVERTURE.md new file mode 100644 index 00000000..411c3ea5 --- /dev/null +++ b/OVERTURE.md @@ -0,0 +1,441 @@ +# Plan for Adding Overture Maps Support to Basemap.java + +This document outlines how to expand Basemap.java to accept Overture Maps data from Geoparquet files as an alternative to OSM input. + +## Overview + +Add `--overture` argument to accept Overture Maps Geoparquet data as an alternative to `--area` (OSM data). These options are mutually exclusive. + +## Overture Data Structure + +Overture Maps data is organized in Hive-partitioned Geoparquet files: +``` +theme=buildings/type=building/*.parquet +theme=buildings/type=building_part/*.parquet +theme=places/type=place/*.parquet +theme=transportation/type=segment/*.parquet +theme=transportation/type=connector/*.parquet +theme=base/type=water/*.parquet +theme=base/type=land/*.parquet +theme=base/type=land_use/*.parquet +theme=base/type=land_cover/*.parquet +theme=base/type=infrastructure/*.parquet +theme=base/type=bathymetry/*.parquet +theme=divisions/type=division/*.parquet +theme=divisions/type=division_area/*.parquet +theme=divisions/type=division_boundary/*.parquet +theme=addresses/type=address/*.parquet +``` + +**Note:** The base theme includes `water`, `land`, and `land_cover` types which replace the three GeoPackage sources (`osm_water`, `osm_land`, `landcover`) used in OSM mode. + +## Code Changes Required + +### 1. Command-line Argument Handling + +**Location:** `Basemap.java:163-216` + +Add `--overture` argument accepting a directory path containing Hive-partitioned Overture data. + +```java +String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", ""); +String overtureBase = args.getString("overture", "Path to Overture Maps base directory", ""); + +// Validate mutual exclusivity +if (!area.isEmpty() && !overtureBase.isEmpty()) { + LOGGER.error("Error: Cannot specify both --area and --overture"); + System.exit(1); +} +if (area.isEmpty() && overtureBase.isEmpty()) { + area = "monaco"; // default +} +``` + +**Update help text** to document `--overture=` option. + +### 2. Data Source Configuration + +**Location:** `Basemap.java:214-222` + +Add multiple `.addParquetSource()` calls for different Overture themes when `--overture` is specified. + +```java +if (!overtureBase.isEmpty()) { + Path base = Path.of(overtureBase); + + // Buildings + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=buildings", "type=building", "*.parquet").find(), + true, // hive-partitioning + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Building parts + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=buildings", "type=building_part", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Places + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=places", "type=place", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Transportation segments (roads) + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=transportation", "type=segment", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Transportation connectors + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=transportation", "type=connector", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Water + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=base", "type=water", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Land + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=base", "type=land", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Land use + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=base", "type=land_use", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + + // Divisions (boundaries) + planetiler.addParquetSource("overture", + Glob.of(base).resolve("theme=divisions", "type=division", "*.parquet").find(), + true, + fields -> fields.get("id"), + fields -> fields.get("type") + ); + +} else { + // Add OSM source (existing code) + planetiler.addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area); + + // Add GeoPackage sources (existing code) - these are OSM-specific + planetiler.addGeoPackageSource("osm_water", sourcesDir.resolve("water-polygons-split-4326.gpkg"), + "https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip"); + planetiler.addGeoPackageSource("osm_land", sourcesDir.resolve("land-polygons-split-4326.gpkg"), + "https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip"); + planetiler.addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"), + "https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg"); +} +``` + +**Important:** When using `--overture`, the three GeoPackage sources (`osm_water`, `osm_land`, `landcover`) are NOT added because Overture's base theme already includes `water`, `land`, and `land_cover` types that serve the same purpose. The handler registration for these layers will remain unchanged but they will receive data from Overture's base theme instead of the GeoPackage files. + +**Key points:** +- All sources use name `"overture"` (routing is by source layer, not source name) +- Enable Hive partitioning with `true` flag +- ID extraction: `fields -> fields.get("id")` +- Layer extraction: `fields -> fields.get("type")` +- Use `Glob.of().resolve().find()` to get `List` of partitioned files + +### 3. Source Handler Registration + +**Location:** `Basemap.java:36-103` (constructor) + +Add `registerSourceHandler("overture", ...)` calls for each layer: + +```java +if (layer.isEmpty() || layer.equals(Buildings.LAYER_NAME)) { + var buildings = new Buildings(); + registerHandler(buildings); + registerSourceHandler("osm", buildings::processOsm); + registerSourceHandler("overture", buildings::processOverture); // NEW +} + +if (layer.isEmpty() || layer.equals(Places.LAYER_NAME)) { + var place = new Places(countryCoder); + registerHandler(place); + registerSourceHandler("osm", place::processOsm); + registerSourceHandler("overture", place::processOverture); // NEW +} + +if (layer.isEmpty() || layer.equals(Roads.LAYER_NAME)) { + var roads = new Roads(countryCoder); + registerHandler(roads); + registerSourceHandler("osm", roads::processOsm); + registerSourceHandler("overture", roads::processOverture); // NEW +} + +// Water - existing registrations remain, but data source changes +if (layer.isEmpty() || layer.equals(Water.LAYER_NAME)) { + var water = new Water(); + registerHandler(water); + registerSourceHandler("osm", water::processOsm); + registerSourceHandler("osm_water", water::processPreparedOsm); // OSM GeoPackage + registerSourceHandler("overture", water::processOverture); // NEW - from base theme +} + +// Earth - existing registrations remain, but data source changes +if (layer.isEmpty() || layer.equals(Earth.LAYER_NAME)) { + var earth = new Earth(); + registerHandler(earth); + registerSourceHandler("osm", earth::processOsm); + registerSourceHandler("osm_land", earth::processPreparedOsm); // OSM GeoPackage + registerSourceHandler("overture", earth::processOverture); // NEW - from base theme +} + +// Landcover - existing registrations remain, but data source changes +if (layer.isEmpty() || layer.equals(Landcover.LAYER_NAME)) { + var landcover = new Landcover(); + registerHandler(landcover); + registerSourceHandler("landcover", landcover::processLandcover); // OSM GeoPackage + registerSourceHandler("overture", landcover::processOverture); // NEW - from base theme +} + +// Repeat for all other layers... +``` + +**Note:** The existing handlers for `osm_water`, `osm_land`, and `landcover` are kept because they're only called when those GeoPackage sources are added (OSM mode). When using `--overture`, those sources aren't added, so only the new `overture` handlers will be called. + +### 4. Layer Processing Methods + +**Location:** Individual layer files (Buildings.java, Places.java, Roads.java, etc.) + +Add `processOverture()` methods in each layer class. Filter by `feature.getSourceLayer()` which comes from the Hive partition `type=` value. + +**Example for Buildings.java:** + +```java +public void processOverture(SourceFeature feature, FeatureCollector features) { + String sourceLayer = feature.getSourceLayer(); + + // Filter by source layer (from Hive partition type=building or type=building_part) + if (!"building".equals(sourceLayer) && !"building_part".equals(sourceLayer)) { + return; + } + + // Read Overture attributes + Double height = feature.getDouble("height"); + String roofColor = feature.getString("roof_color"); + + // Extract nested names (Overture uses structured names object) + String primaryName = feature.getString("names", "primary"); + + String kind = "building_part".equals(sourceLayer) ? "building_part" : "building"; + Integer minZoom = "building_part".equals(sourceLayer) ? 14 : 11; + + features.polygon(LAYER_NAME) + .setId(FeatureId.create(feature)) + .setAttr("kind", kind) + .setAttr("name", primaryName) + .setAttr("height", height) + .setAttr("roof_color", roofColor) + .setAttr("sort_rank", 400) + .setZoomRange(minZoom, 15); +} +``` + +**Example for Places.java:** + +```java +public void processOverture(SourceFeature feature, FeatureCollector features) { + String sourceLayer = feature.getSourceLayer(); + + // Filter by source layer + if (!"place".equals(sourceLayer)) { + return; + } + + // Read Overture structured data + String primaryName = feature.getString("names", "primary"); + String primaryCategory = feature.getString("categories", "primary"); + + // Map Overture categories to basemap place types + String placeType = mapOvertureCategory(primaryCategory); + + features.point(LAYER_NAME) + .setAttr("name", primaryName) + .setAttr("place_type", placeType) + .setMinZoom(calculateMinZoom(primaryCategory)); +} +``` + +**Example for Water.java:** + +```java +public void processOverture(SourceFeature feature, FeatureCollector features) { + String sourceLayer = feature.getSourceLayer(); + + // Filter by source layer - Overture base theme water + if (!"water".equals(sourceLayer)) { + return; + } + + // Read Overture water attributes + String subtype = feature.getString("subtype"); // e.g., "lake", "river", "ocean" + String primaryName = feature.getString("names", "primary"); + + features.polygon(LAYER_NAME) + .setAttr("kind", subtype) + .setAttr("name", primaryName) + .setZoomRange(0, 15); +} +``` + +**Example for Earth.java (land polygons):** + +```java +public void processOverture(SourceFeature feature, FeatureCollector features) { + String sourceLayer = feature.getSourceLayer(); + + // Filter by source layer - Overture base theme land + if (!"land".equals(sourceLayer)) { + return; + } + + features.polygon(LAYER_NAME) + .setAttr("kind", "land") + .setZoomRange(0, 15); +} +``` + +**Example for Landcover.java:** + +```java +public void processOverture(SourceFeature feature, FeatureCollector features) { + String sourceLayer = feature.getSourceLayer(); + + // Filter by source layer - Overture base theme land_cover + if (!"land_cover".equals(sourceLayer)) { + return; + } + + // Read Overture land cover attributes + String subtype = feature.getString("subtype"); // e.g., "forest", "grass", "wetland" + + features.polygon(LAYER_NAME) + .setAttr("kind", subtype) + .setZoomRange(calculateMinZoom(subtype), 15); +} +``` + +**Key differences from OSM processing:** +- Use `feature.getSourceLayer()` for routing, NOT tag checking +- Source layer comes from Hive partition path (e.g., `type=building`) +- Access nested JSON properties: `feature.getString("names", "primary")` +- Overture uses `subtype` instead of OSM tag combinations +- No `hasTag()` checks needed - source layer determines feature type + +### 5. Overture Schema Mapping + +Overture Maps uses a different schema than OSM: + +**Overture structure:** +- `names` object with `primary`, `common`, etc. subfields +- `addresses` object for structured address data +- `categories` object with `primary` and `alternate` fields +- `subtype` field for classification (instead of OSM tags) +- Organized by theme/type in Hive partitions + +**Mapping examples:** +- OSM `building=yes` → Overture `theme=buildings` + `type=building` +- OSM `amenity=restaurant` → Overture `theme=places` + `type=place` + `categories.primary=restaurant` +- OSM `highway=primary` → Overture `theme=transportation` + `type=segment` + `subtype=road` + +### 6. Output Filename + +**Location:** `Basemap.java:267` + +```java +String outputName; +if (!overtureBase.isEmpty()) { + outputName = "overture-basemap"; +} else { + outputName = area; +} +planetiler.setOutput(Path.of(outputName + ".pmtiles")); +``` + +## Summary of Required Files + +| File | Changes | +|------|---------| +| `Basemap.java` | Add `--overture` argument, validation, conditional `.addParquetSource()` calls, source handler registration, output filename | +| `Buildings.java` | Add `processOverture()` method | +| `Places.java` | Add `processOverture()` method | +| `Roads.java` | Add `processOverture()` method | +| `Transit.java` | Add `processOverture()` method | +| `Water.java` | Add `processOverture()` method | +| `Earth.java` | Add `processOverture()` method | +| `Landuse.java` | Add `processOverture()` method | +| `Landcover.java` | Add `processOverture()` method | +| `Boundaries.java` | Add `processOverture()` method | +| `Pois.java` | Add `processOverture()` method | + +## Planetiler Library Support + +Planetiler provides robust built-in support: + +1. **ParquetReader** - handles file reading, Hive partitioning, WKB/WKT geometry parsing +2. **`Planetiler.addParquetSource()`** - adds Geoparquet to processing pipeline +3. **`SourceFeature` interface** - common abstraction for all source types +4. **Bounding box filtering** - built into ParquetReader for efficient spatial queries +5. **Multi-file support** - reads multiple partitioned files efficiently + +## Implementation Phases + +### Phase 1: Core Infrastructure +1. Add `--overture` argument and validation +2. Add conditional `.addParquetSource()` calls for all Overture themes +3. Update help text + +### Phase 2: Layer Implementation +1. Implement `processOverture()` for Buildings layer (proof of concept) +2. Test with Overture buildings data +3. Implement `processOverture()` for remaining layers +4. Create Overture→basemap schema mapping for each layer + +### Phase 3: Polish +1. Test with complete Overture dataset +2. Add integration tests +3. Profile performance and optimize if needed +4. Document Overture schema mappings + +## Key Implementation Notes + +1. **Routing mechanism:** All features from all Overture sources go to all registered handlers. Each handler filters by checking `feature.getSourceLayer()` value. + +2. **Hive partitions:** The `type=` value in partition paths becomes the source layer name. Example: files in `theme=buildings/type=building/` have source layer `"building"`. + +3. **Multiple files:** Each `.addParquetSource()` call processes many partitioned files via `Glob.of().resolve().find()`. + +4. **Source name:** All Overture sources use the same name `"overture"` because routing is by source layer, not source name. + +5. **No remote URL support:** Parquet sources don't support URLs directly. Users must download Overture data first or we must implement download logic separately. + +## Resources + +- [Overture Maps Schema Documentation](https://docs.overturemaps.org/schema/) +- [Overture Maps Data Downloads](https://overturemaps.org/download/) +- [Overture Maps GitHub](https://github.com/OvertureMaps) +- Planetiler example: `planetiler-examples/.../overture/OvertureBasemap.java` From 9d158b1b943ea7a7839f6f14ecc72b2881d89436 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 27 Dec 2025 22:41:34 -0800 Subject: [PATCH 35/76] Added script to download Overture data as Parquet --- tiles/get-overture.py | 139 +++++++++++++++++++++++++++++++++++++++++ tiles/requirements.txt | 1 + 2 files changed, 140 insertions(+) create mode 100755 tiles/get-overture.py create mode 100644 tiles/requirements.txt diff --git a/tiles/get-overture.py b/tiles/get-overture.py new file mode 100755 index 00000000..3331674f --- /dev/null +++ b/tiles/get-overture.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Download Overture Maps data for a given GeoJSON bounding box. +Uses DuckDB to efficiently query S3-hosted Parquet files with spatial partitioning. +""" + +import argparse +import json +import sys +import duckdb + + +def get_bbox_from_geojson(geojson_path): + """Extract bounding box from GeoJSON file.""" + with open(geojson_path, 'r') as f: + data = json.load(f) + + # Collect all coordinates + coords = [] + for feature in data.get('features', []): + geom = feature.get('geometry', {}) + geom_type = geom.get('type') + geom_coords = geom.get('coordinates', []) + + if geom_type == 'Polygon': + # Flatten polygon coordinates + for ring in geom_coords: + coords.extend(ring) + elif geom_type == 'MultiPolygon': + for polygon in geom_coords: + for ring in polygon: + coords.extend(ring) + elif geom_type == 'Point': + coords.append(geom_coords) + elif geom_type == 'LineString': + coords.extend(geom_coords) + + if not coords: + raise ValueError("No coordinates found in GeoJSON") + + # Calculate bounding box + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + + return { + 'xmin': min(lons), + 'ymin': min(lats), + 'xmax': max(lons), + 'ymax': max(lats) + } + + +def query_overture_data(bbox, output_path): + """ + Query Overture Maps data using DuckDB with spatial partition filtering. + """ + print(f"Bounding box: {bbox}") + print("Connecting to DuckDB and configuring S3 access...") + + # Create DuckDB connection + con = duckdb.connect() + + # Install and load spatial extension + con.execute("INSTALL spatial;") + con.execute("LOAD spatial;") + + # Install and load httpfs for S3 access + con.execute("INSTALL httpfs;") + con.execute("LOAD httpfs;") + + # Configure for anonymous S3 access + con.execute("SET s3_region='us-west-2';") + con.execute("SET s3_url_style='path';") + + # Overture base path - all themes, will filter by theme in WHERE clause + base_path = "s3://overturemaps-us-west-2/release/2025-12-17.0" + + print("\nQuerying Overture transportation and places data with bbox filtering...") + print("Using Hive partitioning to filter themes efficiently.") + + # Query with bbox and theme filtering + # The theme is a Hive partition, so filtering on it should be efficient + query = f""" + COPY ( + SELECT * + FROM read_parquet('{base_path}/**/*.parquet', + hive_partitioning=1, + filename=1, + union_by_name=1) + WHERE theme IN ('transportation', 'places', 'buildings') + AND bbox.xmin <= {bbox['xmax']} + AND bbox.xmax >= {bbox['xmin']} + AND bbox.ymin <= {bbox['ymax']} + AND bbox.ymax >= {bbox['ymin']} + ) TO '{output_path}' (FORMAT PARQUET); + """ + + print("\nExecuting query...") + print("(This may take a few minutes depending on partition overlap)") + + try: + con.execute(query) + print(f"\n✓ Successfully wrote data to: {output_path}") + + # Get some stats + result = con.execute(f"SELECT COUNT(*) as count FROM read_parquet('{output_path}')").fetchone() + print(f"✓ Total features retrieved: {result[0]:,}") + + except Exception as e: + print(f"\n✗ Error during query: {e}", file=sys.stderr) + raise + finally: + con.close() + + +def main(): + parser = argparse.ArgumentParser( + description='Download Overture Maps data for a GeoJSON bounding box' + ) + parser.add_argument('geojson', help='Input GeoJSON file defining the area') + parser.add_argument('output', help='Output Parquet file path') + + args = parser.parse_args() + + try: + # Extract bounding box from GeoJSON + print(f"Reading GeoJSON from: {args.geojson}") + bbox = get_bbox_from_geojson(args.geojson) + + # Query Overture data + query_overture_data(bbox, args.output) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/tiles/requirements.txt b/tiles/requirements.txt new file mode 100644 index 00000000..3ae8f46a --- /dev/null +++ b/tiles/requirements.txt @@ -0,0 +1 @@ +duckdb==1.4.3 From 510092827d9710d41c265afcaee33bcf45d002e6 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sun, 28 Dec 2025 21:57:31 -0800 Subject: [PATCH 36/76] Updated Overture plan with some manual edits --- OVERTURE.md | 103 +++++++++++------------------------------- tiles/get-overture.py | 2 +- 2 files changed, 28 insertions(+), 77 deletions(-) diff --git a/OVERTURE.md b/OVERTURE.md index 411c3ea5..f323b78b 100644 --- a/OVERTURE.md +++ b/OVERTURE.md @@ -8,7 +8,7 @@ Add `--overture` argument to accept Overture Maps Geoparquet data as an alternat ## Overture Data Structure -Overture Maps data is organized in Hive-partitioned Geoparquet files: +Overture Maps data is organized in Geoparquet files, sometimes Hive-partitioned but not always: ``` theme=buildings/type=building/*.parquet theme=buildings/type=building_part/*.parquet @@ -57,84 +57,21 @@ if (area.isEmpty() && overtureBase.isEmpty()) { **Location:** `Basemap.java:214-222` -Add multiple `.addParquetSource()` calls for different Overture themes when `--overture` is specified. +Add a `.addParquetSource()` call for all Overture data when `--overture` is specified. ```java if (!overtureBase.isEmpty()) { - Path base = Path.of(overtureBase); - - // Buildings - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=buildings", "type=building", "*.parquet").find(), - true, // hive-partitioning - fields -> fields.get("id"), - fields -> fields.get("type") - ); - - // Building parts - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=buildings", "type=building_part", "*.parquet").find(), - true, - fields -> fields.get("id"), - fields -> fields.get("type") - ); - - // Places - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=places", "type=place", "*.parquet").find(), - true, - fields -> fields.get("id"), - fields -> fields.get("type") - ); - - // Transportation segments (roads) - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=transportation", "type=segment", "*.parquet").find(), - true, - fields -> fields.get("id"), - fields -> fields.get("type") - ); - - // Transportation connectors - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=transportation", "type=connector", "*.parquet").find(), - true, - fields -> fields.get("id"), - fields -> fields.get("type") - ); - - // Water + // All Overture themes together - untested code, hopefully this works okay planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=base", "type=water", "*.parquet").find(), + Glob.of(Path.of(overtureBase)).resolve("*.parquet").find(), true, fields -> fields.get("id"), + // This specifically is uncertain, since type and subtype work differently for different themes, e.g.: + // - theme=buildings in its entirety with multiple types + // - theme=transportation + type=segment for roads, rails, other lines + // - theme=base will split up into type=land, type=land_cover, and type=water fields -> fields.get("type") ); - - // Land - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=base", "type=land", "*.parquet").find(), - true, - fields -> fields.get("id"), - fields -> fields.get("type") - ); - - // Land use - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=base", "type=land_use", "*.parquet").find(), - true, - fields -> fields.get("id"), - fields -> fields.get("type") - ); - - // Divisions (boundaries) - planetiler.addParquetSource("overture", - Glob.of(base).resolve("theme=divisions", "type=division", "*.parquet").find(), - true, - fields -> fields.get("id"), - fields -> fields.get("type") - ); - } else { // Add OSM source (existing code) planetiler.addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area); @@ -351,11 +288,25 @@ public void processOverture(SourceFeature feature, FeatureCollector features) { Overture Maps uses a different schema than OSM: **Overture structure:** -- `names` object with `primary`, `common`, etc. subfields -- `addresses` object for structured address data -- `categories` object with `primary` and `alternate` fields -- `subtype` field for classification (instead of OSM tags) -- Organized by theme/type in Hive partitions + +Overture features are primarily organized by "theme" and "type", these are the most important things to know about them: +- "id" and "geometry" always exist +- "level_rules" designates rendering z-order sections +- "names"."primary" holds the most important name +- theme=transportation and type=segment includes highways, railways, and waterways + - Highways have subtype=road, OSM-like "motorway", "residential", etc. class values, and "road_flags" to designate e.g. bridge or tunnel sections + - Railways have subtype=rail, "standard_gauge", "subway", etc. class values, and "rail_flags" to designate e.g. bridge or tunnel sections + - Waterways have subtype=water +- theme=base includes land, land_cover, and water + - General land has type=land + - Bodies of water have type=water, "subtype" with type of water body, and "class" with further description + - Areas of land have type=land_cover and "subtype" with type of land cover +- theme=buildings includes representations of buildings and building parts + - All buildings and building parts can have height in meters + - Whole buildings have type=building, and optionally boolean "has_parts" + - Parts of buildings have type=building_part and can show higher-detail parts (like towers vs. bases) instead a generic whole building + +Complete Overture schema reference is at https://docs.overturemaps.org/schema/reference/ **Mapping examples:** - OSM `building=yes` → Overture `theme=buildings` + `type=building` diff --git a/tiles/get-overture.py b/tiles/get-overture.py index 3331674f..c324defb 100755 --- a/tiles/get-overture.py +++ b/tiles/get-overture.py @@ -87,7 +87,7 @@ def query_overture_data(bbox, output_path): hive_partitioning=1, filename=1, union_by_name=1) - WHERE theme IN ('transportation', 'places', 'buildings') + WHERE theme IN ('transportation', 'places', 'base') AND bbox.xmin <= {bbox['xmax']} AND bbox.xmax >= {bbox['xmin']} AND bbox.ymin <= {bbox['ymax']} From d184f3db8b7ed6e1b56f36aa94dc21d1dc911f0a Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 12:28:39 -0800 Subject: [PATCH 37/76] Implemented --overture CLI flag and got it working with just land & water at first --- OVERTURE.md | 246 ++++++++++++------ .../java/com/protomaps/basemap/Basemap.java | 57 +++- .../com/protomaps/basemap/layers/Earth.java | 15 ++ .../com/protomaps/basemap/layers/Water.java | 25 ++ 4 files changed, 250 insertions(+), 93 deletions(-) diff --git a/OVERTURE.md b/OVERTURE.md index f323b78b..d202a10d 100644 --- a/OVERTURE.md +++ b/OVERTURE.md @@ -2,10 +2,29 @@ This document outlines how to expand Basemap.java to accept Overture Maps data from Geoparquet files as an alternative to OSM input. +## Implementation Status + +**Phase 1 Complete (2025-12-29):** Basic infrastructure with land and water layers +- Added `--overture` CLI flag accepting single Parquet file path +- Implemented mutual exclusivity between `--overture` and `--area` +- Conditional data source loading (Overture Parquet vs OSM+GeoPackage) +- Natural Earth retained for low-zoom (0-5) rendering in both modes +- Implemented Water.java::processOverture() for theme=base/type=water +- Implemented Earth.java::processOverture() for theme=base/type=land +- Output filename derived from input Parquet file basename +- Successfully tested with lake-merritt-slice-overture.parquet + +**Remaining Work:** +- Implement processOverture() methods for other layers (Buildings, Places, Roads, Transit, Landuse, Landcover, Boundaries, Pois) +- Handle nested JSON fields properly (currently warnings for `names.primary` access) +- Support for additional Overture themes beyond base theme + ## Overview Add `--overture` argument to accept Overture Maps Geoparquet data as an alternative to `--area` (OSM data). These options are mutually exclusive. +**Implementation Note:** The `--overture` argument accepts a **single Parquet file path**, not a directory. This differs from the original plan but matches the actual usage pattern in the Makefile. + ## Overture Data Structure Overture Maps data is organized in Geoparquet files, sometimes Hive-partitioned but not always: @@ -33,134 +52,184 @@ theme=addresses/type=address/*.parquet ### 1. Command-line Argument Handling -**Location:** `Basemap.java:163-216` +**Location:** `Basemap.java:213-223` **IMPLEMENTED** -Add `--overture` argument accepting a directory path containing Hive-partitioned Overture data. +Added `--overture` argument accepting a **single Parquet file path** (not directory). ```java String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", ""); -String overtureBase = args.getString("overture", "Path to Overture Maps base directory", ""); +String overtureFile = args.getString("overture", "Path to Overture Maps Parquet file", ""); // Validate mutual exclusivity -if (!area.isEmpty() && !overtureBase.isEmpty()) { +if (!area.isEmpty() && !overtureFile.isEmpty()) { LOGGER.error("Error: Cannot specify both --area and --overture"); System.exit(1); } -if (area.isEmpty() && overtureBase.isEmpty()) { +if (area.isEmpty() && overtureFile.isEmpty()) { area = "monaco"; // default } ``` -**Update help text** to document `--overture=` option. +**Help text updated** at `Basemap.java:176` to document `--overture=` option. ### 2. Data Source Configuration -**Location:** `Basemap.java:214-222` +**Location:** `Basemap.java:225-246` **IMPLEMENTED** -Add a `.addParquetSource()` call for all Overture data when `--overture` is specified. +Added conditional `.addParquetSource()` call for single Overture file when `--overture` is specified. Natural Earth source is **always added** for low-zoom (0-5) rendering. ```java -if (!overtureBase.isEmpty()) { - // All Overture themes together - untested code, hopefully this works okay +var planetiler = Planetiler.create(args) + .addNaturalEarthSource("ne", nePath, neUrl); // ALWAYS added for low zooms + +if (!overtureFile.isEmpty()) { + // Add Overture Parquet source planetiler.addParquetSource("overture", - Glob.of(Path.of(overtureBase)).resolve("*.parquet").find(), - true, + List.of(Path.of(overtureFile)), + true, // enable Hive partitioning fields -> fields.get("id"), - // This specifically is uncertain, since type and subtype work differently for different themes, e.g.: - // - theme=buildings in its entirety with multiple types - // - theme=transportation + type=segment for roads, rails, other lines - // - theme=base will split up into type=land, type=land_cover, and type=water fields -> fields.get("type") ); } else { - // Add OSM source (existing code) - planetiler.addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area); - - // Add GeoPackage sources (existing code) - these are OSM-specific - planetiler.addGeoPackageSource("osm_water", sourcesDir.resolve("water-polygons-split-4326.gpkg"), - "https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip"); - planetiler.addGeoPackageSource("osm_land", sourcesDir.resolve("land-polygons-split-4326.gpkg"), - "https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip"); - planetiler.addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"), - "https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg"); + // Add OSM and GeoPackage sources + planetiler + .addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area) + .addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"), + "https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip") + .addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"), + "https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip") + .addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"), + "https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg"); } ``` -**Important:** When using `--overture`, the three GeoPackage sources (`osm_water`, `osm_land`, `landcover`) are NOT added because Overture's base theme already includes `water`, `land`, and `land_cover` types that serve the same purpose. The handler registration for these layers will remain unchanged but they will receive data from Overture's base theme instead of the GeoPackage files. +**Important Changes from Original Plan:** +- Natural Earth is **retained** in Overture mode for low-zoom rendering (zooms 0-5) +- Single Parquet file path instead of directory with glob pattern +- The three GeoPackage sources (`osm_water`, `osm_land`, `landcover`) are NOT added in Overture mode +- Overture's base theme `water` and `land` types provide zoom 6+ data +- Natural Earth + Overture together provide complete zoom 0-15 coverage **Key points:** -- All sources use name `"overture"` (routing is by source layer, not source name) -- Enable Hive partitioning with `true` flag +- Source name: `"overture"` +- Enable Hive partitioning: `true` - ID extraction: `fields -> fields.get("id")` -- Layer extraction: `fields -> fields.get("type")` -- Use `Glob.of().resolve().find()` to get `List` of partitioned files +- Layer extraction: `fields -> fields.get("type")` (gets "water", "land", etc. from Hive partition) +- Single file path wrapped in `List.of()` ### 3. Source Handler Registration -**Location:** `Basemap.java:36-103` (constructor) +**Location:** `Basemap.java:88-104` (constructor) + +Add `registerSourceHandler("overture", ...)` calls for each layer. + +**IMPLEMENTED for Water and Earth layers:** + +```java +// Water - Natural Earth (zooms 0-5) + Overture base/water (zooms 6+) +if (layer.isEmpty() || layer.equals(Water.LAYER_NAME)) { + var water = new Water(); + registerHandler(water); + registerSourceHandler("osm", water::processOsm); + registerSourceHandler("osm_water", water::processPreparedOsm); // OSM GeoPackage + registerSourceHandler("ne", water::processNe); // Low-zoom (0-5) + registerSourceHandler("overture", water::processOverture); // IMPLEMENTED +} + +// Earth - Natural Earth (zooms 0-5) + Overture base/land (zooms 6+) +if (layer.isEmpty() || layer.equals(Earth.LAYER_NAME)) { + var earth = new Earth(); + registerHandler(earth); + registerSourceHandler("osm", earth::processOsm); + registerSourceHandler("osm_land", earth::processPreparedOsm); // OSM GeoPackage + registerSourceHandler("ne", earth::processNe); // Low-zoom (0-5) + registerSourceHandler("overture", earth::processOverture); // IMPLEMENTED +} +``` -Add `registerSourceHandler("overture", ...)` calls for each layer: +**TODO for remaining layers:** ```java if (layer.isEmpty() || layer.equals(Buildings.LAYER_NAME)) { var buildings = new Buildings(); registerHandler(buildings); registerSourceHandler("osm", buildings::processOsm); - registerSourceHandler("overture", buildings::processOverture); // NEW + registerSourceHandler("overture", buildings::processOverture); // TODO } if (layer.isEmpty() || layer.equals(Places.LAYER_NAME)) { var place = new Places(countryCoder); registerHandler(place); registerSourceHandler("osm", place::processOsm); - registerSourceHandler("overture", place::processOverture); // NEW + registerSourceHandler("overture", place::processOverture); // TODO } if (layer.isEmpty() || layer.equals(Roads.LAYER_NAME)) { var roads = new Roads(countryCoder); registerHandler(roads); registerSourceHandler("osm", roads::processOsm); - registerSourceHandler("overture", roads::processOverture); // NEW + registerSourceHandler("overture", roads::processOverture); // TODO } -// Water - existing registrations remain, but data source changes -if (layer.isEmpty() || layer.equals(Water.LAYER_NAME)) { - var water = new Water(); - registerHandler(water); - registerSourceHandler("osm", water::processOsm); - registerSourceHandler("osm_water", water::processPreparedOsm); // OSM GeoPackage - registerSourceHandler("overture", water::processOverture); // NEW - from base theme -} +// ... and others (Transit, Landuse, Landcover, Boundaries, Pois) +``` -// Earth - existing registrations remain, but data source changes -if (layer.isEmpty() || layer.equals(Earth.LAYER_NAME)) { - var earth = new Earth(); - registerHandler(earth); - registerSourceHandler("osm", earth::processOsm); - registerSourceHandler("osm_land", earth::processPreparedOsm); // OSM GeoPackage - registerSourceHandler("overture", earth::processOverture); // NEW - from base theme -} +**Note:** The existing handlers for `osm_water`, `osm_land`, and `landcover` are kept because they're only called when those GeoPackage sources are added (OSM mode). When using `--overture`, those sources aren't added, so only the Natural Earth and Overture handlers are called. -// Landcover - existing registrations remain, but data source changes -if (layer.isEmpty() || layer.equals(Landcover.LAYER_NAME)) { - var landcover = new Landcover(); - registerHandler(landcover); - registerSourceHandler("landcover", landcover::processLandcover); // OSM GeoPackage - registerSourceHandler("overture", landcover::processOverture); // NEW - from base theme -} +### 4. Layer Processing Methods + +**Location:** Individual layer files + +Add `processOverture()` methods in each layer class. Filter by `feature.getSourceLayer()` which comes from the Hive partition `type=` value. + +**IMPLEMENTED - Water.java::processOverture() (`Water.java:434-456`)** + +```java +public void processOverture(SourceFeature sf, FeatureCollector features) { + String sourceLayer = sf.getSourceLayer(); + + // Filter by source layer - Overture base theme water + if (!"water".equals(sourceLayer)) { + return; + } -// Repeat for all other layers... + // Read Overture water attributes + String subtype = sf.getString("subtype"); // e.g., "lake", "river", "ocean" + String primaryName = sf.getString("names", "primary"); + + if (sf.canBePolygon()) { + features.polygon(LAYER_NAME) + .setAttr("kind", subtype != null ? subtype : "water") + .setAttr("name", primaryName) + .setAttr("sort_rank", 200) + .setPixelTolerance(Earth.PIXEL_TOLERANCE) + .setMinZoom(6) + .setMinPixelSize(1.0) + .setBufferPixels(8); + } +} ``` -**Note:** The existing handlers for `osm_water`, `osm_land`, and `landcover` are kept because they're only called when those GeoPackage sources are added (OSM mode). When using `--overture`, those sources aren't added, so only the new `overture` handlers will be called. +**IMPLEMENTED - Earth.java::processOverture() (`Earth.java:78-91`)** -### 4. Layer Processing Methods +```java +public void processOverture(SourceFeature sf, FeatureCollector features) { + String sourceLayer = sf.getSourceLayer(); -**Location:** Individual layer files (Buildings.java, Places.java, Roads.java, etc.) + // Filter by source layer - Overture base theme land + if (!"land".equals(sourceLayer)) { + return; + } -Add `processOverture()` methods in each layer class. Filter by `feature.getSourceLayer()` which comes from the Hive partition `type=` value. + features.polygon(LAYER_NAME) + .setAttr("kind", "earth") + .setPixelTolerance(PIXEL_TOLERANCE) + .setMinZoom(6) + .setBufferPixels(8); +} +``` -**Example for Buildings.java:** +**TODO - Example for Buildings.java:** ```java public void processOverture(SourceFeature feature, FeatureCollector features) { @@ -315,33 +384,44 @@ Complete Overture schema reference is at https://docs.overturemaps.org/schema/re ### 6. Output Filename -**Location:** `Basemap.java:267` +**Location:** `Basemap.java:292-304` **IMPLEMENTED** + +Output filename is derived from the input Parquet file basename: ```java String outputName; -if (!overtureBase.isEmpty()) { - outputName = "overture-basemap"; +if (!overtureFile.isEmpty()) { + // Use base filename from input Parquet file + String filename = Path.of(overtureFile).getFileName().toString(); + // Remove .parquet extension if present + if (filename.endsWith(".parquet")) { + outputName = filename.substring(0, filename.length() - ".parquet".length()); + } else { + outputName = filename; + } } else { outputName = area; } planetiler.setOutput(Path.of(outputName + ".pmtiles")); ``` -## Summary of Required Files - -| File | Changes | -|------|---------| -| `Basemap.java` | Add `--overture` argument, validation, conditional `.addParquetSource()` calls, source handler registration, output filename | -| `Buildings.java` | Add `processOverture()` method | -| `Places.java` | Add `processOverture()` method | -| `Roads.java` | Add `processOverture()` method | -| `Transit.java` | Add `processOverture()` method | -| `Water.java` | Add `processOverture()` method | -| `Earth.java` | Add `processOverture()` method | -| `Landuse.java` | Add `processOverture()` method | -| `Landcover.java` | Add `processOverture()` method | -| `Boundaries.java` | Add `processOverture()` method | -| `Pois.java` | Add `processOverture()` method | +**Example:** Input `data/sources/lake-merritt-slice-overture.parquet` → Output `lake-merritt-slice-overture.pmtiles` + +## Summary of Implementation Status + +| File | Status | Changes | +|------|--------|---------| +| `Basemap.java` | **Complete** | Added `--overture` argument, validation, conditional `.addParquetSource()` calls, source handler registration for Water/Earth, output filename logic | +| `Water.java` | **Complete** | Added `processOverture()` method for theme=base/type=water | +| `Earth.java` | **Complete** | Added `processOverture()` method for theme=base/type=land | +| `Buildings.java` | **TODO** | Need to add `processOverture()` method for theme=buildings | +| `Places.java` | **TODO** | Need to add `processOverture()` method for theme=places | +| `Roads.java` | **TODO** | Need to add `processOverture()` method for theme=transportation/type=segment | +| `Transit.java` | **TODO** | Need to add `processOverture()` method for theme=transportation | +| `Landuse.java` | **TODO** | Need to add `processOverture()` method for theme=base/type=land_use | +| `Landcover.java` | **TODO** | Need to add `processOverture()` method for theme=base/type=land_cover | +| `Boundaries.java` | **TODO** | Need to add `processOverture()` method for theme=divisions | +| `Pois.java` | **TODO** | Need to add `processOverture()` method for theme=places | ## Planetiler Library Support diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index 74abfc68..7cabc3ce 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -91,6 +91,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, registerSourceHandler("osm", water::processOsm); registerSourceHandler("osm_water", water::processPreparedOsm); registerSourceHandler("ne", water::processNe); + registerSourceHandler("overture", water::processOverture); } if (layer.isEmpty() || layer.equals(Earth.LAYER_NAME)) { @@ -100,6 +101,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, registerSourceHandler("osm", earth::processOsm); registerSourceHandler("osm_land", earth::processPreparedOsm); registerSourceHandler("ne", earth::processNe); + registerSourceHandler("overture", earth::processOverture); } if (clip != null) { @@ -173,6 +175,7 @@ private static void printHelp() { --help, -h Show this help message and exit --area= Geofabrik area name to download, or filename in data/sources/ (default: monaco, e.g., us/california, washington) + --overture= Path to Overture Maps Parquet file (mutually exclusive with --area) --maxzoom= Maximum zoom level (default: 15) --layer= Process only a single layer (optional) Valid values: boundaries, buildings, landuse, landcover, @@ -209,17 +212,39 @@ static void run(Arguments args) throws IOException { var countryCoder = CountryCoder.fromJarResource(); - String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", "monaco"); + String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", ""); + String overtureFile = args.getString("overture", "Path to Overture Maps Parquet file", ""); + + if (!area.isEmpty() && !overtureFile.isEmpty()) { + LOGGER.error("Error: Cannot specify both --area and --overture"); + System.exit(1); + } + if (area.isEmpty() && overtureFile.isEmpty()) { + area = "monaco"; // default + } var planetiler = Planetiler.create(args) - .addNaturalEarthSource("ne", nePath, neUrl) - .addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area) - .addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"), - "https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip") - .addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"), - "https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip") - .addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"), - "https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg"); + .addNaturalEarthSource("ne", nePath, neUrl); + + if (!overtureFile.isEmpty()) { + // Add Overture Parquet source + planetiler.addParquetSource("overture", + List.of(Path.of(overtureFile)), + false, // not Hive partitioned dirname, just a single file + fields -> fields.get("id"), + fields -> fields.get("type") // source layer + ); + } else { + // Add OSM and GeoPackage sources + planetiler + .addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area) + .addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"), + "https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip") + .addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"), + "https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip") + .addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"), + "https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg"); + } Path pgfEncodingZip = sourcesDir.resolve("pgf-encoding.zip"); Path qrankCsv = sourcesDir.resolve("qrank.csv.gz"); @@ -263,8 +288,20 @@ static void run(Arguments args) throws IOException { fontRegistry.loadFontBundle("NotoSansDevanagari-Regular", "1", "Devanagari"); + String outputName; + if (!overtureFile.isEmpty()) { + String filename = Path.of(overtureFile).getFileName().toString(); + if (filename.endsWith(".parquet")) { + outputName = filename.substring(0, filename.length() - ".parquet".length()); + } else { + outputName = filename; + } + } else { + outputName = area; + } + planetiler.setProfile(new Basemap(qrankDb, countryCoder, clip, layer)) - .setOutput(Path.of(area + ".pmtiles")) + .setOutput(Path.of(outputName + ".pmtiles")) .run(); } } diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Earth.java b/tiles/src/main/java/com/protomaps/basemap/layers/Earth.java index f97734eb..a996969c 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Earth.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Earth.java @@ -75,6 +75,21 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } + public void processOverture(SourceFeature sf, FeatureCollector features) { + String type = sf.getString("type"); + + // Filter by type field - Overture base theme land + if (!"land".equals(type)) { + return; + } + + features.polygon(LAYER_NAME) + .setAttr("kind", "earth") + .setPixelTolerance(PIXEL_TOLERANCE) + .setMinZoom(6) + .setBufferPixels(8); + } + @Override public List postProcess(int zoom, List items) throws GeometryException { return FeatureMerge.mergeNearbyPolygons(items, MIN_AREA, MIN_AREA, 0.5, BUFFER); diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Water.java b/tiles/src/main/java/com/protomaps/basemap/layers/Water.java index 6ec197eb..d1215232 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Water.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Water.java @@ -431,6 +431,31 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } + public void processOverture(SourceFeature sf, FeatureCollector features) { + String type = sf.getString("type"); + + // Filter by type field - Overture base theme water + if (!"water".equals(type)) { + return; + } + + // Read Overture water attributes + String subtype = sf.getString("subtype"); // e.g., "lake", "river", "ocean" + // Access nested struct field: names.primary + String primaryName = sf.getString("names.primary"); + + if (sf.canBePolygon()) { + features.polygon(LAYER_NAME) + .setAttr("kind", subtype != null ? subtype : "water") + .setAttr("name", primaryName) + .setAttr("sort_rank", 200) + .setPixelTolerance(Earth.PIXEL_TOLERANCE) + .setMinZoom(6) + .setMinPixelSize(1.0) + .setBufferPixels(8); + } + } + @Override public List postProcess(int zoom, List items) throws GeometryException { items = FeatureMerge.mergeLineStrings(items, 0.5, Earth.PIXEL_TOLERANCE, 4.0); From 39bc1671ff10d96e03f5140521e2a47947f7dcb8 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 18:13:58 -0800 Subject: [PATCH 38/76] Expanded Overture theme=base to landuse layer --- OVERTURE.md | 24 ++----------------- .../java/com/protomaps/basemap/Basemap.java | 1 + .../com/protomaps/basemap/layers/Landuse.java | 23 ++++++++++++++++++ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/OVERTURE.md b/OVERTURE.md index d202a10d..cbd9c56d 100644 --- a/OVERTURE.md +++ b/OVERTURE.md @@ -15,7 +15,7 @@ This document outlines how to expand Basemap.java to accept Overture Maps data f - Successfully tested with lake-merritt-slice-overture.parquet **Remaining Work:** -- Implement processOverture() methods for other layers (Buildings, Places, Roads, Transit, Landuse, Landcover, Boundaries, Pois) +- Implement processOverture() methods for other layers (Buildings, Places, Roads, Transit, Boundaries, Pois) - Handle nested JSON fields properly (currently warnings for `names.primary` access) - Support for additional Overture themes beyond base theme @@ -325,26 +325,6 @@ public void processOverture(SourceFeature feature, FeatureCollector features) { } ``` -**Example for Landcover.java:** - -```java -public void processOverture(SourceFeature feature, FeatureCollector features) { - String sourceLayer = feature.getSourceLayer(); - - // Filter by source layer - Overture base theme land_cover - if (!"land_cover".equals(sourceLayer)) { - return; - } - - // Read Overture land cover attributes - String subtype = feature.getString("subtype"); // e.g., "forest", "grass", "wetland" - - features.polygon(LAYER_NAME) - .setAttr("kind", subtype) - .setZoomRange(calculateMinZoom(subtype), 15); -} -``` - **Key differences from OSM processing:** - Use `feature.getSourceLayer()` for routing, NOT tag checking - Source layer comes from Hive partition path (e.g., `type=building`) @@ -418,7 +398,7 @@ planetiler.setOutput(Path.of(outputName + ".pmtiles")); | `Places.java` | **TODO** | Need to add `processOverture()` method for theme=places | | `Roads.java` | **TODO** | Need to add `processOverture()` method for theme=transportation/type=segment | | `Transit.java` | **TODO** | Need to add `processOverture()` method for theme=transportation | -| `Landuse.java` | **TODO** | Need to add `processOverture()` method for theme=base/type=land_use | +| `Landuse.java` | **Complete** | Added `processOverture()` method for theme=base/type=land_cover|land_use | | `Landcover.java` | **TODO** | Need to add `processOverture()` method for theme=base/type=land_cover | | `Boundaries.java` | **TODO** | Need to add `processOverture()` method for theme=divisions | | `Pois.java` | **TODO** | Need to add `processOverture()` method for theme=places | diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index 7cabc3ce..9bd704c3 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -52,6 +52,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, var landuse = new Landuse(); registerHandler(landuse); registerSourceHandler("osm", landuse::processOsm); + registerSourceHandler("overture", landuse::processOverture); } if (layer.isEmpty() || layer.equals(Landcover.LAYER_NAME)) { diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Landuse.java b/tiles/src/main/java/com/protomaps/basemap/layers/Landuse.java index b528fde8..3cda3df1 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Landuse.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Landuse.java @@ -256,6 +256,29 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } + public void processOverture(SourceFeature sf, FeatureCollector features) { + // Filter by type field - Overture theme + if (!"base".equals(sf.getString("theme"))) { + return; + } + + if (/* !"land_cover".equals(sf.getString("type")) && */ !"land_use".equals(sf.getString("type"))) { + return; + } + + // Overture land_cover examples: barren, crop, forest, grass, shrub, urban, wetland + // Overture land_use examples: agriculture, aquaculture, campground, cemetery, construction + String kind = sf.getString("subtype"); + + features.polygon(LAYER_NAME) + //.setId(1L + sortKey) + .setAttr("kind", kind) + .setAttr("sort_rank", 189) + // Below z8 this data shows up as Landcover.java + .setZoomRange(8, 15) + .setMinPixelSize(2.0); + } + public static final String LAYER_NAME = "landuse"; @Override From d3b937baf0107b615ca8cfbf67aa14c486cefa39 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 20:38:41 -0800 Subject: [PATCH 39/76] Added Overture theme=buildings to buildings layer --- tiles/get-overture.py | 2 +- .../java/com/protomaps/basemap/Basemap.java | 1 + .../protomaps/basemap/layers/Buildings.java | 23 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tiles/get-overture.py b/tiles/get-overture.py index c324defb..5510b987 100755 --- a/tiles/get-overture.py +++ b/tiles/get-overture.py @@ -87,7 +87,7 @@ def query_overture_data(bbox, output_path): hive_partitioning=1, filename=1, union_by_name=1) - WHERE theme IN ('transportation', 'places', 'base') + WHERE theme IN ('transportation', 'places', 'base', 'buildings') AND bbox.xmin <= {bbox['xmax']} AND bbox.xmax >= {bbox['xmin']} AND bbox.ymin <= {bbox['ymax']} diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index 9bd704c3..1cc6c3e0 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -46,6 +46,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, var buildings = new Buildings(); registerHandler(buildings); registerSourceHandler("osm", buildings::processOsm); + registerSourceHandler("overture", buildings::processOverture); } if (layer.isEmpty() || layer.equals(Landuse.LAYER_NAME)) { diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java b/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java index 9259c7a4..98238d8e 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java @@ -137,6 +137,29 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } } + public void processOverture(SourceFeature sf, FeatureCollector features) { + // Filter by type field - Overture theme + if (!"buildings".equals(sf.getString("theme"))) { + return; + } + + // Ignore type=building_part for now + if (!"building".equals(sf.getString("type"))) { + return; + } + + features.polygon(this.name()) + //.setId(FeatureId.create(sf)) + // Core Tilezen schema properties + .setAttr("kind", "building") + // Core OSM tags for different kinds of places + //.setAttrWithMinzoom("layer", Parse.parseIntOrNull(sf.getString("layer")), 13) + // NOTE: Height is quantized by zoom in a post-process step + //.setAttr(HEIGHT_KEY, height.height()) + .setAttr("sort_rank", 400) + .setZoomRange(11, 15); + } + @Override public List postProcess(int zoom, List items) throws GeometryException { if (zoom == 15) { From 3654f397b4b0a861c37a3b8c42cc5097a1c123a3 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 20:43:38 -0800 Subject: [PATCH 40/76] Added Overture theme=transportation to roads layer --- .../java/com/protomaps/basemap/Basemap.java | 1 + .../com/protomaps/basemap/layers/Roads.java | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index 1cc6c3e0..cf90c7c8 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -79,6 +79,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, var roads = new Roads(countryCoder); registerHandler(roads); registerSourceHandler("osm", roads::processOsm); + registerSourceHandler("overture", roads::processOverture); } if (layer.isEmpty() || layer.equals(Transit.LAYER_NAME)) { diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 6c2d991d..584ee823 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -472,6 +472,33 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } + public void processOverture(SourceFeature sf, FeatureCollector features) { + // Filter by type field - Overture transportation theme + if (!"transportation".equals(sf.getString("theme"))) { + return; + } + + if (!"segment".equals(sf.getString("type"))) { + return; + } + + if (!"road".equals(sf.getString("subtype"))) { + return; + } + + features.line("roads") + .setId(FeatureId.create(sf)) + .setAttr("kind", "minor_road") // sf.getString("class")) + // To power better client label collisions + .setMinZoom(6) + // `highway` is a temporary attribute that gets removed in the post-process step + .setAttr("highway", sf.getString("class")) + .setAttr("sort_rank", 400) + .setMinPixelSize(0) + .setPixelTolerance(0) + .setMinZoom(6); + } + @Override public List postProcess(int zoom, List items) throws GeometryException { // limit the application of LinkSimplify to where cloverleafs are unlikely to be at tile edges. From 14bd973fc7e61a5dc8de75140c1b8c5596b17a03 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 20:54:18 -0800 Subject: [PATCH 41/76] Added Overture theme=places to pois layer --- .../java/com/protomaps/basemap/Basemap.java | 1 + .../com/protomaps/basemap/layers/Pois.java | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index cf90c7c8..a7cf90df 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -73,6 +73,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, var poi = new Pois(qrankDb); registerHandler(poi); registerSourceHandler("osm", poi::processOsm); + registerSourceHandler("overture", poi::processOverture); } if (layer.isEmpty() || layer.equals(Roads.LAYER_NAME)) { 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 1a07dc89..0941c09f 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -525,6 +525,34 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { outputFeature.setPointLabelGridSizeAndLimit(14, 8, 1); } + public void processOverture(SourceFeature sf, FeatureCollector features) { + // Filter by type field - Overture transportation theme + if (!"places".equals(sf.getString("theme"))) { + return; + } + + if (!"place".equals(sf.getString("type"))) { + return; + } + + String kind = sf.getString("basic_category"); + String name = sf.getString("names.primary"); + + features.point(this.name()) + // all POIs should receive their IDs at all zooms + // (there is no merging of POIs like with lines and polygons in other layers) + //.setId(FeatureId.create(sf)) + // Core Tilezen schema properties + .setAttr("kind", kind) + .setAttr("name", name) + // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions + // 512 px zooms versus 256 px logical zooms + .setAttr("min_zoom", 15 + 1) + // + .setBufferPixels(8) + .setZoomRange(Math.min(15, 15), 15); + } + @Override public List postProcess(int zoom, List items) throws GeometryException { return items; From ba28f22a22942bbc097501bc73d6a781af659e28 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 29 Dec 2025 21:39:21 -0800 Subject: [PATCH 42/76] Added first failing Overture POI tests --- .../protomaps/basemap/layers/PoisTest.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) 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 a4c19a0f..c2354d74 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -1133,3 +1133,60 @@ void kind_winterSports_fromLanduse() { ))); } } + +class PoisOvertureTest extends LayerTest { + + @Test + void kind_nationalPark_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "national_park", "min_zoom", 12, "name", "Alcatraz National Park")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "814b8a78-161f-4273-a4bb-7d686d0e3be4", + "theme", "places", + "type", "place", + "basic_category", "national_park", + "names.primary", "Alcatraz National Park", + "confidence", 0.64 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_hospital_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "hospital", "min_zoom", 13, "name", "UCSF Medical Center")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "58a71696-1d7d-4160-9947-b51ae7967881", + "theme", "places", + "type", "place", + "basic_category", "hospital", + "names.primary", "UCSF Medical Center", + "confidence", 0.99 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_aerodrome_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "aerodrome", "min_zoom", 14, "name", "San Francisco Bay Oakland International Airport (OAK)")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "d9d88226-ac41-429f-a554-b16aefddffc7", + "theme", "places", + "type", "place", + "basic_category", "airport", + "names.primary", "San Francisco Bay Oakland International Airport (OAK)", + "confidence", 0.77 + )), + "overture", null, 0 + ))); + } +} From bce832d1478c84ae3460c292b48d0dd3d01d83e2 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 10:41:49 -0800 Subject: [PATCH 43/76] Added feature finder script to assist with Overture test cases --- tiles/feature-finder.py | 342 ++++++++++++++++++++++++++++++++++++++++ tiles/requirements.txt | 2 + 2 files changed, 344 insertions(+) create mode 100755 tiles/feature-finder.py diff --git a/tiles/feature-finder.py b/tiles/feature-finder.py new file mode 100755 index 00000000..7d543f6d --- /dev/null +++ b/tiles/feature-finder.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +Feature finder script to match OSM and Overture features based on tags and spatial proximity. + +Usage examples: + feature-finder.py aerodrome airport --name OAK + feature-finder.py national_park --name Alcatraz +""" +from __future__ import annotations + +import argparse +import sys +import random +import pathlib +import logging +import multiprocessing +import math +import duckdb +import geopandas +import osgeo.ogr +import shapely.wkb +import shapely.geometry + + +DISTANCE_THRESHOLD_KM = 2.0 +DISTANCE_THRESHOLD_METERS = DISTANCE_THRESHOLD_KM * 1000 +MAX_RESULTS = 3 + +# Common OSM tag columns to check +OSM_TAG_COLUMNS = [ + 'aeroway', 'amenity', 'leisure', 'tourism', 'landuse', 'natural', + 'shop', 'historic', 'building', 'highway', 'railway', 'waterway', + 'boundary', 'place', 'man_made', 'craft', 'office', 'sport' +] + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description='Find matching features between OSM and Overture data' + ) + parser.add_argument( + 'tags', + nargs='+', + help='Tags to search for (e.g., aerodrome airport national_park)' + ) + parser.add_argument( + '--name', + help='Optional name filter (case-insensitive substring match)' + ) + return parser.parse_args() + + +def find_osm_features(pbf_path: str, tags: list[str], name_filter: str | None = None) -> list[dict]: + """ + Query OSM PBF file for features matching any of the given tags. + + Returns list of dicts with: osm_id, layer, name, matched_tag, matched_value, geometry + """ + features = [] + datasource = osgeo.ogr.Open(pbf_path) + + if not datasource: + return features + + # Search across all three geometry layers using SQL + for layer_name in ['points', 'lines', 'multipolygons']: + # First, get available columns for this layer + temp_layer = datasource.GetLayerByName(layer_name) + if not temp_layer: + continue + + layer_defn = temp_layer.GetLayerDefn() + available_columns = set() + for i in range(layer_defn.GetFieldCount()): + field_defn = layer_defn.GetFieldDefn(i) + available_columns.add(field_defn.GetName()) + + # Build WHERE clause for SQL + conditions = [] + for tag in tags: + for col in OSM_TAG_COLUMNS: + if col in available_columns: + conditions.append(f"{col} = '{tag}'") + + if not conditions: + continue + + where_clause = ' OR '.join(conditions) + if name_filter: + where_clause = f"({where_clause}) AND (name LIKE '%{name_filter}%')" + + sql = f"SELECT * FROM {layer_name} WHERE {where_clause}" + result_layer = datasource.ExecuteSQL(sql) + if not result_layer: + continue + + # Process results + for feature in result_layer: + name = feature.GetField('name') + + # Check which tag matched + matched_tag = None + matched_value = None + + for col in OSM_TAG_COLUMNS: + if col not in available_columns: + continue + val = feature.GetField(col) + if val and val in tags: + matched_tag = col + matched_value = val + break + + if not matched_tag: + continue + + # Get geometry + geom = feature.GetGeometryRef() + if not geom: + continue + + # Convert geometry to shapely + geom_wkb = geom.ExportToWkb() + shapely_geom = shapely.wkb.loads(geom_wkb) + + osm_id = feature.GetField('osm_id') + features.append({ + 'osm_id': osm_id, + 'layer': layer_name, + 'name': name, + 'matched_tag': matched_tag, + 'matched_value': matched_value, + 'geometry': shapely_geom, + 'other_tags': feature.GetField('other_tags') if 'other_tags' in available_columns else None + }) + + datasource.ReleaseResultSet(result_layer) + + return features + + +def find_overture_features(parquet_path: str, tags: list[str], name_filter: str | None = None) -> list[dict]: + """ + Query Overture parquet file for features matching any of the given tags. + + Returns list of dicts with: id, name, basic_category, categories_primary, geometry + """ + conn = duckdb.connect(':memory:') + + # Build WHERE clause for categories + category_conditions = [] + for tag in tags: + category_conditions.append(f"categories.primary = '{tag}'") + category_conditions.append(f"basic_category = '{tag}'") + + where_clause = ' OR '.join(category_conditions) + + if name_filter: + where_clause = f"({where_clause}) AND (names.primary LIKE '%{name_filter}%')" + + query = f""" + SELECT + id, + names.primary as name, + basic_category, + categories.primary as categories_primary, + geometry, + confidence + FROM read_parquet('{parquet_path}') + WHERE {where_clause} + """ + + try: + result = conn.execute(query).fetchall() + features = [] + + for row in result: + overture_id, name, basic_cat, cat_primary, geom_blob, confidence = row + + # Convert geometry blob to shapely + if geom_blob: + shapely_geom = shapely.wkb.loads(bytes(geom_blob)) + + features.append({ + 'id': overture_id, + 'name': name, + 'basic_category': basic_cat, + 'categories_primary': cat_primary, + 'geometry': shapely_geom, + 'confidence': confidence + }) + + return features + + except Exception as e: + logging.error(f"Error querying {parquet_path}: {e}") + return [] + finally: + conn.close() + + +def get_utm_zone_epsg(longitude: float) -> str: + """ + Calculate appropriate UTM zone EPSG code based on longitude. + + UTM zones are 6 degrees wide, numbered 1-60 starting at -180 degrees. + Northern hemisphere uses EPSG:326xx, Southern hemisphere uses EPSG:327xx. + For simplicity, assuming Northern hemisphere (add latitude check if needed). + """ + zone_number = int((longitude + 180) / 6) + 1 + # Assuming Northern hemisphere + epsg_code = 32600 + zone_number + return f'EPSG:{epsg_code}' + + +def calculate_distance_meters(geom1, geom2) -> float: + """Calculate distance between two geometries in meters using centroids and appropriate UTM projection.""" + # Create GeoDataFrames with geometries + gdf1 = geopandas.GeoDataFrame([1], geometry=[geom1], crs='EPSG:4326') + gdf2 = geopandas.GeoDataFrame([1], geometry=[geom2], crs='EPSG:4326') + + # Calculate average longitude to determine UTM zone + centroid1_wgs84 = geom1.centroid + centroid2_wgs84 = geom2.centroid + avg_longitude = (centroid1_wgs84.x + centroid2_wgs84.x) / 2 + + # Get appropriate UTM zone + utm_crs = get_utm_zone_epsg(avg_longitude) + + # Project to UTM for accurate distance calculation + gdf1_proj = gdf1.to_crs(utm_crs) + gdf2_proj = gdf2.to_crs(utm_crs) + + # Get centroids and calculate distance + centroid1 = gdf1_proj.geometry.iloc[0].centroid + centroid2 = gdf2_proj.geometry.iloc[0].centroid + + return centroid1.distance(centroid2) + + +def find_matches(osm_features: list[dict], overture_features: list[dict]) -> list[tuple[dict, dict, float]]: + """ + Find OSM-Overture pairs within distance threshold. + + Returns list of tuples: (osm_feature, overture_feature, distance_meters) + """ + matches = [] + + for osm_feat in osm_features: + for ov_feat in overture_features: + distance_m = calculate_distance_meters(osm_feat['geometry'], ov_feat['geometry']) + + if distance_m <= DISTANCE_THRESHOLD_METERS: + matches.append((osm_feat, ov_feat, distance_m)) + + return matches + + +def format_output(matches: list[tuple[dict, dict, float]]) -> str: + """Format matched features for display.""" + if not matches: + return "No matches found." + + output_lines = [] + output_lines.append(f"Found {len(matches)} match(es) within {DISTANCE_THRESHOLD_KM} km:\n") + + for i, (osm, ov, dist_m) in enumerate(matches, 1): + dist_km = dist_m / 1000 + output_lines.append(f"Match {i}:") + output_lines.append(f" OSM:") + output_lines.append(f" ID: {osm['osm_id']}") + output_lines.append(f" Layer: {osm['layer']}") + output_lines.append(f" Name: {osm['name']}") + output_lines.append(f" Tag: {osm['matched_tag']}={osm['matched_value']}") + + output_lines.append(f" Overture:") + output_lines.append(f" ID: {ov['id']}") + output_lines.append(f" Name: {ov['name']}") + output_lines.append(f" Basic Category: {ov['basic_category']}") + output_lines.append(f" Primary Category: {ov['categories_primary']}") + output_lines.append(f" Confidence: {ov['confidence']:.3f}") + + output_lines.append(f" Distance: {dist_m:.1f} meters ({dist_km:.3f} km)") + output_lines.append("") + + return '\n'.join(output_lines) + + +def main(): + # Setup logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + args = parse_args() + + # Find all transect files + data_dir = pathlib.Path('data/sources') + osm_files = list(data_dir.glob('*-Transect.osm.pbf')) + parquet_files = list(data_dir.glob('*-Transect.parquet')) + + if not osm_files or not parquet_files: + logging.error("No transect files found in data/sources/") + sys.exit(1) + + # Collect all features from all transect files in parallel + all_osm_features = [] + all_overture_features = [] + + # Process OSM files in parallel using multiprocessing + with multiprocessing.Pool() as pool: + osm_args = [(str(osm_file), args.tags, args.name) for osm_file in osm_files] + osm_results = pool.starmap(find_osm_features, osm_args) + for osm_features in osm_results: + all_osm_features.extend(osm_features) + + # Process Overture files in parallel using multiprocessing + with multiprocessing.Pool() as pool: + ov_args = [(str(parquet_file), args.tags, args.name) for parquet_file in parquet_files] + ov_results = pool.starmap(find_overture_features, ov_args) + for ov_features in ov_results: + all_overture_features.extend(ov_features) + + logging.info(f"Found {len(all_osm_features)} OSM features and {len(all_overture_features)} Overture features") + + # Find matches + matches = find_matches(all_osm_features, all_overture_features) + + # Select up to MAX_RESULTS with weighted random selection (prefer closer matches) + if len(matches) > MAX_RESULTS: + # Calculate weights as inverse of log distance (closer = higher weight) + weights = [1.0 / math.log(dist_m + 2.0) for _, _, dist_m in matches] + matches = random.choices(matches, weights=weights, k=MAX_RESULTS) + + # Display results + print(format_output(matches)) + + +if __name__ == '__main__': + main() diff --git a/tiles/requirements.txt b/tiles/requirements.txt index 3ae8f46a..38f189d7 100644 --- a/tiles/requirements.txt +++ b/tiles/requirements.txt @@ -1 +1,3 @@ duckdb==1.4.3 +GDAL==3.2.2 +geopandas==1.0.1 From e55ad3e57e75d1af35d3b86dc84939f7d23b8901 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 11:39:29 -0800 Subject: [PATCH 44/76] Sped up feature finder with RTree --- tiles/feature-finder.py | 81 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/tiles/feature-finder.py b/tiles/feature-finder.py index 7d543f6d..514e6b77 100755 --- a/tiles/feature-finder.py +++ b/tiles/feature-finder.py @@ -24,7 +24,7 @@ DISTANCE_THRESHOLD_KM = 2.0 DISTANCE_THRESHOLD_METERS = DISTANCE_THRESHOLD_KM * 1000 -MAX_RESULTS = 3 +MAX_RESULTS = 5 # Common OSM tag columns to check OSM_TAG_COLUMNS = [ @@ -124,7 +124,11 @@ def find_osm_features(pbf_path: str, tags: list[str], name_filter: str | None = geom_wkb = geom.ExportToWkb() shapely_geom = shapely.wkb.loads(geom_wkb) + # Get OSM ID - try osm_id first, fallback to osm_way_id for multipolygons osm_id = feature.GetField('osm_id') + if not osm_id and 'osm_way_id' in available_columns: + osm_id = feature.GetField('osm_way_id') + features.append({ 'osm_id': osm_id, 'layer': layer_name, @@ -245,14 +249,68 @@ def find_matches(osm_features: list[dict], overture_features: list[dict]) -> lis Returns list of tuples: (osm_feature, overture_feature, distance_meters) """ + if not osm_features or not overture_features: + return [] + + # Create GeoDataFrames from the feature lists + osm_gdf = geopandas.GeoDataFrame([ + { + 'osm_id': f['osm_id'], + 'layer': f['layer'], + 'name': f['name'], + 'matched_tag': f['matched_tag'], + 'matched_value': f['matched_value'], + 'other_tags': f['other_tags'], + 'geometry': f['geometry'], + 'index': i + } + for i, f in enumerate(osm_features) + ], crs='EPSG:4326') + + overture_gdf = geopandas.GeoDataFrame([ + { + 'id': f['id'], + 'name': f['name'], + 'basic_category': f['basic_category'], + 'categories_primary': f['categories_primary'], + 'confidence': f['confidence'], + 'geometry': f['geometry'], + 'index': i + } + for i, f in enumerate(overture_features) + ], crs='EPSG:4326') + + # Project both to EPSG:3857 (Web Mercator) for distance calculations + osm_gdf_proj = osm_gdf.to_crs('EPSG:3857') + overture_gdf_proj = overture_gdf.to_crs('EPSG:3857') + + # Use spatial join with a buffer to find potential matches + # Buffer by 1.5x the threshold for a conservative search area + buffer_distance = DISTANCE_THRESHOLD_METERS * 2 + osm_buffered = osm_gdf_proj.copy() + osm_buffered['geometry'] = osm_buffered.geometry.buffer(buffer_distance) + + # Spatial join to find candidates within buffer distance + joined = osm_buffered.sjoin(overture_gdf_proj, how='inner', predicate='intersects') + + # Now calculate precise distances only for candidates matches = [] + for _, row in joined.iterrows(): + osm_idx = row['index_left'] + ov_idx = row['index_right'] + + # Get the original (unbuffered) geometries + osm_geom = osm_gdf_proj.iloc[osm_idx].geometry + ov_geom = overture_gdf_proj.iloc[ov_idx].geometry - for osm_feat in osm_features: - for ov_feat in overture_features: - distance_m = calculate_distance_meters(osm_feat['geometry'], ov_feat['geometry']) + # Calculate distance between centroids in EPSG:3857 + distance_m = osm_geom.centroid.distance(ov_geom.centroid) - if distance_m <= DISTANCE_THRESHOLD_METERS: - matches.append((osm_feat, ov_feat, distance_m)) + if distance_m <= DISTANCE_THRESHOLD_METERS: + # Reconstruct feature dicts from original lists + osm_feat = osm_features[osm_idx] + ov_feat = overture_features[ov_idx] + matches.append((osm_feat, ov_feat, distance_m)) return matches @@ -279,9 +337,9 @@ def format_output(matches: list[tuple[dict, dict, float]]) -> str: output_lines.append(f" Name: {ov['name']}") output_lines.append(f" Basic Category: {ov['basic_category']}") output_lines.append(f" Primary Category: {ov['categories_primary']}") - output_lines.append(f" Confidence: {ov['confidence']:.3f}") + output_lines.append(f" Confidence: {ov['confidence']:.2f}") - output_lines.append(f" Distance: {dist_m:.1f} meters ({dist_km:.3f} km)") + output_lines.append(f" Distance: {dist_km:.2f} km") output_lines.append("") return '\n'.join(output_lines) @@ -331,7 +389,12 @@ def main(): # Select up to MAX_RESULTS with weighted random selection (prefer closer matches) if len(matches) > MAX_RESULTS: # Calculate weights as inverse of log distance (closer = higher weight) - weights = [1.0 / math.log(dist_m + 2.0) for _, _, dist_m in matches] + weights = [ + (min(len(osm['name']), len(ov['name'])) / max(len(osm['name']), len(ov['name']))) + * ov['confidence'] + / math.log(dist_m + 2.0) + for osm, ov, dist_m in matches + ] matches = random.choices(matches, weights=weights, k=MAX_RESULTS) # Display results From adc288305d6872ce958967355d0dd036a4fbf84d Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 11:44:51 -0800 Subject: [PATCH 45/76] Added more failing Overture POI tests --- .../protomaps/basemap/layers/PoisTest.java | 114 +++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) 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 c2354d74..41532ab0 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -1143,7 +1143,7 @@ void kind_nationalPark_fromBasicCategory() { process(SimpleFeature.create( newPoint(1, 1), new HashMap<>(Map.of( - "id", "814b8a78-161f-4273-a4bb-7d686d0e3be4", + "id", "814b8a78-161f-4273-a4bb-7d686d0e3be4", // https://www.openstreetmap.org/way/295140461/history/15 "theme", "places", "type", "place", "basic_category", "national_park", @@ -1161,12 +1161,12 @@ void kind_hospital_fromBasicCategory() { process(SimpleFeature.create( newPoint(1, 1), new HashMap<>(Map.of( - "id", "58a71696-1d7d-4160-9947-b51ae7967881", + "id", "408fa8fa-bd35-43a9-ac4d-a87b0b1a8760", // https://www.openstreetmap.org/way/256633745/history/4 "theme", "places", "type", "place", "basic_category", "hospital", "names.primary", "UCSF Medical Center", - "confidence", 0.99 + "confidence", 0.95 )), "overture", null, 0 ))); @@ -1189,4 +1189,112 @@ void kind_aerodrome_fromBasicCategory() { "overture", null, 0 ))); } + + @Test + void kind_library_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "library", "min_zoom", 14, "name", "West Oakland Branch")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "7566afef-3e91-4c59-86e9-78649016f442", // https://www.openstreetmap.org/way/43919891/history/9 + "theme", "places", + "type", "place", + "basic_category", "library", + "names.primary", "West Oakland Branch", + "confidence", 0.99 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_postOffice_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "post_office", "min_zoom", 14, "name", "郵便局")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "6fc2403b-7dcd-4376-8406-81e7b7c91d4e", // https://www.openstreetmap.org/way/1327222659/history/1 + "theme", "places", + "type", "place", + "basic_category", "post_office", + "names.primary", "郵便局", + "confidence", 0.95 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_stadium_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "stadium", "min_zoom", 14, "name", "Oracle Park")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "b62c1105-5cf5-47ab-b6d8-b0e4f4677f91", // https://www.openstreetmap.org/relation/7325085/history/15 + "theme", "places", + "type", "place", + "basic_category", "sport_stadium", + "names.primary", "Oracle Park", + "confidence", 0.99 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_college_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "college", "min_zoom", 15, "name", "Laney College")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "3dd5485c-ff72-4ebe-b367-879cd02f3227", // https://www.openstreetmap.org/way/23597202/history/14 + "theme", "places", + "type", "place", + "basic_category", "college_university", + "names.primary", "Laney College", + "confidence", 0.99 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_cemetery_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "cemetery", "min_zoom", 15, "name", "Mountain View Cemetery")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "90ba6f53-a6df-4f58-9db8-f56101524df2", // https://www.openstreetmap.org/way/25207152/history/29 + "theme", "places", + "type", "place", + "basic_category", "cemetery", + "names.primary", "Mountain View Cemetery", + "confidence", 0.96 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_park_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "park", "min_zoom", 15, "name", "Snow Park")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "729357b1-f6c3-4d72-83d8-5c0357bcd89c", // https://www.openstreetmap.org/way/22672037/history/19 + "theme", "places", + "type", "place", + "basic_category", "park", + "names.primary", "Snow Park", + "confidence", 0.96 + )), + "overture", null, 0 + ))); + } } From 60fc3e0a7bffab60c9aadf7c0f0b3174ba34ea05 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 12:22:10 -0800 Subject: [PATCH 46/76] Replaced failing OGR SQL queries that were too long --- tiles/feature-finder.py | 85 ++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 53 deletions(-) diff --git a/tiles/feature-finder.py b/tiles/feature-finder.py index 514e6b77..b354c471 100755 --- a/tiles/feature-finder.py +++ b/tiles/feature-finder.py @@ -15,16 +15,16 @@ import logging import multiprocessing import math + import duckdb import geopandas -import osgeo.ogr import shapely.wkb import shapely.geometry DISTANCE_THRESHOLD_KM = 2.0 DISTANCE_THRESHOLD_METERS = DISTANCE_THRESHOLD_KM * 1000 -MAX_RESULTS = 5 +MAX_RESULTS = 3 # Common OSM tag columns to check OSM_TAG_COLUMNS = [ @@ -58,55 +58,40 @@ def find_osm_features(pbf_path: str, tags: list[str], name_filter: str | None = Returns list of dicts with: osm_id, layer, name, matched_tag, matched_value, geometry """ features = [] - datasource = osgeo.ogr.Open(pbf_path) - - if not datasource: - return features - # Search across all three geometry layers using SQL + # Search across all three geometry layers for layer_name in ['points', 'lines', 'multipolygons']: - # First, get available columns for this layer - temp_layer = datasource.GetLayerByName(layer_name) - if not temp_layer: + try: + # Load layer into GeoPandas + gdf = geopandas.read_file(pbf_path, layer=layer_name) + except Exception as e: + logging.debug(f"Could not read layer {layer_name} from {pbf_path}: {e}") continue - layer_defn = temp_layer.GetLayerDefn() - available_columns = set() - for i in range(layer_defn.GetFieldCount()): - field_defn = layer_defn.GetFieldDefn(i) - available_columns.add(field_defn.GetName()) - - # Build WHERE clause for SQL - conditions = [] - for tag in tags: - for col in OSM_TAG_COLUMNS: - if col in available_columns: - conditions.append(f"{col} = '{tag}'") - - if not conditions: + if gdf.empty: continue - where_clause = ' OR '.join(conditions) + # Apply name filter first if provided if name_filter: - where_clause = f"({where_clause}) AND (name LIKE '%{name_filter}%')" + if 'name' in gdf.columns: + gdf = gdf[gdf['name'].notna() & gdf['name'].str.contains(name_filter, case=False, na=False)] + else: + continue - sql = f"SELECT * FROM {layer_name} WHERE {where_clause}" - result_layer = datasource.ExecuteSQL(sql) - if not result_layer: + if gdf.empty: continue - # Process results - for feature in result_layer: - name = feature.GetField('name') + # Check which OSM tag columns are available + available_tag_cols = [col for col in OSM_TAG_COLUMNS if col in gdf.columns] - # Check which tag matched + # For each row, check if any of the tag columns matches any of our search tags + for idx, row in gdf.iterrows(): matched_tag = None matched_value = None - for col in OSM_TAG_COLUMNS: - if col not in available_columns: - continue - val = feature.GetField(col) + # Check each available tag column + for col in available_tag_cols: + val = row[col] if val and val in tags: matched_tag = col matched_value = val @@ -115,32 +100,26 @@ def find_osm_features(pbf_path: str, tags: list[str], name_filter: str | None = if not matched_tag: continue + # Get OSM ID + osm_id = row.get('osm_id') + if not osm_id: + osm_id = row.get('osm_way_id') + # Get geometry - geom = feature.GetGeometryRef() - if not geom: + geom = row['geometry'] + if geom is None or geom.is_empty: continue - # Convert geometry to shapely - geom_wkb = geom.ExportToWkb() - shapely_geom = shapely.wkb.loads(geom_wkb) - - # Get OSM ID - try osm_id first, fallback to osm_way_id for multipolygons - osm_id = feature.GetField('osm_id') - if not osm_id and 'osm_way_id' in available_columns: - osm_id = feature.GetField('osm_way_id') - features.append({ 'osm_id': osm_id, 'layer': layer_name, - 'name': name, + 'name': row.get('name'), 'matched_tag': matched_tag, 'matched_value': matched_value, - 'geometry': shapely_geom, - 'other_tags': feature.GetField('other_tags') if 'other_tags' in available_columns else None + 'geometry': geom, + 'other_tags': row.get('other_tags') }) - datasource.ReleaseResultSet(result_layer) - return features From 57f792e0ba931aaab25348b53b8f47f6c1be9619 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 12:22:49 -0800 Subject: [PATCH 47/76] Added failing Overture POI test --- .../com/protomaps/basemap/layers/PoisTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 41532ab0..572bee7b 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -1297,4 +1297,22 @@ void kind_park_fromBasicCategory() { "overture", null, 0 ))); } + + @Test + void kind_supermarket_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "supermarket", "min_zoom", 15, "name", "Piedmont Grocery")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "57217644-ca78-4060-9580-f008f7dc9f01", // https://www.openstreetmap.org/way/221073981/history/5 + "theme", "places", + "type", "place", + "basic_category", "grocery_store", + "names.primary", "Piedmont Grocery", + "confidence", 0.96 + )), + "overture", null, 0 + ))); + } } From 2ba9b960e381818ce0a369fd1bb5bb17919776e6 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 13:29:31 -0800 Subject: [PATCH 48/76] Sped up feature finder with more parallel ops --- tiles/feature-finder.py | 118 +++++++++--------- .../protomaps/basemap/layers/PoisTest.java | 18 +++ 2 files changed, 78 insertions(+), 58 deletions(-) diff --git a/tiles/feature-finder.py b/tiles/feature-finder.py index b354c471..96dec444 100755 --- a/tiles/feature-finder.py +++ b/tiles/feature-finder.py @@ -51,7 +51,7 @@ def parse_args(): return parser.parse_args() -def find_osm_features(pbf_path: str, tags: list[str], name_filter: str | None = None) -> list[dict]: +def find_osm_features(pbf_path: str, layer_name: str, tags: list[str], name_filter: str | None = None) -> list[dict]: """ Query OSM PBF file for features matching any of the given tags. @@ -59,66 +59,64 @@ def find_osm_features(pbf_path: str, tags: list[str], name_filter: str | None = """ features = [] - # Search across all three geometry layers - for layer_name in ['points', 'lines', 'multipolygons']: - try: - # Load layer into GeoPandas - gdf = geopandas.read_file(pbf_path, layer=layer_name) - except Exception as e: - logging.debug(f"Could not read layer {layer_name} from {pbf_path}: {e}") - continue + try: + # Load layer into GeoPandas + gdf = geopandas.read_file(pbf_path, layer=layer_name) + except Exception as e: + logging.debug(f"Could not read layer {layer_name} from {pbf_path}: {e}") + return features + + if gdf.empty: + return features + + # Apply name filter first if provided + if name_filter: + if 'name' in gdf.columns: + gdf = gdf[gdf['name'].notna() & gdf['name'].str.contains(name_filter, case=False, na=False)] + else: + return features + + if gdf.empty: + return features + + # Check which OSM tag columns are available + available_tag_cols = [col for col in OSM_TAG_COLUMNS if col in gdf.columns] - if gdf.empty: + # For each row, check if any of the tag columns matches any of our search tags + for idx, row in gdf.iterrows(): + matched_tag = None + matched_value = None + + # Check each available tag column + for col in available_tag_cols: + val = row[col] + if val and (val in tags or any(f'"{tag}"' in val for tag in tags)): + matched_tag = col + matched_value = val + break + + if not matched_tag: continue - # Apply name filter first if provided - if name_filter: - if 'name' in gdf.columns: - gdf = gdf[gdf['name'].notna() & gdf['name'].str.contains(name_filter, case=False, na=False)] - else: - continue + # Get OSM ID + osm_id = row.get('osm_id') + if not osm_id: + osm_id = row.get('osm_way_id') - if gdf.empty: + # Get geometry + geom = row['geometry'] + if geom is None or geom.is_empty: continue - # Check which OSM tag columns are available - available_tag_cols = [col for col in OSM_TAG_COLUMNS if col in gdf.columns] - - # For each row, check if any of the tag columns matches any of our search tags - for idx, row in gdf.iterrows(): - matched_tag = None - matched_value = None - - # Check each available tag column - for col in available_tag_cols: - val = row[col] - if val and val in tags: - matched_tag = col - matched_value = val - break - - if not matched_tag: - continue - - # Get OSM ID - osm_id = row.get('osm_id') - if not osm_id: - osm_id = row.get('osm_way_id') - - # Get geometry - geom = row['geometry'] - if geom is None or geom.is_empty: - continue - - features.append({ - 'osm_id': osm_id, - 'layer': layer_name, - 'name': row.get('name'), - 'matched_tag': matched_tag, - 'matched_value': matched_value, - 'geometry': geom, - 'other_tags': row.get('other_tags') - }) + features.append({ + 'osm_id': osm_id, + 'layer': layer_name, + 'name': row.get('name'), + 'matched_tag': matched_tag, + 'matched_value': matched_value, + 'geometry': geom, + 'other_tags': row.get('other_tags') + }) return features @@ -348,7 +346,11 @@ def main(): # Process OSM files in parallel using multiprocessing with multiprocessing.Pool() as pool: - osm_args = [(str(osm_file), args.tags, args.name) for osm_file in osm_files] + osm_args = [ + (str(osm_file), layer_name, args.tags, args.name) + for osm_file in osm_files + for layer_name in ['points', 'lines', 'multipolygons'] + ] osm_results = pool.starmap(find_osm_features, osm_args) for osm_features in osm_results: all_osm_features.extend(osm_features) @@ -369,9 +371,9 @@ def main(): if len(matches) > MAX_RESULTS: # Calculate weights as inverse of log distance (closer = higher weight) weights = [ - (min(len(osm['name']), len(ov['name'])) / max(len(osm['name']), len(ov['name']))) + (min(len(osm['name'] or ''), len(ov['name'] or '')) / max(len(osm['name'] or ''), len(ov['name'] or ''))) * ov['confidence'] - / math.log(dist_m + 2.0) + / (dist_m + 1) for osm, ov, dist_m in matches ] matches = random.choices(matches, weights=weights, k=MAX_RESULTS) 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 572bee7b..fc61b6de 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -1315,4 +1315,22 @@ void kind_supermarket_fromBasicCategory() { "overture", null, 0 ))); } + + @Test + void kind_dentist_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "dentist", "min_zoom", 17, "name", "高橋歯科クリニック")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "37183e35-9c5f-43c2-b7b1-4a0cbafe70ae", // https://www.openstreetmap.org/way/609840343/history/2 + "theme", "places", + "type", "place", + "basic_category", "dentist", + "names.primary", "高橋歯科クリニック", + "confidence", 0.95 + )), + "overture", null, 0 + ))); + } } From 1c09979f7913579acffea1e82fa8fd9a8683256c Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 16:57:57 -0800 Subject: [PATCH 49/76] Added numerous failing RoadsTest cases --- tiles/feature-finder.py | 54 +++++-- .../protomaps/basemap/layers/PoisTest.java | 41 ++++- .../protomaps/basemap/layers/RoadsTest.java | 148 ++++++++++++++++++ 3 files changed, 229 insertions(+), 14 deletions(-) diff --git a/tiles/feature-finder.py b/tiles/feature-finder.py index 96dec444..ded25f68 100755 --- a/tiles/feature-finder.py +++ b/tiles/feature-finder.py @@ -24,7 +24,6 @@ DISTANCE_THRESHOLD_KM = 2.0 DISTANCE_THRESHOLD_METERS = DISTANCE_THRESHOLD_KM * 1000 -MAX_RESULTS = 3 # Common OSM tag columns to check OSM_TAG_COLUMNS = [ @@ -48,6 +47,14 @@ def parse_args(): '--name', help='Optional name filter (case-insensitive substring match)' ) + parser.add_argument( + '--10x', + dest='max_results', + action='store_const', + const=30, + default=3, + help='10x the result count' + ) return parser.parse_args() @@ -134,6 +141,8 @@ def find_overture_features(parquet_path: str, tags: list[str], name_filter: str for tag in tags: category_conditions.append(f"categories.primary = '{tag}'") category_conditions.append(f"basic_category = '{tag}'") + category_conditions.append(f"subtype = '{tag}'") + category_conditions.append(f"class = '{tag}'") where_clause = ' OR '.join(category_conditions) @@ -144,6 +153,10 @@ def find_overture_features(parquet_path: str, tags: list[str], name_filter: str SELECT id, names.primary as name, + theme, + type, + subtype, + class, basic_category, categories.primary as categories_primary, geometry, @@ -157,7 +170,7 @@ def find_overture_features(parquet_path: str, tags: list[str], name_filter: str features = [] for row in result: - overture_id, name, basic_cat, cat_primary, geom_blob, confidence = row + overture_id, name, theme, type_, subtype, class_, basic_cat, cat_primary, geom_blob, confidence = row # Convert geometry blob to shapely if geom_blob: @@ -166,6 +179,10 @@ def find_overture_features(parquet_path: str, tags: list[str], name_filter: str features.append({ 'id': overture_id, 'name': name, + 'theme': theme, + 'type': type_, + 'subtype': subtype, + 'class': class_, 'basic_category': basic_cat, 'categories_primary': cat_primary, 'geometry': shapely_geom, @@ -248,6 +265,10 @@ def find_matches(osm_features: list[dict], overture_features: list[dict]) -> lis { 'id': f['id'], 'name': f['name'], + 'theme': f['theme'], + 'type': f['type'], + 'subtype': f['subtype'], + 'class': f['class'], 'basic_category': f['basic_category'], 'categories_primary': f['categories_primary'], 'confidence': f['confidence'], @@ -312,9 +333,14 @@ def format_output(matches: list[tuple[dict, dict, float]]) -> str: output_lines.append(f" Overture:") output_lines.append(f" ID: {ov['id']}") output_lines.append(f" Name: {ov['name']}") + output_lines.append(f" Theme: {ov['theme']}") + output_lines.append(f" Type: {ov['type']}") + output_lines.append(f" Subtype: {ov['subtype']}") + output_lines.append(f" Class: {ov['class']}") output_lines.append(f" Basic Category: {ov['basic_category']}") output_lines.append(f" Primary Category: {ov['categories_primary']}") - output_lines.append(f" Confidence: {ov['confidence']:.2f}") + if ov['confidence'] is not None: + output_lines.append(f" Confidence: {ov['confidence']:.2f}") output_lines.append(f" Distance: {dist_km:.2f} km") output_lines.append("") @@ -367,16 +393,18 @@ def main(): # Find matches matches = find_matches(all_osm_features, all_overture_features) - # Select up to MAX_RESULTS with weighted random selection (prefer closer matches) - if len(matches) > MAX_RESULTS: - # Calculate weights as inverse of log distance (closer = higher weight) - weights = [ - (min(len(osm['name'] or ''), len(ov['name'] or '')) / max(len(osm['name'] or ''), len(ov['name'] or ''))) - * ov['confidence'] - / (dist_m + 1) - for osm, ov, dist_m in matches - ] - matches = random.choices(matches, weights=weights, k=MAX_RESULTS) + # Select up to args.max_results with weighted random selection (prefer closer matches) + if len(matches) > args.max_results: + # Calculate weights as inverse of distance (closer = higher weight) + weights = [(ov.get('confidence') or 1.0) / (dist_m + 1) for osm, ov, dist_m in matches] + try: + weights = [ + (min(len(osm['name']), len(ov['name'])) / max(len(osm['name']), len(ov['name']))) + * weight for weight in weights + ] + except: + pass + matches = random.choices(matches, weights=weights, k=args.max_results) # Display results print(format_output(matches)) 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 fc61b6de..fa8ce414 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PoisTest.java @@ -1134,6 +1134,7 @@ void kind_winterSports_fromLanduse() { } } + class PoisOvertureTest extends LayerTest { @Test @@ -1175,7 +1176,8 @@ void kind_hospital_fromBasicCategory() { @Test void kind_aerodrome_fromBasicCategory() { assertFeatures(15, - List.of(Map.of("kind", "aerodrome", "min_zoom", 14, "name", "San Francisco Bay Oakland International Airport (OAK)")), + List.of( + Map.of("kind", "aerodrome", "min_zoom", 14, "name", "San Francisco Bay Oakland International Airport (OAK)")), process(SimpleFeature.create( newPoint(1, 1), new HashMap<>(Map.of( @@ -1333,4 +1335,41 @@ void kind_dentist_fromBasicCategory() { "overture", null, 0 ))); } + + @Test + void kind_bakery_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "bakery", "min_zoom", 17, "name", "Boulangerie Rougès | Paris")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "c180100c-3cb7-4b36-a112-339ba238b632", // https://www.openstreetmap.org/way/75747993/history/9 + "theme", "places", + "type", "place", + "basic_category", "bakery", + "names.primary", "Boulangerie Rougès | Paris", + "confidence", 0.95 + )), + "overture", null, 0 + ))); + } + + @Test + void kind_hostel_fromBasicCategory() { + assertFeatures(15, + List.of(Map.of("kind", "hostel", "min_zoom", 17, "name", "CITAN")), + process(SimpleFeature.create( + newPoint(1, 1), + new HashMap<>(Map.of( + "id", "70888a32-51c4-4201-9e57-68e6bbdb581b", // https://www.openstreetmap.org/way/466646992/history/3 + "theme", "places", + "type", "place", + "basic_category", "accommodation", + "categories.primary", "hostel", + "names.primary", "CITAN", + "confidence", 1.00 + )), + "overture", null, 0 + ))); + } } diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index c37d1df2..06007e31 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -467,3 +467,151 @@ void testRailwayService(String service) { } } + + +class RoadsOvertureTest extends LayerTest { + + @Test + void kind_majorRoad_fromPrimaryClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 8, "name", "Ashby Avenue")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "7189cf1b-a235-463d-8871-5e6ffe0c8c3d", // https://www.openstreetmap.org/way/202317700/history/16 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "primary", + "names.primary", "Ashby Avenue" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_majorRoad_fromSecondaryClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 10, "name", "40th Street")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "6dc28d02-5b57-4091-9db2-30462f4e2273", // https://www.openstreetmap.org/way/36982937/history/14 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "secondary", + "names.primary", "40th Street" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_majorRoad_fromTertiaryClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 10, "name", "West Street")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "e3046f27-df36-4aaa-97f5-7c12eee4310d", // https://www.openstreetmap.org/way/6346756/history/24 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "tertiary", + "names.primary", "West Street" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_path_fromPedestrianClass() { + assertFeatures(15, + List.of(Map.of("kind", "path", "min_zoom", 13, "name", "13th Street")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "c7a5a72b-cb51-40a9-899c-4ecb2d3fa809", // https://www.openstreetmap.org/way/513726605/history/4 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "pedestrian", + "names.primary", "13th Street" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_minorRoad_fromResidentialClass() { + assertFeatures(15, + List.of(Map.of("kind", "minor_road", "min_zoom", 13, "name", "17th Street")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "1314012e-c948-4812-b9bd-cb9cfd9c2b63", // https://www.openstreetmap.org/way/1033706847/history/3 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "residential", + "names.primary", "17th Street" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_minorRoad_fromServiceClass() { + assertFeatures(15, + List.of(Map.of("kind", "minor_road", "min_zoom", 14, "name", "Derby Street")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "4651ca6a-16e7-4f97-99b5-5dad4228f146", // https://www.openstreetmap.org/way/8917186/history/6 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "service", + "names.primary", "Derby Street" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_path_fromCyclewayClass() { + assertFeatures(15, + List.of(Map.of("kind", "path", "min_zoom", 14, "name", "Ohlone Greenway")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "96154d80-f268-4b2d-99da-7bb411cf1718", // https://www.openstreetmap.org/way/164658210/history/11 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "cycleway", + "names.primary", "Ohlone Greenway" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_sidewalk_fromFootwayClass() { + assertFeatures(15, + List.of(Map.of("kind", "path", "kind_detail", "sidewalk", "min_zoom", 15)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "5cdc43ee-68d2-416f-9b50-4b9058415f51", // https://www.openstreetmap.org/way/1040431008/history/2 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "footway", + "subclass", "sidewalk" + )), + "overture", null, 0 + ))); + } +} From eec3db5814be6352a4798ce24d5511e44233e9c3 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 17:54:01 -0800 Subject: [PATCH 50/76] Added failing trunk and motorway tests --- .../protomaps/basemap/layers/RoadsTest.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index 06007e31..3ae07c4b 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -471,6 +471,42 @@ void testRailwayService(String service) { class RoadsOvertureTest extends LayerTest { + @Test + void kind_highway_fromMotorwayClass() { + assertFeatures(15, + List.of(Map.of("kind", "highway", "min_zoom", 4, "name", "Nimitz Freeway")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "99f8b0b1-efde-4649-820a-9ef5498ba58a", // https://www.openstreetmap.org/way/692662557/history/5 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "motorway", + "names.primary", "Nimitz Freeway" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_majorRoad_fromTrunkClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 7, "name", "Mission Street")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "1dfe52aa-9432-4d38-8117-4b1e1fa345f0", // https://www.openstreetmap.org/way/143666210/history/16 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "trunk", + "names.primary", "Mission Street" + )), + "overture", null, 0 + ))); + } + @Test void kind_majorRoad_fromPrimaryClass() { assertFeatures(15, From 67eaba4ec309a014c3e4db4bbbf6169b10105ed6 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 20:18:40 -0800 Subject: [PATCH 51/76] Added test for all classes of link roads --- .../protomaps/basemap/layers/RoadsTest.java | 103 +++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index 3ae07c4b..bdc150bf 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -474,10 +474,11 @@ class RoadsOvertureTest extends LayerTest { @Test void kind_highway_fromMotorwayClass() { assertFeatures(15, - List.of(Map.of("kind", "highway", "min_zoom", 4, "name", "Nimitz Freeway")), + List.of(Map.of("kind", "highway", "min_zoom", 4, "oneway", true, "name", "Nimitz Freeway")), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( + // TODO: get access_restrictions in here "id", "99f8b0b1-efde-4649-820a-9ef5498ba58a", // https://www.openstreetmap.org/way/692662557/history/5 "theme", "transportation", "type", "segment", @@ -489,6 +490,26 @@ void kind_highway_fromMotorwayClass() { ))); } + @Test + void kind_highwayLink_fromMotorwayClass() { + assertFeatures(15, + List.of(Map.of("kind", "highway", "min_zoom", 4, "oneway", true, "is_link", true)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + // TODO: get access_restrictions in here + "id", "ed49cecd-d577-4924-92b6-abaaf92bee6c", // https://www.openstreetmap.org/way/932872494/history/2 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "motorway", + "subclass", "link", + "road_flags", List.of("is_link") + )), + "overture", null, 0 + ))); + } + @Test void kind_majorRoad_fromTrunkClass() { assertFeatures(15, @@ -507,6 +528,26 @@ void kind_majorRoad_fromTrunkClass() { ))); } + @Test + void kind_majorLink_fromTrunkClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 7, "oneway", true, "is_link", true)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + // TODO: get access_restrictions in here + "id", "3aefac69-2653-41a1-ae19-0d36d6d03491", // https://www.openstreetmap.org/way/198565349/history/11 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "trunk", + "subclass", "link", + "road_flags", List.of("is_link") + )), + "overture", null, 0 + ))); + } + @Test void kind_majorRoad_fromPrimaryClass() { assertFeatures(15, @@ -525,6 +566,26 @@ void kind_majorRoad_fromPrimaryClass() { ))); } + @Test + void kind_majorLink_fromPrimaryClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 8, "oneway", true, "is_link", true)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + // TODO: get access_restrictions in here + "id", "2c9442b6-14c2-44e0-975d-d69bd83a0da7", // https://www.openstreetmap.org/way/198565347/history/10 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "primary", + "subclass", "link", + "road_flags", List.of("is_link") + )), + "overture", null, 0 + ))); + } + @Test void kind_majorRoad_fromSecondaryClass() { assertFeatures(15, @@ -543,6 +604,26 @@ void kind_majorRoad_fromSecondaryClass() { ))); } + @Test + void kind_majorLink_fromSecondaryClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 10, "oneway", true, "is_link", true)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + // TODO: get access_restrictions in here + "id", "bf0ba372-0a6e-417b-a180-e174f4276b9c", // https://www.openstreetmap.org/way/23591806/history/10 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "secondary", + "subclass", "link", + "road_flags", List.of("is_link") + )), + "overture", null, 0 + ))); + } + @Test void kind_majorRoad_fromTertiaryClass() { assertFeatures(15, @@ -561,6 +642,26 @@ void kind_majorRoad_fromTertiaryClass() { ))); } + @Test + void kind_majorLink_fromTertiaryClass() { + assertFeatures(15, + List.of(Map.of("kind", "major_road", "min_zoom", 10, "oneway", true, "is_link", true)), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + // TODO: get access_restrictions in here + "id", "ad765059-60f9-4eb5-b672-9faf90748f00", // https://www.openstreetmap.org/way/8915068/history/18 + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "tertiary", + "subclass", "link", + "road_flags", List.of("is_link") + )), + "overture", null, 0 + ))); + } + @Test void kind_path_fromPedestrianClass() { assertFeatures(15, From 5057890ce7f733ac1200703c8b1044e7446891c2 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 20:48:33 -0800 Subject: [PATCH 52/76] Switched from raw tags to assigned kind in POI zooms to prepare for Overture --- .../com/protomaps/basemap/layers/Pois.java | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 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 0941c09f..cad56bbe 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -186,44 +186,44 @@ public Pois(QrankDb qrankDb) { rule( Expression.or( - with("amenity", "university", "college"), // One would think University should be earlier, but there are lots of dinky node only places, so if the university has a large area, it'll naturally improve its zoom in another section... - with("landuse", "cemetery"), - with("leisure", "park"), // Lots of pocket parks and NODE parks, show those later than rest of leisure - with("shop", "grocery", "supermarket") + with(KIND, "university", "college"), // One would think University should be earlier, but there are lots of dinky node only places, so if the university has a large area, it'll naturally improve its zoom in another section... + with(KIND, "cemetery"), + with(KIND, "park"), // Lots of pocket parks and NODE parks, show those later than rest of leisure + with(KIND, "grocery", "supermarket") ), use(MINZOOM, 14) ), rule( Expression.or( - with("aeroway", "aerodrome"), - with("amenity", "library", "post_office", "townhall"), - with("leisure", "golf_course", "marina", "stadium"), - with("natural", "peak") + with(KIND, "aerodrome"), + with(KIND, "library", "post_office", "townhall"), + with(KIND, "golf_course", "marina", "stadium"), + with(KIND, "peak") ), use(MINZOOM, 13) ), - rule(with("amenity", "hospital"), use(MINZOOM, 12)), + rule(with(KIND, "hospital"), use(MINZOOM, 12)), rule(with(KIND, "national_park"), use(MINZOOM, 11)), - rule(with("aeroway", "aerodrome"), with(KIND, "aerodrome"), with("iata"), use(MINZOOM, 11)), // Emphasize large international airports earlier + rule(with(KIND, "aerodrome"), with(KIND, "aerodrome"), with("iata"), use(MINZOOM, 11)), // Emphasize large international airports earlier // Demote some unimportant point categories to very late zooms - rule(with("highway", "bus_stop"), use(MINZOOM, 17)), + rule(with(KIND, "bus_stop"), use(MINZOOM, 17)), rule( Expression.or( - with("amenity", "clinic", "dentist", "doctors", "social_facility", "baby_hatch", "childcare", + with(KIND, "clinic", "dentist", "doctors", "social_facility", "baby_hatch", "childcare", "car_sharing", "bureau_de_change", "emergency_phone", "karaoke", "karaoke_box", "money_transfer", "car_wash", "hunting_stand", "studio", "boat_storage", "gambling", "adult_gaming_centre", "sanitary_dump_station", - "attraction", "animal", "water_slide", "roller_coaster", "summer_toboggan", "carousel", "amusement_ride", + "animal", "roller_coaster", "summer_toboggan", "carousel", "amusement_ride", "maze"), - with("historic", "memorial", "district"), - with("leisure", "pitch", "playground", "slipway"), - with("shop", "scuba_diving", "atv", "motorcycle", "snowmobile", "art", "bakery", "beauty", "bookmaker", + with(KIND, "memorial", "district"), + with(KIND, "pitch", "playground", "slipway"), + with(KIND, "scuba_diving", "atv", "motorcycle", "snowmobile", "art", "bakery", "beauty", "bookmaker", "books", "butcher", "car", "car_parts", "car_repair", "clothes", "computer", "convenience", "fashion", "florist", "garden_centre", "gift", "golf", "greengrocer", "grocery", "hairdresser", "hifi", "jewelry", "lottery", "mobile_phone", "newsagent", "optician", "perfumery", "ship_chandler", "stationery", "tobacco", "travel_agency"), - with("tourism", "artwork", "hanami", "trail_riding_station", "bed_and_breakfast", "chalet", + with(KIND, "artwork", "hanami", "trail_riding_station", "bed_and_breakfast", "chalet", "guest_house", "hostel") ), use(MINZOOM, 16) @@ -234,16 +234,16 @@ public Pois(QrankDb qrankDb) { rule( without("name"), Expression.or( - with("amenity", "atm", "bbq", "bench", "bicycle_parking", + with(KIND, "atm", "bbq", "bench", "bicycle_parking", "bicycle_rental", "bicycle_repair_station", "boat_storage", "bureau_de_change", "car_rental", "car_sharing", "car_wash", "charging_station", "customs", "drinking_water", "fuel", "harbourmaster", "hunting_stand", "karaoke_box", "life_ring", "money_transfer", "motorcycle_parking", "parking", "picnic_table", "post_box", "ranger_station", "recycling", "sanitary_dump_station", "shelter", "shower", "taxi", "telephone", "toilets", "waste_basket", "waste_disposal", "water_point", "watering_place", "bicycle_rental", "motorcycle_parking", "charging_station"), - with("historic", "landmark", "wayside_cross"), - with("leisure", "dog_park", "firepit", "fishing", "pitch", "playground", "slipway", "swimming_area"), - with("tourism", "alpine_hut", "information", "picnic_site", "viewpoint", "wilderness_hut") + with(KIND, "landmark", "wayside_cross"), + with(KIND, "dog_park", "firepit", "fishing", "pitch", "playground", "slipway", "swimming_area"), + with(KIND, "alpine_hut", "information", "picnic_site", "viewpoint", "wilderness_hut") ), use(MINZOOM, 16) ) From 3eb6dfab983e881b86ab7fda83d149ad903b8a7e Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 22:30:37 -0800 Subject: [PATCH 53/76] Passed all Overture POI tests by applying new rules --- .../com/protomaps/basemap/layers/Pois.java | 49 +++++++++++++++++-- 1 file changed, 45 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 cad56bbe..44ff14e5 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -59,7 +59,9 @@ public Pois(QrankDb qrankDb) { "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"); - private static final MultiExpression.Index> kindsIndex = MultiExpression.ofOrdered(List.of( + // OSM tags to Protomaps kind/kind_detail mapping + + private static final MultiExpression.Index> osmKindsIndex = MultiExpression.ofOrdered(List.of( // Everything is undefined at first rule(use(KIND, UNDEFINED), use(KIND_DETAIL, UNDEFINED)), @@ -177,6 +179,28 @@ public Pois(QrankDb qrankDb) { )).index(); + + // Overture properties to Protomaps kind/kind_detail mapping + + private static final MultiExpression.Index> overtureKindsIndex = MultiExpression.ofOrdered(List.of( + + // Everything is undefined at first + rule(use(KIND, UNDEFINED), use(KIND_DETAIL, UNDEFINED)), + + // Pull from basic_category + rule(with("basic_category"), use(KIND, fromTag("basic_category"))), + + // Some basic categories don't match OSM-style expectations + rule(with("basic_category", "accommodation"), with("categories.primary", "hostel"), use(KIND, "hostel")), + rule(with("basic_category", "airport"), use(KIND, "aerodrome")), + rule(with("basic_category", "college_university"), use(KIND, "college")), + rule(with("basic_category", "grocery_store"), use(KIND, "supermarket")), + rule(with("basic_category", "sport_stadium"), use(KIND, "stadium")) + + )).index(); + + // Protomaps kind/kind_detail to min_zoom mapping for points + private static final MultiExpression.Index> pointZoomsIndex = MultiExpression.ofOrdered(List.of( // Every point is zoom=15 at first @@ -260,6 +284,8 @@ public Pois(QrankDb qrankDb) { private static final Expression WITH_ETC = with(KIND, "aerodrome", "golf_course", "military", "naval_base", "stadium", "zoo"); + // Protomaps kind/kind_detail to min_zoom mapping for named polygons + private static final MultiExpression.Index> namedPolygonZoomsIndex = MultiExpression.ofOrdered(List.of( @@ -413,7 +439,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { return; // Map the Protomaps KIND classification to incoming tags - var kindMatches = kindsIndex.getMatches(sf); + var kindMatches = osmKindsIndex.getMatches(sf); // Output feature and its basic values to assign FeatureCollector.Feature outputFeature; @@ -535,7 +561,22 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { return; } - String kind = sf.getString("basic_category"); + // Map the Protomaps KIND classification to incoming tags + var kindMatches = overtureKindsIndex.getMatches(sf); + + String kind = getString(sf, kindMatches, KIND, UNDEFINED); + String kindDetail = getString(sf, kindMatches, KIND_DETAIL, UNDEFINED); + Integer minZoom; + + // Calculate minZoom using zooms indexes + var sf2 = computeExtraTags(sf, getString(sf, kindMatches, KIND, UNDEFINED)); + var zoomMatches = pointZoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + // Initial minZoom + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + String name = sf.getString("names.primary"); features.point(this.name()) @@ -547,7 +588,7 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { .setAttr("name", name) // While other layers don't need min_zoom, POIs do for more predictable client-side label collisions // 512 px zooms versus 256 px logical zooms - .setAttr("min_zoom", 15 + 1) + .setAttr("min_zoom", minZoom + 1) // .setBufferPixels(8) .setZoomRange(Math.min(15, 15), 15); From 26b1aab3fa418fa3afbb2ed95bd9d7aa4bf000db Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Tue, 30 Dec 2025 22:56:39 -0800 Subject: [PATCH 54/76] Added QRank for Overture --- .../com/protomaps/basemap/layers/Pois.java | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 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 44ff14e5..8083828b 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -195,7 +195,8 @@ public Pois(QrankDb qrankDb) { rule(with("basic_category", "airport"), use(KIND, "aerodrome")), rule(with("basic_category", "college_university"), use(KIND, "college")), rule(with("basic_category", "grocery_store"), use(KIND, "supermarket")), - rule(with("basic_category", "sport_stadium"), use(KIND, "stadium")) + rule(with("basic_category", "sport_stadium"), use(KIND, "stadium")), + rule(with("basic_category", "place_of_learning", "middle_school"), use(KIND, "school")) )).index(); @@ -568,14 +569,28 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { String kindDetail = getString(sf, kindMatches, KIND_DETAIL, UNDEFINED); Integer minZoom; - // Calculate minZoom using zooms indexes - var sf2 = computeExtraTags(sf, getString(sf, kindMatches, KIND, UNDEFINED)); - var zoomMatches = pointZoomsIndex.getMatches(sf2); - if (zoomMatches.isEmpty()) + // Quickly eliminate any features with non-matching tags + if (kind.equals(UNDEFINED)) return; - // Initial minZoom - minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + // QRank may override minZoom entirely + String wikidata = sf.getString("wikidata"); + long qrank = (wikidata != null) ? qrankDb.get(wikidata) : 0; + var qrankedZoom = QrankDb.assignZoom(qrankGrading, kind, qrank); + + if (qrankedZoom.isPresent()) { + // Set minZoom from QRank + minZoom = qrankedZoom.get(); + } else { + // Calculate minZoom using zooms indexes + var sf2 = computeExtraTags(sf, getString(sf, kindMatches, KIND, UNDEFINED)); + var zoomMatches = pointZoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + // Initial minZoom + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + } String name = sf.getString("names.primary"); From 0adca8b8e8e6f85d651d1ea96bb3cdaba9259e54 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 10:23:30 -0800 Subject: [PATCH 55/76] Added basic kind assignments for Overture roads --- .../com/protomaps/basemap/layers/Roads.java | 47 +++++++++++++++++-- .../protomaps/basemap/layers/RoadsTest.java | 2 +- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 584ee823..cf4d81f7 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -34,6 +34,11 @@ public Roads(CountryCoder countryCoder) { public static final String LAYER_NAME = "roads"; + // Internal tags used to reference calculated values between matchers + private static final String KIND = "protomaps-basemaps:kind"; + private static final String KIND_DETAIL = "protomaps-basemaps:kindDetail"; + private static final String UNDEFINED = "protomaps-basemaps:undefined"; + private static final MultiExpression.Index> indexHighways = MultiExpression.of(List.of( rule( with(), @@ -288,6 +293,27 @@ public Roads(CountryCoder countryCoder) { ) )).index(); + // Overture properties to Protomaps kind mapping + + private static final MultiExpression.Index> overtureHighwaysIndex = MultiExpression.ofOrdered(List.of( + + // Everything is undefined at first + rule(use(KIND, UNDEFINED), use(KIND_DETAIL, UNDEFINED)), + + // Pull from road class by default? + rule(with("class"), use(KIND, fromTag("class")), use(KIND_DETAIL, "")), + + // Assign HighRoad kinds + rule(with("class", "motorway", "motorway_link"), use(KIND, "highway")), + rule(with("class", "trunk", "trunk_link", "primary", "primary_link", "secondary", "secondary_link", "tertiary", "tertiary_link"), use(KIND, "major_road")), + rule(with("class", "residential", "unclassified", "road", "raceway", "service"), use(KIND, "minor_road")), + rule(with("class", "pedestrian", "track", "corridor", "path", "cycleway", "bridleway", "footway", "steps"), use(KIND, "path")), + + // Assign kind_detail + rule(with("class", "service"), use(KIND_DETAIL, "service")) + + )).index(); + @Override public String name() { return LAYER_NAME; @@ -486,13 +512,28 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { return; } - features.line("roads") + var matches = overtureHighwaysIndex.getMatches(sf); + if (matches.isEmpty()) { + return; + } + + String kind = getString(sf, matches, KIND, UNDEFINED); + String kindDetail = getString(sf, matches, KIND_DETAIL, UNDEFINED); + String name = sf.getString("names.primary"); + + // Quickly eliminate any features with non-matching tags + if (kind.equals(UNDEFINED)) + return; + + features.line(this.name()) .setId(FeatureId.create(sf)) - .setAttr("kind", "minor_road") // sf.getString("class")) + .setAttr("kind", kind) + .setAttr("kind_detail", kindDetail) + .setAttr("name", name) // To power better client label collisions .setMinZoom(6) // `highway` is a temporary attribute that gets removed in the post-process step - .setAttr("highway", sf.getString("class")) + .setAttr("highway", kind) .setAttr("sort_rank", 400) .setMinPixelSize(0) .setPixelTolerance(0) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index bdc150bf..74f567d3 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -701,7 +701,7 @@ void kind_minorRoad_fromResidentialClass() { @Test void kind_minorRoad_fromServiceClass() { assertFeatures(15, - List.of(Map.of("kind", "minor_road", "min_zoom", 14, "name", "Derby Street")), + List.of(Map.of("kind", "minor_road", "kind_detail", "service", "min_zoom", 14, "name", "Derby Street")), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( From 4ea04aa9bd981fccf72ef7db3d6ea00cb0580dad Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 10:56:49 -0800 Subject: [PATCH 56/76] Added basic zoom assignments for Overture roads --- .../com/protomaps/basemap/layers/Pois.java | 2 +- .../com/protomaps/basemap/layers/Roads.java | 99 ++++++++++++++----- 2 files changed, 78 insertions(+), 23 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 8083828b..ebb4a964 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -606,7 +606,7 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { .setAttr("min_zoom", minZoom + 1) // .setBufferPixels(8) - .setZoomRange(Math.min(15, 15), 15); + .setZoomRange(Math.min(minZoom, 15), 15); } @Override diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index cf4d81f7..21a59357 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -19,6 +19,7 @@ import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; import com.protomaps.basemap.feature.CountryCoder; import com.protomaps.basemap.feature.FeatureId; +import com.protomaps.basemap.feature.Matcher; import com.protomaps.basemap.locales.CartographicLocale; import com.protomaps.basemap.names.OsmNames; import java.util.*; @@ -37,6 +38,8 @@ public Roads(CountryCoder countryCoder) { // Internal tags used to reference calculated values between matchers private static final String KIND = "protomaps-basemaps:kind"; private static final String KIND_DETAIL = "protomaps-basemaps:kindDetail"; + private static final String MINZOOM = "protomaps-basemaps:minZoom"; + private static final String HIGHWAY = "protomaps-basemaps:highway"; private static final String UNDEFINED = "protomaps-basemaps:undefined"; private static final MultiExpression.Index> indexHighways = MultiExpression.of(List.of( @@ -295,22 +298,60 @@ public Roads(CountryCoder countryCoder) { // Overture properties to Protomaps kind mapping - private static final MultiExpression.Index> overtureHighwaysIndex = MultiExpression.ofOrdered(List.of( - - // Everything is undefined at first - rule(use(KIND, UNDEFINED), use(KIND_DETAIL, UNDEFINED)), - - // Pull from road class by default? - rule(with("class"), use(KIND, fromTag("class")), use(KIND_DETAIL, "")), - - // Assign HighRoad kinds - rule(with("class", "motorway", "motorway_link"), use(KIND, "highway")), - rule(with("class", "trunk", "trunk_link", "primary", "primary_link", "secondary", "secondary_link", "tertiary", "tertiary_link"), use(KIND, "major_road")), - rule(with("class", "residential", "unclassified", "road", "raceway", "service"), use(KIND, "minor_road")), - rule(with("class", "pedestrian", "track", "corridor", "path", "cycleway", "bridleway", "footway", "steps"), use(KIND, "path")), - - // Assign kind_detail - rule(with("class", "service"), use(KIND_DETAIL, "service")) + private static final MultiExpression.Index> overtureKindsIndex = + MultiExpression.ofOrdered(List.of( + + // Everything is undefined at first + rule(use(KIND, UNDEFINED), use(KIND_DETAIL, UNDEFINED), use(HIGHWAY, UNDEFINED)), + + // Pull detail from road class by default, also store in HIGHWAY for zoom grading + rule( + with("class"), + use(KIND, fromTag("class")), + use(KIND_DETAIL, fromTag("class")), + use(HIGHWAY, fromTag("class")) + ), + + // Overwrite detail with subclass if it exists + rule( + with("class"), + with("subclass"), + use(KIND_DETAIL, fromTag("subclass")) + ), + + // Assign specific HighRoad kinds from class + rule(with("class", "motorway", "motorway_link"), use(KIND, "highway")), + rule( + with("class", "trunk", "trunk_link", "primary", "primary_link", "secondary", "secondary_link", "tertiary", + "tertiary_link"), + use(KIND, "major_road") + ), + rule(with("class", "residential", "unclassified", "road", "raceway", "service"), use(KIND, "minor_road")), + rule( + with("class", "pedestrian", "track", "corridor", "path", "cycleway", "bridleway", "footway", "steps"), + use(KIND, "path") + ), + + // Assign kind_detail=service if appropriate + rule(with("class", "service"), use(KIND_DETAIL, "service")) + + )).index(); + + private static final MultiExpression.Index> highwayZoomsIndex = MultiExpression.ofOrdered(List.of( + + rule(use(MINZOOM, 99)), + + rule(with(KIND, "highway"), use(MINZOOM, 3)), + rule(with(KIND, "major_road"), with(HIGHWAY, "trunk", "trunk_link"), use(MINZOOM, 6)), + rule(with(KIND, "major_road"), with(HIGHWAY, "primary", "primary_link"), use(MINZOOM, 7)), + rule(with(KIND, "major_road"), with(HIGHWAY, "secondary", "secondary_link"), use(MINZOOM, 9)), + rule(with(KIND, "major_road"), with(HIGHWAY, "tertiary", "tertiary_link"), use(MINZOOM, 9)), + rule(with(KIND, "minor_road"), use(MINZOOM, 12)), + rule(with(KIND, "minor_road"), with(KIND_DETAIL, "service"), use(MINZOOM, 13)), + rule(with(KIND, "path"), use(MINZOOM, 12)), + + rule(with(KIND, "path"), with(KIND_DETAIL, "path", "cycleway", "bridleway", "footway", "steps"), use(MINZOOM, 13)), + rule(with(KIND, "path"), with(KIND_DETAIL, "sidewalk", "crossing"), use(MINZOOM, 14)) )).index(); @@ -512,32 +553,46 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { return; } - var matches = overtureHighwaysIndex.getMatches(sf); - if (matches.isEmpty()) { + var kindMatches = overtureKindsIndex.getMatches(sf); + if (kindMatches.isEmpty()) { return; } - String kind = getString(sf, matches, KIND, UNDEFINED); - String kindDetail = getString(sf, matches, KIND_DETAIL, UNDEFINED); String name = sf.getString("names.primary"); + String kind = getString(sf, kindMatches, KIND, UNDEFINED); + String kindDetail = getString(sf, kindMatches, KIND_DETAIL, UNDEFINED); + String highway = getString(sf, kindMatches, HIGHWAY, UNDEFINED); + Integer minZoom; // Quickly eliminate any features with non-matching tags if (kind.equals(UNDEFINED)) return; + // Calculate minZoom using zooms indexes + var sf2 = new Matcher.SourceFeatureWithComputedTags( + sf, + Map.of(KIND, kind, KIND_DETAIL, kindDetail, HIGHWAY, highway) + ); + var zoomMatches = highwayZoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + // Initial minZoom + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + features.line(this.name()) .setId(FeatureId.create(sf)) .setAttr("kind", kind) .setAttr("kind_detail", kindDetail) .setAttr("name", name) // To power better client label collisions - .setMinZoom(6) + .setAttr("min_zoom", minZoom + 1) // `highway` is a temporary attribute that gets removed in the post-process step .setAttr("highway", kind) .setAttr("sort_rank", 400) .setMinPixelSize(0) .setPixelTolerance(0) - .setMinZoom(6); + .setZoomRange(Math.min(minZoom, 15), 15); } @Override From 3a35a570a37dbe6d7b0b1efebae3bd19bf280419 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 11:58:50 -0800 Subject: [PATCH 57/76] Add comprehensive tests for Overture roads oneway/is_link extraction and line splitting - Updated 6 existing tests to include access_restrictions and road_flags data - Added 6 new splitting tests for partial bridge/tunnel/oneway/level application - Tests use simple geometries (0,0)-(1,0) for easy verification - All 12 tests failing as expected (property extraction and splitting not yet implemented) - References real Overture feature IDs and OSM way IDs in comments --- .../protomaps/basemap/layers/RoadsTest.java | 299 +++++++++++++++++- 1 file changed, 287 insertions(+), 12 deletions(-) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index 74f567d3..732c3ab4 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -478,13 +478,15 @@ void kind_highway_fromMotorwayClass() { process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( - // TODO: get access_restrictions in here "id", "99f8b0b1-efde-4649-820a-9ef5498ba58a", // https://www.openstreetmap.org/way/692662557/history/5 "theme", "transportation", "type", "segment", "subtype", "road", "class", "motorway", - "names.primary", "Nimitz Freeway" + "names.primary", "Nimitz Freeway", + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) )), "overture", null, 0 ))); @@ -497,14 +499,16 @@ void kind_highwayLink_fromMotorwayClass() { process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( - // TODO: get access_restrictions in here "id", "ed49cecd-d577-4924-92b6-abaaf92bee6c", // https://www.openstreetmap.org/way/932872494/history/2 "theme", "transportation", "type", "segment", "subtype", "road", "class", "motorway", "subclass", "link", - "road_flags", List.of("is_link") + "road_flags", List.of(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) )), "overture", null, 0 ))); @@ -535,14 +539,16 @@ void kind_majorLink_fromTrunkClass() { process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( - // TODO: get access_restrictions in here "id", "3aefac69-2653-41a1-ae19-0d36d6d03491", // https://www.openstreetmap.org/way/198565349/history/11 "theme", "transportation", "type", "segment", "subtype", "road", "class", "trunk", "subclass", "link", - "road_flags", List.of("is_link") + "road_flags", List.of(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) )), "overture", null, 0 ))); @@ -573,14 +579,16 @@ void kind_majorLink_fromPrimaryClass() { process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( - // TODO: get access_restrictions in here "id", "2c9442b6-14c2-44e0-975d-d69bd83a0da7", // https://www.openstreetmap.org/way/198565347/history/10 "theme", "transportation", "type", "segment", "subtype", "road", "class", "primary", "subclass", "link", - "road_flags", List.of("is_link") + "road_flags", List.of(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) )), "overture", null, 0 ))); @@ -611,14 +619,16 @@ void kind_majorLink_fromSecondaryClass() { process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( - // TODO: get access_restrictions in here "id", "bf0ba372-0a6e-417b-a180-e174f4276b9c", // https://www.openstreetmap.org/way/23591806/history/10 "theme", "transportation", "type", "segment", "subtype", "road", "class", "secondary", "subclass", "link", - "road_flags", List.of("is_link") + "road_flags", List.of(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) )), "overture", null, 0 ))); @@ -649,14 +659,16 @@ void kind_majorLink_fromTertiaryClass() { process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( - // TODO: get access_restrictions in here "id", "ad765059-60f9-4eb5-b672-9faf90748f00", // https://www.openstreetmap.org/way/8915068/history/18 "theme", "transportation", "type", "segment", "subtype", "road", "class", "tertiary", "subclass", "link", - "road_flags", List.of("is_link") + "road_flags", List.of(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) )), "overture", null, 0 ))); @@ -751,4 +763,267 @@ void kind_sidewalk_fromFootwayClass() { "overture", null, 0 ))); } + + // ===== Line Splitting Tests ===== + // Tests for partial application of properties (bridge, tunnel, oneway, level) requiring line splitting + + @Test + void split_partialBridge_middleSection() { + // Test: Single bridge section in the middle of a line + // Geometry: (0,0) to (1,0) - length=1 for easy math + // Bridge from 0.25 to 0.75 + // Expected: 3 output features with correct geometries and is_bridge attribute + var results = process(SimpleFeature.create( + newLineString(0, 0, 1, 0), + new HashMap<>(Map.of( + "id", "test-bridge-middle", + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "primary", + "road_flags", List.of( + Map.of("values", List.of("is_bridge"), "between", List.of(0.25, 0.75)) + ) + )), + "overture", null, 0 + )); + + assertFeatures(15, List.of( + Map.of( + "kind", "major_road", + "kind_detail", "primary", + "_geom", newLineString(0, 0, 0.25, 0) + // No is_bridge attribute + ), + Map.of( + "kind", "major_road", + "kind_detail", "primary", + "_geom", newLineString(0.25, 0, 0.75, 0), + "is_bridge", true + ), + Map.of( + "kind", "major_road", + "kind_detail", "primary", + "_geom", newLineString(0.75, 0, 1, 0) + // No is_bridge attribute + ) + ), results); + } + + @Test + void split_partialBridge_twoSections() { + // Test: Two separate bridge sections + // Geometry: (0,0) to (1,0) + // Bridges from 0.1-0.3 and 0.6-0.8 + // Expected: 5 output features + // Based on Overture c3b55f85-220c-4d00-8419-be3f2c795729 (footway with 2 bridge sections) + // OSM ways: 999763975, 999763974, 22989089, 999763972, 999763973 + var results = process(SimpleFeature.create( + newLineString(0, 0, 1, 0), + new HashMap<>(Map.of( + "id", "c3b55f85-220c-4d00-8419-be3f2c795729", + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "footway", + "road_flags", List.of( + Map.of("values", List.of("is_bridge"), "between", List.of(0.1, 0.3)), + Map.of("values", List.of("is_bridge"), "between", List.of(0.6, 0.8)) + ) + )), + "overture", null, 0 + )); + + assertFeatures(15, List.of( + Map.of("kind", "path", "_geom", newLineString(0, 0, 0.1, 0)), + Map.of("kind", "path", "_geom", newLineString(0.1, 0, 0.3, 0), "is_bridge", true), + Map.of("kind", "path", "_geom", newLineString(0.3, 0, 0.6, 0)), + Map.of("kind", "path", "_geom", newLineString(0.6, 0, 0.8, 0), "is_bridge", true), + Map.of("kind", "path", "_geom", newLineString(0.8, 0, 1, 0)) + ), results); + } + + @Test + void split_partialTunnel_fromStart() { + // Test: Tunnel from start to middle + // Geometry: (0,0) to (1,0) + // Tunnel from 0.0 to 0.5 + // Expected: 2 output features + // Based on Overture 6c52a051-7433-470a-aa89-935be681c967 (primary with tunnel) + // OSM ways: 659613394, 25966237 + var results = process(SimpleFeature.create( + newLineString(0, 0, 1, 0), + new HashMap<>(Map.of( + "id", "6c52a051-7433-470a-aa89-935be681c967", + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "primary", + "road_flags", List.of( + Map.of("values", List.of("is_tunnel"), "between", List.of(0.0, 0.5)) + ) + )), + "overture", null, 0 + )); + + assertFeatures(15, List.of( + Map.of( + "kind", "major_road", + "kind_detail", "primary", + "_geom", newLineString(0, 0, 0.5, 0), + "is_tunnel", true + ), + Map.of( + "kind", "major_road", + "kind_detail", "primary", + "_geom", newLineString(0.5, 0, 1, 0) + // No is_tunnel attribute + ) + ), results); + } + + @Test + void split_partialLevel_elevatedSection() { + // Test: Elevated/bridge section with level=1 + // Geometry: (0,0) to (1,0) + // Level 1 from 0.25 to 0.75 + // Expected: 3 output features with different level values + // Based on Overture 8d70a823-6584-459d-999d-cabf3b9672f6 (motorway with elevated section) + // OSM ways: 41168616, 931029707, 41168617 + var results = process(SimpleFeature.create( + newLineString(0, 0, 1, 0), + new HashMap<>(Map.of( + "id", "8d70a823-6584-459d-999d-cabf3b9672f6", + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "motorway", + "level_rules", List.of( + Map.of("value", 1, "between", List.of(0.25, 0.75)) + ) + )), + "overture", null, 0 + )); + + assertFeatures(15, List.of( + Map.of( + "kind", "highway", + "kind_detail", "motorway", + "_geom", newLineString(0, 0, 0.25, 0) + // level=0 or no level attribute + ), + Map.of( + "kind", "highway", + "kind_detail", "motorway", + "_geom", newLineString(0.25, 0, 0.75, 0), + "level", 1 + ), + Map.of( + "kind", "highway", + "kind_detail", "motorway", + "_geom", newLineString(0.75, 0, 1, 0) + // level=0 or no level attribute + ) + ), results); + } + + @Test + void split_partialOneway_secondHalf() { + // Test: Oneway restriction on second half of line + // Geometry: (0,0) to (1,0) + // Oneway (access denied backward) from 0.5 to 1.0 + // Expected: 2 output features + // Based on Overture 10536347-2a89-4f05-9a3d-92d365931bc4 (secondary with partial oneway) + // OSM ways: 394110740, 59689569 + var results = process(SimpleFeature.create( + newLineString(0, 0, 1, 0), + new HashMap<>(Map.of( + "id", "10536347-2a89-4f05-9a3d-92d365931bc4", + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "secondary", + "access_restrictions", List.of( + Map.of( + "access_type", "denied", + "when", Map.of("heading", "backward"), + "between", List.of(0.5, 1.0) + ) + ) + )), + "overture", null, 0 + )); + + assertFeatures(15, List.of( + Map.of( + "kind", "major_road", + "kind_detail", "secondary", + "_geom", newLineString(0, 0, 0.5, 0) + // No oneway attribute + ), + Map.of( + "kind", "major_road", + "kind_detail", "secondary", + "_geom", newLineString(0.5, 0, 1, 0), + "oneway", true + ) + ), results); + } + + @Test + void split_overlapping_bridgeAndOneway() { + // Test: Overlapping bridge and oneway restrictions + // Geometry: (0,0) to (1,0) + // Bridge from 0.25 to 0.75 + // Oneway from 0.5 to 1.0 + // Expected: 4 output features with different combinations + var results = process(SimpleFeature.create( + newLineString(0, 0, 1, 0), + new HashMap<>(Map.of( + "id", "test-overlap", + "theme", "transportation", + "type", "segment", + "subtype", "road", + "class", "primary", + "road_flags", List.of( + Map.of("values", List.of("is_bridge"), "between", List.of(0.25, 0.75)) + ), + "access_restrictions", List.of( + Map.of( + "access_type", "denied", + "when", Map.of("heading", "backward"), + "between", List.of(0.5, 1.0) + ) + ) + )), + "overture", null, 0 + )); + + assertFeatures(15, List.of( + Map.of( + "kind", "major_road", + "_geom", newLineString(0, 0, 0.25, 0) + // Neither is_bridge nor oneway + ), + Map.of( + "kind", "major_road", + "_geom", newLineString(0.25, 0, 0.5, 0), + "is_bridge", true + // is_bridge only, no oneway + ), + Map.of( + "kind", "major_road", + "_geom", newLineString(0.5, 0, 0.75, 0), + "is_bridge", true, + "oneway", true + // Both is_bridge AND oneway + ), + Map.of( + "kind", "major_road", + "_geom", newLineString(0.75, 0, 1, 0), + "oneway", true + // oneway only, no is_bridge + ) + ), results); + } } From 0291908389cd01a29b4b4555925f43c1a6560abb Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 12:15:22 -0800 Subject: [PATCH 58/76] Implement Overture roads line splitting for partial bridge/tunnel/oneway/level properties Major Changes: - Created com.protomaps.basemap.geometry.Linear utility class for line splitting operations - Rewrote Roads.processOverture() to handle fractional 'between' ranges from Overture data - Implemented collectSplitPoints() to gather all split positions from road_flags, access_restrictions, and level_rules - Implemented extractSegmentProperties() to determine which properties apply to each split segment - Added emitRoadFeature() to create features with custom split geometries Results: - 15/21 tests now passing (6 original property extraction tests now pass) - 6 splitting tests create correct features with correct attributes and geometries - Only remaining issue: cosmetic Norm{} wrapper in test assertions (geometries are actually correct) Implementation handles: - Partial bridges via road_flags with 'is_bridge' flag - Partial tunnels via road_flags with 'is_tunnel' flag - Partial oneway restrictions via access_restrictions with heading='backward' - Partial level changes via level_rules - Overlapping property ranges (e.g., bridge + oneway on same segment) - Multiple split points creating 2-5 output features per input feature --- .../protomaps/basemap/geometry/Linear.java | 152 +++++++++ .../com/protomaps/basemap/layers/Roads.java | 288 +++++++++++++++++- .../protomaps/basemap/layers/RoadsTest.java | 39 +-- 3 files changed, 456 insertions(+), 23 deletions(-) create mode 100644 tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java diff --git a/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java new file mode 100644 index 00000000..07a0c3af --- /dev/null +++ b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java @@ -0,0 +1,152 @@ +package com.protomaps.basemap.geometry; + +import java.util.*; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +/** + * Utility class for linear geometry operations, particularly splitting LineStrings at fractional positions. + */ +public class Linear { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + /** + * Represents a segment of a line with fractional start/end positions. + */ + public static class Segment { + public final double start; // Fractional position 0.0-1.0 + public final double end; // Fractional position 0.0-1.0 + + public Segment(double start, double end) { + this.start = start; + this.end = end; + } + } + + /** + * Split a LineString at fractional positions and return list of split LineStrings. + * + * @param line The LineString to split + * @param splitPoints List of fractional positions (0.0-1.0) where to split + * @return List of LineString segments + */ + public static List splitAtFractions(LineString line, List splitPoints) { + if (splitPoints.isEmpty()) { + return List.of(line); + } + + // Sort split points and remove duplicates, ensure 0.0 and 1.0 are included + Set pointSet = new TreeSet<>(); + pointSet.add(0.0); + pointSet.addAll(splitPoints); + pointSet.add(1.0); + + List points = new ArrayList<>(pointSet); + List segments = new ArrayList<>(); + + // Calculate total length + double totalLength = 0.0; + for (int i = 0; i < line.getNumPoints() - 1; i++) { + Coordinate c1 = line.getCoordinateN(i); + Coordinate c2 = line.getCoordinateN(i + 1); + totalLength += c1.distance(c2); + } + + // For each pair of split points, create a segment + for (int i = 0; i < points.size() - 1; i++) { + double startFrac = points.get(i); + double endFrac = points.get(i + 1); + + Coordinate startCoord = getCoordinateAtFraction(line, startFrac, totalLength); + Coordinate endCoord = getCoordinateAtFraction(line, endFrac, totalLength); + + if (startCoord != null && endCoord != null && !startCoord.equals2D(endCoord)) { + LineString segment = GEOMETRY_FACTORY.createLineString(new Coordinate[]{startCoord, endCoord}); + segments.add(segment); + } + } + + return segments; + } + + /** + * Create list of Segments representing the split ranges between all split points. + * + * @param splitPoints List of fractional positions (0.0-1.0) where to split + * @return List of Segment objects with start/end fractions + */ + public static List createSegments(List splitPoints) { + if (splitPoints.isEmpty()) { + return List.of(new Segment(0.0, 1.0)); + } + + // Sort split points and remove duplicates, ensure 0.0 and 1.0 are included + Set pointSet = new TreeSet<>(); + pointSet.add(0.0); + pointSet.addAll(splitPoints); + pointSet.add(1.0); + + List points = new ArrayList<>(pointSet); + List segments = new ArrayList<>(); + + for (int i = 0; i < points.size() - 1; i++) { + segments.add(new Segment(points.get(i), points.get(i + 1))); + } + + return segments; + } + + /** + * Get coordinate at fractional position along line. + * + * @param line The LineString + * @param fraction Fractional position (0.0-1.0) + * @param totalLength Pre-calculated total length of the line + * @return Coordinate at the fractional position + */ + private static Coordinate getCoordinateAtFraction(LineString line, double fraction, double totalLength) { + if (fraction <= 0.0) { + return line.getCoordinateN(0); + } + if (fraction >= 1.0) { + return line.getCoordinateN(line.getNumPoints() - 1); + } + + double targetDist = fraction * totalLength; + double currentDist = 0.0; + + for (int i = 0; i < line.getNumPoints() - 1; i++) { + Coordinate c1 = line.getCoordinateN(i); + Coordinate c2 = line.getCoordinateN(i + 1); + double segmentLength = c1.distance(c2); + + if (currentDist + segmentLength >= targetDist) { + // Interpolate within this segment + double segmentFraction = (targetDist - currentDist) / segmentLength; + double x = c1.x + (c2.x - c1.x) * segmentFraction; + double y = c1.y + (c2.y - c1.y) * segmentFraction; + return new Coordinate(x, y); + } + + currentDist += segmentLength; + } + + return line.getCoordinateN(line.getNumPoints() - 1); + } + + /** + * Check if a segment defined by [segStart, segEnd] overlaps with a range [rangeStart, rangeEnd]. + * + * @param segStart Start of segment (0.0-1.0) + * @param segEnd End of segment (0.0-1.0) + * @param rangeStart Start of range (0.0-1.0) + * @param rangeEnd End of range (0.0-1.0) + * @return true if the segment overlaps with the range + */ + public static boolean overlaps(double segStart, double segEnd, double rangeStart, double rangeEnd) { + // Segments overlap if they share any fractional position + return segEnd > rangeStart && segStart < rangeEnd; + } +} diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 21a59357..c5fdfd8b 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -22,7 +22,9 @@ import com.protomaps.basemap.feature.Matcher; import com.protomaps.basemap.locales.CartographicLocale; import com.protomaps.basemap.names.OsmNames; +import com.protomaps.basemap.geometry.Linear; import java.util.*; +import org.locationtech.jts.geom.LineString; @SuppressWarnings("java:S1192") public class Roads implements ForwardingProfile.LayerPostProcessor, ForwardingProfile.OsmRelationPreprocessor { @@ -539,6 +541,25 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } + /** + * Represents properties that can apply to a segment of a road + */ + private static class SegmentProperties { + boolean isBridge; + boolean isTunnel; + boolean isOneway; + boolean isLink; + Integer level; + + SegmentProperties() { + this.isBridge = false; + this.isTunnel = false; + this.isOneway = false; + this.isLink = false; + this.level = null; + } + } + public void processOverture(SourceFeature sf, FeatureCollector features) { // Filter by type field - Overture transportation theme if (!"transportation".equals(sf.getString("theme"))) { @@ -580,19 +601,278 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { // Initial minZoom minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); - features.line(this.name()) + // Collect all split points from all property arrays + List splitPoints = new ArrayList<>(); + collectSplitPoints(sf, splitPoints); + + // Get the original geometry - use latLonGeometry for consistency with test infrastructure + try { + LineString originalLine = (LineString) sf.latLonGeometry(); + + // If no split points, process as single feature + if (splitPoints.isEmpty()) { + emitRoadFeature(features, sf, originalLine, kind, kindDetail, name, highway, minZoom, + extractSegmentProperties(sf, 0.0, 1.0)); + return; + } + + // Split the line and emit features for each segment + List splitGeometries = Linear.splitAtFractions(originalLine, splitPoints); + List segments = Linear.createSegments(splitPoints); + + for (int i = 0; i < segments.size() && i < splitGeometries.size(); i++) { + Linear.Segment seg = segments.get(i); + LineString segmentGeom = splitGeometries.get(i); + SegmentProperties props = extractSegmentProperties(sf, seg.start, seg.end); + + emitRoadFeature(features, sf, segmentGeom, kind, kindDetail, name, highway, minZoom, props); + } + + } catch (GeometryException e) { + // Skip features with geometry problems + } + } + + /** + * Emit a road feature with given geometry and properties + */ + private void emitRoadFeature(FeatureCollector features, SourceFeature sf, LineString geometry, + String kind, String kindDetail, String name, String highway, int minZoom, + SegmentProperties props) { + + var feat = features.geometry(this.name(), geometry) .setId(FeatureId.create(sf)) .setAttr("kind", kind) .setAttr("kind_detail", kindDetail) .setAttr("name", name) - // To power better client label collisions .setAttr("min_zoom", minZoom + 1) - // `highway` is a temporary attribute that gets removed in the post-process step - .setAttr("highway", kind) + .setAttr("highway", highway) .setAttr("sort_rank", 400) .setMinPixelSize(0) .setPixelTolerance(0) .setZoomRange(Math.min(minZoom, 15), 15); + + if (props.isOneway) { + feat.setAttrWithMinzoom("oneway", true, 14); + } + + if (props.isLink) { + feat.setAttr("is_link", true); + } + + if (props.isBridge) { + feat.setAttrWithMinzoom("is_bridge", true, 12); + } + + if (props.isTunnel) { + feat.setAttrWithMinzoom("is_tunnel", true, 12); + } + + if (props.level != null) { + feat.setAttr("level", props.level); + } + } + + /** + * Collect all split points from road_flags, access_restrictions, and level_rules + */ + private void collectSplitPoints(SourceFeature sf, List splitPoints) { + // From road_flags + Object roadFlagsObj = sf.getTag("road_flags"); + if (roadFlagsObj instanceof List) { + @SuppressWarnings("unchecked") + List roadFlags = (List) roadFlagsObj; + for (Object flagObj : roadFlags) { + if (flagObj instanceof Map) { + @SuppressWarnings("unchecked") + Map flag = (Map) flagObj; + Object betweenObj = flag.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") + List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + splitPoints.add(((Number) between.get(0)).doubleValue()); + splitPoints.add(((Number) between.get(1)).doubleValue()); + } + } + } + } + } + + // From access_restrictions + Object accessRestrictionsObj = sf.getTag("access_restrictions"); + if (accessRestrictionsObj instanceof List) { + @SuppressWarnings("unchecked") + List accessRestrictions = (List) accessRestrictionsObj; + for (Object restrictionObj : accessRestrictions) { + if (restrictionObj instanceof Map) { + @SuppressWarnings("unchecked") + Map restriction = (Map) restrictionObj; + Object betweenObj = restriction.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") + List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + splitPoints.add(((Number) between.get(0)).doubleValue()); + splitPoints.add(((Number) between.get(1)).doubleValue()); + } + } + } + } + } + + // From level_rules + Object levelRulesObj = sf.getTag("level_rules"); + if (levelRulesObj instanceof List) { + @SuppressWarnings("unchecked") + List levelRules = (List) levelRulesObj; + for (Object ruleObj : levelRules) { + if (ruleObj instanceof Map) { + @SuppressWarnings("unchecked") + Map rule = (Map) ruleObj; + Object betweenObj = rule.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") + List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + splitPoints.add(((Number) between.get(0)).doubleValue()); + splitPoints.add(((Number) between.get(1)).doubleValue()); + } + } + } + } + } + } + + /** + * Extract properties that apply to a segment defined by [start, end] fractional positions + */ + private SegmentProperties extractSegmentProperties(SourceFeature sf, double start, double end) { + SegmentProperties props = new SegmentProperties(); + + // Check road_flags for is_bridge, is_tunnel, is_link + Object roadFlagsObj = sf.getTag("road_flags"); + if (roadFlagsObj instanceof List) { + @SuppressWarnings("unchecked") + List roadFlags = (List) roadFlagsObj; + for (Object flagObj : roadFlags) { + if (flagObj instanceof Map) { + @SuppressWarnings("unchecked") + Map flag = (Map) flagObj; + + Object valuesObj = flag.get("values"); + Object betweenObj = flag.get("between"); + + // Determine the range this flag applies to + double rangeStart = 0.0; + double rangeEnd = 1.0; + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") + List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + rangeStart = ((Number) between.get(0)).doubleValue(); + rangeEnd = ((Number) between.get(1)).doubleValue(); + } + } + + // Check if this segment overlaps with the flag's range + if (Linear.overlaps(start, end, rangeStart, rangeEnd) && valuesObj instanceof List) { + @SuppressWarnings("unchecked") + List values = (List) valuesObj; + if (values.contains("is_bridge")) { + props.isBridge = true; + } + if (values.contains("is_tunnel")) { + props.isTunnel = true; + } + if (values.contains("is_link")) { + props.isLink = true; + } + } + } + } + } + + // Check access_restrictions for oneway + Object accessRestrictionsObj = sf.getTag("access_restrictions"); + if (accessRestrictionsObj instanceof List) { + @SuppressWarnings("unchecked") + List accessRestrictions = (List) accessRestrictionsObj; + for (Object restrictionObj : accessRestrictions) { + if (restrictionObj instanceof Map) { + @SuppressWarnings("unchecked") + Map restriction = (Map) restrictionObj; + + String accessType = (String) restriction.get("access_type"); + if (!"denied".equals(accessType)) { + continue; + } + + Object whenObj = restriction.get("when"); + if (whenObj instanceof Map) { + @SuppressWarnings("unchecked") + Map when = (Map) whenObj; + String heading = (String) when.get("heading"); + + if ("backward".equals(heading)) { + // Determine the range this restriction applies to + double rangeStart = 0.0; + double rangeEnd = 1.0; + Object betweenObj = restriction.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") + List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + rangeStart = ((Number) between.get(0)).doubleValue(); + rangeEnd = ((Number) between.get(1)).doubleValue(); + } + } + + if (Linear.overlaps(start, end, rangeStart, rangeEnd)) { + props.isOneway = true; + } + } + } + } + } + } + + // Check level_rules + Object levelRulesObj = sf.getTag("level_rules"); + if (levelRulesObj instanceof List) { + @SuppressWarnings("unchecked") + List levelRules = (List) levelRulesObj; + for (Object ruleObj : levelRules) { + if (ruleObj instanceof Map) { + @SuppressWarnings("unchecked") + Map rule = (Map) ruleObj; + + Object valueObj = rule.get("value"); + if (valueObj instanceof Number) { + Integer levelValue = ((Number) valueObj).intValue(); + + // Determine the range this level applies to + double rangeStart = 0.0; + double rangeEnd = 1.0; + Object betweenObj = rule.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") + List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + rangeStart = ((Number) between.get(0)).doubleValue(); + rangeEnd = ((Number) between.get(1)).doubleValue(); + } + } + + if (Linear.overlaps(start, end, rangeStart, rangeEnd)) { + props.level = levelValue; + } + } + } + } + } + + return props; } @Override diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index 732c3ab4..f3d31936 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -3,6 +3,7 @@ import static com.onthegomap.planetiler.TestUtils.newLineString; import com.onthegomap.planetiler.FeatureCollector; +import com.onthegomap.planetiler.TestUtils; import com.onthegomap.planetiler.reader.SimpleFeature; import com.onthegomap.planetiler.reader.osm.OsmElement; import com.onthegomap.planetiler.reader.osm.OsmReader; @@ -792,19 +793,19 @@ void split_partialBridge_middleSection() { Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", newLineString(0, 0, 0.25, 0) + "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.25, 0)) // No is_bridge attribute ), Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", newLineString(0.25, 0, 0.75, 0), + "_geom", new TestUtils.NormGeometry(newLineString(0.25, 0, 0.75, 0)), "is_bridge", true ), Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", newLineString(0.75, 0, 1, 0) + "_geom", new TestUtils.NormGeometry(newLineString(0.75, 0, 1, 0)) // No is_bridge attribute ) ), results); @@ -835,11 +836,11 @@ void split_partialBridge_twoSections() { )); assertFeatures(15, List.of( - Map.of("kind", "path", "_geom", newLineString(0, 0, 0.1, 0)), - Map.of("kind", "path", "_geom", newLineString(0.1, 0, 0.3, 0), "is_bridge", true), - Map.of("kind", "path", "_geom", newLineString(0.3, 0, 0.6, 0)), - Map.of("kind", "path", "_geom", newLineString(0.6, 0, 0.8, 0), "is_bridge", true), - Map.of("kind", "path", "_geom", newLineString(0.8, 0, 1, 0)) + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.1, 0))), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.1, 0, 0.3, 0)), "is_bridge", true), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.3, 0, 0.6, 0))), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0, 0.8, 0)), "is_bridge", true), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0, 1, 0))) ), results); } @@ -870,13 +871,13 @@ void split_partialTunnel_fromStart() { Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", newLineString(0, 0, 0.5, 0), + "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.5, 0)), "is_tunnel", true ), Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", newLineString(0.5, 0, 1, 0) + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0, 1, 0)) // No is_tunnel attribute ) ), results); @@ -909,19 +910,19 @@ void split_partialLevel_elevatedSection() { Map.of( "kind", "highway", "kind_detail", "motorway", - "_geom", newLineString(0, 0, 0.25, 0) + "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.25, 0)) // level=0 or no level attribute ), Map.of( "kind", "highway", "kind_detail", "motorway", - "_geom", newLineString(0.25, 0, 0.75, 0), + "_geom", new TestUtils.NormGeometry(newLineString(0.25, 0, 0.75, 0)), "level", 1 ), Map.of( "kind", "highway", "kind_detail", "motorway", - "_geom", newLineString(0.75, 0, 1, 0) + "_geom", new TestUtils.NormGeometry(newLineString(0.75, 0, 1, 0)) // level=0 or no level attribute ) ), results); @@ -958,13 +959,13 @@ void split_partialOneway_secondHalf() { Map.of( "kind", "major_road", "kind_detail", "secondary", - "_geom", newLineString(0, 0, 0.5, 0) + "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.5, 0)) // No oneway attribute ), Map.of( "kind", "major_road", "kind_detail", "secondary", - "_geom", newLineString(0.5, 0, 1, 0), + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0, 1, 0)), "oneway", true ) ), results); @@ -1002,25 +1003,25 @@ void split_overlapping_bridgeAndOneway() { assertFeatures(15, List.of( Map.of( "kind", "major_road", - "_geom", newLineString(0, 0, 0.25, 0) + "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.25, 0)) // Neither is_bridge nor oneway ), Map.of( "kind", "major_road", - "_geom", newLineString(0.25, 0, 0.5, 0), + "_geom", new TestUtils.NormGeometry(newLineString(0.25, 0, 0.5, 0)), "is_bridge", true // is_bridge only, no oneway ), Map.of( "kind", "major_road", - "_geom", newLineString(0.5, 0, 0.75, 0), + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0, 0.75, 0)), "is_bridge", true, "oneway", true // Both is_bridge AND oneway ), Map.of( "kind", "major_road", - "_geom", newLineString(0.75, 0, 1, 0), + "_geom", new TestUtils.NormGeometry(newLineString(0.75, 0, 1, 0)), "oneway", true // oneway only, no is_bridge ) From 1b4b172ee333d7189f5129be104de7b7384a3d65 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 12:56:09 -0800 Subject: [PATCH 59/76] Fix Linear.splitAtFractions() to preserve intermediate vertices for curved roads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive unit tests for line splitting with curves - Rewrite Linear.splitAtFractions() to preserve all vertices between split points - Add coordinate transformation from lat/lon to world coordinates before emitting - All 9 new Linear tests pass, roads render correctly with curves preserved 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../protomaps/basemap/geometry/Linear.java | 126 +++++++++- .../com/protomaps/basemap/layers/Roads.java | 68 ++---- .../basemap/geometry/LinearTest.java | 229 ++++++++++++++++++ 3 files changed, 368 insertions(+), 55 deletions(-) create mode 100644 tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java diff --git a/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java index 07a0c3af..4d753705 100644 --- a/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java +++ b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java @@ -17,7 +17,7 @@ public class Linear { */ public static class Segment { public final double start; // Fractional position 0.0-1.0 - public final double end; // Fractional position 0.0-1.0 + public final double end; // Fractional position 0.0-1.0 public Segment(double start, double end) { this.start = start; @@ -27,8 +27,9 @@ public Segment(double start, double end) { /** * Split a LineString at fractional positions and return list of split LineStrings. + * Preserves all intermediate vertices between split points to maintain curve geometry. * - * @param line The LineString to split + * @param line The LineString to split * @param splitPoints List of fractional positions (0.0-1.0) where to split * @return List of LineString segments */ @@ -46,24 +47,28 @@ public static List splitAtFractions(LineString line, List sp List points = new ArrayList<>(pointSet); List segments = new ArrayList<>(); - // Calculate total length + // Calculate total length and cumulative distances at each vertex + double[] cumulativeDistances = new double[line.getNumPoints()]; + cumulativeDistances[0] = 0.0; double totalLength = 0.0; + for (int i = 0; i < line.getNumPoints() - 1; i++) { Coordinate c1 = line.getCoordinateN(i); Coordinate c2 = line.getCoordinateN(i + 1); totalLength += c1.distance(c2); + cumulativeDistances[i + 1] = totalLength; } - // For each pair of split points, create a segment + // For each pair of split points, create a segment preserving intermediate vertices for (int i = 0; i < points.size() - 1; i++) { double startFrac = points.get(i); double endFrac = points.get(i + 1); - Coordinate startCoord = getCoordinateAtFraction(line, startFrac, totalLength); - Coordinate endCoord = getCoordinateAtFraction(line, endFrac, totalLength); + List segmentCoords = extractSegmentCoordinates( + line, startFrac, endFrac, totalLength, cumulativeDistances); - if (startCoord != null && endCoord != null && !startCoord.equals2D(endCoord)) { - LineString segment = GEOMETRY_FACTORY.createLineString(new Coordinate[]{startCoord, endCoord}); + if (segmentCoords.size() >= 2) { + LineString segment = GEOMETRY_FACTORY.createLineString(segmentCoords.toArray(new Coordinate[0])); segments.add(segment); } } @@ -71,6 +76,101 @@ public static List splitAtFractions(LineString line, List sp return segments; } + /** + * Extract all coordinates between startFrac and endFrac, preserving intermediate vertices. + * + * @param line The source LineString + * @param startFrac Start fraction (0.0-1.0) + * @param endFrac End fraction (0.0-1.0) + * @param totalLength Total length of the line + * @param cumulativeDistances Cumulative distances at each vertex + * @return List of coordinates for the segment + */ + private static List extractSegmentCoordinates( + LineString line, double startFrac, double endFrac, + double totalLength, double[] cumulativeDistances) { + + List coords = new ArrayList<>(); + double startDist = startFrac * totalLength; + double endDist = endFrac * totalLength; + + // Find the segment containing the start position + int startSegmentIdx = -1; + for (int i = 0; i < line.getNumPoints() - 1; i++) { + if (cumulativeDistances[i] <= startDist && startDist <= cumulativeDistances[i + 1]) { + startSegmentIdx = i; + break; + } + } + + // Find the segment containing the end position + int endSegmentIdx = -1; + for (int i = 0; i < line.getNumPoints() - 1; i++) { + if (cumulativeDistances[i] <= endDist && endDist <= cumulativeDistances[i + 1]) { + endSegmentIdx = i; + break; + } + } + + if (startSegmentIdx == -1 || endSegmentIdx == -1) { + // Fallback to simple 2-point line + Coordinate start = getCoordinateAtFraction(line, startFrac, totalLength); + Coordinate end = getCoordinateAtFraction(line, endFrac, totalLength); + if (start != null && end != null) { + coords.add(start); + coords.add(end); + } + return coords; + } + + // Add the start coordinate (interpolated if not at a vertex) + if (Math.abs(cumulativeDistances[startSegmentIdx] - startDist) < 1e-10) { + // Start is at a vertex + coords.add(line.getCoordinateN(startSegmentIdx)); + } else { + // Interpolate within the start segment + Coordinate c1 = line.getCoordinateN(startSegmentIdx); + Coordinate c2 = line.getCoordinateN(startSegmentIdx + 1); + double segmentLength = c1.distance(c2); + double distIntoSegment = startDist - cumulativeDistances[startSegmentIdx]; + double segmentFraction = distIntoSegment / segmentLength; + double x = c1.x + (c2.x - c1.x) * segmentFraction; + double y = c1.y + (c2.y - c1.y) * segmentFraction; + coords.add(new Coordinate(x, y)); + } + + // Add all intermediate vertices between start and end segments + for (int i = startSegmentIdx + 1; i <= endSegmentIdx; i++) { + // Don't duplicate if this vertex is exactly at startDist + if (Math.abs(cumulativeDistances[i] - startDist) > 1e-10) { + coords.add(line.getCoordinateN(i)); + } + } + + // Add the end coordinate (interpolated if not at a vertex) + if (Math.abs(cumulativeDistances[endSegmentIdx + 1] - endDist) < 1e-10) { + // End is exactly at a vertex + // Only add if not already added (could be same as last intermediate vertex) + Coordinate lastCoord = coords.isEmpty() ? null : coords.get(coords.size() - 1); + Coordinate endVertex = line.getCoordinateN(endSegmentIdx + 1); + if (lastCoord == null || !lastCoord.equals2D(endVertex)) { + coords.add(endVertex); + } + } else { + // Interpolate within the end segment + Coordinate c1 = line.getCoordinateN(endSegmentIdx); + Coordinate c2 = line.getCoordinateN(endSegmentIdx + 1); + double segmentLength = c1.distance(c2); + double distIntoSegment = endDist - cumulativeDistances[endSegmentIdx]; + double segmentFraction = distIntoSegment / segmentLength; + double x = c1.x + (c2.x - c1.x) * segmentFraction; + double y = c1.y + (c2.y - c1.y) * segmentFraction; + coords.add(new Coordinate(x, y)); + } + + return coords; + } + /** * Create list of Segments representing the split ranges between all split points. * @@ -101,8 +201,8 @@ public static List createSegments(List splitPoints) { /** * Get coordinate at fractional position along line. * - * @param line The LineString - * @param fraction Fractional position (0.0-1.0) + * @param line The LineString + * @param fraction Fractional position (0.0-1.0) * @param totalLength Pre-calculated total length of the line * @return Coordinate at the fractional position */ @@ -139,10 +239,10 @@ private static Coordinate getCoordinateAtFraction(LineString line, double fracti /** * Check if a segment defined by [segStart, segEnd] overlaps with a range [rangeStart, rangeEnd]. * - * @param segStart Start of segment (0.0-1.0) - * @param segEnd End of segment (0.0-1.0) + * @param segStart Start of segment (0.0-1.0) + * @param segEnd End of segment (0.0-1.0) * @param rangeStart Start of range (0.0-1.0) - * @param rangeEnd End of range (0.0-1.0) + * @param rangeEnd End of range (0.0-1.0) * @return true if the segment overlaps with the range */ public static boolean overlaps(double segStart, double segEnd, double rangeStart, double rangeEnd) { diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index c5fdfd8b..e601bc7b 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -14,15 +14,16 @@ import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.expression.MultiExpression; import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.reader.SourceFeature; import com.onthegomap.planetiler.reader.osm.OsmElement; import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; import com.protomaps.basemap.feature.CountryCoder; import com.protomaps.basemap.feature.FeatureId; import com.protomaps.basemap.feature.Matcher; +import com.protomaps.basemap.geometry.Linear; import com.protomaps.basemap.locales.CartographicLocale; import com.protomaps.basemap.names.OsmNames; -import com.protomaps.basemap.geometry.Linear; import java.util.*; import org.locationtech.jts.geom.LineString; @@ -640,7 +641,10 @@ private void emitRoadFeature(FeatureCollector features, SourceFeature sf, LineSt String kind, String kindDetail, String name, String highway, int minZoom, SegmentProperties props) { - var feat = features.geometry(this.name(), geometry) + // Transform geometry from lat/lon to world coordinates for rendering + LineString worldGeometry = (LineString) GeoUtils.latLonToWorldCoords(geometry); + + var feat = features.geometry(this.name(), worldGeometry) .setId(FeatureId.create(sf)) .setAttr("kind", kind) .setAttr("kind_detail", kindDetail) @@ -680,16 +684,13 @@ private void collectSplitPoints(SourceFeature sf, List splitPoints) { // From road_flags Object roadFlagsObj = sf.getTag("road_flags"); if (roadFlagsObj instanceof List) { - @SuppressWarnings("unchecked") - List roadFlags = (List) roadFlagsObj; + @SuppressWarnings("unchecked") List roadFlags = (List) roadFlagsObj; for (Object flagObj : roadFlags) { if (flagObj instanceof Map) { - @SuppressWarnings("unchecked") - Map flag = (Map) flagObj; + @SuppressWarnings("unchecked") Map flag = (Map) flagObj; Object betweenObj = flag.get("between"); if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") - List between = (List) betweenObj; + @SuppressWarnings("unchecked") List between = (List) betweenObj; if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { splitPoints.add(((Number) between.get(0)).doubleValue()); splitPoints.add(((Number) between.get(1)).doubleValue()); @@ -702,16 +703,13 @@ private void collectSplitPoints(SourceFeature sf, List splitPoints) { // From access_restrictions Object accessRestrictionsObj = sf.getTag("access_restrictions"); if (accessRestrictionsObj instanceof List) { - @SuppressWarnings("unchecked") - List accessRestrictions = (List) accessRestrictionsObj; + @SuppressWarnings("unchecked") List accessRestrictions = (List) accessRestrictionsObj; for (Object restrictionObj : accessRestrictions) { if (restrictionObj instanceof Map) { - @SuppressWarnings("unchecked") - Map restriction = (Map) restrictionObj; + @SuppressWarnings("unchecked") Map restriction = (Map) restrictionObj; Object betweenObj = restriction.get("between"); if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") - List between = (List) betweenObj; + @SuppressWarnings("unchecked") List between = (List) betweenObj; if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { splitPoints.add(((Number) between.get(0)).doubleValue()); splitPoints.add(((Number) between.get(1)).doubleValue()); @@ -724,16 +722,13 @@ private void collectSplitPoints(SourceFeature sf, List splitPoints) { // From level_rules Object levelRulesObj = sf.getTag("level_rules"); if (levelRulesObj instanceof List) { - @SuppressWarnings("unchecked") - List levelRules = (List) levelRulesObj; + @SuppressWarnings("unchecked") List levelRules = (List) levelRulesObj; for (Object ruleObj : levelRules) { if (ruleObj instanceof Map) { - @SuppressWarnings("unchecked") - Map rule = (Map) ruleObj; + @SuppressWarnings("unchecked") Map rule = (Map) ruleObj; Object betweenObj = rule.get("between"); if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") - List between = (List) betweenObj; + @SuppressWarnings("unchecked") List between = (List) betweenObj; if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { splitPoints.add(((Number) between.get(0)).doubleValue()); splitPoints.add(((Number) between.get(1)).doubleValue()); @@ -753,12 +748,10 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star // Check road_flags for is_bridge, is_tunnel, is_link Object roadFlagsObj = sf.getTag("road_flags"); if (roadFlagsObj instanceof List) { - @SuppressWarnings("unchecked") - List roadFlags = (List) roadFlagsObj; + @SuppressWarnings("unchecked") List roadFlags = (List) roadFlagsObj; for (Object flagObj : roadFlags) { if (flagObj instanceof Map) { - @SuppressWarnings("unchecked") - Map flag = (Map) flagObj; + @SuppressWarnings("unchecked") Map flag = (Map) flagObj; Object valuesObj = flag.get("values"); Object betweenObj = flag.get("between"); @@ -767,8 +760,7 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star double rangeStart = 0.0; double rangeEnd = 1.0; if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") - List between = (List) betweenObj; + @SuppressWarnings("unchecked") List between = (List) betweenObj; if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { rangeStart = ((Number) between.get(0)).doubleValue(); rangeEnd = ((Number) between.get(1)).doubleValue(); @@ -777,8 +769,7 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star // Check if this segment overlaps with the flag's range if (Linear.overlaps(start, end, rangeStart, rangeEnd) && valuesObj instanceof List) { - @SuppressWarnings("unchecked") - List values = (List) valuesObj; + @SuppressWarnings("unchecked") List values = (List) valuesObj; if (values.contains("is_bridge")) { props.isBridge = true; } @@ -796,12 +787,10 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star // Check access_restrictions for oneway Object accessRestrictionsObj = sf.getTag("access_restrictions"); if (accessRestrictionsObj instanceof List) { - @SuppressWarnings("unchecked") - List accessRestrictions = (List) accessRestrictionsObj; + @SuppressWarnings("unchecked") List accessRestrictions = (List) accessRestrictionsObj; for (Object restrictionObj : accessRestrictions) { if (restrictionObj instanceof Map) { - @SuppressWarnings("unchecked") - Map restriction = (Map) restrictionObj; + @SuppressWarnings("unchecked") Map restriction = (Map) restrictionObj; String accessType = (String) restriction.get("access_type"); if (!"denied".equals(accessType)) { @@ -810,8 +799,7 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star Object whenObj = restriction.get("when"); if (whenObj instanceof Map) { - @SuppressWarnings("unchecked") - Map when = (Map) whenObj; + @SuppressWarnings("unchecked") Map when = (Map) whenObj; String heading = (String) when.get("heading"); if ("backward".equals(heading)) { @@ -820,8 +808,7 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star double rangeEnd = 1.0; Object betweenObj = restriction.get("between"); if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") - List between = (List) betweenObj; + @SuppressWarnings("unchecked") List between = (List) betweenObj; if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { rangeStart = ((Number) between.get(0)).doubleValue(); rangeEnd = ((Number) between.get(1)).doubleValue(); @@ -840,12 +827,10 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star // Check level_rules Object levelRulesObj = sf.getTag("level_rules"); if (levelRulesObj instanceof List) { - @SuppressWarnings("unchecked") - List levelRules = (List) levelRulesObj; + @SuppressWarnings("unchecked") List levelRules = (List) levelRulesObj; for (Object ruleObj : levelRules) { if (ruleObj instanceof Map) { - @SuppressWarnings("unchecked") - Map rule = (Map) ruleObj; + @SuppressWarnings("unchecked") Map rule = (Map) ruleObj; Object valueObj = rule.get("value"); if (valueObj instanceof Number) { @@ -856,8 +841,7 @@ private SegmentProperties extractSegmentProperties(SourceFeature sf, double star double rangeEnd = 1.0; Object betweenObj = rule.get("between"); if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") - List between = (List) betweenObj; + @SuppressWarnings("unchecked") List between = (List) betweenObj; if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { rangeStart = ((Number) between.get(0)).doubleValue(); rangeEnd = ((Number) between.get(1)).doubleValue(); diff --git a/tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java b/tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java new file mode 100644 index 00000000..2b1cbc01 --- /dev/null +++ b/tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java @@ -0,0 +1,229 @@ +package com.protomaps.basemap.geometry; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +class LinearTest { + + private static final GeometryFactory gf = new GeometryFactory(); + private static final double EPSILON = 0.0001; + + private LineString createLine(double... coords) { + if (coords.length % 2 != 0) { + throw new IllegalArgumentException("Coordinates must be pairs of x,y values"); + } + Coordinate[] coordArray = new Coordinate[coords.length / 2]; + for (int i = 0; i < coords.length / 2; i++) { + coordArray[i] = new Coordinate(coords[i * 2], coords[i * 2 + 1]); + } + return gf.createLineString(coordArray); + } + + @Test + void testSplitAtFractions_noSplitPoints_returnOriginal() { + LineString line = createLine(0, 0, 1, 0); + List result = Linear.splitAtFractions(line, List.of()); + + assertEquals(1, result.size()); + assertEquals(line, result.get(0)); + } + + @Test + void testSplitAtFractions_simpleLine_midpoint() { + // Simple straight line from (0,0) to (1,0), split at 0.5 + LineString line = createLine(0, 0, 1, 0); + List result = Linear.splitAtFractions(line, List.of(0.5)); + + assertEquals(2, result.size()); + + // First segment: (0,0) to (0.5,0) + assertEquals(2, result.get(0).getNumPoints()); + assertEquals(0.0, result.get(0).getCoordinateN(0).x, EPSILON); + assertEquals(0.0, result.get(0).getCoordinateN(0).y, EPSILON); + assertEquals(0.5, result.get(0).getCoordinateN(1).x, EPSILON); + assertEquals(0.0, result.get(0).getCoordinateN(1).y, EPSILON); + + // Second segment: (0.5,0) to (1,0) + assertEquals(2, result.get(1).getNumPoints()); + assertEquals(0.5, result.get(1).getCoordinateN(0).x, EPSILON); + assertEquals(0.0, result.get(1).getCoordinateN(0).y, EPSILON); + assertEquals(1.0, result.get(1).getCoordinateN(1).x, EPSILON); + assertEquals(0.0, result.get(1).getCoordinateN(1).y, EPSILON); + } + + @Test + void testSplitAtFractions_curvedLine_preservesVertices() { + // Curved line with 5 points forming a curve + LineString line = createLine( + 0, 0, // start + 0.25, 0.5, // curve up + 0.5, 0.5, // middle top + 0.75, 0.5, // continue curve + 1, 0 // end back down + ); + + // Split at 0.4 (before middle) and 0.6 (after middle) + List result = Linear.splitAtFractions(line, List.of(0.4, 0.6)); + + assertEquals(3, result.size()); + + // First segment should have at least 3 points (start, first curve point, and split point) + assertTrue(result.get(0).getNumPoints() >= 2, + "First segment should preserve vertices, got " + result.get(0).getNumPoints() + " points"); + + // Middle segment should include the middle vertices + assertTrue(result.get(1).getNumPoints() >= 2, + "Middle segment should preserve vertices, got " + result.get(1).getNumPoints() + " points"); + + // Last segment should have at least 3 points + assertTrue(result.get(2).getNumPoints() >= 2, + "Last segment should preserve vertices, got " + result.get(2).getNumPoints() + " points"); + + // Verify start and end points + assertEquals(0.0, result.get(0).getCoordinateN(0).x, EPSILON); + assertEquals(1.0, result.get(2).getCoordinateN(result.get(2).getNumPoints() - 1).x, EPSILON); + } + + @Test + void testSplitAtFractions_splitBetweenVertices() { + // Line with 3 points, split in the middle of second segment + LineString line = createLine( + 0, 0, // Point 0 + 0.5, 1, // Point 1 (at halfway distance) + 1, 0 // Point 2 + ); + + // Split at 0.75 (should be in the second segment between points 1 and 2) + List result = Linear.splitAtFractions(line, List.of(0.75)); + + assertEquals(2, result.size()); + + // First segment: should include points 0, 1, and the split point + assertTrue(result.get(0).getNumPoints() >= 3, + "First segment should have at least 3 points (start, middle vertex, split), got " + + result.get(0).getNumPoints()); + + // Verify that point 1 (0.5, 1) is preserved in first segment + boolean hasMiddlePoint = false; + for (int i = 0; i < result.get(0).getNumPoints(); i++) { + Coordinate c = result.get(0).getCoordinateN(i); + if (Math.abs(c.x - 0.5) < EPSILON && Math.abs(c.y - 1.0) < EPSILON) { + hasMiddlePoint = true; + break; + } + } + assertTrue(hasMiddlePoint, "First segment should preserve the middle vertex (0.5, 1)"); + + // Second segment: should include split point and point 2 + assertTrue(result.get(1).getNumPoints() >= 2, + "Second segment should have at least 2 points"); + } + + @Test + void testSplitAtFractions_complexCurve() { + // More complex curved line with 7 points + LineString line = createLine( + 0, 0, + 0.1, 0.2, + 0.3, 0.4, + 0.5, 0.5, + 0.7, 0.4, + 0.9, 0.2, + 1, 0 + ); + + // Split at quarter and three-quarter points + List result = Linear.splitAtFractions(line, List.of(0.25, 0.75)); + + assertEquals(3, result.size()); + + // Each segment should preserve multiple vertices + int totalPointsInSegments = 0; + for (LineString segment : result) { + totalPointsInSegments += segment.getNumPoints(); + } + + // Should have significantly more points than just 2 per segment (6 total) + // We expect at least the original 7 points plus 2 split points = 9 minimum + assertTrue(totalPointsInSegments >= 9, + "Split segments should preserve vertices, expected at least 9 points, got " + totalPointsInSegments); + } + + @Test + void testSplitAtFractions_splitAtExistingVertex() { + // Line with 3 equally-spaced points + LineString line = createLine(0, 0, 0.5, 0, 1, 0); + + // Split at 0.5, which is exactly at the middle vertex + List result = Linear.splitAtFractions(line, List.of(0.5)); + + assertEquals(2, result.size()); + + // First segment should include first two points + assertEquals(2, result.get(0).getNumPoints()); + assertEquals(0.0, result.get(0).getCoordinateN(0).x, EPSILON); + assertEquals(0.5, result.get(0).getCoordinateN(1).x, EPSILON); + + // Second segment should include middle and last point + assertEquals(2, result.get(1).getNumPoints()); + assertEquals(0.5, result.get(1).getCoordinateN(0).x, EPSILON); + assertEquals(1.0, result.get(1).getCoordinateN(1).x, EPSILON); + } + + @Test + void testSplitAtFractions_multipleSplits() { + // Line with multiple vertices + LineString line = createLine(0, 0, 0.2, 0, 0.4, 0, 0.6, 0, 0.8, 0, 1, 0); + + // Multiple split points + List result = Linear.splitAtFractions(line, List.of(0.1, 0.3, 0.5, 0.7, 0.9)); + + assertEquals(6, result.size(), "Should create 6 segments from 5 split points"); + + // Verify continuity - end of each segment should match start of next + for (int i = 0; i < result.size() - 1; i++) { + LineString current = result.get(i); + LineString next = result.get(i + 1); + + Coordinate currentEnd = current.getCoordinateN(current.getNumPoints() - 1); + Coordinate nextStart = next.getCoordinateN(0); + + assertEquals(currentEnd.x, nextStart.x, EPSILON, + "Segment " + i + " end should match segment " + (i + 1) + " start (x)"); + assertEquals(currentEnd.y, nextStart.y, EPSILON, + "Segment " + i + " end should match segment " + (i + 1) + " start (y)"); + } + } + + @Test + void testOverlaps() { + // Test various overlap scenarios + assertTrue(Linear.overlaps(0.0, 0.5, 0.25, 0.75)); // Partial overlap + assertTrue(Linear.overlaps(0.25, 0.75, 0.0, 0.5)); // Reverse partial overlap + assertTrue(Linear.overlaps(0.0, 1.0, 0.25, 0.75)); // Contains + assertTrue(Linear.overlaps(0.25, 0.75, 0.0, 1.0)); // Contained by + assertTrue(Linear.overlaps(0.2, 0.6, 0.4, 0.8)); // Partial overlap + + assertFalse(Linear.overlaps(0.0, 0.3, 0.5, 0.8)); // No overlap (gap) + assertFalse(Linear.overlaps(0.5, 0.8, 0.0, 0.3)); // No overlap (gap reversed) + assertFalse(Linear.overlaps(0.0, 0.5, 0.5, 1.0)); // Adjacent but not overlapping + } + + @Test + void testCreateSegments() { + List segments = Linear.createSegments(List.of(0.25, 0.75)); + + assertEquals(3, segments.size()); + assertEquals(0.0, segments.get(0).start, EPSILON); + assertEquals(0.25, segments.get(0).end, EPSILON); + assertEquals(0.25, segments.get(1).start, EPSILON); + assertEquals(0.75, segments.get(1).end, EPSILON); + assertEquals(0.75, segments.get(2).start, EPSILON); + assertEquals(1.0, segments.get(2).end, EPSILON); + } +} From 7e469de889fca3291e340fe276236b458b159bd2 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 13:17:48 -0800 Subject: [PATCH 60/76] Reimplemented Linear.java using existing JTS linear referencing support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../protomaps/basemap/geometry/Linear.java | 163 ++---------------- 1 file changed, 10 insertions(+), 153 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java index 4d753705..276b5ab4 100644 --- a/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java +++ b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java @@ -1,17 +1,15 @@ package com.protomaps.basemap.geometry; import java.util.*; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.linearref.LengthIndexedLine; /** * Utility class for linear geometry operations, particularly splitting LineStrings at fractional positions. */ public class Linear { - private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); - /** * Represents a segment of a line with fractional start/end positions. */ @@ -47,130 +45,27 @@ public static List splitAtFractions(LineString line, List sp List points = new ArrayList<>(pointSet); List segments = new ArrayList<>(); - // Calculate total length and cumulative distances at each vertex - double[] cumulativeDistances = new double[line.getNumPoints()]; - cumulativeDistances[0] = 0.0; - double totalLength = 0.0; - - for (int i = 0; i < line.getNumPoints() - 1; i++) { - Coordinate c1 = line.getCoordinateN(i); - Coordinate c2 = line.getCoordinateN(i + 1); - totalLength += c1.distance(c2); - cumulativeDistances[i + 1] = totalLength; - } + // Use JTS LengthIndexedLine for efficient extraction + double totalLength = line.getLength(); + LengthIndexedLine indexedLine = new LengthIndexedLine(line); // For each pair of split points, create a segment preserving intermediate vertices for (int i = 0; i < points.size() - 1; i++) { double startFrac = points.get(i); double endFrac = points.get(i + 1); - List segmentCoords = extractSegmentCoordinates( - line, startFrac, endFrac, totalLength, cumulativeDistances); + double startLength = startFrac * totalLength; + double endLength = endFrac * totalLength; - if (segmentCoords.size() >= 2) { - LineString segment = GEOMETRY_FACTORY.createLineString(segmentCoords.toArray(new Coordinate[0])); - segments.add(segment); + Geometry segment = indexedLine.extractLine(startLength, endLength); + if (segment instanceof LineString ls && ls.getNumPoints() >= 2) { + segments.add(ls); } } return segments; } - /** - * Extract all coordinates between startFrac and endFrac, preserving intermediate vertices. - * - * @param line The source LineString - * @param startFrac Start fraction (0.0-1.0) - * @param endFrac End fraction (0.0-1.0) - * @param totalLength Total length of the line - * @param cumulativeDistances Cumulative distances at each vertex - * @return List of coordinates for the segment - */ - private static List extractSegmentCoordinates( - LineString line, double startFrac, double endFrac, - double totalLength, double[] cumulativeDistances) { - - List coords = new ArrayList<>(); - double startDist = startFrac * totalLength; - double endDist = endFrac * totalLength; - - // Find the segment containing the start position - int startSegmentIdx = -1; - for (int i = 0; i < line.getNumPoints() - 1; i++) { - if (cumulativeDistances[i] <= startDist && startDist <= cumulativeDistances[i + 1]) { - startSegmentIdx = i; - break; - } - } - - // Find the segment containing the end position - int endSegmentIdx = -1; - for (int i = 0; i < line.getNumPoints() - 1; i++) { - if (cumulativeDistances[i] <= endDist && endDist <= cumulativeDistances[i + 1]) { - endSegmentIdx = i; - break; - } - } - - if (startSegmentIdx == -1 || endSegmentIdx == -1) { - // Fallback to simple 2-point line - Coordinate start = getCoordinateAtFraction(line, startFrac, totalLength); - Coordinate end = getCoordinateAtFraction(line, endFrac, totalLength); - if (start != null && end != null) { - coords.add(start); - coords.add(end); - } - return coords; - } - - // Add the start coordinate (interpolated if not at a vertex) - if (Math.abs(cumulativeDistances[startSegmentIdx] - startDist) < 1e-10) { - // Start is at a vertex - coords.add(line.getCoordinateN(startSegmentIdx)); - } else { - // Interpolate within the start segment - Coordinate c1 = line.getCoordinateN(startSegmentIdx); - Coordinate c2 = line.getCoordinateN(startSegmentIdx + 1); - double segmentLength = c1.distance(c2); - double distIntoSegment = startDist - cumulativeDistances[startSegmentIdx]; - double segmentFraction = distIntoSegment / segmentLength; - double x = c1.x + (c2.x - c1.x) * segmentFraction; - double y = c1.y + (c2.y - c1.y) * segmentFraction; - coords.add(new Coordinate(x, y)); - } - - // Add all intermediate vertices between start and end segments - for (int i = startSegmentIdx + 1; i <= endSegmentIdx; i++) { - // Don't duplicate if this vertex is exactly at startDist - if (Math.abs(cumulativeDistances[i] - startDist) > 1e-10) { - coords.add(line.getCoordinateN(i)); - } - } - - // Add the end coordinate (interpolated if not at a vertex) - if (Math.abs(cumulativeDistances[endSegmentIdx + 1] - endDist) < 1e-10) { - // End is exactly at a vertex - // Only add if not already added (could be same as last intermediate vertex) - Coordinate lastCoord = coords.isEmpty() ? null : coords.get(coords.size() - 1); - Coordinate endVertex = line.getCoordinateN(endSegmentIdx + 1); - if (lastCoord == null || !lastCoord.equals2D(endVertex)) { - coords.add(endVertex); - } - } else { - // Interpolate within the end segment - Coordinate c1 = line.getCoordinateN(endSegmentIdx); - Coordinate c2 = line.getCoordinateN(endSegmentIdx + 1); - double segmentLength = c1.distance(c2); - double distIntoSegment = endDist - cumulativeDistances[endSegmentIdx]; - double segmentFraction = distIntoSegment / segmentLength; - double x = c1.x + (c2.x - c1.x) * segmentFraction; - double y = c1.y + (c2.y - c1.y) * segmentFraction; - coords.add(new Coordinate(x, y)); - } - - return coords; - } - /** * Create list of Segments representing the split ranges between all split points. * @@ -198,44 +93,6 @@ public static List createSegments(List splitPoints) { return segments; } - /** - * Get coordinate at fractional position along line. - * - * @param line The LineString - * @param fraction Fractional position (0.0-1.0) - * @param totalLength Pre-calculated total length of the line - * @return Coordinate at the fractional position - */ - private static Coordinate getCoordinateAtFraction(LineString line, double fraction, double totalLength) { - if (fraction <= 0.0) { - return line.getCoordinateN(0); - } - if (fraction >= 1.0) { - return line.getCoordinateN(line.getNumPoints() - 1); - } - - double targetDist = fraction * totalLength; - double currentDist = 0.0; - - for (int i = 0; i < line.getNumPoints() - 1; i++) { - Coordinate c1 = line.getCoordinateN(i); - Coordinate c2 = line.getCoordinateN(i + 1); - double segmentLength = c1.distance(c2); - - if (currentDist + segmentLength >= targetDist) { - // Interpolate within this segment - double segmentFraction = (targetDist - currentDist) / segmentLength; - double x = c1.x + (c2.x - c1.x) * segmentFraction; - double y = c1.y + (c2.y - c1.y) * segmentFraction; - return new Coordinate(x, y); - } - - currentDist += segmentLength; - } - - return line.getCoordinateN(line.getNumPoints() - 1); - } - /** * Check if a segment defined by [segStart, segEnd] overlaps with a range [rangeStart, rangeEnd]. * From 7da30b0a258190f0f49ae99b3bf12b35cda31af2 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 13:46:32 -0800 Subject: [PATCH 61/76] Fixed split geometry tests to correctly handle world coordinates --- .../protomaps/basemap/layers/RoadsTest.java | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index f3d31936..150f7c3f 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -765,17 +765,17 @@ void kind_sidewalk_fromFootwayClass() { ))); } - // ===== Line Splitting Tests ===== // Tests for partial application of properties (bridge, tunnel, oneway, level) requiring line splitting @Test void split_partialBridge_middleSection() { // Test: Single bridge section in the middle of a line - // Geometry: (0,0) to (1,0) - length=1 for easy math + // Geometry: (0,0) to (144,0) - Input treated as lat/lon, output transformed to world coords // Bridge from 0.25 to 0.75 // Expected: 3 output features with correct geometries and is_bridge attribute + // After transformation: lon 0->0.5, lon 0.25->0.5006944, lon 0.75->0.5020833, lon 1->0.5027777 var results = process(SimpleFeature.create( - newLineString(0, 0, 1, 0), + newLineString(0, 0, 144, 0), new HashMap<>(Map.of( "id", "test-bridge-middle", "theme", "transportation", @@ -793,19 +793,19 @@ void split_partialBridge_middleSection() { Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.25, 0)) + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) // No is_bridge attribute ), Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", new TestUtils.NormGeometry(newLineString(0.25, 0, 0.75, 0)), + "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.8, 0.5)), "is_bridge", true ), Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", new TestUtils.NormGeometry(newLineString(0.75, 0, 1, 0)) + "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)) // No is_bridge attribute ) ), results); @@ -814,13 +814,13 @@ void split_partialBridge_middleSection() { @Test void split_partialBridge_twoSections() { // Test: Two separate bridge sections - // Geometry: (0,0) to (1,0) - // Bridges from 0.1-0.3 and 0.6-0.8 + // Geometry: (0,0) to (180,0) - Input treated as lat/lon, output transformed to world coords + // Bridges from 0.2-0.4 and 0.6-0.8 // Expected: 5 output features // Based on Overture c3b55f85-220c-4d00-8419-be3f2c795729 (footway with 2 bridge sections) // OSM ways: 999763975, 999763974, 22989089, 999763972, 999763973 var results = process(SimpleFeature.create( - newLineString(0, 0, 1, 0), + newLineString(0, 0, 180, 0), new HashMap<>(Map.of( "id", "c3b55f85-220c-4d00-8419-be3f2c795729", "theme", "transportation", @@ -828,7 +828,7 @@ void split_partialBridge_twoSections() { "subtype", "road", "class", "footway", "road_flags", List.of( - Map.of("values", List.of("is_bridge"), "between", List.of(0.1, 0.3)), + Map.of("values", List.of("is_bridge"), "between", List.of(0.2, 0.4)), Map.of("values", List.of("is_bridge"), "between", List.of(0.6, 0.8)) ) )), @@ -836,24 +836,24 @@ void split_partialBridge_twoSections() { )); assertFeatures(15, List.of( - Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.1, 0))), - Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.1, 0, 0.3, 0)), "is_bridge", true), - Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.3, 0, 0.6, 0))), - Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0, 0.8, 0)), "is_bridge", true), - Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0, 1, 0))) + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5))), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), "is_bridge", true), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.7, 0.5, 0.8, 0.5))), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)), "is_bridge", true), + Map.of("kind", "path", "_geom", new TestUtils.NormGeometry(newLineString(0.9, 0.5, 1.0, 0.5))) ), results); } @Test void split_partialTunnel_fromStart() { // Test: Tunnel from start to middle - // Geometry: (0,0) to (1,0) + // Geometry: (0,0) to (72,0) - Input treated as lat/lon, output transformed to world coords // Tunnel from 0.0 to 0.5 // Expected: 2 output features // Based on Overture 6c52a051-7433-470a-aa89-935be681c967 (primary with tunnel) // OSM ways: 659613394, 25966237 var results = process(SimpleFeature.create( - newLineString(0, 0, 1, 0), + newLineString(0, 0, 72, 0), new HashMap<>(Map.of( "id", "6c52a051-7433-470a-aa89-935be681c967", "theme", "transportation", @@ -871,13 +871,13 @@ void split_partialTunnel_fromStart() { Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.5, 0)), + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)), "is_tunnel", true ), Map.of( "kind", "major_road", "kind_detail", "primary", - "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0, 1, 0)) + "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)) // No is_tunnel attribute ) ), results); @@ -886,13 +886,13 @@ void split_partialTunnel_fromStart() { @Test void split_partialLevel_elevatedSection() { // Test: Elevated/bridge section with level=1 - // Geometry: (0,0) to (1,0) + // Geometry: (0,0) to (144,0) - Input treated as lat/lon, output transformed to world coords // Level 1 from 0.25 to 0.75 // Expected: 3 output features with different level values // Based on Overture 8d70a823-6584-459d-999d-cabf3b9672f6 (motorway with elevated section) // OSM ways: 41168616, 931029707, 41168617 var results = process(SimpleFeature.create( - newLineString(0, 0, 1, 0), + newLineString(0, 0, 144, 0), new HashMap<>(Map.of( "id", "8d70a823-6584-459d-999d-cabf3b9672f6", "theme", "transportation", @@ -910,19 +910,19 @@ void split_partialLevel_elevatedSection() { Map.of( "kind", "highway", "kind_detail", "motorway", - "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.25, 0)) + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) // level=0 or no level attribute ), Map.of( "kind", "highway", "kind_detail", "motorway", - "_geom", new TestUtils.NormGeometry(newLineString(0.25, 0, 0.75, 0)), + "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.8, 0.5)), "level", 1 ), Map.of( "kind", "highway", "kind_detail", "motorway", - "_geom", new TestUtils.NormGeometry(newLineString(0.75, 0, 1, 0)) + "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)) // level=0 or no level attribute ) ), results); @@ -931,13 +931,13 @@ void split_partialLevel_elevatedSection() { @Test void split_partialOneway_secondHalf() { // Test: Oneway restriction on second half of line - // Geometry: (0,0) to (1,0) + // Geometry: (0,0) to (72,0) - Input treated as lat/lon, output transformed to world coords // Oneway (access denied backward) from 0.5 to 1.0 // Expected: 2 output features // Based on Overture 10536347-2a89-4f05-9a3d-92d365931bc4 (secondary with partial oneway) // OSM ways: 394110740, 59689569 var results = process(SimpleFeature.create( - newLineString(0, 0, 1, 0), + newLineString(0, 0, 72, 0), new HashMap<>(Map.of( "id", "10536347-2a89-4f05-9a3d-92d365931bc4", "theme", "transportation", @@ -959,13 +959,13 @@ void split_partialOneway_secondHalf() { Map.of( "kind", "major_road", "kind_detail", "secondary", - "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.5, 0)) + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) // No oneway attribute ), Map.of( "kind", "major_road", "kind_detail", "secondary", - "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0, 1, 0)), + "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), "oneway", true ) ), results); @@ -974,12 +974,12 @@ void split_partialOneway_secondHalf() { @Test void split_overlapping_bridgeAndOneway() { // Test: Overlapping bridge and oneway restrictions - // Geometry: (0,0) to (1,0) + // Geometry: (0,0) to (144,0) - Input treated as lat/lon, output transformed to world coords // Bridge from 0.25 to 0.75 // Oneway from 0.5 to 1.0 // Expected: 4 output features with different combinations var results = process(SimpleFeature.create( - newLineString(0, 0, 1, 0), + newLineString(0, 0, 144, 0), new HashMap<>(Map.of( "id", "test-overlap", "theme", "transportation", @@ -1003,25 +1003,25 @@ void split_overlapping_bridgeAndOneway() { assertFeatures(15, List.of( Map.of( "kind", "major_road", - "_geom", new TestUtils.NormGeometry(newLineString(0, 0, 0.25, 0)) + "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) // Neither is_bridge nor oneway ), Map.of( "kind", "major_road", - "_geom", new TestUtils.NormGeometry(newLineString(0.25, 0, 0.5, 0)), + "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), "is_bridge", true // is_bridge only, no oneway ), Map.of( "kind", "major_road", - "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0, 0.75, 0)), + "_geom", new TestUtils.NormGeometry(newLineString(0.7, 0.5, 0.8, 0.5)), "is_bridge", true, "oneway", true // Both is_bridge AND oneway ), Map.of( "kind", "major_road", - "_geom", new TestUtils.NormGeometry(newLineString(0.75, 0, 1, 0)), + "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)), "oneway", true // oneway only, no is_bridge ) From 385025fd8ca64ddf52733fbca27d5af957fc4a94 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 13:55:53 -0800 Subject: [PATCH 62/76] Corrected oneway=yes to get arrows showing up --- .../com/protomaps/basemap/layers/Roads.java | 2 +- .../protomaps/basemap/layers/RoadsTest.java | 24 +++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index e601bc7b..88b7cde9 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -657,7 +657,7 @@ private void emitRoadFeature(FeatureCollector features, SourceFeature sf, LineSt .setZoomRange(Math.min(minZoom, 15), 15); if (props.isOneway) { - feat.setAttrWithMinzoom("oneway", true, 14); + feat.setAttrWithMinzoom("oneway", "yes", 14); } if (props.isLink) { diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index 150f7c3f..f0b6bb8b 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -475,7 +475,7 @@ class RoadsOvertureTest extends LayerTest { @Test void kind_highway_fromMotorwayClass() { assertFeatures(15, - List.of(Map.of("kind", "highway", "min_zoom", 4, "oneway", true, "name", "Nimitz Freeway")), + List.of(Map.of("kind", "highway", "min_zoom", 4, "oneway", "yes", "name", "Nimitz Freeway")), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( @@ -496,7 +496,7 @@ void kind_highway_fromMotorwayClass() { @Test void kind_highwayLink_fromMotorwayClass() { assertFeatures(15, - List.of(Map.of("kind", "highway", "min_zoom", 4, "oneway", true, "is_link", true)), + List.of(Map.of("kind", "highway", "min_zoom", 4, "oneway", "yes", "is_link", true)), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( @@ -536,7 +536,7 @@ void kind_majorRoad_fromTrunkClass() { @Test void kind_majorLink_fromTrunkClass() { assertFeatures(15, - List.of(Map.of("kind", "major_road", "min_zoom", 7, "oneway", true, "is_link", true)), + List.of(Map.of("kind", "major_road", "min_zoom", 7, "oneway", "yes", "is_link", true)), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( @@ -576,7 +576,7 @@ void kind_majorRoad_fromPrimaryClass() { @Test void kind_majorLink_fromPrimaryClass() { assertFeatures(15, - List.of(Map.of("kind", "major_road", "min_zoom", 8, "oneway", true, "is_link", true)), + List.of(Map.of("kind", "major_road", "min_zoom", 8, "oneway", "yes", "is_link", true)), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( @@ -616,7 +616,7 @@ void kind_majorRoad_fromSecondaryClass() { @Test void kind_majorLink_fromSecondaryClass() { assertFeatures(15, - List.of(Map.of("kind", "major_road", "min_zoom", 10, "oneway", true, "is_link", true)), + List.of(Map.of("kind", "major_road", "min_zoom", 10, "oneway", "yes", "is_link", true)), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( @@ -656,7 +656,7 @@ void kind_majorRoad_fromTertiaryClass() { @Test void kind_majorLink_fromTertiaryClass() { assertFeatures(15, - List.of(Map.of("kind", "major_road", "min_zoom", 10, "oneway", true, "is_link", true)), + List.of(Map.of("kind", "major_road", "min_zoom", 10, "oneway", "yes", "is_link", true)), process(SimpleFeature.create( newLineString(0, 0, 1, 1), new HashMap<>(Map.of( @@ -878,7 +878,6 @@ void split_partialTunnel_fromStart() { "kind", "major_road", "kind_detail", "primary", "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)) - // No is_tunnel attribute ) ), results); } @@ -911,7 +910,6 @@ void split_partialLevel_elevatedSection() { "kind", "highway", "kind_detail", "motorway", "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) - // level=0 or no level attribute ), Map.of( "kind", "highway", @@ -923,7 +921,6 @@ void split_partialLevel_elevatedSection() { "kind", "highway", "kind_detail", "motorway", "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)) - // level=0 or no level attribute ) ), results); } @@ -960,13 +957,12 @@ void split_partialOneway_secondHalf() { "kind", "major_road", "kind_detail", "secondary", "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) - // No oneway attribute ), Map.of( "kind", "major_road", "kind_detail", "secondary", "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), - "oneway", true + "oneway", "yes" ) ), results); } @@ -1016,14 +1012,12 @@ void split_overlapping_bridgeAndOneway() { "kind", "major_road", "_geom", new TestUtils.NormGeometry(newLineString(0.7, 0.5, 0.8, 0.5)), "is_bridge", true, - "oneway", true - // Both is_bridge AND oneway + "oneway", "yes" ), Map.of( "kind", "major_road", "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)), - "oneway", true - // oneway only, no is_bridge + "oneway", "yes" ) ), results); } From 5de599bb6af0898981d785ea9f90a73e4f69a31e Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 14:14:11 -0800 Subject: [PATCH 63/76] Minor cleanup --- .../protomaps/basemap/geometry/Linear.java | 4 +-- .../com/protomaps/basemap/layers/Roads.java | 28 +++++++++-------- .../basemap/geometry/LinearTest.java | 30 +++++++++---------- .../protomaps/basemap/layers/RoadsTest.java | 4 --- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java index 276b5ab4..1d6ea7b1 100644 --- a/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java +++ b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java @@ -24,8 +24,8 @@ public Segment(double start, double end) { } /** - * Split a LineString at fractional positions and return list of split LineStrings. - * Preserves all intermediate vertices between split points to maintain curve geometry. + * Split a LineString at fractional positions and return list of split LineStrings. Preserves all intermediate + * vertices between split points to maintain curve geometry. * * @param line The LineString to split * @param splitPoints List of fractional positions (0.0-1.0) where to split diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 88b7cde9..dfba95e2 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -13,8 +13,8 @@ import com.onthegomap.planetiler.ForwardingProfile; import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.expression.MultiExpression; -import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.reader.SourceFeature; import com.onthegomap.planetiler.reader.osm.OsmElement; import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; @@ -340,6 +340,8 @@ public Roads(CountryCoder countryCoder) { )).index(); + // Protomaps kind/kind_detail to min_zoom mapping + private static final MultiExpression.Index> highwayZoomsIndex = MultiExpression.ofOrdered(List.of( rule(use(MINZOOM, 99)), @@ -545,14 +547,14 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { /** * Represents properties that can apply to a segment of a road */ - private static class SegmentProperties { + private static class OvertureSegmentProperties { boolean isBridge; boolean isTunnel; boolean isOneway; boolean isLink; Integer level; - SegmentProperties() { + OvertureSegmentProperties() { this.isBridge = false; this.isTunnel = false; this.isOneway = false; @@ -604,7 +606,7 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { // Collect all split points from all property arrays List splitPoints = new ArrayList<>(); - collectSplitPoints(sf, splitPoints); + collectOvertureSplitPoints(sf, splitPoints); // Get the original geometry - use latLonGeometry for consistency with test infrastructure try { @@ -612,8 +614,8 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { // If no split points, process as single feature if (splitPoints.isEmpty()) { - emitRoadFeature(features, sf, originalLine, kind, kindDetail, name, highway, minZoom, - extractSegmentProperties(sf, 0.0, 1.0)); + emitOvertureFeature(features, sf, originalLine, kind, kindDetail, name, highway, minZoom, + extractOvertureSegmentProperties(sf, 0.0, 1.0)); return; } @@ -624,9 +626,9 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { for (int i = 0; i < segments.size() && i < splitGeometries.size(); i++) { Linear.Segment seg = segments.get(i); LineString segmentGeom = splitGeometries.get(i); - SegmentProperties props = extractSegmentProperties(sf, seg.start, seg.end); + OvertureSegmentProperties props = extractOvertureSegmentProperties(sf, seg.start, seg.end); - emitRoadFeature(features, sf, segmentGeom, kind, kindDetail, name, highway, minZoom, props); + emitOvertureFeature(features, sf, segmentGeom, kind, kindDetail, name, highway, minZoom, props); } } catch (GeometryException e) { @@ -637,9 +639,9 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { /** * Emit a road feature with given geometry and properties */ - private void emitRoadFeature(FeatureCollector features, SourceFeature sf, LineString geometry, + private void emitOvertureFeature(FeatureCollector features, SourceFeature sf, LineString geometry, String kind, String kindDetail, String name, String highway, int minZoom, - SegmentProperties props) { + OvertureSegmentProperties props) { // Transform geometry from lat/lon to world coordinates for rendering LineString worldGeometry = (LineString) GeoUtils.latLonToWorldCoords(geometry); @@ -680,7 +682,7 @@ private void emitRoadFeature(FeatureCollector features, SourceFeature sf, LineSt /** * Collect all split points from road_flags, access_restrictions, and level_rules */ - private void collectSplitPoints(SourceFeature sf, List splitPoints) { + private void collectOvertureSplitPoints(SourceFeature sf, List splitPoints) { // From road_flags Object roadFlagsObj = sf.getTag("road_flags"); if (roadFlagsObj instanceof List) { @@ -742,8 +744,8 @@ private void collectSplitPoints(SourceFeature sf, List splitPoints) { /** * Extract properties that apply to a segment defined by [start, end] fractional positions */ - private SegmentProperties extractSegmentProperties(SourceFeature sf, double start, double end) { - SegmentProperties props = new SegmentProperties(); + private OvertureSegmentProperties extractOvertureSegmentProperties(SourceFeature sf, double start, double end) { + OvertureSegmentProperties props = new OvertureSegmentProperties(); // Check road_flags for is_bridge, is_tunnel, is_link Object roadFlagsObj = sf.getTag("road_flags"); diff --git a/tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java b/tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java index 2b1cbc01..13a2215c 100644 --- a/tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/geometry/LinearTest.java @@ -60,11 +60,11 @@ void testSplitAtFractions_simpleLine_midpoint() { void testSplitAtFractions_curvedLine_preservesVertices() { // Curved line with 5 points forming a curve LineString line = createLine( - 0, 0, // start + 0, 0, // start 0.25, 0.5, // curve up - 0.5, 0.5, // middle top + 0.5, 0.5, // middle top 0.75, 0.5, // continue curve - 1, 0 // end back down + 1, 0 // end back down ); // Split at 0.4 (before middle) and 0.6 (after middle) @@ -93,9 +93,9 @@ void testSplitAtFractions_curvedLine_preservesVertices() { void testSplitAtFractions_splitBetweenVertices() { // Line with 3 points, split in the middle of second segment LineString line = createLine( - 0, 0, // Point 0 - 0.5, 1, // Point 1 (at halfway distance) - 1, 0 // Point 2 + 0, 0, // Point 0 + 0.5, 1, // Point 1 (at halfway distance) + 1, 0 // Point 2 ); // Split at 0.75 (should be in the second segment between points 1 and 2) @@ -203,15 +203,15 @@ void testSplitAtFractions_multipleSplits() { @Test void testOverlaps() { // Test various overlap scenarios - assertTrue(Linear.overlaps(0.0, 0.5, 0.25, 0.75)); // Partial overlap - assertTrue(Linear.overlaps(0.25, 0.75, 0.0, 0.5)); // Reverse partial overlap - assertTrue(Linear.overlaps(0.0, 1.0, 0.25, 0.75)); // Contains - assertTrue(Linear.overlaps(0.25, 0.75, 0.0, 1.0)); // Contained by - assertTrue(Linear.overlaps(0.2, 0.6, 0.4, 0.8)); // Partial overlap - - assertFalse(Linear.overlaps(0.0, 0.3, 0.5, 0.8)); // No overlap (gap) - assertFalse(Linear.overlaps(0.5, 0.8, 0.0, 0.3)); // No overlap (gap reversed) - assertFalse(Linear.overlaps(0.0, 0.5, 0.5, 1.0)); // Adjacent but not overlapping + assertTrue(Linear.overlaps(0.0, 0.5, 0.25, 0.75)); // Partial overlap + assertTrue(Linear.overlaps(0.25, 0.75, 0.0, 0.5)); // Reverse partial overlap + assertTrue(Linear.overlaps(0.0, 1.0, 0.25, 0.75)); // Contains + assertTrue(Linear.overlaps(0.25, 0.75, 0.0, 1.0)); // Contained by + assertTrue(Linear.overlaps(0.2, 0.6, 0.4, 0.8)); // Partial overlap + + assertFalse(Linear.overlaps(0.0, 0.3, 0.5, 0.8)); // No overlap (gap) + assertFalse(Linear.overlaps(0.5, 0.8, 0.0, 0.3)); // No overlap (gap reversed) + assertFalse(Linear.overlaps(0.0, 0.5, 0.5, 1.0)); // Adjacent but not overlapping } @Test diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index f0b6bb8b..a95fd2f8 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -794,7 +794,6 @@ void split_partialBridge_middleSection() { "kind", "major_road", "kind_detail", "primary", "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) - // No is_bridge attribute ), Map.of( "kind", "major_road", @@ -806,7 +805,6 @@ void split_partialBridge_middleSection() { "kind", "major_road", "kind_detail", "primary", "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)) - // No is_bridge attribute ) ), results); } @@ -1000,13 +998,11 @@ void split_overlapping_bridgeAndOneway() { Map.of( "kind", "major_road", "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) - // Neither is_bridge nor oneway ), Map.of( "kind", "major_road", "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), "is_bridge", true - // is_bridge only, no oneway ), Map.of( "kind", "major_road", From 14ad779590d4d4e9455418b4b604e95e207556f6 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 14:53:15 -0800 Subject: [PATCH 64/76] ... --- tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index a95fd2f8..611f2c1f 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -773,7 +773,6 @@ void split_partialBridge_middleSection() { // Geometry: (0,0) to (144,0) - Input treated as lat/lon, output transformed to world coords // Bridge from 0.25 to 0.75 // Expected: 3 output features with correct geometries and is_bridge attribute - // After transformation: lon 0->0.5, lon 0.25->0.5006944, lon 0.75->0.5020833, lon 1->0.5027777 var results = process(SimpleFeature.create( newLineString(0, 0, 144, 0), new HashMap<>(Map.of( From e67bd22c09fc8d755c6fc280951ebe1874e73f12 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 16:20:19 -0800 Subject: [PATCH 65/76] Shortened some superlong function bodies --- .../com/protomaps/basemap/layers/Roads.java | 255 ++++++++---------- 1 file changed, 111 insertions(+), 144 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index dfba95e2..639d3b3e 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -683,175 +683,142 @@ private void emitOvertureFeature(FeatureCollector features, SourceFeature sf, Li * Collect all split points from road_flags, access_restrictions, and level_rules */ private void collectOvertureSplitPoints(SourceFeature sf, List splitPoints) { - // From road_flags - Object roadFlagsObj = sf.getTag("road_flags"); - if (roadFlagsObj instanceof List) { - @SuppressWarnings("unchecked") List roadFlags = (List) roadFlagsObj; - for (Object flagObj : roadFlags) { - if (flagObj instanceof Map) { - @SuppressWarnings("unchecked") Map flag = (Map) flagObj; - Object betweenObj = flag.get("between"); - if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") List between = (List) betweenObj; - if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { - splitPoints.add(((Number) between.get(0)).doubleValue()); - splitPoints.add(((Number) between.get(1)).doubleValue()); + List segmentObjects = Arrays.asList( + sf.getTag("road_flags"), + sf.getTag("access_restrictions"), + sf.getTag("level_rules") + ); + + for (Object segmentsObj : segmentObjects) { + if (segmentsObj instanceof List) { + @SuppressWarnings("unchecked") List segmentList = (List) segmentsObj; + for (Object segmentObj : segmentList) { + if (segmentObj instanceof Map) { + @SuppressWarnings("unchecked") Map flag = (Map) segmentObj; + Object betweenObj = flag.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + splitPoints.add(((Number) between.get(0)).doubleValue()); + splitPoints.add(((Number) between.get(1)).doubleValue()); + } } } } } } + } - // From access_restrictions - Object accessRestrictionsObj = sf.getTag("access_restrictions"); - if (accessRestrictionsObj instanceof List) { - @SuppressWarnings("unchecked") List accessRestrictions = (List) accessRestrictionsObj; - for (Object restrictionObj : accessRestrictions) { - if (restrictionObj instanceof Map) { - @SuppressWarnings("unchecked") Map restriction = (Map) restrictionObj; - Object betweenObj = restriction.get("between"); - if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") List between = (List) betweenObj; - if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { - splitPoints.add(((Number) between.get(0)).doubleValue()); - splitPoints.add(((Number) between.get(1)).doubleValue()); - } - } - } + private void extractOvertureSegmentRoadFlags(OvertureSegmentProperties props, Map flag, double start, + double end) { + Object valuesObj = flag.get("values"); + Object betweenObj = flag.get("between"); + + // Determine the range this flag applies to + double rangeStart = 0.0; + double rangeEnd = 1.0; + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + rangeStart = ((Number) between.get(0)).doubleValue(); + rangeEnd = ((Number) between.get(1)).doubleValue(); } } - // From level_rules - Object levelRulesObj = sf.getTag("level_rules"); - if (levelRulesObj instanceof List) { - @SuppressWarnings("unchecked") List levelRules = (List) levelRulesObj; - for (Object ruleObj : levelRules) { - if (ruleObj instanceof Map) { - @SuppressWarnings("unchecked") Map rule = (Map) ruleObj; - Object betweenObj = rule.get("between"); - if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") List between = (List) betweenObj; - if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { - splitPoints.add(((Number) between.get(0)).doubleValue()); - splitPoints.add(((Number) between.get(1)).doubleValue()); - } - } - } + // Check if this segment overlaps with the flag's range + if (Linear.overlaps(start, end, rangeStart, rangeEnd) && valuesObj instanceof List) { + @SuppressWarnings("unchecked") List values = (List) valuesObj; + if (values.contains("is_bridge")) { + props.isBridge = true; + } + if (values.contains("is_tunnel")) { + props.isTunnel = true; + } + if (values.contains("is_link")) { + props.isLink = true; } } } - /** - * Extract properties that apply to a segment defined by [start, end] fractional positions - */ - private OvertureSegmentProperties extractOvertureSegmentProperties(SourceFeature sf, double start, double end) { - OvertureSegmentProperties props = new OvertureSegmentProperties(); + private void extractOvertureSegmentRestrictions(OvertureSegmentProperties props, Map restriction, + double start, double end) { + String accessType = (String) restriction.get("access_type"); + if (!"denied".equals(accessType)) { + return; + } - // Check road_flags for is_bridge, is_tunnel, is_link - Object roadFlagsObj = sf.getTag("road_flags"); - if (roadFlagsObj instanceof List) { - @SuppressWarnings("unchecked") List roadFlags = (List) roadFlagsObj; - for (Object flagObj : roadFlags) { - if (flagObj instanceof Map) { - @SuppressWarnings("unchecked") Map flag = (Map) flagObj; - - Object valuesObj = flag.get("values"); - Object betweenObj = flag.get("between"); - - // Determine the range this flag applies to - double rangeStart = 0.0; - double rangeEnd = 1.0; - if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") List between = (List) betweenObj; - if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { - rangeStart = ((Number) between.get(0)).doubleValue(); - rangeEnd = ((Number) between.get(1)).doubleValue(); - } + Object whenObj = restriction.get("when"); + if (whenObj instanceof Map) { + @SuppressWarnings("unchecked") Map when = (Map) whenObj; + String heading = (String) when.get("heading"); + + if ("backward".equals(heading)) { + // Determine the range this restriction applies to + double rangeStart = 0.0; + double rangeEnd = 1.0; + Object betweenObj = restriction.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + rangeStart = ((Number) between.get(0)).doubleValue(); + rangeEnd = ((Number) between.get(1)).doubleValue(); } + } - // Check if this segment overlaps with the flag's range - if (Linear.overlaps(start, end, rangeStart, rangeEnd) && valuesObj instanceof List) { - @SuppressWarnings("unchecked") List values = (List) valuesObj; - if (values.contains("is_bridge")) { - props.isBridge = true; - } - if (values.contains("is_tunnel")) { - props.isTunnel = true; - } - if (values.contains("is_link")) { - props.isLink = true; - } - } + if (Linear.overlaps(start, end, rangeStart, rangeEnd)) { + props.isOneway = true; } } } + } - // Check access_restrictions for oneway - Object accessRestrictionsObj = sf.getTag("access_restrictions"); - if (accessRestrictionsObj instanceof List) { - @SuppressWarnings("unchecked") List accessRestrictions = (List) accessRestrictionsObj; - for (Object restrictionObj : accessRestrictions) { - if (restrictionObj instanceof Map) { - @SuppressWarnings("unchecked") Map restriction = (Map) restrictionObj; - - String accessType = (String) restriction.get("access_type"); - if (!"denied".equals(accessType)) { - continue; - } - - Object whenObj = restriction.get("when"); - if (whenObj instanceof Map) { - @SuppressWarnings("unchecked") Map when = (Map) whenObj; - String heading = (String) when.get("heading"); - - if ("backward".equals(heading)) { - // Determine the range this restriction applies to - double rangeStart = 0.0; - double rangeEnd = 1.0; - Object betweenObj = restriction.get("between"); - if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") List between = (List) betweenObj; - if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { - rangeStart = ((Number) between.get(0)).doubleValue(); - rangeEnd = ((Number) between.get(1)).doubleValue(); - } - } - - if (Linear.overlaps(start, end, rangeStart, rangeEnd)) { - props.isOneway = true; - } - } - } + private void extractOvertureSegmentLevels(OvertureSegmentProperties props, Map rule, double start, + double end) { + Object valueObj = rule.get("value"); + if (valueObj instanceof Number) { + Integer levelValue = ((Number) valueObj).intValue(); + + // Determine the range this level applies to + double rangeStart = 0.0; + double rangeEnd = 1.0; + Object betweenObj = rule.get("between"); + if (betweenObj instanceof List) { + @SuppressWarnings("unchecked") List between = (List) betweenObj; + if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { + rangeStart = ((Number) between.get(0)).doubleValue(); + rangeEnd = ((Number) between.get(1)).doubleValue(); } } - } - - // Check level_rules - Object levelRulesObj = sf.getTag("level_rules"); - if (levelRulesObj instanceof List) { - @SuppressWarnings("unchecked") List levelRules = (List) levelRulesObj; - for (Object ruleObj : levelRules) { - if (ruleObj instanceof Map) { - @SuppressWarnings("unchecked") Map rule = (Map) ruleObj; - Object valueObj = rule.get("value"); - if (valueObj instanceof Number) { - Integer levelValue = ((Number) valueObj).intValue(); + if (Linear.overlaps(start, end, rangeStart, rangeEnd)) { + props.level = levelValue; + } + } + } - // Determine the range this level applies to - double rangeStart = 0.0; - double rangeEnd = 1.0; - Object betweenObj = rule.get("between"); - if (betweenObj instanceof List) { - @SuppressWarnings("unchecked") List between = (List) betweenObj; - if (between.size() >= 2 && between.get(0) instanceof Number && between.get(1) instanceof Number) { - rangeStart = ((Number) between.get(0)).doubleValue(); - rangeEnd = ((Number) between.get(1)).doubleValue(); - } - } + /** + * Extract properties that apply to a segment defined by [start, end] fractional positions + */ + private OvertureSegmentProperties extractOvertureSegmentProperties(SourceFeature sf, double start, double end) { + OvertureSegmentProperties props = new OvertureSegmentProperties(); - if (Linear.overlaps(start, end, rangeStart, rangeEnd)) { - props.level = levelValue; + for (String segmentsKey : List.of("road_flags", "access_restrictions", "level_rules")) { + Object segmentsObj = sf.getTag(segmentsKey); + if (segmentsObj instanceof List) { + @SuppressWarnings("unchecked") List segmentList = (List) segmentsObj; + for (Object segmentObj : segmentList) { + if (segmentObj instanceof Map) { + if (segmentsKey == "road_flags") { + @SuppressWarnings("unchecked") Map flag = (Map) segmentObj; + extractOvertureSegmentRoadFlags(props, flag, start, end); + + } else if (segmentsKey == "access_restrictions") { + @SuppressWarnings("unchecked") Map restriction = (Map) segmentObj; + extractOvertureSegmentRestrictions(props, restriction, start, end); + + } else if (segmentsKey == "level_rules") { + @SuppressWarnings("unchecked") Map rule = (Map) segmentObj; + extractOvertureSegmentLevels(props, rule, start, end); } } } From e8c935c8e8c019b4675b7f8ec6f867a7cbb2c5cd Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 18:35:02 -0800 Subject: [PATCH 66/76] Migrated OSM minZoom to highwayZoomsIndex --- .../com/protomaps/basemap/layers/Roads.java | 92 +++++++++---------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 639d3b3e..8177792f 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -43,83 +43,74 @@ public Roads(CountryCoder countryCoder) { private static final String KIND_DETAIL = "protomaps-basemaps:kindDetail"; private static final String MINZOOM = "protomaps-basemaps:minZoom"; private static final String HIGHWAY = "protomaps-basemaps:highway"; + private static final String COUNTRY = "protomaps-basemaps:country"; private static final String UNDEFINED = "protomaps-basemaps:undefined"; private static final MultiExpression.Index> indexHighways = MultiExpression.of(List.of( rule( with(), - use("kindDetail", fromTag("highway")) + use(KIND_DETAIL, fromTag("highway")) ), rule( with("service"), - use("kindDetail", fromTag("service")) + use(KIND_DETAIL, fromTag("service")) ), rule( with("highway", "motorway"), use("kind", "highway"), - use("minZoom", 3), use("minZoomShieldText", 7), use("minZoomNames", 11) ), rule( with("highway", "motorway_link"), use("kind", "highway"), - use("minZoom", 3), use("minZoomShieldText", 12), use("minZoomNames", 11) ), rule( with("highway", "trunk"), use("kind", "major_road"), - use("minZoom", 6), use("minZoomShieldText", 8), use("minZoomNames", 12) ), rule( with("highway", "trunk_link"), use("kind", "major_road"), - use("minZoom", 6), use("minZoomShieldText", 12), use("minZoomNames", 12) ), rule( with("highway", "primary"), use("kind", "major_road"), - use("minZoom", 7), use("minZoomShieldText", 10), use("minZoomNames", 12) ), rule( with("highway", "primary_link"), use("kind", "major_road"), - use("minZoom", 7), use("minZoomNames", 13) ), rule( with("highway", "secondary"), use("kind", "major_road"), - use("minZoom", 9), use("minZoomShieldText", 11), use("minZoomNames", 12) ), rule( with("highway", "secondary_link"), use("kind", "major_road"), - use("minZoom", 9), use("minZoomShieldText", 13), use("minZoomNames", 14) ), rule( with("highway", "tertiary"), use("kind", "major_road"), - use("minZoom", 9), use("minZoomShieldText", 12), use("minZoomNames", 13) ), rule( with("highway", "tertiary_link"), use("kind", "major_road"), - use("minZoom", 9), use("minZoomShieldText", 13), use("minZoomNames", 14) ), @@ -132,15 +123,13 @@ public Roads(CountryCoder countryCoder) { raceway """), use("kind", "minor_road"), - use("minZoom", 12), use("minZoomShieldText", 12), use("minZoomNames", 14) ), rule( with("highway", "service"), use("kind", "minor_road"), - use("kindDetail", "service"), - use("minZoom", 13), + use(KIND_DETAIL, "service"), use("minZoomShieldText", 12), use("minZoomNames", 14) ), @@ -148,8 +137,7 @@ public Roads(CountryCoder countryCoder) { with("highway", "service"), with("service"), use("kind", "minor_road"), - use("kindDetail", "service"), - use("minZoom", 14), + use(KIND_DETAIL, "service"), use("minZoomShieldText", 12), use("minZoomNames", 14), use("service", fromTag("service")) @@ -162,7 +150,6 @@ public Roads(CountryCoder countryCoder) { corridor """), use("kind", "path"), - use("minZoom", 12), use("minZoomShieldText", 12), use("minZoomNames", 14) ), @@ -176,7 +163,6 @@ public Roads(CountryCoder countryCoder) { steps """), use("kind", "path"), - use("minZoom", 13), use("minZoomShieldText", 12), use("minZoomNames", 14) ), @@ -188,39 +174,16 @@ public Roads(CountryCoder countryCoder) { crossing """), use("kind", "path"), - use("kindDetail", fromTag("footway")), - use("minZoom", 14), + use(KIND_DETAIL, fromTag("footway")), use("minZoomShieldText", 12), use("minZoomNames", 14) ), rule( with("highway", "corridor"), use("kind", "path"), - use("kindDetail", fromTag("footway")), - use("minZoom", 14), + use(KIND_DETAIL, "corridor"), // fromTag("footway") fails tests use("minZoomShieldText", 12), use("minZoomNames", 14) - ), - rule( - with("_country", "US"), - with(""" - highway - motorway - motorway_link - trunk - trunk_link - """), - use("minZoom", 7) - ), - rule( - with("_country", "US"), - with("_r_network_US:US"), - use("minZoom", 6) - ), - rule( - with("_country", "US"), - with("_r_network_US:I"), - use("minZoom", 3) ) )).index(); @@ -344,7 +307,8 @@ public Roads(CountryCoder countryCoder) { private static final MultiExpression.Index> highwayZoomsIndex = MultiExpression.ofOrdered(List.of( - rule(use(MINZOOM, 99)), + // Everything is 14 at first + rule(use(MINZOOM, 14)), rule(with(KIND, "highway"), use(MINZOOM, 3)), rule(with(KIND, "major_road"), with(HIGHWAY, "trunk", "trunk_link"), use(MINZOOM, 6)), @@ -356,7 +320,23 @@ public Roads(CountryCoder countryCoder) { rule(with(KIND, "path"), use(MINZOOM, 12)), rule(with(KIND, "path"), with(KIND_DETAIL, "path", "cycleway", "bridleway", "footway", "steps"), use(MINZOOM, 13)), - rule(with(KIND, "path"), with(KIND_DETAIL, "sidewalk", "crossing"), use(MINZOOM, 14)) + rule(with(KIND, "path"), with(KIND_DETAIL, "sidewalk", "crossing", "corridor"), use(MINZOOM, 14)), + + rule( + with(COUNTRY, "US"), + with("highway", "motorway", "motorway_link", "trunk", "trunk_link"), + use(MINZOOM, 7) + ), + rule( + with(COUNTRY, "US"), + with("_r_network_US:US"), + use(MINZOOM, 6) + ), + rule( + with(COUNTRY, "US"), + with("_r_network_US:I"), + use(MINZOOM, 3) + ) )).index(); @@ -407,7 +387,7 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { try { var code = countryCoder.getCountryCode(sf.latLonGeometry()); - code.ifPresent(s -> sf.setTag("_country", s)); + code.ifPresent(s -> sf.setTag(COUNTRY, s)); locale = CountryCoder.getLocale(code); } catch (GeometryException e) { // do nothing @@ -419,10 +399,24 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { } String kind = getString(sf, matches, "kind", "other"); - String kindDetail = getString(sf, matches, "kindDetail", ""); - int minZoom = getInteger(sf, matches, "minZoom", 14); + String kindDetail = getString(sf, matches, KIND_DETAIL, ""); + int minZoom; // = getInteger(sf, matches, "minZoom", 14); int minZoomShieldText = getInteger(sf, matches, "minZoomShieldText", 14); int minZoomNames = getInteger(sf, matches, "minZoomNames", 14); + System.err.println(String.format("1. kind=%s kindDetail=%s", kind, kindDetail)); + + // Calculate minZoom using zooms indexes + var sf2 = new Matcher.SourceFeatureWithComputedTags( + sf, + Map.of(KIND, kind, KIND_DETAIL, kindDetail, HIGHWAY, highway) + ); + var zoomMatches = highwayZoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + // Initial minZoom + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + System.err.println(String.format("2. kind=%s kindDetail=%s minZoom=%s", kind, kindDetail, minZoom)); if (sf.hasTag("access", "private", "no")) { minZoom = Math.max(minZoom, 15); From 9a0a636d8bf50b8eadf256a6062b7b125e69f6d8 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 18:59:29 -0800 Subject: [PATCH 67/76] Separated remaining zoom-related rules for OSM roads --- .../com/protomaps/basemap/layers/Roads.java | 212 ++++++------------ 1 file changed, 66 insertions(+), 146 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 8177792f..2cb111ae 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -42,148 +42,55 @@ public Roads(CountryCoder countryCoder) { private static final String KIND = "protomaps-basemaps:kind"; private static final String KIND_DETAIL = "protomaps-basemaps:kindDetail"; private static final String MINZOOM = "protomaps-basemaps:minZoom"; + private static final String MINZOOM_SHIELD = "protomaps-basemaps:minZoomShield"; + private static final String MINZOOM_NAME = "protomaps-basemaps:minZoomName"; private static final String HIGHWAY = "protomaps-basemaps:highway"; private static final String COUNTRY = "protomaps-basemaps:country"; private static final String UNDEFINED = "protomaps-basemaps:undefined"; - private static final MultiExpression.Index> indexHighways = MultiExpression.of(List.of( + private static final MultiExpression.Index> osmKindsIndex = MultiExpression.of(List.of( rule( - with(), - use(KIND_DETAIL, fromTag("highway")) - ), - rule( - with("service"), - use(KIND_DETAIL, fromTag("service")) - ), - rule( - with("highway", "motorway"), - use("kind", "highway"), - use("minZoomShieldText", 7), - use("minZoomNames", 11) - ), - rule( - with("highway", "motorway_link"), - use("kind", "highway"), - use("minZoomShieldText", 12), - use("minZoomNames", 11) - ), - rule( - with("highway", "trunk"), - use("kind", "major_road"), - use("minZoomShieldText", 8), - use("minZoomNames", 12) - ), - rule( - with("highway", "trunk_link"), - use("kind", "major_road"), - use("minZoomShieldText", 12), - use("minZoomNames", 12) - ), - rule( - with("highway", "primary"), - use("kind", "major_road"), - use("minZoomShieldText", 10), - use("minZoomNames", 12) - ), - rule( - with("highway", "primary_link"), - use("kind", "major_road"), - use("minZoomNames", 13) - ), - rule( - with("highway", "secondary"), - use("kind", "major_road"), - use("minZoomShieldText", 11), - use("minZoomNames", 12) - ), - rule( - with("highway", "secondary_link"), - use("kind", "major_road"), - use("minZoomShieldText", 13), - use("minZoomNames", 14) - ), - rule( - with("highway", "tertiary"), - use("kind", "major_road"), - use("minZoomShieldText", 12), - use("minZoomNames", 13) - ), - rule( - with("highway", "tertiary_link"), - use("kind", "major_road"), - use("minZoomShieldText", 13), - use("minZoomNames", 14) - ), - rule( - with(""" - highway - residential - unclassified - road - raceway - """), - use("kind", "minor_road"), - use("minZoomShieldText", 12), - use("minZoomNames", 14) - ), - rule( - with("highway", "service"), - use("kind", "minor_road"), - use(KIND_DETAIL, "service"), - use("minZoomShieldText", 12), - use("minZoomNames", 14) + use(KIND_DETAIL, fromTag("highway")), + use(HIGHWAY, fromTag("highway")) ), + rule(with("service"), use(KIND_DETAIL, fromTag("service"))), + + rule(with("highway", "motorway"), use(KIND, "highway")), + rule(with("highway", "motorway_link"), use(KIND, "highway")), + + rule(with("highway", "trunk"), use(KIND, "major_road")), + rule(with("highway", "trunk_link"), use(KIND, "major_road")), + rule(with("highway", "primary"), use(KIND, "major_road")), + rule(with("highway", "primary_link"), use(KIND, "major_road")), + rule(with("highway", "secondary"), use(KIND, "major_road")), + rule(with("highway", "secondary_link"), use(KIND, "major_road")), + rule(with("highway", "tertiary"), use(KIND, "major_road")), + rule(with("highway", "tertiary_link"), use(KIND, "major_road")), + + rule(with("highway", "residential", "unclassified", "road", "raceway"), use(KIND, "minor_road")), + rule(with("highway", "service"), use(KIND, "minor_road"), use(KIND_DETAIL, "service")), rule( with("highway", "service"), with("service"), - use("kind", "minor_road"), + use(KIND, "minor_road"), use(KIND_DETAIL, "service"), - use("minZoomShieldText", 12), - use("minZoomNames", 14), use("service", fromTag("service")) ), + rule(with("highway", "pedestrian", "track", "corridor"), use(KIND, "path")), rule( - with(""" - highway - pedestrian - track - corridor - """), - use("kind", "path"), - use("minZoomShieldText", 12), - use("minZoomNames", 14) - ), - rule( - with(""" - highway - path - cycleway - bridleway - footway - steps - """), - use("kind", "path"), - use("minZoomShieldText", 12), - use("minZoomNames", 14) + with("highway", "path", "cycleway", "bridleway", "footway", "steps"), + use(KIND, "path") ), rule( with("highway", "footway"), - with(""" - footway - sidewalk - crossing - """), - use("kind", "path"), - use(KIND_DETAIL, fromTag("footway")), - use("minZoomShieldText", 12), - use("minZoomNames", 14) + with("footway", "sidewalk", "crossing"), + use(KIND, "path"), + use(KIND_DETAIL, fromTag("footway")) ), rule( with("highway", "corridor"), - use("kind", "path"), - use(KIND_DETAIL, "corridor"), // fromTag("footway") fails tests - use("minZoomShieldText", 12), - use("minZoomNames", 14) + use(KIND, "path"), + use(KIND_DETAIL, "corridor") // fromTag("footway") fails tests ) )).index(); @@ -307,21 +214,33 @@ public Roads(CountryCoder countryCoder) { private static final MultiExpression.Index> highwayZoomsIndex = MultiExpression.ofOrdered(List.of( - // Everything is 14 at first - rule(use(MINZOOM, 14)), + // Everything is ~14 at first + rule(use(MINZOOM, 14), use(MINZOOM_NAME, 14), use(MINZOOM_SHIELD, 12)), - rule(with(KIND, "highway"), use(MINZOOM, 3)), - rule(with(KIND, "major_road"), with(HIGHWAY, "trunk", "trunk_link"), use(MINZOOM, 6)), - rule(with(KIND, "major_road"), with(HIGHWAY, "primary", "primary_link"), use(MINZOOM, 7)), - rule(with(KIND, "major_road"), with(HIGHWAY, "secondary", "secondary_link"), use(MINZOOM, 9)), - rule(with(KIND, "major_road"), with(HIGHWAY, "tertiary", "tertiary_link"), use(MINZOOM, 9)), + // Freeways show up earliest + rule(with(KIND, "highway"), use(MINZOOM, 3), use(MINZOOM_NAME, 11), use(MINZOOM_SHIELD, 7)), + rule(with(KIND, "highway"), with(HIGHWAY, "motorway_link"), use(MINZOOM_NAME, 11), use(MINZOOM_SHIELD, 12)), + + // Major roads show up early also + rule(with(KIND, "major_road"), with(HIGHWAY, "trunk"), use(MINZOOM, 6), use(MINZOOM_NAME, 12), use(MINZOOM_SHIELD, 8)), + rule(with(KIND, "major_road"), with(HIGHWAY, "trunk_link"), use(MINZOOM, 6), use(MINZOOM_NAME, 12), use(MINZOOM_SHIELD, 12)), + rule(with(KIND, "major_road"), with(HIGHWAY, "primary"), use(MINZOOM, 7), use(MINZOOM_NAME, 12), use(MINZOOM_SHIELD, 10)), + rule(with(KIND, "major_road"), with(HIGHWAY, "primary_link"), use(MINZOOM, 7), use(MINZOOM_NAME, 13), use(MINZOOM_SHIELD, 11)), + rule(with(KIND, "major_road"), with(HIGHWAY, "secondary"), use(MINZOOM, 9), use(MINZOOM_NAME, 12), use(MINZOOM_SHIELD, 11)), + rule(with(KIND, "major_road"), with(HIGHWAY, "secondary_link"), use(MINZOOM, 9), use(MINZOOM_NAME, 14), use(MINZOOM_SHIELD, 13)), + rule(with(KIND, "major_road"), with(HIGHWAY, "tertiary"), use(MINZOOM, 9), use(MINZOOM_NAME, 13), use(MINZOOM_SHIELD, 12)), + rule(with(KIND, "major_road"), with(HIGHWAY, "tertiary_link"), use(MINZOOM, 9), use(MINZOOM_NAME, 14), use(MINZOOM_SHIELD, 13)), + + // Minor roads and paths show up a little early rule(with(KIND, "minor_road"), use(MINZOOM, 12)), rule(with(KIND, "minor_road"), with(KIND_DETAIL, "service"), use(MINZOOM, 13)), - rule(with(KIND, "path"), use(MINZOOM, 12)), + rule(with(KIND, "path"), use(MINZOOM, 12)), rule(with(KIND, "path"), with(KIND_DETAIL, "path", "cycleway", "bridleway", "footway", "steps"), use(MINZOOM, 13)), rule(with(KIND, "path"), with(KIND_DETAIL, "sidewalk", "crossing", "corridor"), use(MINZOOM, 14)), + // Freeways in the US are special + rule( with(COUNTRY, "US"), with("highway", "motorway", "motorway_link", "trunk", "trunk_link"), @@ -393,17 +312,16 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { // do nothing } - var matches = indexHighways.getMatches(sf); + var matches = osmKindsIndex.getMatches(sf); if (matches.isEmpty()) { return; } - String kind = getString(sf, matches, "kind", "other"); + String kind = getString(sf, matches, KIND, "other"); String kindDetail = getString(sf, matches, KIND_DETAIL, ""); - int minZoom; // = getInteger(sf, matches, "minZoom", 14); - int minZoomShieldText = getInteger(sf, matches, "minZoomShieldText", 14); - int minZoomNames = getInteger(sf, matches, "minZoomNames", 14); - System.err.println(String.format("1. kind=%s kindDetail=%s", kind, kindDetail)); + int minZoom; + int minZoomShieldText; + int minZoomNames; // Calculate minZoom using zooms indexes var sf2 = new Matcher.SourceFeatureWithComputedTags( @@ -416,7 +334,8 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { // Initial minZoom minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); - System.err.println(String.format("2. kind=%s kindDetail=%s minZoom=%s", kind, kindDetail, minZoom)); + minZoomShieldText = getInteger(sf2, zoomMatches, MINZOOM_SHIELD, 99); + minZoomNames = getInteger(sf2, zoomMatches, MINZOOM_NAME, 99); if (sf.hasTag("access", "private", "no")) { minZoom = Math.max(minZoom, 15); @@ -432,8 +351,8 @@ private void processOsmHighways(SourceFeature sf, FeatureCollector features) { .setAttrWithMinzoom("network", shield.network(), minZoomShieldText) .setAttrWithMinzoom("oneway", sf.getString("oneway"), 14) .setAttrWithMinzoom("access", sf.getTag("access"), 15) - // `highway` is a temporary attribute that gets removed in the post-process step - .setAttr("highway", highway) + // temporary attribute that gets removed in the post-process step + .setAttr(HIGHWAY, highway) .setAttr("sort_rank", 400) .setMinPixelSize(0) .setPixelTolerance(0) @@ -646,7 +565,8 @@ private void emitOvertureFeature(FeatureCollector features, SourceFeature sf, Li .setAttr("kind_detail", kindDetail) .setAttr("name", name) .setAttr("min_zoom", minZoom + 1) - .setAttr("highway", highway) + // temporary attribute that gets removed in the post-process step + .setAttr(HIGHWAY, highway) .setAttr("sort_rank", 400) .setMinPixelSize(0) .setPixelTolerance(0) @@ -826,14 +746,14 @@ private OvertureSegmentProperties extractOvertureSegmentProperties(SourceFeature public List postProcess(int zoom, List items) throws GeometryException { // limit the application of LinkSimplify to where cloverleafs are unlikely to be at tile edges. if (zoom < 12) { - items = linkSimplify(items, "highway", "motorway", "motorway_link"); - items = linkSimplify(items, "highway", "trunk", "trunk_link"); - items = linkSimplify(items, "highway", "primary", "primary_link"); - items = linkSimplify(items, "highway", "secondary", "secondary_link"); + items = linkSimplify(items, HIGHWAY, "motorway", "motorway_link"); + items = linkSimplify(items, HIGHWAY, "trunk", "trunk_link"); + items = linkSimplify(items, HIGHWAY, "primary", "primary_link"); + items = linkSimplify(items, HIGHWAY, "secondary", "secondary_link"); } for (var item : items) { - item.tags().remove("highway"); + item.tags().remove(HIGHWAY); } items = FeatureMerge.mergeLineStrings(items, From 3b0d9e4dda005523fdb256f43cc094f636b27bc1 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Wed, 31 Dec 2025 22:39:16 -0800 Subject: [PATCH 68/76] Claude's version of Places.java --- tiles/get-overture.py | 2 +- .../java/com/protomaps/basemap/Basemap.java | 1 + .../com/protomaps/basemap/layers/Places.java | 141 ++++++++++++++++++ .../protomaps/basemap/layers/PlacesTest.java | 110 ++++++++++++++ 4 files changed, 253 insertions(+), 1 deletion(-) diff --git a/tiles/get-overture.py b/tiles/get-overture.py index 5510b987..94b03873 100755 --- a/tiles/get-overture.py +++ b/tiles/get-overture.py @@ -87,7 +87,7 @@ def query_overture_data(bbox, output_path): hive_partitioning=1, filename=1, union_by_name=1) - WHERE theme IN ('transportation', 'places', 'base', 'buildings') + WHERE theme IN ('transportation', 'places', 'base', 'buildings', 'divisions') AND bbox.xmin <= {bbox['xmax']} AND bbox.xmax >= {bbox['xmin']} AND bbox.ymin <= {bbox['ymax']} diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index a7cf90df..29d7a223 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -67,6 +67,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, var place = new Places(countryCoder); registerHandler(place); registerSourceHandler("osm", place::processOsm); + registerSourceHandler("overture", place::processOverture); } if (layer.isEmpty() || layer.equals(Pois.LAYER_NAME)) { diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java index 9c03ea55..c149f992 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java @@ -200,6 +200,42 @@ public Places(CountryCoder countryCoder) { ) )).index(); + // Overture properties to Protomaps kind mapping + private static final MultiExpression.Index> overtureKindsIndex = MultiExpression.ofOrdered(List.of( + rule( + with("subtype", "locality"), + with("class", "city"), + use("kind", "locality"), + use("kind_detail", "city"), + use("kindRank", 2) + ), + rule( + with("subtype", "locality"), + with("class", "town"), + use("kind", "locality"), + use("kind_detail", "town"), + use("kindRank", 2) + ), + rule( + with("subtype", "macrohood"), + use("kind", "neighbourhood"), + use("kind_detail", "macrohood"), + use("kindRank", 10) + ), + rule( + with("subtype", "neighborhood"), + use("kind", "neighbourhood"), + use("kind_detail", "neighbourhood"), + use("kindRank", 11) + ), + rule( + with("subtype", "microhood"), + use("kind", "neighbourhood"), + use("kind_detail", "neighbourhood"), + use("kindRank", 11) + ) + )).index(); + private record WikidataConfig(int minZoom, int maxZoom, int rankMax) {} private static Map readWikidataConfigs() { @@ -382,6 +418,111 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { OsmNames.setOsmRefs(feat, sf, 0); } + public void processOverture(SourceFeature sf, FeatureCollector features) { + // Filter by theme and type + if (!"divisions".equals(sf.getString("theme"))) { + return; + } + + if (!"division".equals(sf.getString("type"))) { + return; + } + + // Must be a point with a name + if (!sf.isPoint() || !sf.hasTag("names.primary")) { + return; + } + + var matches = overtureKindsIndex.getMatches(sf); + if (matches.isEmpty()) { + return; + } + + String kind = getString(sf, matches, "kind", null); + if (kind == null) { + return; + } + + String kindDetail = getString(sf, matches, "kind_detail", null); + Integer kindRank = getInteger(sf, matches, "kindRank", 6); + + // Use default zoom ranges based on kind + Integer minZoom = 12; + Integer maxZoom = 15; + + if (kind.equals("locality")) { + minZoom = 7; + maxZoom = 15; + } else if (kind.equals("neighbourhood")) { + if (kindRank == 10) { // macrohood + minZoom = 10; + } else { // neighbourhood/microhood + minZoom = 12; + } + } + + // Extract name + String name = sf.getString("names.primary"); + + // Extract population (if available) + Integer population = 0; + if (sf.hasTag("population")) { + Object popValue = sf.getTag("population"); + if (popValue instanceof Number) { + population = ((Number) popValue).intValue(); + } + } + + int populationRank = 0; + int[] popBreaks = { + 1000000000, + 100000000, + 50000000, + 20000000, + 10000000, + 5000000, + 1000000, + 500000, + 200000, + 100000, + 50000, + 20000, + 10000, + 5000, + 2000, + 1000, + 200, + 0}; + + for (int i = 0; i < popBreaks.length; i++) { + if (population >= popBreaks[i]) { + populationRank = popBreaks.length - i; + break; + } + } + + var feat = features.point(this.name()) + .setAttr("kind", kind) + .setAttr("name", name) + .setAttr("min_zoom", minZoom + 1) + .setAttr("population", population) + .setAttr("population_rank", populationRank) + .setZoomRange(minZoom, maxZoom); + + if (kindDetail != null) { + feat.setAttr("kind_detail", kindDetail); + } + + int sortKey = getSortKey(minZoom, kindRank, population, name); + feat.setSortKey(sortKey); + feat.setAttr("sort_key", sortKey); + + feat.setBufferPixels(24); + feat.setPointLabelGridPixelSize(LOCALITY_GRID_SIZE_ZOOM_FUNCTION) + .setPointLabelGridLimit(LOCALITY_GRID_LIMIT_ZOOM_FUNCTION); + feat.setBufferPixelOverrides(ZoomFunction.maxZoom(12, 64)); + } + @Override public List postProcess(int zoom, List items) { return items; diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java index 709a0145..d402611a 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java @@ -203,3 +203,113 @@ void testAllotmentsOsm() { ))); } } + +class PlacesOvertureTest extends LayerTest { + + @Test + void testOaklandCity() { + assertFeatures(12, + List.of(Map.of( + "kind", "locality", + "kind_detail", "city", + "name", "Oakland", + "min_zoom", 8, + "population", 433031, + "population_rank", 11 + )), + process(SimpleFeature.create( + newPoint(-122.2708, 37.8044), + new HashMap<>(Map.of( + "id", "9d45ba84-c664-42bd-81e4-3f75b1d179c9", + "theme", "divisions", + "type", "division", + "subtype", "locality", + "class", "city", + "names.primary", "Oakland", + "population", 433031 + )), + "overture", + null, + 0 + ))); + } + + @Test + void testPiedmontTown() { + assertFeatures(12, + List.of(Map.of( + "kind", "locality", + "kind_detail", "town", + "name", "Piedmont", + "min_zoom", 8, + "population", 0, + "population_rank", 1 + )), + process(SimpleFeature.create( + newPoint(-122.2312, 37.8244), + new HashMap<>(Map.of( + "id", "bf3e15f5-1287-48a2-b8c4-2b9061950f74", + "theme", "divisions", + "type", "division", + "subtype", "locality", + "class", "town", + "names.primary", "Piedmont" + )), + "overture", + null, + 0 + ))); + } + + @Test + void testDowntownOaklandMacrohood() { + assertFeatures(12, + List.of(Map.of( + "kind", "neighbourhood", + "kind_detail", "macrohood", + "name", "Downtown Oakland", + "min_zoom", 11, + "population", 0, + "population_rank", 1 + )), + process(SimpleFeature.create( + newPoint(-122.2708, 37.8044), + new HashMap<>(Map.of( + "id", "81e4b45f-1210-4e79-9ea1-becc6e223778", + "theme", "divisions", + "type", "division", + "subtype", "macrohood", + "names.primary", "Downtown Oakland" + )), + "overture", + null, + 0 + ))); + } + + @Test + void testLakesideNeighborhood() { + assertFeatures(14, + List.of(Map.of( + "kind", "neighbourhood", + "kind_detail", "neighbourhood", + "name", "Lakeside", + "min_zoom", 13, + "population", 0, + "population_rank", 1 + )), + process(SimpleFeature.create( + newPoint(-122.2476, 37.8074), + new HashMap<>(Map.of( + "id", "d95da2a7-5c9d-44ce-9d9b-8b1fa7aa93a1", + "theme", "divisions", + "type", "division", + "subtype", "neighborhood", + "names.primary", "Lakeside" + )), + "overture", + null, + 0 + ))); + } +} From c5c233e2cf87cbfd4a690b1fcab2614d5d857141 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Thu, 1 Jan 2026 11:31:50 -0800 Subject: [PATCH 69/76] Organized Places rules to separate kinds and zooms indexes --- .../com/protomaps/basemap/layers/Places.java | 356 ++++++++---------- .../protomaps/basemap/layers/PlacesTest.java | 10 +- 2 files changed, 158 insertions(+), 208 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java index c149f992..fccfdad4 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java @@ -18,6 +18,7 @@ import com.onthegomap.planetiler.util.ZoomFunction; import com.protomaps.basemap.feature.CountryCoder; import com.protomaps.basemap.feature.FeatureId; +import com.protomaps.basemap.feature.Matcher; import com.protomaps.basemap.names.OsmNames; import java.io.BufferedReader; import java.io.IOException; @@ -43,199 +44,182 @@ public Places(CountryCoder countryCoder) { public static final String LAYER_NAME = "places"; + // Internal tags used to reference calculated values between matchers + private static final String KIND = "protomaps-basemaps:kind"; + private static final String KIND_DETAIL = "protomaps-basemaps:kindDetail"; + private static final String KIND_RANK = "protomaps-basemaps:kindRank"; + private static final String POPULATION = "protomaps-basemaps:population"; + private static final String MINZOOM = "protomaps-basemaps:minZoom"; + private static final String MAXZOOM = "protomaps-basemaps:maxZoom"; + private static final String COUNTRY = "protomaps-basemaps:country"; + private static final String UNDEFINED = "protomaps-basemaps:undefined"; + + private static int[] POP_BREAKS = { + 0, + 200, + 1000, + 2000, + 5000, + 10000, + 20000, + 50000, + 100000, + 200000, + 500000, + 1000000, + 5000000, + 10000000, + 20000000, + 50000000, + 100000000, + 1000000000 + }; + private static final MultiExpression.Index> index = MultiExpression.ofOrdered(List.of( + + rule(use(KIND, UNDEFINED)), + rule(with("population"), use(POPULATION, fromTag("population"))), + + rule(with("place", "country"), use(KIND, "country")), rule( - with("population"), - use("population", fromTag("population")) - ), - rule( - with("place", "country"), - use("kind", "country"), - use("minZoom", 5), - use("maxZoom", 8), - use("kindRank", 0) - ), - rule( - with(""" - place - state - province - """), - with(""" - _country - US - CA - BR - IN - CN - AU - """ - ), - use("kind", "region"), - use("minZoom", 8), - use("maxZoom", 11), - use("kindRank", 1) - ), - rule( - with(""" - place - city - town - """), - use("kind", "locality"), - use("minZoom", 7), - use("maxZoom", 15), - use("kindRank", 2) - ), - rule( - with("place", "city"), - without("population"), - use("population", 5000), - use("minZoom", 8) - ), - rule( - with("place", "town"), - without("population"), - use("population", 10000), - use("minZoom", 9) + with("place", "state", "province"), + with(COUNTRY, "US", "CA", "BR", "IN", "CN", "AU"), + use(KIND, "region"), + use(KIND_RANK, 1) // TODO: move this down to zoomsIndex ), + rule(with("place", "city", "town"), use(KIND, "locality"), use(KIND_DETAIL, fromTag("place"))), + rule(with("place", "city"), without("population"), use(POPULATION, 5000)), + rule(with("place", "town"), without("population"), use(POPULATION, 10000)), + + // Neighborhood-scale places + + rule(with("place", "neighbourhood", "suburb"), use(KIND, "neighbourhood")), + rule(with("place", "suburb"), use(KIND, "neighbourhood"), use(KIND_DETAIL, "suburb")), + rule(with("place", "quarter"), use(KIND, "macrohood")), + + // Smaller places detailed in OSM but not fully tested for Overture + // TODO: move these zoom and rank settings down to zoomsIndex + rule( with("place", "village"), - use("kind", "locality"), - use("minZoom", 10), - use("maxZoom", 15), - use("kindRank", 3) + use(KIND, "locality"), + use(KIND_DETAIL, fromTag("place")), + use(KIND_RANK, 3) ), rule( with("place", "village"), without("population"), - use("minZoom", 11), - use("population", 2000) + use(MINZOOM, 11), + use(POPULATION, 2000) ), rule( with("place", "locality"), - use("kind", "locality"), - use("minZoom", 11), - use("maxZoom", 15), - use("kindRank", 4) + use(KIND, "locality"), + use(MINZOOM, 11), + use(KIND_RANK, 4) ), rule( with("place", "locality"), without("population"), - use("minZoom", 12), - use("population", 1000) + use(MINZOOM, 12), + use(POPULATION, 1000) ), rule( with("place", "hamlet"), - use("kind", "locality"), - use("minZoom", 11), - use("maxZoom", 15), - use("kindRank", 5) + use(KIND, "locality"), + use(MINZOOM, 11), + use(KIND_RANK, 5) ), rule( with("place", "hamlet"), without("population"), - use("minZoom", 12), - use("population", 200) + use(MINZOOM, 12), + use(POPULATION, 200) ), rule( with("place", "isolated_dwelling"), - use("kind", "locality"), - use("minZoom", 13), - use("maxZoom", 15), - use("kindRank", 6) + use(KIND, "locality"), + use(MINZOOM, 13), + use(KIND_RANK, 6) ), rule( with("place", "isolated_dwelling"), without("population"), - use("minZoom", 14), - use("population", 100) + use(MINZOOM, 14), + use(POPULATION, 100) ), rule( with("place", "farm"), - use("kind", "locality"), - use("minZoom", 13), - use("maxZoom", 15), - use("kindRank", 7) + use(KIND, "locality"), + use(MINZOOM, 13), + use(KIND_RANK, 7) ), rule( with("place", "farm"), without("population"), - use("minZoom", 14), - use("population", 50) + use(MINZOOM, 14), + use(POPULATION, 50) ), rule( with("place", "allotments"), - use("kind", "locality"), - use("minZoom", 13), - use("maxZoom", 15), - use("kindRank", 8) + use(KIND, "locality"), + use(MINZOOM, 13), + use(KIND_RANK, 8) ), rule( with("place", "allotments"), without("population"), - use("minZoom", 14), - use("population", 1000) - ), - rule( - with("place", "suburb"), - use("kind", "neighbourhood"), - use("minZoom", 11), - use("maxZoom", 15), - use("kindRank", 9) - ), - rule( - with("place", "quarter"), - use("kind", "macrohood"), - use("minZoom", 10), - use("maxZoom", 15), - use("kindRank", 10) - ), - rule( - with("place", "neighbourhood"), - use("kind", "neighbourhood"), - use("minZoom", 12), - use("maxZoom", 15), - use("kindRank", 11) + use(MINZOOM, 14), + use(POPULATION, 1000) ) + )).index(); // Overture properties to Protomaps kind mapping + private static final MultiExpression.Index> overtureKindsIndex = MultiExpression.ofOrdered(List.of( rule( with("subtype", "locality"), with("class", "city"), - use("kind", "locality"), - use("kind_detail", "city"), - use("kindRank", 2) + use(KIND, "locality"), + use(KIND_DETAIL, "city") ), rule( with("subtype", "locality"), with("class", "town"), - use("kind", "locality"), - use("kind_detail", "town"), - use("kindRank", 2) + use(KIND, "locality"), + use(KIND_DETAIL, "town") ), rule( with("subtype", "macrohood"), - use("kind", "neighbourhood"), - use("kind_detail", "macrohood"), - use("kindRank", 10) - ), - rule( - with("subtype", "neighborhood"), - use("kind", "neighbourhood"), - use("kind_detail", "neighbourhood"), - use("kindRank", 11) + use(KIND, "macrohood") ), rule( - with("subtype", "microhood"), - use("kind", "neighbourhood"), - use("kind_detail", "neighbourhood"), - use("kindRank", 11) + with("subtype", "neighborhood", "microhood"), + use(KIND, "neighbourhood"), + use(KIND_DETAIL, "neighbourhood") ) )).index(); + // Protomaps kind/kind_detail to min_zoom/max_zoom/kind_rank mapping + + private static final MultiExpression.Index> zoomsIndex = MultiExpression.ofOrdered(List.of( + // Top-level defaults + rule(use(MINZOOM, 12), use(MAXZOOM, 15)), + + rule(with(KIND, "country"), use(KIND_RANK, 0), use(MINZOOM, 5), use(MAXZOOM, 8)), + rule(with(KIND, "region"), use(MINZOOM, 8), use(MAXZOOM, 11)), + + rule(with(KIND, "locality"), use(MINZOOM, 7)), + rule(with(KIND, "locality"), with(KIND_DETAIL, "city"), use(KIND_RANK, 2), use(MINZOOM, 8)), + rule(with(KIND, "locality"), with(KIND_DETAIL, "town"), use(KIND_RANK, 2), use(MINZOOM, 9)), + rule(with(KIND, "locality"), with(KIND_DETAIL, "village"), use(MINZOOM, 10), use(MAXZOOM, 15)), + + rule(with(KIND, "macrohood"), use(KIND_RANK, 10), use(MINZOOM, 10)), + rule(with(KIND, "neighbourhood"), use(KIND_RANK, 11), use(MINZOOM, 12)), + rule(with(KIND, "neighbourhood"), with(KIND_DETAIL, "suburb"), use(KIND_RANK, 9), use(MINZOOM, 12)) + )).index(); + private record WikidataConfig(int minZoom, int maxZoom, int rankMax) {} private static Map readWikidataConfigs() { @@ -316,7 +300,7 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { try { Optional code = countryCoder.getCountryCode(sf.latLonGeometry()); if (code.isPresent()) { - sf.setTag("_country", code.get()); + sf.setTag(COUNTRY, code.get()); } } catch (GeometryException e) { // do nothing @@ -327,42 +311,32 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { return; } - String kind = getString(sf, matches, "kind", null); - if (kind == null) { + String kind = getString(sf, matches, KIND, UNDEFINED); + String kindDetail = getString(sf, matches, KIND_DETAIL, ""); + Integer population = getInteger(sf, matches, POPULATION, 0); + + if (kind == UNDEFINED) { return; } - Integer kindRank = getInteger(sf, matches, "kindRank", 6); - Integer minZoom = getInteger(sf, matches, "minZoom", 12); - Integer maxZoom = getInteger(sf, matches, "maxZoom", 15); - Integer population = getInteger(sf, matches, "population", 0); + Integer minZoom; + Integer maxZoom; + Integer kindRank; + + var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, Map.of(KIND, kind, KIND_DETAIL, kindDetail)); + var zoomMatches = zoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + maxZoom = getInteger(sf2, zoomMatches, MAXZOOM, 99); + kindRank = getInteger(sf2, zoomMatches, KIND_RANK, 99); int populationRank = 0; - int[] popBreaks = { - 1000000000, - 100000000, - 50000000, - 20000000, - 10000000, - 5000000, - 1000000, - 500000, - 200000, - 100000, - 50000, - 20000, - 10000, - 5000, - 2000, - 1000, - 200, - 0}; - - for (int i = 0; i < popBreaks.length; i++) { - if (population >= popBreaks[i]) { - populationRank = popBreaks.length - i; - break; + for (int i = 0; i < POP_BREAKS.length; i++) { + if (population >= POP_BREAKS[i]) { + populationRank = i + 1; } } @@ -438,28 +412,24 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { return; } - String kind = getString(sf, matches, "kind", null); - if (kind == null) { + String kind = getString(sf, matches, KIND, UNDEFINED); + String kindDetail = getString(sf, matches, KIND_DETAIL, ""); + Integer kindRank = getInteger(sf, matches, KIND_RANK, 6); + + if (kind == UNDEFINED) { return; } - String kindDetail = getString(sf, matches, "kind_detail", null); - Integer kindRank = getInteger(sf, matches, "kindRank", 6); - - // Use default zoom ranges based on kind - Integer minZoom = 12; - Integer maxZoom = 15; - - if (kind.equals("locality")) { - minZoom = 7; - maxZoom = 15; - } else if (kind.equals("neighbourhood")) { - if (kindRank == 10) { // macrohood - minZoom = 10; - } else { // neighbourhood/microhood - minZoom = 12; - } - } + Integer minZoom; + Integer maxZoom; + + var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, Map.of(KIND, kind, KIND_DETAIL, kindDetail)); + var zoomMatches = zoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + maxZoom = getInteger(sf2, zoomMatches, MAXZOOM, 99); // Extract name String name = sf.getString("names.primary"); @@ -474,30 +444,10 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { } int populationRank = 0; - int[] popBreaks = { - 1000000000, - 100000000, - 50000000, - 20000000, - 10000000, - 5000000, - 1000000, - 500000, - 200000, - 100000, - 50000, - 20000, - 10000, - 5000, - 2000, - 1000, - 200, - 0}; - - for (int i = 0; i < popBreaks.length; i++) { - if (population >= popBreaks[i]) { - populationRank = popBreaks.length - i; - break; + + for (int i = 0; i < POP_BREAKS.length; i++) { + if (population >= POP_BREAKS[i]) { + populationRank = i + 1; } } diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java index d402611a..f8a7bd70 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/PlacesTest.java @@ -204,6 +204,7 @@ void testAllotmentsOsm() { } } + class PlacesOvertureTest extends LayerTest { @Test @@ -213,9 +214,9 @@ void testOaklandCity() { "kind", "locality", "kind_detail", "city", "name", "Oakland", - "min_zoom", 8, + "min_zoom", 9, "population", 433031, - "population_rank", 11 + "population_rank", 10 )), process(SimpleFeature.create( newPoint(-122.2708, 37.8044), @@ -241,7 +242,7 @@ void testPiedmontTown() { "kind", "locality", "kind_detail", "town", "name", "Piedmont", - "min_zoom", 8, + "min_zoom", 10, "population", 0, "population_rank", 1 )), @@ -265,8 +266,7 @@ void testPiedmontTown() { void testDowntownOaklandMacrohood() { assertFeatures(12, List.of(Map.of( - "kind", "neighbourhood", - "kind_detail", "macrohood", + "kind", "macrohood", "name", "Downtown Oakland", "min_zoom", 11, "population", 0, From 4abbab62d4e3aa633498568eae39baaadbbd5bf7 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Thu, 1 Jan 2026 13:25:48 -0800 Subject: [PATCH 70/76] kindRank can come from osmKindsIndex (for now!) and zoomsIndex, but not overtureKindsIndex --- .../main/java/com/protomaps/basemap/layers/Places.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java index fccfdad4..9dd9f571 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Places.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Places.java @@ -75,7 +75,7 @@ public Places(CountryCoder countryCoder) { 1000000000 }; - private static final MultiExpression.Index> index = MultiExpression.ofOrdered(List.of( + private static final MultiExpression.Index> osmKindsIndex = MultiExpression.ofOrdered(List.of( rule(use(KIND, UNDEFINED)), rule(with("population"), use(POPULATION, fromTag("population"))), @@ -306,13 +306,14 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { // do nothing } - var matches = index.getMatches(sf); + var matches = osmKindsIndex.getMatches(sf); if (matches.isEmpty()) { return; } String kind = getString(sf, matches, KIND, UNDEFINED); String kindDetail = getString(sf, matches, KIND_DETAIL, ""); + Integer kindRank = getInteger(sf, matches, KIND_RANK, 0); Integer population = getInteger(sf, matches, POPULATION, 0); if (kind == UNDEFINED) { @@ -321,7 +322,6 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { Integer minZoom; Integer maxZoom; - Integer kindRank; var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, Map.of(KIND, kind, KIND_DETAIL, kindDetail)); var zoomMatches = zoomsIndex.getMatches(sf2); @@ -414,7 +414,6 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { String kind = getString(sf, matches, KIND, UNDEFINED); String kindDetail = getString(sf, matches, KIND_DETAIL, ""); - Integer kindRank = getInteger(sf, matches, KIND_RANK, 6); if (kind == UNDEFINED) { return; @@ -422,6 +421,7 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { Integer minZoom; Integer maxZoom; + Integer kindRank; var sf2 = new Matcher.SourceFeatureWithComputedTags(sf, Map.of(KIND, kind, KIND_DETAIL, kindDetail)); var zoomMatches = zoomsIndex.getMatches(sf2); @@ -430,6 +430,7 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); maxZoom = getInteger(sf2, zoomMatches, MAXZOOM, 99); + kindRank = getInteger(sf2, zoomMatches, KIND_RANK, 99); // Extract name String name = sf.getString("names.primary"); From adf174440dcc755f3babc7c382ad6815b9c2d682 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Fri, 2 Jan 2026 23:11:30 -0800 Subject: [PATCH 71/76] Building parts look nice --- tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java b/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java index 98238d8e..ba803d57 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java @@ -144,7 +144,7 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { } // Ignore type=building_part for now - if (!"building".equals(sf.getString("type"))) { + if (!"building".equals(sf.getString("type")) && !"building_part".equals(sf.getString("type"))) { return; } From 9da323b9c16bf60f99a1f7b28db83478fffeb749 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 3 Jan 2026 15:15:45 -0800 Subject: [PATCH 72/76] Added rendering and tests for theme=transportation/type=segment rail and water --- .../com/protomaps/basemap/layers/Roads.java | 52 ++++++++++-- .../protomaps/basemap/layers/RoadsTest.java | 84 +++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java index 2cb111ae..8effaa00 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -171,7 +171,7 @@ public Roads(CountryCoder countryCoder) { // Overture properties to Protomaps kind mapping - private static final MultiExpression.Index> overtureKindsIndex = + private static final MultiExpression.Index> overtureRoadKindsIndex = MultiExpression.ofOrdered(List.of( // Everything is undefined at first @@ -210,6 +210,25 @@ public Roads(CountryCoder countryCoder) { )).index(); + private static final MultiExpression.Index> overtureRailKindsIndex = + MultiExpression.ofOrdered(List.of( + + // Everything is undefined at first + rule(use(KIND, UNDEFINED), use(KIND_DETAIL, UNDEFINED), use(HIGHWAY, UNDEFINED)), + + // Move Overture type=segment/subtype=rail class to kind_detail + rule(with("class"), use(KIND, "rail"), use(KIND_DETAIL, fromTag("class"))) + + )).index(); + + private static final MultiExpression.Index> overtureWaterKindsIndex = + MultiExpression.ofOrdered(List.of( + + // All Overture type=segment/subtype=water is going to be kind=ferry for now + rule(use(KIND, "ferry"), use(KIND_DETAIL, UNDEFINED), use(HIGHWAY, UNDEFINED)) + + )).index(); + // Protomaps kind/kind_detail to min_zoom mapping private static final MultiExpression.Index> highwayZoomsIndex = MultiExpression.ofOrdered(List.of( @@ -239,6 +258,15 @@ public Roads(CountryCoder countryCoder) { rule(with(KIND, "path"), with(KIND_DETAIL, "path", "cycleway", "bridleway", "footway", "steps"), use(MINZOOM, 13)), rule(with(KIND, "path"), with(KIND_DETAIL, "sidewalk", "crossing", "corridor"), use(MINZOOM, 14)), + // Non-roads + rule(with(KIND, "ferry"), use(MINZOOM, 11)), + rule(with(KIND, "rail"), use(MINZOOM, 11)), + rule( + with(KIND, "rail"), + with(KIND_DETAIL, "funicular", "light_rail", "monorail", "narrow_gauge", "subway", "tram", "unknown"), + use(MINZOOM, 14) + ), + // Freeways in the US are special rule( @@ -486,11 +514,18 @@ public void processOverture(SourceFeature sf, FeatureCollector features) { return; } - if (!"road".equals(sf.getString("subtype"))) { + List> kindMatches; + + if ("road".equals(sf.getString("subtype"))) { + kindMatches = overtureRoadKindsIndex.getMatches(sf); + } else if ("rail".equals(sf.getString("subtype"))) { + kindMatches = overtureRailKindsIndex.getMatches(sf); + } else if ("water".equals(sf.getString("subtype"))) { + kindMatches = overtureWaterKindsIndex.getMatches(sf); + } else { return; } - var kindMatches = overtureKindsIndex.getMatches(sf); if (kindMatches.isEmpty()) { return; } @@ -594,11 +629,12 @@ private void emitOvertureFeature(FeatureCollector features, SourceFeature sf, Li } /** - * Collect all split points from road_flags, access_restrictions, and level_rules + * Collect all split points from road_flags, rail_flags, access_restrictions, and level_rules */ private void collectOvertureSplitPoints(SourceFeature sf, List splitPoints) { List segmentObjects = Arrays.asList( sf.getTag("road_flags"), + sf.getTag("rail_flags"), sf.getTag("access_restrictions"), sf.getTag("level_rules") ); @@ -623,7 +659,7 @@ private void collectOvertureSplitPoints(SourceFeature sf, List splitPoin } } - private void extractOvertureSegmentRoadFlags(OvertureSegmentProperties props, Map flag, double start, + private void extractOvertureSegmentFlags(OvertureSegmentProperties props, Map flag, double start, double end) { Object valuesObj = flag.get("values"); Object betweenObj = flag.get("between"); @@ -716,15 +752,15 @@ private void extractOvertureSegmentLevels(OvertureSegmentProperties props, Map segmentList = (List) segmentsObj; for (Object segmentObj : segmentList) { if (segmentObj instanceof Map) { - if (segmentsKey == "road_flags") { + if (segmentsKey == "road_flags" || segmentsKey == "rail_flags") { @SuppressWarnings("unchecked") Map flag = (Map) segmentObj; - extractOvertureSegmentRoadFlags(props, flag, start, end); + extractOvertureSegmentFlags(props, flag, start, end); } else if (segmentsKey == "access_restrictions") { @SuppressWarnings("unchecked") Map restriction = (Map) segmentObj; diff --git a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java index 611f2c1f..a7c040f3 100644 --- a/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java +++ b/tiles/src/test/java/com/protomaps/basemap/layers/RoadsTest.java @@ -747,6 +747,59 @@ void kind_path_fromCyclewayClass() { ))); } + @Test + void kind_rail_fromStandardGauge() { + assertFeatures(15, + List.of(Map.of("kind", "rail", "kind_detail", "standard_gauge", "min_zoom", 12, "name", "UP Niles Subdivision")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "c6fe375e-046f-40d2-a872-0ed5506d13a0", // https://www.openstreetmap.org/way/318755220/history/15 + "theme", "transportation", + "type", "segment", + "subtype", "rail", + "class", "standard_gauge", + "names.primary", "UP Niles Subdivision" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_ferry_fromWaterway() { + assertFeatures(15, + List.of(Map.of("kind", "ferry", "min_zoom", 12, "name", "Oakland Jack London Square - San Francisco Ferry Building")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "7553c04c-b6fb-4ce5-b03b-a8966816c3f9", // https://www.openstreetmap.org/way/662437195/history/16 + "theme", "transportation", + "type", "segment", + "subtype", "water", + "names.primary", "Oakland Jack London Square - San Francisco Ferry Building" + )), + "overture", null, 0 + ))); + } + + @Test + void kind_rail_fromSubwayClass() { + assertFeatures(15, + List.of(Map.of("kind", "rail", "kind_detail", "subway", "min_zoom", 15, "name", "A-Line")), + process(SimpleFeature.create( + newLineString(0, 0, 1, 1), + new HashMap<>(Map.of( + "id", "d445b0b6-82a9-4e23-8944-478099e6f3fd", // https://www.openstreetmap.org/way/50970282/history/18 + "theme", "transportation", + "type", "segment", + "subtype", "rail", + "class", "subway", + "names.primary", "A-Line" + )), + "overture", null, 0 + ))); + } + @Test void kind_sidewalk_fromFootwayClass() { assertFeatures(15, @@ -841,6 +894,37 @@ void split_partialBridge_twoSections() { ), results); } + @Test + void split_partialTunnel_subwayRail() { + // Test: Rail with bridge and tunnel sections + // Geometry: (0,0) to (144,0) - Input treated as lat/lon, output transformed to world coords + // Expected: 4 output features + // Based on Overture d445b0b6-82a9-4e23-8944-478099e6f3fd (railway with tunnel sections) + // OSM ways: 32103864, 50970282, etc. + var results = process(SimpleFeature.create( + newLineString(0, 0, 144, 0), + new HashMap<>(Map.of( + "id", "d445b0b6-82a9-4e23-8944-478099e6f3fd", + "theme", "transportation", + "type", "segment", + "subtype", "rail", + "class", "subway", + "rail_flags", List.of( + Map.of("values", List.of("is_bridge"), "between", List.of(0.25, 0.5)), + Map.of("values", List.of("is_tunnel"), "between", List.of(0.75, 1.0)) + ) + )), + "overture", null, 0 + )); + + assertFeatures(15, List.of( + Map.of("kind", "rail", "_geom", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5))), + Map.of("kind", "rail", "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), "is_bridge", true), + Map.of("kind", "rail", "_geom", new TestUtils.NormGeometry(newLineString(0.7, 0.5, 0.8, 0.5))), + Map.of("kind", "rail", "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)), "is_tunnel", true) + ), results); + } + @Test void split_partialTunnel_fromStart() { // Test: Tunnel from start to middle From e1e83274428999657bf588c436d31960b4b36a13 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sat, 3 Jan 2026 16:18:05 -0800 Subject: [PATCH 73/76] Delete outdated implementation plan --- OVERTURE.md | 452 ---------------------------------------------------- 1 file changed, 452 deletions(-) delete mode 100644 OVERTURE.md diff --git a/OVERTURE.md b/OVERTURE.md deleted file mode 100644 index cbd9c56d..00000000 --- a/OVERTURE.md +++ /dev/null @@ -1,452 +0,0 @@ -# Plan for Adding Overture Maps Support to Basemap.java - -This document outlines how to expand Basemap.java to accept Overture Maps data from Geoparquet files as an alternative to OSM input. - -## Implementation Status - -**Phase 1 Complete (2025-12-29):** Basic infrastructure with land and water layers -- Added `--overture` CLI flag accepting single Parquet file path -- Implemented mutual exclusivity between `--overture` and `--area` -- Conditional data source loading (Overture Parquet vs OSM+GeoPackage) -- Natural Earth retained for low-zoom (0-5) rendering in both modes -- Implemented Water.java::processOverture() for theme=base/type=water -- Implemented Earth.java::processOverture() for theme=base/type=land -- Output filename derived from input Parquet file basename -- Successfully tested with lake-merritt-slice-overture.parquet - -**Remaining Work:** -- Implement processOverture() methods for other layers (Buildings, Places, Roads, Transit, Boundaries, Pois) -- Handle nested JSON fields properly (currently warnings for `names.primary` access) -- Support for additional Overture themes beyond base theme - -## Overview - -Add `--overture` argument to accept Overture Maps Geoparquet data as an alternative to `--area` (OSM data). These options are mutually exclusive. - -**Implementation Note:** The `--overture` argument accepts a **single Parquet file path**, not a directory. This differs from the original plan but matches the actual usage pattern in the Makefile. - -## Overture Data Structure - -Overture Maps data is organized in Geoparquet files, sometimes Hive-partitioned but not always: -``` -theme=buildings/type=building/*.parquet -theme=buildings/type=building_part/*.parquet -theme=places/type=place/*.parquet -theme=transportation/type=segment/*.parquet -theme=transportation/type=connector/*.parquet -theme=base/type=water/*.parquet -theme=base/type=land/*.parquet -theme=base/type=land_use/*.parquet -theme=base/type=land_cover/*.parquet -theme=base/type=infrastructure/*.parquet -theme=base/type=bathymetry/*.parquet -theme=divisions/type=division/*.parquet -theme=divisions/type=division_area/*.parquet -theme=divisions/type=division_boundary/*.parquet -theme=addresses/type=address/*.parquet -``` - -**Note:** The base theme includes `water`, `land`, and `land_cover` types which replace the three GeoPackage sources (`osm_water`, `osm_land`, `landcover`) used in OSM mode. - -## Code Changes Required - -### 1. Command-line Argument Handling - -**Location:** `Basemap.java:213-223` **IMPLEMENTED** - -Added `--overture` argument accepting a **single Parquet file path** (not directory). - -```java -String area = args.getString("area", "Geofabrik area name to download, or filename in data/sources/", ""); -String overtureFile = args.getString("overture", "Path to Overture Maps Parquet file", ""); - -// Validate mutual exclusivity -if (!area.isEmpty() && !overtureFile.isEmpty()) { - LOGGER.error("Error: Cannot specify both --area and --overture"); - System.exit(1); -} -if (area.isEmpty() && overtureFile.isEmpty()) { - area = "monaco"; // default -} -``` - -**Help text updated** at `Basemap.java:176` to document `--overture=` option. - -### 2. Data Source Configuration - -**Location:** `Basemap.java:225-246` **IMPLEMENTED** - -Added conditional `.addParquetSource()` call for single Overture file when `--overture` is specified. Natural Earth source is **always added** for low-zoom (0-5) rendering. - -```java -var planetiler = Planetiler.create(args) - .addNaturalEarthSource("ne", nePath, neUrl); // ALWAYS added for low zooms - -if (!overtureFile.isEmpty()) { - // Add Overture Parquet source - planetiler.addParquetSource("overture", - List.of(Path.of(overtureFile)), - true, // enable Hive partitioning - fields -> fields.get("id"), - fields -> fields.get("type") - ); -} else { - // Add OSM and GeoPackage sources - planetiler - .addOsmSource("osm", Path.of("data", "sources", area + ".osm.pbf"), "geofabrik:" + area) - .addShapefileSource("osm_water", sourcesDir.resolve("water-polygons-split-3857.zip"), - "https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip") - .addShapefileSource("osm_land", sourcesDir.resolve("land-polygons-split-3857.zip"), - "https://osmdata.openstreetmap.de/download/land-polygons-split-3857.zip") - .addGeoPackageSource("landcover", sourcesDir.resolve("daylight-landcover.gpkg"), - "https://r2-public.protomaps.com/datasets/daylight-landcover.gpkg"); -} -``` - -**Important Changes from Original Plan:** -- Natural Earth is **retained** in Overture mode for low-zoom rendering (zooms 0-5) -- Single Parquet file path instead of directory with glob pattern -- The three GeoPackage sources (`osm_water`, `osm_land`, `landcover`) are NOT added in Overture mode -- Overture's base theme `water` and `land` types provide zoom 6+ data -- Natural Earth + Overture together provide complete zoom 0-15 coverage - -**Key points:** -- Source name: `"overture"` -- Enable Hive partitioning: `true` -- ID extraction: `fields -> fields.get("id")` -- Layer extraction: `fields -> fields.get("type")` (gets "water", "land", etc. from Hive partition) -- Single file path wrapped in `List.of()` - -### 3. Source Handler Registration - -**Location:** `Basemap.java:88-104` (constructor) - -Add `registerSourceHandler("overture", ...)` calls for each layer. - -**IMPLEMENTED for Water and Earth layers:** - -```java -// Water - Natural Earth (zooms 0-5) + Overture base/water (zooms 6+) -if (layer.isEmpty() || layer.equals(Water.LAYER_NAME)) { - var water = new Water(); - registerHandler(water); - registerSourceHandler("osm", water::processOsm); - registerSourceHandler("osm_water", water::processPreparedOsm); // OSM GeoPackage - registerSourceHandler("ne", water::processNe); // Low-zoom (0-5) - registerSourceHandler("overture", water::processOverture); // IMPLEMENTED -} - -// Earth - Natural Earth (zooms 0-5) + Overture base/land (zooms 6+) -if (layer.isEmpty() || layer.equals(Earth.LAYER_NAME)) { - var earth = new Earth(); - registerHandler(earth); - registerSourceHandler("osm", earth::processOsm); - registerSourceHandler("osm_land", earth::processPreparedOsm); // OSM GeoPackage - registerSourceHandler("ne", earth::processNe); // Low-zoom (0-5) - registerSourceHandler("overture", earth::processOverture); // IMPLEMENTED -} -``` - -**TODO for remaining layers:** - -```java -if (layer.isEmpty() || layer.equals(Buildings.LAYER_NAME)) { - var buildings = new Buildings(); - registerHandler(buildings); - registerSourceHandler("osm", buildings::processOsm); - registerSourceHandler("overture", buildings::processOverture); // TODO -} - -if (layer.isEmpty() || layer.equals(Places.LAYER_NAME)) { - var place = new Places(countryCoder); - registerHandler(place); - registerSourceHandler("osm", place::processOsm); - registerSourceHandler("overture", place::processOverture); // TODO -} - -if (layer.isEmpty() || layer.equals(Roads.LAYER_NAME)) { - var roads = new Roads(countryCoder); - registerHandler(roads); - registerSourceHandler("osm", roads::processOsm); - registerSourceHandler("overture", roads::processOverture); // TODO -} - -// ... and others (Transit, Landuse, Landcover, Boundaries, Pois) -``` - -**Note:** The existing handlers for `osm_water`, `osm_land`, and `landcover` are kept because they're only called when those GeoPackage sources are added (OSM mode). When using `--overture`, those sources aren't added, so only the Natural Earth and Overture handlers are called. - -### 4. Layer Processing Methods - -**Location:** Individual layer files - -Add `processOverture()` methods in each layer class. Filter by `feature.getSourceLayer()` which comes from the Hive partition `type=` value. - -**IMPLEMENTED - Water.java::processOverture() (`Water.java:434-456`)** - -```java -public void processOverture(SourceFeature sf, FeatureCollector features) { - String sourceLayer = sf.getSourceLayer(); - - // Filter by source layer - Overture base theme water - if (!"water".equals(sourceLayer)) { - return; - } - - // Read Overture water attributes - String subtype = sf.getString("subtype"); // e.g., "lake", "river", "ocean" - String primaryName = sf.getString("names", "primary"); - - if (sf.canBePolygon()) { - features.polygon(LAYER_NAME) - .setAttr("kind", subtype != null ? subtype : "water") - .setAttr("name", primaryName) - .setAttr("sort_rank", 200) - .setPixelTolerance(Earth.PIXEL_TOLERANCE) - .setMinZoom(6) - .setMinPixelSize(1.0) - .setBufferPixels(8); - } -} -``` - -**IMPLEMENTED - Earth.java::processOverture() (`Earth.java:78-91`)** - -```java -public void processOverture(SourceFeature sf, FeatureCollector features) { - String sourceLayer = sf.getSourceLayer(); - - // Filter by source layer - Overture base theme land - if (!"land".equals(sourceLayer)) { - return; - } - - features.polygon(LAYER_NAME) - .setAttr("kind", "earth") - .setPixelTolerance(PIXEL_TOLERANCE) - .setMinZoom(6) - .setBufferPixels(8); -} -``` - -**TODO - Example for Buildings.java:** - -```java -public void processOverture(SourceFeature feature, FeatureCollector features) { - String sourceLayer = feature.getSourceLayer(); - - // Filter by source layer (from Hive partition type=building or type=building_part) - if (!"building".equals(sourceLayer) && !"building_part".equals(sourceLayer)) { - return; - } - - // Read Overture attributes - Double height = feature.getDouble("height"); - String roofColor = feature.getString("roof_color"); - - // Extract nested names (Overture uses structured names object) - String primaryName = feature.getString("names", "primary"); - - String kind = "building_part".equals(sourceLayer) ? "building_part" : "building"; - Integer minZoom = "building_part".equals(sourceLayer) ? 14 : 11; - - features.polygon(LAYER_NAME) - .setId(FeatureId.create(feature)) - .setAttr("kind", kind) - .setAttr("name", primaryName) - .setAttr("height", height) - .setAttr("roof_color", roofColor) - .setAttr("sort_rank", 400) - .setZoomRange(minZoom, 15); -} -``` - -**Example for Places.java:** - -```java -public void processOverture(SourceFeature feature, FeatureCollector features) { - String sourceLayer = feature.getSourceLayer(); - - // Filter by source layer - if (!"place".equals(sourceLayer)) { - return; - } - - // Read Overture structured data - String primaryName = feature.getString("names", "primary"); - String primaryCategory = feature.getString("categories", "primary"); - - // Map Overture categories to basemap place types - String placeType = mapOvertureCategory(primaryCategory); - - features.point(LAYER_NAME) - .setAttr("name", primaryName) - .setAttr("place_type", placeType) - .setMinZoom(calculateMinZoom(primaryCategory)); -} -``` - -**Example for Water.java:** - -```java -public void processOverture(SourceFeature feature, FeatureCollector features) { - String sourceLayer = feature.getSourceLayer(); - - // Filter by source layer - Overture base theme water - if (!"water".equals(sourceLayer)) { - return; - } - - // Read Overture water attributes - String subtype = feature.getString("subtype"); // e.g., "lake", "river", "ocean" - String primaryName = feature.getString("names", "primary"); - - features.polygon(LAYER_NAME) - .setAttr("kind", subtype) - .setAttr("name", primaryName) - .setZoomRange(0, 15); -} -``` - -**Example for Earth.java (land polygons):** - -```java -public void processOverture(SourceFeature feature, FeatureCollector features) { - String sourceLayer = feature.getSourceLayer(); - - // Filter by source layer - Overture base theme land - if (!"land".equals(sourceLayer)) { - return; - } - - features.polygon(LAYER_NAME) - .setAttr("kind", "land") - .setZoomRange(0, 15); -} -``` - -**Key differences from OSM processing:** -- Use `feature.getSourceLayer()` for routing, NOT tag checking -- Source layer comes from Hive partition path (e.g., `type=building`) -- Access nested JSON properties: `feature.getString("names", "primary")` -- Overture uses `subtype` instead of OSM tag combinations -- No `hasTag()` checks needed - source layer determines feature type - -### 5. Overture Schema Mapping - -Overture Maps uses a different schema than OSM: - -**Overture structure:** - -Overture features are primarily organized by "theme" and "type", these are the most important things to know about them: -- "id" and "geometry" always exist -- "level_rules" designates rendering z-order sections -- "names"."primary" holds the most important name -- theme=transportation and type=segment includes highways, railways, and waterways - - Highways have subtype=road, OSM-like "motorway", "residential", etc. class values, and "road_flags" to designate e.g. bridge or tunnel sections - - Railways have subtype=rail, "standard_gauge", "subway", etc. class values, and "rail_flags" to designate e.g. bridge or tunnel sections - - Waterways have subtype=water -- theme=base includes land, land_cover, and water - - General land has type=land - - Bodies of water have type=water, "subtype" with type of water body, and "class" with further description - - Areas of land have type=land_cover and "subtype" with type of land cover -- theme=buildings includes representations of buildings and building parts - - All buildings and building parts can have height in meters - - Whole buildings have type=building, and optionally boolean "has_parts" - - Parts of buildings have type=building_part and can show higher-detail parts (like towers vs. bases) instead a generic whole building - -Complete Overture schema reference is at https://docs.overturemaps.org/schema/reference/ - -**Mapping examples:** -- OSM `building=yes` → Overture `theme=buildings` + `type=building` -- OSM `amenity=restaurant` → Overture `theme=places` + `type=place` + `categories.primary=restaurant` -- OSM `highway=primary` → Overture `theme=transportation` + `type=segment` + `subtype=road` - -### 6. Output Filename - -**Location:** `Basemap.java:292-304` **IMPLEMENTED** - -Output filename is derived from the input Parquet file basename: - -```java -String outputName; -if (!overtureFile.isEmpty()) { - // Use base filename from input Parquet file - String filename = Path.of(overtureFile).getFileName().toString(); - // Remove .parquet extension if present - if (filename.endsWith(".parquet")) { - outputName = filename.substring(0, filename.length() - ".parquet".length()); - } else { - outputName = filename; - } -} else { - outputName = area; -} -planetiler.setOutput(Path.of(outputName + ".pmtiles")); -``` - -**Example:** Input `data/sources/lake-merritt-slice-overture.parquet` → Output `lake-merritt-slice-overture.pmtiles` - -## Summary of Implementation Status - -| File | Status | Changes | -|------|--------|---------| -| `Basemap.java` | **Complete** | Added `--overture` argument, validation, conditional `.addParquetSource()` calls, source handler registration for Water/Earth, output filename logic | -| `Water.java` | **Complete** | Added `processOverture()` method for theme=base/type=water | -| `Earth.java` | **Complete** | Added `processOverture()` method for theme=base/type=land | -| `Buildings.java` | **TODO** | Need to add `processOverture()` method for theme=buildings | -| `Places.java` | **TODO** | Need to add `processOverture()` method for theme=places | -| `Roads.java` | **TODO** | Need to add `processOverture()` method for theme=transportation/type=segment | -| `Transit.java` | **TODO** | Need to add `processOverture()` method for theme=transportation | -| `Landuse.java` | **Complete** | Added `processOverture()` method for theme=base/type=land_cover|land_use | -| `Landcover.java` | **TODO** | Need to add `processOverture()` method for theme=base/type=land_cover | -| `Boundaries.java` | **TODO** | Need to add `processOverture()` method for theme=divisions | -| `Pois.java` | **TODO** | Need to add `processOverture()` method for theme=places | - -## Planetiler Library Support - -Planetiler provides robust built-in support: - -1. **ParquetReader** - handles file reading, Hive partitioning, WKB/WKT geometry parsing -2. **`Planetiler.addParquetSource()`** - adds Geoparquet to processing pipeline -3. **`SourceFeature` interface** - common abstraction for all source types -4. **Bounding box filtering** - built into ParquetReader for efficient spatial queries -5. **Multi-file support** - reads multiple partitioned files efficiently - -## Implementation Phases - -### Phase 1: Core Infrastructure -1. Add `--overture` argument and validation -2. Add conditional `.addParquetSource()` calls for all Overture themes -3. Update help text - -### Phase 2: Layer Implementation -1. Implement `processOverture()` for Buildings layer (proof of concept) -2. Test with Overture buildings data -3. Implement `processOverture()` for remaining layers -4. Create Overture→basemap schema mapping for each layer - -### Phase 3: Polish -1. Test with complete Overture dataset -2. Add integration tests -3. Profile performance and optimize if needed -4. Document Overture schema mappings - -## Key Implementation Notes - -1. **Routing mechanism:** All features from all Overture sources go to all registered handlers. Each handler filters by checking `feature.getSourceLayer()` value. - -2. **Hive partitions:** The `type=` value in partition paths becomes the source layer name. Example: files in `theme=buildings/type=building/` have source layer `"building"`. - -3. **Multiple files:** Each `.addParquetSource()` call processes many partitioned files via `Glob.of().resolve().find()`. - -4. **Source name:** All Overture sources use the same name `"overture"` because routing is by source layer, not source name. - -5. **No remote URL support:** Parquet sources don't support URLs directly. Users must download Overture data first or we must implement download logic separately. - -## Resources - -- [Overture Maps Schema Documentation](https://docs.overturemaps.org/schema/) -- [Overture Maps Data Downloads](https://overturemaps.org/download/) -- [Overture Maps GitHub](https://github.com/OvertureMaps) -- Planetiler example: `planetiler-examples/.../overture/OvertureBasemap.java` From 03a5d922ee4d31d29da03f7a08f62b03994c6069 Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Sun, 4 Jan 2026 18:20:35 -0800 Subject: [PATCH 74/76] Added basic Overture landcover --- .../java/com/protomaps/basemap/Basemap.java | 1 + .../protomaps/basemap/layers/Landcover.java | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index 29d7a223..9624d83e 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -61,6 +61,7 @@ public Basemap(QrankDb qrankDb, CountryCoder countryCoder, Clip clip, registerHandler(landcover); registerSourceHandler("landcover", landcover::processLandcover); registerSourceHandler("ne", landcover::processNe); + registerSourceHandler("overture", landcover::processOverture); } if (layer.isEmpty() || layer.equals(Places.LAYER_NAME)) { diff --git a/tiles/src/main/java/com/protomaps/basemap/layers/Landcover.java b/tiles/src/main/java/com/protomaps/basemap/layers/Landcover.java index dbc27f03..4eb80086 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Landcover.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Landcover.java @@ -57,6 +57,43 @@ public void processLandcover(SourceFeature sf, FeatureCollector features) { .setPixelTolerance(Earth.PIXEL_TOLERANCE); } + public void processOverture(SourceFeature sf, FeatureCollector features) { + String type = sf.getString("type"); + String kind = sf.getString("subtype"); + + // Filter by type field - Overture base theme land + if (!"land_cover".equals(type)) { + return; + } + + // Map base_layers.ts from https://docs.overturemaps.org/schema/reference/base/land_cover/ + if (kind == "grass") { + kind = "grassland"; + } else if (kind == "barren") { + kind = "barren"; + } else if (kind == "urban") { + kind = "urban_area"; + } else if (kind == "crop") { + kind = "farmland"; + } else if (kind == "snow") { + kind = "glacier"; + } else if (kind == "shrub") { + kind = "scrub"; + } else if (kind == "forest" || kind == "mangrove" || kind == "moss" || kind == "wetland") { + kind = "forest"; + } + + // polygons are disjoint and non-overlapping, but order them in archive in consistent way + Integer sortKey = sortKeyMapping.getOrDefault(kind, 6); + + features.polygon(LAYER_NAME) + .setAttr("kind", kind) + .setZoomRange(0, 7) + .setSortKey(sortKey) + .setMinPixelSize(1.0) + .setPixelTolerance(Earth.PIXEL_TOLERANCE); + } + public static final String LAYER_NAME = "landcover"; @Override From 2c5f7252c2361d9812de6074255bd1602ea24f8f Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 5 Jan 2026 17:07:32 -0800 Subject: [PATCH 75/76] Replaced Python scripts with https://gist.github.com/migurski/8765492ef831457d7c21c20f4e454e1b --- tiles/feature-finder.py | 414 ---------------------------------------- tiles/get-overture.py | 139 -------------- tiles/requirements.txt | 3 - 3 files changed, 556 deletions(-) delete mode 100755 tiles/feature-finder.py delete mode 100755 tiles/get-overture.py delete mode 100644 tiles/requirements.txt diff --git a/tiles/feature-finder.py b/tiles/feature-finder.py deleted file mode 100755 index ded25f68..00000000 --- a/tiles/feature-finder.py +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env python3 -""" -Feature finder script to match OSM and Overture features based on tags and spatial proximity. - -Usage examples: - feature-finder.py aerodrome airport --name OAK - feature-finder.py national_park --name Alcatraz -""" -from __future__ import annotations - -import argparse -import sys -import random -import pathlib -import logging -import multiprocessing -import math - -import duckdb -import geopandas -import shapely.wkb -import shapely.geometry - - -DISTANCE_THRESHOLD_KM = 2.0 -DISTANCE_THRESHOLD_METERS = DISTANCE_THRESHOLD_KM * 1000 - -# Common OSM tag columns to check -OSM_TAG_COLUMNS = [ - 'aeroway', 'amenity', 'leisure', 'tourism', 'landuse', 'natural', - 'shop', 'historic', 'building', 'highway', 'railway', 'waterway', - 'boundary', 'place', 'man_made', 'craft', 'office', 'sport' -] - - -def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description='Find matching features between OSM and Overture data' - ) - parser.add_argument( - 'tags', - nargs='+', - help='Tags to search for (e.g., aerodrome airport national_park)' - ) - parser.add_argument( - '--name', - help='Optional name filter (case-insensitive substring match)' - ) - parser.add_argument( - '--10x', - dest='max_results', - action='store_const', - const=30, - default=3, - help='10x the result count' - ) - return parser.parse_args() - - -def find_osm_features(pbf_path: str, layer_name: str, tags: list[str], name_filter: str | None = None) -> list[dict]: - """ - Query OSM PBF file for features matching any of the given tags. - - Returns list of dicts with: osm_id, layer, name, matched_tag, matched_value, geometry - """ - features = [] - - try: - # Load layer into GeoPandas - gdf = geopandas.read_file(pbf_path, layer=layer_name) - except Exception as e: - logging.debug(f"Could not read layer {layer_name} from {pbf_path}: {e}") - return features - - if gdf.empty: - return features - - # Apply name filter first if provided - if name_filter: - if 'name' in gdf.columns: - gdf = gdf[gdf['name'].notna() & gdf['name'].str.contains(name_filter, case=False, na=False)] - else: - return features - - if gdf.empty: - return features - - # Check which OSM tag columns are available - available_tag_cols = [col for col in OSM_TAG_COLUMNS if col in gdf.columns] - - # For each row, check if any of the tag columns matches any of our search tags - for idx, row in gdf.iterrows(): - matched_tag = None - matched_value = None - - # Check each available tag column - for col in available_tag_cols: - val = row[col] - if val and (val in tags or any(f'"{tag}"' in val for tag in tags)): - matched_tag = col - matched_value = val - break - - if not matched_tag: - continue - - # Get OSM ID - osm_id = row.get('osm_id') - if not osm_id: - osm_id = row.get('osm_way_id') - - # Get geometry - geom = row['geometry'] - if geom is None or geom.is_empty: - continue - - features.append({ - 'osm_id': osm_id, - 'layer': layer_name, - 'name': row.get('name'), - 'matched_tag': matched_tag, - 'matched_value': matched_value, - 'geometry': geom, - 'other_tags': row.get('other_tags') - }) - - return features - - -def find_overture_features(parquet_path: str, tags: list[str], name_filter: str | None = None) -> list[dict]: - """ - Query Overture parquet file for features matching any of the given tags. - - Returns list of dicts with: id, name, basic_category, categories_primary, geometry - """ - conn = duckdb.connect(':memory:') - - # Build WHERE clause for categories - category_conditions = [] - for tag in tags: - category_conditions.append(f"categories.primary = '{tag}'") - category_conditions.append(f"basic_category = '{tag}'") - category_conditions.append(f"subtype = '{tag}'") - category_conditions.append(f"class = '{tag}'") - - where_clause = ' OR '.join(category_conditions) - - if name_filter: - where_clause = f"({where_clause}) AND (names.primary LIKE '%{name_filter}%')" - - query = f""" - SELECT - id, - names.primary as name, - theme, - type, - subtype, - class, - basic_category, - categories.primary as categories_primary, - geometry, - confidence - FROM read_parquet('{parquet_path}') - WHERE {where_clause} - """ - - try: - result = conn.execute(query).fetchall() - features = [] - - for row in result: - overture_id, name, theme, type_, subtype, class_, basic_cat, cat_primary, geom_blob, confidence = row - - # Convert geometry blob to shapely - if geom_blob: - shapely_geom = shapely.wkb.loads(bytes(geom_blob)) - - features.append({ - 'id': overture_id, - 'name': name, - 'theme': theme, - 'type': type_, - 'subtype': subtype, - 'class': class_, - 'basic_category': basic_cat, - 'categories_primary': cat_primary, - 'geometry': shapely_geom, - 'confidence': confidence - }) - - return features - - except Exception as e: - logging.error(f"Error querying {parquet_path}: {e}") - return [] - finally: - conn.close() - - -def get_utm_zone_epsg(longitude: float) -> str: - """ - Calculate appropriate UTM zone EPSG code based on longitude. - - UTM zones are 6 degrees wide, numbered 1-60 starting at -180 degrees. - Northern hemisphere uses EPSG:326xx, Southern hemisphere uses EPSG:327xx. - For simplicity, assuming Northern hemisphere (add latitude check if needed). - """ - zone_number = int((longitude + 180) / 6) + 1 - # Assuming Northern hemisphere - epsg_code = 32600 + zone_number - return f'EPSG:{epsg_code}' - - -def calculate_distance_meters(geom1, geom2) -> float: - """Calculate distance between two geometries in meters using centroids and appropriate UTM projection.""" - # Create GeoDataFrames with geometries - gdf1 = geopandas.GeoDataFrame([1], geometry=[geom1], crs='EPSG:4326') - gdf2 = geopandas.GeoDataFrame([1], geometry=[geom2], crs='EPSG:4326') - - # Calculate average longitude to determine UTM zone - centroid1_wgs84 = geom1.centroid - centroid2_wgs84 = geom2.centroid - avg_longitude = (centroid1_wgs84.x + centroid2_wgs84.x) / 2 - - # Get appropriate UTM zone - utm_crs = get_utm_zone_epsg(avg_longitude) - - # Project to UTM for accurate distance calculation - gdf1_proj = gdf1.to_crs(utm_crs) - gdf2_proj = gdf2.to_crs(utm_crs) - - # Get centroids and calculate distance - centroid1 = gdf1_proj.geometry.iloc[0].centroid - centroid2 = gdf2_proj.geometry.iloc[0].centroid - - return centroid1.distance(centroid2) - - -def find_matches(osm_features: list[dict], overture_features: list[dict]) -> list[tuple[dict, dict, float]]: - """ - Find OSM-Overture pairs within distance threshold. - - Returns list of tuples: (osm_feature, overture_feature, distance_meters) - """ - if not osm_features or not overture_features: - return [] - - # Create GeoDataFrames from the feature lists - osm_gdf = geopandas.GeoDataFrame([ - { - 'osm_id': f['osm_id'], - 'layer': f['layer'], - 'name': f['name'], - 'matched_tag': f['matched_tag'], - 'matched_value': f['matched_value'], - 'other_tags': f['other_tags'], - 'geometry': f['geometry'], - 'index': i - } - for i, f in enumerate(osm_features) - ], crs='EPSG:4326') - - overture_gdf = geopandas.GeoDataFrame([ - { - 'id': f['id'], - 'name': f['name'], - 'theme': f['theme'], - 'type': f['type'], - 'subtype': f['subtype'], - 'class': f['class'], - 'basic_category': f['basic_category'], - 'categories_primary': f['categories_primary'], - 'confidence': f['confidence'], - 'geometry': f['geometry'], - 'index': i - } - for i, f in enumerate(overture_features) - ], crs='EPSG:4326') - - # Project both to EPSG:3857 (Web Mercator) for distance calculations - osm_gdf_proj = osm_gdf.to_crs('EPSG:3857') - overture_gdf_proj = overture_gdf.to_crs('EPSG:3857') - - # Use spatial join with a buffer to find potential matches - # Buffer by 1.5x the threshold for a conservative search area - buffer_distance = DISTANCE_THRESHOLD_METERS * 2 - osm_buffered = osm_gdf_proj.copy() - osm_buffered['geometry'] = osm_buffered.geometry.buffer(buffer_distance) - - # Spatial join to find candidates within buffer distance - joined = osm_buffered.sjoin(overture_gdf_proj, how='inner', predicate='intersects') - - # Now calculate precise distances only for candidates - matches = [] - for _, row in joined.iterrows(): - osm_idx = row['index_left'] - ov_idx = row['index_right'] - - # Get the original (unbuffered) geometries - osm_geom = osm_gdf_proj.iloc[osm_idx].geometry - ov_geom = overture_gdf_proj.iloc[ov_idx].geometry - - # Calculate distance between centroids in EPSG:3857 - distance_m = osm_geom.centroid.distance(ov_geom.centroid) - - if distance_m <= DISTANCE_THRESHOLD_METERS: - # Reconstruct feature dicts from original lists - osm_feat = osm_features[osm_idx] - ov_feat = overture_features[ov_idx] - matches.append((osm_feat, ov_feat, distance_m)) - - return matches - - -def format_output(matches: list[tuple[dict, dict, float]]) -> str: - """Format matched features for display.""" - if not matches: - return "No matches found." - - output_lines = [] - output_lines.append(f"Found {len(matches)} match(es) within {DISTANCE_THRESHOLD_KM} km:\n") - - for i, (osm, ov, dist_m) in enumerate(matches, 1): - dist_km = dist_m / 1000 - output_lines.append(f"Match {i}:") - output_lines.append(f" OSM:") - output_lines.append(f" ID: {osm['osm_id']}") - output_lines.append(f" Layer: {osm['layer']}") - output_lines.append(f" Name: {osm['name']}") - output_lines.append(f" Tag: {osm['matched_tag']}={osm['matched_value']}") - - output_lines.append(f" Overture:") - output_lines.append(f" ID: {ov['id']}") - output_lines.append(f" Name: {ov['name']}") - output_lines.append(f" Theme: {ov['theme']}") - output_lines.append(f" Type: {ov['type']}") - output_lines.append(f" Subtype: {ov['subtype']}") - output_lines.append(f" Class: {ov['class']}") - output_lines.append(f" Basic Category: {ov['basic_category']}") - output_lines.append(f" Primary Category: {ov['categories_primary']}") - if ov['confidence'] is not None: - output_lines.append(f" Confidence: {ov['confidence']:.2f}") - - output_lines.append(f" Distance: {dist_km:.2f} km") - output_lines.append("") - - return '\n'.join(output_lines) - - -def main(): - # Setup logging - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' - ) - - args = parse_args() - - # Find all transect files - data_dir = pathlib.Path('data/sources') - osm_files = list(data_dir.glob('*-Transect.osm.pbf')) - parquet_files = list(data_dir.glob('*-Transect.parquet')) - - if not osm_files or not parquet_files: - logging.error("No transect files found in data/sources/") - sys.exit(1) - - # Collect all features from all transect files in parallel - all_osm_features = [] - all_overture_features = [] - - # Process OSM files in parallel using multiprocessing - with multiprocessing.Pool() as pool: - osm_args = [ - (str(osm_file), layer_name, args.tags, args.name) - for osm_file in osm_files - for layer_name in ['points', 'lines', 'multipolygons'] - ] - osm_results = pool.starmap(find_osm_features, osm_args) - for osm_features in osm_results: - all_osm_features.extend(osm_features) - - # Process Overture files in parallel using multiprocessing - with multiprocessing.Pool() as pool: - ov_args = [(str(parquet_file), args.tags, args.name) for parquet_file in parquet_files] - ov_results = pool.starmap(find_overture_features, ov_args) - for ov_features in ov_results: - all_overture_features.extend(ov_features) - - logging.info(f"Found {len(all_osm_features)} OSM features and {len(all_overture_features)} Overture features") - - # Find matches - matches = find_matches(all_osm_features, all_overture_features) - - # Select up to args.max_results with weighted random selection (prefer closer matches) - if len(matches) > args.max_results: - # Calculate weights as inverse of distance (closer = higher weight) - weights = [(ov.get('confidence') or 1.0) / (dist_m + 1) for osm, ov, dist_m in matches] - try: - weights = [ - (min(len(osm['name']), len(ov['name'])) / max(len(osm['name']), len(ov['name']))) - * weight for weight in weights - ] - except: - pass - matches = random.choices(matches, weights=weights, k=args.max_results) - - # Display results - print(format_output(matches)) - - -if __name__ == '__main__': - main() diff --git a/tiles/get-overture.py b/tiles/get-overture.py deleted file mode 100755 index 94b03873..00000000 --- a/tiles/get-overture.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -""" -Download Overture Maps data for a given GeoJSON bounding box. -Uses DuckDB to efficiently query S3-hosted Parquet files with spatial partitioning. -""" - -import argparse -import json -import sys -import duckdb - - -def get_bbox_from_geojson(geojson_path): - """Extract bounding box from GeoJSON file.""" - with open(geojson_path, 'r') as f: - data = json.load(f) - - # Collect all coordinates - coords = [] - for feature in data.get('features', []): - geom = feature.get('geometry', {}) - geom_type = geom.get('type') - geom_coords = geom.get('coordinates', []) - - if geom_type == 'Polygon': - # Flatten polygon coordinates - for ring in geom_coords: - coords.extend(ring) - elif geom_type == 'MultiPolygon': - for polygon in geom_coords: - for ring in polygon: - coords.extend(ring) - elif geom_type == 'Point': - coords.append(geom_coords) - elif geom_type == 'LineString': - coords.extend(geom_coords) - - if not coords: - raise ValueError("No coordinates found in GeoJSON") - - # Calculate bounding box - lons = [c[0] for c in coords] - lats = [c[1] for c in coords] - - return { - 'xmin': min(lons), - 'ymin': min(lats), - 'xmax': max(lons), - 'ymax': max(lats) - } - - -def query_overture_data(bbox, output_path): - """ - Query Overture Maps data using DuckDB with spatial partition filtering. - """ - print(f"Bounding box: {bbox}") - print("Connecting to DuckDB and configuring S3 access...") - - # Create DuckDB connection - con = duckdb.connect() - - # Install and load spatial extension - con.execute("INSTALL spatial;") - con.execute("LOAD spatial;") - - # Install and load httpfs for S3 access - con.execute("INSTALL httpfs;") - con.execute("LOAD httpfs;") - - # Configure for anonymous S3 access - con.execute("SET s3_region='us-west-2';") - con.execute("SET s3_url_style='path';") - - # Overture base path - all themes, will filter by theme in WHERE clause - base_path = "s3://overturemaps-us-west-2/release/2025-12-17.0" - - print("\nQuerying Overture transportation and places data with bbox filtering...") - print("Using Hive partitioning to filter themes efficiently.") - - # Query with bbox and theme filtering - # The theme is a Hive partition, so filtering on it should be efficient - query = f""" - COPY ( - SELECT * - FROM read_parquet('{base_path}/**/*.parquet', - hive_partitioning=1, - filename=1, - union_by_name=1) - WHERE theme IN ('transportation', 'places', 'base', 'buildings', 'divisions') - AND bbox.xmin <= {bbox['xmax']} - AND bbox.xmax >= {bbox['xmin']} - AND bbox.ymin <= {bbox['ymax']} - AND bbox.ymax >= {bbox['ymin']} - ) TO '{output_path}' (FORMAT PARQUET); - """ - - print("\nExecuting query...") - print("(This may take a few minutes depending on partition overlap)") - - try: - con.execute(query) - print(f"\n✓ Successfully wrote data to: {output_path}") - - # Get some stats - result = con.execute(f"SELECT COUNT(*) as count FROM read_parquet('{output_path}')").fetchone() - print(f"✓ Total features retrieved: {result[0]:,}") - - except Exception as e: - print(f"\n✗ Error during query: {e}", file=sys.stderr) - raise - finally: - con.close() - - -def main(): - parser = argparse.ArgumentParser( - description='Download Overture Maps data for a GeoJSON bounding box' - ) - parser.add_argument('geojson', help='Input GeoJSON file defining the area') - parser.add_argument('output', help='Output Parquet file path') - - args = parser.parse_args() - - try: - # Extract bounding box from GeoJSON - print(f"Reading GeoJSON from: {args.geojson}") - bbox = get_bbox_from_geojson(args.geojson) - - # Query Overture data - query_overture_data(bbox, args.output) - - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/tiles/requirements.txt b/tiles/requirements.txt deleted file mode 100644 index 38f189d7..00000000 --- a/tiles/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -duckdb==1.4.3 -GDAL==3.2.2 -geopandas==1.0.1 From b80cb91e199f06697320270aec08fa168cde331f Mon Sep 17 00:00:00 2001 From: Michal Migurski Date: Mon, 5 Jan 2026 17:09:16 -0800 Subject: [PATCH 76/76] Bumped version to 4.14 --- CHANGELOG.md | 4 ++++ tiles/src/main/java/com/protomaps/basemap/Basemap.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b42d9a..b270f84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +Tiles 4.14.0 +------ +- Add Overture data source support [#541] + Tiles 4.13.6 ------ - Translate POI min_zoom= assignments to MultiExpression rules [#539] diff --git a/tiles/src/main/java/com/protomaps/basemap/Basemap.java b/tiles/src/main/java/com/protomaps/basemap/Basemap.java index 9624d83e..22c58667 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -127,7 +127,7 @@ public String description() { @Override public String version() { - return "4.13.6"; + return "4.14.0"; } @Override