RouteResultBuilder.java

/*  This file is part of Openrouteservice.
 *
 *  Openrouteservice is free software; you can redistribute it and/or modify it under the terms of the
 *  GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1
 *  of the License, or (at your option) any later version.

 *  This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 *  without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 *  See the GNU Lesser General Public License for more details.

 *  You should have received a copy of the GNU Lesser General Public License along with this library;
 *  if not, see <https://www.gnu.org/licenses/>.
 */
package org.heigit.ors.routing;

import com.graphhopper.GHResponse;
import com.graphhopper.ResponsePath;
import com.graphhopper.Trip;
import com.graphhopper.util.*;
import org.heigit.ors.common.ArrivalDirection;
import org.heigit.ors.common.CardinalDirection;
import org.heigit.ors.common.DistanceUnit;
import org.heigit.ors.exceptions.InternalServerException;
import org.heigit.ors.routing.instructions.InstructionTranslator;
import org.heigit.ors.routing.instructions.InstructionTranslatorsCache;
import org.heigit.ors.routing.instructions.InstructionType;
import org.heigit.ors.util.DistanceUnitUtil;
import org.heigit.ors.util.FormatUtility;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.LineString;

import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;

import static org.heigit.ors.routing.RouteResult.*;

// visibilities needed until RouteResultBuilderTest is properly migrated
public class RouteResultBuilder {
    private final AngleCalc angleCalc;
    private final DistanceCalc distCalc;
    private static final CardinalDirection[] directions = {CardinalDirection.NORTH, CardinalDirection.NORTH_EAST, CardinalDirection.EAST, CardinalDirection.SOUTH_EAST, CardinalDirection.SOUTH, CardinalDirection.SOUTH_WEST, CardinalDirection.WEST, CardinalDirection.NORTH_WEST};
    private int startWayPointIndex = 0;

    public RouteResultBuilder() {
        angleCalc = new AngleCalc();
        distCalc = new DistanceCalcEarth();
    }

    RouteResult[] createRouteResults(List<GHResponse> responses, RoutingRequest request, List<RouteExtraInfo>[] extras) throws Exception {
        if (responses.isEmpty())
            throw new InternalServerException(RoutingErrorCodes.UNKNOWN, "Unable to find a route.");
        if (responses.size() > 1) { // request had multiple segments (route with via points)
            return createMergedRouteResultSetFromBestPaths(responses, request, extras);
        } else
            return createRouteResultSetFromMultiplePaths(responses.get(0), request, extras);
    }

    private RouteResult createInitialRouteResult(RoutingRequest request, List<RouteExtraInfo> extras) {
        RouteResult result = new RouteResult(request.getExtraInfo());

        result.addExtras(request, extras);

        if (request.getSkipSegments() != null && !request.getSkipSegments().isEmpty()) {
            result.addWarning(new RouteWarning(RouteWarning.SKIPPED_SEGMENTS));
        }

        startWayPointIndex = 0;

        if (request.getIncludeGeometry()) {
            result.addWayPointIndex(0);
        }
        return result;
    }

    public RouteResult createMergedRouteResultFromBestPaths(List<GHResponse> responses, RoutingRequest request, List<RouteExtraInfo>[] extras) throws Exception {
        RouteResult result = createInitialRouteResult(request, extras[0]);

        for (int ri = 0; ri < responses.size(); ++ri) {
            GHResponse response = responses.get(ri);
            if (response.hasErrors())
                throw new InternalServerException(RoutingErrorCodes.UNKNOWN, "Unable to find a route between points %d (%s) and %d (%s)".formatted(ri, FormatUtility.formatCoordinate(request.getCoordinates()[ri]), ri + 1, FormatUtility.formatCoordinate(request.getCoordinates()[ri + 1])));

            handleResponseWarnings(result, response);

            ResponsePath path = response.getBest();

            result.addPointlist(path.getPoints());

            if (request.getIncludeGeometry()) {
                result.addPointsToGeometry(path.getPoints(), ri > 0, request.getIncludeElevation());
                result.addWayPointIndex(result.getGeometry().length - 1);
            }

            result.addSegment(createRouteSegment(path, request, getNextResponseFirstStepPoints(responses, ri)));
            result.setGraphDate(response.getHints().getString("data.date", "0000-00-00T00:00:00Z"));
        }

        result.calculateRouteSummary(request);

        if (request.getSearchParameters().isTimeDependent()) {
            String timezoneDeparture = responses.get(0).getHints().getString(KEY_TIMEZONE_DEPARTURE, DEFAULT_TIMEZONE);
            String timezoneArrival = responses.get(responses.size() - 1).getHints().getString(KEY_TIMEZONE_ARRIVAL, DEFAULT_TIMEZONE);

            setDepartureArrivalTimes(timezoneDeparture, timezoneArrival, request, result);
        }

        if (!request.getIncludeInstructions()) {
            result.resetSegments();
        }

        return result;
    }

