matchKeys) {
- if (input instanceof SourceFeature sourceFeature) {
+ if (input instanceof WithGeometryType withGeom) {
return switch (type) {
- case LINESTRING_TYPE -> sourceFeature.canBeLine();
- case POLYGON_TYPE -> sourceFeature.canBePolygon();
- case POINT_TYPE -> sourceFeature.isPoint();
- case RELATION_MEMBER_TYPE -> sourceFeature.hasRelationInfo();
+ case LINESTRING_TYPE -> withGeom.canBeLine();
+ case POLYGON_TYPE -> withGeom.canBePolygon();
+ case POINT_TYPE -> withGeom.isPoint();
default -> false;
};
} else {
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java
index e39715da..97bc6c77 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/MultiExpression.java
@@ -3,10 +3,9 @@ package com.onthegomap.planetiler.expression;
import static com.onthegomap.planetiler.expression.Expression.FALSE;
import static com.onthegomap.planetiler.expression.Expression.TRUE;
import static com.onthegomap.planetiler.expression.Expression.matchType;
-import static com.onthegomap.planetiler.geo.GeoUtils.EMPTY_GEOMETRY;
-import com.onthegomap.planetiler.reader.SimpleFeature;
-import com.onthegomap.planetiler.reader.SourceFeature;
+import com.onthegomap.planetiler.reader.WithGeometryType;
+import com.onthegomap.planetiler.reader.WithTags;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -28,12 +27,12 @@ import org.slf4j.LoggerFactory;
* {@link #index()} returns an optimized {@link Index} that evaluates the minimal set of expressions on the keys present
* on the element.
*
- * {@link Index#getMatches(SourceFeature)} returns the data value associated with the expressions that match an input
+ * {@link Index#getMatches(WithTags)} )} returns the data value associated with the expressions that match an input
* element.
*
* @param type of data value associated with each expression
*/
-public record MultiExpression (List> expressions) {
+public record MultiExpression (List> expressions) implements Simplifiable> {
private static final Logger LOGGER = LoggerFactory.getLogger(MultiExpression.class);
private static final Comparator BY_ID = Comparator.comparingInt(WithId::id);
@@ -46,25 +45,6 @@ public record MultiExpression (List> expressions) {
return new Entry<>(result, expression);
}
- /**
- * Evaluates a list of expressions on an input element, storing the matches into {@code result} and using {@code
- * visited} to avoid evaluating an expression more than once.
- */
- private static void visitExpressions(SourceFeature input, List> result,
- boolean[] visited, List> expressions) {
- if (expressions != null) {
- for (EntryWithId expressionValue : expressions) {
- if (!visited[expressionValue.id]) {
- visited[expressionValue.id] = true;
- List matchKeys = new ArrayList<>();
- if (expressionValue.expression().evaluate(input, matchKeys)) {
- result.add(new Match<>(expressionValue.result, matchKeys, expressionValue.id));
- }
- }
- }
- }
- }
-
/**
* Returns true if {@code expression} only contains "not filter" so we can't limit evaluating this expression to only
* when a particular key is present on the input.
@@ -79,7 +59,9 @@ public record MultiExpression (List> expressions) {
} else if (expression instanceof Expression.MatchAny any && any.matchWhenMissing()) {
return true;
} else {
- return TRUE.equals(expression);
+ return !(expression instanceof Expression.MatchAny) &&
+ !(expression instanceof Expression.MatchField) &&
+ !FALSE.equals(expression);
}
}
@@ -136,8 +118,8 @@ public record MultiExpression (List> expressions) {
}
/**
- * Returns a copy of this multi-expression that replaces every sub-expression that matches {@code test} with {@code
- * b}.
+ * Returns a copy of this multi-expression that replaces every sub-expression that matches {@code test} with
+ * {@code b}.
*/
public MultiExpression replace(Predicate test, Expression b) {
return map(e -> e.replace(test, b));
@@ -151,8 +133,9 @@ public record MultiExpression (List> expressions) {
}
/** Returns a copy of this multi-expression with each expression simplified. */
- public MultiExpression simplify() {
- return map(e -> e.simplify());
+ @Override
+ public MultiExpression simplifyOnce() {
+ return map(Simplifiable::simplify);
}
/** Returns a copy of this multi-expression, filtering-out the entry for each data value matching {@code accept}. */
@@ -176,37 +159,36 @@ public record MultiExpression (List> expressions) {
/**
* An optimized index for finding which expressions match an input element.
*
- * @param type of data value associated with each expression
+ * @param type of data value associated with each expression
*/
- public interface Index {
+ public interface Index {
- List> getMatchesWithTriggers(SourceFeature input);
+ List> getMatchesWithTriggers(WithTags input);
/** Returns all data values associated with expressions that match an input element. */
- default List getMatches(SourceFeature input) {
- List> matches = getMatchesWithTriggers(input);
- return matches.stream().sorted(BY_ID).map(d -> d.match).toList();
+ default List getMatches(WithTags input) {
+ return getMatchesWithTriggers(input).stream().map(d -> d.match).toList();
}
/**
* Returns the data value associated with the first expression that match an input element, or {@code defaultValue}
* if none match.
*/
- default T getOrElse(SourceFeature input, T defaultValue) {
- List matches = getMatches(input);
+ default O getOrElse(WithTags input, O defaultValue) {
+ List matches = getMatches(input);
return matches.isEmpty() ? defaultValue : matches.get(0);
}
/**
* Returns the data value associated with expressions matching a feature with {@code tags}.
*/
- default T getOrElse(Map tags, T defaultValue) {
- List matches = getMatches(SimpleFeature.create(EMPTY_GEOMETRY, tags));
+ default O getOrElse(Map tags, O defaultValue) {
+ List matches = getMatches(WithTags.from(tags));
return matches.isEmpty() ? defaultValue : matches.get(0);
}
/** Returns true if any expression matches that tags from an input element. */
- default boolean matches(SourceFeature input) {
+ default boolean matches(WithTags input) {
return !getMatchesWithTriggers(input).isEmpty();
}
@@ -216,13 +198,14 @@ public record MultiExpression (List> expressions) {
}
private interface WithId {
+
int id();
}
private static class EmptyIndex implements Index {
@Override
- public List> getMatchesWithTriggers(SourceFeature input) {
+ public List> getMatchesWithTriggers(WithTags input) {
return List.of();
}
@@ -277,9 +260,28 @@ public record MultiExpression (List> expressions) {
numExpressions = id;
}
+ /**
+ * Evaluates a list of expressions on an input element, storing the matches into {@code result} and using
+ * {@code visited} to avoid evaluating an expression more than once.
+ */
+ private static void visitExpressions(WithTags input, List> result,
+ boolean[] visited, List> expressions) {
+ if (expressions != null) {
+ for (EntryWithId expressionValue : expressions) {
+ if (!visited[expressionValue.id]) {
+ visited[expressionValue.id] = true;
+ List matchKeys = new ArrayList<>();
+ if (expressionValue.expression().evaluate(input, matchKeys)) {
+ result.add(new Match<>(expressionValue.result, matchKeys, expressionValue.id));
+ }
+ }
+ }
+ }
+ }
+
/** Lookup matches in this index for expressions that match a certain type. */
@Override
- public List> getMatchesWithTriggers(SourceFeature input) {
+ public List> getMatchesWithTriggers(WithTags input) {
List> result = new ArrayList<>();
boolean[] visited = new boolean[numExpressions];
visitExpressions(input, result, visited, alwaysEvaluateExpressionList);
@@ -295,6 +297,7 @@ public record MultiExpression (List> expressions) {
}
}
}
+ result.sort(BY_ID);
return result;
}
}
@@ -305,6 +308,7 @@ public record MultiExpression (List> expressions) {
private final KeyIndex pointIndex;
private final KeyIndex lineIndex;
private final KeyIndex polygonIndex;
+ private final KeyIndex otherIndex;
private GeometryTypeIndex(MultiExpression expressions, boolean warn) {
// build an index per type then search in each of those indexes based on the geometry type of each input element
@@ -312,6 +316,7 @@ public record MultiExpression (List> expressions) {
pointIndex = indexForType(expressions, Expression.POINT_TYPE, warn);
lineIndex = indexForType(expressions, Expression.LINESTRING_TYPE, warn);
polygonIndex = indexForType(expressions, Expression.POLYGON_TYPE, warn);
+ otherIndex = indexForType(expressions, Expression.UNKNOWN_GEOMETRY_TYPE, warn);
}
private KeyIndex indexForType(MultiExpression expressions, String type, boolean warn) {
@@ -328,21 +333,26 @@ public record MultiExpression (List> expressions) {
* Returns all data values associated with expressions that match an input element, along with the tag keys that
* caused the match.
*/
- public List> getMatchesWithTriggers(SourceFeature input) {
+ public List> getMatchesWithTriggers(WithTags input) {
List> result;
- if (input.isPoint()) {
- result = pointIndex.getMatchesWithTriggers(input);
- } else if (input.canBeLine()) {
- result = lineIndex.getMatchesWithTriggers(input);
- // closed ways can be lines or polygons, unless area=yes or no
- if (input.canBePolygon()) {
- result.addAll(polygonIndex.getMatchesWithTriggers(input));
+ if (input instanceof WithGeometryType withGeometryType) {
+ if (withGeometryType.isPoint()) {
+ result = pointIndex.getMatchesWithTriggers(input);
+ } else if (withGeometryType.canBeLine()) {
+ result = lineIndex.getMatchesWithTriggers(input);
+ // closed ways can be lines or polygons, unless area=yes or no
+ if (withGeometryType.canBePolygon()) {
+ result.addAll(polygonIndex.getMatchesWithTriggers(input));
+ }
+ } else if (withGeometryType.canBePolygon()) {
+ result = polygonIndex.getMatchesWithTriggers(input);
+ } else {
+ result = otherIndex.getMatchesWithTriggers(input);
}
- } else if (input.canBePolygon()) {
- result = polygonIndex.getMatchesWithTriggers(input);
} else {
- result = pointIndex.getMatchesWithTriggers(input);
+ result = otherIndex.getMatchesWithTriggers(input);
}
+ result.sort(BY_ID);
return result;
}
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/Simplifiable.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/Simplifiable.java
new file mode 100644
index 00000000..640bb0c8
--- /dev/null
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/expression/Simplifiable.java
@@ -0,0 +1,53 @@
+package com.onthegomap.planetiler.expression;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * An expression that can be simplified to an equivalent, but cheaper to evaluate expression.
+ *
+ * Implementers should only override {@link #simplifyOnce()} which applies all the rules that can be used to simplify
+ * this expression, and {@link #simplify()} will take care of applying it repeatedly until the output settles to a fixed
+ * point.
+ *
+ * Implementers must also ensure {@code equals} and {@code hashCode} reflect equivalence between expressions so that
+ * {@link #simplify()} can know when to stop.
+ */
+public interface Simplifiable> {
+
+ /**
+ * Returns a copy of this expression, with all simplification rules applied once.
+ *
+ * {@link #simplify()} will take care of applying it repeatedly until the output settles.
+ */
+ default T simplifyOnce() {
+ return self();
+ }
+
+ default T self() {
+ @SuppressWarnings("unchecked") T self = (T) this;
+ return self;
+ }
+
+ /**
+ * Returns an equivalent, simplified copy of this expression but does not modify {@code this} by repeatedly running
+ * {@link #simplifyOnce()}.
+ */
+ default T simplify() {
+ // iteratively simplify the expression until we reach a fixed point and start seeing
+ // an expression that's already been seen before
+ T simplified = self();
+ Set seen = new HashSet<>();
+ seen.add(simplified);
+ while (true) {
+ simplified = simplified.simplifyOnce();
+ if (seen.contains(simplified)) {
+ return simplified;
+ }
+ if (seen.size() > 1000) {
+ throw new IllegalStateException("Infinite loop while simplifying " + this);
+ }
+ seen.add(simplified);
+ }
+ }
+}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeometryType.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeometryType.java
index e34bb2a6..faa4c879 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeometryType.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeometryType.java
@@ -1,11 +1,7 @@
package com.onthegomap.planetiler.geo;
import com.fasterxml.jackson.annotation.JsonProperty;
-import com.onthegomap.planetiler.FeatureCollector;
-import com.onthegomap.planetiler.FeatureCollector.Feature;
import com.onthegomap.planetiler.expression.Expression;
-import java.util.function.BiFunction;
-import java.util.function.Function;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.Lineal;
import org.locationtech.jts.geom.Polygonal;
@@ -13,30 +9,25 @@ import org.locationtech.jts.geom.Puntal;
import vector_tile.VectorTileProto;
public enum GeometryType {
- UNKNOWN(VectorTileProto.Tile.GeomType.UNKNOWN, 0, (f, l) -> {
- throw new UnsupportedOperationException();
- }, "unknown"),
+ UNKNOWN(VectorTileProto.Tile.GeomType.UNKNOWN, 0, "unknown"),
@JsonProperty("point")
- POINT(VectorTileProto.Tile.GeomType.POINT, 1, FeatureCollector::point, "point"),
+ POINT(VectorTileProto.Tile.GeomType.POINT, 1, "point"),
@JsonProperty("line")
- LINE(VectorTileProto.Tile.GeomType.LINESTRING, 2, FeatureCollector::line, "linestring"),
+ LINE(VectorTileProto.Tile.GeomType.LINESTRING, 2, "linestring"),
@JsonProperty("polygon")
- POLYGON(VectorTileProto.Tile.GeomType.POLYGON, 4, FeatureCollector::polygon, "polygon");
+ POLYGON(VectorTileProto.Tile.GeomType.POLYGON, 4, "polygon");
private final VectorTileProto.Tile.GeomType protobufType;
private final int minPoints;
- private final BiFunction geometryFactory;
private final String matchTypeString;
- GeometryType(VectorTileProto.Tile.GeomType protobufType, int minPoints,
- BiFunction geometryFactory, String matchTypeString) {
+ GeometryType(VectorTileProto.Tile.GeomType protobufType, int minPoints, String matchTypeString) {
this.protobufType = protobufType;
this.minPoints = minPoints;
- this.geometryFactory = geometryFactory;
this.matchTypeString = matchTypeString;
}
- public static GeometryType valueOf(Geometry geom) {
+ public static GeometryType typeOf(Geometry geom) {
return geom instanceof Puntal ? POINT : geom instanceof Lineal ? LINE : geom instanceof Polygonal ? POLYGON :
UNKNOWN;
}
@@ -66,17 +57,6 @@ public enum GeometryType {
return minPoints;
}
- /**
- * Generates a factory method which creates a {@link Feature} from a {@link FeatureCollector} of the appropriate
- * geometry type.
- *
- * @param layerName - name of the layer
- * @return geometry factory method
- */
- public Function geometryFactory(String layerName) {
- return features -> geometryFactory.apply(features, layerName);
- }
-
/**
* Generates a test for whether a source feature is of the correct geometry to be included in the tile.
*
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java
index d44a44b7..83b84155 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java
@@ -11,9 +11,7 @@ import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Lineal;
import org.locationtech.jts.geom.MultiLineString;
-import org.locationtech.jts.geom.MultiPoint;
import org.locationtech.jts.geom.MultiPolygon;
-import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
/**
@@ -26,7 +24,7 @@ import org.locationtech.jts.geom.Polygon;
* All geometries except for {@link #latLonGeometry()} return elements in world web mercator coordinates where (0,0) is
* the northwest corner and (1,1) is the southeast corner of the planet.
*/
-public abstract class SourceFeature implements WithTags {
+public abstract class SourceFeature implements WithTags, WithGeometryType {
private final Map tags;
private final String source;
@@ -247,25 +245,6 @@ public abstract class SourceFeature implements WithTags {
(isPoint() || canBePolygon() || canBeLine()) ? worldGeometry().getLength() : 0) : length;
}
- /** Returns true if this feature can be interpreted as a {@link Point} or {@link MultiPoint}. */
- public abstract boolean isPoint();
-
- /**
- * Returns true if this feature can be interpreted as a {@link Polygon} or {@link MultiPolygon}.
- *
- * A closed ring can either be a polygon or linestring, so return false to not allow this closed ring to be treated as
- * a polygon.
- */
- public abstract boolean canBePolygon();
-
- /**
- * Returns true if this feature can be interpreted as a {@link LineString} or {@link MultiLineString}.
- *
- * A closed ring can either be a polygon or linestring, so return false to not allow this closed ring to be treated as
- * a linestring.
- */
- public abstract boolean canBeLine();
-
/** Returns the ID of the source that this feature came from. */
public String getSource() {
return source;
@@ -308,6 +287,7 @@ public abstract class SourceFeature implements WithTags {
return id;
}
+
/** Returns true if this element has any OSM relation info. */
public boolean hasRelationInfo() {
return relationInfos != null && !relationInfos.isEmpty();
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/WithGeometryType.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/WithGeometryType.java
new file mode 100644
index 00000000..211187b2
--- /dev/null
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/WithGeometryType.java
@@ -0,0 +1,34 @@
+package com.onthegomap.planetiler.reader;
+
+import org.locationtech.jts.geom.LineString;
+import org.locationtech.jts.geom.MultiLineString;
+import org.locationtech.jts.geom.MultiPoint;
+import org.locationtech.jts.geom.MultiPolygon;
+import org.locationtech.jts.geom.Point;
+import org.locationtech.jts.geom.Polygon;
+
+/**
+ * Something attached to a geometry that can be matched using a
+ * {@link com.onthegomap.planetiler.expression.Expression.MatchType} geometry type filter expression.
+ */
+public interface WithGeometryType {
+
+ /** Returns true if this feature can be interpreted as a {@link Point} or {@link MultiPoint}. */
+ boolean isPoint();
+
+ /**
+ * Returns true if this feature can be interpreted as a {@link Polygon} or {@link MultiPolygon}.
+ *
+ * A closed ring can either be a polygon or linestring, so return false to not allow this closed ring to be treated as
+ * a polygon.
+ */
+ boolean canBePolygon();
+
+ /**
+ * Returns true if this feature can be interpreted as a {@link LineString} or {@link MultiLineString}.
+ *
+ * A closed ring can either be a polygon or linestring, so return false to not allow this closed ring to be treated as
+ * a linestring.
+ */
+ boolean canBeLine();
+}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/WithTags.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/WithTags.java
index f5808a77..80dbde4f 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/WithTags.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/WithTags.java
@@ -6,6 +6,9 @@ import java.util.Map;
/** An input element with a set of string key/object value pairs. */
public interface WithTags {
+ static WithTags from(Map tags) {
+ return new OfMap(tags);
+ }
/** The key/value pairs on this element. */
Map tags();
@@ -108,4 +111,6 @@ public interface WithTags {
default void setTag(String key, Object value) {
tags().put(key, value);
}
+
+ record OfMap(@Override Map tags) implements WithTags {}
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/ProgressLoggers.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/ProgressLoggers.java
index b2ccf0d4..3d7535ce 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/ProgressLoggers.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/ProgressLoggers.java
@@ -1,5 +1,9 @@
package com.onthegomap.planetiler.stats;
+import static com.onthegomap.planetiler.util.AnsiColors.blue;
+import static com.onthegomap.planetiler.util.AnsiColors.green;
+import static com.onthegomap.planetiler.util.AnsiColors.red;
+import static com.onthegomap.planetiler.util.AnsiColors.yellow;
import static com.onthegomap.planetiler.util.Exceptions.throwFatalException;
import static com.onthegomap.planetiler.util.Format.padLeft;
import static com.onthegomap.planetiler.util.Format.padRight;
@@ -39,36 +43,10 @@ import org.slf4j.LoggerFactory;
*/
@SuppressWarnings({"UnusedReturnValue", "unused"})
public class ProgressLoggers {
-
- private static final String COLOR_RESET = "\u001B[0m";
- private static final String FG_RED = "\u001B[31m";
- private static final String FG_GREEN = "\u001B[32m";
- private static final String FG_YELLOW = "\u001B[33m";
- private static final String FG_BLUE = "\u001B[34m";
private static final Logger LOGGER = LoggerFactory.getLogger(ProgressLoggers.class);
private final List loggers = new ArrayList<>();
private final Format format;
- private static String fg(String fg, String string) {
- return fg + string + COLOR_RESET;
- }
-
- private static String red(String string) {
- return fg(FG_RED, string);
- }
-
- private static String green(String string) {
- return fg(FG_GREEN, string);
- }
-
- private static String yellow(String string) {
- return fg(FG_YELLOW, string);
- }
-
- private static String blue(String string) {
- return fg(FG_BLUE, string);
- }
-
private ProgressLoggers(Locale locale) {
this.format = Format.forLocale(locale);
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/AnsiColors.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/AnsiColors.java
new file mode 100644
index 00000000..a0c20d99
--- /dev/null
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/AnsiColors.java
@@ -0,0 +1,50 @@
+package com.onthegomap.planetiler.util;
+
+/** Utilities for styling terminal output. */
+public class AnsiColors {
+ private AnsiColors() {}
+
+ private static final String COLOR_RESET = "\u001B[0m";
+ private static final String FG_RED = "\u001B[31m";
+ private static final String FG_GREEN = "\u001B[32m";
+ private static final String FG_YELLOW = "\u001B[33m";
+ private static final String FG_BLUE = "\u001B[34m";
+ private static final String REVERSE = "\u001B[7m";
+ private static final String BOLD = "\u001B[1m";
+
+ private static String color(String fg, String string) {
+ return fg + string + COLOR_RESET;
+ }
+
+ public static String red(String string) {
+ return color(FG_RED, string);
+ }
+
+ public static String green(String string) {
+ return color(FG_GREEN, string);
+ }
+
+ public static String yellow(String string) {
+ return color(FG_YELLOW, string);
+ }
+
+ public static String blue(String string) {
+ return color(FG_BLUE, string);
+ }
+
+ public static String redBackground(String string) {
+ return color(REVERSE + BOLD + FG_RED, string);
+ }
+
+ public static String greenBackground(String string) {
+ return color(REVERSE + BOLD + FG_GREEN, string);
+ }
+
+ public static String redBold(String string) {
+ return color(BOLD + FG_RED, string);
+ }
+
+ public static String greenBold(String string) {
+ return color(BOLD + FG_GREEN, string);
+ }
+}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/CacheByZoom.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/CacheByZoom.java
index b8f4079f..b2bb0be4 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/CacheByZoom.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/CacheByZoom.java
@@ -23,13 +23,12 @@ public class CacheByZoom {
/**
* Returns a cache for {@code supplier} that can handle a min/max zoom range specified in {@code config}.
*
- * @param config min/max zoom range this can handle
* @param supplier function that will be called with each zoom-level to get the value
* @param return type of the function
* @return a cache for {@code supplier} by zom
*/
- public static CacheByZoom create(PlanetilerConfig config, IntFunction supplier) {
- return new CacheByZoom<>(config.minzoom(), config.maxzoomForRendering(), supplier);
+ public static CacheByZoom create(IntFunction supplier) {
+ return new CacheByZoom<>(0, PlanetilerConfig.MAX_MAXZOOM, supplier);
}
public T get(int zoom) {
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileWatcher.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileWatcher.java
new file mode 100644
index 00000000..ab08f945
--- /dev/null
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileWatcher.java
@@ -0,0 +1,122 @@
+package com.onthegomap.planetiler.util;
+
+import static com.onthegomap.planetiler.util.Exceptions.throwFatalException;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Watches a set of paths so that each time you call {@link #poll()} it returns the set of paths that have been modified
+ * since the last call to {@link #poll()}.
+ */
+public class FileWatcher {
+ private final Map modificationTimes = new TreeMap<>();
+
+ /** Returns the canonical form of {@code path}. */
+ static Path normalize(Path path) {
+ return path.toAbsolutePath().normalize();
+ }
+
+ /** Returns a new file watcher watching a set of files for modifications. */
+ public static FileWatcher newWatcher(Path... paths) {
+ var watcher = new FileWatcher();
+ for (var path : paths) {
+ watcher.watch(path);
+ }
+ return watcher;
+ }
+
+ /** Returns the (normalized) paths modified since the last call to poll. */
+ public Set poll() {
+ Set result = new TreeSet<>();
+ for (var path : List.copyOf(modificationTimes.keySet())) {
+ if (watch(path)) {
+ result.add(path);
+ }
+ }
+ return result;
+ }
+
+ /** Adds {@code path} to the set of paths to check on each call to {@link #poll()}. */
+ public boolean watch(Path path) {
+ path = normalize(path);
+ Long modifiedTime;
+ try {
+ modifiedTime = Files.getLastModifiedTime(path).toMillis();
+ } catch (IOException e) {
+ modifiedTime = null;
+ }
+ var last = modificationTimes.put(path, modifiedTime);
+ return !Objects.equals(modifiedTime, last);
+ }
+
+ /** Removes {@code path} from the set of paths to check on each call to {@link #poll()}. */
+ public void unwatch(Path path) {
+ modificationTimes.remove(normalize(path));
+ }
+
+ /** Returns true if we are currently watching {@code path} for changes. */
+ public boolean watching(Path path) {
+ return modificationTimes.containsKey(normalize(path));
+ }
+
+ /** Ensures we are only watching {@code paths} provided. */
+ public void setWatched(Set paths) {
+ if (paths == null || paths.isEmpty()) {
+ return;
+ }
+ paths = paths.stream().map(FileWatcher::normalize).collect(Collectors.toSet());
+ for (var toWatch : paths) {
+ if (!watching(toWatch)) {
+ watch(toWatch);
+ }
+ }
+ for (var watching : Set.copyOf(modificationTimes.keySet())) {
+ if (!paths.contains(watching)) {
+ unwatch(watching);
+ }
+ }
+ }
+
+ /**
+ * Blocks and invokes {@code action} every time one of the watched files changes, checking every {@code delay}
+ * interval.
+ */
+ public void pollForChanges(Duration delay, FunctionThatThrows, Set> action) {
+ while (!Thread.currentThread().isInterrupted()) {
+ var changes = poll();
+ if (!changes.isEmpty()) {
+ setWatched(action.runAndWrapException(changes));
+ }
+ try {
+ Thread.sleep(delay.toMillis());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ @FunctionalInterface
+ public interface FunctionThatThrows {
+
+ @SuppressWarnings("java:S112")
+ O apply(I value) throws Exception;
+
+ default O runAndWrapException(I value) {
+ try {
+ return apply(value);
+ } catch (Exception e) {
+ return throwFatalException(e);
+ }
+ }
+ }
+}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Imposm3Parsers.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Imposm3Parsers.java
index ad4a3613..77f59f37 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Imposm3Parsers.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Imposm3Parsers.java
@@ -71,10 +71,12 @@ public class Imposm3Parsers {
*
* @see OSM one-way
*/
- public static int direction(Object string) {
- if (string == null) {
+ public static int direction(Object obj) {
+ if (obj == null) {
return 0;
- } else if (forwardDirections.contains(string(string))) {
+ }
+ String string = string(obj);
+ if (forwardDirections.contains(string)) {
return 1;
} else if ("-1".equals(string)) {
return -1;
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Parse.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Parse.java
index 55e19c7a..cf2cb1db 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Parse.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Parse.java
@@ -1,6 +1,10 @@
package com.onthegomap.planetiler.util;
+import java.text.NumberFormat;
+import java.text.ParseException;
+import java.util.Locale;
import java.util.Map;
+import java.util.function.Function;
import java.util.regex.Pattern;
/**
@@ -8,8 +12,6 @@ import java.util.regex.Pattern;
*/
public class Parse {
- private Parse() {}
-
private static final Pattern INT_SUBSTRING_PATTERN = Pattern.compile("^(-?\\d+)(\\D|$)");
private static final Pattern TO_ROUND_INT_SUBSTRING_PATTERN = Pattern.compile("^(-?[\\d.]+)(\\D|$)");
// See https://wiki.openstreetmap.org/wiki/Map_features/Units
@@ -17,13 +19,31 @@ public class Parse {
Pattern.compile(
"(?-?[\\d.]+)\\s*((?mi)|(?m|$)|(?km|kilom)|(?ft|')|(?in|\")|(?nmi|international nautical mile|nautical))",
Pattern.CASE_INSENSITIVE);
+ private static final NumberFormat PARSER = NumberFormat.getNumberInstance(Locale.ROOT);
+
+ private Parse() {}
/** Returns {@code tag} as a long or null if invalid. */
public static Long parseLongOrNull(Object tag) {
+ return tag == null ? null : tag instanceof Number number ? Long.valueOf(number.longValue()) :
+ parseLongOrNull(tag.toString());
+ }
+
+ /** Returns {@code tag} as a long or null if invalid. */
+ public static Long parseLongOrNull(String tag) {
try {
- return tag == null ? null : tag instanceof Number number ? number.longValue() : Long.parseLong(tag.toString());
+ return tag == null ? null : Long.parseLong(tag);
} catch (NumberFormatException e) {
- return null;
+ return retryParseNumber(tag, Number::longValue, null);
+ }
+ }
+
+ private static T retryParseNumber(Object obj, Function getter, T backup) {
+ // more expensive parser in case simple valueOf parse fails
+ try {
+ return getter.apply(PARSER.parse(obj.toString()));
+ } catch (ParseException e) {
+ return backup;
}
}
@@ -32,7 +52,7 @@ public class Parse {
try {
return tag == null ? 0 : tag instanceof Number number ? number.longValue() : Long.parseLong(tag.toString());
} catch (NumberFormatException e) {
- return 0;
+ return retryParseNumber(tag, Number::longValue, 0L);
}
}
@@ -64,16 +84,16 @@ public class Parse {
/** Returns {@code tag} as an integer or null if invalid. */
public static Integer parseIntOrNull(Object tag) {
- if (tag instanceof Number num) {
- return num.intValue();
- }
- if (!(tag instanceof String)) {
- return null;
- }
+ return tag == null ? null : tag instanceof Number number ? Integer.valueOf(number.intValue()) :
+ parseIntOrNull(tag.toString());
+ }
+
+ /** Returns {@code tag} as an integer or null if invalid. */
+ public static Integer parseIntOrNull(String tag) {
try {
- return Integer.parseInt(tag.toString());
+ return tag == null ? null : Integer.parseInt(tag);
} catch (NumberFormatException e) {
- return null;
+ return retryParseNumber(tag, Number::intValue, null);
}
}
@@ -101,17 +121,17 @@ public class Parse {
}
/** Returns {@code tag} as a double or null if invalid. */
- public static Double parseDoubleOrNull(Object value) {
- if (value instanceof Number num) {
- return num.doubleValue();
- }
- if (value == null) {
- return null;
- }
+ public static Double parseDoubleOrNull(Object tag) {
+ return tag == null ? null : tag instanceof Number number ? Double.valueOf(number.doubleValue()) :
+ parseDoubleOrNull(tag.toString());
+ }
+
+ /** Returns {@code tag} as a double or null if invalid. */
+ public static Double parseDoubleOrNull(String tag) {
try {
- return Double.parseDouble(value.toString());
+ return tag == null ? null : Double.parseDouble(tag);
} catch (NumberFormatException e) {
- return null;
+ return retryParseNumber(tag, Number::doubleValue, null);
}
}
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Try.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Try.java
new file mode 100644
index 00000000..69e5061e
--- /dev/null
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/Try.java
@@ -0,0 +1,62 @@
+package com.onthegomap.planetiler.util;
+
+/**
+ * A container for the result of an operation that may succeed or fail.
+ *
+ * @param Type of the result value, if success
+ */
+public interface Try {
+ /**
+ * Calls {@code supplier} and wraps the result in {@link Success} if successful, or {@link Failure} if it throws an
+ * exception.
+ */
+ static Try apply(SupplierThatThrows supplier) {
+ try {
+ return success(supplier.get());
+ } catch (Exception e) {
+ return failure(e);
+ }
+ }
+
+ static Success success(T item) {
+ return new Success<>(item);
+ }
+
+ static Failure failure(Exception throwable) {
+ return new Failure<>(throwable);
+ }
+
+ /**
+ * Returns the result if success, or throws an exception if failure.
+ *
+ * @throws IllegalStateException wrapping the exception on failure
+ */
+ T get();
+
+ default boolean isSuccess() {
+ return !isFailure();
+ }
+
+ default boolean isFailure() {
+ return exception() != null;
+ }
+
+ default Exception exception() {
+ return null;
+ }
+
+ record Success (T get) implements Try {}
+ record Failure (@Override Exception exception) implements Try {
+
+ @Override
+ public T get() {
+ throw new IllegalStateException(exception);
+ }
+ }
+
+ @FunctionalInterface
+ interface SupplierThatThrows {
+ @SuppressWarnings("java:S112")
+ T get() throws Exception;
+ }
+}
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/ExpressionTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/ExpressionTest.java
index bb90f1e8..74f2b7db 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/ExpressionTest.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/ExpressionTest.java
@@ -2,13 +2,11 @@ package com.onthegomap.planetiler.expression;
import static com.onthegomap.planetiler.expression.Expression.*;
import static com.onthegomap.planetiler.expression.ExpressionTestUtil.featureWithTags;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.*;
import com.onthegomap.planetiler.reader.WithTags;
import java.util.ArrayList;
+import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
@@ -30,6 +28,17 @@ class ExpressionTest {
);
}
+ @Test
+ void testSimplifyDuplicates() {
+ assertEquals(matchAB, or(or(matchAB), or(matchAB)).simplify());
+ assertEquals(matchAB, and(matchAB, matchAB).simplify());
+ }
+
+ @Test
+ void testMatchAnyEquals() {
+ assertEquals(matchAny("a", "b%"), matchAny("a", "b%"));
+ }
+
@Test
void testSimplifyOrWithOneChild() {
assertEquals(matchAB, or(matchAB).simplify());
@@ -133,12 +142,82 @@ class ExpressionTest {
@Test
void testContains() {
+ assertNull(matchCD.pattern());
assertTrue(matchCD.contains(e -> e.equals(matchCD)));
assertTrue(or(not(matchCD)).contains(e -> e.equals(matchCD)));
assertFalse(matchCD.contains(e -> e.equals(matchAB)));
assertFalse(or(not(matchCD)).contains(e -> e.equals(matchAB)));
}
+ @Test
+ void testWildcardStartsWith() {
+ var matcher = matchAny("key", "a%");
+ assertEquals(Set.of(), matcher.exactMatches());
+ assertNotNull(matcher.pattern());
+
+ assertTrue(matcher.evaluate(featureWithTags("key", "abc")));
+ assertTrue(matcher.evaluate(featureWithTags("key", "a")));
+ assertFalse(matcher.evaluate(featureWithTags("key", "cba")));
+ }
+
+ @Test
+ void testWildcardEndsWith() {
+ var matcher = matchAny("key", "%a");
+ assertEquals(Set.of(), matcher.exactMatches());
+ assertNotNull(matcher.pattern());
+
+ assertTrue(matcher.evaluate(featureWithTags("key", "cba")));
+ assertTrue(matcher.evaluate(featureWithTags("key", "a")));
+ assertFalse(matcher.evaluate(featureWithTags("key", "abc")));
+ }
+
+ @Test
+ void testWildcardContains() {
+ var matcher = matchAny("key", "%a%");
+ assertEquals(Set.of(), matcher.exactMatches());
+ assertNotNull(matcher.pattern());
+
+ assertTrue(matcher.evaluate(featureWithTags("key", "bab")));
+ assertTrue(matcher.evaluate(featureWithTags("key", "a")));
+ assertFalse(matcher.evaluate(featureWithTags("key", "c")));
+ }
+
+ @Test
+ void testWildcardAny() {
+ var matcher = matchAny("key", "%");
+ assertEquals(Set.of(), matcher.exactMatches());
+ assertNotNull(matcher.pattern());
+ assertEquals(matchField("key"), matcher.simplify());
+
+ assertTrue(matcher.evaluate(featureWithTags("key", "abc")));
+ assertFalse(matcher.evaluate(featureWithTags("key", "")));
+ }
+
+ @Test
+ void testWildcardMiddle() {
+ var matcher = matchAny("key", "a%c");
+ assertEquals(Set.of(), matcher.exactMatches());
+ assertNotNull(matcher.pattern());
+
+ assertTrue(matcher.evaluate(featureWithTags("key", "abc")));
+ assertTrue(matcher.evaluate(featureWithTags("key", "ac")));
+ assertFalse(matcher.evaluate(featureWithTags("key", "ab")));
+ }
+
+ @Test
+ void testWildcardEscape() {
+ assertTrue(matchAny("key", "a\\%").evaluate(featureWithTags("key", "a%")));
+ assertFalse(matchAny("key", "a\\%").evaluate(featureWithTags("key", "ab")));
+
+ assertTrue(matchAny("key", "a\\%b").evaluate(featureWithTags("key", "a%b")));
+ assertTrue(matchAny("key", "%a\\%b%").evaluate(featureWithTags("key", "dda%b")));
+ assertTrue(matchAny("key", "\\%%").evaluate(featureWithTags("key", "%abc")));
+ assertTrue(matchAny("key", "%\\%").evaluate(featureWithTags("key", "abc%")));
+ assertTrue(matchAny("key", "%\\%%").evaluate(featureWithTags("key", "a%c")));
+ assertTrue(matchAny("key", "%\\%%").evaluate(featureWithTags("key", "%")));
+ assertFalse(matchAny("key", "\\%%").evaluate(featureWithTags("key", "abc%")));
+ }
+
@Test
void testStringifyExpression() {
//Ensure Expression.toString() returns valid Java code
@@ -150,7 +229,7 @@ class ExpressionTest {
@Test
void testEvaluate() {
- WithTags feature = featureWithTags("key1", "value1", "key2", "value2");
+ WithTags feature = featureWithTags("key1", "value1", "key2", "value2", "key3", "");
//And
assertTrue(and(matchAny("key1", "value1"), matchAny("key2", "value2")).evaluate(feature));
@@ -173,9 +252,41 @@ class ExpressionTest {
assertFalse(matchField("wrong").evaluate(feature));
assertTrue(not(matchAny("key1", "")).evaluate(feature));
assertTrue(matchAny("wrong", "").evaluate(feature));
+ assertTrue(matchAny("key3", "").evaluate(feature));
//Constants
assertTrue(TRUE.evaluate(feature));
assertFalse(FALSE.evaluate(feature));
}
+
+ @Test
+ void testCustomExpression() {
+ Expression custom = new Expression() {
+ @Override
+ public boolean evaluate(WithTags input, List matchKeys) {
+ return input.hasTag("abc");
+ }
+
+ @Override
+ public String generateJavaCode() {
+ return null;
+ }
+ };
+ WithTags matching = featureWithTags("abc", "123");
+ WithTags notMatching = featureWithTags("abcd", "123");
+
+ assertTrue(custom.evaluate(matching));
+ assertTrue(and(custom).evaluate(matching));
+ assertTrue(and(custom, custom).evaluate(matching));
+ assertTrue(or(custom, custom).evaluate(matching));
+ assertTrue(and(TRUE, custom).evaluate(matching));
+ assertTrue(or(FALSE, custom).evaluate(matching));
+
+ assertFalse(custom.evaluate(notMatching));
+ assertFalse(and(custom).evaluate(notMatching));
+ assertFalse(and(custom, custom).evaluate(notMatching));
+ assertFalse(or(custom, custom).evaluate(notMatching));
+ assertFalse(and(TRUE, custom).evaluate(notMatching));
+ assertFalse(or(FALSE, custom).evaluate(notMatching));
+ }
}
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/MultiExpressionTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/MultiExpressionTest.java
index b0064570..3872fdf9 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/MultiExpressionTest.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/expression/MultiExpressionTest.java
@@ -8,6 +8,7 @@ import static com.onthegomap.planetiler.expression.ExpressionTestUtil.featureWit
import static com.onthegomap.planetiler.expression.MultiExpression.entry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.onthegomap.planetiler.expression.MultiExpression.Index;
@@ -131,8 +132,9 @@ class MultiExpressionTest {
private void matchFieldCheck(Index index) {
assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "value")));
- assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "")));
assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "value2", "otherkey", "othervalue")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", null)));
assertSameElements(List.of(), index.getMatches(featureWithTags("key2", "value", "key3", "value")));
assertSameElements(List.of(), index.getMatches(featureWithTags("key2", "value")));
assertSameElements(List.of(), index.getMatches(featureWithTags("key2", "no")));
@@ -145,7 +147,8 @@ class MultiExpressionTest {
entry("a", not(matchField("key")))
)).index();
assertSameElements(List.of(), index.getMatches(featureWithTags("key", "value")));
- assertSameElements(List.of(), index.getMatches(featureWithTags("key", "")));
+ assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "")));
+ assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", null)));
assertSameElements(List.of(), index.getMatches(featureWithTags("key", "value2", "otherkey", "othervalue")));
assertSameElements(List.of("a"), index.getMatches(featureWithTags("key2", "value", "key3", "value")));
assertSameElements(List.of("a"), index.getMatches(featureWithTags("key2", "value")));
@@ -207,6 +210,38 @@ class MultiExpressionTest {
assertSameElements(List.of(), index.getMatches(featureWithTags()));
}
+ @Test
+ void testStartsWith() {
+ var index = MultiExpression.of(List.of(
+ entry("a", matchAny("key", "value%"))
+ )).index();
+ assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "value")));
+ assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "value1")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "1value")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "1value1")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "1value1", "otherkey", "othervalue")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key2", "value", "key3", "value")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "no")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key2", "value")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags()));
+ }
+
+ @Test
+ void testEndsWith() {
+ var index = MultiExpression.of(List.of(
+ entry("a", matchAny("key", "%value"))
+ )).index();
+ assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "value")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "value1")));
+ assertSameElements(List.of("a"), index.getMatches(featureWithTags("key", "1value")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "1value1")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "1value1", "otherkey", "othervalue")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key2", "value", "key3", "value")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key", "no")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("key2", "value")));
+ assertSameElements(List.of(), index.getMatches(featureWithTags()));
+ }
+
@Test
void testMultipleMatches() {
var feature = featureWithTags("a", "b", "c", "d");
@@ -520,21 +555,26 @@ class MultiExpressionTest {
Expression polygonExpression = and(matchType("polygon"), matchField("field"));
Expression linestringExpression = and(matchType("linestring"), matchField("field"));
Expression pointExpression = and(matchType("point"), matchField("field"));
+ Expression otherExpression = matchField("field");
Map map = Map.of("field", "value");
SourceFeature point = SimpleFeature.create(newPoint(0, 0), map);
SourceFeature linestring = SimpleFeature.create(newLineString(0, 0, 1, 1), map);
SourceFeature polygon = SimpleFeature.create(rectangle(0, 1), map);
+ WithTags other = WithTags.from(Map.of("field", "value"));
var index = MultiExpression.of(List.of(
entry("polygon", polygonExpression),
entry("linestring", linestringExpression),
- entry("point", pointExpression)
+ entry("point", pointExpression),
+ entry("other", otherExpression)
)).index();
assertTrue(pointExpression.evaluate(point, new ArrayList<>()));
assertTrue(linestringExpression.evaluate(linestring, new ArrayList<>()));
assertTrue(polygonExpression.evaluate(polygon, new ArrayList<>()));
+ assertTrue(otherExpression.evaluate(other, new ArrayList<>()));
assertEquals("point", index.getOrElse(point, null));
assertEquals("linestring", index.getOrElse(linestring, null));
assertEquals("polygon", index.getOrElse(polygon, null));
+ assertEquals("other", index.getOrElse(other, null));
}
@Test
@@ -583,6 +623,44 @@ class MultiExpressionTest {
});
}
+ @Test
+ void testCustomExpression() {
+ Expression dontEvaluate = new Expression() {
+ @Override
+ public boolean evaluate(WithTags input, List matchKeys) {
+ throw new AssertionError("should not evaluate");
+ }
+
+ @Override
+ public String generateJavaCode() {
+ return null;
+ }
+ };
+ Expression matchAbc = new Expression() {
+ @Override
+ public boolean evaluate(WithTags input, List matchKeys) {
+ return input.hasTag("abc");
+ }
+
+ @Override
+ public String generateJavaCode() {
+ return null;
+ }
+ };
+ var index = MultiExpression.of(List.of(
+ entry("a", matchAbc),
+ entry("b", and(matchField("def"), dontEvaluate)),
+ entry("c", or(matchField("abc"), matchAbc))
+ )).index();
+
+ assertSameElements(List.of(), index.getMatches(featureWithTags()));
+ assertSameElements(List.of(), index.getMatches(featureWithTags("a", "1")));
+ assertSameElements(List.of("a", "c"), index.getMatches(featureWithTags("abc", "123")));
+ var bad = featureWithTags("def", "123");
+ assertThrows(AssertionError.class, () -> index.getMatches(bad));
+ }
+
+ @Test
void testAndOrMatch() {
var expr = and(
or(
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeometryTypeTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeometryTypeTest.java
index db1c6403..d7482edd 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeometryTypeTest.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeometryTypeTest.java
@@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test;
class GeometryTypeTest {
@Test
- void testGeometryFactory() throws Exception {
+ void testGeometryFactory() {
Map tags = Map.of("key1", "value1");
var line =
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/CacheByZoomTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/CacheByZoomTest.java
index f9c4d374..b7849d58 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/CacheByZoomTest.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/CacheByZoomTest.java
@@ -2,8 +2,6 @@ package com.onthegomap.planetiler.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import com.onthegomap.planetiler.config.Arguments;
-import com.onthegomap.planetiler.config.PlanetilerConfig;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -13,10 +11,7 @@ class CacheByZoomTest {
@Test
void testCacheZoom() {
List calls = new ArrayList<>();
- CacheByZoom cached = CacheByZoom.create(PlanetilerConfig.from(Arguments.of(
- "minzoom", "1",
- "maxzoom", "10"
- )), i -> {
+ CacheByZoom cached = CacheByZoom.create(i -> {
calls.add(i);
return i + 1;
});
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileWatcherTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileWatcherTest.java
new file mode 100644
index 00000000..f9cbbad9
--- /dev/null
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileWatcherTest.java
@@ -0,0 +1,88 @@
+package com.onthegomap.planetiler.util;
+
+import static com.onthegomap.planetiler.util.FileWatcher.normalize;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class FileWatcherTest {
+ @TempDir
+ static Path tempDir;
+ Path a = normalize(tempDir.resolve("a"));
+ Path b = normalize(tempDir.resolve("b"));
+ Path c = normalize(tempDir.resolve("c"));
+ long time = 1;
+
+ private void touch(Path... paths) throws IOException {
+ for (var path : paths) {
+ Files.write(path, new byte[0]);
+ Files.setLastModifiedTime(path, FileTime.fromMillis(time++));
+ }
+ }
+
+ @Test
+ void testWatch() throws IOException {
+ touch(a);
+ var watcher = FileWatcher.newWatcher(a, b);
+ assertEquals(Set.of(), watcher.poll());
+ assertEquals(Set.of(), watcher.poll());
+ touch(a);
+ assertEquals(Set.of(a), watcher.poll());
+ touch(b);
+ assertEquals(Set.of(b), watcher.poll());
+ touch(a, b);
+ assertEquals(Set.of(a, b), watcher.poll());
+ }
+
+ @Test
+ void testRemoveWatch() throws IOException {
+ touch(a, b);
+ var watcher = FileWatcher.newWatcher(a, b);
+ assertEquals(Set.of(), watcher.poll());
+ watcher.unwatch(a);
+ touch(a, b);
+ assertEquals(Set.of(b), watcher.poll());
+ watcher.unwatch(b);
+ touch(a, b);
+ assertEquals(Set.of(), watcher.poll());
+ }
+
+ @Test
+ void testReturnWatched() throws IOException {
+ touch(a);
+ var watcher = FileWatcher.newWatcher(a, b);
+ assertEquals(Set.of(), watcher.poll());
+ assertEquals(Set.of(), watcher.poll());
+ touch(a);
+ assertEquals(Set.of(a), watcher.poll());
+
+ watcher.setWatched(Set.of(b, c));
+ touch(b);
+ assertEquals(Set.of(b), watcher.poll());
+ touch(a);
+ assertEquals(Set.of(), watcher.poll());
+ touch(a, b, c);
+ assertEquals(Set.of(b, c), watcher.poll());
+
+ watcher.setWatched(Set.of(a));
+ touch(b);
+ assertEquals(Set.of(), watcher.poll());
+ touch(a);
+ assertEquals(Set.of(a), watcher.poll());
+ touch(a, b, c);
+ assertEquals(Set.of(a), watcher.poll());
+
+ watcher.setWatched(Set.of());
+ touch(a, b, c);
+ assertEquals(Set.of(a), watcher.poll());
+ watcher.setWatched(null);
+ touch(a, b, c);
+ assertEquals(Set.of(a), watcher.poll());
+ }
+}
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/ParseTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/ParseTest.java
index a642f8c6..fedff1bd 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/ParseTest.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/ParseTest.java
@@ -33,7 +33,8 @@ class ParseTest {
@CsvSource(value = {
"0, 0, 0",
"false, 0, null",
- "123, 123, 123"
+ "123, 123, 123",
+ "123.123, 123, 123",
}, nullValues = {"null"})
void testLong(String in, long out, Long obj) {
assertEquals(out, Parse.parseLong(in));
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TryTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TryTest.java
new file mode 100644
index 00000000..40e2bf00
--- /dev/null
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TryTest.java
@@ -0,0 +1,25 @@
+package com.onthegomap.planetiler.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+class TryTest {
+ @Test
+ void success() {
+ var result = Try.apply(() -> 1);
+ assertEquals(Try.success(1), result);
+ assertEquals(1, result.get());
+ }
+
+ @Test
+ void failure() {
+ var exception = new IllegalStateException();
+ var result = Try.apply(() -> {
+ throw exception;
+ });
+ assertEquals(Try.failure(exception), result);
+ assertThrows(IllegalStateException.class, result::get);
+ }
+}
diff --git a/planetiler-custommap/README.md b/planetiler-custommap/README.md
index 0230cadc..4a85bc80 100644
--- a/planetiler-custommap/README.md
+++ b/planetiler-custommap/README.md
@@ -1,113 +1,586 @@
# Configurable Planetiler Schema
-It is possible to customize planetiler's output from configuration files. This is done using the parameter:
-`--schema=schema_file.yml`
+You can define how planetiler turns input sources into vector tiles by running planetiler with a YAML configuration
+file as the first argument:
-The schema file provides information to planetiler about how to construct the tiles and which layers, features, and
-attributes will be posted to the file. Schema files are in [YAML](https://yaml.org) format.
+```bash
+# from a java build
+java -jar planetiler.jar schema.yml
+# or with docker (put the schema in data/schema.yml to include in the attached volume)
+docker run -v "$(pwd)/data":/data ghcr.io/onthegomap/planetiler:latest /data/schema.yml
+```
-NOTE: The configuration schema is under active development so the format may change between releases. Feedback is
-welcome to help shape the final product!
+Schema files are in [YAML 1.2](https://yaml.org) format and this page and
+accompanying [JSON schema](planetiler.schema.json) describe the required format and available
+options. See the [samples](src/main/resources/samples) directory for working examples.
-For examples, see [samples](src/main/resources/samples) or [test cases](src/test/resources/validSchema).
+:construction: The configuration schema is under active development so the format may change between releases.
+Only a subset of the Java API is currently exposed so for more complex schemas you should switch to the Java API (see
+the [examples project](../planetiler-examples)). Feedback is welcome to help shape the final product!
-## Schema file definition
+## Root
The root of the schema has the following attributes:
-* `schema_name` - A descriptive name for the schema
-* `schema_description` - A longer description of the schema
-* `attribution` - An attribution statement, which may include HTML such as links
-* `sources` - A list of sources from which features should be extracted, specified as a list of names.
+- `schema_name` - A descriptive name for the schema
+- `schema_description` - A longer description of the schema
+- `attribution` - An attribution string, which may include HTML such as links
+- `sources` - An object where key is the source ID and object is the [Source](#source) definition that points to a file
+ containing geographic features to process
+- `tag_mappings` - Specifies that certain tag key should have their values treated as a certain data type.
See [Tag Mappings](#tag-mappings).
-* `dataTypes` - A map of tag keys that should be treated as a certain data type, with strings being the default.
- See [Tag Mappings](#tag-mappings).
-* `layers` - A list of vector tile layers and their definitions. See [Layers](#layers)
+- `layers` - A list of vector tile [Layers](#layer) to emit and their definitions
+- `examples` - A list of [Test Case](#test-case) input features and the vector tile features they should map to, or a
+ relative path to a file with those examples in it. Run planetiler with `verify schema_file.yml` to see
+ if they work as expected.
+- `definitions` - An unparsed spot where you can
+ define [anchor labels](https://en.wikipedia.org/wiki/YAML#Advanced_components) to be used in other parts of the
+ schema
-### Data Sources
+For example:
-A data source contains geospatial objects with tags that are consumed by planetiler. The configured data sources in the
-schema provide complete information on how to access those data sources.
+```yaml
+schema_name: Power Lines
+schema_description: A map of power lines from OpenStreetMap
+attribution: © OpenStreetMap contributors
+sources: { ... }
+tag_mappings: { ... }
+layers: [...]
+examples: [...]
+definitions: # anything ...
+```
-* `type` - Either `shapefile` or `osm`
-* `url` - Location to download the shapefile from. For geofabrik named areas, use `geofabrik:` prefixes, for
- example `geofabrik:rhode-island`
+## Source
-### Layers
+A description that tells planetiler how to read geospatial objects with tags from an input file.
-A layer contains a thematically-related set of features.
+- `type` - Enum representing the file format of the data source, one
+ of [`osm`](https://wiki.openstreetmap.org/wiki/PBF_Format) or [`shapefile`](https://en.wikipedia.org/wiki/Shapefile)
+- `local_path` - Local path to the file to use, inferred from `url` if missing
+- `url` - Location to download the file from if not present at `local_path`.
+ For [geofabrik](https://download.geofabrik.de/) named areas, use `geofabrik:`
+ prefixes, for example `geofabrik:rhode-island`.
-* `name` - Name of this layer
-* `features` - A list of features contained in this layer. See [Features](#features)
+For example:
-### Features
+```yaml
+sources:
+ osm:
+ type: osm
+ url: geofabrik:switzerland
+```
-A feature is a defined set of objects that meet specified filter criteria.
+## Tag Mappings
-* `geometry` - Include objects of a certain geometry type. Options are `polygon`, `line`, or `point`.
-* `min_tile_cover_size` - include objects of a certain geometry size, where 1.0 means "is the same size as a tile at
- this zoom".
-* `include_when` - A tag specification which determines which features to include. If unspecified, all features from the
- specified sources are included. See [Tag Filters](#tag-filters)
-* `exclude_when` - A tag specification which determines which features to exclude. This rule is applied
- after `includeWhen`. If unspecified, no exclusion filter is applied. See [Tag Filters](#tag-filters)
-* `min_zoom` - Minimum zoom to show the feature that matches the filter specifications.
-* `zoom_override` - List of rules that overrides the `min_zoom` for this feature if certain tags are present. If
- multiple rules match, the first matching rule will be applied. See [Feature Zoom Overrides](#feature-zoom-override)
-* `attributes` - Specifies the attributes that should be rendered into the tiles for this feature, and how they are
- constructed. See [Attributes](#attributes)
+Specifies that certain tags should have their values parsed to a certain data type. This can be specified as an object
+where key is the tag name and value is the [data type](#data-type), for example:
-### Tag Mappings
+```yaml
+tag_mappings:
+ population: integer
+```
-Specifies that certain tag key should have their values treated as being a certain data type.
+If you still want to be able to access the original value, then you can remap the parsed value into a new tag
+using `type` and `input` fields:
-* `: data_type` - A key, along with one of `boolean`, `string`, `direction`, or `long`
-* `: mapping` - A mapping which produces a new attribute by retrieving from a different key.
- See [Tag Input and Output Mappings](#tag-input-and-output-mappings)
+```yaml
+tag_mappings:
+ population_as_int:
+ input: population
+ type: integer
+```
-### Tag Input and Output Mappings
+## Layer
-* `type`: One of `boolean`, `string`, `direction`, or `long`
-* `output`: The name of the typed key that will be presented to the attribute logic
+A layer contains a thematically-related set of features from one or more input sources.
-### Feature Zoom Override
+- `id` - Unique name of this layer
+- `features` - A list of features contained in this layer. See [Layer Features](#layer-feature)
-Specifies a zoom-based inclusion rules for this feature.
+For example:
-* `min` - Minimum zoom to render a feature matching this rule
-* `tag` - List of tags for which this rule applies. Tags are specified as a list of key/value pairs
+```yaml
+layers:
+ - id: power
+ features:
+ - { ... }
+ - { ... }
+```
-### Attributes
+## Layer Feature
-* `key` - Name of this attribute in the tile.
-* `constant_value` - Value of the attribute in the tile, as a constant
-* `tag_value` - Value of the attribute in the tile, as copied from the value of the specified tag key. If neither
- constantValue nor tagValue are specified, the default behavior is to set the tag value equal to the input value (
- pass-through)
-* `include_when` - A filter specification which determines whether to include this attribute. If unspecified, the
- attribute will be included unless excluded by `excludeWhen`. See [Tag Filters](#tag-filters)
-* `exclude_when` - A filter specification which determines whether to exclude this attribute. This rule is applied
- after `includeWhen`. If unspecified, no exclusion filter is applied. See [Tag Filters](#tag-filters)
-* `min_zoom` - The minimum zoom at which to render this attribute.
-* `min_zoom_by_value` - Minimum zoom to render this attribute depending on the value. Contains a map of `value: zoom`
- entries that indicate the minimum zoom for each possible value.
+A feature is a defined set of objects that meet a specified filter criteria.
-### Tag Filters
+- `source` - A string [source](#source) ID, or list of source IDs from which features should be extracted
+- `geometry` - A string enum that indicates which geometry types to include, and how to transform them. Can be one
+ of:
+ - `point` `line` or `polygon` to pass the original feature through
+ - `polygon_centroid` to match on polygons, and emit a point at the center
+ - `polygon_point_on_surface` to match on polygons, and emit an interior point
+ - `polygon_centroid_if_convex` to match on polygons, and if the polygon is convex emit the centroid, otherwise emit an
+ interior point
+- `min_tile_cover_size` - Include objects of a certain geometry size, where 1.0 means "is
+ the same size as a tile at this zoom"
+- `include_when` - A [Boolean Expression](#boolean-expression) which determines the features to include.
+ If unspecified, all features from the specified sources are included.
+- `exclude_when` - A [Boolean Expression](#boolean-expression) which determines if a feature that matched the include
+ expression should be skipped. If unspecified, no exclusion filter is applied.
+- `min_zoom` - An [Expression](#expression) that returns the minimum zoom to render this feature at.
+- `attributes` - An array of [Feature Attribute](#feature-attribute) objects that specify the attributes to be included
+ on this output feature.
-A tag filter matches an object based on its tagging. Multiple key entries may be specified:
+For example:
-* `:` - Match objects that contain this key.
-* ` ` - A single value or a list of values. Match objects in the specified key that contains one of these
- values. If no values are specified, this will match any value tagged with the specified key.
+```yaml
+source: osm
+geometry: line
+min_zoom: 7
+include_when:
+ power:
+ - line
+attributes:
+ - { ... }
+ - { ... }
+```
-Example: match all `natural=water`:
+## Feature Attribute
- natural: water
+Defines an attribute to include on an output vector tile feature and how to compute its value.
-Example: match residential, commercial, and industrial land use:
+- `key` - ID of this attribute in the tile
+- `include_when` - A [Boolean Expression](#boolean-expression) which determines whether to include
+ this attribute. If unspecified, the attribute will be included unless
+ excluded by `excludeWhen`.
+- `exclude_when` - A [Boolean Expression](#boolean-expression) which determines whether to exclude
+ this attribute. This rule is applied after `include_when`. If unspecified,
+ no exclusion filter is applied.
+- `min_zoom` - The minimum zoom at which to render this attribute
+- `min_zoom_by_value` - Minimum zoom to render this attribute depending on the
+ value. Contains an object with `: zoom` entries that indicate the
+ minimum zoom for each output value.
+- `type` - The [Data Type](#data-type) to coerce the value to, or `match_key` to set this attribute to the key that
+ triggered the match in the include expression, or `match_value` to set it to the value for the matching key.
- landuse:
- - residential
- - commercial
- - industrial
+To define the value, use one of:
+- `value` - A constant string/number/boolean value, or an [Expression](#expression) that computes the value for this key
+ for each input element.
+- `coalesce` - A [Coalesce Expression](#coalesce-expression) that sets this attribute to the first non-null match from a
+ list of expressions.
+- `tag_value` - A [Tag Value Expression](#tag-value-expression) that sets this attribute to the value for a tag.
+
+For example:
+
+```yaml
+key: voltage
+min_zoom: 10
+include_when: "${ double(feature.tags.voltage) > 1000 }"
+tag_value: voltage
+type: integer
+```
+
+## Data Type
+
+A string enum that defines how to map from an input. Allowed values:
+
+- `boolean` - Map 0, "no", or "false" to false and everything else to true
+- `string` - Returns the string representation of the input value
+- `direction` - Maps "-1" to -1, "1" "yes" or "true" to 1, and everything else to 0.
+ See [Key:oneway](https://wiki.openstreetmap.org/wiki/Key:oneway#Data_consumers).
+- `long` - Parses an input as a 64-bit signed number
+- `integer` - Parses an input as a 32-bit signed number
+- `double` - Parses an input as a floating point number
+
+## Expression
+
+Expressions let you define how to dynamically compute a value (attribute value, min zoom, etc.) at runtime. You can
+structure data-heavy expressions in YAML (ie. [match](#match-expression) or [coalesce](#coalesce-expression)) or
+simpler expressions that require more flexibility as an [inline script](#inline-script) using `${ expression }` syntax.
+
+### Constant Value Expression
+
+The simplest expression just returns a constant value from a string, number or boolean, for example:
+
+```yaml
+value: 1
+value: 'string'
+value: true
+```
+
+### Tag Value Expression
+
+Use `tag_value:` to return the value for each feature's tag at runtime:
+
+```yaml
+# return value for "natural" tag
+value:
+ tag_value: natural
+```
+
+### Coalesce Expression
+
+Use `coalesce: [expression, expression, ...]` to make the expression evaluate to the first non-null result of a list of
+expressions at runtime:
+
+```yaml
+value:
+ coalesce:
+ - tag_value: highway
+ - tag_value: aerialway
+ - tag_value: railway
+ - "fallback value"
+```
+
+### Match Expression
+
+Use `{ value1: condition1, value2: condition2, ... }` to make the expression evaluate to the value associated
+with the first matching [boolean expression](#boolean-expression) at runtime:
+
+```yaml
+value:
+ # returns "farmland" if subclass is farmland, farm, or orchard
+ farmland:
+ subclass:
+ - farmland
+ - farm
+ - orchard
+ ice:
+ subclass:
+ - glacier
+ - ice_shelf
+ # "otherwise" keyword means this is the fallback value
+ water: otherwise
+```
+
+If the values are not simple strings, then you can use an array of objects with `if` / `value` / `else` conditions:
+
+```yaml
+value:
+ - value: 100000
+ if:
+ place: city
+ - value: 5000
+ if:
+ place: town
+ - value: 100
+ if:
+ place: [village, neighborhood]
+ # fallback value
+ - else: 0
+```
+
+In some cases it is more straightforward to express match logic as a `default_value` with `overrides`, for example:
+
+```yaml
+min_zoom:
+ default_value: 13
+ overrides:
+ 5:
+ # match motorway or motorway_link
+ highway: motorway%
+ 6:
+ highway: trunk%
+ 8:
+ highway: primary%
+```
+
+Default values, and values associated with conditions can themselves be an [Expression](#expression).
+
+### Type
+
+Add the `type` property to any expression to coerce the result to a particular [data type](#data-type):
+
+```yaml
+value:
+ tag_value: oneway
+ type: direction
+```
+
+### Inline Script
+
+Use `${ expression }` syntax to compute a value dynamically at runtime using an
+embedded [Common Expression Language (CEL)](https://github.com/google/cel-spec) script.
+
+For example, to normalize highway values like "motorway_link" to "motorway":
+
+```yaml
+value: '${ feature.tags.highway.replace("_link", "") }'
+```
+
+If a script's value will never change, planetiler evaluates it once ahead of time, so you can also use this to
+compute a complex value with no runtime overhead:
+
+```yaml
+value: "${ 8 * 24 - 2 }"
+```
+
+#### Inline Script Contexts
+
+Scripts are parsed and evaluated inside a "context" that defines the variables available to that script. Contexts are
+nested, so each child context can also access the variables from its parent.
+
+> ##### root context
+>
+> defines no variables
+>
+>> ##### process feature context
+>>
+>> Context available when processing an input feature, for example testing whether to include it from `include_when`.
+>> Available variables:
+>>
+>> - `feature.tags` - map with key/value tags from the input feature
+>> - `feature.id` - numeric ID of the input feature
+>> - `feature.source` - string source ID this feature came from
+>> - `feature.source_layer` - optional layer within the source the feature came from
+>>
+>>> ##### post-match context
+>>>
+>>> Context available after a feature has matched, for example computing an attribute value. Adds variables:
+>>>
+>>> - `match_key` - string tag that triggered a match to include the feature in this layer
+>>> - `match_value` - the tag value associated with that key
+>>>
+>>>> ##### configure attribute context
+>>>>
+>>>> Context available after the value of an attribute has been computed, for example: set min zoom to render an
+>>>> attribute. Adds variables:
+>>>>
+>>>> - `value` the value that was computed for this key
+
+For example:
+
+```yaml
+# return the value associated with the matching tag, converted to lower case:
+value: '${ match_value.lowerAscii() }'
+```
+
+#### Built-In Functions
+
+Inline scripts can use
+the [standard CEL built-in functions](https://github.com/google/cel-spec/blob/master/doc/langdef.md#list-of-standard-definitions)
+plus the following added by planetiler (defined
+in [PlanetilerStdLib](src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerStdLib.java)).
+
+- `coalesce(any, any, ...)` returns the first non-null argument
+- `nullif(arg1, arg2)` returns null if arg1 is the same as arg2, otherwise arg1
+- `min(list)` returns the minimum value from a list
+- `max(list)` returns the maximum value from a list
+- map extensions:
+ - `.has(key)` returns true if the map contains a key
+ - `.has(key, value)` returns true if the map contains a key and the value for that key is value
+ - `.has(key, value1, value2, ...)` returns true if the map contains a key and the value for that key is in the
+ list provided
+ - `.get(key)` similar to `map[key]` except it returns null instead of throwing an error if the map is missing
+ that key
+ - `.getOrDefault(key, default)` returns the value for key if it is present, otherwise default
+- string extensions:
+ - `.charAt(number)` returns the character at an index from a string
+ - `.indexOf(string)` returns the first index of a substring or -1 if not found
+ - `.lastIndexOf(string)` returns the last index of a substring or -1 if not found
+ - `.join(separator)` returns a string that joins elements together separated by the provided string
+ - `.lowerAscii()` returns the input string transformed to lower-case
+ - `.upperAscii()` returns the input string transformed to upper-case
+ - `.replace(from, to)` returns the input string with all occurrences of from replaced by to
+ - `.replace(from, to, limit)` returns the input string with the first N occurrences of from replaced by to
+ - `.replaceRegex(pattern, value)` replaces every occurrence of regular expression with value from the string
+ it was called on using java's
+ built-in [replaceAll]()
+ behavior
+ - `.split(separator)` returns a list of strings split from the input by a separator
+ - `.split(separator, limit)` splits the list into up to N parts
+ - `.substring(n)` returns a copy of the string with first N characters omitted
+ - `.substring(a, b)` returns a substring from index [a, b)
+ - `.trim()` trims leading and trailing whitespace
+
+## Boolean Expression
+
+A boolean expression evaluates to true or false for a given input feature. It can be specified as
+a [tag-based boolean expression](#tag-based-boolean-expressions),
+a [complex boolean expression](#complex-boolean-expressions), or
+an [inline script](#inline-boolean-expression-script).
+
+### Tag-Based Boolean Expressions
+
+Boolean expressions can be specified as a map from key to value or list of values. For example:
+
+```yaml
+# match features where natural=glacier, waterway=riverbank, OR waterway=canal
+include_when:
+ natural: water
+ waterway:
+ - riverbank
+ - canal
+```
+
+Planetiler optimizes runtime performance by pre-processing all of the `include_when` boolean expressions in
+each [match expression](#match-expression) and `include_when` block in order to evaluate the minimum set of them at
+runtime based on the tags present on the feature.
+
+To match when a tag is present, use the `__any__` keyword:
+
+```yaml
+# match when the feature has a building tag
+include_when:
+ building: __any__
+```
+
+To match when a feature does _not_ have a tag use `''` as the value:
+
+```yaml
+# exclude features without a name tag
+exclude_when:
+ name: ""
+```
+
+To match when the value for a key matches a pattern, use the `%` wildcard character:
+
+```yaml
+# include features where highway tag ends in "_link"
+include_when:
+ highway: "%_link"
+```
+
+When a feature matches a boolean expression in the `include_when` field, the first key that triggered the match is
+available to other expressions as `match_key` and its value is available as `match_value`
+(See [Post-Match Context](#post-match-context)):
+
+```yaml
+include_when:
+ highway:
+ - motorway%
+ - trunk%
+ - primary%
+ railway: rail
+attributes:
+ # set "kind" attribute to the value for highway or railway, with trailing "_link" stripped off
+ - key: kind
+ value: '${ match_value.replace("_link", ") }'
+```
+
+### Complex Boolean Expressions
+
+The [tag-based boolean expressions](#tag-based-boolean-expressions) above match when _any_ of the tag conditions are
+true, but to match only when all of them are true, you can nest them under an `__all__` key:
+
+```yaml
+# match when highway=pedestrian or highway=service AND area=yes
+__all__:
+ highway:
+ - pedestrian
+ - service
+ area: yes
+```
+
+`__all__` can take an array as well. By default, each array item matches if _any_ of its children match, and you can
+make that explicit with the `__any__` keyword:
+
+```yaml
+# match when highway=pedestrian OR foot=yes, and area=yes
+__all__:
+- highway: pedestrian
+ foot: yes
+- area: yes
+
+# equivalent to:
+__all__:
+- __any__:
+ highway: pedestrian
+ foot: yes
+- area: yes
+```
+
+You can also match when the subexpression is false using the `__not__` keyword:
+
+```yaml
+# match when place=city AND capital is not 'yes' or '4'
+__all__:
+ place: city
+ __not__:
+ capital: [yes, "4"]
+```
+
+### Inline Boolean Expression Script
+
+You can also specify boolean logic with an [inline script](#inline-script) that evaluates to `true` or `false` using
+the `${ expression }` syntax. For example:
+
+```yaml
+# set the `min_zoom` attribute to:
+# 2 if area > 20 million, 3 if > 7 million, 4 if > 1 million, or 5 otherwise
+min_zoom:
+ default_value: 5
+ overrides:
+ 2: "${ double(feature.tags.area) >= 2e8 }"
+ 3: "${ double(feature.tags.area) >= 7e7 }"
+ 4: "${ double(feature.tags.area) >= 1e7 }"
+```
+
+:warning: If you use an expression script in `include_when`, it will get evaluated against every input element
+and will not set the `match_key` or `match_value` variables. When possible,
+use [structured tag expressions](#tag-based-boolean-expressions) which are optimized for runtime matching performance.
+
+You can, however combine a post-filter in an `__all__` block which will only get evaluated if
+the [structured tag expressions](#tag-based-boolean-expressions) matches first:
+
+```yaml
+# Include a feature when place=city or place=town
+# AND it has a population tag
+# AND the population value is greater than 10000
+include_when:
+ __all__:
+ - place: [city, town]
+ - population: __any__
+ # only evaluated if previous conditions are true
+ - "${ double(feature.tags.population) > 10000 }"
+```
+
+## Test Case
+
+An example input source feature, and the expected vector tile features that it produces. Run planetiler
+with `verify schema.yml` to test your schema against each of the examples. Or you can add the `--watch` argument watch
+the input file(s) for changes and validate the test cases on each change:
+
+```yaml
+# from a java build
+java -jar planetiler.jar verify schema.yml --watch
+# or with docker (put the schema in data/schema.yml to include in the attached volume)
+docker run -v "$(pwd)/data":/data ghcr.io/onthegomap/planetiler:latest verify /data/schema.yml --watch
+```
+
+- `name` - Unique name for this test case.
+- `input` - The input feature from a source, with the following attributes:
+ - `source` - ID of the source this feature comes from.
+ - `geometry` - Geometry type of the input feature, one of `point` `line` `polygon` or
+ a [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) encoding of a specific geometry.
+ - `tags` - Key/value attributes on the source feature.
+- `output` - The output vector tile feature(s) this map to, or `[]` for no features. Allowed attributes:
+ - `layer` - Vector tile layer of the expected output feature.
+ - `geometry` - Geometry type of the expected output feature.
+ - `min_zoom` - Min zoom level that the output feature appears in.
+ - `max_zoom` - Max zoom level that the output feature appears in.
+ - `tags` - Attributes expected on the output vector tile feature, or `null` if the attribute should not be set. Use
+ `allow_extra_tags: true` to fail if any other tags appear besides the ones specified here.
+ - `allow_extra_tags` - If `true`, then fail when extra attributes besides tags appear on the output feature.
+ If `false` or unset then ignore them.
+ - `at_zoom` - Some attributes change by zoom level, so get values at this zoom level for comparison.
+
+For example:
+
+```yaml
+name: Example power=line
+input:
+ geometry: line
+ source: osm
+ tags:
+ power: line
+ voltage: "1200"
+output:
+ - layer: power
+ geometry: line
+ min_zoom: 7
+ tags:
+ power: line
+ voltage: 1200
+```
+
+See [shortbread.spec.yml](src/main/resources/samples/shortbread.spec.yml) for more examples.
diff --git a/planetiler-custommap/planetiler.schema.json b/planetiler-custommap/planetiler.schema.json
new file mode 100644
index 00000000..d8dbeaec
--- /dev/null
+++ b/planetiler-custommap/planetiler.schema.json
@@ -0,0 +1,451 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/onthegomap/planetiler/main/planetiler-custommap/planetiler.schema.json",
+ "title": "Planetiler",
+ "description": "Planetiler schema definition",
+ "type": "object",
+ "properties": {
+ "schema_name": {
+ "description": "A descriptive name for the schema",
+ "type": "string"
+ },
+ "schema_description": {
+ "description": "A longer description of the schema",
+ "type": "string"
+ },
+ "attribution": {
+ "description": "An attribution statement, which may include HTML such as links",
+ "type": "string"
+ },
+ "definitions": {
+ "description": "An unparsed spot where you can define anchors and aliases to be used in other parts of the schema",
+ "type": "object",
+ "properties": {
+ "attributes": {
+ "description": "An unparsed array of attribute fragments to be used below.",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/attribute"
+ }
+ }
+ }
+ },
+ "sources": {
+ "description": "An object where key is the source ID and value is the definition of where the features should be extracted from",
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "description": "File format of the data source",
+ "enum": [
+ "osm",
+ "shapefile"
+ ]
+ },
+ "url": {
+ "description": "Location to download the file from. For geofabrik named areas, use `geofabrik:` prefixes, for example `geofabrik:rhode-island`.",
+ "type": "string"
+ },
+ "local_path": {
+ "description": "Local path to the file to use, inferred from `url` if missing"
+ }
+ },
+ "anyOf": [
+ {
+ "required": [
+ "url"
+ ]
+ },
+ {
+ "required": [
+ "local_path"
+ ]
+ }
+ ]
+ }
+ },
+ "tag_mappings": {
+ "description": "Specifies that certain tag key should have their values treated as being a certain data type",
+ "type": "object",
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/datatype"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "type": {
+ "$ref": "#/$defs/datatype"
+ },
+ "input": {
+ "description": "The name of the key that this attribute is parsed from",
+ "type": "string"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "layers": {
+ "description": "A list of vector tile layers and their definitions",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "properties": {
+ "id": {
+ "description": "Unique layer name",
+ "type": "string"
+ },
+ "features": {
+ "description": "A list of features contained in this layer",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/feature"
+ }
+ }
+ }
+ }
+ },
+ "examples": {
+ "description": "Example input features and the vector tile features they map to, or a relative path to a file with those examples in it.",
+ "oneOf": [
+ {
+ "$ref": "#/$defs/include"
+ },
+ {
+ "$ref": "planetilerspec.schema.json#/properties/examples"
+ }
+ ]
+ }
+ },
+ "$defs": {
+ "datatype": {
+ "type": "string",
+ "enum": [
+ "boolean",
+ "string",
+ "direction",
+ "long",
+ "integer",
+ "double"
+ ]
+ },
+ "feature": {
+ "type": "object",
+ "required": [
+ "geometry"
+ ],
+ "properties": {
+ "geometry": {
+ "description": "Include objects of a certain geometry type",
+ "type": "string",
+ "enum": [
+ "point",
+ "line",
+ "polygon",
+ "polygon_centroid",
+ "polygon_centroid_if_convex",
+ "polygon_point_on_surface"
+ ]
+ },
+ "source": {
+ "description": "A source ID or list of source IDs from which features should be extracted",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "min_tile_cover_size": {
+ "description": "include objects of a certain geometry size, where 1.0 means \"is the same size as a tile at this zoom\"",
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "include_when": {
+ "description": "A tag specification which determines the features to include. If unspecified, all features from the specified sources are included",
+ "$ref": "#/$defs/boolean_expression"
+ },
+ "exclude_when": {
+ "description": "A tag specification which determines the features to exclude. This rule is applied after `includeWhen`. If unspecified, no exclusion filter is applied.",
+ "$ref": "#/$defs/boolean_expression"
+ },
+ "min_zoom": {
+ "description": "An expression that returns the minimum zoom to render this feature at.",
+ "$ref": "#/$defs/expression"
+ },
+ "attributes": {
+ "description": "Specifies the attributes that should be rendered into the tiles for this feature, and how they are constructed",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/attribute"
+ }
+ }
+ }
+ },
+ "zoom_level": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 15
+ },
+ "attribute": {
+ "type": "object",
+ "anyOf": [
+ {
+ "$ref": "#/$defs/expression_coalesce"
+ },
+ {
+ "$ref": "#/$defs/expression_tag_value"
+ },
+ {
+ "$ref": "#/$defs/expression_value"
+ },
+ {
+ "$ref": "#/$defs/expression_with_type_or_match_key_value"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "key": {
+ "description": "ID of this attribute in the tile",
+ "type": "string"
+ },
+ "include_when": {
+ "description": "A filter specification which determines whether to include this attribute. If unspecified, the attribute will be included unless excluded by `excludeWhen`",
+ "$ref": "#/$defs/boolean_expression"
+ },
+ "exclude_when": {
+ "description": "A filter specification which determines whether to exclude this attribute. This rule is applied after `includeWhen`. If unspecified, no exclusion filter is applied.",
+ "$ref": "#/$defs/boolean_expression"
+ },
+ "min_zoom": {
+ "description": "The minimum zoom at which to render this attribute",
+ "$ref": "#/$defs/zoom_level"
+ },
+ "min_zoom_by_value": {
+ "description": "Minimum zoom to render this attribute depending on the value. Contains a map of `value: zoom` entries that indicate the minimum zoom for each possible value",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/$defs/zoom_level"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "boolean_expression": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "#/$defs/single_boolean_expression"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/single_boolean_expression"
+ }
+ }
+ ]
+ },
+ "single_boolean_expression": {
+ "type": "object",
+ "properties": {
+ "__all__": {
+ "$ref": "#/$defs/boolean_expression"
+ },
+ "__any__": {
+ "$ref": "#/$defs/boolean_expression"
+ },
+ "__not__": {
+ "$ref": "#/$defs/boolean_expression"
+ }
+ },
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "description": "Matches any value for this key",
+ "const": "__any__"
+ },
+ {
+ "description": "Matches when this key is missing or empty",
+ "const": ""
+ },
+ {
+ "type": "array",
+ "description": "A list of possible values for the key",
+ "items": {
+ "description": "One of the possible values for the key"
+ }
+ },
+ {
+ "description": "A single value for the key"
+ }
+ ]
+ }
+ },
+ "expression": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "$ref": "#/$defs/expression_default_overrides"
+ },
+ {
+ "$ref": "#/$defs/expression_match"
+ },
+ {
+ "$ref": "#/$defs/expression_coalesce"
+ },
+ {
+ "$ref": "#/$defs/expression_tag_value"
+ },
+ {
+ "$ref": "#/$defs/expression_value"
+ },
+ {
+ "$ref": "#/$defs/expression_with_type"
+ },
+ {
+ "$ref": "#/$defs/multiexpression"
+ }
+ ]
+ },
+ "expression_with_type_or_match_key_value": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "description": "Type of the attribute to map to",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "match_key",
+ "match_value"
+ ]
+ },
+ {
+ "$ref": "#/$defs/datatype"
+ }
+ ]
+ }
+ }
+ },
+ "expression_with_type": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "description": "Type of the attribute to map to",
+ "$ref": "#/$defs/datatype"
+ }
+ }
+ },
+ "expression_tag_value": {
+ "type": "object",
+ "properties": {
+ "tag_value": {
+ "description": "Value of the attribute in the tile, as copied from the value of the specified tag key. If neither constantValue nor tagValue are specified, the default behavior is to set the tag value equal to the input value (pass-through)",
+ "$ref": "#/$defs/expression"
+ }
+ }
+ },
+ "expression_value": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "description": "An expression that computes the value for this key for each input element",
+ "$ref": "#/$defs/expression"
+ }
+ }
+ },
+ "expression_coalesce": {
+ "type": "object",
+ "properties": {
+ "coalesce": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/expression"
+ }
+ }
+ }
+ },
+ "expression_match": {
+ "type": "object",
+ "properties": {
+ "match": {
+ "$ref": "#/$defs/multiexpression"
+ }
+ }
+ },
+ "expression_default_overrides": {
+ "type": "object",
+ "properties": {
+ "default_value": {
+ "$ref": "#/$defs/expression"
+ },
+ "overrides": {
+ "$ref": "#/$defs/multiexpression"
+ }
+ }
+ },
+ "multiexpression": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/multiexpression_object"
+ },
+ {
+ "$ref": "#/$defs/multiexpression_array"
+ }
+ ]
+ },
+ "multiexpression_object": {
+ "type": "object",
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/boolean_expression"
+ },
+ {
+ "const": "otherwise"
+ }
+ ]
+ }
+ },
+ "multiexpression_array": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "$ref": "#/$defs/expression"
+ },
+ "if": {
+ "$ref": "#/$defs/boolean_expression"
+ },
+ "else": {
+ "$ref": "#/$defs/expression"
+ }
+ }
+ }
+ },
+ "include": {
+ "type": "string"
+ }
+ }
+}
diff --git a/planetiler-custommap/planetilerspec.schema.json b/planetiler-custommap/planetilerspec.schema.json
new file mode 100644
index 00000000..c642ca18
--- /dev/null
+++ b/planetiler-custommap/planetilerspec.schema.json
@@ -0,0 +1,116 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/onthegomap/planetiler/main/planetiler-custommap/planetilerspec.schema.json",
+ "title": "Planetiler Specification",
+ "description": "Planetiler schema specification with example input features and the expected output features",
+ "type": "object",
+ "properties": {
+ "examples": {
+ "description": "A list of example input features, and the output vector tile features they should map to",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "Unique name for this test case",
+ "type": "string"
+ },
+ "input": {
+ "description": "The input feature from a source",
+ "type": "object",
+ "properties": {
+ "source": {
+ "description": "ID of the source this feature comes from",
+ "type": "string"
+ },
+ "geometry": {
+ "description": "Geometry type of the input feature",
+ "type": "string",
+ "enum": [
+ "polygon",
+ "line",
+ "point"
+ ]
+ },
+ "tags": {
+ "description": "Key/value attributes on the source feature",
+ "oneOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ },
+ "output": {
+ "description": "The output vector tile feature(s) this map to, or [] for no features",
+ "oneOf": [
+ {
+ "$ref": "#/$defs/output"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/output"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "$defs": {
+ "output": {
+ "type": "object",
+ "properties": {
+ "layer": {
+ "description": "Vector tile layer of the expected output feature",
+ "type": "string"
+ },
+ "geometry": {
+ "description": "Geometry type of the expected output feature",
+ "$ref": "#/$defs/geometry"
+ },
+ "min_zoom": {
+ "description": "Min zoom level that the output feature appears in",
+ "type": "integer"
+ },
+ "max_zoom": {
+ "description": "Max zoom level that the output feature appears in",
+ "type": "integer"
+ },
+ "tags": {
+ "description": "Attributes expected on the output vector tile feature, or null if the attribute should not be set. Use allow_extra_tags: true to fail if any other tags appear besides the ones specified here",
+ "oneOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "allow_extra_tags": {
+ "description": "If true, then fail when extra attributes besides tags appear on the output feature. If false or unset then ignore them.",
+ "type": "boolean"
+ },
+ "at_zoom": {
+ "description": "Some attributes change by zoom level, so get values at this zoom level for comparison",
+ "type": "integer"
+ }
+ }
+ },
+ "geometry": {
+ "type": "string",
+ "enum": [
+ "polygon",
+ "line",
+ "point"
+ ]
+ }
+ }
+}
diff --git a/planetiler-custommap/pom.xml b/planetiler-custommap/pom.xml
index ede7ad58..1b85be7b 100644
--- a/planetiler-custommap/pom.xml
+++ b/planetiler-custommap/pom.xml
@@ -19,13 +19,17 @@
${project.parent.version}