kopia lustrzana https://github.com/onthegomap/planetiler
Add length/area units and CEL expression geometry accessors (#1084)
rodzic
6e7de64645
commit
2c4062db1a
|
@ -0,0 +1,214 @@
|
|||
package com.onthegomap.planetiler.geo;
|
||||
|
||||
import com.onthegomap.planetiler.util.ToDoubleFunctionThatThrows;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import systems.uom.common.USCustomary;
|
||||
|
||||
/** Units of length and area measurement based off of constants defined in {@link USCustomary}. */
|
||||
public interface Unit {
|
||||
|
||||
Pattern EXTRA_CHARS = Pattern.compile("[^a-z]+");
|
||||
Pattern TRAILING_S = Pattern.compile("s$");
|
||||
|
||||
private static <T extends Unit> Map<String, T> index(T[] values) {
|
||||
return Arrays.stream(values)
|
||||
.flatMap(unit -> Stream.concat(unit.symbols().stream(), Stream.of(unit.unitName(), unit.toString()))
|
||||
.map(label -> Map.entry(normalize(label), unit))
|
||||
.distinct())
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
}
|
||||
|
||||
private static String normalize(String unit) {
|
||||
String result = EXTRA_CHARS.matcher(unit.toLowerCase()).replaceAll("");
|
||||
return TRAILING_S.matcher(result).replaceAll("");
|
||||
}
|
||||
|
||||
/** The {@link Base} measurement this unit is based off of. */
|
||||
Base base();
|
||||
|
||||
/** Computes the size of {@code geometry} in this unit. */
|
||||
double of(WithGeometry geometry);
|
||||
|
||||
/** The aliases for this unit. */
|
||||
List<String> symbols();
|
||||
|
||||
/** The full name for this unit. */
|
||||
String unitName();
|
||||
|
||||
/** Converts a measurement in {@link Base} units to this unit. */
|
||||
double fromBaseUnit(double base);
|
||||
|
||||
/** The base units that all other units are derived from. */
|
||||
enum Base {
|
||||
/** Size of a feature in "z0 tiles" where 1=the length/width/area entire world. */
|
||||
Z0_TILE(
|
||||
WithGeometry::length,
|
||||
WithGeometry::area),
|
||||
/** Size of a feature in meters. */
|
||||
METER(
|
||||
WithGeometry::lengthMeters,
|
||||
WithGeometry::areaMeters);
|
||||
|
||||
private final ToDoubleFunctionThatThrows<WithGeometry> area;
|
||||
private final ToDoubleFunctionThatThrows<WithGeometry> length;
|
||||
|
||||
Base(ToDoubleFunctionThatThrows<WithGeometry> length, ToDoubleFunctionThatThrows<WithGeometry> area) {
|
||||
this.length = length;
|
||||
this.area = area;
|
||||
}
|
||||
|
||||
public double area(WithGeometry geometry) {
|
||||
return area.applyAndWrapException(geometry);
|
||||
}
|
||||
|
||||
public double length(WithGeometry geometry) {
|
||||
return length.applyAndWrapException(geometry);
|
||||
}
|
||||
}
|
||||
|
||||
/** Units to measure line length. */
|
||||
enum Length implements Unit {
|
||||
METER(USCustomary.METER, "m"),
|
||||
FOOT(USCustomary.FOOT, "ft", "feet"),
|
||||
YARD(USCustomary.YARD, "yd"),
|
||||
NAUTICAL_MILE(USCustomary.NAUTICAL_MILE, "nm"),
|
||||
MILE(USCustomary.MILE, "mi", "miles"),
|
||||
KILOMETER(Base.METER, 1e-3, List.of("km"), "Kilometer"),
|
||||
|
||||
Z0_PIXEL(Base.Z0_TILE, 1d / 256, List.of("z0_px"), "Z0 Pixel"),
|
||||
Z0_TILE(Base.Z0_TILE, 1d, List.of("z0_ti"), "Z0 Tile");
|
||||
|
||||
private static final Map<String, Length> NAMES = index(values());
|
||||
private final Base base;
|
||||
private final double multiplier;
|
||||
private final List<String> symbols;
|
||||
private final String name;
|
||||
|
||||
Length(Base base, double multiplier, List<String> symbols, String name) {
|
||||
this.base = base;
|
||||
this.multiplier = multiplier;
|
||||
this.symbols = Stream.concat(symbols.stream(), Stream.of(name, name())).distinct().toList();
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
Length(javax.measure.Unit<javax.measure.quantity.Length> from, String... alias) {
|
||||
this(Base.METER, USCustomary.METER.getConverterTo(from).convert(1d), List.of(alias), from.getName());
|
||||
}
|
||||
|
||||
public static Length from(String label) {
|
||||
Length unit = NAMES.get(normalize(label));
|
||||
if (unit == null) {
|
||||
throw new IllegalArgumentException("Could not find area unit for '%s'".formatted(label));
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double fromBaseUnit(double i) {
|
||||
return i * multiplier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Base base() {
|
||||
return base;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double of(WithGeometry geometry) {
|
||||
return fromBaseUnit(base.length.applyAndWrapException(geometry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> symbols() {
|
||||
return symbols;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String unitName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Units to measure polygon areas. */
|
||||
enum Area implements Unit {
|
||||
SQUARE_METER(Length.METER),
|
||||
SQUARE_FOOT(Length.FOOT),
|
||||
SQUARE_YARD(Length.YARD),
|
||||
SQUARE_NAUTICAL_MILE(Length.NAUTICAL_MILE),
|
||||
SQUARE_MILE(Length.MILE),
|
||||
SQUARE_KILOMETER(Length.KILOMETER),
|
||||
|
||||
SQUARE_Z0_PIXEL(Length.Z0_PIXEL),
|
||||
SQUARE_Z0_TILE(Length.Z0_TILE),
|
||||
|
||||
ARE(USCustomary.ARE, "a"),
|
||||
HECTARE(USCustomary.HECTARE, "ha"),
|
||||
ACRE(USCustomary.ACRE, "ac");
|
||||
|
||||
private static final Map<String, Area> NAMES = index(values());
|
||||
private final Base base;
|
||||
private final double multiplier;
|
||||
private final List<String> symbols;
|
||||
private final String name;
|
||||
|
||||
Area(Base base, double multiplier, List<String> symbols, String name) {
|
||||
this.base = base;
|
||||
this.multiplier = multiplier;
|
||||
this.symbols = symbols;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
Area(Length length) {
|
||||
this(length.base, length.multiplier * length.multiplier,
|
||||
length.symbols().stream().flatMap(symbol -> Stream.of(
|
||||
"s" + symbol,
|
||||
"square " + symbol,
|
||||
"sq " + symbol,
|
||||
symbol + "2"
|
||||
)).toList(),
|
||||
"Square " + length.name);
|
||||
}
|
||||
|
||||
Area(javax.measure.Unit<javax.measure.quantity.Area> area, String... symbols) {
|
||||
this(Base.METER, USCustomary.ARE.getConverterTo(area).convert(0.01d), List.of(symbols), area.getName());
|
||||
}
|
||||
|
||||
public static Area from(String label) {
|
||||
Area unit = NAMES.get(normalize(label));
|
||||
if (unit == null) {
|
||||
throw new IllegalArgumentException("Could not find area unit for '%s'".formatted(label));
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double fromBaseUnit(double base) {
|
||||
return base * multiplier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Base base() {
|
||||
return base;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double of(WithGeometry geometry) {
|
||||
return fromBaseUnit(base.area.applyAndWrapException(geometry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> symbols() {
|
||||
return symbols;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String unitName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,344 @@
|
|||
package com.onthegomap.planetiler.geo;
|
||||
|
||||
import com.onthegomap.planetiler.reader.WithGeometryType;
|
||||
import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle;
|
||||
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.MultiPolygon;
|
||||
import org.locationtech.jts.geom.Polygon;
|
||||
import org.locationtech.jts.geom.Polygonal;
|
||||
import org.locationtech.jts.geom.Puntal;
|
||||
|
||||
/**
|
||||
* Wraps a geometry and provides cached accessor methods for applying transformations and transforming to lat/lon.
|
||||
* <p>
|
||||
* 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 WithGeometry implements WithGeometryType {
|
||||
private Geometry centroid = null;
|
||||
private Geometry pointOnSurface = null;
|
||||
private Geometry centroidIfConvex = null;
|
||||
private double innermostPointTolerance = Double.NaN;
|
||||
private Geometry innermostPoint = null;
|
||||
private Geometry linearGeometry = null;
|
||||
private Geometry polygonGeometry = null;
|
||||
private Geometry validPolygon = null;
|
||||
private double area = Double.NaN;
|
||||
private double length = Double.NaN;
|
||||
private double areaMeters = Double.NaN;
|
||||
private double lengthMeters = Double.NaN;
|
||||
private LineSplitter lineSplitter;
|
||||
|
||||
|
||||
/**
|
||||
* Returns a geometry in world web mercator coordinates.
|
||||
*
|
||||
* @return the geometry in web mercator coordinates
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
public abstract Geometry worldGeometry() throws GeometryException;
|
||||
|
||||
|
||||
/**
|
||||
* Returns this geometry in latitude/longitude degree coordinates.
|
||||
*
|
||||
* @return the latitude/longitude geometry
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
public abstract Geometry latLonGeometry() throws GeometryException;
|
||||
|
||||
|
||||
/**
|
||||
* Returns and caches the result of {@link Geometry#getArea()} of this feature in world web mercator coordinates where
|
||||
* {@code 1} means the area of the entire planet.
|
||||
*/
|
||||
public double area() throws GeometryException {
|
||||
return Double.isNaN(area) ? (area = canBePolygon() ? Math.abs(polygon().getArea()) : 0) : area;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and caches the result of {@link Geometry#getLength()} of this feature in world web mercator coordinates
|
||||
* where {@code 1} means the circumference of the entire planet or the distance from 85 degrees north to 85 degrees
|
||||
* south.
|
||||
*/
|
||||
public double length() throws GeometryException {
|
||||
return Double.isNaN(length) ? (length =
|
||||
(isPoint() || canBePolygon() || canBeLine()) ? worldGeometry().getLength() : 0) : length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sqrt of {@link #area()} if polygon or {@link #length()} if a line string.
|
||||
*/
|
||||
public double size() throws GeometryException {
|
||||
return canBePolygon() ? Math.sqrt(Math.abs(area())) : canBeLine() ? length() : 0;
|
||||
}
|
||||
|
||||
/** Returns the approximate area of the geometry in square meters. */
|
||||
public double areaMeters() throws GeometryException {
|
||||
return Double.isNaN(areaMeters) ? (areaMeters =
|
||||
(isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.areaInMeters(latLonGeometry()) : 0) : areaMeters;
|
||||
}
|
||||
|
||||
/** Returns the approximate length of the geometry in meters. */
|
||||
public double lengthMeters() throws GeometryException {
|
||||
return Double.isNaN(lengthMeters) ? (lengthMeters =
|
||||
(isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.lengthInMeters(latLonGeometry()) : 0) : lengthMeters;
|
||||
}
|
||||
|
||||
/** Returns the sqrt of {@link #areaMeters()} if polygon or {@link #lengthMeters()} if a line string. */
|
||||
public double sizeMeters() throws GeometryException {
|
||||
return canBePolygon() ? Math.sqrt(Math.abs(areaMeters())) : canBeLine() ? lengthMeters() : 0;
|
||||
}
|
||||
|
||||
|
||||
/** Returns the length of this geometry in units of {@link Unit.Length}. */
|
||||
public double length(Unit.Length length) {
|
||||
return length.of(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of this geometry if it is a line or the square root of the area if it is a polygon in units of
|
||||
* {@link Unit.Length}.
|
||||
*/
|
||||
public double size(Unit.Length length) {
|
||||
return canBePolygon() ? Math.sqrt(length.base().area(this)) : length.base().length(this);
|
||||
}
|
||||
|
||||
/** Returns the area of this geometry in units of {@link Unit.Area}. */
|
||||
public double area(Unit.Area area) {
|
||||
return area.of(this);
|
||||
}
|
||||
|
||||
/** Returns and caches {@link Geometry#getCentroid()} of this geometry in world web mercator coordinates. */
|
||||
public final Geometry centroid() throws GeometryException {
|
||||
return centroid != null ? centroid : (centroid =
|
||||
canBePolygon() ? polygon().getCentroid() :
|
||||
canBeLine() ? line().getCentroid() :
|
||||
worldGeometry().getCentroid());
|
||||
}
|
||||
|
||||
/** Returns and caches {@link Geometry#getInteriorPoint()} of this geometry in world web mercator coordinates. */
|
||||
public final Geometry pointOnSurface() throws GeometryException {
|
||||
return pointOnSurface != null ? pointOnSurface : (pointOnSurface =
|
||||
canBePolygon() ? polygon().getInteriorPoint() :
|
||||
canBeLine() ? line().getInteriorPoint() :
|
||||
worldGeometry().getInteriorPoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@link MaximumInscribedCircle#getCenter()} of this geometry in world web mercator coordinates.
|
||||
*
|
||||
* @param tolerance precision for calculating maximum inscribed circle. 0.01 means 1% of the square root of the area.
|
||||
* Smaller values for a more precise tolerance become very expensive to compute. Values between
|
||||
* 0.05-0.1 are a good compromise of performance vs. precision.
|
||||
*/
|
||||
public final Geometry innermostPoint(double tolerance) throws GeometryException {
|
||||
if (canBePolygon()) {
|
||||
// cache as long as the tolerance hasn't changed
|
||||
if (tolerance != innermostPointTolerance || innermostPoint == null) {
|
||||
innermostPoint = MaximumInscribedCircle.getCenter(polygon(), Math.sqrt(area()) * tolerance);
|
||||
innermostPointTolerance = tolerance;
|
||||
}
|
||||
return innermostPoint;
|
||||
} else if (canBeLine()) {
|
||||
return lineMidpoint();
|
||||
} else {
|
||||
return pointOnSurface();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the midpoint of this line, or the longest segment if it is a multilinestring.
|
||||
*/
|
||||
public final Geometry lineMidpoint() throws GeometryException {
|
||||
if (innermostPoint == null) {
|
||||
innermostPoint = pointAlongLine(0.5);
|
||||
}
|
||||
return innermostPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns along this line where {@code ratio=0} is the start {@code ratio=1} is the end and {@code ratio=0.5} is the
|
||||
* midpoint.
|
||||
* <p>
|
||||
* When this is a multilinestring, the longest segment is used.
|
||||
*/
|
||||
public final Geometry pointAlongLine(double ratio) throws GeometryException {
|
||||
if (lineSplitter == null) {
|
||||
var line = line();
|
||||
lineSplitter = new LineSplitter(line instanceof MultiLineString multi ? GeoUtils.getLongestLine(multi) : line);
|
||||
}
|
||||
return lineSplitter.get(ratio);
|
||||
}
|
||||
|
||||
private Geometry computeCentroidIfConvex() throws GeometryException {
|
||||
if (!canBePolygon()) {
|
||||
return centroid();
|
||||
} else if (polygon() instanceof Polygon poly &&
|
||||
poly.getNumInteriorRing() == 0 &&
|
||||
GeoUtils.isConvex(poly.getExteriorRing())) {
|
||||
return centroid();
|
||||
} else { // multipolygon, polygon with holes, or concave polygon
|
||||
return pointOnSurface();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and caches a point inside the geometry in world web mercator coordinates.
|
||||
* <p>
|
||||
* If the geometry is convex, uses the faster {@link Geometry#getCentroid()} but otherwise falls back to the slower
|
||||
* {@link Geometry#getInteriorPoint()}.
|
||||
*/
|
||||
public final Geometry centroidIfConvex() throws GeometryException {
|
||||
return centroidIfConvex != null ? centroidIfConvex : (centroidIfConvex = computeCentroidIfConvex());
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates.
|
||||
*
|
||||
* @return the linestring in web mercator coordinates
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
protected Geometry computeLine() throws GeometryException {
|
||||
Geometry world = worldGeometry();
|
||||
return world instanceof Lineal ? world : GeoUtils.polygonToLineString(world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a line
|
||||
*/
|
||||
public final Geometry line() throws GeometryException {
|
||||
if (!canBeLine()) {
|
||||
throw new GeometryException("feature_not_line", "cannot be line", true);
|
||||
}
|
||||
if (linearGeometry == null) {
|
||||
linearGeometry = computeLine();
|
||||
}
|
||||
return linearGeometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a partial line string from {@code start} to {@code end} where 0 is the beginning of the line and 1 is the
|
||||
* end of the line.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a single line (multilinestrings are not allowed).
|
||||
*/
|
||||
public final Geometry partialLine(double start, double end) throws GeometryException {
|
||||
Geometry line = line();
|
||||
if (start <= 0 && end >= 1) {
|
||||
return line;
|
||||
} else if (line instanceof LineString lineString) {
|
||||
if (this.lineSplitter == null) {
|
||||
this.lineSplitter = new LineSplitter(lineString);
|
||||
}
|
||||
return lineSplitter.get(start, end);
|
||||
} else {
|
||||
throw new GeometryException("partial_multilinestring", "cannot get partial of a multiline", true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes this feature as a {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates.
|
||||
*
|
||||
* @return the polygon in web mercator coordinates
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
protected Geometry computePolygon() throws GeometryException {
|
||||
return worldGeometry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this feature as a {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a line
|
||||
*/
|
||||
public final Geometry polygon() throws GeometryException {
|
||||
if (!canBePolygon()) {
|
||||
throw new GeometryException("feature_not_polygon", "cannot be polygon", true);
|
||||
}
|
||||
return polygonGeometry != null ? polygonGeometry : (polygonGeometry = computePolygon());
|
||||
}
|
||||
|
||||
private Geometry computeValidPolygon() throws GeometryException {
|
||||
Geometry polygon = polygon();
|
||||
if (!polygon.isValid()) {
|
||||
polygon = GeoUtils.fixPolygon(polygon);
|
||||
}
|
||||
return polygon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this feature as a valid {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates.
|
||||
* <p>
|
||||
* Validating and fixing invalid polygons can be expensive, so use only if necessary. Invalid polygons will also be
|
||||
* fixed at render-time.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a line
|
||||
*/
|
||||
public final Geometry validatedPolygon() throws GeometryException {
|
||||
if (!canBePolygon()) {
|
||||
throw new GeometryException("feature_not_polygon", "cannot be polygon", true);
|
||||
}
|
||||
return validPolygon != null ? validPolygon : (validPolygon = computeValidPolygon());
|
||||
}
|
||||
|
||||
/** Wraps a world web mercator geometry. */
|
||||
public static WithGeometry fromWorldGeometry(Geometry worldGeometry) {
|
||||
return new FromWorld(worldGeometry);
|
||||
}
|
||||
|
||||
private static class FromWorld extends WithGeometry {
|
||||
private final Geometry worldGeometry;
|
||||
private Geometry latLonGeometry;
|
||||
|
||||
FromWorld(Geometry worldGeometry) {
|
||||
this.worldGeometry = worldGeometry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Geometry worldGeometry() {
|
||||
return worldGeometry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Geometry latLonGeometry() {
|
||||
return latLonGeometry != null ? latLonGeometry : (latLonGeometry = GeoUtils.worldToLatLonCoords(worldGeometry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPoint() {
|
||||
return worldGeometry instanceof Puntal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canBePolygon() {
|
||||
return worldGeometry instanceof Polygonal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canBeLine() {
|
||||
return worldGeometry instanceof Lineal;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,20 +1,11 @@
|
|||
package com.onthegomap.planetiler.reader;
|
||||
|
||||
import com.onthegomap.planetiler.geo.GeoUtils;
|
||||
import com.onthegomap.planetiler.geo.GeometryException;
|
||||
import com.onthegomap.planetiler.geo.LineSplitter;
|
||||
import com.onthegomap.planetiler.geo.WithGeometry;
|
||||
import com.onthegomap.planetiler.reader.osm.OsmReader;
|
||||
import com.onthegomap.planetiler.reader.osm.OsmRelationInfo;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle;
|
||||
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.MultiPolygon;
|
||||
import org.locationtech.jts.geom.Polygon;
|
||||
|
||||
/**
|
||||
* Base class for input features read from a data source.
|
||||
|
@ -26,26 +17,14 @@ 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, WithGeometryType, WithSource, WithSourceLayer {
|
||||
public abstract class SourceFeature extends WithGeometry
|
||||
implements WithTags, WithSource, WithSourceLayer {
|
||||
|
||||
private final Map<String, Object> tags;
|
||||
private final String source;
|
||||
private final String sourceLayer;
|
||||
private final List<OsmReader.RelationMember<OsmRelationInfo>> relationInfos;
|
||||
private final long id;
|
||||
private Geometry centroid = null;
|
||||
private Geometry pointOnSurface = null;
|
||||
private Geometry centroidIfConvex = null;
|
||||
private double innermostPointTolerance = Double.NaN;
|
||||
private Geometry innermostPoint = null;
|
||||
private Geometry linearGeometry = null;
|
||||
private Geometry polygonGeometry = null;
|
||||
private Geometry validPolygon = null;
|
||||
private double area = Double.NaN;
|
||||
private double length = Double.NaN;
|
||||
private double areaMeters = Double.NaN;
|
||||
private double lengthMeters = Double.NaN;
|
||||
private LineSplitter lineSplitter;
|
||||
|
||||
/**
|
||||
* Constructs a new input feature.
|
||||
|
@ -72,255 +51,6 @@ public abstract class SourceFeature implements WithTags, WithGeometryType, WithS
|
|||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this feature's geometry in latitude/longitude degree coordinates.
|
||||
*
|
||||
* @return the latitude/longitude geometry
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
public abstract Geometry latLonGeometry() throws GeometryException;
|
||||
|
||||
/**
|
||||
* Returns this feature's geometry in world web mercator coordinates.
|
||||
*
|
||||
* @return the geometry in web mercator coordinates
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
public abstract Geometry worldGeometry() throws GeometryException;
|
||||
|
||||
/** Returns and caches {@link Geometry#getCentroid()} of this geometry in world web mercator coordinates. */
|
||||
public final Geometry centroid() throws GeometryException {
|
||||
return centroid != null ? centroid : (centroid =
|
||||
canBePolygon() ? polygon().getCentroid() :
|
||||
canBeLine() ? line().getCentroid() :
|
||||
worldGeometry().getCentroid());
|
||||
}
|
||||
|
||||
/** Returns and caches {@link Geometry#getInteriorPoint()} of this geometry in world web mercator coordinates. */
|
||||
public final Geometry pointOnSurface() throws GeometryException {
|
||||
return pointOnSurface != null ? pointOnSurface : (pointOnSurface =
|
||||
canBePolygon() ? polygon().getInteriorPoint() :
|
||||
canBeLine() ? line().getInteriorPoint() :
|
||||
worldGeometry().getInteriorPoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@link MaximumInscribedCircle#getCenter()} of this geometry in world web mercator coordinates.
|
||||
*
|
||||
* @param tolerance precision for calculating maximum inscribed circle. 0.01 means 1% of the square root of the area.
|
||||
* Smaller values for a more precise tolerance become very expensive to compute. Values between
|
||||
* 0.05-0.1 are a good compromise of performance vs. precision.
|
||||
*/
|
||||
public final Geometry innermostPoint(double tolerance) throws GeometryException {
|
||||
if (canBePolygon()) {
|
||||
// cache as long as the tolerance hasn't changed
|
||||
if (tolerance != innermostPointTolerance || innermostPoint == null) {
|
||||
innermostPoint = MaximumInscribedCircle.getCenter(polygon(), Math.sqrt(area()) * tolerance);
|
||||
innermostPointTolerance = tolerance;
|
||||
}
|
||||
return innermostPoint;
|
||||
} else if (canBeLine()) {
|
||||
return lineMidpoint();
|
||||
} else {
|
||||
return pointOnSurface();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the midpoint of this line, or the longest segment if it is a multilinestring.
|
||||
*/
|
||||
public final Geometry lineMidpoint() throws GeometryException {
|
||||
if (innermostPoint == null) {
|
||||
innermostPoint = pointAlongLine(0.5);
|
||||
}
|
||||
return innermostPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns along this line where {@code ratio=0} is the start {@code ratio=1} is the end and {@code ratio=0.5} is the
|
||||
* midpoint.
|
||||
* <p>
|
||||
* When this is a multilinestring, the longest segment is used.
|
||||
*/
|
||||
public final Geometry pointAlongLine(double ratio) throws GeometryException {
|
||||
if (lineSplitter == null) {
|
||||
var line = line();
|
||||
lineSplitter = new LineSplitter(line instanceof MultiLineString multi ? GeoUtils.getLongestLine(multi) : line);
|
||||
}
|
||||
return lineSplitter.get(ratio);
|
||||
}
|
||||
|
||||
private Geometry computeCentroidIfConvex() throws GeometryException {
|
||||
if (!canBePolygon()) {
|
||||
return centroid();
|
||||
} else if (polygon() instanceof Polygon poly &&
|
||||
poly.getNumInteriorRing() == 0 &&
|
||||
GeoUtils.isConvex(poly.getExteriorRing())) {
|
||||
return centroid();
|
||||
} else { // multipolygon, polygon with holes, or concave polygon
|
||||
return pointOnSurface();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and caches a point inside the geometry in world web mercator coordinates.
|
||||
* <p>
|
||||
* If the geometry is convex, uses the faster {@link Geometry#getCentroid()} but otherwise falls back to the slower
|
||||
* {@link Geometry#getInteriorPoint()}.
|
||||
*/
|
||||
public final Geometry centroidIfConvex() throws GeometryException {
|
||||
return centroidIfConvex != null ? centroidIfConvex : (centroidIfConvex = computeCentroidIfConvex());
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates.
|
||||
*
|
||||
* @return the linestring in web mercator coordinates
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
protected Geometry computeLine() throws GeometryException {
|
||||
Geometry world = worldGeometry();
|
||||
return world instanceof Lineal ? world : GeoUtils.polygonToLineString(world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a line
|
||||
*/
|
||||
public final Geometry line() throws GeometryException {
|
||||
if (!canBeLine()) {
|
||||
throw new GeometryException("feature_not_line", "cannot be line", true);
|
||||
}
|
||||
if (linearGeometry == null) {
|
||||
linearGeometry = computeLine();
|
||||
}
|
||||
return linearGeometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a partial line string from {@code start} to {@code end} where 0 is the beginning of the line and 1 is the
|
||||
* end of the line.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a single line (multilinestrings are not allowed).
|
||||
*/
|
||||
public final Geometry partialLine(double start, double end) throws GeometryException {
|
||||
Geometry line = line();
|
||||
if (start <= 0 && end >= 1) {
|
||||
return line;
|
||||
} else if (line instanceof LineString lineString) {
|
||||
if (this.lineSplitter == null) {
|
||||
this.lineSplitter = new LineSplitter(lineString);
|
||||
}
|
||||
return lineSplitter.get(start, end);
|
||||
} else {
|
||||
throw new GeometryException("partial_multilinestring", "cannot get partial of a multiline", true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes this feature as a {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates.
|
||||
*
|
||||
* @return the polygon in web mercator coordinates
|
||||
* @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should
|
||||
* be logged for debugging
|
||||
* @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower
|
||||
* log level
|
||||
*/
|
||||
protected Geometry computePolygon() throws GeometryException {
|
||||
return worldGeometry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this feature as a {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a line
|
||||
*/
|
||||
public final Geometry polygon() throws GeometryException {
|
||||
if (!canBePolygon()) {
|
||||
throw new GeometryException("feature_not_polygon", "cannot be polygon", true);
|
||||
}
|
||||
return polygonGeometry != null ? polygonGeometry : (polygonGeometry = computePolygon());
|
||||
}
|
||||
|
||||
private Geometry computeValidPolygon() throws GeometryException {
|
||||
Geometry polygon = polygon();
|
||||
if (!polygon.isValid()) {
|
||||
polygon = GeoUtils.fixPolygon(polygon);
|
||||
}
|
||||
return polygon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this feature as a valid {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates.
|
||||
* <p>
|
||||
* Validating and fixing invalid polygons can be expensive, so use only if necessary. Invalid polygons will also be
|
||||
* fixed at render-time.
|
||||
*
|
||||
* @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be
|
||||
* interpreted as a line
|
||||
*/
|
||||
public final Geometry validatedPolygon() throws GeometryException {
|
||||
if (!canBePolygon()) {
|
||||
throw new GeometryException("feature_not_polygon", "cannot be polygon", true);
|
||||
}
|
||||
return validPolygon != null ? validPolygon : (validPolygon = computeValidPolygon());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and caches the result of {@link Geometry#getArea()} of this feature in world web mercator coordinates where
|
||||
* {@code 1} means the area of the entire planet.
|
||||
*/
|
||||
public double area() throws GeometryException {
|
||||
return Double.isNaN(area) ? (area = canBePolygon() ? Math.abs(polygon().getArea()) : 0) : area;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and caches the result of {@link Geometry#getLength()} of this feature in world web mercator coordinates
|
||||
* where {@code 1} means the circumference of the entire planet or the distance from 85 degrees north to 85 degrees
|
||||
* south.
|
||||
*/
|
||||
public double length() throws GeometryException {
|
||||
return Double.isNaN(length) ? (length =
|
||||
(isPoint() || canBePolygon() || canBeLine()) ? worldGeometry().getLength() : 0) : length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sqrt of {@link #area()} if polygon or {@link #length()} if a line string.
|
||||
*/
|
||||
public double size() throws GeometryException {
|
||||
return canBePolygon() ? Math.sqrt(Math.abs(area())) : canBeLine() ? length() : 0;
|
||||
}
|
||||
|
||||
/** Returns and caches the approximate area of the geometry in square meters. */
|
||||
public double areaMeters() throws GeometryException {
|
||||
return Double.isNaN(areaMeters) ? (areaMeters =
|
||||
(isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.areaInMeters(latLonGeometry()) : 0) : areaMeters;
|
||||
}
|
||||
|
||||
/** Returns and caches the approximate length of the geometry in meters. */
|
||||
public double lengthMeters() throws GeometryException {
|
||||
return Double.isNaN(lengthMeters) ? (lengthMeters =
|
||||
(isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.lengthInMeters(latLonGeometry()) : 0) : lengthMeters;
|
||||
}
|
||||
|
||||
/** Returns the sqrt of {@link #areaMeters()} if polygon or {@link #lengthMeters()} if a line string. */
|
||||
public double sizeMeters() throws GeometryException {
|
||||
return canBePolygon() ? Math.sqrt(Math.abs(areaMeters())) : canBeLine() ? lengthMeters() : 0;
|
||||
}
|
||||
|
||||
/** Returns the ID of the source that this feature came from. */
|
||||
@Override
|
||||
public String getSource() {
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
package com.onthegomap.planetiler.util;
|
||||
|
||||
import static com.onthegomap.planetiler.util.Exceptions.throwFatalException;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ToDoubleFunctionThatThrows<I> {
|
||||
|
||||
@SuppressWarnings("java:S112")
|
||||
double applyAsDouble(I value) throws Exception;
|
||||
|
||||
default double applyAndWrapException(I value) {
|
||||
try {
|
||||
return applyAsDouble(value);
|
||||
} catch (Exception e) {
|
||||
return throwFatalException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package com.onthegomap.planetiler.geo;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
class UnitTest {
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"METER, 1, m; meters; metre; metres",
|
||||
"KILOMETER, 0.001, km; kilometers",
|
||||
"FOOT, 3.28084, ft; feet; foot",
|
||||
"MILE, 0.000621371, mi; mile; miles",
|
||||
"NAUTICAL_MILE, 0.000539957, nm; nautical miles",
|
||||
"YARD, 1.0936136964129, yd; yards; yds",
|
||||
|
||||
"Z0_PIXEL, 0.00390625, z0px; z0 pixels; z0 pixel",
|
||||
"Z0_TILE, 1, z0ti; z0tile; z0 tile; z0_tiles; z0_tiles; z0 tiles",
|
||||
})
|
||||
void testLengthAndDerivedArea(String name, double expected, String aliases) {
|
||||
Unit.Length length = Unit.Length.from(name);
|
||||
Unit.Area area = Unit.Area.from("SQUARE_" + name);
|
||||
assertEquals(expected, length.fromBaseUnit(1), expected / 1e5);
|
||||
double expectedArea = expected * expected;
|
||||
assertEquals(expected * expected, area.fromBaseUnit(1), expectedArea / 1e5);
|
||||
|
||||
for (String alias : aliases.split(";")) {
|
||||
assertEquals(length, Unit.Length.from(alias), alias);
|
||||
assertEquals(area, Unit.Area.from("s" + alias), "s" + alias);
|
||||
assertEquals(area, Unit.Area.from("sq " + alias), "sq " + alias);
|
||||
assertEquals(area, Unit.Area.from("square " + alias), "square " + alias);
|
||||
assertEquals(area, Unit.Area.from(alias + "2"), alias + "2");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"ARE, 0.01, a; ares",
|
||||
"HECTARE, 0.0001, ha; hectares",
|
||||
"ACRE, 0.000247105, ac; acres",
|
||||
})
|
||||
void testCustomArea(String name, double expected, String aliases) {
|
||||
Unit.Area area = Unit.Area.valueOf(name);
|
||||
assertEquals(expected, area.fromBaseUnit(1), expected / 1e5);
|
||||
for (String alias : aliases.split(";")) {
|
||||
assertEquals(area, Unit.Area.from(alias));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -506,6 +506,38 @@ Additional variables, on top of the root context:
|
|||
- `feature.osm_user_name` - optional name of the OSM user that last modified this feature
|
||||
- `feature.osm_type` - type of the OSM element as a string: `"node"`, `"way"`, or `"relation"`
|
||||
|
||||
On the original feature or any accessor that returns a geometry, you can also use:
|
||||
|
||||
- `feature.length("unit")` - length of the feature if it is a line, 0 otherwise. Allowed units: "meters"/"m", "feet"
|
||||
/"ft", "yards"/"yd", "nautical miles"/"nm", "kilometer"/"km" for units relative to the size in meters, or "z0 tiles"/"
|
||||
z0 ti", "z0 pixels"/"z0 px" for sizes relative to the size of the geometry when projected into a z0 web mercator tile
|
||||
containing the entire world.
|
||||
- `feature.area("unit")` - area of the feature if it is a polygon, 0 otherwise. Allowed units: any length unit like "
|
||||
km2", "mi2", or "z0 px2" or also "acres"/"ac", "hectares"/"ha", or "ares"/"a".
|
||||
- `feature.min_lat` / `feature.min_lon` / `feature.max_lat` / `feature.max_lon` - returns coordinates from the bounding
|
||||
box of this geometry
|
||||
- `feature.lat` / `feature.lon` - returns the coordinate of an arbitrary point on this shape (useful to get the lat/lon
|
||||
of a point)
|
||||
- `feature.bbox` - returns the rectangle bounding box that contains this entire shape
|
||||
- `feature.centroid` - returns the weighted center point of the geometry, which may fall outside the the shape
|
||||
- `feature.point_on_surface` - returns a point that is within the shape (on the line, or inside the polygon)
|
||||
- `feature.validated_polygon` - if this is a polygon, fixes any self-intersections and returns the result
|
||||
- `feature.centroid_if_convex` - returns point_on_surface if this is a concave polygon, or centroid if convex
|
||||
- `feature.line_midpoint` - returns midpoint of this feature if it is a line
|
||||
- `feature.point_along_line(amount)` - when amount=0 returns the start of the line, when amount=1 returns the end,
|
||||
otherwise a point at a certain ratio along the line
|
||||
- `feature.partial_line(start, end)` - returns a partial line segment from start to end where 0=the beginning of the
|
||||
line and 1=the end
|
||||
- `feature.innermost_point` / `feature.innermost_point(tolerance)` - returns the midpoint of a line, or
|
||||
the [pole of inaccessibility](https://en.wikipedia.org/wiki/Pole_of_inaccessibility) if it is a polygon
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
key: bbox_area_km2
|
||||
value: ${ feature.bbox.area('km2') }
|
||||
```
|
||||
|
||||
##### 3. Post-Match Context
|
||||
|
||||
Context available after a feature has matched, for example computing an attribute value.
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.onthegomap.planetiler.config.PlanetilerConfig;
|
|||
import com.onthegomap.planetiler.custommap.expression.ParseException;
|
||||
import com.onthegomap.planetiler.custommap.expression.ScriptContext;
|
||||
import com.onthegomap.planetiler.custommap.expression.ScriptEnvironment;
|
||||
import com.onthegomap.planetiler.custommap.expression.stdlib.GeometryVal;
|
||||
import com.onthegomap.planetiler.expression.DataType;
|
||||
import com.onthegomap.planetiler.reader.SourceFeature;
|
||||
import com.onthegomap.planetiler.reader.WithGeometryType;
|
||||
|
@ -362,6 +363,7 @@ public class Contexts {
|
|||
private static final String FEATURE_OSM_USER_ID = "feature.osm_user_id";
|
||||
private static final String FEATURE_OSM_USER_NAME = "feature.osm_user_name";
|
||||
private static final String FEATURE_OSM_TYPE = "feature.osm_type";
|
||||
private static final String FEATURE_GEOMETRY = "feature";
|
||||
|
||||
public static ScriptEnvironment<ProcessFeature> description(Root root) {
|
||||
return root.description()
|
||||
|
@ -376,7 +378,8 @@ public class Contexts {
|
|||
Decls.newVar(FEATURE_OSM_TIMESTAMP, Decls.Int),
|
||||
Decls.newVar(FEATURE_OSM_USER_ID, Decls.Int),
|
||||
Decls.newVar(FEATURE_OSM_USER_NAME, Decls.String),
|
||||
Decls.newVar(FEATURE_OSM_TYPE, Decls.String)
|
||||
Decls.newVar(FEATURE_OSM_TYPE, Decls.String),
|
||||
Decls.newVar(FEATURE_GEOMETRY, GeometryVal.PROTO_TYPE)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -388,6 +391,7 @@ public class Contexts {
|
|||
case FEATURE_ID -> feature.id();
|
||||
case FEATURE_SOURCE -> feature.getSource();
|
||||
case FEATURE_SOURCE_LAYER -> wrapNullable(feature.getSourceLayer());
|
||||
case FEATURE_GEOMETRY -> new GeometryVal(feature);
|
||||
default -> {
|
||||
OsmElement elem = feature instanceof OsmSourceFeature osm ? osm.originalElement() : null;
|
||||
if (FEATURE_OSM_TYPE.equals(key)) {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package com.onthegomap.planetiler.custommap.expression;
|
||||
|
||||
import com.onthegomap.planetiler.custommap.TypeConversion;
|
||||
import com.onthegomap.planetiler.custommap.expression.stdlib.GeometryVal;
|
||||
import com.onthegomap.planetiler.custommap.expression.stdlib.PlanetilerStdLib;
|
||||
import com.onthegomap.planetiler.custommap.expression.stdlib.PlanetilerTypeRegistry;
|
||||
import com.onthegomap.planetiler.util.Memoized;
|
||||
import com.onthegomap.planetiler.util.Try;
|
||||
import java.util.Objects;
|
||||
|
@ -98,7 +100,8 @@ public class ConfigExpressionScript<I extends ScriptContext, O> implements Confi
|
|||
*/
|
||||
public static <I extends ScriptContext, O> ConfigExpressionScript<I, O> parse(String string,
|
||||
ScriptEnvironment<I> description, Class<O> expected) {
|
||||
ScriptHost scriptHost = ScriptHost.newBuilder().build();
|
||||
var scriptHost = ScriptHost.newBuilder().registry(new PlanetilerTypeRegistry())
|
||||
.build();
|
||||
try {
|
||||
var scriptBuilder = scriptHost.buildScript(string).withLibraries(
|
||||
new StringsLib(),
|
||||
|
@ -107,6 +110,7 @@ public class ConfigExpressionScript<I extends ScriptContext, O> implements Confi
|
|||
if (!description.declarations().isEmpty()) {
|
||||
scriptBuilder.withDeclarations(description.declarations());
|
||||
}
|
||||
scriptBuilder.withTypes(GeometryVal.PROTO_TYPE);
|
||||
var script = scriptBuilder.build();
|
||||
|
||||
return new ConfigExpressionScript<>(string, script, description, expected);
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
package com.onthegomap.planetiler.custommap.expression.stdlib;
|
||||
|
||||
import static org.projectnessie.cel.common.types.Err.newTypeConversionError;
|
||||
import static org.projectnessie.cel.common.types.Err.noSuchOverload;
|
||||
import static org.projectnessie.cel.common.types.Types.boolOf;
|
||||
|
||||
import com.onthegomap.planetiler.geo.GeoUtils;
|
||||
import com.onthegomap.planetiler.geo.WithGeometry;
|
||||
import com.onthegomap.planetiler.util.FunctionThatThrows;
|
||||
import com.onthegomap.planetiler.util.ToDoubleFunctionThatThrows;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import org.locationtech.jts.geom.Geometry;
|
||||
import org.projectnessie.cel.checker.Decls;
|
||||
import org.projectnessie.cel.common.types.DoubleT;
|
||||
import org.projectnessie.cel.common.types.Err;
|
||||
import org.projectnessie.cel.common.types.StringT;
|
||||
import org.projectnessie.cel.common.types.TypeT;
|
||||
import org.projectnessie.cel.common.types.ref.BaseVal;
|
||||
import org.projectnessie.cel.common.types.ref.Type;
|
||||
import org.projectnessie.cel.common.types.ref.Val;
|
||||
import org.projectnessie.cel.common.types.traits.FieldTester;
|
||||
import org.projectnessie.cel.common.types.traits.Indexer;
|
||||
|
||||
/** Wrapper for a geometry that exposes utility functions to CEL expressions. */
|
||||
public class GeometryVal extends BaseVal implements Indexer, FieldTester {
|
||||
public static final String NAME = "geometry";
|
||||
public static final com.google.api.expr.v1alpha1.Type PROTO_TYPE = Decls.newObjectType(NAME);
|
||||
private static final Type TYPE = TypeT.newObjectTypeValue(NAME);
|
||||
private final WithGeometry geometry;
|
||||
private static final Map<String, Field> FIELDS = Stream.of(
|
||||
doubleField("lat", geom -> GeoUtils.getWorldLat(geom.worldGeometry().getCoordinate().getY())),
|
||||
doubleField("lon", geom -> GeoUtils.getWorldLon(geom.worldGeometry().getCoordinate().getX())),
|
||||
doubleField("min_lat", geom -> geom.latLonGeometry().getEnvelopeInternal().getMinY()),
|
||||
doubleField("max_lat", geom -> geom.latLonGeometry().getEnvelopeInternal().getMaxY()),
|
||||
doubleField("min_lon", geom -> geom.latLonGeometry().getEnvelopeInternal().getMinX()),
|
||||
doubleField("max_lon", geom -> geom.latLonGeometry().getEnvelopeInternal().getMaxX()),
|
||||
geometryField("bbox", geom -> geom.worldGeometry().getEnvelope()),
|
||||
geometryField("centroid", WithGeometry::centroid),
|
||||
geometryField("centroid_if_convex", WithGeometry::centroidIfConvex),
|
||||
geometryField("validated_polygon", WithGeometry::validatedPolygon),
|
||||
geometryField("point_on_surface", WithGeometry::pointOnSurface),
|
||||
geometryField("line_midpoint", WithGeometry::lineMidpoint),
|
||||
geometryField("innermost_point", geom -> geom.innermostPoint(0.1))
|
||||
).collect(Collectors.toMap(field -> field.name, Function.identity()));
|
||||
|
||||
public static GeometryVal fromWorldGeom(Geometry geometry) {
|
||||
return new GeometryVal(WithGeometry.fromWorldGeometry(geometry));
|
||||
}
|
||||
|
||||
record Field(String name, com.google.api.expr.v1alpha1.Type type, FunctionThatThrows<WithGeometry, Val> getter) {}
|
||||
|
||||
private static Field doubleField(String name, ToDoubleFunctionThatThrows<WithGeometry> getter) {
|
||||
return new Field(name, Decls.Double, geom -> DoubleT.doubleOf(getter.applyAsDouble(geom)));
|
||||
}
|
||||
|
||||
private static Field geometryField(String name, FunctionThatThrows<WithGeometry, Geometry> getter) {
|
||||
return new Field(name, PROTO_TYPE, geom -> new GeometryVal(WithGeometry.fromWorldGeometry(getter.apply(geom))));
|
||||
}
|
||||
|
||||
public GeometryVal(WithGeometry geometry) {
|
||||
this.geometry = geometry;
|
||||
}
|
||||
|
||||
public static com.google.api.expr.v1alpha1.Type fieldType(String fieldName) {
|
||||
var field = FIELDS.get(fieldName);
|
||||
return field == null ? null : field.type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T convertToNative(Class<T> typeDesc) {
|
||||
return typeDesc.isInstance(geometry) ? typeDesc.cast(geometry) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val convertToType(Type typeValue) {
|
||||
return newTypeConversionError(TYPE, typeValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val equal(Val other) {
|
||||
return boolOf(other instanceof GeometryVal val && Objects.equals(val.geometry, geometry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object value() {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val isSet(Val field) {
|
||||
if (!(field instanceof StringT)) {
|
||||
return noSuchOverload(this, "isSet", field);
|
||||
}
|
||||
String fieldName = (String) field.value();
|
||||
return boolOf(FIELDS.containsKey(fieldName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val get(Val index) {
|
||||
if (!(index instanceof StringT)) {
|
||||
return noSuchOverload(this, "get", index);
|
||||
}
|
||||
String fieldName = (String) index.value();
|
||||
try {
|
||||
var field = FIELDS.get(fieldName);
|
||||
return field.getter.apply(geometry);
|
||||
} catch (Exception err) {
|
||||
return Err.newErr(err, "Error getting %s", fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public final boolean equals(Object o) {
|
||||
return this == o || (o instanceof GeometryVal val && val.geometry.equals(geometry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return geometry.hashCode();
|
||||
}
|
||||
}
|
|
@ -3,6 +3,9 @@ package com.onthegomap.planetiler.custommap.expression.stdlib;
|
|||
import static org.projectnessie.cel.checker.Decls.newOverload;
|
||||
|
||||
import com.google.api.expr.v1alpha1.Type;
|
||||
import com.onthegomap.planetiler.geo.GeometryException;
|
||||
import com.onthegomap.planetiler.geo.Unit;
|
||||
import com.onthegomap.planetiler.geo.WithGeometry;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
@ -162,6 +165,83 @@ public class PlanetilerStdLib extends PlanetilerLib {
|
|||
Decls.newOverload("max_double", List.of(Decls.newListType(Decls.Double)), Decls.Double)
|
||||
),
|
||||
Overload.unary("max", list -> reduceNumeric(list, Math::max, Math::max))
|
||||
),
|
||||
|
||||
new BuiltInFunction(
|
||||
Decls.newFunction("area",
|
||||
Decls.newInstanceOverload("area", List.of(GeometryVal.PROTO_TYPE, Decls.String), Decls.Double)
|
||||
),
|
||||
Overload.binary("area",
|
||||
(a, b) -> DoubleT
|
||||
.doubleOf(a.convertToNative(WithGeometry.class).area(Unit.Area.from(b.convertToNative(String.class)))))
|
||||
),
|
||||
|
||||
new BuiltInFunction(
|
||||
Decls.newFunction("length",
|
||||
Decls.newInstanceOverload("length", List.of(GeometryVal.PROTO_TYPE, Decls.String), Decls.Double)
|
||||
),
|
||||
Overload.binary("length",
|
||||
(a, b) -> DoubleT
|
||||
.doubleOf(a.convertToNative(WithGeometry.class).length(Unit.Length.from(b.convertToNative(String.class)))))
|
||||
),
|
||||
|
||||
new BuiltInFunction(
|
||||
Decls.newFunction("point_along_line",
|
||||
Decls.newInstanceOverload("point_along_line_double", List.of(GeometryVal.PROTO_TYPE, Decls.Double),
|
||||
GeometryVal.PROTO_TYPE),
|
||||
Decls.newInstanceOverload("point_along_line_int", List.of(GeometryVal.PROTO_TYPE, Decls.Int),
|
||||
GeometryVal.PROTO_TYPE)
|
||||
),
|
||||
Overload.binary("point_along_line",
|
||||
(a, b) -> {
|
||||
try {
|
||||
return GeometryVal.fromWorldGeom(a.convertToNative(WithGeometry.class).pointAlongLine(b.doubleValue()));
|
||||
} catch (GeometryException e) {
|
||||
return Err.newErr(e, "Unable to compute point_along_line(%d)", b.doubleValue());
|
||||
}
|
||||
})
|
||||
),
|
||||
|
||||
new BuiltInFunction(
|
||||
Decls.newFunction("innermost_point",
|
||||
Decls.newInstanceOverload("innermost_point_double", List.of(GeometryVal.PROTO_TYPE, Decls.Double),
|
||||
GeometryVal.PROTO_TYPE),
|
||||
Decls.newInstanceOverload("innermost_point_int", List.of(GeometryVal.PROTO_TYPE, Decls.Int),
|
||||
GeometryVal.PROTO_TYPE)
|
||||
),
|
||||
Overload.binary("innermost_point",
|
||||
(a, b) -> {
|
||||
try {
|
||||
return GeometryVal.fromWorldGeom(a.convertToNative(WithGeometry.class).innermostPoint(b.doubleValue()));
|
||||
} catch (GeometryException e) {
|
||||
return Err.newErr(e, "Unable to compute innermost_point(%d)", b.doubleValue());
|
||||
}
|
||||
})
|
||||
),
|
||||
|
||||
new BuiltInFunction(
|
||||
Decls.newFunction("partial_line",
|
||||
Decls.newInstanceOverload("partial_line_double_double",
|
||||
List.of(GeometryVal.PROTO_TYPE, Decls.Double, Decls.Double),
|
||||
GeometryVal.PROTO_TYPE),
|
||||
Decls.newInstanceOverload("partial_line_double_int", List.of(GeometryVal.PROTO_TYPE, Decls.Double, Decls.Int),
|
||||
GeometryVal.PROTO_TYPE),
|
||||
Decls.newInstanceOverload("partial_line_int_double", List.of(GeometryVal.PROTO_TYPE, Decls.Int, Decls.Double),
|
||||
GeometryVal.PROTO_TYPE),
|
||||
Decls.newInstanceOverload("partial_line_int_int", List.of(GeometryVal.PROTO_TYPE, Decls.Int, Decls.Int),
|
||||
GeometryVal.PROTO_TYPE)
|
||||
),
|
||||
Overload.function("partial_line",
|
||||
(Val[] args) -> {
|
||||
Val a = args[0];
|
||||
double b = args[1].doubleValue(), c = args[2].doubleValue();
|
||||
try {
|
||||
return GeometryVal
|
||||
.fromWorldGeom(a.convertToNative(WithGeometry.class).partialLine(b, c));
|
||||
} catch (GeometryException e) {
|
||||
return Err.newErr(e, "Unable to compute partial_line(%d, %d)", b, c);
|
||||
}
|
||||
})
|
||||
)
|
||||
));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
package com.onthegomap.planetiler.custommap.expression.stdlib;
|
||||
|
||||
import static org.projectnessie.cel.common.types.Err.newErr;
|
||||
import static org.projectnessie.cel.common.types.Err.unsupportedRefValConversionErr;
|
||||
|
||||
import com.onthegomap.planetiler.reader.SourceFeature;
|
||||
import java.util.Map;
|
||||
import org.projectnessie.cel.common.types.pb.Db;
|
||||
import org.projectnessie.cel.common.types.pb.DefaultTypeAdapter;
|
||||
import org.projectnessie.cel.common.types.ref.FieldType;
|
||||
import org.projectnessie.cel.common.types.ref.Type;
|
||||
import org.projectnessie.cel.common.types.ref.TypeRegistry;
|
||||
import org.projectnessie.cel.common.types.ref.Val;
|
||||
|
||||
/** Registers any types that are available to CEL expressions in planetiler configs. */
|
||||
public final class PlanetilerTypeRegistry implements TypeRegistry {
|
||||
|
||||
@Override
|
||||
public TypeRegistry copy() {
|
||||
return new PlanetilerTypeRegistry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(Object t) {
|
||||
// types are defined statically
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerType(Type... types) {
|
||||
// types are defined statically
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val nativeToValue(Object value) {
|
||||
return switch (value) {
|
||||
case Val val -> val;
|
||||
case SourceFeature sourceFeature -> new GeometryVal(sourceFeature);
|
||||
case null, default -> {
|
||||
Val val = DefaultTypeAdapter.nativeToValue(Db.defaultDb, this, value);
|
||||
if (val != null) {
|
||||
yield val;
|
||||
}
|
||||
yield unsupportedRefValConversionErr(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val enumValue(String enumName) {
|
||||
return newErr("unknown enum name '%s'", enumName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val findIdent(String identName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.google.api.expr.v1alpha1.Type findType(String typeName) {
|
||||
return typeName.equals(GeometryVal.NAME) ? GeometryVal.PROTO_TYPE : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FieldType findFieldType(String messageType, String fieldName) {
|
||||
com.google.api.expr.v1alpha1.Type type = switch (messageType) {
|
||||
case GeometryVal.NAME -> GeometryVal.fieldType(fieldName);
|
||||
case null, default -> null;
|
||||
};
|
||||
return type == null ? null : new FieldType(type, any -> false, any -> null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Val newValue(String typeName, Map<String, Val> fields) {
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1518,4 +1518,88 @@ class ConfiguredFeatureTest {
|
|||
testFeature(config, sfNoMatch, any -> {
|
||||
}, 1);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(value = {
|
||||
"feature.length('z0 px'); 3.0712E-5",
|
||||
"feature.length('z0 tiles'); 0.007862",
|
||||
"feature.length('m'); 314283",
|
||||
"feature.length('km'); 314.28",
|
||||
"feature.length('nm'); 169.7",
|
||||
"feature.length('ft'); 1031114",
|
||||
"feature.length('yd'); 343704",
|
||||
"feature.length('mi'); 195.287",
|
||||
"feature.bbox.area('mi2'); 19068",
|
||||
"feature.centroid.lat; 3",
|
||||
"feature.centroid.lon; 2",
|
||||
"feature.innermost_point.lat; 3",
|
||||
"feature.innermost_point(0.01).lat; 3",
|
||||
"feature.line_midpoint.lat; 3",
|
||||
"feature.point_along_line(0).lat; 2",
|
||||
"feature.point_along_line(1.0).lat; 4",
|
||||
"feature.partial_line(0.0, 0.1).centroid.lat; 2.1",
|
||||
}, delimiter = ';')
|
||||
void testGeometryAttributesLine(String expression, double expected) {
|
||||
var config = """
|
||||
sources:
|
||||
osm:
|
||||
type: osm
|
||||
url: geofabrik:rhode-island
|
||||
local_path: data/rhode-island.osm.pbf
|
||||
layers:
|
||||
- id: testLayer
|
||||
features:
|
||||
- source: osm
|
||||
attributes:
|
||||
- key: attr
|
||||
value: ${%s}
|
||||
""".formatted(expression);
|
||||
var sfMatch =
|
||||
SimpleFeature.createFakeOsmFeature(newLineString(1, 2, 3, 4), Map.of(), "osm", "layer", 1, emptyList(),
|
||||
new OsmElement.Info(2, 3, 4, 5, "user"));
|
||||
testFeature(config, sfMatch,
|
||||
any -> assertEquals(expected, (Double) any.getAttrsAtZoom(14).get("attr"), expected / 1e3), 1);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(value = {
|
||||
"feature.area('z0 px2'); 1.17743E-10",
|
||||
"feature.area('z0 tiles'); 7.7164E-6",
|
||||
"feature.area('sm'); 1.2364E10",
|
||||
"feature.area('km2'); 12363",
|
||||
"feature.area('ft2'); 1.3308E11",
|
||||
"feature.area('a'); 1.23637E8",
|
||||
"feature.area('ac'); 3055141",
|
||||
"feature.area('acres'); 3055141",
|
||||
"feature.area('ha'); 1236371",
|
||||
"feature.area('mi2'); 4773.7",
|
||||
"feature.bbox.area('mi2'); 4773.7",
|
||||
"feature.centroid.lat; 0.5",
|
||||
"feature.centroid.lon; 0.5",
|
||||
"feature.centroid_if_convex.lon; 0.5",
|
||||
"feature.point_on_surface.lat; 0.5",
|
||||
"feature.innermost_point.lat; 0.5",
|
||||
"feature.validated_polygon.area('mi2'); 4773.7",
|
||||
}, delimiter = ';')
|
||||
void testGeometryAttributesArea(String expression, double expected) {
|
||||
var config = """
|
||||
sources:
|
||||
osm:
|
||||
type: osm
|
||||
url: geofabrik:rhode-island
|
||||
local_path: data/rhode-island.osm.pbf
|
||||
layers:
|
||||
- id: testLayer
|
||||
features:
|
||||
- source: osm
|
||||
attributes:
|
||||
- key: attr
|
||||
value: ${%s}
|
||||
""".formatted(expression);
|
||||
var sfMatch =
|
||||
SimpleFeature.createFakeOsmFeature(rectangle(0, 1), Map.of(), "osm", "layer", 1, emptyList(),
|
||||
new OsmElement.Info(2, 3, 4, 5, "user"));
|
||||
testFeature(config, sfMatch,
|
||||
any -> assertEquals(expected, (Double) any.getAttrsAtZoom(14).get("attr"), expected / 1e3), 1);
|
||||
}
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue