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);
+ }
+}