Files
VBB-Status-Web-App/index.html
T
2025-12-31 16:05:43 +01:00

3401 lines
113 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<title>VBB Netz Status</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="images/favicon.png">
<link rel="apple-touch-icon" href="images/favicon.png">
<style>
/* DotMatrix Font by NikBLN - https://github.com/NikBLN/weilSieDichLieben */
@font-face {
font-family: 'DotMatrix';
src: url('https://raw.githubusercontent.com/NikBLN/weilSieDichLieben/refs/heads/main/public/fonts/DotMatrix.ttf') format('truetype');
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'DotMatrix', 'Courier New', monospace;
background-color: #000;
color: #FFED00;
min-height: 100vh;
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
}
.container {
max-width: 100%;
padding: 10px;
}
@media (min-width: 768px) {
.container {
padding: 15px 20px;
}
}
.header {
text-align: center;
padding: 20px 10px 15px;
background-color: #000;
position: sticky;
top: 0;
z-index: 100;
border-bottom: 2px solid #FFED00;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 100%;
}
.header-title {
flex: 1;
text-align: center;
}
.header h1 {
font-size: 26px;
letter-spacing: 2px;
margin-bottom: 6px;
}
.header .subtitle {
font-size: 11px;
opacity: 0.7;
letter-spacing: 1px;
margin-bottom: 6px;
}
.header .author {
font-size: 9px;
opacity: 0.5;
font-style: italic;
}
.menu-btn {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
background: none;
border: 2px solid #FFED00;
color: #FFED00;
font-size: 24px;
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s;
line-height: 1;
}
.menu-btn:active {
background-color: #FFED00;
color: #000;
}
/* Navigation Menu */
.nav-menu {
position: fixed;
top: 0;
right: -300px;
width: 280px;
height: 100%;
background-color: #000;
border-left: 2px solid #FFED00;
z-index: 1000;
transition: right 0.3s ease;
}
.nav-menu.active {
right: 0;
}
.nav-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 999;
display: none;
}
.nav-overlay.active {
display: block;
}
.nav-header {
padding: 20px;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-header h3 {
font-size: 18px;
color: #FFED00;
}
.nav-close {
background: none;
border: none;
color: #FFED00;
font-size: 32px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.nav-items {
padding: 10px 0;
}
.nav-item {
width: 100%;
padding: 18px 20px;
background: none;
border: none;
color: #FFED00;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 15px;
font-family: 'DotMatrix', 'Courier New', monospace;
font-size: 16px;
transition: background-color 0.2s;
border-left: 4px solid transparent;
}
.nav-item:active,
.nav-item.active {
background-color: #1a1a1a;
border-left-color: #FFED00;
}
.nav-icon {
font-size: 24px;
}
.nav-label {
flex: 1;
}
/* View Container */
.view-container {
display: none;
}
.view-container.active {
display: block;
}
.controls {
background-color: #0a0a0a;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
border: 1px solid #222;
}
.search-row {
display: flex;
gap: 10px;
margin-bottom: 15px;
align-items: stretch;
}
.search-container {
position: relative;
flex: 1;
}
.search-input {
width: 100%;
padding: 12px 45px 12px 12px;
font-family: 'DotMatrix', 'Courier New', monospace;
font-size: 16px;
background-color: #000 !important;
color: #FFED00;
border: 2px solid #FFED00;
border-radius: 6px;
outline: none;
transition: border-color 0.2s;
height: 48px;
box-sizing: border-box;
}
.search-input:focus {
border-color: #fff;
}
.search-input::placeholder {
color: rgba(255, 237, 0, 0.5);
}
/* Webkit Auto-fill Override */
.search-input:-webkit-autofill,
.search-input:-webkit-autofill:hover,
.search-input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px #000 inset !important;
-webkit-text-fill-color: #FFED00 !important;
}
.clear-btn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #FFED00;
font-size: 24px;
cursor: pointer;
padding: 4px 6px;
display: none;
line-height: 1;
opacity: 0.7;
transition: opacity 0.2s;
}
.clear-btn:hover,
.clear-btn:active {
opacity: 1;
}
.search-input:not(:placeholder-shown) ~ .clear-btn {
display: block;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: #0a0a0a;
border: 2px solid #FFED00;
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.suggestions.active {
display: block;
}
.suggestion-item {
padding: 12px;
cursor: pointer;
border-bottom: 1px solid #222;
transition: background-color 0.15s;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover,
.suggestion-item:active {
background-color: #FFED00;
color: #000;
}
.suggestion-name {
font-size: 14px;
font-weight: bold;
}
.suggestion-products {
font-size: 11px;
opacity: 0.7;
margin-top: 4px;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 0;
}
.btn {
flex: 1;
padding: 12px;
font-family: 'DotMatrix', 'Courier New', monospace;
font-size: 14px;
background-color: #FFED00;
color: #000;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.15s;
}
.btn:active {
background-color: #FFD700;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background-color: #666;
}
.departures-container {
margin-top: 20px;
}
.station-info {
background-color: #0a0a0a;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
border-left: 4px solid #FFED00;
}
.station-name {
font-size: 22px;
font-weight: bold;
margin-bottom: 6px;
}
.last-update {
font-size: 13px;
opacity: 0.6;
}
.departure-item {
background-color: #0a0a0a;
padding: 14px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 4px solid #666;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background-color 0.15s;
}
.departure-item:active {
background-color: #111;
}
.departure-info {
flex: 1;
}
.departure-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.line-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 4px;
font-weight: bold;
font-size: 16px;
}
.departure-destination {
font-size: 18px;
margin-bottom: 4px;
}
.departure-platform {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background-color: #1a1a1a;
border-radius: 3px;
font-size: 13px;
border: 1px solid #333;
}
.platform-icon {
font-size: 12px;
opacity: 0.7;
}
.platform-text {
font-weight: bold;
color: #FFED00;
}
.departure-time {
text-align: right;
font-size: 24px;
font-weight: bold;
min-width: 70px;
}
.departure-delay {
font-size: 13px;
color: #ff5555;
margin-top: 4px;
font-weight: bold;
}
/* Journey Planner */
.journey-inputs {
margin-bottom: 15px;
}
.journey-input-group {
margin-bottom: 12px;
}
.journey-label {
display: block;
font-size: 16px;
margin-bottom: 8px;
font-weight: bold;
}
.swap-btn {
width: 100%;
padding: 12px;
margin: 15px 0;
background-color: #0a0a0a;
color: #FFED00;
border: 1px solid #333;
border-radius: 6px;
font-size: 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.swap-btn:active {
background-color: #1a1a1a;
}
.journey-container {
margin-top: 15px;
}
.journey-result {
background-color: #0a0a0a;
border: 1px solid #222;
border-radius: 8px;
padding: 15px;
margin-bottom: 12px;
}
.journey-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #222;
}
.journey-time {
font-size: 20px;
font-weight: bold;
}
.journey-duration {
font-size: 14px;
opacity: 0.7;
}
.journey-legs {
margin-top: 12px;
}
.journey-leg {
margin-bottom: 12px;
padding: 16px;
background-color: #050505;
border-radius: 6px;
border-left: 4px solid #666;
}
.journey-leg-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.journey-leg-icon {
font-size: 28px;
min-width: 32px;
}
.journey-leg-line {
font-weight: bold;
font-size: 20px;
flex: 1;
}
.journey-leg-duration {
font-size: 16px;
opacity: 0.6;
}
.journey-leg-stop {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 30px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #111;
}
.journey-leg-stop:last-child {
border-bottom: none;
}
.journey-leg-station {
font-size: 18px;
font-weight: 500;
}
.journey-leg-platform {
font-size: 16px;
color: #FFED00;
font-weight: bold;
min-width: 100px;
text-align: right;
}
.journey-leg-time {
font-size: 20px;
font-weight: bold;
text-align: right;
min-width: 70px;
}
.journey-transfer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
margin: 10px 0;
background-color: #1a1a00;
border-radius: 4px;
font-size: 14px;
border-left: 3px solid #FFED00;
}
.journey-transfer.tight {
background-color: #2a1a00;
border-left-color: #ff9900;
}
.journey-transfer.very-tight {
background-color: #2a0000;
border-left-color: #ff3333;
}
.journey-transfer-time {
font-weight: bold;
color: #FFED00;
font-size: 16px;
margin-left: auto;
}
.journey-transfer.tight .journey-transfer-time {
color: #ff9900;
}
.journey-transfer.very-tight .journey-transfer-time {
color: #ff3333;
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.error {
background: linear-gradient(135deg, #ff4444 0%, #cc0000 100%);
color: #fff;
padding: 18px;
border-radius: 12px;
margin: 15px 0;
text-align: center;
box-shadow: 0 4px 20px rgba(255, 68, 68, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.empty-state {
text-align: center;
padding: 50px 20px;
opacity: 0.7;
}
.empty-state-icon {
font-size: 56px;
margin-bottom: 20px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.offline-indicator {
background: linear-gradient(135deg, #ff4444 0%, #cc0000 100%);
color: #fff;
padding: 12px;
text-align: center;
font-size: 12px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 200;
display: none;
box-shadow: 0 2px 10px rgba(255, 68, 68, 0.5);
font-weight: bold;
letter-spacing: 0.5px;
}
.offline-indicator.show {
display: block;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
/* Detail Modal */
.detail-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.97);
z-index: 1000;
display: none;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.detail-modal.active {
display: block;
}
.detail-header {
position: sticky;
top: 0;
background-color: #000;
padding: 15px;
border-bottom: 2px solid #FFED00;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
}
.detail-back-btn {
background: none;
border: 1px solid #FFED00;
color: #FFED00;
font-size: 22px;
cursor: pointer;
padding: 6px 12px;
font-family: 'DotMatrix', 'Courier New', monospace;
border-radius: 6px;
transition: background-color 0.15s;
}
.detail-back-btn:active {
background-color: #FFED00;
color: #000;
}
.detail-title {
flex: 1;
text-align: center;
font-size: 16px;
font-weight: bold;
}
.detail-content {
padding: 15px;
}
.detail-section {
background-color: #0a0a0a;
padding: 15px;
margin-bottom: 12px;
border-radius: 8px;
border-left: 4px solid #FFED00;
}
.detail-section-title {
font-size: 13px;
font-weight: bold;
margin-bottom: 10px;
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #222;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-size: 12px;
opacity: 0.7;
}
.detail-value {
font-size: 12px;
font-weight: bold;
text-align: right;
}
.stop-list {
margin-top: 10px;
}
.stop-item {
padding: 10px;
margin-bottom: 8px;
background-color: #050505;
border-radius: 6px;
border-left: 3px solid #555;
transition: background-color 0.15s;
}
.stop-item.current {
border-left-color: #FFED00;
background-color: #0f0f0a;
}
.stop-name {
font-size: 13px;
font-weight: bold;
margin-bottom: 4px;
}
.stop-time {
font-size: 11px;
opacity: 0.7;
}
.stop-delay {
color: #ff5555;
font-size: 11px;
margin-left: 8px;
font-weight: bold;
}
.remark-item {
background-color: #0a0500;
padding: 10px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 3px solid #ff8800;
}
.remark-type {
font-size: 10px;
opacity: 0.7;
text-transform: uppercase;
margin-bottom: 4px;
color: #ff8800;
font-weight: bold;
}
.remark-text {
font-size: 12px;
line-height: 1.3;
}
.loading-detail {
text-align: center;
padding: 50px 20px;
font-size: 16px;
opacity: 0.7;
}
/* Journey Detail Modal */
.journey-detail-leg {
margin-bottom: 20px;
padding: 15px;
background-color: #050505;
border-radius: 6px;
}
.journey-detail-leg-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #222;
}
.journey-detail-stop {
padding: 10px 0;
margin-left: 30px;
}
.journey-detail-stop-time {
font-size: 18px;
font-weight: bold;
color: #FFED00;
margin-bottom: 4px;
}
.journey-detail-stop-name {
font-size: 14px;
margin-bottom: 3px;
}
.journey-detail-platform {
font-size: 11px;
color: #FFED00;
opacity: 0.8;
}
.journey-detail-intermediate {
margin: 8px 0 8px 30px;
padding: 8px;
background-color: #0a0a0a;
border-radius: 4px;
}
.journey-detail-transfer {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
margin: 10px 0;
background-color: #1a1a00;
border-radius: 4px;
border-left: 3px solid #FFED00;
font-size: 12px;
}
.journey-detail-transfer.tight {
background-color: #2a1a00;
border-left-color: #ff9900;
}
.journey-detail-transfer.very-tight {
background-color: #2a0000;
border-left-color: #ff3333;
}
/* Touch improvements */
.departure-item {
min-height: 70px;
-webkit-tap-highlight-color: rgba(255, 237, 0, 0.1);
}
.suggestion-item {
min-height: 50px;
-webkit-tap-highlight-color: rgba(255, 237, 0, 0.1);
}
/* Better scrolling */
.suggestions {
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #FFED00 #1a1a1a;
}
.suggestions::-webkit-scrollbar {
width: 6px;
}
.suggestions::-webkit-scrollbar-track {
background: #1a1a1a;
}
.suggestions::-webkit-scrollbar-thumb {
background-color: #FFED00;
border-radius: 3px;
}
/* Improved button spacing */
.btn {
min-height: 48px;
}
.location-btn-side {
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
padding: 0;
font-size: 20px;
background-color: #000;
color: #FFED00;
border: 2px solid #FFED00;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
outline: none;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
}
.location-btn-side:active {
background-color: #FFED00;
color: #000;
}
.location-btn-side:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* KEIN Scale/Transform - nur Emoji ändert sich */
.location-btn-side.loading {
/* Button bleibt gleich, nur Emoji wird gewechselt */
}
/* Status badge */
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
margin-left: 8px;
font-weight: bold;
}
.status-cancelled {
background-color: #cc0000;
color: #fff;
}
.status-ontime {
background-color: #00cc00;
color: #000;
}
@media (min-width: 768px) {
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.detail-modal {
left: 50%;
transform: translateX(-50%);
max-width: 600px;
}
}
/* Pull-to-refresh styles */
.ptr-container {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
transform: translateY(-100%);
transition: transform 0.3s;
z-index: 99;
}
.ptr-container.pulling {
transform: translateY(0);
}
.ptr-spinner {
width: 30px;
height: 30px;
border: 3px solid #333;
border-top-color: #FFED00;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ============================================
RESPONSIVE DESIGN
============================================ */
/* Tablet & Desktop */
@media (min-width: 768px) {
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px 30px;
}
.header h1 {
font-size: 32px;
letter-spacing: 3px;
}
.header .subtitle {
font-size: 13px;
}
.menu-btn {
font-size: 28px;
padding: 10px 16px;
right: 20px;
}
/* Hover-Effekte für Desktop */
.menu-btn:hover {
background-color: rgba(255, 237, 0, 0.1);
}
.nav-item:hover {
background-color: #1a1a1a;
}
.departure-item:hover {
background-color: #111;
transform: translateX(4px);
transition: transform 0.2s;
}
.journey-result:hover {
background-color: #111;
border-color: #333;
}
.btn:hover {
background-color: rgba(255, 237, 0, 0.1);
}
.swap-btn:hover {
background-color: #1a1a1a;
}
.suggestion-item:hover {
background-color: #2a2a2a;
}
.nav-menu {
width: 280px;
}
/* Header größer auf Desktop */
.header h1 {
font-size: 36px;
letter-spacing: 3px;
}
.header .subtitle {
font-size: 14px;
}
.header .author {
font-size: 11px;
}
/* Controls größer */
.controls {
padding: 20px;
background-color: rgba(10, 10, 10, 0.5);
border-radius: 8px;
}
.search-input {
height: 56px;
font-size: 18px;
padding: 14px 50px 14px 14px;
}
.location-btn-side {
width: 56px;
height: 56px;
min-width: 56px;
min-height: 56px;
font-size: 22px;
}
.btn {
padding: 16px;
font-size: 16px;
min-height: 56px;
}
/* Journey Input - 2 Column Layout */
.journey-inputs {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 20px;
align-items: start;
}
.journey-input-group {
margin-bottom: 0;
}
.swap-btn {
margin: 45px 0 0 0;
width: auto;
padding: 12px 20px;
}
.journey-label {
font-size: 18px;
}
/* Station Info größer */
.station-info {
padding: 25px;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a00 100%);
}
.station-name {
font-size: 28px;
}
.last-update {
font-size: 15px;
}
/* Departure Items größer */
.departure-item {
padding: 24px;
margin-bottom: 18px;
cursor: pointer;
}
.line-badge {
padding: 8px 16px;
font-size: 20px;
}
.departure-destination {
font-size: 22px;
}
.departure-platform {
font-size: 16px;
padding: 5px 12px;
}
.platform-icon {
font-size: 14px;
}
.departure-time {
font-size: 32px;
}
.departure-delay {
font-size: 16px;
}
/* Journey Results */
.journey-result {
padding: 25px;
margin-bottom: 20px;
cursor: pointer;
transition: all 0.2s;
}
.journey-time {
font-size: 32px;
}
.journey-duration {
font-size: 20px;
}
.journey-leg {
padding: 25px;
}
.journey-leg-header {
margin-bottom: 20px;
}
.journey-leg-icon {
font-size: 36px;
}
.journey-leg-line {
font-size: 28px;
}
.journey-leg-duration {
font-size: 20px;
}
.journey-leg-stop {
padding: 14px 0;
gap: 40px;
}
.journey-leg-station {
font-size: 22px;
}
.journey-leg-platform {
font-size: 20px;
min-width: 120px;
}
.journey-leg-time {
font-size: 24px;
min-width: 80px;
}
.journey-transfer {
padding: 18px 24px;
font-size: 20px;
}
.journey-transfer-time {
font-size: 22px;
}
/* Detail Modal */
.detail-header {
padding: 28px;
}
.detail-title {
font-size: 26px;
}
.detail-content {
padding: 32px 28px;
}
.detail-section {
padding: 24px;
}
.stop-item {
padding: 16px;
}
.stop-name {
font-size: 18px;
}
/* Journey Detail Modal */
.journey-detail-leg {
padding: 22px;
}
.journey-detail-stop-time {
font-size: 24px;
}
.journey-detail-stop-name {
font-size: 20px;
}
.journey-detail-platform {
font-size: 16px;
}
.journey-detail-transfer {
font-size: 18px;
}
}
/* Large Desktop - 2 Column Layout */
@media (min-width: 1200px) {
.container {
padding: 30px 40px;
}
/* Abfahrten in 2 Spalten */
#departuresContainer {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.station-info {
grid-column: 1 / -1;
}
/* Journey auch größer */
.journey-result {
padding: 30px;
}
.journey-leg {
padding: 16px;
}
}
/* Extra Large Desktop */
@media (min-width: 1600px) {
.container {
max-width: 1400px;
}
/* 3 Spalten für Abfahrten */
#departuresContainer {
grid-template-columns: repeat(3, 1fr);
}
.station-info {
grid-column: 1 / -1;
}
}
/* Mobile Optimierungen */
@media (max-width: 767px) {
/* Mobile Design mit größerer Schrift */
.container {
padding: 10px;
}
.header {
padding: 15px 10px 12px;
}
.header h1 {
font-size: 26px;
letter-spacing: 2px;
}
.header .subtitle {
font-size: 12px;
}
.header .author {
font-size: 10px;
}
.menu-btn {
font-size: 26px;
padding: 8px 12px;
right: 10px;
}
.controls {
padding: 12px;
}
.search-input {
font-size: 18px;
padding: 12px 44px 12px 12px;
height: 48px;
}
.location-btn-side {
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
font-size: 20px;
}
.btn {
padding: 12px;
font-size: 16px;
}
.station-info {
padding: 14px;
}
.station-name {
font-size: 20px;
}
.last-update {
font-size: 12px;
}
.departure-item {
padding: 16px;
margin-bottom: 12px;
}
.line-badge {
padding: 6px 12px;
font-size: 16px;
}
.departure-destination {
font-size: 18px;
}
.departure-platform {
font-size: 14px;
padding: 4px 10px;
}
.platform-icon {
font-size: 13px;
}
.departure-time {
font-size: 26px;
}
.departure-delay {
font-size: 14px;
}
.journey-result {
padding: 16px;
margin-bottom: 12px;
}
.journey-header {
margin-bottom: 12px;
padding-bottom: 12px;
}
.journey-time {
font-size: 20px;
}
.journey-duration {
font-size: 16px;
}
.journey-leg {
padding: 14px;
}
.journey-leg-header {
margin-bottom: 10px;
gap: 10px;
}
.journey-leg-icon {
font-size: 22px;
}
.journey-leg-line {
font-size: 17px;
}
.journey-leg-duration {
font-size: 13px;
}
.journey-leg-stop {
padding: 6px 0;
gap: 12px;
}
.journey-leg-station {
font-size: 15px;
}
.journey-leg-platform {
font-size: 13px;
min-width: 70px;
}
.journey-leg-time {
font-size: 16px;
min-width: 55px;
}
.journey-transfer {
padding: 12px 14px;
font-size: 15px;
}
.journey-transfer-time {
font-size: 17px;
}
.journey-label {
font-size: 16px;
}
.swap-btn {
margin: 12px 0;
font-size: 20px;
}
body {
overflow-x: hidden;
}
/* Text truncation für lange Namen */
.departure-destination {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.journey-leg {
padding: 10px;
gap: 10px;
}
.journey-leg-icon {
font-size: 18px;
}
.journey-leg-line {
font-size: 13px;
}
.journey-leg-stations {
font-size: 11px;
}
.journey-leg-time {
font-size: 10px;
min-width: 50px;
}
.journey-transfer {
padding: 10px 12px;
font-size: 13px;
}
.journey-transfer-time {
font-size: 15px;
}
.journey-label {
font-size: 14px;
}
.swap-btn {
margin: 10px 0;
font-size: 18px;
}
body {
overflow-x: hidden;
}
/* Text truncation für lange Namen */
.departure-destination {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
/* Extra Small Mobile (SE, kleine Geräte) */
@media (max-width: 360px) {
.header h1 {
font-size: 20px;
}
.departure-item {
padding: 10px;
}
.line-badge {
font-size: 11px;
}
.departure-destination {
font-size: 13px;
}
.departure-time {
font-size: 16px;
}
}
</style>
</head>
<body>
<div class="offline-indicator" id="offlineIndicator">
📵 Offline-Modus - Zeige gespeicherte Daten
</div>
<div class="ptr-container" id="ptrContainer">
<div class="ptr-spinner"></div>
</div>
<div class="header">
<div class="header-content">
<div class="header-title">
<h1>VBB NETZ STATUS</h1>
<div class="subtitle">ECHTZEIT-ABFAHRTSMONITOR</div>
<div class="author">by Aaron K.</div>
</div>
<button class="menu-btn" id="menuBtn" aria-label="Menü öffnen">
☰
</button>
</div>
</div>
<!-- Navigation Menu -->
<div class="nav-menu" id="navMenu">
<div class="nav-header">
<h3>Funktionen</h3>
<button class="nav-close" id="navClose">×</button>
</div>
<div class="nav-items">
<button class="nav-item active" data-view="departures">
<span class="nav-icon">🚇</span>
<span class="nav-label">Abfahrten</span>
</button>
<button class="nav-item" data-view="journey">
<span class="nav-icon">🗺️</span>
<span class="nav-label">Routenplaner</span>
</button>
</div>
</div>
<div class="nav-overlay" id="navOverlay"></div>
<!-- View: Abfahrten -->
<div class="view-container active" id="view-departures">
<div class="container">
<div class="controls">
<div class="search-row">
<div class="search-container">
<input
type="text"
id="stationSearch"
class="search-input"
placeholder="Station suchen..."
autocomplete="off"
>
<button class="clear-btn" id="clearBtn">×</button>
<div class="suggestions" id="suggestions"></div>
</div>
<button class="location-btn-side" id="locationBtn" title="Nächste Station per GPS finden">
📍
</button>
</div>
<div class="button-group">
<button class="btn" id="refreshBtn" disabled>🔄 Aktualisieren</button>
</div>
</div>
<div id="departuresContainer" class="departures-container"></div>
</div>
</div>
<!-- View: Routenplaner -->
<div class="view-container" id="view-journey">
<div class="container">
<div class="controls">
<div class="journey-inputs">
<div class="journey-input-group">
<label class="journey-label">🔵 Von</label>
<div class="search-row">
<div class="search-container">
<input
type="text"
id="journeyFrom"
class="search-input"
placeholder="Startstation..."
autocomplete="off"
>
<button class="clear-btn" id="clearFromBtn">×</button>
<div class="suggestions" id="suggestionsFrom"></div>
</div>
<button class="location-btn-side" id="locationFromBtn" title="Aktuellen Standort nutzen">
📍
</button>
</div>
</div>
<button class="swap-btn" id="swapBtn" title="Start und Ziel tauschen">
⇅
</button>
<div class="journey-input-group">
<label class="journey-label">🔴 Nach</label>
<div class="search-row">
<div class="search-container">
<input
type="text"
id="journeyTo"
class="search-input"
placeholder="Zielstation..."
autocomplete="off"
>
<button class="clear-btn" id="clearToBtn">×</button>
<div class="suggestions" id="suggestionsTo"></div>
</div>
</div>
</div>
</div>
<div class="button-group">
<button class="btn" id="searchJourneyBtn" disabled>🔍 Route suchen</button>
<div style="font-size: 11px; opacity: 0.6; text-align: center; margin-top: 8px;">
Garantiert mind. 5 Min Umstiegszeit
</div>
</div>
</div>
<div id="journeyContainer" class="journey-container"></div>
</div>
</div>
<!-- Detail Modal -->
<div class="detail-modal" id="detailModal">
<div class="detail-header">
<button class="detail-back-btn" id="detailBackBtn">←</button>
<div class="detail-title" id="detailTitle">Fahrzeug-Details</div>
<div style="width: 34px;"></div>
</div>
<div class="detail-content" id="detailContent">
<div class="loading-detail">⏳ Lade Details...</div>
</div>
</div>
<!-- Journey Detail Modal -->
<div class="detail-modal" id="journeyDetailModal">
<div class="detail-header">
<button class="detail-back-btn" id="journeyDetailBackBtn">←</button>
<div class="detail-title" id="journeyDetailTitle">Routen-Details</div>
<div style="width: 34px;"></div>
</div>
<div class="detail-content" id="journeyDetailContent">
<div class="loading-detail">⏳ Lade Details...</div>
</div>
</div>
<script>
const API_BASE = 'https://v6.vbb.transport.rest';
let currentStationId = null;
let currentStationName = null;
let searchTimeout = null;
let touchStartY = 0;
let isPulling = false;
let autoRefreshInterval = null;
let apiCallTimestamps = []; // Track API calls for rate limiting
// DOM Elements - Departures View
const stationSearch = document.getElementById('stationSearch');
const clearBtn = document.getElementById('clearBtn');
const locationBtn = document.getElementById('locationBtn');
const suggestions = document.getElementById('suggestions');
const refreshBtn = document.getElementById('refreshBtn');
const departuresContainer = document.getElementById('departuresContainer');
const offlineIndicator = document.getElementById('offlineIndicator');
const ptrContainer = document.getElementById('ptrContainer');
const detailModal = document.getElementById('detailModal');
const detailBackBtn = document.getElementById('detailBackBtn');
const detailTitle = document.getElementById('detailTitle');
const detailContent = document.getElementById('detailContent');
// DOM Elements - Journey Detail Modal
const journeyDetailModal = document.getElementById('journeyDetailModal');
const journeyDetailBackBtn = document.getElementById('journeyDetailBackBtn');
const journeyDetailTitle = document.getElementById('journeyDetailTitle');
const journeyDetailContent = document.getElementById('journeyDetailContent');
// DOM Elements - Navigation
const menuBtn = document.getElementById('menuBtn');
const navMenu = document.getElementById('navMenu');
const navClose = document.getElementById('navClose');
const navOverlay = document.getElementById('navOverlay');
const navItems = document.querySelectorAll('.nav-item');
const viewDepartures = document.getElementById('view-departures');
const viewJourney = document.getElementById('view-journey');
// DOM Elements - Journey View
const journeyFrom = document.getElementById('journeyFrom');
const journeyTo = document.getElementById('journeyTo');
const clearFromBtn = document.getElementById('clearFromBtn');
const clearToBtn = document.getElementById('clearToBtn');
const locationFromBtn = document.getElementById('locationFromBtn');
const suggestionsFrom = document.getElementById('suggestionsFrom');
const suggestionsTo = document.getElementById('suggestionsTo');
const swapBtn = document.getElementById('swapBtn');
const searchJourneyBtn = document.getElementById('searchJourneyBtn');
const journeyContainer = document.getElementById('journeyContainer');
// Service Worker registrieren
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js')
.then(reg => console.log('Service Worker registriert'))
.catch(err => console.error('Service Worker Fehler:', err));
}
// Offline-Status überwachen
window.addEventListener('online', () => {
offlineIndicator.classList.remove('show');
});
window.addEventListener('offline', () => {
offlineIndicator.classList.add('show');
});
// Menu Navigation
function openMenu() {
navMenu.classList.add('active');
navOverlay.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeMenu() {
navMenu.classList.remove('active');
navOverlay.classList.remove('active');
document.body.style.overflow = '';
}
menuBtn.addEventListener('click', openMenu);
navClose.addEventListener('click', closeMenu);
navOverlay.addEventListener('click', closeMenu);
// View Switching
let currentView = 'departures';
function switchView(viewName) {
// Update active nav item
navItems.forEach(item => {
if (item.dataset.view === viewName) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
// Switch views
if (viewName === 'departures') {
viewDepartures.classList.add('active');
viewJourney.classList.remove('active');
} else if (viewName === 'journey') {
viewDepartures.classList.remove('active');
viewJourney.classList.add('active');
}
currentView = viewName;
closeMenu();
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(10);
}
}
navItems.forEach(item => {
item.addEventListener('click', () => {
const view = item.dataset.view;
switchView(view);
});
});
// Pull-to-refresh
let startY = 0;
let pulling = false;
document.addEventListener('touchstart', (e) => {
if (window.scrollY === 0 && !detailModal.classList.contains('active')) {
startY = e.touches[0].pageY;
pulling = true;
}
});
document.addEventListener('touchmove', (e) => {
if (!pulling) return;
const currentY = e.touches[0].pageY;
const pullDistance = currentY - startY;
if (pullDistance > 0 && pullDistance < 100) {
ptrContainer.style.transform = `translateY(${pullDistance - 60}px)`;
}
});
document.addEventListener('touchend', async (e) => {
if (!pulling) return;
pulling = false;
const currentY = e.changedTouches[0].pageY;
const pullDistance = currentY - startY;
if (pullDistance > 60 && currentStationId) {
ptrContainer.classList.add('pulling');
await loadDepartures();
setTimeout(() => {
ptrContainer.classList.remove('pulling');
ptrContainer.style.transform = '';
}, 300);
} else {
ptrContainer.style.transform = '';
}
});
// Swipe to close detail modal
let modalStartY = 0;
let modalPulling = false;
detailModal.addEventListener('touchstart', (e) => {
if (e.target.closest('.detail-content') && detailModal.scrollTop === 0) {
modalStartY = e.touches[0].pageY;
modalPulling = true;
}
});
detailModal.addEventListener('touchmove', (e) => {
if (!modalPulling) return;
const currentY = e.touches[0].pageY;
const pullDistance = currentY - modalStartY;
if (pullDistance > 0 && pullDistance < 200) {
detailModal.style.transform = `translateY(${pullDistance}px)`;
}
});
detailModal.addEventListener('touchend', (e) => {
if (!modalPulling) return;
modalPulling = false;
const currentY = e.changedTouches[0].pageY;
const pullDistance = currentY - modalStartY;
if (pullDistance > 100) {
closeDetailModal();
} else {
detailModal.style.transform = '';
}
});
// Swipe-to-close für Journey Detail Modal
let journeyModalStartY = 0;
let journeyModalPulling = false;
journeyDetailModal.addEventListener('touchstart', (e) => {
if (e.target.closest('.detail-content') && journeyDetailModal.scrollTop === 0) {
journeyModalStartY = e.touches[0].pageY;
journeyModalPulling = true;
}
});
journeyDetailModal.addEventListener('touchmove', (e) => {
if (!journeyModalPulling) return;
const currentY = e.touches[0].pageY;
const pullDistance = currentY - journeyModalStartY;
if (pullDistance > 0 && pullDistance < 200) {
journeyDetailModal.style.transform = `translateY(${pullDistance}px)`;
}
});
journeyDetailModal.addEventListener('touchend', (e) => {
if (!journeyModalPulling) return;
journeyModalPulling = false;
const currentY = e.changedTouches[0].pageY;
const pullDistance = currentY - journeyModalStartY;
if (pullDistance > 100) {
closeJourneyDetailModal();
} else {
journeyDetailModal.style.transform = '';
}
});
// Station suchen mit Autocomplete
stationSearch.addEventListener('input', (e) => {
const query = e.target.value.trim();
clearTimeout(searchTimeout);
if (query.length < 2) {
suggestions.classList.remove('active');
suggestions.innerHTML = '';
return;
}
// Stop auto-refresh during search
stopAutoRefresh();
searchTimeout = setTimeout(async () => {
if (!canMakeApiCall()) {
console.warn('Rate limit reached, skipping search');
return;
}
try {
const response = await fetch(`${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`);
const locations = await response.json();
const stations = locations.filter(loc =>
loc.type === 'stop' || loc.type === 'station'
);
displaySuggestions(stations);
} catch (error) {
console.error('Suchfehler:', error);
}
}, 300);
});
// Clear button
clearBtn.addEventListener('click', () => {
stationSearch.value = '';
suggestions.classList.remove('active');
suggestions.innerHTML = '';
stationSearch.focus();
});
// Location button - Find nearest station
locationBtn.addEventListener('click', async () => {
// Check if geolocation is supported
if (!navigator.geolocation) {
showLocationError('⚠️ Dein Browser unterstützt keine Standortdienste');
return;
}
// Check if HTTPS (required for geolocation in most browsers)
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
showLocationError('⚠️ Standortdienste benötigen HTTPS. Bitte nutze eine sichere Verbindung.');
return;
}
// Check rate limit before starting
if (!canMakeApiCall()) {
showLocationError('⚠️ Rate-Limit erreicht. Bitte warte kurz.');
return;
}
locationBtn.disabled = true;
locationBtn.textContent = '⌛'; // Emoji wechseln während Loading
// Show loading in search field
const originalPlaceholder = stationSearch.placeholder;
stationSearch.placeholder = '📍 Suche nächste Station...';
try {
// Try to check permission state first (if available)
if (navigator.permissions) {
try {
const permissionStatus = await navigator.permissions.query({ name: 'geolocation' });
if (permissionStatus.state === 'denied') {
throw new Error('PERMISSION_DENIED');
}
} catch (e) {
// Permissions API might not be available, continue anyway
console.log('Permissions API not available, trying geolocation directly');
}
}
// Get current position with better options
const position = await new Promise((resolve, reject) => {
const options = {
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 30000 // Cache for 30 seconds
};
navigator.geolocation.getCurrentPosition(resolve, reject, options);
});
const { latitude, longitude } = position.coords;
stationSearch.placeholder = '📍 Suche Stationen in der Nähe...';
// Search nearby stations - VBB API endpoint
const nearbyUrl = `${API_BASE}/locations/nearby?latitude=${latitude}&longitude=${longitude}&results=10`;
console.log('Searching nearby stations:', nearbyUrl);
const response = await fetch(nearbyUrl);
if (!response.ok) {
console.error('API Response status:', response.status);
throw new Error('API_ERROR');
}
const locations = await response.json();
// Filter for stops/stations only
const stations = locations.filter(loc =>
loc.type === 'stop' || loc.type === 'station'
);
if (!stations || stations.length === 0) {
throw new Error('NO_STATIONS');
}
// Select nearest station
const nearest = stations[0];
const distance = nearest.distance ? Math.round(nearest.distance) : '?';
selectStation(nearest.id, nearest.name);
// Show success feedback
if (navigator.vibrate) {
navigator.vibrate([50, 100, 50]);
}
stationSearch.placeholder = `✅ ${nearest.name} (${distance}m entfernt)`;
setTimeout(() => {
stationSearch.placeholder = originalPlaceholder;
}, 4000);
} catch (error) {
console.error('Standortfehler:', error);
let errorMsg = '';
let errorDetail = '';
if (error.message === 'PERMISSION_DENIED' || error.code === 1) {
errorMsg = '📍 Standortzugriff verweigert';
errorDetail = 'Bitte erlaube den Standortzugriff in deinen Browser-Einstellungen:\n\n' +
'• iOS: Einstellungen → Safari → Standort\n' +
'• Android: Chrome → Einstellungen → Website-Einstellungen → Standort\n' +
'• Desktop: Adresszeile → 🔒 Symbol → Berechtigungen';
} else if (error.code === 2) {
errorMsg = '📍 Standort nicht verfügbar';
errorDetail = 'Dein Gerät konnte deinen Standort nicht ermitteln. Stelle sicher, dass:\n\n' +
'• GPS/Standortdienste aktiviert sind\n' +
'• Du dich nicht im Flugmodus befindest\n' +
'• Du eine gute Verbindung hast';
} else if (error.code === 3) {
errorMsg = '⏱️ Zeitüberschreitung';
errorDetail = 'Die Standortermittlung hat zu lange gedauert. Bitte versuche es erneut.';
} else if (error.message === 'NO_STATIONS') {
errorMsg = '🚉 Keine Stationen in der Nähe';
errorDetail = 'Im Umkreis von 1km wurden keine VBB-Stationen gefunden.\n\nBitte nutze die manuelle Suche.';
} else if (error.message === 'API_ERROR') {
errorMsg = '⚠️ Verbindungsfehler';
errorDetail = 'Die Station konnte nicht gefunden werden.\n\nMögliche Ursachen:\n• Keine Internetverbindung\n• VBB-API vorübergehend nicht erreichbar\n\nBitte versuche es erneut oder nutze die manuelle Suche.';
} else {
errorMsg = '⚠️ Unbekannter Fehler';
errorDetail = error.message || 'Bitte versuche es erneut oder nutze die manuelle Suche.';
}
showLocationError(errorMsg, errorDetail);
stationSearch.placeholder = originalPlaceholder;
} finally {
locationBtn.disabled = false;
locationBtn.textContent = '📍'; // Zurück zum Pin-Emoji
}
});
function showLocationError(message, detail = '') {
if (detail) {
alert(message + '\n\n' + detail);
} else {
alert(message);
}
}
// Click außerhalb schließt Vorschläge
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-container')) {
suggestions.classList.remove('active');
}
});
function displaySuggestions(stations) {
if (stations.length === 0) {
suggestions.innerHTML = '<div class="suggestion-item">Keine Stationen gefunden</div>';
suggestions.classList.add('active');
return;
}
suggestions.innerHTML = stations.map(station => {
const products = station.products ?
Object.entries(station.products)
.filter(([_, available]) => available)
.map(([type]) => type.toUpperCase())
.join(', ') : '';
return `
<div class="suggestion-item" data-id="${station.id}" data-name="${station.name}">
<div class="suggestion-name">${station.name}</div>
${products ? `<div class="suggestion-products">${products}</div>` : ''}
</div>
`;
}).join('');
suggestions.classList.add('active');
// Event-Listener für Vorschläge
suggestions.querySelectorAll('.suggestion-item').forEach(item => {
item.addEventListener('click', () => {
const stationId = item.dataset.id;
const stationName = item.dataset.name;
if (stationId && stationName) {
selectStation(stationId, stationName);
}
});
});
}
function selectStation(stationId, stationName) {
currentStationId = stationId;
currentStationName = stationName;
stationSearch.value = stationName;
suggestions.classList.remove('active');
refreshBtn.disabled = false;
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(10);
}
loadDepartures();
}
// Rate Limiting: Max 100 requests per minute
function canMakeApiCall() {
const now = Date.now();
const oneMinuteAgo = now - 60000;
// Remove timestamps older than 1 minute
apiCallTimestamps = apiCallTimestamps.filter(timestamp => timestamp > oneMinuteAgo);
// Check if we're under the limit
if (apiCallTimestamps.length >= 100) {
console.warn('Rate limit reached: 100 requests per minute');
return false;
}
// Add current timestamp
apiCallTimestamps.push(now);
return true;
}
// Start/Stop Auto-Refresh
function startAutoRefresh() {
stopAutoRefresh(); // Clear any existing interval
autoRefreshInterval = setInterval(() => {
if (currentStationId && canMakeApiCall()) {
updateDeparturesValues(); // Nur Werte updaten, nicht neu rendern
}
}, 30000); // 30 seconds
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
async function loadDepartures() {
if (!currentStationId) return;
if (!canMakeApiCall()) {
console.warn('Skipping API call due to rate limit');
return;
}
departuresContainer.innerHTML = '<div class="loading">⏳ Lade Abfahrten...</div>';
try {
const response = await fetch(
`${API_BASE}/stops/${currentStationId}/departures?duration=60&results=20`
);
if (!response.ok) throw new Error('API-Fehler');
const data = await response.json();
displayDepartures(data.departures || []);
// Start auto-refresh after successful load
startAutoRefresh();
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(10);
}
} catch (error) {
console.error('Fehler beim Laden:', error);
departuresContainer.innerHTML = `
<div class="error">
⚠️ Fehler beim Laden der Abfahrten<br>
<small>${error.message}</small>
</div>
`;
// Stop auto-refresh on error
stopAutoRefresh();
}
}
// Update nur die Werte (Zeiten, Verspätungen) ohne DOM neu zu rendern
async function updateDeparturesValues() {
if (!currentStationId) return;
try {
const response = await fetch(
`${API_BASE}/stops/${currentStationId}/departures?duration=60&results=20`
);
if (!response.ok) throw new Error('API-Fehler');
const data = await response.json();
const departures = data.departures || [];
// Aktualisiere nur die Zeitanzeigen
const now = new Date();
const departureItems = departuresContainer.querySelectorAll('.departure-item');
departures.forEach((dep, index) => {
if (index >= departureItems.length) return;
const item = departureItems[index];
const when = new Date(dep.when);
const minutes = Math.round((when - now) / 60000);
const timeDisplay = minutes <= 0 ? 'Jetzt' : `${minutes} min`;
const delay = dep.delay ? Math.round(dep.delay / 60) : 0;
// Update Zeit
const timeElement = item.querySelector('.departure-time');
if (timeElement) {
const delayHTML = delay > 0 ?
`<div class="departure-delay">+${delay} min Verspätung</div>` : '';
timeElement.innerHTML = timeDisplay + delayHTML;
}
});
// Update Timestamp in Station Info
const lastUpdateElement = departuresContainer.querySelector('.last-update');
if (lastUpdateElement) {
lastUpdateElement.textContent = `Aktualisiert: ${now.toLocaleTimeString('de-DE')} • Auto-Refresh alle 30s`;
}
} catch (error) {
console.error('Fehler beim Aktualisieren:', error);
// Bei Fehler nicht stoppen, nächster Versuch in 30s
}
}
function displayDepartures(departures) {
if (departures.length === 0) {
departuresContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🚉</div>
<div>Keine Abfahrten in den nächsten 60 Minuten</div>
</div>
`;
return;
}
const now = new Date();
const stationInfoHTML = `
<div class="station-info">
<div class="station-name">${currentStationName}</div>
<div class="last-update">Aktualisiert: ${now.toLocaleTimeString('de-DE')} • Auto-Refresh alle 30s</div>
</div>
`;
const departuresHTML = departures.map((dep, index) => {
const when = new Date(dep.when);
const minutes = Math.round((when - now) / 60000);
const timeDisplay = minutes <= 0 ? 'Jetzt' : `${minutes} min`;
const delay = dep.delay ? Math.round(dep.delay / 60) : 0;
const delayHTML = delay > 0 ?
`<div class="departure-delay">+${delay} min Verspätung</div>` : '';
const lineColor = getLineColor(dep.line);
const platformHTML = dep.platform ?
`<div class="departure-platform">
<span class="platform-icon">🚉</span>
<span class="platform-text">Gleis ${dep.platform}</span>
</div>` : '';
const cancelled = dep.cancelled ? '<span class="status-badge status-cancelled">FÄLLT AUS</span>' : '';
return `
<div class="departure-item" style="border-left-color: ${lineColor}" data-trip-id="${dep.tripId}" data-index="${index}">
<div class="departure-info">
<div class="departure-header">
<div class="line-badge" style="background-color: ${lineColor}; color: #000;">
${dep.line.name}${cancelled}
</div>
${platformHTML}
</div>
<div class="departure-destination">→ ${dep.direction}</div>
</div>
<div class="departure-time">
${timeDisplay}
${delayHTML}
</div>
</div>
`;
}).join('');
departuresContainer.innerHTML = stationInfoHTML + departuresHTML;
// Event-Listener für Departure-Items
departuresContainer.querySelectorAll('.departure-item').forEach(item => {
item.addEventListener('click', () => {
const tripId = item.dataset.tripId;
const index = item.dataset.index;
if (tripId) {
showDepartureDetails(departures[index]);
}
});
});
}
async function showDepartureDetails(departure) {
detailModal.classList.add('active');
detailTitle.textContent = departure.line.name;
detailContent.innerHTML = '<div class="loading-detail">⏳ Lade Details...</div>';
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Pause auto-refresh while viewing details
stopAutoRefresh();
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(15);
}
try {
let tripDetails = null;
// Versuche Trip-Details zu laden
if (departure.tripId && canMakeApiCall()) {
try {
const response = await fetch(`${API_BASE}/trips/${departure.tripId}`);
if (response.ok) {
const data = await response.json();
tripDetails = data.trip;
}
} catch (e) {
console.log('Keine Trip-Details verfügbar');
}
}
displayDepartureDetails(departure, tripDetails);
} catch (error) {
console.error('Fehler beim Laden der Details:', error);
detailContent.innerHTML = `
<div class="error">
⚠️ Fehler beim Laden der Details<br>
<small>${error.message}</small>
</div>
`;
}
}
function displayDepartureDetails(departure, tripDetails) {
const when = new Date(departure.when);
const plannedWhen = departure.plannedWhen ? new Date(departure.plannedWhen) : when;
const delay = departure.delay ? Math.round(departure.delay / 60) : 0;
const lineColor = getLineColor(departure.line);
let html = `
<!-- Linie & Richtung -->
<div class="detail-section" style="border-left-color: ${lineColor}">
<div class="line-badge" style="background-color: ${lineColor}; color: #000; margin-bottom: 10px;">
${departure.line.name}
</div>
<div style="font-size: 18px; font-weight: bold; margin-bottom: 10px;">
→ ${departure.direction}
</div>
${departure.cancelled ? '<div class="status-badge status-cancelled">FÄLLT AUS</div>' : ''}
</div>
<!-- Abfahrt -->
<div class="detail-section">
<div class="detail-section-title">⏱️ Abfahrt</div>
<div class="detail-row">
<div class="detail-label">Geplant</div>
<div class="detail-value">${plannedWhen.toLocaleTimeString('de-DE')}</div>
</div>
<div class="detail-row">
<div class="detail-label">Aktuell</div>
<div class="detail-value">${when.toLocaleTimeString('de-DE')}</div>
</div>
${delay > 0 ? `
<div class="detail-row">
<div class="detail-label">Verspätung</div>
<div class="detail-value" style="color: #ff4444;">+${delay} min</div>
</div>
` : ''}
${departure.platform ? `
<div class="detail-row">
<div class="detail-label">Gleis/Steig</div>
<div class="detail-value">${departure.platform}</div>
</div>
` : ''}
</div>
<!-- Fahrzeug-Info -->
<div class="detail-section">
<div class="detail-section-title">🚊 Fahrzeug</div>
${departure.line.product ? `
<div class="detail-row">
<div class="detail-label">Verkehrsmittel</div>
<div class="detail-value">${departure.line.product}</div>
</div>
` : ''}
${departure.line.operator ? `
<div class="detail-row">
<div class="detail-label">Betreiber</div>
<div class="detail-value">${departure.line.operator.name || departure.line.operator.id}</div>
</div>
` : ''}
${departure.line.mode ? `
<div class="detail-row">
<div class="detail-label">Modus</div>
<div class="detail-value">${departure.line.mode}</div>
</div>
` : ''}
${departure.tripId ? `
<div class="detail-row">
<div class="detail-label">Fahrt-ID</div>
<div class="detail-value" style="font-size: 10px; word-break: break-all;">${departure.tripId}</div>
</div>
` : ''}
</div>
`;
// Halte anzeigen wenn verfügbar
if (tripDetails && tripDetails.stopovers && tripDetails.stopovers.length > 0) {
const currentStopIndex = tripDetails.stopovers.findIndex(stop =>
stop.stop.id === currentStationId
);
html += `
<div class="detail-section">
<div class="detail-section-title">🚏 Weitere Halte (${tripDetails.stopovers.length})</div>
<div class="stop-list">
${tripDetails.stopovers.map((stop, index) => {
const stopWhen = stop.departure || stop.arrival;
const stopPlannedWhen = stop.plannedDeparture || stop.plannedArrival;
const stopTime = stopWhen ? new Date(stopWhen) : null;
const stopPlannedTime = stopPlannedWhen ? new Date(stopPlannedWhen) : null;
const stopDelay = stop.departureDelay || stop.arrivalDelay || 0;
const stopDelayMin = Math.round(stopDelay / 60);
const isCurrent = index === currentStopIndex;
const isPassed = index < currentStopIndex;
return `
<div class="stop-item ${isCurrent ? 'current' : ''}" style="opacity: ${isPassed ? '0.5' : '1'}">
<div class="stop-name">
${isCurrent ? '📍 ' : ''}${stop.stop.name}
</div>
<div class="stop-time">
${stopPlannedTime ? stopPlannedTime.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) : '—'}
${stopDelayMin > 0 ? `<span class="stop-delay">+${stopDelayMin}'</span>` : ''}
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
}
// Hinweise anzeigen
if (departure.remarks && departure.remarks.length > 0) {
html += `
<div class="detail-section">
<div class="detail-section-title">ℹ️ Hinweise</div>
${departure.remarks.map(remark => `
<div class="remark-item">
${remark.type ? `<div class="remark-type">${remark.type}</div>` : ''}
<div class="remark-text">${remark.text || remark.summary || 'Keine Details'}</div>
</div>
`).join('')}
</div>
`;
}
// Zusätzliche Trip-Details
if (tripDetails) {
if (tripDetails.cycle) {
html += `
<div class="detail-section">
<div class="detail-section-title">🔄 Umlauf</div>
${tripDetails.cycle.min ? `
<div class="detail-row">
<div class="detail-label">Min. Takt</div>
<div class="detail-value">${tripDetails.cycle.min} min</div>
</div>
` : ''}
${tripDetails.cycle.max ? `
<div class="detail-row">
<div class="detail-label">Max. Takt</div>
<div class="detail-value">${tripDetails.cycle.max} min</div>
</div>
` : ''}
</div>
`;
}
if (tripDetails.direction) {
html += `
<div class="detail-section">
<div class="detail-row">
<div class="detail-label">Ziel-Richtung</div>
<div class="detail-value">${tripDetails.direction}</div>
</div>
</div>
`;
}
}
detailContent.innerHTML = html;
}
function closeDetailModal() {
detailModal.classList.remove('active');
detailModal.style.transform = '';
document.body.style.overflow = '';
// Resume auto-refresh
if (currentStationId) {
startAutoRefresh();
}
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(10);
}
}
// Detail Modal schließen
detailBackBtn.addEventListener('click', closeDetailModal);
// ESC-Taste schließt Modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && detailModal.classList.contains('active')) {
closeDetailModal();
}
if (e.key === 'Escape' && journeyDetailModal.classList.contains('active')) {
closeJourneyDetailModal();
}
});
// Journey Detail Modal schließen
journeyDetailBackBtn.addEventListener('click', closeJourneyDetailModal);
function closeJourneyDetailModal() {
journeyDetailModal.classList.remove('active');
document.body.style.overflow = '';
}
function getLineColor(line) {
if (!line || !line.product) return '#666';
const product = line.product.toLowerCase();
const name = line.name ? line.name.toUpperCase() : '';
// U-Bahn Linien
if (product === 'subway' || name.startsWith('U')) {
const ubahnColors = {
'U1': '#55a838', 'U2': '#da421e', 'U3': '#16683d',
'U4': '#f0d722', 'U5': '#7e5330', 'U6': '#8c6dab',
'U7': '#528dba', 'U8': '#224f86', 'U9': '#f3791d'
};
return ubahnColors[name] || '#0066CC';
}
// S-Bahn
if (product === 'suburban' || name.startsWith('S')) {
return '#5D871E';
}
// Tram
if (product === 'tram') {
return '#CC0000';
}
// Bus
if (product === 'bus') {
return '#993399';
}
// Regional
if (product === 'regional' || product === 'regionalexp') {
return '#DC281E';
}
return '#666';
}
// Refresh button
refreshBtn.addEventListener('click', loadDepartures);
// Journey Planner State
let journeyFromStation = null;
let journeyToStation = null;
// Journey Autocomplete
let journeyFromTimeout = null;
let journeyToTimeout = null;
function setupJourneyAutocomplete(inputField, suggestionsContainer, onSelect) {
let timeout = null;
inputField.addEventListener('input', (e) => {
const query = e.target.value.trim();
clearTimeout(timeout);
if (query.length < 2) {
suggestionsContainer.classList.remove('active');
suggestionsContainer.innerHTML = '';
return;
}
timeout = setTimeout(async () => {
if (!canMakeApiCall()) {
console.warn('Rate limit reached, skipping search');
return;
}
try {
const response = await fetch(`${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`);
const locations = await response.json();
const stops = locations.filter(loc =>
loc.type === 'stop' || loc.type === 'station'
);
if (stops.length > 0) {
suggestionsContainer.innerHTML = stops.map(stop => `
<div class="suggestion-item" data-id="${stop.id}" data-name="${stop.name}">
${stop.name}
</div>
`).join('');
suggestionsContainer.classList.add('active');
suggestionsContainer.querySelectorAll('.suggestion-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.id;
const name = item.dataset.name;
onSelect(id, name);
inputField.value = name;
suggestionsContainer.classList.remove('active');
if (navigator.vibrate) navigator.vibrate(10);
});
});
}
} catch (error) {
console.error('Autocomplete error:', error);
}
}, 300);
});
}
// Setup Journey From
setupJourneyAutocomplete(journeyFrom, suggestionsFrom, (id, name) => {
journeyFromStation = { id, name };
checkJourneySearchReady();
});
// Setup Journey To
setupJourneyAutocomplete(journeyTo, suggestionsTo, (id, name) => {
journeyToStation = { id, name };
checkJourneySearchReady();
});
// Clear buttons
clearFromBtn.addEventListener('click', () => {
journeyFrom.value = '';
journeyFromStation = null;
suggestionsFrom.classList.remove('active');
checkJourneySearchReady();
});
clearToBtn.addEventListener('click', () => {
journeyTo.value = '';
journeyToStation = null;
suggestionsTo.classList.remove('active');
checkJourneySearchReady();
});
// Swap button
swapBtn.addEventListener('click', () => {
const tempStation = journeyFromStation;
const tempValue = journeyFrom.value;
journeyFromStation = journeyToStation;
journeyFrom.value = journeyTo.value;
journeyToStation = tempStation;
journeyTo.value = tempValue;
checkJourneySearchReady();
if (navigator.vibrate) navigator.vibrate(15);
});
// Location From Button
locationFromBtn.addEventListener('click', async () => {
if (!navigator.geolocation) {
alert('⚠️ Standortdienste nicht verfügbar');
return;
}
if (!canMakeApiCall()) {
alert('⚠️ Rate-Limit erreicht');
return;
}
locationFromBtn.disabled = true;
locationFromBtn.textContent = '⌛';
try {
const position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 30000
});
});
const { latitude, longitude } = position.coords;
const response = await fetch(
`${API_BASE}/locations/nearby?latitude=${latitude}&longitude=${longitude}&results=10`
);
if (!response.ok) throw new Error('API_ERROR');
const locations = await response.json();
const stations = locations.filter(loc =>
loc.type === 'stop' || loc.type === 'station'
);
if (stations.length === 0) throw new Error('NO_STATIONS');
const nearest = stations[0];
journeyFromStation = { id: nearest.id, name: nearest.name };
journeyFrom.value = nearest.name;
checkJourneySearchReady();
if (navigator.vibrate) navigator.vibrate([50, 100, 50]);
} catch (error) {
console.error('Location error:', error);
alert('⚠️ Standort konnte nicht ermittelt werden');
} finally {
locationFromBtn.disabled = false;
locationFromBtn.textContent = '📍';
}
});
// Check if search is ready
function checkJourneySearchReady() {
if (journeyFromStation && journeyToStation) {
searchJourneyBtn.disabled = false;
} else {
searchJourneyBtn.disabled = true;
}
}
// Search Journey
searchJourneyBtn.addEventListener('click', async () => {
if (!journeyFromStation || !journeyToStation) return;
if (!canMakeApiCall()) {
alert('⚠️ Rate-Limit erreicht. Bitte warte kurz.');
return;
}
journeyContainer.innerHTML = '<div class="loading">⏳ Suche Verbindungen...</div>';
try {
const url = `${API_BASE}/journeys?from=${journeyFromStation.id}&to=${journeyToStation.id}&results=5&transfers=3&transferTime=5`;
const response = await fetch(url);
if (!response.ok) throw new Error('API-Fehler');
const data = await response.json();
displayJourneys(data.journeys || []);
if (navigator.vibrate) navigator.vibrate(10);
} catch (error) {
console.error('Journey search error:', error);
journeyContainer.innerHTML = `
<div class="error">
⚠️ Fehler bei der Routensuche<br>
<small>${error.message}</small>
</div>
`;
}
});
// Display Journeys
let currentJourneys = []; // Speichert aktuelle Journeys für Detail-Ansicht
function displayJourneys(journeys) {
currentJourneys = journeys; // Speichern für später
if (!journeys || journeys.length === 0) {
journeyContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🚫</div>
Keine Verbindungen gefunden
</div>
`;
return;
}
const html = journeys.map((journey, journeyIndex) => {
const departure = new Date(journey.legs[0].plannedDeparture || journey.legs[0].departure);
const arrival = new Date(journey.legs[journey.legs.length - 1].plannedArrival || journey.legs[journey.legs.length - 1].arrival);
const durationMs = arrival - departure;
const durationMin = Math.round(durationMs / 60000);
const hours = Math.floor(durationMin / 60);
const minutes = durationMin % 60;
const durationText = hours > 0 ? `${hours}h ${minutes}min` : `${minutes}min`;
// Normalisierungs-Funktion definieren (wird mehrfach gebraucht)
function normalizeStationName(name) {
if (!name) return '';
return name
.replace(/^(S\+U|U|S)\s+/i, '')
.replace(/\s*\(.*?\)\s*/g, '')
.replace(/^Berlin\s+/i, '')
.toLowerCase()
.trim();
}
// SCHRITT 1: Filtere Same-Place-Walks VORHER aus dem Array
const filteredLegs = journey.legs.filter((leg) => {
// Behalte alle Fahrzeuge
if (leg.line) return true;
// Prüfe Fußwege: Nur behalten wenn verschiedene Orte
const originNorm = normalizeStationName(leg.origin?.name);
const destNorm = normalizeStationName(leg.destination?.name);
const samePlace = originNorm && destNorm && (
originNorm === destNorm ||
originNorm.includes(destNorm) ||
destNorm.includes(originNorm)
);
return !samePlace; // NUR Fußwege zwischen verschiedenen Orten behalten
});
// SCHRITT 2: Zeige gefilterte Legs an
const legsHtml = filteredLegs.map((leg, index) => {
const icon = getTransportIcon(leg);
const lineColor = leg.line ? getLineColor(leg.line) : '#666';
const depTime = new Date(leg.plannedDeparture || leg.departure).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const arrTime = new Date(leg.plannedArrival || leg.arrival).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
// Fahrtdauer dieses Legs
const legDuration = Math.round((new Date(leg.plannedArrival || leg.arrival) - new Date(leg.plannedDeparture || leg.departure)) / 60000);
// Gleisangaben
const depPlatform = leg.departurePlatform ? `Gleis ${leg.departurePlatform}` : '';
const arrPlatform = leg.arrivalPlatform ? `Gleis ${leg.arrivalPlatform}` : '';
let transferHtml = '';
// Transfer-Zeit zum NÄCHSTEN gefilterten Leg
if (index < filteredLegs.length - 1) {
const nextLeg = filteredLegs[index + 1];
const arrivalTime = new Date(leg.plannedArrival || leg.arrival);
const nextDepartureTime = new Date(nextLeg.plannedDeparture || nextLeg.departure);
const transferTime = Math.round((nextDepartureTime - arrivalTime) / 60000);
// Bestimme Art des Übergangs
let transferType = '';
let transferIcon = '';
// Prüfe ob gleicher Bahnhof
const destName = normalizeStationName(leg.destination?.name);
const originName = normalizeStationName(nextLeg.origin?.name);
const sameStation = destName && originName && (
destName === originName ||
destName.includes(originName) ||
originName.includes(destName)
);
if (leg.line && nextLeg.line) {
// Beide sind Fahrzeuge
if (sameStation) {
transferType = 'Umstieg';
transferIcon = '🔄';
} else {
transferType = 'Fußweg';
transferIcon = '🚶';
}
} else if (!leg.line || !nextLeg.line) {
// Mindestens eines ist Fußweg
transferType = 'Fußweg';
transferIcon = '🚶';
}
const platformInfo = arrPlatform && nextLeg.departurePlatform ?
` • ${arrPlatform} → Gleis ${nextLeg.departurePlatform}` : '';
// Berechne Anzeige-Zeit
let displayTime = transferTime >= 0 ? transferTime : 0;
// Mindestens 2 Min bei Umstieg am gleichen Bahnhof
if (transferType === 'Umstieg' && sameStation && displayTime < 2) {
displayTime = Math.max(2, displayTime);
}
// Warnung bei kurzen Umstiegszeiten
let warningClass = '';
if (transferType === 'Umstieg' && displayTime < 3) {
warningClass = ' very-tight';
} else if (transferType === 'Umstieg' && displayTime < 5) {
warningClass = ' tight';
}
transferHtml = `
<div class="journey-transfer${warningClass}">
<span>${transferIcon} ${transferType}${platformInfo}</span>
<span class="journey-transfer-time">${displayTime} min</span>
</div>
`;
}
// Zeige Dauer bei Fußwegen in der Leg-Zeile
const durationBadge = !leg.line ? ` <span style="opacity:0.7;font-size:0.9em">(${legDuration} min)</span>` : '';
return `
<div class="journey-leg" style="border-left-color: ${lineColor}" data-leg-index="${index}">
<div class="journey-leg-header">
<div class="journey-leg-icon">${icon}</div>
<div class="journey-leg-line">${leg.line ? leg.line.name : 'Fußweg'}${durationBadge}</div>
<div class="journey-leg-duration">${legDuration} min</div>
</div>
<div class="journey-leg-stop">
<div class="journey-leg-station">${leg.origin.name}</div>
<div class="journey-leg-platform">${depPlatform || ''}</div>
<div class="journey-leg-time">${depTime}</div>
</div>
<div class="journey-leg-stop">
<div class="journey-leg-station">${leg.destination.name}</div>
<div class="journey-leg-platform">${arrPlatform || ''}</div>
<div class="journey-leg-time">${arrTime}</div>
</div>
</div>
${transferHtml}
`;
}).join('');
return `
<div class="journey-result" data-journey-index="${journeyIndex}" style="cursor: pointer;">
<div class="journey-header">
<div class="journey-time">
${departure.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
→
${arrival.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div>
<div class="journey-duration">${durationText}</div>
</div>
<div class="journey-legs">
${legsHtml}
</div>
</div>
`;
}).join('');
journeyContainer.innerHTML = html;
// Click-Handler für Journey Results
journeyContainer.querySelectorAll('.journey-result').forEach(item => {
item.addEventListener('click', () => {
const journeyIndex = parseInt(item.dataset.journeyIndex);
showJourneyDetails(currentJourneys[journeyIndex], journeyIndex);
if (navigator.vibrate) {
navigator.vibrate(10);
}
});
});
}
// Show Journey Details
function showJourneyDetails(journey, journeyIndex) {
journeyDetailModal.classList.add('active');
document.body.style.overflow = 'hidden';
const departure = new Date(journey.legs[0].plannedDeparture || journey.legs[0].departure);
const arrival = new Date(journey.legs[journey.legs.length - 1].plannedArrival || journey.legs[journey.legs.length - 1].arrival);
const durationMs = arrival - departure;
const durationMin = Math.round(durationMs / 60000);
const hours = Math.floor(durationMin / 60);
const minutes = durationMin % 60;
const durationText = hours > 0 ? `${hours}h ${minutes}min` : `${minutes}min`;
journeyDetailTitle.textContent = `Verbindung ${journeyIndex + 1}`;
let contentHTML = `
<div class="detail-section">
<div class="detail-section-title">📍 Übersicht</div>
<div class="detail-row">
<span class="detail-label">Von:</span>
<span class="detail-value">${journey.legs[0].origin.name}</span>
</div>
<div class="detail-row">
<span class="detail-label">Nach:</span>
<span class="detail-value">${journey.legs[journey.legs.length - 1].destination.name}</span>
</div>
<div class="detail-row">
<span class="detail-label">Abfahrt:</span>
<span class="detail-value">${departure.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</span>
</div>
<div class="detail-row">
<span class="detail-label">Ankunft:</span>
<span class="detail-value">${arrival.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr</span>
</div>
<div class="detail-row">
<span class="detail-label">Dauer:</span>
<span class="detail-value">${durationText}</span>
</div>
<div class="detail-row">
<span class="detail-label">Umstiege:</span>
<span class="detail-value">${journey.legs.filter(leg => leg.line).length - 1} Umstiege</span>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">🚉 Fahrtabschnitte</div>
`;
// Normalisierungs-Funktion
function normalizeStationName(name) {
if (!name) return '';
return name
.replace(/^(S\+U|U|S)\s+/i, '')
.replace(/\s*\(.*?\)\s*/g, '')
.replace(/^Berlin\s+/i, '')
.toLowerCase()
.trim();
}
// Filtere Same-Place-Walks VORHER
const filteredLegs = journey.legs.filter((leg) => {
if (leg.line) return true;
const originNorm = normalizeStationName(leg.origin?.name);
const destNorm = normalizeStationName(leg.destination?.name);
const samePlace = originNorm && destNorm && (
originNorm === destNorm ||
originNorm.includes(destNorm) ||
destNorm.includes(originNorm)
);
return !samePlace;
});
filteredLegs.forEach((leg, index) => {
const icon = getTransportIcon(leg);
const lineColor = leg.line ? getLineColor(leg.line) : '#666';
const depTime = new Date(leg.plannedDeparture || leg.departure).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const arrTime = new Date(leg.plannedArrival || leg.arrival).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const depPlatform = leg.departurePlatform ? `Gleis ${leg.departurePlatform}` : '';
const arrPlatform = leg.arrivalPlatform ? `Gleis ${leg.arrivalPlatform}` : '';
const legDuration = Math.round((new Date(leg.plannedArrival || leg.arrival) - new Date(leg.plannedDeparture || leg.departure)) / 60000);
contentHTML += `
<div class="journey-detail-leg" style="border-left: 4px solid ${lineColor}">
<div class="journey-detail-leg-header">
<span style="font-size: 20px;">${icon}</span>
<span style="font-weight: bold; font-size: 16px;">${leg.line ? leg.line.name : 'Fußweg'}</span>
<span style="opacity: 0.7; font-size: 13px;">${legDuration} min</span>
</div>
<div class="journey-detail-stop">
<div class="journey-detail-stop-time">${depTime}</div>
<div class="journey-detail-stop-name">${leg.origin.name}</div>
${depPlatform ? `<div class="journey-detail-platform">${depPlatform}</div>` : ''}
</div>
${leg.stopovers && leg.stopovers.length > 0 ? `
<div class="journey-detail-intermediate">
<span style="opacity: 0.6; font-size: 12px;">🔄 ${leg.stopovers.length} Halte</span>
</div>
` : ''}
<div class="journey-detail-stop">
<div class="journey-detail-stop-time">${arrTime}</div>
<div class="journey-detail-stop-name">${leg.destination.name}</div>
${arrPlatform ? `<div class="journey-detail-platform">${arrPlatform}</div>` : ''}
</div>
</div>
`;
// Transfer-Zeit zum NÄCHSTEN gefilterten Leg
if (index < filteredLegs.length - 1) {
const nextLeg = filteredLegs[index + 1];
const arrivalTime = new Date(leg.plannedArrival || leg.arrival);
const nextDepartureTime = new Date(nextLeg.plannedDeparture || nextLeg.departure);
const transferTime = Math.round((nextDepartureTime - arrivalTime) / 60000);
// Bestimme Art des Übergangs
let transferType = '';
let transferIcon = '';
const destName = normalizeStationName(leg.destination?.name);
const originName = normalizeStationName(nextLeg.origin?.name);
const sameStation = destName && originName && (
destName === originName ||
destName.includes(originName) ||
originName.includes(destName)
);
if (leg.line && nextLeg.line) {
// Beide sind Fahrzeuge
if (sameStation) {
transferType = 'Umstieg';
transferIcon = '🔄';
} else {
transferType = 'Fußweg';
transferIcon = '🚶';
}
} else if (leg.walking || nextLeg.walking || !leg.line || !nextLeg.line) {
transferType = 'Fußweg';
transferIcon = '🚶';
}
const platformInfo = arrPlatform && nextLeg.departurePlatform ?
`<span style="font-size: 11px; opacity: 0.7;">${arrPlatform} → Gleis ${nextLeg.departurePlatform}</span>`
: '';
let displayTime = transferTime >= 0 ? transferTime : 0;
if (transferType === 'Umstieg' && sameStation && displayTime < 2) {
displayTime = Math.max(2, displayTime);
}
let warningClass = '';
if (transferType === 'Umstieg' && displayTime < 3) {
warningClass = ' very-tight';
} else if (transferType === 'Umstieg' && displayTime < 5) {
warningClass = ' tight';
}
contentHTML += `
<div class="journey-detail-transfer${warningClass}">
<span>${transferIcon} ${transferType} • ${displayTime} min</span>
${platformInfo}
</div>
`;
}
});
contentHTML += `</div>`;
// Hinweise wenn vorhanden
if (journey.remarks && journey.remarks.length > 0) {
contentHTML += `
<div class="detail-section">
<div class="detail-section-title">ℹ️ Hinweise</div>
`;
journey.remarks.forEach(remark => {
if (remark.text) {
contentHTML += `
<div class="remark-item">
<div class="remark-text">${remark.text}</div>
</div>
`;
}
});
contentHTML += `</div>`;
}
journeyDetailContent.innerHTML = contentHTML;
if (navigator.vibrate) {
navigator.vibrate(15);
}
}
// Get Transport Icon
function getTransportIcon(leg) {
if (leg.walking) return '🚶';
if (!leg.line) return '🚶';
const product = leg.line.product;
if (product === 'subway') return '🚇';
if (product === 'suburban') return '🚊';
if (product === 'tram') return '🚋';
if (product === 'bus') return '🚌';
if (product === 'regional' || product === 'regionalExp') return '🚆';
if (product === 'ferry') return '⛴️';
return '🚉';
}
// Initial state
if (!navigator.onLine) {
offlineIndicator.classList.add('show');
}
</script>
</body>
</html>