diff --git a/.github/workflows/debug-client.yml b/.github/workflows/debug-client.yml index 3c87e1c1b5c..82777ea22c6 100644 --- a/.github/workflows/debug-client.yml +++ b/.github/workflows/debug-client.yml @@ -41,7 +41,7 @@ jobs: working-directory: client run: | npm install - npm run build -- --base https://cdn.jsdelivr.net/gh/opentripplanner/debug-client-assets@main/${VERSION}/ + npm run build -- --base https://www.opentripplanner.org/debug-client-assets/${VERSION}/ npm run coverage - name: Deploy compiled assets to repo diff --git a/.github/workflows/performance-test.yml b/.github/workflows/performance-test.yml index 5e55e158a6f..d4376c242c5 100644 --- a/.github/workflows/performance-test.yml +++ b/.github/workflows/performance-test.yml @@ -91,7 +91,7 @@ jobs: cp otp-shaded/target/otp-shaded-*-SNAPSHOT.jar otp.jar java -Xmx32G -jar otp.jar --build --save test/performance/${{ matrix.location }}/ - - name: Run speed test + - name: Run RAPTOR speed test if: matrix.profile == 'core' || github.ref == 'refs/heads/dev-2.x' env: PERFORMANCE_INFLUX_DB_PASSWORD: ${{ secrets.PERFORMANCE_INFLUX_DB_PASSWORD }} @@ -113,3 +113,12 @@ jobs: with: name: ${{ matrix.location }}-flight-recorder path: application/${{ matrix.location }}-speed-test.jfr + + - name: Run transfer cache speed test + if: matrix.profile == 'core' || github.ref == 'refs/heads/dev-2.x' + env: + PERFORMANCE_INFLUX_DB_PASSWORD: ${{ secrets.PERFORMANCE_INFLUX_DB_PASSWORD }} + SPEEDTEST_LOCATION: ${{ matrix.location }} + MAVEN_OPTS: "-Xmx50g -Dmaven.repo.local=/home/lenni/.m2/repository/" + run: | + mvn --projects application exec:java -Dexec.mainClass="org.opentripplanner.transit.speed_test.TransferCacheTest" -Dexec.classpathScope=test -Dexec.args="--dir=test/performance/${{ matrix.location }}" diff --git a/application/pom.xml b/application/pom.xml index 1efe110a583..69dfc596033 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -296,7 +296,7 @@ org.onebusaway onebusaway-gtfs - 5.0.0 + 5.0.2 diff --git a/application/src/client/index.html b/application/src/client/index.html index d3dd8544a53..0e8f974de9e 100644 --- a/application/src/client/index.html +++ b/application/src/client/index.html @@ -5,10 +5,10 @@ OTP Debug - - + +
- + \ No newline at end of file diff --git a/application/src/ext-test/java/org/opentripplanner/ext/fares/impl/GtfsFaresV2ServiceTest.java b/application/src/ext-test/java/org/opentripplanner/ext/fares/impl/GtfsFaresV2ServiceTest.java index 19b55489778..c22c8c97a70 100644 --- a/application/src/ext-test/java/org/opentripplanner/ext/fares/impl/GtfsFaresV2ServiceTest.java +++ b/application/src/ext-test/java/org/opentripplanner/ext/fares/impl/GtfsFaresV2ServiceTest.java @@ -12,7 +12,7 @@ import java.util.Set; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.opentripplanner.ext.fares.model.Distance; +import org.opentripplanner.transit.model.basic.Distance; import org.opentripplanner.ext.fares.model.FareDistance; import org.opentripplanner.ext.fares.model.FareDistance.LinearDistance; import org.opentripplanner.ext.fares.model.FareLegRule; @@ -331,15 +331,22 @@ class DistanceFares { List distanceRules = List.of( FareLegRule .of(DISTANCE_ID, tenKmProduct) - .withFareDistance(new LinearDistance(Distance.ofKilometers(7), Distance.ofKilometers(10))) + .withFareDistance(new LinearDistance( + Distance.ofKilometersBoxed(7d, ignore -> {}).orElse(null), + Distance.ofKilometersBoxed(10d, ignore -> {}).orElse(null))) .build(), FareLegRule .of(DISTANCE_ID, threeKmProduct) - .withFareDistance(new LinearDistance(Distance.ofKilometers(3), Distance.ofKilometers(6))) + .withFareDistance(new LinearDistance( + Distance.ofKilometersBoxed(3d, ignore -> {}).orElse(null), + Distance.ofKilometersBoxed(6d, ignore -> {}).orElse(null))) .build(), FareLegRule .of(DISTANCE_ID, twoKmProduct) - .withFareDistance(new LinearDistance(Distance.ofMeters(0), Distance.ofMeters(2000))) + .withFareDistance(new LinearDistance( + Distance.ofMetersBoxed(0d, ignore -> {}).orElse(null), + Distance.ofMetersBoxed(2000d, ignore -> {}).orElse(null)) + ) .build() ); diff --git a/application/src/ext-test/java/org/opentripplanner/ext/restapi/mapping/EnumMapperTest.java b/application/src/ext-test/java/org/opentripplanner/ext/restapi/mapping/EnumMapperTest.java index 35cc368fec4..5b03ada1c1f 100644 --- a/application/src/ext-test/java/org/opentripplanner/ext/restapi/mapping/EnumMapperTest.java +++ b/application/src/ext-test/java/org/opentripplanner/ext/restapi/mapping/EnumMapperTest.java @@ -8,39 +8,11 @@ import java.util.Map; import java.util.function.Function; import org.junit.jupiter.api.Test; -import org.opentripplanner.ext.restapi.model.ApiAbsoluteDirection; -import org.opentripplanner.ext.restapi.model.ApiRelativeDirection; import org.opentripplanner.ext.restapi.model.ApiVertexType; -import org.opentripplanner.model.plan.AbsoluteDirection; -import org.opentripplanner.model.plan.RelativeDirection; import org.opentripplanner.model.plan.VertexType; public class EnumMapperTest { - private static final String MSG = - "Assert that the API enums have the exact same values that " + - "the domain enums of the same type, and that the specialized mapper is mapping all " + - "values. If this assumtion does not hold, create a new test."; - - @Test - public void map() { - try { - verifyExactMatch( - AbsoluteDirection.class, - ApiAbsoluteDirection.class, - AbsoluteDirectionMapper::mapAbsoluteDirection - ); - verifyExactMatch( - RelativeDirection.class, - ApiRelativeDirection.class, - RelativeDirectionMapper::mapRelativeDirection - ); - } catch (RuntimeException ex) { - System.out.println(MSG); - throw ex; - } - } - @Test public void testVertexTypeMapping() { verifyExplicitMatch( @@ -75,17 +47,4 @@ private , A extends Enum> void verifyExplicitMatch( assertTrue(rest.isEmpty()); } - private , A extends Enum> void verifyExactMatch( - Class domainClass, - Class apiClass, - Function mapper - ) { - List rest = new ArrayList<>(List.of(apiClass.getEnumConstants())); - for (D it : domainClass.getEnumConstants()) { - A result = mapper.apply(it); - assertEquals(result.name(), it.name()); - rest.remove(result); - } - assertTrue(rest.isEmpty()); - } } diff --git a/application/src/ext/java/org/opentripplanner/ext/fares/model/Distance.java b/application/src/ext/java/org/opentripplanner/ext/fares/model/Distance.java deleted file mode 100644 index f30712d4cad..00000000000 --- a/application/src/ext/java/org/opentripplanner/ext/fares/model/Distance.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.opentripplanner.ext.fares.model; - -import org.opentripplanner.utils.tostring.ValueObjectToStringBuilder; - -public class Distance { - - private static final int METERS_PER_KM = 1000; - private final double meters; - - /** Returns a Distance object representing the given number of meters */ - public Distance(double value) { - this.meters = value; - } - - /** Returns a Distance object representing the given number of meters */ - public static Distance ofMeters(double value) { - return new Distance(value); - } - - /** Returns a Distance object representing the given number of kilometers */ - public static Distance ofKilometers(double value) { - return new Distance(value * METERS_PER_KM); - } - - /** Returns the distance in meters */ - public double toMeters() { - return this.meters; - } - - @Override - public boolean equals(Object other) { - if (other instanceof Distance distance) { - return distance.meters == this.meters; - } else { - return false; - } - } - - @Override - public String toString() { - if (meters < METERS_PER_KM) { - return ValueObjectToStringBuilder.of().addNum(meters, "m").toString(); - } else { - return ValueObjectToStringBuilder.of().addNum(meters / 1000, "km").toString(); - } - } -} diff --git a/application/src/ext/java/org/opentripplanner/ext/fares/model/FareDistance.java b/application/src/ext/java/org/opentripplanner/ext/fares/model/FareDistance.java index 9c18e3947b3..bbbc5e64426 100644 --- a/application/src/ext/java/org/opentripplanner/ext/fares/model/FareDistance.java +++ b/application/src/ext/java/org/opentripplanner/ext/fares/model/FareDistance.java @@ -1,5 +1,7 @@ package org.opentripplanner.ext.fares.model; +import org.opentripplanner.transit.model.basic.Distance; + /** Represents a distance metric used in distance-based fare computation*/ public sealed interface FareDistance { /** Represents the number of stops as a distance metric in fare computation */ diff --git a/application/src/ext/java/org/opentripplanner/ext/flex/FlexIndex.java b/application/src/ext/java/org/opentripplanner/ext/flex/FlexIndex.java index 8097bd05c6e..b0e851fa1d3 100644 --- a/application/src/ext/java/org/opentripplanner/ext/flex/FlexIndex.java +++ b/application/src/ext/java/org/opentripplanner/ext/flex/FlexIndex.java @@ -64,6 +64,10 @@ public Route getRouteById(FeedScopedId id) { return routeById.get(id); } + public boolean contains(Route route) { + return routeById.containsKey(route.getId()); + } + public Collection getAllFlexRoutes() { return routeById.values(); } 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/ext/java/org/opentripplanner/ext/restapi/mapping/RelativeDirectionMapper.java b/application/src/ext/java/org/opentripplanner/ext/restapi/mapping/RelativeDirectionMapper.java index a1bdd145a55..ab9abaa4481 100644 --- a/application/src/ext/java/org/opentripplanner/ext/restapi/mapping/RelativeDirectionMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/restapi/mapping/RelativeDirectionMapper.java @@ -14,7 +14,7 @@ public static ApiRelativeDirection mapRelativeDirection(RelativeDirection domain case HARD_LEFT -> ApiRelativeDirection.HARD_LEFT; case LEFT -> ApiRelativeDirection.LEFT; case SLIGHTLY_LEFT -> ApiRelativeDirection.SLIGHTLY_LEFT; - case CONTINUE -> ApiRelativeDirection.CONTINUE; + case CONTINUE, ENTER_OR_EXIT_STATION -> ApiRelativeDirection.CONTINUE; case SLIGHTLY_RIGHT -> ApiRelativeDirection.SLIGHTLY_RIGHT; case RIGHT -> ApiRelativeDirection.RIGHT; case HARD_RIGHT -> ApiRelativeDirection.HARD_RIGHT; diff --git a/application/src/ext/java/org/opentripplanner/ext/restapi/mapping/WalkStepMapper.java b/application/src/ext/java/org/opentripplanner/ext/restapi/mapping/WalkStepMapper.java index 8767abe7478..c4aa11904cc 100644 --- a/application/src/ext/java/org/opentripplanner/ext/restapi/mapping/WalkStepMapper.java +++ b/application/src/ext/java/org/opentripplanner/ext/restapi/mapping/WalkStepMapper.java @@ -39,7 +39,7 @@ public ApiWalkStep mapWalkStep(WalkStep domain) { api.streetName = domain.getDirectionText().toString(locale); api.absoluteDirection = domain.getAbsoluteDirection().map(AbsoluteDirectionMapper::mapAbsoluteDirection).orElse(null); - api.exit = domain.getExit(); + api.exit = domain.highwayExit().orElse(null); api.stayOn = domain.isStayOn(); api.area = domain.getArea(); api.bogusName = domain.nameIsDerived(); diff --git a/application/src/ext/resources/org/opentripplanner/ext/apis/transmodel/custom-documentation-entur.properties b/application/src/ext/resources/org/opentripplanner/ext/apis/transmodel/custom-documentation-entur.properties new file mode 100644 index 00000000000..29c44b67f96 --- /dev/null +++ b/application/src/ext/resources/org/opentripplanner/ext/apis/transmodel/custom-documentation-entur.properties @@ -0,0 +1,27 @@ +# Use: +# [.].(description|deprecated)[.append] +# +# Examples +# // Replace the existing type description +# Quay.description=The place for boarding/alighting a vehicle +# +# // Append to the existing type description +# Quay.description.append=Append +# +# // Replace the existing field description +# Quay.name.description=The public name +# +# // Append to the existing field description +# Quay.name.description.append=(Source NSR) +# +# // Insert deprecated reason. Due to a bug in the Java GraphQL lib, an existing deprecated +# // reason cannot be updated. Deleting the reason from the schema, and adding it back using +# // the "default" TransmodelApiDocumentationProfile is a workaround. +# Quay.name.deprecated=This field is deprecated ... + + +TariffZone.description=A **zone** used to define a zonal fare structure in a zone-counting or \ + zone-matrix system. This includes TariffZone, as well as the specialised FareZone elements. \ + TariffZones are deprecated, please use FareZones. \ + \ + **TariffZone data will not be maintained from 1. MAY 2025 (Entur).** diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java b/application/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java index 302458ac656..721ad3e3ee7 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/GtfsGraphQLIndex.java @@ -39,6 +39,7 @@ import org.opentripplanner.apis.gtfs.datafetchers.CurrencyImpl; import org.opentripplanner.apis.gtfs.datafetchers.DefaultFareProductImpl; import org.opentripplanner.apis.gtfs.datafetchers.DepartureRowImpl; +import org.opentripplanner.apis.gtfs.datafetchers.EntranceImpl; import org.opentripplanner.apis.gtfs.datafetchers.EstimatedTimeImpl; import org.opentripplanner.apis.gtfs.datafetchers.FareProductTypeResolver; import org.opentripplanner.apis.gtfs.datafetchers.FareProductUseImpl; @@ -58,12 +59,14 @@ import org.opentripplanner.apis.gtfs.datafetchers.QueryTypeImpl; import org.opentripplanner.apis.gtfs.datafetchers.RealTimeEstimateImpl; import org.opentripplanner.apis.gtfs.datafetchers.RentalPlaceTypeResolver; +import org.opentripplanner.apis.gtfs.datafetchers.RentalVehicleFuelImpl; import org.opentripplanner.apis.gtfs.datafetchers.RentalVehicleImpl; import org.opentripplanner.apis.gtfs.datafetchers.RentalVehicleTypeImpl; import org.opentripplanner.apis.gtfs.datafetchers.RideHailingEstimateImpl; import org.opentripplanner.apis.gtfs.datafetchers.RouteImpl; import org.opentripplanner.apis.gtfs.datafetchers.RouteTypeImpl; import org.opentripplanner.apis.gtfs.datafetchers.RoutingErrorImpl; +import org.opentripplanner.apis.gtfs.datafetchers.StepFeatureTypeResolver; import org.opentripplanner.apis.gtfs.datafetchers.StopCallImpl; import org.opentripplanner.apis.gtfs.datafetchers.StopGeometriesImpl; import org.opentripplanner.apis.gtfs.datafetchers.StopImpl; @@ -137,6 +140,7 @@ protected static GraphQLSchema buildSchema() { .type("AlertEntity", type -> type.typeResolver(new AlertEntityTypeResolver())) .type("CallStopLocation", type -> type.typeResolver(new CallStopLocationTypeResolver())) .type("CallScheduledTime", type -> type.typeResolver(new CallScheduledTimeTypeResolver())) + .type("StepFeature", type -> type.typeResolver(new StepFeatureTypeResolver())) .type(typeWiring.build(AgencyImpl.class)) .type(typeWiring.build(AlertImpl.class)) .type(typeWiring.build(BikeParkImpl.class)) @@ -195,6 +199,8 @@ protected static GraphQLSchema buildSchema() { .type(typeWiring.build(LegTimeImpl.class)) .type(typeWiring.build(RealTimeEstimateImpl.class)) .type(typeWiring.build(EstimatedTimeImpl.class)) + .type(typeWiring.build(EntranceImpl.class)) + .type(typeWiring.build(RentalVehicleFuelImpl.class)) .build(); SchemaGenerator schemaGenerator = new SchemaGenerator(); return schemaGenerator.makeExecutableSchema(typeRegistry, runtimeWiring); diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/EntranceImpl.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/EntranceImpl.java new file mode 100644 index 00000000000..f9faa9cc4d1 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/EntranceImpl.java @@ -0,0 +1,45 @@ +package org.opentripplanner.apis.gtfs.datafetchers; + +import graphql.schema.DataFetcher; +import org.opentripplanner.apis.gtfs.GraphQLUtils; +import org.opentripplanner.apis.gtfs.generated.GraphQLDataFetchers; +import org.opentripplanner.apis.gtfs.generated.GraphQLTypes; +import org.opentripplanner.transit.model.site.Entrance; + +public class EntranceImpl implements GraphQLDataFetchers.GraphQLEntrance { + + @Override + public DataFetcher publicCode() { + return environment -> { + Entrance entrance = environment.getSource(); + return entrance.getCode(); + }; + } + + @Override + public DataFetcher entranceId() { + return environment -> { + Entrance entrance = environment.getSource(); + return entrance.getId().toString(); + }; + } + + @Override + public DataFetcher name() { + return environment -> { + Entrance entrance = environment.getSource(); + return org.opentripplanner.framework.graphql.GraphQLUtils.getTranslation( + entrance.getName(), + environment + ); + }; + } + + @Override + public DataFetcher wheelchairAccessible() { + return environment -> { + Entrance entrance = environment.getSource(); + return GraphQLUtils.toGraphQL(entrance.getWheelchairAccessibility()); + }; + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/RentalVehicleFuelImpl.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/RentalVehicleFuelImpl.java new file mode 100644 index 00000000000..aca43154e02 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/RentalVehicleFuelImpl.java @@ -0,0 +1,25 @@ +package org.opentripplanner.apis.gtfs.datafetchers; + +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import org.opentripplanner.apis.gtfs.generated.GraphQLDataFetchers; +import org.opentripplanner.service.vehiclerental.model.RentalVehicleFuel; + +public class RentalVehicleFuelImpl implements GraphQLDataFetchers.GraphQLRentalVehicleFuel { + + @Override + public DataFetcher percent() { + return environment -> + getSource(environment).percent() != null ? getSource(environment).percent().asDouble() : null; + } + + @Override + public DataFetcher range() { + return environment -> + getSource(environment).range() != null ? getSource(environment).range().toMeters() : null; + } + + private RentalVehicleFuel getSource(DataFetchingEnvironment environment) { + return environment.getSource(); + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/RentalVehicleImpl.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/RentalVehicleImpl.java index c4fb92c0ef4..5697aa15a5f 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/RentalVehicleImpl.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/RentalVehicleImpl.java @@ -4,6 +4,7 @@ import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import org.opentripplanner.apis.gtfs.generated.GraphQLDataFetchers; +import org.opentripplanner.service.vehiclerental.model.RentalVehicleFuel; import org.opentripplanner.service.vehiclerental.model.RentalVehicleType; import org.opentripplanner.service.vehiclerental.model.VehicleRentalStationUris; import org.opentripplanner.service.vehiclerental.model.VehicleRentalSystem; @@ -16,6 +17,11 @@ public DataFetcher allowPickupNow() { return environment -> getSource(environment).allowPickupNow(); } + @Override + public DataFetcher fuel() { + return environment -> getSource(environment).getFuel(); + } + @Override public DataFetcher id() { return environment -> diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/StepFeatureTypeResolver.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/StepFeatureTypeResolver.java new file mode 100644 index 00000000000..714518cb9ea --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/StepFeatureTypeResolver.java @@ -0,0 +1,21 @@ +package org.opentripplanner.apis.gtfs.datafetchers; + +import graphql.TypeResolutionEnvironment; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLSchema; +import graphql.schema.TypeResolver; +import org.opentripplanner.transit.model.site.Entrance; + +public class StepFeatureTypeResolver implements TypeResolver { + + @Override + public GraphQLObjectType getType(TypeResolutionEnvironment environment) { + Object o = environment.getObject(); + GraphQLSchema schema = environment.getSchema(); + + if (o instanceof Entrance) { + return schema.getObjectType("Entrance"); + } + return null; + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/stepImpl.java b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/stepImpl.java index f14db6f213f..409bb2abb1d 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/stepImpl.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/datafetchers/stepImpl.java @@ -50,7 +50,12 @@ public DataFetcher> elevationProfile() { @Override public DataFetcher exit() { - return environment -> getSource(environment).getExit(); + return environment -> getSource(environment).highwayExit().orElse(null); + } + + @Override + public DataFetcher feature() { + return environment -> getSource(environment).entrance().orElse(null); } @Override diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java b/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java index 9c95ea65e91..4a29f62e84d 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLDataFetchers.java @@ -58,6 +58,7 @@ import org.opentripplanner.service.vehicleparking.model.VehicleParkingSpaces; import org.opentripplanner.service.vehicleparking.model.VehicleParkingState; import org.opentripplanner.service.vehiclerental.model.RentalVehicleEntityCounts; +import org.opentripplanner.service.vehiclerental.model.RentalVehicleFuel; import org.opentripplanner.service.vehiclerental.model.RentalVehicleType; import org.opentripplanner.service.vehiclerental.model.RentalVehicleTypeCount; import org.opentripplanner.service.vehiclerental.model.VehicleRentalPlace; @@ -392,6 +393,17 @@ public interface GraphQLEmissions { public DataFetcher co2(); } + /** Station entrance or exit, originating from OSM or GTFS data. */ + public interface GraphQLEntrance { + public DataFetcher entranceId(); + + public DataFetcher name(); + + public DataFetcher publicCode(); + + public DataFetcher wheelchairAccessible(); + } + /** Real-time estimates for an arrival or departure at a certain place. */ public interface GraphQLEstimatedTime { public DataFetcher delay(); @@ -897,6 +909,8 @@ public interface GraphQLRentalPlace extends TypeResolver {} public interface GraphQLRentalVehicle { public DataFetcher allowPickupNow(); + public DataFetcher fuel(); + public DataFetcher id(); public DataFetcher lat(); @@ -924,6 +938,13 @@ public interface GraphQLRentalVehicleEntityCounts { public DataFetcher total(); } + /** Rental vehicle fuel represent the current status of the battery or fuel of a rental vehicle */ + public interface GraphQLRentalVehicleFuel { + public DataFetcher percent(); + + public DataFetcher range(); + } + public interface GraphQLRentalVehicleType { public DataFetcher formFactor(); @@ -1024,6 +1045,9 @@ public interface GraphQLRoutingError { public DataFetcher inputField(); } + /** A feature for a step */ + public interface GraphQLStepFeature extends TypeResolver {} + /** * Stop can represent either a single public transport stop, where passengers can * board and/or disembark vehicles, or a station, which contains multiple stops. @@ -1521,6 +1545,8 @@ public interface GraphQLStep { public DataFetcher exit(); + public DataFetcher feature(); + public DataFetcher lat(); public DataFetcher lon(); diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLTypes.java b/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLTypes.java index a969b5223b1..fc20625e18e 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLTypes.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/generated/GraphQLTypes.java @@ -4327,7 +4327,11 @@ public enum GraphQLRealtimeState { UPDATED, } - /** Actions to take relative to the current position when engaging a walking/driving step. */ + /** + * A direction that is not absolute but rather fuzzy and context-dependent. + * It provides the passenger with information what they should do in this step depending on where they + * were in the previous one. + */ public enum GraphQLRelativeDirection { CIRCLE_CLOCKWISE, CIRCLE_COUNTERCLOCKWISE, diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml b/application/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml index a9bb87a6ea5..3272efa894f 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/generated/graphql-codegen.yml @@ -134,4 +134,5 @@ config: CallRealTime: org.opentripplanner.apis.gtfs.model.CallRealTime#CallRealTime RentalPlace: org.opentripplanner.service.vehiclerental.model.VehicleRentalPlace#VehicleRentalPlace CallSchedule: org.opentripplanner.apis.gtfs.model.CallSchedule#CallSchedule + RentalVehicleFuel: org.opentripplanner.service.vehiclerental.model.RentalVehicleFuel#RentalVehicleFuel diff --git a/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapper.java b/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapper.java index 69a78b05f55..3f69047f94d 100644 --- a/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapper.java +++ b/application/src/main/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapper.java @@ -27,7 +27,7 @@ public static GraphQLRelativeDirection map(RelativeDirection relativeDirection) case HARD_LEFT -> GraphQLRelativeDirection.HARD_LEFT; case LEFT -> GraphQLRelativeDirection.LEFT; case SLIGHTLY_LEFT -> GraphQLRelativeDirection.SLIGHTLY_LEFT; - case CONTINUE -> GraphQLRelativeDirection.CONTINUE; + case CONTINUE, ENTER_OR_EXIT_STATION -> GraphQLRelativeDirection.CONTINUE; case SLIGHTLY_RIGHT -> GraphQLRelativeDirection.SLIGHTLY_RIGHT; case RIGHT -> GraphQLRelativeDirection.RIGHT; case HARD_RIGHT -> GraphQLRelativeDirection.HARD_RIGHT; diff --git a/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/ApiDocumentationProfile.java b/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/ApiDocumentationProfile.java new file mode 100644 index 00000000000..93452589ace --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/ApiDocumentationProfile.java @@ -0,0 +1,30 @@ +package org.opentripplanner.apis.support.graphql.injectdoc; + +import org.opentripplanner.framework.doc.DocumentedEnum; + +public enum ApiDocumentationProfile implements DocumentedEnum { + DEFAULT, + ENTUR; + + private static final String TYPE_DOC = + """ + List of available custom documentation profiles. A profile is used to inject custom + documentation like type and field description or a deprecated reason. + + Currently, ONLY the Transmodel API supports this feature. + """; + + @Override + public String typeDescription() { + return TYPE_DOC; + } + + @Override + public String enumValueDescription() { + return switch (this) { + case DEFAULT -> "Default documentation is used."; + case ENTUR -> "Entur specific documentation. This deprecate features not supported at Entur," + + " Norway."; + }; + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/CustomDocumentation.java b/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/CustomDocumentation.java new file mode 100644 index 00000000000..4afe0cf6952 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/CustomDocumentation.java @@ -0,0 +1,173 @@ +package org.opentripplanner.apis.support.graphql.injectdoc; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import javax.annotation.Nullable; +import org.opentripplanner.framework.application.OtpAppException; +import org.opentripplanner.utils.text.TextVariablesSubstitution; + +/** + * Load custom documentation from a properties file and make it available to any + * consumer using the {@code type-name[.field-name]} as key for lookups. + */ +public class CustomDocumentation { + + private static final String APPEND_SUFFIX = ".append"; + private static final String DESCRIPTION_SUFFIX = ".description"; + private static final String DEPRECATED_SUFFIX = ".deprecated"; + + /** Put custom documentaion in the following sandbox package */ + private static final String DOC_PATH = "org/opentripplanner/ext/apis/transmodel/"; + private static final String FILE_NAME = "custom-documentation"; + private static final String FILE_EXTENSION = ".properties"; + + private static final CustomDocumentation EMPTY = new CustomDocumentation(Map.of()); + + private final Map textMap; + + /** + * Package local to be unit-testable + */ + CustomDocumentation(Map textMap) { + this.textMap = textMap; + } + + public static CustomDocumentation of(ApiDocumentationProfile profile) { + if (profile == ApiDocumentationProfile.DEFAULT) { + return EMPTY; + } + var map = loadCustomDocumentationFromPropertiesFile(profile); + return map.isEmpty() ? EMPTY : new CustomDocumentation(map); + } + + public boolean isEmpty() { + return textMap.isEmpty(); + } + + /** + * Get documentation for a type. The given {@code typeName} is used as the key. The + * documentation text is resolved by: + *
    + *
  1. + * first looking up the given {@code key} + {@code ".description"}. If a value is found, then + * the value is returned. + *
  2. + * then {@code key} + {@code ".description.append"} is used. If a value is found the + * {@code originalDoc} + {@code value} is returned. + *
  3. + *
+ * @param typeName Use {@code TYPE_NAME} or {@code TYPE_NAME.FIELD_NAME} as key. + */ + public Optional typeDescription(String typeName, @Nullable String originalDoc) { + return text(typeName, DESCRIPTION_SUFFIX, originalDoc); + } + + /** + * Same as {@link #typeDescription(String, String)} except the given {@code typeName} and + * {@code fieldName} is used as the key. + *
+   * key := typeName + "." fieldNAme
+   * 
+ */ + public Optional fieldDescription( + String typeName, + String fieldName, + @Nullable String originalDoc + ) { + return text(key(typeName, fieldName), DESCRIPTION_SUFFIX, originalDoc); + } + + /** + * Get deprecated reason for a field (types cannot be deprecated). The key + * ({@code key = typeName + '.' + fieldName} is used to retrieve the reason from the properties + * file. The deprecated documentation text is resolved by: + *
    + *
  1. + * first looking up the given {@code key} + {@code ".deprecated"}. If a value is found, then + * the value is returned. + *
  2. + * then {@code key} + {@code ".deprecated.append"} is used. If a value is found the + * {@code originalDoc} + {@code text} is returned. + *
  3. + *
+ * Any {@code null} values are excluded from the result and if both the input {@code originalDoc} + * and the resolved value is {@code null}, then {@code empty} is returned. + */ + public Optional fieldDeprecatedReason( + String typeName, + String fieldName, + @Nullable String originalDoc + ) { + return text(key(typeName, fieldName), DEPRECATED_SUFFIX, originalDoc); + } + + /* private methods */ + + /** + * Create a key from the given {@code typeName} and {@code fieldName} + */ + private static String key(String typeName, String fieldName) { + return typeName + "." + fieldName; + } + + private Optional text(String key, String suffix, @Nullable String originalText) { + final String k = key + suffix; + return text(k).or(() -> appendText(k, originalText)); + } + + private Optional text(String key) { + return Optional.ofNullable(textMap.get(key)); + } + + private Optional appendText(String key, @Nullable String originalText) { + String value = textMap.get(key + APPEND_SUFFIX); + if (value == null) { + return Optional.empty(); + } + return originalText == null ? Optional.of(value) : Optional.of(originalText + "\n\n" + value); + } + + /* private methods */ + + private static Map loadCustomDocumentationFromPropertiesFile( + ApiDocumentationProfile profile + ) { + try { + final String resource = resourceName(profile); + var input = ClassLoader.getSystemResourceAsStream(resource); + if (input == null) { + throw new OtpAppException("Resource not found: %s", resource); + } + var props = new Properties(); + props.load(new InputStreamReader(input, StandardCharsets.UTF_8)); + Map map = new HashMap<>(); + + for (String key : props.stringPropertyNames()) { + String value = props.getProperty(key); + if (value == null) { + value = ""; + } + map.put(key, value); + } + return TextVariablesSubstitution.insertVariables( + map, + varName -> errorHandlerVariableSubstitution(varName, resource) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static void errorHandlerVariableSubstitution(String name, String source) { + throw new OtpAppException("Variable substitution failed for '${%s}' in %s.", name, source); + } + + private static String resourceName(ApiDocumentationProfile profile) { + return DOC_PATH + FILE_NAME + "-" + profile.name().toLowerCase() + FILE_EXTENSION; + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentation.java b/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentation.java new file mode 100644 index 00000000000..4e98f202d90 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentation.java @@ -0,0 +1,173 @@ +package org.opentripplanner.apis.support.graphql.injectdoc; + +import static graphql.util.TraversalControl.CONTINUE; + +import graphql.schema.GraphQLEnumType; +import graphql.schema.GraphQLEnumValueDefinition; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLInputObjectField; +import graphql.schema.GraphQLInputObjectType; +import graphql.schema.GraphQLInterfaceType; +import graphql.schema.GraphQLNamedSchemaElement; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLScalarType; +import graphql.schema.GraphQLSchemaElement; +import graphql.schema.GraphQLTypeVisitor; +import graphql.schema.GraphQLTypeVisitorStub; +import graphql.schema.GraphQLUnionType; +import graphql.util.TraversalControl; +import graphql.util.TraverserContext; +import java.util.Optional; +import java.util.function.BiFunction; + +/** + * This is GraphQL visitor which injects custom documentation on types and fields. + */ +public class InjectCustomDocumentation + extends GraphQLTypeVisitorStub + implements GraphQLTypeVisitor { + + private final CustomDocumentation customDocumentation; + + public InjectCustomDocumentation(CustomDocumentation customDocumentation) { + this.customDocumentation = customDocumentation; + } + + @Override + public TraversalControl visitGraphQLScalarType( + GraphQLScalarType scalar, + TraverserContext context + ) { + return typeDoc(context, scalar, (s, doc) -> s.transform(b -> b.description(doc))); + } + + @Override + public TraversalControl visitGraphQLInterfaceType( + GraphQLInterfaceType interface_, + TraverserContext context + ) { + return typeDoc(context, interface_, (f, doc) -> f.transform(b -> b.description(doc))); + } + + @Override + public TraversalControl visitGraphQLEnumType( + GraphQLEnumType enumType, + TraverserContext context + ) { + return typeDoc(context, enumType, (f, doc) -> f.transform(b -> b.description(doc))); + } + + @Override + public TraversalControl visitGraphQLEnumValueDefinition( + GraphQLEnumValueDefinition enumValue, + TraverserContext context + ) { + return fieldDoc( + context, + enumValue, + enumValue.getDeprecationReason(), + (f, doc) -> f.transform(b -> b.description(doc)), + (f, reason) -> f.transform(b -> b.deprecationReason(reason)) + ); + } + + @Override + public TraversalControl visitGraphQLFieldDefinition( + GraphQLFieldDefinition field, + TraverserContext context + ) { + return fieldDoc( + context, + field, + field.getDeprecationReason(), + (f, doc) -> f.transform(b -> b.description(doc)), + (f, reason) -> f.transform(b -> b.deprecate(reason)) + ); + } + + @Override + public TraversalControl visitGraphQLInputObjectField( + GraphQLInputObjectField inputField, + TraverserContext context + ) { + return fieldDoc( + context, + inputField, + inputField.getDeprecationReason(), + (f, doc) -> f.transform(b -> b.description(doc)), + (f, reason) -> f.transform(b -> b.deprecate(reason)) + ); + } + + @Override + public TraversalControl visitGraphQLInputObjectType( + GraphQLInputObjectType inputType, + TraverserContext context + ) { + return typeDoc(context, inputType, (f, doc) -> f.transform(b -> b.description(doc))); + } + + @Override + public TraversalControl visitGraphQLObjectType( + GraphQLObjectType object, + TraverserContext context + ) { + return typeDoc(context, object, (f, doc) -> f.transform(b -> b.description(doc))); + } + + @Override + public TraversalControl visitGraphQLUnionType( + GraphQLUnionType union, + TraverserContext context + ) { + return typeDoc(context, union, (f, doc) -> f.transform(b -> b.description(doc))); + } + + /* private methods */ + + /** + * Set or append description on a Scalar, Object, InputType, Union, Interface or Enum. + */ + private TraversalControl typeDoc( + TraverserContext context, + T element, + BiFunction setDescription + ) { + customDocumentation + .typeDescription(element.getName(), element.getDescription()) + .map(doc -> setDescription.apply(element, doc)) + .ifPresent(f -> changeNode(context, f)); + return CONTINUE; + } + + /** + * Set or append description and deprecated reason on a field [Object, InputType, Interface, + * Union or Enum]. + */ + private TraversalControl fieldDoc( + TraverserContext context, + T field, + String originalDeprecatedReason, + BiFunction setDescription, + BiFunction setDeprecatedReason + ) { + // All fields need to be defined in a named element + if (!(context.getParentNode() instanceof GraphQLNamedSchemaElement parent)) { + throw new IllegalArgumentException("The field does not have a named parent: " + field); + } + var fieldName = field.getName(); + var typeName = parent.getName(); + + Optional withDescription = customDocumentation + .fieldDescription(typeName, fieldName, field.getDescription()) + .map(doc -> setDescription.apply(field, doc)); + + Optional withDeprecated = customDocumentation + .fieldDeprecatedReason(typeName, fieldName, originalDeprecatedReason) + .map(doc -> setDeprecatedReason.apply(withDescription.orElse(field), doc)); + + withDeprecated.or(() -> withDescription).ifPresent(f -> changeNode(context, f)); + + return CONTINUE; + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelAPI.java b/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelAPI.java index 66377a56390..62b9b5f0a45 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelAPI.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelAPI.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; +import org.opentripplanner.apis.support.graphql.injectdoc.ApiDocumentationProfile; import org.opentripplanner.apis.transmodel.mapping.TransitIdMapper; import org.opentripplanner.routing.api.request.RouteRequest; import org.opentripplanner.standalone.api.OtpServerRequestContext; @@ -80,6 +81,7 @@ public static void setUp( TransmodelAPIParameters config, TimetableRepository timetableRepository, RouteRequest defaultRouteRequest, + ApiDocumentationProfile documentationProfile, TransitRoutingConfig transitRoutingConfig ) { if (config.hideFeedId()) { @@ -91,6 +93,7 @@ public static void setUp( TransmodelGraphQLSchema.create( defaultRouteRequest, timetableRepository.getTimeZone(), + documentationProfile, transitRoutingConfig ); } diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java b/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java index 9ee9f2dad35..e9056081b12 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchema.java @@ -29,6 +29,7 @@ import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLScalarType; import graphql.schema.GraphQLSchema; +import graphql.schema.SchemaTransformer; import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; @@ -44,8 +45,12 @@ import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.annotation.Nullable; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; +import org.opentripplanner.apis.support.graphql.injectdoc.ApiDocumentationProfile; +import org.opentripplanner.apis.support.graphql.injectdoc.CustomDocumentation; +import org.opentripplanner.apis.support.graphql.injectdoc.InjectCustomDocumentation; import org.opentripplanner.apis.transmodel.mapping.PlaceMapper; import org.opentripplanner.apis.transmodel.mapping.TransitIdMapper; import org.opentripplanner.apis.transmodel.model.DefaultRouteRequestType; @@ -118,10 +123,10 @@ import org.opentripplanner.service.vehiclerental.model.VehicleRentalPlace; import org.opentripplanner.transit.api.model.FilterValues; import org.opentripplanner.transit.api.request.FindRegularStopsByBoundingBoxRequest; +import org.opentripplanner.transit.api.request.FindRoutesRequest; import org.opentripplanner.transit.api.request.TripRequest; import org.opentripplanner.transit.model.basic.TransitMode; import org.opentripplanner.transit.model.framework.FeedScopedId; -import org.opentripplanner.transit.model.network.Route; import org.opentripplanner.transit.model.site.StopLocation; import org.opentripplanner.transit.service.TransitService; import org.slf4j.Logger; @@ -158,10 +163,12 @@ private TransmodelGraphQLSchema( public static GraphQLSchema create( RouteRequest defaultRequest, ZoneId timeZoneId, - TransitTuningParameters transitTuningParameters + ApiDocumentationProfile docProfile, + TransitTuningParameters transitTuning ) { - return new TransmodelGraphQLSchema(defaultRequest, timeZoneId, transitTuningParameters) - .create(); + var schema = new TransmodelGraphQLSchema(defaultRequest, timeZoneId, transitTuning).create(); + schema = decorateSchemaWithCustomDocumentation(schema, docProfile); + return schema; } @SuppressWarnings("unchecked") @@ -1091,24 +1098,42 @@ private GraphQLSchema create() { GraphQLFieldDefinition .newFieldDefinition() .name("lines") - .description("Get all lines") + .description("Get all _lines_") .withDirective(TransmodelDirectives.TIMING_DATA) .type(new GraphQLNonNull(new GraphQLList(lineType))) .argument( GraphQLArgument .newArgument() .name("ids") + .description( + "Set of ids of _lines_ to fetch. If this is set, no other filters can be set." + ) .type(new GraphQLList(Scalars.GraphQLID)) .build() ) - .argument(GraphQLArgument.newArgument().name("name").type(Scalars.GraphQLString).build()) .argument( - GraphQLArgument.newArgument().name("publicCode").type(Scalars.GraphQLString).build() + GraphQLArgument + .newArgument() + .name("name") + .description( + "Prefix of the _name_ of the _line_ to fetch. This filter is case insensitive." + ) + .type(Scalars.GraphQLString) + .build() + ) + .argument( + GraphQLArgument + .newArgument() + .name("publicCode") + .description("_Public code_ of the _line_ to fetch.") + .type(Scalars.GraphQLString) + .build() ) .argument( GraphQLArgument .newArgument() .name("publicCodes") + .description("Set of _public codes_ to fetch _lines_ for.") .type(new GraphQLList(Scalars.GraphQLString)) .build() ) @@ -1116,6 +1141,7 @@ private GraphQLSchema create() { GraphQLArgument .newArgument() .name("transportModes") + .description("Set of _transport modes_ to fetch _lines_ for.") .type(new GraphQLList(TRANSPORT_MODE)) .build() ) @@ -1123,7 +1149,7 @@ private GraphQLSchema create() { GraphQLArgument .newArgument() .name("authorities") - .description("Set of ids of authorities to fetch lines for.") + .description("Set of ids of _authorities_ to fetch _lines_ for.") .type(new GraphQLList(Scalars.GraphQLString)) .build() ) @@ -1131,83 +1157,57 @@ private GraphQLSchema create() { GraphQLArgument .newArgument() .name("flexibleOnly") - .description("Filter by lines containing flexible / on demand serviceJourneys only.") + .description( + "Filter by _lines_ containing flexible / on demand _service journey_ only." + ) .type(Scalars.GraphQLBoolean) - .defaultValue(false) + .defaultValueProgrammatic(false) .build() ) .dataFetcher(environment -> { - if ((environment.getArgument("ids") instanceof List)) { + if (environment.containsArgument("ids")) { + var ids = mapIDsToDomainNullSafe(environment.getArgument("ids")); + + // flexibleLines gets special treatment because it has a default value. if ( - environment - .getArguments() - .entrySet() - .stream() - .filter(it -> - it.getValue() != null && - !(it.getKey().equals("flexibleOnly") && it.getValue().equals(false)) - ) - .count() != - 1 + Stream + .of("name", "publicCode", "publicCodes", "transportModes", "authorities") + .anyMatch(environment::containsArgument) || + Boolean.TRUE.equals(environment.getArgument("flexibleOnly")) ) { throw new IllegalArgumentException("Unable to combine other filters with ids"); } - return ((List) environment.getArgument("ids")).stream() - .map(TransitIdMapper::mapIDToDomain) - .map(id -> { - return GqlUtil.getTransitService(environment).getRoute(id); - }) - .collect(Collectors.toList()); - } - Stream stream = GqlUtil.getTransitService(environment).listRoutes().stream(); - if ((boolean) environment.getArgument("flexibleOnly")) { - Collection flexRoutes = GqlUtil - .getTransitService(environment) - .getFlexIndex() - .getAllFlexRoutes(); - stream = stream.filter(flexRoutes::contains); - } - if (environment.getArgument("name") != null) { - stream = - stream - .filter(route -> route.getLongName() != null) - .filter(route -> - route - .getLongName() - .toString() - .toLowerCase() - .startsWith(((String) environment.getArgument("name")).toLowerCase()) - ); - } - if (environment.getArgument("publicCode") != null) { - stream = - stream - .filter(route -> route.getShortName() != null) - .filter(route -> - route.getShortName().equals(environment.getArgument("publicCode")) - ); - } - if (environment.getArgument("publicCodes") instanceof List) { - Set publicCodes = Set.copyOf(environment.getArgument("publicCodes")); - stream = - stream - .filter(route -> route.getShortName() != null) - .filter(route -> publicCodes.contains(route.getShortName())); + return GqlUtil.getTransitService(environment).getRoutes(ids); } - if (environment.getArgument("transportModes") != null) { - Set modes = Set.copyOf(environment.getArgument("transportModes")); - stream = stream.filter(route -> modes.contains(route.getMode())); - } - if ((environment.getArgument("authorities") instanceof Collection)) { - Collection authorityIds = environment.getArgument("authorities"); - stream = - stream.filter(route -> - route.getAgency() != null && - authorityIds.contains(route.getAgency().getId().getId()) - ); - } - return stream.collect(Collectors.toList()); + + var name = environment.getArgument("name"); + var publicCode = environment.getArgument("publicCode"); + var publicCodes = FilterValues.ofEmptyIsEverything( + "publicCodes", + environment.>getArgument("publicCodes") + ); + var transportModes = FilterValues.ofEmptyIsEverything( + "transportModes", + environment.>getArgument("transportModes") + ); + var authorities = FilterValues.ofEmptyIsEverything( + "authorities", + environment.>getArgument("authorities") + ); + boolean flexibleOnly = Boolean.TRUE.equals(environment.getArgument("flexibleOnly")); + + FindRoutesRequest findRoutesRequest = FindRoutesRequest + .of() + .withLongName(name) + .withShortName(publicCode) + .withShortNames(publicCodes) + .withTransitModes(transportModes) + .withAgencies(authorities) + .withFlexibleOnly(flexibleOnly) + .build(); + + return GqlUtil.getTransitService(environment).findRoutes(findRoutesRequest); }) .build() ) @@ -1622,7 +1622,7 @@ private GraphQLSchema create() { .field(DatedServiceJourneyQuery.createQuery(datedServiceJourneyType)) .build(); - return GraphQLSchema + var schema = GraphQLSchema .newSchema() .query(queryType) .additionalType(placeInterface) @@ -1630,9 +1630,23 @@ private GraphQLSchema create() { .additionalType(Relay.pageInfoType) .additionalDirective(TransmodelDirectives.TIMING_DATA) .build(); + + return schema; + } + + private static GraphQLSchema decorateSchemaWithCustomDocumentation( + GraphQLSchema schema, + ApiDocumentationProfile docProfile + ) { + var customDocumentation = CustomDocumentation.of(docProfile); + if (customDocumentation.isEmpty()) { + return schema; + } + var visitor = new InjectCustomDocumentation(customDocumentation); + return SchemaTransformer.transformSchema(schema, visitor); } - private List toIdList(List ids) { + private List toIdList(@Nullable List ids) { if (ids == null) { return Collections.emptyList(); } diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/mapping/BookingInfoMapper.java b/application/src/main/java/org/opentripplanner/apis/transmodel/mapping/BookingInfoMapper.java new file mode 100644 index 00000000000..de59794ea18 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/mapping/BookingInfoMapper.java @@ -0,0 +1,38 @@ +package org.opentripplanner.apis.transmodel.mapping; + +import org.opentripplanner.transit.model.timetable.booking.BookingInfo; +import org.opentripplanner.transit.model.timetable.booking.BookingTime; + +/** + * Maps the {@link BookingInfo} to enum value (as a string) returned by the API. + */ +public class BookingInfoMapper { + + public static String mapToBookWhen(BookingInfo bookingInfo) { + if (bookingInfo.getMinimumBookingNotice().isPresent()) { + return null; + } + BookingTime latestBookingTime = bookingInfo.getLatestBookingTime(); + BookingTime earliestBookingTime = bookingInfo.getEarliestBookingTime(); + + // Try to deduce the original enum from stored values + if (earliestBookingTime == null) { + if (latestBookingTime == null) { + return "timeOfTravelOnly"; + } else if (latestBookingTime.getDaysPrior() == 1) { + return "untilPreviousDay"; + } else if (latestBookingTime.getDaysPrior() == 0) { + return "advanceAndDayOfTravel"; + } else { + return "other"; + } + } else if ( + earliestBookingTime.getDaysPrior() == 0 && + (latestBookingTime == null || latestBookingTime.getDaysPrior() == 0) + ) { + return "dayOfTravelOnly"; + } else { + return "other"; + } + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/mapping/RelativeDirectionMapper.java b/application/src/main/java/org/opentripplanner/apis/transmodel/mapping/RelativeDirectionMapper.java new file mode 100644 index 00000000000..1787515e0c7 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/mapping/RelativeDirectionMapper.java @@ -0,0 +1,33 @@ +package org.opentripplanner.apis.transmodel.mapping; + +import org.opentripplanner.model.plan.RelativeDirection; + +/** + * This mapper makes sure that only those values are returned which have a mapping in the Transmodel API, + * as we don't really want to return all of them. + */ +public class RelativeDirectionMapper { + + public static RelativeDirection map(RelativeDirection relativeDirection) { + return switch (relativeDirection) { + case DEPART, + SLIGHTLY_LEFT, + HARD_LEFT, + LEFT, + CONTINUE, + SLIGHTLY_RIGHT, + RIGHT, + HARD_RIGHT, + CIRCLE_CLOCKWISE, + CIRCLE_COUNTERCLOCKWISE, + ELEVATOR, + UTURN_LEFT, + UTURN_RIGHT, + ENTER_STATION, + EXIT_STATION, + FOLLOW_SIGNS -> relativeDirection; + // this type should never be exposed by an API + case ENTER_OR_EXIT_STATION -> RelativeDirection.CONTINUE; + }; + } +} diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java b/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java index 2f8e69cc593..fba1a8f637a 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/model/EnumTypes.java @@ -289,6 +289,9 @@ public class EnumTypes { .value("elevator", RelativeDirection.ELEVATOR) .value("uturnLeft", RelativeDirection.UTURN_LEFT) .value("uturnRight", RelativeDirection.UTURN_RIGHT) + .value("enterStation", RelativeDirection.ENTER_STATION) + .value("exitStation", RelativeDirection.EXIT_STATION) + .value("followSigns", RelativeDirection.FOLLOW_SIGNS) .build(); public static final GraphQLEnumType REPORT_TYPE = GraphQLEnumType diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/PathGuidanceType.java b/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/PathGuidanceType.java index 68406f57d54..8840e8fedb8 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/PathGuidanceType.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/model/plan/PathGuidanceType.java @@ -5,6 +5,7 @@ import graphql.schema.GraphQLList; import graphql.schema.GraphQLNonNull; import graphql.schema.GraphQLObjectType; +import org.opentripplanner.apis.transmodel.mapping.RelativeDirectionMapper; import org.opentripplanner.apis.transmodel.model.EnumTypes; import org.opentripplanner.framework.graphql.GraphQLUtils; import org.opentripplanner.model.plan.WalkStep; @@ -31,7 +32,9 @@ public static GraphQLObjectType create(GraphQLObjectType elevationStepType) { .name("relativeDirection") .description("The relative direction of this step.") .type(EnumTypes.RELATIVE_DIRECTION) - .dataFetcher(environment -> ((WalkStep) environment.getSource()).getRelativeDirection()) + .dataFetcher(environment -> + RelativeDirectionMapper.map(((WalkStep) environment.getSource()).getRelativeDirection()) + ) .build() ) .field( @@ -65,7 +68,9 @@ public static GraphQLObjectType create(GraphQLObjectType elevationStepType) { .name("exit") .description("When exiting a highway or traffic circle, the exit name/number.") .type(Scalars.GraphQLString) - .dataFetcher(environment -> ((WalkStep) environment.getSource()).getExit()) + .dataFetcher(environment -> + ((WalkStep) environment.getSource()).highwayExit().orElse(null) + ) .build() ) .field( diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/model/stop/RentalVehicleType.java b/application/src/main/java/org/opentripplanner/apis/transmodel/model/stop/RentalVehicleType.java index e44639e09e7..a142873cd97 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/model/stop/RentalVehicleType.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/model/stop/RentalVehicleType.java @@ -71,7 +71,7 @@ public static GraphQLObjectType create( .name("currentRangeMeters") .type(Scalars.GraphQLFloat) .dataFetcher(environment -> - ((VehicleRentalVehicle) environment.getSource()).currentRangeMeters + ((VehicleRentalVehicle) environment.getSource()).getFuel().range() ) .build() ) diff --git a/application/src/main/java/org/opentripplanner/apis/transmodel/model/timetable/BookingArrangementType.java b/application/src/main/java/org/opentripplanner/apis/transmodel/model/timetable/BookingArrangementType.java index 911ec8d9b0c..097fa92baca 100644 --- a/application/src/main/java/org/opentripplanner/apis/transmodel/model/timetable/BookingArrangementType.java +++ b/application/src/main/java/org/opentripplanner/apis/transmodel/model/timetable/BookingArrangementType.java @@ -6,6 +6,7 @@ import graphql.schema.GraphQLList; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLOutputType; +import org.opentripplanner.apis.transmodel.mapping.BookingInfoMapper; import org.opentripplanner.apis.transmodel.model.EnumTypes; import org.opentripplanner.apis.transmodel.model.framework.TransmodelScalars; import org.opentripplanner.transit.model.organization.ContactInfo; @@ -107,34 +108,7 @@ public static GraphQLObjectType create() { .name("bookWhen") .description("Time constraints for booking") .type(EnumTypes.PURCHASE_WHEN) - .dataFetcher(environment -> { - BookingInfo bookingInfo = bookingInfo(environment); - if (bookingInfo.getMinimumBookingNotice().isPresent()) { - return null; - } - BookingTime latestBookingTime = bookingInfo.getLatestBookingTime(); - BookingTime earliestBookingTime = bookingInfo.getEarliestBookingTime(); - - // Try to deduce the original enum from stored values - if (earliestBookingTime == null) { - if (latestBookingTime == null) { - return "timeOfTravelOnly"; - } else if (latestBookingTime.getDaysPrior() == 1) { - return "untilPreviousDay"; - } else if (latestBookingTime.getDaysPrior() == 0) { - return "advanceAndDayOfTravel"; - } else { - return "other"; - } - } else if ( - earliestBookingTime.getDaysPrior() == 0 && - (latestBookingTime == null || latestBookingTime.getDaysPrior() == 0) - ) { - return "dayOfTravelOnly"; - } else { - return "other"; - } - }) + .dataFetcher(environment -> BookingInfoMapper.mapToBookWhen(bookingInfo(environment))) .build() ) .field( 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 46c74b52a2d..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 @@ -73,6 +73,7 @@ static OsmModule provideOsmModule( osmConfiguredDataSource.dataSource(), osmConfiguredDataSource.config().osmTagMapper(), osmConfiguredDataSource.config().timeZone(), + osmConfiguredDataSource.config().includeOsmSubwayEntrances(), config.osmCacheDataInMem, issueStore ) @@ -88,6 +89,7 @@ static OsmModule provideOsmModule( .withStaticBikeParkAndRide(config.staticBikeParkAndRide) .withMaxAreaNodes(config.maxAreaNodes) .withBoardingAreaRefTags(config.boardingLocationTags) + .withIncludeOsmSubwayEntrances(config.osmDefaults.includeOsmSubwayEntrances()) .withIssueStore(issueStore) .withStreetLimitationParameters(streetLimitationParameters) .build(); @@ -257,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/graph_builder/module/osm/ElevatorProcessor.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/ElevatorProcessor.java index 490d6a266b9..45ed01e4568 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/ElevatorProcessor.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/ElevatorProcessor.java @@ -95,7 +95,7 @@ public void buildElevatorEdges(Graph graph) { } int travelTime = parseDuration(node).orElse(-1); - var wheelchair = node.getWheelchairAccessibility(); + var wheelchair = node.wheelchairAccessibility(); createElevatorHopEdges( onboardVertices, @@ -138,7 +138,7 @@ public void buildElevatorEdges(Graph graph) { int travelTime = parseDuration(elevatorWay).orElse(-1); int levels = nodes.size(); - var wheelchair = elevatorWay.getWheelchairAccessibility(); + var wheelchair = elevatorWay.wheelchairAccessibility(); createElevatorHopEdges( onboardVertices, diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java index 195d36b9ed1..3b9f411fec6 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModule.java @@ -81,7 +81,13 @@ public class OsmModule implements GraphBuilderModule { this.issueStore = issueStore; this.params = params; this.osmdb = new OsmDatabase(issueStore); - this.vertexGenerator = new VertexGenerator(osmdb, graph, params.boardingAreaRefTags()); + this.vertexGenerator = + new VertexGenerator( + osmdb, + graph, + params.boardingAreaRefTags(), + params.includeOsmSubwayEntrances() + ); this.normalizer = new SafetyValueNormalizer(graph, issueStore); this.streetLimitationParameters = Objects.requireNonNull(streetLimitationParameters); } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java index f7038a40c74..ce50dbbde64 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmModuleBuilder.java @@ -28,6 +28,7 @@ public class OsmModuleBuilder { private boolean platformEntriesLinking = false; private boolean staticParkAndRide = false; private boolean staticBikeParkAndRide = false; + private boolean includeOsmSubwayEntrances = false; private int maxAreaNodes; private StreetLimitationParameters streetLimitationParameters = new StreetLimitationParameters(); @@ -83,6 +84,11 @@ public OsmModuleBuilder withMaxAreaNodes(int maxAreaNodes) { return this; } + public OsmModuleBuilder withIncludeOsmSubwayEntrances(boolean includeOsmSubwayEntrances) { + this.includeOsmSubwayEntrances = includeOsmSubwayEntrances; + return this; + } + public OsmModuleBuilder withStreetLimitationParameters(StreetLimitationParameters parameters) { this.streetLimitationParameters = parameters; return this; @@ -103,7 +109,8 @@ public OsmModule build() { areaVisibility, platformEntriesLinking, staticParkAndRide, - staticBikeParkAndRide + staticBikeParkAndRide, + includeOsmSubwayEntrances ) ); } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/VertexGenerator.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/VertexGenerator.java index e6fec74b798..8c707d005a9 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/VertexGenerator.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/VertexGenerator.java @@ -33,12 +33,19 @@ class VertexGenerator { private final HashMap> multiLevelNodes = new HashMap<>(); private final OsmDatabase osmdb; private final Set boardingAreaRefTags; + private final Boolean includeOsmSubwayEntrances; private final VertexFactory vertexFactory; - public VertexGenerator(OsmDatabase osmdb, Graph graph, Set boardingAreaRefTags) { + public VertexGenerator( + OsmDatabase osmdb, + Graph graph, + Set boardingAreaRefTags, + boolean includeOsmSubwayEntrances + ) { this.osmdb = osmdb; this.vertexFactory = new VertexFactory(graph); this.boardingAreaRefTags = boardingAreaRefTags; + this.includeOsmSubwayEntrances = includeOsmSubwayEntrances; } /** @@ -95,6 +102,11 @@ IntersectionVertex getVertexForOsmNode(OsmNode node, OsmWithTags way) { iv = bv; } + if (includeOsmSubwayEntrances && node.isSubwayEntrance()) { + String ref = node.getTag("ref"); + iv = vertexFactory.stationEntrance(nid, coordinate, ref, node.wheelchairAccessibility()); + } + if (iv == null) { iv = vertexFactory.osm( diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParameters.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParameters.java index 175b9c04c5b..a59147137f6 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParameters.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParameters.java @@ -11,16 +11,28 @@ * Example: {@code "osm" : [ {source: "file:///path/to/otp/norway.pbf"} ] } * */ -public record OsmExtractParameters(URI source, OsmTagMapperSource osmTagMapper, ZoneId timeZone) +public record OsmExtractParameters( + URI source, + OsmTagMapperSource osmTagMapper, + ZoneId timeZone, + boolean includeOsmSubwayEntrances +) implements DataSourceConfig { public static final OsmTagMapperSource DEFAULT_OSM_TAG_MAPPER = OsmTagMapperSource.DEFAULT; public static final ZoneId DEFAULT_TIME_ZONE = null; + public static final boolean DEFAULT_INCLUDE_OSM_SUBWAY_ENTRANCES = false; + public static final OsmExtractParameters DEFAULT = new OsmExtractParametersBuilder().build(); OsmExtractParameters(OsmExtractParametersBuilder builder) { - this(builder.getSource(), builder.getOsmTagMapper(), builder.getTimeZone()); + this( + builder.getSource(), + builder.getOsmTagMapper(), + builder.getTimeZone(), + builder.includeOsmSubwayEntrances() + ); } @Override @@ -37,6 +49,10 @@ public ZoneId timeZone() { return timeZone; } + public boolean includeOsmSubwayEntrances() { + return includeOsmSubwayEntrances; + } + public OsmExtractParametersBuilder copyOf() { return new OsmExtractParametersBuilder(this); } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParametersBuilder.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParametersBuilder.java index 2d9bb71d9f5..66c65e05d81 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParametersBuilder.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmExtractParametersBuilder.java @@ -24,14 +24,18 @@ public class OsmExtractParametersBuilder { */ private ZoneId timeZone; + private boolean includeOsmSubwayEntrances; + public OsmExtractParametersBuilder() { this.osmTagMapper = OsmExtractParameters.DEFAULT_OSM_TAG_MAPPER; this.timeZone = OsmExtractParameters.DEFAULT_TIME_ZONE; + this.includeOsmSubwayEntrances = OsmExtractParameters.DEFAULT_INCLUDE_OSM_SUBWAY_ENTRANCES; } public OsmExtractParametersBuilder(OsmExtractParameters original) { this.osmTagMapper = original.osmTagMapper(); this.timeZone = original.timeZone(); + this.includeOsmSubwayEntrances = original.includeOsmSubwayEntrances(); } public OsmExtractParametersBuilder withSource(URI source) { @@ -49,6 +53,13 @@ public OsmExtractParametersBuilder withTimeZone(ZoneId timeZone) { return this; } + public OsmExtractParametersBuilder withIncludeOsmSubwayEntrances( + boolean includeOsmSubwayEntrances + ) { + this.includeOsmSubwayEntrances = includeOsmSubwayEntrances; + return this; + } + public URI getSource() { return source; } @@ -61,6 +72,10 @@ public ZoneId getTimeZone() { return timeZone; } + public boolean includeOsmSubwayEntrances() { + return includeOsmSubwayEntrances; + } + public OsmExtractParameters build() { return new OsmExtractParameters(this); } diff --git a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java index 52bf8d65314..a3fd14020e8 100644 --- a/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java +++ b/application/src/main/java/org/opentripplanner/graph_builder/module/osm/parameters/OsmProcessingParameters.java @@ -13,6 +13,7 @@ * @param platformEntriesLinking Whether platform entries should be linked * @param staticParkAndRide Whether we should create car P+R stations from OSM data. * @param staticBikeParkAndRide Whether we should create bike P+R stations from OSM data. + * @param includeOsmSubwayEntrances Whether we should create subway entrances from OSM data. */ public record OsmProcessingParameters( Set boardingAreaRefTags, @@ -21,7 +22,8 @@ public record OsmProcessingParameters( boolean areaVisibility, boolean platformEntriesLinking, boolean staticParkAndRide, - boolean staticBikeParkAndRide + boolean staticBikeParkAndRide, + boolean includeOsmSubwayEntrances ) { public OsmProcessingParameters { boardingAreaRefTags = Set.copyOf(Objects.requireNonNull(boardingAreaRefTags)); diff --git a/application/src/main/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapper.java b/application/src/main/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapper.java index 2218be9cf30..31fcf95d3cb 100644 --- a/application/src/main/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapper.java +++ b/application/src/main/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapper.java @@ -4,14 +4,18 @@ import java.util.Collection; import java.util.Objects; -import org.opentripplanner.ext.fares.model.Distance; import org.opentripplanner.ext.fares.model.FareDistance; import org.opentripplanner.ext.fares.model.FareLegRule; import org.opentripplanner.graph_builder.issue.api.DataImportIssueStore; +import org.opentripplanner.transit.model.basic.Distance; import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public final class FareLegRuleMapper { + private static final Logger LOG = LoggerFactory.getLogger(FareLegRuleMapper.class); + private final FareProductMapper fareProductMapper; private final DataImportIssueStore issueStore; @@ -75,8 +79,28 @@ private static FareDistance createFareDistance( fareLegRule.getMaxDistance().intValue() ); case 1 -> new FareDistance.LinearDistance( - Distance.ofMeters(fareLegRule.getMinDistance()), - Distance.ofMeters(fareLegRule.getMaxDistance()) + Distance + .ofMetersBoxed( + fareLegRule.getMinDistance(), + error -> + LOG.warn( + "Fare leg rule min distance not valid: {} - {}", + fareLegRule.getMinDistance(), + error + ) + ) + .orElse(null), + Distance + .ofMetersBoxed( + fareLegRule.getMaxDistance(), + error -> + LOG.warn( + "Fare leg rule max distance not valid: {} - {}", + fareLegRule.getMaxDistance(), + error + ) + ) + .orElse(null) ); default -> null; }; diff --git a/application/src/main/java/org/opentripplanner/model/plan/RelativeDirection.java b/application/src/main/java/org/opentripplanner/model/plan/RelativeDirection.java index ffc8993d0db..3ce16a45c11 100644 --- a/application/src/main/java/org/opentripplanner/model/plan/RelativeDirection.java +++ b/application/src/main/java/org/opentripplanner/model/plan/RelativeDirection.java @@ -21,6 +21,13 @@ public enum RelativeDirection { UTURN_RIGHT, ENTER_STATION, EXIT_STATION, + /** + * We don't have a way to reliably tell if we are entering or exiting a station and therefore + * use this generic enum value. Please don't expose it in APIs. + *

+ * If we manage to figure it out in the future, we can remove this. + */ + ENTER_OR_EXIT_STATION, FOLLOW_SIGNS; public static RelativeDirection calculate( diff --git a/application/src/main/java/org/opentripplanner/model/plan/WalkStep.java b/application/src/main/java/org/opentripplanner/model/plan/WalkStep.java index c2c2b2c609e..7ade16de39a 100644 --- a/application/src/main/java/org/opentripplanner/model/plan/WalkStep.java +++ b/application/src/main/java/org/opentripplanner/model/plan/WalkStep.java @@ -8,6 +8,7 @@ import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.note.StreetNote; +import org.opentripplanner.transit.model.site.Entrance; import org.opentripplanner.utils.lang.DoubleUtils; import org.opentripplanner.utils.tostring.ToStringBuilder; @@ -43,7 +44,8 @@ public final class WalkStep { private final double angle; private final boolean walkingBike; - private final String exit; + private final String highwayExit; + private final Entrance entrance; private final ElevationProfile elevationProfile; private final boolean stayOn; @@ -55,7 +57,8 @@ public final class WalkStep { AbsoluteDirection absoluteDirection, I18NString directionText, Set streetNotes, - String exit, + String highwayExit, + Entrance entrance, ElevationProfile elevationProfile, boolean nameIsDerived, boolean walkingBike, @@ -75,7 +78,8 @@ public final class WalkStep { this.angle = DoubleUtils.roundTo2Decimals(angle); this.walkingBike = walkingBike; this.area = area; - this.exit = exit; + this.highwayExit = highwayExit; + this.entrance = entrance; this.elevationProfile = elevationProfile; this.stayOn = stayOn; this.edges = List.copyOf(Objects.requireNonNull(edges)); @@ -126,8 +130,15 @@ public Optional getAbsoluteDirection() { /** * When exiting a highway or traffic circle, the exit name/number. */ - public String getExit() { - return exit; + public Optional highwayExit() { + return Optional.ofNullable(highwayExit); + } + + /** + * Get information about a subway station entrance or exit. + */ + public Optional entrance() { + return Optional.ofNullable(entrance); } /** diff --git a/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java b/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java index b2f9e1f7510..75589718861 100644 --- a/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java +++ b/application/src/main/java/org/opentripplanner/model/plan/WalkStepBuilder.java @@ -9,6 +9,7 @@ import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.street.model.edge.Edge; import org.opentripplanner.street.model.note.StreetNote; +import org.opentripplanner.transit.model.site.Entrance; import org.opentripplanner.utils.lang.DoubleUtils; import org.opentripplanner.utils.lang.IntUtils; @@ -25,6 +26,7 @@ public class WalkStepBuilder { private RelativeDirection relativeDirection; private ElevationProfile elevationProfile; private String exit; + private Entrance entrance; private boolean stayOn = false; /** * Distance used for appending elevation profiles @@ -74,6 +76,11 @@ public WalkStepBuilder withExit(String exit) { return this; } + public WalkStepBuilder withEntrance(@Nullable Entrance entrance) { + this.entrance = entrance; + return this; + } + public WalkStepBuilder withStayOn(boolean stayOn) { this.stayOn = stayOn; return this; @@ -159,6 +166,7 @@ public WalkStep build() { directionText, streetNotes, exit, + entrance, elevationProfile, nameIsDerived, walkingBike, diff --git a/application/src/main/java/org/opentripplanner/osm/OsmProvider.java b/application/src/main/java/org/opentripplanner/osm/OsmProvider.java index 91944a95b86..53d6acc87b9 100644 --- a/application/src/main/java/org/opentripplanner/osm/OsmProvider.java +++ b/application/src/main/java/org/opentripplanner/osm/OsmProvider.java @@ -37,6 +37,8 @@ public class OsmProvider { private final OsmTagMapper osmTagMapper; + private boolean includeOsmSubwayEntrances = false; + private final WayPropertySet wayPropertySet; private byte[] cachedBytes = null; @@ -46,6 +48,7 @@ public OsmProvider(File file, boolean cacheDataInMem) { new FileDataSource(file, FileType.OSM), OsmTagMapperSource.DEFAULT, null, + false, cacheDataInMem, DataImportIssueStore.NOOP ); @@ -55,11 +58,13 @@ public OsmProvider( DataSource dataSource, OsmTagMapperSource tagMapperSource, ZoneId zoneId, + boolean includeOsmSubwayEntrances, boolean cacheDataInMem, DataImportIssueStore issueStore ) { this.source = dataSource; this.zoneId = zoneId; + this.includeOsmSubwayEntrances = includeOsmSubwayEntrances; this.osmTagMapper = tagMapperSource.getInstance(); this.wayPropertySet = new WayPropertySet(issueStore); osmTagMapper.populateProperties(wayPropertySet); @@ -152,6 +157,10 @@ public OsmTagMapper getOsmTagMapper() { return osmTagMapper; } + public boolean getIncludeOsmSubwayEntrances() { + return includeOsmSubwayEntrances; + } + public WayPropertySet getWayPropertySet() { return wayPropertySet; } diff --git a/application/src/main/java/org/opentripplanner/osm/model/OsmNode.java b/application/src/main/java/org/opentripplanner/osm/model/OsmNode.java index c5539d1296e..cb9fcd679f0 100644 --- a/application/src/main/java/org/opentripplanner/osm/model/OsmNode.java +++ b/application/src/main/java/org/opentripplanner/osm/model/OsmNode.java @@ -63,6 +63,15 @@ public boolean isBarrier() { ); } + /** + * Checks if this node is a subway station entrance. + * + * @return true if it is + */ + public boolean isSubwayEntrance() { + return hasTag("railway") && "subway_entrance".equals(getTag("railway")); + } + /** * Consider barrier tag in permissions. Leave the rest for the super class. */ diff --git a/application/src/main/java/org/opentripplanner/osm/model/OsmWithTags.java b/application/src/main/java/org/opentripplanner/osm/model/OsmWithTags.java index 3f47d4454bd..10214460b15 100644 --- a/application/src/main/java/org/opentripplanner/osm/model/OsmWithTags.java +++ b/application/src/main/java/org/opentripplanner/osm/model/OsmWithTags.java @@ -139,7 +139,7 @@ public boolean isTagFalse(String tag) { /** * Returns the level of wheelchair access of the element. */ - public Accessibility getWheelchairAccessibility() { + public Accessibility wheelchairAccessibility() { if (isTagTrue("wheelchair")) { return Accessibility.POSSIBLE; } else if (isTagFalse("wheelchair")) { 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/mapping/StatesToWalkStepsMapper.java b/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapper.java index 4ce1c616e65..8ec6ac07e34 100644 --- a/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapper.java +++ b/application/src/main/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapper.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import javax.annotation.Nullable; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.opentripplanner.framework.geometry.DirectionUtils; @@ -25,9 +26,11 @@ import org.opentripplanner.street.model.edge.StreetEdge; import org.opentripplanner.street.model.edge.StreetTransitEntranceLink; import org.opentripplanner.street.model.vertex.ExitVertex; +import org.opentripplanner.street.model.vertex.StationEntranceVertex; import org.opentripplanner.street.model.vertex.Vertex; import org.opentripplanner.street.search.TraverseMode; import org.opentripplanner.street.search.state.State; +import org.opentripplanner.transit.model.site.Entrance; /** * Process a list of states into a list of walking/driving instructions for a street leg. @@ -158,7 +161,7 @@ private void processState(State backState, State forwardState) { return; } else if (edge instanceof StreetTransitEntranceLink link) { var direction = relativeDirectionForTransitLink(link); - createAndSaveStep(backState, forwardState, link.getName(), direction, edge); + createAndSaveStep(backState, forwardState, link.getName(), direction, edge, link.entrance()); return; } @@ -175,8 +178,18 @@ private void processState(State backState, State forwardState) { if (edge instanceof ElevatorAlightEdge) { addStep(createElevatorWalkStep(backState, forwardState, edge)); return; + } else if (backState.getVertex() instanceof StationEntranceVertex stationEntranceVertex) { + addStep(createStationEntranceWalkStep(backState, forwardState, stationEntranceVertex)); + return; } else if (edge instanceof PathwayEdge pwe && pwe.signpostedAs().isPresent()) { - createAndSaveStep(backState, forwardState, pwe.signpostedAs().get(), FOLLOW_SIGNS, edge); + createAndSaveStep( + backState, + forwardState, + pwe.signpostedAs().get(), + FOLLOW_SIGNS, + edge, + null + ); return; } @@ -515,12 +528,33 @@ private WalkStepBuilder createElevatorWalkStep(State backState, State forwardSta return step; } + private WalkStepBuilder createStationEntranceWalkStep( + State backState, + State forwardState, + StationEntranceVertex vertex + ) { + Entrance entrance = Entrance + .of(vertex.id()) + .withCode(vertex.code()) + .withCoordinate(new WgsCoordinate(vertex.getCoordinate())) + .withWheelchairAccessibility(vertex.wheelchairAccessibility()) + .build(); + + // don't care what came before or comes after + return createWalkStep(forwardState, backState) + // There is not a way to definitively determine if a user is entering or exiting the station, + // since the doors might be between or inside stations. + .withRelativeDirection(RelativeDirection.ENTER_OR_EXIT_STATION) + .withEntrance(entrance); + } + private void createAndSaveStep( State backState, State forwardState, I18NString name, RelativeDirection direction, - Edge edge + Edge edge, + @Nullable Entrance entrance ) { addStep( createWalkStep(forwardState, backState) @@ -528,6 +562,7 @@ private void createAndSaveStep( .withNameIsDerived(false) .withDirections(lastAngle, DirectionUtils.getFirstAngle(edge.getGeometry()), false) .withRelativeDirection(direction) + .withEntrance(entrance) .addDistance(edge.getDistanceMeters()) ); 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/service/vehiclerental/model/RentalVehicleFuel.java b/application/src/main/java/org/opentripplanner/service/vehiclerental/model/RentalVehicleFuel.java new file mode 100644 index 00000000000..0cc8e489b49 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/service/vehiclerental/model/RentalVehicleFuel.java @@ -0,0 +1,40 @@ +package org.opentripplanner.service.vehiclerental.model; + +import javax.annotation.Nullable; +import org.opentripplanner.transit.model.basic.Distance; +import org.opentripplanner.transit.model.basic.Ratio; + +/** + * Contains information about the current battery or fuel status. + * See the GBFS + * vehicle_status specification for more details. + */ +public class RentalVehicleFuel { + + /** + * Current fuel percentage, expressed from 0 to 1. + */ + @Nullable + private final Ratio percent; + + /** + * Distance that the vehicle can travel with the current fuel. + */ + @Nullable + private final Distance range; + + public RentalVehicleFuel(@Nullable Ratio fuelPercent, @Nullable Distance range) { + this.percent = fuelPercent; + this.range = range; + } + + @Nullable + public Ratio percent() { + return this.percent; + } + + @Nullable + public Distance range() { + return range; + } +} diff --git a/application/src/main/java/org/opentripplanner/service/vehiclerental/model/VehicleRentalVehicle.java b/application/src/main/java/org/opentripplanner/service/vehiclerental/model/VehicleRentalVehicle.java index 042e608c88f..446711bad36 100644 --- a/application/src/main/java/org/opentripplanner/service/vehiclerental/model/VehicleRentalVehicle.java +++ b/application/src/main/java/org/opentripplanner/service/vehiclerental/model/VehicleRentalVehicle.java @@ -22,9 +22,9 @@ public class VehicleRentalVehicle implements VehicleRentalPlace { public boolean isReserved = false; public boolean isDisabled = false; public Instant lastReported; - public Double currentRangeMeters; public VehicleRentalStation station; public String pricingPlanId; + public RentalVehicleFuel fuel; @Override public FeedScopedId getId() { @@ -133,4 +133,8 @@ public VehicleRentalStationUris getRentalUris() { public VehicleRentalSystem getVehicleRentalSystem() { return system; } + + public RentalVehicleFuel getFuel() { + return fuel; + } } 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/OsmConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/OsmConfig.java index 1b2ec0ed74d..5cc67844b50 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/OsmConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/buildconfig/OsmConfig.java @@ -1,6 +1,7 @@ package org.opentripplanner.standalone.config.buildconfig; import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_2; +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_7; import org.opentripplanner.graph_builder.module.osm.parameters.OsmExtractParameters; import org.opentripplanner.graph_builder.module.osm.parameters.OsmExtractParametersBuilder; @@ -84,6 +85,14 @@ public static OsmExtractParametersBuilder mapOsmGenericParameters( ) .docDefaultValue(docDefaults.timeZone()) .asZoneId(defaults.timeZone()) + ) + .withIncludeOsmSubwayEntrances( + node + .of("includeOsmSubwayEntrances") + .since(V2_7) + .summary("Whether to include subway entrances from the OSM data." + documentationAddition) + .docDefaultValue(docDefaults.includeOsmSubwayEntrances()) + .asBoolean(defaults.includeOsmSubwayEntrances()) ); } } 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/framework/project/EnvironmentVariableReplacer.java b/application/src/main/java/org/opentripplanner/standalone/config/framework/project/EnvironmentVariableReplacer.java index 17910fa62ca..c71c1237d3f 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/framework/project/EnvironmentVariableReplacer.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/framework/project/EnvironmentVariableReplacer.java @@ -3,12 +3,12 @@ import static java.util.Map.entry; import static org.opentripplanner.model.projectinfo.OtpProjectInfo.projectInfo; -import java.util.HashMap; import java.util.Map; import java.util.function.Function; -import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.annotation.Nullable; import org.opentripplanner.framework.application.OtpAppException; +import org.opentripplanner.utils.text.TextVariablesSubstitution; /** * Replaces environment variable placeholders specified on the format ${variable} in a text with the @@ -58,46 +58,47 @@ public class EnvironmentVariableReplacer { * Search for {@link #PATTERN}s and replace each placeholder with the value of the corresponding * environment variable. * - * @param source is used only to generate human friendly error message in case the text contain a - * placeholder which can not be found. - * @throws IllegalArgumentException if a placeholder exist in the {@code text}, but the - * environment variable do not exist. + * @param source is used only to generate a human friendly error message in case the text + * contains a placeholder which cannot be found. + * @throws IllegalArgumentException if a placeholder exists in the {@code text}, but the + * environment variable does not exist. */ public static String insertEnvironmentVariables(String text, String source) { - return insertVariables(text, source, System::getenv); + return insertVariables(text, source, EnvironmentVariableReplacer::getEnvVarOrProjectInfo); } + /** + * Same as {@link #insertEnvironmentVariables(String, String)}, but the caller mus provide the + * {@code variableResolver} - environment and project info variables are not available. + */ public static String insertVariables( String text, String source, - Function getEnvVar + Function variableResolver ) { - Map substitutions = new HashMap<>(); - Matcher matcher = PATTERN.matcher(text); + return TextVariablesSubstitution.insertVariables( + text, + variableResolver, + varName -> errorVariableNameNotFound(varName, source) + ); + } - while (matcher.find()) { - String subKey = matcher.group(0); - String nameOnly = matcher.group(1); - if (!substitutions.containsKey(nameOnly)) { - String value = getEnvVar.apply(nameOnly); - if (value != null) { - substitutions.put(subKey, value); - } else if (PROJECT_INFO.containsKey(nameOnly)) { - substitutions.put(subKey, PROJECT_INFO.get(nameOnly)); - } else { - throw new OtpAppException( - "Environment variable name '" + - nameOnly + - "' in config '" + - source + - "' not found in the system environment variables." - ); - } - } + @Nullable + private static String getEnvVarOrProjectInfo(String key) { + String value = System.getenv(key); + if (value == null) { + return PROJECT_INFO.get(key); } - for (Map.Entry entry : substitutions.entrySet()) { - text = text.replace(entry.getKey(), entry.getValue()); - } - return text; + return value; + } + + private static void errorVariableNameNotFound(String variableName, String source) { + throw new OtpAppException( + "Environment variable name '" + + variableName + + "' in config '" + + source + + "' not found in the system environment variables." + ); } } diff --git a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/ServerConfig.java b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/ServerConfig.java index a75300f62a2..64921eb813b 100644 --- a/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/ServerConfig.java +++ b/application/src/main/java/org/opentripplanner/standalone/config/routerconfig/ServerConfig.java @@ -1,9 +1,12 @@ package org.opentripplanner.standalone.config.routerconfig; +import static org.opentripplanner.standalone.config.framework.json.EnumMapper.docEnumValueList; import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_4; +import static org.opentripplanner.standalone.config.framework.json.OtpVersion.V2_7; import java.time.Duration; import java.util.List; +import org.opentripplanner.apis.support.graphql.injectdoc.ApiDocumentationProfile; import org.opentripplanner.framework.application.OtpAppException; import org.opentripplanner.standalone.config.framework.json.NodeAdapter; import org.opentripplanner.standalone.server.OTPWebApplicationParameters; @@ -13,6 +16,7 @@ public class ServerConfig implements OTPWebApplicationParameters { private final Duration apiProcessingTimeout; private final List traceParameters; + private final ApiDocumentationProfile apiDocumentationProfile; public ServerConfig(String parameterName, NodeAdapter root) { NodeAdapter c = root @@ -42,6 +46,14 @@ public ServerConfig(String parameterName, NodeAdapter root) { ) .asDuration(Duration.ofSeconds(-1)); + this.apiDocumentationProfile = + c + .of("apiDocumentationProfile") + .since(V2_7) + .summary(ApiDocumentationProfile.DEFAULT.typeDescription()) + .description(docEnumValueList(ApiDocumentationProfile.values())) + .asEnum(ApiDocumentationProfile.DEFAULT); + this.traceParameters = c .of("traceParameters") @@ -105,6 +117,15 @@ public Duration apiProcessingTimeout() { return apiProcessingTimeout; } + @Override + public List traceParameters() { + return traceParameters; + } + + public ApiDocumentationProfile apiDocumentationProfile() { + return apiDocumentationProfile; + } + public void validate(Duration streetRoutingTimeout) { if ( !apiProcessingTimeout.isNegative() && @@ -119,9 +140,4 @@ public void validate(Duration streetRoutingTimeout) { ); } } - - @Override - public List traceParameters() { - return traceParameters; - } } 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/standalone/configure/ConstructApplication.java b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java index ebb83a045d0..eeaaf6427cb 100644 --- a/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java +++ b/application/src/main/java/org/opentripplanner/standalone/configure/ConstructApplication.java @@ -192,6 +192,7 @@ private void setupTransitRoutingServer() { routerConfig().transmodelApi(), timetableRepository(), routerConfig().routingRequestDefaults(), + routerConfig().server().apiDocumentationProfile(), routerConfig().transitTuningConfig() ); } diff --git a/application/src/main/java/org/opentripplanner/street/model/edge/StreetTransitEntranceLink.java b/application/src/main/java/org/opentripplanner/street/model/edge/StreetTransitEntranceLink.java index 7145f6183e4..34ca3faeeb3 100644 --- a/application/src/main/java/org/opentripplanner/street/model/edge/StreetTransitEntranceLink.java +++ b/application/src/main/java/org/opentripplanner/street/model/edge/StreetTransitEntranceLink.java @@ -2,6 +2,7 @@ import org.opentripplanner.street.model.vertex.StreetVertex; import org.opentripplanner.street.model.vertex.TransitEntranceVertex; +import org.opentripplanner.transit.model.site.Entrance; /** * This represents the connection between a street vertex and a transit vertex belonging the street @@ -43,6 +44,18 @@ public boolean isExit() { return !isEntrance; } + /** + * Get the {@link Entrance} that this edge links to. + */ + public Entrance entrance() { + if (getToVertex() instanceof TransitEntranceVertex tev) { + return tev.getEntrance(); + } else if (getFromVertex() instanceof TransitEntranceVertex tev) { + return tev.getEntrance(); + } + throw new IllegalStateException("%s doesn't link to an entrance.".formatted(this)); + } + protected int getStreetToStopTime() { return 0; } diff --git a/application/src/main/java/org/opentripplanner/street/model/vertex/StationEntranceVertex.java b/application/src/main/java/org/opentripplanner/street/model/vertex/StationEntranceVertex.java new file mode 100644 index 00000000000..7b9a94b0725 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/street/model/vertex/StationEntranceVertex.java @@ -0,0 +1,58 @@ +package org.opentripplanner.street.model.vertex; + +import javax.annotation.Nullable; +import org.opentripplanner.transit.model.basic.Accessibility; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.utils.tostring.ToStringBuilder; + +/** + * A station entrance extracted from OSM and therefore not (yet) associated with the transit + * entity {@link org.opentripplanner.transit.model.site.Station}. + */ +public class StationEntranceVertex extends OsmVertex { + + private static final String FEED_ID = "osm"; + private final String code; + private final Accessibility wheelchairAccessibility; + + public StationEntranceVertex( + double lat, + double lon, + long nodeId, + String code, + Accessibility wheelchairAccessibility + ) { + super(lat, lon, nodeId); + this.code = code; + this.wheelchairAccessibility = wheelchairAccessibility; + } + + /** + * The id of the entrance which may or may not be human-readable. + */ + public FeedScopedId id() { + return new FeedScopedId(FEED_ID, String.valueOf(nodeId)); + } + + /** + * Short human-readable code of the exit, like A or H3. + * If we need a proper name like "Oranienplatz" we have to add a name field. + */ + @Nullable + public String code() { + return code; + } + + public Accessibility wheelchairAccessibility() { + return wheelchairAccessibility; + } + + @Override + public String toString() { + return ToStringBuilder + .of(StationEntranceVertex.class) + .addNum("nodeId", nodeId) + .addStr("code", code) + .toString(); + } +} diff --git a/application/src/main/java/org/opentripplanner/street/model/vertex/VertexFactory.java b/application/src/main/java/org/opentripplanner/street/model/vertex/VertexFactory.java index 422fc16c837..393502ba3be 100644 --- a/application/src/main/java/org/opentripplanner/street/model/vertex/VertexFactory.java +++ b/application/src/main/java/org/opentripplanner/street/model/vertex/VertexFactory.java @@ -11,6 +11,7 @@ import org.opentripplanner.service.vehiclerental.model.VehicleRentalPlace; import org.opentripplanner.service.vehiclerental.street.VehicleRentalPlaceVertex; import org.opentripplanner.street.model.edge.StreetEdge; +import org.opentripplanner.transit.model.basic.Accessibility; import org.opentripplanner.transit.model.site.BoardingArea; import org.opentripplanner.transit.model.site.Entrance; import org.opentripplanner.transit.model.site.PathwayNode; @@ -94,6 +95,17 @@ public ExitVertex exit(long nid, Coordinate coordinate, String exitName) { return addToGraph(new ExitVertex(coordinate.x, coordinate.y, nid, exitName)); } + public StationEntranceVertex stationEntrance( + long nid, + Coordinate coordinate, + String code, + Accessibility wheelchairAccessibility + ) { + return addToGraph( + new StationEntranceVertex(coordinate.x, coordinate.y, nid, code, wheelchairAccessibility) + ); + } + public OsmVertex osm( Coordinate coordinate, OsmNode node, 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/main/java/org/opentripplanner/transit/api/request/FindRoutesRequest.java b/application/src/main/java/org/opentripplanner/transit/api/request/FindRoutesRequest.java new file mode 100644 index 00000000000..d0f2153921d --- /dev/null +++ b/application/src/main/java/org/opentripplanner/transit/api/request/FindRoutesRequest.java @@ -0,0 +1,65 @@ +package org.opentripplanner.transit.api.request; + +import org.opentripplanner.transit.api.model.FilterValues; +import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.network.Route; + +/** + * A request for finding {@link Route}s. + *

+ * This request is used to retrieve Routes that match the provided filter values. + * At least one filter value must be provided. + */ +public class FindRoutesRequest { + + private final boolean flexibleOnly; + private final String longName; + private final String shortName; + private final FilterValues shortNames; + private final FilterValues transitModes; + private final FilterValues agencyIds; + + protected FindRoutesRequest( + boolean flexibleOnly, + String longName, + String shortName, + FilterValues shortNames, + FilterValues transitModes, + FilterValues agencyIds + ) { + this.flexibleOnly = flexibleOnly; + this.longName = longName; + this.shortName = shortName; + this.shortNames = shortNames; + this.transitModes = transitModes; + this.agencyIds = agencyIds; + } + + public static FindRoutesRequestBuilder of() { + return new FindRoutesRequestBuilder(); + } + + public boolean flexibleOnly() { + return flexibleOnly; + } + + public String longName() { + return longName; + } + + public String shortName() { + return shortName; + } + + public FilterValues shortNames() { + return shortNames; + } + + public FilterValues transitModes() { + return transitModes; + } + + public FilterValues agencies() { + return agencyIds; + } +} diff --git a/application/src/main/java/org/opentripplanner/transit/api/request/FindRoutesRequestBuilder.java b/application/src/main/java/org/opentripplanner/transit/api/request/FindRoutesRequestBuilder.java new file mode 100644 index 00000000000..66b03904abe --- /dev/null +++ b/application/src/main/java/org/opentripplanner/transit/api/request/FindRoutesRequestBuilder.java @@ -0,0 +1,65 @@ +package org.opentripplanner.transit.api.request; + +import java.util.List; +import javax.annotation.Nullable; +import org.opentripplanner.transit.api.model.FilterValues; +import org.opentripplanner.transit.model.basic.TransitMode; + +public class FindRoutesRequestBuilder { + + private boolean flexibleOnly; + private String longName; + private String shortName; + private FilterValues shortNames = FilterValues.ofEmptyIsEverything( + "shortNames", + List.of() + ); + private FilterValues transitModes = FilterValues.ofEmptyIsEverything( + "transitModes", + List.of() + ); + private FilterValues agencies = FilterValues.ofEmptyIsEverything("agencies", List.of()); + + protected FindRoutesRequestBuilder() {} + + public FindRoutesRequestBuilder withAgencies(FilterValues agencies) { + this.agencies = agencies; + return this; + } + + public FindRoutesRequestBuilder withFlexibleOnly(boolean flexibleOnly) { + this.flexibleOnly = flexibleOnly; + return this; + } + + public FindRoutesRequestBuilder withLongName(@Nullable String longName) { + this.longName = longName; + return this; + } + + public FindRoutesRequestBuilder withShortName(@Nullable String shortName) { + this.shortName = shortName; + return this; + } + + public FindRoutesRequestBuilder withShortNames(FilterValues shortNames) { + this.shortNames = shortNames; + return this; + } + + public FindRoutesRequestBuilder withTransitModes(FilterValues transitModes) { + this.transitModes = transitModes; + return this; + } + + public FindRoutesRequest build() { + return new FindRoutesRequest( + flexibleOnly, + longName, + shortName, + shortNames, + transitModes, + agencies + ); + } +} diff --git a/application/src/main/java/org/opentripplanner/transit/model/basic/Distance.java b/application/src/main/java/org/opentripplanner/transit/model/basic/Distance.java new file mode 100644 index 00000000000..04c6c1bbf6f --- /dev/null +++ b/application/src/main/java/org/opentripplanner/transit/model/basic/Distance.java @@ -0,0 +1,112 @@ +package org.opentripplanner.transit.model.basic; + +import java.util.Optional; +import java.util.function.Consumer; +import javax.annotation.Nullable; +import org.opentripplanner.utils.tostring.ValueObjectToStringBuilder; + +public class Distance { + + private static final int MILLIMETERS_PER_M = 1000; + private static final int MILLIMETERS_PER_KM = 1000 * MILLIMETERS_PER_M; + private final int millimeters; + + /** + * Represents a distance. + * The class ensures that the distance, saved as an integer + * representing the millimeters, is not negative. + */ + private Distance(int distanceInMillimeters) { + this.millimeters = distanceInMillimeters; + } + + /** + * This method is similar to {@link #of(double, Consumer)}, but throws an + * {@link IllegalArgumentException} if the distance is negative. + */ + private static Distance of(int distanceInMillimeters) { + return of( + distanceInMillimeters, + errMsg -> { + throw new IllegalArgumentException(errMsg); + } + ) + .orElseThrow(); + } + + private static Optional of( + int distanceInMillimeters, + Consumer validationErrorHandler + ) { + if (distanceInMillimeters >= 0) { + return Optional.of(new Distance(distanceInMillimeters)); + } else { + validationErrorHandler.accept( + "Distance must be greater or equal than 0, but was: " + distanceInMillimeters + ); + return Optional.empty(); + } + } + + private static Optional ofBoxed( + @Nullable Double value, + Consumer validationErrorHandler, + int multiplier + ) { + if (value == null) { + return Optional.empty(); + } + return of((int) (value * multiplier), validationErrorHandler); + } + + /** Returns a Distance object representing the given number of meters */ + public static Optional ofMetersBoxed( + @Nullable Double value, + Consumer validationErrorHandler + ) { + return ofBoxed(value, validationErrorHandler, MILLIMETERS_PER_M); + } + + /** Returns a Distance object representing the given number of kilometers */ + public static Optional ofKilometersBoxed( + @Nullable Double value, + Consumer validationErrorHandler + ) { + return ofBoxed(value, validationErrorHandler, MILLIMETERS_PER_KM); + } + + /** Returns the distance in meters */ + public int toMeters() { + double meters = (double) this.millimeters / (double) MILLIMETERS_PER_M; + return (int) Math.round(meters); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + var other = (Distance) o; + return this.millimeters == other.millimeters; + } + + @Override + public int hashCode() { + return Integer.hashCode(this.millimeters); + } + + @Override + public String toString() { + if (millimeters > MILLIMETERS_PER_KM) { + return ValueObjectToStringBuilder + .of() + .addNum((double) this.millimeters / (double) MILLIMETERS_PER_KM, "km") + .toString(); + } else { + return ValueObjectToStringBuilder + .of() + .addNum((double) this.millimeters / (double) MILLIMETERS_PER_M, "m") + .toString(); + } + } +} diff --git a/application/src/main/java/org/opentripplanner/transit/model/basic/Ratio.java b/application/src/main/java/org/opentripplanner/transit/model/basic/Ratio.java new file mode 100644 index 00000000000..0ca16391475 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/transit/model/basic/Ratio.java @@ -0,0 +1,76 @@ +package org.opentripplanner.transit.model.basic; + +import java.util.Optional; +import java.util.function.Consumer; +import javax.annotation.Nullable; +import org.opentripplanner.utils.lang.DoubleUtils; + +/** + * Represents a ratio within the range [0, 1]. + * The class ensures that the ratio value, represented as a double, + * falls withing the specified range. + */ +public class Ratio { + + private final double ratio; + + private Ratio(double ratio) { + this.ratio = DoubleUtils.roundTo3Decimals(ratio); + } + + /** + * This method is similar to {@link #of(double, Consumer)}, but throws an + * {@link IllegalArgumentException} if the ratio is not valid. + */ + public static Ratio of(double ratio) { + return of( + ratio, + errMsg -> { + throw new IllegalArgumentException(errMsg); + } + ) + .orElseThrow(); + } + + public static Optional of(double ratio, Consumer validationErrorHandler) { + if (ratio >= 0d && ratio <= 1d) { + return Optional.of(new Ratio(ratio)); + } else { + validationErrorHandler.accept("Ratio must be in range [0,1], but was: " + ratio); + return Optional.empty(); + } + } + + public static Optional ofBoxed( + @Nullable Double ratio, + Consumer validationErrorHandler + ) { + if (ratio == null) { + return Optional.empty(); + } + return of(ratio, validationErrorHandler); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + var other = (Ratio) o; + return Double.compare(ratio, other.ratio) == 0; + } + + @Override + public int hashCode() { + return Double.hashCode(ratio); + } + + @Override + public String toString() { + return Double.toString(ratio); + } + + public double asDouble() { + return ratio; + } +} diff --git a/application/src/main/java/org/opentripplanner/transit/model/filter/expr/CaseInsensitiveStringPrefixMatcher.java b/application/src/main/java/org/opentripplanner/transit/model/filter/expr/CaseInsensitiveStringPrefixMatcher.java new file mode 100644 index 00000000000..befa4517426 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/transit/model/filter/expr/CaseInsensitiveStringPrefixMatcher.java @@ -0,0 +1,41 @@ +package org.opentripplanner.transit.model.filter.expr; + +import java.util.function.Function; + +/** + * A matcher that checks if a string field starts with a given value. + *

+ * @param The type of the entity being matched. + */ +public class CaseInsensitiveStringPrefixMatcher implements Matcher { + + private final String typeName; + private final String value; + private final Function valueProvider; + + /** + * @param typeName The typeName appears in the toString for easier debugging. + * @param value - The String that may be a prefix. + * @param valueProvider - A function that maps the entity being matched to the String being + * checked for a prefix match. + */ + public CaseInsensitiveStringPrefixMatcher( + String typeName, + String value, + Function valueProvider + ) { + this.typeName = typeName; + this.value = value; + this.valueProvider = valueProvider; + } + + @Override + public boolean match(T entity) { + return valueProvider.apply(entity).toLowerCase().startsWith(value.toLowerCase()); + } + + @Override + public String toString() { + return typeName + " has prefix: " + value; + } +} diff --git a/application/src/main/java/org/opentripplanner/transit/model/filter/expr/NullSafeWrapperMatcher.java b/application/src/main/java/org/opentripplanner/transit/model/filter/expr/NullSafeWrapperMatcher.java new file mode 100644 index 00000000000..4e972ac69c6 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/transit/model/filter/expr/NullSafeWrapperMatcher.java @@ -0,0 +1,42 @@ +package org.opentripplanner.transit.model.filter.expr; + +import java.util.function.Function; + +/** + * A matcher that validates that a value is not null before applying another matcher. A useful case + * is when you want to check that a String field is not null before applying a {@link CaseInsensitiveStringPrefixMatcher}. + *

+ * @param The type of the entity being matched. + * @param The type of the value that the matcher will test for not null. + */ +public class NullSafeWrapperMatcher implements Matcher { + + private final String typeName; + private final Function valueProvider; + private final Matcher valueMatcher; + + /** + * @param typeName The typeName appears in the toString for easier debugging. + * @param valueProvider The function that maps the entity being matched by this matcher (T) to + * the value being checked for non-null. + */ + public NullSafeWrapperMatcher( + String typeName, + Function valueProvider, + Matcher valueMatcher + ) { + this.typeName = typeName; + this.valueProvider = valueProvider; + this.valueMatcher = valueMatcher; + } + + @Override + public boolean match(T entity) { + return valueProvider.apply(entity) != null && valueMatcher.match(entity); + } + + @Override + public String toString() { + return typeName + " is not null"; + } +} diff --git a/application/src/main/java/org/opentripplanner/transit/model/filter/transit/RouteMatcherFactory.java b/application/src/main/java/org/opentripplanner/transit/model/filter/transit/RouteMatcherFactory.java new file mode 100644 index 00000000000..c673a193a50 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/transit/model/filter/transit/RouteMatcherFactory.java @@ -0,0 +1,81 @@ +package org.opentripplanner.transit.model.filter.transit; + +import java.util.function.Predicate; +import org.opentripplanner.transit.api.request.FindRoutesRequest; +import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.filter.expr.CaseInsensitiveStringPrefixMatcher; +import org.opentripplanner.transit.model.filter.expr.EqualityMatcher; +import org.opentripplanner.transit.model.filter.expr.ExpressionBuilder; +import org.opentripplanner.transit.model.filter.expr.GenericUnaryMatcher; +import org.opentripplanner.transit.model.filter.expr.Matcher; +import org.opentripplanner.transit.model.filter.expr.NullSafeWrapperMatcher; +import org.opentripplanner.transit.model.network.Route; + +public class RouteMatcherFactory { + + public static Matcher of( + FindRoutesRequest request, + Predicate isFlexRoutePredicate + ) { + ExpressionBuilder expr = ExpressionBuilder.of(); + + if (request.flexibleOnly()) { + expr.matches(isFlexRoute(isFlexRoutePredicate)); + } + expr.atLeastOneMatch(request.agencies(), RouteMatcherFactory::agencies); + expr.atLeastOneMatch(request.transitModes(), RouteMatcherFactory::transitModes); + if (request.shortName() != null) { + expr.matches(shortName(request.shortName())); + } + expr.atLeastOneMatch(request.shortNames(), RouteMatcherFactory::shortNames); + if (request.longName() != null) { + expr.matches(longName(request.longName())); + } + + return expr.build(); + } + + static Matcher agencies(String agencyId) { + return new NullSafeWrapperMatcher<>( + "agency", + Route::getAgency, + new EqualityMatcher<>("agencyId", agencyId, route -> route.getAgency().getId().getId()) + ); + } + + static Matcher transitModes(TransitMode transitMode) { + return new EqualityMatcher<>("transitMode", transitMode, Route::getMode); + } + + static Matcher shortName(String publicCode) { + return new NullSafeWrapperMatcher<>( + "shortName", + Route::getShortName, + new EqualityMatcher<>("shortName", publicCode, Route::getShortName) + ); + } + + static Matcher shortNames(String publicCode) { + return new NullSafeWrapperMatcher<>( + "shortNames", + Route::getShortName, + new EqualityMatcher<>("shortNames", publicCode, Route::getShortName) + ); + } + + static Matcher isFlexRoute(Predicate isFlexRoute) { + return new GenericUnaryMatcher<>("isFlexRoute", isFlexRoute); + } + + static Matcher longName(String name) { + return new NullSafeWrapperMatcher<>( + "longName", + Route::getLongName, + new CaseInsensitiveStringPrefixMatcher<>( + "name", + name, + route -> route.getLongName().toString() + ) + ); + } +} diff --git a/application/src/main/java/org/opentripplanner/transit/model/site/StationElementBuilder.java b/application/src/main/java/org/opentripplanner/transit/model/site/StationElementBuilder.java index 7a7fc0e4621..ea90231bead 100644 --- a/application/src/main/java/org/opentripplanner/transit/model/site/StationElementBuilder.java +++ b/application/src/main/java/org/opentripplanner/transit/model/site/StationElementBuilder.java @@ -1,5 +1,6 @@ package org.opentripplanner.transit.model.site; +import javax.annotation.Nullable; import org.opentripplanner.framework.geometry.WgsCoordinate; import org.opentripplanner.framework.i18n.I18NString; import org.opentripplanner.transit.model.basic.Accessibility; @@ -54,7 +55,7 @@ public String code() { return code; } - public B withCode(String code) { + public B withCode(@Nullable String code) { this.code = code; return instance(); } diff --git a/application/src/main/java/org/opentripplanner/transit/service/DefaultTransitService.java b/application/src/main/java/org/opentripplanner/transit/service/DefaultTransitService.java index d27fe138ef4..7c59030605d 100644 --- a/application/src/main/java/org/opentripplanner/transit/service/DefaultTransitService.java +++ b/application/src/main/java/org/opentripplanner/transit/service/DefaultTransitService.java @@ -14,6 +14,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -36,12 +37,14 @@ import org.opentripplanner.routing.stoptimes.ArrivalDeparture; import org.opentripplanner.routing.stoptimes.StopTimesHelper; import org.opentripplanner.transit.api.request.FindRegularStopsByBoundingBoxRequest; +import org.opentripplanner.transit.api.request.FindRoutesRequest; import org.opentripplanner.transit.api.request.TripOnServiceDateRequest; import org.opentripplanner.transit.api.request.TripRequest; import org.opentripplanner.transit.model.basic.Notice; import org.opentripplanner.transit.model.basic.TransitMode; import org.opentripplanner.transit.model.filter.expr.Matcher; import org.opentripplanner.transit.model.filter.transit.RegularStopMatcherFactory; +import org.opentripplanner.transit.model.filter.transit.RouteMatcherFactory; import org.opentripplanner.transit.model.filter.transit.TripMatcherFactory; import org.opentripplanner.transit.model.filter.transit.TripOnServiceDateMatcherFactory; import org.opentripplanner.transit.model.framework.AbstractTransitEntity; @@ -193,6 +196,17 @@ public Route getRoute(FeedScopedId id) { return timetableRepositoryIndex.getRouteForId(id); } + @Override + public Collection getRoutes(Collection ids) { + return ids.stream().map(this::getRoute).filter(Objects::nonNull).toList(); + } + + @Override + public Collection findRoutes(FindRoutesRequest request) { + Matcher matcher = RouteMatcherFactory.of(request, this.getFlexIndex()::contains); + return listRoutes().stream().filter(matcher::match).toList(); + } + /** * Add a route to the transit model. * Used only in unit tests. diff --git a/application/src/main/java/org/opentripplanner/transit/service/TransitService.java b/application/src/main/java/org/opentripplanner/transit/service/TransitService.java index 6e005b355d1..00cfcc6673d 100644 --- a/application/src/main/java/org/opentripplanner/transit/service/TransitService.java +++ b/application/src/main/java/org/opentripplanner/transit/service/TransitService.java @@ -25,6 +25,7 @@ import org.opentripplanner.routing.services.TransitAlertService; import org.opentripplanner.routing.stoptimes.ArrivalDeparture; import org.opentripplanner.transit.api.request.FindRegularStopsByBoundingBoxRequest; +import org.opentripplanner.transit.api.request.FindRoutesRequest; import org.opentripplanner.transit.api.request.TripOnServiceDateRequest; import org.opentripplanner.transit.api.request.TripRequest; import org.opentripplanner.transit.model.basic.Notice; @@ -105,6 +106,11 @@ public interface TransitService { */ Route getRoute(FeedScopedId id); + /** + * Return all routes for a given set of ids, including routes created by real-time updates. + */ + Collection getRoutes(Collection ids); + /** * Return the routes using the given stop, not including real-time updates. */ @@ -334,4 +340,9 @@ List findTripTimeOnDate( Collection findRegularStopsByBoundingBox( FindRegularStopsByBoundingBoxRequest request ); + + /** + * Returns a list of {@link Route}s that match the filtering defined in the request. + */ + Collection findRoutes(FindRoutesRequest request); } diff --git a/application/src/main/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapper.java b/application/src/main/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapper.java index 959521e017e..7c242e85bbe 100644 --- a/application/src/main/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapper.java +++ b/application/src/main/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapper.java @@ -9,14 +9,23 @@ import org.mobilitydata.gbfs.v2_3.free_bike_status.GBFSBike; import org.mobilitydata.gbfs.v2_3.free_bike_status.GBFSRentalUris; import org.opentripplanner.framework.i18n.NonLocalizedString; +import org.opentripplanner.service.vehiclerental.model.RentalVehicleFuel; import org.opentripplanner.service.vehiclerental.model.RentalVehicleType; import org.opentripplanner.service.vehiclerental.model.VehicleRentalStationUris; import org.opentripplanner.service.vehiclerental.model.VehicleRentalSystem; import org.opentripplanner.service.vehiclerental.model.VehicleRentalVehicle; +import org.opentripplanner.transit.model.basic.Distance; +import org.opentripplanner.transit.model.basic.Ratio; import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.utils.logging.Throttle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class GbfsFreeVehicleStatusMapper { + private static final Logger LOG = LoggerFactory.getLogger(GbfsFreeVehicleStatusMapper.class); + private static final Throttle LOG_THROTTLE = Throttle.ofOneMinute(); + private final VehicleRentalSystem system; private final Map vehicleTypes; @@ -52,7 +61,41 @@ public VehicleRentalVehicle mapFreeVehicleStatus(GBFSBike vehicle) { vehicle.getLastReported() != null ? Instant.ofEpochSecond((long) (double) vehicle.getLastReported()) : null; - rentalVehicle.currentRangeMeters = vehicle.getCurrentRangeMeters(); + + var fuelRatio = Ratio + .ofBoxed( + vehicle.getCurrentFuelPercent(), + validationErrorMessage -> + LOG_THROTTLE.throttle(() -> + LOG.warn("'currentFuelPercent' is not valid. Details: {}", validationErrorMessage) + ) + ) + .orElse(null); + var rangeMeters = Distance + .ofMetersBoxed( + vehicle.getCurrentRangeMeters(), + error -> { + LOG_THROTTLE.throttle(() -> + LOG.warn( + "Current range meter value not valid: {} - {}", + vehicle.getCurrentRangeMeters(), + error + ) + ); + } + ) + .orElse(null); + // if the propulsion type has an engine current_range_meters is required + if ( + vehicle.getVehicleTypeId() != null && + vehicleTypes.get(vehicle.getVehicleTypeId()) != null && + vehicleTypes.get(vehicle.getVehicleTypeId()).propulsionType != + RentalVehicleType.PropulsionType.HUMAN && + rangeMeters == null + ) { + return null; + } + rentalVehicle.fuel = new RentalVehicleFuel(fuelRatio, rangeMeters); rentalVehicle.pricingPlanId = vehicle.getPricingPlanId(); GBFSRentalUris rentalUris = vehicle.getRentalUris(); if (rentalUris != null) { diff --git a/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls b/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls index 537d9b680b4..838e4d49bf1 100644 --- a/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls +++ b/application/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls @@ -82,6 +82,9 @@ union CallStopLocation = Stop "Rental place union that represents either a VehicleRentalStation or a RentalVehicle" union RentalPlace = RentalVehicle | VehicleRentalStation +"A feature for a step" +union StepFeature = Entrance + union StopPosition = PositionAtStop | PositionBetweenStops "A public transport agency" @@ -488,6 +491,18 @@ type Emissions { co2: Grams } +"Station entrance or exit, originating from OSM or GTFS data." +type Entrance { + "ID of the entrance in the format of `FeedId:EntranceId`. If the `FeedId` is `osm`, the entrance originates from OSM data." + entranceId: String! + "Name of the entrance or exit." + name: String + "Short text or a number that identifies the entrance or exit for passengers. For example, `A` or `B`." + publicCode: String + "Whether the entrance or exit is accessible by wheelchair" + wheelchairAccessible: WheelchairBoarding +} + "Real-time estimates for an arrival or departure at a certain place." type EstimatedTime { """ @@ -1874,6 +1889,8 @@ type RealTimeEstimate { type RentalVehicle implements Node & PlaceInterface { "If true, vehicle is currently available for renting." allowPickupNow: Boolean + "Fuel or battery status of the rental vehicle" + fuel: RentalVehicleFuel "Global object ID provided by Relay. This value can be used to refetch this object using **node** query." id: ID! "Latitude of the vehicle (WGS 84)" @@ -1903,6 +1920,14 @@ type RentalVehicleEntityCounts { total: Int! } +"Rental vehicle fuel represent the current status of the battery or fuel of a rental vehicle" +type RentalVehicleFuel { + "Fuel or battery power remaining in the vehicle. Expressed from 0 to 1." + percent: Ratio + "Range in meters that the vehicle can travel with the current charge or fuel." + range: Int +} + type RentalVehicleType { "The vehicle's general form factor" formFactor: FormFactor @@ -2844,6 +2869,8 @@ type step { elevationProfile: [elevationProfileComponent] "When exiting a highway or traffic circle, the exit name/number." exit: String + "Information about an feature associated with a step e.g. an station entrance or exit" + feature: StepFeature "The latitude of the start of the step." lat: Float "The longitude of the start of the step." @@ -3518,15 +3545,40 @@ enum RealtimeState { UPDATED } -"Actions to take relative to the current position when engaging a walking/driving step." +""" +A direction that is not absolute but rather fuzzy and context-dependent. +It provides the passenger with information what they should do in this step depending on where they +were in the previous one. +""" enum RelativeDirection { CIRCLE_CLOCKWISE CIRCLE_COUNTERCLOCKWISE + """ + Moving straight ahead in one of these cases + + - Passing through a crossing or intersection. + - Passing through a station entrance or exit when it is not know whether the passenger is + entering or exiting. If it _is_ known then `ENTER_STATION`/`EXIT_STATION` is used. + More information about the entrance is in the `step.feature` field. + """ CONTINUE DEPART ELEVATOR + """ + Entering a public transport station. If it's not known if the passenger is entering or exiting + then `CONTINUE` is used. + + More information about the entrance is in the `step.feature` field. + """ ENTER_STATION + """ + Exiting a public transport station. If it's not known if the passenger is entering or exiting + then `CONTINUE` is used. + + More information about the entrance is in the `step.feature` field. + """ EXIT_STATION + "Follow the signs indicating a specific location like \"platform 1\" or \"exit B\"." FOLLOW_SIGNS HARD_LEFT HARD_RIGHT diff --git a/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql b/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql index 6834d375bf1..611f6e38156 100644 --- a/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql +++ b/application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql @@ -651,16 +651,21 @@ type QueryType { leg(id: ID!): Leg @timingData "Get a single line based on its id" line(id: ID!): Line @timingData - "Get all lines" + "Get all _lines_" lines( - "Set of ids of authorities to fetch lines for." + "Set of ids of _authorities_ to fetch _lines_ for." authorities: [String], - "Filter by lines containing flexible / on demand serviceJourneys only." + "Filter by _lines_ containing flexible / on demand _service journey_ only." flexibleOnly: Boolean = false, + "Set of ids of _lines_ to fetch. If this is set, no other filters can be set." ids: [ID], + "Prefix of the _name_ of the _line_ to fetch. This filter is case insensitive." name: String, + "_Public code_ of the _line_ to fetch." publicCode: String, + "Set of _public codes_ to fetch _lines_ for." publicCodes: [String], + "Set of _transport modes_ to fetch _lines_ for." transportModes: [TransportMode] ): [Line]! @timingData "Get all places (quays, stop places, car parks etc. with coordinates) within the specified radius from a location. The returned type has two fields place and distance. The search is done by walking so the distance is according to the network of walkables." @@ -1719,6 +1724,9 @@ enum RelativeDirection { continue depart elevator + enterStation + exitStation + followSigns hardLeft hardRight left 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/_support/text/TextAssertions.java b/application/src/test/java/org/opentripplanner/_support/text/TextAssertions.java new file mode 100644 index 00000000000..a009b76237c --- /dev/null +++ b/application/src/test/java/org/opentripplanner/_support/text/TextAssertions.java @@ -0,0 +1,64 @@ +package org.opentripplanner._support.text; + +import org.junit.jupiter.api.Assertions; + +/** + * This class contains test assert methods not supported by the standard JUnit + * framework. + */ +public final class TextAssertions { + + private static final String LINE_DELIMITERS = "(\n|\r|\r\n)"; + private static final int END_OF_TEXT = -111; + + /** + + * Assert to texts are equals line by line. Empty lines and white-space in the start and end of + * a line is ignored. + */ + public static void assertLinesEquals(String expected, String actual) { + var expLines = expected.split(LINE_DELIMITERS); + var actLines = actual.split(LINE_DELIMITERS); + + int i = -1; + int j = -1; + + while (true) { + i = next(expLines, i); + j = next(actLines, j); + + if (i == END_OF_TEXT && j == END_OF_TEXT) { + return; + } + + var exp = getLine(expLines, i); + var act = getLine(actLines, j); + + if (i == END_OF_TEXT || j == END_OF_TEXT || !exp.equals(act)) { + Assertions.fail( + "Expected%s: <%s>%n".formatted(lineText(i), exp) + + "Actual %s: <%s>%n".formatted(lineText(j), act) + ); + } + } + } + + private static String lineText(int index) { + return index < 0 ? "(@end-of-text)" : "(@line %d)".formatted(index); + } + + private static String getLine(String[] lines, int i) { + return i == END_OF_TEXT ? "" : lines[i].trim(); + } + + private static int next(String[] lines, int index) { + ++index; + while (index < lines.length) { + if (!lines[index].isBlank()) { + return index; + } + ++index; + } + return END_OF_TEXT; + } +} diff --git a/application/src/test/java/org/opentripplanner/_support/text/TextAssertionsTest.java b/application/src/test/java/org/opentripplanner/_support/text/TextAssertionsTest.java new file mode 100644 index 00000000000..739b7b59c4b --- /dev/null +++ b/application/src/test/java/org/opentripplanner/_support/text/TextAssertionsTest.java @@ -0,0 +1,49 @@ +package org.opentripplanner._support.text; + +import static org.opentripplanner._support.text.TextAssertions.assertLinesEquals; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TextAssertionsTest { + + @Test + void testIgnoreWhiteSpace() { + // Empty text + assertLinesEquals("", "\n\n"); + + // Text with white-space inserted + assertLinesEquals( + """ + A Test + Line 2 + DOS\r\n + line-shift + """, + """ + + A Test \t + \t + + \tLine 2 + DOS\rline-shift + """ + ); + } + + @Test + void testEndOfText() { + var ex = Assertions.assertThrows( + org.opentest4j.AssertionFailedError.class, + () -> assertLinesEquals("A\n", "A\nExtra Line") + ); + Assertions.assertTrue( + ex.getMessage().contains("Expected(@end-of-text)"), + "<" + ex.getMessage() + "> does not contain expected line." + ); + Assertions.assertTrue( + ex.getMessage().contains("Actual (@line 1): "), + "<" + ex.getMessage() + "> does not contain actual line." + ); + } +} diff --git a/application/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java b/application/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java index 7e1bf24287a..ddb96c90847 100644 --- a/application/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java +++ b/application/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java @@ -87,6 +87,7 @@ import org.opentripplanner.standalone.config.framework.json.JsonSupport; import org.opentripplanner.test.support.FilePatternSource; import org.opentripplanner.transit.model._data.TimetableRepositoryForTest; +import org.opentripplanner.transit.model.basic.Accessibility; import org.opentripplanner.transit.model.basic.Money; import org.opentripplanner.transit.model.basic.TransitMode; import org.opentripplanner.transit.model.framework.AbstractBuilder; @@ -96,6 +97,7 @@ import org.opentripplanner.transit.model.network.Route; import org.opentripplanner.transit.model.network.TripPattern; import org.opentripplanner.transit.model.organization.Agency; +import org.opentripplanner.transit.model.site.Entrance; import org.opentripplanner.transit.model.site.RegularStop; import org.opentripplanner.transit.model.site.StopLocation; import org.opentripplanner.transit.model.timetable.RealTimeTripTimes; @@ -133,10 +135,17 @@ class GraphQLIntegrationTest { .withSystem("Network-1", "https://foo.bar") .build(); - private static final VehicleRentalVehicle RENTAL_VEHICLE = new TestFreeFloatingRentalVehicleBuilder() + private static final VehicleRentalVehicle RENTAL_VEHICLE_1 = new TestFreeFloatingRentalVehicleBuilder() .withSystem("Network-1", "https://foo.bar") .build(); + private static final VehicleRentalVehicle RENTAL_VEHICLE_2 = new TestFreeFloatingRentalVehicleBuilder() + .withSystem("Network-2", "https://foo.bar.baz") + .withNetwork("Network-2") + .withCurrentRangeMeters(null) + .withCurrentFuelPercent(null) + .build(); + static final Graph GRAPH = new Graph(); static final Instant ALERT_START_TIME = OffsetDateTime @@ -267,9 +276,20 @@ public Set findRoutes(StopLocation stop) { .withAbsoluteDirection(20) .build(); var step2 = walkStep("elevator").withRelativeDirection(RelativeDirection.ELEVATOR).build(); + FeedScopedId entranceId = new FeedScopedId("osm", "123"); + Entrance entrance = Entrance + .of(entranceId) + .withCoordinate(new WgsCoordinate(60, 80)) + .withCode("A") + .withWheelchairAccessibility(Accessibility.POSSIBLE) + .build(); + var step3 = walkStep("entrance") + .withRelativeDirection(RelativeDirection.ENTER_OR_EXIT_STATION) + .withEntrance(entrance) + .build(); Itinerary i1 = newItinerary(A, T11_00) - .walk(20, B, List.of(step1, step2)) + .walk(20, B, List.of(step1, step2, step3)) .bus(busRoute, 122, T11_01, T11_15, C) .rail(439, T11_30, T11_50, D) .carHail(D10m, E) @@ -344,7 +364,8 @@ public Set findRoutes(StopLocation stop) { DefaultVehicleRentalService defaultVehicleRentalService = new DefaultVehicleRentalService(); defaultVehicleRentalService.addVehicleRentalStation(VEHICLE_RENTAL_STATION); - defaultVehicleRentalService.addVehicleRentalStation(RENTAL_VEHICLE); + defaultVehicleRentalService.addVehicleRentalStation(RENTAL_VEHICLE_1); + defaultVehicleRentalService.addVehicleRentalStation(RENTAL_VEHICLE_2); context = new GraphQLRequestContext( @@ -511,7 +532,7 @@ public List findClosestPlaces( return List.of( new PlaceAtDistance(stop, 0), new PlaceAtDistance(VEHICLE_RENTAL_STATION, 30), - new PlaceAtDistance(RENTAL_VEHICLE, 50) + new PlaceAtDistance(RENTAL_VEHICLE_1, 50) ); } }; diff --git a/application/src/test/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapperTest.java b/application/src/test/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapperTest.java index 2c69f3dca46..1dcd6e210a3 100644 --- a/application/src/test/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapperTest.java +++ b/application/src/test/java/org/opentripplanner/apis/gtfs/mapping/DirectionMapperTest.java @@ -23,6 +23,7 @@ void absoluteDirection() { void relativeDirection() { Arrays .stream(RelativeDirection.values()) + .filter(v -> v != RelativeDirection.ENTER_OR_EXIT_STATION) .forEach(d -> { var mapped = DirectionMapper.map(d); assertEquals(d.toString(), mapped.toString()); diff --git a/application/src/test/java/org/opentripplanner/apis/support/graphql/injectdoc/CustomDocumentationTest.java b/application/src/test/java/org/opentripplanner/apis/support/graphql/injectdoc/CustomDocumentationTest.java new file mode 100644 index 00000000000..dc9356530b6 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/apis/support/graphql/injectdoc/CustomDocumentationTest.java @@ -0,0 +1,76 @@ +package org.opentripplanner.apis.support.graphql.injectdoc; + +import static java.util.Optional.empty; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class CustomDocumentationTest { + + private static final String ORIGINAL_DOC = "Original"; + + // We use a HashMap to allow inserting 'null' values + private static final Map PROPERTIES = new HashMap<>(Map.ofEntries()); + + static { + PROPERTIES.put("Type1.description", "Doc 1"); + PROPERTIES.put("Type2.description.append", "Doc 2"); + PROPERTIES.put("Type3.description", null); + PROPERTIES.put("Type.field1.description", "Doc f1"); + PROPERTIES.put("Type.field2.deprecated", "Deprecated f2"); + PROPERTIES.put("Type.field3.description.append", "Doc f3"); + PROPERTIES.put("Type.field4.deprecated.append", "Deprecated f4"); + PROPERTIES.put("Type.field5.description", null); + } + + private final CustomDocumentation subject = new CustomDocumentation(PROPERTIES); + + @Test + void testCreate() { + var defaultDoc = CustomDocumentation.of(ApiDocumentationProfile.DEFAULT); + assertTrue(defaultDoc.isEmpty()); + + var enturDoc = CustomDocumentation.of(ApiDocumentationProfile.ENTUR); + assertFalse(enturDoc.isEmpty()); + } + + @Test + void testTypeDescriptionWithUnknownKey() { + assertEquals(empty(), subject.typeDescription("", ORIGINAL_DOC)); + assertEquals(empty(), subject.typeDescription("ANY_KEY", ORIGINAL_DOC)); + assertEquals(empty(), subject.typeDescription("ANY_KEY", null)); + } + + @Test + void testTypeDescription() { + assertEquals(Optional.of("Doc 1"), subject.typeDescription("Type1", ORIGINAL_DOC)); + assertEquals( + Optional.of(ORIGINAL_DOC + "\n\nDoc 2"), + subject.typeDescription("Type2", ORIGINAL_DOC) + ); + assertEquals(Optional.empty(), subject.typeDescription("Type3", ORIGINAL_DOC)); + } + + @Test + void testFieldDescription() { + assertEquals(Optional.of("Doc f1"), subject.fieldDescription("Type", "field1", ORIGINAL_DOC)); + assertEquals( + Optional.of("Deprecated f2"), + subject.fieldDeprecatedReason("Type", "field2", ORIGINAL_DOC) + ); + assertEquals( + Optional.of("Original\n\nDoc f3"), + subject.fieldDescription("Type", "field3", ORIGINAL_DOC) + ); + assertEquals( + Optional.of("Original\n\nDeprecated f4"), + subject.fieldDeprecatedReason("Type", "field4", ORIGINAL_DOC) + ); + assertEquals(Optional.empty(), subject.fieldDeprecatedReason("Type", "field5", ORIGINAL_DOC)); + } +} diff --git a/application/src/test/java/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.java b/application/src/test/java/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.java new file mode 100644 index 00000000000..44318d613a4 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.java @@ -0,0 +1,139 @@ +package org.opentripplanner.apis.support.graphql.injectdoc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import graphql.schema.Coercing; +import graphql.schema.GraphQLScalarType; +import graphql.schema.GraphQLSchema; +import graphql.schema.SchemaTransformer; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.SchemaGenerator; +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.SchemaPrinter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner._support.text.TextAssertions; + +/** + * This test reads in a schema file, injects documentation and convert the + * new schema to an SDL text string. The result is then compared to the + * "expected" SDL file. The input and expected files are found in the + * resources - with the same name as this test. + *

+ * Note! There is a bug in the Java GraphQL library. Existing deprecated reasons + * cannot be changed or replaced. This test adds test-cases for this, but excludes + * them from the expected result. If this is fixed in the GraphQL library, this + * test will fail, and should be updated by updating the expected result. + */ +class InjectCustomDocumentationTest { + + private GraphQLSchema schema; + private String sdlExpected; + + @BeforeEach + void setUp() throws IOException { + var sdl = loadSchemaResource(".graphql"); + sdlExpected = loadSchemaResource(".graphql.expected"); + + var parser = new SchemaParser(); + var generator = new SchemaGenerator(); + var typeRegistry = parser.parse(sdl); + schema = generator.makeExecutableSchema(typeRegistry, buildRuntimeWiring()); + } + + private static RuntimeWiring buildRuntimeWiring() { + return RuntimeWiring + .newRuntimeWiring() + .type("QueryType", b -> b.dataFetcher("listE", e -> List.of())) + .type("En", b -> b.enumValues(n -> n)) + .type("AB", b -> b.typeResolver(it -> null)) + .type("AC", b -> b.typeResolver(it -> null)) + .scalar( + GraphQLScalarType + .newScalar() + .name("Duration") + .coercing(new Coercing() {}) + .build() + ) + .build(); + } + + /** + * Return a map of documentation key/values. The + * value is the same as the key for easy recognition. + */ + static Map text() { + return Stream + .of( + "AB.description", + "AC.description.append", + "AType.description", + "AType.a.description", + "AType.b.deprecated", + "BType.description", + "BType.a.description", + "BType.a.deprecated", + "CType.description.append", + "CType.a.description.append", + "CType.b.deprecated.append", + "QueryType.findAB.description", + "QueryType.getAC.deprecated", + "AEnum.description", + "AEnum.E1.description", + "AEnum.E2.deprecated", + "AEnum.E3.deprecated", + "Duration.description", + "InputType.description", + "InputType.a.description", + "InputType.b.deprecated", + "InputType.c.deprecated" + ) + .collect(Collectors.toMap(e -> e, e -> e)); + } + + @Test + void test() { + Map texts = text(); + var customDocumentation = new CustomDocumentation(texts); + var visitor = new InjectCustomDocumentation(customDocumentation); + var newSchema = SchemaTransformer.transformSchema(schema, visitor); + var p = new SchemaPrinter(); + var result = p.print(newSchema); + + var missingValues = texts + .values() + .stream() + .sorted() + .filter(it -> !result.contains(it)) + .toList(); + + // There is a bug in the Java GraphQL API, existing deprecated + // doc is not updated or replaced. + var expected = List.of( + "AEnum.E3.deprecated", + "BType.a.deprecated", + "CType.b.deprecated.append", + "InputType.c.deprecated" + ); + + assertEquals(expected, missingValues); + + TextAssertions.assertLinesEquals(sdlExpected, result); + } + + @SuppressWarnings("DataFlowIssue") + String loadSchemaResource(String suffix) throws IOException { + var cl = getClass(); + var name = cl.getName().replace('.', '/') + suffix; + return new String( + ClassLoader.getSystemResourceAsStream(name).readAllBytes(), + StandardCharsets.UTF_8 + ); + } +} diff --git a/application/src/test/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchemaTest.java b/application/src/test/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchemaTest.java index 4cdb0586aa7..3fc33081cda 100644 --- a/application/src/test/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchemaTest.java +++ b/application/src/test/java/org/opentripplanner/apis/transmodel/TransmodelGraphQLSchemaTest.java @@ -9,6 +9,7 @@ import java.io.File; import org.junit.jupiter.api.Test; import org.opentripplanner._support.time.ZoneIds; +import org.opentripplanner.apis.support.graphql.injectdoc.ApiDocumentationProfile; import org.opentripplanner.routing.algorithm.raptoradapter.transit.TransitTuningParameters; import org.opentripplanner.routing.api.request.RouteRequest; @@ -23,6 +24,7 @@ void testSchemaBuild() { var schema = TransmodelGraphQLSchema.create( new RouteRequest(), ZoneIds.OSLO, + ApiDocumentationProfile.DEFAULT, TransitTuningParameters.FOR_TEST ); assertNotNull(schema); diff --git a/application/src/test/java/org/opentripplanner/apis/transmodel/mapping/BookingInfoMapperTest.java b/application/src/test/java/org/opentripplanner/apis/transmodel/mapping/BookingInfoMapperTest.java new file mode 100644 index 00000000000..76af5f76d37 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/apis/transmodel/mapping/BookingInfoMapperTest.java @@ -0,0 +1,83 @@ +package org.opentripplanner.apis.transmodel.mapping; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.opentripplanner.apis.transmodel.mapping.BookingInfoMapper.mapToBookWhen; + +import java.time.Duration; +import java.time.LocalTime; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.opentripplanner.transit.model.timetable.booking.BookingInfo; +import org.opentripplanner.transit.model.timetable.booking.BookingTime; + +class BookingInfoMapperTest { + + private static final Duration TEN_MINUTES = Duration.ofMinutes(10); + private static final BookingTime BOOKING_TIME_ZERO_DAYS_PRIOR = new BookingTime( + LocalTime.of(10, 0), + 0 + ); + + @Test + void bookingNotice() { + assertNull(mapToBookWhen(BookingInfo.of().withMinimumBookingNotice(TEN_MINUTES).build())); + } + + @Test + void timeOfTravelOnly() { + assertEquals("timeOfTravelOnly", mapToBookWhen(BookingInfo.of().build())); + } + + @Test + void untilPreviousDay() { + var info = daysPrior(1); + assertEquals("untilPreviousDay", mapToBookWhen(info)); + } + + @Test + void advanceAndDayOfTravel() { + var info = daysPrior(0); + assertEquals("advanceAndDayOfTravel", mapToBookWhen(info)); + } + + @ParameterizedTest + @ValueSource(ints = { 2, 3, 4, 14, 28 }) + void other(int days) { + var info = daysPrior(days); + assertEquals("other", mapToBookWhen(info)); + } + + @Test + void dayOfTravelOnly() { + var info = BookingInfo.of().withEarliestBookingTime(BOOKING_TIME_ZERO_DAYS_PRIOR).build(); + assertEquals("dayOfTravelOnly", mapToBookWhen(info)); + } + + @Test + void latestBookingTime() { + var info = BookingInfo + .of() + .withEarliestBookingTime(BOOKING_TIME_ZERO_DAYS_PRIOR) + .withLatestBookingTime(BOOKING_TIME_ZERO_DAYS_PRIOR) + .build(); + assertEquals("dayOfTravelOnly", mapToBookWhen(info)); + } + + @Test + void earliestBookingTimeZero() { + var info = BookingInfo + .of() + .withEarliestBookingTime(new BookingTime(LocalTime.of(10, 0), 10)) + .build(); + assertEquals("other", mapToBookWhen(info)); + } + + private static BookingInfo daysPrior(int daysPrior) { + return BookingInfo + .of() + .withLatestBookingTime(new BookingTime(LocalTime.of(10, 0), daysPrior)) + .build(); + } +} diff --git a/application/src/test/java/org/opentripplanner/apis/transmodel/model/EnumTypesTest.java b/application/src/test/java/org/opentripplanner/apis/transmodel/model/EnumTypesTest.java index 3d834fced58..5a83ec66bb8 100644 --- a/application/src/test/java/org/opentripplanner/apis/transmodel/model/EnumTypesTest.java +++ b/application/src/test/java/org/opentripplanner/apis/transmodel/model/EnumTypesTest.java @@ -1,14 +1,24 @@ package org.opentripplanner.apis.transmodel.model; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.opentripplanner.apis.transmodel.model.EnumTypes.RELATIVE_DIRECTION; import static org.opentripplanner.apis.transmodel.model.EnumTypes.ROUTING_ERROR_CODE; import static org.opentripplanner.apis.transmodel.model.EnumTypes.map; +import graphql.GraphQLContext; import java.util.EnumSet; import java.util.List; +import java.util.Locale; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.opentripplanner.apis.transmodel.mapping.RelativeDirectionMapper; import org.opentripplanner.framework.doc.DocumentedEnum; +import org.opentripplanner.model.plan.RelativeDirection; import org.opentripplanner.routing.api.response.RoutingErrorCode; class EnumTypesTest { @@ -75,6 +85,18 @@ void testMap() { assertEquals("DocumentedEnumMapping[apiName=iH, internal=Hi]", mapping.toString()); } + @ParameterizedTest + @EnumSource(RelativeDirection.class) + void serializeRelativeDirection(RelativeDirection direction) { + var value = RELATIVE_DIRECTION.serialize( + RelativeDirectionMapper.map(direction), + GraphQLContext.getDefault(), + Locale.ENGLISH + ); + assertInstanceOf(String.class, value); + assertFalse(((String) value).isEmpty()); + } + @Test void assertAllRoutingErrorCodesAreMapped() { var expected = EnumSet.allOf(RoutingErrorCode.class); 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/graph_builder/module/osm/WalkableAreaBuilderTest.java b/application/src/test/java/org/opentripplanner/graph_builder/module/osm/WalkableAreaBuilderTest.java index b712ee48c5a..1516c9df91a 100644 --- a/application/src/test/java/org/opentripplanner/graph_builder/module/osm/WalkableAreaBuilderTest.java +++ b/application/src/test/java/org/opentripplanner/graph_builder/module/osm/WalkableAreaBuilderTest.java @@ -48,7 +48,7 @@ public Graph buildGraph(final TestInfo testInfo) { final WalkableAreaBuilder walkableAreaBuilder = new WalkableAreaBuilder( graph, osmdb, - new VertexGenerator(osmdb, graph, Set.of()), + new VertexGenerator(osmdb, graph, Set.of(), false), new DefaultNamer(), new SafetyValueNormalizer(graph, DataImportIssueStore.NOOP), DataImportIssueStore.NOOP, diff --git a/application/src/test/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapperTest.java b/application/src/test/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapperTest.java index dbe833a7aaa..334d30fa94f 100644 --- a/application/src/test/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapperTest.java +++ b/application/src/test/java/org/opentripplanner/gtfs/mapping/FareLegRuleMapperTest.java @@ -12,11 +12,11 @@ import org.onebusaway.gtfs.model.FareLegRule; import org.onebusaway.gtfs.model.FareMedium; import org.onebusaway.gtfs.model.FareProduct; -import org.opentripplanner.ext.fares.model.Distance; import org.opentripplanner.ext.fares.model.FareDistance; import org.opentripplanner.ext.fares.model.FareDistance.LinearDistance; import org.opentripplanner.ext.fares.model.FareDistance.Stops; import org.opentripplanner.graph_builder.issue.api.DataImportIssueStore; +import org.opentripplanner.transit.model.basic.Distance; class FareLegRuleMapperTest { @@ -33,7 +33,10 @@ private record TestCase( 1, 5000d, 10000d, - new LinearDistance(Distance.ofKilometers(5), Distance.ofKilometers(10)) + new LinearDistance( + Distance.ofKilometersBoxed(5d, ignore -> {}).orElse(null), + Distance.ofKilometersBoxed(10d, ignore -> {}).orElse(null) + ) ), new TestCase(null, null, null, null) ); diff --git a/application/src/test/java/org/opentripplanner/osm/model/OsmWithTagsTest.java b/application/src/test/java/org/opentripplanner/osm/model/OsmWithTagsTest.java index 84b74b8f655..597593f7333 100644 --- a/application/src/test/java/org/opentripplanner/osm/model/OsmWithTagsTest.java +++ b/application/src/test/java/org/opentripplanner/osm/model/OsmWithTagsTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.opentripplanner.osm.wayproperty.specifier.WayTestData; +import org.opentripplanner.transit.model.basic.Accessibility; public class OsmWithTagsTest { @@ -215,6 +216,20 @@ void isWheelchairAccessible() { assertTrue(osm3.isWheelchairAccessible()); } + @Test + void wheelchairAccessibility() { + var osm1 = new OsmWithTags(); + assertEquals(Accessibility.NO_INFORMATION, osm1.wheelchairAccessibility()); + + var osm2 = new OsmWithTags(); + osm2.addTag("wheelchair", "no"); + assertEquals(Accessibility.NOT_POSSIBLE, osm2.wheelchairAccessibility()); + + var osm3 = new OsmWithTags(); + osm3.addTag("wheelchair", "yes"); + assertEquals(Accessibility.POSSIBLE, osm3.wheelchairAccessibility()); + } + @Test void isRoutable() { assertFalse(WayTestData.zooPlatform().isRoutable()); diff --git a/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java b/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java index de9fe21718a..a2bb428a78c 100644 --- a/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java +++ b/application/src/test/java/org/opentripplanner/routing/algorithm/mapping/StatesToWalkStepsMapperTest.java @@ -13,6 +13,7 @@ import org.opentripplanner.model.plan.WalkStep; import org.opentripplanner.routing.services.notes.StreetNotesService; import org.opentripplanner.street.search.state.TestStateBuilder; +import org.opentripplanner.transit.model.framework.FeedScopedId; class StatesToWalkStepsMapperTest { @@ -42,6 +43,7 @@ void enterStation() { var walkSteps = buildWalkSteps(builder); assertEquals(2, walkSteps.size()); var enter = walkSteps.get(1); + assertEquals(new FeedScopedId("F", "Lichterfelde-Ost"), enter.entrance().get().getId()); assertEquals(ENTER_STATION, enter.getRelativeDirection()); } @@ -53,8 +55,9 @@ void exitStation() { .exitStation("Lichterfelde-Ost"); var walkSteps = buildWalkSteps(builder); assertEquals(3, walkSteps.size()); - var enter = walkSteps.get(2); - assertEquals(EXIT_STATION, enter.getRelativeDirection()); + var exit = walkSteps.get(2); + assertEquals(new FeedScopedId("F", "Lichterfelde-Ost"), exit.entrance().get().getId()); + assertEquals(EXIT_STATION, exit.getRelativeDirection()); } @Test diff --git a/application/src/test/java/org/opentripplanner/routing/core/DistanceTest.java b/application/src/test/java/org/opentripplanner/routing/core/DistanceTest.java new file mode 100644 index 00000000000..5eb1ce8bc6a --- /dev/null +++ b/application/src/test/java/org/opentripplanner/routing/core/DistanceTest.java @@ -0,0 +1,45 @@ +package org.opentripplanner.routing.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; +import org.opentripplanner.transit.model.basic.Distance; + +public class DistanceTest { + + private static final Distance ONE_THOUSAND_FIVE_HUNDRED_METERS = Distance + .ofMetersBoxed(1500d, ignore -> {}) + .orElse(null); + private static final Distance ONE_POINT_FIVE_KILOMETERS = Distance + .ofKilometersBoxed(1.5d, ignore -> {}) + .orElse(null); + private static final Distance TWO_KILOMETERS = Distance + .ofKilometersBoxed(2d, ignore -> {}) + .orElse(null); + private static final Distance ONE_HUNDRED_METERS = Distance + .ofMetersBoxed(100d, ignore -> {}) + .orElse(null); + private static final Distance POINT_ONE_KILOMETER = Distance + .ofKilometersBoxed(0.1d, ignore -> {}) + .orElse(null); + private static final Distance ONE_HUNDRED_POINT_FIVE_METERS = Distance + .ofMetersBoxed(100.5d, ignore -> {}) + .orElse(null); + + @Test + void equals() { + assertEquals(ONE_THOUSAND_FIVE_HUNDRED_METERS, ONE_POINT_FIVE_KILOMETERS); + assertEquals(POINT_ONE_KILOMETER, ONE_HUNDRED_METERS); + assertNotEquals(ONE_HUNDRED_POINT_FIVE_METERS, ONE_HUNDRED_METERS); + assertNotEquals(TWO_KILOMETERS, ONE_POINT_FIVE_KILOMETERS); + } + + @Test + void testHashCode() { + assertEquals( + Distance.ofMetersBoxed(5d, ignore -> {}).hashCode(), + Distance.ofMetersBoxed(5d, ignore -> {}).hashCode() + ); + } +} diff --git a/application/src/test/java/org/opentripplanner/routing/core/RatioTest.java b/application/src/test/java/org/opentripplanner/routing/core/RatioTest.java new file mode 100644 index 00000000000..74a8fe6060a --- /dev/null +++ b/application/src/test/java/org/opentripplanner/routing/core/RatioTest.java @@ -0,0 +1,42 @@ +package org.opentripplanner.routing.core; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.opentripplanner.transit.model.basic.Ratio; + +public class RatioTest { + + private static final Double HALF = 0.5d; + private static final Double ZERO = 0d; + private static final Double ONE = 1d; + private static final Double TOO_HIGH = 1.1d; + private static final Double TOO_LOW = -1.1d; + + @Test + void validRatios() { + assertDoesNotThrow(() -> Ratio.of(HALF)); + assertDoesNotThrow(() -> Ratio.of(ZERO)); + assertDoesNotThrow(() -> Ratio.of(ONE)); + } + + @Test + void invalidRatios() { + assertThrows(IllegalArgumentException.class, () -> Ratio.of(TOO_HIGH)); + assertThrows(IllegalArgumentException.class, () -> Ratio.of(TOO_LOW)); + } + + @Test + void testHashCode() { + Ratio half = Ratio.of(HALF); + + Ratio half2 = Ratio.of(HALF); + assertEquals(half.hashCode(), half2.hashCode()); + + Double halfDouble = 2d; + assertNotEquals(half.hashCode(), halfDouble); + } +} diff --git a/application/src/test/java/org/opentripplanner/service/vehiclerental/model/TestFreeFloatingRentalVehicleBuilder.java b/application/src/test/java/org/opentripplanner/service/vehiclerental/model/TestFreeFloatingRentalVehicleBuilder.java index a9b2398f686..2b8881715a9 100644 --- a/application/src/test/java/org/opentripplanner/service/vehiclerental/model/TestFreeFloatingRentalVehicleBuilder.java +++ b/application/src/test/java/org/opentripplanner/service/vehiclerental/model/TestFreeFloatingRentalVehicleBuilder.java @@ -1,7 +1,10 @@ package org.opentripplanner.service.vehiclerental.model; +import javax.annotation.Nullable; import org.opentripplanner.framework.i18n.NonLocalizedString; import org.opentripplanner.street.model.RentalFormFactor; +import org.opentripplanner.transit.model.basic.Distance; +import org.opentripplanner.transit.model.basic.Ratio; import org.opentripplanner.transit.model.framework.FeedScopedId; public class TestFreeFloatingRentalVehicleBuilder { @@ -9,10 +12,15 @@ public class TestFreeFloatingRentalVehicleBuilder { public static final String NETWORK_1 = "Network-1"; public static final double DEFAULT_LATITUDE = 47.520; public static final double DEFAULT_LONGITUDE = 19.01; + public static final double DEFAULT_CURRENT_FUEL_PERCENT = 0.5; + public static final double DEFAULT_CURRENT_RANGE_METERS = 5500.7; private double latitude = DEFAULT_LATITUDE; private double longitude = DEFAULT_LONGITUDE; + private Ratio currentFuelPercent = Ratio.of(DEFAULT_CURRENT_FUEL_PERCENT); + private Double currentRangeMeters = DEFAULT_CURRENT_RANGE_METERS; private VehicleRentalSystem system = null; + private String network = NETWORK_1; private RentalVehicleType vehicleType = RentalVehicleType.getDefaultType(NETWORK_1); @@ -30,6 +38,27 @@ public TestFreeFloatingRentalVehicleBuilder withLongitude(double longitude) { return this; } + public TestFreeFloatingRentalVehicleBuilder withCurrentFuelPercent( + @Nullable Double currentFuelPercent + ) { + if (currentFuelPercent == null) { + this.currentFuelPercent = null; + } else { + this.currentFuelPercent = Ratio.ofBoxed(currentFuelPercent, ignore -> {}).orElse(null); + } + return this; + } + + public TestFreeFloatingRentalVehicleBuilder withCurrentRangeMeters(Double currentRangeMeters) { + this.currentRangeMeters = currentRangeMeters; + return this; + } + + public TestFreeFloatingRentalVehicleBuilder withNetwork(String network) { + this.network = network; + return this; + } + public TestFreeFloatingRentalVehicleBuilder withSystem(String id, String url) { this.system = new VehicleRentalSystem( @@ -64,6 +93,23 @@ public TestFreeFloatingRentalVehicleBuilder withVehicleCar() { return buildVehicleType(RentalFormFactor.CAR); } + public VehicleRentalVehicle build() { + var vehicle = new VehicleRentalVehicle(); + var stationName = "free-floating-" + vehicleType.formFactor.name().toLowerCase(); + vehicle.id = new FeedScopedId(this.network, stationName); + vehicle.name = new NonLocalizedString(stationName); + vehicle.latitude = latitude; + vehicle.longitude = longitude; + vehicle.vehicleType = vehicleType; + vehicle.system = system; + vehicle.fuel = + new RentalVehicleFuel( + currentFuelPercent, + Distance.ofMetersBoxed(currentRangeMeters, ignore -> {}).orElse(null) + ); + return vehicle; + } + private TestFreeFloatingRentalVehicleBuilder buildVehicleType(RentalFormFactor rentalFormFactor) { this.vehicleType = new RentalVehicleType( @@ -75,16 +121,4 @@ private TestFreeFloatingRentalVehicleBuilder buildVehicleType(RentalFormFactor r ); return this; } - - public VehicleRentalVehicle build() { - var vehicle = new VehicleRentalVehicle(); - var stationName = "free-floating-" + vehicleType.formFactor.name().toLowerCase(); - vehicle.id = new FeedScopedId(NETWORK_1, stationName); - vehicle.name = new NonLocalizedString(stationName); - vehicle.latitude = latitude; - vehicle.longitude = longitude; - vehicle.vehicleType = vehicleType; - vehicle.system = system; - return vehicle; - } } 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/java/org/opentripplanner/transit/model/filter/expr/CaseInsensitiveStringPrefixMatcherTest.java b/application/src/test/java/org/opentripplanner/transit/model/filter/expr/CaseInsensitiveStringPrefixMatcherTest.java new file mode 100644 index 00000000000..112fbaa7002 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/transit/model/filter/expr/CaseInsensitiveStringPrefixMatcherTest.java @@ -0,0 +1,16 @@ +package org.opentripplanner.transit.model.filter.expr; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class CaseInsensitiveStringPrefixMatcherTest { + + @Test + void testMatches() { + var matcher = new CaseInsensitiveStringPrefixMatcher<>("prefix", "foo", s -> s.toString()); + assertTrue(matcher.match("foo")); + assertTrue(matcher.match("foobar")); + assertFalse(matcher.match("bar")); + } +} diff --git a/application/src/test/java/org/opentripplanner/transit/model/filter/expr/NullSafeWrapperMatcherTest.java b/application/src/test/java/org/opentripplanner/transit/model/filter/expr/NullSafeWrapperMatcherTest.java new file mode 100644 index 00000000000..0b2f008a2fc --- /dev/null +++ b/application/src/test/java/org/opentripplanner/transit/model/filter/expr/NullSafeWrapperMatcherTest.java @@ -0,0 +1,30 @@ +package org.opentripplanner.transit.model.filter.expr; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class NullSafeWrapperMatcherTest { + + @Test + void testMatches() { + var matcher = new NullSafeWrapperMatcher<>( + "string", + s -> s, + new CaseInsensitiveStringPrefixMatcher<>("string", "namePrefix", s -> s.toString()) + ); + assertTrue(matcher.match("namePrefix and more")); + assertFalse(matcher.match("not namePrefix")); + assertFalse(matcher.match(null)); + } + + @Test + void testFailsWithoutNullSafeWrapperMatcher() { + var matcher = new CaseInsensitiveStringPrefixMatcher<>( + "string", + "here's a string", + s -> s.toString() + ); + assertThrows(NullPointerException.class, () -> matcher.match(null)); + } +} diff --git a/application/src/test/java/org/opentripplanner/transit/model/filter/transit/RouteMatcherFactoryTest.java b/application/src/test/java/org/opentripplanner/transit/model/filter/transit/RouteMatcherFactoryTest.java new file mode 100644 index 00000000000..ce9230ac0a6 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/transit/model/filter/transit/RouteMatcherFactoryTest.java @@ -0,0 +1,146 @@ +package org.opentripplanner.transit.model.filter.transit; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentripplanner.framework.i18n.I18NString; +import org.opentripplanner.transit.api.model.FilterValues; +import org.opentripplanner.transit.api.request.FindRoutesRequest; +import org.opentripplanner.transit.model._data.TimetableRepositoryForTest; +import org.opentripplanner.transit.model.basic.TransitMode; +import org.opentripplanner.transit.model.filter.expr.Matcher; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.network.Route; +import org.opentripplanner.transit.model.organization.Agency; + +class RouteMatcherFactoryTest { + + private Route route1; + private Route route2; + + @BeforeEach + void setUp() { + route1 = + Route + .of(new FeedScopedId("feedId", "routeId")) + .withAgency(TimetableRepositoryForTest.agency("AGENCY")) + .withMode(TransitMode.BUS) + .withShortName("ROUTE1") + .withLongName(I18NString.of("ROUTE1LONG")) + .build(); + route2 = + Route + .of(new FeedScopedId("otherFeedId", "otherRouteId")) + .withAgency(TimetableRepositoryForTest.agency("OTHER_AGENCY")) + .withMode(TransitMode.RAIL) + .withShortName("ROUTE2") + .withLongName(I18NString.of("ROUTE2LONG")) + .build(); + } + + @Test + void testAgencies() { + FindRoutesRequest request = FindRoutesRequest + .of() + .withAgencies(FilterValues.ofEmptyIsEverything("agencies", List.of("AGENCY"))) + .build(); + + Matcher matcher = RouteMatcherFactory.of(request, r -> false); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testTransitModes() { + FindRoutesRequest request = FindRoutesRequest + .of() + .withTransitModes(FilterValues.ofEmptyIsEverything("transitModes", List.of(TransitMode.BUS))) + .build(); + + Matcher matcher = RouteMatcherFactory.of(request, r -> false); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testShortLongName() { + FindRoutesRequest request = FindRoutesRequest.of().withShortName("ROUTE1").build(); + + Matcher matcher = RouteMatcherFactory.of(request, r -> false); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testShortNames() { + FindRoutesRequest request = FindRoutesRequest + .of() + .withShortNames(FilterValues.ofEmptyIsEverything("publicCodes", List.of("ROUTE1", "ROUTE3"))) + .build(); + + Matcher matcher = RouteMatcherFactory.of(request, r -> false); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testIsFlexRoute() { + FindRoutesRequest request = FindRoutesRequest.of().withFlexibleOnly(true).build(); + + Set flexRoutes = Set.of(route1); + + Matcher matcher = RouteMatcherFactory.of(request, flexRoutes::contains); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testLongNameExactMatch() { + FindRoutesRequest request = FindRoutesRequest.of().withLongName("ROUTE1LONG").build(); + + Matcher matcher = RouteMatcherFactory.of(request, r -> false); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testLongNamePrefixMatch() { + FindRoutesRequest request = FindRoutesRequest.of().withLongName("ROUTE1").build(); + + Matcher matcher = RouteMatcherFactory.of(request, r -> false); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testLongNameCaseInsensitivePrefixMatch() { + FindRoutesRequest request = FindRoutesRequest.of().withLongName("route1").build(); + + Matcher matcher = RouteMatcherFactory.of(request, r -> false); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } + + @Test + void testAll() { + FindRoutesRequest request = FindRoutesRequest + .of() + .withAgencies(FilterValues.ofEmptyIsEverything("agencies", List.of("AGENCY"))) + .withTransitModes(FilterValues.ofEmptyIsEverything("transitModes", List.of(TransitMode.BUS))) + .withShortName("ROUTE1") + .withShortNames(FilterValues.ofEmptyIsEverything("publicCodes", List.of("ROUTE1", "ROUTE3"))) + .withFlexibleOnly(true) + .withLongName("ROUTE1") + .build(); + + Set flexRoutes = Set.of(route1); + + Matcher matcher = RouteMatcherFactory.of(request, flexRoutes::contains); + assertTrue(matcher.match(route1)); + assertFalse(matcher.match(route2)); + } +} diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/LoadModel.java b/application/src/test/java/org/opentripplanner/transit/speed_test/LoadModel.java new file mode 100644 index 00000000000..5e5823c842b --- /dev/null +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/LoadModel.java @@ -0,0 +1,7 @@ +package org.opentripplanner.transit.speed_test; + +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.standalone.config.BuildConfig; +import org.opentripplanner.transit.service.TimetableRepository; + +record LoadModel(Graph graph, TimetableRepository timetableRepository, BuildConfig buildConfig) {} diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SetupHelper.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SetupHelper.java new file mode 100644 index 00000000000..86383d6d94e --- /dev/null +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SetupHelper.java @@ -0,0 +1,41 @@ +package org.opentripplanner.transit.speed_test; + +import java.io.File; +import java.net.URI; +import javax.annotation.Nullable; +import org.opentripplanner.datastore.OtpDataStore; +import org.opentripplanner.routing.graph.Graph; +import org.opentripplanner.routing.graph.SerializedGraphObject; +import org.opentripplanner.standalone.config.ConfigModel; +import org.opentripplanner.standalone.config.OtpConfigLoader; +import org.opentripplanner.transit.service.TimetableRepository; +import org.opentripplanner.transit.speed_test.options.SpeedTestCmdLineOpts; + +/** + * A package-private helper class for setting up speed tests. + */ +class SetupHelper { + + static LoadModel loadGraph(File baseDir, @Nullable URI path) { + File file = path == null + ? OtpDataStore.graphFile(baseDir) + : path.isAbsolute() ? new File(path) : new File(baseDir, path.getPath()); + SerializedGraphObject serializedGraphObject = SerializedGraphObject.load(file); + Graph graph = serializedGraphObject.graph; + + if (graph == null) { + throw new IllegalStateException( + "Could not find graph at %s".formatted(file.getAbsolutePath()) + ); + } + + TimetableRepository timetableRepository = serializedGraphObject.timetableRepository; + timetableRepository.index(); + graph.index(timetableRepository.getSiteRepository()); + return new LoadModel(graph, timetableRepository, serializedGraphObject.buildConfig); + } + + static void loadOtpFeatures(SpeedTestCmdLineOpts opts) { + ConfigModel.initializeOtpFeatures(new OtpConfigLoader(opts.rootDir()).loadOtpConfig()); + } +} diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java index ca4e85eed84..2a3add223e8 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/SpeedTest.java @@ -27,7 +27,6 @@ import org.opentripplanner.service.vehiclerental.internal.DefaultVehicleRentalService; import org.opentripplanner.standalone.OtpStartupInfo; import org.opentripplanner.standalone.api.OtpServerRequestContext; -import org.opentripplanner.standalone.config.BuildConfig; import org.opentripplanner.standalone.config.ConfigModel; import org.opentripplanner.standalone.config.DebugUiConfig; import org.opentripplanner.standalone.config.OtpConfigLoader; @@ -153,8 +152,8 @@ public static void main(String[] args) { // Given the following setup SpeedTestCmdLineOpts opts = new SpeedTestCmdLineOpts(args); var config = SpeedTestConfig.config(opts.rootDir()); - loadOtpFeatures(opts); - var model = loadGraph(opts.rootDir(), config.graph); + SetupHelper.loadOtpFeatures(opts); + var model = SetupHelper.loadGraph(opts.rootDir(), config.graph); var timetableRepository = model.timetableRepository(); var buildConfig = model.buildConfig(); var graph = model.graph(); @@ -192,6 +191,9 @@ public void runTest() { } updateTimersWithGlobalCounters(); + + timer.finishUp(); + printProfileStatistics(); saveTestCasesToResultFile(); System.err.println("\nSpeedTest done! " + projectInfo().getVersionString()); @@ -267,27 +269,6 @@ private RoutingResponse performRouting(TestCase testCase) { /* setup helper methods */ - private static void loadOtpFeatures(SpeedTestCmdLineOpts opts) { - ConfigModel.initializeOtpFeatures(new OtpConfigLoader(opts.rootDir()).loadOtpConfig()); - } - - private static LoadModel loadGraph(File baseDir, URI path) { - File file = path == null - ? OtpDataStore.graphFile(baseDir) - : path.isAbsolute() ? new File(path) : new File(baseDir, path.getPath()); - SerializedGraphObject serializedGraphObject = SerializedGraphObject.load(file); - Graph graph = serializedGraphObject.graph; - - if (graph == null) { - throw new IllegalStateException(); - } - - TimetableRepository timetableRepository = serializedGraphObject.timetableRepository; - timetableRepository.index(); - graph.index(timetableRepository.getSiteRepository()); - return new LoadModel(graph, timetableRepository, serializedGraphObject.buildConfig); - } - private void initProfileStatistics() { for (SpeedTestProfile key : opts.profiles()) { workerResults.put(key, new ArrayList<>()); @@ -352,7 +333,6 @@ private void updateTimersWithGlobalCounters() { timer.globalCount("jvm_max_memory", runtime.maxMemory()); timer.globalCount("jvm_total_memory", runtime.totalMemory()); timer.globalCount("jvm_used_memory", runtime.totalMemory() - runtime.freeMemory()); - timer.finishUp(); } /** @@ -368,8 +348,4 @@ private List trimItineraries(RoutingResponse routingResponse) { } return stream.limit(opts.numOfItineraries()).toList(); } - - /* inline classes */ - - record LoadModel(Graph graph, TimetableRepository timetableRepository, BuildConfig buildConfig) {} } diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/TransferCacheTest.java b/application/src/test/java/org/opentripplanner/transit/speed_test/TransferCacheTest.java new file mode 100644 index 00000000000..e6c8de67688 --- /dev/null +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/TransferCacheTest.java @@ -0,0 +1,68 @@ +package org.opentripplanner.transit.speed_test; + +import static org.opentripplanner.standalone.configure.ConstructApplication.creatTransitLayerForRaptor; +import static org.opentripplanner.transit.speed_test.support.AssertSpeedTestSetup.assertTestDateHasData; + +import java.util.stream.IntStream; +import org.opentripplanner.framework.application.OtpAppException; +import org.opentripplanner.routing.api.request.RouteRequest; +import org.opentripplanner.standalone.OtpStartupInfo; +import org.opentripplanner.transit.service.TimetableRepository; +import org.opentripplanner.transit.speed_test.model.timer.SpeedTestTimer; +import org.opentripplanner.transit.speed_test.options.SpeedTestCmdLineOpts; +import org.opentripplanner.transit.speed_test.options.SpeedTestConfig; + +/** + * Test how long it takes to compute the transfer cache. + */ +public class TransferCacheTest { + + public static void main(String[] args) { + try { + OtpStartupInfo.logInfo("Run transfer cache test"); + // Given the following setup + SpeedTestCmdLineOpts opts = new SpeedTestCmdLineOpts(args); + var config = SpeedTestConfig.config(opts.rootDir()); + SetupHelper.loadOtpFeatures(opts); + var model = SetupHelper.loadGraph(opts.rootDir(), config.graph); + var timetableRepository = model.timetableRepository(); + var buildConfig = model.buildConfig(); + + var timer = new SpeedTestTimer(); + timer.setUp(false); + + // Creating transitLayerForRaptor should be integrated into the TimetableRepository, but for now + // we do it manually here + creatTransitLayerForRaptor(timetableRepository, config.transitRoutingParams); + + assertTestDateHasData(timetableRepository, config, buildConfig); + + measureTransferCacheComputation(timer, timetableRepository); + + timer.finishUp(); + } catch (Exception e) { + System.err.println(e.getMessage()); + e.printStackTrace(System.err); + System.exit(1); + } + } + + /** + * Measure how long it takes to compute the transfer cache. + */ + private static void measureTransferCacheComputation( + SpeedTestTimer timer, + TimetableRepository timetableRepository + ) { + IntStream + .range(1, 7) + .forEach(reluctance -> { + RouteRequest routeRequest = new RouteRequest(); + routeRequest.withPreferences(b -> b.withWalk(c -> c.withReluctance(reluctance))); + timer.recordTimer( + "transfer_cache_computation", + () -> timetableRepository.getTransitLayer().initTransferCacheForRequest(routeRequest) + ); + }); + } +} diff --git a/application/src/test/java/org/opentripplanner/transit/speed_test/model/timer/SpeedTestTimer.java b/application/src/test/java/org/opentripplanner/transit/speed_test/model/timer/SpeedTestTimer.java index 48a0548d28e..80970eaad0a 100644 --- a/application/src/test/java/org/opentripplanner/transit/speed_test/model/timer/SpeedTestTimer.java +++ b/application/src/test/java/org/opentripplanner/transit/speed_test/model/timer/SpeedTestTimer.java @@ -39,6 +39,7 @@ public class SpeedTestTimer { List.of(loggerRegistry) ); private final MeterRegistry uploadRegistry = MeterRegistrySetup.getRegistry().orElse(null); + private boolean groupResultByTestCaseCategory = false; public static int nanosToMillisecond(long nanos) { @@ -136,6 +137,18 @@ public void globalCount(String meterName, long count) { } } + /** + * Execute the runnable and record its runtime in the meter name passed in. + */ + public void recordTimer(String meterName, Runnable runnable) { + if (uploadRegistry != null) { + registry.add(uploadRegistry); + var timer = registry.timer(meterName); + timer.record(runnable); + registry.remove(uploadRegistry); + } + } + /** * Calculate the total time mean for the given timer. If the timer is not * found {@link #NOT_AVAILABLE} is returned. This can be the case in unit tests, @@ -175,7 +188,7 @@ public String name(String name, Meter.Type type, String unit) { } private String capitalize(String name) { - if (name.length() != 0 && !Character.isUpperCase(name.charAt(0))) { + if (!name.isEmpty() && !Character.isUpperCase(name.charAt(0))) { char[] chars = name.toCharArray(); chars[0] = Character.toUpperCase(chars[0]); return new String(chars); @@ -208,8 +221,8 @@ public static Result merge(Collection results) { for (Result it : results) { any = it; - min = it.min < min ? it.min : min; - max = it.max > max ? it.max : max; + min = Math.min(it.min, min); + max = Math.max(it.max, max); totTime += it.totTime; count += it.count; } diff --git a/application/src/test/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapperTest.java b/application/src/test/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapperTest.java index 02295ce09e9..79322d325fc 100644 --- a/application/src/test/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapperTest.java +++ b/application/src/test/java/org/opentripplanner/updater/vehicle_rental/datasources/GbfsFreeVehicleStatusMapperTest.java @@ -37,7 +37,7 @@ class GbfsFreeVehicleStatusMapperTest { new FeedScopedId("1", "scooter"), "Scooter", RentalFormFactor.SCOOTER, - null, + RentalVehicleType.PropulsionType.COMBUSTION, null ) ) @@ -62,7 +62,6 @@ void withDefaultType() { bike.setLon(1d); bike.setVehicleTypeId("bike"); var mapped = MAPPER.mapFreeVehicleStatus(bike); - assertEquals("Default vehicle type", mapped.name.toString()); } @@ -73,6 +72,7 @@ void withType() { bike.setLat(1d); bike.setLon(1d); bike.setVehicleTypeId("scooter"); + bike.setCurrentRangeMeters(2000d); var mapped = MAPPER.mapFreeVehicleStatus(bike); assertEquals("Scooter", mapped.name.toString()); diff --git a/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/rental-vehicle.json b/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/rental-vehicle.json index bcff74d0413..7d739570a9d 100644 --- a/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/rental-vehicle.json +++ b/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/rental-vehicle.json @@ -15,6 +15,10 @@ "rentalNetwork": { "networkId": "Network-1", "url": "https://foo.bar" + }, + "fuel": { + "percent": 0.5, + "range": 5501 } } } diff --git a/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/vehicle-rentals-bybbox.json b/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/vehicle-rentals-bybbox.json index d28e62f8d93..10b7924d745 100644 --- a/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/vehicle-rentals-bybbox.json +++ b/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/vehicle-rentals-bybbox.json @@ -76,6 +76,32 @@ "rentalNetwork": { "networkId": "Network-1", "url": "https://foo.bar" + }, + "fuel": { + "percent": 0.5, + "range": 5501 + } + }, + { + "__typename": "RentalVehicle", + "vehicleId": "Network-2:free-floating-bicycle", + "name": "free-floating-bicycle", + "allowPickupNow": true, + "lon": 19.01, + "lat": 47.52, + "rentalUris": null, + "operative": true, + "vehicleType": { + "formFactor": "BICYCLE", + "propulsionType": "HUMAN" + }, + "rentalNetwork": { + "networkId": "Network-2", + "url": "https://foo.bar.baz" + }, + "fuel": { + "percent": null, + "range": null } } ] diff --git a/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/walk-steps.json b/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/walk-steps.json index 49908207d44..95adec34ea8 100644 --- a/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/walk-steps.json +++ b/application/src/test/resources/org/opentripplanner/apis/gtfs/expectations/walk-steps.json @@ -11,13 +11,27 @@ "streetName": "street", "area": false, "relativeDirection": "DEPART", - "absoluteDirection": "NORTHEAST" + "absoluteDirection": "NORTHEAST", + "feature": null }, { "streetName": "elevator", "area": false, "relativeDirection": "ELEVATOR", - "absoluteDirection": null + "absoluteDirection": null, + "feature": null + }, + { + "streetName": "entrance", + "area": false, + "relativeDirection": "CONTINUE", + "absoluteDirection": null, + "feature": { + "__typename": "Entrance", + "publicCode": "A", + "entranceId": "osm:123", + "wheelchairAccessible": "POSSIBLE" + } } ] }, diff --git a/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/rental-vehicle.graphql b/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/rental-vehicle.graphql index 9a912781c56..8f32632abc0 100644 --- a/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/rental-vehicle.graphql +++ b/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/rental-vehicle.graphql @@ -19,5 +19,9 @@ networkId url } + fuel { + percent + range + } } } diff --git a/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/vehicle-rentals-bybbox.graphql b/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/vehicle-rentals-bybbox.graphql index 26209f427f9..55c71954692 100644 --- a/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/vehicle-rentals-bybbox.graphql +++ b/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/vehicle-rentals-bybbox.graphql @@ -26,6 +26,10 @@ networkId url } + fuel { + percent + range + } } ... on VehicleRentalStation { stationId diff --git a/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/walk-steps.graphql b/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/walk-steps.graphql index dd2b96395ad..18cb5a8d49d 100644 --- a/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/walk-steps.graphql +++ b/application/src/test/resources/org/opentripplanner/apis/gtfs/queries/walk-steps.graphql @@ -20,6 +20,14 @@ area relativeDirection absoluteDirection + feature { + __typename + ... on Entrance { + publicCode + entranceId + wheelchairAccessible + } + } } } } diff --git a/application/src/test/resources/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.graphql b/application/src/test/resources/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.graphql new file mode 100644 index 00000000000..33deaa2a364 --- /dev/null +++ b/application/src/test/resources/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.graphql @@ -0,0 +1,54 @@ +schema { + query: QueryType +} + +"REPLACE" +union AB = AType | BType + +"APPEND TO" +union AC = AType | BType + +# Add doc to an undocumented type +type AType { + a: Duration + b: String +} + +# Replace existing doc +"REPLACE" +type BType { + a: String @deprecated(reason: "REPLACE") +} + +# Append doc to existing documentation +"APPEND TO" +type CType { + "APPENT TO" + a: Duration + b: String @deprecated(reason: "APPEND TO") +} + +type QueryType { + # Add doc to method - args is currently not supported + findAB(args: InputType): AB + getAC: AC + listCs: CType + listEs: [AEnum] +} + +# Add doc to enums +enum AEnum { + E1 + E2 + E3 @deprecated(reason: "REPLACE") +} + +# Add doc to scalar +scalar Duration + +# Add doc to input type +input InputType { + a: String + b: String + c: String @deprecated(reason: "REPLACE") +} diff --git a/application/src/test/resources/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.graphql.expected b/application/src/test/resources/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.graphql.expected new file mode 100644 index 00000000000..47319e07ae0 --- /dev/null +++ b/application/src/test/resources/org/opentripplanner/apis/support/graphql/injectdoc/InjectCustomDocumentationTest.graphql.expected @@ -0,0 +1,95 @@ +schema { + query: QueryType +} + +"Marks the field, argument, input field or enum value as deprecated" +directive @deprecated( + "The reason for the deprecation" + reason: String = "No longer supported" + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + +"Directs the executor to include this field or fragment only when the `if` argument is true" +directive @include( + "Included when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +"Indicates an Input Object is a OneOf Input Object." +directive @oneOf on INPUT_OBJECT + +"Directs the executor to skip this field or fragment when the `if` argument is true." +directive @skip( + "Skipped when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +"Exposes a URL that specifies the behaviour of this scalar." +directive @specifiedBy( + "The URL that specifies the behaviour of this scalar." + url: String! + ) on SCALAR + +"AB.description" +union AB = AType | BType + +""" +APPEND TO + +AC.description.append +""" +union AC = AType | BType + +"AType.description" +type AType { + "AType.a.description" + a: Duration + b: String @deprecated(reason : "AType.b.deprecated") +} + +"BType.description" +type BType { + "BType.a.description" + a: String @deprecated(reason : "REPLACE") +} + +""" +APPEND TO + +CType.description.append +""" +type CType { + """ + APPENT TO + + CType.a.description.append + """ + a: Duration + b: String @deprecated(reason : "APPEND TO") +} + +type QueryType { + "QueryType.findAB.description" + findAB(args: InputType): AB + getAC: AC @deprecated(reason : "QueryType.getAC.deprecated") + listCs: CType + listEs: [AEnum] +} + +"AEnum.description" +enum AEnum { + "AEnum.E1.description" + E1 + E2 @deprecated(reason : "AEnum.E2.deprecated") + E3 @deprecated(reason : "REPLACE") +} + +"Duration.description" +scalar Duration + +"InputType.description" +input InputType { + "InputType.a.description" + a: String + b: String @deprecated(reason : "InputType.b.deprecated") + c: String @deprecated(reason : "REPLACE") +} 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/index.html b/client/index.html index f09832636f0..d73361cce9f 100644 --- a/client/index.html +++ b/client/index.html @@ -10,4 +10,4 @@

- + \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index bb145274bd4..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", @@ -30,9 +31,9 @@ "@types/react": "19.0.4", "@types/react-dom": "19.0.2", "@vitejs/plugin-react": "4.3.4", - "@vitest/coverage-v8": "2.1.8", + "@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,8 +44,8 @@ "prettier": "3.4.2", "typescript": "5.7.3", "typescript-eslint": "8.19.1", - "vite": "6.0.7", - "vitest": "2.1.8" + "vite": "6.0.9", + "vitest": "3.0.2" } }, "node_modules/@ampproject/remapping": { @@ -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" @@ -1026,10 +1017,14 @@ } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@csstools/color-helpers": { "version": "5.0.1", @@ -1146,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", @@ -1723,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", @@ -2888,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", @@ -2902,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" } @@ -2911,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" } @@ -2919,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" @@ -3869,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", @@ -4166,30 +4291,31 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.8.tgz", - "integrity": "sha512-2Y7BPlKH18mAZYAW1tYByudlCYrQyl5RGvnnDYJKW5tCiO5qg3KSAy3XAxcxKz900a0ZXxWtKrMuZLe3lKBpJw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.2.tgz", + "integrity": "sha512-U+hZYb0FtgNDb6B3E9piAHzXXIuxuBw2cd6Lvepc9sYYY4KjgiwCBmo3Sird9ZRu3ggLpLBTfw1ZRr77ipiSfw==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", + "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.8", - "vitest": "2.1.8" + "@vitest/browser": "3.0.2", + "vitest": "3.0.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4198,64 +4324,96 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", - "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.2.tgz", + "integrity": "sha512-dKSHLBcoZI+3pmP5hiZ7I5grNru2HRtEW8Z5Zp4IXog8QYcxhlox7JUPyIIFWfN53+3HW3KPLIl6nSzUGgKSuQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.8", - "@vitest/utils": "2.1.8", + "@vitest/spy": "3.0.2", + "@vitest/utils": "3.0.2", "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.2.tgz", + "integrity": "sha512-Hr09FoBf0jlwwSyzIF4Xw31OntpO3XtZjkccpcBf8FeVW3tpiyKlkeUzxS/txzHqpUCNIX157NaTySxedyZLvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", - "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.2.tgz", + "integrity": "sha512-yBohcBw/T/p0/JRgYD+IYcjCmuHzjC3WLAKsVE4/LwiubzZkE8N49/xIQ/KGQwDRA8PaviF8IRO8JMWMngdVVQ==", "dev": true, + "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", - "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.2.tgz", + "integrity": "sha512-GHEsWoncrGxWuW8s405fVoDfSLk6RF2LCXp6XhevbtDjdDme1WV/eNmUueDfpY1IX3MJaCRelVCEXsT9cArfEg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.8", - "pathe": "^1.1.2" + "@vitest/utils": "3.0.2", + "pathe": "^2.0.1" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", - "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.2.tgz", + "integrity": "sha512-h9s67yD4+g+JoYG0zPCo/cLTabpDqzqNdzMawmNPzDStTiwxwkyYM1v5lWE8gmGv3SVJ2DcxA2NpQJZJv9ym3g==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.8", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "3.0.2", + "magic-string": "^0.30.17", + "pathe": "^2.0.1" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", - "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.2.tgz", + "integrity": "sha512-8mI2iUn+PJFMT44e3ISA1R+K6ALVs47W6eriDTfXe6lFqlflID05MB4+rIFhmDSLBj8iBsZkzBYlgSkinxLzSQ==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^3.0.2" }, @@ -4264,14 +4422,15 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", - "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.2.tgz", + "integrity": "sha512-Qu01ZYZlgHvDP02JnMBRpX43nRaZtNpIzw3C1clDXmn8eakgX6iQVGzTQ/NjkIr64WD8ioqOjkaYRVvHQI5qiw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.8", + "@vitest/pretty-format": "3.0.2", "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4686,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", @@ -4961,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" } @@ -5438,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" }, @@ -5698,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" } @@ -5923,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" }, @@ -5992,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" @@ -6489,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", @@ -6599,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" } @@ -7057,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" }, @@ -7075,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", @@ -7172,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" @@ -7188,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" } @@ -7329,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", @@ -7394,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" }, @@ -7964,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" }, @@ -7981,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", @@ -8097,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", @@ -8387,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", @@ -8488,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", @@ -8897,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" }, @@ -8923,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", @@ -8999,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", @@ -9049,16 +9252,16 @@ "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" } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", + "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "dev": true, + "license": "MIT" }, "node_modules/pathval": { "version": "2.0.0", @@ -9084,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", @@ -9392,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", @@ -9517,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", @@ -10037,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", @@ -10319,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", @@ -10343,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" }, @@ -10457,10 +10690,11 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -10470,6 +10704,7 @@ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -10907,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", @@ -10923,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": { @@ -10995,1044 +11243,90 @@ } }, "node_modules/vite-node": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", - "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.2.tgz", + "integrity": "sha512-hsEQerBAHvVAbv40m3TFQe/lTEbOp7yDpyqMJqr2Tnd+W58+DEYOt+fluQgekOePcsNBmR77lpVAnIU2Xu4SvQ==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.1", + "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], + "node_modules/vitest": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.2.tgz", + "integrity": "sha512-5bzaHakQ0hmVVKLhfh/jXf6oETDBtgPo8tQCHYB+wftNgFJ+Hah67IsWc8ivx4vFL025Ow8UiuTf4W57z4izvQ==", "dev": true, - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.0.2", + "@vitest/mocker": "3.0.2", + "@vitest/pretty-format": "^3.0.2", + "@vitest/runner": "3.0.2", + "@vitest/snapshot": "3.0.2", + "@vitest/spy": "3.0.2", + "@vitest/utils": "3.0.2", + "chai": "^5.1.2", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.1", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.2", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", - "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", - "dev": true, - "dependencies": { - "@vitest/expect": "2.1.8", - "@vitest/mocker": "2.1.8", - "@vitest/pretty-format": "^2.1.8", - "@vitest/runner": "2.1.8", - "@vitest/snapshot": "2.1.8", - "@vitest/spy": "2.1.8", - "@vitest/utils": "2.1.8", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.8", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.8", - "@vitest/ui": "2.1.8", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", - "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", - "dev": true, - "dependencies": { - "@vitest/spy": "2.1.8", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.2", + "@vitest/ui": "3.0.2", + "happy-dom": "*", + "jsdom": "*" }, "peerDependenciesMeta": { - "msw": { + "@edge-runtime/vm": { "optional": true }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { + "@vitest/browser": { "optional": true }, - "stylus": { + "@vitest/ui": { "optional": true }, - "sugarss": { + "happy-dom": { "optional": true }, - "terser": { + "jsdom": { "optional": true } } diff --git a/client/package.json b/client/package.json index 78180de6b6a..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", @@ -39,9 +42,9 @@ "@types/react": "19.0.4", "@types/react-dom": "19.0.2", "@vitejs/plugin-react": "4.3.4", - "@vitest/coverage-v8": "2.1.8", + "@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", - "vitest": "2.1.8" + "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/templates/DebugUiConfiguration.md b/doc/templates/DebugUiConfiguration.md index a54b1db8d2a..51491b36cef 100644 --- a/doc/templates/DebugUiConfiguration.md +++ b/doc/templates/DebugUiConfiguration.md @@ -8,7 +8,7 @@ # Debug UI configuration The Debug UI is the standard interface that is bundled with OTP and available by visiting -[`http://localhost:8080`](http://localhost:8080). This page list the configuration options available +[`http://localhost:8080`](http://localhost:8080). This page lists the configuration options available by placing a file `debug-ui-config.json` into OTP's working directory. diff --git a/doc/user/BuildConfiguration.md b/doc/user/BuildConfiguration.md index 99e98066e73..1ec7a7e4c7c 100644 --- a/doc/user/BuildConfiguration.md +++ b/doc/user/BuildConfiguration.md @@ -17,103 +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 | -|       [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 | -|    [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 | @@ -952,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` @@ -1184,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 4639e68da92..70c6ffcebf9 100644 --- a/doc/user/Changelog.md +++ b/doc/user/Changelog.md @@ -74,6 +74,15 @@ based on merged pull requests. Search GitHub issues and pull requests for smalle - When using ScheduledTransitLeg's copy builder, also copy alerts [#6368](https://github.com/opentripplanner/OpenTripPlanner/pull/6368) - Process boarding location for OSM ways (linear platforms) [#6247](https://github.com/opentripplanner/OpenTripPlanner/pull/6247) - 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) +- Add currentFuelPercent and currentRangeMeters to RentalVehichle in the GTFS GraphQL API [#6272](https://github.com/opentripplanner/OpenTripPlanner/pull/6272) +- Add a matcher API for filters in the transit service used for route lookup [#6378](https://github.com/opentripplanner/OpenTripPlanner/pull/6378) [](AUTOMATIC_CHANGELOG_PLACEHOLDER_DO_NOT_REMOVE) ## 2.6.0 (2024-09-18) diff --git a/doc/user/DebugUiConfiguration.md b/doc/user/DebugUiConfiguration.md index a1657796fe0..d0a9c65c9a0 100644 --- a/doc/user/DebugUiConfiguration.md +++ b/doc/user/DebugUiConfiguration.md @@ -8,7 +8,7 @@ # Debug UI configuration The Debug UI is the standard interface that is bundled with OTP and available by visiting -[`http://localhost:8080`](http://localhost:8080). This page list the configuration options available +[`http://localhost:8080`](http://localhost:8080). This page lists the configuration options available by placing a file `debug-ui-config.json` into OTP's working directory. 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 82d14f36392..7d9d2e912d4 100644 --- a/doc/user/RouterConfiguration.md +++ b/doc/user/RouterConfiguration.md @@ -31,45 +31,46 @@ A full list of them can be found in the [RouteRequest](RouteRequest.md). -| Config Parameter | Type | Summary | Req./Opt. | Default Value | Since | -|-------------------------------------------------------------------------------------------|:---------------------:|-------------------------------------------------------------------------------------------------------|:----------:|---------------|:-----:| -| [configVersion](#configVersion) | `string` | Deployment version of the *router-config.json*. | *Optional* | | 2.1 | -| [flex](sandbox/Flex.md) | `object` | Configuration for flex routing. | *Optional* | | 2.1 | -| [rideHailingServices](sandbox/RideHailing.md) | `object[]` | Configuration for interfaces to external ride hailing services like Uber. | *Optional* | | 2.3 | -| [routingDefaults](RouteRequest.md) | `object` | The default parameters for the routing query. | *Optional* | | 2.0 | -| [server](#server) | `object` | Configuration for router server. | *Optional* | | 2.4 | -|    [apiProcessingTimeout](#server_apiProcessingTimeout) | `duration` | Maximum processing time for an API request | *Optional* | `"PT-1S"` | 2.4 | -|    [traceParameters](#server_traceParameters) | `object[]` | Trace OTP request using HTTP request/response parameter(s) combined with logging. | *Optional* | | 2.4 | -|          generateIdIfMissing | `boolean` | If `true` a unique value is generated if no http request header is provided, or the value is missing. | *Optional* | `false` | 2.4 | -|          httpRequestHeader | `string` | The header-key to use when fetching the trace parameter value | *Optional* | | 2.4 | -|          httpResponseHeader | `string` | The header-key to use when saving the value back into the http response | *Optional* | | 2.4 | -|          [logKey](#server_traceParameters_0_logKey) | `string` | The log event key used. | *Optional* | | 2.4 | -| timetableUpdates | `object` | Global configuration for timetable updaters. | *Optional* | | 2.2 | -|    [maxSnapshotFrequency](#timetableUpdates_maxSnapshotFrequency) | `duration` | How long a snapshot should be cached. | *Optional* | `"PT1S"` | 2.2 | -|    purgeExpiredData | `boolean` | Should expired real-time data be purged from the graph. Apply to GTFS-RT and Siri updates. | *Optional* | `true` | 2.2 | -| [transit](#transit) | `object` | Configuration for transit searches with RAPTOR. | *Optional* | | na | -|    [iterationDepartureStepInSeconds](#transit_iterationDepartureStepInSeconds) | `integer` | Step for departure times between each RangeRaptor iterations. | *Optional* | `60` | na | -|    [maxNumberOfTransfers](#transit_maxNumberOfTransfers) | `integer` | This parameter is used to allocate enough memory space for Raptor. | *Optional* | `12` | na | -|    [maxSearchWindow](#transit_maxSearchWindow) | `duration` | Upper limit of the request parameter searchWindow. | *Optional* | `"PT24H"` | 2.4 | -|    [scheduledTripBinarySearchThreshold](#transit_scheduledTripBinarySearchThreshold) | `integer` | This threshold is used to determine when to perform a binary trip schedule search. | *Optional* | `50` | na | -|    [searchThreadPoolSize](#transit_searchThreadPoolSize) | `integer` | Split a travel search in smaller jobs and run them in parallel to improve performance. | *Optional* | `0` | na | -|    [transferCacheMaxSize](#transit_transferCacheMaxSize) | `integer` | The maximum number of distinct transfers parameters to cache pre-calculated transfers for. | *Optional* | `25` | na | -|    [dynamicSearchWindow](#transit_dynamicSearchWindow) | `object` | The dynamic search window coefficients used to calculate the EDT, LAT and SW. | *Optional* | | 2.1 | -|       [maxWindow](#transit_dynamicSearchWindow_maxWindow) | `duration` | Upper limit for the search-window calculation. | *Optional* | `"PT3H"` | 2.2 | -|       [minTransitTimeCoefficient](#transit_dynamicSearchWindow_minTransitTimeCoefficient) | `double` | The coefficient to multiply with `minTransitTime`. | *Optional* | `0.5` | 2.1 | -|       [minWaitTimeCoefficient](#transit_dynamicSearchWindow_minWaitTimeCoefficient) | `double` | The coefficient to multiply with `minWaitTime`. | *Optional* | `0.5` | 2.1 | -|       [minWindow](#transit_dynamicSearchWindow_minWindow) | `duration` | The constant minimum duration for a raptor-search-window. | *Optional* | `"PT40M"` | 2.2 | -|       [stepMinutes](#transit_dynamicSearchWindow_stepMinutes) | `integer` | Used to set the steps the search-window is rounded to. | *Optional* | `10` | 2.1 | -|    [pagingSearchWindowAdjustments](#transit_pagingSearchWindowAdjustments) | `duration[]` | The provided array of durations is used to increase the search-window for the next/previous page. | *Optional* | | na | -|    [stopBoardAlightDuringTransferCost](#transit_stopBoardAlightDuringTransferCost) | `enum map of integer` | Costs for boarding and alighting during transfers at stops with a given transfer priority. | *Optional* | | 2.0 | -|    [transferCacheRequests](#transit_transferCacheRequests) | `object[]` | Routing requests to use for pre-filling the stop-to-stop transfer cache. | *Optional* | | 2.3 | -| transmodelApi | `object` | Configuration for the Transmodel GraphQL API. | *Optional* | | 2.1 | -|    [hideFeedId](#transmodelApi_hideFeedId) | `boolean` | Hide the FeedId in all API output, and add it to input. | *Optional* | `false` | na | -|    [maxNumberOfResultFields](#transmodelApi_maxNumberOfResultFields) | `integer` | The maximum number of fields in a GraphQL result | *Optional* | `1000000` | 2.6 | -|    [tracingHeaderTags](#transmodelApi_tracingHeaderTags) | `string[]` | Used to group requests when monitoring OTP. | *Optional* | | na | -| [updaters](UpdaterConfig.md) | `object[]` | Configuration for the updaters that import various types of data into OTP. | *Optional* | | 1.5 | -| [vectorTiles](sandbox/MapboxVectorTilesApi.md) | `object` | Vector tile configuration | *Optional* | | na | -| [vehicleRentalServiceDirectory](sandbox/VehicleRentalServiceDirectory.md) | `object` | Configuration for the vehicle rental service directory. | *Optional* | | 2.0 | +| Config Parameter | Type | Summary | Req./Opt. | Default Value | Since | +|-------------------------------------------------------------------------------------------|:---------------------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------:|---------------|:-----:| +| [configVersion](#configVersion) | `string` | Deployment version of the *router-config.json*. | *Optional* | | 2.1 | +| [flex](sandbox/Flex.md) | `object` | Configuration for flex routing. | *Optional* | | 2.1 | +| [rideHailingServices](sandbox/RideHailing.md) | `object[]` | Configuration for interfaces to external ride hailing services like Uber. | *Optional* | | 2.3 | +| [routingDefaults](RouteRequest.md) | `object` | The default parameters for the routing query. | *Optional* | | 2.0 | +| [server](#server) | `object` | Configuration for router server. | *Optional* | | 2.4 | +|    [apiDocumentationProfile](#server_apiDocumentationProfile) | `enum` | List of available custom documentation profiles. A profile is used to inject custom documentation like type and field description or a deprecated reason. Currently, ONLY the Transmodel API supports this feature. | *Optional* | `"default"` | 2.7 | +|    [apiProcessingTimeout](#server_apiProcessingTimeout) | `duration` | Maximum processing time for an API request | *Optional* | `"PT-1S"` | 2.4 | +|    [traceParameters](#server_traceParameters) | `object[]` | Trace OTP request using HTTP request/response parameter(s) combined with logging. | *Optional* | | 2.4 | +|          generateIdIfMissing | `boolean` | If `true` a unique value is generated if no http request header is provided, or the value is missing. | *Optional* | `false` | 2.4 | +|          httpRequestHeader | `string` | The header-key to use when fetching the trace parameter value | *Optional* | | 2.4 | +|          httpResponseHeader | `string` | The header-key to use when saving the value back into the http response | *Optional* | | 2.4 | +|          [logKey](#server_traceParameters_0_logKey) | `string` | The log event key used. | *Optional* | | 2.4 | +| timetableUpdates | `object` | Global configuration for timetable updaters. | *Optional* | | 2.2 | +|    [maxSnapshotFrequency](#timetableUpdates_maxSnapshotFrequency) | `duration` | How long a snapshot should be cached. | *Optional* | `"PT1S"` | 2.2 | +|    purgeExpiredData | `boolean` | Should expired real-time data be purged from the graph. Apply to GTFS-RT and Siri updates. | *Optional* | `true` | 2.2 | +| [transit](#transit) | `object` | Configuration for transit searches with RAPTOR. | *Optional* | | na | +|    [iterationDepartureStepInSeconds](#transit_iterationDepartureStepInSeconds) | `integer` | Step for departure times between each RangeRaptor iterations. | *Optional* | `60` | na | +|    [maxNumberOfTransfers](#transit_maxNumberOfTransfers) | `integer` | This parameter is used to allocate enough memory space for Raptor. | *Optional* | `12` | na | +|    [maxSearchWindow](#transit_maxSearchWindow) | `duration` | Upper limit of the request parameter searchWindow. | *Optional* | `"PT24H"` | 2.4 | +|    [scheduledTripBinarySearchThreshold](#transit_scheduledTripBinarySearchThreshold) | `integer` | This threshold is used to determine when to perform a binary trip schedule search. | *Optional* | `50` | na | +|    [searchThreadPoolSize](#transit_searchThreadPoolSize) | `integer` | Split a travel search in smaller jobs and run them in parallel to improve performance. | *Optional* | `0` | na | +|    [transferCacheMaxSize](#transit_transferCacheMaxSize) | `integer` | The maximum number of distinct transfers parameters to cache pre-calculated transfers for. | *Optional* | `25` | na | +|    [dynamicSearchWindow](#transit_dynamicSearchWindow) | `object` | The dynamic search window coefficients used to calculate the EDT, LAT and SW. | *Optional* | | 2.1 | +|       [maxWindow](#transit_dynamicSearchWindow_maxWindow) | `duration` | Upper limit for the search-window calculation. | *Optional* | `"PT3H"` | 2.2 | +|       [minTransitTimeCoefficient](#transit_dynamicSearchWindow_minTransitTimeCoefficient) | `double` | The coefficient to multiply with `minTransitTime`. | *Optional* | `0.5` | 2.1 | +|       [minWaitTimeCoefficient](#transit_dynamicSearchWindow_minWaitTimeCoefficient) | `double` | The coefficient to multiply with `minWaitTime`. | *Optional* | `0.5` | 2.1 | +|       [minWindow](#transit_dynamicSearchWindow_minWindow) | `duration` | The constant minimum duration for a raptor-search-window. | *Optional* | `"PT40M"` | 2.2 | +|       [stepMinutes](#transit_dynamicSearchWindow_stepMinutes) | `integer` | Used to set the steps the search-window is rounded to. | *Optional* | `10` | 2.1 | +|    [pagingSearchWindowAdjustments](#transit_pagingSearchWindowAdjustments) | `duration[]` | The provided array of durations is used to increase the search-window for the next/previous page. | *Optional* | | na | +|    [stopBoardAlightDuringTransferCost](#transit_stopBoardAlightDuringTransferCost) | `enum map of integer` | Costs for boarding and alighting during transfers at stops with a given transfer priority. | *Optional* | | 2.0 | +|    [transferCacheRequests](#transit_transferCacheRequests) | `object[]` | Routing requests to use for pre-filling the stop-to-stop transfer cache. | *Optional* | | 2.3 | +| transmodelApi | `object` | Configuration for the Transmodel GraphQL API. | *Optional* | | 2.1 | +|    [hideFeedId](#transmodelApi_hideFeedId) | `boolean` | Hide the FeedId in all API output, and add it to input. | *Optional* | `false` | na | +|    [maxNumberOfResultFields](#transmodelApi_maxNumberOfResultFields) | `integer` | The maximum number of fields in a GraphQL result | *Optional* | `1000000` | 2.6 | +|    [tracingHeaderTags](#transmodelApi_tracingHeaderTags) | `string[]` | Used to group requests when monitoring OTP. | *Optional* | | na | +| [updaters](UpdaterConfig.md) | `object[]` | Configuration for the updaters that import various types of data into OTP. | *Optional* | | 1.5 | +| [vectorTiles](sandbox/MapboxVectorTilesApi.md) | `object` | Vector tile configuration | *Optional* | | na | +| [vehicleRentalServiceDirectory](sandbox/VehicleRentalServiceDirectory.md) | `object` | Configuration for the vehicle rental service directory. | *Optional* | | 2.0 | @@ -108,6 +109,22 @@ These parameters are used to configure the router server. Many parameters are sp domain, these are set in the routing request. +

apiDocumentationProfile

+ +**Since version:** `2.7` ∙ **Type:** `enum` ∙ **Cardinality:** `Optional` ∙ **Default value:** `"default"` +**Path:** /server +**Enum values:** `default` | `entur` + +List of available custom documentation profiles. A profile is used to inject custom +documentation like type and field description or a deprecated reason. + +Currently, ONLY the Transmodel API supports this feature. + + + - `default` Default documentation is used. + - `entur` Entur specific documentation. This deprecate features not supported at Entur, Norway. + +

apiProcessingTimeout

**Since version:** `2.4` ∙ **Type:** `duration` ∙ **Cardinality:** `Optional` ∙ **Default value:** `"PT-1S"` @@ -551,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 f77d753472d..611e60d4c8c 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ - 176 + 178 32.1 @@ -71,7 +71,7 @@ 5.6.0 1.5.12 10.1.0 - 1.14.1 + 1.14.3 2.0.15 5.6.0 4.28.3 diff --git a/renovate.json5 b/renovate.json5 index 30a5c6a99c1..14d025745bb 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -109,7 +109,7 @@ }, { "groupName": "Update GTFS API code generation in a single PR", - "matchFiles": ["src/main/java/org/opentripplanner/apis/gtfs/generated/package.json"], + "matchFiles": ["application/src/main/java/org/opentripplanner/apis/gtfs/generated/package.json"], "reviewers": ["optionsome", "leonardehrenfried"], "schedule": "on the 11th through 12th day of the month" }, @@ -158,7 +158,8 @@ "io.micrometer:micrometer-registry-influx", "com.fasterxml.jackson:{/,}**", "com.fasterxml.jackson.datatype::{/,}**" - ] + ], + "automerge": true }, { "description": "give some projects time to publish a changelog before opening the PR", diff --git a/utils/src/main/java/org/opentripplanner/utils/text/TextVariablesSubstitution.java b/utils/src/main/java/org/opentripplanner/utils/text/TextVariablesSubstitution.java new file mode 100644 index 00000000000..95226ed4bd0 --- /dev/null +++ b/utils/src/main/java/org/opentripplanner/utils/text/TextVariablesSubstitution.java @@ -0,0 +1,102 @@ +package org.opentripplanner.utils.text; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * This utility class substitute variable placeholders in a given text on the format ${variable}. + * + * The pattern matching a placeholder must start with '${' and end with '}'. The variable name + * must consist of only alphanumerical characters (a-z, A-Z, 0-9), dot `.` and underscore '_'. + */ +public class TextVariablesSubstitution { + + private static final Pattern PATTERN = Pattern.compile("\\$\\{([.\\w]+)}"); + + /** + * This method uses the {@link #insertVariables(String, Function, Consumer)} to substitute + * all variable tokens in all values in the given {@code properties}. It supports nesting, but + * you must avoid cyclic references. + *

+ * Example: + *

+   *   a -> My car is a ${b} car, with an ${c} look.
+   *   b -> good old ${c}
+   *   c -> fancy
+   * 
+ * This will resolve to: + *
+   *   a -> My car is a good old fancy car, with an fancy look.
+   *   b -> good old fancy
+   *   c -> fancy
+   * 
+ */ + public static Map insertVariables( + Map properties, + Consumer errorHandler + ) { + var result = new HashMap(properties); + + for (String key : result.keySet()) { + var value = result.get(key); + var sub = insertVariables(value, result::get, errorHandler); + if (!value.equals(sub)) { + result.put(key, sub); + } + } + return result; + } + + /** + * Replace all variables({@code ${variable.name}}) in the given {@code text}. The given + * {@code variableProvider} is used to look up values to insert into the text replacing the + * variable token. + * + * @param errorHandler The error handler is called if a variable key does not exist in the + * {@code variableProvider}. + * @return the new value with all variables replaced. + */ + public static String insertVariables( + String text, + Function variableProvider, + Consumer errorHandler + ) { + return insert(text, PATTERN.matcher(text), variableProvider, errorHandler); + } + + private static String insert( + String text, + Matcher matcher, + Function variableProvider, + Consumer errorHandler + ) { + boolean matchFound = matcher.find(); + if (!matchFound) { + return text; + } + + Map substitutions = new HashMap<>(); + + while (matchFound) { + String subKey = matcher.group(0); + String nameOnly = matcher.group(1); + if (!substitutions.containsKey(nameOnly)) { + String value = variableProvider.apply(nameOnly); + if (value != null) { + substitutions.put(subKey, value); + } else { + errorHandler.accept(nameOnly); + } + } + matchFound = matcher.find(); + } + for (Map.Entry entry : substitutions.entrySet()) { + text = text.replace(entry.getKey(), entry.getValue()); + } + return insert(text, PATTERN.matcher(text), variableProvider, errorHandler); + } +} diff --git a/utils/src/test/java/org/opentripplanner/utils/text/TextVariablesSubstitutionTest.java b/utils/src/test/java/org/opentripplanner/utils/text/TextVariablesSubstitutionTest.java new file mode 100644 index 00000000000..5c1c2014cc2 --- /dev/null +++ b/utils/src/test/java/org/opentripplanner/utils/text/TextVariablesSubstitutionTest.java @@ -0,0 +1,56 @@ +package org.opentripplanner.utils.text; + +import static java.util.Map.entry; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.opentripplanner.utils.text.TextVariablesSubstitution.insertVariables; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TextVariablesSubstitutionTest { + + @Test + void testInsertVariablesInProperties() { + Map map = Map.ofEntries( + entry("a", "A"), + entry("b", "B"), + entry("ab", "${a}${b}"), + entry("ab2", "${ab} - ${a} - ${b}") + ); + + var result = insertVariables(map, this::errorHandler); + + assertEquals("A", result.get("a")); + assertEquals("B", result.get("b")); + assertEquals("AB", result.get("ab")); + assertEquals("AB - A - B", result.get("ab2")); + } + + @Test + void testInsertVariablesInValue() { + var map = Map.ofEntries( + entry("a", "A"), + entry("b", "B"), + entry("ab", "${a}${b}"), + entry("ab2", "${ab} - ${a} - ${b}") + ); + + assertEquals( + "No substitution", + insertVariables("No substitution", map::get, this::errorHandler) + ); + assertEquals("A B", insertVariables("${a} ${b}", map::get, this::errorHandler)); + assertEquals("AB", insertVariables("${ab}", map::get, this::errorHandler)); + assertEquals("AB - A - B", insertVariables("${ab2}", map::get, this::errorHandler)); + var ex = assertThrows( + IllegalArgumentException.class, + () -> insertVariables("${c}", map::get, this::errorHandler) + ); + assertEquals("c", ex.getMessage()); + } + + private void errorHandler(String name) { + throw new IllegalArgumentException(name); + } +}