From 7e9e34286781d8c60e81a87eac3f6e25d4f6b472 Mon Sep 17 00:00:00 2001 From: Mike Barry Date: Fri, 25 Jun 2021 07:06:55 -0400 Subject: [PATCH] landcover, landuse, park --- .../com/onthegomap/flatmap/Arguments.java | 3 +- .../onthegomap/flatmap/FeatureCollector.java | 10 + .../com/onthegomap/flatmap/FeatureMerge.java | 19 +- .../onthegomap/flatmap/VectorTileEncoder.java | 4 + .../flatmap/render/FeatureRenderer.java | 13 +- .../onthegomap/flatmap/FeatureMergeTest.java | 1 + .../com/onthegomap/flatmap/FlatMapTest.java | 39 ++++ .../com/onthegomap/flatmap/TestUtils.java | 1 + .../openmaptiles/OpenMapTilesProfile.java | 7 +- .../flatmap/openmaptiles/Utils.java | 8 + .../flatmap/openmaptiles/layers/Building.java | 2 +- .../openmaptiles/layers/Landcover.java | 121 ++++++++++- .../flatmap/openmaptiles/layers/Landuse.java | 67 +++++- .../flatmap/openmaptiles/layers/Park.java | 98 ++++++++- .../layers/AbstractLayerTest.java | 2 +- .../openmaptiles/layers/LandcoverTest.java | 202 ++++++++++++++++++ .../openmaptiles/layers/LanduseTest.java | 80 +++++++ .../flatmap/openmaptiles/layers/ParkTest.java | 145 +++++++++++++ 18 files changed, 797 insertions(+), 25 deletions(-) create mode 100644 openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LandcoverTest.java create mode 100644 openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LanduseTest.java create mode 100644 openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/ParkTest.java diff --git a/core/src/main/java/com/onthegomap/flatmap/Arguments.java b/core/src/main/java/com/onthegomap/flatmap/Arguments.java index 2311af32..973a24a0 100644 --- a/core/src/main/java/com/onthegomap/flatmap/Arguments.java +++ b/core/src/main/java/com/onthegomap/flatmap/Arguments.java @@ -106,7 +106,8 @@ public class Arguments { public List get(String arg, String description, String[] defaultValue) { String value = getArg(arg, String.join(",", defaultValue)); - List results = List.of(value.split("[\\s,]+")); + List results = Stream.of(value.split("[\\s,]+")) + .filter(c -> !c.isBlank()).toList(); LOGGER.debug(description + ": " + value); return results; } diff --git a/core/src/main/java/com/onthegomap/flatmap/FeatureCollector.java b/core/src/main/java/com/onthegomap/flatmap/FeatureCollector.java index 030a0cd2..7d548ecb 100644 --- a/core/src/main/java/com/onthegomap/flatmap/FeatureCollector.java +++ b/core/src/main/java/com/onthegomap/flatmap/FeatureCollector.java @@ -134,6 +134,7 @@ public class FeatureCollector implements Iterable { private double defaultPixelTolerance = 0.1d; private double pixelToleranceAtMaxZoom = 256d / 4096; private ZoomFunction pixelTolerance = null; + private String numPointsAttr = null; private Feature(String layer, Geometry geom, long sourceId) { this.layer = layer; @@ -349,5 +350,14 @@ public class FeatureCollector implements Iterable { attrs.putAll(names); return this; } + + public Feature setNumPointsAttr(String numPointsAttr) { + this.numPointsAttr = numPointsAttr; + return this; + } + + public String getNumPointsAttr() { + return numPointsAttr; + } } } diff --git a/core/src/main/java/com/onthegomap/flatmap/FeatureMerge.java b/core/src/main/java/com/onthegomap/flatmap/FeatureMerge.java index fb21ce6c..939f3701 100644 --- a/core/src/main/java/com/onthegomap/flatmap/FeatureMerge.java +++ b/core/src/main/java/com/onthegomap/flatmap/FeatureMerge.java @@ -138,6 +138,17 @@ public class FeatureMerge { public static List mergePolygons(List features, double minArea, double minDist, double buffer) throws GeometryException { + return mergePolygons( + features, + minArea, + 0, + minDist, + buffer + ); + } + + public static List mergePolygons(List features, double minArea, + double minHoleArea, double minDist, double buffer) throws GeometryException { List result = new ArrayList<>(features.size()); Collection> groupedByAttrs = groupByAttrs(features, result, GeometryType.POLYGON); for (List groupedFeatures : groupedByAttrs) { @@ -167,7 +178,7 @@ public class FeatureMerge { } } // TODO VW simplify? - extractPolygons(merged, outPolygons, minArea); + extractPolygons(merged, outPolygons, minArea, minHoleArea); } if (!outPolygons.isEmpty()) { Geometry combined = GeoUtils.combinePolygons(outPolygons); @@ -177,11 +188,11 @@ public class FeatureMerge { return result; } - private static void extractPolygons(Geometry geom, List result, double minArea) { + private static void extractPolygons(Geometry geom, List result, double minArea, double minHoleArea) { if (geom instanceof Polygon poly) { if (Area.ofRing(poly.getExteriorRing().getCoordinateSequence()) > minArea) { int innerRings = poly.getNumInteriorRing(); - if (innerRings > 0) { + if (minHoleArea > 0 && innerRings > 0) { List rings = new ArrayList<>(innerRings); for (int i = 0; i < innerRings; i++) { LinearRing innerRing = poly.getInteriorRingN(i); @@ -197,7 +208,7 @@ public class FeatureMerge { } } else if (geom instanceof GeometryCollection) { for (int i = 0; i < geom.getNumGeometries(); i++) { - extractPolygons(geom.getGeometryN(i), result, minArea); + extractPolygons(geom.getGeometryN(i), result, minArea, minHoleArea); } } } diff --git a/core/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java b/core/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java index a76c2307..15f61fe4 100644 --- a/core/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java +++ b/core/src/main/java/com/onthegomap/flatmap/VectorTileEncoder.java @@ -446,6 +446,10 @@ public class VectorTileEncoder { public static final long NO_GROUP = Long.MIN_VALUE; + public boolean hasGroup() { + return group != NO_GROUP; + } + public Feature copyWithNewGeometry(Geometry newGeometry) { return new Feature( layer, diff --git a/core/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java b/core/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java index 8b281e07..86777d39 100644 --- a/core/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java +++ b/core/src/main/java/com/onthegomap/flatmap/render/FeatureRenderer.java @@ -10,6 +10,7 @@ import com.onthegomap.flatmap.geo.GeoUtils; import com.onthegomap.flatmap.geo.GeometryException; import com.onthegomap.flatmap.geo.TileCoord; import com.onthegomap.flatmap.monitoring.Stats; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -143,6 +144,7 @@ public class FeatureRenderer implements Consumer { long id = idGen.incrementAndGet(); boolean area = input instanceof Polygonal; double worldLength = (area || input.getNumGeometries() > 1) ? 0 : input.getLength(); + String numPointsAttr = feature.getNumPointsAttr(); for (int z = feature.getMaxZoom(); z >= feature.getMinZoom(); z--) { double scale = 1 << z; double tolerance = feature.getPixelTolerance(z) / 256d; @@ -164,14 +166,19 @@ public class FeatureRenderer implements Consumer { double buffer = feature.getBufferPixelsAtZoom(z) / 256; TileExtents.ForZoom extents = config.extents().getForZoom(z); TiledGeometry sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents, feature.sourceId()); - writeTileFeatures(z, id, feature, sliced); + Map attrs = feature.getAttrsAtZoom(sliced.zoomLevel()); + if (numPointsAttr != null) { + attrs = new HashMap<>(attrs); + attrs.put(numPointsAttr, geom.getNumPoints()); + } + writeTileFeatures(z, id, feature, sliced, attrs); } stats.processedElement(area ? "polygon" : "line", feature.getLayer()); } - private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature feature, TiledGeometry sliced) { - Map attrs = feature.getAttrsAtZoom(sliced.zoomLevel()); + private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature feature, TiledGeometry sliced, + Map attrs) { int emitted = 0; for (var entry : sliced.getTileData()) { TileCoord tile = entry.getKey(); diff --git a/core/src/test/java/com/onthegomap/flatmap/FeatureMergeTest.java b/core/src/test/java/com/onthegomap/flatmap/FeatureMergeTest.java index b215dd98..35783945 100644 --- a/core/src/test/java/com/onthegomap/flatmap/FeatureMergeTest.java +++ b/core/src/test/java/com/onthegomap/flatmap/FeatureMergeTest.java @@ -621,6 +621,7 @@ public class FeatureMergeTest { feature(1, rectangle(10, 20, 22, 22), Map.of("a", 1)) ), 1, + 1, 0, 0 ) diff --git a/core/src/test/java/com/onthegomap/flatmap/FlatMapTest.java b/core/src/test/java/com/onthegomap/flatmap/FlatMapTest.java index 284aae06..1f3b2235 100644 --- a/core/src/test/java/com/onthegomap/flatmap/FlatMapTest.java +++ b/core/src/test/java/com/onthegomap/flatmap/FlatMapTest.java @@ -416,6 +416,45 @@ public class FlatMapTest { ), results.tiles); } + @Test + public void testNumPointsAttr() throws Exception { + double x1 = 0.5 + Z14_WIDTH / 2; + double y1 = 0.5 + Z14_WIDTH / 2 - Z14_WIDTH / 2; + double x2 = x1 + Z14_WIDTH; + double y2 = y1 + Z14_WIDTH + Z14_WIDTH / 2; + double x3 = x2 + Z14_WIDTH; + double y3 = y2 + Z14_WIDTH; + double lat1 = GeoUtils.getWorldLat(y1); + double lng1 = GeoUtils.getWorldLon(x1); + double lat2 = GeoUtils.getWorldLat(y2); + double lng2 = GeoUtils.getWorldLon(x2); + double lat3 = GeoUtils.getWorldLat(y3); + double lng3 = GeoUtils.getWorldLon(x3); + + var results = runWithReaderFeatures( + Map.of("threads", "1"), + List.of( + newReaderFeature(newLineString(lng1, lat1, lng2, lat2, lng3, lat3), Map.of( + "attr", "value" + )) + ), + (in, features) -> { + features.line("layer") + .setZoomRange(13, 14) + .setBufferPixels(4) + .setNumPointsAttr("_numpoints"); + } + ); + + assertSubmap(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2 + 2, Z14_TILES / 2 + 2, 14), List.of( + feature(newLineString(-4, -4, 128, 128), Map.of( + "_numpoints", 3L + )) + ) + ), results.tiles); + } + @Test public void testMultiLineString() throws Exception { double x1 = 0.5 + Z14_WIDTH / 2; diff --git a/core/src/test/java/com/onthegomap/flatmap/TestUtils.java b/core/src/test/java/com/onthegomap/flatmap/TestUtils.java index 5319cb1d..c2191031 100644 --- a/core/src/test/java/com/onthegomap/flatmap/TestUtils.java +++ b/core/src/test/java/com/onthegomap/flatmap/TestUtils.java @@ -375,6 +375,7 @@ public class TestUtils { result.put("_labelgrid_size", feature.getLabelGridPixelSizeAtZoom(zoom)); result.put("_minpixelsize", feature.getMinPixelSize(zoom)); result.put("_type", geom instanceof Puntal ? "point" : geom instanceof Lineal ? "line" : "polygon"); + result.put("_numpointsattr", feature.getNumPointsAttr()); return result; } diff --git a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/OpenMapTilesProfile.java b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/OpenMapTilesProfile.java index 4282a918..aaac8c04 100644 --- a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/OpenMapTilesProfile.java +++ b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/OpenMapTilesProfile.java @@ -62,7 +62,12 @@ public class OpenMapTilesProfile implements Profile { } public OpenMapTilesProfile(Translations translations, Arguments arguments, Stats stats) { - this.layers = OpenMapTilesSchema.createInstances(translations, arguments, stats); + List onlyLayers = arguments.get("only_layers", "Include only certain layers", new String[]{}); + List excludeLayers = arguments.get("exclude_layers", "Exclude certain layers", new String[]{}); + this.layers = OpenMapTilesSchema.createInstances(translations, arguments, stats) + .stream() + .filter(l -> (onlyLayers.isEmpty() || onlyLayers.contains(l.name())) && !excludeLayers.contains(l.name())) + .toList(); osmDispatchMap = new HashMap<>(); Tables.generateDispatchMap(layers).forEach((clazz, handlers) -> { osmDispatchMap.put(clazz, handlers.stream().map(handler -> { diff --git a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/Utils.java b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/Utils.java index e32b5078..6789ad32 100644 --- a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/Utils.java +++ b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/Utils.java @@ -19,6 +19,14 @@ public class Utils { return a != null ? a : b != null ? b : c != null ? c : d; } + public static T coalesce(T a, T b, T c, T d, T e) { + return a != null ? a : b != null ? b : c != null ? c : d != null ? d : e; + } + + public static T coalesce(T a, T b, T c, T d, T e, T f) { + return a != null ? a : b != null ? b : c != null ? c : d != null ? d : e != null ? e : f; + } + public static T coalesceLazy(T a, Supplier b) { return a != null ? a : b.get(); } diff --git a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Building.java b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Building.java index 28e481c9..e80b3118 100644 --- a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Building.java +++ b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Building.java @@ -126,6 +126,6 @@ public class Building implements OpenMapTilesSchema.Building, @Override public List postProcess(int zoom, List items) throws GeometryException { - return (mergeZ13Buildings && zoom == 13) ? FeatureMerge.mergePolygons(items, 4, 0.5, 0.5) : items; + return (mergeZ13Buildings && zoom == 13) ? FeatureMerge.mergePolygons(items, 4, 4, 0.5, 0.5) : items; } } diff --git a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landcover.java b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landcover.java index 16bfd2aa..819e8c87 100644 --- a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landcover.java +++ b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landcover.java @@ -1,14 +1,131 @@ package com.onthegomap.flatmap.openmaptiles.layers; import com.onthegomap.flatmap.Arguments; +import com.onthegomap.flatmap.FeatureCollector; +import com.onthegomap.flatmap.FeatureMerge; +import com.onthegomap.flatmap.SourceFeature; import com.onthegomap.flatmap.Translations; +import com.onthegomap.flatmap.VectorTileEncoder; +import com.onthegomap.flatmap.ZoomFunction; +import com.onthegomap.flatmap.geo.GeometryException; import com.onthegomap.flatmap.monitoring.Stats; +import com.onthegomap.flatmap.openmaptiles.MultiExpression; +import com.onthegomap.flatmap.openmaptiles.OpenMapTilesProfile; import com.onthegomap.flatmap.openmaptiles.generated.OpenMapTilesSchema; +import com.onthegomap.flatmap.openmaptiles.generated.Tables; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; -public class Landcover implements OpenMapTilesSchema.Landcover { +public class Landcover implements + OpenMapTilesSchema.Landcover, + OpenMapTilesProfile.NaturalEarthProcessor, + Tables.OsmLandcoverPolygon.Handler, + OpenMapTilesProfile.FeaturePostProcessor { + + public static final ZoomFunction MIN_PIXEL_SIZE_THRESHOLDS = ZoomFunction.fromMaxZoomThresholds(Map.of( + 13, 8, + 10, 4, + 9, 2 + )); + private static final String NUM_POINTS_ATTR = "_numpoints"; + private static final Set WOOD_OR_FOREST = Set.of( + FieldValues.SUBCLASS_WOOD, + FieldValues.SUBCLASS_FOREST + ); + private final MultiExpression.MultiExpressionIndex classMapping; public Landcover(Translations translations, Arguments args, Stats stats) { + this.classMapping = FieldMappings.Class.index(); } - // TODO implement + private String getClassFromSubclass(String subclass) { + return subclass == null ? null : classMapping.getOrElse(Map.of(Fields.SUBCLASS, subclass), null); + } + + @Override + public void processNaturalEarth(String table, SourceFeature feature, + FeatureCollector features) { + record LandcoverInfo(String subclass, int minzoom, int maxzoom) {} + LandcoverInfo info = switch (table) { + case "ne_110m_glaciated_areas" -> new LandcoverInfo(FieldValues.SUBCLASS_GLACIER, 0, 1); + case "ne_50m_glaciated_areas" -> new LandcoverInfo(FieldValues.SUBCLASS_GLACIER, 2, 4); + case "ne_10m_glaciated_areas" -> new LandcoverInfo(FieldValues.SUBCLASS_GLACIER, 5, 6); + case "ne_50m_antarctic_ice_shelves_polys" -> new LandcoverInfo("ice_shelf", 2, 4); + case "ne_10m_antarctic_ice_shelves_polys" -> new LandcoverInfo("ice_shelf", 5, 6); + default -> null; + }; + if (info != null) { + String clazz = getClassFromSubclass(info.subclass); + if (clazz != null) { + features.polygon(LAYER_NAME).setBufferPixels(BUFFER_SIZE) + .setAttr(Fields.CLASS, clazz) + .setAttr(Fields.SUBCLASS, info.subclass) + .setZoomRange(info.minzoom, info.maxzoom); + } + } + } + + @Override + public void process(Tables.OsmLandcoverPolygon element, FeatureCollector features) { + String subclass = element.subclass(); + String clazz = getClassFromSubclass(subclass); + if (clazz != null) { + features.polygon(LAYER_NAME).setBufferPixels(BUFFER_SIZE) + .setMinPixelSizeThresholds(MIN_PIXEL_SIZE_THRESHOLDS) + .setAttr(Fields.CLASS, clazz) + .setAttr(Fields.SUBCLASS, subclass) + .setNumPointsAttr(NUM_POINTS_ATTR) + .setZoomRange(WOOD_OR_FOREST.contains(subclass) ? 9 : 7, 14); + } + } + + @Override + public List postProcess(int zoom, List items) + throws GeometryException { + if (zoom < 7 || zoom > 13) { + for (var item : items) { + item.attrs().remove(NUM_POINTS_ATTR); + } + return items; + } else { // z7-13 + String groupKey = "_group"; + List result = new ArrayList<>(); + List toMerge = new ArrayList<>(); + for (var item : items) { + Map attrs = item.attrs(); + Object numPointsObj = attrs.remove(NUM_POINTS_ATTR); + Object subclassObj = attrs.get(Fields.SUBCLASS); + if (numPointsObj instanceof Number num && subclassObj instanceof String subclass) { + long numPoints = num.longValue(); + if (zoom >= 10) { + if (WOOD_OR_FOREST.contains(subclass) && numPoints < 300) { + attrs.put(groupKey, numPoints < 50 ? "<50" : "<300"); + toMerge.add(item); + } else { // don't merge + result.add(item); + } + } else if (zoom == 9) { + if (WOOD_OR_FOREST.contains(subclass)) { + attrs.put(groupKey, numPoints < 50 ? "<50" : numPoints < 300 ? "<300" : ">300"); + toMerge.add(item); + } else { // don't merge + result.add(item); + } + } else { // zoom between 7 and 8 + toMerge.add(item); + } + } else { + result.add(item); + } + } + var merged = FeatureMerge.mergePolygons(toMerge, 4, 0, 0); + for (var item : merged) { + item.attrs().remove(groupKey); + } + result.addAll(merged); + return result; + } + } } diff --git a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landuse.java b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landuse.java index d288ad20..998330b6 100644 --- a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landuse.java +++ b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Landuse.java @@ -1,14 +1,69 @@ package com.onthegomap.flatmap.openmaptiles.layers; -import com.onthegomap.flatmap.Arguments; -import com.onthegomap.flatmap.Translations; -import com.onthegomap.flatmap.monitoring.Stats; -import com.onthegomap.flatmap.openmaptiles.generated.OpenMapTilesSchema; +import static com.onthegomap.flatmap.openmaptiles.Utils.coalesce; +import static com.onthegomap.flatmap.openmaptiles.Utils.nullIfEmpty; -public class Landuse implements OpenMapTilesSchema.Landuse { +import com.onthegomap.flatmap.Arguments; +import com.onthegomap.flatmap.FeatureCollector; +import com.onthegomap.flatmap.Parse; +import com.onthegomap.flatmap.SourceFeature; +import com.onthegomap.flatmap.Translations; +import com.onthegomap.flatmap.ZoomFunction; +import com.onthegomap.flatmap.monitoring.Stats; +import com.onthegomap.flatmap.openmaptiles.OpenMapTilesProfile; +import com.onthegomap.flatmap.openmaptiles.generated.OpenMapTilesSchema; +import com.onthegomap.flatmap.openmaptiles.generated.Tables; +import java.util.Map; +import java.util.Set; + +public class Landuse implements + OpenMapTilesSchema.Landuse, + OpenMapTilesProfile.NaturalEarthProcessor, + Tables.OsmLandusePolygon.Handler { public Landuse(Translations translations, Arguments args, Stats stats) { } - // TODO implement + @Override + public void processNaturalEarth(String table, SourceFeature feature, FeatureCollector features) { + if ("ne_50m_urban_areas".equals(table)) { + Double scalerank = Parse.parseDoubleOrNull(feature.getTag("scalerank")); + if (scalerank != null && scalerank <= 2) { + features.polygon(LAYER_NAME).setBufferPixels(BUFFER_SIZE) + .setAttr(Fields.CLASS, FieldValues.CLASS_RESIDENTIAL) + .setZoomRange(4, 5); + } + } + } + + private static final ZoomFunction MIN_PIXEL_SIZE_THRESHOLDS = ZoomFunction.fromMaxZoomThresholds(Map.of( + 13, 4, + 7, 2, + 6, 1 + )); + + private static final Set Z6_CLASSES = Set.of( + FieldValues.CLASS_RESIDENTIAL, + FieldValues.CLASS_SUBURB, + FieldValues.CLASS_QUARTER, + FieldValues.CLASS_NEIGHBOURHOOD + ); + + @Override + public void process(Tables.OsmLandusePolygon element, FeatureCollector features) { + String clazz = coalesce( + nullIfEmpty(element.landuse()), + nullIfEmpty(element.amenity()), + nullIfEmpty(element.leisure()), + nullIfEmpty(element.tourism()), + nullIfEmpty(element.place()), + nullIfEmpty(element.waterway()) + ); + if (clazz != null) { + features.polygon(LAYER_NAME).setBufferPixels(BUFFER_SIZE) + .setAttr(Fields.CLASS, clazz) + .setMinPixelSizeThresholds(MIN_PIXEL_SIZE_THRESHOLDS) + .setZoomRange(Z6_CLASSES.contains(clazz) ? 6 : 9, 14); + } + } } diff --git a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Park.java b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Park.java index c7f9c635..d4e8bdf4 100644 --- a/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Park.java +++ b/openmaptiles/src/main/java/com/onthegomap/flatmap/openmaptiles/layers/Park.java @@ -1,14 +1,100 @@ package com.onthegomap.flatmap.openmaptiles.layers; -import com.onthegomap.flatmap.Arguments; -import com.onthegomap.flatmap.Translations; -import com.onthegomap.flatmap.monitoring.Stats; -import com.onthegomap.flatmap.openmaptiles.generated.OpenMapTilesSchema; +import static com.onthegomap.flatmap.collections.FeatureGroup.Z_ORDER_BITS; +import static com.onthegomap.flatmap.collections.FeatureGroup.Z_ORDER_MIN; +import static com.onthegomap.flatmap.openmaptiles.Utils.coalesce; +import static com.onthegomap.flatmap.openmaptiles.Utils.nullIfEmpty; -public class Park implements OpenMapTilesSchema.Park { +import com.carrotsearch.hppc.LongIntHashMap; +import com.carrotsearch.hppc.LongIntMap; +import com.onthegomap.flatmap.Arguments; +import com.onthegomap.flatmap.FeatureCollector; +import com.onthegomap.flatmap.GeometryType; +import com.onthegomap.flatmap.Translations; +import com.onthegomap.flatmap.VectorTileEncoder; +import com.onthegomap.flatmap.geo.GeoUtils; +import com.onthegomap.flatmap.geo.GeometryException; +import com.onthegomap.flatmap.monitoring.Stats; +import com.onthegomap.flatmap.openmaptiles.LanguageUtils; +import com.onthegomap.flatmap.openmaptiles.OpenMapTilesProfile; +import com.onthegomap.flatmap.openmaptiles.generated.OpenMapTilesSchema; +import com.onthegomap.flatmap.openmaptiles.generated.Tables; +import java.util.List; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Park implements + OpenMapTilesSchema.Park, + Tables.OsmParkPolygon.Handler, + OpenMapTilesProfile.FeaturePostProcessor { + + private static final Logger LOGGER = LoggerFactory.getLogger(Park.class); + private final Translations translations; public Park(Translations translations, Arguments args, Stats stats) { + this.translations = translations; } - // TODO implement + private static final int PARK_NATIONAL_PARK_BOOST = 1 << (Z_ORDER_BITS - 1); + private static final int PARK_WIKIPEDIA_BOOST = 1 << (Z_ORDER_BITS - 2); + private static final double WORLD_AREA_FOR_70K_SQUARE_METERS = + Math.pow(GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d, 2); + private static final double LOG2 = Math.log(2); + private static final int PARK_AREA_RANGE = 1 << (Z_ORDER_BITS - 3); + private static final double PARK_LOG_RANGE = Math.log(Math.pow(4, 26)); // 2^14 tiles, 2^12 pixels per tile + private static final double LOG4 = Math.log(4); + + @Override + public void process(Tables.OsmParkPolygon element, FeatureCollector features) { + String protectionTitle = element.protectionTitle(); + if (protectionTitle != null) { + protectionTitle = protectionTitle.replace(' ', '_').toLowerCase(Locale.ROOT); + } + String clazz = coalesce( + nullIfEmpty(protectionTitle), + nullIfEmpty(element.boundary()), + nullIfEmpty(element.leisure()) + ); + features.polygon(LAYER_NAME).setBufferPixels(BUFFER_SIZE) + .setAttr(Fields.CLASS, clazz) + .setMinPixelSize(2) + .setZoomRange(6, 14); + + if (element.name() != null) { + try { + double area = element.source().area(); + int minzoom = (int) Math.floor(20 - Math.log(area / WORLD_AREA_FOR_70K_SQUARE_METERS) / LOG2); + double logWorldArea = Math.min(1d, Math.max(0d, (Math.log(area) + PARK_LOG_RANGE) / PARK_LOG_RANGE)); + int areaBoost = (int) (logWorldArea * PARK_AREA_RANGE); + minzoom = Math.min(14, Math.max(6, minzoom)); + + features.centroid(LAYER_NAME).setBufferPixels(256) + .setAttr(Fields.CLASS, clazz) + .setAttrs(LanguageUtils.getNames(element.source().properties(), translations)) + .setLabelGridPixelSize(14, 100) + .setZorder(Z_ORDER_MIN + + ("national_park".equals(clazz) ? PARK_NATIONAL_PARK_BOOST : 0) + + ((element.source().hasTag("wikipedia") || element.source().hasTag("wikidata")) ? PARK_WIKIPEDIA_BOOST : 0) + + areaBoost + ).setZoomRange(minzoom, 14); + } catch (GeometryException e) { + LOGGER.warn("Unable to get park area for " + element.source().id() + ": " + e.getMessage()); + } + } + } + + @Override + public List postProcess(int zoom, List items) { + LongIntMap counts = new LongIntHashMap(); + for (int i = items.size() - 1; i >= 0; i--) { + var feature = items.get(i); + if (feature.geometry().geomType() == GeometryType.POINT && feature.hasGroup()) { + int count = counts.getOrDefault(feature.group(), 0) + 1; + feature.attrs().put("rank", count); + counts.put(feature.group(), count); + } + } + return items; + } } diff --git a/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/AbstractLayerTest.java b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/AbstractLayerTest.java index eb238ed6..41628301 100644 --- a/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/AbstractLayerTest.java +++ b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/AbstractLayerTest.java @@ -39,7 +39,7 @@ public abstract class AbstractLayerTest { static void assertFeatures(int zoom, List> expected, Iterable actual) { List actualList = StreamSupport.stream(actual.spliterator(), false).toList(); - assertEquals(expected.size(), actualList.size(), "size"); + assertEquals(expected.size(), actualList.size(), () -> "size: " + actualList); for (int i = 0; i < expected.size(); i++) { assertSubmap(expected.get(i), TestUtils.toMap(actualList.get(i), zoom)); } diff --git a/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LandcoverTest.java b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LandcoverTest.java new file mode 100644 index 00000000..46346c5d --- /dev/null +++ b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LandcoverTest.java @@ -0,0 +1,202 @@ +package com.onthegomap.flatmap.openmaptiles.layers; + +import static com.onthegomap.flatmap.TestUtils.rectangle; +import static com.onthegomap.flatmap.openmaptiles.OpenMapTilesProfile.NATURAL_EARTH_SOURCE; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.onthegomap.flatmap.VectorTileEncoder; +import com.onthegomap.flatmap.geo.GeoUtils; +import com.onthegomap.flatmap.geo.GeometryException; +import com.onthegomap.flatmap.read.ReaderFeature; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +public class LandcoverTest extends AbstractLayerTest { + + @Test + public void testNaturalEarthGlaciers() { + var glacier1 = process(new ReaderFeature( + GeoUtils.worldToLatLonCoords(rectangle(0, Math.sqrt(1))), + Map.of(), + NATURAL_EARTH_SOURCE, + "ne_110m_glaciated_areas", + 0 + )); + var glacier2 = process(new ReaderFeature( + GeoUtils.worldToLatLonCoords(rectangle(0, Math.sqrt(1))), + Map.of(), + NATURAL_EARTH_SOURCE, + "ne_50m_glaciated_areas", + 0 + )); + var glacier3 = process(new ReaderFeature( + GeoUtils.worldToLatLonCoords(rectangle(0, Math.sqrt(1))), + Map.of(), + NATURAL_EARTH_SOURCE, + "ne_10m_glaciated_areas", + 0 + )); + assertFeatures(0, List.of(Map.of( + "_layer", "landcover", + "subclass", "glacier", + "class", "ice", + "_buffer", 4d + )), glacier1); + assertFeatures(0, List.of(Map.of( + "_layer", "landcover", + "subclass", "glacier", + "class", "ice", + "_buffer", 4d + )), glacier2); + assertFeatures(0, List.of(Map.of( + "_layer", "landcover", + "subclass", "glacier", + "class", "ice", + "_buffer", 4d + )), glacier3); + assertCoversZoomRange(0, 6, "landcover", + glacier1, + glacier2, + glacier3 + ); + } + + @Test + public void testNaturalEarthAntarcticIceShelves() { + var ice1 = process(new ReaderFeature( + GeoUtils.worldToLatLonCoords(rectangle(0, Math.sqrt(1))), + Map.of(), + NATURAL_EARTH_SOURCE, + "ne_50m_antarctic_ice_shelves_polys", + 0 + )); + var ice2 = process(new ReaderFeature( + GeoUtils.worldToLatLonCoords(rectangle(0, Math.sqrt(1))), + Map.of(), + NATURAL_EARTH_SOURCE, + "ne_10m_antarctic_ice_shelves_polys", + 0 + )); + assertFeatures(0, List.of(Map.of( + "_layer", "landcover", + "subclass", "ice_shelf", + "class", "ice", + "_buffer", 4d + )), ice1); + assertFeatures(0, List.of(Map.of( + "_layer", "landcover", + "subclass", "ice_shelf", + "class", "ice", + "_buffer", 4d + )), ice2); + assertCoversZoomRange(2, 6, "landcover", + ice1, + ice2 + ); + } + + @Test + public void testOsmLandcover() { + assertFeatures(13, List.of(Map.of( + "_layer", "landcover", + "subclass", "wood", + "class", "wood", + "_minpixelsize", 8d, + "_numpointsattr", "_numpoints", + "_minzoom", 9, + "_maxzoom", 14 + )), process(polygonFeature(Map.of( + "natural", "wood" + )))); + assertFeatures(12, List.of(Map.of( + "_layer", "landcover", + "subclass", "forest", + "class", "wood", + "_minpixelsize", 8d, + "_minzoom", 9, + "_maxzoom", 14 + )), process(polygonFeature(Map.of( + "landuse", "forest" + )))); + assertFeatures(10, List.of(Map.of( + "_layer", "landcover", + "subclass", "dune", + "class", "sand", + "_minpixelsize", 4d, + "_minzoom", 7, + "_maxzoom", 14 + )), process(polygonFeature(Map.of( + "natural", "dune" + )))); + } + + @Test + public void testMergeForestsBuNumPointsZ9to13() throws GeometryException { + Map map = Map.of("subclass", "wood"); + + assertMerges(List.of(map, map, map, map, map, map), List.of( + feature(rectangle(10, 20), Map.of("_numpoints", 48, "subclass", "wood")), + feature(rectangle(10, 20), Map.of("_numpoints", 49, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 50, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 299, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 300, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 301, "subclass", "wood")) + ), 14); + assertMerges(List.of(map, map, map, map), List.of( + feature(rectangle(10, 20), Map.of("_numpoints", 48, "subclass", "wood")), + feature(rectangle(10, 20), Map.of("_numpoints", 49, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 50, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 299, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 300, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 301, "subclass", "wood")) + ), 13); + assertMerges(List.of(map, map, map), List.of( + feature(rectangle(10, 20), Map.of("_numpoints", 48, "subclass", "wood")), + feature(rectangle(10, 20), Map.of("_numpoints", 49, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 50, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 299, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 300, "subclass", "wood")), + feature(rectangle(12, 18), Map.of("_numpoints", 301, "subclass", "wood")) + ), 9); + } + + @Test + public void testMergeNonForestsBelowZ9() throws GeometryException { + Map map = Map.of("subclass", "dune"); + + assertMerges(List.of(map, map), List.of( + feature(rectangle(10, 20), Map.of("_numpoints", 48, "subclass", "dune")), + feature(rectangle(12, 18), Map.of("_numpoints", 301, "subclass", "dune")) + ), 9); + assertMerges(List.of(map), List.of( + feature(rectangle(10, 20), Map.of("_numpoints", 48, "subclass", "dune")), + feature(rectangle(12, 18), Map.of("_numpoints", 301, "subclass", "dune")) + ), 8); + assertMerges(List.of(map, map), List.of( + feature(rectangle(10, 20), Map.of("_numpoints", 48, "subclass", "dune")), + feature(rectangle(12, 18), Map.of("_numpoints", 301, "subclass", "dune")) + ), 6); + } + + @NotNull + private VectorTileEncoder.Feature feature(org.locationtech.jts.geom.Polygon geom, Map m) { + return new VectorTileEncoder.Feature( + "landcover", + 1, + VectorTileEncoder.encodeGeometry(geom), + new HashMap<>(m), + 0 + ); + } + + private void assertMerges(List> expected, List in, int zoom) + throws GeometryException { + assertEquals(expected, + profile.postProcessLayerFeatures("landcover", zoom, in).stream().map( + VectorTileEncoder.Feature::attrs) + .toList()); + } +} diff --git a/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LanduseTest.java b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LanduseTest.java new file mode 100644 index 00000000..f30dffac --- /dev/null +++ b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/LanduseTest.java @@ -0,0 +1,80 @@ +package com.onthegomap.flatmap.openmaptiles.layers; + +import static com.onthegomap.flatmap.TestUtils.rectangle; +import static com.onthegomap.flatmap.openmaptiles.OpenMapTilesProfile.NATURAL_EARTH_SOURCE; + +import com.onthegomap.flatmap.geo.GeoUtils; +import com.onthegomap.flatmap.read.ReaderFeature; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class LanduseTest extends AbstractLayerTest { + + @Test + public void testNaturalEarthUrbanAreas() { + assertFeatures(0, List.of(Map.of( + "_layer", "landuse", + "class", "residential", + "_buffer", 4d + )), process(new ReaderFeature( + GeoUtils.worldToLatLonCoords(rectangle(0, Math.sqrt(1))), + Map.of("scalerank", 1.9), + NATURAL_EARTH_SOURCE, + "ne_50m_urban_areas", + 0 + ))); + assertFeatures(0, List.of(), process(new ReaderFeature( + GeoUtils.worldToLatLonCoords(rectangle(0, Math.sqrt(1))), + Map.of("scalerank", 2.1), + NATURAL_EARTH_SOURCE, + "ne_50m_urban_areas", + 0 + ))); + } + + @Test + public void testOsmLanduse() { + assertFeatures(13, List.of(Map.of( + "_layer", "landuse", + "class", "railway", + "_minpixelsize", 4d, + "_minzoom", 9, + "_maxzoom", 14 + )), process(polygonFeature(Map.of( + "landuse", "railway", + "amenity", "school" + )))); + assertFeatures(13, List.of(Map.of( + "_layer", "landuse", + "class", "school", + "_minpixelsize", 4d, + "_minzoom", 9, + "_maxzoom", 14 + )), process(polygonFeature(Map.of( + "amenity", "school" + )))); + } + + @Test + public void testOsmLanduseLowerZoom() { + assertFeatures(6, List.of(Map.of( + "_layer", "landuse", + "class", "suburb", + "_minzoom", 6, + "_maxzoom", 14, + "_minpixelsize", 1d + )), process(polygonFeature(Map.of( + "place", "suburb" + )))); + assertFeatures(7, List.of(Map.of( + "_layer", "landuse", + "class", "residential", + "_minzoom", 6, + "_maxzoom", 14, + "_minpixelsize", 2d + )), process(polygonFeature(Map.of( + "landuse", "residential" + )))); + } +} diff --git a/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/ParkTest.java b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/ParkTest.java new file mode 100644 index 00000000..e63ed229 --- /dev/null +++ b/openmaptiles/src/test/java/com/onthegomap/flatmap/openmaptiles/layers/ParkTest.java @@ -0,0 +1,145 @@ +package com.onthegomap.flatmap.openmaptiles.layers; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.onthegomap.flatmap.geo.GeoUtils; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class ParkTest extends AbstractLayerTest { + + @Test + public void testNationalPark() { + assertFeatures(13, List.of(Map.of( + "_layer", "park", + "_type", "polygon", + "class", "national_park", + "name", "", + "_minpixelsize", 2d, + "_minzoom", 6, + "_maxzoom", 14 + ), Map.of( + "_layer", "park", + "_type", "point", + "class", "national_park", + "name", "Grand Canyon National Park", + "name_int", "Grand Canyon National Park", + "name:latin", "Grand Canyon National Park", + "name:es", "es name", + "_minzoom", 6, + "_maxzoom", 14 + )), process(polygonFeature(Map.of( + "boundary", "national_park", + "name", "Grand Canyon National Park", + "name:es", "es name", + "protection_title", "National Park", + "wikipedia", "en:Grand Canyon National Park" + )))); + + // needs a name + assertFeatures(13, List.of(Map.of( + "_layer", "park", + "_type", "polygon" + )), process(polygonFeature(Map.of( + "boundary", "national_park", + "protection_title", "National Park" + )))); + } + + @Test + public void testSmallerPark() { + double z11area = Math.pow((GeoUtils.metersToPixelAtEquator(0, Math.sqrt(70_000)) / 256d), 2) * Math.pow(2, 20 - 11); + assertFeatures(13, List.of(Map.of( + "_layer", "park", + "_type", "polygon", + "class", "protected_area", + "name", "", + "_minpixelsize", 2d, + "_minzoom", 6, + "_maxzoom", 14 + ), Map.of( + "_layer", "park", + "_type", "point", + "class", "protected_area", + "name", "Small park", + "name_int", "Small park", + "_minzoom", 11, + "_maxzoom", 14 + )), process(polygonFeatureWithArea(z11area, Map.of( + "boundary", "protected_area", + "name", "Small park", + "wikipedia", "en:Small park" + )))); + assertFeatures(13, List.of(Map.of( + "_layer", "park", + "_type", "polygon" + ), Map.of( + "_layer", "park", + "_type", "point", + "_minzoom", 6, + "_maxzoom", 14 + )), process(polygonFeatureWithArea(1, Map.of( + "boundary", "protected_area", + "name", "Small park", + "wikidata", "Q123" + )))); + } + + @Test + public void testZorders() { + assertDescending( + getLabelZorder(1, Map.of( + "boundary", "national_park", + "name", "a", + "wikipedia", "en:park" + )), + getLabelZorder(1e-10, Map.of( + "boundary", "national_park", + "name", "a", + "wikipedia", "en:Park" + )), + getLabelZorder(1, Map.of( + "boundary", "national_park", + "name", "a" + )), + getLabelZorder(1e-10, Map.of( + "boundary", "national_park", + "name", "a" + )), + + getLabelZorder(1, Map.of( + "boundary", "protected_area", + "name", "a", + "wikipedia", "en:park" + )), + getLabelZorder(1e-10, Map.of( + "boundary", "protected_area", + "name", "a", + "wikipedia", "en:Park" + )), + getLabelZorder(1, Map.of( + "boundary", "protected_area", + "name", "a" + )), + getLabelZorder(1e-10, Map.of( + "boundary", "protected_area", + "name", "a" + )) + ); + } + + private void assertDescending(int... vals) { + for (int i = 1; i < vals.length; i++) { + if (vals[i - 1] < vals[i]) { + fail("element at " + (i - 1) + " is less than element at " + i); + } + } + } + + private int getLabelZorder(double area, Map tags) { + var iter = process(polygonFeatureWithArea(area, tags)).iterator(); + iter.next(); + return iter.next().getZorder(); + } +}