Date: Mon, 20 Nov 2023 06:15:52 -0500
Subject: [PATCH 3/3] Add `setAttrWithMinSize` to feature API (#725)
---
.../planetiler/FeatureCollector.java | 66 +++++++++++++----
.../onthegomap/planetiler/geo/GeoUtils.java | 14 ++++
.../planetiler/PlanetilerTests.java | 70 +++++++++++++++++++
.../planetiler/geo/GeoUtilsTest.java | 26 +++++++
4 files changed, 163 insertions(+), 13 deletions(-)
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java
index 680b393faa..f1e76d8a27 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java
@@ -200,6 +200,28 @@ public Feature innermostPoint(String layer) {
return innermostPoint(layer, 0.1);
}
+ /** Returns the minimum zoom level at which this feature is at least {@code pixelSize} pixels large. */
+ public int getMinZoomForPixelSize(double pixelSize) {
+ try {
+ return GeoUtils.minZoomForPixelSize(source.size(), pixelSize);
+ } catch (GeometryException e) {
+ e.log(stats, "min_zoom_for_size_failure", "Error getting min zoom for size from geometry " + source.id());
+ return config.maxzoom();
+ }
+ }
+
+
+ /** Returns the actual pixel size of the source feature at {@code zoom} (length if line, sqrt(area) if polygon). */
+ public double getPixelSizeAtZoom(int zoom) {
+ try {
+ return source.size() * (256 << zoom);
+ } catch (GeometryException e) {
+ e.log(stats, "source_feature_pixel_size_at_zoom_failure",
+ "Error getting source feature pixel size at zoom from geometry " + source.id());
+ return 0;
+ }
+ }
+
/**
* Creates new feature collector instances for each source feature that we encounter.
*/
@@ -703,6 +725,29 @@ public Feature setAttrWithMinzoom(String key, Object value, int minzoom) {
return setAttr(key, ZoomFunction.minZoom(minzoom, value));
}
+ /**
+ * Sets the value for {@code key} only at zoom levels where the feature is at least {@code minPixelSize} pixels in
+ * size.
+ */
+ public Feature setAttrWithMinSize(String key, Object value, double minPixelSize) {
+ return setAttrWithMinzoom(key, value, getMinZoomForPixelSize(minPixelSize));
+ }
+
+ /**
+ * Sets the value for {@code key} so that it always shows when {@code zoom_level >= minZoomToShowAlways} but only
+ * shows when {@code minZoomIfBigEnough <= zoom_level < minZoomToShowAlways} when it is at least
+ * {@code minPixelSize} pixels in size.
+ *
+ * If you need more flexibility, use {@link #getMinZoomForPixelSize(double)} directly, or create a
+ * {@link ZoomFunction} that calculates {@link #getPixelSizeAtZoom(int)} and applies a custom threshold based on the
+ * zoom level.
+ */
+ public Feature setAttrWithMinSize(String key, Object value, double minPixelSize, int minZoomIfBigEnough,
+ int minZoomToShowAlways) {
+ return setAttrWithMinzoom(key, value,
+ Math.clamp(getMinZoomForPixelSize(minPixelSize), minZoomIfBigEnough, minZoomToShowAlways));
+ }
+
/**
* Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature at or above
* {@code minzoom}.
@@ -736,20 +781,20 @@ public Feature putAttrs(Map attrs) {
}
/**
- * Sets a special attribute key that the renderer will use to store the number of points in the simplified geometry
+ * Returns the attribute key that the renderer should use to store the number of points in the simplified geometry
* before slicing it into tiles.
*/
- public Feature setNumPointsAttr(String numPointsAttr) {
- this.numPointsAttr = numPointsAttr;
- return this;
+ public String getNumPointsAttr() {
+ return numPointsAttr;
}
/**
- * Returns the attribute key that the renderer should use to store the number of points in the simplified geometry
+ * Sets a special attribute key that the renderer will use to store the number of points in the simplified geometry
* before slicing it into tiles.
*/
- public String getNumPointsAttr() {
- return numPointsAttr;
+ public Feature setNumPointsAttr(String numPointsAttr) {
+ this.numPointsAttr = numPointsAttr;
+ return this;
}
@Override
@@ -763,12 +808,7 @@ public String toString() {
/** Returns the actual pixel size of the source feature at {@code zoom} (length if line, sqrt(area) if polygon). */
public double getSourceFeaturePixelSizeAtZoom(int zoom) {
- try {
- return source.size() * (256 << zoom);
- } catch (GeometryException e) {
- e.log(stats, "point_get_size_failure", "Error getting min size for point from geometry " + source.id());
- return 0;
- }
+ return getPixelSizeAtZoom(zoom);
}
}
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java
index 0809f4de4b..f6d0aceb7b 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java
@@ -1,6 +1,7 @@
package com.onthegomap.planetiler.geo;
import com.onthegomap.planetiler.collection.LongLongMap;
+import com.onthegomap.planetiler.config.PlanetilerConfig;
import com.onthegomap.planetiler.stats.Stats;
import java.util.ArrayList;
import java.util.List;
@@ -51,6 +52,7 @@ public class GeoUtils {
public static final double WORLD_CIRCUMFERENCE_METERS = Math.PI * 2 * WORLD_RADIUS_METERS;
private static final double RADIANS_PER_DEGREE = Math.PI / 180;
private static final double DEGREES_PER_RADIAN = 180 / Math.PI;
+ private static final double LOG2 = Math.log(2);
/**
* Transform web mercator coordinates where top-left corner of the planet is (0,0) and bottom-right is (1,1) to
* latitude/longitude coordinates.
@@ -534,6 +536,18 @@ public static Geometry combine(Geometry... geometries) {
JTS_FACTORY.createGeometryCollection(innerGeometries.toArray(Geometry[]::new));
}
+ /**
+ * For a feature of size {@code worldGeometrySize} (where 1=full planet), determine the minimum zoom level at which
+ * the feature appears at least {@code minPixelSize} pixels large.
+ *
+ * The result will be clamped to the range [0, {@link PlanetilerConfig#MAX_MAXZOOM}].
+ */
+ public static int minZoomForPixelSize(double worldGeometrySize, double minPixelSize) {
+ double worldPixels = worldGeometrySize * 256;
+ return Math.clamp((int) Math.ceil(Math.log(minPixelSize / worldPixels) / LOG2), 0,
+ PlanetilerConfig.MAX_MAXZOOM);
+ }
+
/** Helper class to sort polygons by area of their outer shell. */
private record PolyAndArea(Polygon poly, double area) implements Comparable {
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
index f44ada3556..d1224780f3 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
@@ -87,6 +87,8 @@ class PlanetilerTests {
private static final double Z13_WIDTH = 1d / Z13_TILES;
private static final int Z12_TILES = 1 << 12;
private static final double Z12_WIDTH = 1d / Z12_TILES;
+ private static final int Z11_TILES = 1 << 11;
+ private static final double Z11_WIDTH = 1d / Z11_TILES;
private static final int Z4_TILES = 1 << 4;
private static final Polygon WORLD_POLYGON = newPolygon(
worldCoordinateList(
@@ -2434,6 +2436,74 @@ void testCentroidWithLineMinSize() throws Exception {
), results.tiles);
}
+ @Test
+ void testAttributeMinSizeLine() throws Exception {
+ List points = z14CoordinatePixelList(0, 4, 40, 4);
+
+ var results = runWithReaderFeatures(
+ Map.of("threads", "1"),
+ List.of(
+ newReaderFeature(newLineString(points), Map.of())
+ ),
+ (in, features) -> features.line("layer")
+ .setZoomRange(11, 14)
+ .setBufferPixels(0)
+ .setAttrWithMinSize("a", "1", 10)
+ .setAttrWithMinSize("b", "2", 20)
+ .setAttrWithMinSize("c", "3", 40)
+ .setAttrWithMinSize("d", "4", 40, 0, 13) // should show up at z13 and above
+ );
+
+ assertEquals(Map.ofEntries(
+ newTileEntry(Z11_TILES / 2, Z11_TILES / 2, 11, List.of(
+ feature(newLineString(0, 0.5, 5, 0.5), Map.of())
+ )),
+ newTileEntry(Z12_TILES / 2, Z12_TILES / 2, 12, List.of(
+ feature(newLineString(0, 1, 10, 1), Map.of("a", "1"))
+ )),
+ newTileEntry(Z13_TILES / 2, Z13_TILES / 2, 13, List.of(
+ feature(newLineString(0, 2, 20, 2), Map.of("a", "1", "b", "2", "d", "4"))
+ )),
+ newTileEntry(Z14_TILES / 2, Z14_TILES / 2, 14, List.of(
+ feature(newLineString(0, 4, 40, 4), Map.of("a", "1", "b", "2", "c", "3", "d", "4"))
+ ))
+ ), results.tiles);
+ }
+
+ @Test
+ void testAttributeMinSizePoint() throws Exception {
+ List points = z14CoordinatePixelList(0, 4, 40, 4);
+
+ var results = runWithReaderFeatures(
+ Map.of("threads", "1"),
+ List.of(
+ newReaderFeature(newLineString(points), Map.of())
+ ),
+ (in, features) -> features.centroid("layer")
+ .setZoomRange(11, 14)
+ .setBufferPixels(0)
+ .setAttrWithMinSize("a", "1", 10)
+ .setAttrWithMinSize("b", "2", 20)
+ .setAttrWithMinSize("c", "3", 40)
+ .setAttrWithMinSize("d", "4", 40, 0, 13) // should show up at z13 and above
+ );
+
+ assertEquals(Map.ofEntries(
+ newTileEntry(Z11_TILES / 2, Z11_TILES / 2, 11, List.of(
+ feature(newPoint(2.5, 0.5), Map.of())
+ )),
+ newTileEntry(Z12_TILES / 2, Z12_TILES / 2, 12, List.of(
+ feature(newPoint(5, 1), Map.of("a", "1"))
+ )),
+ newTileEntry(Z13_TILES / 2, Z13_TILES / 2, 13, List.of(
+ feature(newPoint(10, 2), Map.of("a", "1", "b", "2", "d", "4"))
+ )),
+ newTileEntry(Z14_TILES / 2, Z14_TILES / 2, 14, List.of(
+ feature(newPoint(20, 4), Map.of("a", "1", "b", "2", "c", "3", "d", "4"))
+ ))
+ ), results.tiles);
+ }
+
@Test
void testBoundFiltersFill() throws Exception {
var polyResultz8 = runForBoundsTest(8, 8, "polygon", TestUtils.pathToResource("bottomrightearth.poly").toString());
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java
index f1b528f9ac..dead314178 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java
@@ -447,4 +447,30 @@ void testSnapAndFixIssue546_3() throws GeometryException, ParseException {
assertTrue(result.isValid());
assertFalse(result.contains(point));
}
+
+ @ParameterizedTest
+ @CsvSource({
+ "1,0,0",
+ "1,10,0",
+ "1,255,0",
+
+ "0.5,0,0",
+ "0.5,128,0",
+ "0.5,129,1",
+ "0.5,256,1",
+
+ "0.25,0,0",
+ "0.25,128,1",
+ "0.25,129,2",
+ "0.25,256,2",
+ })
+ void minZoomForPixelSize(double worldGeometrySize, double minPixelSize, int expectedMinZoom) {
+ assertEquals(expectedMinZoom, GeoUtils.minZoomForPixelSize(worldGeometrySize, minPixelSize));
+ }
+
+ @Test
+ void minZoomForPixelSizesAtZ9_10() {
+ assertEquals(10, GeoUtils.minZoomForPixelSize(3.1 / (256 << 10), 3));
+ assertEquals(9, GeoUtils.minZoomForPixelSize(6.1 / (256 << 10), 3));
+ }
}