initial commit
This commit is contained in:
commit
f5bbfe171d
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
1888
Cargo.lock
generated
Normal file
1888
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal 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
579
index.html
Normal 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"> 🚌 </span>
|
||||
<span id="TransportModeTram" class="TransportModeIcon" title="Tram & Metro"> 🚊 </span>
|
||||
<span id="TransportModeS" class="TransportModeIcon" title="Light Rail / S-Bahn"> 🚈 </span>
|
||||
<span id="TransportModeRE" class="TransportModeIcon" title="Regional Train (RB/RE)"> 🚆 </span>
|
||||
<span id="TransportModeICE" class="TransportModeIcon" title="High-Speed Rail (IC/ICE)"> 🚄 </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>
|
87
src/bahn_api/basic_types.rs
Normal file
87
src/bahn_api/basic_types.rs
Normal 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);
|
||||
}
|
285
src/bahn_api/departures_arrivals.rs
Normal file
285
src/bahn_api/departures_arrivals.rs
Normal 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
5
src/bahn_api/mod.rs
Normal 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;
|
69
src/bahn_api/route_info.rs
Normal file
69
src/bahn_api/route_info.rs
Normal 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),
|
||||
}
|
96
src/bahn_api/station_search.rs
Normal file
96
src/bahn_api/station_search.rs
Normal 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)
|
||||
}
|
||||
}
|
184
src/bahn_api/transport_modes.rs
Normal file
184
src/bahn_api/transport_modes.rs
Normal 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
34
src/main.rs
Normal 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
256
src/server/mod.rs
Normal 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
46
src/server/ratelimit.rs
Normal 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
1
src/storage/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod stations;
|
67
src/storage/stations.rs
Normal file
67
src/storage/stations.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user