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.

+
+ + +
+
+ + + + + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + \ 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 = ` +
    ⚠ ${r.summary || 'Hinweis'}
    +
    ${r.text || ''}
    +
    ${r.company || ''}
    + `; + 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