diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7be090..b270f84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +Tiles 4.14.0 +------ +- Add Overture data source support [#541] + +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..22c58667 100644 --- a/tiles/src/main/java/com/protomaps/basemap/Basemap.java +++ b/tiles/src/main/java/com/protomaps/basemap/Basemap.java @@ -46,12 +46,14 @@ 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)) { var landuse = new Landuse(); registerHandler(landuse); registerSourceHandler("osm", landuse::processOsm); + registerSourceHandler("overture", landuse::processOverture); } if (layer.isEmpty() || layer.equals(Landcover.LAYER_NAME)) { @@ -59,24 +61,28 @@ 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)) { var place = new Places(countryCoder); registerHandler(place); registerSourceHandler("osm", place::processOsm); + registerSourceHandler("overture", place::processOverture); } if (layer.isEmpty() || layer.equals(Pois.LAYER_NAME)) { var poi = new Pois(qrankDb); registerHandler(poi); registerSourceHandler("osm", poi::processOsm); + registerSourceHandler("overture", poi::processOverture); } if (layer.isEmpty() || layer.equals(Roads.LAYER_NAME)) { var roads = new Roads(countryCoder); registerHandler(roads); registerSourceHandler("osm", roads::processOsm); + registerSourceHandler("overture", roads::processOverture); } if (layer.isEmpty() || layer.equals(Transit.LAYER_NAME)) { @@ -91,6 +97,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 +107,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) { @@ -119,7 +127,7 @@ public String description() { @Override public String version() { - return "4.13.5"; + return "4.14.0"; } @Override @@ -173,6 +181,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 +218,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 +294,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/feature/Matcher.java b/tiles/src/main/java/com/protomaps/basemap/feature/Matcher.java index 24291d41..68f4d456 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/geometry/Linear.java b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java new file mode 100644 index 00000000..1d6ea7b1 --- /dev/null +++ b/tiles/src/main/java/com/protomaps/basemap/geometry/Linear.java @@ -0,0 +1,109 @@ +package com.protomaps.basemap.geometry; + +import java.util.*; +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 { + + /** + * 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. 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 + * @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<>(); + + // 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); + + double startLength = startFrac * totalLength; + double endLength = endFrac * totalLength; + + Geometry segment = indexedLine.extractLine(startLength, endLength); + if (segment instanceof LineString ls && ls.getNumPoints() >= 2) { + segments.add(ls); + } + } + + 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; + } + + /** + * 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/Buildings.java b/tiles/src/main/java/com/protomaps/basemap/layers/Buildings.java index 9259c7a4..ba803d57 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")) && !"building_part".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) { 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/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 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 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..9dd9f571 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,163 +44,182 @@ public Places(CountryCoder countryCoder) { public static final String LAYER_NAME = "places"; - private static final MultiExpression.Index> index = MultiExpression.ofOrdered(List.of( + // 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> osmKindsIndex = 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) + 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") ), rule( - with("place", "suburb"), - use("kind", "neighbourhood"), - use("minZoom", 11), - use("maxZoom", 15), - use("kindRank", 9) + with("subtype", "locality"), + with("class", "town"), + use(KIND, "locality"), + use(KIND_DETAIL, "town") ), rule( - with("place", "quarter"), - use("kind", "macrohood"), - use("minZoom", 10), - use("maxZoom", 15), - use("kindRank", 10) + with("subtype", "macrohood"), + use(KIND, "macrohood") ), rule( - with("place", "neighbourhood"), - use("kind", "neighbourhood"), - use("minZoom", 12), - use("maxZoom", 15), - 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() { @@ -280,53 +300,43 @@ 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 } - var matches = index.getMatches(sf); + var matches = osmKindsIndex.getMatches(sf); if (matches.isEmpty()) { 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, 0); + 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; + + 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; } } @@ -382,6 +392,88 @@ 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, UNDEFINED); + String kindDetail = getString(sf, matches, KIND_DETAIL, ""); + + if (kind == UNDEFINED) { + return; + } + + 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); + + // 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; + + for (int i = 0; i < POP_BREAKS.length; i++) { + if (population >= POP_BREAKS[i]) { + populationRank = i + 1; + } + } + + 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/main/java/com/protomaps/basemap/layers/Pois.java b/tiles/src/main/java/com/protomaps/basemap/layers/Pois.java index 40f981ae..ebb4a964 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,65 @@ 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( + // OSM tags to Protomaps kind/kind_detail mapping + + private static final MultiExpression.Index> osmKindsIndex = 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 +123,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 +145,468 @@ 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(); + + // 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")), + rule(with("basic_category", "place_of_learning", "middle_school"), use(KIND, "school")) + + )).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 + rule(use(MINZOOM, 15)), + + // Promote important point categories to earlier zooms + + rule( + Expression.or( + 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(KIND, "aerodrome"), + with(KIND, "library", "post_office", "townhall"), + with(KIND, "golf_course", "marina", "stadium"), + with(KIND, "peak") + ), + use(MINZOOM, 13) + ), + rule(with(KIND, "hospital"), use(MINZOOM, 12)), + rule(with(KIND, "national_park"), use(MINZOOM, 11)), + 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(KIND, "bus_stop"), use(MINZOOM, 17)), + rule( + Expression.or( + 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", + "animal", "roller_coaster", "summer_toboggan", "carousel", "amusement_ride", + "maze"), + 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(KIND, "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(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(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) + ) + + )).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"); + + // Protomaps kind/kind_detail to min_zoom mapping for named polygons + + 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 = osmKindsIndex.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); - } + // 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; + } - 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); - } + // 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); + } - OsmNames.setOsmNames(pointFeature, sf, 0); + public void processOverture(SourceFeature sf, FeatureCollector features) { + // Filter by type field - Overture transportation theme + if (!"places".equals(sf.getString("theme"))) { + return; + } - // 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 (!"place".equals(sf.getString("type"))) { + return; + } - 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); - } + // Map the Protomaps KIND classification to incoming tags + var kindMatches = overtureKindsIndex.getMatches(sf); - // 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); + String kind = getString(sf, kindMatches, KIND, UNDEFINED); + String kindDetail = getString(sf, kindMatches, KIND_DETAIL, UNDEFINED); + Integer minZoom; - // 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); - } + // Quickly eliminate any features with non-matching tags + if (kind.equals(UNDEFINED)) + return; + + // 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"); + + 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", minZoom + 1) + // + .setBufferPixels(8) + .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 6c2d991d..8effaa00 100644 --- a/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java +++ b/tiles/src/main/java/com/protomaps/basemap/layers/Roads.java @@ -13,15 +13,19 @@ import com.onthegomap.planetiler.ForwardingProfile; import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.expression.MultiExpression; +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; 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 java.util.*; +import org.locationtech.jts.geom.LineString; @SuppressWarnings("java:S1192") public class Roads implements ForwardingProfile.LayerPostProcessor, ForwardingProfile.OsmRelationPreprocessor { @@ -34,182 +38,59 @@ public Roads(CountryCoder countryCoder) { public static final String LAYER_NAME = "roads"; - private static final MultiExpression.Index> indexHighways = MultiExpression.of(List.of( - rule( - with(), - use("kindDetail", fromTag("highway")) - ), - rule( - with("service"), - use("kindDetail", 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) - ), - rule( - with(""" - highway - residential - unclassified - road - raceway - """), - use("kind", "minor_road"), - use("minZoom", 12), - use("minZoomShieldText", 12), - use("minZoomNames", 14) - ), + // 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 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> osmKindsIndex = MultiExpression.of(List.of( rule( - with("highway", "service"), - use("kind", "minor_road"), - use("kindDetail", "service"), - use("minZoom", 13), - 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("kindDetail", "service"), - use("minZoom", 14), - use("minZoomShieldText", 12), - use("minZoomNames", 14), + use(KIND, "minor_road"), + use(KIND_DETAIL, "service"), use("service", fromTag("service")) ), + rule(with("highway", "pedestrian", "track", "corridor"), use(KIND, "path")), rule( - with(""" - highway - pedestrian - track - corridor - """), - use("kind", "path"), - use("minZoom", 12), - use("minZoomShieldText", 12), - use("minZoomNames", 14) - ), - rule( - with(""" - highway - path - cycleway - bridleway - footway - steps - """), - use("kind", "path"), - use("minZoom", 13), - 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("kindDetail", fromTag("footway")), - use("minZoom", 14), - 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("kindDetail", fromTag("footway")), - use("minZoom", 14), - 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) + use(KIND, "path"), + use(KIND_DETAIL, "corridor") // fromTag("footway") fails tests ) )).index(); @@ -288,6 +169,124 @@ public Roads(CountryCoder countryCoder) { ) )).index(); + // Overture properties to Protomaps kind mapping + + private static final MultiExpression.Index> overtureRoadKindsIndex = + 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> 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( + + // Everything is ~14 at first + rule(use(MINZOOM, 14), use(MINZOOM_NAME, 14), use(MINZOOM_SHIELD, 12)), + + // 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"), 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( + 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(); + @Override public String name() { return LAYER_NAME; @@ -335,22 +334,36 @@ 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 } - var matches = indexHighways.getMatches(sf); + var matches = osmKindsIndex.getMatches(sf); if (matches.isEmpty()) { return; } - String kind = getString(sf, matches, "kind", "other"); - String kindDetail = getString(sf, matches, "kindDetail", ""); - int minZoom = getInteger(sf, matches, "minZoom", 14); - int minZoomShieldText = getInteger(sf, matches, "minZoomShieldText", 14); - int minZoomNames = getInteger(sf, matches, "minZoomNames", 14); + String kind = getString(sf, matches, KIND, "other"); + String kindDetail = getString(sf, matches, KIND_DETAIL, ""); + int minZoom; + int minZoomShieldText; + int minZoomNames; + + // 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); + 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); @@ -366,8 +379,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) @@ -472,18 +485,311 @@ public void processOsm(SourceFeature sf, FeatureCollector features) { } + /** + * Represents properties that can apply to a segment of a road + */ + private static class OvertureSegmentProperties { + boolean isBridge; + boolean isTunnel; + boolean isOneway; + boolean isLink; + Integer level; + + OvertureSegmentProperties() { + 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"))) { + return; + } + + if (!"segment".equals(sf.getString("type"))) { + return; + } + + 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; + } + + if (kindMatches.isEmpty()) { + return; + } + + 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); + + // Collect all split points from all property arrays + List splitPoints = new ArrayList<>(); + collectOvertureSplitPoints(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()) { + emitOvertureFeature(features, sf, originalLine, kind, kindDetail, name, highway, minZoom, + extractOvertureSegmentProperties(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); + OvertureSegmentProperties props = extractOvertureSegmentProperties(sf, seg.start, seg.end); + + emitOvertureFeature(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 emitOvertureFeature(FeatureCollector features, SourceFeature sf, LineString geometry, + String kind, String kindDetail, String name, String highway, int minZoom, + OvertureSegmentProperties props) { + + // 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) + .setAttr("name", name) + .setAttr("min_zoom", minZoom + 1) + // temporary attribute that gets removed in the post-process step + .setAttr(HIGHWAY, highway) + .setAttr("sort_rank", 400) + .setMinPixelSize(0) + .setPixelTolerance(0) + .setZoomRange(Math.min(minZoom, 15), 15); + + if (props.isOneway) { + feat.setAttrWithMinzoom("oneway", "yes", 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, 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") + ); + + 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()); + } + } + } + } + } + } + } + + private void extractOvertureSegmentFlags(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(); + } + } + + // 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; + } + } + } + + private void extractOvertureSegmentRestrictions(OvertureSegmentProperties props, Map restriction, + double start, double end) { + String accessType = (String) restriction.get("access_type"); + if (!"denied".equals(accessType)) { + return; + } + + 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(); + } + } + + if (Linear.overlaps(start, end, rangeStart, rangeEnd)) { + props.level = levelValue; + } + } + } + + /** + * 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(); + + for (String segmentsKey : List.of("road_flags", "rail_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" || segmentsKey == "rail_flags") { + @SuppressWarnings("unchecked") Map flag = (Map) segmentObj; + extractOvertureSegmentFlags(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); + } + } + } + } + } + + return props; + } + @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. 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, 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); 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..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; @@ -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())); + } + } 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..13a2215c --- /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); + } +} 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..f8a7bd70 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", 9, + "population", 433031, + "population_rank", 10 + )), + 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", 10, + "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", "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 + ))); + } +} 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..fa8ce414 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,243 @@ 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", // https://www.openstreetmap.org/way/295140461/history/15 + "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", "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.95 + )), + "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 + ))); + } + + @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 + ))); + } + + @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 + ))); + } + + @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 + ))); + } + + @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..a7c040f3 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; @@ -467,3 +468,636 @@ void testRailwayService(String service) { } } + + +class RoadsOvertureTest extends LayerTest { + + @Test + void kind_highway_fromMotorwayClass() { + assertFeatures(15, + 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( + "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", + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) + )), + "overture", null, 0 + ))); + } + + @Test + void kind_highwayLink_fromMotorwayClass() { + assertFeatures(15, + 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( + "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(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) + )), + "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_majorLink_fromTrunkClass() { + assertFeatures(15, + 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( + "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(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) + )), + "overture", null, 0 + ))); + } + + @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_majorLink_fromPrimaryClass() { + assertFeatures(15, + 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( + "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(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) + )), + "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_majorLink_fromSecondaryClass() { + assertFeatures(15, + 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( + "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(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) + )), + "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_majorLink_fromTertiaryClass() { + assertFeatures(15, + 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( + "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(Map.of("values", List.of("is_link"))), + "access_restrictions", List.of( + Map.of("access_type", "denied", "when", Map.of("heading", "backward")) + ) + )), + "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", "kind_detail", "service", "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_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, + 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 + ))); + } + + // 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 (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 + var results = process(SimpleFeature.create( + newLineString(0, 0, 144, 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", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) + ), + Map.of( + "kind", "major_road", + "kind_detail", "primary", + "_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.8, 0.5, 0.9, 0.5)) + ) + ), results); + } + + @Test + void split_partialBridge_twoSections() { + // Test: Two separate bridge sections + // 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, 180, 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.2, 0.4)), + 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", 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_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 + // 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, 72, 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", 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.6, 0.5, 0.7, 0.5)) + ) + ), results); + } + + @Test + void split_partialLevel_elevatedSection() { + // Test: Elevated/bridge section with level=1 + // 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, 144, 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", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) + ), + Map.of( + "kind", "highway", + "kind_detail", "motorway", + "_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.8, 0.5, 0.9, 0.5)) + ) + ), results); + } + + @Test + void split_partialOneway_secondHalf() { + // Test: Oneway restriction on second half of line + // 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, 72, 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", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) + ), + Map.of( + "kind", "major_road", + "kind_detail", "secondary", + "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), + "oneway", "yes" + ) + ), results); + } + + @Test + void split_overlapping_bridgeAndOneway() { + // Test: Overlapping bridge and oneway restrictions + // 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, 144, 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", new TestUtils.NormGeometry(newLineString(0.5, 0.5, 0.6, 0.5)) + ), + Map.of( + "kind", "major_road", + "_geom", new TestUtils.NormGeometry(newLineString(0.6, 0.5, 0.7, 0.5)), + "is_bridge", true + ), + Map.of( + "kind", "major_road", + "_geom", new TestUtils.NormGeometry(newLineString(0.7, 0.5, 0.8, 0.5)), + "is_bridge", true, + "oneway", "yes" + ), + Map.of( + "kind", "major_road", + "_geom", new TestUtils.NormGeometry(newLineString(0.8, 0.5, 0.9, 0.5)), + "oneway", "yes" + ) + ), results); + } +}