    private RouteResult[] createMergedRouteResultSetFromBestPaths(List<GHResponse> responses, RoutingRequest request, List<RouteExtraInfo>[] extras) throws Exception {
        return new RouteResult[]{createMergedRouteResultFromBestPaths(responses, request, extras)};
    }

    private RouteResult[] createRouteResultSetFromMultiplePaths(GHResponse response, RoutingRequest request, List<RouteExtraInfo>[] extras) throws Exception {
        if (response.hasErrors())
            throw new InternalServerException(RoutingErrorCodes.UNKNOWN, "Unable to find a route between points %d (%s) and %d (%s)".formatted(0, FormatUtility.formatCoordinate(request.getCoordinates()[0]), 1, FormatUtility.formatCoordinate(request.getCoordinates()[1])));

        RouteResult[] resultSet = new RouteResult[response.getAll().size()];

        int pathIndex = 0;
        for (ResponsePath path : response.getAll()) {
            List<RouteExtraInfo> extraList = extras.length == response.getAll().size() ? extras[pathIndex] : extras[0];
            RouteResult result = createInitialRouteResult(request, extraList);

            handleResponseWarnings(result, response);

            result.addPointlist(path.getPoints());
            if (request.getIncludeGeometry()) {
                result.addPointsToGeometry(path.getPoints(), false, request.getIncludeElevation());
                result.addWayPointIndex(result.getGeometry().length - 1);
            }

            result.addSegment(createRouteSegment(path, request, null));

            if (request.getSearchParameters().getProfileType() == RoutingProfileType.PUBLIC_TRANSPORT) {
                addLegsToRouteResult(result, request, path.getLegs(), response);
            }

            result.calculateRouteSummary(request, path);
            if (!request.getIncludeInstructions()) {
                result.resetSegments();
            }

            result.setGraphDate(response.getHints().getString("data.date", "0000-00-00T00:00:00Z"));
            resultSet[response.getAll().indexOf(path)] = result;

            if (request.getSearchParameters().isTimeDependent()) {
                String timezoneDeparture = response.getHints().getString(KEY_TIMEZONE_DEPARTURE, DEFAULT_TIMEZONE);
                String timezoneArrival = response.getHints().getString(KEY_TIMEZONE_ARRIVAL, DEFAULT_TIMEZONE);

                setDepartureArrivalTimes(timezoneDeparture, timezoneArrival, request, result);
            }

            pathIndex++;
        }

        return resultSet;
    }

    private void setDepartureArrivalTimes(String timezoneDeparture, String timezoneArrival, RoutingRequest request, RouteResult result) {
        ZonedDateTime departure;
        ZonedDateTime arrival;

        long duration = (long) result.getSummary().getDuration();
        if (request.getSearchParameters().hasDeparture()) {
            ZonedDateTime zonedDateTime = request.getSearchParameters().getDeparture().atZone(ZoneId.of(timezoneDeparture));
            departure = zonedDateTime;
            arrival = zonedDateTime.plusSeconds(duration);
        } else {
            ZonedDateTime zonedDateTime = request.getSearchParameters().getArrival().atZone(ZoneId.of(timezoneArrival));
            arrival = zonedDateTime;
            departure = zonedDateTime.minusSeconds(duration);
        }

        result.setDeparture(departure);
        result.setArrival(arrival);
    }

    private PointList getNextResponseFirstStepPoints(List<GHResponse> routes, int ri) {
        if (ri + 1 >= 0 && ri + 1 < routes.size()) {
            GHResponse resp = routes.get(ri + 1);
            InstructionList instructions = resp.getBest().getInstructions();
            if (!instructions.isEmpty())
                return instructions.get(0).getPoints();
        }
        return null;

    }

