diff --git a/include/motis/endpoints/nearest.h b/include/motis/endpoints/nearest.h new file mode 100644 index 0000000000..02e1b34eb9 --- /dev/null +++ b/include/motis/endpoints/nearest.h @@ -0,0 +1,19 @@ +#pragma once + +#include "motis-api/motis-api.h" +#include "motis/config.h" + +#include "osr/lookup.h" +#include "osr/ways.h" + +namespace motis::ep { + +struct nearest { + api::NearestResponse operator()(boost::urls::url_view const&) const; + + osr::ways const& w_; + osr::lookup const& l_; + config const& c_; +}; + +} // namespace motis::ep diff --git a/include/motis/motis_instance.h b/include/motis/motis_instance.h index fc30cd1c16..0a4da9ed77 100644 --- a/include/motis/motis_instance.h +++ b/include/motis/motis_instance.h @@ -24,6 +24,7 @@ #include "motis/endpoints/map/trips.h" #include "motis/endpoints/matches.h" #include "motis/endpoints/metrics.h" +#include "motis/endpoints/nearest.h" #include "motis/endpoints/ojp.h" #include "motis/endpoints/one_to_all.h" #include "motis/endpoints/one_to_many.h" @@ -94,6 +95,7 @@ struct motis_instance { POST("/api/route", d); POST("/api/platforms", d); POST("/api/graph", d); + GET("/nearest/v1", d); GET("/api/debug/transfers", d); GET("/api/debug/flex", d); GET("/api/v1/map/levels", d); diff --git a/openapi.yaml b/openapi.yaml index 3a12ce6629..ec8a0ff15e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3077,7 +3077,45 @@ paths: application/json: schema: $ref: '#/components/schemas/HealthResponse' - + + /nearest/v1/{profile}/{coordinates}: + get: + operationId: nearest + parameters: + - name: profile + in: path + required: true + schema: + type: string + + - name: coordinates + in: path + required: true + schema: + type: string + + - name: number + in: query + required: false + description: Number of nearest segments that should be returned. + schema: + type: integer + default: 1 + + - name: radiuses + in: query + required: false + description: Radius constraint + schema: + type: number + responses: + '200': + description: Returns the nearest road segment(s) for the provided coordinates. + content: + application/json: + schema: + $ref: '#/components/schemas/NearestResponse' + /api/debug/transfers: get: tags: @@ -5424,3 +5462,33 @@ components: gbfs: type: boolean description: GBFS feeds. + + Waypoint: + type: object + properties: + name: + type: string + description: name Name of the street the coordinate snapped to + location: + type: array + items: + type: number + description: longitude, latitude pair of the snapped coordinate + distance: + type: number + description: The distance of the snapped point from the original + + NearestResponse: + type: object + required: + - code + - waypoints + properties: + code: + type: string + message: + type: string + waypoints: + type: array + items: + $ref: '#/components/schemas/Waypoint' diff --git a/src/endpoints/nearest.cc b/src/endpoints/nearest.cc new file mode 100644 index 0000000000..10896951f0 --- /dev/null +++ b/src/endpoints/nearest.cc @@ -0,0 +1,64 @@ +#include "motis/endpoints/nearest.h" + +#include "motis/parse_location.h" + +#include "osr/routing/with_profile.h" + +#include "net/bad_request_exception.h" + +namespace motis::ep { + +api::NearestResponse nearest::operator()( + boost::urls::url_view const& url) const { + + api::nearest_params params{url.params(), url.segments()}; + osr::search_profile p; + + try { + p = osr::to_profile(params.profile_); + } catch (...) { + throw net::bad_request_exception("invalid profile"); + } + + auto coord = parse_location(params.coordinates_); + utl::verify(coord.has_value(), + "invalid coordinates"); + utl::verify(params.number_ >= 1, + "invalid number"); + + std::swap(coord->pos_.lat_, coord->pos_.lng_); + api::NearestResponse res; + auto const from = coord.value(); + auto const candidates = osr::with_profile(p, [&](P&&) { + constexpr auto kDefaultRadius = 100U; + auto const max_dist = params.radiuses_.value_or(kDefaultRadius); + return l_.match

