diff --git a/index.html b/index.html
new file mode 100644
index 0000000..7691467
--- /dev/null
+++ b/index.html
@@ -0,0 +1,176 @@
+
+
+
+
+
+ ÖPNV Navi – Live-Abfahrten für Berlin & Brandenburg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
LIVE · VBB
+
Was fährt jetzt?
+
Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.
+
+
+
+
+
+
+
+
+
SCHNELLZUGRIFF
Meine Haltestellen
+
+
+
+
+
+
+
OPTIONAL
Haltestellen in der Nähe
+
+
+
+
+
+
+
+
Aktualisierung alle 30 Sekunden
+
+
+
Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.
+
+
+
+
+
+ LIVEKarte
Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.
+
+
+ Verfolge Linie live
+
+
+
+
+
+
+ VERBINDUNG SUCHENWohin möchtest du?
Starte mit deinem Standort oder suche einen Ausgangspunkt.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ STANDORTIn der Nähe
Finde Haltestellen rund um deinen aktuellen Standort.
+
+ Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.
+
+
+
+ AKTUELLStörungen
Aktuelle Hinweise und Einschränkungen im Netz.
+
+
+
+
+ WEITERE FUNKTIONENMehr
Alles, was nicht bei jeder Fahrt gebraucht wird.
+
+
+
+
+
+
VERLAUF
Zuletzt gesucht
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/js/api.js b/js/api.js
new file mode 100644
index 0000000..d0f9979
--- /dev/null
+++ b/js/api.js
@@ -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 })}`);
+ },
+ };
+})();
\ No newline at end of file
diff --git a/js/app.js b/js/app.js
new file mode 100644
index 0000000..f5f91a4
--- /dev/null
+++ b/js/app.js
@@ -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 = 'Noch keine Suchen.
';
+ return;
+ }
+ items.forEach((item) => {
+ const button = document.createElement('button');
+ button.className = 'recent-item';
+ button.innerHTML = `↺${escapeHtml(item.name)}`;
+ 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 = `★${escapeHtml(fav.name)}Abfahrten anzeigen→`;
+ 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 = 'Keine Treffer';
+ container.hidden = false;
+ return;
+ }
+
+ locations.forEach((loc) => {
+ const li = document.createElement('li');
+ li.className = 'search-result';
+ li.innerHTML = `${kindIcon(loc.kind)} ${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 = `⚠ ${err.message}
`;
+ }
+ }
+ function renderDepartureBoard(departures) {
+ const board = document.getElementById('departure-board');
+
+ if (departures.length === 0) {
+ board.innerHTML = 'Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.
';
+ 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 = `
+
+ Linie
+
+
+
+ Ziel
+
+
+
+ Gleis
+
+
+
+ Abfahrt
+
+
+ `;
+ 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 = `
+ FÄLLT AUS
+ urspr. ${plannedTime}
+ `;
+ }
+ } 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 = 'Standort wird ermittelt…
';
+
+ 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 = `⚠ ${err.message}
`;
+ }
+ });
+ }
+
+ 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 = `${escapeHtml(loc.name)}🚏 Haltestelle${loc.distance != null ? loc.distance + ' m' : '→'}`;
+ 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 = 'Standort wird ermittelt…
';
+ 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 = 'Keine Haltestellen in der Nähe gefunden.
';
+ return;
+ }
+
+ locations.forEach((loc) => {
+ const item = document.createElement('button');
+ item.className = 'nearby-item';
+ item.innerHTML = `
+ ${loc.name}
+ ${loc.distance != null ? loc.distance + ' m' : ''}
+ `;
+ item.addEventListener('click', () => {
+ selectStation(loc);
+ activateTab('departures');
+ });
+ container.appendChild(item);
+ });
+ }
+
+ async function loadRemarks() {
+ const container = document.getElementById('remarks-list');
+ container.innerHTML = 'Lade Störungsmeldungen…
';
+
+ try {
+ const { remarks } = await API.getRemarks({ results: 30 });
+ remarksLoadedOnce = true;
+ renderRemarks(remarks, container);
+ } catch (err) {
+ container.innerHTML = `⚠ ${err.message}
`;
+ }
+ }
+
+ function renderRemarks(remarks, container) {
+ container.innerHTML = '';
+
+ if (remarks.length === 0) {
+ container.innerHTML = 'Aktuell keine Störungsmeldungen bekannt.
';
+ return;
+ }
+
+ remarks.forEach((r) => {
+ const card = document.createElement('div');
+ card.className = 'remark-card';
+ card.innerHTML = `
+
+
+
+ `;
+ 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 = 'Bitte Start und Ziel aus der Liste auswählen.
';
+ return;
+ }
+
+ resultsEl.innerHTML = 'Suche Verbindungen…
';
+
+ 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 = `⚠ ${err.message}
`;
+ }
+ });
+ }
+
+ 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 = 'Keine Verbindungen gefunden.
';
+ 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
+ ? ``
+ : '';
+ const infoBtn = !leg.walking && leg.tripId
+ ? ``
+ : '';
+ return `
+
+ ${lineLabel}
+ ${leg.origin || ''} → ${leg.destination || ''}
+ ${depTime} – ${arrTime}
+ ${infoBtn}
+ ${trackBtn}
+
+ `;
+ })
+ .join('');
+
+ card.innerHTML = `
+ ${legsHtml}
+
+
+ `;
+
+ 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);
\ No newline at end of file
diff --git a/js/config.js b/js/config.js
new file mode 100644
index 0000000..6ae8e88
--- /dev/null
+++ b/js/config.js
@@ -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',
+};
\ No newline at end of file
diff --git a/js/livemap.js b/js/livemap.js
new file mode 100644
index 0000000..5a0a841
--- /dev/null
+++ b/js/livemap.js
@@ -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: '© OpenStreetMap-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: `${label || '?'}`,
+ 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(
+ `${v.line || 'Linie unbekannt'}
${v.direction || ''}
` +
+ `Nächster Halt: ${v.nextStopover || '–'}`,
+ );
+ 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(`${v.line || ''}
${v.direction || ''}
Nächster Halt: ${v.nextStopover || '–'}`);
+ 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(`${data.line || ''}
→ ${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,
+ };
+})();
\ No newline at end of file
diff --git a/js/modefilter.js b/js/modefilter.js
new file mode 100644
index 0000000..2e504bf
--- /dev/null
+++ b/js/modefilter.js
@@ -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 };
+})();
\ No newline at end of file
diff --git a/js/splitflap.js b/js/splitflap.js
new file mode 100644
index 0000000..08cdef5
--- /dev/null
+++ b/js/splitflap.js
@@ -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 };
+})();
\ No newline at end of file
diff --git a/js/tripmodal.js b/js/tripmodal.js
new file mode 100644
index 0000000..c60151f
--- /dev/null
+++ b/js/tripmodal.js
@@ -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 = `
+
+ `;
+ 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 = `${formatTime(plannedTime) || '–'}`;
+ } else if (delay && delay > 0) {
+ timeHtml = `${formatTime(time)} (+${Math.round(delay / 60)})`;
+ } else {
+ timeHtml = `${formatTime(time) || '–'}`;
+ }
+
+ return `
+
+
+ ${s.name || ''}
+ ${timeHtml}
+
+
+ `;
+ }
+
+ 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 = 'Lade Fahrt-Details…
';
+
+ let trip;
+ try {
+ trip = await API.getTrip(tripId, { polyline: true });
+ } catch (err) {
+ document.getElementById('trip-modal-body').innerHTML = `⚠ ${err.message}
`;
+ 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: `${trip.line || '?'}`,
+ 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 = `
+
+ ${stopovers.map((s, i) => renderStopover(s, i === currentIndex)).join('')}
+
+ `;
+ document.getElementById('trip-modal-body').innerHTML = stopovers.length
+ ? bodyHtml
+ : 'Keine Zwischenhalte verfügbar.
';
+ }
+
+ return { open, close };
+})();
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..2ebdf46
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,9 @@
+{
+ "name": "ÖPNV Navi",
+ "short_name": "ÖPNV Navi",
+ "start_url": "./index.html",
+ "display": "standalone",
+ "background_color": "#0C1116",
+ "theme_color": "#0C1116",
+ "icons": []
+}
\ No newline at end of file
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..c2834c4
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,1253 @@
+/* ============================================================
+ ÖPNV NAVI — Design-Tokens
+ Thema: Bahnsteig nach Sonnenuntergang, Solari-Anzeigetafel
+ ============================================================ */
+
+:root {
+ --bg-void: #0C1116;
+ --panel: #171E26;
+ --panel-raised: #212A34;
+ --line: #2A323D;
+ --ink: #EDEFEC;
+ --ink-muted: #8E99A6;
+ --signal-amber: #F5A623;
+ --signal-green: #3DDC84;
+ --signal-red: #FF5D6C;
+
+ --font-display: 'IBM Plex Mono', 'SFMono-Regular', monospace;
+ --font-body: 'IBM Plex Sans', -apple-system, sans-serif;
+ --font-label: 'IBM Plex Sans Condensed', sans-serif;
+
+ --radius: 10px;
+ --header-height: 64px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg-void);
+ color: var(--ink);
+ font-family: var(--font-body);
+ min-height: 100vh;
+}
+
+button {
+ font-family: inherit;
+ cursor: pointer;
+}
+
+:focus-visible {
+ outline: 2px solid var(--signal-amber);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* ============================================================
+ Header & Suche
+ ============================================================ */
+
+.app-header {
+ height: var(--header-height);
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 0 20px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--line);
+ position: sticky;
+ top: 0;
+ z-index: 20;
+}
+
+.brand {
+ font-family: var(--font-display);
+ font-weight: 700;
+ font-size: 18px;
+ letter-spacing: 0.06em;
+ white-space: nowrap;
+}
+
+.brand__mark {
+ color: var(--ink);
+}
+
+.brand__mark--accent {
+ color: var(--signal-amber);
+}
+
+.search-box {
+ position: relative;
+ flex: 1;
+ max-width: 420px;
+}
+
+.search-box input {
+ width: 100%;
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ font-family: var(--font-body);
+ font-size: 15px;
+ padding: 10px 14px;
+ border-radius: var(--radius);
+}
+
+.search-box input::placeholder {
+ color: var(--ink-muted);
+}
+
+.search-results {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ list-style: none;
+ margin: 0;
+ padding: 4px;
+ max-height: 260px;
+ overflow-y: auto;
+ z-index: 30;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
+}
+
+.search-result {
+ padding: 10px 12px;
+ border-radius: 6px;
+ font-size: 14px;
+}
+
+.search-result:hover,
+.search-result:focus {
+ background: var(--panel);
+}
+
+.search-result--empty {
+ color: var(--ink-muted);
+ cursor: default;
+}
+
+/* ============================================================
+ Favoriten-Leiste
+ ============================================================ */
+
+.favorites-bar {
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ overflow-x: auto;
+}
+
+.favorite-chips {
+ display: flex;
+ gap: 8px;
+}
+
+.chip {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: 999px;
+ padding: 6px 14px;
+ font-size: 13px;
+ white-space: nowrap;
+}
+
+.chip:hover {
+ border-color: var(--signal-amber);
+}
+
+.favorites-empty-hint {
+ color: var(--ink-muted);
+ font-size: 13px;
+ margin: 0;
+}
+
+/* ============================================================
+ Tabs
+ ============================================================ */
+
+.tab-nav {
+ display: flex;
+ gap: 4px;
+ padding: 0 20px;
+ border-bottom: 1px solid var(--line);
+}
+
+.tab-button {
+ background: none;
+ border: none;
+ color: var(--ink-muted);
+ font-family: var(--font-label);
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 14px 16px;
+ border-bottom: 2px solid transparent;
+}
+
+.tab-button.is-active {
+ color: var(--signal-amber);
+ border-bottom-color: var(--signal-amber);
+}
+
+.app-main {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+/* ============================================================
+ Abfahrtstafel (Signature-Element)
+ ============================================================ */
+
+.board-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.board-station-name {
+ font-size: 18px;
+ font-weight: 600;
+ margin: 0;
+}
+
+.favorite-toggle {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: var(--radius);
+ padding: 8px 14px;
+ font-size: 13px;
+}
+
+.favorite-toggle.is-active {
+ color: var(--signal-amber);
+ border-color: var(--signal-amber);
+}
+
+.board-empty-state,
+.board-empty,
+.board-error {
+ color: var(--ink-muted);
+ padding: 32px 16px;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+ border: 1px solid var(--line);
+}
+
+.board-error {
+ color: var(--signal-red);
+}
+
+.departure-board {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.flap-row {
+ display: grid;
+ grid-template-columns: 82px minmax(0, 1fr) 82px 112px;
+ gap: 18px;
+ align-items: center;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ font-family: var(--font-display);
+}
+
+.flap-row--delayed .flap-cell--time {
+ color: var(--signal-red);
+}
+
+.flap-row--cancelled .flap-field--line,
+.flap-row--cancelled .flap-field--direction,
+.flap-row--cancelled .flap-field--platform {
+ opacity: 0.5;
+}
+
+.flap-row {
+ cursor: pointer;
+}
+
+.flap-row:hover,
+.flap-row:focus-visible {
+ border-color: var(--signal-amber);
+}
+
+.flap-field {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.flap-label {
+ display: block;
+ color: var(--ink-muted);
+ font-family: var(--font-label);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ line-height: 1;
+}
+
+.flap-cell {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.flap-cell--line {
+ width: max-content;
+ min-width: 38px;
+ font-weight: 700;
+ color: var(--line-text-color, var(--signal-amber));
+ background: var(--line-color, transparent);
+ padding: 5px 9px;
+ border-radius: 4px;
+ font-size: 16px;
+}
+
+.flap-cell--direction {
+ font-family: var(--font-body);
+ color: var(--ink);
+ font-size: 15px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.flap-cell--platform {
+ color: var(--ink);
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.flap-cell--time {
+ color: var(--signal-green);
+ font-weight: 700;
+ font-size: 17px;
+ text-align: right;
+}
+
+.flap-cell--time-cancelled {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ white-space: normal;
+}
+
+.cancelled-flag {
+ color: var(--signal-red);
+ font-weight: 700;
+ font-size: 15px;
+ letter-spacing: 0.02em;
+}
+
+.cancelled-original {
+ color: var(--signal-red);
+ font-size: 11px;
+ opacity: 0.85;
+ text-decoration: line-through;
+ font-family: var(--font-display);
+}
+
+.flap-field--line {
+ align-items: flex-start;
+ padding-right: 18px;
+ border-right: 1px solid var(--line);
+}
+
+.flap-field--direction {
+ padding-left: 2px;
+}
+
+.flap-field--time {
+ align-items: flex-end;
+}
+
+.flap-char {
+ display: inline-block;
+ white-space: pre;
+}
+
+.flap-char--flipping {
+ transform: translateY(-1px);
+}
+
+/* ============================================================
+ Live-Karte
+ ============================================================ */
+
+.live-map {
+ height: calc(100vh - var(--header-height) - 160px);
+ min-height: 400px;
+ border-radius: var(--radius);
+ overflow: hidden;
+ border: 1px solid var(--line);
+}
+
+.vehicle-marker span {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--signal-amber);
+ color: var(--bg-void);
+ font-family: var(--font-display);
+ font-weight: 700;
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ white-space: nowrap;
+ box-shadow: 0 0 0 2px var(--bg-void);
+}
+
+/* ============================================================
+ Routenplaner
+ ============================================================ */
+
+.journey-form {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.journey-form .search-box {
+ max-width: none;
+ flex: 1;
+ min-width: 160px;
+}
+
+.journey-form__arrow {
+ color: var(--ink-muted);
+ font-size: 18px;
+}
+
+.journey-submit {
+ background: var(--signal-amber);
+ color: var(--bg-void);
+ border: none;
+ border-radius: var(--radius);
+ padding: 10px 18px;
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.journey-card {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 14px;
+ margin-bottom: 10px;
+}
+
+.journey-leg {
+ display: grid;
+ grid-template-columns: 48px 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--line);
+ font-size: 13px;
+}
+
+.journey-leg:last-child {
+ border-bottom: none;
+}
+
+.journey-leg__line {
+ font-family: var(--font-display);
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 4px;
+ display: inline-block;
+}
+
+.journey-leg--walk .journey-leg__line {
+ color: var(--ink-muted);
+ font-family: var(--font-body);
+ font-size: 12px;
+}
+
+.journey-leg__route {
+ color: var(--ink);
+}
+
+.journey-leg__time {
+ font-family: var(--font-display);
+ color: var(--ink-muted);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+/* ============================================================
+ Verkehrsmittel-Filter (Mode Filter)
+ ============================================================ */
+
+.mode-filter {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+
+.mode-chip {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ color: var(--ink-muted);
+ border-radius: 999px;
+ padding: 5px 12px;
+ font-size: 12px;
+ font-family: var(--font-label);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+.mode-chip.is-active {
+ color: var(--signal-amber);
+ border-color: var(--signal-amber);
+ background: rgba(245, 166, 35, 0.1);
+}
+
+/* ============================================================
+ Standort-Buttons
+ ============================================================ */
+
+.location-btn {
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+.location-btn:hover {
+ border-color: var(--signal-amber);
+}
+
+.location-btn--wide {
+ width: 100%;
+ font-size: 14px;
+ margin-bottom: 16px;
+ font-family: var(--font-body);
+}
+
+/* ============================================================
+ In der Nähe
+ ============================================================ */
+
+.nearby-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.nearby-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 12px 14px;
+ color: var(--ink);
+ font-size: 14px;
+ text-align: left;
+ width: 100%;
+}
+
+.nearby-item:hover {
+ border-color: var(--signal-amber);
+}
+
+.nearby-item__distance {
+ color: var(--signal-amber);
+ font-family: var(--font-display);
+ font-size: 13px;
+ white-space: nowrap;
+}
+
+/* ============================================================
+ Störungen
+ ============================================================ */
+
+.remarks-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.remark-card {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-left: 3px solid var(--signal-red);
+ border-radius: var(--radius);
+ padding: 14px;
+}
+
+.remark-card__summary {
+ font-weight: 600;
+ color: var(--signal-red);
+ margin-bottom: 6px;
+ font-size: 14px;
+}
+
+.remark-card__text {
+ color: var(--ink);
+ font-size: 13px;
+ line-height: 1.5;
+ margin-bottom: 8px;
+}
+
+.remark-card__meta {
+ color: var(--ink-muted);
+ font-size: 12px;
+}
+
+/* ============================================================
+ Suchergebnis-Icons (Haltestelle/Adresse/POI)
+ ============================================================ */
+
+.search-result__icon {
+ display: inline-block;
+ width: 20px;
+}
+
+/* ============================================================
+ Live-Verfolgen-Button in Journey-Legs
+ ============================================================ */
+
+.leg-track-btn {
+ grid-column: 1 / -1;
+ justify-self: start;
+ margin-top: 4px;
+ background: none;
+ border: 1px solid var(--line);
+ color: var(--signal-amber);
+ border-radius: 6px;
+ padding: 4px 10px;
+ font-size: 11px;
+ font-family: var(--font-label);
+}
+
+.leg-track-btn:hover {
+ border-color: var(--signal-amber);
+}
+
+.journey-map-btn {
+ width: 100%;
+ margin-top: 8px;
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: 8px;
+ padding: 8px;
+ font-size: 12px;
+ font-family: var(--font-label);
+}
+
+.journey-map-btn:hover {
+ border-color: var(--signal-amber);
+ color: var(--signal-amber);
+}
+
+/* ============================================================
+ Routenplaner-Erweiterungen (Standort-Button, Ankunft/Abfahrt)
+ ============================================================ */
+
+.journey-form__row {
+ display: flex;
+ gap: 8px;
+ flex: 1;
+ min-width: 160px;
+}
+
+.journey-options {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 14px;
+ flex-wrap: wrap;
+}
+
+.journey-select,
+.journey-datetime {
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: var(--radius);
+ padding: 8px 10px;
+ font-size: 13px;
+ font-family: var(--font-body);
+}
+
+.journey-datetime {
+ flex: 1;
+ min-width: 160px;
+}
+
+@media (max-width: 560px) {
+ .app-header {
+ gap: 12px;
+ padding: 0 12px;
+ }
+
+ .brand__mark {
+ font-size: 15px;
+ }
+
+ .app-main {
+ padding: 14px;
+ }
+
+ .flap-row {
+ grid-template-columns: 44px 1fr 52px 76px;
+ gap: 8px;
+ padding: 10px;
+ }
+
+ .flap-cell--direction {
+ font-size: 12px;
+ }
+}
+/* ============================================================
+ UX REDESIGN — clearer hierarchy, fewer competing choices
+ ============================================================ */
+
+body { padding-bottom: 0; }
+button { transition: border-color .15s ease, background .15s ease, transform .15s ease; }
+button:active { transform: translateY(1px); }
+
+.app-header {
+ height: 72px;
+ padding: 0 max(20px, calc((100vw - 1120px) / 2));
+ gap: 28px;
+}
+.brand {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ font: inherit;
+}
+.search-box--global { max-width: 620px; }
+.search-box--global input { padding: 12px 42px 12px 42px; font-size: 15px; }
+.search-box__icon {
+ position: absolute;
+ left: 14px;
+ top: 50%;
+ transform: translateY(-52%);
+ color: var(--ink-muted);
+ font-size: 22px;
+ line-height: 1;
+ z-index: 1;
+}
+.search-clear {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ border: 0;
+ background: transparent;
+ color: var(--ink-muted);
+ font-size: 22px;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+}
+.search-clear:hover { background: var(--panel); color: var(--ink); }
+
+.favorites-bar { display: none; }
+.tab-nav {
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: 0 20px;
+ border-bottom: 1px solid var(--line);
+}
+.tab-button { padding: 14px 18px; display: inline-flex; gap: 8px; align-items: center; }
+.tab-button span { filter: grayscale(.35); }
+
+.app-main { max-width: 920px; padding: 30px 20px 50px; }
+
+.welcome-panel {
+ padding: 28px;
+ background: linear-gradient(135deg, var(--panel), var(--panel-raised));
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ margin-bottom: 24px;
+}
+.welcome-panel__eyebrow,
+.section-heading__eyebrow {
+ display: block;
+ color: var(--signal-amber);
+ font: 600 11px/1 var(--font-label);
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ margin-bottom: 8px;
+}
+.welcome-panel h1 { font-size: clamp(26px, 5vw, 38px); margin: 0 0 8px; letter-spacing: -.02em; }
+.welcome-panel p { margin: 0; max-width: 650px; color: var(--ink-muted); line-height: 1.55; }
+.welcome-actions { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; margin-top: 24px; }
+.action-card {
+ display: flex; align-items: center; gap: 14px; text-align: left;
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; padding: 16px;
+}
+.action-card:hover { border-color: var(--signal-amber); background: var(--panel-raised); }
+.action-card--primary { border-color: rgba(245,166,35,.45); }
+.action-card__icon { font-size: 23px; }
+.action-card strong, .action-card small { display: block; }
+.action-card small { color: var(--ink-muted); margin-top: 3px; font-size: 12px; }
+
+.home-section { margin: 0 0 24px; }
+.section-heading { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin-bottom: 10px; }
+.section-heading h2, .panel-heading h1 { margin: 0; }
+.section-heading h2 { font-size: 17px; }
+.text-button { background: none; border: 0; color: var(--signal-amber); padding: 5px 0; font-size: 12px; }
+.text-button:hover { text-decoration: underline; }
+.favorite-cards { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; }
+.favorite-card {
+ display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; text-align: left;
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; padding: 14px;
+}
+.favorite-card:hover { border-color: var(--signal-amber); }
+.favorite-card__star { color: var(--signal-amber); font-size: 18px; }
+.favorite-card strong, .favorite-card small { display: block; }
+.favorite-card small { color: var(--ink-muted); font-size: 11px; margin-top: 3px; }
+.favorite-card__arrow { color: var(--ink-muted); font-size: 18px; }
+.nearby-preview { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
+.nearby-preview__item {
+ display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left;
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 10px; padding: 12px;
+}
+.nearby-preview__item:hover { border-color: var(--signal-amber); }
+.nearby-preview__item strong, .nearby-preview__item small { display: block; }
+.nearby-preview__item small { color: var(--ink-muted); font-size: 11px; margin-top: 3px; }
+.nearby-preview__item b { color: var(--signal-amber); font: 700 12px var(--font-display); white-space: nowrap; }
+
+.board-header { margin-bottom: 8px; }
+.board-station-name { font-size: 24px; margin-top: 2px; }
+.board-status { color: var(--ink-muted); font-size: 11px; margin-bottom: 14px; display: flex; align-items: center; gap: 7px; }
+.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--signal-green); box-shadow: 0 0 0 3px rgba(61,220,132,.12); }
+.favorite-toggle { padding: 8px 12px; }
+
+.flap-row { grid-template-columns: 82px minmax(0,1fr) 82px 112px; padding: 14px 16px; }
+.flap-cell--time { font-size: 17px; }
+.flap-cell--direction { font-size: 15px; font-weight: 600; }
+.flap-cell--platform { font-size: 14px; }
+.station-disruption { background: rgba(255,93,108,.08); border: 1px solid rgba(255,93,108,.3); color: var(--ink); border-radius: 9px; padding: 10px 12px; margin-bottom: 12px; font-size: 12px; }
+
+.panel-heading { margin-bottom: 22px; }
+.panel-heading h1 { font-size: 26px; margin-bottom: 6px; }
+.panel-heading p { margin: 0; color: var(--ink-muted); font-size: 13px; }
+.live-map { height: min(680px, calc(100vh - 220px)); min-height: 460px; }
+
+.journey-card-form {
+ background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px;
+}
+.journey-input-row { display: flex; align-items: center; gap: 10px; }
+.journey-input-row .search-box { flex: 1; max-width: none; }
+.journey-input-row input { background: var(--panel-raised); }
+.journey-input-marker { width: 22px; text-align: center; font-size: 12px; }
+.journey-input-marker--start { color: var(--signal-green); }
+.journey-input-marker--end { color: var(--signal-red); }
+.journey-connector { height: 13px; border-left: 1px solid var(--line); margin-left: 10px; }
+.journey-when { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
+.when-choice { background: var(--panel); color: var(--ink-muted); border: 1px solid var(--line); border-radius: 999px; padding: 7px 11px; font-size: 12px; }
+.when-choice.is-active { color: var(--signal-amber); border-color: var(--signal-amber); background: rgba(245,166,35,.08); }
+.journey-options-row { display: flex; align-items: end; justify-content: space-between; gap: 12px; }
+.journey-options-row .mode-filter { margin-bottom: 0; }
+.journey-submit { min-height: 38px; padding: 9px 15px; }
+.journey-submit span { margin-left: 6px; }
+.journey-card { padding: 16px; }
+.journey-leg { grid-template-columns: 52px minmax(0,1fr) auto; }
+
+.location-btn--wide { min-height: 46px; }
+.more-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; }
+.more-card { display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; text-align: left; background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 11px; padding: 16px; }
+.more-card > span { grid-row: span 2; font-size: 23px; }
+.more-card small { color: var(--ink-muted); font-size: 12px; }
+.more-card:hover { border-color: var(--signal-amber); }
+.recent-section { margin-top: 28px; }
+.recent-searches { display: flex; flex-wrap: wrap; gap: 8px; }
+.recent-item { display: inline-flex; align-items: center; gap: 7px; background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 999px; padding: 8px 12px; font-size: 12px; }
+.recent-item:hover { border-color: var(--signal-amber); }
+.recent-item span { color: var(--ink-muted); }
+.recent-empty { color: var(--ink-muted); font-size: 12px; padding: 8px 0; }
+
+.mobile-nav { display: none; }
+
+/* ============================================================
+ Trip-Detail-Modal (Klick auf eine Abfahrt/Verbindung)
+ ============================================================ */
+
+.trip-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(6, 9, 12, 0.72);
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ z-index: 200;
+ animation: trip-modal-fade 0.15s ease;
+}
+
+.trip-modal-backdrop[hidden] {
+ display: none;
+}
+
+@keyframes trip-modal-fade {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.trip-modal {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 16px 16px 0 0;
+ width: 100%;
+ max-width: 560px;
+ max-height: 88vh;
+ overflow-y: auto;
+ padding: 0 0 20px;
+}
+
+@media (min-width: 701px) {
+ .trip-modal-backdrop { align-items: center; }
+ .trip-modal { border-radius: 16px; max-height: 80vh; }
+}
+
+.trip-modal__header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 18px 18px 14px;
+ border-bottom: 1px solid var(--line);
+ position: sticky;
+ top: 0;
+ background: var(--panel);
+ z-index: 1;
+}
+
+.trip-modal__line {
+ font-family: var(--font-display);
+ font-weight: 700;
+ font-size: 16px;
+ padding: 5px 10px;
+ border-radius: 6px;
+ flex-shrink: 0;
+}
+
+.trip-modal__title {
+ flex: 1;
+ min-width: 0;
+}
+
+.trip-modal__title strong {
+ display: block;
+ font-size: 15px;
+}
+
+.trip-modal__title small {
+ color: var(--ink-muted);
+ font-size: 12px;
+}
+
+.trip-modal__close {
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ font-size: 18px;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.trip-modal__close:hover {
+ border-color: var(--signal-amber);
+}
+
+.trip-modal__map {
+ height: 220px;
+ border-bottom: 1px solid var(--line);
+}
+
+.trip-modal__body {
+ padding: 14px 18px 0;
+}
+
+.trip-modal__loading,
+.trip-modal__error {
+ padding: 40px 18px;
+ text-align: center;
+ color: var(--ink-muted);
+}
+
+.trip-modal__error {
+ color: var(--signal-red);
+}
+
+.trip-stopovers {
+ position: relative;
+ padding-left: 18px;
+}
+
+.trip-stopover {
+ position: relative;
+ padding: 9px 0;
+ border-left: 2px solid var(--line);
+ padding-left: 16px;
+ margin-left: -1px;
+}
+
+.trip-stopover::before {
+ content: '';
+ position: absolute;
+ left: -6px;
+ top: 14px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--panel);
+ border: 2px solid var(--ink-muted);
+}
+
+.trip-stopover--current::before {
+ background: var(--signal-amber);
+ border-color: var(--signal-amber);
+ box-shadow: 0 0 0 4px rgba(245, 166, 35, 0.2);
+}
+
+.trip-stopover--cancelled {
+ opacity: 0.55;
+}
+
+.trip-stopover__row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 10px;
+}
+
+.trip-stopover__name {
+ font-size: 13px;
+ color: var(--ink);
+}
+
+.trip-stopover__time {
+ font-family: var(--font-display);
+ font-size: 12px;
+ color: var(--ink-muted);
+ white-space: nowrap;
+}
+
+.trip-stopover__time--delayed {
+ color: var(--signal-red);
+}
+
+.trip-stopover__time--cancelled {
+ color: var(--signal-red);
+ text-decoration: line-through;
+}
+
+/* ============================================================
+ Live-Karten-Steuerung (Filter, Standort, Isolation)
+ ============================================================ */
+
+.map-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.map-controls .mode-filter {
+ margin-bottom: 0;
+ flex: 1;
+}
+
+.map-locate-btn {
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: var(--radius);
+ padding: 8px 14px;
+ font-size: 13px;
+ white-space: nowrap;
+}
+
+.map-locate-btn:hover {
+ border-color: var(--signal-amber);
+}
+
+.map-isolation-banner {
+ display: none;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ background: var(--panel-raised);
+ border: 1px solid var(--signal-amber);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ margin-bottom: 10px;
+ font-size: 13px;
+}
+
+.map-isolation-banner.is-visible {
+ display: flex;
+}
+
+.map-isolation-banner__line {
+ font-family: var(--font-display);
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: 4px;
+}
+
+.map-isolation-banner button {
+ background: none;
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: 6px;
+ padding: 5px 10px;
+ font-size: 12px;
+}
+
+.map-isolation-banner button:hover {
+ border-color: var(--signal-amber);
+}
+
+.user-location-marker {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #4A9EFF;
+ border: 3px solid #fff;
+ box-shadow: 0 0 0 4px rgba(74, 158, 255, 0.35);
+}
+
+/* ============================================================
+ Journey-Mini-Karte (Streckenverlauf inline im Kartenergebnis)
+ ============================================================ */
+
+.journey-map-toggle {
+ width: 100%;
+ margin-top: 8px;
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ border-radius: 8px;
+ padding: 8px;
+ font-size: 12px;
+ font-family: var(--font-label);
+}
+
+.journey-map-toggle:hover {
+ border-color: var(--signal-amber);
+ color: var(--signal-amber);
+}
+
+.journey-mini-map {
+ height: 0;
+ overflow: hidden;
+ border-radius: 8px;
+ margin-top: 0;
+ transition: height 0.2s ease;
+}
+
+.journey-mini-map.is-open {
+ height: 160px;
+ margin-top: 8px;
+ border: 1px solid var(--line);
+}
+
+/* ============================================================
+ Ladezustände / Fehler allgemein
+ ============================================================ */
+
+.spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255, 255, 255, 0.25);
+ border-top-color: var(--signal-amber);
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+ vertical-align: -2px;
+ margin-right: 8px;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.board-error-detail {
+ color: var(--ink-muted);
+ font-size: 12px;
+ margin-top: 6px;
+}
+
+@media (max-width: 700px) {
+ body { padding-bottom: 66px; }
+ .app-header { height: auto; min-height: 64px; padding: 10px 12px; gap: 12px; }
+ .brand { font-size: 15px; }
+ .search-box--global { min-width: 0; }
+ .search-box--global input { font-size: 14px; padding: 10px 38px; }
+ .tab-nav { display: none; }
+ .app-main { padding: 16px 12px 28px; }
+ .welcome-panel { padding: 20px 16px; }
+ .welcome-actions { grid-template-columns: 1fr; }
+ .favorite-cards, .nearby-preview, .more-grid { grid-template-columns: 1fr; }
+ .board-header { align-items: flex-start; }
+ .board-station-name { font-size: 20px; }
+ .flap-row { grid-template-columns: 64px minmax(0,1fr) 58px 82px; gap: 14px; padding: 11px 10px; }
+ .flap-label { font-size: 9px; }
+ .flap-cell--direction { font-size: 12px; }
+ .flap-cell--platform { font-size: 12px; }
+ .flap-cell--time { font-size: 14px; }
+ .journey-options-row { display: block; }
+ .journey-options-row .journey-submit { width: 100%; margin-top: 8px; }
+ .journey-options-row .mode-filter { margin-bottom: 8px; }
+ .live-map { min-height: 62vh; height: 62vh; }
+ .mobile-nav {
+ display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; bottom: 0; left: 0; right: 0;
+ z-index: 50; background: rgba(23,30,38,.97); border-top: 1px solid var(--line); padding: 5px 4px calc(5px + env(safe-area-inset-bottom));
+ }
+ .mobile-nav__item { border: 0; background: transparent; color: var(--ink-muted); padding: 5px 2px; display: flex; flex-direction: column; align-items: center; gap: 2px; }
+ .mobile-nav__item span { font-size: 18px; line-height: 1.1; }
+ .mobile-nav__item small { font-size: 10px; }
+ .mobile-nav__item.is-active { color: var(--signal-amber); }
+}
+
+@media (max-width: 430px) {
+ .flap-row { grid-template-columns: 58px minmax(0,1fr) 72px; gap: 12px; }
+ .flap-field--platform { display: none; }
+ .flap-field--time { grid-column: 3; }
+}
\ No newline at end of file