diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7be0909..94b42d9a8 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 6a4bc3406..74abfc687 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 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 24291d414..68f4d4565 100644 --- a/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java +++ b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java @@ -2,6 +2,7 @@ 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.reader.SourceFeature; import java.util.ArrayList; @@ -9,26 +10,27 @@ 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. - * + * *

* 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();
@@ -42,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. @@ -71,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. @@ -88,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("""
@@ -122,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. */ @@ -149,6 +151,82 @@ 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 inclusive. The upper bound, if provided, is exclusive. + *

+ * + *

+ * 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. + * @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 lower bound integer and upper bound double + */ + public static Expression withinRange(String tagName, Integer lowerBound, Double upperBound) { + 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, lowerBound.longValue(), upperBound.longValue()); + } + + /** + * 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 atLeast(String tagName, Double lowerBound) { + return new WithinRangeExpression(tagName, lowerBound.longValue(), null); + } + + /** + * 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); + } + } + public static Expression withPoint() { return Expression.matchGeometryType(GeometryType.POINT); } @@ -177,15 +255,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();
@@ -195,7 +273,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. */ @@ -277,4 +355,66 @@ 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 SourceFeature { + 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) { + 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); + } + + @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(); + } + + @Override + public boolean hasRelationInfo() { + return delegate.hasRelationInfo(); + } + } + } 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 40f981ae0..1a07dc899 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java @@ -1,11 +1,14 @@ 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; 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.withinRange; import static com.protomaps.basemap.feature.Matcher.without; import com.onthegomap.planetiler.FeatureCollector; @@ -17,12 +20,12 @@ 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.List; import java.util.Map; - @SuppressWarnings("java:S1192") public class Pois implements ForwardingProfile.LayerPostProcessor { @@ -43,33 +46,63 @@ 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 = "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"; + 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", "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.ofOrdered(List.of( - // Everything is "other"/"" at first - rule(use("kind", "other"), use("kindDetail", "")), + // Everything is undefined at first + rule(use(KIND, UNDEFINED), use(KIND_DETAIL, 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 - 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 @@ -88,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), @@ -110,470 +143,386 @@ 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)), + + // Promote important point categories to earlier zooms + + 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") + ), + use(MINZOOM, 14) + ), + rule( + Expression.or( + with("aeroway", "aerodrome"), + with("amenity", "library", "post_office", "townhall"), + with("leisure", "golf_course", "marina", "stadium"), + with("natural", "peak") + ), + 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 + + // Demote some unimportant point categories to very late zooms + + rule(with("highway", "bus_stop"), use(MINZOOM, 17)), + rule( + 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) + ), + + // Demote some unnamed point categories to very late zooms + + rule( + 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(); + + // 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 is zoom=15 at first + 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(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"), 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. + // 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), 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 + // 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, atLeast(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, atLeast(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, 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 + + 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, atLeast(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, atLeast(WAYAREA, 2e7), use(MINZOOM, 7)) + + )).index(); + @Override public String name() { return LAYER_NAME; } - // ~= pow((sqrt(70k) / (40m / 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 processOsm(SourceFeature sf, FeatureCollector features) { - var matches = index.getMatches(sf); - if (matches.isEmpty()) { - return; - } + // ~= 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); - String kind = getString(sf, matches, "kind", "undefined"); - String kindDetail = getString(sf, matches, "kindDetail", "undefined"); - - 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")))) { - Integer minZoom = 15; - long qrank = 0; - - String wikidata = sf.getString("wikidata"); - if (wikidata != null) { - qrank = qrankDb.get(wikidata); - } + private boolean isNamedPolygon(SourceFeature sf) { + return sf.canBePolygon() && sf.hasTag("name") && sf.getString("name") != null; + } - if (sf.hasTag("aeroway", "aerodrome")) { - minZoom = 13; + public Matcher.SourceFeatureWithComputedTags computeExtraTags(SourceFeature sf, String kind) { + Double wayArea = 0.0; + Double height = 0.0; + boolean hasNamedPolygon = isNamedPolygon(sf); - // 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; + if (hasNamedPolygon) { + try { + wayArea = sf.worldGeometry().getEnvelopeInternal().getArea() / WORLD_AREA_FOR_70_SQUARE_METERS; + } catch (GeometryException e) { + e.log("Exception in POI way calculation"); } - - // 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; + if (sf.hasTag("height")) { + Double parsed = parseDoubleOrNull(sf.getString("height")); + if (parsed != null) { + height = parsed; } } + } - // 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"); - } + Map computedTags; - double height = 0.0; - if (sf.hasTag("height")) { - Double parsed = parseDoubleOrNull(sf.getString("height")); - if (parsed != null) { - height = parsed; - } - } + 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); + } - // 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")) { - 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") || - 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; - } + return new Matcher.SourceFeatureWithComputedTags(sf, computedTags); + } - // 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; - } - } - } - } 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; - } + public void processOsm(SourceFeature sf, FeatureCollector features) { + boolean hasNamedPolygon = isNamedPolygon(sf); - // 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") || - 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; - } + // We only do POI display for points and named polygons + if (!sf.isPoint() && !hasNamedPolygon) + return; - // 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")) { - 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) { - 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; - } + // Map the Protomaps KIND classification to incoming tags + var kindMatches = kindsIndex.getMatches(sf); - // 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; - } + // Output feature and its basic values to assign + FeatureCollector.Feature outputFeature; + String kind = getString(sf, kindMatches, KIND, UNDEFINED); + String kindDetail = getString(sf, kindMatches, KIND_DETAIL, UNDEFINED); + Integer minZoom; - // 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; - } - } + // Quickly eliminate any features with non-matching tags + if (kind.equals(UNDEFINED)) + return; - // Discount tall self storage buildings - if (kind.equals("storage_rental")) { - minZoom = 14; + // 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 = hasNamedPolygon ? namedPolygonZoomsIndex.getMatches(sf2) : pointZoomsIndex.getMatches(sf2); + if (zoomMatches.isEmpty()) + return; + + // Initial minZoom + minZoom = getInteger(sf2, zoomMatches, MINZOOM, 99); + + // Adjusted minZoom + if (hasNamedPolygon) { + // 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; } - - // Discount tall university buildings, require a related university landuse AOI - if (kind.equals("university")) { - minZoom = 13; + } 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; + } + // 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; } } - - 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); - - // 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); - - // Core Tilezen schema properties - if (!kindDetail.isEmpty()) { - pointFeature.setAttr("kind_detail", kindDetail); - } - - 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); - - // 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); } } + + // Assign outputFeature + if (hasNamedPolygon) { + 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; + } + + // 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 + .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.equals(UNDEFINED)) + 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 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 799769549..81e17ffed 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; @@ -14,11 +15,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 +704,207 @@ void testGetBooleanFromTag() { assertEquals(true, getBoolean(sf, matches, "a", false)); } + @Test + void testWithinRange() { + 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 + ); + assertTrue(expression.evaluate(sf, List.of())); + + // Value at upper bound (10 <= 10) + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("population", "10"), + "osm", + null, + 0 + ); + assertFalse(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 testAtLeast() { + var expression = atLeast("population", 5); + + // 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 + ); + assertTrue(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 + ); + assertTrue(expression.evaluate(sf, List.of())); + + // Value at upper bound + sf = SimpleFeature.create( + newPoint(0, 0), + Map.of("temperature", "5"), + "osm", + null, + 0 + ); + assertFalse(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 + ); + assertTrue(expression.evaluate(sf, List.of())); + } + }