(std::get(osr::get_parameters(p)), + from, false, osr::direction::kForward, max_dist, + nullptr); + }); + + if (candidates.empty()) { + res.code_ = "NoSegment"; + return res; + } + + auto const n = + std::min(static_cast(params.number_), candidates.size()); + for (auto i = 0U; i < n; ++i) { + auto const c = candidates[i]; + auto const loc = c.closest_point_on_way_; + auto const id = w_.way_names_[c.way_]; + auto wp = api::Waypoint{}; + if (id != osr::string_idx_t::invalid()) { + wp.name_ = w_.strings_[id].view(); + } + wp.distance_ = geo::distance(loc, from.pos_); + wp.location_ = {loc.lng(), loc.lat()}; + res.waypoints_.emplace_back(wp); + } + + res.code_ = "OK"; + return res; +} + +} // namespace motis::ep diff --git a/test/endpoints/nearest_test.cc b/test/endpoints/nearest_test.cc new file mode 100644 index 0000000000..465037cb82 --- /dev/null +++ b/test/endpoints/nearest_test.cc @@ -0,0 +1,85 @@ +#include "motis/endpoints/nearest.h" +#include "gtest/gtest.h" + +#include "motis/data.h" +#include "motis/import.h" + +#include "osr/routing/with_profile.h" + +#include "net/bad_request_exception.h" + +#include "openapi/bad_request_exception.h" + +static std::string query(osr::search_profile const p, + osr::location const loc, + std::size_t const n, + std::size_t const r) { + return fmt::format("/nearest/v1/{}/{},{}?number={}&radiuses={}", + osr::to_str(p), loc.pos_.lng(), loc.pos_.lat(), n, r); +} + +TEST(motis, nearest_endpoint_profiles) { + auto ec = std::error_code{}; + std::filesystem::remove_all("test/data", ec); + auto const c = motis::config{.osm_ = {"test/resources/test_case.osm.pbf"}, + .street_routing_ = true}; + motis::import(c, "test/data"); + auto d = motis::data{"test/data", c}; + auto const ep = motis::ep::nearest{*d.w_, *d.l_, c}; + auto const loc = osr::location{{49.87260, 8.63085}, osr::kNoLevel}; + auto const radius = 100U; + auto const number = 100U; + + for (auto i = 0U; i <= static_cast(osr::search_profile::kFerry); + ++i) { + auto const profile = static_cast(i); + for (auto n = 1U; n <= number; ++n) { + auto const q = query(profile, loc, n, radius); + auto const res = ep(boost::urls::url_view{q}); + auto const expected = osr::with_profile(profile, [&](P&&) { + return d.l_->match

