initial commit

This commit is contained in:
Mark 2025-08-19 23:21:54 +02:00
commit f5bbfe171d
15 changed files with 3610 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1888
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "bahnreise"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.4", features = ["macros", "ws"] }
rand = "0.9.2"
reqwest = { version = "0.12.23", default-features = false, features = ["http2", "rustls-tls", "charset", "system-proxy"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
tokio = { version = "1.47.1", features = ["full"] }

579
index.html Normal file
View File

@ -0,0 +1,579 @@
<!DOCTYPE html>
<html>
<!--
IDEAS:
- Achievements (i.e. "take a train which is delayed by at least 60 minutes" xd)
- Show a map in the end (no need for world map, just use coordinates on flat black rectangle)
-->
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🚉 bahnreise</title>
<style>
* {
margin: 0px;
border-width: 0px;
padding: 0px;
max-width: 100%;
}
body {
background: light-dark(#ECE, #000);
}
#Header {
position: sticky;
top: 0px;
display: flex;
flex-wrap: wrap;
background: light-dark(#FC8888, #421414);
width: 100%;
border-bottom-style: solid;
padding-top: 0.2em;
padding-bottom: 0.2em;
border-bottom-width: 0.4em;
margin-bottom: 0.2em;
border-bottom-color: light-dark(#ECEA, #000A);
}
#HeaderLeft, #HeaderRight {
display: inline-block;
flex: 1;
text-wrap-mode: nowrap;
cursor: default;
user-select: none;
}
#HeaderLeft {
left: 0px;
}
#HeaderRight {
right: 0px;
}
#StationCurrent, #StationDestination {
position: relative;
user-select: none;
}
#Welcome, #InGame {
max-width: min(90%, 50em);
margin: 0 auto;
}
#TransportModeSelection {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
cursor: default;
}
.TransportModeIcon {
font-size: xx-large;
text-wrap-mode: nowrap;
cursor: pointer;
user-select: none;
transition: opacity 0.3s;
}
#StartGameButton {
border-style: solid;
padding: 2px;
border-width: 2px;
border-radius: 4px;
border-color: #0000;
cursor: pointer;
background: light-dark(#FC6C6C, #541818);
opacity: 1;
}
#StartGameButton:hover {
transition: filter 2s ease-out;
filter: hue-rotate(-270deg);
border-color: light-dark(#0004, #FFF3);
}
#StartGameButton:disabled {
filter: none;
border-color: #0000;
cursor: default;
}
#StartGameButton:active {
opacity: 0.5;
}
input[type="text"] {
margin: 2px;
padding: 2px;
border-width: 2px;
border-radius: 4px;
border-style: solid;
border-color: light-dark(#4006, #FCC6);
background: inherit;
}
.StationSearchResult {
margin-top: 4px;
margin-bottom: 4px;
position: relative;
user-select: none;
}
#InGame {
display: none;
position: relative;
}
#FocusedDeparturePanel {
position: fixed;
top: 2.5em;
bottom: 0.5em;
right: max(5%, calc(50% - 25em));
padding-right: 0.5em;
padding-left: 0.5em;
left: 100%;
transition: left 0.3s ease-in-out;
border: 1px solid light-dark(#0004, #FFF2);
background: light-dark(#DBD, #080808);
overflow: scroll;
}
#FocusedDeparturePanelStops {
padding-left: 1em;
min-height: 100%;
}
#FocusedDeparturePanelCloseClickable {
display: none;
position: fixed;
inset: 0px;
}
.DetailedViewPastStop {
opacity: 0.8;
font-size: x-small;
cursor: default;
}
.DetailedViewCurrentStop {
opacity: 0.8;
font-size: small;
text-decoration: underline;
cursor: default;
}
.DetailedViewFutureStop {
opacity: 0.8;
transition: opacity 0.15s;
cursor: pointer;
}
.DetailedViewFutureStop:hover {
opacity: 1;
}
</style>
</head>
<body>
<div id="Header">
<span id="HeaderLeft"><span id="StationCurrent"></span><pre style="display:inline;"> </pre></span>
<span id="HeaderRight"><span id="StationDestination"></span></span>
</div>
<div id="Welcome">
<div id="TransportModeHeader">I like traveling by...</div>
<div id="TransportModeSelection">
<span id="TransportModeBus" class="TransportModeIcon" title="Bus">&thinsp;🚌&thinsp;</span>
<span id="TransportModeTram" class="TransportModeIcon" title="Tram & Metro">&thinsp;🚊&thinsp;</span>
<span id="TransportModeS" class="TransportModeIcon" title="Light Rail / S-Bahn">&thinsp;🚈&thinsp;</span>
<span id="TransportModeRE" class="TransportModeIcon" title="Regional Train (RB/RE)">&thinsp;🚆&thinsp;</span>
<span id="TransportModeICE" class="TransportModeIcon" title="High-Speed Rail (IC/ICE)">&thinsp;🚄&thinsp;</span>
</div>
<br>
<div id="StartGameSection">
<button id="StartGameButton">Begin Journey</button>
</div>
<br>
<div id="StationSearch">
<input id="StationSearchInput" type="text">
<div id="StationSearchResults"></div>
</div>
</div>
<div id="InGame">
<div id="DeparturesList"></div>
<div id="FocusedDeparturePanelCloseClickable"></div>
<div id="FocusedDeparturePanel">
<div id="FocusedDeparturePanelHead"></div>
<ul id="FocusedDeparturePanelStops"></ul>
</div>
</div>
<script>
// === Utilities ===
function runInit(func) {
func();
return func;
}
// === Game State ===
var isInGame = false;
var transportModesInteger = 0b00011;
var transportModeBus = true;
var transportModeTram = true;
var transportModeS = false;
var transportModeRE = false;
var transportModeICE = false;
var stationCurrent = null;
var stationDestination = null;
// === Dragging ===
var draggingElement = null;
var draggingStartPos = null;
var draggingEndCallback = null;
var draggableElements = new Array();
function draggingEnable(elem, onUp) {
// NOTE: Assumes the element has `position: relative;`
draggableElements.push(elem);
elem.style.cursor = "grab";
elem.addEventListener("mousedown", (e) => {
if (isInGame) return;
if (e.button != 0) return;
draggingEndCallback = onUp;
draggingOnDown(e.clientX, e.clientY, elem);
});
elem.addEventListener("touchstart", (e) => {
if (isInGame) return;
if (e.touches.length == 1) {
draggingEndCallback = onUp;
draggingOnDown(e.touches[0].clientX, e.touches[0].clientY, elem);
} else {
draggingCancel();
}
});
}
document.addEventListener("mousemove", (e) => {
draggingOnMove(e.clientX, e.clientY);
});
document.addEventListener("mouseup", (e) => {
draggingOnUp(e.clientX, e.clientY);
});
document.addEventListener("touchmove", (e) => {
if (e.touches.length == 1) {
draggingOnMove(e.touches[0].clientX, e.touches[0].clientY);
} else {
draggingCancel();
}
});
document.addEventListener("touchend", (e) => {
if (e.changedTouches.length == 1) {
draggingOnUp(e.changedTouches[0].clientX, e.changedTouches[0].clientY);
}
});
document.addEventListener("touchcancel", (e) => {
draggingCancel();
});
function draggingOnDown(x, y, e) {
if (isInGame) return;
if (draggingElement !== null) return;
draggingElement = e;
draggingStartPos = [x, y];
}
function draggingOnMove(x, y) {
if (isInGame) return;
if (draggingElement === null) return;
let dx = x - draggingStartPos[0];
let dy = y - draggingStartPos[1];
draggingElement.style.top = dy + "px";
draggingElement.style.left = dx + "px";
}
function draggingOnUp(x, y) {
if (draggingElement === null) return;
if (!isInGame) {
if (draggingEndCallback) draggingEndCallback(x, y);
}
draggingCancel();
}
function draggingCancel() {
draggingElement.style.removeProperty("top");
draggingElement.style.removeProperty("left");
draggingElement = null;
}
// === Header ===
const domHeaderLeft = document.getElementById("HeaderLeft");
const domHeaderRight = document.getElementById("HeaderRight");
const domStationCurrent = document.getElementById("StationCurrent");
const domStationDestination = document.getElementById("StationDestination");
function setHeaderTexts() {
domStationCurrent.innerText = stationCurrent ? stationCurrent.name : "";
domStationDestination.innerText = stationDestination ? stationDestination.name : "";
}
setHeaderTexts();
var chooseRandomStationsTimeoutId = null;
async function chooseRandomStations() {
if (chooseRandomStationsTimeoutId) clearTimeout(chooseRandomStationsTimeoutId);
chooseRandomStationsTimeoutId = setTimeout(async () => {
let response = await fetch("./random_stations/" + encodeURIComponent(transportModesInteger) + "/2/");
if (!response.ok) return;
let randomStations = await response.json();
if (randomStations.length >= 2) {
stationCurrent = { name: randomStations[0][0], evaNumber: randomStations[0][1] };
stationDestination = { name: randomStations[1][0], evaNumber: randomStations[1][1] };
setHeaderTexts();
}
chooseRandomStationsTimeoutId = null;
}, 100);
}
draggingEnable(domStationCurrent, (x, y) => {
let target = domHeaderRight.getBoundingClientRect();
if (target.left <= x && x <= target.right && target.top <= y && y <= target.bottom) {
let temp = stationDestination;
stationDestination = stationCurrent;
stationCurrent = temp;
setHeaderTexts();
}
});
draggingEnable(domStationDestination, (x, y) => {
let target = domHeaderLeft.getBoundingClientRect();
if (target.left <= x && x <= target.right && target.top <= y && y <= target.bottom) {
let temp = stationDestination;
stationDestination = stationCurrent;
stationCurrent = temp;
setHeaderTexts();
}
});
// === Transport Mode Selection ===
const domTransportModeBus = document.getElementById("TransportModeBus");
const domTransportModeTram = document.getElementById("TransportModeTram");
const domTransportModeS = document.getElementById("TransportModeS");
const domTransportModeRE = document.getElementById("TransportModeRE");
const domTransportModeICE = document.getElementById("TransportModeICE");
const TRANSPORT_MODE_ICON_BUS = "🚌";
const TRANSPORT_MODE_ICON_TRAM = "🚊";
const TRANSPORT_MODE_ICON_S = "🚈";
const TRANSPORT_MODE_ICON_RE = "🚆";
const TRANSPORT_MODE_ICON_ICE = "🚄";
const TRANSPORT_MODE_TEXT_BUS = domTransportModeBus.title;
const TRANSPORT_MODE_TEXT_TRAM = domTransportModeTram.title;
const TRANSPORT_MODE_TEXT_S = domTransportModeS.title;
const TRANSPORT_MODE_TEXT_RE = domTransportModeRE.title;
const TRANSPORT_MODE_TEXT_ICE = domTransportModeICE.title;
domTransportModeBus.addEventListener("click", runInit((e) => {
if (e) e.preventDefault(); transportModeBus ^= true; transportModesInteger ^= 1;
domTransportModeBus.style.opacity = transportModeBus ? 1 : 0.5;
if (!transportModeBus || transportModesInteger == 1) chooseRandomStations();
}));
domTransportModeTram.addEventListener("click", runInit((e) => {
if (e) e.preventDefault(); transportModeTram ^= true; transportModesInteger ^= 2;
domTransportModeTram.style.opacity = transportModeTram ? 1 : 0.5;
if (!transportModeTram || transportModesInteger == 2) chooseRandomStations();
}));
domTransportModeS.addEventListener("click", runInit((e) => {
if (e) e.preventDefault(); transportModeS ^= true; transportModesInteger ^= 4;
domTransportModeS.style.opacity = transportModeS ? 1 : 0.5;
if (!transportModeS || transportModesInteger == 4) chooseRandomStations();
}));
domTransportModeRE.addEventListener("click", runInit((e) => {
if (e) e.preventDefault(); transportModeRE ^= true; transportModesInteger ^= 8;
domTransportModeRE.style.opacity = transportModeRE ? 1 : 0.5;
if (!transportModeRE || transportModesInteger == 8) chooseRandomStations();
}));
domTransportModeICE.addEventListener("click", runInit((e) => {
if (e) e.preventDefault(); transportModeICE ^= true; transportModesInteger ^= 16;
domTransportModeICE.style.opacity = transportModeICE ? 1 : 0.5;
if (!transportModeICE || transportModesInteger == 16) chooseRandomStations();
}));
// === Station Search ===
const domStationSearchInput = document.getElementById("StationSearchInput");
const domStationSearchResults = document.getElementById("StationSearchResults");
const stationSearchTimeoutDuration = 500;
var stationSearchTimeoutId = null;
var stationSearchPrevious = null;
const stationSearchInputChanged = () => {
let query = domStationSearchInput.value;
if (stationSearchPrevious === query) return;
stationSearchPrevious = query;
if (stationSearchTimeoutId !== null) clearTimeout(stationSearchTimeoutId);
stationSearchTimeoutId = (query && query.length > 1) ? setTimeout(async () => {
let response = await fetch("./query_stations/" + encodeURIComponent(query) + "/");
if (!response.ok) {
let error = await response.text();
console.warn("Station Search: Got error " + response.status + " from server: " + error);
} else {
let result = await response.json();
domStationSearchResults.replaceChildren();
for (const [stationName, stationEvaNum, transportModesInteger] of result) {
let searchResultElem = document.createElement("div");
let searchResultTransportModesElem = document.createElement("small");
searchResultTransportModesElem.style.cursor = "default";
for (const [i, char, text] of [[1, TRANSPORT_MODE_ICON_BUS, TRANSPORT_MODE_TEXT_BUS], [2, TRANSPORT_MODE_ICON_TRAM, TRANSPORT_MODE_TEXT_TRAM], [4, TRANSPORT_MODE_ICON_S, TRANSPORT_MODE_TEXT_S], [8, TRANSPORT_MODE_ICON_RE, TRANSPORT_MODE_TEXT_RE], [16, TRANSPORT_MODE_ICON_ICE, TRANSPORT_MODE_TEXT_ICE]]) {
if ((transportModesInteger & i) != 0) {
let transportModeElem = document.createElement("span");
transportModeElem.innerText = char;
transportModeElem.title = text;
searchResultTransportModesElem.append(transportModeElem);
}
}
let searchResultNameElem = document.createElement("span");
searchResultNameElem.innerText = stationName;
searchResultElem.append(searchResultTransportModesElem, searchResultNameElem);
searchResultElem.classList.add("StationSearchResult");
draggingEnable(searchResultElem, (x, y) => {
let right = domHeaderRight.getBoundingClientRect();
let left = domHeaderLeft.getBoundingClientRect();
if (left.left <= x && x <= left.right && left.top <= y && y <= left.bottom) {
stationCurrent = { name: stationName, evaNumber: stationEvaNum };
setHeaderTexts();
}
if (right.left <= x && x <= right.right && right.top <= y && y <= right.bottom) {
stationDestination = { name: stationName, evaNumber: stationEvaNum };
setHeaderTexts();
}
});
domStationSearchResults.appendChild(searchResultElem);
}
}
}, stationSearchTimeoutDuration) : (() => { domStationSearchResults.replaceChildren(); return null; })();
;
};
domStationSearchInput.addEventListener("change", stationSearchInputChanged);
domStationSearchInput.addEventListener("input", stationSearchInputChanged);
// === Loading (Network) ===
chooseRandomStations();
// === InGame ===
const domStartGameButton = document.getElementById("StartGameButton");
const domWelcome = document.getElementById("Welcome");
const domInGame = document.getElementById("InGame");
const domDeparturesList = document.getElementById("DeparturesList");
const domFocusedDeparturePanel = document.getElementById("FocusedDeparturePanel");
const domFocusedDeparturePanelHead = document.getElementById("FocusedDeparturePanelHead");
const domFocusedDeparturePanelStops = document.getElementById("FocusedDeparturePanelStops");
const domFocusedDeparturePanelCloseClickable = document.getElementById("FocusedDeparturePanelCloseClickable");
function hideFocusDeparturePanel() {
domFocusedDeparturePanel.style.left = "100%";
domFocusedDeparturePanelCloseClickable.style.display = "none";
}
domFocusedDeparturePanelCloseClickable.addEventListener("click", hideFocusDeparturePanel);
function goInGame() {
isInGame = true;
for (const elem of draggableElements) elem.style.cursor = "default";
draggableElements = new Array();
domWelcome.style.display = "none";
domInGame.style.display = "block";
reloadDepartures();
}
domStartGameButton.onclick = () => goInGame();
async function reloadDepartures() {
domDeparturesList.replaceChildren();
hideFocusDeparturePanel();
let currentEvaNumber = stationCurrent.evaNumber;
if (currentEvaNumber === stationDestination.evaNumber) {
// TODO: obv
alert("You won! Unfortunately, i haven't made a screen for that yet...");
location.reload();
return;
}
let response = await fetch("./query_departures/" + encodeURIComponent(transportModesInteger) + "/" + encodeURIComponent(currentEvaNumber) + "/");
if (!response.ok) {
let error = await response.text();
console.warn("Query Departures: Got error " + response.status + " from server: " + error);
} else {
let result = await response.json();
for (const [route, stops, routeId] of result) {
if (stops.length > 0) {
let departureElem = document.createElement("div");
let departureElemHead = document.createElement("div");
departureElemHead.style.marginTop = "0.5em";
departureElemHead.innerText = route + " ➟ " + stops[stops.length-1];
let departureElemDetails = document.createElement("small");
departureElemDetails.style.display = "flex";
departureElemDetails.style.flexWrap = "wrap";
let firstStop = true;
for (const stop of stops.slice(0, stops.length-1)) {
if (firstStop && stop == stationCurrent.name) continue;
let departureStopElem = document.createElement("span");
departureStopElem.style.whiteSpace = "pre";
departureStopElem.innerText = (firstStop ? "" : " ➞ ") + stop;
departureElemDetails.append(departureStopElem);
firstStop = false;
}
let departureElemSelect = document.createElement("div");
departureElemSelect.style.overflow = "hidden";
departureElemSelect.style.height = "0vh";
departureElemSelect.style.transition = "height 0.25s ease-in-out";
departureElem.append(departureElemHead, departureElemDetails, departureElemSelect);
departureElem.style.cursor = "pointer";
let resultCache = [null];
departureElem.addEventListener("click", async () => {
domFocusedDeparturePanelHead.replaceChildren();
domFocusedDeparturePanelStops.replaceChildren();
domFocusedDeparturePanelCloseClickable.style.display = "block";
domFocusedDeparturePanel.style.left = "max(calc(5% + 5em), calc(50% - 22.5em))";
let headElem = document.createElement("b");
headElem.innerText = route;
let headSmallerElem = document.createElement("span");
headSmallerElem.style.whiteSpace = "pre";
headSmallerElem.innerText = " ➟ " + stops[stops.length-1];
domFocusedDeparturePanelHead.append(headElem, headSmallerElem);
if (resultCache[0] === null) {
let response = await fetch("./query_route/" + encodeURIComponent(routeId) + "/");
if (!response.ok) {
let error = await response.text();
console.warn("Query Route: Got error " + response.status + " from server: " + error);
return;
} else {
resultCache[0] = await response.json();
}
}
let result = resultCache[0];
let canceled = result[1];
let foundCurrent = true;
for (const [stop, evaNumber] of result[0]) {
if (evaNumber === currentEvaNumber) foundCurrent = false;
}
for (const [stop, evaNumber] of result[0]) {
console.log(stop, ": ", evaNumber);
let stopElem = document.createElement("li");
stopElem.innerText = stop;
stopElem.style.wordWrap = "nowrap";
if (!foundCurrent) {
if (evaNumber === currentEvaNumber) {
foundCurrent = true;
stopElem.classList.add("DetailedViewCurrentStop");
} else {
stopElem.classList.add("DetailedViewPastStop");
}
} else {
stopElem.classList.add("DetailedViewFutureStop");
stopElem.addEventListener("click", async () => {
stationCurrent = { name: stop, evaNumber: evaNumber };
setHeaderTexts();
await reloadDepartures();
});
}
domFocusedDeparturePanelStops.append(stopElem);
}
});
domDeparturesList.append(departureElem);
}
}
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,87 @@
pub mod station_ids {
use std::fmt::Display;
#[derive(Debug)]
pub struct NamedStation {
pub name: String,
pub id: StationId,
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct StationEvaNumber(pub String);
#[derive(Debug, PartialEq, Eq)]
pub struct DbStopId(pub String);
impl StationEvaNumber {
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl AsRef<str> for StationEvaNumber {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl DbStopId {
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl AsRef<str> for DbStopId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl Display for StationEvaNumber {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl Display for DbStopId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug)]
pub struct StationId {
pub eva_number: StationEvaNumber,
pub db_station_id: DbStopId,
}
#[derive(Debug)]
pub struct StationIdRef<'a> {
pub eva_number: &'a StationEvaNumber,
pub db_station_id: &'a DbStopId,
}
pub trait AsStationId<'a>: Into<StationIdRef<'a>> {}
impl<'a, T: Into<StationIdRef<'a>>> AsStationId<'a> for T {}
impl<'a> From<&'a StationId> for StationIdRef<'a> {
fn from(value: &'a StationId) -> Self {
StationIdRef {
eva_number: &value.eva_number,
db_station_id: &value.db_station_id,
}
}
}
impl<'a> From<&'a NamedStation> for StationIdRef<'a> {
fn from(value: &'a NamedStation) -> Self {
From::from(&value.id)
}
}
}
pub mod route_ids {
#[derive(Debug, PartialEq, Eq)]
pub struct RouteId(pub String);
}

View File

@ -0,0 +1,285 @@
use deserialize::{DepartureOrArrivalData, Direction, GetDepartureOrArrivalError};
use reqwest::Url;
use crate::bahn_api::transport_modes::TransportMode;
use super::{
basic_types::{
route_ids::RouteId,
station_ids::{AsStationId, StationIdRef},
},
transport_modes::TransportModesSet,
};
pub async fn departures(
station: impl AsStationId<'_>,
transport_modes: TransportModesSet,
) -> Result<Departures, GetDeparturesError> {
departures_or_arrivals(station, transport_modes).await
}
pub async fn arrivals(
station: impl AsStationId<'_>,
transport_modes: TransportModesSet,
) -> Result<Arrivals, GetArrivalsError> {
departures_or_arrivals(station, transport_modes).await
}
pub async fn departures_or_arrivals<T: DepartureOrArrivalData>(
station: impl AsStationId<'_>,
transport_modes: TransportModesSet,
) -> Result<T, T::Err> {
let StationIdRef {
eva_number,
db_station_id,
} = station.into();
let response = reqwest::get(
Url::parse_with_params(
match T::DIRECTION {
Direction::Departure => "https://www.bahn.de/web/api/reiseloesung/abfahrten",
Direction::Arrival => "https://www.bahn.de/web/api/reiseloesung/ankuenfte",
},
[
("ortExtId", eva_number.as_str()),
("ortId", db_station_id.as_str()),
("mitVias", "true"),
("maxVias", "8"),
]
.into_iter()
.chain(
transport_modes
.into_iter()
.map(|v| ("verkehrsmittel[]", v.as_str_for_bahn_api())),
),
)
.expect("hardcoded url and generated parameters should always be valid"),
)
.await
.map_err(T::Err::network_error)?
.error_for_status()
.map_err(T::Err::network_error)?
.text()
.await
.map_err(T::Err::network_error)?;
let data: T::De =
serde_json::from_str(&response).map_err(move |e| T::Err::parse_error(response, e))?;
T::convert(data)
}
#[derive(Debug)]
pub struct Departures {
pub departures: Vec<Departure>,
}
#[derive(Debug)]
pub struct Arrivals {
pub arrivals: Vec<Arrival>,
}
#[derive(Debug)]
pub struct Departure {
pub id: RouteId,
pub route: String,
pub category: Option<TransportMode>,
pub stops: Vec<NamedStop>,
}
#[derive(Debug)]
pub struct Arrival {
pub id: RouteId,
pub route: String,
pub category: Option<TransportMode>,
pub stops: Vec<NamedStop>,
}
#[derive(Debug)]
pub struct NamedStop {
pub name: String,
}
#[derive(Debug)]
pub enum GetDeparturesError {
NetworkError(reqwest::Error),
ParseError(String, serde_json::Error),
}
#[derive(Debug)]
pub enum GetArrivalsError {
NetworkError(reqwest::Error),
ParseError(String, serde_json::Error),
}
impl GetDepartureOrArrivalError for GetDeparturesError {
fn network_error(err: reqwest::Error) -> Self {
Self::NetworkError(err)
}
fn parse_error(str: String, err: serde_json::Error) -> Self {
Self::ParseError(str, err)
}
}
impl GetDepartureOrArrivalError for GetArrivalsError {
fn network_error(err: reqwest::Error) -> Self {
Self::NetworkError(err)
}
fn parse_error(str: String, err: serde_json::Error) -> Self {
Self::ParseError(str, err)
}
}
pub mod deserialize {
use serde::Deserialize;
use crate::bahn_api::{basic_types::route_ids::RouteId, transport_modes::TransportMode};
use super::{
Arrival, Arrivals, Departure, Departures, GetArrivalsError, GetDeparturesError, NamedStop,
};
pub enum Direction {
Departure,
Arrival,
}
pub trait DepartureOrArrivalData: Sized {
const DIRECTION: Direction;
type De: for<'de> Deserialize<'de>;
type Err: GetDepartureOrArrivalError;
fn convert(data: Self::De) -> Result<Self, Self::Err>;
}
pub trait GetDepartureOrArrivalError {
fn network_error(err: reqwest::Error) -> Self;
fn parse_error(str: String, err: serde_json::Error) -> Self;
}
impl DepartureOrArrivalData for Departures {
const DIRECTION: Direction = Direction::Departure;
type De = DepData;
type Err = GetDeparturesError;
fn convert(mut data: Self::De) -> Result<Self, Self::Err> {
for departure in &mut data.departures {
// add terminus to the stops list if it isn't the last stop already
if let Some(terminus) = departure.terminus.take() {
if departure.stops.last().is_none_or(|stop| *stop != terminus) {
departure.stops.push(terminus);
}
}
}
Ok(Self {
departures: data
.departures
.into_iter()
.map(|dep| Departure {
id: RouteId(dep.journey_id),
route: dep.vehicle.route,
category: dep
.vehicle
.category
.and_then(|cat| TransportMode::from_str_from_bahn_api(cat.trim())),
stops: dep
.stops
.into_iter()
.map(|name| NamedStop { name })
.collect(),
})
.collect(),
})
}
}
impl DepartureOrArrivalData for Arrivals {
const DIRECTION: Direction = Direction::Arrival;
type De = ArrData;
type Err = GetArrivalsError;
fn convert(mut data: Self::De) -> Result<Self, Self::Err> {
for departure in &mut data.arrivals {
// add terminus to the stops list if it isn't the first or last stop already
// NOTE: the bahn.de api currently sets `terminus` to the *first* station when arrivals are requested
if let Some(terminus) = departure.terminus.take() {
if [departure.stops.first(), departure.stops.last()]
.into_iter()
.flatten()
.all(|stop| *stop != terminus)
{
departure.stops.insert(0, terminus);
}
}
}
Ok(Self {
arrivals: data
.arrivals
.into_iter()
.map(|arr| Arrival {
id: RouteId(arr.journey_id),
route: arr.vehicle.route,
category: arr
.vehicle
.category
.and_then(|cat| TransportMode::from_str_from_bahn_api(cat.trim())),
stops: arr
.stops
.into_iter()
.map(|name| NamedStop { name })
.collect(),
})
.collect(),
})
}
}
#[derive(Deserialize)]
pub struct DepData {
#[serde(rename = "entries")]
departures: Vec<DepDeparture>,
}
#[derive(Deserialize)]
pub struct ArrData {
#[serde(rename = "entries")]
arrivals: Vec<ArrArrival>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DepDeparture {
#[serde(rename = "ueber")]
stops: Vec<String>,
journey_id: String,
#[serde(default)]
terminus: Option<String>,
#[serde(rename = "verkehrmittel")]
vehicle: DepVehicle,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ArrArrival {
#[serde(rename = "ueber")]
stops: Vec<String>,
journey_id: String,
#[serde(default)]
terminus: Option<String>,
#[serde(rename = "verkehrmittel")]
vehicle: ArrVehicle,
}
#[derive(Deserialize)]
struct DepVehicle {
#[serde(rename = "mittelText")]
route: String,
#[serde(default, rename = "produktGattung")]
category: Option<String>,
}
#[derive(Deserialize)]
struct ArrVehicle {
#[serde(rename = "mittelText")]
route: String,
#[serde(default, rename = "produktGattung")]
category: Option<String>,
}
}

5
src/bahn_api/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod basic_types;
pub mod departures_arrivals;
pub mod route_info;
pub mod station_search;
pub mod transport_modes;

View File

@ -0,0 +1,69 @@
use reqwest::Url;
use serde::Deserialize;
use super::basic_types::{
route_ids::RouteId,
station_ids::{DbStopId, NamedStation, StationEvaNumber, StationId},
};
pub async fn route_info(id: RouteId) -> Result<RouteInfo, GetRouteInfoError> {
let response = reqwest::get(
Url::parse_with_params(
"https://www.bahn.de/web/api/reiseloesung/fahrt",
[("journeyId", id.0.as_str())],
)
.expect("hardcoded url and bahn.de-provided route id should always be valid"),
)
.await
.map_err(GetRouteInfoError::NetworkError)?
.error_for_status()
.map_err(GetRouteInfoError::NetworkError)?
.text()
.await
.map_err(GetRouteInfoError::NetworkError)?;
let data: RIData = serde_json::from_str(&response)
.map_err(move |e| GetRouteInfoError::ParseError(response, e))?;
#[derive(Deserialize)]
struct RIData {
#[serde(rename = "halte")]
stops: Vec<RIStop>,
#[serde(rename = "cancelled")]
canceled: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RIStop {
id: String,
name: String,
ext_id: String,
}
Ok(RouteInfo {
stops: data
.stops
.into_iter()
.map(|stop| NamedStation {
name: stop.name,
id: StationId {
eva_number: StationEvaNumber(stop.ext_id),
db_station_id: DbStopId(stop.id),
},
})
.collect(),
canceled: data.canceled,
})
}
#[derive(Debug)]
pub struct RouteInfo {
pub stops: Vec<NamedStation>,
pub canceled: bool,
}
#[derive(Debug)]
pub enum GetRouteInfoError {
NetworkError(reqwest::Error),
ParseError(String, serde_json::Error),
}

View File

@ -0,0 +1,96 @@
use reqwest::Url;
use serde::Deserialize;
use crate::bahn_api::{basic_types::station_ids::StationId, transport_modes::TransportMode};
use super::{
basic_types::station_ids::{DbStopId, NamedStation, StationEvaNumber, StationIdRef},
transport_modes::TransportModesSet,
};
pub async fn query_stations(query: &str) -> Result<QueriedStations, QueryStationsError> {
let response = reqwest::get(
Url::parse_with_params(
"https://www.bahn.de/web/api/reiseloesung/orte",
[("suchbegriff", query), ("typ", "ALL"), ("limit", "10")],
)
.expect("hardcoded url and bahn.de-provided route id should always be valid"),
)
.await
.map_err(QueryStationsError::NetworkError)?
.error_for_status()
.map_err(QueryStationsError::NetworkError)?
.text()
.await
.map_err(QueryStationsError::NetworkError)?;
let data: QSData = serde_json::from_str(&response)
.map_err(move |e| QueryStationsError::ParseError(response, e))?;
type QSData = Vec<QSStation>;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct QSStation {
#[serde(default)]
ext_id: Option<String>,
#[serde(default)]
id: Option<String>,
name: String,
#[serde(default)]
lat: Option<f64>,
#[serde(default)]
lon: Option<f64>,
#[serde(default)]
products: Vec<String>,
}
Ok(QueriedStations {
stations: data
.into_iter()
.filter_map(|station| {
Some(QueriedStation {
station: NamedStation {
name: station.name,
id: StationId {
eva_number: StationEvaNumber(station.ext_id?),
db_station_id: DbStopId(station.id?),
},
},
lat_lon: station
.lat
.and_then(|lat| station.lon.map(|lon| (lat, lon))),
transport_modes: station
.products
.iter()
.filter_map(|v| TransportMode::from_str_from_bahn_api(v))
.fold(TransportModesSet::new(), |set, v| set.with(v)),
})
})
.collect(),
})
}
#[derive(Debug)]
pub struct QueriedStations {
pub stations: Vec<QueriedStation>,
}
#[derive(Debug)]
pub struct QueriedStation {
pub station: NamedStation,
pub lat_lon: Option<(f64, f64)>,
pub transport_modes: TransportModesSet,
}
#[derive(Debug)]
pub enum QueryStationsError {
NetworkError(reqwest::Error),
ParseError(String, serde_json::Error),
}
impl<'a> From<&'a QueriedStation> for StationIdRef<'a> {
fn from(value: &'a QueriedStation) -> Self {
From::from(&value.station)
}
}

View File

@ -0,0 +1,184 @@
use std::fmt::Debug;
pub struct TransportModesSet(u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransportMode {
Bus,
Tram,
Ubahn,
Sbahn,
Regional,
Ir,
EcIc,
Ice,
}
impl TransportMode {
pub fn as_str_for_bahn_api(self) -> &'static str {
match self {
Self::Bus => "BUS",
Self::Tram => "TRAM",
Self::Ubahn => "UBAHN",
Self::Sbahn => "SBAHN",
Self::Regional => "REGIONAL",
Self::Ir => "IR",
Self::EcIc => "EC_IC",
Self::Ice => "ICE",
}
}
pub fn from_str_from_bahn_api(str: &str) -> Option<Self> {
Some(match str {
"BUS" => Self::Bus,
"TRAM" => Self::Tram,
"UBAHN" => Self::Ubahn,
"SBAHN" => Self::Sbahn,
"REGIONAL" => Self::Regional,
"IR" => Self::Ir,
"EC_IC" => Self::EcIc,
"ICE" => Self::Ice,
_ => return None,
})
}
}
impl TransportModesSet {
pub fn new() -> Self {
Self(0)
}
pub fn len(&self) -> usize {
self.0.count_ones() as usize
}
pub fn contains(&self, mode: TransportMode) -> bool {
(self.0 & mode.numerical_representation_flag()) != 0
}
pub fn contains_any(&self, modes: &Self) -> bool {
(self.0 & modes.0) != 0
}
pub fn contains_all(&self, modes: &Self) -> bool {
(self.0 & modes.0) == modes.0
}
pub fn insert(&mut self, mode: TransportMode) -> &mut Self {
self.0 |= mode.numerical_representation_flag();
self
}
pub fn remove(&mut self, mode: TransportMode) -> &mut Self {
self.insert(mode);
self.0 ^= mode.numerical_representation_flag();
self
}
pub fn with(mut self, mode: TransportMode) -> Self {
self.insert(mode);
self
}
pub fn without(mut self, mode: TransportMode) -> Self {
self.remove(mode);
self
}
/// The transport modes which are in `self` and `other`.
pub fn intersection(&self, other: &Self) -> Self {
Self(self.0 & other.0)
}
/// The transport modes which are in `self` or `other`.
pub fn union(&self, other: &Self) -> Self {
Self(self.0 | other.0)
}
}
impl IntoIterator for TransportModesSet {
type Item = TransportMode;
type IntoIter = TransportModesSetIter;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl TransportModesSet {
pub fn iter(&self) -> TransportModesSetIter {
TransportModesSetIter(self.0)
}
}
pub struct TransportModesSetIter(u32);
impl Iterator for TransportModesSetIter {
type Item = TransportMode;
fn next(&mut self) -> Option<Self::Item> {
if self.0 == 0 {
None
} else {
let least_significant_bit_position = self.0.trailing_zeros();
// remove this bit from the iterator
let only_least_significant_bit = 1 << least_significant_bit_position;
self.0 ^= only_least_significant_bit;
// return the transport mode. this (probably) never returns `None`.
TransportMode::from_numerical_representation(least_significant_bit_position)
}
}
}
impl TransportMode {
pub const VARIANTS: [Self; 8] = [
Self::Bus,
Self::Tram,
Self::Ubahn,
Self::Sbahn,
Self::Regional,
Self::Ir,
Self::EcIc,
Self::Ice,
];
fn numerical_representation(self) -> u32 {
const A: TransportMode = TransportMode::VARIANTS[0];
const B: TransportMode = TransportMode::VARIANTS[1];
const C: TransportMode = TransportMode::VARIANTS[2];
const D: TransportMode = TransportMode::VARIANTS[3];
const E: TransportMode = TransportMode::VARIANTS[4];
const F: TransportMode = TransportMode::VARIANTS[5];
const G: TransportMode = TransportMode::VARIANTS[6];
const H: TransportMode = TransportMode::VARIANTS[7];
match self {
A => 0,
B => 1,
C => 2,
D => 3,
E => 4,
F => 5,
G => 6,
H => 7,
}
}
fn numerical_representation_flag(self) -> u32 {
1 << self.numerical_representation()
}
fn from_numerical_representation(n: u32) -> Option<Self> {
const A: TransportMode = TransportMode::VARIANTS[0];
const B: TransportMode = TransportMode::VARIANTS[1];
const C: TransportMode = TransportMode::VARIANTS[2];
const D: TransportMode = TransportMode::VARIANTS[3];
const E: TransportMode = TransportMode::VARIANTS[4];
const F: TransportMode = TransportMode::VARIANTS[5];
const G: TransportMode = TransportMode::VARIANTS[6];
const H: TransportMode = TransportMode::VARIANTS[7];
Some(match n {
0 => A,
1 => B,
2 => C,
3 => D,
4 => E,
5 => F,
6 => G,
7 => H,
_ => return None,
})
}
}
impl Debug for TransportModesSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[derive(Debug)]
// derived `Debug` impl is ignored, so we allow dead code because
// we want to use the derived `Debug` impl and nothing else
#[allow(dead_code)]
struct TransportModesSet(pub Vec<TransportMode>);
Debug::fmt(&TransportModesSet(self.iter().collect()), f)
}
}

34
src/main.rs Normal file
View File

@ -0,0 +1,34 @@
pub mod bahn_api;
pub mod server;
pub mod storage;
#[tokio::main(flavor = "current_thread")]
async fn main() {
server::main().await
}
// async fn other_main() {
// let station = dbg!(
// query_stations("stuttgart")
// .await
// .unwrap()
// .stations
// .swap_remove(0)
// );
// dbg!(
// departures(
// &station,
// TransportModesSet::new()
// .with(TransportMode::Bus)
// .with(TransportMode::Tram)
// .with(TransportMode::Ubahn)
// .with(TransportMode::Sbahn)
// .with(TransportMode::Regional)
// .with(TransportMode::Ir)
// .with(TransportMode::EcIc)
// .with(TransportMode::Ice),
// )
// .await
// .unwrap()
// );
// }

256
src/server/mod.rs Normal file
View File

@ -0,0 +1,256 @@
pub mod ratelimit;
use std::sync::Arc;
use axum::{Json, extract::Path, response::Html, routing::get};
use rand::seq::IndexedRandom;
use ratelimit::Ratelimit;
use reqwest::StatusCode;
use tokio::sync::Mutex;
use crate::{
bahn_api::{
basic_types::{route_ids::RouteId, station_ids::StationEvaNumber},
departures_arrivals::departures,
route_info::route_info,
station_search::query_stations,
transport_modes::{TransportMode, TransportModesSet},
},
storage::stations::{Station, Stations},
};
pub async fn main() {
let index = Arc::new(
tokio::fs::read_to_string("index.html")
.await
.expect("failed to load index.html (must be present in cwd)"),
);
let ratelimit = Ratelimit::new(10, 10);
let stations = Arc::new(Mutex::new(Stations::new()));
axum::serve(
tokio::net::TcpListener::bind(
std::env::var("ADDR").unwrap_or_else(|_| "[::1]:8000".to_owned()),
)
.await
.unwrap(),
axum::Router::new()
.route("/", get(|| async move { Html(index.as_ref().clone()) }))
.route(
"/query_stations/{query}/",
get({
let ratelimit = ratelimit.clone();
let stations = Arc::clone(&stations);
|Path(query): Path<String>| async move {
if query.trim().is_empty() {
return Err(StatusCode::NOT_FOUND);
}
ratelimit.wait().await?;
match query_stations(&query).await {
Err(e) => {
eprintln!(
"Tried to query station named {query}, but got error: {e:?}"
);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
Ok(queried_stations) => {
let json = queried_stations
.stations
.iter()
.map(|station| {
(
station.station.name.clone(),
station.station.id.eva_number.0.clone(),
transport_modes_num_from_set(&station.transport_modes),
)
})
.collect::<Vec<_>>();
let mut lock = stations.lock().await;
for station in queried_stations.stations.into_iter() {
lock.insert(
station.station.id.eva_number,
Station {
name: station.station.name,
db_station_id: station.station.id.db_station_id,
lat_lon: station.lat_lon,
transport_modes: Some(station.transport_modes),
},
);
}
drop(lock);
Ok(Json(json))
}
}
}
}),
)
.route(
"/query_departures/{transport_modes}/{eva_number}/",
get({
let ratelimit = ratelimit.clone();
let stations = Arc::clone(&stations);
|Path((transport_modes_num, eva_number)): Path<(u8, String)>| async move {
ratelimit.wait().await?;
let transport_modes = transport_modes_num_to_set(transport_modes_num);
if transport_modes.len() == 0 {
return Err(StatusCode::NOT_FOUND);
}
let eva_number = StationEvaNumber(eva_number);
if let Some(station) = stations.lock().await.get(&eva_number) {
match departures((&eva_number, station), transport_modes).await {
Err(e) => {
eprintln!(
"Tried to get departures at {}, but got error: {e:?}",
station.name,
);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
Ok(departures) => Ok(Json(
departures
.departures
.into_iter()
.map(|departure| {
(
departure.route,
departure
.stops
.into_iter()
.map(|stop| stop.name)
.collect::<Vec<_>>(),
departure.id.0,
)
})
.collect::<Vec<_>>(),
)),
}
} else {
Err(StatusCode::GONE)
}
}
}),
)
.route(
"/query_route/{route_id}/",
get({
let ratelimit = ratelimit.clone();
let stations = Arc::clone(&stations);
|Path(route_id): Path<String>| async move {
ratelimit.wait().await?;
if route_id.trim().is_empty() {
return Err(StatusCode::NOT_FOUND);
}
match route_info(RouteId(route_id)).await {
Err(e) => {
eprintln!(
"Tried to get route info but got error: {e:?}",
);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
Ok(route) => {
let json_stops = route.stops
.iter()
.map(|station| (
station.name.clone(),
station.id.eva_number.0.clone(),
))
.collect::<Vec<_>>();
let mut lock = stations.lock().await;
for station in route.stops.into_iter() {
lock.insert(
station.id.eva_number,
Station {
name: station.name,
db_station_id: station.id.db_station_id,
lat_lon: None,
transport_modes: None,
},
);
}
drop(lock);
Ok(Json((json_stops, route.canceled)))
}
}
}
}),
)
.route(
"/random_stations/{transport_modes_num}/{count}/",
get({
let stations = Arc::clone(&stations);
|Path((transport_modes_num, count)): Path<(u8, u8)>| async move {
let transport_modes = transport_modes_num_to_set(transport_modes_num);
if transport_modes.len() == 0 {
return Err(StatusCode::NOT_FOUND);
}
let lock = stations.lock().await;
let all_stations = lock
.stations
.iter()
.filter(|(_, station)| {
station
.transport_modes
.as_ref()
.is_some_and(|modes| modes.contains_any(&transport_modes))
})
.collect::<Vec<_>>();
let json = all_stations
.choose_multiple(
&mut rand::rng(),
all_stations.len().min(count as usize),
)
.map(|(eva_number, station)| {
(
station.name.clone(),
eva_number.0.clone(),
transport_modes_num_from_set(station.transport_modes.as_ref().expect(
"otherwise this would not have passed the pre-rand filter",
)),
)
})
.collect::<Vec<_>>();
drop(lock);
Ok(Json(json))
}
}),
),
)
.await
.unwrap();
}
fn transport_modes_num_to_set(num: u8) -> TransportModesSet {
let mut transport_modes = TransportModesSet::new();
for (i, m) in [
(1, [TransportMode::Bus, TransportMode::Bus]), // Bus
(2, [TransportMode::Tram, TransportMode::Ubahn]), // Tram+U
(4, [TransportMode::Sbahn, TransportMode::Sbahn]), // S-Bahn
(8, [TransportMode::Regional, TransportMode::Ir]), // RB/RE
(16, [TransportMode::EcIc, TransportMode::Ice]), // IC/ICE
] {
if (num & i) != 0 {
for m in m {
transport_modes.insert(m);
}
}
}
transport_modes
}
fn transport_modes_num_from_set(set: &TransportModesSet) -> u8 {
let mut num = 0;
for (i, m) in [
(1, [TransportMode::Bus, TransportMode::Bus]), // Bus
(2, [TransportMode::Tram, TransportMode::Ubahn]), // Tram+U
(4, [TransportMode::Sbahn, TransportMode::Sbahn]), // S-Bahn
(8, [TransportMode::Regional, TransportMode::Ir]), // RB/RE
(16, [TransportMode::EcIc, TransportMode::Ice]), // IC/ICE
] {
for m in m {
if set.contains(m) {
num |= i;
}
}
}
num
}

46
src/server/ratelimit.rs Normal file
View File

@ -0,0 +1,46 @@
use std::{
collections::VecDeque,
sync::{Arc, atomic::AtomicU32},
time::{Duration, Instant},
};
use reqwest::StatusCode;
use tokio::{sync::Mutex, time::sleep};
#[derive(Clone)]
pub struct Ratelimit(usize, u64, Arc<AtomicU32>, Arc<Mutex<VecDeque<Instant>>>);
impl Ratelimit {
pub fn new(max_concurrent_requests: usize, seconds: u64) -> Self {
Self(
max_concurrent_requests,
seconds,
Arc::new(AtomicU32::new(0)),
Arc::new(Mutex::new(VecDeque::new())),
)
}
#[must_use]
pub async fn wait(&self) -> Result<(), StatusCode> {
let time = Duration::from_secs(self.1);
let mut waiting = self.2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let out = loop {
if waiting > 50 {
break Err(StatusCode::SERVICE_UNAVAILABLE);
}
let mut lock = self.3.lock().await;
let now = Instant::now();
lock.retain(|t| now.saturating_duration_since(*t) < time);
if lock.len() < self.0 {
lock.push_back(now);
break Ok(());
} else {
drop(lock);
sleep(Duration::from_millis(100)).await;
}
waiting = self.2.load(std::sync::atomic::Ordering::Relaxed);
};
self.2.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
out
}
}

1
src/storage/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod stations;

67
src/storage/stations.rs Normal file
View File

@ -0,0 +1,67 @@
use std::collections::HashMap;
use crate::bahn_api::{
basic_types::station_ids::{DbStopId, StationEvaNumber, StationIdRef},
transport_modes::TransportModesSet,
};
pub struct Stations {
pub stations: HashMap<StationEvaNumber, Station>,
}
pub struct Station {
pub name: String,
pub db_station_id: DbStopId,
pub lat_lon: Option<(f64, f64)>,
pub transport_modes: Option<TransportModesSet>,
}
impl Stations {
pub fn new() -> Self {
Self {
stations: HashMap::new(),
}
}
pub fn insert(&mut self, eva_number: StationEvaNumber, station: Station) {
if let Some(prev) = self.stations.get_mut(&eva_number) {
// partial update: if we had more information than the new `station` has,
// keep parts of the old information, but wherever the new `station` has
// information, use the newer information.
#[allow(dead_code, unreachable_code)]
fn never_called() -> Station {
// NOTE: this is here in case the contents of `Station` change,
// as a reminder that the partial update must be adjusted too.
Station {
name: String::new(),
db_station_id: DbStopId(unreachable!()),
lat_lon: None,
transport_modes: None,
}
}
prev.name = station.name;
prev.db_station_id = station.db_station_id;
if let Some(v) = station.lat_lon {
prev.lat_lon = Some(v);
}
if let Some(v) = station.transport_modes {
prev.transport_modes = Some(v);
}
} else {
self.stations.insert(eva_number, station);
}
}
pub fn get(&mut self, eva_number: &StationEvaNumber) -> Option<&Station> {
self.stations.get(eva_number)
}
}
impl<'a> From<(&'a StationEvaNumber, &'a Station)> for StationIdRef<'a> {
fn from(value: (&'a StationEvaNumber, &'a Station)) -> Self {
StationIdRef {
eva_number: value.0,
db_station_id: &value.1.db_station_id,
}
}
}