111 lines
3.6 KiB
Rust
111 lines
3.6 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::response::IntoResponse;
|
|
use axum::response::Json;
|
|
use axum::Extension;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::auth::OptionalUser;
|
|
use crate::data::{slugify, PlaceData};
|
|
use crate::licensing::{check_license_point, resolve_share_code};
|
|
use crate::state::SharedState;
|
|
use crate::utils::normalize_postcode;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct JourneyQuery {
|
|
postcode: String,
|
|
mode: String,
|
|
slug: String,
|
|
share: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct JourneyResponse {
|
|
/// Raw JSON array of journey legs, or null if no journey data available.
|
|
journey: Option<serde_json::Value>,
|
|
/// Median (50th percentile) total travel time in minutes.
|
|
minutes: Option<i16>,
|
|
/// Best-case (5th percentile) total travel time in minutes (transit only).
|
|
best_minutes: Option<i16>,
|
|
/// Destination coordinates from PlaceData, used by clients for unambiguous external links.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
destination_lat: Option<f32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
destination_lon: Option<f32>,
|
|
}
|
|
|
|
fn destination_coordinates(place_data: &PlaceData, slug: &str) -> Option<(f32, f32)> {
|
|
let best_idx = place_data
|
|
.name
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, name)| {
|
|
(place_data.travel_destination[idx] && slugify(name) == slug).then_some(idx)
|
|
})
|
|
.min_by(|a, b| {
|
|
place_data.type_rank[*a]
|
|
.cmp(&place_data.type_rank[*b])
|
|
.then(place_data.population[*b].cmp(&place_data.population[*a]))
|
|
.then(place_data.name[*a].len().cmp(&place_data.name[*b].len()))
|
|
})?;
|
|
|
|
Some((place_data.lat[best_idx], place_data.lon[best_idx]))
|
|
}
|
|
|
|
pub async fn get_journey(
|
|
State(shared): State<Arc<SharedState>>,
|
|
Extension(user): Extension<OptionalUser>,
|
|
query: axum::extract::Query<JourneyQuery>,
|
|
) -> Result<Json<JourneyResponse>, axum::response::Response> {
|
|
let state = shared.load_state();
|
|
let store = &state.travel_time_store;
|
|
let postcode = normalize_postcode(&query.postcode);
|
|
|
|
let pc_idx = state
|
|
.postcode_data
|
|
.postcode_to_idx
|
|
.get(&postcode)
|
|
.copied()
|
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Postcode not found").into_response())?;
|
|
let (lat, lon) = state.postcode_data.centroids[pc_idx];
|
|
|
|
let share_bounds = resolve_share_code(&state, query.share.as_deref()).await;
|
|
check_license_point(&user.0, lat as f64, lon as f64, share_bounds)?;
|
|
|
|
if !store.has_destination(&query.mode, &query.slug) {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("No travel data for mode={} slug={}", query.mode, query.slug),
|
|
)
|
|
.into_response());
|
|
}
|
|
|
|
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to load travel data: {e}"),
|
|
)
|
|
.into_response()
|
|
})?;
|
|
|
|
let row = travel_data.get(&postcode);
|
|
let journey = row
|
|
.and_then(|r| r.journey.as_ref())
|
|
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());
|
|
let minutes = row.map(|r| r.minutes);
|
|
let best_minutes = row.and_then(|r| r.best_minutes);
|
|
let (destination_lat, destination_lon) =
|
|
destination_coordinates(&state.place_data, &query.slug)
|
|
.map(|(lat, lon)| (Some(lat), Some(lon)))
|
|
.unwrap_or((None, None));
|
|
|
|
Ok(Json(JourneyResponse {
|
|
journey,
|
|
minutes,
|
|
best_minutes,
|
|
destination_lat,
|
|
destination_lon,
|
|
}))
|
|
}
|