Add support for linear-referenced tags. (#900)

pull/904/head
Michael Barry 2024-05-30 05:19:15 -04:00 zatwierdzone przez GitHub
rodzic 9dbd5d358e
commit 21f061efe0
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
13 zmienionych plików z 1079 dodań i 114 usunięć

Wyświetl plik

@ -0,0 +1,74 @@
package com.onthegomap.planetiler.benchmarks;
import com.onthegomap.planetiler.geo.LineSplitter;
import java.util.concurrent.ThreadLocalRandom;
import org.locationtech.jts.util.GeometricShapeFactory;
public class BenchmarkLineSplitter {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
System.err.println(
"reused:\t" +
timeReused(10, 1_000_000) + "\t" +
timeReused(100, 100_000) + "\t" +
timeReused(1_000, 10_000) +
"\t!reused\t" +
timeNotReused(10, 1_000_000) + "\t" +
timeNotReused(100, 100_000) + "\t" +
timeNotReused(1_000, 10_000) +
"\tcacheable\t" +
timeCacheable(10, 1_000_000) + "\t" +
timeCacheable(100, 100_000) + "\t" +
timeCacheable(1_000, 10_000));
}
}
private static long timeCacheable(int points, int iters) {
var fact = new GeometricShapeFactory();
fact.setNumPoints(points);
fact.setWidth(10);
var shape = fact.createArc(0, Math.PI);
long start = System.currentTimeMillis();
LineSplitter splitter = new LineSplitter(shape);
var random = ThreadLocalRandom.current();
for (int i = 0; i < iters; i++) {
int a = random.nextInt(0, 90);
int b = random.nextInt(a + 2, 100);
splitter.get(a / 100d, b / 100d);
}
return System.currentTimeMillis() - start;
}
private static long timeReused(int points, int iters) {
var fact = new GeometricShapeFactory();
fact.setNumPoints(points);
fact.setWidth(10);
var shape = fact.createArc(0, Math.PI);
long start = System.currentTimeMillis();
LineSplitter splitter = new LineSplitter(shape);
var random = ThreadLocalRandom.current();
for (int i = 0; i < iters; i++) {
var a = random.nextDouble(0, 1);
var b = random.nextDouble(a, 1);
splitter.get(a, b);
}
return System.currentTimeMillis() - start;
}
private static long timeNotReused(int points, int iters) {
var fact = new GeometricShapeFactory();
fact.setNumPoints(points);
fact.setWidth(10);
var shape = fact.createArc(0, Math.PI);
long start = System.currentTimeMillis();
var random = ThreadLocalRandom.current();
for (int i = 0; i < iters; i++) {
LineSplitter splitter = new LineSplitter(shape);
var a = random.nextDouble(0, 1);
var b = random.nextDouble(a, 1);
splitter.get(a, b);
}
return System.currentTimeMillis() - start;
}
}

Wyświetl plik