    private RouteSegment createRouteSegment(ResponsePath path, RoutingRequest request, PointList nextRouteFirstStepPoints) throws Exception {
        RouteSegment seg = new RouteSegment(path, request.getUnits());

        if (request.getIncludeInstructions()) {
            if (request.hasAttribute(RoutingRequest.ATTR_DETOURFACTOR)) {
                seg.setDetourFactor(FormatUtility.roundToDecimals(calculateDetourFactor(path), 2));
            }
            seg.addSteps(convertRouteSteps(path.getInstructions(), path.getPoints(), request, nextRouteFirstStepPoints));
        }
        return seg;
    }

    private void addLegsToRouteResult(RouteResult result, RoutingRequest request, List<Trip.Leg> legs, GHResponse response) throws Exception {
        for (Trip.Leg leg : legs) {
            startWayPointIndex = 0;
            List<RouteStep> instructions = leg instanceof Trip.WalkLeg wl ? convertRouteSteps(wl.instructions, PointList.from((LineString) leg.geometry), request, null) : null;
            result.addLeg(new RouteLeg(leg, instructions, response, request));
        }
    }

    private List<RouteStep> convertRouteSteps(InstructionList instructions, PointList points, RoutingRequest request, PointList nextRouteFirstStepPoints) throws Exception {
        List<RouteStep> result = new ArrayList<>();
        int nInstructions = instructions.size();
        InstructionTranslator instrTranslator = InstructionTranslatorsCache.getInstance().getTranslator(request.getLanguage());
        for (int ii = 0; ii < nInstructions; ++ii) {
            RouteStep step = new RouteStep();

            Instruction instr = instructions.get(ii);
            if (instr instanceof ViaInstruction && request.isRoundTripRequest()) {
                // if this is a via instruction, then we don't want to process it in the case of a round trip
                continue;
            }
            InstructionType instrType = getInstructionType(ii == 0, instr);

            PointList currentStepPoints = instr.getPoints();
            PointList nextStepPoints = (ii + 1 < nInstructions) ? instructions.get(ii + 1).getPoints() : nextRouteFirstStepPoints;
            PointList prevStepPoints = ii > 0 ? instructions.get(ii - 1).getPoints() : null;

            step.setName(instr.getName());

            double stepDistance = DistanceUnitUtil.convert(instr.getDistance(), DistanceUnit.METERS, request.getUnits());
            step.setDistance(FormatUtility.roundToDecimalsForUnits(stepDistance, request.getUnits()));

            step.setDuration(FormatUtility.roundToDecimals(instr.getTime() / 1000.0, 1));

            if (request.getIncludeManeuvers() || instrType.isSlightLeftOrRight()) {
                RouteStepManeuver maneuver = calcManeuver(instrType, prevStepPoints, currentStepPoints, nextStepPoints);
                if (request.getIncludeManeuvers()) {
                    step.setManeuver(maneuver);
                }
                if (instrType.isSlightLeftOrRight() && maneuver.isContinue()) {
                    // see com.graphhopper.routing.InstructionsFromEdges.getTurn(...)
                    // is generating the TurnInformation - for what EVER reason this
                    // is not correct from time to time - so I ADJUST THEM!
                    instrType = InstructionType.CONTINUE;
                }
            }

            step.setType(instrType.ordinal());

            String instrText;
            String roadName = instr.getName();
            if (request.getInstructionsFormat() == RouteInstructionsFormat.HTML && !Helper.isEmpty(instr.getName()))
                roadName = "<b>" + instr.getName() + "</b>";
            if (ii == 0) {
                double lat;
                double lon;
                if (currentStepPoints.size() == 1) {
                    if (nextStepPoints != null) {
                        lat = nextStepPoints.getLat(0);
                        lon = nextStepPoints.getLon(0);
                    } else {
                        lat = currentStepPoints.getLat(0);
                        lon = currentStepPoints.getLon(0);
                    }
                } else {
                    lat = currentStepPoints.getLat(1);
                    lon = currentStepPoints.getLon(1);
                }
                instrText = instrTranslator.getDepart(calcDirection(currentStepPoints.getLat(0), currentStepPoints.getLon(0), lat, lon), roadName);
            } else {
                if (instr instanceof RoundaboutInstruction raInstr) {
                    step.setExitNumber(raInstr.getExitNumber());
                    instrText = instrTranslator.getRoundabout(raInstr.getExitNumber(), roadName);
                } else {
                    if (isTurnInstruction(instrType)) {
                        instrText = instrTranslator.getTurn(instrType, roadName);
                    } else if (isKeepInstruction(instrType)) {
                        instrText = instrTranslator.getKeep(instrType, roadName);
                    } else if (instrType == InstructionType.PT_ENTER) {
                        instrText = instrTranslator.getPt(instrType, roadName, instr.getHeadsign());
                    } else if (instrType == InstructionType.PT_TRANSFER) {
                        instrText = instrTranslator.getPt(instrType, roadName, instr.getHeadsign());
                    } else if (instrType == InstructionType.PT_EXIT) {
                        instrText = instrTranslator.getPt(instrType, roadName);
                    } else if (instrType == InstructionType.CONTINUE) {
                        instrText = instrTranslator.getContinue(instrType, roadName);
                    } else if (instrType == InstructionType.FINISH) {
                        String lastInstrName = instructions.get(ii - 1).getName();
                        instrText = instrTranslator.getArrive(getArrivalDirection(points, request.getDestination()), lastInstrName);
                    } else
                        instrText = "Unknown instruction type!";
                }
            }
            step.setInstruction(instrText);

            int endWayPointIndex = getEndWayPointIndex(startWayPointIndex, instrType, instr);
            step.setWayPoints(new int[]{startWayPointIndex, endWayPointIndex});
            startWayPointIndex = endWayPointIndex;
            result.add(step);
        }
        return result;
    }

