Add files via upload
This commit is contained in:
1 parent
6ca6d44ce4
commit
e79d3b4cdf
10 files changed
+2840
No files matched your search
+176
@@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ÖPNV Navi – Live-Abfahrten für Berlin & Brandenburg</title>
|
||||||
|
<meta name="description" content="Live-Abfahrten, Live-Karte und Routenplaner für den VBB-Verbund." />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ÖPNV Navi" />
|
||||||
|
<meta name="theme-color" content="#0C1116" />
|
||||||
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<button class="brand" data-tab="departures" aria-label="Zur Startseite">
|
||||||
|
<span class="brand__mark">ÖPNV</span><span class="brand__mark brand__mark--accent">NAVI</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="search-box search-box--global">
|
||||||
|
<span class="search-box__icon" aria-hidden="true">⌕</span>
|
||||||
|
<input type="text" id="station-search" placeholder="Haltestelle, Ort oder Ziel suchen…" autocomplete="off" aria-label="Haltestelle, Ort oder Ziel suchen" />
|
||||||
|
<button id="search-clear" class="search-clear" type="button" aria-label="Suche leeren" hidden>×</button>
|
||||||
|
<ul id="search-results" class="search-results" hidden></ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tab-nav" aria-label="Hauptnavigation">
|
||||||
|
<button class="tab-button is-active" data-tab="departures"><span>🚏</span> Abfahrten</button>
|
||||||
|
<button class="tab-button" data-tab="journey"><span>🧭</span> Route</button>
|
||||||
|
<button class="tab-button" data-tab="map"><span>🗺</span> Karte</button>
|
||||||
|
<button class="tab-button" data-tab="more"><span>☰</span> Mehr</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<section class="tab-panel" data-tab-panel="departures">
|
||||||
|
<div class="welcome-panel" id="welcome-panel">
|
||||||
|
<div class="welcome-panel__eyebrow">LIVE · VBB</div>
|
||||||
|
<h1>Was fährt jetzt?</h1>
|
||||||
|
<p>Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.</p>
|
||||||
|
<div class="welcome-actions">
|
||||||
|
<button id="welcome-nearby" class="action-card action-card--primary" type="button">
|
||||||
|
<span class="action-card__icon">📍</span>
|
||||||
|
<span><strong>In meiner Nähe</strong><small>Haltestellen im Umkreis finden</small></span>
|
||||||
|
</button>
|
||||||
|
<button id="welcome-route" class="action-card" type="button">
|
||||||
|
<span class="action-card__icon">🧭</span>
|
||||||
|
<span><strong>Route planen</strong><small>Von A nach B mit Bus & Bahn</small></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="favorites-home" class="home-section" hidden>
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><span class="section-heading__eyebrow">SCHNELLZUGRIFF</span><h2>Meine Haltestellen</h2></div>
|
||||||
|
</div>
|
||||||
|
<div id="favorite-cards" class="favorite-cards"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="nearby-home" class="home-section" hidden>
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><span class="section-heading__eyebrow">OPTIONAL</span><h2>Haltestellen in der Nähe</h2></div>
|
||||||
|
<button id="nearby-home-open" class="text-button" type="button">Alle anzeigen</button>
|
||||||
|
</div>
|
||||||
|
<div id="nearby-home-list" class="nearby-preview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="departures-view" hidden>
|
||||||
|
<div class="board-header">
|
||||||
|
<div>
|
||||||
|
<span class="section-heading__eyebrow">LIVE-ABFAHRTEN</span>
|
||||||
|
<h1 id="board-station-name" class="board-station-name">Keine Haltestelle ausgewählt</h1>
|
||||||
|
</div>
|
||||||
|
<button id="favorite-toggle" class="favorite-toggle" hidden>☆ Favorit</button>
|
||||||
|
</div>
|
||||||
|
<div id="board-status" class="board-status" aria-live="polite"><span class="status-dot"></span> Aktualisierung alle 30 Sekunden</div>
|
||||||
|
<div id="departures-mode-filter" class="mode-filter"></div>
|
||||||
|
<div id="station-disruption" class="station-disruption" hidden></div>
|
||||||
|
<div id="board-empty-state" class="board-empty-state">Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.</div>
|
||||||
|
<div id="departure-board" class="departure-board" role="table" aria-label="Abfahrtstafel"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-panel" data-tab-panel="map" hidden>
|
||||||
|
<div class="panel-heading"><div><span class="section-heading__eyebrow">LIVE</span><h1>Karte</h1><p>Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.</p></div></div>
|
||||||
|
<div class="map-controls">
|
||||||
|
<div id="map-mode-filter" class="mode-filter"></div>
|
||||||
|
<button id="map-locate" class="map-locate-btn" type="button">📍 Mein Standort</button>
|
||||||
|
</div>
|
||||||
|
<div id="map-isolation-banner" class="map-isolation-banner">
|
||||||
|
<span>Verfolge Linie <span id="map-isolation-line" class="map-isolation-banner__line"></span> live</span>
|
||||||
|
<button id="map-isolation-reset" type="button">Alle Linien anzeigen</button>
|
||||||
|
</div>
|
||||||
|
<div id="live-map" class="live-map"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-panel" data-tab-panel="journey" hidden>
|
||||||
|
<div class="panel-heading"><div><span class="section-heading__eyebrow">VERBINDUNG SUCHEN</span><h1>Wohin möchtest du?</h1><p>Starte mit deinem Standort oder suche einen Ausgangspunkt.</p></div></div>
|
||||||
|
<div class="journey-card-form">
|
||||||
|
<div class="journey-input-row">
|
||||||
|
<div class="journey-input-marker journey-input-marker--start">●</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="journey-from" placeholder="Mein Standort oder Start…" autocomplete="off" />
|
||||||
|
<ul id="journey-from-results" class="search-results" hidden></ul>
|
||||||
|
</div>
|
||||||
|
<button id="journey-use-location" class="location-btn" title="Meinen Standort verwenden" aria-label="Meinen Standort verwenden">📍</button>
|
||||||
|
</div>
|
||||||
|
<div class="journey-connector"></div>
|
||||||
|
<div class="journey-input-row">
|
||||||
|
<div class="journey-input-marker journey-input-marker--end">●</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="journey-to" placeholder="Wohin?" autocomplete="off" />
|
||||||
|
<ul id="journey-to-results" class="search-results" hidden></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="journey-when">
|
||||||
|
<button class="when-choice is-active" data-when="now" type="button">● Jetzt</button>
|
||||||
|
<button class="when-choice" data-when="departure" type="button">Abfahrt um</button>
|
||||||
|
<button class="when-choice" data-when="arrival" type="button">Ankunft um</button>
|
||||||
|
<input type="datetime-local" id="journey-when" class="journey-datetime" hidden />
|
||||||
|
</div>
|
||||||
|
<div class="journey-options-row">
|
||||||
|
<div id="journey-mode-filter" class="mode-filter"></div>
|
||||||
|
<button id="journey-submit" class="journey-submit" type="button">Verbindungen suchen <span>→</span></button>
|
||||||
|
</div>
|
||||||
|
<div id="journey-results" class="journey-results"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-panel" data-tab-panel="nearby" hidden>
|
||||||
|
<div class="panel-heading"><div><span class="section-heading__eyebrow">STANDORT</span><h1>In der Nähe</h1><p>Finde Haltestellen rund um deinen aktuellen Standort.</p></div></div>
|
||||||
|
<button id="nearby-locate" class="location-btn location-btn--wide">📍 Meinen Standort verwenden</button>
|
||||||
|
<div id="nearby-list" class="nearby-list"><div class="board-empty-state">Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-panel" data-tab-panel="disruptions" hidden>
|
||||||
|
<div class="panel-heading"><div><span class="section-heading__eyebrow">AKTUELL</span><h1>Störungen</h1><p>Aktuelle Hinweise und Einschränkungen im Netz.</p></div></div>
|
||||||
|
<div id="remarks-list" class="remarks-list"><div class="board-empty-state">Störungsmeldungen werden geladen…</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-panel" data-tab-panel="more" hidden>
|
||||||
|
<div class="panel-heading"><div><span class="section-heading__eyebrow">WEITERE FUNKTIONEN</span><h1>Mehr</h1><p>Alles, was nicht bei jeder Fahrt gebraucht wird.</p></div></div>
|
||||||
|
<div class="more-grid">
|
||||||
|
<button class="more-card" data-open-tab="nearby" type="button"><span>📍</span><strong>In der Nähe</strong><small>Haltestellen in deiner Umgebung</small></button>
|
||||||
|
<button class="more-card" data-open-tab="disruptions" type="button"><span>⚠</span><strong>Störungen</strong><small>Aktuelle Hinweise im VBB-Netz</small></button>
|
||||||
|
</div>
|
||||||
|
<div class="recent-section">
|
||||||
|
<div class="section-heading"><div><span class="section-heading__eyebrow">VERLAUF</span><h2>Zuletzt gesucht</h2></div><button id="clear-recent" class="text-button" type="button">Leeren</button></div>
|
||||||
|
<div id="recent-searches" class="recent-searches"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="mobile-nav" aria-label="Hauptnavigation mobil">
|
||||||
|
<button class="mobile-nav__item is-active" data-tab="departures"><span>🚏</span><small>Abfahrten</small></button>
|
||||||
|
<button class="mobile-nav__item" data-tab="journey"><span>🧭</span><small>Route</small></button>
|
||||||
|
<button class="mobile-nav__item" data-tab="map"><span>🗺</span><small>Karte</small></button>
|
||||||
|
<button class="mobile-nav__item" data-tab="more"><span>☰</span><small>Mehr</small></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/splitflap.js"></script>
|
||||||
|
<script src="js/modefilter.js"></script>
|
||||||
|
<script src="js/livemap.js"></script>
|
||||||
|
<script src="js/tripmodal.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Schlanker Wrapper um alle Aufrufe an das ÖPNV-Navi-Backend.
|
||||||
|
// Wirft bei Fehlern verständliche Errors, die die UI direkt anzeigen kann.
|
||||||
|
|
||||||
|
const API = (() => {
|
||||||
|
const BASE = window.APP_CONFIG.API_BASE;
|
||||||
|
|
||||||
|
async function request(path) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${BASE}${path}`);
|
||||||
|
} catch (networkErr) {
|
||||||
|
throw new Error('Backend nicht erreichbar. Prüfe deine Internetverbindung.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Fehler ${response.status}`;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.error) message = body.error;
|
||||||
|
} catch (_) {
|
||||||
|
// Antwort war kein JSON, Standardmeldung behalten
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function qs(params) {
|
||||||
|
const parts = [];
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === '') return;
|
||||||
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||||
|
});
|
||||||
|
return parts.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchLocations(query) {
|
||||||
|
return request(`/locations/search?query=${encodeURIComponent(query)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
nearby({ lat, lon, distance, results, includePoi }) {
|
||||||
|
return request(`/locations/nearby?${qs({ lat, lon, distance, results, includePoi })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getDepartures(stationId, { duration = 30, results = 15, modes } = {}) {
|
||||||
|
return request(`/departures/${encodeURIComponent(stationId)}?${qs({ duration, results, modes })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `from`/`to` sind entweder { id, name } (Haltestelle) oder
|
||||||
|
* { lat, lon, name } (Adresse/POI/GPS-Standort, wird als Adresse behandelt).
|
||||||
|
*/
|
||||||
|
getJourneys({ from, to, when, arrival, modes, polylines, results = 5 }) {
|
||||||
|
const fromParams = from.id
|
||||||
|
? { fromId: from.id }
|
||||||
|
: { fromLat: from.lat, fromLon: from.lon, fromAddress: from.name };
|
||||||
|
const toParams = to.id
|
||||||
|
? { toId: to.id }
|
||||||
|
: { toLat: to.lat, toLon: to.lon, toAddress: to.name };
|
||||||
|
|
||||||
|
return request(
|
||||||
|
`/journeys?${qs({
|
||||||
|
...fromParams,
|
||||||
|
...toParams,
|
||||||
|
when: when || undefined,
|
||||||
|
arrival: arrival ? 'true' : undefined,
|
||||||
|
modes,
|
||||||
|
polylines: polylines ? 'true' : undefined,
|
||||||
|
results,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTrip(tripId, { polyline = false } = {}) {
|
||||||
|
return request(`/trip/${encodeURIComponent(tripId)}?${qs({ polyline: polyline ? 'true' : undefined })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRemarks({ modes, results = 30 } = {}) {
|
||||||
|
return request(`/remarks?${qs({ modes, results })}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRadar({ north, west, south, east, modes, tripId }) {
|
||||||
|
return request(`/radar?${qs({ north, west, south, east, modes, tripId })}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
const App = (() => {
|
||||||
|
const FAVORITES_KEY = 'oepnv-navi:favorites';
|
||||||
|
const LAST_STOP_KEY = 'oepnv-navi:last-stop';
|
||||||
|
const RECENT_SEARCHES_KEY = 'oepnv-navi:recent-searches';
|
||||||
|
|
||||||
|
let currentStop = null;
|
||||||
|
let departuresPollHandle = null;
|
||||||
|
let departuresModeFilter = null;
|
||||||
|
let journeyModeFilter = null;
|
||||||
|
|
||||||
|
let journeyFrom = null;
|
||||||
|
let journeyTo = null;
|
||||||
|
let journeyArrivalMode = false;
|
||||||
|
let lastJourneyParams = null;
|
||||||
|
let remarksLoadedOnce = false;
|
||||||
|
|
||||||
|
function loadFavorites() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(FAVORITES_KEY)) || [];
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFavorites(favorites) {
|
||||||
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFavorite(stationId) {
|
||||||
|
return loadFavorites().some((f) => f.id === stationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecentSearches() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(RECENT_SEARCHES_KEY)) || []; }
|
||||||
|
catch (_) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRecentSearch(station) {
|
||||||
|
if (!station || !station.name) return;
|
||||||
|
const items = loadRecentSearches().filter((item) => item.id !== station.id);
|
||||||
|
items.unshift({ id: station.id, name: station.name, latitude: station.latitude, longitude: station.longitude });
|
||||||
|
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(items.slice(0, 6)));
|
||||||
|
renderRecentSearches();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentSearches() {
|
||||||
|
const container = document.getElementById('recent-searches');
|
||||||
|
if (!container) return;
|
||||||
|
const items = loadRecentSearches();
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = '<div class="recent-empty">Noch keine Suchen.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach((item) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'recent-item';
|
||||||
|
button.innerHTML = `<span>↺</span><strong>${escapeHtml(item.name)}</strong>`;
|
||||||
|
button.addEventListener('click', () => selectStation(item));
|
||||||
|
container.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite(station) {
|
||||||
|
const favorites = loadFavorites();
|
||||||
|
const idx = favorites.findIndex((f) => f.id === station.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
favorites.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
favorites.push(station);
|
||||||
|
}
|
||||||
|
saveFavorites(favorites);
|
||||||
|
renderFavoriteChips();
|
||||||
|
renderRecentSearches();
|
||||||
|
updateFavoriteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFavoriteChips() {
|
||||||
|
const container = document.getElementById('favorite-chips');
|
||||||
|
const cardContainer = document.getElementById('favorite-cards');
|
||||||
|
const favorites = loadFavorites();
|
||||||
|
if (container) container.innerHTML = '';
|
||||||
|
if (cardContainer) cardContainer.innerHTML = '';
|
||||||
|
|
||||||
|
favorites.forEach((fav) => {
|
||||||
|
if (container) {
|
||||||
|
const chip = document.createElement('button');
|
||||||
|
chip.className = 'chip';
|
||||||
|
chip.textContent = fav.name;
|
||||||
|
chip.addEventListener('click', () => selectStation(fav));
|
||||||
|
container.appendChild(chip);
|
||||||
|
}
|
||||||
|
if (cardContainer) {
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.className = 'favorite-card';
|
||||||
|
card.innerHTML = `<span class="favorite-card__star">★</span><span><strong>${escapeHtml(fav.name)}</strong><small>Abfahrten anzeigen</small></span><span class="favorite-card__arrow">→</span>`;
|
||||||
|
card.addEventListener('click', () => selectStation(fav));
|
||||||
|
cardContainer.appendChild(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const empty = document.getElementById('favorites-empty-hint');
|
||||||
|
if (empty) empty.hidden = favorites.length > 0;
|
||||||
|
const home = document.getElementById('favorites-home');
|
||||||
|
if (home) home.hidden = favorites.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFavoriteButton() {
|
||||||
|
const btn = document.getElementById('favorite-toggle');
|
||||||
|
if (!currentStop) {
|
||||||
|
btn.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.hidden = false;
|
||||||
|
const active = isFavorite(currentStop.id);
|
||||||
|
btn.textContent = active ? '★ Favorit' : '☆ Favorit';
|
||||||
|
btn.classList.toggle('is-active', active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserLocation() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
reject(new Error('Geolocation wird von diesem Browser nicht unterstützt.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
|
||||||
|
(err) => reject(new Error('Standort konnte nicht ermittelt werden: ' + err.message)),
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchDebounce = null;
|
||||||
|
|
||||||
|
function initSearch() {
|
||||||
|
const input = document.getElementById('station-search');
|
||||||
|
const results = document.getElementById('search-results');
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const clear = document.getElementById('search-clear');
|
||||||
|
if (clear) clear.hidden = input.value.length === 0;
|
||||||
|
clearTimeout(searchDebounce);
|
||||||
|
const query = input.value.trim();
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
results.hidden = true;
|
||||||
|
results.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchDebounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { locations } = await API.searchLocations(query);
|
||||||
|
const stops = locations.filter((l) => l.kind === 'stop');
|
||||||
|
renderSearchResults(results, stops, (station) => {
|
||||||
|
selectStation(station);
|
||||||
|
input.value = '';
|
||||||
|
results.hidden = true;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Suchfehler:', err.message);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
const clear = document.getElementById('search-clear');
|
||||||
|
if (clear) clear.addEventListener('click', () => { input.value = ''; results.hidden = true; clear.hidden = true; input.focus(); });
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.search-box')) {
|
||||||
|
results.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindIcon(kind) {
|
||||||
|
if (kind === 'address') return '📍';
|
||||||
|
if (kind === 'poi') return '⭐';
|
||||||
|
return '🚏';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchResults(container, locations, onSelect) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
container.innerHTML = '<li class="search-result search-result--empty">Keine Treffer</li>';
|
||||||
|
container.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
locations.forEach((loc) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'search-result';
|
||||||
|
li.innerHTML = `<span class="search-result__icon">${kindIcon(loc.kind)}</span> ${loc.name}`;
|
||||||
|
li.addEventListener('click', () => onSelect(loc));
|
||||||
|
container.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStation(station) {
|
||||||
|
currentStop = station;
|
||||||
|
localStorage.setItem(LAST_STOP_KEY, JSON.stringify({ id: station.id, name: station.name }));
|
||||||
|
saveRecentSearch(station);
|
||||||
|
activateTab('departures');
|
||||||
|
document.getElementById('board-station-name').textContent = station.name;
|
||||||
|
document.getElementById('board-empty-state').hidden = true;
|
||||||
|
document.getElementById('welcome-panel').hidden = true;
|
||||||
|
document.getElementById('departures-view').hidden = false;
|
||||||
|
updateFavoriteButton();
|
||||||
|
loadDepartures();
|
||||||
|
restartDeparturesPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDepartures() {
|
||||||
|
if (!currentStop) return;
|
||||||
|
const board = document.getElementById('departure-board');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { departures } = await API.getDepartures(currentStop.id, {
|
||||||
|
modes: departuresModeFilter ? departuresModeFilter.getModesParam() : null,
|
||||||
|
});
|
||||||
|
renderDepartureBoard(departures);
|
||||||
|
} catch (err) {
|
||||||
|
board.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderDepartureBoard(departures) {
|
||||||
|
const board = document.getElementById('departure-board');
|
||||||
|
|
||||||
|
if (departures.length === 0) {
|
||||||
|
board.innerHTML = '<div class="board-empty">Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = board.querySelectorAll('.flap-row');
|
||||||
|
if (existingRows.length !== departures.length) {
|
||||||
|
board.innerHTML = '';
|
||||||
|
departures.forEach((dep, i) => {
|
||||||
|
board.appendChild(buildDepartureRow(dep, i));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
departures.forEach((dep, i) => {
|
||||||
|
updateDepartureRow(board.children[i], dep);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDepartureRow(dep, index) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flap-row';
|
||||||
|
row.style.setProperty('--row-index', index);
|
||||||
|
row.setAttribute('role', 'button');
|
||||||
|
row.setAttribute('tabindex', '0');
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="flap-field flap-field--line">
|
||||||
|
<span class="flap-label">Linie</span>
|
||||||
|
<span class="flap-cell flap-cell--line" data-field="line"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flap-field flap-field--direction">
|
||||||
|
<span class="flap-label">Ziel</span>
|
||||||
|
<span class="flap-cell flap-cell--direction" data-field="direction"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flap-field flap-field--platform">
|
||||||
|
<span class="flap-label">Gleis</span>
|
||||||
|
<span class="flap-cell flap-cell--platform" data-field="platform"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flap-field flap-field--time">
|
||||||
|
<span class="flap-label">Abfahrt</span>
|
||||||
|
<span class="flap-cell flap-cell--time" data-field="time"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const openModal = () => {
|
||||||
|
if (row.dataset.tripId) TripModal.open(row.dataset.tripId);
|
||||||
|
};
|
||||||
|
row.addEventListener('click', openModal);
|
||||||
|
row.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDepartureRow(row, dep) {
|
||||||
|
row.dataset.tripId = dep.tripId || '';
|
||||||
|
|
||||||
|
const minutesUntil = dep.when
|
||||||
|
? Math.max(0, Math.round((new Date(dep.when) - new Date()) / 60000))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const timeText = dep.cancelled
|
||||||
|
? 'FAELLT AUS'
|
||||||
|
: minutesUntil !== null
|
||||||
|
? `${String(minutesUntil).padStart(2, '0')} MIN`
|
||||||
|
: '-- MIN';
|
||||||
|
|
||||||
|
row.classList.toggle('flap-row--delayed', !!dep.delay && dep.delay > 0);
|
||||||
|
row.classList.toggle('flap-row--cancelled', !!dep.cancelled);
|
||||||
|
|
||||||
|
const lineCell = row.querySelector('[data-field="line"]');
|
||||||
|
lineCell.style.setProperty('--line-color', dep.color || '#8E99A6');
|
||||||
|
lineCell.style.setProperty('--line-text-color', dep.textColor || '#fff');
|
||||||
|
|
||||||
|
SplitFlap.render(lineCell, (dep.line || '?').toUpperCase());
|
||||||
|
SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase());
|
||||||
|
SplitFlap.render(row.querySelector('[data-field="platform"]'), (dep.platform || '-').toUpperCase());
|
||||||
|
|
||||||
|
const timeCell = row.querySelector('[data-field="time"]');
|
||||||
|
if (dep.cancelled) {
|
||||||
|
const plannedTime = dep.plannedWhen
|
||||||
|
? new Date(dep.plannedWhen).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: '';
|
||||||
|
if (timeCell.dataset.cancelledPlanned !== plannedTime) {
|
||||||
|
timeCell.dataset.cancelledPlanned = plannedTime;
|
||||||
|
timeCell.classList.add('flap-cell--time-cancelled');
|
||||||
|
timeCell.innerHTML = `
|
||||||
|
<span class="cancelled-flag">FÄLLT AUS</span>
|
||||||
|
<span class="cancelled-original">urspr. ${plannedTime}</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete timeCell.dataset.cancelledPlanned;
|
||||||
|
timeCell.classList.remove('flap-cell--time-cancelled');
|
||||||
|
SplitFlap.render(timeCell, timeText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartDeparturesPolling() {
|
||||||
|
if (departuresPollHandle) clearInterval(departuresPollHandle);
|
||||||
|
departuresPollHandle = setInterval(loadDepartures, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
document.querySelectorAll('[data-tab]').forEach((tab) => {
|
||||||
|
tab.addEventListener('click', () => activateTab(tab.dataset.tab));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab-button, .mobile-nav__item').forEach((btn) => {
|
||||||
|
btn.classList.toggle('is-active', btn.dataset.tab === tabName);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-panel').forEach((panel) => {
|
||||||
|
panel.hidden = panel.dataset.tabPanel !== tabName;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tabName === 'map') {
|
||||||
|
LiveMap.startPolling();
|
||||||
|
} else {
|
||||||
|
LiveMap.stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabName === 'disruptions' && !remarksLoadedOnce) {
|
||||||
|
loadRemarks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNearby() {
|
||||||
|
document.getElementById('welcome-nearby').addEventListener('click', () => { activateTab('nearby'); document.getElementById('nearby-locate').click(); });
|
||||||
|
document.getElementById('welcome-route').addEventListener('click', () => activateTab('journey'));
|
||||||
|
document.getElementById('nearby-home-open').addEventListener('click', () => activateTab('nearby'));
|
||||||
|
document.getElementById('nearby-locate').addEventListener('click', async () => {
|
||||||
|
const listEl = document.getElementById('nearby-list');
|
||||||
|
listEl.innerHTML = '<div class="board-empty">Standort wird ermittelt…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { lat, lon } = await getUserLocation();
|
||||||
|
const { locations } = await API.nearby({ lat, lon, distance: 1000, results: 15 });
|
||||||
|
renderNearbyList(locations, listEl);
|
||||||
|
renderNearbyPreview(locations);
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNearbyPreview(locations) {
|
||||||
|
const container = document.getElementById('nearby-home-list');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
locations.slice(0, 3).forEach((loc) => {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'nearby-preview__item';
|
||||||
|
item.innerHTML = `<span><strong>${escapeHtml(loc.name)}</strong><small>🚏 Haltestelle</small></span><b>${loc.distance != null ? loc.distance + ' m' : '→'}</b>`;
|
||||||
|
item.addEventListener('click', () => { selectStation(loc); activateTab('departures'); });
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
document.getElementById('nearby-home').hidden = locations.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNearbyPreview() {
|
||||||
|
const home = document.getElementById('nearby-home');
|
||||||
|
const list = document.getElementById('nearby-home-list');
|
||||||
|
if (!navigator.geolocation || !home || !list) return;
|
||||||
|
list.innerHTML = '<div class="recent-empty">Standort wird ermittelt…</div>';
|
||||||
|
try {
|
||||||
|
const { lat, lon } = await getUserLocation();
|
||||||
|
const { locations } = await API.nearby({ lat, lon, distance: 1000, results: 3 });
|
||||||
|
renderNearbyPreview(locations);
|
||||||
|
} catch (_) {
|
||||||
|
home.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNearbyList(locations, container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
container.innerHTML = '<div class="board-empty">Keine Haltestellen in der Nähe gefunden.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
locations.forEach((loc) => {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'nearby-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="nearby-item__name">${loc.name}</span>
|
||||||
|
<span class="nearby-item__distance">${loc.distance != null ? loc.distance + ' m' : ''}</span>
|
||||||
|
`;
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
selectStation(loc);
|
||||||
|
activateTab('departures');
|
||||||
|
});
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRemarks() {
|
||||||
|
const container = document.getElementById('remarks-list');
|
||||||
|
container.innerHTML = '<div class="board-empty">Lade Störungsmeldungen…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { remarks } = await API.getRemarks({ results: 30 });
|
||||||
|
remarksLoadedOnce = true;
|
||||||
|
renderRemarks(remarks, container);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRemarks(remarks, container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (remarks.length === 0) {
|
||||||
|
container.innerHTML = '<div class="board-empty">Aktuell keine Störungsmeldungen bekannt.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remarks.forEach((r) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'remark-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="remark-card__summary">⚠ ${r.summary || 'Hinweis'}</div>
|
||||||
|
<div class="remark-card__text">${r.text || ''}</div>
|
||||||
|
<div class="remark-card__meta">${r.company || ''}</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function initJourneyPlanner() {
|
||||||
|
setupJourneyInput('journey-from', (loc) => (journeyFrom = loc));
|
||||||
|
setupJourneyInput('journey-to', (loc) => (journeyTo = loc));
|
||||||
|
|
||||||
|
document.getElementById('journey-use-location').addEventListener('click', async () => {
|
||||||
|
const input = document.getElementById('journey-from');
|
||||||
|
input.value = 'Standort wird ermittelt…';
|
||||||
|
try {
|
||||||
|
const { lat, lon } = await getUserLocation();
|
||||||
|
journeyFrom = { lat, lon, name: 'Mein Standort' };
|
||||||
|
input.value = 'Mein Standort';
|
||||||
|
} catch (err) {
|
||||||
|
input.value = '';
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.when-choice').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.when-choice').forEach((item) => item.classList.remove('is-active'));
|
||||||
|
button.classList.add('is-active');
|
||||||
|
const mode = button.dataset.when;
|
||||||
|
journeyArrivalMode = mode === 'arrival';
|
||||||
|
const when = document.getElementById('journey-when');
|
||||||
|
when.hidden = mode === 'now';
|
||||||
|
if (mode !== 'now' && !when.value) {
|
||||||
|
const d = new Date(Date.now() + 10 * 60000);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
when.value = new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0,16);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('journey-submit').addEventListener('click', async () => {
|
||||||
|
const resultsEl = document.getElementById('journey-results');
|
||||||
|
|
||||||
|
if (!journeyFrom || !journeyTo) {
|
||||||
|
resultsEl.innerHTML = '<div class="board-error">Bitte Start und Ziel aus der Liste auswählen.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.innerHTML = '<div class="board-empty">Suche Verbindungen…</div>';
|
||||||
|
|
||||||
|
const whenInput = document.getElementById('journey-when').value;
|
||||||
|
const params = {
|
||||||
|
from: journeyFrom,
|
||||||
|
to: journeyTo,
|
||||||
|
when: whenInput || undefined,
|
||||||
|
arrival: journeyArrivalMode,
|
||||||
|
modes: journeyModeFilter ? journeyModeFilter.getModesParam() : null,
|
||||||
|
results: 5,
|
||||||
|
};
|
||||||
|
lastJourneyParams = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { journeys } = await API.getJourneys(params);
|
||||||
|
renderJourneys(journeys, resultsEl);
|
||||||
|
} catch (err) {
|
||||||
|
resultsEl.innerHTML = `<div class="board-error">⚠ ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupJourneyInput(inputId, onSelect) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const results = document.getElementById(`${inputId}-results`);
|
||||||
|
let debounce = null;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
const query = input.value.trim();
|
||||||
|
if (query.length < 2) {
|
||||||
|
results.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounce = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { locations } = await API.searchLocations(query);
|
||||||
|
renderSearchResults(results, locations, (loc) => {
|
||||||
|
onSelect({ id: loc.id || undefined, lat: loc.latitude, lon: loc.longitude, name: loc.name });
|
||||||
|
input.value = loc.name;
|
||||||
|
results.hidden = true;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Suchfehler:', err.message);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest(`#${inputId}`) && !e.target.closest(`#${inputId}-results`)) {
|
||||||
|
results.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJourneys(journeys, container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (journeys.length === 0) {
|
||||||
|
container.innerHTML = '<div class="board-empty">Keine Verbindungen gefunden.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
journeys.forEach((journey, journeyIndex) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'journey-card';
|
||||||
|
|
||||||
|
const legsHtml = journey.legs
|
||||||
|
.map((leg) => {
|
||||||
|
const depTime = leg.departure ? new Date(leg.departure).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
const arrTime = leg.arrival ? new Date(leg.arrival).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
const lineLabel = leg.walking ? 'Fußweg' : leg.line;
|
||||||
|
const lineStyle = leg.walking
|
||||||
|
? ''
|
||||||
|
: `style="background:${leg.color || '#8E99A6'};color:${leg.textColor || '#fff'};"`;
|
||||||
|
const trackBtn = !leg.walking && leg.tripId
|
||||||
|
? `<button class="leg-track-btn" data-trip-id="${leg.tripId}" data-action="live">📡 Live</button>`
|
||||||
|
: '';
|
||||||
|
const infoBtn = !leg.walking && leg.tripId
|
||||||
|
? `<button class="leg-track-btn" data-trip-id="${leg.tripId}" data-action="info">ℹ Details</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="journey-leg ${leg.walking ? 'journey-leg--walk' : ''}">
|
||||||
|
<span class="journey-leg__line" ${lineStyle}>${lineLabel}</span>
|
||||||
|
<span class="journey-leg__route">${leg.origin || ''} → ${leg.destination || ''}</span>
|
||||||
|
<span class="journey-leg__time">${depTime} – ${arrTime}</span>
|
||||||
|
${infoBtn}
|
||||||
|
${trackBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
${legsHtml}
|
||||||
|
<button class="journey-map-toggle" data-journey-index="${journeyIndex}">🗺️ Streckenverlauf anzeigen</button>
|
||||||
|
<div class="journey-mini-map" id="journey-mini-map-${journeyIndex}"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.querySelector('.journey-map-toggle').addEventListener('click', (e) => {
|
||||||
|
toggleJourneyMiniMap(journeyIndex, journey, e.currentTarget);
|
||||||
|
});
|
||||||
|
card.querySelectorAll('.leg-track-btn[data-action="live"]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
LiveMap.trackTrip(btn.dataset.tripId);
|
||||||
|
activateTab('map');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
card.querySelectorAll('.leg-track-btn[data-action="info"]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => TripModal.open(btn.dataset.tripId));
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let openMiniMapIndex = null;
|
||||||
|
let miniMapInstance = null;
|
||||||
|
|
||||||
|
async function toggleJourneyMiniMap(journeyIndex, journeyFallback, button) {
|
||||||
|
const mapEl = document.getElementById(`journey-mini-map-${journeyIndex}`);
|
||||||
|
|
||||||
|
if (openMiniMapIndex === journeyIndex) {
|
||||||
|
mapEl.classList.remove('is-open');
|
||||||
|
if (miniMapInstance) { miniMapInstance.remove(); miniMapInstance = null; }
|
||||||
|
openMiniMapIndex = null;
|
||||||
|
button.textContent = '🗺️ Streckenverlauf anzeigen';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openMiniMapIndex !== null) {
|
||||||
|
const prevMapEl = document.getElementById(`journey-mini-map-${openMiniMapIndex}`);
|
||||||
|
if (prevMapEl) prevMapEl.classList.remove('is-open');
|
||||||
|
if (miniMapInstance) { miniMapInstance.remove(); miniMapInstance = null; }
|
||||||
|
const prevButton = document.querySelector(`.journey-map-toggle[data-journey-index="${openMiniMapIndex}"]`);
|
||||||
|
if (prevButton) prevButton.textContent = '🗺️ Streckenverlauf anzeigen';
|
||||||
|
}
|
||||||
|
|
||||||
|
openMiniMapIndex = journeyIndex;
|
||||||
|
mapEl.classList.add('is-open');
|
||||||
|
button.textContent = '🗺️ Streckenverlauf ausblenden';
|
||||||
|
|
||||||
|
let journeyWithPolylines = journeyFallback;
|
||||||
|
if (lastJourneyParams) {
|
||||||
|
try {
|
||||||
|
const { journeys } = await API.getJourneys({
|
||||||
|
...lastJourneyParams,
|
||||||
|
polylines: true,
|
||||||
|
results: journeyIndex + 1,
|
||||||
|
});
|
||||||
|
if (journeys[journeys.length - 1]) journeyWithPolylines = journeys[journeys.length - 1];
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback: ohne Polylines, zeigt dann nur die Marker-Punkte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
miniMapInstance = LiveMap.createMiniMap(mapEl.id, journeyWithPolylines);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreLastStop() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(LAST_STOP_KEY));
|
||||||
|
if (saved && saved.id) selectStation(saved);
|
||||||
|
} catch (_) {
|
||||||
|
// kein gespeicherter Stop, ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initSearch();
|
||||||
|
initTabs();
|
||||||
|
initJourneyPlanner();
|
||||||
|
initNearby();
|
||||||
|
renderFavoriteChips();
|
||||||
|
renderRecentSearches();
|
||||||
|
|
||||||
|
departuresModeFilter = ModeFilter.create(
|
||||||
|
document.getElementById('departures-mode-filter'),
|
||||||
|
'oepnv-navi:modes:departures',
|
||||||
|
() => loadDepartures(),
|
||||||
|
);
|
||||||
|
journeyModeFilter = ModeFilter.create(
|
||||||
|
document.getElementById('journey-mode-filter'),
|
||||||
|
'oepnv-navi:modes:journey',
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapModeFilter = ModeFilter.create(
|
||||||
|
document.getElementById('map-mode-filter'),
|
||||||
|
'oepnv-navi:modes:map',
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
LiveMap.setModeFilter(() => mapModeFilter.getModesParam());
|
||||||
|
document.getElementById('map-locate').addEventListener('click', () => LiveMap.locateUser());
|
||||||
|
document.getElementById('map-isolation-reset').addEventListener('click', () => LiveMap.clearIsolation());
|
||||||
|
|
||||||
|
document.getElementById('clear-recent').addEventListener('click', () => { localStorage.removeItem(RECENT_SEARCHES_KEY); renderRecentSearches(); });
|
||||||
|
document.querySelectorAll('[data-open-tab]').forEach((button) => button.addEventListener('click', () => activateTab(button.dataset.openTab)));
|
||||||
|
|
||||||
|
document.getElementById('favorite-toggle').addEventListener('click', () => {
|
||||||
|
if (currentStop) toggleFavorite(currentStop);
|
||||||
|
});
|
||||||
|
|
||||||
|
restoreLastStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', App.init);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Zentrale Konfiguration der Web-App.
|
||||||
|
// API_BASE zeigt auf das Backend hinter deinem NPM-Reverse-Proxy.
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
API_BASE: 'https://serverlele.ddns.net/live_navi',
|
||||||
|
};
|
||||||
+321
@@ -0,0 +1,321 @@
|
|||||||
|
// Live-Karte mit mehreren Modi:
|
||||||
|
// - "radar": zeigt alle Fahrzeuge im aktuellen Kartenausschnitt (Standard),
|
||||||
|
// gefiltert nach den gewählten Verkehrsmitteln
|
||||||
|
// - "isolated": ein Fahrzeug wurde angeklickt -> nur dieses wird live weiter-
|
||||||
|
// verfolgt (serverseitig via tripId gefiltert), sein Streckenverlauf
|
||||||
|
// wird eingezeichnet, alle anderen Fahrzeuge sind ausgeblendet
|
||||||
|
// - "trip": verfolgt ein bekanntes Fahrzeug (z.B. aus einer Verbindung heraus)
|
||||||
|
// Nutzt bewusst reines OpenStreetMap ohne zusätzlichen Kartenstil.
|
||||||
|
// Zusätzlich: createMiniMap() für kleine, unabhängige Vorschau-Karten in den
|
||||||
|
// Verbindungsergebnissen (Routenplaner).
|
||||||
|
|
||||||
|
const LiveMap = (() => {
|
||||||
|
let map = null;
|
||||||
|
let radarLayer = null;
|
||||||
|
let routeLayer = null;
|
||||||
|
let userLayer = null;
|
||||||
|
let tripMarker = null;
|
||||||
|
|
||||||
|
let mode = 'radar';
|
||||||
|
let radarPollHandle = null;
|
||||||
|
let tripPollHandle = null;
|
||||||
|
let currentTripId = null;
|
||||||
|
let isolatedVehicleMeta = null;
|
||||||
|
let getModesParam = () => null;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (map) return;
|
||||||
|
|
||||||
|
map = L.map('live-map', { zoomControl: true }).setView([52.5200, 13.4050], 13);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
radarLayer = L.layerGroup().addTo(map);
|
||||||
|
routeLayer = L.layerGroup().addTo(map);
|
||||||
|
userLayer = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
|
map.on('moveend', () => {
|
||||||
|
if (mode === 'radar') refreshRadar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModeFilter(fn) {
|
||||||
|
getModesParam = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicleIcon(label, bg, fg) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'vehicle-marker',
|
||||||
|
html: `<span style="background:${bg || '#F5A623'};color:${fg || '#fff'};">${label || '?'}</span>`,
|
||||||
|
iconSize: [null, null],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylineToLatLngs(geojson) {
|
||||||
|
if (!geojson || !geojson.features) return [];
|
||||||
|
return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRadar() {
|
||||||
|
if (!map || mode !== 'radar') return;
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await API.getRadar({
|
||||||
|
north: bounds.getNorth().toFixed(5),
|
||||||
|
west: bounds.getWest().toFixed(5),
|
||||||
|
south: bounds.getSouth().toFixed(5),
|
||||||
|
east: bounds.getEast().toFixed(5),
|
||||||
|
modes: getModesParam(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Radar-Fehler:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
radarLayer.clearLayers();
|
||||||
|
data.vehicles.forEach((v) => {
|
||||||
|
if (v.latitude == null || v.longitude == null) return;
|
||||||
|
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
||||||
|
marker.bindPopup(
|
||||||
|
`<strong>${v.line || 'Linie unbekannt'}</strong><br>${v.direction || ''}<br>` +
|
||||||
|
`<small>Nächster Halt: ${v.nextStopover || '–'}</small>`,
|
||||||
|
);
|
||||||
|
marker.on('click', () => isolateVehicle(v));
|
||||||
|
marker.addTo(radarLayer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRadar() {
|
||||||
|
init();
|
||||||
|
setMode('radar');
|
||||||
|
refreshRadar();
|
||||||
|
if (radarPollHandle) clearInterval(radarPollHandle);
|
||||||
|
radarPollHandle = setInterval(refreshRadar, 15000);
|
||||||
|
setTimeout(() => map && map.invalidateSize(), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRadar() {
|
||||||
|
if (radarPollHandle) {
|
||||||
|
clearInterval(radarPollHandle);
|
||||||
|
radarPollHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshIsolated() {
|
||||||
|
if (!map || mode !== 'isolated' || !currentTripId) return;
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await API.getRadar({
|
||||||
|
north: bounds.getNorth().toFixed(5),
|
||||||
|
west: bounds.getWest().toFixed(5),
|
||||||
|
south: bounds.getSouth().toFixed(5),
|
||||||
|
east: bounds.getEast().toFixed(5),
|
||||||
|
tripId: currentTripId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Radar-Fehler (isoliert):', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
radarLayer.clearLayers();
|
||||||
|
const v = data.vehicles[0];
|
||||||
|
if (v && v.latitude != null && v.longitude != null) {
|
||||||
|
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
||||||
|
marker.bindPopup(`<strong>${v.line || ''}</strong><br>${v.direction || ''}<br><small>Nächster Halt: ${v.nextStopover || '–'}</small>`);
|
||||||
|
marker.addTo(radarLayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isolateVehicle(vehicle) {
|
||||||
|
if (!vehicle || !vehicle.tripId) return;
|
||||||
|
setMode('isolated');
|
||||||
|
currentTripId = vehicle.tripId;
|
||||||
|
isolatedVehicleMeta = { line: vehicle.line, color: vehicle.color, textColor: vehicle.textColor };
|
||||||
|
updateIsolationBanner();
|
||||||
|
|
||||||
|
routeLayer.clearLayers();
|
||||||
|
try {
|
||||||
|
const trip = await API.getTrip(vehicle.tripId, { polyline: true });
|
||||||
|
if (trip.polyline) {
|
||||||
|
const latlngs = polylineToLatLngs(trip.polyline);
|
||||||
|
if (latlngs.length > 0) {
|
||||||
|
L.polyline(latlngs, { color: vehicle.color || '#F5A623', weight: 5, opacity: 0.85 }).addTo(routeLayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Trip-Polyline-Fehler:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshIsolated();
|
||||||
|
if (radarPollHandle) clearInterval(radarPollHandle);
|
||||||
|
radarPollHandle = setInterval(refreshIsolated, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIsolation() {
|
||||||
|
currentTripId = null;
|
||||||
|
isolatedVehicleMeta = null;
|
||||||
|
updateIsolationBanner();
|
||||||
|
startRadar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIsolationBanner() {
|
||||||
|
const banner = document.getElementById('map-isolation-banner');
|
||||||
|
if (!banner) return;
|
||||||
|
if (!isolatedVehicleMeta) {
|
||||||
|
banner.classList.remove('is-visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lineEl = document.getElementById('map-isolation-line');
|
||||||
|
if (lineEl) {
|
||||||
|
lineEl.textContent = isolatedVehicleMeta.line || '?';
|
||||||
|
lineEl.style.background = isolatedVehicleMeta.color || '#F5A623';
|
||||||
|
lineEl.style.color = isolatedVehicleMeta.textColor || '#fff';
|
||||||
|
}
|
||||||
|
banner.classList.add('is-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTrip() {
|
||||||
|
if (!currentTripId) return;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await API.getTrip(currentTripId, { polyline: !tripMarker });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Trip-Fehler:', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.polyline && routeLayer) {
|
||||||
|
routeLayer.clearLayers();
|
||||||
|
const latlngs = polylineToLatLngs(data.polyline);
|
||||||
|
if (latlngs.length > 0) {
|
||||||
|
L.polyline(latlngs, { color: data.color || '#F5A623', weight: 4, opacity: 0.7 }).addTo(routeLayer);
|
||||||
|
map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.currentLocation) {
|
||||||
|
const pos = [data.currentLocation.latitude, data.currentLocation.longitude];
|
||||||
|
if (tripMarker) {
|
||||||
|
tripMarker.setLatLng(pos);
|
||||||
|
} else {
|
||||||
|
tripMarker = L.marker(pos, { icon: vehicleIcon(data.line, data.color, data.textColor) }).addTo(routeLayer);
|
||||||
|
}
|
||||||
|
tripMarker.bindPopup(`<strong>${data.line || ''}</strong><br>→ ${data.direction || ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackTrip(tripId) {
|
||||||
|
init();
|
||||||
|
setMode('trip');
|
||||||
|
currentTripId = tripId;
|
||||||
|
tripMarker = null;
|
||||||
|
routeLayer.clearLayers();
|
||||||
|
|
||||||
|
refreshTrip();
|
||||||
|
if (tripPollHandle) clearInterval(tripPollHandle);
|
||||||
|
tripPollHandle = setInterval(refreshTrip, 10000);
|
||||||
|
setTimeout(() => map && map.invalidateSize(), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTracking() {
|
||||||
|
if (tripPollHandle) {
|
||||||
|
clearInterval(tripPollHandle);
|
||||||
|
tripPollHandle = null;
|
||||||
|
}
|
||||||
|
currentTripId = null;
|
||||||
|
tripMarker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locateUser() {
|
||||||
|
init();
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert('Geolocation wird von diesem Browser nicht unterstützt.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const { latitude, longitude } = pos.coords;
|
||||||
|
userLayer.clearLayers();
|
||||||
|
L.marker([latitude, longitude], {
|
||||||
|
icon: L.divIcon({ className: 'user-location-marker', iconSize: [16, 16] }),
|
||||||
|
}).addTo(userLayer);
|
||||||
|
map.setView([latitude, longitude], 16);
|
||||||
|
},
|
||||||
|
(err) => alert('Standort konnte nicht ermittelt werden: ' + err.message),
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(newMode) {
|
||||||
|
if (mode === newMode) return;
|
||||||
|
if (mode === 'radar') stopRadar();
|
||||||
|
if (mode === 'isolated') stopRadar();
|
||||||
|
if (mode === 'trip') stopTracking();
|
||||||
|
if (newMode === 'radar') routeLayer.clearLayers();
|
||||||
|
mode = newMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (mode === 'radar' || mode === 'idle') startRadar();
|
||||||
|
else if (map) setTimeout(() => map.invalidateSize(), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
stopRadar();
|
||||||
|
stopTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMiniMap(containerId, journey) {
|
||||||
|
const miniMap = L.map(containerId, {
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: false,
|
||||||
|
dragging: true,
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
tap: true,
|
||||||
|
}).setView([52.5200, 13.4050], 12);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap-Mitwirkende',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(miniMap);
|
||||||
|
|
||||||
|
const allPoints = [];
|
||||||
|
(journey.legs || []).forEach((leg) => {
|
||||||
|
if (!leg.polyline) return;
|
||||||
|
const latlngs = polylineToLatLngs(leg.polyline);
|
||||||
|
if (latlngs.length === 0) return;
|
||||||
|
L.polyline(latlngs, {
|
||||||
|
color: leg.walking ? '#8E99A6' : leg.color || '#F5A623',
|
||||||
|
weight: leg.walking ? 3 : 4,
|
||||||
|
dashArray: leg.walking ? '5, 7' : null,
|
||||||
|
opacity: 0.9,
|
||||||
|
}).addTo(miniMap);
|
||||||
|
allPoints.push(...latlngs);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allPoints.length > 0) {
|
||||||
|
miniMap.fitBounds(L.latLngBounds(allPoints), { padding: [16, 16] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => miniMap.invalidateSize(), 50);
|
||||||
|
return miniMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
trackTrip,
|
||||||
|
setModeFilter,
|
||||||
|
clearIsolation,
|
||||||
|
locateUser,
|
||||||
|
createMiniMap,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Wiederverwendbare Verkehrsmittel-Filter-Chips. Wird sowohl für die
|
||||||
|
// Abfahrtstafel als auch für den Routenplaner genutzt (zwei unabhängige Instanzen).
|
||||||
|
|
||||||
|
const ModeFilter = (() => {
|
||||||
|
const LABELS = {
|
||||||
|
suburban: 'S',
|
||||||
|
subway: 'U',
|
||||||
|
tram: 'Tram',
|
||||||
|
bus: 'Bus',
|
||||||
|
ferry: 'Fähre',
|
||||||
|
express: 'Fern',
|
||||||
|
regional: 'RE/RB',
|
||||||
|
};
|
||||||
|
const ALL = Object.keys(LABELS);
|
||||||
|
|
||||||
|
function create(container, storageKey, onChange) {
|
||||||
|
let selected = loadState();
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(storageKey));
|
||||||
|
if (Array.isArray(saved) && saved.length) return new Set(saved);
|
||||||
|
} catch (_) {
|
||||||
|
// ignorieren, Standard verwenden
|
||||||
|
}
|
||||||
|
return new Set(ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify([...selected]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container.innerHTML = '';
|
||||||
|
ALL.forEach((mode) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'mode-chip' + (selected.has(mode) ? ' is-active' : '');
|
||||||
|
btn.textContent = LABELS[mode];
|
||||||
|
btn.setAttribute('aria-pressed', selected.has(mode) ? 'true' : 'false');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (selected.has(mode)) {
|
||||||
|
selected.delete(mode);
|
||||||
|
} else {
|
||||||
|
selected.add(mode);
|
||||||
|
}
|
||||||
|
if (selected.size === 0) selected = new Set(ALL);
|
||||||
|
persist();
|
||||||
|
render();
|
||||||
|
onChange(getModesParam());
|
||||||
|
});
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModesParam() {
|
||||||
|
if (selected.size === ALL.length) return null;
|
||||||
|
return [...selected].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
return { getModesParam };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { create };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Simuliert das Umklappen einer mechanischen Bahnhofs-Anzeigetafel (Solari-Board).
|
||||||
|
// Jedes Zeichen "rattert" kurz durch ein paar Zufallszeichen, bevor es sich auf
|
||||||
|
// den Zielwert setzt - versetzt pro Zeichen, damit es wie eine echte Tafel wirkt.
|
||||||
|
|
||||||
|
const SplitFlap = (() => {
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :+-.äöüÄÖÜß';
|
||||||
|
const STEPS_PER_CHAR = 6;
|
||||||
|
const STEP_DELAY_MS = 28;
|
||||||
|
const STAGGER_MS = 18;
|
||||||
|
|
||||||
|
function randomChar() {
|
||||||
|
return CHARSET[Math.floor(Math.random() * CHARSET.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(el, text) {
|
||||||
|
const chars = text.split('');
|
||||||
|
const existing = el.querySelectorAll('.flap-char');
|
||||||
|
|
||||||
|
if (existing.length !== chars.length) {
|
||||||
|
el.innerHTML = '';
|
||||||
|
chars.forEach(() => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'flap-char';
|
||||||
|
span.textContent = ' ';
|
||||||
|
el.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const spans = el.querySelectorAll('.flap-char');
|
||||||
|
|
||||||
|
chars.forEach((targetChar, i) => {
|
||||||
|
const span = spans[i];
|
||||||
|
if (span.dataset.value === targetChar) return;
|
||||||
|
span.dataset.value = targetChar;
|
||||||
|
|
||||||
|
let step = 0;
|
||||||
|
const delay = i * STAGGER_MS;
|
||||||
|
setTimeout(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
step += 1;
|
||||||
|
span.textContent = step >= STEPS_PER_CHAR ? targetChar : randomChar();
|
||||||
|
span.classList.add('flap-char--flipping');
|
||||||
|
if (step >= STEPS_PER_CHAR) {
|
||||||
|
clearInterval(interval);
|
||||||
|
span.classList.remove('flap-char--flipping');
|
||||||
|
}
|
||||||
|
}, STEP_DELAY_MS);
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { render };
|
||||||
|
})();
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
// Modal, das beim Klick auf eine Abfahrtszeile oder einen Verbindungsabschnitt
|
||||||
|
// geöffnet wird: zeigt Linie, Richtung, eine kleine Karte mit dem Streckenverlauf
|
||||||
|
// und alle Zwischenhalte mit Zeiten/Verspätungen.
|
||||||
|
|
||||||
|
const TripModal = (() => {
|
||||||
|
let backdrop = null;
|
||||||
|
let miniMap = null;
|
||||||
|
|
||||||
|
function ensureBackdrop() {
|
||||||
|
if (backdrop) return backdrop;
|
||||||
|
backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'trip-modal-backdrop';
|
||||||
|
backdrop.hidden = true;
|
||||||
|
backdrop.innerHTML = `
|
||||||
|
<div class="trip-modal" role="dialog" aria-modal="true" aria-label="Fahrt-Details">
|
||||||
|
<div class="trip-modal__header">
|
||||||
|
<span class="trip-modal__line" id="trip-modal-line"></span>
|
||||||
|
<div class="trip-modal__title">
|
||||||
|
<strong id="trip-modal-direction"></strong>
|
||||||
|
<small id="trip-modal-meta"></small>
|
||||||
|
</div>
|
||||||
|
<button class="trip-modal__close" type="button" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="trip-modal__map" id="trip-modal-map"></div>
|
||||||
|
<div class="trip-modal__body" id="trip-modal-body">
|
||||||
|
<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
|
backdrop.querySelector('.trip-modal__close').addEventListener('click', close);
|
||||||
|
backdrop.addEventListener('click', (e) => {
|
||||||
|
if (e.target === backdrop) close();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && !backdrop.hidden) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
return backdrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!backdrop) return;
|
||||||
|
backdrop.hidden = true;
|
||||||
|
if (miniMap) {
|
||||||
|
miniMap.remove();
|
||||||
|
miniMap = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStopover(s, isCurrentOrNext) {
|
||||||
|
const time = s.departure || s.arrival || s.plannedDeparture || s.plannedArrival;
|
||||||
|
const plannedTime = s.plannedDeparture || s.plannedArrival;
|
||||||
|
const delay = s.departureDelay ?? s.arrivalDelay;
|
||||||
|
|
||||||
|
let timeHtml;
|
||||||
|
if (s.cancelled) {
|
||||||
|
timeHtml = `<span class="trip-stopover__time trip-stopover__time--cancelled">${formatTime(plannedTime) || '–'}</span>`;
|
||||||
|
} else if (delay && delay > 0) {
|
||||||
|
timeHtml = `<span class="trip-stopover__time trip-stopover__time--delayed">${formatTime(time)} (+${Math.round(delay / 60)})</span>`;
|
||||||
|
} else {
|
||||||
|
timeHtml = `<span class="trip-stopover__time">${formatTime(time) || '–'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="trip-stopover ${isCurrentOrNext ? 'trip-stopover--current' : ''} ${s.cancelled ? 'trip-stopover--cancelled' : ''}">
|
||||||
|
<div class="trip-stopover__row">
|
||||||
|
<span class="trip-stopover__name">${s.name || ''}</span>
|
||||||
|
${timeHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(tripId) {
|
||||||
|
if (!tripId) return;
|
||||||
|
const el = ensureBackdrop();
|
||||||
|
el.hidden = false;
|
||||||
|
|
||||||
|
document.getElementById('trip-modal-line').textContent = '…';
|
||||||
|
document.getElementById('trip-modal-line').style.background = '#8E99A6';
|
||||||
|
document.getElementById('trip-modal-direction').textContent = '';
|
||||||
|
document.getElementById('trip-modal-meta').textContent = '';
|
||||||
|
document.getElementById('trip-modal-body').innerHTML = '<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>';
|
||||||
|
|
||||||
|
let trip;
|
||||||
|
try {
|
||||||
|
trip = await API.getTrip(tripId, { polyline: true });
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('trip-modal-body').innerHTML = `<div class="trip-modal__error">⚠ ${err.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineEl = document.getElementById('trip-modal-line');
|
||||||
|
lineEl.textContent = trip.line || '?';
|
||||||
|
lineEl.style.background = trip.color || '#8E99A6';
|
||||||
|
lineEl.style.color = trip.textColor || '#fff';
|
||||||
|
|
||||||
|
document.getElementById('trip-modal-direction').textContent = `→ ${trip.direction || trip.destination || ''}`;
|
||||||
|
document.getElementById('trip-modal-meta').textContent = trip.cancelled
|
||||||
|
? 'Fahrt fällt aus'
|
||||||
|
: `${trip.origin || ''} → ${trip.destination || ''}`;
|
||||||
|
|
||||||
|
if (miniMap) { miniMap.remove(); miniMap = null; }
|
||||||
|
if (trip.polyline) {
|
||||||
|
setTimeout(() => {
|
||||||
|
miniMap = LiveMap.createMiniMap('trip-modal-map', {
|
||||||
|
legs: [{ polyline: trip.polyline, walking: false, color: trip.color }],
|
||||||
|
});
|
||||||
|
if (trip.currentLocation) {
|
||||||
|
L.marker([trip.currentLocation.latitude, trip.currentLocation.longitude], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: 'vehicle-marker',
|
||||||
|
html: `<span style="background:${trip.color || '#F5A623'};color:${trip.textColor || '#fff'};">${trip.line || '?'}</span>`,
|
||||||
|
iconSize: [null, null],
|
||||||
|
}),
|
||||||
|
}).addTo(miniMap);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
document.getElementById('trip-modal-map').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopovers = trip.stopovers || [];
|
||||||
|
const now = Date.now();
|
||||||
|
let currentIndex = stopovers.findIndex((s) => {
|
||||||
|
const t = s.departure || s.plannedDeparture;
|
||||||
|
return t && new Date(t).getTime() >= now;
|
||||||
|
});
|
||||||
|
if (currentIndex === -1) currentIndex = stopovers.length - 1;
|
||||||
|
|
||||||
|
const bodyHtml = `
|
||||||
|
<div class="trip-stopovers">
|
||||||
|
${stopovers.map((s, i) => renderStopover(s, i === currentIndex)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('trip-modal-body').innerHTML = stopovers.length
|
||||||
|
? bodyHtml
|
||||||
|
: '<div class="trip-modal__loading">Keine Zwischenhalte verfügbar.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { open, close };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "ÖPNV Navi",
|
||||||
|
"short_name": "ÖPNV Navi",
|
||||||
|
"start_url": "./index.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0C1116",
|
||||||
|
"theme_color": "#0C1116",
|
||||||
|
"icons": []
|
||||||
|
}
|
||||||
+1253
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user