@ -1,5 +1,6 @@
package com.onthegomap.planetiler;
import com.google.common.collect.Range;
import com.onthegomap.planetiler.collection.FeatureGroup;
import com.onthegomap.planetiler.config.PlanetilerConfig;
import com.onthegomap.planetiler.geo.GeoUtils;
@ -9,6 +10,8 @@ import com.onthegomap.planetiler.reader.SourceFeature;
import com.onthegomap.planetiler.render.FeatureRenderer;
import com.onthegomap.planetiler.stats.Stats;
import com.onthegomap.planetiler.util.CacheByZoom;
import com.onthegomap.planetiler.util.MapUtil;
import com.onthegomap.planetiler.util.MergingRangeMap;
import com.onthegomap.planetiler.util.ZoomFunction;
import java.util.ArrayList;
import java.util.Iterator;
@ -24,6 +27,7 @@ import org.locationtech.jts.geom.Geometry;
* <p>
* For example to add a polygon feature for a lake and a center label point with its name:
* {@snippet :
* FeatureCollector featureCollector;
* featureCollector.polygon("water")
* .setAttr("class", "lake");
* featureCollector.centroid("water_name")
@ -105,6 +109,26 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
}
}
/**
* Starts building a new partial line feature from {@code start} to {@code end} where 0 is the beginning of the line
* and 1 is the end of the line.
* <p>
* If the source feature cannot be a line, logs an error and returns a feature that can be configured, but won't
* actually emit anything to the map.
*
* @param layer the output vector tile layer this feature will be written to
* @return a feature that can be configured further.
*/
public Feature partialLine(String layer, double start, double end) {
try {
return geometry(layer, source.partialLine(start, end));
} catch (GeometryException e) {
e.log(stats, "feature_partial_line", "Error constructing partial line for " + source);
return new Feature(layer, EMPTY_GEOM, source.id());
}
}
/**
* Starts building a new polygon map feature that expects the source feature to be a polygon.
* <p>
@ -222,6 +246,128 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
}
}
private sealed interface OverrideCommand {
Range<Double> range();
}
private record Minzoom(Range<Double> range, int minzoom) implements OverrideCommand {}
private record Maxzoom(Range<Double> range, int maxzoom) implements OverrideCommand {}
private record Omit(Range<Double> range) implements OverrideCommand {}
private record Attr(Range<Double> range, String key, Object value) implements OverrideCommand {}
public interface WithZoomRange<T extends WithZoomRange<T>> {
/**
* Sets the zoom range (inclusive) that this feature appears in.
* <p>
* If not called, then defaults to all zoom levels.
*/
default T setZoomRange(int min, int max) {
assert min <= max;
return setMinZoom(min).setMaxZoom(max);
}
/**
* Sets the minimum zoom level (inclusive) that this feature appears in.
* <p>
* If not called, defaults to minimum zoom-level of the map.
*/
T setMinZoom(int min);
/**
* Sets the maximum zoom level (inclusive) that this feature appears in.
* <p>
* If not called, defaults to maximum zoom-level of the map.
*/
T setMaxZoom(int max);
}
public interface WithSelf<T extends WithSelf<T>> {
default T self() {
return (T) this;
}
}
public interface WithAttrs<T extends WithAttrs<T>> extends WithSelf<T> {
/** Copies the value for {@code key} attribute from source feature to the output feature. */
default T inheritAttrFromSource(String key) {
return setAttr(key, collector().source.getTag(key));
}
/**
* Sets an attribute on the output feature to either a string, number, boolean, or instance of {@link ZoomFunction}
* to change the value for {@code key} by zoom-level.
*/
T setAttr(String key, Object value);
/**
* Sets the value for {@code key} attribute at or above {@code minzoom}. Below {@code minzoom} it will be ignored.
* <p>
* Replaces all previous value that has been for {@code key} at any zoom level. To have a value that changes at
* multiple zoom level thresholds, call {@link #setAttr(String, Object)} with a manually-constructed
* {@link ZoomFunction} value.
*/
default T setAttrWithMinzoom(String key, Object value, int minzoom) {
return setAttr(key, ZoomFunction.minZoom(minzoom, value));
}
/**
* Sets the value for {@code key} only at zoom levels where the feature is at least {@code minPixelSize} pixels in
* size.
*/
default T setAttrWithMinSize(String key, Object value, double minPixelSize) {
return setAttrWithMinzoom(key, value, collector().getMinZoomForPixelSize(minPixelSize));
}
/**
* Sets the value for {@code key} so that it always shows when {@code zoom_level >= minZoomToShowAlways} but only
* shows when {@code minZoomIfBigEnough <= zoom_level < minZoomToShowAlways} when it is at least
* {@code minPixelSize} pixels in size.
* <p>
* If you need more flexibility, use {@link #getMinZoomForPixelSize(double)} directly, or create a
* {@link ZoomFunction} that calculates {@link #getPixelSizeAtZoom(int)} and applies a custom threshold based on the
* zoom level.
*/
default T setAttrWithMinSize(String key, Object value, double minPixelSize, int minZoomIfBigEnough,
int minZoomToShowAlways) {
return setAttrWithMinzoom(key, value,
Math.clamp(collector().getMinZoomForPixelSize(minPixelSize), minZoomIfBigEnough, minZoomToShowAlways));
}
/**
* Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature at or above
* {@code minzoom}.
* <p>
* Replace values that have already been set.
*/
default T putAttrsWithMinzoom(Map<String, Object> attrs, int minzoom) {
for (var entry : attrs.entrySet()) {
setAttrWithMinzoom(entry.getKey(), entry.getValue(), minzoom);
}
return self();
}
/**
* Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature.
* <p>
* Does not touch attributes that have already been set.
* <p>
* Values in {@code attrs} can either be the raw value to set, or an instance of {@link ZoomFunction} to change the
* value for that attribute by zoom level.
*/
default T putAttrs(Map<String, Object> attrs) {
for (var entry : attrs.entrySet()) {
setAttr(entry.getKey(), entry.getValue());
}
return self();
}
/** Returns the {@link FeatureCollector} this feature came from. */
FeatureCollector collector();
}
/**
* Creates new feature collector instances for each source feature that we encounter.
*/
@ -232,6 +378,11 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
}
}
private record PartialOverride(Range<Double> range, Object key, Object value) {}
/** A fully-configured subset of this line feature with linear-scoped attributes applied to a subset of the range.. */
public record RangeWithTags(double start, double end, Geometry geom, Map<String, Object> attrs) {}
/**
* A builder for an output map feature that contains all the information that will be needed to render vector tile
* features from the input element.
@ -239,7 +390,7 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
* Some feature attributes are set globally (like sort key), and some allow the value to change by zoom-level (like
* tags).
*/
public final class Feature {
public final class Feature implements WithZoomRange<Feature>, WithAttrs<Feature> {
private static final double DEFAULT_LABEL_GRID_SIZE = 0;
private static final int DEFAULT_LABEL_GRID_LIMIT = 0;
@ -260,6 +411,7 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
private boolean attrsChangeByZoom = false;
private CacheByZoom<Map<String, Object>> attrCache = null;
private CacheByZoom<List<RangeWithTags>> partialRangeCache = null;
private double defaultBufferPixels = 4;
private ZoomFunction<Number> bufferPixelOverrides;
@ -274,6 +426,7 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
private ZoomFunction<Number> pixelTolerance = null;
private String numPointsAttr = null;
private List<OverrideCommand> partialOverrides = null;
private Feature(String layer, Geometry geom, long id) {
this.layer = layer;
@ -335,27 +488,12 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
return setSortKey(FeatureGroup.SORT_KEY_MAX + FeatureGroup.SORT_KEY_MIN - sortKey);
}
/**
* Sets the zoom range (inclusive) that this feature appears in.
* <p>
* If not called, then defaults to all zoom levels.
*/
public Feature setZoomRange(int min, int max) {
assert min <= max;
return setMinZoom(min).setMaxZoom(max);
}
/** Returns the minimum zoom level (inclusive) that this feature appears in. */
public int getMinZoom() {
return minzoom;
}
/**
* Sets the minimum zoom level (inclusive) that this feature appears in.
* <p>
* If not called, defaults to minimum zoom-level of the map.
*/
@Override
public Feature setMinZoom(int min) {
minzoom = Math.max(min, config.minzoom());
return this;
@ -366,11 +504,7 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
return maxzoom;
}
/**
* Sets the maximum zoom level (inclusive) that this feature appears in.
* <p>
* If not called, defaults to maximum zoom-level of the map.
*/
@Override
public Feature setMaxZoom(int max) {
maxzoom = Math.min(max, config.maxzoom());
return this;
@ -695,15 +829,8 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
return attrCache.get(zoom);
}
/** Copies the value for {@code key} attribute from source feature to the output feature. */
public Feature inheritAttrFromSource(String key) {
return setAttr(key, source.getTag(key));
}
/**
* Sets an attribute on the output feature to either a string, number, boolean, or instance of {@link ZoomFunction}
* to change the value for {@code key} by zoom-level.
*/
@Override
public Feature setAttr(String key, Object value) {
if (value instanceof ZoomFunction) {
attrsChangeByZoom = true;
@ -714,61 +841,7 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
return this;
}
/**
* Sets the value for {@code key} attribute at or above {@code minzoom}. Below {@code minzoom} it will be ignored.
* <p>
* Replaces all previous value that has been for {@code key} at any zoom level. To have a value that changes at
* multiple zoom level thresholds, call {@link #setAttr(String, Object)} with a manually-constructed
* {@link ZoomFunction} value.
*/
public Feature setAttrWithMinzoom(String key, Object value, int minzoom) {
return setAttr(key, ZoomFunction.minZoom(minzoom, value));
}
/**
* Sets the value for {@code key} only at zoom levels where the feature is at least {@code minPixelSize} pixels in
* size.
*/
public Feature setAttrWithMinSize(String key, Object value, double minPixelSize) {
return setAttrWithMinzoom(key, value, getMinZoomForPixelSize(minPixelSize));
}
/**
* Sets the value for {@code key} so that it always shows when {@code zoom_level >= minZoomToShowAlways} but only
* shows when {@code minZoomIfBigEnough <= zoom_level < minZoomToShowAlways} when it is at least
* {@code minPixelSize} pixels in size.
* <p>
* If you need more flexibility, use {@link #getMinZoomForPixelSize(double)} directly, or create a
* {@link ZoomFunction} that calculates {@link #getPixelSizeAtZoom(int)} and applies a custom threshold based on the
* zoom level.
*/
public Feature setAttrWithMinSize(String key, Object value, double minPixelSize, int minZoomIfBigEnough,
int minZoomToShowAlways) {
return setAttrWithMinzoom(key, value,
Math.clamp(getMinZoomForPixelSize(minPixelSize), minZoomIfBigEnough, minZoomToShowAlways));
}
/**
* Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature at or above
* {@code minzoom}.
* <p>
* Replace values that have already been set.
*/
public Feature putAttrsWithMinzoom(Map<String, Object> attrs, int minzoom) {
for (var entry : attrs.entrySet()) {
setAttrWithMinzoom(entry.getKey(), entry.getValue(), minzoom);
}
return this;
}
/**
* Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature.
* <p>
* Does not touch attributes that have already been set.
* <p>
* Values in {@code attrs} can either be the raw value to set, or an instance of {@link ZoomFunction} to change the
* value for that attribute by zoom level.
*/
@Override
public Feature putAttrs(Map<String, Object> attrs) {
for (Object value : attrs.values()) {
if (value instanceof ZoomFunction) {
@ -780,6 +853,11 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
return this;
}
@Override
public FeatureCollector collector() {
return FeatureCollector.this;
}
/**
* Returns the attribute key that the renderer should use to store the number of points in the simplified geometry
* before slicing it into tiles.
@ -816,5 +894,158 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> {
public double getSourceFeaturePixelSizeAtZoom(int zoom) {
return getPixelSizeAtZoom(zoom);
}
/**
* Returns a {@link LinearRange} that can be used to configure attributes that apply to only a portion of this line
* from {@code start} to {@code end} where 0 is the beginning of the line and 1 is the end.
* <p>
* Since mapbox vector tiles can't handle this natively, the line will be broken up into multiple lines in the
* output tiles at each zoom level with the unique sets of tags on each line. Adjacent segments with the same tags
* will get merged into a single segment.
*/
public LinearRange linearRange(double start, double end) {
return linearRange(Range.closedOpen(start, end));
}
/**
* Returns a {@link LinearRange} that can be used to configure attributes that apply to only a portion of this line
* from {@code range.lowerBound} to {@code range.lowerBound} where 0 is the beginning of the line and 1 is the end.
* <p>
* Since mapbox vector tiles can't handle this natively, the line will be broken up into multiple lines in the
* output tiles at each zoom level with the unique sets of tags on each line. Adjacent segments with the same tags
* will get merged into a single segment.
*/
public LinearRange linearRange(Range<Double> range) {
return new LinearRange(range);
}
/** Returns true if any attributes have been configured over a subset of this line. */
public boolean hasLinearRanges() {
return partialOverrides != null;
}
/** Computes and returns the linear-scoped attributes of this line, and the geometry they apply to. */
public List<RangeWithTags> getLinearRangesAtZoom(int zoom) {
if (partialOverrides == null) {
return List.of();
}
if (partialRangeCache == null) {
partialRangeCache = CacheByZoom.create(this::computeLinearRangesAtZoom);
}
return partialRangeCache.get(zoom);
}
private List<RangeWithTags> computeLinearRangesAtZoom(int zoom) {
record Partial(boolean omit, Map<String, Object> attrs) {
Partial withOmit(boolean newValue) {
return new Partial(newValue || omit, attrs);
}
Partial merge(Partial other) {
return new Partial(other.omit, MapUtil.merge(attrs, other.attrs));
}
Partial withAttr(String key, Object value) {
return new Partial(omit, MapUtil.with(attrs, key, value));
}
}
MergingRangeMap<Partial> result = MergingRangeMap.unit(new Partial(false, attrs), Partial::merge);
for (var override : partialOverrides) {
result.update(override.range(), m -> switch (override) {
case Attr attr ->
m.withAttr(attr.key, attr.value instanceof ZoomFunction<?> fn ? fn.apply(zoom) : attr.value);
case Maxzoom mz -> m.withOmit(mz.maxzoom < zoom);
case Minzoom mz -> m.withOmit(mz.minzoom > zoom);
case Omit ignored -> m.withOmit(true);
});
}
var ranges = result.result();
List<RangeWithTags> rangesWithGeometries = new ArrayList<>(ranges.size());
for (var range : ranges) {
var value = range.value();
if (!value.omit) {
try {
rangesWithGeometries.add(new RangeWithTags(
range.start(),
range.end(),
source.partialLine(range.start(), range.end()),
value.attrs
));
} catch (GeometryException e) {
throw new IllegalStateException(e);
}
}
}
return rangesWithGeometries;
}
/**
* A builder that can be used to configure linear-scoped attributes for a partial segment of a line feature.
*/
public final class LinearRange implements WithZoomRange<LinearRange>, WithAttrs<LinearRange> {
private final Range<Double> range;
private LinearRange(Range<Double> range) {
this.range = range;
}
private LinearRange add(OverrideCommand override) {
if (partialOverrides == null) {
partialOverrides = new ArrayList<>();
}
partialOverrides.add(override);
return this;
}
@Override
public LinearRange setMinZoom(int min) {
return add(new Minzoom(range, min));
}
@Override
public LinearRange setMaxZoom(int max) {
return add(new Maxzoom(range, max));
}
@Override
public LinearRange setAttr(String key, Object value) {
return add(new Attr(range, key, value));
}
/** Exclude this segment of the line feature at all zoom levels. */
public LinearRange omit() {
return add(new Omit(range));
}
/** Returns the full line {@link Feature} that this segment came from. */
public Feature entireLine() {
return Feature.this;
}
/**
* Returns a segment of the full parent line (not the current segment) that can be configured further.
*
* @see Feature#linearRange(double, double)
*/
public LinearRange linearRange(double start, double end) {
return entireLine().linearRange(start, end);
}
/**
* Returns a segment of the full parent line (not the current segment) that can be configured further.
*
* @see Feature#linearRange(Range)
*/
public LinearRange linearRange(Range<Double> range) {
return entireLine().linearRange(range);
}
@Override
public FeatureCollector collector() {
return FeatureCollector.this;
}
}
}
}

Wyświetl plik

@ -0,0 +1,100 @@
package com.onthegomap.planetiler.geo;
import java.util.Arrays;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;
/**
* Utility for extracting sub-ranges of a line.
* <p>
* For example:
* {@snippet :
* LineSplitter splitter = new LineSplitter(line);
* LineString firstHalf = splitter.get(0, 0.5);
* LineString lastQuarter = splitter.get(0.75, 1);
* }
*/
public class LineSplitter {
private final LineString line;
private double length = 0;
private double[] nodeLocations = null;
public LineSplitter(Geometry geom) {
if (geom instanceof LineString linestring) {
this.line = linestring;
} else {
throw new IllegalArgumentException("Expected LineString, got " + geom.getGeometryType());
}
}
/**
* 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.
*/
public LineString get(double start, double end) {
if (start < 0d || end > 1d || end < start) {
throw new IllegalArgumentException("Invalid range: " + start + " to " + end);
}
if (start <= 0 && end >= 1) {
return line;
}
var cs = line.getCoordinateSequence();
if (nodeLocations == null) {
nodeLocations = new double[cs.size()];
double x1 = cs.getX(0);
double y1 = cs.getY(0);
nodeLocations[0] = 0d;
for (int i = 1; i < cs.size(); i++) {
double x2 = cs.getX(i);
double y2 = cs.getY(i);
double dx = x2 - x1;
double dy = y2 - y1;
length += Math.sqrt(dx * dx + dy * dy);
nodeLocations[i] = length;
x1 = x2;
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) {
int idx = Arrays.binarySearch(nodeLocations, length);
return idx < 0 ? (-idx - 2) : idx;
}
private int lowerIndex(double length) {
int idx = Arrays.binarySearch(nodeLocations, length);
return idx < 0 ? (-idx - 2) : idx - 1;
}
private void addInterpolated(MutableCoordinateSequence result, CoordinateSequence cs,
int startIdx, double position) {
int endIdx = startIdx + 1;
double startPos = nodeLocations[startIdx];
double endPos = nodeLocations[endIdx];
double x1 = cs.getX(startIdx);
double y1 = cs.getY(startIdx);
double x2 = cs.getX(endIdx);
double y2 = cs.getY(endIdx);
double ratio = (position - startPos) / (endPos - startPos);
result.addPoint(
x1 + (x2 - x1) * ratio,
y1 + (y2 - y1) * ratio
);
}
}

Wyświetl plik

@ -2,6 +2,7 @@ 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.reader.osm.OsmReader;
import com.onthegomap.planetiler.reader.osm.OsmRelationInfo;
import java.util.ArrayList;
@ -43,6 +44,7 @@ public abstract class SourceFeature implements WithTags, WithGeometryType {
private double area = Double.NaN;
private double length = Double.NaN;
private double size = Double.NaN;
private LineSplitter lineSplitter;
/**
* Constructs a new input feature.
@ -192,6 +194,27 @@ public abstract class SourceFeature implements WithTags, WithGeometryType {
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.
*

Wyświetl plik

@ -174,10 +174,8 @@ public class FeatureRenderer implements Consumer<FeatureCollector.Feature>, Clos
private void renderLineOrPolygon(FeatureCollector.Feature feature, Geometry input) {
boolean area = input instanceof Polygonal;
double worldLength = (area || input.getNumGeometries() > 1) ? 0 : input.getLength();
String numPointsAttr = feature.getNumPointsAttr();
for (int z = feature.getMaxZoom(); z >= feature.getMinZoom(); z--) {
double scale = 1 << z;
double tolerance = feature.getPixelToleranceAtZoom(z) / 256d;
double minSize = feature.getMinPixelSizeAtZoom(z) / 256d;
if (area) {
// treat minPixelSize as the edge of a square that defines minimum area for features
@ -187,40 +185,55 @@ public class FeatureRenderer implements Consumer<FeatureCollector.Feature>, Clos
continue;
}
double buffer = feature.getBufferPixelsAtZoom(z) / 256;
TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(z);
// TODO potential optimization: iteratively simplify z+1 to get z instead of starting with original geom each time
// simplify only takes 4-5 minutes of wall time when generating the planet though, so not a big deal
Geometry scaled = AffineTransformation.scaleInstance(scale, scale).transform(input);
TiledGeometry sliced;
Geometry geom = DouglasPeuckerSimplifier.simplify(scaled, tolerance);
List<List<CoordinateSequence>> groups = GeometryCoordinateSequences.extractGroups(geom, minSize);
try {
sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents);
} catch (GeometryException e) {
try {
geom = GeoUtils.fixPolygon(geom);
groups = GeometryCoordinateSequences.extractGroups(geom, minSize);
sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents);
} catch (GeometryException ex) {
ex.log(stats, "slice_line_or_polygon", "Error slicing feature at z" + z + ": " + feature);
// omit from this zoom level, but maybe the next will be better
continue;
if (feature.hasLinearRanges()) {
for (var range : feature.getLinearRangesAtZoom(z)) {
if (worldLength * scale * (range.end() - range.start()) >= minSize) {
renderLineOrPolygonGeometry(feature, range.geom(), range.attrs(), z, minSize, area);
}
}
} else {
renderLineOrPolygonGeometry(feature, input, feature.getAttrsAtZoom(z), z, minSize, area);
}
Map<String, Object> attrs = feature.getAttrsAtZoom(sliced.zoomLevel());
if (numPointsAttr != null) {
// if profile wants the original number off points that the simplified but untiled geometry started with
attrs = new HashMap<>(attrs);
attrs.put(numPointsAttr, geom.getNumPoints());
}
writeTileFeatures(z, feature.getId(), feature, sliced, attrs);
}
stats.processedElement(area ? "polygon" : "line", feature.getLayer());
}
private void renderLineOrPolygonGeometry(FeatureCollector.Feature feature, Geometry input, Map<String, Object> attrs,
int z, double minSize, boolean area) {
double scale = 1 << z;
double tolerance = feature.getPixelToleranceAtZoom(z) / 256d;
double buffer = feature.getBufferPixelsAtZoom(z) / 256;
TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(z);
// TODO potential optimization: iteratively simplify z+1 to get z instead of starting with original geom each time
// simplify only takes 4-5 minutes of wall time when generating the planet though, so not a big deal
Geometry scaled = AffineTransformation.scaleInstance(scale, scale).transform(input);
TiledGeometry sliced;
Geometry geom = DouglasPeuckerSimplifier.simplify(scaled, tolerance);
List<List<CoordinateSequence>> groups = GeometryCoordinateSequences.extractGroups(geom, minSize);
try {
sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents);
} catch (GeometryException e) {
try {
geom = GeoUtils.fixPolygon(geom);
groups = GeometryCoordinateSequences.extractGroups(geom, minSize);
sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents);
} catch (GeometryException ex) {
ex.log(stats, "slice_line_or_polygon", "Error slicing feature at z" + z + ": " + feature);
// omit from this zoom level, but maybe the next will be better
return;
}
}
String numPointsAttr = feature.getNumPointsAttr();
if (numPointsAttr != null) {
// if profile wants the original number off points that the simplified but untiled geometry started with
attrs = new HashMap<>(attrs);
attrs.put(numPointsAttr, geom.getNumPoints());
}
writeTileFeatures(z, feature.getId(), feature, sliced, attrs);
}
private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature feature, TiledGeometry sliced,
Map<String, Object> attrs) {
int emitted = 0;

Wyświetl plik

@ -0,0 +1,31 @@
package com.onthegomap.planetiler.util;
import java.util.HashMap;
import java.util.Map;
public class MapUtil {
private MapUtil() {}
/**
* Returns a new map with the union of entries from {@code a} and {@code b} where conflicts take the value from
* {@code b}.
*/
public static <K, V> Map<K, V> merge(Map<K, V> a, Map<K, V> b) {
Map<K, V> copy = new HashMap<>(a);
copy.putAll(b);
return copy;
}
/**
* Returns a new map the entries of {@code a} added to {@code (k, v)}.
*/
public static <K, V> Map<K, V> with(Map<K, V> a, K k, V v) {
Map<K, V> copy = new HashMap<>(a);
if (v == null || "".equals(v)) {
copy.remove(k);
} else {
copy.put(k, v);
}
return copy;
}
}

Wyświetl plik

@ -0,0 +1,106 @@
package com.onthegomap.planetiler.util;
import com.google.common.collect.Range;
import com.google.common.collect.RangeMap;
import com.google.common.collect.TreeRangeMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.BinaryOperator;
import java.util.function.UnaryOperator;
/**
* A mapping from disjoint ranges to values that merges values assigned to overlapping ranges.
* <p>
* This is similar {@link RangeMap} that merges overlapping values and coalesces ranges with identical values by
* default.
* <p>
* For example:
* {@snippet :
* MergingRangeMap map = MergingRangeMap.unitMap();
* map.put(0, 0.5, Map.of("key", "value"));
* // overrides value for key from [0.4, 0.5), and sets from [0.5, 1)
* map.put(0.4, 1, Map.of("key", "value2"));
* // adds key2 from [0.9, 1)
* map.put(0.9, 1, Map.of("key2", "value3"));
* // returns [
* // Partial[start=0.0, end=0.4, value={key=value}],
* // Partial[start=0.4, end=0.9, value={key=value2}],
* // Partial[start=0.9, end=1.0, value={key2=value3, key=value2}]
* // ]
* map.result();
* }
*/
public class MergingRangeMap<T> {
private final RangeMap<Double, T> items = TreeRangeMap.create();
private final BinaryOperator<T> merger;
private MergingRangeMap(double lo, double hi, T identity, BinaryOperator<T> merger) {
items.put(Range.closedOpen(lo, hi), identity);
this.merger = merger;
}
/**
* Returns a new range map where values are {@link Map Maps} that get merged together when they overlap from
* {@code [0, 1)}.
*/
public static <K, V> MergingRangeMap<Map<K, V>> unitMap() {
return unit(Map.of(), MapUtil::merge);
}
/**
* Returns a new range map with {@code identity} from {@code [0, 1)} and {@code merger} as the default merging
* function for {@link #put(Range, Object)}.
*/
public static <T> MergingRangeMap<T> unit(T identity, BinaryOperator<T> merger) {
return create(0, 1, identity, merger);
}
/**
* Returns a new range map with {@code identity} from {@code [lo, hi)} and {@code merger} as the default merging
* function for {@link #put(Range, Object)}.
*/
public static <T> MergingRangeMap<T> create(double lo, double hi, T identity, BinaryOperator<T> merger) {
return new MergingRangeMap<>(lo, hi, identity, merger);
}
/** Returns the distinct set of ranges and their values where adjacent maps with identical values are merged. */
public List<Partial<T>> result() {
List<Partial<T>> result = new ArrayList<>();
for (var entry : items.asMapOfRanges().entrySet()) {
result.add(new Partial<>(entry.getKey().lowerEndpoint(), entry.getKey().upperEndpoint(), entry.getValue()));
}
return result;
}
/**
* Change each of the distinct values over {@code range} to the result of applying {@code operator} to the existing
* value.
*/
public void update(Range<Double> range, UnaryOperator<T> operator) {
var overlaps = new ArrayList<>(items.subRangeMap(range).asMapOfRanges().entrySet());
for (var overlap : overlaps) {
items.putCoalescing(overlap.getKey(), operator.apply(overlap.getValue()));
}
}
/**
* Merge {@code next} into the value associated with all ranges that overlap {@code [start, end)} using the default
* merging function.
*/
public void put(double start, double end, T next) {
put(Range.closedOpen(start, end), next);
}
/**
* Merge {@code next} into the value associated with all ranges that overlap {@code range} using the default merging
* function.
*/
public void put(Range<Double> range, T next) {
update(range, prev -> merger.apply(prev, next));
}
/** Subset of the range and value that applies to it. */
public record Partial<T>(double start, double end, T value) {}
}

Wyświetl plik

@ -653,4 +653,99 @@ class FeatureCollectorTest {
)
), collector);
}
@Test
void testPartialLineFeature() {
var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of()));
collector.partialLine("layername", 0.25, 0.5).setAttr("k1", "v1");
collector.partialLine("layername", 0.75, 1).setAttr("k2", "v2");
assertFeatures(14, List.of(
Map.of(
"_geom", new RoundGeometry(newLineString(0.25, 0, 0.5, 0)),
"k1", "v1",
"k2", "<null>"
),
Map.of(
"_geom", new RoundGeometry(newLineString(0.75, 0, 1, 0)),
"k1", "<null>",
"k2", "v2"
)
), collector);
}
@Test
void testLinearReferenceTags() {
var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of()));
collector.line("layername")
.linearRange(0.1, 0.5).setAttr("k1", "v1")
.linearRange(0.3, 0.7).setAttr("k2", "v2")
.entireLine().setAttr("k3", "v3");
var feature = collector.iterator().next();
assertTrue(feature.hasLinearRanges());
assertEquals(List.of(
new FeatureCollector.RangeWithTags(0, 0.1, roundTrip(newLineString(0, 0, 0.1, 0)), Map.of(
"k3", "v3"
)),
new FeatureCollector.RangeWithTags(0.1, 0.3, roundTrip(newLineString(0.1, 0, 0.3, 0)), Map.of(
"k1", "v1",
"k3", "v3"
)),
new FeatureCollector.RangeWithTags(0.3, 0.5, roundTrip(newLineString(0.3, 0, 0.5, 0)), Map.of(
"k1", "v1",
"k2", "v2",
"k3", "v3"
)),
new FeatureCollector.RangeWithTags(0.5, 0.7, roundTrip(newLineString(0.5, 0, 0.7, 0)), Map.of(
"k2", "v2",
"k3", "v3"
)),
new FeatureCollector.RangeWithTags(0.7, 1, roundTrip(newLineString(0.7, 0, 1, 0)), Map.of(
"k3", "v3"
))
), feature.getLinearRangesAtZoom(14));
}
@Test
void testPartialMinzoom() {
var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of()));
collector.line("layername")
.linearRange(0.25, 0.75).setMinZoom(14);
assertFeatures(13, List.of(
Map.of("_geom", new RoundGeometry(newLineString(0, 0, 1, 0)))
), collector);
assertFeatures(14, List.of(
Map.of("_geom", new RoundGeometry(newLineString(0, 0, 1, 0)))
), collector);
var feature = collector.iterator().next();
assertTrue(feature.hasLinearRanges());
assertEquals(List.of(
new FeatureCollector.RangeWithTags(0, 0.25, roundTrip(newLineString(0, 0, 0.25, 0)), Map.of()),
new FeatureCollector.RangeWithTags(0.75, 1, roundTrip(newLineString(0.75, 0, 1, 0)), Map.of())
), feature.getLinearRangesAtZoom(13));
assertEquals(List.of(
new FeatureCollector.RangeWithTags(0, 1, roundTrip(newLineString(0, 0, 1, 0)), Map.of())
), feature.getLinearRangesAtZoom(14));
}
private static Geometry roundTrip(Geometry world) {
return GeoUtils.latLonToWorldCoords(GeoUtils.worldToLatLonCoords(world));
}
@Test
void testPartialOmit() {
var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of()));
collector.line("layername")
.linearRange(0.25, 0.75).omit();
var feature = collector.iterator().next();
assertTrue(feature.hasLinearRanges());
assertEquals(List.of(
new FeatureCollector.RangeWithTags(0, 0.25, roundTrip(newLineString(0, 0, 0.25, 0)), Map.of()),
new FeatureCollector.RangeWithTags(0.75, 1, roundTrip(newLineString(0.75, 0, 1, 0)), Map.of())
), feature.getLinearRangesAtZoom(13));
assertEquals(List.of(
new FeatureCollector.RangeWithTags(0, 0.25, roundTrip(newLineString(0, 0, 0.25, 0)), Map.of()),
new FeatureCollector.RangeWithTags(0.75, 1, roundTrip(newLineString(0.75, 0, 1, 0)), Map.of())
), feature.getLinearRangesAtZoom(14));
}
}