    private double calculateDetourFactor(ResponsePath path) {
        PointList pathPoints = path.getPoints();
        double lat0 = pathPoints.getLat(0);
        double lon0 = pathPoints.getLon(0);
        double lat1 = pathPoints.getLat(pathPoints.size() - 1);
        double lon1 = pathPoints.getLon(pathPoints.size() - 1);
        double distanceDirect = distCalc.calcDist(lat0, lon0, lat1, lon1);
        if (distanceDirect == 0) return 0;
        return path.getDistance() / distanceDirect;

    }

    private ArrivalDirection getArrivalDirection(PointList points, Coordinate destination) {
        if (points.size() < 2)
            return ArrivalDirection.UNKNOWN;

        int lastIndex = points.size() - 1;
        double lon0 = points.getLon(lastIndex - 1);
        double lat0 = points.getLat(lastIndex - 1);
        double lon1 = points.getLon(lastIndex);
        double lat1 = points.getLat(lastIndex);

        double dist = distCalc.calcDist(lat1, lon1, destination.y, destination.x);

        if (dist < 1)
            return ArrivalDirection.STRAIGHT_AHEAD;
        else {
            double sign = Math.signum((lon1 - lon0) * (destination.y - lat0) - (lat1 - lat0) * (destination.x - lon0));
            if (sign == 0)
                return ArrivalDirection.STRAIGHT_AHEAD;
            else if (sign == 1)
                return ArrivalDirection.LEFT;
            else
                return ArrivalDirection.RIGHT;
        }
    }

    private int getEndWayPointIndex(int startIndex, InstructionType instrType, Instruction instr) {
        if (instrType == InstructionType.FINISH
                // "empty" departure instruction means start and end coordinates are the same, index should not increase
                || (instrType == InstructionType.DEPART && instr.getDistance() == 0.0 && instr.getPoints().size() == 1)
        )
            return startIndex;
        else
            return startIndex + instr.getPoints().size();
    }