( + std::get(osr::get_parameters(profile)), loc, + false, osr::direction::kForward, radius, nullptr); + }); + if (res.code_ == "NoSegment") { + EXPECT_TRUE(expected.empty()); + continue; + } + auto const size = std::min(static_cast(n), expected.size()); + ASSERT_EQ(res.waypoints_.size(), size); + for (auto j = 0U; j < size; ++j) { + EXPECT_EQ((*res.waypoints_[j].location_)[0], + expected[j].closest_point_on_way_.lng()); + EXPECT_EQ((*res.waypoints_[j].location_)[1], + expected[j].closest_point_on_way_.lat()); + EXPECT_EQ(res.waypoints_[j].distance_, + geo::distance(expected[j].closest_point_on_way_, loc.pos_)); + } + } + } +} + +TEST(motis, nearest_endpoint_invalid) { + auto ec = std::error_code{}; + std::filesystem::remove_all("test/data", ec); + auto const c = motis::config{.osm_ = {"test/resources/test_case.osm.pbf"}, + .street_routing_ = true}; + motis::import(c, "test/data"); + auto d = motis::data{"test/data", c}; + auto const ep = motis::ep::nearest{*d.w_, *d.l_, c}; + + // too few segments + EXPECT_THROW(ep(boost::urls::url_view{"/nearest/v1/foot"}), + openapi::bad_request_exception); + + // invalid coordinates + EXPECT_THROW(ep(boost::urls::url_view{"/nearest/v1/foot/abc,def"}), + net::bad_request_exception); + + // number < 1 + auto const bad_number = + query(osr::search_profile::kFoot, + osr::location{{49.8731904, 8.6221451}, osr::kNoLevel}, 0, 100); + EXPECT_THROW(ep(boost::urls::url_view{bad_number}), + net::bad_request_exception); +} diff --git a/ui/api/openapi/schemas.gen.ts b/ui/api/openapi/schemas.gen.ts index b357dc603e..657c5cecaf 100644 --- a/ui/api/openapi/schemas.gen.ts +++ b/ui/api/openapi/schemas.gen.ts @@ -2528,4 +2528,44 @@ export const HealthResponseSchema = { description: 'GBFS feeds.' } } +} as const; + +export const WaypointSchema = { + type: 'object', + properties: { + name: { + type: 'string', + description: 'name Name of the street the coordinate snapped to' + }, + location: { + type: 'array', + items: { + type: 'number' + }, + description: 'longitude, latitude pair of the snapped coordinate' + }, + distance: { + type: 'number', + description: 'The distance of the snapped point from the original' + } + } +} as const; + +export const NearestResponseSchema = { + type: 'object', + required: ['code', 'waypoints'], + properties: { + code: { + type: 'string' + }, + message: { + type: 'string' + }, + waypoints: { + type: 'array', + items: { + '$ref': '#/components/schemas/Waypoint' + } + } + } } as const; \ No newline at end of file diff --git a/ui/api/openapi/services.gen.ts b/ui/api/openapi/services.gen.ts index 353f6dac04..1fb2126487 100644 --- a/ui/api/openapi/services.gen.ts +++ b/ui/api/openapi/services.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type Options } from '@hey-api/client-fetch'; -import type { PlanData, PlanError, PlanResponse, OneToManyData, OneToManyError, OneToManyResponse, OneToManyPostData, OneToManyPostError, OneToManyPostResponse, OneToManyIntermodalData, OneToManyIntermodalError, OneToManyIntermodalResponse2, OneToManyIntermodalPostData, OneToManyIntermodalPostError, OneToManyIntermodalPostResponse, OneToAllData, OneToAllError, OneToAllResponse, ReverseGeocodeData, ReverseGeocodeError, ReverseGeocodeResponse, GeocodeData, GeocodeError, GeocodeResponse, TripData, TripError, TripResponse, RefreshItineraryData, RefreshItineraryError, RefreshItineraryResponse, RefreshItineraryPostData, RefreshItineraryPostError, RefreshItineraryPostResponse, StoptimesData, StoptimesError, StoptimesResponse, TripsData, TripsError, TripsResponse, InitialError, InitialResponse, StopsData, StopsError, StopsResponse, LevelsData, LevelsError, LevelsResponse, RoutesData, RoutesError, RoutesResponse, RouteDetailsData, RouteDetailsError, RouteDetailsResponse, RentalsData, RentalsError, RentalsResponse, HealthError, HealthResponse2, TransfersData, TransfersError, TransfersResponse } from './types.gen'; +import type { PlanData, PlanError, PlanResponse, OneToManyData, OneToManyError, OneToManyResponse, OneToManyPostData, OneToManyPostError, OneToManyPostResponse, OneToManyIntermodalData, OneToManyIntermodalError, OneToManyIntermodalResponse2, OneToManyIntermodalPostData, OneToManyIntermodalPostError, OneToManyIntermodalPostResponse, OneToAllData, OneToAllError, OneToAllResponse, ReverseGeocodeData, ReverseGeocodeError, ReverseGeocodeResponse, GeocodeData, GeocodeError, GeocodeResponse, TripData, TripError, TripResponse, RefreshItineraryData, RefreshItineraryError, RefreshItineraryResponse, RefreshItineraryPostData, RefreshItineraryPostError, RefreshItineraryPostResponse, StoptimesData, StoptimesError, StoptimesResponse, TripsData, TripsError, TripsResponse, InitialError, InitialResponse, StopsData, StopsError, StopsResponse, LevelsData, LevelsError, LevelsResponse, RoutesData, RoutesError, RoutesResponse, RouteDetailsData, RouteDetailsError, RouteDetailsResponse, RentalsData, RentalsError, RentalsResponse, HealthError, HealthResponse2, NearestData, NearestError, NearestResponse2, TransfersData, TransfersError, TransfersResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -256,6 +256,13 @@ export const health = (options?: Options(options: Options) => { + return (options?.client ?? client).get({ + ...options, + url: '/nearest/v1/{profile}/{coordinates}' + }); +}; + /** * Prints all transfers of a timetable location (track, bus stop, etc.) */ diff --git a/ui/api/openapi/types.gen.ts b/ui/api/openapi/types.gen.ts index da24abbddd..182ee39a3d 100644 --- a/ui/api/openapi/types.gen.ts +++ b/ui/api/openapi/types.gen.ts @@ -1957,6 +1957,27 @@ export type HealthResponse = { gbfs?: boolean; }; +export type Waypoint = { + /** + * name Name of the street the coordinate snapped to + */ + name?: string; + /** + * longitude, latitude pair of the snapped coordinate + */ + location?: Array<(number)>; + /** + * The distance of the snapped point from the original + */ + distance?: number; +}; + +export type NearestResponse = { + code: string; + message?: string; + waypoints: Array; +}; + export type PlanData = { query: { /** @@ -3581,6 +3602,27 @@ export type HealthResponse2 = (HealthResponse); export type HealthError = (HealthResponse); +export type NearestData = { + path: { + coordinates: string; + profile: string; + }; + query?: { + /** + * Number of nearest segments that should be returned. + */ + number?: number; + /** + * Radius constraint + */ + radiuses?: number; + }; +}; + +export type NearestResponse2 = (NearestResponse); + +export type NearestError = unknown; + export type TransfersData = { query: { /**