Wyświetl plik

@ -474,6 +474,81 @@ class PlanetilerTests {
), results.tiles);
}
@Test
void testPartialLine() throws Exception {
double x1 = 0.5 + Z14_WIDTH / 2;
double y1 = 0.5 + Z14_WIDTH / 2;
double x2 = x1 + Z14_WIDTH;
double y2 = y1 + Z14_WIDTH;
double lat1 = GeoUtils.getWorldLat(y1);
double lng1 = GeoUtils.getWorldLon(x1);
double lat2 = GeoUtils.getWorldLat(y2);
double lng2 = GeoUtils.getWorldLon(x2);
var results = runWithReaderFeatures(
Map.of("threads", "1"),
List.of(
newReaderFeature(newLineString(lng1, lat1, lng2, lat2), Map.of(
"attr", "value"
))
),
(in, features) -> features.partialLine("layer", 0, 0.5)
.setZoomRange(13, 14)
.setBufferPixels(4)
);
assertSubmap(Map.of(
TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of(
feature(newLineString(128, 128, 256, 256), Map.of())
),
TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14), List.of(
feature(newLineString(-4, -4, 0, 0), Map.of())
),
TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13), List.of(
feature(newLineString(64, 64, 128, 128), Map.of())
)
), results.tiles);
}
@Test
void testLineWithPartialAttr() throws Exception {
double x1 = 0.5 + Z14_WIDTH / 2;
double y1 = 0.5 + Z14_WIDTH / 2;
double x2 = x1 + Z14_WIDTH;
double y2 = y1 + Z14_WIDTH;
double lat1 = GeoUtils.getWorldLat(y1);
double lng1 = GeoUtils.getWorldLon(x1);
double lat2 = GeoUtils.getWorldLat(y2);
double lng2 = GeoUtils.getWorldLon(x2);
var results = runWithReaderFeatures(
Map.of("threads", "1"),
List.of(
newReaderFeature(newLineString(lng1, lat1, lng2, lat2), Map.of(
"attr", "value"
))
),
(in, features) -> features.line("layer")
.linearRange(0, 0.25).setAttrWithMinzoom("k", "v", 14)
.entireLine()
.setZoomRange(13, 14)
.setBufferPixels(4)
);
assertSubmap(Map.of(
TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of(
feature(newLineString(192, 192, 260, 260), Map.of()),
feature(newLineString(128, 128, 192, 192), Map.of("k", "v"))
),
TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14), List.of(
feature(newLineString(-4, -4, 128, 128), Map.of())
),
TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13), List.of(
feature(newLineString(64, 64, 192, 192), Map.of())
)
), results.tiles);
}
@Test
void testLineStringDegenerateWhenUnscaled() throws Exception {
double x1 = 0.5 + Z12_WIDTH / 2;

Wyświetl plik

@ -428,6 +428,24 @@ public class TestUtils {
}
}
public record RoundGeometry(Geometry geom) implements GeometryComparision {
@Override
public boolean equals(Object o) {
return o instanceof GeometryComparision that && round(geom).equalsNorm(round(that.geom()));
}
@Override
public String toString() {
return "Round{" + round(geom).norm() + '}';
}
@Override
public int hashCode() {
return 0;
}
}
private record ExactGeometry(Geometry geom) implements GeometryComparision {
@Override

Wyświetl plik

@ -0,0 +1,67 @@
package com.onthegomap.planetiler.geo;
import static com.onthegomap.planetiler.TestUtils.newLineString;
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;
class LineSplitterTest {
@ParameterizedTest
@CsvSource({
"0,1",
"0,0.25",
"0.75, 1",
"0.25, 0.75",
})
void testSingleSegment(double start, double end) {
var l = new LineSplitter(newLineString(0, 0, 2, 1));
assertEquals(
newLineString(start * 2, start, end * 2, end),
l.get(start, end)
);
}
@Test
void testLength2() {
var l = new LineSplitter(newLineString(0, 0, 1, 2, 2, 4));
assertEquals(
newLineString(0, 0, 0.5, 1),
l.get(0, 0.25)
);
assertEquals(
newLineString(0.2, 0.4, 0.5, 1),
l.get(0.1, 0.25)
);
assertEquals(
newLineString(0.5, 1, 1, 2),
l.get(0.25, 0.5)
);
assertEquals(
newLineString(0.5, 1, 1, 2, 1.5, 3),
l.get(0.25, 0.75)
);
assertEquals(
newLineString(1, 2, 1.5, 3),
l.get(0.5, 0.75)
);
assertEquals(
newLineString(1.2, 2.4, 1.5, 3),
l.get(0.6, 0.75)
);
assertEquals(
newLineString(1.5, 3, 2, 4),
l.get(0.75, 1)
);
}
@Test
void testInvalid() {
var l = new LineSplitter(newLineString(0, 0, 1, 2, 2, 4));
assertThrows(IllegalArgumentException.class, () -> l.get(-0.1, 0.5));
assertThrows(IllegalArgumentException.class, () -> l.get(0.9, 1.1));
assertThrows(IllegalArgumentException.class, () -> l.get(0.6, 0.5));
}
}

Wyświetl plik

@ -12,6 +12,7 @@ import com.onthegomap.planetiler.TestUtils;
import com.onthegomap.planetiler.config.Arguments;
import com.onthegomap.planetiler.config.PlanetilerConfig;
import com.onthegomap.planetiler.geo.GeoUtils;
import com.onthegomap.planetiler.geo.GeometryException;
import com.onthegomap.planetiler.geo.TileCoord;
import com.onthegomap.planetiler.reader.SimpleFeature;
import com.onthegomap.planetiler.stats.Stats;
@ -23,8 +24,10 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import java.util.stream.DoubleStream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
@ -1425,4 +1428,64 @@ class FeatureRendererTest {
actual
);
}
@Test
void testLinearRangeFeature() {
var feature = lineFeature(
newLineString(
0.5 + Z14_WIDTH / 2, 0.5 + Z14_WIDTH / 2,
0.5 + Z14_WIDTH / 2 + Z14_PX * 10, 0.5 + Z14_WIDTH / 2 + Z14_PX * 10
)
).linearRange(0.5, 1).setAttr("k", "v").entireLine();
Map<TileCoord, Collection<RenderedFeature>> rendered = renderFeatures(feature);
assertEquals(
Set.of(
List.of(newLineString(128 + 5, 128 + 5, 128 + 10, 128 + 10), Map.of("k", "v")),
List.of(newLineString(128, 128, 128 + 5, 128 + 5), Map.of())
),
rendered.get(TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14)).stream()
.map(RenderedFeature::vectorTileFeature)
.map(d -> {
try {
return List.of(d.geometry().decode(), d.tags());
} catch (GeometryException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toSet())
);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testLinearRangeFeaturePartialMinzoom(boolean viaMinzoom) {
var feature = lineFeature(
newLineString(
0.5 + Z13_WIDTH / 2, 0.5 + Z13_WIDTH / 2,
0.5 + Z13_WIDTH / 2 + Z13_PX * 10, 0.5 + Z13_WIDTH / 2 + Z13_PX * 10
)
);
if (viaMinzoom) {
feature.linearRange(0.5, 1).setMinZoom(14);
} else {
feature.linearRange(0.5, 1).omit();
}
Map<TileCoord, Collection<RenderedFeature>> rendered = renderFeatures(feature);
assertEquals(
Set.of(
List.of(newLineString(128, 128, 128 + 5, 128 + 5), Map.of())
),
rendered.get(TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13)).stream()
.map(RenderedFeature::vectorTileFeature)
.map(d -> {
try {
return List.of(d.geometry().decode(), d.tags());
} catch (GeometryException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toSet())
);
}
}

Wyświetl plik

@ -0,0 +1,69 @@
package com.onthegomap.planetiler.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class MergingRangeMapTest {
@Test
void empty() {
var map = MergingRangeMap.unitMap();
assertEquals(
List.of(
new MergingRangeMap.Partial<>(0, 1.0, Map.of())
),
map.result()
);
}
@Test
void testPartialOverlap() {
var map = MergingRangeMap.unitMap();
map.put(0.25, 0.75, Map.of("b", 3, "c", 4));
map.put(0.5, 1.0, Map.of("a", 1, "b", 2));
assertEquals(
List.of(
new MergingRangeMap.Partial<>(0, 0.25, Map.of()),
new MergingRangeMap.Partial<>(0.25, 0.5, Map.of("b", 3, "c", 4)),
new MergingRangeMap.Partial<>(0.5, 0.75, Map.of("a", 1, "b", 2, "c", 4)),
new MergingRangeMap.Partial<>(0.75, 1.0, Map.of("a", 1, "b", 2))
),
map.result()
);
}
@Test
void testPutSingle() {
var map = MergingRangeMap.unitMap();
map.put(0.25, 0.75, Map.of("b", 3));
map.put(0.25, 0.75, Map.of("c", 4));
map.put(0.5, 1.0, Map.of("a", 1));
map.put(0.5, 1.0, Map.of("b", 2));
assertEquals(
List.of(
new MergingRangeMap.Partial<>(0, 0.25, Map.of()),
new MergingRangeMap.Partial<>(0.25, 0.5, Map.of("b", 3, "c", 4)),
new MergingRangeMap.Partial<>(0.5, 0.75, Map.of("a", 1, "b", 2, "c", 4)),
new MergingRangeMap.Partial<>(0.75, 1.0, Map.of("a", 1, "b", 2))
),
map.result()
);
}
@Test
void testDuplicateKeys() {
var map = MergingRangeMap.unitMap();
map.put(0.25, 0.75, Map.of("a", 1));
map.put(0.5, 1.0, Map.of("a", 1));
assertEquals(
List.of(
new MergingRangeMap.Partial<>(0, 0.25, Map.of()),
new MergingRangeMap.Partial<>(0.25, 1d, Map.of("a", 1))
),
map.result()
);
}
}