diff --git a/pom.xml b/pom.xml index d60314ea..dc35011b 100644 --- a/pom.xml +++ b/pom.xml @@ -207,6 +207,11 @@ mapbox-sdk-geojson 5.8.0 + + org.geotools + gt-main + 31.3 + @@ -356,4 +361,21 @@ + + + + osgeo + OSGeo Release Repository + https://repo.osgeo.org/repository/release/ + false + true + + + osgeo-snapshot + OSGeo Snapshot Repository + https://repo.osgeo.org/repository/snapshot/ + true + false + + diff --git a/src/main/java/org/entur/lamassu/controller/GraphQLQueryController.java b/src/main/java/org/entur/lamassu/controller/GraphQLQueryController.java index 1f6410bd..fc3f43fa 100644 --- a/src/main/java/org/entur/lamassu/controller/GraphQLQueryController.java +++ b/src/main/java/org/entur/lamassu/controller/GraphQLQueryController.java @@ -18,6 +18,7 @@ import org.entur.lamassu.model.entities.Station; import org.entur.lamassu.model.entities.Vehicle; import org.entur.lamassu.model.provider.FeedProvider; +import org.entur.lamassu.service.BoundingBoxQueryParameters; import org.entur.lamassu.service.FeedProviderService; import org.entur.lamassu.service.GeoSearchService; import org.entur.lamassu.service.RangeQueryParameters; @@ -71,6 +72,10 @@ public Collection getVehicles( Double lat, Double lon, Double range, + Double minimumLatitude, + Double minimumLongitude, + Double maximumLatitude, + Double maximumLongitude, Integer count, List codespaces, List systems, @@ -84,17 +89,10 @@ public Collection getVehicles( return vehicleCache.getAll(ids); } - validateRange(range); validateCount(count); validateCodespaces(codespaces); validateSystems(systems); - var queryParams = new RangeQueryParameters(); - queryParams.setLat(lat); - queryParams.setLon(lon); - queryParams.setRange(range); - queryParams.setCount(count); - var filterParams = new VehicleFilterParameters(); filterParams.setCodespaces(codespaces); filterParams.setSystems(systems); @@ -103,10 +101,54 @@ public Collection getVehicles( filterParams.setPropulsionTypes(propulsionTypes); filterParams.setIncludeReserved(includeReserved); filterParams.setIncludeDisabled(includeDisabled); + filterParams.setCount(count); + + if ( + isBoundingBoxSearch( + minimumLatitude, + minimumLongitude, + maximumLatitude, + maximumLongitude + ) + ) { + var boundingBoxQueryParameters = new BoundingBoxQueryParameters(); + boundingBoxQueryParameters.setMinimumLatitude(minimumLatitude); + boundingBoxQueryParameters.setMinimumLongitude(minimumLongitude); + boundingBoxQueryParameters.setMaximumLatitude(maximumLatitude); + boundingBoxQueryParameters.setMaximumLongitude(maximumLongitude); + + logger.debug( + "getVehicles called boundingBoxQueryParameters={} filter={}", + boundingBoxQueryParameters, + filterParams + ); + + return geoSearchService.getVehiclesInBoundingBox( + boundingBoxQueryParameters, + filterParams + ); + } else if (isRangeSearch(range, lat, lon)) { + validateRange(range); - logger.debug("getVehicles called query={} filter={}", queryParams, filterParams); + var rangeQueryParameters = new RangeQueryParameters(); + rangeQueryParameters.setLat(lat); + rangeQueryParameters.setLon(lon); + rangeQueryParameters.setRange(range); - return geoSearchService.getVehiclesNearby(queryParams, filterParams); + logger.debug( + "getVehicles called rangeQueryParameters={} filter={}", + rangeQueryParameters, + filterParams + ); + + return geoSearchService.getVehiclesWithinRange(rangeQueryParameters, filterParams); + } else { + throw new GraphqlErrorException.Builder() + .message( + "You must either specify lat, lon and range OR minimumLatitude, minimumLongitude, maximumLatitude and maximumLongitude" + ) + .build(); + } } public Vehicle getVehicle(String id) { @@ -118,6 +160,10 @@ public Collection getStations( Double lat, Double lon, Double range, + Double minimumLatitude, + Double minimumLongitude, + Double maximumLatitude, + Double maximumLongitude, Integer count, List codespaces, List systems, @@ -129,27 +175,77 @@ public Collection getStations( return stationCache.getAll(ids); } - validateRange(range); validateCount(count); validateCodespaces(codespaces); validateSystems(systems); - var queryParams = new RangeQueryParameters(); - queryParams.setLat(lat); - queryParams.setLon(lon); - queryParams.setRange(range); - queryParams.setCount(count); - var filterParams = new StationFilterParameters(); filterParams.setCodespaces(codespaces); filterParams.setSystems(systems); filterParams.setOperators(operators); filterParams.setAvailableFormFactors(availableFormFactors); filterParams.setAvailablePropulsionTypes(availablePropulsionTypes); + filterParams.setCount(count); - logger.debug("getStations called query={} filter={}", queryParams, filterParams); + if ( + isBoundingBoxSearch( + minimumLatitude, + minimumLongitude, + maximumLatitude, + maximumLongitude + ) + ) { + var boundingBoxQueryParameters = new BoundingBoxQueryParameters(); + boundingBoxQueryParameters.setMinimumLatitude(minimumLatitude); + boundingBoxQueryParameters.setMinimumLongitude(minimumLongitude); + boundingBoxQueryParameters.setMaximumLatitude(maximumLatitude); + boundingBoxQueryParameters.setMaximumLongitude(maximumLongitude); + logger.debug( + "getStations called boundingBoxQueryParameters={} filter={}", + boundingBoxQueryParameters, + filterParams + ); + return geoSearchService.getStationsInBoundingBox( + boundingBoxQueryParameters, + filterParams + ); + } else if (isRangeSearch(range, lat, lon)) { + validateRange(range); + var rangeQueryParameters = new RangeQueryParameters(); + rangeQueryParameters.setLat(lat); + rangeQueryParameters.setLon(lon); + rangeQueryParameters.setRange(range); + logger.debug( + "getStations called rangeQueryParameters={} filter={}", + rangeQueryParameters, + filterParams + ); + return geoSearchService.getStationsWithinRange(rangeQueryParameters, filterParams); + } else { + throw new GraphqlErrorException.Builder() + .message( + "You must either specify lat, lon and range OR minimumLatitude, minimumLongitude, maximumLatitude and maximumLongitude" + ) + .build(); + } + } - return geoSearchService.getStationsNearby(queryParams, filterParams); + private boolean isRangeSearch(Double range, Double lat, Double lon) { + return range != null && lat != null && lon != null; + } + + private boolean isBoundingBoxSearch( + Double minimumLatitude, + Double minimumLongitude, + Double maximumLatitude, + Double maximumLongitude + ) { + return ( + minimumLatitude != null && + minimumLongitude != null && + maximumLatitude != null && + maximumLongitude != null + ); } public Station getStation(String id) { diff --git a/src/main/java/org/entur/lamassu/service/BoundingBoxQueryParameters.java b/src/main/java/org/entur/lamassu/service/BoundingBoxQueryParameters.java new file mode 100644 index 00000000..b4163cb5 --- /dev/null +++ b/src/main/java/org/entur/lamassu/service/BoundingBoxQueryParameters.java @@ -0,0 +1,59 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.service; + +public class BoundingBoxQueryParameters { + + private Double minimumLatitude; + private Double minimumLongitude; + private Double maximumLatitude; + private Double maximumLongitude; + + public Double getMinimumLatitude() { + return minimumLatitude; + } + + public void setMinimumLatitude(Double minimumLatitude) { + this.minimumLatitude = minimumLatitude; + } + + public Double getMinimumLongitude() { + return minimumLongitude; + } + + public void setMinimumLongitude(Double minimumLongitude) { + this.minimumLongitude = minimumLongitude; + } + + public Double getMaximumLatitude() { + return maximumLatitude; + } + + public void setMaximumLatitude(Double maximumLatitude) { + this.maximumLatitude = maximumLatitude; + } + + public Double getMaximumLongitude() { + return maximumLongitude; + } + + public void setMaximumLongitude(Double maximumLongitude) { + this.maximumLongitude = maximumLongitude; + } +} diff --git a/src/main/java/org/entur/lamassu/service/FilterParameters.java b/src/main/java/org/entur/lamassu/service/FilterParameters.java index 5307904a..a006a6bd 100644 --- a/src/main/java/org/entur/lamassu/service/FilterParameters.java +++ b/src/main/java/org/entur/lamassu/service/FilterParameters.java @@ -25,6 +25,7 @@ public class FilterParameters { private List codespaces; private List systems; private List operators; + private Integer count; public List getCodespaces() { return codespaces; @@ -49,4 +50,12 @@ public List getOperators() { public void setOperators(List operators) { this.operators = operators; } + + public Integer getCount() { + return count; + } + + public void setCount(Integer count) { + this.count = count; + } } diff --git a/src/main/java/org/entur/lamassu/service/GeoSearchService.java b/src/main/java/org/entur/lamassu/service/GeoSearchService.java index 0eb66b9f..9342fc6e 100644 --- a/src/main/java/org/entur/lamassu/service/GeoSearchService.java +++ b/src/main/java/org/entur/lamassu/service/GeoSearchService.java @@ -6,14 +6,22 @@ import org.entur.lamassu.model.entities.Vehicle; public interface GeoSearchService { - List getVehiclesNearby( + List getVehiclesWithinRange( RangeQueryParameters rangeQueryParameters, VehicleFilterParameters vehicleFilterParameters ); - List getStationsNearby( + List getVehiclesInBoundingBox( + BoundingBoxQueryParameters boundingBoxQueryParameters, + VehicleFilterParameters vehicleFilterParameters + ); + List getStationsWithinRange( RangeQueryParameters rangeQueryParameters, StationFilterParameters stationFilterParameters ); + List getStationsInBoundingBox( + BoundingBoxQueryParameters boundingBoxQueryParameters, + StationFilterParameters stationFilterParameters + ); Collection getVehicleSpatialIndexOrphans(); Collection removeVehicleSpatialIndexOrphans(); } diff --git a/src/main/java/org/entur/lamassu/service/RangeQueryParameters.java b/src/main/java/org/entur/lamassu/service/RangeQueryParameters.java index a35a7a1f..70aa914e 100644 --- a/src/main/java/org/entur/lamassu/service/RangeQueryParameters.java +++ b/src/main/java/org/entur/lamassu/service/RangeQueryParameters.java @@ -5,7 +5,6 @@ public class RangeQueryParameters { private Double lat; private Double lon; private Double range; - private Integer count; public Double getLat() { return lat; @@ -30,12 +29,4 @@ public Double getRange() { public void setRange(Double range) { this.range = range; } - - public Integer getCount() { - return count; - } - - public void setCount(Integer count) { - this.count = count; - } } diff --git a/src/main/java/org/entur/lamassu/service/impl/GeoSearchServiceImpl.java b/src/main/java/org/entur/lamassu/service/impl/GeoSearchServiceImpl.java index 48298b98..99ef1a61 100644 --- a/src/main/java/org/entur/lamassu/service/impl/GeoSearchServiceImpl.java +++ b/src/main/java/org/entur/lamassu/service/impl/GeoSearchServiceImpl.java @@ -13,11 +13,13 @@ import org.entur.lamassu.cache.VehicleSpatialIndexId; import org.entur.lamassu.model.entities.Station; import org.entur.lamassu.model.entities.Vehicle; +import org.entur.lamassu.service.BoundingBoxQueryParameters; import org.entur.lamassu.service.GeoSearchService; import org.entur.lamassu.service.RangeQueryParameters; import org.entur.lamassu.service.StationFilterParameters; import org.entur.lamassu.service.VehicleFilterParameters; import org.entur.lamassu.util.SpatialIndexIdFilter; +import org.geotools.geometry.jts.ReferencedEnvelope; import org.redisson.api.GeoOrder; import org.redisson.api.GeoUnit; import org.springframework.beans.factory.annotation.Autowired; @@ -45,14 +47,13 @@ public GeoSearchServiceImpl( } @Override - public List getVehiclesNearby( + public List getVehiclesWithinRange( RangeQueryParameters rangeQueryParameters, VehicleFilterParameters vehicleFilterParameters ) { Double longitude = rangeQueryParameters.getLon(); Double latitude = rangeQueryParameters.getLat(); Double range = rangeQueryParameters.getRange(); - Integer count = rangeQueryParameters.getCount(); List indexIds = vehicleSpatialIndex.radius( longitude, @@ -67,6 +68,8 @@ public List getVehiclesNearby( .filter(Objects::nonNull) .filter(id -> SpatialIndexIdFilter.filterVehicle(id, vehicleFilterParameters)); + Integer count = vehicleFilterParameters.getCount(); + if (count != null) { stream = stream.limit(count.longValue()); } @@ -79,14 +82,34 @@ public List getVehiclesNearby( } @Override - public List getStationsNearby( + public List getVehiclesInBoundingBox( + BoundingBoxQueryParameters boundingBoxQueryParameters, + VehicleFilterParameters vehicleFilterParameters + ) { + ReferencedEnvelope envelope = GeoUtils.mapToEnvelope(boundingBoxQueryParameters); + RangeQueryParameters rangeQueryParameters = GeoUtils.mapToRangeQueryParameters( + envelope + ); + + List vehicles = getVehiclesWithinRange( + rangeQueryParameters, + vehicleFilterParameters + ); + + return vehicles + .stream() + .filter(vehicle -> envelope.contains(vehicle.getLon(), vehicle.getLat())) + .toList(); + } + + @Override + public List getStationsWithinRange( RangeQueryParameters rangeQueryParameters, StationFilterParameters filterParameters ) { Double longitude = rangeQueryParameters.getLon(); Double latitude = rangeQueryParameters.getLat(); Double range = rangeQueryParameters.getRange(); - Integer count = rangeQueryParameters.getCount(); List indexIds = stationSpatialIndex.radius( longitude, @@ -101,6 +124,8 @@ public List getStationsNearby( .filter(Objects::nonNull) .filter(id -> SpatialIndexIdFilter.filterStation(id, filterParameters)); + Integer count = filterParameters.getCount(); + if (count != null) { stream = stream.limit(count.longValue()); } @@ -112,6 +137,25 @@ public List getStationsNearby( return stationCache.getAll(stationIds); } + @Override + public List getStationsInBoundingBox( + BoundingBoxQueryParameters boundingBoxQueryParameters, + StationFilterParameters stationFilterParameters + ) { + ReferencedEnvelope envelope = GeoUtils.mapToEnvelope(boundingBoxQueryParameters); + RangeQueryParameters rangeQueryParameters = GeoUtils.mapToRangeQueryParameters( + envelope + ); + List stations = getStationsWithinRange( + rangeQueryParameters, + stationFilterParameters + ); + return stations + .stream() + .filter(station -> envelope.contains(station.getLon(), station.getLat())) + .toList(); + } + @Override public Collection getVehicleSpatialIndexOrphans() { var indexIds = vehicleSpatialIndex.getAll(); diff --git a/src/main/java/org/entur/lamassu/service/impl/GeoUtils.java b/src/main/java/org/entur/lamassu/service/impl/GeoUtils.java new file mode 100644 index 00000000..a3fae703 --- /dev/null +++ b/src/main/java/org/entur/lamassu/service/impl/GeoUtils.java @@ -0,0 +1,151 @@ +/* + * + * + * * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * * the European Commission - subsequent versions of the EUPL (the "Licence"); + * * You may not use this work except in compliance with the Licence. + * * You may obtain a copy of the Licence at: + * * + * * https://joinup.ec.europa.eu/software/page/eupl + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the Licence is distributed on an "AS IS" basis, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the Licence for the specific language governing permissions and + * * limitations under the Licence. + * + */ + +package org.entur.lamassu.service.impl; + +import org.apache.commons.pool.BasePoolableObjectFactory; +import org.apache.commons.pool.PoolableObjectFactory; +import org.apache.commons.pool.impl.GenericObjectPool; +import org.entur.lamassu.service.BoundingBoxQueryParameters; +import org.entur.lamassu.service.RangeQueryParameters; +import org.geotools.geometry.jts.JTS; +import org.geotools.geometry.jts.ReferencedEnvelope; +import org.geotools.referencing.GeodeticCalculator; +import org.geotools.referencing.crs.DefaultGeographicCRS; +import org.jetbrains.annotations.NotNull; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateXY; + +public class GeoUtils { + + private GeoUtils() {} + + /** + * Map the bounding box parameters to a geometric envelope + * @param boundingBoxQueryParameters + * @return + */ + public static @NotNull ReferencedEnvelope mapToEnvelope( + BoundingBoxQueryParameters boundingBoxQueryParameters + ) { + return new ReferencedEnvelope( + boundingBoxQueryParameters.getMinimumLongitude(), + boundingBoxQueryParameters.getMaximumLongitude(), + boundingBoxQueryParameters.getMinimumLatitude(), + boundingBoxQueryParameters.getMaximumLatitude(), + DefaultGeographicCRS.WGS84 + ); + } + + /** + * Uses the center of the envelope, and the radius of the circle the encloses the envelope to + * define the coordinates and range of the range query parameters. + * @param envelope + * @return + */ + public static @NotNull RangeQueryParameters mapToRangeQueryParameters( + ReferencedEnvelope envelope + ) { + RangeQueryParameters rangeQueryParameters = new RangeQueryParameters(); + + // The center of the circle that encloses the envelope is equal to the center + // of the envelope itself + rangeQueryParameters.setLon(envelope.getCenterX()); + rangeQueryParameters.setLat(envelope.getCenterY()); + + // The diameter of the circle that encloses the envelope is equal to the length + // of the diagonal of the envelope, hence half this length equals the circle's radius + rangeQueryParameters.setRange(getDiagonalLength(envelope) / 2.0); + return rangeQueryParameters; + } + + /** + * Find the great circle distance between the southwest and northeast corners of the envelope, + * which we called the diagonal length of the envelope. + * @param envelope + * @return + */ + private static double getDiagonalLength(ReferencedEnvelope envelope) { + GeodeticCalculator geodeticCalculator = null; + try { + geodeticCalculator = GeodeticCalculatorPoolManager.getInstance().get(); + Coordinate start = new CoordinateXY(envelope.getMinX(), envelope.getMinY()); + Coordinate end = new CoordinateXY(envelope.getMaxX(), envelope.getMaxY()); + geodeticCalculator.setStartingPosition( + JTS.toDirectPosition(start, DefaultGeographicCRS.WGS84) + ); + geodeticCalculator.setDestinationPosition( + JTS.toDirectPosition(end, DefaultGeographicCRS.WGS84) + ); + return geodeticCalculator.getOrthodromicDistance(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Unable to calculate diagonal length of envelope", + e + ); + } catch (Exception e) { + throw new IllegalStateException( + "Unable to calculate diagonal length of envelope", + e + ); + } finally { + GeodeticCalculatorPoolManager.getInstance().release(geodeticCalculator); + } + } + + private static class GeodeticCalculatorPoolManager { + + // pool size can be high, purpose is to not share instances between threads + private static final int POOL_SIZE = 100; + private static final GeodeticCalculatorPoolManager INSTANCE = + new GeodeticCalculatorPoolManager(); + + private final GenericObjectPool pool; + + private GeodeticCalculatorPoolManager() { + GenericObjectPool.Config config = new GenericObjectPool.Config(); + config.maxActive = POOL_SIZE; + PoolableObjectFactory geodeticCalculatorFactory = + new BasePoolableObjectFactory<>() { + @Override + public GeodeticCalculator makeObject() { + return new GeodeticCalculator(DefaultGeographicCRS.WGS84); + } + }; + + pool = new GenericObjectPool<>(geodeticCalculatorFactory, config); + } + + public static GeodeticCalculatorPoolManager getInstance() { + return INSTANCE; + } + + private GeodeticCalculator get() throws Exception { + return pool.borrowObject(); + } + + private void release(GeodeticCalculator gc) { + try { + pool.returnObject(gc); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/resources/graphql/schema.graphqls b/src/main/resources/graphql/schema.graphqls index f1fbbd06..145eb18a 100644 --- a/src/main/resources/graphql/schema.graphqls +++ b/src/main/resources/graphql/schema.graphqls @@ -18,6 +18,18 @@ type Query { "Search radius in meters. Required unless using 'ids'" range: Int + "Bounding box minimum latitude" + minimumLatitude: Float + + "Bounding box minimum longitude" + minimumLongitude: Float + + "Bounding box maximum latitude" + maximumLatitude: Float + + "Bounding box maximum longitude" + maximumLongitude: Float + "Max results to return." count: Int @@ -58,6 +70,18 @@ type Query { "Search radius in meters. Required unless using 'ids'" range: Int + "Bounding box minimum latitude" + minimumLatitude: Float + + "Bounding box minimum longitude" + minimumLongitude: Float + + "Bounding box maximum latitude" + maximumLatitude: Float + + "Bounding box maximum longitude" + maximumLongitude: Float + "Max results to return." count: Int diff --git a/src/test/java/org/entur/lamassu/integration/GraphQLIntegrationTest.java b/src/test/java/org/entur/lamassu/integration/GraphQLIntegrationTest.java index bbeb775a..394ecb0b 100644 --- a/src/test/java/org/entur/lamassu/integration/GraphQLIntegrationTest.java +++ b/src/test/java/org/entur/lamassu/integration/GraphQLIntegrationTest.java @@ -178,4 +178,35 @@ public void testUnknownOperatorDoesNotThrow() throws IOException { .isEmpty() ); } + + @Test + public void testVehiclesBoundingBoxQuery() throws IOException { + GraphQLResponse response = graphQLTestTemplate.postForResource( + "vehicles_bbox_query.graphql" + ); + assertEquals(HttpStatus.OK, response.getStatusCode()); + + assertEquals( + 2, + JsonPath + .parse(response.getRawResponse().getBody()) + .read("$.data.vehicles", List.class) + .size() + ); + } + + @Test + public void testStationsBoundingBoxQuery() throws IOException { + GraphQLResponse response = graphQLTestTemplate.postForResource( + "stations_bbox_query.graphql" + ); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals( + 1, + JsonPath + .parse(response.getRawResponse().getBody()) + .read("$.data.stations", List.class) + .size() + ); + } } diff --git a/src/test/resources/stations_bbox_query.graphql b/src/test/resources/stations_bbox_query.graphql new file mode 100644 index 00000000..afe332c4 --- /dev/null +++ b/src/test/resources/stations_bbox_query.graphql @@ -0,0 +1,14 @@ +{ + stations( + minimumLongitude: 44.0, + minimumLatitude: 11.0, + maximumLongitude: 45.0 + maximumLatitude: 12.0 + count: 1 + ) { + id + lat + lon + } +} +, diff --git a/src/test/resources/vehicles_bbox_query.graphql b/src/test/resources/vehicles_bbox_query.graphql new file mode 100644 index 00000000..643a323d --- /dev/null +++ b/src/test/resources/vehicles_bbox_query.graphql @@ -0,0 +1,14 @@ +{ + vehicles( + minimumLongitude: 10.0, + minimumLatitude: 59.0, + maximumLongitude: 11.0 + maximumLatitude: 60.0 + count: 2, + includeDisabled: true + ) { + id + lat + lon + } +}