bahnreise/index.html
2025-08-21 23:49:56 +02:00

605 lines
21 KiB
HTML

<!DOCTYPE html>
<html>
<!--
IDEAS:
- Achievements (i.e. "take a train which is delayed by at least 60 minutes" xd)
- Bonus bei Verspätung, Gesamtverspätung sammeln
- 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);
}
.DraggingTop {
z-index: 15;
}
#Header {
z-index: 5;
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;
draggingElement.classList.add("DraggingTop");
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;
draggingElement.classList.remove("DraggingTop");
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);
async function goInGame() {
isInGame = true;
// make server get all eva numbers of the destination station
let testDestinationDepartures = await (await fetch("./query_departures/" + encodeURIComponent(transportModesInteger) + "/" + encodeURIComponent(stationDestination.evaNumber) + "/")).json();
if (testDestinationDepartures[1].length < 10) {
if (!window.confirm("Warning: Low activity (" + testDestinationDepartures[1].length + ") at the specified destination. It may not be possible to win.")) {
isInGame = false;
return;
}
}
stationDestination.relatedEvaNumbers = testDestinationDepartures[0];
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();
if (stationDestination.evaNumber === stationCurrent.evaNumber || (stationDestination.relatedEvaNumbers && stationDestination.relatedEvaNumbers.includes(stationCurrent.evaNumber))) {
endGameWin();
return;
}
let response = await fetch("./query_departures/" + encodeURIComponent(transportModesInteger) + "/" + encodeURIComponent(stationCurrent.evaNumber) + "/");
if (!response.ok) {
let error = await response.text();
console.warn("Query Departures: Got error " + response.status + " from server: " + error);
} else {
let [stationRelatedEvaNumbers, result] = await response.json();
for (let relatedEvaNumber of stationRelatedEvaNumbers) {
if (stationDestination.evaNumber === relatedEvaNumber || (stationDestination.relatedEvaNumbers && stationDestination.relatedEvaNumbers.includes(relatedEvaNumber))) {
endGameWin();
return;
}
}
for (const [route, stopEvaNumber, 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 === stopEvaNumber) foundCurrent = false;
}
for (const [stop, evaNumber] of result[0]) {
let stopElem = document.createElement("li");
stopElem.innerText = stop;
stopElem.style.wordWrap = "nowrap";
if (!foundCurrent) {
if (evaNumber === stopEvaNumber) {
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);
}
}
}
}
function endGameWin() {
// TODO: obv
alert("You won! Unfortunately, i haven't made a screen for that yet...");
location.reload();
}
</script>
</body>
</html>