diff --git a/web-bundle/src/main/java/com/graphhopper/resources/BufferResource.java b/web-bundle/src/main/java/com/graphhopper/resources/BufferResource.java index 4a362bae149..2a019dfe227 100644 --- a/web-bundle/src/main/java/com/graphhopper/resources/BufferResource.java +++ b/web-bundle/src/main/java/com/graphhopper/resources/BufferResource.java @@ -21,7 +21,6 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; -import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.PrecisionModel; import jakarta.inject.Inject; @@ -43,37 +42,13 @@ public class BufferResource { private final GraphHopperConfig config; private final DistanceCalculationHelper distanceHelper; private final EdgeAngleCalculator angleCalculator; + private final DirectionFilterHelper directionFilterHelper; private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(1E8)); //region Constants, Enums and Records private static final double PROXIMITY_THRESHOLD_METERS = 6.096; // 20 feet private static final double INITIAL_SEARCH_RADIUS_DEGREES = 0.0001; // Roughly 11 meters - private static final int DUE_NORTHEAST = 45; - private static final int DUE_SOUTHEAST = 135; - private static final int DUE_SOUTHWEST = 225; - private static final int DUE_NORTHWEST = 315; - - - enum Direction { - UNKNOWN, - NORTH, - SOUTH, - EAST, - WEST, - NORTHWEST, - NORTHEAST, - SOUTHWEST, - SOUTHEAST, - BOTH - } - - private static final EnumSet CARDINAL_DIRECTIONS = EnumSet.of( - Direction.NORTH, - Direction.SOUTH, - Direction.EAST, - Direction.WEST - ); /** * Represents the result of edge matching logic for the given location. @@ -103,6 +78,7 @@ public BufferResource(GraphHopperConfig config, GraphHopper graphHopper) { this.edgeExplorer = graph.createEdgeExplorer(); this.distanceHelper = new DistanceCalculationHelper(graph, nodeAccess); this.angleCalculator = new EdgeAngleCalculator(graph); + this.directionFilterHelper = new DirectionFilterHelper(); EncodingManager encodingManager = graphHopper.getEncodingManager(); this.roundaboutAccessEnc = encodingManager.getBooleanEncodedValue(Roundabout.KEY); @@ -126,7 +102,6 @@ public Response doGet( validateInputParameters(queryMultiplier, requireRoadNameMatch, roadName); StopWatch stopWatch = new StopWatch().start(); - Direction directionEnum = Direction.valueOf(direction.toUpperCase()); EdgeMatchingDecision edgeMatchingDecision = determineEdgeMatchingStrategy(roadName, requireRoadNameMatch, point); String edgeRoadName = edgeMatchingDecision.roadName(); @@ -150,7 +125,10 @@ public Response doGet( } } - List filteredLineStrings = filterPathsByDirection(generatedPaths, buildUpstream, directionEnum); + List filteredLineStrings = directionFilterHelper.filterPathsByDirection( + generatedPaths, + buildUpstream, + DirectionFilterHelper.Direction.valueOf(direction.toUpperCase())); return createGeoJsonResponse(filteredLineStrings, stopWatch); } @@ -567,7 +545,7 @@ private BufferFeature buildPathToThresholdDistance(final BufferFeature startFeat List potentialEdges = new ArrayList<>(); List potentialRoundaboutEdges = new ArrayList<>(); List potentialRoundaboutEdgesWithoutName = new ArrayList<>(); - List potentialUnnamedEdges = new ArrayList<>(); + List candidateEdgesFromUnnamedRoad = new ArrayList<>(); currentEdge = -1; while (iterator.next()) { @@ -608,15 +586,15 @@ else if (roadName == null && !tempState.get(this.roundaboutAccessEnc)) { boolean matchesPreviousEdgeName = (currentIsBlank && previousIsBlank) || (currentEdgeRoadName != null && currentEdgeRoadName.equals(previousRoadName)); - if (matchesPreviousEdgeName) { - if (!currentIsBlank) { - currentEdge = tempEdge; - usedEdges.add(tempEdge); - break; - } - potentialUnnamedEdges.add(tempEdge); - } else if (previousIsBlank) { - potentialEdges.add(tempEdge); + if (matchesPreviousEdgeName && !currentIsBlank) { + currentEdge = tempEdge; + usedEdges.add(tempEdge); + break; + } + + // Collect both named and unnamed edges for angle-based selection when continuing from an unnamed road. + if (previousIsBlank) { + candidateEdgesFromUnnamedRoad.add(tempEdge); } } @@ -628,8 +606,8 @@ else if (tempState.get(this.roundaboutAccessEnc)) { } // No bidirectional edge found. Choose from potential edge lists. - if (!potentialUnnamedEdges.isEmpty()) { - currentEdge = angleCalculator.selectStraightestEdge(potentialUnnamedEdges, currentState, currentNode); + if (!candidateEdgesFromUnnamedRoad.isEmpty()) { + currentEdge = angleCalculator.selectStraightestEdge(candidateEdgesFromUnnamedRoad, currentState, currentNode); usedEdges.add(currentEdge); } else if (currentEdge == -1) { @@ -899,88 +877,6 @@ private Integer findClosestEdgeByDistance(List edges, double lat, doubl return closestEdge; } - //endregion - //region Direction Filtering - - /** - * Filters a list of LineStrings based on the specified cardinal or intercardinal direction. - * Compares the furthest points of the first and last LineStrings to determine which aligns - * best with the desired direction, returning only the selected LineString. - * @param lineStrings list of LineStrings to filter - * @param buildUpstream whether the paths were built up or downstream - * @param directionEnum the direction to filter by - * @return A list of LineStrings filtered by the specified direction. - */ - private List filterPathsByDirection(List lineStrings, Boolean buildUpstream, Direction directionEnum) { - if (lineStrings == null || lineStrings.isEmpty() || lineStrings.size() == 1) { - return lineStrings != null ? lineStrings : Collections.emptyList(); - } - - if (directionEnum == Direction.BOTH || directionEnum == Direction.UNKNOWN) { - return lineStrings; - } - - Point furthestPointOfFirstPath = buildUpstream ? lineStrings.get(0).getStartPoint() : lineStrings.get(0).getEndPoint(); - Point furthestPointOfSecondPath = buildUpstream ? lineStrings.get(lineStrings.size() - 1).getStartPoint() : lineStrings.get(lineStrings.size() - 1).getEndPoint(); - boolean selectFirstPath = true; - boolean isNonCardinal = !CARDINAL_DIRECTIONS.contains(directionEnum); - - switch (directionEnum) { - case NORTH: - selectFirstPath = buildUpstream - ? furthestPointOfFirstPath.getY() < furthestPointOfSecondPath.getY() - : furthestPointOfFirstPath.getY() > furthestPointOfSecondPath.getY(); - break; - case SOUTH: - selectFirstPath = buildUpstream - ? furthestPointOfFirstPath.getY() > furthestPointOfSecondPath.getY() - : furthestPointOfFirstPath.getY() < furthestPointOfSecondPath.getY(); - break; - case EAST: - selectFirstPath = buildUpstream - ? furthestPointOfFirstPath.getX() < furthestPointOfSecondPath.getX() - : furthestPointOfFirstPath.getX() > furthestPointOfSecondPath.getX(); - break; - case WEST: - selectFirstPath = buildUpstream - ? furthestPointOfFirstPath.getX() > furthestPointOfSecondPath.getX() - : furthestPointOfFirstPath.getX() < furthestPointOfSecondPath.getX(); - break; - default: - break; - } - - if (isNonCardinal) { - // For non-cardinal directions: Calculate bearing between terminal points for better direction accuracy. - // Splits the circle into two halves and checks if the angle is within the specified half. - int bearing = (int) Math.round(AngleCalc.ANGLE_CALC.calcAzimuth( - furthestPointOfFirstPath.getY(), furthestPointOfFirstPath.getX(), - furthestPointOfSecondPath.getY(),furthestPointOfSecondPath.getX())); - - switch (directionEnum) { - case NORTHEAST: - selectFirstPath = bearing >= DUE_NORTHWEST || bearing <= DUE_SOUTHEAST; - break; - case SOUTHWEST: - selectFirstPath = bearing > DUE_SOUTHEAST && bearing < DUE_NORTHWEST; - break; - case NORTHWEST: - selectFirstPath = bearing >= DUE_SOUTHWEST || bearing <= DUE_NORTHEAST; - break; - case SOUTHEAST: - selectFirstPath = bearing > DUE_NORTHEAST && bearing < DUE_SOUTHWEST; - break; - default: - break; - } - } - - - return selectFirstPath - ? Collections.singletonList(lineStrings.get(0)) - : Collections.singletonList(lineStrings.get(lineStrings.size() - 1)); - } - //endregion //region Road Name Helpers diff --git a/web-bundle/src/main/java/com/graphhopper/util/DirectionFilterHelper.java b/web-bundle/src/main/java/com/graphhopper/util/DirectionFilterHelper.java new file mode 100644 index 00000000000..eb80ddfef63 --- /dev/null +++ b/web-bundle/src/main/java/com/graphhopper/util/DirectionFilterHelper.java @@ -0,0 +1,161 @@ +package com.graphhopper.util; + +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Point; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; + +/** + * Helper class for filtering paths based on cardinal and intercardinal directions. + */ +public class DirectionFilterHelper { + + private static final int BEARING_DUE_NORTHEAST = 45; + private static final int BEARING_DUE_SOUTHEAST = 135; + private static final int BEARING_DUE_SOUTHWEST = 225; + private static final int BEARING_DUE_NORTHWEST = 315; + + public enum Direction { + UNKNOWN, + NORTH, + SOUTH, + EAST, + WEST, + NORTHWEST, + NORTHEAST, + SOUTHWEST, + SOUTHEAST, + BOTH + } + + private static final EnumSet CARDINAL_DIRECTIONS = EnumSet.of( + Direction.NORTH, + Direction.SOUTH, + Direction.EAST, + Direction.WEST + ); + + /** + * Filters a list of 2 LineStrings based on the specified cardinal or intercardinal direction. + * Compares the furthest points of the first and last LineStrings to determine which aligns + * best with the desired direction, returning only the selected LineString. + * @param lineStrings list of 2 LineStrings to filter + * @param buildUpstream whether the paths were built up or downstream + * @param direction the direction to filter by + * @return A list of LineStrings filtered by the specified direction. + */ + public List filterPathsByDirection(List lineStrings, Boolean buildUpstream, Direction direction) { + if (lineStrings == null || lineStrings.isEmpty() || lineStrings.size() == 1) { + return lineStrings != null ? lineStrings : Collections.emptyList(); + } + + if (direction == Direction.BOTH || direction == Direction.UNKNOWN) { + return lineStrings; + } + + Point furthestPointOfFirstPath = getTerminalPoint(lineStrings.get(0), buildUpstream); + Point furthestPointOfSecondPath = getTerminalPoint(lineStrings.get(lineStrings.size() - 1), buildUpstream); + + boolean selectFirstPath = true; + boolean isNonCardinal = !CARDINAL_DIRECTIONS.contains(direction); + + if (isNonCardinal) { + // For non-cardinal directions: Calculate bearing between terminal points for better direction accuracy. + // Since selectFirstPath defaults to true, we only need to adjust if the bearing is 'clearly the other direction.' + // As in the cardinal directions, if the bearing is within 30 degrees of perpendicular to the desired direction, + // then leave selectFirstPath = true. + int bearing = (int) Math.round(AngleCalc.ANGLE_CALC.calcAzimuth( + furthestPointOfFirstPath.getY(), furthestPointOfFirstPath.getX(), + furthestPointOfSecondPath.getY(),furthestPointOfSecondPath.getX())); + + // compute target bearing for the requested intercardinal direction + int targetBearing = switch (direction) { + case NORTHEAST -> buildUpstream ? BEARING_DUE_NORTHEAST : BEARING_DUE_SOUTHWEST; + case SOUTHEAST -> buildUpstream ? BEARING_DUE_SOUTHEAST : BEARING_DUE_NORTHWEST; + case SOUTHWEST -> buildUpstream ? BEARING_DUE_SOUTHWEST : BEARING_DUE_NORTHEAST; + case NORTHWEST -> buildUpstream ? BEARING_DUE_NORTHWEST : BEARING_DUE_SOUTHEAST; + default -> 0; + }; + + // convert difference to range 0-180 (for instance, if two bearings are a difference of 190 then use 170 since going the other way is shorter) + int diff = Math.abs(bearing - targetBearing); + if (diff > 180) diff = 360 - diff; + + // more than 120 degrees off -> selectFirstPath = false + selectFirstPath = diff <= 120; + } + else { + // Calculate the total separation between the two terminal points + double totalSeparation = Math.sqrt( + Math.pow(furthestPointOfFirstPath.getX() - furthestPointOfSecondPath.getX(), 2) + + Math.pow(furthestPointOfFirstPath.getY() - furthestPointOfSecondPath.getY(), 2) + ); + double halfSeparation = totalSeparation / 2; + + switch (direction) { + case NORTH: + case SOUTH: + // Default to the first path if Y difference < half of total separation (paths too horizontal for N/S determination) + double yDiff = Math.abs(furthestPointOfFirstPath.getY() - furthestPointOfSecondPath.getY()); + if (yDiff < halfSeparation) break; + + selectFirstPath = compareCoordinates( + furthestPointOfFirstPath.getY(), + furthestPointOfSecondPath.getY(), + direction == Direction.NORTH, + buildUpstream + ); + break; + case EAST: + case WEST: + // Default to the first path if X difference < half of total separation (paths too vertical for E/W determination) + double xDiff = Math.abs(furthestPointOfFirstPath.getX() - furthestPointOfSecondPath.getX()); + if (xDiff < halfSeparation) break; + + selectFirstPath = compareCoordinates( + furthestPointOfFirstPath.getX(), + furthestPointOfSecondPath.getX(), + direction == Direction.EAST, + buildUpstream + ); + break; + default: + break; + } + } + + return selectFirstPath + ? Collections.singletonList(lineStrings.get(0)) + : Collections.singletonList(lineStrings.get(lineStrings.size() - 1)); + } + + /** + * Retrieves the terminal point of a LineString based on the build direction. + * + * @param lineString the LineString to extract the terminal point from + * @param buildUpstream if true, returns the start point; if false, returns the end point + * @return the terminal Point of the LineString + */ + private Point getTerminalPoint(LineString lineString, boolean buildUpstream) { + return buildUpstream ? lineString.getStartPoint() : lineString.getEndPoint(); + } + + /** + * Compares coordinate values to determine path selection based on the direction. + * + * @param firstCoordinate coordinate value from the first path (X for E/W, Y for N/S) + * @param secondCoordinate coordinate value from the second path + * @param isPositiveDirection true for NORTH/EAST, false for SOUTH/WEST + * @param buildUpstream whether paths were built upstream + * @return true to select the first path, false to select the second path + */ + private boolean compareCoordinates(double firstCoordinate, double secondCoordinate, boolean isPositiveDirection, boolean buildUpstream) { + if (isPositiveDirection) { + return buildUpstream ? firstCoordinate < secondCoordinate : firstCoordinate > secondCoordinate; + } else { + return buildUpstream ? firstCoordinate > secondCoordinate : firstCoordinate < secondCoordinate; + } + } +} diff --git a/web-bundle/src/main/java/com/graphhopper/util/DirectionFilterHelper_README.md b/web-bundle/src/main/java/com/graphhopper/util/DirectionFilterHelper_README.md new file mode 100644 index 00000000000..40c08916497 --- /dev/null +++ b/web-bundle/src/main/java/com/graphhopper/util/DirectionFilterHelper_README.md @@ -0,0 +1,108 @@ +# DirectionFilterHelper Data Flow + +```mermaid +flowchart TD + Start([filterPathsByDirection]) --> CheckNull{lineStrings null
or empty
or size == 1?} + CheckNull -->|Yes| ReturnInput[Return lineStrings
or empty list] + CheckNull -->|No| CheckDirection{direction ==
BOTH or UNKNOWN?} + + CheckDirection -->|Yes| ReturnAll[Return all lineStrings] + CheckDirection -->|No| GetPoints[Get terminal points:
- furthestPointOfFirstPath
- furthestPointOfSecondPath] + + GetPoints --> CalcSeparation[Calculate total separation
and half separation
using Pythagorean theorem] + + CalcSeparation --> InitSelect[Initialize:
selectFirstPath = true
isNonCardinal = check direction] + + InitSelect --> SwitchDirection{Switch on
direction type} + + SwitchDirection -->|NORTH or SOUTH| CheckYDiff{Y difference <
half separation?} + CheckYDiff -->|Yes| CheckNonCardinal + CheckYDiff -->|No| CompareY[compareCoordinates on Y
Update selectFirstPath] + CompareY --> CheckNonCardinal + + SwitchDirection -->|EAST or WEST| CheckXDiff{X difference <
half separation?} + CheckXDiff -->|Yes| CheckNonCardinal + CheckXDiff -->|No| CompareX[compareCoordinates on X
Update selectFirstPath] + CompareX --> CheckNonCardinal + + SwitchDirection -->|Other| CheckNonCardinal{Is non-cardinal
direction?} + + CheckNonCardinal -->|No| ReturnSelected + CheckNonCardinal -->|Yes| CalcBearing[Calculate bearing
using AngleCalc.calcAzimuth] + + CalcBearing --> DetermineTarget[Determine target bearing
based on direction
and buildUpstream] + + DetermineTarget --> CalcDiff[Calculate angular difference
normalize to 0-180°] + + CalcDiff --> CheckBearing{Difference > 120°?} + CheckBearing -->|Yes| SetFalse[selectFirstPath = false] + CheckBearing -->|No| KeepTrue[selectFirstPath stays true] + + SetFalse --> ReturnSelected[Return selected path
based on selectFirstPath] + KeepTrue --> ReturnSelected + + ReturnSelected --> End([End]) + ReturnInput --> End + ReturnAll --> End + + style Start fill:#e1f5ff + style End fill:#e1f5ff + style ReturnSelected fill:#c8e6c9 + style ReturnInput fill:#c8e6c9 + style ReturnAll fill:#c8e6c9 + style CheckNull fill:#fff9c4 + style CheckDirection fill:#fff9c4 + style CheckNonCardinal fill:#fff9c4 + style CheckYDiff fill:#fff9c4 + style CheckXDiff fill:#fff9c4 + style CheckBearing fill:#fff9c4 + style SwitchDirection fill:#ffe0b2 +``` + +## Flow Description + +The diagram illustrates the data flow through the `DirectionFilterHelper.filterPathsByDirection()` method: + +1. **Entry point**: `filterPathsByDirection` method receives lineStrings, buildUpstream flag, and direction +2. **Early exits**: Returns immediately for null/empty lists or BOTH/UNKNOWN directions +3. **Cardinal direction handling**: Uses coordinate comparison for N/S/E/W with separation checks +4. **Non-cardinal direction handling**: Uses bearing calculations for NE/SE/SW/NW +5. **Decision logic**: The `selectFirstPath` boolean determines which path is returned +6. **Helper methods**: `getTerminalPoint` and `compareCoordinates` are invoked within the flow + +## Pythagorean Theorem in Distance Calculation + +The algorithm uses the Pythagorean theorem to calculate the total separation between the terminal points of two paths: + +``` + Point 2 (x₂, y₂) + ● + /| + / | + / | + totalSep / | yDiff = |y₂ - y₁| + / | + / | + / | + / | + / | + ●---------+ + Point 1 (x₁, y₁) + + xDiff = |x₂ - x₁| + + + totalSeparation = √(xDiff² + yDiff²) + halfSeparation = totalSeparation / 2 +``` + +**Key Concepts:** +- **Point 1**: Terminal point of first LineString (`furthestPointOfFirstPath`) +- **Point 2**: Terminal point of last LineString (`furthestPointOfSecondPath`) +- **xDiff**: Horizontal distance between points = `|x₂ - x₁|` +- **yDiff**: Vertical distance between points = `|y₂ - y₁|` +- **totalSeparation**: Euclidean distance using `√((x₂ - x₁)² + (y₂ - y₁)²)` +- **halfSeparation**: Used as threshold to determine if paths are divergent enough for directional filtering + +**Example Use Case:** +For NORTH/SOUTH filtering, if `yDiff < halfSeparation`, the paths are too horizontal (not divergent enough in the Y direction) to make a reliable directional determination, so the algorithm defaults to returning the first path. diff --git a/web-bundle/src/test/java/com/graphhopper/util/DirectionFilterHelperTest.java b/web-bundle/src/test/java/com/graphhopper/util/DirectionFilterHelperTest.java new file mode 100644 index 00000000000..ad048791e84 --- /dev/null +++ b/web-bundle/src/test/java/com/graphhopper/util/DirectionFilterHelperTest.java @@ -0,0 +1,362 @@ +package com.graphhopper.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class DirectionFilterHelperTest { + + private DirectionFilterHelper helper; + private GeometryFactory geometryFactory; + + @BeforeEach + void setUp() { + helper = new DirectionFilterHelper(); + geometryFactory = new GeometryFactory(); + } + + @Test + void testFilterPathsByDirection_NullInput() { + List result = helper.filterPathsByDirection(null, true, DirectionFilterHelper.Direction.NORTH); + assertTrue(result.isEmpty()); + } + + @Test + void testFilterPathsByDirection_EmptyList() { + List result = helper.filterPathsByDirection(Collections.emptyList(), true, DirectionFilterHelper.Direction.NORTH); + assertTrue(result.isEmpty()); + } + + @Test + void testFilterPathsByDirection_SinglePath() { + LineString line = createLineString(0, 0, 0, 1); + List paths = Collections.singletonList(line); + + List result = helper.filterPathsByDirection(paths, true, DirectionFilterHelper.Direction.NORTH); + + assertEquals(1, result.size()); + assertEquals(line, result.get(0)); + } + + @Test + void testFilterPathsByDirection_BothDirection() { + LineString northPath = createLineString(0, 0, 0, 2); + LineString southPath = createLineString(0, 0, 0, -2); + List paths = Arrays.asList(northPath, southPath); + + List result = helper.filterPathsByDirection(paths, true, DirectionFilterHelper.Direction.BOTH); + + assertEquals(2, result.size()); + } + + @Test + void testFilterPathsByDirection_UnknownDirection() { + LineString northPath = createLineString(0, 0, 0, 2); + LineString southPath = createLineString(0, 0, 0, -2); + List paths = Arrays.asList(northPath, southPath); + + List result = helper.filterPathsByDirection(paths, true, DirectionFilterHelper.Direction.UNKNOWN); + + assertEquals(2, result.size()); + } + + @ParameterizedTest + @CsvSource({ + "NORTH, 0, -2, 0, 0, 0, 2, 0, 0, true", + "SOUTH, 0, 2, 0, 0, 0, -2, 0, 0, true", + "EAST, -2, 0, 0, 0, 2, 0, 0, 0, true", + "WEST, 2, 0, 0, 0, -2, 0, 0, 0, true" + }) + void testFilterPathsByDirection_CardinalDirections_Upstream( + DirectionFilterHelper.Direction direction, + double x1Path1, double y1Path1, double x2Path1, double y2Path1, + double x1Path2, double y1Path2, double x2Path2, double y2Path2, + boolean expectFirstPath) { + + LineString path1 = createLineString(x1Path1, y1Path1, x2Path1, y2Path1); + LineString path2 = createLineString(x1Path2, y1Path2, x2Path2, y2Path2); + List paths = Arrays.asList(path1, path2); + + List result = helper.filterPathsByDirection(paths, true, direction); + + assertSinglePathSelected(result, expectFirstPath ? path1 : path2); + } + + @ParameterizedTest + @CsvSource({ + "NORTH, 0, 0, 0, 2, 0, 0, 0, -2, true", + "SOUTH, 0, 0, 0, 2, 0, 0, 0, -2, false", + "EAST, 0, 0, 2, 0, 0, 0, -2, 0, true", + "WEST, 0, 0, 2, 0, 0, 0, -2, 0, false" + }) + void testFilterPathsByDirection_CardinalDirections_Downstream( + DirectionFilterHelper.Direction direction, + double x1Path1, double y1Path1, double x2Path1, double y2Path1, + double x1Path2, double y1Path2, double x2Path2, double y2Path2, + boolean expectFirstPath) { + + LineString path1 = createLineString(x1Path1, y1Path1, x2Path1, y2Path1); + LineString path2 = createLineString(x1Path2, y1Path2, x2Path2, y2Path2); + List paths = Arrays.asList(path1, path2); + + List result = helper.filterPathsByDirection(paths, false, direction); + + assertSinglePathSelected(result, expectFirstPath ? path1 : path2); + } + + @ParameterizedTest + @CsvSource({ + "NORTHEAST, 0, 0, 1, 1, 0, 0, -1, -1, true", + "SOUTHWEST, 0, 0, 1, 1, 0, 0, -1, -1, false", + "NORTHWEST, 0, 0, -1, 1, -1, 1, -2, 2, true", + "SOUTHEAST, 0, 0, 1, -1, 1, -1, 2, -2, true" + }) + void testFilterPathsByDirection_IntercardinalDirections_Upstream( + DirectionFilterHelper.Direction direction, + double x1Path1, double y1Path1, double x2Path1, double y2Path1, + double x1Path2, double y1Path2, double x2Path2, double y2Path2, + boolean expectFirstPath) { + + LineString path1 = createLineString(x1Path1, y1Path1, x2Path1, y2Path1); + LineString path2 = createLineString(x1Path2, y1Path2, x2Path2, y2Path2); + List paths = Arrays.asList(path1, path2); + + List result = helper.filterPathsByDirection(paths, true, direction); + + assertSinglePathSelected(result, expectFirstPath ? path1 : path2); + } + + @ParameterizedTest + @CsvSource({ + "EAST, 0, 0.0001, 0, 0, 0, -0.0001, 0, 0", + "NORTH, 0.0001, 0, 0, 0, -0.0001, 0, 0, 0" + }) + void testFilterPathsByDirection_BelowThreshold_DefaultsToFirstPath( + DirectionFilterHelper.Direction direction, + double x1Path1, double y1Path1, double x2Path1, double y2Path1, + double x1Path2, double y1Path2, double x2Path2, double y2Path2) { + + LineString path1 = createLineString(x1Path1, y1Path1, x2Path1, y2Path1); + LineString path2 = createLineString(x1Path2, y1Path2, x2Path2, y2Path2); + List paths = Arrays.asList(path1, path2); + + List result = helper.filterPathsByDirection(paths, true, direction); + + assertSinglePathSelected(result, path1); + } + + @Test + void testFilterPathsByDirection_ComprehensiveDirectionTest() { + DirectionFilterHelper.Direction[] cardinals = { + DirectionFilterHelper.Direction.NORTH, + DirectionFilterHelper.Direction.EAST, + DirectionFilterHelper.Direction.SOUTH, + DirectionFilterHelper.Direction.WEST + }; + DirectionFilterHelper.Direction[] intercardinals = { + DirectionFilterHelper.Direction.NORTHEAST, + DirectionFilterHelper.Direction.SOUTHEAST, + DirectionFilterHelper.Direction.SOUTHWEST, + DirectionFilterHelper.Direction.NORTHWEST + }; + + System.out.println("Starting comprehensive direction filtering test..."); + + // Test with buildUpstream = true + System.out.println("\n=== Testing with buildUpstream = true ==="); + runDirectionTest(cardinals, intercardinals, true); + + // Test with buildUpstream = false + System.out.println("\n=== Testing with buildUpstream = false ==="); + runDirectionTest(cardinals, intercardinals, false); + + System.out.println("\nComprehensive direction filtering test completed."); + } + + // Bug 17110 Regression Test: Road on the incorrect side of the road + // as a result of the path section not going in the specified direction. + // Test: Ensures that the correct side of the road is selected when filtering by direction. + @Test + void testFilterPathsByDirection_RegressionTest_Bug17110_RoadSectionNotInDirection() throws IOException { + List paths = loadLineStringsFromGeoJSON("test-examples/regression-direction-filter-test.json"); + + List resultPath = helper.filterPathsByDirection(paths, true, DirectionFilterHelper.Direction.WEST); + assertSinglePathSelected(resultPath, paths.get(0)); + } + + // Helper methods + + /** + * Asserts that exactly one path was selected and it matches the expected path + */ + private void assertSinglePathSelected(List result, LineString expectedPath) { + assertEquals(1, result.size(), "Expected exactly one path to be selected"); + assertEquals(expectedPath, result.get(0), "Expected specific path to be selected"); + } + + /** + * Creates a simple two-point LineString from start to end coordinates + */ + private LineString createLineString(double x1, double y1, double x2, double y2) { + Coordinate[] coords = new Coordinate[]{ + new Coordinate(x1, y1), + new Coordinate(x2, y2) + }; + return geometryFactory.createLineString(coords); + } + + /** + * Loads LineString geometries from a GeoJSON FeatureCollection file + * @param fileName name of the GeoJSON file in test resources + * @return list of LineStrings from the file, or empty list if the file not found + */ + private List loadLineStringsFromGeoJSON(String fileName) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + List lineStrings = new ArrayList<>(); + + try (InputStream is = getClass().getClassLoader().getResourceAsStream(fileName)) { + assertNotNull(is, "Test resource not found: " + fileName); + + JsonNode root = mapper.readTree(is); + JsonNode features = root.get("features"); + + if (features != null && features.isArray()) { + for (JsonNode feature : features) { + JsonNode geometry = feature.get("geometry"); + if (geometry != null && "LineString".equals(geometry.get("type").asText())) { + JsonNode coordinates = geometry.get("coordinates"); + Coordinate[] coords = new Coordinate[coordinates.size()]; + + for (int i = 0; i < coordinates.size(); i++) { + JsonNode coord = coordinates.get(i); + coords[i] = new Coordinate(coord.get(0).asDouble(), coord.get(1).asDouble()); + } + + lineStrings.add(geometryFactory.createLineString(coords)); + } + } + } + } + + return lineStrings; + } + + private void runDirectionTest(DirectionFilterHelper.Direction[] cardinals, + DirectionFilterHelper.Direction[] intercardinals, + boolean buildUpstream) { + + int unexpectedResults = 0; + int totalTests = 0; + + // Iterate through first 6 test coordinate arrays only + for (int arrayIndex = 0; arrayIndex < Math.min(6000, DirectionFilterTestData.getTestCoordinateArrayCount()); arrayIndex++) { + double[][] coords = DirectionFilterTestData.getTestCoordinateArray(arrayIndex); + + // Extract points A, B, C, D, F + Coordinate A = new Coordinate(coords[0][0], coords[0][1]); + Coordinate B = new Coordinate(coords[1][0], coords[1][1]); + Coordinate C = new Coordinate(coords[2][0], coords[2][1]); + Coordinate D = new Coordinate(coords[3][0], coords[3][1]); + Coordinate F = new Coordinate(coords[4][0], coords[4][1]); + + // Cycle through direction indexes + for (int directionIndex = 0; directionIndex < cardinals.length; directionIndex++) { + DirectionFilterHelper.Direction cardinal = cardinals[directionIndex]; + DirectionFilterHelper.Direction intercardinal = intercardinals[directionIndex]; + + // Create LineStrings based on buildUpstream parameter + LineString line1, line2, line3, line4; + if (buildUpstream) { + line1 = createLineStringFromCoords(A, B); // AB + line2 = createLineStringFromCoords(C, B); // CB + line3 = createLineStringFromCoords(D, B); // DB + line4 = createLineStringFromCoords(F, B); // FB + } else { + line1 = createLineStringFromCoords(B, A); // BA + line2 = createLineStringFromCoords(B, C); // BC + line3 = createLineStringFromCoords(B, D); // BD + line4 = createLineStringFromCoords(B, F); // BF + } + + // First call: filterPathsByDirection with cardinal direction + List cardinalPaths = Arrays.asList(line1, line2); + List cardinalResult = helper.filterPathsByDirection( + cardinalPaths, buildUpstream, cardinal); + + // Second call: filterPathsByDirection with intercardinal direction + List intercardinalPaths = Arrays.asList(line3, line4); + List intercardinalResult = helper.filterPathsByDirection( + intercardinalPaths, buildUpstream, intercardinal); + + totalTests++; + + // Check if results meet expected criteria + boolean isExpected = false; + String expectedPattern = ""; + + if (cardinalResult.size() == 1 && intercardinalResult.size() == 1) { + LineString cardinalSelected = cardinalResult.get(0); + LineString intercardinalSelected = intercardinalResult.get(0); + + // Check for expected patterns + if ((cardinalSelected.equals(line1) && intercardinalSelected.equals(line3)) || + (cardinalSelected.equals(line2) && intercardinalSelected.equals(line4))) { + isExpected = true; + expectedPattern = cardinalSelected.equals(line1) ? "Pattern 1 (AB->DB)" : "Pattern 2 (CB->FB)"; + if (!buildUpstream) { + expectedPattern = cardinalSelected.equals(line1) ? "Pattern 1 (BA->BD)" : "Pattern 2 (BC->BF)"; + } + } + } + + if (!isExpected) { + unexpectedResults++; + System.out.printf( + "UNEXPECTED RESULT - Array %d, Direction Index %d (%s/%s), buildUpstream=%b:%n", + arrayIndex, directionIndex, cardinal, intercardinal, buildUpstream); + System.out.printf(" Points: A=%.2f,%.2f B=%.2f,%.2f C=%.2f,%.2f D=%.2f,%.2f F=%.2f,%.2f%n", + A.x, A.y, B.x, B.y, C.x, C.y, D.x, D.y, F.x, F.y); + System.out.printf(" Cardinal (%s) selected %d paths: %s%n", + cardinal, cardinalResult.size(), + cardinalResult.isEmpty() ? "none" : + (cardinalResult.get(0).equals(line1) ? (buildUpstream ? "AB" : "BA") : (buildUpstream ? "CB" : "BC"))); + System.out.printf(" Intercardinal (%s) selected %d paths: %s%n", + intercardinal, intercardinalResult.size(), + intercardinalResult.isEmpty() ? "none" : + (intercardinalResult.get(0).equals(line3) ? (buildUpstream ? "DB" : "BD") : (buildUpstream ? "FB" : "BF"))); + } else { + System.out.printf( + "Expected result - Array %d, Direction Index %d: %s%n", + arrayIndex, directionIndex, expectedPattern); + } + } + } + + System.out.printf( + "\nTest Summary (buildUpstream=%b): %d/%d tests met expected criteria (%d unexpected)%n", + buildUpstream, totalTests - unexpectedResults, totalTests, unexpectedResults); + } + + /** + * Creates a LineString from two Coordinate objects + */ + private LineString createLineStringFromCoords(Coordinate start, Coordinate end) { + Coordinate[] coords = new Coordinate[]{start, end}; + return geometryFactory.createLineString(coords); + } +} diff --git a/web-bundle/src/test/java/com/graphhopper/util/DirectionFilterTestData.java b/web-bundle/src/test/java/com/graphhopper/util/DirectionFilterTestData.java new file mode 100644 index 00000000000..d35098078a1 --- /dev/null +++ b/web-bundle/src/test/java/com/graphhopper/util/DirectionFilterTestData.java @@ -0,0 +1,84 @@ +package com.graphhopper.util; + +/** + * Test data for DirectionFilterHelper unit tests. + * Contains arrays of coordinate pairs for testing various direction filtering scenarios. + */ +public class DirectionFilterTestData { + + /** + * Test coordinate arrays for direction filtering tests. + * Each inner array contains coordinate pairs [x, y] representing different path scenarios. + */ + public static final double[][][] TEST_COORDINATE_ARRAYS = { + { {-6.96, -11.37}, {-7.82, -7.46}, {-8.69, -3.55}, {-9.98, -10.83}, {-5.67, -4.08} }, + { {15.12, 11.27}, {10.81, 13.00}, {6.50, 14.73}, {12.63, 8.73}, {8.99, 17.27} }, + { {13.18, -19.11}, {13.80, -18.97}, {14.42, -18.84}, {13.26, -18.63}, {14.33, -19.31} }, + { {22.38, -17.97}, {19.40, -14.46}, {16.41, -10.95}, {19.03, -19.05}, {19.77, -9.86} }, + { {10.26, 11.38}, {15.24, 3.29}, {20.21, -4.81}, {17.44, 12.53}, {13.03, -5.95} }, + { {-3.50, -6.82}, {-3.68, -6.76}, {-3.86, -6.71}, {-3.59, -6.93}, {-3.77, -6.6} }, + { {7.41, 13.36}, {14.62, 1.40}, {21.84, -10.57}, {17.98, 14.96}, {11.26, -12.17} }, + { {5.47, 14.85}, {12.58, 13.10}, {19.69, 11.36}, {8.79, 19.37}, {16.38, 6.84} }, + { {4.10, -1.58}, {1.87, -6.38}, {-0.36, -11.18}, {6.84, -4.56}, {-3.1, -8.2} }, + { {0.60, 3.41}, {8.43, 4.66}, {16.25, 5.92}, {2.01, 9.31}, {14.85, 0.02} }, + { {9.92, 13.31}, {12.77, 17.86}, {15.61, 22.41}, {7.54, 16.66}, {18, 19.07} }, + { {-14.63, 9.36}, {-14.04, 6.86}, {-13.45, 4.35}, {-12.69, 9.05}, {-15.4, 4.67} }, + { {0.03, -2.99}, {-11.11, -2.57}, {-22.25, -2.15}, {-3.53, -10.74}, {-18.69, 5.6} }, + { {-14.65, -4.69}, {-1.88, -3.04}, {10.90, -1.39}, {-12.08, 4.82}, {8.33, -10.91} }, + { {-8.66, 5.31}, {-9.26, 6.32}, {-9.85, 7.32}, {-9.55, 5.18}, {-8.97, 7.45} }, + { {8.45, -4.29}, {12.93, -1.59}, {17.41, 1.11}, {7.85, -0.33}, {18.01, -2.85} }, + { {16.52, -9.50}, {10.28, 4.63}, {4.04, 18.76}, {4.7, -9.77}, {15.86, 19.03} }, + { {8.67, 9.24}, {6.61, 14.32}, {4.56, 19.40}, {4.47, 9.27}, {8.75, 19.36} }, + { {-1.48, 9.34}, {-2.24, -6.44}, {-2.99, -22.21}, {9.45, 4.18}, {-13.92, -17.06} }, + { {-3.28, 9.45}, {-4.40, 4.04}, {-5.52, -1.38}, {0.22, 7.07}, {-9.02, 1} }, + { {-12.00, -2.76}, {4.25, 2.30}, {20.51, 7.36}, {-10.82, 10.21}, {19.32, -5.62} }, + { {3.17, 6.95}, {1.90, 13.95}, {0.63, 20.95}, {-2.15, 8.1}, {5.95, 19.8} }, + { {3.15, 22.08}, {-2.37, 5.40}, {-7.89, -11.28}, {13.33, 13.29}, {-18.07, -2.49} }, + { {6.95, -9.84}, {-1.44, -3.94}, {-9.84, 1.97}, {0.32, -14.05}, {-3.2, 6.18} }, + { {-19.74, -15.72}, {-2.64, -8.46}, {14.47, -1.21}, {-19.87, -1.5}, {14.59, -15.43} }, + { {-5.87, 5.44}, {1.36, 15.88}, {8.59, 26.33}, {-11.14, 13.61}, {13.86, 18.16} }, + { {-4.37, -15.50}, {-0.94, -2.10}, {2.50, 11.29}, {-12.84, -9.15}, {10.96, 4.94} }, + { {6.32, -20.01}, {7.76, -14.64}, {9.20, -9.25}, {2.94, -17.42}, {12.59, -11.85} }, + { {-0.10, -5.46}, {3.62, -5.80}, {7.33, -6.15}, {1.23, -2.93}, {6, -8.67} }, + { {-6.81, -9.94}, {-6.46, -13.66}, {-6.11, -17.37}, {-4.08, -10.78}, {-8.83, -16.53} }, + { {-1.26, 22.53}, {-0.62, 4.49}, {0.03, -13.54}, {11.68, 17.7}, {-12.91, -8.72} }, + { {-14.96, 7.41}, {-6.84, 9.49}, {1.28, 11.58}, {-14.05, 13.76}, {0.38, 5.22} }, + { {20.72, 6.53}, {11.24, 4.44}, {1.76, 2.34}, {19.42, -0.79}, {3.05, 9.66} }, + { {-9.14, -16.92}, {-5.02, -10.02}, {-0.91, -3.11}, {-12.81, -11.99}, {2.77, -8.04} }, + { {-13.87, -12.70}, {-3.28, -6.22}, {7.31, 0.28}, {-15.35, -3.32}, {8.8, -9.11} }, + { {-19.19, 7.11}, {-11.12, -2.81}, {-3.05, -12.73}, {-9.81, 9.91}, {-12.43, -15.53} }, + { {14.46, 6.82}, {17.58, 2.40}, {20.70, -2.01}, {18.5, 7.73}, {16.67, -2.92} }, + { {2.92, -12.83}, {3.36, 5.44}, {3.80, 23.73}, {-9.87, -7.17}, {16.6, 18.06} }, + { {-2.56, -8.48}, {-3.43, -15.36}, {-4.31, -22.23}, {2.05, -11.11}, {-8.91, -19.6} }, + { {13.89, 15.16}, {17.14, 15.55}, {20.38, 15.93}, {14.57, 17.57}, {19.7, 13.53} }, + { {16.38, -4.85}, {15.60, -7.24}, {14.82, -9.63}, {17.84, -6.1}, {13.36, -8.38} }, + { {-4.08, 6.55}, {0.89, -4.64}, {5.85, -15.83}, {5.29, 6.79}, {-3.51, -16.06} }, + { {-14.87, 3.86}, {-4.77, 14.36}, {5.33, 24.86}, {-19.34, 14.08}, {9.8, 14.64} }, + { {20.57, -1.87}, {12.51, -2.22}, {4.46, -2.58}, {18.46, -7.67}, {6.56, 3.22} }, + { {-8.76, 3.88}, {-5.4, -5.84}, {-2.05, -15.56}, {-0.9, 3.41}, {-9.9, -15.08} }, + { {-13.60, -4.35}, {-15.76, -7.16}, {-17.92, -9.97}, {-12.25, -6.7}, {-19.27, -7.62} } + }; + + /** + * Gets a specific test coordinate array by index. + * + * @param index the index of the coordinate array to retrieve (0-46) + * @return the coordinate array at the specified index + * @throws IndexOutOfBoundsException if index is out of range + */ + public static double[][] getTestCoordinateArray(int index) { + if (index < 0 || index >= TEST_COORDINATE_ARRAYS.length) { + throw new IndexOutOfBoundsException("Index " + index + " is out of range. Valid range: 0-" + (TEST_COORDINATE_ARRAYS.length - 1)); + } + return TEST_COORDINATE_ARRAYS[index]; + } + + /** + * Gets the total number of test coordinate arrays available. + * + * @return the number of test coordinate arrays + */ + public static int getTestCoordinateArrayCount() { + return TEST_COORDINATE_ARRAYS.length; + } +} diff --git a/web-bundle/src/test/resources/test-examples/regression-direction-filter-test.json b/web-bundle/src/test/resources/test-examples/regression-direction-filter-test.json new file mode 100644 index 00000000000..40fb44309d2 --- /dev/null +++ b/web-bundle/src/test/resources/test-examples/regression-direction-filter-test.json @@ -0,0 +1,389 @@ +{ + "type": "FeatureCollection", + "copyrights": [ + "GraphHopper", + "OpenStreetMap contributors" + ], + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -120.026901, + 39.4057405 + ], + [ + -120.0268054, + 39.4054703 + ], + [ + -120.0267573, + 39.4053585 + ], + [ + -120.0266296, + 39.4051107 + ], + [ + -120.0262936, + 39.4046472 + ], + [ + -120.0260926, + 39.4044328 + ], + [ + -120.0259872, + 39.4043338 + ], + [ + -120.0257489, + 39.4041353 + ], + [ + -120.0255004, + 39.4039615 + ], + [ + -120.0253024, + 39.4038389 + ], + [ + -120.0242178, + 39.4032076 + ], + [ + -120.0238795, + 39.4030038 + ], + [ + -120.0236234, + 39.4028312 + ], + [ + -120.0233882, + 39.4026447 + ], + [ + -120.0232785, + 39.4025456 + ], + [ + -120.0230715, + 39.4023332 + ], + [ + -120.0229752, + 39.4022224 + ], + [ + -120.0228048, + 39.401992 + ], + [ + -120.0226551, + 39.4017512 + ], + [ + -120.0225325, + 39.401499 + ], + [ + -120.0224371, + 39.4012415 + ], + [ + -120.0220987, + 39.3999354 + ], + [ + -120.0220541, + 39.3996711 + ], + [ + -120.022041, + 39.3995375 + ], + [ + -120.0220325, + 39.3992693 + ], + [ + -120.0220394, + 39.3991353 + ], + [ + -120.0220506, + 39.3990007 + ], + [ + -120.0220961, + 39.3987371 + ], + [ + -120.0221275, + 39.3986049 + ], + [ + -120.0222077, + 39.3983428 + ], + [ + -120.0223134, + 39.3980877 + ], + [ + -120.0223749, + 39.3979645 + ], + [ + -120.0225173, + 39.3977183 + ], + [ + -120.0226805, + 39.397483 + ], + [ + -120.0228656, + 39.3972578 + ], + [ + -120.0230711, + 39.3970433 + ], + [ + -120.0244791, + 39.3956981 + ], + [ + -120.0247731, + 39.3954052 + ], + [ + -120.0249915, + 39.3951467 + ], + [ + -120.0251566, + 39.394921 + ], + [ + -120.0253722, + 39.3945653 + ], + [ + -120.0255054, + 39.3942787 + ], + [ + -120.0255054, + 39.3942787 + ], + [ + -120.0256329, + 39.393929 + ], + [ + -120.0257268, + 39.3935363 + ], + [ + -120.0257631, + 39.3932767 + ], + [ + -120.0257726, + 39.3931439 + ], + [ + -120.0257698, + 39.3927464 + ], + [ + -120.0257401, + 39.3924828 + ], + [ + -120.025653, + 39.3920897 + ] + ] + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -120.0240007, + 39.3786665 + ], + [ + -120.0237514, + 39.3788452 + ], + [ + -120.0236299, + 39.378942 + ], + [ + -120.0234093, + 39.3791418 + ], + [ + -120.0232083, + 39.3793579 + ], + [ + -120.0231166, + 39.379469 + ], + [ + -120.0229517, + 39.3796989 + ], + [ + -120.0228082, + 39.3799401 + ], + [ + -120.0227476, + 39.3800632 + ], + [ + -120.0226428, + 39.3803145 + ], + [ + -120.0225992, + 39.3804443 + ], + [ + -120.0225341, + 39.3807034 + ], + [ + -120.0224954, + 39.3809674 + ], + [ + -120.0224857, + 39.3811004 + ], + [ + -120.0224869, + 39.3813654 + ], + [ + -120.0224985, + 39.3814966 + ], + [ + -120.0225397, + 39.3817609 + ], + [ + -120.0226497, + 39.3821498 + ], + [ + -120.0227947, + 39.3825323 + ], + [ + -120.0229111, + 39.3827808 + ], + [ + -120.0230421, + 39.3830219 + ], + [ + -120.0232719, + 39.3833822 + ], + [ + -120.0242092, + 39.3846575 + ], + [ + -120.0244495, + 39.3850044 + ], + [ + -120.0245871, + 39.3852442 + ], + [ + -120.0247021, + 39.3854918 + ], + [ + -120.0247953, + 39.3857459 + ], + [ + -120.0248673, + 39.3860011 + ], + [ + -120.0249131, + 39.3862625 + ], + [ + -120.0249365, + 39.3865235 + ], + [ + -120.0249368, + 39.3867872 + ], + [ + -120.0249268, + 39.3869198 + ], + [ + -120.0248915, + 39.3871783 + ], + [ + -120.0246389, + 39.3884865 + ], + [ + -120.0245997, + 39.3888836 + ], + [ + -120.0246039, + 39.3891494 + ], + [ + -120.0246309, + 39.3894066 + ], + [ + -120.0247174, + 39.3898054 + ], + [ + -120.0247982, + 39.3900626 + ], + [ + -120.0254575, + 39.391976 + ], + [ + -120.0255293, + 39.3922354 + ] + ] + } + } + ] +}