    private RouteStepManeuver calcManeuver(InstructionType instrType, PointList prevSegPoints, PointList segPoints, PointList nextSegPoints) {
        RouteStepManeuver maneuver = new RouteStepManeuver();
        maneuver.setBearingBefore(0);
        maneuver.setBearingAfter(0);
        if (nextSegPoints == null) {
            return maneuver;
        }
        if (instrType == InstructionType.DEPART) {
            double lon0 = segPoints.getLon(0);
            double lat0 = segPoints.getLat(0);
            maneuver.setLocation(new Coordinate(lon0, lat0));
            double lon1;
            double lat1;
            if (segPoints.size() == 1) {
                lon1 = nextSegPoints.getLon(0);
                lat1 = nextSegPoints.getLat(0);
            } else {
                lon1 = segPoints.getLon(1);
                lat1 = segPoints.getLat(1);
            }
            maneuver.setBearingAfter((int) Math.round(angleCalc.calcAzimuth(lat0, lon0, lat1, lon1)));
        } else if (prevSegPoints.size() > 0) {
            int locIndex = prevSegPoints.size() - 1;
            double lon0 = prevSegPoints.getLon(locIndex);
            double lat0 = prevSegPoints.getLat(locIndex);
            double lon1 = segPoints.getLon(0);
            double lat1 = segPoints.getLat(0);
            maneuver.setLocation(new Coordinate(lon1, lat1));
            maneuver.setBearingBefore((int) Math.round(angleCalc.calcAzimuth(lat0, lon0, lat1, lon1)));
            if (instrType != InstructionType.FINISH) {
                double lon2;
                double lat2;
                if (segPoints.size() == 1) {
                    lon2 = nextSegPoints.getLon(0);
                    lat2 = nextSegPoints.getLat(0);
                } else {
                    lon2 = segPoints.getLon(1);
                    lat2 = segPoints.getLat(1);
                }
                maneuver.setBearingAfter((int) Math.round(angleCalc.calcAzimuth(lat1, lon1, lat2, lon2)));
            }
        }
        return maneuver;
    }

    private boolean isTurnInstruction(InstructionType instrType) {
        return instrType == InstructionType.TURN_LEFT || instrType == InstructionType.TURN_SLIGHT_LEFT
                || instrType == InstructionType.TURN_SHARP_LEFT || instrType == InstructionType.TURN_RIGHT
                || instrType == InstructionType.TURN_SLIGHT_RIGHT || instrType == InstructionType.TURN_SHARP_RIGHT;
    }

    private boolean isKeepInstruction(InstructionType instrType) {
        return instrType == InstructionType.KEEP_LEFT || instrType == InstructionType.KEEP_RIGHT;
    }

    private InstructionType getInstructionType(boolean isDepart, Instruction instr) {
        if (isDepart) {
            return InstructionType.DEPART;
        }

        return switch (instr.getSign()) {
            case Instruction.TURN_LEFT -> InstructionType.TURN_LEFT;
            case Instruction.TURN_RIGHT -> InstructionType.TURN_RIGHT;
            case Instruction.TURN_SHARP_LEFT -> InstructionType.TURN_SHARP_LEFT;
            case Instruction.TURN_SHARP_RIGHT -> InstructionType.TURN_SHARP_RIGHT;
            case Instruction.TURN_SLIGHT_LEFT -> InstructionType.TURN_SLIGHT_LEFT;
            case Instruction.TURN_SLIGHT_RIGHT -> InstructionType.TURN_SLIGHT_RIGHT;
            case Instruction.USE_ROUNDABOUT -> InstructionType.ENTER_ROUNDABOUT;
            case Instruction.LEAVE_ROUNDABOUT -> InstructionType.EXIT_ROUNDABOUT;
            case Instruction.FINISH -> InstructionType.FINISH;
            case Instruction.KEEP_LEFT -> InstructionType.KEEP_LEFT;
            case Instruction.KEEP_RIGHT -> InstructionType.KEEP_RIGHT;
            case Instruction.PT_START_TRIP -> InstructionType.PT_ENTER;
            case Instruction.PT_TRANSFER -> InstructionType.PT_TRANSFER;
            case Instruction.PT_END_TRIP -> InstructionType.PT_EXIT;
            case Instruction.CONTINUE_ON_STREET -> InstructionType.CONTINUE;
            default -> InstructionType.CONTINUE;
        };
    }

    private CardinalDirection calcDirection(double lat1, double lon1, double lat2, double lon2) {
        double orientation = -angleCalc.calcOrientation(lat1, lon1, lat2, lon2);
        orientation = Helper.round4(orientation + Math.PI / 2);
        if (orientation < 0)
            orientation += 2 * Math.PI;

        double degree = Math.toDegrees(orientation);
        return directions[(int) Math.floor(((degree + 22.5) % 360) / 45)];
    }

    private void handleResponseWarnings(RouteResult result, GHResponse response) {
        String skippedExtras = response.getHints().getString("skipped_extra_info", "");
        if (!skippedExtras.isEmpty()) {
            result.addWarning(new RouteWarning(RouteWarning.SKIPPED_EXTRAS, skippedExtras));
        }
    }
}