diff --git a/application/src/client/index.html b/application/src/client/index.html index 29f6abb024b..c399d429d0e 100644 --- a/application/src/client/index.html +++ b/application/src/client/index.html @@ -5,8 +5,8 @@ OTP Debug - - + +
diff --git a/application/src/ext/java/org/opentripplanner/ext/flex/template/AbstractFlexTemplate.java b/application/src/ext/java/org/opentripplanner/ext/flex/template/AbstractFlexTemplate.java index aeeab84259c..8dbcf4d785e 100644 --- a/application/src/ext/java/org/opentripplanner/ext/flex/template/AbstractFlexTemplate.java +++ b/application/src/ext/java/org/opentripplanner/ext/flex/template/AbstractFlexTemplate.java @@ -199,7 +199,7 @@ private FlexAccessEgress createFlexAccessEgress( return null; } - final var finalStateOpt = EdgeTraverser.traverseEdges(afterFlexState, transferEdges); + final var finalStateOpt = EdgeTraverser.traverseEdges(afterFlexState[0], transferEdges); return finalStateOpt .map(finalState -> { diff --git a/application/src/ext/java/org/opentripplanner/ext/flex/template/FlexDirectPathFactory.java b/application/src/ext/java/org/opentripplanner/ext/flex/template/FlexDirectPathFactory.java index ae35c262a1e..f27a502911f 100644 --- a/application/src/ext/java/org/opentripplanner/ext/flex/template/FlexDirectPathFactory.java +++ b/application/src/ext/java/org/opentripplanner/ext/flex/template/FlexDirectPathFactory.java @@ -113,7 +113,7 @@ private Optional createDirectGraphPath( final State[] afterFlexState = flexEdge.traverse(accessNearbyStop.state); - var finalStateOpt = EdgeTraverser.traverseEdges(afterFlexState, egress.edges); + var finalStateOpt = EdgeTraverser.traverseEdges(afterFlexState[0], egress.edges); if (finalStateOpt.isEmpty()) { return Optional.empty(); diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/DirectTransferGenerator.java b/application/src/main/java/org/opentripplanner/graph_builder/module/DirectTransferGenerator.java index 37ac69d88ff..f6e6ce1b238 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/DirectTransferGenerator.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/DirectTransferGenerator.java @@ -8,8 +8,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; import org.opentripplanner.framework.application.OTPFeature; import org.opentripplanner.graph_builder.issue.api.DataImportIssueStore; import org.opentripplanner.graph_builder.issues.StopNotLinkedForTransfers; @@ -45,36 +45,69 @@ public class DirectTransferGenerator implements GraphBuilderModule { private static final Logger LOG = LoggerFactory.getLogger(DirectTransferGenerator.class); - private final Duration radiusByDuration; + private final Duration defaultMaxTransferDuration; private final List transferRequests; + private final Map transferParametersForMode; private final Graph graph; private final TimetableRepository timetableRepository; private final DataImportIssueStore issueStore; + /** + * Constructor used in tests. This initializes transferParametersForMode as an empty map. + */ public DirectTransferGenerator( Graph graph, TimetableRepository timetableRepository, DataImportIssueStore issueStore, - Duration radiusByDuration, + Duration defaultMaxTransferDuration, List transferRequests ) { this.graph = graph; this.timetableRepository = timetableRepository; this.issueStore = issueStore; - this.radiusByDuration = radiusByDuration; + this.defaultMaxTransferDuration = defaultMaxTransferDuration; + this.transferRequests = transferRequests; + this.transferParametersForMode = Map.of(); + } + + public DirectTransferGenerator( + Graph graph, + TimetableRepository timetableRepository, + DataImportIssueStore issueStore, + Duration defaultMaxTransferDuration, + List transferRequests, + Map transferParametersForMode + ) { + this.graph = graph; + this.timetableRepository = timetableRepository; + this.issueStore = issueStore; + this.defaultMaxTransferDuration = defaultMaxTransferDuration; this.transferRequests = transferRequests; + this.transferParametersForMode = transferParametersForMode; } @Override public void buildGraph() { - /* Initialize transit model index which is needed by the nearby stop finder. */ + // Initialize transit model index which is needed by the nearby stop finder. timetableRepository.index(); - /* The linker will use streets if they are available, or straight-line distance otherwise. */ - NearbyStopFinder nearbyStopFinder = createNearbyStopFinder(); + // The linker will use streets if they are available, or straight-line distance otherwise. + NearbyStopFinder nearbyStopFinder = createNearbyStopFinder(defaultMaxTransferDuration); List stops = graph.getVerticesOfType(TransitStopVertex.class); + Set carsAllowedStops = timetableRepository.getStopLocationsUsedForCarsAllowedTrips(); + + LOG.info("Creating transfers based on requests:"); + transferRequests.forEach(transferProfile -> LOG.info(transferProfile.toString())); + if (transferParametersForMode.isEmpty()) { + LOG.info("No mode-specific transfer configurations provided."); + } else { + LOG.info("Using transfer configurations for modes:"); + transferParametersForMode.forEach((mode, transferParameters) -> + LOG.info(mode + ": " + transferParameters) + ); + } ProgressTracker progress = ProgressTracker.track( "Create transfer edges for stops", @@ -90,16 +123,8 @@ public void buildGraph() { HashMultimap.create() ); - List flexTransferRequests = new ArrayList<>(); - // Flex transfer requests only use the WALK mode. - if (OTPFeature.FlexRouting.isOn()) { - flexTransferRequests.addAll( - transferRequests - .stream() - .filter(transferProfile -> transferProfile.journey().transfer().mode() == StreetMode.WALK) - .toList() - ); - } + // Parse the transfer configuration from the parameters given in the build config. + TransferConfiguration transferConfiguration = parseTransferParameters(nearbyStopFinder); stops .stream() @@ -116,70 +141,15 @@ public void buildGraph() { LOG.debug("Linking stop '{}' {}", stop, ts0); - // Calculate default transfers. - for (RouteRequest transferProfile : transferRequests) { - StreetMode mode = transferProfile.journey().transfer().mode(); - for (NearbyStop sd : nearbyStopFinder.findNearbyStops( - ts0, - transferProfile, - transferProfile.journey().transfer(), - false - )) { - // Skip the origin stop, loop transfers are not needed. - if (sd.stop == stop) { - continue; - } - if (sd.stop.transfersNotAllowed()) { - continue; - } - TransferKey transferKey = new TransferKey(stop, sd.stop, sd.edges); - PathTransfer pathTransfer = distinctTransfers.get(transferKey); - if (pathTransfer == null) { - // If the PathTransfer can't be found, it is created. - distinctTransfers.put( - transferKey, - new PathTransfer(stop, sd.stop, sd.distance, sd.edges, EnumSet.of(mode)) - ); - } else { - // If the PathTransfer is found, a new PathTransfer with the added mode is created. - distinctTransfers.put(transferKey, pathTransfer.withAddedMode(mode)); - } - } - } - // Calculate flex transfers if flex routing is enabled. - for (RouteRequest transferProfile : flexTransferRequests) { - // Flex transfer requests only use the WALK mode. - StreetMode mode = StreetMode.WALK; - // This code is for finding transfers from AreaStops to Stops, transfers - // from Stops to AreaStops and between Stops are already covered above. - for (NearbyStop sd : nearbyStopFinder.findNearbyStops( - ts0, - transferProfile, - transferProfile.journey().transfer(), - true - )) { - // Skip the origin stop, loop transfers are not needed. - if (sd.stop == stop) { - continue; - } - if (sd.stop instanceof RegularStop) { - continue; - } - // The TransferKey and PathTransfer are created differently for flex routing. - TransferKey transferKey = new TransferKey(sd.stop, stop, sd.edges); - PathTransfer pathTransfer = distinctTransfers.get(transferKey); - if (pathTransfer == null) { - // If the PathTransfer can't be found, it is created. - distinctTransfers.put( - transferKey, - new PathTransfer(sd.stop, stop, sd.distance, sd.edges, EnumSet.of(mode)) - ); - } else { - // If the PathTransfer is found, a new PathTransfer with the added mode is created. - distinctTransfers.put(transferKey, pathTransfer.withAddedMode(mode)); - } - } - } + calculateDefaultTransfers(transferConfiguration, ts0, stop, distinctTransfers); + calculateFlexTransfers(transferConfiguration, ts0, stop, distinctTransfers); + calculateCarsAllowedTransfers( + transferConfiguration, + ts0, + stop, + distinctTransfers, + carsAllowedStops + ); LOG.debug( "Linked stop {} with {} transfers to stops with different patterns.", @@ -227,7 +197,7 @@ public void buildGraph() { * whether the graph has a street network and if ConsiderPatternsForDirectTransfers feature is * enabled. */ - private NearbyStopFinder createNearbyStopFinder() { + private NearbyStopFinder createNearbyStopFinder(Duration radiusByDuration) { var transitService = new DefaultTransitService(timetableRepository); NearbyStopFinder finder; if (!graph.hasStreets) { @@ -247,5 +217,209 @@ private NearbyStopFinder createNearbyStopFinder() { } } + private void createPathTransfer( + StopLocation from, + StopLocation to, + NearbyStop sd, + Map distinctTransfers, + StreetMode mode + ) { + TransferKey transferKey = new TransferKey(from, to, sd.edges); + PathTransfer pathTransfer = distinctTransfers.get(transferKey); + if (pathTransfer == null) { + // If the PathTransfer can't be found, it is created. + distinctTransfers.put( + transferKey, + new PathTransfer(from, to, sd.distance, sd.edges, EnumSet.of(mode)) + ); + } else { + // If the PathTransfer is found, a new PathTransfer with the added mode is created. + distinctTransfers.put(transferKey, pathTransfer.withAddedMode(mode)); + } + } + + /** + * This method parses the given transfer parameters into a transfer configuration and checks for invalid input. + */ + private TransferConfiguration parseTransferParameters(NearbyStopFinder nearbyStopFinder) { + List defaultTransferRequests = new ArrayList<>(); + List carsAllowedStopTransferRequests = new ArrayList<>(); + List flexTransferRequests = new ArrayList<>(); + HashMap defaultNearbyStopFinderForMode = new HashMap<>(); + // These are used for calculating transfers only between carsAllowedStops. + HashMap carsAllowedStopNearbyStopFinderForMode = new HashMap<>(); + + // Check that the mode specified in transferParametersForMode can also be found in transferRequests. + for (StreetMode mode : transferParametersForMode.keySet()) { + if ( + !transferRequests + .stream() + .anyMatch(transferProfile -> transferProfile.journey().transfer().mode() == mode) + ) { + throw new IllegalArgumentException( + String.format( + "Mode %s is used in transferParametersForMode but not in transferRequests", + mode + ) + ); + } + } + + for (RouteRequest transferProfile : transferRequests) { + StreetMode mode = transferProfile.journey().transfer().mode(); + TransferParameters transferParameters = transferParametersForMode.get(mode); + if (transferParameters != null) { + // WALK mode transfers can not be disabled. For example, flex transfers need them. + if (transferParameters.disableDefaultTransfers() && mode == StreetMode.WALK) { + throw new IllegalArgumentException("WALK mode transfers can not be disabled"); + } + // Disable normal transfer calculations for the specific mode, if disableDefaultTransfers is set in the build config. + if (!transferParameters.disableDefaultTransfers()) { + defaultTransferRequests.add(transferProfile); + // Set mode-specific maxTransferDuration, if it is set in the build config. + Duration maxTransferDuration = transferParameters.maxTransferDuration(); + if (maxTransferDuration != null) { + defaultNearbyStopFinderForMode.put(mode, createNearbyStopFinder(maxTransferDuration)); + } else { + defaultNearbyStopFinderForMode.put(mode, nearbyStopFinder); + } + } + // Create transfers between carsAllowedStops for the specific mode if carsAllowedStopMaxTransferDuration is set in the build config. + Duration carsAllowedStopMaxTransferDuration = transferParameters.carsAllowedStopMaxTransferDuration(); + if (carsAllowedStopMaxTransferDuration != null) { + carsAllowedStopTransferRequests.add(transferProfile); + carsAllowedStopNearbyStopFinderForMode.put( + mode, + createNearbyStopFinder(carsAllowedStopMaxTransferDuration) + ); + } + } else { + defaultTransferRequests.add(transferProfile); + defaultNearbyStopFinderForMode.put(mode, nearbyStopFinder); + } + } + + // Flex transfer requests only use the WALK mode. + if (OTPFeature.FlexRouting.isOn()) { + flexTransferRequests.addAll( + transferRequests + .stream() + .filter(transferProfile -> transferProfile.journey().transfer().mode() == StreetMode.WALK) + .toList() + ); + } + + return new TransferConfiguration( + defaultTransferRequests, + carsAllowedStopTransferRequests, + flexTransferRequests, + defaultNearbyStopFinderForMode, + carsAllowedStopNearbyStopFinderForMode + ); + } + + /** + * This method calculates default transfers. + */ + private void calculateDefaultTransfers( + TransferConfiguration transferConfiguration, + TransitStopVertex ts0, + RegularStop stop, + Map distinctTransfers + ) { + for (RouteRequest transferProfile : transferConfiguration.defaultTransferRequests()) { + StreetMode mode = transferProfile.journey().transfer().mode(); + var nearbyStops = transferConfiguration + .defaultNearbyStopFinderForMode() + .get(mode) + .findNearbyStops(ts0, transferProfile, transferProfile.journey().transfer(), false); + for (NearbyStop sd : nearbyStops) { + // Skip the origin stop, loop transfers are not needed. + if (sd.stop == stop) { + continue; + } + if (sd.stop.transfersNotAllowed()) { + continue; + } + createPathTransfer(stop, sd.stop, sd, distinctTransfers, mode); + } + } + } + + /** + * This method calculates flex transfers if flex routing is enabled. + */ + private void calculateFlexTransfers( + TransferConfiguration transferConfiguration, + TransitStopVertex ts0, + RegularStop stop, + Map distinctTransfers + ) { + for (RouteRequest transferProfile : transferConfiguration.flexTransferRequests()) { + // Flex transfer requests only use the WALK mode. + StreetMode mode = StreetMode.WALK; + var nearbyStops = transferConfiguration + .defaultNearbyStopFinderForMode() + .get(mode) + .findNearbyStops(ts0, transferProfile, transferProfile.journey().transfer(), true); + // This code is for finding transfers from AreaStops to Stops, transfers + // from Stops to AreaStops and between Stops are already covered above. + for (NearbyStop sd : nearbyStops) { + // Skip the origin stop, loop transfers are not needed. + if (sd.stop == stop) { + continue; + } + if (sd.stop instanceof RegularStop) { + continue; + } + // The TransferKey and PathTransfer are created differently for flex routing. + createPathTransfer(sd.stop, stop, sd, distinctTransfers, mode); + } + } + } + + /** + * This method calculates transfers between stops that are visited by trips that allow cars, if configured. + */ + private void calculateCarsAllowedTransfers( + TransferConfiguration transferConfiguration, + TransitStopVertex ts0, + RegularStop stop, + Map distinctTransfers, + Set carsAllowedStops + ) { + if (carsAllowedStops.contains(stop)) { + for (RouteRequest transferProfile : transferConfiguration.carsAllowedStopTransferRequests()) { + StreetMode mode = transferProfile.journey().transfer().mode(); + var nearbyStops = transferConfiguration + .carsAllowedStopNearbyStopFinderForMode() + .get(mode) + .findNearbyStops(ts0, transferProfile, transferProfile.journey().transfer(), false); + for (NearbyStop sd : nearbyStops) { + // Skip the origin stop, loop transfers are not needed. + if (sd.stop == stop) { + continue; + } + if (sd.stop.transfersNotAllowed()) { + continue; + } + // Only calculate transfers between carsAllowedStops. + if (!carsAllowedStops.contains(sd.stop)) { + continue; + } + createPathTransfer(stop, sd.stop, sd, distinctTransfers, mode); + } + } + } + } + + private record TransferConfiguration( + List defaultTransferRequests, + List carsAllowedStopTransferRequests, + List flexTransferRequests, + HashMap defaultNearbyStopFinderForMode, + HashMap carsAllowedStopNearbyStopFinderForMode + ) {} + private record TransferKey(StopLocation source, StopLocation target, List edges) {} } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/TransferParameters.java b/application/src/main/java/org/opentripplanner/graph_builder/module/TransferParameters.java new file mode 100644 index 00000000000..0d7b31b4a81 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/TransferParameters.java @@ -0,0 +1,68 @@ +package org.opentripplanner.graph_builder.module; + +import java.time.Duration; +import org.opentripplanner.utils.tostring.ToStringBuilder; + +/** + * Mode-specific parameters for transfers. + */ +public record TransferParameters( + Duration maxTransferDuration, + Duration carsAllowedStopMaxTransferDuration, + boolean disableDefaultTransfers +) { + public static final Duration DEFAULT_MAX_TRANSFER_DURATION = null; + public static final Duration DEFAULT_CARS_ALLOWED_STOP_MAX_TRANSFER_DURATION = null; + public static final boolean DEFAULT_DISABLE_DEFAULT_TRANSFERS = false; + + TransferParameters(Builder builder) { + this( + builder.maxTransferDuration, + builder.carsAllowedStopMaxTransferDuration, + builder.disableDefaultTransfers + ); + } + + public String toString() { + return ToStringBuilder + .of(getClass()) + .addDuration("maxTransferDuration", maxTransferDuration) + .addDuration("carsAllowedStopMaxTransferDuration", carsAllowedStopMaxTransferDuration) + .addBool("disableDefaultTransfers", disableDefaultTransfers) + .toString(); + } + + public static class Builder { + + private Duration maxTransferDuration; + private Duration carsAllowedStopMaxTransferDuration; + private boolean disableDefaultTransfers; + + public Builder() { + this.maxTransferDuration = DEFAULT_MAX_TRANSFER_DURATION; + this.carsAllowedStopMaxTransferDuration = DEFAULT_CARS_ALLOWED_STOP_MAX_TRANSFER_DURATION; + this.disableDefaultTransfers = DEFAULT_DISABLE_DEFAULT_TRANSFERS; + } + + public Builder withMaxTransferDuration(Duration maxTransferDuration) { + this.maxTransferDuration = maxTransferDuration; + return this; + } + + public Builder withCarsAllowedStopMaxTransferDuration( + Duration carsAllowedStopMaxTransferDuration + ) { + this.carsAllowedStopMaxTransferDuration = carsAllowedStopMaxTransferDuration; + return this; + } + + public Builder withDisableDefaultTransfers(boolean disableDefaultTransfers) { + this.disableDefaultTransfers = disableDefaultTransfers; + return this; + } + + public TransferParameters build() { + return new TransferParameters(this); + } + } +} diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java b/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java index d464523a61a..6cf3e593d93 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/configure/GraphBuilderModules.java @@ -259,7 +259,8 @@ static DirectTransferGenerator provideDirectTransferGenerator( timetableRepository, issueStore, config.maxTransferDuration, - config.transferRequests + config.transferRequests, + config.transferParametersForMode ); } diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java b/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java index 15e3307b4e9..683acdd1a9e 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java @@ -6,7 +6,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Set; import org.opentripplanner.astar.model.GraphPath; import org.opentripplanner.framework.application.OTPFeature; import org.opentripplanner.framework.geometry.GeometryUtils; @@ -39,8 +38,8 @@ import org.opentripplanner.street.search.TraverseMode; import org.opentripplanner.street.search.request.StreetSearchRequest; import org.opentripplanner.street.search.request.StreetSearchRequestMapper; -import org.opentripplanner.street.search.state.EdgeTraverser; import org.opentripplanner.street.search.state.State; +import org.opentripplanner.street.search.state.StateEditor; import org.opentripplanner.transit.model.timetable.TripIdAndServiceDate; import org.opentripplanner.transit.model.timetable.TripOnServiceDate; import org.opentripplanner.transit.service.TransitService; @@ -361,15 +360,24 @@ private List mapNonTransitLeg( .build() ); } else { - var legTransferSearchRequest = transferStreetRequest - .copyOf(createZonedDateTime(pathLeg.fromTime()).toInstant()) - .build(); - var initialStates = State.getInitialStates( - Set.of(edges.getFirst().getFromVertex()), - legTransferSearchRequest - ); - var state = EdgeTraverser.traverseEdges(initialStates, edges); - var graphPath = new GraphPath<>(state.get()); + StateEditor se = new StateEditor(edges.get(0).getFromVertex(), transferStreetRequest); + se.setTimeSeconds(createZonedDateTime(pathLeg.fromTime()).toEpochSecond()); + + State s = se.makeState(); + ArrayList transferStates = new ArrayList<>(); + transferStates.add(s); + for (Edge e : edges) { + var states = e.traverse(s); + if (State.isEmpty(states)) { + s = null; + } else { + transferStates.add(states[0]); + s = states[0]; + } + } + + State[] states = transferStates.toArray(new State[0]); + var graphPath = new GraphPath<>(states[states.length - 1]); Itinerary subItinerary = graphPathToItineraryMapper.generateItinerary(graphPath); diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java index d332013d5ac..23293e6d200 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/router/TransitRouter.java @@ -36,6 +36,7 @@ import org.opentripplanner.routing.algorithm.transferoptimization.configure.TransferOptimizationServiceConfigurator; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.api.request.preference.AccessEgressPreferences; import org.opentripplanner.routing.api.request.request.StreetRequest; import org.opentripplanner.routing.api.response.InputField; import org.opentripplanner.routing.api.response.RoutingError; @@ -239,6 +240,7 @@ private Collection fetchEgress() { private Collection fetchAccessEgresses(AccessEgressType type) { var streetRequest = type.isAccess() ? request.journey().access() : request.journey().egress(); + StreetMode mode = streetRequest.mode(); // Prepare access/egress lists RouteRequest accessRequest = request.clone(); @@ -252,13 +254,15 @@ private Collection fetchAccessEgresses(AccessEgre }); } - Duration durationLimit = accessRequest + AccessEgressPreferences accessEgressPreferences = accessRequest .preferences() .street() - .accessEgress() - .maxDuration() - .valueOf(streetRequest.mode()); - int stopCountLimit = accessRequest.preferences().street().accessEgress().maxStopCount(); + .accessEgress(); + + Duration durationLimit = accessEgressPreferences.maxDuration().valueOf(mode); + int stopCountLimit = accessEgressPreferences + .maxStopCountForMode() + .getOrDefault(mode, accessEgressPreferences.defaultMaxStopCount()); var nearbyStops = AccessEgressRouter.findAccessEgresses( accessRequest, @@ -275,7 +279,7 @@ private Collection fetchAccessEgresses(AccessEgre var results = new ArrayList<>(accessEgresses); // Special handling of flex accesses - if (OTPFeature.FlexRouting.isOn() && streetRequest.mode() == StreetMode.FLEXIBLE) { + if (OTPFeature.FlexRouting.isOn() && mode == StreetMode.FLEXIBLE) { var flexAccessList = FlexAccessEgressRouter.routeAccessEgress( accessRequest, temporaryVerticesContainer, diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/RaptorTransferIndex.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/RaptorTransferIndex.java index 8676e863911..fa44d793664 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/RaptorTransferIndex.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/RaptorTransferIndex.java @@ -5,12 +5,19 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Function; +import java.util.stream.IntStream; +import org.opentripplanner.framework.application.OTPFeature; import org.opentripplanner.raptor.api.model.RaptorTransfer; import org.opentripplanner.routing.api.request.StreetMode; import org.opentripplanner.street.search.request.StreetSearchRequest; public class RaptorTransferIndex { + private enum RequestSource { + SETUP, + REQUEST_SCOPE, + } + private final List[] forwardTransfers; private final List[] reversedTransfers; @@ -24,19 +31,47 @@ public RaptorTransferIndex( this.reversedTransfers = reversedTransfers.stream().map(List::copyOf).toArray(List[]::new); } - public static RaptorTransferIndex create( + /** + * Create an index for a route request configured in router-config.json + */ + public static RaptorTransferIndex createInitialSetup( + List> transfersByStopIndex, + StreetSearchRequest request + ) { + return create(transfersByStopIndex, request, RequestSource.SETUP); + } + + /** + * Create an index for a route request originated from the client + */ + public static RaptorTransferIndex createRequestScope( List> transfersByStopIndex, StreetSearchRequest request + ) { + return create(transfersByStopIndex, request, RequestSource.REQUEST_SCOPE); + } + + private static RaptorTransferIndex create( + List> transfersByStopIndex, + StreetSearchRequest request, + RequestSource requestSource ) { var forwardTransfers = new ArrayList>(transfersByStopIndex.size()); var reversedTransfers = new ArrayList>(transfersByStopIndex.size()); StreetMode mode = request.mode(); for (int i = 0; i < transfersByStopIndex.size(); i++) { + forwardTransfers.add(new ArrayList<>()); reversedTransfers.add(new ArrayList<>()); } - for (int fromStop = 0; fromStop < transfersByStopIndex.size(); fromStop++) { + var stopIndices = IntStream.range(0, transfersByStopIndex.size()); + // we want to always parallelize the cache building during the startup + // and only parallelize during runtime requests if the feature flag is on + if (requestSource == RequestSource.SETUP || OTPFeature.ParallelRouting.isOn()) { + stopIndices = stopIndices.parallel(); + } + stopIndices.forEach(fromStop -> { // The transfers are filtered so that there is only one possible directional transfer // for a stop pair. var transfers = transfersByStopIndex @@ -49,15 +84,18 @@ public static RaptorTransferIndex create( ) .values(); - forwardTransfers.add(new ArrayList<>(transfers)); + // forwardTransfers is not modified here, and no two threads will access the same element + // in it, so this is still thread safe. + forwardTransfers.get(fromStop).addAll(transfers); + }); - for (RaptorTransfer forwardTransfer : transfers) { + for (int fromStop = 0; fromStop < transfersByStopIndex.size(); fromStop++) { + for (var forwardTransfer : forwardTransfers.get(fromStop)) { reversedTransfers .get(forwardTransfer.stop()) .add(DefaultRaptorTransfer.reverseOf(fromStop, forwardTransfer)); } } - return new RaptorTransferIndex(forwardTransfers, reversedTransfers); } diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/Transfer.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/Transfer.java index 2643067398e..20a36376ae7 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/Transfer.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/Transfer.java @@ -15,7 +15,7 @@ import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.search.request.StreetSearchRequest; import org.opentripplanner.street.search.state.EdgeTraverser; -import org.opentripplanner.street.search.state.State; +import org.opentripplanner.street.search.state.StateEditor; import org.opentripplanner.utils.logging.Throttle; import org.opentripplanner.utils.tostring.ToStringBuilder; import org.slf4j.Logger; @@ -97,8 +97,10 @@ public Optional asRaptorTransfer(StreetSearchRequest request) { ); } - var initialStates = State.getInitialStates(Set.of(edges.getFirst().getFromVertex()), request); - var state = EdgeTraverser.traverseEdges(initialStates, edges); + StateEditor se = new StateEditor(edges.get(0).getFromVertex(), request); + se.setTimeSeconds(0); + + var state = EdgeTraverser.traverseEdges(se.makeState(), edges); return state.map(s -> new DefaultRaptorTransfer( diff --git a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RaptorRequestTransferCache.java b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RaptorRequestTransferCache.java index d778f491142..80814fdeee2 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RaptorRequestTransferCache.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/raptoradapter/transit/request/RaptorRequestTransferCache.java @@ -36,7 +36,7 @@ public LoadingCache getTransferCache() { public void put(List> transfersByStopIndex, RouteRequest request) { final CacheKey cacheKey = new CacheKey(transfersByStopIndex, request); - final RaptorTransferIndex raptorTransferIndex = RaptorTransferIndex.create( + final RaptorTransferIndex raptorTransferIndex = RaptorTransferIndex.createInitialSetup( transfersByStopIndex, cacheKey.request ); @@ -58,7 +58,10 @@ private CacheLoader cacheLoader() { @Override public RaptorTransferIndex load(CacheKey cacheKey) { LOG.info("Adding runtime request to cache: {}", cacheKey.options); - return RaptorTransferIndex.create(cacheKey.transfersByStopIndex, cacheKey.request); + return RaptorTransferIndex.createRequestScope( + cacheKey.transfersByStopIndex, + cacheKey.request + ); } }; } diff --git a/application/src/main/java/org/opentripplanner/routing/api/request/preference/AccessEgressPreferences.java b/application/src/main/java/org/opentripplanner/routing/api/request/preference/AccessEgressPreferences.java index 289e06e6e02..4a8c342275f 100644 --- a/application/src/main/java/org/opentripplanner/routing/api/request/preference/AccessEgressPreferences.java +++ b/application/src/main/java/org/opentripplanner/routing/api/request/preference/AccessEgressPreferences.java @@ -4,6 +4,7 @@ import java.io.Serializable; import java.time.Duration; +import java.util.Collections; import java.util.Map; import java.util.Objects; import java.util.function.Consumer; @@ -27,18 +28,21 @@ public final class AccessEgressPreferences implements Serializable { private final TimeAndCostPenaltyForEnum penalty; private final DurationForEnum maxDuration; - private final int maxStopCount; + private final int defaultMaxStopCount; + private final Map maxStopCountForMode; private AccessEgressPreferences() { this.maxDuration = durationForStreetModeOf(ofMinutes(45)); this.penalty = DEFAULT_TIME_AND_COST; - this.maxStopCount = 500; + this.defaultMaxStopCount = 500; + this.maxStopCountForMode = Map.of(); } private AccessEgressPreferences(Builder builder) { this.maxDuration = builder.maxDuration; this.penalty = builder.penalty; - this.maxStopCount = builder.maxStopCount; + this.defaultMaxStopCount = builder.defaultMaxStopCount; + this.maxStopCountForMode = Collections.unmodifiableMap(builder.maxStopCountForMode); } public static Builder of() { @@ -57,8 +61,12 @@ public DurationForEnum maxDuration() { return maxDuration; } - public int maxStopCount() { - return maxStopCount; + public int defaultMaxStopCount() { + return defaultMaxStopCount; + } + + public Map maxStopCountForMode() { + return maxStopCountForMode; } @Override @@ -69,13 +77,14 @@ public boolean equals(Object o) { return ( penalty.equals(that.penalty) && maxDuration.equals(that.maxDuration) && - maxStopCount == that.maxStopCount + defaultMaxStopCount == that.defaultMaxStopCount && + maxStopCountForMode.equals(that.maxStopCountForMode) ); } @Override public int hashCode() { - return Objects.hash(penalty, maxDuration, maxStopCount); + return Objects.hash(penalty, maxDuration, defaultMaxStopCount, maxStopCountForMode); } @Override @@ -84,7 +93,8 @@ public String toString() { .of(AccessEgressPreferences.class) .addObj("penalty", penalty, DEFAULT.penalty) .addObj("maxDuration", maxDuration, DEFAULT.maxDuration) - .addObj("maxStopCount", maxStopCount, DEFAULT.maxStopCount) + .addObj("defaultMaxStopCount", defaultMaxStopCount, DEFAULT.defaultMaxStopCount) + .addObj("maxStopCountForMode", maxStopCountForMode, DEFAULT.maxStopCountForMode) .toString(); } @@ -93,13 +103,15 @@ public static class Builder { private final AccessEgressPreferences original; private TimeAndCostPenaltyForEnum penalty; private DurationForEnum maxDuration; - private int maxStopCount; + private Map maxStopCountForMode; + private int defaultMaxStopCount; public Builder(AccessEgressPreferences original) { this.original = original; this.maxDuration = original.maxDuration; this.penalty = original.penalty; - this.maxStopCount = original.maxStopCount; + this.defaultMaxStopCount = original.defaultMaxStopCount; + this.maxStopCountForMode = original.maxStopCountForMode; } public Builder withMaxDuration(Consumer> body) { @@ -112,8 +124,12 @@ public Builder withMaxDuration(Duration defaultValue, Map return withMaxDuration(b -> b.withDefault(defaultValue).withValues(values)); } - public Builder withMaxStopCount(int maxCount) { - this.maxStopCount = maxCount; + public Builder withMaxStopCount( + int defaultMaxStopCount, + Map maxStopCountForMode + ) { + this.defaultMaxStopCount = defaultMaxStopCount; + this.maxStopCountForMode = maxStopCountForMode; return this; } diff --git a/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java index 16c6f1e722c..f8533bd75ca 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/BuildConfig.java @@ -15,6 +15,7 @@ import java.time.LocalDate; import java.time.ZoneId; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import javax.annotation.Nullable; @@ -23,6 +24,7 @@ import org.opentripplanner.ext.emissions.EmissionsConfig; import org.opentripplanner.ext.fares.FaresConfiguration; import org.opentripplanner.framework.geometry.CompactElevationProfile; +import org.opentripplanner.graph_builder.module.TransferParameters; import org.opentripplanner.graph_builder.module.ned.parameter.DemExtractParameters; import org.opentripplanner.graph_builder.module.ned.parameter.DemExtractParametersList; import org.opentripplanner.graph_builder.module.osm.parameters.OsmExtractParameters; @@ -32,6 +34,8 @@ import org.opentripplanner.model.calendar.ServiceDateInterval; import org.opentripplanner.netex.config.NetexFeedParameters; import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.api.request.framework.DurationForEnum; import org.opentripplanner.routing.fares.FareServiceFactory; import org.opentripplanner.standalone.config.buildconfig.DemConfig; import org.opentripplanner.standalone.config.buildconfig.GtfsConfig; @@ -39,6 +43,7 @@ import org.opentripplanner.standalone.config.buildconfig.NetexConfig; import org.opentripplanner.standalone.config.buildconfig.OsmConfig; import org.opentripplanner.standalone.config.buildconfig.S3BucketConfig; +import org.opentripplanner.standalone.config.buildconfig.TransferConfig; import org.opentripplanner.standalone.config.buildconfig.TransferRequestConfig; import org.opentripplanner.standalone.config.buildconfig.TransitFeedConfig; import org.opentripplanner.standalone.config.buildconfig.TransitFeeds; @@ -151,6 +156,7 @@ public class BuildConfig implements OtpDataStoreConfig { public final IslandPruningConfig islandPruning; public final Duration maxTransferDuration; + public final Map transferParametersForMode; public final NetexFeedParameters netexDefaults; public final GtfsFeedParameters gtfsDefaults; @@ -284,9 +290,10 @@ When set to true (it is false by default), the elevation module will include the .of("maxTransferDuration") .since(V2_1) .summary( - "Transfers up to this duration with the default walk speed value will be pre-calculated and included in the Graph." + "Transfers up to this duration with a mode-specific speed value will be pre-calculated and included in the Graph." ) .asDuration(Duration.ofMinutes(30)); + transferParametersForMode = TransferConfig.map(root, "transferParametersForMode"); maxStopToShapeSnapDistance = root .of("maxStopToShapeSnapDistance") diff --git a/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/TransferConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/TransferConfig.java new file mode 100644 index 00000000000..5549e009c48 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/TransferConfig.java @@ -0,0 +1,44 @@ +package org.opentripplanner.standalone.config.buildconfig; + +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_7; + +import java.util.EnumMap; +import java.util.Map; +import org.opentripplanner.graph_builder.module.TransferParameters; +import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.standalone.config.framework.json.NodeAdapter; + +public class TransferConfig { + + public static Map map(NodeAdapter root, String parameterName) { + return root + .of(parameterName) + .since(V2_7) + .summary("Configures mode-specific properties for transfer calculations.") + .description( + """ +This field enables configuring mode-specific parameters for transfer calculations. +To configure mode-specific parameters, the modes should also be used in the `transferRequests` field in the build config. + +**Example** + +```JSON +// build-config.json +{ + "transferParametersForMode": { + "CAR": { + "disableDefaultTransfers": true, + "carsAllowedStopMaxTransferDuration": "3h" + }, + "BIKE": { + "maxTransferDuration": "30m", + "carsAllowedStopMaxTransferDuration": "3h" + } + } +} +``` +""" + ) + .asEnumMap(StreetMode.class, TransferParametersMapper::map, new EnumMap<>(StreetMode.class)); + } +} diff --git a/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/TransferParametersMapper.java b/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/TransferParametersMapper.java new file mode 100644 index 00000000000..e9cce1a367d --- /dev/null +++ b/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/TransferParametersMapper.java @@ -0,0 +1,67 @@ +package org.opentripplanner.standalone.config.buildconfig; + +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_7; + +import org.opentripplanner.graph_builder.module.TransferParameters; +import org.opentripplanner.standalone.config.framework.json.NodeAdapter; + +public class TransferParametersMapper { + + public static TransferParameters map(NodeAdapter c) { + TransferParameters.Builder builder = new TransferParameters.Builder(); + builder.withMaxTransferDuration( + c + .of("maxTransferDuration") + .summary("This overwrites the default `maxTransferDuration` for the given mode.") + .since(V2_7) + .asDuration(TransferParameters.DEFAULT_MAX_TRANSFER_DURATION) + ); + builder.withCarsAllowedStopMaxTransferDuration( + c + .of("carsAllowedStopMaxTransferDuration") + .summary( + "This is used for specifying a `maxTransferDuration` value to use with transfers between stops which are visited by trips that allow cars." + ) + .description( + """ +This parameter configures additional transfers to be calculated for the specified mode only between stops that have trips with cars. +The transfers are calculated for the mode in a range based on the given duration. +By default, these transfers are not calculated unless specified for a mode with this field. + +Calculating transfers only between stops that have trips with cars can be useful with car ferries, for example. +Using transit with cars can only occur between certain stops. +These kinds of stops require support for loading cars into ferries, for example. +The default transfers are calculated based on a configurable range (configurable by using the `maxTransferDuration` field) +which limits transfers from stops to only be calculated to other stops that are in range. +When compared to walking, using a car can cover larger distances within the same duration specified in the `maxTransferDuration` field. +This can lead to large amounts of transfers calculated between stops that do not require car transfers between them. +This in turn can lead to a large increase in memory for the stored graph, depending on the data used in the graph. + +For cars, using this parameter in conjunction with `disableDefaultTransfers` allows calculating transfers only between relevant stops. +For bikes, using this parameter can enable transfers between ferry stops that would normally not be in range. +In Finland this is useful for bike routes that use ferries near the Turku archipelago, for example. +""" + ) + .since(V2_7) + .asDuration(TransferParameters.DEFAULT_CARS_ALLOWED_STOP_MAX_TRANSFER_DURATION) + ); + builder.withDisableDefaultTransfers( + c + .of("disableDefaultTransfers") + .summary("This disables default transfer calculations.") + .description( + """ +The default transfers are calculated based on a configurable range (configurable by using the `maxTransferDuration` field) +which limits transfers from stops to only be calculated to other stops that are in range. +This parameter disables these transfers. +A motivation to disable default transfers could be related to using the `carsAllowedStopMaxTransferDuration` field which only +calculates transfers between stops that have trips with cars. +For example, when using the `carsAllowedStopMaxTransferDuration` field with cars, the default transfers can be redundant. +""" + ) + .since(V2_7) + .asBoolean(TransferParameters.DEFAULT_DISABLE_DEFAULT_TRANSFERS) + ); + return builder.build(); + } +} diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java index 454ab29a68c..3ea1e83148c 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerequest/RouteRequestConfig.java @@ -542,7 +542,20 @@ duration can be set per mode(`maxDurationForMode`), because some street modes se Safety limit to prevent access to and egress from too many stops. """ ) - .asInt(dftAccessEgress.maxStopCount()) + .asInt(dftAccessEgress.defaultMaxStopCount()), + cae + .of("maxStopCountForMode") + .since(V2_7) + .summary( + "Maximal number of stops collected in access/egress routing for the given mode" + ) + .description( + """ + Safety limit to prevent access to and egress from too many stops. + Mode-specific version of `maxStopCount`. + """ + ) + .asEnumMap(StreetMode.class, Integer.class) ); }) .withMaxDirectDuration( diff --git a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java index df8933cd22d..c93ea598256 100644 --- a/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java +++ b/application/src/main/java/org/opentripplanner/street/search/request/StreetSearchRequest.java @@ -124,10 +124,6 @@ public DataOverlayContext dataOverlayContext() { return dataOverlayContext; } - public StreetSearchRequestBuilder copyOf(Instant time) { - return copyOf(this).withStartTime(time); - } - public StreetSearchRequestBuilder copyOfReversed(Instant time) { return copyOf(this).withStartTime(time).withArriveBy(!arriveBy); } diff --git a/application/src/main/java/org/opentripplanner/street/search/state/EdgeTraverser.java b/application/src/main/java/org/opentripplanner/street/search/state/EdgeTraverser.java index 502d014e358..8755f014e14 100644 --- a/application/src/main/java/org/opentripplanner/street/search/state/EdgeTraverser.java +++ b/application/src/main/java/org/opentripplanner/street/search/state/EdgeTraverser.java @@ -2,10 +2,7 @@ import java.util.Collection; import java.util.Optional; -import org.opentripplanner.astar.model.ShortestPathTree; import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.Vertex; -import org.opentripplanner.street.search.strategy.DominanceFunctions; /** * This is a very reduced version of the A* algorithm: from an initial state a number of edges are @@ -17,49 +14,24 @@ */ public class EdgeTraverser { - public static Optional traverseEdges( - final Collection initialStates, - final Collection edges - ) { - return traverseEdges(initialStates.toArray(new State[0]), edges); - } - - public static Optional traverseEdges( - final State[] initialStates, - final Collection edges - ) { - if (edges.isEmpty()) { - return Optional.of(initialStates[0]); - } - - // The shortest path tree is used to prune dominated parallel states. For example, - // CAR_PICKUP can return both a CAR/WALK state after each traversal of which only - // the optimal states need to be continued. - var dominanceFunction = new DominanceFunctions.MinimumWeight(); - var spt = new ShortestPathTree<>(dominanceFunction); - for (State initialState : initialStates) { - spt.add(initialState); - } - - Vertex lastVertex = null; - var isArriveBy = initialStates[0].getRequest().arriveBy(); + public static Optional traverseEdges(final State s, final Collection edges) { + var state = s; for (Edge e : edges) { - var vertex = isArriveBy ? e.getToVertex() : e.getFromVertex(); - var fromStates = spt.getStates(vertex); - if (fromStates == null || fromStates.isEmpty()) { - return Optional.empty(); + var afterTraversal = e.traverse(state); + if (afterTraversal.length > 1) { + throw new IllegalStateException( + "Expected only a single state returned from edge %s but received %s".formatted( + e, + afterTraversal.length + ) + ); } - - for (State fromState : fromStates) { - var newToStates = e.traverse(fromState); - for (State newToState : newToStates) { - spt.add(newToState); - } + if (State.isEmpty(afterTraversal)) { + return Optional.empty(); + } else { + state = afterTraversal[0]; } - - lastVertex = isArriveBy ? e.getFromVertex() : e.getToVertex(); } - - return Optional.ofNullable(lastVertex).map(spt::getState); + return Optional.ofNullable(state); } } diff --git a/application/src/test/java/org/opentripplanner/_support/geometry/Coordinates.java b/application/src/test/java/org/opentripplanner/_support/geometry/Coordinates.java index 33569a34b2e..5a4526012c9 100644 --- a/application/src/test/java/org/opentripplanner/_support/geometry/Coordinates.java +++ b/application/src/test/java/org/opentripplanner/_support/geometry/Coordinates.java @@ -6,8 +6,6 @@ public class Coordinates { public static final Coordinate BERLIN = of(52.5212, 13.4105); public static final Coordinate BERLIN_BRANDENBURG_GATE = of(52.51627, 13.37770); - public static final Coordinate BERLIN_FERNSEHTURM = of(52.52084, 13.40934); - public static final Coordinate BERLIN_ADMIRALBRUCKE = of(52.49526, 13.415093); public static final Coordinate HAMBURG = of(53.5566, 10.0003); public static final Coordinate KONGSBERG_PLATFORM_1 = of(59.67216, 9.65107); public static final Coordinate BOSTON = of(42.36541, -71.06129); diff --git a/application/src/test/java/org/opentripplanner/generate/doc/framework/NodeAdapterHelper.java b/application/src/test/java/org/opentripplanner/generate/doc/framework/NodeAdapterHelper.java index 7df4429da15..a0eb9180c97 100644 --- a/application/src/test/java/org/opentripplanner/generate/doc/framework/NodeAdapterHelper.java +++ b/application/src/test/java/org/opentripplanner/generate/doc/framework/NodeAdapterHelper.java @@ -15,6 +15,7 @@ public class NodeAdapterHelper { new AnchorAbbreviation("od.", "osmDefaults."), new AnchorAbbreviation("lfp.", "localFileNamePatterns."), new AnchorAbbreviation("u.", "updaters."), + new AnchorAbbreviation("tpfm.", "transferParametersForMode."), new AnchorAbbreviation("0.", "[0]."), new AnchorAbbreviation("1.", "[1].") ); diff --git a/application/src/test/java/org/opentripplanner/graph_builder/module/DirectTransferGeneratorTest.java b/application/src/test/java/org/opentripplanner/graph_builder/module/DirectTransferGeneratorTest.java index 39d2f4b5684..c18200793df 100644 --- a/application/src/test/java/org/opentripplanner/graph_builder/module/DirectTransferGeneratorTest.java +++ b/application/src/test/java/org/opentripplanner/graph_builder/module/DirectTransferGeneratorTest.java @@ -8,8 +8,11 @@ import java.time.Duration; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -21,15 +24,18 @@ import org.opentripplanner.routing.algorithm.GraphRoutingTest; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.routing.api.request.StreetMode; +import org.opentripplanner.routing.api.request.framework.DurationForEnum; import org.opentripplanner.street.model.StreetTraversalPermission; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.vertex.StreetVertex; import org.opentripplanner.street.model.vertex.TransitStopVertex; import org.opentripplanner.transit.model._data.TimetableRepositoryForTest; import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.network.CarAccess; import org.opentripplanner.transit.model.network.StopPattern; import org.opentripplanner.transit.model.network.TripPattern; import org.opentripplanner.transit.model.site.StopLocation; +import org.opentripplanner.transit.model.timetable.ScheduledTripTimes; import org.opentripplanner.utils.tostring.ToStringBuilder; /** @@ -241,36 +247,145 @@ public void testMultipleRequestsWithPatterns() { ) .buildGraph(); + var walkTransfers = timetableRepository.findTransfers(StreetMode.WALK); + var bikeTransfers = timetableRepository.findTransfers(StreetMode.BIKE); + var carTransfers = timetableRepository.findTransfers(StreetMode.CAR); + assertTransfers( - timetableRepository.getAllPathTransfers(), + walkTransfers, + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 100, List.of(V0, V21), S21), + tr(S11, 100, List.of(V11, V21), S21) + ); + assertTransfers( + bikeTransfers, tr(S0, 100, List.of(V0, V11), S11), tr(S0, 100, List.of(V0, V21), S21), - tr(S11, 100, List.of(V11, V21), S21), tr(S11, 110, List.of(V11, V22), S22) ); + assertTransfers(carTransfers); } @Test - public void testPathTransfersWithModesForMultipleRequestsWithPatterns() { + public void testTransferOnIsolatedStations() { + var otpModel = model(true, false, true, false); + var graph = otpModel.graph(); + graph.hasStreets = false; + + var timetableRepository = otpModel.timetableRepository(); + var req = new RouteRequest(); + req.journey().transfer().setMode(StreetMode.WALK); + var transferRequests = List.of(req); + + new DirectTransferGenerator( + graph, + timetableRepository, + DataImportIssueStore.NOOP, + MAX_TRANSFER_DURATION, + transferRequests + ) + .buildGraph(); + + assertTrue(timetableRepository.getAllPathTransfers().isEmpty()); + } + + @Test + public void testRequestWithCarsAllowedPatterns() { + var reqCar = new RouteRequest(); + reqCar.journey().transfer().setMode(StreetMode.CAR); + + var transferRequests = List.of(reqCar); + + var otpModel = model(false, false, false, true); + var graph = otpModel.graph(); + graph.hasStreets = true; + var timetableRepository = otpModel.timetableRepository(); + + TransferParameters.Builder transferParametersBuilder = new TransferParameters.Builder(); + transferParametersBuilder.withCarsAllowedStopMaxTransferDuration(Duration.ofMinutes(60)); + transferParametersBuilder.withDisableDefaultTransfers(true); + Map transferParametersForMode = new HashMap<>(); + transferParametersForMode.put(StreetMode.CAR, transferParametersBuilder.build()); + + new DirectTransferGenerator( + graph, + timetableRepository, + DataImportIssueStore.NOOP, + MAX_TRANSFER_DURATION, + transferRequests, + transferParametersForMode + ) + .buildGraph(); + + assertTransfers( + timetableRepository.getAllPathTransfers(), + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 200, List.of(V0, V12), S12) + ); + } + + @Test + public void testRequestWithCarsAllowedPatternsWithDurationLimit() { + var reqCar = new RouteRequest(); + reqCar.journey().transfer().setMode(StreetMode.CAR); + + var transferRequests = List.of(reqCar); + + var otpModel = model(false, false, false, true); + var graph = otpModel.graph(); + graph.hasStreets = true; + var timetableRepository = otpModel.timetableRepository(); + + TransferParameters.Builder transferParametersBuilder = new TransferParameters.Builder(); + transferParametersBuilder.withCarsAllowedStopMaxTransferDuration(Duration.ofSeconds(10)); + transferParametersBuilder.withDisableDefaultTransfers(true); + Map transferParametersForMode = new HashMap<>(); + transferParametersForMode.put(StreetMode.CAR, transferParametersBuilder.build()); + + new DirectTransferGenerator( + graph, + timetableRepository, + DataImportIssueStore.NOOP, + MAX_TRANSFER_DURATION, + transferRequests, + transferParametersForMode + ) + .buildGraph(); + + assertTransfers(timetableRepository.getAllPathTransfers(), tr(S0, 100, List.of(V0, V11), S11)); + } + + @Test + public void testMultipleRequestsWithPatternsAndWithCarsAllowedPatterns() { var reqWalk = new RouteRequest(); reqWalk.journey().transfer().setMode(StreetMode.WALK); var reqBike = new RouteRequest(); reqBike.journey().transfer().setMode(StreetMode.BIKE); - var transferRequests = List.of(reqWalk, reqBike); + var reqCar = new RouteRequest(); + reqCar.journey().transfer().setMode(StreetMode.CAR); - TestOtpModel model = model(true); - var graph = model.graph(); + var transferRequests = List.of(reqWalk, reqBike, reqCar); + + var otpModel = model(true, false, false, true); + var graph = otpModel.graph(); graph.hasStreets = true; - var timetableRepository = model.timetableRepository(); + var timetableRepository = otpModel.timetableRepository(); + + TransferParameters.Builder transferParametersBuilder = new TransferParameters.Builder(); + transferParametersBuilder.withCarsAllowedStopMaxTransferDuration(Duration.ofMinutes(60)); + transferParametersBuilder.withDisableDefaultTransfers(true); + Map transferParametersForMode = new HashMap<>(); + transferParametersForMode.put(StreetMode.CAR, transferParametersBuilder.build()); new DirectTransferGenerator( graph, timetableRepository, DataImportIssueStore.NOOP, MAX_TRANSFER_DURATION, - transferRequests + transferRequests, + transferParametersForMode ) .buildGraph(); @@ -282,38 +397,194 @@ public void testPathTransfersWithModesForMultipleRequestsWithPatterns() { walkTransfers, tr(S0, 100, List.of(V0, V11), S11), tr(S0, 100, List.of(V0, V21), S21), - tr(S11, 100, List.of(V11, V21), S21) + tr(S11, 100, List.of(V11, V21), S21), + tr(S0, 200, List.of(V0, V12), S12), + tr(S11, 100, List.of(V11, V12), S12) ); assertTransfers( bikeTransfers, tr(S0, 100, List.of(V0, V11), S11), tr(S0, 100, List.of(V0, V21), S21), + tr(S11, 110, List.of(V11, V22), S22), + tr(S0, 200, List.of(V0, V12), S12), + tr(S11, 100, List.of(V11, V12), S12) + ); + assertTransfers( + carTransfers, + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 200, List.of(V0, V12), S12), + tr(S0, 100, List.of(V0, V21), S21) + ); + } + + @Test + public void testBikeRequestWithPatternsAndWithCarsAllowedPatterns() { + var reqBike = new RouteRequest(); + reqBike.journey().transfer().setMode(StreetMode.BIKE); + + var transferRequests = List.of(reqBike); + + var otpModel = model(true, false, false, true); + var graph = otpModel.graph(); + graph.hasStreets = true; + var timetableRepository = otpModel.timetableRepository(); + + TransferParameters.Builder transferParametersBuilder = new TransferParameters.Builder(); + transferParametersBuilder.withCarsAllowedStopMaxTransferDuration(Duration.ofMinutes(120)); + Map transferParametersForMode = new HashMap<>(); + transferParametersForMode.put(StreetMode.BIKE, transferParametersBuilder.build()); + + new DirectTransferGenerator( + graph, + timetableRepository, + DataImportIssueStore.NOOP, + Duration.ofSeconds(30), + transferRequests, + transferParametersForMode + ) + .buildGraph(); + + assertTransfers( + timetableRepository.getAllPathTransfers(), + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 100, List.of(V0, V21), S21), + tr(S0, 200, List.of(V0, V12), S12), + tr(S11, 110, List.of(V11, V22), S22), + tr(S11, 100, List.of(V11, V12), S12) + ); + } + + @Test + public void testBikeRequestWithPatternsAndWithCarsAllowedPatternsWithoutCarInTransferRequests() { + var reqBike = new RouteRequest(); + reqBike.journey().transfer().setMode(StreetMode.BIKE); + + var transferRequests = List.of(reqBike); + + var otpModel = model(true, false, false, true); + var graph = otpModel.graph(); + graph.hasStreets = true; + var timetableRepository = otpModel.timetableRepository(); + + new DirectTransferGenerator( + graph, + timetableRepository, + DataImportIssueStore.NOOP, + Duration.ofSeconds(30), + transferRequests + ) + .buildGraph(); + + assertTransfers( + timetableRepository.getAllPathTransfers(), + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 100, List.of(V0, V21), S21), tr(S11, 110, List.of(V11, V22), S22) ); - assertTransfers(carTransfers); } @Test - public void testTransferOnIsolatedStations() { - var otpModel = model(true, false, true); + public void testDisableDefaultTransfersForMode() { + var reqWalk = new RouteRequest(); + reqWalk.journey().transfer().setMode(StreetMode.WALK); + + var reqBike = new RouteRequest(); + reqBike.journey().transfer().setMode(StreetMode.BIKE); + + var reqCar = new RouteRequest(); + reqCar.journey().transfer().setMode(StreetMode.CAR); + + var transferRequests = List.of(reqWalk, reqBike, reqCar); + + var otpModel = model(true, false, false, true); var graph = otpModel.graph(); - graph.hasStreets = false; + graph.hasStreets = true; + var timetableRepository = otpModel.timetableRepository(); + + TransferParameters.Builder transferParametersBuilderBike = new TransferParameters.Builder(); + transferParametersBuilderBike.withDisableDefaultTransfers(true); + TransferParameters.Builder transferParametersBuilderCar = new TransferParameters.Builder(); + transferParametersBuilderCar.withDisableDefaultTransfers(true); + Map transferParametersForMode = new HashMap<>(); + transferParametersForMode.put(StreetMode.BIKE, transferParametersBuilderBike.build()); + transferParametersForMode.put(StreetMode.CAR, transferParametersBuilderCar.build()); + + new DirectTransferGenerator( + graph, + timetableRepository, + DataImportIssueStore.NOOP, + MAX_TRANSFER_DURATION, + transferRequests, + transferParametersForMode + ) + .buildGraph(); + + var walkTransfers = timetableRepository.findTransfers(StreetMode.WALK); + var bikeTransfers = timetableRepository.findTransfers(StreetMode.BIKE); + var carTransfers = timetableRepository.findTransfers(StreetMode.CAR); + + assertTransfers( + walkTransfers, + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 100, List.of(V0, V21), S21), + tr(S11, 100, List.of(V11, V21), S21), + tr(S0, 200, List.of(V0, V12), S12), + tr(S11, 100, List.of(V11, V12), S12) + ); + assertTransfers(bikeTransfers); + assertTransfers(carTransfers); + } + + @Test + public void testMaxTransferDurationForMode() { + var reqWalk = new RouteRequest(); + reqWalk.journey().transfer().setMode(StreetMode.WALK); + + var reqBike = new RouteRequest(); + reqBike.journey().transfer().setMode(StreetMode.BIKE); + var transferRequests = List.of(reqWalk, reqBike); + + var otpModel = model(true, false, false, true); + var graph = otpModel.graph(); + graph.hasStreets = true; var timetableRepository = otpModel.timetableRepository(); - var req = new RouteRequest(); - req.journey().transfer().setMode(StreetMode.WALK); - var transferRequests = List.of(req); + + TransferParameters.Builder transferParametersBuilderWalk = new TransferParameters.Builder(); + transferParametersBuilderWalk.withMaxTransferDuration(Duration.ofSeconds(100)); + TransferParameters.Builder transferParametersBuilderBike = new TransferParameters.Builder(); + transferParametersBuilderBike.withMaxTransferDuration(Duration.ofSeconds(21)); + Map transferParametersForMode = new HashMap<>(); + transferParametersForMode.put(StreetMode.WALK, transferParametersBuilderWalk.build()); + transferParametersForMode.put(StreetMode.BIKE, transferParametersBuilderBike.build()); new DirectTransferGenerator( graph, timetableRepository, DataImportIssueStore.NOOP, MAX_TRANSFER_DURATION, - transferRequests + transferRequests, + transferParametersForMode ) .buildGraph(); - assertTrue(timetableRepository.getAllPathTransfers().isEmpty()); + var walkTransfers = timetableRepository.findTransfers(StreetMode.WALK); + var bikeTransfers = timetableRepository.findTransfers(StreetMode.BIKE); + var carTransfers = timetableRepository.findTransfers(StreetMode.CAR); + + assertTransfers( + walkTransfers, + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 100, List.of(V0, V21), S21), + tr(S11, 100, List.of(V11, V21), S21), + tr(S11, 100, List.of(V11, V12), S12) + ); + assertTransfers( + bikeTransfers, + tr(S0, 100, List.of(V0, V11), S11), + tr(S0, 100, List.of(V0, V21), S21) + ); + assertTransfers(carTransfers); } private TestOtpModel model(boolean addPatterns) { @@ -321,13 +592,14 @@ private TestOtpModel model(boolean addPatterns) { } private TestOtpModel model(boolean addPatterns, boolean withBoardingConstraint) { - return model(addPatterns, withBoardingConstraint, false); + return model(addPatterns, withBoardingConstraint, false, false); } private TestOtpModel model( boolean addPatterns, boolean withBoardingConstraint, - boolean withNoTransfersOnStations + boolean withNoTransfersOnStations, + boolean addCarsAllowedPatterns ) { return modelOf( new Builder() { @@ -395,6 +667,76 @@ public void build() { .build() ); } + + if (addCarsAllowedPatterns) { + var agency = TimetableRepositoryForTest.agency("FerryAgency"); + + tripPattern( + TripPattern + .of(TimetableRepositoryForTest.id("TP3")) + .withRoute(route("R3", TransitMode.FERRY, agency)) + .withStopPattern(new StopPattern(List.of(st(S11), st(S21)))) + .withScheduledTimeTableBuilder(builder -> + builder.addTripTimes( + ScheduledTripTimes + .of() + .withTrip( + TimetableRepositoryForTest + .trip("carsAllowedTrip") + .withCarsAllowed(CarAccess.ALLOWED) + .build() + ) + .withDepartureTimes("00:00 01:00") + .build() + ) + ) + .build() + ); + + tripPattern( + TripPattern + .of(TimetableRepositoryForTest.id("TP4")) + .withRoute(route("R4", TransitMode.FERRY, agency)) + .withStopPattern(new StopPattern(List.of(st(S0), st(S13)))) + .withScheduledTimeTableBuilder(builder -> + builder.addTripTimes( + ScheduledTripTimes + .of() + .withTrip( + TimetableRepositoryForTest + .trip("carsAllowedTrip") + .withCarsAllowed(CarAccess.ALLOWED) + .build() + ) + .withDepartureTimes("00:00 01:00") + .build() + ) + ) + .build() + ); + + tripPattern( + TripPattern + .of(TimetableRepositoryForTest.id("TP5")) + .withRoute(route("R5", TransitMode.FERRY, agency)) + .withStopPattern(new StopPattern(List.of(st(S12), st(S22)))) + .withScheduledTimeTableBuilder(builder -> + builder.addTripTimes( + ScheduledTripTimes + .of() + .withTrip( + TimetableRepositoryForTest + .trip("carsAllowedTrip") + .withCarsAllowed(CarAccess.ALLOWED) + .build() + ) + .withDepartureTimes("00:00 01:00") + .build() + ) + ) + .build() + ); + } } } ); diff --git a/application/src/test/java/org/opentripplanner/street/search/state/EdgeTraverserTest.java b/application/src/test/java/org/opentripplanner/street/search/state/EdgeTraverserTest.java deleted file mode 100644 index a2cd7e61b62..00000000000 --- a/application/src/test/java/org/opentripplanner/street/search/state/EdgeTraverserTest.java +++ /dev/null @@ -1,191 +0,0 @@ -package org.opentripplanner.street.search.state; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.opentripplanner.street.model._data.StreetModelForTest.intersectionVertex; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.function.Function; -import org.junit.jupiter.api.Test; -import org.opentripplanner._support.geometry.Coordinates; -import org.opentripplanner.routing.api.request.StreetMode; -import org.opentripplanner.street.model.StreetTraversalPermission; -import org.opentripplanner.street.model._data.StreetModelForTest; -import org.opentripplanner.street.model.edge.Edge; -import org.opentripplanner.street.model.vertex.IntersectionVertex; -import org.opentripplanner.street.search.TraverseMode; -import org.opentripplanner.street.search.request.StreetSearchRequest; - -class EdgeTraverserTest { - - private static final IntersectionVertex BERLIN_V = intersectionVertex(Coordinates.BERLIN); - private static final IntersectionVertex BRANDENBURG_GATE_V = intersectionVertex( - Coordinates.BERLIN_BRANDENBURG_GATE - ); - private static final IntersectionVertex FERNSEHTURM_V = intersectionVertex( - Coordinates.BERLIN_FERNSEHTURM - ); - private static final IntersectionVertex ADMIRALBRUCKE_V = intersectionVertex( - Coordinates.BERLIN_ADMIRALBRUCKE - ); - - @Test - void emptyEdges() { - var request = StreetSearchRequest - .of() - .withStartTime(Instant.EPOCH) - .withMode(StreetMode.WALK) - .build(); - var initialStates = State.getInitialStates(Set.of(BERLIN_V), request); - var traversedState = EdgeTraverser.traverseEdges(initialStates, List.of()); - - assertSame(initialStates.iterator().next(), traversedState.get()); - } - - @Test - void failedTraversal() { - var edge = StreetModelForTest - .streetEdge(BERLIN_V, BRANDENBURG_GATE_V) - .toBuilder() - .withPermission(StreetTraversalPermission.NONE) - .buildAndConnect(); - - var edges = List.of(edge); - var request = StreetSearchRequest - .of() - .withStartTime(Instant.EPOCH) - .withMode(StreetMode.WALK) - .build(); - var initialStates = State.getInitialStates(Set.of(edge.getFromVertex()), request); - var traversedState = EdgeTraverser.traverseEdges(initialStates, edges); - - assertTrue(traversedState.isEmpty()); - } - - @Test - void withSingleState() { - var edge = StreetModelForTest - .streetEdge(BERLIN_V, BRANDENBURG_GATE_V) - .toBuilder() - .withPermission(StreetTraversalPermission.ALL) - .buildAndConnect(); - - var edges = List.of(edge); - var request = StreetSearchRequest - .of() - .withStartTime(Instant.EPOCH) - .withMode(StreetMode.WALK) - .build(); - var initialStates = State.getInitialStates(Set.of(edge.getFromVertex()), request); - var traversedState = EdgeTraverser.traverseEdges(initialStates, edges).get(); - - assertEquals(List.of(TraverseMode.WALK), stateValues(traversedState, State::getBackMode)); - assertEquals(1719, traversedState.getElapsedTimeSeconds()); - } - - @Test - void withSingleArriveByState() { - var edge = StreetModelForTest - .streetEdge(BERLIN_V, BRANDENBURG_GATE_V) - .toBuilder() - .withPermission(StreetTraversalPermission.ALL) - .buildAndConnect(); - - var edges = List.of(edge); - var request = StreetSearchRequest - .of() - .withStartTime(Instant.EPOCH) - .withMode(StreetMode.WALK) - .withArriveBy(true) - .build(); - var initialStates = State.getInitialStates(Set.of(edge.getToVertex()), request); - var traversedState = EdgeTraverser.traverseEdges(initialStates, edges).get(); - - assertSame(BERLIN_V, traversedState.getVertex()); - assertEquals(List.of(TraverseMode.WALK), stateValues(traversedState, State::getBackMode)); - assertEquals(1719, traversedState.getElapsedTimeSeconds()); - } - - @Test - void withMultipleStates() { - // CAR_PICKUP creates parallel walking and driving states - // This tests that of the two states (WALKING, CAR) the least weight (CAR) is selected - var edge = StreetModelForTest - .streetEdge(BERLIN_V, BRANDENBURG_GATE_V) - .toBuilder() - .withPermission(StreetTraversalPermission.ALL) - .buildAndConnect(); - - var edges = List.of(edge); - var request = StreetSearchRequest - .of() - .withStartTime(Instant.EPOCH) - .withMode(StreetMode.CAR_PICKUP) - .build(); - var initialStates = State.getInitialStates(Set.of(edge.getFromVertex()), request); - var traversedState = EdgeTraverser.traverseEdges(initialStates, edges).get(); - - assertEquals(List.of(TraverseMode.CAR), stateValues(traversedState, State::getBackMode)); - assertEquals(205, traversedState.getElapsedTimeSeconds()); - } - - @Test - void withDominatedStates() { - // CAR_PICKUP creates parallel walking and driving states - // This tests that the most optimal (walking and driving the last stretch) is found after - // discarding the initial driving state for edge1 - var edge1 = StreetModelForTest - .streetEdge(FERNSEHTURM_V, BERLIN_V) - .toBuilder() - .withPermission(StreetTraversalPermission.ALL) - .buildAndConnect(); - var edge2 = StreetModelForTest - .streetEdge(BERLIN_V, BRANDENBURG_GATE_V) - .toBuilder() - .withPermission(StreetTraversalPermission.PEDESTRIAN) - .buildAndConnect(); - var edge3 = StreetModelForTest - .streetEdge(BRANDENBURG_GATE_V, ADMIRALBRUCKE_V) - .toBuilder() - .withPermission(StreetTraversalPermission.ALL) - .buildAndConnect(); - - var edges = List.of(edge1, edge2, edge3); - var request = StreetSearchRequest - .of() - .withStartTime(Instant.EPOCH) - .withMode(StreetMode.CAR_PICKUP) - .build(); - var initialStates = State.getInitialStates(Set.of(edge1.getFromVertex()), request); - var traversedState = EdgeTraverser.traverseEdges(initialStates, edges).get(); - - assertEquals( - List.of(88.103, 2286.029, 3444.28), - stateValues( - traversedState, - state -> state.getBackEdge() != null ? state.getBackEdge().getDistanceMeters() : null - ) - ); - assertEquals( - List.of(TraverseMode.WALK, TraverseMode.WALK, TraverseMode.CAR), - stateValues(traversedState, State::getBackMode) - ); - assertEquals(2169, traversedState.getElapsedTimeSeconds()); - } - - private List stateValues(State state, Function extractor) { - var values = new ArrayList(); - while (state != null) { - var value = extractor.apply(state); - if (value != null) { - values.add(value); - } - state = state.getBackState(); - } - return values.reversed(); - } -} diff --git a/application/src/test/java/org/opentripplanner/street/search/state/TestStateBuilder.java b/application/src/test/java/org/opentripplanner/street/search/state/TestStateBuilder.java index 7ea56f66145..9605d950ae0 100644 --- a/application/src/test/java/org/opentripplanner/street/search/state/TestStateBuilder.java +++ b/application/src/test/java/org/opentripplanner/street/search/state/TestStateBuilder.java @@ -202,7 +202,7 @@ public TestStateBuilder elevator() { currentState = EdgeTraverser - .traverseEdges(new State[] { currentState }, List.of(link, boardEdge, hopEdge, alightEdge)) + .traverseEdges(currentState, List.of(link, boardEdge, hopEdge, alightEdge)) .orElseThrow(); return this; } diff --git a/application/src/test/resources/standalone/config/build-config.json b/application/src/test/resources/standalone/config/build-config.json index 11ea4a36b2e..9a32b3bd892 100644 --- a/application/src/test/resources/standalone/config/build-config.json +++ b/application/src/test/resources/standalone/config/build-config.json @@ -82,5 +82,15 @@ "emissions": { "carAvgCo2PerKm": 170, "carAvgOccupancy": 1.3 + }, + "transferParametersForMode": { + "CAR": { + "disableDefaultTransfers": true, + "carsAllowedStopMaxTransferDuration": "3h" + }, + "BIKE": { + "maxTransferDuration": "30m", + "carsAllowedStopMaxTransferDuration": "3h" + } } } diff --git a/application/src/test/resources/standalone/config/router-config.json b/application/src/test/resources/standalone/config/router-config.json index 77c67d85742..c20a5fbc6ca 100644 --- a/application/src/test/resources/standalone/config/router-config.json +++ b/application/src/test/resources/standalone/config/router-config.json @@ -99,6 +99,9 @@ "BIKE_RENTAL": "20m" }, "maxStopCount": 500, + "maxStopCountForMode": { + "CAR": 0 + }, "penalty": { "FLEXIBLE": { "timePenalty": "2m + 1.1t", diff --git a/client/.prettierignore b/client/.prettierignore index a96d61e932a..ba27ff090d8 100644 --- a/client/.prettierignore +++ b/client/.prettierignore @@ -1,3 +1,4 @@ node_modules/ output/ src/gql/ +src/static/query/tripQuery.tsx diff --git a/client/codegen-preprocess.ts b/client/codegen-preprocess.ts new file mode 100644 index 00000000000..ec1b1dfce0d --- /dev/null +++ b/client/codegen-preprocess.ts @@ -0,0 +1,16 @@ +import type { CodegenConfig } from '@graphql-codegen/cli'; + +import * as path from 'node:path'; + +const config: CodegenConfig = { + overwrite: true, + schema: '../application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql', + documents: 'src/**/*.{ts,tsx}', + generates: { + 'src/static/query/tripQuery.tsx': { + plugins: [path.resolve(__dirname, './src/util/generate-queries.cjs')], + }, + }, +}; + +export default config; diff --git a/client/package-lock.json b/client/package-lock.json index 86d94c08ae1..71c57f7262a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,7 +17,8 @@ "react": "19.0.0", "react-bootstrap": "2.10.7", "react-dom": "19.0.0", - "react-map-gl": "7.1.8" + "react-map-gl": "7.1.8", + "react-select": "5.9.0" }, "devDependencies": { "@eslint/compat": "1.2.5", @@ -32,7 +33,7 @@ "@vitejs/plugin-react": "4.3.4", "@vitest/coverage-v8": "3.0.2", "eslint": "9.18.0", - "eslint-config-prettier": "9.1.0", + "eslint-config-prettier": "10.0.1", "eslint-plugin-import": "2.31.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.3", @@ -43,7 +44,7 @@ "prettier": "3.4.2", "typescript": "5.7.3", "typescript-eslint": "8.19.1", - "vite": "6.0.7", + "vite": "6.0.9", "vitest": "3.0.2" } }, @@ -235,7 +236,6 @@ "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -288,7 +288,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", - "dev": true, "dependencies": { "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", @@ -366,7 +365,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -447,7 +445,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -456,7 +453,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -487,7 +483,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", - "dev": true, "dependencies": { "@babel/types": "^7.26.3" }, @@ -975,7 +970,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", @@ -989,7 +983,6 @@ "version": "7.26.4", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.3", @@ -1007,7 +1000,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, "engines": { "node": ">=4" } @@ -1016,7 +1008,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", - "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -1150,6 +1141,114 @@ "node": ">=18" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "node_modules/@envelop/core": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.0.2.tgz", @@ -1727,6 +1826,28 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@googlemaps/polyline-codec": { "version": "1.0.28", "resolved": "https://registry.npmjs.org/@googlemaps/polyline-codec/-/polyline-codec-1.0.28.tgz", @@ -2892,7 +3013,6 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -2906,7 +3026,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -2915,7 +3034,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -2923,14 +3041,12 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3873,6 +3989,11 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", @@ -4724,6 +4845,43 @@ "node": ">= 0.4" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-plugin-syntax-trailing-function-commas": { "version": "7.0.0-beta.0", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz", @@ -4999,7 +5157,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -5476,7 +5633,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -5736,7 +5892,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -5961,7 +6116,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -6030,12 +6184,13 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", + "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "dev": true, + "license": "MIT", "bin": { - "eslint-config-prettier": "bin/cli.js" + "eslint-config-prettier": "build/bin/cli.js" }, "peerDependencies": { "eslint": ">=7.0.0" @@ -6527,6 +6682,11 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6637,7 +6797,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7095,7 +7254,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -7113,6 +7271,19 @@ "tslib": "^2.0.3" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -7210,7 +7381,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7226,7 +7396,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -7367,8 +7536,7 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "node_modules/is-async-function": { "version": "2.0.0", @@ -7432,7 +7600,6 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "dependencies": { "hasown": "^2.0.2" }, @@ -8002,7 +8169,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -8019,8 +8185,7 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -8135,8 +8300,7 @@ "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/listr2": { "version": "4.0.5", @@ -8425,6 +8589,11 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8526,8 +8695,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/murmurhash-js": { "version": "1.0.0", @@ -8935,7 +9103,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -8961,7 +9128,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -9037,8 +9203,7 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-root": { "version": "0.1.1", @@ -9087,7 +9252,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -9123,8 +9287,7 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -9431,6 +9594,26 @@ "node": ">=0.10.0" } }, + "node_modules/react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -9556,7 +9739,6 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", @@ -10076,6 +10258,14 @@ "node": ">=0.10.0" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -10358,6 +10548,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -10382,7 +10577,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -10948,6 +11142,19 @@ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", "dev": true }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10964,9 +11171,9 @@ } }, "node_modules/vite": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz", - "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==", + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.9.tgz", + "integrity": "sha512-MSgUxHcaXLtnBPktkbUSoQUANApKYuxZ6DrbVENlIorbhL2dZydTLaZ01tjUoE3szeFzlFk9ANOKk0xurh4MKA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/client/package.json b/client/package.json index c25ddc14212..2322dcf60af 100644 --- a/client/package.json +++ b/client/package.json @@ -14,6 +14,8 @@ "preview": "vite preview", "prebuild": "npm run codegen && npm run lint && npm run check-format", "predev": "npm run codegen", + "codegen-preprocess": "graphql-codegen --config codegen-preprocess.ts", + "precodegen": "npm run codegen-preprocess", "codegen": "graphql-codegen --config codegen.ts" }, "dependencies": { @@ -26,7 +28,8 @@ "react": "19.0.0", "react-bootstrap": "2.10.7", "react-dom": "19.0.0", - "react-map-gl": "7.1.8" + "react-map-gl": "7.1.8", + "react-select": "5.9.0" }, "devDependencies": { "@eslint/compat": "1.2.5", @@ -41,7 +44,7 @@ "@vitejs/plugin-react": "4.3.4", "@vitest/coverage-v8": "3.0.2", "eslint": "9.18.0", - "eslint-config-prettier": "9.1.0", + "eslint-config-prettier": "10.0.1", "eslint-plugin-import": "2.31.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.3", @@ -52,7 +55,7 @@ "prettier": "3.4.2", "typescript": "5.7.3", "typescript-eslint": "8.19.1", - "vite": "6.0.7", + "vite": "6.0.9", "vitest": "3.0.2" } } diff --git a/client/src/components/ItineraryList/ItineraryListContainer.tsx b/client/src/components/ItineraryList/ItineraryListContainer.tsx index b474d2eb5ec..affff253388 100644 --- a/client/src/components/ItineraryList/ItineraryListContainer.tsx +++ b/client/src/components/ItineraryList/ItineraryListContainer.tsx @@ -26,39 +26,46 @@ export function ItineraryListContainer({ const timeZone = useContext(TimeZoneContext); return ( -
- - setSelectedTripPatternIndex(parseInt(eventKey as string))} - > - {tripQueryResult && - tripQueryResult.trip.tripPatterns.map((tripPattern, itineraryIndex) => ( - - - - - - - - - ))} - +
+ <> +
Itinerary results
+
+ +
+ setSelectedTripPatternIndex(parseInt(eventKey as string))} + > + {tripQueryResult && + tripQueryResult.trip.tripPatterns.map((tripPattern, itineraryIndex) => ( + + + + + + + + + ))} + + + + {/* Time Zone Info */}
All times in {timeZone}
diff --git a/client/src/components/ItineraryList/ItineraryPaginationControl.tsx b/client/src/components/ItineraryList/ItineraryPaginationControl.tsx index dc197a2451e..2e3e335cee0 100644 --- a/client/src/components/ItineraryList/ItineraryPaginationControl.tsx +++ b/client/src/components/ItineraryList/ItineraryPaginationControl.tsx @@ -12,7 +12,7 @@ export function ItineraryPaginationControl({ loading: boolean; }) { return ( -
+
+ + {/* Sidebar */} +
+ {isSidebarOpen && activeContent === 'debugLayer' && ( + + )} +
+
+ ); + } +} + +export default RightMenu; diff --git a/client/src/components/SearchBar/DepartureArrivalSelect.tsx b/client/src/components/SearchBar/DepartureArrivalSelect.tsx index b6a92cdd495..a94516dfc3b 100644 --- a/client/src/components/SearchBar/DepartureArrivalSelect.tsx +++ b/client/src/components/SearchBar/DepartureArrivalSelect.tsx @@ -24,6 +24,7 @@ export function DepartureArrivalSelect({ size="sm" onChange={(e) => (e.target.value === 'arrival' ? onChange(true) : onChange(false))} value={tripQueryVariables.arriveBy ? 'arrival' : 'departure'} + style={{ verticalAlign: 'bottom' }} > diff --git a/client/src/components/SearchBar/InputFieldsSection.tsx b/client/src/components/SearchBar/InputFieldsSection.tsx new file mode 100644 index 00000000000..234626d0c76 --- /dev/null +++ b/client/src/components/SearchBar/InputFieldsSection.tsx @@ -0,0 +1,82 @@ +import { Button, ButtonGroup, Spinner } from 'react-bootstrap'; +import { TripQueryVariables } from '../../gql/graphql.ts'; +import { LocationInputField } from './LocationInputField.tsx'; +import { SwapLocationsButton } from './SwapLocationsButton.tsx'; +import { DepartureArrivalSelect } from './DepartureArrivalSelect.tsx'; +import { DateTimeInputField } from './DateTimeInputField.tsx'; +import { SearchWindowInput } from './SearchWindowInput.tsx'; +import { AccessSelect } from './AccessSelect.tsx'; +import { EgressSelect } from './EgressSelect.tsx'; +import { DirectModeSelect } from './DirectModeSelect.tsx'; +import { TransitModeSelect } from './TransitModeSelect.tsx'; +import { NumTripPatternsInput } from './NumTripPatternsInput.tsx'; +import { ItineraryFilterDebugSelect } from './ItineraryFilterDebugSelect.tsx'; +import GraphiQLRouteButton from './GraphiQLRouteButton.tsx'; + +type InputFieldsSectionProps = { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; + onRoute: () => void; + loading: boolean; +}; + +export function InputFieldsSection({ + tripQueryVariables, + setTripQueryVariables, + onRoute, + loading, +}: InputFieldsSectionProps) { + return ( +
+
+ + + +
+
+ + +
+
+ + +
+
+ + + + +
+ + +
+ + + + +
+
+ ); +} diff --git a/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx b/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx index 6f479290947..5c781d93e7d 100644 --- a/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx +++ b/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx @@ -20,10 +20,10 @@ export function ItineraryFilterDebugSelect({ onChange={(e) => { setTripQueryVariables({ ...tripQueryVariables, - itineraryFiltersDebug: e.target.value as ItineraryFilterDebugProfile, + itineraryFilters: { debug: e.target.value as ItineraryFilterDebugProfile }, }); }} - value={tripQueryVariables.itineraryFiltersDebug || 'not_selected'} + value={tripQueryVariables.itineraryFilters?.debug || 'not_selected'} > {Object.values(ItineraryFilterDebugProfile).map((debugProfile) => ( diff --git a/client/src/components/SearchBar/LogoSection.tsx b/client/src/components/SearchBar/LogoSection.tsx new file mode 100644 index 00000000000..087263e8167 --- /dev/null +++ b/client/src/components/SearchBar/LogoSection.tsx @@ -0,0 +1,30 @@ +import { useState, useRef } from 'react'; +import Navbar from 'react-bootstrap/Navbar'; +import { ServerInfo } from '../../gql/graphql.ts'; +import { ServerInfoTooltip } from './ServerInfoTooltip.tsx'; +import logo from '../../static/img/otp-logo.svg'; + +type LogoSectionProps = { + serverInfo?: ServerInfo; +}; + +export function LogoSection({ serverInfo }: LogoSectionProps) { + const [showServerInfo, setShowServerInfo] = useState(false); + const target = useRef(null); + + return ( +
+ setShowServerInfo((v) => !v)}> +
+ + OTP Debug + {showServerInfo && } +
+
+
+
Version: {serverInfo?.version}
+
Time zone: {serverInfo?.internalTransitModelTimeZone}
+
+
+ ); +} diff --git a/client/src/components/SearchBar/NumTripPatternsInput.tsx b/client/src/components/SearchBar/NumTripPatternsInput.tsx index 360ce1c2c73..ae33e2f4e19 100644 --- a/client/src/components/SearchBar/NumTripPatternsInput.tsx +++ b/client/src/components/SearchBar/NumTripPatternsInput.tsx @@ -11,7 +11,7 @@ export function NumTripPatternsInput({ return ( - Num. results + # setTripQueryVariables({ diff --git a/client/src/components/SearchInput/ArgumentTooltip.tsx b/client/src/components/SearchInput/ArgumentTooltip.tsx new file mode 100644 index 00000000000..efb7a11dc19 --- /dev/null +++ b/client/src/components/SearchInput/ArgumentTooltip.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import infoIcon from '../../static/img/help-info-solid.svg'; +import inputIcon from '../../static/img/input.svg'; +import durationIcon from '../../static/img/lap-timer.svg'; +import { ResolvedType } from './useTripArgs.ts'; + +interface ArgumentTooltipProps { + defaultValue?: string | number | boolean | object | null | undefined; + type?: ResolvedType; +} + +const ArgumentTooltip: React.FC = ({ defaultValue, type }) => { + return ( + + {defaultValue !== undefined && defaultValue !== null && ( + + {'Info'} + + )} + {type !== undefined && type !== null && type.subtype === 'DoubleFunction' && ( + + {'Info'} + + )} + {type !== undefined && type !== null && type.subtype === 'Duration' && ( + + {'Info'} + + )} + + ); +}; + +export default ArgumentTooltip; diff --git a/client/src/components/SearchInput/ResetButton.tsx b/client/src/components/SearchInput/ResetButton.tsx new file mode 100644 index 00000000000..42e9d9e3d6b --- /dev/null +++ b/client/src/components/SearchInput/ResetButton.tsx @@ -0,0 +1,34 @@ +import { TripQueryVariables } from '../../gql/graphql.ts'; +import { excludedArguments } from './excluded-arguments.ts'; +import { getNestedValue, setNestedValue } from './nestedUtils.tsx'; +import React from 'react'; + +interface ResetButtonProps { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; +} + +const ResetButton: React.FC = ({ tripQueryVariables, setTripQueryVariables }) => { + function handleReset(): void { + // Start with an empty object (or partially typed) + let newVars: TripQueryVariables = {} as TripQueryVariables; + + // For each path in our excluded set, copy over that value (if any) + excludedArguments.forEach((excludedPath) => { + const value = getNestedValue(tripQueryVariables, excludedPath); + if (value !== undefined) { + newVars = setNestedValue(newVars, excludedPath, value) as TripQueryVariables; + } + }); + + setTripQueryVariables(newVars); + } + + return ( + + ); +}; + +export default ResetButton; diff --git a/client/src/components/SearchInput/Sidebar.tsx b/client/src/components/SearchInput/Sidebar.tsx new file mode 100644 index 00000000000..b362ad4720c --- /dev/null +++ b/client/src/components/SearchInput/Sidebar.tsx @@ -0,0 +1,53 @@ +import React, { useState, ReactNode } from 'react'; +import tripIcon from '../../static/img/route.svg'; +import filterIcon from '../../static/img/filter.svg'; +import jsonIcon from '../../static/img/json.svg'; + +interface SidebarProps { + children: ReactNode | ReactNode[]; +} + +const Sidebar: React.FC = ({ children }) => { + const [activeIndex, setActiveIndex] = useState(0); + + // Function to return the appropriate image based on the index + const getIconForIndex = (index: number) => { + switch (index) { + case 0: + return Itineray list; + case 1: + return Filters; + case 2: + return Filters; + default: + return null; + } + }; + + // Ensure children is always an array and filter out invalid children (null, undefined) + const childArray = React.Children.toArray(children).filter((child) => React.isValidElement(child)); + + return ( +
+ {/* Sidebar Navigation Buttons */} +
+ {childArray.map((_, index) => ( +
setActiveIndex(index)} + > + {getIconForIndex(index)} +
+ ))} +
+ + {/* Content Area */} +
+ {childArray.map((child, index) => (index === activeIndex ?
{child}
: null))} +
+
+ ); +}; + +export default Sidebar; diff --git a/client/src/components/SearchInput/TripArguments.ts b/client/src/components/SearchInput/TripArguments.ts new file mode 100644 index 00000000000..fbf31b5cbf8 --- /dev/null +++ b/client/src/components/SearchInput/TripArguments.ts @@ -0,0 +1,20 @@ +export interface TripArguments { + trip: { + arguments: { + [key: string]: Argument; + }; + }; +} + +export interface Argument { + type: TypeDescriptor; + defaultValue?: string; +} + +export type TypeDescriptor = ScalarType | NestedObject; + +export type ScalarType = 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Duration'; + +export interface NestedObject { + [key: string]: Argument | string[]; // Allows for nested objects or enum arrays +} diff --git a/client/src/components/SearchInput/TripQueryArguments.tsx b/client/src/components/SearchInput/TripQueryArguments.tsx new file mode 100644 index 00000000000..3abcc19edc2 --- /dev/null +++ b/client/src/components/SearchInput/TripQueryArguments.tsx @@ -0,0 +1,409 @@ +import React, { JSX, useEffect, useState } from 'react'; +import { useTripSchema } from './useTripSchema.ts'; +import { TripQueryVariables } from '../../gql/graphql'; +import { getNestedValue, setNestedValue } from './nestedUtils'; +import ArgumentTooltip from './ArgumentTooltip.tsx'; +import { excludedArguments } from './excluded-arguments.ts'; +import { ResolvedType } from './useTripArgs.ts'; +import ResetButton from './ResetButton.tsx'; +import { DefaultValue, extractAllArgs, formatArgumentName, ProcessedArgument } from './extractArgs.ts'; + +interface TripQueryArgumentsProps { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; +} + +const TripQueryArguments: React.FC = ({ tripQueryVariables, setTripQueryVariables }) => { + const [argumentsList, setArgumentsList] = useState([]); + const [expandedArguments, setExpandedArguments] = useState>({}); + const [searchText] = useState(''); + + const { tripArgs, loading, error } = useTripSchema(); + + useEffect(() => { + if (!tripArgs) return; // Don't run if the data isn't loaded yet + if (loading || error) return; // Optionally handle error/loading + + const extractedArgs = extractAllArgs(tripArgs.trip.arguments); + setArgumentsList(extractedArgs); + }, [tripArgs, loading, error]); + + function normalizePathForList(path: string): string { + // Replace numeric segments with `*` + return path.replace(/\.\d+/g, '.*'); + } + + function handleInputChange(path: string, value: DefaultValue | undefined): void { + const normalizedPath = normalizePathForList(path); + const argumentConfig = argumentsList.find((arg) => arg.path === normalizedPath); + + if (!argumentConfig) { + console.error(`No matching argumentConfig found for path: ${path}`); + return; + } + + // Handle comma-separated input for string arrays + if ( + argumentConfig.type.subtype != null && + ['String', 'DoubleFunction', 'ID', 'Duration'].includes(argumentConfig.type.subtype) && + argumentConfig.isList + ) { + if (typeof value === 'string') { + // Convert comma-separated string into an array + const idsArray = value.split(',').map((id) => id.trim()); + + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, path, idsArray) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, path); + setTripQueryVariables(updatedTripQueryVariables); + return; + } + } + + // Default handling for other cases + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, path, value) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, path); + setTripQueryVariables(updatedTripQueryVariables); + } + + function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + /** + * Recursively removes empty arrays/objects from `variables` based on a path. + * Returns the updated variables. + */ + function cleanUpParentIfEmpty(variables: TripQueryVariables, path: string): TripQueryVariables { + if (!path.includes('.')) { + const topValue = getNestedValue(variables, path); + + if (Array.isArray(topValue) && topValue.length === 0) { + // Create a shallow copy as a flexible object: + const copy = { ...variables } as Record; + // Remove the property: + delete copy[path]; + return copy as TripQueryVariables; + } + + // If it's a plain object and all keys are undefined/null or empty, remove it + if (isPlainObject(topValue)) { + const allKeysEmpty = Object.keys(topValue).every((key) => { + const childVal = (topValue as Record)[key]; + return childVal === undefined || childVal === null || (Array.isArray(childVal) && childVal.length === 0); + }); + + if (allKeysEmpty) { + const copy = { ...variables } as Record; + delete copy[path]; + return copy as TripQueryVariables; + } + } + + return variables; // Otherwise leave it as is + } + + // For nested paths + const pathParts = path.split('.'); + for (let i = pathParts.length - 1; i > 0; i--) { + const parentPath = pathParts.slice(0, i).join('.'); + const parentValue = getNestedValue(variables, parentPath); + + if (parentValue == null) { + // Already null or undefined, nothing to do + continue; + } + + if (Array.isArray(parentValue)) { + // If the parent array is now empty, remove it + if (parentValue.length === 0) { + variables = setNestedValue(variables, parentPath, undefined) as TripQueryVariables; + } + } else if (isPlainObject(parentValue)) { + // If all child values are null/undefined or empty, remove the parent + const allKeysEmpty = Object.keys(parentValue).every((key) => { + const childPath = `${parentPath}.${key}`; + const childValue = getNestedValue(variables, childPath); + return ( + childValue === undefined || childValue === null || (Array.isArray(childValue) && childValue.length === 0) + ); + }); + + if (allKeysEmpty) { + variables = setNestedValue(variables, parentPath, undefined) as TripQueryVariables; + } + } + } + + return variables; + } + + function toggleExpand(path: string): void { + setExpandedArguments((prev) => ({ + ...prev, + [path]: !prev[path], + })); + } + + const filteredArgumentsList = argumentsList + .filter(({ path }) => formatArgumentName(path).toLowerCase().includes(searchText.toLowerCase())) + .filter(({ path }) => !excludedArguments.has(path)); + + /** + * Renders multiple InputObjects within an array. Each item in the array + * is shown with an expand/collapse toggle and a remove button. + */ + function renderListOfInputObjects( + listPath: string, + allArgs: ProcessedArgument[], + level: number, + type: ResolvedType, + ): React.JSX.Element { + // We assume getNestedValue returns unknown; cast to an array if needed + const arrayVal = (getNestedValue(tripQueryVariables, listPath) ?? []) as unknown[]; + + // You can customize this if you have a better naming scheme + const typeName = type.name; + + return ( +
+ {arrayVal.map((_, index) => { + const itemPath = `${listPath}.${index}`; + + // Replace the `.*` placeholder with the actual index + const itemNestedArgs = allArgs + .filter((arg) => arg.path.startsWith(`${listPath}.*.`) && arg.path !== `${listPath}.*`) + .map((arg) => ({ + ...arg, + path: arg.path.replace(`${listPath}.*`, itemPath), + })); + + const immediateNestedArgs = itemNestedArgs.filter( + (arg) => arg.path.split('.').length === itemPath.split('.').length + 1, + ); + + const isExpandedItem = expandedArguments[itemPath]; + + return ( +
+ toggleExpand(itemPath)}> + {isExpandedItem ? '▼ ' : '▶ '} [#{index + 1}] + + + + {isExpandedItem && ( +
+ {renderArgumentInputs(immediateNestedArgs, level + 1, itemNestedArgs)} +
+ )} +
+ ); + })} + +
+ ); + } + + function handleAddItem(listPath: string): void { + const currentValue = (getNestedValue(tripQueryVariables, listPath) ?? []) as unknown[]; + const newValue = [...currentValue, {}]; + const updatedTripQueryVariables = setNestedValue(tripQueryVariables, listPath, newValue) as TripQueryVariables; + setTripQueryVariables(updatedTripQueryVariables); + } + + function handleRemoveItem(listPath: string, index: number): void { + const currentValue = (getNestedValue(tripQueryVariables, listPath) ?? []) as unknown[]; + const newValue = currentValue.filter((_, i) => i !== index); + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, listPath, newValue) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, listPath); + setTripQueryVariables(updatedTripQueryVariables); + } + + function handleRemoveArgument(path: string): void { + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, path, undefined) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, path); + setTripQueryVariables(updatedTripQueryVariables); + } + + function renderArgumentInputs(args: ProcessedArgument[], level: number, allArgs: ProcessedArgument[]): JSX.Element[] { + return args.map(({ path, type, defaultValue, enumValues, isComplex, isList }) => { + const isExpanded = expandedArguments[path]; + const currentDepth = path.split('.').length; + const nestedArgs = allArgs.filter((arg) => { + const argDepth = arg.path.split('.').length; + return arg.path.startsWith(`${path}.`) && arg.path !== path && argDepth === currentDepth + 1; + }); + + const nestedLevel = level + 1; + + // Various input renderings depending on subtype + return ( +
+ {isComplex ? ( +
+ toggleExpand(path)}> + {isExpanded ? '▼ ' : '▶ '} {formatArgumentName(path)} + + {isExpanded && isList ? ( +
{renderListOfInputObjects(path, allArgs, nestedLevel, type)}
+ ) : isExpanded ? ( + renderArgumentInputs(nestedArgs, nestedLevel, allArgs) + ) : null} +
+ ) : ( +
+ + {type.subtype === 'Boolean' && + (() => { + const currentValue = getNestedValue(tripQueryVariables, path) as boolean | undefined; + const isInUse = currentValue !== undefined; + return ( + + handleInputChange(path, e.target.checked)} + /> + {isInUse && ( + handleRemoveArgument(path)} className="remove-argument"> + x + + )} + + ); + })()} + + {type.subtype != null && + ['String', 'DoubleFunction', 'ID', 'Duration'].includes(type.subtype) && + isList && ( + { + const currentValue = getNestedValue(tripQueryVariables, path); + return Array.isArray(currentValue) ? currentValue.join(', ') : ''; + })()} + onChange={(e) => handleInputChange(path, e.target.value)} + placeholder="Comma-separated list" + /> + )} + + {type.subtype != null && + ['String', 'DoubleFunction', 'ID', 'Duration'].includes(type.subtype) && + !isList && ( + handleInputChange(path, e.target.value || undefined)} + /> + )} + + {type.subtype === 'Int' && ( + { + const val = parseInt(e.target.value, 10); + handleInputChange(path, Number.isNaN(val) ? undefined : val); + }} + /> + )} + + {type.subtype === 'Float' && ( + { + const val = parseFloat(e.target.value); + handleInputChange(path, Number.isNaN(val) ? undefined : val); + }} + /> + )} + + {type.subtype === 'DateTime' && ( + { + const newValue = e.target.value ? new Date(e.target.value).toISOString() : undefined; + handleInputChange(path, newValue); + }} + /> + )} + + {type.type === 'Enum' && enumValues && isList && ( + + )} + + {type.type === 'Enum' && enumValues && !isList && ( + + )} +
+ )} +
+ ); + }); + } + + return ( +
+
+ Filters + +
+ {filteredArgumentsList.length === 0 ? ( +

No arguments found.

+ ) : ( +
+ {renderArgumentInputs( + // Top-level arguments have a path depth of 1 + filteredArgumentsList.filter((arg) => arg.path.split('.').length === 1), + 0, + filteredArgumentsList, + )} +
+ )} +
+ ); +}; + +export default TripQueryArguments; diff --git a/client/src/components/SearchInput/TripSchemaContext.tsx b/client/src/components/SearchInput/TripSchemaContext.tsx new file mode 100644 index 00000000000..f769b33855d --- /dev/null +++ b/client/src/components/SearchInput/TripSchemaContext.tsx @@ -0,0 +1,10 @@ +import { createContext } from 'react'; +import type { TripArgsRepresentation } from './useTripArgs'; + +export interface TripSchemaContextValue { + tripArgs: TripArgsRepresentation | null; + loading: boolean; + error: string | null; +} + +export const TripSchemaContext = createContext(undefined); diff --git a/client/src/components/SearchInput/TripSchemaProvider.tsx b/client/src/components/SearchInput/TripSchemaProvider.tsx new file mode 100644 index 00000000000..a8a3f9b30a8 --- /dev/null +++ b/client/src/components/SearchInput/TripSchemaProvider.tsx @@ -0,0 +1,47 @@ +import React, { useEffect, useState } from 'react'; +import { TripSchemaContext, TripSchemaContextValue } from './TripSchemaContext'; +import { fetchTripArgs, TripArgsRepresentation } from './useTripArgs'; + +interface TripSchemaProviderProps { + endpoint: string; + children: React.ReactNode; +} + +export function TripSchemaProvider({ endpoint, children }: TripSchemaProviderProps) { + const [tripArgs, setTripArgs] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + async function loadSchema() { + setLoading(true); + setError(null); + try { + const result = await fetchTripArgs(endpoint); + if (isMounted) { + setTripArgs(result); + } + } catch (err) { + console.error('Error loading trip arguments:', err); + if (isMounted) { + setError('Failed to load trip schema'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + loadSchema(); + return () => { + isMounted = false; + }; + }, [endpoint]); + + const value: TripSchemaContextValue = { tripArgs, loading, error }; + + return {children}; +} diff --git a/client/src/components/SearchInput/ViewArgumentsRaw.tsx b/client/src/components/SearchInput/ViewArgumentsRaw.tsx new file mode 100644 index 00000000000..c08fd833a65 --- /dev/null +++ b/client/src/components/SearchInput/ViewArgumentsRaw.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { TripQueryVariables } from '../../gql/graphql.ts'; +import ResetButton from './ResetButton.tsx'; + +interface ViewArgumentsRawProps { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; +} + +const ViewArgumentsRaw: React.FC = ({ tripQueryVariables, setTripQueryVariables }) => { + return ( +
+
+ Arguments raw + +
+ +
{JSON.stringify(tripQueryVariables, null, 2)}
+
+ ); +}; + +export default ViewArgumentsRaw; diff --git a/client/src/components/SearchInput/excluded-arguments.ts b/client/src/components/SearchInput/excluded-arguments.ts new file mode 100644 index 00000000000..bef4f1f6075 --- /dev/null +++ b/client/src/components/SearchInput/excluded-arguments.ts @@ -0,0 +1,12 @@ +export const excludedArguments = new Set([ + 'numTripPatterns', + 'arriveBy', + 'from', + 'to', + 'dateTime', + 'searchWindow', + 'modes.accessMode', + 'modes.directMode', + 'modes.egressMode', + // Add every full path you want to exclude - top level paths will remove all children! +]); diff --git a/client/src/components/SearchInput/extractArgs.ts b/client/src/components/SearchInput/extractArgs.ts new file mode 100644 index 00000000000..9bb9b6812b0 --- /dev/null +++ b/client/src/components/SearchInput/extractArgs.ts @@ -0,0 +1,123 @@ +import { ResolvedType } from './useTripArgs.ts'; + +export type DefaultValue = string | number | boolean | object | null; + +interface ArgData { + type: ResolvedType; + name?: string; + defaultValue?: DefaultValue; + enumValues?: string[]; + isComplex?: boolean; + isList?: boolean; + args?: Record; // Recursive for nested arguments +} + +export interface ProcessedArgument { + path: string; + type: ResolvedType; + name?: string; + defaultValue?: DefaultValue; + enumValues?: string[]; + isComplex?: boolean; + isList?: boolean; +} +/** + * Returns a human-readable name from a path like "someNestedArg.subArg". + */ +export function formatArgumentName(input: string): string { + if (!input) { + return ' '; + } + const parts = input.split('.'); + const formatted = parts[parts.length - 1].replace(/([A-Z])/g, ' $1').trim(); + return formatted.replace(/\b\w/g, (char) => char.toUpperCase()) + ' '; +} +/** + * Recursively extracts a flat list of arguments (ProcessedArgument[]). + */ +export function extractAllArgs( + args: Record | undefined, + parentPath: string[] = [], +): ProcessedArgument[] { + let allArgs: ProcessedArgument[] = []; + if (!args) return []; + + Object.entries(args).forEach(([argName, argData]) => { + const currentPath = [...parentPath, argName].join('.'); + allArgs = allArgs.concat(processArgument(argName, argData, currentPath, parentPath)); + }); + + return allArgs; +} + +/** + * Converts a single ArgData into one or more ProcessedArgument entries. + * If the argData is an InputObject with nested fields, we recurse. + */ +function processArgument( + argName: string, + argData: ArgData, + currentPath: string, + parentPath: string[], +): ProcessedArgument[] { + let allArgs: ProcessedArgument[] = []; + + if (typeof argData === 'object' && argData.type) { + if (argData.type.type === 'Enum') { + const enumValues = ['Not selected', ...(argData.type.values || [])]; + const defaultValue = argData.defaultValue !== undefined ? argData.defaultValue : 'Not selected'; + + allArgs.push({ + path: currentPath, + type: { type: 'Enum' }, + defaultValue, + enumValues, + isList: argData.isList, + }); + } else if (argData.type.type === 'InputObject' && argData.isList) { + // This is a list of InputObjects + allArgs.push({ + path: currentPath, + type: { type: 'Group', name: argData.type.name }, // We'll still call this 'Group' + defaultValue: argData.defaultValue, + isComplex: true, + isList: true, + }); + + allArgs = allArgs.concat(extractAllArgs(argData.type.fields, [...parentPath, `${argName}.*`])); + } else if (argData.type.type === 'InputObject') { + // Single InputObject + allArgs.push({ + path: currentPath, + type: { type: 'Group', name: argData.type.name }, + isComplex: true, + isList: false, + }); + allArgs = allArgs.concat(extractAllArgs(argData.type.fields, [...parentPath, argName])); + } else if (argData.type.type === 'Scalar') { + allArgs.push({ + path: currentPath, + type: { type: argData.type.type, subtype: argData.type.subtype }, + defaultValue: argData.defaultValue, + isList: argData.isList, + }); + } + } else if (typeof argData === 'object' && argData.type?.fields) { + // Possibly a nested object with fields + allArgs.push({ + path: currentPath, + type: { type: 'Group' }, + isComplex: true, + }); + allArgs = allArgs.concat(extractAllArgs(argData.type.fields, [...parentPath, argName])); + } else { + // Fallback case + allArgs.push({ + path: currentPath, + type: argData.type ?? (typeof argData as unknown), // <— If argData.type is missing, fallback + defaultValue: argData.defaultValue, + }); + } + + return allArgs; +} diff --git a/client/src/components/SearchInput/nestedUtils.tsx b/client/src/components/SearchInput/nestedUtils.tsx new file mode 100644 index 00000000000..cfcfcfc232d --- /dev/null +++ b/client/src/components/SearchInput/nestedUtils.tsx @@ -0,0 +1,129 @@ +/** + * Retrieves a nested value from an object or array based on a dot-separated path. + * @param obj - The object/array to traverse (can be anything). + * @param path - The dot-separated path string (e.g. "myList.0.fieldName"). + * @returns The value at the specified path or undefined if not found. + */ +export function getNestedValue(obj: unknown, path: string): unknown { + return path.split('.').reduce((acc, key) => { + if (acc == null) { + return undefined; + } + + if (Array.isArray(acc)) { + // If the current accumulator is an array, parse key as a numeric index + const idx = Number(key); + if (Number.isNaN(idx)) return undefined; // mismatch (path wanted array index but got non-numeric) + return acc[idx]; + } else if (typeof acc === 'object') { + // treat it like a dictionary + const record = acc as Record; + return record[key]; + } + // If acc is neither object nor array, we can't go deeper + return undefined; + }, obj); +} + +/** + * Sets a nested value in an object (or array) based on a dot-separated path, + * returning a new top-level object/array to ensure immutability. + * + * This version detects numeric path segments (like "0", "1") and uses arrays + * at those levels. Non-numeric segments use objects. If there's a mismatch, + * it will convert that level to the correct type. + * + * @param obj - The original object/array (could be anything). + * @param path - The dot-separated path (e.g. "myList.0.fieldName"). + * @param value - The value to set at that path. + * @returns A new object or array with the updated value. + */ +export function setNestedValue(obj: unknown, path: string, value: unknown): unknown { + const keys = path.split('.'); + + /** + * Recursively traverse `current` based on the path segments. + * At each level, create a shallow clone of the array/object + * and update the correct child. + */ + function cloneAndSet(current: unknown, index: number): unknown { + const key = keys[index]; + const isNumeric = !isNaN(Number(key)); + + // Base case: if we're at the final segment, just return `value`. + if (index === keys.length - 1) { + // If current is an array and key is numeric, place `value` at that index + if (Array.isArray(current) && isNumeric) { + const newArray = [...current]; + newArray[Number(key)] = value; + return newArray; + } + // If current is an object (Record) and key is non-numeric, place `value` in that object + if (isObject(current) && !isNumeric) { + return { ...current, [key]: value }; + } + // Otherwise there's a type mismatch, so we convert: + if (isNumeric) { + // We expected an array + const arr = Array.isArray(current) ? [...current] : []; + arr[Number(key)] = value; + return arr; + } else { + // We expected an object + const base = isObject(current) ? current : {}; + return { + ...base, + [key]: value, + }; + } + } + + // Not at the final segment => recurse deeper + const nextIndex = index + 1; + const nextKey = keys[nextIndex]; + const nextIsNumeric = !isNaN(Number(nextKey)); + + if (Array.isArray(current) && isNumeric) { + // current is an array, and we have a numeric key + const newArray = [...current]; + const childVal = current[Number(key)]; + newArray[Number(key)] = cloneAndSet(childVal !== undefined ? childVal : nextIsNumeric ? [] : {}, nextIndex); + return newArray; + } else if (isObject(current) && !isNumeric) { + // current is an object (Record), and we have a string key + const newObj = { ...current }; + const childVal = (current as Record)[key]; + newObj[key] = cloneAndSet(childVal !== undefined ? childVal : nextIsNumeric ? [] : {}, nextIndex); + return newObj; + } else { + // There's a mismatch at this level + // e.g. current is an object but key is numeric => we want an array, or vice versa. + if (isNumeric) { + // create a new array at this level + const arr: unknown[] = []; + arr[Number(key)] = cloneAndSet(nextIsNumeric ? [] : {}, nextIndex); + return arr; + } else { + // create a new object at this level + return { + [key]: cloneAndSet(nextIsNumeric ? [] : {}, nextIndex), + }; + } + } + } + + // If the root `obj` is undefined or null, base it on the first key + if (obj == null) { + const firstKeyIsNumeric = !isNaN(Number(keys[0])); + obj = firstKeyIsNumeric ? [] : {}; + } + + return cloneAndSet(obj, 0); +} + +/** + * A small helper type-guard to check if `value` is a non-null object (but not an array). + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/client/src/components/SearchInput/useTripArgs.ts b/client/src/components/SearchInput/useTripArgs.ts new file mode 100644 index 00000000000..8f41f9f78d8 --- /dev/null +++ b/client/src/components/SearchInput/useTripArgs.ts @@ -0,0 +1,174 @@ +import { + buildClientSchema, + getIntrospectionQuery, + GraphQLSchema, + GraphQLType, + GraphQLNamedType, + isNonNullType, + isListType, + isScalarType, + isEnumType, + isInputObjectType, +} from 'graphql'; + +// +// Types +// +export interface ResolvedType { + type: 'Scalar' | 'Enum' | 'InputObject' | 'Group'; + // For scalars or fallback, e.g. "String", "Int", etc. + subtype?: string; + // For input objects + name?: string; + fields?: { + [fieldName: string]: { + type: ResolvedType; + defaultValue?: string | number | boolean | object | null; // Updated type + isList: boolean; + }; + }; + // For enums + values?: string[]; +} + +export interface ArgumentRepresentation { + [argName: string]: { + type: ResolvedType; + defaultValue?: string | number | boolean | object | null; // Updated type + isList: boolean; + }; +} + +export interface TripArgsRepresentation { + trip: { + arguments: ArgumentRepresentation; + }; +} + +/** + * Repeatedly unwraps NonNull and List wrappers until we get a named type. + */ +function getNamedType(type: GraphQLType): GraphQLNamedType { + let current: GraphQLType = type; + + while (true) { + if (isNonNullType(current)) { + current = current.ofType; + } else if (isListType(current)) { + current = current.ofType; + } else { + break; + } + } + + // At this point, current should be a GraphQLNamedType + return current as GraphQLNamedType; +} + +function resolveType(type: GraphQLType): ResolvedType { + const namedType = getNamedType(type); + + if (isScalarType(namedType)) { + return { type: 'Scalar', subtype: namedType.name }; + } + + if (isEnumType(namedType)) { + return { + type: 'Enum', + values: namedType.getValues().map((val) => val.name), + }; + } + + if (isInputObjectType(namedType)) { + const fields = namedType.getFields(); + const fieldTypes: Record< + string, + { type: ResolvedType; defaultValue?: string | number | boolean | object | null; isList: boolean } // Updated type + > = {}; + + for (const fieldName of Object.keys(fields)) { + const field = fields[fieldName]; + + // Exclude deprecated fields + if (field.deprecationReason) { + continue; + } + + const isList = isListType(field.type); + const defaultValue = field.defaultValue !== undefined ? field.defaultValue : null; + + fieldTypes[fieldName] = { + type: resolveType(field.type), + defaultValue: defaultValue, + isList, + }; + } + + return { + type: 'InputObject', + name: namedType.name, + fields: fieldTypes, + }; + } + + return { type: 'Scalar', subtype: 'String' }; +} + +function generateTripArgs(schema: GraphQLSchema): TripArgsRepresentation { + const queryType = schema.getQueryType(); + if (!queryType) { + throw new Error('No Query type found in the schema.'); + } + + const tripField = queryType.getFields()['trip']; + if (!tripField) { + throw new Error('No trip query found in the schema.'); + } + + const argsJson: ArgumentRepresentation = {}; + + tripField.args.forEach((arg) => { + if (arg.deprecationReason) { + // Skip deprecated arguments + return; + } + + const argName = arg.name; + const argType = resolveType(arg.type); + const argDefaultValue = arg.defaultValue !== null ? arg.defaultValue : null; + const isList = isListType(arg.type); + + argsJson[argName] = { + type: argType, + ...(argDefaultValue !== null && { defaultValue: argDefaultValue }), + isList, + }; + }); + + return { + trip: { + arguments: argsJson, + }, + }; +} + +//Fetch the remote GraphQL schema via introspection +export async function fetchTripArgs(graphqlEndpointUrl: string): Promise { + const introspectionQuery = getIntrospectionQuery(); + + const response = await fetch(graphqlEndpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: introspectionQuery }), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch schema. HTTP error: ${response.status}`); + } + + const { data } = await response.json(); + + const schema = buildClientSchema(data); + + return generateTripArgs(schema); +} diff --git a/client/src/components/SearchInput/useTripSchema.ts b/client/src/components/SearchInput/useTripSchema.ts new file mode 100644 index 00000000000..b7cc210026a --- /dev/null +++ b/client/src/components/SearchInput/useTripSchema.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { TripSchemaContext } from './TripSchemaContext'; + +export function useTripSchema() { + const context = useContext(TripSchemaContext); + if (!context) { + throw new Error('useTripSchema must be used within a TripSchemaProvider'); + } + return context; +} diff --git a/client/src/screens/App.tsx b/client/src/screens/App.tsx index 1b6b86b7a81..38cac431fb0 100644 --- a/client/src/screens/App.tsx +++ b/client/src/screens/App.tsx @@ -1,12 +1,17 @@ -import { Stack } from 'react-bootstrap'; import { MapView } from '../components/MapView/MapView.tsx'; -import { SearchBar } from '../components/SearchBar/SearchBar.tsx'; import { ItineraryListContainer } from '../components/ItineraryList/ItineraryListContainer.tsx'; import { useState } from 'react'; import { useTripQuery } from '../hooks/useTripQuery.ts'; import { useServerInfo } from '../hooks/useServerInfo.ts'; import { useTripQueryVariables } from '../hooks/useTripQueryVariables.ts'; import { TimeZoneContext } from '../hooks/TimeZoneContext.ts'; +import { LogoSection } from '../components/SearchBar/LogoSection.tsx'; +import { InputFieldsSection } from '../components/SearchBar/InputFieldsSection.tsx'; +import TripQueryArguments from '../components/SearchInput/TripQueryArguments.tsx'; +import Sidebar from '../components/SearchInput/Sidebar.tsx'; +import ViewArgumentsRaw from '../components/SearchInput/ViewArgumentsRaw.tsx'; +import { TripSchemaProvider } from '../components/SearchInput/TripSchemaProvider.tsx'; +import { getApiUrl } from '../util/getApiUrl.ts'; export function App() { const serverInfo = useServerInfo(); @@ -18,30 +23,49 @@ export function App() { return (
- - - - - - +
+
+ +
+
+ +
+
+ + + + + + + +
+
+ +
+
); diff --git a/client/src/static/img/code.svg b/client/src/static/img/code.svg new file mode 100644 index 00000000000..d303b8d18b5 --- /dev/null +++ b/client/src/static/img/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/data-visualization.svg b/client/src/static/img/data-visualization.svg new file mode 100644 index 00000000000..043b9ee35a4 --- /dev/null +++ b/client/src/static/img/data-visualization.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/debug-layer.svg b/client/src/static/img/debug-layer.svg new file mode 100644 index 00000000000..ac614e639dc --- /dev/null +++ b/client/src/static/img/debug-layer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/filter.svg b/client/src/static/img/filter.svg new file mode 100644 index 00000000000..cbda5f955d5 --- /dev/null +++ b/client/src/static/img/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/graph.svg b/client/src/static/img/graph.svg new file mode 100644 index 00000000000..6eef9e5100a --- /dev/null +++ b/client/src/static/img/graph.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/static/img/graphic.svg b/client/src/static/img/graphic.svg new file mode 100644 index 00000000000..344e8f9d5d5 --- /dev/null +++ b/client/src/static/img/graphic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/help-info-solid.svg b/client/src/static/img/help-info-solid.svg new file mode 100644 index 00000000000..bd87cd69731 --- /dev/null +++ b/client/src/static/img/help-info-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/info-circle.svg b/client/src/static/img/info-circle.svg new file mode 100644 index 00000000000..0689c0044ec --- /dev/null +++ b/client/src/static/img/info-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/input.svg b/client/src/static/img/input.svg new file mode 100644 index 00000000000..4ed4605b2c6 --- /dev/null +++ b/client/src/static/img/input.svg @@ -0,0 +1,8 @@ + + + diff --git a/client/src/static/img/json.svg b/client/src/static/img/json.svg new file mode 100644 index 00000000000..a92f3eec55b --- /dev/null +++ b/client/src/static/img/json.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/lap-timer.svg b/client/src/static/img/lap-timer.svg new file mode 100644 index 00000000000..1de0b3be6ce --- /dev/null +++ b/client/src/static/img/lap-timer.svg @@ -0,0 +1,8 @@ + + + diff --git a/client/src/static/img/route.svg b/client/src/static/img/route.svg new file mode 100644 index 00000000000..6699f08361b --- /dev/null +++ b/client/src/static/img/route.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/client/src/static/query/selector.fragment.graphql b/client/src/static/query/selector.fragment.graphql new file mode 100644 index 00000000000..6f3bc847ee7 --- /dev/null +++ b/client/src/static/query/selector.fragment.graphql @@ -0,0 +1,63 @@ +{ + previousPageCursor + nextPageCursor + tripPatterns { + aimedStartTime + aimedEndTime + expectedEndTime + expectedStartTime + duration + distance + legs { + id + mode + aimedStartTime + aimedEndTime + expectedEndTime + expectedStartTime + realtime + distance + duration + fromPlace { + name + quay { + id + } + } + toPlace { + name + quay { + id + } + } + toEstimatedCall { + destinationDisplay { + frontText + } + } + line { + publicCode + name + id + presentation { + colour + } + } + authority { + name + id + } + pointsOnLink { + points + } + interchangeTo { + staySeated + } + interchangeFrom { + staySeated + } + } + systemNotices { + tag + } + } \ No newline at end of file diff --git a/client/src/static/query/tripQuery.tsx b/client/src/static/query/tripQuery.tsx index f435c56e4d6..14c5ed2ec26 100644 --- a/client/src/static/query/tripQuery.tsx +++ b/client/src/static/query/tripQuery.tsx @@ -1,97 +1,155 @@ import { graphql } from '../../gql'; import { print } from 'graphql/index'; +// Generated trip query based on schema.graphql + export const query = graphql(` - query trip( - $from: Location! - $to: Location! - $arriveBy: Boolean - $dateTime: DateTime - $numTripPatterns: Int - $searchWindow: Int - $modes: Modes - $itineraryFiltersDebug: ItineraryFilterDebugProfile - $wheelchairAccessible: Boolean - $pageCursor: String - ) { - trip( - from: $from - to: $to - arriveBy: $arriveBy - dateTime: $dateTime - numTripPatterns: $numTripPatterns - searchWindow: $searchWindow - modes: $modes - itineraryFilters: { debug: $itineraryFiltersDebug } - wheelchairAccessible: $wheelchairAccessible - pageCursor: $pageCursor - ) { - previousPageCursor - nextPageCursor - tripPatterns { +query trip( + $accessEgressPenalty: [PenaltyForStreetMode!] + $alightSlackDefault: Int + $alightSlackList: [TransportModeSlack] + $arriveBy: Boolean + $banned: InputBanned + $bicycleOptimisationMethod: BicycleOptimisationMethod + $bikeSpeed: Float + $boardSlackDefault: Int + $boardSlackList: [TransportModeSlack] + $bookingTime: DateTime + $dateTime: DateTime + $filters: [TripFilterInput!] + $from: Location! + $ignoreRealtimeUpdates: Boolean + $includePlannedCancellations: Boolean + $includeRealtimeCancellations: Boolean + $itineraryFilters: ItineraryFilters + $locale: Locale + $maxAccessEgressDurationForMode: [StreetModeDurationInput!] + $maxDirectDurationForMode: [StreetModeDurationInput!] + $maximumAdditionalTransfers: Int + $maximumTransfers: Int + $modes: Modes + $numTripPatterns: Int + $pageCursor: String + $relaxTransitGroupPriority: RelaxCostInput + $searchWindow: Int + $timetableView: Boolean + $to: Location! + $transferPenalty: Int + $transferSlack: Int + $triangleFactors: TriangleFactors + $useBikeRentalAvailabilityInformation: Boolean + $via: [TripViaLocationInput!] + $waitReluctance: Float + $walkReluctance: Float + $walkSpeed: Float + $wheelchairAccessible: Boolean + $whiteListed: InputWhiteListed +) { + trip( + accessEgressPenalty: $accessEgressPenalty + alightSlackDefault: $alightSlackDefault + alightSlackList: $alightSlackList + arriveBy: $arriveBy + banned: $banned + bicycleOptimisationMethod: $bicycleOptimisationMethod + bikeSpeed: $bikeSpeed + boardSlackDefault: $boardSlackDefault + boardSlackList: $boardSlackList + bookingTime: $bookingTime + dateTime: $dateTime + filters: $filters + from: $from + ignoreRealtimeUpdates: $ignoreRealtimeUpdates + includePlannedCancellations: $includePlannedCancellations + includeRealtimeCancellations: $includeRealtimeCancellations + itineraryFilters: $itineraryFilters + locale: $locale + maxAccessEgressDurationForMode: $maxAccessEgressDurationForMode + maxDirectDurationForMode: $maxDirectDurationForMode + maximumAdditionalTransfers: $maximumAdditionalTransfers + maximumTransfers: $maximumTransfers + modes: $modes + numTripPatterns: $numTripPatterns + pageCursor: $pageCursor + relaxTransitGroupPriority: $relaxTransitGroupPriority + searchWindow: $searchWindow + timetableView: $timetableView + to: $to + transferPenalty: $transferPenalty + transferSlack: $transferSlack + triangleFactors: $triangleFactors + useBikeRentalAvailabilityInformation: $useBikeRentalAvailabilityInformation + via: $via + waitReluctance: $waitReluctance + walkReluctance: $walkReluctance + walkSpeed: $walkSpeed + wheelchairAccessible: $wheelchairAccessible + whiteListed: $whiteListed + ) + { + previousPageCursor + nextPageCursor + tripPatterns { aimedStartTime aimedEndTime expectedEndTime expectedStartTime duration distance - generalizedCost legs { - id - mode - aimedStartTime - aimedEndTime - expectedEndTime - expectedStartTime - realtime - distance - duration - generalizedCost - fromPlace { - name - quay { - id + id + mode + aimedStartTime + aimedEndTime + expectedEndTime + expectedStartTime + realtime + distance + duration + fromPlace { + name + quay { + id + } } - } - toPlace { - name - quay { - id + toPlace { + name + quay { + id + } } - } - toEstimatedCall { - destinationDisplay { - frontText + toEstimatedCall { + destinationDisplay { + frontText + } } - } - line { - publicCode - name - id - presentation { - colour + line { + publicCode + name + id + presentation { + colour + } + } + authority { + name + id + } + pointsOnLink { + points + } + interchangeTo { + staySeated + } + interchangeFrom { + staySeated } - } - authority { - name - id - } - pointsOnLink { - points - } - interchangeTo { - staySeated - } - interchangeFrom { - staySeated - } } systemNotices { - tag + tag } - } } } -`); +}`); -export const queryAsString = print(query); +export const queryAsString = print(query); \ No newline at end of file diff --git a/client/src/style.css b/client/src/style.css index eb5cbadf93b..a3f8946b3ec 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1,39 +1,43 @@ -.app { - min-width: 810px; +.layout { + display: grid; + grid-template-columns: 1fr 3fr; + grid-template-rows: 1fr 2fr; + height: 100vh; + gap: 0; } + +.box { + display: flex; + justify-content: center; + align-items: center; +} + .navbar-brand { color: #4078bc; - margin-top: 20px; - margin-right: 14px; + font-size: 2rem; } @media (min-width: 1895px) { - .top-content { - height: 75px; - } - .below-content { - height: calc(100vh - 75px); + height: calc(100vh - 175px); } } @media (max-width: 1896px) { - .top-content { - height: 150px; - } - .below-content { - height: calc(100vh - 150px); + height: calc(100vh - 175px); } } -@media (max-width: 1120px) { - .top-content { - height: 200px; +@media (max-width: 1250px) { + .below-content { + height: calc(100vh - 250px); } +} +@media (max-width: 900px) { .below-content { - height: calc(100vh - 200px); + height: calc(100vh - 325px); } } @@ -50,6 +54,10 @@ margin-right: 1rem; } +.search-bar input.input-tiny { + max-width: 50px; +} + .search-bar input.input-small { max-width: 100px; } @@ -73,16 +81,40 @@ margin: 30px 0 auto 0; } -.search-bar .swap-from-to img { +.input-family { + display: flex; + align-items: center; + gap: 2px; +} + +.swap-from-to img { width: 15px; } -.itinerary-list-container { - width: 36rem; +.logo-container { + display: flex; + flex-direction: column; +} + +.logo-container .details { + font-size: 0.8rem; + color: #666; + margin-top: 4px; + text-align: left; +} + +.logo-image { + margin-right: 2px; +} + +.left-pane-container { + font-size: 12px; + width: 100%; overflow-y: auto; + min-width: 300px; } -.itinerary-list-container .time-zone-info { +.left-pane-container .time-zone-info { margin: 10px 20px; font-size: 12px; text-align: right; @@ -207,3 +239,208 @@ .maplibregl-ctrl-group.layer-select div.layer { margin-left: 17px; } + +.right-menu-container { + position: absolute; + top: 0; + right: 0; + width: 0; + height: 100%; + background-color: #f4f4f4; + overflow-x: hidden; + transition: 0.3s; + padding-top: 60px; + box-shadow: none; +} + +.right-menu-container.open { + width: 250px; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.2); +} + +.sidebar-button.right { + position: absolute; + right: 0; /* Default position when sidebar is closed */ + background: #fff; + color: white; + border: none; + border-radius: 4px; + padding: 10px; + cursor: pointer; + transition: + right 0.3s, + background-color 0.2s; /* Smooth transitions */ +} + +.sidebar-button.right.open { + right: 270px; /* Shifted position when sidebar is open */ +} + +.sidebar-button.active { + background: #fff; +} + +.sidebar-button:hover { + background: #4078bc; /* Slightly darker when hovered */ +} + +.sidebar-button.active:hover { + background: #fff; +} + +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* For Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} + +.default-tooltip-container { + position: relative; + cursor: pointer; +} + +.pagination-controls { + margin-top: 10px; + margin-bottom: 5px; +} + +.default-tooltip-icon { + width: 10px; + height: 10px; +} +.argument-label { + padding-right: 2px; +} + +/* Sidebar Container */ +.sidebar-container { + display: flex; + width: 100%; + height: 100%; + border-top: black 1px solid; +} + +/* Sidebar Navigation */ +.sidebar { + display: flex; + flex-direction: column; + align-items: center; + width: 40px; + background-color: #f7f7f7; + border-right: 1px solid #ccc; +} + +/* Sidebar Buttons */ +.sidebar-button { + cursor: pointer; + padding: 5px; + text-align: center; + border-radius: 8px; + margin: 5px 0; + background-color: transparent; + transition: background-color 0.3s ease; +} + +.sidebar-button:hover { + background-color: #e0e0e0; +} + +.sidebar-button.active { + background-color: #ddd; + font-weight: bold; +} + +/* Content Area */ +.sidebar-content { + flex: 1; + overflow-y: auto; + margin: 5px; +} + +.panel-header { + font-size: 24px; + text-align: center; + position: relative; + margin-bottom: 10px; +} + +.argument-list { + font-size: 12px; + line-height: 1; +} + +.argument-list button { + font-size: 13px; + padding: 5px 10px; + margin: 5px 0; + background-color: #007bff; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; +} + +.argument-list button:hover { + background-color: #0056b3; /* Darker on hover */ +} + +.argument-list input[type='text'], +.argument-list input[type='number'], +.argument-list input[type='datetime-local'], +select { + font-size: 12px; + padding: 0; + margin: 0; + border: none; + border-bottom: 1px solid #ccc; /* Bottom border only */ + background: none; + box-sizing: border-box; +} + +.argument-list input[type='text'], +.argument-list input[type='number'] { + width: 50px; +} +.argument-list input[type='datetime-local'] { + width: 140px; +} + +input.comma-separated-input[type='text'], +input.comma-separated-input[type='number'] { + width: 140px; +} + +.remove-argument { + margin-left: 2px; + color: red; + cursor: pointer; +} + +.reset-button { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + /* The transform ensures the button is vertically centered + if your header has a fixed height or if text is multiline. */ +} + +.panel-header button { + font-size: 13px; + padding: 5px 10px; + margin: 5px 0; + background-color: #007bff; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; +} + +.panel-header button:hover { + background-color: #0056b3; /* Darker on hover */ +} diff --git a/client/src/util/generate-arguments.cjs b/client/src/util/generate-arguments.cjs new file mode 100644 index 00000000000..d2ff4b639b7 --- /dev/null +++ b/client/src/util/generate-arguments.cjs @@ -0,0 +1,130 @@ +const { + isScalarType, + isInputObjectType, + isNonNullType, + isListType, + isEnumType, +} = require('graphql'); + +/** + * Utility function to resolve the named type (unwrapping NonNull and List types) + */ +function getNamedType(type) { + let namedType = type; + while (isNonNullType(namedType) || isListType(namedType)) { + namedType = namedType.ofType; + } + return namedType; +} + +/** + * Recursively breaks down a GraphQL type into its primitive fields with default values + */ +function resolveType(type, schema = new Set()) { + const namedType = getNamedType(type); + + + if (isScalarType(namedType)) { + return { type: 'Scalar', subtype: namedType.name }; + } + + if (isEnumType(namedType)) { + return { type: 'Enum', values: namedType.getValues().map((val) => val.name) }; + } + + if (isInputObjectType(namedType)) { + const fields = namedType.getFields(); + const fieldTypes = {}; + + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + + // Exclude deprecated fields within input objects + if (field.deprecationReason) { + return; // Skip deprecated fields + } + + const fieldType = field.type; + const isList = isListType(fieldType); // Detect if the field is a list + const fieldDefaultValue = field.defaultValue !== undefined ? field.defaultValue : null; + + // Include defaultValue consistently, setting it to null if not defined + fieldTypes[fieldName] = { + type: resolveType(fieldType, schema), + defaultValue: fieldDefaultValue, + isList, // Explicitly indicate if it's a list + }; + }); + return { type: 'InputObject', name: namedType.name, fields: fieldTypes }; + } + + // Handle interfaces and unions if necessary + // For simplicity, treating them as strings + return { type: 'Scalar', subtype: 'String' }; +} + +/** + * Plugin to generate a JSON file with all arguments from a specified query, + * excluding deprecated arguments based on deprecationReason, + * and including their types, default values, + * and whether they support multiple selection. + */ +const generateTripArgsJsonPlugin = async (schema) => { + try { + const queryType = schema.getQueryType(); + if (!queryType) { + console.error('No Query type found in the schema.'); + return JSON.stringify({ error: 'No Query type found in the schema' }, null, 2); + } + + const tripField = queryType.getFields()['trip']; + if (!tripField) { + console.error('No trip query found in the schema.'); + return JSON.stringify({ error: 'No trip query found in the schema' }, null, 2); + } + + const args = tripField.args; + const argsJson = {}; + + args.forEach((arg) => { + if (arg.deprecationReason) { + return; // Skip deprecated arguments + } + + const argName = arg.name; + const argType = resolveType(arg.type, schema); + const argDefaultValue = arg.defaultValue !== undefined ? arg.defaultValue : null; + const isList = isListType(arg.type); // Detect if the argument is a list + + // Consistent representation for enum types + if (argDefaultValue !== null) { + argsJson[argName] = { + type: argType, + defaultValue: argDefaultValue, + isList, // Explicitly indicate if it's a list + }; + } else { + argsJson[argName] = { + type: argType, + isList, // Explicitly indicate if it's a list + }; + } + }); + + const output = { + trip: { + arguments: argsJson, + }, + }; + + // Stringify the JSON with indentation for readability + return JSON.stringify(output, null, 2); + } catch (error) { + console.error('Error generating tripArguments.json:', error); + return JSON.stringify({ error: 'Failed to generate trip arguments JSON' }, null, 2); + } +}; + +module.exports = { + plugin: generateTripArgsJsonPlugin, +}; diff --git a/client/src/util/generate-queries.cjs b/client/src/util/generate-queries.cjs new file mode 100644 index 00000000000..00366bc6a11 --- /dev/null +++ b/client/src/util/generate-queries.cjs @@ -0,0 +1,67 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Plugin to generate GraphQL queries dynamically from schema + */ +const generateQueriesPlugin = async (schema) => { + const queryType = schema.getQueryType(); + if (!queryType) { + return '// No Query type found in the schema'; + } + + // Read the content from the input file to replace "replacementContent" + const inputFilePath = path.join(__dirname, '../static/query/selector.fragment.graphql'); + let replacementContent = ''; + + try { + replacementContent = fs.readFileSync(inputFilePath, 'utf-8').trim(); + } catch (error) { + console.error(`Failed to read the input file at ${inputFilePath}`, error); + return '// Error: Failed to read the input file'; + } + + const queryFields = queryType.getFields(); + const queries = []; + + Object.keys(queryFields).forEach((fieldName) => { + if (fieldName === 'trip') { + // Only interested in the trip query + const field = queryFields[fieldName]; + + // Filter out deprecated arguments using deprecationReason - isDeprecated does not work + const validArgs = field.args.filter((arg) => !arg.deprecationReason); + + // Generate the arguments for the query with filtered arguments + const args = validArgs.map((arg) => ` $${arg.name}: ${arg.type}`).join('\n'); + + // Generate the arguments for the query variables with filtered arguments + const argsForQuery = validArgs.map((arg) => ` ${arg.name}: $${arg.name}`).join('\n'); + + const query = `import { graphql } from '../../gql'; +import { print } from 'graphql/index'; + +// Generated trip query based on schema.graphql + +export const query = graphql(\` +query ${fieldName}( +${args} +) { + ${fieldName}( +${argsForQuery} + ) + ${replacementContent} + } +}\`); + +export const queryAsString = print(query);`; + queries.push(query.trim()); // Trim unnecessary whitespace + } + }); + + return queries.join('\n\n'); // Separate queries with a blank line +}; + +module.exports = { + plugin: generateQueriesPlugin, +}; diff --git a/doc/user/BuildConfiguration.md b/doc/user/BuildConfiguration.md index c5fdfa8095b..1ec7a7e4c7c 100644 --- a/doc/user/BuildConfiguration.md +++ b/doc/user/BuildConfiguration.md @@ -17,105 +17,114 @@ Sections follow that describe particular settings in more depth. -| Config Parameter | Type | Summary | Req./Opt. | Default Value | Since | -|--------------------------------------------------------------------------|:------------------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------:|-----------------------------------|:-----:| -| [areaVisibility](#areaVisibility) | `boolean` | Perform visibility calculations. | *Optional* | `false` | 1.5 | -| [buildReportDir](#buildReportDir) | `uri` | URI to the directory where the graph build report should be written to. | *Optional* | | 2.0 | -| [configVersion](#configVersion) | `string` | Deployment version of the *build-config.json*. | *Optional* | | 2.1 | -| [dataImportReport](#dataImportReport) | `boolean` | Generate nice HTML report of Graph errors/warnings | *Optional* | `false` | 2.0 | -| [distanceBetweenElevationSamples](#distanceBetweenElevationSamples) | `double` | The distance between elevation samples in meters. | *Optional* | `10.0` | 2.0 | -| embedRouterConfig | `boolean` | Embed the Router config in the graph, which allows it to be sent to a server fully configured over the wire. | *Optional* | `true` | 2.0 | -| [graph](#graph) | `uri` | URI to the graph object file for reading and writing. | *Optional* | | 2.0 | -| [gsCredentials](#gsCredentials) | `string` | Local file system path to Google Cloud Platform service accounts credentials file. | *Optional* | | 2.0 | -| [includeEllipsoidToGeoidDifference](#includeEllipsoidToGeoidDifference) | `boolean` | Include the Ellipsoid to Geoid difference in the calculations of every point along every StreetWithElevationEdge. | *Optional* | `false` | 2.0 | -| maxAreaNodes | `integer` | Visibility calculations for an area will not be done if there are more nodes than this limit. | *Optional* | `150` | 2.1 | -| [maxDataImportIssuesPerFile](#maxDataImportIssuesPerFile) | `integer` | When to split the import report. | *Optional* | `1000` | 2.0 | -| maxElevationPropagationMeters | `integer` | The maximum distance to propagate elevation to vertices which have no elevation. | *Optional* | `2000` | 1.5 | -| [maxStopToShapeSnapDistance](#maxStopToShapeSnapDistance) | `double` | Maximum distance between route shapes and their stops. | *Optional* | `150.0` | 2.1 | -| maxTransferDuration | `duration` | Transfers up to this duration with the default walk speed value will be pre-calculated and included in the Graph. | *Optional* | `"PT30M"` | 2.1 | -| [multiThreadElevationCalculations](#multiThreadElevationCalculations) | `boolean` | Configuring multi-threading during elevation calculations. | *Optional* | `false` | 2.0 | -| [osmCacheDataInMem](#osmCacheDataInMem) | `boolean` | If OSM data should be cached in memory during processing. | *Optional* | `false` | 2.0 | -| [osmNaming](#osmNaming) | `enum` | A custom OSM namer to use. | *Optional* | `"default"` | 1.5 | -| platformEntriesLinking | `boolean` | Link unconnected entries to public transport platforms. | *Optional* | `false` | 2.0 | -| [readCachedElevations](#readCachedElevations) | `boolean` | Whether to read cached elevation data. | *Optional* | `true` | 2.0 | -| staticBikeParkAndRide | `boolean` | Whether we should create bike P+R stations from OSM data. | *Optional* | `false` | 1.5 | -| staticParkAndRide | `boolean` | Whether we should create car P+R stations from OSM data. | *Optional* | `true` | 1.5 | -| stopConsolidationFile | `uri` | Name of the CSV-formatted file in the build directory which contains the configuration for stop consolidation. | *Optional* | | 2.5 | -| [streetGraph](#streetGraph) | `uri` | URI to the street graph object file for reading and writing. | *Optional* | | 2.0 | -| [subwayAccessTime](#subwayAccessTime) | `double` | Minutes necessary to reach stops served by trips on routes of route_type=1 (subway) from the street. | *Optional* | `2.0` | 1.5 | -| [transitModelTimeZone](#transitModelTimeZone) | `time-zone` | Time zone for the graph. | *Optional* | | 2.2 | -| [transitServiceEnd](#transitServiceEnd) | `duration` | Limit the import of transit services to the given end date. | *Optional* | `"P3Y"` | 2.0 | -| [transitServiceStart](#transitServiceStart) | `duration` | Limit the import of transit services to the given START date. | *Optional* | `"-P1Y"` | 2.0 | -| [writeCachedElevations](#writeCachedElevations) | `boolean` | Reusing elevation data from previous builds | *Optional* | `false` | 2.0 | -| [boardingLocationTags](#boardingLocationTags) | `string[]` | What OSM tags should be looked on for the source of matching stops to platforms and stops. | *Optional* | | 2.2 | -| [dataOverlay](sandbox/DataOverlay.md) | `object` | Config for the DataOverlay Sandbox module | *Optional* | | 2.2 | -| [dem](#dem) | `object[]` | Specify parameters for DEM extracts. | *Optional* | | 2.2 | -|       [elevationUnitMultiplier](#dem_0_elevationUnitMultiplier) | `double` | Specify a multiplier to convert elevation units from source to meters. Overrides the value specified in `demDefaults`. | *Optional* | `1.0` | 2.3 | -|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | -| demDefaults | `object` | Default properties for DEM extracts. | *Optional* | | 2.3 | -|    [elevationUnitMultiplier](#demDefaults_elevationUnitMultiplier) | `double` | Specify a multiplier to convert elevation units from source to meters. | *Optional* | `1.0` | 2.3 | -| [elevationBucket](#elevationBucket) | `object` | Used to download NED elevation tiles from the given AWS S3 bucket. | *Optional* | | na | -| [emissions](sandbox/Emissions.md) | `object` | Emissions configuration. | *Optional* | | 2.5 | -| [fares](sandbox/Fares.md) | `object` | Fare configuration. | *Optional* | | 2.0 | -| gtfsDefaults | `object` | The gtfsDefaults section allows you to specify default properties for GTFS files. | *Optional* | | 2.3 | -|    blockBasedInterlining | `boolean` | Whether to create stay-seated transfers in between two trips with the same block id. | *Optional* | `true` | 2.3 | -|    [discardMinTransferTimes](#gd_discardMinTransferTimes) | `boolean` | Should minimum transfer times in GTFS files be discarded. | *Optional* | `false` | 2.3 | -|    maxInterlineDistance | `integer` | Maximal distance between stops in meters that will connect consecutive trips that are made with same vehicle. | *Optional* | `200` | 2.3 | -|    removeRepeatedStops | `boolean` | Should consecutive identical stops be merged into one stop time entry. | *Optional* | `true` | 2.3 | -|    [stationTransferPreference](#gd_stationTransferPreference) | `enum` | Should there be some preference or aversion for transfers at stops that are part of a station. | *Optional* | `"allowed"` | 2.3 | -| islandPruning | `object` | Settings for fixing street graph connectivity errors | *Optional* | | 2.3 | -|    [adaptivePruningDistance](#islandPruning_adaptivePruningDistance) | `integer` | Search distance for analyzing islands in pruning. | *Optional* | `250` | 2.3 | -|    [adaptivePruningFactor](#islandPruning_adaptivePruningFactor) | `double` | Defines how much pruning thresholds grow maximally by distance. | *Optional* | `50.0` | 2.3 | -|    [islandWithStopsMaxSize](#islandPruning_islandWithStopsMaxSize) | `integer` | When a graph island with stops in it should be pruned. | *Optional* | `2` | 2.3 | -|    [islandWithoutStopsMaxSize](#islandPruning_islandWithoutStopsMaxSize) | `integer` | When a graph island without stops should be pruned. | *Optional* | `10` | 2.3 | -| [localFileNamePatterns](#localFileNamePatterns) | `object` | Patterns for matching OTP file types in the base directory | *Optional* | | 2.0 | -|    [dem](#lfp_dem) | `regexp` | Pattern for matching elevation DEM files. | *Optional* | `"(?i)\.tiff?$"` | 2.0 | -|    [gtfs](#lfp_gtfs) | `regexp` | Patterns for matching GTFS zip-files or directories. | *Optional* | `"(?i)gtfs"` | 2.0 | -|    [netex](#lfp_netex) | `regexp` | Patterns for matching NeTEx zip files or directories. | *Optional* | `"(?i)netex"` | 2.0 | -|    [osm](#lfp_osm) | `regexp` | Pattern for matching Open Street Map input files. | *Optional* | `"(?i)(\.pbf¦\.osm¦\.osm\.xml)$"` | 2.0 | -| netexDefaults | `object` | The netexDefaults section allows you to specify default properties for NeTEx files. | *Optional* | | 2.2 | -|    feedId | `string` | This field is used to identify the specific NeTEx feed. It is used instead of the feed_id field in GTFS file feed_info.txt. | *Optional* | `"NETEX"` | 2.2 | -|    [groupFilePattern](#nd_groupFilePattern) | `regexp` | Pattern for matching group NeTEx files. | *Optional* | `"(\w{3})-.*\.xml"` | 2.0 | -|    ignoreFareFrame | `boolean` | Ignore contents of the FareFrame | *Optional* | `false` | 2.3 | -|    [ignoreFilePattern](#nd_ignoreFilePattern) | `regexp` | Pattern for matching ignored files in a NeTEx bundle. | *Optional* | `"$^"` | 2.0 | -|    ignoreParking | `boolean` | Ignore Parking elements. | *Optional* | `true` | 2.6 | -|    noTransfersOnIsolatedStops | `boolean` | Whether we should allow transfers to and from StopPlaces marked with LimitedUse.ISOLATED | *Optional* | `false` | 2.2 | -|    [sharedFilePattern](#nd_sharedFilePattern) | `regexp` | Pattern for matching shared NeTEx files in a NeTEx bundle. | *Optional* | `"shared-data\.xml"` | 2.0 | -|    [sharedGroupFilePattern](#nd_sharedGroupFilePattern) | `regexp` | Pattern for matching shared group NeTEx files in a NeTEx bundle. | *Optional* | `"(\w{3})-.*-shared\.xml"` | 2.0 | -|    [ferryIdsNotAllowedForBicycle](#nd_ferryIdsNotAllowedForBicycle) | `string[]` | List ferries which do not allow bikes. | *Optional* | | 2.0 | -| [osm](#osm) | `object[]` | Configure properties for a given OpenStreetMap feed. | *Optional* | | 2.2 | -|       includeOsmSubwayEntrances | `boolean` | Whether to include subway entrances from the OSM data. Overrides the value specified in `osmDefaults`. | *Optional* | `false` | 2.7 | -|       [osmTagMapping](#osm_0_osmTagMapping) | `enum` | The named set of mapping rules applied when parsing OSM tags. Overrides the value specified in `osmDefaults`. | *Optional* | `"default"` | 2.2 | -|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | -|       timeZone | `time-zone` | The timezone used to resolve opening hours in OSM data. Overrides the value specified in `osmDefaults`. | *Optional* | | 2.2 | -| osmDefaults | `object` | Default properties for OpenStreetMap feeds. | *Optional* | | 2.2 | -|    includeOsmSubwayEntrances | `boolean` | Whether to include subway entrances from the OSM data. | *Optional* | `false` | 2.7 | -|    [osmTagMapping](#od_osmTagMapping) | `enum` | The named set of mapping rules applied when parsing OSM tags. | *Optional* | `"default"` | 2.2 | -|    timeZone | `time-zone` | The timezone used to resolve opening hours in OSM data. | *Optional* | | 2.2 | -| [transferRequests](RouteRequest.md) | `object[]` | Routing requests to use for pre-calculating stop-to-stop transfers. | *Optional* | | 2.1 | -| [transitFeeds](#transitFeeds) | `object[]` | Scan for transit data files | *Optional* | | 2.2 | -|    { object } | `object` | Nested object in array. The object type is determined by the parameters. | *Optional* | | 2.2 | -|       type = "gtfs" | `enum` | The feed input format. | *Required* | | 2.2 | -|       blockBasedInterlining | `boolean` | Whether to create stay-seated transfers in between two trips with the same block id. Overrides the value specified in `gtfsDefaults`. | *Optional* | `true` | 2.3 | -|       [discardMinTransferTimes](#tf_0_discardMinTransferTimes) | `boolean` | Should minimum transfer times in GTFS files be discarded. Overrides the value specified in `gtfsDefaults`. | *Optional* | `false` | 2.3 | -|       feedId | `string` | The unique ID for this feed. This overrides any feed ID defined within the feed itself. | *Optional* | | 2.2 | -|       maxInterlineDistance | `integer` | Maximal distance between stops in meters that will connect consecutive trips that are made with same vehicle. Overrides the value specified in `gtfsDefaults`. | *Optional* | `200` | 2.3 | -|       removeRepeatedStops | `boolean` | Should consecutive identical stops be merged into one stop time entry. Overrides the value specified in `gtfsDefaults`. | *Optional* | `true` | 2.3 | -|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | -|       [stationTransferPreference](#tf_0_stationTransferPreference) | `enum` | Should there be some preference or aversion for transfers at stops that are part of a station. Overrides the value specified in `gtfsDefaults`. | *Optional* | `"allowed"` | 2.3 | -|    { object } | `object` | Nested object in array. The object type is determined by the parameters. | *Optional* | | 2.2 | -|       type = "netex" | `enum` | The feed input format. | *Required* | | 2.2 | -|       feedId | `string` | This field is used to identify the specific NeTEx feed. It is used instead of the feed_id field in GTFS file feed_info.txt. | *Required* | | 2.2 | -|       [groupFilePattern](#tf_1_groupFilePattern) | `regexp` | Pattern for matching group NeTEx files. | *Optional* | `"(\w{3})-.*\.xml"` | 2.0 | -|       ignoreFareFrame | `boolean` | Ignore contents of the FareFrame | *Optional* | `false` | 2.3 | -|       [ignoreFilePattern](#tf_1_ignoreFilePattern) | `regexp` | Pattern for matching ignored files in a NeTEx bundle. | *Optional* | `"$^"` | 2.0 | -|       ignoreParking | `boolean` | Ignore Parking elements. | *Optional* | `true` | 2.6 | -|       noTransfersOnIsolatedStops | `boolean` | Whether we should allow transfers to and from StopPlaces marked with LimitedUse.ISOLATED | *Optional* | `false` | 2.2 | -|       [sharedFilePattern](#tf_1_sharedFilePattern) | `regexp` | Pattern for matching shared NeTEx files in a NeTEx bundle. | *Optional* | `"shared-data\.xml"` | 2.0 | -|       [sharedGroupFilePattern](#tf_1_sharedGroupFilePattern) | `regexp` | Pattern for matching shared group NeTEx files in a NeTEx bundle. | *Optional* | `"(\w{3})-.*-shared\.xml"` | 2.0 | -|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | -|       [ferryIdsNotAllowedForBicycle](#tf_1_ferryIdsNotAllowedForBicycle) | `string[]` | List ferries which do not allow bikes. | *Optional* | | 2.0 | -| [transitRouteToStationCentroid](#transitRouteToStationCentroid) | `feed-scoped-id[]` | List stations that should route to centroid. | *Optional* | | 2.7 | +| Config Parameter | Type | Summary | Req./Opt. | Default Value | Since | +|-------------------------------------------------------------------------------------------|:--------------------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------:|-----------------------------------|:-----:| +| [areaVisibility](#areaVisibility) | `boolean` | Perform visibility calculations. | *Optional* | `false` | 1.5 | +| [buildReportDir](#buildReportDir) | `uri` | URI to the directory where the graph build report should be written to. | *Optional* | | 2.0 | +| [configVersion](#configVersion) | `string` | Deployment version of the *build-config.json*. | *Optional* | | 2.1 | +| [dataImportReport](#dataImportReport) | `boolean` | Generate nice HTML report of Graph errors/warnings | *Optional* | `false` | 2.0 | +| [distanceBetweenElevationSamples](#distanceBetweenElevationSamples) | `double` | The distance between elevation samples in meters. | *Optional* | `10.0` | 2.0 | +| embedRouterConfig | `boolean` | Embed the Router config in the graph, which allows it to be sent to a server fully configured over the wire. | *Optional* | `true` | 2.0 | +| [graph](#graph) | `uri` | URI to the graph object file for reading and writing. | *Optional* | | 2.0 | +| [gsCredentials](#gsCredentials) | `string` | Local file system path to Google Cloud Platform service accounts credentials file. | *Optional* | | 2.0 | +| [includeEllipsoidToGeoidDifference](#includeEllipsoidToGeoidDifference) | `boolean` | Include the Ellipsoid to Geoid difference in the calculations of every point along every StreetWithElevationEdge. | *Optional* | `false` | 2.0 | +| maxAreaNodes | `integer` | Visibility calculations for an area will not be done if there are more nodes than this limit. | *Optional* | `150` | 2.1 | +| [maxDataImportIssuesPerFile](#maxDataImportIssuesPerFile) | `integer` | When to split the import report. | *Optional* | `1000` | 2.0 | +| maxElevationPropagationMeters | `integer` | The maximum distance to propagate elevation to vertices which have no elevation. | *Optional* | `2000` | 1.5 | +| [maxStopToShapeSnapDistance](#maxStopToShapeSnapDistance) | `double` | Maximum distance between route shapes and their stops. | *Optional* | `150.0` | 2.1 | +| maxTransferDuration | `duration` | Transfers up to this duration with a mode-specific speed value will be pre-calculated and included in the Graph. | *Optional* | `"PT30M"` | 2.1 | +| [multiThreadElevationCalculations](#multiThreadElevationCalculations) | `boolean` | Configuring multi-threading during elevation calculations. | *Optional* | `false` | 2.0 | +| [osmCacheDataInMem](#osmCacheDataInMem) | `boolean` | If OSM data should be cached in memory during processing. | *Optional* | `false` | 2.0 | +| [osmNaming](#osmNaming) | `enum` | A custom OSM namer to use. | *Optional* | `"default"` | 1.5 | +| platformEntriesLinking | `boolean` | Link unconnected entries to public transport platforms. | *Optional* | `false` | 2.0 | +| [readCachedElevations](#readCachedElevations) | `boolean` | Whether to read cached elevation data. | *Optional* | `true` | 2.0 | +| staticBikeParkAndRide | `boolean` | Whether we should create bike P+R stations from OSM data. | *Optional* | `false` | 1.5 | +| staticParkAndRide | `boolean` | Whether we should create car P+R stations from OSM data. | *Optional* | `true` | 1.5 | +| stopConsolidationFile | `uri` | Name of the CSV-formatted file in the build directory which contains the configuration for stop consolidation. | *Optional* | | 2.5 | +| [streetGraph](#streetGraph) | `uri` | URI to the street graph object file for reading and writing. | *Optional* | | 2.0 | +| [subwayAccessTime](#subwayAccessTime) | `double` | Minutes necessary to reach stops served by trips on routes of route_type=1 (subway) from the street. | *Optional* | `2.0` | 1.5 | +| [transitModelTimeZone](#transitModelTimeZone) | `time-zone` | Time zone for the graph. | *Optional* | | 2.2 | +| [transitServiceEnd](#transitServiceEnd) | `duration` | Limit the import of transit services to the given end date. | *Optional* | `"P3Y"` | 2.0 | +| [transitServiceStart](#transitServiceStart) | `duration` | Limit the import of transit services to the given START date. | *Optional* | `"-P1Y"` | 2.0 | +| [writeCachedElevations](#writeCachedElevations) | `boolean` | Reusing elevation data from previous builds | *Optional* | `false` | 2.0 | +| [boardingLocationTags](#boardingLocationTags) | `string[]` | What OSM tags should be looked on for the source of matching stops to platforms and stops. | *Optional* | | 2.2 | +| [dataOverlay](sandbox/DataOverlay.md) | `object` | Config for the DataOverlay Sandbox module | *Optional* | | 2.2 | +| [dem](#dem) | `object[]` | Specify parameters for DEM extracts. | *Optional* | | 2.2 | +|       [elevationUnitMultiplier](#dem_0_elevationUnitMultiplier) | `double` | Specify a multiplier to convert elevation units from source to meters. Overrides the value specified in `demDefaults`. | *Optional* | `1.0` | 2.3 | +|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | +| demDefaults | `object` | Default properties for DEM extracts. | *Optional* | | 2.3 | +|    [elevationUnitMultiplier](#demDefaults_elevationUnitMultiplier) | `double` | Specify a multiplier to convert elevation units from source to meters. | *Optional* | `1.0` | 2.3 | +| [elevationBucket](#elevationBucket) | `object` | Used to download NED elevation tiles from the given AWS S3 bucket. | *Optional* | | na | +| [emissions](sandbox/Emissions.md) | `object` | Emissions configuration. | *Optional* | | 2.5 | +| [fares](sandbox/Fares.md) | `object` | Fare configuration. | *Optional* | | 2.0 | +| gtfsDefaults | `object` | The gtfsDefaults section allows you to specify default properties for GTFS files. | *Optional* | | 2.3 | +|    blockBasedInterlining | `boolean` | Whether to create stay-seated transfers in between two trips with the same block id. | *Optional* | `true` | 2.3 | +|    [discardMinTransferTimes](#gd_discardMinTransferTimes) | `boolean` | Should minimum transfer times in GTFS files be discarded. | *Optional* | `false` | 2.3 | +|    maxInterlineDistance | `integer` | Maximal distance between stops in meters that will connect consecutive trips that are made with same vehicle. | *Optional* | `200` | 2.3 | +|    removeRepeatedStops | `boolean` | Should consecutive identical stops be merged into one stop time entry. | *Optional* | `true` | 2.3 | +|    [stationTransferPreference](#gd_stationTransferPreference) | `enum` | Should there be some preference or aversion for transfers at stops that are part of a station. | *Optional* | `"allowed"` | 2.3 | +| islandPruning | `object` | Settings for fixing street graph connectivity errors | *Optional* | | 2.3 | +|    [adaptivePruningDistance](#islandPruning_adaptivePruningDistance) | `integer` | Search distance for analyzing islands in pruning. | *Optional* | `250` | 2.3 | +|    [adaptivePruningFactor](#islandPruning_adaptivePruningFactor) | `double` | Defines how much pruning thresholds grow maximally by distance. | *Optional* | `50.0` | 2.3 | +|    [islandWithStopsMaxSize](#islandPruning_islandWithStopsMaxSize) | `integer` | When a graph island with stops in it should be pruned. | *Optional* | `2` | 2.3 | +|    [islandWithoutStopsMaxSize](#islandPruning_islandWithoutStopsMaxSize) | `integer` | When a graph island without stops should be pruned. | *Optional* | `10` | 2.3 | +| [localFileNamePatterns](#localFileNamePatterns) | `object` | Patterns for matching OTP file types in the base directory | *Optional* | | 2.0 | +|    [dem](#lfp_dem) | `regexp` | Pattern for matching elevation DEM files. | *Optional* | `"(?i)\.tiff?$"` | 2.0 | +|    [gtfs](#lfp_gtfs) | `regexp` | Patterns for matching GTFS zip-files or directories. | *Optional* | `"(?i)gtfs"` | 2.0 | +|    [netex](#lfp_netex) | `regexp` | Patterns for matching NeTEx zip files or directories. | *Optional* | `"(?i)netex"` | 2.0 | +|    [osm](#lfp_osm) | `regexp` | Pattern for matching Open Street Map input files. | *Optional* | `"(?i)(\.pbf¦\.osm¦\.osm\.xml)$"` | 2.0 | +| netexDefaults | `object` | The netexDefaults section allows you to specify default properties for NeTEx files. | *Optional* | | 2.2 | +|    feedId | `string` | This field is used to identify the specific NeTEx feed. It is used instead of the feed_id field in GTFS file feed_info.txt. | *Optional* | `"NETEX"` | 2.2 | +|    [groupFilePattern](#nd_groupFilePattern) | `regexp` | Pattern for matching group NeTEx files. | *Optional* | `"(\w{3})-.*\.xml"` | 2.0 | +|    ignoreFareFrame | `boolean` | Ignore contents of the FareFrame | *Optional* | `false` | 2.3 | +|    [ignoreFilePattern](#nd_ignoreFilePattern) | `regexp` | Pattern for matching ignored files in a NeTEx bundle. | *Optional* | `"$^"` | 2.0 | +|    ignoreParking | `boolean` | Ignore Parking elements. | *Optional* | `true` | 2.6 | +|    noTransfersOnIsolatedStops | `boolean` | Whether we should allow transfers to and from StopPlaces marked with LimitedUse.ISOLATED | *Optional* | `false` | 2.2 | +|    [sharedFilePattern](#nd_sharedFilePattern) | `regexp` | Pattern for matching shared NeTEx files in a NeTEx bundle. | *Optional* | `"shared-data\.xml"` | 2.0 | +|    [sharedGroupFilePattern](#nd_sharedGroupFilePattern) | `regexp` | Pattern for matching shared group NeTEx files in a NeTEx bundle. | *Optional* | `"(\w{3})-.*-shared\.xml"` | 2.0 | +|    [ferryIdsNotAllowedForBicycle](#nd_ferryIdsNotAllowedForBicycle) | `string[]` | List ferries which do not allow bikes. | *Optional* | | 2.0 | +| [osm](#osm) | `object[]` | Configure properties for a given OpenStreetMap feed. | *Optional* | | 2.2 | +|       includeOsmSubwayEntrances | `boolean` | Whether to include subway entrances from the OSM data. Overrides the value specified in `osmDefaults`. | *Optional* | `false` | 2.7 | +|       [osmTagMapping](#osm_0_osmTagMapping) | `enum` | The named set of mapping rules applied when parsing OSM tags. Overrides the value specified in `osmDefaults`. | *Optional* | `"default"` | 2.2 | +|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | +|       timeZone | `time-zone` | The timezone used to resolve opening hours in OSM data. Overrides the value specified in `osmDefaults`. | *Optional* | | 2.2 | +| osmDefaults | `object` | Default properties for OpenStreetMap feeds. | *Optional* | | 2.2 | +|    includeOsmSubwayEntrances | `boolean` | Whether to include subway entrances from the OSM data. | *Optional* | `false` | 2.7 | +|    [osmTagMapping](#od_osmTagMapping) | `enum` | The named set of mapping rules applied when parsing OSM tags. | *Optional* | `"default"` | 2.2 | +|    timeZone | `time-zone` | The timezone used to resolve opening hours in OSM data. | *Optional* | | 2.2 | +| [transferParametersForMode](#transferParametersForMode) | `enum map of object` | Configures mode-specific properties for transfer calculations. | *Optional* | | 2.7 | +|    BIKE | `object` | NA | *Optional* | | 2.7 | +|       [carsAllowedStopMaxTransferDuration](#tpfm_BIKE_carsAllowedStopMaxTransferDuration) | `duration` | This is used for specifying a `maxTransferDuration` value to use with transfers between stops which are visited by trips that allow cars. | *Optional* | | 2.7 | +|       [disableDefaultTransfers](#tpfm_BIKE_disableDefaultTransfers) | `boolean` | This disables default transfer calculations. | *Optional* | `false` | 2.7 | +|       maxTransferDuration | `duration` | This overwrites the default `maxTransferDuration` for the given mode. | *Optional* | | 2.7 | +|    CAR | `object` | NA | *Optional* | | 2.7 | +|       [carsAllowedStopMaxTransferDuration](#tpfm_CAR_carsAllowedStopMaxTransferDuration) | `duration` | This is used for specifying a `maxTransferDuration` value to use with transfers between stops which are visited by trips that allow cars. | *Optional* | | 2.7 | +|       [disableDefaultTransfers](#tpfm_CAR_disableDefaultTransfers) | `boolean` | This disables default transfer calculations. | *Optional* | `false` | 2.7 | +|       maxTransferDuration | `duration` | This overwrites the default `maxTransferDuration` for the given mode. | *Optional* | | 2.7 | +| [transferRequests](RouteRequest.md) | `object[]` | Routing requests to use for pre-calculating stop-to-stop transfers. | *Optional* | | 2.1 | +| [transitFeeds](#transitFeeds) | `object[]` | Scan for transit data files | *Optional* | | 2.2 | +|    { object } | `object` | Nested object in array. The object type is determined by the parameters. | *Optional* | | 2.2 | +|       type = "gtfs" | `enum` | The feed input format. | *Required* | | 2.2 | +|       blockBasedInterlining | `boolean` | Whether to create stay-seated transfers in between two trips with the same block id. Overrides the value specified in `gtfsDefaults`. | *Optional* | `true` | 2.3 | +|       [discardMinTransferTimes](#tf_0_discardMinTransferTimes) | `boolean` | Should minimum transfer times in GTFS files be discarded. Overrides the value specified in `gtfsDefaults`. | *Optional* | `false` | 2.3 | +|       feedId | `string` | The unique ID for this feed. This overrides any feed ID defined within the feed itself. | *Optional* | | 2.2 | +|       maxInterlineDistance | `integer` | Maximal distance between stops in meters that will connect consecutive trips that are made with same vehicle. Overrides the value specified in `gtfsDefaults`. | *Optional* | `200` | 2.3 | +|       removeRepeatedStops | `boolean` | Should consecutive identical stops be merged into one stop time entry. Overrides the value specified in `gtfsDefaults`. | *Optional* | `true` | 2.3 | +|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | +|       [stationTransferPreference](#tf_0_stationTransferPreference) | `enum` | Should there be some preference or aversion for transfers at stops that are part of a station. Overrides the value specified in `gtfsDefaults`. | *Optional* | `"allowed"` | 2.3 | +|    { object } | `object` | Nested object in array. The object type is determined by the parameters. | *Optional* | | 2.2 | +|       type = "netex" | `enum` | The feed input format. | *Required* | | 2.2 | +|       feedId | `string` | This field is used to identify the specific NeTEx feed. It is used instead of the feed_id field in GTFS file feed_info.txt. | *Required* | | 2.2 | +|       [groupFilePattern](#tf_1_groupFilePattern) | `regexp` | Pattern for matching group NeTEx files. | *Optional* | `"(\w{3})-.*\.xml"` | 2.0 | +|       ignoreFareFrame | `boolean` | Ignore contents of the FareFrame | *Optional* | `false` | 2.3 | +|       [ignoreFilePattern](#tf_1_ignoreFilePattern) | `regexp` | Pattern for matching ignored files in a NeTEx bundle. | *Optional* | `"$^"` | 2.0 | +|       ignoreParking | `boolean` | Ignore Parking elements. | *Optional* | `true` | 2.6 | +|       noTransfersOnIsolatedStops | `boolean` | Whether we should allow transfers to and from StopPlaces marked with LimitedUse.ISOLATED | *Optional* | `false` | 2.2 | +|       [sharedFilePattern](#tf_1_sharedFilePattern) | `regexp` | Pattern for matching shared NeTEx files in a NeTEx bundle. | *Optional* | `"shared-data\.xml"` | 2.0 | +|       [sharedGroupFilePattern](#tf_1_sharedGroupFilePattern) | `regexp` | Pattern for matching shared group NeTEx files in a NeTEx bundle. | *Optional* | `"(\w{3})-.*-shared\.xml"` | 2.0 | +|       source | `uri` | The unique URI pointing to the data file. | *Required* | | 2.2 | +|       [ferryIdsNotAllowedForBicycle](#tf_1_ferryIdsNotAllowedForBicycle) | `string[]` | List ferries which do not allow bikes. | *Optional* | | 2.0 | +| [transitRouteToStationCentroid](#transitRouteToStationCentroid) | `feed-scoped-id[]` | List stations that should route to centroid. | *Optional* | | 2.7 | @@ -954,6 +963,116 @@ The named set of mapping rules applied when parsing OSM tags. Overrides the valu The named set of mapping rules applied when parsing OSM tags. +

transferParametersForMode

+ +**Since version:** `2.7` ∙ **Type:** `enum map of object` ∙ **Cardinality:** `Optional` +**Path:** / +**Enum keys:** `not-set` | `walk` | `bike` | `bike-to-park` | `bike-rental` | `scooter-rental` | `car` | `car-to-park` | `car-pickup` | `car-rental` | `car-hailing` | `flexible` + +Configures mode-specific properties for transfer calculations. + +This field enables configuring mode-specific parameters for transfer calculations. +To configure mode-specific parameters, the modes should also be used in the `transferRequests` field in the build config. + +**Example** + +```JSON +// build-config.json +{ + "transferParametersForMode": { + "CAR": { + "disableDefaultTransfers": true, + "carsAllowedStopMaxTransferDuration": "3h" + }, + "BIKE": { + "maxTransferDuration": "30m", + "carsAllowedStopMaxTransferDuration": "3h" + } + } +} +``` + + +

carsAllowedStopMaxTransferDuration

+ +**Since version:** `2.7` ∙ **Type:** `duration` ∙ **Cardinality:** `Optional` +**Path:** /transferParametersForMode/BIKE + +This is used for specifying a `maxTransferDuration` value to use with transfers between stops which are visited by trips that allow cars. + +This parameter configures additional transfers to be calculated for the specified mode only between stops that have trips with cars. +The transfers are calculated for the mode in a range based on the given duration. +By default, these transfers are not calculated unless specified for a mode with this field. + +Calculating transfers only between stops that have trips with cars can be useful with car ferries, for example. +Using transit with cars can only occur between certain stops. +These kinds of stops require support for loading cars into ferries, for example. +The default transfers are calculated based on a configurable range (configurable by using the `maxTransferDuration` field) +which limits transfers from stops to only be calculated to other stops that are in range. +When compared to walking, using a car can cover larger distances within the same duration specified in the `maxTransferDuration` field. +This can lead to large amounts of transfers calculated between stops that do not require car transfers between them. +This in turn can lead to a large increase in memory for the stored graph, depending on the data used in the graph. + +For cars, using this parameter in conjunction with `disableDefaultTransfers` allows calculating transfers only between relevant stops. +For bikes, using this parameter can enable transfers between ferry stops that would normally not be in range. +In Finland this is useful for bike routes that use ferries near the Turku archipelago, for example. + + +

disableDefaultTransfers

+ +**Since version:** `2.7` ∙ **Type:** `boolean` ∙ **Cardinality:** `Optional` ∙ **Default value:** `false` +**Path:** /transferParametersForMode/BIKE + +This disables default transfer calculations. + +The default transfers are calculated based on a configurable range (configurable by using the `maxTransferDuration` field) +which limits transfers from stops to only be calculated to other stops that are in range. +This parameter disables these transfers. +A motivation to disable default transfers could be related to using the `carsAllowedStopMaxTransferDuration` field which only +calculates transfers between stops that have trips with cars. +For example, when using the `carsAllowedStopMaxTransferDuration` field with cars, the default transfers can be redundant. + + +

carsAllowedStopMaxTransferDuration

+ +**Since version:** `2.7` ∙ **Type:** `duration` ∙ **Cardinality:** `Optional` +**Path:** /transferParametersForMode/CAR + +This is used for specifying a `maxTransferDuration` value to use with transfers between stops which are visited by trips that allow cars. + +This parameter configures additional transfers to be calculated for the specified mode only between stops that have trips with cars. +The transfers are calculated for the mode in a range based on the given duration. +By default, these transfers are not calculated unless specified for a mode with this field. + +Calculating transfers only between stops that have trips with cars can be useful with car ferries, for example. +Using transit with cars can only occur between certain stops. +These kinds of stops require support for loading cars into ferries, for example. +The default transfers are calculated based on a configurable range (configurable by using the `maxTransferDuration` field) +which limits transfers from stops to only be calculated to other stops that are in range. +When compared to walking, using a car can cover larger distances within the same duration specified in the `maxTransferDuration` field. +This can lead to large amounts of transfers calculated between stops that do not require car transfers between them. +This in turn can lead to a large increase in memory for the stored graph, depending on the data used in the graph. + +For cars, using this parameter in conjunction with `disableDefaultTransfers` allows calculating transfers only between relevant stops. +For bikes, using this parameter can enable transfers between ferry stops that would normally not be in range. +In Finland this is useful for bike routes that use ferries near the Turku archipelago, for example. + + +

disableDefaultTransfers

+ +**Since version:** `2.7` ∙ **Type:** `boolean` ∙ **Cardinality:** `Optional` ∙ **Default value:** `false` +**Path:** /transferParametersForMode/CAR + +This disables default transfer calculations. + +The default transfers are calculated based on a configurable range (configurable by using the `maxTransferDuration` field) +which limits transfers from stops to only be calculated to other stops that are in range. +This parameter disables these transfers. +A motivation to disable default transfers could be related to using the `carsAllowedStopMaxTransferDuration` field which only +calculates transfers between stops that have trips with cars. +For example, when using the `carsAllowedStopMaxTransferDuration` field with cars, the default transfers can be redundant. + +

transitFeeds

**Since version:** `2.2` ∙ **Type:** `object[]` ∙ **Cardinality:** `Optional` @@ -1186,6 +1305,16 @@ the centroid. "emissions" : { "carAvgCo2PerKm" : 170, "carAvgOccupancy" : 1.3 + }, + "transferParametersForMode" : { + "CAR" : { + "disableDefaultTransfers" : true, + "carsAllowedStopMaxTransferDuration" : "3h" + }, + "BIKE" : { + "maxTransferDuration" : "30m", + "carsAllowedStopMaxTransferDuration" : "3h" + } } } ``` diff --git a/doc/user/Changelog.md b/doc/user/Changelog.md index a01ad18a8fb..352fc295f99 100644 --- a/doc/user/Changelog.md +++ b/doc/user/Changelog.md @@ -76,6 +76,11 @@ based on merged pull requests. Search GitHub issues and pull requests for smalle - Fix `bookWhen` field is `null` in the Transmodel API [#6385](https://github.com/opentripplanner/OpenTripPlanner/pull/6385) - Make it possible to add custom API documentation based on the deployment location [#6355](https://github.com/opentripplanner/OpenTripPlanner/pull/6355) - If configured, add subway station entrances from OSM to walk steps [#6343](https://github.com/opentripplanner/OpenTripPlanner/pull/6343) +- Revert allow multiple states during transfer edge traversals [#6357](https://github.com/opentripplanner/OpenTripPlanner/pull/6357) +- Generate Raptor transfer cache in parallel [#6326](https://github.com/opentripplanner/OpenTripPlanner/pull/6326) +- Add 'transferParametersForMode' build config field [#6215](https://github.com/opentripplanner/OpenTripPlanner/pull/6215) +- Add 'maxStopCountForMode' to the router config [#6383](https://github.com/opentripplanner/OpenTripPlanner/pull/6383) +- Add all routing parameters to debug UI [#6370](https://github.com/opentripplanner/OpenTripPlanner/pull/6370) [](AUTOMATIC_CHANGELOG_PLACEHOLDER_DO_NOT_REMOVE) ## 2.6.0 (2024-09-18) diff --git a/doc/user/RouteRequest.md b/doc/user/RouteRequest.md index 332058b2a42..ea7ced375ab 100644 --- a/doc/user/RouteRequest.md +++ b/doc/user/RouteRequest.md @@ -46,6 +46,7 @@ and in the [transferRequests in build-config.json](BuildConfiguration.md#transfe |    [maxDuration](#rd_accessEgress_maxDuration) | `duration` | This is the maximum duration for access/egress for street searches. | *Optional* | `"PT45M"` | 2.1 | |    [maxStopCount](#rd_accessEgress_maxStopCount) | `integer` | Maximal number of stops collected in access/egress routing | *Optional* | `500` | 2.4 | |    [maxDurationForMode](#rd_accessEgress_maxDurationForMode) | `enum map of duration` | Limit access/egress per street mode. | *Optional* | | 2.1 | +|    [maxStopCountForMode](#rd_accessEgress_maxStopCountForMode) | `enum map of integer` | Maximal number of stops collected in access/egress routing for the given mode | *Optional* | | 2.7 | |    [penalty](#rd_accessEgress_penalty) | `enum map of object` | Penalty for access/egress by street mode. | *Optional* | | 2.4 | |       FLEXIBLE | `object` | NA | *Optional* | | 2.4 | |          costFactor | `double` | A factor multiplied with the time-penalty to get the cost-penalty. | *Optional* | `0.0` | 2.4 | @@ -431,6 +432,18 @@ Override the settings in `maxDuration` for specific street modes. This is done because some street modes searches are much more resource intensive than others. +

maxStopCountForMode

+ +**Since version:** `2.7` ∙ **Type:** `enum map of integer` ∙ **Cardinality:** `Optional` +**Path:** /routingDefaults/accessEgress +**Enum keys:** `not-set` | `walk` | `bike` | `bike-to-park` | `bike-rental` | `scooter-rental` | `car` | `car-to-park` | `car-pickup` | `car-rental` | `car-hailing` | `flexible` + +Maximal number of stops collected in access/egress routing for the given mode + +Safety limit to prevent access to and egress from too many stops. +Mode-specific version of `maxStopCount`. + +

penalty

**Since version:** `2.4` ∙ **Type:** `enum map of object` ∙ **Cardinality:** `Optional` @@ -1250,6 +1263,9 @@ include stairs as a last result. "BIKE_RENTAL" : "20m" }, "maxStopCount" : 500, + "maxStopCountForMode" : { + "CAR" : 0 + }, "penalty" : { "FLEXIBLE" : { "timePenalty" : "2m + 1.1t", diff --git a/doc/user/RouterConfiguration.md b/doc/user/RouterConfiguration.md index b5cbf15a4a5..7d9d2e912d4 100644 --- a/doc/user/RouterConfiguration.md +++ b/doc/user/RouterConfiguration.md @@ -568,6 +568,9 @@ Used to group requests when monitoring OTP. "BIKE_RENTAL" : "20m" }, "maxStopCount" : 500, + "maxStopCountForMode" : { + "CAR" : 0 + }, "penalty" : { "FLEXIBLE" : { "timePenalty" : "2m + 1.1t", diff --git a/pom.xml b/pom.xml index f1d1729b936..3c4eaf5a3b9 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ - 177 + 178 32.1