package com.onthegomap.planetiler; import com.onthegomap.planetiler.collection.FeatureGroup; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeometryType; import com.onthegomap.planetiler.reader.SourceFeature; import com.onthegomap.planetiler.render.FeatureRenderer; import com.onthegomap.planetiler.stats.Stats; import com.onthegomap.planetiler.util.CacheByZoom; import com.onthegomap.planetiler.util.ZoomFunction; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.TreeMap; import org.locationtech.jts.geom.Geometry; /** * Utility that {@link Profile} implementations use to build map features that should be emitted for an input source * feature. *

* For example to add a polygon feature for a lake and a center label point with its name: * *

 * {@code
 * featureCollector.polygon("water")
 *   .setAttr("class", "lake");
 * featureCollector.centroid("water_name")
 *   .setAttr("class", "lake")
 *   .setAttr("name", element.getString("name"));
 * }
 * 
*/ public class FeatureCollector implements Iterable { private static final Geometry EMPTY_GEOM = GeoUtils.JTS_FACTORY.createGeometryCollection(); private final SourceFeature source; private final List output = new ArrayList<>(); private final PlanetilerConfig config; private final Stats stats; private FeatureCollector(SourceFeature source, PlanetilerConfig config, Stats stats) { this.source = source; this.config = config; this.stats = stats; } @Override public Iterator iterator() { return output.iterator(); } /** * Starts building a new map feature with an explicit JTS {@code geometry} that overrides the source geometry. * * @param layer the output vector tile layer this feature will be written to * @param geometry the explicit geometry to use instead of what is present in source data * @return a feature that can be configured further. */ public Feature geometry(String layer, Geometry geometry) { Feature feature = new Feature(layer, geometry, source.id()); output.add(feature); return feature; } /** * Starts building a new point map feature that expects the source feature to be a point. *

* If the source feature is not a point, logs an error and returns a feature that can be configured, but won't * actually emit anything to the map. * * @param layer the output vector tile layer this feature will be written to * @return a feature that can be configured further. */ public Feature point(String layer) { try { if (!source.isPoint()) { throw new GeometryException("feature_not_point", "not a point"); } return geometry(layer, source.worldGeometry()); } catch (GeometryException e) { e.log(stats, "feature_point", "Error getting point geometry for " + source.id()); return new Feature(layer, EMPTY_GEOM, source.id()); } } /** * Starts building a new line map feature that expects the source feature to be a line. *

* If the source feature cannot be a line, logs an error and returns a feature that can be configured, but won't * actually emit anything to the map. *

* Some OSM closed OSM ways can be both a polygon and a line * * @param layer the output vector tile layer this feature will be written to * @return a feature that can be configured further. */ public Feature line(String layer) { try { return geometry(layer, source.line()); } catch (GeometryException e) { e.log(stats, "feature_line", "Error constructing line for " + source.id()); return new Feature(layer, EMPTY_GEOM, source.id()); } } /** * Starts building a new polygon map feature that expects the source feature to be a polygon. *

* If the source feature cannot be a polygon, logs an error and returns a feature that can be configured, but won't * actually emit anything to the map. *

* Some OSM closed OSM ways can be both a polygon and a line * * @param layer the output vector tile layer this feature will be written to * @return a feature that can be configured further. */ public Feature polygon(String layer) { try { return geometry(layer, source.polygon()); } catch (GeometryException e) { e.log(stats, "feature_polygon", "Error constructing polygon for " + source.id()); return new Feature(layer, EMPTY_GEOM, source.id()); } } /** * Starts building a new point map feature with geometry from {@link Geometry#getCentroid()} of the source feature. * * @param layer the output vector tile layer this feature will be written to * @return a feature that can be configured further. */ public Feature centroid(String layer) { try { return geometry(layer, source.centroid()); } catch (GeometryException e) { e.log(stats, "feature_centroid", "Error getting centroid for " + source.id()); return new Feature(layer, EMPTY_GEOM, source.id()); } } /** * Starts building a new point map feature with geometry from {@link Geometry#getCentroid()} if the source feature is * a point, line, or simple convex polygon, or {@link Geometry#getInteriorPoint()} if it is a multipolygon, polygon * with holes, or concave simple polygon. * * @param layer the output vector tile layer this feature will be written to * @return a feature that can be configured further. */ public Feature centroidIfConvex(String layer) { try { return geometry(layer, source.centroidIfConvex()); } catch (GeometryException e) { e.log(stats, "feature_centroid_if_convex", "Error constructing centroid if convex for " + source.id()); return new Feature(layer, EMPTY_GEOM, source.id()); } } /** * Starts building a new point map feature with geometry from {@link Geometry#getInteriorPoint()} of the source * feature. * * @param layer the output vector tile layer this feature will be written to * @return a feature that can be configured further. */ public Feature pointOnSurface(String layer) { try { return geometry(layer, source.pointOnSurface()); } catch (GeometryException e) { e.log(stats, "feature_point_on_surface", "Error constructing point on surface for " + source.id()); return new Feature(layer, EMPTY_GEOM, source.id()); } } /** * Creates new feature collector instances for each source feature that we encounter. */ public record Factory(PlanetilerConfig config, Stats stats) { public FeatureCollector get(SourceFeature source) { return new FeatureCollector(source, config, stats); } } /** * A builder for an output map feature that contains all the information that will be needed to render vector tile * features from the input element. *

* Some feature attributes are set globally (like sort key), and some allow the value to change by zoom-level (like * tags). */ public final class Feature { private static final double DEFAULT_LABEL_GRID_SIZE = 0; private static final int DEFAULT_LABEL_GRID_LIMIT = 0; private final String layer; private final Geometry geom; private final Map attrs = new TreeMap<>(); private final GeometryType geometryType; private final long sourceId; private int sortKey = 0; private int minzoom = config.minzoom(); private int maxzoom = config.maxzoom(); private ZoomFunction labelGridPixelSize = null; private ZoomFunction labelGridLimit = null; private boolean attrsChangeByZoom = false; private CacheByZoom> attrCache = null; private double defaultBufferPixels = 4; private ZoomFunction bufferPixelOverrides; // TODO better API for default value, value at max zoom, and zoom-specific overrides for tolerance and min size? private double defaultMinPixelSize = config.minFeatureSizeBelowMaxZoom(); private double minPixelSizeAtMaxZoom = config.minFeatureSizeAtMaxZoom(); private ZoomFunction minPixelSize = null; private double defaultPixelTolerance = config.simplifyToleranceBelowMaxZoom(); private double pixelToleranceAtMaxZoom = config.simplifyToleranceAtMaxZoom(); private ZoomFunction pixelTolerance = null; private String numPointsAttr = null; private Feature(String layer, Geometry geom, long sourceId) { this.layer = layer; this.geom = geom; this.geometryType = GeometryType.valueOf(geom); this.sourceId = sourceId; } /** Returns the original ID of the source feature that this feature came from (i.e. OSM node/way ID). */ public long getSourceId() { return sourceId; } GeometryType getGeometryType() { return geometryType; } public boolean isPolygon() { return geometryType == GeometryType.POLYGON; } /** * Returns the value by which features are sorted within a layer in the output vector tile. */ public int getSortKey() { return sortKey; } /** * Sets the value by which features are sorted within a layer in the output vector tile. Sort key gets packed into * {@link FeatureGroup#SORT_KEY_BITS} bits so the range of this is limited to {@code -(2^(bits-1))} to {@code * (2^(bits-1))-1}. *

* Circles, lines, and polygons are rendered in the order they appear in each layer, so features that appear later * (higher sort key) show up on top of features with a lower sort key. *

* For symbols (text/icons) where clients try to avoid label collisions, features are placed in the order they * appear in each layer, so features that appear earlier (lower sort key) will show up at lower zoom levels than * feature that appear later (higher sort key) in a layer. */ public Feature setSortKey(int sortKey) { assert sortKey >= FeatureGroup.SORT_KEY_MIN && sortKey <= FeatureGroup.SORT_KEY_MAX : "Sort key " + sortKey + " outside of allowed range [" + FeatureGroup.SORT_KEY_MIN + ", " + FeatureGroup.SORT_KEY_MAX + "]"; this.sortKey = sortKey; return this; } /** Sets the value by which features are sorted from high to low within a layer in the output vector tile. */ public Feature setSortKeyDescending(int sortKey) { return setSortKey(FeatureGroup.SORT_KEY_MAX + FeatureGroup.SORT_KEY_MIN - sortKey); } /** * Sets the zoom range (inclusive) that this feature appears in. *

* If not called, then defaults to all zoom levels. */ public Feature setZoomRange(int min, int max) { assert min <= max; return setMinZoom(min).setMaxZoom(max); } /** Returns the minimum zoom level (inclusive) that this feature appears in. */ public int getMinZoom() { return minzoom; } /** * Sets the minimum zoom level (inclusive) that this feature appears in. *

* If not called, defaults to minimum zoom-level of the map. */ public Feature setMinZoom(int min) { minzoom = Math.max(min, config.minzoom()); return this; } /** Returns the maximum zoom level (inclusive) that this feature appears in. */ public int getMaxZoom() { return maxzoom; } /** * Sets the maximum zoom level (inclusive) that this feature appears in. *

* If not called, defaults to maximum zoom-level of the map. */ public Feature setMaxZoom(int max) { maxzoom = Math.min(max, config.maxzoom()); return this; } /** Returns the output vector tile layer that this feature will appear in. */ public String getLayer() { return layer; } /** * Returns the JTS geometry (in world web mercator coordinates) of this feature. *

* Subsequent postprocessing in {@link FeatureRenderer} will slice this into tile geometries. */ public Geometry getGeometry() { return geom; } /** Returns the number of pixels of detail to render outside the visible tile boundary at {@code zoom}. */ public double getBufferPixelsAtZoom(int zoom) { return ZoomFunction.applyAsDoubleOrElse(bufferPixelOverrides, zoom, defaultBufferPixels); } /** * Sets the default number of pixels of detail to render outside the visible tile boundary when no zoom-specific * override is set in {@link #setBufferPixelOverrides(ZoomFunction)}. */ public Feature setBufferPixels(double buffer) { defaultBufferPixels = buffer; return this; } /** * Sets zoom-specific overrides to the number of pixels of detail to render outside the visible tile boundary. *

* If {@code buffer} is {@code null} or returns {@code null}, the buffer pixels will default to * {@link #setBufferPixels(double)}. */ public Feature setBufferPixelOverrides(ZoomFunction buffer) { bufferPixelOverrides = buffer; return this; } /** * Returns the minimum resolution in tile pixels of features to emit at {@code zoom}. *

* For line features, this is length, and for polygon features this is the square root of the minimum area of * features to emit. */ public double getMinPixelSizeAtZoom(int zoom) { return zoom == config.maxzoom() ? minPixelSizeAtMaxZoom : ZoomFunction.applyAsDoubleOrElse(minPixelSize, zoom, defaultMinPixelSize); } /** * Sets the minimum length of line features or square root of the minimum area of polygon features to emit below the * maximum zoom-level of the map. *

* At the maximum zoom level of the map, clients can "overzoom" in on features, so this leaves the minimum size at * the max zoom level at {@link PlanetilerConfig#minFeatureSizeAtMaxZoom()} unless you explicitly override it with * {@link #setMinPixelSizeAtMaxZoom(double)} or {@link #setMinPixelSizeAtAllZooms(int)}. */ public Feature setMinPixelSize(double minPixelSize) { this.defaultMinPixelSize = minPixelSize; return this; } /** * Sets zoom-specific overrides to the minimum length of line features or square root of the minimum area of polygon * features to emit below the maximum zoom-level of the map. *

* At the maximum zoom level of the map, clients can "overzoom" in on features, so this leaves the minimum size at * the max zoom level at {@link PlanetilerConfig#minFeatureSizeAtMaxZoom()} unless you explicitly override it with * {@link #setMinPixelSizeAtMaxZoom(double)} or {@link #setMinPixelSizeAtAllZooms(int)}. *

* If {@code levels} is {@code null} or returns {@code null}, the min pixel size will default to the default value. */ public Feature setMinPixelSizeOverrides(ZoomFunction levels) { this.minPixelSize = levels; return this; } /** * Overrides the default minimum pixel size at and below {@code zoom} with {@code minPixelSize}. *

* This replaces all previous zoom overrides that were set. To use multiple zoom-level thresholds, create a * {@link ZoomFunction} explicitly and pass it to {@link #setMinPixelSizeOverrides(ZoomFunction)}. */ public Feature setMinPixelSizeBelowZoom(int zoom, double minPixelSize) { if (zoom >= config.maxzoom()) { minPixelSizeAtMaxZoom = minPixelSize; } this.minPixelSize = ZoomFunction.maxZoom(zoom, minPixelSize); return this; } /** * Sets the minimum length of line features or square root of the minimum area of polygon features to emit at the * maximum zoom-level of the map. *

* Since clients can "overzoom" in on features past the maximum zoom level, this is typically much smaller than min * pixel size at lower zoom levels. *

* This overrides, but does not replace the default min pixel size or overrides set through other methods. */ public Feature setMinPixelSizeAtMaxZoom(double minPixelSize) { this.minPixelSizeAtMaxZoom = minPixelSize; return this; } /** * Sets the minimum length of line features or square root of the minimum area of polygon features to emit at all * zoom levels, including the maximum zoom-level of the map. *

* This replaces previous default values, but not overrides set with * {@link #setMinPixelSizeOverrides(ZoomFunction)}. */ public Feature setMinPixelSizeAtAllZooms(int minPixelSize) { this.minPixelSizeAtMaxZoom = minPixelSize; return this.setMinPixelSize(minPixelSize); } /** * Returns the simplification tolerance for lines and polygons in tile pixels at {@code zoom}. */ public double getPixelToleranceAtZoom(int zoom) { return zoom == config.maxzoom() ? pixelToleranceAtMaxZoom : ZoomFunction.applyAsDoubleOrElse(pixelTolerance, zoom, defaultPixelTolerance); } /** * Sets the simplification tolerance for lines and polygons in tile pixels below the maximum zoom-level of the map. *

* Since clients can "overzoom" past the max zoom of the map, this is typically smaller than the default tolerance * to provide more detail as you zoom in. *

* This does not replace any overrides that were set with {@link #setPixelToleranceOverrides(ZoomFunction)}. */ public Feature setPixelTolerance(double tolerance) { this.defaultPixelTolerance = tolerance; return this; } /** * Sets the simplification tolerance for lines and polygons in tile pixels at the maximum zoom-level of the map. *

* This does not replace the default value at other zoom levels set through {@link #setPixelTolerance(double)} any * zoom-specific overrides that were set with {@link #setPixelToleranceOverrides(ZoomFunction)}. */ public Feature setPixelToleranceAtMaxZoom(double tolerance) { this.pixelToleranceAtMaxZoom = tolerance; return this; } /** * Sets the simplification tolerance for lines and polygons in tile pixels including at the maximum zoom-level of * the map. *

* This does not replace the default value at other zoom levels set through {@link #setPixelTolerance(double)}. */ public Feature setPixelToleranceAtAllZooms(double tolerance) { return setPixelToleranceAtMaxZoom(tolerance).setPixelTolerance(tolerance); } /** * Sets zoom-specific overrides to the simplification tolerance for lines and polygons in tile pixels below the * maximum zoom-level of the map. *

* At the maximum zoom level of the map, clients can "overzoom" in on features, so this leaves the tolerance at the * max zoom level set to {@link PlanetilerConfig#simplifyToleranceAtMaxZoom()} unless you explicitly override it * with {@link #setMinPixelSizeAtAllZooms(int)} or {@link #setMinPixelSizeAtMaxZoom(double)}. *

* If {@code levels} is {@code null} or returns {@code null}, the min pixel size will default to the default value. */ public Feature setPixelToleranceOverrides(ZoomFunction overrides) { this.pixelTolerance = overrides; return this; } /** * Overrides the default simplification tolerance for lines and polygons in tile pixels at and below {@code zoom} * with {@code minPixelSize}. *

* This replaces all previous zoom overrides that were set. To use multiple zoom-level thresholds, create a * {@link ZoomFunction} explicitly and pass it to {@link #setPixelToleranceOverrides(ZoomFunction)} */ public Feature setPixelToleranceBelowZoom(int zoom, double tolerance) { if (zoom == config.maxzoom()) { pixelToleranceAtMaxZoom = tolerance; } return setPixelToleranceOverrides(ZoomFunction.maxZoom(zoom, tolerance)); } public boolean hasLabelGrid() { return labelGridPixelSize != null || labelGridLimit != null; } /** * Returns the size in pixels of the grid used to group or limit output points. * * @throws AssertionError when assertions are enabled and the returned value is smaller than the buffer pixel size */ public double getPointLabelGridPixelSizeAtZoom(int zoom) { double result = ZoomFunction.applyAsDoubleOrElse(labelGridPixelSize, zoom, DEFAULT_LABEL_GRID_SIZE); // TODO is this enough? what about a grid square that ends just before the start of the tile assert result <= getBufferPixelsAtZoom( zoom) : "to avoid inconsistent rendering of the same point between adjacent tiles, buffer pixel size should be >= label grid size but in '%s' buffer pixel size=%f was greater than label grid size=%f" .formatted( getLayer(), getBufferPixelsAtZoom(zoom), result); return result; } /** * Returns the maximum number of lowest-sort-key points to include in the output vector tile in each square of a * grid with size {@link #getPointLabelGridPixelSizeAtZoom(int)}. */ public int getPointLabelGridLimitAtZoom(int zoom) { return ZoomFunction.applyAsIntOrElse(labelGridLimit, zoom, DEFAULT_LABEL_GRID_LIMIT); } /** * Sets the size of a grid in tile pixels that will be used to compute a "location hash" of points rendered in each * zoom-level for limiting the density of features in the output tile, or computing a "rank" key that clients can * use to control label density. *

* If limit is set, features will be dropped automatically before encoding the vector tile, but "rank" must be added * explicitly in {@link Profile#postProcessLayerFeatures(String, int, List)}. *

* Replaces any previous values set for label grid pixel size. *

* NOTE: the label grid is computed within each tile independently of its neighbors, so to ensure consistent limits * and ranking of a point rendered in adjacent tiles, be sure to set the buffer pixel size to at least be larger * than the label grid pixel size at each zoom level. * * @param labelGridSize a function that returns the size of the label grid to use at each zoom level. If function is * or returns null for a zoom-level, no label grid will be computed. * @see LabelGrid postgis * function */ public Feature setPointLabelGridPixelSize(ZoomFunction labelGridSize) { this.labelGridPixelSize = labelGridSize; return this; } /** * Sets the size of a grid in tile pixels that will be used to compute a "location hash" of points rendered in each * zoom-level at and below {@code maxzoom}. *

* This is a thin wrapper around {@link #setPointLabelGridPixelSize(ZoomFunction)}. It replaces any previous value * set for label grid size. To set multiple thresholds, use the other method directly. * * @see LabelGrid postgis * function */ public Feature setPointLabelGridPixelSize(int maxzoom, double size) { return setPointLabelGridPixelSize(ZoomFunction.maxZoom(maxzoom, size)); } /** * Sets the maximum number of points with the lowest sort-key to include with the same label grid hash in a tile. *

* Replaces any previous values set for label grid limit. * * @param labelGridLimit a function that returns the size of the label grid to use at each zoom level. If function * is or returns null for a zoom-level, no label grid will be computed. * @see LabelGrid postgis * function */ public Feature setPointLabelGridLimit(ZoomFunction labelGridLimit) { this.labelGridLimit = labelGridLimit; return this; } /** * Limits the density of points on an output tile at and below {@code maxzoom} by only emitting the {@code limit} * features with lowest sort-key in each square of a {@code size x size} pixel grid. *

* This is a thin wrapper around {@link #setPointLabelGridPixelSize(ZoomFunction)} and * {@link #setPointLabelGridLimit(ZoomFunction)}. It replaces any previous value set for label grid size or limit. * To set multiple thresholds, use the other methods directly. *

* NOTE: the label grid is computed within each tile independently of its neighbors, so to ensure consistent limits * and ranking of a point rendered in adjacent tiles, be sure to set the buffer pixel size to at least be larger * than the label grid pixel size at each zoom level. * * @param maxzoom the zoom-level at and below which we should limit point density * @param size the label grid size to use when computing hashes * @param limit the number of lowest-sort-key points to include in each square of the grid * @see LabelGrid postgis * function */ public Feature setPointLabelGridSizeAndLimit(int maxzoom, double size, int limit) { return setPointLabelGridPixelSize(ZoomFunction.maxZoom(maxzoom, size)) .setPointLabelGridLimit(ZoomFunction.maxZoom(maxzoom, limit)); } // could be expensive, so cache results private Map computeAttrsAtZoom(int zoom) { Map result = new TreeMap<>(); for (var entry : attrs.entrySet()) { Object value = entry.getValue(); if (value instanceof ZoomFunction fn) { value = fn.apply(zoom); } if (value != null && !"".equals(value)) { result.put(entry.getKey(), value); } } return result; } /** Returns the attribute to put on all output vector tile features at a zoom level. */ public Map getAttrsAtZoom(int zoom) { if (!attrsChangeByZoom) { return attrs; } if (attrCache == null) { attrCache = CacheByZoom.create(config, this::computeAttrsAtZoom); } return attrCache.get(zoom); } /** Copies the value for {@code key} attribute from source feature to the output feature. */ public Feature inheritAttrFromSource(String key) { return setAttr(key, source.getTag(key)); } /** * Sets an attribute on the output feature to either a string, number, boolean, or instance of {@link ZoomFunction} * to change the value for {@code key} by zoom-level. */ public Feature setAttr(String key, Object value) { if (value instanceof ZoomFunction) { attrsChangeByZoom = true; } if (value != null) { attrs.put(key, value); } return this; } /** * Sets the value for {@code key} attribute at or above {@code minzoom}. Below {@code minzoom} it will be ignored. *

* Replaces all previous value that has been for {@code key} at any zoom level. To have a value that changes at * multiple zoom level thresholds, call {@link #setAttr(String, Object)} with a manually-constructed * {@link ZoomFunction} value. */ public Feature setAttrWithMinzoom(String key, Object value, int minzoom) { return setAttr(key, ZoomFunction.minZoom(minzoom, value)); } /** * Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature at or above * {@code minzoom}. *

* Replaces values that have already been set. */ public Feature putAttrsWithMinzoom(Map attrs, int minzoom) { for (var entry : attrs.entrySet()) { setAttrWithMinzoom(entry.getKey(), entry.getValue(), minzoom); } return this; } /** * Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature. *

* Does not touch attributes that have already been set. *

* Values in {@code attrs} can either be the raw value to set, or an instance of {@link ZoomFunction} to change the * value for that attribute by zoom level. */ public Feature putAttrs(Map attrs) { for (Object value : attrs.values()) { if (value instanceof ZoomFunction) { attrsChangeByZoom = true; break; } } this.attrs.putAll(attrs); return this; } /** * 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 Feature setNumPointsAttr(String numPointsAttr) { this.numPointsAttr = numPointsAttr; return this; } /** * 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 String getNumPointsAttr() { return numPointsAttr; } @Override public String toString() { return "Feature{" + "layer='" + layer + '\'' + ", geom=" + geom.getGeometryType() + ", attrs=" + attrs + '}'; } } }