diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java index 3f8caa9329..7d0a327a1b 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java @@ -246,6 +246,40 @@ public Feature innermostPoint(String layer) { return innermostPoint(layer, 0.1); } + + /** + * Starts building a new point map feature at the midpoint of this line, or the longest line segment if a + * multilinestring. + * + * @param layer the output vector tile layer this feature will be written to + * @return a feature that can be configured further. + */ + public Feature lineMidpoint(String layer) { + try { + return geometry(layer, source.lineMidpoint()); + } catch (GeometryException e) { + e.log(stats, "feature_line_midpoint", "Error getting midpoint for " + source); + return empty(layer); + } + } + + /** + * Starts building a new point map feature at a certain ratio along the linestring or longest segment if it is a + * multilinestring. + * + * @param layer the output vector tile layer this feature will be written to + * @param ratio the ratio along the line: 0 for start, 1 for end, 0.5 for midpoint + * @return a feature that can be configured further. + */ + public Feature pointAlongLine(String layer, double ratio) { + try { + return geometry(layer, source.pointAlongLine(ratio)); + } catch (GeometryException e) { + e.log(stats, "feature_point_along_line", "Error getting point along line for " + source); + return empty(layer); + } + } + /** Returns the minimum zoom level at which this feature is at least {@code pixelSize} pixels large. */ public int getMinZoomForPixelSize(double pixelSize) { try { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java index 540933b4b8..f0e0c13189 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeoUtils.java @@ -552,6 +552,21 @@ public static int minZoomForPixelSize(double worldGeometrySize, double minPixelS PlanetilerConfig.MAX_MAXZOOM); } + public static LineString getLongestLine(MultiLineString multiLineString) { + LineString result = null; + double max = -1; + for (int i = 0; i < multiLineString.getNumGeometries(); i++) { + if (multiLineString.getGeometryN(i) instanceof LineString ls) { + double length = ls.getLength(); + if (length > max) { + max = length; + result = ls; + } + } + } + return result; + } + public static WKBReader wkbReader() { return new WKBReader(JTS_FACTORY); } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java index 45f367d28e..ff73396803 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java @@ -4,6 +4,7 @@ import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Point; /** * Utility for extracting sub-ranges of a line. @@ -29,6 +30,22 @@ public LineSplitter(Geometry geom) { } } + /** + * Returns a point at {@code ratio} along this line segment where 0 is the beginning of the line and 1 is the end. + */ + public Point get(double ratio) { + if (ratio < 0d || ratio > 1d) { + throw new IllegalArgumentException("Invalid ratio: " + ratio); + } + init(); + double pos = ratio * length; + var cs = line.getCoordinateSequence(); + var idx = Math.max(lowerIndex(pos), 0); + MutableCoordinateSequence result = new MutableCoordinateSequence(1); + addInterpolated(result, cs, idx, pos); + return GeoUtils.JTS_FACTORY.createPoint(result); + } + /** * Returns a partial segment of this line from {@code start} to {@code end} where 0 is the beginning of the line and 1 * is the end. @@ -40,6 +57,24 @@ public LineString get(double start, double end) { if (start <= 0 && end >= 1) { return line; } + var cs = line.getCoordinateSequence(); + init(); + MutableCoordinateSequence result = new MutableCoordinateSequence(); + + double startPos = start * length; + double endPos = end * length; + var first = floorIndex(startPos); + var last = lowerIndex(endPos); + addInterpolated(result, cs, first, startPos); + for (int i = first + 1; i <= last; i++) { + result.addPoint(cs.getX(i), cs.getY(i)); + } + addInterpolated(result, cs, last, endPos); + + return GeoUtils.JTS_FACTORY.createLineString(result); + } + + private void init() { var cs = line.getCoordinateSequence(); if (nodeLocations == null) { nodeLocations = new double[cs.size()]; @@ -57,19 +92,6 @@ public LineString get(double start, double end) { y1 = y2; } } - MutableCoordinateSequence result = new MutableCoordinateSequence(); - - double startPos = start * length; - double endPos = end * length; - var first = floorIndex(startPos); - var last = lowerIndex(endPos); - addInterpolated(result, cs, first, startPos); - for (int i = first + 1; i <= last; i++) { - result.addPoint(cs.getX(i), cs.getY(i)); - } - addInterpolated(result, cs, last, endPos); - - return GeoUtils.JTS_FACTORY.createLineString(result); } private int floorIndex(double length) { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/MutableCoordinateSequence.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/MutableCoordinateSequence.java index 8dc0c442f0..3607b55571 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/MutableCoordinateSequence.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/MutableCoordinateSequence.java @@ -15,10 +15,15 @@ */ public class MutableCoordinateSequence extends PackedCoordinateSequence { - private final DoubleArrayList points = new DoubleArrayList(); + private final DoubleArrayList points; public MutableCoordinateSequence() { + this(2); + } + + public MutableCoordinateSequence(int size) { super(2, 0); + points = new DoubleArrayList(2 * size); } /** 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 7dfdf6e842..10a04a6a7f 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 @@ -124,11 +124,37 @@ public final Geometry innermostPoint(double tolerance) throws GeometryException 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. + *

+ * 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(); diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java index daab902c6a..799e2ae81f 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java @@ -852,4 +852,42 @@ void testSetAttrPartialWithMinSize() { assertEquals(7, line.linearRange(0, 0.5).getMinZoomForPixelSize(50)); assertEquals(7, line.linearRange(0, 0.25).getMinZoomForPixelSize(25)); } + + + @Test + void testLineMidpoint() { + var sourceLine = newReaderFeature(newLineString(worldToLatLon( + 0, 0, + 1, 0 + )), Map.of()); + + var fc = factory.get(sourceLine); + fc.lineMidpoint("layer").setZoomRange(0, 10); + var iter = fc.iterator(); + + var item = iter.next(); + assertEquals(GeometryType.POINT, item.getGeometryType()); + assertEquals(round(newPoint(0.5, 0)), round(item.getGeometry())); + + assertFalse(iter.hasNext()); + } + + + @Test + void testPointAlongLine() { + var sourceLine = newReaderFeature(newLineString(worldToLatLon( + 0, 0, + 1, 0 + )), Map.of()); + + var fc = factory.get(sourceLine); + fc.pointAlongLine("layer", 0.25).setZoomRange(0, 10); + var iter = fc.iterator(); + + var item = iter.next(); + assertEquals(GeometryType.POINT, item.getGeometryType()); + assertEquals(round(newPoint(0.25, 0)), round(item.getGeometry())); + + assertFalse(iter.hasNext()); + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java index 76c4076cc8..64809ec0e7 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeoUtilsTest.java @@ -4,6 +4,7 @@ import static com.onthegomap.planetiler.geo.GeoUtils.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.onthegomap.planetiler.stats.Stats; @@ -447,4 +448,13 @@ void minZoomForPixelSizesAtZ9_10() { assertEquals(10, GeoUtils.minZoomForPixelSize(3.1 / (256 << 10), 3)); assertEquals(9, GeoUtils.minZoomForPixelSize(6.1 / (256 << 10), 3)); } + + @Test + void getLongestLine() { + var line1 = newLineString(0, 0, 1, 1); + var line2 = newLineString(0, 0, 2, 2); + assertNull(GeoUtils.getLongestLine(newMultiLineString())); + assertEquals(line1, GeoUtils.getLongestLine(newMultiLineString(line1))); + assertEquals(line2, GeoUtils.getLongestLine(newMultiLineString(line1, line2))); + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java index 859bd5aa94..82c8be3ba9 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java @@ -1,12 +1,15 @@ package com.onthegomap.planetiler.geo; import static com.onthegomap.planetiler.TestUtils.newLineString; +import static com.onthegomap.planetiler.TestUtils.newPoint; +import static com.onthegomap.planetiler.TestUtils.round; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; class LineSplitterTest { @ParameterizedTest @@ -57,6 +60,18 @@ void testLength2() { ); } + @ParameterizedTest + @ValueSource(doubles = { + 0, 0.00001, 0.1, 0.49999, 0.5, 0.50001, 0.9, 0.99999, 1.0 + }) + void testDistanceAlongLine(double ratio) { + var l = new LineSplitter(newLineString(0, 0, 1, 0.5, 2, 1)); + assertEquals( + round(newPoint(ratio * 2, ratio)), + round(l.get(ratio)) + ); + } + @Test void testInvalid() { var l = new LineSplitter(newLineString(0, 0, 1, 2, 2, 4)); diff --git a/planetiler-custommap/README.md b/planetiler-custommap/README.md index d89d9d21e0..d3059f0965 100644 --- a/planetiler-custommap/README.md +++ b/planetiler-custommap/README.md @@ -221,12 +221,15 @@ A feature is a defined set of objects that meet a specified filter criteria. - `point` `line` or `polygon` to pass the original feature through - `any` (default) to pass the original feature through regardless of geometry type - `polygon_centroid` to match on polygons, and emit a point at the center - - `line_centroid` to match on lines, and emit a point at the center + - `line_centroid` to match on lines, and emit a point at the centroid of the line + - `line_midpoint` to match on lines, and emit a point at midpoint of the line - `centroid` to match any geometry, and emit a point at the center - `polygon_point_on_surface` to match on polygons, and emit an interior point - `point_on_line` to match on lines, and emit a point somewhere along the line - `polygon_centroid_if_convex` to match on polygons, and if the polygon is convex emit the centroid, otherwise emit an interior point + - `innermost_point` to match on any geometry and for polygons, emit the furthest point from an edge, or for lines emit + the midpoint. - `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 diff --git a/planetiler-custommap/planetiler.schema.json b/planetiler-custommap/planetiler.schema.json index 437d691c16..636c6cf52f 100644 --- a/planetiler-custommap/planetiler.schema.json +++ b/planetiler-custommap/planetiler.schema.json @@ -353,10 +353,12 @@ "polygon", "polygon_centroid", "line_centroid", + "line_midpoint", "centroid", "polygon_centroid_if_convex", "polygon_point_on_surface", - "point_on_line" + "point_on_line", + "innermost_point" ] }, "source": { diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureGeometry.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureGeometry.java index a0575046df..4a2cfc6509 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureGeometry.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureGeometry.java @@ -21,6 +21,8 @@ public enum FeatureGeometry { POLYGON_CENTROID(GeometryType.POLYGON, FeatureCollector::centroid), @JsonProperty("line_centroid") LINE_CENTROID(GeometryType.LINE, FeatureCollector::centroid), + @JsonProperty("line_midpoint") + LINE_MIDPOINT(GeometryType.LINE, FeatureCollector::lineMidpoint), @JsonProperty("centroid") CENTROID(GeometryType.UNKNOWN, FeatureCollector::centroid), @JsonProperty("polygon_centroid_if_convex") @@ -28,7 +30,9 @@ public enum FeatureGeometry { @JsonProperty("polygon_point_on_surface") POLYGON_POINT_ON_SURFACE(GeometryType.POLYGON, FeatureCollector::pointOnSurface), @JsonProperty("point_on_line") - POINT_ON_LINE(GeometryType.LINE, FeatureCollector::pointOnSurface); + POINT_ON_LINE(GeometryType.LINE, FeatureCollector::pointOnSurface), + @JsonProperty("innermost_point") + INNERMOST_POINT(GeometryType.UNKNOWN, FeatureCollector::innermostPoint); public final GeometryType geometryType; public final BiFunction geometryFactory;